@ultimat3/entity 11.3.0 → 13.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +243 -20
- package/README.md +206 -1
- package/package.json +5 -5
- package/src/aggregate-decode.ts +35 -0
- package/src/aggregate-fold.ts +91 -0
- package/src/aggregate.ts +232 -0
- package/src/batch.ts +2 -1
- package/src/column-values.ts +29 -0
- package/src/column.ts +37 -1
- package/src/columns.ts +5 -46
- package/src/containment.ts +94 -0
- package/src/cursor.ts +100 -14
- package/src/database.ts +1 -1
- package/src/describe.ts +38 -3
- package/src/entity.ts +117 -8
- package/src/enum-column.ts +81 -0
- package/src/errors.ts +16 -0
- package/src/feature-errors.ts +121 -0
- package/src/index-name.ts +83 -0
- package/src/index.ts +42 -4
- package/src/instant.ts +93 -0
- package/src/memory-match.ts +72 -0
- package/src/memory-repo.ts +357 -0
- package/src/pg-driver.ts +94 -7
- package/src/pg-row.ts +38 -1
- package/src/pg-sql.ts +311 -133
- package/src/pg-write-sql.ts +129 -0
- package/src/plan.ts +71 -10
- package/src/query.ts +120 -3
- package/src/registry.ts +16 -0
- package/src/repo.ts +16 -328
- package/src/search.ts +153 -0
- package/src/state-machine.ts +132 -0
- package/src/tenancy.ts +23 -2
- package/src/transition.ts +119 -0
- package/src/types.ts +60 -0
package/src/repo.ts
CHANGED
|
@@ -7,18 +7,8 @@
|
|
|
7
7
|
// table silently skips and repeats rows. A keyset cursor is stable because it names a
|
|
8
8
|
// position in the sort order, not a row count.
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { entityNow } from './clock';
|
|
13
|
-
import { narrowMoney } from './columns';
|
|
14
|
-
import { countsFrom, groupColumnOf } from './count-by';
|
|
15
|
-
import { cursorFor, kindOf, seekFrom, valueAt } from './cursor';
|
|
16
|
-
import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
17
|
-
import { notFound } from './errors';
|
|
18
|
-
import { compareByKind, matchesPredicate } from './memory-match';
|
|
19
|
-
import { deletePlan, idPlan, readPlan, singleKeyOf, updatePlan } from './plan';
|
|
20
|
-
import type { Predicate, QueryPlan, SortKey } from './tenancy';
|
|
21
|
-
import { assertRowTenant } from './tenancy';
|
|
10
|
+
import type { AggregateFn } from './aggregate';
|
|
11
|
+
import type { Predicate, SortKey } from './tenancy';
|
|
22
12
|
import type { IdOf, RowPatch } from './types';
|
|
23
13
|
|
|
24
14
|
export interface Tx {
|
|
@@ -136,6 +126,20 @@ export interface Repo<T = unknown> {
|
|
|
136
126
|
* `ReadBuilder.countBy`, which knows the row. Ordered by count, biggest group first.
|
|
137
127
|
*/
|
|
138
128
|
countBy(column: string, args?: FindManyArgs): Promise<ReadonlyMap<unknown, number>>;
|
|
129
|
+
/**
|
|
130
|
+
* One aggregate over exactly the rows `count(args)` counts. Row-agnostic here and typed on the
|
|
131
|
+
* chain, the same seam `countBy` has: a column name is a runtime string at this contract.
|
|
132
|
+
*
|
|
133
|
+
* `null` for an empty set in every function, which is what SQL answers — a `0` would claim rows
|
|
134
|
+
* were seen. A `sum` or an `avg` comes back as decimal TEXT and a money aggregate as a
|
|
135
|
+
* `MoneyValue`; neither is ever a float.
|
|
136
|
+
*/
|
|
137
|
+
aggregate(fn: AggregateFn, column: string, args?: FindManyArgs): Promise<unknown>;
|
|
138
|
+
/**
|
|
139
|
+
* The planner's own row estimate for the table — not a count, and never filtered. `null` when
|
|
140
|
+
* the table has never been analysed, which is a fact and not an estimate.
|
|
141
|
+
*/
|
|
142
|
+
approximateCount(args?: FindManyArgs): Promise<number | null>;
|
|
139
143
|
}
|
|
140
144
|
|
|
141
145
|
/**
|
|
@@ -154,319 +158,3 @@ export interface MemoryRepo<Row> extends Repo<Row> {
|
|
|
154
158
|
export interface Transactor {
|
|
155
159
|
run<R>(work: (tx: Tx) => Promise<R>): Promise<R>;
|
|
156
160
|
}
|
|
157
|
-
|
|
158
|
-
const field = (row: unknown, property: string): unknown =>
|
|
159
|
-
typeof row === 'object' && row !== null ? (row as Record<string, unknown>)[property] : undefined;
|
|
160
|
-
|
|
161
|
-
/** Lexicographic over the sort keys, direction applied. `> 0` means "after the cursor". */
|
|
162
|
-
const compareToSeek = <Row>(
|
|
163
|
-
entity: EntityCore<Row>,
|
|
164
|
-
plan: QueryPlan,
|
|
165
|
-
row: unknown,
|
|
166
|
-
seek: readonly unknown[],
|
|
167
|
-
): number => {
|
|
168
|
-
for (const [index, entry] of plan.orderBy.entries()) {
|
|
169
|
-
// The COLUMN's kind, not the value's: the seek was revived from the same kind (`cursor.ts`),
|
|
170
|
-
// so a `bigint` column compares its stored decimal string against a revived `BigInt` as one
|
|
171
|
-
// number instead of as two pieces of text.
|
|
172
|
-
const order = compareByKind(
|
|
173
|
-
kindOf(entity, entry.column),
|
|
174
|
-
valueAt(row, entry.column),
|
|
175
|
-
seek[index],
|
|
176
|
-
);
|
|
177
|
-
if (order !== 0) return entry.direction === 'desc' ? -order : order;
|
|
178
|
-
}
|
|
179
|
-
return 0;
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Where the next page starts. By sort position, not by the previous row's id: that row may have
|
|
184
|
-
* been deleted between the two requests, and an id that is no longer there would restart
|
|
185
|
-
* pagination at the top instead of continuing it.
|
|
186
|
-
*/
|
|
187
|
-
const afterCursor = <Row>(
|
|
188
|
-
entity: EntityCore<Row>,
|
|
189
|
-
plan: QueryPlan,
|
|
190
|
-
found: readonly Row[],
|
|
191
|
-
): number => {
|
|
192
|
-
const seek = seekFrom(entity, plan);
|
|
193
|
-
if (seek === undefined) return 0;
|
|
194
|
-
const start = found.findIndex((row) => compareToSeek(entity, plan, row, seek) > 0);
|
|
195
|
-
return start === -1 ? found.length : start;
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* The default driver: correct semantics, no database. `x dev` uses it before the first
|
|
200
|
-
* migration and tests use it everywhere. Postgres is the production driver and implements
|
|
201
|
-
* this same interface.
|
|
202
|
-
*/
|
|
203
|
-
export const memoryRepo = <Row>(
|
|
204
|
-
entity: EntityCore<Row>,
|
|
205
|
-
seed: readonly Row[] = [],
|
|
206
|
-
): MemoryRepo<Row> => {
|
|
207
|
-
/**
|
|
208
|
-
* A stored row's key, spelled the way `batch-read.ts` spells an id — because Postgres compares a
|
|
209
|
-
* `uuid` as a VALUE and prints it lower-cased, so `findById(UPPER)` reads the row there while
|
|
210
|
-
* `String(...)` missed it here: `null` from a read and `X_NOT_FOUND` from a write, against a row
|
|
211
|
-
* that exists, reachable from a path parameter, a client-supplied id or a legacy import.
|
|
212
|
-
*/
|
|
213
|
-
const storeKey = (row: unknown): string =>
|
|
214
|
-
entity.$primaryKey
|
|
215
|
-
.map((property) => keyOf(kindOf(entity, property) ?? '', field(row, property)))
|
|
216
|
-
.join('');
|
|
217
|
-
/** The same key, from the id a caller named rather than from a row it has in hand. */
|
|
218
|
-
const idStoreKey = (id: unknown, operation: string): string =>
|
|
219
|
-
keyOf(kindOf(entity, singleKeyOf(entity, operation)) ?? '', id);
|
|
220
|
-
const rows = new Map<string, Row>(seed.map((row) => [storeKey(row), row]));
|
|
221
|
-
|
|
222
|
-
const rowsOf = (plan: QueryPlan, args: FindManyArgs): Row[] => {
|
|
223
|
-
const visible = (row: Row): boolean =>
|
|
224
|
-
!entity.$softDelete ||
|
|
225
|
-
args.includeDeleted === true ||
|
|
226
|
-
field(row, SOFT_DELETE_COLUMN) === null ||
|
|
227
|
-
field(row, SOFT_DELETE_COLUMN) === undefined;
|
|
228
|
-
return [...rows.values()]
|
|
229
|
-
.filter((row) => plan.where.every((predicate) => matchesPredicate(entity, row, predicate)))
|
|
230
|
-
.filter(visible)
|
|
231
|
-
.sort((left, right) => {
|
|
232
|
-
for (const entry of plan.orderBy) {
|
|
233
|
-
const order = compareByKind(
|
|
234
|
-
kindOf(entity, entry.column),
|
|
235
|
-
valueAt(left, entry.column),
|
|
236
|
-
valueAt(right, entry.column),
|
|
237
|
-
);
|
|
238
|
-
if (order !== 0) return entry.direction === 'desc' ? -order : order;
|
|
239
|
-
}
|
|
240
|
-
return 0;
|
|
241
|
-
});
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
const select = (args: FindManyArgs, operation: string): { plan: QueryPlan; found: Row[] } => {
|
|
245
|
-
const plan = readPlan(entity, args, operation);
|
|
246
|
-
return { plan, found: rowsOf(plan, args) };
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
const write = (given: Row, options: RepoOptions | undefined, operation: string): Row => {
|
|
250
|
-
// `MoneyInput` lets a writer hand a `bigint`; a stored row holds the value type. The Postgres
|
|
251
|
-
// driver narrows in `bindValues` and reads its answer back through `returning *`, so without
|
|
252
|
-
// this an in-memory row would be the one row in the framework `JSON.stringify` refuses.
|
|
253
|
-
const row = narrowMoney(entity.$columns, given);
|
|
254
|
-
// Beside `$assert`, and before the row lands: a write is judged by the tenant it names as well
|
|
255
|
-
// as by the invariants it declares, and the Postgres driver runs the same pair in `writeRows`.
|
|
256
|
-
// `update` reaches here with the STORED row merged under its patch, so a patch that moves a row
|
|
257
|
-
// out of this tenant is refused by the same call that refuses an insert into another one.
|
|
258
|
-
assertRowTenant(entity.$name, entity.$tenantColumn, operation, row);
|
|
259
|
-
entity.$assert(row);
|
|
260
|
-
const key = storeKey(row);
|
|
261
|
-
const previous = rows.get(key);
|
|
262
|
-
options?.tx?.onRollback(() => {
|
|
263
|
-
if (previous === undefined) rows.delete(key);
|
|
264
|
-
else rows.set(key, previous);
|
|
265
|
-
});
|
|
266
|
-
rows.set(key, row);
|
|
267
|
-
return row;
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
// The same guard the read path applies: on a tenant-scoped entity an id alone is not enough
|
|
271
|
-
// to name a row, so `update`/`delete` resolve through a plan rather than through the map.
|
|
272
|
-
const addressed = (id: string, options: RepoOptions | undefined, operation: string): Row => {
|
|
273
|
-
const plan = idPlan(entity, id, options, operation);
|
|
274
|
-
const current = rows.get(idStoreKey(id, operation));
|
|
275
|
-
// A soft-deleted row is hidden from writes too — `delete` on one is `X_NOT_FOUND`, not a
|
|
276
|
-
// second stamp, which is what the Postgres driver's `deleted_at is null` clause already says.
|
|
277
|
-
const hidden =
|
|
278
|
-
current !== undefined &&
|
|
279
|
-
entity.$softDelete &&
|
|
280
|
-
field(current, SOFT_DELETE_COLUMN) !== null &&
|
|
281
|
-
field(current, SOFT_DELETE_COLUMN) !== undefined;
|
|
282
|
-
if (
|
|
283
|
-
current === undefined ||
|
|
284
|
-
hidden ||
|
|
285
|
-
!plan.where.every((predicate) => matchesPredicate(entity, current, predicate))
|
|
286
|
-
) {
|
|
287
|
-
throw notFound(entity.$name, id);
|
|
288
|
-
}
|
|
289
|
-
return current;
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
// Every method is async: a repository call that fails must reject, never throw
|
|
293
|
-
// synchronously, or half the call sites would need two error paths.
|
|
294
|
-
return {
|
|
295
|
-
async findById(id, options) {
|
|
296
|
-
const { found } = select(
|
|
297
|
-
{ ...options, where: [{ column: singleKeyOf(entity, 'findById'), op: 'eq', value: id }] },
|
|
298
|
-
'findById',
|
|
299
|
-
);
|
|
300
|
-
return found[0] ?? null;
|
|
301
|
-
},
|
|
302
|
-
|
|
303
|
-
async findMany(args = {}) {
|
|
304
|
-
const { plan, found } = select(args, 'findMany');
|
|
305
|
-
const start = afterCursor(entity, plan, found);
|
|
306
|
-
const page = found.slice(start, start + plan.limit);
|
|
307
|
-
const last = page.at(-1);
|
|
308
|
-
const more = start + page.length < found.length;
|
|
309
|
-
return {
|
|
310
|
-
rows: page,
|
|
311
|
-
nextCursor:
|
|
312
|
-
more && last !== undefined ? cursorFor(entity, plan, last, storeKey(last)) : null,
|
|
313
|
-
};
|
|
314
|
-
},
|
|
315
|
-
|
|
316
|
-
async insert(values, options) {
|
|
317
|
-
return write(values, options, 'insert');
|
|
318
|
-
},
|
|
319
|
-
|
|
320
|
-
async insertAll(batch, options) {
|
|
321
|
-
// The whole batch is judged before any of it lands: Postgres refuses the statement as one,
|
|
322
|
-
// so a row an invariant rejects — or one naming a tenant this actor may not write — must not
|
|
323
|
-
// leave the rows before it stored here either. `write` re-checks both per row; this loop is
|
|
324
|
-
// what makes the batch all-or-nothing, which is the half a per-row check cannot give.
|
|
325
|
-
for (const row of batch) {
|
|
326
|
-
assertRowTenant(entity.$name, entity.$tenantColumn, 'insertAll', row);
|
|
327
|
-
entity.$assert(row);
|
|
328
|
-
}
|
|
329
|
-
return batch.map((row) => write(row, options, 'insertAll'));
|
|
330
|
-
},
|
|
331
|
-
|
|
332
|
-
async upsertAll(batch, args) {
|
|
333
|
-
// The INCOMING rows, judged before any of them is matched: under `onMatch: 'nothing'` a
|
|
334
|
-
// colliding row is skipped and never reaches `write()`, so checking only what lands would
|
|
335
|
-
// let a row naming another tenant through whenever it happened to collide.
|
|
336
|
-
for (const row of batch) {
|
|
337
|
-
assertRowTenant(entity.$name, entity.$tenantColumn, 'upsertAll', row);
|
|
338
|
-
entity.$assert(row);
|
|
339
|
-
}
|
|
340
|
-
const plan = upsertPlan(entity, batch, args.onConflict, args.onMatch ?? 'update');
|
|
341
|
-
const keys = conflictKeys(entity, plan, batch);
|
|
342
|
-
// The stored rows under the same key, so "does this collide" is the question the unique
|
|
343
|
-
// index answers in Postgres and not a scan per row. A soft-deleted row still occupies its
|
|
344
|
-
// key here, because the index it would collide with there is not partial either — and a row
|
|
345
|
-
// whose target holds a null occupies none, because the index is `NULLS DISTINCT`.
|
|
346
|
-
const stored = new Map<string, Row>();
|
|
347
|
-
for (const row of rows.values()) {
|
|
348
|
-
const key = conflictKeyOf(entity, plan.on, row);
|
|
349
|
-
if (key !== undefined) stored.set(key, row);
|
|
350
|
-
}
|
|
351
|
-
const written: Row[] = [];
|
|
352
|
-
for (const [position, row] of batch.entries()) {
|
|
353
|
-
const key = keys[position];
|
|
354
|
-
const existing = key === undefined ? undefined : stored.get(key);
|
|
355
|
-
// `do nothing` writes no row, and `returning *` therefore names none: a skipped row is
|
|
356
|
-
// absent from the result rather than present and unchanged.
|
|
357
|
-
if (existing !== undefined && plan.set.length === 0) continue;
|
|
358
|
-
const merged =
|
|
359
|
-
existing === undefined
|
|
360
|
-
? row
|
|
361
|
-
: Object.assign(
|
|
362
|
-
{},
|
|
363
|
-
existing,
|
|
364
|
-
Object.fromEntries(plan.set.map((property) => [property, field(row, property)])),
|
|
365
|
-
);
|
|
366
|
-
// `UpsertArgs extends RepoOptions`, so the args ARE the options — one bag, and a `tx`
|
|
367
|
-
// passed to an upsert registers its undo exactly as it does for every other write here.
|
|
368
|
-
const result = write(merged, args, 'upsertAll');
|
|
369
|
-
// Filed as it lands, so a later row of the same batch collides with an earlier one exactly
|
|
370
|
-
// as it would with a row the request stored a moment before it.
|
|
371
|
-
if (key !== undefined) stored.set(key, result);
|
|
372
|
-
written.push(result);
|
|
373
|
-
}
|
|
374
|
-
return written;
|
|
375
|
-
},
|
|
376
|
-
|
|
377
|
-
async update(id, patch, options) {
|
|
378
|
-
return write(Object.assign({}, addressed(id, options, 'update'), patch), options, 'update');
|
|
379
|
-
},
|
|
380
|
-
|
|
381
|
-
async delete(id, options) {
|
|
382
|
-
const current = addressed(id, options, 'delete');
|
|
383
|
-
// Soft delete hides the row without losing it; the column's presence is the switch.
|
|
384
|
-
if (entity.$softDelete) {
|
|
385
|
-
write(Object.assign({}, current, { [SOFT_DELETE_COLUMN]: entityNow() }), options, 'delete');
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
const key = storeKey(current);
|
|
389
|
-
options?.tx?.onRollback(() => rows.set(key, current));
|
|
390
|
-
rows.delete(key);
|
|
391
|
-
},
|
|
392
|
-
|
|
393
|
-
async deleteWhere(filter, options) {
|
|
394
|
-
// `rowsOf` is the read path: the same predicates, the same tenant scoping, and the same
|
|
395
|
-
// soft-delete visibility. A row already stamped is not matched, so a second call cannot
|
|
396
|
-
// move `deletedAt` forward — which is what the Postgres driver's `deleted_at is null`
|
|
397
|
-
// clause says there.
|
|
398
|
-
const doomed = rowsOf(deletePlan(entity, filter, options, 'deleteWhere'), {});
|
|
399
|
-
for (const row of doomed) {
|
|
400
|
-
if (entity.$softDelete) {
|
|
401
|
-
write(
|
|
402
|
-
Object.assign({}, row, { [SOFT_DELETE_COLUMN]: entityNow() }),
|
|
403
|
-
options,
|
|
404
|
-
'deleteWhere',
|
|
405
|
-
);
|
|
406
|
-
continue;
|
|
407
|
-
}
|
|
408
|
-
const key = storeKey(row);
|
|
409
|
-
options?.tx?.onRollback(() => rows.set(key, row));
|
|
410
|
-
rows.delete(key);
|
|
411
|
-
}
|
|
412
|
-
return doomed.length;
|
|
413
|
-
},
|
|
414
|
-
|
|
415
|
-
async updateWhere(filter, patch, options) {
|
|
416
|
-
const plan = updatePlan(entity, filter, patch, options, 'updateWhere');
|
|
417
|
-
// The PATCH, judged whole and before the rows are read — the same call `postgresRepo` makes
|
|
418
|
-
// before its statement exists. Inside the loop below it is judged only where a row was
|
|
419
|
-
// matched, so a patch handing rows to another tenant was refused or accepted depending on
|
|
420
|
-
// what the table happened to hold: `updateWhere(filter, { orgId: theirs })` over a filter
|
|
421
|
-
// matching nothing answered `0` here and threw there, from one call.
|
|
422
|
-
assertRowTenant(entity.$name, entity.$tenantColumn, 'updateWhere', patch);
|
|
423
|
-
// `rowsOf` again, so a soft-deleted row is as unreachable here as it is through
|
|
424
|
-
// `addressed()` — patching a row the app has already deleted is not an update, it is a
|
|
425
|
-
// resurrection nobody asked for. `write` re-asserts the invariants on each result.
|
|
426
|
-
const found = rowsOf(plan, {});
|
|
427
|
-
for (const row of found) write(Object.assign({}, row, patch), options, 'updateWhere');
|
|
428
|
-
return found.length;
|
|
429
|
-
},
|
|
430
|
-
|
|
431
|
-
async count(args = {}) {
|
|
432
|
-
return select(args, 'count').found.length;
|
|
433
|
-
},
|
|
434
|
-
|
|
435
|
-
async countBy(column, args = {}) {
|
|
436
|
-
// Refused before a row is read, and by the same function the Postgres driver calls: a column
|
|
437
|
-
// a map cannot be keyed by is that mistake in both drivers or in neither.
|
|
438
|
-
groupColumnOf(entity, column, 'countBy');
|
|
439
|
-
const { found } = select(args, 'countBy');
|
|
440
|
-
const groups = new Map<unknown, number>();
|
|
441
|
-
for (const row of found) {
|
|
442
|
-
// `?? null`, so a property this row never carried lands in the same group Postgres puts a
|
|
443
|
-
// NULL row in — and `0`, `''` and `false` stay the values they are.
|
|
444
|
-
const value = field(row, column) ?? null;
|
|
445
|
-
groups.set(value, (groups.get(value) ?? 0) + 1);
|
|
446
|
-
}
|
|
447
|
-
return countsFrom(entity, column, 'countBy', [...groups]);
|
|
448
|
-
},
|
|
449
|
-
|
|
450
|
-
reset() {
|
|
451
|
-
rows.clear();
|
|
452
|
-
for (const row of seed) rows.set(storeKey(row), row);
|
|
453
|
-
},
|
|
454
|
-
};
|
|
455
|
-
};
|
|
456
|
-
|
|
457
|
-
let txCounter = 0;
|
|
458
|
-
|
|
459
|
-
/** In-memory transactor: undo closures registered by drivers run on failure. */
|
|
460
|
-
export const memoryTransactor = (): Transactor => ({
|
|
461
|
-
async run(work) {
|
|
462
|
-
const undos: (() => void)[] = [];
|
|
463
|
-
txCounter += 1;
|
|
464
|
-
const tx: Tx = { id: `tx-${txCounter}`, onRollback: (undo) => undos.push(undo) };
|
|
465
|
-
try {
|
|
466
|
-
return await work(tx);
|
|
467
|
-
} catch (error) {
|
|
468
|
-
for (const undo of undos.reverse()) undo();
|
|
469
|
-
throw error;
|
|
470
|
-
}
|
|
471
|
-
},
|
|
472
|
-
});
|
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
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The MECHANISM half of a state machine on a column: the transition table, what a terminal state
|
|
2
|
+
// is, and the one legality question. The states themselves never ship — they arrive as the
|
|
3
|
+
// `enumerated()` set the column already declares, and nothing in this file knows what any of them
|
|
4
|
+
// means. An illegal transition is a defect in every business; an approval chain is not.
|
|
5
|
+
|
|
6
|
+
import { refuseColumn } from './refuse';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Every state names the states it may move to. A MAPPED type over the union, so the exhaustiveness
|
|
10
|
+
* is the compiler's: a state left out, a key that is not a state and a target that is not a state
|
|
11
|
+
* are each a compile error at the declaration, and the runtime checks below are what a JS caller
|
|
12
|
+
* and a table built from parsed JSON get instead.
|
|
13
|
+
*
|
|
14
|
+
* A state with an empty list is TERMINAL. That is the whole of the terminal concept — nothing to
|
|
15
|
+
* declare, nothing to name, and no way for the framework to have an opinion about which one it is.
|
|
16
|
+
*/
|
|
17
|
+
export type TransitionTable<S extends string> = { readonly [K in S]: readonly S[] };
|
|
18
|
+
|
|
19
|
+
export interface StateMachine<S extends string = string> {
|
|
20
|
+
/** The declared states, in declaration order. */
|
|
21
|
+
readonly states: readonly S[];
|
|
22
|
+
/**
|
|
23
|
+
* A `Map`, never the table object itself: `table[from]` with a caller's string answers an
|
|
24
|
+
* `Object.prototype` member, so `canMove(machine, 'constructor', …)` would read the `Object`
|
|
25
|
+
* function and every guard downstream would pass. The rule `bun run proto-index` enforces.
|
|
26
|
+
*/
|
|
27
|
+
readonly moves: ReadonlyMap<S, ReadonlySet<S>>;
|
|
28
|
+
/** Derived: every state whose outgoing set is empty. */
|
|
29
|
+
readonly terminal: ReadonlySet<S>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One `refuseColumn` site, five conditions, and the FIX comes from the caller — because a fix line
|
|
34
|
+
* carrying a `<placeholder>` is advice, not an edit, and `refuse.test.ts` refuses one. Every caller
|
|
35
|
+
* below names real states out of the set the column already declared, so each answer is pasteable.
|
|
36
|
+
*/
|
|
37
|
+
const refuse = (detail: string, fix: string): never => refuseColumn('transitions', detail, fix);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The machine a set of states and a table describe, validated once at declaration.
|
|
41
|
+
*
|
|
42
|
+
* Every rule here is structural: it is about whether the table describes a machine at all, never
|
|
43
|
+
* about which machine is the right one. A table missing a state cannot answer "may this row move",
|
|
44
|
+
* a self-loop is a transition that transitions nothing — and under the compare-and-set the write
|
|
45
|
+
* path uses it would report a move that did not happen — and a repeated target is a typo whose
|
|
46
|
+
* only effect is to make the declaration read as though it meant something.
|
|
47
|
+
*/
|
|
48
|
+
export const stateMachineOf = <S extends string>(
|
|
49
|
+
states: readonly S[],
|
|
50
|
+
table: TransitionTable<S>,
|
|
51
|
+
): StateMachine<S> => {
|
|
52
|
+
const declared = new Set<string>(states);
|
|
53
|
+
const keys = Object.keys(table);
|
|
54
|
+
const unknown = keys.filter((key) => !declared.has(key));
|
|
55
|
+
if (unknown.length > 0) {
|
|
56
|
+
refuse(
|
|
57
|
+
`${unknown.join(', ')} ${unknown.length === 1 ? 'is not one of' : 'are not among'} the declared states: ${states.join(' | ')}`,
|
|
58
|
+
`delete the ${unknown.map((key) => `"${key}"`).join(', ')} entry from transitions(), or add it to the enumerated([${states.map((state) => `'${state}'`).join(', ')}]) set on this column`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const named = new Set(keys);
|
|
62
|
+
const missing = states.filter((state) => !named.has(state));
|
|
63
|
+
if (missing.length > 0) {
|
|
64
|
+
refuse(
|
|
65
|
+
`no entry for ${missing.join(', ')} — every state needs one, and a terminal state is written as an empty list`,
|
|
66
|
+
`add ${missing.map((state) => `${state}: []`).join(', ')} to transitions() — an empty list is how a state nothing leaves is written`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const moves = new Map<S, ReadonlySet<S>>();
|
|
70
|
+
// `origin` and not `state`, which is the word this loop is about: `bun run secret-compare` reads
|
|
71
|
+
// a NAME, and `state` is in its vocabulary because an OAuth CSRF `state` is a credential compared
|
|
72
|
+
// with `===` — so a state machine, whose domain word is literally that, trips a rule written for
|
|
73
|
+
// a different thing. Renaming is the honest repair; a package-wide pin would spend the rule.
|
|
74
|
+
for (const origin of states) {
|
|
75
|
+
// Through `Object.hasOwn` even though the keys were just checked: this is the one read of a
|
|
76
|
+
// caller's object literal by a name, and the guard is what makes it a read of DATA.
|
|
77
|
+
const targets: readonly string[] = Object.hasOwn(table, origin) ? table[origin] : [];
|
|
78
|
+
const seen = new Set<S>();
|
|
79
|
+
for (const target of targets) {
|
|
80
|
+
if (!declared.has(target)) {
|
|
81
|
+
refuse(
|
|
82
|
+
`${origin} may move to ${target}, which is not one of: ${states.join(' | ')}`,
|
|
83
|
+
`remove '${target}' from the ${origin} entry of transitions(), or add it to the enumerated([${states.map((each) => `'${each}'`).join(', ')}]) set on this column`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (target === origin) {
|
|
87
|
+
refuse(
|
|
88
|
+
`${origin} lists itself as a target; a transition that changes nothing is not one`,
|
|
89
|
+
`remove '${origin}' from its own entry of transitions() — write ${origin}: [] if nothing leaves it`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (seen.has(target as S)) {
|
|
93
|
+
refuse(
|
|
94
|
+
`${origin} lists ${target} twice`,
|
|
95
|
+
`remove the second '${target}' from the ${origin} entry of transitions()`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
seen.add(target as S);
|
|
99
|
+
}
|
|
100
|
+
moves.set(origin, seen);
|
|
101
|
+
}
|
|
102
|
+
const terminal = new Set<S>(states.filter((state) => (moves.get(state)?.size ?? 0) === 0));
|
|
103
|
+
return { states: [...states], moves, terminal };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Whether the machine holds this exact move. Unknown states answer `false`, never throw. */
|
|
107
|
+
export const canMove = <S extends string>(
|
|
108
|
+
machine: StateMachine<S>,
|
|
109
|
+
from: string,
|
|
110
|
+
to: string,
|
|
111
|
+
): boolean => machine.moves.get(from as S)?.has(to as S) === true;
|
|
112
|
+
|
|
113
|
+
export const isTerminal = <S extends string>(machine: StateMachine<S>, state: string): boolean =>
|
|
114
|
+
machine.terminal.has(state as S);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Whether the machine declares this state at all — the question `isTerminal` cannot answer, and the
|
|
118
|
+
* reason it is asked FIRST at the call site. An unknown state has no outgoing moves either, so
|
|
119
|
+
* without this the refusal for a typo read "the row is terminal in <typo>", which is a sentence
|
|
120
|
+
* about a state that does not exist.
|
|
121
|
+
*/
|
|
122
|
+
export const isState = <S extends string>(machine: StateMachine<S>, state: string): boolean =>
|
|
123
|
+
machine.moves.has(state as S);
|
|
124
|
+
|
|
125
|
+
/** Everywhere this state may go, in declaration order — what a refusal lists back at the caller. */
|
|
126
|
+
export const movesFrom = <S extends string>(
|
|
127
|
+
machine: StateMachine<S>,
|
|
128
|
+
from: string,
|
|
129
|
+
): readonly S[] => {
|
|
130
|
+
const targets = machine.moves.get(from as S);
|
|
131
|
+
return targets === undefined ? [] : machine.states.filter((state) => targets.has(state));
|
|
132
|
+
};
|