From 86382f80d856113aebc819943ff381f1f9a5a40f Mon Sep 17 00:00:00 2001 From: Maxim Smakouz Date: Mon, 27 Jul 2026 13:55:22 +0300 Subject: [PATCH] feat(migration): add support for migration aliases --- src/migration/README.md | 1 + src/migration/_index.yaml | 17 +++ src/migration/docs/migration.spec.md | 44 ++++++ src/migration/migration.lua | 21 +++ src/migration/registry.lua | 52 +++++++ src/migration/registry_test.lua | 93 +++++++++++++ src/migration/runner.lua | 70 ++++++++-- src/migration/runner_alias_test.lua | 195 +++++++++++++++++++++++++++ src/migration/test/_index.yaml | 35 +++++ src/migration/test/alias_mig_one.lua | 21 +++ src/migration/test/alias_mig_two.lua | 21 +++ 11 files changed, 560 insertions(+), 10 deletions(-) create mode 100644 src/migration/runner_alias_test.lua create mode 100644 src/migration/test/alias_mig_one.lua create mode 100644 src/migration/test/alias_mig_two.lua diff --git a/src/migration/README.md b/src/migration/README.md index 488de6c..cf30928 100644 --- a/src/migration/README.md +++ b/src/migration/README.md @@ -35,6 +35,7 @@ Key features include: - Automatic migration tracking and duplicate detection - Forward and backward migration support with rollback capabilities - Registry integration for discovering migrations by target database and tags +- Safe renames and namespace moves via `meta.alias` — ledger rows recorded under a former registry ID still count as applied - Isolated execution environment with proper error handling and cleanup The module is used by the bootloader during application startup and can be used programmatically for database schema management tasks. diff --git a/src/migration/_index.yaml b/src/migration/_index.yaml index 87f2b39..14bc080 100644 --- a/src/migration/_index.yaml +++ b/src/migration/_index.yaml @@ -129,3 +129,20 @@ entries: imports: migration_registry: wippy.migration:registry test: wippy.test:test + + # wippy.migration:runner_alias_test + - name: runner_alias_test + kind: function.lua + meta: + type: test + suite: migration-runner + comment: Integration tests for alias-aware runner against a real database + source: file://runner_alias_test.lua + method: run + modules: + - sql + - funcs + imports: + runner: wippy.migration:runner + repository: wippy.migration:repository + test: wippy.test:test diff --git a/src/migration/docs/migration.spec.md b/src/migration/docs/migration.spec.md index 9b9cf54..02c9805 100644 --- a/src/migration/docs/migration.spec.md +++ b/src/migration/docs/migration.spec.md @@ -365,6 +365,50 @@ up(function(db) end) ``` +## Renaming or Moving Migrations (`meta.alias`) + +A migration is identified by its registry entry ID (`namespace:name`), and that exact string is recorded in the +`_migrations` ledger when the migration is applied. Renaming an entry or moving a module to another namespace changes +the ID — without extra care the same migration would run again on databases where it was applied under the old ID. + +Declare the former ID(s) in the entry's `meta.alias` to keep the ledger history valid. The value is either a single +full ID or an array of full IDs (`namespace:name`): + +```yaml +entries: + - name: 01_create_orders_table + kind: function.lua + meta: + type: migration + description: Create orders table + timestamp: "2025-04-08T10:00:00Z" + # Former ID after a namespace move; also accepts an array: + # alias: [acme.shop.migrations:01_create_orders_table, acme.legacy:01_orders] + alias: acme.shop.migrations:01_create_orders_table + source: file://01_create_orders_table.lua + imports: + migration: wippy.migration:migration + method: migrate +``` + +Semantics: + +- A ledger row recorded under any alias counts as applied — the migration is never re-applied. The entry's own ID is + checked first, then aliases in declaration order; the first match wins. +- New applications are always recorded under the **current** ID; aliases are read-only matching keys. +- Rolling back a row recorded under an old ID executes the **current** entry's `down` and deletes the old ledger row. +- `allowed_ids` options of the runner accept old IDs as well as current ones. +- The `status()` report exposes `applied_id` — the ledger row ID that matched (differs from `id` for alias matches). + +Rules and edge cases: + +- Aliases must be full IDs in `namespace:name` form. +- An alias must not equal the ID of a live migration, and one alias cannot be claimed by two entries — both are + configuration errors that abort the run (and application boot) with an explicit message. +- If the ledger somehow contains rows for both the old and the new ID, the entry's own ID wins; remove the stale old + row manually. +- Keep the alias for as long as any deployment's ledger may still hold the old ID; it is safe to keep it indefinitely. + ## Testing Migrations Before finalizing any migration: diff --git a/src/migration/migration.lua b/src/migration/migration.lua index adc3e1c..ac7e63c 100644 --- a/src/migration/migration.lua +++ b/src/migration/migration.lua @@ -12,6 +12,7 @@ type RunOptions = { direction: string?, force: boolean?, id: string?, + aliases: {string}?, } type RunResult = { @@ -83,6 +84,25 @@ local function execute_migration(migration_item: any, options: any): any } end + if not is_applied and type(options.aliases) == "table" then + for _, alias in ipairs(options.aliases) do + local alias_applied, alias_err = repository.is_applied(db, tostring(alias)) + if alias_err then + return { + status = "error", + description = migration_item.description, + error = "Failed to check migration status: " .. tostring(alias_err), + name = migration_item.description + } + end + + if alias_applied then + is_applied = true + break + end + end + end + if is_applied and not options.force then return { status = "skipped", @@ -291,6 +311,7 @@ function migration.run(fn: () -> (), options: RunOptions?): any direction = opts.direction, force = opts.force, id = opts.id, + aliases = opts.aliases, }) table.insert(results.migrations, result) diff --git a/src/migration/registry.lua b/src/migration/registry.lua index b986ec2..1f93271 100644 --- a/src/migration/registry.lua +++ b/src/migration/registry.lua @@ -40,6 +40,58 @@ function migrations.compare(a: any, b: any): boolean return tostring(a and a.id or "") < tostring(b and b.id or "") end +function migrations.get_aliases(entry: any): {string} + local meta = entry and entry.meta + if type(meta) ~= "table" or meta.alias == nil then + return {} + end + + local candidates: any + if type(meta.alias) == "string" then + candidates = { meta.alias } + elseif type(meta.alias) == "table" then + candidates = meta.alias + else + return {} + end + + local own_id = tostring(entry and entry.id or "") + local seen = {} + local result = {} + for _, alias in ipairs(candidates) do + if type(alias) == "string" and alias ~= "" and alias ~= own_id and not seen[alias] then + seen[alias] = true + table.insert(result, alias) + end + end + + return result +end + +function migrations.build_alias_index(entries: {any}?): ({[string]: any}?, string?) + local by_id = {} + for _, entry in ipairs(entries or {}) do + by_id[tostring(entry.id)] = entry + end + + local index = {} + for _, entry in ipairs(entries or {}) do + for _, alias in ipairs(migrations.get_aliases(entry)) do + if by_id[alias] then + return nil, "alias '" .. alias .. "' on migration '" .. tostring(entry.id) + .. "' collides with an existing migration id" + end + if index[alias] then + return nil, "alias '" .. alias .. "' is claimed by both '" + .. tostring(index[alias].id) .. "' and '" .. tostring(entry.id) .. "'" + end + index[alias] = entry + end + end + + return index +end + -- Find migrations in registry based on provided options function migrations.find(options: any?): ({MigrationEntry}?, string?) local opts = options or {} diff --git a/src/migration/registry_test.lua b/src/migration/registry_test.lua index 02671c4..67ed111 100644 --- a/src/migration/registry_test.lua +++ b/src/migration/registry_test.lua @@ -235,6 +235,99 @@ local function define_tests() end) end) + test.describe("get_aliases", function() + test.it("returns empty for entry without meta", function() + test.eq(#migration_registry.get_aliases({ id = "m:one" }), 0) + test.eq(#migration_registry.get_aliases(nil), 0) + end) + + test.it("returns empty when alias is missing", function() + local entry = { id = "m:one", meta = { type = "migration" } } + test.eq(#migration_registry.get_aliases(entry), 0) + end) + + test.it("wraps a string alias into an array", function() + local entry = { id = "m:one", meta = { type = "migration", alias = "legacy:one" } } + local aliases = migration_registry.get_aliases(entry) + test.eq(#aliases, 1) + test.eq(aliases[1], "legacy:one") + end) + + test.it("preserves array alias order", function() + local entry = { + id = "m:one", + meta = { type = "migration", alias = { "legacy:one", "older:one" } }, + } + local aliases = migration_registry.get_aliases(entry) + test.eq(#aliases, 2) + test.eq(aliases[1], "legacy:one") + test.eq(aliases[2], "older:one") + end) + + test.it("drops non-string, empty, self and duplicate values", function() + local entry = { + id = "m:one", + meta = { + type = "migration", + alias = { "legacy:one", 42, "", "m:one", "legacy:one", "older:one" }, + }, + } + local aliases = migration_registry.get_aliases(entry) + test.eq(#aliases, 2) + test.eq(aliases[1], "legacy:one") + test.eq(aliases[2], "older:one") + end) + + test.it("returns empty for a non-string non-table alias", function() + local entry = { id = "m:one", meta = { type = "migration", alias = 42 } } + test.eq(#migration_registry.get_aliases(entry), 0) + end) + end) + + test.describe("build_alias_index", function() + test.it("maps aliases from multiple entries", function() + local one = { id = "m:one", meta = { type = "migration", alias = "legacy:one" } } + local two = { + id = "m:two", + meta = { type = "migration", alias = { "legacy:two_a", "legacy:two_b" } }, + } + local index, err = migration_registry.build_alias_index({ one, two }) + test.is_nil(err) + test.eq(index["legacy:one"].id, "m:one") + test.eq(index["legacy:two_a"].id, "m:two") + test.eq(index["legacy:two_b"].id, "m:two") + end) + + test.it("returns empty index when no entry has aliases", function() + local index, err = migration_registry.build_alias_index({ + { id = "m:one", meta = { type = "migration" } }, + }) + test.is_nil(err) + test.is_nil(next(index)) + end) + + test.it("fails when an alias is claimed by two entries", function() + local index, err = migration_registry.build_alias_index({ + { id = "m:one", meta = { type = "migration", alias = "legacy:shared" } }, + { id = "m:two", meta = { type = "migration", alias = { "legacy:shared" } } }, + }) + test.is_nil(index) + test.contains(err, "legacy:shared") + test.contains(err, "m:one") + test.contains(err, "m:two") + end) + + test.it("fails when an alias collides with a live migration id", function() + local index, err = migration_registry.build_alias_index({ + { id = "m:one", meta = { type = "migration" } }, + { id = "m:two", meta = { type = "migration", alias = "m:one" } }, + }) + test.is_nil(index) + test.contains(err, "m:one") + test.contains(err, "m:two") + end) + end) + test.describe("get_target_dbs", function() test.before_each(save_registry) test.after_each(restore_registry) diff --git a/src/migration/runner.lua b/src/migration/runner.lua index f9a8972..beed613 100644 --- a/src/migration/runner.lua +++ b/src/migration/runner.lua @@ -39,6 +39,18 @@ local function get_description(migration: any): any return "" end +local function matches_migration_id(migration: any, wanted: string): boolean + if migration.id == wanted then + return true + end + for _, alias in ipairs(registry_finder.get_aliases(migration)) do + if alias == wanted then + return true + end + end + return false +end + local function compare_applied(a: any, b: any): boolean local a_applied_at = tostring(a.applied_at or "") local b_applied_at = tostring(b.applied_at or "") @@ -115,18 +127,34 @@ function Runner:find_migrations(options: RunnerOptions?): ({any}?, string?) db:release() + local _, alias_err = registry_finder.build_alias_index(migrations) + if alias_err then + return nil, "Invalid migration aliases: " .. tostring(alias_err) + end + local applied = {} local pending = {} for _, migration in ipairs(migrations) do - local migration_id = migration.id - if applied_map[migration_id] then + local applied_row = applied_map[migration.id] + if not applied_row then + for _, alias in ipairs(registry_finder.get_aliases(migration)) do + applied_row = applied_map[alias] + if applied_row then + break + end + end + end + + if applied_row then migration.applied = true - migration.applied_at = applied_map[migration_id].applied_at + migration.applied_at = applied_row.applied_at + migration.applied_id = applied_row.id table.insert(applied, migration) else migration.applied = false migration.applied_at = nil + migration.applied_id = nil table.insert(pending, migration) end end @@ -228,6 +256,7 @@ function Runner:run(options: RunnerOptions?): any skip_type = "already_applied", reason = "Already applied", applied_at = migration.applied_at, + applied_id = migration.applied_id, description = get_description(migration) }) goto continue @@ -236,7 +265,8 @@ function Runner:run(options: RunnerOptions?): any local migration_options = { database_id = self.database_id, direction = "up", - id = migration.id + id = migration.id, + aliases = registry_finder.get_aliases(migration) } local result = execute_migration(tostring(migration.id), migration_options) @@ -334,7 +364,7 @@ function Runner:run_next(options: RunnerOptions?): any if #allowed_ids > 0 then local is_allowed = false for _, allowed_id in ipairs(allowed_ids) do - if migration.id == allowed_id then + if matches_migration_id(migration, allowed_id) then is_allowed = true break end @@ -390,7 +420,8 @@ function Runner:run_next(options: RunnerOptions?): any local migration_options = { database_id = self.database_id, direction = "up", - id = target_migration.id + id = target_migration.id, + aliases = registry_finder.get_aliases(target_migration) } local result = execute_migration(tostring(target_migration.id), migration_options) @@ -481,8 +512,18 @@ function Runner:rollback(options: RunnerOptions?): any } end + local registry_entries, reg_err = registry_finder.find({ target_db = tostring(self.database_id) }) + if reg_err then + return create_error("Failed to find migrations: " .. tostring(reg_err)) + end + + local alias_index, alias_err = registry_finder.build_alias_index(registry_entries) + if alias_err then + return create_error("Invalid migration aliases: " .. tostring(alias_err)) + end + for i, migration in ipairs(applied_migrations) do - local registry_entry = registry_finder.get(tostring(migration.id)) + local registry_entry = registry_finder.get(tostring(migration.id)) or alias_index[tostring(migration.id)] if registry_entry then applied_migrations[i].registry_entry = registry_entry end @@ -496,7 +537,8 @@ function Runner:rollback(options: RunnerOptions?): any local filtered = {} for _, migration in ipairs(applied_migrations) do for _, allowed_id in ipairs(allowed_ids) do - if migration.id == allowed_id then + if migration.id == allowed_id + or (migration.registry_entry and migration.registry_entry.id == allowed_id) then table.insert(filtered, migration) break end @@ -540,13 +582,20 @@ function Runner:rollback(options: RunnerOptions?): any local start_time = time.now() for _, migration in ipairs(to_rollback) do + -- Call the current entry (the ledger id may be a former one), but keep + -- the ledger row id in options so the down path removes that exact row. + local call_target = tostring(migration.id) + if migration.registry_entry then + call_target = tostring(migration.registry_entry.id) + end + local migration_options = { database_id = self.database_id, direction = "down", id = migration.id } - local result = execute_migration(tostring(migration.id), migration_options) + local result = execute_migration(call_target, migration_options) if result and result.status == "error" then results.migrations_failed = results.migrations_failed + 1 @@ -642,7 +691,8 @@ function Runner:status(options: RunnerOptions?): any timestamp = migration.meta and migration.meta.timestamp or "", tags = migration.meta and migration.meta.tags or {}, status = migration.applied and "applied" or "pending", - applied_at = migration.applied_at + applied_at = migration.applied_at, + applied_id = migration.applied_id } if migration.applied then diff --git a/src/migration/runner_alias_test.lua b/src/migration/runner_alias_test.lua new file mode 100644 index 0000000..f20487d --- /dev/null +++ b/src/migration/runner_alias_test.lua @@ -0,0 +1,195 @@ +local test = require("test") +local sql = require("sql") +local funcs = require("funcs") +local runner = require("runner") +local repository = require("repository") + +local DB_ID = "app:db" +local MIG_ONE = "app:alias_mig_one" +local MIG_TWO = "app:alias_mig_two" +local OLD_ONE = "app.legacy:alias_mig_one" +local OLD_TWO_B = "app.legacy:alias_mig_two_b" + +local function with_db(fn: (any) -> any): any + local conn, err = sql.get(DB_ID) + test.is_nil(err) + local db: any = test.not_nil(conn) + + local ok, result = pcall(fn, db) + db:release() + if not ok then + error(result, 0) + end + return result +end + +local function reset() + with_db(function(db) + local _, init_err = repository.init_tracking_table(db) + test.is_nil(init_err) + db:execute("DELETE FROM _migrations") + db:execute("DROP TABLE IF EXISTS alias_one") + db:execute("DROP TABLE IF EXISTS alias_two") + end) +end + +local function seed(id: string) + with_db(function(db) + local _, err = repository.record_migration(db, id, "seeded by runner_alias_test") + test.is_nil(err) + end) +end + +local function ledger_ids(): any + return with_db(function(db) + local rows, err = repository.get_migrations(db) + test.is_nil(err) + + local ids = {} + for _, row in ipairs(rows or {}) do + ids[row.id] = true + end + return ids + end) +end + +local function find_status_row(report: any, id: string): any + for _, m in ipairs(report.migrations) do + if m.id == id then + return m + end + end + return nil +end + +local function define_tests() + test.describe("runner with aliases", function() + test.before_each(reset) + + test.it("applies fixture migrations on an empty ledger", function() + local result = runner.setup(DB_ID):run() + test.eq(result.status, "complete") + test.eq(result.migrations_applied, 2) + test.eq(result.migrations_failed, 0) + + local ids = ledger_ids() + test.is_true(ids[MIG_ONE]) + test.is_true(ids[MIG_TWO]) + end) + + test.it("skips migrations recorded under an old id", function() + seed(OLD_ONE) + seed(OLD_TWO_B) + + local result = runner.setup(DB_ID):run() + test.eq(result.status, "complete") + test.eq(result.migrations_applied, 0) + test.eq(result.migrations_skipped, 2) + for _, m in ipairs(result.migrations) do + test.eq(m.status, "skipped") + test.eq(m.skip_type, "already_applied") + end + + -- Ledger is untouched: old rows stay, nothing recorded under new ids + local ids = ledger_ids() + test.is_true(ids[OLD_ONE]) + test.is_true(ids[OLD_TWO_B]) + test.is_nil(ids[MIG_ONE]) + test.is_nil(ids[MIG_TWO]) + end) + + test.it("status reports applied_id for alias-matched rows", function() + seed(OLD_ONE) + + local report = runner.setup(DB_ID):status() + local one = find_status_row(report, MIG_ONE) + test.not_nil(one) + test.eq(one.status, "applied") + test.eq(one.applied_id, OLD_ONE) + + local two = find_status_row(report, MIG_TWO) + test.not_nil(two) + test.eq(two.status, "pending") + end) + + test.it("status reports applied_id equal to id after a normal apply", function() + runner.setup(DB_ID):run() + + local report = runner.setup(DB_ID):status() + local one = find_status_row(report, MIG_ONE) + test.eq(one.status, "applied") + test.eq(one.applied_id, MIG_ONE) + end) + + test.it("prefers the migration's own ledger row over alias rows", function() + seed(MIG_ONE) + seed(OLD_ONE) + + local report = runner.setup(DB_ID):status() + local one = find_status_row(report, MIG_ONE) + test.eq(one.status, "applied") + test.eq(one.applied_id, MIG_ONE) + end) + + test.it("rolls back rows recorded under an old id", function() + seed(OLD_ONE) + seed(OLD_TWO_B) + + -- Resolution must call the current entries: a funcs call on the + -- old ids would fail because those entries no longer exist. + local result = runner.setup(DB_ID):rollback({ count = 2 }) + test.eq(result.status, "complete") + test.eq(result.migrations_reverted, 2) + test.eq(result.migrations_failed, 0) + + test.is_nil(next(ledger_ids())) + end) + + test.it("rollback allowed_ids accepts the current id for an old row", function() + seed(OLD_ONE) + + local result = runner.setup(DB_ID):rollback({ allowed_ids = { MIG_ONE } }) + test.eq(result.status, "complete") + test.eq(result.migrations_reverted, 1) + + test.is_nil(next(ledger_ids())) + end) + + test.it("run_next allowed_ids accepts an old id for a pending migration", function() + local result = runner.setup(DB_ID):run_next({ allowed_ids = { OLD_ONE } }) + test.eq(result.status, "complete") + test.eq(result.migrations_applied, 1) + + local ids = ledger_ids() + test.is_true(ids[MIG_ONE]) + test.is_nil(ids[MIG_TWO]) + end) + + test.it("direct execution honors aliases in the applied check", function() + seed(OLD_ONE) + + local executor = funcs.new() + local result, err = executor:call(MIG_ONE, { + database_id = DB_ID, + direction = "up", + id = MIG_ONE, + aliases = { OLD_ONE }, + }) + test.is_nil(err) + test.eq(result.migrations[1].status, "skipped") + test.eq(result.migrations[1].reason, "Migration already applied") + + local ids = ledger_ids() + test.is_true(ids[OLD_ONE]) + test.is_nil(ids[MIG_ONE]) + end) + end) +end + +local run_cases = test.run_cases(define_tests) + +local function run(options: any): any + return run_cases(options) +end + +return { run = run } diff --git a/src/migration/test/_index.yaml b/src/migration/test/_index.yaml index 70ea4e8..1c93f92 100644 --- a/src/migration/test/_index.yaml +++ b/src/migration/test/_index.yaml @@ -13,3 +13,38 @@ entries: kind: process.host lifecycle: auto_start: true + + - name: db + kind: db.sql.sqlite + meta: + comment: In-memory database for migration runner tests + file: ":memory:" + + # Fixtures for wippy.migration:runner_alias_test + - name: alias_mig_one + kind: function.lua + meta: + type: migration + target_db: app:db + description: Alias fixture migration with a string alias + timestamp: "2025-01-01T00:00:00Z" + alias: app.legacy:alias_mig_one + source: file://alias_mig_one.lua + imports: + migration: wippy.migration:migration + method: migrate + + - name: alias_mig_two + kind: function.lua + meta: + type: migration + target_db: app:db + description: Alias fixture migration with an array of aliases + timestamp: "2025-01-02T00:00:00Z" + alias: + - app.legacy:alias_mig_two_a + - app.legacy:alias_mig_two_b + source: file://alias_mig_two.lua + imports: + migration: wippy.migration:migration + method: migrate diff --git a/src/migration/test/alias_mig_one.lua b/src/migration/test/alias_mig_one.lua new file mode 100644 index 0000000..7f74a34 --- /dev/null +++ b/src/migration/test/alias_mig_one.lua @@ -0,0 +1,21 @@ +-- Fixture migration with a string meta.alias; bodies are idempotent because +-- the bootloader auto-applies it on test app boot and tests reset the ledger. +return require("migration").define(function() + migration("Create alias_one table", function() + database("sqlite", function() + up(function(db) + local _, err = db:execute("CREATE TABLE IF NOT EXISTS alias_one (id INTEGER PRIMARY KEY)") + if err then + error(err) + end + end) + + down(function(db) + local _, err = db:execute("DROP TABLE IF EXISTS alias_one") + if err then + error(err) + end + end) + end) + end) +end) diff --git a/src/migration/test/alias_mig_two.lua b/src/migration/test/alias_mig_two.lua new file mode 100644 index 0000000..52302ad --- /dev/null +++ b/src/migration/test/alias_mig_two.lua @@ -0,0 +1,21 @@ +-- Fixture migration with an array meta.alias; bodies are idempotent because +-- the bootloader auto-applies it on test app boot and tests reset the ledger. +return require("migration").define(function() + migration("Create alias_two table", function() + database("sqlite", function() + up(function(db) + local _, err = db:execute("CREATE TABLE IF NOT EXISTS alias_two (id INTEGER PRIMARY KEY)") + if err then + error(err) + end + end) + + down(function(db) + local _, err = db:execute("DROP TABLE IF EXISTS alias_two") + if err then + error(err) + end + end) + end) + end) +end)