diff --git a/.changeset/pg-enums-unique-constraints.md b/.changeset/pg-enums-unique-constraints.md
new file mode 100644
index 0000000..15ed6c9
--- /dev/null
+++ b/.changeset/pg-enums-unique-constraints.md
@@ -0,0 +1,5 @@
+---
+"@maple-dev/effect-orm": minor
+---
+
+Postgres enum types and unique constraints are first-class. `PG.enumType(name, labels, { schema? })` is a column type (rows decode to its labels; inserts and comparisons take only them; `nullable` and `array` wrap it, and enum arrays decode from the text form drivers send) and a schema object: `generate` creates the type before the tables that use it, adds labels with `ADD VALUE ... BEFORE`, renames one behind a `confirm_rename` hint, asks before dropping the type, and reports removing or reordering labels, or using a label in the migration that adds it, as unsupported. `PG.unique(name, columns, { nullsNotDistinct? })` in a table's `uniqueConstraints`, or `unique` in `PG.column` (named `
__unique` by default), declares an `ADD CONSTRAINT ... UNIQUE` constraint, diffed as a constraint rather than an index. `verify` reads enums from `pg_enum` and unique constraints from `pg_constraint`, and resolves types of the current schema during its scratch rebuild, so `PG.custom("public.x", ...)` enums no longer report false drift. `generate --baseline --from-drizzle` converts drizzle-kit enums, enum columns and unique constraints.
diff --git a/.changeset/runnable-typescript-5.md b/.changeset/runnable-typescript-5.md
new file mode 100644
index 0000000..6225f6c
--- /dev/null
+++ b/.changeset/runnable-typescript-5.md
@@ -0,0 +1,5 @@
+---
+"@maple-dev/effect-orm": patch
+---
+
+A concrete insert is assignable to `Runnable` (from `/database`) and accepted by `compile` under TypeScript 5.x again. Both took `CHInsert`, which TypeScript 5 compares method by method, and the generic `onConflictDoUpdate` failed that comparison; they now take an insert by its tag, state and phantom (`AnyInsert`). A workaround such as `type Runnable = Orm.Runnable | { readonly _tag: "CHInsert" }` can be removed.
diff --git a/design/migrations.md b/design/migrations.md
index 19656fd..f8f05b1 100644
--- a/design/migrations.md
+++ b/design/migrations.md
@@ -518,6 +518,15 @@ legacy: `check` accepts them, `generate` refuses to diff until `--baseline` (fro
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`.
+**Enums and unique constraints.** `PG.enumType` is a column type and a schema object; its
+`enum` entity sorts before tables, `create_enum` runs first and `drop_enum` last. Labels are
+added in place (`ADD VALUE ... BEFORE`), a label replaced in place is renamed (`RENAME VALUE`)
+behind a `confirm_rename` hint, and removing or reordering labels is unsupported. A migration is
+one transaction, so a label it adds cannot be used in it; the diff reports a default or
+predicate that would. Unique constraints are `unique` entities, kept apart from unique indexes
+all the way to `verify`, which reads them from `pg_constraint`. `verify` searches the current
+schema after the scratch one, so types of the current schema deparse unqualified on both sides.
+
+**Not done.** Check constraints, views, sequences beyond identity defaults, tables in non-`public`
+schemas, removing enum labels (a type rebuild), `CREATE INDEX CONCURRENTLY` (needs a migration
+outside a transaction), rename detection for tables and columns, and `pull` / `push`.
diff --git a/docs/migrations.md b/docs/migrations.md
index 1066837..6d731fc 100644
--- a/docs/migrations.md
+++ b/docs/migrations.md
@@ -285,17 +285,59 @@ with `where` for a partial index and `using` for the access method. A foreign ke
`name` gets drizzle-orm's, `____fk`, shortened
with drizzle-kit's hash to `__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, generated columns, enums, views, sequences and other schemas
-are not modeled yet; write them in a `--custom` migration, and declare a view you query as an
+with drizzle-kit.
+
+**Unique constraints.** `uniqueConstraints: [PG.unique(name, columns, { nullsNotDistinct? })]`
+on a table, or `unique` in `PG.column` for one column, declares a `UNIQUE` constraint: `ALTER
+TABLE ... ADD CONSTRAINT name UNIQUE (columns)`, which is what drizzle-kit writes for `unique()`
+and `.unique()`. It is not a unique index: Postgres backs it with an index of the same name, but
+drops it with `DROP CONSTRAINT`, and a foreign key may reference its columns. `unique: true`
+names the constraint `__unique`, drizzle-orm's name; `unique: "name"` or `unique:
+{ name, nullsNotDistinct }` gives another (drizzle-kit v1 names a column's unnamed `.unique()`
+`__key`, so say so when adopting one). `nullsNotDistinct` needs Postgres 15.
+`onConflictDoNothing` / `onConflictDoUpdate` take the constraint's columns as `target`, or `{
+constraint: name }`.
+
+**Enums.** `PG.enumType(name, labels)` is a column type and a schema object; see
+[Enums](./postgres.md#enums). Its `CREATE TYPE` comes before every table, and is skipped when
+the type already exists (Postgres has no `IF NOT EXISTS` for it), so an enum created by hand
+behind `PG.custom` can be declared with `enumType` later.
+
+Check constraints, generated columns, views, sequences and tables in other schemas are not
+modeled yet; write them in a `--custom` migration, and declare a view you query as an
[external table](./tables-and-types.md#external-tables).
**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"`. With `emit: "sql"` in
-the config, `generate` writes the rendered statements to `migration.sql` instead, each under a
-comment naming its label, so a tool that applies drizzle-kit folders applies the migration too.
+(`ALTER COLUMN`), and a primary key, an index, a unique constraint or a foreign key by dropping
+and re-creating it. A type change is labeled `rewrite`: Postgres rewrites the table under an
+exclusive lock. Drops need confirmation, dropping an enum type included (`"kind": "enum"`), and
+renames of tables and columns still read as a drop and an add. Enum types are created first and
+dropped last, unique constraints are added before the foreign keys that reference them, and
+turning a `uniqueIndex` into a `unique` of the same name drops the index before adding the
+constraint. Generated files carry `"dialect": "postgres"`. With `emit: "sql"` in the config,
+`generate` writes the rendered statements to `migration.sql` instead, each under a comment
+naming its label, so a tool that applies drizzle-kit folders applies the migration too.
+
+Enum labels change as far as Postgres can change them in place:
+
+- **A new label** is `ALTER TYPE ... ADD VALUE`, with `BEFORE` the label it precedes, so the
+ order matches the definition.
+- **A renamed label**, the same labels in the same order but one replaced in place, is `ALTER
+ TYPE ... RENAME VALUE`. Rows holding the old label then hold the new one, so it asks for a
+ hint first: `{"type":"confirm_rename","kind":"enum_value","entity":"channel_type.direct","to":"dm"}`.
+- **Removing or reordering labels** needs a new type and every column moved to it. `generate`
+ reports it as unsupported and writes nothing; write that rebuild with `--custom`.
+
+A migration is one transaction, and Postgres does not let a transaction use a label it added
+(`unsafe use of new value`). So `generate` refuses a migration that adds a label and also
+uses it, in a default or a partial index's predicate: add the label, generate, then use it and
+generate again. Before Postgres 12, `ADD VALUE` cannot run in a transaction at all, so a
+migration that adds a label needs Postgres 12 or later.
+
+Moving an enum from `PG.custom("public.channel_type", ...)` to `PG.enumType("channel_type",
+...)` generates a `create_enum`, skipped since the type exists, and an `alter_column` per column
+from `public.channel_type` to `channel_type`, the same type under its unqualified name, which
+leaves the data as it is.
**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
@@ -307,10 +349,15 @@ CONCURRENTLY`, `ALTER TYPE ... ADD VALUE` before Postgres 12) cannot be in a mig
**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.
+sides and `'open'` matches the stored `'open'::text`. The scratch schema is searched before the
+current one, so a type of the current schema resolves in both, and a column of it reads
+`channel_type`, its default `'public'::channel_type`, on both sides, however the definition
+qualified it. It checks enum types (labels, in order, read from `pg_enum`), tables, columns
+(type, `NOT NULL`, default, identity), primary keys, unique constraints (columns, `NULLS NOT
+DISTINCT`), indexes (uniqueness, method, keys, predicate) and foreign keys in the current
+schema. A unique constraint is never read as an index, nor an index as a constraint. An enum
+the snapshot does not declare is reported only if no column uses it, so a `PG.custom` enum still
+verifies. `Migrate.verify(migrations, { ignoreTables })` skips tables another tool owns.
### Adopting a drizzle-kit folder
@@ -319,8 +366,10 @@ split on `--> statement-breakpoint`. Its `snapshot.json` files are recognized as
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
+ snapshot is drizzle-kit's last one converted to entities: enums, tables, columns (enum columns
+ included), keys, indexes, unique constraints and foreign keys. Anything the conversion cannot
+ model (sequences, views, check constraints, policies, tables in other schemas) is listed and
+ nothing is written. Without `--from-drizzle` the snapshot comes from your
`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 `
diff --git a/docs/postgres.md b/docs/postgres.md
index ecfeef8..8964dcb 100644
--- a/docs/postgres.md
+++ b/docs/postgres.md
@@ -3,7 +3,8 @@
The same builder writes Postgres SQL. Queries, params, tenant scoping and row decoding work
as they do for ClickHouse. `@maple-dev/effect-orm/postgres` is the one import for it: the
builder, column types whose codecs read what Postgres drivers send, functions spelled the
-Postgres way, `table` with its keys, indexes and foreign keys, and a `compile` that defaults to
+Postgres way, `table` with its keys, indexes, unique constraints and foreign keys, `enumType`,
+and a `compile` that defaults to
the Postgres dialect. Nothing from `/clickhouse` is needed.
```ts title="postgres-quickstart.ts"
@@ -59,7 +60,8 @@ await db.close()
`@effect/sql-pg`'s `unsafe`. The builder never runs the query. `Id` is an identity column, so an
insert may leave it out: `PG.InsertRowOf` makes it optional. See
[Tables and column types](./tables-and-types.md) for column options and external tables, and
-[Schema and migrations](./migrations.md#postgres) for indexes, foreign keys, and `generate`.
+[Schema and migrations](./migrations.md#postgres) for indexes, unique constraints, foreign keys,
+and `generate`; [Enums](#enums) for enum types.
## What the dialect changes
@@ -98,6 +100,7 @@ literal that still contained it would fail the compile with `InvalidLiteral`.
| `timestamptz` | `timestamptz` | `DateTime.Utc` | `Date`, or text such as `2026-01-01 00:00:00+00` |
| `timestamptzMillis` | `timestamptz` | epoch milliseconds (`number`) | the same |
| `jsonb(schema?)` | `jsonb` | the schema's type (`unknown` by default) | a parsed value |
+| `enumType(name, labels, { schema? })` | the enum type | one of its labels | string |
| `array(type)` | `type[]` | `ReadonlyArray` | array |
| `nullable(type)` | the same type | `T \| null` | the same, or `null` |
| `custom(sql, schema, literalSchema?)` | anything | the schema's type | whatever the schema reads |
@@ -115,6 +118,70 @@ Comparisons with a literal-union column take only its members: with `status` typ
`"open" | "closed"`, `$.status.eq("opne")` is a type error. A param of the primitive
(`param.string`) still compares, for a value known only at run time.
+## Enums
+
+`PG.enumType(name, labels)` declares a Postgres enum type. The value is a column type, so rows
+decode to the union of its labels and inserts and comparisons take only those, and it is a
+schema object, so `generate` writes `CREATE TYPE ... AS ENUM` before any table that uses it and
+alters the type when its labels change. Use it bare, in `PG.column` with a `default`, or inside
+`PG.nullable` and `PG.array`:
+
+```ts title="postgres-enums.ts"
+import { Effect } from "effect"
+import * as PG from "@maple-dev/effect-orm/postgres"
+import * as S from "@maple-dev/effect-orm/schema"
+
+export const ChannelType = PG.enumType("channel_type", ["public", "private", "direct"])
+
+export const Channels = PG.table("channels", {
+ columns: {
+ id: PG.text,
+ org_id: PG.text,
+ slug: PG.column(PG.text, { unique: true }),
+ type: PG.column(ChannelType, { default: "public" }),
+ allowed: PG.nullable(PG.array(ChannelType)),
+ },
+ primaryKey: ["id"],
+ uniqueConstraints: [PG.unique("channels_org_slug_unique", ["org_id", "slug"])],
+})
+
+export type Channel = PG.SelectRowOf
+// { ...; readonly type: "public" | "private" | "direct"; readonly allowed: ReadonlyArray<...> | null }
+
+export const shared = PG.compileUnsafe(
+ PG.from(Channels)
+ .select("id", "type")
+ .where(($) => [$.type.in_("public", "private")]),
+ {},
+)
+
+export const upsert = PG.compileUnsafe(
+ PG.insertInto(Channels)
+ .values({ id: "c1", org_id: "o", slug: "general", type: "private" })
+ .onConflictDoUpdate({ target: ["slug"], set: { type: "private" } }),
+ {},
+)
+
+export const ddl = Effect.map(S.pgEntitiesOf([ChannelType, Channels]), (entities) => S.renderPgSchema(entities))
+// CREATE TYPE "channel_type" AS ENUM ('public', 'private', 'direct') (skipped if it exists)
+// CREATE TABLE IF NOT EXISTS "channels" (... "type" channel_type NOT NULL DEFAULT 'public', ...)
+// ALTER TABLE "channels" ADD CONSTRAINT "channels_org_slug_unique" UNIQUE ("org_id", "slug")
+// ALTER TABLE "channels" ADD CONSTRAINT "channels_slug_unique" UNIQUE ("slug")
+```
+
+`ChannelType.values` lists the labels for application code. Exporting the enum from a schema
+module is optional: the tables that use it bring it along. Pass `{ schema: "auth" }` for a type
+in another schema; leave it out for the schema migrations run in (usually `public`), which is
+how columns of the type are spelled there.
+
+Drivers parse arrays of built-in types but not of an enum, whose array type they do not know:
+node-postgres and PGlite send `{public,private}` as text. An `array(enumType)` column reads
+either form, and writes its value as array text, which Postgres casts to the column's type.
+
+`PG.brand(PG.text, Schema.Literals([...]))` and `PG.custom("public.channel_type", ...)` still
+work for an enum `generate` does not manage; see [migrations](./migrations.md#postgres) for
+moving one to `enumType`.
+
## Functions
| Function | SQL | Notes |
diff --git a/docs/reference.md b/docs/reference.md
index 3fef497..66e108a 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -410,15 +410,20 @@ following. See [Postgres](./postgres.md).
### Tables and DDL
```ts
+const Role = PG.enumType("user_role", ["owner", "member"])
+
const Users = PG.table("users", {
columns: {
id: PG.column(PG.int8, { identity: "always" }),
orgId: PG.text,
email: PG.text,
+ handle: PG.column(PG.text, { unique: true }),
+ role: PG.column(Role, { default: "member" }),
createdAt: PG.column(PG.timestamptz, { defaultExpr: "now()" }),
},
primaryKey: ["id"],
indexes: [PG.uniqueIndex("users_org_email_idx", ["orgId", "email"])],
+ uniqueConstraints: [PG.unique("users_org_handle_unique", ["orgId", "handle"])],
tenantColumn: "orgId",
})
```
@@ -429,22 +434,33 @@ const Users = PG.table("users", {
| `column` | `(type, ColumnOptions) => ColumnSpec` |
| `index` | `(name, on, IndexOptions?) => IndexSpec`: columns, or a callback building expressions |
| `uniqueIndex` | The same, `UNIQUE`; with `where`, a partial unique index |
+| `unique` | `(name, columns, UniqueOptions?) => UniqueSpec`: a `UNIQUE` constraint, for `uniqueConstraints` |
+| `enumType` | `(name, labels, EnumOptions?) => PgEnum`: an enum type, both a column type and a schema object |
| `foreignKey` | `({ columns, references, foreignColumns, onDelete?, onUpdate?, name? }) => ForeignKeySpec`; `references` is a `Table` (its columns are checked) or a name |
| `defaultForeignKeyName` | `(table, columns, foreignTable, foreignColumns) => string`: the name drizzle-kit gives, shortened past 63 characters as drizzle-kit does |
**`TableDefinition`** — `columns` (required), `primaryKey` (column names, or
-`{ columns, name? }`), `indexes`, `foreignKeys`, `tenantColumn`.
+`{ columns, name? }`), `indexes`, `uniqueConstraints`, `foreignKeys`, `tenantColumn`.
**`ExternalTableDefinition`** — `{ external: true, columns, tenantColumn? }`, as on
`/clickhouse`: a view, a catalog table, one another tool migrates. No DDL.
**`ColumnOptions`** — one of `default`, `defaultExpr`, `identity` (`"always"` or
-`"by default"`); any of them lets an insert leave the column out (`DefaultedColumnsOf`).
+`"by default"`); any of them lets an insert leave the column out (`DefaultedColumnsOf`). Also
+`name` (the column's name in SQL) and `unique` (`true`, a constraint name, or
+`UniqueColumnOptions`: `{ name?, nullsNotDistinct? }`), which names a one-column constraint
+`__unique` by default.
+
+**`UniqueOptions`** — `nullsNotDistinct` (Postgres 15+).
+
+**`PgEnum`** — a `PgType` whose rows decode to its labels, with `name`, `values` (the labels),
+and the `ddl` and `problems` `generate` reads. **`EnumOptions`** — `schema`, for a type outside
+the schema migrations run in.
**`IndexOptions`** — `where` (a `DdlPredicate`: SQL text or a callback returning a condition),
`using` (the access method; default `btree`).
-**Types** — `ColumnInput`, `ColumnSpec`, `ColumnsOf`, `IndexSpec`, `ForeignKeySpec`,
+**Types** — `ColumnInput`, `ColumnSpec`, `ColumnsOf`, `IndexSpec`, `UniqueSpec`, `ForeignKeySpec`,
`ReferentialAction` (drizzle's lowercase spelling or the catalog's), `TableDdl`, `PgSchemaTable`,
`DdlExpr`, `DdlKey`. A definition that cannot render records a problem; `S.pgEntitiesOf` fails with it.
@@ -452,12 +468,13 @@ const Users = PG.table("users", {
`text`, `uuid`, `bool`, `int2`, `int4`, `int8`, `float4`, `float8`, `numeric`, `timestamptz`,
`timestamptzMillis` (a timestamptz read as epoch milliseconds), `jsonb(schema?)`, `array(type)`, `nullable(type)`, `custom(sql, schema, literalSchema?)`,
-`brand(type, schema)`. See [Postgres column types](./postgres.md#column-types).
+`brand(type, schema)`, and `enumType(name, labels)` above. See [Postgres column types](./postgres.md#column-types).
Types: `PgType` (a Postgres column type; a `CHType`), `PgArray`, `PgNullable`. Codecs:
`PgNumber` (a number, numeric string or `bigint`), `PgTimestampLiteral` (an instant written as
ISO-8601), `timestampLiteral(format)` (the same with another format), and `pgTimestampToIso`,
-which normalizes Postgres timestamp text to ISO-8601.
+which normalizes Postgres timestamp text to ISO-8601. `parsePgArrayLiteral` reads a
+one-dimensional array literal (`{a,"b c",NULL}`), the form enum arrays arrive in.
### Functions
@@ -485,7 +502,7 @@ placeholders, double-quoted identifiers.
| `SqlQuery`, `compileQuery` | `/sql` |
| `ClickHouseStatement`, `parseStatement`, `renderStatement`, `withSettings`, `withFormat` | `/sql` |
| `ClickHouseStatementFromString`, `splitTerminalClauses`, `maskLiteralsAndComments` | `/sql` |
-| Schema tooling: `isSchemaObject`, `makeSnapshot`, `renderSchema`, `diffSchemas`, `diffPgSchemas`, `fromDrizzleSnapshot`, snapshot and entity schemas | `/schema` |
+| Schema tooling: `isSchemaObject`, `makeSnapshot`, `renderSchema`, `diffSchemas`, `diffPgSchemas`, `fromDrizzleSnapshot`, snapshot and entity schemas (`PgEnumEntity`, `PgUniqueEntity`, …), `Hint` | `/schema` |
| `run`, `status`, `verify`, `baseline`, `MigrationDriver` and the migrate errors | `/migrate` |
| `Database`, `run`, `sql`, `query`, `execute`, `transaction`, `requireTransaction`, `retryContention`, `Transaction` | `/database` |
| `DatabaseError`, `TransactionCommitFailed`, `TransactionRollbackFailed` and the other transaction errors | `/database` |
diff --git a/docs/tables-and-types.md b/docs/tables-and-types.md
index 9249e11..23b0795 100644
--- a/docs/tables-and-types.md
+++ b/docs/tables-and-types.md
@@ -60,9 +60,10 @@ A column's key is its name in SQL unless `PG.column` gives it another: `orgId: P
{ name: "org_id" })` is stored as `org_id` and read, written, filtered and indexed as `orgId`
everywhere else, as drizzle's `text("org_id")` is. Rows decode under the key.
-A Postgres column is `NOT NULL` unless its type is `PG.nullable(...)`. See
-[Postgres](./postgres.md) for its types and [migrations](./migrations.md#postgres) for its
-indexes and foreign keys.
+A Postgres column is `NOT NULL` unless its type is `PG.nullable(...)`. An enum column's type is
+a `PG.enumType("status", ["open", "closed"])`, which `generate` creates as a type of its own. See
+[Postgres](./postgres.md) for its types and enums, and [migrations](./migrations.md#postgres) for
+its indexes, unique constraints and foreign keys.
## Keys, defaults, and tenancy
@@ -78,7 +79,9 @@ decide the insert row type, so it is derived from the DDL rather than declared t
| `codec`, `comment` | `CODEC(…)`, `COMMENT` | unchanged |
Postgres columns take `default`, `defaultExpr`, and `identity` (`"always"` or `"by default"`);
-each makes the column optional on insert. Postgres generated columns are not modeled yet.
+each makes the column optional on insert. They also take `unique` (`true`, a constraint name, or
+`{ name, nullsNotDistinct }`), a one-column `UNIQUE` constraint, which leaves the insert type
+alone. Postgres generated columns are not modeled yet.
```ts
const Requests = CH.table("requests", {
@@ -134,7 +137,8 @@ const RoutesHourlyMv = CH.materializedView("routes_hourly_mv", {
```
Postgres indexes are `PG.index` and `PG.uniqueIndex`, over column names or expressions, with
-`where` for a partial index; foreign keys are `PG.foreignKey`. Both are covered in
+`where` for a partial index; unique constraints are `PG.unique` in `uniqueConstraints`, and
+foreign keys are `PG.foreignKey`. All are covered in
[Schema and migrations](./migrations.md#postgres).
## External tables
@@ -339,7 +343,8 @@ A brand is strict everywhere it is written or compared:
param value that fails them is a `QueryBuilderError` from `compile`.
A literal union (`PG.brand(PG.text, Schema.Literals(["open", "closed"]))`) is not a brand: it
-compares against any string, and the database checks the value.
+compares against its members, or a param of the primitive (`param.string`), which the database
+checks. A Postgres enum column, `PG.enumType(...)`, behaves the same way.
`CH.custom("String", OrgId)` brands the same way, but replaces the wire codec with `OrgId` itself;
prefer `brand` over a built-in type whose codec does work (numbers, timestamps).
diff --git a/scripts/check-doc-examples.mjs b/scripts/check-doc-examples.mjs
index f9ba68e..d8b190a 100644
--- a/scripts/check-doc-examples.mjs
+++ b/scripts/check-doc-examples.mjs
@@ -109,6 +109,14 @@ assert.deepEqual(postgres.rows, [
{ route: "/checkout", count: 2, slow: 1, p50: 510 },
{ route: "/search", count: 1, slow: 0, p50: 40 },
])
+const enums = await import("./postgres-enums")
+const enumDdl = await Effect.runPromise(enums.ddl)
+assert.match(enumDdl[0], /CREATE TYPE "channel_type" AS ENUM \\('public', 'private', 'direct'\\)/)
+assert.equal(enumDdl.at(-1), 'ALTER TABLE "channels" ADD CONSTRAINT "channels_slug_unique" UNIQUE ("slug")')
+assert.deepEqual(enums.shared.parameters, ["public", "private"])
+assert.deepEqual(await Effect.runPromise(enums.shared.decodeRows([{ id: "c", type: "public" }])), [{ id: "c", type: "public" }])
+await assert.rejects(() => Effect.runPromise(enums.shared.decodeRows([{ id: "c", type: "secret" }])))
+assert.match(sql(enums.upsert), /ON CONFLICT \\("slug"\\) DO UPDATE SET/)
const database = await import("./database-transaction")
assert.deepEqual(database.balances, { from: 70, to: 30, refused: "InsufficientFunds" })
console.log("Markdown example behavior checks passed")
diff --git a/scripts/check-package.ts b/scripts/check-package.ts
index 45a3ae4..651b2be 100644
--- a/scripts/check-package.ts
+++ b/scripts/check-package.ts
@@ -53,6 +53,8 @@ const program = Effect.gen(function* () {
},
devDependencies: {
typescript: yield* version("typescript"),
+ // Consumers still on TypeScript 5 see the built types too; see the consumer's Runnable check.
+ "typescript-5": "npm:typescript@~5.9.3",
"@types/node": yield* version("@types/node"),
},
}),
@@ -78,6 +80,16 @@ const program = Effect.gen(function* () {
}),
)
yield* runCommand(path.join(temporary, "node_modules/.bin/tsc"), ["-p", "tsconfig.json"], temporary)
+ // The TypeScript 5 checks, alone in their own program (see the file's header), on both compilers.
+ yield* fs.copyFile(path.join(root, "tests/package-consumer-ts5.mts"), path.join(temporary, "consumer-ts5.mts"))
+ const ts5Config = path.join(temporary, "tsconfig.ts5.json")
+ yield* fs.writeFileString(
+ ts5Config,
+ yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ extends: "./tsconfig.json", include: ["consumer-ts5.mts"] }),
+ )
+ yield* runCommand(path.join(temporary, "node_modules/.bin/tsc"), ["-p", ts5Config], temporary)
+ yield* runCommand("node", [path.join(temporary, "node_modules/typescript-5/bin/tsc"), "-p", ts5Config], temporary)
+ yield* runCommand("node", ["consumer-ts5.mts"], temporary)
// The script runs on Bun; the published library must also work in Node.
yield* runCommand("node", ["consumer.mts"], temporary)
yield* runCommand(
diff --git a/src/ch/compile.ts b/src/ch/compile.ts
index 323a570..5709864 100644
--- a/src/ch/compile.ts
+++ b/src/ch/compile.ts
@@ -11,7 +11,7 @@ import { compiled, compiledUnsafe, fail } from "./failure"
import { columnSqlName, custom, dateTime, dateTime64, type CHType, type ColumnDefs } from "./types"
import type { CHQuery, CHQueryState, NeedsSelect } from "./query"
import type { CHUnionQuery } from "./union"
-import { isInsert, type CHInsert } from "./insert"
+import { isInsert, type AnyInsert, type CHInsert } from "./insert"
import { isDelete, isUpdate, type CHDelete, type CHUpdate } from "./update"
import type { Table } from "./table"
import { createColumnAccessor, createQualifiedColumnAccessor, createJoinedColumnAccessor, sourceAlias } from "./query"
@@ -606,7 +606,7 @@ export function compileCH(
/** A write statement: what `compile` takes besides a query. */
export type CHWrite