@ultimat3/entity 29.4.0 → 30.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +66 -96
- package/README.md +118 -8
- package/package.json +15 -5
- package/src/append-only.ts +7 -0
- package/src/batch-read.ts +12 -4
- package/src/bulk-write-send.ts +49 -0
- package/src/bulk-write.ts +48 -11
- package/src/column.ts +29 -4
- package/src/counter.ts +109 -0
- package/src/cross-tenant.ts +13 -0
- package/src/cursor.ts +17 -1
- package/src/database.ts +16 -10
- package/src/describe-tenancy.ts +25 -0
- package/src/describe.ts +144 -3
- package/src/entity-error.ts +33 -0
- package/src/entity-parse.ts +89 -0
- package/src/entity-patch.ts +42 -0
- package/src/entity.ts +90 -76
- package/src/errors.ts +8 -0
- package/src/history-actor.ts +22 -0
- package/src/history.ts +86 -0
- package/src/index.ts +20 -1
- package/src/jit-preload.ts +35 -25
- package/src/lifecycle.ts +100 -0
- package/src/lock-key.ts +27 -0
- package/src/lock-unsupported.ts +11 -0
- package/src/memory-aggregates.ts +99 -0
- package/src/memory-batch.ts +69 -0
- package/src/memory-journal.ts +68 -0
- package/src/memory-lifecycle.ts +106 -0
- package/src/memory-lock.ts +144 -0
- package/src/memory-observed.ts +121 -0
- package/src/memory-repo.ts +199 -186
- package/src/memory-same-tenant.ts +83 -0
- package/src/memory-seek.ts +44 -0
- package/src/memory-transactor.ts +94 -0
- package/src/memory-unique.ts +19 -4
- package/src/n-plus-one.ts +28 -10
- package/src/numeric-compare.ts +43 -0
- package/src/pg-aggregates.ts +185 -0
- package/src/pg-driver.ts +79 -166
- package/src/pg-lock.ts +50 -0
- package/src/pg-row.ts +29 -2
- package/src/pg-sql-aggregate.ts +31 -0
- package/src/pg-sql.ts +17 -1
- package/src/query.ts +26 -105
- package/src/read-aggregates.ts +138 -0
- package/src/read-capture.ts +22 -0
- package/src/registry.ts +44 -1
- package/src/repo.ts +34 -0
- package/src/retain.ts +71 -0
- package/src/row-observer.ts +22 -4
- package/src/row-security-settings.ts +20 -0
- package/src/sealed-repo.ts +15 -0
- package/src/seed.ts +43 -7
- package/src/series-unsupported.ts +11 -0
- package/src/table-locks.ts +41 -0
- package/src/test-driver.ts +90 -0
- package/src/time-bucket.ts +147 -0
- package/src/touch.ts +33 -0
- package/src/types.ts +20 -0
- package/src/view-entity.ts +78 -0
- package/src/view-issue.ts +47 -0
- package/src/view-repo.ts +38 -0
- package/src/view.ts +41 -27
package/src/pg-driver.ts
CHANGED
|
@@ -2,59 +2,39 @@
|
|
|
2
2
|
// only difference is where the rows are, which is the point: a test that passes against memory
|
|
3
3
|
// means something about Postgres.
|
|
4
4
|
//
|
|
5
|
-
// It
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// that caused it. `RepoOptions.tx` is the in-memory driver's undo hook and is ignored here.
|
|
5
|
+
// It takes no connection. `db()` from `@ultimat3/db` returns the open transaction, so a repository
|
|
6
|
+
// call inside `withTransaction` joins it unasked, which lands `ctx.jobs.enqueue()`'s outbox row
|
|
7
|
+
// with its write. `RepoOptions.tx` is the in-memory driver's undo hook and is ignored here.
|
|
9
8
|
|
|
10
9
|
import { pageOf } from '@ultimat3/core';
|
|
11
10
|
import {
|
|
12
11
|
currentTx,
|
|
13
12
|
type DbClient,
|
|
14
13
|
db,
|
|
14
|
+
refuseAbandoned,
|
|
15
15
|
type SqlFragment,
|
|
16
16
|
withStatementAttribution,
|
|
17
17
|
} from '@ultimat3/db';
|
|
18
|
-
import { aggregateColumnOf, aggregateMinor, assertOneUnit } from './aggregate';
|
|
19
|
-
import { decodeAggregate } from './aggregate-decode';
|
|
20
18
|
import { appendOnlyRepo } from './append-only';
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
insertChunks,
|
|
24
|
-
insertColumns,
|
|
25
|
-
namedProperties,
|
|
26
|
-
upsertPlan,
|
|
27
|
-
} from './bulk-write';
|
|
19
|
+
import { conflictKeys, insertColumns, statementColumns, upsertPlan } from './bulk-write';
|
|
20
|
+
import { writeChunks } from './bulk-write-send';
|
|
28
21
|
import { entityNow } from './clock';
|
|
29
22
|
import { coalesceFindById } from './coalesce';
|
|
30
|
-
import { moneyColumns } from './column';
|
|
31
23
|
import { narrowRow } from './columns';
|
|
32
|
-
import { countsFrom, groupColumnOf, groupValue, MAX_GROUPS } from './count-by';
|
|
33
24
|
import { cursorFor, seekFrom, valueAt } from './cursor';
|
|
34
25
|
import type { Driver } from './database';
|
|
35
26
|
import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
36
27
|
import { notFound, repoClientPinned } from './errors';
|
|
28
|
+
import { stampHistoryActor } from './history-actor';
|
|
37
29
|
import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
|
|
38
30
|
import { forgetPreloaded, tagSiblings } from './jit-preload';
|
|
39
31
|
import { judgedWrite } from './judged-write';
|
|
32
|
+
import { postgresAggregates } from './pg-aggregates';
|
|
33
|
+
import { postgresLocks } from './pg-lock';
|
|
40
34
|
import { bindValues, decodeRow, type PhysicalRow, physicalName, sortPrecision } from './pg-row';
|
|
41
35
|
import { countStatement, type ReadShape, selectStatement } from './pg-sql';
|
|
42
|
-
import {
|
|
43
|
-
type AggregateRow,
|
|
44
|
-
aggregateStatement,
|
|
45
|
-
countByStatement,
|
|
46
|
-
currenciesStatement,
|
|
47
|
-
estimateStatement,
|
|
48
|
-
type GroupRow,
|
|
49
|
-
type MoneyUnitRow,
|
|
50
|
-
} from './pg-sql-aggregate';
|
|
51
36
|
import { postgresTransactor } from './pg-transactor';
|
|
52
|
-
import {
|
|
53
|
-
type ConflictTarget,
|
|
54
|
-
deleteStatement,
|
|
55
|
-
insertStatement,
|
|
56
|
-
updateStatement,
|
|
57
|
-
} from './pg-write-sql';
|
|
37
|
+
import { type ConflictTarget, deleteStatement, updateStatement } from './pg-write-sql';
|
|
58
38
|
import { deletePlan, idPlan, readPlan, updatePlan } from './plan';
|
|
59
39
|
import type { FindManyArgs, Repo, UpsertArgs } from './repo';
|
|
60
40
|
import { sealedRepo } from './sealed-repo';
|
|
@@ -65,15 +45,13 @@ import { taggedWrite } from './write-tag';
|
|
|
65
45
|
|
|
66
46
|
export interface PostgresDriverOptions {
|
|
67
47
|
/**
|
|
68
|
-
* Pin a client. Left out, every call resolves `db()` — the ambient pool, or the open
|
|
69
|
-
*
|
|
70
|
-
* globally with `setDbClient()`.
|
|
48
|
+
* Pin a client. Left out, every call resolves `db()` — the ambient pool, or the open transaction
|
|
49
|
+
* in scope. Tests pass `recordingClient()` here or install one with `setDbClient()`.
|
|
71
50
|
*/
|
|
72
51
|
readonly client?: DbClient | undefined;
|
|
73
52
|
/**
|
|
74
|
-
* Preload foreign keys resolved by a page into a request-scoped cache, so the first
|
|
75
|
-
*
|
|
76
|
-
* Default: true.
|
|
53
|
+
* Preload foreign keys resolved by a page into a request-scoped cache, so the first `findById`
|
|
54
|
+
* for any of them resolves that key for the whole page in one statement. Default: true.
|
|
77
55
|
*/
|
|
78
56
|
readonly jitPreload?: boolean | undefined;
|
|
79
57
|
}
|
|
@@ -165,19 +143,59 @@ export const postgresRepo = <Row>(
|
|
|
165
143
|
* Every write goes out through here: the ONE place the request's preloaded rows are dropped (a
|
|
166
144
|
* row this statement changes must not be served afterwards from a page read before it — so
|
|
167
145
|
* before the statement, not after), and where a keyed request's write names itself in the WAL
|
|
168
|
-
* (`write-tag.ts`).
|
|
146
|
+
* (`write-tag.ts`). The one funnel for an autocommit repository write, so it is also where a scope
|
|
147
|
+
* whose caller was already answered stops: the pool does not refuse, and must not (`runOn`).
|
|
169
148
|
*/
|
|
170
|
-
const writing = <T>(send: () => Promise<T>): Promise<T> => {
|
|
171
|
-
|
|
149
|
+
const writing = async <T>(send: () => Promise<T>): Promise<T> => {
|
|
150
|
+
refuseAbandoned();
|
|
151
|
+
forgetPreloaded();
|
|
152
|
+
await stampHistoryActor(entity, config.client !== undefined);
|
|
172
153
|
return taggedWrite(config.client !== undefined, send);
|
|
173
154
|
};
|
|
174
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Only an entity with a JS-only rule comes here: rows ARE needed, so how many is a memory bound.
|
|
158
|
+
* Counted BEFORE the write, because a refusal after `returning *` has already allocated what it
|
|
159
|
+
* refuses; a concurrent writer can drift the count, which moves a bound, never a decision.
|
|
160
|
+
*/
|
|
161
|
+
const judgedRows = async (
|
|
162
|
+
op: string,
|
|
163
|
+
plan: QueryPlan,
|
|
164
|
+
values: ReadonlyMap<string, unknown>,
|
|
165
|
+
shape: ReadShape,
|
|
166
|
+
): Promise<number> => {
|
|
167
|
+
const matched = await attributed(op, () =>
|
|
168
|
+
client().one<{ count: unknown }>(countStatement(entity, plan, shape)),
|
|
169
|
+
);
|
|
170
|
+
const rows = Number(matched?.count ?? 0);
|
|
171
|
+
if (rows > MAX_ASSERTED_ROWS) throw assertedRowsTooMany(entity.$name, op, rows);
|
|
172
|
+
const statement = updateStatement(entity, plan, values, shape, true);
|
|
173
|
+
return judged(async (send) => {
|
|
174
|
+
const written = await attributed(op, () => writing(() => send.query<PhysicalRow>(statement)));
|
|
175
|
+
for (const row of written) entity.$assert(decodeRow(entity, row));
|
|
176
|
+
return written.length;
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* `delete` and `deleteWhere`. A soft delete is an update, so an app-only rule judges the stamped
|
|
182
|
+
* rows, as `memoryRepo`'s `write` does; everything else is one statement and a count.
|
|
183
|
+
*/
|
|
184
|
+
const removed = (op: string, plan: QueryPlan): Promise<number> =>
|
|
185
|
+
entity.$softDelete && hasJsOnlyInvariant(entity.$invariants)
|
|
186
|
+
? judgedRows(
|
|
187
|
+
op,
|
|
188
|
+
plan,
|
|
189
|
+
new Map([[physicalName(entity, SOFT_DELETE_COLUMN), entityNow()]]),
|
|
190
|
+
shapeOf({}),
|
|
191
|
+
)
|
|
192
|
+
: attributed(op, () => writing(() => client().execute(removal(plan))));
|
|
193
|
+
|
|
175
194
|
/**
|
|
176
195
|
* The one insert path, for one row or ten thousand: the batch's own column list, split into as
|
|
177
196
|
* many statements as Postgres's bind count allows, and the rows the server stored. `insert(row)`
|
|
178
|
-
* comes through here too, so there is no second builder for the two to drift apart in
|
|
179
|
-
*
|
|
180
|
-
* the call in `withTransaction` rather than trusting one statement's atomicity.
|
|
197
|
+
* comes through here too, so there is no second builder for the two to drift apart in. All or
|
|
198
|
+
* nothing however many statements it takes (`bulk-write-send.ts`).
|
|
181
199
|
*/
|
|
182
200
|
const writeRows = async (
|
|
183
201
|
op: string,
|
|
@@ -193,25 +211,17 @@ export const postgresRepo = <Row>(
|
|
|
193
211
|
assertRowTenant(entity.$name, entity.$tenantColumn, op, row);
|
|
194
212
|
entity.$assert(row);
|
|
195
213
|
}
|
|
196
|
-
const columns = insertColumns(entity, namedProperties(entity, batch));
|
|
197
214
|
const bound = batch.map((row) => bindValues(entity, row));
|
|
198
|
-
const shape = {
|
|
199
|
-
|
|
215
|
+
const shape = {
|
|
216
|
+
columns: statementColumns(entity, batch),
|
|
217
|
+
...(conflict === undefined ? {} : { conflict }),
|
|
218
|
+
};
|
|
200
219
|
// Attributed here rather than in each of the three callers: a batch wide enough to split is
|
|
201
|
-
// several statements sent inside this
|
|
220
|
+
// several statements sent inside this call, and every one of them belongs to the call that
|
|
202
221
|
// asked for it — `insert`, `insertAll` or `upsertAll`, which is why the op is a parameter.
|
|
203
|
-
|
|
204
|
-
writing(
|
|
205
|
-
for (const chunk of insertChunks(bound, columns.length)) {
|
|
206
|
-
for (const row of await client().query<PhysicalRow>(
|
|
207
|
-
insertStatement(entity, chunk, shape),
|
|
208
|
-
)) {
|
|
209
|
-
written.push(decodeRow(entity, row));
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
}),
|
|
222
|
+
return attributed(op, () =>
|
|
223
|
+
writing(() => writeChunks(entity, bound, shape, client(), config.client)),
|
|
213
224
|
);
|
|
214
|
-
return written;
|
|
215
225
|
};
|
|
216
226
|
|
|
217
227
|
const repo: Repo<Row> = {
|
|
@@ -280,7 +290,8 @@ export const postgresRepo = <Row>(
|
|
|
280
290
|
|
|
281
291
|
async upsertAll(batch, args: UpsertArgs<Row>) {
|
|
282
292
|
const rows = narrowed(batch);
|
|
283
|
-
const
|
|
293
|
+
const { onConflict, onMatch = 'update', overwrites } = args;
|
|
294
|
+
const plan = upsertPlan(entity, rows, onConflict, onMatch, overwrites);
|
|
284
295
|
// Refused here, not by the server: a batch that repeats a conflict target is `21000` in
|
|
285
296
|
// Postgres and a silent overwrite in memory, and the two drivers have to mean one thing.
|
|
286
297
|
conflictKeys(entity, plan, rows);
|
|
@@ -290,7 +301,9 @@ export const postgresRepo = <Row>(
|
|
|
290
301
|
});
|
|
291
302
|
},
|
|
292
303
|
|
|
293
|
-
async update(id,
|
|
304
|
+
async update(id, given, options) {
|
|
305
|
+
// Here and in `memory-repo.ts`, the one door: a value `insert` refuses, a fragment included.
|
|
306
|
+
const patch = entity.$parsePatch(given);
|
|
294
307
|
const op = 'update';
|
|
295
308
|
const plan = idPlan(entity, id, options, op);
|
|
296
309
|
// The plan bounds WHICH row is written; the patch decides what it becomes, and a patch
|
|
@@ -321,7 +334,7 @@ export const postgresRepo = <Row>(
|
|
|
321
334
|
async delete(id, options) {
|
|
322
335
|
const op = 'delete';
|
|
323
336
|
const plan = idPlan(entity, id, options, op);
|
|
324
|
-
if ((await
|
|
337
|
+
if ((await removed(op, plan)) === 0) {
|
|
325
338
|
throw notFound(entity.$name, id);
|
|
326
339
|
}
|
|
327
340
|
},
|
|
@@ -338,10 +351,11 @@ export const postgresRepo = <Row>(
|
|
|
338
351
|
const op = 'deleteWhere';
|
|
339
352
|
// The plan is built before the write is announced: a refused filter never happened.
|
|
340
353
|
const plan = deletePlan(entity, filter, options, op);
|
|
341
|
-
return
|
|
354
|
+
return removed(op, plan);
|
|
342
355
|
},
|
|
343
356
|
|
|
344
|
-
async updateWhere(filter,
|
|
357
|
+
async updateWhere(filter, given, options) {
|
|
358
|
+
const patch = entity.$parsePatch(given);
|
|
345
359
|
const op = 'updateWhere';
|
|
346
360
|
const plan = updatePlan(entity, filter, patch, options, op);
|
|
347
361
|
// The same move, filtered: one statement can hand away every row it matches.
|
|
@@ -361,24 +375,7 @@ export const postgresRepo = <Row>(
|
|
|
361
375
|
const statement = updateStatement(entity, plan, values, shape, false);
|
|
362
376
|
return attributed(op, () => writing(() => client().execute(statement)));
|
|
363
377
|
}
|
|
364
|
-
|
|
365
|
-
// statement in the driver whose result size is the caller's filter and not a page. Counted
|
|
366
|
-
// BEFORE the write, because a refusal after `returning *` has already allocated the thing it
|
|
367
|
-
// is refusing; the count can drift by whatever a concurrent writer adds between the two,
|
|
368
|
-
// which moves a bound, never a decision.
|
|
369
|
-
const matched = await attributed(op, () =>
|
|
370
|
-
client().one<{ count: unknown }>(countStatement(entity, plan, shape)),
|
|
371
|
-
);
|
|
372
|
-
const rows = Number(matched?.count ?? 0);
|
|
373
|
-
if (rows > MAX_ASSERTED_ROWS) throw assertedRowsTooMany(entity.$name, op, rows);
|
|
374
|
-
const statement = updateStatement(entity, plan, values, shape, true);
|
|
375
|
-
return judged(async (send) => {
|
|
376
|
-
const written = await attributed(op, () =>
|
|
377
|
-
writing(() => send.query<PhysicalRow>(statement)),
|
|
378
|
-
);
|
|
379
|
-
for (const row of written) entity.$assert(decodeRow(entity, row));
|
|
380
|
-
return written.length;
|
|
381
|
-
});
|
|
378
|
+
return judgedRows(op, plan, values, shape);
|
|
382
379
|
},
|
|
383
380
|
|
|
384
381
|
async count(args = {}) {
|
|
@@ -390,92 +387,8 @@ export const postgresRepo = <Row>(
|
|
|
390
387
|
return Number(row?.count ?? 0);
|
|
391
388
|
},
|
|
392
389
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const grouped = groupColumnOf(entity, column, op);
|
|
396
|
-
const plan = readPlan(entity, args, op);
|
|
397
|
-
// One group past the bound, for the same reason a page reads one row past its limit: the
|
|
398
|
-
// presence of that group is what says the answer was never going to fit, and `countsFrom`
|
|
399
|
-
// refuses it rather than hand back a breakdown missing its tail.
|
|
400
|
-
const rows = await attributed(op, () =>
|
|
401
|
-
client().query<GroupRow>(
|
|
402
|
-
countByStatement(entity, plan, shapeOf(args), column, MAX_GROUPS + 1),
|
|
403
|
-
),
|
|
404
|
-
);
|
|
405
|
-
return countsFrom(
|
|
406
|
-
entity,
|
|
407
|
-
column,
|
|
408
|
-
op,
|
|
409
|
-
rows.map((row) => [groupValue(grouped, row.group_value), Number(row.group_count)] as const),
|
|
410
|
-
);
|
|
411
|
-
},
|
|
412
|
-
|
|
413
|
-
async aggregate(fn, column, args = {}) {
|
|
414
|
-
const op = fn;
|
|
415
|
-
const declared = aggregateColumnOf(entity, fn, column);
|
|
416
|
-
const plan = readPlan(entity, args, op);
|
|
417
|
-
const shape = shapeOf(args);
|
|
418
|
-
const money = declared.$meta.kind === 'money' ? moneyColumns(column, declared.$meta) : null;
|
|
419
|
-
// Money is judged BEFORE the aggregate is asked for, in its own statement: `sum(minor)` over
|
|
420
|
-
// two currencies is a number in neither, and the refusal has to name them.
|
|
421
|
-
const unit =
|
|
422
|
-
money === null
|
|
423
|
-
? undefined
|
|
424
|
-
: assertOneUnit(
|
|
425
|
-
entity,
|
|
426
|
-
fn,
|
|
427
|
-
column,
|
|
428
|
-
(
|
|
429
|
-
await attributed(op, () =>
|
|
430
|
-
client().query<MoneyUnitRow>(
|
|
431
|
-
currenciesStatement(entity, plan, shape, money.currency, money.scale),
|
|
432
|
-
),
|
|
433
|
-
)
|
|
434
|
-
).map((row) => ({
|
|
435
|
-
currency: String(row.group_value ?? '').trim(),
|
|
436
|
-
scale:
|
|
437
|
-
row.group_scale === null || row.group_scale === undefined
|
|
438
|
-
? null
|
|
439
|
-
: Number(row.group_scale),
|
|
440
|
-
})),
|
|
441
|
-
);
|
|
442
|
-
const row = await attributed(op, () =>
|
|
443
|
-
client().one<AggregateRow>(
|
|
444
|
-
aggregateStatement(entity, plan, shape, fn, money === null ? column : `${column}.minor`),
|
|
445
|
-
),
|
|
446
|
-
);
|
|
447
|
-
const text = row?.agg_value;
|
|
448
|
-
if (text === null || text === undefined) return null;
|
|
449
|
-
if (money === null) return decodeAggregate(fn, declared.$meta.kind, String(text));
|
|
450
|
-
if (unit === undefined) return null;
|
|
451
|
-
return {
|
|
452
|
-
minor: aggregateMinor(entity, fn, column, String(text)),
|
|
453
|
-
currency: unit.currency,
|
|
454
|
-
...(unit.scale === null ? {} : { scale: unit.scale }),
|
|
455
|
-
};
|
|
456
|
-
},
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* `reltuples`, the planner's own estimate — one row out of `pg_class`, constant time, and the
|
|
460
|
-
* only answer that stays constant time as the table grows. `count(*)` walks every visible row
|
|
461
|
-
* because MVCC gives it no shortcut, so past a few million it is the read that trips a web
|
|
462
|
-
* role's `statement_timeout`, and no index can help: `X_DB_STATEMENT_TIMEOUT`'s fix names one
|
|
463
|
-
* anyway, and following it changes nothing.
|
|
464
|
-
*/
|
|
465
|
-
async approximateCount(args = {}) {
|
|
466
|
-
const op = 'approximateCount';
|
|
467
|
-
// Built and therefore GUARDED: tenancy still applies, and a filtered chain is refused here
|
|
468
|
-
// rather than answered with the whole table's estimate.
|
|
469
|
-
readPlan(entity, args, op);
|
|
470
|
-
const row = await attributed(op, () =>
|
|
471
|
-
client().one<{ estimate: unknown }>(estimateStatement(entity.$table)),
|
|
472
|
-
);
|
|
473
|
-
const estimate = Number(row?.estimate ?? -1);
|
|
474
|
-
// `-1` is what Postgres 14+ stores for a table nobody has analysed. That is an absence of an
|
|
475
|
-
// estimate, not an estimate of zero, and answering `0` would read exactly like an empty
|
|
476
|
-
// table to every caller.
|
|
477
|
-
return Number.isFinite(estimate) && estimate >= 0 ? estimate : null;
|
|
478
|
-
},
|
|
390
|
+
...postgresAggregates(entity, client),
|
|
391
|
+
...postgresLocks(entity, config.client, client),
|
|
479
392
|
};
|
|
480
393
|
// The one sealing seam, shared with `memoryRepo()`: sealed on the way in, opened on the way out.
|
|
481
394
|
// Append-only outermost, as there: a refused rewrite seals nothing and sends no statement.
|
package/src/pg-lock.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Single responsibility: `lock` and `lockKey` on Postgres. A transaction, through the driver's own
|
|
2
|
+
// transactor, so nested in `withTransaction` it is a savepoint and the lock lives to the root COMMIT.
|
|
3
|
+
// No wait option: the bound is the role's `statement_timeout`, and a deadlock the server breaks is
|
|
4
|
+
// `X_DB_SERIALIZATION_FAILURE` through the funnel like any other statement.
|
|
5
|
+
|
|
6
|
+
import { type DbClient, type SqlFragment, sql, withStatementAttribution } from '@ultimat3/db';
|
|
7
|
+
import type { EntityCore } from './entity';
|
|
8
|
+
import { notFound } from './errors';
|
|
9
|
+
import { lockKeyInput } from './lock-key';
|
|
10
|
+
import { decodeRow, type PhysicalRow } from './pg-row';
|
|
11
|
+
import { selectStatement } from './pg-sql';
|
|
12
|
+
import { postgresTransactor } from './pg-transactor';
|
|
13
|
+
import { idPlan } from './plan';
|
|
14
|
+
import type { Repo } from './repo';
|
|
15
|
+
|
|
16
|
+
/** The read path's own statement, so tenant and soft delete are its own, `for update` after it. */
|
|
17
|
+
const lockedRow = <Row>(entity: EntityCore<Row>, plan: ReturnType<typeof idPlan>): SqlFragment =>
|
|
18
|
+
sql`${selectStatement(entity, plan, { includeDeleted: false }, 1)} for update`;
|
|
19
|
+
|
|
20
|
+
/** Scoped to the transaction: COMMIT and ROLLBACK release it, and a dropped connection does too. */
|
|
21
|
+
const advisoryLock = (input: string): SqlFragment =>
|
|
22
|
+
sql`select pg_advisory_xact_lock(hashtextextended(${input}::text, 0))`;
|
|
23
|
+
|
|
24
|
+
export const postgresLocks = <Row>(
|
|
25
|
+
entity: EntityCore<Row>,
|
|
26
|
+
pinned: DbClient | undefined,
|
|
27
|
+
client: () => DbClient,
|
|
28
|
+
): Required<Pick<Repo<Row>, 'lock' | 'lockKey'>> => ({
|
|
29
|
+
async lock(id, fn, options) {
|
|
30
|
+
const plan = idPlan(entity, id, options, 'lock');
|
|
31
|
+
return postgresTransactor(pinned === undefined ? {} : { client: pinned }).run(async (tx) => {
|
|
32
|
+
const found = await withStatementAttribution(entity.$name, 'lock', () =>
|
|
33
|
+
client().query<PhysicalRow>(lockedRow(entity, plan)),
|
|
34
|
+
);
|
|
35
|
+
const [row] = found;
|
|
36
|
+
if (row === undefined) throw notFound(entity.$name, id);
|
|
37
|
+
return fn(decodeRow(entity, row), tx);
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async lockKey(key, fn, options) {
|
|
42
|
+
const input = lockKeyInput(entity, key, options);
|
|
43
|
+
return postgresTransactor(pinned === undefined ? {} : { client: pinned }).run(async (tx) => {
|
|
44
|
+
await withStatementAttribution(entity.$name, 'lockKey', () =>
|
|
45
|
+
client().query(advisoryLock(input)),
|
|
46
|
+
);
|
|
47
|
+
return fn(tx);
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
});
|
package/src/pg-row.ts
CHANGED
|
@@ -189,14 +189,41 @@ const cellOf = (entityName: string, property: string, column: AnyColumn, value:
|
|
|
189
189
|
);
|
|
190
190
|
};
|
|
191
191
|
|
|
192
|
+
/** One column as the decoder reads it: the property, its column, and its physical names. */
|
|
193
|
+
type DecodeStep = readonly [
|
|
194
|
+
property: string,
|
|
195
|
+
column: AnyColumn,
|
|
196
|
+
head: string | undefined,
|
|
197
|
+
currency: string | undefined,
|
|
198
|
+
scale: string | undefined,
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The physical names, derived once per column map. Column names never change after `entity()`,
|
|
203
|
+
* and deriving them per row was most of a list's CPU: `snake` is a regex replace, and it ran once
|
|
204
|
+
* per column per row (measured 2026-11-06: 35.6% self time of a 100-row list). Weak, so a test's
|
|
205
|
+
* throwaway entity is not held by it.
|
|
206
|
+
*/
|
|
207
|
+
const DECODE_PLANS = new WeakMap<object, readonly DecodeStep[]>();
|
|
208
|
+
|
|
209
|
+
const decodePlan = (columns: EntityCore<unknown>['$columns']): readonly DecodeStep[] => {
|
|
210
|
+
const known = DECODE_PLANS.get(columns);
|
|
211
|
+
if (known !== undefined) return known;
|
|
212
|
+
const steps = Object.entries(columns).map(([property, column]): DecodeStep => {
|
|
213
|
+
const [head, currency, scale] = columnsOf(property, column);
|
|
214
|
+
return [property, column, head, currency, scale];
|
|
215
|
+
});
|
|
216
|
+
DECODE_PLANS.set(columns, steps);
|
|
217
|
+
return steps;
|
|
218
|
+
};
|
|
219
|
+
|
|
192
220
|
/**
|
|
193
221
|
* Physical row -> entity row. A column the projection left out is left out here too, so a
|
|
194
222
|
* `select` narrows the object as well as the statement.
|
|
195
223
|
*/
|
|
196
224
|
export const decodeRow = <Row>(entity: EntityCore<Row>, source: PhysicalRow): Row => {
|
|
197
225
|
const row: Record<string, unknown> = {};
|
|
198
|
-
for (const [property, column] of
|
|
199
|
-
const [head, currency, scale] = columnsOf(property, column);
|
|
226
|
+
for (const [property, column, head, currency, scale] of decodePlan(entity.$columns)) {
|
|
200
227
|
if (head === undefined || !(head in source)) continue;
|
|
201
228
|
// Decided by the column's KIND and never by how many names came back: a money column whose
|
|
202
229
|
// table has no scale column projects two names, and reading that as a non-money column handed
|
package/src/pg-sql-aggregate.ts
CHANGED
|
@@ -130,3 +130,34 @@ export interface MoneyUnitRow {
|
|
|
130
130
|
*/
|
|
131
131
|
export const estimateStatement = (table: string): SqlFragment =>
|
|
132
132
|
sql`select reltuples::bigint as estimate from pg_class where oid = to_regclass(${table})`;
|
|
133
|
+
|
|
134
|
+
/** What a series comes back as. Every name is fixed, so none can be a column's. */
|
|
135
|
+
export interface SeriesRow {
|
|
136
|
+
readonly bucket_ms: unknown;
|
|
137
|
+
readonly bucket_count: unknown;
|
|
138
|
+
readonly bucket_value: unknown;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* One row per bucket over exactly the rows `countStatement` would have counted with an instant in
|
|
143
|
+
* `on`: the three-argument `date_trunc`, with the unit AND the zone bound — a two-argument one
|
|
144
|
+
* truncates in the session's zone, which is the bug this statement exists to make unwritable. The
|
|
145
|
+
* bucket crosses as epoch milliseconds, as `min`/`max` over an instant does, never as session text
|
|
146
|
+
* — and as a `numeric`, not its text, so `group by 1 order by 1` orders instants, not strings.
|
|
147
|
+
* `limit` bounds the buckets, so a refusal costs one row more than the ceiling.
|
|
148
|
+
*/
|
|
149
|
+
export const seriesStatement = <Row>(
|
|
150
|
+
entity: EntityCore<Row>,
|
|
151
|
+
plan: QueryPlan,
|
|
152
|
+
shape: ReadShape,
|
|
153
|
+
series: { readonly on: string; readonly unit: string; readonly timeZone: string },
|
|
154
|
+
sumColumn: string | null,
|
|
155
|
+
limit: number,
|
|
156
|
+
): SqlFragment => {
|
|
157
|
+
const on = columnRef(entity, series.on);
|
|
158
|
+
const bucket = sql`date_trunc(${series.unit}::text, ${on}, ${series.timeZone}::text)`;
|
|
159
|
+
const value = sumColumn === null ? sql`null` : sql`sum(${columnRef(entity, sumColumn)})::text`;
|
|
160
|
+
return sql`select extract(epoch from ${bucket}) * 1000 as bucket_ms, count(*) as bucket_count, ${value} as bucket_value from ${identifier(
|
|
161
|
+
entity.$table,
|
|
162
|
+
)} where ${conditions(entity, plan, shape)} and ${on} is not null group by 1 order by 1 limit ${limit}`;
|
|
163
|
+
};
|
package/src/pg-sql.ts
CHANGED
|
@@ -56,11 +56,27 @@ const searchSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFragm
|
|
|
56
56
|
return sql`${column} @@ websearch_to_tsquery(${raw(`'${vector.language}'`)}, ${String(term)})`;
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The path a predicate's column resolves through. A NULL test on a whole money property asks about
|
|
61
|
+
* its amount, the one thing that is NULL when the money is: memory answers it off the property, and
|
|
62
|
+
* resolving the property here refused it. An amount compared whole stays refused: no single column
|
|
63
|
+
* holds one, so naming a part is the caller's to do.
|
|
64
|
+
*/
|
|
65
|
+
const nullTestPath = <Row>(entity: EntityCore<Row>, predicate: Predicate): string => {
|
|
66
|
+
const asksNull =
|
|
67
|
+
predicate.op === 'is-null' ||
|
|
68
|
+
predicate.op === 'is-not-null' ||
|
|
69
|
+
((predicate.op === 'eq' || predicate.op === 'neq') && predicate.value === null);
|
|
70
|
+
return asksNull && columnFor(entity.$columns, predicate.column)?.$meta.kind === 'money'
|
|
71
|
+
? `${predicate.column}.minor`
|
|
72
|
+
: predicate.column;
|
|
73
|
+
};
|
|
74
|
+
|
|
59
75
|
const predicateSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFragment => {
|
|
60
76
|
// BEFORE the column is resolved: a `matches` predicate names `SEARCH_PROPERTY`, which is not a
|
|
61
77
|
// column and must never be looked up as one.
|
|
62
78
|
if (predicate.op === 'matches') return searchSql(entity, predicate);
|
|
63
|
-
const column = columnRef(entity, predicate
|
|
79
|
+
const column = columnRef(entity, nullTestPath(entity, predicate));
|
|
64
80
|
const value = predicate.value;
|
|
65
81
|
switch (predicate.op) {
|
|
66
82
|
case 'eq':
|