@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.
- package/CLAUDE.md +66 -96
- package/README.md +125 -8
- package/package.json +15 -5
- package/src/append-only.ts +7 -0
- package/src/batch-read.ts +12 -4
- package/src/bulk-write-send.ts +49 -0
- package/src/bulk-write.ts +48 -11
- package/src/column.ts +29 -4
- package/src/counter.ts +109 -0
- package/src/cross-tenant.ts +13 -0
- package/src/cursor.ts +17 -1
- package/src/database.ts +16 -10
- package/src/describe-tenancy.ts +25 -0
- package/src/describe.ts +146 -3
- package/src/entity-error.ts +33 -0
- package/src/entity-parse.ts +89 -0
- package/src/entity-patch.ts +42 -0
- package/src/entity.ts +95 -98
- package/src/errors.ts +8 -0
- package/src/history-actor.ts +22 -0
- package/src/history.ts +86 -0
- package/src/index-identity.ts +46 -0
- package/src/index-init.ts +12 -0
- package/src/index.ts +20 -1
- package/src/jit-preload.ts +35 -25
- package/src/lifecycle.ts +100 -0
- package/src/lock-key.ts +27 -0
- package/src/lock-unsupported.ts +11 -0
- package/src/memory-aggregates.ts +99 -0
- package/src/memory-batch.ts +69 -0
- package/src/memory-journal.ts +68 -0
- package/src/memory-lifecycle.ts +106 -0
- package/src/memory-lock.ts +144 -0
- package/src/memory-observed.ts +121 -0
- package/src/memory-repo.ts +199 -186
- package/src/memory-same-tenant.ts +83 -0
- package/src/memory-seek.ts +44 -0
- package/src/memory-transactor.ts +94 -0
- package/src/memory-unique.ts +19 -4
- package/src/n-plus-one.ts +28 -10
- package/src/numeric-compare.ts +43 -0
- package/src/pg-aggregates.ts +185 -0
- package/src/pg-driver.ts +79 -166
- package/src/pg-lock.ts +50 -0
- package/src/pg-row.ts +29 -2
- package/src/pg-sql-aggregate.ts +31 -0
- package/src/pg-sql.ts +17 -1
- package/src/query.ts +26 -105
- package/src/read-aggregates.ts +138 -0
- package/src/read-capture.ts +22 -0
- package/src/registry.ts +49 -1
- package/src/repo.ts +34 -0
- package/src/retain.ts +71 -0
- package/src/row-observer.ts +22 -4
- package/src/row-security-settings.ts +20 -0
- package/src/sealed-repo.ts +15 -0
- package/src/seed.ts +43 -7
- package/src/series-unsupported.ts +11 -0
- package/src/table-locks.ts +41 -0
- package/src/test-driver.ts +90 -0
- package/src/time-bucket.ts +147 -0
- package/src/touch.ts +33 -0
- package/src/types.ts +22 -0
- package/src/view-entity.ts +78 -0
- package/src/view-issue.ts +47 -0
- package/src/view-repo.ts +38 -0
- 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 {
|
|
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 {
|
|
12
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|