@michaelthielemann/kestrel-migrations-default
v5.8.1
Published
migrations@1: applies content migrations once, per document and stored locale, with a ledger.
Readme
migrations/default
migrations@1 on top of content@1: every pending migration ({ id, collection, up } in config)
runs exactly once, in config order, against every document of its collection and every stored
locale of that document, validated against the current model and — when a validate@1 provider is
registered — against the consumer's JSON schemas. A migration is recorded in the ledger
(content_migrations) only after it succeeds; a failing migration leaves the documents it already
rewrote as they are (no transactions across documents) and names the migration, the document and
the locale in its error. No per-document events; a successful non-dry apply() that ran at least
one migration emits exactly one migrations.applied with the applied ids and total changed
document count.
Config: migrations ({ id, collection, up }[], id matching ^[A-Za-z0-9][A-Za-z0-9._-]*$,
unique, collection naming a type in content.model().types — otherwise boot fails), mode
("apply" default, "check" fails boot listing what's pending, "off" runs nothing at boot),
chunk (paging size, default 50).
Steps: migrations.list ({ applied, pending }), migrations.apply (payload.dry === true reports
changes without writing). Both write result and read nothing. apply answers CONFLICT (409)
while another apply() is already running, MIGRATION_FAILED (500) with the failing migration's
own message and details: { migration, document, locale?, problems? }, and TRANSIENT (503,
retryable) when the ledger or content@1 fails transiently; list answers TRANSIENT only.
Schema evolution: changing a stored field or block shape is a migration, not a one-off script — see
helpers (@michaelthielemann/kestrel-migrations-default/helpers, pure, no core import):
defineMigration, mapBlocks(document, type, fn, blocksField = "body") (depth-first, slots before
the node itself, fn returning null removes the node), renameBlock, renameProp, omit. An
example: moving a serviced-apartments block's props.images into its first
props.categories entry —
defineMigration({
id: "serviced-apartments-images-to-categories",
collection: "pages",
up: ({ document }) =>
mapBlocks(document, "serviced-apartments", (block) => {
const props = block.props ?? {};
const images = props.images;
const categories = (props.categories as Array<Record<string, unknown>> | undefined) ?? [];
if (!Array.isArray(images) || categories.length === 0) return block;
const [first, ...rest] = categories;
return { ...block, props: omit({ ...props, categories: [{ ...first, images }, ...rest] }, "images") };
}),
});and a rename: renameBlock(document, "serviced-apartments", "apartments").
The boot-time migrations.applied (mode "apply") reaches no listener by construction: it fires
inside setup(), and event triggers subscribe in start(), after every module is set up. Only an
apply() triggered through a pipeline reaches event triggers; after a partial failure the event still fires, covering only the migrations that were
applied before the failing one.
Not included: down migrations (point-in-time recovery is the way back), transactions, file
discovery of migration modules, a CLI — all a consumer concern.
Generated from the manifest
@michaelthielemann/kestrel-migrations-default – module migrations/default: provides migrations@1; requires content@1, persistence@1, events@1; optional validate@1.
| Config | Type | Required | Default |
|---|---|---|---|
| migrations | array | yes | – |
| mode | enum | no | "apply" |
| chunk | integer | no | 50 |
| Step | Summary | Reads | Writes | Input | Output | Errors |
|---|---|---|---|---|---|---|
| migrations.list | Applied migrations (ledger, oldest first) and still-pending migrations (config order) | – | result | – | { applied: object[], pending: object[], … } | – |
| migrations.apply | Apply every pending migration; payload.dry === true reports the changes without writing | – | result | { dry?: boolean } | object | object | 409 another apply() is already running; 500 a migration failed (message names the migration, document and locale) |
Pipelines in examples/minimal using these steps:
- applyMigrations (POST /admin/migrations/apply):
authn.requireUser→authz.require:migrations.manage→migrations.apply - listMigrations (GET /admin/migrations):
authn.requireUser→authz.require:migrations.manage→migrations.list
