@ultimat3/entity 2.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/pg-sql.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // declared. That is the whole reason this file exists instead of a template literal per method.
5
5
 
6
6
  import { identifier, join, raw, type SqlFragment, sql } from '@ultimat3/db';
7
- import { snake } from './column';
7
+ import { columnFor, columnName } from './column';
8
8
  import type { EntityCore } from './entity';
9
9
  import { SOFT_DELETE_COLUMN } from './entity';
10
10
  import { allColumns, columnsOf, physicalName } from './pg-row';
@@ -33,10 +33,23 @@ const predicateSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFr
33
33
  // `is distinct from` so a null on either side compares as a value, not as unknown.
34
34
  return sql`${column} is distinct from ${value}`;
35
35
  case 'in': {
36
- const values = Array.isArray(value) ? value : [value];
37
- return values.length === 0
38
- ? NEVER
39
- : sql`${column} in (${join(values.map((each) => sql`${each}`))})`;
36
+ // `in` reads a LIST or nothing. A scalar used to be wrapped into a one-element list, which
37
+ // matched a row here that `memoryRepo`'s `matches` refuses outright — 0 rows in memory, 1 in
38
+ // Postgres, from a call `andWhere(column, op, value: unknown)` compiles. One answer, and it
39
+ // is the one `@ultimat3/query`'s `filterClause` already gives: no rows.
40
+ if (!Array.isArray(value)) return NEVER;
41
+ // A NULL bound as a parameter is `col = null`, which is UNKNOWN and therefore excludes the
42
+ // very row the caller listed — while memory's `sameValue(null, null)` includes it. Postgres
43
+ // has no `in` that compares a null as a value, so the list is partitioned and the nulls are
44
+ // asked for as `is null`: the `(… in (…) or … is null)` pair `eq` and `neq` already emit.
45
+ const present = value.filter((each) => each !== null && each !== undefined);
46
+ const list =
47
+ present.length === 0
48
+ ? undefined
49
+ : sql`${column} in (${join(present.map((e) => sql`${e}`))})`;
50
+ const nulls = present.length === value.length ? undefined : sql`${column} is null`;
51
+ if (list === undefined) return nulls ?? NEVER;
52
+ return nulls === undefined ? list : sql`(${list} or ${nulls})`;
40
53
  }
41
54
  case 'gt':
42
55
  return sql`${column} > ${value}`;
@@ -111,7 +124,7 @@ const conditions = <Row>(
111
124
  ): SqlFragment => {
112
125
  const parts = plan.where.map((predicate) => predicateSql(entity, predicate));
113
126
  if (entity.$softDelete && !shape.includeDeleted) {
114
- parts.push(sql`${identifier(snake(SOFT_DELETE_COLUMN))} is null`);
127
+ parts.push(sql`${identifier(physicalName(entity, SOFT_DELETE_COLUMN))} is null`);
115
128
  }
116
129
  if (shape.seek !== undefined) parts.push(seekSql(entity, plan.orderBy, shape.seek));
117
130
  return parts.length === 0 ? sql`true` : join(parts, ' and ');
@@ -137,7 +150,7 @@ const projection = <Row>(entity: EntityCore<Row>, plan: QueryPlan): SqlFragment
137
150
  ...plan.orderBy.map((entry) => entry.column.split('.')[0] ?? entry.column),
138
151
  ]);
139
152
  const names = [...wanted].flatMap((property) => {
140
- const column = entity.$columns[property];
153
+ const column = columnFor(entity.$columns, property);
141
154
  return column === undefined ? [] : columnsOf(property, column);
142
155
  });
143
156
  return join(names.map(identifier));
@@ -224,6 +237,35 @@ const conflictSql = (conflict: ConflictTarget): SqlFragment => {
224
237
  )}`;
225
238
  };
226
239
 
240
+ /**
241
+ * The one column that cannot be bound as itself. A `jsonb` value is a plain object, and the
242
+ * driver seam refuses one as a parameter (`X_SQL_UNSAFE` — `isBoundValue` takes scalars, a `Date`,
243
+ * a `Uint8Array` and arrays of those); so `bindValues` hands over the JSON TEXT and the cell says
244
+ * what to do with it.
245
+ *
246
+ * `::text::jsonb` and not `::jsonb`, and the double cast is load-bearing rather than defensive.
247
+ * Measured against Postgres 17.10 through Bun's `sql`: with `$1::jsonb` the server describes the
248
+ * parameter as `jsonb`, the client JSON-ENCODES the string it was given, and `{"a":1}` is stored
249
+ * as the JSON *string* `"{\"a\":1}"` — `jsonb_typeof` says `string`. Pinning the parameter to
250
+ * `text` first makes the client send the characters and the server parse them, which is the one
251
+ * spelling that stores an object.
252
+ */
253
+ /** Physical names of this entity's `jsonb` columns. Resolved ONCE per statement, never per cell. */
254
+ const jsonColumns = <Row>(entity: EntityCore<Row>): ReadonlySet<string> => {
255
+ const names = new Set<string>();
256
+ for (const [property, column] of Object.entries(entity.$columns)) {
257
+ if (column.$meta.kind === 'jsonb') names.add(columnName(property, column.$meta));
258
+ }
259
+ return names;
260
+ };
261
+
262
+ /**
263
+ * `${value}`, plus the cast that column needs. The `raw()` argument is a literal written here and
264
+ * nowhere else — the audit point that call is stays a two-word constant, never a value.
265
+ */
266
+ const cell = (json: ReadonlySet<string>, column: string, value: unknown): SqlFragment =>
267
+ json.has(column) ? sql`${value}${raw('::text::jsonb')}` : sql`${value}`;
268
+
227
269
  /**
228
270
  * One statement for any number of rows. A single row compiles to exactly the text it always did,
229
271
  * which is the point: `insertAll([row])` and `insert(row)` are one code path, so there is no
@@ -234,10 +276,13 @@ export const insertStatement = <Row>(
234
276
  rows: readonly ReadonlyMap<string, unknown>[],
235
277
  shape: InsertShape,
236
278
  ): SqlFragment => {
279
+ const json = jsonColumns(entity);
237
280
  const tuples = rows.map(
238
281
  (row) =>
239
282
  sql`(${join(
240
- shape.columns.map((column) => (row.has(column) ? sql`${row.get(column)}` : DEFAULT_CELL)),
283
+ shape.columns.map((column) =>
284
+ row.has(column) ? cell(json, column, row.get(column)) : DEFAULT_CELL,
285
+ ),
241
286
  )})`,
242
287
  );
243
288
  const conflict = shape.conflict === undefined ? sql`` : conflictSql(shape.conflict);
@@ -259,10 +304,12 @@ export const updateStatement = <Row>(
259
304
  values: ReadonlyMap<string, unknown>,
260
305
  shape: ReadShape,
261
306
  returning: boolean,
262
- ): SqlFragment =>
263
- sql`update ${identifier(entity.$table)} set ${join(
264
- [...values].map(([column, value]) => sql`${identifier(column)} = ${value}`),
307
+ ): SqlFragment => {
308
+ const json = jsonColumns(entity);
309
+ return sql`update ${identifier(entity.$table)} set ${join(
310
+ [...values].map(([column, value]) => sql`${identifier(column)} = ${cell(json, column, value)}`),
265
311
  )} where ${conditions(entity, plan, shape)}${returning ? sql` returning *` : sql``}`;
312
+ };
266
313
 
267
314
  /** Only reached when the entity has no soft-delete column, so there is no filter to apply. */
268
315
  export const deleteStatement = <Row>(entity: EntityCore<Row>, plan: QueryPlan): SqlFragment =>
package/src/plan.ts CHANGED
@@ -8,6 +8,7 @@ import { EntityError, invariantViolated, patchEmpty, writeUnfiltered } from './e
8
8
  import type { FindManyArgs, RepoOptions } from './repo';
9
9
  import type { Predicate, QueryPlan, SortKey } from './tenancy';
10
10
  import { scopedPlan } from './tenancy';
11
+ import type { RowPatch } from './types';
11
12
 
12
13
  /** A page is bounded by default; an unbounded read is a production incident waiting for traffic. */
13
14
  export const DEFAULT_PAGE_SIZE = 50;
@@ -146,7 +147,7 @@ export const namedColumns = (values: unknown): readonly (readonly [string, unkno
146
147
  /** The filter a filtered write is allowed to run with: never the empty one. */
147
148
  const boundedWhere = <Row>(
148
149
  entity: EntityCore<Row>,
149
- filter: Partial<Row>,
150
+ filter: RowPatch<Row>,
150
151
  operation: string,
151
152
  ): Predicate[] => {
152
153
  const where = namedColumns(filter).map(
@@ -166,7 +167,7 @@ const boundedWhere = <Row>(
166
167
  */
167
168
  export const deletePlan = <Row>(
168
169
  entity: EntityCore<Row>,
169
- filter: Partial<Row>,
170
+ filter: RowPatch<Row>,
170
171
  options: RepoOptions | undefined,
171
172
  operation: string,
172
173
  ): QueryPlan =>
@@ -179,8 +180,8 @@ export const deletePlan = <Row>(
179
180
  */
180
181
  export const updatePlan = <Row>(
181
182
  entity: EntityCore<Row>,
182
- filter: Partial<Row>,
183
- patch: Partial<Row>,
183
+ filter: RowPatch<Row>,
184
+ patch: RowPatch<Row>,
184
185
  options: RepoOptions | undefined,
185
186
  operation: string,
186
187
  ): QueryPlan => {
package/src/query.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  // concurrent writes an insert before the offset shifts every later page, so a client silently
5
5
  // skips and repeats rows.
6
6
 
7
- import { systemClock } from '@ultimat3/core';
8
7
  import type { BatchIterator } from './batch';
9
8
  import { assertBatchable, batchIterator } from './batch';
9
+ import { entityNow } from './clock';
10
10
  import type { EntityCore } from './entity';
11
11
  import { assertPageSize, DEFAULT_PAGE_SIZE, namedColumns } from './plan';
12
12
  import type { RelatedTables } from './preload';
@@ -15,7 +15,7 @@ import type { Relation } from './relations';
15
15
  import { relationNamed } from './relations';
16
16
  import type { Page, Repo, RepoOptions, UpsertArgs } from './repo';
17
17
  import type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
18
- import type { ColumnMap, IdOf, Insertable } from './types';
18
+ import type { ColumnMap, IdOf, Insertable, RowPatch } from './types';
19
19
 
20
20
  /**
21
21
  * What a preloaded relation adds to a row. `unknown` because the name is a string resolved at
@@ -26,7 +26,7 @@ export type Preloaded<Name extends string> = { readonly [K in Name]: unknown };
26
26
 
27
27
  export interface ReadBuilder<Row> {
28
28
  /** Equality on the columns given. `where({ orgId })` is what satisfies the tenancy guard. */
29
- where(filter: Partial<Row>): ReadBuilder<Row>;
29
+ where(filter: RowPatch<Row>): ReadBuilder<Row>;
30
30
  andWhere(column: keyof Row & string, op: Operator, value: unknown): ReadBuilder<Row>;
31
31
  orderBy(column: keyof Row & string, direction?: SortDirection): ReadBuilder<Row>;
32
32
  limit(rows: number): ReadBuilder<Row>;
@@ -106,21 +106,21 @@ export interface Table<Row, C extends ColumnMap = ColumnMap> extends ReadBuilder
106
106
  */
107
107
  upsertAll(rows: readonly Insertable<C>[], args: UpsertArgs<Row>): Promise<readonly Row[]>;
108
108
  /** `IdOf<Row>`: an entity that declared `uuid<PostId>()` is addressed by a `PostId` only. */
109
- update(id: IdOf<Row>, patch: Partial<Row>, options?: RepoOptions): Promise<Row>;
109
+ update(id: IdOf<Row>, patch: RowPatch<Row>, options?: RepoOptions): Promise<Row>;
110
110
  delete(id: IdOf<Row>, options?: RepoOptions): Promise<void>;
111
111
  /**
112
112
  * Delete by equality filter; resolves with the number of rows removed. The only way to remove a
113
113
  * row from an entity whose primary key is composite — `likes`, `blocks`, a join table — where
114
114
  * one id cannot name it. `deleteWhere({})` is `X_WRITE_UNFILTERED`, never every row.
115
115
  */
116
- deleteWhere(filter: Partial<Row>, options?: RepoOptions): Promise<number>;
116
+ deleteWhere(filter: RowPatch<Row>, options?: RepoOptions): Promise<number>;
117
117
  /**
118
118
  * Update by equality filter; resolves with the number of rows written. The `update(id, patch)`
119
119
  * a composite primary key cannot express — `participants.updateWhere({ conversationId, userId },
120
120
  * { lastReadAt })` is the reference case. Empty filter: `X_WRITE_UNFILTERED`. Empty patch:
121
121
  * `X_PATCH_EMPTY`. `onUpdateNow()` columns are stamped exactly as `update(id, patch)` stamps them.
122
122
  */
123
- updateWhere(filter: Partial<Row>, patch: Partial<Row>, options?: RepoOptions): Promise<number>;
123
+ updateWhere(filter: RowPatch<Row>, patch: RowPatch<Row>, options?: RepoOptions): Promise<number>;
124
124
  }
125
125
 
126
126
  interface State {
@@ -311,7 +311,7 @@ const touch = <Row, Patch>(entity: EntityCore<Row>, patch: Patch): Patch => {
311
311
  if (namedColumns(patch).length === 0) return patch;
312
312
  const stamped: Record<string, unknown> = {};
313
313
  for (const [property, column] of Object.entries(entity.$columns)) {
314
- if (column.$meta.onUpdate !== undefined) stamped[property] = systemClock.now();
314
+ if (column.$meta.onUpdate !== undefined) stamped[property] = entityNow();
315
315
  }
316
316
  return Object.assign({}, patch, stamped);
317
317
  };
package/src/registry.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { entityDuplicate } from './errors';
7
7
  import type { InvariantKind } from './invariants';
8
+ import type { OnDelete } from './types';
8
9
 
9
10
  export interface ColumnDescription {
10
11
  readonly property: string;
@@ -16,6 +17,13 @@ export interface ColumnDescription {
16
17
  readonly hasDefault: boolean;
17
18
  readonly check: string | null;
18
19
  readonly references: string | null;
20
+ /**
21
+ * The `references()` rule, `null` when the key declared none. Beside `references` rather than
22
+ * inside it because that field is a flat `"<table>.<column>"` string with no room for it, and
23
+ * `@ultimat3/db` is tier 1: it cannot import this package, so a rule that is not on this
24
+ * projection reaches no `alter table` at all. It reached none until 3.0.
25
+ */
26
+ readonly onDelete: OnDelete | null;
19
27
  }
20
28
 
21
29
  /**
@@ -35,6 +43,8 @@ export interface ReferenceDescription {
35
43
  readonly targetEntity: string;
36
44
  readonly targetProperty: string;
37
45
  readonly targetColumn: string;
46
+ /** What the database does to this row when the target goes. `null` is Postgres' `no action`. */
47
+ readonly onDelete: OnDelete | null;
38
48
  }
39
49
 
40
50
  export interface InvariantDescription {
package/src/relations.ts CHANGED
@@ -204,7 +204,10 @@ export const relationsFor = (entityName: string): EntityRelations =>
204
204
  */
205
205
  export const relationNamed = (entityName: string, name: string): Relation => {
206
206
  const relations = relationsFor(entityName);
207
- const relation = relations[name];
207
+ // `relations[name]` walks the prototype: `preload('toString')` used to hand back
208
+ // `Function.prototype.toString` AS a `Relation`, past the refusal, to be read for a `.through`
209
+ // it does not have. A relation map is derived from foreign keys, so a name is caller data here.
210
+ const relation = Object.hasOwn(relations, name) ? relations[name] : undefined;
208
211
  if (relation === undefined) {
209
212
  throw preloadUnknownRelation(entityName, name, Object.keys(relations));
210
213
  }
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
  };