@ultimat3/entity 19.1.3 → 19.3.1

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 CHANGED
@@ -67,12 +67,20 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
67
67
  decimals, so a caller with no kinds — `@ultimat3/query`, whose `OrderKey` is a name and a
68
68
  direction — deliberately never calls it: Postgres orders a `text` column of digits lexically, and
69
69
  a comparator guessing "both sides look like decimals" would trade this agreement for that
70
- disagreement. That residual gap is `@ultimat3/query`'s `shape-order.test.ts` `DECLARED_GAP`. **A `uuid` is a VALUE**: Postgres parses it and
71
- prints it lower-cased, so `findById(UPPER)` reads the row there and answered `null` here, and
70
+ disagreement. That residual gap is `@ultimat3/query`'s `shape-order.test.ts` `DECLARED_GAP`.
71
+ **A `uuid` is a VALUE, and the value the row HOLDS is lower case**: Postgres parses it and prints
72
+ it lower-cased, so `findById(UPPER)` reads the row there and answered `null` here, and
72
73
  `update(UPPER)` was `X_NOT_FOUND` against a row that exists — `keyOf(kind, value)`
73
74
  (`batch-read.ts`), which already carried that rule for a batched read, now spells the memory
74
- store's key and its equality too. Text is NOT narrowed: lower-casing it would merge two rows
75
- Postgres keeps apart. **A `LIKE` pattern uses Postgres' default escape**: `\` escapes `%`, `_`
75
+ store's key and its equality too. Equality was only half of it: the STORED value stayed the
76
+ caller's spelling here while the server's row never carries one, so `countBy('authorId')` keyed
77
+ its `Map` by `AAAA…` in memory and by `aaaa…` in production — a breakdown one driver's caller
78
+ cannot look up, out of a call that read the right rows. Narrowed in the two places a value
79
+ crosses: `parseUuid` on the way back (`decodeRow`, `entity.$parse`) and `narrowUuid` at each
80
+ write method's entry (`columns.ts`), beside `narrowMoney` and for its reason — `entity.$assert`
81
+ runs before a statement exists, so an invariant reading an id must see the value the row will
82
+ hold. Text is NOT narrowed: lower-casing it would merge two rows Postgres keeps apart.
83
+ **A `LIKE` pattern uses Postgres' default escape**: `\` escapes `%`, `_`
76
84
  or itself, so `like 'a\%b'` matches the literal `a%b` in both drivers rather than
77
85
  `a\<anything>b` in one — and a pattern ending in the escape character is refused here as
78
86
  Postgres refuses it (`22025`). A RUN of `%` is still one `.*`: twenty adjacent `.*` groups in an
@@ -343,10 +351,17 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
343
351
  `cursorFor` runs only when a page found a row past its limit, so `orderBy('publishedAt', 'desc')
344
352
  .limit(20)` over a nullable column was green on fifteen seeded rows for as long as the suite
345
353
  existed and `X_INVARIANT_VIOLATED` on the first read past twenty in production. That file's own
346
- doc comment claimed the opposite for two majors. `assertBatchable` has always judged `inBatches()`
347
- this way. This package owns only
348
- what a cursor is *bound* to — `planScope(plan)`: the entity, its filters and its sort order,
349
- hashed. Not the page size (a bigger next page is the same query) and not `select` (a projection
354
+ doc comment claimed the opposite for two majors. `assertBatchable` has always judged
355
+ `inBatches()` this way.
356
+ **A `timestamptz` sort key is refused when its ALIAS would not fit, `As of 2026-09`.** That key
357
+ selects a second output — `<column>$US`, the microsecond half `sortPrecision` reads back — and
358
+ `assertColumnName` admits 63 bytes, which is the server's whole budget: a 61-byte column
359
+ produced a 64-byte alias, Postgres truncated it and said nothing, the read answered `undefined`,
360
+ and `cursorFor` fell back to the millisecond `Date` `instant.ts` exists to replace. The page
361
+ then cuts where no row sits. Bounded against `index-name.ts`'s `MAX_IDENTIFIER_BYTES` and
362
+ measured on the alias `seekAlias` actually builds, never against a hard-coded suffix length.
363
+ This package owns only what a cursor is *bound* to — `planScope(plan)`: the entity, its filters
364
+ and its sort order, hashed. Not the page size (a bigger next page is the same query) and not `select` (a projection
350
365
  cannot move a row). A cursor that fails either the signature or the scope is `X_CURSOR_INVALID`;
351
366
  it must never decode to "start from the top", which is what the old codec's `null` did.
352
367
  - **A NULLABLE sort key orders, `As of 2026-08-24` — `asc nulls last` / `desc nulls first`, and
@@ -414,19 +429,20 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
414
429
  is recursive structural containment, the second is plain element membership, because an array's
415
430
  elements are scalars of one declared type — `arrayOf()` refuses `jsonb`, `bytea`, `money` and a
416
431
  nested array, which is what makes that true. A `Date` element compares by its instant, never by
417
- reference. **`jsonb_exists(col, $1)`, never the `?` operator**: a literal `?` is a parameter
418
- placeholder to more than one client on the way to the server. **No jsonpath expression operator**
419
- beside them, deliberately: `contains` already matches nested structure, and a path language
420
- inside the query language is a second way to ask one question. `&&` on a `jsonb` column is
421
- refused where it was written, since Postgres has no such operator and any answer would be one no
422
- statement can make. **`has-key` emits the `?` OPERATOR, schema-qualified
423
- (`operator(pg_catalog.?)`), and not `jsonb_exists(col, $1)`** — the two are the same test and only
424
- the first is INDEXABLE: measured on Postgres 16 with a GIN index and `enable_seqscan = off`,
425
- `data ? 'k'` plans as a Bitmap Index Scan and the function form is a Seq Scan the planner will not
426
- convert, because an index is matched against an operator expression and a bare function call is
427
- not one. The function form shipped first, on a stated fear of `?` being read as a placeholder;
428
- Bun's client passes it through verbatim (measured), and the qualified spelling is immune to a
429
- client that does not and to a `search_path` that shadows the operator.
432
+ reference. **No jsonpath expression operator** beside them, deliberately: `contains` already
433
+ matches nested structure, and a path language inside the query language is a second way to ask
434
+ one question. `&&` on a `jsonb` column is refused where it was written, since Postgres has no
435
+ such operator and any answer would be one no statement can make. **`has-key` emits the `?`
436
+ OPERATOR, schema-qualified (`operator(pg_catalog.?)`), and not `jsonb_exists(col, $1)`** — the two
437
+ are the same test and only the first is INDEXABLE: measured on Postgres 16 with a GIN index and
438
+ `enable_seqscan = off`, `data ? 'k'` plans as a Bitmap Index Scan and the function form is a Seq
439
+ Scan the planner will not convert, because an index is matched against an operator expression and
440
+ a bare function call is not one. The function form shipped first, on a stated fear of `?` being
441
+ read as a placeholder; Bun's client passes it through verbatim (measured), and the qualified
442
+ spelling is immune to a client that does not and to a `search_path` that shadows the operator. The
443
+ bullet ABOVE this one said the opposite — "`jsonb_exists(col, $1)`, never the `?` operator" — for
444
+ two majors after the SQL moved, and `containment.ts`'s own doc comment repeated it; both are gone,
445
+ and `pg-sql.test.ts` now pins the emitted form so neither can come back as prose.
430
446
  - **A GIN index is declarable — `indexes: [{ on: ['tags'], using: 'gin' }]`, `As of 2026-08-24`.**
431
447
  Without one every containment operator above is a sequential scan, which is the whole reason they
432
448
  needed an index at all: measured over 20,000 rows, array `@>` / `<@` / `&&` and jsonb `@>` and
@@ -677,6 +693,12 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
677
693
  the three callers want three answers and the wrong one is invisible in the result. The soft
678
694
  delete inside `removal()` passes `false` too — both its callers read a count through
679
695
  `execute()`, so its rows were never readable by anyone.
696
+ **And the bound is BOTH drivers', `As of 2026-09`.** `memoryRepo.updateWhere` applied no
697
+ ceiling at all, so a sweep of 50,001 rows answered `50001` here and `X_INVARIANT_VIOLATED`
698
+ there from one call — a test written against memory proving a call production refuses, which
699
+ is the drift the two-driver split exists to prevent. Same condition (`hasJsOnlyInvariant`),
700
+ same constant, same error, and checked before the write loop so a refusal never writes the
701
+ rows it declined to judge.
680
702
  - **Every instant the write path stamps comes from `ctx.clock`, through `entityNow()`**
681
703
  (`clock.ts`, `As of 2026-08`). `defaultNow()`, `touch()`'s `onUpdateNow()`, the soft-delete stamp
682
704
  in BOTH drivers and a seed's `now` each read `systemClock` directly, so a frozen test clock drove
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "19.1.3",
3
+ "version": "19.3.1",
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.1.3",
35
- "@ultimat3/db": "19.1.3",
36
- "@ultimat3/schema": "19.1.3",
37
- "@ultimat3/time": "19.1.3"
34
+ "@ultimat3/core": "19.3.1",
35
+ "@ultimat3/db": "19.3.1",
36
+ "@ultimat3/schema": "19.3.1",
37
+ "@ultimat3/time": "19.3.1"
38
38
  }
39
39
  }
package/src/columns.ts CHANGED
@@ -37,9 +37,18 @@ export const newId = (): string => uuidV7();
37
37
 
38
38
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
39
39
 
40
+ /**
41
+ * Lower-cased, because Postgres parses a `uuid` on the way in and prints it lower-cased on the way
42
+ * out: the row the server stores never carries the caller's spelling, so a row this driver stored
43
+ * verbatim was a value production does not hold. `keyOf` (`batch-read.ts`) and `sameValueOfKind`
44
+ * (`memory-match.ts`) already narrow the same way for EQUALITY; this is the value itself, which is
45
+ * what a caller reads back and what `countBy` keys its `Map` by — an upper-cased insert produced a
46
+ * breakdown keyed one way in memory and the other in production. `text()` is deliberately NOT
47
+ * narrowed: lower-casing it would merge two rows Postgres keeps apart.
48
+ */
40
49
  const parseUuid = (value: unknown): string =>
41
50
  typeof value === 'string' && UUID.test(value)
42
- ? value
51
+ ? value.toLowerCase()
43
52
  : refuseColumn(
44
53
  'format',
45
54
  `expected a uuid, ${got(value)}`,
@@ -367,11 +376,13 @@ const checkedParts = (columns: MoneyColumnNames): MoneyColumnNames => ({
367
376
  * exactly the drift the two-driver split exists to prevent — here it would mean an in-memory row
368
377
  * holding a `bigint` that `JSON.stringify` refuses while the Postgres row holds a `number`.
369
378
  *
370
- * Every other kind is returned untouched: writes are asserted, not parsed, and money is the only
371
- * kind that widens. A value already holding safe-integer minor units is left alone — that is the
372
- * overwhelmingly common case and it costs one `typeof`-grade check and no allocation (axiom 6);
373
- * everything else goes through `parseMinor`, so a `bigint` narrows and a float is refused with
374
- * the same message it would get coming back from the database.
379
+ * Every other kind is returned untouched HERE: writes are asserted, not parsed, and money is the
380
+ * only kind whose declared TYPE widens. `uuid` is narrowed too and is narrowed beside this one
381
+ * (`narrowUuid`), because its width is in the value's spelling rather than in its type. A value
382
+ * already holding safe-integer minor units is left alone — that is the overwhelmingly common case
383
+ * and it costs one `typeof`-grade check and no allocation (axiom 6); everything else goes through
384
+ * `parseMinor`, so a `bigint` narrows and a float is refused with the same message it would get
385
+ * coming back from the database.
375
386
  */
376
387
  export const narrowMoney = <Row>(columns: ColumnMap, row: Row): Row => {
377
388
  let narrowed: Record<string, unknown> | undefined;
@@ -388,6 +399,46 @@ export const narrowMoney = <Row>(columns: ColumnMap, row: Row): Row => {
388
399
  return (narrowed ?? row) as Row;
389
400
  };
390
401
 
402
+ /**
403
+ * The second narrowing, and the same shape as the one above: what the caller spelled in is wider
404
+ * than what the row holds. Postgres parses a `uuid` on the way IN and prints it lower-cased on the
405
+ * way OUT, so an upper-cased insert is stored — and read back — lower-cased there whatever the
406
+ * writer typed, while the in-memory driver kept the spelling verbatim.
407
+ *
408
+ * That was one value with two spellings across the two drivers, not a cosmetic difference: `keyOf`
409
+ * (`batch-read.ts`) and `sameValueOfKind` (`memory-match.ts`) already narrow a `uuid` for
410
+ * EQUALITY, so the row was found either way, but `countBy` keys its `Map` by the value it read off
411
+ * the row — so `counts.get(id)` answered the count in production and `undefined` in memory, from
412
+ * one call. `text()` is deliberately never narrowed: lower-casing it would merge two rows Postgres
413
+ * keeps apart.
414
+ *
415
+ * At the write entry rather than in `bindValues`, for `narrowMoney`'s reason: `entity.$assert`
416
+ * runs before a statement exists, so an invariant reading an id must see the value the row will
417
+ * hold. `parseUuid` applies the same rule on the read side (`decodeRow`, `entity.$parse`).
418
+ */
419
+ const narrowUuid = <Row>(columns: ColumnMap, row: Row): Row => {
420
+ let narrowed: Record<string, unknown> | undefined;
421
+ const record = row as Readonly<Record<string, unknown>>;
422
+ for (const [property, column] of Object.entries(columns)) {
423
+ if (column.$meta.kind !== 'uuid') continue;
424
+ const value = record[property];
425
+ // Untouched unless it is a string that actually carries an upper-case character: a malformed
426
+ // value is the column parser's to refuse, with its own message, and no allocation is paid by
427
+ // the overwhelmingly common case (axiom 6).
428
+ if (typeof value !== 'string') continue;
429
+ const lower = value.toLowerCase();
430
+ if (lower === value) continue;
431
+ // And only a value Postgres would have accepted, which is the whole justification for touching
432
+ // it: the memory driver stores what `$assert` was shown, so a malformed id lower-cased here is
433
+ // a spelling NEITHER driver holds — Postgres refuses that insert outright. Asked last, so the
434
+ // regex is paid only by the rare value that carries an upper-case character at all.
435
+ if (!UUID.test(value)) continue;
436
+ narrowed ??= { ...record };
437
+ narrowed[property] = lower;
438
+ }
439
+ return (narrowed ?? row) as Row;
440
+ };
441
+
391
442
  /**
392
443
  * The same narrowing at a write METHOD'S entry, typed honestly: what a caller may spell in,
393
444
  * the row the entity declares out.
@@ -402,7 +453,7 @@ export const narrowMoney = <Row>(columns: ColumnMap, row: Row): Row => {
402
453
  * once `narrowMoney` has returned, and `parseMinor` threw for anything that could not become one.
403
454
  */
404
455
  export const narrowRow = <Row>(columns: ColumnMap, values: RowWrite<Row>): Row =>
405
- narrowMoney(columns, values) as Row;
456
+ narrowUuid(columns, narrowMoney(columns, values)) as Row;
406
457
 
407
458
  /**
408
459
  * The CHECK that stops a psql session writing a currency the app would refuse — the app's own
@@ -78,12 +78,16 @@ export const arrayOverlaps = (left: readonly unknown[], right: readonly unknown[
78
78
  right.some((item) => left.some((candidate) => sameElement(candidate, item)));
79
79
 
80
80
  /**
81
- * `jsonb_exists(value, key)` — the function form of the `?` operator, which is what the SQL side
82
- * emits so a literal `?` can never be read as a parameter placeholder by anything on the way.
81
+ * The memory half of `col ? key`. The SQL side emits the OPERATOR, schema-qualified —
82
+ * `col operator(pg_catalog.?) $1` (`pg-sql.ts`) — and not `jsonb_exists(col, $1)`: the two are the
83
+ * same test and only the operator form is INDEXABLE, because an index is matched against an
84
+ * operator expression and a bare function call is not one. The function form shipped first, on a
85
+ * stated fear of `?` being read as a parameter placeholder; the qualification is what answers that
86
+ * fear, and it is also immune to a `search_path` that shadows the operator.
83
87
  *
84
88
  * Three shapes, all of them Postgres': a top-level key of an object, a string ELEMENT of an array,
85
- * and a string value equal to the key. A number never matches — `jsonb_exists('[1]', '1')` is
86
- * false there, and a `String(item) === key` here would have made it true.
89
+ * and a string value equal to the key. A number never matches — `'[1]'::jsonb ? '1'` is false
90
+ * there, and a `String(item) === key` here would have made it true.
87
91
  */
88
92
  export const jsonHasKey = (value: unknown, key: unknown): boolean => {
89
93
  if (typeof key !== 'string' || isNull(value)) return false;
package/src/cursor.ts CHANGED
@@ -7,10 +7,11 @@
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
+ import { columnFor, columnName } from './column';
11
11
  import type { EntityCore } from './entity';
12
12
  import { invariantViolated } from './errors';
13
- import { instantMicros } from './instant';
13
+ import { identifierBytes, MAX_IDENTIFIER_BYTES } from './index-name';
14
+ import { instantMicros, SEEK_ALIAS_SUFFIX, seekAlias } from './instant';
14
15
  import type { QueryPlan } from './tenancy';
15
16
  import type { AnyColumn, ColumnKind } from './types';
16
17
 
@@ -158,6 +159,38 @@ const reviveSortValue = (kind: ColumnKind, text: string): unknown => {
158
159
  }
159
160
  };
160
161
 
162
+ /**
163
+ * The one sort key that needs a SECOND output column, and therefore a second identifier: a
164
+ * `timestamptz` carries microseconds the decoded `Date` cannot hold, so the statement selects
165
+ * `<column>$US` beside it and `sortPrecision` (`pg-row.ts`) reads the exact instant back under
166
+ * that name.
167
+ *
168
+ * `assertColumnName` admits 63 bytes, which is the server's whole budget — so a column of 61 bytes
169
+ * produced a 64-byte alias, Postgres TRUNCATED it and said nothing, `sortPrecision` looked up a
170
+ * name the row does not carry, and `cursorFor` silently fell back to the millisecond `Date` this
171
+ * precision path exists to replace. The page then cuts at a position no row occupies and every row
172
+ * inside the boundary millisecond is served on no page at all — the exact defect `instant.ts` was
173
+ * written to close, reintroduced by a long column name.
174
+ *
175
+ * Refused where the PLAN is built rather than where the alias is read, for the reason the whole
176
+ * function exists: the read path has no error to raise, only a quieter answer. The bound is
177
+ * `index-name.ts`'s, never a second 63 — and the alias is BUILT rather than measured against a
178
+ * hard-coded suffix length, so changing the suffix cannot leave this arithmetic behind.
179
+ */
180
+ const assertAliasFits = <Row>(entity: EntityCore<Row>, path: string): void => {
181
+ const alias = seekAlias(columnName(partsOf(path).property, columnAt(entity, path).$meta));
182
+ const bytes = identifierBytes(alias);
183
+ if (bytes <= MAX_IDENTIFIER_BYTES) return;
184
+ throw invariantViolated(
185
+ entity.$name,
186
+ 'cursor',
187
+ `ordering by ${path} needs the output column "${alias}", which is ${bytes} bytes — ` +
188
+ `Postgres truncates past ${MAX_IDENTIFIER_BYTES} and says nothing, so the page would ` +
189
+ `silently lose microsecond precision: rename the column with ` +
190
+ `.column('<at most ${MAX_IDENTIFIER_BYTES - identifierBytes(SEEK_ALIAS_SUFFIX)} bytes>')`,
191
+ );
192
+ };
193
+
161
194
  /**
162
195
  * A keyset seek only has a total order when every sort column is present on every row —
163
196
  * `null > 'x'` is unknown in SQL and would drop rows from the middle of a listing.
@@ -176,7 +209,7 @@ export const assertSeekable = <Row>(
176
209
  for (const key of orderBy) {
177
210
  // Resolving the kind is the other half: it refuses a column the entity never declared and a
178
211
  // money property named without its part — both mint a cursor nothing can decode.
179
- kindAt(entity, key.column);
212
+ if (kindAt(entity, key.column) === 'timestamptz') assertAliasFits(entity, key.column);
180
213
  if (columnAt(entity, key.column).$meta.notNull) continue;
181
214
  // An ORDINARY nullable key is orderable, `As of 2026-08-24`: NULL has a declared place
182
215
  // (`asc nulls last` / `desc nulls first`), the cursor carries that place, and the seek reaches
package/src/index-name.ts CHANGED
@@ -52,9 +52,10 @@ const indexDiscriminator = (
52
52
  * DECLARED names because those still differ. Bytes and not characters: 63 is what the server
53
53
  * counts, and `.length` would stop seeing the truncation the moment a name is not ASCII.
54
54
  */
55
- const MAX_IDENTIFIER_BYTES = 63;
55
+ export const MAX_IDENTIFIER_BYTES = 63;
56
56
 
57
- const byteLength = (value: string): number => new TextEncoder().encode(value).length;
57
+ /** Bytes, never `.length`: the truncation the server performs is counted in bytes. */
58
+ export const identifierBytes = (value: string): number => new TextEncoder().encode(value).length;
58
59
 
59
60
  export const indexName = (
60
61
  entityName: string,
@@ -71,7 +72,7 @@ export const indexName = (
71
72
  const name = plain
72
73
  ? `${base}_${suffix}`
73
74
  : `${base}_${indexDiscriminator(order, where, using)}_${suffix}`;
74
- const bytes = byteLength(name);
75
+ const bytes = identifierBytes(name);
75
76
  if (bytes <= MAX_IDENTIFIER_BYTES) return name;
76
77
  throw invariantViolated(
77
78
  entityName,
package/src/instant.ts CHANGED
@@ -90,4 +90,7 @@ export const microsToIso = (micros: bigint): string => {
90
90
  * or a `.column()` name `assertColumnName` refuses unless it matches `[a-z_][a-z0-9_$]*` — so an
91
91
  * UPPER-CASE suffix is a name no entity can declare and this alias can never shadow a column.
92
92
  */
93
- export const seekAlias = (physicalColumn: string): string => `${physicalColumn}$US`;
93
+ export const SEEK_ALIAS_SUFFIX = '$US';
94
+
95
+ export const seekAlias = (physicalColumn: string): string =>
96
+ `${physicalColumn}${SEEK_ALIAS_SUFFIX}`;
@@ -16,6 +16,7 @@ import { countsFrom, groupColumnOf } from './count-by';
16
16
  import { cursorFor, kindOf, seekFrom, valueAt } from './cursor';
17
17
  import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
18
18
  import { notFound } from './errors';
19
+ import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
19
20
  import { compareByKind, matchesPredicate } from './memory-match';
20
21
  import { deletePlan, idPlan, readPlan, singleKeyOf, updatePlan } from './plan';
21
22
  import type { FindManyArgs, MemoryRepo, RepoOptions, Transactor, Tx } from './repo';
@@ -306,6 +307,15 @@ export const memoryRepo = <Row>(
306
307
  // `addressed()` — patching a row the app has already deleted is not an update, it is a
307
308
  // resurrection nobody asked for. `write` re-asserts the invariants on each result.
308
309
  const found = rowsOf(plan, {});
310
+ // The same ceiling `postgresRepo` applies from its own count, and for the same reason: this
311
+ // is the one write whose result size is the caller's FILTER rather than a page, and a rule
312
+ // only the app can judge is what forces every matched row through `$assert`. Without it a
313
+ // sweep of twelve million rows was green here and `X_INVARIANT_VIOLATED` there, from one
314
+ // call — a test proving a call production refuses. Before the loop, so a refusal never
315
+ // writes the rows it declined to judge.
316
+ if (hasJsOnlyInvariant(entity.$invariants) && found.length > MAX_ASSERTED_ROWS) {
317
+ throw assertedRowsTooMany(entity.$name, 'updateWhere', found.length);
318
+ }
309
319
  for (const row of found) write(Object.assign({}, row, patch), options, 'updateWhere');
310
320
  return found.length;
311
321
  },