@ultimat3/entity 2.0.0 → 4.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/index.ts CHANGED
@@ -4,7 +4,9 @@
4
4
  export type { Infer } from '@ultimat3/schema';
5
5
  export { t } from '@ultimat3/schema';
6
6
  export type { BatchIterator } from './batch';
7
- export type { TextOptions } from './columns';
7
+ export type { MoneyColumns } from './column';
8
+ export { columnName, moneyColumns, snake } from './column';
9
+ export type { MoneyOptions, TextOptions } from './columns';
8
10
  export {
9
11
  boolean,
10
12
  enumerated,
@@ -18,11 +20,18 @@ export {
18
20
  url,
19
21
  uuid,
20
22
  } from './columns';
23
+ // The vocabulary an EXISTING schema needs. Separate from the blessed builders on purpose: those
24
+ // are decisions this framework made for a table it was going to create, and these are the shapes
25
+ // a table already has (`docs`: Entities-And-Migrations, "Adopting an existing database").
26
+ export type { DecimalOptions } from './columns-data';
27
+ export { arrayOf, bigint, bytes, date, decimal, json } from './columns-data';
21
28
  // `crossTenantReason` stays internal: an app that could read the flag would have a second way to
22
29
  // reason about tenant scope — branch on it — next to the one way, which is entering the scope.
23
30
  export { CROSS_TENANT_SCOPE, crossTenant } from './cross-tenant';
24
31
  export type { Database, DatabaseOptions, Driver, EntitySet } from './database';
25
32
  export { database, defaultDriver, memoryDriver } from './database';
33
+ export type { DescribeInput } from './describe';
34
+ export { sqlTypeOf } from './describe';
26
35
  export type { Entity, EntityCore, EntityInit, IndexInit } from './entity';
27
36
  export { entity, SOFT_DELETE_COLUMN } from './entity';
28
37
  export type {
@@ -92,6 +101,7 @@ export {
92
101
  export type { EntityRelations, Relation, RelationKind, RelationMap } from './relations';
93
102
  export { relationMap, relationNamed, relationsFor, relationsOf } from './relations';
94
103
  export type {
104
+ FindByIdOptions,
95
105
  FindManyArgs,
96
106
  MemoryRepo,
97
107
  Page,
@@ -102,8 +112,18 @@ export type {
102
112
  UpsertArgs,
103
113
  } from './repo';
104
114
  export { memoryRepo, memoryTransactor } from './repo';
105
- export type { Seed, SeedContext, SeedOptions } from './seed';
106
- export { defineSeed, seedId } from './seed';
115
+ export type {
116
+ Seed,
117
+ SeedContext,
118
+ SeedInit,
119
+ SeedKey,
120
+ SeedMetrics,
121
+ SeedOptions,
122
+ SeedRun,
123
+ SeedTier,
124
+ SeedWrite,
125
+ } from './seed';
126
+ export { defineSeed, isSeed, SEED_TIERS, seedId, seedTiersFor } from './seed';
107
127
  export type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
108
128
  export {
109
129
  assertRowTenant,
@@ -127,14 +147,17 @@ export type {
127
147
  IdOf,
128
148
  IndexDef,
129
149
  Insertable,
150
+ MoneyColumnNames,
130
151
  MoneyInput,
131
152
  MoneyValue,
132
153
  OnDelete,
133
154
  ReferenceOptions,
134
155
  RowOf,
156
+ RowPatch,
135
157
  TimestampColumn,
136
158
  TypeOf,
137
159
  UuidColumn,
138
160
  } from './types';
161
+ export { COLUMN_KINDS } from './types';
139
162
  // `viewFor` stays internal: a view is reached through the entity, as `posts.$view([...])`.
140
163
  export type { EntityView } from './view';
@@ -5,7 +5,9 @@
5
5
  //
6
6
  // The trigger carries an id, not a row, so what a page leaves behind is an index of its foreign
7
7
  // key VALUES rather than a map keyed by row identity: an id is a thing that can be looked up in
8
- // it, and it holds values, so it pins no rows for the request's lifetime.
8
+ // it, and a page therefore costs its keys rather than its rows. That was true PER PAGE and false
9
+ // across them until `MAX_SIBLING_KEYS` — the store outlives every page and dies with the ctx,
10
+ // which for a job is the whole attempt, so both maps here are bounded and evict the oldest page.
9
11
  //
10
12
  // The scope guard is a security boundary, not a tuning knob. A preloaded row is served only to a
11
13
  // lookup with the same scope key, the same client and no write since — anything else reads the
@@ -14,7 +16,15 @@
14
16
  import type { Ctx } from '@ultimat3/core';
15
17
  import { tryUseContext } from '@ultimat3/core';
16
18
  import type { DbClient } from '@ultimat3/db';
17
- import { type Answer, keyOf, type PointRead, readByIds, statementChunks } from './batch-read';
19
+ import {
20
+ type Answer,
21
+ keyOf,
22
+ MAX_IDS_PER_STATEMENT,
23
+ type PointRead,
24
+ readByIds,
25
+ statementChunks,
26
+ } from './batch-read';
27
+ import { columnFor } from './column';
18
28
  import type { EntityCore } from './entity';
19
29
 
20
30
  /** The rows one page's worth of foreign keys resolved to, under one scope. */
@@ -53,10 +63,54 @@ const storeFor = (ctx: Ctx): Store => {
53
63
  return created;
54
64
  };
55
65
 
66
+ /**
67
+ * How many id keys ONE edge may hold, and how many rows one bucket may keep — a few pages' worth,
68
+ * the way `MAX_IDS_PER_STATEMENT` bounds a statement.
69
+ *
70
+ * `MAX_IDS_PER_STATEMENT` bounded the statement and nothing bounded the STORE: every page merged
71
+ * its keys in and the store died only with the ctx, which for a job is the whole attempt. Measured
72
+ * at 1,000 pages x 1,000 rows with distinct foreign keys, rows dropped after each call and
73
+ * `Bun.gc(true)` either side: **159.3 MB retained**, against 2.7 MB with the tagging off — so a
74
+ * 12M-row `backfill()` retains ~2 GB and OOMs the worker on the DEFAULT configuration, since
75
+ * `jitPreload` defaults to true and `backfill()` names no driver option.
76
+ *
77
+ * Four statements' worth. The keys of one page are filed contiguously, so the survivors are the
78
+ * newest pages' and the arrays every evicted key referenced go with them — which is what makes the
79
+ * bound a bound on bytes and not only on entries. Past it a lookup DECLINES, and declining is the
80
+ * old behaviour everywhere else in this file: the caller reads the statement it always read.
81
+ */
82
+ export const MAX_SIBLING_KEYS = MAX_IDS_PER_STATEMENT * 4;
83
+
84
+ /**
85
+ * Newest wins, oldest goes. A `Map` iterates in insertion order, so its first key is the oldest
86
+ * page's — and the page a sequential `for … of` loop is walking is the newest one, which is the
87
+ * only page this store exists to answer for. Re-filed rather than overwritten, so a key a later
88
+ * page carries again moves to the newest end instead of ageing out under it.
89
+ */
90
+ const remember = <V>(index: Map<string, V>, key: string, value: V, cap: number): void => {
91
+ index.delete(key);
92
+ index.set(key, value);
93
+ while (index.size > cap) {
94
+ const oldest = index.keys().next();
95
+ if (oldest.done === true) return;
96
+ index.delete(oldest.value);
97
+ }
98
+ };
99
+
56
100
  /** Both ends of the edge: a key pointing at another column of the same entity is another edge. */
57
101
  const siblingKey = (targetEntity: string, targetProperty: string): string =>
58
102
  JSON.stringify([targetEntity, targetProperty]);
59
103
 
104
+ /** TEST SEAM: id keys this request is holding, across every edge. A bound nothing can observe is a
105
+ * bound nothing can pin, and `MAX_SIBLING_KEYS` is the number this answers against. */
106
+ export const siblingKeysHeld = (ctx: Ctx): number => {
107
+ const store = requests.get(ctx);
108
+ if (store === undefined) return 0;
109
+ let held = 0;
110
+ for (const index of store.siblings.values()) held += index.size;
111
+ return held;
112
+ };
113
+
60
114
  const writesTo = (store: Store, entity: string): number => store.writes.get(entity) ?? 0;
61
115
 
62
116
  /**
@@ -80,7 +134,7 @@ export const tagSiblings = <Row>(entity: EntityCore<Row>, rows: readonly Row[]):
80
134
  for (const reference of references) {
81
135
  // The declaring column's own kind: a foreign key mirrors the key it points at, and a value is
82
136
  // filed here exactly as `findById` will spell it when it comes looking.
83
- const kind = entity.$columns[reference.property]?.$meta.kind;
137
+ const kind = columnFor(entity.$columns, reference.property)?.$meta.kind;
84
138
  if (kind === undefined) continue;
85
139
  const ids: unknown[] = [];
86
140
  const keys = new Set<string>();
@@ -96,7 +150,7 @@ export const tagSiblings = <Row>(entity: EntityCore<Row>, rows: readonly Row[]):
96
150
  if (ids.length === 0) continue;
97
151
  const at = siblingKey(reference.targetEntity, reference.targetProperty);
98
152
  const index = store.siblings.get(at) ?? new Map<string, readonly unknown[]>();
99
- for (const key of keys) index.set(key, ids);
153
+ for (const key of keys) remember(index, key, ids, MAX_SIBLING_KEYS);
100
154
  store.siblings.set(at, index);
101
155
  }
102
156
  };
@@ -165,12 +219,14 @@ const preload = <Row>(read: PointRead<Row>, bucket: Bucket, ids: readonly unknow
165
219
  if (bucket.rows.has(at)) continue;
166
220
  // The executor runs synchronously, so `settle` is assigned before the promise is stored.
167
221
  let settle!: (answer: Answer) => void;
168
- bucket.rows.set(
169
- at,
170
- new Promise<Answer>((resolve) => {
171
- settle = resolve;
172
- }),
173
- );
222
+ const answer = new Promise<Answer>((resolve) => {
223
+ settle = resolve;
224
+ });
225
+ // Bounded for the reason the sibling index is: a bucket holds ROWS, so a long request that
226
+ // preloads page after page retains every row it ever resolved. An evicted entry still settles
227
+ // — `fill` holds its own settler — and a lookup that no longer finds one reads its own
228
+ // statement, which is what it would have read had no page indexed the id at all.
229
+ remember(bucket.rows, at, answer, MAX_SIBLING_KEYS);
174
230
  settlers.set(at, settle);
175
231
  wanted.push(id);
176
232
  }
@@ -0,0 +1,169 @@
1
+ // Single responsibility: what a `Predicate` MEANS in the in-memory driver — equality, ordering and
2
+ // LIKE. Every rule here exists so the answer matches the one Postgres gives for the same predicate
3
+ // on the same column, which is why each is decided by the column's DECLARED KIND and never by the
4
+ // JS type of whichever value is in hand: the database decides by the column's type, so a driver
5
+ // deciding by `typeof` is answering a different question.
6
+
7
+ import { compareDecimalText } from '@ultimat3/core';
8
+ import { keyOf } from './batch-read';
9
+ import { kindOf, valueAt } from './cursor';
10
+ import type { EntityCore } from './entity';
11
+ import { EntityError } from './errors';
12
+ import type { Predicate } from './tenancy';
13
+ import type { ColumnKind } from './types';
14
+
15
+ /**
16
+ * The kinds whose ROW VALUE is a decimal string. `bigint()` and `decimal()` both hand back digits
17
+ * as text on purpose (`columns-data.ts`): a JS `bigint` is what `JSON.stringify` throws on and a
18
+ * `number` loses digits past 2^53, exactly where a legacy `int8` key lives.
19
+ *
20
+ * Which makes them the kinds no `typeof` branch can catch. `compare` had a `number`/`number` case
21
+ * and a `bigint`/`bigint` case and neither fired for these, so both fell to
22
+ * `String(left) < String(right)`: memory answered `["10","100","2","9"]` where Postgres answers
23
+ * `["2","9","10","100"]`, and a keyset page boundary was cut where the database never cuts one.
24
+ *
25
+ * This SET is the whole of what this package contributes; the comparison itself is
26
+ * `@ultimat3/core`'s `compareDecimalText`. The split is the point — the text arrives in more than
27
+ * one package and the DECLARED KIND does not, so a caller with no column kinds
28
+ * (`@ultimat3/query`, whose `OrderKey` is a name and a direction) deliberately never asks: a
29
+ * `text` column holding `"10"` and `"9"` is ordered lexically by Postgres, and a comparator
30
+ * guessing "both sides look like decimals" would trade this disagreement for that one.
31
+ */
32
+ const DECIMAL_TEXT: ReadonlySet<ColumnKind> = new Set<ColumnKind>(['bigint', 'numeric']);
33
+
34
+ const sign = <T extends number | bigint | string>(left: T, right: T): number =>
35
+ left < right ? -1 : left > right ? 1 : 0;
36
+
37
+ /**
38
+ * Two values of one column, ordered as Postgres orders that column. `-1`, `0` or `1` — never a
39
+ * difference, so a `bigint` pair needs no subtraction it cannot express in a `number`.
40
+ */
41
+ export const compareByKind = (
42
+ kind: ColumnKind | undefined,
43
+ left: unknown,
44
+ right: unknown,
45
+ ): number => {
46
+ if (left instanceof Date && right instanceof Date) return sign(left.getTime(), right.getTime());
47
+ if (kind !== undefined && DECIMAL_TEXT.has(kind)) {
48
+ // `undefined` when either side is not a plain decimal — that pair is not a numeric comparison,
49
+ // so it falls through to the branches below rather than being guessed at.
50
+ const exact = compareDecimalText(left, right);
51
+ if (exact !== undefined) return exact;
52
+ }
53
+ if (typeof left === 'number' && typeof right === 'number') return sign(left, right);
54
+ if (typeof left === 'bigint' && typeof right === 'bigint') return sign(left, right);
55
+ return sign(String(left), String(right));
56
+ };
57
+
58
+ /**
59
+ * Equality, in the two places `===` is not what the database means. A `Date` compares by identity,
60
+ * so `where({ publishedAt })` would match nothing here and every row there. And Postgres compares a
61
+ * `uuid` as a VALUE — it parses the text and prints it lower-cased — so an id handed in upper case
62
+ * matches the row there and used to miss it here, which is `findById(UPPER)` answering `null` in
63
+ * memory and the row in production. `keyOf` is where that rule already lived, for the batched read.
64
+ */
65
+ export const sameValueOfKind = (
66
+ kind: ColumnKind | undefined,
67
+ left: unknown,
68
+ right: unknown,
69
+ ): boolean => {
70
+ if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime();
71
+ if (kind === 'uuid' && typeof left === 'string' && typeof right === 'string') {
72
+ return keyOf('uuid', left) === keyOf('uuid', right);
73
+ }
74
+ return left === right;
75
+ };
76
+
77
+ const REGEX_SPECIAL = /[.*+?^${}()|[\]\\]/g;
78
+
79
+ const quote = (text: string): string => text.replace(REGEX_SPECIAL, '\\$&');
80
+
81
+ /**
82
+ * Postgres answers a `LIKE` pattern ending in the escape character with `22025 — LIKE pattern must
83
+ * not end with escape character`, so a pattern that means nothing there means nothing here either.
84
+ * The pattern itself is never echoed: a filter value is app data, and this cause is rendered into
85
+ * a log line.
86
+ */
87
+ const danglingEscape = (entityName: string): EntityError =>
88
+ new EntityError({
89
+ code: 'X_INVARIANT_VIOLATED',
90
+ cause: `${entityName}: a like pattern ends with a backslash, which is the escape character — Postgres answers that pattern with 22025 (LIKE pattern must not end with escape character)`,
91
+ fix: "double it — 'a\\\\' is the pattern that matches one literal backslash, and 'a\\%b' matches a literal %",
92
+ });
93
+
94
+ /**
95
+ * A SQL `LIKE` pattern as a regex, with Postgres' DEFAULT escape handling: `%` and `_` are the
96
+ * wildcards, a backslash escapes either (or itself), and everything else is literal.
97
+ *
98
+ * The backslash used to be quoted for the regex BEFORE the wildcards were expanded, so `'a\%b'`
99
+ * matched the literal `a%b` in Postgres and `a\<anything>b` here — one pattern, two meanings, and
100
+ * the driver that disagreed was the one every test runs against.
101
+ *
102
+ * A RUN of `%` is still one `.*`, not one each: `%%%…x` compiled to twenty adjacent `.*` groups,
103
+ * and an anchored regex with twenty of them takes exponential time to fail on a long value — a
104
+ * filter value forwarded from a search box is then a CPU stall in the process. Postgres reads a run
105
+ * of `%` as one wildcard too, so this is the two drivers agreeing rather than a defensive
106
+ * narrowing.
107
+ */
108
+ const likePattern = (entityName: string, pattern: string): RegExp => {
109
+ let source = '';
110
+ let at = 0;
111
+ while (at < pattern.length) {
112
+ const char = pattern[at];
113
+ if (char === '\\') {
114
+ const escaped = pattern[at + 1];
115
+ if (escaped === undefined) throw danglingEscape(entityName);
116
+ source += quote(escaped);
117
+ at += 2;
118
+ continue;
119
+ }
120
+ if (char === '%') {
121
+ while (pattern[at] === '%') at += 1;
122
+ source += '.*';
123
+ continue;
124
+ }
125
+ source += char === '_' ? '.' : quote(char ?? '');
126
+ at += 1;
127
+ }
128
+ return new RegExp(`^${source}$`, 's');
129
+ };
130
+
131
+ /** One predicate against one stored row, in the meaning the Postgres driver compiles it to. */
132
+ export const matchesPredicate = <Row>(
133
+ entity: EntityCore<Row>,
134
+ row: unknown,
135
+ predicate: Predicate,
136
+ ): boolean => {
137
+ // The column's declared kind, resolved once — `price.minor` included, which is the path a money
138
+ // predicate and a money sort key both name.
139
+ const kind = kindOf(entity, predicate.column);
140
+ const actual = valueAt(row, predicate.column);
141
+ const same = (candidate: unknown): boolean => sameValueOfKind(kind, actual, candidate);
142
+ const order = (): number => compareByKind(kind, actual, predicate.value);
143
+ switch (predicate.op) {
144
+ case 'eq':
145
+ return same(predicate.value);
146
+ case 'neq':
147
+ return !same(predicate.value);
148
+ // `in` reads a LIST or nothing: an operand that is not an array matches no row, which is what
149
+ // `predicateSql` now compiles it to and what `@ultimat3/query` answers for the same operand.
150
+ case 'in':
151
+ return Array.isArray(predicate.value) && predicate.value.some(same);
152
+ case 'gt':
153
+ return order() > 0;
154
+ case 'gte':
155
+ return order() >= 0;
156
+ case 'lt':
157
+ return order() < 0;
158
+ case 'lte':
159
+ return order() <= 0;
160
+ // Real LIKE semantics, so `'draft%'` means "starts with" here exactly as it does in Postgres.
161
+ // Treating the pattern as a substring would make the two drivers disagree.
162
+ case 'like':
163
+ return likePattern(entity.$name, String(predicate.value)).test(String(actual));
164
+ case 'is-null':
165
+ return actual === null || actual === undefined;
166
+ case 'is-not-null':
167
+ return actual !== null && actual !== undefined;
168
+ }
169
+ };
package/src/pg-driver.ts CHANGED
@@ -7,7 +7,6 @@
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 { systemClock } from '@ultimat3/core';
11
10
  import {
12
11
  currentTx,
13
12
  type DbClient,
@@ -24,8 +23,8 @@ import {
24
23
  namedProperties,
25
24
  upsertPlan,
26
25
  } from './bulk-write';
26
+ import { entityNow } from './clock';
27
27
  import { coalesceFindById } from './coalesce';
28
- import { snake } from './column';
29
28
  import { countsFrom, groupColumnOf, groupValue, MAX_GROUPS } from './count-by';
30
29
  import { cursorFor, seekFrom, valueAt } from './cursor';
31
30
  import type { Driver } from './database';
@@ -33,7 +32,7 @@ import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
33
32
  import { notFound, repoClientPinned } from './errors';
34
33
  import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
35
34
  import { forgetPreloaded, tagSiblings } from './jit-preload';
36
- import { bindValues, decodeRow, type PhysicalRow } from './pg-row';
35
+ import { bindValues, decodeRow, type PhysicalRow, physicalName } from './pg-row';
37
36
  import {
38
37
  type ConflictTarget,
39
38
  countByStatement,
@@ -133,7 +132,7 @@ export const postgresRepo = <Row>(
133
132
  ? updateStatement(
134
133
  entity,
135
134
  plan,
136
- new Map([[snake(SOFT_DELETE_COLUMN), systemClock.now()]]),
135
+ new Map([[physicalName(entity, SOFT_DELETE_COLUMN), entityNow()]]),
137
136
  shapeOf({}),
138
137
  false,
139
138
  )
package/src/pg-row.ts CHANGED
@@ -4,11 +4,11 @@
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 { columnFor, columnName, moneyColumns } from './column';
8
8
  import { narrowMoney } from './columns';
9
9
  import type { EntityCore } from './entity';
10
10
  import { invariantViolated } from './errors';
11
- import type { AnyColumn, MoneyValue } from './types';
11
+ import type { AnyColumn, MoneyValue, RowPatch } from './types';
12
12
 
13
13
  export type PhysicalRow = Readonly<Record<string, unknown>>;
14
14
 
@@ -23,10 +23,15 @@ const MONEY_PARTS = new Set(['minor', 'currency']);
23
23
  * addressable as a predicate or a sort key (`MONEY_PARTS` below, and `cursor.ts`'s copy) — a scale
24
24
  * says which units `minor` counts, so ordering or filtering by it compares two different questions.
25
25
  */
26
- export const columnsOf = (property: string, column: AnyColumn): readonly string[] =>
27
- column.$meta.kind === 'money'
28
- ? [`${snake(property)}_minor`, `${snake(property)}_currency`, `${snake(property)}_scale`]
29
- : [snake(property)];
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
+ };
30
35
 
31
36
  /**
32
37
  * A predicate or sort key names a property, never a physical column — so `orgId` becomes
@@ -34,7 +39,7 @@ export const columnsOf = (property: string, column: AnyColumn): readonly string[
34
39
  */
35
40
  export const physicalName = <Row>(entity: EntityCore<Row>, path: string): string => {
36
41
  const [property = path, part] = path.split('.');
37
- const column = entity.$columns[property];
42
+ const column = columnFor(entity.$columns, property);
38
43
  if (column === undefined) {
39
44
  throw invariantViolated(
40
45
  entity.$name,
@@ -44,7 +49,7 @@ export const physicalName = <Row>(entity: EntityCore<Row>, path: string): string
44
49
  }
45
50
  const isMoney = column.$meta.kind === 'money';
46
51
  if (part === undefined) {
47
- if (!isMoney) return snake(property);
52
+ if (!isMoney) return columnName(property, column.$meta);
48
53
  throw invariantViolated(
49
54
  entity.$name,
50
55
  property,
@@ -54,7 +59,8 @@ export const physicalName = <Row>(entity: EntityCore<Row>, path: string): string
54
59
  if (!isMoney || !MONEY_PARTS.has(part)) {
55
60
  throw invariantViolated(entity.$name, property, `${property} has no part "${part}"`);
56
61
  }
57
- return `${snake(property)}_${part}`;
62
+ const parts = moneyColumns(property, column.$meta);
63
+ return part === 'minor' ? parts.minor : parts.currency;
58
64
  };
59
65
 
60
66
  /** Every physical column of the entity, in declaration order. */
@@ -67,7 +73,7 @@ export const allColumns = <Row>(entity: EntityCore<Row>): readonly string[] =>
67
73
  */
68
74
  export const bindValues = <Row>(
69
75
  entity: EntityCore<Row>,
70
- values: Partial<Row>,
76
+ values: RowPatch<Row>,
71
77
  ): ReadonlyMap<string, unknown> => {
72
78
  const bound = new Map<string, unknown>();
73
79
  // `MoneyInput` lets a writer hand a `bigint`; the row type is `MoneyValue`. `memoryRepo` calls
@@ -77,26 +83,63 @@ export const bindValues = <Row>(
77
83
  if (!Object.hasOwn(record, property)) continue;
78
84
  const value = record[property];
79
85
  if (column.$meta.kind !== 'money') {
80
- bound.set(snake(property), value ?? null);
86
+ bound.set(columnName(property, column.$meta), bindable(column, value));
81
87
  continue;
82
88
  }
89
+ const parts = moneyColumns(property, column.$meta);
83
90
  const money = value as MoneyValue | null | undefined;
84
- bound.set(`${snake(property)}_minor`, money?.minor ?? null);
85
- bound.set(`${snake(property)}_currency`, money?.currency ?? null);
91
+ bound.set(parts.minor, money?.minor ?? null);
92
+ bound.set(parts.currency, money?.currency ?? null);
86
93
  // `?? null` and not `!== undefined`: an amount at the currency's own scale carries no key at
87
94
  // all, and that absence is what the nullable column stores. A `0` written here for it would
88
95
  // claim whole units — a 100x reinterpretation of every ordinary price.
89
- bound.set(`${snake(property)}_scale`, money?.scale ?? null);
96
+ if (parts.scale !== null) bound.set(parts.scale, money?.scale ?? null);
90
97
  }
91
98
  return bound;
92
99
  };
93
100
 
94
- const moneyOf = (source: PhysicalRow, minor: string, currency: string, scale: 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 => {
95
137
  const amount = source[minor];
96
138
  if (amount === null || amount === undefined) return null;
97
139
  // 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];
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];
100
143
  return {
101
144
  minor: amount,
102
145
  currency: String(source[currency] ?? '').trim(),
@@ -113,10 +156,13 @@ export const decodeRow = <Row>(entity: EntityCore<Row>, source: PhysicalRow): Ro
113
156
  for (const [property, column] of Object.entries(entity.$columns)) {
114
157
  const [head, currency, scale] = columnsOf(property, column);
115
158
  if (head === undefined || !(head in source)) continue;
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.
116
162
  const value =
117
- currency === undefined || scale === undefined
118
- ? source[head]
119
- : moneyOf(source, head, currency, scale);
163
+ column.$meta.kind === 'money' && currency !== undefined
164
+ ? moneyOf(source, head, currency, scale)
165
+ : source[head];
120
166
  if (value !== null && value !== undefined) {
121
167
  row[property] = column.$parse(value);
122
168
  continue;