@ultimat3/entity 2.0.0 → 4.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 +142 -7
- package/README.md +83 -5
- package/package.json +5 -4
- package/src/bulk-write.ts +9 -7
- package/src/clock.ts +18 -0
- package/src/coalesce.ts +30 -16
- package/src/column.ts +82 -2
- package/src/columns-data.ts +205 -0
- package/src/columns.ts +47 -5
- package/src/count-by.ts +2 -1
- package/src/cursor.ts +23 -2
- package/src/describe.ts +58 -16
- package/src/entity.ts +27 -11
- package/src/index.ts +26 -3
- package/src/jit-preload.ts +66 -10
- package/src/memory-match.ts +169 -0
- package/src/pg-driver.ts +3 -4
- package/src/pg-row.ts +66 -20
- package/src/pg-sql.ts +58 -11
- package/src/plan.ts +5 -4
- package/src/query.ts +7 -7
- package/src/registry.ts +10 -0
- package/src/relations.ts +4 -1
- package/src/repo.ts +70 -99
- package/src/seed.ts +289 -19
- package/src/types.ts +103 -11
package/CLAUDE.md
CHANGED
|
@@ -29,6 +29,38 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
29
29
|
`pg-driver-bulk.live.test.ts`, `pg-driver-tenancy.live.test.ts`). A method with only the first is
|
|
30
30
|
unproven against Postgres itself; a method with only the second is unproven against memory. Both
|
|
31
31
|
are the bar, not either one.
|
|
32
|
+
- **What a PREDICATE means is decided by the column's declared KIND, and `memory-match.ts` is
|
|
33
|
+
where that one meaning is written.** The database decides by the column's type, so a driver
|
|
34
|
+
deciding by the JS `typeof` of the value in hand is answering a different question — four rules,
|
|
35
|
+
each of them a place the two drivers used to disagree, `As of 2026-08`. **A decimal-string column
|
|
36
|
+
orders by its digits**: `bigint()` and `decimal()` both hand back a STRING (deliberately —
|
|
37
|
+
`JSON.stringify` throws on a `bigint` and a `number` loses digits past 2^53), so neither the
|
|
38
|
+
`number`/`number` branch nor the `bigint`/`bigint` branch fired and both fell to
|
|
39
|
+
`String(left) < String(right)` — `["10","100","2","9"]` against Postgres' `2, 9, 10, 100`, and a
|
|
40
|
+
keyset page cut where the database cuts none, since the seek compares the stored string against
|
|
41
|
+
a revived `BigInt`. The comparison is exact at any width (the fractions are padded and both sides
|
|
42
|
+
become one integer), which no `Number()` is — and `As of 2026-08` it is **`@ultimat3/core`'s
|
|
43
|
+
`compareDecimalText`**, not this file's, because the text arrives in more than one package while
|
|
44
|
+
the declared kind does not. What stays here is `DECIMAL_TEXT` (which kinds are decimal text) and
|
|
45
|
+
the decision to ask; core's function answers `undefined` for a pair that is not two plain
|
|
46
|
+
decimals, so a caller with no kinds — `@ultimat3/query`, whose `OrderKey` is a name and a
|
|
47
|
+
direction — deliberately never calls it: Postgres orders a `text` column of digits lexically, and
|
|
48
|
+
a comparator guessing "both sides look like decimals" would trade this agreement for that
|
|
49
|
+
disagreement. That residual gap is `@ultimat3/query`'s `shape-order.test.ts` `DECLARED_GAP`. **A `uuid` is a VALUE**: Postgres parses it and
|
|
50
|
+
prints it lower-cased, so `findById(UPPER)` reads the row there and answered `null` here, and
|
|
51
|
+
`update(UPPER)` was `X_NOT_FOUND` against a row that exists — `keyOf(kind, value)`
|
|
52
|
+
(`batch-read.ts`), which already carried that rule for a batched read, now spells the memory
|
|
53
|
+
store's key and its equality too. Text is NOT narrowed: lower-casing it would merge two rows
|
|
54
|
+
Postgres keeps apart. **A `LIKE` pattern uses Postgres' default escape**: `\` escapes `%`, `_`
|
|
55
|
+
or itself, so `like 'a\%b'` matches the literal `a%b` in both drivers rather than
|
|
56
|
+
`a\<anything>b` in one — and a pattern ending in the escape character is refused here as
|
|
57
|
+
Postgres refuses it (`22025`). A RUN of `%` is still one `.*`: twenty adjacent `.*` groups in an
|
|
58
|
+
anchored regex is a CPU stall on a filter value forwarded from a search box. **`in` takes a list
|
|
59
|
+
or nothing**, in both drivers and in `@ultimat3/query`: a scalar operand matches NO rows (it was
|
|
60
|
+
wrapped into a one-element list for the SQL and refused in memory — 0 rows against one driver, 1
|
|
61
|
+
against the other, from a call `andWhere(column, op, value: unknown)` compiles), and a list
|
|
62
|
+
carrying a NULL emits `(col in (…) or col is null)` — `col = null` is UNKNOWN, so the null row
|
|
63
|
+
the caller listed was the one row Postgres left out while memory included it.
|
|
32
64
|
- **The Postgres driver is proved against a real Postgres, not only against a recording client.**
|
|
33
65
|
`pg-driver.live.test.ts` runs the whole chain — `entity()` -> `$describe()` ->
|
|
34
66
|
`generateMigration()` -> a live server -> `postgresDriver()` -> decoded row — and skips when no
|
|
@@ -48,7 +80,15 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
48
80
|
sent, which is why `findById` keeps its signature and there is no `batch()` to opt into. The
|
|
49
81
|
window closes before the statement goes out, so a lookup arriving mid-flight opens the next batch
|
|
50
82
|
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.
|
|
83
|
+
several whole statements rather than one Postgres refuses for its bind count. **No caller of a
|
|
84
|
+
batch is ever left unsettled** — every promise `coalesceFindById` returns was handed out before
|
|
85
|
+
the flush was scheduled, so `flush` settles the whole of `waiting` in a catch of its own rather
|
|
86
|
+
than only the chunk that failed, and the scheduled `flush` carries a `.catch`: a rejection there
|
|
87
|
+
has nobody left to hand it to and an unhandled one ends the Bun process. Unsettled forever is
|
|
88
|
+
strictly worse than failed — a rejection is a stack trace and a hang is a request that never
|
|
89
|
+
answers — which is why `coalesce.test.ts` races its assertions against a deadline instead of
|
|
90
|
+
letting the runner time out. `jit-preload.ts` has the same property by construction, settling
|
|
91
|
+
with an `Answer` rather than a rejection. A sequential
|
|
52
92
|
`for … of` loop shares no microtask — its `await` ends the window — which is what the sibling
|
|
53
93
|
preload below is for.
|
|
54
94
|
- **A page batches the loop it causes, and a preloaded row is only ever served to the statement
|
|
@@ -66,9 +106,15 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
66
106
|
calls `forgetPreloaded(entity.$name)` *before* the statement, so a row a request changed is
|
|
67
107
|
re-read and never served from a page read before it. **Values, not rows**: the index is keyed by
|
|
68
108
|
id and holds ids, so a page early in a long request pins its keys and not its rows, and it dies
|
|
69
|
-
with the request like every other per-ctx store here. **
|
|
70
|
-
|
|
71
|
-
|
|
109
|
+
with the request like every other per-ctx store here. **And the store itself is BOUNDED**
|
|
110
|
+
(`MAX_SIBLING_KEYS`, four statements' worth), `As of 2026-08`: "dies with the request" is a job's
|
|
111
|
+
whole attempt, `MAX_IDS_PER_STATEMENT` bounded the statement and nothing bounded the store, and
|
|
112
|
+
1,000 pages x 1,000 distinct keys measured **159.3 MB retained** against a 2.7 MB control — ~2 GB
|
|
113
|
+
on a 12M-row `backfill()`, an OOM in the worker on the DEFAULT configuration, since `jitPreload`
|
|
114
|
+
defaults to true and `backfill()` names no driver option. Oldest page first, for both maps: the
|
|
115
|
+
key index AND the bucket, which holds rows and is therefore the worse of the two.
|
|
116
|
+
**Declining is the old behaviour**: no request in scope, an id no page indexed, a key that
|
|
117
|
+
resolved to nothing, a key the bound evicted — the caller reads the statement it always read. `MAX_IDS_PER_STATEMENT` bounds the preload exactly as it bounds a
|
|
72
118
|
batch. What both share — the scope key, `keyOf`, the one `in` statement — lives in
|
|
73
119
|
`batch-read.ts` so the two can never disagree about when a shared statement is legal.
|
|
74
120
|
**One switch, where the driver is built**: `postgresDriver({ jitPreload: false })` /
|
|
@@ -274,7 +320,12 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
274
320
|
rendered from it.** The resolved records are the source; `ColumnDescription.references` spells
|
|
275
321
|
`"<table>.<column>"` out of one for the migration generator, which is in tier 1 and cannot
|
|
276
322
|
import this package. Never parse that string back — it carries physical names and a traversal
|
|
277
|
-
reads row *properties*, so the parse would be a second, lossy resolver. `
|
|
323
|
+
reads row *properties*, so the parse would be a second, lossy resolver. **`onDelete` rides
|
|
324
|
+
beside it, on both `ColumnDescription` and `ReferenceDescription`, `As of 2026-08-19`**: the flat
|
|
325
|
+
string has no room for a rule and neither record had a field for one, so a declared
|
|
326
|
+
`{ onDelete: 'cascade' }` type-checked and reached no SQL for three majors — `@ultimat3/db` emits
|
|
327
|
+
it now, and it can only see what the projection carries. Read off the resolved reference, never
|
|
328
|
+
off `meta` a second time: a rule with no key is not a thing. `references()` is a
|
|
278
329
|
method, not a field: a thunk may point at an entity two modules of an import cycle have not
|
|
279
330
|
finished evaluating. `relationMap()` memoises the whole-registry derivation against
|
|
280
331
|
`registryGeneration()`, which every registration bumps — a schema module imported late must
|
|
@@ -360,6 +411,16 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
360
411
|
the three callers want three answers and the wrong one is invisible in the result. The soft
|
|
361
412
|
delete inside `removal()` passes `false` too — both its callers read a count through
|
|
362
413
|
`execute()`, so its rows were never readable by anyone.
|
|
414
|
+
- **Every instant the write path stamps comes from `ctx.clock`, through `entityNow()`**
|
|
415
|
+
(`clock.ts`, `As of 2026-08`). `defaultNow()`, `touch()`'s `onUpdateNow()`, the soft-delete stamp
|
|
416
|
+
in BOTH drivers and a seed's `now` each read `systemClock` directly, so a frozen test clock drove
|
|
417
|
+
nothing the entity layer wrote — `createdAt`, `updatedAt` and `deletedAt` were the wall clock
|
|
418
|
+
however the ctx was built, and a test could only assert a range where it wanted a value. The read
|
|
419
|
+
path still reads no clock at all, which is what makes IT drivable (`@ultimat3/query`'s CLAUDE.md
|
|
420
|
+
says so in as many words); this is the write half of the same property. Outside a request there
|
|
421
|
+
is no ctx and the system clock IS the answer — a script, a worker boot and a seed take that
|
|
422
|
+
branch exactly as before. Never read `systemClock` on the write path again: five sites is how the
|
|
423
|
+
four stamps of one write ended up able to disagree.
|
|
363
424
|
- **`touch()` in `query.ts` is the ONE place `onUpdateNow()` columns are stamped**, for
|
|
364
425
|
`update(id, patch)` and `updateWhere(filter, patch)` alike — a second copy is how one of them
|
|
365
426
|
ends up writing a stale `updatedAt`. It returns an empty patch untouched, so whether
|
|
@@ -578,6 +639,76 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
578
639
|
±2^53 message, where the value is a `number`, a `bigint` or a digits-only string, the amount is
|
|
579
640
|
the only fact that repairs the row, and `@ultimat3/realtime` renders the same value the same way
|
|
580
641
|
for the same reason. Changing either means changing both.
|
|
642
|
+
- **A seed is replayable by construction, and `insert` is the verb that makes it so** — decided
|
|
643
|
+
2026-08. `defineSeed`'s context offered `insert` and nothing else, and a plain insert meant two
|
|
644
|
+
different things to the two drivers: the memory repository overwrites by primary key, Postgres
|
|
645
|
+
raises `23505`. So a seed replayed twice passed every test in this repo and killed the SECOND
|
|
646
|
+
boot of any container on a durable store, which is why both tracked apps had (or needed) a
|
|
647
|
+
hand-written `Driver` decorator over `insert`. `SeedContext.insert` now writes one
|
|
648
|
+
`upsertAll(rows, { onConflict: entity.$primaryKey, onMatch: 'nothing' })` per call. Four
|
|
649
|
+
properties, none optional. **`'nothing'`, never `'update'`**: `upsertPlan` refuses an updating
|
|
650
|
+
upsert whose target omits the tenant column (`X_TENANCY_UNSCOPED`), and a tenant-scoped entity
|
|
651
|
+
whose only unique keys are global — `posts` in the reference app — has no legal updating target
|
|
652
|
+
at all; `'nothing'` also skips the uneven-batch and nothing-to-set refusals, which a fixture
|
|
653
|
+
graph would otherwise have to satisfy. **One statement per call**, so the per-row `insert` loop
|
|
654
|
+
this replaced is no longer the N+1 of its own bulk form. **The metrics are the driver's answer**,
|
|
655
|
+
not a count of the input: `upsertAll` under `'nothing'` resolves with the rows it actually wrote,
|
|
656
|
+
so `skipped` is the replay, observed. **A generated primary key the row does not name is
|
|
657
|
+
refused** — `uuid().primaryKey()` carries `GENERATED_UUID`, so `$parse` fills it with a fresh
|
|
658
|
+
uuid, the conflict target matches nothing and run five leaves five copies; it is the one
|
|
659
|
+
duplication no other rule in this package can see.
|
|
660
|
+
- **`upsert(entity, { by }, values)` is the second verb, and it exists because only the SEED AUTHOR
|
|
661
|
+
knows the natural key.** A seed writing into a table whose ids already exist — `banks` keyed by
|
|
662
|
+
`value`, `users` by `email`, `exchange_rates` by `(base, target, effective_on)` — cannot choose a
|
|
663
|
+
primary key, so keying replay on `$primaryKey` would be keying it on something Postgres does not
|
|
664
|
+
enforce. It reads first so an unchanged row can answer `'skipped'` with no statement, then writes
|
|
665
|
+
through ONE `on conflict … do update`: the read is for the report, never for the decision, or two
|
|
666
|
+
containers booting at once would race between the two. **`createdAt` is preserved on a match** —
|
|
667
|
+
the row handed to the update omits it, so `namedProperties` leaves it out of the `set` — because
|
|
668
|
+
a replay must not move when a row first arrived.
|
|
669
|
+
- **The environment guard is the CLI's, not `run()`'s.** `seedTiersFor(environment, requested)` is
|
|
670
|
+
the one table (`reference` everywhere, `dev` everywhere but production) and `x db seed` is what
|
|
671
|
+
refuses. `run()` stays permissive on purpose: `dummy/social-media-clone/apps/web/api/index.ts`
|
|
672
|
+
seeds its own production demo database from its boot code and says out loud that this is an app
|
|
673
|
+
decision (axiom 8) — a library refusal would break it. A seed declares its tier as DATA, the way
|
|
674
|
+
a `backfill()` declares its environments.
|
|
675
|
+
- **One resolver decides a physical name, and it is `columnName(property, meta)`** — decided
|
|
676
|
+
2026-08 with `entity(name, { table })` and `.column(name)`. Before them, `snake(property)` was
|
|
677
|
+
called in nine places and `$table` was the entity name, so a schema this framework did not
|
|
678
|
+
generate could not be declared at all: adoption meant a rewrite. Every projection now reads the
|
|
679
|
+
resolver — the DDL (`describe.ts`), the binding and the decoder (`pg-row.ts`), the predicate and
|
|
680
|
+
sort resolver, the index names, the invariant SQL, the soft-delete clause in `pg-sql.ts` and
|
|
681
|
+
`pg-driver.ts`. **A second `snake(property)` anywhere is a statement naming a column the table
|
|
682
|
+
does not have**, and the first table that proves it is somebody's production database. It is
|
|
683
|
+
additive by construction: with no override the resolver IS `snake(property)`.
|
|
684
|
+
- **The entity NAME and the TABLE are different things.** The name stays the framework's key — the
|
|
685
|
+
registry, the cache tag (`entity:account`), `$tagFor`, every relation and every policy — and the
|
|
686
|
+
table is physical. Renaming a table must never move a cache tag. Index names are the TABLE's,
|
|
687
|
+
because an index is a physical object.
|
|
688
|
+
- **Money's three columns are per-part and `scale: null` is a real answer.** `money({ columns })`
|
|
689
|
+
merges over `<base>_minor`/`<base>_currency`/`<base>_scale` one part at a time, so a table that
|
|
690
|
+
renamed one does not restate the other two. `scale: null` says the table has no scale column at
|
|
691
|
+
all — the ordinary shape of an amount written before scale existed — and then `columnsOf`
|
|
692
|
+
projects TWO names, `bindValues` writes two, and `decodeRow` folds two. That last one is why
|
|
693
|
+
`decodeRow` branches on `$meta.kind === 'money'` and not on how many names came back: reading a
|
|
694
|
+
two-column amount as a non-money column handed the caller a raw minor unit where a `Money` goes.
|
|
695
|
+
- **A `jsonb` value is bound as TEXT and cast back, `::text::jsonb`** — and the double cast is
|
|
696
|
+
load-bearing, not defensive. The driver seam refuses a plain object as a parameter
|
|
697
|
+
(`X_SQL_UNSAFE`; `isBoundValue` takes scalars, `Date`, `Uint8Array` and arrays of those). With
|
|
698
|
+
`$1::jsonb` the server describes the parameter as `jsonb`, Bun's `sql` JSON-ENCODES the string it
|
|
699
|
+
was handed, and `{"a":1}` is stored as the JSON *string* — `jsonb_typeof` says `string`
|
|
700
|
+
(measured, Postgres 17.10). Pinning the parameter to `text` first makes the client send the
|
|
701
|
+
characters and the server parse them. An ARRAY is the other value that cannot cross as itself:
|
|
702
|
+
Bun serialises a JS array to `x,y`, which Postgres answers with `malformed array literal`, so
|
|
703
|
+
`bindValues` writes the `{…}` literal with every element quoted.
|
|
704
|
+
- **The wide column types were chosen from what a driver actually returns, not from what reads
|
|
705
|
+
well.** `int8` is a string from Bun's `sql` and a `bigint` from PGlite; `numeric` is a string
|
|
706
|
+
from both; `date` is a `Date` at midnight UTC from both; `bytea` is a `Buffer` from one and a
|
|
707
|
+
`Uint8Array` from the other. Every one of those is normalised in `$parse` to a single row type,
|
|
708
|
+
because a row that means two things by driver is the drift this package's two-driver split exists
|
|
709
|
+
to refuse. `bigint()` and `decimal()` are STRINGS for the same reason `money.minor` is a
|
|
710
|
+
`number`: `JSON.stringify` throws on a bigint, and a `number` loses digits exactly where a legacy
|
|
711
|
+
`int8` key lives.
|
|
581
712
|
- Never throw a bare `Error` — use `errors.ts`.
|
|
582
713
|
- Tests restore the process-global registry in `afterAll` (`clearRegistry()`): a leaked registry
|
|
583
714
|
breaks an unrelated package's tests, as it did in `@ultimat3/policy`.
|
|
@@ -586,12 +717,15 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
586
717
|
|
|
587
718
|
| File | Job |
|
|
588
719
|
|---|---|
|
|
589
|
-
| `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 |
|
|
720
|
+
| `types.ts` | `Column`, `RowOf`, `Insertable`, `IdOf` — the type derivation. `COLUMN_KINDS` is the runtime array `ColumnKind` DERIVES from (the shape core's `PRIMITIVE_KINDS` uses), so a package answering "one case per kind" reads a real list rather than spelling its own |
|
|
721
|
+
| `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 |
|
|
722
|
+
| `columns-data.ts` | the wide vocabulary an existing schema needs: `json`, `decimal`, `date`, `bigint`, `bytes`, `arrayOf` |
|
|
591
723
|
| `expr.ts` / `invariants.ts` | the `invariants: (c) => …` rule language; bind + `toSql()` DDL |
|
|
592
724
|
| `entity.ts` / `describe.ts` | `entity()`, `$row`; the `EntityDescription` projection |
|
|
593
725
|
| `view.ts` | `$view(keys)` — the row projection an action names as its `output` |
|
|
594
726
|
| `query.ts` / `database.ts` | chainable read to a cursor page; `database()` + `Driver` |
|
|
727
|
+
| `clock.ts` | `entityNow()` — the ONE clock read on the write path, `ctx.clock` else the system's |
|
|
728
|
+
| `memory-match.ts` | what a `Predicate` means in the memory driver: compare/equal/LIKE, by the column's kind. The decimal comparison itself is `@ultimat3/core`'s `compareDecimalText` |
|
|
595
729
|
| `repo.ts` / `tenancy.ts` | `Repo<T>` + `memoryDriver`'s repo, tx rollback; `QueryPlan` + `scopedPlan()` for a read and `assertRowTenant()` for a write — one actor-derived tenant guard, both halves |
|
|
596
730
|
| `cross-tenant.ts` | `crossTenant(reason, fn)` — the capability-gated scope that lifts it |
|
|
597
731
|
| `plan.ts` / `cursor.ts` | the plan both drivers execute; the one keyset cursor codec |
|
|
@@ -607,6 +741,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
607
741
|
| `registry.ts` | duplicate detection, `describeEntities()` for the manifest, `references()` per entry |
|
|
608
742
|
| `relations.ts` | `relationMap()`/`relationsFor()`/`relationNamed()` — the FKs as a named `belongsTo`/`hasMany` map |
|
|
609
743
|
| `n-plus-one.ts` | a repeated statement → the error whose `fix` is the preload or bulk call that ends it |
|
|
744
|
+
| `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
745
|
| `type-pins.ts` | compile-time assertions `tsc` checks — the column proxy, `Invariant` variance, the branded id |
|
|
611
746
|
|
|
612
747
|
## 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
|
|
|
@@ -331,6 +381,13 @@ database({ orgs, posts }, { driver: postgresDriver() }); // production
|
|
|
331
381
|
| For | tests, `x dev` before the first migration | production |
|
|
332
382
|
| Transaction | `memoryTransactor()` — undo closures | `postgresTransactor()` — real `BEGIN`/`COMMIT` |
|
|
333
383
|
| `reset()` | empties every repository it built | not implemented — the rows are the app's |
|
|
384
|
+
| A predicate | `memory-match.ts`, by the column's declared KIND | the SQL `pg-sql.ts` compiles |
|
|
385
|
+
|
|
386
|
+
Both answer the same predicate the same way, and the kind is what decides — never the JS type of
|
|
387
|
+
the value: `bigint()`/`decimal()` hold decimal STRINGS and order by their digits (`2, 9, 10, 100`),
|
|
388
|
+
a `uuid` compares case-insensitively because Postgres compares it as a value, `\` escapes a `%` or
|
|
389
|
+
a `_` inside a `like`, and `in` takes a list or nothing (a scalar matches no rows; a list carrying
|
|
390
|
+
a `null` also matches the NULL rows, which `col = null` never does).
|
|
334
391
|
|
|
335
392
|
`database()` called with no driver takes the process default, and `defaultDriver()` is that same
|
|
336
393
|
object — the one seam a test harness needs, `As of 2026-08`:
|
|
@@ -514,9 +571,30 @@ the enforcement.
|
|
|
514
571
|
|
|
515
572
|
## Seeds
|
|
516
573
|
|
|
517
|
-
`defineSeed(
|
|
518
|
-
|
|
519
|
-
the invariants, which makes a seed a test of the schema as well.
|
|
574
|
+
`defineSeed(name, build, { tier })` — the fixture graph, written once and **replayed anywhere**: a
|
|
575
|
+
second run writes nothing and raises nothing, against Postgres as well as against memory. Rows go
|
|
576
|
+
through the columns and the invariants either way, which makes a seed a test of the schema as well.
|
|
577
|
+
`x db seed [<name>]` is what applies it.
|
|
578
|
+
|
|
579
|
+
Two write verbs, because only the author knows which key identifies a row:
|
|
580
|
+
|
|
581
|
+
| Context member | Use it when | A replay |
|
|
582
|
+
|---|---|---|
|
|
583
|
+
| `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` |
|
|
584
|
+
| `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 |
|
|
585
|
+
| `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 |
|
|
586
|
+
| `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 |
|
|
587
|
+
| `id`, `now`, `environment`, `tier`, `dryRun`, `metrics` | — | `now` is one instant per run; `metrics` is `{ inserted, updated, skipped }` and `run()` returns it |
|
|
588
|
+
|
|
589
|
+
Rules worth knowing before the first seed: `upsert` never overwrites `createdAt` (`preserve` names
|
|
590
|
+
other columns to spare); `upsert` on a tenant-scoped entity needs the tenant column inside `by`,
|
|
591
|
+
exactly as `upsertAll` does, while `insert` needs nothing because `do nothing` writes nothing to a
|
|
592
|
+
row it does not own; and `insert` refuses a row that leaves a `uuid().primaryKey()` unnamed —
|
|
593
|
+
`$parse` would fill it with a fresh uuid and every replay would insert one more copy.
|
|
594
|
+
|
|
595
|
+
`tier` is `'dev'` (the default, fixture data) or `'reference'` (data the app is wrong without,
|
|
596
|
+
which ships to production). The refusal is the CLI's, never `run()`'s: an app that seeds its own
|
|
597
|
+
database from its boot code has decided to, and a library that overruled that would break it.
|
|
520
598
|
|
|
521
599
|
## Errors
|
|
522
600
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.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": "4.0.0",
|
|
35
|
+
"@ultimat3/db": "4.0.0",
|
|
36
|
+
"@ultimat3/schema": "4.0.0",
|
|
37
|
+
"@ultimat3/time": "4.0.0"
|
|
37
38
|
}
|
|
38
39
|
}
|
package/src/bulk-write.ts
CHANGED
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
// reads the same answer rather than a second copy of the rule.
|
|
6
6
|
|
|
7
7
|
import { keyOf } from './batch-read';
|
|
8
|
+
import { columnFor } from './column';
|
|
8
9
|
import { valueAt } from './cursor';
|
|
9
10
|
import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
10
11
|
import { EntityError, invariantViolated } from './errors';
|
|
11
12
|
import { columnsOf } from './pg-row';
|
|
13
|
+
import type { RowPatch } from './types';
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Postgres binds at most 65535 parameters in one statement and a multi-row insert spends
|
|
@@ -36,7 +38,7 @@ const owns = (row: unknown, property: string): boolean =>
|
|
|
36
38
|
*/
|
|
37
39
|
export const namedProperties = <Row>(
|
|
38
40
|
entity: EntityCore<Row>,
|
|
39
|
-
rows: readonly
|
|
41
|
+
rows: readonly RowPatch<Row>[],
|
|
40
42
|
): readonly string[] =>
|
|
41
43
|
Object.keys(entity.$columns).filter((property) => rows.some((row) => owns(row, property)));
|
|
42
44
|
|
|
@@ -46,7 +48,7 @@ export const insertColumns = <Row>(
|
|
|
46
48
|
properties: readonly string[],
|
|
47
49
|
): readonly string[] =>
|
|
48
50
|
properties.flatMap((property) => {
|
|
49
|
-
const column = entity.$columns
|
|
51
|
+
const column = columnFor(entity.$columns, property);
|
|
50
52
|
return column === undefined ? [] : columnsOf(property, column);
|
|
51
53
|
});
|
|
52
54
|
|
|
@@ -180,13 +182,13 @@ const sameColumns = (left: readonly string[], right: readonly string[]): boolean
|
|
|
180
182
|
*/
|
|
181
183
|
export const upsertPlan = <Row>(
|
|
182
184
|
entity: EntityCore<Row>,
|
|
183
|
-
rows: readonly
|
|
185
|
+
rows: readonly RowPatch<Row>[],
|
|
184
186
|
onConflict: readonly string[],
|
|
185
187
|
onMatch: 'update' | 'nothing',
|
|
186
188
|
): UpsertPlan => {
|
|
187
189
|
if (onConflict.length === 0) throw noConflictTarget(entity.$name);
|
|
188
190
|
for (const property of onConflict) {
|
|
189
|
-
if (entity.$columns
|
|
191
|
+
if (columnFor(entity.$columns, property) === undefined) {
|
|
190
192
|
throw invariantViolated(
|
|
191
193
|
entity.$name,
|
|
192
194
|
'upsertAll',
|
|
@@ -252,10 +254,10 @@ const cellKey = (kind: string, value: unknown): string | undefined => {
|
|
|
252
254
|
export const conflictKeyOf = <Row>(
|
|
253
255
|
entity: EntityCore<Row>,
|
|
254
256
|
on: readonly string[],
|
|
255
|
-
row:
|
|
257
|
+
row: RowPatch<Row>,
|
|
256
258
|
): string | undefined => {
|
|
257
259
|
const cells = on.map((property) =>
|
|
258
|
-
cellKey(entity.$columns
|
|
260
|
+
cellKey(columnFor(entity.$columns, property)?.$meta.kind ?? '', valueAt(row, property)),
|
|
259
261
|
);
|
|
260
262
|
return cells.some((cell) => cell === undefined) ? undefined : JSON.stringify(cells);
|
|
261
263
|
};
|
|
@@ -270,7 +272,7 @@ export const conflictKeyOf = <Row>(
|
|
|
270
272
|
export const conflictKeys = <Row>(
|
|
271
273
|
entity: EntityCore<Row>,
|
|
272
274
|
plan: UpsertPlan,
|
|
273
|
-
rows: readonly
|
|
275
|
+
rows: readonly RowPatch<Row>[],
|
|
274
276
|
): readonly (string | undefined)[] => {
|
|
275
277
|
const keys = rows.map((row) => conflictKeyOf(entity, plan.on, row));
|
|
276
278
|
if (plan.set.length === 0) return keys;
|
package/src/clock.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Single responsibility: the instant the entity layer WRITES. One reader of the clock, so
|
|
2
|
+
// `defaultNow()`, `onUpdateNow()`, the soft-delete stamp and a seed's `now` are one value from one
|
|
3
|
+
// source — and a second `systemClock.now()` anywhere on the write path is a timestamp no test can
|
|
4
|
+
// drive and no two of these four can agree with.
|
|
5
|
+
|
|
6
|
+
import { systemClock, tryUseContext } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `ctx.clock`, and the system clock outside every request.
|
|
10
|
+
*
|
|
11
|
+
* The READ path reads no clock at all, which is what makes it drivable (`@ultimat3/query`'s
|
|
12
|
+
* CLAUDE.md says so in as many words). The write path read `systemClock` directly at five sites, so
|
|
13
|
+
* a frozen test clock drove nothing the entity layer stamped: `createdAt`, `updatedAt` and
|
|
14
|
+
* `deletedAt` all came from the wall clock however the ctx was built, and a test asserting on one
|
|
15
|
+
* had to assert a range instead of a value. Outside a request there is no ctx and the system clock
|
|
16
|
+
* IS the answer — a script, a worker boot and a seed all take that branch, exactly as before.
|
|
17
|
+
*/
|
|
18
|
+
export const entityNow = (): Date => (tryUseContext()?.clock ?? systemClock).now();
|
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,66 @@
|
|
|
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
|
+
ColumnMap,
|
|
15
|
+
ColumnMeta,
|
|
16
|
+
MoneyColumnNames,
|
|
17
|
+
TimestampColumn,
|
|
18
|
+
} from './types';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The column a NAME resolves to, or `undefined` — the ONE read of a column map by a name that came
|
|
22
|
+
* from outside it.
|
|
23
|
+
*
|
|
24
|
+
* `columns[property]` alone walks `Object.prototype`: an app's column map is a plain object
|
|
25
|
+
* literal, so `columns['constructor']` answers the `Object` FUNCTION, every `=== undefined` guard
|
|
26
|
+
* downstream passes, and the next `.$meta.kind` is a bare `TypeError` where the caller was owed
|
|
27
|
+
* `X_INVARIANT_VIOLATED` naming the columns that do exist. The name is caller data on every path
|
|
28
|
+
* that reaches here — a predicate column, a sort key, an `onConflict` target, a `select` list — so
|
|
29
|
+
* the discriminator lives in one place. Same read `tenancy.ts` already does for the tenant column.
|
|
30
|
+
*/
|
|
31
|
+
export const columnFor = (columns: ColumnMap, property: string): AnyColumn | undefined =>
|
|
32
|
+
Object.hasOwn(columns, property) ? columns[property] : undefined;
|
|
11
33
|
|
|
12
34
|
export const snake = (value: string): string =>
|
|
13
35
|
value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
14
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The physical column, decided in ONE place: what `.column()` declared, else `snake(property)`.
|
|
39
|
+
*
|
|
40
|
+
* Every projection reads it here — the DDL, the binding, the decoder, the invariant resolver, the
|
|
41
|
+
* index names — because a second `snake(property)` anywhere is a statement naming a column the
|
|
42
|
+
* table does not have, and the first table that proves it is somebody's production database.
|
|
43
|
+
*/
|
|
44
|
+
export const columnName = (property: string, meta: ColumnMeta): string =>
|
|
45
|
+
meta.name ?? snake(property);
|
|
46
|
+
|
|
47
|
+
/** Money's three physical columns, resolved. `scale: null` is a table that has no scale column. */
|
|
48
|
+
export interface MoneyColumns {
|
|
49
|
+
readonly minor: string;
|
|
50
|
+
readonly currency: string;
|
|
51
|
+
readonly scale: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Per part, merged over the `<base>_minor` / `<base>_currency` / `<base>_scale` defaults — so a
|
|
56
|
+
* table that renamed one of the three does not have to restate the other two, and `.column()`
|
|
57
|
+
* moves the base for all of them at once.
|
|
58
|
+
*/
|
|
59
|
+
export const moneyColumns = (property: string, meta: ColumnMeta): MoneyColumns => {
|
|
60
|
+
const base = columnName(property, meta);
|
|
61
|
+
const declared: MoneyColumnNames = meta.parts ?? {};
|
|
62
|
+
return {
|
|
63
|
+
minor: declared.minor ?? `${base}_minor`,
|
|
64
|
+
currency: declared.currency ?? `${base}_currency`,
|
|
65
|
+
// `undefined` takes the default; `null` is the caller saying the column is not there at all.
|
|
66
|
+
scale: declared.scale === undefined ? `${base}_scale` : declared.scale,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
15
70
|
export const GENERATED_UUID: ColumnDefault = { kind: 'generated', by: 'uuid-v7' };
|
|
16
71
|
export const GENERATED_NOW: ColumnDefault = { kind: 'generated', by: 'now' };
|
|
17
72
|
|
|
@@ -49,7 +104,7 @@ export const bindColumn = (column: AnyColumn, table: string, property: string):
|
|
|
49
104
|
'build a new column instead of sharing one between entities',
|
|
50
105
|
);
|
|
51
106
|
}
|
|
52
|
-
const binding: Binding = { table, property, name:
|
|
107
|
+
const binding: Binding = { table, property, name: columnName(property, column.$meta) };
|
|
53
108
|
bindings.set(column, binding);
|
|
54
109
|
return binding;
|
|
55
110
|
};
|
|
@@ -135,8 +190,30 @@ export const makeColumn = <T, Optional extends boolean>(
|
|
|
135
190
|
),
|
|
136
191
|
|
|
137
192
|
default: (value) => makeColumn<T, true>({ ...meta, default: literal(value) }, parse, true),
|
|
193
|
+
|
|
194
|
+
column: (name) =>
|
|
195
|
+
makeColumn<T, Optional>({ ...meta, name: assertColumnName(name) }, parse, optional),
|
|
138
196
|
});
|
|
139
197
|
|
|
198
|
+
/**
|
|
199
|
+
* A physical name is spliced into DDL and into every statement as a quoted identifier, so it is
|
|
200
|
+
* checked where it is written rather than trusted there: an empty name produces `""`, and a name
|
|
201
|
+
* carrying a quote or a newline is the one value in a column declaration that could close the
|
|
202
|
+
* identifier. `[a-z_][a-z0-9_$]*`, which is what an unquoted Postgres identifier may be, and the
|
|
203
|
+
* bound is the same 63 bytes the server truncates at — a longer one silently addresses a
|
|
204
|
+
* different column.
|
|
205
|
+
*/
|
|
206
|
+
export const assertColumnName = (name: string): string => {
|
|
207
|
+
if (!/^[a-z_][a-z0-9_$]*$/.test(name) || name.length > 63) {
|
|
208
|
+
throw invariantViolated(
|
|
209
|
+
'column',
|
|
210
|
+
'column-name',
|
|
211
|
+
`"${name}" is not a physical column name: lower-case letters, digits and underscores, at most 63 of them`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return name;
|
|
215
|
+
};
|
|
216
|
+
|
|
140
217
|
export const column = <T>(
|
|
141
218
|
kind: ColumnMeta['kind'],
|
|
142
219
|
parse: (value: unknown) => T,
|
|
@@ -155,4 +232,7 @@ export const makeTimestamp = <Optional extends boolean>(
|
|
|
155
232
|
...makeColumn<Date, Optional>(meta, parse, optional),
|
|
156
233
|
defaultNow: () => makeTimestamp({ ...meta, default: GENERATED_NOW }, parse, true),
|
|
157
234
|
onUpdateNow: () => makeTimestamp({ ...meta, onUpdate: GENERATED_NOW }, parse, optional),
|
|
235
|
+
// Overridden so `timestamp().column('created').defaultNow()` still has `defaultNow` — the
|
|
236
|
+
// general link returns the general column, and a builder with methods of its own keeps them.
|
|
237
|
+
column: (name) => makeTimestamp({ ...meta, name: assertColumnName(name) }, parse, optional),
|
|
158
238
|
});
|