@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/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 { keyOf } from './batch-read';
11
- import { conflictKeyOf, conflictKeys, upsertPlan } from './bulk-write';
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
+ };