Data Migrations
Between module releases the expected data format can change: an asset CSV gains
columns, a new CSV appears, an input JSON changes shape. The workspace data directory
is user-mutable and is deliberately never overwritten by rescile mod sync — so a
module upgrade alone leaves old-format data in place. Data migrations close that gap:
- Modules own the migration logic for the release that changes the data schema.
- The controller owns detection, ordering, execution, and the startup gate.
- The packager only ships migration files — it never interprets or runs them.
Data format is tracked separately from both the module version and
rescile_version, because both are unreliable witnesses: two module releases can
carry the same version while changing data shape.
For concrete recipes — “a new column is added”, “a column must be renamed”, “one column must be split into two” — see Migration Examples.
Declaring the data schema
Each module declares the data format it expects with an integer:
# module.toml
[module]
name = "app-runtime"
version = "0.0.2"
data_schema = 2 # integer; absent means 1 (all pre-migration modules are format 1)
Bump data_schema in the release that changes the format, and ship the matching
migration steps with that release. The workspace root module participates in the
same mechanism: a workspace can declare its own data_schema and ship migrations/
next to its module.toml (authored by the workspace owner, not a package), which is
how user-owned data that no dependency bootstrapped gets migrated.
Migration files
Migrations live in the module package under migrations/ — one TOML file per
step, with the step’s Rhai script (when it has one) co-located under the same
stem:
migrations/
├── 001-instance-fields.toml # ops step: declarative [[op]] column operations
├── 002-server-csv.toml # script step: metadata + script reference
├── 002-server-csv.rhai # the script itself
└── 003-cleanup.toml
A module ships one file per step — each release appends new step files and
never edits old ones (the same discipline as Rails’ db/migrate/ and Django’s
numbered migration modules, so the per-release diff shows only additions). The
step id is the file stem, and each file declares the schema range it covers
plus — critically — the targets: the only files the step is allowed to
touch.
# migrations/001-instance-fields.toml
from = 1
to = 2
targets = ["instance.csv"]
description = "Add retention_days and backup_policy columns, fill defaults"
# ...the step's body: [[op]] column operations, or a script reference.
A step is either a list of declarative column operations or a Rhai script — never both:
# migrations/002-server-csv.toml
from = 2
to = 3
targets = ["server.csv", "config.json"]
description = "Introduce server.csv, move zone keys in config.json"
script = "custom-name.rhai" # omitted: defaults to the co-located 002-server-csv.rhai
Validation rules enforced by the controller:
- Steps must chain contiguously per module (
1 → 2,2 → 3, …) and have unique IDs (unique file stems). targetsare root-relative, exactly like script paths:instance.csv(andsubdir/instance.csv) resolve underassets/,config.jsonunderinput/. Writing the root explicitly ("assets/instance.csv") is rejected — it would aim atassets/assets/instance.csv. Only.csvand.jsonfiles can be targets.- Forbidden as targets (validation error): the vault secrets file,
action_queue.jsonand state-dir files,module.toml,rescile-lock.toml,bootstrap-state.json,migration-state.json, anything outsidedata/, and files the owning module’s generators write (target_asset/target_inputoutputs — generator output is derived state; fix the generator, don’t migrate its files). - No two modules may declare the same target — shared files must move into the owning module.
Multi-step releases add one step file per schema hop; the chain is validated
across files (each step’s from must equal the previous step’s to):
migrations/001-instance-fields.toml # from = 1, to = 2
migrations/002-server-csv.toml # from = 2, to = 3
migrations/003-cleanup.toml # from = 3, to = 4
What belongs in a migration — and what does not
Migrations are structural recipes, not content scripts:
| Change class | Mechanism |
|---|---|
| New columns, renames, structural derivations, structural defaults | Migration steps (prefer [[op]] entries) |
| New files — new resource types arriving with a release | Starter data via mod sync (Once semantics copies them) |
| Business cell values — policies, checksums, extra rows, cleanup | Workspace-owned content. A release-level default belongs in the module.toml [assets] column definition, not in the migration. |
A step that hardcodes per-row business values to make a diff look byte-exact is a smell: derive what follows from the data itself, add the rest of the columns empty, and let the workspace owner (or the importer’s column defaults) fill the content.
The full step vocabulary — column operations and the sandboxed script API — is documented with one example per use case in Migration Examples.
Migration workflow
# 1. Stop the controller and any other writers (runners can keep idling).
systemctl stop rescile-ee
# 2. Back up the workspace data directory (migration also keeps per-step backups,
# but a full copy is the safe baseline).
# 3. Plan: show the steps that would run, never touching data.
rescile-ee migrate --data-dir ./data --check
# 4. Apply, verify, and record.
rescile-ee migrate --data-dir ./data
# 5. Start; the controller re-validates schema compatibility on startup.
systemctl start rescile-ee
Note: The
migratesubcommand ships in every component that reads data assets —rescile-ce,rescile-ee, andrescile-controller— all sharing one implementation. Substitute the binary you deploy in the examples above. The service binaryrescile-controllerexposes the same flow as mode flags instead of a subcommand:rescile-controller --artifact-url <url> --migrate-check --migrate-data-dir ./data rescile-controller --artifact-url <url> --migrate-apply --migrate-data-dir ./data
--check prints the plan — module, from → to, step IDs, the actual column
operations or script paths — and exits non-zero when migrations are required,
so it can gate deploy pipelines.
The controller never migrates silently on startup — an incompatible data format is
instead a hard error, and serve, save, and validate refuse to run until
migrate has been applied. An existing deployment with no
migration-state.json and a module requiring schema > 1 is also refused — the
starting schema is unknown, so the controller does not guess. Record it explicitly:
rescile-ee migrate --data-dir ./data --baseline 1
--baseline records the starting schema and immediately applies the steps from
there, so a stopped workspace migrates in one invocation. The same flags exist on
rescile-ce (migrate subcommand) and rescile-controller
(--migrate-baseline).
What apply does per step
- Acquires an exclusive lock on
migration-state.json(concurrentserveormigrateis refused). - Copies every target to
data/.migrations/backup/<module>/<step-id>-<timestamp>/, preserving permissions. Backups older than 30 days are pruned at the start of each run (--keepto retain everything). - Runs the step — the compiled column operations, or the Rhai script, with file access restricted to the declared targets.
- Verifies outputs parse (CSV shape, JSON validity).
- Records the step in
migration-state.jsonatomically.
After the last step, the controller runs a full graph build. On failure it reports the step and the backup path — there is no automatic rollback; restoring is a plain copy from the backup directory.
The execution record
Applied steps are tracked in data/migration-state.json, separate from
bootstrap-state.json (which tracks packaging, not data format):
{
"version": 1,
"modules": {
"app-runtime": { "data_schema": 2, "applied": ["001-instance-fields"] }
}
}
Crash recovery is re-planning: recorded steps never re-run; the interrupted step runs again. Declarative steps are idempotent by construction; script steps must be written idempotently (see the examples).
Startup gate
Every component that reads data assets — rescile-ce, rescile-ee, and
rescile-controller — compares the schema each module requires against the recorded
state during serve, save, and validate, and fails closed when a migration is
outstanding (the message names the component printing it):
ERROR: workspace data for module 'app-runtime' is at schema 1, module requires 2.
Run: rescile-ee migrate --data-dir ./data --check
rescile mod add and mod update warn when a newly resolved dependency requires
a data migration.
Ordering, downgrades, and derived data
- Ordering: modules migrate in lockfile dependency order (dependencies first); steps within a module run strictly in sequence.
- Downgrades: migrating down is not supported. Rolling a module back to an older
release means restoring data from the step backups or re-pinning an older lockfile
—
migrate --checkreports the conflict instead of running destructive steps. - Derived data: saved graph artifacts are invalidated, not migrated — the graph is rebuilt from migrated data on the next serve.
Safeguards summary
| Safeguard | Behavior |
|---|---|
| Blast radius | A step can only touch its declared targets; enforced by the scripting sandbox. |
| Locking | Exclusive lock during migration; concurrent writers refused. |
| Backups | Per-step, timestamped, permissions preserved; --no-backup opt-out; auto-pruned after 30 days (--keep disables). |
| Fail closed | Unknown starting schema or outstanding migration blocks startup. |
| Logging | Step IDs and file paths only — never cell values, never secrets. |
| Forbidden files | Vault, queue, lockfile, and controller state can never be targets. |
Note:
rescile mod syncis unchanged — it still bootstraps new installs with starter files and never overwrites existing data. Upgrading the data of an existing workspace is exclusively the job of migrations.rescile mod updatewarns when a dependency update requires a data migration.