@ultimat3/entity 11.3.0 → 12.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.
@@ -0,0 +1,357 @@
1
+ // Single responsibility: the in-memory driver. Same `Repo` contract as `postgresRepo`, the same
2
+ // plans, the same cursor — the only difference is where the rows are, which is the point: a test
3
+ // that passes here means something about Postgres.
4
+ //
5
+ // Split from `repo.ts` when that file passed the 500-line ceiling. What stays there is the
6
+ // CONTRACT — `Repo`, `Page`, `FindManyArgs`, `Transactor` — which `postgresRepo` implements too
7
+ // and which nothing about storing rows in a `Map` belongs in.
8
+
9
+ import { aggregateColumnOf } from './aggregate';
10
+ import { foldAggregate } from './aggregate-fold';
11
+ import { keyOf } from './batch-read';
12
+ import { conflictKeyOf, conflictKeys, upsertPlan } from './bulk-write';
13
+ import { entityNow } from './clock';
14
+ import { narrowMoney } from './columns';
15
+ import { countsFrom, groupColumnOf } from './count-by';
16
+ import { cursorFor, kindOf, seekFrom, valueAt } from './cursor';
17
+ import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
18
+ import { notFound } from './errors';
19
+ import { compareByKind, matchesPredicate } from './memory-match';
20
+ import { deletePlan, idPlan, readPlan, singleKeyOf, updatePlan } from './plan';
21
+ import type { FindManyArgs, MemoryRepo, RepoOptions, Transactor, Tx } from './repo';
22
+ import type { QueryPlan } from './tenancy';
23
+ import { assertRowTenant } from './tenancy';
24
+
25
+ const field = (row: unknown, property: string): unknown =>
26
+ typeof row === 'object' && row !== null ? (row as Record<string, unknown>)[property] : undefined;
27
+
28
+ /** Lexicographic over the sort keys, direction applied. `> 0` means "after the cursor". */
29
+ const compareToSeek = <Row>(
30
+ entity: EntityCore<Row>,
31
+ plan: QueryPlan,
32
+ row: unknown,
33
+ seek: readonly unknown[],
34
+ ): number => {
35
+ for (const [index, entry] of plan.orderBy.entries()) {
36
+ // The COLUMN's kind, not the value's: the seek was revived from the same kind (`cursor.ts`),
37
+ // so a `bigint` column compares its stored decimal string against a revived `BigInt` as one
38
+ // number instead of as two pieces of text.
39
+ const order = compareByKind(
40
+ kindOf(entity, entry.column),
41
+ valueAt(row, entry.column),
42
+ seek[index],
43
+ );
44
+ if (order !== 0) return entry.direction === 'desc' ? -order : order;
45
+ }
46
+ return 0;
47
+ };
48
+
49
+ /**
50
+ * Where the next page starts. By sort position, not by the previous row's id: that row may have
51
+ * been deleted between the two requests, and an id that is no longer there would restart
52
+ * pagination at the top instead of continuing it.
53
+ */
54
+ const afterCursor = <Row>(
55
+ entity: EntityCore<Row>,
56
+ plan: QueryPlan,
57
+ found: readonly Row[],
58
+ ): number => {
59
+ const seek = seekFrom(entity, plan);
60
+ if (seek === undefined) return 0;
61
+ const start = found.findIndex((row) => compareToSeek(entity, plan, row, seek) > 0);
62
+ return start === -1 ? found.length : start;
63
+ };
64
+
65
+ /**
66
+ * The default driver: correct semantics, no database. `x dev` uses it before the first
67
+ * migration and tests use it everywhere. Postgres is the production driver and implements
68
+ * this same interface.
69
+ */
70
+ export const memoryRepo = <Row>(
71
+ entity: EntityCore<Row>,
72
+ seed: readonly Row[] = [],
73
+ ): MemoryRepo<Row> => {
74
+ /**
75
+ * A stored row's key, spelled the way `batch-read.ts` spells an id — because Postgres compares a
76
+ * `uuid` as a VALUE and prints it lower-cased, so `findById(UPPER)` reads the row there while
77
+ * `String(...)` missed it here: `null` from a read and `X_NOT_FOUND` from a write, against a row
78
+ * that exists, reachable from a path parameter, a client-supplied id or a legacy import.
79
+ */
80
+ const storeKey = (row: unknown): string =>
81
+ entity.$primaryKey
82
+ .map((property) => keyOf(kindOf(entity, property) ?? '', field(row, property)))
83
+ .join('');
84
+ /** The same key, from the id a caller named rather than from a row it has in hand. */
85
+ const idStoreKey = (id: unknown, operation: string): string =>
86
+ keyOf(kindOf(entity, singleKeyOf(entity, operation)) ?? '', id);
87
+ const rows = new Map<string, Row>(seed.map((row) => [storeKey(row), row]));
88
+
89
+ const rowsOf = (plan: QueryPlan, args: FindManyArgs): Row[] => {
90
+ const visible = (row: Row): boolean =>
91
+ !entity.$softDelete ||
92
+ args.includeDeleted === true ||
93
+ field(row, SOFT_DELETE_COLUMN) === null ||
94
+ field(row, SOFT_DELETE_COLUMN) === undefined;
95
+ return [...rows.values()]
96
+ .filter((row) => plan.where.every((predicate) => matchesPredicate(entity, row, predicate)))
97
+ .filter(visible)
98
+ .sort((left, right) => {
99
+ for (const entry of plan.orderBy) {
100
+ const order = compareByKind(
101
+ kindOf(entity, entry.column),
102
+ valueAt(left, entry.column),
103
+ valueAt(right, entry.column),
104
+ );
105
+ if (order !== 0) return entry.direction === 'desc' ? -order : order;
106
+ }
107
+ return 0;
108
+ });
109
+ };
110
+
111
+ const select = (args: FindManyArgs, operation: string): { plan: QueryPlan; found: Row[] } => {
112
+ const plan = readPlan(entity, args, operation);
113
+ return { plan, found: rowsOf(plan, args) };
114
+ };
115
+
116
+ const write = (given: Row, options: RepoOptions | undefined, operation: string): Row => {
117
+ // `MoneyInput` lets a writer hand a `bigint`; a stored row holds the value type. The Postgres
118
+ // driver narrows in `bindValues` and reads its answer back through `returning *`, so without
119
+ // this an in-memory row would be the one row in the framework `JSON.stringify` refuses.
120
+ const row = narrowMoney(entity.$columns, given);
121
+ // Beside `$assert`, and before the row lands: a write is judged by the tenant it names as well
122
+ // as by the invariants it declares, and the Postgres driver runs the same pair in `writeRows`.
123
+ // `update` reaches here with the STORED row merged under its patch, so a patch that moves a row
124
+ // out of this tenant is refused by the same call that refuses an insert into another one.
125
+ assertRowTenant(entity.$name, entity.$tenantColumn, operation, row);
126
+ entity.$assert(row);
127
+ const key = storeKey(row);
128
+ const previous = rows.get(key);
129
+ options?.tx?.onRollback(() => {
130
+ if (previous === undefined) rows.delete(key);
131
+ else rows.set(key, previous);
132
+ });
133
+ rows.set(key, row);
134
+ return row;
135
+ };
136
+
137
+ // The same guard the read path applies: on a tenant-scoped entity an id alone is not enough
138
+ // to name a row, so `update`/`delete` resolve through a plan rather than through the map.
139
+ const addressed = (id: string, options: RepoOptions | undefined, operation: string): Row => {
140
+ const plan = idPlan(entity, id, options, operation);
141
+ const current = rows.get(idStoreKey(id, operation));
142
+ // A soft-deleted row is hidden from writes too — `delete` on one is `X_NOT_FOUND`, not a
143
+ // second stamp, which is what the Postgres driver's `deleted_at is null` clause already says.
144
+ const hidden =
145
+ current !== undefined &&
146
+ entity.$softDelete &&
147
+ field(current, SOFT_DELETE_COLUMN) !== null &&
148
+ field(current, SOFT_DELETE_COLUMN) !== undefined;
149
+ if (
150
+ current === undefined ||
151
+ hidden ||
152
+ !plan.where.every((predicate) => matchesPredicate(entity, current, predicate))
153
+ ) {
154
+ throw notFound(entity.$name, id);
155
+ }
156
+ return current;
157
+ };
158
+
159
+ // Every method is async: a repository call that fails must reject, never throw
160
+ // synchronously, or half the call sites would need two error paths.
161
+ return {
162
+ async findById(id, options) {
163
+ const { found } = select(
164
+ { ...options, where: [{ column: singleKeyOf(entity, 'findById'), op: 'eq', value: id }] },
165
+ 'findById',
166
+ );
167
+ return found[0] ?? null;
168
+ },
169
+
170
+ async findMany(args = {}) {
171
+ const { plan, found } = select(args, 'findMany');
172
+ const start = afterCursor(entity, plan, found);
173
+ const page = found.slice(start, start + plan.limit);
174
+ const last = page.at(-1);
175
+ const more = start + page.length < found.length;
176
+ return {
177
+ rows: page,
178
+ nextCursor:
179
+ more && last !== undefined ? cursorFor(entity, plan, last, storeKey(last)) : null,
180
+ };
181
+ },
182
+
183
+ async insert(values, options) {
184
+ return write(values, options, 'insert');
185
+ },
186
+
187
+ async insertAll(batch, options) {
188
+ // The whole batch is judged before any of it lands: Postgres refuses the statement as one,
189
+ // so a row an invariant rejects — or one naming a tenant this actor may not write — must not
190
+ // leave the rows before it stored here either. `write` re-checks both per row; this loop is
191
+ // what makes the batch all-or-nothing, which is the half a per-row check cannot give.
192
+ for (const row of batch) {
193
+ assertRowTenant(entity.$name, entity.$tenantColumn, 'insertAll', row);
194
+ entity.$assert(row);
195
+ }
196
+ return batch.map((row) => write(row, options, 'insertAll'));
197
+ },
198
+
199
+ async upsertAll(batch, args) {
200
+ // The INCOMING rows, judged before any of them is matched: under `onMatch: 'nothing'` a
201
+ // colliding row is skipped and never reaches `write()`, so checking only what lands would
202
+ // let a row naming another tenant through whenever it happened to collide.
203
+ for (const row of batch) {
204
+ assertRowTenant(entity.$name, entity.$tenantColumn, 'upsertAll', row);
205
+ entity.$assert(row);
206
+ }
207
+ const plan = upsertPlan(entity, batch, args.onConflict, args.onMatch ?? 'update');
208
+ const keys = conflictKeys(entity, plan, batch);
209
+ // The stored rows under the same key, so "does this collide" is the question the unique
210
+ // index answers in Postgres and not a scan per row. A soft-deleted row still occupies its
211
+ // key here, because the index it would collide with there is not partial either — and a row
212
+ // whose target holds a null occupies none, because the index is `NULLS DISTINCT`.
213
+ const stored = new Map<string, Row>();
214
+ for (const row of rows.values()) {
215
+ const key = conflictKeyOf(entity, plan.on, row);
216
+ if (key !== undefined) stored.set(key, row);
217
+ }
218
+ const written: Row[] = [];
219
+ for (const [position, row] of batch.entries()) {
220
+ const key = keys[position];
221
+ const existing = key === undefined ? undefined : stored.get(key);
222
+ // `do nothing` writes no row, and `returning *` therefore names none: a skipped row is
223
+ // absent from the result rather than present and unchanged.
224
+ if (existing !== undefined && plan.set.length === 0) continue;
225
+ const merged =
226
+ existing === undefined
227
+ ? row
228
+ : Object.assign(
229
+ {},
230
+ existing,
231
+ Object.fromEntries(plan.set.map((property) => [property, field(row, property)])),
232
+ );
233
+ // `UpsertArgs extends RepoOptions`, so the args ARE the options — one bag, and a `tx`
234
+ // passed to an upsert registers its undo exactly as it does for every other write here.
235
+ const result = write(merged, args, 'upsertAll');
236
+ // Filed as it lands, so a later row of the same batch collides with an earlier one exactly
237
+ // as it would with a row the request stored a moment before it.
238
+ if (key !== undefined) stored.set(key, result);
239
+ written.push(result);
240
+ }
241
+ return written;
242
+ },
243
+
244
+ async update(id, patch, options) {
245
+ return write(Object.assign({}, addressed(id, options, 'update'), patch), options, 'update');
246
+ },
247
+
248
+ async delete(id, options) {
249
+ const current = addressed(id, options, 'delete');
250
+ // Soft delete hides the row without losing it; the column's presence is the switch.
251
+ if (entity.$softDelete) {
252
+ write(Object.assign({}, current, { [SOFT_DELETE_COLUMN]: entityNow() }), options, 'delete');
253
+ return;
254
+ }
255
+ const key = storeKey(current);
256
+ options?.tx?.onRollback(() => rows.set(key, current));
257
+ rows.delete(key);
258
+ },
259
+
260
+ async deleteWhere(filter, options) {
261
+ // `rowsOf` is the read path: the same predicates, the same tenant scoping, and the same
262
+ // soft-delete visibility. A row already stamped is not matched, so a second call cannot
263
+ // move `deletedAt` forward — which is what the Postgres driver's `deleted_at is null`
264
+ // clause says there.
265
+ const doomed = rowsOf(deletePlan(entity, filter, options, 'deleteWhere'), {});
266
+ for (const row of doomed) {
267
+ if (entity.$softDelete) {
268
+ write(
269
+ Object.assign({}, row, { [SOFT_DELETE_COLUMN]: entityNow() }),
270
+ options,
271
+ 'deleteWhere',
272
+ );
273
+ continue;
274
+ }
275
+ const key = storeKey(row);
276
+ options?.tx?.onRollback(() => rows.set(key, row));
277
+ rows.delete(key);
278
+ }
279
+ return doomed.length;
280
+ },
281
+
282
+ async updateWhere(filter, patch, options) {
283
+ const plan = updatePlan(entity, filter, patch, options, 'updateWhere');
284
+ // The PATCH, judged whole and before the rows are read — the same call `postgresRepo` makes
285
+ // before its statement exists. Inside the loop below it is judged only where a row was
286
+ // matched, so a patch handing rows to another tenant was refused or accepted depending on
287
+ // what the table happened to hold: `updateWhere(filter, { orgId: theirs })` over a filter
288
+ // matching nothing answered `0` here and threw there, from one call.
289
+ assertRowTenant(entity.$name, entity.$tenantColumn, 'updateWhere', patch);
290
+ // `rowsOf` again, so a soft-deleted row is as unreachable here as it is through
291
+ // `addressed()` — patching a row the app has already deleted is not an update, it is a
292
+ // resurrection nobody asked for. `write` re-asserts the invariants on each result.
293
+ const found = rowsOf(plan, {});
294
+ for (const row of found) write(Object.assign({}, row, patch), options, 'updateWhere');
295
+ return found.length;
296
+ },
297
+
298
+ async count(args = {}) {
299
+ return select(args, 'count').found.length;
300
+ },
301
+
302
+ async aggregate(fn, column, args = {}) {
303
+ // Refused before a row is read, and by the same function the Postgres driver calls: a column
304
+ // that has no aggregate is that mistake in both drivers or in neither.
305
+ const declared = aggregateColumnOf(entity, fn, column);
306
+ const { found } = select(args, fn);
307
+ return foldAggregate(entity, fn, column, declared.$meta.kind, found);
308
+ },
309
+
310
+ /**
311
+ * Exact here, and that is the honest answer rather than a shortcut: the estimate exists
312
+ * because `count(*)` walks every visible row of a real table, and this driver's rows are
313
+ * already an array whose length is free. Filters are refused in both drivers by `plan.ts`,
314
+ * so the two still answer the same QUESTION.
315
+ */
316
+ async approximateCount(args = {}) {
317
+ return select(args, 'approximateCount').found.length;
318
+ },
319
+
320
+ async countBy(column, args = {}) {
321
+ // Refused before a row is read, and by the same function the Postgres driver calls: a column
322
+ // a map cannot be keyed by is that mistake in both drivers or in neither.
323
+ groupColumnOf(entity, column, 'countBy');
324
+ const { found } = select(args, 'countBy');
325
+ const groups = new Map<unknown, number>();
326
+ for (const row of found) {
327
+ // `?? null`, so a property this row never carried lands in the same group Postgres puts a
328
+ // NULL row in — and `0`, `''` and `false` stay the values they are.
329
+ const value = field(row, column) ?? null;
330
+ groups.set(value, (groups.get(value) ?? 0) + 1);
331
+ }
332
+ return countsFrom(entity, column, 'countBy', [...groups]);
333
+ },
334
+
335
+ reset() {
336
+ rows.clear();
337
+ for (const row of seed) rows.set(storeKey(row), row);
338
+ },
339
+ };
340
+ };
341
+
342
+ let txCounter = 0;
343
+
344
+ /** In-memory transactor: undo closures registered by drivers run on failure. */
345
+ export const memoryTransactor = (): Transactor => ({
346
+ async run(work) {
347
+ const undos: (() => void)[] = [];
348
+ txCounter += 1;
349
+ const tx: Tx = { id: `tx-${txCounter}`, onRollback: (undo) => undos.push(undo) };
350
+ try {
351
+ return await work(tx);
352
+ } catch (error) {
353
+ for (const undo of undos.reverse()) undo();
354
+ throw error;
355
+ }
356
+ },
357
+ });
package/src/pg-driver.ts CHANGED
@@ -16,6 +16,8 @@ import {
16
16
  withStatementAttribution,
17
17
  withTransaction,
18
18
  } from '@ultimat3/db';
19
+ import { aggregateColumnOf, aggregateMinor, assertOneUnit } from './aggregate';
20
+ import { decodeAggregate } from './aggregate-decode';
19
21
  import {
20
22
  conflictKeys,
21
23
  insertChunks,
@@ -25,6 +27,7 @@ import {
25
27
  } from './bulk-write';
26
28
  import { entityNow } from './clock';
27
29
  import { coalesceFindById } from './coalesce';
30
+ import { moneyColumns } from './column';
28
31
  import { countsFrom, groupColumnOf, groupValue, MAX_GROUPS } from './count-by';
29
32
  import { cursorFor, seekFrom, valueAt } from './cursor';
30
33
  import type { Driver } from './database';
@@ -32,18 +35,25 @@ import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
32
35
  import { notFound, repoClientPinned } from './errors';
33
36
  import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
34
37
  import { forgetPreloaded, tagSiblings } from './jit-preload';
35
- import { bindValues, decodeRow, type PhysicalRow, physicalName } from './pg-row';
38
+ import { bindValues, decodeRow, type PhysicalRow, physicalName, sortPrecision } from './pg-row';
36
39
  import {
37
- type ConflictTarget,
40
+ type AggregateRow,
41
+ aggregateStatement,
38
42
  countByStatement,
39
43
  countStatement,
40
- deleteStatement,
44
+ currenciesStatement,
45
+ estimateStatement,
41
46
  type GroupRow,
42
- insertStatement,
47
+ type MoneyUnitRow,
43
48
  type ReadShape,
44
49
  selectStatement,
45
- updateStatement,
46
50
  } from './pg-sql';
51
+ import {
52
+ type ConflictTarget,
53
+ deleteStatement,
54
+ insertStatement,
55
+ updateStatement,
56
+ } from './pg-write-sql';
47
57
  import { deletePlan, idPlan, readPlan, updatePlan } from './plan';
48
58
  import type { FindManyArgs, Repo, Transactor, UpsertArgs } from './repo';
49
59
  import type { QueryPlan } from './tenancy';
@@ -219,7 +229,8 @@ export const postgresRepo = <Row>(
219
229
  selectStatement(entity, plan, shapeOf(args, seekFrom(entity, plan)), plan.limit + 1),
220
230
  ),
221
231
  );
222
- const rows = found.slice(0, plan.limit).map((row) => decodeRow(entity, row));
232
+ const page = found.slice(0, plan.limit);
233
+ const rows = page.map((row) => decodeRow(entity, row));
223
234
  // What the page leaves behind: its foreign key values, so the first `findById` for any one
224
235
  // of them resolves the key for the whole page. A `for … of` loop over these rows costs one
225
236
  // statement, not one per row — its `await` already ended the coalescing window.
@@ -229,7 +240,16 @@ export const postgresRepo = <Row>(
229
240
  rows,
230
241
  nextCursor:
231
242
  found.length > plan.limit && last !== undefined
232
- ? cursorFor(entity, plan, last, idOf(last))
243
+ ? // Minted from the PHYSICAL row as well as the decoded one: a `timestamptz` decodes
244
+ // to a `Date`, and the microseconds that drops are the difference between a position
245
+ // the `order by` agrees with and one that cuts between two rows.
246
+ cursorFor(
247
+ entity,
248
+ plan,
249
+ last,
250
+ idOf(last),
251
+ sortPrecision(entity, plan.orderBy, page.at(-1)),
252
+ )
233
253
  : null,
234
254
  };
235
255
  },
@@ -376,6 +396,73 @@ export const postgresRepo = <Row>(
376
396
  rows.map((row) => [groupValue(grouped, row.group_value), Number(row.group_count)] as const),
377
397
  );
378
398
  },
399
+
400
+ async aggregate(fn, column, args = {}) {
401
+ const op = fn;
402
+ const declared = aggregateColumnOf(entity, fn, column);
403
+ const plan = readPlan(entity, args, op);
404
+ const shape = shapeOf(args);
405
+ const money = declared.$meta.kind === 'money' ? moneyColumns(column, declared.$meta) : null;
406
+ // Money is judged BEFORE the aggregate is asked for, in its own statement: `sum(minor)` over
407
+ // two currencies is a number in neither, and the refusal has to name them.
408
+ const unit =
409
+ money === null
410
+ ? undefined
411
+ : assertOneUnit(
412
+ entity,
413
+ fn,
414
+ column,
415
+ (
416
+ await attributed(op, () =>
417
+ client().query<MoneyUnitRow>(
418
+ currenciesStatement(entity, plan, shape, money.currency, money.scale),
419
+ ),
420
+ )
421
+ ).map((row) => ({
422
+ currency: String(row.group_value ?? '').trim(),
423
+ scale:
424
+ row.group_scale === null || row.group_scale === undefined
425
+ ? null
426
+ : Number(row.group_scale),
427
+ })),
428
+ );
429
+ const row = await attributed(op, () =>
430
+ client().one<AggregateRow>(
431
+ aggregateStatement(entity, plan, shape, fn, money === null ? column : `${column}.minor`),
432
+ ),
433
+ );
434
+ const text = row?.agg_value;
435
+ if (text === null || text === undefined) return null;
436
+ if (money === null) return decodeAggregate(fn, declared.$meta.kind, String(text));
437
+ if (unit === undefined) return null;
438
+ return {
439
+ minor: aggregateMinor(entity, fn, column, String(text)),
440
+ currency: unit.currency,
441
+ ...(unit.scale === null ? {} : { scale: unit.scale }),
442
+ };
443
+ },
444
+
445
+ /**
446
+ * `reltuples`, the planner's own estimate — one row out of `pg_class`, constant time, and the
447
+ * only answer that stays constant time as the table grows. `count(*)` walks every visible row
448
+ * because MVCC gives it no shortcut, so past a few million it is the read that trips a web
449
+ * role's `statement_timeout`, and no index can help: `X_DB_STATEMENT_TIMEOUT`'s fix names one
450
+ * anyway, and following it changes nothing.
451
+ */
452
+ async approximateCount(args = {}) {
453
+ const op = 'approximateCount';
454
+ // Built and therefore GUARDED: tenancy still applies, and a filtered chain is refused here
455
+ // rather than answered with the whole table's estimate.
456
+ readPlan(entity, args, op);
457
+ const row = await attributed(op, () =>
458
+ client().one<{ estimate: unknown }>(estimateStatement(entity.$table)),
459
+ );
460
+ const estimate = Number(row?.estimate ?? -1);
461
+ // `-1` is what Postgres 14+ stores for a table nobody has analysed. That is an absence of an
462
+ // estimate, not an estimate of zero, and answering `0` would read exactly like an empty
463
+ // table to every caller.
464
+ return Number.isFinite(estimate) && estimate >= 0 ? estimate : null;
465
+ },
379
466
  };
380
467
  };
381
468
 
package/src/pg-row.ts CHANGED
@@ -6,8 +6,11 @@
6
6
 
7
7
  import { columnFor, columnName, moneyColumns } from './column';
8
8
  import { narrowMoney } from './columns';
9
+ import { kindOf } from './cursor';
9
10
  import type { EntityCore } from './entity';
10
11
  import { invariantViolated } from './errors';
12
+ import { pgInstantMicros, seekAlias } from './instant';
13
+ import type { SortKey } from './tenancy';
11
14
  import type { AnyColumn, MoneyValue, RowPatch } from './types';
12
15
 
13
16
  export type PhysicalRow = Readonly<Record<string, unknown>>;
@@ -125,13 +128,21 @@ const arrayElement = (value: unknown): string => {
125
128
  * answers with `malformed array literal` (measured). What it accepts is the literal, so this is
126
129
  * where a JS array becomes one.
127
130
  */
131
+ /**
132
+ * A JS array as the literal Postgres accepts. Exported because a containment predicate binds one
133
+ * too (`tags @> $1`) and building a second one there is how the two spellings drift: Bun's `sql`
134
+ * serialises a JS array to `x,y`, which the server answers `malformed array literal`.
135
+ */
136
+ export const arrayLiteral = (value: unknown): string =>
137
+ `{${(Array.isArray(value) ? value : [value]).map(arrayElement).join(',')}}`;
138
+
128
139
  const bindable = (column: AnyColumn, value: unknown): unknown => {
129
140
  if (value === null || value === undefined) return null;
130
141
  // A plain object is not a bindable parameter (`X_SQL_UNSAFE`), so a `jsonb` value crosses as its
131
142
  // TEXT and `pg-sql.ts`'s cell casts it back — see `cellCast` for why the cast is `::text::jsonb`.
132
143
  if (column.$meta.kind === 'jsonb') return JSON.stringify(value);
133
144
  if (column.$meta.kind !== 'array' || !Array.isArray(value)) return value;
134
- return `{${value.map(arrayElement).join(',')}}`;
145
+ return arrayLiteral(value);
135
146
  };
136
147
 
137
148
  const moneyOf = (
@@ -185,3 +196,29 @@ export const decodeRow = <Row>(entity: EntityCore<Row>, source: PhysicalRow): Ro
185
196
  // Every property present was validated by the column that declared it, so this is the row.
186
197
  return row as Row;
187
198
  };
199
+
200
+ /**
201
+ * The sort values a cursor has to be minted from when the DECODED row cannot hold them. Exactly
202
+ * one kind is in that position: a `timestamptz` comes back through Bun's client as a JS `Date`,
203
+ * which is milliseconds, while the column and the `order by` are microseconds — so a cursor minted
204
+ * from the decoded row cuts the page at a position no row occupies, and every row inside the
205
+ * boundary millisecond is served on no page at all.
206
+ *
207
+ * Keyed by the sort key PATH, because that is what `cursorFor` looks a value up by. A key the
208
+ * statement did not carry a precision output for is simply absent, and the decoded `Date` is the
209
+ * position — which is the behaviour every other kind has.
210
+ */
211
+ export const sortPrecision = <Row>(
212
+ entity: EntityCore<Row>,
213
+ orderBy: readonly SortKey[],
214
+ source: PhysicalRow | undefined,
215
+ ): ReadonlyMap<string, unknown> => {
216
+ const exact = new Map<string, unknown>();
217
+ if (source === undefined) return exact;
218
+ for (const entry of orderBy) {
219
+ if (kindOf(entity, entry.column) !== 'timestamptz') continue;
220
+ const micros = pgInstantMicros(source[seekAlias(physicalName(entity, entry.column))]);
221
+ if (micros !== undefined) exact.set(entry.column, micros);
222
+ }
223
+ return exact;
224
+ };