@ultimat3/entity 12.0.0 → 14.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 CHANGED
@@ -33,6 +33,23 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
33
33
  `pg-driver-bulk.live.test.ts`, `pg-driver-tenancy.live.test.ts`). A method with only the first is
34
34
  unproven against Postgres itself; a method with only the second is unproven against memory. Both
35
35
  are the bar, not either one.
36
+ - **Money's write shape is wider than its row shape, and both drivers narrow it at the WRITE
37
+ METHOD'S entry — `As of 2026-08-25`.** `MoneyInput` lets a writer hand a `bigint` minor unit read
38
+ straight off a `bigint` column; `MoneyValue` is what a row holds, because `JSON.stringify` refuses
39
+ a `bigint` and money crosses every wire this framework projects. `RowWrite<Row>` is the type that
40
+ says so at `Repo.insert`/`insertAll`/`upsertAll`, which took the ROW type instead — so the
41
+ widening this package documents, narrows and stores correctly was a **compile error at the only
42
+ call an app makes**, `postgresRepo()` being exported, and it was the last two entries on
43
+ `scripts/lib/test-typecheck-pins.ts`. `narrowRow` (`columns.ts`) is the narrowing, called at each
44
+ entry rather than deep inside `bindValues`/`write`, and the POSITION is the rule. `entity.$assert`
45
+ and `upsertPlan` both run before a statement exists, so an invariant reading `total.minor` was
46
+ handed the caller's `bigint` and never the `number` the row would hold — it rejected rows both
47
+ drivers then stored correctly. And it decides whether a refusal costs a row: Bun's client binds a
48
+ `bigint` verbatim (measured), so a minor unit past ±2^53 narrowed any later is INSERTed,
49
+ committed, and only then refused by the decode of its own `returning *` — a row the app wrote and
50
+ can never read. `pg-money-write.live.test.ts` is the proof, because only a real table can see
51
+ that; `money-write-parity.test.ts` pins both drivers together, and `type-pins.ts` fails the build
52
+ if those three writes stop taking `RowWrite` or start answering with it.
36
53
  - **What a PREDICATE means is decided by the column's declared KIND, and `memory-match.ts` is
37
54
  where that one meaning is written.** The database decides by the column's type, so a driver
38
55
  deciding by the JS `typeof` of the value in hand is answering a different question — four rules,
@@ -776,8 +793,28 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
776
793
  - **Every framework member on an entity is `$`-prefixed** — the columns are `Object.assign`ed onto
777
794
  the core, so an unprefixed member would make `view`, `name` or `tenant` an illegal column name.
778
795
  `$view`, never `view`; no free `view(entity, keys)` either — one way to write a projection.
779
- - **Invariants run twice**: in the app on write AND as a Postgres CHECK/UNIQUE via `toSql()`. An
780
- untranslatable JS predicate reports `kind: 'assert'`, `sql: null` — never a pretend CHECK.
796
+ - **Invariants run twice, and only ONE side of the pair is rendered here.** In the app on write
797
+ (`assertInvariants`), and as a Postgres CHECK/UNIQUE emitted by `@ultimat3/db` —
798
+ `constraintNameFor`, `declaredChecks`, `declaredIndexes` (`invariant-ddl.ts`), reading
799
+ `$describe()`. An untranslatable JS predicate reports `kind: 'assert'`, `sql: null` — never a
800
+ pretend CHECK. **This package rendered a second copy of that DDL until 2026-08-25**
801
+ (`toSql`/`invariantsToSql`/`constraintName`, reachable through `entity.$migration()`), and the
802
+ copy is the argument: nothing but its own tests ever called it, so nobody noticed it passed the
803
+ entity NAME where the table belongs — `entity('account', { table: 'legacy_accounts' })` rendered
804
+ `ALTER TABLE "account" ADD CONSTRAINT "account_…_check"`, a relation Postgres answers `42P01` for
805
+ and a constraint name no migration has ever written. All four are deleted; `$migration()` was on
806
+ `EntityCore`, so this is a breaking change to a documented member. Never render constraint DDL
807
+ here again — the entity's job is to DESCRIBE the rule, and `<table>_<name>_<check|key>` now has
808
+ exactly one source.
809
+ - **`InvariantDescription.columns` is projected, `As of 2026-08-25`** — the physical names the rule
810
+ reads, for every kind, straight off `Invariant.columns`. Same argument as `onDelete`, `generated`
811
+ and `default` on `ColumnDescription`: `@ultimat3/db` is tier 1 and cannot import this package, so
812
+ a fact this projection drops is a fact the generator must recover from a rendering. It was
813
+ recovering it — `uniqueColumns()` split a `unique` rule's `sql` on commas and re-validated each
814
+ part — which is the shape that made `posts_org_id_created_at_idx` read back as the single column
815
+ `"org_id_created_at"`. `snapshotOf` derives from `declaredChecks`/`declaredIndexes` and not from
816
+ this record, so the field changes no snapshot and nothing regenerates
817
+ (`describe-invariant.test.ts` pins both halves).
781
818
  - **And the two halves must AGREE, term by term** (`expr.ts`). A rule the app accepts and the CHECK
782
819
  refuses is not a stricter database: the write comes back as a raw constraint error instead of
783
820
  `X_INVARIANT_VIOLATED`, which is the framework's own invariant bypassed on the way out. Two
@@ -938,6 +975,52 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
938
975
  `EntityError` inline** rather than delegating to a shared one, because `fix-scan.ts` reads a fix
939
976
  literal only at a call site whose callee builds the error itself — a wrapper would take all 34
940
977
  fix lines back out of `x verify`'s `errors` step (measured: `checked` 1040 -> 1071).
978
+ - **Full-text search is one generated `tsvector` per entity, and the TERM is never syntax.**
979
+ `.searchable()` on a `text()` column puts it in the vector (`search.ts`); `entity()` derives the
980
+ column, the `generated always as (…) stored` expression and the GIN index through the existing
981
+ `IndexInit` path. Rules, none optional. **`websearch_to_tsquery`, never `to_tsquery`**: the term
982
+ crosses as a bound parameter either way — that is what stops an injection — but bare `to_tsquery`
983
+ reads `&`, `|`, `!`, `<->`, `:*` and parentheses as OPERATORS, so a search box sends either a
984
+ `42601` or a query the caller did not write; `plainto_tsquery` is safe and throws the user's own
985
+ quotes and `-negation` away in silence. **The configuration is spliced, from a CLOSED set**
986
+ (`SEARCH_LANGUAGES`), because `regconfig` cannot be a bound parameter inside a generated column at
987
+ all — and `to_tsvector(text)` with no configuration is not immutable, so Postgres refuses it there.
988
+ **`coalesce(col, '')` on every source**: `to_tsvector(NULL)` is NULL and `NULL || tsvector` is
989
+ NULL, so one nullable column would erase the whole row's vector. **The vector column is NOT NULL**,
990
+ which is what makes a generator that does not render the `generated` clause fail on the first
991
+ insert (`23502`) instead of leaving a table of NULL vectors under a search that quietly answers
992
+ nothing. **The memory driver REFUSES** (`X_SEARCH_IN_MEMORY`) rather than emulating: stemming, stop
993
+ words and a phrase parser are not a JS token comparison, and a green unit test over a different
994
+ question is the one outcome the two-driver split exists to prevent — the parity rule inverted, and
995
+ `predicateSql`/`matchesPredicate` are exhaustive switches over `Operator`, so neither can be given
996
+ a case the other lacks. **RELEVANCE is not an order this chain serves**: `ts_rank` is a computed
997
+ value and the cursor carries columns, so `.search()` filters and the declared `orderBy` pages —
998
+ proven over 30 tied rows in `pg-search.live.test.ts`, which also explains the GIN index and pins
999
+ the plan the tenant predicate produces.
1000
+ - **A state machine on a column is the MECHANISM only, and the line is `19-mechanism-not-convention.md`'s.**
1001
+ What ships: the transition table, the refusal of a move not in it, the ATOMICITY of check-and-move,
1002
+ the terminal-state concept, and the stamp saying when the row moved. What never ships: the states,
1003
+ an approval chain, a role that may perform a move, a side effect on arrival. **There is no enum of
1004
+ state names anywhere in this package** — `.transitions()` hangs off `enumerated()`, so the states
1005
+ are the app's own set and `TransitionTable<S>` is a MAPPED type over it: a missing state, an
1006
+ unknown key and an unknown target are compile errors against a list the framework never saw.
1007
+ **A terminal state is one whose outgoing list is empty** — derived, never declared, so "nothing
1008
+ leaves cancelled" is structural and *which* state is terminal is not the framework's business.
1009
+ **The move is ONE statement.** `from` rides in the predicate (`where id = $1 and status = $2`), so
1010
+ the state that was OBSERVED and the state that was WRITTEN are one decision made under the row's
1011
+ lock, and no rows is the refusal. A read-then-check-then-write is the same code with a window in
1012
+ it: measured against a real server, twenty concurrent callers naming `pending` produced **14
1013
+ winners** that way and **exactly 1** this way (`pg-transition.live.test.ts`). Legality is asked
1014
+ BEFORE the statement, because the table is a property of the declaration and not of the database.
1015
+ **The refusal is a read, and only ever after the decision** — `X_STATE_CONFLICT` names the state
1016
+ the row is really in, from a tenant-scoped `findById` that runs once the statement has already
1017
+ refused. Another org's row reads as absent, so the answer is `X_NOT_FOUND` and never a conflict
1018
+ that would confirm it exists. **The machine adds no DDL**: `enumerated()` already emits the CHECK,
1019
+ so there is one declaration of what a legal value is. **A machine column may not be nullable** —
1020
+ NULL is not a state, and `= NULL` matches no row, so every move out of it would read as a
1021
+ conflict. **`whyNot` asks three questions in one order** — unknown state, then terminal, then the
1022
+ legal list — because an unknown state has no outgoing moves either, and a check that skipped it
1023
+ reported a typo as "the row is terminal in `pendign`".
941
1024
  - Never throw a bare `Error` — use `errors.ts`.
942
1025
  - Tests restore the process-global registry in `afterAll` (`clearRegistry()`): a leaked registry
943
1026
  breaks an unrelated package's tests, as it did in `@ultimat3/policy`.
@@ -951,8 +1034,15 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
951
1034
  | `columns-data.ts` | the wide vocabulary an existing schema needs: `json`, `decimal`, `date`, `bigint`, `bytes`, `arrayOf` |
952
1035
  | `array-element.ts` | which element kinds `arrayOf()` refuses, and the one-line edit that repairs each |
953
1036
  | `refuse.ts` | `refuseColumn`/`refuseInvariant` — the refusals raised before any entity exists, each carrying the EDIT that repairs it |
954
- | `expr.ts` / `invariants.ts` | the `invariants: (c) => …` rule language; bind + `toSql()` DDL |
1037
+ | `expr.ts` / `invariants.ts` | the `invariants: (c) => …` rule language; `bindInvariant` resolves property paths to physical names. No DDL — that is `@ultimat3/db`'s `invariant-ddl.ts` |
955
1038
  | `entity.ts` / `describe.ts` | `entity()`, `$row`; the `EntityDescription` projection |
1039
+ | `index-name.ts` | what an index is CALLED — the predicate/direction/method discriminator and the 63-byte bound |
1040
+ | `search.ts` | the generated `tsvector` a `.searchable()` column set derives: the closed language list, the weights, the expression |
1041
+ | `state-machine.ts` | the transition table, its five declaration rules, and what a terminal state IS |
1042
+ | `transition.ts` | one atomic move: the legality question, the conditional statement, the diagnosis of a statement that matched nothing |
1043
+ | `enum-column.ts` | `enumerated()` and its own chain — the one builder that may declare a machine |
1044
+ | `column-values.ts` | `got()` and `oneOf()`, so `enum-column.ts` needs no import of the file that imports it |
1045
+ | `feature-errors.ts` | the refusals search and the state machine raise at call time; the codes and titles stay in `errors.ts` |
956
1046
  | `view.ts` | `$view(keys)` — the row projection an action names as its `output` |
957
1047
  | `query.ts` / `database.ts` | chainable read to a cursor page; `database()` + `Driver` |
958
1048
  | `clock.ts` | `entityNow()` — the ONE clock read on the write path, `ctx.clock` else the system's |
package/README.md CHANGED
@@ -61,7 +61,7 @@ still imports one package: `import { entity, t } from '@ultimat3/entity'`.
61
61
  |---|---|---|
62
62
  | `uuid()`, `uuid<PostId>()` | `uuid`; `.primaryKey()` defaults to v7 | time-ordered keys keep the pk index append-friendly; the optional brand is declared once and survives to every signature |
63
63
  | `timestamp()` | `timestamptz` | UTC storage is not a per-table decision; there is no naive variant |
64
- | `money()` | `<name>_minor bigint` + `<name>_currency char(3)` + `<name>_scale integer null` | never a float, never one implied currency. The row value is `@ultimat3/schema`'s `MoneyValue` — the same declaration `@ultimat3/money`'s `Money` is — so a decoded row goes straight to `add()`/`formatMoney()`. A writer may hand a `bigint`; a stored minor unit past ±2^53 is refused on read, never rounded. `scale` is the decimal exponent `minor` counts in when it is not the currency's own (`{ minor: 2, currency: 'USD', scale: 6 }` is $0.000002); NULL in the column means "the currency's own minor unit" and round-trips as an ABSENT key, never as `0` |
64
+ | `money()` | `<name>_minor bigint` + `<name>_currency char(3)` + `<name>_scale integer null` | never a float, never one implied currency. The row value is `@ultimat3/schema`'s `MoneyValue` — the same declaration `@ultimat3/money`'s `Money` is — so a decoded row goes straight to `add()`/`formatMoney()`. A writer may hand a `bigint` — `Insertable` says so on a table, `RowWrite<Row>` at a repository, and every whole-row write narrows it at its own entry, before an invariant or a statement sees it; a minor unit past ±2^53 is refused there and on read, never rounded. `scale` is the decimal exponent `minor` counts in when it is not the currency's own (`{ minor: 2, currency: 'USD', scale: 6 }` is $0.000002); NULL in the column means "the currency's own minor unit" and round-trips as an ABSENT key, never as `0` |
65
65
  | `enumerated(v)` | `text` + CHECK | a variant is a one-line migration, not `ALTER TYPE` |
66
66
  | `tz(zones)`, `locale(tags)` | `text` + CHECK, `Intl`-validated at declaration | an offset is not a time zone |
67
67
  | `text({ max })`, `integer()`, `boolean()`, `url()` | `text`/`integer`/`boolean` + CHECK | format is enforced by the database too |
@@ -142,9 +142,13 @@ Nothing is checked at runtime: a brand has no witness, `$parse` still validates
142
142
  One declaration, two enforcement points: the app checks it on every write, and the migration
143
143
  emits it. A bulk import or a `psql` session hits the same rule.
144
144
 
145
+ The DDL is `@ultimat3/db`'s — one renderer, reading `$describe()`, so what `x db gen` writes is the
146
+ only spelling there is. On a table this migration creates the check is an inline clause; on one that
147
+ already exists it is the `alter table` beside it.
148
+
145
149
  ```sql
146
- ALTER TABLE "posts" ADD CONSTRAINT "posts_post_like_count_non_negative_check" CHECK (like_count >= 0);
147
- CREATE UNIQUE INDEX "posts_post_slug_unique_per_org_key" ON "posts" ("org_id", "slug");
150
+ alter table "posts" add constraint "posts_post_like_count_non_negative_check" check (like_count >= 0);
151
+ create unique index "posts_post_slug_unique_per_org_key" on "posts" ("org_id", "slug");
148
152
  ```
149
153
 
150
154
  A rule written as a JS predicate — `c.slug.matches(isValidSlug)`, `c.satisfies(fn, [...])` —
@@ -325,6 +329,92 @@ export const posts = entity('posts', {
325
329
  | Refused | a unique GIN and an ordered GIN, at `entity()` — Postgres has neither, and the refusal names the edit |
326
330
  | 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
331
 
332
+ ## Full-text search
333
+
334
+ `.searchable()` on a `text()` column puts it in the entity's **one** generated `tsvector`, with a
335
+ GIN index on it. Nothing else to declare, and no second column on the row.
336
+
337
+ ```ts
338
+ import { database, entity, text, timestamp, uuid } from '@ultimat3/entity';
339
+
340
+ declare const orgId: string;
341
+ declare const term: string; // what the user typed, verbatim
342
+
343
+ const posts = entity('posts', {
344
+ columns: {
345
+ id: uuid().primaryKey(),
346
+ orgId: uuid().tenant(),
347
+ title: text({ max: 120 }).searchable('A'), // 'A' outranks 'D' under ts_rank
348
+ body: text().nullable().searchable(), // 'D' by default, Postgres' own
349
+ createdAt: timestamp().defaultNow(),
350
+ },
351
+ // Only when the defaults do not fit: the column is `search_tsv`, the language is 'english'.
352
+ search: { column: 'search_tsv', language: 'english' },
353
+ });
354
+
355
+ const db = database({ posts });
356
+
357
+ await db.posts.where({ orgId }).search(term).orderBy('createdAt', 'desc').limit(20).page();
358
+ ```
359
+
360
+ | Fact | Why |
361
+ |---|---|
362
+ | 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 |
363
+ | 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 |
364
+ | the column is `generated always as (…) stored`, `not null` | the database computes it on every write, including one made from psql |
365
+ | tenancy, soft delete, the projection, the order and the cursor are unchanged | `.search()` is one more predicate on the chain you already had |
366
+ | `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` |
367
+ | 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 |
368
+
369
+ ## A state machine over a column
370
+
371
+ `.transitions()` on an `enumerated()` column. The states are yours; the machine is the framework's.
372
+
373
+ ```ts
374
+ import { database, entity, enumerated, timestamp, uuid } from '@ultimat3/entity';
375
+
376
+ declare const id: string;
377
+
378
+ const ORDER_STATES = ['pending', 'paid', 'shipped', 'delivered', 'cancelled'] as const;
379
+
380
+ const orders = entity('orders', {
381
+ columns: {
382
+ id: uuid().primaryKey(),
383
+ orgId: uuid().tenant(),
384
+ status: enumerated(ORDER_STATES)
385
+ .transitions({
386
+ pending: ['paid', 'cancelled'],
387
+ paid: ['shipped', 'cancelled'],
388
+ shipped: ['delivered'],
389
+ delivered: [], // terminal — nothing leaves it, and an empty list is how you say so
390
+ cancelled: [],
391
+ })
392
+ .default('pending'),
393
+ updatedAt: timestamp().defaultNow().onUpdateNow(),
394
+ },
395
+ });
396
+
397
+ const db = database({ orders });
398
+
399
+ // One statement. `from` is the state you believe the row is in, and it rides in the predicate.
400
+ const shipped = await db.orders.transition('status', id, { from: 'paid', to: 'shipped' });
401
+ ```
402
+
403
+ | Fact | Why |
404
+ |---|---|
405
+ | 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 |
406
+ | **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 |
407
+ | 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 |
408
+ | 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 |
409
+ | another org's row is `X_NOT_FOUND`, never a conflict | a conflict would confirm the row exists and name its state |
410
+ | a terminal state is one with an empty list | derived. *Which* state is terminal is yours |
411
+ | `onUpdateNow()` moves, because a transition is an update | the audit of *when* it moved, with no second mechanism beside it |
412
+ | the CHECK comes from `enumerated()` | the machine emits no DDL of its own — one declaration of what a legal value is |
413
+ | `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 |
414
+
415
+ What is deliberately **not** here: who may make a move, what happens on arrival, an approval chain,
416
+ a reason code. Those differ per app — wrap `transition()` in your own function and put them there.
417
+
328
418
  ## Counting by a column
329
419
 
330
420
  `As of 2026-08`. `count()` answers one number, so a screen or a backfill that needs one per row
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "12.0.0",
3
+ "version": "14.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": "12.0.0",
35
- "@ultimat3/db": "12.0.0",
36
- "@ultimat3/schema": "12.0.0",
37
- "@ultimat3/time": "12.0.0"
34
+ "@ultimat3/core": "14.0.0",
35
+ "@ultimat3/db": "14.0.0",
36
+ "@ultimat3/schema": "14.0.0",
37
+ "@ultimat3/time": "14.0.0"
38
38
  }
39
39
  }
@@ -0,0 +1,29 @@
1
+ // Two things every column builder needs and neither owns: how a rejected value is DESCRIBED, and
2
+ // the CHECK a closed set of values emits. Here rather than in `columns.ts` so `enum-column.ts` can
3
+ // read them without importing the file that imports it.
4
+
5
+ import { describeValue } from '@ultimat3/schema';
6
+
7
+ /**
8
+ * The rejected value, rendered as its SHAPE and never its content — `@ultimat3/schema`'s
9
+ * `describeValue`, the same renderer every builtin validator fails through, so a column and a
10
+ * schema describe one bad value the same way.
11
+ *
12
+ * WHY it is not `String(value)`: a column rejection is not a private diagnostic. It becomes
13
+ * `X_INVARIANT_VIOLATED`'s `cause` and a `$view` issue, which `@ultimat3/http` returns to the
14
+ * caller AND writes into the log line — and core's logger redacts by KEY, so a value baked into a
15
+ * message has no key left to redact. `text()` on a password field wrote the mistyped password to
16
+ * the central log index in cleartext and into the user's own network tab; a `uuid()` holding an
17
+ * API key surrogate does the same. A column is the worse half of that pair, because the value can
18
+ * arrive from the DATABASE — so the leak is not bounded by what someone just typed.
19
+ *
20
+ * `got` stays `got` and the "expected …" half is untouched: only what follows it changes.
21
+ */
22
+ export const got = (value: unknown): string => `got ${describeValue(value)}`;
23
+
24
+ const quote = (value: string): string => `'${value.replaceAll("'", "''")}'`;
25
+
26
+ export const oneOf =
27
+ (values: readonly string[]) =>
28
+ (name: string): string =>
29
+ `${name} in (${values.map(quote).join(', ')})`;
package/src/column.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { invariantViolated } from './errors';
10
10
  import { refuseColumn } from './refuse';
11
+ import { DEFAULT_SEARCH_WEIGHT, isSearchWeight } from './search';
11
12
  import type {
12
13
  AnyColumn,
13
14
  Column,
@@ -181,6 +182,27 @@ export const makeColumn = <T, Optional extends boolean>(
181
182
 
182
183
  unique: () => makeColumn<T, Optional>({ ...meta, unique: true }, parse, optional),
183
184
 
185
+ searchable: (weight = DEFAULT_SEARCH_WEIGHT) => {
186
+ // Refused where the chain was written, because the alternative is a `to_tsvector` over a cast
187
+ // the DDL cannot express: `to_tsvector` takes text, and a `jsonb` or a `timestamptz` reaching
188
+ // it is a `42883` inside `ROLE=migrate`, with the server's words and none of the column's.
189
+ if (meta.kind !== 'text') {
190
+ refuseColumn(
191
+ 'searchable',
192
+ `a ${meta.kind} column is not searchable — full text search reads text`,
193
+ 'text().searchable() — index a text() column, and store the searchable projection of a structured value in one of its own',
194
+ );
195
+ }
196
+ if (!isSearchWeight(weight)) {
197
+ refuseColumn(
198
+ 'searchable',
199
+ `"${String(weight)}" is not a search weight`,
200
+ "text().searchable('A') — one of A, B, C or D, biggest first; omit it for D",
201
+ );
202
+ }
203
+ return makeColumn<T, Optional>({ ...meta, searchable: weight }, parse, optional);
204
+ },
205
+
184
206
  tenant: () => makeColumn<T, Optional>({ ...meta, tenant: true, index: true }, parse, optional),
185
207
 
186
208
  references: (target, options = {}) =>
package/src/columns.ts CHANGED
@@ -5,7 +5,6 @@
5
5
  import { uuid as uuidV7 } from '@ultimat3/core';
6
6
  import {
7
7
  CURRENCY_CODE_PATTERN,
8
- describeValue,
9
8
  isCurrencyCode,
10
9
  isMoneyScale,
11
10
  MAX_MONEY_SCALE,
@@ -19,6 +18,7 @@ import {
19
18
  makeColumn,
20
19
  makeTimestamp,
21
20
  } from './column';
21
+ import { got, oneOf } from './column-values';
22
22
  import { refuseColumn } from './refuse';
23
23
  import type {
24
24
  Column,
@@ -27,27 +27,11 @@ import type {
27
27
  MoneyColumnNames,
28
28
  MoneyInput,
29
29
  MoneyValue,
30
+ RowWrite,
30
31
  TimestampColumn,
31
32
  UuidColumn,
32
33
  } from './types';
33
34
 
34
- /**
35
- * The rejected value, rendered as its SHAPE and never its content — `@ultimat3/schema`'s
36
- * `describeValue`, the same renderer every builtin validator fails through, so a column and a
37
- * schema describe one bad value the same way.
38
- *
39
- * WHY it is not `String(value)`: a column rejection is not a private diagnostic. It becomes
40
- * `X_INVARIANT_VIOLATED`'s `cause` and a `$view` issue, which `@ultimat3/http` returns to the
41
- * caller AND writes into the log line — and core's logger redacts by KEY, so a value baked into a
42
- * message has no key left to redact. `text()` on a password field wrote the mistyped password to
43
- * the central log index in cleartext and into the user's own network tab; a `uuid()` holding an
44
- * API key surrogate does the same. A column is the worse half of that pair, because the value can
45
- * arrive from the DATABASE — so the leak is not bounded by what someone just typed.
46
- *
47
- * `got` stays `got` and the "expected …" half is untouched: only what follows it changes.
48
- */
49
- const got = (value: unknown): string => `got ${describeValue(value)}`;
50
-
51
35
  /** uuid v7: time-ordered, so a primary key index stays append-friendly. */
52
36
  export const newId = (): string => uuidV7();
53
37
 
@@ -152,34 +136,6 @@ const parseInstant = (value: unknown): Date => {
152
136
  export const timestamp = (): TimestampColumn =>
153
137
  makeTimestamp<false>({ ...BARE, kind: 'timestamptz' }, parseInstant, false);
154
138
 
155
- const quote = (value: string): string => `'${value.replaceAll("'", "''")}'`;
156
-
157
- const oneOf =
158
- (values: readonly string[]) =>
159
- (name: string): string =>
160
- `${name} in (${values.map(quote).join(', ')})`;
161
-
162
- /**
163
- * A closed set of strings, emitted as a CHECK rather than a Postgres `ENUM` type: adding a
164
- * variant is then a one-line migration instead of `ALTER TYPE`, which cannot run inside a
165
- * transaction on older servers.
166
- */
167
- export const enumerated = <const V extends readonly string[]>(values: V): Column<V[number]> => {
168
- const allowed = new Set<string>(values);
169
- return column<V[number]>(
170
- 'text',
171
- (value) =>
172
- typeof value === 'string' && allowed.has(value)
173
- ? value
174
- : refuseColumn(
175
- 'enum',
176
- `expected one of ${values.join(' | ')}, ${got(value)}`,
177
- 'store one of the values enumerated() declares, or add the new variant to that list and run x db gen "extend the enum check" — the values are a CHECK constraint, so the table moves with them',
178
- ),
179
- { values, check: oneOf(values) },
180
- );
181
- };
182
-
183
139
  /**
184
140
  * An absolute http(s) URL, validated on write rather than on render: a bad URL stored once is
185
141
  * served to every reader, and `<img src>` fails silently in the browser.
@@ -432,6 +388,22 @@ export const narrowMoney = <Row>(columns: ColumnMap, row: Row): Row => {
432
388
  return (narrowed ?? row) as Row;
433
389
  };
434
390
 
391
+ /**
392
+ * The same narrowing at a write METHOD'S entry, typed honestly: what a caller may spell in,
393
+ * the row the entity declares out.
394
+ *
395
+ * `narrowMoney` is `<Row>(columns, row: Row): Row` — sound for `bindValues`, whose input and
396
+ * output are both a patch, and a lie for a full row that arrived as `RowWrite<Row>`, which is
397
+ * exactly the position `Repo.insert`/`insertAll`/`upsertAll` are in. Narrowing here rather than at
398
+ * `bindValues` is also what makes an `assert` invariant judge the value the row will HOLD instead
399
+ * of the spelling a caller happened to use: `entity.$assert` runs before the statement exists, so
400
+ * a rule reading `total.minor` saw the caller's `bigint` in both drivers and the stored `number`
401
+ * nowhere. The `as` is the one assertion this file is for — every money property is a `MoneyValue`
402
+ * once `narrowMoney` has returned, and `parseMinor` threw for anything that could not become one.
403
+ */
404
+ export const narrowRow = <Row>(columns: ColumnMap, values: RowWrite<Row>): Row =>
405
+ narrowMoney(columns, values) as Row;
406
+
435
407
  /**
436
408
  * The CHECK that stops a psql session writing a currency the app would refuse — the app's own
437
409
  * bound, projected into SQL rather than restated in it.
@@ -454,3 +426,7 @@ export const currencyCheck = (currencyColumn: string): string =>
454
426
  */
455
427
  export const scaleCheck = (scaleColumn: string): string =>
456
428
  `${scaleColumn} is null or (${scaleColumn} >= 0 and ${scaleColumn} <= ${MAX_MONEY_SCALE})`;
429
+
430
+ // `enumerated()` lives in `enum-column.ts` — it is the one builder with a chain of its own, and
431
+ // splitting it is what kept this file under the ceiling. Re-exported so no caller had to move.
432
+ export { enumerated } from './enum-column';
package/src/describe.ts CHANGED
@@ -9,6 +9,7 @@ import { columnName, moneyColumns, referenceBinding } from './column';
9
9
  import { currencyCheck, scaleCheck } from './columns';
10
10
  import type { Invariant } from './invariants';
11
11
  import type { ColumnDescription, EntityDescription, ReferenceDescription } from './registry';
12
+ import type { SearchVector } from './search';
12
13
  import type { AnyColumn, ColumnMeta, IndexDef } from './types';
13
14
 
14
15
  export interface DescribeInput<Row> {
@@ -23,8 +24,34 @@ export interface DescribeInput<Row> {
23
24
  readonly cacheTag: string;
24
25
  readonly softDelete: boolean;
25
26
  readonly tenantColumn: string | null;
27
+ /** The generated `tsvector`, when any column is `.searchable()`. */
28
+ readonly search?: SearchVector | null;
26
29
  }
27
30
 
31
+ /**
32
+ * The search vector as a physical column: `tsvector`, computed by the database, never written.
33
+ *
34
+ * `notNull` is what makes a missing `generated` clause LOUD rather than silent. Every function in
35
+ * the expression is total over a coalesced text, so the value can never be NULL — and if a
36
+ * generator that does not yet render `generated` emits the column as a plain `tsvector`, the first
37
+ * insert is a `23502` naming this column, instead of a table of NULL vectors where every search
38
+ * quietly answers nothing.
39
+ */
40
+ const describeSearchColumn = (search: SearchVector): ColumnDescription => ({
41
+ // `$`-prefixed: a property key no column can be spelled as, because nothing may address it.
42
+ property: '$search',
43
+ column: search.column,
44
+ kind: 'tsvector',
45
+ notNull: true,
46
+ primaryKey: false,
47
+ unique: false,
48
+ hasDefault: false,
49
+ check: null,
50
+ references: null,
51
+ onDelete: null,
52
+ generated: search.expression,
53
+ });
54
+
28
55
  /**
29
56
  * The foreign keys an entity declares, resolved through the one binding resolver. Money is
30
57
  * skipped for the reason the DDL projection drops a reference there too: one property is two
@@ -143,6 +170,9 @@ const describeColumn = <Row>(
143
170
  primaryKey: meta.primaryKey || input.primaryKey.includes(property),
144
171
  unique: meta.unique,
145
172
  hasDefault: meta.default !== undefined,
173
+ // The value beside the boolean: `@ultimat3/db` renders it, and without it the generator
174
+ // could only infer two expressions and dropped every other default without saying so.
175
+ ...(meta.default === undefined ? {} : { default: meta.default }),
146
176
  check: meta.check?.(physical) ?? null,
147
177
  // Rendered from the resolved record, so the string a migration reads and the record a
148
178
  // traversal reads can never disagree about what a `references()` points at.
@@ -170,15 +200,23 @@ export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescriptio
170
200
  name: input.name,
171
201
  table: input.table,
172
202
  primaryKey: input.primaryKey.map(physicalOf),
173
- columns: input.columns.flatMap(([property, column]) =>
174
- describeColumn(input, property, column.$meta, references.get(property)),
175
- ),
203
+ columns: [
204
+ ...input.columns.flatMap(([property, column]) =>
205
+ describeColumn(input, property, column.$meta, references.get(property)),
206
+ ),
207
+ // LAST, so every column an author declared keeps the position it had and no snapshot of an
208
+ // entity without a search vector moves.
209
+ ...(input.search == null ? [] : [describeSearchColumn(input.search)]),
210
+ ],
211
+ // `columns` rides along whole: the generator needs the list a `unique` names, and recovering
212
+ // it by splitting `sql` is reading this package's own rendering back. See InvariantDescription.
176
213
  invariants: input.invariants.map((inv) => ({
177
214
  name: inv.name,
178
215
  kind: inv.kind,
179
216
  message: inv.message,
180
217
  sql: inv.sql,
181
218
  where: inv.where ?? null,
219
+ columns: inv.columns,
182
220
  })),
183
221
  // Projected whole, never reduced to the name: the generator spells the column list from this
184
222
  // and a name cannot be parsed back into one. See `IndexDescription`.