@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/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 { type DbClient, db, type TransactionOptions, withTransaction } from '@ultimat3/db';
11
- import { snake } from './column';
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 { bindValues, decodeRow, type PhysicalRow } from './pg-row';
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
- const client = (): DbClient => config.client ?? db();
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 plan = idPlan(entity, id, options, 'findById');
61
- return one(plan, options ?? {});
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 plan = readPlan(entity, args, 'findMany');
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 client().query<PhysicalRow>(
69
- selectStatement(entity, plan, shapeOf(args, seekFrom(entity, plan)), plan.limit + 1),
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
- entity.$assert(values);
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 === null ? values : decodeRow(entity, 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 plan = idPlan(entity, id, options, 'update');
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 client().one<PhysicalRow>(
100
- updateStatement(entity, plan, values, shapeOf(options ?? {})),
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 plan = idPlan(entity, id, options, 'delete');
113
- // Soft delete hides the row without losing it; the column's presence is the switch.
114
- const statement = entity.$softDelete
115
- ? updateStatement(
116
- entity,
117
- plan,
118
- new Map([[snake(SOFT_DELETE_COLUMN), new Date()]]),
119
- shapeOf({}),
120
- )
121
- : deleteStatement(entity, plan);
122
- if ((await client().execute(statement)) === 0) throw notFound(entity.$name, id);
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 plan = readPlan(entity, args, 'count');
127
- const row = await client().one<{ count: unknown }>(
128
- countStatement(entity, plan, shapeOf(args)),
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 { snake } from './column';
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
- /** `price` -> `price_minor`, `price_currency`. Everything else is one snake_case column. */
17
- export const columnsOf = (property: string, column: AnyColumn): readonly string[] =>
18
- column.$meta.kind === 'money'
19
- ? [`${snake(property)}_minor`, `${snake(property)}_currency`]
20
- : [snake(property)];
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 snake(property);
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
- return `${snake(property)}_${part}`;
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
- const record = values as Readonly<Record<string, unknown>>;
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(snake(property), value ?? null);
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(`${snake(property)}_minor`, money?.minor ?? null);
74
- bound.set(`${snake(property)}_currency`, money?.currency ?? null);
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
- const moneyOf = (source: PhysicalRow, minor: string, currency: string): unknown => {
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
- return { minor: amount, currency: String(source[currency] ?? '').trim() };
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, tail] = columnsOf(property, column);
157
+ const [head, currency, scale] = columnsOf(property, column);
93
158
  if (head === undefined || !(head in source)) continue;
94
- const value = tail === undefined ? source[head] : moneyOf(source, head, tail);
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;