@ultimat3/entity 21.0.0 → 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/README.md CHANGED
@@ -890,6 +890,15 @@ database from its boot code has decided to, and a library that overruled that wo
890
890
  `X_PRELOAD_UNKNOWN_RELATION` · `X_N_PLUS_ONE_QUERY` · `X_N_PLUS_ONE_WRITE` ·
891
891
  `X_RECORD_KEY_MISSING`
892
892
 
893
+ ### Error classes
894
+
895
+ Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
896
+ a job boundary the class is gone and the `code` is what survives — match on that.
897
+
898
+ | Class | Code | Declared in |
899
+ |---|---|---|
900
+ | `EntityError` | any `EntityErrorCode` — `ENTITY_ERROR_CODES` | `src/entity-error.ts` |
901
+
893
902
  ## Boundaries
894
903
 
895
904
  Tier 2. Imports `@ultimat3/core`, `@ultimat3/schema` and `@ultimat3/db` only — `db` is tier 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "21.0.0",
3
+ "version": "22.0.0",
4
4
  "description": "A table + its domain type + invariants the database also enforces",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,9 +32,9 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "21.0.0",
36
- "@ultimat3/db": "21.0.0",
37
- "@ultimat3/schema": "21.0.0",
38
- "@ultimat3/time": "21.0.0"
35
+ "@ultimat3/core": "22.0.0",
36
+ "@ultimat3/db": "22.0.0",
37
+ "@ultimat3/schema": "22.0.0",
38
+ "@ultimat3/time": "22.0.0"
39
39
  }
40
40
  }
@@ -24,7 +24,8 @@ export const decodeAggregate = (fn: AggregateFn, kind: ColumnKind, text: string)
24
24
  // whatever they fit in, while `sum('likeCount')` over a million of them does not.
25
25
  if (fn === 'sum' || fn === 'avg') return text;
26
26
  if (kind === 'timestamptz') {
27
- const at = new Date(text);
27
+ // Epoch milliseconds (`pg-sql.ts`), so no zone and no calendar is parsed here at all.
28
+ const at = new Date(Number(text));
28
29
  return Number.isNaN(at.getTime()) ? null : at;
29
30
  }
30
31
  if (kind === 'integer') {
@@ -47,6 +47,32 @@ export const json = <T>(schema: StandardSchemaV1<unknown, T>): Column<T> =>
47
47
 
48
48
  const DIGITS = /^-?\d+$/;
49
49
 
50
+ /**
51
+ * The one spelling Postgres answers with: no leading zeros, and no `-0`. Memory stored `'007'`
52
+ * where Postgres returned `7`, so the same row was two values by driver. String work only — the
53
+ * digits never pass through a `Number`.
54
+ */
55
+ const canonicalDigits = (digits: string): string => {
56
+ const negative = digits.startsWith('-');
57
+ const whole = digits.replace('-', '').replace(/^0+(?=\d)/, '');
58
+ return negative && whole !== '0' ? `-${whole}` : whole;
59
+ };
60
+
61
+ /**
62
+ * An ACCEPTED decimal in Postgres's spelling: leading zeros stripped, `-0` and `-0.00` unsigned,
63
+ * and a short fraction padded to the column's scale (`numeric(8, 2)` answers `7.50` for `7.5`).
64
+ * Never rounds — excess scale was refused above, and rounding here would widen what the column
65
+ * accepts. An unbounded `numeric` keeps the fraction it was given, as Postgres does.
66
+ */
67
+ const canonicalDecimal = (text: string, scale: number | undefined): string => {
68
+ const negative = text.startsWith('-');
69
+ const [rawWhole = '', rawFraction = ''] = text.replace('-', '').split('.');
70
+ const whole = rawWhole.replace(/^0+(?=\d)/, '');
71
+ const fraction = scale === undefined ? rawFraction : rawFraction.padEnd(scale, '0');
72
+ const zero = /^0*$/.test(whole + fraction);
73
+ return `${negative && !zero ? '-' : ''}${whole}${fraction === '' ? '' : `.${fraction}`}`;
74
+ };
75
+
50
76
  /**
51
77
  * `bigint`, whose row type is a decimal STRING. Neither alternative survives contact:
52
78
  * a JS `bigint` is what `JSON.stringify` throws on — the reason `money.minor` is a `number` — and
@@ -71,7 +97,7 @@ export const bigint = (): Column<string> =>
71
97
  );
72
98
  }
73
99
  return typeof value === 'string' && DIGITS.test(value)
74
- ? value
100
+ ? canonicalDigits(value)
75
101
  : refuseColumn(
76
102
  'bigint',
77
103
  `expected whole digits, ${got(value)}`,
@@ -149,7 +175,7 @@ export const decimal = (options: DecimalOptions = {}): Column<string> => {
149
175
  `widen the column — decimal({ precision: ${whole + (scale ?? 0)}, scale: ${scale ?? 0} }) — and run x db gen "widen the numeric": what overflows is the digits BEFORE the point`,
150
176
  );
151
177
  }
152
- return text;
178
+ return canonicalDecimal(text, scale);
153
179
  },
154
180
  precision === undefined || scale === undefined ? {} : { precision, numericScale: scale },
155
181
  );
@@ -0,0 +1,79 @@
1
+ // Single responsibility: the three plain scalar columns — `text`, `integer`, `boolean` — each
2
+ // refusing in `$parse` exactly what Postgres refuses at the column, so the memory driver cannot
3
+ // store a value production answers 23514 or 22003 for. Split from `columns.ts` at its ceiling.
4
+
5
+ import { charCount } from '@ultimat3/schema';
6
+ import { column } from './column';
7
+ import { got } from './column-values';
8
+ import { refuseColumn } from './refuse';
9
+ import type { Column } from './types';
10
+
11
+ export interface TextOptions {
12
+ /** Emits `char_length(<column>) <= max`, so Postgres refuses an over-long string too. */
13
+ readonly max?: number;
14
+ }
15
+
16
+ export const text = (options: TextOptions = {}): Column<string> => {
17
+ const { max } = options;
18
+ // Refused where it is declared: a `NaN` or fractional max emitted `char_length(x) <= NaN` into
19
+ // the DDL, which Postgres refuses one migration later, far from the line that wrote it.
20
+ if (max !== undefined && !(Number.isSafeInteger(max) && max >= 1)) {
21
+ refuseColumn(
22
+ 'length',
23
+ `text({ max }) must be a whole number of characters, at least 1, ${got(max)}`,
24
+ 'text({ max: 200 }) — a whole count of characters, or text() for no bound',
25
+ );
26
+ }
27
+ return column<string>(
28
+ 'text',
29
+ (value) => {
30
+ if (typeof value !== 'string') {
31
+ return refuseColumn(
32
+ 'type',
33
+ `expected a string, ${got(value)}`,
34
+ 'String(value) at the call site when this really is text — a number column is integer(), an exact decimal is decimal(), a structured payload is json(schema)',
35
+ );
36
+ }
37
+ // Code points, as `char_length` counts them: the CHECK refused an over-long value in
38
+ // Postgres (23514) while memory stored it, so a test passed a write production refuses.
39
+ if (max !== undefined && charCount(value) > max) {
40
+ return refuseColumn(
41
+ 'length',
42
+ `expected at most ${max} characters, got ${charCount(value)}`,
43
+ `truncate at the call site, or widen the column — text({ max: ${charCount(value)} }) — and run x db gen "widen the text"`,
44
+ );
45
+ }
46
+ return value;
47
+ },
48
+ max === undefined ? {} : { length: max, check: (name) => `char_length(${name}) <= ${max}` },
49
+ );
50
+ };
51
+
52
+ /** Postgres `integer` is int4: a safe JS integer past this range is 22003 there. */
53
+ const INT4_MIN = -2_147_483_648;
54
+ const INT4_MAX = 2_147_483_647;
55
+
56
+ export const integer = (): Column<number> =>
57
+ column<number>('integer', (value) =>
58
+ typeof value === 'number' &&
59
+ Number.isSafeInteger(value) &&
60
+ value >= INT4_MIN &&
61
+ value <= INT4_MAX
62
+ ? value
63
+ : refuseColumn(
64
+ 'type',
65
+ `expected a whole number in the int4 range (${INT4_MIN}..${INT4_MAX}), ${got(value)}`,
66
+ 'Math.trunc(value) for a float and Number(value) for a numeric string — a count past int4 is bigint(), a fractional value is decimal()',
67
+ ),
68
+ );
69
+
70
+ export const boolean = (): Column<boolean> =>
71
+ column<boolean>('boolean', (value) =>
72
+ typeof value === 'boolean'
73
+ ? value
74
+ : refuseColumn(
75
+ 'type',
76
+ `expected a boolean, ${got(value)}`,
77
+ "value === 'true' at the call site for a text flag, and boolean().nullable() when the column has a third state",
78
+ ),
79
+ );
package/src/columns.ts CHANGED
@@ -6,6 +6,7 @@ import { uuid as uuidV7 } from '@ultimat3/core';
6
6
  import {
7
7
  CURRENCY_CODE_PATTERN,
8
8
  isCurrencyCode,
9
+ isIsoDateTime,
9
10
  isMoneyScale,
10
11
  MAX_MONEY_SCALE,
11
12
  } from '@ultimat3/schema';
@@ -85,59 +86,22 @@ const uuidWith = <T extends string>(meta: ColumnMeta): UuidColumn<T> => ({
85
86
  column: (name) => uuidWith<T>({ ...meta, name: assertColumnName(name) }),
86
87
  });
87
88
 
88
- export interface TextOptions {
89
- /** Emits `char_length(<column>) <= max`, so Postgres refuses an over-long string too. */
90
- readonly max?: number;
91
- }
92
-
93
- export const text = (options: TextOptions = {}): Column<string> =>
94
- column<string>(
95
- 'text',
96
- (value) =>
97
- typeof value === 'string'
98
- ? value
99
- : refuseColumn(
100
- 'type',
101
- `expected a string, ${got(value)}`,
102
- 'String(value) at the call site when this really is text — a number column is integer(), an exact decimal is decimal(), a structured payload is json(schema)',
103
- ),
104
- options.max === undefined
105
- ? {}
106
- : { length: options.max, check: (name) => `char_length(${name}) <= ${options.max}` },
107
- );
108
-
109
- export const integer = (): Column<number> =>
110
- column<number>('integer', (value) =>
111
- typeof value === 'number' && Number.isSafeInteger(value)
112
- ? value
113
- : refuseColumn(
114
- 'type',
115
- `expected a safe integer, ${got(value)}`,
116
- 'Math.trunc(value) for a float and Number(value) for a numeric string — a count past ±2^53 is bigint(), a fractional value is decimal()',
117
- ),
118
- );
119
-
120
- export const boolean = (): Column<boolean> =>
121
- column<boolean>('boolean', (value) =>
122
- typeof value === 'boolean'
123
- ? value
124
- : refuseColumn(
125
- 'type',
126
- `expected a boolean, ${got(value)}`,
127
- "value === 'true' at the call site for a text flag, and boolean().nullable() when the column has a third state",
128
- ),
129
- );
89
+ export type { TextOptions } from './columns-scalar';
90
+ export { boolean, integer, text } from './columns-scalar';
130
91
 
131
92
  const parseInstant = (value: unknown): Date => {
132
93
  if (value instanceof Date && !Number.isNaN(value.getTime())) return value;
133
- if (typeof value === 'string' || typeof value === 'number') {
94
+ // A string must be ISO-8601 naming its own instant (`@ultimat3/schema`'s `isIsoDateTime`):
95
+ // `new Date('2026-03-14T09:00')` and `new Date('March 14, 2026')` resolved through the HOST's
96
+ // zone on insert and seed, so one row was a different instant per container `TZ`.
97
+ if ((typeof value === 'string' && isIsoDateTime(value)) || typeof value === 'number') {
134
98
  const parsed = new Date(value);
135
99
  if (!Number.isNaN(parsed.getTime())) return parsed;
136
100
  }
137
101
  return refuseColumn(
138
102
  'format',
139
- `expected a UTC instant, ${got(value)}`,
140
- 'new Date(value) at the call site — timestamp() stores an instant; a calendar date with no clock is date(), and an elapsed span is integer()',
103
+ `expected a UTC instant — a Date, epoch milliseconds, or an ISO-8601 string with Z or an offset — ${got(value)}`,
104
+ "'2026-03-14T09:00:00Z' or a Date — timestamp() stores an instant, so a string must name its zone; a calendar date with no clock is date(), and an elapsed span is integer()",
141
105
  );
142
106
  };
143
107
 
@@ -156,7 +120,12 @@ export const url = (): Column<string> =>
156
120
  if (typeof value === 'string') {
157
121
  try {
158
122
  const parsed = new URL(value);
159
- if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return value;
123
+ // The scheme is stored in its canonical LOWER case: the CHECK is `~ '^https?://'`, so
124
+ // `HTTPS://a.b` was stored by memory and refused by Postgres. Only the scheme is
125
+ // rewritten — the rest of the URL is the caller's, byte for byte.
126
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
127
+ return value.replace(/^https?(?=:)/i, (scheme) => scheme.toLowerCase());
128
+ }
160
129
  } catch {
161
130
  // fall through to the shared rejection so the error names the rule
162
131
  }
package/src/entity.ts CHANGED
@@ -423,6 +423,7 @@ export const entity = <const C extends ColumnMap>(
423
423
  tableName: table,
424
424
  persist: init.persist === true,
425
425
  projection: recordProjection(core),
426
+ core: core as EntityCore<unknown>,
426
427
  describe,
427
428
  references,
428
429
  });
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
@@ -82,7 +81,11 @@ export type { StatementLoop } from './n-plus-one';
82
81
  export { N_PLUS_ONE_THRESHOLD, nPlusOne, preloadsFor } from './n-plus-one';
83
82
  export { persistedRecordTypes } from './persisted-types';
84
83
  export type { PostgresDriverOptions } from './pg-driver';
85
- 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';
86
89
  // The two page bounds, beside `N_PLUS_ONE_THRESHOLD` and for the same reason: an app validating
87
90
  // its own `pageSize` input against a hardcoded 10_000 is a second declaration of one number.
88
91
  export { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from './plan';
@@ -106,6 +109,7 @@ export type {
106
109
  export {
107
110
  clearRegistry,
108
111
  describeEntities,
112
+ entityForTable,
109
113
  entityNames,
110
114
  getEntity,
111
115
  registerEntity,
@@ -138,9 +142,7 @@ export {
138
142
  isSearchLanguage,
139
143
  isSearchWeight,
140
144
  SEARCH_LANGUAGES,
141
- SEARCH_PROPERTY,
142
145
  SEARCH_WEIGHTS,
143
- searchExpression,
144
146
  } from './search';
145
147
  export type {
146
148
  Seed,
@@ -168,14 +170,9 @@ export type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './t
168
170
  export {
169
171
  assertRowTenant,
170
172
  assertScoped,
171
- describePlan,
172
- emptyPlan,
173
- hasOrgPredicate,
174
- isOrgScoped,
175
173
  ORG_COLUMN,
176
174
  orgScoped,
177
175
  scopedPlan,
178
- tenantColumnOf,
179
176
  } from './tenancy';
180
177
  export type { Move } from './transition';
181
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
+ };
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,7 +52,7 @@ 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';
@@ -484,16 +480,3 @@ export const postgresRepo = <Row>(
484
480
  export const postgresDriver = (config: PostgresDriverOptions = {}): Driver => ({
485
481
  repo: <Row>(entity: EntityCore<Row>) => postgresRepo(entity, config),
486
482
  });
487
-
488
- /**
489
- * A real Postgres transaction behind the same `Transactor` the in-memory one implements. The
490
- * `Tx` handed to the callback is a token: repositories find the transaction through `db()`, so
491
- * nothing has to thread a connection through the call stack.
492
- */
493
- export const postgresTransactor = (options: TransactionOptions = {}): Transactor => ({
494
- run: (work) =>
495
- withTransaction(
496
- (tx) => work({ id: tx.id, onRollback: (undo: () => void) => tx.onRollback(undo) }),
497
- options,
498
- ),
499
- });
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;