@ultimat3/entity 30.8.0 → 31.0.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/CLAUDE.md +68 -106
- package/README.md +180 -8
- package/package.json +6 -5
- package/src/adapter.ts +65 -0
- package/src/batch-read.ts +12 -0
- package/src/column.ts +12 -0
- package/src/columns.ts +20 -8
- package/src/cross-tenant.ts +11 -14
- package/src/cursor.ts +3 -0
- package/src/database.ts +2 -2
- package/src/declared-index.ts +72 -0
- package/src/define-driver.ts +156 -0
- package/src/describe.ts +5 -0
- package/src/driver-delete.ts +62 -0
- package/src/driver-errors.ts +80 -0
- package/src/driver-kit-types.ts +107 -0
- package/src/driver-registry.ts +26 -0
- package/src/driver-repo-reads.ts +102 -0
- package/src/driver-repo-writes.ts +236 -0
- package/src/driver-rows.ts +108 -0
- package/src/driver-scope.ts +77 -0
- package/src/driver-write-scope.ts +107 -0
- package/src/entity-error.ts +10 -0
- package/src/entity-parse.ts +3 -2
- package/src/entity-patch.ts +3 -1
- package/src/entity.ts +40 -54
- package/src/enum-column.ts +8 -0
- package/src/index.ts +43 -27
- package/src/json-null.ts +13 -0
- package/src/memory-match.ts +51 -17
- package/src/memory-observed.ts +16 -11
- package/src/memory-repo.ts +46 -99
- package/src/memory-seek.ts +12 -25
- package/src/memory-unique.ts +7 -7
- package/src/memory-update.ts +7 -2
- package/src/memory-upsert.ts +102 -0
- package/src/partition.ts +140 -0
- package/src/personal-data-kind.ts +15 -0
- package/src/personal-data.ts +66 -0
- package/src/pg-driver.ts +83 -30
- package/src/pg-row.ts +7 -1
- package/src/pg-write-sql.ts +39 -4
- package/src/plan.ts +2 -0
- package/src/predicate-screen.ts +36 -0
- package/src/query.ts +13 -4
- package/src/registry.ts +3 -0
- package/src/row-key.ts +19 -0
- package/src/row-observer.ts +38 -90
- package/src/search-index-installed.ts +24 -0
- package/src/search-index-memory.ts +132 -0
- package/src/search-index-postgres.ts +107 -0
- package/src/search-index-sync.ts +96 -0
- package/src/search-index-version.ts +93 -0
- package/src/search-index.ts +168 -0
- package/src/search-mirror-batch.ts +133 -0
- package/src/search-mirrored-repo.ts +98 -0
- package/src/search-repo.ts +91 -0
- package/src/search-written-rows.ts +36 -0
- package/src/tenant-index.ts +113 -0
- package/src/types.ts +12 -0
- package/src/view.ts +2 -1
- package/src/write-commit.ts +28 -0
- package/src/write-tag.ts +6 -2
package/src/memory-observed.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// Single responsibility: the memory driver's half of
|
|
1
|
+
// Single responsibility: the memory driver's half of the `db.statement` event. Generated
|
|
2
2
|
// tests run on this driver, so a loop it never reported was a `statements` assertion that could not
|
|
3
3
|
// fail. One event per repository operation, attributed exactly as `postgresRepo` attributes its
|
|
4
4
|
// statement; nothing is sent, so `text` names the operation and holds no SQL.
|
|
5
5
|
|
|
6
|
-
import { tryUseContext } from '@ultimat3/core';
|
|
7
|
-
import { expectedQueryLoopReason
|
|
6
|
+
import { callerStack, instrument, subscribed, tryUseContext } from '@ultimat3/core';
|
|
7
|
+
import { expectedQueryLoopReason } from '@ultimat3/db';
|
|
8
8
|
import type { MemoryRepo } from './repo';
|
|
9
9
|
|
|
10
10
|
/** What one operation reports: the verb `statementKind` reads, and its row count. */
|
|
@@ -48,11 +48,13 @@ const report = (
|
|
|
48
48
|
op: string,
|
|
49
49
|
verb: Reported['verb'],
|
|
50
50
|
rows: number,
|
|
51
|
+
callerStack: string | undefined,
|
|
51
52
|
error?: unknown,
|
|
52
53
|
): void => {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
if (!subscribed('db.statement')) return;
|
|
55
|
+
// Emitted here, not through `@ultimat3/db`: nothing is sent, so no funnel sees it — this IS the
|
|
56
|
+
// memory driver's funnel, and a subscriber must not be able to tell the two drivers apart.
|
|
57
|
+
instrument('db.statement', {
|
|
56
58
|
text: `${verb} ${entity}.${op} (memory driver, no SQL sent)`,
|
|
57
59
|
values: [],
|
|
58
60
|
// Nothing crossed a wire, and a duration made up of `Map` reads would only rank as noise.
|
|
@@ -61,6 +63,7 @@ const report = (
|
|
|
61
63
|
...(error === undefined ? {} : { error }),
|
|
62
64
|
attribution: { entity, op },
|
|
63
65
|
expected: expectedQueryLoopReason(),
|
|
66
|
+
callerStack,
|
|
64
67
|
});
|
|
65
68
|
};
|
|
66
69
|
|
|
@@ -86,7 +89,7 @@ const pointLookups = (): (() => boolean) => {
|
|
|
86
89
|
|
|
87
90
|
type AnyMethod = (...args: readonly unknown[]) => Promise<unknown>;
|
|
88
91
|
|
|
89
|
-
/** `repo`, each statement-shaped method
|
|
92
|
+
/** `repo`, each statement-shaped method emitting once it settles, as the funnels emit. */
|
|
90
93
|
export function observedMemoryRepo<Row>(entity: string, repo: MemoryRepo<Row>): MemoryRepo<Row> {
|
|
91
94
|
const wrapped: Record<string, AnyMethod> = {};
|
|
92
95
|
const firstInMicrotask = pointLookups();
|
|
@@ -94,26 +97,28 @@ export function observedMemoryRepo<Row>(entity: string, repo: MemoryRepo<Row>):
|
|
|
94
97
|
const method = repo[op] as AnyMethod;
|
|
95
98
|
const { verb, rows } = OPERATIONS[op];
|
|
96
99
|
wrapped[op] = async (...args) => {
|
|
97
|
-
if (
|
|
100
|
+
if (!subscribed('db.statement')) return method(...args);
|
|
98
101
|
// Decided as the call is made: by the time the row is back the microtask has moved on.
|
|
99
102
|
const reports = op !== 'findById' || firstInMicrotask();
|
|
100
103
|
// `postgresRepo` attributes an aggregate to its function, never to `aggregate`.
|
|
101
104
|
const name = op === 'aggregate' ? String(args[0]) : op;
|
|
105
|
+
// Before the `await`, as the Postgres funnel reads it: nothing unless capture is on.
|
|
106
|
+
const stack = callerStack();
|
|
102
107
|
let answer: unknown;
|
|
103
108
|
try {
|
|
104
109
|
answer = await method(...args);
|
|
105
110
|
} catch (error) {
|
|
106
|
-
if (reports) report(entity, name, verb, 0, error);
|
|
111
|
+
if (reports) report(entity, name, verb, 0, stack, error);
|
|
107
112
|
throw error;
|
|
108
113
|
}
|
|
109
|
-
if (reports) report(entity, name, verb, rows(answer));
|
|
114
|
+
if (reports) report(entity, name, verb, rows(answer), stack);
|
|
110
115
|
return answer;
|
|
111
116
|
};
|
|
112
117
|
}
|
|
113
118
|
for (const op of LOCKS) {
|
|
114
119
|
const method = repo[op] as AnyMethod;
|
|
115
120
|
wrapped[op] = async (...args) => {
|
|
116
|
-
report(entity, op, 'select', 1);
|
|
121
|
+
report(entity, op, 'select', 1, callerStack());
|
|
117
122
|
return method(...args);
|
|
118
123
|
};
|
|
119
124
|
}
|
package/src/memory-repo.ts
CHANGED
|
@@ -10,10 +10,10 @@ import { pageOf } from '@ultimat3/core';
|
|
|
10
10
|
import { refuseAbandoned } from '@ultimat3/db';
|
|
11
11
|
import { appendOnlyRepo } from './append-only';
|
|
12
12
|
import { keyOf } from './batch-read';
|
|
13
|
-
import { conflictKeyOf, conflictKeys, upsertPlan } from './bulk-write';
|
|
14
13
|
import { entityNow } from './clock';
|
|
15
14
|
import { narrowRow } from './columns';
|
|
16
15
|
import { cursorFor, kindOf, valueAt } from './cursor';
|
|
16
|
+
import { projectRow } from './driver-rows';
|
|
17
17
|
import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
18
18
|
import { invariantViolated, notFound } from './errors';
|
|
19
19
|
import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
|
|
@@ -29,10 +29,14 @@ import { type MemoryTables, sameTenantJudge } from './memory-same-tenant';
|
|
|
29
29
|
import { afterCursor } from './memory-seek';
|
|
30
30
|
import { StoredRows, uniqueIndex, uniqueViolation } from './memory-unique';
|
|
31
31
|
import { judgeUpdate, mergedRow } from './memory-update';
|
|
32
|
+
import { memoryUpsertAll } from './memory-upsert';
|
|
32
33
|
import { deletePlan, idPlan, readPlan, singleKeyOf, updatePlan } from './plan';
|
|
33
34
|
import type { FindManyArgs, MemoryRepo, RepoOptions } from './repo';
|
|
35
|
+
import { rowKey } from './row-key';
|
|
34
36
|
import { sealedFields } from './sealed';
|
|
35
37
|
import { sealedRepo } from './sealed-repo';
|
|
38
|
+
import { deletionStamp } from './search-index-version';
|
|
39
|
+
import { writtenRowsSink } from './search-written-rows';
|
|
36
40
|
import type { QueryPlan } from './tenancy';
|
|
37
41
|
import { assertRowTenant } from './tenancy';
|
|
38
42
|
import type { RowWrite } from './types';
|
|
@@ -51,16 +55,8 @@ export const memoryRepo = <Row>(
|
|
|
51
55
|
/** The driver's other tables, for a same-tenant reference. Alone, this repository sees itself. */
|
|
52
56
|
tables: MemoryTables = new Map(),
|
|
53
57
|
): MemoryRepo<Row> => {
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
-
* `uuid` as a VALUE and prints it lower-cased, so `findById(UPPER)` reads the row there while
|
|
57
|
-
* `String(...)` missed it here: `null` from a read and `X_NOT_FOUND` from a write, against a row
|
|
58
|
-
* that exists, reachable from a path parameter, a client-supplied id or a legacy import.
|
|
59
|
-
*/
|
|
60
|
-
const storeKey = (row: unknown): string =>
|
|
61
|
-
entity.$primaryKey
|
|
62
|
-
.map((property) => keyOf(kindOf(entity, property) ?? '', field(row, property)))
|
|
63
|
-
.join('');
|
|
58
|
+
/** A stored row's key — `row-key.ts`, the one spelling both drivers use. */
|
|
59
|
+
const storeKey = (row: unknown): string => rowKey(entity, row);
|
|
64
60
|
/** The same key, from the id a caller named rather than from a row it has in hand. */
|
|
65
61
|
const idStoreKey = (id: unknown, operation: string): string =>
|
|
66
62
|
keyOf(kindOf(entity, singleKeyOf(entity, operation)) ?? '', id);
|
|
@@ -105,6 +101,9 @@ export const memoryRepo = <Row>(
|
|
|
105
101
|
const changed = memoryLifecycle(entity as EntityCore, tables);
|
|
106
102
|
const judgeReferences = sameTenantJudge(entity, tables);
|
|
107
103
|
const locks = memoryLocks();
|
|
104
|
+
/** The stamp a soft delete writes: `deletedAt`, and a searchable entity's version advanced. */
|
|
105
|
+
const softDeletion = () =>
|
|
106
|
+
deletionStamp(entity as EntityCore<unknown>, SOFT_DELETE_COLUMN, entityNow());
|
|
108
107
|
/** One guard for every write, as `postgresRepo`'s `writing()`: async, so a refusal rejects. */
|
|
109
108
|
const writing =
|
|
110
109
|
<A extends unknown[], R>(body: (...args: A) => Promise<R>) =>
|
|
@@ -113,15 +112,21 @@ export const memoryRepo = <Row>(
|
|
|
113
112
|
return body(...args);
|
|
114
113
|
};
|
|
115
114
|
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
/** Whether a soft-deleted row is in the read: hidden unless the caller asked to include it. */
|
|
116
|
+
const visibleTo =
|
|
117
|
+
(args: FindManyArgs) =>
|
|
118
|
+
(row: Row): boolean =>
|
|
118
119
|
!entity.$softDelete ||
|
|
119
120
|
args.includeDeleted === true ||
|
|
120
121
|
field(row, SOFT_DELETE_COLUMN) === null ||
|
|
121
122
|
field(row, SOFT_DELETE_COLUMN) === undefined;
|
|
123
|
+
const matchesPlan = (plan: QueryPlan, row: Row): boolean =>
|
|
124
|
+
plan.where.every((predicate) => matchesPredicate(entity, row, predicate));
|
|
125
|
+
|
|
126
|
+
const rowsOf = (plan: QueryPlan, args: FindManyArgs): Row[] => {
|
|
122
127
|
return [...rows.values()]
|
|
123
|
-
.filter((row) => plan
|
|
124
|
-
.filter(
|
|
128
|
+
.filter((row) => matchesPlan(plan, row))
|
|
129
|
+
.filter(visibleTo(args))
|
|
125
130
|
.sort((left, right) => {
|
|
126
131
|
for (const entry of plan.orderBy) {
|
|
127
132
|
const order = compareByKind(
|
|
@@ -226,12 +231,17 @@ export const memoryRepo = <Row>(
|
|
|
226
231
|
// synchronously, or half the call sites would need two error paths. Every write asks
|
|
227
232
|
// `refuseAbandoned()` first, as `postgresRepo`'s `writing()` does (`write-abandon.test.ts`).
|
|
228
233
|
const repo: MemoryRepo<Row> = {
|
|
234
|
+
// Keyed, never a scan: the plan is still built — it is where tenancy is applied and refused —
|
|
235
|
+
// and the one row under the id must pass every predicate in it and be visible, as `rowsOf`
|
|
236
|
+
// would judge it. Filtering and sorting the table cost 6.49 ms at 20k rows.
|
|
229
237
|
async findById(id, options) {
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
'findById',
|
|
233
|
-
|
|
234
|
-
|
|
238
|
+
const args: FindManyArgs = {
|
|
239
|
+
...options,
|
|
240
|
+
where: [{ column: singleKeyOf(entity, 'findById'), op: 'eq', value: id }],
|
|
241
|
+
};
|
|
242
|
+
const plan = readPlan(entity, args, 'findById');
|
|
243
|
+
const row = rows.get(idStoreKey(id, 'findById'));
|
|
244
|
+
return row !== undefined && visibleTo(args)(row) && matchesPlan(plan, row) ? row : null;
|
|
235
245
|
},
|
|
236
246
|
|
|
237
247
|
async findMany(args = {}) {
|
|
@@ -240,8 +250,10 @@ export const memoryRepo = <Row>(
|
|
|
240
250
|
const page = found.slice(start, start + plan.limit);
|
|
241
251
|
const last = page.at(-1);
|
|
242
252
|
const more = start + page.length < found.length;
|
|
253
|
+
// Projected as `pg-sql.ts` projects (`driver-rows.ts`): the asked columns, the key and the
|
|
254
|
+
// sort keys — the cursor is minted from the stored row, which holds all of them anyway.
|
|
243
255
|
return pageOf(
|
|
244
|
-
page,
|
|
256
|
+
page.map((row) => projectRow(entity, plan, row)),
|
|
245
257
|
more && last !== undefined ? cursorFor(entity, plan, last, storeKey(last)) : null,
|
|
246
258
|
);
|
|
247
259
|
},
|
|
@@ -280,69 +292,9 @@ export const memoryRepo = <Row>(
|
|
|
280
292
|
return batch.map((row) => write(row, options, 'insertAll', undefined, true));
|
|
281
293
|
}),
|
|
282
294
|
|
|
283
|
-
upsertAll: writing(async (given, args) =>
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
// let a row naming another tenant through whenever it happened to collide. Narrowed first
|
|
287
|
-
// for the reason `insertAll` above is, and before `conflictKeyOf` reads a target too.
|
|
288
|
-
const batch = narrowed(given);
|
|
289
|
-
// Plan first, then rows: the order `postgresRepo` refuses in, so one bad call has one code.
|
|
290
|
-
const { onConflict, onMatch = 'update', overwrites } = args;
|
|
291
|
-
const plan = upsertPlan(entity, batch, onConflict, onMatch, overwrites);
|
|
292
|
-
const keys = conflictKeys(entity, plan, batch);
|
|
293
|
-
for (const row of batch) {
|
|
294
|
-
assertRowTenant(entity.$name, entity.$tenantColumn, 'upsertAll', row);
|
|
295
|
-
entity.$assert(row);
|
|
296
|
-
}
|
|
297
|
-
// The stored rows under the same key, so "does this collide" is the question the unique
|
|
298
|
-
// index answers in Postgres and not a scan per row. A soft-deleted row still occupies its
|
|
299
|
-
// key here, because the index it would collide with there is not partial either — and a row
|
|
300
|
-
// whose target holds a null occupies none, because the index is `NULLS DISTINCT`.
|
|
301
|
-
const stored = new Map<string, Row>();
|
|
302
|
-
for (const row of rows.values()) {
|
|
303
|
-
const key = conflictKeyOf(entity, plan.on, row);
|
|
304
|
-
if (key !== undefined) stored.set(key, row);
|
|
305
|
-
}
|
|
306
|
-
// A row that lands is judged before any of them does: the statement refuses as one. One left
|
|
307
|
-
// alone by `do nothing` wrote nothing, so its reference is checked by nobody on either driver.
|
|
308
|
-
for (const [position, row] of batch.entries()) {
|
|
309
|
-
const key = keys[position];
|
|
310
|
-
const collides = key !== undefined && stored.has(key);
|
|
311
|
-
if (!collides || plan.set.length > 0) judgeReferences(row, batch);
|
|
312
|
-
}
|
|
313
|
-
const written: Row[] = [];
|
|
314
|
-
// A row colliding on ANOTHER unique takes the rows before it with it, as the statement does.
|
|
315
|
-
undoingBatch(args.tx, (tx) => {
|
|
316
|
-
for (const [position, row] of batch.entries()) {
|
|
317
|
-
const key = keys[position];
|
|
318
|
-
const existing = key === undefined ? undefined : stored.get(key);
|
|
319
|
-
// `do nothing` writes no row, and `returning *` therefore names none: a skipped row is
|
|
320
|
-
// absent from the result rather than present and unchanged.
|
|
321
|
-
if (existing !== undefined && plan.set.length === 0) continue;
|
|
322
|
-
const merged =
|
|
323
|
-
existing === undefined
|
|
324
|
-
? row
|
|
325
|
-
: Object.assign(
|
|
326
|
-
{},
|
|
327
|
-
existing,
|
|
328
|
-
Object.fromEntries(plan.set.map((property) => [property, field(row, property)])),
|
|
329
|
-
);
|
|
330
|
-
// `UpsertArgs extends RepoOptions`, so the args ARE the options — one bag, and a `tx`
|
|
331
|
-
// passed to an upsert registers its undo exactly as it does for every other write here.
|
|
332
|
-
const result = write(
|
|
333
|
-
merged,
|
|
334
|
-
{ tx },
|
|
335
|
-
'upsertAll',
|
|
336
|
-
existing === undefined ? undefined : storeKey(existing),
|
|
337
|
-
);
|
|
338
|
-
// Filed as it lands, so a later row of the same batch collides with an earlier one exactly
|
|
339
|
-
// as it would with a row the request stored a moment before it.
|
|
340
|
-
if (key !== undefined) stored.set(key, result);
|
|
341
|
-
written.push(result);
|
|
342
|
-
}
|
|
343
|
-
});
|
|
344
|
-
return written;
|
|
345
|
-
}),
|
|
295
|
+
upsertAll: writing(async (given, args) =>
|
|
296
|
+
memoryUpsertAll({ entity, rows, write, storeKey, judgeReferences }, narrowed(given), args),
|
|
297
|
+
),
|
|
346
298
|
|
|
347
299
|
update: writing(async (id, given, options) => {
|
|
348
300
|
// Here and in `pg-driver.ts`, the one door: a value `insert` refuses, a fragment included.
|
|
@@ -359,13 +311,10 @@ export const memoryRepo = <Row>(
|
|
|
359
311
|
return locks.turn(`row:${idStoreKey(id, 'delete')}`, options?.tx, () => {
|
|
360
312
|
const current = addressed(id, options, 'delete');
|
|
361
313
|
// Soft delete hides the row without losing it; the column's presence is the switch.
|
|
314
|
+
// The deleted row, for the search mirror when it asked (`search-written-rows.ts`).
|
|
315
|
+
writtenRowsSink()?.([current]);
|
|
362
316
|
if (entity.$softDelete) {
|
|
363
|
-
write(
|
|
364
|
-
Object.assign({}, current, { [SOFT_DELETE_COLUMN]: entityNow() }),
|
|
365
|
-
options,
|
|
366
|
-
'delete',
|
|
367
|
-
storeKey(current),
|
|
368
|
-
);
|
|
317
|
+
write(mergedRow(entity, current, softDeletion()), options, 'delete', storeKey(current));
|
|
369
318
|
return;
|
|
370
319
|
}
|
|
371
320
|
const key = storeKey(current);
|
|
@@ -389,12 +338,7 @@ export const memoryRepo = <Row>(
|
|
|
389
338
|
undoingBatch(options?.tx, (tx) => {
|
|
390
339
|
for (const row of doomed) {
|
|
391
340
|
if (entity.$softDelete) {
|
|
392
|
-
write(
|
|
393
|
-
Object.assign({}, row, { [SOFT_DELETE_COLUMN]: entityNow() }),
|
|
394
|
-
{ tx },
|
|
395
|
-
'deleteWhere',
|
|
396
|
-
storeKey(row),
|
|
397
|
-
);
|
|
341
|
+
write(mergedRow(entity, row, softDeletion()), { tx }, 'deleteWhere', storeKey(row));
|
|
398
342
|
continue;
|
|
399
343
|
}
|
|
400
344
|
const key = storeKey(row);
|
|
@@ -403,6 +347,8 @@ export const memoryRepo = <Row>(
|
|
|
403
347
|
changed(row, undefined, tx);
|
|
404
348
|
}
|
|
405
349
|
});
|
|
350
|
+
// Past the judged bound the rows are not handed over, as Postgres's `counted` decides.
|
|
351
|
+
if (doomed.length <= MAX_ASSERTED_ROWS) writtenRowsSink()?.(doomed);
|
|
406
352
|
return doomed.length;
|
|
407
353
|
}),
|
|
408
354
|
|
|
@@ -432,9 +378,10 @@ export const memoryRepo = <Row>(
|
|
|
432
378
|
// statement back; here nothing was written yet, so nothing has to be undone.
|
|
433
379
|
const merged = judgeUpdate(entity, found, patch, untouched(found), storeKey);
|
|
434
380
|
for (const row of merged) judgeReferences(row);
|
|
435
|
-
|
|
436
|
-
write(row, options, 'updateWhere', storeKey(found[
|
|
437
|
-
|
|
381
|
+
const written = merged.map((row, at) =>
|
|
382
|
+
write(row, options, 'updateWhere', storeKey(found[at])),
|
|
383
|
+
);
|
|
384
|
+
if (written.length <= MAX_ASSERTED_ROWS) writtenRowsSink()?.(written);
|
|
438
385
|
return found.length;
|
|
439
386
|
}),
|
|
440
387
|
|
package/src/memory-seek.ts
CHANGED
|
@@ -1,32 +1,11 @@
|
|
|
1
1
|
// Single responsibility: where a cursor's next page starts among the memory driver's sorted rows.
|
|
2
2
|
// Split from `memory-repo.ts` for its line ceiling.
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { seekFrom } from './cursor';
|
|
5
|
+
import { afterSeek } from './driver-rows';
|
|
5
6
|
import type { EntityCore } from './entity';
|
|
6
|
-
import { compareByKind } from './memory-match';
|
|
7
7
|
import type { QueryPlan } from './tenancy';
|
|
8
8
|
|
|
9
|
-
/** Lexicographic over the sort keys, direction applied. `> 0` means "after the cursor". */
|
|
10
|
-
const compareToSeek = <Row>(
|
|
11
|
-
entity: EntityCore<Row>,
|
|
12
|
-
plan: QueryPlan,
|
|
13
|
-
row: unknown,
|
|
14
|
-
seek: readonly unknown[],
|
|
15
|
-
): number => {
|
|
16
|
-
for (const [index, entry] of plan.orderBy.entries()) {
|
|
17
|
-
// The COLUMN's kind, not the value's: the seek was revived from the same kind (`cursor.ts`),
|
|
18
|
-
// so a `bigint` column compares its stored decimal string against a revived `BigInt` as one
|
|
19
|
-
// number instead of as two pieces of text.
|
|
20
|
-
const order = compareByKind(
|
|
21
|
-
kindOf(entity, entry.column),
|
|
22
|
-
valueAt(row, entry.column),
|
|
23
|
-
seek[index],
|
|
24
|
-
);
|
|
25
|
-
if (order !== 0) return entry.direction === 'desc' ? -order : order;
|
|
26
|
-
}
|
|
27
|
-
return 0;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
9
|
/**
|
|
31
10
|
* Where the next page starts. By sort position, not by the previous row's id: that row may have
|
|
32
11
|
* been deleted between the two requests, and an id that is no longer there would restart
|
|
@@ -39,6 +18,14 @@ export const afterCursor = <Row>(
|
|
|
39
18
|
): number => {
|
|
40
19
|
const seek = seekFrom(entity, plan);
|
|
41
20
|
if (seek === undefined) return 0;
|
|
42
|
-
|
|
43
|
-
|
|
21
|
+
// Binary search: `found` is sorted by the very keys `afterSeek` reads, so "after the cursor"
|
|
22
|
+
// is a suffix. A linear `findIndex` made walking a table page by page quadratic.
|
|
23
|
+
let low = 0;
|
|
24
|
+
let high = found.length;
|
|
25
|
+
while (low < high) {
|
|
26
|
+
const middle = (low + high) >>> 1;
|
|
27
|
+
if (afterSeek(entity, plan, found[middle], seek)) high = middle;
|
|
28
|
+
else low = middle + 1;
|
|
29
|
+
}
|
|
30
|
+
return low;
|
|
44
31
|
};
|
package/src/memory-unique.ts
CHANGED
|
@@ -3,21 +3,21 @@
|
|
|
3
3
|
// duplicate key and ignored `unique()` entirely, so a signup race that is a 409 in production was
|
|
4
4
|
// a quiet overwrite under `x dev` and in every app test.
|
|
5
5
|
|
|
6
|
-
import { driverError } from '@ultimat3/db';
|
|
7
6
|
import { keyOf } from './batch-read';
|
|
8
7
|
import { columnName } from './column';
|
|
8
|
+
import { entityUniqueViolation } from './driver-errors';
|
|
9
9
|
import type { EntityCore } from './entity';
|
|
10
10
|
import { bindValues } from './pg-row';
|
|
11
11
|
import type { RowPatch } from './types';
|
|
12
12
|
import { uniqueConstraints } from './unique-constraints';
|
|
13
13
|
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* The refusal Postgres answers `23505` with: `X_DB_UNIQUE_VIOLATION`, the same code, through
|
|
16
|
+
* `driver-errors.ts` — the one builder every non-Postgres store raises it with. No SQLSTATE is
|
|
17
|
+
* faked: no server said `23505`, so `sqlState()` answers `undefined` here.
|
|
18
|
+
*/
|
|
15
19
|
export const uniqueViolation = (entity: EntityCore, constraint: string): Error =>
|
|
16
|
-
|
|
17
|
-
code: '23505',
|
|
18
|
-
constraint,
|
|
19
|
-
message: `duplicate key value violates unique constraint "${constraint}"`,
|
|
20
|
-
});
|
|
20
|
+
entityUniqueViolation(entity, constraint);
|
|
21
21
|
|
|
22
22
|
/** A number's kinds: `'2.5'` and `'2.50'` are one key to Postgres, and so are `7` and `'7'`. */
|
|
23
23
|
const BY_VALUE: ReadonlySet<string> = new Set(['integer', 'bigint', 'numeric']);
|
package/src/memory-update.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { narrowRow } from './columns';
|
|
11
11
|
import type { EntityCore } from './entity';
|
|
12
12
|
import { uniqueIndex, uniqueViolation } from './memory-unique';
|
|
13
|
+
import { advanceVersion } from './search-index-version';
|
|
13
14
|
import { assertRowTenant } from './tenancy';
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -29,8 +30,12 @@ const defined = (patch: object): object =>
|
|
|
29
30
|
* same reason (`memory-repo.ts`), and narrowing twice is harmless: the second pass finds nothing
|
|
30
31
|
* left to change.
|
|
31
32
|
*/
|
|
32
|
-
export const mergedRow = <Row>(entity: EntityCore<Row>, row: Row, patch: object): Row =>
|
|
33
|
-
|
|
33
|
+
export const mergedRow = <Row>(entity: EntityCore<Row>, row: Row, patch: object): Row => {
|
|
34
|
+
const given = defined(patch);
|
|
35
|
+
const merged = narrowRow<Row>(entity.$columns, Object.assign({}, row, given));
|
|
36
|
+
// A searchable entity's version moves past the stored one even inside one millisecond.
|
|
37
|
+
return advanceVersion(entity, row, merged, (property) => Object.hasOwn(given, property));
|
|
38
|
+
};
|
|
34
39
|
|
|
35
40
|
/**
|
|
36
41
|
* The rows a filtered update would write, judged as ONE batch: the patch merged into every
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Single responsibility: the memory driver's `upsertAll` — which incoming row collides with which
|
|
2
|
+
// stored one, and what a collision overwrites — so a test over memory resolves a conflict exactly
|
|
3
|
+
// as `insert … on conflict` does. Split from `memory-repo.ts` for its line ceiling.
|
|
4
|
+
|
|
5
|
+
import { conflictKeyOf, conflictKeys, upsertPlan } from './bulk-write';
|
|
6
|
+
import type { EntityCore } from './entity';
|
|
7
|
+
import { undoingBatch } from './memory-batch';
|
|
8
|
+
import type { RepoOptions, UpsertArgs } from './repo';
|
|
9
|
+
import { advanceVersion } from './search-index-version';
|
|
10
|
+
import { assertRowTenant } from './tenancy';
|
|
11
|
+
import type { RowWrite } from './types';
|
|
12
|
+
|
|
13
|
+
const field = (row: unknown, property: string): unknown =>
|
|
14
|
+
typeof row === 'object' && row !== null ? (row as Record<string, unknown>)[property] : undefined;
|
|
15
|
+
|
|
16
|
+
/** What the upsert needs of the repository it belongs to: its rows and its one write. */
|
|
17
|
+
export interface MemoryUpsertScope<Row> {
|
|
18
|
+
readonly entity: EntityCore<Row>;
|
|
19
|
+
readonly rows: { values(): Iterable<Row> };
|
|
20
|
+
write(
|
|
21
|
+
given: RowWrite<Row>,
|
|
22
|
+
options: RepoOptions | undefined,
|
|
23
|
+
operation: string,
|
|
24
|
+
from?: string,
|
|
25
|
+
): Row;
|
|
26
|
+
storeKey(row: unknown): string;
|
|
27
|
+
judgeReferences(row: Row, batch?: readonly Row[]): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `batch` is already narrowed: the rows as they would be stored. */
|
|
31
|
+
export const memoryUpsertAll = <Row>(
|
|
32
|
+
{ entity, rows, write, storeKey, judgeReferences }: MemoryUpsertScope<Row>,
|
|
33
|
+
batch: readonly Row[],
|
|
34
|
+
args: UpsertArgs<Row>,
|
|
35
|
+
): readonly Row[] => {
|
|
36
|
+
// The INCOMING rows, judged before any of them is matched: under `onMatch: 'nothing'` a
|
|
37
|
+
// colliding row is skipped and never reaches `write()`, so checking only what lands would
|
|
38
|
+
// let a row naming another tenant through whenever it happened to collide. The caller narrowed
|
|
39
|
+
// them, for the reason its `insertAll` does, and before `conflictKeyOf` reads a target too.
|
|
40
|
+
// Plan first, then rows: the order `postgresRepo` refuses in, so one bad call has one code.
|
|
41
|
+
const { onConflict, onMatch = 'update', overwrites } = args;
|
|
42
|
+
const plan = upsertPlan(entity, batch, onConflict, onMatch, overwrites);
|
|
43
|
+
const keys = conflictKeys(entity, plan, batch);
|
|
44
|
+
for (const row of batch) {
|
|
45
|
+
assertRowTenant(entity.$name, entity.$tenantColumn, 'upsertAll', row);
|
|
46
|
+
entity.$assert(row);
|
|
47
|
+
}
|
|
48
|
+
// The stored rows under the same key, so "does this collide" is the question the unique
|
|
49
|
+
// index answers in Postgres and not a scan per row. A soft-deleted row still occupies its
|
|
50
|
+
// key here, because the index it would collide with there is not partial either — and a row
|
|
51
|
+
// whose target holds a null occupies none, because the index is `NULLS DISTINCT`.
|
|
52
|
+
const stored = new Map<string, Row>();
|
|
53
|
+
for (const row of rows.values()) {
|
|
54
|
+
const key = conflictKeyOf(entity, plan.on, row);
|
|
55
|
+
if (key !== undefined) stored.set(key, row);
|
|
56
|
+
}
|
|
57
|
+
// A row that lands is judged before any of them does: the statement refuses as one. One left
|
|
58
|
+
// alone by `do nothing` wrote nothing, so its reference is checked by nobody on either driver.
|
|
59
|
+
for (const [position, row] of batch.entries()) {
|
|
60
|
+
const key = keys[position];
|
|
61
|
+
const collides = key !== undefined && stored.has(key);
|
|
62
|
+
if (!collides || plan.set.length > 0) judgeReferences(row, batch);
|
|
63
|
+
}
|
|
64
|
+
const written: Row[] = [];
|
|
65
|
+
// A row colliding on ANOTHER unique takes the rows before it with it, as the statement does.
|
|
66
|
+
undoingBatch(args.tx, (tx) => {
|
|
67
|
+
for (const [position, row] of batch.entries()) {
|
|
68
|
+
const key = keys[position];
|
|
69
|
+
const existing = key === undefined ? undefined : stored.get(key);
|
|
70
|
+
// `do nothing` writes no row, and `returning *` therefore names none: a skipped row is
|
|
71
|
+
// absent from the result rather than present and unchanged.
|
|
72
|
+
if (existing !== undefined && plan.set.length === 0) continue;
|
|
73
|
+
// A collision's version moves past the stored one, as an update's does (`advanceVersion`).
|
|
74
|
+
const merged =
|
|
75
|
+
existing === undefined
|
|
76
|
+
? row
|
|
77
|
+
: advanceVersion(
|
|
78
|
+
entity,
|
|
79
|
+
existing,
|
|
80
|
+
Object.assign(
|
|
81
|
+
{},
|
|
82
|
+
existing,
|
|
83
|
+
Object.fromEntries(plan.set.map((property) => [property, field(row, property)])),
|
|
84
|
+
),
|
|
85
|
+
(property) => plan.set.includes(property),
|
|
86
|
+
);
|
|
87
|
+
// `UpsertArgs extends RepoOptions`, so the args ARE the options — one bag, and a `tx`
|
|
88
|
+
// passed to an upsert registers its undo exactly as it does for every other write here.
|
|
89
|
+
const result = write(
|
|
90
|
+
merged,
|
|
91
|
+
{ tx },
|
|
92
|
+
'upsertAll',
|
|
93
|
+
existing === undefined ? undefined : storeKey(existing),
|
|
94
|
+
);
|
|
95
|
+
// Filed as it lands, so a later row of the same batch collides with an earlier one exactly
|
|
96
|
+
// as it would with a row the request stored a moment before it.
|
|
97
|
+
if (key !== undefined) stored.set(key, result);
|
|
98
|
+
written.push(result);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
return written;
|
|
102
|
+
};
|