@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.
Files changed (58) hide show
  1. package/CLAUDE.md +68 -106
  2. package/README.md +180 -8
  3. package/package.json +6 -5
  4. package/src/adapter.ts +65 -0
  5. package/src/batch-read.ts +12 -0
  6. package/src/column.ts +12 -0
  7. package/src/columns.ts +20 -8
  8. package/src/cross-tenant.ts +11 -14
  9. package/src/cursor.ts +3 -0
  10. package/src/database.ts +2 -2
  11. package/src/declared-index.ts +72 -0
  12. package/src/define-driver.ts +156 -0
  13. package/src/describe.ts +5 -0
  14. package/src/driver-delete.ts +62 -0
  15. package/src/driver-errors.ts +80 -0
  16. package/src/driver-kit-types.ts +107 -0
  17. package/src/driver-registry.ts +26 -0
  18. package/src/driver-repo-reads.ts +102 -0
  19. package/src/driver-repo-writes.ts +236 -0
  20. package/src/driver-rows.ts +108 -0
  21. package/src/driver-scope.ts +77 -0
  22. package/src/driver-write-scope.ts +107 -0
  23. package/src/entity-error.ts +10 -0
  24. package/src/entity.ts +40 -54
  25. package/src/enum-column.ts +8 -0
  26. package/src/index.ts +43 -27
  27. package/src/memory-match.ts +35 -5
  28. package/src/memory-observed.ts +16 -11
  29. package/src/memory-repo.ts +46 -99
  30. package/src/memory-seek.ts +12 -25
  31. package/src/memory-unique.ts +7 -7
  32. package/src/memory-update.ts +7 -2
  33. package/src/memory-upsert.ts +102 -0
  34. package/src/partition.ts +140 -0
  35. package/src/personal-data-kind.ts +15 -0
  36. package/src/personal-data.ts +66 -0
  37. package/src/pg-driver.ts +83 -30
  38. package/src/pg-write-sql.ts +39 -4
  39. package/src/plan.ts +2 -0
  40. package/src/predicate-screen.ts +36 -0
  41. package/src/query.ts +13 -4
  42. package/src/registry.ts +3 -0
  43. package/src/row-key.ts +19 -0
  44. package/src/row-observer.ts +38 -90
  45. package/src/search-index-installed.ts +24 -0
  46. package/src/search-index-memory.ts +132 -0
  47. package/src/search-index-postgres.ts +107 -0
  48. package/src/search-index-sync.ts +96 -0
  49. package/src/search-index-version.ts +93 -0
  50. package/src/search-index.ts +168 -0
  51. package/src/search-mirror-batch.ts +133 -0
  52. package/src/search-mirrored-repo.ts +98 -0
  53. package/src/search-repo.ts +91 -0
  54. package/src/search-written-rows.ts +36 -0
  55. package/src/tenant-index.ts +113 -0
  56. package/src/types.ts +12 -0
  57. package/src/write-commit.ts +28 -0
  58. 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
- // Untouched unless it is a string that actually carries an upper-case character: a malformed
405
- // value is the column parser's to refuse, with its own message, and no allocation is paid by
406
- // the overwhelmingly common case (axiom 6).
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
  }
@@ -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 { actorLabel, assert, asyncContext, hasScope, tryUseContext } from '@ultimat3/core';
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
- witness?.(reason);
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 — `setRowObserver` is the seam, and with none installed the
107
- // wrapper is one comparison per write. `related` below stays unwrapped on purpose: a relation
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
+ });