SQL and transactions
Repositories write SQL by hand over effect/sql, on Postgres, SQLite or D1. This page answers
which library and casing to use, who owns a transaction (and what replaces it on D1),
and how SQL failures become typed errors.
Use SqlClient + SqlSchema with hand-written SQL, one client per database
Section titled “Use SqlClient + SqlSchema with hand-written SQL, one client per database”Impact: HIGH every row is decoded and every statement joins the ambient transaction
- Repositories write tagged-template SQL and run it through
SqlSchema.findAll/findOne/findOneOption/findNonEmpty/void, withResult: Rowfor reads andRequest: Rowfor writes. No ORM: the table is defined in the migration DDL and in theRow, nowhere else. - The driver is whichever
@effect/sql-*package matches the database:@effect/sql-pg,@effect/sql-sqlite-node/-bun, or@effect/sql-d1on the Cloudflare Worker profile. - One
SqlClientlayer per database, provided once at the composition root. The transaction tag is minted per client instance, so a repository on a second instance silently runs outside every transaction (see layer memoization). - Accepted cost: no query builder, and a misspelled column compiles. Every repository method gets a test against a real database of its own dialect.
❌ Incorrect — a second client “for reads”:
const ReadsLayer = Layer.fresh(DbLayer) // a new instance: its statements escape transactionsexport const RuleQueriesLayer = RuleQueries.layer.pipe(Layer.provide(ReadsLayer))✅ Correct — one client, provided at the root:
const MainLayer = Layer.mergeAll(LabelingLayer, BillingLayer).pipe( Layer.provide(DbLayer), // the one SqlClient for this database)Source: notes/07-schema-and-data/sql-and-transactions.md · Decision 1, amended
Keep column names in the Row; rename in the mapper
Section titled “Keep column names in the Row; rename in the mapper”Impact: MEDIUM one spelling per column wherever SQL is involved
- No client transform (
transformQueryNames,transformResultNames,transformJsonstay unset) and noAS "camelCase"aliases. The DDL, the SQL text and theRowkey are the same string. The mapper that exists anyway does the rename. SELECT */RETURNING *is fine: theRowdecode is the column allowlist. AnASalias is only for a computed column, named in snake_case (count(*) AS rule_count).labeling_rulesis a tenant table: theRowhasorg_id, every statement pins it, andtoRowtakesorgIdfrom the caller, never from the payload.- This is the canonical row-mapper sketch, shown here with the SQLite / D1 codecs (see schema at boundaries for the Postgres ones).
❌ Incorrect — a global rename leaves two spellings in one repository:
const Row = Schema.Struct({ repositoryId: RepositoryId, createdAt: … })sql`SELECT * FROM labeling_rules WHERE repository_id = ${id}` // snake here, camel in the Row// and on Postgres transformJson rewrites jsonb keys: { userId } is stored as { user_id }✅ Correct — snake_case Row, exhaustive mapper renames:
const Row = Schema.Struct({ id: RuleId, org_id: OrgId, repository_id: RepositoryId, enabled: Schema.BooleanFromBit, evidence: Schema.fromJsonString(Evidence), client_name: Schema.String, client_ip: Schema.String, created_at: Schema.DateTimeUtcFromMillis, deleted_at: Schema.NullOr(Schema.DateTimeUtcFromMillis),})const toRow = (orgId: OrgId, { id, repositoryId, enabled, evidence, client, createdAt, ...rest }: LabelingRule) => { rest satisfies Exhausted return { id, org_id: orgId, repository_id: repositoryId, enabled, evidence, client_name: client.name, client_ip: client.ip, created_at: createdAt, deleted_at: null }}Source: notes/07-schema-and-data/sql-and-transactions.md · Decision 2, amended
Bind values in their column’s encoded form
Section titled “Bind values in their column’s encoded form”Impact: MEDIUM a plain object cannot be bound; a DB clock read hides time
- On Postgres every write binds each
jsonbfield withsql.json(…). TheRowcodec stays the plain schema, which decodes the parsed object the driver returns. - Hand-written SQL binds a time value as its column stores it:
DateTime.toDateUtc(t)fortimestamptzon Postgres,DateTime.toEpochMillis(t)forINTEGERon SQLite and D1, and a number fromClock.currentTimeMillisin everyinfra/statement. See time and clocks. - A cutoff is bound from
now. The statement never reads the database’s clock.
❌ Incorrect — a plain object bound to jsonb, and the database’s clock:
sql`INSERT INTO labeling_rules ${sql.insert(row)}` // "Cannot infer a PostgreSQL type"sql`DELETE FROM outbox_events WHERE created_at < now() - interval '7 days'`✅ Correct — sql.json for jsonb, a cutoff bound from the app’s clock:
sql`INSERT INTO labeling_rules ${sql.insert({ ...row, evidence: sql.json(row.evidence) })} RETURNING *`const now = yield* Clock.currentTimeMillissql`DELETE FROM outbox_events WHERE created_at < ${now - retentionMillis}`Source: notes/07-schema-and-data/sql-and-transactions.md · Decision 2, amended
Let the use-case own the transaction, through a Transactions port
Section titled “Let the use-case own the transaction, through a Transactions port”Impact: HIGH atomicity across slices without merging repositories
- A transaction wraps a use-case, not a repository method. Repositories never take a
txargument and never know whether they are in one. The connection is ambient, so another slice’s service call joins the same transaction. - The use-case depends on the one-method
Transactionsport (@app/infra/Transactions), not onSqlClient. It maps a failedBEGINorCOMMITtoPersistenceError, and a test fakes it withwithTransaction: (e) => e. - Signals that cannot roll back go after the transaction returns: PubSub, outbound HTTP, queue
enqueue, cache invalidation. A domain-pair event is the exception:
Outbox.appendruns inside (see idempotency and outbox). - Nesting means a savepoint. No forking inside a transaction except into work that finishes
before it. Accepted cost: a forgotten
withTransactioncompiles and autocommits.
❌ Incorrect — SqlClient in the use-case, and publishing before commit:
const sql = yield* SqlClient.SqlClientyield* sql.withTransaction(Effect.gen(function* () { const stored = yield* rules.insert(org.orgId, input) yield* PubSub.publish(events, RuleCreated.make({ rule: stored })) // sent even if rollback follows yield* audit.append(entryFor(stored))}))✅ Correct — the port owns the boundary; publish after it returns:
const tx = yield* Transactions.Transactionsconst org = yield* CurrentOrgconst rule = yield* tx.withTransaction(Effect.gen(function* () { const stored = yield* rules.insert(org.orgId, input) yield* gitHubRepositories.bumpRulesRevision(repositoryId, expectedRevision) // github's service yield* audit.append(entryFor(stored)) return stored}))yield* PubSub.publish(events, RuleCreated.make({ rule }))Source: notes/07-schema-and-data/sql-and-transactions.md · Decision 3, amended
On D1, make an atomic unit one repository method running one batch
Section titled “On D1, make an atomic unit one repository method running one batch”Impact: HIGH a D1 transaction dies at runtime
- D1 has no interactive transactions. The Worker profile’s graph does not provide the
Transactionslayer, so a use-case that needs it fails to compile there instead of dying. - A multi-statement atomic unit is one repository method running one
batch. Every value is computed first (ids from the app, invariants checked when the domain value is made). Guards on stored state go into the SQL (WHERE revision = ${expected}). No read-modify-write in a batch. - There is no atomic write across repositories or slices on D1. One slice owns all the rows, or
the follower reacts through an outbox row written in the same
batch.
❌ Incorrect — a transaction on the D1 profile:
yield* sql.withTransaction(Effect.gen(function* () { // D1's transactionAcquirer dies yield* sql`INSERT INTO finance_transactions ${sql.insert(toTransactionRow(transaction))}` yield* sql`INSERT INTO finance_postings ${sql.insert(transaction.postings.map(toPostingRow))}`}))✅ Correct — one repository method, one batch:
post: (transaction: Transaction) => d1.batch([ sql`INSERT INTO finance_transactions ${sql.insert(toTransactionRow(transaction))}`, sql`INSERT INTO finance_postings ${sql.insert(transaction.postings.map(toPostingRow))}`, ]).pipe(Effect.asVoid, Effect.catchTag("SqlError", Persistence.fail("LedgerRepo.post")))Source: notes/07-schema-and-data/sql-and-transactions.md · Decision 3, amended
Write conflicts into the SQL; everything else is PersistenceError
Section titled “Write conflicts into the SQL; everything else is PersistenceError”Impact: HIGH the same conflict handling on Postgres, SQLite and D1
- A repository’s error channel is the domain errors it can predict plus one shared
PersistenceError(@app/infra/PersistenceError, with anoperationstring and the originalcause). Nothing else. - A domain conflict is expressed in the statement:
ON CONFLICT (<unique columns>) DO NOTHING RETURNING *, and zero rows means the domain error. Matching a named constraint withcatchReasonis Postgres-only; D1 classifies nothing. - “No row” is
Option(findOneOption), not an error. ARowdecode failure is an infrastructure fault, so it becomesPersistenceErrortoo. - Map with
catchTags, notmapError, so the domain error is not wrapped. A repository never retries; see scheduling and retry.
❌ Incorrect — a raw SqlError and a pre-check read:
insert: (orgId, rule) => Effect.gen(function* () { const existing = yield* findByLabel(orgId, rule.label) // a race still becomes a 500 if (Option.isSome(existing)) return yield* new RuleLabelTaken({ label: rule.label }) return yield* insertRow(toRow(orgId, rule)) // SqlError | SchemaError leak out})✅ Correct — the conflict is in the SQL; the row count decides:
const insertRow = SqlSchema.findOneOption({ Request: Row, Result: Row, execute: (row) => sql`INSERT INTO labeling_rules ${sql.insert(row)} ON CONFLICT (org_id, repository_id, label) DO NOTHING RETURNING *` })
insert: (orgId, rule) => insertRow(toRow(orgId, rule)).pipe( Effect.flatMap(Option.match({ onNone: () => Effect.fail(new RuleLabelTaken({ label: rule.label })), onSome: (row) => Effect.succeed(fromRow(row)) })), Effect.catchTags({ SqlError: Persistence.fail("LabelingRulesRepo.insert"), SchemaError: Persistence.fail("LabelingRulesRepo.insert") }))Source: notes/07-schema-and-data/sql-and-transactions.md · Decision 4, amended
Deferred
Section titled “Deferred”- Drizzle — trigger: Drizzle’s Effect drivers are released as stable and can decode through a schema.