Migrations & Upgrades
You change your models; you never write a migration. Running fullfinity-server -u all
upgrades a database across any number of versions safely (it never loses data and
never half-applies) and painlessly (no hand-written migration scripts for the common
case, and custom modules survive core changes).
The guarantee is mechanical, not a matter of discipline: the schema diff is incapable of
destroying data, a gate blocks silent breaking changes before they merge, and every
upgrade runs in one transaction. (For the full rationale, see
MIGRATION_UPGRADE_DESIGN.md at the repo root.)
What -u all does
Section titled “What -u all does”A single -u all runs one atomic transaction — everything below commits together or
rolls back together, so a failure never leaves a half-migrated database:
- Additive schema sync. New columns, tables, and indexes are added; safe in-place
ALTERs are applied. The diff never drops a column or table — so a field that merely disappeared from the code can’t silently destroy data. - Ledger replay. Recorded schema changes are applied in order, per installed module: a rename copies the old column’s data to the new column, then drops the old column; a delete drops its column; a complex change runs its transform hook.
- Commit (or roll back the whole thing on any error).
So a rename neither loses data nor leaves clutter behind: it carries, then drops, in the same command. There is no separate “cleanup” step to run.
Reads during an upgrade tolerate the old schema
Section titled “Reads during an upgrade tolerate the old schema”An upgrade necessarily runs against a database whose schema is behind the code — that is what it is there to fix. But the upgrade is itself an application that reads records (the module list, the system user, category and view records), and an ORM read normally selects every column the model declares. On a database that predates a newly-declared field, such a read names a column that doesn’t exist yet.
So for the duration of a module operation, reads select only the columns the database
actually has. This is automatic and covers the whole operation, first statement to last —
you don’t opt into it, and adding a field to any model, User and Group included, needs
no special handling: declare it and run -u all.
This matters more than it looks, because Postgres aborts the entire transaction on the
first failed statement. One untolerated read would take the upgrade down and every statement
after it would fail with current transaction is aborted — naming whichever innocent
statement ran next, not the one at fault. If you ever do see that error, the message now
carries the originating statement and its real error alongside it; that first error is the
only one worth reading.
The additive sync is also self-healing against a stale table. If a physical table survives
from an earlier failed or rolled-back install while the framework’s metadata considers the
model new, the sync no longer trusts CREATE TABLE IF NOT EXISTS to be enough — it detects the
pre-existing table and reconciles its columns to the model (adding any that are missing, still
additive-only). So a leftover table can never leave a newer column absent and crash the next
query on it; the next -u all (or module install) brings the physical schema back in line.
Upgrading every database in one command
Section titled “Upgrading every database in one command”-u all targets one database, named with -db. On a multi-tenant deployment you can
instead pass --all-databases (alias --all-dbs) to run the same operation against every
Fullfinity database this deployment owns — discovered automatically, no list to maintain:
fullfinity-server -c config.yaml --all-databases -u all--all-databasesand-dbare mutually exclusive — a missing-dbstill errors rather than silently upgrading everything, so the fleet-wide run is always a deliberate choice.- Disposable databases are skipped. Ephemeral
testdatabases and hand-madedev_scratch_scratch databases are never fleet-upgraded. - Each database upgrades in its own atomic transaction. A failure on one database is reported and the remaining databases still upgrade; a per-database pass/fail summary is printed at the end, and the command exits non-zero if any database failed.
The same flag works for any module operation (--install, --update/-u, --uninstall,
--init, --update-module-list) and for the upgrade alias.
Uninstalling a module drops what it brought
Section titled “Uninstalling a module drops what it brought”The additive-only rule protects code changes — an ambiguous diff must never destroy data. A module uninstall is different: it’s an explicit, intentional removal, so it drops the schema the module brought in. On uninstall the framework drops
- the tables of models the module owns, and
- the columns it added to other modules’ tables via
__inherit__(a bridge’s contributions),
inside the uninstall’s transaction, then updates the parent modules to their un-extended state.
This keys on field.module / modelregistry.module — each field is attributed to the module
that introduced it (a base declaration, or the __inherit__ extension that added it), so a
bridge’s columns are removed with the bridge, not left orphaned. Reinstalling recreates them.
No schema-change ledger entry is involved — the ledger records code history; an uninstall is a
runtime action whose safety net is the transaction plus the pre-op snapshot.
Selection choices that stop being offered are reconciled for you
Section titled “Selection choices that stop being offered are reconciled for you”A Selection’s choice set is composed from the base declaration plus every installed module’s
_selection_add. That set can shrink three ways, and every one of them leaves rows holding
a value the field no longer declares — the next write of that field fails validation, with no
legal value the user can pick instead:
- a module contributing choices is uninstalled;
- the base declaration is edited in code, dropping a choice it used to offer;
- a module is installed whose composition narrows the set some other way.
You do not write a migration for any of them. The composed set is persisted onto the field
registry, and each upgrade compares the freshly composed set against it. Whatever is missing is
reconciled by the policy the contributing module declared with
_selection_ondelete
— cleared, or moved to a named replacement. The policy is read from the stored baseline rather
than from the class, because the module that declared it is usually the one that just left.
Detection deliberately compares outcomes rather than watching module operations: the case that strands data most often is a base declaration edited in code, where no module is installed or uninstalled at all and a lifecycle hook would never fire. A database upgraded from before the baseline existed has nothing to diff against, so on that one upgrade the stored data itself is scanned and any value the field no longer offers is reconciled the same way.
A required Selection with no recorded policy is the one case left untouched: blanking it would swap one validation failure for another, and choosing a replacement would be a guess about business meaning. Those rows are reported in the upgrade log. Contributing a choice to a required field without declaring a policy is refused when the registry is composed, so this can only arise for a value that predates the rule.
There is no way for a module to remove a choice from another module’s field — see Selection Field Inheritance.
The append-only rule (and the gate)
Section titled “The append-only rule (and the gate)”Existing stored fields may not be renamed, retyped, or removed without recording the
change — and, because a Selection stores its choice text verbatim (the value shown in
the dropdown IS the value written to the column), an existing Selection choice may not be
renamed or removed without recording it either. Adding a choice is free (a wider dropdown,
no data impact); renaming or dropping one orphans every existing row that holds the old value,
so it goes through the ledger like a field change. This is all enforced by a static gate, so
you can’t ship a silent break:
Lengthening a column is additive. Growing a field’s max_length — a wider Char, or
renaming a Selection choice to a longer string — is a safe in-place VARCHAR(n) → VARCHAR(m>n)
that -u all applies automatically; the gate treats it as additive, not a retype. Only a
shrink (a smaller max_length, which could truncate or reject stored values) is breaking and
must go through resolve. (A Selection-choice rename still records a rename_selection
entry to carry the stored text — that’s the value change, independent of the column width.)
fullfinity-server check --only schemacheck --only schema composes the field surface from code (no database needed) and diffs each
module against the committed baseline that lives inside that module
(<module_dir>/schema_baseline.json). It exits non-zero if any existing field a module owns
was removed, renamed, or retyped, if an existing Selection choice was renamed or removed —
or if a complex change ships without a fixture test. It runs in CI and as a pre-commit hook:
git config core.hooksPath .githooks # enable the local pre-commit gateYou never have to remember any of this — if you rename a field, the gate fails the build and tells you to resolve it.
Trustless across releases. The committed schema_baseline.json is what the gate diffs
against within a working tree — but a baseline is a file a change can re-snapshot, so the gate
also runs a second, trustless comparison keyed on the immutable git release tag. Two
checks, both reading the state at the last release tag (which an ordinary commit can’t rewrite):
- Surface — it reads each module’s baseline as it shipped
(
git show vX.Y:<module>/schema_baseline.json) and requires every field, model, orSelectionchoice that was in that release but is gone now to be named by a ledger entry (delete/rename/complex/delete_model/…). Soresolve --snapshot-ing the baseline to hide a removal no longer passes — the removal is caught against the tag. - Ledger — it reads the ledger as it shipped (
git show vX.Y:<module>/schema_changes.yaml) and requires it to be a prefix of the current ledger: every released entry still present and byte-identical, only new higher-id entries appended. Deleting or editing a past entry (which would silently break-u allreplay for anyone upgrading from an older version) fails the build.
The view-anchor and report-anchor gates use the same release-tag ratchet
(view_anchor_baseline.json, and the report bodies themselves). Before the first release tag
exists there is no frozen surface, so pre-release the surface churns freely.
A ledger id, though, is protected from the moment it exists — tag or no tag. The release ratchet above only speaks at tagging time, and databases are upgraded from the tree continuously, so an id can be applied to a real database and then deleted again entirely inside one release window. That deletion is not a tidy-up; it is a break, and it lands on whoever already ran it:
A database’s replay clock is a high-water mark, not a set of applied ids. Meet a module whose highest id is lower than the mark and the upgrade reads as a downgrade and refuses — not just that module, the entire run. The database stops upgrading until the entry comes back.
So each module’s baseline records every ledger id it has ever carried (change_ids in
schema_baseline.json), and check --only schema fails while any recorded id is missing from
schema_changes.yaml. That record is append-only too: resolve --snapshot unions ids
rather than replacing them, so re-snapshotting cannot quietly forget an id you just deleted.
If a migration turns out to be unnecessary, leave the entry in place. Replay skips what a database has already done, and a hook that is a no-op costs a fresh install nothing.
What the guarantee rests on. The gate logic is trustless — no commit that passes it can hide a breaking change. But a release-tag ratchet is only as immutable as the reference it reads, so the guarantee assumes three repo-governance controls (configure these once; no gate can enforce them from inside the code):
- Release tags are created only on CI-green commits, and tag creation is restricted. The tag is the reference; if an arbitrary or unverified commit is tagged, the reference is dishonest.
- Tags and the main branch are protected from force-push / history rewrite. A trustless ratchet assumes its reference history can’t be moved.
- CI runs as a required, server-side status check — not just the local
.githookspre-commit (which--no-verifybypasses). The check must be un-skippable on the protected branch.
With those in place the chain is complete: the surface and the ledger can’t change a released
contract without a recorded, replayable entry, and the record itself can’t be rewritten. A
malicious-but-recorded change (e.g. a deliberate delete) still surfaces at the customer’s
upgrade via preview --db — accountable, never silent. If you run your own checkout with custom
modules, the same three controls make the guarantee hold for your modules’ baselines and ledgers.
Per-module ledger & baseline (and third-party modules)
Section titled “Per-module ledger & baseline (and third-party modules)”Both the change ledger and the baseline are per-module files that travel inside the module’s own directory:
| File | What it is |
|---|---|
<module_dir>/schema_changes.yaml | this module’s append-only change ledger (ids monotonic within the module) |
<module_dir>/schema_baseline.json | this module’s committed field-surface contract (only the fields/models it owns) |
A field a module adds to another module’s model via __inherit__ is attributed to the
adding module, so it lives in that module’s baseline — renaming or deleting it is checked
against your contract, not the model owner’s. Because each module carries its own files:
- Third-party modules work. A module distributed through an app store records and ships
its own sanctioned breaking changes. Its ids never collide with another publisher’s (they
start from 1 within the module), and the engine resolves its baseline/ledger relative to
wherever the module is installed (
Module.path) — not a hardcoded path — so a customer’s folder layout is irrelevant. - Enforcement reaches modules that skip your CI. Third-party publishers don’t run your
CI, so the gate also runs at upgrade time on the customer’s machine:
-u allrefuses to upgrade a non-first-party module whose shipped code diverged from the baseline shipped in its own package with no recorded migration — or that ships a stored surface but no baseline at all (silent absence → a loudConfigurationError). For the app store itself, runcheck --only schema --module <id>on each submitted package at intake.
Scope any of the three commands to one module with --module <identifier>:
fullfinity-server resolve --snapshot --module acme_billing # (re)write just that baselinefullfinity-server check --only schema --module acme_billing # gate just that module (intake)The first-party product baselines stay stable no matter what custom modules a checkout
contains — partitioning isolates each module’s surface, so a custom module can never shift core’s
baseline.
The version bump is enforced (the check --only version-bump gate)
Section titled “The version bump is enforced (the check --only version-bump gate)”The append-only gate guarantees a breaking change is recorded; a second release-time gate guarantees the release is labelled correctly. Because every change is already recorded — a break as a ledger entry, an addition as new surface in the baseline — the minimum legal SemVer bump is derivable, not a human’s guess:
| Change since the last release | Minimum bump |
|---|---|
| bug fixes only, no schema change | patch |
new fields/models (additive), or a data backfill | minor |
any delete/rename/retype/Selection change/complex/removed view anchor/removed module | major |
At release time (a pushed vX.Y.Z tag), check --only version-bump composes the field surface
in-process, reads the previous release tag’s baselines and ledgers via git show, classifies
the delta, and refuses the tag when:
- the tag disagrees with the code’s own
__version__(the single source of truth infullfinity/engine/__init__.py, kept in lockstep withfullfinity.__version__), - the version is a downgrade from the previous release, or
- the bump is smaller than the change class demands (e.g. a release that drops a column tagged as a patch).
This is what lets an auto-updater trust the label: a patch can never contain a migration, so it
is safe to apply without the schema-change ceremony a minor/major needs. The gate runs only at
release time (commits on main don’t move the version), keyed on the immutable release tag — the
same trustless reference the append-only checks use. It does not cover pure code-level API
breaks (a changed method signature, a reshaped REST payload) — those touch neither the ledger nor
the baseline and remain a manual judgement.
Recording a change: resolve
Section titled “Recording a change: resolve”When the gate flags a change, record it:
fullfinity-server resolve # interactivefullfinity-server resolve --assume-renames # auto-record obvious renamesFor each flagged field it asks one question — rename, delete, or complex — and for a
flagged Selection choice it asks rename-value or complex. It writes an entry to the
change ledger (schema_changes.yaml), then re-snapshots the baseline so the gate goes green:
Non-interactive (agent / CI / automation)
Section titled “Non-interactive (agent / CI / automation)”An interactive prompt is useless to an agent, CI job, or a third-party publish pipeline — there’s no TTY to answer it. Declare the intent as data instead; each flag is repeatable:
fullfinity-server resolve \ --rename SaleOrder.partner=customer \ # field rename: carry data, drop old column --delete SaleOrder.legacy_note \ # sanctioned field deletion --complex Invoice.amount=mod.path:fn@tests/fixtures/amount.py \ # transform: hook@fixture --rename-choice SaleOrder.state:Quotation=Draft \ # Selection choice rename/fold: carry values --clear-choice SaleOrder.state:Legacy \ # NULL the rows on a removed choice --complex-choice Invoice.state=mod.path:fn@tests/fixtures/state.py \ # custom remap of orphans --rename-model OldName=NewName \ # model/table rename --delete-model DeadModel # sanctioned model deletionResolution order is: an explicit flag → --assume-renames (obvious single-candidate
renames) → interactive prompt (TTY only). Non-interactive resolution is atomic: if any
flagged change is left undeclared, the command records nothing and exits non-zero, listing
exactly what to declare — so a forgotten decision fails loudly instead of silently
half-resolving (which would either bake an undeclared removal into the baseline or duplicate
a ledger entry on re-run). This is the path Claude/CI use; the same check --only schema gate
still verifies the result.
- rename → the new field name; replay will carry the data.
- delete → the column is dropped on upgrade (the pre-upgrade backup + the atomic transaction are the safety net).
- rename-choice → carry every row that held the old choice text to a new value: replay
runs
UPDATE … SET field = new WHERE field = old. Deterministic, no hook. The target only has to be a current choice, so this also folds a removed choice’s rows into a surviving one (e.g.--rename-choice Order.state:Cancelled=Draft). - clear-choice → for a removed choice whose orphaned rows should become NULL rather
than fold into another value: replay runs
UPDATE … SET field = NULL WHERE field = old. Also deterministic and hook-free (records aclear_selectionentry). - complex-choice → when the orphaned rows need a conditional / row-specific disposition (split by another column, delete the rows), supply a transform hook + fixture (as with complex). One hook covers the whole field.
There is deliberately no “remove the choice and leave the rows as-is” option — that is the silent-orphan bug this gate exists to prevent. Every removal must state where its rows go (fold, NULL, or a hook).
- delete-model → the table is dropped on upgrade. Replay tries a plain
DROPfirst, so a surviving table that still references the model surfaces as a hard error rather than a silent constraint drop. When you delete several related models together (e.g. a parent and its child line), record them in any order: if the ledger happens to drop the parent before the child whose foreign key points at it, replay retries that one drop withCASCADE— which on aDROP TABLEonly removes the dependent constraint, never the child table’s rows, and the child is dropped by its own entry moments later. - complex → a transform hook (
module.path:fn) plus a fixture test that proves it. The gate refuses a complex change without its fixture.
There is also a data entry for a one-time data pass that isn’t triggered by a schema
change (a backfill, a storage reshape) — a hook (module.path:fn, e.g. in
modules/<m>/migrations.py) + a fixture, replayed once per DB via the id clock. This is the
home for what used to be a post_upgrade hook. There is one mechanism: the version-keyed
upgrades/v{X}_{Y}.py / pre_upgrade/post_upgrade path has been retired, and
fullfinity-server upgrade is just an alias for -u all.
Writing a complex transform
Section titled “Writing a complex transform”A complex hook is async def fn(env) that replay calls inside the upgrade transaction.
For the common cases — changing a column’s type, or remapping values / a foreign key —
use the set-based helpers in fullfinity.engine.migration_helpers (they scale to large
tables and are one line, instead of hand-written DDL):
# invoicing/migrations.py (a complex/data ledger entry references it as "module.path:fn")from fullfinity.engine.migration_helpers import retype_column, remap_column
async def quantity_to_integer(env): # Change a column's type, transforming existing values (ALTER ... TYPE ... USING). await retype_column(env, "InvoiceLine", "quantity", "integer", using="round(NULLIF(quantity, ''))::integer")
async def retarget_partner_fk(env): # Re-map a foreign key by matching a natural key (set-based UPDATE). await remap_column(env, "Order", "partner", using="(SELECT id FROM contact c WHERE c.legacy_ref = \"order\".partner_legacy)")Then record it and write the fixture:
fullfinity-server resolve # choose 'complex', point at the hook + a fixture testThe fixture seeds representative rows, runs the transform, and asserts the output — the
input→output oracle the gate requires. (See
fullfinity/modules/core/tests/test_schema_replay.py for worked examples of both helpers.)
using/whereexpressions are interpolated into SQL and must be trusted, code-authored — never user input.
Turning a value into a relation (or back)
Section titled “Turning a value into a relation (or back)”A scalar and a relation don’t share a column: a Char named service is stored as
service, while a ManyToOne named service is stored as service_id. So changing a
field between the two is not an in-place type change, and your hook must not try to
retype_column it.
The upgrade adds the new column (with its index and foreign key) in the additive pass and leaves the old one in place, so by the time your hook runs both columns exist. Read the old one, write the new one, then drop the old one yourself:
async def service_codes_to_records(env): conn = env.conn rows = await conn.fetch( "SELECT id, service FROM shippingmethod WHERE service IS NOT NULL AND service <> ''") for row in rows: record, _ = await get_model("CarrierService").get_or_create(code=row["service"]) await conn.execute( "UPDATE shippingmethod SET service_id = $1 WHERE id = $2", record.id, row["id"]) await conn.execute("ALTER TABLE shippingmethod DROP COLUMN IF EXISTS service")Guard the read on the old column still existing (information_schema.columns) so the hook
is a no-op on a database created after the change, and record it with
resolve --complex like any other transform.
If your hook runs its own DDL, invalidate the statement cache
Section titled “If your hook runs its own DDL, invalidate the statement cache”The database driver caches a prepared plan per query text, and Postgres invalidates the plan
of any query whose table changed shape — reported on the next run of that same query text.
Outside a transaction the driver quietly re-prepares and retries; inside one it cannot, and
the error aborts the whole upgrade. Replay runs your hook inside the upgrade transaction,
so a hand-written ALTER/DROP followed later by a query against that table can take the
upgrade down with cached statement plan is invalid.
The helpers above already handle this. If you issue DDL yourself, call the invalidation right after it:
from fullfinity.engine.db import invalidate_statement_cache
async def add_a_partial_index(env): await env.conn.execute("CREATE INDEX CONCURRENTLY ... ") # your own DDL await invalidate_statement_cache(env.conn) # shed the stale plansIt is cheap and idempotent — calling it after every DDL statement is the correct habit. Pure
data changes (UPDATE/INSERT/DELETE, including remap_column) don’t need it; only
statements that change a table’s shape do.
Each module’s ledger is keyed by a monotonic change id (within that module), not a
version — so a field renamed several times across many commits replays correctly, and a
database jumping many versions at once applies exactly the changes it’s missing, in order.
Each database tracks how far it has applied per module (Module.schema_applied_id, a
column on the installed-module row), so changes are never re-applied, and a module that
isn’t installed simply skips its entries. The same per-module mark is what makes many
databases on one server work cleanly: they share the module’s one ledger file but each
advances its own mark independently.
A fresh install replays no history
Section titled “A fresh install replays no history”Replay only ever moves a database forward from where it already is. A brand-new install is already at the head: every table is built directly at its current shape, so there is no old column to rename and nothing to drop. The newly installed module’s mark is set to the ledger’s latest id, and the historical entries — which describe a journey from shapes this database never had — are skipped (a recorded rename or delete is a no-op when the old column was never there). So renames and deletes only ever run against databases installed at an earlier id that are now catching up. An install that never saw the old shape applies none of them.
Backing out a change before it ships
Section titled “Backing out a change before it ships”The append-only rule binds a ledger entry only once it has shipped and another database has replayed it — at that point its id is immutable history that other databases are counting on. Before that line, the ledger and baseline are ordinary tracked files in your working tree, and a mistaken entry is undone like any other uncommitted change.
So if you record a rename or delete you didn’t mean — or you add a field and remove it again within the same unshipped change — do not record a second entry to “correct” the first. Revert to net zero instead:
git checkout -- fullfinity/modules/<m>/models/<model>.py \ fullfinity/modules/<m>/schema_changes.yaml \ fullfinity/modules/<m>/schema_baseline.jsonThis restores the model, drops the unwanted ledger entry, and rewinds the baseline together,
so check --only schema sees no breaking change and there is nothing left to replay. Revert
the baseline with it — it carries the record of which ids have existed, so dropping the
entry alone leaves the id recorded and the gate red (which is the point: that is
indistinguishable from withdrawing an id someone has already applied). (If your own
dev database already applied the bad change and dropped a column, re-adding the field is a
free additive change — -u all recreates and backfills the column on the next run.)
Two things this rests on:
- The gate that nags you during development is the commit-time one —
check --only schema, which composes the field surface from code and diffs it against the committed baseline. It fires whether or not any database exists, so the way to clear it is to make the diff net-zero, not to satisfy it with a deletion entry you don’t want. - The ledger only has teeth against databases catching up from an earlier id (see A fresh install replays no history above). A module that has never shipped has no such databases — other than possibly your own dev DB — so a bad entry caught before release costs nothing.
Model-level changes
Section titled “Model-level changes”Whole-model changes are handled the same way as fields, one level up. The gate detects a
model removed from the code (or a table rename) and routes it through resolve:
- rename a model →
rename_model: replay runsALTER TABLE old RENAME TO new. The data and every foreign key pointing at the table follow automatically — no copy needed. (If the model’s_table_nameis explicit and unchanged, only the Python name changed and there’s nothing to do at the DB level.) - delete a model →
delete_model: the table is dropped (plainDROP, notCASCADE— if other tables still reference it the upgrade fails and rolls back, surfacing the dependency rather than cascading loss). - split / merge → resolve as complex: a hook apportions or combines rows across
tables (use
remap_columnand rawINSERT ... SELECTas needed), with a fixture.
resolve suggests a rename target by matching the removed model’s field set
against newly-added models. As with fields, the diff itself never drops a table — model
deletes happen only through a recorded ledger entry.
Moving a model (or field) to another module
Section titled “Moving a model (or field) to another module”Splitting a feature out — e.g. relocating a stored model, or a field added via
__inherit__, from module A into a new module B that declares the identical table and
columns — is not a delete. Because migration is additive-only and the physical table is
unchanged, there is nothing to drop or copy: the data stays put and simply changes owning
module. Do not record a delete_model (or delete) — that would DROP the table on
replay and lose the data.
Instead, re-snapshot both baselines in the same change and record no ledger entry:
fullfinity-server resolve --snapshot --module B # new module: table is now B's (additive)fullfinity-server resolve --snapshot --module A # A's surface no longer includes itThe gate then passes because A’s regenerated baseline matches A’s code, and B owns the
surface. On every existing database -u all is a no-op for those tables (they already
exist, additive-only leaves them in place), so the data survives — including a column A
declared on a shared table (e.g. a SaleOrder FK) that B now re-declares. If B is gated
behind a toggle and isn’t installed yet, the orphaned table/column simply persists until B
is installed and adopts it. This is the one model/field change that is not a
resolve entry: same-name, same-shape, additive on both sides.
Required fields whose default depends on install-hook data
Section titled “Required fields whose default depends on install-hook data”Adding a required=True field with a default is normally free: the migration adds the
column nullable, backfills every existing row from the default, then enforces NOT NULL.
That assumes the default can produce a value while the migration runs.
Some defaults can’t. When a bridge module adds a required field to an already-populated
model and that field’s default resolves to records the module only creates in its
post_install/post_update hook, the prerequisite rows don’t exist yet at migration
time. The canonical case: installing inventory onto a DB that already has sales orders adds
SaleOrder.warehouse (required), whose default is the company’s default Warehouse — but
that warehouse is created by inventory.post_install(), which runs after the migration.
The framework handles this automatically — you don’t write anything special:
- The migration backfills what it can. If a deferred-
NOT NULLcolumn still has NULLs, it is stashed instead of failing the migration (noNotNullViolationError). - Install/update hooks run (
post_installcreates the warehouses). - A finalize pass re-runs the backfill — the default now resolves — and then adds
NOT NULL. Existing rows (including demo data) end up populated, not empty. If a row is still NULL afterwards, finalize raises and the whole install rolls back: a genuinely unsatisfiable required field fails loudly rather than shipping bad data.
For this to backfill correctly on a multi-company DB, write the default so it derives from
the record’s own data (e.g. self.company’s warehouse) rather than the current user’s
context — the backfill iterates existing rows, where “current user” is meaningless.
Scale (large tables)
Section titled “Scale (large tables)”Most upgrade operations are metadata-only and instant regardless of row count: adding
a column, dropping a column, and renaming a table (ALTER TABLE … RENAME, used for model
renames) don’t touch rows. The one operation that does touch rows is a field rename’s
data carry — a single set-based UPDATE to copy the old column into the new one, then a
metadata-only DROP. On a table with millions of rows that UPDATE is the cost (minutes +
WAL, inside the upgrade transaction — fine for a maintenance window).
Type changes (ALTER COLUMN TYPE) rewrite the table and are inherently heavy at scale —
they go through complex hooks, where the author can choose a batched or online strategy.
Custom modules
Section titled “Custom modules”Because core never removes the fields a custom module depends on (the gate enforces it), and renames are recorded and replayed, a custom module’s references keep resolving across upgrades. View inheritance is anchored to field names and stable element anchors, so a reflowed core layout doesn’t orphan an extension.
hook and fixture paths in a custom module
Section titled “hook and fixture paths in a custom module”A data/complex ledger entry names two things — a hook (the code that runs) and a
fixture (the test that proves it). They are resolved by two different mechanisms, and
both must reference only your own module — never a path specific to where a given
customer installed it. Every customer mounts custom modules under a different absolute module
path, so anything customer-specific would break on the next install.
-
hook: <import.path>:<fn>is a Python import path handed straight toimportlib.import_module. A custom module is loaded by putting its parent directory onsys.pathand importing it by its own folder name (basename), so the hook’s package is just your module’s name — regardless of where it lives on disk:hook: my_module.migrations:backfill_widgets # my_module/ is the module folderNot the customer’s module path, and not
fullfinity.modules.…(that prefix is only for modules bundled inside the framework package). The module folder name must be globally unique across all installed modules — it becomes a top-level importable package — which the unique-module-identifier rule already guarantees. -
fixture: <file/path.py>is a filesystem path, resolved against your module’s own directory (and its parent), so make it module-relative:fixture: tests/test_backfill.py # <module_dir>/tests/test_backfill.pyThe gate only checks the file exists (it forces you to ship an example alongside a hand-written hook); it does not execute it here. The same existence check runs at your
check --only schematime and at the upgrade-time self-check on the customer’s install, where your module directory is present.
Common issues
Section titled “Common issues”Most friction with the system is one of a handful of cases. Each has a mechanical fix — you never hand-write SQL or edit the baseline by hand.
check --only schema fails on a field I didn’t consciously change.
A refactor renamed or dropped a stored field, or moved one between modules. The gate is
working — it caught a breaking change. Run resolve and declare what it is
(rename / delete / complex), then commit the ledger entry and re-snapshotted baseline with
your model change. A field added to another module’s model via __inherit__ is attributed
to your module, so renaming it is checked against your baseline — declare it there.
check --only schema still fails after I reverted the model.
Your baseline drifted out of sync with the reverted code. Don’t add a ledger entry to paper
over it — rewind the baseline instead: git checkout the model, schema_changes.yaml, and
schema_baseline.json together (see Backing out a change before it ships), or regenerate
a clean baseline with resolve --snapshot --module <id> if the field really is gone.
-u all refuses: “ships a stored surface but no schema_baseline.json”.
A module owns stored fields but never snapshotted its baseline, so the upgrade-time gate
can’t tell sanctioned changes from silent breakage. Generate it once and commit it:
fullfinity-server resolve --snapshot --module <id>.
resolve rejects a complex or data entry: “missing fixture”.
Those actions run a hand-written hook whose correctness is only checkable by example, so the
gate requires a fixture test that proves the transform’s input → output. Write the fixture
(see Writing a complex transform) and point the entry at it; the gate refuses the entry
until the fixture exists on disk.
Non-interactive resolve recorded nothing and exited non-zero.
Resolution is atomic: if any flagged change is left undeclared, the command records none
of them rather than half-resolving. Read the list it printed and declare every flagged
change (--rename / --delete / --complex / --rename-choice / --clear-choice /
--complex-choice / --rename-model / --delete-model), then re-run.
-u all refuses: “a module on disk is OLDER than the schema this database already
carries”.
You deployed code whose ledger stops at a lower id than the database has already applied — a
downgrade. Migrations are forward-only; there is no down-migration. Restore the newer module
version, or restore the database from a backup taken before the upgrade.
Existing rows are NULL after adding a required=True field, or the upgrade fails enforcing
NOT NULL.
The default couldn’t produce a value while the migration ran — usually because it depends on
records the module only creates in its post_install hook. Write the default so it derives
from the record’s own data (e.g. self.company’s warehouse) rather than the current
user’s context; the framework then backfills after the hook runs (see Required fields whose
default depends on install-hook data).
-u all fails with current transaction is aborted, commands ignored until end of transaction block.
That is never the real error — Postgres reports it for every statement after the one that
actually failed, so the traceback points at whichever statement happened to run next. The
message includes the originating statement and its own error underneath (“The transaction was
aborted earlier by: …”); diagnose from that line, not from the traceback’s frames.
My model change doesn’t take effect at all. Module Python is imported once, not re-executed per request — a live edit isn’t picked up until the server/worker process restarts. Restart, then re-run the upgrade.
Can I just hand-edit schema_changes.yaml or schema_baseline.json?
No. Never reorder, reuse, or delete ids in the ledger (ids are the replay clock) and never
hand-edit the baseline. Add changes only through resolve, and regenerate the baseline only
through resolve --snapshot. Before an entry has shipped, back it out by reverting the files
(net-zero), not by editing ids in place.
A migration I recorded turned out to be unnecessary — can I delete the entry?
Not once any database has replayed it. Deleting it makes that database refuse to upgrade at
all (its mark is higher than anything the code ships, which reads as a downgrade), and
check --only schema fails the build for exactly that reason. Leave the entry where it is —
replay skips what a database has already applied, and it costs a fresh install one no-op. If
nothing has replayed it yet, revert the ledger and the baseline together (see Backing out
a change before it ships), which drops the id from both sides.
Command reference
Section titled “Command reference”| Command | Purpose | DB needed |
|---|---|---|
fullfinity-server -u all | Upgrade: additive sync + ledger replay, atomic | yes |
fullfinity-server --all-databases -u all | Same, against every owned database (skips test/scratch) | auto-discovered |
fullfinity-server check --only schema | Gate: fail on a breaking field-surface change | no |
fullfinity-server check --only version-bump --tag vX.Y.Z | Release gate: the tag’s SemVer bump must match the change class since the last release | no |
fullfinity-server resolve | Record a rename/delete/complex change | no |
fullfinity-server resolve --snapshot | Regenerate the committed baseline | no |