@ultimat3/entity 12.0.0 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ };
package/src/tenancy.ts CHANGED
@@ -39,7 +39,12 @@ export type Operator =
39
39
  | 'contains'
40
40
  | 'contained-by'
41
41
  | 'overlaps'
42
- | 'has-key';
42
+ | 'has-key'
43
+ // The FULL-TEXT half, added 2026-08-24. `column` is not a column: it is `SEARCH_PROPERTY`, and
44
+ // both drivers branch on the OPERATOR and read the entity's own `$search` — a `tsvector` is a
45
+ // physical column no row carries, so resolving it as a property would be a lie in two places.
46
+ // The operand is a search TERM, always bound, never parsed as tsquery syntax.
47
+ | 'matches';
43
48
 
44
49
  export interface Predicate {
45
50
  readonly column: string;
@@ -0,0 +1,119 @@
1
+ // One atomic move of one row through its column's state machine: the legality question answered
2
+ // from the declaration, the move made by a single conditional statement, and the diagnosis of a
3
+ // statement that matched nothing. Split from `query.ts` for the line ceiling and because this is
4
+ // the one write whose refusal is a READ — see `diagnose`.
5
+
6
+ import { columnFor } from './column';
7
+ import type { EntityCore } from './entity';
8
+ import { notFound } from './errors';
9
+ import type { IllegalTransition } from './feature-errors';
10
+ import { stateConflict, stateTransitionIllegal, stateUndeclared } from './feature-errors';
11
+ import type { Repo, RepoOptions } from './repo';
12
+ import { canMove, isState, isTerminal, movesFrom, type StateMachine } from './state-machine';
13
+ import type { ColumnMap, IdOf, RowPatch } from './types';
14
+
15
+ /** What a caller names: the state it believes the row is in, and the one it wants. */
16
+ export interface Move<S extends string = string> {
17
+ readonly from: S;
18
+ readonly to: S;
19
+ }
20
+
21
+ /** Every property whose column declares a machine — what `X_STATE_UNDECLARED` lists back. */
22
+ export const machineColumns = <Row, C extends ColumnMap>(
23
+ entity: EntityCore<Row, C>,
24
+ ): readonly string[] =>
25
+ Object.entries(entity.$columns)
26
+ .filter(([, column]) => column.$meta.machine !== undefined)
27
+ .map(([property]) => property);
28
+
29
+ /**
30
+ * The machine on a named column, or the refusal. `columnFor` and not `$columns[property]`: the name
31
+ * is caller data on this path, and a plain read answers an `Object.prototype` member.
32
+ */
33
+ export const machineFor = <Row, C extends ColumnMap>(
34
+ entity: EntityCore<Row, C>,
35
+ property: string,
36
+ ): StateMachine => {
37
+ const machine = columnFor(entity.$columns, property)?.$meta.machine;
38
+ if (machine === undefined) {
39
+ throw stateUndeclared(entity.$name, property, machineColumns(entity));
40
+ }
41
+ return machine;
42
+ };
43
+
44
+ /**
45
+ * Why the statement matched no row, asked only once it already has.
46
+ *
47
+ * A read AFTER the decision, never before one: the conditional update is what refused, and this
48
+ * exists so the refusal carries the state the row is really in instead of "0 rows". It is
49
+ * tenant-scoped like every other read, so a row belonging to another org reads as absent and the
50
+ * caller is told `X_NOT_FOUND` — which is the truth available to them, and the only answer that
51
+ * does not confirm the row exists somewhere.
52
+ */
53
+ const diagnose = async <Row>(
54
+ entity: EntityCore<Row>,
55
+ repo: Repo<Row>,
56
+ property: string,
57
+ id: IdOf<Row>,
58
+ move: Move,
59
+ options: RepoOptions | undefined,
60
+ ): Promise<Error> => {
61
+ const row = await repo.findById(id, options);
62
+ if (row === null) return notFound(entity.$name, String(id));
63
+ const actual = (row as Readonly<Record<string, unknown>>)[property];
64
+ return stateConflict(entity.$name, property, String(id), move.from, String(actual));
65
+ };
66
+
67
+ const whyNot = (machine: StateMachine, move: Move): IllegalTransition => {
68
+ if (!isState(machine, move.from)) return { reason: 'unknown-state', states: machine.states };
69
+ if (isTerminal(machine, move.from)) return { reason: 'terminal' };
70
+ return { reason: 'not-declared', legal: movesFrom(machine, move.from) };
71
+ };
72
+
73
+ /**
74
+ * The move, made by ONE statement.
75
+ *
76
+ * The predicate carries the state the caller expects, so the state that was OBSERVED and the state
77
+ * that was WRITTEN are one decision the database made under its own row lock. A read-then-check-
78
+ * then-write is the same code with a window in it: two callers both read `pending`, both find the
79
+ * move legal, and both write — and the second write is a transition out of a state the row had
80
+ * already left. Here the second statement's `status = 'pending'` matches nothing, and no rows is
81
+ * the refusal.
82
+ *
83
+ * The legality question is answered before the statement rather than inside it, because the
84
+ * transition table is not in the database and does not belong there: it is a property of the
85
+ * declaration, so an illegal move is refused without a round trip and without touching the row.
86
+ *
87
+ * The row is READ BACK afterwards rather than returned by the statement. `updateWhere` answers a
88
+ * count in both drivers, and a second read is honest about what it is — the row as it stands now,
89
+ * which is the row this call moved unless something moved it again, and something moving it again
90
+ * is exactly what this design permits and reports.
91
+ */
92
+ export const transitionRow = async <Row, C extends ColumnMap>(
93
+ entity: EntityCore<Row, C>,
94
+ repo: Repo<Row>,
95
+ property: string,
96
+ id: IdOf<Row>,
97
+ move: Move,
98
+ patch: (values: RowPatch<Row>) => RowPatch<Row>,
99
+ options: RepoOptions | undefined,
100
+ ): Promise<Row> => {
101
+ const machine = machineFor(entity, property);
102
+ if (!canMove(machine, move.from, move.to)) {
103
+ // Asked in this order and no other: an unknown state is terminal-looking (no outgoing moves)
104
+ // and a terminal state is legal-list-looking (an empty list), so a check that skipped either
105
+ // one would answer a true sentence about the wrong thing.
106
+ throw stateTransitionIllegal(entity.$name, property, move.from, move.to, whyNot(machine, move));
107
+ }
108
+ // `as unknown as`, and the double step is the honest one: `RowPatch<Row>` is a mapped type over
109
+ // an UNRESOLVED `Row`, so it never reduces and no object literal is ever assignable to it — the
110
+ // same reason `expr.ts` and `@ultimat3/query`'s `paginate` spell theirs the same way. The column
111
+ // name came from `machineFor`, which resolved it against the entity, so the shape is a real one.
112
+ const filter = { id, [property]: move.from } as unknown as RowPatch<Row>;
113
+ const values = { [property]: move.to } as unknown as RowPatch<Row>;
114
+ const written = await repo.updateWhere(filter, patch(values), options);
115
+ if (written === 0) throw await diagnose(entity, repo, property, id, move, options);
116
+ const row = await repo.findById(id, options);
117
+ if (row === null) throw notFound(entity.$name, String(id));
118
+ return row;
119
+ };
package/src/types.ts CHANGED
@@ -48,6 +48,13 @@ export type ColumnDefault =
48
48
 
49
49
  export type OnDelete = 'cascade' | 'restrict' | 'set null';
50
50
 
51
+ /**
52
+ * A source column's contribution to the entity's search vector — Postgres' own four labels, which
53
+ * `ts_rank` weights `{D, C, B, A}` = `{0.1, 0.2, 0.4, 1.0}` by default. `D` is what an unweighted
54
+ * `to_tsvector` produces, so it is the default here too.
55
+ */
56
+ export type SearchWeight = 'A' | 'B' | 'C' | 'D';
57
+
51
58
  export interface ReferenceOptions {
52
59
  readonly onDelete?: OnDelete;
53
60
  }
@@ -63,6 +70,7 @@ export interface ReferenceOptions {
63
70
  * `t.money` — the schema node that becomes the OpenAPI contract — rejected the framework's own row.
64
71
  */
65
72
  import type { MoneyValue } from '@ultimat3/schema';
73
+ import type { StateMachine, TransitionTable } from './state-machine';
66
74
 
67
75
  export type { MoneyValue };
68
76
 
@@ -133,6 +141,20 @@ export interface ColumnMeta {
133
141
  /** A thunk: schema modules reference each other in a cycle. */
134
142
  readonly references?: () => AnyColumn;
135
143
  readonly onDelete?: OnDelete;
144
+ /**
145
+ * Presence is what puts this column in the entity's generated `tsvector`, and the value is its
146
+ * weight. A modifier and not a column of its own: the vector is derived from every searchable
147
+ * column at once (`search.ts`), so declaring it per column would be one vector per column and
148
+ * one GIN index per column.
149
+ */
150
+ readonly searchable?: SearchWeight;
151
+ /**
152
+ * The state machine this column's values move through, when one was declared. Built once at
153
+ * declaration (`stateMachineOf`) so an illegal table is a refusal where it was written, and held
154
+ * as the built machine rather than the literal table because the literal is an object a caller
155
+ * indexes by a data key.
156
+ */
157
+ readonly machine?: StateMachine;
136
158
  }
137
159
 
138
160
  /**
@@ -159,6 +181,34 @@ export interface Column<T, Optional extends boolean = false> {
159
181
  * override it to keep theirs, and nothing else has any.
160
182
  */
161
183
  column(name: string): Column<T, Optional>;
184
+ /**
185
+ * Adds this column to the entity's one generated `tsvector`, at `weight` (default `D`). Text
186
+ * only — every other kind is refused here, where the chain was written.
187
+ */
188
+ searchable(weight?: SearchWeight): Column<T, Optional>;
189
+ }
190
+
191
+ /**
192
+ * `enumerated()`'s own column: the one that may declare a state machine, because it is the one
193
+ * that already declares a closed set of values for the machine to move through.
194
+ *
195
+ * The links below are overridden for the reason `UuidColumn`'s and `TimestampColumn`'s are — the
196
+ * generic chain answers the general `Column`, so `enumerated(S).default('draft')` would lose
197
+ * `transitions` and `enumerated(S).transitions(T).column('c')` would lose the machine's own type.
198
+ */
199
+ export interface EnumeratedColumn<V extends readonly string[], Optional extends boolean = false>
200
+ extends Column<V[number], Optional> {
201
+ /**
202
+ * Declares which values may follow which. The states are `V` — this column's own — so the
203
+ * framework never names one: a missing state, an unknown key and an unknown target are each a
204
+ * compile error against the set the app already wrote.
205
+ *
206
+ * A state machine column may not be nullable: NULL is not a state, so nothing could say what it
207
+ * may move to.
208
+ */
209
+ transitions(table: TransitionTable<V[number]>): EnumeratedColumn<V, Optional>;
210
+ default(value: V[number]): EnumeratedColumn<V, true>;
211
+ column(name: string): EnumeratedColumn<V, Optional>;
162
212
  }
163
213
 
164
214
  /**