@ultimat3/entity 30.9.0 → 31.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 (58) hide show
  1. package/CLAUDE.md +68 -106
  2. package/README.md +180 -8
  3. package/package.json +6 -5
  4. package/src/adapter.ts +65 -0
  5. package/src/batch-read.ts +12 -0
  6. package/src/column.ts +12 -0
  7. package/src/columns.ts +20 -8
  8. package/src/cross-tenant.ts +11 -14
  9. package/src/cursor.ts +3 -0
  10. package/src/database.ts +2 -2
  11. package/src/declared-index.ts +72 -0
  12. package/src/define-driver.ts +156 -0
  13. package/src/describe.ts +5 -0
  14. package/src/driver-delete.ts +62 -0
  15. package/src/driver-errors.ts +80 -0
  16. package/src/driver-kit-types.ts +107 -0
  17. package/src/driver-registry.ts +26 -0
  18. package/src/driver-repo-reads.ts +102 -0
  19. package/src/driver-repo-writes.ts +236 -0
  20. package/src/driver-rows.ts +108 -0
  21. package/src/driver-scope.ts +77 -0
  22. package/src/driver-write-scope.ts +107 -0
  23. package/src/entity-error.ts +10 -0
  24. package/src/entity.ts +40 -54
  25. package/src/enum-column.ts +8 -0
  26. package/src/index.ts +43 -27
  27. package/src/memory-match.ts +35 -5
  28. package/src/memory-observed.ts +16 -11
  29. package/src/memory-repo.ts +46 -99
  30. package/src/memory-seek.ts +12 -25
  31. package/src/memory-unique.ts +7 -7
  32. package/src/memory-update.ts +7 -2
  33. package/src/memory-upsert.ts +102 -0
  34. package/src/partition.ts +140 -0
  35. package/src/personal-data-kind.ts +15 -0
  36. package/src/personal-data.ts +66 -0
  37. package/src/pg-driver.ts +83 -30
  38. package/src/pg-write-sql.ts +39 -4
  39. package/src/plan.ts +2 -0
  40. package/src/predicate-screen.ts +36 -0
  41. package/src/query.ts +13 -4
  42. package/src/registry.ts +3 -0
  43. package/src/row-key.ts +19 -0
  44. package/src/row-observer.ts +38 -90
  45. package/src/search-index-installed.ts +24 -0
  46. package/src/search-index-memory.ts +132 -0
  47. package/src/search-index-postgres.ts +107 -0
  48. package/src/search-index-sync.ts +96 -0
  49. package/src/search-index-version.ts +93 -0
  50. package/src/search-index.ts +168 -0
  51. package/src/search-mirror-batch.ts +133 -0
  52. package/src/search-mirrored-repo.ts +98 -0
  53. package/src/search-repo.ts +91 -0
  54. package/src/search-written-rows.ts +36 -0
  55. package/src/tenant-index.ts +113 -0
  56. package/src/types.ts +12 -0
  57. package/src/write-commit.ts +28 -0
  58. package/src/write-tag.ts +6 -2
package/CLAUDE.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # @ultimat3/entity
2
2
 
3
3
  Columns + invariants; the row type is derived from the columns. Tier 2.
4
+ A rule marked `†` has its full text in [`docs/history/entity-rules.md`](../../docs/history/entity-rules.md).
4
5
 
5
6
  ## Boundary
6
7
 
@@ -16,34 +17,23 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
16
17
  - **`repo.ts` is the contract** (`Repo`, `FindManyArgs`, `Transactor`); `memory-repo.ts` is
17
18
  `memoryRepo()`.
18
19
  - **Two drivers, one meaning.** `memoryDriver()` and `postgresDriver()` share `plan.ts`, `cursor.ts`
19
- and the `Repo` contract. Every bulk or read feature carries BOTH a `*-parity.test.ts` (identical rows
20
- into both drivers, identical output) and a `pg-driver-<feature>.live.test.ts` against a real server.
21
- Both are the bar.
22
- - **`write-parity.test.ts` runs every write against memory AND PGlite**: a patch property present with
23
- `undefined` is ABSENT (only `null` clears); memory refuses a duplicate PK, a duplicate non-partial
24
- unique — index OR `c.unique([…])` invariant, the one list in `unique-constraints.ts` — and a PK
25
- patch onto another row with `X_DB_UNIQUE_VIOLATION` (partial uniques are not checked in memory);
26
- each kind's stored spelling: `docs/history/entity.md`.
27
- `invariant-parity.test.ts` asks the app, the driver and the raw CHECK the same question.
28
- - **`min`/`max` over a `timestamptz` crosses as epoch ms** (`aggregate-time-parity.test.ts`). A whole
20
+ and the `Repo` contract. Every bulk or read feature is proved by `entityDriverConformance`
21
+ (`@ultimat3/testing/conformance`) and, where only a server shows it, a
22
+ `pg-driver-<feature>.live.test.ts`.
23
+ - **`entityDriverConformance` runs every write on memory, PGlite, a server and a `defineDriver`
24
+ store**: `undefined` is ABSENT (only `null` clears); memory refuses duplicate non-partial uniques. †
25
+ - **`min`/`max` over a `timestamptz` crosses as epoch ms** (the suite; under four session zones, `entity-driver-conformance-pglite.test.ts`). A whole
29
26
  row read under a non-UTC session still fails on PGlite — recorded, not fixed.
30
- - **Text ordering: byte (`C`) order is the parity target** — a linguistic production collation is a
31
- known divergence; never `collate "C"` in generated SQL (defeats the index). Create the database with
32
- `LC_COLLATE=C` when byte order must hold.
27
+ - **Text order is the database's collation; memory's is code point, which is `C`** (`byCodePoint`) — a
28
+ linguistic production collation orders differently, by decision (the suite checks it on PGlite only); never `collate "C"` in generated SQL (defeats the
29
+ index). Create the database with `LC_COLLATE=C` when byte order must hold.
33
30
  - **Money's write shape is wider than its row shape** (`MoneyInput` takes a `bigint`; `MoneyValue`
34
31
  holds a `number`); `RowWrite<Row>` types `insert`/`insertAll`/`upsertAll`; `narrowRow` (`columns.ts`)
35
32
  narrows at each write method's ENTRY, before `$assert`/`upsertPlan`. `pg-money-write.live.test.ts`,
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`.**
38
- - **A PREDICATE's meaning is decided by the column's KIND** (`memory-match.ts`); `compareByKind` /
39
- `sameValueOfKind` are EXPORTED — `@ultimat3/query` calls them. **ONE numeric comparison,
40
- `numericOrder`**: `DECIMAL_TEXT` columns by VALUE (`'2.50' = 2.5`), `number`/`bigint` numerically
41
- (`compare-parity.test.ts`). A `uuid` is a value, compared lower-cased and
42
- stored lower-case (`keyOf`, `parseUuid`, `narrowUuid`; text is never narrowed); `LIKE` (`like.ts`)
43
- uses Postgres' default `\` escape, refuses a trailing escape, counts CHARACTERS and is a bounded
44
- two-pointer walk — never a regex; `in` takes a
45
- list or nothing, and a NULL in it emits `(col in (…) or col is null)`; a column the row never NAMED
46
- is NULL for `eq`/`neq`/`in`.
33
+ `pg-money-bind.test.ts`, the suite, `type-pins.ts`.
34
+ - **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`; any other predicate on it, or an unknown operator, is refused at plan time (`predicate-screen.ts`).** Why: `docs/history/entity.md` (31.0.0).
35
+ - **A PREDICATE's meaning is decided by the column's KIND** (`memory-match.ts`; `compareByKind` /
36
+ `sameValueOfKind` exported for query); ONE numeric comparison, `numericOrder`; `LIKE` never a regex. †
47
37
  - **The Postgres driver is proved against a real Postgres** (`pg-driver.live.test.ts`, skipped without
48
38
  `TEST_DATABASE_URL`). A new operator, kind or write path is not done until it round-trips.
49
39
  - **A repository call rejects, never throws synchronously** (`tableFor`'s writes are `async`).
@@ -58,16 +48,10 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
58
48
 
59
49
  ## Do not regress — reading
60
50
 
61
- - **A point lookup batches itself** (`coalesce.ts`): `findById` calls in one microtask of one request
62
- are one `in` statement, keyed by ctx (`WeakMap`) and a scope key covering every input but the id;
63
- it declines rather than guesses; past `MAX_IDS_PER_STATEMENT` it is several statements; no caller is
64
- ever left unsettled (`coalesce.test.ts` races a deadline).
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 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:
69
- `postgresDriver({ jitPreload: false })` — never an `app.config.ts` key. Shared pieces live in
70
- `batch-read.ts`.
51
+ - **A point lookup batches itself** (`coalesce.ts`): one microtask's `findById`s are one `in`;
52
+ no caller left unsettled. †
53
+ - **A page batches the loop it causes** (`jit-preload.ts`): served only under the SAME `scopeKey`,
54
+ dropped by any write; off only via `postgresDriver({ jitPreload: false })`. †
71
55
  - **`preload(name, { max })` shares `batch-read.ts`, keeps no request cache and is BOUNDED**
72
56
  (`preload-ceiling.ts`: default `MAX_PRELOADED_ROWS`, refused past it, never truncated). Tenancy is CARRIED only between
73
57
  entities scoped by a same-named column (`X_TENANCY_UNSCOPED`). Full rule: `docs/history/entity.md`.
@@ -90,38 +74,24 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
90
74
  `groupColumnOf` before the statement and `countsFrom` after the rows. The bound is a REFUSAL at
91
75
  `MAX_GROUPS + 1`, never a truncation; it names a groupable column of THIS entity, never
92
76
  `x entity explain`. Full rule: `docs/history/entity.md`.
93
- - **The codec is `@ultimat3/core`'s**, reached through `cursorFor(entity, plan, row, id)` and
94
- `seekFrom(entity, plan)`; **`assertSeekable` runs in `planFor`**, before a statement exists, and refuses `jsonb`, `bytea`, `array` (`UNSEEKABLE`). A
95
- `timestamptz` sort key is refused when its `<column>$US` alias would pass 63 bytes. A cursor is bound
96
- to `planScope(plan)` (entity, filters, sort); a bad one is `X_CURSOR_INVALID`, never "from the top".
97
- - **A NULLABLE sort key orders** `asc nulls last` / `desc nulls first`; the cursor tags
98
- `~` for NULL and `!` for present; the seek reaches NULLs; a nullable key has no row comparison. Only a
99
- nullable PRIMARY-KEY column is refused. `pg-null-order.live.test.ts`.
77
+ - **The cursor codec is core's**; `assertSeekable` runs in `planFor`; a bad cursor is
78
+ `X_CURSOR_INVALID`, never "from the top". †
79
+ - **A NULLABLE sort key orders** `asc nulls last` / `desc nulls first`; the cursor tags NULL. †
100
80
  - **A `timestamp` cursor carries MICROSECONDS** (`seekPrecision`, `instant.ts`). Full rule: `docs/history/entity.md`.
101
81
  - **The four aggregates** (`sum`, `avg`, `min`, `max`) share `aggregate.ts`:
102
82
  never a float; `null` for an empty set; refusals `X_AGGREGATE_UNSUPPORTED`,
103
83
  `X_AGGREGATE_MIXED_CURRENCY`. Full rule: `docs/history/entity.md`. **`approximateCount()` is `reltuples`**; a filtered chain or tenant-scoped entity is
104
84
  `X_APPROXIMATE_COUNT_FILTERED`; `null` for an unanalysed table.
105
85
  - **`series`, views**: `time-bucket.ts` (zone rules, measured), `view-repo.ts` (read-only); `time-bucket.test.ts`, `view-entity.test.ts`.
106
- - **`json()` / `arrayOf()` are filterable** (`contains`, `contained-by`, `overlaps`, `has-key`) with
107
- Postgres' measured meaning in `containment.ts`; `has-key` emits `operator(pg_catalog.?)` (indexable)
108
- and takes a STRING key, else no rows; a NULL array element equals nothing. No jsonpath operator.
109
- - **A GIN index is declarable** (`using: 'gin'`; the set is `@ultimat3/db`'s `INDEX_METHODS`); absent
110
- is `btree` byte for byte; the method joins the name discriminator only when declared; GIN cannot be
111
- unique or ordered (refused here). `pg-containment.live.test.ts`.
86
+ - **`json()` / `arrayOf()` are filterable** (`containment.ts`); no jsonpath operator. †
87
+ - **A GIN index is declarable** (`using: 'gin'`); never unique or ordered. †
112
88
 
113
89
  ## Do not regress — schema and DDL
114
90
 
115
- - **A relation is a foreign key read a second way** (`relations.ts`): `belongsTo` from own
116
- `references()`, `hasMany` from inbound; `referenceBinding()` resolves a thunk in one place; colliding
117
- names all take their long form; ambiguity is `X_INVARIANT_VIOLATED`. `relationMap()` memoises on
118
- `registryGeneration()`, in one pass. `relationNamed()` refuses with `X_PRELOAD_UNKNOWN_RELATION`
119
- (fix: a real `relationNamed()` call, or `x entities list --json`). `onDelete` rides on both
120
- descriptions; never parse the `"<table>.<column>"` string back.
121
- - **An index is described whole** (`IndexDescription`: columns, uniqueness, predicate, direction,
122
- method); `on: []` refused. **The NAME carries predicate and direction** (8 hex of sha256) only when
123
- one is present; the dedup is on the whole `IndexDef`; names are bounded at 63 BYTES
124
- (`MAX_IDENTIFIER_BYTES`, `index-name.ts`).
91
+ - **A relation is a foreign key read a second way** (`relations.ts`); never parse
92
+ `"<table>.<column>"` back. †
93
+ - **An index is described whole** (`IndexDescription`); names bounded at 63 BYTES
94
+ (`index-name.ts`). †
125
95
  - **Every physical name is checked, including the DERIVED one** (`assertColumnName` at `bindColumn`,
126
96
  and on the entity-name table fallback).
127
97
  - **One resolver decides a physical name: `columnName(property, meta)`** (with
@@ -157,19 +127,12 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
157
127
 
158
128
  ## Do not regress — tenancy and writes
159
129
 
160
- - **A tenant column means every query runs under the ACTING ACTOR's tenant** (`scopedPlan`,
161
- `tenancy.ts`, from `tryUseContext()?.actor.orgId`); inference applies when undeclared; **the column
162
- may not be nullable** (`resolveTenantColumn`). A caller-supplied `orgId` is an assertion
163
- (`X_TENANCY_ACTOR_MISMATCH` when different, `eq` only, refused never overridden); an actor with no
164
- org is `X_TENANCY_ACTOR_ORG_REQUIRED`; outside a request the caller names the tenant and
165
- `X_TENANCY_UNSCOPED` still refuses an unscoped plan.
166
- - **Tenancy applies to writes in two places**: the plan bounds WHICH rows; `assertRowTenant` judges the
167
- VALUE at every write seam (both drivers), a filtered update judging the PATCH before any row; refuse,
168
- never stamp; all or nothing; the incoming rows too; before the statement exists
169
- (`write-tenancy-parity.test.ts`, `pg-driver-tenancy.live.test.ts`). With the conflict-target rule, a
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).
130
+ - **A tenant column means every query runs under the ACTING ACTOR's tenant** (`scopedPlan`); a
131
+ caller's `orgId` is an assertion (`X_TENANCY_ACTOR_MISMATCH`), never an override. †
132
+ - **Tenancy applies to writes in two places**: the plan bounds WHICH rows; `assertRowTenant`
133
+ judges the VALUE at every write seam — refuse, never stamp. †
134
+ - **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. The suite, `same-tenant.live.test.ts`.
135
+ - **`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`). the suite, `pg-lock-attribution.test.ts`, `lock.live.test.ts`, `lock-seams.test.ts`. Why: `docs/history/entity.md` (2026-11-02).
173
136
  - **`crossTenant(reason, fn)` is the ONE way to read across tenants** — an `asyncContext` scope from
174
137
  core (`scripts/async-context-guard.ts`), `CROSS_TENANT_SCOPE` (`tenancy:cross`) proven at the call
175
138
  and at every plan (`X_TENANCY_CROSS_DENIED`), refused outside a request, blank reason `X_INVARIANT`.
@@ -181,28 +144,35 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
181
144
  - **Every instant the write path stamps comes from `entityNow()`** (`clock.ts`, `ctx.clock`).
182
145
  **`touch()` in `touch.ts` is the ONE place `onUpdateNow()` columns are stamped.**
183
146
  **Both repositories parse a patch at the door** (`$parsePatch`): `$parse`'s issues, no default, a fragment refused.
184
- - **A many-row write is one statement** (`insertStatement` builds every insert; `bulk-write.ts`
185
- decides in property space): a collision overwrites everything but the conflict target, the PK and the
186
- soft-delete stamp; the target must be a declared non-partial unique constraint (PK, `$indexes`, or a
187
- `kind: 'unique'` invariant); under `'update'` the tenant column must be in the target
188
- (`X_TENANCY_UNSCOPED`), a repeated target and an uneven batch are refused; NULL in the target
189
- collides with nothing; memory judges the whole batch first; past `MAX_BIND_PARAMETERS` it is several
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`.
147
+ - **A many-row write is one statement** (`bulk-write.ts`); the conflict target is a declared
148
+ non-partial unique; `upsertAll` overwrites what the caller NAMED. †
192
149
  - **`memoryTransactor().run` is ambient** (`memory-journal.ts`): a write naming no `tx` joins it; a nested
193
150
  commit hands its undos and effects up; a write after the root ended is `X_DB_TRANSACTION_CLOSED`; a
194
151
  row lock taken with no tx is held to the root. `memory-journal.test.ts`, `memory-transactor.test.ts`.
152
+ - **Every tenant table is indexed `(tenant, pk)`** (`tenant-index.ts`, `concurrently`), inferred or
153
+ declared; it replaces the tenant column's own index; none when the PK, an index, a unique or a
154
+ same-tenant key already starts with it.
155
+ - **Custom stores: `defineDriver` from `@ultimat3/entity/adapter` only** — scoped plan in, rows
156
+ re-checked, `X_DRIVER_UNSUPPORTED` per capability; the store owns its schema, `x db`/drift are
157
+ Postgres-only. README "Custom drivers". **One row identity: `rowKey`** (`row-key.ts`).
195
158
  - **`shared: '<why>'` is the one way an untenanted child of a tenant-owned table says so**
196
159
  (`assertShared`, `describe.ts`); the reach check is the CLI's `exercise-static.ts`
197
160
  (`X_ENTITY_TENANT_UNDECLARED`), over `EntityDescription.shared`.
198
161
  - **`scan: '<why>'`**: a table read whole (`assertScan`).
199
- - **A seed is replayable**: `SeedContext.insert` is one `upsertAll(…, { onMatch: 'nothing' })` per
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`.
203
- - **`setRowObserver` reports committed row changes above the driver** — one per process (returns the
204
- replaced one), applied by `database()`, one comparison per write when unset, `before` only when the PK
205
- is `id`, a filtered write is `onBulk`. Not a second change-feed path.
162
+ - **A seed is replayable**; a dry run is the real run rolled back (`Driver.transactor()`). †
163
+ - **`row-observer.ts` emits committed row changes above the driver** as `entity.row` on core's bus
164
+ (`EntityRowEvent`), applied by `database()`; one map read per write with no subscriber, `before`
165
+ only when the PK is `id`, a filtered write is `entity.bulk`. No slot (v31,
166
+ `X_INSTRUMENT_SLOT_FORBIDDEN`). Not a second change-feed path. `crossTenant()` emits
167
+ `entity.cross_tenant` once the capability is proven; `memory-observed.ts` emits `db.statement`
168
+ for the memory driver.
169
+ - **Search mirroring is `search-mirrored-repo.ts`, beneath the observer and at commit**
170
+ (`write-commit.ts`), never the bus; a driver answers a delete's or filtered write's rows only inside
171
+ `collectWrittenRows` (`search-written-rows.ts`). `search-mirror-batch.ts` merges a transaction's ops
172
+ per key; `committed` documents replace tombstones; `rowKey` spells an instant in ISO (`keyOf`). A
173
+ searchable entity's version is STRICTLY increasing per row: `advanceVersion` (memory, `defineDriver`,
174
+ `DriverRepo.update`'s `advance`) and `greatest(…, col + interval '1 millisecond')` (`pg-write-sql.ts`)
175
+ — `search-version-monotonic.test.ts`.
206
176
  - **`appendOnly: true`** (`append-only.ts`): `appendOnlyRepo` wraps OUTSIDE `sealedRepo` in both
207
177
  drivers; every rewrite rejects before any statement (`X_ENTITY_APPEND_ONLY`). No bypass, backfills
208
178
  included. Full rule: `docs/history/entity.md`.
@@ -219,12 +189,13 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
219
189
  | `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
190
  | `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
191
  | 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 |
192
+ | the memory driver | `memory-lifecycle.ts` | mirrors both triggers through the table's raw store |
193
+ | `partitionBy` | `partition.ts` | Postgres' rules, at declaration (`X_ENTITY_PARTITION_INVALID`); SQL in db |
223
194
 
224
195
  - **Known gap: the row observer does not see trigger writes.** A history row and a counter move
225
196
  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.
197
+ `entity.row` reports neither; a live counter on PGlite or memory moves on the parent's next
198
+ write. The WAL replicator reports them. As of 2026-11.
228
199
  - **`read-capture.ts` is failure capture's read half**: `observedRepo`'s `findById`/`findMany` hand
229
200
  rows to core's `recordFailureFact` — sealed properties dropped first; one check when off.
230
201
 
@@ -234,41 +205,32 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
234
205
  `sealedRepo(entity, repo)`, which seals rows and patches going in, opens rows coming out and
235
206
  rewrites or refuses every predicate. A driver never seals for itself; an entity with no sealed
236
207
  column gets its repository back untouched. The cipher is core's (`scripts/seal-calls.ts`).
237
- - **A sealed column's `$parse` passes a sealed string and judges a plaintext**; `decodeRow` parses
238
- none. `sealValue` runs the UNWRAPPED plaintext parser (`$meta.sealed.plaintext`) BEFORE sealing — nothing
239
- after can, and the column's own `$parse` would pass a sealed-shaped lookalike.
208
+ - **A sealed column's `$parse` passes a sealed string and judges a plaintext**; `sealValue` parses
209
+ the plaintext BEFORE sealing. †
240
210
  - **No reading of an unsealed value.** Not sealed is `X_SEAL_INVALID`; existing plaintext is
241
211
  expand → `backfill()` → contract (README). No `legacy` option.
242
- - **Purpose is `entity:<table>.<column>`, physical names** (`sealed.ts`). **Sealing is not on
243
- `$describe()`**: it changes no DDL, so it must not move the drift hash — the manifest and
244
- `x doctor` read `sealedFields(entity)`. Only `check`/`length` leave the description.
212
+ - **Purpose is `entity:<table>.<column>`; sealing is not on `$describe()`** (no drift move). †
245
213
  - **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`.
246
214
  - **Nothing sealed leaves**: `$schema` (node and parsed value; typed `WireRow` on `Entity`,
247
215
  `unknown` on `EntityCore`), `$view` (`X_ENTITY_SEALED_IN_VIEW`), `rowsOf`, the row observer, and
248
216
  the repository row itself (above). `sealed-egress.test.ts` here and one per consuming package.
249
- - **Opaque: NULL tests only. `lookup`: also `eq` / `in`, `.unique()`, an unordered index.**
250
- Refused at the type (`SealedKeysOf<C>` on `ReadBuilder`, `type-pins.ts`) AND at runtime
251
- (`X_ENTITY_SEALED_PREDICATE`). A filtered write's FILTER refuses both kinds. Declaration rules
252
- are `sealed-column.ts` (the chain) and `sealed-declaration.ts` (the entity).
217
+ - **Opaque: NULL tests only. `lookup`: also `eq` / `in`** — refused at the type and at runtime
218
+ (`X_ENTITY_SEALED_PREDICATE`). †
253
219
 
254
220
  ## Do not regress — records and tests
255
221
 
256
- - **An entity row on the client is a RECORD, derived** (`row-schema.ts`, `ENTITY_BRAND` =
257
- `Symbol.for('ultimate.entity')` on the NODE, re-branded by the five copying methods; a partial row is
258
- never a record — `rows-of.test.ts`). `rowsOf` answers type → record key → row (null-prototype).
259
- `persist` defaults `false`, read only off the projection. **The browser path is
260
- `@ultimat3/entity/record`**, whose modules import `entity-error.ts`, never `errors.ts`
261
- (`record-bundle.test.ts`).
222
+ - **An entity row on the client is a RECORD, derived** (`row-schema.ts`); the browser path is
223
+ `@ultimat3/entity/record`, importing `entity-error.ts`, never `errors.ts`. †
262
224
  - Never throw a bare `Error` — use `errors.ts`. `X_DB_DRIFT` is `@ultimat3/db`'s, factory included.
263
225
  - **Tests restore the registry with a FILE-scope `afterAll(() => { clearRegistry(); })`** — matched on
264
226
  exact text by `live-registry-cleanup.test.ts`; never inside a skippable suite's teardown.
265
227
 
266
228
  ## Files
267
229
 
268
- Each file's header states its one job; `ls packages/entity/src` is the map, kept by no hand.
230
+ Each file's header states its job; `ls packages/entity/src` is the map.
269
231
 
270
232
  ## Commands
271
233
 
272
- `bun test packages/entity` · `bun run --filter @ultimat3/entity typecheck`
234
+ `bun test ./packages/entity/` · `bun run --filter @ultimat3/entity typecheck`
273
235
 
274
236
  Why each rule above is shaped the way it is: [`docs/history/entity.md`](../../docs/history/entity.md).
package/README.md CHANGED
@@ -165,7 +165,7 @@ export async function passwordFor(email: string): Promise<string | null> {
165
165
  | `entity.$schema` (an action's or query's `output`) | absent from the node and from the parsed value; the type is `WireRow` — the row without it |
166
166
  | `entity.$view([...])` | `X_ENTITY_SEALED_IN_VIEW` |
167
167
  | a record (`rowsOf`, `recordProjection`, the `persist: true` browser store) | dropped from every collected row; `projection.sealed` lists them |
168
- | a change reported to `setRowObserver` | dropped from `before` and `after` |
168
+ | an `entity.row` event | dropped from `before` and `after` |
169
169
  | `decodeRow` on a write-ahead-log row | the stored string, never the plaintext (`@ultimat3/realtime` drops the property before a change leaves) |
170
170
  | a repository row serialised whole — a `query`'s rows, a loose `output`, a log line, an error's `meta`, an island prop, a job payload, a cache tier | absent: the property is **server-only** (below) |
171
171
  | `{ token: row.token }` — the secret named by hand | leaves. Naming it is the one read there is |
@@ -585,6 +585,54 @@ await db.posts.where({ orgId }).search(term).orderBy('createdAt', 'desc').limit(
585
585
  | `memoryDriver()` **refuses** it — `X_SEARCH_IN_MEMORY` | stemming, stop words and a phrase parser are not a JS token comparison, and an answer memory could give is one Postgres would contradict. Assert a search in a `.live.test.ts` |
586
586
  | relevance is **not** an order the chain serves | `ts_rank` is a computed value and a cursor carries columns; the order is the one you declared, and it pages |
587
587
 
588
+ ## Search index (the `searchIndex` port)
589
+
590
+ `.searchable()` columns are searched through one port, `SearchIndex` (`index` / `remove` / `query`
591
+ / `list`, every call tenant-scoped by `{ entity, tenant }`; `list` leaving `tenant` out is every
592
+ tenant's, the sweep's maintenance read).
593
+
594
+ | Index | What it is |
595
+ |---|---|
596
+ | `postgresSearchIndex()` | the default: the generated `tsvector`; `index`/`remove` send nothing (the server keeps the vector), `query` is the repository's own `matches` read — tenancy, actor check and soft delete included. Hits in primary-key order (no rank). `list` pages the table itself |
597
+ | `memorySearchIndex()` | the reference to copy and the unit-test index: words, case-folded, every word required; no stemming |
598
+ | Meilisearch / Typesense | the app's: implement `SearchIndex`, prove it with `behavesLike(searchIndexConformance, …)` from `@ultimat3/testing/conformance`, return it as `searchIndex` from `runtime.ts` |
599
+
600
+ ```ts
601
+ import { database, entity, searchIndex, text, timestamp, uuid } from '@ultimat3/entity';
602
+
603
+ const posts = entity('posts', {
604
+ columns: {
605
+ id: uuid().primaryKey(),
606
+ orgId: uuid().tenant(),
607
+ title: text().searchable('A'),
608
+ updatedAt: timestamp().defaultNow().onUpdateNow(), // the document's version
609
+ },
610
+ });
611
+ const db = database({ posts });
612
+
613
+ export const find = async (orgId: string, q: string) => {
614
+ const hits = await searchIndex().query({ entity: 'posts', tenant: orgId, term: q, limit: 20 });
615
+ const page = await db.posts.where({ orgId }).search(q).page(); // the same port, one path
616
+ return { hits, page };
617
+ };
618
+ ```
619
+
620
+ | Rule | Detail |
621
+ |---|---|
622
+ | `.search(term)` asks the installed index | built-in: the `matches` predicate in the statement (today's SQL). Any other: ids first (at most `MAX_PAGE_SIZE`), then the chain reads those rows with its own tenancy, soft delete, order and cursor |
623
+ | a document has a **version** | the row's `onUpdateNow()` instant, epoch ms. A searchable entity indexed by anything declares one — `updatedAt: timestamp().defaultNow().onUpdateNow()` — or `searchDocumentOf` and `reindex()` refuse (`X_INVARIANT_VIOLATED`) |
624
+ | the version is STRICTLY increasing per row | a write of a searchable entity — a soft delete included — stores its version no earlier than the stored one + 1 ms: Postgres `greatest($now, updated_at + interval '1 millisecond')` in the `update` and the upsert's `on conflict` set; the memory driver in its merge (`advanceVersion`); a `defineDriver` store in ITS write, atomically per row — `DriverRepo.update`'s `advance` argument, and `advanceVersion(entity, stored, merged, (p) => plan.set.includes(p))` in its own `upsert` (`entityDriverConformance` refuses a store that skips either). So two writes inside one millisecond are two versions, and the index keeps the second. The column may run ahead of the clock by a millisecond per same-ms write. Other entities' `onUpdateNow()` columns are the clock's reading, unchanged |
625
+ | keep only newer | an index applies a document only when its version is newer than what it holds for that id: a document, or the tombstone `remove(scope, ids, version)` leaves (a delete's: past the row's last version and no earlier than its instant). Equal = replay. EXCEPT a `committed` document — the write path's, `index(docs, { committed: true })` — which replaces a tombstone whatever its version: a key deleted and written again is live at once, inside one millisecond too. `reindex`'s reads are not `committed`, so a stale read never brings a deleted row back |
626
+ | the write path | while an external index is installed (`runtime.ts`'s `searchIndex`, `installSearchIndex`), every COMMITTED write of a searchable entity reaches it: `insert`/`insertAll`/`upsertAll`/`update`/transition/`updateWhere` index the rows written, `delete`/`deleteWhere` (soft or hard) remove them — whatever the key: `id`, another column, composite. Taken when the write runs, merged per key per transaction (a removal then a document of one key sends both, in that order; a document then a removal, the removal), sent at COMMIT on one chain per index, so the index hears this process's writes in commit order (`search-mirror-batch.ts`). No bus subscription: a rollback indexes nothing |
627
+ | the rows of a delete or filtered write | the driver hands them over (`returning *` on Postgres) ONLY for a searchable entity under an external index — every other write stays a count |
628
+ | a failed mirror never fails the write | logged as `entity.search-index.write-failed` (`entity`, `op`, `code`, `cause`, `fix`); `reindex()` (`@ultimat3/jobs`) is the repair, its sweep removing what the table lost |
629
+ | not mirrored, logged the same way | a delete or filtered write past `MAX_ASSERTED_ROWS` rows: Postgres counts first and sends no `returning *` past it (memory and `defineDriver` stores the same) |
630
+ | the built-in index costs nothing | under `postgresSearchIndex()` a write sends the statement it always did: no `returning`, no read |
631
+
632
+ `searchDocumentOf(entity, row)` is the one document builder (only `.searchable()` columns leave).
633
+ `searchVersionColumn` and `searchVersionOf` are on the `.` barrel; `installSearchIndex` is on
634
+ `@ultimat3/entity/adapter`.
635
+
588
636
  ## A state machine over a column
589
637
 
590
638
  `.transitions()` on an `enumerated()` column. The states are yours; the machine is the framework's.
@@ -769,6 +817,60 @@ Three declarations, each a trigger or a purge target `x db gen` and `purge()` co
769
817
 
770
818
  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
819
 
820
+ ## Partitioned tables — `partitionBy`
821
+
822
+ A range by one `timestamp()`/`date()` column, one partition per `interval`, bounds in UTC. `As of 2026-11-09`.
823
+
824
+ ```ts
825
+ import { entity, text, timestamp, uuid } from '@ultimat3/entity';
826
+
827
+ export const events = entity('events', {
828
+ columns: { id: uuid(), at: timestamp().defaultNow(), orgId: uuid().tenant(), kind: text() },
829
+ primaryKey: ['id', 'at'], // must hold the partition column
830
+ partitionBy: { column: 'at', interval: 'month', ahead: 3, retain: { after: '400d' } },
831
+ });
832
+ ```
833
+
834
+ | Question | Answer |
835
+ |---|---|
836
+ | Refused at declaration (`X_ENTITY_PARTITION_INVALID`) | key or a unique index/invariant without the column (Postgres' rule), a column that is no `timestamp()`/`date()`, an interval outside `day`·`week`·`month`·`year`, `ahead` not 0–1000, a bad `retain.after`, row `retain` beside it, a `view`, a table name over 53 bytes |
837
+ | `x db gen` | `create table … partition by range ("at")`, then `select ultimate_partitions(…)` — the current partition and `ahead` more, judged at APPLY time, so a migration replayed a year later still has this month's |
838
+ | Partition names | `<table>_pYYYYMMDD`, the lower bound. `week` is ISO (Monday) |
839
+ | Maintenance | `partitions()` from `@ultimat3/jobs`: a job plus a daily UTC `.schedule` task. Per table `maintainPartitions` (`@ultimat3/db`) creates `ahead` under a 5 s `lock_timeout` (a blocked step is `X_DB_LOCK_TIMEOUT`, retried — never a queue of inserts behind it), then retires each partition `retain.after` passed: marked, `detach … concurrently`, dropped; a run that died midway is resumed. Only `_pYYYYMMDD` children are touched |
840
+ | A row past every partition | refused by Postgres (`23514`). No default partition, by decision: it forbids `detach … concurrently`, and creating a month it caught rows for scans and moves them under ACCESS EXCLUSIVE. Size `ahead` so `ahead × interval` outlasts the longest scheduler outage you would tolerate (default 3); a failing `partitions()` run dead-letters loudly long before |
841
+ | Repository, tenancy, cursors | unchanged — partitions are storage. The memory driver has none to simulate |
842
+ | Turning it on for an existing plain table | refused at migrate time by the function (`X_MIGRATION_PARTITION_CHANGE`): Postgres cannot partition in place. Taking it off a partitioned table is refused by `x db gen` (same code) |
843
+ | Changing `interval` later | refused by `x db gen` (`X_MIGRATION_PARTITION_CHANGE`): the existing partitions would overlap the new width |
844
+ | Moving rows in (a new entity replacing a plain or re-widened one) | enqueue `partitions()` once with `{ from: '<oldest row, ISO>' }` — it creates every partition back to that row's and sweeps nothing — then `backfill()` the copy. Rows older than `retain.after` are dropped by the next scheduled run |
845
+ | A reference INTO it | a composite key by hand; a same-tenant key needs `crossTenant: '<why>'` — `wiki/Migrations-And-Backfills.md#partitioned-tables` |
846
+
847
+ ## Personal data (GDPR)
848
+
849
+ `.personalData(kind)` marks a column as holding personal data. `kind` is one of `identifier` (ties
850
+ rows to a person: a handle, a national id), `contact` (reaches one: email, phone, address) or
851
+ `free-text` (may hold either: a bio, a message).
852
+
853
+ ```ts
854
+ import { entity, text, uuid } from '@ultimat3/entity';
855
+
856
+ export const members = entity('members', {
857
+ columns: {
858
+ id: uuid().primaryKey(),
859
+ email: text().unique().personalData('contact'),
860
+ bio: text({ max: 400 }).nullable().personalData('free-text'),
861
+ },
862
+ });
863
+ ```
864
+
865
+ - Changes no DDL and no `$describe()` hash: marking an existing column is not a migration.
866
+ - Every chain link after the marker keeps it.
867
+ - `personalDataOf(entity)` lists the marked columns (`property`, physical `column`, `kind`,
868
+ `columnKind`, `nullable`); `personalDataEntities()` walks the registry, each entity with its
869
+ `tenantColumn`. `@ultimat3/jobs`' `accountExport`/`accountErase` read these.
870
+ - Not wired into log redaction: `redactKeys()` (core) is by key name, and a `free-text` `name` column
871
+ would redact every `name` log field. An app wanting it calls
872
+ `redactKeys(personalDataEntities().flatMap((e) => e.columns.map((c) => c.property)))`.
873
+
772
874
  ## Writing many rows
773
875
 
774
876
  Each call is all or nothing, `As of 2026-11-02`.
@@ -903,7 +1005,7 @@ a `null` also matches the NULL rows, which `col = null` never does).
903
1005
  | 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
1006
  | 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
1007
  | 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 |
1008
+ | observers | `entity.row` and the action's cache bust wait for the root commit, and see nothing of a rollback |
907
1009
 
908
1010
  `memoryJournal()` answers the open memory transaction of this async chain, or `undefined` — the
909
1011
  memory twin of `@ultimat3/db`'s `currentTx()`, which `@ultimat3/action`'s commit scope reads.
@@ -911,8 +1013,13 @@ memory twin of `@ultimat3/db`'s `currentTx()`, which `@ultimat3/action`'s commit
911
1013
  `compareByKind(kind, left, right)` and `sameValueOfKind(kind, left, right)` are that rule, exported
912
1014
  (`As of 2026-10`): `-1 | 0 | 1` and a boolean for two values of one column of a declared
913
1015
  `ColumnKind`, NULL sorting largest. `@ultimat3/query`'s live matcher and in-memory source call them
914
- rather than keep a comparator of their own; `compare-parity.test.ts` runs the rule against both
915
- drivers.
1016
+ rather than keep a comparator of their own; `compare-by-kind.test.ts` pins the rule, and
1017
+ `@ultimat3/testing`'s `entityDriverConformance` holds every driver to it.
1018
+
1019
+ Text orders by the DATABASE's collation. Memory orders by code point, which is `C` (PGlite's
1020
+ default): a server created with a linguistic collation (`en_US.utf8`) orders text differently, by
1021
+ decision — `collate "C"` in generated SQL would stop a default-collation index serving the sort.
1022
+ Create the database with `LC_COLLATE=C` where byte order must hold.
916
1023
 
917
1024
  `testDriver()` is the third, and only a test takes it (`As of 30.0`): memory until
918
1025
  `@ultimat3/db`'s `setDbClient` installs a client, `postgresDriver()` through that client while one
@@ -958,6 +1065,71 @@ unpinned repository is the shape that joins the ambient one.
958
1065
  Every value is bound to `$n` and every identifier is resolved through the entity, so a column
959
1066
  name can only be one the entity declared and a row value can never become SQL.
960
1067
 
1068
+ ## Custom drivers — `@ultimat3/entity/adapter`
1069
+
1070
+ A store that is not Postgres (Mongo, Cassandra, a key-value store) is a `Driver` built with
1071
+ `defineDriver`, imported from the `./adapter` subpath — never a hand-written `Repo`.
1072
+
1073
+ ```ts
1074
+ import type { EntityCore } from '@ultimat3/entity';
1075
+ import type { DriverFilter, DriverRepo } from '@ultimat3/entity/adapter';
1076
+ import { defineDriver, entityUniqueViolation, rowKey, selectRows } from '@ultimat3/entity/adapter';
1077
+
1078
+ // A whole key-value store: it knows nothing about tenants, cursors or soft delete — the kit does.
1079
+ const tables = new Map<string, Map<string, unknown>>();
1080
+
1081
+ export const kvDriver = defineDriver({
1082
+ name: 'kv',
1083
+ capabilities: [], // declare only what the store implements
1084
+ repo: <Row>(entity: EntityCore<Row>): DriverRepo<Row> => {
1085
+ const rows = (tables.get(entity.$name) ?? new Map()) as Map<string, Row>;
1086
+ tables.set(entity.$name, rows);
1087
+ const key = (row: Row): string => rowKey(entity, row);
1088
+ const matched = ({ plan }: DriverFilter): Row[] =>
1089
+ selectRows(entity, rows.values(), { plan, after: undefined, limit: rows.size });
1090
+ return {
1091
+ select: async (read) => selectRows(entity, rows.values(), read),
1092
+ count: async (filter) => matched(filter).length,
1093
+ insert: async (batch) => {
1094
+ for (const row of batch) {
1095
+ if (rows.has(key(row))) throw entityUniqueViolation(entity, `${entity.$table}_pkey`);
1096
+ }
1097
+ for (const row of batch) rows.set(key(row), row);
1098
+ return batch;
1099
+ },
1100
+ update: async (filter, patch) => {
1101
+ const hit = matched(filter);
1102
+ for (const row of hit) rows.set(key(row), { ...row, ...patch });
1103
+ return hit.length;
1104
+ },
1105
+ delete: async (filter) => {
1106
+ const hit = matched(filter);
1107
+ for (const row of hit) rows.delete(key(row));
1108
+ return hit.length;
1109
+ },
1110
+ };
1111
+ },
1112
+ });
1113
+ ```
1114
+
1115
+ | `defineDriver` guarantees | How |
1116
+ |---|---|
1117
+ | tenant scoping, the soft-delete filter, the page bound, the PK tiebreak | the plan is built with `plan.ts` BEFORE the store sees it — the store answers a scoped plan |
1118
+ | no cross-tenant row escapes | every returned row is re-checked against the tenant, soft-delete and PK predicates (`X_DRIVER_ROW_OUT_OF_SCOPE`) |
1119
+ | a written row names this actor's tenant and passes its invariants | `assertRowTenant` + `$assert` before the store's `insert`/`update` |
1120
+ | sealed columns, append-only, the abort guard | the same `sealedRepo` / `appendOnlyRepo` / `refuseAbandoned` seams both shipped drivers return through |
1121
+ | a call the store cannot answer is a refusal, not a wrong answer | `X_DRIVER_UNSUPPORTED`, the capability in `meta.capability`; a declared capability with no method is `X_DRIVER_CAPABILITY_MISSING` at definition |
1122
+
1123
+ Capabilities (`DRIVER_CAPABILITIES`): `transactions`, `lock`, `series`, `upsert`, `matches`,
1124
+ `aggregate:countBy`, `aggregate:sum`, `aggregate:avg`, `aggregate:min`, `aggregate:max`,
1125
+ `aggregate:approximateCount`. A duplicate key is `entityUniqueViolation(entity, constraint)` —
1126
+ `X_DB_UNIQUE_VIOLATION`, the code Postgres' `23505` becomes, with no faked SQLSTATE.
1127
+
1128
+ **A custom store owns its own schema, uniqueness and foreign keys.** Migrations, drift and every
1129
+ `x db` command describe Postgres only; `x verify`'s drift step skips (and says why) when no SQL
1130
+ store was constructed (`driverStores()`). The meanings a custom store borrows — `matchesPredicate`,
1131
+ `compareByKind`, the cursor codec — are the memory driver's, which is Postgres' by parity.
1132
+
961
1133
  ## Point lookups batch themselves
962
1134
 
963
1135
  `As of 2026-08`:
@@ -1040,7 +1212,7 @@ one number both detectors read *is* here — `N_PLUS_ONE_THRESHOLD` (5), next to
1040
1212
  it triggers, so a loop that fails a test and a loop that warns in dev are the same loop.
1041
1213
 
1042
1214
  **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
1215
+ per repository operation as a `db.statement` event on core's bus, under the pair `postgresRepo` names
1044
1216
  (`posts.findById`) and with no SQL text. A microtask's point lookups in one request are one event, as
1045
1217
  `coalesce.ts` makes them one statement. Postgres also serves a sibling lookup off a page it already
1046
1218
  read (`jit-preload.ts`); memory has no such page, so it counts that lookup and is the stricter driver.
@@ -1145,9 +1317,9 @@ await crossTenant('nightly invite expiry runs for every org', async () => {
1145
1317
  The scope needs the `tenancy:cross` capability on the actor (`scopes: ['tenancy:cross']`), proven
1146
1318
  at the call and again at every plan built inside it, so an impersonated child context cannot
1147
1319
  inherit it: `X_TENANCY_CROSS_DENIED` otherwise. A blank reason is refused — an escape with no
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
1320
+ argument is a pragma. Each scope opened is an `entity.cross_tenant` event
1321
+ (`{ reason }`) on core's bus once its capability is proven — `x exercise`'s tenant proof subscribes
1322
+ to tell a job's declared sweep from a leak. There is no build-time tenancy check in `x verify`, and there cannot
1151
1323
  usefully be one: the tenant is a request-time value, so the seam every plan is built through is
1152
1324
  the enforcement.
1153
1325
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "30.9.0",
3
+ "version": "31.0.0",
4
4
  "description": "A table + its domain type + invariants the database also enforces",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "exports": {
27
27
  ".": "./src/index.ts",
28
+ "./adapter": "./src/adapter.ts",
28
29
  "./record": "./src/record.ts"
29
30
  },
30
31
  "files": [
@@ -43,9 +44,9 @@
43
44
  "test": "bun test"
44
45
  },
45
46
  "dependencies": {
46
- "@ultimat3/core": "30.9.0",
47
- "@ultimat3/db": "30.9.0",
48
- "@ultimat3/schema": "30.9.0",
49
- "@ultimat3/time": "30.9.0"
47
+ "@ultimat3/core": "31.0.0",
48
+ "@ultimat3/db": "31.0.0",
49
+ "@ultimat3/schema": "31.0.0",
50
+ "@ultimat3/time": "31.0.0"
50
51
  }
51
52
  }
package/src/adapter.ts ADDED
@@ -0,0 +1,65 @@
1
+ // `@ultimat3/entity/adapter` — the driver kit. What a custom store (Mongo, Cassandra, a key-value
2
+ // store) is built from: `defineDriver` and its contract, and the same plan builders, cursor codec,
3
+ // predicate meaning and write guards both shipped drivers use, so a third driver means what they
4
+ // mean. Explicit names only; nothing here is reachable from the main barrel's app-facing surface.
5
+
6
+ export type { AggregateFn } from './aggregate';
7
+ export type { UpsertPlan } from './bulk-write';
8
+ export { conflictKeyOf, conflictKeys, upsertPlan } from './bulk-write';
9
+ export { narrowRow } from './columns';
10
+ export { cursorFor, kindOf, seekFrom, valueAt } from './cursor';
11
+ export type {
12
+ DriverAdvance,
13
+ DriverCapability,
14
+ DriverDefinition,
15
+ DriverFilter,
16
+ DriverRead,
17
+ DriverRepo,
18
+ DriverWrite,
19
+ } from './define-driver';
20
+ export { defineDriver } from './define-driver';
21
+ export {
22
+ DriverError,
23
+ driverCapabilityMissing,
24
+ driverRowOutOfScope,
25
+ driverUnsupported,
26
+ entityUniqueViolation,
27
+ } from './driver-errors';
28
+ export { DRIVER_CAPABILITIES } from './driver-kit-types';
29
+ export type { DriverStoreKind } from './driver-registry';
30
+ export { clearDriverStores, driverStores, noteDriverStore } from './driver-registry';
31
+ export { afterSeek, projectRow, selectRows } from './driver-rows';
32
+ export { compareByKind, matchesPredicate, sameValueOfKind } from './memory-match';
33
+ export type { PostgresDriverOptions } from './pg-driver';
34
+ export {
35
+ DEFAULT_PAGE_SIZE,
36
+ deletePlan,
37
+ idPlan,
38
+ MAX_PAGE_SIZE,
39
+ planFor,
40
+ readPlan,
41
+ singleKeyOf,
42
+ updatePlan,
43
+ } from './plan';
44
+ export { rowKey } from './row-key';
45
+ // The searchIndex port: what an app's Meilisearch/Typesense index implements, and its installer.
46
+ export type {
47
+ SearchDocument,
48
+ SearchHit,
49
+ SearchIndex,
50
+ SearchIndexOptions,
51
+ SearchListEntry,
52
+ SearchListPage,
53
+ SearchListQuery,
54
+ SearchQuery,
55
+ SearchScope,
56
+ } from './search-index';
57
+ export { searchLimitOf, searchListLimitOf } from './search-index';
58
+ export { installSearchIndex } from './search-index-installed';
59
+ export { searchWords } from './search-index-memory';
60
+ // A store's own upsert merges a collision itself: this is the version rule it applies there.
61
+ export { advanceVersion } from './search-index-version';
62
+ export type { Predicate, QueryPlan, SortKey } from './tenancy';
63
+ export { assertRowTenant, scopedPlan } from './tenancy';
64
+ export type { UniqueConstraint } from './unique-constraints';
65
+ export { uniqueConstraints } from './unique-constraints';