@ultimat3/entity 1.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md ADDED
@@ -0,0 +1,694 @@
1
+ # @ultimat3/entity
2
+
3
+ Columns + invariants; the row type is derived from the columns. Tier 2.
4
+
5
+ ## Boundary
6
+
7
+ - May import `@ultimat3/core`, `@ultimat3/schema` and `@ultimat3/db`. Nothing else — `http`,
8
+ `policy` and `auth` are the same tier.
9
+ - `db` is tier 1 (it imports only `core`), which is what lets the Postgres driver live **here**
10
+ rather than in a tier-3 package: `Driver` and its production implementation stay in one place.
11
+ See [`docs/architecture/01-package-map.md`](../../docs/architecture/01-package-map.md).
12
+ - No `drizzle-orm` dependency, and none is the production backing — `postgresDriver()`
13
+ (`pg-driver.ts`/`pg-sql.ts`) is a hand-written SQL driver. `types.ts` declares the narrow
14
+ structural column vocabulary this package consumes so the generated SQL stays readable and
15
+ an agent can self-correct against it.
16
+
17
+ ## Do not regress
18
+
19
+ - **Two drivers, one meaning.** `memoryDriver()` and `postgresDriver()` share `plan.ts` (scope,
20
+ sort order, page size), `cursor.ts` (one codec, values included) and the `Repo` contract, so a
21
+ test that passes against memory says something about Postgres. A guard, an operator or a sort
22
+ rule added to one and not the other is the bug this split exists to prevent — `pg-driver.test.ts`
23
+ pins the parity, and every bulk method added since carries the same two files: a
24
+ `*-parity.test.ts` seeding identical rows into both drivers and asserting identical output
25
+ (`batch-parity.test.ts`, `preload-parity.test.ts`, `count-by-parity.test.ts`, and the
26
+ `insertAll`/`upsertAll` cross-driver assertions inside `pg-driver-bulk.test.ts`), and a
27
+ `pg-driver-<feature>.live.test.ts` proving the same call against a real server
28
+ (`pg-driver-batch.live.test.ts`, `pg-driver-preload.live.test.ts`, `pg-driver-count.live.test.ts`,
29
+ `pg-driver-bulk.live.test.ts`, `pg-driver-tenancy.live.test.ts`). A method with only the first is
30
+ unproven against Postgres itself; a method with only the second is unproven against memory. Both
31
+ are the bar, not either one.
32
+ - **The Postgres driver is proved against a real Postgres, not only against a recording client.**
33
+ `pg-driver.live.test.ts` runs the whole chain — `entity()` -> `$describe()` ->
34
+ `generateMigration()` -> a live server -> `postgresDriver()` -> decoded row — and skips when no
35
+ `TEST_DATABASE_URL` is set. Asserting statement *text* cannot catch a statement Postgres refuses:
36
+ that is how a `unique()` column shipped a migration failing on `42P07` and money's currency
37
+ shipped as `char(1)`. A new operator, column kind or write path is not done until it round-trips
38
+ there.
39
+ - **A point lookup batches itself, and the batch is never wider than the statement it replaces.**
40
+ `findById` called several times in one microtask of one request is one `select … where "id" in
41
+ (…)` — `coalesce.ts`, keyed by ctx identity (a `WeakMap`, so the batch dies with the request, the
42
+ shape `@ultimat3/query`'s request memo has one tier up) and by a scope key covering **every**
43
+ input to the statement except the id. Two tenants, two soft-delete visibilities, two projections,
44
+ two entities or two clients therefore never share one: a coalesced statement has to be one each
45
+ of the singles would have been served by, or a caller is answered with rows their own statement
46
+ could never have returned. It declines rather than guesses — no request in scope, a composite
47
+ key, a predicate value it cannot render — and declining is just the statement `findById` always
48
+ sent, which is why `findById` keeps its signature and there is no `batch()` to opt into. The
49
+ window closes before the statement goes out, so a lookup arriving mid-flight opens the next batch
50
+ instead of joining ids already on the wire, and past `MAX_IDS_PER_STATEMENT` a batch becomes
51
+ several whole statements rather than one Postgres refuses for its bind count. **No caller of a
52
+ batch is ever left unsettled** — every promise `coalesceFindById` returns was handed out before
53
+ the flush was scheduled, so `flush` settles the whole of `waiting` in a catch of its own rather
54
+ than only the chunk that failed, and the scheduled `flush` carries a `.catch`: a rejection there
55
+ has nobody left to hand it to and an unhandled one ends the Bun process. Unsettled forever is
56
+ strictly worse than failed — a rejection is a stack trace and a hang is a request that never
57
+ answers — which is why `coalesce.test.ts` races its assertions against a deadline instead of
58
+ letting the runner time out. `jit-preload.ts` has the same property by construction, settling
59
+ with an `Answer` rather than a rejection. A sequential
60
+ `for … of` loop shares no microtask — its `await` ends the window — which is what the sibling
61
+ preload below is for.
62
+ - **A page batches the loop it causes, and a preloaded row is only ever served to the statement
63
+ that read it.** `findMany` leaves its page's foreign key *values* behind (`jit-preload.ts`,
64
+ `tagSiblings`), so the first `findById` for any one of them resolves that key for every row of
65
+ the page in one `in` statement and the rest of a `for … of` loop is memory. Five rules, none
66
+ optional. **The scope guard is a security boundary**: a preloaded row is served only under the
67
+ *same* `scopeKey` the coalescer uses — same tenant predicate, same soft-delete visibility, same
68
+ projection, same entity — and the preload statement is that scope widened to the page's ids, so
69
+ a page read under one tenant can never resolve another tenant's rows, whichever tenant asks.
70
+ **Same client, or nothing**: a bucket filled through the ambient pool is not read through a
71
+ pinned one, which is also what stops a row read inside a transaction being served after it —
72
+ `db()` hands back a different client once the transaction is over, rolled back or not.
73
+ **A write drops it**: `postgresRepo`'s `writing()` is the one place every write goes out, and it
74
+ calls `forgetPreloaded(entity.$name)` *before* the statement, so a row a request changed is
75
+ re-read and never served from a page read before it. **Values, not rows**: the index is keyed by
76
+ id and holds ids, so a page early in a long request pins its keys and not its rows, and it dies
77
+ with the request like every other per-ctx store here. **Declining is the old behaviour**: no
78
+ request in scope, an id no page indexed, a key that resolved to nothing — the caller reads the
79
+ statement it always read. `MAX_IDS_PER_STATEMENT` bounds the preload exactly as it bounds a
80
+ batch. What both share — the scope key, `keyOf`, the one `in` statement — lives in
81
+ `batch-read.ts` so the two can never disagree about when a shared statement is legal.
82
+ **One switch, where the driver is built**: `postgresDriver({ jitPreload: false })` /
83
+ `postgresRepo(entity, { jitPreload: false })` turns the tagging off. Never an `app.config.ts`
84
+ key — nothing reads config at the seam that builds a repository, so a `database.jitPreload`
85
+ field would be a switch the framework cannot read, which is a switch that does nothing.
86
+ - **`preload(name)` shares `batch-read.ts` with the coalescer and the JIT preload above, but
87
+ keeps no request-scoped cache of its own.** `keyOf`, `MAX_IDS_PER_STATEMENT` and
88
+ `statementChunks` come from the same file, so a bind-count bound and a key's identity can
89
+ never disagree across the three — but `preload()` reads its scope straight off the chain's
90
+ own `where` and issues its statement every call; nothing here declines to an old statement
91
+ the way the coalescer or the JIT preload can, because there is no old statement to decline
92
+ to — a chain that calls `preload('author')` always gets the extra statement. **Tenancy is
93
+ carried, never inferred, and that is a security boundary, not a convenience**:
94
+ `tenantScope()` carries the page's own tenant predicate onto the related read only when
95
+ **both** entities are scoped by a column of that same name — a value that scopes one entity
96
+ is a guess on another, and serving a guessed scope is a cross-tenant read. Both ends are
97
+ checked, never the target's alone: a source scoped by `workspaceId` may still carry an
98
+ ordinary `orgId` predicate of its own, and matching on the target's column name would lift
99
+ that filter into the target's tenant scope and attach rows from a tenant nobody proved this
100
+ reader owns. A differently-named column carries nothing, on purpose, so the related read
101
+ builds an unscoped plan of its own and `assertScoped` refuses it as `X_TENANCY_UNSCOPED`
102
+ rather than let it pass. **Reach is the same `database()` set the two bullets above already answer
103
+ to**: `RelatedTables` is the resolver `database()` hands every table it builds, an
104
+ entity-name → `{ entity, repo }` map closed over the same call, so `preload('author')`
105
+ resolves `author` only when that call named the entity the relation points at — outside it
106
+ is `X_INVARIANT_VIOLATED`, never a reach around the handle. `tableFor(entity, repo)` built
107
+ by hand takes no `related` resolver, so the identical call fails the identical way with
108
+ `related` itself `undefined`. **A projection cannot drop what a preload needs**:
109
+ `select()` widens its own field list with each preloaded relation's local key, so
110
+ `plan().select` — the projection that actually runs — always carries it, though the row
111
+ type the caller sees still names only what they picked. **Attachment copies, never
112
+ mutates**: a preloaded relation is written onto `{ ...row }`, because the in-memory driver
113
+ hands back the row it stores and attaching directly would leak the relation into the table
114
+ itself. **Preloading terminals only**: `page()`, `all()` and `one()` resolve every named
115
+ relation; `count()`, `countBy()` and `plan()` do not, since none reads a row to attach one to.
116
+ - **Every repository method attributes the statement it sends, and each op is named exactly
117
+ once.** `postgresRepo`'s `attributed(op, send)` wraps `findById`, `findMany`, `insert`,
118
+ `insertAll`, `upsertAll`, `update`, `delete`, `deleteWhere`, `updateWhere`, `count` and
119
+ `countBy` — every method, not a subset — through `@ultimat3/db`'s
120
+ `withStatementAttribution(entity.$name, op, send)`. Each method declares `const op = 'findById'`
121
+ (or its own name) once, and that same local is what everything else downstream of it gets too:
122
+ the plan builder (`idPlan(entity, id, options, op)`, `readPlan(entity, args, op)`,
123
+ `deletePlan`/`updatePlan`), and in `countBy`, `groupColumnOf` and `countsFrom` besides — so the
124
+ operation a refusal names and the operation a diagnostic reports can never drift apart, one
125
+ string read as many times as a method needs it and never retyped by hand a second time. The
126
+ three insert paths do not call `attributed` themselves: `writeRows(op, batch, conflict)` does,
127
+ once, because a batch wide enough to split (past `MAX_BIND_PARAMETERS`) is several statements
128
+ sent inside its own loop and every one of them belongs to the call that asked for it — `op` is
129
+ therefore `writeRows`'s own parameter, passed as the literal `'insert'`, `'insertAll'` or
130
+ `'upsertAll'` by each of the three callers, never a constant closed over the helper. **The scope
131
+ is never entered with no observer installed** — `withStatementAttribution` reads
132
+ `statementObserver()` first, so an app running with no diagnostic pays the one property read and
133
+ one branch every other statement on this path already pays, and nothing more (axiom 6). **A
134
+ preloaded relation is attributed to the related entity and its own operation, never to the read
135
+ that triggered it** — `preload()`'s related read (`preloaded()` in `preload.ts`) calls
136
+ `target.repo.findMany(...)`, the related entity's own `postgresRepo`, so a `posts` page's
137
+ preloaded author carries `{ members, findMany }`, never `{ posts, findMany }` borrowed from the
138
+ page: it is a full call through that entity's own repo, not a fact copied across. **`findById`'s
139
+ coalesced flush carries its opener's pair without anyone threading it there** — `coalesce.ts`'s
140
+ `queueMicrotask` inside `openBatch` is scheduled synchronously while `coalesceFindById` is still
141
+ running inside `attributed('findById', …)`'s scope, so the statement the flush eventually sends
142
+ on behalf of every lookup that shared the microtask is attributed exactly as each of them would
143
+ have been alone. **This is the one rule the two drivers do not share**, and not a drift:
144
+ `memoryRepo` sends no statement, so there is nothing for a pair to name — the parity bar
145
+ (`*-parity.test.ts`) applies to what a call *answers*, and attribution changes no answer.
146
+ `pg-driver-attribution.test.ts` is the pin: a client that reads `statementAttribution()` at send
147
+ time, one case per method — a twelfth method added without `attributed` is a failing test, not a
148
+ review comment — plus the coalesced flush, the sibling preload, a relation's own read, a chunked
149
+ batch's every statement, hand-written SQL (no pair), a refusal (no statement) and the
150
+ no-observer-installed branch.
151
+ - **The two N+1 codes are owned here, and their `fix` is a call the schema already answers.**
152
+ `X_N_PLUS_ONE_QUERY` and `X_N_PLUS_ONE_WRITE` live in this package rather than in the process
153
+ that detects them, because the fix speaks this package's vocabulary — `preload`, `insertAll`,
154
+ `updateWhere` — and a code owned by the CLI would put the one sentence an author acts on in a
155
+ package the entity layer cannot see. **Detection is somebody else's**: `n-plus-one.ts` counts
156
+ nothing, holds no threshold and installs no observer; it takes a verdict (`StatementLoop`) and
157
+ returns the error. **The relation is derived, never invented** — `preloadsFor()` reads the same
158
+ `relationMap()` `preload()` resolves against, so the pasted line compiles; the operation picks
159
+ the side (`findById` → `belongsTo`, `findMany` → `hasMany`), and anything else takes the `in`
160
+ form rather than a relation that would attach the wrong rows. **The threshold is owned here too** —
161
+ `N_PLUS_ONE_THRESHOLD` (5) sits with the codes because it is the number that decides a *verdict*,
162
+ and there are two detectors reading it: `x dev`'s ledger and `@ultimat3/testing`'s `statements`
163
+ fixture. Two numbers would make a loop that fails a test a different loop from one that warns in
164
+ dev. What a *unit of work* is stays each detector's — a request there, one test here. **Edges are
165
+ read by their `to` end**, because the loop repeated on the entity being looked up and the ledger never saw the
166
+ `for … of` above it — so every page that could preload it is named, first one pasteable and the
167
+ rest after it, exactly as `preloadUnknownRelation` spells its names. **A schema whose relations
168
+ cannot be named still reports the loop**: `relationMap()` throws `X_INVARIANT_VIOLATED` on two
169
+ keys it cannot tell apart, and a diagnostic that let that escape would replace the N+1 with a
170
+ schema complaint the loop did not cause — in a dev process, as an uncaught throw — so the
171
+ derivation falls back to the `in` form. **`expectedQueryLoop` is the only way to declare a loop
172
+ deliberate**, and it silences the count upstream; there is no flag on these errors and no fix
173
+ that turns the warning off.
174
+ - **A page is bounded whether or not the caller bounded it.** `DEFAULT_PAGE_SIZE` (50) answers the
175
+ read nobody sized; `MAX_PAGE_SIZE` (10,000), beside it in `plan.ts` so both drivers read one
176
+ number, answers the read they did. `limit(rows)` was `next({ limit: rows })` and nothing else —
177
+ no integer check, no positivity check, no ceiling — so an action taking `pageSize` as input and
178
+ passing it through bound whatever a client sent, and one request could ask for five million rows.
179
+ `assertPageSize` is `assertBatchable`'s three refusals in the other call, deliberately under the
180
+ same code (`X_INVARIANT_VIOLATED`) because `limit(0)` and `inBatches(0)` are one mistake in two
181
+ places. Called from **both** `limit()` on the chain (so the refusal lands on the line the author
182
+ wrote) and `planFor` (so `findMany({ limit })` straight at the repository cannot route around it),
183
+ and `MAX_PAGE_SIZE` bounds `inBatches(size)` too — a batch IS a page, so the ceiling belongs to
184
+ the range and not to one of the two calls.
185
+ - **A repository pinned to its own client refuses to run inside a transaction**
186
+ (`X_REPO_CLIENT_PINNED`), `As of 2026-08`. `client()` in `pg-driver.ts` is the one place a
187
+ connection is chosen, which is why the guard is there and not on each method. Unpinned, `db()`
188
+ answers with the open transaction — that is how a call inside `withTransaction` joins it.
189
+ Pinned through `postgresDriver({ client })` it cannot: `withTransaction` ran `BEGIN` on a
190
+ connection it reserved, and a statement sent straight to `config.client` takes a different one
191
+ out of the pool, so the write commits whatever the transaction decides and survives its rollback
192
+ while the read misses what the transaction wrote — silent both ways. **Refused, not resolved**:
193
+ a `DbTx` does not name the client it was opened on, so this layer cannot tell whether the open
194
+ transaction is even on the same database, and on a sharded app it is not. Joining it instead
195
+ would be the same guess with the worse outcome. The `fix` names `setDbClient(client)` plus an
196
+ unpinned repository, because `db()` resolving `currentTx()` first is the only path a repository
197
+ joins a transaction through.
198
+ - **Cursor pagination only.** OFFSET is wrong under concurrent writes: an insert before the
199
+ offset shifts every later page, so a client silently skips and repeats rows. No `offset` on
200
+ `FindManyArgs` or the builder; the primary key is always the last sort key, so the order is
201
+ total. The cursor carries the sort **values**, not just an id — seeking by an id that was
202
+ deleted between two requests would restart pagination at the top.
203
+ - **`inBatches(size)` is that same page in a loop, and the loop owns it.** `batch.ts` holds no
204
+ driver of its own: a batch is the `findMany` the chain would have sent at that position, so
205
+ filters, tenancy, soft delete, the projection and every `preload()` mean there what they mean in
206
+ `page()` and there is no second read path to drift. Properties, none optional. **The handle is
207
+ the iteration**: it is its own iterator, so `break`, `return`, a throw and `await using` all stop
208
+ the *next* statement — `close()` is `AsyncGenerator.return()` and therefore idempotent by
209
+ construction, never a flag two paths could disagree about — and a second `for await` continues it
210
+ instead of re-reading the table from the top. **The position is readable**: `.cursor` is where
211
+ the next batch starts, advanced *before* the yield, so a consumer that breaks reads the position
212
+ it stopped at and `.after(cursor).inBatches(size)` resumes it; stopping early is then cheap
213
+ rather than wasted. **An empty batch is never yielded** — a consumer forced to check
214
+ `batch.length` is reading around the iterator. **Three refusals, all on the chain**: a size that
215
+ is not a whole number of rows ≥ 1, a chain that also called `limit()` (one number, two meanings —
216
+ honouring it reads a fraction of a batch, dropping it reads the whole table the caller thought
217
+ they had bounded), and an ordering no cursor can carry. That last one is why
218
+ `totalOrder(entity, orderBy)` is exported from `plan.ts` rather than inlined in `planFor`: the
219
+ guard has to judge the order the driver will *send*, primary key included, and a result that fits
220
+ in one batch mints no cursor — so a nullable sort key would otherwise pass in every test and fail
221
+ once the table grew. `State.limit` is `number | undefined` for the same reason: only "the caller
222
+ named a page size" can be told apart from the default, which the driver already applies.
223
+ - **A grouped count means one thing in both drivers, and `count-by.ts` is where that one thing is
224
+ written.** `countBy(column)` is the aggregate a `count()` per row is the N+1 of, so both drivers
225
+ call `groupColumnOf` before their statement exists and `countsFrom` after their rows are in — a
226
+ rule added to `pg-driver.ts` or to `repo.ts` alone is exactly the drift that file exists to
227
+ prevent. **Groupable kinds are a closed set**: `uuid`, `text`, `char`, `boolean`, `integer`,
228
+ `bigint`. A `timestamptz` is a `Date`, a `jsonb` is an object and `money` is two physical columns
229
+ — a `Map` compares a non-primitive key by identity, so any of those would file rows under a key
230
+ no caller can look up again and the result would be a map that only ever answers `undefined`. The
231
+ refusal is `X_INVARIANT_VIOLATED` naming a column of *this* entity that is groupable, never
232
+ `x entity explain`: what repairs it is one edit to the call, and the entity is the only place the
233
+ replacement column lives. **The bound is a refusal, not a truncation.** The statement asks for
234
+ `MAX_GROUPS + 1` groups — the trick a page already uses when it reads one row past its limit — and
235
+ that extra group is what says the answer was never going to fit, so `countsFrom` throws with the
236
+ `andWhere(…, 'in', <values>)` that bounds it. Truncating would hand back a map that reads exactly
237
+ like a complete one, and a caller recounting from it would write the wrong number to every row it
238
+ missed. **Absent is not `0`**: a value nothing matched has no entry, because that is what
239
+ `group by` returns and it is the only way a caller can tell "none" from "never asked" — the
240
+ `?? 0` is theirs to write, and inventing it here would answer for keys the table has never seen.
241
+ **NULL is one group**, keyed `null`: the memory driver reads the property as `?? null` so it lands
242
+ where Postgres puts its NULL rows, while `0`, `''` and `false` stay the values they are. **The
243
+ order is applied after the rows are in, never in SQL** — a hash aggregate returns groups in
244
+ whatever order it built them and a `Map` filled row by row returns insertion order, so an
245
+ `order by` in the statement would let the two drivers disagree about a result they agree on;
246
+ sorting groups (never rows) costs nothing at this size and is what puts the largest bucket at the
247
+ front. **Both output names are fixed aliases** — `group_value` and `group_count` in
248
+ `countByStatement` (`pg-sql.ts`) — because an entity is free to declare a column called `count`,
249
+ and the un-aliased form would then return two outputs of one name; the grouped value is re-parsed
250
+ by the column that declared it, since `int8` arrives as a string and would otherwise key the map
251
+ by text where memory keys it by a `bigint`. **Nothing new to declare**: no `groupBy()` builder and
252
+ no error code of its own — it is a terminal on the chain that already exists, over exactly the
253
+ rows `count()` counts.
254
+ - **The codec is `@ultimat3/core`'s, and both drivers reach it through exactly two functions**:
255
+ `cursorFor(entity, plan, row, id)` and `seekFrom(entity, plan)` in `cursor.ts`. Both call
256
+ `assertSeekable`, so an ordering that cannot carry a position — a nullable key, an undeclared
257
+ column, a money property named without `.minor`/`.currency` — is refused when the cursor is
258
+ *minted*, not one page later where the page size decides whether anyone finds out. This package owns only
259
+ what a cursor is *bound* to — `planScope(plan)`: the entity, its filters and its sort order,
260
+ hashed. Not the page size (a bigger next page is the same query) and not `select` (a projection
261
+ cannot move a row). A cursor that fails either the signature or the scope is `X_CURSOR_INVALID`;
262
+ it must never decode to "start from the top", which is what the old codec's `null` did.
263
+ - **A relation is a foreign key read a second way, never a second declaration.** `relations.ts`
264
+ derives `belongsTo` from an entity's own `references()` columns and `hasMany` from the inbound
265
+ ones; there is no `hasMany: […]` init key and adding one would put two declarations of one fact
266
+ in the schema. A thunk is resolved in exactly one place — `referenceBinding()` in `column.ts` —
267
+ so the DDL projection (`describe.ts`) and the relation map can never disagree about what a
268
+ `references()` points at. Naming is order-independent by construction: when two keys want one
269
+ name, **every** member of that group takes its long form, so declaring a second foreign key
270
+ never renames the first relation behind a caller's back. What the two tiers cannot separate is
271
+ refused with `X_INVARIANT_VIOLATED` naming both columns — never collapsed into one relation.
272
+ - **An index is described whole — columns, uniqueness, predicate, direction — never by its name
273
+ alone.** `EntityDescription.indexes` is a list of `IndexDescription`, not of strings, because the
274
+ `<table>_<a>_<b>_idx` name `entity()` mints joins with `_` and cannot be read back: a two-column
275
+ index recovered from its own name became the single column `"org_id_created_at"`, so
276
+ `generateMigration` emitted DDL Postgres answers `42703` and every composite index in the
277
+ framework — including the composite unique one `upsertAll`'s `on conflict` is inferred against —
278
+ had to be written by hand. `where` and `order` ride along for the same reason: a partial index
279
+ emitted as a total one refuses rows the entity allows. `on: []` is refused at declaration
280
+ (`X_INVARIANT_VIOLATED`), where the author can see it.
281
+ - **Relations reach query time through `RegistryEntry.references()`, and the DDL string is
282
+ rendered from it.** The resolved records are the source; `ColumnDescription.references` spells
283
+ `"<table>.<column>"` out of one for the migration generator, which is in tier 1 and cannot
284
+ import this package. Never parse that string back — it carries physical names and a traversal
285
+ reads row *properties*, so the parse would be a second, lossy resolver. `references()` is a
286
+ method, not a field: a thunk may point at an entity two modules of an import cycle have not
287
+ finished evaluating. `relationMap()` memoises the whole-registry derivation against
288
+ `registryGeneration()`, which every registration bumps — a schema module imported late must
289
+ rebuild the map, never be missed by it. The derivation is **one pass** over the foreign keys,
290
+ filed under both ends as it goes — a rescan per entity is the schema squared, paid again after
291
+ every late registration. `relationNamed()` refuses an unknown name with
292
+ `X_PRELOAD_UNKNOWN_RELATION` whose `fix` is a `relationNamed()` call on a relation that does
293
+ exist, the rest by name after it; a relation is derived, so there is no file a reader could open
294
+ to find them. An entity with no foreign key at all gets `x entities list --json` instead — the
295
+ declaration it needs names a target this error cannot know.
296
+ - **The process default driver has a name, and emptying it is optional on the seam.**
297
+ `defaultDriver()` returns the one `database()` falls back to when a call names none — exported so
298
+ a test harness seeds and empties the object the app actually reads through, since a second
299
+ `memoryDriver()` of its own would be invisible to every `database()` call already made.
300
+ `Driver.reset?()` is **optional**, implemented by `memoryDriver()` and by nothing else:
301
+ `postgresDriver()` leaves it undefined because those rows are the app's, so a harness writes
302
+ `driver.reset?.()`. The reset runs `MemoryRepo.reset()` on the repositories already handed out —
303
+ in place, never a replacement — because `database()` resolves each table's repository once and a
304
+ swapped-in repository is emptied where nothing is reading. Test seam only: no framework code path
305
+ calls either, and neither is a fixture system.
306
+ - **A repository call rejects, never throws synchronously** — `tableFor`'s writes are `async` for
307
+ that reason alone: `$parse` throws, and a call site should not need two error paths for one
308
+ mistake.
309
+ - **Tenancy applies to writes too, and in two places.** `update(id, patch)`, `delete(id)`,
310
+ `deleteWhere(filter)` and `updateWhere(filter, patch)` build the same plan a read does, so an id
311
+ or a filter alone never addresses a row on a tenant-scoped entity — another tenant's id reads as
312
+ `X_NOT_FOUND`, never as their row. That bounds WHICH rows a write touches; it cannot bound what
313
+ they become, and `insert`/`insertAll`/`upsertAll` build no plan at all. So the VALUE is judged as
314
+ well, by `assertRowTenant` (`tenancy.ts`) at the four seams every write passes: `memoryRepo`'s
315
+ `write()` plus its `insertAll`/`upsertAll` batch loops, and `postgresRepo`'s `writeRows()`,
316
+ `update` and `updateWhere`. A row or patch naming another tenant is `X_TENANCY_ACTOR_MISMATCH` —
317
+ the same code the read path throws, because it is the same mistake in a different argument.
318
+ Rules, none optional. **Refuse, never stamp**: a row that names no tenant is left alone and the
319
+ column's `NOT NULL` answers it. Filling one in from the actor would change the column list
320
+ `namedProperties` derives, silence the uneven-batch refusal (`excluded.<col>` is a default, not
321
+ "leave it alone"), and let ambient state decide which stored row a collision lands on — a write
322
+ that creates data from the ambient context is a bigger decision than a guard. **All or nothing**:
323
+ the batch loops run before any row is stored, so memory cannot half-apply what Postgres refuses
324
+ as one statement. **The incoming rows, not only what lands**: under `onMatch: 'nothing'` a
325
+ colliding row never reaches `write()`, so a check only on stored rows would pass exactly the rows
326
+ that collide. **Refused before the statement exists** — `pg-driver` sends nothing and `memoryRepo`
327
+ stores nothing, which `write-tenancy-parity.test.ts` pins for both drivers together, and
328
+ `pg-driver-tenancy.live.test.ts` proves against a real server — that file is where tenancy's live
329
+ proof lives, reads and writes both, and where a new one goes. **Together with the conflict-target rule
330
+ a cross-tenant upsert is unrepresentable**: the target must contain the tenant column under
331
+ `'update'` (`X_TENANCY_UNSCOPED`, which decides which stored row is matched) and every incoming
332
+ row must carry the actor's tenant, so the key can only hold this actor's value.
333
+ - **`deleteWhere(filter)` and `updateWhere(filter, patch)` are the only filtered writes, and they
334
+ are bounded by construction.** `delete(id)` and `update(id, patch)` need a single-column primary
335
+ key, so on a composite key — `likes`, `blocks`, `participants`, any join table — the filtered
336
+ pair is the only write path that exists; without them the entity is create-only and a row can be
337
+ written and never unwritten. They are also the bulk forms of `delete`/`update` for the ordinary
338
+ case — one statement for a `for … of` loop that would otherwise delete or patch one row at a
339
+ time — the same role `insertAll`/`upsertAll` (below) play for a per-row insert loop; a
340
+ write-loop detector's `fix:` names one of these four, never a hand-rolled loop. Properties, none
341
+ of them optional:
342
+ - an empty filter is `X_WRITE_UNFILTERED` and never every row; an empty patch is `X_PATCH_EMPTY`
343
+ and never a counted no-op. An `undefined` value is dropped *before* either count, so a
344
+ forgotten variable lands on the error rather than on the table.
345
+ - **one code for both verbs**, because it is one situation with one remedy. Splitting it into
346
+ `X_DELETE_UNFILTERED`/`X_UPDATE_UNFILTERED` would give two codes the same `fix` and make a
347
+ caller choose which to catch. The situations that genuinely differ — no filter, no patch —
348
+ are what get separate codes.
349
+ - the filter guard runs before tenancy is applied, because one tenant's every row is still
350
+ every row.
351
+ - soft delete follows the entity's `deletedAt` column exactly as `delete(id)` does: stamped rows
352
+ are not matched twice, and `updateWhere` carries the same `deleted_at is null` clause
353
+ `update(id, patch)` does, so a deleted row is never patched back into shape.
354
+ - both return a count, never `void`: a filtered write that silently matches nothing is
355
+ indistinguishable from one that worked.
356
+ - **the rows come back only when something here can still refuse them**, `As of 2026-08`.
357
+ `updateWhere` ended its statement in `returning *` unconditionally and looped `$assert` over
358
+ the result, on every entity — including the ones whose every rule is a CHECK Postgres already
359
+ enforced on the statement, where the loop judges nothing. A tenant-wide sweep
360
+ (`updateWhere({ orgId }, { marketingOptIn: false })`, twelve million rows) therefore streamed
361
+ the whole table into a process sized for one request, and `deleteWhere` beside it was a count,
362
+ which is what made the failure look arbitrary. `hasJsOnlyInvariant($invariants)`
363
+ (`invariants.ts`, reading the same list `uniqueTargets` classifies a conflict target from) is
364
+ the switch: no `assert` rule, no `returning *`, `execute()` and the command tag. When rows ARE
365
+ needed the match is **counted first** and refused past `MAX_ASSERTED_ROWS` (50,000) naming
366
+ `inBatches(1000)` — a refusal issued after `returning *` is already holding what it refuses.
367
+ `updateStatement`'s `returning` is a required parameter with no default for the same reason:
368
+ the three callers want three answers and the wrong one is invisible in the result. The soft
369
+ delete inside `removal()` passes `false` too — both its callers read a count through
370
+ `execute()`, so its rows were never readable by anyone.
371
+ - **`touch()` in `query.ts` is the ONE place `onUpdateNow()` columns are stamped**, for
372
+ `update(id, patch)` and `updateWhere(filter, patch)` alike — a second copy is how one of them
373
+ ends up writing a stale `updatedAt`. It returns an empty patch untouched, so whether
374
+ `X_PATCH_EMPTY` fires depends on the call and not on whether the entity happens to declare the
375
+ column.
376
+ - **A many-row write is one statement, and every refusal it needs happens before that statement
377
+ exists.** `insertStatement` (`pg-sql.ts`) builds *every* insert in the framework — one row or ten
378
+ thousand — so `insertAll([row])` compiles to exactly the text `insert(row)` always compiled to
379
+ and there is no second builder for the two to drift apart in. What both drivers have to agree on
380
+ lives in `bulk-write.ts`, decided in **property** space and projected to physical columns for the
381
+ SQL: the column list a batch writes (`Object.hasOwn`, exactly as `bindValues` decides it), what a
382
+ collision overwrites, the conflict key, and the chunking. Rules, none optional. **A collision
383
+ overwrites every column in the batch except three closed sets** — the conflict target, which is
384
+ how the stored row was found, the primary key, which is where it lives, and the soft-delete
385
+ stamp, which is whether the row is there at all; an upsert that moved either of the first two
386
+ would move a row nobody asked to move and every foreign key already pointing at that id would
387
+ miss it. **The stamp is the third because a soft-deleted row still occupies its conflict target**
388
+ — the index it collides with is not partial — so `excluded."deleted_at"` would clear a delete the
389
+ app made and hand the row back holding the batch's values, which is the resurrection
390
+ `update(id, patch)` and `updateWhere` refuse by carrying `deleted_at is null` and an
391
+ `on conflict` clause cannot carry. Excluded from the set list rather than refused, because
392
+ `$parse` fills every declared column before a row reaches `upsertPlan`: that `deletedAt: null` is
393
+ the framework's and not the caller's, so refusing it would make `onMatch: 'update'` impossible on
394
+ every soft-deleting entity. `insertAll` is untouched — a row colliding with nothing writes the
395
+ stamp it carries, exactly as `insert` does. **The conflict target must be a declared unique
396
+ constraint** — because a target
397
+ Postgres cannot infer an index for is `42P10` wrapped as `X_DB_UNAVAILABLE`, which names nothing
398
+ the author can act on. All **three** of this framework's spellings of one count, or the refusal
399
+ would tell an author to declare a constraint they already declared and ship two indexes: the
400
+ primary key, a non-partial `unique: true` entry in `$indexes` (`unique()` on a column and
401
+ `indexes:` both land there), and a `kind: 'unique'` entry in `$invariants`
402
+ (`invariant(name, c.unique([…]))`, whose `CREATE UNIQUE INDEX` never touches `$indexes`). A
403
+ partial one is deliberately not a target on either list, since its predicate would have to be
404
+ repeated in the `on conflict` clause and this layer does not spell one — which is also why a
405
+ soft-deleting entity's `c.unique()` invariant, stamped `deleted_at is null` by `bindInvariant`,
406
+ is excluded by that same rule. **The tenant column is part of that
407
+ constraint or `onMatch: 'update'` is refused** (`X_TENANCY_UNSCOPED`) — this is a security
408
+ boundary, not ergonomics: `upsertAll` builds no read plan, so nothing else puts an org predicate
409
+ in the statement, and a target that omits the tenant column matches a row stored by another tenant
410
+ and rewrites it, tenant column included. `'nothing'` stays legal on such a target because it
411
+ writes nothing to a row it does not own. **A batch that repeats one conflict target is refused
412
+ under `'update'`** — Postgres answers that statement `ON CONFLICT DO UPDATE command cannot affect
413
+ row a second time`, so passing it in memory and failing in production is the exact drift the two
414
+ drivers exist to prevent — and **an uneven batch is refused under `'update'`** for the same
415
+ reason: `excluded.<column>` for a row that omitted it is that column's *default*, not the stored
416
+ value, so "leave it alone" is not what happens. `insertAll` and `'nothing'` accept an uneven batch
417
+ and render `default` in the missing cell, which is what the same row means on its own.
418
+ **Null is not a value here**: a null anywhere in the conflict target means the row collides with
419
+ nothing, in both drivers, because a Postgres unique index is `NULLS DISTINCT`. **The memory
420
+ driver judges the whole batch before storing any of it** — `$assert` over every row first — since
421
+ Postgres refuses the statement as one and a half-applied batch would make the two disagree about
422
+ what one call did. Past `MAX_BIND_PARAMETERS` (65535) the batch is several whole statements, so
423
+ atomicity across them is `withTransaction`'s and never one statement's.
424
+ - **Nothing is interpolated into SQL.** `pg-sql.ts` binds every value through `sql` and resolves
425
+ every identifier through the entity, so a column name can only be one the entity declared.
426
+ `raw()` appears exactly twice, for `asc|desc` and the `default` cell of a many-row `values` list —
427
+ each a closed set of one word. The seek operator was the third: it is now chosen in TypeScript
428
+ (`seekAfter`/`seekEqual`), because a timestamp seek is not one operator.
429
+ - **A timestamp seek compares against a millisecond WINDOW, never a bare `>`.** A cursor carries a
430
+ `Date` (milliseconds); the column is `timestamptz` (microseconds), so the cursor value is the
431
+ row's own timestamp floored. `created_at > '…123'` is therefore satisfied by the `…123456` row
432
+ the cursor was minted from — the same row served again on every page boundary — and `<` drops
433
+ every row inside that millisecond instead. Ascending seeks `>= v + 1ms`, descending keeps `< v`,
434
+ and an equality prefix is `>= v and < v + 1ms`: what `date_trunc('milliseconds', …)` would say,
435
+ spelled as a half-open range so the column stays bare and the index still range-scans. The memory
436
+ driver stores millisecond `Date`s, so the two drivers agree without a second rule.
437
+ - **`MoneyValue.scale` PERSISTS, in a third physical column — decided 2026-08.** `<p>_scale integer
438
+ null`, through `columnsOf` / `bindValues` / `moneyOf` / `parseMoney` / `describeColumn`. Until
439
+ this branch the entity layer silently dropped it on **both** write and read: `parseMoney` rebuilt
440
+ the value as `{ minor, currency }`, `bindValues` wrote two columns and `columnsOf` declared two,
441
+ so `money().$parse({ minor: 2, currency: 'USD', scale: 6 })` — $0.000002 — was stored and read
442
+ back as $0.02. A silent 10,000x reinterpretation, with no error anywhere, of a field the type
443
+ system (`type-pins.ts` asserts `MoneyValue` is exactly `minor | currency | scale`), the wire
444
+ schema (`t.money` validates and preserves it) and `@ultimat3/money` all carry. **The rejected
445
+ alternative was making `parseMoney` refuse a scaled value**: `scale` exists precisely so a
446
+ sub-cent amount can be named — the $0.00016 model call that rounded up to a whole cent and
447
+ reported 62x the real spend — so refusing it at the persistence layer would delete the feature at
448
+ the one layer that has to keep it. Rules, none optional. **`null` is not `0`**: the column holds
449
+ NULL for "the currency's own minor unit", which is every amount written before the column
450
+ existed, and it decodes to an ABSENT key — `0` means whole units and would be a 100x error on
451
+ every ordinary price, so `bindValues` writes `money?.scale ?? null` and `moneyOf` omits the key
452
+ rather than defaulting it. **Always nullable, whatever the property is**: a NOT NULL there would
453
+ demand a scale on values that have none. **The bound is `@ultimat3/schema`'s** — `parseScale`
454
+ calls `isMoneyScale`, never a restated `0…15`, and `scaleCheck` emits the matching CHECK so a
455
+ psql session cannot write a scale the app would refuse to read. **`scale` is not addressable**:
456
+ `MONEY_PARTS` in `pg-row.ts` and in `cursor.ts` still hold `minor` and `currency` only, because a
457
+ scale says which units `minor` counts — ordering or filtering by it compares two different
458
+ questions. Existing tables need `alter table <t> add column <p>_scale integer` (see the migration
459
+ note in the PR); every existing row's NULL already means what it always meant.
460
+ - **The currency bound is `@ultimat3/schema`'s too, in BOTH halves — decided 2026-08.**
461
+ `parseCurrency` calls `isCurrencyCode` and `currencyCheck` interpolates `CURRENCY_CODE_PATTERN`,
462
+ the pattern source that predicate is built from, exactly as `scaleCheck` interpolates
463
+ `MAX_MONEY_SCALE`. `^[A-Z]{3}$` had been restated four times across three packages — schema's
464
+ private regex, its JSON Schema `pattern`, this column's parse and this CHECK — each individually
465
+ correct, and a divergence between the last two is visible only to a psql session, as a row the
466
+ app then refuses to read back. SQL cannot call a predicate, so what crosses the seam is the
467
+ pattern **string**: legitimate only while the pattern stays inside the syntax ECMAScript and
468
+ POSIX ARE spell identically, which is why `currency-check.live.test.ts` inserts the same corpus
469
+ `columns.test.ts` runs into a real table carrying the emitted CHECK and demands the server accept
470
+ exactly what `isCurrencyCode` accepts. That table is `text`, not `char(3)`, on purpose: a width
471
+ refusal would answer for every over-long case and leave the pattern untested on them.
472
+ - **Money is a `bigint` + `char(3)` column pair, and a `number` + `char(3)` VALUE.** A float throws.
473
+ Never one column, never an implied single currency — and never two declarations of the shape.
474
+ `MoneyValue` is re-exported from `@ultimat3/schema`, which is also what `@ultimat3/money`'s
475
+ `Money` is: **one** declaration, at the only tier every package may import. It was three
476
+ structural restatements, and the entity layer's copy had a `bigint` `minor` — so a row this
477
+ package decoded threw inside `JSON.stringify` (an action returning it crashed the response) and
478
+ failed `t.money`, the node that becomes the OpenAPI contract. `type-pins.ts` fails the build if
479
+ the alias is ever re-declared here, if `minor` widens back to a `bigint`, or if either field
480
+ loses `readonly`. **The column is wider than the value on purpose, and the gap is a refusal, not
481
+ a rounding**: `parseMinor` (`columns.ts`) takes the `bigint`, the `number` and the string int8
482
+ arrives as, and refuses anything past ±2^53 with `X_INVARIANT_VIOLATED` naming the value — the
483
+ same value `@ultimat3/realtime` refuses for the same reason, so the two readers of one column
484
+ agree. **The write half stays wide**: `MoneyInput` takes a `bigint`, so a minor unit read off a
485
+ `bigint` column needs no conversion at the call site — and `narrowMoney` is called by
486
+ **both** drivers, `bindValues` before a statement and `memoryRepo`'s `write` before it stores, so
487
+ a row's money never depends on which driver produced it. Applying it to one of them only is the
488
+ drift the two-driver split exists to prevent: it would leave the in-memory row the one row in
489
+ the framework `JSON.stringify` refuses.
490
+ - **Timestamps are `timestamptz`.** A naive timestamp must stay inexpressible.
491
+ - **A tenant column means every query runs under the ACTING ACTOR's tenant** — derived from
492
+ `tryUseContext()?.actor.orgId` in `scopedPlan` (`tenancy.ts`), which every repository operation
493
+ reaches through `readPlan`, so both drivers and every read, write and count pass one derivation.
494
+ `tenant: 'orgId'` declares the column; omitted, inference still applies (`.tenant()`, else a
495
+ column named `orgId`), so silence never means unscoped. Never make the declaration the only
496
+ switch. **And the column may not be nullable** — refused in `resolveTenantColumn`, at
497
+ declaration, on all three paths and not just the declared one, `As of 2026-08`. `.tenant()` sets
498
+ `{ tenant: true, index: true }` and said nothing about nullability, so `uuid().nullable().tenant()`
499
+ was legal — while `assertRowTenant` returns early on a row that names no tenant and explicitly
500
+ delegates to the column's `NOT NULL`. On a nullable column that delegation has nothing behind it:
501
+ the row lands with a null tenant, no `org_id = $1` matches it, and it is invisible to every
502
+ tenant-scoped read — never exported, never swept on offboarding, owned by nobody for as long as
503
+ the table exists. Five rules, none optional. **A caller-supplied `orgId` is an assertion, never the
504
+ authority**: equal to the actor's it is a restatement (one predicate, not two), different from it
505
+ — which is what an `orgId` taken from action input looks like — it is `X_TENANCY_ACTOR_MISMATCH`
506
+ with both values in the cause. **Refused, never overridden**: rewriting the predicate to the
507
+ actor's org would answer the wrong question correctly and ship the bug. **Every predicate on the
508
+ tenant column is checked and `eq` only**, so `in [mine, theirs]` is a mismatch too. **An actor
509
+ with no org is refused** (`X_TENANCY_ACTOR_ORG_REQUIRED`): anonymous is inside no org, so every
510
+ tenant-scoped row is somebody else's, and letting the caller's value stand there would leave the
511
+ hole open on exactly the unauthenticated path. **Outside every request context there is no actor
512
+ to derive from** — a script, a seed, a test harness — so the caller names the tenant itself and
513
+ `X_TENANCY_UNSCOPED` still refuses a plan that names none. There is no build-time tenancy step in
514
+ `x verify` (its 17 steps check none) and the old comment in `tenancy.ts` claiming one was wrong:
515
+ the tenant is a request-time value, so the seam is the enforcement.
516
+ - **`crossTenant(reason, fn)` (`cross-tenant.ts`) is the ONE way to read across tenants**, for the
517
+ three cases that have no single one: an admin surface over every org, background reconciliation,
518
+ support tooling. An `AsyncLocalStorage` scope with a written reason, the same shape
519
+ `@ultimat3/db`'s `expectedQueryLoop` has, never a boolean argument on a repository call — which
520
+ reads exactly like forgetting the tenant — and never a config list of exempt entities (axiom 1).
521
+ **The capability is proven twice**: `CROSS_TENANT_SCOPE` (`tenancy:cross`) on the actor, at the
522
+ call and again at every plan built inside it, because `withChildContext({ actor })` swaps the
523
+ actor without closing the scope and an impersonated caller must not inherit it —
524
+ `X_TENANCY_CROSS_DENIED`. **Outside a request context it is refused too**: a sweep with nobody to
525
+ attribute it to is ambient authority, so a script mints its own `serviceActor` and says who it
526
+ is. A blank reason is `X_INVARIANT` through core's `assert`, exactly as `expectedQueryLoop`'s is.
527
+ - **Every framework member on an entity is `$`-prefixed** — the columns are `Object.assign`ed onto
528
+ the core, so an unprefixed member would make `view`, `name` or `tenant` an illegal column name.
529
+ `$view`, never `view`; no free `view(entity, keys)` either — one way to write a projection.
530
+ - **Invariants run twice**: in the app on write AND as a Postgres CHECK/UNIQUE via `toSql()`. An
531
+ untranslatable JS predicate reports `kind: 'assert'`, `sql: null` — never a pretend CHECK.
532
+ - **And the two halves must AGREE, term by term** (`expr.ts`). A rule the app accepts and the CHECK
533
+ refuses is not a stricter database: the write comes back as a raw constraint error instead of
534
+ `X_INVARIANT_VIOLATED`, which is the framework's own invariant bypassed on the way out. Two
535
+ divergences closed 2026-08, both proven. **`matches(/…/i)` compiles to `~*`** — `toSql` emitted
536
+ `~ <pattern.source>` and nothing else, so `c.slug.matches(/^[A-Z]+$/i)` approved `'abc'` in the
537
+ app while the CHECK refused it; every other flag is REFUSED at declaration (`matchOperator`),
538
+ never dropped, because `m` and `s` change what the pattern matches and `g` makes `pattern.test`
539
+ stateful so even `holds` stops being a function of the row. **`minLength` counts code points** —
540
+ `[...value].length`, because `char_length('👍')` is 1 and `'👍'.length` is 2. Code points, not
541
+ graphemes: agreeing with Postgres is the point, not agreeing with a human's idea of a letter.
542
+ - **`$parse` tells absence from `null`** (`entity.ts`). `input[property] ?? defaultValue(...)` read
543
+ an explicit `null` as absence and wrote the column's declared default straight back, so a
544
+ nullable-and-defaulted column could not be cleared at all — `{ status: null }` reported success
545
+ and stored `'draft'`. It is `raw === undefined ? defaultValue(...) : raw`: a present `undefined`
546
+ is still absence, which is what a spread of an omitted optional key produces.
547
+ - **`invariants` is ONE callback, and `InvariantColumns<C>` is a mapped type.** `invariants: (c) =>
548
+ [invariant(name, expr)]`, never an array of `(c) => …` builders: a per-element builder is a call
549
+ TypeScript checks before `entity()`'s `C` is fixed, so `C` fell back to its constraint and `c`
550
+ stayed open-keyed. Open-keyed means an index signature, and under `noUncheckedIndexedAccess` that
551
+ made every `c.title` a `ColumnExpr | undefined` — every generated entity red until the author
552
+ added `!`. The Proxy in `invariantColumns()` stays regardless: a JS caller and a dynamically
553
+ built rule never see the compile error, and its message names the columns that do exist.
554
+ - **`Invariant<T>.holds` is a method, never `readonly holds: (row: T) => boolean`.** A
555
+ function-typed property is contravariant, so `Invariant<Post>` stopped being assignable to
556
+ `Invariant<unknown>`, `Entity<Post, C>` stopped satisfying `EntityCore`, and every
557
+ `database({ … })` degraded to `Table<unknown>` — one position, 36 cascading errors downstream.
558
+ - **A branded id survives to the signature, or it does not exist.** `uuid<PostId>()` declares the
559
+ brand once; the derivation (`TypeOf`/`RowOf`/`Insertable`) always carried it, but the BUILDER
560
+ hard-coded `Column<string>` so there was nothing to carry, and `Repo`/`Table` then took
561
+ `id: string`, which erased the rest — two entities' ids were mutually assignable and
562
+ `posts.update(someUserId, …)` compiled into a query that matched nothing. Both halves are
563
+ pinned: fixing either alone leaves that call legal. Id parameters are `IdOf<Row>`, which is
564
+ `string` for every unbranded row and every composite key, so this is additive.
565
+ - **`type-pins.ts` is where all of those are enforced.** Source, not a test: `tsconfig.json`
566
+ excludes `src/**/*.test.ts`, so `tsc` never reads a test file and a type-level assertion written
567
+ in one can never fail. It emits nothing and exports nothing anybody imports.
568
+ - **Row types are derived, never re-declared.** No `as unknown as` to fake the derivation.
569
+ - **`src/index.ts` re-exports `t` from `@ultimat3/schema` verbatim**, so an entity file that also
570
+ hand-writes a view schema imports one package. Never wrap, spread or re-declare it: `t` delegates
571
+ to `schemaProvider()` on every access, and a copy would freeze the provider at import time.
572
+ `index.test.ts` asserts identity.
573
+ - **A rejected column value is rendered as its SHAPE, never its content** — `got(value)` in
574
+ `columns.ts`, one line over `@ultimat3/schema`'s `describeValue`, `As of 2026-08`. Every builder
575
+ used to say `got ${String(value)}`, and a column rejection is not a private diagnostic: it
576
+ becomes `X_INVARIANT_VIOLATED`'s `cause` and a `$view` issue, which `@ultimat3/http` folds into
577
+ `X_BODY_INVALID` — returned to the caller AND written into the log line, where core's logger
578
+ redacts by KEY and a value already baked into a message has no key left to redact. `text()` on a
579
+ password field wrote the mistyped password to the log index in cleartext and into the user's own
580
+ network tab. **A column is the worse half of that pair**: the value can arrive from the DATABASE,
581
+ so the leak is not bounded by what somebody just typed. The renderer is schema's rather than a
582
+ local copy, so a column and a schema describe one bad value the same way; `columns.test.ts` pins
583
+ it with a secret-looking value and checks that not even its four-character prefix survives — a
584
+ truncating "helpful" renderer would still name the vendor. **Two echoes are deliberate and both
585
+ are provably numeric by the branch that reaches them**: `parseMinor`'s float message and its
586
+ ±2^53 message, where the value is a `number`, a `bigint` or a digits-only string, the amount is
587
+ the only fact that repairs the row, and `@ultimat3/realtime` renders the same value the same way
588
+ for the same reason. Changing either means changing both.
589
+ - **A seed is replayable by construction, and `insert` is the verb that makes it so** — decided
590
+ 2026-08. `defineSeed`'s context offered `insert` and nothing else, and a plain insert meant two
591
+ different things to the two drivers: the memory repository overwrites by primary key, Postgres
592
+ raises `23505`. So a seed replayed twice passed every test in this repo and killed the SECOND
593
+ boot of any container on a durable store, which is why both tracked apps had (or needed) a
594
+ hand-written `Driver` decorator over `insert`. `SeedContext.insert` now writes one
595
+ `upsertAll(rows, { onConflict: entity.$primaryKey, onMatch: 'nothing' })` per call. Four
596
+ properties, none optional. **`'nothing'`, never `'update'`**: `upsertPlan` refuses an updating
597
+ upsert whose target omits the tenant column (`X_TENANCY_UNSCOPED`), and a tenant-scoped entity
598
+ whose only unique keys are global — `posts` in the reference app — has no legal updating target
599
+ at all; `'nothing'` also skips the uneven-batch and nothing-to-set refusals, which a fixture
600
+ graph would otherwise have to satisfy. **One statement per call**, so the per-row `insert` loop
601
+ this replaced is no longer the N+1 of its own bulk form. **The metrics are the driver's answer**,
602
+ not a count of the input: `upsertAll` under `'nothing'` resolves with the rows it actually wrote,
603
+ so `skipped` is the replay, observed. **A generated primary key the row does not name is
604
+ refused** — `uuid().primaryKey()` carries `GENERATED_UUID`, so `$parse` fills it with a fresh
605
+ uuid, the conflict target matches nothing and run five leaves five copies; it is the one
606
+ duplication no other rule in this package can see.
607
+ - **`upsert(entity, { by }, values)` is the second verb, and it exists because only the SEED AUTHOR
608
+ knows the natural key.** A seed writing into a table whose ids already exist — `banks` keyed by
609
+ `value`, `users` by `email`, `exchange_rates` by `(base, target, effective_on)` — cannot choose a
610
+ primary key, so keying replay on `$primaryKey` would be keying it on something Postgres does not
611
+ enforce. It reads first so an unchanged row can answer `'skipped'` with no statement, then writes
612
+ through ONE `on conflict … do update`: the read is for the report, never for the decision, or two
613
+ containers booting at once would race between the two. **`createdAt` is preserved on a match** —
614
+ the row handed to the update omits it, so `namedProperties` leaves it out of the `set` — because
615
+ a replay must not move when a row first arrived.
616
+ - **The environment guard is the CLI's, not `run()`'s.** `seedTiersFor(environment, requested)` is
617
+ the one table (`reference` everywhere, `dev` everywhere but production) and `x db seed` is what
618
+ refuses. `run()` stays permissive on purpose: `dummy/social-media-clone/apps/web/api/index.ts`
619
+ seeds its own production demo database from its boot code and says out loud that this is an app
620
+ decision (axiom 8) — a library refusal would break it. A seed declares its tier as DATA, the way
621
+ a `backfill()` declares its environments.
622
+ - **One resolver decides a physical name, and it is `columnName(property, meta)`** — decided
623
+ 2026-08 with `entity(name, { table })` and `.column(name)`. Before them, `snake(property)` was
624
+ called in nine places and `$table` was the entity name, so a schema this framework did not
625
+ generate could not be declared at all: adoption meant a rewrite. Every projection now reads the
626
+ resolver — the DDL (`describe.ts`), the binding and the decoder (`pg-row.ts`), the predicate and
627
+ sort resolver, the index names, the invariant SQL, the soft-delete clause in `pg-sql.ts` and
628
+ `pg-driver.ts`. **A second `snake(property)` anywhere is a statement naming a column the table
629
+ does not have**, and the first table that proves it is somebody's production database. It is
630
+ additive by construction: with no override the resolver IS `snake(property)`.
631
+ - **The entity NAME and the TABLE are different things.** The name stays the framework's key — the
632
+ registry, the cache tag (`entity:account`), `$tagFor`, every relation and every policy — and the
633
+ table is physical. Renaming a table must never move a cache tag. Index names are the TABLE's,
634
+ because an index is a physical object.
635
+ - **Money's three columns are per-part and `scale: null` is a real answer.** `money({ columns })`
636
+ merges over `<base>_minor`/`<base>_currency`/`<base>_scale` one part at a time, so a table that
637
+ renamed one does not restate the other two. `scale: null` says the table has no scale column at
638
+ all — the ordinary shape of an amount written before scale existed — and then `columnsOf`
639
+ projects TWO names, `bindValues` writes two, and `decodeRow` folds two. That last one is why
640
+ `decodeRow` branches on `$meta.kind === 'money'` and not on how many names came back: reading a
641
+ two-column amount as a non-money column handed the caller a raw minor unit where a `Money` goes.
642
+ - **A `jsonb` value is bound as TEXT and cast back, `::text::jsonb`** — and the double cast is
643
+ load-bearing, not defensive. The driver seam refuses a plain object as a parameter
644
+ (`X_SQL_UNSAFE`; `isBoundValue` takes scalars, `Date`, `Uint8Array` and arrays of those). With
645
+ `$1::jsonb` the server describes the parameter as `jsonb`, Bun's `sql` JSON-ENCODES the string it
646
+ was handed, and `{"a":1}` is stored as the JSON *string* — `jsonb_typeof` says `string`
647
+ (measured, Postgres 17.10). Pinning the parameter to `text` first makes the client send the
648
+ characters and the server parse them. An ARRAY is the other value that cannot cross as itself:
649
+ Bun serialises a JS array to `x,y`, which Postgres answers with `malformed array literal`, so
650
+ `bindValues` writes the `{…}` literal with every element quoted.
651
+ - **The wide column types were chosen from what a driver actually returns, not from what reads
652
+ well.** `int8` is a string from Bun's `sql` and a `bigint` from PGlite; `numeric` is a string
653
+ from both; `date` is a `Date` at midnight UTC from both; `bytea` is a `Buffer` from one and a
654
+ `Uint8Array` from the other. Every one of those is normalised in `$parse` to a single row type,
655
+ because a row that means two things by driver is the drift this package's two-driver split exists
656
+ to refuse. `bigint()` and `decimal()` are STRINGS for the same reason `money.minor` is a
657
+ `number`: `JSON.stringify` throws on a bigint, and a `number` loses digits exactly where a legacy
658
+ `int8` key lives.
659
+ - Never throw a bare `Error` — use `errors.ts`.
660
+ - Tests restore the process-global registry in `afterAll` (`clearRegistry()`): a leaked registry
661
+ breaks an unrelated package's tests, as it did in `@ultimat3/policy`.
662
+
663
+ ## Files
664
+
665
+ | File | Job |
666
+ |---|---|
667
+ | `types.ts` | `Column`, `RowOf`, `Insertable`, `IdOf` — the type derivation |
668
+ | `column.ts` / `columns.ts` | the chain + property-key binding; the blessed builders; `columnName`/`moneyColumns`, the ONE physical-name resolver; `narrowMoney`, the one write-side narrowing both drivers run |
669
+ | `columns-data.ts` | the wide vocabulary an existing schema needs: `json`, `decimal`, `date`, `bigint`, `bytes`, `arrayOf` |
670
+ | `expr.ts` / `invariants.ts` | the `invariants: (c) => …` rule language; bind + `toSql()` DDL |
671
+ | `entity.ts` / `describe.ts` | `entity()`, `$row`; the `EntityDescription` projection |
672
+ | `view.ts` | `$view(keys)` — the row projection an action names as its `output` |
673
+ | `query.ts` / `database.ts` | chainable read to a cursor page; `database()` + `Driver` |
674
+ | `repo.ts` / `tenancy.ts` | `Repo<T>` + `memoryDriver`'s repo, tx rollback; `QueryPlan` + `scopedPlan()` for a read and `assertRowTenant()` for a write — one actor-derived tenant guard, both halves |
675
+ | `cross-tenant.ts` | `crossTenant(reason, fn)` — the capability-gated scope that lifts it |
676
+ | `plan.ts` / `cursor.ts` | the plan both drivers execute; the one keyset cursor codec |
677
+ | `batch.ts` | `inBatches(size)` — the chain's page in a loop, closed by the loop that reads it |
678
+ | `pg-driver.ts` | `postgresDriver()`, `postgresRepo()`, `postgresTransactor()` — attributes every statement it sends |
679
+ | `coalesce.ts` | one microtask of `findById` calls → one `where id in (…)`, per request |
680
+ | `batch-read.ts` | what a shared point read is made of — the scope key, `keyOf`, the one `in` statement |
681
+ | `bulk-write.ts` | what a many-row write is made of — the column list, the conflict plan, the bind-count chunking |
682
+ | `count-by.ts` | what a grouped count is made of — the groupable kinds, the group bound, the key's decoding, the order |
683
+ | `jit-preload.ts` | a page's foreign key values → one `in` statement for the whole `for … of` loop |
684
+ | `preload.ts` | the relation `preload()` names → one related-rows statement → attached to the page |
685
+ | `pg-sql.ts` / `pg-row.ts` | plan → parameterised SQL; physical row ⇄ entity row (money is three columns) |
686
+ | `registry.ts` | duplicate detection, `describeEntities()` for the manifest, `references()` per entry |
687
+ | `relations.ts` | `relationMap()`/`relationsFor()`/`relationNamed()` — the FKs as a named `belongsTo`/`hasMany` map |
688
+ | `n-plus-one.ts` | a repeated statement → the error whose `fix` is the preload or bulk call that ends it |
689
+ | `seed.ts` | `defineSeed` — the replayable fixture graph: `insert` (the seed's own ids), `upsert` (a natural key), the sentinel reads, and the tier table `x db seed` refuses from |
690
+ | `type-pins.ts` | compile-time assertions `tsc` checks — the column proxy, `Invariant` variance, the branded id |
691
+
692
+ ## Commands
693
+
694
+ `bun test packages/entity` · `bun run --filter @ultimat3/entity typecheck`