@ultimat3/entity 1.2.0 → 2.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 +614 -0
- package/README.md +391 -11
- package/package.json +5 -4
- package/src/batch-read.ts +134 -0
- package/src/batch.ts +125 -0
- package/src/bulk-write.ts +285 -0
- package/src/coalesce.ts +175 -0
- package/src/column.ts +24 -0
- package/src/columns.ts +183 -35
- package/src/count-by.ts +148 -0
- package/src/cross-tenant.ts +76 -0
- package/src/cursor.ts +17 -3
- package/src/database.ts +35 -2
- package/src/describe.ts +82 -37
- package/src/entity.ts +41 -12
- package/src/errors.ts +346 -5
- package/src/expr.ts +65 -15
- package/src/index.ts +49 -4
- package/src/invariants.ts +56 -14
- package/src/jit-preload.ts +216 -0
- package/src/n-plus-one.ts +122 -0
- package/src/pg-driver.ts +281 -33
- package/src/pg-row.ts +32 -7
- package/src/pg-sql.ts +117 -8
- package/src/plan.ts +130 -20
- package/src/preload.ts +184 -0
- package/src/query.ts +231 -27
- package/src/registry.ts +63 -5
- package/src/relations.ts +212 -0
- package/src/repo.ts +226 -12
- package/src/tenancy.ts +194 -12
- package/src/type-pins.ts +311 -0
- package/src/types.ts +64 -12
- package/src/view.ts +8 -2
package/README.md
CHANGED
|
@@ -22,10 +22,10 @@ export const posts = entity('posts', {
|
|
|
22
22
|
createdAt: timestamp().defaultNow(),
|
|
23
23
|
updatedAt: timestamp().defaultNow().onUpdateNow(),
|
|
24
24
|
},
|
|
25
|
-
invariants: [
|
|
26
|
-
invariant('post_title_present',
|
|
27
|
-
invariant('post_slug_unique_per_org',
|
|
28
|
-
invariant('post_like_count_non_negative',
|
|
25
|
+
invariants: (c) => [
|
|
26
|
+
invariant('post_title_present', c.title.trimmed().minLength(1)),
|
|
27
|
+
invariant('post_slug_unique_per_org', c.unique(['orgId', 'slug'])),
|
|
28
|
+
invariant('post_like_count_non_negative', c.likeCount.atLeast(0)),
|
|
29
29
|
],
|
|
30
30
|
indexes: [{ on: ['orgId', 'createdAt'], order: 'desc', where: (c) => c.status.eq('published') }],
|
|
31
31
|
});
|
|
@@ -59,9 +59,9 @@ still imports one package: `import { entity, t } from '@ultimat3/entity'`.
|
|
|
59
59
|
|
|
60
60
|
| Builder | Emits | Why it is the only way |
|
|
61
61
|
|---|---|---|
|
|
62
|
-
| `uuid()` | `uuid`; `.primaryKey()` defaults to v7 | time-ordered keys keep the pk index append-friendly |
|
|
62
|
+
| `uuid()`, `uuid<PostId>()` | `uuid`; `.primaryKey()` defaults to v7 | time-ordered keys keep the pk index append-friendly; the optional brand is declared once and survives to every signature |
|
|
63
63
|
| `timestamp()` | `timestamptz` | UTC storage is not a per-table decision; there is no naive variant |
|
|
64
|
-
| `money()` | `<name>_minor bigint` + `<name>_currency char(3)` | never a float, never one implied currency |
|
|
64
|
+
| `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`; a stored minor unit past ±2^53 is refused 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` |
|
|
65
65
|
| `enumerated(v)` | `text` + CHECK | a variant is a one-line migration, not `ALTER TYPE` |
|
|
66
66
|
| `tz(zones)`, `locale(tags)` | `text` + CHECK, `Intl`-validated at declaration | an offset is not a time zone |
|
|
67
67
|
| `text({ max })`, `integer()`, `boolean()`, `url()` | `text`/`integer`/`boolean` + CHECK | format is enforced by the database too |
|
|
@@ -70,6 +70,23 @@ Chain: `.primaryKey()` · `.nullable()` · `.unique()` · `.default(v)` · `.def
|
|
|
70
70
|
`.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()`. Physical names are
|
|
71
71
|
derived from the property key (`orgId` → `org_id`); a name is written once, or never.
|
|
72
72
|
|
|
73
|
+
## Branded ids
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
export const posts = entity('posts', {
|
|
77
|
+
columns: { id: uuid<PostId>().primaryKey(), authorId: uuid<UserId>().references(() => users.id) },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const post = await db.posts.findById(postId); // PostId — a UserId here is a compile error
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The brand is declared once, on the column, and carried by the whole chain: `RowOf`, `Insertable`,
|
|
84
|
+
`Repo.findById/update/delete` and `Table.update/delete`, whose id parameters are `IdOf<Row>` —
|
|
85
|
+
the type the entity's own `id` column declared. `IdOf` collapses to `string` for a row that
|
|
86
|
+
declared no brand and for a composite key, so an unbranded entity reads exactly as it always did.
|
|
87
|
+
Nothing is checked at runtime: a brand has no witness, `$parse` still validates the uuid, and
|
|
88
|
+
`type-pins.ts` is where the claim is enforced.
|
|
89
|
+
|
|
73
90
|
## Invariants run twice
|
|
74
91
|
|
|
75
92
|
One declaration, two enforcement points: the app checks it on every write, and the migration
|
|
@@ -101,6 +118,206 @@ these filters, this sort order. A tampered cursor, or one taken from another lis
|
|
|
101
118
|
`X_CURSOR_INVALID` rather than a silent page one. The page size is deliberately outside the scope:
|
|
102
119
|
asking for a bigger next page is the same query.
|
|
103
120
|
|
|
121
|
+
**A page is bounded whether or not the caller bounded it.** `DEFAULT_PAGE_SIZE` (50) covers the read
|
|
122
|
+
nobody sized; `MAX_PAGE_SIZE` (10,000) covers the one they did — `limit(input.pageSize)` on a number
|
|
123
|
+
that arrived over the wire is the same production incident with an argument in front of it. A page
|
|
124
|
+
size that is not a whole number of rows in `1..MAX_PAGE_SIZE` is `X_INVARIANT_VIOLATED` on the chain
|
|
125
|
+
and again inside the plan both drivers build, so `findMany({ limit })` straight at the repository
|
|
126
|
+
cannot route around it. `inBatches(size)` is the call that means "every row" — one page per
|
|
127
|
+
statement, never a table in memory.
|
|
128
|
+
|
|
129
|
+
`DEFAULT_PAGE_SIZE`, `MAX_PAGE_SIZE` and `MAX_ASSERTED_ROWS` are exported, beside
|
|
130
|
+
`N_PLUS_ONE_THRESHOLD` and for the same reason: an action validating its own `pageSize` input
|
|
131
|
+
against a hardcoded `10_000` is a second declaration of one number, and the second one goes stale.
|
|
132
|
+
|
|
133
|
+
## Iterating every row
|
|
134
|
+
|
|
135
|
+
`As of 2026-08`. A page is bounded on purpose, so reading a whole table is a loop — and the loop is
|
|
136
|
+
the terminal, not something the caller writes around `page()`:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
// One statement per batch, one page of rows in memory at a time.
|
|
140
|
+
for await (const batch of db.posts.where({ orgId }).preload('author').inBatches(500)) {
|
|
141
|
+
await search.index(batch);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Stopping early is cheap: the position survives, so the next run resumes where this one stopped.
|
|
145
|
+
await using batches = db.posts.where({ orgId }).after(checkpoint).inBatches(500);
|
|
146
|
+
for await (const batch of batches) {
|
|
147
|
+
await search.index(batch);
|
|
148
|
+
if (ctx.clock.now() > deadline) break;
|
|
149
|
+
}
|
|
150
|
+
await db.checkpoints.update(id, { cursor: batches.cursor });
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Every batch is the page `page()` would have returned at that position — same filters, same tenancy,
|
|
154
|
+
same soft-delete visibility, same `select()`, same `preload()` — so there is no second read path to
|
|
155
|
+
learn or to drift.
|
|
156
|
+
|
|
157
|
+
| | |
|
|
158
|
+
|---|---|
|
|
159
|
+
| Statements | one per batch, each asking for one row past it, exactly as `page()` does. An empty batch is never yielded |
|
|
160
|
+
| Position | keyset, never OFFSET: a row written mid-iteration cannot make the loop skip or repeat one. `after(cursor)` starts it, `.cursor` is where it stopped, `null` once exhausted |
|
|
161
|
+
| Closing | `break`, `return` and a throw all stop the next statement; `await using` is the same guarantee for a handle kept in a variable, and `close()` is idempotent. One handle is one iteration — a second `for await` continues it rather than restarting the table |
|
|
162
|
+
| Refusals | on the chain, not one batch later: a size that is not a whole number of rows between 1 and `MAX_PAGE_SIZE` (10,000), a `limit()` on the same chain (one number, two meanings), and an ordering no cursor can carry — a nullable sort column, which a result that fits in one batch would otherwise hide until the table grew |
|
|
163
|
+
| Tenancy | the plan's, as everywhere else: an unscoped chain is `X_TENANCY_UNSCOPED` on its first batch |
|
|
164
|
+
|
|
165
|
+
## Counting by a column
|
|
166
|
+
|
|
167
|
+
`As of 2026-08`. `count()` answers one number, so a screen or a backfill that needs one per row
|
|
168
|
+
asks N times. `countBy(column)` is that whole loop as one statement, keyed by the value:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
// One statement for every post in `ids`, not one `select count(*)` each.
|
|
172
|
+
const counts = await db.likes.where({ orgId }).andWhere('postId', 'in', ids).countBy('postId');
|
|
173
|
+
for (const id of ids) await db.posts.update(id, { likeCount: counts.get(id) ?? 0 });
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`ReadonlyMap<Row[K], number>`, keyed by the column named — the chain knows the row, so
|
|
177
|
+
`counts.get(postId)` is a `number | undefined` and the `undefined` is load-bearing.
|
|
178
|
+
|
|
179
|
+
| | |
|
|
180
|
+
|---|---|
|
|
181
|
+
| Counts | the whole predicate, exactly as `count()` does: the chain's filters, its tenancy and its soft-delete visibility. `limit()` and `after()` bound the page, never the count |
|
|
182
|
+
| A value nothing matched | absent, never `0` — that is what `group by` returns, and it is what tells "none" apart from "never asked". The default is the caller's `?? 0` |
|
|
183
|
+
| NULL | one group, keyed `null`, in both drivers. `0`, `''` and `false` stay the values they are |
|
|
184
|
+
| Order | biggest group first, ties by the value (numbers and bigints numerically, everything else by its text), `null` last — applied after the rows are in, since a hash aggregate and a `Map` filled row by row have no order to inherit |
|
|
185
|
+
| Groupable columns | `uuid`, `text`, `char`, `boolean`, `integer`, `bigint`. A timestamp, a `jsonb` or `money` is `X_INVARIANT_VIOLATED` naming one of this entity's columns that is: a `Map` compares a non-primitive key by identity, so such a map could only ever answer `undefined` |
|
|
186
|
+
| More than 1000 groups | `X_INVARIANT_VIOLATED`, never a truncated map — the statement asks for one group past the bound, exactly as a page reads one row past its limit. The `fix` spells the `andWhere('<column>', 'in', <values>)` that bounds it |
|
|
187
|
+
| Statement | `select "post_id" as group_value, count(*) as group_count … group by "post_id"`. Both names are fixed aliases, so an entity may still declare a column called `count`, and the grouped value is re-parsed by the column that declared it |
|
|
188
|
+
|
|
189
|
+
## Writing by filter
|
|
190
|
+
|
|
191
|
+
`deleteWhere`/`updateWhere` are the bulk forms of `delete`/`update` — the same fix `insertAll` is
|
|
192
|
+
for a per-row insert loop, applied to the two write shapes a composite-key entity cannot address
|
|
193
|
+
one row at a time: a `for … of` deleting or patching one row per iteration is one statement here,
|
|
194
|
+
not `n`.
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
db.posts.delete(id); // by a single primary key
|
|
198
|
+
db.posts.update(id, { title }); // ditto
|
|
199
|
+
|
|
200
|
+
db.likes.deleteWhere({ postId, userId }); // -> 1 · the only way to unlike
|
|
201
|
+
db.likes.deleteWhere({ postId }); // -> n · every like on that post
|
|
202
|
+
db.participants.updateWhere({ conversationId, userId }, { lastReadAt }); // -> 1 · mark read
|
|
203
|
+
|
|
204
|
+
db.likes.deleteWhere({}); // X_WRITE_UNFILTERED, never every row
|
|
205
|
+
db.participants.updateWhere({ conversationId }, {}); // X_PATCH_EMPTY, never a silent no-op
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`delete(id)` and `update(id, patch)` both need a single-column primary key. A composite one —
|
|
209
|
+
`likes`, `blocks`, `participants`, any join table — has no single id, so the filtered pair is the
|
|
210
|
+
only write path there: without them such an entity is **create-only**, and a row could be written
|
|
211
|
+
and never unwritten.
|
|
212
|
+
|
|
213
|
+
| | |
|
|
214
|
+
|---|---|
|
|
215
|
+
| Returns | the **number of rows affected**, so "nothing matched" is distinguishable from "it worked" |
|
|
216
|
+
| Empty filter | `X_WRITE_UNFILTERED`. An `undefined` value is dropped before the count, so a forgotten variable is the error and not the whole table |
|
|
217
|
+
| Empty patch | `X_PATCH_EMPTY`. Counting rows for a statement that set nothing is the same silent no-op, one argument along |
|
|
218
|
+
| 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 |
|
|
219
|
+
| 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 |
|
|
220
|
+
| `onUpdateNow()` | stamped by `touch()`, the same helper `update(id, patch)` uses — one place, so the two can never disagree about `updatedAt` |
|
|
221
|
+
| 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 |
|
|
222
|
+
|
|
223
|
+
## Writing many rows
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
await db.tags.insertAll(names.map((name) => ({ orgId, name }))); // one statement, n rows
|
|
227
|
+
|
|
228
|
+
await db.likes.upsertAll(rows, { // insert, or leave what is there
|
|
229
|
+
onConflict: ['orgId', 'postId', 'memberId'],
|
|
230
|
+
onMatch: 'nothing',
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await db.counters.upsertAll(rows, { onConflict: ['orgId', 'day'] }); // insert, or overwrite
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`insertAll` is `insert` in bulk and nothing else: rows are `Insertable`, each one goes through
|
|
237
|
+
`$parse`, so declared defaults are filled here rather than by the caller. `upsertAll` adds the one
|
|
238
|
+
thing a per-row loop cannot do without a read first — resolve a collision — and stamps
|
|
239
|
+
`onUpdateNow()` columns through the same `touch()` `update(id, patch)` uses, because an upsert that
|
|
240
|
+
lands on a stored row *is* an update.
|
|
241
|
+
|
|
242
|
+
Both resolve with **the rows this call wrote**, in order. Under `onMatch: 'nothing'` a row already
|
|
243
|
+
stored is skipped and absent from the result, exactly as `returning *` reports it — which is how a
|
|
244
|
+
caller counts what it actually inserted.
|
|
245
|
+
|
|
246
|
+
| | |
|
|
247
|
+
|---|---|
|
|
248
|
+
| One builder | `insertStatement` compiles every insert in the framework, so `insertAll([row])` is the text `insert(row)` always produced. There is no second insert path to drift |
|
|
249
|
+
| What a collision overwrites | every column the batch writes, minus the conflict target (how the row was found), minus the primary key (where it lives) and minus the soft-delete stamp (whether the row is there at all). Moving either of the first two moves a row nobody asked to move, and every foreign key pointing at that id misses it |
|
|
250
|
+
| A soft-deleted row it lands on | stays deleted, and takes the batch's other columns. The stamped row still occupies its conflict target — that index is not partial — so `excluded."deleted_at"` would resurrect it; `$parse` fills `deletedAt: null` into every row before the plan is built, so the stamp is dropped from the set list rather than refused. `insertAll` still writes the stamp a new row carries |
|
|
251
|
+
| Conflict target | properties of a **declared** unique constraint — the primary key, a `unique()` column, an `indexes: [{ on, unique: true }]` entry, or an `invariant(name, c.unique([…]))`. Anything else is `X_INVARIANT_VIOLATED` here rather than `42P10` from the server |
|
|
252
|
+
| Tenancy | on a tenant-scoped entity `onMatch: 'update'` requires the tenant column *in the conflict target*, else `X_TENANCY_UNSCOPED`: a target that omits it matches another tenant's row and rewrites it. `'nothing'` is allowed — it writes nothing to a row it does not own |
|
|
253
|
+
| A batch that repeats itself | two rows with one conflict target under `'update'` is refused. Postgres answers that statement `ON CONFLICT DO UPDATE command cannot affect row a second time`, so it cannot pass in memory either |
|
|
254
|
+
| Uneven batches | under `'update'` every row must name the same columns: `excluded.<column>` for a row that omitted one is that column's *default*, not "leave it alone". Under `'nothing'` and under `insertAll`, an omitted column is `default` in its cell, which is what the same row means on its own |
|
|
255
|
+
| Nulls | a null in the conflict target collides with nothing, in both drivers — a Postgres unique index is `NULLS DISTINCT` |
|
|
256
|
+
| Size | past 65535 bind parameters the batch is several statements, never one the server refuses. Wrap the call in `withTransaction` when all-or-nothing matters |
|
|
257
|
+
| Filtered writes | `updateWhere` / `deleteWhere` above are the bulk forms of `update` and `delete` — one statement for a loop that would otherwise call either per row; there is no `updateAll` |
|
|
258
|
+
|
|
259
|
+
## Relations are the foreign keys, read twice
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
relationMap().posts;
|
|
263
|
+
// { org: { kind: 'belongsTo', to: 'orgs', localKey: 'orgId', remoteKey: 'id' },
|
|
264
|
+
// author: { kind: 'belongsTo', to: 'members', localKey: 'authorId', remoteKey: 'id' },
|
|
265
|
+
// likes: { kind: 'hasMany', to: 'likes', localKey: 'id', remoteKey: 'postId' } }
|
|
266
|
+
|
|
267
|
+
relationsFor('posts'); // one entity's relations, by name
|
|
268
|
+
relationNamed('posts', 'author'); // one relation, or X_PRELOAD_UNKNOWN_RELATION listing the rest
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`.references(() => members.id)` already says a post has an author and a member has posts. There
|
|
272
|
+
is no second declaration syntax for associations and there will not be one: the map reads the keys
|
|
273
|
+
that exist. `belongsTo` comes from an entity's own foreign keys, `hasMany` from the inbound ones —
|
|
274
|
+
so the two sides can never disagree, and neither can drift from the constraint the migration emits.
|
|
275
|
+
|
|
276
|
+
| Rule | Detail |
|
|
277
|
+
|---|---|
|
|
278
|
+
| Names | `authorId` ⇒ `author`; a `hasMany` is named for the entity the rows come from |
|
|
279
|
+
| Collisions | two keys wanting one name ⇒ **both** take the long form (`author` / `authorId`, `postsByAuthor` / `postsByReviewer`), so a name never depends on declaration order |
|
|
280
|
+
| Ambiguity | two keys that differ only by an `Id` suffix ⇒ `X_INVARIANT_VIOLATED` naming both columns, never one relation silently swallowing the other |
|
|
281
|
+
| Unknown name | `X_PRELOAD_UNKNOWN_RELATION`, whose `fix` is a `relationNamed()` call on one that exists plus the rest by name — they are derived, so there is no schema file listing them to go and read |
|
|
282
|
+
| Keys | `local*` is always on `from`, `remote*` on `to`, whichever side the edge is read from |
|
|
283
|
+
| Money | no relation: one property, three physical columns, so none of them is the key |
|
|
284
|
+
|
|
285
|
+
**Where they come from.** `RegistryEntry.references()` — every `entity()` call leaves one behind, so
|
|
286
|
+
a consumer walks the whole domain without importing a schema module. A method rather than a field:
|
|
287
|
+
a `references()` thunk may point at an entity that two modules of an import cycle have not finished
|
|
288
|
+
evaluating. `relationMap()` derives the whole registry and memoises against its generation, so a
|
|
289
|
+
schema module imported late is rebuilt into the map instead of being missed by it, and a read that
|
|
290
|
+
changed nothing costs one integer compare. `relationsOf(entries)` is the same derivation over a
|
|
291
|
+
named subset — a `belongsTo` to an entity outside it is still reported, a `hasMany` needs both
|
|
292
|
+
sides. An entity holding its own keys reads `entity.$references()`: same closure, so a foreign key
|
|
293
|
+
is read once and not twice.
|
|
294
|
+
|
|
295
|
+
`preload()`, below, is what consumes it — exported because the derivation is a fact about the
|
|
296
|
+
schema, not an implementation detail of whoever traverses it first.
|
|
297
|
+
|
|
298
|
+
## Preloading a relation
|
|
299
|
+
|
|
300
|
+
`As of 2026-08`. A single `findById` batches itself, and a `for … of` loop over a page batches the
|
|
301
|
+
loop it causes — reach for `preload()` to carry the relation from the start, without waiting on
|
|
302
|
+
either pattern to trigger it:
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
export const db = database({ orgs, posts, members });
|
|
306
|
+
|
|
307
|
+
// Two statements: the page, then one `select … where "id" in (…)` over its authors.
|
|
308
|
+
const page = await db.posts.where({ orgId }).preload('author').page();
|
|
309
|
+
page.rows[0].author; // the member row, or null — always present
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
| | |
|
|
313
|
+
|---|---|
|
|
314
|
+
| Vocabulary | one method, `preload('<relation>')` — no `include`, `join` or `with` |
|
|
315
|
+
| Unknown name | `X_PRELOAD_UNKNOWN_RELATION` at `preload()` itself, not a page later |
|
|
316
|
+
| Shape | `belongsTo` attaches the row or `null`; `hasMany` an array — always present |
|
|
317
|
+
| Statements | one extra per relation, resolved concurrently; naming one twice is one statement |
|
|
318
|
+
| 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 |
|
|
319
|
+
| Terminals | `page()`, `all()`, `one()` preload; `count()`, `countBy()` and `plan()` don't — none reads a row to attach one to |
|
|
320
|
+
|
|
104
321
|
## Two drivers, one meaning
|
|
105
322
|
|
|
106
323
|
```ts
|
|
@@ -113,6 +330,23 @@ database({ orgs, posts }, { driver: postgresDriver() }); // production
|
|
|
113
330
|
| Rows live | in a `Map` | in Postgres |
|
|
114
331
|
| For | tests, `x dev` before the first migration | production |
|
|
115
332
|
| Transaction | `memoryTransactor()` — undo closures | `postgresTransactor()` — real `BEGIN`/`COMMIT` |
|
|
333
|
+
| `reset()` | empties every repository it built | not implemented — the rows are the app's |
|
|
334
|
+
|
|
335
|
+
`database()` called with no driver takes the process default, and `defaultDriver()` is that same
|
|
336
|
+
object — the one seam a test harness needs, `As of 2026-08`:
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
import { defaultDriver } from '@ultimat3/entity';
|
|
340
|
+
|
|
341
|
+
defaultDriver().repo(posts).insert(row); // seeds what database({ posts }) reads
|
|
342
|
+
defaultDriver().reset?.(); // between tests; `?.` because Postgres has no reset
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
`Driver.reset?()` is optional on the interface and implemented by `memoryDriver()` alone, so a
|
|
346
|
+
harness asks rather than assumes. It empties the repositories in place: `database()` resolves each
|
|
347
|
+
table's repository once, so a driver that swapped in fresh ones would leave every handle the app
|
|
348
|
+
already holds reading the old rows. Application code never calls either — it names its driver in
|
|
349
|
+
`database(entities, { driver })`, or takes the default implicitly.
|
|
116
350
|
|
|
117
351
|
They are not two implementations of an idea. They share the plan (scope, sort order, page size),
|
|
118
352
|
the cursor codec and the `Repo<T>` contract, so a page taken in a test means the same thing as a
|
|
@@ -121,18 +355,162 @@ returns the open transaction when there is one, so a repository call inside `wit
|
|
|
121
355
|
joins it without being told — which is how a job's outbox row lands atomically with the write
|
|
122
356
|
that enqueued it.
|
|
123
357
|
|
|
358
|
+
`postgresDriver({ client })` pins one instead, for a test harness or `x db branch` — and a pinned
|
|
359
|
+
repository used while a transaction is open is `X_REPO_CLIENT_PINNED`, `As of 2026-08`.
|
|
360
|
+
`withTransaction` reserved a connection and ran `BEGIN` on it; a pinned repository sends straight
|
|
361
|
+
to its own client, so the write would commit whatever the transaction decides and the read would
|
|
362
|
+
miss what the transaction has written, both silently. It is refused rather than resolved because a
|
|
363
|
+
`DbTx` does not name the client it was opened on: on a sharded app "the same connection" and "the
|
|
364
|
+
same database" are two different questions and this layer can answer neither. `setDbClient(client)`
|
|
365
|
+
plus an unpinned repository is the shape that joins.
|
|
366
|
+
|
|
124
367
|
Every value is bound to `$n` and every identifier is resolved through the entity, so a column
|
|
125
368
|
name can only be one the entity declared and a row value can never become SQL.
|
|
126
369
|
|
|
370
|
+
## Point lookups batch themselves
|
|
371
|
+
|
|
372
|
+
`As of 2026-08`:
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
const repo = postgresRepo(users);
|
|
376
|
+
// One statement, not one per post: the lookups issued in this microtask are one `in`.
|
|
377
|
+
const authors = await Promise.all(posts.map((post) => repo.findById(post.authorId, { orgId })));
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
`findById` keeps its signature and gets faster. There is no `dataloader()`, no `batch()` and
|
|
381
|
+
nothing to opt into: inside a request the point lookups issued in one microtask become one
|
|
382
|
+
`select … where "id" in ($1, $2, …)` carrying the scope each of them carried, and outside one — a
|
|
383
|
+
job, a script — it is the single statement it always was.
|
|
384
|
+
|
|
385
|
+
| | |
|
|
386
|
+
|---|---|
|
|
387
|
+
| Window | one microtask, closed before the statement is sent |
|
|
388
|
+
| Lifetime | the request; the batch is keyed by context identity and dies with it |
|
|
389
|
+
| Never shared with | another tenant, another soft-delete visibility, another projection, another entity, another client |
|
|
390
|
+
| Wider than 500 ids | several whole statements, never one Postgres refuses |
|
|
391
|
+
| An id with no row | `null`, exactly as the single statement answered |
|
|
392
|
+
|
|
393
|
+
## A page batches the loop it causes
|
|
394
|
+
|
|
395
|
+
`As of 2026-08`. A sequential loop shares no microtask — its `await` ends the window before the
|
|
396
|
+
next lookup exists. So a page leaves its foreign key values behind, and the first lookup for any
|
|
397
|
+
one of them resolves that key for every row of the page:
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
const page = await postgresRepo(posts).findMany({ orgId });
|
|
401
|
+
for (const post of page.rows) {
|
|
402
|
+
// Two statements for the whole loop: the page, then one `in` over every author on it.
|
|
403
|
+
const author = await postgresRepo(users).findById(post.authorId, { orgId });
|
|
404
|
+
}
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Nothing new to write: the relation is the `references()` the column already declares.
|
|
408
|
+
|
|
409
|
+
| | |
|
|
410
|
+
|---|---|
|
|
411
|
+
| Served to | a lookup with the same scope key, the same client, and no write to that entity since — the preload statement *is* the statement it was widened from |
|
|
412
|
+
| Scope | the tenant predicate and `deleted_at is null` are in the preload statement, so a page's ids can never resolve rows outside the reader's own scope |
|
|
413
|
+
| A write | drops what was preloaded for that entity, before the statement goes out, so a changed row is re-read and never served from before it |
|
|
414
|
+
| Held | the ids, never the rows; keyed by context identity, so it dies with the request |
|
|
415
|
+
| Declines to the old statement | no request in scope, an id no page indexed, a key that resolved to nothing |
|
|
416
|
+
| Switched off | `postgresDriver({ jitPreload: false })`, where the driver is constructed — the one switch. Not an `app.config.ts` key: nothing reads config at the seam that builds a repository |
|
|
417
|
+
|
|
418
|
+
## A loop that got past all of that is reported, with the fix already written
|
|
419
|
+
|
|
420
|
+
`As of 2026-08`. The three batching paths above are what a loop *should* have taken; `nPlusOne()`
|
|
421
|
+
is what an author is handed when it did not. It takes a repeated statement — the verdict a ledger
|
|
422
|
+
reached, never a count this package keeps — and returns the error every surface renders:
|
|
423
|
+
|
|
424
|
+
```ts
|
|
425
|
+
nPlusOne({ kind: 'read', subject: 'members.findById', count: 50, entity: 'members', op: 'findById' });
|
|
426
|
+
// X_N_PLUS_ONE_QUERY: a read repeated once per row
|
|
427
|
+
// cause: members.findById ran 50 times in one request — one read per row
|
|
428
|
+
// fix: db.posts.preload('author') # one statement for the whole page
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
The relation in that `fix` is derived, never invented: `preloadsFor(entity, op)` reads the same
|
|
432
|
+
`relationMap()` `preload()` resolves against, so the line pastes into a chain that already
|
|
433
|
+
compiles. Which edge answers which loop follows from the operation — a point lookup per row is the
|
|
434
|
+
`belongsTo` side (`posts.preload('author')`), a filtered read per row the `hasMany` side
|
|
435
|
+
(`posts.preload('comments')`), and every other operation falls back to the batched form of the
|
|
436
|
+
statement that repeated.
|
|
437
|
+
|
|
438
|
+
| The loop | The `fix` |
|
|
439
|
+
|---|---|
|
|
440
|
+
| `findById` / `findMany` with a relation pointing at it | `db.<page>.preload('<relation>')`, the first candidate pasteable and the rest listed — the ledger saw the statement, never the `for … of` above it |
|
|
441
|
+
| a read with no such relation, or an operation no preload answers | `db.<entity>.andWhere('id', 'in', ids).all()` |
|
|
442
|
+
| `insert` / `update` / `delete` per row | `db.<entity>.insertAll(rows)` / `.updateWhere(filter, patch)` / `.deleteWhere(filter)` |
|
|
443
|
+
| hand-written SQL, attributed to no entity | the statement's own `any($1)` form, or `expectedQueryLoop('<why>', fn)` |
|
|
444
|
+
|
|
445
|
+
Nothing here counts or installs anything: `x dev` owns the ledger, `@ultimat3/testing`'s `statements`
|
|
446
|
+
fixture owns the strict one, `expectedQueryLoop` from `@ultimat3/db` is the one way to declare a loop
|
|
447
|
+
deliberate, and a production process pays the one branch the observer seam costs uninstalled. The
|
|
448
|
+
one number both detectors read *is* here — `N_PLUS_ONE_THRESHOLD` (5), next to the codes whose `fix`
|
|
449
|
+
it triggers, so a loop that fails a test and a loop that warns in dev are the same loop.
|
|
450
|
+
|
|
127
451
|
## Tenancy is a guard
|
|
128
452
|
|
|
129
453
|
`tenant: 'orgId'` on the entity names the column outright. Omit it and it is inferred — a
|
|
130
454
|
`.tenant()` column, else one named `orgId` — so an entity never becomes unscoped by forgetting the
|
|
131
455
|
key; name a column that does not exist and the declaration fails with `X_INVARIANT_VIOLATED`.
|
|
132
456
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
457
|
+
**A tenant column may not be nullable**, whichever of the three switches named it, and the
|
|
458
|
+
declaration fails the same way. `assertRowTenant` leaves a row that names no tenant alone and
|
|
459
|
+
delegates to the column's `NOT NULL`; on a nullable column that delegation has nothing behind it, so
|
|
460
|
+
the row lands with a null tenant, is matched by no `org_id = $1`, and belongs to nobody — never in
|
|
461
|
+
an export, never in an offboarding sweep, and there for as long as the table is.
|
|
462
|
+
|
|
463
|
+
**The tenant is the acting actor's, and never an argument.** Inside a request every plan for a
|
|
464
|
+
scoped entity is scoped to `ctx.actor.orgId`, whether the call named a tenant or not — so
|
|
465
|
+
`db.posts.where({ status })` reads one org's posts and a handler no longer threads a tenant
|
|
466
|
+
through its own signatures. Writes are reads: `update(id, patch)` and `delete(id)` build the same
|
|
467
|
+
plan, so an id alone never addresses a row, and another tenant's id is `X_NOT_FOUND` rather than
|
|
468
|
+
theirs.
|
|
469
|
+
|
|
470
|
+
An `orgId` argument is still legal and now means "I assert this is the tenant": equal to the
|
|
471
|
+
actor's it is a restatement, different from it — an `orgId` that arrived as action input, a query
|
|
472
|
+
string or a path parameter — it is `X_TENANCY_ACTOR_MISMATCH`, refused rather than silently
|
|
473
|
+
overridden, with both values in the cause. `in` on the tenant column is judged the same way: a set
|
|
474
|
+
containing the actor's org is still a set that is not it.
|
|
475
|
+
|
|
476
|
+
| Situation | Answer |
|
|
477
|
+
|---|---|
|
|
478
|
+
| actor carries an `orgId` | the plan is scoped to it, derived |
|
|
479
|
+
| a call names the same one | a restatement; one predicate, not two |
|
|
480
|
+
| a call names another one | `X_TENANCY_ACTOR_MISMATCH` |
|
|
481
|
+
| actor carries no `orgId` (anonymous, or a service actor minted without one) | `X_TENANCY_ACTOR_ORG_REQUIRED` — inside no org, every tenant-scoped row is somebody else's |
|
|
482
|
+
| no request context at all (a script, a seed, a test harness) | there is no actor to derive from, so the caller names the tenant and `X_TENANCY_UNSCOPED` refuses a plan that names none |
|
|
483
|
+
| a read that must span tenants | `crossTenant(reason, fn)` |
|
|
484
|
+
|
|
485
|
+
**A row is judged the same way as a predicate.** `insert`, `insertAll` and `upsertAll` build no
|
|
486
|
+
read plan at all, and a patch decides what a row *becomes*, so the tenant a write names is checked
|
|
487
|
+
against the actor too: `insert({ orgId: theirs, … })` and `update(id, { orgId: theirs })` are both
|
|
488
|
+
`X_TENANCY_ACTOR_MISMATCH`, refused before the statement is sent and before anything is stored. A
|
|
489
|
+
batch is all or nothing — one bad row refuses the rows beside it, in both drivers.
|
|
490
|
+
|
|
491
|
+
**Refused, never stamped.** A row that names no tenant is left exactly as it was written: the
|
|
492
|
+
column's own `NOT NULL` answers a missing one. Filling it in from the actor is the ergonomic half
|
|
493
|
+
and it is deliberately absent, because the column list an `upsertAll` writes is decided by which
|
|
494
|
+
properties a row names — a stamped column would change the statement, silence the uneven-batch
|
|
495
|
+
refusal, and let ambient state decide which stored row a collision lands on.
|
|
496
|
+
|
|
497
|
+
**A cross-tenant upsert is unrepresentable rather than documented.** Two halves, and both are now
|
|
498
|
+
enforced: the conflict target must contain the tenant column under `onMatch: 'update'`
|
|
499
|
+
(`X_TENANCY_UNSCOPED` — the target is what decides which stored row a collision lands on), and
|
|
500
|
+
every incoming row must name the acting actor's tenant. Together, the key a collision is judged by
|
|
501
|
+
can only hold a value that is this actor's.
|
|
502
|
+
|
|
503
|
+
```ts
|
|
504
|
+
// admin surfaces, background reconciliation, support tooling — greppable, and never a boolean
|
|
505
|
+
await crossTenant('nightly invite expiry runs for every org', async () => { … });
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
The scope needs the `tenancy:cross` capability on the actor (`scopes: ['tenancy:cross']`), proven
|
|
509
|
+
at the call and again at every plan built inside it, so an impersonated child context cannot
|
|
510
|
+
inherit it: `X_TENANCY_CROSS_DENIED` otherwise. A blank reason is refused — an escape with no
|
|
511
|
+
argument is a pragma. There is no build-time tenancy check in `x verify`, and there cannot
|
|
512
|
+
usefully be one: the tenant is a request-time value, so the seam every plan is built through is
|
|
513
|
+
the enforcement.
|
|
136
514
|
|
|
137
515
|
## Seeds
|
|
138
516
|
|
|
@@ -142,8 +520,10 @@ the invariants, which makes a seed a test of the schema as well.
|
|
|
142
520
|
|
|
143
521
|
## Errors
|
|
144
522
|
|
|
145
|
-
`X_ENTITY_DUPLICATE` · `X_INVARIANT_VIOLATED` · `X_TENANCY_UNSCOPED` ·
|
|
146
|
-
`
|
|
523
|
+
`X_ENTITY_DUPLICATE` · `X_INVARIANT_VIOLATED` · `X_TENANCY_UNSCOPED` ·
|
|
524
|
+
`X_TENANCY_ACTOR_MISMATCH` · `X_TENANCY_ACTOR_ORG_REQUIRED` · `X_TENANCY_CROSS_DENIED` ·
|
|
525
|
+
`X_DB_DRIFT` · `X_NOT_FOUND` · `X_WRITE_UNFILTERED` · `X_PATCH_EMPTY` ·
|
|
526
|
+
`X_PRELOAD_UNKNOWN_RELATION` · `X_N_PLUS_ONE_QUERY` · `X_N_PLUS_ONE_WRITE`
|
|
147
527
|
|
|
148
528
|
## Boundaries
|
|
149
529
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "A table + its domain type + invariants the database also enforces",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"CLAUDE.md",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
|
@@ -30,8 +31,8 @@
|
|
|
30
31
|
"test": "bun test"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@ultimat3/core": "
|
|
34
|
-
"@ultimat3/db": "
|
|
35
|
-
"@ultimat3/schema": "
|
|
34
|
+
"@ultimat3/core": "2.0.0",
|
|
35
|
+
"@ultimat3/db": "2.0.0",
|
|
36
|
+
"@ultimat3/schema": "2.0.0"
|
|
36
37
|
}
|
|
37
38
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Single responsibility: what a batched point read is made of — the scope two lookups must share
|
|
2
|
+
// before one statement is allowed to answer both, and the one `select … where <key> in (…)` that
|
|
3
|
+
// answers them. The microtask coalescer and the sibling-aware preload both read ids through here,
|
|
4
|
+
// so the two can never disagree about when a shared statement is legal.
|
|
5
|
+
|
|
6
|
+
import type { DbClient } from '@ultimat3/db';
|
|
7
|
+
import type { EntityCore } from './entity';
|
|
8
|
+
import { decodeRow, type PhysicalRow } from './pg-row';
|
|
9
|
+
import { type ReadShape, selectStatement } from './pg-sql';
|
|
10
|
+
import type { QueryPlan } from './tenancy';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Postgres binds at most 65535 parameters, so a batch wider than this becomes several statements
|
|
14
|
+
* rather than one the driver refuses. Splitting can only cost round trips; not splitting would
|
|
15
|
+
* fail reads that succeeded one at a time, and a batcher that breaks a working program is worse
|
|
16
|
+
* than no batcher.
|
|
17
|
+
*/
|
|
18
|
+
export const MAX_IDS_PER_STATEMENT = 500;
|
|
19
|
+
|
|
20
|
+
/** The primary key, in the three spellings a batch needs: the plan's, the table's, and the type. */
|
|
21
|
+
export interface KeyColumn {
|
|
22
|
+
readonly property: string;
|
|
23
|
+
readonly column: string;
|
|
24
|
+
readonly kind: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One row, or the failure decoding it. A column the table no longer matches is that row's
|
|
29
|
+
* problem: a caller whose own row decoded must still be handed it, exactly as it would have been
|
|
30
|
+
* by the statement it did not share.
|
|
31
|
+
*/
|
|
32
|
+
export type Answer = { readonly row: unknown } | { readonly error: unknown };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The string both sides of a batch are filed under — the requested id, and the key of a row that
|
|
36
|
+
* came back. Postgres compares a `uuid` as a value and prints it lower-cased, so an id handed in
|
|
37
|
+
* upper case matches the row *there* and would miss it here, which would make an answer depend on
|
|
38
|
+
* whether some other lookup shared the microtask. Every other kind compares by its bytes, and
|
|
39
|
+
* lower-casing a text key would merge two rows Postgres keeps apart.
|
|
40
|
+
*/
|
|
41
|
+
export const keyOf = (kind: string, value: unknown): string =>
|
|
42
|
+
kind === 'uuid' ? String(value).toLowerCase() : String(value);
|
|
43
|
+
|
|
44
|
+
/** A scope value has to be comparable as a string, or two scopes cannot be told apart. */
|
|
45
|
+
const scopeValue = (value: unknown): string | undefined => {
|
|
46
|
+
if (value === null || value === undefined) return 'null';
|
|
47
|
+
if (value instanceof Date) return `date:${value.getTime()}`;
|
|
48
|
+
const kind = typeof value;
|
|
49
|
+
return kind === 'string' || kind === 'number' || kind === 'bigint' || kind === 'boolean'
|
|
50
|
+
? `${kind}:${String(value)}`
|
|
51
|
+
: undefined;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Everything about the read except the id. Two lookups may share one statement only when this
|
|
56
|
+
* matches: another tenant's predicate, a different projection or a different soft-delete
|
|
57
|
+
* visibility is a different query, and merging them would answer a caller with rows the statement
|
|
58
|
+
* they asked for could never have returned.
|
|
59
|
+
*
|
|
60
|
+
* `undefined` when a predicate value cannot be rendered — an object scope is one this cannot prove
|
|
61
|
+
* two lookups share, and a batch is only ever an optimisation, so it declines instead of guessing.
|
|
62
|
+
*/
|
|
63
|
+
export const scopeKey = <Row>(
|
|
64
|
+
entity: EntityCore<Row>,
|
|
65
|
+
scoped: QueryPlan,
|
|
66
|
+
shape: ReadShape,
|
|
67
|
+
): string | undefined => {
|
|
68
|
+
const predicates: string[][] = [];
|
|
69
|
+
for (const predicate of scoped.where) {
|
|
70
|
+
const value = scopeValue(predicate.value);
|
|
71
|
+
if (value === undefined) return undefined;
|
|
72
|
+
predicates.push([predicate.column, predicate.op, value]);
|
|
73
|
+
}
|
|
74
|
+
// JSON rather than a joined string: a value carrying the separator cannot forge a boundary.
|
|
75
|
+
return JSON.stringify([
|
|
76
|
+
entity.$name,
|
|
77
|
+
entity.$table,
|
|
78
|
+
shape.includeDeleted,
|
|
79
|
+
scoped.select ?? null,
|
|
80
|
+
predicates,
|
|
81
|
+
]);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One read, minus the ids. This is the whole of what a shared statement is allowed to vary in —
|
|
86
|
+
* a batch that widens anything else here is a batch answering a caller with rows their own
|
|
87
|
+
* statement could never have returned.
|
|
88
|
+
*/
|
|
89
|
+
export interface PointRead<Row> {
|
|
90
|
+
readonly entity: EntityCore<Row>;
|
|
91
|
+
/** A pinned client and the ambient pool are two places to read from, never one batch. */
|
|
92
|
+
readonly client: DbClient;
|
|
93
|
+
/** The plan with the id predicate removed — the scope every id in the batch shares. */
|
|
94
|
+
readonly scoped: QueryPlan;
|
|
95
|
+
readonly shape: ReadShape;
|
|
96
|
+
readonly key: KeyColumn;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The one statement, filed by key — the same builder, the same scope, `in` instead of `=`. */
|
|
100
|
+
export const readByIds = async <Row>(
|
|
101
|
+
read: PointRead<Row>,
|
|
102
|
+
ids: readonly unknown[],
|
|
103
|
+
): Promise<ReadonlyMap<string, Answer>> => {
|
|
104
|
+
const { entity, scoped, key } = read;
|
|
105
|
+
const found = await read.client.query<PhysicalRow>(
|
|
106
|
+
selectStatement(
|
|
107
|
+
entity,
|
|
108
|
+
{ ...scoped, where: [{ column: key.property, op: 'in', value: ids }, ...scoped.where] },
|
|
109
|
+
read.shape,
|
|
110
|
+
ids.length,
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
const answers = new Map<string, Answer>();
|
|
114
|
+
for (const physical of found) {
|
|
115
|
+
// Filed under the value the statement matched on, which is readable whether or not the rest
|
|
116
|
+
// of the row decodes — so a drifted column fails one caller instead of everyone in the batch.
|
|
117
|
+
const filedAt = keyOf(key.kind, physical[key.column]);
|
|
118
|
+
try {
|
|
119
|
+
answers.set(filedAt, { row: decodeRow(entity, physical) });
|
|
120
|
+
} catch (error) {
|
|
121
|
+
answers.set(filedAt, { error });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return answers;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** One statement's worth at a time — a batch too wide for the bind count is several, never one. */
|
|
128
|
+
export const statementChunks = <T>(values: readonly T[]): readonly (readonly T[])[] => {
|
|
129
|
+
const chunks: T[][] = [];
|
|
130
|
+
for (let from = 0; from < values.length; from += MAX_IDS_PER_STATEMENT) {
|
|
131
|
+
chunks.push(values.slice(from, from + MAX_IDS_PER_STATEMENT));
|
|
132
|
+
}
|
|
133
|
+
return chunks;
|
|
134
|
+
};
|