@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/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);
|
package/src/lifecycle.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Single responsibility: the three lifecycle declarations an entity may carry — `retain`, `history`
|
|
2
|
+
// and the `countedIn` counters kept on its columns — resolved once at `entity()` and projected onto its
|
|
3
|
+
// description. Split from `entity.ts`, which was at its ceiling; each rule is its own file's.
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
CounterDescriptionLike,
|
|
7
|
+
HistoryDescriptionLike,
|
|
8
|
+
RetainDescriptionLike,
|
|
9
|
+
} from '@ultimat3/db';
|
|
10
|
+
import { columnName } from './column';
|
|
11
|
+
import { countersOf, describeCounter } from './counter';
|
|
12
|
+
import type { EntityCore } from './entity';
|
|
13
|
+
import { invariantViolated } from './errors';
|
|
14
|
+
import { type HistoryEntity, type HistoryInit, historyEntity } from './history';
|
|
15
|
+
import type { Retain } from './retain';
|
|
16
|
+
import type { ColumnMap } from './types';
|
|
17
|
+
|
|
18
|
+
/** What `$describe()` gains, each absent unless declared so no other entity's hash moves. */
|
|
19
|
+
export interface LifecycleDescription {
|
|
20
|
+
readonly history?: HistoryDescriptionLike;
|
|
21
|
+
readonly counts?: readonly CounterDescriptionLike[];
|
|
22
|
+
readonly retain?: RetainDescriptionLike;
|
|
23
|
+
readonly historyOf?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LifecycleInput {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly table: string;
|
|
29
|
+
readonly columns: ColumnMap;
|
|
30
|
+
readonly primaryKey: readonly string[];
|
|
31
|
+
readonly tenantColumn: string | null;
|
|
32
|
+
readonly appendOnly: boolean;
|
|
33
|
+
readonly history: HistoryInit | undefined;
|
|
34
|
+
readonly historyOf: string | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The history entity, declared beside its parent — `null` when the entity keeps none. */
|
|
38
|
+
export function lifecycleHistory(input: LifecycleInput): HistoryEntity | null {
|
|
39
|
+
if (input.history === undefined) return null;
|
|
40
|
+
if (input.historyOf !== undefined || input.appendOnly) {
|
|
41
|
+
throw invariantViolated(
|
|
42
|
+
input.name,
|
|
43
|
+
'history',
|
|
44
|
+
input.appendOnly
|
|
45
|
+
? 'an appendOnly entity changes no row, so it has no history to keep — drop one of the two'
|
|
46
|
+
: 'a history table keeps no history of its own',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return historyEntity(input, input.history);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The physical columns whose jsonb spelling is not the row's: an `int8`/`numeric` becomes a JSON
|
|
54
|
+
* number a reader parses into a float, a `timestamptz` the session zone's offset. The trigger
|
|
55
|
+
* stores these as the strings the memory driver keeps. Absent when empty, so no other hash moves.
|
|
56
|
+
*/
|
|
57
|
+
function exactColumns(core: EntityCore): Pick<HistoryDescriptionLike, 'text' | 'instants'> {
|
|
58
|
+
const text: string[] = [];
|
|
59
|
+
const instants: string[] = [];
|
|
60
|
+
for (const [property, column] of Object.entries(core.$columns)) {
|
|
61
|
+
const kind = column.$meta.kind;
|
|
62
|
+
if (kind === 'bigint' || kind === 'numeric') text.push(columnName(property, column.$meta));
|
|
63
|
+
if (kind === 'timestamptz') instants.push(columnName(property, column.$meta));
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
...(text.length === 0 ? {} : { text }),
|
|
67
|
+
...(instants.length === 0 ? {} : { instants }),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const physical = (core: EntityCore, property: string): string => {
|
|
72
|
+
const column = core.$columns[property];
|
|
73
|
+
return column === undefined ? property : columnName(property, column.$meta);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Read at describe time: a counter's thunk can only be answered once both modules evaluated. */
|
|
77
|
+
export function describeLifecycle(
|
|
78
|
+
core: EntityCore,
|
|
79
|
+
retain: Retain | null,
|
|
80
|
+
historyOf: string | undefined,
|
|
81
|
+
): LifecycleDescription {
|
|
82
|
+
const counters = countersOf(core);
|
|
83
|
+
const history = core.$history;
|
|
84
|
+
return {
|
|
85
|
+
...(history === null
|
|
86
|
+
? {}
|
|
87
|
+
: {
|
|
88
|
+
history: {
|
|
89
|
+
table: history.$table,
|
|
90
|
+
key: physical(core, core.$primaryKey[0] ?? ''),
|
|
91
|
+
tenant: core.$tenantColumn === null ? null : physical(core, core.$tenantColumn),
|
|
92
|
+
ignore: counters.map((counter) => physical(core, counter.column)),
|
|
93
|
+
...exactColumns(core),
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
...(counters.length === 0 ? {} : { counts: counters.map(describeCounter) }),
|
|
97
|
+
...(retain === null ? {} : { retain: { after: retain.after, on: retain.column } }),
|
|
98
|
+
...(historyOf === undefined ? {} : { historyOf }),
|
|
99
|
+
};
|
|
100
|
+
}
|
package/src/lock-key.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Single responsibility: what `lockKey` locks, in one place for both drivers. The tenant comes from
|
|
2
|
+
// the read path's own plan, so an org's key never blocks another org's and an actor with none is
|
|
3
|
+
// refused exactly as a read is, by the same guard and the same code.
|
|
4
|
+
|
|
5
|
+
import { assert } from '@ultimat3/core';
|
|
6
|
+
import type { EntityCore } from './entity';
|
|
7
|
+
import { readPlan } from './plan';
|
|
8
|
+
import type { RepoOptions } from './repo';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `<table>:<tenant or ->:<key>`. `-` is an entity with no tenant, or a `crossTenant()` sweep that
|
|
12
|
+
* named none: the sweep then holds its own key and no org's, and naming `orgId` is how it takes one.
|
|
13
|
+
*/
|
|
14
|
+
export const lockKeyInput = <Row>(
|
|
15
|
+
entity: EntityCore<Row>,
|
|
16
|
+
key: string,
|
|
17
|
+
options: RepoOptions | undefined,
|
|
18
|
+
): string => {
|
|
19
|
+
assert(
|
|
20
|
+
key.trim() !== '',
|
|
21
|
+
`${entity.$name}.lockKey() was given a blank key, so every caller would share one lock`,
|
|
22
|
+
`db.${entity.$name}.lockKey('recount:' + id, fn) # name what the lock protects`,
|
|
23
|
+
);
|
|
24
|
+
const plan = readPlan(entity, { ...options }, 'lockKey');
|
|
25
|
+
const named = plan.where.find((predicate) => predicate.column === entity.$tenantColumn)?.value;
|
|
26
|
+
return `${entity.$table}:${typeof named === 'string' ? named : '-'}:${key}`;
|
|
27
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The refusal a hand-written `Repo` answers `lock` and `lockKey` with. They are optional on the
|
|
2
|
+
// contract so such a driver still compiles, which is exactly why the typed handle has to say so.
|
|
3
|
+
|
|
4
|
+
import { EntityError } from './entity-error';
|
|
5
|
+
|
|
6
|
+
export const lockUnsupported = (entityName: string, method: 'lock' | 'lockKey'): EntityError =>
|
|
7
|
+
new EntityError({
|
|
8
|
+
code: 'X_LOCK_UNSUPPORTED',
|
|
9
|
+
cause: `${entityName}.${method}() reached a repository that does not implement it: a hand-written driver, which the contract lets omit both`,
|
|
10
|
+
fix: 'database(entities, { driver: postgresDriver() }) # the shipped driver has both; a hand-written Repo implements lock and lockKey',
|
|
11
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Single responsibility: the in-memory driver's aggregate reads — `aggregate`, `approximateCount`,
|
|
2
|
+
// `countBy` and `series` — over rows the driver already selected. Split from `memory-repo.ts` at
|
|
3
|
+
// its line ceiling; every rule here is read from the file both drivers share (`aggregate.ts`,
|
|
4
|
+
// `count-by.ts`, `time-bucket.ts`), so this is execution, never a second meaning.
|
|
5
|
+
|
|
6
|
+
import { aggregateColumnOf, assertOneUnit, type MoneyUnit } from './aggregate';
|
|
7
|
+
import { foldAggregate } from './aggregate-fold';
|
|
8
|
+
import { countsFrom, groupColumnOf } from './count-by';
|
|
9
|
+
import { valueAt } from './cursor';
|
|
10
|
+
import type { EntityCore } from './entity';
|
|
11
|
+
import type { FindManyArgs, Repo } from './repo';
|
|
12
|
+
import type { QueryPlan } from './tenancy';
|
|
13
|
+
import { bucketStart, type SeriesPoint, seriesFrom, seriesPlanOf } from './time-bucket';
|
|
14
|
+
|
|
15
|
+
/** `memoryRepo`'s own read: the plan built and guarded, the visible rows it matches. */
|
|
16
|
+
export type MemorySelect<Row> = (
|
|
17
|
+
args: FindManyArgs,
|
|
18
|
+
operation: string,
|
|
19
|
+
) => { plan: QueryPlan; found: Row[] };
|
|
20
|
+
|
|
21
|
+
type AggregateReads<Row> = Pick<
|
|
22
|
+
Required<Repo<Row>>,
|
|
23
|
+
'aggregate' | 'approximateCount' | 'countBy' | 'series'
|
|
24
|
+
>;
|
|
25
|
+
|
|
26
|
+
const moneyUnitOf = (value: unknown): MoneyUnit | undefined => {
|
|
27
|
+
if (typeof value !== 'object' || value === null) return undefined;
|
|
28
|
+
const money = value as { readonly currency?: unknown; readonly scale?: unknown };
|
|
29
|
+
if (typeof money.currency !== 'string') return undefined;
|
|
30
|
+
return { currency: money.currency, scale: typeof money.scale === 'number' ? money.scale : null };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const memoryAggregates = <Row>(
|
|
34
|
+
entity: EntityCore<Row>,
|
|
35
|
+
select: MemorySelect<Row>,
|
|
36
|
+
): AggregateReads<Row> => ({
|
|
37
|
+
async aggregate(fn, column, args = {}) {
|
|
38
|
+
// Refused before a row is read, and by the same function the Postgres driver calls: a column
|
|
39
|
+
// that has no aggregate is that mistake in both drivers or in neither.
|
|
40
|
+
const declared = aggregateColumnOf(entity, fn, column);
|
|
41
|
+
const { found } = select(args, fn);
|
|
42
|
+
return foldAggregate(entity, fn, column, declared.$meta.kind, found);
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Exact here, and that is the honest answer rather than a shortcut: the estimate exists
|
|
47
|
+
* because `count(*)` walks every visible row of a real table, and this driver's rows are
|
|
48
|
+
* already an array whose length is free. Filters are refused in both drivers by `plan.ts`,
|
|
49
|
+
* so the two still answer the same QUESTION.
|
|
50
|
+
*/
|
|
51
|
+
async approximateCount(args = {}) {
|
|
52
|
+
return select(args, 'approximateCount').found.length;
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async countBy(column, args = {}) {
|
|
56
|
+
// Refused before a row is read, and by the same function the Postgres driver calls: a column
|
|
57
|
+
// a map cannot be keyed by is that mistake in both drivers or in neither.
|
|
58
|
+
groupColumnOf(entity, column, 'countBy');
|
|
59
|
+
const { found } = select(args, 'countBy');
|
|
60
|
+
const groups = new Map<unknown, number>();
|
|
61
|
+
for (const row of found) {
|
|
62
|
+
// `?? null`, so a property this row never carried lands in the same group Postgres puts a
|
|
63
|
+
// NULL row in — and `0`, `''` and `false` stay the values they are.
|
|
64
|
+
const value = valueAt(row, column) ?? null;
|
|
65
|
+
groups.set(value, (groups.get(value) ?? 0) + 1);
|
|
66
|
+
}
|
|
67
|
+
return countsFrom(entity, column, 'countBy', [...groups]);
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
async series(spec, args = {}) {
|
|
71
|
+
const plan = seriesPlanOf(entity, spec);
|
|
72
|
+
const { found } = select(args, 'series');
|
|
73
|
+
const sum = plan.sum;
|
|
74
|
+
// One unit across EVERY matched row, before any bucket: the Postgres driver judges the whole
|
|
75
|
+
// predicate in one statement, so a bucket-by-bucket check here would answer where it refuses.
|
|
76
|
+
if (sum?.column.$meta.kind === 'money') {
|
|
77
|
+
const units = found.flatMap((row) => moneyUnitOf(valueAt(row, sum.property)) ?? []);
|
|
78
|
+
assertOneUnit(entity, 'sum', sum.property, units);
|
|
79
|
+
}
|
|
80
|
+
// A row with no instant is in no bucket — Postgres' statement says `is not null`.
|
|
81
|
+
const buckets = new Map<number, Row[]>();
|
|
82
|
+
for (const row of found) {
|
|
83
|
+
const at = valueAt(row, plan.on);
|
|
84
|
+
if (!(at instanceof Date)) continue;
|
|
85
|
+
const start = bucketStart(at, plan.unit, plan.timeZone).getTime();
|
|
86
|
+
const bucket = buckets.get(start);
|
|
87
|
+
if (bucket === undefined) buckets.set(start, [row]);
|
|
88
|
+
else bucket.push(row);
|
|
89
|
+
}
|
|
90
|
+
const points: SeriesPoint[] = [...buckets].map(([start, rows]) => ({
|
|
91
|
+
bucket: new Date(start),
|
|
92
|
+
value:
|
|
93
|
+
sum === null
|
|
94
|
+
? rows.length
|
|
95
|
+
: foldAggregate(entity, 'sum', sum.property, sum.column.$meta.kind, rows),
|
|
96
|
+
}));
|
|
97
|
+
return seriesFrom(entity, plan, points);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Single responsibility: the memory driver's one-statement promise for a batch judged as it lands.
|
|
2
|
+
// Postgres refuses a statement as one; rows the memory driver can only judge one at a time are
|
|
3
|
+
// undone here when a later one is refused, and handed to the surrounding `tx` when none is.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
enrolMemoryScope,
|
|
7
|
+
memoryJournal,
|
|
8
|
+
memoryOutcomeOf,
|
|
9
|
+
memoryTxClosed,
|
|
10
|
+
refuseIfMemoryTxClosed,
|
|
11
|
+
} from './memory-journal';
|
|
12
|
+
import type { Tx } from './repo';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `work` under a scope collecting its undos: run on a throw or a rejection, handed to `tx` when it
|
|
16
|
+
* succeeds. An effect with nowhere to wait runs at once, what `Tx.onCommit` promises a bare `Tx`.
|
|
17
|
+
* Async work settles the scope when it settles; a lock's savepoint is this scope too.
|
|
18
|
+
*/
|
|
19
|
+
export const undoingBatch = <T>(given: Tx | undefined, work: (scope: Tx) => T): T => {
|
|
20
|
+
// No `tx` named: the ambient rollback scope, when one is open, is the surrounding transaction.
|
|
21
|
+
const tx = given ?? memoryJournal();
|
|
22
|
+
// Before a row moves, as `@ultimat3/db` refuses before a statement is sent.
|
|
23
|
+
refuseIfMemoryTxClosed(tx);
|
|
24
|
+
const undos: (() => void)[] = [];
|
|
25
|
+
const undo = (): void => {
|
|
26
|
+
for (const each of undos.reverse()) each();
|
|
27
|
+
undos.length = 0;
|
|
28
|
+
};
|
|
29
|
+
const handUp = (): void => {
|
|
30
|
+
// The scope outlived its transaction, as a RELEASE sent after COMMIT or ROLLBACK is refused.
|
|
31
|
+
// Its rows went where the root went: kept by a commit, undone here after a rollback.
|
|
32
|
+
const ended = tx === undefined ? undefined : memoryOutcomeOf(tx)?.value;
|
|
33
|
+
if (tx !== undefined && ended !== undefined && ended !== 'open') {
|
|
34
|
+
if (ended === 'rolled-back') undo();
|
|
35
|
+
undos.length = 0;
|
|
36
|
+
throw memoryTxClosed(tx.id, ended);
|
|
37
|
+
}
|
|
38
|
+
for (const each of undos) tx?.onRollback(each);
|
|
39
|
+
};
|
|
40
|
+
const scope: Tx = {
|
|
41
|
+
id: tx?.id ?? 'batch',
|
|
42
|
+
onRollback: (each) => {
|
|
43
|
+
refuseIfMemoryTxClosed(scope);
|
|
44
|
+
undos.push(each);
|
|
45
|
+
},
|
|
46
|
+
onCommit: (effect) => (tx?.onCommit === undefined ? effect() : tx.onCommit(effect)),
|
|
47
|
+
};
|
|
48
|
+
enrolMemoryScope(scope, tx);
|
|
49
|
+
try {
|
|
50
|
+
const result = work(scope);
|
|
51
|
+
if (result instanceof Promise) {
|
|
52
|
+
return result.then(
|
|
53
|
+
(value) => {
|
|
54
|
+
handUp();
|
|
55
|
+
return value;
|
|
56
|
+
},
|
|
57
|
+
(error: unknown) => {
|
|
58
|
+
undo();
|
|
59
|
+
throw error;
|
|
60
|
+
},
|
|
61
|
+
) as T;
|
|
62
|
+
}
|
|
63
|
+
handUp();
|
|
64
|
+
return result;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
undo();
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Single responsibility: the memory transaction this async chain is inside, ambient as
|
|
2
|
+
// `@ultimat3/db`'s `withTransaction` is — a write that names no `tx` joins it, so a handler that
|
|
3
|
+
// passes none is still undone when the transaction rolls back — and whether that one has ENDED: the
|
|
4
|
+
// async chain outlives the scope, so the store's presence is not an open transaction.
|
|
5
|
+
|
|
6
|
+
import { asyncContext } from '@ultimat3/core';
|
|
7
|
+
import { DbError } from '@ultimat3/db';
|
|
8
|
+
import type { Tx } from './repo';
|
|
9
|
+
|
|
10
|
+
/** How the ROOT ended, shared by reference across nesting, as `@ultimat3/db`'s `TxOutcome` is. */
|
|
11
|
+
export interface MemoryOutcome {
|
|
12
|
+
value: 'open' | 'committed' | 'rolled-back';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Each memory `Tx` handed out, its root and the root's outcome: a hand-written `Tx` has none. */
|
|
16
|
+
interface Scope {
|
|
17
|
+
readonly root: Tx;
|
|
18
|
+
readonly outcome: MemoryOutcome;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const journal = asyncContext<Tx>('an open memory transaction');
|
|
22
|
+
const scopes = new WeakMap<Tx, Scope>();
|
|
23
|
+
|
|
24
|
+
/** The open memory transaction this async chain writes under, or `undefined` outside one. */
|
|
25
|
+
export const memoryJournal = (): Tx | undefined => journal.get();
|
|
26
|
+
|
|
27
|
+
/** `work` with `tx` as the ambient memory transaction. `memoryTransactor` is the one caller. */
|
|
28
|
+
export const inMemoryJournal = <T>(tx: Tx, work: () => T): T => journal.run(tx, work);
|
|
29
|
+
|
|
30
|
+
/** `work` under no memory transaction: an effect after COMMIT writes as its own unit of work. */
|
|
31
|
+
export const outsideMemoryJournal = <T>(work: () => T): T => journal.exit(work);
|
|
32
|
+
|
|
33
|
+
/** Files `tx` under its root. `memoryTransactor` is the one caller. */
|
|
34
|
+
export const enrolMemoryTx = (tx: Tx, root: Tx, outcome: MemoryOutcome): void => {
|
|
35
|
+
scopes.set(tx, { root, outcome });
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** The outcome a memory `tx` shares with its root, or `undefined` for any other `Tx`. */
|
|
39
|
+
export const memoryOutcomeOf = (tx: Tx): MemoryOutcome | undefined => scopes.get(tx)?.outcome;
|
|
40
|
+
|
|
41
|
+
/** `scope` — a batch or a lock's savepoint over `tx` — answers to `tx`'s root, when it has one. */
|
|
42
|
+
export const enrolMemoryScope = (scope: Tx, tx: Tx | undefined): void => {
|
|
43
|
+
const parent = tx === undefined ? undefined : scopes.get(tx);
|
|
44
|
+
if (parent !== undefined) scopes.set(scope, parent);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** The root a memory `tx` belongs to — what a lock is held to — or `tx` itself for any other. */
|
|
48
|
+
export const memoryRoot = (tx: Tx): Tx => scopes.get(tx)?.root ?? tx;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The same refusal a statement on a finished `DbTx` meets (`X_DB_TRANSACTION_CLOSED`): one mistake,
|
|
52
|
+
* one code on both drivers. The cause names the memory scope, because that is what outlived it.
|
|
53
|
+
*/
|
|
54
|
+
export const memoryTxClosed = (id: string, outcome: string): DbError =>
|
|
55
|
+
new DbError({
|
|
56
|
+
code: 'X_DB_TRANSACTION_CLOSED',
|
|
57
|
+
cause: `a write was sent on memory transaction ${id} after its scope ended (${outcome}): an un-awaited promise, or the loser of a Promise.all, outlived memoryTransactor().run, and it was never part of that unit of work`,
|
|
58
|
+
fix: 'await memoryTransactor().run(async () => { await repo.insert(row); }) # await every write inside the body; one meant to outlive the scope goes after run() resolves',
|
|
59
|
+
meta: { tx: id, outcome },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** Refuses a write on a memory `tx` whose root has ended; any other `tx` passes. */
|
|
63
|
+
export const refuseIfMemoryTxClosed = (tx: Tx | undefined): void => {
|
|
64
|
+
const scope = tx === undefined ? undefined : scopes.get(tx);
|
|
65
|
+
if (tx !== undefined && scope !== undefined && scope.outcome.value !== 'open') {
|
|
66
|
+
throw memoryTxClosed(tx.id, scope.outcome.value);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Single responsibility: the memory driver's half of the lifecycle triggers — a history row per row
|
|
2
|
+
// change, a counter moved per child change — so a unit test answers what Postgres answers. Called
|
|
3
|
+
// by `memoryRepo` at each place a stored row changes, with the row before and after.
|
|
4
|
+
|
|
5
|
+
import { tryUseContext } from '@ultimat3/core';
|
|
6
|
+
import { entityNow } from './clock';
|
|
7
|
+
import { columnName, moneyColumns } from './column';
|
|
8
|
+
import { countersOf, countersTargeting } from './counter';
|
|
9
|
+
import type { EntityCore } from './entity';
|
|
10
|
+
import { SOFT_DELETE_COLUMN } from './entity';
|
|
11
|
+
import type { MemoryTables } from './memory-same-tenant';
|
|
12
|
+
import type { Tx } from './repo';
|
|
13
|
+
|
|
14
|
+
const field = (row: unknown, property: string): unknown =>
|
|
15
|
+
typeof row === 'object' && row !== null ? (row as Record<string, unknown>)[property] : undefined;
|
|
16
|
+
|
|
17
|
+
const jsonValue = (value: unknown): unknown =>
|
|
18
|
+
value instanceof Date ? value.toISOString() : value === undefined ? null : value;
|
|
19
|
+
|
|
20
|
+
/** The stored row as `to_jsonb` spells it: physical names, money as its columns. */
|
|
21
|
+
export function imageOf(entity: EntityCore, row: unknown): Record<string, unknown> {
|
|
22
|
+
const image = new Map<string, unknown>();
|
|
23
|
+
for (const [property, column] of Object.entries(entity.$columns)) {
|
|
24
|
+
const value = field(row, property);
|
|
25
|
+
if (column.$meta.kind !== 'money') {
|
|
26
|
+
image.set(columnName(property, column.$meta), jsonValue(value));
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const parts = moneyColumns(property, column.$meta);
|
|
30
|
+
image.set(parts.minor, jsonValue(field(value, 'minor')));
|
|
31
|
+
image.set(parts.currency, jsonValue(field(value, 'currency')));
|
|
32
|
+
if (parts.scale !== null) image.set(parts.scale, jsonValue(field(value, 'scale')));
|
|
33
|
+
}
|
|
34
|
+
return Object.fromEntries(image);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const live = (entity: EntityCore, row: unknown): boolean =>
|
|
38
|
+
!entity.$softDelete || field(row, SOFT_DELETE_COLUMN) == null;
|
|
39
|
+
|
|
40
|
+
/** What one change does to the tables beside it. `undefined` before is an insert, after a delete. */
|
|
41
|
+
export function memoryLifecycle(
|
|
42
|
+
entity: EntityCore,
|
|
43
|
+
tables: MemoryTables,
|
|
44
|
+
): (before: unknown, after: unknown, tx: Tx | undefined) => void {
|
|
45
|
+
return (before, after, tx) => {
|
|
46
|
+
const undo = (fn: () => void): void => tx?.onRollback(fn);
|
|
47
|
+
for (const counter of countersTargeting(entity.$name)) {
|
|
48
|
+
const parent = tables.get(counter.parent.$name);
|
|
49
|
+
const key = counter.parent.$primaryKey[0] ?? '';
|
|
50
|
+
const move = (row: unknown, delta: number): void => {
|
|
51
|
+
const id = field(row, counter.references);
|
|
52
|
+
if (row === undefined || id == null || !live(entity, row)) return;
|
|
53
|
+
const stored = parent?.find(key, id);
|
|
54
|
+
if (stored === undefined) return;
|
|
55
|
+
const count = Number(field(stored, counter.column) ?? 0) + delta;
|
|
56
|
+
parent?.raw?.replace(stored, { ...(stored as object), [counter.column]: count }, undo);
|
|
57
|
+
};
|
|
58
|
+
move(before, -1);
|
|
59
|
+
move(after, 1);
|
|
60
|
+
}
|
|
61
|
+
const history = entity.$history;
|
|
62
|
+
const target = history === null ? undefined : tables.get(history.$name);
|
|
63
|
+
if (history === null || target?.raw === undefined) return;
|
|
64
|
+
const prior = before === undefined ? null : imageOf(entity, before);
|
|
65
|
+
const next = after === undefined ? null : imageOf(entity, after);
|
|
66
|
+
const counted = new Set(countingColumns(entity));
|
|
67
|
+
if (prior !== null && next !== null && sameIgnoring(prior, next, counted)) return;
|
|
68
|
+
const source = after ?? before;
|
|
69
|
+
const ctx = tx === undefined ? undefined : tryUseContext();
|
|
70
|
+
const tenant = entity.$tenantColumn;
|
|
71
|
+
target.raw.store(
|
|
72
|
+
{
|
|
73
|
+
id: crypto.randomUUID(),
|
|
74
|
+
...(tenant === null ? {} : { [tenant]: field(source, tenant) }),
|
|
75
|
+
rowId: String(field(source, entity.$primaryKey[0] ?? '')),
|
|
76
|
+
op: before === undefined ? 'insert' : after === undefined ? 'delete' : 'update',
|
|
77
|
+
at: entityNow(),
|
|
78
|
+
actorId: ctx?.actor.id ?? null,
|
|
79
|
+
requestId: ctx?.requestId ?? null,
|
|
80
|
+
before: prior,
|
|
81
|
+
after: next,
|
|
82
|
+
},
|
|
83
|
+
undo,
|
|
84
|
+
);
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The parent's counting columns: their change alone records no history, as in the trigger. */
|
|
89
|
+
const countingColumns = (entity: EntityCore): readonly string[] =>
|
|
90
|
+
countersOf(entity).map((counter) => {
|
|
91
|
+
const column = entity.$columns[counter.column];
|
|
92
|
+
return column === undefined ? counter.column : columnName(counter.column, column.$meta);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
function sameIgnoring(
|
|
96
|
+
left: Record<string, unknown>,
|
|
97
|
+
right: Record<string, unknown>,
|
|
98
|
+
ignored: ReadonlySet<string>,
|
|
99
|
+
): boolean {
|
|
100
|
+
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
|
101
|
+
for (const key of keys) {
|
|
102
|
+
if (ignored.has(key)) continue;
|
|
103
|
+
if (JSON.stringify(left[key]) !== JSON.stringify(right[key])) return false;
|
|
104
|
+
}
|
|
105
|
+
return true;
|
|
106
|
+
}
|