[ AxiomCore · Documentation ]
Migrations and persistence
Apply, change, roll back and promote versioned database migrations, then connect an Acore backend through typed storage declarations.
This guide takes a database contract from its first migration to CI promotion,
then connects an Acore backend to its tables. You need a contract that passes
axiom database check (see Database contracts) and a
target: a SQLite file, or a reachable PostgreSQL 17 or 18 server. Run the
commands from the project root.
Apply and inspect a migration
axiom database migrate database/schema.acore --database-url "$DATABASE_URL"
axiom database status database/schema.acore --database-url "$DATABASE_URL"
axiom database history database/schema.acore --database-url "$DATABASE_URL"unset DATABASE_URL
axiom database migrate database/schema.acore --database-file ./tasks.sqlite
axiom database status database/schema.acore --database-file ./tasks.sqlite
axiom database history database/schema.acore --database-file ./tasks.sqlitemigrate moves the target up to the working version. It validates the change
on a disposable copy, then applies schema and history together under a writer
lock. axiom run does the same and then opens the SQL console.
--to VERSIONstops at a retained forward version. Missing intermediate definitions must come from the committed catalog or an exported bundle.--dry-runpreviews without applying, and without creating a missing target.--jsonprints machine-readable results to stdout. It is optional; no JSON file is part of the project.statusreports the applied and working versions, pending changes and drift.status --checkexits non-zero (ADB1105, "database is not current") when a change is pending or drift exists, which makes it a CI gate.historylists migrations, compensating rollbacks and failed attempts. It can recover missing local definitions from the target's verified ledger.status --watchandhistory --watchrefresh every 2 seconds until you press Ctrl+C.- Read-only commands never enroll or migrate a target.
Change the schema
-
Edit the schema and raise
project(version: ...), for example from1.0.0to1.0.1. If you forget,migratefails with ADB1103 and suggests a version; press Enter in a terminal to accept it. -
Compare before applying. The two-file form works offline; the
--fromform reads versions from the local catalog thatmigratewrites:Terminal: project root axiom database diff database/schema.acore --from 1.0.0 --to working axiom database diff database/schema.acore --from 1.0.0 --to 1.0.1 -
Apply with
axiom database migrate, then checkstatus.
A required new column on populated rows needs a default, a nullable expansion or a typed backfill. Axiom rejects the change before running target DDL if values are missing.
Typed data changes
Reference a private evolution document from the database entry file:
migration(source: "migration.acore")// After adding a required note column
evolution fill(from: Previous, to: Current,
coordination: EvolutionCoordination.maintenanceFenced) {
backfill(table: Current.Tables.app.tasks,
key: Previous.Columns.app.tasks.id,
column: Current.Columns.app.tasks.note,
using: value(literal: ""), batchSize: 100,
batchesPerRun: 1, maxRows: 1000, catchUpRows: 1000)
}- The developer profile supports bounded copy and literal backfills under one
atomic writer fence (
maintenanceFenced), up to 10,000 rows. - The transition intent counts toward version uniqueness, even for a data-only change.
migratecompiles the document against the previous release and recompiles source, schema and intent from the signed snapshot. Offlinecheckandbuilddo not compile it, so errors appear at migrate time as ADB1121.- Both engines validate before applying; SQLite also validates a populated backup.
- Larger or resumable strategies, reference-data synchronization, explicit conversions and nontransactional index or enum stages keep their certified adapters. This atomic profile rejects them with ADB1121.
Remove the migration(...) line when preparing the next schema release, so the
data intent does not repeat. The earlier release keeps its original source and
intent.
Roll back
axiom database rollback database/schema.acore --database-url "$DATABASE_URL" --steps 1
axiom database rollback database/schema.acore --database-url "$DATABASE_URL" --to 1.0.0- Rollback publishes a new compensating version. It keeps every earlier
entry and leaves your working source unchanged. A rollback that removes
objects is suggested as a new major version, such as
2.0.0; reapplying later may become2.0.1. --stepsdefaults to 1 and cannot be combined with--to.--to emptyrestores the managed empty baseline, again as a new version.- Running
migrateafterward may need another fresh version to reapply the working source. - Dropped values are not reconstructed. A rollback across a data overwrite without stored previous values fails with ADB1113; write a compensating migration or restore a backup.
- In CI, pass
--auto-versionso rollback can allocate its version, plus the approval flags for the categories it changes.
Prepare once, promote unchanged
axiom database migrate database/schema.acore --database-url "$CI_DATABASE_URL" \
--auto-version --non-interactive --export release.axiom
axiom database migrate release.axiom --database-url "$STAGING_DATABASE_URL" \
--frozen --non-interactive
axiom database status release.axiom --database-url "$STAGING_DATABASE_URL" --check- The authoring job allocates a fresh version only when content changed, and records the source edit and catalog entry for review. Unchanged content does not bump.
--exportwrites the complete applied history as one bundle. A frozen bundle cannot be rewritten or auto-versioned (ADB1103 without a prepared release).- Promote that exact bundle to every other eligible target. Never allocate versions separately per environment.
- Add
--allow-destructive,--allow-data-dependentor--allow-security-changesonly for categories you reviewed. CI never prompts or waits for credentials or version approval.
Serialize authoring jobs across checkouts with your CI system's concurrency
controls. A project lock stops concurrent local publishers (ADB1104), and the
target's lock and parent check stop divergent acceptance. Signing keys come
from Axiom's private signing store or AXIOM_DATABASE_SIGNING_KEY (a 32-byte
hex key) injected by your CI secret provider. Enrolled targets require the same
signer. Commit the source edit and the non-secret .axiom/database/migrations/
catalog, or keep the exported bundle as an immutable CI artifact. Never commit
keys or connection bindings. Compiled schema packages from build are offline
artifacts and are not migration bundles.
Connect a backend with typed storage
A backend declares a resource interface, then a storage declaration
implements each operation against a table in the compiled schema. This excerpt
is from the task app:
resource tasks(lifetime: ProviderLifetime.application) {
operation list(input: Models.Empty, output: List<Models.Task>, effect: EffectKind.read)
operation create(input: Models.NewTask, output: Models.Task, effect: EffectKind.write,
errors: [Failures.duplicate])
operation delete(input: Models.RecordId, output: Models.Task, effect: EffectKind.delete)
}storage tasks(schema: "../database/sqlite/schema.acore", resource: Resources.tasks) {
select list(table: "main.tasks", fields: {id: "id", title: "title"}, limit: 100)
insert create(table: "main.tasks", values: {id: Input.title, title: Input.title},
fields: {id: "id", title: "title"}, constraint: "tasks_pkey",
failure: Failures.duplicate, message: "That task already exists")
delete delete(table: "main.tasks", equals: {id: Input.id}, fields: {id: "id", title: "title"})
}For PostgreSQL, point schema: at the PostgreSQL contract and use tables such
as app.tasks. Both engines share the same closed, typed operation interface.
The storage mapping stays private: frontend contracts contain only the public
API surface.
A binding entry associates a model and a browser store with a table. The
browser preview uses bindings for its field and
table associations:
binding rows(table: "app.tasks", model: Models.Task, store: "tasks",
fields: {id: "id", title: "title"})Rules:
select,insert,updateanddeletename a logical table and map API fields to columns. Values come fromInput.field, trustedClaims.fieldor literals. SQL text and arbitrary query builders are not accepted.- Inputs are bound parameters. The tenant comes from the authenticated identity and is added to every generated predicate and write.
- Field types, resource signatures, effect bounds, primary keys and tenant
columns are checked against the compiled schema when you build or run the
backend (ADB1000 for an unknown table or column). In CLI 0.149.1,
axiom checkon the backend does not run this check. constraint,failureandmessageturn a named constraint violation into a declared public failure, such as HTTP 409 for a duplicate.- The host enforces limits, deadlines, response bounds, result validation and transaction rollback. A supported serialization conflict retries the whole transaction within the declared attempt bound. An ambiguous commit is reported, not retried blindly.
- Readiness checks reject drift or an unapplied schema.
- On PostgreSQL, application tables use forced row-level security and an
unelevated application login role, declared with an opaque
credentialRef.
An endpoint does nothing on its own: it selects an action with
handle(action: ...), the action awaits the resource operation (for example
Resources.tasks.delete(input: {id: Input.id})), and the storage declaration
implements it. The operation must declare EffectKind.delete, and the
endpoint declares its response, failures and list-cache invalidation. The
native provider binds the ID and adds the authenticated tenant predicate.
Naming an endpoint delete_task does not create deletion logic.
Run the backend
axiom run backend/main.acore --database-url "$DATABASE_URL" --port 8093
# SQLite variant:
axiom run backend/sqlite.acore --port 8093axiom run frontend/main.acore --port 5173During development startup the CLI compiles the private plan and migrates the
linked database before accepting requests. The backend prints a development API
key for the local UI. Private bindings and the key registry are generated under
.axiom/ with owner-only permissions. No hand-written runtime-spec or
deployment JSON is needed for this path. A production deployment supplies its
own reviewed authentication, signer and provider policy.
Advanced transactions
A storage declaration can also contain transaction blocks with explicit
steps, a result, isolation, a retry bound, an idempotency receipt and
constraint failures:
storage tasks(schema: "../database/schema.acore", resource: Resources.tasks) {
transaction read(readOnly: true, isolation: "serializable", maxAttempts: 3, result: "task") {
select task(table: "app.tasks", fields: {id: "id", title: "title"},
equals: {id: Input.id}, limit: 1, many: false)
}
}This advanced profile, together with batch and savepoint strategies and special codecs, needs separately qualified deployment configuration. The ordinary development adapter reports a capability diagnostic for it. A successful local run does not qualify advanced transaction guarantees; start with ordinary select, insert, update and delete operations.
Source preview: virtual selected models
The current source build adds authoring shortcuts, including resolved endpoint types, explicit failure inference, selected offline model derivation and immutable expressions. Explicit forms remain supported, and matching compiler, host and editor versions are required. Public release and target qualification are separate. See Syntax.
Next steps
- Try the flow in the browser with the browser preview.
- Build the full task app.
- Add database checks to CI.
- Roll out to many tenants with database fleets.