@ultimat3/entity 1.1.0 → 2.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 +614 -0
- package/README.md +391 -11
- package/package.json +5 -4
- package/src/batch-read.ts +134 -0
- package/src/batch.ts +125 -0
- package/src/bulk-write.ts +285 -0
- package/src/coalesce.ts +175 -0
- package/src/column.ts +24 -0
- package/src/columns.ts +183 -35
- package/src/count-by.ts +148 -0
- package/src/cross-tenant.ts +76 -0
- package/src/cursor.ts +17 -3
- package/src/database.ts +35 -2
- package/src/describe.ts +82 -37
- package/src/entity.ts +41 -12
- package/src/errors.ts +346 -5
- package/src/expr.ts +65 -15
- package/src/index.ts +49 -4
- package/src/invariants.ts +56 -14
- package/src/jit-preload.ts +216 -0
- package/src/n-plus-one.ts +122 -0
- package/src/pg-driver.ts +281 -33
- package/src/pg-row.ts +32 -7
- package/src/pg-sql.ts +117 -8
- package/src/plan.ts +130 -20
- package/src/preload.ts +184 -0
- package/src/query.ts +231 -27
- package/src/registry.ts +63 -5
- package/src/relations.ts +212 -0
- package/src/repo.ts +226 -12
- package/src/tenancy.ts +194 -12
- package/src/type-pins.ts +311 -0
- package/src/types.ts +64 -12
- package/src/view.ts +8 -2
package/src/pg-driver.ts
CHANGED
|
@@ -7,24 +7,48 @@
|
|
|
7
7
|
// being told — which is how `ctx.jobs.enqueue()` lands its outbox row atomically with the write
|
|
8
8
|
// that caused it. `RepoOptions.tx` is the in-memory driver's undo hook and is ignored here.
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { systemClock } from '@ultimat3/core';
|
|
11
|
+
import {
|
|
12
|
+
currentTx,
|
|
13
|
+
type DbClient,
|
|
14
|
+
db,
|
|
15
|
+
type SqlFragment,
|
|
16
|
+
type TransactionOptions,
|
|
17
|
+
withStatementAttribution,
|
|
18
|
+
withTransaction,
|
|
19
|
+
} from '@ultimat3/db';
|
|
20
|
+
import {
|
|
21
|
+
conflictKeys,
|
|
22
|
+
insertChunks,
|
|
23
|
+
insertColumns,
|
|
24
|
+
namedProperties,
|
|
25
|
+
upsertPlan,
|
|
26
|
+
} from './bulk-write';
|
|
27
|
+
import { coalesceFindById } from './coalesce';
|
|
11
28
|
import { snake } from './column';
|
|
29
|
+
import { countsFrom, groupColumnOf, groupValue, MAX_GROUPS } from './count-by';
|
|
12
30
|
import { cursorFor, seekFrom, valueAt } from './cursor';
|
|
13
31
|
import type { Driver } from './database';
|
|
14
32
|
import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
15
|
-
import { notFound } from './errors';
|
|
33
|
+
import { notFound, repoClientPinned } from './errors';
|
|
34
|
+
import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
|
|
35
|
+
import { forgetPreloaded, tagSiblings } from './jit-preload';
|
|
16
36
|
import { bindValues, decodeRow, type PhysicalRow } from './pg-row';
|
|
17
37
|
import {
|
|
38
|
+
type ConflictTarget,
|
|
39
|
+
countByStatement,
|
|
18
40
|
countStatement,
|
|
19
41
|
deleteStatement,
|
|
42
|
+
type GroupRow,
|
|
20
43
|
insertStatement,
|
|
21
44
|
type ReadShape,
|
|
22
45
|
selectStatement,
|
|
23
46
|
updateStatement,
|
|
24
47
|
} from './pg-sql';
|
|
25
|
-
import { idPlan, readPlan } from './plan';
|
|
26
|
-
import type { FindManyArgs, Repo, Transactor } from './repo';
|
|
48
|
+
import { deletePlan, idPlan, readPlan, updatePlan } from './plan';
|
|
49
|
+
import type { FindManyArgs, Repo, Transactor, UpsertArgs } from './repo';
|
|
27
50
|
import type { QueryPlan } from './tenancy';
|
|
51
|
+
import { assertRowTenant } from './tenancy';
|
|
28
52
|
|
|
29
53
|
export interface PostgresDriverOptions {
|
|
30
54
|
/**
|
|
@@ -33,6 +57,12 @@ export interface PostgresDriverOptions {
|
|
|
33
57
|
* globally with `setDbClient()`.
|
|
34
58
|
*/
|
|
35
59
|
readonly client?: DbClient | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* Preload foreign keys resolved by a page into a request-scoped cache, so the first
|
|
62
|
+
* `findById` for any one of them resolves that key for the whole page in one statement.
|
|
63
|
+
* Default: true.
|
|
64
|
+
*/
|
|
65
|
+
readonly jitPreload?: boolean | undefined;
|
|
36
66
|
}
|
|
37
67
|
|
|
38
68
|
const shapeOf = (args: FindManyArgs, seek?: readonly unknown[]): ReadShape => ({
|
|
@@ -44,7 +74,41 @@ export const postgresRepo = <Row>(
|
|
|
44
74
|
entity: EntityCore<Row>,
|
|
45
75
|
config: PostgresDriverOptions = {},
|
|
46
76
|
): Repo<Row> => {
|
|
47
|
-
|
|
77
|
+
/**
|
|
78
|
+
* The one place a connection is chosen, which is why the transaction guard is here and not on
|
|
79
|
+
* each method. Unpinned, `db()` answers with the open transaction when there is one — that is
|
|
80
|
+
* how a repository call inside `withTransaction` joins it without being told. Pinned, it cannot:
|
|
81
|
+
* `withTransaction` ran `BEGIN` on a connection IT reserved, and a statement sent straight to
|
|
82
|
+
* `config.client` takes a different connection out of the pool, so the write commits whatever
|
|
83
|
+
* the transaction decides and the read cannot see what the transaction has written. Refused
|
|
84
|
+
* rather than resolved — a `DbTx` does not name the client it was opened on, so this layer
|
|
85
|
+
* cannot even tell whether the two are the same database.
|
|
86
|
+
*/
|
|
87
|
+
const client = (): DbClient => {
|
|
88
|
+
const pinned = config.client;
|
|
89
|
+
if (pinned === undefined) return db();
|
|
90
|
+
if (currentTx() !== undefined) throw repoClientPinned(entity.$name);
|
|
91
|
+
return pinned;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Every statement a repository call sends carries the entity and the operation that compiled it,
|
|
96
|
+
* which is what turns "50× `select … where "id" = $1`" into "50× `findById` on `members`" in a
|
|
97
|
+
* diagnostic. This is the last frame that knows both: below it there is only SQL.
|
|
98
|
+
*
|
|
99
|
+
* It wraps the whole call rather than the `client()` handle because the statement is often sent
|
|
100
|
+
* well below — inside the coalescer's microtask flush, inside a chunked write, inside the
|
|
101
|
+
* preload's `readByIds` — and threading a parameter through all of them is the same fact written
|
|
102
|
+
* five times. The scope is an `AsyncLocalStorage` in `@ultimat3/db` (tier 1, downward), so it
|
|
103
|
+
* survives every one of those awaits, and with no observer installed it is not entered at all:
|
|
104
|
+
* one property read and one branch on the production path (axiom 6).
|
|
105
|
+
*
|
|
106
|
+
* `op` is the string the plan builder was already given, named once per method so the operation
|
|
107
|
+
* a refusal reports and the operation a diagnostic reports cannot drift.
|
|
108
|
+
*/
|
|
109
|
+
const attributed = <T>(op: string, send: () => Promise<T>): Promise<T> =>
|
|
110
|
+
withStatementAttribution(entity.$name, op, send);
|
|
111
|
+
|
|
48
112
|
const idOf = (row: Row): string =>
|
|
49
113
|
entity.$primaryKey.map((property) => String(valueAt(row, property))).join('');
|
|
50
114
|
|
|
@@ -55,20 +119,112 @@ export const postgresRepo = <Row>(
|
|
|
55
119
|
return found === undefined ? null : decodeRow(entity, found);
|
|
56
120
|
};
|
|
57
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Hard or soft, decided once so `delete(id)` and `deleteWhere(filter)` cannot drift apart. Soft
|
|
124
|
+
* delete hides the row without losing it; the column's presence is the switch, and `shapeOf({})`
|
|
125
|
+
* keeps the `deleted_at is null` clause so a second call cannot move an existing stamp forward.
|
|
126
|
+
*
|
|
127
|
+
* No `returning`: both callers send this through `execute()` and read a count, so the rows would
|
|
128
|
+
* be a whole tenant's table crossing the wire for nobody — the same cost `updateWhere` used to
|
|
129
|
+
* pay unconditionally, on the sibling path that looked like the one doing it right.
|
|
130
|
+
*/
|
|
131
|
+
const removal = (plan: QueryPlan): SqlFragment =>
|
|
132
|
+
entity.$softDelete
|
|
133
|
+
? updateStatement(
|
|
134
|
+
entity,
|
|
135
|
+
plan,
|
|
136
|
+
new Map([[snake(SOFT_DELETE_COLUMN), systemClock.now()]]),
|
|
137
|
+
shapeOf({}),
|
|
138
|
+
false,
|
|
139
|
+
)
|
|
140
|
+
: deleteStatement(entity, plan);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Every write goes out through here, which makes it the ONE place the request's preloaded rows
|
|
144
|
+
* are dropped: a row this statement changes must not be served afterwards from a page read
|
|
145
|
+
* before it. Before the statement, not after — a row read back afterwards is the row this write
|
|
146
|
+
* left, and one read concurrently with it was concurrent either way.
|
|
147
|
+
*/
|
|
148
|
+
const writing = <T>(send: () => Promise<T>): Promise<T> => {
|
|
149
|
+
forgetPreloaded(entity.$name);
|
|
150
|
+
return send();
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The one insert path, for one row or ten thousand: the batch's own column list, split into as
|
|
155
|
+
* many statements as Postgres's bind count allows, and the rows the server stored. `insert(row)`
|
|
156
|
+
* comes through here too, so there is no second builder for the two to drift apart in — and a
|
|
157
|
+
* batch wide enough to split is several statements, which is why an all-or-nothing caller wraps
|
|
158
|
+
* the call in `withTransaction` rather than trusting one statement's atomicity.
|
|
159
|
+
*/
|
|
160
|
+
const writeRows = async (
|
|
161
|
+
op: string,
|
|
162
|
+
batch: readonly Row[],
|
|
163
|
+
conflict: ConflictTarget | undefined,
|
|
164
|
+
): Promise<readonly Row[]> => {
|
|
165
|
+
if (batch.length === 0) return [];
|
|
166
|
+
// The whole batch is judged before any statement exists, tenant and invariants together, in
|
|
167
|
+
// the same loop and for the same reason: Postgres refuses one statement as one, so a row this
|
|
168
|
+
// actor may not write must stop the rows beside it too. `memoryRepo`'s `write` runs the same
|
|
169
|
+
// pair — an insert builds no read plan, so this is the only place the tenant is checked.
|
|
170
|
+
for (const row of batch) {
|
|
171
|
+
assertRowTenant(entity.$name, entity.$tenantColumn, op, row);
|
|
172
|
+
entity.$assert(row);
|
|
173
|
+
}
|
|
174
|
+
const columns = insertColumns(entity, namedProperties(entity, batch));
|
|
175
|
+
const bound = batch.map((row) => bindValues(entity, row));
|
|
176
|
+
const shape = { columns, ...(conflict === undefined ? {} : { conflict }) };
|
|
177
|
+
const written: Row[] = [];
|
|
178
|
+
// Attributed here rather than in each of the three callers: a batch wide enough to split is
|
|
179
|
+
// several statements sent inside this loop, and every one of them belongs to the call that
|
|
180
|
+
// asked for it — `insert`, `insertAll` or `upsertAll`, which is why the op is a parameter.
|
|
181
|
+
await attributed(op, () =>
|
|
182
|
+
writing(async () => {
|
|
183
|
+
for (const chunk of insertChunks(bound, columns.length)) {
|
|
184
|
+
for (const row of await client().query<PhysicalRow>(
|
|
185
|
+
insertStatement(entity, chunk, shape),
|
|
186
|
+
)) {
|
|
187
|
+
written.push(decodeRow(entity, row));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
return written;
|
|
193
|
+
};
|
|
194
|
+
|
|
58
195
|
return {
|
|
59
196
|
async findById(id, options) {
|
|
60
|
-
const
|
|
61
|
-
|
|
197
|
+
const op = 'findById';
|
|
198
|
+
const plan = idPlan(entity, id, options, op);
|
|
199
|
+
const args = options ?? {};
|
|
200
|
+
// Every point lookup a request issues in one microtask is one statement: a page that
|
|
201
|
+
// resolves an author per row pays for one round trip, not one per row. The statement is the
|
|
202
|
+
// one this call would have sent — same scope, same soft-delete filter, `in` instead of `=` —
|
|
203
|
+
// and with no request in scope (a job, a script) it is exactly that statement, alone.
|
|
204
|
+
//
|
|
205
|
+
// The batch is flushed from a microtask scheduled inside this scope, so the statement one
|
|
206
|
+
// lookup sends for fifty carries the pair every one of those fifty would have carried.
|
|
207
|
+
return attributed(
|
|
208
|
+
op,
|
|
209
|
+
() => coalesceFindById(entity, client(), plan, shapeOf(args), id) ?? one(plan, args),
|
|
210
|
+
);
|
|
62
211
|
},
|
|
63
212
|
|
|
64
213
|
async findMany(args = {}) {
|
|
65
|
-
const
|
|
214
|
+
const op = 'findMany';
|
|
215
|
+
const plan = readPlan(entity, args, op);
|
|
66
216
|
// One row past the page: the presence of that row is what says there is a next cursor,
|
|
67
217
|
// and it costs one row instead of a second `count(*)` over the same predicate.
|
|
68
|
-
const found = await
|
|
69
|
-
|
|
218
|
+
const found = await attributed(op, () =>
|
|
219
|
+
client().query<PhysicalRow>(
|
|
220
|
+
selectStatement(entity, plan, shapeOf(args, seekFrom(entity, plan)), plan.limit + 1),
|
|
221
|
+
),
|
|
70
222
|
);
|
|
71
223
|
const rows = found.slice(0, plan.limit).map((row) => decodeRow(entity, row));
|
|
224
|
+
// What the page leaves behind: its foreign key values, so the first `findById` for any one
|
|
225
|
+
// of them resolves the key for the whole page. A `for … of` loop over these rows costs one
|
|
226
|
+
// statement, not one per row — its `await` already ended the coalescing window.
|
|
227
|
+
if (config.jitPreload !== false) tagSiblings(entity, rows);
|
|
72
228
|
const last = rows.at(-1);
|
|
73
229
|
return {
|
|
74
230
|
rows,
|
|
@@ -80,24 +236,47 @@ export const postgresRepo = <Row>(
|
|
|
80
236
|
},
|
|
81
237
|
|
|
82
238
|
async insert(values) {
|
|
83
|
-
|
|
84
|
-
const written = await client().one<PhysicalRow>(
|
|
85
|
-
insertStatement(entity, bindValues(entity, values)),
|
|
86
|
-
);
|
|
239
|
+
const [written] = await writeRows('insert', [values], undefined);
|
|
87
240
|
// `returning *` is the row Postgres actually stored, defaults included.
|
|
88
|
-
return written
|
|
241
|
+
return written ?? values;
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
async insertAll(batch) {
|
|
245
|
+
return writeRows('insertAll', batch, undefined);
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
async upsertAll(batch, args: UpsertArgs<Row>) {
|
|
249
|
+
const plan = upsertPlan(entity, batch, args.onConflict, args.onMatch ?? 'update');
|
|
250
|
+
// Refused here, not by the server: a batch that repeats a conflict target is `21000` in
|
|
251
|
+
// Postgres and a silent overwrite in memory, and the two drivers have to mean one thing.
|
|
252
|
+
conflictKeys(entity, plan, batch);
|
|
253
|
+
return writeRows('upsertAll', batch, {
|
|
254
|
+
columns: insertColumns(entity, plan.on),
|
|
255
|
+
set: insertColumns(entity, plan.set),
|
|
256
|
+
});
|
|
89
257
|
},
|
|
90
258
|
|
|
91
259
|
async update(id, patch, options) {
|
|
92
|
-
const
|
|
260
|
+
const op = 'update';
|
|
261
|
+
const plan = idPlan(entity, id, options, op);
|
|
262
|
+
// The plan bounds WHICH row is written; the patch decides what it becomes, and a patch
|
|
263
|
+
// naming another tenant would hand this row away — a leak out of the actor's tenant that no
|
|
264
|
+
// predicate can refuse.
|
|
265
|
+
assertRowTenant(entity.$name, entity.$tenantColumn, op, patch);
|
|
93
266
|
const values = bindValues(entity, patch);
|
|
267
|
+
// The read an empty patch degrades to is still this call: attributed to `update`, because
|
|
268
|
+
// that is the line an author would go and change.
|
|
94
269
|
if (values.size === 0) {
|
|
95
|
-
const current = await one(plan, options ?? {});
|
|
270
|
+
const current = await attributed(op, () => one(plan, options ?? {}));
|
|
96
271
|
if (current === null) throw notFound(entity.$name, id);
|
|
97
272
|
return current;
|
|
98
273
|
}
|
|
99
|
-
const written = await
|
|
100
|
-
|
|
274
|
+
const written = await attributed(op, () =>
|
|
275
|
+
writing(() =>
|
|
276
|
+
client().one<PhysicalRow>(
|
|
277
|
+
updateStatement(entity, plan, values, shapeOf(options ?? {}), true),
|
|
278
|
+
),
|
|
279
|
+
),
|
|
101
280
|
);
|
|
102
281
|
if (written === null) throw notFound(entity.$name, id);
|
|
103
282
|
const after = decodeRow(entity, written);
|
|
@@ -109,26 +288,95 @@ export const postgresRepo = <Row>(
|
|
|
109
288
|
},
|
|
110
289
|
|
|
111
290
|
async delete(id, options) {
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
291
|
+
const op = 'delete';
|
|
292
|
+
const plan = idPlan(entity, id, options, op);
|
|
293
|
+
if ((await attributed(op, () => writing(() => client().execute(removal(plan))))) === 0) {
|
|
294
|
+
throw notFound(entity.$name, id);
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
// No `X_NOT_FOUND` here: a filter that matches nothing is a fact the caller asked for, not a
|
|
299
|
+
// failed address. The count is the answer, which is why it is a `number` and not `void`.
|
|
300
|
+
//
|
|
301
|
+
// `deleteWhere`/`updateWhere` are the bulk forms of `delete(id)`/`update(id, patch)` — same
|
|
302
|
+
// role `insertAll`/`upsertAll` play for a per-row insert loop, one statement instead of one
|
|
303
|
+
// per row. They are also the *only* filtered writes a composite-key entity has: `likes`,
|
|
304
|
+
// `blocks`, `participants`, any join table has no single-column id for `delete`/`update` to
|
|
305
|
+
// address, so without this pair such an entity would be create-only.
|
|
306
|
+
async deleteWhere(filter, options) {
|
|
307
|
+
const op = 'deleteWhere';
|
|
308
|
+
// The plan is built before the write is announced: a refused filter never happened.
|
|
309
|
+
const plan = deletePlan(entity, filter, options, op);
|
|
310
|
+
return attributed(op, () => writing(() => client().execute(removal(plan))));
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
async updateWhere(filter, patch, options) {
|
|
314
|
+
const op = 'updateWhere';
|
|
315
|
+
const plan = updatePlan(entity, filter, patch, options, op);
|
|
316
|
+
// The same move, filtered: one statement can hand away every row it matches.
|
|
317
|
+
assertRowTenant(entity.$name, entity.$tenantColumn, op, patch);
|
|
318
|
+
const values = bindValues(entity, patch);
|
|
319
|
+
// `shapeOf({})` keeps the `deleted_at is null` clause `update(id, patch)` already carries,
|
|
320
|
+
// so a soft-deleted row is not silently patched back into shape.
|
|
321
|
+
const shape = shapeOf({});
|
|
322
|
+
// The rows come back only when something here can still refuse them. A `check` or a `unique`
|
|
323
|
+
// invariant is a constraint Postgres enforced on the statement itself, so the answer is
|
|
324
|
+
// already a count; only a JS-only rule (`kind: 'assert'`, `sql: null`) has to be judged on
|
|
325
|
+
// the result. Unconditional, this was `returning *` on every filtered write in the framework
|
|
326
|
+
// — a GDPR sweep patching twelve million rows streamed all twelve million into the process,
|
|
327
|
+
// decoded each one, asserted nothing about any of them, and answered with a number.
|
|
328
|
+
// `deleteWhere` was the tell: same predicate, same table, `execute()` and a count.
|
|
329
|
+
if (!hasJsOnlyInvariant(entity.$invariants)) {
|
|
330
|
+
const statement = updateStatement(entity, plan, values, shape, false);
|
|
331
|
+
return attributed(op, () => writing(() => client().execute(statement)));
|
|
332
|
+
}
|
|
333
|
+
// Rows ARE needed, so how many is a memory bound rather than a detail — this is the one
|
|
334
|
+
// statement in the driver whose result size is the caller's filter and not a page. Counted
|
|
335
|
+
// BEFORE the write, because a refusal after `returning *` has already allocated the thing it
|
|
336
|
+
// is refusing; the count can drift by whatever a concurrent writer adds between the two,
|
|
337
|
+
// which moves a bound, never a decision.
|
|
338
|
+
const matched = await attributed(op, () =>
|
|
339
|
+
client().one<{ count: unknown }>(countStatement(entity, plan, shape)),
|
|
340
|
+
);
|
|
341
|
+
const rows = Number(matched?.count ?? 0);
|
|
342
|
+
if (rows > MAX_ASSERTED_ROWS) throw assertedRowsTooMany(entity.$name, op, rows);
|
|
343
|
+
const statement = updateStatement(entity, plan, values, shape, true);
|
|
344
|
+
// Inside `withTransaction` a failed assert takes the whole statement with it.
|
|
345
|
+
const written = await attributed(op, () =>
|
|
346
|
+
writing(() => client().query<PhysicalRow>(statement)),
|
|
347
|
+
);
|
|
348
|
+
for (const row of written) entity.$assert(decodeRow(entity, row));
|
|
349
|
+
return written.length;
|
|
123
350
|
},
|
|
124
351
|
|
|
125
352
|
async count(args = {}) {
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
|
|
353
|
+
const op = 'count';
|
|
354
|
+
const plan = readPlan(entity, args, op);
|
|
355
|
+
const row = await attributed(op, () =>
|
|
356
|
+
client().one<{ count: unknown }>(countStatement(entity, plan, shapeOf(args))),
|
|
129
357
|
);
|
|
130
358
|
return Number(row?.count ?? 0);
|
|
131
359
|
},
|
|
360
|
+
|
|
361
|
+
async countBy(column, args = {}) {
|
|
362
|
+
const op = 'countBy';
|
|
363
|
+
const grouped = groupColumnOf(entity, column, op);
|
|
364
|
+
const plan = readPlan(entity, args, op);
|
|
365
|
+
// One group past the bound, for the same reason a page reads one row past its limit: the
|
|
366
|
+
// presence of that group is what says the answer was never going to fit, and `countsFrom`
|
|
367
|
+
// refuses it rather than hand back a breakdown missing its tail.
|
|
368
|
+
const rows = await attributed(op, () =>
|
|
369
|
+
client().query<GroupRow>(
|
|
370
|
+
countByStatement(entity, plan, shapeOf(args), column, MAX_GROUPS + 1),
|
|
371
|
+
),
|
|
372
|
+
);
|
|
373
|
+
return countsFrom(
|
|
374
|
+
entity,
|
|
375
|
+
column,
|
|
376
|
+
op,
|
|
377
|
+
rows.map((row) => [groupValue(grouped, row.group_value), Number(row.group_count)] as const),
|
|
378
|
+
);
|
|
379
|
+
},
|
|
132
380
|
};
|
|
133
381
|
};
|
|
134
382
|
|
package/src/pg-row.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// as a string, timestamptz may arrive as one, and a silent `NaN` is worse than a loud throw.
|
|
6
6
|
|
|
7
7
|
import { snake } from './column';
|
|
8
|
+
import { narrowMoney } from './columns';
|
|
8
9
|
import type { EntityCore } from './entity';
|
|
9
10
|
import { invariantViolated } from './errors';
|
|
10
11
|
import type { AnyColumn, MoneyValue } from './types';
|
|
@@ -13,10 +14,18 @@ export type PhysicalRow = Readonly<Record<string, unknown>>;
|
|
|
13
14
|
|
|
14
15
|
const MONEY_PARTS = new Set(['minor', 'currency']);
|
|
15
16
|
|
|
16
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* `price` -> `price_minor`, `price_currency`, `price_scale`. Everything else is one snake_case
|
|
19
|
+
* column.
|
|
20
|
+
*
|
|
21
|
+
* The scale column is nullable and is the only one of the three an amount may omit: `null` means
|
|
22
|
+
* "the currency's own minor unit", which is every row written before the column existed. It is NOT
|
|
23
|
+
* addressable as a predicate or a sort key (`MONEY_PARTS` below, and `cursor.ts`'s copy) — a scale
|
|
24
|
+
* says which units `minor` counts, so ordering or filtering by it compares two different questions.
|
|
25
|
+
*/
|
|
17
26
|
export const columnsOf = (property: string, column: AnyColumn): readonly string[] =>
|
|
18
27
|
column.$meta.kind === 'money'
|
|
19
|
-
? [`${snake(property)}_minor`, `${snake(property)}_currency`]
|
|
28
|
+
? [`${snake(property)}_minor`, `${snake(property)}_currency`, `${snake(property)}_scale`]
|
|
20
29
|
: [snake(property)];
|
|
21
30
|
|
|
22
31
|
/**
|
|
@@ -61,7 +70,9 @@ export const bindValues = <Row>(
|
|
|
61
70
|
values: Partial<Row>,
|
|
62
71
|
): ReadonlyMap<string, unknown> => {
|
|
63
72
|
const bound = new Map<string, unknown>();
|
|
64
|
-
|
|
73
|
+
// `MoneyInput` lets a writer hand a `bigint`; the row type is `MoneyValue`. `memoryRepo` calls
|
|
74
|
+
// the same narrowing before it stores, so what the two drivers write is one value.
|
|
75
|
+
const record = narrowMoney(entity.$columns, values) as Readonly<Record<string, unknown>>;
|
|
65
76
|
for (const [property, column] of Object.entries(entity.$columns)) {
|
|
66
77
|
if (!Object.hasOwn(record, property)) continue;
|
|
67
78
|
const value = record[property];
|
|
@@ -72,14 +83,25 @@ export const bindValues = <Row>(
|
|
|
72
83
|
const money = value as MoneyValue | null | undefined;
|
|
73
84
|
bound.set(`${snake(property)}_minor`, money?.minor ?? null);
|
|
74
85
|
bound.set(`${snake(property)}_currency`, money?.currency ?? null);
|
|
86
|
+
// `?? null` and not `!== undefined`: an amount at the currency's own scale carries no key at
|
|
87
|
+
// all, and that absence is what the nullable column stores. A `0` written here for it would
|
|
88
|
+
// claim whole units — a 100x reinterpretation of every ordinary price.
|
|
89
|
+
bound.set(`${snake(property)}_scale`, money?.scale ?? null);
|
|
75
90
|
}
|
|
76
91
|
return bound;
|
|
77
92
|
};
|
|
78
93
|
|
|
79
|
-
const moneyOf = (source: PhysicalRow, minor: string, currency: string): unknown => {
|
|
94
|
+
const moneyOf = (source: PhysicalRow, minor: string, currency: string, scale: string): unknown => {
|
|
80
95
|
const amount = source[minor];
|
|
81
96
|
if (amount === null || amount === undefined) return null;
|
|
82
|
-
|
|
97
|
+
// A column the projection left out is absent, not null — and absent must read as "no scale"
|
|
98
|
+
// exactly as a stored NULL does, so both take the same branch.
|
|
99
|
+
const declared = source[scale];
|
|
100
|
+
return {
|
|
101
|
+
minor: amount,
|
|
102
|
+
currency: String(source[currency] ?? '').trim(),
|
|
103
|
+
...(declared === null || declared === undefined ? {} : { scale: declared }),
|
|
104
|
+
};
|
|
83
105
|
};
|
|
84
106
|
|
|
85
107
|
/**
|
|
@@ -89,9 +111,12 @@ const moneyOf = (source: PhysicalRow, minor: string, currency: string): unknown
|
|
|
89
111
|
export const decodeRow = <Row>(entity: EntityCore<Row>, source: PhysicalRow): Row => {
|
|
90
112
|
const row: Record<string, unknown> = {};
|
|
91
113
|
for (const [property, column] of Object.entries(entity.$columns)) {
|
|
92
|
-
const [head,
|
|
114
|
+
const [head, currency, scale] = columnsOf(property, column);
|
|
93
115
|
if (head === undefined || !(head in source)) continue;
|
|
94
|
-
const value =
|
|
116
|
+
const value =
|
|
117
|
+
currency === undefined || scale === undefined
|
|
118
|
+
? source[head]
|
|
119
|
+
: moneyOf(source, head, currency, scale);
|
|
95
120
|
if (value !== null && value !== undefined) {
|
|
96
121
|
row[property] = column.$parse(value);
|
|
97
122
|
continue;
|
package/src/pg-sql.ts
CHANGED
|
@@ -55,6 +55,34 @@ const predicateSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFr
|
|
|
55
55
|
}
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* A cursor's timestamp is the row's own value FLOORED: a `Date` holds milliseconds and a
|
|
60
|
+
* `timestamptz` column holds microseconds, so `created_at > '…123'` is satisfied by the very row
|
|
61
|
+
* at `…123456` the cursor was minted from — the same row, returned again, on every page boundary.
|
|
62
|
+
* Under `desc` the same gap does the opposite and silently drops every row inside that
|
|
63
|
+
* millisecond, which no `id` tiebreak can recover because the first `or` term never matched.
|
|
64
|
+
*
|
|
65
|
+
* So a timestamp seek compares against the millisecond WINDOW its value stands for — what
|
|
66
|
+
* `date_trunc('milliseconds', …)` would say, spelled as a half-open range so the column stays
|
|
67
|
+
* bare and an index can still range-scan it. `timestamptz` is the only sort kind revived as a
|
|
68
|
+
* `Date` (`cursor.ts`), so the type test IS the kind test.
|
|
69
|
+
*/
|
|
70
|
+
const nextMillisecond = (value: Date): Date => new Date(value.getTime() + 1);
|
|
71
|
+
|
|
72
|
+
/** Strictly past the cursor's position in this key's direction. */
|
|
73
|
+
const seekAfter = (column: SqlFragment, direction: string, value: unknown): SqlFragment => {
|
|
74
|
+
if (direction === 'desc') return sql`${column} < ${value}`;
|
|
75
|
+
// `>= v + 1ms` is `trunc(col) > v`; `< v` already is `trunc(col) < v`, so only asc moves.
|
|
76
|
+
if (value instanceof Date) return sql`${column} >= ${nextMillisecond(value)}`;
|
|
77
|
+
return sql`${column} > ${value}`;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** At the cursor's position for this key — the prefix a later key's tiebreak hangs off. */
|
|
81
|
+
const seekEqual = (column: SqlFragment, value: unknown): SqlFragment =>
|
|
82
|
+
value instanceof Date
|
|
83
|
+
? sql`(${column} >= ${value} and ${column} < ${nextMillisecond(value)})`
|
|
84
|
+
: sql`${column} = ${value}`;
|
|
85
|
+
|
|
58
86
|
/**
|
|
59
87
|
* The keyset seek, spelled out rather than as a row comparison: `(a, b) > (x, y)` requires every
|
|
60
88
|
* key to sort the same way, and a listing that is `published_at desc, id asc` does not.
|
|
@@ -67,10 +95,9 @@ const seekSql = <Row>(
|
|
|
67
95
|
const terms = orderBy.map((entry, index) => {
|
|
68
96
|
const equal = orderBy
|
|
69
97
|
.slice(0, index)
|
|
70
|
-
.map((earlier, position) =>
|
|
71
|
-
const after = raw(entry.direction === 'desc' ? '<' : '>');
|
|
98
|
+
.map((earlier, position) => seekEqual(columnRef(entity, earlier.column), seek[position]));
|
|
72
99
|
return sql`(${join(
|
|
73
|
-
[...equal,
|
|
100
|
+
[...equal, seekAfter(columnRef(entity, entry.column), entry.direction, seek[index])],
|
|
74
101
|
' and ',
|
|
75
102
|
)})`;
|
|
76
103
|
});
|
|
@@ -135,25 +162,107 @@ export const countStatement = <Row>(
|
|
|
135
162
|
): SqlFragment =>
|
|
136
163
|
sql`select count(*) as count from ${identifier(entity.$table)} where ${conditions(entity, plan, shape)}`;
|
|
137
164
|
|
|
165
|
+
/** What a grouped count comes back as. Both names are fixed, so neither can be a column's. */
|
|
166
|
+
export interface GroupRow {
|
|
167
|
+
readonly group_value: unknown;
|
|
168
|
+
readonly group_count: unknown;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The grouped count: one row per distinct value of one column, over exactly the rows
|
|
173
|
+
* `countStatement` would have counted — the same predicates, the same soft-delete filter, one
|
|
174
|
+
* `group by` more. `limit` bounds the groups, not the rows, which is what turns a whole-table
|
|
175
|
+
* breakdown into a refusal instead of a result set nobody sized.
|
|
176
|
+
*
|
|
177
|
+
* Both output names are aliases and fixed, so they cannot collide with each other whatever the
|
|
178
|
+
* table declares: an entity is free to have a column called `count`, and the un-aliased form would
|
|
179
|
+
* then return two outputs of one name.
|
|
180
|
+
*/
|
|
181
|
+
export const countByStatement = <Row>(
|
|
182
|
+
entity: EntityCore<Row>,
|
|
183
|
+
plan: QueryPlan,
|
|
184
|
+
shape: ReadShape,
|
|
185
|
+
column: string,
|
|
186
|
+
limit: number,
|
|
187
|
+
): SqlFragment => {
|
|
188
|
+
const grouped = columnRef(entity, column);
|
|
189
|
+
return sql`select ${grouped} as group_value, count(*) as group_count from ${identifier(
|
|
190
|
+
entity.$table,
|
|
191
|
+
)} where ${conditions(entity, plan, shape)} group by ${grouped} limit ${limit}`;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/** `on conflict (…) do update set …`, or `do nothing` when there is nothing to overwrite. */
|
|
195
|
+
export interface ConflictTarget {
|
|
196
|
+
/** Physical columns of the unique index a collision is judged against. */
|
|
197
|
+
readonly columns: readonly string[];
|
|
198
|
+
/** Physical columns a colliding row takes from the incoming one. Empty is `do nothing`. */
|
|
199
|
+
readonly set: readonly string[];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface InsertShape {
|
|
203
|
+
/** Every physical column written — one list, shared by every row of the statement. */
|
|
204
|
+
readonly columns: readonly string[];
|
|
205
|
+
/** How a collision resolves. Absent, it is the caller's error, exactly as it is for one row. */
|
|
206
|
+
readonly conflict?: ConflictTarget | undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The cell of a row that did not name this column. `default` is the second and last `raw()` in
|
|
211
|
+
* this file and, like `asc|desc` above it, a closed set of one word: it is what makes a row inside
|
|
212
|
+
* a many-row `insert` mean what the same row means on its own, where an unnamed column is simply
|
|
213
|
+
* left out. The seek operator used to be a third — it is chosen in TypeScript now
|
|
214
|
+
* (`seekAfter`/`seekEqual`), because a timestamp seek is not one operator.
|
|
215
|
+
*/
|
|
216
|
+
const DEFAULT_CELL = raw('default');
|
|
217
|
+
|
|
218
|
+
const conflictSql = (conflict: ConflictTarget): SqlFragment => {
|
|
219
|
+
const target = join(conflict.columns.map(identifier));
|
|
220
|
+
return conflict.set.length === 0
|
|
221
|
+
? sql` on conflict (${target}) do nothing`
|
|
222
|
+
: sql` on conflict (${target}) do update set ${join(
|
|
223
|
+
conflict.set.map((column) => sql`${identifier(column)} = excluded.${identifier(column)}`),
|
|
224
|
+
)}`;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* One statement for any number of rows. A single row compiles to exactly the text it always did,
|
|
229
|
+
* which is the point: `insertAll([row])` and `insert(row)` are one code path, so there is no
|
|
230
|
+
* second insert builder for the two to drift apart in.
|
|
231
|
+
*/
|
|
138
232
|
export const insertStatement = <Row>(
|
|
139
233
|
entity: EntityCore<Row>,
|
|
140
|
-
|
|
234
|
+
rows: readonly ReadonlyMap<string, unknown>[],
|
|
235
|
+
shape: InsertShape,
|
|
141
236
|
): SqlFragment => {
|
|
142
|
-
const
|
|
237
|
+
const tuples = rows.map(
|
|
238
|
+
(row) =>
|
|
239
|
+
sql`(${join(
|
|
240
|
+
shape.columns.map((column) => (row.has(column) ? sql`${row.get(column)}` : DEFAULT_CELL)),
|
|
241
|
+
)})`,
|
|
242
|
+
);
|
|
243
|
+
const conflict = shape.conflict === undefined ? sql`` : conflictSql(shape.conflict);
|
|
143
244
|
return sql`insert into ${identifier(entity.$table)} (${join(
|
|
144
|
-
|
|
145
|
-
)}) values
|
|
245
|
+
shape.columns.map(identifier),
|
|
246
|
+
)}) values ${join(tuples)}${conflict} returning *`;
|
|
146
247
|
};
|
|
147
248
|
|
|
249
|
+
/**
|
|
250
|
+
* `returning` is a parameter and has no default, because the three callers want three different
|
|
251
|
+
* answers and the wrong one is not visible in the result: `update(id, patch)` needs the stored row,
|
|
252
|
+
* a soft delete and a filtered write need a count, and `returning *` on a filtered write over a
|
|
253
|
+
* whole tenant streams every matched row into the process for nobody to read. A default would make
|
|
254
|
+
* that the quiet case.
|
|
255
|
+
*/
|
|
148
256
|
export const updateStatement = <Row>(
|
|
149
257
|
entity: EntityCore<Row>,
|
|
150
258
|
plan: QueryPlan,
|
|
151
259
|
values: ReadonlyMap<string, unknown>,
|
|
152
260
|
shape: ReadShape,
|
|
261
|
+
returning: boolean,
|
|
153
262
|
): SqlFragment =>
|
|
154
263
|
sql`update ${identifier(entity.$table)} set ${join(
|
|
155
264
|
[...values].map(([column, value]) => sql`${identifier(column)} = ${value}`),
|
|
156
|
-
)} where ${conditions(entity, plan, shape)} returning
|
|
265
|
+
)} where ${conditions(entity, plan, shape)}${returning ? sql` returning *` : sql``}`;
|
|
157
266
|
|
|
158
267
|
/** Only reached when the entity has no soft-delete column, so there is no filter to apply. */
|
|
159
268
|
export const deleteStatement = <Row>(entity: EntityCore<Row>, plan: QueryPlan): SqlFragment =>
|