@ultimat3/entity 1.2.0 → 3.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 +694 -0
- package/README.md +467 -16
- package/package.json +6 -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 +189 -0
- package/src/column.ts +91 -2
- package/src/columns-data.ts +205 -0
- package/src/columns.ts +228 -38
- 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 +121 -39
- package/src/entity.ts +65 -20
- package/src/errors.ts +346 -5
- package/src/expr.ts +65 -15
- package/src/index.ts +72 -7
- 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 +282 -35
- package/src/pg-row.ts +87 -16
- package/src/pg-sql.ts +156 -13
- 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/seed.ts +288 -19
- package/src/tenancy.ts +194 -12
- package/src/type-pins.ts +311 -0
- package/src/types.ts +114 -12
- package/src/view.ts +8 -2
package/src/pg-driver.ts
CHANGED
|
@@ -7,24 +7,47 @@
|
|
|
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 {
|
|
11
|
-
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';
|
|
28
|
+
import { countsFrom, groupColumnOf, groupValue, MAX_GROUPS } from './count-by';
|
|
12
29
|
import { cursorFor, seekFrom, valueAt } from './cursor';
|
|
13
30
|
import type { Driver } from './database';
|
|
14
31
|
import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
15
|
-
import { notFound } from './errors';
|
|
16
|
-
import {
|
|
32
|
+
import { notFound, repoClientPinned } from './errors';
|
|
33
|
+
import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
|
|
34
|
+
import { forgetPreloaded, tagSiblings } from './jit-preload';
|
|
35
|
+
import { bindValues, decodeRow, type PhysicalRow, physicalName } from './pg-row';
|
|
17
36
|
import {
|
|
37
|
+
type ConflictTarget,
|
|
38
|
+
countByStatement,
|
|
18
39
|
countStatement,
|
|
19
40
|
deleteStatement,
|
|
41
|
+
type GroupRow,
|
|
20
42
|
insertStatement,
|
|
21
43
|
type ReadShape,
|
|
22
44
|
selectStatement,
|
|
23
45
|
updateStatement,
|
|
24
46
|
} from './pg-sql';
|
|
25
|
-
import { idPlan, readPlan } from './plan';
|
|
26
|
-
import type { FindManyArgs, Repo, Transactor } from './repo';
|
|
47
|
+
import { deletePlan, idPlan, readPlan, updatePlan } from './plan';
|
|
48
|
+
import type { FindManyArgs, Repo, Transactor, UpsertArgs } from './repo';
|
|
27
49
|
import type { QueryPlan } from './tenancy';
|
|
50
|
+
import { assertRowTenant } from './tenancy';
|
|
28
51
|
|
|
29
52
|
export interface PostgresDriverOptions {
|
|
30
53
|
/**
|
|
@@ -33,6 +56,12 @@ export interface PostgresDriverOptions {
|
|
|
33
56
|
* globally with `setDbClient()`.
|
|
34
57
|
*/
|
|
35
58
|
readonly client?: DbClient | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* Preload foreign keys resolved by a page into a request-scoped cache, so the first
|
|
61
|
+
* `findById` for any one of them resolves that key for the whole page in one statement.
|
|
62
|
+
* Default: true.
|
|
63
|
+
*/
|
|
64
|
+
readonly jitPreload?: boolean | undefined;
|
|
36
65
|
}
|
|
37
66
|
|
|
38
67
|
const shapeOf = (args: FindManyArgs, seek?: readonly unknown[]): ReadShape => ({
|
|
@@ -44,7 +73,41 @@ export const postgresRepo = <Row>(
|
|
|
44
73
|
entity: EntityCore<Row>,
|
|
45
74
|
config: PostgresDriverOptions = {},
|
|
46
75
|
): Repo<Row> => {
|
|
47
|
-
|
|
76
|
+
/**
|
|
77
|
+
* The one place a connection is chosen, which is why the transaction guard is here and not on
|
|
78
|
+
* each method. Unpinned, `db()` answers with the open transaction when there is one — that is
|
|
79
|
+
* how a repository call inside `withTransaction` joins it without being told. Pinned, it cannot:
|
|
80
|
+
* `withTransaction` ran `BEGIN` on a connection IT reserved, and a statement sent straight to
|
|
81
|
+
* `config.client` takes a different connection out of the pool, so the write commits whatever
|
|
82
|
+
* the transaction decides and the read cannot see what the transaction has written. Refused
|
|
83
|
+
* rather than resolved — a `DbTx` does not name the client it was opened on, so this layer
|
|
84
|
+
* cannot even tell whether the two are the same database.
|
|
85
|
+
*/
|
|
86
|
+
const client = (): DbClient => {
|
|
87
|
+
const pinned = config.client;
|
|
88
|
+
if (pinned === undefined) return db();
|
|
89
|
+
if (currentTx() !== undefined) throw repoClientPinned(entity.$name);
|
|
90
|
+
return pinned;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Every statement a repository call sends carries the entity and the operation that compiled it,
|
|
95
|
+
* which is what turns "50× `select … where "id" = $1`" into "50× `findById` on `members`" in a
|
|
96
|
+
* diagnostic. This is the last frame that knows both: below it there is only SQL.
|
|
97
|
+
*
|
|
98
|
+
* It wraps the whole call rather than the `client()` handle because the statement is often sent
|
|
99
|
+
* well below — inside the coalescer's microtask flush, inside a chunked write, inside the
|
|
100
|
+
* preload's `readByIds` — and threading a parameter through all of them is the same fact written
|
|
101
|
+
* five times. The scope is an `AsyncLocalStorage` in `@ultimat3/db` (tier 1, downward), so it
|
|
102
|
+
* survives every one of those awaits, and with no observer installed it is not entered at all:
|
|
103
|
+
* one property read and one branch on the production path (axiom 6).
|
|
104
|
+
*
|
|
105
|
+
* `op` is the string the plan builder was already given, named once per method so the operation
|
|
106
|
+
* a refusal reports and the operation a diagnostic reports cannot drift.
|
|
107
|
+
*/
|
|
108
|
+
const attributed = <T>(op: string, send: () => Promise<T>): Promise<T> =>
|
|
109
|
+
withStatementAttribution(entity.$name, op, send);
|
|
110
|
+
|
|
48
111
|
const idOf = (row: Row): string =>
|
|
49
112
|
entity.$primaryKey.map((property) => String(valueAt(row, property))).join('');
|
|
50
113
|
|
|
@@ -55,20 +118,112 @@ export const postgresRepo = <Row>(
|
|
|
55
118
|
return found === undefined ? null : decodeRow(entity, found);
|
|
56
119
|
};
|
|
57
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Hard or soft, decided once so `delete(id)` and `deleteWhere(filter)` cannot drift apart. Soft
|
|
123
|
+
* delete hides the row without losing it; the column's presence is the switch, and `shapeOf({})`
|
|
124
|
+
* keeps the `deleted_at is null` clause so a second call cannot move an existing stamp forward.
|
|
125
|
+
*
|
|
126
|
+
* No `returning`: both callers send this through `execute()` and read a count, so the rows would
|
|
127
|
+
* be a whole tenant's table crossing the wire for nobody — the same cost `updateWhere` used to
|
|
128
|
+
* pay unconditionally, on the sibling path that looked like the one doing it right.
|
|
129
|
+
*/
|
|
130
|
+
const removal = (plan: QueryPlan): SqlFragment =>
|
|
131
|
+
entity.$softDelete
|
|
132
|
+
? updateStatement(
|
|
133
|
+
entity,
|
|
134
|
+
plan,
|
|
135
|
+
new Map([[physicalName(entity, SOFT_DELETE_COLUMN), systemClock.now()]]),
|
|
136
|
+
shapeOf({}),
|
|
137
|
+
false,
|
|
138
|
+
)
|
|
139
|
+
: deleteStatement(entity, plan);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Every write goes out through here, which makes it the ONE place the request's preloaded rows
|
|
143
|
+
* are dropped: a row this statement changes must not be served afterwards from a page read
|
|
144
|
+
* before it. Before the statement, not after — a row read back afterwards is the row this write
|
|
145
|
+
* left, and one read concurrently with it was concurrent either way.
|
|
146
|
+
*/
|
|
147
|
+
const writing = <T>(send: () => Promise<T>): Promise<T> => {
|
|
148
|
+
forgetPreloaded(entity.$name);
|
|
149
|
+
return send();
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The one insert path, for one row or ten thousand: the batch's own column list, split into as
|
|
154
|
+
* many statements as Postgres's bind count allows, and the rows the server stored. `insert(row)`
|
|
155
|
+
* comes through here too, so there is no second builder for the two to drift apart in — and a
|
|
156
|
+
* batch wide enough to split is several statements, which is why an all-or-nothing caller wraps
|
|
157
|
+
* the call in `withTransaction` rather than trusting one statement's atomicity.
|
|
158
|
+
*/
|
|
159
|
+
const writeRows = async (
|
|
160
|
+
op: string,
|
|
161
|
+
batch: readonly Row[],
|
|
162
|
+
conflict: ConflictTarget | undefined,
|
|
163
|
+
): Promise<readonly Row[]> => {
|
|
164
|
+
if (batch.length === 0) return [];
|
|
165
|
+
// The whole batch is judged before any statement exists, tenant and invariants together, in
|
|
166
|
+
// the same loop and for the same reason: Postgres refuses one statement as one, so a row this
|
|
167
|
+
// actor may not write must stop the rows beside it too. `memoryRepo`'s `write` runs the same
|
|
168
|
+
// pair — an insert builds no read plan, so this is the only place the tenant is checked.
|
|
169
|
+
for (const row of batch) {
|
|
170
|
+
assertRowTenant(entity.$name, entity.$tenantColumn, op, row);
|
|
171
|
+
entity.$assert(row);
|
|
172
|
+
}
|
|
173
|
+
const columns = insertColumns(entity, namedProperties(entity, batch));
|
|
174
|
+
const bound = batch.map((row) => bindValues(entity, row));
|
|
175
|
+
const shape = { columns, ...(conflict === undefined ? {} : { conflict }) };
|
|
176
|
+
const written: Row[] = [];
|
|
177
|
+
// Attributed here rather than in each of the three callers: a batch wide enough to split is
|
|
178
|
+
// several statements sent inside this loop, and every one of them belongs to the call that
|
|
179
|
+
// asked for it — `insert`, `insertAll` or `upsertAll`, which is why the op is a parameter.
|
|
180
|
+
await attributed(op, () =>
|
|
181
|
+
writing(async () => {
|
|
182
|
+
for (const chunk of insertChunks(bound, columns.length)) {
|
|
183
|
+
for (const row of await client().query<PhysicalRow>(
|
|
184
|
+
insertStatement(entity, chunk, shape),
|
|
185
|
+
)) {
|
|
186
|
+
written.push(decodeRow(entity, row));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
return written;
|
|
192
|
+
};
|
|
193
|
+
|
|
58
194
|
return {
|
|
59
195
|
async findById(id, options) {
|
|
60
|
-
const
|
|
61
|
-
|
|
196
|
+
const op = 'findById';
|
|
197
|
+
const plan = idPlan(entity, id, options, op);
|
|
198
|
+
const args = options ?? {};
|
|
199
|
+
// Every point lookup a request issues in one microtask is one statement: a page that
|
|
200
|
+
// resolves an author per row pays for one round trip, not one per row. The statement is the
|
|
201
|
+
// one this call would have sent — same scope, same soft-delete filter, `in` instead of `=` —
|
|
202
|
+
// and with no request in scope (a job, a script) it is exactly that statement, alone.
|
|
203
|
+
//
|
|
204
|
+
// The batch is flushed from a microtask scheduled inside this scope, so the statement one
|
|
205
|
+
// lookup sends for fifty carries the pair every one of those fifty would have carried.
|
|
206
|
+
return attributed(
|
|
207
|
+
op,
|
|
208
|
+
() => coalesceFindById(entity, client(), plan, shapeOf(args), id) ?? one(plan, args),
|
|
209
|
+
);
|
|
62
210
|
},
|
|
63
211
|
|
|
64
212
|
async findMany(args = {}) {
|
|
65
|
-
const
|
|
213
|
+
const op = 'findMany';
|
|
214
|
+
const plan = readPlan(entity, args, op);
|
|
66
215
|
// One row past the page: the presence of that row is what says there is a next cursor,
|
|
67
216
|
// and it costs one row instead of a second `count(*)` over the same predicate.
|
|
68
|
-
const found = await
|
|
69
|
-
|
|
217
|
+
const found = await attributed(op, () =>
|
|
218
|
+
client().query<PhysicalRow>(
|
|
219
|
+
selectStatement(entity, plan, shapeOf(args, seekFrom(entity, plan)), plan.limit + 1),
|
|
220
|
+
),
|
|
70
221
|
);
|
|
71
222
|
const rows = found.slice(0, plan.limit).map((row) => decodeRow(entity, row));
|
|
223
|
+
// What the page leaves behind: its foreign key values, so the first `findById` for any one
|
|
224
|
+
// of them resolves the key for the whole page. A `for … of` loop over these rows costs one
|
|
225
|
+
// statement, not one per row — its `await` already ended the coalescing window.
|
|
226
|
+
if (config.jitPreload !== false) tagSiblings(entity, rows);
|
|
72
227
|
const last = rows.at(-1);
|
|
73
228
|
return {
|
|
74
229
|
rows,
|
|
@@ -80,24 +235,47 @@ export const postgresRepo = <Row>(
|
|
|
80
235
|
},
|
|
81
236
|
|
|
82
237
|
async insert(values) {
|
|
83
|
-
|
|
84
|
-
const written = await client().one<PhysicalRow>(
|
|
85
|
-
insertStatement(entity, bindValues(entity, values)),
|
|
86
|
-
);
|
|
238
|
+
const [written] = await writeRows('insert', [values], undefined);
|
|
87
239
|
// `returning *` is the row Postgres actually stored, defaults included.
|
|
88
|
-
return written
|
|
240
|
+
return written ?? values;
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async insertAll(batch) {
|
|
244
|
+
return writeRows('insertAll', batch, undefined);
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
async upsertAll(batch, args: UpsertArgs<Row>) {
|
|
248
|
+
const plan = upsertPlan(entity, batch, args.onConflict, args.onMatch ?? 'update');
|
|
249
|
+
// Refused here, not by the server: a batch that repeats a conflict target is `21000` in
|
|
250
|
+
// Postgres and a silent overwrite in memory, and the two drivers have to mean one thing.
|
|
251
|
+
conflictKeys(entity, plan, batch);
|
|
252
|
+
return writeRows('upsertAll', batch, {
|
|
253
|
+
columns: insertColumns(entity, plan.on),
|
|
254
|
+
set: insertColumns(entity, plan.set),
|
|
255
|
+
});
|
|
89
256
|
},
|
|
90
257
|
|
|
91
258
|
async update(id, patch, options) {
|
|
92
|
-
const
|
|
259
|
+
const op = 'update';
|
|
260
|
+
const plan = idPlan(entity, id, options, op);
|
|
261
|
+
// The plan bounds WHICH row is written; the patch decides what it becomes, and a patch
|
|
262
|
+
// naming another tenant would hand this row away — a leak out of the actor's tenant that no
|
|
263
|
+
// predicate can refuse.
|
|
264
|
+
assertRowTenant(entity.$name, entity.$tenantColumn, op, patch);
|
|
93
265
|
const values = bindValues(entity, patch);
|
|
266
|
+
// The read an empty patch degrades to is still this call: attributed to `update`, because
|
|
267
|
+
// that is the line an author would go and change.
|
|
94
268
|
if (values.size === 0) {
|
|
95
|
-
const current = await one(plan, options ?? {});
|
|
269
|
+
const current = await attributed(op, () => one(plan, options ?? {}));
|
|
96
270
|
if (current === null) throw notFound(entity.$name, id);
|
|
97
271
|
return current;
|
|
98
272
|
}
|
|
99
|
-
const written = await
|
|
100
|
-
|
|
273
|
+
const written = await attributed(op, () =>
|
|
274
|
+
writing(() =>
|
|
275
|
+
client().one<PhysicalRow>(
|
|
276
|
+
updateStatement(entity, plan, values, shapeOf(options ?? {}), true),
|
|
277
|
+
),
|
|
278
|
+
),
|
|
101
279
|
);
|
|
102
280
|
if (written === null) throw notFound(entity.$name, id);
|
|
103
281
|
const after = decodeRow(entity, written);
|
|
@@ -109,26 +287,95 @@ export const postgresRepo = <Row>(
|
|
|
109
287
|
},
|
|
110
288
|
|
|
111
289
|
async delete(id, options) {
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
290
|
+
const op = 'delete';
|
|
291
|
+
const plan = idPlan(entity, id, options, op);
|
|
292
|
+
if ((await attributed(op, () => writing(() => client().execute(removal(plan))))) === 0) {
|
|
293
|
+
throw notFound(entity.$name, id);
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
// No `X_NOT_FOUND` here: a filter that matches nothing is a fact the caller asked for, not a
|
|
298
|
+
// failed address. The count is the answer, which is why it is a `number` and not `void`.
|
|
299
|
+
//
|
|
300
|
+
// `deleteWhere`/`updateWhere` are the bulk forms of `delete(id)`/`update(id, patch)` — same
|
|
301
|
+
// role `insertAll`/`upsertAll` play for a per-row insert loop, one statement instead of one
|
|
302
|
+
// per row. They are also the *only* filtered writes a composite-key entity has: `likes`,
|
|
303
|
+
// `blocks`, `participants`, any join table has no single-column id for `delete`/`update` to
|
|
304
|
+
// address, so without this pair such an entity would be create-only.
|
|
305
|
+
async deleteWhere(filter, options) {
|
|
306
|
+
const op = 'deleteWhere';
|
|
307
|
+
// The plan is built before the write is announced: a refused filter never happened.
|
|
308
|
+
const plan = deletePlan(entity, filter, options, op);
|
|
309
|
+
return attributed(op, () => writing(() => client().execute(removal(plan))));
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
async updateWhere(filter, patch, options) {
|
|
313
|
+
const op = 'updateWhere';
|
|
314
|
+
const plan = updatePlan(entity, filter, patch, options, op);
|
|
315
|
+
// The same move, filtered: one statement can hand away every row it matches.
|
|
316
|
+
assertRowTenant(entity.$name, entity.$tenantColumn, op, patch);
|
|
317
|
+
const values = bindValues(entity, patch);
|
|
318
|
+
// `shapeOf({})` keeps the `deleted_at is null` clause `update(id, patch)` already carries,
|
|
319
|
+
// so a soft-deleted row is not silently patched back into shape.
|
|
320
|
+
const shape = shapeOf({});
|
|
321
|
+
// The rows come back only when something here can still refuse them. A `check` or a `unique`
|
|
322
|
+
// invariant is a constraint Postgres enforced on the statement itself, so the answer is
|
|
323
|
+
// already a count; only a JS-only rule (`kind: 'assert'`, `sql: null`) has to be judged on
|
|
324
|
+
// the result. Unconditional, this was `returning *` on every filtered write in the framework
|
|
325
|
+
// — a GDPR sweep patching twelve million rows streamed all twelve million into the process,
|
|
326
|
+
// decoded each one, asserted nothing about any of them, and answered with a number.
|
|
327
|
+
// `deleteWhere` was the tell: same predicate, same table, `execute()` and a count.
|
|
328
|
+
if (!hasJsOnlyInvariant(entity.$invariants)) {
|
|
329
|
+
const statement = updateStatement(entity, plan, values, shape, false);
|
|
330
|
+
return attributed(op, () => writing(() => client().execute(statement)));
|
|
331
|
+
}
|
|
332
|
+
// Rows ARE needed, so how many is a memory bound rather than a detail — this is the one
|
|
333
|
+
// statement in the driver whose result size is the caller's filter and not a page. Counted
|
|
334
|
+
// BEFORE the write, because a refusal after `returning *` has already allocated the thing it
|
|
335
|
+
// is refusing; the count can drift by whatever a concurrent writer adds between the two,
|
|
336
|
+
// which moves a bound, never a decision.
|
|
337
|
+
const matched = await attributed(op, () =>
|
|
338
|
+
client().one<{ count: unknown }>(countStatement(entity, plan, shape)),
|
|
339
|
+
);
|
|
340
|
+
const rows = Number(matched?.count ?? 0);
|
|
341
|
+
if (rows > MAX_ASSERTED_ROWS) throw assertedRowsTooMany(entity.$name, op, rows);
|
|
342
|
+
const statement = updateStatement(entity, plan, values, shape, true);
|
|
343
|
+
// Inside `withTransaction` a failed assert takes the whole statement with it.
|
|
344
|
+
const written = await attributed(op, () =>
|
|
345
|
+
writing(() => client().query<PhysicalRow>(statement)),
|
|
346
|
+
);
|
|
347
|
+
for (const row of written) entity.$assert(decodeRow(entity, row));
|
|
348
|
+
return written.length;
|
|
123
349
|
},
|
|
124
350
|
|
|
125
351
|
async count(args = {}) {
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
|
|
352
|
+
const op = 'count';
|
|
353
|
+
const plan = readPlan(entity, args, op);
|
|
354
|
+
const row = await attributed(op, () =>
|
|
355
|
+
client().one<{ count: unknown }>(countStatement(entity, plan, shapeOf(args))),
|
|
129
356
|
);
|
|
130
357
|
return Number(row?.count ?? 0);
|
|
131
358
|
},
|
|
359
|
+
|
|
360
|
+
async countBy(column, args = {}) {
|
|
361
|
+
const op = 'countBy';
|
|
362
|
+
const grouped = groupColumnOf(entity, column, op);
|
|
363
|
+
const plan = readPlan(entity, args, op);
|
|
364
|
+
// One group past the bound, for the same reason a page reads one row past its limit: the
|
|
365
|
+
// presence of that group is what says the answer was never going to fit, and `countsFrom`
|
|
366
|
+
// refuses it rather than hand back a breakdown missing its tail.
|
|
367
|
+
const rows = await attributed(op, () =>
|
|
368
|
+
client().query<GroupRow>(
|
|
369
|
+
countByStatement(entity, plan, shapeOf(args), column, MAX_GROUPS + 1),
|
|
370
|
+
),
|
|
371
|
+
);
|
|
372
|
+
return countsFrom(
|
|
373
|
+
entity,
|
|
374
|
+
column,
|
|
375
|
+
op,
|
|
376
|
+
rows.map((row) => [groupValue(grouped, row.group_value), Number(row.group_count)] as const),
|
|
377
|
+
);
|
|
378
|
+
},
|
|
132
379
|
};
|
|
133
380
|
};
|
|
134
381
|
|
package/src/pg-row.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// from the driver is re-parsed by the column that declared it rather than trusted — int8 arrives
|
|
5
5
|
// as a string, timestamptz may arrive as one, and a silent `NaN` is worse than a loud throw.
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { columnName, moneyColumns } 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,11 +14,24 @@ export type PhysicalRow = Readonly<Record<string, unknown>>;
|
|
|
13
14
|
|
|
14
15
|
const MONEY_PARTS = new Set(['minor', 'currency']);
|
|
15
16
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
+
*/
|
|
26
|
+
export const columnsOf = (property: string, column: AnyColumn): readonly string[] => {
|
|
27
|
+
if (column.$meta.kind !== 'money') return [columnName(property, column.$meta)];
|
|
28
|
+
const parts = moneyColumns(property, column.$meta);
|
|
29
|
+
// Two columns for an adopted amount that has no scale column: the list IS the projection, so a
|
|
30
|
+
// name here that the table does not have is a `42703` on the first select.
|
|
31
|
+
return parts.scale === null
|
|
32
|
+
? [parts.minor, parts.currency]
|
|
33
|
+
: [parts.minor, parts.currency, parts.scale];
|
|
34
|
+
};
|
|
21
35
|
|
|
22
36
|
/**
|
|
23
37
|
* A predicate or sort key names a property, never a physical column — so `orgId` becomes
|
|
@@ -35,7 +49,7 @@ export const physicalName = <Row>(entity: EntityCore<Row>, path: string): string
|
|
|
35
49
|
}
|
|
36
50
|
const isMoney = column.$meta.kind === 'money';
|
|
37
51
|
if (part === undefined) {
|
|
38
|
-
if (!isMoney) return
|
|
52
|
+
if (!isMoney) return columnName(property, column.$meta);
|
|
39
53
|
throw invariantViolated(
|
|
40
54
|
entity.$name,
|
|
41
55
|
property,
|
|
@@ -45,7 +59,8 @@ export const physicalName = <Row>(entity: EntityCore<Row>, path: string): string
|
|
|
45
59
|
if (!isMoney || !MONEY_PARTS.has(part)) {
|
|
46
60
|
throw invariantViolated(entity.$name, property, `${property} has no part "${part}"`);
|
|
47
61
|
}
|
|
48
|
-
|
|
62
|
+
const parts = moneyColumns(property, column.$meta);
|
|
63
|
+
return part === 'minor' ? parts.minor : parts.currency;
|
|
49
64
|
};
|
|
50
65
|
|
|
51
66
|
/** Every physical column of the entity, in declaration order. */
|
|
@@ -61,25 +76,75 @@ export const bindValues = <Row>(
|
|
|
61
76
|
values: Partial<Row>,
|
|
62
77
|
): ReadonlyMap<string, unknown> => {
|
|
63
78
|
const bound = new Map<string, unknown>();
|
|
64
|
-
|
|
79
|
+
// `MoneyInput` lets a writer hand a `bigint`; the row type is `MoneyValue`. `memoryRepo` calls
|
|
80
|
+
// the same narrowing before it stores, so what the two drivers write is one value.
|
|
81
|
+
const record = narrowMoney(entity.$columns, values) as Readonly<Record<string, unknown>>;
|
|
65
82
|
for (const [property, column] of Object.entries(entity.$columns)) {
|
|
66
83
|
if (!Object.hasOwn(record, property)) continue;
|
|
67
84
|
const value = record[property];
|
|
68
85
|
if (column.$meta.kind !== 'money') {
|
|
69
|
-
bound.set(
|
|
86
|
+
bound.set(columnName(property, column.$meta), bindable(column, value));
|
|
70
87
|
continue;
|
|
71
88
|
}
|
|
89
|
+
const parts = moneyColumns(property, column.$meta);
|
|
72
90
|
const money = value as MoneyValue | null | undefined;
|
|
73
|
-
bound.set(
|
|
74
|
-
bound.set(
|
|
91
|
+
bound.set(parts.minor, money?.minor ?? null);
|
|
92
|
+
bound.set(parts.currency, money?.currency ?? null);
|
|
93
|
+
// `?? null` and not `!== undefined`: an amount at the currency's own scale carries no key at
|
|
94
|
+
// all, and that absence is what the nullable column stores. A `0` written here for it would
|
|
95
|
+
// claim whole units — a 100x reinterpretation of every ordinary price.
|
|
96
|
+
if (parts.scale !== null) bound.set(parts.scale, money?.scale ?? null);
|
|
75
97
|
}
|
|
76
98
|
return bound;
|
|
77
99
|
};
|
|
78
100
|
|
|
79
|
-
|
|
101
|
+
/**
|
|
102
|
+
* One array element, as a Postgres array literal spells it. Quoted always: an unquoted element
|
|
103
|
+
* containing a comma, a brace or a backslash is a different array, and an empty string unquoted
|
|
104
|
+
* is nothing at all.
|
|
105
|
+
*/
|
|
106
|
+
const arrayElement = (value: unknown): string => {
|
|
107
|
+
if (value === null || value === undefined) return 'NULL';
|
|
108
|
+
const text =
|
|
109
|
+
value instanceof Date ? value.toISOString() : typeof value === 'object' ? '' : String(value);
|
|
110
|
+
return `"${text.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The value a parameter carries. Every column but one hands its row value straight over — the
|
|
115
|
+
* measured driver behaviour is that an object binds to `jsonb`, a string binds to `numeric`,
|
|
116
|
+
* `int8` and `date`, and a `Uint8Array` binds to `bytea`.
|
|
117
|
+
*
|
|
118
|
+
* An array is the one that cannot: Bun's `sql` serialises a JS array to `x,y`, which Postgres
|
|
119
|
+
* answers with `malformed array literal` (measured). What it accepts is the literal, so this is
|
|
120
|
+
* where a JS array becomes one.
|
|
121
|
+
*/
|
|
122
|
+
const bindable = (column: AnyColumn, value: unknown): unknown => {
|
|
123
|
+
if (value === null || value === undefined) return null;
|
|
124
|
+
// A plain object is not a bindable parameter (`X_SQL_UNSAFE`), so a `jsonb` value crosses as its
|
|
125
|
+
// TEXT and `pg-sql.ts`'s cell casts it back — see `cellCast` for why the cast is `::text::jsonb`.
|
|
126
|
+
if (column.$meta.kind === 'jsonb') return JSON.stringify(value);
|
|
127
|
+
if (column.$meta.kind !== 'array' || !Array.isArray(value)) return value;
|
|
128
|
+
return `{${value.map(arrayElement).join(',')}}`;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const moneyOf = (
|
|
132
|
+
source: PhysicalRow,
|
|
133
|
+
minor: string,
|
|
134
|
+
currency: string,
|
|
135
|
+
scale: string | undefined,
|
|
136
|
+
): unknown => {
|
|
80
137
|
const amount = source[minor];
|
|
81
138
|
if (amount === null || amount === undefined) return null;
|
|
82
|
-
|
|
139
|
+
// A column the projection left out is absent, not null — and absent must read as "no scale"
|
|
140
|
+
// exactly as a stored NULL does, so both take the same branch. So does a table that has no
|
|
141
|
+
// scale column at all, which is why the name itself may be `undefined`.
|
|
142
|
+
const declared = scale === undefined ? undefined : source[scale];
|
|
143
|
+
return {
|
|
144
|
+
minor: amount,
|
|
145
|
+
currency: String(source[currency] ?? '').trim(),
|
|
146
|
+
...(declared === null || declared === undefined ? {} : { scale: declared }),
|
|
147
|
+
};
|
|
83
148
|
};
|
|
84
149
|
|
|
85
150
|
/**
|
|
@@ -89,9 +154,15 @@ const moneyOf = (source: PhysicalRow, minor: string, currency: string): unknown
|
|
|
89
154
|
export const decodeRow = <Row>(entity: EntityCore<Row>, source: PhysicalRow): Row => {
|
|
90
155
|
const row: Record<string, unknown> = {};
|
|
91
156
|
for (const [property, column] of Object.entries(entity.$columns)) {
|
|
92
|
-
const [head,
|
|
157
|
+
const [head, currency, scale] = columnsOf(property, column);
|
|
93
158
|
if (head === undefined || !(head in source)) continue;
|
|
94
|
-
|
|
159
|
+
// Decided by the column's KIND and never by how many names came back: a money column whose
|
|
160
|
+
// table has no scale column projects two names, and reading that as a non-money column handed
|
|
161
|
+
// the caller a raw minor unit where a `Money` belongs.
|
|
162
|
+
const value =
|
|
163
|
+
column.$meta.kind === 'money' && currency !== undefined
|
|
164
|
+
? moneyOf(source, head, currency, scale)
|
|
165
|
+
: source[head];
|
|
95
166
|
if (value !== null && value !== undefined) {
|
|
96
167
|
row[property] = column.$parse(value);
|
|
97
168
|
continue;
|