@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/CLAUDE.md +82 -2
- package/README.md +76 -5
- package/package.json +5 -4
- package/src/coalesce.ts +30 -16
- package/src/column.ts +67 -2
- package/src/columns-data.ts +205 -0
- package/src/columns.ts +47 -5
- package/src/describe.ts +53 -16
- package/src/entity.ts +25 -9
- package/src/index.ts +23 -3
- package/src/pg-driver.ts +2 -3
- package/src/pg-row.ts +63 -17
- package/src/pg-sql.ts +40 -6
- package/src/seed.ts +288 -19
- package/src/types.ts +51 -1
package/CLAUDE.md
CHANGED
|
@@ -48,7 +48,15 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
48
48
|
sent, which is why `findById` keeps its signature and there is no `batch()` to opt into. The
|
|
49
49
|
window closes before the statement goes out, so a lookup arriving mid-flight opens the next batch
|
|
50
50
|
instead of joining ids already on the wire, and past `MAX_IDS_PER_STATEMENT` a batch becomes
|
|
51
|
-
several whole statements rather than one Postgres refuses for its bind count.
|
|
51
|
+
several whole statements rather than one Postgres refuses for its bind count. **No caller of a
|
|
52
|
+
batch is ever left unsettled** — every promise `coalesceFindById` returns was handed out before
|
|
53
|
+
the flush was scheduled, so `flush` settles the whole of `waiting` in a catch of its own rather
|
|
54
|
+
than only the chunk that failed, and the scheduled `flush` carries a `.catch`: a rejection there
|
|
55
|
+
has nobody left to hand it to and an unhandled one ends the Bun process. Unsettled forever is
|
|
56
|
+
strictly worse than failed — a rejection is a stack trace and a hang is a request that never
|
|
57
|
+
answers — which is why `coalesce.test.ts` races its assertions against a deadline instead of
|
|
58
|
+
letting the runner time out. `jit-preload.ts` has the same property by construction, settling
|
|
59
|
+
with an `Answer` rather than a rejection. A sequential
|
|
52
60
|
`for … of` loop shares no microtask — its `await` ends the window — which is what the sibling
|
|
53
61
|
preload below is for.
|
|
54
62
|
- **A page batches the loop it causes, and a preloaded row is only ever served to the statement
|
|
@@ -578,6 +586,76 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
578
586
|
±2^53 message, where the value is a `number`, a `bigint` or a digits-only string, the amount is
|
|
579
587
|
the only fact that repairs the row, and `@ultimat3/realtime` renders the same value the same way
|
|
580
588
|
for the same reason. Changing either means changing both.
|
|
589
|
+
- **A seed is replayable by construction, and `insert` is the verb that makes it so** — decided
|
|
590
|
+
2026-08. `defineSeed`'s context offered `insert` and nothing else, and a plain insert meant two
|
|
591
|
+
different things to the two drivers: the memory repository overwrites by primary key, Postgres
|
|
592
|
+
raises `23505`. So a seed replayed twice passed every test in this repo and killed the SECOND
|
|
593
|
+
boot of any container on a durable store, which is why both tracked apps had (or needed) a
|
|
594
|
+
hand-written `Driver` decorator over `insert`. `SeedContext.insert` now writes one
|
|
595
|
+
`upsertAll(rows, { onConflict: entity.$primaryKey, onMatch: 'nothing' })` per call. Four
|
|
596
|
+
properties, none optional. **`'nothing'`, never `'update'`**: `upsertPlan` refuses an updating
|
|
597
|
+
upsert whose target omits the tenant column (`X_TENANCY_UNSCOPED`), and a tenant-scoped entity
|
|
598
|
+
whose only unique keys are global — `posts` in the reference app — has no legal updating target
|
|
599
|
+
at all; `'nothing'` also skips the uneven-batch and nothing-to-set refusals, which a fixture
|
|
600
|
+
graph would otherwise have to satisfy. **One statement per call**, so the per-row `insert` loop
|
|
601
|
+
this replaced is no longer the N+1 of its own bulk form. **The metrics are the driver's answer**,
|
|
602
|
+
not a count of the input: `upsertAll` under `'nothing'` resolves with the rows it actually wrote,
|
|
603
|
+
so `skipped` is the replay, observed. **A generated primary key the row does not name is
|
|
604
|
+
refused** — `uuid().primaryKey()` carries `GENERATED_UUID`, so `$parse` fills it with a fresh
|
|
605
|
+
uuid, the conflict target matches nothing and run five leaves five copies; it is the one
|
|
606
|
+
duplication no other rule in this package can see.
|
|
607
|
+
- **`upsert(entity, { by }, values)` is the second verb, and it exists because only the SEED AUTHOR
|
|
608
|
+
knows the natural key.** A seed writing into a table whose ids already exist — `banks` keyed by
|
|
609
|
+
`value`, `users` by `email`, `exchange_rates` by `(base, target, effective_on)` — cannot choose a
|
|
610
|
+
primary key, so keying replay on `$primaryKey` would be keying it on something Postgres does not
|
|
611
|
+
enforce. It reads first so an unchanged row can answer `'skipped'` with no statement, then writes
|
|
612
|
+
through ONE `on conflict … do update`: the read is for the report, never for the decision, or two
|
|
613
|
+
containers booting at once would race between the two. **`createdAt` is preserved on a match** —
|
|
614
|
+
the row handed to the update omits it, so `namedProperties` leaves it out of the `set` — because
|
|
615
|
+
a replay must not move when a row first arrived.
|
|
616
|
+
- **The environment guard is the CLI's, not `run()`'s.** `seedTiersFor(environment, requested)` is
|
|
617
|
+
the one table (`reference` everywhere, `dev` everywhere but production) and `x db seed` is what
|
|
618
|
+
refuses. `run()` stays permissive on purpose: `dummy/social-media-clone/apps/web/api/index.ts`
|
|
619
|
+
seeds its own production demo database from its boot code and says out loud that this is an app
|
|
620
|
+
decision (axiom 8) — a library refusal would break it. A seed declares its tier as DATA, the way
|
|
621
|
+
a `backfill()` declares its environments.
|
|
622
|
+
- **One resolver decides a physical name, and it is `columnName(property, meta)`** — decided
|
|
623
|
+
2026-08 with `entity(name, { table })` and `.column(name)`. Before them, `snake(property)` was
|
|
624
|
+
called in nine places and `$table` was the entity name, so a schema this framework did not
|
|
625
|
+
generate could not be declared at all: adoption meant a rewrite. Every projection now reads the
|
|
626
|
+
resolver — the DDL (`describe.ts`), the binding and the decoder (`pg-row.ts`), the predicate and
|
|
627
|
+
sort resolver, the index names, the invariant SQL, the soft-delete clause in `pg-sql.ts` and
|
|
628
|
+
`pg-driver.ts`. **A second `snake(property)` anywhere is a statement naming a column the table
|
|
629
|
+
does not have**, and the first table that proves it is somebody's production database. It is
|
|
630
|
+
additive by construction: with no override the resolver IS `snake(property)`.
|
|
631
|
+
- **The entity NAME and the TABLE are different things.** The name stays the framework's key — the
|
|
632
|
+
registry, the cache tag (`entity:account`), `$tagFor`, every relation and every policy — and the
|
|
633
|
+
table is physical. Renaming a table must never move a cache tag. Index names are the TABLE's,
|
|
634
|
+
because an index is a physical object.
|
|
635
|
+
- **Money's three columns are per-part and `scale: null` is a real answer.** `money({ columns })`
|
|
636
|
+
merges over `<base>_minor`/`<base>_currency`/`<base>_scale` one part at a time, so a table that
|
|
637
|
+
renamed one does not restate the other two. `scale: null` says the table has no scale column at
|
|
638
|
+
all — the ordinary shape of an amount written before scale existed — and then `columnsOf`
|
|
639
|
+
projects TWO names, `bindValues` writes two, and `decodeRow` folds two. That last one is why
|
|
640
|
+
`decodeRow` branches on `$meta.kind === 'money'` and not on how many names came back: reading a
|
|
641
|
+
two-column amount as a non-money column handed the caller a raw minor unit where a `Money` goes.
|
|
642
|
+
- **A `jsonb` value is bound as TEXT and cast back, `::text::jsonb`** — and the double cast is
|
|
643
|
+
load-bearing, not defensive. The driver seam refuses a plain object as a parameter
|
|
644
|
+
(`X_SQL_UNSAFE`; `isBoundValue` takes scalars, `Date`, `Uint8Array` and arrays of those). With
|
|
645
|
+
`$1::jsonb` the server describes the parameter as `jsonb`, Bun's `sql` JSON-ENCODES the string it
|
|
646
|
+
was handed, and `{"a":1}` is stored as the JSON *string* — `jsonb_typeof` says `string`
|
|
647
|
+
(measured, Postgres 17.10). Pinning the parameter to `text` first makes the client send the
|
|
648
|
+
characters and the server parse them. An ARRAY is the other value that cannot cross as itself:
|
|
649
|
+
Bun serialises a JS array to `x,y`, which Postgres answers with `malformed array literal`, so
|
|
650
|
+
`bindValues` writes the `{…}` literal with every element quoted.
|
|
651
|
+
- **The wide column types were chosen from what a driver actually returns, not from what reads
|
|
652
|
+
well.** `int8` is a string from Bun's `sql` and a `bigint` from PGlite; `numeric` is a string
|
|
653
|
+
from both; `date` is a `Date` at midnight UTC from both; `bytea` is a `Buffer` from one and a
|
|
654
|
+
`Uint8Array` from the other. Every one of those is normalised in `$parse` to a single row type,
|
|
655
|
+
because a row that means two things by driver is the drift this package's two-driver split exists
|
|
656
|
+
to refuse. `bigint()` and `decimal()` are STRINGS for the same reason `money.minor` is a
|
|
657
|
+
`number`: `JSON.stringify` throws on a bigint, and a `number` loses digits exactly where a legacy
|
|
658
|
+
`int8` key lives.
|
|
581
659
|
- Never throw a bare `Error` — use `errors.ts`.
|
|
582
660
|
- Tests restore the process-global registry in `afterAll` (`clearRegistry()`): a leaked registry
|
|
583
661
|
breaks an unrelated package's tests, as it did in `@ultimat3/policy`.
|
|
@@ -587,7 +665,8 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
587
665
|
| File | Job |
|
|
588
666
|
|---|---|
|
|
589
667
|
| `types.ts` | `Column`, `RowOf`, `Insertable`, `IdOf` — the type derivation |
|
|
590
|
-
| `column.ts` / `columns.ts` | the chain + property-key binding; the blessed builders; `narrowMoney`, the one write-side narrowing both drivers run |
|
|
668
|
+
| `column.ts` / `columns.ts` | the chain + property-key binding; the blessed builders; `columnName`/`moneyColumns`, the ONE physical-name resolver; `narrowMoney`, the one write-side narrowing both drivers run |
|
|
669
|
+
| `columns-data.ts` | the wide vocabulary an existing schema needs: `json`, `decimal`, `date`, `bigint`, `bytes`, `arrayOf` |
|
|
591
670
|
| `expr.ts` / `invariants.ts` | the `invariants: (c) => …` rule language; bind + `toSql()` DDL |
|
|
592
671
|
| `entity.ts` / `describe.ts` | `entity()`, `$row`; the `EntityDescription` projection |
|
|
593
672
|
| `view.ts` | `$view(keys)` — the row projection an action names as its `output` |
|
|
@@ -607,6 +686,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
607
686
|
| `registry.ts` | duplicate detection, `describeEntities()` for the manifest, `references()` per entry |
|
|
608
687
|
| `relations.ts` | `relationMap()`/`relationsFor()`/`relationNamed()` — the FKs as a named `belongsTo`/`hasMany` map |
|
|
609
688
|
| `n-plus-one.ts` | a repeated statement → the error whose `fix` is the preload or bulk call that ends it |
|
|
689
|
+
| `seed.ts` | `defineSeed` — the replayable fixture graph: `insert` (the seed's own ids), `upsert` (a natural key), the sentinel reads, and the tier table `x db seed` refuses from |
|
|
610
690
|
| `type-pins.ts` | compile-time assertions `tsc` checks — the column proxy, `Invariant` variance, the branded id |
|
|
611
691
|
|
|
612
692
|
## Commands
|
package/README.md
CHANGED
|
@@ -67,8 +67,58 @@ still imports one package: `import { entity, t } from '@ultimat3/entity'`.
|
|
|
67
67
|
| `text({ max })`, `integer()`, `boolean()`, `url()` | `text`/`integer`/`boolean` + CHECK | format is enforced by the database too |
|
|
68
68
|
|
|
69
69
|
Chain: `.primaryKey()` · `.nullable()` · `.unique()` · `.default(v)` · `.defaultNow()` ·
|
|
70
|
-
`.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()
|
|
71
|
-
derived from the property key (`orgId` → `org_id`); a name is written once, or
|
|
70
|
+
`.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()` · `.column(name)`.
|
|
71
|
+
Physical names are derived from the property key (`orgId` → `org_id`); a name is written once, or
|
|
72
|
+
never — `.column()` is the exception, and it exists for tables this framework did not create.
|
|
73
|
+
|
|
74
|
+
## Wide columns
|
|
75
|
+
|
|
76
|
+
The vocabulary an EXISTING schema needs. The blessed set above is a decision the framework made
|
|
77
|
+
for a table it was going to create; these are the shapes a table already has, `As of 2026-08`.
|
|
78
|
+
|
|
79
|
+
| Builder | Emits | Row type, and why |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `json(schema)` | `jsonb` | the schema is **required**: a `json()` returning `unknown` is the `any` hole this framework forbids, and a column is the worst place for one — the value arrives from the database as often as from a caller. The object is bound as an object, never as JSON text |
|
|
82
|
+
| `decimal({ precision, scale })` | `numeric(p, s)` | a `string`, the exact digits. Money is the one decimal with an opinion (integer minor units + a currency); this is every other one, and a value with more decimal places than the column stores is refused rather than rounded |
|
|
83
|
+
| `date()` | `date` | `@ultimat3/time`'s `PlainDate` — a calendar date, no time, no zone. `effective_on` is the date a rate applies, and as a `timestamptz` it is a different date on either side of midnight for half the planet |
|
|
84
|
+
| `bigint()` | `bigint` | a decimal `string`. A JS `bigint` is what `JSON.stringify` throws on and a `number` loses digits past 2^53 — which is exactly where a legacy `int8` key lives. Both driver spellings (a string from Bun's `sql`, a `bigint` from PGlite) arrive as one |
|
|
85
|
+
| `bytes()` | `bytea` | a plain `Uint8Array`, normalised: Bun's `sql` returns a `Buffer` and PGlite a `Uint8Array`, and the two do not serialise alike |
|
|
86
|
+
| `arrayOf(column)` | `<element>[]` | `readonly T[]`, each member parsed by the element column it was given. Money and nested arrays are refused — an element is one scalar column |
|
|
87
|
+
|
|
88
|
+
## Adopting an existing table
|
|
89
|
+
|
|
90
|
+
Three overrides, and together they are what makes a schema Ultimate did not generate declarable
|
|
91
|
+
at all. Nothing here changes what an entity without them emits.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { date, entity, money, text, uuid } from '@ultimat3/entity';
|
|
95
|
+
|
|
96
|
+
export const accounts = entity('account', {
|
|
97
|
+
table: 'legacy_accounts',
|
|
98
|
+
columns: {
|
|
99
|
+
id: uuid().primaryKey().column('account_id'),
|
|
100
|
+
githubLogin: text({ max: 40 }).column('gh_login'),
|
|
101
|
+
balance: money({ columns: { minor: 'amount_cents', currency: 'currency', scale: null } }),
|
|
102
|
+
openedOn: date().column('opened_on'),
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
| Override | What follows it |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `entity(name, { table })` | every statement, index name and foreign key. The entity NAME stays the framework's key — the registry, the cache tag (`entity:account`), `x entities describe` and every relation are keyed by it, so renaming a table never moves a cache tag or a policy |
|
|
110
|
+
| `.column(name)` | the DDL, the binding, the decoder, the predicate, the sort key, the cursor. Name it LAST in a chain — the link returns the general column, and only `uuid()` and `timestamp()` keep their own methods across it |
|
|
111
|
+
| `money({ columns })` | per part, merged over `<name>_minor` / `<name>_currency` / `<name>_scale`, so a table that renamed one does not restate the other two. `scale: null` says the table has no scale column: every amount is then at the currency's own minor unit, which is what an absent scale already means |
|
|
112
|
+
|
|
113
|
+
A physical name is checked where it is written — lower-case letters, digits and underscores, at
|
|
114
|
+
most the 63 bytes Postgres truncates at — because it is spliced into every statement as an
|
|
115
|
+
identifier.
|
|
116
|
+
|
|
117
|
+
**What is not adoptable yet**, `As of 2026-08`: a `numeric` money column with no currency column
|
|
118
|
+
beside it (`money()` is two columns by construction — declare it `decimal()` and keep the currency
|
|
119
|
+
in the app), a Postgres `enum` TYPE (`enumerated()` emits a CHECK, not a `CREATE TYPE`), a
|
|
120
|
+
composite type, and a live query over a renamed column — `@ultimat3/realtime` rebuilds a row from
|
|
121
|
+
the physical names alone and would deliver `ghLogin` where the repository says `githubLogin`.
|
|
72
122
|
|
|
73
123
|
## Branded ids
|
|
74
124
|
|
|
@@ -514,9 +564,30 @@ the enforcement.
|
|
|
514
564
|
|
|
515
565
|
## Seeds
|
|
516
566
|
|
|
517
|
-
`defineSeed(
|
|
518
|
-
|
|
519
|
-
the invariants, which makes a seed a test of the schema as well.
|
|
567
|
+
`defineSeed(name, build, { tier })` — the fixture graph, written once and **replayed anywhere**: a
|
|
568
|
+
second run writes nothing and raises nothing, against Postgres as well as against memory. Rows go
|
|
569
|
+
through the columns and the invariants either way, which makes a seed a test of the schema as well.
|
|
570
|
+
`x db seed [<name>]` is what applies it.
|
|
571
|
+
|
|
572
|
+
Two write verbs, because only the author knows which key identifies a row:
|
|
573
|
+
|
|
574
|
+
| Context member | Use it when | A replay |
|
|
575
|
+
|---|---|---|
|
|
576
|
+
| `insert(entity, rows)` | the seed chose the ids — `id('post:tenancy')` is a UUID v5 of the label, so the same graph gets the same ids on every machine | one `on conflict … do nothing` statement per call; a stored row is left alone and counted `skipped` |
|
|
577
|
+
| `upsert(entity, { by, preserve? }, values)` | the table owns the id and only a natural key identifies the row | reads first, so an unchanged row is `'skipped'` with no statement; otherwise one `on conflict … do update`, which settles the race between two containers booting at once |
|
|
578
|
+
| `exists(entity, where?)` / `count(entity, where?)` | bulk volume data, where the FILE is the unit of idempotency | the sentinel returns early and nothing is written |
|
|
579
|
+
| `deleteWhere(entity, where)` | a scoped wipe before a regenerate | **refused on a soft-deleting entity**: the stamp keeps the row's unique key and no replay can clear it |
|
|
580
|
+
| `id`, `now`, `environment`, `tier`, `dryRun`, `metrics` | — | `now` is one instant per run; `metrics` is `{ inserted, updated, skipped }` and `run()` returns it |
|
|
581
|
+
|
|
582
|
+
Rules worth knowing before the first seed: `upsert` never overwrites `createdAt` (`preserve` names
|
|
583
|
+
other columns to spare); `upsert` on a tenant-scoped entity needs the tenant column inside `by`,
|
|
584
|
+
exactly as `upsertAll` does, while `insert` needs nothing because `do nothing` writes nothing to a
|
|
585
|
+
row it does not own; and `insert` refuses a row that leaves a `uuid().primaryKey()` unnamed —
|
|
586
|
+
`$parse` would fill it with a fresh uuid and every replay would insert one more copy.
|
|
587
|
+
|
|
588
|
+
`tier` is `'dev'` (the default, fixture data) or `'reference'` (data the app is wrong without,
|
|
589
|
+
which ships to production). The refusal is the CLI's, never `run()`'s: an app that seeds its own
|
|
590
|
+
database from its boot code has decided to, and a library that overruled that would break it.
|
|
520
591
|
|
|
521
592
|
## Errors
|
|
522
593
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "A table + its domain type + invariants the database also enforces",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,8 +31,9 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/db": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
34
|
+
"@ultimat3/core": "3.0.0",
|
|
35
|
+
"@ultimat3/db": "3.0.0",
|
|
36
|
+
"@ultimat3/schema": "3.0.0",
|
|
37
|
+
"@ultimat3/time": "3.0.0"
|
|
37
38
|
}
|
|
38
39
|
}
|
package/src/coalesce.ts
CHANGED
|
@@ -81,7 +81,10 @@ const openBatch = (
|
|
|
81
81
|
// mid-flight opens the next batch instead of joining ids already on the wire.
|
|
82
82
|
queueMicrotask(() => {
|
|
83
83
|
if (batches.get(key) === batch) batches.delete(key);
|
|
84
|
-
|
|
84
|
+
// `flush` settles every caller itself, so a rejection escaping it has nobody left to hand it
|
|
85
|
+
// to — and an unhandled rejection ends the Bun process, which turns one bad batch into the
|
|
86
|
+
// whole node. Nothing is swallowed here that a caller was not already given.
|
|
87
|
+
void flush(batch).catch(() => undefined);
|
|
85
88
|
});
|
|
86
89
|
return batch;
|
|
87
90
|
};
|
|
@@ -89,23 +92,34 @@ const openBatch = (
|
|
|
89
92
|
const flush = async (batch: Batch): Promise<void> => {
|
|
90
93
|
const waiting = [...batch.pending.values()];
|
|
91
94
|
batch.pending.clear();
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
95
|
+
try {
|
|
96
|
+
// One statement at a time: a batch wide enough to split must not take the pool with it.
|
|
97
|
+
for (const chunk of statementChunks(waiting)) {
|
|
98
|
+
try {
|
|
99
|
+
const answers = await batch.load(chunk.map((entry) => entry.id));
|
|
100
|
+
for (const entry of chunk) {
|
|
101
|
+
const answer = answers.get(entry.key);
|
|
102
|
+
// An id the statement did not answer for is a row that is not there — `findById`'s
|
|
103
|
+
// `null`, never a rejection, and never another caller's row.
|
|
104
|
+
if (answer === undefined) entry.settle(null);
|
|
105
|
+
else if ('error' in answer) entry.fail(answer.error);
|
|
106
|
+
else entry.settle(answer.row);
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// The statement failed, so everyone in it gets the failure the single statement would
|
|
110
|
+
// have handed them. Every one was returned to a caller, so none goes unhandled.
|
|
111
|
+
for (const entry of chunk) entry.fail(error);
|
|
103
112
|
}
|
|
104
|
-
} catch (error) {
|
|
105
|
-
// The statement failed, so everyone in it gets the failure the single statement would have
|
|
106
|
-
// handed them. Every one of these promises was returned to a caller, so none goes unhandled.
|
|
107
|
-
for (const entry of chunk) entry.fail(error);
|
|
108
113
|
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
// Every promise in `waiting` was handed to a caller before this batch was ever scheduled, so
|
|
116
|
+
// anything escaping the loop above — `statementChunks` itself, or whatever a later edit puts
|
|
117
|
+
// beside it — would leave them awaiting a row that can no longer arrive. Unsettled forever is
|
|
118
|
+
// strictly worse than failed: a rejection is a stack trace, a hang is a request that never
|
|
119
|
+
// answers. Failing an entry an earlier chunk already settled is a no-op, so this is safe over
|
|
120
|
+
// the whole list. `jit-preload.ts` gets the same property by construction, settling with an
|
|
121
|
+
// `Answer` rather than a rejection; this path has a real promise per caller and cannot.
|
|
122
|
+
for (const entry of waiting) entry.fail(error);
|
|
109
123
|
}
|
|
110
124
|
};
|
|
111
125
|
|
package/src/column.ts
CHANGED
|
@@ -7,11 +7,51 @@
|
|
|
7
7
|
// physical name even though two schema modules import each other in a cycle.
|
|
8
8
|
|
|
9
9
|
import { invariantViolated } from './errors';
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
AnyColumn,
|
|
12
|
+
Column,
|
|
13
|
+
ColumnDefault,
|
|
14
|
+
ColumnMeta,
|
|
15
|
+
MoneyColumnNames,
|
|
16
|
+
TimestampColumn,
|
|
17
|
+
} from './types';
|
|
11
18
|
|
|
12
19
|
export const snake = (value: string): string =>
|
|
13
20
|
value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
14
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The physical column, decided in ONE place: what `.column()` declared, else `snake(property)`.
|
|
24
|
+
*
|
|
25
|
+
* Every projection reads it here — the DDL, the binding, the decoder, the invariant resolver, the
|
|
26
|
+
* index names — because a second `snake(property)` anywhere is a statement naming a column the
|
|
27
|
+
* table does not have, and the first table that proves it is somebody's production database.
|
|
28
|
+
*/
|
|
29
|
+
export const columnName = (property: string, meta: ColumnMeta): string =>
|
|
30
|
+
meta.name ?? snake(property);
|
|
31
|
+
|
|
32
|
+
/** Money's three physical columns, resolved. `scale: null` is a table that has no scale column. */
|
|
33
|
+
export interface MoneyColumns {
|
|
34
|
+
readonly minor: string;
|
|
35
|
+
readonly currency: string;
|
|
36
|
+
readonly scale: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Per part, merged over the `<base>_minor` / `<base>_currency` / `<base>_scale` defaults — so a
|
|
41
|
+
* table that renamed one of the three does not have to restate the other two, and `.column()`
|
|
42
|
+
* moves the base for all of them at once.
|
|
43
|
+
*/
|
|
44
|
+
export const moneyColumns = (property: string, meta: ColumnMeta): MoneyColumns => {
|
|
45
|
+
const base = columnName(property, meta);
|
|
46
|
+
const declared: MoneyColumnNames = meta.parts ?? {};
|
|
47
|
+
return {
|
|
48
|
+
minor: declared.minor ?? `${base}_minor`,
|
|
49
|
+
currency: declared.currency ?? `${base}_currency`,
|
|
50
|
+
// `undefined` takes the default; `null` is the caller saying the column is not there at all.
|
|
51
|
+
scale: declared.scale === undefined ? `${base}_scale` : declared.scale,
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
15
55
|
export const GENERATED_UUID: ColumnDefault = { kind: 'generated', by: 'uuid-v7' };
|
|
16
56
|
export const GENERATED_NOW: ColumnDefault = { kind: 'generated', by: 'now' };
|
|
17
57
|
|
|
@@ -49,7 +89,7 @@ export const bindColumn = (column: AnyColumn, table: string, property: string):
|
|
|
49
89
|
'build a new column instead of sharing one between entities',
|
|
50
90
|
);
|
|
51
91
|
}
|
|
52
|
-
const binding: Binding = { table, property, name:
|
|
92
|
+
const binding: Binding = { table, property, name: columnName(property, column.$meta) };
|
|
53
93
|
bindings.set(column, binding);
|
|
54
94
|
return binding;
|
|
55
95
|
};
|
|
@@ -135,8 +175,30 @@ export const makeColumn = <T, Optional extends boolean>(
|
|
|
135
175
|
),
|
|
136
176
|
|
|
137
177
|
default: (value) => makeColumn<T, true>({ ...meta, default: literal(value) }, parse, true),
|
|
178
|
+
|
|
179
|
+
column: (name) =>
|
|
180
|
+
makeColumn<T, Optional>({ ...meta, name: assertColumnName(name) }, parse, optional),
|
|
138
181
|
});
|
|
139
182
|
|
|
183
|
+
/**
|
|
184
|
+
* A physical name is spliced into DDL and into every statement as a quoted identifier, so it is
|
|
185
|
+
* checked where it is written rather than trusted there: an empty name produces `""`, and a name
|
|
186
|
+
* carrying a quote or a newline is the one value in a column declaration that could close the
|
|
187
|
+
* identifier. `[a-z_][a-z0-9_$]*`, which is what an unquoted Postgres identifier may be, and the
|
|
188
|
+
* bound is the same 63 bytes the server truncates at — a longer one silently addresses a
|
|
189
|
+
* different column.
|
|
190
|
+
*/
|
|
191
|
+
export const assertColumnName = (name: string): string => {
|
|
192
|
+
if (!/^[a-z_][a-z0-9_$]*$/.test(name) || name.length > 63) {
|
|
193
|
+
throw invariantViolated(
|
|
194
|
+
'column',
|
|
195
|
+
'column-name',
|
|
196
|
+
`"${name}" is not a physical column name: lower-case letters, digits and underscores, at most 63 of them`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return name;
|
|
200
|
+
};
|
|
201
|
+
|
|
140
202
|
export const column = <T>(
|
|
141
203
|
kind: ColumnMeta['kind'],
|
|
142
204
|
parse: (value: unknown) => T,
|
|
@@ -155,4 +217,7 @@ export const makeTimestamp = <Optional extends boolean>(
|
|
|
155
217
|
...makeColumn<Date, Optional>(meta, parse, optional),
|
|
156
218
|
defaultNow: () => makeTimestamp({ ...meta, default: GENERATED_NOW }, parse, true),
|
|
157
219
|
onUpdateNow: () => makeTimestamp({ ...meta, onUpdate: GENERATED_NOW }, parse, optional),
|
|
220
|
+
// Overridden so `timestamp().column('created').defaultNow()` still has `defaultNow` — the
|
|
221
|
+
// general link returns the general column, and a builder with methods of its own keeps them.
|
|
222
|
+
column: (name) => makeTimestamp({ ...meta, name: assertColumnName(name) }, parse, optional),
|
|
158
223
|
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// The column builders an EXISTING schema needs. `columns.ts` holds the opinionated set — one way
|
|
2
|
+
// to store an id, an instant, money — and every one of them is a decision this framework made for
|
|
3
|
+
// a table it was going to create. These are the shapes a table already has: a `jsonb` payload, a
|
|
4
|
+
// `numeric(18,8)` rate, a calendar `date`, an `int8` id past 2^53, a `bytea` blob, a `text[]`.
|
|
5
|
+
//
|
|
6
|
+
// Two rules run through all of them. A value crossing the driver is parsed by the column that
|
|
7
|
+
// declared it, because the two drivers disagree about what they hand back (`int8` is a string from
|
|
8
|
+
// Bun's `sql` and a `bigint` from PGlite — measured); and nothing here is an `any` hole, so `json()`
|
|
9
|
+
// takes a schema and validates through it.
|
|
10
|
+
|
|
11
|
+
import { describeValue, formatIssues, type StandardSchemaV1, validate } from '@ultimat3/schema';
|
|
12
|
+
import { isPlainDate, type PlainDate, plainDateUtc } from '@ultimat3/time';
|
|
13
|
+
import { column } from './column';
|
|
14
|
+
import { invariantViolated } from './errors';
|
|
15
|
+
import type { AnyColumn, Column, ColumnMeta } from './types';
|
|
16
|
+
|
|
17
|
+
const reject = (rule: string, detail: string): never => {
|
|
18
|
+
throw invariantViolated('column', rule, detail);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The rejected value as its SHAPE, never its content — `columns.ts` explains why at length. */
|
|
22
|
+
const got = (value: unknown): string => `got ${describeValue(value)}`;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A `jsonb` column whose CONTENTS are validated. The schema is required and that is the point: a
|
|
26
|
+
* `json()` returning `unknown` is the `any` hole this framework forbids, and a column is the worst
|
|
27
|
+
* place for one — the value arrives from the DATABASE as often as from a caller, so the row type
|
|
28
|
+
* would be a claim nothing ever checked.
|
|
29
|
+
*
|
|
30
|
+
* The object is bound as an object, never as a string: a JSON string parameter is stored as a JSON
|
|
31
|
+
* *string* by Postgres (measured — `'{"a":1}'` comes back as the text, not the object), so
|
|
32
|
+
* stringifying here would change the value's type in the table.
|
|
33
|
+
*/
|
|
34
|
+
export const json = <T>(schema: StandardSchemaV1<unknown, T>): Column<T> =>
|
|
35
|
+
column<T>('jsonb', (value) => {
|
|
36
|
+
const result = validate(schema, value);
|
|
37
|
+
if (result.issues === undefined) return result.value;
|
|
38
|
+
// The ISSUES, never the value: `formatIssues` renders path + message, and a column rejection
|
|
39
|
+
// reaches the caller and the log line where a value has no key left to redact.
|
|
40
|
+
return reject(
|
|
41
|
+
'json',
|
|
42
|
+
`does not match the column's schema — ${formatIssues(result.issues).join('; ')}`,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const DIGITS = /^-?\d+$/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `bigint`, whose row type is a decimal STRING. Neither alternative survives contact:
|
|
50
|
+
* a JS `bigint` is what `JSON.stringify` throws on — the reason `money.minor` is a `number` — and
|
|
51
|
+
* a `number` silently loses digits past 2^53, which is precisely the range a legacy `int8` key or
|
|
52
|
+
* a snowflake id lives in. A string holds every value the column can and crosses every wire this
|
|
53
|
+
* framework generates.
|
|
54
|
+
*
|
|
55
|
+
* Both driver spellings arrive here and leave as one: Bun's `sql` returns `int8` as a string and
|
|
56
|
+
* PGlite returns a `bigint`, and a row that meant two things by driver is the drift this package
|
|
57
|
+
* exists to refuse.
|
|
58
|
+
*/
|
|
59
|
+
export const bigint = (): Column<string> =>
|
|
60
|
+
column<string>('bigint', (value) => {
|
|
61
|
+
if (typeof value === 'bigint') return value.toString();
|
|
62
|
+
if (typeof value === 'number') {
|
|
63
|
+
return Number.isSafeInteger(value)
|
|
64
|
+
? String(value)
|
|
65
|
+
: reject(
|
|
66
|
+
'bigint',
|
|
67
|
+
`${String(value)} is past ±2^53, where a JS number is no longer exact — pass the digits as a string`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return typeof value === 'string' && DIGITS.test(value)
|
|
71
|
+
? value
|
|
72
|
+
: reject('bigint', `expected whole digits, ${got(value)}`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export interface DecimalOptions {
|
|
76
|
+
/** Emits `numeric(precision, scale)`. Both, or neither — a bare `numeric` is unbounded. */
|
|
77
|
+
readonly precision?: number;
|
|
78
|
+
readonly scale?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `numeric(p, s)`, whose row type is the exact decimal STRING Postgres returns. Money is the one
|
|
83
|
+
* decimal this framework has an opinion about (integer minor units plus a currency, always); this
|
|
84
|
+
* is every other one — a tax rate, an FX rate, a measurement — where the precision is the column's
|
|
85
|
+
* and no JS number holds it.
|
|
86
|
+
*
|
|
87
|
+
* It is deliberately NOT arithmetic-friendly. A framework that handed back a float here would be
|
|
88
|
+
* the float-money bug with a different name, and one that shipped a decimal type would be shipping
|
|
89
|
+
* a numeric tower: the honest thing a driver already does is give you the digits.
|
|
90
|
+
*/
|
|
91
|
+
export const decimal = (options: DecimalOptions = {}): Column<string> => {
|
|
92
|
+
const { precision, scale } = options;
|
|
93
|
+
if ((precision === undefined) !== (scale === undefined)) {
|
|
94
|
+
reject('numeric', 'precision and scale are declared together — numeric(18, 8), or neither');
|
|
95
|
+
}
|
|
96
|
+
if (precision !== undefined && scale !== undefined) {
|
|
97
|
+
if (!Number.isInteger(precision) || precision < 1 || precision > 1000) {
|
|
98
|
+
reject('numeric', `precision must be 1..1000, ${got(precision)}`);
|
|
99
|
+
}
|
|
100
|
+
if (!Number.isInteger(scale) || scale < 0 || scale > precision) {
|
|
101
|
+
reject('numeric', `scale must be 0..precision, ${got(scale)}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const shape = /^-?\d+(\.\d+)?$/;
|
|
105
|
+
return column<string>(
|
|
106
|
+
'numeric',
|
|
107
|
+
(value) => {
|
|
108
|
+
const text = typeof value === 'number' ? decimalOfNumber(value) : value;
|
|
109
|
+
if (typeof text !== 'string' || !shape.test(text)) {
|
|
110
|
+
return reject('numeric', `expected a decimal number, ${got(value)}`);
|
|
111
|
+
}
|
|
112
|
+
const digits = text.replace('-', '').split('.');
|
|
113
|
+
const fraction = digits[1]?.length ?? 0;
|
|
114
|
+
if (scale !== undefined && fraction > scale) {
|
|
115
|
+
return reject(
|
|
116
|
+
'numeric',
|
|
117
|
+
`${text} has ${fraction} decimal places and the column stores ${scale} — Postgres would round it, silently`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (
|
|
121
|
+
precision !== undefined &&
|
|
122
|
+
(digits[0] ?? '').replace(/^0+(?=\d)/, '').length > precision - (scale ?? 0)
|
|
123
|
+
) {
|
|
124
|
+
return reject('numeric', `${text} does not fit numeric(${precision}, ${scale ?? 0})`);
|
|
125
|
+
}
|
|
126
|
+
return text;
|
|
127
|
+
},
|
|
128
|
+
precision === undefined || scale === undefined ? {} : { precision, numericScale: scale },
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A float is accepted only where it is exactly representable as written — anything else is the
|
|
134
|
+
* rounding this column exists to refuse, and refusing it at the write is the only place the caller
|
|
135
|
+
* still knows what they meant.
|
|
136
|
+
*/
|
|
137
|
+
const decimalOfNumber = (value: number): string =>
|
|
138
|
+
Number.isFinite(value) ? String(value) : 'not-a-number';
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A `date`: a calendar date, with no time and therefore no zone. The row type is
|
|
142
|
+
* `@ultimat3/time`'s `PlainDate`, which is why this is not `timestamp()` with the clock zeroed —
|
|
143
|
+
* `effective_on` is the date a rate applies, and stored as an instant it is a different date on
|
|
144
|
+
* either side of midnight for half the planet.
|
|
145
|
+
*
|
|
146
|
+
* A driver hands a `date` column back as a `Date` at UTC midnight (measured: Bun's `sql` and
|
|
147
|
+
* PGlite both), so that is the one conversion here, by its own name. The value written is the
|
|
148
|
+
* string: binding a `Date` to a `date` parameter fails outright on a server whose client zone has
|
|
149
|
+
* no name Postgres knows (`time zone "gmt-0500" not recognized`, measured on 17.10).
|
|
150
|
+
*/
|
|
151
|
+
export const date = (): Column<PlainDate> =>
|
|
152
|
+
column<PlainDate>('date', (value) => {
|
|
153
|
+
if (value instanceof Date) {
|
|
154
|
+
return Number.isNaN(value.getTime())
|
|
155
|
+
? reject('date', `expected a calendar date, ${got(value)}`)
|
|
156
|
+
: plainDateUtc(value);
|
|
157
|
+
}
|
|
158
|
+
return isPlainDate(value)
|
|
159
|
+
? value
|
|
160
|
+
: reject('date', `expected a YYYY-MM-DD calendar date, ${got(value)}`);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `bytea`. The row type is a plain `Uint8Array` and both drivers are normalised into one: Bun's
|
|
165
|
+
* `sql` returns a `Buffer`, PGlite a `Uint8Array`, and the two serialise differently
|
|
166
|
+
* (`{"type":"Buffer","data":[…]}` against `{"0":…}`) — so a row read through one driver and the
|
|
167
|
+
* same row read through the other would not be the same object on any wire.
|
|
168
|
+
*/
|
|
169
|
+
export const bytes = (): Column<Uint8Array> =>
|
|
170
|
+
column<Uint8Array>('bytea', (value) => {
|
|
171
|
+
if (!(value instanceof Uint8Array)) {
|
|
172
|
+
return reject('bytea', `expected bytes, ${got(value)}`);
|
|
173
|
+
}
|
|
174
|
+
// Already the plain form: the overwhelmingly common case, and it costs one prototype read.
|
|
175
|
+
return Object.getPrototypeOf(value) === Uint8Array.prototype ? value : new Uint8Array(value);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* `<element>[]` — a Postgres array of a SCALAR column. The element is a column, so its own
|
|
180
|
+
* `$parse` decides every member: `arrayOf(text({ max: 40 }))` refuses a 41-character tag exactly
|
|
181
|
+
* where a `text()` column would.
|
|
182
|
+
*
|
|
183
|
+
* Money and arrays of arrays are refused rather than approximated: money is three physical columns
|
|
184
|
+
* and cannot be one array element, and a nested array has no unambiguous literal form.
|
|
185
|
+
*/
|
|
186
|
+
export const arrayOf = <T>(element: Column<T>): Column<readonly T[]> => {
|
|
187
|
+
const kind = element.$meta.kind;
|
|
188
|
+
if (kind === 'money' || kind === 'array') {
|
|
189
|
+
reject(
|
|
190
|
+
'array',
|
|
191
|
+
`arrayOf(${kind}) has no single column behind it — an array element is one scalar column`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return column<readonly T[]>(
|
|
195
|
+
'array',
|
|
196
|
+
(value) => {
|
|
197
|
+
if (!Array.isArray(value)) return reject('array', `expected an array, ${got(value)}`);
|
|
198
|
+
return value.map((member) => element.$parse(member));
|
|
199
|
+
},
|
|
200
|
+
{ element: element as AnyColumn },
|
|
201
|
+
);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** The element's own kind, for the projections that need the physical type. */
|
|
205
|
+
export const elementMeta = (meta: ColumnMeta): ColumnMeta | undefined => meta.element?.$meta;
|