@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/README.md CHANGED
@@ -22,10 +22,10 @@ export const posts = entity('posts', {
22
22
  createdAt: timestamp().defaultNow(),
23
23
  updatedAt: timestamp().defaultNow().onUpdateNow(),
24
24
  },
25
- invariants: [
26
- invariant('post_title_present', (c) => c.title.trimmed().minLength(1)),
27
- invariant('post_slug_unique_per_org', (c) => c.unique(['orgId', 'slug'])),
28
- invariant('post_like_count_non_negative', (c) => c.likeCount.atLeast(0)),
25
+ invariants: (c) => [
26
+ invariant('post_title_present', c.title.trimmed().minLength(1)),
27
+ invariant('post_slug_unique_per_org', c.unique(['orgId', 'slug'])),
28
+ invariant('post_like_count_non_negative', c.likeCount.atLeast(0)),
29
29
  ],
30
30
  indexes: [{ on: ['orgId', 'createdAt'], order: 'desc', where: (c) => c.status.eq('published') }],
31
31
  });
@@ -59,16 +59,83 @@ still imports one package: `import { entity, t } from '@ultimat3/entity'`.
59
59
 
60
60
  | Builder | Emits | Why it is the only way |
61
61
  |---|---|---|
62
- | `uuid()` | `uuid`; `.primaryKey()` defaults to v7 | time-ordered keys keep the pk index append-friendly |
62
+ | `uuid()`, `uuid<PostId>()` | `uuid`; `.primaryKey()` defaults to v7 | time-ordered keys keep the pk index append-friendly; the optional brand is declared once and survives to every signature |
63
63
  | `timestamp()` | `timestamptz` | UTC storage is not a per-table decision; there is no naive variant |
64
- | `money()` | `<name>_minor bigint` + `<name>_currency char(3)` | never a float, never one implied currency |
64
+ | `money()` | `<name>_minor bigint` + `<name>_currency char(3)` + `<name>_scale integer null` | never a float, never one implied currency. The row value is `@ultimat3/schema`'s `MoneyValue` — the same declaration `@ultimat3/money`'s `Money` is — so a decoded row goes straight to `add()`/`formatMoney()`. A writer may hand a `bigint`; a stored minor unit past ±2^53 is refused on read, never rounded. `scale` is the decimal exponent `minor` counts in when it is not the currency's own (`{ minor: 2, currency: 'USD', scale: 6 }` is $0.000002); NULL in the column means "the currency's own minor unit" and round-trips as an ABSENT key, never as `0` |
65
65
  | `enumerated(v)` | `text` + CHECK | a variant is a one-line migration, not `ALTER TYPE` |
66
66
  | `tz(zones)`, `locale(tags)` | `text` + CHECK, `Intl`-validated at declaration | an offset is not a time zone |
67
67
  | `text({ max })`, `integer()`, `boolean()`, `url()` | `text`/`integer`/`boolean` + CHECK | format is enforced by the database too |
68
68
 
69
69
  Chain: `.primaryKey()` · `.nullable()` · `.unique()` · `.default(v)` · `.defaultNow()` ·
70
- `.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()`. Physical names are
71
- derived from the property key (`orgId` → `org_id`); a name is written once, or never.
70
+ `.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()` · `.column(name)`.
71
+ Physical names are derived from the property key (`orgId` → `org_id`); a name is written once, or
72
+ never — `.column()` is the exception, and it exists for tables this framework did not create.
73
+
74
+ ## Wide columns
75
+
76
+ The vocabulary an EXISTING schema needs. The blessed set above is a decision the framework made
77
+ for a table it was going to create; these are the shapes a table already has, `As of 2026-08`.
78
+
79
+ | Builder | Emits | Row type, and why |
80
+ |---|---|---|
81
+ | `json(schema)` | `jsonb` | the schema is **required**: a `json()` returning `unknown` is the `any` hole this framework forbids, and a column is the worst place for one — the value arrives from the database as often as from a caller. The object is bound as an object, never as JSON text |
82
+ | `decimal({ precision, scale })` | `numeric(p, s)` | a `string`, the exact digits. Money is the one decimal with an opinion (integer minor units + a currency); this is every other one, and a value with more decimal places than the column stores is refused rather than rounded |
83
+ | `date()` | `date` | `@ultimat3/time`'s `PlainDate` — a calendar date, no time, no zone. `effective_on` is the date a rate applies, and as a `timestamptz` it is a different date on either side of midnight for half the planet |
84
+ | `bigint()` | `bigint` | a decimal `string`. A JS `bigint` is what `JSON.stringify` throws on and a `number` loses digits past 2^53 — which is exactly where a legacy `int8` key lives. Both driver spellings (a string from Bun's `sql`, a `bigint` from PGlite) arrive as one |
85
+ | `bytes()` | `bytea` | a plain `Uint8Array`, normalised: Bun's `sql` returns a `Buffer` and PGlite a `Uint8Array`, and the two do not serialise alike |
86
+ | `arrayOf(column)` | `<element>[]` | `readonly T[]`, each member parsed by the element column it was given. Money and nested arrays are refused — an element is one scalar column |
87
+
88
+ ## Adopting an existing table
89
+
90
+ Three overrides, and together they are what makes a schema Ultimate did not generate declarable
91
+ at all. Nothing here changes what an entity without them emits.
92
+
93
+ ```ts
94
+ import { date, entity, money, text, uuid } from '@ultimat3/entity';
95
+
96
+ export const accounts = entity('account', {
97
+ table: 'legacy_accounts',
98
+ columns: {
99
+ id: uuid().primaryKey().column('account_id'),
100
+ githubLogin: text({ max: 40 }).column('gh_login'),
101
+ balance: money({ columns: { minor: 'amount_cents', currency: 'currency', scale: null } }),
102
+ openedOn: date().column('opened_on'),
103
+ },
104
+ });
105
+ ```
106
+
107
+ | Override | What follows it |
108
+ |---|---|
109
+ | `entity(name, { table })` | every statement, index name and foreign key. The entity NAME stays the framework's key — the registry, the cache tag (`entity:account`), `x entities describe` and every relation are keyed by it, so renaming a table never moves a cache tag or a policy |
110
+ | `.column(name)` | the DDL, the binding, the decoder, the predicate, the sort key, the cursor. Name it LAST in a chain — the link returns the general column, and only `uuid()` and `timestamp()` keep their own methods across it |
111
+ | `money({ columns })` | per part, merged over `<name>_minor` / `<name>_currency` / `<name>_scale`, so a table that renamed one does not restate the other two. `scale: null` says the table has no scale column: every amount is then at the currency's own minor unit, which is what an absent scale already means |
112
+
113
+ A physical name is checked where it is written — lower-case letters, digits and underscores, at
114
+ most the 63 bytes Postgres truncates at — because it is spliced into every statement as an
115
+ identifier.
116
+
117
+ **What is not adoptable yet**, `As of 2026-08`: a `numeric` money column with no currency column
118
+ beside it (`money()` is two columns by construction — declare it `decimal()` and keep the currency
119
+ in the app), a Postgres `enum` TYPE (`enumerated()` emits a CHECK, not a `CREATE TYPE`), a
120
+ composite type, and a live query over a renamed column — `@ultimat3/realtime` rebuilds a row from
121
+ the physical names alone and would deliver `ghLogin` where the repository says `githubLogin`.
122
+
123
+ ## Branded ids
124
+
125
+ ```ts
126
+ export const posts = entity('posts', {
127
+ columns: { id: uuid<PostId>().primaryKey(), authorId: uuid<UserId>().references(() => users.id) },
128
+ });
129
+
130
+ const post = await db.posts.findById(postId); // PostId — a UserId here is a compile error
131
+ ```
132
+
133
+ The brand is declared once, on the column, and carried by the whole chain: `RowOf`, `Insertable`,
134
+ `Repo.findById/update/delete` and `Table.update/delete`, whose id parameters are `IdOf<Row>` —
135
+ the type the entity's own `id` column declared. `IdOf` collapses to `string` for a row that
136
+ declared no brand and for a composite key, so an unbranded entity reads exactly as it always did.
137
+ Nothing is checked at runtime: a brand has no witness, `$parse` still validates the uuid, and
138
+ `type-pins.ts` is where the claim is enforced.
72
139
 
73
140
  ## Invariants run twice
74
141
 
@@ -101,6 +168,206 @@ these filters, this sort order. A tampered cursor, or one taken from another lis
101
168
  `X_CURSOR_INVALID` rather than a silent page one. The page size is deliberately outside the scope:
102
169
  asking for a bigger next page is the same query.
103
170
 
171
+ **A page is bounded whether or not the caller bounded it.** `DEFAULT_PAGE_SIZE` (50) covers the read
172
+ nobody sized; `MAX_PAGE_SIZE` (10,000) covers the one they did — `limit(input.pageSize)` on a number
173
+ that arrived over the wire is the same production incident with an argument in front of it. A page
174
+ size that is not a whole number of rows in `1..MAX_PAGE_SIZE` is `X_INVARIANT_VIOLATED` on the chain
175
+ and again inside the plan both drivers build, so `findMany({ limit })` straight at the repository
176
+ cannot route around it. `inBatches(size)` is the call that means "every row" — one page per
177
+ statement, never a table in memory.
178
+
179
+ `DEFAULT_PAGE_SIZE`, `MAX_PAGE_SIZE` and `MAX_ASSERTED_ROWS` are exported, beside
180
+ `N_PLUS_ONE_THRESHOLD` and for the same reason: an action validating its own `pageSize` input
181
+ against a hardcoded `10_000` is a second declaration of one number, and the second one goes stale.
182
+
183
+ ## Iterating every row
184
+
185
+ `As of 2026-08`. A page is bounded on purpose, so reading a whole table is a loop — and the loop is
186
+ the terminal, not something the caller writes around `page()`:
187
+
188
+ ```ts
189
+ // One statement per batch, one page of rows in memory at a time.
190
+ for await (const batch of db.posts.where({ orgId }).preload('author').inBatches(500)) {
191
+ await search.index(batch);
192
+ }
193
+
194
+ // Stopping early is cheap: the position survives, so the next run resumes where this one stopped.
195
+ await using batches = db.posts.where({ orgId }).after(checkpoint).inBatches(500);
196
+ for await (const batch of batches) {
197
+ await search.index(batch);
198
+ if (ctx.clock.now() > deadline) break;
199
+ }
200
+ await db.checkpoints.update(id, { cursor: batches.cursor });
201
+ ```
202
+
203
+ Every batch is the page `page()` would have returned at that position — same filters, same tenancy,
204
+ same soft-delete visibility, same `select()`, same `preload()` — so there is no second read path to
205
+ learn or to drift.
206
+
207
+ | | |
208
+ |---|---|
209
+ | Statements | one per batch, each asking for one row past it, exactly as `page()` does. An empty batch is never yielded |
210
+ | Position | keyset, never OFFSET: a row written mid-iteration cannot make the loop skip or repeat one. `after(cursor)` starts it, `.cursor` is where it stopped, `null` once exhausted |
211
+ | Closing | `break`, `return` and a throw all stop the next statement; `await using` is the same guarantee for a handle kept in a variable, and `close()` is idempotent. One handle is one iteration — a second `for await` continues it rather than restarting the table |
212
+ | Refusals | on the chain, not one batch later: a size that is not a whole number of rows between 1 and `MAX_PAGE_SIZE` (10,000), a `limit()` on the same chain (one number, two meanings), and an ordering no cursor can carry — a nullable sort column, which a result that fits in one batch would otherwise hide until the table grew |
213
+ | Tenancy | the plan's, as everywhere else: an unscoped chain is `X_TENANCY_UNSCOPED` on its first batch |
214
+
215
+ ## Counting by a column
216
+
217
+ `As of 2026-08`. `count()` answers one number, so a screen or a backfill that needs one per row
218
+ asks N times. `countBy(column)` is that whole loop as one statement, keyed by the value:
219
+
220
+ ```ts
221
+ // One statement for every post in `ids`, not one `select count(*)` each.
222
+ const counts = await db.likes.where({ orgId }).andWhere('postId', 'in', ids).countBy('postId');
223
+ for (const id of ids) await db.posts.update(id, { likeCount: counts.get(id) ?? 0 });
224
+ ```
225
+
226
+ `ReadonlyMap<Row[K], number>`, keyed by the column named — the chain knows the row, so
227
+ `counts.get(postId)` is a `number | undefined` and the `undefined` is load-bearing.
228
+
229
+ | | |
230
+ |---|---|
231
+ | Counts | the whole predicate, exactly as `count()` does: the chain's filters, its tenancy and its soft-delete visibility. `limit()` and `after()` bound the page, never the count |
232
+ | A value nothing matched | absent, never `0` — that is what `group by` returns, and it is what tells "none" apart from "never asked". The default is the caller's `?? 0` |
233
+ | NULL | one group, keyed `null`, in both drivers. `0`, `''` and `false` stay the values they are |
234
+ | Order | biggest group first, ties by the value (numbers and bigints numerically, everything else by its text), `null` last — applied after the rows are in, since a hash aggregate and a `Map` filled row by row have no order to inherit |
235
+ | Groupable columns | `uuid`, `text`, `char`, `boolean`, `integer`, `bigint`. A timestamp, a `jsonb` or `money` is `X_INVARIANT_VIOLATED` naming one of this entity's columns that is: a `Map` compares a non-primitive key by identity, so such a map could only ever answer `undefined` |
236
+ | More than 1000 groups | `X_INVARIANT_VIOLATED`, never a truncated map — the statement asks for one group past the bound, exactly as a page reads one row past its limit. The `fix` spells the `andWhere('<column>', 'in', <values>)` that bounds it |
237
+ | Statement | `select "post_id" as group_value, count(*) as group_count … group by "post_id"`. Both names are fixed aliases, so an entity may still declare a column called `count`, and the grouped value is re-parsed by the column that declared it |
238
+
239
+ ## Writing by filter
240
+
241
+ `deleteWhere`/`updateWhere` are the bulk forms of `delete`/`update` — the same fix `insertAll` is
242
+ for a per-row insert loop, applied to the two write shapes a composite-key entity cannot address
243
+ one row at a time: a `for … of` deleting or patching one row per iteration is one statement here,
244
+ not `n`.
245
+
246
+ ```ts
247
+ db.posts.delete(id); // by a single primary key
248
+ db.posts.update(id, { title }); // ditto
249
+
250
+ db.likes.deleteWhere({ postId, userId }); // -> 1 · the only way to unlike
251
+ db.likes.deleteWhere({ postId }); // -> n · every like on that post
252
+ db.participants.updateWhere({ conversationId, userId }, { lastReadAt }); // -> 1 · mark read
253
+
254
+ db.likes.deleteWhere({}); // X_WRITE_UNFILTERED, never every row
255
+ db.participants.updateWhere({ conversationId }, {}); // X_PATCH_EMPTY, never a silent no-op
256
+ ```
257
+
258
+ `delete(id)` and `update(id, patch)` both need a single-column primary key. A composite one —
259
+ `likes`, `blocks`, `participants`, any join table — has no single id, so the filtered pair is the
260
+ only write path there: without them such an entity is **create-only**, and a row could be written
261
+ and never unwritten.
262
+
263
+ | | |
264
+ |---|---|
265
+ | Returns | the **number of rows affected**, so "nothing matched" is distinguishable from "it worked" |
266
+ | Empty filter | `X_WRITE_UNFILTERED`. An `undefined` value is dropped before the count, so a forgotten variable is the error and not the whole table |
267
+ | Empty patch | `X_PATCH_EMPTY`. Counting rows for a statement that set nothing is the same silent no-op, one argument along |
268
+ | Tenancy | the plan a read builds, through `scopedPlan` — the actor's org predicate is in the statement, and the empty-filter guard runs before it, because one tenant's every row is still every row. The patch is judged too, through `assertRowTenant`: a filter bounds which rows are written, never what they become |
269
+ | Soft delete | the entity's `deletedAt` column is the same switch `delete(id)` uses. Stamped rows are not matched again by either call, so the original deletion time survives and a deleted row is never patched back into shape |
270
+ | `onUpdateNow()` | stamped by `touch()`, the same helper `update(id, patch)` uses — one place, so the two can never disagree about `updatedAt` |
271
+ | Rows read back | **only when something can still refuse them.** A `check` or a `unique` invariant is a constraint Postgres enforced on the statement, so the answer is a count and the statement carries no `returning *`. Only a JS-only rule (`kind: 'assert'`, `sql: null`) has to be judged on the result — and then the match is counted first and refused past `MAX_ASSERTED_ROWS` (50,000), naming `inBatches(1000)`, because a refusal issued after `returning *` is already holding what it is refusing |
272
+
273
+ ## Writing many rows
274
+
275
+ ```ts
276
+ await db.tags.insertAll(names.map((name) => ({ orgId, name }))); // one statement, n rows
277
+
278
+ await db.likes.upsertAll(rows, { // insert, or leave what is there
279
+ onConflict: ['orgId', 'postId', 'memberId'],
280
+ onMatch: 'nothing',
281
+ });
282
+
283
+ await db.counters.upsertAll(rows, { onConflict: ['orgId', 'day'] }); // insert, or overwrite
284
+ ```
285
+
286
+ `insertAll` is `insert` in bulk and nothing else: rows are `Insertable`, each one goes through
287
+ `$parse`, so declared defaults are filled here rather than by the caller. `upsertAll` adds the one
288
+ thing a per-row loop cannot do without a read first — resolve a collision — and stamps
289
+ `onUpdateNow()` columns through the same `touch()` `update(id, patch)` uses, because an upsert that
290
+ lands on a stored row *is* an update.
291
+
292
+ Both resolve with **the rows this call wrote**, in order. Under `onMatch: 'nothing'` a row already
293
+ stored is skipped and absent from the result, exactly as `returning *` reports it — which is how a
294
+ caller counts what it actually inserted.
295
+
296
+ | | |
297
+ |---|---|
298
+ | One builder | `insertStatement` compiles every insert in the framework, so `insertAll([row])` is the text `insert(row)` always produced. There is no second insert path to drift |
299
+ | What a collision overwrites | every column the batch writes, minus the conflict target (how the row was found), minus the primary key (where it lives) and minus the soft-delete stamp (whether the row is there at all). Moving either of the first two moves a row nobody asked to move, and every foreign key pointing at that id misses it |
300
+ | A soft-deleted row it lands on | stays deleted, and takes the batch's other columns. The stamped row still occupies its conflict target — that index is not partial — so `excluded."deleted_at"` would resurrect it; `$parse` fills `deletedAt: null` into every row before the plan is built, so the stamp is dropped from the set list rather than refused. `insertAll` still writes the stamp a new row carries |
301
+ | Conflict target | properties of a **declared** unique constraint — the primary key, a `unique()` column, an `indexes: [{ on, unique: true }]` entry, or an `invariant(name, c.unique([…]))`. Anything else is `X_INVARIANT_VIOLATED` here rather than `42P10` from the server |
302
+ | Tenancy | on a tenant-scoped entity `onMatch: 'update'` requires the tenant column *in the conflict target*, else `X_TENANCY_UNSCOPED`: a target that omits it matches another tenant's row and rewrites it. `'nothing'` is allowed — it writes nothing to a row it does not own |
303
+ | A batch that repeats itself | two rows with one conflict target under `'update'` is refused. Postgres answers that statement `ON CONFLICT DO UPDATE command cannot affect row a second time`, so it cannot pass in memory either |
304
+ | Uneven batches | under `'update'` every row must name the same columns: `excluded.<column>` for a row that omitted one is that column's *default*, not "leave it alone". Under `'nothing'` and under `insertAll`, an omitted column is `default` in its cell, which is what the same row means on its own |
305
+ | Nulls | a null in the conflict target collides with nothing, in both drivers — a Postgres unique index is `NULLS DISTINCT` |
306
+ | Size | past 65535 bind parameters the batch is several statements, never one the server refuses. Wrap the call in `withTransaction` when all-or-nothing matters |
307
+ | Filtered writes | `updateWhere` / `deleteWhere` above are the bulk forms of `update` and `delete` — one statement for a loop that would otherwise call either per row; there is no `updateAll` |
308
+
309
+ ## Relations are the foreign keys, read twice
310
+
311
+ ```ts
312
+ relationMap().posts;
313
+ // { org: { kind: 'belongsTo', to: 'orgs', localKey: 'orgId', remoteKey: 'id' },
314
+ // author: { kind: 'belongsTo', to: 'members', localKey: 'authorId', remoteKey: 'id' },
315
+ // likes: { kind: 'hasMany', to: 'likes', localKey: 'id', remoteKey: 'postId' } }
316
+
317
+ relationsFor('posts'); // one entity's relations, by name
318
+ relationNamed('posts', 'author'); // one relation, or X_PRELOAD_UNKNOWN_RELATION listing the rest
319
+ ```
320
+
321
+ `.references(() => members.id)` already says a post has an author and a member has posts. There
322
+ is no second declaration syntax for associations and there will not be one: the map reads the keys
323
+ that exist. `belongsTo` comes from an entity's own foreign keys, `hasMany` from the inbound ones —
324
+ so the two sides can never disagree, and neither can drift from the constraint the migration emits.
325
+
326
+ | Rule | Detail |
327
+ |---|---|
328
+ | Names | `authorId` ⇒ `author`; a `hasMany` is named for the entity the rows come from |
329
+ | Collisions | two keys wanting one name ⇒ **both** take the long form (`author` / `authorId`, `postsByAuthor` / `postsByReviewer`), so a name never depends on declaration order |
330
+ | Ambiguity | two keys that differ only by an `Id` suffix ⇒ `X_INVARIANT_VIOLATED` naming both columns, never one relation silently swallowing the other |
331
+ | Unknown name | `X_PRELOAD_UNKNOWN_RELATION`, whose `fix` is a `relationNamed()` call on one that exists plus the rest by name — they are derived, so there is no schema file listing them to go and read |
332
+ | Keys | `local*` is always on `from`, `remote*` on `to`, whichever side the edge is read from |
333
+ | Money | no relation: one property, three physical columns, so none of them is the key |
334
+
335
+ **Where they come from.** `RegistryEntry.references()` — every `entity()` call leaves one behind, so
336
+ a consumer walks the whole domain without importing a schema module. A method rather than a field:
337
+ a `references()` thunk may point at an entity that two modules of an import cycle have not finished
338
+ evaluating. `relationMap()` derives the whole registry and memoises against its generation, so a
339
+ schema module imported late is rebuilt into the map instead of being missed by it, and a read that
340
+ changed nothing costs one integer compare. `relationsOf(entries)` is the same derivation over a
341
+ named subset — a `belongsTo` to an entity outside it is still reported, a `hasMany` needs both
342
+ sides. An entity holding its own keys reads `entity.$references()`: same closure, so a foreign key
343
+ is read once and not twice.
344
+
345
+ `preload()`, below, is what consumes it — exported because the derivation is a fact about the
346
+ schema, not an implementation detail of whoever traverses it first.
347
+
348
+ ## Preloading a relation
349
+
350
+ `As of 2026-08`. A single `findById` batches itself, and a `for … of` loop over a page batches the
351
+ loop it causes — reach for `preload()` to carry the relation from the start, without waiting on
352
+ either pattern to trigger it:
353
+
354
+ ```ts
355
+ export const db = database({ orgs, posts, members });
356
+
357
+ // Two statements: the page, then one `select … where "id" in (…)` over its authors.
358
+ const page = await db.posts.where({ orgId }).preload('author').page();
359
+ page.rows[0].author; // the member row, or null — always present
360
+ ```
361
+
362
+ | | |
363
+ |---|---|
364
+ | Vocabulary | one method, `preload('<relation>')` — no `include`, `join` or `with` |
365
+ | Unknown name | `X_PRELOAD_UNKNOWN_RELATION` at `preload()` itself, not a page later |
366
+ | Shape | `belongsTo` attaches the row or `null`; `hasMany` an array — always present |
367
+ | Statements | one extra per relation, resolved concurrently; naming one twice is one statement |
368
+ | Tenancy | carried onto the related read only when the other entity's tenant column shares the name; otherwise `X_TENANCY_UNSCOPED` refuses the related read rather than guess |
369
+ | Terminals | `page()`, `all()`, `one()` preload; `count()`, `countBy()` and `plan()` don't — none reads a row to attach one to |
370
+
104
371
  ## Two drivers, one meaning
105
372
 
106
373
  ```ts
@@ -113,6 +380,23 @@ database({ orgs, posts }, { driver: postgresDriver() }); // production
113
380
  | Rows live | in a `Map` | in Postgres |
114
381
  | For | tests, `x dev` before the first migration | production |
115
382
  | Transaction | `memoryTransactor()` — undo closures | `postgresTransactor()` — real `BEGIN`/`COMMIT` |
383
+ | `reset()` | empties every repository it built | not implemented — the rows are the app's |
384
+
385
+ `database()` called with no driver takes the process default, and `defaultDriver()` is that same
386
+ object — the one seam a test harness needs, `As of 2026-08`:
387
+
388
+ ```ts
389
+ import { defaultDriver } from '@ultimat3/entity';
390
+
391
+ defaultDriver().repo(posts).insert(row); // seeds what database({ posts }) reads
392
+ defaultDriver().reset?.(); // between tests; `?.` because Postgres has no reset
393
+ ```
394
+
395
+ `Driver.reset?()` is optional on the interface and implemented by `memoryDriver()` alone, so a
396
+ harness asks rather than assumes. It empties the repositories in place: `database()` resolves each
397
+ table's repository once, so a driver that swapped in fresh ones would leave every handle the app
398
+ already holds reading the old rows. Application code never calls either — it names its driver in
399
+ `database(entities, { driver })`, or takes the default implicitly.
116
400
 
117
401
  They are not two implementations of an idea. They share the plan (scope, sort order, page size),
118
402
  the cursor codec and the `Repo<T>` contract, so a page taken in a test means the same thing as a
@@ -121,29 +405,196 @@ returns the open transaction when there is one, so a repository call inside `wit
121
405
  joins it without being told — which is how a job's outbox row lands atomically with the write
122
406
  that enqueued it.
123
407
 
408
+ `postgresDriver({ client })` pins one instead, for a test harness or `x db branch` — and a pinned
409
+ repository used while a transaction is open is `X_REPO_CLIENT_PINNED`, `As of 2026-08`.
410
+ `withTransaction` reserved a connection and ran `BEGIN` on it; a pinned repository sends straight
411
+ to its own client, so the write would commit whatever the transaction decides and the read would
412
+ miss what the transaction has written, both silently. It is refused rather than resolved because a
413
+ `DbTx` does not name the client it was opened on: on a sharded app "the same connection" and "the
414
+ same database" are two different questions and this layer can answer neither. `setDbClient(client)`
415
+ plus an unpinned repository is the shape that joins.
416
+
124
417
  Every value is bound to `$n` and every identifier is resolved through the entity, so a column
125
418
  name can only be one the entity declared and a row value can never become SQL.
126
419
 
420
+ ## Point lookups batch themselves
421
+
422
+ `As of 2026-08`:
423
+
424
+ ```ts
425
+ const repo = postgresRepo(users);
426
+ // One statement, not one per post: the lookups issued in this microtask are one `in`.
427
+ const authors = await Promise.all(posts.map((post) => repo.findById(post.authorId, { orgId })));
428
+ ```
429
+
430
+ `findById` keeps its signature and gets faster. There is no `dataloader()`, no `batch()` and
431
+ nothing to opt into: inside a request the point lookups issued in one microtask become one
432
+ `select … where "id" in ($1, $2, …)` carrying the scope each of them carried, and outside one — a
433
+ job, a script — it is the single statement it always was.
434
+
435
+ | | |
436
+ |---|---|
437
+ | Window | one microtask, closed before the statement is sent |
438
+ | Lifetime | the request; the batch is keyed by context identity and dies with it |
439
+ | Never shared with | another tenant, another soft-delete visibility, another projection, another entity, another client |
440
+ | Wider than 500 ids | several whole statements, never one Postgres refuses |
441
+ | An id with no row | `null`, exactly as the single statement answered |
442
+
443
+ ## A page batches the loop it causes
444
+
445
+ `As of 2026-08`. A sequential loop shares no microtask — its `await` ends the window before the
446
+ next lookup exists. So a page leaves its foreign key values behind, and the first lookup for any
447
+ one of them resolves that key for every row of the page:
448
+
449
+ ```ts
450
+ const page = await postgresRepo(posts).findMany({ orgId });
451
+ for (const post of page.rows) {
452
+ // Two statements for the whole loop: the page, then one `in` over every author on it.
453
+ const author = await postgresRepo(users).findById(post.authorId, { orgId });
454
+ }
455
+ ```
456
+
457
+ Nothing new to write: the relation is the `references()` the column already declares.
458
+
459
+ | | |
460
+ |---|---|
461
+ | Served to | a lookup with the same scope key, the same client, and no write to that entity since — the preload statement *is* the statement it was widened from |
462
+ | Scope | the tenant predicate and `deleted_at is null` are in the preload statement, so a page's ids can never resolve rows outside the reader's own scope |
463
+ | A write | drops what was preloaded for that entity, before the statement goes out, so a changed row is re-read and never served from before it |
464
+ | Held | the ids, never the rows; keyed by context identity, so it dies with the request |
465
+ | Declines to the old statement | no request in scope, an id no page indexed, a key that resolved to nothing |
466
+ | Switched off | `postgresDriver({ jitPreload: false })`, where the driver is constructed — the one switch. Not an `app.config.ts` key: nothing reads config at the seam that builds a repository |
467
+
468
+ ## A loop that got past all of that is reported, with the fix already written
469
+
470
+ `As of 2026-08`. The three batching paths above are what a loop *should* have taken; `nPlusOne()`
471
+ is what an author is handed when it did not. It takes a repeated statement — the verdict a ledger
472
+ reached, never a count this package keeps — and returns the error every surface renders:
473
+
474
+ ```ts
475
+ nPlusOne({ kind: 'read', subject: 'members.findById', count: 50, entity: 'members', op: 'findById' });
476
+ // X_N_PLUS_ONE_QUERY: a read repeated once per row
477
+ // cause: members.findById ran 50 times in one request — one read per row
478
+ // fix: db.posts.preload('author') # one statement for the whole page
479
+ ```
480
+
481
+ The relation in that `fix` is derived, never invented: `preloadsFor(entity, op)` reads the same
482
+ `relationMap()` `preload()` resolves against, so the line pastes into a chain that already
483
+ compiles. Which edge answers which loop follows from the operation — a point lookup per row is the
484
+ `belongsTo` side (`posts.preload('author')`), a filtered read per row the `hasMany` side
485
+ (`posts.preload('comments')`), and every other operation falls back to the batched form of the
486
+ statement that repeated.
487
+
488
+ | The loop | The `fix` |
489
+ |---|---|
490
+ | `findById` / `findMany` with a relation pointing at it | `db.<page>.preload('<relation>')`, the first candidate pasteable and the rest listed — the ledger saw the statement, never the `for … of` above it |
491
+ | a read with no such relation, or an operation no preload answers | `db.<entity>.andWhere('id', 'in', ids).all()` |
492
+ | `insert` / `update` / `delete` per row | `db.<entity>.insertAll(rows)` / `.updateWhere(filter, patch)` / `.deleteWhere(filter)` |
493
+ | hand-written SQL, attributed to no entity | the statement's own `any($1)` form, or `expectedQueryLoop('<why>', fn)` |
494
+
495
+ Nothing here counts or installs anything: `x dev` owns the ledger, `@ultimat3/testing`'s `statements`
496
+ fixture owns the strict one, `expectedQueryLoop` from `@ultimat3/db` is the one way to declare a loop
497
+ deliberate, and a production process pays the one branch the observer seam costs uninstalled. The
498
+ one number both detectors read *is* here — `N_PLUS_ONE_THRESHOLD` (5), next to the codes whose `fix`
499
+ it triggers, so a loop that fails a test and a loop that warns in dev are the same loop.
500
+
127
501
  ## Tenancy is a guard
128
502
 
129
503
  `tenant: 'orgId'` on the entity names the column outright. Omit it and it is inferred — a
130
504
  `.tenant()` column, else one named `orgId` — so an entity never becomes unscoped by forgetting the
131
505
  key; name a column that does not exist and the declaration fails with `X_INVARIANT_VIOLATED`.
132
506
 
133
- Either way, every read then needs an org predicate. Without one: `X_TENANCY_UNSCOPED`, at the
134
- seam, every time. Writes are reads: `update(id, patch)` and `delete(id)` build the same plan, so
135
- an id alone never addresses a row, and another tenant's id is `X_NOT_FOUND` rather than theirs.
507
+ **A tenant column may not be nullable**, whichever of the three switches named it, and the
508
+ declaration fails the same way. `assertRowTenant` leaves a row that names no tenant alone and
509
+ delegates to the column's `NOT NULL`; on a nullable column that delegation has nothing behind it, so
510
+ the row lands with a null tenant, is matched by no `org_id = $1`, and belongs to nobody — never in
511
+ an export, never in an offboarding sweep, and there for as long as the table is.
512
+
513
+ **The tenant is the acting actor's, and never an argument.** Inside a request every plan for a
514
+ scoped entity is scoped to `ctx.actor.orgId`, whether the call named a tenant or not — so
515
+ `db.posts.where({ status })` reads one org's posts and a handler no longer threads a tenant
516
+ through its own signatures. Writes are reads: `update(id, patch)` and `delete(id)` build the same
517
+ plan, so an id alone never addresses a row, and another tenant's id is `X_NOT_FOUND` rather than
518
+ theirs.
519
+
520
+ An `orgId` argument is still legal and now means "I assert this is the tenant": equal to the
521
+ actor's it is a restatement, different from it — an `orgId` that arrived as action input, a query
522
+ string or a path parameter — it is `X_TENANCY_ACTOR_MISMATCH`, refused rather than silently
523
+ overridden, with both values in the cause. `in` on the tenant column is judged the same way: a set
524
+ containing the actor's org is still a set that is not it.
525
+
526
+ | Situation | Answer |
527
+ |---|---|
528
+ | actor carries an `orgId` | the plan is scoped to it, derived |
529
+ | a call names the same one | a restatement; one predicate, not two |
530
+ | a call names another one | `X_TENANCY_ACTOR_MISMATCH` |
531
+ | actor carries no `orgId` (anonymous, or a service actor minted without one) | `X_TENANCY_ACTOR_ORG_REQUIRED` — inside no org, every tenant-scoped row is somebody else's |
532
+ | no request context at all (a script, a seed, a test harness) | there is no actor to derive from, so the caller names the tenant and `X_TENANCY_UNSCOPED` refuses a plan that names none |
533
+ | a read that must span tenants | `crossTenant(reason, fn)` |
534
+
535
+ **A row is judged the same way as a predicate.** `insert`, `insertAll` and `upsertAll` build no
536
+ read plan at all, and a patch decides what a row *becomes*, so the tenant a write names is checked
537
+ against the actor too: `insert({ orgId: theirs, … })` and `update(id, { orgId: theirs })` are both
538
+ `X_TENANCY_ACTOR_MISMATCH`, refused before the statement is sent and before anything is stored. A
539
+ batch is all or nothing — one bad row refuses the rows beside it, in both drivers.
540
+
541
+ **Refused, never stamped.** A row that names no tenant is left exactly as it was written: the
542
+ column's own `NOT NULL` answers a missing one. Filling it in from the actor is the ergonomic half
543
+ and it is deliberately absent, because the column list an `upsertAll` writes is decided by which
544
+ properties a row names — a stamped column would change the statement, silence the uneven-batch
545
+ refusal, and let ambient state decide which stored row a collision lands on.
546
+
547
+ **A cross-tenant upsert is unrepresentable rather than documented.** Two halves, and both are now
548
+ enforced: the conflict target must contain the tenant column under `onMatch: 'update'`
549
+ (`X_TENANCY_UNSCOPED` — the target is what decides which stored row a collision lands on), and
550
+ every incoming row must name the acting actor's tenant. Together, the key a collision is judged by
551
+ can only hold a value that is this actor's.
552
+
553
+ ```ts
554
+ // admin surfaces, background reconciliation, support tooling — greppable, and never a boolean
555
+ await crossTenant('nightly invite expiry runs for every org', async () => { … });
556
+ ```
557
+
558
+ The scope needs the `tenancy:cross` capability on the actor (`scopes: ['tenancy:cross']`), proven
559
+ at the call and again at every plan built inside it, so an impersonated child context cannot
560
+ inherit it: `X_TENANCY_CROSS_DENIED` otherwise. A blank reason is refused — an escape with no
561
+ argument is a pragma. There is no build-time tenancy check in `x verify`, and there cannot
562
+ usefully be one: the tenant is a request-time value, so the seam every plan is built through is
563
+ the enforcement.
136
564
 
137
565
  ## Seeds
138
566
 
139
- `defineSeed('dev', async ({ insert, id }) => …)`. `id('post:tenancy')` is a UUID v5 of the label,
140
- so the same fixture graph gets the same ids on every machine. Rows go through the columns and
141
- the invariants, which makes a seed a test of the schema as well.
567
+ `defineSeed(name, build, { tier })` the fixture graph, written once and **replayed anywhere**: a
568
+ second run writes nothing and raises nothing, against Postgres as well as against memory. Rows go
569
+ through the columns and the invariants either way, which makes a seed a test of the schema as well.
570
+ `x db seed [<name>]` is what applies it.
571
+
572
+ Two write verbs, because only the author knows which key identifies a row:
573
+
574
+ | Context member | Use it when | A replay |
575
+ |---|---|---|
576
+ | `insert(entity, rows)` | the seed chose the ids — `id('post:tenancy')` is a UUID v5 of the label, so the same graph gets the same ids on every machine | one `on conflict … do nothing` statement per call; a stored row is left alone and counted `skipped` |
577
+ | `upsert(entity, { by, preserve? }, values)` | the table owns the id and only a natural key identifies the row | reads first, so an unchanged row is `'skipped'` with no statement; otherwise one `on conflict … do update`, which settles the race between two containers booting at once |
578
+ | `exists(entity, where?)` / `count(entity, where?)` | bulk volume data, where the FILE is the unit of idempotency | the sentinel returns early and nothing is written |
579
+ | `deleteWhere(entity, where)` | a scoped wipe before a regenerate | **refused on a soft-deleting entity**: the stamp keeps the row's unique key and no replay can clear it |
580
+ | `id`, `now`, `environment`, `tier`, `dryRun`, `metrics` | — | `now` is one instant per run; `metrics` is `{ inserted, updated, skipped }` and `run()` returns it |
581
+
582
+ Rules worth knowing before the first seed: `upsert` never overwrites `createdAt` (`preserve` names
583
+ other columns to spare); `upsert` on a tenant-scoped entity needs the tenant column inside `by`,
584
+ exactly as `upsertAll` does, while `insert` needs nothing because `do nothing` writes nothing to a
585
+ row it does not own; and `insert` refuses a row that leaves a `uuid().primaryKey()` unnamed —
586
+ `$parse` would fill it with a fresh uuid and every replay would insert one more copy.
587
+
588
+ `tier` is `'dev'` (the default, fixture data) or `'reference'` (data the app is wrong without,
589
+ which ships to production). The refusal is the CLI's, never `run()`'s: an app that seeds its own
590
+ database from its boot code has decided to, and a library that overruled that would break it.
142
591
 
143
592
  ## Errors
144
593
 
145
- `X_ENTITY_DUPLICATE` · `X_INVARIANT_VIOLATED` · `X_TENANCY_UNSCOPED` · `X_DB_DRIFT` ·
146
- `X_NOT_FOUND`
594
+ `X_ENTITY_DUPLICATE` · `X_INVARIANT_VIOLATED` · `X_TENANCY_UNSCOPED` ·
595
+ `X_TENANCY_ACTOR_MISMATCH` · `X_TENANCY_ACTOR_ORG_REQUIRED` · `X_TENANCY_CROSS_DENIED` ·
596
+ `X_DB_DRIFT` · `X_NOT_FOUND` · `X_WRITE_UNFILTERED` · `X_PATCH_EMPTY` ·
597
+ `X_PRELOAD_UNKNOWN_RELATION` · `X_N_PLUS_ONE_QUERY` · `X_N_PLUS_ONE_WRITE`
147
598
 
148
599
  ## Boundaries
149
600
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "1.2.0",
3
+ "version": "3.0.0",
4
4
  "description": "A table + its domain type + invariants the database also enforces",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "CLAUDE.md",
22
23
  "README.md",
23
24
  "LICENSE"
24
25
  ],
@@ -30,8 +31,9 @@
30
31
  "test": "bun test"
31
32
  },
32
33
  "dependencies": {
33
- "@ultimat3/core": "1.2.0",
34
- "@ultimat3/db": "1.2.0",
35
- "@ultimat3/schema": "1.2.0"
34
+ "@ultimat3/core": "3.0.0",
35
+ "@ultimat3/db": "3.0.0",
36
+ "@ultimat3/schema": "3.0.0",
37
+ "@ultimat3/time": "3.0.0"
36
38
  }
37
39
  }