@ultimat3/entity 23.0.0 → 25.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 +49 -31
- package/README.md +76 -20
- package/package.json +6 -6
- package/src/aggregate.ts +31 -22
- package/src/append-only-errors.ts +53 -0
- package/src/append-only.ts +82 -0
- package/src/batch.ts +1 -1
- package/src/bulk-write.ts +4 -13
- package/src/column.ts +36 -1
- package/src/columns-data.ts +24 -3
- package/src/columns.ts +9 -2
- package/src/containment.ts +15 -2
- package/src/database.ts +9 -2
- package/src/describe.ts +5 -0
- package/src/entity-error.ts +31 -23
- package/src/entity.ts +18 -30
- package/src/errors.ts +18 -44
- package/src/expr.ts +66 -20
- package/src/feature-errors.ts +3 -1
- package/src/index-init.ts +29 -0
- package/src/index.ts +12 -3
- package/src/invariants.ts +3 -3
- package/src/judged-write.ts +28 -0
- package/src/like.ts +85 -0
- package/src/memory-match.ts +20 -82
- package/src/memory-repo.ts +14 -9
- package/src/memory-unique.ts +7 -6
- package/src/numeric-compare.ts +57 -0
- package/src/pg-driver.ts +53 -45
- package/src/pg-sql.ts +5 -1
- package/src/plan.ts +9 -3
- package/src/preload-ceiling.ts +75 -0
- package/src/preload.ts +20 -7
- package/src/query.ts +19 -17
- package/src/registry.ts +2 -0
- package/src/repo.ts +5 -6
- package/src/row-observer.ts +2 -2
- package/src/sealed-column.ts +7 -3
- package/src/sealed-repo.ts +2 -1
- package/src/sealed.ts +6 -4
- package/src/seed.ts +113 -74
- package/src/transition.ts +3 -0
- package/src/type-pins.ts +25 -0
- package/src/types.ts +6 -0
- package/src/unique-constraints.ts +44 -0
package/src/batch.ts
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
// exactly what they mean in `page()`. What this file owns is the loop, the refusals that belong on
|
|
7
7
|
// the chain rather than one batch later, and what closing means.
|
|
8
8
|
|
|
9
|
+
import type { Page } from '@ultimat3/core';
|
|
9
10
|
import { assertSeekable } from './cursor';
|
|
10
11
|
import type { EntityCore } from './entity';
|
|
11
12
|
import { EntityError } from './errors';
|
|
12
13
|
import { MAX_PAGE_SIZE, totalOrder } from './plan';
|
|
13
|
-
import type { Page } from './repo';
|
|
14
14
|
import type { SortKey } from './tenancy';
|
|
15
15
|
|
|
16
16
|
/**
|
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.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// locale and a time zone — the alternatives (float money, naive timestamps, a single implied
|
|
3
3
|
// currency) are the bugs this file exists to make unreachable.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { uuidV7 } from '@ultimat3/core';
|
|
6
6
|
import {
|
|
7
7
|
CURRENCY_CODE_PATTERN,
|
|
8
8
|
isCurrencyCode,
|
|
@@ -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/describe.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface DescribeInput<Row> {
|
|
|
26
26
|
readonly tenantColumn: string | null;
|
|
27
27
|
/** The generated `tsvector`, when any column is `.searchable()`. */
|
|
28
28
|
readonly search?: SearchVector | null;
|
|
29
|
+
/** `entity({ appendOnly: true })` — projected only when true. */
|
|
30
|
+
readonly appendOnly?: boolean;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/**
|
|
@@ -234,5 +236,8 @@ export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescriptio
|
|
|
234
236
|
cacheTag: input.cacheTag,
|
|
235
237
|
softDelete: input.softDelete,
|
|
236
238
|
orgScoped: input.tenantColumn !== null,
|
|
239
|
+
// `true` or absent, never `false`: `x verify`'s source drift hashes this projection as JSON,
|
|
240
|
+
// and an entity that never said `appendOnly` must hash exactly what it hashed before.
|
|
241
|
+
...(input.appendOnly === true ? { appendOnly: true as const } : {}),
|
|
237
242
|
};
|
|
238
243
|
};
|
package/src/entity-error.ts
CHANGED
|
@@ -1,12 +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
|
-
// `dbDrift`'s shell-inert fix line.
|
|
3
|
+
// the registry) can raise one without importing `@ultimat3/db`.
|
|
5
4
|
import type { ErrorRetry } from '@ultimat3/core';
|
|
6
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
isFixShellSafe,
|
|
7
|
+
registerErrorCodes,
|
|
8
|
+
registerErrorRetry,
|
|
9
|
+
UltimateError,
|
|
10
|
+
} from '@ultimat3/core';
|
|
7
11
|
|
|
8
|
-
/**
|
|
9
|
-
|
|
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 = [
|
|
10
17
|
'X_ENTITY_DUPLICATE',
|
|
11
18
|
'X_INVARIANT_VIOLATED',
|
|
12
19
|
'X_TENANCY_UNSCOPED',
|
|
@@ -31,25 +38,12 @@ export const ENTITY_OWNED_ERROR_CODES = [
|
|
|
31
38
|
'X_RECORD_KEY_MISSING',
|
|
32
39
|
'X_ENTITY_SEALED_PREDICATE',
|
|
33
40
|
'X_ENTITY_SEALED_IN_VIEW',
|
|
41
|
+
'X_ENTITY_APPEND_ONLY',
|
|
34
42
|
] as const;
|
|
35
43
|
|
|
36
|
-
/**
|
|
37
|
-
* `X_DB_DRIFT` is `@ultimat3/db`'s — drift is a fact about migrations, and this package imports db
|
|
38
|
-
* rather than the other way round. `dbDrift()` below throws it; nothing here titles it, because a
|
|
39
|
-
* second copy of the title is what lets the two packages disagree about what the code means.
|
|
40
|
-
*/
|
|
41
|
-
export const ENTITY_BORROWED_ERROR_CODES = ['X_DB_DRIFT'] as const;
|
|
42
|
-
|
|
43
|
-
/** Every code entity can throw: the ones it owns plus the one it borrows. */
|
|
44
|
-
export const ENTITY_ERROR_CODES = [
|
|
45
|
-
...ENTITY_OWNED_ERROR_CODES,
|
|
46
|
-
...ENTITY_BORROWED_ERROR_CODES,
|
|
47
|
-
] as const;
|
|
48
|
-
|
|
49
|
-
export type EntityOwnedErrorCode = (typeof ENTITY_OWNED_ERROR_CODES)[number];
|
|
50
44
|
export type EntityErrorCode = (typeof ENTITY_ERROR_CODES)[number];
|
|
51
45
|
|
|
52
|
-
export const ENTITY_ERROR_TITLES: Readonly<Record<
|
|
46
|
+
export const ENTITY_ERROR_TITLES: Readonly<Record<EntityErrorCode, string>> = {
|
|
53
47
|
X_ENTITY_DUPLICATE: 'two entities claim the same name',
|
|
54
48
|
X_INVARIANT_VIOLATED: 'a domain invariant rejected this row',
|
|
55
49
|
X_TENANCY_UNSCOPED: 'a tenant-scoped query has no org predicate',
|
|
@@ -77,6 +71,7 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityOwnedErrorCode, string>>
|
|
|
77
71
|
X_ENTITY_SEALED_PREDICATE:
|
|
78
72
|
'a sealed column is read by a predicate, an order or a rule the database evaluates',
|
|
79
73
|
X_ENTITY_SEALED_IN_VIEW: 'a sealed column is named in a view',
|
|
74
|
+
X_ENTITY_APPEND_ONLY: 'an append-only entity was asked to rewrite or remove a row',
|
|
80
75
|
};
|
|
81
76
|
|
|
82
77
|
// Registered at module load, unconditionally, in one call. Without this the registry humanises the
|
|
@@ -87,8 +82,8 @@ registerErrorCodes(
|
|
|
87
82
|
);
|
|
88
83
|
|
|
89
84
|
/**
|
|
90
|
-
* How the codes here that are worth classifying are retried.
|
|
91
|
-
*
|
|
85
|
+
* How the codes here that are worth classifying are retried. The two sealed-column refusals and
|
|
86
|
+
* the append-only refusal: each is the app's own declaration or call, so attempt five answers what attempt one did — and
|
|
92
87
|
* left unclassified, `classifyThrown` reads them as undecided and a job spends its whole retry
|
|
93
88
|
* policy re-proving it. Listed although `terminal` is the default, for the reason core's own table
|
|
94
89
|
* gives for `X_NOT_IMPLEMENTED`. The rest of this package's codes are unclassified, as they were.
|
|
@@ -96,7 +91,8 @@ registerErrorCodes(
|
|
|
96
91
|
export const ENTITY_ERROR_RETRY = {
|
|
97
92
|
X_ENTITY_SEALED_PREDICATE: 'terminal',
|
|
98
93
|
X_ENTITY_SEALED_IN_VIEW: 'terminal',
|
|
99
|
-
|
|
94
|
+
X_ENTITY_APPEND_ONLY: 'terminal',
|
|
95
|
+
} as const satisfies Readonly<Partial<Record<EntityErrorCode, ErrorRetry>>>;
|
|
100
96
|
|
|
101
97
|
registerErrorRetry(ENTITY_ERROR_RETRY);
|
|
102
98
|
|
|
@@ -120,6 +116,18 @@ export class EntityError extends UltimateError {
|
|
|
120
116
|
}
|
|
121
117
|
}
|
|
122
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The one command a BOUND refusal hands back — a page size, a preload ceiling, a judged write: it
|
|
121
|
+
* runs, and it names the real entity. The edit itself is code on a chain whose variable no error
|
|
122
|
+
* can know (`db.members`, not `preload_test_members`), so it is spelled in the `cause` as the call
|
|
123
|
+
* to write, never in the `fix` as though the entity name were that variable. A name a shell would
|
|
124
|
+
* read is not spliced.
|
|
125
|
+
*/
|
|
126
|
+
export const describeCommand = (entityName: string): string =>
|
|
127
|
+
isFixShellSafe(entityName)
|
|
128
|
+
? `x entities describe ${entityName} --json`
|
|
129
|
+
: 'x entities list --json';
|
|
130
|
+
|
|
123
131
|
/**
|
|
124
132
|
* The entity name is a VALUE, never a literal — `entity.$name`, `table`, the `name` `entity()` was
|
|
125
133
|
* given. A literal is an entity that does not exist, and this fix then hands the reader
|
package/src/entity.ts
CHANGED
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
|
|
6
6
|
import type { IndexMethod } from '@ultimat3/db';
|
|
7
7
|
import { describeValue, type Schema } from '@ultimat3/schema';
|
|
8
|
+
import { assertAppendOnlyColumns } from './append-only';
|
|
8
9
|
import { entityNow } from './clock';
|
|
9
|
-
import {
|
|
10
|
+
import { assertTableName, bindColumn, columnName, moneyColumns } from './column';
|
|
10
11
|
import { newId } from './columns';
|
|
11
12
|
import { describeEntity, describeReferences } from './describe';
|
|
12
13
|
import { invariantViolated } from './errors';
|
|
13
|
-
import type {
|
|
14
|
+
import type { InvariantColumns, Resolve } from './expr';
|
|
14
15
|
import { invariantColumns } from './expr';
|
|
16
|
+
import type { IndexInit } from './index-init';
|
|
15
17
|
import { indexName } from './index-name';
|
|
16
18
|
import type { Invariant, InvariantDef } from './invariants';
|
|
17
19
|
import { assertInvariants, bindInvariant } from './invariants';
|
|
@@ -32,29 +34,6 @@ import { viewFor } from './view';
|
|
|
32
34
|
/** Presence of this column is what makes an entity soft-deletable — not a flag. */
|
|
33
35
|
export const SOFT_DELETE_COLUMN = 'deletedAt';
|
|
34
36
|
|
|
35
|
-
export interface IndexInit<C extends ColumnMap> {
|
|
36
|
-
readonly on: readonly (keyof C & string)[];
|
|
37
|
-
readonly order?: 'asc' | 'desc';
|
|
38
|
-
readonly unique?: boolean;
|
|
39
|
-
/** Partial index predicate, written in the same language as an invariant. */
|
|
40
|
-
readonly where?: (columns: InvariantColumns<C>) => Expr;
|
|
41
|
-
/**
|
|
42
|
-
* The access method. Omitted is `btree`, which is Postgres' own default and what every index
|
|
43
|
-
* declared before this existed is — so an entity that names none emits the statement it always
|
|
44
|
-
* emitted and nothing regenerates.
|
|
45
|
-
*
|
|
46
|
-
* `'gin'` is the one with a caller, and it is the whole point of the containment operators:
|
|
47
|
-
* measured on Postgres 16 over 20,000 rows, `tags @> …`, `tags <@ …`, `tags && …` and
|
|
48
|
-
* `data @> …` are each a Bitmap Index Scan with one and a Seq Scan without. The set is
|
|
49
|
-
* `@ultimat3/db`'s `INDEX_METHODS`, imported rather than restated — one declaration of one fact.
|
|
50
|
-
*
|
|
51
|
-
* Two Postgres rules ride with it and both are refused HERE, where the author is, rather than at
|
|
52
|
-
* `x db gen` or inside `ROLE=migrate` as the server's own syntax error: a GIN index cannot be
|
|
53
|
-
* unique and cannot order its keys.
|
|
54
|
-
*/
|
|
55
|
-
readonly using?: IndexMethod;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
37
|
export interface EntityInit<C extends ColumnMap> {
|
|
59
38
|
readonly columns: C;
|
|
60
39
|
/**
|
|
@@ -94,6 +73,12 @@ export interface EntityInit<C extends ColumnMap> {
|
|
|
94
73
|
* and disk is a decision. Read off `recordProjection(entity).persist` by realtime's persister.
|
|
95
74
|
*/
|
|
96
75
|
readonly persist?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Rows are written once and never changed or removed — a ledger, an audit trail, an event log.
|
|
78
|
+
* The repository refuses every rewrite (`append-only.ts`) and `x db gen` emits the trigger that
|
|
79
|
+
* refuses the same in raw SQL. A soft-delete, `onUpdateNow()` or state-machine column is refused.
|
|
80
|
+
*/
|
|
81
|
+
readonly appendOnly?: boolean;
|
|
97
82
|
}
|
|
98
83
|
|
|
99
84
|
/**
|
|
@@ -112,6 +97,8 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
|
|
|
112
97
|
/** `entity:<name>`. `@ultimat3/cache` invalidates by this string. */
|
|
113
98
|
readonly $cacheTag: string;
|
|
114
99
|
readonly $softDelete: boolean;
|
|
100
|
+
/** `entity({ appendOnly: true })`: update and delete are refused, here and by the table. */
|
|
101
|
+
readonly $appendOnly: boolean;
|
|
115
102
|
/** Property key of the tenant column, or `null`. Presence is what turns tenancy on. */
|
|
116
103
|
readonly $tenantColumn: string | null;
|
|
117
104
|
/**
|
|
@@ -186,10 +173,12 @@ export const entity = <const C extends ColumnMap>(
|
|
|
186
173
|
// Both branches. The declared table was checked and the fallback — which is every entity that
|
|
187
174
|
// does not rename its table — was not, so an entity NAME closed the identifier in the same way a
|
|
188
175
|
// column name could: `entity('t" (x int); drop table u; --')` emitted that `drop table` verbatim.
|
|
189
|
-
const table =
|
|
176
|
+
const table = assertTableName(name, init.table ?? name);
|
|
190
177
|
const cacheTag = `entity:${name}`;
|
|
191
178
|
const softDelete = Object.hasOwn(init.columns, SOFT_DELETE_COLUMN);
|
|
192
179
|
const tenantColumn = resolveTenantColumn(name, init.columns, init.tenant);
|
|
180
|
+
const appendOnly = init.appendOnly === true;
|
|
181
|
+
if (appendOnly) assertAppendOnlyColumns(name, entries, SOFT_DELETE_COLUMN);
|
|
193
182
|
|
|
194
183
|
const searchSources: readonly SearchSource[] = entries.flatMap(([property, column]) => {
|
|
195
184
|
const weight = column.$meta.searchable;
|
|
@@ -242,10 +231,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
242
231
|
return part === 'minor' ? parts.minor : parts.currency;
|
|
243
232
|
};
|
|
244
233
|
|
|
245
|
-
const columnsExpr = invariantColumns<C>(
|
|
246
|
-
name,
|
|
247
|
-
entries.map(([property]) => property),
|
|
248
|
-
);
|
|
234
|
+
const columnsExpr = invariantColumns<C>(name, init.columns);
|
|
249
235
|
// Through the one resolver, so a soft-delete column the table spells differently is still the
|
|
250
236
|
// column every partial index excludes rows by.
|
|
251
237
|
const partialWhere = softDelete ? `${resolve([SOFT_DELETE_COLUMN])} is null` : undefined;
|
|
@@ -383,6 +369,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
383
369
|
softDelete,
|
|
384
370
|
tenantColumn,
|
|
385
371
|
search,
|
|
372
|
+
appendOnly,
|
|
386
373
|
});
|
|
387
374
|
const references = (): readonly ReferenceDescription[] => describeReferences(name, entries);
|
|
388
375
|
|
|
@@ -463,6 +450,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
463
450
|
$tags: tags,
|
|
464
451
|
$cacheTag: cacheTag,
|
|
465
452
|
$softDelete: softDelete,
|
|
453
|
+
$appendOnly: appendOnly,
|
|
466
454
|
$tenantColumn: tenantColumn,
|
|
467
455
|
$search: search,
|
|
468
456
|
$schema: schema,
|
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,
|
|
@@ -72,7 +70,7 @@ export const tenancyUnscoped = (
|
|
|
72
70
|
: `${entityName}.${operation}() was checked against a plan with no org predicate, though the acting actor's tenant is ${renderValue(actorOrg)} — a plan is verified here, never rewritten, so the tenant had to be on it already`,
|
|
73
71
|
fix:
|
|
74
72
|
actorOrg === undefined
|
|
75
|
-
? `run it inside runWithContext(
|
|
73
|
+
? `run it inside runWithContext(ctxOf({ actor: userActor({ id, orgId }) }), fn) — the actor's org scopes the plan — or name the tenant: ${entityName}.${operation}({ orgId }), which orgScoped(plan, orgId) is the plan-level form of`
|
|
76
74
|
: `build the plan with scopedPlan('${entityName}', tenantColumn, '${operation}', plan) — it applies the actor's tenant — or add the predicate first: orgScoped(plan, ${asLiteral(actorOrg, "'<org>'")})`,
|
|
77
75
|
});
|
|
78
76
|
|
|
@@ -161,24 +159,22 @@ export const crossTenantDenied = (init: {
|
|
|
161
159
|
new EntityError({
|
|
162
160
|
code: 'X_TENANCY_CROSS_DENIED',
|
|
163
161
|
cause: `crossTenant(${JSON.stringify(init.reason)}) was entered by ${init.actor}, which does not carry the ${JSON.stringify(init.scope)} scope`,
|
|
164
|
-
fix: `grant the capability where the actor is minted — serviceActor({ id: 'reconciler', scopes: ['${init.scope}'] }) — and run the sweep inside runWithContext(
|
|
162
|
+
fix: `grant the capability where the actor is minted — serviceActor({ id: 'reconciler', scopes: ['${init.scope}'] }) — and run the sweep inside runWithContext(ctxOf({ actor }), fn); an ordinary request scopes to its own tenant instead and needs no crossTenant()`,
|
|
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',
|