Content Migration System
Content Migration applies versioned, declarative rules to many content files at once. It is for site authors and teams that need to change the structure of a site — renaming a Frontmatter key, normalizing tags, changing how publication is expressed, or moving a directory — and want a record of what was applied and a way to roll it back.
Think of it as a database migration for Markdown. You define a numbered change, preview the diff, apply it, and keep history. Unlike find-and-replace, rules operate on parsed Frontmatter and logical paths, not raw text, and the result is verified against the future site manifest before anything is written.
Core has no built-in migration presets. You define migrations as project code
in migrations/index.mjs. If you only need common Frontmatter cleanups, the
Frontmatter Transformer plugin ships
ready-made presets on top of the same engine.
Content Migration owns what to change. The Content Mutation system owns how to write the change safely, and is the only component that writes files.
What you can do
| Task | Supported |
|---|---|
| Rename a Frontmatter key | frontmatter-rename |
| Set, remove, or append a Frontmatter value | frontmatter-set, frontmatter-remove, frontmatter-append |
| Transform a value with a function | frontmatter-map |
| Normalize tags or other string lists | frontmatter-normalize-list |
| Change publication visibility | visibility (public, unlisted, draft) |
| Assign a stable content ID | content-id |
| Set a permalink and preserve the old URL | permalink |
| Move a file or a directory prefix | path-rename |
| Limit which files a rule touches | select, or when on any rule |
| Order migrations and share dependencies | dependsOn |
| Preview, save, apply, inspect, roll back, recover | CLI and API |
What it cannot do:
-
Rewrite arbitrary Markdown body text. Rules change Frontmatter and paths.
There is no regex or find-and-replace rule. A
path-renamecan make Content Mutation retarget links, but the body is otherwise left alone. - Provide built-in presets in core. Presets belong to the Frontmatter Transformer plugin.
- Import an Obsidian vault or a Quartz site. There is no importer. You can express Frontmatter conventions as rules, but format and body conversion are out of scope.
- Run automatically during a build. Migration is an explicit CLI or API call.
- Merge conflicting edits. A changed file or definition is a conflict.
Before and after: rename a key and normalize tags
Before, notes/getting-started.md uses a legacy category key and a boolean
published flag:
---
title: Getting started
category: "#guides, setup"
published: true
---
# Getting started
Define the migration in migrations/index.mjs at the project root:
import { defineContentMigration } from "@riebeckite/core";
export default [
defineContentMigration({
id: "legacy-frontmatter",
version: 1,
description: "Normalize legacy frontmatter.",
rules: [
{ kind: "frontmatter-map", key: "category", map: (value) => [value] },
{ kind: "frontmatter-rename", from: "category", to: "tags" },
{ kind: "frontmatter-normalize-list", key: "tags" },
{
kind: "frontmatter-set",
key: "visibility",
value: "public",
when: (content) =>
content.frontmatter.published === true &&
content.frontmatter.visibility === undefined,
},
{ kind: "frontmatter-remove", key: "published" },
],
}),
];
Preview the result:
riebeckite migrate plan
Migration plan <plan-id>
Migrations: legacy-frontmatter@1
Changed files: 1
Created: 0, moved: 0, frontmatter: 1
Nothing has been written yet. Apply it:
riebeckite migrate apply --apply
Applied 1 file change(s); mutation journal <path>.
After:
---
title: Getting started
tags:
- guides
- setup
visibility: public
---
# Getting started
The body is unchanged. The category value became a list, the key became tags,
the # prefix and comma separator were normalized away, published: true became
visibility: public, and the old key was removed. YAML serialization may change
key order or quoting; the values are what matter.
Quick start
-
Create
migrations/index.mjswith the definition above. -
List what the CLI can see:
sh riebeckite migrate listtext legacy-frontmatter@1 pending -
Preview:
sh riebeckite migrate plan -
Apply explicitly:
sh riebeckite migrate apply --apply -
Confirm the recorded state:
sh riebeckite migrate statustext legacy-frontmatter@1 applied History runs: 1 -
Inspect or undo:
sh riebeckite migrate history riebeckite migrate rollback legacy-frontmatter --apply
migrate plan is always a dry run. migrate apply, rollback, and recover
are dry runs until --apply is passed. A saved plan can be applied later:
riebeckite migrate plan --out migration-plan.json
riebeckite migrate apply --plan migration-plan.json --apply
If you do not want to write migration code for common Frontmatter cleanup, install the Frontmatter Transformer plugin and use its presets:
riebeckite frontmatter presets
riebeckite frontmatter plan --preset normalize-tags
riebeckite frontmatter apply --preset normalize-tags --apply
How it works
Diagram source
flowchart TD
Definitions["Migration definitions"]
Registry["Migration registry"]
Planner["Declarative planner"]
Future["Future ContentSource"]
Manifest["Future manifest verification"]
Mutation["Content Mutation"]
State["Migration state and history"]
CLI["riebeckite migrate"]
Definitions --> Registry --> Planner
Planner --> Mutation
Planner --> Future --> Manifest
Mutation --> State
State --> CLI
The layers have separate responsibilities:
| Layer | Responsibility |
|---|---|
| Migration definition | ID, version, dependencies, selection, rules, validation |
| Registry | Duplicate, dependency, cycle, and deterministic order checks |
| Planner | Apply rules to in-memory content and produce mutation operations |
| Future source | Present the planned files to the normal manifest pipeline |
| Mutation | Write, lock, fingerprint, journal, back up, recover, roll back |
| Migration state | Record which migration version was applied |
| Migration history | Record plan identity, result, counts, and journal reference |
plan() performs a dry run:
- Read the current content source.
- Apply the selected migrations in dependency order in memory.
- Convert the final state into Content Mutation operations.
- Build the future content manifest through a virtual ContentSource.
- Verify content IDs, permalinks, redirects, links, visibility, publication fingerprints, and derived data.
- Record the input content fingerprint, mutation fingerprint, plan fingerprint, diagnostics, conflicts, and estimated impact.
Planning does not modify user files. A plan conflict means the change is not safe to apply.
Apply revalidates the plan against the current content and the current registry
before writing. It then calls the Content Mutation executor with a mutation plan
whose fingerprint must match the migration plan. The migration is marked
applied only after the mutation reports success.
Migration metadata is stored under the content directory's internal
.riebeckite/migrations directory. It is not written to the build output.
state.json records the latest applied identity, plan and content fingerprints,
mutation plan identity, journal location, changed paths, and timestamps.
history.json records completed runs with migration ID/version, plan identity,
counts, result, recovery state, and a journal reference. History is bounded and
contains no content bodies, secrets, or absolute paths.
The mutation journal is the transaction record. Migration state only describes which migration produced the journal and whether the migration succeeded.
Relationship to other features
- Content Mutation is the only write path. Migration adds versioned intent, declarative rules, dependency ordering, future-manifest verification, and transaction history above it. There is no second backup engine, file writer, or content parser.
- Ordinary find-and-replace works on text. It does not parse Frontmatter, retarget links, verify the future manifest, or keep a journal. Use it for body text; use Migration for structural Frontmatter and path changes.
- The Frontmatter Transformer plugin is the no-code path for common Frontmatter cleanup. Its presets are versioned Content Migration definitions, not a separate engine.
- Obsidian and Quartz have their own conventions. Riebeckite can express Frontmatter rules that match those conventions, but there is no automated vault or site importer.
Limitations
- Rules change Frontmatter and paths only. Arbitrary Markdown body rewriting is not supported.
- Core ships no presets. Use the Frontmatter Transformer plugin or write a definition.
- There is no Obsidian or Quartz importer.
- A migration never writes files directly; if the change cannot be expressed as a safe mutation, it reports a conflict instead of applying it.
- Applying the same ID, version, and definition fingerprint twice is skipped. Change the version when the definition changes.
- Rollback requires an applied migration with a completed journal and unchanged current files. External edits, missing backups, or a corrupt journal produce a conflict and leave content untouched.
- Migration code is trusted project code. It is not sandboxed.
- There is no scheduler or automatic build integration. Run the CLI explicitly.
-
transformreceives a snapshot and may return Frontmatter changes, a path, and diagnostics. It cannot return a new Markdown body.
CLI reference
riebeckite migrate list [--json]
riebeckite migrate plan [--id <migration>] [--out <path>] [--json]
riebeckite migrate apply [--plan <path>] [--id <migration>] [--apply] [--json]
riebeckite migrate status [--json]
riebeckite migrate history [--limit <n>] [--json]
riebeckite migrate rollback <planId|migrationId> --apply [--json]
riebeckite migrate recover [--journal <path>] [--id <migration>] --apply [--json]
plan and apply are dry runs unless --apply is passed to apply. The CLI
prints conflicts and exits with a non-zero status. JSON output is a single object.
Migration modules live at <project>/migrations/index.mjs (or .js, .mts,
.ts under a TypeScript runner). They export an array or { migrations }. Only
project code is executed. Markdown and Frontmatter are data and are never
evaluated as JavaScript.
migrate is registered in the same typed CLI command
registry as the built-in commands. Each action has its own definition, help
metadata, JSON support, and exit code; there is no separate migration parser or
dispatcher branch. runMigrate remains the implementation behind those
definitions.
API reference
Definition
Call defineContentMigration and register the result with
createContentMigrationRegistry.
import {
createContentMigrationRegistry,
defineContentMigration,
} from "@riebeckite/core";
const legacy = defineContentMigration({
id: "legacy-frontmatter",
version: 1,
description: "Normalize legacy frontmatter.",
rules: [
{ kind: "frontmatter-map", key: "category", map: (value) => [value] },
{ kind: "frontmatter-rename", from: "category", to: "tags" },
{
kind: "frontmatter-set",
key: "visibility",
value: "public",
when: (content) =>
content.frontmatter.published === true &&
content.frontmatter.visibility === undefined,
},
{ kind: "frontmatter-remove", key: "published" },
],
});
const registry = createContentMigrationRegistry([legacy]);
A definition contains:
id: stable lower-case identifier.version: positive integer.description: non-empty explanation.dependsOn: optional migration IDs. The latest version of a dependency is used.select: optional predicate for the content a migration targets.rules: declarative transformations.transform: optional project function for cases that rules cannot express.validate: optional post-transform validation.reversible: optional description of what can be recovered.
The registry rejects duplicate ID/version pairs, missing dependencies, self dependencies, dependency cycles, invalid paths, and invalid rule shapes. Ordering is deterministic: dependencies first, then ID and version.
Multiple versions of the same ID may coexist. Applying a newer version marks the
older applied version as superseded. Applying the same version with a different
definition fingerprint is rejected; bump the version instead.
Transformation rules
Rules operate on parsed Frontmatter and the logical content path. Markdown is never rewritten with a broad regular expression.
| Rule | Effect |
|---|---|
frontmatter-set |
Set a key; a function may compute the value |
frontmatter-remove |
Remove a key |
frontmatter-rename |
Move a value to another key |
frontmatter-normalize-list |
Split strings and deduplicate arrays |
frontmatter-map |
Replace an existing value with a function result |
frontmatter-append |
Add one value or array entry |
visibility |
Set visibility to public, unlisted, or draft |
content-id |
Set the stable content ID |
permalink |
Set an explicit permalink and preserve the previous one |
path-rename |
Move a file or directory prefix |
Every rule accepts an optional when predicate. A rule that would produce the
same value performs no operation. Selectors and predicates run again on later
plans, so an applied migration is a no-op when re-evaluated.
Invalid YAML, non-mapping Frontmatter, unsafe paths, unknown selectors, and rule failures become diagnostics. They are not guessed into a successful transform.
API
The definition and planning API is exported from @riebeckite/core:
| API | Purpose |
|---|---|
defineContentMigration(definition) |
Validate and normalize one migration definition. |
createContentMigrationRegistry(migrations) |
Check duplicates, dependencies, cycles, and deterministic order. |
migrationKey(id, version) |
Stable key used by state and history. |
fingerprintContentMigrationDefinition(definition) |
Fingerprint the definition for revalidation. |
planContentMigrationBatch({ migrations, files, ... }) |
Apply definitions in memory and produce the planned batch. |
applyContentMigration(content, migration) |
Apply one transformation to parsed content. |
parseMigrationContent(path, markdown) |
Parse Markdown and Frontmatter for a migration. |
diffFrontmatter(before, after) |
Compare two Frontmatter maps. |
isSafeContentPath(path) |
Apply the shared logical-path rules. |
appendContentMigrationHistory / parseContentMigrationState / parseContentMigrationHistory |
Validate and bound persisted state. |
The filesystem executor is exported from @riebeckite/node:
import { FileSystemContentMigration } from "@riebeckite/node";
const migration = new FileSystemContentMigration({
contentDirectory,
registry,
});
| Method | Behavior |
|---|---|
plan({ ids? }) |
Dry run: read, transform, build the future manifest, verify, and return a plan. |
savePlan(plan, path?) / loadPlan(path) |
Persist or load a plan. |
apply({ planPath?, ids? }) |
Revalidate and apply through Content Mutation. |
status() |
Reconcile state with the mutation journal and return per-migration status. |
list() |
List definitions with their applied status. |
history({ limit? }) |
Return bounded run history. |
rollback({ planId?, migrationId? }) |
Roll back an applied migration through Content Mutation. |
recover({ journal?, migrationId? }) |
Recover an interrupted mutation journal. |
FileSystemContentMigrationOptions also accepts config, plugins,
buildTime, exclude, and redirectField when the future manifest must match
a custom build.
Idempotency
A migration that has already been applied with the same version and definition fingerprint is skipped. Re-running a plan on migrated content produces no operations because every rule compares the desired value with the current value.
Do not use a predicate that depends on time or external mutable state unless the version and definition fingerprint change when that state changes.
Resume and rollback
If the process stops while applying, status reconciles the migration record
with the mutation journal:
- A complete journal whose files still match is reconciled to
applied. - A prepared, applying, or failed journal requires recovery.
- A missing journal becomes
failed.
recover delegates to Content Mutation recovery. It either completes the
recorded transaction or restores the journal backups.
rollback is available only for an applied migration with a completed journal.
Before restoring, Content Mutation verifies that every current file still matches
the applied fingerprint and that every backup matches the recorded input. An
external edit, missing backup, or corrupt journal produces a conflict and leaves
the content untouched. Rollback is not a general undo system for edits made
outside the journal.
Migration states are pending, planned, applying, applied, failed,
recovery-required, rolled-back, and superseded.
Safety
- Planning never writes user files.
- Apply is explicit and serialized with a migration lock.
- Concurrent applies cannot both succeed.
- Saved plans are re-planned and compared before apply.
- Migration ID, version, definition fingerprint, content fingerprint, plan fingerprint, and mutation preconditions are revalidated.
- Path traversal, absolute paths, and symlinked content are rejected or skipped.
- Migration history omits content bodies and absolute paths.
- Draft, unlisted, and scheduled visibility is preserved unless a migration explicitly changes it.
- Migration code is trusted project code; content is not trusted code.
Troubleshooting
-
migrate listshows nothing: the loader readsmigrations/index.mjs,.js,.mts, or.tsfrom the project root. Confirm the file exports an array or{ migrations }. -
"definition changed" in
status: the registered definition fingerprint no longer matches the applied record. Restore the definition or bumpversion. - Apply is skipped: the same ID, version, and definition fingerprint was already applied. Bump the version for a new change.
-
recovery-required: the mutation journal was interrupted. Runmigrate recover --applybefore another apply. - Rollback reports a conflict: a file changed after the migration, a backup is missing, or the journal is corrupt. Rollback leaves the content untouched; inspect the conflict path.
- A saved plan cannot be applied: plans are bound to the content and registry fingerprint at planning time. Re-plan instead of editing the JSON.
-
A rule has no effect: check
selectandwhen, and confirm the file actually has the key. A rule that produces the same value is a no-op by design. - YAML changed shape: serialization is not byte-preserving. Review the diff.
Performance
The test suite measures 100 and 1,000 Markdown files on Windows. On the reference run, 100 files planned in about 0.5 seconds and applied in about 2.7 seconds. 1,000 files planned in about 5.9 seconds, transformed in about 75 milliseconds, applied in about 19 seconds, and rolled back in about 7.7 seconds. These numbers include the normal manifest pipeline and Content Mutation journal writes; they are not a promise for every machine.