@ultimat3/entity 19.2.0 → 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 +43 -21
- package/package.json +5 -5
- package/src/columns.ts +58 -7
- package/src/containment.ts +8 -4
- package/src/cursor.ts +36 -3
- package/src/index-name.ts +4 -3
- package/src/instant.ts +4 -1
- package/src/memory-repo.ts +10 -0
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`.
|
|
71
|
-
|
|
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.
|
|
75
|
-
|
|
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
|
|
347
|
-
this way.
|
|
348
|
-
|
|
349
|
-
|
|
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.
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
the
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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.
|
|
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.
|
|
35
|
-
"@ultimat3/db": "19.
|
|
36
|
-
"@ultimat3/schema": "19.
|
|
37
|
-
"@ultimat3/time": "19.
|
|
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
|
|
371
|
-
* kind
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
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
|
package/src/containment.ts
CHANGED
|
@@ -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
|
-
*
|
|
82
|
-
*
|
|
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 — `
|
|
86
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
|
93
|
+
export const SEEK_ALIAS_SUFFIX = '$US';
|
|
94
|
+
|
|
95
|
+
export const seekAlias = (physicalColumn: string): string =>
|
|
96
|
+
`${physicalColumn}${SEEK_ALIAS_SUFFIX}`;
|
package/src/memory-repo.ts
CHANGED
|
@@ -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
|
},
|