@ultimat3/entity 30.9.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.ts +40 -54
- package/src/enum-column.ts +8 -0
- package/src/index.ts +43 -27
- package/src/memory-match.ts +35 -5
- 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-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/write-commit.ts +28 -0
- package/src/write-tag.ts +6 -2
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
|
+
};
|
package/src/partition.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Single responsibility: `entity({ partitionBy })` checked where it is written. Postgres' rules
|
|
2
|
+
// become declaration errors here — the key and every unique index hold the partition column, the
|
|
3
|
+
// column is a timestamp or a date — so `x db gen` never writes a migration the server refuses.
|
|
4
|
+
// The SQL (the `partition by` clause, creating ahead, retention) is `@ultimat3/db`'s.
|
|
5
|
+
|
|
6
|
+
import { renderFixShellArg } from '@ultimat3/core';
|
|
7
|
+
import { PARTITION_INTERVALS, type PartitionInterval } from '@ultimat3/db';
|
|
8
|
+
import { parseDuration } from '@ultimat3/time';
|
|
9
|
+
import { columnName } from './column';
|
|
10
|
+
import { EntityError } from './errors';
|
|
11
|
+
import type { AnyColumn, IndexDef } from './types';
|
|
12
|
+
|
|
13
|
+
const INTERVALS: readonly string[] = PARTITION_INTERVALS;
|
|
14
|
+
const KINDS: readonly string[] = ['timestamptz', 'date'];
|
|
15
|
+
|
|
16
|
+
/** Partitions kept ready past the current one, when `ahead` is not said. */
|
|
17
|
+
export const DEFAULT_PARTITIONS_AHEAD = 3;
|
|
18
|
+
/** Postgres' NAMEDATALEN - 1, less the `_pYYYYMMDD` every partition name adds. */
|
|
19
|
+
const MAX_PARENT_BYTES = 63 - 10;
|
|
20
|
+
|
|
21
|
+
/** What `partitionBy` declares. A range by `column`, one partition per `interval`, UTC bounds. */
|
|
22
|
+
export interface PartitionInit<K extends string = string> {
|
|
23
|
+
readonly column: K;
|
|
24
|
+
readonly interval: PartitionInterval;
|
|
25
|
+
/** Partitions created past the current one, by the migration and by every maintenance run. */
|
|
26
|
+
readonly ahead?: number;
|
|
27
|
+
/** Whole partitions whose upper bound is older than `after` are detached, then dropped. */
|
|
28
|
+
readonly retain?: { readonly after: string };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The checked declaration, physical column. Structurally `@ultimat3/db`'s `PartitionDescriptionLike`. */
|
|
32
|
+
export interface PartitionDescription {
|
|
33
|
+
readonly column: string;
|
|
34
|
+
readonly interval: PartitionInterval;
|
|
35
|
+
readonly ahead: number;
|
|
36
|
+
readonly retain?: { readonly after: string; readonly afterMs: number } | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The rest of the declaration a partitioning has to agree with. */
|
|
40
|
+
export interface PartitionContext {
|
|
41
|
+
readonly table: string;
|
|
42
|
+
readonly columns: readonly (readonly [string, AnyColumn])[];
|
|
43
|
+
/** Properties, as `primaryKey` names them. */
|
|
44
|
+
readonly primaryKey: readonly string[];
|
|
45
|
+
readonly indexes: readonly IndexDef[];
|
|
46
|
+
/** Unique invariants' physical columns. */
|
|
47
|
+
readonly uniques: readonly (readonly string[])[];
|
|
48
|
+
readonly retain: boolean;
|
|
49
|
+
readonly view: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const partitionInvalid = (entity: string, reason: string, edit: string): EntityError =>
|
|
53
|
+
new EntityError({
|
|
54
|
+
code: 'X_ENTITY_PARTITION_INVALID',
|
|
55
|
+
cause: `entity "${entity}" declares partitionBy, and ${reason}`,
|
|
56
|
+
fix: `x db gen "partition" # after ${edit} in entity('${renderFixShellArg(entity, '<entity>')}')`,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const retainOf = (entity: string, after: unknown): PartitionDescription['retain'] => {
|
|
60
|
+
let afterMs = Number.NaN;
|
|
61
|
+
try {
|
|
62
|
+
afterMs = typeof after === 'string' ? parseDuration(after) : Number.NaN;
|
|
63
|
+
} catch {
|
|
64
|
+
// Reported below with the edit, the one place a bad window is described.
|
|
65
|
+
}
|
|
66
|
+
if (!(afterMs > 0)) {
|
|
67
|
+
throw partitionInvalid(
|
|
68
|
+
entity,
|
|
69
|
+
'partitionBy.retain.after is no window longer than zero',
|
|
70
|
+
"partitionBy.retain: { after: '400d' }",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return { after: String(after), afterMs };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** `null` when `partitionBy` is absent. Every refusal raised here is at declaration. */
|
|
77
|
+
export function partitionOf(
|
|
78
|
+
entity: string,
|
|
79
|
+
init: PartitionInit | undefined,
|
|
80
|
+
context: PartitionContext,
|
|
81
|
+
): PartitionDescription | null {
|
|
82
|
+
if (init === undefined) return null;
|
|
83
|
+
const refuse = (reason: string, edit: string): never => {
|
|
84
|
+
throw partitionInvalid(entity, reason, edit);
|
|
85
|
+
};
|
|
86
|
+
if (context.view) refuse('is a view: a view has no partitions', 'removing partitionBy');
|
|
87
|
+
if (context.retain) {
|
|
88
|
+
refuse(
|
|
89
|
+
'retain sits beside it: a partitioned table drops whole partitions, never rows',
|
|
90
|
+
'moving the window to partitionBy.retain: { after }',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (!INTERVALS.includes(init.interval)) {
|
|
94
|
+
refuse(
|
|
95
|
+
`interval "${String(init.interval)}" is none of ${INTERVALS.join(', ')}`,
|
|
96
|
+
"interval: 'month'",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const ahead = init.ahead ?? DEFAULT_PARTITIONS_AHEAD;
|
|
100
|
+
if (!Number.isInteger(ahead) || ahead < 0 || ahead > 1000) {
|
|
101
|
+
refuse('partitionBy.ahead is no whole number from 0 to 1000', 'ahead: 3');
|
|
102
|
+
}
|
|
103
|
+
if (new TextEncoder().encode(context.table).length > MAX_PARENT_BYTES) {
|
|
104
|
+
refuse(
|
|
105
|
+
`its table name is over ${MAX_PARENT_BYTES} bytes, and each partition adds 10 (_pYYYYMMDD)`,
|
|
106
|
+
"table: '<a shorter name>'",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const column = context.columns.find(([property]) => property === init.column)?.[1];
|
|
110
|
+
if (column === undefined || !KINDS.includes(column.$meta.kind)) {
|
|
111
|
+
throw partitionInvalid(
|
|
112
|
+
entity,
|
|
113
|
+
`partitionBy.column "${String(init.column)}" is no timestamp() or date() column of it`,
|
|
114
|
+
"column: 'at'",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (!context.primaryKey.includes(init.column)) {
|
|
118
|
+
refuse(
|
|
119
|
+
`its primary key (${context.primaryKey.join(', ')}) does not hold "${init.column}": Postgres requires it`,
|
|
120
|
+
`primaryKey: [${[...context.primaryKey, init.column].map((key) => `'${key}'`).join(', ')}]`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const physical = columnName(init.column, column.$meta);
|
|
124
|
+
const unique = [
|
|
125
|
+
...context.indexes.filter((index) => index.unique).map((index) => index.columns),
|
|
126
|
+
...context.uniques,
|
|
127
|
+
].find((columns) => !columns.includes(physical));
|
|
128
|
+
if (unique !== undefined) {
|
|
129
|
+
refuse(
|
|
130
|
+
`a unique index on (${unique.join(', ')}) does not hold "${physical}": Postgres requires it`,
|
|
131
|
+
`adding ${init.column} to that unique index or invariant`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
column: physical,
|
|
136
|
+
interval: init.interval,
|
|
137
|
+
ahead,
|
|
138
|
+
...(init.retain === undefined ? {} : { retain: retainOf(entity, init.retain.after) }),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The closed set of personal-data kinds `.personalData(kind)` takes, and its membership test.
|
|
2
|
+
// Imports nothing, so `column.ts` (the chain) and `types.ts` (the meta) can both read it without
|
|
3
|
+
// a cycle through the readers in `personal-data.ts` — the shape `search.ts` keeps for `.searchable()`.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a marked value IS, so an export and an erase can treat each kind by its own rule: an
|
|
7
|
+
* identifier (a handle, a national id) ties rows to a person; a contact (email, phone, address)
|
|
8
|
+
* reaches one; free text (a bio, a message) may hold either and is read only by a human.
|
|
9
|
+
*/
|
|
10
|
+
export const PERSONAL_DATA_KINDS = ['identifier', 'contact', 'free-text'] as const;
|
|
11
|
+
|
|
12
|
+
export type PersonalDataKind = (typeof PERSONAL_DATA_KINDS)[number];
|
|
13
|
+
|
|
14
|
+
export const isPersonalDataKind = (value: unknown): value is PersonalDataKind =>
|
|
15
|
+
typeof value === 'string' && (PERSONAL_DATA_KINDS as readonly string[]).includes(value);
|