Implements spec §4 Flow 4: bulk-at-boot is NOT a parallel forward-chain implementation; it's eager iteration of lazy_migrate_row over every stale row. Same original-first logic, same re-projection, zero divergence. Eliminates the transient-wrong window where bulk could write fabricated defaults over recoverable data in original_payload_json. run_bulk_lazy returns the failure count; caller surfaces a dashboard banner when > 0. STALE_PREDICATE catches both 'sv < current' AND 'tolerantly-installed row whose original now outranks the projection'. Migration 014 invokes run_bulk_lazy at the end of up() — no-op pass at v1, but validates the wiring is correct. Future bump migrations (015 = v2, etc) will reuse the same helper.
87 lines
4.1 KiB
Python
87 lines
4.1 KiB
Python
"""Migration 014: payload schema_version + verbatim-local mirror columns.
|
|
|
|
See the community-web design doc at
|
|
docs/superpowers/specs/2026-06-05-schema-versioning-migration-design.md
|
|
(in the community-web repo) sections 4 + 5 for the design. The columns:
|
|
|
|
- schema_version (INTEGER NOT NULL DEFAULT 1): the payload's shape
|
|
version. ``1`` is the implicit baseline — every row that existed
|
|
before this migration was authored against shape v1, so the DEFAULT
|
|
doubles as the backfill.
|
|
- original_payload_json (TEXT, nullable): verbatim future-shaped
|
|
producer bytes preserved through tolerant installs. NULL means no
|
|
preservation was needed (row was installed at-or-below CURRENT, i.e.
|
|
no future-shape tolerance was applied).
|
|
- tolerant_drift_status (TEXT NOT NULL DEFAULT 'none'): enum tracking
|
|
the drift state of the row's payload vs the catalog. ``'none'`` = no
|
|
drift; ``'pending'`` = drifted (tolerant-installed from a future
|
|
shape and not yet reconciled); ``'broken'`` = deferred strict-validate
|
|
failed.
|
|
- schema_migrated_at (TEXT, nullable): timestamp of the last structural
|
|
migration on the row. Intentionally SEPARATE from ``updated_at`` —
|
|
structural migration is the engine moving the payload between shape
|
|
versions, not a user edit, so it must not dirty user-edit state.
|
|
|
|
Backfill: every pre-existing row stamps ``schema_version=1`` and
|
|
``tolerant_drift_status='none'`` implicitly via the DEFAULT on the
|
|
NOT NULL columns — SQLite materializes the default into existing rows
|
|
when ADD COLUMN runs.
|
|
|
|
Idempotent: each ALTER is gated on ``PRAGMA table_info`` so re-running
|
|
the migration against a DB that already has the columns is a no-op.
|
|
The migration runner's existing version table guards against re-runs
|
|
in production; this guard exists for tests that explicitly rewind the
|
|
``migrations`` table to exercise the migration on a populated DB
|
|
(see ``tests/engine/db/test_migration_010.py``).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
|
|
from engine.db.migrations_sql._014_helpers import run_bulk_lazy
|
|
|
|
|
|
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
|
cur = conn.execute(f"PRAGMA table_info({table});")
|
|
return any(row[1] == column for row in cur.fetchall())
|
|
|
|
|
|
def up(conn: sqlite3.Connection) -> None:
|
|
with conn:
|
|
# modules
|
|
if not _has_column(conn, "modules", "schema_version"):
|
|
conn.execute(
|
|
"ALTER TABLE modules ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1;"
|
|
)
|
|
if not _has_column(conn, "modules", "original_payload_json"):
|
|
conn.execute("ALTER TABLE modules ADD COLUMN original_payload_json TEXT;")
|
|
if not _has_column(conn, "modules", "tolerant_drift_status"):
|
|
conn.execute(
|
|
"ALTER TABLE modules ADD COLUMN tolerant_drift_status "
|
|
"TEXT NOT NULL DEFAULT 'none';"
|
|
)
|
|
if not _has_column(conn, "modules", "schema_migrated_at"):
|
|
conn.execute("ALTER TABLE modules ADD COLUMN schema_migrated_at TEXT;")
|
|
|
|
# bundles
|
|
if not _has_column(conn, "bundles", "schema_version"):
|
|
conn.execute(
|
|
"ALTER TABLE bundles ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1;"
|
|
)
|
|
if not _has_column(conn, "bundles", "original_payload_json"):
|
|
conn.execute("ALTER TABLE bundles ADD COLUMN original_payload_json TEXT;")
|
|
if not _has_column(conn, "bundles", "tolerant_drift_status"):
|
|
conn.execute(
|
|
"ALTER TABLE bundles ADD COLUMN tolerant_drift_status "
|
|
"TEXT NOT NULL DEFAULT 'none';"
|
|
)
|
|
if not _has_column(conn, "bundles", "schema_migrated_at"):
|
|
conn.execute("ALTER TABLE bundles ADD COLUMN schema_migrated_at TEXT;")
|
|
|
|
# Bulk run: at the time 014 ships, CURRENT_SCHEMA_VERSION is still 1, so
|
|
# every existing row is already at CURRENT and the bulk is a no-op pass.
|
|
# We invoke it anyway to exercise + validate the wiring on first run.
|
|
# FUTURE: when a bump migration 015 ships, it ALSO calls run_bulk_lazy
|
|
# with the new CURRENT and the bump's migrators.
|
|
run_bulk_lazy(conn, current_version=1, migrators={}, validators={}, tolerant_strip={})
|