@ultimat3/entity 3.0.0 → 4.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/repo.ts CHANGED
@@ -7,17 +7,19 @@
7
7
  // table silently skips and repeats rows. A keyset cursor is stable because it names a
8
8
  // position in the sort order, not a row count.
9
9
 
10
- import { systemClock } from '@ultimat3/core';
10
+ import { keyOf } from './batch-read';
11
11
  import { conflictKeyOf, conflictKeys, upsertPlan } from './bulk-write';
12
+ import { entityNow } from './clock';
12
13
  import { narrowMoney } from './columns';
13
14
  import { countsFrom, groupColumnOf } from './count-by';
14
- import { cursorFor, seekFrom, valueAt } from './cursor';
15
+ import { cursorFor, kindOf, seekFrom, valueAt } from './cursor';
15
16
  import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
16
17
  import { notFound } from './errors';
18
+ import { compareByKind, matchesPredicate } from './memory-match';
17
19
  import { deletePlan, idPlan, readPlan, singleKeyOf, updatePlan } from './plan';
18
20
  import type { Predicate, QueryPlan, SortKey } from './tenancy';
19
21
  import { assertRowTenant } from './tenancy';
20
- import type { IdOf } from './types';
22
+ import type { IdOf, RowPatch } from './types';
21
23
 
22
24
  export interface Tx {
23
25
  readonly id: string;
@@ -52,12 +54,26 @@ export interface UpsertArgs<T = unknown> extends RepoOptions {
52
54
  readonly onMatch?: 'update' | 'nothing';
53
55
  }
54
56
 
55
- export interface FindManyArgs extends RepoOptions {
57
+ /**
58
+ * `findById`'s options: `RepoOptions`, plus the one knob a point READ has and a write must not.
59
+ *
60
+ * `includeDeleted` lives here and deliberately NOT on `RepoOptions`. It has always been honoured
61
+ * on this path — `idPlan` spreads the options straight into `FindManyArgs`, and both drivers read
62
+ * `args.includeDeleted === true` — but `RepoOptions` never declared it, so the only documented way
63
+ * to read a soft-deleted row by its id did not typecheck. Putting it on `RepoOptions` instead would
64
+ * have offered it to `update(id, patch)` and `delete(id)`, which reach the same `idPlan`: that is
65
+ * the resurrection those two carry `deleted_at is null` to refuse.
66
+ */
67
+ export interface FindByIdOptions extends RepoOptions {
68
+ /** Soft-deleted rows are hidden unless the caller asks for them. */
69
+ readonly includeDeleted?: boolean;
70
+ }
71
+
72
+ export interface FindManyArgs extends FindByIdOptions {
56
73
  readonly where?: readonly Predicate[];
57
74
  readonly orderBy?: readonly SortKey[];
58
75
  readonly limit?: number;
59
76
  readonly cursor?: string | null;
60
- readonly includeDeleted?: boolean;
61
77
  readonly select?: readonly string[];
62
78
  }
63
79
 
@@ -76,7 +92,7 @@ export interface Page<T> {
76
92
  * both `string`, so a row-agnostic consumer sees the signature it always saw.
77
93
  */
78
94
  export interface Repo<T = unknown> {
79
- findById(id: IdOf<T>, options?: RepoOptions): Promise<T | null>;
95
+ findById(id: IdOf<T>, options?: FindByIdOptions): Promise<T | null>;
80
96
  findMany(args?: FindManyArgs): Promise<Page<T>>;
81
97
  insert(values: T, options?: RepoOptions): Promise<T>;
82
98
  /**
@@ -93,7 +109,7 @@ export interface Repo<T = unknown> {
93
109
  * which is what `returning *` says on the Postgres side.
94
110
  */
95
111
  upsertAll(rows: readonly T[], args: UpsertArgs<T>): Promise<readonly T[]>;
96
- update(id: IdOf<T>, patch: Partial<T>, options?: RepoOptions): Promise<T>;
112
+ update(id: IdOf<T>, patch: RowPatch<T>, options?: RepoOptions): Promise<T>;
97
113
  delete(id: IdOf<T>, options?: RepoOptions): Promise<void>;
98
114
  /**
99
115
  * Delete by filter, returning how many rows went. The only way to remove a row from an entity
@@ -101,14 +117,14 @@ export interface Repo<T = unknown> {
101
117
  * to be able to tell "nothing matched" from "it worked", and an empty filter is
102
118
  * `X_WRITE_UNFILTERED` rather than every row.
103
119
  */
104
- deleteWhere(filter: Partial<T>, options?: RepoOptions): Promise<number>;
120
+ deleteWhere(filter: RowPatch<T>, options?: RepoOptions): Promise<number>;
105
121
  /**
106
122
  * Update by filter, returning how many rows were written. The `update(id, patch)` a composite
107
123
  * primary key cannot express — `participants.lastReadAt` is the reference case. Same two guards
108
124
  * as `deleteWhere`, plus `X_PATCH_EMPTY` for a patch that names no columns, and soft-deleted
109
125
  * rows are not reachable, exactly as they are not by `update(id, patch)`.
110
126
  */
111
- updateWhere(filter: Partial<T>, patch: Partial<T>, options?: RepoOptions): Promise<number>;
127
+ updateWhere(filter: RowPatch<T>, patch: RowPatch<T>, options?: RepoOptions): Promise<number>;
112
128
  count(args?: FindManyArgs): Promise<number>;
113
129
  /**
114
130
  * The grouped count: one statement, one entry per distinct value of `column`, over exactly the
@@ -142,79 +158,22 @@ export interface Transactor {
142
158
  const field = (row: unknown, property: string): unknown =>
143
159
  typeof row === 'object' && row !== null ? (row as Record<string, unknown>)[property] : undefined;
144
160
 
145
- /**
146
- * `===` on two Dates compares identity, so `where({ publishedAt })` would match nothing here
147
- * and every row in Postgres. Equality has to mean the same thing in both drivers or the
148
- * in-memory one stops being a preview of production.
149
- */
150
- const sameValue = (left: unknown, right: unknown): boolean =>
151
- left instanceof Date && right instanceof Date
152
- ? left.getTime() === right.getTime()
153
- : left === right;
154
-
155
- const matches = (row: unknown, predicate: Predicate): boolean => {
156
- const actual = field(row, predicate.column);
157
- switch (predicate.op) {
158
- case 'eq':
159
- return sameValue(actual, predicate.value);
160
- case 'neq':
161
- return !sameValue(actual, predicate.value);
162
- case 'in':
163
- return (
164
- Array.isArray(predicate.value) &&
165
- predicate.value.some((candidate) => sameValue(candidate, actual))
166
- );
167
- case 'gt':
168
- return compare(actual, predicate.value) > 0;
169
- case 'gte':
170
- return compare(actual, predicate.value) >= 0;
171
- case 'lt':
172
- return compare(actual, predicate.value) < 0;
173
- case 'lte':
174
- return compare(actual, predicate.value) <= 0;
175
- // Real LIKE semantics, so `'draft%'` means "starts with" here exactly as it does in
176
- // Postgres. Treating the pattern as a substring would make the two drivers disagree.
177
- case 'like':
178
- return likePattern(String(predicate.value)).test(String(actual));
179
- case 'is-null':
180
- return actual === null || actual === undefined;
181
- case 'is-not-null':
182
- return actual !== null && actual !== undefined;
183
- }
184
- };
185
-
186
- /**
187
- * `%` and `_` are the wildcards; everything else in the pattern is literal, as in SQL.
188
- *
189
- * A RUN of `%` is one `.*`, not one each: `%%%…x` compiled to twenty adjacent `.*` groups, and an
190
- * anchored regex with twenty of them takes exponential time to fail on a long value — a filter
191
- * value an app forwards from a search box is then a CPU stall in the process, on the in-memory
192
- * driver. Postgres reads a run of `%` as one wildcard too, so this is the two drivers agreeing
193
- * rather than a defensive narrowing.
194
- */
195
- const likePattern = (pattern: string): RegExp =>
196
- new RegExp(
197
- `^${pattern
198
- .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
199
- .replaceAll(/%+/g, '.*')
200
- .replaceAll('_', '.')}$`,
201
- 's',
202
- );
203
-
204
- const compare = (left: unknown, right: unknown): number => {
205
- if (left instanceof Date && right instanceof Date) return left.getTime() - right.getTime();
206
- if (typeof left === 'number' && typeof right === 'number') return left - right;
207
- if (typeof left === 'bigint' && typeof right === 'bigint') {
208
- return left < right ? -1 : left > right ? 1 : 0;
209
- }
210
- const [a, b] = [String(left), String(right)];
211
- return a < b ? -1 : a > b ? 1 : 0;
212
- };
213
-
214
161
  /** Lexicographic over the sort keys, direction applied. `> 0` means "after the cursor". */
215
- const compareToSeek = (plan: QueryPlan, row: unknown, seek: readonly unknown[]): number => {
162
+ const compareToSeek = <Row>(
163
+ entity: EntityCore<Row>,
164
+ plan: QueryPlan,
165
+ row: unknown,
166
+ seek: readonly unknown[],
167
+ ): number => {
216
168
  for (const [index, entry] of plan.orderBy.entries()) {
217
- const order = compare(valueAt(row, entry.column), seek[index]);
169
+ // The COLUMN's kind, not the value's: the seek was revived from the same kind (`cursor.ts`),
170
+ // so a `bigint` column compares its stored decimal string against a revived `BigInt` as one
171
+ // number instead of as two pieces of text.
172
+ const order = compareByKind(
173
+ kindOf(entity, entry.column),
174
+ valueAt(row, entry.column),
175
+ seek[index],
176
+ );
218
177
  if (order !== 0) return entry.direction === 'desc' ? -order : order;
219
178
  }
220
179
  return 0;
@@ -232,7 +191,7 @@ const afterCursor = <Row>(
232
191
  ): number => {
233
192
  const seek = seekFrom(entity, plan);
234
193
  if (seek === undefined) return 0;
235
- const start = found.findIndex((row) => compareToSeek(plan, row, seek) > 0);
194
+ const start = found.findIndex((row) => compareToSeek(entity, plan, row, seek) > 0);
236
195
  return start === -1 ? found.length : start;
237
196
  };
238
197
 
@@ -245,9 +204,20 @@ export const memoryRepo = <Row>(
245
204
  entity: EntityCore<Row>,
246
205
  seed: readonly Row[] = [],
247
206
  ): MemoryRepo<Row> => {
248
- const keyOf = (row: unknown): string =>
249
- entity.$primaryKey.map((property) => String(field(row, property))).join('');
250
- const rows = new Map<string, Row>(seed.map((row) => [keyOf(row), row]));
207
+ /**
208
+ * A stored row's key, spelled the way `batch-read.ts` spells an id — because Postgres compares a
209
+ * `uuid` as a VALUE and prints it lower-cased, so `findById(UPPER)` reads the row there while
210
+ * `String(...)` missed it here: `null` from a read and `X_NOT_FOUND` from a write, against a row
211
+ * that exists, reachable from a path parameter, a client-supplied id or a legacy import.
212
+ */
213
+ const storeKey = (row: unknown): string =>
214
+ entity.$primaryKey
215
+ .map((property) => keyOf(kindOf(entity, property) ?? '', field(row, property)))
216
+ .join('');
217
+ /** The same key, from the id a caller named rather than from a row it has in hand. */
218
+ const idStoreKey = (id: unknown, operation: string): string =>
219
+ keyOf(kindOf(entity, singleKeyOf(entity, operation)) ?? '', id);
220
+ const rows = new Map<string, Row>(seed.map((row) => [storeKey(row), row]));
251
221
 
252
222
  const rowsOf = (plan: QueryPlan, args: FindManyArgs): Row[] => {
253
223
  const visible = (row: Row): boolean =>
@@ -256,11 +226,15 @@ export const memoryRepo = <Row>(
256
226
  field(row, SOFT_DELETE_COLUMN) === null ||
257
227
  field(row, SOFT_DELETE_COLUMN) === undefined;
258
228
  return [...rows.values()]
259
- .filter((row) => plan.where.every((predicate) => matches(row, predicate)))
229
+ .filter((row) => plan.where.every((predicate) => matchesPredicate(entity, row, predicate)))
260
230
  .filter(visible)
261
231
  .sort((left, right) => {
262
232
  for (const entry of plan.orderBy) {
263
- const order = compare(valueAt(left, entry.column), valueAt(right, entry.column));
233
+ const order = compareByKind(
234
+ kindOf(entity, entry.column),
235
+ valueAt(left, entry.column),
236
+ valueAt(right, entry.column),
237
+ );
264
238
  if (order !== 0) return entry.direction === 'desc' ? -order : order;
265
239
  }
266
240
  return 0;
@@ -283,7 +257,7 @@ export const memoryRepo = <Row>(
283
257
  // out of this tenant is refused by the same call that refuses an insert into another one.
284
258
  assertRowTenant(entity.$name, entity.$tenantColumn, operation, row);
285
259
  entity.$assert(row);
286
- const key = keyOf(row);
260
+ const key = storeKey(row);
287
261
  const previous = rows.get(key);
288
262
  options?.tx?.onRollback(() => {
289
263
  if (previous === undefined) rows.delete(key);
@@ -297,7 +271,7 @@ export const memoryRepo = <Row>(
297
271
  // to name a row, so `update`/`delete` resolve through a plan rather than through the map.
298
272
  const addressed = (id: string, options: RepoOptions | undefined, operation: string): Row => {
299
273
  const plan = idPlan(entity, id, options, operation);
300
- const current = rows.get(id);
274
+ const current = rows.get(idStoreKey(id, operation));
301
275
  // A soft-deleted row is hidden from writes too — `delete` on one is `X_NOT_FOUND`, not a
302
276
  // second stamp, which is what the Postgres driver's `deleted_at is null` clause already says.
303
277
  const hidden =
@@ -308,7 +282,7 @@ export const memoryRepo = <Row>(
308
282
  if (
309
283
  current === undefined ||
310
284
  hidden ||
311
- !plan.where.every((predicate) => matches(current, predicate))
285
+ !plan.where.every((predicate) => matchesPredicate(entity, current, predicate))
312
286
  ) {
313
287
  throw notFound(entity.$name, id);
314
288
  }
@@ -334,7 +308,8 @@ export const memoryRepo = <Row>(
334
308
  const more = start + page.length < found.length;
335
309
  return {
336
310
  rows: page,
337
- nextCursor: more && last !== undefined ? cursorFor(entity, plan, last, keyOf(last)) : null,
311
+ nextCursor:
312
+ more && last !== undefined ? cursorFor(entity, plan, last, storeKey(last)) : null,
338
313
  };
339
314
  },
340
315
 
@@ -407,14 +382,10 @@ export const memoryRepo = <Row>(
407
382
  const current = addressed(id, options, 'delete');
408
383
  // Soft delete hides the row without losing it; the column's presence is the switch.
409
384
  if (entity.$softDelete) {
410
- write(
411
- Object.assign({}, current, { [SOFT_DELETE_COLUMN]: systemClock.now() }),
412
- options,
413
- 'delete',
414
- );
385
+ write(Object.assign({}, current, { [SOFT_DELETE_COLUMN]: entityNow() }), options, 'delete');
415
386
  return;
416
387
  }
417
- const key = keyOf(current);
388
+ const key = storeKey(current);
418
389
  options?.tx?.onRollback(() => rows.set(key, current));
419
390
  rows.delete(key);
420
391
  },
@@ -428,13 +399,13 @@ export const memoryRepo = <Row>(
428
399
  for (const row of doomed) {
429
400
  if (entity.$softDelete) {
430
401
  write(
431
- Object.assign({}, row, { [SOFT_DELETE_COLUMN]: systemClock.now() }),
402
+ Object.assign({}, row, { [SOFT_DELETE_COLUMN]: entityNow() }),
432
403
  options,
433
404
  'deleteWhere',
434
405
  );
435
406
  continue;
436
407
  }
437
- const key = keyOf(row);
408
+ const key = storeKey(row);
438
409
  options?.tx?.onRollback(() => rows.set(key, row));
439
410
  rows.delete(key);
440
411
  }
@@ -471,7 +442,7 @@ export const memoryRepo = <Row>(
471
442
 
472
443
  reset() {
473
444
  rows.clear();
474
- for (const row of seed) rows.set(keyOf(row), row);
445
+ for (const row of seed) rows.set(storeKey(row), row);
475
446
  },
476
447
  };
477
448
  };
package/src/seed.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  // every machine), `upsert` where the table owns it and a natural key is all there is.
5
5
 
6
6
  import { createHash } from 'node:crypto';
7
- import { type Environment, resolveEnvironment, systemClock } from '@ultimat3/core';
7
+ import { type Environment, resolveEnvironment } from '@ultimat3/core';
8
+ import { entityNow } from './clock';
8
9
  import type { Driver } from './database';
9
10
  import { memoryDriver } from './database';
10
11
  import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
@@ -325,7 +326,7 @@ export const defineSeed = (
325
326
  },
326
327
 
327
328
  id: seedId,
328
- now: systemClock.now(),
329
+ now: entityNow(),
329
330
  environment: resolveEnvironment({ env: options.env }),
330
331
  tier,
331
332
  dryRun,
package/src/types.ts CHANGED
@@ -15,20 +15,30 @@
15
15
  * and a `bytea` blob cannot be declared at all without them, and an entity that cannot be declared
16
16
  * is a rewrite instead of an adoption.
17
17
  */
18
- export type ColumnKind =
19
- | 'uuid'
20
- | 'text'
21
- | 'char'
22
- | 'boolean'
23
- | 'integer'
24
- | 'bigint'
25
- | 'numeric'
26
- | 'timestamptz'
27
- | 'date'
28
- | 'jsonb'
29
- | 'bytea'
30
- | 'array'
31
- | 'money';
18
+ export const COLUMN_KINDS = [
19
+ 'uuid',
20
+ 'text',
21
+ 'char',
22
+ 'boolean',
23
+ 'integer',
24
+ 'bigint',
25
+ 'numeric',
26
+ 'timestamptz',
27
+ 'date',
28
+ 'jsonb',
29
+ 'bytea',
30
+ 'array',
31
+ 'money',
32
+ ] as const;
33
+
34
+ /**
35
+ * Derived from the array and never written twice — the same shape `PRIMITIVE_KINDS` in
36
+ * `@ultimat3/core`'s `registrar.ts` uses, and for the same reason: a package that has to answer
37
+ * "one case per kind" needs a RUNTIME list, and a list written beside the type is one that drifts
38
+ * from it. `@ultimat3/query`'s `shape-order.test.ts` spelled its own out and counted nine against
39
+ * thirteen — `9 === 9`, a test that could not fail.
40
+ */
41
+ export type ColumnKind = (typeof COLUMN_KINDS)[number];
32
42
 
33
43
  export type ColumnDefault =
34
44
  | { readonly kind: 'value'; readonly value: string | number | boolean | null }
@@ -219,6 +229,38 @@ export type Insertable<C extends ColumnMap> = {
219
229
  readonly [K in DefaultedKeys<C> | NullableKeys<C>]?: InputOf<TypeOf<C[K]>>;
220
230
  };
221
231
 
232
+ /**
233
+ * A patch or a filter: every property optional, **and every property allowed to be present and
234
+ * `undefined`**.
235
+ *
236
+ * `Partial<Row>` cannot say the second half. Under `exactOptionalPropertyTypes` — which this repo
237
+ * has on — `{ k?: T }` means "absent, or a `T`", so `{ postId: undefined }` is a type error, and
238
+ * that is the ONE value every filtered write in this package is built to refuse:
239
+ *
240
+ * - `plan.ts`'s `namedColumns` drops `value !== undefined`, so `deleteWhere({ postId: undefined })`
241
+ * yields no predicate and throws `X_WRITE_UNFILTERED` rather than deleting the table. That
242
+ * refusal exists for exactly one caller — a variable that came back `undefined` — and
243
+ * `Partial<Row>` made the caller unable to spell it and the test unable to prove it.
244
+ * - `updateWhere(filter, { createdAt: undefined })` is `X_PATCH_EMPTY`, one argument over.
245
+ * - `bulk-write.ts`'s `owns()` is `Object.hasOwn`, so for a batch a present-`undefined` property
246
+ * IS a named column — the opposite reading, deliberately, and equally unspellable.
247
+ *
248
+ * A type that forbids the value its own runtime is written to catch is a type that documents
249
+ * nothing: `bulk-write.test.ts` carried `as Partial<Item>` with the comment
250
+ * "`exactOptionalPropertyTypes` is why it takes a cast to say", which is the defect written down.
251
+ *
252
+ * Money goes through `InputOf`, for the reason `Insertable` does: a patch is a WRITE, and money's
253
+ * write shape is the wider one. `conflictKeyOf`'s own `cellKey` renders a `bigint` minor unit
254
+ * (`${part}n`), so refusing one here would have been a type refusing a value one function down
255
+ * spells out how to serialise. The row's own type stays in the union rather than being replaced by
256
+ * `InputOf<Row[K]>`: a conditional type over an unresolved `Row` never reduces, so `Row` itself
257
+ * stopped being assignable to its own patch and every internal caller reddened. Both spellings are
258
+ * accepted here, which is the honest statement anyway — a patch may carry either.
259
+ */
260
+ export type RowPatch<Row> = {
261
+ readonly [K in keyof Row]?: Row[K] | InputOf<Row[K]> | undefined;
262
+ };
263
+
222
264
  export interface IndexDef {
223
265
  readonly name: string;
224
266
  readonly columns: readonly string[];