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
5 changes: 5 additions & 0 deletions .changeset/constraint-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@maple-dev/effect-orm": minor
---

Constraint violations are typed errors. `refineConstraints(effect)` turns a `DatabaseError` or `TransactionCommitFailed` for a broken constraint into `UniqueViolation`, `ForeignKeyViolation`, `NotNullViolation` or `CheckViolation`, so a caller can `catchTag` a duplicate key. Each carries the `constraint`, `table` and `column` the server named. Postgres maps SQLSTATE 23505, 23503, 23502 and 23514; ClickHouse maps code 469 (`VIOLATED_CONSTRAINT`) to `CheckViolation`. SQLite and D1 map the `SQLITE_CONSTRAINT_UNIQUE`, `_PRIMARYKEY`, `_FOREIGNKEY`, `_NOTNULL` and `_CHECK` extended codes, with the table and column SQLite names. `constraintViolation(error)` reads one without failing. `DatabaseError` and `TransactionCommitFailed` now keep the server's numeric error code as `errorCode`, which ClickHouse reports and `sqlState` dropped. `ClickhouseHttpClient` classifies code 469 as `ConstraintError` instead of `StatementTimeoutError`.
2 changes: 1 addition & 1 deletion design/gap-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ builder; **P1** commonly used; **P2** niche.
| ~~`FOR UPDATE` / `FOR SHARE` / `SKIP LOCKED` / `NOWAIT`~~ (built) | 7 | S |
| jsonb and array operators (`@>`, `->`, `?`, `&&`, `ANY`) | ~12 | M |
| ~~`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 |
| ~~Constraint error helpers (unique, foreign key, not null); keep ClickHouse's numeric error codes, which `sqlStateOf` drops today~~ (built: `refineConstraints`, `DatabaseError.errorCode`; Postgres, ClickHouse, SQLite and D1) | 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~~ Done: `S.pg.table`, `dialect: "postgres"`, `--baseline --from-drizzle` (design/migrations.md section 8) | 68 tables, 90 indexes, 47 unique, 75 folders | L |

Expand Down
46 changes: 44 additions & 2 deletions docs/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ Calling `withdraw(1, 30)` outside `transfer` does not compile: `requireTransacti
| `retryContention(options?)` | Re-run on serialization failure or deadlock |
| `Transaction` | The open transaction (`depth`, `isolationLevel`, `accessMode`) |
| `isContention(error)` | Whether an error is a serialization failure or deadlock |
| `refineConstraints(effect)` | Fail with a typed `ConstraintViolation` where a statement broke a constraint ([Constraint violations](#constraint-violations)) |
| `constraintViolation(error)` | The `ConstraintViolation` an error reports, as an `Option` |

`run`, `query`, `execute`, `transaction` and `retryContention` are also methods on
`DatabaseApi`.
Expand Down Expand Up @@ -256,12 +258,52 @@ again. `read uncommitted` is not offered because Postgres runs it as `read commi

| Error | When |
| --- | --- |
| `DatabaseError` | A statement failed. `reason` is the driver's classification (`UniqueViolation`, `SerializationError`, ...) and `sqlState` the server's code |
| `TransactionCommitFailed` | COMMIT failed: a deferred constraint, a serialization failure at commit, a dropped connection. Carries `reason` and `sqlState` |
| `DatabaseError` | A statement failed. `reason` is the driver's classification (`UniqueViolation`, `SerializationError`, ...), `sqlState` the server's five-character code (Postgres) and `errorCode` its numeric code (ClickHouse) |
| `TransactionCommitFailed` | COMMIT failed: a deferred constraint, a serialization failure at commit, a dropped connection. Carries `reason`, `sqlState` and `errorCode` |
| `TransactionRollbackFailed` | ROLLBACK failed after the body failed. `bodyCause` is the body's failure, `cause` the rollback's |
| `TransactionOptionsRejected` | A setting the dialect does not support, or settings on a nested transaction |
| `TransactionUnsupported` | The dialect has no transactions (ClickHouse) |

### Constraint violations

`refineConstraints(effect)` turns a `DatabaseError` or `TransactionCommitFailed` for a broken
constraint into one tagged error per kind, so you can `catchTag` a duplicate key instead of
reading SQLSTATEs. Every other failure passes through unchanged.

| Error | Postgres SQLSTATE | ClickHouse code | SQLite and D1 |
| --- | --- | --- | --- |
| `UniqueViolation` | 23505 | — | `SQLITE_CONSTRAINT_UNIQUE`, `SQLITE_CONSTRAINT_PRIMARYKEY` |
| `ForeignKeyViolation` | 23503 | — | `SQLITE_CONSTRAINT_FOREIGNKEY` |
| `NotNullViolation` | 23502 | — | `SQLITE_CONSTRAINT_NOTNULL` |
| `CheckViolation` | 23514 | 469 (`VIOLATED_CONSTRAINT`) | `SQLITE_CONSTRAINT_CHECK` |

Each carries `message`, `sql`, the server's `sqlState` or `errorCode`, and `cause` (the error it
came from). It also carries `constraint`, `table` and `column` when the server named them:
Postgres names the constraint and the table, and the column for NOT NULL. ClickHouse names the
CHECK constraint and the table (`db.table`) in its message. ClickHouse has no unique, foreign key
or NOT NULL constraints to violate. SQLite names no unique index: a unique or NOT NULL violation
carries the `table` and, when the key has one column, the `column`. A CHECK violation carries
the CHECK's name as `constraint`, or its expression when it has no name. A foreign key violation
names nothing. SQLite's extended code is read from `node:sqlite` and from D1's message; neither
sets `sqlState` or `errorCode`.

```ts title="database-constraints.ts"
import { Effect } from "effect"
import * as Db from "@maple-dev/effect-orm/database"

// An idempotent write: a duplicate email is "already exists", not a failure.
export const register = (email: string) =>
Db.execute(Db.sql`INSERT INTO users (email) VALUES (${email})`).pipe(
Db.refineConstraints,
Effect.as("created" as const),
Effect.catchTag("@maple-dev/effect-orm/UniqueViolation", () => Effect.succeed("exists" as const)),
)
// Effect<"created" | "exists", DatabaseError | ForeignKeyViolation | NotNullViolation | CheckViolation, Database>
```

A deferred constraint fails at COMMIT, so put `refineConstraints` outside the `transaction`
to see it.

Effect fails with a bare `SqlError` on a failed COMMIT, dies on a failed ROLLBACK and, when
ROLLBACK fails, drops the body's own failure. `transaction` turns these into the typed errors above. Defects
from `body` stay defects.
Expand Down
1 change: 1 addition & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,7 @@ placeholders, double-quoted identifiers.
| `run`, `status`, `verify`, `baseline`, `MigrationDriver` and the migrate errors | `/migrate` |
| `Database`, `run`, `sql`, `query`, `execute`, `transaction`, `requireTransaction`, `afterCommit`, `retryContention`, `Transaction` | `/database` |
| `DatabaseError`, `TransactionCommitFailed`, `TransactionRollbackFailed` and the other transaction errors | `/database` |
| `refineConstraints`, `constraintViolation`, and the `ConstraintViolation` errors `UniqueViolation`, `ForeignKeyViolation`, `NotNullViolation`, `CheckViolation` | `/database` |
| `withQuerySummary`, `QuerySummary` — the `db.query.summary` of the statements an effect runs, for spans | `/database` |
| `ClickhouseHttpClient`, `layer`, `make`, `ClickhouseHttpClientConfig` — a ClickHouse `SqlClient` over `fetch`, for Workers | `/clickhouse-http` |
| `defineConfig`, `generate`, `check`, `pull`, `loadSchema`, `readMigrations`, `analyze`, `KitError` | `/kit` |
Expand Down
20 changes: 20 additions & 0 deletions src/clickhouse-http/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ describe("ClickhouseHttpClient", () => {
[516, "AuthenticationError"],
[497, "AuthorizationError"],
[159, "StatementTimeoutError"],
[469, "ConstraintError"],
[241, "UnknownError"],
] as const)("classifies ClickHouse code %s as %s", ([code, reason]) => {
const { live } = fake(() => ({
Expand Down Expand Up @@ -161,6 +162,25 @@ describe("ClickhouseHttpClient", () => {
)
})

it.effect("keeps the numeric code on DatabaseError, and a violated CHECK is CheckViolation", () => {
const body =
"Code: 469. DB::Exception: Constraint `positive` for table default.rents (7fee639d-39fa-44e2-936d-107bc35ca49b) is violated at row 1. Expression: (cents > 0). Column values: cents = -5. (VIOLATED_CONSTRAINT) (version 26.10.2.103 (official build))"
const { live } = fake(() => ({ status: 500, body, headers: { "x-clickhouse-exception-code": "469" } }))
return Effect.gen(function* () {
const insert = Db.execute(Db.sql`INSERT INTO rents VALUES (${1}, ${-5})`)
const raw = yield* Effect.flip(insert)
expect(raw).toMatchObject({ errorCode: 469, reason: "ConstraintError" })
expect(raw.sqlState).toBeUndefined()
const error = yield* Effect.flip(insert.pipe(Db.refineConstraints))
expect(error).toBeInstanceOf(Db.CheckViolation)
expect(error).toMatchObject({ constraint: "positive", table: "default.rents", errorCode: 469 })
}).pipe(
Effect.provide(Layer.effect(Db.Database, Effect.gen(function* () {
return Db.fromSqlClient(yield* SqlClient.SqlClient, { dialect: CH.clickhouseDialect })
})).pipe(Layer.provideMerge(live))),
)
})

it.effect("kills the query by id when its fiber is interrupted", () => {
const sent: Array<Sent> = []
const http = HttpClient.make((request, url) => {
Expand Down
8 changes: 6 additions & 2 deletions src/clickhouse-http/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
AuthenticationError,
AuthorizationError,
ConnectionError,
ConstraintError,
SqlError,
SqlSyntaxError,
StatementTimeoutError,
Expand Down Expand Up @@ -82,15 +83,18 @@ const Settings = Context.Reference<Readonly<Record<string, ClickhouseSetting>>>(
)

// The codes Effect's Node driver classifies, so a failure means the same thing
// whichever client ran it.
// whichever client ran it. One exception: that driver reads 469
// (VIOLATED_CONSTRAINT, a failed CHECK) as a statement timeout, which says
// retry; here it is a ConstraintError.
const SYNTAX_ERROR_CODES = new Set([36, 60, 62, 242])

const classify = (code: number | undefined, message: string, cause: unknown, operation: string) => {
const props = { cause, message, operation }
if (code === 516) return new AuthenticationError(props)
if (code === 497) return new AuthorizationError(props)
if (code !== undefined && SYNTAX_ERROR_CODES.has(code)) return new SqlSyntaxError(props)
if (code === 159 || code === 469) return new StatementTimeoutError(props)
if (code === 159) return new StatementTimeoutError(props)
if (code === 469) return new ConstraintError(props)
return new UnknownError(props)
}

Expand Down
6 changes: 6 additions & 0 deletions src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,20 @@ export {
type TransactionInfo,
type TransactionOptions,
} from "./database/database"
export { constraintViolation, refineConstraints } from "./database/constraint"
export {
CheckViolation,
DatabaseError,
ForeignKeyViolation,
NotNullViolation,
UniqueViolation,
TenantScopeViolation,
TransactionClosed,
TransactionCommitFailed,
TransactionOptionsRejected,
TransactionRollbackFailed,
TransactionUnsupported,
type ConstraintViolation,
type TransactionError,
} from "./database/errors"
export { QuerySummary, withQuerySummary } from "./database/tracing"
Expand Down
176 changes: 176 additions & 0 deletions src/database/constraint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Constraint violations as typed errors, against a real Postgres (PGlite) for
// the SQLSTATE side. ClickHouse's live CHECK test is in
// tests/database.clickhouse.test.ts; here its errors are built by hand.

import * as D1Client from "@effect/sql-d1/D1Client"
import { PgliteClient } from "@effect/sql-pglite"
import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient"
import { describe, expect, it, layer } from "@effect/vitest"
import { Effect, Layer } from "effect"
import * as SqlClient from "effect/sql/SqlClient"
import { Miniflare } from "miniflare"
import { postgresDialect } from "../pg/dialect"
import * as Db from "../database"
import * as SQLite from "../sqlite"

const Live = Layer.effect(
Db.Database,
Effect.gen(function* () {
return Db.fromSqlClient(yield* SqlClient.SqlClient, { dialect: postgresDialect })
}),
).pipe(Layer.provideMerge(PgliteClient.layer()))

layer(Live, { excludeTestServices: true })("constraint violations on PGlite", (it) => {
it.effect("a duplicate key is UniqueViolation with the constraint name", () =>
Effect.gen(function* () {
yield* Db.execute(Db.sql`CREATE TABLE users_u (id int4 PRIMARY KEY, email text CONSTRAINT users_u_email_key UNIQUE)`)
yield* Db.execute(Db.sql`INSERT INTO users_u VALUES (${1}, ${"a@x"})`)
const error = yield* Effect.flip(
Db.execute(Db.sql`INSERT INTO users_u VALUES (${2}, ${"a@x"})`).pipe(Db.refineConstraints),
)
expect(error._tag).toBe("@maple-dev/effect-orm/UniqueViolation")
if (error._tag !== "@maple-dev/effect-orm/UniqueViolation") return
expect(error.constraint).toBe("users_u_email_key")
expect(error.table).toBe("users_u")
expect(error.sqlState).toBe("23505")
expect(error.cause).toBeInstanceOf(Db.DatabaseError)
}),
)

it.effect("a missing referenced row is ForeignKeyViolation", () =>
Effect.gen(function* () {
yield* Db.execute(Db.sql`CREATE TABLE orgs_f (id int4 PRIMARY KEY)`)
yield* Db.execute(
Db.sql`CREATE TABLE units_f (id int4 PRIMARY KEY, org_id int4 CONSTRAINT units_f_org_fk REFERENCES orgs_f (id))`,
)
const error = yield* Effect.flip(Db.execute(Db.sql`INSERT INTO units_f VALUES (${1}, ${9})`).pipe(Db.refineConstraints))
expect(error).toBeInstanceOf(Db.ForeignKeyViolation)
expect(error).toMatchObject({ constraint: "units_f_org_fk", table: "units_f", sqlState: "23503" })
}),
)

it.effect("NULL in a NOT NULL column is NotNullViolation with the column", () =>
Effect.gen(function* () {
yield* Db.execute(Db.sql`CREATE TABLE tours_n (id int4 PRIMARY KEY, starts_at timestamptz NOT NULL)`)
const error = yield* Effect.flip(Db.execute(Db.sql`INSERT INTO tours_n VALUES (${1}, ${null})`).pipe(Db.refineConstraints))
expect(error).toBeInstanceOf(Db.NotNullViolation)
expect(error).toMatchObject({ table: "tours_n", column: "starts_at", sqlState: "23502" })
}),
)

it.effect("a row that fails a CHECK is CheckViolation with the constraint name", () =>
Effect.gen(function* () {
yield* Db.execute(Db.sql`CREATE TABLE rents_c (id int4 PRIMARY KEY, cents int4 CONSTRAINT rents_c_positive CHECK (cents > 0))`)
const error = yield* Effect.flip(Db.execute(Db.sql`INSERT INTO rents_c VALUES (${1}, ${-5})`).pipe(Db.refineConstraints))
expect(error).toBeInstanceOf(Db.CheckViolation)
expect(error).toMatchObject({ constraint: "rents_c_positive", table: "rents_c", sqlState: "23514" })
}),
)

it.effect("a deferred foreign key found at COMMIT is ForeignKeyViolation too", () =>
Effect.gen(function* () {
yield* Db.execute(Db.sql`CREATE TABLE orgs_d (id int4 PRIMARY KEY)`)
yield* Db.execute(
Db.sql`CREATE TABLE units_d (id int4, org_id int4 CONSTRAINT units_d_org_fk REFERENCES orgs_d (id) DEFERRABLE INITIALLY DEFERRED)`,
)
const error = yield* Effect.flip(
Db.transaction(Db.execute(Db.sql`INSERT INTO units_d VALUES (${1}, ${9})`)).pipe(Db.refineConstraints),
)
expect(error).toBeInstanceOf(Db.ForeignKeyViolation)
expect(error).toMatchObject({ constraint: "units_d_org_fk", sqlState: "23503" })
expect((error as Db.ForeignKeyViolation).cause).toBeInstanceOf(Db.TransactionCommitFailed)
}),
)

it.effect("every other failure passes through unchanged", () =>
Effect.gen(function* () {
const syntax = yield* Effect.flip(Db.execute({ sql: "SELEC 1" }).pipe(Db.refineConstraints))
expect(syntax).toBeInstanceOf(Db.DatabaseError)
const domain = new Error("domain")
expect(yield* Effect.flip(Effect.fail(domain).pipe(Db.refineConstraints))).toBe(domain)
}),
)
})

// SQLite reports each kind by its extended result code (node:sqlite keeps it
// as `errcode`; D1 only in its message). SQLite names no unique index, so a
// violation carries the table and, for one column, the column.
const SQLITE_SCHEMA = [
"PRAGMA foreign_keys = ON",
"CREATE TABLE orgs (id INTEGER PRIMARY KEY)",
"CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE, org_id INTEGER REFERENCES orgs (id), cents INTEGER CONSTRAINT positive CHECK (cents > 0), a TEXT, b TEXT, UNIQUE (a, b))",
"INSERT INTO users (id, email, cents, a, b) VALUES (1, 'a@x', 1, 'p', 'q')",
]

const sqliteViolations = (db: Db.DatabaseApi) =>
Effect.gen(function* () {
for (const sql of SQLITE_SCHEMA) yield* db.execute({ sql })
const violation = (sql: string) => Effect.flip(db.execute({ sql }).pipe(Db.refineConstraints))
return {
unique: yield* violation("INSERT INTO users (id, email, cents) VALUES (2, 'a@x', 1)"),
primaryKey: yield* violation("INSERT INTO users (id, email, cents) VALUES (1, 'b@x', 1)"),
composite: yield* violation("INSERT INTO users (id, email, cents, a, b) VALUES (3, 'c@x', 1, 'p', 'q')"),
notNull: yield* violation("INSERT INTO users (id, email, cents) VALUES (4, NULL, 1)"),
check: yield* violation("INSERT INTO users (id, email, cents) VALUES (5, 'd@x', -5)"),
foreignKey: yield* violation("INSERT INTO users (id, email, cents, org_id) VALUES (6, 'e@x', 1, 9)"),
other: yield* violation("SELEC 1"),
}
})

const expectSqliteViolations = (seen: Effect.Success<ReturnType<typeof sqliteViolations>>) => {
expect(seen.unique).toBeInstanceOf(Db.UniqueViolation)
expect(seen.unique).toMatchObject({ table: "users", column: "email" })
expect(seen.unique).not.toHaveProperty("constraint")
expect(seen.primaryKey).toBeInstanceOf(Db.UniqueViolation)
expect(seen.primaryKey).toMatchObject({ table: "users", column: "id" })
expect(seen.composite).toBeInstanceOf(Db.UniqueViolation)
expect(seen.composite).toMatchObject({ table: "users" })
expect(seen.composite).not.toHaveProperty("column")
expect(seen.notNull).toBeInstanceOf(Db.NotNullViolation)
expect(seen.notNull).toMatchObject({ table: "users", column: "email" })
expect(seen.check).toBeInstanceOf(Db.CheckViolation)
expect(seen.check).toMatchObject({ constraint: "positive" })
expect(seen.foreignKey).toBeInstanceOf(Db.ForeignKeyViolation)
expect(seen.other).toBeInstanceOf(Db.DatabaseError)
}

describe("constraint violations on SQLite", () => {
it.effect("node:sqlite: each kind is its tagged error", () =>
Effect.gen(function* () {
const db = Db.fromSqlClient(yield* SqlClient.SqlClient, { dialect: SQLite.sqliteDialect })
expectSqliteViolations(yield* sqliteViolations(db))
}).pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:" }))),
)

it.effect("node:sqlite: a deferred foreign key found at COMMIT is ForeignKeyViolation", () =>
Effect.gen(function* () {
const db = Db.fromSqlClient(yield* SqlClient.SqlClient, { dialect: SQLite.sqliteDialect })
yield* db.execute({ sql: "PRAGMA foreign_keys = ON" })
yield* db.execute({ sql: "CREATE TABLE orgs (id INTEGER PRIMARY KEY)" })
yield* db.execute({ sql: "CREATE TABLE units (id INTEGER, org_id INTEGER REFERENCES orgs (id) DEFERRABLE INITIALLY DEFERRED)" })
const error = yield* Effect.flip(db.transaction(db.execute({ sql: "INSERT INTO units VALUES (1, 9)" })).pipe(Db.refineConstraints))
expect(error).toBeInstanceOf(Db.ForeignKeyViolation)
expect((error as Db.ForeignKeyViolation).cause).toBeInstanceOf(Db.TransactionCommitFailed)
}).pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:" }))),
)

it("D1: each kind is its tagged error", async () => {
const mf = new Miniflare({
modules: true,
script: "export default { fetch() { return new Response('constraint test') } }",
compatibilityDate: "2026-07-01",
d1Databases: ["DB"],
})
try {
const binding = await mf.getD1Database("DB")
const seen = await Effect.runPromise(
Effect.gen(function* () {
return yield* sqliteViolations(Db.fromSqlClient(yield* SqlClient.SqlClient, { dialect: SQLite.d1Dialect }))
}).pipe(Effect.provide(D1Client.layer({ db: binding }))),
)
expectSqliteViolations(seen)
} finally {
await mf.dispose()
}
}, 30_000)
})
Loading
Loading