@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/src/errors.ts CHANGED
@@ -21,6 +21,11 @@ export const ENTITY_OWNED_ERROR_CODES = [
21
21
  'X_AGGREGATE_UNSUPPORTED',
22
22
  'X_AGGREGATE_MIXED_CURRENCY',
23
23
  'X_APPROXIMATE_COUNT_FILTERED',
24
+ 'X_SEARCH_UNDECLARED',
25
+ 'X_SEARCH_IN_MEMORY',
26
+ 'X_STATE_UNDECLARED',
27
+ 'X_STATE_TRANSITION_ILLEGAL',
28
+ 'X_STATE_CONFLICT',
24
29
  ] as const;
25
30
 
26
31
  /**
@@ -58,6 +63,11 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityOwnedErrorCode, string>>
58
63
  X_AGGREGATE_UNSUPPORTED: 'that column has no aggregate both drivers can answer alike',
59
64
  X_AGGREGATE_MIXED_CURRENCY: 'an amount was aggregated across currencies',
60
65
  X_APPROXIMATE_COUNT_FILTERED: 'an estimate was asked of a filtered chain',
66
+ X_SEARCH_UNDECLARED: 'this entity has no searchable column',
67
+ X_SEARCH_IN_MEMORY: 'the in-memory driver cannot answer a full-text match',
68
+ X_STATE_UNDECLARED: 'that column declares no state machine',
69
+ X_STATE_TRANSITION_ILLEGAL: 'the machine has no such transition',
70
+ X_STATE_CONFLICT: 'the row is no longer in the state this transition named',
61
71
  };
62
72
 
63
73
  // Registered at module load, unconditionally, in one call. Without this the registry humanises the
@@ -0,0 +1,121 @@
1
+ // The refusals the two DECLARED capabilities raise at call time — full-text search and a state
2
+ // machine. Split from `errors.ts` at the 500-line ceiling; the codes and their titles stay there,
3
+ // because a registry with two homes is a registry that disagrees with itself.
4
+
5
+ import { EntityError } from './errors';
6
+
7
+ /**
8
+ * A `matches` predicate on an entity whose columns declare no `.searchable()`.
9
+ *
10
+ * Raised where the STATEMENT would be built, not where the chain was written, because both drivers
11
+ * reach it: an entity's search vector is derived from its columns, so there is nothing else the
12
+ * predicate could name and no vector to guess at.
13
+ */
14
+ export const searchUndeclared = (entityName: string): EntityError =>
15
+ new EntityError({
16
+ code: 'X_SEARCH_UNDECLARED',
17
+ cause: `${entityName} has no searchable column, so there is no tsvector to match against`,
18
+ fix: `add .searchable() to a text() column of ${entityName}, then: x db gen "search ${entityName}"`,
19
+ });
20
+
21
+ /**
22
+ * A full-text match asked of `memoryDriver()`. REFUSED rather than emulated, and that is the whole
23
+ * decision: `to_tsvector` stems, drops stop words and applies a language's own rules, and
24
+ * `websearch_to_tsquery` parses quoted phrases and `-`negation — a JS token comparison is a
25
+ * DIFFERENT question with the same shape, so it would answer green in a unit test and differently
26
+ * in production, which is the one outcome the two-driver split exists to prevent.
27
+ */
28
+ export const searchInMemory = (entityName: string): EntityError =>
29
+ new EntityError({
30
+ code: 'X_SEARCH_IN_MEMORY',
31
+ cause: `memoryDriver() cannot stem, weight or rank a tsvector, so a search of ${entityName} has no answer it could give that Postgres would agree with`,
32
+ fix: `move this read into a <name>.live.test.ts and run it with TEST_DATABASE_URL set — bun test packages/entity/src/pg-search.live.test.ts is the model`,
33
+ });
34
+
35
+ /**
36
+ * `transition()` on a column that declares no `.transitions()`.
37
+ *
38
+ * A declaration bug and not a caller's, which is why it lists the columns that DO declare one: the
39
+ * repair is naming a different column or writing the table, and both are edits to source.
40
+ */
41
+ export const stateUndeclared = (
42
+ entityName: string,
43
+ column: string,
44
+ machines: readonly string[],
45
+ ): EntityError =>
46
+ new EntityError({
47
+ code: 'X_STATE_UNDECLARED',
48
+ cause: `${entityName}.${column} declares no state machine, so there is no transition to check`,
49
+ fix:
50
+ machines.length === 0
51
+ ? `add .transitions(…) to ${entityName}.${column} — it must be an enumerated() column, and every value that set declares needs an entry`
52
+ : `${entityName} declares a machine on: ${machines.join(', ')}`,
53
+ });
54
+
55
+ /**
56
+ * Why a move is not in the machine. THREE conditions and one code, because they share one repair —
57
+ * the move is not in the table — and each states its own fact, because they are not the same
58
+ * mistake and the fix line differs.
59
+ *
60
+ * `unknown-state` is separate from `terminal` for a reason a test found: an unknown state has no
61
+ * outgoing moves either, so a single "no legal moves" branch reported a typo as "the row is
62
+ * terminal in <typo>" — a sentence about a state that does not exist. Reachable from JS, and from
63
+ * a `from` that came out of parsed JSON.
64
+ *
65
+ * `terminal` is separate from the ordinary case because "no legal moves" reads like a missing
66
+ * declaration and is not one: an empty list is how a terminal state is written.
67
+ */
68
+ export type IllegalTransition =
69
+ | { readonly reason: 'unknown-state'; readonly states: readonly string[] }
70
+ | { readonly reason: 'terminal' }
71
+ | { readonly reason: 'not-declared'; readonly legal: readonly string[] };
72
+
73
+ export const stateTransitionIllegal = (
74
+ entityName: string,
75
+ column: string,
76
+ from: string,
77
+ to: string,
78
+ detail: IllegalTransition,
79
+ ): EntityError => {
80
+ const subject = `${entityName}.${column}`;
81
+ if (detail.reason === 'unknown-state') {
82
+ return new EntityError({
83
+ code: 'X_STATE_TRANSITION_ILLEGAL',
84
+ cause: `"${from}" is not a state of ${subject} — it declares: ${detail.states.join(' | ')}`,
85
+ fix: `${entityName}.transition('${column}', id, { from: '${detail.states[0] ?? from}', to: '${to}' }) # name a state the enumerated() set declares`,
86
+ });
87
+ }
88
+ if (detail.reason === 'terminal') {
89
+ return new EntityError({
90
+ code: 'X_STATE_TRANSITION_ILLEGAL',
91
+ cause: `${subject} is terminal in "${from}": the machine declares no move out of it, so "${to}" is not one`,
92
+ fix: `move the row before it reaches "${from}", or add "${to}" to the "${from}" entry of the transitions() table`,
93
+ });
94
+ }
95
+ return new EntityError({
96
+ code: 'X_STATE_TRANSITION_ILLEGAL',
97
+ cause: `${subject} has no move from "${from}" to "${to}" — from "${from}" it may go to: ${detail.legal.join(', ')}`,
98
+ fix: `${entityName}.transition('${column}', id, { from: '${from}', to: '${detail.legal[0]}' }) # or add "${to}" to the "${from}" entry of the transitions() table`,
99
+ });
100
+ };
101
+
102
+ /**
103
+ * The conditional update matched no row, and the row is in a different state than the caller named.
104
+ *
105
+ * This is the lost update, caught: two callers both read "pending", both found the move legal, and
106
+ * the second one's statement carried `status = 'pending'` in its predicate and matched nothing. The
107
+ * state in the cause is READ BACK after the refusal, so it is a diagnosis and never the decision —
108
+ * the decision was the statement, and it was atomic.
109
+ */
110
+ export const stateConflict = (
111
+ entityName: string,
112
+ column: string,
113
+ id: string,
114
+ expected: string,
115
+ actual: string,
116
+ ): EntityError =>
117
+ new EntityError({
118
+ code: 'X_STATE_CONFLICT',
119
+ cause: `${entityName}.${column} named "${expected}" for row ${id}, which is in "${actual}" — something moved it first`,
120
+ fix: `re-read the row and decide again against "${actual}": ${entityName}.findById(id) — a transition names the state it expects, so a stale read is refused rather than overwritten`,
121
+ });
@@ -0,0 +1,83 @@
1
+ // What an index is CALLED. Split out of `entity.ts` at the 500-line ceiling, and it is one job:
2
+ // two indexes that differ only in their predicate, their direction or their access method must not
3
+ // share a name, and no name may cross the 63 bytes Postgres silently truncates at.
4
+
5
+ import type { IndexMethod } from '@ultimat3/db';
6
+ import { invariantViolated } from './errors';
7
+
8
+ /**
9
+ * What separates two indexes on the SAME columns: the predicate, the direction and the ACCESS
10
+ * METHOD. Eight hex characters of sha256 over all three — deterministic across processes, so a
11
+ * name is a property of the declaration and never of the run that generated it.
12
+ *
13
+ * The method belongs here for exactly the reason `where` does. A btree on an `arrayOf()` column
14
+ * answers `=` and an ordering; a GIN on the same column answers `@>` / `<@` / `&&`. They are two
15
+ * distinct indexes, and without the method in the name both are `<table>_<cols>_idx` — where the
16
+ * dedup below drops one in silence (the defect this discriminator was added for) or, since that
17
+ * dedup is now on the whole definition, two `create index` statements share one name and the
18
+ * migration is `42P07`.
19
+ */
20
+ const indexDiscriminator = (
21
+ order: string | undefined,
22
+ where: string | null,
23
+ using: string | undefined,
24
+ ): string =>
25
+ new Bun.CryptoHasher('sha256')
26
+ // The method is APPENDED only when one was declared, never as an empty field: every name this
27
+ // function has ever minted for a partial or ordered index is therefore unchanged by the method
28
+ // existing, and an index that declares no method is byte-identical to the one it was.
29
+ .update(`${order ?? ''}|${where ?? ''}${using === undefined ? '' : `|${using}`}`)
30
+ .digest('hex')
31
+ .slice(0, 8);
32
+
33
+ /**
34
+ * `<table>_<columns>_idx`, plus a discriminator when — and only when — the index carries a
35
+ * predicate, a direction or a non-default access method.
36
+ *
37
+ * Only then, because the plain name is load-bearing in two places: `unique()` on a column is an
38
+ * inline column clause and Postgres names the index it creates exactly `<table>_<column>_key`, so
39
+ * a discriminator there would make the generator emit a second `create unique index` for an index
40
+ * that already exists (`42P07`); and a foreign key's own index is deduped against a hand-declared
41
+ * one by this name.
42
+ *
43
+ * Without it, two DIFFERENT partial indexes on one column were one name — `posts_author_id_idx`
44
+ * for both `where status = 'published'` and `where status = 'draft'` — and the dedup below dropped
45
+ * the second with no error, no warning and no drift finding, since a declared index is matched by
46
+ * name.
47
+ */
48
+ /**
49
+ * `NAMEDATALEN - 1`. Postgres truncates a longer identifier and says NOTHING, so two index names
50
+ * sharing their first 63 bytes become one index on the server — the same silent collapse the
51
+ * discriminator above exists to prevent, one layer down, and invisible to a drift check comparing
52
+ * DECLARED names because those still differ. Bytes and not characters: 63 is what the server
53
+ * counts, and `.length` would stop seeing the truncation the moment a name is not ASCII.
54
+ */
55
+ const MAX_IDENTIFIER_BYTES = 63;
56
+
57
+ const byteLength = (value: string): number => new TextEncoder().encode(value).length;
58
+
59
+ export const indexName = (
60
+ entityName: string,
61
+ table: string,
62
+ columns: readonly string[],
63
+ unique: boolean,
64
+ order?: string | undefined,
65
+ where: string | null = null,
66
+ using?: IndexMethod | undefined,
67
+ ): string => {
68
+ const suffix = unique ? 'key' : 'idx';
69
+ const base = `${table}_${columns.join('_')}`;
70
+ const plain = order === undefined && where === null && using === undefined;
71
+ const name = plain
72
+ ? `${base}_${suffix}`
73
+ : `${base}_${indexDiscriminator(order, where, using)}_${suffix}`;
74
+ const bytes = byteLength(name);
75
+ if (bytes <= MAX_IDENTIFIER_BYTES) return name;
76
+ throw invariantViolated(
77
+ entityName,
78
+ 'index',
79
+ `the index on (${columns.join(', ')}) is named "${name}", which is ${bytes} bytes — ` +
80
+ `Postgres truncates an identifier at ${MAX_IDENTIFIER_BYTES} and does not say so, ` +
81
+ 'so two indexes can silently become one',
82
+ );
83
+ };
package/src/index.ts CHANGED
@@ -22,9 +22,6 @@ export {
22
22
  url,
23
23
  uuid,
24
24
  } from './columns';
25
- // The vocabulary an EXISTING schema needs. Separate from the blessed builders on purpose: those
26
- // are decisions this framework made for a table it was going to create, and these are the shapes
27
- // a table already has (`docs`: Entities-And-Migrations, "Adopting an existing database").
28
25
  export type { DecimalOptions } from './columns-data';
29
26
  export { arrayOf, bigint, bytes, date, decimal, json } from './columns-data';
30
27
  // `crossTenantReason` stays internal: an app that could read the flag would have a second way to
@@ -36,6 +33,9 @@ export type { DescribeInput } from './describe';
36
33
  export { sqlTypeOf } from './describe';
37
34
  export type { Entity, EntityCore, EntityInit, IndexInit } from './entity';
38
35
  export { entity, SOFT_DELETE_COLUMN } from './entity';
36
+ // The vocabulary an EXISTING schema needs. Separate from the blessed builders on purpose: those
37
+ // are decisions this framework made for a table it was going to create, and these are the shapes
38
+ // a table already has (`docs`: Entities-And-Migrations, "Adopting an existing database").
39
39
  export type {
40
40
  EntityErrorCode,
41
41
  PreloadCandidate,
@@ -65,6 +65,15 @@ export {
65
65
  writeUnfiltered,
66
66
  } from './errors';
67
67
  export type { ColumnExpr, Expr, InvariantColumns, Resolve } from './expr';
68
+ /** The two DECLARED capabilities' refusals — a third-party driver raises the same ones. */
69
+ export type { IllegalTransition } from './feature-errors';
70
+ export {
71
+ searchInMemory,
72
+ searchUndeclared,
73
+ stateConflict,
74
+ stateTransitionIllegal,
75
+ stateUndeclared,
76
+ } from './feature-errors';
68
77
  export type { Invariant, InvariantDef, InvariantKind } from './invariants';
69
78
  export {
70
79
  assertInvariants,
@@ -116,6 +125,20 @@ export type {
116
125
  } from './repo';
117
126
  export type { RowBulkChange, RowChange, RowChangeOp, RowObserver } from './row-observer';
118
127
  export { observedRepo, rowObserver, setRowObserver } from './row-observer';
128
+ // Full-text search. The LANGUAGE set and the weights are values an app reads to build a form;
129
+ // `SEARCH_PROPERTY` is what a `matches` predicate names, which a hand-built `QueryPlan` needs.
130
+ export type { SearchInit, SearchLanguage, SearchSource, SearchVector } from './search';
131
+ export {
132
+ DEFAULT_SEARCH_COLUMN,
133
+ DEFAULT_SEARCH_LANGUAGE,
134
+ DEFAULT_SEARCH_WEIGHT,
135
+ isSearchLanguage,
136
+ isSearchWeight,
137
+ SEARCH_LANGUAGES,
138
+ SEARCH_PROPERTY,
139
+ SEARCH_WEIGHTS,
140
+ searchExpression,
141
+ } from './search';
119
142
  export type {
120
143
  Seed,
121
144
  SeedContext,
@@ -128,6 +151,16 @@ export type {
128
151
  SeedWrite,
129
152
  } from './seed';
130
153
  export { defineSeed, isSeed, SEED_TIERS, seedId, seedTiersFor } from './seed';
154
+ // A state machine over a column. The MECHANISM only: the table, the refusal, the terminal concept.
155
+ // The states are the app's `enumerated()` set and nothing here names one.
156
+ export type { StateMachine, TransitionTable } from './state-machine';
157
+ export {
158
+ canMove,
159
+ isState,
160
+ isTerminal,
161
+ movesFrom,
162
+ stateMachineOf,
163
+ } from './state-machine';
131
164
  export type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
132
165
  export {
133
166
  assertRowTenant,
@@ -141,6 +174,7 @@ export {
141
174
  scopedPlan,
142
175
  tenantColumnOf,
143
176
  } from './tenancy';
177
+ export type { Move } from './transition';
144
178
  export type {
145
179
  AnyColumn,
146
180
  Column,
@@ -148,6 +182,7 @@ export type {
148
182
  ColumnKind,
149
183
  ColumnMap,
150
184
  ColumnMeta,
185
+ EnumeratedColumn,
151
186
  IdOf,
152
187
  IndexDef,
153
188
  Insertable,
@@ -158,6 +193,7 @@ export type {
158
193
  ReferenceOptions,
159
194
  RowOf,
160
195
  RowPatch,
196
+ SearchWeight,
161
197
  TimestampColumn,
162
198
  TypeOf,
163
199
  UuidColumn,
@@ -10,6 +10,7 @@ import { arrayContains, arrayOverlaps, jsonContains, jsonHasKey } from './contai
10
10
  import { kindOf, valueAt } from './cursor';
11
11
  import type { EntityCore } from './entity';
12
12
  import { EntityError } from './errors';
13
+ import { searchInMemory } from './feature-errors';
13
14
  import { instantMicros } from './instant';
14
15
  import type { Predicate } from './tenancy';
15
16
  import type { ColumnKind } from './types';
@@ -158,6 +159,10 @@ export const matchesPredicate = <Row>(
158
159
  row: unknown,
159
160
  predicate: Predicate,
160
161
  ): boolean => {
162
+ // BEFORE anything is read off the row. A full-text match has no in-memory meaning — see
163
+ // `searchInMemory` — and `valueAt(row, '$search')` would answer `undefined`, which every
164
+ // comparison below reads as NULL and silently turns into "no rows".
165
+ if (predicate.op === 'matches') throw searchInMemory(entity.$name);
161
166
  // The column's declared kind, resolved once — `price.minor` included, which is the path a money
162
167
  // predicate and a money sort key both name.
163
168
  const kind = kindOf(entity, predicate.column);
package/src/pg-sql.ts CHANGED
@@ -10,6 +10,7 @@ import { columnFor } from './column';
10
10
  import { isNullableKey, kindOf } from './cursor';
11
11
  import type { EntityCore } from './entity';
12
12
  import { SOFT_DELETE_COLUMN } from './entity';
13
+ import { searchUndeclared } from './feature-errors';
13
14
  import { microsToIso, seekAlias } from './instant';
14
15
  import { allColumns, arrayLiteral, columnsOf, physicalName } from './pg-row';
15
16
  import type { Predicate, QueryPlan, SortKey } from './tenancy';
@@ -28,7 +29,38 @@ export interface ReadShape {
28
29
  const columnRef = <Row>(entity: EntityCore<Row>, path: string): SqlFragment =>
29
30
  identifier(physicalName(entity, path));
30
31
 
32
+ /**
33
+ * `websearch_to_tsquery`, and the choice is the point of the whole feature.
34
+ *
35
+ * `to_tsquery` reads its argument as tsquery SYNTAX — `&`, `|`, `!`, `<->`, `:*`, parentheses — so
36
+ * a term straight out of a search box is either a `42601` on the first unbalanced paren or, worse,
37
+ * an operator the caller did not write. `plainto_tsquery` is safe but ANDs every word and throws
38
+ * the user's own operators away silently. `websearch_to_tsquery` is the parser Postgres ships for
39
+ * untrusted input: it never raises a syntax error, and it gives `"quoted phrase"`, `or` and a
40
+ * leading `-` the meaning every search box on the web already has. Cats & dogs is three terms.
41
+ *
42
+ * The term crosses as a BOUND PARAMETER either way — nothing here interpolates it — so the choice
43
+ * is not what stops an injection; the parameter is. What the parser decides is whether the user's
44
+ * punctuation is read as syntax, which is the second half of the same question.
45
+ *
46
+ * The configuration is spliced through `raw()`, from `SEARCH_LANGUAGES`, exactly as `asc|desc` is:
47
+ * a closed set of one word, chosen by this file from the ENTITY's declaration and never from a
48
+ * value on the wire. `regconfig` cannot be a bound parameter and stay index-matchable anyway.
49
+ */
50
+ const searchSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFragment => {
51
+ const vector = entity.$search;
52
+ if (vector === null) throw searchUndeclared(entity.$name);
53
+ const column = identifier(vector.column);
54
+ // The term as TEXT, whatever arrived: `websearch_to_tsquery` takes text, and a number or a null
55
+ // reaching it as a parameter is a `42883` where the caller is owed "no rows".
56
+ const term = predicate.value === null || predicate.value === undefined ? '' : predicate.value;
57
+ return sql`${column} @@ websearch_to_tsquery(${raw(`'${vector.language}'`)}, ${String(term)})`;
58
+ };
59
+
31
60
  const predicateSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFragment => {
61
+ // BEFORE the column is resolved: a `matches` predicate names `SEARCH_PROPERTY`, which is not a
62
+ // column and must never be looked up as one.
63
+ if (predicate.op === 'matches') return searchSql(entity, predicate);
32
64
  const column = columnRef(entity, predicate.column);
33
65
  const value = predicate.value;
34
66
  switch (predicate.op) {
package/src/query.ts CHANGED
@@ -8,13 +8,16 @@ import type { BatchIterator } from './batch';
8
8
  import { assertBatchable, batchIterator } from './batch';
9
9
  import { entityNow } from './clock';
10
10
  import type { EntityCore } from './entity';
11
+ import { searchUndeclared } from './feature-errors';
11
12
  import { assertPageSize, DEFAULT_PAGE_SIZE, namedColumns } from './plan';
12
13
  import type { RelatedTables } from './preload';
13
14
  import { preloaded } from './preload';
14
15
  import type { Relation } from './relations';
15
16
  import { relationNamed } from './relations';
16
17
  import type { Page, Repo, RepoOptions, UpsertArgs } from './repo';
18
+ import { SEARCH_PROPERTY } from './search';
17
19
  import type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
20
+ import { transitionRow } from './transition';
18
21
  import type { ColumnMap, IdOf, Insertable, MoneyValue, RowPatch } from './types';
19
22
 
20
23
  /**
@@ -28,6 +31,23 @@ export interface ReadBuilder<Row> {
28
31
  /** Equality on the columns given. `where({ orgId })` is what satisfies the tenancy guard. */
29
32
  where(filter: RowPatch<Row>): ReadBuilder<Row>;
30
33
  andWhere(column: keyof Row & string, op: Operator, value: unknown): ReadBuilder<Row>;
34
+ /**
35
+ * Full-text search over the entity's generated `tsvector` — every `.searchable()` column at
36
+ * once, one GIN index, one predicate. `posts.where({ orgId }).search(input.q).limit(20).page()`.
37
+ *
38
+ * `term` is USER TEXT and is treated as such end to end: it crosses as a bound parameter and is
39
+ * parsed by `websearch_to_tsquery`, so `&`, `|`, `!`, `:*` and an unbalanced paren are characters
40
+ * to be matched, never operators and never a syntax error. There is deliberately no way to hand
41
+ * this layer a tsquery — a query language inside the query language is a second way to ask, and
42
+ * the one caller that would want it is the injection this method exists to make unreachable.
43
+ *
44
+ * It is an ordinary predicate, so the chain's tenancy, soft delete, projection, order, cursor
45
+ * and page size all mean here exactly what they mean without it. RELEVANCE is not an order this
46
+ * chain can serve: `ts_rank` is a computed value and the cursor carries columns, so the order
47
+ * stays the one the caller declared. An entity with no searchable column is `X_SEARCH_UNDECLARED`
48
+ * and the in-memory driver is `X_SEARCH_IN_MEMORY` — never a different answer from the two.
49
+ */
50
+ search(term: string): ReadBuilder<Row>;
31
51
  orderBy(column: keyof Row & string, direction?: SortDirection): ReadBuilder<Row>;
32
52
  limit(rows: number): ReadBuilder<Row>;
33
53
  /** The cursor from the previous page. */
@@ -128,6 +148,32 @@ export interface ReadBuilder<Row> {
128
148
  }
129
149
 
130
150
  export interface Table<Row, C extends ColumnMap = ColumnMap> extends ReadBuilder<Row> {
151
+ /**
152
+ * Move one row through the state machine `column` declares, in ONE statement.
153
+ *
154
+ * `from` is the state the caller believes the row is in, and it rides in the statement's own
155
+ * predicate — so the state that was observed and the state that was written are one decision,
156
+ * made under the row's lock. A read-then-check-then-write is the same call with a window in it,
157
+ * and under two concurrent callers the second one writes a transition out of a state the row had
158
+ * already left. Here the second statement matches no row: `X_STATE_CONFLICT`, naming the state
159
+ * the row is really in.
160
+ *
161
+ * A move the machine does not hold is `X_STATE_TRANSITION_ILLEGAL` and never reaches the
162
+ * database — the table is a property of the declaration. A move out of a TERMINAL state is the
163
+ * same code saying so; a terminal state is one whose outgoing list is empty, which is the whole
164
+ * of the concept and the only part of it the framework owns. Which state is terminal, what any
165
+ * of them mean, who may make a move and what happens on arrival are the app's, every one.
166
+ *
167
+ * Tenant-scoped exactly as `updateWhere` is, because it IS one: a row in another org matches no
168
+ * statement and reads back as absent, so the answer is `X_NOT_FOUND` rather than a conflict that
169
+ * would confirm it exists.
170
+ */
171
+ transition<K extends keyof Row & string>(
172
+ column: K,
173
+ id: IdOf<Row>,
174
+ move: { readonly from: Row[K] & string; readonly to: Row[K] & string },
175
+ options?: RepoOptions,
176
+ ): Promise<Row>;
131
177
  insert(values: Insertable<C>, options?: RepoOptions): Promise<Row>;
132
178
  /**
133
179
  * Many rows, one statement — the bulk write a per-row `insert` loop is the N+1 of, and the line
@@ -241,6 +287,16 @@ const builder = <Source, Row>(
241
287
 
242
288
  andWhere: (column, op, value) => next({ where: [...state.where, { column, op, value }] }),
243
289
 
290
+ // Refused HERE as well as at the statement, because this is the line the author wrote: a chain
291
+ // over an entity that declares nothing searchable can never produce a match, and the repair is
292
+ // one edit to the schema rather than anything about this call.
293
+ search: (term) => {
294
+ if (entity.$search === null) throw searchUndeclared(entity.$name);
295
+ return next({
296
+ where: [...state.where, { column: SEARCH_PROPERTY, op: 'matches', value: term }],
297
+ });
298
+ },
299
+
244
300
  orderBy: (column, direction = 'asc') =>
245
301
  next({ orderBy: [...state.orderBy, { column, direction }] }),
246
302
 
@@ -401,4 +457,9 @@ export const tableFor = <Row, C extends ColumnMap>(
401
457
  deleteWhere: async (filter, options) => repo.deleteWhere(filter, options),
402
458
  updateWhere: async (filter, patch, options) =>
403
459
  repo.updateWhere(filter, touch(entity, patch), options),
460
+ // `touch` is passed rather than applied here: a transition IS an update, so an `onUpdateNow()`
461
+ // column has to move exactly as `update(id, patch)` moves it — which is also the audit of WHEN
462
+ // the row moved, using the stamp already declared instead of a second one beside it.
463
+ transition: async (column, id, move, options) =>
464
+ transitionRow(entity, repo, column, id, move, (patch) => touch(entity, patch), options),
404
465
  });
package/src/registry.ts CHANGED
@@ -25,6 +25,15 @@ export interface ColumnDescription {
25
25
  * projection reaches no `alter table` at all. It reached none until 3.0.
26
26
  */
27
27
  readonly onDelete: OnDelete | null;
28
+ /**
29
+ * The `generated always as (<expr>) stored` body, when the DATABASE computes this column rather
30
+ * than a writer. Absent on every ordinary column, matching `IndexDescription.using`: a
31
+ * description written before this existed reads the same, so nothing regenerates.
32
+ *
33
+ * `@ultimat3/db` is tier 1 and cannot import this package, so — exactly like `onDelete` — a
34
+ * field that is not on this projection reaches no DDL at all.
35
+ */
36
+ readonly generated?: string;
28
37
  }
29
38
 
30
39
  /**
package/src/search.ts ADDED
@@ -0,0 +1,153 @@
1
+ // The full-text search vector an entity DERIVES from its `.searchable()` columns: one generated
2
+ // `tsvector` column, one language, one weight per source. Everything spliced into the expression
3
+ // here comes from a closed set or from a physical column name `assertColumnName` already checked —
4
+ // a search TERM never reaches this file, because a term is bound as a parameter (`pg-sql.ts`).
5
+
6
+ import type { SearchWeight } from './types';
7
+
8
+ /**
9
+ * Postgres' own default text search configurations, as `\dF` lists them on 13 and later. A CLOSED
10
+ * set because the configuration is the one part of `to_tsvector(config, text)` that cannot be a
11
+ * bound parameter inside a generated column — it is spliced — so it may only ever be a value this
12
+ * file already contains. A server without one of these answers `3F000` at `create table`, which is
13
+ * loud and lands on the author, not on a search.
14
+ */
15
+ export const SEARCH_LANGUAGES = [
16
+ 'arabic',
17
+ 'armenian',
18
+ 'basque',
19
+ 'catalan',
20
+ 'danish',
21
+ 'dutch',
22
+ 'english',
23
+ 'finnish',
24
+ 'french',
25
+ 'german',
26
+ 'greek',
27
+ 'hindi',
28
+ 'hungarian',
29
+ 'indonesian',
30
+ 'irish',
31
+ 'italian',
32
+ 'lithuanian',
33
+ 'nepali',
34
+ 'norwegian',
35
+ 'portuguese',
36
+ 'romanian',
37
+ 'russian',
38
+ 'serbian',
39
+ 'simple',
40
+ 'spanish',
41
+ 'swedish',
42
+ 'tamil',
43
+ 'turkish',
44
+ 'yiddish',
45
+ ] as const;
46
+
47
+ export type SearchLanguage = (typeof SEARCH_LANGUAGES)[number];
48
+
49
+ /** The one membership test. `includes` on the tuple, never a computed read of a table. */
50
+ export const isSearchLanguage = (value: unknown): value is SearchLanguage =>
51
+ typeof value === 'string' && (SEARCH_LANGUAGES as readonly string[]).includes(value);
52
+
53
+ export const SEARCH_WEIGHTS = ['A', 'B', 'C', 'D'] as const;
54
+
55
+ export const isSearchWeight = (value: unknown): value is SearchWeight =>
56
+ typeof value === 'string' && (SEARCH_WEIGHTS as readonly string[]).includes(value);
57
+
58
+ /** Postgres' own default weight, so an unweighted source ranks exactly as an unweighted vector. */
59
+ export const DEFAULT_SEARCH_WEIGHT: SearchWeight = 'D';
60
+
61
+ export const DEFAULT_SEARCH_LANGUAGE: SearchLanguage = 'english';
62
+
63
+ export const DEFAULT_SEARCH_COLUMN = 'search_tsv';
64
+
65
+ /**
66
+ * What a `matches` predicate names instead of a column. `$`-prefixed for the reason every member
67
+ * of `EntityCore` is: `assertColumnName` requires `[a-z_]` first, so no declared column can ever
68
+ * be spelled this, and a `matches` predicate can therefore never be confused with one on a real
69
+ * column. Nothing resolves it through `physicalName` — both drivers branch on the OPERATOR.
70
+ */
71
+ export const SEARCH_PROPERTY = '$search';
72
+
73
+ export interface SearchSource {
74
+ /** Physical column, already through `assertColumnName`. */
75
+ readonly column: string;
76
+ readonly weight: SearchWeight;
77
+ }
78
+
79
+ /** How an entity's search is declared, when the defaults do not fit the table it adopted. */
80
+ export interface SearchInit {
81
+ /** The physical vector column, when `search_tsv` is taken or the table already named one. */
82
+ readonly column?: string;
83
+ readonly language?: SearchLanguage;
84
+ }
85
+
86
+ export interface SearchVector {
87
+ /** The physical `tsvector` column. Never a row property. */
88
+ readonly column: string;
89
+ readonly language: SearchLanguage;
90
+ readonly sources: readonly SearchSource[];
91
+ /** The `generated always as (…) stored` body. Deterministic in declaration order. */
92
+ readonly expression: string;
93
+ }
94
+
95
+ /**
96
+ * One `setweight(to_tsvector(…))` per source, concatenated in DECLARATION order.
97
+ *
98
+ * `setweight` even for a single unweighted source, so adding a second column never rewrites the
99
+ * first one's spelling — and a spelling change here is a `drop column` + `add column` on a table
100
+ * that may hold every row an app has. `coalesce(…, '')` because `to_tsvector` of NULL is NULL and
101
+ * `NULL || tsvector` is NULL: one nullable source would erase the whole vector for that row.
102
+ *
103
+ * Every function in it is immutable, which is what Postgres requires of a generated column —
104
+ * `to_tsvector(text)` with no configuration is NOT (it reads `default_text_search_config`), which
105
+ * is why the language is named here and never left to the server.
106
+ */
107
+ export const searchExpression = (
108
+ language: SearchLanguage,
109
+ sources: readonly SearchSource[],
110
+ ): string =>
111
+ sources
112
+ .map(
113
+ (source) =>
114
+ `setweight(to_tsvector('${language}', coalesce("${source.column}", '')), '${source.weight}')`,
115
+ )
116
+ .join(' || ');
117
+
118
+ /**
119
+ * The vector a set of already-resolved sources describes, or `null` when there are none.
120
+ *
121
+ * The physical names arrive resolved and the collision check arrives as `taken`, so this module
122
+ * imports nothing from `column.ts` — which imports THIS one for `.searchable()`. A cycle between
123
+ * the column chain and the thing a column modifier declares is avoidable, so it is avoided.
124
+ */
125
+ export const searchVectorOf = (
126
+ sources: readonly SearchSource[],
127
+ init: SearchInit | undefined,
128
+ taken: (column: string) => boolean,
129
+ refuse: (subject: string, detail: string) => never,
130
+ ): SearchVector | null => {
131
+ if (sources.length === 0) {
132
+ if (init === undefined) return null;
133
+ return refuse(
134
+ 'search',
135
+ 'search is declared but no column is searchable — add .searchable() to a text() column, or drop the search option',
136
+ );
137
+ }
138
+ const language = init?.language ?? DEFAULT_SEARCH_LANGUAGE;
139
+ if (!isSearchLanguage(language)) {
140
+ return refuse(
141
+ 'search',
142
+ `"${String(language)}" is not a Postgres text search configuration — one of: ${SEARCH_LANGUAGES.join(', ')}`,
143
+ );
144
+ }
145
+ const column = init?.column ?? DEFAULT_SEARCH_COLUMN;
146
+ if (taken(column)) {
147
+ return refuse(
148
+ 'search',
149
+ `the search vector column "${column}" is already a declared column — rename it, or name another with search: { column: '<name>' }`,
150
+ );
151
+ }
152
+ return { column, language, sources, expression: searchExpression(language, sources) };
153
+ };