@ultimat3/entity 23.0.0 → 24.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 +37 -21
- package/README.md +32 -19
- package/package.json +5 -5
- package/src/aggregate.ts +31 -22
- package/src/bulk-write.ts +4 -13
- package/src/column.ts +36 -1
- package/src/columns-data.ts +24 -3
- package/src/columns.ts +8 -1
- package/src/containment.ts +15 -2
- package/src/database.ts +9 -2
- package/src/entity-error.ts +26 -21
- package/src/entity.ts +3 -6
- package/src/errors.ts +16 -42
- package/src/expr.ts +66 -20
- package/src/index.ts +6 -1
- package/src/invariants.ts +3 -3
- package/src/judged-write.ts +28 -0
- package/src/like.ts +85 -0
- package/src/memory-match.ts +20 -82
- package/src/memory-unique.ts +7 -6
- package/src/numeric-compare.ts +57 -0
- package/src/pg-driver.ts +34 -28
- package/src/pg-sql.ts +5 -1
- package/src/plan.ts +9 -3
- package/src/preload-ceiling.ts +75 -0
- package/src/preload.ts +20 -7
- package/src/query.ts +14 -14
- package/src/sealed-column.ts +7 -3
- package/src/sealed.ts +6 -4
- package/src/seed.ts +97 -73
- package/src/transition.ts +3 -0
- package/src/types.ts +6 -0
- package/src/unique-constraints.ts +44 -0
package/CLAUDE.md
CHANGED
|
@@ -24,10 +24,14 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
24
24
|
Both are the bar.
|
|
25
25
|
- **`write-parity.test.ts` runs every write against memory AND PGlite**: a patch property present with
|
|
26
26
|
`undefined` is ABSENT (only `null` clears); memory refuses a duplicate PK, a duplicate non-partial
|
|
27
|
-
|
|
28
|
-
uniques are not checked in memory);
|
|
27
|
+
unique — index OR `c.unique([…])` invariant, the one list in `unique-constraints.ts` — and a PK
|
|
28
|
+
patch onto another row with `X_DB_UNIQUE_VIOLATION` (partial uniques are not checked in memory);
|
|
29
|
+
`.transition()` filters on `singleKeyOf(entity)` and refuses an absent id (`X_NOT_FOUND`, no row
|
|
30
|
+
moved);
|
|
29
31
|
`decimal()`/`bigint()` store Postgres's spelling (never rounded); a seed `upsert` compares only named
|
|
30
|
-
columns; `text({ max })` counts code points; `integer()` holds int4
|
|
32
|
+
columns; `text({ max })` counts code points; `integer()` holds int4, `bigint()` int8; a whole part
|
|
33
|
+
of `0` is no `numeric` digit; `url()` lower-cases its scheme and judges the STORED text.
|
|
34
|
+
`invariant-parity.test.ts` asks the app, the driver and the raw CHECK the same question.
|
|
31
35
|
- **`min`/`max` over a `timestamptz` crosses as epoch ms** (`aggregate-time-parity.test.ts`). A whole
|
|
32
36
|
row read under a non-UTC session still fails on PGlite — recorded, not fixed.
|
|
33
37
|
- **Text ordering: byte (`C`) order is the parity target** — a linguistic production collation is a
|
|
@@ -37,10 +41,13 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
37
41
|
holds a `number`); `RowWrite<Row>` types `insert`/`insertAll`/`upsertAll`; `narrowRow` (`columns.ts`)
|
|
38
42
|
narrows at each write method's ENTRY, before `$assert`/`upsertPlan`. `pg-money-write.live.test.ts`,
|
|
39
43
|
`money-write-parity.test.ts`, `type-pins.ts`.
|
|
40
|
-
- **A PREDICATE's meaning is decided by the column's KIND** (`memory-match.ts`)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
- **A PREDICATE's meaning is decided by the column's KIND** (`memory-match.ts`); `compareByKind` /
|
|
45
|
+
`sameValueOfKind` are EXPORTED — `@ultimat3/query` calls them. **ONE numeric comparison,
|
|
46
|
+
`numericOrder`**: `DECIMAL_TEXT` columns by VALUE (`'2.50' = 2.5`), `number`/`bigint` numerically
|
|
47
|
+
(`compare-parity.test.ts`). A `uuid` is a value, compared lower-cased and
|
|
48
|
+
stored lower-case (`keyOf`, `parseUuid`, `narrowUuid`; text is never narrowed); `LIKE` (`like.ts`)
|
|
49
|
+
uses Postgres' default `\` escape, refuses a trailing escape, counts CHARACTERS and is a bounded
|
|
50
|
+
two-pointer walk — never a regex; `in` takes a
|
|
44
51
|
list or nothing, and a NULL in it emits `(col in (…) or col is null)`; a column the row never NAMED
|
|
45
52
|
is NULL for `eq`/`neq`/`in`.
|
|
46
53
|
- **The Postgres driver is proved against a real Postgres** (`pg-driver.live.test.ts`, skipped without
|
|
@@ -48,9 +55,9 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
48
55
|
- **A repository call rejects, never throws synchronously** (`tableFor`'s writes are `async`).
|
|
49
56
|
- **`defaultDriver()`** is the process default; `Driver.reset?()` is optional (memory only), resets
|
|
50
57
|
repositories in place. Test seam only.
|
|
51
|
-
- **A repository pinned to its own client
|
|
52
|
-
(`X_REPO_CLIENT_PINNED`, in `client()` in `pg-driver.ts`); the fix names
|
|
53
|
-
an unpinned repository.
|
|
58
|
+
- **A repository pinned to its own client joins a transaction opened ON that client** (`tx.origin`)
|
|
59
|
+
**and refuses any other** (`X_REPO_CLIENT_PINNED`, in `client()` in `pg-driver.ts`); the fix names
|
|
60
|
+
`setDbClient(client)` plus an unpinned repository.
|
|
54
61
|
|
|
55
62
|
## Do not regress — reading
|
|
56
63
|
|
|
@@ -63,7 +70,8 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
63
70
|
`writing()`), keyed by id, bounded (`MAX_SIBLING_KEYS`, oldest page first). One switch:
|
|
64
71
|
`postgresDriver({ jitPreload: false })` — never an `app.config.ts` key. Shared pieces live in
|
|
65
72
|
`batch-read.ts`.
|
|
66
|
-
- **`preload(name)` shares `batch-read.ts
|
|
73
|
+
- **`preload(name, { max })` shares `batch-read.ts`, keeps no request cache and is BOUNDED**
|
|
74
|
+
(`preload-ceiling.ts`: default `MAX_PRELOADED_ROWS`, refused past it, never truncated). Tenancy is CARRIED only when
|
|
67
75
|
both entities are scoped by a column of that same name, else the related read refuses
|
|
68
76
|
(`X_TENANCY_UNSCOPED`). Reach is the `database()` call's `RelatedTables`; `select()` widens with each
|
|
69
77
|
preloaded relation's local key; attachment copies (`{ ...row }`); only `page()`/`all()`/`one()`
|
|
@@ -108,12 +116,12 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
108
116
|
- **The four aggregates** (`sum`, `avg`, `min`, `max`) share `aggregate.ts`
|
|
109
117
|
(`aggregate-fold.ts` memory, `aggregate-decode.ts` Postgres): never a float; `avg` at `AVG_SCALE` (6),
|
|
110
118
|
half away from zero; `null` for an empty set. Refused: `min`/`max` on text, `avg` over money
|
|
111
|
-
(`X_AGGREGATE_UNSUPPORTED`), mixed currency or scale (`X_AGGREGATE_MIXED_CURRENCY
|
|
119
|
+
(`X_AGGREGATE_UNSUPPORTED`), mixed currency or scale (`X_AGGREGATE_MIXED_CURRENCY`, two causes, two fixes), a money total
|
|
112
120
|
past ±2^53. **`approximateCount()` is `reltuples`**; a filtered chain or tenant-scoped entity is
|
|
113
121
|
`X_APPROXIMATE_COUNT_FILTERED`; `null` for an unanalysed table.
|
|
114
122
|
- **`json()` / `arrayOf()` are filterable** (`contains`, `contained-by`, `overlaps`, `has-key`) with
|
|
115
|
-
Postgres' measured meaning in `containment.ts`; `has-key` emits `operator(pg_catalog.?)` (indexable)
|
|
116
|
-
|
|
123
|
+
Postgres' measured meaning in `containment.ts`; `has-key` emits `operator(pg_catalog.?)` (indexable)
|
|
124
|
+
and takes a STRING key, else no rows; a NULL array element equals nothing. No jsonpath operator.
|
|
117
125
|
- **A GIN index is declarable** (`using: 'gin'`; the set is `@ultimat3/db`'s `INDEX_METHODS`); absent
|
|
118
126
|
is `btree` byte for byte; the method joins the name discriminator only when declared; GIN cannot be
|
|
119
127
|
unique or ordered (refused here). `pg-containment.live.test.ts`.
|
|
@@ -145,7 +153,9 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
145
153
|
local renderers are deleted). An untranslatable predicate is `kind: 'assert'`, `sql: null`.
|
|
146
154
|
**`InvariantDescription.columns` is projected** (`describe-invariant.test.ts`).
|
|
147
155
|
- **The two halves must AGREE, term by term** (`expr.ts`): `matches(/…/i)` is `~*`, other flags are
|
|
148
|
-
refused (`matchOperator`); `minLength` counts code points
|
|
156
|
+
refused (`matchOperator`); `minLength` counts code points; `trimmed()` strips U+0020 only (`btrim`);
|
|
157
|
+
`atLeast`/`eq(<number>)` judge by the column's KIND (`invariantColumns(name, columns)`), so a
|
|
158
|
+
`bigint()`/`decimal()` row compares by its digits. **`isNull()`/`isNotNull()` are the only
|
|
149
159
|
total members**; `iff(a, b)` renders `(a) = (b)` — measured as the safe direction
|
|
150
160
|
(`pg-invariant-null.live.test.ts`); `iff` is a function, and a `unique` operand is refused with the
|
|
151
161
|
`c.unique([…])` fix built through `JSON.stringify`. One app-only operand makes the whole rule
|
|
@@ -200,7 +210,8 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
200
210
|
- **`deleteWhere` / `updateWhere` are the only filtered writes**: empty filter `X_WRITE_UNFILTERED`,
|
|
201
211
|
empty patch `X_PATCH_EMPTY` (undefined values dropped first); soft delete respected; both return a
|
|
202
212
|
count; rows come back only when a JS-only invariant needs them (`hasJsOnlyInvariant`), counted first
|
|
203
|
-
and refused past `MAX_ASSERTED_ROWS` (50,000) in BOTH drivers
|
|
213
|
+
and refused past `MAX_ASSERTED_ROWS` (50,000) in BOTH drivers; on Postgres that statement and its
|
|
214
|
+
judgement share a transaction (`judged-write.ts`, `update` too), so a refusal rolls back. `updateStatement`'s `returning` is
|
|
204
215
|
required.
|
|
205
216
|
- **Every instant the write path stamps comes from `entityNow()`** (`clock.ts`, `ctx.clock`).
|
|
206
217
|
**`touch()` in `query.ts` is the ONE place `onUpdateNow()` columns are stamped.**
|
|
@@ -214,7 +225,9 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
214
225
|
- **A seed is replayable**: `SeedContext.insert` is one `upsertAll(…, { onMatch: 'nothing' })` per
|
|
215
226
|
call; a generated PK the row does not name is refused. `upsert(entity, { by }, values)` keys on a
|
|
216
227
|
natural key, reads first for the report, writes one `on conflict … do update`, preserves `createdAt`.
|
|
217
|
-
**The environment guard is the CLI's** (`seedTiersFor`; `run()` stays permissive).
|
|
228
|
+
**The environment guard is the CLI's** (`seedTiersFor`; `run()` stays permissive). **A dry run is
|
|
229
|
+
the real run rolled back** through `Driver.transactor()` (REQUIRED on the interface) — one rule,
|
|
230
|
+
never a per-verb lookup.
|
|
218
231
|
- **`setRowObserver` reports committed row changes above the driver** — one per process (returns the
|
|
219
232
|
replaced one), applied by `database()`, one comparison per write when unset, `before` only when the PK
|
|
220
233
|
is `id`, a filtered write is `onBulk`. Not a second change-feed path.
|
|
@@ -231,7 +244,8 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
231
244
|
rewrites or refuses every predicate. A driver never seals for itself; an entity with no sealed
|
|
232
245
|
column gets its repository back untouched. The cipher is core's (`scripts/seal-calls.ts`).
|
|
233
246
|
- **A sealed column's `$parse` passes a sealed string and judges a plaintext**; `decodeRow` parses
|
|
234
|
-
none. `sealValue` runs the plaintext parser BEFORE sealing — nothing
|
|
247
|
+
none. `sealValue` runs the UNWRAPPED plaintext parser (`$meta.sealed.plaintext`) BEFORE sealing — nothing
|
|
248
|
+
after can, and the column's own `$parse` would pass a sealed-shaped lookalike.
|
|
235
249
|
- **No reading of an unsealed value.** Not sealed is `X_SEAL_INVALID`; existing plaintext is
|
|
236
250
|
expand → `backfill()` → contract (README). No `legacy` option.
|
|
237
251
|
- **Purpose is `entity:<table>.<column>`, physical names** (`sealed.ts`). **Sealing is not on
|
|
@@ -261,7 +275,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
261
275
|
`persist` defaults `false`, read only off the projection. **The browser path is
|
|
262
276
|
`@ultimat3/entity/record`**, whose modules import `entity-error.ts`, never `errors.ts`
|
|
263
277
|
(`record-bundle.test.ts`).
|
|
264
|
-
- Never throw a bare `Error` — use `errors.ts`.
|
|
278
|
+
- Never throw a bare `Error` — use `errors.ts`. `X_DB_DRIFT` is `@ultimat3/db`'s, factory included.
|
|
265
279
|
- **Tests restore the registry with a FILE-scope `afterAll(() => { clearRegistry(); })`** — matched on
|
|
266
280
|
exact text by `live-registry-cleanup.test.ts`; never inside a skippable suite's teardown.
|
|
267
281
|
|
|
@@ -273,9 +287,11 @@ Each file's header states its one job. The map, by area: `types.ts` (derivation;
|
|
|
273
287
|
`entity.ts` / `describe.ts` / `index-name.ts` / `search.ts`, `state-machine.ts` / `transition.ts`,
|
|
274
288
|
`feature-errors.ts`, `view.ts` / `row-schema.ts` / `record-projection.ts` / `record-key.ts` /
|
|
275
289
|
`rows-of.ts` / `record-table.ts` / `record.ts`, `entity-error.ts` / `errors.ts`, `query.ts` /
|
|
276
|
-
`database.ts` / `clock.ts`, `memory-match.ts` / `
|
|
290
|
+
`database.ts` / `clock.ts`, `memory-match.ts` / `like.ts` / `numeric-compare.ts` / `unique-constraints.ts` / `repo.ts` /
|
|
291
|
+
`memory-repo.ts` / `tenancy.ts` /
|
|
277
292
|
`cross-tenant.ts`, `plan.ts` / `cursor.ts` / `batch.ts`, `pg-driver.ts` / `coalesce.ts` /
|
|
278
|
-
`batch-read.ts` / `bulk-write.ts` / `count-by.ts` / `jit-preload.ts` / `preload.ts` /
|
|
293
|
+
`batch-read.ts` / `bulk-write.ts` / `count-by.ts` / `jit-preload.ts` / `preload.ts` /
|
|
294
|
+
`preload-ceiling.ts` / `judged-write.ts` / `pg-sql.ts` /
|
|
279
295
|
`pg-row.ts`, `row-observer.ts` / `write-tag.ts` (a keyed request's write names itself in the WAL via
|
|
280
296
|
`pg_logical_emit_message`), `registry.ts` / `relations.ts` / `n-plus-one.ts` / `seed.ts`,
|
|
281
297
|
`type-pins.ts`, `live-registry-cleanup.test.ts`.
|
package/README.md
CHANGED
|
@@ -110,7 +110,7 @@ never runs, while the subpath retains the projection, the key and the registry a
|
|
|
110
110
|
| `money()` | `<name>_minor bigint` + `<name>_currency char(3)` + `<name>_scale integer null` | never a float, never one implied currency. The row value is `@ultimat3/schema`'s `MoneyValue` — the same declaration `@ultimat3/money`'s `Money` is — so a decoded row goes straight to `add()`/`formatMoney()`. A writer may hand a `bigint` — `Insertable` says so on a table, `RowWrite<Row>` at a repository, and every whole-row write narrows it at its own entry, before an invariant or a statement sees it; a minor unit past ±2^53 is refused there and on read, never rounded. `scale` is the decimal exponent `minor` counts in when it is not the currency's own (`{ minor: 2, currency: 'USD', scale: 6 }` is $0.000002); NULL in the column means "the currency's own minor unit" and round-trips as an ABSENT key, never as `0` |
|
|
111
111
|
| `enumerated(v)` | `text` + CHECK | a variant is a one-line migration, not `ALTER TYPE` |
|
|
112
112
|
| `tz(zones)`, `locale(tags)` | `text` + CHECK, `Intl`-validated at declaration | an offset is not a time zone |
|
|
113
|
-
| `text({ max })`, `integer()`, `boolean()`, `url()` | `text`/`integer`/`boolean` + CHECK | format is enforced by the database too |
|
|
113
|
+
| `text({ max })`, `integer()`, `boolean()`, `url()` | `text`/`integer`/`boolean` + CHECK | format is enforced by the database too. `url()` judges the STORED text against its CHECK (`^https?://`), so leading whitespace, a tab inside the scheme and `https:/a.b` are refused here rather than by a raw `23514` |
|
|
114
114
|
|
|
115
115
|
Chain: `.primaryKey()` · `.nullable()` · `.unique()` · `.default(v)` · `.defaultNow()` ·
|
|
116
116
|
`.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()` · `.column(name)` ·
|
|
@@ -236,9 +236,9 @@ for a table it was going to create; these are the shapes a table already has, `A
|
|
|
236
236
|
| Builder | Emits | Row type, and why |
|
|
237
237
|
|---|---|---|
|
|
238
238
|
| `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 |
|
|
239
|
-
| `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 |
|
|
239
|
+
| `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. A whole part of `0` counts no digit, so `numeric(2, 2)` holds `0.50` |
|
|
240
240
|
| `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 |
|
|
241
|
-
| `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 |
|
|
241
|
+
| `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. Bounded at `int8` (−2^63 … 2^63−1): one past either end is refused where Postgres answers `22003` |
|
|
242
242
|
| `bytes()` | `bytea` | a plain `Uint8Array`, normalised: Bun's `sql` returns a `Buffer` and PGlite a `Uint8Array`, and the two do not serialise alike |
|
|
243
243
|
| `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 |
|
|
244
244
|
|
|
@@ -487,7 +487,10 @@ written schema-qualified as `operator(pg_catalog.?)` — the function form `json
|
|
|
487
487
|
the same test and the planner will not match a GIN index to it. Their
|
|
488
488
|
meaning is Postgres', to the letter, in both drivers — including that the array-contains-a-primitive
|
|
489
489
|
exception applies at the **top level only** and to **primitives only**, and that `&&`'s empty
|
|
490
|
-
operand overlaps nothing where `@>`'s is contained by everything.
|
|
490
|
+
operand overlaps nothing where `@>`'s is contained by everything. Two more rules both drivers hold:
|
|
491
|
+
a NULL **array element** equals nothing, itself included, on either side of `@>`, `<@` and `&&`
|
|
492
|
+
(a `jsonb` `null` is a value and still equals itself); and `has-key` takes a **string** key — any
|
|
493
|
+
other operand matches no row, never `String(operand)`.
|
|
491
494
|
|
|
492
495
|
There is no jsonpath expression operator: `contains` already matches nested structure, and a path
|
|
493
496
|
language inside the query language would be a second way to ask one question. `&&` on a `jsonb`
|
|
@@ -599,6 +602,7 @@ const shipped = await db.orders.transition('status', id, { from: 'paid', to: 'sh
|
|
|
599
602
|
| `onUpdateNow()` moves, because a transition is an update | the audit of *when* it moved, with no second mechanism beside it |
|
|
600
603
|
| the CHECK comes from `enumerated()` | the machine emits no DDL of its own — one declaration of what a legal value is |
|
|
601
604
|
| `memoryDriver()` answers it exactly as Postgres does | a compare-and-set over a map is the same question; unlike a `tsvector` match, there is nothing to fake |
|
|
605
|
+
| an `undefined` or `null` id is `X_NOT_FOUND`, and moves no row | an absent id names no row — it is refused before the statement, where it used to drop out of the filter and move every row in the `from` state |
|
|
602
606
|
|
|
603
607
|
What is deliberately **not** here: who may make a move, what happens on arrival, an approval chain,
|
|
604
608
|
a reason code. Those differ per app — wrap `transition()` in your own function and put them there.
|
|
@@ -659,7 +663,7 @@ and never unwritten.
|
|
|
659
663
|
| Tenancy | the plan a read builds, through `scopedPlan` — the actor's org predicate is in the statement, and the empty-filter guard runs before it, because one tenant's every row is still every row. The patch is judged too, through `assertRowTenant`: a filter bounds which rows are written, never what they become |
|
|
660
664
|
| Soft delete | the entity's `deletedAt` column is the same switch `delete(id)` uses. Stamped rows are not matched again by either call, so the original deletion time survives and a deleted row is never patched back into shape |
|
|
661
665
|
| `onUpdateNow()` | stamped by `touch()`, the same helper `update(id, patch)` uses — one place, so the two can never disagree about `updatedAt` |
|
|
662
|
-
| Rows read back | **only when something can still refuse them.** A `check` or a `unique` invariant is a constraint Postgres enforced on the statement, so the answer is a count and the statement carries no `returning *`. Only a JS-only rule (`kind: 'assert'`, `sql: null`) has to be judged on the result — and then the match is counted first and refused past `MAX_ASSERTED_ROWS` (50,000), naming `inBatches(1000)`, because a refusal issued after `returning *` is already holding what it is refusing |
|
|
666
|
+
| Rows read back | **only when something can still refuse them.** A `check` or a `unique` invariant is a constraint Postgres enforced on the statement, so the answer is a count and the statement carries no `returning *`. Only a JS-only rule (`kind: 'assert'`, `sql: null`) has to be judged on the result — and then the match is counted first and refused past `MAX_ASSERTED_ROWS` (50,000), naming `inBatches(1000)`, because a refusal issued after `returning *` is already holding what it is refusing. On Postgres the statement and that judgement share one transaction (a SAVEPOINT inside an open one), for `update(id, patch)` too, so a refused write is rolled back rather than left committed |
|
|
663
667
|
|
|
664
668
|
## Writing many rows
|
|
665
669
|
|
|
@@ -756,6 +760,7 @@ page.rows[0].author; // the member row, or null — always present
|
|
|
756
760
|
| Unknown name | `X_PRELOAD_UNKNOWN_RELATION` at `preload()` itself, not a page later |
|
|
757
761
|
| Shape | `belongsTo` attaches the row or `null`; `hasMany` an array — always present |
|
|
758
762
|
| Statements | one extra per relation, resolved concurrently; naming one twice is one statement |
|
|
763
|
+
| Ceiling | `preload('<relation>', { max })` — the most related rows one relation may attach to one page. Default `MAX_PRELOADED_ROWS` (10,000, the largest page a read may ask for). Past it the read is **refused** (`X_INVARIANT_VIOLATED`), never truncated, and it reads at most one row past the ceiling to know. Naming a relation twice is still one statement: a later call that states `max` replaces the earlier one, as a second `.limit()` does, and one that states none leaves it. The refusal's cause spells the call to write (`.preload('<relation>', { max: n })`); its fix is `x entities describe <entity> --json` |
|
|
759
764
|
| Tenancy | carried onto the related read only when the other entity's tenant column shares the name; otherwise `X_TENANCY_UNSCOPED` refuses the related read rather than guess |
|
|
760
765
|
| Terminals | `page()`, `all()`, `one()` preload; `count()`, `countBy()` and `plan()` don't — none reads a row to attach one to |
|
|
761
766
|
|
|
@@ -775,11 +780,20 @@ database({ orgs, posts }, { driver: postgresDriver() }); // production
|
|
|
775
780
|
| A predicate | `memory-match.ts`, by the column's declared KIND | the SQL `pg-sql.ts` compiles |
|
|
776
781
|
|
|
777
782
|
Both answer the same predicate the same way, and the kind is what decides — never the JS type of
|
|
778
|
-
the value: `bigint()`/`decimal()` hold decimal STRINGS
|
|
779
|
-
|
|
780
|
-
a `
|
|
783
|
+
the value: `bigint()`/`decimal()` hold decimal STRINGS, order by their digits (`2, 9, 10, 100`) and
|
|
784
|
+
are EQUAL by value (`'2.50'` matches `'2.5'`, `2.5` and `'2.500'`; `'10'` matches `10`),
|
|
785
|
+
a `uuid` is ordered and equated case-insensitively because Postgres compares it as a value, `\` escapes a `%` or
|
|
786
|
+
a `_` inside a `like` (whose `_` is one CHARACTER, astral or not, and whose match is bounded by
|
|
787
|
+
pattern × value — never a backtracking regex), a unique declared as `invariant(name, c.unique([…]))`
|
|
788
|
+
is enforced by memory exactly as an index is, and `in` takes a list or nothing (a scalar matches no rows; a list carrying
|
|
781
789
|
a `null` also matches the NULL rows, which `col = null` never does).
|
|
782
790
|
|
|
791
|
+
`compareByKind(kind, left, right)` and `sameValueOfKind(kind, left, right)` are that rule, exported
|
|
792
|
+
(`As of 2026-10`): `-1 | 0 | 1` and a boolean for two values of one column of a declared
|
|
793
|
+
`ColumnKind`, NULL sorting largest. `@ultimat3/query`'s live matcher and in-memory source call them
|
|
794
|
+
rather than keep a comparator of their own; `compare-parity.test.ts` runs the rule against both
|
|
795
|
+
drivers.
|
|
796
|
+
|
|
783
797
|
`database()` called with no driver takes the process default, and `defaultDriver()` is that same
|
|
784
798
|
object — the one seam a test harness needs, `As of 2026-08`:
|
|
785
799
|
|
|
@@ -803,14 +817,13 @@ returns the open transaction when there is one, so a repository call inside `wit
|
|
|
803
817
|
joins it without being told — which is how a job's outbox row lands atomically with the write
|
|
804
818
|
that enqueued it.
|
|
805
819
|
|
|
806
|
-
`postgresDriver({ client })` pins one instead, for a test harness or `x db branch
|
|
807
|
-
repository
|
|
808
|
-
`
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
plus an unpinned repository is the shape that joins.
|
|
820
|
+
`postgresDriver({ client })` pins one instead, for a test harness or `x db branch`. A pinned
|
|
821
|
+
repository joins a transaction that was opened ON its own client — `withTransaction(fn, { client })`,
|
|
822
|
+
`postgresTransactor({ client })`, `driver.transactor()` — because `tx.origin` says it is the same
|
|
823
|
+
database and the same reservation (`As of 2026-10-02`). Inside any OTHER open transaction it is
|
|
824
|
+
`X_REPO_CLIENT_PINNED`: a statement sent to the pinned client would commit whatever that
|
|
825
|
+
transaction decides and miss what it has written, both silently. `setDbClient(client)` plus an
|
|
826
|
+
unpinned repository is the shape that joins the ambient one.
|
|
814
827
|
|
|
815
828
|
Every value is bound to `$n` and every identifier is resolved through the entity, so a column
|
|
816
829
|
name can only be one the entity declared and a row value can never become SQL.
|
|
@@ -977,11 +990,11 @@ Two write verbs, because only the author knows which key identifies a row:
|
|
|
977
990
|
|
|
978
991
|
| Context member | Use it when | A replay |
|
|
979
992
|
|---|---|---|
|
|
980
|
-
| `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
|
|
993
|
+
| `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`. |
|
|
981
994
|
| `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 |
|
|
982
995
|
| `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 |
|
|
983
996
|
| `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 |
|
|
984
|
-
| `id`, `now`, `environment`, `tier`, `dryRun`, `metrics` | — | `now` is one instant per run; `metrics` is `{ inserted, updated, skipped }` and `run()` returns it |
|
|
997
|
+
| `id`, `now`, `environment`, `tier`, `dryRun`, `metrics` | — | `now` is one instant per run; `metrics` is `{ inserted, updated, skipped }` and `run()` returns it. A **dry run is the real run, rolled back**: every verb writes inside one transaction from the driver's `transactor()`, so the metrics are what a real run reports — a key another tenant holds is skipped, a later verb sees an earlier one's rows — and nothing is kept. `Driver.transactor()` is required, so a hand-built driver that cannot undo does not compile; one that wraps another delegates (`transactor: () => inner.transactor()`) |
|
|
985
998
|
|
|
986
999
|
Rules worth knowing before the first seed: `upsert` never overwrites `createdAt` (`preserve` names
|
|
987
1000
|
other columns to spare); `upsert` on a tenant-scoped entity needs the tenant column inside `by`,
|
|
@@ -997,7 +1010,7 @@ database from its boot code has decided to, and a library that overruled that wo
|
|
|
997
1010
|
|
|
998
1011
|
`X_ENTITY_DUPLICATE` · `X_INVARIANT_VIOLATED` · `X_TENANCY_UNSCOPED` ·
|
|
999
1012
|
`X_TENANCY_ACTOR_MISMATCH` · `X_TENANCY_ACTOR_ORG_REQUIRED` · `X_TENANCY_CROSS_DENIED` ·
|
|
1000
|
-
`
|
|
1013
|
+
`X_NOT_FOUND` · `X_WRITE_UNFILTERED` · `X_PATCH_EMPTY` ·
|
|
1001
1014
|
`X_PRELOAD_UNKNOWN_RELATION` · `X_N_PLUS_ONE_QUERY` · `X_N_PLUS_ONE_WRITE` ·
|
|
1002
1015
|
`X_RECORD_KEY_MISSING` · `X_ENTITY_SEALED_PREDICATE` · `X_ENTITY_SEALED_IN_VIEW`
|
|
1003
1016
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.0.0",
|
|
4
4
|
"description": "A table + its domain type + invariants the database also enforces",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
"test": "bun test"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@ultimat3/core": "
|
|
37
|
-
"@ultimat3/db": "
|
|
38
|
-
"@ultimat3/schema": "
|
|
39
|
-
"@ultimat3/time": "
|
|
36
|
+
"@ultimat3/core": "24.0.0",
|
|
37
|
+
"@ultimat3/db": "24.0.0",
|
|
38
|
+
"@ultimat3/schema": "24.0.0",
|
|
39
|
+
"@ultimat3/time": "24.0.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
package/src/aggregate.ts
CHANGED
|
@@ -68,23 +68,46 @@ export const notAggregatable = (
|
|
|
68
68
|
: `${entityName}.${fn}('${candidates[0]}') # ${fn} takes one of: ${candidates.join(', ')}`,
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
+
/** One amount is one currency at one scale. Two of either have no common unit. */
|
|
72
|
+
export interface MoneyUnit {
|
|
73
|
+
readonly currency: string;
|
|
74
|
+
readonly scale: number | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
71
77
|
/**
|
|
72
78
|
* Money crossing currencies has no sum, no minimum and no maximum: 100 JPY and 100 EUR are not
|
|
73
79
|
* comparable and adding them answers a number in no currency at all. Both drivers count the
|
|
74
|
-
* distinct
|
|
75
|
-
* silently answering in whichever
|
|
80
|
+
* distinct units of the rows they are about to aggregate and refuse past one, rather than
|
|
81
|
+
* silently answering in whichever happened to come first.
|
|
82
|
+
*
|
|
83
|
+
* Two situations under the one code, told apart because they do not share a repair. Several
|
|
84
|
+
* CURRENCIES are narrowed by a predicate on the currency. One currency at several SCALES is not —
|
|
85
|
+
* `{ minor: 5, currency: 'USD' }` is five cents and the same at `scale: 6` is five millionths of a
|
|
86
|
+
* dollar, a filter on `'USD'` keeps both, and the scale is not a part a predicate can name
|
|
87
|
+
* (`MONEY_PARTS`) — so those rows are folded in the app, at a scale somebody chose.
|
|
76
88
|
*/
|
|
77
89
|
export const mixedCurrency = (
|
|
78
90
|
entityName: string,
|
|
79
91
|
fn: AggregateFn,
|
|
80
92
|
property: string,
|
|
81
|
-
|
|
82
|
-
): EntityError =>
|
|
83
|
-
new
|
|
93
|
+
units: readonly MoneyUnit[],
|
|
94
|
+
): EntityError => {
|
|
95
|
+
const currencies = [...new Set(units.map((unit) => unit.currency))].sort();
|
|
96
|
+
const [only] = currencies;
|
|
97
|
+
if (currencies.length === 1 && only !== undefined) {
|
|
98
|
+
const scales = units.map((unit) => (unit.scale === null ? 'the currency default' : unit.scale));
|
|
99
|
+
return new EntityError({
|
|
100
|
+
code: 'X_AGGREGATE_MIXED_CURRENCY',
|
|
101
|
+
cause: `${entityName}.${fn}('${property}') covers ${scales.length} scales of ${only} (${scales.join(', ')}) — one minor unit is a different amount at each, so they have no common unit`,
|
|
102
|
+
fix: `for await (const rows of ${entityName}.inBatches(1000)) { … } # fold row.${property} at ONE scale in the app: the scale is not a part a predicate can name`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return new EntityError({
|
|
84
106
|
code: 'X_AGGREGATE_MIXED_CURRENCY',
|
|
85
|
-
cause: `${entityName}.${fn}('${property}') covers ${currencies.length} currencies (${
|
|
86
|
-
fix: `${entityName}.andWhere('${property}.currency', 'eq', '${
|
|
107
|
+
cause: `${entityName}.${fn}('${property}') covers ${currencies.length} currencies (${currencies.join(', ')}) — they have no common unit`,
|
|
108
|
+
fix: `${entityName}.andWhere('${property}.currency', 'eq', '${only}').${fn}('${property}') # one currency per call, or countBy('${property}.currency') first`,
|
|
87
109
|
});
|
|
110
|
+
};
|
|
88
111
|
|
|
89
112
|
/** Digits only, optionally signed, optionally with a fraction. What `decimal()` hands back. */
|
|
90
113
|
const DECIMAL_TEXT = /^-?\d+(\.\d+)?$/;
|
|
@@ -180,12 +203,6 @@ export const aggregateColumnOf = <Row>(
|
|
|
180
203
|
return column;
|
|
181
204
|
};
|
|
182
205
|
|
|
183
|
-
/** One amount is one currency at one scale. Two of either have no common unit. */
|
|
184
|
-
export interface MoneyUnit {
|
|
185
|
-
readonly currency: string;
|
|
186
|
-
readonly scale: number | null;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
206
|
export const assertOneUnit = <Row>(
|
|
190
207
|
entity: EntityCore<Row>,
|
|
191
208
|
fn: AggregateFn,
|
|
@@ -195,19 +212,11 @@ export const assertOneUnit = <Row>(
|
|
|
195
212
|
const seen = new Map<string, MoneyUnit>();
|
|
196
213
|
for (const unit of units) seen.set(`${unit.currency}/${unit.scale ?? ''}`, unit);
|
|
197
214
|
if (seen.size > 1) {
|
|
198
|
-
throw mixedCurrency(entity.$name, fn, property, [...seen.values()]
|
|
215
|
+
throw mixedCurrency(entity.$name, fn, property, [...seen.values()]);
|
|
199
216
|
}
|
|
200
217
|
return [...seen.values()][0];
|
|
201
218
|
};
|
|
202
219
|
|
|
203
|
-
/**
|
|
204
|
-
* What the refusal names. The scale rides along because it is half of what makes two amounts
|
|
205
|
-
* incomparable: `{ minor: 5, currency: 'USD' }` is five cents and `{ minor: 5, currency: 'USD',
|
|
206
|
-
* scale: 6 }` is five millionths of a dollar, and adding them is a 10,000x error with no symptom.
|
|
207
|
-
*/
|
|
208
|
-
const unitLabel = (unit: MoneyUnit): string =>
|
|
209
|
-
unit.scale === null ? unit.currency : `${unit.currency}@${unit.scale}`;
|
|
210
|
-
|
|
211
220
|
/**
|
|
212
221
|
* The minor unit an aggregate answers with, narrowed exactly where every other reader of that
|
|
213
222
|
* column narrows it. The column is `bigint` and `MoneyValue.minor` is a `number`, so a total past
|
package/src/bulk-write.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
|
11
11
|
import { EntityError, invariantViolated } from './errors';
|
|
12
12
|
import { columnsOf } from './pg-row';
|
|
13
13
|
import type { RowPatch } from './types';
|
|
14
|
+
import { uniqueConstraints } from './unique-constraints';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Postgres binds at most 65535 parameters in one statement and a multi-row insert spends
|
|
@@ -134,22 +135,12 @@ const crossTenantUpsert = (
|
|
|
134
135
|
});
|
|
135
136
|
|
|
136
137
|
/**
|
|
137
|
-
* Every conflict target Postgres could infer an index for
|
|
138
|
-
*
|
|
139
|
-
* way would send its author to add a SECOND declaration of a constraint they already wrote:
|
|
140
|
-
* the primary key, `unique()`/`indexes: [{ unique: true }]` (both land in `$indexes`), and
|
|
141
|
-
* `invariant(name, c.unique([…]))`, which emits its `create unique index` out of `$invariants`.
|
|
142
|
-
*
|
|
143
|
-
* A partial unique index is excluded from all three: its predicate would have to be repeated in
|
|
144
|
-
* the `on conflict` clause, which this layer does not spell. `bindInvariant` stamps that `where`
|
|
145
|
-
* on a soft-deleting entity, so the same one rule covers both lists.
|
|
138
|
+
* Every conflict target Postgres could infer an index for: the primary key, and every non-partial
|
|
139
|
+
* unique the entity declares by either spelling (`unique-constraints.ts`).
|
|
146
140
|
*/
|
|
147
141
|
const uniqueTargets = <Row>(entity: EntityCore<Row>): readonly (readonly string[])[] => [
|
|
148
142
|
insertColumns(entity, entity.$primaryKey),
|
|
149
|
-
...entity
|
|
150
|
-
...entity.$invariants
|
|
151
|
-
.filter((i) => i.kind === 'unique' && i.where === undefined)
|
|
152
|
-
.map((i) => i.columns),
|
|
143
|
+
...uniqueConstraints(entity).map((unique) => unique.columns),
|
|
153
144
|
];
|
|
154
145
|
|
|
155
146
|
const sameColumns = (left: readonly string[], right: readonly string[]): boolean =>
|
package/src/column.ts
CHANGED
|
@@ -240,8 +240,43 @@ export const makeColumn = <T, Optional extends boolean>(
|
|
|
240
240
|
* the derived name is checked, because that runs once per column at `entity()` rather than on
|
|
241
241
|
* every statement.
|
|
242
242
|
*/
|
|
243
|
+
const isPhysicalName = (name: string): boolean =>
|
|
244
|
+
/^[a-z_][a-z0-9_$]*$/.test(name) && name.length <= 63;
|
|
245
|
+
|
|
246
|
+
/** The nearest legal spelling of a name, so the fix hands back a value and never a placeholder. */
|
|
247
|
+
const physicalSpelling = (name: string): string => {
|
|
248
|
+
const snake = name
|
|
249
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
250
|
+
.toLowerCase()
|
|
251
|
+
.replace(/[^a-z0-9_$]+/g, '_')
|
|
252
|
+
.replace(/^_+|_+$/g, '');
|
|
253
|
+
return (/^[a-z_]/.test(snake) ? snake : `t_${snake}`).slice(0, 63);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/** A name as a TypeScript literal that parses whatever it holds — it is pasted into source. */
|
|
257
|
+
const asSource = (name: string): string =>
|
|
258
|
+
/^[\w-]+$/.test(name) ? `'${name}'` : JSON.stringify(name);
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The TABLE's physical name — `init.table`, or the entity name when none is declared. The same
|
|
262
|
+
* rule as a column's, and its own refusal: it used to go through `assertColumnName`, which called
|
|
263
|
+
* a table "not a physical column name" and handed back `.column('created_at')`, an edit to a
|
|
264
|
+
* column that was never the problem. The repair is at `entity(name, { table })`, and the entity
|
|
265
|
+
* NAME stays what it was — cache tags and policies key on it.
|
|
266
|
+
*/
|
|
267
|
+
export const assertTableName = (entityName: string, table: string): string => {
|
|
268
|
+
if (!isPhysicalName(table)) {
|
|
269
|
+
refuseColumn(
|
|
270
|
+
'table-name',
|
|
271
|
+
`"${table}" is not a physical table name: lower-case letters, digits and underscores, at most 63 of them`,
|
|
272
|
+
`entity(${asSource(entityName)}, { table: '${physicalSpelling(table)}', columns }) # table is the physical name; the entity name is unchanged`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return table;
|
|
276
|
+
};
|
|
277
|
+
|
|
243
278
|
export const assertColumnName = (name: string): string => {
|
|
244
|
-
if (
|
|
279
|
+
if (!isPhysicalName(name)) {
|
|
245
280
|
refuseColumn(
|
|
246
281
|
'column-name',
|
|
247
282
|
`"${name}" is not a physical column name: lower-case letters, digits and underscores, at most 63 of them`,
|
package/src/columns-data.ts
CHANGED
|
@@ -58,6 +58,25 @@ const canonicalDigits = (digits: string): string => {
|
|
|
58
58
|
return negative && whole !== '0' ? `-${whole}` : whole;
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
const INT8_MIN = -(2n ** 63n);
|
|
62
|
+
const INT8_MAX = 2n ** 63n - 1n;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whole digits inside `int8`, in Postgres's spelling. Memory stored a 40-digit value the server
|
|
66
|
+
* answers `22003 — bigint out of range` for, so a row every app test wrote was one production
|
|
67
|
+
* refuses. The digits are never echoed: a column message reaches the log line.
|
|
68
|
+
*/
|
|
69
|
+
const int8 = (digits: string): string => {
|
|
70
|
+
const value = BigInt(digits);
|
|
71
|
+
return value >= INT8_MIN && value <= INT8_MAX
|
|
72
|
+
? canonicalDigits(digits)
|
|
73
|
+
: refuseColumn(
|
|
74
|
+
'bigint',
|
|
75
|
+
'the value is past the int8 range (-2^63 to 2^63 - 1), which Postgres answers 22003 for',
|
|
76
|
+
'declare the column decimal({ precision: 40, scale: 0 }) — a numeric holds whole numbers past int8 — and run x db gen "widen the bigint to numeric"',
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
61
80
|
/**
|
|
62
81
|
* An ACCEPTED decimal in Postgres's spelling: leading zeros stripped, `-0` and `-0.00` unsigned,
|
|
63
82
|
* and a short fraction padded to the column's scale (`numeric(8, 2)` answers `7.50` for `7.5`).
|
|
@@ -86,7 +105,7 @@ const canonicalDecimal = (text: string, scale: number | undefined): string => {
|
|
|
86
105
|
*/
|
|
87
106
|
export const bigint = (): Column<string> =>
|
|
88
107
|
column<string>('bigint', (value) => {
|
|
89
|
-
if (typeof value === 'bigint') return value.toString();
|
|
108
|
+
if (typeof value === 'bigint') return int8(value.toString());
|
|
90
109
|
if (typeof value === 'number') {
|
|
91
110
|
return Number.isSafeInteger(value)
|
|
92
111
|
? String(value)
|
|
@@ -97,7 +116,7 @@ export const bigint = (): Column<string> =>
|
|
|
97
116
|
);
|
|
98
117
|
}
|
|
99
118
|
return typeof value === 'string' && DIGITS.test(value)
|
|
100
|
-
?
|
|
119
|
+
? int8(value)
|
|
101
120
|
: refuseColumn(
|
|
102
121
|
'bigint',
|
|
103
122
|
`expected whole digits, ${got(value)}`,
|
|
@@ -167,7 +186,9 @@ export const decimal = (options: DecimalOptions = {}): Column<string> => {
|
|
|
167
186
|
`Number(value).toFixed(${scale}) at the call site decides the rounding, or widen the column to decimal({ precision: ${(precision ?? fraction) + fraction - scale}, scale: ${fraction} }) and run x db gen "widen the numeric"`,
|
|
168
187
|
);
|
|
169
188
|
}
|
|
170
|
-
|
|
189
|
+
// A whole part of `0` is NO digit: `numeric(2, 2)` holds `0.50`, and counting the zero
|
|
190
|
+
// refused every legal value of a `numeric(p, p)` column.
|
|
191
|
+
const whole = (digits[0] ?? '').replace(/^0+/, '').length;
|
|
171
192
|
if (precision !== undefined && whole > precision - (scale ?? 0)) {
|
|
172
193
|
return refuseColumn(
|
|
173
194
|
'numeric',
|
package/src/columns.ts
CHANGED
|
@@ -109,6 +109,9 @@ const parseInstant = (value: unknown): Date => {
|
|
|
109
109
|
export const timestamp = (): TimestampColumn =>
|
|
110
110
|
makeTimestamp<false>({ ...BARE, kind: 'timestamptz' }, parseInstant, false);
|
|
111
111
|
|
|
112
|
+
/** The column's CHECK (`~ '^https?://'`), before the scheme is lower-cased. */
|
|
113
|
+
const STORED_HTTP = /^https?:\/\//i;
|
|
114
|
+
|
|
112
115
|
/**
|
|
113
116
|
* An absolute http(s) URL, validated on write rather than on render: a bad URL stored once is
|
|
114
117
|
* served to every reader, and `<img src>` fails silently in the browser.
|
|
@@ -126,7 +129,11 @@ export const url = (): Column<string> =>
|
|
|
126
129
|
// The scheme is stored in its canonical LOWER case: the CHECK is `~ '^https?://'`, so
|
|
127
130
|
// `HTTPS://a.b` was stored by memory and refused by Postgres. Only the scheme is
|
|
128
131
|
// rewritten — the rest of the URL is the caller's, byte for byte.
|
|
129
|
-
|
|
132
|
+
// And the STORED text must itself open with it: WHATWG strips leading whitespace and
|
|
133
|
+
// embedded tabs and mends `https:/a.b` before `protocol` is read, while the column keeps
|
|
134
|
+
// the caller's bytes — which the CHECK then refused as a raw 23514.
|
|
135
|
+
const http = parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
136
|
+
if (http && STORED_HTTP.test(value)) {
|
|
130
137
|
return value.replace(/^https?(?=:)/i, (scheme) => scheme.toLowerCase());
|
|
131
138
|
}
|
|
132
139
|
} catch {
|
package/src/containment.ts
CHANGED
|
@@ -28,6 +28,15 @@ const sameElement = (left: unknown, right: unknown): boolean => {
|
|
|
28
28
|
return left === right;
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Two elements of a SQL ARRAY, equal — and a NULL element is equal to nothing, itself included.
|
|
33
|
+
* `array['a', null] @> array[null]` and `array[null] && array[null]` are both FALSE in Postgres:
|
|
34
|
+
* the element comparison is `=`, and `NULL = NULL` is unknown. Arrays only — a `jsonb` `null` is a
|
|
35
|
+
* VALUE and equals itself, which is why `jsonContains` keeps `sameElement`.
|
|
36
|
+
*/
|
|
37
|
+
const sameArrayElement = (left: unknown, right: unknown): boolean =>
|
|
38
|
+
!isNull(left) && !isNull(right) && sameElement(left, right);
|
|
39
|
+
|
|
31
40
|
/**
|
|
32
41
|
* `left @> right` for `jsonb`, to the letter of Postgres' definition — each clause below measured
|
|
33
42
|
* on Postgres 16 rather than read off a summary, because three of them are easy to state wrongly:
|
|
@@ -67,7 +76,7 @@ const contains = (left: unknown, right: unknown, top: boolean): boolean => {
|
|
|
67
76
|
* by every array, which is what Postgres answers.
|
|
68
77
|
*/
|
|
69
78
|
export const arrayContains = (left: readonly unknown[], right: readonly unknown[]): boolean =>
|
|
70
|
-
right.every((item) => left.some((candidate) =>
|
|
79
|
+
right.every((item) => left.some((candidate) => sameArrayElement(candidate, item)));
|
|
71
80
|
|
|
72
81
|
/**
|
|
73
82
|
* `left && right`: they share at least one element. Arrays only — `jsonb` has no `&&` — and the
|
|
@@ -75,7 +84,7 @@ export const arrayContains = (left: readonly unknown[], right: readonly unknown[
|
|
|
75
84
|
* contained by everything.
|
|
76
85
|
*/
|
|
77
86
|
export const arrayOverlaps = (left: readonly unknown[], right: readonly unknown[]): boolean =>
|
|
78
|
-
right.some((item) => left.some((candidate) =>
|
|
87
|
+
right.some((item) => left.some((candidate) => sameArrayElement(candidate, item)));
|
|
79
88
|
|
|
80
89
|
/**
|
|
81
90
|
* The memory half of `col ? key`. The SQL side emits the OPERATOR, schema-qualified —
|
|
@@ -88,6 +97,10 @@ export const arrayOverlaps = (left: readonly unknown[], right: readonly unknown[
|
|
|
88
97
|
* Three shapes, all of them Postgres': a top-level key of an object, a string ELEMENT of an array,
|
|
89
98
|
* and a string value equal to the key. A number never matches — `'[1]'::jsonb ? '1'` is false
|
|
90
99
|
* there, and a `String(item) === key` here would have made it true.
|
|
100
|
+
*
|
|
101
|
+
* The KEY is a string or the test matches no row, in both drivers: `pg-sql.ts` compiles any other
|
|
102
|
+
* operand to no rows rather than binding `String(key)`, which found a key named `"null"` for a
|
|
103
|
+
* `null` operand and `"1"` for the number `1`.
|
|
91
104
|
*/
|
|
92
105
|
export const jsonHasKey = (value: unknown, key: unknown): boolean => {
|
|
93
106
|
if (typeof key !== 'string' || isNull(value)) return false;
|
package/src/database.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// writes a repository class per entity, and nobody can reach a table that is not in the set.
|
|
3
3
|
|
|
4
4
|
import type { EntityCore } from './entity';
|
|
5
|
-
import { memoryRepo } from './memory-repo';
|
|
5
|
+
import { memoryRepo, memoryTransactor } from './memory-repo';
|
|
6
6
|
import type { RelatedTables } from './preload';
|
|
7
7
|
import type { Table } from './query';
|
|
8
8
|
import { tableFor } from './query';
|
|
9
|
-
import type { Repo } from './repo';
|
|
9
|
+
import type { Repo, Transactor } from './repo';
|
|
10
10
|
import { observedRepo } from './row-observer';
|
|
11
11
|
|
|
12
12
|
export type EntitySet = Readonly<Record<string, EntityCore>>;
|
|
@@ -25,6 +25,12 @@ export interface Driver {
|
|
|
25
25
|
* `reset()` eventually would. A harness therefore asks and does not assume: `driver.reset?.()`.
|
|
26
26
|
*/
|
|
27
27
|
reset?(): void;
|
|
28
|
+
/**
|
|
29
|
+
* The transactor that undoes THIS driver's writes. Required: a seed dry run writes for real and
|
|
30
|
+
* rolls back through it, and a driver that could not undo would be found out at run time. A
|
|
31
|
+
* driver that wraps another delegates — `transactor: () => inner.transactor()`.
|
|
32
|
+
*/
|
|
33
|
+
transactor(): Transactor;
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
export interface DatabaseOptions {
|
|
@@ -54,6 +60,7 @@ export const memoryDriver = (): Driver => {
|
|
|
54
60
|
reset() {
|
|
55
61
|
for (const reset of resets) reset();
|
|
56
62
|
},
|
|
63
|
+
transactor: () => memoryTransactor(),
|
|
57
64
|
};
|
|
58
65
|
};
|
|
59
66
|
|