@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/tenancy.ts
CHANGED
|
@@ -13,6 +13,18 @@ import {
|
|
|
13
13
|
} from './errors';
|
|
14
14
|
import type { ColumnMap } from './types';
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The predicate vocabulary, closed. The last four are the CONTAINMENT half, added 2026-08-24: a
|
|
18
|
+
* `json()` or `arrayOf()` column was declared, written and then unfilterable — the ten operators
|
|
19
|
+
* before them could compare a column to a scalar and nothing else — so an app storing either had
|
|
20
|
+
* to leave the query language for hand-written SQL, which is the one read path in this framework
|
|
21
|
+
* with no tenancy guard on it. Their meaning is Postgres', written once in `containment.ts` and
|
|
22
|
+
* read by both drivers.
|
|
23
|
+
*
|
|
24
|
+
* There is deliberately no jsonpath EXPRESSION operator beside them: `contains` already matches
|
|
25
|
+
* nested structure (`data @> '{"a":{"b":1}}'`), and a path language inside the query language
|
|
26
|
+
* would be a second way to ask one question.
|
|
27
|
+
*/
|
|
16
28
|
export type Operator =
|
|
17
29
|
| 'eq'
|
|
18
30
|
| 'neq'
|
|
@@ -23,7 +35,16 @@ export type Operator =
|
|
|
23
35
|
| 'lte'
|
|
24
36
|
| 'like'
|
|
25
37
|
| 'is-null'
|
|
26
|
-
| 'is-not-null'
|
|
38
|
+
| 'is-not-null'
|
|
39
|
+
| 'contains'
|
|
40
|
+
| 'contained-by'
|
|
41
|
+
| 'overlaps'
|
|
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';
|
|
27
48
|
|
|
28
49
|
export interface Predicate {
|
|
29
50
|
readonly column: string;
|
|
@@ -195,7 +216,7 @@ const verifyScope = (
|
|
|
195
216
|
* every repository operation through `readPlan`, so both drivers and every read, write and count
|
|
196
217
|
* pass through this one derivation.
|
|
197
218
|
*
|
|
198
|
-
* Runtime only. There is no build-time tenancy step in `x verify` — its
|
|
219
|
+
* Runtime only. There is no build-time tenancy step in `x verify` — its 20 steps check none — and
|
|
199
220
|
* there cannot usefully be one: the tenant is a request-time value, so a compiler could only prove
|
|
200
221
|
* that some argument was passed, which is exactly the thing that was never a guarantee. That is
|
|
201
222
|
* why this is the seam every plan is built through rather than a lint.
|
|
@@ -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
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// A column carries its TypeScript type in `$parse`, which is what lets the row type be derived
|
|
7
7
|
// from the column set instead of being written a second time as a hand-maintained schema.
|
|
8
8
|
|
|
9
|
+
import type { IndexMethod } from '@ultimat3/db';
|
|
10
|
+
|
|
9
11
|
/**
|
|
10
12
|
* Postgres types the builders emit. `money` expands to `bigint` + `char(3)` (+ a nullable
|
|
11
13
|
* `integer` scale); `array` expands to its element's type with `[]` after it.
|
|
@@ -46,6 +48,13 @@ export type ColumnDefault =
|
|
|
46
48
|
|
|
47
49
|
export type OnDelete = 'cascade' | 'restrict' | 'set null';
|
|
48
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
|
+
|
|
49
58
|
export interface ReferenceOptions {
|
|
50
59
|
readonly onDelete?: OnDelete;
|
|
51
60
|
}
|
|
@@ -61,6 +70,7 @@ export interface ReferenceOptions {
|
|
|
61
70
|
* `t.money` — the schema node that becomes the OpenAPI contract — rejected the framework's own row.
|
|
62
71
|
*/
|
|
63
72
|
import type { MoneyValue } from '@ultimat3/schema';
|
|
73
|
+
import type { StateMachine, TransitionTable } from './state-machine';
|
|
64
74
|
|
|
65
75
|
export type { MoneyValue };
|
|
66
76
|
|
|
@@ -131,6 +141,20 @@ export interface ColumnMeta {
|
|
|
131
141
|
/** A thunk: schema modules reference each other in a cycle. */
|
|
132
142
|
readonly references?: () => AnyColumn;
|
|
133
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;
|
|
134
158
|
}
|
|
135
159
|
|
|
136
160
|
/**
|
|
@@ -157,6 +181,34 @@ export interface Column<T, Optional extends boolean = false> {
|
|
|
157
181
|
* override it to keep theirs, and nothing else has any.
|
|
158
182
|
*/
|
|
159
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>;
|
|
160
212
|
}
|
|
161
213
|
|
|
162
214
|
/**
|
|
@@ -268,4 +320,12 @@ export interface IndexDef {
|
|
|
268
320
|
readonly order?: 'asc' | 'desc';
|
|
269
321
|
/** Partial index predicate — a soft-deleted row is excluded with this. */
|
|
270
322
|
readonly where?: string;
|
|
323
|
+
/**
|
|
324
|
+
* The access method. Absent is `btree`, which is Postgres' own default and what every index
|
|
325
|
+
* declared before this field existed is — so an entity that names none emits the statement it
|
|
326
|
+
* always emitted, byte for byte, and no app's sidecar regenerates. `@ultimat3/db` owns the
|
|
327
|
+
* closed set (`INDEX_METHODS`); redeclaring the union here would be the second declaration of
|
|
328
|
+
* one fact that this release exists to stop.
|
|
329
|
+
*/
|
|
330
|
+
readonly using?: IndexMethod;
|
|
271
331
|
}
|