@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.
@@ -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);
@@ -11,7 +11,7 @@ import { foldAggregate } from './aggregate-fold';
11
11
  import { keyOf } from './batch-read';
12
12
  import { conflictKeyOf, conflictKeys, upsertPlan } from './bulk-write';
13
13
  import { entityNow } from './clock';
14
- import { narrowMoney } from './columns';
14
+ import { narrowRow } from './columns';
15
15
  import { countsFrom, groupColumnOf } from './count-by';
16
16
  import { cursorFor, kindOf, seekFrom, valueAt } from './cursor';
17
17
  import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
@@ -21,6 +21,7 @@ import { deletePlan, idPlan, readPlan, singleKeyOf, updatePlan } from './plan';
21
21
  import type { FindManyArgs, MemoryRepo, RepoOptions, Transactor, Tx } from './repo';
22
22
  import type { QueryPlan } from './tenancy';
23
23
  import { assertRowTenant } from './tenancy';
24
+ import type { RowWrite } from './types';
24
25
 
25
26
  const field = (row: unknown, property: string): unknown =>
26
27
  typeof row === 'object' && row !== null ? (row as Record<string, unknown>)[property] : undefined;
@@ -113,11 +114,19 @@ export const memoryRepo = <Row>(
113
114
  return { plan, found: rowsOf(plan, args) };
114
115
  };
115
116
 
116
- const write = (given: Row, options: RepoOptions | undefined, operation: string): Row => {
117
+ /** Money's write shape narrowed once per batch, at the method the caller reached. */
118
+ const narrowed = (batch: readonly RowWrite<Row>[]): readonly Row[] =>
119
+ batch.map((row) => narrowRow<Row>(entity.$columns, row));
120
+
121
+ const write = (
122
+ given: RowWrite<Row>,
123
+ options: RepoOptions | undefined,
124
+ operation: string,
125
+ ): Row => {
117
126
  // `MoneyInput` lets a writer hand a `bigint`; a stored row holds the value type. The Postgres
118
- // driver narrows in `bindValues` and reads its answer back through `returning *`, so without
119
- // this an in-memory row would be the one row in the framework `JSON.stringify` refuses.
120
- const row = narrowMoney(entity.$columns, given);
127
+ // driver narrows at the same position its write methods' entry so without this an
128
+ // in-memory row would be the one row in the framework `JSON.stringify` refuses.
129
+ const row = narrowRow<Row>(entity.$columns, given);
121
130
  // Beside `$assert`, and before the row lands: a write is judged by the tenant it names as well
122
131
  // as by the invariants it declares, and the Postgres driver runs the same pair in `writeRows`.
123
132
  // `update` reaches here with the STORED row merged under its patch, so a patch that moves a row
@@ -184,11 +193,15 @@ export const memoryRepo = <Row>(
184
193
  return write(values, options, 'insert');
185
194
  },
186
195
 
187
- async insertAll(batch, options) {
196
+ async insertAll(given, options) {
188
197
  // The whole batch is judged before any of it lands: Postgres refuses the statement as one,
189
198
  // so a row an invariant rejects — or one naming a tenant this actor may not write — must not
190
199
  // leave the rows before it stored here either. `write` re-checks both per row; this loop is
191
200
  // what makes the batch all-or-nothing, which is the half a per-row check cannot give.
201
+ //
202
+ // Narrowed FIRST, so what this loop judges is what `write` will store: `$assert` was handed
203
+ // the caller's `bigint` minor unit here and the narrowed `number` one call later.
204
+ const batch = narrowed(given);
192
205
  for (const row of batch) {
193
206
  assertRowTenant(entity.$name, entity.$tenantColumn, 'insertAll', row);
194
207
  entity.$assert(row);
@@ -196,10 +209,12 @@ export const memoryRepo = <Row>(
196
209
  return batch.map((row) => write(row, options, 'insertAll'));
197
210
  },
198
211
 
199
- async upsertAll(batch, args) {
212
+ async upsertAll(given, args) {
200
213
  // The INCOMING rows, judged before any of them is matched: under `onMatch: 'nothing'` a
201
214
  // colliding row is skipped and never reaches `write()`, so checking only what lands would
202
- // let a row naming another tenant through whenever it happened to collide.
215
+ // let a row naming another tenant through whenever it happened to collide. Narrowed first
216
+ // for the reason `insertAll` above is, and before `conflictKeyOf` reads a target too.
217
+ const batch = narrowed(given);
203
218
  for (const row of batch) {
204
219
  assertRowTenant(entity.$name, entity.$tenantColumn, 'upsertAll', row);
205
220
  entity.$assert(row);
package/src/pg-driver.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  import { entityNow } from './clock';
29
29
  import { coalesceFindById } from './coalesce';
30
30
  import { moneyColumns } from './column';
31
+ import { narrowRow } from './columns';
31
32
  import { countsFrom, groupColumnOf, groupValue, MAX_GROUPS } from './count-by';
32
33
  import { cursorFor, seekFrom, valueAt } from './cursor';
33
34
  import type { Driver } from './database';
@@ -58,6 +59,7 @@ import { deletePlan, idPlan, readPlan, updatePlan } from './plan';
58
59
  import type { FindManyArgs, Repo, Transactor, UpsertArgs } from './repo';
59
60
  import type { QueryPlan } from './tenancy';
60
61
  import { assertRowTenant } from './tenancy';
62
+ import type { RowWrite } from './types';
61
63
 
62
64
  export interface PostgresDriverOptions {
63
65
  /**
@@ -121,6 +123,11 @@ export const postgresRepo = <Row>(
121
123
  const idOf = (row: Row): string =>
122
124
  entity.$primaryKey.map((property) => String(valueAt(row, property))).join('');
123
125
 
126
+ /** Where a batch stops being wide — before `writeRows` or `upsertPlan` read a row. `narrowRow`
127
+ * says why the position matters, and `memoryRepo` narrows at the same one. */
128
+ const narrowed = (batch: readonly RowWrite<Row>[]): readonly Row[] =>
129
+ batch.map((row) => narrowRow<Row>(entity.$columns, row));
130
+
124
131
  const one = async (plan: QueryPlan, args: FindManyArgs): Promise<Row | null> => {
125
132
  const [found] = await client().query<PhysicalRow>(
126
133
  selectStatement(entity, plan, shapeOf(args), 1),
@@ -255,21 +262,24 @@ export const postgresRepo = <Row>(
255
262
  },
256
263
 
257
264
  async insert(values) {
258
- const [written] = await writeRows('insert', [values], undefined);
259
- // `returning *` is the row Postgres actually stored, defaults included.
260
- return written ?? values;
265
+ const row = narrowRow<Row>(entity.$columns, values);
266
+ const [written] = await writeRows('insert', [row], undefined);
267
+ // `returning *` is the row Postgres actually stored, defaults included. The fallback is the
268
+ // NARROWED row: handing the caller's back would answer with a `bigint` minor unit.
269
+ return written ?? row;
261
270
  },
262
271
 
263
272
  async insertAll(batch) {
264
- return writeRows('insertAll', batch, undefined);
273
+ return writeRows('insertAll', narrowed(batch), undefined);
265
274
  },
266
275
 
267
276
  async upsertAll(batch, args: UpsertArgs<Row>) {
268
- const plan = upsertPlan(entity, batch, args.onConflict, args.onMatch ?? 'update');
277
+ const rows = narrowed(batch);
278
+ const plan = upsertPlan(entity, rows, args.onConflict, args.onMatch ?? 'update');
269
279
  // Refused here, not by the server: a batch that repeats a conflict target is `21000` in
270
280
  // Postgres and a silent overwrite in memory, and the two drivers have to mean one thing.
271
- conflictKeys(entity, plan, batch);
272
- return writeRows('upsertAll', batch, {
281
+ conflictKeys(entity, plan, rows);
282
+ return writeRows('upsertAll', rows, {
273
283
  columns: insertColumns(entity, plan.on),
274
284
  set: insertColumns(entity, plan.set),
275
285
  });
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
@@ -6,7 +6,7 @@
6
6
  import type { IndexMethod } from '@ultimat3/db';
7
7
  import { entityDuplicate } from './errors';
8
8
  import type { InvariantKind } from './invariants';
9
- import type { OnDelete } from './types';
9
+ import type { ColumnDefault, OnDelete } from './types';
10
10
 
11
11
  export interface ColumnDescription {
12
12
  readonly property: string;
@@ -25,6 +25,25 @@ 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;
37
+ /**
38
+ * The declared default, when there is one — the VALUE, not merely `hasDefault`'s boolean.
39
+ *
40
+ * Same reason as `onDelete` and `generated`: `@ultimat3/db` is tier 1 and cannot import this
41
+ * package, so a fact that is not on this projection reaches no DDL. `hasDefault` alone let the
42
+ * generator infer only `gen_random_uuid()` and `now()`; every other default was dropped in
43
+ * silence, and a regenerated `examples/dummy` lost nine of them. Absent on a column declaring
44
+ * none, so a description written before this existed reads the same and nothing regenerates.
45
+ */
46
+ readonly default?: ColumnDefault;
28
47
  }
29
48
 
30
49
  /**
@@ -55,6 +74,18 @@ export interface InvariantDescription {
55
74
  /** `null` for an `assert`: a JS predicate the database was never told about. */
56
75
  readonly sql: string | null;
57
76
  readonly where: string | null;
77
+ /**
78
+ * The physical columns this rule reads — the same list `Invariant.columns` holds, for every
79
+ * kind, never narrowed to `unique`.
80
+ *
81
+ * Same reason as `onDelete`, `generated` and `default` on `ColumnDescription`: `@ultimat3/db` is
82
+ * tier 1 and cannot import this package, so a fact absent here is a fact the generator has to
83
+ * recover from a rendering. Without it `uniqueColumns()` split a `unique` rule's `sql` on commas
84
+ * and re-validated each part as an identifier — a comma-split of text this package joined, which
85
+ * is the shape `parseIndexName` failed at when `posts_org_id_created_at_idx` became the single
86
+ * column `"org_id_created_at"`. Carried, so the split has nothing left to do.
87
+ */
88
+ readonly columns: readonly string[];
58
89
  }
59
90
 
60
91
  /**
package/src/repo.ts CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  import type { AggregateFn } from './aggregate';
11
11
  import type { Predicate, SortKey } from './tenancy';
12
- import type { IdOf, RowPatch } from './types';
12
+ import type { IdOf, RowPatch, RowWrite } from './types';
13
13
 
14
14
  export interface Tx {
15
15
  readonly id: string;
@@ -80,11 +80,16 @@ export interface Page<T> {
80
80
  * The id parameters are `IdOf<T>`, not `string`: an entity that declared `uuid<PostId>()` is
81
81
  * addressed by a `PostId` and by nothing else. `IdOf<unknown>` and `IdOf<{ id: string }>` are
82
82
  * both `string`, so a row-agnostic consumer sees the signature it always saw.
83
+ *
84
+ * The whole-row writes take `RowWrite<T>` and the filtered ones `RowPatch<T>`, which are one
85
+ * statement in two shapes: money's write type is wider than its row type, and every one of these
86
+ * five entry points narrows it — `narrowRow` — before anything reads the row. Taking `T` here made
87
+ * the documented `bigint` minor unit unspellable at the only call an app makes.
83
88
  */
84
89
  export interface Repo<T = unknown> {
85
90
  findById(id: IdOf<T>, options?: FindByIdOptions): Promise<T | null>;
86
91
  findMany(args?: FindManyArgs): Promise<Page<T>>;
87
- insert(values: T, options?: RepoOptions): Promise<T>;
92
+ insert(values: RowWrite<T>, options?: RepoOptions): Promise<T>;
88
93
  /**
89
94
  * Many rows, one statement — the bulk form a per-row `insert` loop is the N+1 of. Resolves with
90
95
  * the rows as stored, defaults included, in the order given; an empty batch writes nothing and
@@ -92,13 +97,13 @@ export interface Repo<T = unknown> {
92
97
  * Past Postgres's bind count the batch becomes several statements, so wrap it in
93
98
  * `withTransaction` when all-or-nothing matters.
94
99
  */
95
- insertAll(rows: readonly T[], options?: RepoOptions): Promise<readonly T[]>;
100
+ insertAll(rows: readonly RowWrite<T>[], options?: RepoOptions): Promise<readonly T[]>;
96
101
  /**
97
102
  * `insertAll` that resolves a collision instead of failing on it. Resolves with the rows this
98
103
  * call actually wrote — under `onMatch: 'nothing'` a row already stored is skipped and absent,
99
104
  * which is what `returning *` says on the Postgres side.
100
105
  */
101
- upsertAll(rows: readonly T[], args: UpsertArgs<T>): Promise<readonly T[]>;
106
+ upsertAll(rows: readonly RowWrite<T>[], args: UpsertArgs<T>): Promise<readonly T[]>;
102
107
  update(id: IdOf<T>, patch: RowPatch<T>, options?: RepoOptions): Promise<T>;
103
108
  delete(id: IdOf<T>, options?: RepoOptions): Promise<void>;
104
109
  /**
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
+ };