@ultimat3/entity 30.9.0 → 31.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +68 -106
- package/README.md +180 -8
- package/package.json +6 -5
- package/src/adapter.ts +65 -0
- package/src/batch-read.ts +12 -0
- package/src/column.ts +12 -0
- package/src/columns.ts +20 -8
- package/src/cross-tenant.ts +11 -14
- package/src/cursor.ts +3 -0
- package/src/database.ts +2 -2
- package/src/declared-index.ts +72 -0
- package/src/define-driver.ts +156 -0
- package/src/describe.ts +5 -0
- package/src/driver-delete.ts +62 -0
- package/src/driver-errors.ts +80 -0
- package/src/driver-kit-types.ts +107 -0
- package/src/driver-registry.ts +26 -0
- package/src/driver-repo-reads.ts +102 -0
- package/src/driver-repo-writes.ts +236 -0
- package/src/driver-rows.ts +108 -0
- package/src/driver-scope.ts +77 -0
- package/src/driver-write-scope.ts +107 -0
- package/src/entity-error.ts +10 -0
- package/src/entity.ts +40 -54
- package/src/enum-column.ts +8 -0
- package/src/index.ts +43 -27
- package/src/memory-match.ts +35 -5
- package/src/memory-observed.ts +16 -11
- package/src/memory-repo.ts +46 -99
- package/src/memory-seek.ts +12 -25
- package/src/memory-unique.ts +7 -7
- package/src/memory-update.ts +7 -2
- package/src/memory-upsert.ts +102 -0
- package/src/partition.ts +140 -0
- package/src/personal-data-kind.ts +15 -0
- package/src/personal-data.ts +66 -0
- package/src/pg-driver.ts +83 -30
- package/src/pg-write-sql.ts +39 -4
- package/src/plan.ts +2 -0
- package/src/predicate-screen.ts +36 -0
- package/src/query.ts +13 -4
- package/src/registry.ts +3 -0
- package/src/row-key.ts +19 -0
- package/src/row-observer.ts +38 -90
- package/src/search-index-installed.ts +24 -0
- package/src/search-index-memory.ts +132 -0
- package/src/search-index-postgres.ts +107 -0
- package/src/search-index-sync.ts +96 -0
- package/src/search-index-version.ts +93 -0
- package/src/search-index.ts +168 -0
- package/src/search-mirror-batch.ts +133 -0
- package/src/search-mirrored-repo.ts +98 -0
- package/src/search-repo.ts +91 -0
- package/src/search-written-rows.ts +36 -0
- package/src/tenant-index.ts +113 -0
- package/src/types.ts +12 -0
- package/src/write-commit.ts +28 -0
- 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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
- **`
|
|
23
|
-
`undefined` is ABSENT (only `null` clears); memory refuses
|
|
24
|
-
|
|
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
|
|
31
|
-
|
|
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-
|
|
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
|
|
39
|
-
`sameValueOfKind`
|
|
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`
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
94
|
-
`
|
|
95
|
-
|
|
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** (`
|
|
107
|
-
|
|
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`)
|
|
116
|
-
`
|
|
117
|
-
|
|
118
|
-
`
|
|
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
|
-
`
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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** (`
|
|
185
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
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
|
-
`
|
|
227
|
-
write. The WAL replicator
|
|
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**; `
|
|
238
|
-
|
|
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
|
|
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
|
|
250
|
-
|
|
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
|
|
257
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
|
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 |
|
|
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-
|
|
915
|
-
|
|
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
|
|
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.
|
|
1149
|
-
|
|
1150
|
-
tell a job's declared sweep from a leak
|
|
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": "
|
|
3
|
+
"version": "31.1.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": "
|
|
47
|
-
"@ultimat3/db": "
|
|
48
|
-
"@ultimat3/schema": "
|
|
49
|
-
"@ultimat3/time": "
|
|
47
|
+
"@ultimat3/core": "31.1.0",
|
|
48
|
+
"@ultimat3/db": "31.1.0",
|
|
49
|
+
"@ultimat3/schema": "31.1.0",
|
|
50
|
+
"@ultimat3/time": "31.1.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';
|