@ultimat3/entity 29.3.2 → 30.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.
Files changed (67) hide show
  1. package/CLAUDE.md +66 -96
  2. package/README.md +125 -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 +146 -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 +95 -98
  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-identity.ts +46 -0
  23. package/src/index-init.ts +12 -0
  24. package/src/index.ts +20 -1
  25. package/src/jit-preload.ts +35 -25
  26. package/src/lifecycle.ts +100 -0
  27. package/src/lock-key.ts +27 -0
  28. package/src/lock-unsupported.ts +11 -0
  29. package/src/memory-aggregates.ts +99 -0
  30. package/src/memory-batch.ts +69 -0
  31. package/src/memory-journal.ts +68 -0
  32. package/src/memory-lifecycle.ts +106 -0
  33. package/src/memory-lock.ts +144 -0
  34. package/src/memory-observed.ts +121 -0
  35. package/src/memory-repo.ts +199 -186
  36. package/src/memory-same-tenant.ts +83 -0
  37. package/src/memory-seek.ts +44 -0
  38. package/src/memory-transactor.ts +94 -0
  39. package/src/memory-unique.ts +19 -4
  40. package/src/n-plus-one.ts +28 -10
  41. package/src/numeric-compare.ts +43 -0
  42. package/src/pg-aggregates.ts +185 -0
  43. package/src/pg-driver.ts +79 -166
  44. package/src/pg-lock.ts +50 -0
  45. package/src/pg-row.ts +29 -2
  46. package/src/pg-sql-aggregate.ts +31 -0
  47. package/src/pg-sql.ts +17 -1
  48. package/src/query.ts +26 -105
  49. package/src/read-aggregates.ts +138 -0
  50. package/src/read-capture.ts +22 -0
  51. package/src/registry.ts +49 -1
  52. package/src/repo.ts +34 -0
  53. package/src/retain.ts +71 -0
  54. package/src/row-observer.ts +22 -4
  55. package/src/row-security-settings.ts +20 -0
  56. package/src/sealed-repo.ts +15 -0
  57. package/src/seed.ts +43 -7
  58. package/src/series-unsupported.ts +11 -0
  59. package/src/table-locks.ts +41 -0
  60. package/src/test-driver.ts +90 -0
  61. package/src/time-bucket.ts +147 -0
  62. package/src/touch.ts +33 -0
  63. package/src/types.ts +22 -0
  64. package/src/view-entity.ts +78 -0
  65. package/src/view-issue.ts +47 -0
  66. package/src/view-repo.ts +38 -0
  67. package/src/view.ts +41 -27
package/src/query.ts CHANGED
@@ -7,24 +7,26 @@
7
7
  import type { Page } from '@ultimat3/core';
8
8
  import { pageOf } from '@ultimat3/core';
9
9
  import { assertBatchable, type BatchIterator, batchIterator } from './batch';
10
- import { entityNow } from './clock';
10
+ import { overwrittenBy } from './bulk-write';
11
11
  import type { EntityCore } from './entity';
12
12
  import { searchUndeclared } from './feature-errors';
13
13
  import { checkedPredicate, type Operator } from './operator';
14
- import { assertFinitePageSize, DEFAULT_PAGE_SIZE, namedColumns } from './plan';
14
+ import { assertFinitePageSize, DEFAULT_PAGE_SIZE } from './plan';
15
15
  import { preloaded, type RelatedTables } from './preload';
16
16
  import { type PreloadOptions, type PreloadRelation, preloadsWith } from './preload-ceiling';
17
+ import { type ReadAggregates, readAggregates } from './read-aggregates';
17
18
  import type { Repo, RepoOptions, UpsertArgs } from './repo';
18
- import { copyRow, pickRow } from './sealed';
19
+ import { pickRow } from './sealed';
19
20
  import { SEARCH_PROPERTY } from './search';
21
+ import { type TableLocks, tableLocks } from './table-locks';
20
22
  import type { Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
23
+ import { stampedColumns, touch } from './touch';
21
24
  import { type Move, transitionRow } from './transition';
22
25
  import type {
23
26
  ColumnMap,
24
27
  Filterable,
25
28
  IdOf,
26
29
  Insertable,
27
- MoneyValue,
28
30
  NoSealedKeys,
29
31
  RowPatch,
30
32
  SealedKeySet,
@@ -42,7 +44,8 @@ export type Preloaded<Name extends string> = { readonly [K in Name]: unknown };
42
44
  * `S` names the entity's sealed columns, so naming one in a filter or an order is a COMPILE error
43
45
  * and not only `X_ENTITY_SEALED_PREDICATE` at runtime. It defaults to none.
44
46
  */
45
- export interface ReadBuilder<Row, S extends SealedKeySet = NoSealedKeys> {
47
+ export interface ReadBuilder<Row, S extends SealedKeySet = NoSealedKeys>
48
+ extends ReadAggregates<Row, S> {
46
49
  /** Equality on the columns given. `where({ orgId })` is what satisfies the tenancy guard. */
47
50
  where(filter: RowPatch<Filterable<Row, S>>): ReadBuilder<Row, S>;
48
51
  // NULL is never sealed, so its tests take ANY column. First: `Parameters<…>` reads the last.
@@ -123,65 +126,13 @@ export interface ReadBuilder<Row, S extends SealedKeySet = NoSealedKeys> {
123
126
  all(): Promise<readonly Row[]>;
124
127
  one(): Promise<Row | null>;
125
128
  count(): Promise<number>;
126
- /**
127
- * The grouped count: one statement, one entry per distinct value of `column`, keyed by that
128
- * value — the aggregate a `count()` per row is the N+1 of. `recount every post's likes` is one
129
- * `likes.andWhere('postId', 'in', ids).countBy('postId')`, not one statement per post.
130
- *
131
- * Counts the whole predicate, exactly as `count()` does: the chain's filters, its tenancy and
132
- * its soft-delete visibility, never its page. A value nothing matched is absent rather than `0`,
133
- * which is what `group by` returns and what lets a caller tell "none" from "never asked".
134
- * Entries come back biggest group first, ties by the value, `null` — the group every row without
135
- * one shares — last.
136
- *
137
- * Refused rather than answered: a column whose values a map cannot be keyed by (a timestamp, a
138
- * jsonb, money), and a chain matching more distinct values than one statement should answer with.
139
- */
140
- countBy<K extends Exclude<keyof Row & string, S['sealed']>>(
141
- column: K,
142
- ): Promise<ReadonlyMap<Row[K], number>>;
143
- /**
144
- * The four SQL aggregates, over exactly the rows `count()` counts — the chain's filters, its
145
- * tenancy and its soft-delete visibility, never its page. "Total spend this month" is
146
- * `payments.where({ orgId }).andWhere('paidAt', 'gte', from).sum('amount')`, one statement,
147
- * rather than a page loop or a hand-written query outside every guard this layer applies.
148
- *
149
- * **Never a float.** `sum` and `avg` answer decimal TEXT whatever the column was — the sum of a
150
- * million `integer` rows is not an `integer` and `Number()` on it loses digits past 2^53 — and a
151
- * money column answers a `MoneyValue` in integer minor units. `min`/`max` answer the row's own
152
- * type, because the answer is one of the values that went in.
153
- *
154
- * `null` for an empty set in every one of them, which is what SQL answers: a `0` would claim
155
- * rows were seen.
156
- *
157
- * Refused rather than answered: a kind with no aggregate the two drivers can agree on (`text`
158
- * ordering is the database's collation here and JS code-unit order there), `avg` over money
159
- * (every answer would be a silent rounding of a fraction of a minor unit), and an amount
160
- * covering more than one currency or scale.
161
- */
162
- sum<K extends keyof Row & string>(
163
- column: K,
164
- ): Promise<(Row[K] extends MoneyValue | null ? MoneyValue : string) | null>;
165
- avg<K extends keyof Row & string>(column: K): Promise<string | null>;
166
- min<K extends keyof Row & string>(column: K): Promise<Row[K] | null>;
167
- max<K extends keyof Row & string>(column: K): Promise<Row[K] | null>;
168
- /**
169
- * The planner's own row estimate for the table — `reltuples`, one row out of `pg_class`, and the
170
- * only count that stays constant time as the table grows. `count()` walks every visible row
171
- * because MVCC gives it no shortcut, so past a few million it is the read that trips a web
172
- * role's `statement_timeout` and no index can make it cheaper.
173
- *
174
- * The whole TABLE, never the chain's filters — a filtered chain is `X_APPROXIMATE_COUNT_FILTERED`
175
- * rather than an estimate that answers a different question than the one asked. `null` when the
176
- * table has never been analysed, which is the absence of an estimate and not an estimate of zero.
177
- */
178
- approximateCount(): Promise<number | null>;
179
129
  /** The plan this chain describes. Safe to log — `describePlan()` elides values. */
180
130
  plan(): QueryPlan;
181
131
  }
182
132
 
183
133
  export interface Table<Row, C extends ColumnMap = ColumnMap>
184
- extends ReadBuilder<Row, SealedKeysOf<C>> {
134
+ extends ReadBuilder<Row, SealedKeysOf<C>>,
135
+ TableLocks<Row> {
185
136
  /**
186
137
  * Move one row through the state machine `column` declares, in ONE statement.
187
138
  *
@@ -402,28 +353,7 @@ const builder = <Source, Row>(
402
353
 
403
354
  count: () => repo.count(args()),
404
355
 
405
- // The one cast on each of these, and it is the same seam `countBy` and `select()` have: the
406
- // driver contract is row-agnostic because a column name is a runtime string there, while the
407
- // chain knows which property it just named and therefore what comes back.
408
- sum: async <K extends keyof Row & string>(column: K) =>
409
- (await repo.aggregate('sum', column, args())) as
410
- | (Row[K] extends MoneyValue | null ? MoneyValue : string)
411
- | null,
412
- avg: async (column: keyof Row & string) =>
413
- (await repo.aggregate('avg', column, args())) as string | null,
414
- min: async <K extends keyof Row & string>(column: K) =>
415
- (await repo.aggregate('min', column, args())) as Row[K] | null,
416
- max: async <K extends keyof Row & string>(column: K) =>
417
- (await repo.aggregate('max', column, args())) as Row[K] | null,
418
-
419
- approximateCount: () => repo.approximateCount(args()),
420
-
421
- async countBy<K extends keyof Row & string>(column: K) {
422
- // The one cast on this terminal, and it is the same seam `select()` has: the driver contract
423
- // is row-agnostic because a column name is a runtime string there, while the chain knows
424
- // which property it just named and therefore what the map is keyed by.
425
- return (await repo.countBy(column, args())) as ReadonlyMap<Row[K], number>;
426
- },
356
+ ...readAggregates(entity, repo, args),
427
357
 
428
358
  plan: (): QueryPlan => ({
429
359
  entity: entity.$name,
@@ -439,26 +369,6 @@ const builder = <Source, Row>(
439
369
  };
440
370
  };
441
371
 
442
- /**
443
- * Columns declared `onUpdateNow()` are written by the framework, never by the caller. One helper,
444
- * so `update(id, patch)` and `updateWhere(filter, patch)` stamp the same columns at the same
445
- * moment — a second copy is how one of them ends up with a stale `updatedAt`.
446
- *
447
- * A patch that names nothing is returned untouched. Stamping `updatedAt` onto "the caller named no
448
- * columns" would turn that mistake into a real write on any entity that happens to declare the
449
- * column, and `X_PATCH_EMPTY` downstream would never see it — so whether the refusal fires would
450
- * depend on the schema rather than on the call.
451
- */
452
- const touch = <Row, Patch>(entity: EntityCore<Row>, patch: Patch): Patch => {
453
- if (namedColumns(patch).length === 0) return patch;
454
- const stamped: Record<string, unknown> = {};
455
- for (const [property, column] of Object.entries(entity.$columns)) {
456
- if (column.$meta.onUpdate !== undefined) stamped[property] = entityNow();
457
- }
458
- // `copyRow`, never `Object.assign({}, patch)`: a row passed back whole keeps its sealed properties.
459
- return Object.assign(copyRow(patch as object), stamped) as Patch;
460
- };
461
-
462
372
  // Every write is `async`, matching the repository contract: a failing call rejects and never
463
373
  // throws synchronously. `$parse` throws — without the wrapper, a bad row escapes at call time
464
374
  // while a bad id rejects, and every call site would need two error paths for one mistake.
@@ -482,11 +392,21 @@ export const tableFor = <Row, C extends ColumnMap>(
482
392
  // `touch` and not `$parse` alone: an upsert that lands on a stored row IS an update, so an
483
393
  // `onUpdateNow()` column has to move exactly as `update(id, patch)` moves it — and stamping it
484
394
  // in a second place is how one of the two ends up writing a stale `updatedAt`.
485
- upsertAll: async (rows, args) =>
486
- repo.upsertAll(
395
+ // Asked of the caller's rows, BEFORE `$parse` makes every row name every column: otherwise a
396
+ // collision resets each column nobody mentioned to its default.
397
+ upsertAll: async (rows, args) => {
398
+ const overwrites =
399
+ args.onMatch === 'nothing'
400
+ ? undefined
401
+ : ([
402
+ ...overwrittenBy(entity, rows as readonly RowPatch<Row>[]),
403
+ ...stampedColumns(entity),
404
+ ] as (keyof Row & string)[]);
405
+ return repo.upsertAll(
487
406
  rows.map((row) => touch(entity, entity.$parse(row))),
488
- args,
489
- ),
407
+ overwrites === undefined ? args : { ...args, overwrites },
408
+ );
409
+ },
490
410
  update: async (id, patch, options) => repo.update(id, touch(entity, patch), options),
491
411
  delete: async (id, options) => repo.delete(id, options),
492
412
  deleteWhere: async (filter, options) => repo.deleteWhere(filter, options),
@@ -497,4 +417,5 @@ export const tableFor = <Row, C extends ColumnMap>(
497
417
  // the row moved, using the stamp already declared instead of a second one beside it.
498
418
  transition: async (column, id, move, options) =>
499
419
  transitionRow(entity, repo, column, id, move, (patch) => touch(entity, patch), options),
420
+ ...tableLocks(entity, repo),
500
421
  });
@@ -0,0 +1,138 @@
1
+ // Single responsibility: the chain's aggregate terminals — `countBy`, the four SQL aggregates,
2
+ // `approximateCount` and `series` — typed by the row and forwarded to the row-agnostic repository.
3
+ // Split from `query.ts` at its line ceiling; the chain's arguments arrive as `args()`, so every
4
+ // terminal here counts exactly the rows `count()` counts.
5
+
6
+ import type { Repo } from './repo';
7
+ import { seriesUnsupported } from './series-unsupported';
8
+ import type { SeriesPoint, SeriesUnit, SeriesValue } from './time-bucket';
9
+ import type { MoneyValue, NoSealedKeys, SealedKeySet } from './types';
10
+
11
+ /** The properties a series may bucket by: a `timestamp()` column, nullable or not, never sealed. */
12
+ export type TimestampKeys<Row, S extends SealedKeySet = NoSealedKeys> = {
13
+ [K in keyof Row & string]: K extends S['sealed']
14
+ ? never
15
+ : [NonNullable<Row[K]>] extends [Date]
16
+ ? K
17
+ : never;
18
+ }[keyof Row & string];
19
+
20
+ /** What one bucket's `value` is: a count, or the sum `sum()` would answer over that bucket. */
21
+ export type SeriesValueOf<Row, V> = V extends 'count'
22
+ ? number
23
+ : V extends { readonly sum: infer K extends keyof Row }
24
+ ? (Row[K] extends MoneyValue | null ? MoneyValue : string) | null
25
+ : never;
26
+
27
+ /** `ReadBuilder.series`'s argument. `timeZone` is required: there is no ambient zone. */
28
+ export interface SeriesOptions<On, V> {
29
+ readonly on: On;
30
+ readonly unit: SeriesUnit;
31
+ readonly timeZone: string;
32
+ readonly value: V;
33
+ }
34
+
35
+ export interface ReadAggregates<Row, S extends SealedKeySet = NoSealedKeys> {
36
+ /**
37
+ * The grouped count: one statement, one entry per distinct value of `column`, keyed by that
38
+ * value — the aggregate a `count()` per row is the N+1 of. `recount every post's likes` is one
39
+ * `likes.andWhere('postId', 'in', ids).countBy('postId')`, not one statement per post.
40
+ *
41
+ * Counts the whole predicate, exactly as `count()` does: the chain's filters, its tenancy and
42
+ * its soft-delete visibility, never its page. A value nothing matched is absent rather than `0`,
43
+ * which is what `group by` returns and what lets a caller tell "none" from "never asked".
44
+ * Entries come back biggest group first, ties by the value, `null` — the group every row without
45
+ * one shares — last.
46
+ *
47
+ * Refused rather than answered: a column whose values a map cannot be keyed by (a timestamp, a
48
+ * jsonb, money), and a chain matching more distinct values than one statement should answer with.
49
+ */
50
+ countBy<K extends Exclude<keyof Row & string, S['sealed']>>(
51
+ column: K,
52
+ ): Promise<ReadonlyMap<Row[K], number>>;
53
+ /**
54
+ * The four SQL aggregates, over exactly the rows `count()` counts — the chain's filters, its
55
+ * tenancy and its soft-delete visibility, never its page. "Total spend this month" is
56
+ * `payments.where({ orgId }).andWhere('paidAt', 'gte', from).sum('amount')`, one statement,
57
+ * rather than a page loop or a hand-written query outside every guard this layer applies.
58
+ *
59
+ * **Never a float.** `sum` and `avg` answer decimal TEXT whatever the column was — the sum of a
60
+ * million `integer` rows is not an `integer` and `Number()` on it loses digits past 2^53 — and a
61
+ * money column answers a `MoneyValue` in integer minor units. `min`/`max` answer the row's own
62
+ * type, because the answer is one of the values that went in.
63
+ *
64
+ * `null` for an empty set in every one of them, which is what SQL answers: a `0` would claim
65
+ * rows were seen.
66
+ *
67
+ * Refused rather than answered: a kind with no aggregate the two drivers can agree on (`text`
68
+ * ordering is the database's collation here and JS code-unit order there), `avg` over money
69
+ * (every answer would be a silent rounding of a fraction of a minor unit), and an amount
70
+ * covering more than one currency or scale.
71
+ */
72
+ sum<K extends keyof Row & string>(
73
+ column: K,
74
+ ): Promise<(Row[K] extends MoneyValue | null ? MoneyValue : string) | null>;
75
+ avg<K extends keyof Row & string>(column: K): Promise<string | null>;
76
+ min<K extends keyof Row & string>(column: K): Promise<Row[K] | null>;
77
+ max<K extends keyof Row & string>(column: K): Promise<Row[K] | null>;
78
+ /**
79
+ * The planner's own row estimate for the table — `reltuples`, one row out of `pg_class`, and the
80
+ * only count that stays constant time as the table grows. `count()` walks every visible row
81
+ * because MVCC gives it no shortcut, so past a few million it is the read that trips a web
82
+ * role's `statement_timeout` and no index can make it cheaper.
83
+ *
84
+ * The whole TABLE, never the chain's filters — a filtered chain is `X_APPROXIMATE_COUNT_FILTERED`
85
+ * rather than an estimate that answers a different question than the one asked. `null` when the
86
+ * table has never been analysed, which is the absence of an estimate and not an estimate of zero.
87
+ */
88
+ approximateCount(): Promise<number | null>;
89
+ /**
90
+ * Rows per time bucket, in the zone named — "signups per day in this person's zone" is
91
+ * `users.where({ orgId }).series({ on: 'createdAt', unit: 'day', timeZone, value: 'count' })`,
92
+ * one statement, under the chain's filters, tenancy and soft-delete visibility, never its page.
93
+ *
94
+ * `unit` is `minute`, `hour`, `day`, `week` (from Monday) or `month`, truncated as Postgres'
95
+ * `date_trunc(unit, col, zone)` truncates — so a DST day has 23 or 25 hours and 23:30 UTC is the
96
+ * next day in Tokyo. `value` is `'count'` or `{ sum: column }`, never a float. Ascending; a
97
+ * bucket with no rows is absent (the range is the caller's); past `MAX_GROUPS` buckets it is
98
+ * refused (`X_ENTITY_SERIES_INVALID`), never truncated.
99
+ */
100
+ series<
101
+ On extends TimestampKeys<Row, S>,
102
+ V extends SeriesValue<Exclude<keyof Row & string, S['sealed']>>,
103
+ >(spec: SeriesOptions<On, V>): Promise<readonly SeriesPoint<SeriesValueOf<Row, V>>[]>;
104
+ }
105
+
106
+ /** The members of `Repo` these read, none of which names the row — so any repository fits. */
107
+ type AggregateRepo = Pick<Repo<unknown>, 'aggregate' | 'approximateCount' | 'countBy' | 'series'>;
108
+
109
+ export const readAggregates = <Row, S extends SealedKeySet>(
110
+ entity: { readonly $name: string },
111
+ repo: AggregateRepo,
112
+ args: () => Parameters<Repo['count']>[0],
113
+ ): ReadAggregates<Row, S> => ({
114
+ // The one cast on each of these, and it is the same seam `select()` has: the driver contract is
115
+ // row-agnostic because a column name is a runtime string there, while the chain knows which
116
+ // property it just named and therefore what comes back.
117
+ sum: async <K extends keyof Row & string>(column: K) =>
118
+ (await repo.aggregate('sum', column, args())) as
119
+ | (Row[K] extends MoneyValue | null ? MoneyValue : string)
120
+ | null,
121
+ avg: async (column: keyof Row & string) =>
122
+ (await repo.aggregate('avg', column, args())) as string | null,
123
+ min: async <K extends keyof Row & string>(column: K) =>
124
+ (await repo.aggregate('min', column, args())) as Row[K] | null,
125
+ max: async <K extends keyof Row & string>(column: K) =>
126
+ (await repo.aggregate('max', column, args())) as Row[K] | null,
127
+
128
+ approximateCount: () => repo.approximateCount(args()),
129
+
130
+ countBy: async <K extends keyof Row & string>(column: K) =>
131
+ (await repo.countBy(column, args())) as ReadonlyMap<Row[K], number>,
132
+
133
+ series: async <On extends TimestampKeys<Row, S>, V>(spec: SeriesOptions<On, V>) => {
134
+ if (repo.series === undefined) throw seriesUnsupported(entity.$name);
135
+ const points = await repo.series({ ...spec, value: spec.value as SeriesValue }, args());
136
+ return points as readonly SeriesPoint<SeriesValueOf<Row, V>>[];
137
+ },
138
+ });
@@ -0,0 +1,22 @@
1
+ // Single responsibility: the read half of failure capture — a row a repository answered, into
2
+ // `@ultimat3/core`'s buffer while a captured call is in flight, so `x repro` can seed exactly what
3
+ // the failing code read. Sealed columns never; secret-named columns masked by core. Off, one check.
4
+
5
+ import { failureCaptureActive, recordFailureFact } from '@ultimat3/core';
6
+
7
+ /** A recorder for one entity: `sealed` is its sealed properties, which leave through no path. */
8
+ export function readRecorder(
9
+ entity: string,
10
+ sealed: readonly string[],
11
+ ): (rows: readonly unknown[]) => void {
12
+ return (rows) => {
13
+ if (!failureCaptureActive()) return;
14
+ for (const row of rows) {
15
+ if (typeof row !== 'object' || row === null) continue;
16
+ const kept = Object.fromEntries(
17
+ Object.entries(row).filter(([property]) => !sealed.includes(property)),
18
+ );
19
+ recordFailureFact({ kind: 'read', entity, row: kept });
20
+ }
21
+ };
22
+ }
package/src/registry.ts CHANGED
@@ -3,13 +3,27 @@
3
3
  // whole domain without importing it — and what makes a duplicate name a build error rather
4
4
  // than a silent last-one-wins.
5
5
 
6
- import type { IndexMethod } from '@ultimat3/db';
6
+ import type {
7
+ CounterDescriptionLike,
8
+ HistoryDescriptionLike,
9
+ IndexMethod,
10
+ RetainDescriptionLike,
11
+ } from '@ultimat3/db';
7
12
  import type { EntityCore } from './entity';
8
13
  import { entityDuplicate } from './entity-error';
9
14
  import type { InvariantKind } from './invariants';
10
15
  import type { RecordProjection } from './record-projection';
11
16
  import type { ColumnDefault, OnDelete } from './types';
12
17
 
18
+ /**
19
+ * A `references()` whose two ends share a tenant: the physical tenant column on each side, which is
20
+ * what makes the key composite (`(column, key) -> target (targetColumn, its key)`).
21
+ */
22
+ export interface SameTenantKey {
23
+ readonly column: string;
24
+ readonly targetColumn: string;
25
+ }
26
+
13
27
  export interface ColumnDescription {
14
28
  readonly property: string;
15
29
  readonly column: string;
@@ -46,6 +60,12 @@ export interface ColumnDescription {
46
60
  * none, so a description written before this existed reads the same and nothing regenerates.
47
61
  */
48
62
  readonly default?: ColumnDefault;
63
+ /**
64
+ * Present when the key is same-tenant, absent otherwise: `x verify` hashes this projection, and
65
+ * an entity with no such edge must hash exactly what it hashed before. `null` is `@ultimat3/db`'s
66
+ * own word for absent, and this package never writes one here.
67
+ */
68
+ readonly sameTenant?: SameTenantKey;
49
69
  }
50
70
 
51
71
  /**
@@ -63,10 +83,14 @@ export interface ReferenceDescription {
63
83
  readonly nullable: boolean;
64
84
  /** The entity referenced. Not necessarily registered — an entity may point outside a set. */
65
85
  readonly targetEntity: string;
86
+ /** Its physical table, what the key names: the entity's name unless `{ table }` was declared. */
87
+ readonly targetTable: string;
66
88
  readonly targetProperty: string;
67
89
  readonly targetColumn: string;
68
90
  /** What the database does to this row when the target goes. `null` is Postgres' `no action`. */
69
91
  readonly onDelete: OnDelete | null;
92
+ /** `null` is a plain key: an end with no tenant, the tenant column itself, or `crossTenant`. */
93
+ readonly sameTenant: SameTenantKey | null;
70
94
  }
71
95
 
72
96
  export interface InvariantDescription {
@@ -112,6 +136,11 @@ export interface IndexDescription {
112
136
  * predates the field and an index that declares nothing read the same, so nothing regenerates.
113
137
  */
114
138
  readonly using?: IndexMethod | undefined;
139
+ /**
140
+ * `true` when the index is built with `create index concurrently`, outside a transaction; absent
141
+ * otherwise, so an entity that never asked hashes exactly what it hashed before.
142
+ */
143
+ readonly concurrently?: true | undefined;
115
144
  }
116
145
 
117
146
  export interface EntityDescription {
@@ -127,6 +156,25 @@ export interface EntityDescription {
127
156
  readonly orgScoped: boolean;
128
157
  /** `entity({ appendOnly: true })`; absent on every entity that did not say it. `x db gen` reads it. */
129
158
  readonly appendOnly?: true;
159
+ /** `entity({ retired: [...] })`; physical names, absent when there are none. `x db gen` reads it. */
160
+ readonly retired?: readonly string[] | undefined;
161
+ /** `entity({ shared: '<why>' })`: why a row referencing a tenant's belongs to no tenant. */
162
+ readonly shared?: string | undefined;
163
+ /** `entity({ scan: '<why>' })`: why the plan sweep may see this table scanned. */
164
+ readonly scan?: string | undefined;
165
+ /**
166
+ * `entity({ view: { sql } })`: a relation Postgres derives. Absent on a table. `x db gen` emits
167
+ * it (`@ultimat3/db`'s `generate-view.ts`), admin lists it read-only.
168
+ */
169
+ readonly view?: { readonly sql: string; readonly materialized?: true } | undefined;
170
+ /** `entity({ history: true })`: where its trigger records. Absent otherwise. `x db gen` reads it. */
171
+ readonly history?: HistoryDescriptionLike;
172
+ /** `countedIn` keys pointing at it: the counters its children's triggers keep. */
173
+ readonly counts?: readonly CounterDescriptionLike[];
174
+ /** `entity({ retain })`: the window and the physical column a purge sweeps by. */
175
+ readonly retain?: RetainDescriptionLike;
176
+ /** On a history entity: its parent's name. */
177
+ readonly historyOf?: string;
130
178
  }
131
179
 
132
180
  export interface RegistryEntry {
package/src/repo.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  import type { Page } from '@ultimat3/core';
11
11
  import type { AggregateFn } from './aggregate';
12
12
  import type { Predicate, SortKey } from './tenancy';
13
+ import type { SeriesPoint, SeriesSpec } from './time-bucket';
13
14
  import type { IdOf, RowPatch, RowWrite } from './types';
14
15
 
15
16
  export interface Tx {
@@ -48,6 +49,14 @@ export interface UpsertArgs<T = unknown> extends RepoOptions {
48
49
  * omits it from the result, so the result is always "the rows this call wrote".
49
50
  */
50
51
  readonly onMatch?: 'update' | 'nothing';
52
+ /**
53
+ * What a collision overwrites, when the rows cannot say it: `db.*.upsertAll` sets this from what
54
+ * the caller's rows named, plus `onUpdateNow()` columns, because `$parse` fills every column
55
+ * before a row reaches a repository and an unnamed one would otherwise reset the stored value.
56
+ * The conflict target, the primary key and the soft-delete stamp stay spared. Absent, every
57
+ * column the rows write.
58
+ */
59
+ readonly overwrites?: readonly (keyof T & string)[];
51
60
  }
52
61
 
53
62
  /**
@@ -144,11 +153,36 @@ export interface Repo<T = unknown> {
144
153
  * `MoneyValue`; neither is ever a float.
145
154
  */
146
155
  aggregate(fn: AggregateFn, column: string, args?: FindManyArgs): Promise<unknown>;
156
+ /**
157
+ * One point per time bucket that holds rows, over exactly the rows `count(args)` counts with an
158
+ * instant in `spec.on` — truncated in `spec.timeZone` as Postgres' `date_trunc(unit, col, zone)`
159
+ * truncates (`time-bucket.ts`), ascending, refused past `MAX_GROUPS` buckets. `value` is a count
160
+ * (`number`) or a `sum` (decimal text, or a `MoneyValue`), never a float.
161
+ *
162
+ * Optional, like `lock`: a hand-written driver still compiles, and the typed handle answers
163
+ * `X_ENTITY_SERIES_UNSUPPORTED` for one without it.
164
+ */
165
+ series?(spec: SeriesSpec, args?: FindManyArgs): Promise<readonly SeriesPoint[]>;
147
166
  /**
148
167
  * The planner's own row estimate for the table — not a count, and never filtered. `null` when
149
168
  * the table has never been analysed, which is a fact and not an estimate.
150
169
  */
151
170
  approximateCount(args?: FindManyArgs): Promise<number | null>;
171
+ /**
172
+ * The row, locked, for as long as `fn` runs: a read-modify-write that loses no update. A
173
+ * transaction of its own, or a savepoint in an open one, where the lock lives to the root COMMIT.
174
+ * The read is the read path's, so another tenant's id and a soft-deleted row are `X_NOT_FOUND`.
175
+ * `fn` writes with `{ tx }`, the rule on every write.
176
+ *
177
+ * Optional, like `lockKey`: a hand-written driver still compiles, and the typed handle answers
178
+ * `X_LOCK_UNSUPPORTED` for one without it.
179
+ */
180
+ lock?<R>(id: IdOf<T>, fn: (row: T, tx: Tx) => Promise<R> | R, options?: RepoOptions): Promise<R>;
181
+ /**
182
+ * `fn` alone among callers of this key, in this table and this tenant, for work no one row names.
183
+ * On a tenant-scoped entity with no actor it is `X_TENANCY_UNSCOPED`, exactly as a read.
184
+ */
185
+ lockKey?<R>(key: string, fn: (tx: Tx) => Promise<R> | R, options?: RepoOptions): Promise<R>;
152
186
  }
153
187
 
154
188
  /**
package/src/retain.ts ADDED
@@ -0,0 +1,71 @@
1
+ // Single responsibility: `entity({ retain: { after, on } })` checked where it is written. The window
2
+ // is required and the app's — no default anywhere; `on` is an indexed timestamp, or every sweep is a
3
+ // table scan; and `appendOnly` beside it is refused, because that trigger refuses every delete.
4
+
5
+ import { retainInvalid } from '@ultimat3/db';
6
+ import { parseDuration } from '@ultimat3/time';
7
+ import { columnName } from './column';
8
+ import { EntityError } from './errors';
9
+ import type { AnyColumn, IndexDef } from './types';
10
+
11
+ /** What `retain` declares: rows whose `on` is older than `after` are swept by `purge()`. */
12
+ export interface RetainInit<K extends string = string> {
13
+ readonly after: string;
14
+ readonly on: K;
15
+ }
16
+
17
+ /** The checked declaration: the window in milliseconds and both spellings of the column. */
18
+ export interface Retain {
19
+ readonly after: string;
20
+ readonly afterMs: number;
21
+ /** The property, as a row names it. */
22
+ readonly on: string;
23
+ /** The physical column, as the sweep's statement names it. */
24
+ readonly column: string;
25
+ }
26
+
27
+ const windowMs = (entity: string, after: unknown): number => {
28
+ if (typeof after !== 'string' || after.trim() === '') {
29
+ throw retainInvalid(entity, "names no after window — the window is the app's to choose");
30
+ }
31
+ let ms: number;
32
+ try {
33
+ ms = parseDuration(after);
34
+ } catch {
35
+ throw retainInvalid(entity, `"${after}" is not a duration like '90d' or '2w'`);
36
+ }
37
+ if (!(ms > 0))
38
+ throw retainInvalid(entity, `"${after}" is no window: it must be longer than zero`);
39
+ return ms;
40
+ };
41
+
42
+ /** `null` when `retain` is absent. Every refusal raised here is at declaration. */
43
+ export function retainOf(
44
+ entity: string,
45
+ init: RetainInit | undefined,
46
+ columns: readonly (readonly [string, AnyColumn])[],
47
+ indexes: readonly IndexDef[],
48
+ appendOnly: boolean,
49
+ ): Retain | null {
50
+ if (init === undefined) return null;
51
+ if (appendOnly) {
52
+ throw retainInvalid(
53
+ entity,
54
+ 'is appendOnly: its trigger refuses every delete, a sweep included',
55
+ );
56
+ }
57
+ const afterMs = windowMs(entity, init.after);
58
+ const column = columns.find(([property]) => property === init.on)?.[1];
59
+ if (column === undefined || column.$meta.kind !== 'timestamptz') {
60
+ throw retainInvalid(entity, `retain.on "${String(init.on)}" is no timestamp() column of it`);
61
+ }
62
+ const physical = columnName(init.on, column.$meta);
63
+ if (!indexes.some((index) => index.columns[0] === physical)) {
64
+ throw new EntityError({
65
+ code: 'X_ENTITY_RETAIN_UNINDEXED',
66
+ cause: `entity "${entity}" retains by ${init.on}, and no index starts with it: every sweep would read the whole table`,
67
+ fix: `x db gen "retain index" # after adding { on: ['${init.on}'] } to indexes in entity('${entity}')`,
68
+ });
69
+ }
70
+ return { after: init.after, afterMs, on: init.on, column: physical };
71
+ }
@@ -16,7 +16,9 @@
16
16
  import { currentWriteOrigin } from '@ultimat3/core';
17
17
  import { currentTx, expectedQueryLoop } from '@ultimat3/db';
18
18
  import type { EntityCore } from './entity';
19
+ import { memoryJournal } from './memory-journal';
19
20
  import { MAX_PAGE_SIZE } from './plan';
21
+ import { readRecorder } from './read-capture';
20
22
  import type { Repo, RepoOptions, UpsertArgs } from './repo';
21
23
  import { sealedFields } from './sealed';
22
24
  import type { IdOf, RowPatch } from './types';
@@ -137,7 +139,7 @@ const beforeOf = async <Row>(
137
139
 
138
140
  /**
139
141
  * The `before` rows of a batch, read as ONE statement per `MAX_PAGE_SIZE` ids rather than one per
140
- * row. Measured on ai-maxxing, 2026-09-06: an `upsertAll` of five pull requests logged
142
+ * row. Measured on one adopter's app, 2026-09-06: an `upsertAll` of five pull requests logged
141
143
  * `X_N_PLUS_ONE_QUERY: pull_requests.findById ran 5 times in one request` — the framework's own
142
144
  * feed tripping the framework's own detector, exactly as the job step-write did before #415. A
143
145
  * `findById` per row is not even coalesced into one statement, because each one is awaited
@@ -200,14 +202,15 @@ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Rep
200
202
  };
201
203
 
202
204
  /**
203
- * Reported at COMMIT. The write may sit in a transaction — the caller's `options.tx`, or the
204
- * ambient `withTransaction` a Postgres repository joins — and a change reported before COMMIT
205
+ * Reported at COMMIT. The write may sit in a transaction — the caller's `options.tx`, the
206
+ * ambient `withTransaction` a Postgres repository joins, or the ambient memory one — and a change reported before COMMIT
205
207
  * put a row in a live query that a rollback then erased (plan 101, 06 m). Outside a transaction
206
208
  * the write is already durable, so it is reported at once. The write origin is read NOW: it is
207
209
  * the request's, and the commit callback may run outside that request's scope.
208
210
  */
209
211
  const afterCommit = (options: RepoOptions | undefined, report: () => void): void => {
210
- const tx: { onCommit?(effect: () => void): void } | undefined = options?.tx ?? currentTx();
212
+ const tx: { onCommit?(effect: () => void): void } | undefined =
213
+ options?.tx ?? currentTx() ?? memoryJournal();
211
214
  if (tx?.onCommit === undefined) report();
212
215
  else tx.onCommit(report);
213
216
  };
@@ -241,9 +244,24 @@ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Rep
241
244
  // Spread first, exactly as `examples/dummy`'s own capturing driver does: a repository may carry
242
245
  // members `Repo` does not name — `memoryRepo`'s `reset()` is one — and a wrapper that listed only
243
246
  // the interface would silently drop them.
247
+ // The read half of failure capture (`read-capture.ts`): one check when no captured call is open.
248
+ const read = readRecorder(name, sealed);
249
+
244
250
  return {
245
251
  ...repo,
246
252
 
253
+ findById: async (id, options) => {
254
+ const row = await repo.findById(id, options);
255
+ if (row !== null) read([row]);
256
+ return row;
257
+ },
258
+
259
+ findMany: async (args) => {
260
+ const page = await repo.findMany(args);
261
+ read(page.rows);
262
+ return page;
263
+ },
264
+
247
265
  insert: async (values: Row, options?: RepoOptions): Promise<Row> => {
248
266
  const stored = await repo.insert(values, options);
249
267
  emit('insert', null, stored, options);