@ultimat3/entity 20.2.1 → 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 +226 -1135
- package/README.md +56 -1
- package/package.json +7 -6
- package/src/aggregate-decode.ts +2 -1
- package/src/columns-data.ts +28 -2
- package/src/columns-scalar.ts +79 -0
- package/src/columns.ts +15 -46
- package/src/entity-error.ts +126 -0
- package/src/entity.ts +30 -24
- package/src/errors.ts +13 -120
- package/src/index.ts +15 -9
- package/src/jit-preload.ts +19 -1
- package/src/memory-repo.ts +74 -7
- package/src/memory-unique.ts +51 -0
- package/src/persisted-types.ts +21 -0
- package/src/pg-driver.ts +9 -25
- package/src/pg-row.ts +5 -1
- package/src/pg-sql-aggregate.ts +132 -0
- package/src/pg-sql.ts +1 -118
- package/src/pg-transactor.ts +23 -0
- package/src/record-key.ts +59 -0
- package/src/record-projection.ts +88 -0
- package/src/record-table.ts +46 -0
- package/src/record.ts +10 -0
- package/src/registry.ts +19 -1
- package/src/repo.ts +5 -0
- package/src/row-observer.ts +51 -12
- package/src/row-schema.ts +63 -0
- package/src/rows-of.ts +132 -0
- package/src/seed.ts +7 -1
- package/src/transition.ts +6 -1
- package/src/write-tag.ts +75 -0
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
|
-
-
|
|
20
|
-
`
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
(`
|
|
30
|
-
`
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
- **Money's write shape is wider than its row shape
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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,850 +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
|
|
346
|
-
`
|
|
347
|
-
`
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
`
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
`
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
`
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
`
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
`
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
`
|
|
414
|
-
|
|
415
|
-
`
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
`
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
`
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
`
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
`
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
is
|
|
469
|
-
- **A
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
`
|
|
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`".
|
|
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`).
|
|
1133
259
|
- Never throw a bare `Error` — use `errors.ts`.
|
|
1134
|
-
- **Tests restore the
|
|
1135
|
-
|
|
1136
|
-
live suites that had it.** A leaked registry breaks an unrelated package's tests, as it did in
|
|
1137
|
-
`@ultimat3/policy`. Bun evaluates a skipped file's module body and then runs no hook inside
|
|
1138
|
-
`describe.skipIf(true)` (measured in `live-registry-cleanup.test.ts`), so a `clearRegistry()`
|
|
1139
|
-
parked in the suite's teardown — beside `drop table`, where it reads as belonging — never ran in
|
|
1140
|
-
the ONE configuration a live suite is never deliberately run in: **36 entities stayed registered**
|
|
1141
|
-
across `packages/entity/src/*.live.test.ts` with `TEST_DATABASE_URL` unset, which is every CI run
|
|
1142
|
-
of the unit gate. An `if (!hasPostgres) return` above the call is the same hole by a second route.
|
|
1143
|
-
So the cleanup is its own top-level `afterAll(() => { clearRegistry(); })` at the end of the file,
|
|
1144
|
-
matched on exact text by `live-registry-cleanup.test.ts`, which also refuses a live suite that
|
|
1145
|
-
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.
|
|
1146
262
|
|
|
1147
263
|
## Files
|
|
1148
264
|
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
| `transition.ts` | one atomic move: the legality question, the conditional statement, the diagnosis of a statement that matched nothing |
|
|
1162
|
-
| `enum-column.ts` | `enumerated()` and its own chain — the one builder that may declare a machine |
|
|
1163
|
-
| `column-values.ts` | `got()` and `oneOf()`, so `enum-column.ts` needs no import of the file that imports it |
|
|
1164
|
-
| `feature-errors.ts` | the refusals search and the state machine raise at call time; the codes and titles stay in `errors.ts` |
|
|
1165
|
-
| `view.ts` | `$view(keys)` — the row projection an action names as its `output` |
|
|
1166
|
-
| `query.ts` / `database.ts` | chainable read to a cursor page; `database()` + `Driver` |
|
|
1167
|
-
| `clock.ts` | `entityNow()` — the ONE clock read on the write path, `ctx.clock` else the system's |
|
|
1168
|
-
| `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` |
|
|
1169
|
-
| `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 |
|
|
1170
|
-
| `cross-tenant.ts` | `crossTenant(reason, fn)` — the capability-gated scope that lifts it |
|
|
1171
|
-
| `plan.ts` / `cursor.ts` | the plan both drivers execute; the one keyset cursor codec |
|
|
1172
|
-
| `batch.ts` | `inBatches(size)` — the chain's page in a loop, closed by the loop that reads it |
|
|
1173
|
-
| `pg-driver.ts` | `postgresDriver()`, `postgresRepo()`, `postgresTransactor()` — attributes every statement it sends |
|
|
1174
|
-
| `coalesce.ts` | one microtask of `findById` calls → one `where id in (…)`, per request |
|
|
1175
|
-
| `batch-read.ts` | what a shared point read is made of — the scope key, `keyOf`, the one `in` statement |
|
|
1176
|
-
| `bulk-write.ts` | what a many-row write is made of — the column list, the conflict plan, the bind-count chunking |
|
|
1177
|
-
| `count-by.ts` | what a grouped count is made of — the groupable kinds, the group bound, the key's decoding, the order |
|
|
1178
|
-
| `jit-preload.ts` | a page's foreign key values → one `in` statement for the whole `for … of` loop |
|
|
1179
|
-
| `preload.ts` | the relation `preload()` names → one related-rows statement → attached to the page |
|
|
1180
|
-
| `pg-sql.ts` / `pg-row.ts` | plan → parameterised SQL; physical row ⇄ entity row (money is three columns) |
|
|
1181
|
-
| `row-observer.ts` | `setRowObserver` — committed row changes, above the driver, for a change feed that has no log to read |
|
|
1182
|
-
| `registry.ts` | duplicate detection, `describeEntities()` for the manifest, `references()` per entry |
|
|
1183
|
-
| `relations.ts` | `relationMap()`/`relationsFor()`/`relationNamed()` — the FKs as a named `belongsTo`/`hasMany` map |
|
|
1184
|
-
| `n-plus-one.ts` | a repeated statement → the error whose `fix` is the preload or bulk call that ends it |
|
|
1185
|
-
| `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 |
|
|
1186
|
-
| `type-pins.ts` | compile-time assertions `tsc` checks — the column proxy, `Invariant` variance, the branded id |
|
|
1187
|
-
| `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`.
|
|
1188
277
|
|
|
1189
278
|
## Commands
|
|
1190
279
|
|
|
1191
|
-
`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).
|