@ultimat3/entity 12.0.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 CHANGED
@@ -938,6 +938,52 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
938
938
  `EntityError` inline** rather than delegating to a shared one, because `fix-scan.ts` reads a fix
939
939
  literal only at a call site whose callee builds the error itself — a wrapper would take all 34
940
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`".
941
987
  - Never throw a bare `Error` — use `errors.ts`.
942
988
  - Tests restore the process-global registry in `afterAll` (`clearRegistry()`): a leaked registry
943
989
  breaks an unrelated package's tests, as it did in `@ultimat3/policy`.
@@ -953,6 +999,13 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
953
999
  | `refuse.ts` | `refuseColumn`/`refuseInvariant` — the refusals raised before any entity exists, each carrying the EDIT that repairs it |
954
1000
  | `expr.ts` / `invariants.ts` | the `invariants: (c) => …` rule language; bind + `toSql()` DDL |
955
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` |
956
1009
  | `view.ts` | `$view(keys)` — the row projection an action names as its `output` |
957
1010
  | `query.ts` / `database.ts` | chainable read to a cursor page; `database()` + `Driver` |
958
1011
  | `clock.ts` | `entityNow()` — the ONE clock read on the write path, `ctx.clock` else the system's |
package/README.md CHANGED
@@ -325,6 +325,92 @@ export const posts = entity('posts', {
325
325
  | Refused | a unique GIN and an ordered GIN, at `entity()` — Postgres has neither, and the refusal names the edit |
326
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
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
+
328
414
  ## Counting by a column
329
415
 
330
416
  `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": "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": "12.0.0",
35
- "@ultimat3/db": "12.0.0",
36
- "@ultimat3/schema": "12.0.0",
37
- "@ultimat3/time": "12.0.0"
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
  }
@@ -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,
@@ -31,23 +31,6 @@ import type {
31
31
  UuidColumn,
32
32
  } from './types';
33
33
 
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
34
  /** uuid v7: time-ordered, so a primary key index stays append-friendly. */
52
35
  export const newId = (): string => uuidV7();
53
36
 
@@ -152,34 +135,6 @@ const parseInstant = (value: unknown): Date => {
152
135
  export const timestamp = (): TimestampColumn =>
153
136
  makeTimestamp<false>({ ...BARE, kind: 'timestamptz' }, parseInstant, false);
154
137
 
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
138
  /**
184
139
  * An absolute http(s) URL, validated on write rather than on render: a bad URL stored once is
185
140
  * served to every reader, and `<img src>` fails silently in the browser.
@@ -454,3 +409,7 @@ export const currencyCheck = (currencyColumn: string): string =>
454
409
  */
455
410
  export const scaleCheck = (scaleColumn: string): string =>
456
411
  `${scaleColumn} is null or (${scaleColumn} >= 0 and ${scaleColumn} <= ${MAX_MONEY_SCALE})`;
412
+
413
+ // `enumerated()` lives in `enum-column.ts` — it is the one builder with a chain of its own, and
414
+ // splitting it is what kept this file under the ceiling. Re-exported so no caller had to move.
415
+ 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
@@ -170,9 +197,14 @@ export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescriptio
170
197
  name: input.name,
171
198
  table: input.table,
172
199
  primaryKey: input.primaryKey.map(physicalOf),
173
- columns: input.columns.flatMap(([property, column]) =>
174
- describeColumn(input, property, column.$meta, references.get(property)),
175
- ),
200
+ columns: [
201
+ ...input.columns.flatMap(([property, column]) =>
202
+ describeColumn(input, property, column.$meta, references.get(property)),
203
+ ),
204
+ // LAST, so every column an author declared keeps the position it had and no snapshot of an
205
+ // entity without a search vector moves.
206
+ ...(input.search == null ? [] : [describeSearchColumn(input.search)]),
207
+ ],
176
208
  invariants: input.invariants.map((inv) => ({
177
209
  name: inv.name,
178
210
  kind: inv.kind,
package/src/entity.ts CHANGED
@@ -13,10 +13,13 @@ import { describeEntity, describeReferences } from './describe';
13
13
  import { invariantViolated } from './errors';
14
14
  import type { Expr, InvariantColumns, Resolve } from './expr';
15
15
  import { invariantColumns } from './expr';
16
+ import { indexName } from './index-name';
16
17
  import type { Invariant, InvariantDef } from './invariants';
17
18
  import { assertInvariants, bindInvariant, invariantsToSql } from './invariants';
18
19
  import type { EntityDescription, ReferenceDescription } from './registry';
19
20
  import { registerEntity } from './registry';
21
+ import type { SearchInit, SearchSource, SearchVector } from './search';
22
+ import { searchVectorOf } from './search';
20
23
  import { resolveTenantColumn } from './tenancy';
21
24
  import type { AnyColumn, ColumnMap, ColumnMeta, IndexDef, RowOf } from './types';
22
25
  import type { EntityView } from './view';
@@ -73,6 +76,12 @@ export interface EntityInit<C extends ColumnMap> {
73
76
  */
74
77
  readonly invariants?: (columns: InvariantColumns<C>) => readonly InvariantDef[];
75
78
  readonly indexes?: readonly IndexInit<C>[];
79
+ /**
80
+ * Full-text search, when the two defaults do not fit: `search_tsv` and `'english'`. WHICH columns
81
+ * are searched is `.searchable()` on the columns themselves, never restated here — this is the
82
+ * adoption escape, exactly as `table` and `.column()` are.
83
+ */
84
+ readonly search?: SearchInit;
76
85
  /** Extra cache tags this entity participates in, beyond its own. */
77
86
  readonly tags?: readonly string[];
78
87
  }
@@ -95,6 +104,11 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
95
104
  readonly $softDelete: boolean;
96
105
  /** Property key of the tenant column, or `null`. Presence is what turns tenancy on. */
97
106
  readonly $tenantColumn: string | null;
107
+ /**
108
+ * The generated `tsvector` this entity's `.searchable()` columns derive, or `null` when none is.
109
+ * Presence is what makes `.search(text)` legal — both drivers read it, and neither invents one.
110
+ */
111
+ readonly $search: SearchVector | null;
98
112
  /** Phantom: `type Post = typeof posts.$row`. Reading it at runtime throws. */
99
113
  readonly $row: Row;
100
114
  /** The Standard Schema the columns already describe — forms and actions hand input to it. */
@@ -125,88 +139,6 @@ export type Entity<Row, C extends ColumnMap = ColumnMap> = EntityCore<Row, C> &
125
139
 
126
140
  const MONEY_PARTS = new Set(['minor', 'currency']);
127
141
 
128
- /**
129
- * What separates two indexes on the SAME columns: the predicate and the direction. Eight hex
130
- * characters of sha256 over both — deterministic across processes, so a name is a property of the
131
- * declaration and never of the run that generated it.
132
- */
133
- /**
134
- * What separates two indexes on the SAME columns: the predicate, the direction and the ACCESS
135
- * METHOD. Eight hex characters of sha256 over all three — deterministic across processes, so a
136
- * name is a property of the declaration and never of the run that generated it.
137
- *
138
- * The method belongs here for exactly the reason `where` does. A btree on an `arrayOf()` column
139
- * answers `=` and an ordering; a GIN on the same column answers `@>` / `<@` / `&&`. They are two
140
- * distinct indexes, and without the method in the name both are `<table>_<cols>_idx` — where the
141
- * dedup below drops one in silence (the defect this discriminator was added for) or, since that
142
- * dedup is now on the whole definition, two `create index` statements share one name and the
143
- * migration is `42P07`.
144
- */
145
- const indexDiscriminator = (
146
- order: string | undefined,
147
- where: string | null,
148
- using: string | undefined,
149
- ): string =>
150
- new Bun.CryptoHasher('sha256')
151
- // The method is APPENDED only when one was declared, never as an empty field: every name this
152
- // function has ever minted for a partial or ordered index is therefore unchanged by the method
153
- // existing, and an index that declares no method is byte-identical to the one it was.
154
- .update(`${order ?? ''}|${where ?? ''}${using === undefined ? '' : `|${using}`}`)
155
- .digest('hex')
156
- .slice(0, 8);
157
-
158
- /**
159
- * `<table>_<columns>_idx`, plus a discriminator when — and only when — the index carries a
160
- * predicate, a direction or a non-default access method.
161
- *
162
- * Only then, because the plain name is load-bearing in two places: `unique()` on a column is an
163
- * inline column clause and Postgres names the index it creates exactly `<table>_<column>_key`, so
164
- * a discriminator there would make the generator emit a second `create unique index` for an index
165
- * that already exists (`42P07`); and a foreign key's own index is deduped against a hand-declared
166
- * one by this name.
167
- *
168
- * Without it, two DIFFERENT partial indexes on one column were one name — `posts_author_id_idx`
169
- * for both `where status = 'published'` and `where status = 'draft'` — and the dedup below dropped
170
- * the second with no error, no warning and no drift finding, since a declared index is matched by
171
- * name.
172
- */
173
- /**
174
- * `NAMEDATALEN - 1`. Postgres truncates a longer identifier and says NOTHING, so two index names
175
- * sharing their first 63 bytes become one index on the server — the same silent collapse the
176
- * discriminator above exists to prevent, one layer down, and invisible to a drift check comparing
177
- * DECLARED names because those still differ. Bytes and not characters: 63 is what the server
178
- * counts, and `.length` would stop seeing the truncation the moment a name is not ASCII.
179
- */
180
- const MAX_IDENTIFIER_BYTES = 63;
181
-
182
- const byteLength = (value: string): number => new TextEncoder().encode(value).length;
183
-
184
- const indexName = (
185
- entityName: string,
186
- table: string,
187
- columns: readonly string[],
188
- unique: boolean,
189
- order?: string | undefined,
190
- where: string | null = null,
191
- using?: IndexMethod | undefined,
192
- ): string => {
193
- const suffix = unique ? 'key' : 'idx';
194
- const base = `${table}_${columns.join('_')}`;
195
- const plain = order === undefined && where === null && using === undefined;
196
- const name = plain
197
- ? `${base}_${suffix}`
198
- : `${base}_${indexDiscriminator(order, where, using)}_${suffix}`;
199
- const bytes = byteLength(name);
200
- if (bytes <= MAX_IDENTIFIER_BYTES) return name;
201
- throw invariantViolated(
202
- entityName,
203
- 'index',
204
- `the index on (${columns.join(', ')}) is named "${name}", which is ${bytes} bytes — ` +
205
- `Postgres truncates an identifier at ${MAX_IDENTIFIER_BYTES} and does not say so, ` +
206
- 'so two indexes can silently become one',
207
- );
208
- };
209
-
210
142
  const defaultValue = (meta: ColumnMeta): unknown => {
211
143
  const declared = meta.default;
212
144
  if (declared === undefined) return undefined;
@@ -230,6 +162,22 @@ export const entity = <const C extends ColumnMap>(
230
162
  const softDelete = Object.hasOwn(init.columns, SOFT_DELETE_COLUMN);
231
163
  const tenantColumn = resolveTenantColumn(name, init.columns, init.tenant);
232
164
 
165
+ const searchSources: readonly SearchSource[] = entries.flatMap(([property, column]) => {
166
+ const weight = column.$meta.searchable;
167
+ return weight === undefined ? [] : [{ column: columnName(property, column.$meta), weight }];
168
+ });
169
+ const search = searchVectorOf(
170
+ searchSources,
171
+ init.search,
172
+ // `physical`, not `candidate`: a parameter whose NAME reads like a credential is what
173
+ // `bun run secret-compare` refuses an `===` on, and this is a column name.
174
+ (physical) =>
175
+ entries.some(([property, column]) => columnName(property, column.$meta) === physical),
176
+ (subject, detail) => {
177
+ throw invariantViolated(name, subject, detail);
178
+ },
179
+ );
180
+
233
181
  const primaryKey =
234
182
  init.primaryKey ?? entries.filter(([, column]) => column.$meta.primaryKey).map(([key]) => key);
235
183
  if (primaryKey.length === 0) {
@@ -342,6 +290,19 @@ export const entity = <const C extends ColumnMap>(
342
290
  ...(index.using === undefined || index.using === 'btree' ? {} : { using: index.using }),
343
291
  };
344
292
  }),
293
+ // The one index nobody declared and every search needs. Through the SAME `IndexInit` path a
294
+ // hand-written `using: 'gin'` takes — `indexName` gives it the method discriminator, so it can
295
+ // never collide with a btree an author declares on the same column.
296
+ ...(search === null
297
+ ? []
298
+ : [
299
+ {
300
+ name: indexName(name, table, [search.column], false, undefined, null, 'gin'),
301
+ columns: [search.column],
302
+ unique: false,
303
+ using: 'gin' as IndexMethod,
304
+ },
305
+ ]),
345
306
  ];
346
307
  /**
347
308
  * A foreign key already indexes its column; naming it again in `indexes` is not two indexes.
@@ -379,6 +340,7 @@ export const entity = <const C extends ColumnMap>(
379
340
  cacheTag,
380
341
  softDelete,
381
342
  tenantColumn,
343
+ search,
382
344
  });
383
345
  const references = (): readonly ReferenceDescription[] => describeReferences(name, entries);
384
346
 
@@ -423,6 +385,7 @@ export const entity = <const C extends ColumnMap>(
423
385
  $cacheTag: cacheTag,
424
386
  $softDelete: softDelete,
425
387
  $tenantColumn: tenantColumn,
388
+ $search: search,
426
389
  $schema: {
427
390
  '~standard': {
428
391
  version: 1,
@@ -0,0 +1,81 @@
1
+ // The one column that may declare a state machine, and the only one that could: `enumerated()`
2
+ // already declares the closed set of values a machine moves through, as a CHECK the migration
3
+ // emits. Split from `columns.ts` at the 500-line ceiling, along the seam the extra chain draws.
4
+
5
+ import { BARE, makeColumn } from './column';
6
+ import { got, oneOf } from './column-values';
7
+ import { refuseColumn } from './refuse';
8
+ import { stateMachineOf } from './state-machine';
9
+ import type { ColumnMeta, EnumeratedColumn } from './types';
10
+
11
+ /**
12
+ * A closed set of strings, emitted as a CHECK rather than a Postgres `ENUM` type: adding a
13
+ * variant is then a one-line migration instead of `ALTER TYPE`, which cannot run inside a
14
+ * transaction on older servers.
15
+ */
16
+ export const enumerated = <const V extends readonly string[]>(values: V): EnumeratedColumn<V> => {
17
+ const allowed = new Set<string>(values);
18
+ const parse = (value: unknown): V[number] =>
19
+ typeof value === 'string' && allowed.has(value)
20
+ ? value
21
+ : refuseColumn(
22
+ 'enum',
23
+ `expected one of ${values.join(' | ')}, ${got(value)}`,
24
+ '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',
25
+ );
26
+ return enumeratedWith<V, false>(
27
+ { ...BARE, kind: 'text', values, check: oneOf(values) },
28
+ values,
29
+ parse,
30
+ false,
31
+ );
32
+ };
33
+
34
+ /**
35
+ * Every link delegates to the general chain and re-wraps its `$meta`, so there is one definition of
36
+ * what `.default()` accepts and of how `.column()` validates a name — this file adds only the two
37
+ * rules the general chain cannot know: a machine may be declared here, and a column carrying one
38
+ * may not hold NULL.
39
+ */
40
+ const enumeratedWith = <V extends readonly string[], Optional extends boolean>(
41
+ meta: ColumnMeta,
42
+ values: V,
43
+ parse: (value: unknown) => V[number],
44
+ optional: Optional,
45
+ ): EnumeratedColumn<V, Optional> => {
46
+ const base = makeColumn<V[number], Optional>(meta, parse, optional);
47
+ return {
48
+ ...base,
49
+ transitions: (table) => {
50
+ // Refused in BOTH directions, so neither order of the chain can produce the column that has
51
+ // no answer: NULL is not one of the declared states, so nothing could say what it may move
52
+ // to — and the compare-and-set the write path uses compares it with `=`, where NULL matches
53
+ // no row at all and every transition out of it would read as a conflict.
54
+ if (!meta.notNull) {
55
+ refuseColumn(
56
+ 'transitions',
57
+ 'a state machine column may not hold null — null is not one of the declared states',
58
+ 'drop .nullable() from this column, or drop .transitions() — a row outside every state has no legal move',
59
+ );
60
+ }
61
+ return enumeratedWith<V, Optional>(
62
+ { ...meta, machine: stateMachineOf(values, table) },
63
+ values,
64
+ parse,
65
+ optional,
66
+ );
67
+ },
68
+ default: (value) => enumeratedWith<V, true>(base.default(value).$meta, values, parse, true),
69
+ column: (name) => enumeratedWith<V, Optional>(base.column(name).$meta, values, parse, optional),
70
+ nullable: () => {
71
+ if (meta.machine !== undefined) {
72
+ refuseColumn(
73
+ 'transitions',
74
+ 'a state machine column may not hold null — null is not one of the declared states',
75
+ 'drop .nullable() from this column, or drop .transitions() — a row outside every state has no legal move',
76
+ );
77
+ }
78
+ return base.nullable();
79
+ },
80
+ };
81
+ };