Context
Surfaced during the design re-read of PR #304. Not a blocker — there's only one migration today (legacy_identity_to_per_account) and nothing has gone wrong. Filing because the design that's about to grow has a known limitation worth recording before a second migration adds a second case.
The limitation
internal/store/migrations.go records applied migrations as:
CREATE TABLE IF NOT EXISTS applied_migrations (
name TEXT PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
MarkMigrationApplied is INSERT OR IGNORE keyed on name. The contract is "this named migration has run at least once." It can't express "this named migration has run with this implementation."
Real cases this will surface:
- The
legacy_identity_to_per_account migration was extended to skip non-email source types (bcdde74) and defer when no eligible sources exist (ca2afac). If those changes had needed to re-process sources that were skipped under the original implementation, there would have been no way to distinguish "migration ran, didn't apply to this source" from "migration ran fully."
- A future migration that broadens its eligibility filter (add
apple-mail to the email-source allowlist, say) needs a fresh sentinel name. The natural name (legacy_identity_to_per_account_v2) bakes a version into the migration identifier — workable but ugly.
- Migrations that are idempotent except for one edge case have no clean way to opt back in.
Why it matters
Most projects hit this somewhere between migration #2 and migration #5. The longer it goes unaddressed, the more migrations accumulate _v2 / _v3 suffix conventions that drift apart.
Proposed approach
Add a version INTEGER NOT NULL DEFAULT 1 column to applied_migrations, and let MarkMigrationApplied accept an optional version. IsMigrationApplied(name, version) becomes SELECT 1 WHERE name = ? AND version >= ?. Existing callers default to version 1, which is the exact semantics they already have.
Cheap, backward compatible, removes the suffix-naming pressure when the second migration's implementation needs a re-run path.
Alternative: introduce a fresh migration_runs history table (one row per run with name/version/applied_at) and keep applied_migrations as a current-state cache. Heavier; pays off if there's ever a "rollback the last migration" story.
Context
Surfaced during the design re-read of PR #304. Not a blocker — there's only one migration today (
legacy_identity_to_per_account) and nothing has gone wrong. Filing because the design that's about to grow has a known limitation worth recording before a second migration adds a second case.The limitation
internal/store/migrations.gorecords applied migrations as:MarkMigrationAppliedisINSERT OR IGNOREkeyed onname. The contract is "this named migration has run at least once." It can't express "this named migration has run with this implementation."Real cases this will surface:
legacy_identity_to_per_accountmigration was extended to skip non-email source types (bcdde74) and defer when no eligible sources exist (ca2afac). If those changes had needed to re-process sources that were skipped under the original implementation, there would have been no way to distinguish "migration ran, didn't apply to this source" from "migration ran fully."apple-mailto the email-source allowlist, say) needs a fresh sentinel name. The natural name (legacy_identity_to_per_account_v2) bakes a version into the migration identifier — workable but ugly.Why it matters
Most projects hit this somewhere between migration #2 and migration #5. The longer it goes unaddressed, the more migrations accumulate
_v2/_v3suffix conventions that drift apart.Proposed approach
Add a
version INTEGER NOT NULL DEFAULT 1column toapplied_migrations, and letMarkMigrationAppliedaccept an optional version.IsMigrationApplied(name, version)becomesSELECT 1 WHERE name = ? AND version >= ?. Existing callers default to version 1, which is the exact semantics they already have.Cheap, backward compatible, removes the suffix-naming pressure when the second migration's implementation needs a re-run path.
Alternative: introduce a fresh
migration_runshistory table (one row per run with name/version/applied_at) and keepapplied_migrationsas a current-state cache. Heavier; pays off if there's ever a "rollback the last migration" story.