@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 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. A sequential
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()`. Physical names are
71
- derived from the property key (`orgId` → `org_id`); a name is written once, or never.
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('dev', async ({ insert, id }) => …)`. `id('post:tenancy')` is a UUID v5 of the label,
518
- so the same fixture graph gets the same ids on every machine. Rows go through the columns and
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": "2.0.0",
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": "2.0.0",
35
- "@ultimat3/db": "2.0.0",
36
- "@ultimat3/schema": "2.0.0"
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
- void flush(batch);
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
- // One statement at a time: a batch wide enough to split must not take the pool with it.
93
- for (const chunk of statementChunks(waiting)) {
94
- try {
95
- const answers = await batch.load(chunk.map((entry) => entry.id));
96
- for (const entry of chunk) {
97
- const answer = answers.get(entry.key);
98
- // An id the statement did not answer for is a row that is not there — `findById`'s `null`,
99
- // never a rejection, and never another caller's row.
100
- if (answer === undefined) entry.settle(null);
101
- else if ('error' in answer) entry.fail(answer.error);
102
- else entry.settle(answer.row);
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 { AnyColumn, Column, ColumnDefault, ColumnMeta, TimestampColumn } from './types';
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: snake(property) };
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;