@ultimat3/entity 12.0.0 → 14.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +93 -3
- package/README.md +93 -3
- package/package.json +5 -5
- package/src/column-values.ts +29 -0
- package/src/column.ts +22 -0
- package/src/columns.ts +22 -46
- package/src/describe.ts +41 -3
- package/src/entity.ts +50 -86
- package/src/enum-column.ts +81 -0
- package/src/errors.ts +10 -0
- package/src/feature-errors.ts +121 -0
- package/src/index-name.ts +83 -0
- package/src/index.ts +41 -11
- package/src/invariants.ts +7 -22
- package/src/memory-match.ts +5 -0
- package/src/memory-repo.ts +23 -8
- package/src/pg-driver.ts +17 -7
- package/src/pg-sql.ts +32 -0
- package/src/query.ts +61 -0
- package/src/registry.ts +32 -1
- package/src/repo.ts +9 -4
- package/src/search.ts +153 -0
- package/src/state-machine.ts +132 -0
- package/src/tenancy.ts +6 -1
- package/src/transition.ts +119 -0
- package/src/type-pins.ts +36 -0
- package/src/types.ts +67 -0
|
@@ -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/type-pins.ts
CHANGED
|
@@ -309,3 +309,39 @@ type _MoneyInputTakesABigInt = Assert<
|
|
|
309
309
|
|
|
310
310
|
/** And a row value is always a legal input: read a row, write it back. */
|
|
311
311
|
type _MoneyValueIsMoneyInput = Assert<[MoneyValue] extends [MoneyInput] ? true : false>;
|
|
312
|
+
|
|
313
|
+
/** A row with the one column whose write type is wider than its row type, and nothing else. */
|
|
314
|
+
type PinMoneyRow = { readonly id: string; readonly price: MoneyValue };
|
|
315
|
+
|
|
316
|
+
type PinWideMoneyRow = {
|
|
317
|
+
readonly id: string;
|
|
318
|
+
readonly price: { readonly minor: bigint; readonly currency: string };
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The pin the two above needed all along. `MoneyInput` declares the widening and could never
|
|
323
|
+
* enforce it end to end: `Repo.insert` took the ROW type, so the one call an app makes refused the
|
|
324
|
+
* value those lines call legal — a compile error at `postgresRepo().insert(...)`, which is exported
|
|
325
|
+
* and therefore public API, while both drivers narrowed it correctly at runtime. Pinned at `Repo`
|
|
326
|
+
* rather than at `RowWrite`, because a mapped type is only worth having where it is spent.
|
|
327
|
+
*/
|
|
328
|
+
type _RepoInsertTakesABigIntMinor = Assert<
|
|
329
|
+
[PinWideMoneyRow] extends [Parameters<Repo<PinMoneyRow>['insert']>[0]] ? true : false
|
|
330
|
+
>;
|
|
331
|
+
|
|
332
|
+
/** Every whole-row write, not just the single one — three entry points, one shape. */
|
|
333
|
+
type _RepoBatchWritesTakeABigIntMinor = Assert<
|
|
334
|
+
[readonly PinWideMoneyRow[]] extends [Parameters<Repo<PinMoneyRow>['insertAll']>[0]] &
|
|
335
|
+
[Parameters<Repo<PinMoneyRow>['upsertAll']>[0]]
|
|
336
|
+
? true
|
|
337
|
+
: false
|
|
338
|
+
>;
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* And the half that must NOT widen: what a repository answers with is the row type. `minor` stays
|
|
342
|
+
* a `number` because money crosses every wire this framework projects and `JSON.stringify` refuses
|
|
343
|
+
* a `bigint` — the widening is the caller's spelling, never the row's.
|
|
344
|
+
*/
|
|
345
|
+
type _RepoAnswersTheValueType = Assert<
|
|
346
|
+
[Awaited<ReturnType<Repo<PinMoneyRow>['insert']>>] extends [PinMoneyRow] ? true : false
|
|
347
|
+
>;
|
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
|
/**
|
|
@@ -231,6 +281,23 @@ export type Insertable<C extends ColumnMap> = {
|
|
|
231
281
|
readonly [K in DefaultedKeys<C> | NullableKeys<C>]?: InputOf<TypeOf<C[K]>>;
|
|
232
282
|
};
|
|
233
283
|
|
|
284
|
+
/**
|
|
285
|
+
* A whole row as a WRITER may spell it — the row's own type, or money's wider write shape.
|
|
286
|
+
*
|
|
287
|
+
* `Insertable` says this at the `database()` seam, where the columns are still in hand;
|
|
288
|
+
* `Repo.insert`/`insertAll`/`upsertAll` reach the same rows one layer down with only `Row`, and
|
|
289
|
+
* they took `Row` itself — so the `bigint` minor unit `MoneyInput` documents, `narrowMoney` exists
|
|
290
|
+
* to narrow and both drivers already store correctly was a compile error at the one entry point a
|
|
291
|
+
* caller uses. `postgresRepo()` is exported, so that caller is public API, not an internal detour.
|
|
292
|
+
*
|
|
293
|
+
* `Row[K]` stays in the union rather than being replaced by `InputOf<Row[K]>`, for the reason
|
|
294
|
+
* `RowPatch` below states: a conditional type over an unresolved `Row` never reduces, so `Row`
|
|
295
|
+
* would stop being assignable to its own write shape and every internal caller would redden.
|
|
296
|
+
*/
|
|
297
|
+
export type RowWrite<Row> = {
|
|
298
|
+
readonly [K in keyof Row]: Row[K] | InputOf<Row[K]>;
|
|
299
|
+
};
|
|
300
|
+
|
|
234
301
|
/**
|
|
235
302
|
* A patch or a filter: every property optional, **and every property allowed to be present and
|
|
236
303
|
* `undefined`**.
|