Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

## Unreleased

- Add Postgres schema definitions and migrations. `S.pg.table` (with `S.pg.column`, `S.pg.index`,
`S.pg.uniqueIndex`, `S.pg.foreignKey`) defines tables with primary keys, partial and expression
indexes, foreign keys, defaults and identity columns; `dialect: "postgres"` in the kit config
makes `generate`, `check`, `migrate`, `status` and `verify` work on Postgres. Each migration
runs in one transaction under an advisory lock, and `verify` compares the catalog with the
snapshot built in a rolled-back scratch schema.
- Adopt a drizzle-kit folder with `generate --baseline --from-drizzle` (or `--baseline` from
the definitions) and record an existing database with `effect-orm baseline <name>` /
`Migrate.baseline`. drizzle-kit `snapshot.json` files are recognized; migrations before the
first effect-orm snapshot are legacy and run as they are.
- `Snapshot` is a union over `dialect` (`ClickHouseSnapshot`, `PgSnapshot`); `MigrationFile`
over its generated files (`ClickHouseMigrationFile`, `PgMigrationFile`). `MigrationDriver`
gains an optional `transaction`, which `fromSqlClient` provides. `Drift.problem` adds
`not_null`, `identity` and `foreign_key`.

- **Breaking:** invalid queries are refused before any SQL is sent: as type errors where the
type can see them, otherwise as a `QueryBuilderError` / `QueryBuilderDefect` from `compile`.
- Params are in the query's type. `compile`, `compileUnion` and `Database.run` require every
Expand Down
2 changes: 1 addition & 1 deletion design/gap-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ builder; **P1** commonly used; **P2** niche.
| ~~`isNull` / `isNotNull` / `between`; variadic `and` / `or` that skip `undefined`~~ (built) | everywhere | S |
| Constraint error helpers (unique, foreign key, not null); keep ClickHouse's numeric error codes, which `sqlStateOf` drops today | all upserts | S |
| Tenant-scope enforcement in `Database`, opt in, with an explicit cross-tenant entry point | safety | S |
| Postgres `defineTable` (indexes, unique, FKs), Postgres migrations, a drizzle-kit importer | 68 tables, 90 indexes, 47 unique, 75 folders | L; can wait, drizzle-kit can keep migrating |
| ~~Postgres `defineTable` (indexes, unique, FKs), Postgres migrations, a drizzle-kit importer~~ Done: `S.pg.table`, `dialect: "postgres"`, `--baseline --from-drizzle` (design/migrations.md section 8) | 68 tables, 90 indexes, 47 unique, 75 folders | L |

## P1

Expand Down
40 changes: 39 additions & 1 deletion design/migrations.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Migrations: schema-as-code, snapshots, and a migrator

Status: phases 0 to 3 implemented on 2026-10-03 (branch `feat/migrations`); phases 4 to 7 open.
Status: phases 0 to 3 implemented on 2026-10-03 (branch `feat/migrations`); phase 6 (Postgres)
on 2026-10-04, see section 8; phases 4, 5 and 7 open.
Section 7 lists what was built and where it departs from this plan. User docs:
[`docs/migrations.md`](../docs/migrations.md).

Expand Down Expand Up @@ -483,3 +484,40 @@ rendering, diff, snapshots, the CLI in a temp folder, branch analysis).
additive change with a recreated view checked with real inserts, resume after a failed
statement, hash mismatch under `strict`, the lease, and drift. Passing on 26.2.19.43 and 26.8.2.7.
`tests/package-consumer.mts` imports all three entry points from the packed tarball under Node.

---

## 8. Postgres (phase 6)

Open question 1 is answered with full authoring, not runtime-only: Maple's Postgres schema
(70 tables, 76 drizzle-kit migrations) was the consumer, and keeping drizzle-kit for authoring
would have meant two definitions per table.

**Where the dialect seam is.** Shared: the snapshot envelope (`Snapshot` is a union over
`dialect`), `entityKey` / `sortEntities` / hashing, the branch graph (`kit/graph.ts`), folder
loading, `MigrationDriver`. Per dialect: entities (`pg-entities.ts`), definitions
(`pg-define.ts`, exported as `S.pg`), the diff (`pg-diff.ts`), ops and DDL (`pg-ops.ts`), the
ledger (`pg-ledger.ts`), drift (`pg-verify.ts`). `run`, `status`, `verify` and `generate` pick
the dialect from the config or the snapshots and dispatch; nothing ClickHouse-specific moved.

**Runtime.** One transaction per migration, ledger row included, under
`pg_advisory_xact_lock`: transaction-scoped, so a pooled connection cannot leak the lock. The
ledger is a plain table with a primary key. The ClickHouse step journal and lease are not used.

**Drift without normalizing SQL.** The catalog stores `'open'` as `'open'::text` and
`status in ('a', 'b')` as `(status = ANY (ARRAY[...]))`; any text normalizer would be a
heuristic. `verify` instead renders the snapshot into a scratch schema inside a transaction it
always rolls back, and reads both catalogs with the same queries. Checked against Maple: its 76
migrations replayed on PGlite, baselined from drizzle-kit's last snapshot, verify clean except
two real orphans (a table and a column the SQL created and no migration dropped, absent from
drizzle-kit's snapshot).

**Adoption.** A drizzle-kit `snapshot.json` is recognized (it has `ddl`, not `entities`) and
kept aside as `foreignSnapshot`. Migrations sorting before the first effect-orm snapshot are
legacy: `check` accepts them, `generate` refuses to diff until `--baseline` (from the
definitions, or `--from-drizzle` from drizzle-kit's last snapshot) starts the history.
`Migrate.baseline` records already-applied migrations without running them.

**Not done.** Check and unique constraints, enums, views, sequences beyond identity defaults,
non-`public` schemas, `CREATE INDEX CONCURRENTLY` (needs a migration outside a transaction),
rename detection, and `pull` / `push`.
4 changes: 2 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Roughly in reading order.
| [Tenant scoping](./tenant-scoping.md) | `tenantScope`, what marks a query scoped, `crossTenant()` |
| [Extending the DSL](./extending.md) | `defineFn`, raw escape hatches, handwritten SQL |
| [Postgres](./postgres.md) | The Postgres dialect, its column types and functions |
| [Schema and migrations](./migrations.md) | `defineTable`, `materializedView`, `effect-orm generate`, applying migrations |
| [Schema and migrations](./migrations.md) | `defineTable`, `materializedView`, `S.pg.table`, `effect-orm generate`, applying migrations, adopting drizzle-kit |
| [Statements and transactions](./database.md) | `Database` over your `SqlClient`: `run`, `execute`, `transaction`, retry |

## Reference
Expand All @@ -79,7 +79,7 @@ Roughly in reading order.
| `@maple-dev/effect-orm/benchmark/cli` | `runCli(args)` for embedding the bundled `ch-bench` commands |
| `@maple-dev/effect-orm/schema` | `defineTable`, `materializedView`, DDL rendering, snapshots, and the schema diff. Pure |
| `@maple-dev/effect-orm/kit` | `generate` and `check` over a migrations folder, `defineConfig`, and `runCli` for the bundled `effect-orm` command. Node or Bun |
| `@maple-dev/effect-orm/migrate` | `run`, `status`, `verify`, and `MigrationDriver`: applies migrations through a driver you provide |
| `@maple-dev/effect-orm/migrate` | `run`, `status`, `verify`, `baseline`, and `MigrationDriver`: applies ClickHouse or Postgres migrations through a driver you provide |
| `@maple-dev/effect-orm/database` | `Database` over your `SqlClient`: `run` compiled queries, `execute` statements, `transaction` with settings and contention retry |

The root barrel is curated, not exhaustive — see
Expand Down
105 changes: 101 additions & 4 deletions docs/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ in TypeScript too, three entry points add that, all opt-in:

| Entry | Runs where | What it does |
| ---------------------------------- | ------------------ | ----------------------------------------------------------------------------- |
| `@maple-dev/effect-orm/schema` | anywhere, pure | `defineTable` / `materializedView`, DDL rendering, snapshots, the diff |
| `@maple-dev/effect-orm/schema` | anywhere, pure | `defineTable` / `materializedView` / `pg.table`, DDL rendering, snapshots, the diff |
| `@maple-dev/effect-orm/kit` | Node or Bun | `generate` and `check` over a migrations folder; the `effect-orm` command |
| `@maple-dev/effect-orm/migrate` | anywhere Effect runs | applies migrations through a driver you provide, `status`, `verify` |

ClickHouse only, for now. The model follows drizzle-kit (a committed snapshot per migration,
an offline `generate`, data-loss confirmations by prompt or by hints), and the runtime is
built for a database without transactions.
Both ClickHouse and Postgres. The model follows drizzle-kit (a committed snapshot per migration,
an offline `generate`, data-loss confirmations by prompt or by hints). Snapshots, the branch
check and folder loading are shared; table definitions, the diff, the DDL, the runtime and
drift detection are per database, because the two differ where it matters: ClickHouse DDL is
not transactional and cannot change most things in place, and Postgres DDL is and can. The
sections below describe ClickHouse first; [Postgres](#postgres) covers what differs.

## Defining tables

Expand Down Expand Up @@ -176,3 +179,97 @@ or comments yet. `effect-orm verify` exits 3 when it finds drift.
_(Effect's own `ClickhouseMigrator` creates its ledger with a statement ClickHouse 26.8
rejects, and inserts ledger rows before running each migration. That is why this package has
its own runner.)_

## Postgres

Set `dialect: "postgres"` in the config and define tables with `S.pg.table`. `generate`, `check`,
`migrate`, `status` and `verify` then work as above, with the differences below.

```ts title="migrations-postgres.ts"
import * as CH from "@maple-dev/effect-orm"
import * as PG from "@maple-dev/effect-orm/postgres"
import * as S from "@maple-dev/effect-orm/schema"

export const Dashboards = S.pg.table("dashboards", {
columns: {
org_id: PG.text,
id: PG.text,
status: S.pg.column(PG.text, { default: "open" }),
created_at: S.pg.column(PG.timestamptz, { defaultExpr: "now()" }),
archived_at: PG.nullable(PG.timestamptz),
},
primaryKey: ["org_id", "id"],
indexes: [S.pg.index("dashboards_open_idx", ["org_id"], { where: ($) => $.archived_at.isNull() })],
tenantColumn: "org_id",
})

export const Shares = S.pg.table("dashboard_shares", {
columns: { org_id: PG.text, id: PG.text, dashboard_id: PG.text, widget_id: PG.nullable(PG.text), revoked_at: PG.nullable(PG.timestamptz) },
primaryKey: ["org_id", "id"],
indexes: [
// At most one live share per dashboard and widget: a partial unique index on an expression.
S.pg.uniqueIndex("dashboard_shares_live_unq", ($) => [$.org_id, $.dashboard_id, CH.coalesce($.widget_id, CH.lit(""))], {
where: "revoked_at is null",
}),
],
foreignKeys: [
S.pg.foreignKey({ columns: ["org_id", "dashboard_id"], references: Dashboards, foreignColumns: ["org_id", "id"], onDelete: "cascade" }),
],
})

export const ddl = S.renderPgSchema(S.pgEntitiesOf([Dashboards, Shares]))
```

**Definitions.** A column is `NOT NULL` unless its type is `PG.nullable(...)`. `S.pg.column(type,
options)` adds a `default` (a value of the column's type), a `defaultExpr` (SQL or a DSL
expression) or an `identity` (`"always"` or `"by default"`); any of them makes the column
optional on insert. `primaryKey` takes column names, or `{ columns, name }`; the default name is
`<table>_pkey`. Indexes are `S.pg.index` / `S.pg.uniqueIndex` over column names or expressions,
with `where` for a partial index and `using` for the access method. A foreign key without a
`name` gets drizzle-orm's, `<table>_<columns>_<foreign table>_<foreign columns>_fk`, shortened
with drizzle-kit's hash to `<table>_<hash>_fk` when it would pass 63 characters. Types are
stored as Postgres names them (`int4` is `integer`), so snapshots compare with the catalog and
with drizzle-kit. Check and unique constraints, enums, views, sequences and other schemas are
not modeled yet; write them in a `--custom` migration.

**Generating.** Postgres changes a column's type, nullability, default or identity in place
(`ALTER COLUMN`), and a primary key, an index or a foreign key by dropping and re-creating it,
so `generate` reports nothing as unsupported. A type change is labeled `rewrite`: Postgres
rewrites the table under an exclusive lock. Drops still need confirmation, and renames still
read as a drop and an add. Generated files carry `"dialect": "postgres"`.

**Applying.** Each migration runs in one transaction with its ledger row, under a
transaction-scoped advisory lock, so concurrent deploys wait for each other rather than
racing. A failed statement rolls the whole migration back and the next run starts it from the
top: there is no step journal, no lease, no `partial` or `uncertain` state, and nothing for
`resolve` to do. The driver needs a transaction, which `Migrate.fromSqlClient` provides from
`SqlClient.withTransaction`. A statement Postgres refuses inside a transaction (`CREATE INDEX
CONCURRENTLY`, `ALTER TYPE ... ADD VALUE` before Postgres 12) cannot be in a migration yet.

**Drift.** `verify` builds the expected schema in a scratch schema, inside a transaction it
always rolls back, and reads both catalogs with the same queries, so Postgres deparses both
sides and `'open'` matches the stored `'open'::text`. It checks tables, columns (type, `NOT NULL`,
default, identity), primary keys, indexes (uniqueness, method, keys, predicate) and foreign keys
in the current schema. `Migrate.verify(migrations, { ignoreTables })` skips tables another tool
owns.

### Adopting a drizzle-kit folder

A drizzle-kit (v1) folder already has the layout `migrate` reads: `<timestamp>_<name>/migration.sql`
split on `--> statement-breakpoint`. Its `snapshot.json` files are recognized as drizzle-kit's
and set aside, so the folder runs as it is. Adoption is two steps:

1. `effect-orm generate --baseline --from-drizzle` writes a migration that runs nothing, whose
snapshot is drizzle-kit's last one converted to entities. Anything the conversion cannot model
is listed and nothing is written. Without `--from-drizzle` the snapshot comes from your
`S.pg.table` definitions instead. Migrations before the baseline are legacy: they run, but
nothing diffs against them, and a plain `generate` refuses to run until a baseline exists.
2. On a database drizzle-kit (or anything else) already migrated, `effect-orm baseline <name>`
(`Migrate.baseline`) records the baseline and every migration before it as applied, without
running them. A fresh database, such as a test's, simply runs everything.

The first `generate` after the baseline diffs your `S.pg.table` definitions against what
drizzle-kit recorded, so every place they disagree (a constraint name, a default) shows up as an
op to accept or fix. `verify` against the baseline also finds objects the database has and
drizzle-kit's snapshot does not, such as a table a hand-written migration created and nothing
dropped.
Loading
Loading