@ultimat3/entity 20.2.1 → 22.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/src/errors.ts CHANGED
@@ -1,102 +1,20 @@
1
1
  // The entity layer's stable error codes. Each factory produces the exact command
2
2
  // that fixes the situation — `X_DB_DRIFT` is the flagship: it names the table, the
3
- // column and the generator invocation.
4
- import { registerErrorCodes, UltimateError } from '@ultimat3/core';
3
+ // column and the generator invocation. The code registry, the class, `invariantViolated` and
4
+ // `entityDuplicate` live in `entity-error.ts`, which imports no `@ultimat3/db`; re-exported here.
5
5
  import { shellInertIdentifier } from '@ultimat3/db';
6
+ import { EntityError } from './entity-error';
6
7
 
7
- /** Codes this package declares and owns. */
8
- export const ENTITY_OWNED_ERROR_CODES = [
9
- 'X_ENTITY_DUPLICATE',
10
- 'X_INVARIANT_VIOLATED',
11
- 'X_TENANCY_UNSCOPED',
12
- 'X_TENANCY_ACTOR_MISMATCH',
13
- 'X_TENANCY_ACTOR_ORG_REQUIRED',
14
- 'X_TENANCY_CROSS_DENIED',
15
- 'X_NOT_FOUND',
16
- 'X_WRITE_UNFILTERED',
17
- 'X_PATCH_EMPTY',
18
- 'X_PRELOAD_UNKNOWN_RELATION',
19
- 'X_N_PLUS_ONE_QUERY',
20
- 'X_N_PLUS_ONE_WRITE',
21
- 'X_REPO_CLIENT_PINNED',
22
- 'X_AGGREGATE_UNSUPPORTED',
23
- 'X_AGGREGATE_MIXED_CURRENCY',
24
- 'X_APPROXIMATE_COUNT_FILTERED',
25
- 'X_SEARCH_UNDECLARED',
26
- 'X_SEARCH_IN_MEMORY',
27
- 'X_STATE_UNDECLARED',
28
- 'X_STATE_TRANSITION_ILLEGAL',
29
- 'X_STATE_CONFLICT',
30
- ] as const;
31
-
32
- /**
33
- * `X_DB_DRIFT` is `@ultimat3/db`'s — drift is a fact about migrations, and this package imports db
34
- * rather than the other way round. `dbDrift()` below throws it; nothing here titles it, because a
35
- * second copy of the title is what lets the two packages disagree about what the code means.
36
- */
37
- export const ENTITY_BORROWED_ERROR_CODES = ['X_DB_DRIFT'] as const;
38
-
39
- /** Every code entity can throw: the ones it owns plus the one it borrows. */
40
- export const ENTITY_ERROR_CODES = [
41
- ...ENTITY_OWNED_ERROR_CODES,
42
- ...ENTITY_BORROWED_ERROR_CODES,
43
- ] as const;
44
-
45
- export type EntityOwnedErrorCode = (typeof ENTITY_OWNED_ERROR_CODES)[number];
46
- export type EntityErrorCode = (typeof ENTITY_ERROR_CODES)[number];
47
-
48
- export const ENTITY_ERROR_TITLES: Readonly<Record<EntityOwnedErrorCode, string>> = {
49
- X_ENTITY_DUPLICATE: 'two entities claim the same name',
50
- X_INVARIANT_VIOLATED: 'a domain invariant rejected this row',
51
- X_TENANCY_UNSCOPED: 'a tenant-scoped query has no org predicate',
52
- // "call", not "query": the same code covers a predicate that names another tenant and a row or
53
- // patch that writes one, because they are one mistake made in two places.
54
- X_TENANCY_ACTOR_MISMATCH: "a call named a tenant other than the actor's",
55
- X_TENANCY_ACTOR_ORG_REQUIRED: 'the acting actor carries no tenant',
56
- X_TENANCY_CROSS_DENIED: 'a cross-tenant read was entered without the capability',
57
- X_NOT_FOUND: 'no row for that id',
58
- X_WRITE_UNFILTERED: 'a filtered write named no filter columns',
59
- X_PATCH_EMPTY: 'a filtered update named no columns to write',
60
- X_PRELOAD_UNKNOWN_RELATION: 'no relation of that name on this entity',
61
- X_N_PLUS_ONE_QUERY: 'a read repeated once per row',
62
- X_N_PLUS_ONE_WRITE: 'a write repeated once per row',
63
- X_REPO_CLIENT_PINNED: 'a repository pinned to its own client cannot join the open transaction',
64
- X_AGGREGATE_UNSUPPORTED: 'that column has no aggregate both drivers can answer alike',
65
- X_AGGREGATE_MIXED_CURRENCY: 'an amount was aggregated across currencies',
66
- X_APPROXIMATE_COUNT_FILTERED: 'an estimate was asked of a filtered chain',
67
- X_SEARCH_UNDECLARED: 'this entity has no searchable column',
68
- X_SEARCH_IN_MEMORY: 'the in-memory driver cannot answer a full-text match',
69
- X_STATE_UNDECLARED: 'that column declares no state machine',
70
- X_STATE_TRANSITION_ILLEGAL: 'the machine has no such transition',
71
- X_STATE_CONFLICT: 'the row is no longer in the state this transition named',
72
- };
73
-
74
- // Registered at module load, unconditionally, in one call. Without this the registry humanises the
75
- // code and every surface renders a title this package never wrote; with a presence guard, a second
76
- // package claiming one of these codes would silently win instead of throwing X_ERROR_CODE_DUPLICATE.
77
- registerErrorCodes(
78
- Object.fromEntries(Object.entries(ENTITY_ERROR_TITLES).map(([code, title]) => [code, { title }])),
79
- );
80
-
81
- /**
82
- * Base for every error this package throws. No `docs:` — `UltimateError` fills it from
83
- * `describeErrorCode(code).docs`, which is `@ultimat3/core`'s `ERROR_DOCS_URL`: one page for every
84
- * code, never one per code, because `wiki/` is the framework's only public documentation surface
85
- * and a code lives there in a TABLE ROW, which has no anchor. The
86
- * `https://ultimate.dev/errors/<code>` links this class built until 9.x answered 404, host
87
- * included, on every refusal it has ever raised.
88
- */
89
- export class EntityError extends UltimateError {
90
- override readonly name = 'EntityError';
91
-
92
- constructor(init: { code: EntityErrorCode; cause: string; fix: string }) {
93
- super({
94
- code: init.code,
95
- cause: init.cause,
96
- fix: init.fix,
97
- });
98
- }
99
- }
8
+ export type { EntityErrorCode, EntityOwnedErrorCode } from './entity-error';
9
+ export {
10
+ ENTITY_BORROWED_ERROR_CODES,
11
+ ENTITY_ERROR_CODES,
12
+ ENTITY_ERROR_TITLES,
13
+ ENTITY_OWNED_ERROR_CODES,
14
+ EntityError,
15
+ entityDuplicate,
16
+ invariantViolated,
17
+ } from './entity-error';
100
18
 
101
19
  /**
102
20
  * A value from an app, rendered for a `cause` — and it may not throw, whatever the app put there.
@@ -130,31 +48,6 @@ const renderValue = (value: unknown): string => {
130
48
  const asLiteral = (value: unknown, placeholder: string): string =>
131
49
  typeof value === 'string' ? JSON.stringify(value) : placeholder;
132
50
 
133
- export const entityDuplicate = (name: string, existingTable: string): EntityError =>
134
- new EntityError({
135
- code: 'X_ENTITY_DUPLICATE',
136
- cause: `entity "${name}" is already registered for table "${existingTable}"`,
137
- fix: `x entities list --json # then rename one of the two entity({ name }) declarations`,
138
- });
139
-
140
- /**
141
- * The entity name is a VALUE, never a literal — `entity.$name`, `table`, the `name` `entity()` was
142
- * given. A literal is an entity that does not exist, and this fix then hands the reader
143
- * `x entities describe column --json`, which answers `X_DECLARATION_UNKNOWN` (issue #290). A
144
- * refusal raised before any entity exists belongs in `refuse.ts`, where the caller supplies the
145
- * edit; `refuse.test.ts` fails on a literal here.
146
- */
147
- export const invariantViolated = (
148
- entityName: string,
149
- invariantName: string,
150
- message: string,
151
- ): EntityError =>
152
- new EntityError({
153
- code: 'X_INVARIANT_VIOLATED',
154
- cause: `${entityName}.${invariantName}: ${message}`,
155
- fix: `x entities describe ${entityName} --json # shows the invariant and its SQL CHECK`,
156
- });
157
-
158
51
  /**
159
52
  * One code, two situations, and they do not share a repair — so the cause and the fix branch on
160
53
  * which one it is rather than one wording claiming the other's facts.
package/src/index.ts CHANGED
@@ -30,7 +30,6 @@ export { CROSS_TENANT_SCOPE, crossTenant } from './cross-tenant';
30
30
  export type { Database, DatabaseOptions, Driver, EntitySet } from './database';
31
31
  export { database, defaultDriver, memoryDriver } from './database';
32
32
  export type { DescribeInput } from './describe';
33
- export { sqlTypeOf } from './describe';
34
33
  export type { Entity, EntityCore, EntityInit, IndexInit } from './entity';
35
34
  export { entity, SOFT_DELETE_COLUMN } from './entity';
36
35
  // The vocabulary an EXISTING schema needs. Separate from the blessed builders on purpose: those
@@ -80,14 +79,25 @@ export { assertInvariants, invariant, MAX_ASSERTED_ROWS } from './invariants';
80
79
  export { memoryRepo, memoryTransactor } from './memory-repo';
81
80
  export type { StatementLoop } from './n-plus-one';
82
81
  export { N_PLUS_ONE_THRESHOLD, nPlusOne, preloadsFor } from './n-plus-one';
82
+ export { persistedRecordTypes } from './persisted-types';
83
83
  export type { PostgresDriverOptions } from './pg-driver';
84
- export { postgresDriver, postgresRepo, postgresTransactor } from './pg-driver';
84
+ export { postgresDriver, postgresRepo } from './pg-driver';
85
+ // For a change feed holding a table name and raw columns: `entityForTable` + `decodeRow` is the row
86
+ // the app declared, money and all. Exported for `@ultimat3/realtime` (plan 101, 06 i).
87
+ export { decodeRow } from './pg-row';
88
+ export { postgresTransactor } from './pg-transactor';
85
89
  // The two page bounds, beside `N_PLUS_ONE_THRESHOLD` and for the same reason: an app validating
86
90
  // its own `pageSize` input against a hardcoded 10_000 is a second declaration of one number.
87
91
  export { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from './plan';
88
92
  export type { RelatedTable, RelatedTables } from './preload';
89
93
  export type { Preloaded, ReadBuilder, Table } from './query';
90
94
  export { tableFor } from './query';
95
+ // The client projection of an entity (plan 101): record type, record key, and the brand on
96
+ // `$schema` that lets an action or query derive its record envelope from its output schema.
97
+ // Value-light — no Postgres driver behind any of them — so a browser store may import them here.
98
+ export type { ProjectedEntity, RecordProjection } from './record-projection';
99
+ export { ENTITY_BRAND, recordProjection } from './record-projection';
100
+ export { recordProjectionForTable, recordTypeForTable } from './record-table';
91
101
  export type {
92
102
  ColumnDescription,
93
103
  EntityDescription,
@@ -99,6 +109,7 @@ export type {
99
109
  export {
100
110
  clearRegistry,
101
111
  describeEntities,
112
+ entityForTable,
102
113
  entityNames,
103
114
  getEntity,
104
115
  registerEntity,
@@ -119,6 +130,8 @@ export type {
119
130
  } from './repo';
120
131
  export type { RowBulkChange, RowChange, RowChangeOp, RowObserver } from './row-observer';
121
132
  export { observedRepo, rowObserver, setRowObserver } from './row-observer';
133
+ export type { RecordsByKey, RecordsByType } from './rows-of';
134
+ export { hasEntityRows, projectionsIn, rowsOf } from './rows-of';
122
135
  // Full-text search. The LANGUAGE set and the weights are values an app reads to build a form;
123
136
  // `SEARCH_PROPERTY` is what a `matches` predicate names, which a hand-built `QueryPlan` needs.
124
137
  export type { SearchInit, SearchLanguage, SearchSource, SearchVector } from './search';
@@ -129,9 +142,7 @@ export {
129
142
  isSearchLanguage,
130
143
  isSearchWeight,
131
144
  SEARCH_LANGUAGES,
132
- SEARCH_PROPERTY,
133
145
  SEARCH_WEIGHTS,
134
- searchExpression,
135
146
  } from './search';
136
147
  export type {
137
148
  Seed,
@@ -159,14 +170,9 @@ export type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './t
159
170
  export {
160
171
  assertRowTenant,
161
172
  assertScoped,
162
- describePlan,
163
- emptyPlan,
164
- hasOrgPredicate,
165
- isOrgScoped,
166
173
  ORG_COLUMN,
167
174
  orgScoped,
168
175
  scopedPlan,
169
- tenantColumnOf,
170
176
  } from './tenancy';
171
177
  export type { Move } from './transition';
172
178
  export type {
@@ -235,6 +235,24 @@ const preload = <Row>(read: PointRead<Row>, bucket: Bucket, ids: readonly unknow
235
235
  void fill(read, wanted, settlers);
236
236
  };
237
237
 
238
+ /**
239
+ * The ids a bucket can KEEP, from the one asked for onward. The bucket holds `MAX_SIBLING_KEYS`
240
+ * rows, so preloading a wider page read every id and then evicted the oldest — measured, 2,500
241
+ * ids read to keep 2,000. A sequential loop walks forward, so the window starts at this lookup.
242
+ */
243
+ const keptWindow = <Row>(
244
+ read: PointRead<Row>,
245
+ ids: readonly unknown[],
246
+ filedAt: string,
247
+ ): readonly unknown[] => {
248
+ if (ids.length <= MAX_SIBLING_KEYS) return ids;
249
+ const start = Math.max(
250
+ 0,
251
+ ids.findIndex((id) => keyOf(read.key.kind, id) === filedAt),
252
+ );
253
+ return ids.slice(start, start + MAX_SIBLING_KEYS);
254
+ };
255
+
238
256
  const answered = <Row>(answer: Promise<Answer>): Promise<Row | null> =>
239
257
  answer.then((settled) =>
240
258
  'error' in settled ? Promise.reject(settled.error) : (settled.row as Row | null),
@@ -266,7 +284,7 @@ export const preloadedFindById = <Row>(
266
284
  rows: new Map<string, Promise<Answer>>(),
267
285
  };
268
286
  store.preloaded.set(scope, target);
269
- preload(read, target, ids);
287
+ preload(read, target, keptWindow(read, ids, filedAt));
270
288
  const answer = target.rows.get(filedAt);
271
289
  return answer === undefined ? undefined : answered<Row>(answer);
272
290
  };
@@ -18,6 +18,7 @@ import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
18
18
  import { notFound } from './errors';
19
19
  import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
20
20
  import { compareByKind, matchesPredicate } from './memory-match';
21
+ import { uniqueClash, uniqueViolation } from './memory-unique';
21
22
  import { deletePlan, idPlan, readPlan, singleKeyOf, updatePlan } from './plan';
22
23
  import type { FindManyArgs, MemoryRepo, RepoOptions, Transactor, Tx } from './repo';
23
24
  import type { QueryPlan } from './tenancy';
@@ -69,6 +70,14 @@ const afterCursor = <Row>(
69
70
  * migration and tests use it everywhere. Postgres is the production driver and implements
70
71
  * this same interface.
71
72
  */
73
+ /**
74
+ * The patch with every `undefined` property dropped — `bindValues` skips them in Postgres, so a
75
+ * patch built from optional input (`{ body: input.body }`) leaves the column alone in both drivers
76
+ * rather than erasing it. `null` is the value that clears a column.
77
+ */
78
+ const defined = (patch: object): object =>
79
+ Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined));
80
+
72
81
  export const memoryRepo = <Row>(
73
82
  entity: EntityCore<Row>,
74
83
  seed: readonly Row[] = [],
@@ -119,10 +128,16 @@ export const memoryRepo = <Row>(
119
128
  const narrowed = (batch: readonly RowWrite<Row>[]): readonly Row[] =>
120
129
  batch.map((row) => narrowRow<Row>(entity.$columns, row));
121
130
 
131
+ /**
132
+ * `from` is the key the row is stored under NOW, or `undefined` for a new row. A new row may not
133
+ * land on a stored key, and a moved one may not land on another row's — Postgres answers both
134
+ * `X_DB_UNIQUE_VIOLATION` — and a moved row leaves its old key, where this map used to keep it.
135
+ */
122
136
  const write = (
123
137
  given: RowWrite<Row>,
124
138
  options: RepoOptions | undefined,
125
139
  operation: string,
140
+ from?: string,
126
141
  ): Row => {
127
142
  // `MoneyInput` lets a writer hand a `bigint`; a stored row holds the value type. The Postgres
128
143
  // driver narrows at the same position — its write methods' entry — so without this an
@@ -135,11 +150,21 @@ export const memoryRepo = <Row>(
135
150
  assertRowTenant(entity.$name, entity.$tenantColumn, operation, row);
136
151
  entity.$assert(row);
137
152
  const key = storeKey(row);
153
+ if (key !== from && rows.has(key)) throw uniqueViolation(entity, `${entity.$table}_pkey`);
154
+ const clash = uniqueClash(
155
+ entity,
156
+ row,
157
+ [...rows.entries()].filter(([stored]) => stored !== from).map(([, other]) => other),
158
+ );
159
+ if (clash !== undefined) throw uniqueViolation(entity, clash);
160
+ const moved = from !== undefined && from !== key ? rows.get(from) : undefined;
138
161
  const previous = rows.get(key);
139
162
  options?.tx?.onRollback(() => {
140
163
  if (previous === undefined) rows.delete(key);
141
164
  else rows.set(key, previous);
165
+ if (from !== undefined && moved !== undefined) rows.set(from, moved);
142
166
  });
167
+ if (moved !== undefined && from !== undefined) rows.delete(from);
143
168
  rows.set(key, row);
144
169
  return row;
145
170
  };
@@ -203,9 +228,18 @@ export const memoryRepo = <Row>(
203
228
  // Narrowed FIRST, so what this loop judges is what `write` will store: `$assert` was handed
204
229
  // the caller's `bigint` minor unit here and the narrowed `number` one call later.
205
230
  const batch = narrowed(given);
206
- for (const row of batch) {
231
+ const seen = new Set<string>();
232
+ for (const [position, row] of batch.entries()) {
207
233
  assertRowTenant(entity.$name, entity.$tenantColumn, 'insertAll', row);
208
234
  entity.$assert(row);
235
+ // Keys too, before any row lands: one duplicate refuses the whole statement in Postgres.
236
+ const key = storeKey(row);
237
+ if (seen.has(key) || rows.has(key)) {
238
+ throw uniqueViolation(entity, `${entity.$table}_pkey`);
239
+ }
240
+ seen.add(key);
241
+ const clash = uniqueClash(entity, row, [...rows.values(), ...batch.slice(0, position)]);
242
+ if (clash !== undefined) throw uniqueViolation(entity, clash);
209
243
  }
210
244
  return batch.map((row) => write(row, options, 'insertAll'));
211
245
  },
@@ -248,7 +282,12 @@ export const memoryRepo = <Row>(
248
282
  );
249
283
  // `UpsertArgs extends RepoOptions`, so the args ARE the options — one bag, and a `tx`
250
284
  // passed to an upsert registers its undo exactly as it does for every other write here.
251
- const result = write(merged, args, 'upsertAll');
285
+ const result = write(
286
+ merged,
287
+ args,
288
+ 'upsertAll',
289
+ existing === undefined ? undefined : storeKey(existing),
290
+ );
252
291
  // Filed as it lands, so a later row of the same batch collides with an earlier one exactly
253
292
  // as it would with a row the request stored a moment before it.
254
293
  if (key !== undefined) stored.set(key, result);
@@ -258,14 +297,25 @@ export const memoryRepo = <Row>(
258
297
  },
259
298
 
260
299
  async update(id, patch, options) {
261
- return write(Object.assign({}, addressed(id, options, 'update'), patch), options, 'update');
300
+ const current = addressed(id, options, 'update');
301
+ return write(
302
+ Object.assign({}, current, defined(patch)),
303
+ options,
304
+ 'update',
305
+ storeKey(current),
306
+ );
262
307
  },
263
308
 
264
309
  async delete(id, options) {
265
310
  const current = addressed(id, options, 'delete');
266
311
  // Soft delete hides the row without losing it; the column's presence is the switch.
267
312
  if (entity.$softDelete) {
268
- write(Object.assign({}, current, { [SOFT_DELETE_COLUMN]: entityNow() }), options, 'delete');
313
+ write(
314
+ Object.assign({}, current, { [SOFT_DELETE_COLUMN]: entityNow() }),
315
+ options,
316
+ 'delete',
317
+ storeKey(current),
318
+ );
269
319
  return;
270
320
  }
271
321
  const key = storeKey(current);
@@ -285,6 +335,7 @@ export const memoryRepo = <Row>(
285
335
  Object.assign({}, row, { [SOFT_DELETE_COLUMN]: entityNow() }),
286
336
  options,
287
337
  'deleteWhere',
338
+ storeKey(row),
288
339
  );
289
340
  continue;
290
341
  }
@@ -316,7 +367,8 @@ export const memoryRepo = <Row>(
316
367
  if (hasJsOnlyInvariant(entity.$invariants) && found.length > MAX_ASSERTED_ROWS) {
317
368
  throw assertedRowsTooMany(entity.$name, 'updateWhere', found.length);
318
369
  }
319
- for (const row of found) write(Object.assign({}, row, patch), options, 'updateWhere');
370
+ for (const row of found)
371
+ write(Object.assign({}, row, defined(patch)), options, 'updateWhere', storeKey(row));
320
372
  return found.length;
321
373
  },
322
374
 
@@ -370,13 +422,28 @@ let txCounter = 0;
370
422
  export const memoryTransactor = (): Transactor => ({
371
423
  async run(work) {
372
424
  const undos: (() => void)[] = [];
425
+ const commits: (() => void)[] = [];
373
426
  txCounter += 1;
374
- const tx: Tx = { id: `tx-${txCounter}`, onRollback: (undo) => undos.push(undo) };
427
+ const tx: Tx = {
428
+ id: `tx-${txCounter}`,
429
+ onRollback: (undo) => undos.push(undo),
430
+ onCommit: (effect) => commits.push(effect),
431
+ };
432
+ let result: Awaited<ReturnType<typeof work>>;
375
433
  try {
376
- return await work(tx);
434
+ result = await work(tx);
377
435
  } catch (error) {
378
436
  for (const undo of undos.reverse()) undo();
379
437
  throw error;
380
438
  }
439
+ // After the work succeeded — the memory "commit" — and best-effort, as `@ultimat3/db` runs them.
440
+ for (const effect of commits) {
441
+ try {
442
+ effect();
443
+ } catch {
444
+ // an effect is a report about a durable write; it may not fail the write
445
+ }
446
+ }
447
+ return result;
381
448
  },
382
449
  });
@@ -0,0 +1,51 @@
1
+ // Single responsibility: the uniqueness Postgres enforces, enforced by the in-memory driver too —
2
+ // the primary key and every non-partial `unique` index. Memory silently REPLACED a row on a
3
+ // duplicate key and ignored `unique()` entirely, so a signup race that is a 409 in production was
4
+ // a quiet overwrite under `x dev` and in every app test.
5
+
6
+ import { driverError } from '@ultimat3/db';
7
+ import type { EntityCore } from './entity';
8
+ import { bindValues } from './pg-row';
9
+ import type { RowPatch } from './types';
10
+
11
+ /** The refusal Postgres answers `23505` with, in the shape `driverError` gives it there. */
12
+ export const uniqueViolation = (entity: EntityCore, constraint: string): Error =>
13
+ driverError(`memory write into ${entity.$table}`, {
14
+ code: '23505',
15
+ constraint,
16
+ message: `duplicate key value violates unique constraint "${constraint}"`,
17
+ });
18
+
19
+ /** A value as a comparable token; `null` answers `undefined` — NULLS DISTINCT, as Postgres. */
20
+ const cellOf = (value: unknown): string | undefined => {
21
+ if (value === null || value === undefined) return undefined;
22
+ if (value instanceof Date) return `date:${value.getTime()}`;
23
+ if (typeof value === 'string') return `s:${value}`;
24
+ return `${typeof value}:${JSON.stringify(value)}`;
25
+ };
26
+
27
+ /**
28
+ * The first unique index `candidate` collides with among `others`, or `undefined`. Partial indexes
29
+ * are skipped: their predicate is SQL this driver cannot evaluate, and a guess would refuse rows
30
+ * Postgres accepts — the one direction that must never happen.
31
+ */
32
+ export const uniqueClash = <Row>(
33
+ entity: EntityCore<Row>,
34
+ candidate: Row,
35
+ others: Iterable<Row>,
36
+ ): string | undefined => {
37
+ const unique = entity.$indexes.filter((index) => index.unique && index.where === undefined);
38
+ if (unique.length === 0) return undefined;
39
+ const bound = (row: Row) => bindValues(entity, row as unknown as RowPatch<Row>);
40
+ const incoming = bound(candidate);
41
+ for (const index of unique) {
42
+ const key = index.columns.map((name) => cellOf(incoming.get(name)));
43
+ if (key.some((part) => part === undefined)) continue;
44
+ for (const other of others) {
45
+ const stored = bound(other);
46
+ if (index.columns.every((name, at) => cellOf(stored.get(name)) === key[at]))
47
+ return index.name;
48
+ }
49
+ }
50
+ return undefined;
51
+ };
@@ -0,0 +1,21 @@
1
+ // The record types the client keeps on disk — every registered entity declared `persist: true`,
2
+ // by name, sorted. What the server renders as `<meta name="ultimate-persist">`, since the browser
3
+ // holds no entity declarations. Memoised against the registry's generation, like `record-table.ts`.
4
+
5
+ import { registeredEntities, registryGeneration } from './registry';
6
+
7
+ let cached: { readonly generation: number; readonly types: readonly string[] } | null = null;
8
+
9
+ /** Sorted, so the rendered meta is byte-identical for one set of declarations. */
10
+ export const persistedRecordTypes = (): readonly string[] => {
11
+ const generation = registryGeneration();
12
+ if (cached !== null && cached.generation === generation) return cached.types;
13
+ const types = Object.freeze(
14
+ registeredEntities()
15
+ .filter((entry) => entry.persist === true)
16
+ .map((entry) => entry.name)
17
+ .sort(),
18
+ );
19
+ cached = { generation, types };
20
+ return types;
21
+ };
package/src/pg-driver.ts CHANGED
@@ -12,9 +12,7 @@ import {
12
12
  type DbClient,
13
13
  db,
14
14
  type SqlFragment,
15
- type TransactionOptions,
16
15
  withStatementAttribution,
17
- withTransaction,
18
16
  } from '@ultimat3/db';
19
17
  import { aggregateColumnOf, aggregateMinor, assertOneUnit } from './aggregate';
20
18
  import { decodeAggregate } from './aggregate-decode';
@@ -37,18 +35,16 @@ import { notFound, repoClientPinned } from './errors';
37
35
  import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
38
36
  import { forgetPreloaded, tagSiblings } from './jit-preload';
39
37
  import { bindValues, decodeRow, type PhysicalRow, physicalName, sortPrecision } from './pg-row';
38
+ import { countStatement, type ReadShape, selectStatement } from './pg-sql';
40
39
  import {
41
40
  type AggregateRow,
42
41
  aggregateStatement,
43
42
  countByStatement,
44
- countStatement,
45
43
  currenciesStatement,
46
44
  estimateStatement,
47
45
  type GroupRow,
48
46
  type MoneyUnitRow,
49
- type ReadShape,
50
- selectStatement,
51
- } from './pg-sql';
47
+ } from './pg-sql-aggregate';
52
48
  import {
53
49
  type ConflictTarget,
54
50
  deleteStatement,
@@ -56,10 +52,11 @@ import {
56
52
  updateStatement,
57
53
  } from './pg-write-sql';
58
54
  import { deletePlan, idPlan, readPlan, updatePlan } from './plan';
59
- import type { FindManyArgs, Repo, Transactor, UpsertArgs } from './repo';
55
+ import type { FindManyArgs, Repo, UpsertArgs } from './repo';
60
56
  import type { QueryPlan } from './tenancy';
61
57
  import { assertRowTenant } from './tenancy';
62
58
  import type { RowWrite } from './types';
59
+ import { taggedWrite } from './write-tag';
63
60
 
64
61
  export interface PostgresDriverOptions {
65
62
  /**
@@ -156,14 +153,14 @@ export const postgresRepo = <Row>(
156
153
  : deleteStatement(entity, plan);
157
154
 
158
155
  /**
159
- * Every write goes out through here, which makes it the ONE place the request's preloaded rows
160
- * are dropped: a row this statement changes must not be served afterwards from a page read
161
- * before it. Before the statement, not after — a row read back afterwards is the row this write
162
- * left, and one read concurrently with it was concurrent either way.
156
+ * Every write goes out through here: the ONE place the request's preloaded rows are dropped (a
157
+ * row this statement changes must not be served afterwards from a page read before it — so
158
+ * before the statement, not after), and where a keyed request's write names itself in the WAL
159
+ * (`write-tag.ts`).
163
160
  */
164
161
  const writing = <T>(send: () => Promise<T>): Promise<T> => {
165
162
  forgetPreloaded(entity.$name);
166
- return send();
163
+ return taggedWrite(config.client !== undefined, send);
167
164
  };
168
165
 
169
166
  /**
@@ -483,16 +480,3 @@ export const postgresRepo = <Row>(
483
480
  export const postgresDriver = (config: PostgresDriverOptions = {}): Driver => ({
484
481
  repo: <Row>(entity: EntityCore<Row>) => postgresRepo(entity, config),
485
482
  });
486
-
487
- /**
488
- * A real Postgres transaction behind the same `Transactor` the in-memory one implements. The
489
- * `Tx` handed to the callback is a token: repositories find the transaction through `db()`, so
490
- * nothing has to thread a connection through the call stack.
491
- */
492
- export const postgresTransactor = (options: TransactionOptions = {}): Transactor => ({
493
- run: (work) =>
494
- withTransaction(
495
- (tx) => work({ id: tx.id, onRollback: (undo: () => void) => tx.onRollback(undo) }),
496
- options,
497
- ),
498
- });
package/src/pg-row.ts CHANGED
@@ -72,7 +72,10 @@ export const allColumns = <Row>(entity: EntityCore<Row>): readonly string[] =>
72
72
 
73
73
  /**
74
74
  * Row (or patch) -> the columns to write. Absent properties are skipped rather than nulled,
75
- * which is what makes the same function serve `insert` and a partial `update`.
75
+ * which is what makes the same function serve `insert` and a partial `update` — and a property
76
+ * PRESENT with the value `undefined` is absent too, as `namedColumns` already reads it. The common
77
+ * patch is built from optional action input (`{ body: input.body }`), and binding that `undefined`
78
+ * as NULL wiped the column the caller never meant to touch. NULL is written only for `null`.
76
79
  */
77
80
  export const bindValues = <Row>(
78
81
  entity: EntityCore<Row>,
@@ -85,6 +88,7 @@ export const bindValues = <Row>(
85
88
  for (const [property, column] of Object.entries(entity.$columns)) {
86
89
  if (!Object.hasOwn(record, property)) continue;
87
90
  const value = record[property];
91
+ if (value === undefined) continue;
88
92
  if (column.$meta.kind !== 'money') {
89
93
  bound.set(columnName(property, column.$meta), bindable(column, value));
90
94
  continue;