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