@ultimat3/entity 30.9.0 → 31.1.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/batch-read.ts
CHANGED
|
@@ -46,9 +46,21 @@ export const keyOf = (kind: string, value: unknown): string => {
|
|
|
46
46
|
if (kind === 'uuid') return String(value).toLowerCase();
|
|
47
47
|
if (kind === 'integer' || kind === 'bigint') return canonicalInteger(value) ?? String(value);
|
|
48
48
|
if (kind === 'numeric') return canonicalDecimal(value) ?? String(value);
|
|
49
|
+
if (kind === 'timestamptz') return canonicalInstant(value) ?? String(value);
|
|
49
50
|
return String(value);
|
|
50
51
|
};
|
|
51
52
|
|
|
53
|
+
/**
|
|
54
|
+
* An instant as ISO 8601 in UTC — a `Date` and its ISO text are one key. `String(date)` was the
|
|
55
|
+
* process time zone's spelling: a partitioned table's key held a different id per server, and a
|
|
56
|
+
* search index's id could not be read back into the value it named.
|
|
57
|
+
*/
|
|
58
|
+
const canonicalInstant = (value: unknown): string | undefined => {
|
|
59
|
+
const ms =
|
|
60
|
+
value instanceof Date ? value.getTime() : typeof value === 'string' ? Date.parse(value) : NaN;
|
|
61
|
+
return Number.isFinite(ms) ? new Date(ms).toISOString() : undefined;
|
|
62
|
+
};
|
|
63
|
+
|
|
52
64
|
/** A scope value has to be comparable as a string, or two scopes cannot be told apart. */
|
|
53
65
|
const scopeValue = (value: unknown): string | undefined => {
|
|
54
66
|
if (value === null || value === undefined) return 'null';
|
package/src/column.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// physical name even though two schema modules import each other in a cycle.
|
|
8
8
|
|
|
9
9
|
import { invariantViolated } from './errors';
|
|
10
|
+
import { isPersonalDataKind, PERSONAL_DATA_KINDS } from './personal-data-kind';
|
|
10
11
|
import { refuseColumn } from './refuse';
|
|
11
12
|
import { DEFAULT_SEARCH_WEIGHT, isSearchWeight } from './search';
|
|
12
13
|
import type {
|
|
@@ -216,6 +217,17 @@ export const makeColumn = <T, Optional extends boolean>(
|
|
|
216
217
|
return makeColumn<T, Optional>({ ...meta, searchable: weight }, parse, optional);
|
|
217
218
|
},
|
|
218
219
|
|
|
220
|
+
personalData: (kind) => {
|
|
221
|
+
if (!isPersonalDataKind(kind)) {
|
|
222
|
+
refuseColumn(
|
|
223
|
+
'personal-data',
|
|
224
|
+
`"${String(kind)}" is not a personal data kind`,
|
|
225
|
+
`text().personalData('contact') — one of: ${PERSONAL_DATA_KINDS.join(', ')}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return makeColumn<T, Optional>({ ...meta, personalData: kind }, parse, optional);
|
|
229
|
+
},
|
|
230
|
+
|
|
219
231
|
tenant: () => makeColumn<T, Optional>({ ...meta, tenant: true, index: true }, parse, optional),
|
|
220
232
|
|
|
221
233
|
references: (target, options = {}) => {
|
package/src/columns.ts
CHANGED
|
@@ -163,6 +163,14 @@ export const url = (): Column<string> =>
|
|
|
163
163
|
* it is wrong twice a year on top of being unformattable.
|
|
164
164
|
*/
|
|
165
165
|
export const tz = <const Z extends readonly string[]>(zones: Z): Column<Z[number]> => {
|
|
166
|
+
// An empty set renders `x in ()`, which `x db migrate` dies on with 42601 — refused here instead.
|
|
167
|
+
if (zones.length === 0) {
|
|
168
|
+
refuseColumn(
|
|
169
|
+
'iana-tz',
|
|
170
|
+
'the set of allowed values is empty — no row could ever hold this column',
|
|
171
|
+
"tz(['Europe/Bucharest']) — declare at least one IANA zone; Intl.supportedValuesOf('timeZone') lists them",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
166
174
|
for (const zone of zones) {
|
|
167
175
|
if (!isValidTimeZone(zone)) {
|
|
168
176
|
refuseColumn(
|
|
@@ -190,6 +198,13 @@ export const tz = <const Z extends readonly string[]>(zones: Z): Column<Z[number
|
|
|
190
198
|
const BCP47 = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
|
191
199
|
|
|
192
200
|
export const locale = <const L extends readonly string[]>(locales: L): Column<L[number]> => {
|
|
201
|
+
if (locales.length === 0) {
|
|
202
|
+
refuseColumn(
|
|
203
|
+
'bcp-47',
|
|
204
|
+
'the set of allowed values is empty — no row could ever hold this column',
|
|
205
|
+
"locale(['en']) — declare at least one BCP-47 tag",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
193
208
|
for (const tag of locales) {
|
|
194
209
|
if (!BCP47.test(tag)) {
|
|
195
210
|
refuseColumn(
|
|
@@ -401,17 +416,14 @@ const narrowUuid = <Row>(columns: ColumnMap, row: Row): Row => {
|
|
|
401
416
|
for (const [property, column] of Object.entries(columns)) {
|
|
402
417
|
if (column.$meta.kind !== 'uuid') continue;
|
|
403
418
|
const value = record[property];
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
// the
|
|
419
|
+
// A string Postgres would refuse is refused HERE, in both drivers, with the column parser's
|
|
420
|
+
// message: a repository write is not parsed, so memory stored `…-0000000000p0` that Postgres
|
|
421
|
+
// answered 22P02 for. Anything else non-string is the column parser's (or the server's).
|
|
407
422
|
if (typeof value !== 'string') continue;
|
|
423
|
+
parseUuid(value);
|
|
408
424
|
const lower = value.toLowerCase();
|
|
425
|
+
// No allocation for the overwhelmingly common case, an id already lower-cased (axiom 6).
|
|
409
426
|
if (lower === value) continue;
|
|
410
|
-
// And only a value Postgres would have accepted, which is the whole justification for touching
|
|
411
|
-
// it: the memory driver stores what `$assert` was shown, so a malformed id lower-cased here is
|
|
412
|
-
// a spelling NEITHER driver holds — Postgres refuses that insert outright. Asked last, so the
|
|
413
|
-
// regex is paid only by the rare value that carries an upper-case character at all.
|
|
414
|
-
if (!UUID.test(value)) continue;
|
|
415
427
|
narrowed ??= { ...record };
|
|
416
428
|
narrowed[property] = lower;
|
|
417
429
|
}
|
package/src/cross-tenant.ts
CHANGED
|
@@ -7,7 +7,14 @@
|
|
|
7
7
|
// concurrent requests — one of them ordinary — so it needs an async context. Opened through core's
|
|
8
8
|
// one lazy seam rather than a `node:async_hooks` construction here, which threw at module
|
|
9
9
|
// EVALUATION in a browser bundle (the bundler stubs the module to `{}`).
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
actorLabel,
|
|
12
|
+
assert,
|
|
13
|
+
asyncContext,
|
|
14
|
+
hasScope,
|
|
15
|
+
instrument,
|
|
16
|
+
tryUseContext,
|
|
17
|
+
} from '@ultimat3/core';
|
|
11
18
|
import { crossTenantDenied } from './errors';
|
|
12
19
|
|
|
13
20
|
/**
|
|
@@ -20,18 +27,6 @@ export const CROSS_TENANT_SCOPE = 'tenancy:cross';
|
|
|
20
27
|
|
|
21
28
|
const storage = asyncContext<string>('the cross-tenant reason');
|
|
22
29
|
|
|
23
|
-
let witness: ((reason: string) => void) | undefined;
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Install the one process-wide witness of every scope opened, or remove it with `undefined`. The
|
|
27
|
-
* caller is `@ultimat3/testing`'s tenant proof: a job that crossed tenants under a declared reason
|
|
28
|
-
* is a sweep, one that crossed without one is a leak, and only the scope itself knows which. One
|
|
29
|
-
* witness, not a list (`setStatementObserver`'s rule), told only after the capability is proven.
|
|
30
|
-
*/
|
|
31
|
-
export function setCrossTenantObserver(next: ((reason: string) => void) | undefined): void {
|
|
32
|
-
witness = next;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
30
|
/**
|
|
36
31
|
* Run `fn` with the tenant guard lifted — every read and write it issues, at any depth and across
|
|
37
32
|
* every `await`, may span tenants. For the three cases that genuinely have no single tenant: an
|
|
@@ -59,7 +54,9 @@ export function crossTenant<T>(reason: string, fn: () => T): T {
|
|
|
59
54
|
"pass why the read spans tenants: crossTenant('nightly expiry sweeps every org', fn)",
|
|
60
55
|
);
|
|
61
56
|
assertCrossTenant(reason);
|
|
62
|
-
|
|
57
|
+
// Emitted only after the capability is proven: `@ultimat3/testing`'s tenant proof subscribes, and
|
|
58
|
+
// a job that crossed under a declared reason is a sweep, one that crossed without is a leak.
|
|
59
|
+
instrument('entity.cross_tenant', { reason });
|
|
63
60
|
return storage.run(reason, fn);
|
|
64
61
|
}
|
|
65
62
|
|
package/src/cursor.ts
CHANGED
|
@@ -123,6 +123,9 @@ const serializeSortValue = (kind: ColumnKind, value: unknown): string | undefine
|
|
|
123
123
|
if (kind === 'timestamptz') return instantMicros(value)?.toString();
|
|
124
124
|
if (value instanceof Date) return value.toISOString();
|
|
125
125
|
if (typeof value === 'bigint') return value.toString();
|
|
126
|
+
// A `uuid` as its value, the spelling `keyOf` gives it: a store answering upper case must mint
|
|
127
|
+
// the cursor a lower-case one does, or one position has two cursors.
|
|
128
|
+
if (kind === 'uuid') return String(value).toLowerCase();
|
|
126
129
|
return String(value);
|
|
127
130
|
};
|
|
128
131
|
|
package/src/database.ts
CHANGED
|
@@ -103,8 +103,8 @@ export const database = <E extends EntitySet>(
|
|
|
103
103
|
const tables: Record<string, unknown> = {};
|
|
104
104
|
for (const [key, entity] of Object.entries(entities)) {
|
|
105
105
|
// Wrapped here rather than in a driver, so a committed row change is reported the same whether
|
|
106
|
-
// rows live in memory or in Postgres — `
|
|
107
|
-
// wrapper is one
|
|
106
|
+
// rows live in memory or in Postgres — `entity.row` on the bus, and with no subscriber the
|
|
107
|
+
// wrapper is one map read per write. `related` below stays unwrapped on purpose: a relation
|
|
108
108
|
// preload is a READ, and a change feed has nothing to say about one.
|
|
109
109
|
tables[key] = tableFor(entity, observedRepo(entity, driver.repo(entity)), related);
|
|
110
110
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// One `indexes: [...]` entry of `entity(name, { indexes })` turned into the `IndexDef` it declares,
|
|
2
|
+
// refusing what Postgres would refuse later. Split out of `entity.ts` at the 500-line ceiling.
|
|
3
|
+
|
|
4
|
+
import { invariantViolated } from './errors';
|
|
5
|
+
import type { InvariantColumns, Resolve } from './expr';
|
|
6
|
+
import type { IndexInit } from './index-init';
|
|
7
|
+
import { indexName } from './index-name';
|
|
8
|
+
import type { ColumnMap, IndexDef } from './types';
|
|
9
|
+
|
|
10
|
+
export interface DeclaredIndexContext<C extends ColumnMap> {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly table: string;
|
|
13
|
+
readonly resolve: Resolve;
|
|
14
|
+
readonly columns: InvariantColumns<C>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const declaredIndexOf = <C extends ColumnMap>(
|
|
18
|
+
index: IndexInit<C>,
|
|
19
|
+
context: DeclaredIndexContext<C>,
|
|
20
|
+
): IndexDef => {
|
|
21
|
+
const { name, table, resolve, columns: columnsExpr } = context;
|
|
22
|
+
const columns = index.on.map((property) => resolve([property]));
|
|
23
|
+
const unique = index.unique === true;
|
|
24
|
+
// `on: []` is type-legal and names nothing: the generated DDL would be `on "posts" ()`, a
|
|
25
|
+
// syntax error one migration later. Refused where it was written instead.
|
|
26
|
+
if (columns.length === 0) {
|
|
27
|
+
throw invariantViolated(name, 'index', 'an index must name at least one column');
|
|
28
|
+
}
|
|
29
|
+
const where = index.where?.(columnsExpr).toSql(resolve) ?? null;
|
|
30
|
+
if (index.where !== undefined && where === null) {
|
|
31
|
+
throw invariantViolated(
|
|
32
|
+
name,
|
|
33
|
+
'index',
|
|
34
|
+
'a partial index predicate must be expressible in SQL; a JS predicate cannot be one',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
// Two rules Postgres has that a declaration can break, refused where the author wrote it.
|
|
38
|
+
// `@ultimat3/db` refuses both again at `createIndex` — that is not a duplicate, it is the
|
|
39
|
+
// guard for a description nobody built here — but its refusal lands at `x db gen` or, if a
|
|
40
|
+
// migration was already written, inside `ROLE=migrate` as the server's own syntax error with
|
|
41
|
+
// none of the entity's words in it.
|
|
42
|
+
if (index.using !== undefined && index.using !== 'btree') {
|
|
43
|
+
if (unique) {
|
|
44
|
+
throw invariantViolated(
|
|
45
|
+
name,
|
|
46
|
+
'index',
|
|
47
|
+
`the index on (${columns.join(', ')}) is unique and ${index.using}; ` +
|
|
48
|
+
`Postgres has no unique ${index.using} index — drop unique, or drop using`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (index.order !== undefined) {
|
|
52
|
+
throw invariantViolated(
|
|
53
|
+
name,
|
|
54
|
+
'index',
|
|
55
|
+
`the index on (${columns.join(', ')}) is ${index.using} and ${index.order}; ` +
|
|
56
|
+
'only a btree orders its keys — drop order, or drop using',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
name: indexName(name, table, columns, unique, index.order, where, index.using),
|
|
62
|
+
columns,
|
|
63
|
+
unique,
|
|
64
|
+
...(index.order === undefined ? {} : { order: index.order }),
|
|
65
|
+
...(where === null ? {} : { where }),
|
|
66
|
+
// Absent stays absent: `btree` written out would be a field every existing snapshot lacks,
|
|
67
|
+
// and `indexMethodOf` reads the two the same way precisely so nothing regenerates.
|
|
68
|
+
...(index.using === undefined || index.using === 'btree' ? {} : { using: index.using }),
|
|
69
|
+
// How it is BUILT, so not part of the name: `true` or absent, like `appendOnly`.
|
|
70
|
+
...(index.concurrently === true ? { concurrently: true as const } : {}),
|
|
71
|
+
};
|
|
72
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Single responsibility: `defineDriver` — a custom store (Mongo, Cassandra, key-value) as a `Driver`.
|
|
2
|
+
// The store sees only scoped plans and judged rows; this assembles the read and write halves, the
|
|
3
|
+
// locks and both outer seams (`sealedRepo`, then `appendOnlyRepo`), so nothing a store forgets
|
|
4
|
+
// becomes a leak. Capabilities are declared here and checked when a table opens.
|
|
5
|
+
|
|
6
|
+
import { refuseAbandoned } from '@ultimat3/db';
|
|
7
|
+
import { appendOnlyRepo } from './append-only';
|
|
8
|
+
import type { Driver } from './database';
|
|
9
|
+
import { driverCapabilityMissing, driverUnsupported } from './driver-errors';
|
|
10
|
+
import type { DriverCapability, DriverDefinition, DriverRepo } from './driver-kit-types';
|
|
11
|
+
import { noteDriverStore } from './driver-registry';
|
|
12
|
+
import { driverReads } from './driver-repo-reads';
|
|
13
|
+
import { driverWrites } from './driver-repo-writes';
|
|
14
|
+
import { driverScope } from './driver-scope';
|
|
15
|
+
import type { RawFor } from './driver-write-scope';
|
|
16
|
+
import type { EntityCore } from './entity';
|
|
17
|
+
import { notFound } from './errors';
|
|
18
|
+
import { lockKeyInput } from './lock-key';
|
|
19
|
+
import { idPlan } from './plan';
|
|
20
|
+
import type { Repo, RepoOptions, Tx } from './repo';
|
|
21
|
+
import { sealedRepo } from './sealed-repo';
|
|
22
|
+
import type { IdOf } from './types';
|
|
23
|
+
|
|
24
|
+
export type {
|
|
25
|
+
DriverAdvance,
|
|
26
|
+
DriverCapability,
|
|
27
|
+
DriverDefinition,
|
|
28
|
+
DriverFilter,
|
|
29
|
+
DriverRead,
|
|
30
|
+
DriverRepo,
|
|
31
|
+
DriverWrite,
|
|
32
|
+
} from './driver-kit-types';
|
|
33
|
+
|
|
34
|
+
/** The raw-repo member each capability needs; `transactions` is the definition's `transactor`. */
|
|
35
|
+
const MEMBER: Readonly<Record<DriverCapability, keyof DriverRepo<unknown> | 'transactor'>> = {
|
|
36
|
+
transactions: 'transactor',
|
|
37
|
+
lock: 'lock',
|
|
38
|
+
series: 'series',
|
|
39
|
+
upsert: 'upsert',
|
|
40
|
+
matches: 'select',
|
|
41
|
+
'aggregate:countBy': 'countBy',
|
|
42
|
+
'aggregate:sum': 'aggregate',
|
|
43
|
+
'aggregate:avg': 'aggregate',
|
|
44
|
+
'aggregate:min': 'aggregate',
|
|
45
|
+
'aggregate:max': 'aggregate',
|
|
46
|
+
'aggregate:approximateCount': 'approximateCount',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const assertImplemented = (definition: DriverDefinition, raw: DriverRepo<unknown>): void => {
|
|
50
|
+
for (const capability of definition.capabilities) {
|
|
51
|
+
// Own keys only: a JS caller's `capabilities: ['toString']` must not read the prototype.
|
|
52
|
+
const member = Object.hasOwn(MEMBER, capability) ? MEMBER[capability] : undefined;
|
|
53
|
+
if (member === undefined) {
|
|
54
|
+
throw driverCapabilityMissing(definition.name, String(capability), 'a known capability');
|
|
55
|
+
}
|
|
56
|
+
const present =
|
|
57
|
+
member === 'transactor'
|
|
58
|
+
? typeof definition.transactor === 'function'
|
|
59
|
+
: typeof raw[member] === 'function';
|
|
60
|
+
if (!present) throw driverCapabilityMissing(definition.name, capability, member);
|
|
61
|
+
// `'lock'` is two methods, as `Repo` has: one alone would leave both absent on the repository.
|
|
62
|
+
if (capability === 'lock' && typeof raw.lockKey !== 'function') {
|
|
63
|
+
throw driverCapabilityMissing(definition.name, capability, 'lockKey');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** A `defineDriver` repository for one entity: reads, writes, locks when declared, both seams. */
|
|
69
|
+
const driverRepo = <Row>(
|
|
70
|
+
definition: DriverDefinition,
|
|
71
|
+
entity: EntityCore<Row>,
|
|
72
|
+
rawFor: RawFor,
|
|
73
|
+
): Repo<Row> => {
|
|
74
|
+
const scope = driverScope(definition, entity, rawFor);
|
|
75
|
+
assertImplemented(definition, scope.raw as DriverRepo<unknown>);
|
|
76
|
+
const { raw } = scope;
|
|
77
|
+
// Absent unless declared, so the typed handle answers the shipped `X_LOCK_UNSUPPORTED`.
|
|
78
|
+
const locks = (): Pick<Repo<Row>, 'lock' | 'lockKey'> => {
|
|
79
|
+
const { lock, lockKey } = raw;
|
|
80
|
+
if (lock === undefined || lockKey === undefined) return {};
|
|
81
|
+
if (!definition.capabilities.includes('lock')) return {};
|
|
82
|
+
return {
|
|
83
|
+
async lock<R>(
|
|
84
|
+
id: IdOf<Row>,
|
|
85
|
+
fn: (row: Row, tx: Tx) => Promise<R> | R,
|
|
86
|
+
options?: RepoOptions,
|
|
87
|
+
): Promise<R> {
|
|
88
|
+
refuseAbandoned();
|
|
89
|
+
const plan = scope.storePlan('lock', idPlan(entity, id, options, 'lock'));
|
|
90
|
+
const tx = scope.txOf('lock', options);
|
|
91
|
+
return lock.call<DriverRepo<Row>, Parameters<typeof lock<R>>, Promise<R>>(
|
|
92
|
+
raw,
|
|
93
|
+
{ plan, tx },
|
|
94
|
+
(row, held) => {
|
|
95
|
+
if (row === null) throw notFound(entity.$name, id);
|
|
96
|
+
const [inScope] = scope.checked('lock', plan, [row]);
|
|
97
|
+
return fn(inScope ?? row, held);
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
},
|
|
101
|
+
async lockKey<R>(key: string, fn: (tx: Tx) => Promise<R> | R, options?: RepoOptions) {
|
|
102
|
+
refuseAbandoned();
|
|
103
|
+
return lockKey.call<DriverRepo<Row>, Parameters<typeof lockKey<R>>, Promise<R>>(
|
|
104
|
+
raw,
|
|
105
|
+
lockKeyInput(entity, key, options),
|
|
106
|
+
fn,
|
|
107
|
+
options?.tx,
|
|
108
|
+
);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
const repo: Repo<Row> = { ...driverReads(scope), ...driverWrites(scope), ...locks() };
|
|
113
|
+
return appendOnlyRepo(entity, sealedRepo(entity, repo));
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `database(entities, { driver: defineDriver({ name, capabilities, repo }) })`. Repositories are
|
|
118
|
+
* built once per entity, as `memoryDriver()`'s are, so a store keeping state per table keeps one.
|
|
119
|
+
*/
|
|
120
|
+
export const defineDriver = (definition: DriverDefinition): Driver => {
|
|
121
|
+
if (definition.capabilities.includes('transactions') && definition.transactor === undefined) {
|
|
122
|
+
throw driverCapabilityMissing(definition.name, 'transactions', 'transactor');
|
|
123
|
+
}
|
|
124
|
+
noteDriverStore('custom', definition.name);
|
|
125
|
+
const repos = new Map<string, unknown>();
|
|
126
|
+
// One raw store per entity, shared by its own repository and by every reference read into it.
|
|
127
|
+
const raws = new Map<string, unknown>();
|
|
128
|
+
const rawFor: RawFor = <R>(entity: EntityCore<R>): DriverRepo<R> => {
|
|
129
|
+
const known = raws.get(entity.$name);
|
|
130
|
+
if (known !== undefined) return known as DriverRepo<R>;
|
|
131
|
+
const created = definition.repo(entity);
|
|
132
|
+
raws.set(entity.$name, created);
|
|
133
|
+
return created;
|
|
134
|
+
};
|
|
135
|
+
const transactor = definition.transactor;
|
|
136
|
+
const reset = definition.reset;
|
|
137
|
+
return {
|
|
138
|
+
repo: <Row>(entity: EntityCore<Row>): Repo<Row> => {
|
|
139
|
+
const existing = repos.get(entity.$name);
|
|
140
|
+
// Keyed by entity name and written only from that entity, so the row type is the stored one.
|
|
141
|
+
if (existing !== undefined) return existing as Repo<Row>;
|
|
142
|
+
const created = driverRepo(definition, entity, rawFor);
|
|
143
|
+
repos.set(entity.$name, created);
|
|
144
|
+
return created;
|
|
145
|
+
},
|
|
146
|
+
// No `'transactions'`: a seed dry run (which writes and rolls back) refuses here, by name,
|
|
147
|
+
// rather than writing for real into a store that cannot undo.
|
|
148
|
+
transactor: () => {
|
|
149
|
+
if (transactor === undefined) {
|
|
150
|
+
throw driverUnsupported(definition.name, definition.name, 'transactor', 'transactions');
|
|
151
|
+
}
|
|
152
|
+
return transactor.call(definition);
|
|
153
|
+
},
|
|
154
|
+
...(reset === undefined ? {} : { reset: () => reset.call(definition) }),
|
|
155
|
+
};
|
|
156
|
+
};
|
package/src/describe.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { currencyCheck, scaleCheck } from './columns';
|
|
|
10
10
|
import { EntityError, invariantViolated } from './errors';
|
|
11
11
|
import type { Invariant } from './invariants';
|
|
12
12
|
import type { LifecycleDescription } from './lifecycle';
|
|
13
|
+
import type { PartitionDescription } from './partition';
|
|
13
14
|
import type {
|
|
14
15
|
ColumnDescription,
|
|
15
16
|
EntityDescription,
|
|
@@ -48,6 +49,8 @@ export interface DescribeInput<Row> {
|
|
|
48
49
|
readonly view?: ViewQuery | null | undefined;
|
|
49
50
|
/** `retain`, `history`, `countedIn` counters — each projected only when declared (`lifecycle.ts`). */
|
|
50
51
|
readonly lifecycle?: LifecycleDescription | undefined;
|
|
52
|
+
/** `entity({ partitionBy })`, checked (`partition.ts`) — projected only when declared. */
|
|
53
|
+
readonly partition?: PartitionDescription | null | undefined;
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
/**
|
|
@@ -385,5 +388,7 @@ export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescriptio
|
|
|
385
388
|
},
|
|
386
389
|
}),
|
|
387
390
|
...input.lifecycle,
|
|
391
|
+
// Absent unless declared: no entity that never said `partitionBy` moves the hash.
|
|
392
|
+
...(input.partition == null ? {} : { partition: input.partition }),
|
|
388
393
|
};
|
|
389
394
|
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Single responsibility: a custom store's HARD delete. The rows are read and scope-checked first,
|
|
2
|
+
// then deleted by their checked keys only — a store whose filter ignores the tenant is refused
|
|
3
|
+
// before it removes anything, rather than discovered by the rows another tenant no longer has.
|
|
4
|
+
|
|
5
|
+
import { valueAt } from './cursor';
|
|
6
|
+
import type { DriverScope } from './driver-scope';
|
|
7
|
+
import { describeCommand, EntityError } from './errors';
|
|
8
|
+
import { MAX_ASSERTED_ROWS } from './invariants';
|
|
9
|
+
import type { RepoOptions } from './repo';
|
|
10
|
+
import { writtenRowsSink } from './search-written-rows';
|
|
11
|
+
import type { Predicate, QueryPlan } from './tenancy';
|
|
12
|
+
|
|
13
|
+
const tooMany = (entityName: string, operation: string, matched: number): EntityError =>
|
|
14
|
+
new EntityError({
|
|
15
|
+
code: 'X_INVARIANT_VIOLATED',
|
|
16
|
+
cause: `${entityName}.${operation}() matches more than ${MAX_ASSERTED_ROWS} rows (${matched} read) on a custom driver, whose delete is checked row by row before the store removes any. Sweep the filter with .where(filter).inBatches(1000) and delete each page`,
|
|
17
|
+
fix: `${describeCommand(entityName)} # the entity whose filtered delete must be swept in pages`,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** The key predicates that name exactly these rows, beside the plan's own. */
|
|
21
|
+
export const byKeys = <Row>(scope: DriverScope<Row>, plan: QueryPlan, rows: readonly Row[]) => {
|
|
22
|
+
const keys = scope.entity.$primaryKey;
|
|
23
|
+
const [only] = keys;
|
|
24
|
+
if (keys.length === 1 && only !== undefined) {
|
|
25
|
+
const where: Predicate[] = [
|
|
26
|
+
{ column: only, op: 'in', value: rows.map((row) => valueAt(row, only)) },
|
|
27
|
+
...plan.where,
|
|
28
|
+
];
|
|
29
|
+
return [{ ...plan, where }];
|
|
30
|
+
}
|
|
31
|
+
return rows.map((row) => ({
|
|
32
|
+
...plan,
|
|
33
|
+
where: [
|
|
34
|
+
...keys.map((key): Predicate => ({ column: key, op: 'eq', value: valueAt(row, key) })),
|
|
35
|
+
...plan.where,
|
|
36
|
+
],
|
|
37
|
+
}));
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Answers how many rows the store removed. */
|
|
41
|
+
export const checkedDelete = async <Row>(
|
|
42
|
+
scope: DriverScope<Row>,
|
|
43
|
+
op: string,
|
|
44
|
+
plan: QueryPlan,
|
|
45
|
+
options: RepoOptions | undefined,
|
|
46
|
+
): Promise<number> => {
|
|
47
|
+
const tx = scope.txOf(op, options);
|
|
48
|
+
const found = scope.checked(
|
|
49
|
+
op,
|
|
50
|
+
plan,
|
|
51
|
+
await scope.raw.select({ plan, after: undefined, limit: MAX_ASSERTED_ROWS + 1, tx }),
|
|
52
|
+
);
|
|
53
|
+
if (found.length > MAX_ASSERTED_ROWS) throw tooMany(scope.entity.$name, op, found.length);
|
|
54
|
+
if (found.length === 0) return 0;
|
|
55
|
+
let removed = 0;
|
|
56
|
+
for (const keyed of byKeys(scope, plan, found)) {
|
|
57
|
+
removed += await scope.raw.delete({ plan: keyed, tx });
|
|
58
|
+
}
|
|
59
|
+
// The checked rows, for the search mirror when it asked (`search-written-rows.ts`).
|
|
60
|
+
writtenRowsSink()?.(found);
|
|
61
|
+
return removed;
|
|
62
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Single responsibility: the refusals a custom driver built with `defineDriver` meets, and the
|
|
2
|
+
// unique-violation a store raises for a duplicate key. The codes and titles live in
|
|
3
|
+
// `entity-error.ts` with every other entity code; these carry `meta.capability`, which
|
|
4
|
+
// `EntityError` has no slot for, so they are `UltimateError`s of their own.
|
|
5
|
+
|
|
6
|
+
import { renderCauseValue, UltimateError } from '@ultimat3/core';
|
|
7
|
+
import { DbError } from '@ultimat3/db';
|
|
8
|
+
import type { EntityCore } from './entity';
|
|
9
|
+
import type { EntityErrorCode } from './entity-error';
|
|
10
|
+
|
|
11
|
+
/** One refusal of this file: an entity code, plus the capability or the store it names. */
|
|
12
|
+
export class DriverError extends UltimateError {
|
|
13
|
+
override readonly name = 'DriverError';
|
|
14
|
+
|
|
15
|
+
constructor(init: {
|
|
16
|
+
code: EntityErrorCode;
|
|
17
|
+
cause: string;
|
|
18
|
+
fix: string;
|
|
19
|
+
meta: Readonly<Record<string, unknown>>;
|
|
20
|
+
}) {
|
|
21
|
+
super(init);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A call needs a capability the driver did not declare. Raised before the store is reached. */
|
|
26
|
+
export const driverUnsupported = (
|
|
27
|
+
driverName: string,
|
|
28
|
+
entityName: string,
|
|
29
|
+
operation: string,
|
|
30
|
+
capability: string,
|
|
31
|
+
): DriverError =>
|
|
32
|
+
new DriverError({
|
|
33
|
+
code: 'X_DRIVER_UNSUPPORTED',
|
|
34
|
+
cause: `${entityName}.${operation}() needs '${capability}', and driver ${renderCauseValue(driverName)} does not declare it in defineDriver({ capabilities })`,
|
|
35
|
+
fix: `defineDriver({ name: ${JSON.stringify(driverName)}, capabilities: [..., '${capability}'], repo }) # and implement it on the object repo() returns — or read ${entityName} through postgresDriver(), which has every capability`,
|
|
36
|
+
meta: { capability, driver: driverName, entity: entityName, operation },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** A declared capability with nothing behind it: refused when the driver is defined. */
|
|
40
|
+
export const driverCapabilityMissing = (
|
|
41
|
+
driverName: string,
|
|
42
|
+
capability: string,
|
|
43
|
+
member: string,
|
|
44
|
+
): DriverError =>
|
|
45
|
+
new DriverError({
|
|
46
|
+
code: 'X_DRIVER_CAPABILITY_MISSING',
|
|
47
|
+
cause: `driver ${renderCauseValue(driverName)} declares '${capability}' and provides no ${member}`,
|
|
48
|
+
fix: `defineDriver({ name: ${JSON.stringify(driverName)}, capabilities: [/* without '${capability}' */], repo }) # or implement ${member} on the object repo() returns`,
|
|
49
|
+
meta: { capability, driver: driverName },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The store answered with a row the plan excludes. Refused, never filtered out: a driver that
|
|
54
|
+
* ignores a tenant predicate also ignores it on writes, and a quiet filter would hide that.
|
|
55
|
+
*/
|
|
56
|
+
export const driverRowOutOfScope = (
|
|
57
|
+
driverName: string,
|
|
58
|
+
entityName: string,
|
|
59
|
+
operation: string,
|
|
60
|
+
column: string,
|
|
61
|
+
): DriverError =>
|
|
62
|
+
new DriverError({
|
|
63
|
+
code: 'X_DRIVER_ROW_OUT_OF_SCOPE',
|
|
64
|
+
cause: `driver ${renderCauseValue(driverName)} answered ${entityName}.${operation}() with a row whose ${column} the plan excludes — its select() did not apply plan.where`,
|
|
65
|
+
fix: "selectRows(entity, rows, read) # return this from the store's select(): it applies every plan.where predicate in process (@ultimat3/entity/adapter); a native store applies each one itself",
|
|
66
|
+
meta: { column, driver: driverName, entity: entityName, operation },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The refusal a store raises for a duplicate key: `X_DB_UNIQUE_VIOLATION`, the code Postgres'
|
|
71
|
+
* `23505` becomes, so a caller catching it reads one code whatever stores the row. No SQLSTATE
|
|
72
|
+
* is invented: `sqlState()` answers `undefined`, because no server said anything.
|
|
73
|
+
*/
|
|
74
|
+
export const entityUniqueViolation = (entity: EntityCore, constraint: string): DbError =>
|
|
75
|
+
new DbError({
|
|
76
|
+
code: 'X_DB_UNIQUE_VIOLATION',
|
|
77
|
+
cause: `a row written into ${entity.$table} repeats the key of constraint ${renderCauseValue(constraint)}`,
|
|
78
|
+
fix: `upsertAll(rows, { onConflict: [...] }) over the columns ${constraint} covers — or catch X_DB_UNIQUE_VIOLATION and answer 409, which is what a raced signup is`,
|
|
79
|
+
meta: { constraint },
|
|
80
|
+
});
|