@ultimat3/entity 12.0.0 → 14.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +93 -3
- package/README.md +93 -3
- package/package.json +5 -5
- package/src/column-values.ts +29 -0
- package/src/column.ts +22 -0
- package/src/columns.ts +22 -46
- package/src/describe.ts +41 -3
- package/src/entity.ts +50 -86
- package/src/enum-column.ts +81 -0
- package/src/errors.ts +10 -0
- package/src/feature-errors.ts +121 -0
- package/src/index-name.ts +83 -0
- package/src/index.ts +41 -11
- package/src/invariants.ts +7 -22
- package/src/memory-match.ts +5 -0
- package/src/memory-repo.ts +23 -8
- package/src/pg-driver.ts +17 -7
- package/src/pg-sql.ts +32 -0
- package/src/query.ts +61 -0
- package/src/registry.ts +32 -1
- package/src/repo.ts +9 -4
- package/src/search.ts +153 -0
- package/src/state-machine.ts +132 -0
- package/src/tenancy.ts +6 -1
- package/src/transition.ts +119 -0
- package/src/type-pins.ts +36 -0
- package/src/types.ts +67 -0
package/src/memory-match.ts
CHANGED
|
@@ -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/memory-repo.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
119
|
-
//
|
|
120
|
-
const row =
|
|
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(
|
|
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(
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
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,
|
|
272
|
-
return writeRows('upsertAll',
|
|
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
|
|
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
|
+
};
|