@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "30.2.0",
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.2.0",
47
- "@ultimat3/db": "30.2.0",
48
- "@ultimat3/schema": "30.2.0",
49
- "@ultimat3/time": "30.2.0"
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
  }
@@ -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<
@@ -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>('jsonb', (value) => {
37
- const result = validate(schema, value);
38
- if (result.issues === undefined) return result.value;
39
- // The ISSUES, never the value: `formatIssues` renders path + message, and a column rejection
40
- // reaches the caller and the log line where a value has no key left to redact.
41
- return refuseColumn(
42
- 'json',
43
- `does not match the column's schema — ${formatIssues(result.issues).join('; ')}`,
44
- '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',
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
  ? {}
@@ -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,
@@ -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 { 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
- const rows = new Map<string, Row>(seed.map((row) => [storeKey(row), row]));
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 = uniqueClash(entity, row, storedExcept(from));
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
- const earlier = (position: number): Iterable<Row> => ({
269
- *[Symbol.iterator]() {
270
- yield* rows.values();
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 [position, row] of batch.entries()) {
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 = uniqueClash(entity, row, earlier(position));
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) => {
@@ -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
- * The first unique constraint `candidate` collides with among `others`, or `undefined`. Partial
44
- * ones are skipped: their predicate is SQL this driver cannot evaluate, and a guess would refuse
45
- * rows Postgres accepts — the one direction that must never happen.
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 const uniqueClash = <Row>(
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
- candidate: Row,
50
- others: Iterable<Row>,
51
- ): string | undefined => {
52
- const unique = uniqueConstraints(entity);
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();
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
- return undefined;
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
+ }
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { narrowRow } from './columns';
11
11
  import type { EntityCore } from './entity';
12
- import { uniqueClash, uniqueViolation } from './memory-unique';
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 = uniqueClash(entity, result, [...untouched, ...judged]);
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
  }
@@ -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 a view entity — every write rejects with
2
- // `X_ENTITY_VIEW_READ_ONLY` before any statement exists, in both drivers, because both return
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
- export const readOnlyRepo = <Row, R extends Repo<Row>>(entityName: string, repo: R): R => {
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 viewReadOnly(entityName, operation);
37
+ throw refusal(operation);
33
38
  },
34
39
  ]),
35
40
  );