@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
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Single responsibility: the contract a custom store implements under `defineDriver` — the raw
|
|
2
|
+
// repository. It receives plans the kit has already scoped (tenant, soft delete, page bound, total
|
|
3
|
+
// order, cursor position), never `FindManyArgs`, so there is no argument a driver could read and
|
|
4
|
+
// skip a guard by. Every guard stays the kit's; the store only stores.
|
|
5
|
+
|
|
6
|
+
import type { AggregateFn } from './aggregate';
|
|
7
|
+
import type { UpsertPlan } from './bulk-write';
|
|
8
|
+
import type { EntityCore } from './entity';
|
|
9
|
+
import type { Transactor, Tx } from './repo';
|
|
10
|
+
import type { QueryPlan } from './tenancy';
|
|
11
|
+
import type { SeriesPoint, SeriesSpec } from './time-bucket';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What a store may declare beyond the required five methods. A call needing an undeclared one is
|
|
15
|
+
* `X_DRIVER_UNSUPPORTED` with the capability in `meta.capability`; a declared one with no method
|
|
16
|
+
* behind it is `X_DRIVER_CAPABILITY_MISSING` at definition.
|
|
17
|
+
*/
|
|
18
|
+
export const DRIVER_CAPABILITIES = [
|
|
19
|
+
'transactions',
|
|
20
|
+
'lock',
|
|
21
|
+
'series',
|
|
22
|
+
'upsert',
|
|
23
|
+
'matches',
|
|
24
|
+
'aggregate:countBy',
|
|
25
|
+
'aggregate:sum',
|
|
26
|
+
'aggregate:avg',
|
|
27
|
+
'aggregate:min',
|
|
28
|
+
'aggregate:max',
|
|
29
|
+
'aggregate:approximateCount',
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
export type DriverCapability = (typeof DRIVER_CAPABILITIES)[number];
|
|
33
|
+
|
|
34
|
+
/** The rows a filtered call addresses. `plan.where` already carries the tenant and soft delete. */
|
|
35
|
+
export interface DriverFilter {
|
|
36
|
+
readonly plan: QueryPlan;
|
|
37
|
+
readonly tx?: Tx | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One page: the rows matching EVERY `plan.where` predicate, ordered by `plan.orderBy`, strictly
|
|
42
|
+
* after `after` (the cursor's sort values, aligned with `plan.orderBy`) when present, at most
|
|
43
|
+
* `limit`. Whole rows — the kit projects `select` itself, after it has checked the scope.
|
|
44
|
+
*/
|
|
45
|
+
export interface DriverRead extends DriverFilter {
|
|
46
|
+
readonly after: readonly unknown[] | undefined;
|
|
47
|
+
readonly limit: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface DriverWrite {
|
|
51
|
+
readonly tx?: Tx | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Rows arrive narrowed, tenant-checked and invariant-judged; a duplicate key is the store's to
|
|
56
|
+
* refuse, with `entityUniqueViolation(entity, constraint)`. Writes are all or nothing per call.
|
|
57
|
+
*/
|
|
58
|
+
/** A column `update` moves forward per row rather than sets: see `DriverRepo.update`. */
|
|
59
|
+
export interface DriverAdvance {
|
|
60
|
+
readonly property: string;
|
|
61
|
+
readonly stepMs: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface DriverRepo<Row> {
|
|
65
|
+
select(read: DriverRead): Promise<readonly Row[]>;
|
|
66
|
+
count(filter: DriverFilter): Promise<number>;
|
|
67
|
+
/** The rows as stored, defaults included, in the order given. */
|
|
68
|
+
insert(rows: readonly Row[], write: DriverWrite): Promise<readonly Row[]>;
|
|
69
|
+
/**
|
|
70
|
+
* Sets `patch` (property names, already parsed) on every matched row; answers how many. With
|
|
71
|
+
* `advance`, the store writes `advance.property` per row as the LATER of the patch's instant and
|
|
72
|
+
* that row's stored instant + `advance.stepMs`, in the same atomic write — a searchable entity's
|
|
73
|
+
* version, strictly increasing per row even when two processes write it in one millisecond.
|
|
74
|
+
* `entityDriverConformance` checks it; `advanceVersion` is the rule for a store merging in code.
|
|
75
|
+
*/
|
|
76
|
+
update(
|
|
77
|
+
filter: DriverFilter,
|
|
78
|
+
patch: Readonly<Record<string, unknown>>,
|
|
79
|
+
advance?: DriverAdvance,
|
|
80
|
+
): Promise<number>;
|
|
81
|
+
delete(filter: DriverFilter): Promise<number>;
|
|
82
|
+
/** `'upsert'`. The plan is `bulk-write.ts`'s — target, overwritten columns — already judged. */
|
|
83
|
+
upsert?(rows: readonly Row[], plan: UpsertPlan, write: DriverWrite): Promise<readonly Row[]>;
|
|
84
|
+
/** `'aggregate:countBy'`. Pairs of value and count; the kit orders and bounds them. */
|
|
85
|
+
countBy?(column: string, filter: DriverFilter): Promise<readonly (readonly [unknown, number])[]>;
|
|
86
|
+
/** `'aggregate:<fn>'`. Decimal text or a money value, never a float; `null` for no rows. */
|
|
87
|
+
aggregate?(fn: AggregateFn, column: string, filter: DriverFilter): Promise<unknown>;
|
|
88
|
+
/** `'aggregate:approximateCount'`. The whole table's estimate; `null` when unknown. */
|
|
89
|
+
approximateCount?(): Promise<number | null>;
|
|
90
|
+
/** `'series'`. `time-bucket.ts` is the meaning: buckets truncated in `spec.timeZone`. */
|
|
91
|
+
series?(spec: SeriesSpec, filter: DriverFilter): Promise<readonly SeriesPoint[]>;
|
|
92
|
+
/** `'lock'`. Holds the one row `filter` addresses for as long as `fn` runs. */
|
|
93
|
+
lock?<R>(filter: DriverFilter, fn: (row: Row | null, tx: Tx) => Promise<R> | R): Promise<R>;
|
|
94
|
+
/** `'lock'`. `fn` alone among callers of `key` (already tenant-qualified by the kit). */
|
|
95
|
+
lockKey?<R>(key: string, fn: (tx: Tx) => Promise<R> | R, tx: Tx | undefined): Promise<R>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface DriverDefinition {
|
|
99
|
+
/** Named in every refusal, so an operator knows which store answered. */
|
|
100
|
+
readonly name: string;
|
|
101
|
+
readonly capabilities: readonly DriverCapability[];
|
|
102
|
+
repo<Row>(entity: EntityCore<Row>): DriverRepo<Row>;
|
|
103
|
+
/** Required by `'transactions'`; without it the driver's `transactor()` refuses. */
|
|
104
|
+
transactor?(): Transactor;
|
|
105
|
+
/** The test seam `Driver.reset?()` forwards to. */
|
|
106
|
+
reset?(): void;
|
|
107
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Single responsibility: which kinds of store this process has constructed a driver for, so a
|
|
2
|
+
// tool that loaded the app can tell a Postgres app from one whose rows live in a store that owns
|
|
3
|
+
// its own schema. `x verify`'s drift step reads it: migrations describe Postgres and nothing else.
|
|
4
|
+
|
|
5
|
+
/** `sql`: Postgres, whose schema `x db` owns. `custom`: a `defineDriver` store, which owns its own. */
|
|
6
|
+
export type DriverStoreKind = 'sql' | 'custom';
|
|
7
|
+
|
|
8
|
+
const constructed = new Map<DriverStoreKind, Set<string>>();
|
|
9
|
+
|
|
10
|
+
/** Called by every driver factory as it is built. Idempotent per name. */
|
|
11
|
+
export const noteDriverStore = (kind: DriverStoreKind, name: string): void => {
|
|
12
|
+
const names = constructed.get(kind) ?? new Set<string>();
|
|
13
|
+
names.add(name);
|
|
14
|
+
constructed.set(kind, names);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** The store names noted so far, per kind. A fresh view: the caller cannot edit the record. */
|
|
18
|
+
export const driverStores = (): Readonly<Record<DriverStoreKind, readonly string[]>> => ({
|
|
19
|
+
sql: [...(constructed.get('sql') ?? [])].sort(),
|
|
20
|
+
custom: [...(constructed.get('custom') ?? [])].sort(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** TEST SEAM: forget every noted store. */
|
|
24
|
+
export const clearDriverStores = (): void => {
|
|
25
|
+
constructed.clear();
|
|
26
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Single responsibility: the read half of a `defineDriver` repository. Each method builds its plan
|
|
2
|
+
// with `plan.ts` — the builders both shipped drivers use — so tenancy, the page bound, the total
|
|
3
|
+
// order and the cursor mean here what they mean on Postgres; the store only answers the plan.
|
|
4
|
+
|
|
5
|
+
import { pageOf } from '@ultimat3/core';
|
|
6
|
+
import { aggregateColumnOf } from './aggregate';
|
|
7
|
+
import { countsFrom, groupColumnOf } from './count-by';
|
|
8
|
+
import { cursorFor, seekFrom } from './cursor';
|
|
9
|
+
import { projectRow } from './driver-rows';
|
|
10
|
+
import type { DriverScope } from './driver-scope';
|
|
11
|
+
import { idPlan, readPlan, singleKeyOf } from './plan';
|
|
12
|
+
import type { Repo } from './repo';
|
|
13
|
+
import { rowKey } from './row-key';
|
|
14
|
+
import { seriesPlanOf } from './time-bucket';
|
|
15
|
+
|
|
16
|
+
export type DriverReads<Row> = Pick<
|
|
17
|
+
Repo<Row>,
|
|
18
|
+
'findById' | 'findMany' | 'count' | 'countBy' | 'aggregate' | 'approximateCount' | 'series'
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
export const driverReads = <Row>(scope: DriverScope<Row>): DriverReads<Row> => {
|
|
22
|
+
const { entity, raw } = scope;
|
|
23
|
+
const series = raw.series;
|
|
24
|
+
return {
|
|
25
|
+
async findById(id, options) {
|
|
26
|
+
const op = 'findById';
|
|
27
|
+
singleKeyOf(entity, op);
|
|
28
|
+
const plan = scope.storePlan(op, idPlan(entity, id, options, op), options?.includeDeleted);
|
|
29
|
+
const tx = scope.txOf(op, options);
|
|
30
|
+
const found = await raw.select({ plan, after: undefined, limit: 1, tx });
|
|
31
|
+
return scope.checked(op, plan, found)[0] ?? null;
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async findMany(args = {}) {
|
|
35
|
+
const op = 'findMany';
|
|
36
|
+
// The cursor is minted and read against the plan `readPlan` built, never the store's copy:
|
|
37
|
+
// `planScope` hashes the predicates, and the soft-delete one is the kit's, not the caller's.
|
|
38
|
+
const plan = readPlan(entity, args, op);
|
|
39
|
+
const after = seekFrom(entity, plan);
|
|
40
|
+
const stored = scope.storePlan(op, plan, args.includeDeleted);
|
|
41
|
+
const tx = scope.txOf(op, args);
|
|
42
|
+
const found = scope.checked(
|
|
43
|
+
op,
|
|
44
|
+
stored,
|
|
45
|
+
await raw.select({ plan: stored, after, limit: plan.limit + 1, tx }),
|
|
46
|
+
);
|
|
47
|
+
const page = found.slice(0, plan.limit);
|
|
48
|
+
const last = page.at(-1);
|
|
49
|
+
const next =
|
|
50
|
+
found.length > plan.limit && last !== undefined
|
|
51
|
+
? cursorFor(entity, plan, last, rowKey(entity, last))
|
|
52
|
+
: null;
|
|
53
|
+
return pageOf(
|
|
54
|
+
page.map((row) => projectRow(entity, plan, row)),
|
|
55
|
+
next,
|
|
56
|
+
);
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async count(args = {}) {
|
|
60
|
+
const op = 'count';
|
|
61
|
+
const plan = scope.storePlan(op, readPlan(entity, args, op), args.includeDeleted);
|
|
62
|
+
return raw.count({ plan, tx: scope.txOf(op, args) });
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async countBy(column, args = {}) {
|
|
66
|
+
const op = 'countBy';
|
|
67
|
+
groupColumnOf(entity, column, op);
|
|
68
|
+
scope.need(op, 'aggregate:countBy');
|
|
69
|
+
const plan = scope.storePlan(op, readPlan(entity, args, op), args.includeDeleted);
|
|
70
|
+
const groups = await raw.countBy?.(column, { plan, tx: scope.txOf(op, args) });
|
|
71
|
+
return countsFrom(entity, column, op, groups ?? []);
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async aggregate(fn, column, args = {}) {
|
|
75
|
+
aggregateColumnOf(entity, fn, column);
|
|
76
|
+
scope.need(fn, `aggregate:${fn}`);
|
|
77
|
+
const plan = scope.storePlan(fn, readPlan(entity, args, fn), args.includeDeleted);
|
|
78
|
+
return raw.aggregate?.(fn, column, { plan, tx: scope.txOf(fn, args) }) ?? null;
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
async approximateCount(args = {}) {
|
|
82
|
+
const op = 'approximateCount';
|
|
83
|
+
// `readPlan` refuses a filter or a tenant-scoped entity first, as on Postgres.
|
|
84
|
+
readPlan(entity, args, op);
|
|
85
|
+
scope.need(op, 'aggregate:approximateCount');
|
|
86
|
+
return (await raw.approximateCount?.()) ?? null;
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
// Present only when declared, so the typed handle answers `X_ENTITY_SERIES_UNSUPPORTED` — the
|
|
90
|
+
// shipped code for a repository without one — rather than a second code for the same fact.
|
|
91
|
+
...(series === undefined || !scope.definition.capabilities.includes('series')
|
|
92
|
+
? {}
|
|
93
|
+
: {
|
|
94
|
+
async series(spec, args = {}) {
|
|
95
|
+
const op = 'series';
|
|
96
|
+
seriesPlanOf(entity, spec);
|
|
97
|
+
const plan = scope.storePlan(op, readPlan(entity, args, op), args.includeDeleted);
|
|
98
|
+
return series.call(raw, spec, { plan, tx: scope.txOf(op, args) });
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
};
|
|
102
|
+
};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Single responsibility: the write half of a `defineDriver` repository. A custom store enforces
|
|
2
|
+
// no CHECK constraint, so EVERY invariant is judged here on the row as it will be stored, with the
|
|
3
|
+
// tenant, before the store is called — and the store is handed only scoped plans and judged rows.
|
|
4
|
+
|
|
5
|
+
import { refuseAbandoned } from '@ultimat3/db';
|
|
6
|
+
import { conflictKeys, upsertPlan } from './bulk-write';
|
|
7
|
+
import { entityNow } from './clock';
|
|
8
|
+
import { narrowRow } from './columns';
|
|
9
|
+
import { byKeys, checkedDelete } from './driver-delete';
|
|
10
|
+
import type { DriverScope } from './driver-scope';
|
|
11
|
+
import {
|
|
12
|
+
assertWrittenInScope,
|
|
13
|
+
sameTenantProperties,
|
|
14
|
+
sameTenantReferences,
|
|
15
|
+
} from './driver-write-scope';
|
|
16
|
+
import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
17
|
+
import { notFound } from './errors';
|
|
18
|
+
import { assertedRowsTooMany, MAX_ASSERTED_ROWS } from './invariants';
|
|
19
|
+
import { mergedRow } from './memory-update';
|
|
20
|
+
import { deletePlan, idPlan, namedColumns, singleKeyOf, updatePlan } from './plan';
|
|
21
|
+
import type { Repo, RepoOptions, Tx } from './repo';
|
|
22
|
+
import { advancingVersionColumn, deletionStamp, VERSION_STEP_MS } from './search-index-version';
|
|
23
|
+
import { writtenRowsSink } from './search-written-rows';
|
|
24
|
+
import { assertRowTenant, type QueryPlan } from './tenancy';
|
|
25
|
+
import type { RowPatch, RowWrite } from './types';
|
|
26
|
+
|
|
27
|
+
export type DriverWrites<Row> = Pick<
|
|
28
|
+
Repo<Row>,
|
|
29
|
+
'insert' | 'insertAll' | 'upsertAll' | 'update' | 'delete' | 'deleteWhere' | 'updateWhere'
|
|
30
|
+
>;
|
|
31
|
+
|
|
32
|
+
export const driverWrites = <Row>(scope: DriverScope<Row>): DriverWrites<Row> => {
|
|
33
|
+
const { entity, raw } = scope;
|
|
34
|
+
const references = sameTenantReferences(entity, scope.rawFor);
|
|
35
|
+
/** What the store handed back, checked against the tenants the write named. */
|
|
36
|
+
const returned = (op: string, written: readonly Row[], rows: readonly Row[]) =>
|
|
37
|
+
assertWrittenInScope(scope.definition.name, entity, op, written, rows);
|
|
38
|
+
|
|
39
|
+
/** Narrowed, tenant-checked and judged as one batch, before any of it is stored. */
|
|
40
|
+
const judged = (op: string, given: readonly RowWrite<Row>[]): readonly Row[] => {
|
|
41
|
+
const batch = given.map((row) => narrowRow<Row>(entity.$columns, row));
|
|
42
|
+
for (const row of batch) {
|
|
43
|
+
assertRowTenant(entity.$name, entity.$tenantColumn, op, row);
|
|
44
|
+
entity.$assert(row);
|
|
45
|
+
}
|
|
46
|
+
return batch;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The rows a write left, read back by their keys alone — a patch may move a row out of the
|
|
51
|
+
* filter that matched it — for the search mirror, which asked (`writtenRowsSink`).
|
|
52
|
+
*/
|
|
53
|
+
const writtenBack = async (
|
|
54
|
+
op: string,
|
|
55
|
+
plan: QueryPlan,
|
|
56
|
+
rows: readonly Row[],
|
|
57
|
+
tx: Tx | undefined,
|
|
58
|
+
) => {
|
|
59
|
+
const keyed = byKeys(scope, { ...plan, where: [] }, rows);
|
|
60
|
+
const read: Row[] = [];
|
|
61
|
+
for (const each of keyed) {
|
|
62
|
+
const found = await raw.select({ plan: each, after: undefined, limit: rows.length, tx });
|
|
63
|
+
read.push(...scope.checked(op, each, found));
|
|
64
|
+
}
|
|
65
|
+
return read;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A patch applied to the rows a plan matches. With an invariant declared, every merged row is
|
|
70
|
+
* judged first, bounded as a judged write is on Postgres; then one store call. Answers how many
|
|
71
|
+
* rows the store wrote. A searchable entity's version is the STORE's to advance, per row and
|
|
72
|
+
* atomically (`DriverRepo.update`'s `advance`): a value the kit computed from a read would let a
|
|
73
|
+
* second process's newer version be written over.
|
|
74
|
+
*/
|
|
75
|
+
const patched = async (
|
|
76
|
+
op: string,
|
|
77
|
+
plan: QueryPlan,
|
|
78
|
+
patch: object,
|
|
79
|
+
options: RepoOptions | undefined,
|
|
80
|
+
): Promise<number> => {
|
|
81
|
+
const tx = scope.txOf(op, options);
|
|
82
|
+
// Nothing to judge: no read-back and no cap — one store call, as Postgres sends one statement.
|
|
83
|
+
// The patch's tenant was judged by the caller; a soft-delete stamp names none.
|
|
84
|
+
const movesReference = namedColumns(patch).some(([key]) =>
|
|
85
|
+
sameTenantProperties(entity).has(key),
|
|
86
|
+
);
|
|
87
|
+
const sink = writtenRowsSink();
|
|
88
|
+
const version = advancingVersionColumn(entity as EntityCore<unknown>);
|
|
89
|
+
const advance =
|
|
90
|
+
version !== undefined && namedColumns(patch).some(([key]) => key === version)
|
|
91
|
+
? { property: version, stepMs: VERSION_STEP_MS }
|
|
92
|
+
: undefined;
|
|
93
|
+
const narrowed = narrowRow<Record<string, unknown>>(
|
|
94
|
+
entity.$columns,
|
|
95
|
+
Object.fromEntries(namedColumns(patch)),
|
|
96
|
+
);
|
|
97
|
+
if (entity.$invariants.length === 0 && !movesReference) {
|
|
98
|
+
// The search mirror asked for the rows: read them first, as the judged path does. Past the
|
|
99
|
+
// judged bound they are not handed over — the mirror logs the write it could not name.
|
|
100
|
+
const before =
|
|
101
|
+
sink === undefined
|
|
102
|
+
? []
|
|
103
|
+
: scope.checked(
|
|
104
|
+
op,
|
|
105
|
+
plan,
|
|
106
|
+
await raw.select({ plan, after: undefined, limit: MAX_ASSERTED_ROWS + 1, tx }),
|
|
107
|
+
);
|
|
108
|
+
const written = await raw.update({ plan, tx }, narrowed, advance);
|
|
109
|
+
if (sink !== undefined && before.length <= MAX_ASSERTED_ROWS) {
|
|
110
|
+
sink(await writtenBack(op, plan, before, tx));
|
|
111
|
+
}
|
|
112
|
+
return written;
|
|
113
|
+
}
|
|
114
|
+
// Every invariant, a SQL-expressible one too: a custom store enforces no CHECK.
|
|
115
|
+
const found = scope.checked(
|
|
116
|
+
op,
|
|
117
|
+
plan,
|
|
118
|
+
await raw.select({ plan, after: undefined, limit: MAX_ASSERTED_ROWS + 1, tx }),
|
|
119
|
+
);
|
|
120
|
+
if (found.length > MAX_ASSERTED_ROWS) throw assertedRowsTooMany(entity.$name, op, found.length);
|
|
121
|
+
const [first] = found;
|
|
122
|
+
if (first === undefined) return 0;
|
|
123
|
+
const results = found.map((row) => mergedRow(entity, row, patch));
|
|
124
|
+
for (const merged of results) {
|
|
125
|
+
assertRowTenant(entity.$name, entity.$tenantColumn, op, merged);
|
|
126
|
+
entity.$assert(merged);
|
|
127
|
+
}
|
|
128
|
+
await references(results);
|
|
129
|
+
// The narrowed values the merge produced — the same for every row — so the store holds what
|
|
130
|
+
// was judged rather than a money `bigint` the row type never carries. The version is the
|
|
131
|
+
// patch's instant: the store advances it per row.
|
|
132
|
+
const merged = mergedRow(entity, first, patch) as Record<string, unknown>;
|
|
133
|
+
const values = Object.fromEntries(namedColumns(patch).map(([key]) => [key, merged[key]]));
|
|
134
|
+
if (advance !== undefined) values[advance.property] = narrowed[advance.property];
|
|
135
|
+
const written = await raw.update({ plan, tx }, values, advance);
|
|
136
|
+
if (sink !== undefined) sink(await writtenBack(op, plan, found, tx));
|
|
137
|
+
return written;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** Hard or soft, decided once so `delete(id)` and `deleteWhere` cannot drift apart. */
|
|
141
|
+
const removed = (op: string, plan: QueryPlan, options: RepoOptions | undefined) =>
|
|
142
|
+
entity.$softDelete
|
|
143
|
+
? patched(
|
|
144
|
+
op,
|
|
145
|
+
plan,
|
|
146
|
+
deletionStamp(entity as EntityCore<unknown>, SOFT_DELETE_COLUMN, entityNow()),
|
|
147
|
+
options,
|
|
148
|
+
)
|
|
149
|
+
: checkedDelete(scope, op, plan, options);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
async insert(values, options) {
|
|
153
|
+
refuseAbandoned();
|
|
154
|
+
const [row] = judged('insert', [values]);
|
|
155
|
+
if (row === undefined) return narrowRow<Row>(entity.$columns, values);
|
|
156
|
+
await references([row]);
|
|
157
|
+
const stored = await raw.insert([row], { tx: scope.txOf('insert', options) });
|
|
158
|
+
const [written] = returned('insert', [row], stored);
|
|
159
|
+
return written ?? row;
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
async insertAll(given, options) {
|
|
163
|
+
refuseAbandoned();
|
|
164
|
+
const batch = judged('insertAll', given);
|
|
165
|
+
if (batch.length === 0) return [];
|
|
166
|
+
await references(batch);
|
|
167
|
+
return returned(
|
|
168
|
+
'insertAll',
|
|
169
|
+
batch,
|
|
170
|
+
await raw.insert(batch, { tx: scope.txOf('insertAll', options) }),
|
|
171
|
+
);
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
async upsertAll(given, args) {
|
|
175
|
+
refuseAbandoned();
|
|
176
|
+
scope.need('upsertAll', 'upsert');
|
|
177
|
+
const batch = given.map((row) => narrowRow<Row>(entity.$columns, row));
|
|
178
|
+
const { onConflict, onMatch = 'update', overwrites } = args;
|
|
179
|
+
const plan = upsertPlan(entity, batch, onConflict, onMatch, overwrites);
|
|
180
|
+
conflictKeys(entity, plan, batch);
|
|
181
|
+
judged('upsertAll', batch);
|
|
182
|
+
await references(batch);
|
|
183
|
+
const stored = (await raw.upsert?.(batch, plan, { tx: scope.txOf('upsertAll', args) })) ?? [];
|
|
184
|
+
return returned('upsertAll', batch, stored);
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
async update(id, given, options) {
|
|
188
|
+
refuseAbandoned();
|
|
189
|
+
const op = 'update';
|
|
190
|
+
const patch = entity.$parsePatch(given);
|
|
191
|
+
const plan = scope.storePlan(op, idPlan(entity, id, options, op));
|
|
192
|
+
assertRowTenant(entity.$name, entity.$tenantColumn, op, patch);
|
|
193
|
+
if (namedColumns(patch).length > 0 && (await patched(op, plan, patch, options)) === 0) {
|
|
194
|
+
throw notFound(entity.$name, id);
|
|
195
|
+
}
|
|
196
|
+
// The row as the store now holds it — what `returning *` answers on Postgres: read by the
|
|
197
|
+
// key the patch left it under, soft-deleted or not, since the patch may have moved either.
|
|
198
|
+
const tx = scope.txOf(op, options);
|
|
199
|
+
// Only a patch that NAMES the stamp may read a soft-deleted row back: an empty patch wrote
|
|
200
|
+
// nothing, so `update(deletedId, {})` stays the `X_NOT_FOUND` both shipped drivers answer.
|
|
201
|
+
const named = namedColumns(patch);
|
|
202
|
+
const movedTo = named.find(([column]) => column === singleKeyOf(entity, op));
|
|
203
|
+
const key = movedTo === undefined ? id : String(movedTo[1]);
|
|
204
|
+
const stamps = entity.$softDelete && named.some(([column]) => column === SOFT_DELETE_COLUMN);
|
|
205
|
+
const reread = scope.storePlan(op, idPlan(entity, key, options, op), stamps);
|
|
206
|
+
const [after] = scope.checked(
|
|
207
|
+
op,
|
|
208
|
+
reread,
|
|
209
|
+
await raw.select({ plan: reread, after: undefined, limit: 1, tx }),
|
|
210
|
+
);
|
|
211
|
+
if (after === undefined) throw notFound(entity.$name, id);
|
|
212
|
+
return after;
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
async delete(id, options) {
|
|
216
|
+
refuseAbandoned();
|
|
217
|
+
const plan = scope.storePlan('delete', idPlan(entity, id, options, 'delete'));
|
|
218
|
+
if ((await removed('delete', plan, options)) === 0) throw notFound(entity.$name, id);
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
async deleteWhere(filter, options) {
|
|
222
|
+
refuseAbandoned();
|
|
223
|
+
const op = 'deleteWhere';
|
|
224
|
+
return removed(op, scope.storePlan(op, deletePlan(entity, filter, options, op)), options);
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
async updateWhere(filter, given, options) {
|
|
228
|
+
refuseAbandoned();
|
|
229
|
+
const op = 'updateWhere';
|
|
230
|
+
const patch = entity.$parsePatch(given) as RowPatch<Row>;
|
|
231
|
+
const plan = scope.storePlan(op, updatePlan(entity, filter, patch, options, op));
|
|
232
|
+
assertRowTenant(entity.$name, entity.$tenantColumn, op, patch);
|
|
233
|
+
return patched(op, plan, patch, options);
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Single responsibility: rows as the driver kit reads them — `selectRows`, the whole read half of
|
|
2
|
+
// an in-process store in one call; the projection `select` asks for; and the scope check every
|
|
3
|
+
// row a custom store returns passes before a caller sees it. Every meaning is borrowed:
|
|
4
|
+
// `matchesPredicate` and `compareByKind` are the memory driver's, which is Postgres' by parity.
|
|
5
|
+
|
|
6
|
+
import { kindOf, valueAt } from './cursor';
|
|
7
|
+
import type { DriverRead } from './driver-kit-types';
|
|
8
|
+
import type { EntityCore } from './entity';
|
|
9
|
+
import { compareByKind, matchesPredicate } from './memory-match';
|
|
10
|
+
import type { Predicate, QueryPlan } from './tenancy';
|
|
11
|
+
|
|
12
|
+
/** Lexicographic over the plan's sort keys, direction applied. */
|
|
13
|
+
const compareRows = <Row>(entity: EntityCore<Row>, plan: QueryPlan, a: unknown, b: unknown) => {
|
|
14
|
+
for (const entry of plan.orderBy) {
|
|
15
|
+
const order = compareByKind(
|
|
16
|
+
kindOf(entity, entry.column),
|
|
17
|
+
valueAt(a, entry.column),
|
|
18
|
+
valueAt(b, entry.column),
|
|
19
|
+
);
|
|
20
|
+
if (order !== 0) return entry.direction === 'desc' ? -order : order;
|
|
21
|
+
}
|
|
22
|
+
return 0;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether `row` sorts strictly after the cursor's values, by the COLUMN's kind (the seek was
|
|
27
|
+
* revived from it, `cursor.ts`): one rule for this kit and the memory driver's binary search.
|
|
28
|
+
*/
|
|
29
|
+
export const afterSeek = <Row>(
|
|
30
|
+
entity: EntityCore<Row>,
|
|
31
|
+
plan: QueryPlan,
|
|
32
|
+
row: unknown,
|
|
33
|
+
seek: readonly unknown[],
|
|
34
|
+
): boolean => {
|
|
35
|
+
for (const [index, entry] of plan.orderBy.entries()) {
|
|
36
|
+
const order = compareByKind(
|
|
37
|
+
kindOf(entity, entry.column),
|
|
38
|
+
valueAt(row, entry.column),
|
|
39
|
+
seek[index],
|
|
40
|
+
);
|
|
41
|
+
if (order !== 0) return (entry.direction === 'desc' ? -order : order) > 0;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A `DriverRead` answered over rows held in process: every predicate, the total order, the
|
|
48
|
+
* cursor position and the page bound. A key-value or document store that cannot evaluate a plan
|
|
49
|
+
* natively hands its candidate rows here and returns what comes back.
|
|
50
|
+
*/
|
|
51
|
+
export const selectRows = <Row>(
|
|
52
|
+
entity: EntityCore<Row>,
|
|
53
|
+
rows: Iterable<Row>,
|
|
54
|
+
read: Pick<DriverRead, 'plan' | 'after' | 'limit'>,
|
|
55
|
+
): Row[] => {
|
|
56
|
+
const { plan, after } = read;
|
|
57
|
+
const matched = [...rows]
|
|
58
|
+
.filter((row) => plan.where.every((predicate) => matchesPredicate(entity, row, predicate)))
|
|
59
|
+
.sort((a, b) => compareRows(entity, plan, a, b));
|
|
60
|
+
const from =
|
|
61
|
+
after === undefined ? matched : matched.filter((row) => afterSeek(entity, plan, row, after));
|
|
62
|
+
return from.slice(0, read.limit);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What `select` returns, as `pg-sql.ts`'s projection reads it: the asked properties, the primary
|
|
67
|
+
* key and the top-level sort keys — without those the page could not mint its next cursor.
|
|
68
|
+
*/
|
|
69
|
+
export const projectRow = <Row>(entity: EntityCore<Row>, plan: QueryPlan, row: Row): Row => {
|
|
70
|
+
if (plan.select === undefined) return row;
|
|
71
|
+
const wanted = new Set([
|
|
72
|
+
...plan.select,
|
|
73
|
+
...entity.$primaryKey,
|
|
74
|
+
...plan.orderBy.map((entry) => entry.column.split('.')[0] ?? entry.column),
|
|
75
|
+
]);
|
|
76
|
+
const source = row as Record<string, unknown>;
|
|
77
|
+
// Built from the row's own keys, so a property the row never held stays absent, as in Postgres.
|
|
78
|
+
return Object.fromEntries(
|
|
79
|
+
Object.keys(source)
|
|
80
|
+
.filter((key) => wanted.has(key))
|
|
81
|
+
.map((key) => [key, source[key]]),
|
|
82
|
+
) as Row;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The predicates a returned row MUST satisfy whatever the store's own semantics: the tenant, the
|
|
87
|
+
* soft-delete stamp and the primary key. The caller's other predicates are the store's to mean —
|
|
88
|
+
* a collation may differ — but these three are the framework's, and a row failing one is a leak.
|
|
89
|
+
*/
|
|
90
|
+
export const structuralPredicates = <Row>(
|
|
91
|
+
entity: EntityCore<Row>,
|
|
92
|
+
plan: QueryPlan,
|
|
93
|
+
softDeleteColumn: string,
|
|
94
|
+
): readonly Predicate[] =>
|
|
95
|
+
plan.where.filter(
|
|
96
|
+
(predicate) =>
|
|
97
|
+
(predicate.column === entity.$tenantColumn && predicate.op === 'eq') ||
|
|
98
|
+
(predicate.column === softDeleteColumn && predicate.op === 'is-null') ||
|
|
99
|
+
(entity.$primaryKey.includes(predicate.column) && predicate.op === 'eq'),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
/** The first structural predicate `row` fails, by column; `undefined` when it is in scope. */
|
|
103
|
+
export const outOfScope = <Row>(
|
|
104
|
+
entity: EntityCore<Row>,
|
|
105
|
+
structural: readonly Predicate[],
|
|
106
|
+
row: unknown,
|
|
107
|
+
): string | undefined =>
|
|
108
|
+
structural.find((predicate) => !matchesPredicate(entity, row, predicate))?.column;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Single responsibility: what every method of a `defineDriver` repository shares — the raw store,
|
|
2
|
+
// the capability check, the plan a store is handed (soft delete folded in, `select` and `cursor`
|
|
3
|
+
// taken out) and the scope check on every row a store hands back.
|
|
4
|
+
|
|
5
|
+
import { driverRowOutOfScope, driverUnsupported } from './driver-errors';
|
|
6
|
+
import type { DriverCapability, DriverDefinition, DriverRepo } from './driver-kit-types';
|
|
7
|
+
import { outOfScope, structuralPredicates } from './driver-rows';
|
|
8
|
+
import type { RawFor } from './driver-write-scope';
|
|
9
|
+
import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
10
|
+
import type { Tx } from './repo';
|
|
11
|
+
import type { QueryPlan } from './tenancy';
|
|
12
|
+
|
|
13
|
+
export interface DriverScope<Row> {
|
|
14
|
+
readonly definition: DriverDefinition;
|
|
15
|
+
readonly entity: EntityCore<Row>;
|
|
16
|
+
readonly raw: DriverRepo<Row>;
|
|
17
|
+
/** Another entity's raw store in this driver — the one a same-tenant reference is read from. */
|
|
18
|
+
readonly rawFor: RawFor;
|
|
19
|
+
/** Refuses `X_DRIVER_UNSUPPORTED` unless the driver declared `capability`. */
|
|
20
|
+
need(operation: string, capability: DriverCapability): void;
|
|
21
|
+
/** The `tx` a write may carry: only a driver that declared `'transactions'` takes one. */
|
|
22
|
+
txOf(operation: string, options: { readonly tx?: Tx } | undefined): Tx | undefined;
|
|
23
|
+
/** The plan as the store sees it. `includeDeleted` is the caller's; writes never pass it. */
|
|
24
|
+
storePlan(operation: string, plan: QueryPlan, includeDeleted?: boolean): QueryPlan;
|
|
25
|
+
/** Every row a store returned, refused whole if one is out of the plan's scope. */
|
|
26
|
+
checked(operation: string, plan: QueryPlan, rows: readonly Row[]): readonly Row[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const driverScope = <Row>(
|
|
30
|
+
definition: DriverDefinition,
|
|
31
|
+
entity: EntityCore<Row>,
|
|
32
|
+
rawFor: RawFor,
|
|
33
|
+
): DriverScope<Row> => {
|
|
34
|
+
const declared: ReadonlySet<DriverCapability> = new Set(definition.capabilities);
|
|
35
|
+
const need = (operation: string, capability: DriverCapability): void => {
|
|
36
|
+
if (!declared.has(capability)) {
|
|
37
|
+
throw driverUnsupported(definition.name, entity.$name, operation, capability);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
definition,
|
|
42
|
+
entity,
|
|
43
|
+
raw: rawFor(entity),
|
|
44
|
+
rawFor,
|
|
45
|
+
need,
|
|
46
|
+
txOf(operation, options) {
|
|
47
|
+
if (options?.tx === undefined) return undefined;
|
|
48
|
+
// A transaction from elsewhere promises atomicity this store cannot give: refused, not
|
|
49
|
+
// ignored — an ignored tx is a rollback that silently keeps the write.
|
|
50
|
+
need(operation, 'transactions');
|
|
51
|
+
return options.tx;
|
|
52
|
+
},
|
|
53
|
+
storePlan(operation, plan, includeDeleted = false) {
|
|
54
|
+
if (plan.where.some((predicate) => predicate.op === 'matches')) need(operation, 'matches');
|
|
55
|
+
const hidden =
|
|
56
|
+
entity.$softDelete && !includeDeleted
|
|
57
|
+
? [{ column: SOFT_DELETE_COLUMN, op: 'is-null' as const }]
|
|
58
|
+
: [];
|
|
59
|
+
return {
|
|
60
|
+
entity: plan.entity,
|
|
61
|
+
where: [...plan.where, ...hidden],
|
|
62
|
+
orderBy: plan.orderBy,
|
|
63
|
+
limit: plan.limit,
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
checked(operation, plan, rows) {
|
|
67
|
+
const structural = structuralPredicates(entity, plan, SOFT_DELETE_COLUMN);
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
const column = outOfScope(entity, structural, row);
|
|
70
|
+
if (column !== undefined) {
|
|
71
|
+
throw driverRowOutOfScope(definition.name, entity.$name, operation, column);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return rows;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
};
|