@ultimat3/entity 29.4.0 → 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 +118 -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 +144 -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 +90 -76
- package/src/errors.ts +8 -0
- package/src/history-actor.ts +22 -0
- package/src/history.ts +86 -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 +44 -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 +20 -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,33 +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';
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 {
|
package/src/jit-preload.ts
CHANGED
|
@@ -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 —
|
|
14
|
-
// statement it always
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
48
|
-
|
|
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:
|
|
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
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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 = (
|
|
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
|
|
171
|
+
store.writes += 1;
|
|
169
172
|
};
|
|
170
173
|
|
|
171
|
-
/** The bucket this lookup may be served from
|
|
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
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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);
|