@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
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Single responsibility: the memory driver's half of a same-tenant key. A target found under another
|
|
2
|
+
// tenant is refused with the code Postgres answers the composite key with. A target not found stays
|
|
3
|
+
// accepted: this driver enforces no foreign key at all, and starting would break every parent-less
|
|
4
|
+
// fixture. Postgres answers absent and foreign identically, so nothing here can tell them apart.
|
|
5
|
+
|
|
6
|
+
import { driverError, sameTenantKeyName } from '@ultimat3/db';
|
|
7
|
+
import { keyOf } from './batch-read';
|
|
8
|
+
import { kindOf, valueAt } from './cursor';
|
|
9
|
+
import { describeReferences } from './describe';
|
|
10
|
+
import type { EntityCore } from './entity';
|
|
11
|
+
import type { ReferenceDescription } from './registry';
|
|
12
|
+
|
|
13
|
+
/** One repository's rows as a neighbour may look at them: unscoped, because a guard is reading. */
|
|
14
|
+
export interface MemoryTable {
|
|
15
|
+
readonly entity: EntityCore;
|
|
16
|
+
/** The stored row whose `property` holds `value`, or `undefined`. */
|
|
17
|
+
find(property: string, value: unknown): unknown;
|
|
18
|
+
/**
|
|
19
|
+
* A trigger's write, below every seam as the statement inside a Postgres trigger is: store `row`
|
|
20
|
+
* as given, or replace the stored `before` with `after`. `memory-lifecycle.ts` is the one caller.
|
|
21
|
+
*/
|
|
22
|
+
readonly raw?: {
|
|
23
|
+
store(row: unknown, undo: (fn: () => void) => void): void;
|
|
24
|
+
replace(before: unknown, after: unknown, undo: (fn: () => void) => void): void;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One per driver, keyed by entity name: what a reference names (`Binding.table`). */
|
|
29
|
+
export type MemoryTables = Map<string, MemoryTable>;
|
|
30
|
+
|
|
31
|
+
/** Spelled the way Postgres would, so a uuid written in capitals is still the same row. */
|
|
32
|
+
const sameCell = (kind: string | undefined, left: unknown, right: unknown): boolean =>
|
|
33
|
+
keyOf(kind ?? '', left) === keyOf(kind ?? '', right);
|
|
34
|
+
|
|
35
|
+
const inBatch = (
|
|
36
|
+
batch: readonly unknown[],
|
|
37
|
+
owner: EntityCore,
|
|
38
|
+
property: string,
|
|
39
|
+
value: unknown,
|
|
40
|
+
): unknown => batch.find((row) => sameCell(kindOf(owner, property), valueAt(row, property), value));
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The judge for one entity. The references are read on the first write and kept: a thunk cannot be
|
|
44
|
+
* answered at declaration, and a write is the one moment both schema modules have evaluated.
|
|
45
|
+
*/
|
|
46
|
+
export const sameTenantJudge = <Row>(
|
|
47
|
+
entity: EntityCore<Row>,
|
|
48
|
+
tables: MemoryTables,
|
|
49
|
+
): ((row: Row, batch?: readonly Row[]) => void) => {
|
|
50
|
+
let edges: readonly ReferenceDescription[] | undefined;
|
|
51
|
+
const own = entity as EntityCore;
|
|
52
|
+
return (row, batch = []) => {
|
|
53
|
+
const tenant = entity.$tenantColumn;
|
|
54
|
+
if (tenant === null) return;
|
|
55
|
+
edges ??= describeReferences(entity.$name, Object.entries(entity.$columns)).filter(
|
|
56
|
+
(edge) => edge.sameTenant !== null,
|
|
57
|
+
);
|
|
58
|
+
const rowTenant = valueAt(row, tenant);
|
|
59
|
+
if (rowTenant === null || rowTenant === undefined) return;
|
|
60
|
+
for (const edge of edges) {
|
|
61
|
+
const value = valueAt(row, edge.property);
|
|
62
|
+
if (value === null || value === undefined) continue;
|
|
63
|
+
const table = tables.get(edge.targetEntity);
|
|
64
|
+
if (table === undefined) continue;
|
|
65
|
+
const target = table.entity;
|
|
66
|
+
const targetTenant = target.$tenantColumn;
|
|
67
|
+
if (targetTenant === null) continue;
|
|
68
|
+
const found =
|
|
69
|
+
table.find(edge.targetProperty, value) ??
|
|
70
|
+
(target.$name === entity.$name
|
|
71
|
+
? inBatch(batch, own, edge.targetProperty, value)
|
|
72
|
+
: undefined);
|
|
73
|
+
if (found === undefined) continue;
|
|
74
|
+
if (sameCell(kindOf(target, targetTenant), valueAt(found, targetTenant), rowTenant)) continue;
|
|
75
|
+
const constraint = sameTenantKeyName(entity.$table, edge.column);
|
|
76
|
+
throw driverError(`memory write into ${entity.$table}`, {
|
|
77
|
+
code: '23503',
|
|
78
|
+
constraint,
|
|
79
|
+
message: `insert or update on table "${entity.$table}" violates foreign key constraint "${constraint}"`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Single responsibility: where a cursor's next page starts among the memory driver's sorted rows.
|
|
2
|
+
// Split from `memory-repo.ts` for its line ceiling.
|
|
3
|
+
|
|
4
|
+
import { kindOf, seekFrom, valueAt } from './cursor';
|
|
5
|
+
import type { EntityCore } from './entity';
|
|
6
|
+
import { compareByKind } from './memory-match';
|
|
7
|
+
import type { QueryPlan } from './tenancy';
|
|
8
|
+
|
|
9
|
+
/** Lexicographic over the sort keys, direction applied. `> 0` means "after the cursor". */
|
|
10
|
+
const compareToSeek = <Row>(
|
|
11
|
+
entity: EntityCore<Row>,
|
|
12
|
+
plan: QueryPlan,
|
|
13
|
+
row: unknown,
|
|
14
|
+
seek: readonly unknown[],
|
|
15
|
+
): number => {
|
|
16
|
+
for (const [index, entry] of plan.orderBy.entries()) {
|
|
17
|
+
// The COLUMN's kind, not the value's: the seek was revived from the same kind (`cursor.ts`),
|
|
18
|
+
// so a `bigint` column compares its stored decimal string against a revived `BigInt` as one
|
|
19
|
+
// number instead of as two pieces of text.
|
|
20
|
+
const order = compareByKind(
|
|
21
|
+
kindOf(entity, entry.column),
|
|
22
|
+
valueAt(row, entry.column),
|
|
23
|
+
seek[index],
|
|
24
|
+
);
|
|
25
|
+
if (order !== 0) return entry.direction === 'desc' ? -order : order;
|
|
26
|
+
}
|
|
27
|
+
return 0;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Where the next page starts. By sort position, not by the previous row's id: that row may have
|
|
32
|
+
* been deleted between the two requests, and an id that is no longer there would restart
|
|
33
|
+
* pagination at the top instead of continuing it.
|
|
34
|
+
*/
|
|
35
|
+
export const afterCursor = <Row>(
|
|
36
|
+
entity: EntityCore<Row>,
|
|
37
|
+
plan: QueryPlan,
|
|
38
|
+
found: readonly Row[],
|
|
39
|
+
): number => {
|
|
40
|
+
const seek = seekFrom(entity, plan);
|
|
41
|
+
if (seek === undefined) return 0;
|
|
42
|
+
const start = found.findIndex((row) => compareToSeek(entity, plan, row, seek) > 0);
|
|
43
|
+
return start === -1 ? found.length : start;
|
|
44
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Single responsibility: the in-memory `Transactor`. Undo closures a driver registered run on a
|
|
2
|
+
// throw, commit effects after the ROOT succeeded, and a write that outlives the scope is refused as
|
|
3
|
+
// `@ultimat3/db` refuses a statement on a finished transaction. Beside `memory-repo.ts` rather than
|
|
4
|
+
// in it, because the lock needs a transaction and the repository needs the lock.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
enrolMemoryTx,
|
|
8
|
+
inMemoryJournal,
|
|
9
|
+
type MemoryOutcome,
|
|
10
|
+
memoryJournal,
|
|
11
|
+
memoryOutcomeOf,
|
|
12
|
+
memoryRoot,
|
|
13
|
+
memoryTxClosed,
|
|
14
|
+
outsideMemoryJournal,
|
|
15
|
+
refuseIfMemoryTxClosed,
|
|
16
|
+
} from './memory-journal';
|
|
17
|
+
import type { Transactor, Tx } from './repo';
|
|
18
|
+
|
|
19
|
+
let txCounter = 0;
|
|
20
|
+
|
|
21
|
+
/** Best-effort and off the journal, as `@ultimat3/db` runs them: the work is already durable. */
|
|
22
|
+
const runEffects = (effects: readonly (() => void)[]): void =>
|
|
23
|
+
outsideMemoryJournal(() => {
|
|
24
|
+
for (const effect of effects) {
|
|
25
|
+
try {
|
|
26
|
+
effect();
|
|
27
|
+
} catch {
|
|
28
|
+
// an effect is a report about a durable write; it may not fail the write
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* In-memory transactor: undo closures registered by drivers run on failure. Ambient, as
|
|
35
|
+
* `withTransaction` is: a write inside `work` that names no `tx` joins this one. Nested, a commit is
|
|
36
|
+
* a savepoint's release — its undos and its effects move to the enclosing transaction, so an effect
|
|
37
|
+
* runs only once the root commits and never after the root rolls back.
|
|
38
|
+
*
|
|
39
|
+
* Ended, the scope still rides every promise `work` forgot to await. A write there is refused
|
|
40
|
+
* (`X_DB_TRANSACTION_CLOSED`) once the root has ended; on a nested handle while the root is open it
|
|
41
|
+
* is the enclosing transaction's, as a statement on a released savepoint's handle is in Postgres.
|
|
42
|
+
* An effect registered after the root ended runs at once after a COMMIT and never after a ROLLBACK.
|
|
43
|
+
*/
|
|
44
|
+
export const memoryTransactor = (): Transactor => ({
|
|
45
|
+
async run(work) {
|
|
46
|
+
const outer = memoryJournal();
|
|
47
|
+
refuseIfMemoryTxClosed(outer);
|
|
48
|
+
const outcome: MemoryOutcome = (outer && memoryOutcomeOf(outer)) ?? { value: 'open' };
|
|
49
|
+
const undos: (() => void)[] = [];
|
|
50
|
+
const commits: (() => void)[] = [];
|
|
51
|
+
let ended = false;
|
|
52
|
+
txCounter += 1;
|
|
53
|
+
const id = `tx-${txCounter}`;
|
|
54
|
+
const tx: Tx = {
|
|
55
|
+
id,
|
|
56
|
+
onRollback: (undo) => {
|
|
57
|
+
if (outcome.value !== 'open') throw memoryTxClosed(id, outcome.value);
|
|
58
|
+
if (!ended) undos.push(undo);
|
|
59
|
+
else outer?.onRollback(undo);
|
|
60
|
+
},
|
|
61
|
+
onCommit: (effect) => {
|
|
62
|
+
if (outcome.value === 'committed') runEffects([effect]);
|
|
63
|
+
else if (outcome.value === 'rolled-back') return;
|
|
64
|
+
else if (!ended) commits.push(effect);
|
|
65
|
+
else outer?.onCommit?.(effect);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
enrolMemoryTx(tx, outer === undefined ? tx : memoryRoot(outer), outcome);
|
|
69
|
+
let result: Awaited<ReturnType<typeof work>>;
|
|
70
|
+
try {
|
|
71
|
+
result = await inMemoryJournal(tx, () => work(tx));
|
|
72
|
+
} catch (error) {
|
|
73
|
+
ended = true;
|
|
74
|
+
if (outer === undefined) outcome.value = 'rolled-back';
|
|
75
|
+
for (const undo of undos.reverse()) undo();
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
ended = true;
|
|
79
|
+
if (outer === undefined) {
|
|
80
|
+
outcome.value = 'committed';
|
|
81
|
+
runEffects(commits);
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
// The root ended while this savepoint ran: its RELEASE is refused, and its rows went where the
|
|
85
|
+
// root went — kept by a commit, undone here after a rollback, whose undos never saw them.
|
|
86
|
+
if (outcome.value !== 'open') {
|
|
87
|
+
if (outcome.value === 'rolled-back') for (const undo of undos.reverse()) undo();
|
|
88
|
+
throw memoryTxClosed(id, outcome.value);
|
|
89
|
+
}
|
|
90
|
+
for (const undo of undos) outer.onRollback(undo);
|
|
91
|
+
for (const effect of commits) outer.onCommit?.(effect);
|
|
92
|
+
return result;
|
|
93
|
+
},
|
|
94
|
+
});
|
package/src/memory-unique.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// a quiet overwrite under `x dev` and in every app test.
|
|
5
5
|
|
|
6
6
|
import { driverError } from '@ultimat3/db';
|
|
7
|
+
import { keyOf } from './batch-read';
|
|
8
|
+
import { columnName } from './column';
|
|
7
9
|
import type { EntityCore } from './entity';
|
|
8
10
|
import { bindValues } from './pg-row';
|
|
9
11
|
import type { RowPatch } from './types';
|
|
@@ -17,14 +19,26 @@ export const uniqueViolation = (entity: EntityCore, constraint: string): Error =
|
|
|
17
19
|
message: `duplicate key value violates unique constraint "${constraint}"`,
|
|
18
20
|
});
|
|
19
21
|
|
|
22
|
+
/** A number's kinds: `'2.5'` and `'2.50'` are one key to Postgres, and so are `7` and `'7'`. */
|
|
23
|
+
const BY_VALUE: ReadonlySet<string> = new Set(['integer', 'bigint', 'numeric']);
|
|
24
|
+
|
|
20
25
|
/** A value as a comparable token; `null` answers `undefined` — NULLS DISTINCT, as Postgres. */
|
|
21
|
-
const cellOf = (value: unknown): string | undefined => {
|
|
26
|
+
const cellOf = (kind: string | undefined, value: unknown): string | undefined => {
|
|
22
27
|
if (value === null || value === undefined) return undefined;
|
|
23
28
|
if (value instanceof Date) return `date:${value.getTime()}`;
|
|
24
|
-
if (
|
|
29
|
+
if (kind !== undefined && BY_VALUE.has(kind)) return `n:${keyOf(kind, value)}`;
|
|
30
|
+
if (typeof value === 'string') return `s:${kind === undefined ? value : keyOf(kind, value)}`;
|
|
25
31
|
return `${typeof value}:${JSON.stringify(value)}`;
|
|
26
32
|
};
|
|
27
33
|
|
|
34
|
+
/** Physical column -> kind. A money part is absent: its cells compare as the values they hold. */
|
|
35
|
+
const kindsOf = <Row>(entity: EntityCore<Row>): ReadonlyMap<string, string> =>
|
|
36
|
+
new Map(
|
|
37
|
+
Object.entries(entity.$columns)
|
|
38
|
+
.filter(([, column]) => column.$meta.kind !== 'money')
|
|
39
|
+
.map(([property, column]) => [columnName(property, column.$meta), column.$meta.kind]),
|
|
40
|
+
);
|
|
41
|
+
|
|
28
42
|
/**
|
|
29
43
|
* The first unique constraint `candidate` collides with among `others`, or `undefined`. Partial
|
|
30
44
|
* ones are skipped: their predicate is SQL this driver cannot evaluate, and a guess would refuse
|
|
@@ -39,12 +53,13 @@ export const uniqueClash = <Row>(
|
|
|
39
53
|
if (unique.length === 0) return undefined;
|
|
40
54
|
const bound = (row: Row) => bindValues(entity, row as unknown as RowPatch<Row>);
|
|
41
55
|
const incoming = bound(candidate);
|
|
56
|
+
const kinds = kindsOf(entity);
|
|
42
57
|
for (const index of unique) {
|
|
43
|
-
const key = index.columns.map((name) => cellOf(incoming.get(name)));
|
|
58
|
+
const key = index.columns.map((name) => cellOf(kinds.get(name), incoming.get(name)));
|
|
44
59
|
if (key.some((part) => part === undefined)) continue;
|
|
45
60
|
for (const other of others) {
|
|
46
61
|
const stored = bound(other);
|
|
47
|
-
if (index.columns.every((name, at) => cellOf(stored.get(name)) === key[at]))
|
|
62
|
+
if (index.columns.every((name, at) => cellOf(kinds.get(name), stored.get(name)) === key[at]))
|
|
48
63
|
return index.name();
|
|
49
64
|
}
|
|
50
65
|
}
|
package/src/n-plus-one.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// Nothing here counts anything. A ledger elsewhere decides that a shape repeated; this turns that
|
|
6
6
|
// verdict into the one error the surfaces render.
|
|
7
7
|
|
|
8
|
-
import
|
|
8
|
+
import { EntityError } from './entity-error';
|
|
9
|
+
import type { PreloadCandidate } from './errors';
|
|
9
10
|
import { nPlusOneQuery, nPlusOneWrite } from './errors';
|
|
10
11
|
import type { RelationKind } from './relations';
|
|
11
12
|
import { relationMap } from './relations';
|
|
@@ -105,18 +106,35 @@ const readLoop = (loop: StatementLoop): EntityError => {
|
|
|
105
106
|
return nPlusOneQuery(loop.subject, loop.count, { form: 'preload', candidates: [first, ...rest] });
|
|
106
107
|
};
|
|
107
108
|
|
|
109
|
+
/**
|
|
110
|
+
* A write into the job queue's own tables — `@ultimat3/jobs`' outbox stage or its enqueue. The loop
|
|
111
|
+
* is a loop of `handle.enqueue`, the statement is the queue's and not the app's, and `insertAll` is a
|
|
112
|
+
* call no enqueuer can make: the batch is the handle's. Read off the statement text, the subject a
|
|
113
|
+
* hand-written statement carries; a word boundary, so `x_jobs_archive` is still the app's SQL.
|
|
114
|
+
*/
|
|
115
|
+
const QUEUE_INSERT = /\binsert\s+into\s+x_(?:outbox|jobs)\b(?!_)/i;
|
|
116
|
+
|
|
117
|
+
const writeLoop = (loop: StatementLoop): EntityError => {
|
|
118
|
+
if (loop.entity === undefined && QUEUE_INSERT.test(loop.subject)) {
|
|
119
|
+
return new EntityError({
|
|
120
|
+
code: 'X_N_PLUS_ONE_WRITE',
|
|
121
|
+
cause: nPlusOneWrite(loop.subject, loop.count, { form: 'sql' }).cause,
|
|
122
|
+
fix: "handle.enqueueMany(inputs) # one statement per 1,000 jobs, staged in the same transaction — or expectedQueryLoop('<why one enqueue per row is optimal>', fn) when the loop is deliberate",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return nPlusOneWrite(
|
|
126
|
+
loop.subject,
|
|
127
|
+
loop.count,
|
|
128
|
+
loop.entity === undefined
|
|
129
|
+
? { form: 'sql' }
|
|
130
|
+
: { form: 'bulk', entity: loop.entity, op: loop.op },
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
108
134
|
/**
|
|
109
135
|
* One repeated shape, as the error every surface renders. One entry point and not two, because the
|
|
110
136
|
* caller already knows which of the two codes it holds and a second decision here is a second place
|
|
111
137
|
* for the read and the write halves to disagree about what a loop is.
|
|
112
138
|
*/
|
|
113
139
|
export const nPlusOne = (loop: StatementLoop): EntityError =>
|
|
114
|
-
loop.kind === 'read'
|
|
115
|
-
? readLoop(loop)
|
|
116
|
-
: nPlusOneWrite(
|
|
117
|
-
loop.subject,
|
|
118
|
-
loop.count,
|
|
119
|
-
loop.entity === undefined
|
|
120
|
-
? { form: 'sql' }
|
|
121
|
-
: { form: 'bulk', entity: loop.entity, op: loop.op },
|
|
122
|
-
);
|
|
140
|
+
loop.kind === 'read' ? readLoop(loop) : writeLoop(loop);
|
package/src/numeric-compare.ts
CHANGED
|
@@ -55,3 +55,46 @@ export const numericOrder = (
|
|
|
55
55
|
if (!isNumber(value) || !isNumber(operand)) return undefined;
|
|
56
56
|
return value < operand ? -1 : value > operand ? 1 : 0;
|
|
57
57
|
};
|
|
58
|
+
|
|
59
|
+
const INTEGER_TEXT = /^[+-]?\d+$/;
|
|
60
|
+
const DECIMAL_PARTS = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/;
|
|
61
|
+
|
|
62
|
+
/** Past this an exponent would have the padding below build a string nobody asked for. */
|
|
63
|
+
const MAX_EXPONENT = 1000;
|
|
64
|
+
|
|
65
|
+
const textOf = (value: unknown): string | undefined => {
|
|
66
|
+
if (typeof value === 'string') return value.trim();
|
|
67
|
+
return isNumber(value) ? plainDecimal(value) : undefined;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One spelling for the value an integer column compares by: `'007'`, `'+7'`, `7` and `7n` are one
|
|
72
|
+
* key to Postgres. `undefined` for what such a column cannot hold (`'7.0'`, `''`, which `BigInt`
|
|
73
|
+
* alone would read as `0`), so a caller keeps it as written and the server answers its own refusal.
|
|
74
|
+
*/
|
|
75
|
+
export const canonicalInteger = (value: unknown): string | undefined => {
|
|
76
|
+
const text = textOf(value);
|
|
77
|
+
return text !== undefined && INTEGER_TEXT.test(text) ? BigInt(text).toString() : undefined;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One spelling for the value a `numeric` compares by: `'2.5'`, `'2.50'` and `'+02.500'` are one
|
|
82
|
+
* value and one unique key, and a `numeric` has one zero. Answers what `numericOrder` calls equal,
|
|
83
|
+
* plus the literals Postgres also reads (`'.5'`, `'5.'`, `'1e3'`). `undefined` when it is no such
|
|
84
|
+
* literal, for the same reason as above.
|
|
85
|
+
*/
|
|
86
|
+
export const canonicalDecimal = (value: unknown): string | undefined => {
|
|
87
|
+
const parts = DECIMAL_PARTS.exec(textOf(value) ?? 'not a decimal');
|
|
88
|
+
if (parts === null) return undefined;
|
|
89
|
+
const [, sign = '', whole = '', fraction = '', exponent = '0'] = parts;
|
|
90
|
+
const shift = Number(exponent);
|
|
91
|
+
if (whole.length + fraction.length === 0 || Math.abs(shift) > MAX_EXPONENT) return undefined;
|
|
92
|
+
const digits = whole + fraction;
|
|
93
|
+
const point = whole.length + shift;
|
|
94
|
+
const integer = point <= 0 ? '' : digits.slice(0, point).padEnd(point, '0');
|
|
95
|
+
const rest = point <= 0 ? '0'.repeat(-point) + digits : digits.slice(point);
|
|
96
|
+
const before = integer.replace(/^0+/, '') || '0';
|
|
97
|
+
const after = rest.replace(/0+$/, '');
|
|
98
|
+
const unsigned = after === '' ? before : `${before}.${after}`;
|
|
99
|
+
return sign === '-' && unsigned !== '0' ? `-${unsigned}` : unsigned;
|
|
100
|
+
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Single responsibility: the Postgres driver's aggregate reads — `countBy`, `aggregate`,
|
|
2
|
+
// `approximateCount` and `series` — each one statement (money: two) under the read path's plan.
|
|
3
|
+
// Split from `pg-driver.ts` at its line ceiling; the rules are the shared files' (`aggregate.ts`,
|
|
4
|
+
// `count-by.ts`, `time-bucket.ts`), so memory and Postgres answer every one of these alike.
|
|
5
|
+
|
|
6
|
+
import { type DbClient, withStatementAttribution } from '@ultimat3/db';
|
|
7
|
+
import { aggregateColumnOf, aggregateMinor, assertOneUnit, type MoneyUnit } from './aggregate';
|
|
8
|
+
import { decodeAggregate } from './aggregate-decode';
|
|
9
|
+
import { moneyColumns } from './column';
|
|
10
|
+
import { countsFrom, groupColumnOf, groupValue, MAX_GROUPS } from './count-by';
|
|
11
|
+
import type { EntityCore } from './entity';
|
|
12
|
+
import type { ReadShape } from './pg-sql';
|
|
13
|
+
import {
|
|
14
|
+
type AggregateRow,
|
|
15
|
+
aggregateStatement,
|
|
16
|
+
countByStatement,
|
|
17
|
+
currenciesStatement,
|
|
18
|
+
estimateStatement,
|
|
19
|
+
type GroupRow,
|
|
20
|
+
type MoneyUnitRow,
|
|
21
|
+
type SeriesRow,
|
|
22
|
+
seriesStatement,
|
|
23
|
+
} from './pg-sql-aggregate';
|
|
24
|
+
import { readPlan } from './plan';
|
|
25
|
+
import type { FindManyArgs, Repo } from './repo';
|
|
26
|
+
import type { QueryPlan } from './tenancy';
|
|
27
|
+
import { type SeriesPlan, seriesFrom, seriesPlanOf } from './time-bucket';
|
|
28
|
+
import type { MoneyValue } from './types';
|
|
29
|
+
|
|
30
|
+
type AggregateReads<Row> = Pick<
|
|
31
|
+
Required<Repo<Row>>,
|
|
32
|
+
'aggregate' | 'approximateCount' | 'countBy' | 'series'
|
|
33
|
+
>;
|
|
34
|
+
|
|
35
|
+
const shapeOf = (args: FindManyArgs): ReadShape => ({
|
|
36
|
+
includeDeleted: args.includeDeleted === true,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const unitOf = (row: MoneyUnitRow): MoneyUnit => ({
|
|
40
|
+
currency: String(row.group_value ?? '').trim(),
|
|
41
|
+
scale: row.group_scale === null || row.group_scale === undefined ? null : Number(row.group_scale),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const postgresAggregates = <Row>(
|
|
45
|
+
entity: EntityCore<Row>,
|
|
46
|
+
client: () => DbClient,
|
|
47
|
+
): AggregateReads<Row> => {
|
|
48
|
+
const attributed = <T>(op: string, send: () => Promise<T>): Promise<T> =>
|
|
49
|
+
withStatementAttribution(entity.$name, op, send);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Money is judged BEFORE the aggregate is asked for, in its own statement: `sum(minor)` over two
|
|
53
|
+
* currencies is a number in neither, and the refusal has to name them.
|
|
54
|
+
*/
|
|
55
|
+
const moneyUnit = async (
|
|
56
|
+
op: string,
|
|
57
|
+
fn: 'sum' | 'avg' | 'min' | 'max',
|
|
58
|
+
column: string,
|
|
59
|
+
money: ReturnType<typeof moneyColumns>,
|
|
60
|
+
plan: QueryPlan,
|
|
61
|
+
shape: ReadShape,
|
|
62
|
+
): Promise<MoneyUnit | undefined> =>
|
|
63
|
+
assertOneUnit(
|
|
64
|
+
entity,
|
|
65
|
+
fn,
|
|
66
|
+
column,
|
|
67
|
+
(
|
|
68
|
+
await attributed(op, () =>
|
|
69
|
+
client().query<MoneyUnitRow>(
|
|
70
|
+
currenciesStatement(entity, plan, shape, money.currency, money.scale),
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
).map(unitOf),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const moneyOf = (column: string, unit: MoneyUnit, text: string): MoneyValue => ({
|
|
77
|
+
minor: aggregateMinor(entity, 'sum', column, text),
|
|
78
|
+
currency: unit.currency,
|
|
79
|
+
...(unit.scale === null ? {} : { scale: unit.scale }),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** One bucket's value: the count, or the sum decoded as `sum()` decodes it. */
|
|
83
|
+
const pointValue = (plan: SeriesPlan, row: SeriesRow, unit: MoneyUnit | undefined): unknown => {
|
|
84
|
+
if (plan.sum === null) return Number(row.bucket_count);
|
|
85
|
+
const text = row.bucket_value;
|
|
86
|
+
if (text === null || text === undefined) return null;
|
|
87
|
+
const kind = plan.sum.column.$meta.kind;
|
|
88
|
+
if (kind !== 'money') return decodeAggregate('sum', kind, String(text));
|
|
89
|
+
return unit === undefined ? null : moneyOf(plan.sum.property, unit, String(text));
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
async countBy(column, args = {}) {
|
|
94
|
+
const op = 'countBy';
|
|
95
|
+
const grouped = groupColumnOf(entity, column, op);
|
|
96
|
+
const plan = readPlan(entity, args, op);
|
|
97
|
+
// One group past the bound, for the same reason a page reads one row past its limit: the
|
|
98
|
+
// presence of that group is what says the answer was never going to fit, and `countsFrom`
|
|
99
|
+
// refuses it rather than hand back a breakdown missing its tail.
|
|
100
|
+
const rows = await attributed(op, () =>
|
|
101
|
+
client().query<GroupRow>(
|
|
102
|
+
countByStatement(entity, plan, shapeOf(args), column, MAX_GROUPS + 1),
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
return countsFrom(
|
|
106
|
+
entity,
|
|
107
|
+
column,
|
|
108
|
+
op,
|
|
109
|
+
rows.map((row) => [groupValue(grouped, row.group_value), Number(row.group_count)] as const),
|
|
110
|
+
);
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async aggregate(fn, column, args = {}) {
|
|
114
|
+
const op = fn;
|
|
115
|
+
const declared = aggregateColumnOf(entity, fn, column);
|
|
116
|
+
const plan = readPlan(entity, args, op);
|
|
117
|
+
const shape = shapeOf(args);
|
|
118
|
+
const money = declared.$meta.kind === 'money' ? moneyColumns(column, declared.$meta) : null;
|
|
119
|
+
const unit = money === null ? undefined : await moneyUnit(op, fn, column, money, plan, shape);
|
|
120
|
+
const row = await attributed(op, () =>
|
|
121
|
+
client().one<AggregateRow>(
|
|
122
|
+
aggregateStatement(entity, plan, shape, fn, money === null ? column : `${column}.minor`),
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
const text = row?.agg_value;
|
|
126
|
+
if (text === null || text === undefined) return null;
|
|
127
|
+
if (money === null) return decodeAggregate(fn, declared.$meta.kind, String(text));
|
|
128
|
+
if (unit === undefined) return null;
|
|
129
|
+
return {
|
|
130
|
+
minor: aggregateMinor(entity, fn, column, String(text)),
|
|
131
|
+
currency: unit.currency,
|
|
132
|
+
...(unit.scale === null ? {} : { scale: unit.scale }),
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `reltuples`, the planner's own estimate — one row out of `pg_class`, constant time. `count(*)`
|
|
138
|
+
* walks every visible row, so past a few million it trips a web role's `statement_timeout`.
|
|
139
|
+
*/
|
|
140
|
+
async approximateCount(args = {}) {
|
|
141
|
+
const op = 'approximateCount';
|
|
142
|
+
// Built and therefore GUARDED: tenancy still applies, and a filtered chain is refused here
|
|
143
|
+
// rather than answered with the whole table's estimate.
|
|
144
|
+
readPlan(entity, args, op);
|
|
145
|
+
const row = await attributed(op, () =>
|
|
146
|
+
client().one<{ estimate: unknown }>(estimateStatement(entity.$table)),
|
|
147
|
+
);
|
|
148
|
+
const estimate = Number(row?.estimate ?? -1);
|
|
149
|
+
// `-1` is what Postgres 14+ stores for a table nobody has analysed. That is an absence of an
|
|
150
|
+
// estimate, not an estimate of zero, and answering `0` would read exactly like an empty
|
|
151
|
+
// table to every caller.
|
|
152
|
+
return Number.isFinite(estimate) && estimate >= 0 ? estimate : null;
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
async series(spec, args = {}) {
|
|
156
|
+
const op = 'series';
|
|
157
|
+
const series = seriesPlanOf(entity, spec);
|
|
158
|
+
const plan = readPlan(entity, args, op);
|
|
159
|
+
const shape = shapeOf(args);
|
|
160
|
+
const sum = series.sum;
|
|
161
|
+
const money =
|
|
162
|
+
sum?.column.$meta.kind === 'money' ? moneyColumns(sum.property, sum.column.$meta) : null;
|
|
163
|
+
const unit =
|
|
164
|
+
money === null || sum === null
|
|
165
|
+
? undefined
|
|
166
|
+
: await moneyUnit(op, 'sum', sum.property, money, plan, shape);
|
|
167
|
+
const target = sum === null ? null : money === null ? sum.property : `${sum.property}.minor`;
|
|
168
|
+
// One bucket past the bound, as `countBy` reads one group past it: its presence is the
|
|
169
|
+
// refusal, never a chart missing its tail.
|
|
170
|
+
const rows = await attributed(op, () =>
|
|
171
|
+
client().query<SeriesRow>(
|
|
172
|
+
seriesStatement(entity, plan, shape, series, target, MAX_GROUPS + 1),
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
return seriesFrom(
|
|
176
|
+
entity,
|
|
177
|
+
series,
|
|
178
|
+
rows.map((row) => ({
|
|
179
|
+
bucket: new Date(Number(row.bucket_ms)),
|
|
180
|
+
value: pointValue(series, row, unit),
|
|
181
|
+
})),
|
|
182
|
+
);
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
};
|