Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,37 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major

### Added

- **`findById` batches itself — one microtask of point lookups is one `where "id" in (…)`.** A page that resolves an author per row sent one `select … where "id" = $1` per row. Inside a request, `postgresRepo()` now collects the lookups issued in the same microtask and sends one statement for all of them:

```ts
// One statement, not one per post. findById's signature and its meaning are unchanged.
const authors = await Promise.all(posts.map((post) => users.findById(post.authorId, { orgId })));
```

No `dataloader()`, no `batch()`, nothing to opt into: the capability lives inside the method that already exists, which is the only place it can reach code already written. The batch is keyed by context identity — a `WeakMap`, so it dies with the request, the shape `@ultimat3/query`'s request memo has one tier up — and by a scope key covering every input to the statement except the id, so another tenant, another soft-delete visibility, another projection, another entity or another client is a different statement and never joins one. What goes out is the statement each single lookup would have been served by, `in` instead of `=`: the tenant predicate and the `deleted_at is null` clause are inside it, so an id whose row is missing, soft-deleted or another tenant's still reads as `null`. Past 500 ids it becomes several whole statements rather than one Postgres refuses for its bind count, and it declines outright — sending exactly what it always sent — with no request in scope (a job, a script), on a composite primary key, or on a scope it cannot compare. The window is one microtask and closes before the statement is sent, so a sequential `for … of` loop shares nothing through this path — what batches one is the sibling preload below. Proved against a real Postgres in `pg-driver.live.test.ts`: five lookups, one statement, the same five answers.
- **A `for … of` loop over a page is two statements — the sibling-aware preload.** A microtask window cannot see a sequential loop: its `await` closes the window before the next lookup exists. `findMany` now leaves its page's foreign key **values** behind for the request, so the first `findById` for any one of them resolves that key for **every** row of the page in one statement and the rest of the loop is served from memory. Enabled by default via `db.jitPreload: true` in `app.config.ts`; set to `false` to disable:

```ts
const page = await posts.findMany({ orgId });
for (const post of page.rows) {
// Two statements for the whole loop: the page, then one `select … where "id" in (…)` over
// every author on it. Every lookup after the first is memory.
const author = await users.findById(post.authorId, { orgId });
}
```

Nothing new to write — the relation is the `references()` already declared, and `findById` keeps its signature, so the fix reaches loops that are already written. The scope guard is a security boundary, not a tuning knob: a preloaded row is served only to a lookup with the same scope key the batch above uses (tenant predicate, soft-delete visibility, projection, entity), the same client, and no write to that entity since — anything else reads the statement it always read. The preload statement is that same statement widened to the page's ids, so its tenant predicate and `deleted_at is null` clause are in the SQL Postgres runs; a page's ids can therefore never resolve rows outside the reader's own scope. The index holds values and not rows, keyed by context identity, so it pins nothing and dies with the request; a page of a hundred rows by one author is still one bind; a nullable key that resolved to nothing is not a row to go looking for; past 500 ids it becomes whole statements; and with no request in scope (a job, a script) a page leaves nothing behind at all.
- **`preload(name)` — the join a `for … of` loop would have earned, without writing the loop.** The two paths above catch a lookup pattern already written; `preload()` is the same join, named on the chain instead of triggered by a loop:

```ts
export const db = database({ orgs, posts, members });

// Two statements: the page, then one `select … where "id" in (…)` over its authors.
const page = await db.posts.where({ orgId }).preload('author').page();
page.rows[0].author; // the member row, or null — always present
```

One vocabulary, `preload('<relation>')` — no `include`, `join` or `with`, and nothing new to declare: the name is the `references()` already written, resolved by `relationNamed()` when `preload()` is called, so an unknown one is `X_PRELOAD_UNKNOWN_RELATION` on the chain and not a page later. A `belongsTo` attaches the row or `null`, a `hasMany` an array — always present, so "no author" and "nobody preloaded the author" never read the same. Several `preload()` calls resolve concurrently, never one after the other, and naming one relation twice is one statement; keys are chunked at `MAX_IDS_PER_STATEMENT` (500) exactly as a coalesced point read already is, so past 500 keys a relation costs several statements rather than one Postgres refuses for its bind count, and a relation with more rows than one page holds costs another keyset page rather than a silent truncation. The page's own tenant predicate carries onto the related read only when the other entity's tenant column has the same name — a value that scopes one entity is a guess on another, and a guess here is a cross-tenant read — so a differently-named tenant column carries nothing, and carrying nothing is not silent: the related read builds its own plan and `assertScoped` refuses it there as `X_TENANCY_UNSCOPED`. The related read is `findMany`, so `deleted_at is null` applies exactly as it does anywhere else. Attached to a copy of each row — `{ ...row }`, because the in-memory driver hands back the row it stores — after `select()`, which is widened internally with the relation's local key so a projection can drop neither the key a preload reads nor the relation it attaches; `plan().select` reports the widened list. `page()`, `all()` and `one()` preload; `count()` and `plan()` do not, since a count reads no rows. A table reaches only the entities its own `database()` call named, through the driver that call was given, so a preload against memory means what a preload against Postgres means — a relation whose other end is outside the set is `X_INVARIANT_VIOLATED` naming the `database({ … })` call that fixes it, and `tableFor(entity, repo)` built by hand reaches no other table. `preload('author')` returns `ReadBuilder<Row & { readonly author: unknown }>` — `unknown`, not a generated type, because the name is a string resolved at runtime and the row on the other side is parsed by its own entity.
- **`relationMap()` — the foreign keys an entity already declared, readable at query time.** `ColumnMeta.references` was resolved in exactly one place, `describe.ts`, to spell a DDL constraint; at query time nothing could answer "what is a post's author". `@ultimat3/entity` now derives a named map from the same thunks — `belongsTo` from an entity's own foreign keys, `hasMany` from the inbound ones:

```ts
Expand Down
15 changes: 13 additions & 2 deletions docs/architecture/06-data-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ Generated per entity. The **only** place SQL lives.

| Method | Notes |
|---|---|
| `findById(id)` | tenant filter applied automatically; returns `null`, never throws on miss |
| `findMany(args)` | cursor-paginated only (below) |
| `findById(id)` | tenant filter applied automatically; returns `null`, never throws on miss. Inside a request, the calls issued in one microtask are **one** `where "id" in (…)` ([`coalesce.ts`](../../packages/entity/src/coalesce.ts)) — same scope, same soft-delete filter, one round trip. An id that is a foreign key on a page this request read resolves that key for the whole page instead ([`jit-preload.ts`](../../packages/entity/src/jit-preload.ts)), which is what batches a sequential `for … of` loop |
| `findMany(args)` | cursor-paginated only (below); leaves its page's foreign key values behind for the preload above |
| `preload(name)` | not on `Repo<T>` — a `ReadBuilder` chain method, `db.<table>.preload('<relation>')`, resolved by `page()`/`all()`/`one()`. One more `where <key> in (…)` per named relation, over the page `findMany` already read ([`preload.ts`](../../packages/entity/src/preload.ts)); several relations resolve concurrently, and naming one twice is one statement |
| `insert(values)` / `update(id, patch)` | invariants run before the statement |
| `delete(id)` | soft-deletes when the entity declares `deletedAt`, hard-deletes when it does not |
| `deleteWhere(filter)` | delete by equality filter; resolves with the **number of rows removed** |
Expand All @@ -59,6 +60,16 @@ Generated per entity. The **only** place SQL lives.
| `Transactor.run(fn)` | joins the ambient transaction if one exists, opens one otherwise |
| custom | added in the feature's `repo.ts`, returning schema-parsed rows |

One family, three members:

| Path | Trigger | Cost |
|---|---|---|
| Microtask coalescing ([`coalesce.ts`](../../packages/entity/src/coalesce.ts)) | several `findById` calls land in one microtask | one `where id in (…)` — nothing to opt into |
| Sibling JIT preload ([`jit-preload.ts`](../../packages/entity/src/jit-preload.ts)) | the first `findById` whose id is a foreign key on a page this request read | one `where <key> in (…)` for the whole page; every later lookup in the loop is memory — nothing to opt into |
| Eager preload ([`preload.ts`](../../packages/entity/src/preload.ts)) | `.preload('<relation>')` named on the chain | one `where <key> in (…)` per relation, resolved before the caller sees a row |

Reach for `preload()` when the relation is part of what the page *is* — a list rendered with its authors, rows handed to something that will not call back into the repo, or a read a reviewer should see stated rather than inferred from a loop. The other two ask for nothing: a same-microtask fan-out and a sequential `for … of` loop already get them for free. All three read their keys through one file ([`batch-read.ts`](../../packages/entity/src/batch-read.ts)) — one spelling of a key, one 500-id bind cap — so a bound and a key's identity cannot disagree between them. What each may widen is its own: the two implicit paths share a scope key and refuse to widen across a tenant, a soft-delete visibility, a projection or an entity, because they answer a statement the caller already sent; `preload()` sends its own, so it carries the chain's tenant predicate onto it and refuses when it cannot.

The two filtered writes are not conveniences. `delete(id)` and `update(id, patch)` both need a single-column primary key — `singleKeyOf` throws `X_INVARIANT_VIOLATED` on a composite one — so on a join table (`likes`, `blocks`, `participants`) they are the only write paths there are. Without them a composite-key row is create-only: `likes` could be liked and never unliked, and `participants.lastReadAt` could never be marked read. Four properties make them safe to be the only filtered writes:

| Property | Mechanism |
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ describe('defineConfig', () => {
poolSize: 10,
ssl: false,
schema: 'public',
jitPreload: true,
});
expect(config.jobs.queues).toEqual(['myapp-default']);
expect(config.ai.mcp).toEqual({ expose: true, path: '/mcp' });
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ export interface DatabaseConfig {
readonly poolSize: number;
readonly ssl: boolean;
readonly schema: string;
/**
* Preload foreign keys resolved by a page into a request-scoped cache, so the first
* `findById` for any one of them resolves that key for the whole page in one statement.
* A sequential `for … of` loop awaits between iterations, so batching is not possible
* without this; enabling it collapses N+1 loops to one statement after the page. Default:
* true.
*/
readonly jitPreload: boolean;
}

export interface CacheConfig {
Expand Down Expand Up @@ -182,6 +190,7 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
poolSize: 10,
ssl: false,
schema: 'public',
jitPreload: true,
},
cache: { driver: 'memory', urlEnv: undefined, defaultTtlMs: 60_000, tiers: ['memo', 'lru'] },
jobs: {
Expand Down
66 changes: 66 additions & 0 deletions packages/entity/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,68 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
that is how a `unique()` column shipped a migration failing on `42P07` and money's currency
shipped as `char(1)`. A new operator, column kind or write path is not done until it round-trips
there.
- **A point lookup batches itself, and the batch is never wider than the statement it replaces.**
`findById` called several times in one microtask of one request is one `select … where "id" in
(…)` — `coalesce.ts`, keyed by ctx identity (a `WeakMap`, so the batch dies with the request, the
shape `@ultimat3/query`'s request memo has one tier up) and by a scope key covering **every**
input to the statement except the id. Two tenants, two soft-delete visibilities, two projections,
two entities or two clients therefore never share one: a coalesced statement has to be one each
of the singles would have been served by, or a caller is answered with rows their own statement
could never have returned. It declines rather than guesses — no request in scope, a composite
key, a predicate value it cannot render — and declining is just the statement `findById` always
sent, which is why `findById` keeps its signature and there is no `batch()` to opt into. The
window closes before the statement goes out, so a lookup arriving mid-flight opens the next batch
instead of joining ids already on the wire, and past `MAX_IDS_PER_STATEMENT` a batch becomes
several whole statements rather than one Postgres refuses for its bind count. A sequential
`for … of` loop shares no microtask — its `await` ends the window — which is what the sibling
preload below is for.
- **A page batches the loop it causes, and a preloaded row is only ever served to the statement
that read it.** `findMany` leaves its page's foreign key *values* behind (`jit-preload.ts`,
`tagSiblings`), so the first `findById` for any one of them resolves that key for every row of
the page in one `in` statement and the rest of a `for … of` loop is memory. Five rules, none
optional. **The scope guard is a security boundary**: a preloaded row is served only under the
*same* `scopeKey` the coalescer uses — same tenant predicate, same soft-delete visibility, same
projection, same entity — and the preload statement is that scope widened to the page's ids, so
a page read under one tenant can never resolve another tenant's rows, whichever tenant asks.
**Same client, or nothing**: a bucket filled through the ambient pool is not read through a
pinned one, which is also what stops a row read inside a transaction being served after it —
`db()` hands back a different client once the transaction is over, rolled back or not.
**A write drops it**: `postgresRepo`'s `writing()` is the one place every write goes out, and it
calls `forgetPreloaded(entity.$name)` *before* the statement, so a row a request changed is
re-read and never served from a page read before it. **Values, not rows**: the index is keyed by
id and holds ids, so a page early in a long request pins its keys and not its rows, and it dies
with the request like every other per-ctx store here. **Declining is the old behaviour**: no
request in scope, an id no page indexed, a key that resolved to nothing — the caller reads the
statement it always read. `MAX_IDS_PER_STATEMENT` bounds the preload exactly as it bounds a
batch. What both share — the scope key, `keyOf`, the one `in` statement — lives in
`batch-read.ts` so the two can never disagree about when a shared statement is legal.
- **`preload(name)` shares `batch-read.ts` with the coalescer and the JIT preload above, but
keeps no request-scoped cache of its own.** `keyOf`, `MAX_IDS_PER_STATEMENT` and
`statementChunks` come from the same file, so a bind-count bound and a key's identity can
never disagree across the three — but `preload()` reads its scope straight off the chain's
own `where` and issues its statement every call; nothing here declines to an old statement
the way the coalescer or the JIT preload can, because there is no old statement to decline
to — a chain that calls `preload('author')` always gets the extra statement. **Tenancy is
carried, never inferred, and that is a security boundary, not a convenience**:
`tenantScope()` carries the page's own tenant predicate onto the related read only when the
other entity's tenant column has the *same name* as this one's — a value that scopes one
entity is a guess on another, and serving a guessed scope is a cross-tenant read. A
differently-named column carries nothing, on purpose, so the related read builds an
unscoped plan of its own and `assertScoped` refuses it as `X_TENANCY_UNSCOPED` rather than
let it pass. **Reach is the same `database()` set the two bullets above already answer
to**: `RelatedTables` is the resolver `database()` hands every table it builds, an
entity-name → `{ entity, repo }` map closed over the same call, so `preload('author')`
resolves `author` only when that call named the entity the relation points at — outside it
is `X_INVARIANT_VIOLATED`, never a reach around the handle. `tableFor(entity, repo)` built
by hand takes no `related` resolver, so the identical call fails the identical way with
`related` itself `undefined`. **A projection cannot drop what a preload needs**:
`select()` widens its own field list with each preloaded relation's local key, so
`plan().select` — the projection that actually runs — always carries it, though the row
type the caller sees still names only what they picked. **Attachment copies, never
mutates**: a preloaded relation is written onto `{ ...row }`, because the in-memory driver
hands back the row it stores and attaching directly would leak the relation into the table
itself. **Preloading terminals only**: `page()`, `all()` and `one()` resolve every named
relation; `count()` and `plan()` do not, since neither reads a row to attach one to.
- **Cursor pagination only.** OFFSET is wrong under concurrent writes: an insert before the
offset shifts every later page, so a client silently skips and repeats rows. No `offset` on
`FindManyArgs` or the builder; the primary key is always the last sort key, so the order is
Expand Down Expand Up @@ -155,6 +217,10 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
| `repo.ts` / `tenancy.ts` | `Repo<T>` + `memoryDriver`'s repo, tx rollback; `QueryPlan` + `assertScoped()` |
| `plan.ts` / `cursor.ts` | the plan both drivers execute; the one keyset cursor codec |
| `pg-driver.ts` | `postgresDriver()`, `postgresRepo()`, `postgresTransactor()` |
| `coalesce.ts` | one microtask of `findById` calls → one `where id in (…)`, per request |
| `batch-read.ts` | what a shared point read is made of — the scope key, `keyOf`, the one `in` statement |
| `jit-preload.ts` | a page's foreign key values → one `in` statement for the whole `for … of` loop |
| `preload.ts` | the relation `preload()` names → one related-rows statement → attached to the page |
| `pg-sql.ts` / `pg-row.ts` | plan → parameterised SQL; physical row ⇄ entity row (money is two columns) |
| `registry.ts` | duplicate detection, `describeEntities()` for the manifest, `references()` per entry |
| `relations.ts` | `relationMap()`/`relationsFor()`/`relationNamed()` — the FKs as a named `belongsTo`/`hasMany` map |
Expand Down
Loading