@ultimat3/entity 11.2.0 → 12.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 +190 -20
- package/README.md +120 -1
- package/package.json +5 -5
- package/src/aggregate-decode.ts +35 -0
- package/src/aggregate-fold.ts +91 -0
- package/src/aggregate.ts +232 -0
- package/src/batch.ts +2 -1
- package/src/column.ts +15 -1
- package/src/containment.ts +94 -0
- package/src/cursor.ts +100 -14
- package/src/database.ts +1 -1
- package/src/describe.ts +3 -0
- package/src/entity.ts +153 -7
- package/src/errors.ts +6 -0
- package/src/index.ts +3 -1
- package/src/instant.ts +93 -0
- package/src/memory-match.ts +67 -0
- package/src/memory-repo.ts +357 -0
- package/src/pg-driver.ts +94 -7
- package/src/pg-row.ts +38 -1
- package/src/pg-sql.ts +279 -133
- package/src/pg-write-sql.ts +129 -0
- package/src/plan.ts +71 -10
- package/src/query.ts +59 -3
- package/src/registry.ts +7 -0
- package/src/repo.ts +16 -328
- package/src/tenancy.ts +18 -2
- package/src/types.ts +10 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// Single responsibility: the in-memory driver. Same `Repo` contract as `postgresRepo`, the same
|
|
2
|
+
// plans, the same cursor — the only difference is where the rows are, which is the point: a test
|
|
3
|
+
// that passes here means something about Postgres.
|
|
4
|
+
//
|
|
5
|
+
// Split from `repo.ts` when that file passed the 500-line ceiling. What stays there is the
|
|
6
|
+
// CONTRACT — `Repo`, `Page`, `FindManyArgs`, `Transactor` — which `postgresRepo` implements too
|
|
7
|
+
// and which nothing about storing rows in a `Map` belongs in.
|
|
8
|
+
|
|
9
|
+
import { aggregateColumnOf } from './aggregate';
|
|
10
|
+
import { foldAggregate } from './aggregate-fold';
|
|
11
|
+
import { keyOf } from './batch-read';
|
|
12
|
+
import { conflictKeyOf, conflictKeys, upsertPlan } from './bulk-write';
|
|
13
|
+
import { entityNow } from './clock';
|
|
14
|
+
import { narrowMoney } from './columns';
|
|
15
|
+
import { countsFrom, groupColumnOf } from './count-by';
|
|
16
|
+
import { cursorFor, kindOf, seekFrom, valueAt } from './cursor';
|
|
17
|
+
import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
18
|
+
import { notFound } from './errors';
|
|
19
|
+
import { compareByKind, matchesPredicate } from './memory-match';
|
|
20
|
+
import { deletePlan, idPlan, readPlan, singleKeyOf, updatePlan } from './plan';
|
|
21
|
+
import type { FindManyArgs, MemoryRepo, RepoOptions, Transactor, Tx } from './repo';
|
|
22
|
+
import type { QueryPlan } from './tenancy';
|
|
23
|
+
import { assertRowTenant } from './tenancy';
|
|
24
|
+
|
|
25
|
+
const field = (row: unknown, property: string): unknown =>
|
|
26
|
+
typeof row === 'object' && row !== null ? (row as Record<string, unknown>)[property] : undefined;
|
|
27
|
+
|
|
28
|
+
/** Lexicographic over the sort keys, direction applied. `> 0` means "after the cursor". */
|
|
29
|
+
const compareToSeek = <Row>(
|
|
30
|
+
entity: EntityCore<Row>,
|
|
31
|
+
plan: QueryPlan,
|
|
32
|
+
row: unknown,
|
|
33
|
+
seek: readonly unknown[],
|
|
34
|
+
): number => {
|
|
35
|
+
for (const [index, entry] of plan.orderBy.entries()) {
|
|
36
|
+
// The COLUMN's kind, not the value's: the seek was revived from the same kind (`cursor.ts`),
|
|
37
|
+
// so a `bigint` column compares its stored decimal string against a revived `BigInt` as one
|
|
38
|
+
// number instead of as two pieces of text.
|
|
39
|
+
const order = compareByKind(
|
|
40
|
+
kindOf(entity, entry.column),
|
|
41
|
+
valueAt(row, entry.column),
|
|
42
|
+
seek[index],
|
|
43
|
+
);
|
|
44
|
+
if (order !== 0) return entry.direction === 'desc' ? -order : order;
|
|
45
|
+
}
|
|
46
|
+
return 0;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Where the next page starts. By sort position, not by the previous row's id: that row may have
|
|
51
|
+
* been deleted between the two requests, and an id that is no longer there would restart
|
|
52
|
+
* pagination at the top instead of continuing it.
|
|
53
|
+
*/
|
|
54
|
+
const afterCursor = <Row>(
|
|
55
|
+
entity: EntityCore<Row>,
|
|
56
|
+
plan: QueryPlan,
|
|
57
|
+
found: readonly Row[],
|
|
58
|
+
): number => {
|
|
59
|
+
const seek = seekFrom(entity, plan);
|
|
60
|
+
if (seek === undefined) return 0;
|
|
61
|
+
const start = found.findIndex((row) => compareToSeek(entity, plan, row, seek) > 0);
|
|
62
|
+
return start === -1 ? found.length : start;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The default driver: correct semantics, no database. `x dev` uses it before the first
|
|
67
|
+
* migration and tests use it everywhere. Postgres is the production driver and implements
|
|
68
|
+
* this same interface.
|
|
69
|
+
*/
|
|
70
|
+
export const memoryRepo = <Row>(
|
|
71
|
+
entity: EntityCore<Row>,
|
|
72
|
+
seed: readonly Row[] = [],
|
|
73
|
+
): MemoryRepo<Row> => {
|
|
74
|
+
/**
|
|
75
|
+
* A stored row's key, spelled the way `batch-read.ts` spells an id — because Postgres compares a
|
|
76
|
+
* `uuid` as a VALUE and prints it lower-cased, so `findById(UPPER)` reads the row there while
|
|
77
|
+
* `String(...)` missed it here: `null` from a read and `X_NOT_FOUND` from a write, against a row
|
|
78
|
+
* that exists, reachable from a path parameter, a client-supplied id or a legacy import.
|
|
79
|
+
*/
|
|
80
|
+
const storeKey = (row: unknown): string =>
|
|
81
|
+
entity.$primaryKey
|
|
82
|
+
.map((property) => keyOf(kindOf(entity, property) ?? '', field(row, property)))
|
|
83
|
+
.join('');
|
|
84
|
+
/** The same key, from the id a caller named rather than from a row it has in hand. */
|
|
85
|
+
const idStoreKey = (id: unknown, operation: string): string =>
|
|
86
|
+
keyOf(kindOf(entity, singleKeyOf(entity, operation)) ?? '', id);
|
|
87
|
+
const rows = new Map<string, Row>(seed.map((row) => [storeKey(row), row]));
|
|
88
|
+
|
|
89
|
+
const rowsOf = (plan: QueryPlan, args: FindManyArgs): Row[] => {
|
|
90
|
+
const visible = (row: Row): boolean =>
|
|
91
|
+
!entity.$softDelete ||
|
|
92
|
+
args.includeDeleted === true ||
|
|
93
|
+
field(row, SOFT_DELETE_COLUMN) === null ||
|
|
94
|
+
field(row, SOFT_DELETE_COLUMN) === undefined;
|
|
95
|
+
return [...rows.values()]
|
|
96
|
+
.filter((row) => plan.where.every((predicate) => matchesPredicate(entity, row, predicate)))
|
|
97
|
+
.filter(visible)
|
|
98
|
+
.sort((left, right) => {
|
|
99
|
+
for (const entry of plan.orderBy) {
|
|
100
|
+
const order = compareByKind(
|
|
101
|
+
kindOf(entity, entry.column),
|
|
102
|
+
valueAt(left, entry.column),
|
|
103
|
+
valueAt(right, entry.column),
|
|
104
|
+
);
|
|
105
|
+
if (order !== 0) return entry.direction === 'desc' ? -order : order;
|
|
106
|
+
}
|
|
107
|
+
return 0;
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const select = (args: FindManyArgs, operation: string): { plan: QueryPlan; found: Row[] } => {
|
|
112
|
+
const plan = readPlan(entity, args, operation);
|
|
113
|
+
return { plan, found: rowsOf(plan, args) };
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const write = (given: Row, options: RepoOptions | undefined, operation: string): Row => {
|
|
117
|
+
// `MoneyInput` lets a writer hand a `bigint`; a stored row holds the value type. The Postgres
|
|
118
|
+
// driver narrows in `bindValues` and reads its answer back through `returning *`, so without
|
|
119
|
+
// this an in-memory row would be the one row in the framework `JSON.stringify` refuses.
|
|
120
|
+
const row = narrowMoney(entity.$columns, given);
|
|
121
|
+
// Beside `$assert`, and before the row lands: a write is judged by the tenant it names as well
|
|
122
|
+
// as by the invariants it declares, and the Postgres driver runs the same pair in `writeRows`.
|
|
123
|
+
// `update` reaches here with the STORED row merged under its patch, so a patch that moves a row
|
|
124
|
+
// out of this tenant is refused by the same call that refuses an insert into another one.
|
|
125
|
+
assertRowTenant(entity.$name, entity.$tenantColumn, operation, row);
|
|
126
|
+
entity.$assert(row);
|
|
127
|
+
const key = storeKey(row);
|
|
128
|
+
const previous = rows.get(key);
|
|
129
|
+
options?.tx?.onRollback(() => {
|
|
130
|
+
if (previous === undefined) rows.delete(key);
|
|
131
|
+
else rows.set(key, previous);
|
|
132
|
+
});
|
|
133
|
+
rows.set(key, row);
|
|
134
|
+
return row;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// The same guard the read path applies: on a tenant-scoped entity an id alone is not enough
|
|
138
|
+
// to name a row, so `update`/`delete` resolve through a plan rather than through the map.
|
|
139
|
+
const addressed = (id: string, options: RepoOptions | undefined, operation: string): Row => {
|
|
140
|
+
const plan = idPlan(entity, id, options, operation);
|
|
141
|
+
const current = rows.get(idStoreKey(id, operation));
|
|
142
|
+
// A soft-deleted row is hidden from writes too — `delete` on one is `X_NOT_FOUND`, not a
|
|
143
|
+
// second stamp, which is what the Postgres driver's `deleted_at is null` clause already says.
|
|
144
|
+
const hidden =
|
|
145
|
+
current !== undefined &&
|
|
146
|
+
entity.$softDelete &&
|
|
147
|
+
field(current, SOFT_DELETE_COLUMN) !== null &&
|
|
148
|
+
field(current, SOFT_DELETE_COLUMN) !== undefined;
|
|
149
|
+
if (
|
|
150
|
+
current === undefined ||
|
|
151
|
+
hidden ||
|
|
152
|
+
!plan.where.every((predicate) => matchesPredicate(entity, current, predicate))
|
|
153
|
+
) {
|
|
154
|
+
throw notFound(entity.$name, id);
|
|
155
|
+
}
|
|
156
|
+
return current;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Every method is async: a repository call that fails must reject, never throw
|
|
160
|
+
// synchronously, or half the call sites would need two error paths.
|
|
161
|
+
return {
|
|
162
|
+
async findById(id, options) {
|
|
163
|
+
const { found } = select(
|
|
164
|
+
{ ...options, where: [{ column: singleKeyOf(entity, 'findById'), op: 'eq', value: id }] },
|
|
165
|
+
'findById',
|
|
166
|
+
);
|
|
167
|
+
return found[0] ?? null;
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
async findMany(args = {}) {
|
|
171
|
+
const { plan, found } = select(args, 'findMany');
|
|
172
|
+
const start = afterCursor(entity, plan, found);
|
|
173
|
+
const page = found.slice(start, start + plan.limit);
|
|
174
|
+
const last = page.at(-1);
|
|
175
|
+
const more = start + page.length < found.length;
|
|
176
|
+
return {
|
|
177
|
+
rows: page,
|
|
178
|
+
nextCursor:
|
|
179
|
+
more && last !== undefined ? cursorFor(entity, plan, last, storeKey(last)) : null,
|
|
180
|
+
};
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async insert(values, options) {
|
|
184
|
+
return write(values, options, 'insert');
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
async insertAll(batch, options) {
|
|
188
|
+
// The whole batch is judged before any of it lands: Postgres refuses the statement as one,
|
|
189
|
+
// so a row an invariant rejects — or one naming a tenant this actor may not write — must not
|
|
190
|
+
// leave the rows before it stored here either. `write` re-checks both per row; this loop is
|
|
191
|
+
// what makes the batch all-or-nothing, which is the half a per-row check cannot give.
|
|
192
|
+
for (const row of batch) {
|
|
193
|
+
assertRowTenant(entity.$name, entity.$tenantColumn, 'insertAll', row);
|
|
194
|
+
entity.$assert(row);
|
|
195
|
+
}
|
|
196
|
+
return batch.map((row) => write(row, options, 'insertAll'));
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
async upsertAll(batch, args) {
|
|
200
|
+
// The INCOMING rows, judged before any of them is matched: under `onMatch: 'nothing'` a
|
|
201
|
+
// colliding row is skipped and never reaches `write()`, so checking only what lands would
|
|
202
|
+
// let a row naming another tenant through whenever it happened to collide.
|
|
203
|
+
for (const row of batch) {
|
|
204
|
+
assertRowTenant(entity.$name, entity.$tenantColumn, 'upsertAll', row);
|
|
205
|
+
entity.$assert(row);
|
|
206
|
+
}
|
|
207
|
+
const plan = upsertPlan(entity, batch, args.onConflict, args.onMatch ?? 'update');
|
|
208
|
+
const keys = conflictKeys(entity, plan, batch);
|
|
209
|
+
// The stored rows under the same key, so "does this collide" is the question the unique
|
|
210
|
+
// index answers in Postgres and not a scan per row. A soft-deleted row still occupies its
|
|
211
|
+
// key here, because the index it would collide with there is not partial either — and a row
|
|
212
|
+
// whose target holds a null occupies none, because the index is `NULLS DISTINCT`.
|
|
213
|
+
const stored = new Map<string, Row>();
|
|
214
|
+
for (const row of rows.values()) {
|
|
215
|
+
const key = conflictKeyOf(entity, plan.on, row);
|
|
216
|
+
if (key !== undefined) stored.set(key, row);
|
|
217
|
+
}
|
|
218
|
+
const written: Row[] = [];
|
|
219
|
+
for (const [position, row] of batch.entries()) {
|
|
220
|
+
const key = keys[position];
|
|
221
|
+
const existing = key === undefined ? undefined : stored.get(key);
|
|
222
|
+
// `do nothing` writes no row, and `returning *` therefore names none: a skipped row is
|
|
223
|
+
// absent from the result rather than present and unchanged.
|
|
224
|
+
if (existing !== undefined && plan.set.length === 0) continue;
|
|
225
|
+
const merged =
|
|
226
|
+
existing === undefined
|
|
227
|
+
? row
|
|
228
|
+
: Object.assign(
|
|
229
|
+
{},
|
|
230
|
+
existing,
|
|
231
|
+
Object.fromEntries(plan.set.map((property) => [property, field(row, property)])),
|
|
232
|
+
);
|
|
233
|
+
// `UpsertArgs extends RepoOptions`, so the args ARE the options — one bag, and a `tx`
|
|
234
|
+
// passed to an upsert registers its undo exactly as it does for every other write here.
|
|
235
|
+
const result = write(merged, args, 'upsertAll');
|
|
236
|
+
// Filed as it lands, so a later row of the same batch collides with an earlier one exactly
|
|
237
|
+
// as it would with a row the request stored a moment before it.
|
|
238
|
+
if (key !== undefined) stored.set(key, result);
|
|
239
|
+
written.push(result);
|
|
240
|
+
}
|
|
241
|
+
return written;
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
async update(id, patch, options) {
|
|
245
|
+
return write(Object.assign({}, addressed(id, options, 'update'), patch), options, 'update');
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
async delete(id, options) {
|
|
249
|
+
const current = addressed(id, options, 'delete');
|
|
250
|
+
// Soft delete hides the row without losing it; the column's presence is the switch.
|
|
251
|
+
if (entity.$softDelete) {
|
|
252
|
+
write(Object.assign({}, current, { [SOFT_DELETE_COLUMN]: entityNow() }), options, 'delete');
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const key = storeKey(current);
|
|
256
|
+
options?.tx?.onRollback(() => rows.set(key, current));
|
|
257
|
+
rows.delete(key);
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
async deleteWhere(filter, options) {
|
|
261
|
+
// `rowsOf` is the read path: the same predicates, the same tenant scoping, and the same
|
|
262
|
+
// soft-delete visibility. A row already stamped is not matched, so a second call cannot
|
|
263
|
+
// move `deletedAt` forward — which is what the Postgres driver's `deleted_at is null`
|
|
264
|
+
// clause says there.
|
|
265
|
+
const doomed = rowsOf(deletePlan(entity, filter, options, 'deleteWhere'), {});
|
|
266
|
+
for (const row of doomed) {
|
|
267
|
+
if (entity.$softDelete) {
|
|
268
|
+
write(
|
|
269
|
+
Object.assign({}, row, { [SOFT_DELETE_COLUMN]: entityNow() }),
|
|
270
|
+
options,
|
|
271
|
+
'deleteWhere',
|
|
272
|
+
);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const key = storeKey(row);
|
|
276
|
+
options?.tx?.onRollback(() => rows.set(key, row));
|
|
277
|
+
rows.delete(key);
|
|
278
|
+
}
|
|
279
|
+
return doomed.length;
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
async updateWhere(filter, patch, options) {
|
|
283
|
+
const plan = updatePlan(entity, filter, patch, options, 'updateWhere');
|
|
284
|
+
// The PATCH, judged whole and before the rows are read — the same call `postgresRepo` makes
|
|
285
|
+
// before its statement exists. Inside the loop below it is judged only where a row was
|
|
286
|
+
// matched, so a patch handing rows to another tenant was refused or accepted depending on
|
|
287
|
+
// what the table happened to hold: `updateWhere(filter, { orgId: theirs })` over a filter
|
|
288
|
+
// matching nothing answered `0` here and threw there, from one call.
|
|
289
|
+
assertRowTenant(entity.$name, entity.$tenantColumn, 'updateWhere', patch);
|
|
290
|
+
// `rowsOf` again, so a soft-deleted row is as unreachable here as it is through
|
|
291
|
+
// `addressed()` — patching a row the app has already deleted is not an update, it is a
|
|
292
|
+
// resurrection nobody asked for. `write` re-asserts the invariants on each result.
|
|
293
|
+
const found = rowsOf(plan, {});
|
|
294
|
+
for (const row of found) write(Object.assign({}, row, patch), options, 'updateWhere');
|
|
295
|
+
return found.length;
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
async count(args = {}) {
|
|
299
|
+
return select(args, 'count').found.length;
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
async aggregate(fn, column, args = {}) {
|
|
303
|
+
// Refused before a row is read, and by the same function the Postgres driver calls: a column
|
|
304
|
+
// that has no aggregate is that mistake in both drivers or in neither.
|
|
305
|
+
const declared = aggregateColumnOf(entity, fn, column);
|
|
306
|
+
const { found } = select(args, fn);
|
|
307
|
+
return foldAggregate(entity, fn, column, declared.$meta.kind, found);
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Exact here, and that is the honest answer rather than a shortcut: the estimate exists
|
|
312
|
+
* because `count(*)` walks every visible row of a real table, and this driver's rows are
|
|
313
|
+
* already an array whose length is free. Filters are refused in both drivers by `plan.ts`,
|
|
314
|
+
* so the two still answer the same QUESTION.
|
|
315
|
+
*/
|
|
316
|
+
async approximateCount(args = {}) {
|
|
317
|
+
return select(args, 'approximateCount').found.length;
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
async countBy(column, args = {}) {
|
|
321
|
+
// Refused before a row is read, and by the same function the Postgres driver calls: a column
|
|
322
|
+
// a map cannot be keyed by is that mistake in both drivers or in neither.
|
|
323
|
+
groupColumnOf(entity, column, 'countBy');
|
|
324
|
+
const { found } = select(args, 'countBy');
|
|
325
|
+
const groups = new Map<unknown, number>();
|
|
326
|
+
for (const row of found) {
|
|
327
|
+
// `?? null`, so a property this row never carried lands in the same group Postgres puts a
|
|
328
|
+
// NULL row in — and `0`, `''` and `false` stay the values they are.
|
|
329
|
+
const value = field(row, column) ?? null;
|
|
330
|
+
groups.set(value, (groups.get(value) ?? 0) + 1);
|
|
331
|
+
}
|
|
332
|
+
return countsFrom(entity, column, 'countBy', [...groups]);
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
reset() {
|
|
336
|
+
rows.clear();
|
|
337
|
+
for (const row of seed) rows.set(storeKey(row), row);
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
let txCounter = 0;
|
|
343
|
+
|
|
344
|
+
/** In-memory transactor: undo closures registered by drivers run on failure. */
|
|
345
|
+
export const memoryTransactor = (): Transactor => ({
|
|
346
|
+
async run(work) {
|
|
347
|
+
const undos: (() => void)[] = [];
|
|
348
|
+
txCounter += 1;
|
|
349
|
+
const tx: Tx = { id: `tx-${txCounter}`, onRollback: (undo) => undos.push(undo) };
|
|
350
|
+
try {
|
|
351
|
+
return await work(tx);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
for (const undo of undos.reverse()) undo();
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
});
|
package/src/pg-driver.ts
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
withStatementAttribution,
|
|
17
17
|
withTransaction,
|
|
18
18
|
} from '@ultimat3/db';
|
|
19
|
+
import { aggregateColumnOf, aggregateMinor, assertOneUnit } from './aggregate';
|
|
20
|
+
import { decodeAggregate } from './aggregate-decode';
|
|
19
21
|
import {
|
|
20
22
|
conflictKeys,
|
|
21
23
|
insertChunks,
|
|
@@ -25,6 +27,7 @@ import {
|
|
|
25
27
|
} from './bulk-write';
|
|
26
28
|
import { entityNow } from './clock';
|
|
27
29
|
import { coalesceFindById } from './coalesce';
|
|
30
|
+
import { moneyColumns } from './column';
|
|
28
31
|
import { countsFrom, groupColumnOf, groupValue, MAX_GROUPS } from './count-by';
|
|
29
32
|
import { cursorFor, seekFrom, valueAt } from './cursor';
|
|
30
33
|
import type { Driver } from './database';
|
|
@@ -32,18 +35,25 @@ import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
|
32
35
|
import { notFound, repoClientPinned } from './errors';
|
|
33
36
|
import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
|
|
34
37
|
import { forgetPreloaded, tagSiblings } from './jit-preload';
|
|
35
|
-
import { bindValues, decodeRow, type PhysicalRow, physicalName } from './pg-row';
|
|
38
|
+
import { bindValues, decodeRow, type PhysicalRow, physicalName, sortPrecision } from './pg-row';
|
|
36
39
|
import {
|
|
37
|
-
type
|
|
40
|
+
type AggregateRow,
|
|
41
|
+
aggregateStatement,
|
|
38
42
|
countByStatement,
|
|
39
43
|
countStatement,
|
|
40
|
-
|
|
44
|
+
currenciesStatement,
|
|
45
|
+
estimateStatement,
|
|
41
46
|
type GroupRow,
|
|
42
|
-
|
|
47
|
+
type MoneyUnitRow,
|
|
43
48
|
type ReadShape,
|
|
44
49
|
selectStatement,
|
|
45
|
-
updateStatement,
|
|
46
50
|
} from './pg-sql';
|
|
51
|
+
import {
|
|
52
|
+
type ConflictTarget,
|
|
53
|
+
deleteStatement,
|
|
54
|
+
insertStatement,
|
|
55
|
+
updateStatement,
|
|
56
|
+
} from './pg-write-sql';
|
|
47
57
|
import { deletePlan, idPlan, readPlan, updatePlan } from './plan';
|
|
48
58
|
import type { FindManyArgs, Repo, Transactor, UpsertArgs } from './repo';
|
|
49
59
|
import type { QueryPlan } from './tenancy';
|
|
@@ -219,7 +229,8 @@ export const postgresRepo = <Row>(
|
|
|
219
229
|
selectStatement(entity, plan, shapeOf(args, seekFrom(entity, plan)), plan.limit + 1),
|
|
220
230
|
),
|
|
221
231
|
);
|
|
222
|
-
const
|
|
232
|
+
const page = found.slice(0, plan.limit);
|
|
233
|
+
const rows = page.map((row) => decodeRow(entity, row));
|
|
223
234
|
// What the page leaves behind: its foreign key values, so the first `findById` for any one
|
|
224
235
|
// of them resolves the key for the whole page. A `for … of` loop over these rows costs one
|
|
225
236
|
// statement, not one per row — its `await` already ended the coalescing window.
|
|
@@ -229,7 +240,16 @@ export const postgresRepo = <Row>(
|
|
|
229
240
|
rows,
|
|
230
241
|
nextCursor:
|
|
231
242
|
found.length > plan.limit && last !== undefined
|
|
232
|
-
?
|
|
243
|
+
? // Minted from the PHYSICAL row as well as the decoded one: a `timestamptz` decodes
|
|
244
|
+
// to a `Date`, and the microseconds that drops are the difference between a position
|
|
245
|
+
// the `order by` agrees with and one that cuts between two rows.
|
|
246
|
+
cursorFor(
|
|
247
|
+
entity,
|
|
248
|
+
plan,
|
|
249
|
+
last,
|
|
250
|
+
idOf(last),
|
|
251
|
+
sortPrecision(entity, plan.orderBy, page.at(-1)),
|
|
252
|
+
)
|
|
233
253
|
: null,
|
|
234
254
|
};
|
|
235
255
|
},
|
|
@@ -376,6 +396,73 @@ export const postgresRepo = <Row>(
|
|
|
376
396
|
rows.map((row) => [groupValue(grouped, row.group_value), Number(row.group_count)] as const),
|
|
377
397
|
);
|
|
378
398
|
},
|
|
399
|
+
|
|
400
|
+
async aggregate(fn, column, args = {}) {
|
|
401
|
+
const op = fn;
|
|
402
|
+
const declared = aggregateColumnOf(entity, fn, column);
|
|
403
|
+
const plan = readPlan(entity, args, op);
|
|
404
|
+
const shape = shapeOf(args);
|
|
405
|
+
const money = declared.$meta.kind === 'money' ? moneyColumns(column, declared.$meta) : null;
|
|
406
|
+
// Money is judged BEFORE the aggregate is asked for, in its own statement: `sum(minor)` over
|
|
407
|
+
// two currencies is a number in neither, and the refusal has to name them.
|
|
408
|
+
const unit =
|
|
409
|
+
money === null
|
|
410
|
+
? undefined
|
|
411
|
+
: assertOneUnit(
|
|
412
|
+
entity,
|
|
413
|
+
fn,
|
|
414
|
+
column,
|
|
415
|
+
(
|
|
416
|
+
await attributed(op, () =>
|
|
417
|
+
client().query<MoneyUnitRow>(
|
|
418
|
+
currenciesStatement(entity, plan, shape, money.currency, money.scale),
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
).map((row) => ({
|
|
422
|
+
currency: String(row.group_value ?? '').trim(),
|
|
423
|
+
scale:
|
|
424
|
+
row.group_scale === null || row.group_scale === undefined
|
|
425
|
+
? null
|
|
426
|
+
: Number(row.group_scale),
|
|
427
|
+
})),
|
|
428
|
+
);
|
|
429
|
+
const row = await attributed(op, () =>
|
|
430
|
+
client().one<AggregateRow>(
|
|
431
|
+
aggregateStatement(entity, plan, shape, fn, money === null ? column : `${column}.minor`),
|
|
432
|
+
),
|
|
433
|
+
);
|
|
434
|
+
const text = row?.agg_value;
|
|
435
|
+
if (text === null || text === undefined) return null;
|
|
436
|
+
if (money === null) return decodeAggregate(fn, declared.$meta.kind, String(text));
|
|
437
|
+
if (unit === undefined) return null;
|
|
438
|
+
return {
|
|
439
|
+
minor: aggregateMinor(entity, fn, column, String(text)),
|
|
440
|
+
currency: unit.currency,
|
|
441
|
+
...(unit.scale === null ? {} : { scale: unit.scale }),
|
|
442
|
+
};
|
|
443
|
+
},
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* `reltuples`, the planner's own estimate — one row out of `pg_class`, constant time, and the
|
|
447
|
+
* only answer that stays constant time as the table grows. `count(*)` walks every visible row
|
|
448
|
+
* because MVCC gives it no shortcut, so past a few million it is the read that trips a web
|
|
449
|
+
* role's `statement_timeout`, and no index can help: `X_DB_STATEMENT_TIMEOUT`'s fix names one
|
|
450
|
+
* anyway, and following it changes nothing.
|
|
451
|
+
*/
|
|
452
|
+
async approximateCount(args = {}) {
|
|
453
|
+
const op = 'approximateCount';
|
|
454
|
+
// Built and therefore GUARDED: tenancy still applies, and a filtered chain is refused here
|
|
455
|
+
// rather than answered with the whole table's estimate.
|
|
456
|
+
readPlan(entity, args, op);
|
|
457
|
+
const row = await attributed(op, () =>
|
|
458
|
+
client().one<{ estimate: unknown }>(estimateStatement(entity.$table)),
|
|
459
|
+
);
|
|
460
|
+
const estimate = Number(row?.estimate ?? -1);
|
|
461
|
+
// `-1` is what Postgres 14+ stores for a table nobody has analysed. That is an absence of an
|
|
462
|
+
// estimate, not an estimate of zero, and answering `0` would read exactly like an empty
|
|
463
|
+
// table to every caller.
|
|
464
|
+
return Number.isFinite(estimate) && estimate >= 0 ? estimate : null;
|
|
465
|
+
},
|
|
379
466
|
};
|
|
380
467
|
};
|
|
381
468
|
|
package/src/pg-row.ts
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
import { columnFor, columnName, moneyColumns } from './column';
|
|
8
8
|
import { narrowMoney } from './columns';
|
|
9
|
+
import { kindOf } from './cursor';
|
|
9
10
|
import type { EntityCore } from './entity';
|
|
10
11
|
import { invariantViolated } from './errors';
|
|
12
|
+
import { pgInstantMicros, seekAlias } from './instant';
|
|
13
|
+
import type { SortKey } from './tenancy';
|
|
11
14
|
import type { AnyColumn, MoneyValue, RowPatch } from './types';
|
|
12
15
|
|
|
13
16
|
export type PhysicalRow = Readonly<Record<string, unknown>>;
|
|
@@ -125,13 +128,21 @@ const arrayElement = (value: unknown): string => {
|
|
|
125
128
|
* answers with `malformed array literal` (measured). What it accepts is the literal, so this is
|
|
126
129
|
* where a JS array becomes one.
|
|
127
130
|
*/
|
|
131
|
+
/**
|
|
132
|
+
* A JS array as the literal Postgres accepts. Exported because a containment predicate binds one
|
|
133
|
+
* too (`tags @> $1`) and building a second one there is how the two spellings drift: Bun's `sql`
|
|
134
|
+
* serialises a JS array to `x,y`, which the server answers `malformed array literal`.
|
|
135
|
+
*/
|
|
136
|
+
export const arrayLiteral = (value: unknown): string =>
|
|
137
|
+
`{${(Array.isArray(value) ? value : [value]).map(arrayElement).join(',')}}`;
|
|
138
|
+
|
|
128
139
|
const bindable = (column: AnyColumn, value: unknown): unknown => {
|
|
129
140
|
if (value === null || value === undefined) return null;
|
|
130
141
|
// A plain object is not a bindable parameter (`X_SQL_UNSAFE`), so a `jsonb` value crosses as its
|
|
131
142
|
// TEXT and `pg-sql.ts`'s cell casts it back — see `cellCast` for why the cast is `::text::jsonb`.
|
|
132
143
|
if (column.$meta.kind === 'jsonb') return JSON.stringify(value);
|
|
133
144
|
if (column.$meta.kind !== 'array' || !Array.isArray(value)) return value;
|
|
134
|
-
return
|
|
145
|
+
return arrayLiteral(value);
|
|
135
146
|
};
|
|
136
147
|
|
|
137
148
|
const moneyOf = (
|
|
@@ -185,3 +196,29 @@ export const decodeRow = <Row>(entity: EntityCore<Row>, source: PhysicalRow): Ro
|
|
|
185
196
|
// Every property present was validated by the column that declared it, so this is the row.
|
|
186
197
|
return row as Row;
|
|
187
198
|
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The sort values a cursor has to be minted from when the DECODED row cannot hold them. Exactly
|
|
202
|
+
* one kind is in that position: a `timestamptz` comes back through Bun's client as a JS `Date`,
|
|
203
|
+
* which is milliseconds, while the column and the `order by` are microseconds — so a cursor minted
|
|
204
|
+
* from the decoded row cuts the page at a position no row occupies, and every row inside the
|
|
205
|
+
* boundary millisecond is served on no page at all.
|
|
206
|
+
*
|
|
207
|
+
* Keyed by the sort key PATH, because that is what `cursorFor` looks a value up by. A key the
|
|
208
|
+
* statement did not carry a precision output for is simply absent, and the decoded `Date` is the
|
|
209
|
+
* position — which is the behaviour every other kind has.
|
|
210
|
+
*/
|
|
211
|
+
export const sortPrecision = <Row>(
|
|
212
|
+
entity: EntityCore<Row>,
|
|
213
|
+
orderBy: readonly SortKey[],
|
|
214
|
+
source: PhysicalRow | undefined,
|
|
215
|
+
): ReadonlyMap<string, unknown> => {
|
|
216
|
+
const exact = new Map<string, unknown>();
|
|
217
|
+
if (source === undefined) return exact;
|
|
218
|
+
for (const entry of orderBy) {
|
|
219
|
+
if (kindOf(entity, entry.column) !== 'timestamptz') continue;
|
|
220
|
+
const micros = pgInstantMicros(source[seekAlias(physicalName(entity, entry.column))]);
|
|
221
|
+
if (micros !== undefined) exact.set(entry.column, micros);
|
|
222
|
+
}
|
|
223
|
+
return exact;
|
|
224
|
+
};
|