@ultimat3/entity 2.0.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/columns.ts CHANGED
@@ -10,11 +10,20 @@ import {
10
10
  isMoneyScale,
11
11
  MAX_MONEY_SCALE,
12
12
  } from '@ultimat3/schema';
13
- import { BARE, column, GENERATED_UUID, makeColumn, makeTimestamp } from './column';
13
+ import {
14
+ assertColumnName,
15
+ BARE,
16
+ column,
17
+ GENERATED_UUID,
18
+ makeColumn,
19
+ makeTimestamp,
20
+ } from './column';
14
21
  import { invariantViolated } from './errors';
15
22
  import type {
16
23
  Column,
17
24
  ColumnMap,
25
+ ColumnMeta,
26
+ MoneyColumnNames,
18
27
  MoneyInput,
19
28
  MoneyValue,
20
29
  TimestampColumn,
@@ -64,16 +73,22 @@ const parseBrandedUuid = <T extends string>(value: unknown): T => parseUuid(valu
64
73
  * derivation — row, insert, `findById`, `update`, `delete` — so mixing two entities' ids is a
65
74
  * compile error instead of a query that silently matches nothing.
66
75
  */
67
- export const uuid = <T extends string = string>(): UuidColumn<T> => ({
68
- ...makeColumn<T, false>({ ...BARE, kind: 'uuid' }, parseBrandedUuid, false),
76
+ export const uuid = <T extends string = string>(): UuidColumn<T> =>
77
+ uuidWith({ ...BARE, kind: 'uuid' });
78
+
79
+ const uuidWith = <T extends string>(meta: ColumnMeta): UuidColumn<T> => ({
80
+ ...makeColumn<T, false>(meta, parseBrandedUuid, false),
69
81
  // Narrower than the generic chain: a uuid key is generated when omitted, so it is the one
70
82
  // primary key an insert may leave out.
71
83
  primaryKey: () =>
72
84
  makeColumn<T, true>(
73
- { ...BARE, kind: 'uuid', primaryKey: true, default: GENERATED_UUID },
85
+ { ...meta, primaryKey: true, default: GENERATED_UUID },
74
86
  parseBrandedUuid,
75
87
  true,
76
88
  ),
89
+ // Overridden for the same reason `timestamp()`'s is: `uuid().column('user_id').primaryKey()`
90
+ // must still be the KEY form, which is optional on insert, rather than the general one.
91
+ column: (name) => uuidWith<T>({ ...meta, name: assertColumnName(name) }),
77
92
  });
78
93
 
79
94
  export interface TextOptions {
@@ -294,7 +309,34 @@ const parseMoney = (value: unknown): MoneyValue => {
294
309
  * silent 10,000x reinterpretation of a value the type system, `t.money` and `@ultimat3/money` all
295
310
  * carry. `null` in the column is "no explicit scale" and decodes to an ABSENT key, never to `0`.
296
311
  */
297
- export const money = (): Column<MoneyValue> => column<MoneyValue>('money', parseMoney);
312
+ export interface MoneyOptions {
313
+ /**
314
+ * Where the three columns already live, for a table this framework did not create. Named per
315
+ * part and merged over `<name>_minor` / `<name>_currency` / `<name>_scale`, so a legacy
316
+ * `amount_cents` beside a `currency` is two words rather than three.
317
+ *
318
+ * `scale: null` says the table has no scale column at all. What that costs is stated where the
319
+ * option is declared (`MoneyColumnNames`): every amount is then at the currency's own minor
320
+ * unit. What it does NOT cost is correctness — an absent scale already means exactly that.
321
+ */
322
+ readonly columns?: MoneyColumnNames;
323
+ }
324
+
325
+ export const money = (options: MoneyOptions = {}): Column<MoneyValue> =>
326
+ column<MoneyValue>(
327
+ 'money',
328
+ parseMoney,
329
+ options.columns === undefined ? {} : { parts: checkedParts(options.columns) },
330
+ );
331
+
332
+ /** Every part named goes through the same identifier rule a `.column()` does. */
333
+ const checkedParts = (columns: MoneyColumnNames): MoneyColumnNames => ({
334
+ ...(columns.minor === undefined ? {} : { minor: assertColumnName(columns.minor) }),
335
+ ...(columns.currency === undefined ? {} : { currency: assertColumnName(columns.currency) }),
336
+ ...(columns.scale === undefined || columns.scale === null
337
+ ? { ...(columns.scale === null ? { scale: null } : {}) }
338
+ : { scale: assertColumnName(columns.scale) }),
339
+ });
298
340
 
299
341
  /**
300
342
  * Money is the one column whose write type is wider than its row type — `MoneyInput` takes a
package/src/describe.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // Money is the one place the projection is not one-to-one: one property becomes the two
6
6
  // physical columns that back it.
7
7
 
8
- import { referenceBinding, snake } from './column';
8
+ import { columnName, moneyColumns, referenceBinding } from './column';
9
9
  import { currencyCheck, scaleCheck } from './columns';
10
10
  import type { Invariant } from './invariants';
11
11
  import type { ColumnDescription, EntityDescription, ReferenceDescription } from './registry';
@@ -13,6 +13,8 @@ import type { AnyColumn, ColumnMeta, IndexDef } from './types';
13
13
 
14
14
  export interface DescribeInput<Row> {
15
15
  readonly name: string;
16
+ /** The physical table. The entity's own name unless `entity(name, { table })` said otherwise. */
17
+ readonly table: string;
16
18
  readonly columns: readonly (readonly [string, AnyColumn])[];
17
19
  readonly primaryKey: readonly string[];
18
20
  readonly invariants: readonly Invariant<Row>[];
@@ -41,7 +43,7 @@ export const describeReferences = (
41
43
  return [
42
44
  {
43
45
  property,
44
- column: snake(property),
46
+ column: columnName(property, meta),
45
47
  nullable: !meta.notNull,
46
48
  targetEntity: target.table,
47
49
  targetProperty: target.property,
@@ -50,15 +52,38 @@ export const describeReferences = (
50
52
  ];
51
53
  });
52
54
 
55
+ /**
56
+ * The Postgres type a column becomes. `kind` is what the migration generator reads and its table
57
+ * falls through to the kind itself for anything it does not name (`SQL_TYPES[kind] ?? kind`), so a
58
+ * precise type belongs HERE, where the precision, the element and the length are still in scope —
59
+ * the alternative is a second copy of the column vocabulary inside `@ultimat3/db`.
60
+ */
61
+ export const sqlTypeOf = (meta: ColumnMeta): string => {
62
+ if (meta.kind === 'numeric') {
63
+ return meta.precision === undefined || meta.numericScale === undefined
64
+ ? 'numeric'
65
+ : `numeric(${meta.precision}, ${meta.numericScale})`;
66
+ }
67
+ if (meta.kind === 'array') {
68
+ const element = meta.element?.$meta;
69
+ // `arrayOf` refuses an element that is not one scalar column, so this is total in practice;
70
+ // `text[]` is the answer that keeps a description renderable rather than throwing inside a
71
+ // projection, which is the one place an error has no caller to instruct.
72
+ return `${element === undefined ? 'text' : sqlTypeOf(element)}[]`;
73
+ }
74
+ return meta.kind;
75
+ };
76
+
53
77
  const describeColumn = <Row>(
54
78
  input: DescribeInput<Row>,
55
79
  property: string,
56
80
  meta: ColumnMeta,
57
81
  reference: ReferenceDescription | undefined,
58
82
  ): readonly ColumnDescription[] => {
59
- const physical = snake(property);
83
+ const physical = columnName(property, meta);
60
84
  if (meta.kind === 'money') {
61
- const currency = `${physical}_currency`;
85
+ const parts = moneyColumns(property, meta);
86
+ const currency = parts.currency;
62
87
  const shared = {
63
88
  notNull: meta.notNull,
64
89
  primaryKey: false,
@@ -69,7 +94,7 @@ const describeColumn = <Row>(
69
94
  return [
70
95
  {
71
96
  property: `${property}Minor`,
72
- column: `${physical}_minor`,
97
+ column: parts.minor,
73
98
  kind: 'bigint',
74
99
  check: null,
75
100
  ...shared,
@@ -85,21 +110,29 @@ const describeColumn = <Row>(
85
110
  // currency's own minor unit", which is every amount written before the column existed and
86
111
  // every ordinary price after it. A NOT NULL here would demand a scale on values that have
87
112
  // none, and `0` is not that value — it means whole units.
88
- {
89
- property: `${property}Scale`,
90
- column: `${physical}_scale`,
91
- kind: 'integer',
92
- check: scaleCheck(`${physical}_scale`),
93
- ...shared,
94
- notNull: false,
95
- },
113
+ //
114
+ // Absent entirely when the table has none: an adopted amount column predating scale is two
115
+ // physical columns, and describing a third would put a column in the DDL and in every
116
+ // statement that the table does not have.
117
+ ...(parts.scale === null
118
+ ? []
119
+ : [
120
+ {
121
+ property: `${property}Scale`,
122
+ column: parts.scale,
123
+ kind: 'integer',
124
+ check: scaleCheck(parts.scale),
125
+ ...shared,
126
+ notNull: false,
127
+ },
128
+ ]),
96
129
  ];
97
130
  }
98
131
  return [
99
132
  {
100
133
  property,
101
134
  column: physical,
102
- kind: meta.kind,
135
+ kind: sqlTypeOf(meta),
103
136
  notNull: meta.notNull,
104
137
  primaryKey: meta.primaryKey || input.primaryKey.includes(property),
105
138
  unique: meta.unique,
@@ -114,6 +147,10 @@ const describeColumn = <Row>(
114
147
  };
115
148
 
116
149
  export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescription => {
150
+ const physicalOf = (property: string): string => {
151
+ const column = input.columns.find(([key]) => key === property)?.[1];
152
+ return column === undefined ? property : columnName(property, column.$meta);
153
+ };
117
154
  const references = new Map(
118
155
  describeReferences(input.name, input.columns).map((reference) => [
119
156
  reference.property,
@@ -122,8 +159,8 @@ export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescriptio
122
159
  );
123
160
  return {
124
161
  name: input.name,
125
- table: input.name,
126
- primaryKey: input.primaryKey.map((property) => snake(property)),
162
+ table: input.table,
163
+ primaryKey: input.primaryKey.map(physicalOf),
127
164
  columns: input.columns.flatMap(([property, column]) =>
128
165
  describeColumn(input, property, column.$meta, references.get(property)),
129
166
  ),
package/src/entity.ts CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { systemClock } from '@ultimat3/core';
7
7
  import { describeValue, type StandardSchemaV1 } from '@ultimat3/schema';
8
- import { bindColumn, snake } from './column';
8
+ import { assertColumnName, bindColumn, columnName, moneyColumns } from './column';
9
9
  import { newId } from './columns';
10
10
  import { describeEntity, describeReferences } from './describe';
11
11
  import { invariantViolated } from './errors';
@@ -33,6 +33,15 @@ export interface IndexInit<C extends ColumnMap> {
33
33
 
34
34
  export interface EntityInit<C extends ColumnMap> {
35
35
  readonly columns: C;
36
+ /**
37
+ * The physical table, when it is not the entity's own name. The half of adoption a column name
38
+ * cannot cover: `entity('user', { table: 'users', … })` reads and writes the table that is
39
+ * already there, and every statement, index name and foreign key follows it.
40
+ *
41
+ * The entity NAME stays the framework's key — the registry, the cache tag, `x entities describe`
42
+ * and every relation are keyed by it — so renaming a table never moves a cache tag or a policy.
43
+ */
44
+ readonly table?: string;
36
45
  /**
37
46
  * The tenant column, said out loud. Omitted, it is inferred from `.tenant()` or a column named
38
47
  * `orgId`, so silence never means unscoped.
@@ -117,6 +126,7 @@ export const entity = <const C extends ColumnMap>(
117
126
  const entries: readonly (readonly [string, AnyColumn])[] = Object.entries(init.columns);
118
127
  for (const [property, column] of entries) bindColumn(column, name, property);
119
128
 
129
+ const table = init.table === undefined ? name : assertColumnName(init.table);
120
130
  const cacheTag = `entity:${name}`;
121
131
  const softDelete = Object.hasOwn(init.columns, SOFT_DELETE_COLUMN);
122
132
  const tenantColumn = resolveTenantColumn(name, init.columns, init.tenant);
@@ -147,19 +157,22 @@ export const entity = <const C extends ColumnMap>(
147
157
  `${property} is money: name ${property}.minor or ${property}.currency`,
148
158
  );
149
159
  }
150
- return snake(property);
160
+ return columnName(property, column.$meta);
151
161
  }
152
162
  if (!isMoney || !MONEY_PARTS.has(part)) {
153
163
  throw invariantViolated(name, property, `${property} has no part "${part}"`);
154
164
  }
155
- return `${snake(property)}_${part}`;
165
+ const parts = moneyColumns(property, column.$meta);
166
+ return part === 'minor' ? parts.minor : parts.currency;
156
167
  };
157
168
 
158
169
  const columnsExpr = invariantColumns<C>(
159
170
  name,
160
171
  entries.map(([property]) => property),
161
172
  );
162
- const partialWhere = softDelete ? `${snake(SOFT_DELETE_COLUMN)} is null` : undefined;
173
+ // Through the one resolver, so a soft-delete column the table spells differently is still the
174
+ // column every partial index excludes rows by.
175
+ const partialWhere = softDelete ? `${resolve([SOFT_DELETE_COLUMN])} is null` : undefined;
163
176
  // Called once, here, so an unknown column throws while the entity is being declared.
164
177
  const invariants: readonly Invariant<Row>[] = (init.invariants?.(columnsExpr) ?? []).map((def) =>
165
178
  bindInvariant<Row>(def, resolve, partialWhere),
@@ -169,9 +182,11 @@ export const entity = <const C extends ColumnMap>(
169
182
  ...entries.flatMap(([property, column]) => {
170
183
  const meta = column.$meta;
171
184
  if (!meta.unique && !meta.index) return [];
172
- const physical = [meta.kind === 'money' ? `${snake(property)}_minor` : snake(property)];
185
+ const physical = [
186
+ meta.kind === 'money' ? moneyColumns(property, meta).minor : columnName(property, meta),
187
+ ];
173
188
  return [
174
- { name: indexName(name, physical, meta.unique), columns: physical, unique: meta.unique },
189
+ { name: indexName(table, physical, meta.unique), columns: physical, unique: meta.unique },
175
190
  ];
176
191
  }),
177
192
  ...(init.indexes ?? []).map((index) => {
@@ -191,7 +206,7 @@ export const entity = <const C extends ColumnMap>(
191
206
  );
192
207
  }
193
208
  return {
194
- name: indexName(name, columns, unique),
209
+ name: indexName(table, columns, unique),
195
210
  columns,
196
211
  unique,
197
212
  ...(index.order === undefined ? {} : { order: index.order }),
@@ -208,6 +223,7 @@ export const entity = <const C extends ColumnMap>(
208
223
  const describe = (): EntityDescription =>
209
224
  describeEntity({
210
225
  name,
226
+ table,
211
227
  columns: entries,
212
228
  primaryKey,
213
229
  invariants,
@@ -251,7 +267,7 @@ export const entity = <const C extends ColumnMap>(
251
267
 
252
268
  const core: EntityCore<Row, C> = {
253
269
  $name: name,
254
- $table: name,
270
+ $table: table,
255
271
  $columns: init.columns,
256
272
  $primaryKey: primaryKey,
257
273
  $indexes: indexes,
@@ -289,7 +305,7 @@ export const entity = <const C extends ColumnMap>(
289
305
  $references: references,
290
306
  };
291
307
 
292
- registerEntity({ name, tableName: name, describe, references });
308
+ registerEntity({ name, tableName: table, describe, references });
293
309
  // The columns land on the entity itself so `orgs.id` is a column reference; every framework
294
310
  // member is `$`-prefixed, which is why a column may be called `name`.
295
311
  return Object.assign(core, init.columns);
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 {
@@ -102,8 +111,18 @@ export type {
102
111
  UpsertArgs,
103
112
  } from './repo';
104
113
  export { memoryRepo, memoryTransactor } from './repo';
105
- export type { Seed, SeedContext, SeedOptions } from './seed';
106
- export { defineSeed, seedId } from './seed';
114
+ export type {
115
+ Seed,
116
+ SeedContext,
117
+ SeedInit,
118
+ SeedKey,
119
+ SeedMetrics,
120
+ SeedOptions,
121
+ SeedRun,
122
+ SeedTier,
123
+ SeedWrite,
124
+ } from './seed';
125
+ export { defineSeed, isSeed, SEED_TIERS, seedId, seedTiersFor } from './seed';
107
126
  export type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
108
127
  export {
109
128
  assertRowTenant,
@@ -127,6 +146,7 @@ export type {
127
146
  IdOf,
128
147
  IndexDef,
129
148
  Insertable,
149
+ MoneyColumnNames,
130
150
  MoneyInput,
131
151
  MoneyValue,
132
152
  OnDelete,
package/src/pg-driver.ts CHANGED
@@ -25,7 +25,6 @@ import {
25
25
  upsertPlan,
26
26
  } from './bulk-write';
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), systemClock.now()]]),
137
136
  shapeOf({}),
138
137
  false,
139
138
  )
package/src/pg-row.ts CHANGED
@@ -4,7 +4,7 @@
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
8
  import { narrowMoney } from './columns';
9
9
  import type { EntityCore } from './entity';
10
10
  import { invariantViolated } from './errors';
@@ -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
@@ -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. */
@@ -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;
package/src/pg-sql.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // declared. That is the whole reason this file exists instead of a template literal per method.
5
5
 
6
6
  import { identifier, join, raw, type SqlFragment, sql } from '@ultimat3/db';
7
- import { snake } from './column';
7
+ import { columnName } from './column';
8
8
  import type { EntityCore } from './entity';
9
9
  import { SOFT_DELETE_COLUMN } from './entity';
10
10
  import { allColumns, columnsOf, physicalName } from './pg-row';
@@ -111,7 +111,7 @@ const conditions = <Row>(
111
111
  ): SqlFragment => {
112
112
  const parts = plan.where.map((predicate) => predicateSql(entity, predicate));
113
113
  if (entity.$softDelete && !shape.includeDeleted) {
114
- parts.push(sql`${identifier(snake(SOFT_DELETE_COLUMN))} is null`);
114
+ parts.push(sql`${identifier(physicalName(entity, SOFT_DELETE_COLUMN))} is null`);
115
115
  }
116
116
  if (shape.seek !== undefined) parts.push(seekSql(entity, plan.orderBy, shape.seek));
117
117
  return parts.length === 0 ? sql`true` : join(parts, ' and ');
@@ -224,6 +224,35 @@ const conflictSql = (conflict: ConflictTarget): SqlFragment => {
224
224
  )}`;
225
225
  };
226
226
 
227
+ /**
228
+ * The one column that cannot be bound as itself. A `jsonb` value is a plain object, and the
229
+ * driver seam refuses one as a parameter (`X_SQL_UNSAFE` — `isBoundValue` takes scalars, a `Date`,
230
+ * a `Uint8Array` and arrays of those); so `bindValues` hands over the JSON TEXT and the cell says
231
+ * what to do with it.
232
+ *
233
+ * `::text::jsonb` and not `::jsonb`, and the double cast is load-bearing rather than defensive.
234
+ * Measured against Postgres 17.10 through Bun's `sql`: with `$1::jsonb` the server describes the
235
+ * parameter as `jsonb`, the client JSON-ENCODES the string it was given, and `{"a":1}` is stored
236
+ * as the JSON *string* `"{\"a\":1}"` — `jsonb_typeof` says `string`. Pinning the parameter to
237
+ * `text` first makes the client send the characters and the server parse them, which is the one
238
+ * spelling that stores an object.
239
+ */
240
+ /** Physical names of this entity's `jsonb` columns. Resolved ONCE per statement, never per cell. */
241
+ const jsonColumns = <Row>(entity: EntityCore<Row>): ReadonlySet<string> => {
242
+ const names = new Set<string>();
243
+ for (const [property, column] of Object.entries(entity.$columns)) {
244
+ if (column.$meta.kind === 'jsonb') names.add(columnName(property, column.$meta));
245
+ }
246
+ return names;
247
+ };
248
+
249
+ /**
250
+ * `${value}`, plus the cast that column needs. The `raw()` argument is a literal written here and
251
+ * nowhere else — the audit point that call is stays a two-word constant, never a value.
252
+ */
253
+ const cell = (json: ReadonlySet<string>, column: string, value: unknown): SqlFragment =>
254
+ json.has(column) ? sql`${value}${raw('::text::jsonb')}` : sql`${value}`;
255
+
227
256
  /**
228
257
  * One statement for any number of rows. A single row compiles to exactly the text it always did,
229
258
  * which is the point: `insertAll([row])` and `insert(row)` are one code path, so there is no
@@ -234,10 +263,13 @@ export const insertStatement = <Row>(
234
263
  rows: readonly ReadonlyMap<string, unknown>[],
235
264
  shape: InsertShape,
236
265
  ): SqlFragment => {
266
+ const json = jsonColumns(entity);
237
267
  const tuples = rows.map(
238
268
  (row) =>
239
269
  sql`(${join(
240
- shape.columns.map((column) => (row.has(column) ? sql`${row.get(column)}` : DEFAULT_CELL)),
270
+ shape.columns.map((column) =>
271
+ row.has(column) ? cell(json, column, row.get(column)) : DEFAULT_CELL,
272
+ ),
241
273
  )})`,
242
274
  );
243
275
  const conflict = shape.conflict === undefined ? sql`` : conflictSql(shape.conflict);
@@ -259,10 +291,12 @@ export const updateStatement = <Row>(
259
291
  values: ReadonlyMap<string, unknown>,
260
292
  shape: ReadShape,
261
293
  returning: boolean,
262
- ): SqlFragment =>
263
- sql`update ${identifier(entity.$table)} set ${join(
264
- [...values].map(([column, value]) => sql`${identifier(column)} = ${value}`),
294
+ ): SqlFragment => {
295
+ const json = jsonColumns(entity);
296
+ return sql`update ${identifier(entity.$table)} set ${join(
297
+ [...values].map(([column, value]) => sql`${identifier(column)} = ${cell(json, column, value)}`),
265
298
  )} where ${conditions(entity, plan, shape)}${returning ? sql` returning *` : sql``}`;
299
+ };
266
300
 
267
301
  /** Only reached when the entity has no soft-delete column, so there is no filter to apply. */
268
302
  export const deleteStatement = <Row>(entity: EntityCore<Row>, plan: QueryPlan): SqlFragment =>