378 lines
15 KiB
Python
378 lines
15 KiB
Python
"""FlowDeck — versioned schema migrations (lightweight, no Alembic).
|
|
|
|
This replaces the previous "ad-hoc" approach where every new schema change was
|
|
appended directly to `app/db.py::init_db()` with no tracking. A `schema_version`
|
|
table now records the highest applied migration; the full baseline schema
|
|
(created idempotently by `init_db`) is treated as version 1, and any incremental
|
|
change is expressed as an ordered, versioned step below and applied exactly once.
|
|
|
|
Each migration function receives a raw ``sqlite3.Connection`` (WAL + foreign keys
|
|
already enabled) and must be written idempotently (``IF NOT EXISTS`` / guarded
|
|
``ALTER TABLE``) so it is safe even if partially re-run.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import sqlite3
|
|
from typing import Callable
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# The full baseline schema created by `app.db::init_db()` is "version 1".
|
|
BASELINE_VERSION = 1
|
|
|
|
# (version, name, apply_fn). Kept sorted by version at registration time.
|
|
MIGRATIONS: list[tuple[int, str, Callable[[sqlite3.Connection], None]]] = []
|
|
|
|
|
|
def register(version: int, name: str) -> Callable:
|
|
"""Decorator registering a migration in the ordered registry."""
|
|
if any(v == version for v, _, _ in MIGRATIONS):
|
|
raise ValueError(f"Duplicate migration version {version}")
|
|
|
|
def decorator(fn: Callable[[sqlite3.Connection], None]):
|
|
MIGRATIONS.append((version, name, fn))
|
|
MIGRATIONS.sort(key=lambda item: item[0])
|
|
return fn
|
|
|
|
return decorator
|
|
|
|
|
|
def _ensure_table(conn: sqlite3.Connection) -> None:
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS schema_version (
|
|
version INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
"""
|
|
)
|
|
|
|
|
|
def current_version(conn: sqlite3.Connection) -> int:
|
|
_ensure_table(conn)
|
|
row = conn.execute(
|
|
"SELECT COALESCE(MAX(version), 0) AS v FROM schema_version"
|
|
).fetchone()
|
|
return int(row[0])
|
|
|
|
|
|
def fts5_available() -> bool:
|
|
"""True when the bundled SQLite ships the FTS5 extension."""
|
|
probe = sqlite3.connect(":memory:")
|
|
try:
|
|
probe.execute("CREATE VIRTUAL TABLE _fts5_probe USING fts5(x)")
|
|
return True
|
|
except sqlite3.OperationalError:
|
|
return False
|
|
finally:
|
|
probe.close()
|
|
|
|
|
|
def apply_migrations(conn: sqlite3.Connection) -> int:
|
|
"""Seal the baseline schema (version 1) and apply pending migrations.
|
|
|
|
Returns the resulting schema version.
|
|
"""
|
|
_ensure_table(conn)
|
|
applied = current_version(conn)
|
|
|
|
if applied < BASELINE_VERSION:
|
|
# The pre-existing schema (already created by init_db) is our baseline.
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO schema_version (version, name) VALUES (?, ?)",
|
|
(BASELINE_VERSION, "baseline"),
|
|
)
|
|
conn.commit()
|
|
applied = BASELINE_VERSION
|
|
|
|
for version, name, fn in MIGRATIONS:
|
|
if version <= applied:
|
|
continue
|
|
fn(conn)
|
|
conn.execute(
|
|
"INSERT INTO schema_version (version, name) VALUES (?, ?)",
|
|
(version, name),
|
|
)
|
|
conn.commit()
|
|
applied = version
|
|
logger.info("Applied migration %d: %s", version, name)
|
|
|
|
return applied
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Migrations
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
@register(2, "missing indexes")
|
|
def _migration_missing_indexes(conn: sqlite3.Connection) -> None:
|
|
"""Add the indexes flagged in the roadmap (fast lookups by email, forge user)."""
|
|
for ddl in (
|
|
"CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)",
|
|
"CREATE INDEX IF NOT EXISTS idx_user_oauth_tokens_user ON user_oauth_tokens(user_id, provider)",
|
|
"CREATE INDEX IF NOT EXISTS idx_collections_workspace ON collections(workspace_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_pages_workspace ON pages(workspace_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_pages_deleted ON pages(deleted_at)",
|
|
):
|
|
conn.execute(ddl)
|
|
|
|
|
|
@register(3, "full-text search (FTS5)")
|
|
def _migration_fts5(conn: sqlite3.Connection) -> None:
|
|
"""Create a full-text index over pages (title + content) for the command palette.
|
|
|
|
Kept in sync via row-level triggers on the ``pages`` table so page
|
|
insert/update/delete are reflected immediately. Skips gracefully if the
|
|
bundled SQLite lacks FTS5 (search then falls back to LIKE).
|
|
"""
|
|
if not fts5_available():
|
|
logger.warning("FTS5 unavailable — skipping full-text index (LIKE fallback active)")
|
|
return
|
|
|
|
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(title, body)")
|
|
|
|
conn.execute(
|
|
"""
|
|
CREATE TRIGGER IF NOT EXISTS pages_fts_ai AFTER INSERT ON pages BEGIN
|
|
INSERT INTO pages_fts(rowid, title, body)
|
|
VALUES (new.id, COALESCE(new.title, ''), COALESCE(new.content, ''));
|
|
END
|
|
"""
|
|
)
|
|
# `pages_fts` is a standalone FTS5 table (it stores its own content), so deletes
|
|
# use a plain DELETE by rowid (NOT the special 'delete' insert that only applies
|
|
# to external-content/contentless FTS5 tables).
|
|
conn.execute(
|
|
"""
|
|
CREATE TRIGGER IF NOT EXISTS pages_fts_ad AFTER DELETE ON pages BEGIN
|
|
DELETE FROM pages_fts WHERE rowid = old.id;
|
|
END
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"""
|
|
CREATE TRIGGER IF NOT EXISTS pages_fts_au AFTER UPDATE ON pages BEGIN
|
|
DELETE FROM pages_fts WHERE rowid = old.id;
|
|
INSERT INTO pages_fts(rowid, title, body)
|
|
VALUES (new.id, COALESCE(new.title, ''), COALESCE(new.content, ''));
|
|
END
|
|
"""
|
|
)
|
|
|
|
# Backfill the index from any rows that already exist.
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO pages_fts(rowid, title, body)
|
|
SELECT id, COALESCE(title, ''), COALESCE(content, '') FROM pages
|
|
WHERE deleted_at IS NULL
|
|
"""
|
|
)
|
|
|
|
|
|
@register(5, "automations (v5.1.0 rules engine)")
|
|
def _migration_automations(conn: sqlite3.Connection) -> None:
|
|
"""v5.1.0: database automations — if-this-then-that rule engine (trigger +
|
|
condition + action) and clickable buttons that trigger actions.
|
|
|
|
``automations`` — the rules (event/cron/button trigger, optional
|
|
condition JSON, actions JSON, run counters).
|
|
``automation_runs`` — execution history for auditing and the Settings UI.
|
|
"""
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS automations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
workspace TEXT NOT NULL DEFAULT '',
|
|
name TEXT NOT NULL,
|
|
trigger_type TEXT NOT NULL DEFAULT 'event', -- event | cron | button
|
|
event TEXT NOT NULL DEFAULT 'page.created', -- for trigger_type='event'
|
|
cron_expression TEXT NOT NULL DEFAULT '', -- for trigger_type='cron'
|
|
collection_id INTEGER, -- optional scope (event triggers)
|
|
condition_json TEXT NOT NULL DEFAULT '[]', -- list of condition clauses
|
|
actions_json TEXT NOT NULL DEFAULT '[]', -- list of action descriptors
|
|
enabled BOOLEAN NOT NULL DEFAULT 1,
|
|
created_by INTEGER,
|
|
last_run_at TIMESTAMP,
|
|
run_count INTEGER NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_automations_trigger ON automations(trigger_type, event, enabled)"
|
|
)
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS automation_runs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
|
|
trigger_source TEXT NOT NULL DEFAULT 'event',
|
|
status TEXT NOT NULL DEFAULT 'fired', -- fired | skipped | error
|
|
detail TEXT NOT NULL DEFAULT '',
|
|
collection_id INTEGER,
|
|
page_id INTEGER,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_automation_runs_auto ON automation_runs(automation_id, created_at)"
|
|
)
|
|
|
|
|
|
@register(6, "v5.2.0: api tokens, user sessions, projects")
|
|
def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
|
|
"""v5.2.0 (Security & Forge): per-user API tokens, revocable sessions and
|
|
the forge-agnostic ``projects`` table.
|
|
|
|
``api_tokens`` — per-user bearer tokens (sha256-stored), revocable,
|
|
powering the public API (/api/v1) and Settings UI.
|
|
``user_sessions`` — one row per signed session cookie; revocation here
|
|
instantly kills the corresponding cookie.
|
|
``projects`` — normalized project list across forges (builtin/gitea/
|
|
github) + last sync timestamp for the periodic cron.
|
|
"""
|
|
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
|
|
if "id" not in _pcols:
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE api_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL DEFAULT 'API token',
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
token_prefix TEXT NOT NULL DEFAULT '',
|
|
last_used_at TIMESTAMP,
|
|
revoked INTEGER NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id, revoked)"
|
|
)
|
|
|
|
_scols = {r[1] for r in conn.execute("PRAGMA table_info(user_sessions)").fetchall()}
|
|
if "id" not in _scols:
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE user_sessions (
|
|
id TEXT PRIMARY KEY, -- session id (cookie payload)
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
ip_address TEXT DEFAULT '',
|
|
user_agent TEXT DEFAULT '',
|
|
last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
revoked INTEGER NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id, revoked)"
|
|
)
|
|
|
|
_projcols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
|
|
if "id" not in _projcols:
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE projects (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
proj_type TEXT NOT NULL DEFAULT 'builtin', -- builtin | gitea | github
|
|
owner TEXT NOT NULL DEFAULT '',
|
|
forge_id TEXT DEFAULT '',
|
|
clone_url TEXT DEFAULT '',
|
|
default_branch TEXT DEFAULT '',
|
|
language TEXT DEFAULT '',
|
|
description TEXT DEFAULT '',
|
|
last_synced_at TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(proj_type, owner, name)
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_projects_type ON projects(proj_type, last_synced_at)"
|
|
)
|
|
|
|
|
|
@register(7, "v5.4.0/v5.5.0: page versions, cover + icon")
|
|
def _migration_v54_page_versions_cover(conn: sqlite3.Connection) -> None:
|
|
"""v5.4.0 (version history + page duplication) & v5.5.0 (cover & icon).
|
|
|
|
``page_versions`` — undoable version snapshots for block-editor pages
|
|
(NOT tied to ``collection_pages`` like the legacy
|
|
``page_history`` table). One row per save with the
|
|
full block list + title so the UI can browse/restore.
|
|
``pages.cover_url`` — image cover shown above the page title.
|
|
``pages.page_icon`` — emoji / icon label shown next to the title.
|
|
"""
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS page_versions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
|
user_id INTEGER REFERENCES users(id),
|
|
title TEXT NOT NULL DEFAULT '',
|
|
blocks_json TEXT NOT NULL DEFAULT '[]',
|
|
note TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_page_versions_page ON page_versions(page_id, created_at)"
|
|
)
|
|
|
|
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
|
if "cover_url" not in _pcols:
|
|
conn.execute("ALTER TABLE pages ADD COLUMN cover_url TEXT DEFAULT ''")
|
|
if "page_icon" not in _pcols:
|
|
conn.execute("ALTER TABLE pages ADD COLUMN page_icon TEXT DEFAULT ''")
|
|
|
|
|
|
@register(8, "v5.6.0: custom workspace emojis")
|
|
def _migration_custom_emojis(conn: sqlite3.Connection) -> None:
|
|
"""Workspace-wide custom emojis (uploaded images) used as page icons."""
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS custom_emojis (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
workspace_id INTEGER NOT NULL DEFAULT 1,
|
|
name TEXT NOT NULL DEFAULT '',
|
|
url TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_custom_emojis_ws ON custom_emojis(workspace_id, created_at)"
|
|
)
|
|
|
|
|
|
@register(4, "database templates (icon) + property validation")
|
|
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
|
|
"""v5.3.0: database templates get an icon, properties a validation config,
|
|
and the built-in database templates are seeded (idempotently)."""
|
|
_cols = {r[1] for r in conn.execute("PRAGMA table_info(database_templates)").fetchall()}
|
|
if "icon" not in _cols:
|
|
conn.execute("ALTER TABLE database_templates ADD COLUMN icon TEXT NOT NULL DEFAULT '📋'")
|
|
|
|
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
|
|
if "validation_json" not in _pcols:
|
|
conn.execute("ALTER TABLE collection_properties ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'")
|
|
|
|
# Seed built-in templates (idempotent: only missing names are inserted).
|
|
from app.services.db_templates import SEED_TEMPLATES
|
|
for tpl in SEED_TEMPLATES:
|
|
conn.execute(
|
|
"""INSERT OR IGNORE INTO database_templates (name, icon, description, schema_json)
|
|
VALUES (?, ?, ?, ?)""",
|
|
(tpl["name"], tpl.get("icon", "📋"), tpl.get("description", ""),
|
|
__import__("json").dumps(tpl.get("schema", []))),
|
|
)
|