@ultimat3/entity 30.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "30.2.0",
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.2.0",
47
- "@ultimat3/db": "30.2.0",
48
- "@ultimat3/schema": "30.2.0",
49
- "@ultimat3/time": "30.2.0"
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
  }
@@ -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
  ? {}
@@ -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 { 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: 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.
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
- 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
- });
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 [position, row] of batch.entries()) {
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 = uniqueClash(entity, row, earlier(position));
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) => {
@@ -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
- 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();
64
- }
65
- }
66
- return undefined;
67
- };
99
+ ): string | undefined =>
100
+ uniqueConstraints(entity).length === 0 ? undefined : uniqueIndex(entity, others).clash(candidate);
@@ -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 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
  );