Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/migration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions src/migration/_index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
44 changes: 44 additions & 0 deletions src/migration/docs/migration.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions src/migration/migration.lua
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type RunOptions = {
direction: string?,
force: boolean?,
id: string?,
aliases: {string}?,
}

type RunResult = {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
52 changes: 52 additions & 0 deletions src/migration/registry.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
93 changes: 93 additions & 0 deletions src/migration/registry_test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading