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