@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 +40 -3
- package/README.md +7 -3
- package/package.json +5 -5
- package/src/columns.ts +17 -0
- package/src/describe.ts +6 -0
- package/src/entity.ts +5 -4
- package/src/index.ts +2 -8
- package/src/invariants.ts +7 -22
- package/src/memory-repo.ts +23 -8
- package/src/pg-driver.ts +17 -7
- package/src/registry.ts +23 -1
- package/src/repo.ts +9 -4
- package/src/type-pins.ts +36 -0
- package/src/types.ts +17 -0
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
|
|
780
|
-
|
|
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;
|
|
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
|
|
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
|
-
|
|
147
|
-
|
|
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": "
|
|
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": "
|
|
35
|
-
"@ultimat3/db": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
37
|
-
"@ultimat3/time": "
|
|
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
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
package/src/memory-repo.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
119
|
-
//
|
|
120
|
-
const row =
|
|
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(
|
|
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(
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
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,
|
|
272
|
-
return writeRows('upsertAll',
|
|
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
|
|
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`**.
|