@ultimat3/entity 11.3.0 → 13.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +243 -20
- package/README.md +206 -1
- package/package.json +5 -5
- package/src/aggregate-decode.ts +35 -0
- package/src/aggregate-fold.ts +91 -0
- package/src/aggregate.ts +232 -0
- package/src/batch.ts +2 -1
- package/src/column-values.ts +29 -0
- package/src/column.ts +37 -1
- package/src/columns.ts +5 -46
- package/src/containment.ts +94 -0
- package/src/cursor.ts +100 -14
- package/src/database.ts +1 -1
- package/src/describe.ts +38 -3
- package/src/entity.ts +117 -8
- package/src/enum-column.ts +81 -0
- package/src/errors.ts +16 -0
- package/src/feature-errors.ts +121 -0
- package/src/index-name.ts +83 -0
- package/src/index.ts +42 -4
- package/src/instant.ts +93 -0
- package/src/memory-match.ts +72 -0
- package/src/memory-repo.ts +357 -0
- package/src/pg-driver.ts +94 -7
- package/src/pg-row.ts +38 -1
- package/src/pg-sql.ts +311 -133
- package/src/pg-write-sql.ts +129 -0
- package/src/plan.ts +71 -10
- package/src/query.ts +120 -3
- package/src/registry.ts +16 -0
- package/src/repo.ts +16 -328
- package/src/search.ts +153 -0
- package/src/state-machine.ts +132 -0
- package/src/tenancy.ts +23 -2
- package/src/transition.ts +119 -0
- package/src/types.ts +60 -0
package/CLAUDE.md
CHANGED
|
@@ -16,6 +16,10 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
16
16
|
|
|
17
17
|
## Do not regress
|
|
18
18
|
|
|
19
|
+
- **The CONTRACT and the in-memory DRIVER are two files, `As of 2026-08-24`.** `repo.ts` is
|
|
20
|
+
`Repo`, `Page`, `FindManyArgs` and `Transactor` — what `postgresRepo` implements too — and
|
|
21
|
+
`memory-repo.ts` is `memoryRepo()`. Split when `repo.ts` passed the 500-line ceiling; nothing
|
|
22
|
+
about storing rows in a `Map` belonged in the interface `pg-driver.ts` answers to.
|
|
19
23
|
- **Two drivers, one meaning.** `memoryDriver()` and `postgresDriver()` share `plan.ts` (scope,
|
|
20
24
|
sort order, page size), `cursor.ts` (one codec, values included) and the `Repo` contract, so a
|
|
21
25
|
test that passes against memory says something about Postgres. A guard, an operator or a sort
|
|
@@ -156,11 +160,12 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
156
160
|
mutates**: a preloaded relation is written onto `{ ...row }`, because the in-memory driver
|
|
157
161
|
hands back the row it stores and attaching directly would leak the relation into the table
|
|
158
162
|
itself. **Preloading terminals only**: `page()`, `all()` and `one()` resolve every named
|
|
159
|
-
relation; `count()`, `countBy()
|
|
163
|
+
relation; `count()`, `countBy()`, the aggregate terminals and `plan()` do not, since none reads a
|
|
164
|
+
row to attach one to.
|
|
160
165
|
- **Every repository method attributes the statement it sends, and each op is named exactly
|
|
161
166
|
once.** `postgresRepo`'s `attributed(op, send)` wraps `findById`, `findMany`, `insert`,
|
|
162
|
-
`insertAll`, `upsertAll`, `update`, `delete`, `deleteWhere`, `updateWhere`, `count`
|
|
163
|
-
`
|
|
167
|
+
`insertAll`, `upsertAll`, `update`, `delete`, `deleteWhere`, `updateWhere`, `count`, `countBy`,
|
|
168
|
+
`aggregate` and `approximateCount` — every method, not a subset — through `@ultimat3/db`'s
|
|
164
169
|
`withStatementAttribution(entity.$name, op, send)`. Each method declares `const op = 'findById'`
|
|
165
170
|
(or its own name) once, and that same local is what everything else downstream of it gets too:
|
|
166
171
|
the plan builder (`idPlan(entity, id, options, op)`, `readPlan(entity, args, op)`,
|
|
@@ -187,6 +192,9 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
187
192
|
have been alone. **This is the one rule the two drivers do not share**, and not a drift:
|
|
188
193
|
`memoryRepo` sends no statement, so there is nothing for a pair to name — the parity bar
|
|
189
194
|
(`*-parity.test.ts`) applies to what a call *answers*, and attribution changes no answer.
|
|
195
|
+
**`aggregate` names itself by the FUNCTION, not by the method**: "50x aggregate on members" does
|
|
196
|
+
not say which one, and `min` and `sum` are different statements with different costs, so the op
|
|
197
|
+
is `'sum'`/`'avg'`/`'min'`/`'max'`.
|
|
190
198
|
`pg-driver-attribution.test.ts` is the pin: a client that reads `statementAttribution()` at send
|
|
191
199
|
time, one case per method — a twelfth method added without `attributed` is a failing test, not a
|
|
192
200
|
review comment — plus the coalesced flush, the sibling preload, a relation's own read, a chunked
|
|
@@ -244,6 +252,18 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
244
252
|
`FindManyArgs` or the builder; the primary key is always the last sort key, so the order is
|
|
245
253
|
total. The cursor carries the sort **values**, not just an id — seeking by an id that was
|
|
246
254
|
deleted between two requests would restart pagination at the top.
|
|
255
|
+
- **The tiebreak takes the LAST DECLARED key's direction — decided 2026-08-24.** `totalOrder`
|
|
256
|
+
appended the primary key `asc` unconditionally, so `orderBy('createdAt', 'desc')` ran
|
|
257
|
+
`created_at desc, id asc`. `IndexInit.order` is ONE direction for a whole index, so that pair was
|
|
258
|
+
an order this framework's own DSL **cannot declare an index for**, whatever the author wrote. It
|
|
259
|
+
also decided the seek's shape: a mixed order has no row comparison. Measured on Postgres 16 over
|
|
260
|
+
20,000 rows with an index on `(org, at desc, id desc)` — `(at, id) < ($1, $2)` plans as an Index
|
|
261
|
+
Only Scan carrying the whole seek as one Index Cond, while the or-chain the mixed order forces
|
|
262
|
+
plans as a BitmapOr of two index scans plus a Sort over everything they matched. So `seekSql`
|
|
263
|
+
sends the **row comparison** when every key sorts the same way and the spelled-out or-chain only
|
|
264
|
+
when they do not; a caller who wants the mixed order still writes it — naming the key themselves
|
|
265
|
+
is what turns the append off — and `pg-driver-cursor.live.test.ts` walks both shapes against a
|
|
266
|
+
real server. One key stays a scalar comparison: `(("id") > ($1))` is the same plan spelled worse.
|
|
247
267
|
- **`inBatches(size)` is that same page in a loop, and the loop owns it.** `batch.ts` holds no
|
|
248
268
|
driver of its own: a batch is the `findMany` the chain would have sent at that position, so
|
|
249
269
|
filters, tenancy, soft delete, the projection and every `preload()` mean there what they mean in
|
|
@@ -267,8 +287,8 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
267
287
|
- **A grouped count means one thing in both drivers, and `count-by.ts` is where that one thing is
|
|
268
288
|
written.** `countBy(column)` is the aggregate a `count()` per row is the N+1 of, so both drivers
|
|
269
289
|
call `groupColumnOf` before their statement exists and `countsFrom` after their rows are in — a
|
|
270
|
-
rule added to `pg-driver.ts` or to `repo.ts` alone is exactly the drift that file exists
|
|
271
|
-
prevent. **Groupable kinds are a closed set**: `uuid`, `text`, `char`, `boolean`, `integer`,
|
|
290
|
+
rule added to `pg-driver.ts` or to `memory-repo.ts` alone is exactly the drift that file exists
|
|
291
|
+
to prevent. **Groupable kinds are a closed set**: `uuid`, `text`, `char`, `boolean`, `integer`,
|
|
272
292
|
`bigint`. A `timestamptz` is a `Date`, a `jsonb` is an object and `money` is two physical columns
|
|
273
293
|
— a `Map` compares a non-primitive key by identity, so any of those would file rows under a key
|
|
274
294
|
no caller can look up again and the result would be a map that only ever answers `undefined`. The
|
|
@@ -297,13 +317,120 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
297
317
|
rows `count()` counts.
|
|
298
318
|
- **The codec is `@ultimat3/core`'s, and both drivers reach it through exactly two functions**:
|
|
299
319
|
`cursorFor(entity, plan, row, id)` and `seekFrom(entity, plan)` in `cursor.ts`. Both call
|
|
300
|
-
`assertSeekable`, so
|
|
301
|
-
|
|
302
|
-
|
|
320
|
+
`assertSeekable`, and so does `planFor` — **the load-bearing one, `As of 2026-08-24`**. An
|
|
321
|
+
ordering that cannot carry a position — a nullable key, an undeclared column, a money property
|
|
322
|
+
named without `.minor`/`.currency` — is refused where the PLAN is built, before a statement
|
|
323
|
+
exists. Refusing it only where the cursor is minted made the refusal depend on the TABLE:
|
|
324
|
+
`cursorFor` runs only when a page found a row past its limit, so `orderBy('publishedAt', 'desc')
|
|
325
|
+
.limit(20)` over a nullable column was green on fifteen seeded rows for as long as the suite
|
|
326
|
+
existed and `X_INVARIANT_VIOLATED` on the first read past twenty in production. That file's own
|
|
327
|
+
doc comment claimed the opposite for two majors. `assertBatchable` has always judged `inBatches()`
|
|
328
|
+
this way. This package owns only
|
|
303
329
|
what a cursor is *bound* to — `planScope(plan)`: the entity, its filters and its sort order,
|
|
304
330
|
hashed. Not the page size (a bigger next page is the same query) and not `select` (a projection
|
|
305
331
|
cannot move a row). A cursor that fails either the signature or the scope is `X_CURSOR_INVALID`;
|
|
306
332
|
it must never decode to "start from the top", which is what the old codec's `null` did.
|
|
333
|
+
- **A NULLABLE sort key orders, `As of 2026-08-24` — `asc nulls last` / `desc nulls first`, and
|
|
334
|
+
that is `@ultimat3/query`'s spelling read rather than invented.** It was refused outright for
|
|
335
|
+
three majors while the sibling package had defined NULL ordering all along: two pagination
|
|
336
|
+
systems in one framework disagreeing about whether a nullable column is orderable, which is the
|
|
337
|
+
ambiguity axiom 1 forbids, and it made the canonical listing in
|
|
338
|
+
[`docs/architecture/06-data-layer.md`](../../docs/architecture/06-data-layer.md) unwritable in the
|
|
339
|
+
language that page documents. Four parts. **NULL's place is WRITTEN DOWN**, never inherited from
|
|
340
|
+
the server's default, so a driver whose default differs cannot reopen the divergence. **The
|
|
341
|
+
cursor can say "absent"**: a key is one character of tag then the value — `~` alone is NULL, `!`
|
|
342
|
+
prefixes a present one — so a `text` column holding the four characters `null` encodes as `!null`
|
|
343
|
+
and can never be read as an absence, which a bare sentinel would. **The seek reaches the NULLs**:
|
|
344
|
+
descending, a NULL position is `col is not null` (every value follows it under `nulls first`);
|
|
345
|
+
ascending, a value position is `(col > $1 or col is null)` and a NULL position DROPS its own term,
|
|
346
|
+
because nothing sorts after a NULL under `nulls last` and the alternative is SQL the planner has
|
|
347
|
+
to defeat on every page. The `or col is null` is emitted only on a column that can hold one.
|
|
348
|
+
**And a nullable key has NO row comparison**: `(a, b) < ($1, $2)` is UNKNOWN when either side
|
|
349
|
+
holds a NULL, so every NULL row would be excluded from the page the ordering puts it on —
|
|
350
|
+
`rowComparable` therefore wants one direction *and* not-null columns. What is left of the old
|
|
351
|
+
refusal is the one case with no total order: a nullable PRIMARY-KEY column, reachable only
|
|
352
|
+
through `primaryKey: [...]`, where `null = null` is unknown and two such rows are one position to
|
|
353
|
+
the seek. `pg-null-order.live.test.ts` walks both directions at four page sizes with NULLs on both
|
|
354
|
+
sides of every boundary, and compares the walk against the unpaged read and against memory.
|
|
355
|
+
- **The four SQL aggregates ship, and `count(*)` is no longer the only one — `As of 2026-08-24`.**
|
|
356
|
+
`sum`, `avg`, `min` and `max` are terminals beside `countBy`, over exactly the rows `count()`
|
|
357
|
+
counts. Before them, "total spend this month" meant leaving the query language for hand-written
|
|
358
|
+
SQL, which is the one read path here with no tenancy guard on it. **Never a float**: `sum` and
|
|
359
|
+
`avg` answer decimal TEXT whatever the column was (the sum of a million `integer` rows is not an
|
|
360
|
+
`integer`, and `Number()` past 2^53 loses digits), a money aggregate answers `MoneyValue` in
|
|
361
|
+
integer minor units, and `min`/`max` answer the row's own type. `null` for an empty set in every
|
|
362
|
+
one, because that is what SQL answers and a `0` would claim rows were seen. **The shared rules
|
|
363
|
+
live in `aggregate.ts`** — which kinds each function takes, the exact decimal arithmetic — with
|
|
364
|
+
`aggregate-fold.ts` the memory execution and `aggregate-decode.ts` the Postgres one, so the two
|
|
365
|
+
cannot drift. **`avg` rounds at ONE fixed scale (`AVG_SCALE`, 6), half away from zero**, computed
|
|
366
|
+
from the exact rational: `round(avg(...), 6)` in the statement and integer arithmetic in memory,
|
|
367
|
+
because "whatever numeric division gives you" is not a rule two implementations can share — the
|
|
368
|
+
first draft rescaled relatively instead of absolutely and answered `11000.000000` where the server
|
|
369
|
+
said `1.100000`, which the live parity test caught. **Refused rather than answered**: `min`/`max`
|
|
370
|
+
on `text` (ordering is the database's COLLATION there and JS code-unit order here, and a
|
|
371
|
+
comparison that cannot be made to agree is not answered twice differently), `avg` over money
|
|
372
|
+
(`X_AGGREGATE_UNSUPPORTED` — the mean of an integer number of minor units is not one, so every
|
|
373
|
+
answer would be the silent rounding `MoneyValue.scale` exists to prevent), an amount covering more
|
|
374
|
+
than one currency **or scale** (`X_AGGREGATE_MIXED_CURRENCY`, counted in its own statement before
|
|
375
|
+
the aggregate is asked for — the scale half is the one with no symptom, since `{ minor: 5,
|
|
376
|
+
currency: 'USD' }` and the same row at `scale: 6` differ by 10,000x), and a money total past
|
|
377
|
+
±2^53 minor units. **`approximateCount()` is `reltuples`**, one row out of `pg_class`, constant
|
|
378
|
+
time — because `count(*)` walks every visible row and no index can help, which is what makes
|
|
379
|
+
`X_DB_STATEMENT_TIMEOUT`'s "add the index this statement needs" unfollowable on a large table. It
|
|
380
|
+
is the whole TABLE's number, so a filtered chain **and every tenant-scoped entity** are
|
|
381
|
+
`X_APPROXIMATE_COUNT_FILTERED`; the guard runs BEFORE tenancy, or a scoped entity had no reachable
|
|
382
|
+
call at all — unscoped it was `X_TENANCY_UNSCOPED` and scoped it was this. `null` for a table
|
|
383
|
+
nobody has analysed (`-1` in `pg_class`), which is the absence of an estimate and not an estimate
|
|
384
|
+
of zero. The in-memory driver answers the exact count and refuses the same two cases, so both
|
|
385
|
+
drivers answer one QUESTION.
|
|
386
|
+
- **A `json()` or `arrayOf()` column is filterable, `As of 2026-08-24`.** `Operator` gained
|
|
387
|
+
`contains` (`@>`), `contained-by` (`<@`), `overlaps` (`&&`) and `has-key`; before them the
|
|
388
|
+
vocabulary could compare a column to a scalar and nothing else, so an app storing either had to
|
|
389
|
+
leave the query language — the unguarded path again. **The meaning is Postgres', measured rather
|
|
390
|
+
than summarised**, in `containment.ts`, read by both drivers. Three clauses are easy to state
|
|
391
|
+
wrongly and two were wrong here first: the array-contains-a-primitive exception applies **at the
|
|
392
|
+
top level only** (`'{"list":[1,2,3]}' @> '{"list":2}'` is FALSE) and **to primitives only**
|
|
393
|
+
(`'[{"a":1}]' @> '{"a":1}'` is FALSE); `&&`'s empty operand overlaps NOTHING where `@>`'s is
|
|
394
|
+
contained by everything. **`jsonb` and array `@>` are two operators sharing a symbol**: the first
|
|
395
|
+
is recursive structural containment, the second is plain element membership, because an array's
|
|
396
|
+
elements are scalars of one declared type — `arrayOf()` refuses `jsonb`, `bytea`, `money` and a
|
|
397
|
+
nested array, which is what makes that true. A `Date` element compares by its instant, never by
|
|
398
|
+
reference. **`jsonb_exists(col, $1)`, never the `?` operator**: a literal `?` is a parameter
|
|
399
|
+
placeholder to more than one client on the way to the server. **No jsonpath expression operator**
|
|
400
|
+
beside them, deliberately: `contains` already matches nested structure, and a path language
|
|
401
|
+
inside the query language is a second way to ask one question. `&&` on a `jsonb` column is
|
|
402
|
+
refused where it was written, since Postgres has no such operator and any answer would be one no
|
|
403
|
+
statement can make. **`has-key` emits the `?` OPERATOR, schema-qualified
|
|
404
|
+
(`operator(pg_catalog.?)`), and not `jsonb_exists(col, $1)`** — the two are the same test and only
|
|
405
|
+
the first is INDEXABLE: measured on Postgres 16 with a GIN index and `enable_seqscan = off`,
|
|
406
|
+
`data ? 'k'` plans as a Bitmap Index Scan and the function form is a Seq Scan the planner will not
|
|
407
|
+
convert, because an index is matched against an operator expression and a bare function call is
|
|
408
|
+
not one. The function form shipped first, on a stated fear of `?` being read as a placeholder;
|
|
409
|
+
Bun's client passes it through verbatim (measured), and the qualified spelling is immune to a
|
|
410
|
+
client that does not and to a `search_path` that shadows the operator.
|
|
411
|
+
- **A GIN index is declarable — `indexes: [{ on: ['tags'], using: 'gin' }]`, `As of 2026-08-24`.**
|
|
412
|
+
Without one every containment operator above is a sequential scan, which is the whole reason they
|
|
413
|
+
needed an index at all: measured over 20,000 rows, array `@>` / `<@` / `&&` and jsonb `@>` and
|
|
414
|
+
`?` each become a Bitmap Index Scan with a GIN index and none touches it without.
|
|
415
|
+
`pg-containment.live.test.ts` explains the driver's OWN statement rather than a lookalike — the
|
|
416
|
+
`count` one, because a page's `order by "id"` plus `limit` lets a four-row table be served by the
|
|
417
|
+
primary key whatever the predicate could have used. **The closed set is `@ultimat3/db`'s
|
|
418
|
+
`INDEX_METHODS`, imported and never restated** (tier 1, downward): two members, `btree` and
|
|
419
|
+
`gin`. **Absent is `btree`** — an index that names no method emits the statement it always
|
|
420
|
+
emitted byte for byte and its snapshot entry carries no `using` at all, so nothing regenerates;
|
|
421
|
+
proven by generating twice against the first generation's own snapshot and asserting the second
|
|
422
|
+
is empty. **The METHOD joins the name discriminator**, beside `where` and `order`: a btree on an
|
|
423
|
+
`arrayOf()` column answers `=` and an ordering while a GIN on the same column answers `@>`, so
|
|
424
|
+
they are two distinct indexes that would otherwise be one name — the dedup drops one in silence,
|
|
425
|
+
or, since that dedup is on the whole definition, two `create index` statements collide as `42P07`.
|
|
426
|
+
It is appended to the hash only when declared, so every name minted before methods existed is
|
|
427
|
+
unchanged. **Two Postgres rules are refused HERE**, where the author is: a GIN index cannot be
|
|
428
|
+
unique and cannot order its keys. `@ultimat3/db`'s `createIndex` refuses both again — that is the
|
|
429
|
+
guard for a description nobody declared through `entity()`, not a duplicate — but its refusal
|
|
430
|
+
lands at `x db gen`, or inside `ROLE=migrate` as the server's own syntax error with none of the
|
|
431
|
+
entity's words in it. **`jsonb <@` is not indexable and that is Postgres', not this package's**:
|
|
432
|
+
`<@` is not in the default `jsonb_ops` operator class, so it is a sequential scan whatever index
|
|
433
|
+
is declared — pinned in the live test so a reader is not left wondering whose doing it is.
|
|
307
434
|
- **A relation is a foreign key read a second way, never a second declaration.** `relations.ts`
|
|
308
435
|
derives `belongsTo` from an entity's own `references()` columns and `hasMany` from the inbound
|
|
309
436
|
ones; there is no `hasMany: […]` init key and adding one would put two declarations of one fact
|
|
@@ -321,7 +448,31 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
321
448
|
framework — including the composite unique one `upsertAll`'s `on conflict` is inferred against —
|
|
322
449
|
had to be written by hand. `where` and `order` ride along for the same reason: a partial index
|
|
323
450
|
emitted as a total one refuses rows the entity allows. `on: []` is refused at declaration
|
|
324
|
-
(`X_INVARIANT_VIOLATED`), where the author can see it.
|
|
451
|
+
(`X_INVARIANT_VIOLATED`), where the author can see it. **And the NAME carries the predicate and
|
|
452
|
+
the direction too, `As of 2026-08-24`** — eight hex characters of sha256 over `order` and `where`,
|
|
453
|
+
folded in as `<table>_<cols>_<hash>_idx`. Without it two DIFFERENT partial indexes on one column
|
|
454
|
+
were one name (`posts_author_id_idx` for both `where status = 'published'` and
|
|
455
|
+
`where status = 'draft'`), and the dedup below dropped the second with no error, no warning and
|
|
456
|
+
no drift finding either, since `compareTable` matches a declared index by name. **Only when the
|
|
457
|
+
index carries one of the two**: a plain index keeps `<table>_<cols>_idx`/`_key`, because
|
|
458
|
+
`unique()` on a column is an inline column clause and Postgres names the index it creates exactly
|
|
459
|
+
`<table>_<column>_key` — a discriminator there would make the generator emit a second
|
|
460
|
+
`create unique index` for an index that already exists (`42P07`). The dedup itself is on the
|
|
461
|
+
whole `IndexDef`, not on the name: a name is derived, and matching on a derived string is what
|
|
462
|
+
made two indexes indistinguishable in the first place. **And the name is bounded at 63 BYTES**
|
|
463
|
+
(`MAX_IDENTIFIER_BYTES`, `NAMEDATALEN - 1`), refused at declaration: Postgres truncates a longer
|
|
464
|
+
identifier and says nothing, so two names sharing their first 63 bytes are one index on the
|
|
465
|
+
server — the same silent collapse one layer down, and invisible to a drift check comparing
|
|
466
|
+
DECLARED names, which still differ.
|
|
467
|
+
- **Every physical name is checked, including the DERIVED one — `As of 2026-08-24`, and it was a
|
|
468
|
+
DDL injection.** `columnName` is `meta.name ?? snake(property)` and only the first branch reached
|
|
469
|
+
`assertColumnName` for three majors, while `snake()` lower-cases and does nothing else. A column
|
|
470
|
+
declared as `n" , "x" text); drop table t; --` therefore produced a `create table` carrying a real
|
|
471
|
+
`drop table` — measured through `generateMigration`, not theorised — and an entity NAME did the
|
|
472
|
+
same through `table: init.table === undefined ? name : assertColumnName(init.table)`, whose
|
|
473
|
+
fallback is every entity that does not rename its table. Quoting is not a defence against a value
|
|
474
|
+
that can close the quote, which is what `assertColumnName`'s own doc comment already said. Checked
|
|
475
|
+
at `bindColumn` (once per column, at `entity()`) rather than in `columnName` (every statement).
|
|
325
476
|
- **Relations reach query time through `RegistryEntry.references()`, and the DDL string is
|
|
326
477
|
rendered from it.** The resolved records are the source; `ColumnDescription.references` spells
|
|
327
478
|
`"<table>.<column>"` out of one for the migration generator, which is in tier 1 and cannot
|
|
@@ -488,16 +639,35 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
488
639
|
- **Nothing is interpolated into SQL.** `pg-sql.ts` binds every value through `sql` and resolves
|
|
489
640
|
every identifier through the entity, so a column name can only be one the entity declared.
|
|
490
641
|
`raw()` appears exactly twice, for `asc|desc` and the `default` cell of a many-row `values` list —
|
|
491
|
-
each a closed set of one word. The seek operator was the third: it is
|
|
492
|
-
(`seekAfter
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
642
|
+
each a closed set of one word. The seek operator was the third: it is chosen in TypeScript
|
|
643
|
+
(`seekSql`/`seekAfter`), because the seek's SHAPE is decided by the order and its bind's cast is
|
|
644
|
+
part of the template, never a `raw()` argument.
|
|
645
|
+
- **A `timestamp` cursor carries MICROSECONDS, and every seek term is a plain comparison —
|
|
646
|
+
decided 2026-08-24, and it replaces the millisecond window this file described for two majors.**
|
|
647
|
+
A `timestamptz` column holds microseconds; Bun's client hands it back as a JS `Date`, which holds
|
|
648
|
+
milliseconds. The window (`>= v and < v + 1ms`, ascending `>= v + 1ms`) made the SEEK cut on
|
|
649
|
+
`date_trunc('milliseconds', col)` while the `order by` beside it still sorted on the bare column
|
|
650
|
+
at microseconds — **two different equality classes on one page**, and the rows between them were
|
|
651
|
+
served on **no page, ever**. Not a race: three rows inside one millisecond with uuid v7 ids, a
|
|
652
|
+
`desc` page of one, and the two later rows are unreachable on every subsequent page, because
|
|
653
|
+
under `desc` the boundary row always holds the largest id of its millisecond and the `id >`
|
|
654
|
+
tiebreak can never match. Reproduced against Postgres 16 before the fix and pinned by
|
|
655
|
+
`pg-cursor-precision.live.test.ts`. No predicate over `(col, id)` built from a FLOORED value can
|
|
656
|
+
be correct — the information is gone — so the precision is carried instead. Three parts, none
|
|
657
|
+
optional. **The statement asks for it**: `seekPrecision` (`pg-sql.ts`) projects
|
|
658
|
+
`(col at time zone 'UTC')::text as "<col>$US"` beside every `timestamptz` sort key, under an
|
|
659
|
+
UPPER-CASE alias no physical column name can be (`snake()` lower-cases, `assertColumnName`
|
|
660
|
+
refuses the rest). `at time zone 'UTC'` and not a bare `::text`, or a page position would depend
|
|
661
|
+
on the connection's `TimeZone`. **The cursor is minted from the PHYSICAL row**: `sortPrecision`
|
|
662
|
+
(`pg-row.ts`) reads that output, and `cursorFor`'s optional `exact` map is how a driver hands
|
|
663
|
+
over a value the decoded row cannot hold. **The seek binds an ISO instant with all six digits**:
|
|
664
|
+
`col < $1::timestamptz`, the cast in the template rather than a `raw()` call, the column bare so
|
|
665
|
+
the index still range-scans. `instant.ts` is the only place the two representations meet —
|
|
666
|
+
microseconds since the epoch, as a `bigint`, in the cursor and in `compareByKind`. The memory
|
|
667
|
+
driver stores millisecond `Date`s, which are exact in that domain, so the two drivers still agree
|
|
668
|
+
without a second rule; `nextMillisecond` and `seekEqual`'s `Date` branch are gone. A cursor
|
|
669
|
+
minted before this carries an ISO string and is `X_CURSOR_INVALID`, never a bare `SyntaxError`
|
|
670
|
+
out of `BigInt`.
|
|
501
671
|
- **`MoneyValue.scale` PERSISTS, in a third physical column — decided 2026-08.** `<p>_scale integer
|
|
502
672
|
null`, through `columnsOf` / `bindValues` / `moneyOf` / `parseMoney` / `describeColumn`. Until
|
|
503
673
|
this branch the entity layer silently dropped it on **both** write and read: `parseMoney` rebuilt
|
|
@@ -575,7 +745,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
575
745
|
hole open on exactly the unauthenticated path. **Outside every request context there is no actor
|
|
576
746
|
to derive from** — a script, a seed, a test harness — so the caller names the tenant itself and
|
|
577
747
|
`X_TENANCY_UNSCOPED` still refuses a plan that names none. There is no build-time tenancy step in
|
|
578
|
-
`x verify` (its
|
|
748
|
+
`x verify` (its 20 steps check none) and the old comment in `tenancy.ts` claiming one was wrong:
|
|
579
749
|
the tenant is a request-time value, so the seam is the enforcement.
|
|
580
750
|
- **`crossTenant(reason, fn)` (`cross-tenant.ts`) is the ONE way to read across tenants**, for the
|
|
581
751
|
three cases that have no single one: an admin surface over every org, background reconciliation,
|
|
@@ -768,6 +938,52 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
768
938
|
`EntityError` inline** rather than delegating to a shared one, because `fix-scan.ts` reads a fix
|
|
769
939
|
literal only at a call site whose callee builds the error itself — a wrapper would take all 34
|
|
770
940
|
fix lines back out of `x verify`'s `errors` step (measured: `checked` 1040 -> 1071).
|
|
941
|
+
- **Full-text search is one generated `tsvector` per entity, and the TERM is never syntax.**
|
|
942
|
+
`.searchable()` on a `text()` column puts it in the vector (`search.ts`); `entity()` derives the
|
|
943
|
+
column, the `generated always as (…) stored` expression and the GIN index through the existing
|
|
944
|
+
`IndexInit` path. Rules, none optional. **`websearch_to_tsquery`, never `to_tsquery`**: the term
|
|
945
|
+
crosses as a bound parameter either way — that is what stops an injection — but bare `to_tsquery`
|
|
946
|
+
reads `&`, `|`, `!`, `<->`, `:*` and parentheses as OPERATORS, so a search box sends either a
|
|
947
|
+
`42601` or a query the caller did not write; `plainto_tsquery` is safe and throws the user's own
|
|
948
|
+
quotes and `-negation` away in silence. **The configuration is spliced, from a CLOSED set**
|
|
949
|
+
(`SEARCH_LANGUAGES`), because `regconfig` cannot be a bound parameter inside a generated column at
|
|
950
|
+
all — and `to_tsvector(text)` with no configuration is not immutable, so Postgres refuses it there.
|
|
951
|
+
**`coalesce(col, '')` on every source**: `to_tsvector(NULL)` is NULL and `NULL || tsvector` is
|
|
952
|
+
NULL, so one nullable column would erase the whole row's vector. **The vector column is NOT NULL**,
|
|
953
|
+
which is what makes a generator that does not render the `generated` clause fail on the first
|
|
954
|
+
insert (`23502`) instead of leaving a table of NULL vectors under a search that quietly answers
|
|
955
|
+
nothing. **The memory driver REFUSES** (`X_SEARCH_IN_MEMORY`) rather than emulating: stemming, stop
|
|
956
|
+
words and a phrase parser are not a JS token comparison, and a green unit test over a different
|
|
957
|
+
question is the one outcome the two-driver split exists to prevent — the parity rule inverted, and
|
|
958
|
+
`predicateSql`/`matchesPredicate` are exhaustive switches over `Operator`, so neither can be given
|
|
959
|
+
a case the other lacks. **RELEVANCE is not an order this chain serves**: `ts_rank` is a computed
|
|
960
|
+
value and the cursor carries columns, so `.search()` filters and the declared `orderBy` pages —
|
|
961
|
+
proven over 30 tied rows in `pg-search.live.test.ts`, which also explains the GIN index and pins
|
|
962
|
+
the plan the tenant predicate produces.
|
|
963
|
+
- **A state machine on a column is the MECHANISM only, and the line is `19-mechanism-not-convention.md`'s.**
|
|
964
|
+
What ships: the transition table, the refusal of a move not in it, the ATOMICITY of check-and-move,
|
|
965
|
+
the terminal-state concept, and the stamp saying when the row moved. What never ships: the states,
|
|
966
|
+
an approval chain, a role that may perform a move, a side effect on arrival. **There is no enum of
|
|
967
|
+
state names anywhere in this package** — `.transitions()` hangs off `enumerated()`, so the states
|
|
968
|
+
are the app's own set and `TransitionTable<S>` is a MAPPED type over it: a missing state, an
|
|
969
|
+
unknown key and an unknown target are compile errors against a list the framework never saw.
|
|
970
|
+
**A terminal state is one whose outgoing list is empty** — derived, never declared, so "nothing
|
|
971
|
+
leaves cancelled" is structural and *which* state is terminal is not the framework's business.
|
|
972
|
+
**The move is ONE statement.** `from` rides in the predicate (`where id = $1 and status = $2`), so
|
|
973
|
+
the state that was OBSERVED and the state that was WRITTEN are one decision made under the row's
|
|
974
|
+
lock, and no rows is the refusal. A read-then-check-then-write is the same code with a window in
|
|
975
|
+
it: measured against a real server, twenty concurrent callers naming `pending` produced **14
|
|
976
|
+
winners** that way and **exactly 1** this way (`pg-transition.live.test.ts`). Legality is asked
|
|
977
|
+
BEFORE the statement, because the table is a property of the declaration and not of the database.
|
|
978
|
+
**The refusal is a read, and only ever after the decision** — `X_STATE_CONFLICT` names the state
|
|
979
|
+
the row is really in, from a tenant-scoped `findById` that runs once the statement has already
|
|
980
|
+
refused. Another org's row reads as absent, so the answer is `X_NOT_FOUND` and never a conflict
|
|
981
|
+
that would confirm it exists. **The machine adds no DDL**: `enumerated()` already emits the CHECK,
|
|
982
|
+
so there is one declaration of what a legal value is. **A machine column may not be nullable** —
|
|
983
|
+
NULL is not a state, and `= NULL` matches no row, so every move out of it would read as a
|
|
984
|
+
conflict. **`whyNot` asks three questions in one order** — unknown state, then terminal, then the
|
|
985
|
+
legal list — because an unknown state has no outgoing moves either, and a check that skipped it
|
|
986
|
+
reported a typo as "the row is terminal in `pendign`".
|
|
771
987
|
- Never throw a bare `Error` — use `errors.ts`.
|
|
772
988
|
- Tests restore the process-global registry in `afterAll` (`clearRegistry()`): a leaked registry
|
|
773
989
|
breaks an unrelated package's tests, as it did in `@ultimat3/policy`.
|
|
@@ -783,6 +999,13 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
783
999
|
| `refuse.ts` | `refuseColumn`/`refuseInvariant` — the refusals raised before any entity exists, each carrying the EDIT that repairs it |
|
|
784
1000
|
| `expr.ts` / `invariants.ts` | the `invariants: (c) => …` rule language; bind + `toSql()` DDL |
|
|
785
1001
|
| `entity.ts` / `describe.ts` | `entity()`, `$row`; the `EntityDescription` projection |
|
|
1002
|
+
| `index-name.ts` | what an index is CALLED — the predicate/direction/method discriminator and the 63-byte bound |
|
|
1003
|
+
| `search.ts` | the generated `tsvector` a `.searchable()` column set derives: the closed language list, the weights, the expression |
|
|
1004
|
+
| `state-machine.ts` | the transition table, its five declaration rules, and what a terminal state IS |
|
|
1005
|
+
| `transition.ts` | one atomic move: the legality question, the conditional statement, the diagnosis of a statement that matched nothing |
|
|
1006
|
+
| `enum-column.ts` | `enumerated()` and its own chain — the one builder that may declare a machine |
|
|
1007
|
+
| `column-values.ts` | `got()` and `oneOf()`, so `enum-column.ts` needs no import of the file that imports it |
|
|
1008
|
+
| `feature-errors.ts` | the refusals search and the state machine raise at call time; the codes and titles stay in `errors.ts` |
|
|
786
1009
|
| `view.ts` | `$view(keys)` — the row projection an action names as its `output` |
|
|
787
1010
|
| `query.ts` / `database.ts` | chainable read to a cursor page; `database()` + `Driver` |
|
|
788
1011
|
| `clock.ts` | `entityNow()` — the ONE clock read on the write path, `ctx.clock` else the system's |
|
package/README.md
CHANGED
|
@@ -168,6 +168,24 @@ these filters, this sort order. A tampered cursor, or one taken from another lis
|
|
|
168
168
|
`X_CURSOR_INVALID` rather than a silent page one. The page size is deliberately outside the scope:
|
|
169
169
|
asking for a bigger next page is the same query.
|
|
170
170
|
|
|
171
|
+
The primary key is appended as the final sort key **in the last declared key's direction**, so
|
|
172
|
+
`orderBy('createdAt', 'desc')` runs `created_at desc, id desc` — one direction throughout, which is
|
|
173
|
+
what `indexes: [{ on: ['createdAt', 'id'], order: 'desc' }]` can cover and what lets the seek go out
|
|
174
|
+
as the row comparison `(created_at, id) < ($1, $2)`. Write the mixed order yourself
|
|
175
|
+
(`.orderBy('createdAt', 'desc').orderBy('id', 'asc')`) and you get it, spelled out as an or-chain.
|
|
176
|
+
|
|
177
|
+
A **nullable** sort key orders rather than being refused: `asc nulls last`, `desc nulls first` —
|
|
178
|
+
written into the statement rather than inherited from the server, and the same spelling
|
|
179
|
+
`@ultimat3/query` uses. `posts.orderBy('publishedAt', 'desc')` on a nullable column pages correctly
|
|
180
|
+
in both drivers; the cursor carries "this row had none" as a position of its own. The one ordering
|
|
181
|
+
still refused is a nullable **primary-key** column, which no tiebreak can make total.
|
|
182
|
+
|
|
183
|
+
A `timestamp()` sort key is carried at the column's own **microsecond** precision, not at the
|
|
184
|
+
millisecond a JS `Date` holds: the read projects the instant as text beside the column and the seek
|
|
185
|
+
binds all six digits. Without it a `desc` page over rows sharing one millisecond — which
|
|
186
|
+
`defaultNow()` produces routinely — served some of them on no page at all. Cursors do not survive
|
|
187
|
+
that change: one minted by an older version is `X_CURSOR_INVALID`.
|
|
188
|
+
|
|
171
189
|
**A page is bounded whether or not the caller bounded it.** `DEFAULT_PAGE_SIZE` (50) covers the read
|
|
172
190
|
nobody sized; `MAX_PAGE_SIZE` (10,000) covers the one they did — `limit(input.pageSize)` on a number
|
|
173
191
|
that arrived over the wire is the same production incident with an argument in front of it. A page
|
|
@@ -212,6 +230,187 @@ learn or to drift.
|
|
|
212
230
|
| Refusals | on the chain, not one batch later: a size that is not a whole number of rows between 1 and `MAX_PAGE_SIZE` (10,000), a `limit()` on the same chain (one number, two meanings), and an ordering no cursor can carry — a nullable sort column, which a result that fits in one batch would otherwise hide until the table grew |
|
|
213
231
|
| Tenancy | the plan's, as everywhere else: an unscoped chain is `X_TENANCY_UNSCOPED` on its first batch |
|
|
214
232
|
|
|
233
|
+
## Aggregates
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
import { database, entity, integer, money, timestamp, uuid } from '@ultimat3/entity';
|
|
237
|
+
|
|
238
|
+
const payments = entity('payments', {
|
|
239
|
+
columns: {
|
|
240
|
+
id: uuid().primaryKey(),
|
|
241
|
+
orgId: uuid(),
|
|
242
|
+
amount: money(),
|
|
243
|
+
installments: integer().default(1),
|
|
244
|
+
paidAt: timestamp(),
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
// No tenant column, because an estimate is the whole TABLE's: a tenant-scoped entity is refused.
|
|
248
|
+
const events = entity('events', { columns: { id: uuid().primaryKey(), at: timestamp() } });
|
|
249
|
+
const db = database({ payments, events });
|
|
250
|
+
declare const orgId: string;
|
|
251
|
+
declare const since: Date;
|
|
252
|
+
|
|
253
|
+
await db.payments.where({ orgId }).andWhere('paidAt', 'gte', since).sum('amount');
|
|
254
|
+
// { minor: 128400, currency: 'EUR' }
|
|
255
|
+
|
|
256
|
+
await db.payments.where({ orgId }).avg('installments'); // '2.500000'
|
|
257
|
+
await db.payments.where({ orgId }).max('paidAt'); // Date | null
|
|
258
|
+
await db.events.approximateCount(); // 12_400_000 | null
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Over exactly the rows `count()` counts — the chain's filters, its tenancy and its soft-delete
|
|
262
|
+
visibility, never its page.
|
|
263
|
+
|
|
264
|
+
| | |
|
|
265
|
+
|---|---|
|
|
266
|
+
| Never a float | `sum`/`avg` answer decimal **text** whatever the column was (the sum of a million `integer` rows is not an `integer`, and `Number()` past 2^53 loses digits); money answers `MoneyValue` in integer minor units; `min`/`max` answer the row's own type |
|
|
267
|
+
| Empty set | `null` in every function, which is what SQL answers — a `0` would claim rows were seen. `count()` is `0`, and that is the distinction |
|
|
268
|
+
| `avg` | one fixed scale (6 digits), rounded half away from zero from the exact rational, so both drivers land on one number |
|
|
269
|
+
| Refused | `min`/`max` on `text` (ordering is the database's collation there and JS code-unit order here); `avg` over money (every answer would be a silent rounding of a fraction of a minor unit — `sum()` and `count()` instead, dividing where the rounding is a decision); an amount covering more than one currency **or scale**; a money total past ±2^53 minor units |
|
|
270
|
+
| `approximateCount()` | `reltuples` out of `pg_class`, constant time, because `count(*)` walks every visible row and no index can make it cheaper. The whole **table** — a filtered chain and every tenant-scoped entity are `X_APPROXIMATE_COUNT_FILTERED`, and `null` means the table has never been analysed |
|
|
271
|
+
|
|
272
|
+
## Filtering inside a json() or arrayOf() column
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
import { arrayOf, database, entity, json, text, uuid } from '@ultimat3/entity';
|
|
276
|
+
import { t } from '@ultimat3/schema';
|
|
277
|
+
|
|
278
|
+
const posts = entity('posts', {
|
|
279
|
+
columns: {
|
|
280
|
+
id: uuid().primaryKey(),
|
|
281
|
+
tags: arrayOf(text({ max: 40 })),
|
|
282
|
+
settings: json(t.object({ notify: t.object({ email: t.boolean }) })),
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
const db = database({ posts });
|
|
286
|
+
|
|
287
|
+
await db.posts.andWhere('tags', 'contains', ['release']).all();
|
|
288
|
+
await db.posts.andWhere('tags', 'overlaps', ['release', 'beta']).all();
|
|
289
|
+
await db.posts.andWhere('settings', 'contains', { notify: { email: true } }).all();
|
|
290
|
+
await db.posts.andWhere('settings', 'has-key', 'notify').all();
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
`contains` is `@>`, `contained-by` is `<@`, `overlaps` is `&&`, `has-key` is the `?` operator,
|
|
294
|
+
written schema-qualified as `operator(pg_catalog.?)` — the function form `jsonb_exists(col, $1)` is
|
|
295
|
+
the same test and the planner will not match a GIN index to it. Their
|
|
296
|
+
meaning is Postgres', to the letter, in both drivers — including that the array-contains-a-primitive
|
|
297
|
+
exception applies at the **top level only** and to **primitives only**, and that `&&`'s empty
|
|
298
|
+
operand overlaps nothing where `@>`'s is contained by everything.
|
|
299
|
+
|
|
300
|
+
There is no jsonpath expression operator: `contains` already matches nested structure, and a path
|
|
301
|
+
language inside the query language would be a second way to ask one question. `&&` on a `jsonb`
|
|
302
|
+
column is refused, because Postgres has no such operator.
|
|
303
|
+
|
|
304
|
+
Declare the index those operators need, or every one of them is a sequential scan:
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
import { arrayOf, entity, json, text, uuid } from '@ultimat3/entity';
|
|
308
|
+
import { t } from '@ultimat3/schema';
|
|
309
|
+
|
|
310
|
+
export const posts = entity('posts', {
|
|
311
|
+
columns: {
|
|
312
|
+
id: uuid().primaryKey(),
|
|
313
|
+
tags: arrayOf(text({ max: 40 })),
|
|
314
|
+
settings: json(t.object({ notify: t.object({ email: t.boolean }) })),
|
|
315
|
+
},
|
|
316
|
+
indexes: [{ on: ['tags'], using: 'gin' }, { on: ['settings'], using: 'gin' }],
|
|
317
|
+
});
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
| | |
|
|
321
|
+
|---|---|
|
|
322
|
+
| Methods | `btree` (the default, and what every index without `using` is) and `gin`. Omitting it emits the statement it always emitted, and regenerates nothing |
|
|
323
|
+
| Served by a GIN index | array `contains` / `contained-by` / `overlaps`, and jsonb `contains` / `has-key` — measured on the planner, not assumed |
|
|
324
|
+
| Not served | jsonb `contained-by`: `<@` is not in Postgres' default `jsonb_ops` operator class, so it is a sequential scan whatever you declare |
|
|
325
|
+
| Refused | a unique GIN and an ordered GIN, at `entity()` — Postgres has neither, and the refusal names the edit |
|
|
326
|
+
| Naming | the method is part of what separates two indexes on the same columns, so a btree and a GIN on one column are two indexes with two names |
|
|
327
|
+
|
|
328
|
+
## Full-text search
|
|
329
|
+
|
|
330
|
+
`.searchable()` on a `text()` column puts it in the entity's **one** generated `tsvector`, with a
|
|
331
|
+
GIN index on it. Nothing else to declare, and no second column on the row.
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
import { database, entity, text, timestamp, uuid } from '@ultimat3/entity';
|
|
335
|
+
|
|
336
|
+
declare const orgId: string;
|
|
337
|
+
declare const term: string; // what the user typed, verbatim
|
|
338
|
+
|
|
339
|
+
const posts = entity('posts', {
|
|
340
|
+
columns: {
|
|
341
|
+
id: uuid().primaryKey(),
|
|
342
|
+
orgId: uuid().tenant(),
|
|
343
|
+
title: text({ max: 120 }).searchable('A'), // 'A' outranks 'D' under ts_rank
|
|
344
|
+
body: text().nullable().searchable(), // 'D' by default, Postgres' own
|
|
345
|
+
createdAt: timestamp().defaultNow(),
|
|
346
|
+
},
|
|
347
|
+
// Only when the defaults do not fit: the column is `search_tsv`, the language is 'english'.
|
|
348
|
+
search: { column: 'search_tsv', language: 'english' },
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const db = database({ posts });
|
|
352
|
+
|
|
353
|
+
await db.posts.where({ orgId }).search(term).orderBy('createdAt', 'desc').limit(20).page();
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
| Fact | Why |
|
|
357
|
+
|---|---|
|
|
358
|
+
| the term is a **bound parameter**, parsed by `websearch_to_tsquery` | `&`, `\|`, `!`, `:*` and an unbalanced paren are characters to match, never operators and never a `42601`. `plainto_tsquery` is safe too and silently discards `"a phrase"` and `-negation`; bare `to_tsquery` on user text is the injection |
|
|
359
|
+
| the language is spliced from a closed set (`SEARCH_LANGUAGES`) | `regconfig` cannot be a bound parameter inside a generated column, and `to_tsvector(text)` with no configuration is not immutable, so Postgres refuses it there |
|
|
360
|
+
| the column is `generated always as (…) stored`, `not null` | the database computes it on every write, including one made from psql |
|
|
361
|
+
| tenancy, soft delete, the projection, the order and the cursor are unchanged | `.search()` is one more predicate on the chain you already had |
|
|
362
|
+
| `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` |
|
|
363
|
+
| 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 |
|
|
364
|
+
|
|
365
|
+
## A state machine over a column
|
|
366
|
+
|
|
367
|
+
`.transitions()` on an `enumerated()` column. The states are yours; the machine is the framework's.
|
|
368
|
+
|
|
369
|
+
```ts
|
|
370
|
+
import { database, entity, enumerated, timestamp, uuid } from '@ultimat3/entity';
|
|
371
|
+
|
|
372
|
+
declare const id: string;
|
|
373
|
+
|
|
374
|
+
const ORDER_STATES = ['pending', 'paid', 'shipped', 'delivered', 'cancelled'] as const;
|
|
375
|
+
|
|
376
|
+
const orders = entity('orders', {
|
|
377
|
+
columns: {
|
|
378
|
+
id: uuid().primaryKey(),
|
|
379
|
+
orgId: uuid().tenant(),
|
|
380
|
+
status: enumerated(ORDER_STATES)
|
|
381
|
+
.transitions({
|
|
382
|
+
pending: ['paid', 'cancelled'],
|
|
383
|
+
paid: ['shipped', 'cancelled'],
|
|
384
|
+
shipped: ['delivered'],
|
|
385
|
+
delivered: [], // terminal — nothing leaves it, and an empty list is how you say so
|
|
386
|
+
cancelled: [],
|
|
387
|
+
})
|
|
388
|
+
.default('pending'),
|
|
389
|
+
updatedAt: timestamp().defaultNow().onUpdateNow(),
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const db = database({ orders });
|
|
394
|
+
|
|
395
|
+
// One statement. `from` is the state you believe the row is in, and it rides in the predicate.
|
|
396
|
+
const shipped = await db.orders.transition('status', id, { from: 'paid', to: 'shipped' });
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
| Fact | Why |
|
|
400
|
+
|---|---|
|
|
401
|
+
| the table is a **mapped type** over your `enumerated()` set | a missing state, an unknown key and an unknown target are compile errors — the framework never names a state |
|
|
402
|
+
| **one statement**, with `from` in its predicate | the state observed and the state written are one decision, under the row's lock. Two callers who both read `pending` cannot both move it: the second matches no row |
|
|
403
|
+
| a move the table does not hold is `X_STATE_TRANSITION_ILLEGAL`, before any statement | the table is a property of the declaration, so an illegal move never reaches the database |
|
|
404
|
+
| a row that moved first is `X_STATE_CONFLICT`, naming the state it is really in | read back **after** the refusal — a diagnosis, never the decision |
|
|
405
|
+
| another org's row is `X_NOT_FOUND`, never a conflict | a conflict would confirm the row exists and name its state |
|
|
406
|
+
| a terminal state is one with an empty list | derived. *Which* state is terminal is yours |
|
|
407
|
+
| `onUpdateNow()` moves, because a transition is an update | the audit of *when* it moved, with no second mechanism beside it |
|
|
408
|
+
| the CHECK comes from `enumerated()` | the machine emits no DDL of its own — one declaration of what a legal value is |
|
|
409
|
+
| `memoryDriver()` answers it exactly as Postgres does | a compare-and-set over a map is the same question; unlike a `tsvector` match, there is nothing to fake |
|
|
410
|
+
|
|
411
|
+
What is deliberately **not** here: who may make a move, what happens on arrival, an approval chain,
|
|
412
|
+
a reason code. Those differ per app — wrap `transition()` in your own function and put them there.
|
|
413
|
+
|
|
215
414
|
## Counting by a column
|
|
216
415
|
|
|
217
416
|
`As of 2026-08`. `count()` answers one number, so a screen or a backfill that needs one per row
|
|
@@ -558,8 +757,14 @@ every incoming row must name the acting actor's tenant. Together, the key a coll
|
|
|
558
757
|
can only hold a value that is this actor's.
|
|
559
758
|
|
|
560
759
|
```ts
|
|
760
|
+
import { crossTenant } from '@ultimat3/entity';
|
|
761
|
+
|
|
762
|
+
declare const expireInvites: () => Promise<void>;
|
|
763
|
+
|
|
561
764
|
// admin surfaces, background reconciliation, support tooling — greppable, and never a boolean
|
|
562
|
-
await crossTenant('nightly invite expiry runs for every org', async () => {
|
|
765
|
+
await crossTenant('nightly invite expiry runs for every org', async () => {
|
|
766
|
+
await expireInvites();
|
|
767
|
+
});
|
|
563
768
|
```
|
|
564
769
|
|
|
565
770
|
The scope needs the `tenancy:cross` capability on the actor (`scopes: ['tenancy:cross']`), proven
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "13.0.0",
|
|
4
4
|
"description": "A table + its domain type + invariants the database also enforces",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/db": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
37
|
-
"@ultimat3/time": "
|
|
34
|
+
"@ultimat3/core": "13.0.0",
|
|
35
|
+
"@ultimat3/db": "13.0.0",
|
|
36
|
+
"@ultimat3/schema": "13.0.0",
|
|
37
|
+
"@ultimat3/time": "13.0.0"
|
|
38
38
|
}
|
|
39
39
|
}
|