fix: A31 — transaction par migration + helper columns() (v7.6.0)
- `_apply_one()` : BEGIN explicite → `fn(conn)` → marque `schema_version` →
commit ; rollback complet à l'échec. Avant le DDL sortait en autocommit
(isolation_level legacy) : un échec au milieu laissait un schéma partiel
commité SANS ligne de version, et la reprise rejouait un DDL déjà appliqué.
Si une transaction englobante subsiste (init_db commit juste avant), on la
vide d'abord plutôt que de l'englober.
- Helper unique `columns(conn, table)` (valide l'identifiant, ValueError sinon)
: 25 copies de `{r[1] for r in conn.execute("PRAGMA table_info(...)")}`
éliminées dans migrations.py (21 littéraux + 3 f-string + 1 variante row).
`table_exists`/`column_exists` préconisés par l'audit NON livrés : aucune
migration n'interroge sqlite_master, un contrôle unitaire se lit dans le set.
- Smoke : DB fraîche → 28 migrations → version 29, ré-apply idempotent.
tests : test_migration_transaction_rolls_back (DDL partiel annulé + zéro marque
de version), test_columns_helper_validates_table_name
suite **1036/1036** · `ruff check app tests` OK · OpenAPI 511 chemins / 7.6.0
docs (ROADMAP/CHANGELOG/WORKLOAD/VERSION) à jour
This commit is contained in:
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.5.0",
|
||||
version="7.6.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
+61
-28
@@ -50,6 +50,19 @@ def _ensure_table(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
def columns(conn: sqlite3.Connection, table: str) -> set[str]:
|
||||
"""Colonnes d'une table — A31 : l'unique helper qui remplace les 24 copies
|
||||
de `{r[1] for r in conn.execute("PRAGMA table_info(...)")}`.
|
||||
|
||||
``table_exists``/``column_exists`` (préconisés par l'audit) ne sont pas
|
||||
livrés : aucune migration n'interroge ``sqlite_master``, et un contrôle
|
||||
unitaire se lit déjà dans le set.
|
||||
"""
|
||||
if not table.replace("_", "").isalnum():
|
||||
raise ValueError(f"nom de table invalide: {table!r}")
|
||||
return {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
|
||||
|
||||
def current_version(conn: sqlite3.Connection) -> int:
|
||||
_ensure_table(conn)
|
||||
row = conn.execute(
|
||||
@@ -90,16 +103,36 @@ def apply_migrations(conn: sqlite3.Connection) -> int:
|
||||
for version, name, fn in MIGRATIONS:
|
||||
if version <= applied:
|
||||
continue
|
||||
_apply_one(conn, version, name, fn)
|
||||
applied = version
|
||||
logger.info("Applied migration %d: %s", version, name)
|
||||
|
||||
return applied
|
||||
|
||||
|
||||
def _apply_one(conn: sqlite3.Connection, version: int, name: str, fn: Callable) -> None:
|
||||
"""A31 : une migration = une transaction (DDL tout-ou-rien).
|
||||
|
||||
Avant : le DDL sortait en autocommit (isolation_level legacy) — un échec au
|
||||
milieu laissait un schéma partiel commité ET pas de ligne schema_version :
|
||||
la reprise rejouait un DDL déjà appliqué. Maintenant : BEGIN explicite,
|
||||
rollback complet à l'échec, donc la prochaine exécution retente proprement.
|
||||
"""
|
||||
if conn.in_transaction:
|
||||
# transaction résiduelle du caller (init_db commit juste avant) — on
|
||||
# part d'un état propre plutôt que d'englober son travail.
|
||||
conn.commit()
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
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
|
||||
except BaseException:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -236,7 +269,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
|
||||
``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()}
|
||||
_pcols = columns(conn, "api_tokens")
|
||||
if "id" not in _pcols:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -256,7 +289,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
|
||||
"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()}
|
||||
_scols = columns(conn, "user_sessions")
|
||||
if "id" not in _scols:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -275,7 +308,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
|
||||
"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()}
|
||||
_projcols = columns(conn, "projects")
|
||||
if "id" not in _projcols:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -328,7 +361,7 @@ def _migration_v54_page_versions_cover(conn: sqlite3.Connection) -> None:
|
||||
"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()}
|
||||
_pcols = columns(conn, "pages")
|
||||
if "cover_url" not in _pcols:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN cover_url TEXT DEFAULT ''")
|
||||
if "page_icon" not in _pcols:
|
||||
@@ -358,11 +391,11 @@ def _migration_custom_emojis(conn: sqlite3.Connection) -> None:
|
||||
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()}
|
||||
_cols = columns(conn, "database_templates")
|
||||
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()}
|
||||
_pcols = columns(conn, "collection_properties")
|
||||
if "validation_json" not in _pcols:
|
||||
conn.execute("ALTER TABLE collection_properties ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'")
|
||||
|
||||
@@ -432,19 +465,19 @@ def _migration_v57_db_advanced(conn: sqlite3.Connection) -> None:
|
||||
``collection_pages.cover_url`` — per-row cover image (gallery/board
|
||||
cards), independent from the block-page ``pages.cover_url``.
|
||||
"""
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
|
||||
_pcols = columns(conn, "collection_properties")
|
||||
if "group_name" not in _pcols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collection_properties ADD COLUMN group_name TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
|
||||
_vcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_views)").fetchall()}
|
||||
_vcols = columns(conn, "collection_views")
|
||||
if "created_by" not in _vcols:
|
||||
conn.execute("ALTER TABLE collection_views ADD COLUMN created_by INTEGER")
|
||||
if "updated_at" not in _vcols:
|
||||
conn.execute("ALTER TABLE collection_views ADD COLUMN updated_at TIMESTAMP")
|
||||
|
||||
_cpcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
|
||||
_cpcols = columns(conn, "collection_pages")
|
||||
if "cover_url" not in _cpcols:
|
||||
conn.execute("ALTER TABLE collection_pages ADD COLUMN cover_url TEXT DEFAULT ''")
|
||||
|
||||
@@ -471,7 +504,7 @@ def _migration_v58_calendar_reminders(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_remlog_page ON reminder_log(page_id)"
|
||||
)
|
||||
|
||||
_ucols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
|
||||
_ucols = columns(conn, "users")
|
||||
if "timezone" not in _ucols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT ''")
|
||||
|
||||
@@ -522,7 +555,7 @@ def _migration_v511_wiki_v512_templates(conn: sqlite3.Connection) -> None:
|
||||
``page_global_templates`` — user-created global page templates
|
||||
(blocks_json = same format as the block editor saves).
|
||||
"""
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
_pcols = columns(conn, "pages")
|
||||
if "is_locked" not in _pcols:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0")
|
||||
if "locked_by" not in _pcols:
|
||||
@@ -777,12 +810,12 @@ def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
for table in ("pages", "collection_pages"):
|
||||
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
cols = columns(conn, table)
|
||||
if "permission_type" not in cols:
|
||||
conn.execute(
|
||||
f"ALTER TABLE {table} ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
|
||||
)
|
||||
_ccols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
|
||||
_ccols = columns(conn, "collections")
|
||||
if "permission_type" not in _ccols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collections ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
|
||||
@@ -791,7 +824,7 @@ def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
|
||||
|
||||
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
|
||||
"""Add ``sync_version`` to ``table`` if it is not already present."""
|
||||
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
cols = columns(conn, table)
|
||||
if "sync_version" not in cols:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1")
|
||||
|
||||
@@ -853,7 +886,7 @@ def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
|
||||
``idempotency_keys`` — Idempotency-Key support for POST creations.
|
||||
"""
|
||||
# api_tokens extra columns
|
||||
_cols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
|
||||
_cols = columns(conn, "api_tokens")
|
||||
if "scopes" not in _cols:
|
||||
conn.execute("ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT 'read,write'")
|
||||
if "expires_at" not in _cols:
|
||||
@@ -913,7 +946,7 @@ def _migration_v640_webhooks_prod(conn: sqlite3.Connection) -> None:
|
||||
New statuses: ``retrying`` (a later attempt is scheduled) and
|
||||
``superseded`` (a retry row replaced this attempt).
|
||||
"""
|
||||
_cols = {r[1] for r in conn.execute("PRAGMA table_info(webhook_deliveries)").fetchall()}
|
||||
_cols = columns(conn, "webhook_deliveries")
|
||||
if "event" not in _cols:
|
||||
conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN event TEXT NOT NULL DEFAULT ''")
|
||||
if "next_retry_at" not in _cols:
|
||||
@@ -973,7 +1006,7 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
|
||||
``ON DELETE CASCADE``: deleting a database row deletes its content
|
||||
page (and ``page_synced_blocks`` cascades from ``pages``).
|
||||
"""
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
cols = columns(conn, "pages")
|
||||
if "collection_row_id" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE pages ADD COLUMN collection_row_id INTEGER "
|
||||
@@ -1053,7 +1086,7 @@ def _migration_sites_forms(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_form_responses_col ON form_responses(collection_id, created_at)"
|
||||
)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
|
||||
cols = columns(conn, "collections")
|
||||
if "form_config_json" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collections ADD COLUMN form_config_json TEXT NOT NULL DEFAULT '{}'"
|
||||
@@ -1100,7 +1133,7 @@ def _migration_semantic_search(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
"""
|
||||
)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
cols = columns(conn, "pages")
|
||||
if "search_excluded" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE pages ADD COLUMN search_excluded INTEGER NOT NULL DEFAULT 0"
|
||||
@@ -1169,12 +1202,12 @@ def _migration_automations_v2_workers(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_worker_runs_worker "
|
||||
"ON worker_runs(worker_id, created_at)"
|
||||
)
|
||||
auto_cols = {r[1] for r in conn.execute("PRAGMA table_info(automations)").fetchall()}
|
||||
auto_cols = columns(conn, "automations")
|
||||
if "trigger_mode" not in auto_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE automations ADD COLUMN trigger_mode TEXT NOT NULL DEFAULT 'any'"
|
||||
)
|
||||
prop_cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
|
||||
prop_cols = columns(conn, "collection_properties")
|
||||
if "button_automation_id" not in prop_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collection_properties ADD COLUMN button_automation_id "
|
||||
@@ -1226,7 +1259,7 @@ def _migration_calendar_meetings(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_meeting_transcripts_page "
|
||||
"ON meeting_transcripts(page_id)"
|
||||
)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
|
||||
cols = columns(conn, "collection_pages")
|
||||
if "external_event_id" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collection_pages ADD COLUMN external_event_id TEXT DEFAULT ''"
|
||||
@@ -1322,7 +1355,7 @@ def _migration_enterprise_admin(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_agent_approvals_status "
|
||||
"ON agent_approvals(status, created_at)"
|
||||
)
|
||||
user_cols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
|
||||
user_cols = columns(conn, "users")
|
||||
if "totp_secret_enc" not in user_cols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN totp_secret_enc TEXT DEFAULT ''")
|
||||
if "totp_backup_hashes" not in user_cols:
|
||||
@@ -1433,7 +1466,7 @@ def _migration_wiki_teamspaces(conn: sqlite3.Connection) -> None:
|
||||
"""
|
||||
)
|
||||
for table in ("pages", "collections"):
|
||||
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
cols = columns(conn, table)
|
||||
if "teamspace_id" not in cols:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN teamspace_id INTEGER")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user