@ultimat3/entity 30.3.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.3.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.3.0",
47
- "@ultimat3/db": "30.3.0",
48
- "@ultimat3/schema": "30.3.0",
49
- "@ultimat3/time": "30.3.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
  }
@@ -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
 
@@ -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, uniqueIndex, 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: one write asks it once, and an entity with no unique never iterates it. A batch judges
147
- * against `uniqueIndex` instead, built once.
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
@@ -178,7 +170,7 @@ export const memoryRepo = <Row>(
178
170
  judgeReferences(row);
179
171
  const key = storeKey(row);
180
172
  if (key !== from && rows.has(key)) throw uniqueViolation(entity, `${entity.$table}_pkey`);
181
- const clash = uniquesJudged ? undefined : uniqueClash(entity, row, storedExcept(from));
173
+ const clash = uniquesJudged ? undefined : rows.clash(row, from);
182
174
  if (clash !== undefined) throw uniqueViolation(entity, clash);
183
175
  const moved = from !== undefined && from !== key ? rows.get(from) : undefined;
184
176
  const previous = rows.get(key);
@@ -267,9 +259,9 @@ export const memoryRepo = <Row>(
267
259
  // Narrowed FIRST, so what this loop judges is what `write` will store: `$assert` was handed
268
260
  // the caller's `bigint` minor unit here and the narrowed `number` one call later.
269
261
  const batch = narrowed(given);
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());
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);
273
265
  const seen = new Set<string>();
274
266
  for (const row of batch) {
275
267
  assertRowTenant(entity.$name, entity.$tenantColumn, 'insertAll', row);
@@ -55,10 +55,28 @@ const keysOf = <Row>(entity: EntityCore<Row>) => {
55
55
  };
56
56
  };
57
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
+
58
73
  /**
59
- * Every unique key a set of rows holds, one `Set` per constraint: a batch is judged against it row
74
+ * Every unique key a set of rows holds, one count per constraint: a batch is judged against it row
60
75
  * by row, each row bound once. A scan of every other row per row — rebound per constraint — made a
61
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.
62
80
  */
63
81
  export interface UniqueIndex<Row> {
64
82
  /** The first constraint, in declaration order, `row` collides on — or `undefined`. */
@@ -66,21 +84,24 @@ export interface UniqueIndex<Row> {
66
84
  add(row: Row): void;
67
85
  }
68
86
 
69
- export function uniqueIndex<Row>(entity: EntityCore<Row>, rows: Iterable<Row>): UniqueIndex<Row> {
87
+ export function uniqueIndex<Row>(
88
+ entity: EntityCore<Row>,
89
+ rows: Iterable<Row>,
90
+ under?: StoredRows<Row>,
91
+ ): UniqueIndex<Row> {
70
92
  const { unique, keys } = keysOf(entity);
71
- const taken = unique.map(() => new Set<string>());
93
+ const taken: Taken[] = unique.map(() => new Map());
72
94
  const index: UniqueIndex<Row> = {
73
95
  clash(row) {
74
96
  if (unique.length === 0) return undefined;
75
97
  const mine = keys(row);
76
- const at = mine.findIndex((key, i) => key !== undefined && taken[i]?.has(key) === true);
98
+ const at = mine.findIndex(
99
+ (key, i) => holds(taken, i, key) > 0 || (under?.holds(i, key) ?? false),
100
+ );
77
101
  return at === -1 ? undefined : unique[at]?.name();
78
102
  },
79
103
  add(row) {
80
- if (unique.length === 0) return;
81
- keys(row).forEach((key, i) => {
82
- if (key !== undefined) taken[i]?.add(key);
83
- });
104
+ if (unique.length > 0) move(taken, keys(row), 1);
84
105
  },
85
106
  };
86
107
  for (const row of rows) index.add(row);
@@ -88,13 +109,72 @@ export function uniqueIndex<Row>(entity: EntityCore<Row>, rows: Iterable<Row>):
88
109
  }
89
110
 
90
111
  /**
91
- * The first unique constraint `candidate` collides with among `others`, or `undefined`. Partial
92
- * ones are skipped: their predicate is SQL this driver cannot evaluate, and a guess would refuse
93
- * rows Postgres accepts — the one direction that must never happen.
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.
94
118
  */
95
- export const uniqueClash = <Row>(
96
- entity: EntityCore<Row>,
97
- candidate: Row,
98
- others: Iterable<Row>,
99
- ): string | undefined =>
100
- uniqueConstraints(entity).length === 0 ? undefined : uniqueIndex(entity, others).clash(candidate);
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);
139
+ }
140
+ return super.set(key, row);
141
+ }
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
  }
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. */