@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/entity.ts CHANGED
@@ -4,33 +4,43 @@
4
4
  // this one call.
5
5
 
6
6
  import type { IndexMethod } from '@ultimat3/db';
7
- import { describeValue, type Schema } from '@ultimat3/schema';
7
+ import type { Schema } from '@ultimat3/schema';
8
8
  import { assertAppendOnlyColumns } from './append-only';
9
- import { entityNow } from './clock';
10
9
  import { assertTableName, bindColumn, columnName, moneyColumns } from './column';
11
- import { newId } from './columns';
12
- import { describeEntity, describeReferences } from './describe';
10
+ import {
11
+ assertNotRetired,
12
+ assertScan,
13
+ assertShared,
14
+ describeEntity,
15
+ describeReferences,
16
+ } from './describe';
17
+ import { rowParsers } from './entity-parse';
18
+ import { parseEntityPatch } from './entity-patch';
13
19
  import { invariantViolated } from './errors';
14
20
  import type { InvariantColumns, Resolve } from './expr';
15
21
  import { invariantColumns } from './expr';
22
+ import type { HistoryEntity, HistoryInit } from './history';
16
23
  import { distinctIndexes } from './index-identity';
17
24
  import type { IndexInit } from './index-init';
18
25
  import { indexName } from './index-name';
19
26
  import type { Invariant, InvariantDef } from './invariants';
20
27
  import { assertInvariants, bindInvariant } from './invariants';
28
+ import { describeLifecycle, lifecycleHistory } from './lifecycle';
21
29
  import { recordProjection } from './record-projection';
22
30
  import type { EntityDescription, ReferenceDescription } from './registry';
23
31
  import { registerEntity } from './registry';
32
+ import { type Retain, type RetainInit, retainOf } from './retain';
24
33
  import { rowSchema } from './row-schema';
25
34
  import { sealedFields } from './sealed';
26
35
  import { assertSealedDeclaration } from './sealed-declaration';
27
- import { sealedInView, sealedMissing } from './sealed-errors';
36
+ import { sealedInView } from './sealed-errors';
28
37
  import type { SearchInit, SearchSource, SearchVector } from './search';
29
38
  import { searchVectorOf } from './search';
30
39
  import { resolveTenantColumn } from './tenancy';
31
- import type { AnyColumn, ColumnMap, ColumnMeta, IndexDef, RowOf, WireRow } from './types';
40
+ import type { AnyColumn, ColumnMap, IndexDef, RowOf, WireRow } from './types';
32
41
  import type { EntityView } from './view';
33
42
  import { viewFor } from './view';
43
+ import { type ViewInit, type ViewQuery, viewQueryOf } from './view-entity';
34
44
 
35
45
  /** Presence of this column is what makes an entity soft-deletable — not a flag. */
36
46
  export const SOFT_DELETE_COLUMN = 'deletedAt';
@@ -51,6 +61,17 @@ export interface EntityInit<C extends ColumnMap> {
51
61
  * `orgId`, so silence never means unscoped.
52
62
  */
53
63
  readonly tenant?: keyof C & string;
64
+ /**
65
+ * Why an entity with no tenant column, referencing a tenant-owned one, belongs to no tenant.
66
+ * Without it `x verify`'s exercise step reports `X_ENTITY_TENANT_UNDECLARED`; blank is refused.
67
+ */
68
+ readonly shared?: string;
69
+ /**
70
+ * Why this table may be read by a sequential scan: a closed catalog that stays a handful of rows.
71
+ * Without it `x verify`'s exercise step reports a statement no index serves (`X_QUERY_UNINDEXED`)
72
+ * and a reference column no index leads with; blank is refused.
73
+ */
74
+ readonly scan?: string;
54
75
  /** Composite keys only — a single key is `uuid().primaryKey()` on the column itself. */
55
76
  readonly primaryKey?: readonly (keyof C & string)[];
56
77
  /**
@@ -80,6 +101,26 @@ export interface EntityInit<C extends ColumnMap> {
80
101
  * refuses the same in raw SQL. A soft-delete, `onUpdateNow()` or state-machine column is refused.
81
102
  */
82
103
  readonly appendOnly?: boolean;
104
+ /**
105
+ * Physical columns the previous release still reads and this one does not: a rolling deploy's
106
+ * first half of a column drop. Delete the column from `columns` and name it here, so it leaves
107
+ * the row type, the projection and every insert while `x db gen` writes a marker and keeps it.
108
+ * Remove it from this list in the next release, and `x db gen --allow-destructive` drops it. A
109
+ * name, not a chain on the column: a column here has no builder left to chain from.
110
+ */
111
+ readonly retired?: readonly string[];
112
+ /**
113
+ * A relation Postgres derives rather than a table: `x db gen` emits `create [materialized] view`
114
+ * from `sql`, reads are the chain's (tenancy, codec, `$view`), and every write is
115
+ * `X_ENTITY_VIEW_READ_ONLY`. `columns` name its output columns; `primaryKey` is the read identity.
116
+ */
117
+ readonly view?: ViewInit;
118
+ /** Rows older than `after` by `on` are swept by `purge({ entities: 'declared' })` (`retain.ts`). */
119
+ readonly retain?: RetainInit<keyof C & string>;
120
+ /** Every row change recorded into `<table>_history` by trigger (`history.ts`, `$history`). */
121
+ readonly history?: HistoryInit;
122
+ /** Set by `history.ts` on the history entity it declares: the parent's name. Never by hand. */
123
+ readonly historyOf?: string;
83
124
  }
84
125
 
85
126
  /**
@@ -100,6 +141,14 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
100
141
  readonly $softDelete: boolean;
101
142
  /** `entity({ appendOnly: true })`: update and delete are refused, here and by the table. */
102
143
  readonly $appendOnly: boolean;
144
+ /** `entity({ retain })`, checked; `null` when rows are kept. */
145
+ readonly $retain: Retain | null;
146
+ /** `entity({ history: true })`: the history entity, registered beside this one; else `null`. */
147
+ readonly $history: HistoryEntity | null;
148
+ /** On a history entity, its parent's name; `null` everywhere else. */
149
+ readonly $historyOf: string | null;
150
+ /** `entity({ view })`: the query a view is derived by, `null` on a table. Writes are refused. */
151
+ readonly $viewQuery: ViewQuery | null;
103
152
  /** Property key of the tenant column, or `null`. Presence is what turns tenancy on. */
104
153
  readonly $tenantColumn: string | null;
105
154
  /**
@@ -125,6 +174,8 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
125
174
  $tagFor(id: string): string;
126
175
  /** Fills declared defaults, then validates every column. Throws on a bad value. */
127
176
  $parse(value: unknown): Row;
177
+ /** `$parse` for the columns a patch names: no default fills an absent one. `entity-patch.ts`. */
178
+ $parsePatch<Patch>(patch: Patch): Patch;
128
179
  /**
129
180
  * `const PostView = posts.$view(['id', 'title'])` — the projection an action names as its
130
181
  * `output`. An unknown key is a compile error, and a declaration error for a JS caller.
@@ -156,28 +207,27 @@ export type Entity<Row, C extends ColumnMap = ColumnMap> = Omit<EntityCore<Row,
156
207
 
157
208
  const MONEY_PARTS = new Set(['minor', 'currency']);
158
209
 
159
- const defaultValue = (meta: ColumnMeta): unknown => {
160
- const declared = meta.default;
161
- if (declared === undefined) return undefined;
162
- if (declared.kind === 'value') return declared.value;
163
- return declared.by === 'uuid-v7' ? newId() : entityNow();
164
- };
165
-
166
210
  export const entity = <const C extends ColumnMap>(
167
211
  name: string,
168
212
  init: EntityInit<C>,
169
213
  ): Entity<RowOf<C>, C> => {
170
214
  type Row = RowOf<C>;
171
215
  const entries: readonly (readonly [string, AnyColumn])[] = Object.entries(init.columns);
172
- for (const [property, column] of entries) bindColumn(column, name, property);
216
+ // Resolved before the bindings, because a binding carries the tenant's PHYSICAL column: a
217
+ // `references()` on another entity reads it through this column's binding at describe time.
218
+ const tenantColumn = resolveTenantColumn(name, init.columns, init.tenant);
219
+ const tenantMeta = tenantColumn === null ? undefined : init.columns[tenantColumn]?.$meta;
220
+ const tenant = tenantMeta === undefined ? null : columnName(tenantColumn ?? '', tenantMeta);
173
221
 
174
222
  // Both branches. The declared table was checked and the fallback — which is every entity that
175
223
  // does not rename its table — was not, so an entity NAME closed the identifier in the same way a
176
224
  // column name could: `entity('t" (x int); drop table u; --')` emitted that `drop table` verbatim.
177
225
  const table = assertTableName(name, init.table ?? name);
226
+ assertShared(name, table, init.shared, tenantColumn);
227
+ assertScan(name, init.scan);
228
+ for (const [property, column] of entries) bindColumn(column, name, property, tenant, table);
178
229
  const cacheTag = `entity:${name}`;
179
230
  const softDelete = Object.hasOwn(init.columns, SOFT_DELETE_COLUMN);
180
- const tenantColumn = resolveTenantColumn(name, init.columns, init.tenant);
181
231
  const appendOnly = init.appendOnly === true;
182
232
  if (appendOnly) assertAppendOnlyColumns(name, entries, SOFT_DELETE_COLUMN);
183
233
 
@@ -325,6 +375,7 @@ export const entity = <const C extends ColumnMap>(
325
375
  // A foreign key's own index named again in `indexes` is one index, not two (`index-identity.ts`).
326
376
  const indexes = distinctIndexes(declared);
327
377
 
378
+ const retain = retainOf(name, init.retain, entries, indexes, appendOnly);
328
379
  const sealed = sealedFields({ $name: name, $table: table, $columns: init.columns });
329
380
  assertSealedDeclaration({
330
381
  name,
@@ -335,6 +386,11 @@ export const entity = <const C extends ColumnMap>(
335
386
  invariants,
336
387
  });
337
388
  const sealedKeys = new Set(sealed.map((field) => field.property));
389
+ const viewQuery = viewQueryOf(name, init.view, {
390
+ appendOnly,
391
+ indexes,
392
+ sealed: [...sealedKeys],
393
+ });
338
394
  /** The columns that leave the server: every one that is not sealed. */
339
395
  const wireEntries = entries.filter(([property]) => !sealedKeys.has(property));
340
396
 
@@ -353,69 +409,16 @@ export const entity = <const C extends ColumnMap>(
353
409
  tenantColumn,
354
410
  search,
355
411
  appendOnly,
412
+ retired: init.retired,
413
+ shared: init.shared,
414
+ scan: init.scan,
415
+ view: viewQuery,
416
+ lifecycle: describeLifecycle(core as EntityCore, retain, init.historyOf),
356
417
  });
418
+ assertNotRetired(name, entries, init.retired);
357
419
  const references = (): readonly ReferenceDescription[] => describeReferences(name, entries);
358
420
 
359
- const parse = (value: unknown): Row => {
360
- if (typeof value !== 'object' || value === null) {
361
- // `describeValue`, never `String(value)`: this message is a `$parse` failure, which reaches
362
- // the caller and the log line — and the whole row is the last value in the framework that
363
- // may be echoed there. Same renderer as the column builders (`columns.ts`) use.
364
- throw invariantViolated(name, 'row', `expected an object, got ${describeValue(value)}`);
365
- }
366
- const input = value as Readonly<Record<string, unknown>>;
367
- const row: Record<string, unknown> = {};
368
- for (const [property, column] of entries) {
369
- // `=== undefined`, never `??`: an explicit `null` is the caller CLEARING a nullable column,
370
- // and `??` read it as absence and wrote the column's declared default straight back — so
371
- // `update(id, { status: null })` reported success and stored `'draft'`, with nothing on any
372
- // surface to say otherwise. A present `undefined` still means absence, which is what a
373
- // spread of an omitted optional key produces and is the shape every existing caller has.
374
- const raw = input[property];
375
- const given = raw === undefined ? defaultValue(column.$meta) : raw;
376
- if (given === undefined || given === null) {
377
- if (column.$meta.notNull) {
378
- // A required sealed column ABSENT from a row is nearly always a spread of a repository
379
- // row, which does not enumerate it — so the refusal names that, and the rewrite.
380
- throw given === undefined && sealedKeys.has(property)
381
- ? sealedMissing(name, property)
382
- : invariantViolated(name, property, 'is required and has no default');
383
- }
384
- row[property] = null;
385
- continue;
386
- }
387
- row[property] = column.$parse(given);
388
- }
389
- // Every property was validated by its own column above, so the shape is the derived row.
390
- return row as Row;
391
- };
392
-
393
- /**
394
- * The row as `$schema` validates it: the sealed columns are neither required nor returned, so a
395
- * handler's row — which holds their plaintext — parses to a value that does not.
396
- */
397
- const parseWire = (value: unknown): WireRow<Row, C> => {
398
- if (sealedKeys.size === 0) return parse(value) as WireRow<Row, C>;
399
- if (typeof value !== 'object' || value === null) {
400
- throw invariantViolated(name, 'row', `expected an object, got ${describeValue(value)}`);
401
- }
402
- const input = value as Readonly<Record<string, unknown>>;
403
- const row: Record<string, unknown> = {};
404
- for (const [property, column] of wireEntries) {
405
- const raw = input[property];
406
- const given = raw === undefined ? defaultValue(column.$meta) : raw;
407
- if (given === undefined || given === null) {
408
- if (column.$meta.notNull) {
409
- throw invariantViolated(name, property, 'is required and has no default');
410
- }
411
- row[property] = null;
412
- continue;
413
- }
414
- row[property] = column.$parse(given);
415
- }
416
- // Every wire property was validated by its own column, and no sealed one was copied.
417
- return row as WireRow<Row, C>;
418
- };
421
+ const { parse, parseWire } = rowParsers<Row, WireRow<Row, C>>(name, entries, sealedKeys);
419
422
 
420
423
  const schema = rowSchema<WireRow<Row, C>>(
421
424
  { name, table, primaryKey, persist: init.persist === true, sealed: [...sealedKeys] },
@@ -433,7 +436,8 @@ export const entity = <const C extends ColumnMap>(
433
436
  $tags: tags,
434
437
  $cacheTag: cacheTag,
435
438
  $softDelete: softDelete,
436
- $appendOnly: appendOnly,
439
+ $appendOnly: appendOnly || init.historyOf !== undefined,
440
+ $viewQuery: viewQuery,
437
441
  $tenantColumn: tenantColumn,
438
442
  $search: search,
439
443
  $schema: schema,
@@ -443,6 +447,7 @@ export const entity = <const C extends ColumnMap>(
443
447
  },
444
448
  $tagFor: (id) => `${cacheTag}:${id}`,
445
449
  $parse: parse,
450
+ $parsePatch: (patch) => parseEntityPatch(name, init.columns, patch),
446
451
  $view: <K extends keyof Row & string>(keys: readonly K[]) => {
447
452
  // Before the view exists: a view is an `output`, and a sealed column is in none.
448
453
  const named = keys.find((key) => sealedKeys.has(key));
@@ -452,6 +457,11 @@ export const entity = <const C extends ColumnMap>(
452
457
  $assert: (row) => assertInvariants(name, invariants, row),
453
458
  $describe: describe,
454
459
  $references: references,
460
+ $retain: retain,
461
+ // Assigned below, once this entity is registered: the history entity is declared after it.
462
+ $history: null,
463
+ // A history table is append-only to every repository: its rows are its trigger's to write.
464
+ $historyOf: init.historyOf ?? null,
455
465
  };
456
466
 
457
467
  registerEntity({
@@ -463,6 +473,10 @@ export const entity = <const C extends ColumnMap>(
463
473
  describe,
464
474
  references,
465
475
  });
476
+ const { history, historyOf } = init;
477
+ const lifecycle = { name, table, columns: init.columns, primaryKey, tenantColumn, appendOnly };
478
+ const historyEntity = lifecycleHistory({ ...lifecycle, history, historyOf });
479
+ if (historyEntity !== null) Object.assign(core, { $history: historyEntity });
466
480
  // The columns land on the entity itself so `orgs.id` is a column reference; every framework
467
481
  // member is `$`-prefixed, which is why a column may be called `name`.
468
482
  // `$schema` is restated so the result carries its real output type; it is the same object.
package/src/errors.ts CHANGED
@@ -183,6 +183,14 @@ export const repoClientPinned = (entityName: string): EntityError =>
183
183
  fix: `setDbClient(client) at boot and build the repository with no client: — postgresDriver() then resolves the open transaction through db() — or run this call outside withTransaction()`,
184
184
  });
185
185
 
186
+ /** `testDriver()`: the transaction began on one store and `setDbClient` now names the other. */
187
+ export const testDriverClientChanged = (entityName: string, began: string): EntityError =>
188
+ new EntityError({
189
+ code: 'X_TEST_DRIVER_CLIENT_CHANGED',
190
+ cause: `${entityName} was called inside a transaction that began on ${began}, and setDbClient has since ${began === 'memory' ? 'installed a client' : 'cleared the client'} — following it would split one transaction's writes across two stores`,
191
+ fix: 'beforeAll(() => setDbClient(client)); afterAll(() => setDbClient(undefined)); # outside every transactor().run / withTransaction scope',
192
+ });
193
+
186
194
  export const notFound = (entityName: string, id: string): EntityError =>
187
195
  new EntityError({
188
196
  code: 'X_NOT_FOUND',
@@ -0,0 +1,22 @@
1
+ // Single responsibility: who a history row names. The history trigger reads `x.actor` and
2
+ // `x.request` from the WRITE'S transaction; this sets them, once per transaction, before the first
3
+ // repository write to an entity that keeps history. No transaction, nothing set: the trigger then
4
+ // records `null`, never a guess.
5
+
6
+ import { tryUseContext } from '@ultimat3/core';
7
+ import { ACTOR_SETTING, currentTx, type DbTx, REQUEST_SETTING, setLocal } from '@ultimat3/db';
8
+
9
+ const stamped = new WeakSet<DbTx>();
10
+
11
+ /** Before a write to `entity` on the ambient connection. A pinned repository is never stamped. */
12
+ export async function stampHistoryActor(
13
+ entity: { readonly $history: unknown },
14
+ pinned: boolean,
15
+ ): Promise<void> {
16
+ if (entity.$history === null || pinned) return;
17
+ const tx = currentTx();
18
+ const ctx = tryUseContext();
19
+ if (tx === undefined || ctx === undefined || stamped.has(tx)) return;
20
+ await setLocal(tx, { [ACTOR_SETTING]: ctx.actor.id, [REQUEST_SETTING]: ctx.requestId });
21
+ stamped.add(tx);
22
+ }
package/src/history.ts ADDED
@@ -0,0 +1,86 @@
1
+ // Single responsibility: `entity({ history: true })`'s history table as a REAL entity, registered
2
+ // beside its parent — so `$tenantColumn` holds, row security emits its policy, the tenant proof and
3
+ // subject data walk it, and a read of it is an ordinary tenant-scoped, cursor-paged read.
4
+
5
+ import { t } from '@ultimat3/schema';
6
+ import { columnName, makeColumn } from './column';
7
+ import { text, timestamp, uuid } from './columns';
8
+ import { json } from './columns-data';
9
+ import type { Entity } from './entity';
10
+ import { entity } from './entity';
11
+ import { invariantViolated } from './errors';
12
+ import type { AnyColumn, ColumnMap, RowOf } from './types';
13
+
14
+ /** `history: true`, or with a retention window of its own. No default window, here or anywhere. */
15
+ export type HistoryInit = true | { readonly retain?: { readonly after: string } };
16
+
17
+ /** The parent facts the history table is built from. */
18
+ export interface HistoryParent {
19
+ readonly name: string;
20
+ readonly table: string;
21
+ readonly columns: ColumnMap;
22
+ readonly primaryKey: readonly string[];
23
+ readonly tenantColumn: string | null;
24
+ }
25
+
26
+ /** A row image: the stored row as `to_jsonb` spells it, physical names. Any JSON object. */
27
+ const image = () => json(t.record(t.json())).nullable();
28
+
29
+ /** Every history table's own columns; the parent's tenant column joins them at runtime. */
30
+ const historyColumns = () => ({
31
+ id: uuid().primaryKey(),
32
+ rowId: text(),
33
+ op: text(),
34
+ at: timestamp().defaultNow(),
35
+ actorId: text().nullable(),
36
+ requestId: text().nullable(),
37
+ before: image(),
38
+ after: image(),
39
+ });
40
+
41
+ export type HistoryColumns = ReturnType<typeof historyColumns>;
42
+ /**
43
+ * A history entity as a read types it. The tenant column is on the row at runtime and absent from
44
+ * the type: every read of it is scoped by the repository, never filtered by hand.
45
+ */
46
+ export type HistoryEntity = Entity<RowOf<HistoryColumns>, HistoryColumns>;
47
+
48
+ /**
49
+ * The tenant column, copied without its foreign key: a history row outlives an org's cascade only
50
+ * until its purge, and a key would cascade a delete the guard refuses.
51
+ */
52
+ const tenantCopy = (column: AnyColumn): AnyColumn => {
53
+ const { references: _r, onDelete: _o, crossTenant: _c, countedIn: _n, ...meta } = column.$meta;
54
+ return makeColumn({ ...meta, tenant: true, index: true }, column.$parse, false);
55
+ };
56
+
57
+ /** The history entity of `parent`, declared now. Its name and table are the parent's `_history`. */
58
+ export function historyEntity(parent: HistoryParent, init: HistoryInit): HistoryEntity {
59
+ if (parent.primaryKey.length !== 1) {
60
+ throw invariantViolated(
61
+ parent.name,
62
+ 'history',
63
+ 'history records a row by its single primary key, and this entity declares a composite one',
64
+ );
65
+ }
66
+ const tenant = parent.tenantColumn;
67
+ const tenantColumn = tenant === null ? undefined : parent.columns[tenant];
68
+ const columns = {
69
+ ...historyColumns(),
70
+ ...(tenant === null || tenantColumn === undefined
71
+ ? {}
72
+ : { [tenant]: tenantCopy(tenantColumn).column(columnName(tenant, tenantColumn.$meta)) }),
73
+ };
74
+ const after = init === true ? undefined : init.retain?.after;
75
+ // Typed as its fixed columns: the tenant key is a runtime name, so it cannot be in the type.
76
+ return entity<ColumnMap>(`${parent.name}_history`, {
77
+ table: `${parent.table}_history`,
78
+ columns,
79
+ historyOf: parent.name,
80
+ indexes: [
81
+ { on: [...(tenant === null ? [] : [tenant]), 'rowId', 'at'] },
82
+ ...(after === undefined ? [] : [{ on: ['at'] }]),
83
+ ],
84
+ ...(after === undefined ? {} : { retain: { after, on: 'at' } }),
85
+ }) as unknown as HistoryEntity;
86
+ }
package/src/index.ts CHANGED
@@ -28,12 +28,16 @@ export {
28
28
  } from './columns';
29
29
  export type { DecimalOptions } from './columns-data';
30
30
  export { arrayOf, bigint, bytes, date, decimal, json } from './columns-data';
31
+ export type { Counter } from './counter';
32
+ export { countersOf, describeCounter } from './counter';
31
33
  // `crossTenantReason` stays internal: an app that could read the flag would have a second way to
32
34
  // reason about tenant scope — branch on it — next to the one way, which is entering the scope.
33
- export { CROSS_TENANT_SCOPE, crossTenant } from './cross-tenant';
35
+ export { CROSS_TENANT_SCOPE, crossTenant, setCrossTenantObserver } from './cross-tenant';
34
36
  export type { Database, DatabaseOptions, Driver, EntitySet } from './database';
35
37
  export { database, defaultDriver, memoryDriver } from './database';
36
38
  export type { DescribeInput } from './describe';
39
+ export type { TenantColumnDescription } from './describe-tenancy';
40
+ export { describeTenancy } from './describe-tenancy';
37
41
  export type { Entity, EntityCore, EntityInit } from './entity';
38
42
  export { entity, SOFT_DELETE_COLUMN } from './entity';
39
43
  // The vocabulary an EXISTING schema needs. Separate from the blessed builders on purpose: those
@@ -77,9 +81,11 @@ export {
77
81
  stateTransitionIllegal,
78
82
  stateUndeclared,
79
83
  } from './feature-errors';
84
+ export type { HistoryColumns, HistoryEntity, HistoryInit } from './history';
80
85
  export type { IndexInit } from './index-init';
81
86
  export type { Invariant, InvariantDef, InvariantKind } from './invariants';
82
87
  export { assertInvariants, invariant, MAX_ASSERTED_ROWS } from './invariants';
88
+ export { memoryJournal } from './memory-journal';
83
89
  // How Postgres compares two values of one column, decided by the column's declared KIND. Exported
84
90
  // because `@ultimat3/query`'s matcher and in-memory source ask the same question — and a second
85
91
  // answer there is how a live window and the database returned one read in two orders.
@@ -105,6 +111,12 @@ export type { PreloadOptions } from './preload-ceiling';
105
111
  export { MAX_PRELOADED_ROWS } from './preload-ceiling';
106
112
  export type { Preloaded, ReadBuilder, Table } from './query';
107
113
  export { tableFor } from './query';
114
+ export type {
115
+ ReadAggregates,
116
+ SeriesOptions,
117
+ SeriesValueOf,
118
+ TimestampKeys,
119
+ } from './read-aggregates';
108
120
  // The client projection of an entity (plan 101): record type, record key, and the brand on
109
121
  // `$schema` that lets an action or query derive its record envelope from its output schema.
110
122
  // Value-light — no Postgres driver behind any of them — so a browser store may import them here.
@@ -118,6 +130,7 @@ export type {
118
130
  InvariantDescription,
119
131
  ReferenceDescription,
120
132
  RegistryEntry,
133
+ SameTenantKey,
121
134
  } from './registry';
122
135
  export {
123
136
  clearRegistry,
@@ -140,8 +153,10 @@ export type {
140
153
  Tx,
141
154
  UpsertArgs,
142
155
  } from './repo';
156
+ export type { Retain, RetainInit } from './retain';
143
157
  export type { RowBulkChange, RowChange, RowChangeOp, RowObserver } from './row-observer';
144
158
  export { observedRepo, rowObserver, setRowObserver } from './row-observer';
159
+ export { rowSecuritySettings } from './row-security-settings';
145
160
  export type { RecordsByKey, RecordsByType } from './rows-of';
146
161
  export { hasEntityRows, projectionsIn, rowsOf } from './rows-of';
147
162
  // Sealed columns. `sealedFields` is what a manifest or `x doctor` reads to say which columns are
@@ -191,6 +206,10 @@ export {
191
206
  orgScoped,
192
207
  scopedPlan,
193
208
  } from './tenancy';
209
+ // The scaffolded handle's driver under test: memory until `setDbClient` installs a client.
210
+ export { testDriver } from './test-driver';
211
+ export type { SeriesPoint, SeriesSpec, SeriesUnit, SeriesValue } from './time-bucket';
212
+ export { bucketStart, SERIES_UNITS } from './time-bucket';
194
213
  export type { Move } from './transition';
195
214
  export type { TransitionObservation } from './transition-pins';
196
215
  export type {
@@ -10,8 +10,9 @@
10
10
  // which for a job is the whole attempt, so both maps here are bounded and evict the oldest page.
11
11
  //
12
12
  // The scope guard is a security boundary, not a tuning knob. A preloaded row is served only to a
13
- // lookup with the same scope key, the same client and no write since — anything else reads the
14
- // statement it always read.
13
+ // lookup with the same scope key, the same client and no write since — to ANY entity, because the
14
+ // database rewrites rows the statement never named — anything else reads the statement it always
15
+ // read.
15
16
 
16
17
  import type { Ctx } from '@ultimat3/core';
17
18
  import { tryUseContext } from '@ultimat3/core';
@@ -29,11 +30,9 @@ import type { EntityCore } from './entity';
29
30
 
30
31
  /** The rows one page's worth of foreign keys resolved to, under one scope. */
31
32
  interface Bucket {
32
- /** The entity the rows belong to — what a write invalidates. */
33
- readonly entity: string;
34
33
  /** Where the rows were read from. A pinned client and the ambient pool are two places. */
35
34
  readonly client: DbClient;
36
- /** Writes to `entity` when the bucket was opened. A later write makes every row of it stale. */
35
+ /** The store's writes when the bucket was opened. A later write makes every row of it stale. */
37
36
  readonly generation: number;
38
37
  /** Never rejects: a failure is an `Answer`, so an id nobody asks for cannot go unhandled. */
39
38
  readonly rows: Map<string, Promise<Answer>>;
@@ -44,8 +43,13 @@ interface Store {
44
43
  readonly siblings: Map<string, Map<string, readonly unknown[]>>;
45
44
  /** Scope key -> the rows preloaded under it. */
46
45
  readonly preloaded: Map<string, Bucket>;
47
- /** Entity name -> writes this request has issued against it. */
48
- readonly writes: Map<string, number>;
46
+ /**
47
+ * Writes this request has issued, to any entity. ONE counter, not one per entity: a
48
+ * `countedIn` trigger rewrites the parent of the row inserted, an `onDelete` cascade or
49
+ * `set null` the children of the row deleted, and an app's own trigger anything at all — a
50
+ * write's statement names its entity, never every row it moves.
51
+ */
52
+ writes: number;
49
53
  }
50
54
 
51
55
  /**
@@ -58,7 +62,7 @@ const storeFor = (ctx: Ctx): Store => {
58
62
  const key: object = ctx;
59
63
  const existing = requests.get(key);
60
64
  if (existing !== undefined) return existing;
61
- const created: Store = { siblings: new Map(), preloaded: new Map(), writes: new Map() };
65
+ const created: Store = { siblings: new Map(), preloaded: new Map(), writes: 0 };
62
66
  requests.set(key, created);
63
67
  return created;
64
68
  };
@@ -111,8 +115,6 @@ export const siblingKeysHeld = (ctx: Ctx): number => {
111
115
  return held;
112
116
  };
113
117
 
114
- const writesTo = (store: Store, entity: string): number => store.writes.get(entity) ?? 0;
115
-
116
118
  /**
117
119
  * What a page of rows leaves behind: for each foreign key it declares, the distinct values that
118
120
  * page carried, filed under every one of them. A later `findById` for any of those values is a
@@ -156,19 +158,20 @@ export const tagSiblings = <Row>(entity: EntityCore<Row>, rows: readonly Row[]):
156
158
  };
157
159
 
158
160
  /**
159
- * A write makes every preloaded row of that entity stale, so the bucket holding them is not read
160
- * again. Called before the statement goes out: a row read back afterwards is the row the write
161
- * left, and one read concurrently with it was concurrent either way.
161
+ * A write makes every preloaded row stale — of every entity, since the database may rewrite rows
162
+ * of another (`Store.writes`) — so no bucket open before it is read again. Called before the
163
+ * statement goes out: a row read back afterwards is the row the write left, and one read
164
+ * concurrently with it was concurrent either way.
162
165
  */
163
- export const forgetPreloaded = (entityName: string): void => {
166
+ export const forgetPreloaded = (): void => {
164
167
  const ctx = tryUseContext();
165
168
  if (ctx === undefined) return;
166
169
  const store = requests.get(ctx);
167
170
  if (store === undefined) return;
168
- store.writes.set(entityName, writesTo(store, entityName) + 1);
171
+ store.writes += 1;
169
172
  };
170
173
 
171
- /** The bucket this lookup may be served from, or `undefined` — a stale one is dropped, not read. */
174
+ /** The bucket this lookup may be served from: one opened on this client with no write since. */
172
175
  const usableBucket = <Row>(
173
176
  store: Store,
174
177
  read: PointRead<Row>,
@@ -176,11 +179,15 @@ const usableBucket = <Row>(
176
179
  ): Bucket | undefined => {
177
180
  const open = store.preloaded.get(scope);
178
181
  if (open === undefined) return undefined;
179
- if (open.client === read.client && open.generation === writesTo(store, open.entity)) return open;
180
- store.preloaded.delete(scope);
181
- return undefined;
182
+ return open.client === read.client && open.generation === store.writes ? open : undefined;
182
183
  };
183
184
 
185
+ const emptyBucket = <Row>(store: Store, read: PointRead<Row>): Bucket => ({
186
+ client: read.client,
187
+ generation: store.writes,
188
+ rows: new Map<string, Promise<Answer>>(),
189
+ });
190
+
184
191
  type Settlers = ReadonlyMap<string, (answer: Answer) => void>;
185
192
 
186
193
  /** One statement at a time: a page wide enough to split must not take the pool with it. */
@@ -277,12 +284,15 @@ export const preloadedFindById = <Row>(
277
284
  if (already !== undefined) return answered<Row>(already);
278
285
  const ids = store.siblings.get(siblingKey(read.entity.$name, read.key.property))?.get(filedAt);
279
286
  if (ids === undefined) return undefined;
280
- const target = bucket ?? {
281
- entity: read.entity.$name,
282
- client: read.client,
283
- generation: writesTo(store, read.entity.$name),
284
- rows: new Map<string, Promise<Answer>>(),
285
- };
287
+ const stale = store.preloaded.get(scope);
288
+ if (bucket === undefined && stale !== undefined && stale.generation !== store.writes) {
289
+ // A write since this scope last preloaded. Declined, not re-preloaded: a loop that writes on
290
+ // every iteration would otherwise re-read the rest of the page each time — the page's rows
291
+ // squared. The empty bucket lets the first lookup after a write-free stretch preload again.
292
+ store.preloaded.set(scope, emptyBucket(store, read));
293
+ return undefined;
294
+ }
295
+ const target = bucket ?? emptyBucket(store, read);
286
296
  store.preloaded.set(scope, target);
287
297
  preload(read, target, keptWindow(read, ids, filedAt));
288
298
  const answer = target.rows.get(filedAt);