@ultimat3/entity 30.2.0 → 30.4.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/package.json +5 -5
- package/src/append-only.ts +9 -0
- package/src/columns-data.ts +15 -11
- package/src/describe.ts +3 -0
- package/src/entity-error.ts +2 -0
- package/src/entity.ts +11 -0
- package/src/memory-repo.ts +14 -22
- package/src/memory-unique.ts +134 -21
- package/src/memory-update.ts +5 -2
- package/src/read-only.ts +53 -0
- package/src/registry.ts +2 -0
- package/src/types.ts +7 -1
- package/src/view-repo.ts +10 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "30.
|
|
3
|
+
"version": "30.4.0",
|
|
4
4
|
"description": "A table + its domain type + invariants the database also enforces",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ultimate",
|
|
@@ -43,9 +43,9 @@
|
|
|
43
43
|
"test": "bun test"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@ultimat3/core": "30.
|
|
47
|
-
"@ultimat3/db": "30.
|
|
48
|
-
"@ultimat3/schema": "30.
|
|
49
|
-
"@ultimat3/time": "30.
|
|
46
|
+
"@ultimat3/core": "30.4.0",
|
|
47
|
+
"@ultimat3/db": "30.4.0",
|
|
48
|
+
"@ultimat3/schema": "30.4.0",
|
|
49
|
+
"@ultimat3/time": "30.4.0"
|
|
50
50
|
}
|
|
51
51
|
}
|
package/src/append-only.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// alike — and before any statement exists, so memory and Postgres answer the same code.
|
|
5
5
|
|
|
6
6
|
import { appendOnlyColumnRefused, appendOnlyRefused } from './append-only-errors';
|
|
7
|
+
import { readOnlyRefused } from './read-only';
|
|
7
8
|
import type { Repo, UpsertArgs } from './repo';
|
|
8
9
|
import type { AnyColumn } from './types';
|
|
9
10
|
import { readOnlyRepo } from './view-repo';
|
|
@@ -14,6 +15,8 @@ export interface AppendOnlySource {
|
|
|
14
15
|
readonly $appendOnly: boolean;
|
|
15
16
|
/** `entity({ view })`: every write refused. Optional so a hand-built source still passes. */
|
|
16
17
|
readonly $viewQuery?: unknown;
|
|
18
|
+
/** `entity({ readOnly })`'s reason: every write refused. Optional, as `$viewQuery` is. */
|
|
19
|
+
readonly $readOnly?: string | null;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
/**
|
|
@@ -62,6 +65,12 @@ export const appendOnlyRepo = <Row, R extends Repo<Row>>(entity: AppendOnlySourc
|
|
|
62
65
|
if (entity.$viewQuery !== undefined && entity.$viewQuery !== null) {
|
|
63
66
|
return readOnlyRepo<Row, R>(entity.$name, repo);
|
|
64
67
|
}
|
|
68
|
+
const reason = entity.$readOnly;
|
|
69
|
+
if (typeof reason === 'string') {
|
|
70
|
+
return readOnlyRepo<Row, R>(entity.$name, repo, (operation) =>
|
|
71
|
+
readOnlyRefused(entity.$name, operation, reason),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
65
74
|
if (!entity.$appendOnly) return repo;
|
|
66
75
|
const name = entity.$name;
|
|
67
76
|
const refusing: Pick<
|
package/src/columns-data.ts
CHANGED
|
@@ -33,17 +33,21 @@ const got = (value: unknown): string => `got ${describeValue(value)}`;
|
|
|
33
33
|
* the server parse the characters, so neither half may be changed without the other.
|
|
34
34
|
*/
|
|
35
35
|
export const json = <T>(schema: StandardSchemaV1<unknown, T>): Column<T> =>
|
|
36
|
-
column<T>(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
36
|
+
column<T>(
|
|
37
|
+
'jsonb',
|
|
38
|
+
(value) => {
|
|
39
|
+
const result = validate(schema, value);
|
|
40
|
+
if (result.issues === undefined) return result.value;
|
|
41
|
+
// The ISSUES, never the value: `formatIssues` renders path + message, and a column rejection
|
|
42
|
+
// reaches the caller and the log line where a value has no key left to redact.
|
|
43
|
+
return refuseColumn(
|
|
44
|
+
'json',
|
|
45
|
+
`does not match the column's schema — ${formatIssues(result.issues).join('; ')}`,
|
|
46
|
+
'correct the key the cause names, or widen the schema this column was declared with — json(t.object({ seats: t.number })) validates on the way in and on the way back',
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
{ schema },
|
|
50
|
+
);
|
|
47
51
|
|
|
48
52
|
const DIGITS = /^-?\d+$/;
|
|
49
53
|
|
package/src/describe.ts
CHANGED
|
@@ -42,6 +42,8 @@ export interface DescribeInput<Row> {
|
|
|
42
42
|
readonly shared?: string | undefined;
|
|
43
43
|
/** `entity({ scan: '<why>' })` — projected only when declared. */
|
|
44
44
|
readonly scan?: string | undefined;
|
|
45
|
+
/** `entity({ readOnly: '<why>' })` — projected only when declared. */
|
|
46
|
+
readonly readOnly?: string | undefined;
|
|
45
47
|
/** `entity({ view })` — projected only when declared. */
|
|
46
48
|
readonly view?: ViewQuery | null | undefined;
|
|
47
49
|
/** `retain`, `history`, `countedIn` counters — each projected only when declared (`lifecycle.ts`). */
|
|
@@ -372,6 +374,7 @@ export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescriptio
|
|
|
372
374
|
// Absent unless declared, like `appendOnly`: no entity that never said it moves the hash.
|
|
373
375
|
...(input.shared === undefined ? {} : { shared: input.shared }),
|
|
374
376
|
...(input.scan === undefined ? {} : { scan: input.scan }),
|
|
377
|
+
...(input.readOnly === undefined ? {} : { readOnly: input.readOnly }),
|
|
375
378
|
// Absent on every table, so no entity that never said `view` moves the hash.
|
|
376
379
|
...(input.view === null || input.view === undefined
|
|
377
380
|
? {}
|
package/src/entity-error.ts
CHANGED
|
@@ -48,6 +48,7 @@ export const ENTITY_ERROR_CODES = [
|
|
|
48
48
|
'X_ENTITY_VIEW_READ_ONLY',
|
|
49
49
|
'X_ENTITY_RETAIN_UNINDEXED',
|
|
50
50
|
'X_ENTITY_COUNTS_INVALID',
|
|
51
|
+
'X_ENTITY_READ_ONLY',
|
|
51
52
|
] as const;
|
|
52
53
|
|
|
53
54
|
export type EntityErrorCode = (typeof ENTITY_ERROR_CODES)[number];
|
|
@@ -91,6 +92,7 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityErrorCode, string>> = {
|
|
|
91
92
|
X_ENTITY_RETAIN_UNINDEXED: 'entity({ retain }) names a column no index leads with',
|
|
92
93
|
X_ENTITY_COUNTS_INVALID:
|
|
93
94
|
'references(…, { countedIn }) names no integer of its parent, or is no key to its primary key',
|
|
95
|
+
X_ENTITY_READ_ONLY: 'a read-only entity was asked to write a row',
|
|
94
96
|
};
|
|
95
97
|
|
|
96
98
|
// Registered at module load, unconditionally, in one call. Without this the registry humanises the
|
package/src/entity.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { indexName } from './index-name';
|
|
|
26
26
|
import type { Invariant, InvariantDef } from './invariants';
|
|
27
27
|
import { assertInvariants, bindInvariant } from './invariants';
|
|
28
28
|
import { describeLifecycle, lifecycleHistory } from './lifecycle';
|
|
29
|
+
import { assertReadOnly } from './read-only';
|
|
29
30
|
import { recordProjection } from './record-projection';
|
|
30
31
|
import type { EntityDescription, ReferenceDescription } from './registry';
|
|
31
32
|
import { registerEntity } from './registry';
|
|
@@ -72,6 +73,11 @@ export interface EntityInit<C extends ColumnMap> {
|
|
|
72
73
|
* and a reference column no index leads with; blank is refused.
|
|
73
74
|
*/
|
|
74
75
|
readonly scan?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Why the app never writes this table — legacy history a trigger closed, a table another system
|
|
78
|
+
* owns. Every write is `X_ENTITY_READ_ONLY` and `x exercise` seeds no row of it; blank is refused.
|
|
79
|
+
*/
|
|
80
|
+
readonly readOnly?: string;
|
|
75
81
|
/** Composite keys only — a single key is `uuid().primaryKey()` on the column itself. */
|
|
76
82
|
readonly primaryKey?: readonly (keyof C & string)[];
|
|
77
83
|
/**
|
|
@@ -149,6 +155,8 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
|
|
|
149
155
|
readonly $historyOf: string | null;
|
|
150
156
|
/** `entity({ view })`: the query a view is derived by, `null` on a table. Writes are refused. */
|
|
151
157
|
readonly $viewQuery: ViewQuery | null;
|
|
158
|
+
/** `entity({ readOnly })`'s reason, `null` on a table the app writes. Writes are refused. */
|
|
159
|
+
readonly $readOnly: string | null;
|
|
152
160
|
/** Property key of the tenant column, or `null`. Presence is what turns tenancy on. */
|
|
153
161
|
readonly $tenantColumn: string | null;
|
|
154
162
|
/**
|
|
@@ -230,6 +238,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
230
238
|
const softDelete = Object.hasOwn(init.columns, SOFT_DELETE_COLUMN);
|
|
231
239
|
const appendOnly = init.appendOnly === true;
|
|
232
240
|
if (appendOnly) assertAppendOnlyColumns(name, entries, SOFT_DELETE_COLUMN);
|
|
241
|
+
const readOnly = assertReadOnly(name, init.readOnly, { appendOnly, view: init.view != null });
|
|
233
242
|
|
|
234
243
|
const searchSources: readonly SearchSource[] = entries.flatMap(([property, column]) => {
|
|
235
244
|
const weight = column.$meta.searchable;
|
|
@@ -412,6 +421,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
412
421
|
retired: init.retired,
|
|
413
422
|
shared: init.shared,
|
|
414
423
|
scan: init.scan,
|
|
424
|
+
readOnly: init.readOnly,
|
|
415
425
|
view: viewQuery,
|
|
416
426
|
lifecycle: describeLifecycle(core as EntityCore, retain, init.historyOf),
|
|
417
427
|
});
|
|
@@ -438,6 +448,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
438
448
|
$softDelete: softDelete,
|
|
439
449
|
$appendOnly: appendOnly || init.historyOf !== undefined,
|
|
440
450
|
$viewQuery: viewQuery,
|
|
451
|
+
$readOnly: readOnly,
|
|
441
452
|
$tenantColumn: tenantColumn,
|
|
442
453
|
$search: search,
|
|
443
454
|
$schema: schema,
|
package/src/memory-repo.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { compareByKind, matchesPredicate } from './memory-match';
|
|
|
27
27
|
import { observedMemoryRepo } from './memory-observed';
|
|
28
28
|
import { type MemoryTables, sameTenantJudge } from './memory-same-tenant';
|
|
29
29
|
import { afterCursor } from './memory-seek';
|
|
30
|
-
import {
|
|
30
|
+
import { StoredRows, uniqueIndex, uniqueViolation } from './memory-unique';
|
|
31
31
|
import { judgeUpdate, mergedRow } from './memory-update';
|
|
32
32
|
import { deletePlan, idPlan, readPlan, singleKeyOf, updatePlan } from './plan';
|
|
33
33
|
import type { FindManyArgs, MemoryRepo, RepoOptions } from './repo';
|
|
@@ -73,7 +73,9 @@ export const memoryRepo = <Row>(
|
|
|
73
73
|
'declares a sealed column, and memoryRepo(entity, seed) stores seed rows unsealed — pass no seed and write them with insertAll(rows)',
|
|
74
74
|
);
|
|
75
75
|
}
|
|
76
|
-
|
|
76
|
+
// Every unique key kept as rows move, so a single write is judged by its own keys, never a scan.
|
|
77
|
+
const rows = new StoredRows<Row>(entity);
|
|
78
|
+
for (const row of seed) rows.set(storeKey(row), row);
|
|
77
79
|
tables.set(entity.$name, {
|
|
78
80
|
entity: entity as EntityCore,
|
|
79
81
|
// By the primary key when that is what is asked, which is nearly every reference.
|
|
@@ -142,16 +144,6 @@ export const memoryRepo = <Row>(
|
|
|
142
144
|
const narrowed = (batch: readonly RowWrite<Row>[]): readonly Row[] =>
|
|
143
145
|
batch.map((row) => narrowRow<Row>(entity.$columns, row));
|
|
144
146
|
|
|
145
|
-
/**
|
|
146
|
-
* Lazy: a batch asks this once a row, and an entity with no unique never iterates it. Re-iterable
|
|
147
|
-
* and not a generator: `uniqueClash` walks it once per constraint.
|
|
148
|
-
*/
|
|
149
|
-
const storedExcept = (from: string | undefined): Iterable<Row> => ({
|
|
150
|
-
*[Symbol.iterator]() {
|
|
151
|
-
for (const [stored, other] of rows) if (stored !== from) yield other;
|
|
152
|
-
},
|
|
153
|
-
});
|
|
154
|
-
|
|
155
147
|
/**
|
|
156
148
|
* `from` is the key the row is stored under NOW, or `undefined` for a new row. A new row may not
|
|
157
149
|
* land on a stored key, and a moved one may not land on another row's — Postgres answers both
|
|
@@ -162,6 +154,8 @@ export const memoryRepo = <Row>(
|
|
|
162
154
|
options: RepoOptions | undefined,
|
|
163
155
|
operation: string,
|
|
164
156
|
from?: string,
|
|
157
|
+
/** `insertAll` judged every unique key of its batch already, against one index. */
|
|
158
|
+
uniquesJudged = false,
|
|
165
159
|
): Row => {
|
|
166
160
|
// `MoneyInput` lets a writer hand a `bigint`; a stored row holds the value type. The Postgres
|
|
167
161
|
// driver narrows at the same position — its write methods' entry — so without this an
|
|
@@ -176,7 +170,7 @@ export const memoryRepo = <Row>(
|
|
|
176
170
|
judgeReferences(row);
|
|
177
171
|
const key = storeKey(row);
|
|
178
172
|
if (key !== from && rows.has(key)) throw uniqueViolation(entity, `${entity.$table}_pkey`);
|
|
179
|
-
const clash =
|
|
173
|
+
const clash = uniquesJudged ? undefined : rows.clash(row, from);
|
|
180
174
|
if (clash !== undefined) throw uniqueViolation(entity, clash);
|
|
181
175
|
const moved = from !== undefined && from !== key ? rows.get(from) : undefined;
|
|
182
176
|
const previous = rows.get(key);
|
|
@@ -265,14 +259,11 @@ export const memoryRepo = <Row>(
|
|
|
265
259
|
// Narrowed FIRST, so what this loop judges is what `write` will store: `$assert` was handed
|
|
266
260
|
// the caller's `bigint` minor unit here and the narrowed `number` one call later.
|
|
267
261
|
const batch = narrowed(given);
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
for (let at = 0; at < position; at += 1) yield batch[at] as Row;
|
|
272
|
-
},
|
|
273
|
-
});
|
|
262
|
+
// Layered over the stored rows' index, each batch row's keys added as it is judged: a row
|
|
263
|
+
// collides with what is stored and with the rows before it, never after.
|
|
264
|
+
const taken = uniqueIndex(entity, [], rows);
|
|
274
265
|
const seen = new Set<string>();
|
|
275
|
-
for (const
|
|
266
|
+
for (const row of batch) {
|
|
276
267
|
assertRowTenant(entity.$name, entity.$tenantColumn, 'insertAll', row);
|
|
277
268
|
entity.$assert(row);
|
|
278
269
|
judgeReferences(row, batch);
|
|
@@ -282,10 +273,11 @@ export const memoryRepo = <Row>(
|
|
|
282
273
|
throw uniqueViolation(entity, `${entity.$table}_pkey`);
|
|
283
274
|
}
|
|
284
275
|
seen.add(key);
|
|
285
|
-
const clash =
|
|
276
|
+
const clash = taken.clash(row);
|
|
286
277
|
if (clash !== undefined) throw uniqueViolation(entity, clash);
|
|
278
|
+
taken.add(row);
|
|
287
279
|
}
|
|
288
|
-
return batch.map((row) => write(row, options, 'insertAll'));
|
|
280
|
+
return batch.map((row) => write(row, options, 'insertAll', undefined, true));
|
|
289
281
|
}),
|
|
290
282
|
|
|
291
283
|
upsertAll: writing(async (given, args) => {
|
package/src/memory-unique.ts
CHANGED
|
@@ -39,29 +39,142 @@ const kindsOf = <Row>(entity: EntityCore<Row>): ReadonlyMap<string, string> =>
|
|
|
39
39
|
.map(([property, column]) => [columnName(property, column.$meta), column.$meta.kind]),
|
|
40
40
|
);
|
|
41
41
|
|
|
42
|
+
/** Each unique constraint's key for a row, in declaration order; `undefined` where a part is NULL. */
|
|
43
|
+
const keysOf = <Row>(entity: EntityCore<Row>) => {
|
|
44
|
+
const unique = uniqueConstraints(entity);
|
|
45
|
+
const kinds = kindsOf(entity);
|
|
46
|
+
return {
|
|
47
|
+
unique,
|
|
48
|
+
keys: (row: Row): readonly (string | undefined)[] => {
|
|
49
|
+
const bound = bindValues(entity, row as unknown as RowPatch<Row>);
|
|
50
|
+
return unique.map((index) => {
|
|
51
|
+
const parts = index.columns.map((name) => cellOf(kinds.get(name), bound.get(name)));
|
|
52
|
+
return parts.some((part) => part === undefined) ? undefined : JSON.stringify(parts);
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** One constraint's keys, counted: a seed or a trigger's raw write may hold one key twice. */
|
|
59
|
+
type Taken = Map<string, number>;
|
|
60
|
+
|
|
61
|
+
const holds = (taken: readonly Taken[], i: number, key: string | undefined): number =>
|
|
62
|
+
key === undefined ? 0 : (taken[i]?.get(key) ?? 0);
|
|
63
|
+
|
|
64
|
+
const move = (taken: readonly Taken[], keys: readonly (string | undefined)[], by: 1 | -1): void =>
|
|
65
|
+
keys.forEach((key, i) => {
|
|
66
|
+
const at = taken[i];
|
|
67
|
+
if (key === undefined || at === undefined) return;
|
|
68
|
+
const left = (at.get(key) ?? 0) + by;
|
|
69
|
+
if (left > 0) at.set(key, left);
|
|
70
|
+
else at.delete(key);
|
|
71
|
+
});
|
|
72
|
+
|
|
42
73
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
74
|
+
* Every unique key a set of rows holds, one count per constraint: a batch is judged against it row
|
|
75
|
+
* by row, each row bound once. A scan of every other row per row — rebound per constraint — made a
|
|
76
|
+
* 1,000-row `insertAll` take tens of seconds.
|
|
77
|
+
*
|
|
78
|
+
* `under` is a second index this one is layered over: a key either holds is taken. `insertAll`
|
|
79
|
+
* judges its batch over the stored rows' index this way, so a batch never copies the table.
|
|
46
80
|
*/
|
|
47
|
-
export
|
|
81
|
+
export interface UniqueIndex<Row> {
|
|
82
|
+
/** The first constraint, in declaration order, `row` collides on — or `undefined`. */
|
|
83
|
+
clash(row: Row): string | undefined;
|
|
84
|
+
add(row: Row): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function uniqueIndex<Row>(
|
|
48
88
|
entity: EntityCore<Row>,
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
):
|
|
52
|
-
const unique =
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
89
|
+
rows: Iterable<Row>,
|
|
90
|
+
under?: StoredRows<Row>,
|
|
91
|
+
): UniqueIndex<Row> {
|
|
92
|
+
const { unique, keys } = keysOf(entity);
|
|
93
|
+
const taken: Taken[] = unique.map(() => new Map());
|
|
94
|
+
const index: UniqueIndex<Row> = {
|
|
95
|
+
clash(row) {
|
|
96
|
+
if (unique.length === 0) return undefined;
|
|
97
|
+
const mine = keys(row);
|
|
98
|
+
const at = mine.findIndex(
|
|
99
|
+
(key, i) => holds(taken, i, key) > 0 || (under?.holds(i, key) ?? false),
|
|
100
|
+
);
|
|
101
|
+
return at === -1 ? undefined : unique[at]?.name();
|
|
102
|
+
},
|
|
103
|
+
add(row) {
|
|
104
|
+
if (unique.length > 0) move(taken, keys(row), 1);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
for (const row of rows) index.add(row);
|
|
108
|
+
return index;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The memory driver's table: a `Map` from store key to row that keeps every non-partial unique's
|
|
113
|
+
* keys as rows come and go. Every way a row moves — a write, a delete, a trigger's raw write, an
|
|
114
|
+
* undo run by a rollback, a reset — is a `set`, `delete` or `clear` on this map, so the index
|
|
115
|
+
* cannot miss one. Rebuilt per write, it made N single inserts cost N² (1,100 rows: ~14 s).
|
|
116
|
+
*
|
|
117
|
+
* A row's keys are recorded as it lands and removed by that record, so removal never rebinds.
|
|
118
|
+
*/
|
|
119
|
+
export class StoredRows<Row> extends Map<string, Row> {
|
|
120
|
+
readonly #unique;
|
|
121
|
+
readonly #keys;
|
|
122
|
+
readonly #taken: Taken[];
|
|
123
|
+
readonly #held = new Map<string, readonly (string | undefined)[]>();
|
|
124
|
+
|
|
125
|
+
constructor(entity: EntityCore<Row>) {
|
|
126
|
+
super();
|
|
127
|
+
const { unique, keys } = keysOf(entity);
|
|
128
|
+
this.#unique = unique;
|
|
129
|
+
this.#keys = keys;
|
|
130
|
+
this.#taken = unique.map(() => new Map());
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
override set(key: string, row: Row): this {
|
|
134
|
+
if (this.#unique.length > 0) {
|
|
135
|
+
this.#release(key);
|
|
136
|
+
const keys = this.#keys(row);
|
|
137
|
+
move(this.#taken, keys, 1);
|
|
138
|
+
this.#held.set(key, keys);
|
|
64
139
|
}
|
|
140
|
+
return super.set(key, row);
|
|
65
141
|
}
|
|
66
|
-
|
|
67
|
-
|
|
142
|
+
|
|
143
|
+
override delete(key: string): boolean {
|
|
144
|
+
this.#release(key);
|
|
145
|
+
return super.delete(key);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
override clear(): void {
|
|
149
|
+
for (const taken of this.#taken) taken.clear();
|
|
150
|
+
this.#held.clear();
|
|
151
|
+
super.clear();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Whether constraint `i` holds `key` — for an index layered over this one. */
|
|
155
|
+
holds(i: number, key: string | undefined): boolean {
|
|
156
|
+
return holds(this.#taken, i, key) > 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The first constraint, in declaration order, `row` collides on among the stored rows — the row
|
|
161
|
+
* stored under `from` excepted: it is the one `row` replaces. Partial uniques are not in the list:
|
|
162
|
+
* their predicate is SQL this driver cannot evaluate, and a guess would refuse rows Postgres
|
|
163
|
+
* accepts — the one direction that must never happen.
|
|
164
|
+
*/
|
|
165
|
+
clash(row: Row, from?: string): string | undefined {
|
|
166
|
+
if (this.#unique.length === 0) return undefined;
|
|
167
|
+
const own = from === undefined ? undefined : this.#held.get(from);
|
|
168
|
+
const at = this.#keys(row).findIndex(
|
|
169
|
+
(key, i) => holds(this.#taken, i, key) - (key !== undefined && own?.[i] === key ? 1 : 0) > 0,
|
|
170
|
+
);
|
|
171
|
+
return at === -1 ? undefined : this.#unique[at]?.name();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#release(key: string): void {
|
|
175
|
+
const keys = this.#held.get(key);
|
|
176
|
+
if (keys === undefined) return;
|
|
177
|
+
move(this.#taken, keys, -1);
|
|
178
|
+
this.#held.delete(key);
|
|
179
|
+
}
|
|
180
|
+
}
|
package/src/memory-update.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { narrowRow } from './columns';
|
|
11
11
|
import type { EntityCore } from './entity';
|
|
12
|
-
import {
|
|
12
|
+
import { uniqueIndex, uniqueViolation } from './memory-unique';
|
|
13
13
|
import { assertRowTenant } from './tenancy';
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -55,6 +55,8 @@ export const judgeUpdate = <Row>(
|
|
|
55
55
|
const judged: Row[] = [];
|
|
56
56
|
const judgedKeys = new Set<string>();
|
|
57
57
|
const untouchedKeys = new Set(untouched.map((row) => key(row)));
|
|
58
|
+
// Built once and grown by each result: rebuilt per matched row, a sweep cost matched x stored.
|
|
59
|
+
const taken = uniqueIndex(entity, untouched);
|
|
58
60
|
for (const row of matched) {
|
|
59
61
|
const result = mergedRow(entity, row, patch);
|
|
60
62
|
// The same four judgements `write` makes, in the same order — on the merged row rather than
|
|
@@ -70,8 +72,9 @@ export const judgeUpdate = <Row>(
|
|
|
70
72
|
}
|
|
71
73
|
// The unique constraints, against the same two sets. The rows this batch matched are not in
|
|
72
74
|
// either: each of them is replaced by its own result, which is judged here instead.
|
|
73
|
-
const clash =
|
|
75
|
+
const clash = taken.clash(result);
|
|
74
76
|
if (clash !== undefined) throw uniqueViolation(entity, clash);
|
|
77
|
+
taken.add(result);
|
|
75
78
|
judged.push(result);
|
|
76
79
|
judgedKeys.add(resultKey);
|
|
77
80
|
}
|
package/src/read-only.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// `entity({ readOnly: '<why>' })`: a table the app reads and never writes — legacy history behind a
|
|
2
|
+
// trigger that refuses every INSERT, a table another system owns. A reason, like `shared` and
|
|
3
|
+
// `scan`: blank is no reason. The repository refuses every write (`view-repo.ts`'s seam), and the
|
|
4
|
+
// exercise pass's seed graph leaves the table empty, as it does a view.
|
|
5
|
+
|
|
6
|
+
import { renderFixShellArg } from '@ultimat3/core';
|
|
7
|
+
import { EntityError } from './entity-error';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Refused at declaration: a blank reason, and a second spelling of a narrower rule — a view
|
|
11
|
+
* already refuses every write, and `appendOnly` admits the inserts this refuses.
|
|
12
|
+
*/
|
|
13
|
+
export const assertReadOnly = (
|
|
14
|
+
entityName: string,
|
|
15
|
+
readOnly: string | undefined,
|
|
16
|
+
also: { readonly appendOnly: boolean; readonly view: boolean },
|
|
17
|
+
): string | null => {
|
|
18
|
+
if (readOnly === undefined) return null;
|
|
19
|
+
const refuse = (why: string, fix: string): never => {
|
|
20
|
+
throw new EntityError({
|
|
21
|
+
code: 'X_INVARIANT_VIOLATED',
|
|
22
|
+
cause: `${entityName} declares readOnly ${why}`,
|
|
23
|
+
fix,
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
if (readOnly.trim() === '') {
|
|
27
|
+
refuse(
|
|
28
|
+
'with no reason: a blank reason says nothing about why the app never writes the table',
|
|
29
|
+
`entity('${entityName}', { readOnly: '<why the app never writes it, e.g. legacy history a trigger closed>' }) — or drop readOnly`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (also.view) {
|
|
33
|
+
refuse(
|
|
34
|
+
'and is a view: a view refuses every write already',
|
|
35
|
+
`entity('${entityName}', { view }) — with the readOnly option removed`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if (also.appendOnly) {
|
|
39
|
+
refuse(
|
|
40
|
+
'and appendOnly: appendOnly admits the inserts readOnly refuses',
|
|
41
|
+
`entity('${entityName}', { readOnly: '<why>' }) — with appendOnly removed, or appendOnly alone when the app appends`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return readOnly;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** `X_ENTITY_READ_ONLY`: the write, the entity, and the reason its declaration gave. */
|
|
48
|
+
export const readOnlyRefused = (entityName: string, operation: string, reason: string) =>
|
|
49
|
+
new EntityError({
|
|
50
|
+
code: 'X_ENTITY_READ_ONLY',
|
|
51
|
+
cause: `${entityName}.${operation}() — ${entityName} is declared readOnly (${reason}), so the app never writes a row of it`,
|
|
52
|
+
fix: `x entities show ${renderFixShellArg(entityName, '<entity>')} --json # write the table the app owns; drop readOnly from entity('<entity>', { … }) only once the table takes writes again`,
|
|
53
|
+
});
|
package/src/registry.ts
CHANGED
|
@@ -162,6 +162,8 @@ export interface EntityDescription {
|
|
|
162
162
|
readonly shared?: string | undefined;
|
|
163
163
|
/** `entity({ scan: '<why>' })`: why the plan sweep may see this table scanned. */
|
|
164
164
|
readonly scan?: string | undefined;
|
|
165
|
+
/** `entity({ readOnly: '<why>' })`: why the app never writes the table; every write refused. */
|
|
166
|
+
readonly readOnly?: string | undefined;
|
|
165
167
|
/**
|
|
166
168
|
* `entity({ view: { sql } })`: a relation Postgres derives. Absent on a table. `x db gen` emits
|
|
167
169
|
* it (`@ultimat3/db`'s `generate-view.ts`), admin lists it read-only.
|
package/src/types.ts
CHANGED
|
@@ -82,7 +82,7 @@ export interface ReferenceOptions {
|
|
|
82
82
|
* on a bigint, so returning a row with a money column from an action crashed the response, and
|
|
83
83
|
* `t.money` — the schema node that becomes the OpenAPI contract — rejected the framework's own row.
|
|
84
84
|
*/
|
|
85
|
-
import type { MoneyValue } from '@ultimat3/schema';
|
|
85
|
+
import type { MoneyValue, StandardSchemaV1 } from '@ultimat3/schema';
|
|
86
86
|
import type { StateMachine, TransitionTable } from './state-machine';
|
|
87
87
|
|
|
88
88
|
export type { MoneyValue };
|
|
@@ -181,6 +181,12 @@ export interface ColumnMeta {
|
|
|
181
181
|
* database keeps equal to the number of rows pointing there. `counter.ts` reads it.
|
|
182
182
|
*/
|
|
183
183
|
readonly countedIn?: () => AnyColumn;
|
|
184
|
+
/**
|
|
185
|
+
* `json(schema)`'s schema, beside the `$parse` that validates through it, so a reader that must
|
|
186
|
+
* PRODUCE a storable value (`@ultimat3/testing`'s seed values) draws from the declaration rather
|
|
187
|
+
* than guess. Not on `$describe()`: the DDL is `jsonb` whatever it holds, so no hash moves.
|
|
188
|
+
*/
|
|
189
|
+
readonly schema?: StandardSchemaV1<unknown, unknown>;
|
|
184
190
|
}
|
|
185
191
|
|
|
186
192
|
/** What `.sealed(options)` declared, resolved. */
|
package/src/view-repo.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Single responsibility: the repository seam of
|
|
2
|
-
// `X_ENTITY_VIEW_READ_ONLY`
|
|
3
|
-
// through `appendOnlyRepo`. Reads pass through untouched; a spread keeps `MemoryRepo.reset()`.
|
|
1
|
+
// Single responsibility: the repository seam of an entity no row of which is written — a view
|
|
2
|
+
// (`X_ENTITY_VIEW_READ_ONLY`), a `readOnly` table (`X_ENTITY_READ_ONLY`) — every write rejecting
|
|
3
|
+
// before any statement exists, in both drivers, because both return through `appendOnlyRepo`. Reads pass through untouched; a spread keeps `MemoryRepo.reset()`.
|
|
4
4
|
|
|
5
5
|
import { renderFixShellArg } from '@ultimat3/core';
|
|
6
6
|
import { EntityError } from './entity-error';
|
|
@@ -24,12 +24,17 @@ export const viewReadOnly = (entityName: string, operation: string): EntityError
|
|
|
24
24
|
fix: `x entities show ${renderFixShellArg(entityName, '<entity>')} --json # write the table its view.sql reads; the view answers on the next read (materialized: after its refreshView job runs)`,
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
/** `repo` with every write rejecting `refusal(operation)` — a view's code unless told otherwise. */
|
|
28
|
+
export const readOnlyRepo = <Row, R extends Repo<Row>>(
|
|
29
|
+
entityName: string,
|
|
30
|
+
repo: R,
|
|
31
|
+
refusal: (operation: string) => EntityError = (operation) => viewReadOnly(entityName, operation),
|
|
32
|
+
): R => {
|
|
28
33
|
const refusing = Object.fromEntries(
|
|
29
34
|
WRITES.map((operation) => [
|
|
30
35
|
operation,
|
|
31
36
|
async () => {
|
|
32
|
-
throw
|
|
37
|
+
throw refusal(operation);
|
|
33
38
|
},
|
|
34
39
|
]),
|
|
35
40
|
);
|