@ultimat3/entity 29.4.0 → 30.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.
Files changed (65) hide show
  1. package/CLAUDE.md +66 -96
  2. package/README.md +118 -8
  3. package/package.json +15 -5
  4. package/src/append-only.ts +7 -0
  5. package/src/batch-read.ts +12 -4
  6. package/src/bulk-write-send.ts +49 -0
  7. package/src/bulk-write.ts +48 -11
  8. package/src/column.ts +29 -4
  9. package/src/counter.ts +109 -0
  10. package/src/cross-tenant.ts +13 -0
  11. package/src/cursor.ts +17 -1
  12. package/src/database.ts +16 -10
  13. package/src/describe-tenancy.ts +25 -0
  14. package/src/describe.ts +144 -3
  15. package/src/entity-error.ts +33 -0
  16. package/src/entity-parse.ts +89 -0
  17. package/src/entity-patch.ts +42 -0
  18. package/src/entity.ts +90 -76
  19. package/src/errors.ts +8 -0
  20. package/src/history-actor.ts +22 -0
  21. package/src/history.ts +86 -0
  22. package/src/index.ts +20 -1
  23. package/src/jit-preload.ts +35 -25
  24. package/src/lifecycle.ts +100 -0
  25. package/src/lock-key.ts +27 -0
  26. package/src/lock-unsupported.ts +11 -0
  27. package/src/memory-aggregates.ts +99 -0
  28. package/src/memory-batch.ts +69 -0
  29. package/src/memory-journal.ts +68 -0
  30. package/src/memory-lifecycle.ts +106 -0
  31. package/src/memory-lock.ts +144 -0
  32. package/src/memory-observed.ts +121 -0
  33. package/src/memory-repo.ts +199 -186
  34. package/src/memory-same-tenant.ts +83 -0
  35. package/src/memory-seek.ts +44 -0
  36. package/src/memory-transactor.ts +94 -0
  37. package/src/memory-unique.ts +19 -4
  38. package/src/n-plus-one.ts +28 -10
  39. package/src/numeric-compare.ts +43 -0
  40. package/src/pg-aggregates.ts +185 -0
  41. package/src/pg-driver.ts +79 -166
  42. package/src/pg-lock.ts +50 -0
  43. package/src/pg-row.ts +29 -2
  44. package/src/pg-sql-aggregate.ts +31 -0
  45. package/src/pg-sql.ts +17 -1
  46. package/src/query.ts +26 -105
  47. package/src/read-aggregates.ts +138 -0
  48. package/src/read-capture.ts +22 -0
  49. package/src/registry.ts +44 -1
  50. package/src/repo.ts +34 -0
  51. package/src/retain.ts +71 -0
  52. package/src/row-observer.ts +22 -4
  53. package/src/row-security-settings.ts +20 -0
  54. package/src/sealed-repo.ts +15 -0
  55. package/src/seed.ts +43 -7
  56. package/src/series-unsupported.ts +11 -0
  57. package/src/table-locks.ts +41 -0
  58. package/src/test-driver.ts +90 -0
  59. package/src/time-bucket.ts +147 -0
  60. package/src/touch.ts +33 -0
  61. package/src/types.ts +20 -0
  62. package/src/view-entity.ts +78 -0
  63. package/src/view-issue.ts +47 -0
  64. package/src/view-repo.ts +38 -0
  65. package/src/view.ts +41 -27
package/CLAUDE.md CHANGED
@@ -23,11 +23,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
23
23
  `undefined` is ABSENT (only `null` clears); memory refuses a duplicate PK, a duplicate non-partial
24
24
  unique — index OR `c.unique([…])` invariant, the one list in `unique-constraints.ts` — and a PK
25
25
  patch onto another row with `X_DB_UNIQUE_VIOLATION` (partial uniques are not checked in memory);
26
- `.transition()` filters on `singleKeyOf(entity)` and refuses an absent id (`X_NOT_FOUND`, no row
27
- moved);
28
- `decimal()`/`bigint()` store Postgres's spelling (never rounded); a seed `upsert` compares only named
29
- columns; `text({ max })` counts code points; `integer()` holds int4, `bigint()` int8; a whole part
30
- of `0` is no `numeric` digit; `url()` lower-cases its scheme and judges the STORED text.
26
+ each kind's stored spelling: `docs/history/entity.md`.
31
27
  `invariant-parity.test.ts` asks the app, the driver and the raw CHECK the same question.
32
28
  - **`min`/`max` over a `timestamptz` crosses as epoch ms** (`aggregate-time-parity.test.ts`). A whole
33
29
  row read under a non-UTC session still fails on PGlite — recorded, not fixed.
@@ -38,6 +34,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
38
34
  holds a `number`); `RowWrite<Row>` types `insert`/`insertAll`/`upsertAll`; `narrowRow` (`columns.ts`)
39
35
  narrows at each write method's ENTRY, before `$assert`/`upsertPlan`. `pg-money-write.live.test.ts`,
40
36
  `money-write-parity.test.ts`, `type-pins.ts`.
37
+ - **A numeric key has one spelling** (`keyOf`): batches, conflict targets and memory's uniques. **A null test on a whole money property reads `<p>.minor`.**
41
38
  - **A PREDICATE's meaning is decided by the column's KIND** (`memory-match.ts`); `compareByKind` /
42
39
  `sameValueOfKind` are EXPORTED — `@ultimat3/query` calls them. **ONE numeric comparison,
43
40
  `numericOrder`**: `DECIMAL_TEXT` columns by VALUE (`'2.50' = 2.5`), `number`/`bigint` numerically
@@ -52,6 +49,9 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
52
49
  - **A repository call rejects, never throws synchronously** (`tableFor`'s writes are `async`).
53
50
  - **`defaultDriver()`** is the process default; `Driver.reset?()` is optional (memory only), resets
54
51
  repositories in place. Test seam only.
52
+ - **`testDriver()` (`test-driver.ts`) follows `@ultimat3/db`'s `dbClientInstalled()` on every call.**
53
+ A plain object of forwarders, never a `Proxy`; a side change inside `transactor().run` rejects
54
+ `X_TEST_DRIVER_CLIENT_CHANGED`. Full rule: `docs/history/entity.md`.
55
55
  - **A repository pinned to its own client joins a transaction opened ON that client** (`tx.origin`)
56
56
  **and refuses any other** (`X_REPO_CLIENT_PINNED`, in `client()` in `pg-driver.ts`); the fix names
57
57
  `setDbClient(client)` plus an unpinned repository.
@@ -63,21 +63,18 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
63
63
  it declines rather than guesses; past `MAX_IDS_PER_STATEMENT` it is several statements; no caller is
64
64
  ever left unsettled (`coalesce.test.ts` races a deadline).
65
65
  - **A page batches the loop it causes** (`jit-preload.ts`): a preloaded row is served only under the
66
- SAME `scopeKey` (a security boundary), same client, dropped by any write (`forgetPreloaded` in
67
- `writing()`), keyed by id, bounded (`MAX_SIBLING_KEYS`, oldest page first). One switch:
66
+ SAME `scopeKey` (a security boundary), same client, dropped by any write to ANY entity — triggers
67
+ and FK actions rewrite rows a statement never named (`forgetPreloaded` in `writing()`;
68
+ `jit-preload-db-writes.test.ts`), keyed by id, bounded (`MAX_SIBLING_KEYS`, oldest page first). One switch:
68
69
  `postgresDriver({ jitPreload: false })` — never an `app.config.ts` key. Shared pieces live in
69
70
  `batch-read.ts`.
70
71
  - **`preload(name, { max })` shares `batch-read.ts`, keeps no request cache and is BOUNDED**
71
- (`preload-ceiling.ts`: default `MAX_PRELOADED_ROWS`, refused past it, never truncated). Tenancy is CARRIED only when
72
- both entities are scoped by a column of that same name, else the related read refuses
73
- (`X_TENANCY_UNSCOPED`). Reach is the `database()` call's `RelatedTables`; `select()` widens with each
74
- preloaded relation's local key; attachment copies (`{ ...row }`); only `page()`/`all()`/`one()`
75
- preload.
72
+ (`preload-ceiling.ts`: default `MAX_PRELOADED_ROWS`, refused past it, never truncated). Tenancy is CARRIED only between
73
+ entities scoped by a same-named column (`X_TENANCY_UNSCOPED`). Full rule: `docs/history/entity.md`.
76
74
  - **Every repository method attributes its statement** through `@ultimat3/db`'s
77
75
  `withStatementAttribution(entity.$name, op, send)` via `attributed(op, send)`, `op` declared once per
78
- method and passed to the plan builders; `writeRows(op, …)` for the three inserts; `aggregate` names
79
- the function. Never entered with no observer. A preloaded relation is attributed to the related
80
- entity. `memoryRepo` sends no statement. `pg-driver-attribution.test.ts` — one case per method.
76
+ method and passed to the plan builders; `memoryRepo` reports one event per operation (`memory-observed.ts`)
77
+ under the same pair. `pg-driver-attribution.test.ts` — one case per method. Full rule: `docs/history/entity.md`.
81
78
  - **The two N+1 codes are owned here** (`X_N_PLUS_ONE_QUERY`, `X_N_PLUS_ONE_WRITE`, `n-plus-one.ts`):
82
79
  it detects nothing, takes a `StatementLoop` verdict, and derives the fix from `relationMap()`
83
80
  (`preloadsFor()`), falling back to the `in` form. `N_PLUS_ONE_THRESHOLD` (5) is shared by `x dev`'s
@@ -88,43 +85,30 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
88
85
  sort VALUES. **The tiebreak takes the LAST DECLARED key's direction**; `seekSql` sends a ROW
89
86
  comparison when every key sorts one way and the or-chain otherwise
90
87
  (`pg-driver-cursor.live.test.ts`).
91
- - **`inBatches(size)` is the same page in a loop** (`batch.ts`): the handle is its own iterator
92
- (`close()` = `AsyncGenerator.return()`), `.cursor` is advanced before the yield, no empty batch, and
93
- three refusals on the chain (size, `limit()` alongside, an order no cursor can carry — judged by
94
- exported `totalOrder`).
88
+ - **`inBatches(size)` is the same page in a loop** (`batch.ts`). Full rule: `docs/history/entity.md`.
95
89
  - **A grouped count means one thing in both drivers, written in `count-by.ts`**: both call
96
- `groupColumnOf` before the statement and `countsFrom` after the rows. Groupable kinds are a closed
97
- set (`uuid`, `text`, `char`, `boolean`, `integer`, `bigint`); the bound is a REFUSAL at
98
- `MAX_GROUPS + 1`, never a truncation; absent is not `0`; NULL is one group keyed `null`; the order
99
- is applied after the rows are in, never in SQL; `group_value` / `group_count` are fixed aliases.
100
- The refusal names a groupable column of THIS entity, never `x entity explain`.
90
+ `groupColumnOf` before the statement and `countsFrom` after the rows. The bound is a REFUSAL at
91
+ `MAX_GROUPS + 1`, never a truncation; it names a groupable column of THIS entity, never
92
+ `x entity explain`. Full rule: `docs/history/entity.md`.
101
93
  - **The codec is `@ultimat3/core`'s**, reached through `cursorFor(entity, plan, row, id)` and
102
- `seekFrom(entity, plan)`; **`assertSeekable` runs in `planFor`**, before a statement exists. A
94
+ `seekFrom(entity, plan)`; **`assertSeekable` runs in `planFor`**, before a statement exists, and refuses `jsonb`, `bytea`, `array` (`UNSEEKABLE`). A
103
95
  `timestamptz` sort key is refused when its `<column>$US` alias would pass 63 bytes. A cursor is bound
104
96
  to `planScope(plan)` (entity, filters, sort); a bad one is `X_CURSOR_INVALID`, never "from the top".
105
97
  - **A NULLABLE sort key orders** `asc nulls last` / `desc nulls first`; the cursor tags
106
98
  `~` for NULL and `!` for present; the seek reaches NULLs; a nullable key has no row comparison. Only a
107
99
  nullable PRIMARY-KEY column is refused. `pg-null-order.live.test.ts`.
108
- - **A `timestamp` cursor carries MICROSECONDS**: `seekPrecision` projects `(col at time zone
109
- 'UTC')::text as "<col>$US"`; `sortPrecision` reads it; the seek binds a six-digit ISO instant
110
- (`col < $1::timestamptz`). `instant.ts` is where the two representations meet.
111
- `pg-cursor-precision.live.test.ts`.
112
- - **The four aggregates** (`sum`, `avg`, `min`, `max`) share `aggregate.ts`
113
- (`aggregate-fold.ts` memory, `aggregate-decode.ts` Postgres): never a float; `avg` at `AVG_SCALE` (6),
114
- half away from zero; `null` for an empty set. Refused: `min`/`max` on text, `avg` over money
115
- (`X_AGGREGATE_UNSUPPORTED`), mixed currency or scale (`X_AGGREGATE_MIXED_CURRENCY`, two causes, two fixes), a money total
116
- past ±2^53. **`approximateCount()` is `reltuples`**; a filtered chain or tenant-scoped entity is
100
+ - **A `timestamp` cursor carries MICROSECONDS** (`seekPrecision`, `instant.ts`). Full rule: `docs/history/entity.md`.
101
+ - **The four aggregates** (`sum`, `avg`, `min`, `max`) share `aggregate.ts`:
102
+ never a float; `null` for an empty set; refusals `X_AGGREGATE_UNSUPPORTED`,
103
+ `X_AGGREGATE_MIXED_CURRENCY`. Full rule: `docs/history/entity.md`. **`approximateCount()` is `reltuples`**; a filtered chain or tenant-scoped entity is
117
104
  `X_APPROXIMATE_COUNT_FILTERED`; `null` for an unanalysed table.
105
+ - **`series`, views**: `time-bucket.ts` (zone rules, measured), `view-repo.ts` (read-only); `time-bucket.test.ts`, `view-entity.test.ts`.
118
106
  - **`json()` / `arrayOf()` are filterable** (`contains`, `contained-by`, `overlaps`, `has-key`) with
119
107
  Postgres' measured meaning in `containment.ts`; `has-key` emits `operator(pg_catalog.?)` (indexable)
120
108
  and takes a STRING key, else no rows; a NULL array element equals nothing. No jsonpath operator.
121
109
  - **A GIN index is declarable** (`using: 'gin'`; the set is `@ultimat3/db`'s `INDEX_METHODS`); absent
122
110
  is `btree` byte for byte; the method joins the name discriminator only when declared; GIN cannot be
123
111
  unique or ordered (refused here). `pg-containment.live.test.ts`.
124
- - **Full-text search** (`search.ts`): one generated `tsvector` per entity, `websearch_to_tsquery`, a
125
- closed `SEARCH_LANGUAGES` set spliced, `coalesce(col, '')` per source, NOT NULL vector column; the
126
- memory driver refuses (`X_SEARCH_IN_MEMORY`); relevance is not a served order.
127
- `pg-search.live.test.ts`.
128
112
 
129
113
  ## Do not regress — schema and DDL
130
114
 
@@ -147,14 +131,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
147
131
  - **Invariants run twice and only ONE side is rendered here**: app-side `assertInvariants`, and the
148
132
  CHECK/UNIQUE DDL is `@ultimat3/db`'s `invariant-ddl.ts` reading `$describe()`. An untranslatable predicate is `kind: 'assert'`, `sql: null`.
149
133
  **`InvariantDescription.columns` is projected** (`describe-invariant.test.ts`).
150
- - **The two halves must AGREE, term by term** (`expr.ts`): `matches(/…/i)` is `~*`, other flags are
151
- refused (`matchOperator`); `minLength` counts code points; `trimmed()` strips U+0020 only (`btrim`);
152
- `atLeast`/`eq(<number>)` judge by the column's KIND (`invariantColumns(name, columns)`), so a
153
- `bigint()`/`decimal()` row compares by its digits. **`isNull()`/`isNotNull()` are the only
154
- total members**; `iff(a, b)` renders `(a) = (b)` — measured as the safe direction
155
- (`pg-invariant-null.live.test.ts`); `iff` is a function, and a `unique` operand is refused with the
156
- `c.unique([…])` fix built through `JSON.stringify`. One app-only operand makes the whole rule
157
- app-only.
134
+ - **The two halves must AGREE, term by term** (`expr.ts`, `pg-invariant-null.live.test.ts`); one app-only operand makes the whole rule app-only. Full rule: `docs/history/entity.md`.
158
135
  - **A `matches()` pattern reaches the CHECK as the same string or is refused**
159
136
  (`pattern-portability.ts`, every entry measured; `pg-invariant-pattern.live.test.ts` asserts kept
160
137
  constructs agree and refused ones disagree).
@@ -165,21 +142,13 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
165
142
  `invariantColumns()` stays for JS callers. **`Invariant<T>.holds` is a method** (variance).
166
143
  - **Nothing is interpolated into SQL** (`pg-sql.ts`): `raw()` only for `asc|desc` and the `default`
167
144
  cell. The seek operator is chosen in TypeScript.
168
- - **Money is three columns**: `<p>_minor bigint`, `<p>_currency char(3)`, `<p>_scale integer null`.
169
- NULL scale decodes to an ABSENT key (never `0`); the scale bound is `@ultimat3/schema`'s
170
- (`isMoneyScale`, `scaleCheck`); scale is not addressable (`MONEY_PARTS`). The currency bound is
171
- schema's in both halves (`isCurrencyCode`, `CURRENCY_CODE_PATTERN`; `currency-check.live.test.ts`).
172
- `MoneyValue` is re-exported from `@ultimat3/schema` — one declaration (`type-pins.ts`). `parseMinor`
173
- refuses past ±2^53; `narrowMoney` runs in both drivers. `money({ columns })` merges per part and
174
- `scale: null` means a two-column amount (`decodeRow` branches on `$meta.kind === 'money'`).
175
- - **Timestamps are `timestamptz`.** A naive timestamp stays inexpressible.
145
+ - **Money is three columns**: `<p>_minor bigint`, `<p>_currency char(3)`, `<p>_scale integer null`; a NULL scale decodes to an ABSENT key, never `0`. Full rule: `docs/history/entity.md`.
176
146
  - **A `jsonb` value is bound `::text::jsonb`** (load-bearing); an array is written as a quoted `{…}`
177
147
  literal, read by its DECLARATION (`pg-array-decode.ts`); `column-matrix-fixture.ts` is every kind
178
148
  on both drivers. **Wide types are normalised in `$parse`** (`bigint()`, `decimal()`: strings).
179
149
  - **Every framework member on an entity is `$`-prefixed.** Row types are derived, never re-declared.
180
150
  **A branded id survives to the signature** (`IdOf<Row>`). `type-pins.ts` enforces all of it.
181
151
  - **`$parse` tells absence from `null`** (`raw === undefined ? defaultValue(...) : raw`).
182
- - `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim** (`index.test.ts`).
183
152
  - **A rejected column value is rendered as its SHAPE** (`got(value)` over `describeValue`); only
184
153
  `parseMinor`'s two messages echo a provably numeric value.
185
154
  - **A refusal raised before any entity exists carries an EDIT** (`refuse.ts`,
@@ -199,45 +168,66 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
199
168
  never stamp; all or nothing; the incoming rows too; before the statement exists
200
169
  (`write-tenancy-parity.test.ts`, `pg-driver-tenancy.live.test.ts`). With the conflict-target rule, a
201
170
  cross-tenant upsert is unrepresentable.
171
+ - **A reference stays inside its tenant** (`sameTenantOf`, `describe.ts`, at describe time): `sameTenant` is `null` on the record and ABSENT on the column otherwise (the hash). `entity()` never resolves a thunk at declaration. Memory (`memory-same-tenant.ts`) refuses only a target FOUND elsewhere. `same-tenant-parity.test.ts`, `same-tenant.live.test.ts`.
172
+ - **`lock`/`lockKey`**: optional on `Repo`, required on `Table` (`table-locks.ts`, `X_LOCK_UNSUPPORTED`); `pg-lock.ts`, `memory-lock.ts` (`update`/`delete` queue too, the filtered writes do not), one tenant derivation (`lock-key.ts`, via `readPlan`). `lock-parity.test.ts`, `lock.live.test.ts`, `lock-seams.test.ts`. Why: `docs/history/entity.md` (2026-11-02).
202
173
  - **`crossTenant(reason, fn)` is the ONE way to read across tenants** — an `asyncContext` scope from
203
174
  core (`scripts/async-context-guard.ts`), `CROSS_TENANT_SCOPE` (`tenancy:cross`) proven at the call
204
175
  and at every plan (`X_TENANCY_CROSS_DENIED`), refused outside a request, blank reason `X_INVARIANT`.
205
176
  - **`deleteWhere` / `updateWhere` are the only filtered writes**: empty filter `X_WRITE_UNFILTERED`,
206
177
  empty patch `X_PATCH_EMPTY` (undefined values dropped first); soft delete respected; both return a
207
- count; rows come back only when a JS-only invariant needs them (`hasJsOnlyInvariant`), counted first
208
- and refused past `MAX_ASSERTED_ROWS` (50,000) in BOTH drivers; on Postgres that statement and its
209
- judgement share a transaction (`judged-write.ts`, `update` too), so a refusal rolls back. `updateStatement`'s `returning` is
210
- required.
178
+ count; a JS-only invariant is judged
179
+ in the statement's transaction (`judged-write.ts`), refused past `MAX_ASSERTED_ROWS`. Full rule: `docs/history/entity.md`.
180
+ - **A write past a coded abort is refused on both drivers**: `refuseAbandoned()` in `writing()` and each `memoryRepo` write. `write-abandon.test.ts`.
211
181
  - **Every instant the write path stamps comes from `entityNow()`** (`clock.ts`, `ctx.clock`).
212
- **`touch()` in `query.ts` is the ONE place `onUpdateNow()` columns are stamped.**
182
+ **`touch()` in `touch.ts` is the ONE place `onUpdateNow()` columns are stamped.**
183
+ **Both repositories parse a patch at the door** (`$parsePatch`): `$parse`'s issues, no default, a fragment refused.
213
184
  - **A many-row write is one statement** (`insertStatement` builds every insert; `bulk-write.ts`
214
185
  decides in property space): a collision overwrites everything but the conflict target, the PK and the
215
186
  soft-delete stamp; the target must be a declared non-partial unique constraint (PK, `$indexes`, or a
216
187
  `kind: 'unique'` invariant); under `'update'` the tenant column must be in the target
217
188
  (`X_TENANCY_UNSCOPED`), a repeated target and an uneven batch are refused; NULL in the target
218
189
  collides with nothing; memory judges the whole batch first; past `MAX_BIND_PARAMETERS` it is several
219
- statements.
190
+ statements in ONE transaction (`bulk-write-send.ts`). **`db.*.upsertAll` and `seed.upsert`
191
+ overwrite what the caller NAMED** (`overwrittenBy`). Full rule: `docs/history/entity.md`.
192
+ - **`memoryTransactor().run` is ambient** (`memory-journal.ts`): a write naming no `tx` joins it; a nested
193
+ commit hands its undos and effects up; a write after the root ended is `X_DB_TRANSACTION_CLOSED`; a
194
+ row lock taken with no tx is held to the root. `memory-journal.test.ts`, `memory-transactor.test.ts`.
195
+ - **`shared: '<why>'` is the one way an untenanted child of a tenant-owned table says so**
196
+ (`assertShared`, `describe.ts`); the reach check is the CLI's `exercise-static.ts`
197
+ (`X_ENTITY_TENANT_UNDECLARED`), over `EntityDescription.shared`.
198
+ - **`scan: '<why>'`**: a table read whole (`assertScan`).
220
199
  - **A seed is replayable**: `SeedContext.insert` is one `upsertAll(…, { onMatch: 'nothing' })` per
221
- call; a generated PK the row does not name is refused. `upsert(entity, { by }, values)` keys on a
222
- natural key, reads first for the report, writes one `on conflict … do update`, preserves `createdAt`.
223
- **The environment guard is the CLI's** (`seedTiersFor`; `run()` stays permissive). **A dry run is
224
- the real run rolled back** through `Driver.transactor()` (REQUIRED on the interface) — one rule,
225
- never a per-verb lookup.
200
+ call; a generated PK the row does not name is refused; `upsert` reads with `includeDeleted` and
201
+ SKIPS a soft-deleted match. **A dry run is the real run rolled back**
202
+ (`Driver.transactor()`, REQUIRED). Full rule: `docs/history/entity.md`.
226
203
  - **`setRowObserver` reports committed row changes above the driver** — one per process (returns the
227
204
  replaced one), applied by `database()`, one comparison per write when unset, `before` only when the PK
228
205
  is `id`, a filtered write is `onBulk`. Not a second change-feed path.
229
206
  - **`appendOnly: true`** (`append-only.ts`): `appendOnlyRepo` wraps OUTSIDE `sealedRepo` in both
230
- drivers (seed `upsert` appends, never rewrites); `update`/`delete`/`*Where` and an
231
- `upsertAll` not `onMatch: 'nothing'` reject before any statement (`X_ENTITY_APPEND_ONLY`); a soft-delete,
232
- `onUpdateNow()` or machine column is refused at declaration; `$describe().appendOnly` is `true` or
233
- absent. No bypass, backfills included. `append-only{,-parity}.test.ts`.
207
+ drivers; every rewrite rejects before any statement (`X_ENTITY_APPEND_ONLY`). No bypass, backfills
208
+ included. Full rule: `docs/history/entity.md`.
234
209
  - **A state machine is the MECHANISM only** (`.transitions()` on `enumerated()`, a mapped
235
- `TransitionTable<S>`): a terminal state is one with no outgoing moves; the move is ONE statement with
236
- `from` in the predicate (`pg-transition.live.test.ts`: 1 winner of 20); `X_STATE_CONFLICT` is read
237
- after the refusal from a tenant-scoped `findById`; no DDL beyond `enumerated()`'s CHECK; a machine
238
- column may not be nullable; `whyNot` asks unknown → terminal → legal list. **`move.observed`
210
+ `TransitionTable<S>`): the move is ONE statement with
211
+ `from` in the predicate (`X_STATE_CONFLICT`). Full rule: `docs/history/entity.md`. **`move.observed`
239
212
  pins what a decision read** (#702, `transition-pins.ts`).
240
213
 
214
+ ## Do not regress — lifecycle (retain, history, counters)
215
+
216
+ | Declaration | Here | The rule |
217
+ |---|---|---|
218
+ | `retain: { after, on }` | `retain.ts` | no default window; `on` a `timestamp()` some index STARTS with (`X_ENTITY_RETAIN_UNINDEXED`); refused beside `appendOnly` (`X_ENTITY_RETAIN_INVALID`). The sweep is `@ultimat3/jobs`' `retainTarget` |
219
+ | `history: true` | `history.ts`, `history-actor.ts` | `<table>_history` is a REAL registered entity (`$historyOf`), tenant column copied without its key. Written by `@ultimat3/db`'s trigger; `stampHistoryActor` sets `x.actor`/`x.request` once per transaction, never on a pinned repository — no transaction records `null`. `bigint`/`decimal`/`timestamp` columns ride `text`/`instants`: the image is the memory driver's strings (`history-values.test.ts`) |
220
+ | `countedIn` on a key | `counter.ts` | the target is a NOT NULL `integer()` of the entity the key points at (Postgres' `NULL + 1` stays NULL), by its single primary key (`X_ENTITY_COUNTS_INVALID`); resolved at describe time, memoised on `registryGeneration()` |
221
+ | all three, described | `lifecycle.ts` | each `$describe()` key ABSENT unless declared, so no other entity's hash moves |
222
+ | the memory driver | `memory-lifecycle.ts` | mirrors both triggers through the table's raw store, so a unit test answers what Postgres answers |
223
+
224
+ - **Known gap: the row observer does not see trigger writes.** A history row and a counter move
225
+ are the trigger's (memory: `memoryLifecycle`'s raw store), never a repository write, so
226
+ `setRowObserver` reports neither; a live counter on PGlite or memory moves on the parent's next
227
+ write. The WAL replicator does report them. As of 2026-11.
228
+ - **`read-capture.ts` is failure capture's read half**: `observedRepo`'s `findById`/`findMany` hand
229
+ rows to core's `recordFailureFact` — sealed properties dropped first; one check when off.
230
+
241
231
  ## Do not regress — sealed columns
242
232
 
243
233
  - **`sealed-repo.ts` is the ONE seam**: `memoryRepo()` and `postgresRepo()` both return through
@@ -252,14 +242,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
252
242
  - **Purpose is `entity:<table>.<column>`, physical names** (`sealed.ts`). **Sealing is not on
253
243
  `$describe()`**: it changes no DDL, so it must not move the drift hash — the manifest and
254
244
  `x doctor` read `sealedFields(entity)`. Only `check`/`length` leave the description.
255
- - **A sealed property on a repository row is SERVER-ONLY** — own, readable, writable, NOT
256
- enumerable (`serverOnly`, `sealed.ts`; set by `openRow`). Every generic serialiser omits it, which
257
- is what holds a `query`'s rows, a loose `output`, a log line, an island prop, a job payload and a
258
- cache tier — none of them parses a row. So ABOVE the seam a row is copied with `copyRow` /
259
- `pickRow` (descriptors), never `{ ...row }`, `Object.assign({}, row)` or `out[k] = row[k]`:
260
- `preload.ts`, `select()` and `touch()` in `query.ts`. `sealRow` reads each sealed property BY NAME,
261
- so a row passed back whole is written whole. A required one missing on insert is `sealedMissing`
262
- (`X_INVARIANT_VIOLATED`, from `$parse` and from the seam), naming the spread. `sealed.test.ts`.
245
+ - **A sealed property on a repository row is SERVER-ONLY**, not enumerable (`sealed.ts`): above the seam copy with `copyRow` / `pickRow`, never `{ ...row }`. Full rule: `docs/history/entity.md`.
263
246
  - **Nothing sealed leaves**: `$schema` (node and parsed value; typed `WireRow` on `Entity`,
264
247
  `unknown` on `EntityCore`), `$view` (`X_ENTITY_SEALED_IN_VIEW`), `rowsOf`, the row observer, and
265
248
  the repository row itself (above). `sealed-egress.test.ts` here and one per consuming package.
@@ -282,20 +265,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
282
265
 
283
266
  ## Files
284
267
 
285
- Each file's header states its one job. The map, by area: `types.ts` (derivation; `COLUMN_KINDS`),
286
- `column.ts` / `columns.ts` / `columns-data.ts` / `array-element.ts` / `enum-column.ts` /
287
- `column-values.ts` (builders, `columnName`, `narrowMoney`), `refuse.ts`, `expr.ts` / `invariants.ts`,
288
- `entity.ts` / `describe.ts` / `index-name.ts` / `search.ts`, `state-machine.ts` / `transition*.ts`,
289
- `feature-errors.ts`, `view.ts` / `row-schema.ts` / `record-projection.ts` / `record-key.ts` /
290
- `rows-of.ts` / `record-table.ts` / `record.ts`, `entity-error.ts` / `errors.ts`, `query.ts` /
291
- `database.ts` / `clock.ts`, `memory-match.ts` / `like.ts` / `numeric-compare.ts` / `unique-constraints.ts` / `repo.ts` /
292
- `memory-repo.ts` / `tenancy.ts` /
293
- `cross-tenant.ts`, `plan.ts` / `cursor.ts` / `batch.ts`, `pg-driver.ts` / `coalesce.ts` /
294
- `batch-read.ts` / `bulk-write.ts` / `count-by.ts` / `jit-preload.ts` / `preload.ts` /
295
- `preload-ceiling.ts` / `judged-write.ts` / `pg-sql.ts` /
296
- `pg-row.ts`, `row-observer.ts` / `write-tag.ts` (a keyed request's write names itself in the WAL via
297
- `pg_logical_emit_message`), `registry.ts` / `relations.ts` / `n-plus-one.ts` / `seed.ts`,
298
- `type-pins.ts`, `live-registry-cleanup.test.ts`.
268
+ Each file's header states its one job; `ls packages/entity/src` is the map, kept by no hand.
299
269
 
300
270
  ## Commands
301
271
 
package/README.md CHANGED
@@ -4,6 +4,8 @@ An entity is **columns + the invariants that hold for every row**. The row type
4
4
  the columns: `type Post = typeof posts.$row`. Declare the shape once — repos, migrations, the
5
5
  admin screen, cache tags and the manifest are all projections of that one call.
6
6
 
7
+ `bun add @ultimat3/entity` · guide: [Entities And Migrations](https://github.com/developerz-ai/ultimate/wiki/Entities-And-Migrations)
8
+
7
9
  ```ts
8
10
  import {
9
11
  entity, enumerated, integer, invariant, text, timestamp, url, uuid,
@@ -46,6 +48,7 @@ directly — `output: PostView` — and the projected type flows on to the clien
46
48
  | Keys are checked twice | unknown key ⇒ `tsc` error, and `X_INVARIANT_VIOLATED` at declaration for a JS caller |
47
49
  | Values are the columns' | each key is parsed by the column that declared it; no second copy of the rule |
48
50
  | Nothing is invented | a view projects a row that exists — an absent required key is missing data, not a default |
51
+ | It is an input too | `input: posts.$view(['title'])` publishes `maxLength` and refuses past it with one issue PER KEY — `path: ['title']`, a `rule` from the column's fact (`string.max` `{ max }`, `enum` `{ values }`, `required`, else `type` `{ type }`) and the column's own sentence (`expected at most 200 characters, got 300`) — so a form binds it to the field. There is no `$input.pick()`: this is it |
49
52
  | `$name` | `posts.view.id_title_coverUrl_status` — stable, and legal as an OpenAPI `components.schemas` key |
50
53
  | It projects | a view carries the schema IR (`node`) the same way every `t.*` schema does, so `output: PostView` reaches OpenAPI, the MCP tool's `outputSchema` and the typed client — `As of 2026-09-05`; before that it validated fine and was refused at registration with `X_SCHEMA_UNSUPPORTED`, so every app re-declared its outputs as `t.object`. Column kinds publish the ROW value's shape: `bigint()`/`decimal()` are strings, `date()` is a `YYYY-MM-DD` string, `money()` is `{ minor, currency, scale? }`, `enumerated()` is an `enum`, a nullable column is `anyOf: [<type>, null]` and still required |
51
54
 
@@ -113,7 +116,7 @@ never runs, while the subpath retains the projection, the key and the registry a
113
116
  | `text({ max })`, `integer()`, `boolean()`, `url()` | `text`/`integer`/`boolean` + CHECK | format is enforced by the database too. `url()` judges the STORED text against its CHECK (`^https?://`), so leading whitespace, a tab inside the scheme and `https:/a.b` are refused here rather than by a raw `23514` |
114
117
 
115
118
  Chain: `.primaryKey()` · `.nullable()` · `.unique()` · `.default(v)` · `.defaultNow()` ·
116
- `.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()` · `.column(name)` ·
119
+ `.onUpdateNow()` · `.references(() => other.id, { onDelete, crossTenant })` · `.tenant()` · `.column(name)` ·
117
120
  `.sealed()` (`text()` only).
118
121
  Physical names are derived from the property key (`orgId` → `org_id`); a name is written once, or
119
122
  never — `.column()` is the exception, and it exists for tables this framework did not create.
@@ -376,6 +379,10 @@ written into the statement rather than inherited from the server, and the same s
376
379
  in both drivers; the cursor carries "this row had none" as a position of its own. The one ordering
377
380
  still refused is a nullable **primary-key** column, which no tiebreak can make total.
378
381
 
382
+ `orderBy` on a `json`, `bytes` or array column is refused where the plan is built, at any row count
383
+ (`X_INVARIANT_VIOLATED`). It has no position a cursor can carry: it paged wrongly or threw past page
384
+ 1. Order by a scalar.
385
+
379
386
  A `timestamp()` sort key is carried at the column's own **microsecond** precision, not at the
380
387
  millisecond a JS `Date` holds: the read projects the instant as text beside the column and the seek
381
388
  binds all six digits. Without it a `desc` page over rows sharing one millisecond — which
@@ -638,6 +645,18 @@ await db.orders.transition('status', id, {
638
645
  What is deliberately **not** here: who may make a move, what happens on arrival, an approval chain,
639
646
  a reason code. Those differ per app — wrap `transition()` in your own function and put them there.
640
647
 
648
+ ## A series of time buckets, and views
649
+
650
+ `As of 2026-11`. `series({ on, unit, timeZone, value })` is the grouped count per bucket of time —
651
+ `minute`/`hour`/`day`/`week`/`month`, in the IANA zone named (required), truncated as Postgres'
652
+ `date_trunc(unit, col, zone)` truncates, by the same rules in the memory driver (`time-bucket.ts`).
653
+ `value` is `'count'` or `{ sum: column }`; ascending; past 1000 buckets `X_ENTITY_SERIES_INVALID`.
654
+ Optional on `Repo` (`X_ENTITY_SERIES_UNSUPPORTED` for a hand-written one).
655
+
656
+ `entity(name, { view: { sql, materialized? }, columns })` is a read-only relation: `$viewQuery`,
657
+ `$describe().view`, every write `X_ENTITY_VIEW_READ_ONLY` (`view-repo.ts`, applied through
658
+ `appendOnlyRepo`). Full reference: [Entities and migrations](https://github.com/developerz-ai/ultimate/wiki/Entities-And-Migrations#a-count-per-day-is-a-series).
659
+
641
660
  ## Counting by a column
642
661
 
643
662
  `As of 2026-08`. `count()` answers one number, so a screen or a backfill that needs one per row
@@ -671,7 +690,7 @@ not `n`.
671
690
 
672
691
  ```ts
673
692
  db.posts.delete(id); // by a single primary key
674
- db.posts.update(id, { title }); // ditto
693
+ db.posts.update(id, { title }); // ditto; named columns validate as insert does
675
694
 
676
695
  db.likes.deleteWhere({ postId, userId }); // -> 1 · the only way to unlike
677
696
  db.likes.deleteWhere({ postId }); // -> n · every like on that post
@@ -691,6 +710,7 @@ and never unwritten.
691
710
  | Returns | the **number of rows affected**, so "nothing matched" is distinguishable from "it worked" |
692
711
  | 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 |
693
712
  | Empty patch | `X_PATCH_EMPTY`. Counting rows for a statement that set nothing is the same silent no-op, one argument along |
713
+ | Patch values | named columns pass their schemas, the same issues `insert` raises (`X_INVARIANT_VIOLATED`), before any statement, on both drivers. Absent stays absent, `null` clears a nullable column |
694
714
  | 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 |
695
715
  | 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 |
696
716
  | `onUpdateNow()` | stamped by `touch()`, the same helper `update(id, patch)` uses — one place, so the two can never disagree about `updatedAt` |
@@ -698,6 +718,8 @@ and never unwritten.
698
718
 
699
719
  ## Append-only entities
700
720
 
721
+ `retired: ['legacy']` is the first half of a column drop under a rolling deploy: delete the column from `columns` and name its physical spelling here. It leaves the row type, the projection and every insert, so the new release never reads it, while `x db gen` writes only a marker, so the old release keeps working. Remove it from the list in the next release and `x db gen "drop legacy" --allow-destructive` drops it. A name still in `columns` is `X_INVARIANT_VIOLATED`. → `wiki/Migrations-And-Backfills.md`.
722
+
701
723
  `appendOnly: true` declares a table whose rows are written once and never changed or removed — a
702
724
  ledger, an audit trail, an event log. Held twice, like an invariant: the repository refuses, and
703
725
  `x db gen` emits a trigger that refuses the same statement in raw SQL.
@@ -735,8 +757,22 @@ await db.entries.update(first.id, { amountMinor: 0 }).catch((refused: unknown) =
735
757
  | Tenancy | unchanged — the refusal comes first and judges no tenant; an insert is scoped exactly as on any entity |
736
758
  | The seam | `appendOnlyRepo(entity, repo)`, wrapped outside `sealedRepo` by both drivers — so `database()`, a seed's `driver.repo()` and a hand-held `postgresRepo(entity)` agree. A driver written outside this package returns through `appendOnlyRepo(entity, sealedRepo(entity, repo))` |
737
759
 
760
+ ## Retention, history and counters
761
+
762
+ Three declarations, each a trigger or a purge target `x db gen` and `purge()` compile — full rules in `wiki/Entities-And-Migrations.md#lifecycle-retention-history-counters`.
763
+
764
+ | Declaration | Does |
765
+ |---|---|
766
+ | `retain: { after: '90d', on: 'createdAt' }` | `purge({ entities: 'declared' })` (`@ultimat3/jobs`) deletes older rows in batches, across tenants. The window is required; `on` is an indexed `timestamp()` (`X_ENTITY_RETAIN_UNINDEXED`); never beside `appendOnly` (`X_ENTITY_RETAIN_INVALID`). `$retain` is the checked declaration. `x verify` refuses one no purge covers (`X_ENTITY_RETAIN_UNSWEPT`) |
767
+ | `history: true` · `{ retain: { after } }` | `<table>_history`, a registered entity (`$history`; it carries `$historyOf`), written by trigger with a before and after image of every change — `updateWhere`, cascades and raw SQL included. Its repository refuses every rewrite (`X_ENTITY_APPEND_ONLY`), the table too, except a retention sweep's delete |
768
+ | `references(() => posts.id, { countedIn: () => posts.likeCount })` | the child's trigger keeps the parent's `integer()` equal to its row count, in the write's transaction; a soft-deleted child is not counted. Declared on the key because the child already names its parent: a thunk on the parent naming the child would make each declaration the other's type. `X_ENTITY_COUNTS_INVALID` when the target is no integer or the key points at no single primary key |
769
+
770
+ Memory parity: the memory driver records history and moves counters in its repository (`memory-lifecycle.ts`); `history.test.ts` and `counter.test.ts` are the parity tables.
771
+
738
772
  ## Writing many rows
739
773
 
774
+ Each call is all or nothing, `As of 2026-11-02`.
775
+
740
776
  ```ts
741
777
  await db.tags.insertAll(names.map((name) => ({ orgId, name }))); // one statement, n rows
742
778
 
@@ -761,14 +797,14 @@ caller counts what it actually inserted.
761
797
  | | |
762
798
  |---|---|
763
799
  | 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 |
764
- | 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 |
800
+ | What a collision overwrites | every column the batch's rows **named** (defaults `$parse` fills reach a new row only, so an unnamed `createdAt` or `label` keeps its stored value; `onUpdateNow()` columns move; a hand-held repository reads the rows it was given, or `UpsertArgs.overwrites`), 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 |
765
801
  | 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 |
766
802
  | 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 |
767
803
  | 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 |
768
804
  | 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 |
769
- | 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 |
805
+ | 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. A property present with `undefined` is absent here as in every write: refused beside a row writing a value, and left out of the set list when no row writes one |
770
806
  | Nulls | a null in the conflict target collides with nothing, in both drivers — a Postgres unique index is `NULLS DISTINCT` |
771
- | 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 |
807
+ | Size | past 65535 bind parameters the batch is several statements inside one transaction (a savepoint inside an open `withTransaction`), never one the server refuses. A failure in any chunk leaves no row of the batch, in both drivers; one chunk stays one autocommit statement, no `BEGIN`. Partial progress is yours to ask for: split the batch and call once per part |
772
808
  | 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` |
773
809
 
774
810
  ## Relations are the foreign keys, read twice
@@ -858,12 +894,36 @@ pattern × value — never a backtracking regex), a unique declared as `invarian
858
894
  is enforced by memory exactly as an index is, and `in` takes a list or nothing (a scalar matches no rows; a list carrying
859
895
  a `null` also matches the NULL rows, which `col = null` never does).
860
896
 
897
+ **A memory transaction ends as a Postgres one does**, `As of 2026-11-05` (`memory-transactor.ts`,
898
+ `memory-journal.ts`):
899
+
900
+ | Rule | What it means |
901
+ |---|---|
902
+ | ambient | a memory-store write inside `memoryTransactor().run` that names no `tx` joins it, as one inside `withTransaction` does |
903
+ | nested | a nested commit hands its undos **and its effects** up: an `onCommit` effect runs once the root commits, and never after the root rolls back |
904
+ | ended | a write after the root ended — a promise `work` forgot to await — is `X_DB_TRANSACTION_CLOSED`, as a statement on a finished Postgres transaction is |
905
+ | locks | a row or key lock taken with no `tx` inside a memory transaction is a savepoint of it, held to the root's end |
906
+ | observers | the row observer and the action's cache bust wait for the root commit, and see nothing of a rollback |
907
+
908
+ `memoryJournal()` answers the open memory transaction of this async chain, or `undefined` — the
909
+ memory twin of `@ultimat3/db`'s `currentTx()`, which `@ultimat3/action`'s commit scope reads.
910
+
861
911
  `compareByKind(kind, left, right)` and `sameValueOfKind(kind, left, right)` are that rule, exported
862
912
  (`As of 2026-10`): `-1 | 0 | 1` and a boolean for two values of one column of a declared
863
913
  `ColumnKind`, NULL sorting largest. `@ultimat3/query`'s live matcher and in-memory source call them
864
914
  rather than keep a comparator of their own; `compare-parity.test.ts` runs the rule against both
865
915
  drivers.
866
916
 
917
+ `testDriver()` is the third, and only a test takes it (`As of 30.0`): memory until
918
+ `@ultimat3/db`'s `setDbClient` installs a client, `postgresDriver()` through that client while one
919
+ is, decided per repository call — `database()` resolves each table's repository once at module
920
+ load, so a driver chosen once would never follow a test's migrated database. `reset()` empties the
921
+ memory half only. `transactor().run` takes the side installed when it starts, and every call inside
922
+ it stays there: a client installed or cleared mid-transaction is `X_TEST_DRIVER_CLIENT_CHANGED`,
923
+ never a transaction split across two stores. The scaffolded handle's line is
924
+ `storeMode(env) === 'memory' ? testDriver() : postgresDriver()`; the recipe is
925
+ [Testing](../../wiki/Testing.md#database-state-and-what-a-run-leaves-behind).
926
+
867
927
  `database()` called with no driver takes the process default, and `defaultDriver()` is that same
868
928
  object — the one seam a test harness needs, `As of 2026-08`:
869
929
 
@@ -939,9 +999,9 @@ Nothing new to write: the relation is the `references()` the column already decl
939
999
 
940
1000
  | | |
941
1001
  |---|---|
942
- | 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 |
1002
+ | Served to | a lookup with the same scope key, the same client, and no write since — to any entity — the preload statement *is* the statement it was widened from |
943
1003
  | 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 |
944
- | 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 |
1004
+ | A write | drops everything preloaded, before the statement goes out: a `countedIn` trigger or an `onDelete` action rewrites rows of an entity the statement never named. The next lookup reads its own statement; the one after a write-free stretch preloads again, so a loop that writes every iteration reads each row once, never the rest of the page |
945
1005
  | Held | the ids, never the rows; keyed by context identity, so it dies with the request |
946
1006
  | Declines to the old statement | no request in scope, an id no page indexed, a key that resolved to nothing |
947
1007
  | 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 |
@@ -979,6 +1039,12 @@ deliberate, and a production process pays the one branch the observer seam costs
979
1039
  one number both detectors read *is* here — `N_PLUS_ONE_THRESHOLD` (5), next to the codes whose `fix`
980
1040
  it triggers, so a loop that fails a test and a loop that warns in dev are the same loop.
981
1041
 
1042
+ **Both drivers report**, `As of 2026-11-02`. `memoryRepo` sends no statement, so it reports one event
1043
+ per repository operation through `statementObserver()`, under the pair `postgresRepo` names
1044
+ (`posts.findById`) and with no SQL text. A microtask's point lookups in one request are one event, as
1045
+ `coalesce.ts` makes them one statement. Postgres also serves a sibling lookup off a page it already
1046
+ read (`jit-preload.ts`); memory has no such page, so it counts that lookup and is the stricter driver.
1047
+
982
1048
  ## Tenancy is a guard
983
1049
 
984
1050
  `tenant: 'orgId'` on the entity names the column outright. Omit it and it is inferred — a
@@ -1004,6 +1070,40 @@ string or a path parameter — it is `X_TENANCY_ACTOR_MISMATCH`, refused rather
1004
1070
  overridden, with both values in the cause. `in` on the tenant column is judged the same way: a set
1005
1071
  containing the actor's org is still a set that is not it.
1006
1072
 
1073
+ **The database can hold the same line.** `database.rowSecurity: true` in `app.config.ts` gives
1074
+ every tenant table a forced Postgres policy over this same column (`x db gen "row security"`), so a
1075
+ raw `sql` read of another tenant's row returns nothing. `describeTenancy()` is the projection the
1076
+ generator reads — `{ table, column }` per tenant entity, physical names, beside `describeEntities()`
1077
+ rather than on it, so no schema hash moves. Rules: [Policies and authz](../../wiki/Policies-And-Authz.md#the-databases-own-guard--row-security-as-of-2026-11-06).
1078
+
1079
+ **A reference stays inside its tenant.** Both ends scoped makes `references()` a composite key:
1080
+ `(org_id, post_id) -> posts (org_id, id)`, so a row naming another org's post is refused by the
1081
+ database with `X_DB_FOREIGN_KEY_VIOLATION`, the code and the text a dangling id gets. Memory refuses
1082
+ a target found under another tenant and accepts one not found, because it enforces no foreign key.
1083
+ `crossTenant: '<reason>'` on the option is the stated escape; blank, or on an edge that was never
1084
+ same-tenant, is `X_INVARIANT_VIOLATED`. `x db gen` adds the `(tenant, id)` unique index the target
1085
+ needs and swaps the key online. [The wiki](../../wiki/Entities-And-Migrations.md#same-tenant-keys).
1086
+
1087
+ **A table with no tenant column that hangs off one with a tenant says why.** `entity('plans', {
1088
+ columns, shared: 'one price list for every org' })` states its rows belong to no tenant. Without
1089
+ it, `x verify`'s `exercise` step reports `X_ENTITY_TENANT_UNDECLARED` for each untenanted entity
1090
+ whose references reach, directly or through others, a tenant-columned one: a row of one tenant
1091
+ readable by id from another. A blank reason is `X_ENTITY_TENANT_UNDECLARED` at `entity()`, and
1092
+ `shared` beside a tenant column is `X_INVARIANT_VIOLATED`. A `shared` entity is not walked through.
1093
+
1094
+ **A table read whole says why.** `x verify`'s `exercise` step plans every statement the pass sent
1095
+ with no index but the declared ones: a sequential scan is `X_QUERY_UNINDEXED`, a limited read that
1096
+ sorts is `X_QUERY_SORT_UNSERVED`, each printing the `indexes: [{ on: [...] }]` line to add.
1097
+ `entity('plans', { columns, scan: 'a closed catalog of 8 rows' })` is the one way a bounded table
1098
+ opts out; blank is `X_INVARIANT_VIOLATED`. A hand-written statement that must scan is
1099
+ `expectedSeqScan('<why>', fn)` from `@ultimat3/db`.
1100
+
1101
+ **A read-modify-write takes a lock.** `db.posts.lock(id, (row, tx) => …)` holds the row for the
1102
+ callback and `db.posts.lockKey(key, (tx) => …)` holds a key of this table and tenant. A transaction
1103
+ each, a savepoint inside an open one, and the lock lasts to the root COMMIT. `fn` writes with
1104
+ `{ tx }`. A hand-written `Repo` without them answers `X_LOCK_UNSUPPORTED`.
1105
+ [The wiki](../../wiki/Entities-And-Migrations.md#locking-a-row-or-a-key).
1106
+
1007
1107
  | Situation | Answer |
1008
1108
  |---|---|
1009
1109
  | actor carries an `orgId` | the plan is scoped to it, derived |
@@ -1045,7 +1145,9 @@ await crossTenant('nightly invite expiry runs for every org', async () => {
1045
1145
  The scope needs the `tenancy:cross` capability on the actor (`scopes: ['tenancy:cross']`), proven
1046
1146
  at the call and again at every plan built inside it, so an impersonated child context cannot
1047
1147
  inherit it: `X_TENANCY_CROSS_DENIED` otherwise. A blank reason is refused — an escape with no
1048
- argument is a pragma. There is no build-time tenancy check in `x verify`, and there cannot
1148
+ argument is a pragma. `setCrossTenantObserver(fn | undefined)` installs the one process-wide
1149
+ witness told each reason once its capability is proven — `x exercise`'s tenant proof reads it to
1150
+ tell a job's declared sweep from a leak; an app has no use for it. There is no build-time tenancy check in `x verify`, and there cannot
1049
1151
  usefully be one: the tenant is a request-time value, so the seam every plan is built through is
1050
1152
  the enforcement.
1051
1153
 
@@ -1101,3 +1203,11 @@ implementation in one package instead of two. No `drizzle-orm` dependency:
1101
1203
  `types.ts` declares the narrow structural column vocabulary this package consumes, so the
1102
1204
  generated SQL stays readable and an agent can self-correct against it. `@ultimat3/cache`
1103
1205
  invalidates by the `entity:<name>` tag.
1206
+
1207
+ ## Supporting exports
1208
+
1209
+ `As of 2026-11-02`.
1210
+
1211
+ | Name | What |
1212
+ |---|---|
1213
+ | `SameTenantKey` | a `references()` whose two ends share a tenant: the physical tenant column on each side, which makes the key composite. `ReferenceDescription.sameTenant` holds it, or `null` |
package/package.json CHANGED
@@ -1,7 +1,17 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "29.4.0",
3
+ "version": "30.0.0",
4
4
  "description": "A table + its domain type + invariants the database also enforces",
5
+ "keywords": [
6
+ "ultimate",
7
+ "bun",
8
+ "framework",
9
+ "entity",
10
+ "orm",
11
+ "postgres",
12
+ "schema"
13
+ ],
14
+ "homepage": "https://github.com/developerz-ai/ultimate/wiki/Entities-And-Migrations",
5
15
  "license": "MIT",
6
16
  "type": "module",
7
17
  "repository": {
@@ -33,9 +43,9 @@
33
43
  "test": "bun test"
34
44
  },
35
45
  "dependencies": {
36
- "@ultimat3/core": "29.4.0",
37
- "@ultimat3/db": "29.4.0",
38
- "@ultimat3/schema": "29.4.0",
39
- "@ultimat3/time": "29.4.0"
46
+ "@ultimat3/core": "30.0.0",
47
+ "@ultimat3/db": "30.0.0",
48
+ "@ultimat3/schema": "30.0.0",
49
+ "@ultimat3/time": "30.0.0"
40
50
  }
41
51
  }
@@ -6,11 +6,14 @@
6
6
  import { appendOnlyColumnRefused, appendOnlyRefused } from './append-only-errors';
7
7
  import type { Repo, UpsertArgs } from './repo';
8
8
  import type { AnyColumn } from './types';
9
+ import { readOnlyRepo } from './view-repo';
9
10
 
10
11
  /** The two members the seam reads — so a driver written outside this package can pass its entity. */
11
12
  export interface AppendOnlySource {
12
13
  readonly $name: string;
13
14
  readonly $appendOnly: boolean;
15
+ /** `entity({ view })`: every write refused. Optional so a hand-built source still passes. */
16
+ readonly $viewQuery?: unknown;
14
17
  }
15
18
 
16
19
  /**
@@ -55,6 +58,10 @@ export const assertAppendOnlyColumns = (
55
58
  * rewrite is not append-only. Changing existing rows is lifting the flag, in a migration.
56
59
  */
57
60
  export const appendOnlyRepo = <Row, R extends Repo<Row>>(entity: AppendOnlySource, repo: R): R => {
61
+ // A view is the stricter rule and the same seam: nothing written, not even an append.
62
+ if (entity.$viewQuery !== undefined && entity.$viewQuery !== null) {
63
+ return readOnlyRepo<Row, R>(entity.$name, repo);
64
+ }
58
65
  if (!entity.$appendOnly) return repo;
59
66
  const name = entity.$name;
60
67
  const refusing: Pick<