@ultimat3/entity 3.0.0 → 4.1.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 +60 -5
- package/README.md +7 -0
- package/package.json +5 -5
- package/src/bulk-write.ts +9 -7
- package/src/clock.ts +18 -0
- package/src/column.ts +15 -0
- package/src/count-by.ts +2 -1
- package/src/cursor.ts +23 -2
- package/src/describe.ts +5 -0
- package/src/entity.ts +2 -2
- package/src/index.ts +3 -0
- package/src/jit-preload.ts +66 -10
- package/src/memory-match.ts +169 -0
- package/src/pg-driver.ts +2 -2
- package/src/pg-row.ts +4 -4
- package/src/pg-sql.ts +19 -6
- 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 +3 -2
- package/src/types.ts +56 -14
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
|
|
@@ -74,9 +106,15 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
74
106
|
calls `forgetPreloaded(entity.$name)` *before* the statement, so a row a request changed is
|
|
75
107
|
re-read and never served from a page read before it. **Values, not rows**: the index is keyed by
|
|
76
108
|
id and holds ids, so a page early in a long request pins its keys and not its rows, and it dies
|
|
77
|
-
with the request like every other per-ctx store here. **
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
80
118
|
batch. What both share — the scope key, `keyOf`, the one `in` statement — lives in
|
|
81
119
|
`batch-read.ts` so the two can never disagree about when a shared statement is legal.
|
|
82
120
|
**One switch, where the driver is built**: `postgresDriver({ jitPreload: false })` /
|
|
@@ -282,7 +320,12 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
282
320
|
rendered from it.** The resolved records are the source; `ColumnDescription.references` spells
|
|
283
321
|
`"<table>.<column>"` out of one for the migration generator, which is in tier 1 and cannot
|
|
284
322
|
import this package. Never parse that string back — it carries physical names and a traversal
|
|
285
|
-
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
|
|
286
329
|
method, not a field: a thunk may point at an entity two modules of an import cycle have not
|
|
287
330
|
finished evaluating. `relationMap()` memoises the whole-registry derivation against
|
|
288
331
|
`registryGeneration()`, which every registration bumps — a schema module imported late must
|
|
@@ -368,6 +411,16 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
368
411
|
the three callers want three answers and the wrong one is invisible in the result. The soft
|
|
369
412
|
delete inside `removal()` passes `false` too — both its callers read a count through
|
|
370
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.
|
|
371
424
|
- **`touch()` in `query.ts` is the ONE place `onUpdateNow()` columns are stamped**, for
|
|
372
425
|
`update(id, patch)` and `updateWhere(filter, patch)` alike — a second copy is how one of them
|
|
373
426
|
ends up writing a stale `updatedAt`. It returns an empty patch untouched, so whether
|
|
@@ -664,13 +717,15 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
664
717
|
|
|
665
718
|
| File | Job |
|
|
666
719
|
|---|---|
|
|
667
|
-
| `types.ts` | `Column`, `RowOf`, `Insertable`, `IdOf` — the type derivation |
|
|
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 |
|
|
668
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 |
|
|
669
722
|
| `columns-data.ts` | the wide vocabulary an existing schema needs: `json`, `decimal`, `date`, `bigint`, `bytes`, `arrayOf` |
|
|
670
723
|
| `expr.ts` / `invariants.ts` | the `invariants: (c) => …` rule language; bind + `toSql()` DDL |
|
|
671
724
|
| `entity.ts` / `describe.ts` | `entity()`, `$row`; the `EntityDescription` projection |
|
|
672
725
|
| `view.ts` | `$view(keys)` — the row projection an action names as its `output` |
|
|
673
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` |
|
|
674
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 |
|
|
675
730
|
| `cross-tenant.ts` | `crossTenant(reason, fn)` — the capability-gated scope that lifts it |
|
|
676
731
|
| `plan.ts` / `cursor.ts` | the plan both drivers execute; the one keyset cursor codec |
|
package/README.md
CHANGED
|
@@ -381,6 +381,13 @@ database({ orgs, posts }, { driver: postgresDriver() }); // production
|
|
|
381
381
|
| For | tests, `x dev` before the first migration | production |
|
|
382
382
|
| Transaction | `memoryTransactor()` — undo closures | `postgresTransactor()` — real `BEGIN`/`COMMIT` |
|
|
383
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).
|
|
384
391
|
|
|
385
392
|
`database()` called with no driver takes the process default, and `defaultDriver()` is that same
|
|
386
393
|
object — the one seam a test harness needs, `As of 2026-08`:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "A table + its domain type + invariants the database also enforces",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/db": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
37
|
-
"@ultimat3/time": "
|
|
34
|
+
"@ultimat3/core": "4.1.0",
|
|
35
|
+
"@ultimat3/db": "4.1.0",
|
|
36
|
+
"@ultimat3/schema": "4.1.0",
|
|
37
|
+
"@ultimat3/time": "4.1.0"
|
|
38
38
|
}
|
|
39
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/column.ts
CHANGED
|
@@ -11,11 +11,26 @@ import type {
|
|
|
11
11
|
AnyColumn,
|
|
12
12
|
Column,
|
|
13
13
|
ColumnDefault,
|
|
14
|
+
ColumnMap,
|
|
14
15
|
ColumnMeta,
|
|
15
16
|
MoneyColumnNames,
|
|
16
17
|
TimestampColumn,
|
|
17
18
|
} from './types';
|
|
18
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;
|
|
33
|
+
|
|
19
34
|
export const snake = (value: string): string =>
|
|
20
35
|
value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
21
36
|
|
package/src/count-by.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// a `countBy` against Postgres means; a rule added to one driver alone is the drift this file
|
|
5
5
|
// exists to prevent.
|
|
6
6
|
|
|
7
|
+
import { columnFor } from './column';
|
|
7
8
|
import type { EntityCore } from './entity';
|
|
8
9
|
import { EntityError } from './errors';
|
|
9
10
|
import type { AnyColumn, ColumnKind } from './types';
|
|
@@ -86,7 +87,7 @@ export const groupColumnOf = <Row>(
|
|
|
86
87
|
property: string,
|
|
87
88
|
operation: string,
|
|
88
89
|
): AnyColumn => {
|
|
89
|
-
const column = entity.$columns
|
|
90
|
+
const column = columnFor(entity.$columns, property);
|
|
90
91
|
if (column === undefined) {
|
|
91
92
|
throw notGroupable(
|
|
92
93
|
entity,
|
package/src/cursor.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// to still exist, and a row deleted between two pages would silently restart pagination.
|
|
8
8
|
|
|
9
9
|
import { CursorInvalidError, decodeCursor, encodeCursor } from '@ultimat3/core';
|
|
10
|
+
import { columnFor } from './column';
|
|
10
11
|
import type { EntityCore } from './entity';
|
|
11
12
|
import { invariantViolated } from './errors';
|
|
12
13
|
import type { QueryPlan } from './tenancy';
|
|
@@ -28,7 +29,7 @@ const partsOf = (path: string): { readonly property: string; readonly part?: str
|
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
const columnAt = <Row>(entity: EntityCore<Row>, path: string): AnyColumn => {
|
|
31
|
-
const column = entity.$columns
|
|
32
|
+
const column = columnFor(entity.$columns, partsOf(path).property);
|
|
32
33
|
if (column === undefined) {
|
|
33
34
|
throw invariantViolated(entity.$name, 'orderBy', `no column "${path}"`);
|
|
34
35
|
}
|
|
@@ -51,13 +52,33 @@ const kindAt = <Row>(entity: EntityCore<Row>, path: string): ColumnKind => {
|
|
|
51
52
|
`${path} is money: order by ${path}.minor or ${path}.currency`,
|
|
52
53
|
);
|
|
53
54
|
}
|
|
54
|
-
|
|
55
|
+
// `MONEY_PARTS[part]` alone answers a FUNCTION for `orderBy('price.toString')` — not
|
|
56
|
+
// `undefined` — so the refusal below never fired and `assertSeekable` minted a cursor for it.
|
|
57
|
+
const money =
|
|
58
|
+
kind === 'money' && Object.hasOwn(MONEY_PARTS, part) ? MONEY_PARTS[part] : undefined;
|
|
55
59
|
if (money === undefined) {
|
|
56
60
|
throw invariantViolated(entity.$name, 'orderBy', `${path} names no column part`);
|
|
57
61
|
}
|
|
58
62
|
return money;
|
|
59
63
|
};
|
|
60
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The kind a PATH holds, or `undefined` when it names none — the non-throwing half of `kindAt`.
|
|
67
|
+
*
|
|
68
|
+
* The in-memory driver asks this about a predicate column and a sort key, both of which are caller
|
|
69
|
+
* data: an unknown name compares as text there exactly as it always did, rather than turning a
|
|
70
|
+
* filter into a refusal the Postgres driver does not make. It is what lets a comparison be decided
|
|
71
|
+
* by the column's DECLARED kind — which is what Postgres decides by — instead of by the JS type of
|
|
72
|
+
* whichever value is in hand.
|
|
73
|
+
*/
|
|
74
|
+
export const kindOf = <Row>(entity: EntityCore<Row>, path: string): ColumnKind | undefined => {
|
|
75
|
+
const { property, part } = partsOf(path);
|
|
76
|
+
const kind = columnFor(entity.$columns, property)?.$meta.kind;
|
|
77
|
+
if (kind === undefined) return undefined;
|
|
78
|
+
if (part === undefined) return kind === 'money' ? undefined : kind;
|
|
79
|
+
return kind === 'money' && Object.hasOwn(MONEY_PARTS, part) ? MONEY_PARTS[part] : undefined;
|
|
80
|
+
};
|
|
81
|
+
|
|
61
82
|
export const valueAt = (row: unknown, path: string): unknown => {
|
|
62
83
|
const { property, part } = partsOf(path);
|
|
63
84
|
const record = typeof row === 'object' && row !== null ? (row as Record<string, unknown>) : {};
|
package/src/describe.ts
CHANGED
|
@@ -48,6 +48,7 @@ export const describeReferences = (
|
|
|
48
48
|
targetEntity: target.table,
|
|
49
49
|
targetProperty: target.property,
|
|
50
50
|
targetColumn: target.name,
|
|
51
|
+
onDelete: meta.onDelete ?? null,
|
|
51
52
|
},
|
|
52
53
|
];
|
|
53
54
|
});
|
|
@@ -90,6 +91,7 @@ const describeColumn = <Row>(
|
|
|
90
91
|
unique: false,
|
|
91
92
|
hasDefault: false,
|
|
92
93
|
references: null,
|
|
94
|
+
onDelete: null,
|
|
93
95
|
};
|
|
94
96
|
return [
|
|
95
97
|
{
|
|
@@ -142,6 +144,9 @@ const describeColumn = <Row>(
|
|
|
142
144
|
// traversal reads can never disagree about what a `references()` points at.
|
|
143
145
|
references:
|
|
144
146
|
reference === undefined ? null : `${reference.targetEntity}.${reference.targetColumn}`,
|
|
147
|
+
// Off the resolved reference, never off `meta` again: a rule with no key is not a thing, and
|
|
148
|
+
// reading the option twice is two places for the pair to disagree.
|
|
149
|
+
onDelete: reference?.onDelete ?? null,
|
|
145
150
|
},
|
|
146
151
|
];
|
|
147
152
|
};
|
package/src/entity.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// (the typed db handle, migrations, cache tags, the admin UI, the manifest) is projected from
|
|
4
4
|
// this one call.
|
|
5
5
|
|
|
6
|
-
import { systemClock } from '@ultimat3/core';
|
|
7
6
|
import { describeValue, type StandardSchemaV1 } from '@ultimat3/schema';
|
|
7
|
+
import { entityNow } from './clock';
|
|
8
8
|
import { assertColumnName, bindColumn, columnName, moneyColumns } from './column';
|
|
9
9
|
import { newId } from './columns';
|
|
10
10
|
import { describeEntity, describeReferences } from './describe';
|
|
@@ -115,7 +115,7 @@ const defaultValue = (meta: ColumnMeta): unknown => {
|
|
|
115
115
|
const declared = meta.default;
|
|
116
116
|
if (declared === undefined) return undefined;
|
|
117
117
|
if (declared.kind === 'value') return declared.value;
|
|
118
|
-
return declared.by === 'uuid-v7' ? newId() :
|
|
118
|
+
return declared.by === 'uuid-v7' ? newId() : entityNow();
|
|
119
119
|
};
|
|
120
120
|
|
|
121
121
|
export const entity = <const C extends ColumnMap>(
|
package/src/index.ts
CHANGED
|
@@ -101,6 +101,7 @@ export {
|
|
|
101
101
|
export type { EntityRelations, Relation, RelationKind, RelationMap } from './relations';
|
|
102
102
|
export { relationMap, relationNamed, relationsFor, relationsOf } from './relations';
|
|
103
103
|
export type {
|
|
104
|
+
FindByIdOptions,
|
|
104
105
|
FindManyArgs,
|
|
105
106
|
MemoryRepo,
|
|
106
107
|
Page,
|
|
@@ -152,9 +153,11 @@ export type {
|
|
|
152
153
|
OnDelete,
|
|
153
154
|
ReferenceOptions,
|
|
154
155
|
RowOf,
|
|
156
|
+
RowPatch,
|
|
155
157
|
TimestampColumn,
|
|
156
158
|
TypeOf,
|
|
157
159
|
UuidColumn,
|
|
158
160
|
} from './types';
|
|
161
|
+
export { COLUMN_KINDS } from './types';
|
|
159
162
|
// `viewFor` stays internal: a view is reached through the entity, as `posts.$view([...])`.
|
|
160
163
|
export type { EntityView } from './view';
|
package/src/jit-preload.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
//
|
|
6
6
|
// The trigger carries an id, not a row, so what a page leaves behind is an index of its foreign
|
|
7
7
|
// key VALUES rather than a map keyed by row identity: an id is a thing that can be looked up in
|
|
8
|
-
// it, and
|
|
8
|
+
// it, and a page therefore costs its keys rather than its rows. That was true PER PAGE and false
|
|
9
|
+
// across them until `MAX_SIBLING_KEYS` — the store outlives every page and dies with the ctx,
|
|
10
|
+
// which for a job is the whole attempt, so both maps here are bounded and evict the oldest page.
|
|
9
11
|
//
|
|
10
12
|
// The scope guard is a security boundary, not a tuning knob. A preloaded row is served only to a
|
|
11
13
|
// lookup with the same scope key, the same client and no write since — anything else reads the
|
|
@@ -14,7 +16,15 @@
|
|
|
14
16
|
import type { Ctx } from '@ultimat3/core';
|
|
15
17
|
import { tryUseContext } from '@ultimat3/core';
|
|
16
18
|
import type { DbClient } from '@ultimat3/db';
|
|
17
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
type Answer,
|
|
21
|
+
keyOf,
|
|
22
|
+
MAX_IDS_PER_STATEMENT,
|
|
23
|
+
type PointRead,
|
|
24
|
+
readByIds,
|
|
25
|
+
statementChunks,
|
|
26
|
+
} from './batch-read';
|
|
27
|
+
import { columnFor } from './column';
|
|
18
28
|
import type { EntityCore } from './entity';
|
|
19
29
|
|
|
20
30
|
/** The rows one page's worth of foreign keys resolved to, under one scope. */
|
|
@@ -53,10 +63,54 @@ const storeFor = (ctx: Ctx): Store => {
|
|
|
53
63
|
return created;
|
|
54
64
|
};
|
|
55
65
|
|
|
66
|
+
/**
|
|
67
|
+
* How many id keys ONE edge may hold, and how many rows one bucket may keep — a few pages' worth,
|
|
68
|
+
* the way `MAX_IDS_PER_STATEMENT` bounds a statement.
|
|
69
|
+
*
|
|
70
|
+
* `MAX_IDS_PER_STATEMENT` bounded the statement and nothing bounded the STORE: every page merged
|
|
71
|
+
* its keys in and the store died only with the ctx, which for a job is the whole attempt. Measured
|
|
72
|
+
* at 1,000 pages x 1,000 rows with distinct foreign keys, rows dropped after each call and
|
|
73
|
+
* `Bun.gc(true)` either side: **159.3 MB retained**, against 2.7 MB with the tagging off — so a
|
|
74
|
+
* 12M-row `backfill()` retains ~2 GB and OOMs the worker on the DEFAULT configuration, since
|
|
75
|
+
* `jitPreload` defaults to true and `backfill()` names no driver option.
|
|
76
|
+
*
|
|
77
|
+
* Four statements' worth. The keys of one page are filed contiguously, so the survivors are the
|
|
78
|
+
* newest pages' and the arrays every evicted key referenced go with them — which is what makes the
|
|
79
|
+
* bound a bound on bytes and not only on entries. Past it a lookup DECLINES, and declining is the
|
|
80
|
+
* old behaviour everywhere else in this file: the caller reads the statement it always read.
|
|
81
|
+
*/
|
|
82
|
+
export const MAX_SIBLING_KEYS = MAX_IDS_PER_STATEMENT * 4;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Newest wins, oldest goes. A `Map` iterates in insertion order, so its first key is the oldest
|
|
86
|
+
* page's — and the page a sequential `for … of` loop is walking is the newest one, which is the
|
|
87
|
+
* only page this store exists to answer for. Re-filed rather than overwritten, so a key a later
|
|
88
|
+
* page carries again moves to the newest end instead of ageing out under it.
|
|
89
|
+
*/
|
|
90
|
+
const remember = <V>(index: Map<string, V>, key: string, value: V, cap: number): void => {
|
|
91
|
+
index.delete(key);
|
|
92
|
+
index.set(key, value);
|
|
93
|
+
while (index.size > cap) {
|
|
94
|
+
const oldest = index.keys().next();
|
|
95
|
+
if (oldest.done === true) return;
|
|
96
|
+
index.delete(oldest.value);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
56
100
|
/** Both ends of the edge: a key pointing at another column of the same entity is another edge. */
|
|
57
101
|
const siblingKey = (targetEntity: string, targetProperty: string): string =>
|
|
58
102
|
JSON.stringify([targetEntity, targetProperty]);
|
|
59
103
|
|
|
104
|
+
/** TEST SEAM: id keys this request is holding, across every edge. A bound nothing can observe is a
|
|
105
|
+
* bound nothing can pin, and `MAX_SIBLING_KEYS` is the number this answers against. */
|
|
106
|
+
export const siblingKeysHeld = (ctx: Ctx): number => {
|
|
107
|
+
const store = requests.get(ctx);
|
|
108
|
+
if (store === undefined) return 0;
|
|
109
|
+
let held = 0;
|
|
110
|
+
for (const index of store.siblings.values()) held += index.size;
|
|
111
|
+
return held;
|
|
112
|
+
};
|
|
113
|
+
|
|
60
114
|
const writesTo = (store: Store, entity: string): number => store.writes.get(entity) ?? 0;
|
|
61
115
|
|
|
62
116
|
/**
|
|
@@ -80,7 +134,7 @@ export const tagSiblings = <Row>(entity: EntityCore<Row>, rows: readonly Row[]):
|
|
|
80
134
|
for (const reference of references) {
|
|
81
135
|
// The declaring column's own kind: a foreign key mirrors the key it points at, and a value is
|
|
82
136
|
// filed here exactly as `findById` will spell it when it comes looking.
|
|
83
|
-
const kind = entity.$columns
|
|
137
|
+
const kind = columnFor(entity.$columns, reference.property)?.$meta.kind;
|
|
84
138
|
if (kind === undefined) continue;
|
|
85
139
|
const ids: unknown[] = [];
|
|
86
140
|
const keys = new Set<string>();
|
|
@@ -96,7 +150,7 @@ export const tagSiblings = <Row>(entity: EntityCore<Row>, rows: readonly Row[]):
|
|
|
96
150
|
if (ids.length === 0) continue;
|
|
97
151
|
const at = siblingKey(reference.targetEntity, reference.targetProperty);
|
|
98
152
|
const index = store.siblings.get(at) ?? new Map<string, readonly unknown[]>();
|
|
99
|
-
for (const key of keys) index
|
|
153
|
+
for (const key of keys) remember(index, key, ids, MAX_SIBLING_KEYS);
|
|
100
154
|
store.siblings.set(at, index);
|
|
101
155
|
}
|
|
102
156
|
};
|
|
@@ -165,12 +219,14 @@ const preload = <Row>(read: PointRead<Row>, bucket: Bucket, ids: readonly unknow
|
|
|
165
219
|
if (bucket.rows.has(at)) continue;
|
|
166
220
|
// The executor runs synchronously, so `settle` is assigned before the promise is stored.
|
|
167
221
|
let settle!: (answer: Answer) => void;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
222
|
+
const answer = new Promise<Answer>((resolve) => {
|
|
223
|
+
settle = resolve;
|
|
224
|
+
});
|
|
225
|
+
// Bounded for the reason the sibling index is: a bucket holds ROWS, so a long request that
|
|
226
|
+
// preloads page after page retains every row it ever resolved. An evicted entry still settles
|
|
227
|
+
// — `fill` holds its own settler — and a lookup that no longer finds one reads its own
|
|
228
|
+
// statement, which is what it would have read had no page indexed the id at all.
|
|
229
|
+
remember(bucket.rows, at, answer, MAX_SIBLING_KEYS);
|
|
174
230
|
settlers.set(at, settle);
|
|
175
231
|
wanted.push(id);
|
|
176
232
|
}
|