@ultimat3/entity 29.4.0 → 30.1.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.
Files changed (65) hide show
  1. package/CLAUDE.md +66 -96
  2. package/README.md +118 -8
  3. package/package.json +15 -5
  4. package/src/append-only.ts +7 -0
  5. package/src/batch-read.ts +12 -4
  6. package/src/bulk-write-send.ts +49 -0
  7. package/src/bulk-write.ts +48 -11
  8. package/src/column.ts +29 -4
  9. package/src/counter.ts +109 -0
  10. package/src/cross-tenant.ts +13 -0
  11. package/src/cursor.ts +17 -1
  12. package/src/database.ts +16 -10
  13. package/src/describe-tenancy.ts +25 -0
  14. package/src/describe.ts +144 -3
  15. package/src/entity-error.ts +33 -0
  16. package/src/entity-parse.ts +89 -0
  17. package/src/entity-patch.ts +42 -0
  18. package/src/entity.ts +90 -76
  19. package/src/errors.ts +8 -0
  20. package/src/history-actor.ts +22 -0
  21. package/src/history.ts +86 -0
  22. package/src/index.ts +20 -1
  23. package/src/jit-preload.ts +35 -25
  24. package/src/lifecycle.ts +100 -0
  25. package/src/lock-key.ts +27 -0
  26. package/src/lock-unsupported.ts +11 -0
  27. package/src/memory-aggregates.ts +99 -0
  28. package/src/memory-batch.ts +69 -0
  29. package/src/memory-journal.ts +68 -0
  30. package/src/memory-lifecycle.ts +106 -0
  31. package/src/memory-lock.ts +144 -0
  32. package/src/memory-observed.ts +121 -0
  33. package/src/memory-repo.ts +199 -186
  34. package/src/memory-same-tenant.ts +83 -0
  35. package/src/memory-seek.ts +44 -0
  36. package/src/memory-transactor.ts +94 -0
  37. package/src/memory-unique.ts +19 -4
  38. package/src/n-plus-one.ts +28 -10
  39. package/src/numeric-compare.ts +43 -0
  40. package/src/pg-aggregates.ts +185 -0
  41. package/src/pg-driver.ts +79 -166
  42. package/src/pg-lock.ts +50 -0
  43. package/src/pg-row.ts +29 -2
  44. package/src/pg-sql-aggregate.ts +31 -0
  45. package/src/pg-sql.ts +17 -1
  46. package/src/query.ts +26 -105
  47. package/src/read-aggregates.ts +138 -0
  48. package/src/read-capture.ts +22 -0
  49. package/src/registry.ts +44 -1
  50. package/src/repo.ts +34 -0
  51. package/src/retain.ts +71 -0
  52. package/src/row-observer.ts +22 -4
  53. package/src/row-security-settings.ts +20 -0
  54. package/src/sealed-repo.ts +15 -0
  55. package/src/seed.ts +43 -7
  56. package/src/series-unsupported.ts +11 -0
  57. package/src/table-locks.ts +41 -0
  58. package/src/test-driver.ts +90 -0
  59. package/src/time-bucket.ts +147 -0
  60. package/src/touch.ts +33 -0
  61. package/src/types.ts +20 -0
  62. package/src/view-entity.ts +78 -0
  63. package/src/view-issue.ts +47 -0
  64. package/src/view-repo.ts +38 -0
  65. package/src/view.ts +41 -27
package/src/pg-driver.ts CHANGED
@@ -2,59 +2,39 @@
2
2
  // only difference is where the rows are, which is the point: a test that passes against memory
3
3
  // means something about Postgres.
4
4
  //
5
- // It never takes a connection as an argument. `db()` from `@ultimat3/db` returns the open
6
- // transaction when there is one, so a repository call inside `withTransaction` joins it without
7
- // being told — which is how `ctx.jobs.enqueue()` lands its outbox row atomically with the write
8
- // that caused it. `RepoOptions.tx` is the in-memory driver's undo hook and is ignored here.
5
+ // It takes no connection. `db()` from `@ultimat3/db` returns the open transaction, so a repository
6
+ // call inside `withTransaction` joins it unasked, which lands `ctx.jobs.enqueue()`'s outbox row
7
+ // with its write. `RepoOptions.tx` is the in-memory driver's undo hook and is ignored here.
9
8
 
10
9
  import { pageOf } from '@ultimat3/core';
11
10
  import {
12
11
  currentTx,
13
12
  type DbClient,
14
13
  db,
14
+ refuseAbandoned,
15
15
  type SqlFragment,
16
16
  withStatementAttribution,
17
17
  } from '@ultimat3/db';
18
- import { aggregateColumnOf, aggregateMinor, assertOneUnit } from './aggregate';
19
- import { decodeAggregate } from './aggregate-decode';
20
18
  import { appendOnlyRepo } from './append-only';
21
- import {
22
- conflictKeys,
23
- insertChunks,
24
- insertColumns,
25
- namedProperties,
26
- upsertPlan,
27
- } from './bulk-write';
19
+ import { conflictKeys, insertColumns, statementColumns, upsertPlan } from './bulk-write';
20
+ import { writeChunks } from './bulk-write-send';
28
21
  import { entityNow } from './clock';
29
22
  import { coalesceFindById } from './coalesce';
30
- import { moneyColumns } from './column';
31
23
  import { narrowRow } from './columns';
32
- import { countsFrom, groupColumnOf, groupValue, MAX_GROUPS } from './count-by';
33
24
  import { cursorFor, seekFrom, valueAt } from './cursor';
34
25
  import type { Driver } from './database';
35
26
  import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
36
27
  import { notFound, repoClientPinned } from './errors';
28
+ import { stampHistoryActor } from './history-actor';
37
29
  import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
38
30
  import { forgetPreloaded, tagSiblings } from './jit-preload';
39
31
  import { judgedWrite } from './judged-write';
32
+ import { postgresAggregates } from './pg-aggregates';
33
+ import { postgresLocks } from './pg-lock';
40
34
  import { bindValues, decodeRow, type PhysicalRow, physicalName, sortPrecision } from './pg-row';
41
35
  import { countStatement, type ReadShape, selectStatement } from './pg-sql';
42
- import {
43
- type AggregateRow,
44
- aggregateStatement,
45
- countByStatement,
46
- currenciesStatement,
47
- estimateStatement,
48
- type GroupRow,
49
- type MoneyUnitRow,
50
- } from './pg-sql-aggregate';
51
36
  import { postgresTransactor } from './pg-transactor';
52
- import {
53
- type ConflictTarget,
54
- deleteStatement,
55
- insertStatement,
56
- updateStatement,
57
- } from './pg-write-sql';
37
+ import { type ConflictTarget, deleteStatement, updateStatement } from './pg-write-sql';
58
38
  import { deletePlan, idPlan, readPlan, updatePlan } from './plan';
59
39
  import type { FindManyArgs, Repo, UpsertArgs } from './repo';
60
40
  import { sealedRepo } from './sealed-repo';
@@ -65,15 +45,13 @@ import { taggedWrite } from './write-tag';
65
45
 
66
46
  export interface PostgresDriverOptions {
67
47
  /**
68
- * Pin a client. Left out, every call resolves `db()` — the ambient pool, or the open
69
- * transaction when one is in scope. Tests pass `recordingClient()` here or install one
70
- * globally with `setDbClient()`.
48
+ * Pin a client. Left out, every call resolves `db()` — the ambient pool, or the open transaction
49
+ * in scope. Tests pass `recordingClient()` here or install one with `setDbClient()`.
71
50
  */
72
51
  readonly client?: DbClient | undefined;
73
52
  /**
74
- * Preload foreign keys resolved by a page into a request-scoped cache, so the first
75
- * `findById` for any one of them resolves that key for the whole page in one statement.
76
- * Default: true.
53
+ * Preload foreign keys resolved by a page into a request-scoped cache, so the first `findById`
54
+ * for any of them resolves that key for the whole page in one statement. Default: true.
77
55
  */
78
56
  readonly jitPreload?: boolean | undefined;
79
57
  }
@@ -165,19 +143,59 @@ export const postgresRepo = <Row>(
165
143
  * Every write goes out through here: the ONE place the request's preloaded rows are dropped (a
166
144
  * row this statement changes must not be served afterwards from a page read before it — so
167
145
  * before the statement, not after), and where a keyed request's write names itself in the WAL
168
- * (`write-tag.ts`).
146
+ * (`write-tag.ts`). The one funnel for an autocommit repository write, so it is also where a scope
147
+ * whose caller was already answered stops: the pool does not refuse, and must not (`runOn`).
169
148
  */
170
- const writing = <T>(send: () => Promise<T>): Promise<T> => {
171
- forgetPreloaded(entity.$name);
149
+ const writing = async <T>(send: () => Promise<T>): Promise<T> => {
150
+ refuseAbandoned();
151
+ forgetPreloaded();
152
+ await stampHistoryActor(entity, config.client !== undefined);
172
153
  return taggedWrite(config.client !== undefined, send);
173
154
  };
174
155
 
156
+ /**
157
+ * Only an entity with a JS-only rule comes here: rows ARE needed, so how many is a memory bound.
158
+ * Counted BEFORE the write, because a refusal after `returning *` has already allocated what it
159
+ * refuses; a concurrent writer can drift the count, which moves a bound, never a decision.
160
+ */
161
+ const judgedRows = async (
162
+ op: string,
163
+ plan: QueryPlan,
164
+ values: ReadonlyMap<string, unknown>,
165
+ shape: ReadShape,
166
+ ): Promise<number> => {
167
+ const matched = await attributed(op, () =>
168
+ client().one<{ count: unknown }>(countStatement(entity, plan, shape)),
169
+ );
170
+ const rows = Number(matched?.count ?? 0);
171
+ if (rows > MAX_ASSERTED_ROWS) throw assertedRowsTooMany(entity.$name, op, rows);
172
+ const statement = updateStatement(entity, plan, values, shape, true);
173
+ return judged(async (send) => {
174
+ const written = await attributed(op, () => writing(() => send.query<PhysicalRow>(statement)));
175
+ for (const row of written) entity.$assert(decodeRow(entity, row));
176
+ return written.length;
177
+ });
178
+ };
179
+
180
+ /**
181
+ * `delete` and `deleteWhere`. A soft delete is an update, so an app-only rule judges the stamped
182
+ * rows, as `memoryRepo`'s `write` does; everything else is one statement and a count.
183
+ */
184
+ const removed = (op: string, plan: QueryPlan): Promise<number> =>
185
+ entity.$softDelete && hasJsOnlyInvariant(entity.$invariants)
186
+ ? judgedRows(
187
+ op,
188
+ plan,
189
+ new Map([[physicalName(entity, SOFT_DELETE_COLUMN), entityNow()]]),
190
+ shapeOf({}),
191
+ )
192
+ : attributed(op, () => writing(() => client().execute(removal(plan))));
193
+
175
194
  /**
176
195
  * The one insert path, for one row or ten thousand: the batch's own column list, split into as
177
196
  * many statements as Postgres's bind count allows, and the rows the server stored. `insert(row)`
178
- * comes through here too, so there is no second builder for the two to drift apart in — and a
179
- * batch wide enough to split is several statements, which is why an all-or-nothing caller wraps
180
- * the call in `withTransaction` rather than trusting one statement's atomicity.
197
+ * comes through here too, so there is no second builder for the two to drift apart in. All or
198
+ * nothing however many statements it takes (`bulk-write-send.ts`).
181
199
  */
182
200
  const writeRows = async (
183
201
  op: string,
@@ -193,25 +211,17 @@ export const postgresRepo = <Row>(
193
211
  assertRowTenant(entity.$name, entity.$tenantColumn, op, row);
194
212
  entity.$assert(row);
195
213
  }
196
- const columns = insertColumns(entity, namedProperties(entity, batch));
197
214
  const bound = batch.map((row) => bindValues(entity, row));
198
- const shape = { columns, ...(conflict === undefined ? {} : { conflict }) };
199
- const written: Row[] = [];
215
+ const shape = {
216
+ columns: statementColumns(entity, batch),
217
+ ...(conflict === undefined ? {} : { conflict }),
218
+ };
200
219
  // Attributed here rather than in each of the three callers: a batch wide enough to split is
201
- // several statements sent inside this loop, and every one of them belongs to the call that
220
+ // several statements sent inside this call, and every one of them belongs to the call that
202
221
  // asked for it — `insert`, `insertAll` or `upsertAll`, which is why the op is a parameter.
203
- await attributed(op, () =>
204
- writing(async () => {
205
- for (const chunk of insertChunks(bound, columns.length)) {
206
- for (const row of await client().query<PhysicalRow>(
207
- insertStatement(entity, chunk, shape),
208
- )) {
209
- written.push(decodeRow(entity, row));
210
- }
211
- }
212
- }),
222
+ return attributed(op, () =>
223
+ writing(() => writeChunks(entity, bound, shape, client(), config.client)),
213
224
  );
214
- return written;
215
225
  };
216
226
 
217
227
  const repo: Repo<Row> = {
@@ -280,7 +290,8 @@ export const postgresRepo = <Row>(
280
290
 
281
291
  async upsertAll(batch, args: UpsertArgs<Row>) {
282
292
  const rows = narrowed(batch);
283
- const plan = upsertPlan(entity, rows, args.onConflict, args.onMatch ?? 'update');
293
+ const { onConflict, onMatch = 'update', overwrites } = args;
294
+ const plan = upsertPlan(entity, rows, onConflict, onMatch, overwrites);
284
295
  // Refused here, not by the server: a batch that repeats a conflict target is `21000` in
285
296
  // Postgres and a silent overwrite in memory, and the two drivers have to mean one thing.
286
297
  conflictKeys(entity, plan, rows);
@@ -290,7 +301,9 @@ export const postgresRepo = <Row>(
290
301
  });
291
302
  },
292
303
 
293
- async update(id, patch, options) {
304
+ async update(id, given, options) {
305
+ // Here and in `memory-repo.ts`, the one door: a value `insert` refuses, a fragment included.
306
+ const patch = entity.$parsePatch(given);
294
307
  const op = 'update';
295
308
  const plan = idPlan(entity, id, options, op);
296
309
  // The plan bounds WHICH row is written; the patch decides what it becomes, and a patch
@@ -321,7 +334,7 @@ export const postgresRepo = <Row>(
321
334
  async delete(id, options) {
322
335
  const op = 'delete';
323
336
  const plan = idPlan(entity, id, options, op);
324
- if ((await attributed(op, () => writing(() => client().execute(removal(plan))))) === 0) {
337
+ if ((await removed(op, plan)) === 0) {
325
338
  throw notFound(entity.$name, id);
326
339
  }
327
340
  },
@@ -338,10 +351,11 @@ export const postgresRepo = <Row>(
338
351
  const op = 'deleteWhere';
339
352
  // The plan is built before the write is announced: a refused filter never happened.
340
353
  const plan = deletePlan(entity, filter, options, op);
341
- return attributed(op, () => writing(() => client().execute(removal(plan))));
354
+ return removed(op, plan);
342
355
  },
343
356
 
344
- async updateWhere(filter, patch, options) {
357
+ async updateWhere(filter, given, options) {
358
+ const patch = entity.$parsePatch(given);
345
359
  const op = 'updateWhere';
346
360
  const plan = updatePlan(entity, filter, patch, options, op);
347
361
  // The same move, filtered: one statement can hand away every row it matches.
@@ -361,24 +375,7 @@ export const postgresRepo = <Row>(
361
375
  const statement = updateStatement(entity, plan, values, shape, false);
362
376
  return attributed(op, () => writing(() => client().execute(statement)));
363
377
  }
364
- // Rows ARE needed, so how many is a memory bound rather than a detail — this is the one
365
- // statement in the driver whose result size is the caller's filter and not a page. Counted
366
- // BEFORE the write, because a refusal after `returning *` has already allocated the thing it
367
- // is refusing; the count can drift by whatever a concurrent writer adds between the two,
368
- // which moves a bound, never a decision.
369
- const matched = await attributed(op, () =>
370
- client().one<{ count: unknown }>(countStatement(entity, plan, shape)),
371
- );
372
- const rows = Number(matched?.count ?? 0);
373
- if (rows > MAX_ASSERTED_ROWS) throw assertedRowsTooMany(entity.$name, op, rows);
374
- const statement = updateStatement(entity, plan, values, shape, true);
375
- return judged(async (send) => {
376
- const written = await attributed(op, () =>
377
- writing(() => send.query<PhysicalRow>(statement)),
378
- );
379
- for (const row of written) entity.$assert(decodeRow(entity, row));
380
- return written.length;
381
- });
378
+ return judgedRows(op, plan, values, shape);
382
379
  },
383
380
 
384
381
  async count(args = {}) {
@@ -390,92 +387,8 @@ export const postgresRepo = <Row>(
390
387
  return Number(row?.count ?? 0);
391
388
  },
392
389
 
393
- async countBy(column, args = {}) {
394
- const op = 'countBy';
395
- const grouped = groupColumnOf(entity, column, op);
396
- const plan = readPlan(entity, args, op);
397
- // One group past the bound, for the same reason a page reads one row past its limit: the
398
- // presence of that group is what says the answer was never going to fit, and `countsFrom`
399
- // refuses it rather than hand back a breakdown missing its tail.
400
- const rows = await attributed(op, () =>
401
- client().query<GroupRow>(
402
- countByStatement(entity, plan, shapeOf(args), column, MAX_GROUPS + 1),
403
- ),
404
- );
405
- return countsFrom(
406
- entity,
407
- column,
408
- op,
409
- rows.map((row) => [groupValue(grouped, row.group_value), Number(row.group_count)] as const),
410
- );
411
- },
412
-
413
- async aggregate(fn, column, args = {}) {
414
- const op = fn;
415
- const declared = aggregateColumnOf(entity, fn, column);
416
- const plan = readPlan(entity, args, op);
417
- const shape = shapeOf(args);
418
- const money = declared.$meta.kind === 'money' ? moneyColumns(column, declared.$meta) : null;
419
- // Money is judged BEFORE the aggregate is asked for, in its own statement: `sum(minor)` over
420
- // two currencies is a number in neither, and the refusal has to name them.
421
- const unit =
422
- money === null
423
- ? undefined
424
- : assertOneUnit(
425
- entity,
426
- fn,
427
- column,
428
- (
429
- await attributed(op, () =>
430
- client().query<MoneyUnitRow>(
431
- currenciesStatement(entity, plan, shape, money.currency, money.scale),
432
- ),
433
- )
434
- ).map((row) => ({
435
- currency: String(row.group_value ?? '').trim(),
436
- scale:
437
- row.group_scale === null || row.group_scale === undefined
438
- ? null
439
- : Number(row.group_scale),
440
- })),
441
- );
442
- const row = await attributed(op, () =>
443
- client().one<AggregateRow>(
444
- aggregateStatement(entity, plan, shape, fn, money === null ? column : `${column}.minor`),
445
- ),
446
- );
447
- const text = row?.agg_value;
448
- if (text === null || text === undefined) return null;
449
- if (money === null) return decodeAggregate(fn, declared.$meta.kind, String(text));
450
- if (unit === undefined) return null;
451
- return {
452
- minor: aggregateMinor(entity, fn, column, String(text)),
453
- currency: unit.currency,
454
- ...(unit.scale === null ? {} : { scale: unit.scale }),
455
- };
456
- },
457
-
458
- /**
459
- * `reltuples`, the planner's own estimate — one row out of `pg_class`, constant time, and the
460
- * only answer that stays constant time as the table grows. `count(*)` walks every visible row
461
- * because MVCC gives it no shortcut, so past a few million it is the read that trips a web
462
- * role's `statement_timeout`, and no index can help: `X_DB_STATEMENT_TIMEOUT`'s fix names one
463
- * anyway, and following it changes nothing.
464
- */
465
- async approximateCount(args = {}) {
466
- const op = 'approximateCount';
467
- // Built and therefore GUARDED: tenancy still applies, and a filtered chain is refused here
468
- // rather than answered with the whole table's estimate.
469
- readPlan(entity, args, op);
470
- const row = await attributed(op, () =>
471
- client().one<{ estimate: unknown }>(estimateStatement(entity.$table)),
472
- );
473
- const estimate = Number(row?.estimate ?? -1);
474
- // `-1` is what Postgres 14+ stores for a table nobody has analysed. That is an absence of an
475
- // estimate, not an estimate of zero, and answering `0` would read exactly like an empty
476
- // table to every caller.
477
- return Number.isFinite(estimate) && estimate >= 0 ? estimate : null;
478
- },
390
+ ...postgresAggregates(entity, client),
391
+ ...postgresLocks(entity, config.client, client),
479
392
  };
480
393
  // The one sealing seam, shared with `memoryRepo()`: sealed on the way in, opened on the way out.
481
394
  // Append-only outermost, as there: a refused rewrite seals nothing and sends no statement.
package/src/pg-lock.ts ADDED
@@ -0,0 +1,50 @@
1
+ // Single responsibility: `lock` and `lockKey` on Postgres. A transaction, through the driver's own
2
+ // transactor, so nested in `withTransaction` it is a savepoint and the lock lives to the root COMMIT.
3
+ // No wait option: the bound is the role's `statement_timeout`, and a deadlock the server breaks is
4
+ // `X_DB_SERIALIZATION_FAILURE` through the funnel like any other statement.
5
+
6
+ import { type DbClient, type SqlFragment, sql, withStatementAttribution } from '@ultimat3/db';
7
+ import type { EntityCore } from './entity';
8
+ import { notFound } from './errors';
9
+ import { lockKeyInput } from './lock-key';
10
+ import { decodeRow, type PhysicalRow } from './pg-row';
11
+ import { selectStatement } from './pg-sql';
12
+ import { postgresTransactor } from './pg-transactor';
13
+ import { idPlan } from './plan';
14
+ import type { Repo } from './repo';
15
+
16
+ /** The read path's own statement, so tenant and soft delete are its own, `for update` after it. */
17
+ const lockedRow = <Row>(entity: EntityCore<Row>, plan: ReturnType<typeof idPlan>): SqlFragment =>
18
+ sql`${selectStatement(entity, plan, { includeDeleted: false }, 1)} for update`;
19
+
20
+ /** Scoped to the transaction: COMMIT and ROLLBACK release it, and a dropped connection does too. */
21
+ const advisoryLock = (input: string): SqlFragment =>
22
+ sql`select pg_advisory_xact_lock(hashtextextended(${input}::text, 0))`;
23
+
24
+ export const postgresLocks = <Row>(
25
+ entity: EntityCore<Row>,
26
+ pinned: DbClient | undefined,
27
+ client: () => DbClient,
28
+ ): Required<Pick<Repo<Row>, 'lock' | 'lockKey'>> => ({
29
+ async lock(id, fn, options) {
30
+ const plan = idPlan(entity, id, options, 'lock');
31
+ return postgresTransactor(pinned === undefined ? {} : { client: pinned }).run(async (tx) => {
32
+ const found = await withStatementAttribution(entity.$name, 'lock', () =>
33
+ client().query<PhysicalRow>(lockedRow(entity, plan)),
34
+ );
35
+ const [row] = found;
36
+ if (row === undefined) throw notFound(entity.$name, id);
37
+ return fn(decodeRow(entity, row), tx);
38
+ });
39
+ },
40
+
41
+ async lockKey(key, fn, options) {
42
+ const input = lockKeyInput(entity, key, options);
43
+ return postgresTransactor(pinned === undefined ? {} : { client: pinned }).run(async (tx) => {
44
+ await withStatementAttribution(entity.$name, 'lockKey', () =>
45
+ client().query(advisoryLock(input)),
46
+ );
47
+ return fn(tx);
48
+ });
49
+ },
50
+ });
package/src/pg-row.ts CHANGED
@@ -189,14 +189,41 @@ const cellOf = (entityName: string, property: string, column: AnyColumn, value:
189
189
  );
190
190
  };
191
191
 
192
+ /** One column as the decoder reads it: the property, its column, and its physical names. */
193
+ type DecodeStep = readonly [
194
+ property: string,
195
+ column: AnyColumn,
196
+ head: string | undefined,
197
+ currency: string | undefined,
198
+ scale: string | undefined,
199
+ ];
200
+
201
+ /**
202
+ * The physical names, derived once per column map. Column names never change after `entity()`,
203
+ * and deriving them per row was most of a list's CPU: `snake` is a regex replace, and it ran once
204
+ * per column per row (measured 2026-11-06: 35.6% self time of a 100-row list). Weak, so a test's
205
+ * throwaway entity is not held by it.
206
+ */
207
+ const DECODE_PLANS = new WeakMap<object, readonly DecodeStep[]>();
208
+
209
+ const decodePlan = (columns: EntityCore<unknown>['$columns']): readonly DecodeStep[] => {
210
+ const known = DECODE_PLANS.get(columns);
211
+ if (known !== undefined) return known;
212
+ const steps = Object.entries(columns).map(([property, column]): DecodeStep => {
213
+ const [head, currency, scale] = columnsOf(property, column);
214
+ return [property, column, head, currency, scale];
215
+ });
216
+ DECODE_PLANS.set(columns, steps);
217
+ return steps;
218
+ };
219
+
192
220
  /**
193
221
  * Physical row -> entity row. A column the projection left out is left out here too, so a
194
222
  * `select` narrows the object as well as the statement.
195
223
  */
196
224
  export const decodeRow = <Row>(entity: EntityCore<Row>, source: PhysicalRow): Row => {
197
225
  const row: Record<string, unknown> = {};
198
- for (const [property, column] of Object.entries(entity.$columns)) {
199
- const [head, currency, scale] = columnsOf(property, column);
226
+ for (const [property, column, head, currency, scale] of decodePlan(entity.$columns)) {
200
227
  if (head === undefined || !(head in source)) continue;
201
228
  // Decided by the column's KIND and never by how many names came back: a money column whose
202
229
  // table has no scale column projects two names, and reading that as a non-money column handed
@@ -130,3 +130,34 @@ export interface MoneyUnitRow {
130
130
  */
131
131
  export const estimateStatement = (table: string): SqlFragment =>
132
132
  sql`select reltuples::bigint as estimate from pg_class where oid = to_regclass(${table})`;
133
+
134
+ /** What a series comes back as. Every name is fixed, so none can be a column's. */
135
+ export interface SeriesRow {
136
+ readonly bucket_ms: unknown;
137
+ readonly bucket_count: unknown;
138
+ readonly bucket_value: unknown;
139
+ }
140
+
141
+ /**
142
+ * One row per bucket over exactly the rows `countStatement` would have counted with an instant in
143
+ * `on`: the three-argument `date_trunc`, with the unit AND the zone bound — a two-argument one
144
+ * truncates in the session's zone, which is the bug this statement exists to make unwritable. The
145
+ * bucket crosses as epoch milliseconds, as `min`/`max` over an instant does, never as session text
146
+ * — and as a `numeric`, not its text, so `group by 1 order by 1` orders instants, not strings.
147
+ * `limit` bounds the buckets, so a refusal costs one row more than the ceiling.
148
+ */
149
+ export const seriesStatement = <Row>(
150
+ entity: EntityCore<Row>,
151
+ plan: QueryPlan,
152
+ shape: ReadShape,
153
+ series: { readonly on: string; readonly unit: string; readonly timeZone: string },
154
+ sumColumn: string | null,
155
+ limit: number,
156
+ ): SqlFragment => {
157
+ const on = columnRef(entity, series.on);
158
+ const bucket = sql`date_trunc(${series.unit}::text, ${on}, ${series.timeZone}::text)`;
159
+ const value = sumColumn === null ? sql`null` : sql`sum(${columnRef(entity, sumColumn)})::text`;
160
+ return sql`select extract(epoch from ${bucket}) * 1000 as bucket_ms, count(*) as bucket_count, ${value} as bucket_value from ${identifier(
161
+ entity.$table,
162
+ )} where ${conditions(entity, plan, shape)} and ${on} is not null group by 1 order by 1 limit ${limit}`;
163
+ };
package/src/pg-sql.ts CHANGED
@@ -56,11 +56,27 @@ const searchSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFragm
56
56
  return sql`${column} @@ websearch_to_tsquery(${raw(`'${vector.language}'`)}, ${String(term)})`;
57
57
  };
58
58
 
59
+ /**
60
+ * The path a predicate's column resolves through. A NULL test on a whole money property asks about
61
+ * its amount, the one thing that is NULL when the money is: memory answers it off the property, and
62
+ * resolving the property here refused it. An amount compared whole stays refused: no single column
63
+ * holds one, so naming a part is the caller's to do.
64
+ */
65
+ const nullTestPath = <Row>(entity: EntityCore<Row>, predicate: Predicate): string => {
66
+ const asksNull =
67
+ predicate.op === 'is-null' ||
68
+ predicate.op === 'is-not-null' ||
69
+ ((predicate.op === 'eq' || predicate.op === 'neq') && predicate.value === null);
70
+ return asksNull && columnFor(entity.$columns, predicate.column)?.$meta.kind === 'money'
71
+ ? `${predicate.column}.minor`
72
+ : predicate.column;
73
+ };
74
+
59
75
  const predicateSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFragment => {
60
76
  // BEFORE the column is resolved: a `matches` predicate names `SEARCH_PROPERTY`, which is not a
61
77
  // column and must never be looked up as one.
62
78
  if (predicate.op === 'matches') return searchSql(entity, predicate);
63
- const column = columnRef(entity, predicate.column);
79
+ const column = columnRef(entity, nullTestPath(entity, predicate));
64
80
  const value = predicate.value;
65
81
  switch (predicate.op) {
66
82
  case 'eq':