@ultimat3/entity 30.1.0 → 30.3.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/describe.ts +3 -0
- package/src/entity-error.ts +2 -0
- package/src/entity.ts +11 -0
- package/src/memory-repo.ts +13 -13
- package/src/memory-unique.ts +50 -17
- package/src/read-only.ts +53 -0
- package/src/registry.ts +2 -0
- 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.3.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.3.0",
|
|
47
|
+
"@ultimat3/db": "30.3.0",
|
|
48
|
+
"@ultimat3/schema": "30.3.0",
|
|
49
|
+
"@ultimat3/time": "30.3.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/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 { uniqueClash, uniqueViolation } from './memory-unique';
|
|
30
|
+
import { uniqueClash, 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';
|
|
@@ -143,8 +143,8 @@ export const memoryRepo = <Row>(
|
|
|
143
143
|
batch.map((row) => narrowRow<Row>(entity.$columns, row));
|
|
144
144
|
|
|
145
145
|
/**
|
|
146
|
-
* Lazy:
|
|
147
|
-
*
|
|
146
|
+
* Lazy: one write asks it once, and an entity with no unique never iterates it. A batch judges
|
|
147
|
+
* against `uniqueIndex` instead, built once.
|
|
148
148
|
*/
|
|
149
149
|
const storedExcept = (from: string | undefined): Iterable<Row> => ({
|
|
150
150
|
*[Symbol.iterator]() {
|
|
@@ -162,6 +162,8 @@ export const memoryRepo = <Row>(
|
|
|
162
162
|
options: RepoOptions | undefined,
|
|
163
163
|
operation: string,
|
|
164
164
|
from?: string,
|
|
165
|
+
/** `insertAll` judged every unique key of its batch already, against one index. */
|
|
166
|
+
uniquesJudged = false,
|
|
165
167
|
): Row => {
|
|
166
168
|
// `MoneyInput` lets a writer hand a `bigint`; a stored row holds the value type. The Postgres
|
|
167
169
|
// driver narrows at the same position — its write methods' entry — so without this an
|
|
@@ -176,7 +178,7 @@ export const memoryRepo = <Row>(
|
|
|
176
178
|
judgeReferences(row);
|
|
177
179
|
const key = storeKey(row);
|
|
178
180
|
if (key !== from && rows.has(key)) throw uniqueViolation(entity, `${entity.$table}_pkey`);
|
|
179
|
-
const clash = uniqueClash(entity, row, storedExcept(from));
|
|
181
|
+
const clash = uniquesJudged ? undefined : uniqueClash(entity, row, storedExcept(from));
|
|
180
182
|
if (clash !== undefined) throw uniqueViolation(entity, clash);
|
|
181
183
|
const moved = from !== undefined && from !== key ? rows.get(from) : undefined;
|
|
182
184
|
const previous = rows.get(key);
|
|
@@ -265,14 +267,11 @@ export const memoryRepo = <Row>(
|
|
|
265
267
|
// Narrowed FIRST, so what this loop judges is what `write` will store: `$assert` was handed
|
|
266
268
|
// the caller's `bigint` minor unit here and the narrowed `number` one call later.
|
|
267
269
|
const batch = narrowed(given);
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
for (let at = 0; at < position; at += 1) yield batch[at] as Row;
|
|
272
|
-
},
|
|
273
|
-
});
|
|
270
|
+
// The stored rows' keys once, then each batch row's as it is judged: a row collides with
|
|
271
|
+
// what is stored and with the rows before it, never after — the same first refusal.
|
|
272
|
+
const taken = uniqueIndex(entity, rows.values());
|
|
274
273
|
const seen = new Set<string>();
|
|
275
|
-
for (const
|
|
274
|
+
for (const row of batch) {
|
|
276
275
|
assertRowTenant(entity.$name, entity.$tenantColumn, 'insertAll', row);
|
|
277
276
|
entity.$assert(row);
|
|
278
277
|
judgeReferences(row, batch);
|
|
@@ -282,10 +281,11 @@ export const memoryRepo = <Row>(
|
|
|
282
281
|
throw uniqueViolation(entity, `${entity.$table}_pkey`);
|
|
283
282
|
}
|
|
284
283
|
seen.add(key);
|
|
285
|
-
const clash =
|
|
284
|
+
const clash = taken.clash(row);
|
|
286
285
|
if (clash !== undefined) throw uniqueViolation(entity, clash);
|
|
286
|
+
taken.add(row);
|
|
287
287
|
}
|
|
288
|
-
return batch.map((row) => write(row, options, 'insertAll'));
|
|
288
|
+
return batch.map((row) => write(row, options, 'insertAll', undefined, true));
|
|
289
289
|
}),
|
|
290
290
|
|
|
291
291
|
upsertAll: writing(async (given, args) => {
|
package/src/memory-unique.ts
CHANGED
|
@@ -39,6 +39,54 @@ 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
|
+
/**
|
|
59
|
+
* Every unique key a set of rows holds, one `Set` per constraint: a batch is judged against it row
|
|
60
|
+
* by row, each row bound once. A scan of every other row per row — rebound per constraint — made a
|
|
61
|
+
* 1,000-row `insertAll` take tens of seconds.
|
|
62
|
+
*/
|
|
63
|
+
export interface UniqueIndex<Row> {
|
|
64
|
+
/** The first constraint, in declaration order, `row` collides on — or `undefined`. */
|
|
65
|
+
clash(row: Row): string | undefined;
|
|
66
|
+
add(row: Row): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function uniqueIndex<Row>(entity: EntityCore<Row>, rows: Iterable<Row>): UniqueIndex<Row> {
|
|
70
|
+
const { unique, keys } = keysOf(entity);
|
|
71
|
+
const taken = unique.map(() => new Set<string>());
|
|
72
|
+
const index: UniqueIndex<Row> = {
|
|
73
|
+
clash(row) {
|
|
74
|
+
if (unique.length === 0) return undefined;
|
|
75
|
+
const mine = keys(row);
|
|
76
|
+
const at = mine.findIndex((key, i) => key !== undefined && taken[i]?.has(key) === true);
|
|
77
|
+
return at === -1 ? undefined : unique[at]?.name();
|
|
78
|
+
},
|
|
79
|
+
add(row) {
|
|
80
|
+
if (unique.length === 0) return;
|
|
81
|
+
keys(row).forEach((key, i) => {
|
|
82
|
+
if (key !== undefined) taken[i]?.add(key);
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
for (const row of rows) index.add(row);
|
|
87
|
+
return index;
|
|
88
|
+
}
|
|
89
|
+
|
|
42
90
|
/**
|
|
43
91
|
* The first unique constraint `candidate` collides with among `others`, or `undefined`. Partial
|
|
44
92
|
* ones are skipped: their predicate is SQL this driver cannot evaluate, and a guess would refuse
|
|
@@ -48,20 +96,5 @@ export const uniqueClash = <Row>(
|
|
|
48
96
|
entity: EntityCore<Row>,
|
|
49
97
|
candidate: Row,
|
|
50
98
|
others: Iterable<Row>,
|
|
51
|
-
): string | undefined =>
|
|
52
|
-
|
|
53
|
-
if (unique.length === 0) return undefined;
|
|
54
|
-
const bound = (row: Row) => bindValues(entity, row as unknown as RowPatch<Row>);
|
|
55
|
-
const incoming = bound(candidate);
|
|
56
|
-
const kinds = kindsOf(entity);
|
|
57
|
-
for (const index of unique) {
|
|
58
|
-
const key = index.columns.map((name) => cellOf(kinds.get(name), incoming.get(name)));
|
|
59
|
-
if (key.some((part) => part === undefined)) continue;
|
|
60
|
-
for (const other of others) {
|
|
61
|
-
const stored = bound(other);
|
|
62
|
-
if (index.columns.every((name, at) => cellOf(kinds.get(name), stored.get(name)) === key[at]))
|
|
63
|
-
return index.name();
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return undefined;
|
|
67
|
-
};
|
|
99
|
+
): string | undefined =>
|
|
100
|
+
uniqueConstraints(entity).length === 0 ? undefined : uniqueIndex(entity, others).clash(candidate);
|
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/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
|
);
|