@ultimat3/entity 19.3.1 → 19.3.2
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/package.json +5 -5
- package/src/row-observer.ts +58 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "19.3.
|
|
3
|
+
"version": "19.3.2",
|
|
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": "19.3.
|
|
35
|
-
"@ultimat3/db": "19.3.
|
|
36
|
-
"@ultimat3/schema": "19.3.
|
|
37
|
-
"@ultimat3/time": "19.3.
|
|
34
|
+
"@ultimat3/core": "19.3.2",
|
|
35
|
+
"@ultimat3/db": "19.3.2",
|
|
36
|
+
"@ultimat3/schema": "19.3.2",
|
|
37
|
+
"@ultimat3/time": "19.3.2"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/row-observer.ts
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
// the replicator — `@ultimat3/realtime`'s `selectChangeFeed` still decides, and this is never in
|
|
14
14
|
// that decision.
|
|
15
15
|
|
|
16
|
+
import { expectedQueryLoop } from '@ultimat3/db';
|
|
16
17
|
import type { EntityCore } from './entity';
|
|
18
|
+
import { MAX_PAGE_SIZE } from './plan';
|
|
17
19
|
import type { Repo, RepoOptions, UpsertArgs } from './repo';
|
|
18
20
|
import type { IdOf, RowPatch } from './types';
|
|
19
21
|
|
|
@@ -111,12 +113,60 @@ const beforeOf = async <Row>(
|
|
|
111
113
|
): Promise<Row | null> => {
|
|
112
114
|
if (!readsById(entity as EntityCore<unknown>)) return null;
|
|
113
115
|
try {
|
|
114
|
-
|
|
116
|
+
// Expected, with the reason on the statement: this is ONE read for ONE point write, and a
|
|
117
|
+
// request that updates fifty rows one at a time is already fifty `update` statements — the
|
|
118
|
+
// caller's loop, reported on the caller's writes. Without the scope the same loop was reported
|
|
119
|
+
// twice, the second verdict naming a `findById` no app code issued.
|
|
120
|
+
return await expectedQueryLoop(
|
|
121
|
+
'the row observer reads the row a point write is about to change — one read per write',
|
|
122
|
+
() => repo.findById(id),
|
|
123
|
+
);
|
|
115
124
|
} catch {
|
|
116
125
|
return null;
|
|
117
126
|
}
|
|
118
127
|
};
|
|
119
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The `before` rows of a batch, read as ONE statement per `MAX_PAGE_SIZE` ids rather than one per
|
|
131
|
+
* row. Measured on ai-maxxing, 2026-09-06: an `upsertAll` of five pull requests logged
|
|
132
|
+
* `X_N_PLUS_ONE_QUERY: pull_requests.findById ran 5 times in one request` — the framework's own
|
|
133
|
+
* feed tripping the framework's own detector, exactly as the job step-write did before #415. A
|
|
134
|
+
* `findById` per row is not even coalesced into one statement, because each one is awaited
|
|
135
|
+
* before the next is issued and the coalescer's window is a microtask.
|
|
136
|
+
*
|
|
137
|
+
* `findMany` with `in` reads through the same plan a `findById` does — same tenant scope, same
|
|
138
|
+
* soft-delete filter — so a row this cannot see is a row `findById` could not see either. The
|
|
139
|
+
* page bound is the one every read has: past it the batch is several statements, never a refusal.
|
|
140
|
+
*
|
|
141
|
+
* The `catch` is `beforeOf`'s, for its reason: a diagnostic must not turn a working write into a
|
|
142
|
+
* failing one. On failure every row of the batch reports as an insert with no `before`, which is
|
|
143
|
+
* what `null` already means.
|
|
144
|
+
*/
|
|
145
|
+
const beforeAllOf = async <Row>(
|
|
146
|
+
entity: EntityCore<Row>,
|
|
147
|
+
repo: Repo<Row>,
|
|
148
|
+
ids: readonly string[],
|
|
149
|
+
): Promise<ReadonlyMap<string, Row>> => {
|
|
150
|
+
const before = new Map<string, Row>();
|
|
151
|
+
if (ids.length === 0 || !readsById(entity as EntityCore<unknown>)) return before;
|
|
152
|
+
try {
|
|
153
|
+
for (let at = 0; at < ids.length; at += MAX_PAGE_SIZE) {
|
|
154
|
+
const chunk = ids.slice(at, at + MAX_PAGE_SIZE);
|
|
155
|
+
const page = await repo.findMany({
|
|
156
|
+
where: [{ column: 'id', op: 'in', value: chunk }],
|
|
157
|
+
limit: chunk.length,
|
|
158
|
+
});
|
|
159
|
+
for (const row of page.rows) {
|
|
160
|
+
const id = idOf(row);
|
|
161
|
+
if (id !== undefined) before.set(id, row);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
before.clear();
|
|
166
|
+
}
|
|
167
|
+
return before;
|
|
168
|
+
};
|
|
169
|
+
|
|
120
170
|
/**
|
|
121
171
|
* Wrap one repository so its writes are reported. Applied by `database()` to every table it builds,
|
|
122
172
|
* so an app opts in by installing an observer and never by choosing a different repository — the
|
|
@@ -153,18 +203,16 @@ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Rep
|
|
|
153
203
|
},
|
|
154
204
|
|
|
155
205
|
/**
|
|
156
|
-
* `before` is read
|
|
157
|
-
* separates an insert from an update here and nothing else in the result
|
|
158
|
-
* Under `onMatch: 'nothing'` a row already stored is absent from the
|
|
159
|
-
* nothing, and reporting a change for it would be reporting a write that
|
|
206
|
+
* `before` is read for the rows that carry an id — as one statement, `beforeAllOf` — because a
|
|
207
|
+
* collision is what separates an insert from an update here and nothing else in the result
|
|
208
|
+
* says which happened. Under `onMatch: 'nothing'` a row already stored is absent from the
|
|
209
|
+
* result — so it wrote nothing, and reporting a change for it would be reporting a write that
|
|
210
|
+
* did not occur.
|
|
160
211
|
*/
|
|
161
212
|
upsertAll: async (rows: readonly Row[], args: UpsertArgs<Row>): Promise<readonly Row[]> => {
|
|
162
213
|
if (installed === null) return await repo.upsertAll(rows, args);
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
const id = idOf(row);
|
|
166
|
-
if (id !== undefined) before.set(id, await beforeOf(entity, repo, id as IdOf<Row>));
|
|
167
|
-
}
|
|
214
|
+
const ids = [...new Set(rows.flatMap((row) => idOf(row) ?? []))];
|
|
215
|
+
const before = await beforeAllOf(entity, repo, ids);
|
|
168
216
|
const stored = await repo.upsertAll(rows, args);
|
|
169
217
|
for (const row of stored) {
|
|
170
218
|
const id = idOf(row);
|