@ultimat3/entity 19.3.1 → 19.3.3

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.
Files changed (2) hide show
  1. package/package.json +5 -5
  2. 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.1",
3
+ "version": "19.3.3",
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.1",
35
- "@ultimat3/db": "19.3.1",
36
- "@ultimat3/schema": "19.3.1",
37
- "@ultimat3/time": "19.3.1"
34
+ "@ultimat3/core": "19.3.3",
35
+ "@ultimat3/db": "19.3.3",
36
+ "@ultimat3/schema": "19.3.3",
37
+ "@ultimat3/time": "19.3.3"
38
38
  }
39
39
  }
@@ -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
- return await repo.findById(id);
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 per row and only for rows that carry an id, because a collision is what
157
- * separates an insert from an update here and nothing else in the result says which happened.
158
- * Under `onMatch: 'nothing'` a row already stored is absent from the result — so it wrote
159
- * nothing, and reporting a change for it would be reporting a write that did not occur.
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 before = new Map<string, unknown>();
164
- for (const row of rows) {
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);