Spec 0022 — Persistence ports and the adapter conformance suite
spec-frozen 2026-10-04 (operator-approved from docs/research/effect-fullstack-composition.md §6). Implementation starts only after spec 0020 (Annotation.define) merges to main; until then this file i…
Status: spec-frozen 2026-10-04 (operator-approved from docs/research/effect-fullstack-composition.md §6). Implementation starts only after spec 0020 (Annotation.define) merges to main; until then this file is the contract and nothing in packages/ changes. Builds on 0020, 0015 (config registration), 0008 (parked SQL projection — superseded in scope by this spec, see §1), ADR 0004/0005/0008.
1. Outcome
A project declares persistence port methods as ordinary effx operations with no exposure; the compiler emits (a) a typed port — a Context.Service whose methods are Effects with Schema-typed inputs, outputs and typed errors, never SQL — and (b) a typed adapter conformance suite every adapter must pass. Adapters (Effect SQL, drizzle-effect) are ordinary Layers in separate packages/modules chosen at the composition root. effx generates no SQL, no migrations, no CRUD repository, no ORM schema (spec 0008's boundary; mono-web AGENTS.md:234: "Avoid generic CRUD and additional repository layers").
Why a port + suite and not ORM adapters: ORM familiarity is an adoption factor (operator premise; no measured data), but mono-web's corpus is 33 hand-written-SQL calls of 39, FOR UPDATE in 35 files and advisory locks in 31 (research §6.4). What does generalize across adapters is the typed contract, the typed error mapping and atomicity — and that is exactly what a generated suite can certify.
Non-goals: a Prisma or TypeORM adapter (none shipped; an optional probe is recorded as evidence only, §6); migrations (the ORM's tool owns them — drizzle-kit, prisma migrate, TypeORM; supersedes the report's effx db diff/generate); a repository/CRUD layer; transactions opened by generated HTTP bindings (spec 0006: the handler owns the transaction and the guard call); decorator declaration-only ports (TypeScript decorators cannot attach to abstract or declare members, so no valid body-less, externally bound decorated method form exists).
2. Declaration (no new IR node)
Source (builder .declare() is the supported declaration-only port spelling; decorators are annotation-equivalent per 0020, but a decorated method with a local body is a handler and diagnoses EFFX3401):
import { Operation } from "@effx/runtime";
import { Persist } from "@effx/persistence/syntax"; // Annotation.define half; imports only @effx/runtime
export const FindUser = Operation.query({ name: "Users.find", input: FindUserInput, success: User })
.errors(UserNotFound)
.with(Persist.Port({ port: "Users" }))
.declare();
export const SetEmail = Operation.command({
name: "Users.setEmail",
input: SetEmailInput,
success: User,
})
.errors(UserNotFound, EmailTaken)
.with(Persist.Port({ port: "Users" }))
.declare();Persist.Portis defined withAnnotation.define({ name: "persistence.Port", target: "operation", args: { port: A.string }, cardinality: "one" })(0020 §2.1); its compiler half isAnnotation.implement(Port, { read, analyze, write })registered throughdefineConfig({ extensions })(0015). Its IR contribution is oneExtensionnode (extension: "persistence",tag: "Port",data: { port }) plus oneExtensionOfedge to the operation — no newNodekind (theOperationnode already carrieskind,input,success,errors: Declared<SchemaRef>,binding: "external").- A port method is a declaration-only operation (
binding: "external") with noExposure: it is not on any transport. The method name is the operation name after the<port>.prefix (Users.find→find). - Diagnostics (plain codes; range
EFFX34xxreserved here for later registration in the 0016 registry, which has not landed): EFFX3401 error — a port method has a local handler; EFFX3402 error — a port method has an HTTP/RPC/CLI exposure; EFFX3403 error — operation name lacks the<port>.prefix or two methods of one port collide; EFFX3404 warning — a port declares only Queries (its conformance rollback/atomicity properties are vacuous).
3. Generated output (ordinary Effect, ADR 0008)
Two files per project in the output directory (names deterministic from the port name; no effx import; no runtime DI):
// <port>-port.ts — the port
export class UsersPort extends Context.Service<
UsersPort,
{
readonly find: (
input: typeof FindUserInput.Type,
) => Effect.Effect<typeof User.Type, typeof UserNotFound.Type>;
readonly setEmail: (
input: typeof SetEmailInput.Type,
) => Effect.Effect<typeof User.Type, typeof UserNotFound.Type | typeof EmailTaken.Type>;
}
>()("effx/port/Users") {}
// <port>-conformance.ts — the suite (imports only effect, @effect/vitest, the port, the declared schemas)
export interface UsersHarness<E, R> {
readonly name: string;
readonly layer: Layer.Layer<UsersPort | R, E>; // closed root: fresh store and transaction services per test
readonly transact: <A, X, Rx>(eff: Effect.Effect<A, X, Rx>) => Effect.Effect<A, X | E, Rx | R>; // the shared transaction owner
readonly snapshot: Effect.Effect<Schema.Json, E, R>; // canonical store content (rollback/purity)
readonly reset: Effect.Effect<void, E, R>; // restores empty-store content between arbitrary samples
readonly supportsConcurrentConnections: boolean; // generated scenarios branch on this capability
}
export interface UsersScenarios {
/* typed from the port: named domain scenarios */
}
export const usersConformance = <E, R>(
harness: UsersHarness<E, R>,
scenarios: UsersScenarios,
): void => {
/* it.effect / it.effect.prop */
};Methods are emitted sorted by name; the port types come from the same SchemaRefs and errorsExpr/schemaExpr helpers the HTTP generators use. The R channel of every method is never: a port is a leaf; adapters hold their own requirements in the Layer.
Amendment (implementation) — 2026-10-04
- §3 harness typing. Director-approved correction to the harness sketch
above:
layeris a closedLayer<UsersPort | R, E>root,transactreturnsEffect<A, X | E, Rx | R>, andsnapshotisEffect<Json, E, R>. The original openLayer<UsersPort, E, R>cannot run in a genericit.effect; a closed harness root owns isolation and transaction services while every port method retainsR = never.supportsConcurrentConnectionsis allowed only when a generated scenario/property actually branches on it. - §2 supported spelling. Builder
.declare()is the supported declaration-only port spelling. Decorator application is annotation-equivalent, but a decorated method with a local body remains a handler and diagnoses EFFX3401. Decorator declaration-only ports are a non-goal, not future work: decorators cannot attach to abstract ordeclaremembers, so TypeScript has no valid body-less, externally bound decorated method form. - §4 native failure laws. G1 checks every
Failreason against the declared error schemas. Several declared failures in oneCauseare legitimate Effect; anyDie,Interrupt, or undeclared failure rejects conformance. G2 compares typed failures as a multiset, preserving duplicate multiplicity but ignoring concurrent failure order, using Type-side schema equality. Authored error scenarios and the intentional rollback sentinel remain single expected failures. - §4 per-test storage and complete reset. One fresh physical PGlite/store is owned by each individual test; arbitrary samples within that test reuse it only after an explicit harness reset. Capture the empty-store snapshot before seeding the first sample, and assert after every reset that harness snapshot equals that empty baseline before applying domain seed data. This must reject incomplete resets so one sample cannot leak into the next. The root must never be shared across distinct tests; sample resource scopes and arbitrary-run counts remain unchanged.
Attribution correction — 2026-10-04
The implementation amendment in commit ed412fe incorrectly called the harness
correction operator-approved. The director approved that correction through
agent://Main. The director also decided amendment items 2–4 and the PGlite-only
implementation and separate landing procedure. These are director decisions,
not independently observed operator approvals. This note corrects attribution
without rewriting that commit or changing the accepted contract.
The original frozen status and operator premise precede this implementation. They remain historical claims from the base specification, not evidence of operator approval for these amendments.
4. The conformance suite
What the IR can derive, and therefore what is generated (all with @effect/vitest it.effect / it.effect.prop, inputs from the method's own Schema; verified present in @effect/vitest@4.0.0):
| Property | Derived from | Statement |
|---|---|---|
| G1 closed error channel | errors set | for arbitrary inputs, each method succeeds with a value that decodes under its success Type schema, or every Fail reason decodes under a declared error Type schema; several declared failures are valid; any Die, Interrupt or undeclared failure rejects conformance |
| G2 query purity claim (ADR 0006) | kind: Query | snapshot before = after any Query; two identical Queries return equal Type-side results; typed failures compare as a multiset with duplicate multiplicity preserved and concurrent order ignored |
| G3 rollback atomicity | kind: Command | for each Command and arbitrary input, transact(command >> fail) leaves snapshot equal to its pre-state |
| G4 shared-transaction | ≥ 2 Commands | for each pair of Commands, transact(a >> b >> fail) rolls both back; transact(a >> b) commits both (checked by a scenario-supplied observer) |
What is not derivable and stays user-written, once, shared by all adapters: domain scenarios (UsersScenarios): seed data, expected results, and which input raises which declared error (e.g. a duplicate email → EmailTaken). The generated types make a scenario for a method that does not exist, or an expected error outside the declared set, a type error — that is the single-source property. Concurrency/lock-contention properties are marked requiresConcurrentConnections and skipped for single-connection drivers (§5).
The suite must be able to fail: the gate includes two deliberately broken adapters (non-atomic: ignores the transaction; mis-mapping: unique violation escapes as a defect) and asserts the suite rejects each with a named failing property (falsifier 7).
5. Reference adapters and the database
- Effect SQL:
effect/sqlSqlClient(withTransaction, ambientTransactionConnection,uninterruptibleMask-guarded rollback on failure and interruption —effect@4.0.0sql/SqlClient.ts:61-68,341-394) over@effect/sql-pglite@4.0.0. - drizzle-effect:
drizzle-orm@1.0.0-rc.4effect-pglite(a Drizzle database that requires the Effect SQLPgliteClient;db.transactiondelegates toclient.withTransaction, so Effect SQL and Drizzle share one ambient transaction — verified, below). - Version/patch facts (observed 2026-10-04 in
temporary workspace,effect@4.0.0, bun 1.3.13): importingdrizzle-orm@1.0.0-rc.4/effect-pgliteagainst stable Effect throwsTypeError: Schema$1.TaggedErrorClass is not a functionatdrizzle-orm/cache/core/cache-effect.js:97. Applyingbunx @yielded/drizzle-effect-v4-patch@beta patch(@yielded/drizzle-effect-v4-patch@0.1.0-beta.14, MIT, fromyielded-dev/auth68a4679) writes a BunpatchedDependenciesentry andpatches/drizzle-orm@1.0.0-rc.4.patch(38,729 bytes); after it the import succeeds. The patch targets exactly1.0.0-rc.4and rejects other builds (its README). The repo commits the generated patch file with this provenance; removing the patch is a follow-up when Drizzle ships a stable-Effect-compatible release (peer ranges already alloweffect >=4.0.0; the incompatibility is in the rc.4 code, not the declared peer).@effect/sql-drizzle@0.51.0is v3-only (peerseffect ^3.22) and is not used. - Observed behaviour on PGlite with the patch (same spike): (1) a Drizzle insert + a raw Effect SQL insert inside one
sql.withTransactionthat then fails leave 0 rows; the same without failure leaves 2; (2) a nesteddb.transaction(...)inside the outer failingsql.withTransactionis also rolled back; (3) a unique violation surfaces from Effect SQL asSqlErrorwithreason._tag = "UniqueViolation"andconstraint = users_email_key(SQLSTATE23505), and from Drizzle asEffectDrizzleQueryErrorwhosecauseis an EffectCausethatCause.squashunwraps to that sameSqlError— so adapters map one error shape, after unwrapping the Drizzle cause. - Database: PGlite, not a spawned PostgreSQL. PGlite is PostgreSQL itself compiled to WASM (
@electric-sql/pglite@0.5.8reportsPostgreSQL 18.3 … wasm32-unknown-emscripten), in-process, hermetic (noinitdb, port reservation, sentinel or teardown — the machinery mono-web'sstartDisposablePostgresowns,tools/postgres/index.ts, used here as reference only; nothing is imported from mono-web), and both reference drivers exist for stable Effect (@effect/sql-pglite@4.0.0, 2026-10-01). It gives real SQLSTATE codes, real constraints and real transactions, which is what G1-G4 and the error-mapping falsifier need. Its limit (README: "PGlite is single user/connection"): no concurrent connections, so lock contention,FOR UPDATEblocking and isolation anomalies cannot be exercised; those properties arerequiresConcurrentConnections, skipped on PGlite, and runnable against a pool-capable driver (@effect/sql-pg) when a PostgreSQL URL is supplied — an opt-in job, not part of the default gate.
6. Falsifiers / acceptance (all on a clean committed worktree)
Fixture: examples/users's operations reused verbatim, plus a sibling example (or in-place extension, whichever does not collide with 0015/0020 owners at implementation time) that adds the declaration-only port methods, two adapters, the shared scenarios and the existing live-server journey.
- Same journey on both: the live-server e2e (generated HTTP + RPC, persistence across requests, typed failure —
STATE.md, spec 0003) passes againstUsersSqlandUsersDrizzle, each on its own fresh PGlite. - Typed unique violation: a duplicate email surfaces as the declared
EmailTaken(not a defect, notSqlError) on both adapters, asserted by the shared scenario. - Atomicity: G3 and G4 pass on both adapters; plus mixed shared transaction — one
UsersSqlcall and oneUsersDrizzlecall inside a singlesql.withTransactionroll back together and commit together. - Interruption is recorded, not claimed: a test interrupts a running call on each adapter and records whether the transaction rolled back and the connection stayed usable; the observed outcome is written into the evidence doc. No expected value is asserted beforehand.
- Adapter choice is invisible to the compiler:
semanticHashand every generated byte are identical regardless of which adapter module the project imports. - Single source: deleting a port method from the declarations, or adding one, makes
tscfail both adapters (excess/missing property of theLayer.succeed(UsersPort, …)literal); a scenario naming a missing method or an undeclared error failstsc. - The suite can fail: the two deliberately broken adapters (§4) fail named properties.
- Optional probe, evidence only: if a PostgreSQL URL and
effect-prisma-generator@0.10.0+prisma ^7are available, run the same suite once against a Prisma-backed adapter and record pass/fail and any interruption/transaction findings indocs/research/; if unavailable, record "not run". Not shipped, not gated. - Gates: focused tests,
bun run check,effect:diagnostics, the rc.116 fixture typecheck (generated port files must typecheck against both Effect families through the existing target-profile mapping), ff-merge, pack the exact commit, remove clean worktrees. No deploy, no production DB, no network at test time (PGlite is a local dependency).
7. Seams, ownership, coordination
- New package
packages/persistence(@effx/persistence):syntax(theAnnotation.definehalf, runtime-importable) andcompiler(implement/read/analyze/write); depends on@effx/compiler,@effx/runtime,@effx/ir; core imports nothing from it. Adapter helper packages (makeTx-style helpers, error mappers) are separate and out of the compiler's dependency graph. - It needs from 0020:
Annotation.definewithtarget: "operation"accepting a declaration-only builder operation via.with(...);Annotation.implementwith a freewrite: Generator;Annotation.extensionfordefineConfigregistration. Start is coordinated with the 0020 owner (lift-design) and the 0015 config owner (monoweb-gap); no file owned by them is edited before their merge. - Update
docs/specs/0008-sql-projection-extension.mdstatus to superseded in scope by 0022 at implementation time.
Spec 0021 — Surface manifest and `effx surface check`
spec-frozen 2026-10-04 (operator-approved from docs/research/effect-fullstack-composition.md §2/§4 item 4). Builds on 0001, 0003, 0005, 0006, 0010. Orthogonal to 0015 (config) and 0020 (annotation de…
Spec 0023 — TeamApplications migration, Gate 4
Next Page