@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/entity.ts CHANGED
@@ -4,32 +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';
23
+ import { distinctIndexes } from './index-identity';
16
24
  import type { IndexInit } from './index-init';
17
25
  import { indexName } from './index-name';
18
26
  import type { Invariant, InvariantDef } from './invariants';
19
27
  import { assertInvariants, bindInvariant } from './invariants';
28
+ import { describeLifecycle, lifecycleHistory } from './lifecycle';
20
29
  import { recordProjection } from './record-projection';
21
30
  import type { EntityDescription, ReferenceDescription } from './registry';
22
31
  import { registerEntity } from './registry';
32
+ import { type Retain, type RetainInit, retainOf } from './retain';
23
33
  import { rowSchema } from './row-schema';
24
34
  import { sealedFields } from './sealed';
25
35
  import { assertSealedDeclaration } from './sealed-declaration';
26
- import { sealedInView, sealedMissing } from './sealed-errors';
36
+ import { sealedInView } from './sealed-errors';
27
37
  import type { SearchInit, SearchSource, SearchVector } from './search';
28
38
  import { searchVectorOf } from './search';
29
39
  import { resolveTenantColumn } from './tenancy';
30
- import type { AnyColumn, ColumnMap, ColumnMeta, IndexDef, RowOf, WireRow } from './types';
40
+ import type { AnyColumn, ColumnMap, IndexDef, RowOf, WireRow } from './types';
31
41
  import type { EntityView } from './view';
32
42
  import { viewFor } from './view';
43
+ import { type ViewInit, type ViewQuery, viewQueryOf } from './view-entity';
33
44
 
34
45
  /** Presence of this column is what makes an entity soft-deletable — not a flag. */
35
46
  export const SOFT_DELETE_COLUMN = 'deletedAt';
@@ -50,6 +61,17 @@ export interface EntityInit<C extends ColumnMap> {
50
61
  * `orgId`, so silence never means unscoped.
51
62
  */
52
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;
53
75
  /** Composite keys only — a single key is `uuid().primaryKey()` on the column itself. */
54
76
  readonly primaryKey?: readonly (keyof C & string)[];
55
77
  /**
@@ -79,6 +101,26 @@ export interface EntityInit<C extends ColumnMap> {
79
101
  * refuses the same in raw SQL. A soft-delete, `onUpdateNow()` or state-machine column is refused.
80
102
  */
81
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;
82
124
  }
83
125
 
84
126
  /**
@@ -99,6 +141,14 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
99
141
  readonly $softDelete: boolean;
100
142
  /** `entity({ appendOnly: true })`: update and delete are refused, here and by the table. */
101
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;
102
152
  /** Property key of the tenant column, or `null`. Presence is what turns tenancy on. */
103
153
  readonly $tenantColumn: string | null;
104
154
  /**
@@ -124,6 +174,8 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
124
174
  $tagFor(id: string): string;
125
175
  /** Fills declared defaults, then validates every column. Throws on a bad value. */
126
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;
127
179
  /**
128
180
  * `const PostView = posts.$view(['id', 'title'])` — the projection an action names as its
129
181
  * `output`. An unknown key is a compile error, and a declaration error for a JS caller.
@@ -155,28 +207,27 @@ export type Entity<Row, C extends ColumnMap = ColumnMap> = Omit<EntityCore<Row,
155
207
 
156
208
  const MONEY_PARTS = new Set(['minor', 'currency']);
157
209
 
158
- const defaultValue = (meta: ColumnMeta): unknown => {
159
- const declared = meta.default;
160
- if (declared === undefined) return undefined;
161
- if (declared.kind === 'value') return declared.value;
162
- return declared.by === 'uuid-v7' ? newId() : entityNow();
163
- };
164
-
165
210
  export const entity = <const C extends ColumnMap>(
166
211
  name: string,
167
212
  init: EntityInit<C>,
168
213
  ): Entity<RowOf<C>, C> => {
169
214
  type Row = RowOf<C>;
170
215
  const entries: readonly (readonly [string, AnyColumn])[] = Object.entries(init.columns);
171
- 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);
172
221
 
173
222
  // Both branches. The declared table was checked and the fallback — which is every entity that
174
223
  // does not rename its table — was not, so an entity NAME closed the identifier in the same way a
175
224
  // column name could: `entity('t" (x int); drop table u; --')` emitted that `drop table` verbatim.
176
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);
177
229
  const cacheTag = `entity:${name}`;
178
230
  const softDelete = Object.hasOwn(init.columns, SOFT_DELETE_COLUMN);
179
- const tenantColumn = resolveTenantColumn(name, init.columns, init.tenant);
180
231
  const appendOnly = init.appendOnly === true;
181
232
  if (appendOnly) assertAppendOnlyColumns(name, entries, SOFT_DELETE_COLUMN);
182
233
 
@@ -303,6 +354,8 @@ export const entity = <const C extends ColumnMap>(
303
354
  // Absent stays absent: `btree` written out would be a field every existing snapshot lacks,
304
355
  // and `indexMethodOf` reads the two the same way precisely so nothing regenerates.
305
356
  ...(index.using === undefined || index.using === 'btree' ? {} : { using: index.using }),
357
+ // How it is BUILT, so not part of the name: `true` or absent, like `appendOnly`.
358
+ ...(index.concurrently === true ? { concurrently: true as const } : {}),
306
359
  };
307
360
  }),
308
361
  // The one index nobody declared and every search needs. Through the SAME `IndexInit` path a
@@ -319,29 +372,10 @@ export const entity = <const C extends ColumnMap>(
319
372
  },
320
373
  ]),
321
374
  ];
322
- /**
323
- * A foreign key already indexes its column; naming it again in `indexes` is not two indexes.
324
- *
325
- * On the WHOLE definition and not on the name. With the discriminator above the two rules agree
326
- * exactly, so this is not a behaviour change on its own — it is which one FAILS LOUDLY if the
327
- * naming is ever weakened again. Matching on the name drops the second index in silence, which
328
- * is how two different partial indexes became one for three majors; matching on the definition
329
- * keeps both, and two `create index` statements sharing a name is `42P07` on the next migration.
330
- */
331
- const identity = (index: IndexDef): string =>
332
- [
333
- index.name,
334
- index.columns.join(','),
335
- index.unique,
336
- index.order ?? '',
337
- index.where ?? '',
338
- index.using ?? '',
339
- ].join('|');
340
- const indexes: readonly IndexDef[] = declared.filter(
341
- (index, position) =>
342
- declared.findIndex((other) => identity(other) === identity(index)) === position,
343
- );
375
+ // A foreign key's own index named again in `indexes` is one index, not two (`index-identity.ts`).
376
+ const indexes = distinctIndexes(declared);
344
377
 
378
+ const retain = retainOf(name, init.retain, entries, indexes, appendOnly);
345
379
  const sealed = sealedFields({ $name: name, $table: table, $columns: init.columns });
346
380
  assertSealedDeclaration({
347
381
  name,
@@ -352,6 +386,11 @@ export const entity = <const C extends ColumnMap>(
352
386
  invariants,
353
387
  });
354
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
+ });
355
394
  /** The columns that leave the server: every one that is not sealed. */
356
395
  const wireEntries = entries.filter(([property]) => !sealedKeys.has(property));
357
396
 
@@ -370,69 +409,16 @@ export const entity = <const C extends ColumnMap>(
370
409
  tenantColumn,
371
410
  search,
372
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),
373
417
  });
418
+ assertNotRetired(name, entries, init.retired);
374
419
  const references = (): readonly ReferenceDescription[] => describeReferences(name, entries);
375
420
 
376
- const parse = (value: unknown): Row => {
377
- if (typeof value !== 'object' || value === null) {
378
- // `describeValue`, never `String(value)`: this message is a `$parse` failure, which reaches
379
- // the caller and the log line — and the whole row is the last value in the framework that
380
- // may be echoed there. Same renderer as the column builders (`columns.ts`) use.
381
- throw invariantViolated(name, 'row', `expected an object, got ${describeValue(value)}`);
382
- }
383
- const input = value as Readonly<Record<string, unknown>>;
384
- const row: Record<string, unknown> = {};
385
- for (const [property, column] of entries) {
386
- // `=== undefined`, never `??`: an explicit `null` is the caller CLEARING a nullable column,
387
- // and `??` read it as absence and wrote the column's declared default straight back — so
388
- // `update(id, { status: null })` reported success and stored `'draft'`, with nothing on any
389
- // surface to say otherwise. A present `undefined` still means absence, which is what a
390
- // spread of an omitted optional key produces and is the shape every existing caller has.
391
- const raw = input[property];
392
- const given = raw === undefined ? defaultValue(column.$meta) : raw;
393
- if (given === undefined || given === null) {
394
- if (column.$meta.notNull) {
395
- // A required sealed column ABSENT from a row is nearly always a spread of a repository
396
- // row, which does not enumerate it — so the refusal names that, and the rewrite.
397
- throw given === undefined && sealedKeys.has(property)
398
- ? sealedMissing(name, property)
399
- : invariantViolated(name, property, 'is required and has no default');
400
- }
401
- row[property] = null;
402
- continue;
403
- }
404
- row[property] = column.$parse(given);
405
- }
406
- // Every property was validated by its own column above, so the shape is the derived row.
407
- return row as Row;
408
- };
409
-
410
- /**
411
- * The row as `$schema` validates it: the sealed columns are neither required nor returned, so a
412
- * handler's row — which holds their plaintext — parses to a value that does not.
413
- */
414
- const parseWire = (value: unknown): WireRow<Row, C> => {
415
- if (sealedKeys.size === 0) return parse(value) as WireRow<Row, C>;
416
- if (typeof value !== 'object' || value === null) {
417
- throw invariantViolated(name, 'row', `expected an object, got ${describeValue(value)}`);
418
- }
419
- const input = value as Readonly<Record<string, unknown>>;
420
- const row: Record<string, unknown> = {};
421
- for (const [property, column] of wireEntries) {
422
- const raw = input[property];
423
- const given = raw === undefined ? defaultValue(column.$meta) : raw;
424
- if (given === undefined || given === null) {
425
- if (column.$meta.notNull) {
426
- throw invariantViolated(name, property, 'is required and has no default');
427
- }
428
- row[property] = null;
429
- continue;
430
- }
431
- row[property] = column.$parse(given);
432
- }
433
- // Every wire property was validated by its own column, and no sealed one was copied.
434
- return row as WireRow<Row, C>;
435
- };
421
+ const { parse, parseWire } = rowParsers<Row, WireRow<Row, C>>(name, entries, sealedKeys);
436
422
 
437
423
  const schema = rowSchema<WireRow<Row, C>>(
438
424
  { name, table, primaryKey, persist: init.persist === true, sealed: [...sealedKeys] },
@@ -450,7 +436,8 @@ export const entity = <const C extends ColumnMap>(
450
436
  $tags: tags,
451
437
  $cacheTag: cacheTag,
452
438
  $softDelete: softDelete,
453
- $appendOnly: appendOnly,
439
+ $appendOnly: appendOnly || init.historyOf !== undefined,
440
+ $viewQuery: viewQuery,
454
441
  $tenantColumn: tenantColumn,
455
442
  $search: search,
456
443
  $schema: schema,
@@ -460,6 +447,7 @@ export const entity = <const C extends ColumnMap>(
460
447
  },
461
448
  $tagFor: (id) => `${cacheTag}:${id}`,
462
449
  $parse: parse,
450
+ $parsePatch: (patch) => parseEntityPatch(name, init.columns, patch),
463
451
  $view: <K extends keyof Row & string>(keys: readonly K[]) => {
464
452
  // Before the view exists: a view is an `output`, and a sealed column is in none.
465
453
  const named = keys.find((key) => sealedKeys.has(key));
@@ -469,6 +457,11 @@ export const entity = <const C extends ColumnMap>(
469
457
  $assert: (row) => assertInvariants(name, invariants, row),
470
458
  $describe: describe,
471
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,
472
465
  };
473
466
 
474
467
  registerEntity({
@@ -480,6 +473,10 @@ export const entity = <const C extends ColumnMap>(
480
473
  describe,
481
474
  references,
482
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 });
483
480
  // The columns land on the entity itself so `orgs.id` is a column reference; every framework
484
481
  // member is `$`-prefixed, which is why a column may be called `name`.
485
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
+ }
@@ -0,0 +1,46 @@
1
+ // When two index declarations are ONE index. Split out of `entity.ts` at the 500-line ceiling: a
2
+ // foreign key already indexes its column, and naming it again in `indexes` is not two indexes.
3
+
4
+ import type { IndexDef } from './types';
5
+
6
+ /**
7
+ * WHAT the index is — never how it is built. `concurrently` is left out on purpose: it changes the
8
+ * migration that builds an index, not the index, so a foreign key's own index and an `indexes:`
9
+ * entry asking for the same one concurrently are still one index (`distinctIndexes` keeps the ask).
10
+ */
11
+ const identity = (index: IndexDef): string =>
12
+ [
13
+ index.name,
14
+ index.columns.join(','),
15
+ index.unique,
16
+ index.order ?? '',
17
+ index.where ?? '',
18
+ index.using ?? '',
19
+ ].join('|');
20
+
21
+ /**
22
+ * Every declared index once, in first-declared order.
23
+ *
24
+ * On the WHOLE definition and not on the name. With the name discriminator (`index-name.ts`) the
25
+ * two rules agree exactly, so this is not a behaviour change on its own — it is which one FAILS
26
+ * LOUDLY if the naming is ever weakened again. Matching on the name drops the second index in
27
+ * silence, which is how two different partial indexes became one for three majors; matching on the
28
+ * definition keeps both, and two `create index` statements sharing a name is `42P07` on the next
29
+ * migration.
30
+ *
31
+ * A duplicate that asks for `concurrently: true` hands that ask to the one kept: dropping it would
32
+ * silently build a hot table's index inside the migration's transaction after all.
33
+ */
34
+ export function distinctIndexes(declared: readonly IndexDef[]): readonly IndexDef[] {
35
+ return declared
36
+ .filter(
37
+ (index, position) =>
38
+ declared.findIndex((other) => identity(other) === identity(index)) === position,
39
+ )
40
+ .map((index) =>
41
+ index.concurrently !== true &&
42
+ declared.some((other) => other.concurrently === true && identity(other) === identity(index))
43
+ ? { ...index, concurrently: true as const }
44
+ : index,
45
+ );
46
+ }
package/src/index-init.ts CHANGED
@@ -26,4 +26,16 @@ export interface IndexInit<C extends ColumnMap> {
26
26
  * unique and cannot order its keys.
27
27
  */
28
28
  readonly using?: IndexMethod;
29
+ /**
30
+ * Build it with `create index concurrently`, in a migration step of its own that runs OUTSIDE a
31
+ * transaction — so a big, hot table keeps taking writes while the index builds. A plain `create
32
+ * index` holds SHARE on the table for the whole build: every insert, update and delete waits.
33
+ *
34
+ * How the index is BUILT, never what it is: not part of its name, not recorded in a snapshot,
35
+ * and switching it on for an existing index generates nothing. `x db gen` writes the step as
36
+ * `<id>_concurrently.sql` beside the migration (or as the migration, when it is the only change),
37
+ * marked `-- transaction: false`; an index on a table the same migration creates is still built
38
+ * inside that migration's transaction — the table is empty and nobody else can see it.
39
+ */
40
+ readonly concurrently?: boolean;
29
41
  }
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 {