Writing Migrations
Every table in the trax schema is created by a migration that applies automatically at
startup. Do not hand-create one, and do not reach for EnsureCreated to produce it. The rule
is about Trax's own tables: a consumer's domain tables are bootstrapped by
EnsureSchemaCreatedAsync, which is a different mechanism on purpose, and
Trax.Effect/docs/adr/0002 records why.
How it works
Migrations are plain SQL files:
Trax.Effect/src/Trax.Effect.Data.Postgres/Migrations/<NNN>_<name>.sql
Trax.Effect/src/Trax.Effect.Data.Sqlite/Migrations/<NNN>_<name>.sqlThose are the only two sets. Trax.Effect.Data.InMemory has no Migrations/ folder.
They are embedded by a csproj glob, so a new file in the folder is picked up with no
per-file edit. DatabaseMigrator (DbUp) applies every pending script from inside the
provider registration, synchronously, when UsePostgres(...) or UseSqlite(...) runs.
SkipMigrations() opts out for an externally managed schema, though it is declared only in
the Postgres package.
Scripts run in ordinal filename order, which is what the NNN_ prefix is for. Applied
scripts are journaled and never re-run. On Postgres the migrator holds a session advisory lock
for the whole run, so hosts that start together against one database migrate one after another,
and every session it opens has its time zone pinned to UTC. The Postgres migrator journals to trax.migrations;
the Sqlite one sets no journal and so lands on DbUp's default SchemaVersions table. The
runner scans exactly one assembly, the provider's own, so there is no cross-assembly
discovery.
The Postgres and Sqlite sets are numbered independently and each must be gapless from 001. A table that must work on both needs a file in both.
Adding a table
- Add the model and its persistent mapping, per Project Layout,
and its
DbSeton bothDataContextandIDataContext. A model keyed by something other than alongid does not implementIModel, soDataContext.OnModelCreatingmaps it by name, as it doesPersistentPersistedOperationandPersistentRunnerNonce. A new member onIDataContextneeds a default, or package validation refuses it as a break. - Add
NNN_<name>.sqlto both providerMigrations/folders. The DDL column names must match the EF[Column(...)]names exactly, because the stores query by those names and nothing reconciles the two. - Write a test that builds the table from the shipped migration and round-trips through the
real store.
EnsureCreatedcannot provide it. For the data context's own tables,EveryTableIsModelledTestsand its Sqlite twin already check that every migrated table is mapped and every mapped column exists; a table the context should not map goes in their exceptions list with its reason.
Every Postgres script can run again
The Postgres migrator runs a script without a transaction, one statement at a time. A script that stops partway (a failed statement, a killed process) keeps the statements before the failure and is not journaled, so at the next start it runs again from its first statement. From 046 on, every statement has to survive that:
| Statement | Written as |
|---|---|
| a table, index, schema or sequence | CREATE ... IF NOT EXISTS |
| a drop | DROP ... IF EXISTS |
| a column | ALTER TABLE ... ADD COLUMN IF NOT EXISTS |
| an enum value | ALTER TYPE ... ADD VALUE IF NOT EXISTS |
a default or NOT NULL | ALTER COLUMN ... SET DEFAULT / SET NOT NULL, which repeat harmlessly |
| a type change, a new constraint, a rename, a new enum type | inside a DO $$ ... $$ block that checks the catalog first |
| a row | INSERT ... ON CONFLICT, or an UPDATE/DELETE whose WHERE excludes rows already done |
An index on metadata, log or work_queue is built CREATE INDEX CONCURRENTLY IF NOT EXISTS, so
enqueue, dispatch and run writes carry on while it builds; a plain build blocks them for as long as
it takes. That works because the script is not in a transaction. A concurrent build that fails leaves
an INVALID index behind, which IF NOT EXISTS would skip forever, so before it runs the pending
scripts the migrator drops every invalid index in the trax schema whose name a shipped script
creates. It reads those names from the scripts, so an index a new script builds is covered with no
list to update. Any other invalid index, such as your own or the _ccnew copy of a
REINDEX CONCURRENTLY in progress, is left alone.
The scripts run with a five-second lock_timeout. An ALTER TABLE waiting behind a transaction on
another instance would otherwise hold every later write to that table behind itself, and
UsePostgres migrates at startup, so that is a rolling deploy stalling enqueue and dispatch on every
host. When a script gives up (55P03), the migrator drops any index that left invalid and runs the
pending scripts again, up to ten times, then fails startup. That is why a script has to survive
running again even when nothing crashed. CREATE INDEX CONCURRENTLY's wait for older transactions is
a lock wait too, so an index build behind a transaction that stays open for about a minute fails
startup rather than waiting it out.
A type change that Postgres can make without rewriting the table should be written so it does.
timestamp to timestamptz is one, but only in a UTC session and with no USING; migration 049 sets
the zone for its own transaction with PERFORM set_config('TimeZone', 'UTC', true) inside each DO
block. A rewrite holds ACCESS EXCLUSIVE for as long as it copies the table.
PostgresMigrationRerunTests reads each script from 046 on and refuses a statement that is not in
one of these forms, and PostgresMigrationTests runs every one of them again over a migrated
database. Trax.Effect/docs/adr/0014 records why the migrator works this way.
SQLite runs each script in its own transaction, so a script there lands whole or not at all.
A timestamp column is timestamptz defaulting to now(), never timestamp without time zone and
never now() AT TIME ZONE 'utc': a plain timestamp stores the writing session's wall-clock time
(Trax.Effect/docs/adr/0015).
Provider dialects
| Postgres | Sqlite | |
|---|---|---|
| Schema | tables live in trax, DDL is trax.-qualified | no schemas, tables unqualified |
| Types | jsonb, uuid, timestamptz, bigint | TEXT, INTEGER, and REAL for a fractional number |
| Style | mixed: lowercase and uppercase keywords side by side, and 5 of the 13 create table statements have no IF NOT EXISTS | uniform: uppercase throughout, and 13 of the 14 CREATE TABLE statements are IF NOT EXISTS (the exception is the transient table 015_snapshot_draft_machine_key.sql rebuilds snapshot_draft through) |
The Postgres style is not a convention to match, it is drift. Write new Postgres scripts the
way the Sqlite set is already written, with if not exists and one case throughout.
A DbContext used with both strips the trax schema and remaps jsonb to TEXT when
running on Sqlite, branching on Database.ProviderName.
Feature packages
A higher-level feature package does not carry its own migrations. Its DDL ships in the two
core provider sets above, because the runner scans only the provider assembly: a
.sql embedded anywhere else is never discovered and never runs. This does not invert the
dependency, since the SQL is text and the provider references nothing from the feature.
The table's model ships with it. A feature package reaches its table through IDataContext and
the model, with LINQ, ExecuteUpdate and ExecuteDelete, not with SQL of its own or a
DbContext of its own. Persisted operations in Trax.Api and the runner nonce store in
Trax.Scheduler both work this way. When the providers differ in a way the feature must handle,
such as how a key conflict is reported, the difference goes in ISqlDialect in Trax.Effect:
IsUniqueViolation is how the nonce store tells a repeated nonce from any other failed save, and how
the state-machine stores tell a lost race for a draft or an effect claim from a real failure.
Integration test databases
A fixture that creates or drops a throwaway database must connect to the always-present
postgres maintenance database, never the app database. CI's POSTGRES_DB differs per repo,
so a fixture assuming a specific app database fails with 3D000 database ... does not exist.