@ultimat3/entity 13.0.0 → 15.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 CHANGED
@@ -33,6 +33,23 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
33
33
  `pg-driver-bulk.live.test.ts`, `pg-driver-tenancy.live.test.ts`). A method with only the first is
34
34
  unproven against Postgres itself; a method with only the second is unproven against memory. Both
35
35
  are the bar, not either one.
36
+ - **Money's write shape is wider than its row shape, and both drivers narrow it at the WRITE
37
+ METHOD'S entry — `As of 2026-08-25`.** `MoneyInput` lets a writer hand a `bigint` minor unit read
38
+ straight off a `bigint` column; `MoneyValue` is what a row holds, because `JSON.stringify` refuses
39
+ a `bigint` and money crosses every wire this framework projects. `RowWrite<Row>` is the type that
40
+ says so at `Repo.insert`/`insertAll`/`upsertAll`, which took the ROW type instead — so the
41
+ widening this package documents, narrows and stores correctly was a **compile error at the only
42
+ call an app makes**, `postgresRepo()` being exported, and it was the last two entries on
43
+ `scripts/lib/test-typecheck-pins.ts`. `narrowRow` (`columns.ts`) is the narrowing, called at each
44
+ entry rather than deep inside `bindValues`/`write`, and the POSITION is the rule. `entity.$assert`
45
+ and `upsertPlan` both run before a statement exists, so an invariant reading `total.minor` was
46
+ handed the caller's `bigint` and never the `number` the row would hold — it rejected rows both
47
+ drivers then stored correctly. And it decides whether a refusal costs a row: Bun's client binds a
48
+ `bigint` verbatim (measured), so a minor unit past ±2^53 narrowed any later is INSERTed,
49
+ committed, and only then refused by the decode of its own `returning *` — a row the app wrote and
50
+ can never read. `pg-money-write.live.test.ts` is the proof, because only a real table can see
51
+ that; `money-write-parity.test.ts` pins both drivers together, and `type-pins.ts` fails the build
52
+ if those three writes stop taking `RowWrite` or start answering with it.
36
53
  - **What a PREDICATE means is decided by the column's declared KIND, and `memory-match.ts` is
37
54
  where that one meaning is written.** The database decides by the column's type, so a driver
38
55
  deciding by the JS `typeof` of the value in hand is answering a different question — four rules,
@@ -776,8 +793,28 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
776
793
  - **Every framework member on an entity is `$`-prefixed** — the columns are `Object.assign`ed onto
777
794
  the core, so an unprefixed member would make `view`, `name` or `tenant` an illegal column name.
778
795
  `$view`, never `view`; no free `view(entity, keys)` either — one way to write a projection.
779
- - **Invariants run twice**: in the app on write AND as a Postgres CHECK/UNIQUE via `toSql()`. An
780
- untranslatable JS predicate reports `kind: 'assert'`, `sql: null` — never a pretend CHECK.
796
+ - **Invariants run twice, and only ONE side of the pair is rendered here.** In the app on write
797
+ (`assertInvariants`), and as a Postgres CHECK/UNIQUE emitted by `@ultimat3/db` —
798
+ `constraintNameFor`, `declaredChecks`, `declaredIndexes` (`invariant-ddl.ts`), reading
799
+ `$describe()`. An untranslatable JS predicate reports `kind: 'assert'`, `sql: null` — never a
800
+ pretend CHECK. **This package rendered a second copy of that DDL until 2026-08-25**
801
+ (`toSql`/`invariantsToSql`/`constraintName`, reachable through `entity.$migration()`), and the
802
+ copy is the argument: nothing but its own tests ever called it, so nobody noticed it passed the
803
+ entity NAME where the table belongs — `entity('account', { table: 'legacy_accounts' })` rendered
804
+ `ALTER TABLE "account" ADD CONSTRAINT "account_…_check"`, a relation Postgres answers `42P01` for
805
+ and a constraint name no migration has ever written. All four are deleted; `$migration()` was on
806
+ `EntityCore`, so this is a breaking change to a documented member. Never render constraint DDL
807
+ here again — the entity's job is to DESCRIBE the rule, and `<table>_<name>_<check|key>` now has
808
+ exactly one source.
809
+ - **`InvariantDescription.columns` is projected, `As of 2026-08-25`** — the physical names the rule
810
+ reads, for every kind, straight off `Invariant.columns`. Same argument as `onDelete`, `generated`
811
+ and `default` on `ColumnDescription`: `@ultimat3/db` is tier 1 and cannot import this package, so
812
+ a fact this projection drops is a fact the generator must recover from a rendering. It was
813
+ recovering it — `uniqueColumns()` split a `unique` rule's `sql` on commas and re-validated each
814
+ part — which is the shape that made `posts_org_id_created_at_idx` read back as the single column
815
+ `"org_id_created_at"`. `snapshotOf` derives from `declaredChecks`/`declaredIndexes` and not from
816
+ this record, so the field changes no snapshot and nothing regenerates
817
+ (`describe-invariant.test.ts` pins both halves).
781
818
  - **And the two halves must AGREE, term by term** (`expr.ts`). A rule the app accepts and the CHECK
782
819
  refuses is not a stricter database: the write comes back as a raw constraint error instead of
783
820
  `X_INVARIANT_VIOLATED`, which is the framework's own invariant bypassed on the way out. Two
@@ -997,7 +1034,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
997
1034
  | `columns-data.ts` | the wide vocabulary an existing schema needs: `json`, `decimal`, `date`, `bigint`, `bytes`, `arrayOf` |
998
1035
  | `array-element.ts` | which element kinds `arrayOf()` refuses, and the one-line edit that repairs each |
999
1036
  | `refuse.ts` | `refuseColumn`/`refuseInvariant` — the refusals raised before any entity exists, each carrying the EDIT that repairs it |
1000
- | `expr.ts` / `invariants.ts` | the `invariants: (c) => …` rule language; bind + `toSql()` DDL |
1037
+ | `expr.ts` / `invariants.ts` | the `invariants: (c) => …` rule language; `bindInvariant` resolves property paths to physical names. No DDL — that is `@ultimat3/db`'s `invariant-ddl.ts` |
1001
1038
  | `entity.ts` / `describe.ts` | `entity()`, `$row`; the `EntityDescription` projection |
1002
1039
  | `index-name.ts` | what an index is CALLED — the predicate/direction/method discriminator and the 63-byte bound |
1003
1040
  | `search.ts` | the generated `tsvector` a `.searchable()` column set derives: the closed language list, the weights, the expression |
package/README.md CHANGED
@@ -61,7 +61,7 @@ still imports one package: `import { entity, t } from '@ultimat3/entity'`.
61
61
  |---|---|---|
62
62
  | `uuid()`, `uuid<PostId>()` | `uuid`; `.primaryKey()` defaults to v7 | time-ordered keys keep the pk index append-friendly; the optional brand is declared once and survives to every signature |
63
63
  | `timestamp()` | `timestamptz` | UTC storage is not a per-table decision; there is no naive variant |
64
- | `money()` | `<name>_minor bigint` + `<name>_currency char(3)` + `<name>_scale integer null` | never a float, never one implied currency. The row value is `@ultimat3/schema`'s `MoneyValue` — the same declaration `@ultimat3/money`'s `Money` is — so a decoded row goes straight to `add()`/`formatMoney()`. A writer may hand a `bigint`; a stored minor unit past ±2^53 is refused on read, never rounded. `scale` is the decimal exponent `minor` counts in when it is not the currency's own (`{ minor: 2, currency: 'USD', scale: 6 }` is $0.000002); NULL in the column means "the currency's own minor unit" and round-trips as an ABSENT key, never as `0` |
64
+ | `money()` | `<name>_minor bigint` + `<name>_currency char(3)` + `<name>_scale integer null` | never a float, never one implied currency. The row value is `@ultimat3/schema`'s `MoneyValue` — the same declaration `@ultimat3/money`'s `Money` is — so a decoded row goes straight to `add()`/`formatMoney()`. A writer may hand a `bigint` — `Insertable` says so on a table, `RowWrite<Row>` at a repository, and every whole-row write narrows it at its own entry, before an invariant or a statement sees it; a minor unit past ±2^53 is refused there and on read, never rounded. `scale` is the decimal exponent `minor` counts in when it is not the currency's own (`{ minor: 2, currency: 'USD', scale: 6 }` is $0.000002); NULL in the column means "the currency's own minor unit" and round-trips as an ABSENT key, never as `0` |
65
65
  | `enumerated(v)` | `text` + CHECK | a variant is a one-line migration, not `ALTER TYPE` |
66
66
  | `tz(zones)`, `locale(tags)` | `text` + CHECK, `Intl`-validated at declaration | an offset is not a time zone |
67
67
  | `text({ max })`, `integer()`, `boolean()`, `url()` | `text`/`integer`/`boolean` + CHECK | format is enforced by the database too |
@@ -142,9 +142,13 @@ Nothing is checked at runtime: a brand has no witness, `$parse` still validates
142
142
  One declaration, two enforcement points: the app checks it on every write, and the migration
143
143
  emits it. A bulk import or a `psql` session hits the same rule.
144
144
 
145
+ The DDL is `@ultimat3/db`'s — one renderer, reading `$describe()`, so what `x db gen` writes is the
146
+ only spelling there is. On a table this migration creates the check is an inline clause; on one that
147
+ already exists it is the `alter table` beside it.
148
+
145
149
  ```sql
146
- ALTER TABLE "posts" ADD CONSTRAINT "posts_post_like_count_non_negative_check" CHECK (like_count >= 0);
147
- CREATE UNIQUE INDEX "posts_post_slug_unique_per_org_key" ON "posts" ("org_id", "slug");
150
+ alter table "posts" add constraint "posts_post_like_count_non_negative_check" check (like_count >= 0);
151
+ create unique index "posts_post_slug_unique_per_org_key" on "posts" ("org_id", "slug");
148
152
  ```
149
153
 
150
154
  A rule written as a JS predicate — `c.slug.matches(isValidSlug)`, `c.satisfies(fn, [...])` —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "13.0.0",
3
+ "version": "15.0.0",
4
4
  "description": "A table + its domain type + invariants the database also enforces",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,9 +31,9 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "13.0.0",
35
- "@ultimat3/db": "13.0.0",
36
- "@ultimat3/schema": "13.0.0",
37
- "@ultimat3/time": "13.0.0"
34
+ "@ultimat3/core": "15.0.0",
35
+ "@ultimat3/db": "15.0.0",
36
+ "@ultimat3/schema": "15.0.0",
37
+ "@ultimat3/time": "15.0.0"
38
38
  }
39
39
  }
package/src/columns.ts CHANGED
@@ -27,6 +27,7 @@ import type {
27
27
  MoneyColumnNames,
28
28
  MoneyInput,
29
29
  MoneyValue,
30
+ RowWrite,
30
31
  TimestampColumn,
31
32
  UuidColumn,
32
33
  } from './types';
@@ -387,6 +388,22 @@ export const narrowMoney = <Row>(columns: ColumnMap, row: Row): Row => {
387
388
  return (narrowed ?? row) as Row;
388
389
  };
389
390
 
391
+ /**
392
+ * The same narrowing at a write METHOD'S entry, typed honestly: what a caller may spell in,
393
+ * the row the entity declares out.
394
+ *
395
+ * `narrowMoney` is `<Row>(columns, row: Row): Row` — sound for `bindValues`, whose input and
396
+ * output are both a patch, and a lie for a full row that arrived as `RowWrite<Row>`, which is
397
+ * exactly the position `Repo.insert`/`insertAll`/`upsertAll` are in. Narrowing here rather than at
398
+ * `bindValues` is also what makes an `assert` invariant judge the value the row will HOLD instead
399
+ * of the spelling a caller happened to use: `entity.$assert` runs before the statement exists, so
400
+ * a rule reading `total.minor` saw the caller's `bigint` in both drivers and the stored `number`
401
+ * nowhere. The `as` is the one assertion this file is for — every money property is a `MoneyValue`
402
+ * once `narrowMoney` has returned, and `parseMinor` threw for anything that could not become one.
403
+ */
404
+ export const narrowRow = <Row>(columns: ColumnMap, values: RowWrite<Row>): Row =>
405
+ narrowMoney(columns, values) as Row;
406
+
390
407
  /**
391
408
  * The CHECK that stops a psql session writing a currency the app would refuse — the app's own
392
409
  * bound, projected into SQL rather than restated in it.
package/src/describe.ts CHANGED
@@ -170,6 +170,9 @@ const describeColumn = <Row>(
170
170
  primaryKey: meta.primaryKey || input.primaryKey.includes(property),
171
171
  unique: meta.unique,
172
172
  hasDefault: meta.default !== undefined,
173
+ // The value beside the boolean: `@ultimat3/db` renders it, and without it the generator
174
+ // could only infer two expressions and dropped every other default without saying so.
175
+ ...(meta.default === undefined ? {} : { default: meta.default }),
173
176
  check: meta.check?.(physical) ?? null,
174
177
  // Rendered from the resolved record, so the string a migration reads and the record a
175
178
  // traversal reads can never disagree about what a `references()` points at.
@@ -205,12 +208,15 @@ export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescriptio
205
208
  // entity without a search vector moves.
206
209
  ...(input.search == null ? [] : [describeSearchColumn(input.search)]),
207
210
  ],
211
+ // `columns` rides along whole: the generator needs the list a `unique` names, and recovering
212
+ // it by splitting `sql` is reading this package's own rendering back. See InvariantDescription.
208
213
  invariants: input.invariants.map((inv) => ({
209
214
  name: inv.name,
210
215
  kind: inv.kind,
211
216
  message: inv.message,
212
217
  sql: inv.sql,
213
218
  where: inv.where ?? null,
219
+ columns: inv.columns,
214
220
  })),
215
221
  // Projected whole, never reduced to the name: the generator spells the column list from this
216
222
  // and a name cannot be parsed back into one. See `IndexDescription`.
package/src/entity.ts CHANGED
@@ -15,7 +15,7 @@ import type { Expr, InvariantColumns, Resolve } from './expr';
15
15
  import { invariantColumns } from './expr';
16
16
  import { indexName } from './index-name';
17
17
  import type { Invariant, InvariantDef } from './invariants';
18
- import { assertInvariants, bindInvariant, invariantsToSql } from './invariants';
18
+ import { assertInvariants, bindInvariant } from './invariants';
19
19
  import type { EntityDescription, ReferenceDescription } from './registry';
20
20
  import { registerEntity } from './registry';
21
21
  import type { SearchInit, SearchSource, SearchVector } from './search';
@@ -124,8 +124,10 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
124
124
  $view<K extends keyof Row & string>(keys: readonly K[]): EntityView<Row, K>;
125
125
  /** Runs every invariant. Called by the repository on insert and update. */
126
126
  $assert(row: Row): void;
127
- /** The CHECK/UNIQUE statements the migration emits for this entity. */
128
- $migration(): string;
127
+ // No `$migration()`. The CHECK/UNIQUE statements an entity contributes are `@ultimat3/db`'s to
128
+ // render, off `$describe()`, beside the columns, indexes and foreign keys they have to be
129
+ // ordered against — a fragment of a migration is not one, and the one here named the wrong
130
+ // relation for three majors because nothing but its own test ever read it.
129
131
  $describe(): EntityDescription;
130
132
  /**
131
133
  * The foreign keys this entity declares, resolved — one record per `references()`, both ends
@@ -414,7 +416,6 @@ export const entity = <const C extends ColumnMap>(
414
416
  $view: <K extends keyof Row & string>(keys: readonly K[]) =>
415
417
  viewFor<Row, K>(name, init.columns, keys),
416
418
  $assert: (row) => assertInvariants(name, invariants, row),
417
- $migration: () => invariantsToSql(name, invariants),
418
419
  $describe: describe,
419
420
  $references: references,
420
421
  };
package/src/index.ts CHANGED
@@ -75,14 +75,7 @@ export {
75
75
  stateUndeclared,
76
76
  } from './feature-errors';
77
77
  export type { Invariant, InvariantDef, InvariantKind } from './invariants';
78
- export {
79
- assertInvariants,
80
- constraintName,
81
- invariant,
82
- invariantsToSql,
83
- MAX_ASSERTED_ROWS,
84
- toSql,
85
- } from './invariants';
78
+ export { assertInvariants, invariant, MAX_ASSERTED_ROWS } from './invariants';
86
79
  export { memoryRepo, memoryTransactor } from './memory-repo';
87
80
  export type { StatementLoop } from './n-plus-one';
88
81
  export { N_PLUS_ONE_THRESHOLD, nPlusOne, preloadsFor } from './n-plus-one';
@@ -193,6 +186,7 @@ export type {
193
186
  ReferenceOptions,
194
187
  RowOf,
195
188
  RowPatch,
189
+ RowWrite,
196
190
  SearchWeight,
197
191
  TimestampColumn,
198
192
  TypeOf,
package/src/invariants.ts CHANGED
@@ -69,28 +69,13 @@ export const bindInvariant = <T>(
69
69
  };
70
70
  };
71
71
 
72
- export const constraintName = (
73
- table: string,
74
- inv: { readonly name: string; readonly kind: InvariantKind },
75
- ): string => `${table}_${inv.name}_${inv.kind === 'unique' ? 'key' : 'check'}`;
76
-
77
- /** The DDL the migration emits. One statement, terminated, ready to diff. */
78
- export const toSql = <T>(table: string, inv: Invariant<T>): string | null => {
79
- if (inv.sql === null) return null;
80
- const name = constraintName(table, inv);
81
- if (inv.kind === 'check') {
82
- return `ALTER TABLE "${table}" ADD CONSTRAINT "${name}" CHECK (${inv.sql});`;
83
- }
84
- const where = inv.where === undefined ? '' : ` WHERE ${inv.where}`;
85
- const columns = inv.columns.map((column) => `"${column}"`).join(', ');
86
- return `CREATE UNIQUE INDEX "${name}" ON "${table}" (${columns})${where};`;
87
- };
88
-
89
- export const invariantsToSql = <T>(table: string, invariants: readonly Invariant<T>[]): string =>
90
- invariants
91
- .map((inv) => toSql(table, inv))
92
- .filter((statement): statement is string => statement !== null)
93
- .join('\n');
72
+ // The DDL an invariant becomes is NOT rendered here. `@ultimat3/db` owns it — `constraintNameFor`,
73
+ // `declaredChecks` and `declaredIndexes` (`invariant-ddl.ts`), reading `$describe()` — and this
74
+ // package rendered a second copy of the same fact until 2026-08-25. The copy is what made the case
75
+ // for one renderer: it was reachable only through `entity.$migration()`, which passed the entity
76
+ // NAME where the table belongs, so `entity('account', { table: 'legacy_accounts' })` rendered
77
+ // `ALTER TABLE "account" ADD CONSTRAINT "account_seats_non_negative_check" …` a relation that
78
+ // does not exist and a constraint name no migration ever wrote. Never re-render it here.
94
79
 
95
80
  /**
96
81
  * Whether any rule here can only be judged in the app. This is what decides whether a FILTERED
@@ -11,7 +11,7 @@ import { foldAggregate } from './aggregate-fold';
11
11
  import { keyOf } from './batch-read';
12
12
  import { conflictKeyOf, conflictKeys, upsertPlan } from './bulk-write';
13
13
  import { entityNow } from './clock';
14
- import { narrowMoney } from './columns';
14
+ import { narrowRow } from './columns';
15
15
  import { countsFrom, groupColumnOf } from './count-by';
16
16
  import { cursorFor, kindOf, seekFrom, valueAt } from './cursor';
17
17
  import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
@@ -21,6 +21,7 @@ import { deletePlan, idPlan, readPlan, singleKeyOf, updatePlan } from './plan';
21
21
  import type { FindManyArgs, MemoryRepo, RepoOptions, Transactor, Tx } from './repo';
22
22
  import type { QueryPlan } from './tenancy';
23
23
  import { assertRowTenant } from './tenancy';
24
+ import type { RowWrite } from './types';
24
25
 
25
26
  const field = (row: unknown, property: string): unknown =>
26
27
  typeof row === 'object' && row !== null ? (row as Record<string, unknown>)[property] : undefined;
@@ -113,11 +114,19 @@ export const memoryRepo = <Row>(
113
114
  return { plan, found: rowsOf(plan, args) };
114
115
  };
115
116
 
116
- const write = (given: Row, options: RepoOptions | undefined, operation: string): Row => {
117
+ /** Money's write shape narrowed once per batch, at the method the caller reached. */
118
+ const narrowed = (batch: readonly RowWrite<Row>[]): readonly Row[] =>
119
+ batch.map((row) => narrowRow<Row>(entity.$columns, row));
120
+
121
+ const write = (
122
+ given: RowWrite<Row>,
123
+ options: RepoOptions | undefined,
124
+ operation: string,
125
+ ): Row => {
117
126
  // `MoneyInput` lets a writer hand a `bigint`; a stored row holds the value type. The Postgres
118
- // driver narrows in `bindValues` and reads its answer back through `returning *`, so without
119
- // this an in-memory row would be the one row in the framework `JSON.stringify` refuses.
120
- const row = narrowMoney(entity.$columns, given);
127
+ // driver narrows at the same position its write methods' entry so without this an
128
+ // in-memory row would be the one row in the framework `JSON.stringify` refuses.
129
+ const row = narrowRow<Row>(entity.$columns, given);
121
130
  // Beside `$assert`, and before the row lands: a write is judged by the tenant it names as well
122
131
  // as by the invariants it declares, and the Postgres driver runs the same pair in `writeRows`.
123
132
  // `update` reaches here with the STORED row merged under its patch, so a patch that moves a row
@@ -184,11 +193,15 @@ export const memoryRepo = <Row>(
184
193
  return write(values, options, 'insert');
185
194
  },
186
195
 
187
- async insertAll(batch, options) {
196
+ async insertAll(given, options) {
188
197
  // The whole batch is judged before any of it lands: Postgres refuses the statement as one,
189
198
  // so a row an invariant rejects — or one naming a tenant this actor may not write — must not
190
199
  // leave the rows before it stored here either. `write` re-checks both per row; this loop is
191
200
  // what makes the batch all-or-nothing, which is the half a per-row check cannot give.
201
+ //
202
+ // Narrowed FIRST, so what this loop judges is what `write` will store: `$assert` was handed
203
+ // the caller's `bigint` minor unit here and the narrowed `number` one call later.
204
+ const batch = narrowed(given);
192
205
  for (const row of batch) {
193
206
  assertRowTenant(entity.$name, entity.$tenantColumn, 'insertAll', row);
194
207
  entity.$assert(row);
@@ -196,10 +209,12 @@ export const memoryRepo = <Row>(
196
209
  return batch.map((row) => write(row, options, 'insertAll'));
197
210
  },
198
211
 
199
- async upsertAll(batch, args) {
212
+ async upsertAll(given, args) {
200
213
  // The INCOMING rows, judged before any of them is matched: under `onMatch: 'nothing'` a
201
214
  // colliding row is skipped and never reaches `write()`, so checking only what lands would
202
- // let a row naming another tenant through whenever it happened to collide.
215
+ // let a row naming another tenant through whenever it happened to collide. Narrowed first
216
+ // for the reason `insertAll` above is, and before `conflictKeyOf` reads a target too.
217
+ const batch = narrowed(given);
203
218
  for (const row of batch) {
204
219
  assertRowTenant(entity.$name, entity.$tenantColumn, 'upsertAll', row);
205
220
  entity.$assert(row);
package/src/pg-driver.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  import { entityNow } from './clock';
29
29
  import { coalesceFindById } from './coalesce';
30
30
  import { moneyColumns } from './column';
31
+ import { narrowRow } from './columns';
31
32
  import { countsFrom, groupColumnOf, groupValue, MAX_GROUPS } from './count-by';
32
33
  import { cursorFor, seekFrom, valueAt } from './cursor';
33
34
  import type { Driver } from './database';
@@ -58,6 +59,7 @@ import { deletePlan, idPlan, readPlan, updatePlan } from './plan';
58
59
  import type { FindManyArgs, Repo, Transactor, UpsertArgs } from './repo';
59
60
  import type { QueryPlan } from './tenancy';
60
61
  import { assertRowTenant } from './tenancy';
62
+ import type { RowWrite } from './types';
61
63
 
62
64
  export interface PostgresDriverOptions {
63
65
  /**
@@ -121,6 +123,11 @@ export const postgresRepo = <Row>(
121
123
  const idOf = (row: Row): string =>
122
124
  entity.$primaryKey.map((property) => String(valueAt(row, property))).join('');
123
125
 
126
+ /** Where a batch stops being wide — before `writeRows` or `upsertPlan` read a row. `narrowRow`
127
+ * says why the position matters, and `memoryRepo` narrows at the same one. */
128
+ const narrowed = (batch: readonly RowWrite<Row>[]): readonly Row[] =>
129
+ batch.map((row) => narrowRow<Row>(entity.$columns, row));
130
+
124
131
  const one = async (plan: QueryPlan, args: FindManyArgs): Promise<Row | null> => {
125
132
  const [found] = await client().query<PhysicalRow>(
126
133
  selectStatement(entity, plan, shapeOf(args), 1),
@@ -255,21 +262,24 @@ export const postgresRepo = <Row>(
255
262
  },
256
263
 
257
264
  async insert(values) {
258
- const [written] = await writeRows('insert', [values], undefined);
259
- // `returning *` is the row Postgres actually stored, defaults included.
260
- return written ?? values;
265
+ const row = narrowRow<Row>(entity.$columns, values);
266
+ const [written] = await writeRows('insert', [row], undefined);
267
+ // `returning *` is the row Postgres actually stored, defaults included. The fallback is the
268
+ // NARROWED row: handing the caller's back would answer with a `bigint` minor unit.
269
+ return written ?? row;
261
270
  },
262
271
 
263
272
  async insertAll(batch) {
264
- return writeRows('insertAll', batch, undefined);
273
+ return writeRows('insertAll', narrowed(batch), undefined);
265
274
  },
266
275
 
267
276
  async upsertAll(batch, args: UpsertArgs<Row>) {
268
- const plan = upsertPlan(entity, batch, args.onConflict, args.onMatch ?? 'update');
277
+ const rows = narrowed(batch);
278
+ const plan = upsertPlan(entity, rows, args.onConflict, args.onMatch ?? 'update');
269
279
  // Refused here, not by the server: a batch that repeats a conflict target is `21000` in
270
280
  // Postgres and a silent overwrite in memory, and the two drivers have to mean one thing.
271
- conflictKeys(entity, plan, batch);
272
- return writeRows('upsertAll', batch, {
281
+ conflictKeys(entity, plan, rows);
282
+ return writeRows('upsertAll', rows, {
273
283
  columns: insertColumns(entity, plan.on),
274
284
  set: insertColumns(entity, plan.set),
275
285
  });
package/src/registry.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  import type { IndexMethod } from '@ultimat3/db';
7
7
  import { entityDuplicate } from './errors';
8
8
  import type { InvariantKind } from './invariants';
9
- import type { OnDelete } from './types';
9
+ import type { ColumnDefault, OnDelete } from './types';
10
10
 
11
11
  export interface ColumnDescription {
12
12
  readonly property: string;
@@ -34,6 +34,16 @@ export interface ColumnDescription {
34
34
  * field that is not on this projection reaches no DDL at all.
35
35
  */
36
36
  readonly generated?: string;
37
+ /**
38
+ * The declared default, when there is one — the VALUE, not merely `hasDefault`'s boolean.
39
+ *
40
+ * Same reason as `onDelete` and `generated`: `@ultimat3/db` is tier 1 and cannot import this
41
+ * package, so a fact that is not on this projection reaches no DDL. `hasDefault` alone let the
42
+ * generator infer only `gen_random_uuid()` and `now()`; every other default was dropped in
43
+ * silence, and a regenerated `examples/dummy` lost nine of them. Absent on a column declaring
44
+ * none, so a description written before this existed reads the same and nothing regenerates.
45
+ */
46
+ readonly default?: ColumnDefault;
37
47
  }
38
48
 
39
49
  /**
@@ -64,6 +74,18 @@ export interface InvariantDescription {
64
74
  /** `null` for an `assert`: a JS predicate the database was never told about. */
65
75
  readonly sql: string | null;
66
76
  readonly where: string | null;
77
+ /**
78
+ * The physical columns this rule reads — the same list `Invariant.columns` holds, for every
79
+ * kind, never narrowed to `unique`.
80
+ *
81
+ * Same reason as `onDelete`, `generated` and `default` on `ColumnDescription`: `@ultimat3/db` is
82
+ * tier 1 and cannot import this package, so a fact absent here is a fact the generator has to
83
+ * recover from a rendering. Without it `uniqueColumns()` split a `unique` rule's `sql` on commas
84
+ * and re-validated each part as an identifier — a comma-split of text this package joined, which
85
+ * is the shape `parseIndexName` failed at when `posts_org_id_created_at_idx` became the single
86
+ * column `"org_id_created_at"`. Carried, so the split has nothing left to do.
87
+ */
88
+ readonly columns: readonly string[];
67
89
  }
68
90
 
69
91
  /**
package/src/repo.ts CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  import type { AggregateFn } from './aggregate';
11
11
  import type { Predicate, SortKey } from './tenancy';
12
- import type { IdOf, RowPatch } from './types';
12
+ import type { IdOf, RowPatch, RowWrite } from './types';
13
13
 
14
14
  export interface Tx {
15
15
  readonly id: string;
@@ -80,11 +80,16 @@ export interface Page<T> {
80
80
  * The id parameters are `IdOf<T>`, not `string`: an entity that declared `uuid<PostId>()` is
81
81
  * addressed by a `PostId` and by nothing else. `IdOf<unknown>` and `IdOf<{ id: string }>` are
82
82
  * both `string`, so a row-agnostic consumer sees the signature it always saw.
83
+ *
84
+ * The whole-row writes take `RowWrite<T>` and the filtered ones `RowPatch<T>`, which are one
85
+ * statement in two shapes: money's write type is wider than its row type, and every one of these
86
+ * five entry points narrows it — `narrowRow` — before anything reads the row. Taking `T` here made
87
+ * the documented `bigint` minor unit unspellable at the only call an app makes.
83
88
  */
84
89
  export interface Repo<T = unknown> {
85
90
  findById(id: IdOf<T>, options?: FindByIdOptions): Promise<T | null>;
86
91
  findMany(args?: FindManyArgs): Promise<Page<T>>;
87
- insert(values: T, options?: RepoOptions): Promise<T>;
92
+ insert(values: RowWrite<T>, options?: RepoOptions): Promise<T>;
88
93
  /**
89
94
  * Many rows, one statement — the bulk form a per-row `insert` loop is the N+1 of. Resolves with
90
95
  * the rows as stored, defaults included, in the order given; an empty batch writes nothing and
@@ -92,13 +97,13 @@ export interface Repo<T = unknown> {
92
97
  * Past Postgres's bind count the batch becomes several statements, so wrap it in
93
98
  * `withTransaction` when all-or-nothing matters.
94
99
  */
95
- insertAll(rows: readonly T[], options?: RepoOptions): Promise<readonly T[]>;
100
+ insertAll(rows: readonly RowWrite<T>[], options?: RepoOptions): Promise<readonly T[]>;
96
101
  /**
97
102
  * `insertAll` that resolves a collision instead of failing on it. Resolves with the rows this
98
103
  * call actually wrote — under `onMatch: 'nothing'` a row already stored is skipped and absent,
99
104
  * which is what `returning *` says on the Postgres side.
100
105
  */
101
- upsertAll(rows: readonly T[], args: UpsertArgs<T>): Promise<readonly T[]>;
106
+ upsertAll(rows: readonly RowWrite<T>[], args: UpsertArgs<T>): Promise<readonly T[]>;
102
107
  update(id: IdOf<T>, patch: RowPatch<T>, options?: RepoOptions): Promise<T>;
103
108
  delete(id: IdOf<T>, options?: RepoOptions): Promise<void>;
104
109
  /**
package/src/type-pins.ts CHANGED
@@ -309,3 +309,39 @@ type _MoneyInputTakesABigInt = Assert<
309
309
 
310
310
  /** And a row value is always a legal input: read a row, write it back. */
311
311
  type _MoneyValueIsMoneyInput = Assert<[MoneyValue] extends [MoneyInput] ? true : false>;
312
+
313
+ /** A row with the one column whose write type is wider than its row type, and nothing else. */
314
+ type PinMoneyRow = { readonly id: string; readonly price: MoneyValue };
315
+
316
+ type PinWideMoneyRow = {
317
+ readonly id: string;
318
+ readonly price: { readonly minor: bigint; readonly currency: string };
319
+ };
320
+
321
+ /**
322
+ * The pin the two above needed all along. `MoneyInput` declares the widening and could never
323
+ * enforce it end to end: `Repo.insert` took the ROW type, so the one call an app makes refused the
324
+ * value those lines call legal — a compile error at `postgresRepo().insert(...)`, which is exported
325
+ * and therefore public API, while both drivers narrowed it correctly at runtime. Pinned at `Repo`
326
+ * rather than at `RowWrite`, because a mapped type is only worth having where it is spent.
327
+ */
328
+ type _RepoInsertTakesABigIntMinor = Assert<
329
+ [PinWideMoneyRow] extends [Parameters<Repo<PinMoneyRow>['insert']>[0]] ? true : false
330
+ >;
331
+
332
+ /** Every whole-row write, not just the single one — three entry points, one shape. */
333
+ type _RepoBatchWritesTakeABigIntMinor = Assert<
334
+ [readonly PinWideMoneyRow[]] extends [Parameters<Repo<PinMoneyRow>['insertAll']>[0]] &
335
+ [Parameters<Repo<PinMoneyRow>['upsertAll']>[0]]
336
+ ? true
337
+ : false
338
+ >;
339
+
340
+ /**
341
+ * And the half that must NOT widen: what a repository answers with is the row type. `minor` stays
342
+ * a `number` because money crosses every wire this framework projects and `JSON.stringify` refuses
343
+ * a `bigint` — the widening is the caller's spelling, never the row's.
344
+ */
345
+ type _RepoAnswersTheValueType = Assert<
346
+ [Awaited<ReturnType<Repo<PinMoneyRow>['insert']>>] extends [PinMoneyRow] ? true : false
347
+ >;
package/src/types.ts CHANGED
@@ -281,6 +281,23 @@ export type Insertable<C extends ColumnMap> = {
281
281
  readonly [K in DefaultedKeys<C> | NullableKeys<C>]?: InputOf<TypeOf<C[K]>>;
282
282
  };
283
283
 
284
+ /**
285
+ * A whole row as a WRITER may spell it — the row's own type, or money's wider write shape.
286
+ *
287
+ * `Insertable` says this at the `database()` seam, where the columns are still in hand;
288
+ * `Repo.insert`/`insertAll`/`upsertAll` reach the same rows one layer down with only `Row`, and
289
+ * they took `Row` itself — so the `bigint` minor unit `MoneyInput` documents, `narrowMoney` exists
290
+ * to narrow and both drivers already store correctly was a compile error at the one entry point a
291
+ * caller uses. `postgresRepo()` is exported, so that caller is public API, not an internal detour.
292
+ *
293
+ * `Row[K]` stays in the union rather than being replaced by `InputOf<Row[K]>`, for the reason
294
+ * `RowPatch` below states: a conditional type over an unresolved `Row` never reduces, so `Row`
295
+ * would stop being assignable to its own write shape and every internal caller would redden.
296
+ */
297
+ export type RowWrite<Row> = {
298
+ readonly [K in keyof Row]: Row[K] | InputOf<Row[K]>;
299
+ };
300
+
284
301
  /**
285
302
  * A patch or a filter: every property optional, **and every property allowed to be present and
286
303
  * `undefined`**.