@ultimat3/entity 22.15.0 → 24.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 +72 -51
- package/README.md +145 -21
- package/package.json +6 -5
- package/src/aggregate.ts +31 -22
- package/src/bulk-write.ts +4 -13
- package/src/column.ts +36 -1
- package/src/columns-data.ts +24 -3
- package/src/columns-scalar.ts +13 -6
- package/src/columns.ts +8 -1
- package/src/containment.ts +15 -2
- package/src/database.ts +9 -2
- package/src/entity-error.ts +45 -20
- package/src/entity.ts +81 -18
- package/src/errors.ts +16 -42
- package/src/expr.ts +66 -20
- package/src/index.ts +16 -1
- package/src/instant.ts +84 -5
- package/src/invariants.ts +3 -3
- package/src/judged-write.ts +28 -0
- package/src/like.ts +85 -0
- package/src/memory-match.ts +27 -82
- package/src/memory-repo.ts +16 -2
- package/src/memory-unique.ts +7 -6
- package/src/numeric-compare.ts +57 -0
- package/src/pg-driver.ts +38 -29
- package/src/pg-row.ts +4 -1
- package/src/pg-sql.ts +5 -1
- package/src/plan.ts +9 -3
- package/src/preload-ceiling.ts +75 -0
- package/src/preload.ts +23 -8
- package/src/query.ts +67 -34
- package/src/record-projection.ts +17 -2
- package/src/row-observer.ts +17 -2
- package/src/rows-of.ts +13 -1
- package/src/sealed-column.ts +123 -0
- package/src/sealed-declaration.ts +64 -0
- package/src/sealed-errors.ts +112 -0
- package/src/sealed-repo.ts +235 -0
- package/src/sealed.ts +196 -0
- package/src/seed.ts +97 -73
- package/src/transition.ts +3 -0
- package/src/type-pins.ts +109 -3
- package/src/types.ts +106 -0
- package/src/unique-constraints.ts +44 -0
package/src/bulk-write.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
|
11
11
|
import { EntityError, invariantViolated } from './errors';
|
|
12
12
|
import { columnsOf } from './pg-row';
|
|
13
13
|
import type { RowPatch } from './types';
|
|
14
|
+
import { uniqueConstraints } from './unique-constraints';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Postgres binds at most 65535 parameters in one statement and a multi-row insert spends
|
|
@@ -134,22 +135,12 @@ const crossTenantUpsert = (
|
|
|
134
135
|
});
|
|
135
136
|
|
|
136
137
|
/**
|
|
137
|
-
* Every conflict target Postgres could infer an index for
|
|
138
|
-
*
|
|
139
|
-
* way would send its author to add a SECOND declaration of a constraint they already wrote:
|
|
140
|
-
* the primary key, `unique()`/`indexes: [{ unique: true }]` (both land in `$indexes`), and
|
|
141
|
-
* `invariant(name, c.unique([…]))`, which emits its `create unique index` out of `$invariants`.
|
|
142
|
-
*
|
|
143
|
-
* A partial unique index is excluded from all three: its predicate would have to be repeated in
|
|
144
|
-
* the `on conflict` clause, which this layer does not spell. `bindInvariant` stamps that `where`
|
|
145
|
-
* on a soft-deleting entity, so the same one rule covers both lists.
|
|
138
|
+
* Every conflict target Postgres could infer an index for: the primary key, and every non-partial
|
|
139
|
+
* unique the entity declares by either spelling (`unique-constraints.ts`).
|
|
146
140
|
*/
|
|
147
141
|
const uniqueTargets = <Row>(entity: EntityCore<Row>): readonly (readonly string[])[] => [
|
|
148
142
|
insertColumns(entity, entity.$primaryKey),
|
|
149
|
-
...entity
|
|
150
|
-
...entity.$invariants
|
|
151
|
-
.filter((i) => i.kind === 'unique' && i.where === undefined)
|
|
152
|
-
.map((i) => i.columns),
|
|
143
|
+
...uniqueConstraints(entity).map((unique) => unique.columns),
|
|
153
144
|
];
|
|
154
145
|
|
|
155
146
|
const sameColumns = (left: readonly string[], right: readonly string[]): boolean =>
|
package/src/column.ts
CHANGED
|
@@ -240,8 +240,43 @@ export const makeColumn = <T, Optional extends boolean>(
|
|
|
240
240
|
* the derived name is checked, because that runs once per column at `entity()` rather than on
|
|
241
241
|
* every statement.
|
|
242
242
|
*/
|
|
243
|
+
const isPhysicalName = (name: string): boolean =>
|
|
244
|
+
/^[a-z_][a-z0-9_$]*$/.test(name) && name.length <= 63;
|
|
245
|
+
|
|
246
|
+
/** The nearest legal spelling of a name, so the fix hands back a value and never a placeholder. */
|
|
247
|
+
const physicalSpelling = (name: string): string => {
|
|
248
|
+
const snake = name
|
|
249
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
250
|
+
.toLowerCase()
|
|
251
|
+
.replace(/[^a-z0-9_$]+/g, '_')
|
|
252
|
+
.replace(/^_+|_+$/g, '');
|
|
253
|
+
return (/^[a-z_]/.test(snake) ? snake : `t_${snake}`).slice(0, 63);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/** A name as a TypeScript literal that parses whatever it holds — it is pasted into source. */
|
|
257
|
+
const asSource = (name: string): string =>
|
|
258
|
+
/^[\w-]+$/.test(name) ? `'${name}'` : JSON.stringify(name);
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The TABLE's physical name — `init.table`, or the entity name when none is declared. The same
|
|
262
|
+
* rule as a column's, and its own refusal: it used to go through `assertColumnName`, which called
|
|
263
|
+
* a table "not a physical column name" and handed back `.column('created_at')`, an edit to a
|
|
264
|
+
* column that was never the problem. The repair is at `entity(name, { table })`, and the entity
|
|
265
|
+
* NAME stays what it was — cache tags and policies key on it.
|
|
266
|
+
*/
|
|
267
|
+
export const assertTableName = (entityName: string, table: string): string => {
|
|
268
|
+
if (!isPhysicalName(table)) {
|
|
269
|
+
refuseColumn(
|
|
270
|
+
'table-name',
|
|
271
|
+
`"${table}" is not a physical table name: lower-case letters, digits and underscores, at most 63 of them`,
|
|
272
|
+
`entity(${asSource(entityName)}, { table: '${physicalSpelling(table)}', columns }) # table is the physical name; the entity name is unchanged`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return table;
|
|
276
|
+
};
|
|
277
|
+
|
|
243
278
|
export const assertColumnName = (name: string): string => {
|
|
244
|
-
if (
|
|
279
|
+
if (!isPhysicalName(name)) {
|
|
245
280
|
refuseColumn(
|
|
246
281
|
'column-name',
|
|
247
282
|
`"${name}" is not a physical column name: lower-case letters, digits and underscores, at most 63 of them`,
|
package/src/columns-data.ts
CHANGED
|
@@ -58,6 +58,25 @@ const canonicalDigits = (digits: string): string => {
|
|
|
58
58
|
return negative && whole !== '0' ? `-${whole}` : whole;
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
const INT8_MIN = -(2n ** 63n);
|
|
62
|
+
const INT8_MAX = 2n ** 63n - 1n;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whole digits inside `int8`, in Postgres's spelling. Memory stored a 40-digit value the server
|
|
66
|
+
* answers `22003 — bigint out of range` for, so a row every app test wrote was one production
|
|
67
|
+
* refuses. The digits are never echoed: a column message reaches the log line.
|
|
68
|
+
*/
|
|
69
|
+
const int8 = (digits: string): string => {
|
|
70
|
+
const value = BigInt(digits);
|
|
71
|
+
return value >= INT8_MIN && value <= INT8_MAX
|
|
72
|
+
? canonicalDigits(digits)
|
|
73
|
+
: refuseColumn(
|
|
74
|
+
'bigint',
|
|
75
|
+
'the value is past the int8 range (-2^63 to 2^63 - 1), which Postgres answers 22003 for',
|
|
76
|
+
'declare the column decimal({ precision: 40, scale: 0 }) — a numeric holds whole numbers past int8 — and run x db gen "widen the bigint to numeric"',
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
61
80
|
/**
|
|
62
81
|
* An ACCEPTED decimal in Postgres's spelling: leading zeros stripped, `-0` and `-0.00` unsigned,
|
|
63
82
|
* and a short fraction padded to the column's scale (`numeric(8, 2)` answers `7.50` for `7.5`).
|
|
@@ -86,7 +105,7 @@ const canonicalDecimal = (text: string, scale: number | undefined): string => {
|
|
|
86
105
|
*/
|
|
87
106
|
export const bigint = (): Column<string> =>
|
|
88
107
|
column<string>('bigint', (value) => {
|
|
89
|
-
if (typeof value === 'bigint') return value.toString();
|
|
108
|
+
if (typeof value === 'bigint') return int8(value.toString());
|
|
90
109
|
if (typeof value === 'number') {
|
|
91
110
|
return Number.isSafeInteger(value)
|
|
92
111
|
? String(value)
|
|
@@ -97,7 +116,7 @@ export const bigint = (): Column<string> =>
|
|
|
97
116
|
);
|
|
98
117
|
}
|
|
99
118
|
return typeof value === 'string' && DIGITS.test(value)
|
|
100
|
-
?
|
|
119
|
+
? int8(value)
|
|
101
120
|
: refuseColumn(
|
|
102
121
|
'bigint',
|
|
103
122
|
`expected whole digits, ${got(value)}`,
|
|
@@ -167,7 +186,9 @@ export const decimal = (options: DecimalOptions = {}): Column<string> => {
|
|
|
167
186
|
`Number(value).toFixed(${scale}) at the call site decides the rounding, or widen the column to decimal({ precision: ${(precision ?? fraction) + fraction - scale}, scale: ${fraction} }) and run x db gen "widen the numeric"`,
|
|
168
187
|
);
|
|
169
188
|
}
|
|
170
|
-
|
|
189
|
+
// A whole part of `0` is NO digit: `numeric(2, 2)` holds `0.50`, and counting the zero
|
|
190
|
+
// refused every legal value of a `numeric(p, p)` column.
|
|
191
|
+
const whole = (digits[0] ?? '').replace(/^0+/, '').length;
|
|
171
192
|
if (precision !== undefined && whole > precision - (scale ?? 0)) {
|
|
172
193
|
return refuseColumn(
|
|
173
194
|
'numeric',
|
package/src/columns-scalar.ts
CHANGED
|
@@ -3,17 +3,18 @@
|
|
|
3
3
|
// store a value production answers 23514, 22003 or 22021 (a NUL in text) for. Split from `columns.ts` at its ceiling.
|
|
4
4
|
|
|
5
5
|
import { charCount } from '@ultimat3/schema';
|
|
6
|
-
import { column } from './column';
|
|
6
|
+
import { BARE, column } from './column';
|
|
7
7
|
import { got, refuseNul } from './column-values';
|
|
8
8
|
import { refuseColumn } from './refuse';
|
|
9
|
-
import
|
|
9
|
+
import { makeTextColumn } from './sealed-column';
|
|
10
|
+
import type { Column, TextColumn } from './types';
|
|
10
11
|
|
|
11
12
|
export interface TextOptions {
|
|
12
13
|
/** Emits `char_length(<column>) <= max`, so Postgres refuses an over-long string too. */
|
|
13
14
|
readonly max?: number;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export const text = (options: TextOptions = {}):
|
|
17
|
+
export const text = (options: TextOptions = {}): TextColumn => {
|
|
17
18
|
const { max } = options;
|
|
18
19
|
// Refused where it is declared: a `NaN` or fractional max emitted `char_length(x) <= NaN` into
|
|
19
20
|
// the DDL, which Postgres refuses one migration later, far from the line that wrote it.
|
|
@@ -24,8 +25,14 @@ export const text = (options: TextOptions = {}): Column<string> => {
|
|
|
24
25
|
'text({ max: 200 }) — a whole count of characters, or text() for no bound',
|
|
25
26
|
);
|
|
26
27
|
}
|
|
27
|
-
return
|
|
28
|
-
|
|
28
|
+
return makeTextColumn<string, false>(
|
|
29
|
+
{
|
|
30
|
+
...BARE,
|
|
31
|
+
kind: 'text',
|
|
32
|
+
...(max === undefined
|
|
33
|
+
? {}
|
|
34
|
+
: { length: max, check: (name) => `char_length(${name}) <= ${max}` }),
|
|
35
|
+
},
|
|
29
36
|
(value) => {
|
|
30
37
|
if (typeof value !== 'string') {
|
|
31
38
|
return refuseColumn(
|
|
@@ -45,7 +52,7 @@ export const text = (options: TextOptions = {}): Column<string> => {
|
|
|
45
52
|
}
|
|
46
53
|
return refuseNul(value);
|
|
47
54
|
},
|
|
48
|
-
|
|
55
|
+
false,
|
|
49
56
|
);
|
|
50
57
|
};
|
|
51
58
|
|
package/src/columns.ts
CHANGED
|
@@ -109,6 +109,9 @@ const parseInstant = (value: unknown): Date => {
|
|
|
109
109
|
export const timestamp = (): TimestampColumn =>
|
|
110
110
|
makeTimestamp<false>({ ...BARE, kind: 'timestamptz' }, parseInstant, false);
|
|
111
111
|
|
|
112
|
+
/** The column's CHECK (`~ '^https?://'`), before the scheme is lower-cased. */
|
|
113
|
+
const STORED_HTTP = /^https?:\/\//i;
|
|
114
|
+
|
|
112
115
|
/**
|
|
113
116
|
* An absolute http(s) URL, validated on write rather than on render: a bad URL stored once is
|
|
114
117
|
* served to every reader, and `<img src>` fails silently in the browser.
|
|
@@ -126,7 +129,11 @@ export const url = (): Column<string> =>
|
|
|
126
129
|
// The scheme is stored in its canonical LOWER case: the CHECK is `~ '^https?://'`, so
|
|
127
130
|
// `HTTPS://a.b` was stored by memory and refused by Postgres. Only the scheme is
|
|
128
131
|
// rewritten — the rest of the URL is the caller's, byte for byte.
|
|
129
|
-
|
|
132
|
+
// And the STORED text must itself open with it: WHATWG strips leading whitespace and
|
|
133
|
+
// embedded tabs and mends `https:/a.b` before `protocol` is read, while the column keeps
|
|
134
|
+
// the caller's bytes — which the CHECK then refused as a raw 23514.
|
|
135
|
+
const http = parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
136
|
+
if (http && STORED_HTTP.test(value)) {
|
|
130
137
|
return value.replace(/^https?(?=:)/i, (scheme) => scheme.toLowerCase());
|
|
131
138
|
}
|
|
132
139
|
} catch {
|
package/src/containment.ts
CHANGED
|
@@ -28,6 +28,15 @@ const sameElement = (left: unknown, right: unknown): boolean => {
|
|
|
28
28
|
return left === right;
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Two elements of a SQL ARRAY, equal — and a NULL element is equal to nothing, itself included.
|
|
33
|
+
* `array['a', null] @> array[null]` and `array[null] && array[null]` are both FALSE in Postgres:
|
|
34
|
+
* the element comparison is `=`, and `NULL = NULL` is unknown. Arrays only — a `jsonb` `null` is a
|
|
35
|
+
* VALUE and equals itself, which is why `jsonContains` keeps `sameElement`.
|
|
36
|
+
*/
|
|
37
|
+
const sameArrayElement = (left: unknown, right: unknown): boolean =>
|
|
38
|
+
!isNull(left) && !isNull(right) && sameElement(left, right);
|
|
39
|
+
|
|
31
40
|
/**
|
|
32
41
|
* `left @> right` for `jsonb`, to the letter of Postgres' definition — each clause below measured
|
|
33
42
|
* on Postgres 16 rather than read off a summary, because three of them are easy to state wrongly:
|
|
@@ -67,7 +76,7 @@ const contains = (left: unknown, right: unknown, top: boolean): boolean => {
|
|
|
67
76
|
* by every array, which is what Postgres answers.
|
|
68
77
|
*/
|
|
69
78
|
export const arrayContains = (left: readonly unknown[], right: readonly unknown[]): boolean =>
|
|
70
|
-
right.every((item) => left.some((candidate) =>
|
|
79
|
+
right.every((item) => left.some((candidate) => sameArrayElement(candidate, item)));
|
|
71
80
|
|
|
72
81
|
/**
|
|
73
82
|
* `left && right`: they share at least one element. Arrays only — `jsonb` has no `&&` — and the
|
|
@@ -75,7 +84,7 @@ export const arrayContains = (left: readonly unknown[], right: readonly unknown[
|
|
|
75
84
|
* contained by everything.
|
|
76
85
|
*/
|
|
77
86
|
export const arrayOverlaps = (left: readonly unknown[], right: readonly unknown[]): boolean =>
|
|
78
|
-
right.some((item) => left.some((candidate) =>
|
|
87
|
+
right.some((item) => left.some((candidate) => sameArrayElement(candidate, item)));
|
|
79
88
|
|
|
80
89
|
/**
|
|
81
90
|
* The memory half of `col ? key`. The SQL side emits the OPERATOR, schema-qualified —
|
|
@@ -88,6 +97,10 @@ export const arrayOverlaps = (left: readonly unknown[], right: readonly unknown[
|
|
|
88
97
|
* Three shapes, all of them Postgres': a top-level key of an object, a string ELEMENT of an array,
|
|
89
98
|
* and a string value equal to the key. A number never matches — `'[1]'::jsonb ? '1'` is false
|
|
90
99
|
* there, and a `String(item) === key` here would have made it true.
|
|
100
|
+
*
|
|
101
|
+
* The KEY is a string or the test matches no row, in both drivers: `pg-sql.ts` compiles any other
|
|
102
|
+
* operand to no rows rather than binding `String(key)`, which found a key named `"null"` for a
|
|
103
|
+
* `null` operand and `"1"` for the number `1`.
|
|
91
104
|
*/
|
|
92
105
|
export const jsonHasKey = (value: unknown, key: unknown): boolean => {
|
|
93
106
|
if (typeof key !== 'string' || isNull(value)) return false;
|
package/src/database.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// writes a repository class per entity, and nobody can reach a table that is not in the set.
|
|
3
3
|
|
|
4
4
|
import type { EntityCore } from './entity';
|
|
5
|
-
import { memoryRepo } from './memory-repo';
|
|
5
|
+
import { memoryRepo, memoryTransactor } from './memory-repo';
|
|
6
6
|
import type { RelatedTables } from './preload';
|
|
7
7
|
import type { Table } from './query';
|
|
8
8
|
import { tableFor } from './query';
|
|
9
|
-
import type { Repo } from './repo';
|
|
9
|
+
import type { Repo, Transactor } from './repo';
|
|
10
10
|
import { observedRepo } from './row-observer';
|
|
11
11
|
|
|
12
12
|
export type EntitySet = Readonly<Record<string, EntityCore>>;
|
|
@@ -25,6 +25,12 @@ export interface Driver {
|
|
|
25
25
|
* `reset()` eventually would. A harness therefore asks and does not assume: `driver.reset?.()`.
|
|
26
26
|
*/
|
|
27
27
|
reset?(): void;
|
|
28
|
+
/**
|
|
29
|
+
* The transactor that undoes THIS driver's writes. Required: a seed dry run writes for real and
|
|
30
|
+
* rolls back through it, and a driver that could not undo would be found out at run time. A
|
|
31
|
+
* driver that wraps another delegates — `transactor: () => inner.transactor()`.
|
|
32
|
+
*/
|
|
33
|
+
transactor(): Transactor;
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
export interface DatabaseOptions {
|
|
@@ -54,6 +60,7 @@ export const memoryDriver = (): Driver => {
|
|
|
54
60
|
reset() {
|
|
55
61
|
for (const reset of resets) reset();
|
|
56
62
|
},
|
|
63
|
+
transactor: () => memoryTransactor(),
|
|
57
64
|
};
|
|
58
65
|
};
|
|
59
66
|
|
package/src/entity-error.ts
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
// The entity layer's code registry, its error class, and the two refusals the declaration path
|
|
2
2
|
// raises — split from `errors.ts` so a module the BROWSER loads (the record key, the projection,
|
|
3
|
-
// the registry) can raise one without importing `@ultimat3/db
|
|
4
|
-
|
|
5
|
-
import {
|
|
3
|
+
// the registry) can raise one without importing `@ultimat3/db`.
|
|
4
|
+
import type { ErrorRetry } from '@ultimat3/core';
|
|
5
|
+
import {
|
|
6
|
+
isFixShellSafe,
|
|
7
|
+
registerErrorCodes,
|
|
8
|
+
registerErrorRetry,
|
|
9
|
+
UltimateError,
|
|
10
|
+
} from '@ultimat3/core';
|
|
6
11
|
|
|
7
|
-
/**
|
|
8
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Every code this package throws, and it owns each one. `X_DB_DRIFT` is `@ultimat3/db`'s and is
|
|
14
|
+
* raised there — drift is a fact about migrations.
|
|
15
|
+
*/
|
|
16
|
+
export const ENTITY_ERROR_CODES = [
|
|
9
17
|
'X_ENTITY_DUPLICATE',
|
|
10
18
|
'X_INVARIANT_VIOLATED',
|
|
11
19
|
'X_TENANCY_UNSCOPED',
|
|
@@ -28,25 +36,13 @@ export const ENTITY_OWNED_ERROR_CODES = [
|
|
|
28
36
|
'X_STATE_TRANSITION_ILLEGAL',
|
|
29
37
|
'X_STATE_CONFLICT',
|
|
30
38
|
'X_RECORD_KEY_MISSING',
|
|
39
|
+
'X_ENTITY_SEALED_PREDICATE',
|
|
40
|
+
'X_ENTITY_SEALED_IN_VIEW',
|
|
31
41
|
] as const;
|
|
32
42
|
|
|
33
|
-
/**
|
|
34
|
-
* `X_DB_DRIFT` is `@ultimat3/db`'s — drift is a fact about migrations, and this package imports db
|
|
35
|
-
* rather than the other way round. `dbDrift()` below throws it; nothing here titles it, because a
|
|
36
|
-
* second copy of the title is what lets the two packages disagree about what the code means.
|
|
37
|
-
*/
|
|
38
|
-
export const ENTITY_BORROWED_ERROR_CODES = ['X_DB_DRIFT'] as const;
|
|
39
|
-
|
|
40
|
-
/** Every code entity can throw: the ones it owns plus the one it borrows. */
|
|
41
|
-
export const ENTITY_ERROR_CODES = [
|
|
42
|
-
...ENTITY_OWNED_ERROR_CODES,
|
|
43
|
-
...ENTITY_BORROWED_ERROR_CODES,
|
|
44
|
-
] as const;
|
|
45
|
-
|
|
46
|
-
export type EntityOwnedErrorCode = (typeof ENTITY_OWNED_ERROR_CODES)[number];
|
|
47
43
|
export type EntityErrorCode = (typeof ENTITY_ERROR_CODES)[number];
|
|
48
44
|
|
|
49
|
-
export const ENTITY_ERROR_TITLES: Readonly<Record<
|
|
45
|
+
export const ENTITY_ERROR_TITLES: Readonly<Record<EntityErrorCode, string>> = {
|
|
50
46
|
X_ENTITY_DUPLICATE: 'two entities claim the same name',
|
|
51
47
|
X_INVARIANT_VIOLATED: 'a domain invariant rejected this row',
|
|
52
48
|
X_TENANCY_UNSCOPED: 'a tenant-scoped query has no org predicate',
|
|
@@ -71,6 +67,9 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityOwnedErrorCode, string>>
|
|
|
71
67
|
X_STATE_TRANSITION_ILLEGAL: 'the machine has no such transition',
|
|
72
68
|
X_STATE_CONFLICT: 'the row is no longer in the state this transition named',
|
|
73
69
|
X_RECORD_KEY_MISSING: 'a row reached its record key without a primary-key value',
|
|
70
|
+
X_ENTITY_SEALED_PREDICATE:
|
|
71
|
+
'a sealed column is read by a predicate, an order or a rule the database evaluates',
|
|
72
|
+
X_ENTITY_SEALED_IN_VIEW: 'a sealed column is named in a view',
|
|
74
73
|
};
|
|
75
74
|
|
|
76
75
|
// Registered at module load, unconditionally, in one call. Without this the registry humanises the
|
|
@@ -80,6 +79,20 @@ registerErrorCodes(
|
|
|
80
79
|
Object.fromEntries(Object.entries(ENTITY_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
81
80
|
);
|
|
82
81
|
|
|
82
|
+
/**
|
|
83
|
+
* How the codes here that are worth classifying are retried. Only the two sealed-column refusals:
|
|
84
|
+
* both are the app's own declaration or call, so attempt five answers what attempt one did — and
|
|
85
|
+
* left unclassified, `classifyThrown` reads them as undecided and a job spends its whole retry
|
|
86
|
+
* policy re-proving it. Listed although `terminal` is the default, for the reason core's own table
|
|
87
|
+
* gives for `X_NOT_IMPLEMENTED`. The rest of this package's codes are unclassified, as they were.
|
|
88
|
+
*/
|
|
89
|
+
export const ENTITY_ERROR_RETRY = {
|
|
90
|
+
X_ENTITY_SEALED_PREDICATE: 'terminal',
|
|
91
|
+
X_ENTITY_SEALED_IN_VIEW: 'terminal',
|
|
92
|
+
} as const satisfies Readonly<Partial<Record<EntityErrorCode, ErrorRetry>>>;
|
|
93
|
+
|
|
94
|
+
registerErrorRetry(ENTITY_ERROR_RETRY);
|
|
95
|
+
|
|
83
96
|
/**
|
|
84
97
|
* Base for every error this package throws. No `docs:` — `UltimateError` fills it from
|
|
85
98
|
* `describeErrorCode(code).docs`, which is `@ultimat3/core`'s `ERROR_DOCS_URL`: one page for every
|
|
@@ -100,6 +113,18 @@ export class EntityError extends UltimateError {
|
|
|
100
113
|
}
|
|
101
114
|
}
|
|
102
115
|
|
|
116
|
+
/**
|
|
117
|
+
* The one command a BOUND refusal hands back — a page size, a preload ceiling, a judged write: it
|
|
118
|
+
* runs, and it names the real entity. The edit itself is code on a chain whose variable no error
|
|
119
|
+
* can know (`db.members`, not `preload_test_members`), so it is spelled in the `cause` as the call
|
|
120
|
+
* to write, never in the `fix` as though the entity name were that variable. A name a shell would
|
|
121
|
+
* read is not spliced.
|
|
122
|
+
*/
|
|
123
|
+
export const describeCommand = (entityName: string): string =>
|
|
124
|
+
isFixShellSafe(entityName)
|
|
125
|
+
? `x entities describe ${entityName} --json`
|
|
126
|
+
: 'x entities list --json';
|
|
127
|
+
|
|
103
128
|
/**
|
|
104
129
|
* The entity name is a VALUE, never a literal — `entity.$name`, `table`, the `name` `entity()` was
|
|
105
130
|
* given. A literal is an entity that does not exist, and this fix then hands the reader
|
package/src/entity.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { IndexMethod } from '@ultimat3/db';
|
|
7
7
|
import { describeValue, type Schema } from '@ultimat3/schema';
|
|
8
8
|
import { entityNow } from './clock';
|
|
9
|
-
import {
|
|
9
|
+
import { assertTableName, bindColumn, columnName, moneyColumns } from './column';
|
|
10
10
|
import { newId } from './columns';
|
|
11
11
|
import { describeEntity, describeReferences } from './describe';
|
|
12
12
|
import { invariantViolated } from './errors';
|
|
@@ -19,10 +19,13 @@ import { recordProjection } from './record-projection';
|
|
|
19
19
|
import type { EntityDescription, ReferenceDescription } from './registry';
|
|
20
20
|
import { registerEntity } from './registry';
|
|
21
21
|
import { rowSchema } from './row-schema';
|
|
22
|
+
import { sealedFields } from './sealed';
|
|
23
|
+
import { assertSealedDeclaration } from './sealed-declaration';
|
|
24
|
+
import { sealedInView, sealedMissing } from './sealed-errors';
|
|
22
25
|
import type { SearchInit, SearchSource, SearchVector } from './search';
|
|
23
26
|
import { searchVectorOf } from './search';
|
|
24
27
|
import { resolveTenantColumn } from './tenancy';
|
|
25
|
-
import type { AnyColumn, ColumnMap, ColumnMeta, IndexDef, RowOf } from './types';
|
|
28
|
+
import type { AnyColumn, ColumnMap, ColumnMeta, IndexDef, RowOf, WireRow } from './types';
|
|
26
29
|
import type { EntityView } from './view';
|
|
27
30
|
import { viewFor } from './view';
|
|
28
31
|
|
|
@@ -123,8 +126,13 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
|
|
|
123
126
|
* (bare or wrapped: `t.array(posts.$schema)`) is a row the client store adopts, because its node
|
|
124
127
|
* carries the `recordProjection` brand. A `$view` or a `.pick()` carries none: a partial row is
|
|
125
128
|
* never a record.
|
|
129
|
+
*
|
|
130
|
+
* Typed `unknown` HERE and precisely on `Entity`: the parsed value is the row WITHOUT its sealed
|
|
131
|
+
* columns (`WireRow`), which is not `Row`, and a conditional over `C` in this interface would
|
|
132
|
+
* stop every `EntityCore<Row, C>` being an `EntityCore<Row>`. A row-agnostic consumer never read
|
|
133
|
+
* the output type; the one that does holds the `entity()` result.
|
|
126
134
|
*/
|
|
127
|
-
readonly $schema: Schema<unknown,
|
|
135
|
+
readonly $schema: Schema<unknown, unknown>;
|
|
128
136
|
/** `entity:<name>:<id>` — row-level invalidation for live queries. */
|
|
129
137
|
$tagFor(id: string): string;
|
|
130
138
|
/** Fills declared defaults, then validates every column. Throws on a bad value. */
|
|
@@ -149,7 +157,14 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
|
|
|
149
157
|
$references(): readonly ReferenceDescription[];
|
|
150
158
|
}
|
|
151
159
|
|
|
152
|
-
|
|
160
|
+
/**
|
|
161
|
+
* What `entity()` returns. `$schema` is restated with its real output: the row as it leaves the
|
|
162
|
+
* server, without the sealed columns — so `output: users.$schema` types a client that cannot read
|
|
163
|
+
* `password`, which is the value it will not be sent. `$parse` and `$row` are the whole row.
|
|
164
|
+
*/
|
|
165
|
+
export type Entity<Row, C extends ColumnMap = ColumnMap> = Omit<EntityCore<Row, C>, '$schema'> & {
|
|
166
|
+
readonly $schema: Schema<unknown, WireRow<Row, C>>;
|
|
167
|
+
} & C;
|
|
153
168
|
|
|
154
169
|
const MONEY_PARTS = new Set(['minor', 'currency']);
|
|
155
170
|
|
|
@@ -171,7 +186,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
171
186
|
// Both branches. The declared table was checked and the fallback — which is every entity that
|
|
172
187
|
// does not rename its table — was not, so an entity NAME closed the identifier in the same way a
|
|
173
188
|
// column name could: `entity('t" (x int); drop table u; --')` emitted that `drop table` verbatim.
|
|
174
|
-
const table =
|
|
189
|
+
const table = assertTableName(name, init.table ?? name);
|
|
175
190
|
const cacheTag = `entity:${name}`;
|
|
176
191
|
const softDelete = Object.hasOwn(init.columns, SOFT_DELETE_COLUMN);
|
|
177
192
|
const tenantColumn = resolveTenantColumn(name, init.columns, init.tenant);
|
|
@@ -227,10 +242,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
227
242
|
return part === 'minor' ? parts.minor : parts.currency;
|
|
228
243
|
};
|
|
229
244
|
|
|
230
|
-
const columnsExpr = invariantColumns<C>(
|
|
231
|
-
name,
|
|
232
|
-
entries.map(([property]) => property),
|
|
233
|
-
);
|
|
245
|
+
const columnsExpr = invariantColumns<C>(name, init.columns);
|
|
234
246
|
// Through the one resolver, so a soft-delete column the table spells differently is still the
|
|
235
247
|
// column every partial index excludes rows by.
|
|
236
248
|
const partialWhere = softDelete ? `${resolve([SOFT_DELETE_COLUMN])} is null` : undefined;
|
|
@@ -341,6 +353,19 @@ export const entity = <const C extends ColumnMap>(
|
|
|
341
353
|
declared.findIndex((other) => identity(other) === identity(index)) === position,
|
|
342
354
|
);
|
|
343
355
|
|
|
356
|
+
const sealed = sealedFields({ $name: name, $table: table, $columns: init.columns });
|
|
357
|
+
assertSealedDeclaration({
|
|
358
|
+
name,
|
|
359
|
+
fields: sealed,
|
|
360
|
+
primaryKey,
|
|
361
|
+
tenantColumn,
|
|
362
|
+
indexes,
|
|
363
|
+
invariants,
|
|
364
|
+
});
|
|
365
|
+
const sealedKeys = new Set(sealed.map((field) => field.property));
|
|
366
|
+
/** The columns that leave the server: every one that is not sealed. */
|
|
367
|
+
const wireEntries = entries.filter(([property]) => !sealedKeys.has(property));
|
|
368
|
+
|
|
344
369
|
const tags = [cacheTag, ...(init.tags ?? [])];
|
|
345
370
|
const describe = (): EntityDescription =>
|
|
346
371
|
describeEntity({
|
|
@@ -377,7 +402,11 @@ export const entity = <const C extends ColumnMap>(
|
|
|
377
402
|
const given = raw === undefined ? defaultValue(column.$meta) : raw;
|
|
378
403
|
if (given === undefined || given === null) {
|
|
379
404
|
if (column.$meta.notNull) {
|
|
380
|
-
|
|
405
|
+
// A required sealed column ABSENT from a row is nearly always a spread of a repository
|
|
406
|
+
// row, which does not enumerate it — so the refusal names that, and the rewrite.
|
|
407
|
+
throw given === undefined && sealedKeys.has(property)
|
|
408
|
+
? sealedMissing(name, property)
|
|
409
|
+
: invariantViolated(name, property, 'is required and has no default');
|
|
381
410
|
}
|
|
382
411
|
row[property] = null;
|
|
383
412
|
continue;
|
|
@@ -388,6 +417,39 @@ export const entity = <const C extends ColumnMap>(
|
|
|
388
417
|
return row as Row;
|
|
389
418
|
};
|
|
390
419
|
|
|
420
|
+
/**
|
|
421
|
+
* The row as `$schema` validates it: the sealed columns are neither required nor returned, so a
|
|
422
|
+
* handler's row — which holds their plaintext — parses to a value that does not.
|
|
423
|
+
*/
|
|
424
|
+
const parseWire = (value: unknown): WireRow<Row, C> => {
|
|
425
|
+
if (sealedKeys.size === 0) return parse(value) as WireRow<Row, C>;
|
|
426
|
+
if (typeof value !== 'object' || value === null) {
|
|
427
|
+
throw invariantViolated(name, 'row', `expected an object, got ${describeValue(value)}`);
|
|
428
|
+
}
|
|
429
|
+
const input = value as Readonly<Record<string, unknown>>;
|
|
430
|
+
const row: Record<string, unknown> = {};
|
|
431
|
+
for (const [property, column] of wireEntries) {
|
|
432
|
+
const raw = input[property];
|
|
433
|
+
const given = raw === undefined ? defaultValue(column.$meta) : raw;
|
|
434
|
+
if (given === undefined || given === null) {
|
|
435
|
+
if (column.$meta.notNull) {
|
|
436
|
+
throw invariantViolated(name, property, 'is required and has no default');
|
|
437
|
+
}
|
|
438
|
+
row[property] = null;
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
row[property] = column.$parse(given);
|
|
442
|
+
}
|
|
443
|
+
// Every wire property was validated by its own column, and no sealed one was copied.
|
|
444
|
+
return row as WireRow<Row, C>;
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const schema = rowSchema<WireRow<Row, C>>(
|
|
448
|
+
{ name, table, primaryKey, persist: init.persist === true, sealed: [...sealedKeys] },
|
|
449
|
+
wireEntries,
|
|
450
|
+
parseWire,
|
|
451
|
+
);
|
|
452
|
+
|
|
391
453
|
const core: EntityCore<Row, C> = {
|
|
392
454
|
$name: name,
|
|
393
455
|
$table: table,
|
|
@@ -400,19 +462,19 @@ export const entity = <const C extends ColumnMap>(
|
|
|
400
462
|
$softDelete: softDelete,
|
|
401
463
|
$tenantColumn: tenantColumn,
|
|
402
464
|
$search: search,
|
|
403
|
-
$schema:
|
|
404
|
-
{ name, table, primaryKey, persist: init.persist === true },
|
|
405
|
-
entries,
|
|
406
|
-
parse,
|
|
407
|
-
),
|
|
465
|
+
$schema: schema,
|
|
408
466
|
get $row(): Row {
|
|
409
467
|
// Type-only. Reading it means someone expected a value where a type was meant.
|
|
410
468
|
throw invariantViolated(name, '$row', '$row is a type, not a value — use typeof x.$row');
|
|
411
469
|
},
|
|
412
470
|
$tagFor: (id) => `${cacheTag}:${id}`,
|
|
413
471
|
$parse: parse,
|
|
414
|
-
$view: <K extends keyof Row & string>(keys: readonly K[]) =>
|
|
415
|
-
|
|
472
|
+
$view: <K extends keyof Row & string>(keys: readonly K[]) => {
|
|
473
|
+
// Before the view exists: a view is an `output`, and a sealed column is in none.
|
|
474
|
+
const named = keys.find((key) => sealedKeys.has(key));
|
|
475
|
+
if (named !== undefined) throw sealedInView(name, named);
|
|
476
|
+
return viewFor<Row, K>(name, init.columns, keys);
|
|
477
|
+
},
|
|
416
478
|
$assert: (row) => assertInvariants(name, invariants, row),
|
|
417
479
|
$describe: describe,
|
|
418
480
|
$references: references,
|
|
@@ -429,5 +491,6 @@ export const entity = <const C extends ColumnMap>(
|
|
|
429
491
|
});
|
|
430
492
|
// The columns land on the entity itself so `orgs.id` is a column reference; every framework
|
|
431
493
|
// member is `$`-prefixed, which is why a column may be called `name`.
|
|
432
|
-
|
|
494
|
+
// `$schema` is restated so the result carries its real output type; it is the same object.
|
|
495
|
+
return Object.assign(core, init.columns, { $schema: schema });
|
|
433
496
|
};
|
package/src/errors.ts
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
// The entity layer's stable error codes. Each factory produces the exact command
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
import { shellInertIdentifier } from '@ultimat3/db';
|
|
1
|
+
// The entity layer's stable error codes. Each factory produces the exact command that fixes the
|
|
2
|
+
// situation. The code registry, the class, `invariantViolated` and `entityDuplicate` live in
|
|
3
|
+
// `entity-error.ts`, which imports no `@ultimat3/db`; re-exported here. `X_DB_DRIFT` is not
|
|
4
|
+
// raised here at all: drift is `@ultimat3/db`'s, factory included.
|
|
6
5
|
import { EntityError } from './entity-error';
|
|
7
6
|
|
|
8
|
-
export type { EntityErrorCode
|
|
7
|
+
export type { EntityErrorCode } from './entity-error';
|
|
9
8
|
export {
|
|
10
|
-
|
|
9
|
+
describeCommand,
|
|
11
10
|
ENTITY_ERROR_CODES,
|
|
12
11
|
ENTITY_ERROR_TITLES,
|
|
13
|
-
ENTITY_OWNED_ERROR_CODES,
|
|
14
12
|
EntityError,
|
|
15
13
|
entityDuplicate,
|
|
16
14
|
invariantViolated,
|
|
@@ -165,20 +163,18 @@ export const crossTenantDenied = (init: {
|
|
|
165
163
|
});
|
|
166
164
|
|
|
167
165
|
/**
|
|
168
|
-
* A repository built with an explicit `client`, used
|
|
166
|
+
* A repository built with an explicit `client`, used inside a transaction that was opened on some
|
|
167
|
+
* OTHER client.
|
|
169
168
|
*
|
|
170
|
-
* Refused rather than resolved
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* worse half of the same guess: a `DbTx` does not name the client it was opened on, so "is this
|
|
177
|
-
* even the same database" is not a question this layer can ask, and on a sharded app the answer
|
|
178
|
-
* is no.
|
|
169
|
+
* Refused rather than resolved. `withTransaction` reserves one connection and runs `BEGIN` on it,
|
|
170
|
+
* while a pinned repository sends to its own client — another connection, and on a sharded app
|
|
171
|
+
* another database — so the write would commit immediately and survive the rollback, and the read
|
|
172
|
+
* would miss rows the transaction has written; both silent. A transaction opened ON the pinned
|
|
173
|
+
* client (`tx.origin`) is the same database and the same reservation, and the repository joins it:
|
|
174
|
+
* that case never reaches here.
|
|
179
175
|
*
|
|
180
|
-
* The fix names the ambient seam because that is the
|
|
181
|
-
* through: `db()` resolves `currentTx()` first, which is
|
|
176
|
+
* The fix names the ambient seam because that is the path a repository joins the AMBIENT
|
|
177
|
+
* transaction through: `db()` resolves `currentTx()` first, which is what a pinned client skips.
|
|
182
178
|
*/
|
|
183
179
|
export const repoClientPinned = (entityName: string): EntityError =>
|
|
184
180
|
new EntityError({
|
|
@@ -187,28 +183,6 @@ export const repoClientPinned = (entityName: string): EntityError =>
|
|
|
187
183
|
fix: `setDbClient(client) at boot and build the repository with no client: — postgresDriver() then resolves the open transaction through db() — or run this call outside withTransaction()`,
|
|
188
184
|
});
|
|
189
185
|
|
|
190
|
-
/**
|
|
191
|
-
* The contract's pinned wording. Mirror of `@ultimat3/db`'s `dbDrift()` (`drift-errors.ts`) —
|
|
192
|
-
* keep in sync; both screen through the same `shellInertIdentifier`, so the two lines are one
|
|
193
|
-
* text on both sides of the tier seam, and `errors.test.ts` asserts it rather than asking.
|
|
194
|
-
*
|
|
195
|
-
* The column is the CATALOG's, so it is data, and `x db gen "add C"` puts it inside SHELL DOUBLE
|
|
196
|
-
* QUOTES where `$(…)` and a backtick substitute before `x` is reached at all. The argument is a
|
|
197
|
-
* migration DESCRIPTION, not an identifier, so no quoted form makes a hostile name safe to pass —
|
|
198
|
-
* a refused one is left OUT of the command rather than escaped into it, and read off `cause`.
|
|
199
|
-
*/
|
|
200
|
-
export const dbDrift = (tableName: string, columnName: string): EntityError =>
|
|
201
|
-
new EntityError({
|
|
202
|
-
code: 'X_DB_DRIFT',
|
|
203
|
-
cause: `table "${tableName}" has column "${columnName}" not present in any migration`,
|
|
204
|
-
fix:
|
|
205
|
-
shellInertIdentifier(columnName) === null
|
|
206
|
-
? 'x db gen "add the column named in this error" # its name carries a backtick, a ' +
|
|
207
|
-
'dollar sign, a quote, a backslash or whitespace, so it is in the cause and not in ' +
|
|
208
|
-
'this command'
|
|
209
|
-
: `x db gen "add ${columnName}"`,
|
|
210
|
-
});
|
|
211
|
-
|
|
212
186
|
export const notFound = (entityName: string, id: string): EntityError =>
|
|
213
187
|
new EntityError({
|
|
214
188
|
code: 'X_NOT_FOUND',
|