@ultimat3/entity 21.0.0 → 22.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 CHANGED
@@ -14,303 +14,79 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
14
14
  structural column vocabulary this package consumes so the generated SQL stays readable and
15
15
  an agent can self-correct against it.
16
16
 
17
- ## Do not regress
17
+ ## Do not regress — two drivers, one meaning
18
18
 
19
- - **The CONTRACT and the in-memory DRIVER are two files, `As of 2026-08-24`.** `repo.ts` is
20
- `Repo`, `Page`, `FindManyArgs` and `Transactor` — what `postgresRepo` implements too — and
21
- `memory-repo.ts` is `memoryRepo()`. Split when `repo.ts` passed the 500-line ceiling; nothing
22
- about storing rows in a `Map` belonged in the interface `pg-driver.ts` answers to.
23
- - **Two drivers, one meaning.** `memoryDriver()` and `postgresDriver()` share `plan.ts` (scope,
24
- sort order, page size), `cursor.ts` (one codec, values included) and the `Repo` contract, so a
25
- test that passes against memory says something about Postgres. A guard, an operator or a sort
26
- rule added to one and not the other is the bug this split exists to prevent — `pg-driver.test.ts`
27
- pins the parity, and every bulk method added since carries the same two files: a
28
- `*-parity.test.ts` seeding identical rows into both drivers and asserting identical output
29
- (`batch-parity.test.ts`, `preload-parity.test.ts`, `count-by-parity.test.ts`, and the
30
- `insertAll`/`upsertAll` cross-driver assertions inside `pg-driver-bulk.test.ts`), and a
31
- `pg-driver-<feature>.live.test.ts` proving the same call against a real server
32
- (`pg-driver-batch.live.test.ts`, `pg-driver-preload.live.test.ts`, `pg-driver-count.live.test.ts`,
33
- `pg-driver-bulk.live.test.ts`, `pg-driver-tenancy.live.test.ts`). A method with only the first is
34
- unproven against Postgres itself; a method with only the second is unproven against memory. Both
35
- are the bar, not either one.
36
- - **Money's write shape is wider than its row shape, and both drivers narrow it at the WRITE
37
- METHOD'S entry — `As of 2026-08-25`.** `MoneyInput` lets a writer hand a `bigint` minor unit read
38
- straight off a `bigint` column; `MoneyValue` is what a row holds, because `JSON.stringify` refuses
39
- a `bigint` and money crosses every wire this framework projects. `RowWrite<Row>` is the type that
40
- says so at `Repo.insert`/`insertAll`/`upsertAll`, which took the ROW type instead — so the
41
- widening this package documents, narrows and stores correctly was a **compile error at the only
42
- call an app makes**, `postgresRepo()` being exported, and it was the last two entries on
43
- `scripts/lib/test-typecheck-pins.ts`. `narrowRow` (`columns.ts`) is the narrowing, called at each
44
- entry rather than deep inside `bindValues`/`write`, and the POSITION is the rule. `entity.$assert`
45
- and `upsertPlan` both run before a statement exists, so an invariant reading `total.minor` was
46
- handed the caller's `bigint` and never the `number` the row would hold — it rejected rows both
47
- drivers then stored correctly. And it decides whether a refusal costs a row: Bun's client binds a
48
- `bigint` verbatim (measured), so a minor unit past ±2^53 narrowed any later is INSERTed,
49
- committed, and only then refused by the decode of its own `returning *` — a row the app wrote and
50
- can never read. `pg-money-write.live.test.ts` is the proof, because only a real table can see
51
- that; `money-write-parity.test.ts` pins both drivers together, and `type-pins.ts` fails the build
52
- if those three writes stop taking `RowWrite` or start answering with it.
53
- - **What a PREDICATE means is decided by the column's declared KIND, and `memory-match.ts` is
54
- where that one meaning is written.** The database decides by the column's type, so a driver
55
- deciding by the JS `typeof` of the value in hand is answering a different question — four rules,
56
- each of them a place the two drivers used to disagree, `As of 2026-08`. **A decimal-string column
57
- orders by its digits**: `bigint()` and `decimal()` both hand back a STRING (deliberately —
58
- `JSON.stringify` throws on a `bigint` and a `number` loses digits past 2^53), so neither the
59
- `number`/`number` branch nor the `bigint`/`bigint` branch fired and both fell to
60
- `String(left) < String(right)` — `["10","100","2","9"]` against Postgres' `2, 9, 10, 100`, and a
61
- keyset page cut where the database cuts none, since the seek compares the stored string against
62
- a revived `BigInt`. The comparison is exact at any width (the fractions are padded and both sides
63
- become one integer), which no `Number()` is — and `As of 2026-08` it is **`@ultimat3/core`'s
64
- `compareDecimalText`**, not this file's, because the text arrives in more than one package while
65
- the declared kind does not. What stays here is `DECIMAL_TEXT` (which kinds are decimal text) and
66
- the decision to ask; core's function answers `undefined` for a pair that is not two plain
67
- decimals, so a caller with no kinds — `@ultimat3/query`, whose `OrderKey` is a name and a
68
- direction — deliberately never calls it: Postgres orders a `text` column of digits lexically, and
69
- a comparator guessing "both sides look like decimals" would trade this agreement for that
70
- disagreement. That residual gap is `@ultimat3/query`'s `shape-order.test.ts` `DECLARED_GAP`.
71
- **A `uuid` is a VALUE, and the value the row HOLDS is lower case**: Postgres parses it and prints
72
- it lower-cased, so `findById(UPPER)` reads the row there and answered `null` here, and
73
- `update(UPPER)` was `X_NOT_FOUND` against a row that exists — `keyOf(kind, value)`
74
- (`batch-read.ts`), which already carried that rule for a batched read, now spells the memory
75
- store's key and its equality too. Equality was only half of it: the STORED value stayed the
76
- caller's spelling here while the server's row never carries one, so `countBy('authorId')` keyed
77
- its `Map` by `AAAA…` in memory and by `aaaa…` in production — a breakdown one driver's caller
78
- cannot look up, out of a call that read the right rows. Narrowed in the two places a value
79
- crosses: `parseUuid` on the way back (`decodeRow`, `entity.$parse`) and `narrowUuid` at each
80
- write method's entry (`columns.ts`), beside `narrowMoney` and for its reason — `entity.$assert`
81
- runs before a statement exists, so an invariant reading an id must see the value the row will
82
- hold. Text is NOT narrowed: lower-casing it would merge two rows Postgres keeps apart.
83
- **A `LIKE` pattern uses Postgres' default escape**: `\` escapes `%`, `_`
84
- or itself, so `like 'a\%b'` matches the literal `a%b` in both drivers rather than
85
- `a\<anything>b` in one — and a pattern ending in the escape character is refused here as
86
- Postgres refuses it (`22025`). A RUN of `%` is still one `.*`: twenty adjacent `.*` groups in an
87
- anchored regex is a CPU stall on a filter value forwarded from a search box. **`in` takes a list
88
- or nothing**, in both drivers and in `@ultimat3/query`: a scalar operand matches NO rows (it was
89
- wrapped into a one-element list for the SQL and refused in memory — 0 rows against one driver, 1
90
- against the other, from a call `andWhere(column, op, value: unknown)` compiles), and a list
91
- carrying a NULL emits `(col in (…) or col is null)` — `col = null` is UNKNOWN, so the null row
92
- the caller listed was the one row Postgres left out while memory included it. **A column the row
93
- never NAMED is NULL**, `As of 2026-08-23`: the table holds NULL whether a row spelled it out or
94
- omitted it, so `eq`, `neq` and `in` read the row side through `isNull` exactly as `is-null` and
95
- the ordering guard already did — `===` made the two rows different, and `eq null` skipped the
96
- absent one, `in [null]` missed it and `neq null` answered it, each the opposite of the same
97
- predicate in production. A `money()` column holding NULL reaches this with no hand-built row at
98
- all: `valueAt(row, 'price.minor')` has nothing to read, whatever `$parse` produced.
99
- - **The Postgres driver is proved against a real Postgres, not only against a recording client.**
100
- `pg-driver.live.test.ts` runs the whole chain — `entity()` -> `$describe()` ->
101
- `generateMigration()` -> a live server -> `postgresDriver()` -> decoded row — and skips when no
102
- `TEST_DATABASE_URL` is set. Asserting statement *text* cannot catch a statement Postgres refuses:
103
- that is how a `unique()` column shipped a migration failing on `42P07` and money's currency
104
- shipped as `char(1)`. A new operator, column kind or write path is not done until it round-trips
105
- there.
106
- - **A point lookup batches itself, and the batch is never wider than the statement it replaces.**
107
- `findById` called several times in one microtask of one request is one `select … where "id" in
108
- (…)` — `coalesce.ts`, keyed by ctx identity (a `WeakMap`, so the batch dies with the request, the
109
- shape `@ultimat3/query`'s request memo has one tier up) and by a scope key covering **every**
110
- input to the statement except the id. Two tenants, two soft-delete visibilities, two projections,
111
- two entities or two clients therefore never share one: a coalesced statement has to be one each
112
- of the singles would have been served by, or a caller is answered with rows their own statement
113
- could never have returned. It declines rather than guesses — no request in scope, a composite
114
- key, a predicate value it cannot render — and declining is just the statement `findById` always
115
- sent, which is why `findById` keeps its signature and there is no `batch()` to opt into. The
116
- window closes before the statement goes out, so a lookup arriving mid-flight opens the next batch
117
- instead of joining ids already on the wire, and past `MAX_IDS_PER_STATEMENT` a batch becomes
118
- several whole statements rather than one Postgres refuses for its bind count. **No caller of a
119
- batch is ever left unsettled** — every promise `coalesceFindById` returns was handed out before
120
- the flush was scheduled, so `flush` settles the whole of `waiting` in a catch of its own rather
121
- than only the chunk that failed, and the scheduled `flush` carries a `.catch`: a rejection there
122
- has nobody left to hand it to and an unhandled one ends the Bun process. Unsettled forever is
123
- strictly worse than failed — a rejection is a stack trace and a hang is a request that never
124
- answers — which is why `coalesce.test.ts` races its assertions against a deadline instead of
125
- letting the runner time out. `jit-preload.ts` has the same property by construction, settling
126
- with an `Answer` rather than a rejection. A sequential
127
- `for … of` loop shares no microtask — its `await` ends the window — which is what the sibling
128
- preload below is for.
129
- - **A page batches the loop it causes, and a preloaded row is only ever served to the statement
130
- that read it.** `findMany` leaves its page's foreign key *values* behind (`jit-preload.ts`,
131
- `tagSiblings`), so the first `findById` for any one of them resolves that key for every row of
132
- the page in one `in` statement and the rest of a `for … of` loop is memory. Five rules, none
133
- optional. **The scope guard is a security boundary**: a preloaded row is served only under the
134
- *same* `scopeKey` the coalescer uses — same tenant predicate, same soft-delete visibility, same
135
- projection, same entity — and the preload statement is that scope widened to the page's ids, so
136
- a page read under one tenant can never resolve another tenant's rows, whichever tenant asks.
137
- **Same client, or nothing**: a bucket filled through the ambient pool is not read through a
138
- pinned one, which is also what stops a row read inside a transaction being served after it —
139
- `db()` hands back a different client once the transaction is over, rolled back or not.
140
- **A write drops it**: `postgresRepo`'s `writing()` is the one place every write goes out, and it
141
- calls `forgetPreloaded(entity.$name)` *before* the statement, so a row a request changed is
142
- re-read and never served from a page read before it. **Values, not rows**: the index is keyed by
143
- id and holds ids, so a page early in a long request pins its keys and not its rows, and it dies
144
- with the request like every other per-ctx store here. **And the store itself is BOUNDED**
145
- (`MAX_SIBLING_KEYS`, four statements' worth), `As of 2026-08`: "dies with the request" is a job's
146
- whole attempt, `MAX_IDS_PER_STATEMENT` bounded the statement and nothing bounded the store, and
147
- 1,000 pages x 1,000 distinct keys measured **159.3 MB retained** against a 2.7 MB control — ~2 GB
148
- on a 12M-row `backfill()`, an OOM in the worker on the DEFAULT configuration, since `jitPreload`
149
- defaults to true and `backfill()` names no driver option. Oldest page first, for both maps: the
150
- key index AND the bucket, which holds rows and is therefore the worse of the two.
151
- **Declining is the old behaviour**: no request in scope, an id no page indexed, a key that
152
- resolved to nothing, a key the bound evicted — the caller reads the statement it always read. `MAX_IDS_PER_STATEMENT` bounds the preload exactly as it bounds a
153
- batch. What both share — the scope key, `keyOf`, the one `in` statement — lives in
154
- `batch-read.ts` so the two can never disagree about when a shared statement is legal.
155
- **One switch, where the driver is built**: `postgresDriver({ jitPreload: false })` /
156
- `postgresRepo(entity, { jitPreload: false })` turns the tagging off. Never an `app.config.ts`
157
- key — nothing reads config at the seam that builds a repository, so a `database.jitPreload`
158
- field would be a switch the framework cannot read, which is a switch that does nothing.
159
- - **`preload(name)` shares `batch-read.ts` with the coalescer and the JIT preload above, but
160
- keeps no request-scoped cache of its own.** `keyOf`, `MAX_IDS_PER_STATEMENT` and
161
- `statementChunks` come from the same file, so a bind-count bound and a key's identity can
162
- never disagree across the three — but `preload()` reads its scope straight off the chain's
163
- own `where` and issues its statement every call; nothing here declines to an old statement
164
- the way the coalescer or the JIT preload can, because there is no old statement to decline
165
- to — a chain that calls `preload('author')` always gets the extra statement. **Tenancy is
166
- carried, never inferred, and that is a security boundary, not a convenience**:
167
- `tenantScope()` carries the page's own tenant predicate onto the related read only when
168
- **both** entities are scoped by a column of that same name — a value that scopes one entity
169
- is a guess on another, and serving a guessed scope is a cross-tenant read. Both ends are
170
- checked, never the target's alone: a source scoped by `workspaceId` may still carry an
171
- ordinary `orgId` predicate of its own, and matching on the target's column name would lift
172
- that filter into the target's tenant scope and attach rows from a tenant nobody proved this
173
- reader owns. A differently-named column carries nothing, on purpose, so the related read
174
- builds an unscoped plan of its own and `assertScoped` refuses it as `X_TENANCY_UNSCOPED`
175
- rather than let it pass. **Reach is the same `database()` set the two bullets above already answer
176
- to**: `RelatedTables` is the resolver `database()` hands every table it builds, an
177
- entity-name → `{ entity, repo }` map closed over the same call, so `preload('author')`
178
- resolves `author` only when that call named the entity the relation points at — outside it
179
- is `X_INVARIANT_VIOLATED`, never a reach around the handle. `tableFor(entity, repo)` built
180
- by hand takes no `related` resolver, so the identical call fails the identical way with
181
- `related` itself `undefined`. **A projection cannot drop what a preload needs**:
182
- `select()` widens its own field list with each preloaded relation's local key, so
183
- `plan().select` — the projection that actually runs — always carries it, though the row
184
- type the caller sees still names only what they picked. **Attachment copies, never
185
- mutates**: a preloaded relation is written onto `{ ...row }`, because the in-memory driver
186
- hands back the row it stores and attaching directly would leak the relation into the table
187
- itself. **Preloading terminals only**: `page()`, `all()` and `one()` resolve every named
188
- relation; `count()`, `countBy()`, the aggregate terminals and `plan()` do not, since none reads a
189
- row to attach one to.
190
- - **Every repository method attributes the statement it sends, and each op is named exactly
191
- once.** `postgresRepo`'s `attributed(op, send)` wraps `findById`, `findMany`, `insert`,
192
- `insertAll`, `upsertAll`, `update`, `delete`, `deleteWhere`, `updateWhere`, `count`, `countBy`,
193
- `aggregate` and `approximateCount` — every method, not a subset — through `@ultimat3/db`'s
194
- `withStatementAttribution(entity.$name, op, send)`. Each method declares `const op = 'findById'`
195
- (or its own name) once, and that same local is what everything else downstream of it gets too:
196
- the plan builder (`idPlan(entity, id, options, op)`, `readPlan(entity, args, op)`,
197
- `deletePlan`/`updatePlan`), and in `countBy`, `groupColumnOf` and `countsFrom` besides — so the
198
- operation a refusal names and the operation a diagnostic reports can never drift apart, one
199
- string read as many times as a method needs it and never retyped by hand a second time. The
200
- three insert paths do not call `attributed` themselves: `writeRows(op, batch, conflict)` does,
201
- once, because a batch wide enough to split (past `MAX_BIND_PARAMETERS`) is several statements
202
- sent inside its own loop and every one of them belongs to the call that asked for it — `op` is
203
- therefore `writeRows`'s own parameter, passed as the literal `'insert'`, `'insertAll'` or
204
- `'upsertAll'` by each of the three callers, never a constant closed over the helper. **The scope
205
- is never entered with no observer installed** — `withStatementAttribution` reads
206
- `statementObserver()` first, so an app running with no diagnostic pays the one property read and
207
- one branch every other statement on this path already pays, and nothing more (axiom 6). **A
208
- preloaded relation is attributed to the related entity and its own operation, never to the read
209
- that triggered it** — `preload()`'s related read (`preloaded()` in `preload.ts`) calls
210
- `target.repo.findMany(...)`, the related entity's own `postgresRepo`, so a `posts` page's
211
- preloaded author carries `{ members, findMany }`, never `{ posts, findMany }` borrowed from the
212
- page: it is a full call through that entity's own repo, not a fact copied across. **`findById`'s
213
- coalesced flush carries its opener's pair without anyone threading it there** — `coalesce.ts`'s
214
- `queueMicrotask` inside `openBatch` is scheduled synchronously while `coalesceFindById` is still
215
- running inside `attributed('findById', …)`'s scope, so the statement the flush eventually sends
216
- on behalf of every lookup that shared the microtask is attributed exactly as each of them would
217
- have been alone. **This is the one rule the two drivers do not share**, and not a drift:
218
- `memoryRepo` sends no statement, so there is nothing for a pair to name — the parity bar
219
- (`*-parity.test.ts`) applies to what a call *answers*, and attribution changes no answer.
220
- **`aggregate` names itself by the FUNCTION, not by the method**: "50x aggregate on members" does
221
- not say which one, and `min` and `sum` are different statements with different costs, so the op
222
- is `'sum'`/`'avg'`/`'min'`/`'max'`.
223
- `pg-driver-attribution.test.ts` is the pin: a client that reads `statementAttribution()` at send
224
- time, one case per method — a twelfth method added without `attributed` is a failing test, not a
225
- review comment — plus the coalesced flush, the sibling preload, a relation's own read, a chunked
226
- batch's every statement, hand-written SQL (no pair), a refusal (no statement) and the
227
- no-observer-installed branch.
228
- - **The two N+1 codes are owned here, and their `fix` is a call the schema already answers.**
229
- `X_N_PLUS_ONE_QUERY` and `X_N_PLUS_ONE_WRITE` live in this package rather than in the process
230
- that detects them, because the fix speaks this package's vocabulary — `preload`, `insertAll`,
231
- `updateWhere` — and a code owned by the CLI would put the one sentence an author acts on in a
232
- package the entity layer cannot see. **Detection is somebody else's**: `n-plus-one.ts` counts
233
- nothing, holds no threshold and installs no observer; it takes a verdict (`StatementLoop`) and
234
- returns the error. **The relation is derived, never invented** — `preloadsFor()` reads the same
235
- `relationMap()` `preload()` resolves against, so the pasted line compiles; the operation picks
236
- the side (`findById` → `belongsTo`, `findMany` → `hasMany`), and anything else takes the `in`
237
- form rather than a relation that would attach the wrong rows. **The threshold is owned here too** —
238
- `N_PLUS_ONE_THRESHOLD` (5) sits with the codes because it is the number that decides a *verdict*,
239
- and there are two detectors reading it: `x dev`'s ledger and `@ultimat3/testing`'s `statements`
240
- fixture. Two numbers would make a loop that fails a test a different loop from one that warns in
241
- dev. What a *unit of work* is stays each detector's — a request there, one test here. **Edges are
242
- read by their `to` end**, because the loop repeated on the entity being looked up and the ledger never saw the
243
- `for … of` above it — so every page that could preload it is named, first one pasteable and the
244
- rest after it, exactly as `preloadUnknownRelation` spells its names. **A schema whose relations
245
- cannot be named still reports the loop**: `relationMap()` throws `X_INVARIANT_VIOLATED` on two
246
- keys it cannot tell apart, and a diagnostic that let that escape would replace the N+1 with a
247
- schema complaint the loop did not cause — in a dev process, as an uncaught throw — so the
248
- derivation falls back to the `in` form. **`expectedQueryLoop` is the only way to declare a loop
249
- deliberate**, and it silences the count upstream; there is no flag on these errors and no fix
250
- that turns the warning off.
251
- - **A page is bounded whether or not the caller bounded it.** `DEFAULT_PAGE_SIZE` (50) answers the
252
- read nobody sized; `MAX_PAGE_SIZE` (10,000), beside it in `plan.ts` so both drivers read one
253
- number, answers the read they did. `limit(rows)` was `next({ limit: rows })` and nothing else —
254
- no integer check, no positivity check, no ceiling — so an action taking `pageSize` as input and
255
- passing it through bound whatever a client sent, and one request could ask for five million rows.
256
- `assertFinitePageSize` is `assertBatchable`'s three refusals in the other call, deliberately under the
257
- same code (`X_INVARIANT_VIOLATED`) because `limit(0)` and `inBatches(0)` are one mistake in two
258
- places. Called from **both** `limit()` on the chain (so the refusal lands on the line the author
259
- wrote) and both `plan()` builders, on the RESOLVED page — so `findMany({ limit })` straight at the
260
- repository cannot route around it, and `bun run finite-bounds` can see the repair, which it could
261
- not while the screen was spelled `assertPageSize` and took a parameter called `rows` —
262
- and `MAX_PAGE_SIZE` bounds `inBatches(size)` too — a batch IS a page, so the ceiling belongs to
263
- the range and not to one of the two calls.
19
+ - **`repo.ts` is the contract** (`Repo`, `Page`, `FindManyArgs`, `Transactor`); `memory-repo.ts` is
20
+ `memoryRepo()`.
21
+ - **Two drivers, one meaning.** `memoryDriver()` and `postgresDriver()` share `plan.ts`, `cursor.ts`
22
+ and the `Repo` contract. Every bulk or read feature carries BOTH a `*-parity.test.ts` (identical rows
23
+ into both drivers, identical output) and a `pg-driver-<feature>.live.test.ts` against a real server.
24
+ Both are the bar.
25
+ - **`write-parity.test.ts` runs every write against memory AND PGlite**: a patch property present with
26
+ `undefined` is ABSENT (only `null` clears); memory refuses a duplicate PK, a duplicate non-partial
27
+ `unique` and a PK patch onto another row with `X_DB_UNIQUE_VIOLATION` (`memory-unique.ts`; partial
28
+ uniques are not checked in memory); `.transition()` filters on `singleKeyOf(entity)`;
29
+ `decimal()`/`bigint()` store Postgres's spelling (never rounded); a seed `upsert` compares only named
30
+ columns; `text({ max })` counts code points; `integer()` holds int4; `url()` lower-cases its scheme.
31
+ - **`min`/`max` over a `timestamptz` crosses as epoch ms** (`aggregate-time-parity.test.ts`). A whole
32
+ row read under a non-UTC session still fails on PGlite — recorded, not fixed.
33
+ - **Text ordering: byte (`C`) order is the parity target** — a linguistic production collation is a
34
+ known divergence; never `collate "C"` in generated SQL (defeats the index). Create the database with
35
+ `LC_COLLATE=C` when byte order must hold.
36
+ - **Money's write shape is wider than its row shape** (`MoneyInput` takes a `bigint`; `MoneyValue`
37
+ holds a `number`); `RowWrite<Row>` types `insert`/`insertAll`/`upsertAll`; `narrowRow` (`columns.ts`)
38
+ narrows at each write method's ENTRY, before `$assert`/`upsertPlan`. `pg-money-write.live.test.ts`,
39
+ `money-write-parity.test.ts`, `type-pins.ts`.
40
+ - **A PREDICATE's meaning is decided by the column's KIND** (`memory-match.ts`): decimal-text columns
41
+ compare through core's `compareDecimalText` (`DECIMAL_TEXT` decides who asks); a `uuid` is a value
42
+ stored lower-case (`keyOf`, `parseUuid`, `narrowUuid`; text is never narrowed); `LIKE` uses
43
+ Postgres' default `\` escape and refuses a trailing escape; a run of `%` is one `.*`; `in` takes a
44
+ list or nothing, and a NULL in it emits `(col in (…) or col is null)`; a column the row never NAMED
45
+ is NULL for `eq`/`neq`/`in`.
46
+ - **The Postgres driver is proved against a real Postgres** (`pg-driver.live.test.ts`, skipped without
47
+ `TEST_DATABASE_URL`). A new operator, column kind or write path is not done until it round-trips.
48
+ - **A repository call rejects, never throws synchronously** (`tableFor`'s writes are `async`).
49
+ - **`defaultDriver()`** is the process default; `Driver.reset?()` is optional (memory only), resets
50
+ repositories in place. Test seam only.
264
51
  - **A repository pinned to its own client refuses to run inside a transaction**
265
- (`X_REPO_CLIENT_PINNED`), `As of 2026-08`. `client()` in `pg-driver.ts` is the one place a
266
- connection is chosen, which is why the guard is there and not on each method. Unpinned, `db()`
267
- answers with the open transaction — that is how a call inside `withTransaction` joins it.
268
- Pinned through `postgresDriver({ client })` it cannot: `withTransaction` ran `BEGIN` on a
269
- connection it reserved, and a statement sent straight to `config.client` takes a different one
270
- out of the pool, so the write commits whatever the transaction decides and survives its rollback
271
- while the read misses what the transaction wrote — silent both ways. **Refused, not resolved**:
272
- a `DbTx` does not name the client it was opened on, so this layer cannot tell whether the open
273
- transaction is even on the same database, and on a sharded app it is not. Joining it instead
274
- would be the same guess with the worse outcome. The `fix` names `setDbClient(client)` plus an
275
- unpinned repository, because `db()` resolving `currentTx()` first is the only path a repository
276
- joins a transaction through.
277
- - **Cursor pagination only.** OFFSET is wrong under concurrent writes: an insert before the
278
- offset shifts every later page, so a client silently skips and repeats rows. No `offset` on
279
- `FindManyArgs` or the builder; the primary key is always the last sort key, so the order is
280
- total. The cursor carries the sort **values**, not just an id — seeking by an id that was
281
- deleted between two requests would restart pagination at the top.
282
- - **The tiebreak takes the LAST DECLARED key's direction — decided 2026-08-24.** `totalOrder`
283
- appended the primary key `asc` unconditionally, so `orderBy('createdAt', 'desc')` ran
284
- `created_at desc, id asc`. `IndexInit.order` is ONE direction for a whole index, so that pair was
285
- an order this framework's own DSL **cannot declare an index for**, whatever the author wrote. It
286
- also decided the seek's shape: a mixed order has no row comparison. Measured on Postgres 16 over
287
- 20,000 rows with an index on `(org, at desc, id desc)` — `(at, id) < ($1, $2)` plans as an Index
288
- Only Scan carrying the whole seek as one Index Cond, while the or-chain the mixed order forces
289
- plans as a BitmapOr of two index scans plus a Sort over everything they matched. So `seekSql`
290
- sends the **row comparison** when every key sorts the same way and the spelled-out or-chain only
291
- when they do not; a caller who wants the mixed order still writes it — naming the key themselves
292
- is what turns the append off — and `pg-driver-cursor.live.test.ts` walks both shapes against a
293
- real server. One key stays a scalar comparison: `(("id") > ($1))` is the same plan spelled worse.
294
- - **`inBatches(size)` is that same page in a loop, and the loop owns it.** `batch.ts` holds no
295
- driver of its own: a batch is the `findMany` the chain would have sent at that position, so
296
- filters, tenancy, soft delete, the projection and every `preload()` mean there what they mean in
297
- `page()` and there is no second read path to drift. Properties, none optional. **The handle is
298
- the iteration**: it is its own iterator, so `break`, `return`, a throw and `await using` all stop
299
- the *next* statement — `close()` is `AsyncGenerator.return()` and therefore idempotent by
300
- construction, never a flag two paths could disagree about — and a second `for await` continues it
301
- instead of re-reading the table from the top. **The position is readable**: `.cursor` is where
302
- the next batch starts, advanced *before* the yield, so a consumer that breaks reads the position
303
- it stopped at and `.after(cursor).inBatches(size)` resumes it; stopping early is then cheap
304
- rather than wasted. **An empty batch is never yielded** — a consumer forced to check
305
- `batch.length` is reading around the iterator. **Three refusals, all on the chain**: a size that
306
- is not a whole number of rows ≥ 1, a chain that also called `limit()` (one number, two meanings —
307
- honouring it reads a fraction of a batch, dropping it reads the whole table the caller thought
308
- they had bounded), and an ordering no cursor can carry. That last one is why
309
- `totalOrder(entity, orderBy)` is exported from `plan.ts` rather than inlined in `planFor`: the
310
- guard has to judge the order the driver will *send*, primary key included, and a result that fits
311
- in one batch mints no cursor — so a nullable sort key would otherwise pass in every test and fail
312
- once the table grew. `State.limit` is `number | undefined` for the same reason: only "the caller
313
- named a page size" can be told apart from the default, which the driver already applies.
52
+ (`X_REPO_CLIENT_PINNED`, in `client()` in `pg-driver.ts`); the fix names `setDbClient(client)` plus
53
+ an unpinned repository.
54
+
55
+ ## Do not regress — reading
56
+
57
+ - **A point lookup batches itself** (`coalesce.ts`): `findById` calls in one microtask of one request
58
+ are one `in` statement, keyed by ctx (`WeakMap`) and a scope key covering every input but the id;
59
+ it declines rather than guesses; past `MAX_IDS_PER_STATEMENT` it is several statements; no caller is
60
+ ever left unsettled (`coalesce.test.ts` races a deadline).
61
+ - **A page batches the loop it causes** (`jit-preload.ts`): a preloaded row is served only under the
62
+ SAME `scopeKey` (a security boundary), same client, dropped by any write (`forgetPreloaded` in
63
+ `writing()`), keyed by id, bounded (`MAX_SIBLING_KEYS`, oldest page first). One switch:
64
+ `postgresDriver({ jitPreload: false })` — never an `app.config.ts` key. Shared pieces live in
65
+ `batch-read.ts`.
66
+ - **`preload(name)` shares `batch-read.ts` and keeps no request cache.** Tenancy is CARRIED only when
67
+ both entities are scoped by a column of that same name, else the related read refuses
68
+ (`X_TENANCY_UNSCOPED`). Reach is the `database()` call's `RelatedTables`; `select()` widens with each
69
+ preloaded relation's local key; attachment copies (`{ ...row }`); only `page()`/`all()`/`one()`
70
+ preload.
71
+ - **Every repository method attributes its statement** through `@ultimat3/db`'s
72
+ `withStatementAttribution(entity.$name, op, send)` via `attributed(op, send)`, `op` declared once per
73
+ method and passed to the plan builders; `writeRows(op, …)` for the three inserts; `aggregate` names
74
+ the function. Never entered with no observer. A preloaded relation is attributed to the related
75
+ entity. `memoryRepo` sends no statement. `pg-driver-attribution.test.ts` — one case per method.
76
+ - **The two N+1 codes are owned here** (`X_N_PLUS_ONE_QUERY`, `X_N_PLUS_ONE_WRITE`, `n-plus-one.ts`):
77
+ it detects nothing, takes a `StatementLoop` verdict, and derives the fix from `relationMap()`
78
+ (`preloadsFor()`), falling back to the `in` form. `N_PLUS_ONE_THRESHOLD` (5) is shared by `x dev`'s
79
+ ledger and `@ultimat3/testing`'s `statements` fixture. `expectedQueryLoop` is the only opt-out.
80
+ - **A page is bounded**: `DEFAULT_PAGE_SIZE` (50), `MAX_PAGE_SIZE` (10,000) in `plan.ts`;
81
+ `assertFinitePageSize` runs in `limit()` and both `plan()` builders, and bounds `inBatches(size)` too.
82
+ - **Cursor pagination only** — no `offset`; the primary key is the last sort key; the cursor carries
83
+ sort VALUES. **The tiebreak takes the LAST DECLARED key's direction**; `seekSql` sends a ROW
84
+ comparison when every key sorts one way and the or-chain otherwise
85
+ (`pg-driver-cursor.live.test.ts`).
86
+ - **`inBatches(size)` is the same page in a loop** (`batch.ts`): the handle is its own iterator
87
+ (`close()` = `AsyncGenerator.return()`), `.cursor` is advanced before the yield, no empty batch, and
88
+ three refusals on the chain (size, `limit()` alongside, an order no cursor can carry — judged by
89
+ exported `totalOrder`).
314
90
  - **A grouped count means one thing in both drivers, and `count-by.ts` is where that one thing is
315
91
  written.** `countBy(column)` is the aggregate a `count()` per row is the N+1 of, so both drivers
316
92
  call `groupColumnOf` before their statement exists and `countsFrom` after their rows are in — a
@@ -342,873 +118,165 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
342
118
  by text where memory keys it by a `bigint`. **Nothing new to declare**: no `groupBy()` builder and
343
119
  no error code of its own — it is a terminal on the chain that already exists, over exactly the
344
120
  rows `count()` counts.
345
- - **The codec is `@ultimat3/core`'s, and both drivers reach it through exactly two functions**:
346
- `cursorFor(entity, plan, row, id)` and `seekFrom(entity, plan)` in `cursor.ts`. Both call
347
- `assertSeekable`, and so does `planFor` — **the load-bearing one, `As of 2026-08-24`**. An
348
- ordering that cannot carry a position — a nullable key, an undeclared column, a money property
349
- named without `.minor`/`.currency` — is refused where the PLAN is built, before a statement
350
- exists. Refusing it only where the cursor is minted made the refusal depend on the TABLE:
351
- `cursorFor` runs only when a page found a row past its limit, so `orderBy('publishedAt', 'desc')
352
- .limit(20)` over a nullable column was green on fifteen seeded rows for as long as the suite
353
- existed and `X_INVARIANT_VIOLATED` on the first read past twenty in production. That file's own
354
- doc comment claimed the opposite for two majors. `assertBatchable` has always judged
355
- `inBatches()` this way.
356
- **A `timestamptz` sort key is refused when its ALIAS would not fit, `As of 2026-09`.** That key
357
- selects a second output — `<column>$US`, the microsecond half `sortPrecision` reads back — and
358
- `assertColumnName` admits 63 bytes, which is the server's whole budget: a 61-byte column
359
- produced a 64-byte alias, Postgres truncated it and said nothing, the read answered `undefined`,
360
- and `cursorFor` fell back to the millisecond `Date` `instant.ts` exists to replace. The page
361
- then cuts where no row sits. Bounded against `index-name.ts`'s `MAX_IDENTIFIER_BYTES` and
362
- measured on the alias `seekAlias` actually builds, never against a hard-coded suffix length.
363
- This package owns only what a cursor is *bound* to — `planScope(plan)`: the entity, its filters
364
- and its sort order, hashed. Not the page size (a bigger next page is the same query) and not `select` (a projection
365
- cannot move a row). A cursor that fails either the signature or the scope is `X_CURSOR_INVALID`;
366
- it must never decode to "start from the top", which is what the old codec's `null` did.
367
- - **A NULLABLE sort key orders, `As of 2026-08-24` — `asc nulls last` / `desc nulls first`, and
368
- that is `@ultimat3/query`'s spelling read rather than invented.** It was refused outright for
369
- three majors while the sibling package had defined NULL ordering all along: two pagination
370
- systems in one framework disagreeing about whether a nullable column is orderable, which is the
371
- ambiguity axiom 1 forbids, and it made the canonical listing in
372
- [`docs/architecture/06-data-layer.md`](../../docs/architecture/06-data-layer.md) unwritable in the
373
- language that page documents. Four parts. **NULL's place is WRITTEN DOWN**, never inherited from
374
- the server's default, so a driver whose default differs cannot reopen the divergence. **The
375
- cursor can say "absent"**: a key is one character of tag then the value — `~` alone is NULL, `!`
376
- prefixes a present one — so a `text` column holding the four characters `null` encodes as `!null`
377
- and can never be read as an absence, which a bare sentinel would. **The seek reaches the NULLs**:
378
- descending, a NULL position is `col is not null` (every value follows it under `nulls first`);
379
- ascending, a value position is `(col > $1 or col is null)` and a NULL position DROPS its own term,
380
- because nothing sorts after a NULL under `nulls last` and the alternative is SQL the planner has
381
- to defeat on every page. The `or col is null` is emitted only on a column that can hold one.
382
- **And a nullable key has NO row comparison**: `(a, b) < ($1, $2)` is UNKNOWN when either side
383
- holds a NULL, so every NULL row would be excluded from the page the ordering puts it on —
384
- `rowComparable` therefore wants one direction *and* not-null columns. What is left of the old
385
- refusal is the one case with no total order: a nullable PRIMARY-KEY column, reachable only
386
- through `primaryKey: [...]`, where `null = null` is unknown and two such rows are one position to
387
- the seek. `pg-null-order.live.test.ts` walks both directions at four page sizes with NULLs on both
388
- sides of every boundary, and compares the walk against the unpaged read and against memory.
389
- - **The four SQL aggregates ship, and `count(*)` is no longer the only one — `As of 2026-08-24`.**
390
- `sum`, `avg`, `min` and `max` are terminals beside `countBy`, over exactly the rows `count()`
391
- counts. Before them, "total spend this month" meant leaving the query language for hand-written
392
- SQL, which is the one read path here with no tenancy guard on it. **Never a float**: `sum` and
393
- `avg` answer decimal TEXT whatever the column was (the sum of a million `integer` rows is not an
394
- `integer`, and `Number()` past 2^53 loses digits), a money aggregate answers `MoneyValue` in
395
- integer minor units, and `min`/`max` answer the row's own type. `null` for an empty set in every
396
- one, because that is what SQL answers and a `0` would claim rows were seen. **The shared rules
397
- live in `aggregate.ts`** — which kinds each function takes, the exact decimal arithmetic — with
398
- `aggregate-fold.ts` the memory execution and `aggregate-decode.ts` the Postgres one, so the two
399
- cannot drift. **`avg` rounds at ONE fixed scale (`AVG_SCALE`, 6), half away from zero**, computed
400
- from the exact rational: `round(avg(...), 6)` in the statement and integer arithmetic in memory,
401
- because "whatever numeric division gives you" is not a rule two implementations can share — the
402
- first draft rescaled relatively instead of absolutely and answered `11000.000000` where the server
403
- said `1.100000`, which the live parity test caught. **Refused rather than answered**: `min`/`max`
404
- on `text` (ordering is the database's COLLATION there and JS code-unit order here, and a
405
- comparison that cannot be made to agree is not answered twice differently), `avg` over money
406
- (`X_AGGREGATE_UNSUPPORTED` — the mean of an integer number of minor units is not one, so every
407
- answer would be the silent rounding `MoneyValue.scale` exists to prevent), an amount covering more
408
- than one currency **or scale** (`X_AGGREGATE_MIXED_CURRENCY`, counted in its own statement before
409
- the aggregate is asked for — the scale half is the one with no symptom, since `{ minor: 5,
410
- currency: 'USD' }` and the same row at `scale: 6` differ by 10,000x), and a money total past
411
- ±2^53 minor units. **`approximateCount()` is `reltuples`**, one row out of `pg_class`, constant
412
- time — because `count(*)` walks every visible row and no index can help, which is what makes
413
- `X_DB_STATEMENT_TIMEOUT`'s "add the index this statement needs" unfollowable on a large table. It
414
- is the whole TABLE's number, so a filtered chain **and every tenant-scoped entity** are
415
- `X_APPROXIMATE_COUNT_FILTERED`; the guard runs BEFORE tenancy, or a scoped entity had no reachable
416
- call at all — unscoped it was `X_TENANCY_UNSCOPED` and scoped it was this. `null` for a table
417
- nobody has analysed (`-1` in `pg_class`), which is the absence of an estimate and not an estimate
418
- of zero. The in-memory driver answers the exact count and refuses the same two cases, so both
419
- drivers answer one QUESTION.
420
- - **A `json()` or `arrayOf()` column is filterable, `As of 2026-08-24`.** `Operator` gained
421
- `contains` (`@>`), `contained-by` (`<@`), `overlaps` (`&&`) and `has-key`; before them the
422
- vocabulary could compare a column to a scalar and nothing else, so an app storing either had to
423
- leave the query language — the unguarded path again. **The meaning is Postgres', measured rather
424
- than summarised**, in `containment.ts`, read by both drivers. Three clauses are easy to state
425
- wrongly and two were wrong here first: the array-contains-a-primitive exception applies **at the
426
- top level only** (`'{"list":[1,2,3]}' @> '{"list":2}'` is FALSE) and **to primitives only**
427
- (`'[{"a":1}]' @> '{"a":1}'` is FALSE); `&&`'s empty operand overlaps NOTHING where `@>`'s is
428
- contained by everything. **`jsonb` and array `@>` are two operators sharing a symbol**: the first
429
- is recursive structural containment, the second is plain element membership, because an array's
430
- elements are scalars of one declared type — `arrayOf()` refuses `jsonb`, `bytea`, `money` and a
431
- nested array, which is what makes that true. A `Date` element compares by its instant, never by
432
- reference. **No jsonpath expression operator** beside them, deliberately: `contains` already
433
- matches nested structure, and a path language inside the query language is a second way to ask
434
- one question. `&&` on a `jsonb` column is refused where it was written, since Postgres has no
435
- such operator and any answer would be one no statement can make. **`has-key` emits the `?`
436
- OPERATOR, schema-qualified (`operator(pg_catalog.?)`), and not `jsonb_exists(col, $1)`** — the two
437
- are the same test and only the first is INDEXABLE: measured on Postgres 16 with a GIN index and
438
- `enable_seqscan = off`, `data ? 'k'` plans as a Bitmap Index Scan and the function form is a Seq
439
- Scan the planner will not convert, because an index is matched against an operator expression and
440
- a bare function call is not one. The function form shipped first, on a stated fear of `?` being
441
- read as a placeholder; Bun's client passes it through verbatim (measured), and the qualified
442
- spelling is immune to a client that does not and to a `search_path` that shadows the operator. The
443
- bullet ABOVE this one said the opposite — "`jsonb_exists(col, $1)`, never the `?` operator" — for
444
- two majors after the SQL moved, and `containment.ts`'s own doc comment repeated it; both are gone,
445
- and `pg-sql.test.ts` now pins the emitted form so neither can come back as prose.
446
- - **A GIN index is declarable — `indexes: [{ on: ['tags'], using: 'gin' }]`, `As of 2026-08-24`.**
447
- Without one every containment operator above is a sequential scan, which is the whole reason they
448
- needed an index at all: measured over 20,000 rows, array `@>` / `<@` / `&&` and jsonb `@>` and
449
- `?` each become a Bitmap Index Scan with a GIN index and none touches it without.
450
- `pg-containment.live.test.ts` explains the driver's OWN statement rather than a lookalike — the
451
- `count` one, because a page's `order by "id"` plus `limit` lets a four-row table be served by the
452
- primary key whatever the predicate could have used. **The closed set is `@ultimat3/db`'s
453
- `INDEX_METHODS`, imported and never restated** (tier 1, downward): two members, `btree` and
454
- `gin`. **Absent is `btree`** — an index that names no method emits the statement it always
455
- emitted byte for byte and its snapshot entry carries no `using` at all, so nothing regenerates;
456
- proven by generating twice against the first generation's own snapshot and asserting the second
457
- is empty. **The METHOD joins the name discriminator**, beside `where` and `order`: a btree on an
458
- `arrayOf()` column answers `=` and an ordering while a GIN on the same column answers `@>`, so
459
- they are two distinct indexes that would otherwise be one name — the dedup drops one in silence,
460
- or, since that dedup is on the whole definition, two `create index` statements collide as `42P07`.
461
- It is appended to the hash only when declared, so every name minted before methods existed is
462
- unchanged. **Two Postgres rules are refused HERE**, where the author is: a GIN index cannot be
463
- unique and cannot order its keys. `@ultimat3/db`'s `createIndex` refuses both again — that is the
464
- guard for a description nobody declared through `entity()`, not a duplicate — but its refusal
465
- lands at `x db gen`, or inside `ROLE=migrate` as the server's own syntax error with none of the
466
- entity's words in it. **`jsonb <@` is not indexable and that is Postgres', not this package's**:
467
- `<@` is not in the default `jsonb_ops` operator class, so it is a sequential scan whatever index
468
- is declared — pinned in the live test so a reader is not left wondering whose doing it is.
469
- - **A relation is a foreign key read a second way, never a second declaration.** `relations.ts`
470
- derives `belongsTo` from an entity's own `references()` columns and `hasMany` from the inbound
471
- ones; there is no `hasMany: […]` init key and adding one would put two declarations of one fact
472
- in the schema. A thunk is resolved in exactly one place — `referenceBinding()` in `column.ts` —
473
- so the DDL projection (`describe.ts`) and the relation map can never disagree about what a
474
- `references()` points at. Naming is order-independent by construction: when two keys want one
475
- name, **every** member of that group takes its long form, so declaring a second foreign key
476
- never renames the first relation behind a caller's back. What the two tiers cannot separate is
477
- refused with `X_INVARIANT_VIOLATED` naming both columns — never collapsed into one relation.
478
- - **An index is described whole — columns, uniqueness, predicate, direction — never by its name
479
- alone.** `EntityDescription.indexes` is a list of `IndexDescription`, not of strings, because the
480
- `<table>_<a>_<b>_idx` name `entity()` mints joins with `_` and cannot be read back: a two-column
481
- index recovered from its own name became the single column `"org_id_created_at"`, so
482
- `generateMigration` emitted DDL Postgres answers `42703` and every composite index in the
483
- framework — including the composite unique one `upsertAll`'s `on conflict` is inferred against —
484
- had to be written by hand. `where` and `order` ride along for the same reason: a partial index
485
- emitted as a total one refuses rows the entity allows. `on: []` is refused at declaration
486
- (`X_INVARIANT_VIOLATED`), where the author can see it. **And the NAME carries the predicate and
487
- the direction too, `As of 2026-08-24`** — eight hex characters of sha256 over `order` and `where`,
488
- folded in as `<table>_<cols>_<hash>_idx`. Without it two DIFFERENT partial indexes on one column
489
- were one name (`posts_author_id_idx` for both `where status = 'published'` and
490
- `where status = 'draft'`), and the dedup below dropped the second with no error, no warning and
491
- no drift finding either, since `compareTable` matches a declared index by name. **Only when the
492
- index carries one of the two**: a plain index keeps `<table>_<cols>_idx`/`_key`, because
493
- `unique()` on a column is an inline column clause and Postgres names the index it creates exactly
494
- `<table>_<column>_key` — a discriminator there would make the generator emit a second
495
- `create unique index` for an index that already exists (`42P07`). The dedup itself is on the
496
- whole `IndexDef`, not on the name: a name is derived, and matching on a derived string is what
497
- made two indexes indistinguishable in the first place. **And the name is bounded at 63 BYTES**
498
- (`MAX_IDENTIFIER_BYTES`, `NAMEDATALEN - 1`), refused at declaration: Postgres truncates a longer
499
- identifier and says nothing, so two names sharing their first 63 bytes are one index on the
500
- server — the same silent collapse one layer down, and invisible to a drift check comparing
501
- DECLARED names, which still differ.
502
- - **`isNull()`/`isNotNull()` are the ONLY total members of the invariant vocabulary, and `iff` is
503
- built out of them — `As of 2026-08-25`.** Postgres' `IS NULL` answers true or false for every
504
- input NULL included; every other operator here answers NULL for a NULL operand and **a CHECK
505
- PASSES on NULL**, so the database is the more permissive half wherever a predicate reads a
506
- nullable column. The app side reads an ABSENT key and a stored `null` as one value, which is
507
- `is-null.ts` — one rule, because `memory-match.ts` and `containment.ts` each had a private copy
508
- and `expr.ts` was about to be the third. `iff(a, b)` renders `(a) = (b)`, byte for byte the shape
509
- `examples/dummy`'s hand-written `0001_init.sql:67` already holds.
510
- **`=` and not `is not distinct from`, decided on a measurement, and the reasoning inverts the
511
- obvious one.** With both operands total the two spellings are identical on all four boolean pairs
512
- (measured, PG 18.4). They part only on a NULL operand, and there the TOTAL form is the DANGEROUS
513
- one: `(NULL) is not distinct from (false)` is false and refuses the row, while TypeScript reads a
514
- NULL operand as false and `false === false` ACCEPTS it — a raw `23514` in place of
515
- `X_INVARIANT_VIOLATED`, which is the exact failure `matchOperator`'s flag refusal exists against.
516
- `=` leaves the disagreement in the safe direction, where the app refuses first and no write ever
517
- reaches a CHECK that would have refused it. `pg-invariant-null.live.test.ts` measures both
518
- spellings on a real table, and `expr.test.ts` pins the permissive direction so a half-fix that
519
- flips it fails loudly. **`iff` is a FUNCTION, not a method on `Expr`**: `Expr` is exported, so a
520
- required member breaks a structural implementer, and `kind: 'unique'` is an `Expr` whose `toSql`
521
- is a column LIST — a `.iff()` there would be a method that cannot mean anything for some values
522
- of its own type. That operand is refused in one place, with the `c.unique([…])` invariant the
523
- author meant spelled out from its own columns — **each path through `JSON.stringify`, `As of
524
- 2026-08-25`**: a `fix:` is TypeScript to PASTE, so a column name reaching it is a value spliced
525
- into source, and `'${column}'` produced `invariant('o'brien_unique', …)` on a name carrying a
526
- quote. `columns: { "o'brien": text() }` is a legal declaration and `unique()` is reached untyped
527
- by a JS caller besides; a backslash is the half that doubling the quote would still have missed.
528
- The same defect as an unescaped pattern, one layer up in the error message. **One app-only operand makes the WHOLE rule
529
- app-only** (`sql: null`, so `bindInvariant` lands it as `assert`): emitting half a biconditional
530
- would enforce something nobody wrote.
531
- - **A `matches()` pattern reaches the CHECK as the SAME STRING `pattern.test` runs, or it is
532
- refused — `As of 2026-08-25`.** Nothing is translated and nothing ever may be: a "close enough"
533
- POSIX rewrite of a JavaScript-only construct ships two rules under one name, which is worse than
534
- the `assert` a predicate already gives you. What makes one string in front of two engines legal is
535
- `pattern-portability.ts`, a scanner over the source that names the first construct ARE and
536
- ECMAScript read differently, and every entry on it is a MEASUREMENT against a real server, not a
537
- reading of the docs. The flagship: `'foo' ~ '\bfoo'` is FALSE on Postgres 18.4 and
538
- `/\bfoo/.test('foo')` is true, because ARE reads `\b` as a BACKSPACE — both compile, neither
539
- errors, and the CHECK enforces a rule the entity never wrote. So are `.` (matches a newline there
540
- and never here), `\w` (the locale's alnum class, which matches `é`), `\s` (JavaScript adds
541
- U+00A0), `[[:alpha:]]`, a leading `]` in a class, `\x` (three hex digits there, two here), `\A`
542
- and `\Z`, and a named group. `\d` is IN, measured rather than assumed — POSIX fixes
543
- `[[:digit:]]` at the ten ASCII digits, so `'٣'` and `'5'` are false on both sides. `\uwxyz` is in
544
- for a reason that is not convenience: **Bun escapes a regex LITERAL's non-ASCII characters**,
545
- `/^é$/.source` is `^\u00E9$` while `new RegExp('^é$').source` is `^é$`, so refusing the escape
546
- would refuse every i18n pattern written the ordinary way. The refusal carries the portable
547
- spelling where one exists and the app-only predicate where none does, and it lands at DECLARATION
548
- beside `matchOperator`'s flag refusal, on the line that wrote it. `pg-invariant-pattern.live.test.ts`
549
- runs both halves against a server: every kept construct must AGREE and every refused one must
550
- still DISAGREE — so a future Postgres that grows JavaScript's `\b` turns the list red instead of
551
- leaving a stale exclusion in place. **The kept half is only as broad as its table, and the table
552
- was narrower than the claim from the day it landed, closed 2026-08-25**:
553
- `pattern-portability.ts` called `(?<=` and `(?<!`
554
- measured and neither had ever been run, and nor had a capturing group, a top-level `|`, `{n,}`,
555
- `{n,m}`, `\t`/`\f`/`\v`, an escaped punctuation outside a bracket expression, a bare `]`/`}`, or
556
- a trailing `-` in a class. All of them agree on 18.4 (73 pairs, 0 disagreements) and all of them
557
- now have rows; every row is also asserted to be a construct `unportableConstruct` KEEPS, since a
558
- row for a refused one measures something `matches()` can never emit. What remains unmechanised is
559
- the direction no source can enumerate — a construct added to the kept set with no row here.
560
- - **A declared string is spliced by `@ultimat3/db`'s `literal()` and by nothing in this package —
561
- `As of 2026-08-25`, and doubling the quote is only HALF the rule.** `expr.ts` and
562
- `column-values.ts` each carried `'${v.replaceAll("'", "''")}'`; a CHECK takes no bind parameters,
563
- so a `matches()` pattern, a `contains()` needle and every `enumerated()` member an app declares
564
- reach statement text unescaped against the one character that is not a quote. With
565
- `standard_conforming_strings = off` — a SESSION setting, `SET`table by anyone — a backslash
566
- escapes the character after it inside an ordinary literal: measured on 18.4, `'dd' ~ '^\d+$'` is
567
- FALSE with the GUC on and **TRUE** with it off, because the server compiles `^d+$` and the CHECK
568
- silently enforces a pattern nobody wrote; and `'\''` leaves the literal UNTERMINATED, so
569
- following text becomes string data until the next `'` puts the remainder back into code position
570
- (reproduced as `syntax error at or near "x') > 0 , '"`). `E'…'` fixes the dialect in the TEXT
571
- rather than trusting the setting, and **only** when the value carries a backslash — without one
572
- there is no escape mechanism to disagree about, so every CHECK already generated stays byte for
573
- byte what it was and nothing regenerates; both tracked apps hold applied migrations whose
574
- checksums are taken over that text.
575
- **The rule lives in tier 1 and this package imports it down.** It was written here first, as
576
- `sql-literal.ts`, and that file is deleted: `@ultimat3/db`'s `literal()` now carries the same
577
- transformation and the same measurement, `packages/entity` already depends on `@ultimat3/db`, and
578
- `bun run sql-literal-copies` refuses any module outside `packages/db/src/sql.ts` that turns `'`
579
- into `''` — matched on the TRANSFORMATION, because the three copies were called `literal`,
580
- `literalText` and an unnamed inline splice, and a name-based rule reads past the third exactly as
581
- one spelled `RenderMode` read past `PwaRenderMode`. `expr.ts` keeps a four-type wrapper that
582
- delegates and unwraps `.text` — `Invariant.sql` is a bare string and a `SqlFragment` cannot
583
- survive that round trip — and it re-spells nothing. **The half that ratchet cannot see is a
584
- producer DROPPING the call**: `` `'${value}'` `` doubles no quote, so it matches no rule, which is
585
- why `expr.test.ts` pins all four splice sites (`contains`, `eq`, `matches`, `oneOf`) against a
586
- quote-bearing and a backslash-bearing value. A fifth producer added without the call fails there.
587
- - **Every physical name is checked, including the DERIVED one — `As of 2026-08-24`, and it was a
588
- DDL injection.** `columnName` is `meta.name ?? snake(property)` and only the first branch reached
589
- `assertColumnName` for three majors, while `snake()` lower-cases and does nothing else. A column
590
- declared as `n" , "x" text); drop table t; --` therefore produced a `create table` carrying a real
591
- `drop table` — measured through `generateMigration`, not theorised — and an entity NAME did the
592
- same through `table: init.table === undefined ? name : assertColumnName(init.table)`, whose
593
- fallback is every entity that does not rename its table. Quoting is not a defence against a value
594
- that can close the quote, which is what `assertColumnName`'s own doc comment already said. Checked
595
- at `bindColumn` (once per column, at `entity()`) rather than in `columnName` (every statement).
596
- - **Relations reach query time through `RegistryEntry.references()`, and the DDL string is
597
- rendered from it.** The resolved records are the source; `ColumnDescription.references` spells
598
- `"<table>.<column>"` out of one for the migration generator, which is in tier 1 and cannot
599
- import this package. Never parse that string back — it carries physical names and a traversal
600
- reads row *properties*, so the parse would be a second, lossy resolver. **`onDelete` rides
601
- beside it, on both `ColumnDescription` and `ReferenceDescription`, `As of 2026-08-19`**: the flat
602
- string has no room for a rule and neither record had a field for one, so a declared
603
- `{ onDelete: 'cascade' }` type-checked and reached no SQL for three majors — `@ultimat3/db` emits
604
- it now, and it can only see what the projection carries. Read off the resolved reference, never
605
- off `meta` a second time: a rule with no key is not a thing. `references()` is a
606
- method, not a field: a thunk may point at an entity two modules of an import cycle have not
607
- finished evaluating. `relationMap()` memoises the whole-registry derivation against
608
- `registryGeneration()`, which every registration bumps — a schema module imported late must
609
- rebuild the map, never be missed by it. The derivation is **one pass** over the foreign keys,
610
- filed under both ends as it goes — a rescan per entity is the schema squared, paid again after
611
- every late registration. `relationNamed()` refuses an unknown name with
612
- `X_PRELOAD_UNKNOWN_RELATION` whose `fix` is a `relationNamed()` call on a relation that does
613
- exist, the rest by name after it; a relation is derived, so there is no file a reader could open
614
- to find them. An entity with no foreign key at all gets `x entities list --json` instead — the
615
- declaration it needs names a target this error cannot know.
616
- - **The process default driver has a name, and emptying it is optional on the seam.**
617
- `defaultDriver()` returns the one `database()` falls back to when a call names none — exported so
618
- a test harness seeds and empties the object the app actually reads through, since a second
619
- `memoryDriver()` of its own would be invisible to every `database()` call already made.
620
- `Driver.reset?()` is **optional**, implemented by `memoryDriver()` and by nothing else:
621
- `postgresDriver()` leaves it undefined because those rows are the app's, so a harness writes
622
- `driver.reset?.()`. The reset runs `MemoryRepo.reset()` on the repositories already handed out —
623
- in place, never a replacement — because `database()` resolves each table's repository once and a
624
- swapped-in repository is emptied where nothing is reading. Test seam only: no framework code path
625
- calls either, and neither is a fixture system.
626
- - **A repository call rejects, never throws synchronously** — `tableFor`'s writes are `async` for
627
- that reason alone: `$parse` throws, and a call site should not need two error paths for one
628
- mistake.
629
- - **Tenancy applies to writes too, and in two places.** `update(id, patch)`, `delete(id)`,
630
- `deleteWhere(filter)` and `updateWhere(filter, patch)` build the same plan a read does, so an id
631
- or a filter alone never addresses a row on a tenant-scoped entity — another tenant's id reads as
632
- `X_NOT_FOUND`, never as their row. That bounds WHICH rows a write touches; it cannot bound what
633
- they become, and `insert`/`insertAll`/`upsertAll` build no plan at all. So the VALUE is judged as
634
- well, by `assertRowTenant` (`tenancy.ts`) at the seams every write passes: `memoryRepo`'s
635
- `write()` plus its `insertAll`/`upsertAll` batch loops and its `updateWhere`, and
636
- `postgresRepo`'s `writeRows()`, `update` and `updateWhere`. **A filtered update judges the PATCH,
637
- before it reads a row** — `As of 2026-08-23`, in both drivers. `memoryRepo` judged the merged
638
- rows inside its loop, and a loop over no rows judges nothing, so
639
- `updateWhere(filter, { orgId: theirs })` over a filter matching nothing answered `0` in memory
640
- and `X_TENANCY_ACTOR_MISMATCH` in Postgres: whether the guard fired depended on what the table
641
- held rather than on what the caller asked for. A row or patch naming another tenant is `X_TENANCY_ACTOR_MISMATCH` —
642
- the same code the read path throws, because it is the same mistake in a different argument.
643
- Rules, none optional. **Refuse, never stamp**: a row that names no tenant is left alone and the
644
- column's `NOT NULL` answers it. Filling one in from the actor would change the column list
645
- `namedProperties` derives, silence the uneven-batch refusal (`excluded.<col>` is a default, not
646
- "leave it alone"), and let ambient state decide which stored row a collision lands on — a write
647
- that creates data from the ambient context is a bigger decision than a guard. **All or nothing**:
648
- the batch loops run before any row is stored, so memory cannot half-apply what Postgres refuses
649
- as one statement. **The incoming rows, not only what lands**: under `onMatch: 'nothing'` a
650
- colliding row never reaches `write()`, so a check only on stored rows would pass exactly the rows
651
- that collide. **Refused before the statement exists** — `pg-driver` sends nothing and `memoryRepo`
652
- stores nothing, which `write-tenancy-parity.test.ts` pins for both drivers together, and
653
- `pg-driver-tenancy.live.test.ts` proves against a real server — that file is where tenancy's live
654
- proof lives, reads and writes both, and where a new one goes. **Together with the conflict-target rule
655
- a cross-tenant upsert is unrepresentable**: the target must contain the tenant column under
656
- `'update'` (`X_TENANCY_UNSCOPED`, which decides which stored row is matched) and every incoming
657
- row must carry the actor's tenant, so the key can only hold this actor's value.
658
- - **`deleteWhere(filter)` and `updateWhere(filter, patch)` are the only filtered writes, and they
659
- are bounded by construction.** `delete(id)` and `update(id, patch)` need a single-column primary
660
- key, so on a composite key — `likes`, `blocks`, `participants`, any join table — the filtered
661
- pair is the only write path that exists; without them the entity is create-only and a row can be
662
- written and never unwritten. They are also the bulk forms of `delete`/`update` for the ordinary
663
- case — one statement for a `for … of` loop that would otherwise delete or patch one row at a
664
- time — the same role `insertAll`/`upsertAll` (below) play for a per-row insert loop; a
665
- write-loop detector's `fix:` names one of these four, never a hand-rolled loop. Properties, none
666
- of them optional:
667
- - an empty filter is `X_WRITE_UNFILTERED` and never every row; an empty patch is `X_PATCH_EMPTY`
668
- and never a counted no-op. An `undefined` value is dropped *before* either count, so a
669
- forgotten variable lands on the error rather than on the table.
670
- - **one code for both verbs**, because it is one situation with one remedy. Splitting it into
671
- `X_DELETE_UNFILTERED`/`X_UPDATE_UNFILTERED` would give two codes the same `fix` and make a
672
- caller choose which to catch. The situations that genuinely differ — no filter, no patch —
673
- are what get separate codes.
674
- - the filter guard runs before tenancy is applied, because one tenant's every row is still
675
- every row.
676
- - soft delete follows the entity's `deletedAt` column exactly as `delete(id)` does: stamped rows
677
- are not matched twice, and `updateWhere` carries the same `deleted_at is null` clause
678
- `update(id, patch)` does, so a deleted row is never patched back into shape.
679
- - both return a count, never `void`: a filtered write that silently matches nothing is
680
- indistinguishable from one that worked.
681
- - **the rows come back only when something here can still refuse them**, `As of 2026-08`.
682
- `updateWhere` ended its statement in `returning *` unconditionally and looped `$assert` over
683
- the result, on every entity — including the ones whose every rule is a CHECK Postgres already
684
- enforced on the statement, where the loop judges nothing. A tenant-wide sweep
685
- (`updateWhere({ orgId }, { marketingOptIn: false })`, twelve million rows) therefore streamed
686
- the whole table into a process sized for one request, and `deleteWhere` beside it was a count,
687
- which is what made the failure look arbitrary. `hasJsOnlyInvariant($invariants)`
688
- (`invariants.ts`, reading the same list `uniqueTargets` classifies a conflict target from) is
689
- the switch: no `assert` rule, no `returning *`, `execute()` and the command tag. When rows ARE
690
- needed the match is **counted first** and refused past `MAX_ASSERTED_ROWS` (50,000) naming
691
- `inBatches(1000)` — a refusal issued after `returning *` is already holding what it refuses.
692
- `updateStatement`'s `returning` is a required parameter with no default for the same reason:
693
- the three callers want three answers and the wrong one is invisible in the result. The soft
694
- delete inside `removal()` passes `false` too — both its callers read a count through
695
- `execute()`, so its rows were never readable by anyone.
696
- **And the bound is BOTH drivers', `As of 2026-09`.** `memoryRepo.updateWhere` applied no
697
- ceiling at all, so a sweep of 50,001 rows answered `50001` here and `X_INVARIANT_VIOLATED`
698
- there from one call — a test written against memory proving a call production refuses, which
699
- is the drift the two-driver split exists to prevent. Same condition (`hasJsOnlyInvariant`),
700
- same constant, same error, and checked before the write loop so a refusal never writes the
701
- rows it declined to judge.
702
- - **Every instant the write path stamps comes from `ctx.clock`, through `entityNow()`**
703
- (`clock.ts`, `As of 2026-08`). `defaultNow()`, `touch()`'s `onUpdateNow()`, the soft-delete stamp
704
- in BOTH drivers and a seed's `now` each read `systemClock` directly, so a frozen test clock drove
705
- nothing the entity layer wrote — `createdAt`, `updatedAt` and `deletedAt` were the wall clock
706
- however the ctx was built, and a test could only assert a range where it wanted a value. The read
707
- path still reads no clock at all, which is what makes IT drivable (`@ultimat3/query`'s CLAUDE.md
708
- says so in as many words); this is the write half of the same property. Outside a request there
709
- is no ctx and the system clock IS the answer — a script, a worker boot and a seed take that
710
- branch exactly as before. Never read `systemClock` on the write path again: five sites is how the
711
- four stamps of one write ended up able to disagree.
712
- - **`touch()` in `query.ts` is the ONE place `onUpdateNow()` columns are stamped**, for
713
- `update(id, patch)` and `updateWhere(filter, patch)` alike — a second copy is how one of them
714
- ends up writing a stale `updatedAt`. It returns an empty patch untouched, so whether
715
- `X_PATCH_EMPTY` fires depends on the call and not on whether the entity happens to declare the
716
- column.
717
- - **A many-row write is one statement, and every refusal it needs happens before that statement
718
- exists.** `insertStatement` (`pg-sql.ts`) builds *every* insert in the framework — one row or ten
719
- thousand — so `insertAll([row])` compiles to exactly the text `insert(row)` always compiled to
720
- and there is no second builder for the two to drift apart in. What both drivers have to agree on
721
- lives in `bulk-write.ts`, decided in **property** space and projected to physical columns for the
722
- SQL: the column list a batch writes (`Object.hasOwn`, exactly as `bindValues` decides it), what a
723
- collision overwrites, the conflict key, and the chunking. Rules, none optional. **A collision
724
- overwrites every column in the batch except three closed sets** — the conflict target, which is
725
- how the stored row was found, the primary key, which is where it lives, and the soft-delete
726
- stamp, which is whether the row is there at all; an upsert that moved either of the first two
727
- would move a row nobody asked to move and every foreign key already pointing at that id would
728
- miss it. **The stamp is the third because a soft-deleted row still occupies its conflict target**
729
- — the index it collides with is not partial — so `excluded."deleted_at"` would clear a delete the
730
- app made and hand the row back holding the batch's values, which is the resurrection
731
- `update(id, patch)` and `updateWhere` refuse by carrying `deleted_at is null` and an
732
- `on conflict` clause cannot carry. Excluded from the set list rather than refused, because
733
- `$parse` fills every declared column before a row reaches `upsertPlan`: that `deletedAt: null` is
734
- the framework's and not the caller's, so refusing it would make `onMatch: 'update'` impossible on
735
- every soft-deleting entity. `insertAll` is untouched — a row colliding with nothing writes the
736
- stamp it carries, exactly as `insert` does. **The conflict target must be a declared unique
737
- constraint** — because a target
738
- Postgres cannot infer an index for is `42P10` wrapped as `X_DB_UNAVAILABLE`, which names nothing
739
- the author can act on. All **three** of this framework's spellings of one count, or the refusal
740
- would tell an author to declare a constraint they already declared and ship two indexes: the
741
- primary key, a non-partial `unique: true` entry in `$indexes` (`unique()` on a column and
742
- `indexes:` both land there), and a `kind: 'unique'` entry in `$invariants`
743
- (`invariant(name, c.unique([…]))`, whose `CREATE UNIQUE INDEX` never touches `$indexes`). A
744
- partial one is deliberately not a target on either list, since its predicate would have to be
745
- repeated in the `on conflict` clause and this layer does not spell one — which is also why a
746
- soft-deleting entity's `c.unique()` invariant, stamped `deleted_at is null` by `bindInvariant`,
747
- is excluded by that same rule. **The tenant column is part of that
748
- constraint or `onMatch: 'update'` is refused** (`X_TENANCY_UNSCOPED`) — this is a security
749
- boundary, not ergonomics: `upsertAll` builds no read plan, so nothing else puts an org predicate
750
- in the statement, and a target that omits the tenant column matches a row stored by another tenant
751
- and rewrites it, tenant column included. `'nothing'` stays legal on such a target because it
752
- writes nothing to a row it does not own. **A batch that repeats one conflict target is refused
753
- under `'update'`** — Postgres answers that statement `ON CONFLICT DO UPDATE command cannot affect
754
- row a second time`, so passing it in memory and failing in production is the exact drift the two
755
- drivers exist to prevent — and **an uneven batch is refused under `'update'`** for the same
756
- reason: `excluded.<column>` for a row that omitted it is that column's *default*, not the stored
757
- value, so "leave it alone" is not what happens. `insertAll` and `'nothing'` accept an uneven batch
758
- and render `default` in the missing cell, which is what the same row means on its own.
759
- **Null is not a value here**: a null anywhere in the conflict target means the row collides with
760
- nothing, in both drivers, because a Postgres unique index is `NULLS DISTINCT`. **The memory
761
- driver judges the whole batch before storing any of it** — `$assert` over every row first — since
762
- Postgres refuses the statement as one and a half-applied batch would make the two disagree about
763
- what one call did. Past `MAX_BIND_PARAMETERS` (65535) the batch is several whole statements, so
764
- atomicity across them is `withTransaction`'s and never one statement's.
765
- - **Nothing is interpolated into SQL.** `pg-sql.ts` binds every value through `sql` and resolves
766
- every identifier through the entity, so a column name can only be one the entity declared.
767
- `raw()` appears exactly twice, for `asc|desc` and the `default` cell of a many-row `values` list —
768
- each a closed set of one word. The seek operator was the third: it is chosen in TypeScript
769
- (`seekSql`/`seekAfter`), because the seek's SHAPE is decided by the order and its bind's cast is
770
- part of the template, never a `raw()` argument.
771
- - **A `timestamp` cursor carries MICROSECONDS, and every seek term is a plain comparison —
772
- decided 2026-08-24, and it replaces the millisecond window this file described for two majors.**
773
- A `timestamptz` column holds microseconds; Bun's client hands it back as a JS `Date`, which holds
774
- milliseconds. The window (`>= v and < v + 1ms`, ascending `>= v + 1ms`) made the SEEK cut on
775
- `date_trunc('milliseconds', col)` while the `order by` beside it still sorted on the bare column
776
- at microseconds — **two different equality classes on one page**, and the rows between them were
777
- served on **no page, ever**. Not a race: three rows inside one millisecond with uuid v7 ids, a
778
- `desc` page of one, and the two later rows are unreachable on every subsequent page, because
779
- under `desc` the boundary row always holds the largest id of its millisecond and the `id >`
780
- tiebreak can never match. Reproduced against Postgres 16 before the fix and pinned by
781
- `pg-cursor-precision.live.test.ts`. No predicate over `(col, id)` built from a FLOORED value can
782
- be correct — the information is gone — so the precision is carried instead. Three parts, none
783
- optional. **The statement asks for it**: `seekPrecision` (`pg-sql.ts`) projects
784
- `(col at time zone 'UTC')::text as "<col>$US"` beside every `timestamptz` sort key, under an
785
- UPPER-CASE alias no physical column name can be (`snake()` lower-cases, `assertColumnName`
786
- refuses the rest). `at time zone 'UTC'` and not a bare `::text`, or a page position would depend
787
- on the connection's `TimeZone`. **The cursor is minted from the PHYSICAL row**: `sortPrecision`
788
- (`pg-row.ts`) reads that output, and `cursorFor`'s optional `exact` map is how a driver hands
789
- over a value the decoded row cannot hold. **The seek binds an ISO instant with all six digits**:
790
- `col < $1::timestamptz`, the cast in the template rather than a `raw()` call, the column bare so
791
- the index still range-scans. `instant.ts` is the only place the two representations meet —
792
- microseconds since the epoch, as a `bigint`, in the cursor and in `compareByKind`. The memory
793
- driver stores millisecond `Date`s, which are exact in that domain, so the two drivers still agree
794
- without a second rule; `nextMillisecond` and `seekEqual`'s `Date` branch are gone. A cursor
795
- minted before this carries an ISO string and is `X_CURSOR_INVALID`, never a bare `SyntaxError`
796
- out of `BigInt`.
797
- - **`MoneyValue.scale` PERSISTS, in a third physical column — decided 2026-08.** `<p>_scale integer
798
- null`, through `columnsOf` / `bindValues` / `moneyOf` / `parseMoney` / `describeColumn`. Until
799
- this branch the entity layer silently dropped it on **both** write and read: `parseMoney` rebuilt
800
- the value as `{ minor, currency }`, `bindValues` wrote two columns and `columnsOf` declared two,
801
- so `money().$parse({ minor: 2, currency: 'USD', scale: 6 })` — $0.000002 — was stored and read
802
- back as $0.02. A silent 10,000x reinterpretation, with no error anywhere, of a field the type
803
- system (`type-pins.ts` asserts `MoneyValue` is exactly `minor | currency | scale`), the wire
804
- schema (`t.money` validates and preserves it) and `@ultimat3/money` all carry. **The rejected
805
- alternative was making `parseMoney` refuse a scaled value**: `scale` exists precisely so a
806
- sub-cent amount can be named — the $0.00016 model call that rounded up to a whole cent and
807
- reported 62x the real spend — so refusing it at the persistence layer would delete the feature at
808
- the one layer that has to keep it. Rules, none optional. **`null` is not `0`**: the column holds
809
- NULL for "the currency's own minor unit", which is every amount written before the column
810
- existed, and it decodes to an ABSENT key — `0` means whole units and would be a 100x error on
811
- every ordinary price, so `bindValues` writes `money?.scale ?? null` and `moneyOf` omits the key
812
- rather than defaulting it. **Always nullable, whatever the property is**: a NOT NULL there would
813
- demand a scale on values that have none. **The bound is `@ultimat3/schema`'s** — `parseScale`
814
- calls `isMoneyScale`, never a restated `0…15`, and `scaleCheck` emits the matching CHECK so a
815
- psql session cannot write a scale the app would refuse to read. **`scale` is not addressable**:
816
- `MONEY_PARTS` in `pg-row.ts` and in `cursor.ts` still hold `minor` and `currency` only, because a
817
- scale says which units `minor` counts — ordering or filtering by it compares two different
818
- questions. Existing tables need `alter table <t> add column <p>_scale integer` (see the migration
819
- note in the PR); every existing row's NULL already means what it always meant.
820
- - **The currency bound is `@ultimat3/schema`'s too, in BOTH halves — decided 2026-08.**
821
- `parseCurrency` calls `isCurrencyCode` and `currencyCheck` interpolates `CURRENCY_CODE_PATTERN`,
822
- the pattern source that predicate is built from, exactly as `scaleCheck` interpolates
823
- `MAX_MONEY_SCALE`. `^[A-Z]{3}$` had been restated four times across three packages — schema's
824
- private regex, its JSON Schema `pattern`, this column's parse and this CHECK — each individually
825
- correct, and a divergence between the last two is visible only to a psql session, as a row the
826
- app then refuses to read back. SQL cannot call a predicate, so what crosses the seam is the
827
- pattern **string**: legitimate only while the pattern stays inside the syntax ECMAScript and
828
- POSIX ARE spell identically, which is why `currency-check.live.test.ts` inserts the same corpus
829
- `columns.test.ts` runs into a real table carrying the emitted CHECK and demands the server accept
830
- exactly what `isCurrencyCode` accepts. That table is `text`, not `char(3)`, on purpose: a width
831
- refusal would answer for every over-long case and leave the pattern untested on them.
832
- - **Money is a `bigint` + `char(3)` column pair, and a `number` + `char(3)` VALUE.** A float throws.
833
- Never one column, never an implied single currency — and never two declarations of the shape.
834
- `MoneyValue` is re-exported from `@ultimat3/schema`, which is also what `@ultimat3/money`'s
835
- `Money` is: **one** declaration, at the only tier every package may import. It was three
836
- structural restatements, and the entity layer's copy had a `bigint` `minor` — so a row this
837
- package decoded threw inside `JSON.stringify` (an action returning it crashed the response) and
838
- failed `t.money`, the node that becomes the OpenAPI contract. `type-pins.ts` fails the build if
839
- the alias is ever re-declared here, if `minor` widens back to a `bigint`, or if either field
840
- loses `readonly`. **The column is wider than the value on purpose, and the gap is a refusal, not
841
- a rounding**: `parseMinor` (`columns.ts`) takes the `bigint`, the `number` and the string int8
842
- arrives as, and refuses anything past ±2^53 with `X_INVARIANT_VIOLATED` naming the value — the
843
- same value `@ultimat3/realtime` refuses for the same reason, so the two readers of one column
844
- agree. **The write half stays wide**: `MoneyInput` takes a `bigint`, so a minor unit read off a
845
- `bigint` column needs no conversion at the call site — and `narrowMoney` is called by
846
- **both** drivers, `bindValues` before a statement and `memoryRepo`'s `write` before it stores, so
847
- a row's money never depends on which driver produced it. Applying it to one of them only is the
848
- drift the two-driver split exists to prevent: it would leave the in-memory row the one row in
849
- the framework `JSON.stringify` refuses.
850
- - **Timestamps are `timestamptz`.** A naive timestamp must stay inexpressible.
851
- - **A tenant column means every query runs under the ACTING ACTOR's tenant** — derived from
852
- `tryUseContext()?.actor.orgId` in `scopedPlan` (`tenancy.ts`), which every repository operation
853
- reaches through `readPlan`, so both drivers and every read, write and count pass one derivation.
854
- `tenant: 'orgId'` declares the column; omitted, inference still applies (`.tenant()`, else a
855
- column named `orgId`), so silence never means unscoped. Never make the declaration the only
856
- switch. **And the column may not be nullable** — refused in `resolveTenantColumn`, at
857
- declaration, on all three paths and not just the declared one, `As of 2026-08`. `.tenant()` sets
858
- `{ tenant: true, index: true }` and said nothing about nullability, so `uuid().nullable().tenant()`
859
- was legal — while `assertRowTenant` returns early on a row that names no tenant and explicitly
860
- delegates to the column's `NOT NULL`. On a nullable column that delegation has nothing behind it:
861
- the row lands with a null tenant, no `org_id = $1` matches it, and it is invisible to every
862
- tenant-scoped read — never exported, never swept on offboarding, owned by nobody for as long as
863
- the table exists. Five rules, none optional. **A caller-supplied `orgId` is an assertion, never the
864
- authority**: equal to the actor's it is a restatement (one predicate, not two), different from it
865
- — which is what an `orgId` taken from action input looks like — it is `X_TENANCY_ACTOR_MISMATCH`
866
- with both values in the cause. **Refused, never overridden**: rewriting the predicate to the
867
- actor's org would answer the wrong question correctly and ship the bug. **Every predicate on the
868
- tenant column is checked and `eq` only**, so `in [mine, theirs]` is a mismatch too. **An actor
869
- with no org is refused** (`X_TENANCY_ACTOR_ORG_REQUIRED`): anonymous is inside no org, so every
870
- tenant-scoped row is somebody else's, and letting the caller's value stand there would leave the
871
- hole open on exactly the unauthenticated path. **Outside every request context there is no actor
872
- to derive from** — a script, a seed, a test harness — so the caller names the tenant itself and
873
- `X_TENANCY_UNSCOPED` still refuses a plan that names none. There is no build-time tenancy step in
874
- `x verify` (its 20 steps check none) and the old comment in `tenancy.ts` claiming one was wrong:
875
- the tenant is a request-time value, so the seam is the enforcement.
876
- - **`crossTenant(reason, fn)` (`cross-tenant.ts`) is the ONE way to read across tenants**, for the
877
- three cases that have no single one: an admin surface over every org, background reconciliation,
878
- support tooling. An async-context scope with a written reason, the same shape
879
- `@ultimat3/db`'s `expectedQueryLoop` has, never a boolean argument on a repository call — which
880
- reads exactly like forgetting the tenant — and never a config list of exempt entities (axiom 1).
881
- The scope opens through `asyncContext<string>('the cross-tenant reason')` from `@ultimat3/core`,
882
- **never a `new AsyncLocalStorage` here, and that is a build error rather than a convention `As of
883
- 2026-08`** — `scripts/async-context-guard.ts` refuses the construction *and* the import that
884
- binds the class, anywhere but `packages/core/src/async-context.ts`, and
885
- `scripts/async-context-guard.test.ts` runs it over the tree in the gate's `unit` step. The
886
- module-scope `new` this replaced threw `TypeError: undefined is not a constructor` at module
887
- **evaluation** in a browser bundle, where the bundler stubs `node:async_hooks` to `{}`, taking
888
- every importer of `cross-tenant.ts` with it. Now the module evaluates and `crossTenantReason()`
889
- answers `undefined` there — in a browser nothing IS in flight, so that is the true answer. A
890
- write is the case that names itself: `storage.run` throws `X_ASYNC_CONTEXT_UNAVAILABLE` instead
891
- of a bare `TypeError`, though `crossTenant()` reaches it only past `assertCrossTenant`, which
892
- wants a request context a browser does not have. A server saves no allocation — the store is
893
- built on the first `get()` **or** `run()`, so a read constructs it too; what the laziness costs
894
- is nothing observable, since `getStore()` outside a scope answers `undefined` whether the storage
895
- existed or not.
896
- **The capability is proven twice**: `CROSS_TENANT_SCOPE` (`tenancy:cross`) on the actor, at the
897
- call and again at every plan built inside it, because `withChildContext({ actor })` swaps the
898
- actor without closing the scope and an impersonated caller must not inherit it —
899
- `X_TENANCY_CROSS_DENIED`. **Outside a request context it is refused too**: a sweep with nobody to
900
- attribute it to is ambient authority, so a script mints its own `serviceActor` and says who it
901
- is. A blank reason is `X_INVARIANT` through core's `assert`, exactly as `expectedQueryLoop`'s is.
902
- - **Every framework member on an entity is `$`-prefixed** — the columns are `Object.assign`ed onto
903
- the core, so an unprefixed member would make `view`, `name` or `tenant` an illegal column name.
904
- `$view`, never `view`; no free `view(entity, keys)` either — one way to write a projection.
905
- - **Invariants run twice, and only ONE side of the pair is rendered here.** In the app on write
906
- (`assertInvariants`), and as a Postgres CHECK/UNIQUE emitted by `@ultimat3/db` —
907
- `constraintNameFor`, `declaredChecks`, `declaredIndexes` (`invariant-ddl.ts`), reading
908
- `$describe()`. An untranslatable JS predicate reports `kind: 'assert'`, `sql: null` — never a
909
- pretend CHECK. **This package rendered a second copy of that DDL until 2026-08-25**
910
- (`toSql`/`invariantsToSql`/`constraintName`, reachable through `entity.$migration()`), and the
911
- copy is the argument: nothing but its own tests ever called it, so nobody noticed it passed the
912
- entity NAME where the table belongs — `entity('account', { table: 'legacy_accounts' })` rendered
913
- `ALTER TABLE "account" ADD CONSTRAINT "account_…_check"`, a relation Postgres answers `42P01` for
914
- and a constraint name no migration has ever written. All four are deleted; `$migration()` was on
915
- `EntityCore`, so this is a breaking change to a documented member. Never render constraint DDL
916
- here again — the entity's job is to DESCRIBE the rule, and `<table>_<name>_<check|key>` now has
917
- exactly one source.
918
- - **`InvariantDescription.columns` is projected, `As of 2026-08-25`** — the physical names the rule
919
- reads, for every kind, straight off `Invariant.columns`. Same argument as `onDelete`, `generated`
920
- and `default` on `ColumnDescription`: `@ultimat3/db` is tier 1 and cannot import this package, so
921
- a fact this projection drops is a fact the generator must recover from a rendering. It was
922
- recovering it — `uniqueColumns()` split a `unique` rule's `sql` on commas and re-validated each
923
- part — which is the shape that made `posts_org_id_created_at_idx` read back as the single column
924
- `"org_id_created_at"`. `snapshotOf` derives from `declaredChecks`/`declaredIndexes` and not from
925
- this record, so the field changes no snapshot and nothing regenerates
926
- (`describe-invariant.test.ts` pins both halves).
927
- - **And the two halves must AGREE, term by term** (`expr.ts`). A rule the app accepts and the CHECK
928
- refuses is not a stricter database: the write comes back as a raw constraint error instead of
929
- `X_INVARIANT_VIOLATED`, which is the framework's own invariant bypassed on the way out. Two
930
- divergences closed 2026-08, both proven. **`matches(/…/i)` compiles to `~*`** — `toSql` emitted
931
- `~ <pattern.source>` and nothing else, so `c.slug.matches(/^[A-Z]+$/i)` approved `'abc'` in the
932
- app while the CHECK refused it; every other flag is REFUSED at declaration (`matchOperator`),
933
- never dropped, because `m` and `s` change what the pattern matches and `g` makes `pattern.test`
934
- stateful so even `holds` stops being a function of the row. **`minLength` counts code points** —
935
- `[...value].length`, because `char_length('👍')` is 1 and `'👍'.length` is 2. Code points, not
936
- graphemes: agreeing with Postgres is the point, not agreeing with a human's idea of a letter.
937
- - **`$parse` tells absence from `null`** (`entity.ts`). `input[property] ?? defaultValue(...)` read
938
- an explicit `null` as absence and wrote the column's declared default straight back, so a
939
- nullable-and-defaulted column could not be cleared at all — `{ status: null }` reported success
940
- and stored `'draft'`. It is `raw === undefined ? defaultValue(...) : raw`: a present `undefined`
941
- is still absence, which is what a spread of an omitted optional key produces.
942
- - **`invariants` is ONE callback, and `InvariantColumns<C>` is a mapped type.** `invariants: (c) =>
943
- [invariant(name, expr)]`, never an array of `(c) => …` builders: a per-element builder is a call
944
- TypeScript checks before `entity()`'s `C` is fixed, so `C` fell back to its constraint and `c`
945
- stayed open-keyed. Open-keyed means an index signature, and under `noUncheckedIndexedAccess` that
946
- made every `c.title` a `ColumnExpr | undefined` — every generated entity red until the author
947
- added `!`. The Proxy in `invariantColumns()` stays regardless: a JS caller and a dynamically
948
- built rule never see the compile error, and its message names the columns that do exist.
949
- - **`Invariant<T>.holds` is a method, never `readonly holds: (row: T) => boolean`.** A
950
- function-typed property is contravariant, so `Invariant<Post>` stopped being assignable to
951
- `Invariant<unknown>`, `Entity<Post, C>` stopped satisfying `EntityCore`, and every
952
- `database({ … })` degraded to `Table<unknown>` — one position, 36 cascading errors downstream.
953
- - **A branded id survives to the signature, or it does not exist.** `uuid<PostId>()` declares the
954
- brand once; the derivation (`TypeOf`/`RowOf`/`Insertable`) always carried it, but the BUILDER
955
- hard-coded `Column<string>` so there was nothing to carry, and `Repo`/`Table` then took
956
- `id: string`, which erased the rest — two entities' ids were mutually assignable and
957
- `posts.update(someUserId, …)` compiled into a query that matched nothing. Both halves are
958
- pinned: fixing either alone leaves that call legal. Id parameters are `IdOf<Row>`, which is
959
- `string` for every unbranded row and every composite key, so this is additive.
960
- - **`type-pins.ts` is where all of those are enforced.** Source, not a test: `tsconfig.json`
961
- excludes `src/**/*.test.ts`, so `tsc` never reads a test file and a type-level assertion written
962
- in one can never fail. It emits nothing and exports nothing anybody imports.
963
- - **Row types are derived, never re-declared.** No `as unknown as` to fake the derivation.
964
- - **`src/index.ts` re-exports `t` from `@ultimat3/schema` verbatim**, so an entity file that also
965
- hand-writes a view schema imports one package. Never wrap, spread or re-declare it: `t` delegates
966
- to `schemaProvider()` on every access, and a copy would freeze the provider at import time.
967
- `index.test.ts` asserts identity.
968
- - **A rejected column value is rendered as its SHAPE, never its content** — `got(value)` in
969
- `columns.ts`, one line over `@ultimat3/schema`'s `describeValue`, `As of 2026-08`. Every builder
970
- used to say `got ${String(value)}`, and a column rejection is not a private diagnostic: it
971
- becomes `X_INVARIANT_VIOLATED`'s `cause` and a `$view` issue, which `@ultimat3/http` folds into
972
- `X_BODY_INVALID` — returned to the caller AND written into the log line, where core's logger
973
- redacts by KEY and a value already baked into a message has no key left to redact. `text()` on a
974
- password field wrote the mistyped password to the log index in cleartext and into the user's own
975
- network tab. **A column is the worse half of that pair**: the value can arrive from the DATABASE,
976
- so the leak is not bounded by what somebody just typed. The renderer is schema's rather than a
977
- local copy, so a column and a schema describe one bad value the same way; `columns.test.ts` pins
978
- it with a secret-looking value and checks that not even its four-character prefix survives — a
979
- truncating "helpful" renderer would still name the vendor. **Two echoes are deliberate and both
980
- are provably numeric by the branch that reaches them**: `parseMinor`'s float message and its
981
- ±2^53 message, where the value is a `number`, a `bigint` or a digits-only string, the amount is
982
- the only fact that repairs the row, and `@ultimat3/realtime` renders the same value the same way
983
- for the same reason. Changing either means changing both.
984
- - **A seed is replayable by construction, and `insert` is the verb that makes it so** — decided
985
- 2026-08. `defineSeed`'s context offered `insert` and nothing else, and a plain insert meant two
986
- different things to the two drivers: the memory repository overwrites by primary key, Postgres
987
- raises `23505`. So a seed replayed twice passed every test in this repo and killed the SECOND
988
- boot of any container on a durable store, which is why both tracked apps had (or needed) a
989
- hand-written `Driver` decorator over `insert`. `SeedContext.insert` now writes one
990
- `upsertAll(rows, { onConflict: entity.$primaryKey, onMatch: 'nothing' })` per call. Four
991
- properties, none optional. **`'nothing'`, never `'update'`**: `upsertPlan` refuses an updating
992
- upsert whose target omits the tenant column (`X_TENANCY_UNSCOPED`), and a tenant-scoped entity
993
- whose only unique keys are global — `posts` in the reference app — has no legal updating target
994
- at all; `'nothing'` also skips the uneven-batch and nothing-to-set refusals, which a fixture
995
- graph would otherwise have to satisfy. **One statement per call**, so the per-row `insert` loop
996
- this replaced is no longer the N+1 of its own bulk form. **The metrics are the driver's answer**,
997
- not a count of the input: `upsertAll` under `'nothing'` resolves with the rows it actually wrote,
998
- so `skipped` is the replay, observed. **A generated primary key the row does not name is
999
- refused** — `uuid().primaryKey()` carries `GENERATED_UUID`, so `$parse` fills it with a fresh
1000
- uuid, the conflict target matches nothing and run five leaves five copies; it is the one
1001
- duplication no other rule in this package can see.
1002
- - **`upsert(entity, { by }, values)` is the second verb, and it exists because only the SEED AUTHOR
1003
- knows the natural key.** A seed writing into a table whose ids already exist — `banks` keyed by
1004
- `value`, `users` by `email`, `exchange_rates` by `(base, target, effective_on)` — cannot choose a
1005
- primary key, so keying replay on `$primaryKey` would be keying it on something Postgres does not
1006
- enforce. It reads first so an unchanged row can answer `'skipped'` with no statement, then writes
1007
- through ONE `on conflict … do update`: the read is for the report, never for the decision, or two
1008
- containers booting at once would race between the two. **`createdAt` is preserved on a match** —
1009
- the row handed to the update omits it, so `namedProperties` leaves it out of the `set` — because
1010
- a replay must not move when a row first arrived.
1011
- - **The environment guard is the CLI's, not `run()`'s.** `seedTiersFor(environment, requested)` is
1012
- the one table (`reference` everywhere, `dev` everywhere but production) and `x db seed` is what
1013
- refuses. `run()` stays permissive on purpose: `dummy/social-media-clone/apps/web/api/index.ts`
1014
- seeds its own production demo database from its boot code and says out loud that this is an app
1015
- decision (axiom 8) — a library refusal would break it. A seed declares its tier as DATA, the way
1016
- a `backfill()` declares its environments.
1017
- - **One resolver decides a physical name, and it is `columnName(property, meta)`** — decided
1018
- 2026-08 with `entity(name, { table })` and `.column(name)`. Before them, `snake(property)` was
1019
- called in nine places and `$table` was the entity name, so a schema this framework did not
1020
- generate could not be declared at all: adoption meant a rewrite. Every projection now reads the
1021
- resolver — the DDL (`describe.ts`), the binding and the decoder (`pg-row.ts`), the predicate and
1022
- sort resolver, the index names, the invariant SQL, the soft-delete clause in `pg-sql.ts` and
1023
- `pg-driver.ts`. **A second `snake(property)` anywhere is a statement naming a column the table
1024
- does not have**, and the first table that proves it is somebody's production database. It is
1025
- additive by construction: with no override the resolver IS `snake(property)`.
1026
- - **The entity NAME and the TABLE are different things.** The name stays the framework's key — the
1027
- registry, the cache tag (`entity:account`), `$tagFor`, every relation and every policy — and the
1028
- table is physical. Renaming a table must never move a cache tag. Index names are the TABLE's,
1029
- because an index is a physical object.
1030
- - **Money's three columns are per-part and `scale: null` is a real answer.** `money({ columns })`
1031
- merges over `<base>_minor`/`<base>_currency`/`<base>_scale` one part at a time, so a table that
1032
- renamed one does not restate the other two. `scale: null` says the table has no scale column at
1033
- all — the ordinary shape of an amount written before scale existed — and then `columnsOf`
1034
- projects TWO names, `bindValues` writes two, and `decodeRow` folds two. That last one is why
1035
- `decodeRow` branches on `$meta.kind === 'money'` and not on how many names came back: reading a
1036
- two-column amount as a non-money column handed the caller a raw minor unit where a `Money` goes.
1037
- - **A `jsonb` value is bound as TEXT and cast back, `::text::jsonb`** — and the double cast is
1038
- load-bearing, not defensive. The driver seam refuses a plain object as a parameter
1039
- (`X_SQL_UNSAFE`; `isBoundValue` takes scalars, `Date`, `Uint8Array` and arrays of those). With
1040
- `$1::jsonb` the server describes the parameter as `jsonb`, Bun's `sql` JSON-ENCODES the string it
1041
- was handed, and `{"a":1}` is stored as the JSON *string* — `jsonb_typeof` says `string`
1042
- (measured, Postgres 17.10). Pinning the parameter to `text` first makes the client send the
1043
- characters and the server parse them. An ARRAY is the other value that cannot cross as itself:
1044
- Bun serialises a JS array to `x,y`, which Postgres answers with `malformed array literal`, so
1045
- `bindValues` writes the `{…}` literal with every element quoted.
1046
- - **The wide column types were chosen from what a driver actually returns, not from what reads
1047
- well.** `int8` is a string from Bun's `sql` and a `bigint` from PGlite; `numeric` is a string
1048
- from both; `date` is a `Date` at midnight UTC from both; `bytea` is a `Buffer` from one and a
1049
- `Uint8Array` from the other. Every one of those is normalised in `$parse` to a single row type,
1050
- because a row that means two things by driver is the drift this package's two-driver split exists
1051
- to refuse. `bigint()` and `decimal()` are STRINGS for the same reason `money.minor` is a
1052
- `number`: `JSON.stringify` throws on a bigint, and a `number` loses digits exactly where a legacy
1053
- `int8` key lives.
1054
- - **`setRowObserver` reports committed row changes, above the driver, so memory and Postgres report
1055
- the same thing.** It exists because a change feed needs a SOURCE and only production has one:
1056
- `@ultimat3/realtime` decodes the write-ahead log, PGlite has no walsender and the memory driver has
1057
- no log at all — so `InMemoryChangeFeed`, which that package calls "the blessed development and
1058
- test feed", had nothing upstream of it. That is what left `@ultimat3/testing`'s `subscribe` fixture
1059
- with no driver. Rules, none optional. **One observer per process**, exactly like `@ultimat3/db`'s
1060
- `setStatementObserver`, and it hands back what it replaced so a nested harness restores rather than
1061
- clears. **Applied by `database()`**, not by a driver, so an app opts in by installing an observer
1062
- and never by choosing a different repository — the rows under test are the rows the app reads.
1063
- **With none installed it is one comparison per write**, which is why the guard is the first line of
1064
- every method rather than a flag read at wrap time. **`before` is read only when the primary key IS
1065
- `id`** — on a composite key `findById` cannot name a row, and an `id` column that is not the key
1066
- would read a DIFFERENT row than the write touched; `null` there is what logical replication reports
1067
- without `REPLICA IDENTITY FULL`, and a consumer already handles it. **A filtered write is `onBulk`,
1068
- never silence**: `deleteWhere`/`updateWhere` name a filter and not rows, and reading the matches
1069
- first would turn one statement into two and change what the code under test issues — so a count is
1070
- reported and a consumer re-reads. It is NOT a second change-feed path: `selectChangeFeed` still
1071
- decides what a real node reads, and this is never in that decision.
1072
- - **A refusal raised before any entity exists carries an EDIT, never a lookup** — `refuse.ts`,
1073
- `As of 2026-08-22`. Both `reject()` helpers called `invariantViolated('column', rule, detail)`,
1074
- whose fix is `x entities describe <entityName> --json`, so 34 column and invariant refusals
1075
- emitted `x entities describe column --json` — which answers `X_DECLARATION_UNKNOWN`, because no
1076
- entity is named `column` and at declaration time there is no entity at all. A fix line that
1077
- raises a second, unrelated error is worse than none: the reader debugs the wrong subsystem, and
1078
- an agent follows it literally. So the fix is a PARAMETER — `refuseColumn(rule, detail, fix)` —
1079
- and every site names the column form the author should have written, the shape
1080
- `arrayElementRefused` already had. **`invariantViolated`'s entity name is a value, never a
1081
- literal**, and `refuse.test.ts` scans this package's source for one; it also holds every refusal
1082
- to naming a call or a command, carrying no `<placeholder>`, and having a case in its own table,
1083
- so a refusal added without a repair is a failing test. **The two builders construct their
1084
- `EntityError` inline** rather than delegating to a shared one, because `fix-scan.ts` reads a fix
1085
- literal only at a call site whose callee builds the error itself — a wrapper would take all 34
1086
- fix lines back out of `x verify`'s `errors` step (measured: `checked` 1040 -> 1071).
1087
- - **Full-text search is one generated `tsvector` per entity, and the TERM is never syntax.**
1088
- `.searchable()` on a `text()` column puts it in the vector (`search.ts`); `entity()` derives the
1089
- column, the `generated always as (…) stored` expression and the GIN index through the existing
1090
- `IndexInit` path. Rules, none optional. **`websearch_to_tsquery`, never `to_tsquery`**: the term
1091
- crosses as a bound parameter either way — that is what stops an injection — but bare `to_tsquery`
1092
- reads `&`, `|`, `!`, `<->`, `:*` and parentheses as OPERATORS, so a search box sends either a
1093
- `42601` or a query the caller did not write; `plainto_tsquery` is safe and throws the user's own
1094
- quotes and `-negation` away in silence. **The configuration is spliced, from a CLOSED set**
1095
- (`SEARCH_LANGUAGES`), because `regconfig` cannot be a bound parameter inside a generated column at
1096
- all — and `to_tsvector(text)` with no configuration is not immutable, so Postgres refuses it there.
1097
- **`coalesce(col, '')` on every source**: `to_tsvector(NULL)` is NULL and `NULL || tsvector` is
1098
- NULL, so one nullable column would erase the whole row's vector. **The vector column is NOT NULL**,
1099
- which is what makes a generator that does not render the `generated` clause fail on the first
1100
- insert (`23502`) instead of leaving a table of NULL vectors under a search that quietly answers
1101
- nothing. **The memory driver REFUSES** (`X_SEARCH_IN_MEMORY`) rather than emulating: stemming, stop
1102
- words and a phrase parser are not a JS token comparison, and a green unit test over a different
1103
- question is the one outcome the two-driver split exists to prevent — the parity rule inverted, and
1104
- `predicateSql`/`matchesPredicate` are exhaustive switches over `Operator`, so neither can be given
1105
- a case the other lacks. **RELEVANCE is not an order this chain serves**: `ts_rank` is a computed
1106
- value and the cursor carries columns, so `.search()` filters and the declared `orderBy` pages —
1107
- proven over 30 tied rows in `pg-search.live.test.ts`, which also explains the GIN index and pins
1108
- the plan the tenant predicate produces.
1109
- - **A state machine on a column is the MECHANISM only, and the line is `19-mechanism-not-convention.md`'s.**
1110
- What ships: the transition table, the refusal of a move not in it, the ATOMICITY of check-and-move,
1111
- the terminal-state concept, and the stamp saying when the row moved. What never ships: the states,
1112
- an approval chain, a role that may perform a move, a side effect on arrival. **There is no enum of
1113
- state names anywhere in this package** — `.transitions()` hangs off `enumerated()`, so the states
1114
- are the app's own set and `TransitionTable<S>` is a MAPPED type over it: a missing state, an
1115
- unknown key and an unknown target are compile errors against a list the framework never saw.
1116
- **A terminal state is one whose outgoing list is empty** — derived, never declared, so "nothing
1117
- leaves cancelled" is structural and *which* state is terminal is not the framework's business.
1118
- **The move is ONE statement.** `from` rides in the predicate (`where id = $1 and status = $2`), so
1119
- the state that was OBSERVED and the state that was WRITTEN are one decision made under the row's
1120
- lock, and no rows is the refusal. A read-then-check-then-write is the same code with a window in
1121
- it: measured against a real server, twenty concurrent callers naming `pending` produced **14
1122
- winners** that way and **exactly 1** this way (`pg-transition.live.test.ts`). Legality is asked
1123
- BEFORE the statement, because the table is a property of the declaration and not of the database.
1124
- **The refusal is a read, and only ever after the decision** — `X_STATE_CONFLICT` names the state
1125
- the row is really in, from a tenant-scoped `findById` that runs once the statement has already
1126
- refused. Another org's row reads as absent, so the answer is `X_NOT_FOUND` and never a conflict
1127
- that would confirm it exists. **The machine adds no DDL**: `enumerated()` already emits the CHECK,
1128
- so there is one declaration of what a legal value is. **A machine column may not be nullable** —
1129
- NULL is not a state, and `= NULL` matches no row, so every move out of it would read as a
1130
- conflict. **`whyNot` asks three questions in one order** — unknown state, then terminal, then the
1131
- legal list — because an unknown state has no outgoing moves either, and a check that skipped it
1132
- reported a typo as "the row is terminal in `pendign`".
1133
- - **An entity row on the client is a RECORD, and the record is derived — plan 101, `As of
1134
- 2026-09-22`.** `$schema` is a full `t` schema (`row-schema.ts`) whose node carries the entity's
1135
- `RecordProjection` under the non-enumerable `ENTITY_BRAND` (`Symbol.for('ultimate.entity')`, so a
1136
- brand minted in one island bundle is read in another). Rules, none optional. **The brand lives on
1137
- the NODE**, because `t.array`/`t.object`/`t.record`/`t.union` keep only the child's node, by
1138
- reference; the five methods that COPY a node (`nullable`, `optional`, `default`, `describe`,
1139
- `refine`) are re-branded in `row-schema.ts`, since a spread drops a non-enumerable symbol — do not
1140
- "fix" that in `@ultimat3/schema` by making the brand enumerable, which would put it in every
1141
- spread and `toEqual` of the IR. **A partial row is never a record**: `$view` and any
1142
- `.pick/.omit/.extend` build unbranded nodes, and `rows-of.test.ts` pins it. **`rowsOf` answers type → record key → row**, both levels null-prototype: the key is what
1143
- travels, since a browser cannot compute one without importing the app's `entity()` declarations. **`persist` is an `EntityInit` key, default `false`**, read ONLY off the projection
1144
- (`recordProjection(e).persist`) — realtime's persister is its reader, so disk is a per-entity
1145
- declaration and never a store-wide switch. **The browser path is `@ultimat3/entity/record`** (`record.ts`), and the modules behind
1146
- it import `entity-error.ts`, never `errors.ts`: `errors.ts` imports `@ultimat3/db` for
1147
- `dbDrift`, and one such import put 16 `db` modules (pglite included) in the chunk.
1148
- `record-bundle.test.ts` lists the retained entity modules by name, so a new import there fails.
121
+ - **The codec is `@ultimat3/core`'s**, reached through `cursorFor(entity, plan, row, id)` and
122
+ `seekFrom(entity, plan)`; **`assertSeekable` runs in `planFor`**, before a statement exists. A
123
+ `timestamptz` sort key is refused when its `<column>$US` alias would pass 63 bytes. A cursor is bound
124
+ to `planScope(plan)` (entity, filters, sort); a bad one is `X_CURSOR_INVALID`, never "from the top".
125
+ - **A NULLABLE sort key orders** `asc nulls last` / `desc nulls first` (written down); the cursor tags
126
+ `~` for NULL and `!` for present; the seek reaches NULLs; a nullable key has no row comparison. Only a
127
+ nullable PRIMARY-KEY column is refused. `pg-null-order.live.test.ts`.
128
+ - **A `timestamp` cursor carries MICROSECONDS**: `seekPrecision` projects `(col at time zone
129
+ 'UTC')::text as "<col>$US"`; `sortPrecision` reads it; the seek binds a six-digit ISO instant
130
+ (`col < $1::timestamptz`). `instant.ts` is where the two representations meet.
131
+ `pg-cursor-precision.live.test.ts`.
132
+ - **The four aggregates** (`sum`, `avg`, `min`, `max`) share `aggregate.ts`
133
+ (`aggregate-fold.ts` memory, `aggregate-decode.ts` Postgres): never a float; `avg` at `AVG_SCALE` (6),
134
+ half away from zero; `null` for an empty set. Refused: `min`/`max` on text, `avg` over money
135
+ (`X_AGGREGATE_UNSUPPORTED`), mixed currency or scale (`X_AGGREGATE_MIXED_CURRENCY`), a money total
136
+ past ±2^53. **`approximateCount()` is `reltuples`**; a filtered chain or tenant-scoped entity is
137
+ `X_APPROXIMATE_COUNT_FILTERED`; `null` for an unanalysed table.
138
+ - **`json()` / `arrayOf()` are filterable** (`contains`, `contained-by`, `overlaps`, `has-key`) with
139
+ Postgres' measured meaning in `containment.ts`; `has-key` emits `operator(pg_catalog.?)` (indexable),
140
+ pinned by `pg-sql.test.ts`. No jsonpath operator.
141
+ - **A GIN index is declarable** (`using: 'gin'`; the set is `@ultimat3/db`'s `INDEX_METHODS`); absent
142
+ is `btree` byte for byte; the method joins the name discriminator only when declared; GIN cannot be
143
+ unique or ordered (refused here). `pg-containment.live.test.ts`.
144
+ - **Full-text search** (`search.ts`): one generated `tsvector` per entity, `websearch_to_tsquery`, a
145
+ closed `SEARCH_LANGUAGES` set spliced, `coalesce(col, '')` per source, NOT NULL vector column; the
146
+ memory driver refuses (`X_SEARCH_IN_MEMORY`); relevance is not a served order.
147
+ `pg-search.live.test.ts`.
148
+
149
+ ## Do not regress — schema and DDL
150
+
151
+ - **A relation is a foreign key read a second way** (`relations.ts`): `belongsTo` from own
152
+ `references()`, `hasMany` from inbound; `referenceBinding()` resolves a thunk in one place; colliding
153
+ names all take their long form; ambiguity is `X_INVARIANT_VIOLATED`. `relationMap()` memoises on
154
+ `registryGeneration()`, in one pass. `relationNamed()` refuses with `X_PRELOAD_UNKNOWN_RELATION`
155
+ (fix: a real `relationNamed()` call, or `x entities list --json`). `onDelete` rides on both
156
+ descriptions; never parse the `"<table>.<column>"` string back.
157
+ - **An index is described whole** (`IndexDescription`: columns, uniqueness, predicate, direction,
158
+ method); `on: []` refused. **The NAME carries predicate and direction** (8 hex of sha256) only when
159
+ one is present; the dedup is on the whole `IndexDef`; names are bounded at 63 BYTES
160
+ (`MAX_IDENTIFIER_BYTES`, `index-name.ts`).
161
+ - **Every physical name is checked, including the DERIVED one** (`assertColumnName` at `bindColumn`,
162
+ and on the entity-name table fallback).
163
+ - **One resolver decides a physical name: `columnName(property, meta)`** (with
164
+ `entity(name, { table })` and `.column(name)`); a second `snake(property)` is a bug. **The entity
165
+ NAME and the TABLE are different things** — cache tags and policies key on the name; index names are
166
+ the table's.
167
+ - **Invariants run twice and only ONE side is rendered here**: app-side `assertInvariants`, and the
168
+ CHECK/UNIQUE DDL is `@ultimat3/db`'s `invariant-ddl.ts` reading `$describe()` (`$migration()` and the
169
+ local renderers are deleted). An untranslatable predicate is `kind: 'assert'`, `sql: null`.
170
+ **`InvariantDescription.columns` is projected** (`describe-invariant.test.ts`).
171
+ - **The two halves must AGREE, term by term** (`expr.ts`): `matches(/…/i)` is `~*`, other flags are
172
+ refused (`matchOperator`); `minLength` counts code points. **`isNull()`/`isNotNull()` are the only
173
+ total members**; `iff(a, b)` renders `(a) = (b)` — measured as the safe direction
174
+ (`pg-invariant-null.live.test.ts`); `iff` is a function, and a `unique` operand is refused with the
175
+ `c.unique([…])` fix built through `JSON.stringify`. One app-only operand makes the whole rule
176
+ app-only.
177
+ - **A `matches()` pattern reaches the CHECK as the same string or is refused**
178
+ (`pattern-portability.ts`, every entry measured; `pg-invariant-pattern.live.test.ts` asserts kept
179
+ constructs agree and refused ones disagree).
180
+ - **A declared string is spliced by `@ultimat3/db`'s `literal()` only** (`E'…'` when a backslash is
181
+ present); `bun run sql-literal-copies` enforces it; `expr.test.ts` pins all four splice sites against
182
+ quote- and backslash-bearing values.
183
+ - **`invariants` is ONE callback** and `InvariantColumns<C>` a mapped type; the Proxy in
184
+ `invariantColumns()` stays for JS callers. **`Invariant<T>.holds` is a method** (variance).
185
+ - **Nothing is interpolated into SQL** (`pg-sql.ts`): `raw()` only for `asc|desc` and the `default`
186
+ cell. The seek operator is chosen in TypeScript.
187
+ - **Money is three columns**: `<p>_minor bigint`, `<p>_currency char(3)`, `<p>_scale integer null`.
188
+ NULL scale decodes to an ABSENT key (never `0`); the scale bound is `@ultimat3/schema`'s
189
+ (`isMoneyScale`, `scaleCheck`); scale is not addressable (`MONEY_PARTS`). The currency bound is
190
+ schema's in both halves (`isCurrencyCode`, `CURRENCY_CODE_PATTERN`; `currency-check.live.test.ts`).
191
+ `MoneyValue` is re-exported from `@ultimat3/schema` — one declaration (`type-pins.ts`). `parseMinor`
192
+ refuses past ±2^53; `narrowMoney` runs in both drivers. `money({ columns })` merges per part and
193
+ `scale: null` means a two-column amount (`decodeRow` branches on `$meta.kind === 'money'`).
194
+ - **Timestamps are `timestamptz`.** A naive timestamp stays inexpressible.
195
+ - **A `jsonb` value is bound `::text::jsonb`** (load-bearing); an array is written as a quoted `{…}`
196
+ literal. **Wide column types are normalised in `$parse`** to one row type per driver (`bigint()` and
197
+ `decimal()` are strings).
198
+ - **Every framework member on an entity is `$`-prefixed.** Row types are derived, never re-declared.
199
+ **A branded id survives to the signature** (`IdOf<Row>`). `type-pins.ts` enforces all of it.
200
+ - **`$parse` tells absence from `null`** (`raw === undefined ? defaultValue(...) : raw`).
201
+ - `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim** (`index.test.ts`).
202
+ - **A rejected column value is rendered as its SHAPE** (`got(value)` over `describeValue`); only
203
+ `parseMinor`'s two messages echo a provably numeric value.
204
+ - **A refusal raised before any entity exists carries an EDIT** (`refuse.ts`,
205
+ `refuseColumn(rule, detail, fix)`); `refuse.test.ts` scans for a literal entity name and a
206
+ placeholder. The builders construct `EntityError` inline so `fix-scan.ts` can read the fix.
207
+
208
+ ## Do not regress — tenancy and writes
209
+
210
+ - **A tenant column means every query runs under the ACTING ACTOR's tenant** (`scopedPlan`,
211
+ `tenancy.ts`, from `tryUseContext()?.actor.orgId`); inference applies when undeclared; **the column
212
+ may not be nullable** (`resolveTenantColumn`). A caller-supplied `orgId` is an assertion
213
+ (`X_TENANCY_ACTOR_MISMATCH` when different, `eq` only, refused never overridden); an actor with no
214
+ org is `X_TENANCY_ACTOR_ORG_REQUIRED`; outside a request the caller names the tenant and
215
+ `X_TENANCY_UNSCOPED` still refuses an unscoped plan.
216
+ - **Tenancy applies to writes in two places**: the plan bounds WHICH rows; `assertRowTenant` judges the
217
+ VALUE at every write seam (both drivers), a filtered update judging the PATCH before any row; refuse,
218
+ never stamp; all or nothing; the incoming rows too; before the statement exists
219
+ (`write-tenancy-parity.test.ts`, `pg-driver-tenancy.live.test.ts`). With the conflict-target rule, a
220
+ cross-tenant upsert is unrepresentable.
221
+ - **`crossTenant(reason, fn)` is the ONE way to read across tenants** — an `asyncContext` scope from
222
+ core (`scripts/async-context-guard.ts`), `CROSS_TENANT_SCOPE` (`tenancy:cross`) proven at the call
223
+ and at every plan (`X_TENANCY_CROSS_DENIED`), refused outside a request, blank reason `X_INVARIANT`.
224
+ - **`deleteWhere` / `updateWhere` are the only filtered writes**: empty filter `X_WRITE_UNFILTERED`,
225
+ empty patch `X_PATCH_EMPTY` (undefined values dropped first); soft delete respected; both return a
226
+ count; rows come back only when a JS-only invariant needs them (`hasJsOnlyInvariant`), counted first
227
+ and refused past `MAX_ASSERTED_ROWS` (50,000) in BOTH drivers. `updateStatement`'s `returning` is
228
+ required.
229
+ - **Every instant the write path stamps comes from `entityNow()`** (`clock.ts`, `ctx.clock`).
230
+ **`touch()` in `query.ts` is the ONE place `onUpdateNow()` columns are stamped.**
231
+ - **A many-row write is one statement** (`insertStatement` builds every insert; `bulk-write.ts`
232
+ decides in property space): a collision overwrites everything but the conflict target, the PK and the
233
+ soft-delete stamp; the target must be a declared non-partial unique constraint (PK, `$indexes`, or a
234
+ `kind: 'unique'` invariant); under `'update'` the tenant column must be in the target
235
+ (`X_TENANCY_UNSCOPED`), a repeated target and an uneven batch are refused; NULL in the target
236
+ collides with nothing; memory judges the whole batch first; past `MAX_BIND_PARAMETERS` it is several
237
+ statements.
238
+ - **A seed is replayable**: `SeedContext.insert` is one `upsertAll(…, { onMatch: 'nothing' })` per
239
+ call; a generated PK the row does not name is refused. `upsert(entity, { by }, values)` keys on a
240
+ natural key, reads first for the report, writes one `on conflict … do update`, preserves `createdAt`.
241
+ **The environment guard is the CLI's** (`seedTiersFor`; `run()` stays permissive).
242
+ - **`setRowObserver` reports committed row changes above the driver** — one per process (returns the
243
+ replaced one), applied by `database()`, one comparison per write when unset, `before` only when the PK
244
+ is `id`, a filtered write is `onBulk`. Not a second change-feed path.
245
+ - **A state machine is the MECHANISM only** (`.transitions()` on `enumerated()`, a mapped
246
+ `TransitionTable<S>`): a terminal state is one with no outgoing moves; the move is ONE statement with
247
+ `from` in the predicate (`pg-transition.live.test.ts`: 1 winner of 20); `X_STATE_CONFLICT` is read
248
+ after the refusal from a tenant-scoped `findById`; no DDL beyond `enumerated()`'s CHECK; a machine
249
+ column may not be nullable; `whyNot` asks unknown → terminal → legal list.
250
+
251
+ ## Do not regress — records and tests
252
+
253
+ - **An entity row on the client is a RECORD, derived** (`row-schema.ts`, `ENTITY_BRAND` =
254
+ `Symbol.for('ultimate.entity')` on the NODE, re-branded by the five copying methods; a partial row is
255
+ never a record — `rows-of.test.ts`). `rowsOf` answers type → record key → row (null-prototype).
256
+ `persist` defaults `false`, read only off the projection. **The browser path is
257
+ `@ultimat3/entity/record`**, whose modules import `entity-error.ts`, never `errors.ts`
258
+ (`record-bundle.test.ts`).
1149
259
  - Never throw a bare `Error` — use `errors.ts`.
1150
- - **Tests restore the process-global registry in `afterAll` (`clearRegistry()`), and the hook is at
1151
- FILE scope — a build error since 2026-08-25, because the prose form was violated by 19 of the 19
1152
- live suites that had it.** A leaked registry breaks an unrelated package's tests, as it did in
1153
- `@ultimat3/policy`. Bun evaluates a skipped file's module body and then runs no hook inside
1154
- `describe.skipIf(true)` (measured in `live-registry-cleanup.test.ts`), so a `clearRegistry()`
1155
- parked in the suite's teardown — beside `drop table`, where it reads as belonging — never ran in
1156
- the ONE configuration a live suite is never deliberately run in: **36 entities stayed registered**
1157
- across `packages/entity/src/*.live.test.ts` with `TEST_DATABASE_URL` unset, which is every CI run
1158
- of the unit gate. An `if (!hasPostgres) return` above the call is the same hole by a second route.
1159
- So the cleanup is its own top-level `afterAll(() => { clearRegistry(); })` at the end of the file,
1160
- matched on exact text by `live-registry-cleanup.test.ts`, which also refuses a live suite that
1161
- registers on import and imports no seam. Never put it back in the teardown.
260
+ - **Tests restore the registry with a FILE-scope `afterAll(() => { clearRegistry(); })`** — matched on
261
+ exact text by `live-registry-cleanup.test.ts`; never inside a skippable suite's teardown.
1162
262
 
1163
263
  ## Files
1164
264
 
1165
- | File | Job |
1166
- |---|---|
1167
- | `types.ts` | `Column`, `RowOf`, `Insertable`, `IdOf` — the type derivation. `COLUMN_KINDS` is the runtime array `ColumnKind` DERIVES from (the shape core's `PRIMITIVE_KINDS` uses), so a package answering "one case per kind" reads a real list rather than spelling its own |
1168
- | `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 |
1169
- | `columns-data.ts` | the wide vocabulary an existing schema needs: `json`, `decimal`, `date`, `bigint`, `bytes`, `arrayOf` |
1170
- | `array-element.ts` | which element kinds `arrayOf()` refuses, and the one-line edit that repairs each |
1171
- | `refuse.ts` | `refuseColumn`/`refuseInvariant` — the refusals raised before any entity exists, each carrying the EDIT that repairs it |
1172
- | `expr.ts` / `invariants.ts` | the `invariants: (c) => …` rule language; `bindInvariant` resolves property paths to physical names. No DDL — that is `@ultimat3/db`'s `invariant-ddl.ts` |
1173
- | `entity.ts` / `describe.ts` | `entity()`, `$row`; the `EntityDescription` projection |
1174
- | `index-name.ts` | what an index is CALLED — the predicate/direction/method discriminator and the 63-byte bound |
1175
- | `search.ts` | the generated `tsvector` a `.searchable()` column set derives: the closed language list, the weights, the expression |
1176
- | `state-machine.ts` | the transition table, its five declaration rules, and what a terminal state IS |
1177
- | `transition.ts` | one atomic move: the legality question, the conditional statement, the diagnosis of a statement that matched nothing |
1178
- | `enum-column.ts` | `enumerated()` and its own chain — the one builder that may declare a machine |
1179
- | `column-values.ts` | `got()` and `oneOf()`, so `enum-column.ts` needs no import of the file that imports it |
1180
- | `feature-errors.ts` | the refusals search and the state machine raise at call time; the codes and titles stay in `errors.ts` |
1181
- | `view.ts` | `$view(keys)` — the row projection an action names as its `output` |
1182
- | `row-schema.ts` | `$schema` — the whole row as a `t` schema, branded, with the copying wrappers re-branded |
1183
- | `record-projection.ts` / `record-key.ts` | `ENTITY_BRAND`, `RecordProjection`, `recordProjection()`; the record key and `X_RECORD_KEY_MISSING` |
1184
- | `rows-of.ts` | `rowsOf`/`hasEntityRows` — the entity rows an output schema declares, read beside its value |
1185
- | `record-table.ts` | `recordTypeForTable` — a changefeed's table back to the record type, memoised on the registry generation |
1186
- | `record.ts` | the `@ultimat3/entity/record` subpath — the browser-safe entry for the four above |
1187
- | `entity-error.ts` | the code registry, `EntityError`, `invariantViolated`, `entityDuplicate` — no `@ultimat3/db`, so the browser path can raise them; `errors.ts` re-exports all of it |
1188
- | `query.ts` / `database.ts` | chainable read to a cursor page; `database()` + `Driver` |
1189
- | `clock.ts` | `entityNow()` — the ONE clock read on the write path, `ctx.clock` else the system's |
1190
- | `memory-match.ts` | what a `Predicate` means in the memory driver: compare/equal/LIKE, by the column's kind. The decimal comparison itself is `@ultimat3/core`'s `compareDecimalText` |
1191
- | `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 |
1192
- | `cross-tenant.ts` | `crossTenant(reason, fn)` — the capability-gated scope that lifts it |
1193
- | `plan.ts` / `cursor.ts` | the plan both drivers execute; the one keyset cursor codec |
1194
- | `batch.ts` | `inBatches(size)` — the chain's page in a loop, closed by the loop that reads it |
1195
- | `pg-driver.ts` | `postgresDriver()`, `postgresRepo()`, `postgresTransactor()` — attributes every statement it sends |
1196
- | `coalesce.ts` | one microtask of `findById` calls → one `where id in (…)`, per request |
1197
- | `batch-read.ts` | what a shared point read is made of — the scope key, `keyOf`, the one `in` statement |
1198
- | `bulk-write.ts` | what a many-row write is made of — the column list, the conflict plan, the bind-count chunking |
1199
- | `count-by.ts` | what a grouped count is made of — the groupable kinds, the group bound, the key's decoding, the order |
1200
- | `jit-preload.ts` | a page's foreign key values → one `in` statement for the whole `for … of` loop |
1201
- | `preload.ts` | the relation `preload()` names → one related-rows statement → attached to the page |
1202
- | `pg-sql.ts` / `pg-row.ts` | plan → parameterised SQL; physical row ⇄ entity row (money is three columns) |
1203
- | `row-observer.ts` | `setRowObserver` — committed row changes, above the driver, for a change feed that has no log to read. A change made inside a keyed request carries `write` (`currentWriteOrigin()`) |
1204
- | `write-tag.ts` | a keyed request's write names itself in the WAL: `pg_logical_emit_message(true, WRITE_ORIGIN_WAL_PREFIX, digest)` opens its transaction, once per transaction; a write outside one gets a transaction of its own; a role that may not execute it is probed once and its writes go out untagged; a pinned repository is never wrapped |
1205
- | `registry.ts` | duplicate detection, `describeEntities()` for the manifest, `references()` per entry |
1206
- | `relations.ts` | `relationMap()`/`relationsFor()`/`relationNamed()` — the FKs as a named `belongsTo`/`hasMany` map |
1207
- | `n-plus-one.ts` | a repeated statement → the error whose `fix` is the preload or bulk call that ends it |
1208
- | `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 |
1209
- | `type-pins.ts` | compile-time assertions `tsc` checks — the column proxy, `Invariant` variance, the branded id |
1210
- | `live-registry-cleanup.test.ts` | the build error behind the registry rule above: a live suite that registers on import clears unconditionally, in a top-level hook a skip cannot swallow |
265
+ Each file's header states its one job. The map, by area: `types.ts` (derivation; `COLUMN_KINDS`),
266
+ `column.ts` / `columns.ts` / `columns-data.ts` / `array-element.ts` / `enum-column.ts` /
267
+ `column-values.ts` (builders, `columnName`, `narrowMoney`), `refuse.ts`, `expr.ts` / `invariants.ts`,
268
+ `entity.ts` / `describe.ts` / `index-name.ts` / `search.ts`, `state-machine.ts` / `transition.ts`,
269
+ `feature-errors.ts`, `view.ts` / `row-schema.ts` / `record-projection.ts` / `record-key.ts` /
270
+ `rows-of.ts` / `record-table.ts` / `record.ts`, `entity-error.ts` / `errors.ts`, `query.ts` /
271
+ `database.ts` / `clock.ts`, `memory-match.ts` / `repo.ts` / `memory-repo.ts` / `tenancy.ts` /
272
+ `cross-tenant.ts`, `plan.ts` / `cursor.ts` / `batch.ts`, `pg-driver.ts` / `coalesce.ts` /
273
+ `batch-read.ts` / `bulk-write.ts` / `count-by.ts` / `jit-preload.ts` / `preload.ts` / `pg-sql.ts` /
274
+ `pg-row.ts`, `row-observer.ts` / `write-tag.ts` (a keyed request's write names itself in the WAL via
275
+ `pg_logical_emit_message`), `registry.ts` / `relations.ts` / `n-plus-one.ts` / `seed.ts`,
276
+ `type-pins.ts`, `live-registry-cleanup.test.ts`.
1211
277
 
1212
278
  ## Commands
1213
279
 
1214
- `bun test packages/entity` · `bun run --filter @ultimat3/entity typecheck`
280
+ `bun test packages/entity` · `bun run --filter @ultimat3/entity typecheck`
281
+
282
+ Why each rule above is shaped the way it is: [`docs/history/entity.md`](../../docs/history/entity.md).