@ultimat3/entity 30.3.0 → 30.5.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/columns-data.ts +15 -11
- package/src/memory-repo.ts +8 -16
- package/src/memory-unique.ts +97 -17
- package/src/memory-update.ts +5 -2
- package/src/types.ts +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "30.
|
|
3
|
+
"version": "30.5.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.5.0",
|
|
47
|
+
"@ultimat3/db": "30.5.0",
|
|
48
|
+
"@ultimat3/schema": "30.5.0",
|
|
49
|
+
"@ultimat3/time": "30.5.0"
|
|
50
50
|
}
|
|
51
51
|
}
|
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/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: 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 :
|
|
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
|
-
//
|
|
271
|
-
// what is stored and with the rows before it, never after
|
|
272
|
-
const taken = uniqueIndex(entity, rows
|
|
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);
|
package/src/memory-unique.ts
CHANGED
|
@@ -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
|
|
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>(
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
+
}
|
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/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. */
|