@quereus/store 4.3.1 → 4.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.
Files changed (44) hide show
  1. package/README.md +486 -436
  2. package/dist/src/common/bytes.d.ts +4 -1
  3. package/dist/src/common/bytes.d.ts.map +1 -1
  4. package/dist/src/common/bytes.js +23 -2
  5. package/dist/src/common/bytes.js.map +1 -1
  6. package/dist/src/common/cached-kv-store.d.ts.map +1 -1
  7. package/dist/src/common/cached-kv-store.js +6 -13
  8. package/dist/src/common/cached-kv-store.js.map +1 -1
  9. package/dist/src/common/encoding.d.ts +69 -26
  10. package/dist/src/common/encoding.d.ts.map +1 -1
  11. package/dist/src/common/encoding.js +292 -230
  12. package/dist/src/common/encoding.js.map +1 -1
  13. package/dist/src/common/index.d.ts +1 -1
  14. package/dist/src/common/index.d.ts.map +1 -1
  15. package/dist/src/common/index.js +1 -1
  16. package/dist/src/common/index.js.map +1 -1
  17. package/dist/src/common/key-builder.d.ts +5 -1
  18. package/dist/src/common/key-builder.d.ts.map +1 -1
  19. package/dist/src/common/key-builder.js +30 -2
  20. package/dist/src/common/key-builder.js.map +1 -1
  21. package/dist/src/common/kv-store.d.ts +1 -1
  22. package/dist/src/common/memory-store.d.ts.map +1 -1
  23. package/dist/src/common/memory-store.js +21 -6
  24. package/dist/src/common/memory-store.js.map +1 -1
  25. package/dist/src/common/serialization.d.ts.map +1 -1
  26. package/dist/src/common/serialization.js +6 -3
  27. package/dist/src/common/serialization.js.map +1 -1
  28. package/dist/src/common/store-module.d.ts +293 -26
  29. package/dist/src/common/store-module.d.ts.map +1 -1
  30. package/dist/src/common/store-module.js +1543 -612
  31. package/dist/src/common/store-module.js.map +1 -1
  32. package/dist/src/common/store-table.d.ts +515 -36
  33. package/dist/src/common/store-table.d.ts.map +1 -1
  34. package/dist/src/common/store-table.js +873 -100
  35. package/dist/src/common/store-table.js.map +1 -1
  36. package/dist/src/common/transaction.d.ts +8 -5
  37. package/dist/src/common/transaction.d.ts.map +1 -1
  38. package/dist/src/common/transaction.js +32 -5
  39. package/dist/src/common/transaction.js.map +1 -1
  40. package/dist/src/testing/kv-conformance.d.ts +47 -0
  41. package/dist/src/testing/kv-conformance.d.ts.map +1 -0
  42. package/dist/src/testing/kv-conformance.js +418 -0
  43. package/dist/src/testing/kv-conformance.js.map +1 -0
  44. package/package.json +17 -5
@@ -6,57 +6,72 @@
6
6
  *
7
7
  * Type prefixes ensure correct cross-type ordering:
8
8
  * 0x00 - NULL (sorts first)
9
- * 0x01 - INTEGER (signed, big-endian with sign flip)
10
- * 0x02 - REAL (IEEE 754 with sign flip)
9
+ * 0x01 - NUMERIC (bigint + number unified; orders by value across int/real)
11
10
  * 0x03 - TEXT (UTF-8, null-terminated, NOCASE by default)
12
- * 0x04 - BLOB (length-prefixed)
11
+ * 0x04 - BLOB (raw bytes, escaped + null-terminated)
13
12
  *
14
13
  * Collation support:
15
- * Collations can register a CollationEncoder to transform strings before
16
- * binary encoding, preserving their sort semantics in the key-value store.
17
- */
18
- /** Registry of collation encoders. */
19
- const collationEncoders = new Map();
20
- /**
21
- * Register a collation encoder.
22
- * @param name Collation name (case-insensitive)
23
- * @param encoder The encoder implementation
14
+ * A TEXT/OBJECT value's key bytes are produced by running the value through the
15
+ * collation's KEY NORMALIZER the `(s: string) => string` whose output equality
16
+ * partitions strings exactly as the collation's comparator does. Normalizers are
17
+ * resolved through `EncodeOptions.normalizers`, which callers holding a `Database`
18
+ * must set to `db.getKeyNormalizerResolver()` so key bytes and value comparisons
19
+ * agree on which strings are the same value.
20
+ *
21
+ * Unpaired surrogates:
22
+ * A TEXT value holding an unpaired surrogate (a half of a surrogate pair with no
23
+ * matching other half) is REJECTED here rather than encoded — see
24
+ * {@link findUnpairedSurrogate}. It is a legal JS string and a legal in-memory
25
+ * Quereus text value, but it is not valid Unicode and no UTF-8 byte sequence encodes
26
+ * it, so it has no faithful key bytes. This is the one deliberate behavioural
27
+ * divergence between a memory table (accepts the value) and a store-backed table
28
+ * (raises at encode time). The alternative — `TextEncoder`'s silent fold to U+FFFD —
29
+ * maps all 2048 lone surrogates onto one key, merging distinct rows.
24
30
  */
25
- export function registerCollationEncoder(name, encoder) {
26
- collationEncoders.set(name.toUpperCase(), encoder);
27
- }
31
+ import { canonicalJsonString, BUILTIN_NORMALIZERS, QuereusError, StatusCode } from '@quereus/quereus';
32
+ // ============================================================================
33
+ // Encoding Options
34
+ // ============================================================================
28
35
  /**
29
- * Get a registered collation encoder.
30
- * @param name Collation name (case-insensitive)
31
- * @returns The encoder, or undefined if not registered
36
+ * Built-ins-only key-normalizer resolver: the default when no `Database` is threaded to
37
+ * an encode call site. Knows exactly BINARY / NOCASE / RTRIM, with the engine's own
38
+ * normalizer functions (never a store-local copy a divergent RTRIM here would key rows
39
+ * the engine's `RTRIM_COLLATION` comparator calls distinct at identical bytes).
40
+ *
41
+ * Throws on any other name rather than falling back: guessing a normalizer would encode
42
+ * two comparator-distinct values to the same key, or split one value across two keys.
32
43
  */
33
- export function getCollationEncoder(name) {
34
- return collationEncoders.get(name.toUpperCase());
35
- }
36
- // Built-in collation encoders
37
- /** NOCASE: Lowercase for case-insensitive ordering (default). */
38
- const NOCASE_ENCODER = {
39
- encode: (value) => value.toLowerCase(),
40
- };
41
- /** BINARY: No transformation, native byte ordering. */
42
- const BINARY_ENCODER = {
43
- encode: (value) => value,
44
- };
45
- /** RTRIM: Trim trailing spaces before encoding. */
46
- const RTRIM_ENCODER = {
47
- encode: (value) => value.replace(/\s+$/, ''),
44
+ export const BUILTIN_KEY_NORMALIZER_RESOLVER = (collationName) => {
45
+ if (!collationName || collationName === 'BINARY')
46
+ return BUILTIN_NORMALIZERS.BINARY;
47
+ const normalizer = BUILTIN_NORMALIZERS[collationName.toUpperCase()];
48
+ if (!normalizer) {
49
+ throw new QuereusError(`no such collation sequence: ${collationName}`, StatusCode.ERROR);
50
+ }
51
+ return normalizer;
48
52
  };
49
- // Register built-in encoders
50
- registerCollationEncoder('NOCASE', NOCASE_ENCODER);
51
- registerCollationEncoder('BINARY', BINARY_ENCODER);
52
- registerCollationEncoder('RTRIM', RTRIM_ENCODER);
53
53
  /** Type prefix bytes. */
54
54
  const TYPE_NULL = 0x00;
55
- const TYPE_INTEGER = 0x01;
56
- const TYPE_REAL = 0x02;
55
+ /**
56
+ * Unified numeric tag for BOTH bigint (INTEGER) and number (REAL). A single tag
57
+ * is required so a whole number and a fractional number interleave by value:
58
+ * a per-shape INTEGER/REAL tag would sort every integer-shaped value before
59
+ * every real-shaped one (0x01 < 0x02), placing 3.0 below 2.5. See encodeNumeric.
60
+ */
61
+ const TYPE_NUMERIC = 0x01;
57
62
  const TYPE_TEXT = 0x03;
58
63
  const TYPE_BLOB = 0x04;
59
64
  const TYPE_OBJECT = 0x05;
65
+ /**
66
+ * Fixed width of a TYPE_NUMERIC key: tag + sortable double + signed tie-break.
67
+ *
68
+ * NOTE: this is ~2x the old 9-byte int/real key. The 8-byte tie-break tail is
69
+ * bulletproof but wider than needed — the residual `value - nearestDouble` for an
70
+ * int64 is bounded by ~2^11, so a 4-byte (int32) tail would suffice. If numeric-PK
71
+ * key size ever shows up as a storage/index-size problem, shrink the tail to 4
72
+ * bytes (keep it fixed-width so DESC bit-inversion stays trivially correct).
73
+ */
74
+ const NUMERIC_KEY_LENGTH = 17;
60
75
  /** Escape byte for null bytes within strings. */
61
76
  const ESCAPE_BYTE = 0x01;
62
77
  const NULL_BYTE = 0x00;
@@ -65,27 +80,27 @@ const NULL_BYTE = 0x00;
65
80
  */
66
81
  export function encodeValue(value, options) {
67
82
  const collation = options?.collation ?? 'NOCASE';
83
+ const normalizers = options?.normalizers ?? BUILTIN_KEY_NORMALIZER_RESOLVER;
68
84
  if (value === null) {
69
85
  return new Uint8Array([TYPE_NULL]);
70
86
  }
71
- if (typeof value === 'bigint' || (typeof value === 'number' && Number.isInteger(value))) {
72
- return encodeInteger(typeof value === 'bigint' ? value : BigInt(value));
73
- }
74
- if (typeof value === 'number') {
75
- return encodeReal(value);
87
+ if (typeof value === 'bigint' || typeof value === 'number') {
88
+ return encodeNumeric(value);
76
89
  }
77
90
  if (typeof value === 'string') {
78
- return encodeText(value, collation);
91
+ return encodeText(value, collation, normalizers);
79
92
  }
80
93
  if (value instanceof Uint8Array) {
81
94
  return encodeBlob(value);
82
95
  }
83
96
  if (typeof value === 'boolean') {
84
- return encodeInteger(value ? 1n : 0n);
97
+ return encodeNumeric(value ? 1n : 0n);
85
98
  }
86
- // JSON objects/arrays — serialize to JSON string and encode as text with OBJECT prefix
99
+ // JSON objects/arrays — serialize to a canonical (recursive object-key-sorted)
100
+ // JSON string so reorder-equal values ({a:1,b:2} vs {b:2,a:1}) encode to the
101
+ // same bytes, matching the in-memory JSON comparator. Arrays stay positional.
87
102
  if (typeof value === 'object') {
88
- return encodeObject(JSON.stringify(value), collation);
103
+ return encodeObject(canonicalJsonString(value), collation, normalizers);
89
104
  }
90
105
  throw new Error(`Cannot encode value of type ${typeof value}`);
91
106
  }
@@ -127,64 +142,87 @@ export function encodeCompositeKey(values, options, directions, collations) {
127
142
  return result;
128
143
  }
129
144
  /**
130
- * Encode an integer with sign-preserving byte ordering.
131
- * Uses big-endian with XOR on sign bit so negative < positive.
132
- */
133
- function encodeInteger(value) {
134
- const buffer = new Uint8Array(9);
135
- buffer[0] = TYPE_INTEGER;
136
- // Convert to 64-bit signed representation
137
- const view = new DataView(buffer.buffer);
138
- // Write as big-endian int64
139
- view.setBigInt64(1, value, false);
140
- // Flip sign bit so negative numbers sort before positive
141
- buffer[1] ^= 0x80;
142
- return buffer;
143
- }
144
- /**
145
- * Encode a floating-point number with proper sort ordering.
146
- * IEEE 754 with sign manipulation for correct ordering.
145
+ * Encode any numeric value bigint (INTEGER) OR number (REAL) — into ONE
146
+ * order-preserving key whose memcmp order matches `compareNumbers` (the
147
+ * in-memory NUMERIC comparator): every value orders by true magnitude across the
148
+ * int/real boundary, with full int64 precision preserved even where a large
149
+ * integer shares its nearest double with a neighbour.
150
+ *
151
+ * Layout (fixed 17 bytes so the DESC bit-inversion `encodeCompositeKey` applies
152
+ * stays trivially order-correct, exactly as the old fixed-width int/real bodies):
153
+ *
154
+ * [TYPE_NUMERIC][ 8-byte sortable double ][ 8-byte signed tie-break ]
155
+ *
156
+ * Primary 8 bytes: the sortable-double transform (IEEE-754 big-endian, all bits
157
+ * flipped for negatives / sign bit only for non-negatives) of the nearest double
158
+ * `p = Number(value)`. This alone orders every value correctly EXCEPT ties, and
159
+ * preserves `-Inf < … < +Inf < NaN`. Two distinct finite doubles never share a
160
+ * bit pattern, so the ONLY prefix collisions are among integers that round to the
161
+ * same double (a contiguous run of int64s past 2^53).
162
+ *
163
+ * Tie-break 8 bytes: the exact signed residual `offset = value - p` (big-endian
164
+ * with sign bit flipped, so negative < positive). Within a same-double tie-set
165
+ * the true order is integer order, which `offset` reproduces exactly — its
166
+ * magnitude is bounded by half the double's ulp (≤ ~2^11 for int64). A `number`
167
+ * is its own exact double (`p === value`), so its offset is always 0 and it never
168
+ * ties with anything.
169
+ *
170
+ * `-0` is normalized to `+0` so `-0`, `+0`, and `0n` collide to one key
171
+ * (`compareNumbers` treats them equal).
147
172
  */
148
- function encodeReal(value) {
149
- const buffer = new Uint8Array(9);
150
- buffer[0] = TYPE_REAL;
173
+ function encodeNumeric(value) {
174
+ const buffer = new Uint8Array(NUMERIC_KEY_LENGTH);
175
+ buffer[0] = TYPE_NUMERIC;
151
176
  const view = new DataView(buffer.buffer);
152
- view.setFloat64(1, value, false); // big-endian
153
- // Flip all bits for negative, just sign bit for positive
154
- // This makes: -Inf < -1 < -0 < +0 < +1 < +Inf < NaN
155
- if (value < 0 || Object.is(value, -0)) {
156
- for (let i = 1; i < 9; i++) {
177
+ // Nearest double + exact residual. A number is exact (offset 0); a bigint's
178
+ // nearest double is always integer-valued, so the residual is an exact integer.
179
+ let primary;
180
+ let offset;
181
+ if (typeof value === 'bigint') {
182
+ primary = Number(value);
183
+ offset = value - BigInt(primary);
184
+ }
185
+ else {
186
+ primary = Object.is(value, -0) ? 0 : value;
187
+ offset = 0n;
188
+ }
189
+ // Primary: sortable IEEE-754 double.
190
+ view.setFloat64(1, primary, false); // big-endian
191
+ if (primary < 0) {
192
+ // Negative: flip all bits so more-negative sorts first.
193
+ for (let i = 1; i < 9; i++)
157
194
  buffer[i] ^= 0xff;
158
- }
159
195
  }
160
196
  else {
197
+ // Non-negative (incl. +0, +Inf, NaN): flip only the sign bit.
161
198
  buffer[1] ^= 0x80;
162
199
  }
200
+ // Tie-break: signed int64 residual, big-endian with sign bit flipped.
201
+ view.setBigInt64(9, offset, false);
202
+ buffer[9] ^= 0x80;
163
203
  return buffer;
164
204
  }
165
205
  /**
166
- * Encode text with collation support.
167
- * Uses null-termination with escape sequences for embedded nulls.
206
+ * Encode raw bytes as an order-preserving, null-terminated sequence behind a
207
+ * type tag. Each 0x00 content byte becomes `0x01 0x01` and each 0x01 becomes
208
+ * `0x01 0x02`, then a single 0x00 terminator is appended. This preserves memcmp
209
+ * order for variable-length byte strings: the terminator (0x00) sorts below any
210
+ * escaped content continuation (which begins at 0x01, or a raw byte >= 0x02), so
211
+ * a proper prefix always sorts before its extensions, and the escape map is
212
+ * monotonic in the source byte. Shared by TEXT, OBJECT, and BLOB.
168
213
  */
169
- function encodeText(value, collation) {
170
- // Apply collation transformation via encoder registry
171
- const collationEncoder = getCollationEncoder(collation) ?? NOCASE_ENCODER;
172
- const sortValue = collationEncoder.encode(value);
173
- // Encode as UTF-8
174
- const encoder = new TextEncoder();
175
- const utf8 = encoder.encode(sortValue);
214
+ function writeEscapedWithTerminator(typeTag, bytes) {
176
215
  // Count bytes needing escape (null bytes and escape bytes)
177
216
  let escapeCount = 0;
178
- for (const byte of utf8) {
179
- if (byte === NULL_BYTE || byte === ESCAPE_BYTE) {
217
+ for (const byte of bytes) {
218
+ if (byte === NULL_BYTE || byte === ESCAPE_BYTE)
180
219
  escapeCount++;
181
- }
182
220
  }
183
221
  // Allocate: type prefix + escaped content + null terminator
184
- const result = new Uint8Array(1 + utf8.length + escapeCount + 1);
185
- result[0] = TYPE_TEXT;
222
+ const result = new Uint8Array(1 + bytes.length + escapeCount + 1);
223
+ result[0] = typeTag;
186
224
  let writePos = 1;
187
- for (const byte of utf8) {
225
+ for (const byte of bytes) {
188
226
  if (byte === NULL_BYTE) {
189
227
  result[writePos++] = ESCAPE_BYTE;
190
228
  result[writePos++] = 0x01; // Escaped null
@@ -201,66 +239,124 @@ function encodeText(value, collation) {
201
239
  return result;
202
240
  }
203
241
  /**
204
- * Encode a blob with length prefix.
205
- * Length is encoded as a variable-length integer for compact storage.
242
+ * Any surrogate code unit, paired or not. A cheap pre-test for
243
+ * {@link findUnpairedSurrogate}: virtually every real string fails it on V8's compiled-
244
+ * regex path, so the per-code-unit pairing scan never runs.
206
245
  */
207
- function encodeBlob(value) {
208
- const lengthBytes = encodeVarInt(value.length);
209
- const result = new Uint8Array(1 + lengthBytes.length + value.length);
210
- result[0] = TYPE_BLOB;
211
- result.set(lengthBytes, 1);
212
- result.set(value, 1 + lengthBytes.length);
213
- return result;
214
- }
246
+ const HAS_SURROGATE = /[\uD800-\uDFFF]/;
215
247
  /**
216
- * Encode a JSON object/array as text with TYPE_OBJECT prefix.
217
- * Uses the same encoding as TEXT for sort order (by JSON string representation).
248
+ * Offset of the first UNPAIRED surrogate code unit in `value`, or -1 when every surrogate
249
+ * it holds is the high half of a well-formed high+low pair (an astral character) or the
250
+ * low half of one.
251
+ *
252
+ * A JS string is a sequence of 16-bit code units, not of Unicode characters. A character
253
+ * above U+FFFF is a surrogate PAIR: a high unit (U+D800–U+DBFF) followed by a low unit
254
+ * (U+DC00–U+DFFF). A surrogate with no matching partner is legal in a JS string but is
255
+ * not valid Unicode — it denotes no character, and no UTF-8 byte sequence encodes it.
218
256
  */
219
- function encodeObject(jsonString, collation) {
220
- const collationEncoder = getCollationEncoder(collation) ?? NOCASE_ENCODER;
221
- const sortValue = collationEncoder.encode(jsonString);
222
- const encoder = new TextEncoder();
223
- const utf8 = encoder.encode(sortValue);
224
- let escapeCount = 0;
225
- for (const byte of utf8) {
226
- if (byte === NULL_BYTE || byte === ESCAPE_BYTE)
227
- escapeCount++;
257
+ export function findUnpairedSurrogate(value) {
258
+ if (!HAS_SURROGATE.test(value))
259
+ return -1;
260
+ for (let i = 0; i < value.length; i++) {
261
+ const unit = value.charCodeAt(i);
262
+ if (unit < 0xD800 || unit > 0xDFFF)
263
+ continue;
264
+ // A low surrogate reached here was not consumed as the tail of a pair below, so
265
+ // nothing precedes it — unpaired.
266
+ if (unit >= 0xDC00)
267
+ return i;
268
+ // High surrogate: well-formed only when a low surrogate follows.
269
+ const next = i + 1 < value.length ? value.charCodeAt(i + 1) : 0;
270
+ if (next < 0xDC00 || next > 0xDFFF)
271
+ return i;
272
+ i++; // Skip the pair's low half.
228
273
  }
229
- const result = new Uint8Array(1 + utf8.length + escapeCount + 1);
230
- result[0] = TYPE_OBJECT;
231
- let writePos = 1;
232
- for (const byte of utf8) {
233
- if (byte === NULL_BYTE) {
234
- result[writePos++] = ESCAPE_BYTE;
235
- result[writePos++] = 0x01;
236
- }
237
- else if (byte === ESCAPE_BYTE) {
238
- result[writePos++] = ESCAPE_BYTE;
239
- result[writePos++] = 0x02;
240
- }
241
- else {
242
- result[writePos++] = byte;
243
- }
244
- }
245
- result[writePos] = NULL_BYTE;
246
- return result;
274
+ return -1;
247
275
  }
248
276
  /**
249
- * Encode an unsigned integer as a variable-length byte sequence.
250
- * Uses high bit continuation: 1xxxxxxx means more bytes follow.
277
+ * Raise on a string the store cannot key (or persist as schema text) faithfully.
278
+ *
279
+ * `TextEncoder` replaces EVERY unpaired surrogate with U+FFFD (bytes `EF BF BD`), so all
280
+ * 2048 of them would encode to the same three bytes: two distinct text values would share
281
+ * one primary-key / index-key byte string (producing a spurious `UNIQUE` violation or —
282
+ * worse — an upsert that overwrites an unrelated row), and two distinct identifiers or DDL
283
+ * texts would share one catalog key or be silently mangled on write. Refusing the value is
284
+ * the only answer that never merges rows or corrupts persisted text.
285
+ *
286
+ * Runs on whatever string is ABOUT to be encoded — for a TEXT value that is the normalized
287
+ * string (a custom key normalizer that slices a string can itself split a surrogate pair,
288
+ * so the reported offset is into the normalizer's output; for BINARY and the case-folding
289
+ * built-ins it is the caller's own offset). `describe` names what's being validated, so the
290
+ * message reads naturally for a TEXT value, an identifier, or a block of persisted DDL text.
291
+ *
292
+ * NOTE: a store written BEFORE this guard existed may hold rows whose text key was folded
293
+ * to U+FFFD. They still scan (decoding to `'�'`), but no lone-surrogate literal can
294
+ * address them any more — the encode raises first. If pre-guard stores ever have to be
295
+ * opened, a one-time migration must rewrite or reject those rows; nothing detects them today.
251
296
  */
252
- function encodeVarInt(value) {
253
- if (value < 0)
254
- throw new Error('VarInt must be non-negative');
255
- const bytes = [];
256
- do {
257
- let byte = value & 0x7f;
258
- value >>>= 7;
259
- if (value > 0)
260
- byte |= 0x80;
261
- bytes.push(byte);
262
- } while (value > 0);
263
- return new Uint8Array(bytes);
297
+ export function assertNoUnpairedSurrogate(value, describe) {
298
+ const at = findUnpairedSurrogate(value);
299
+ if (at < 0)
300
+ return;
301
+ const codeUnit = value.charCodeAt(at).toString(16).toUpperCase().padStart(4, '0');
302
+ throw new QuereusError(`cannot store ${describe} containing an unpaired surrogate (U+${codeUnit} at offset ${at}): ` +
303
+ `persistent storage keys text by its UTF-8 bytes, and no UTF-8 sequence encodes a lone ` +
304
+ `surrogate every one of them would collide on U+FFFD, merging distinct rows. ` +
305
+ `In-memory tables accept the value.`, StatusCode.ERROR);
306
+ }
307
+ /**
308
+ * Encode text with collation support.
309
+ * Uses null-termination with escape sequences for embedded nulls.
310
+ *
311
+ * `normalizers` resolves the collation to its key normalizer and RAISES on a name it
312
+ * cannot key (unregistered, or comparator-only). There is deliberately no fallback:
313
+ * silently keying under a different collation's normalizer is exactly how two values
314
+ * the database's comparator calls equal end up at two distinct primary keys.
315
+ *
316
+ * Raises on an unpaired surrogate — see {@link assertNoUnpairedSurrogate}. This is the point
317
+ * at which a store-backed table diverges from a memory table, and it is deliberate: the
318
+ * only alternative encoding of a lone surrogate collides distinct values onto one key.
319
+ */
320
+ function encodeText(value, collation, normalizers) {
321
+ const sortValue = normalizers(collation)(value);
322
+ assertNoUnpairedSurrogate(sortValue, 'a text value');
323
+ // Encode as UTF-8
324
+ const utf8 = new TextEncoder().encode(sortValue);
325
+ return writeEscapedWithTerminator(TYPE_TEXT, utf8);
326
+ }
327
+ /**
328
+ * Encode a blob so its stored bytes sort element-by-element (matching SQL blob
329
+ * comparison). Emits the raw content bytes through the shared escape + 0x00
330
+ * terminator scheme — a blob is already raw bytes, so there is no collation or
331
+ * UTF-8 step. (The prior length-prefix layout sorted a shorter blob before a
332
+ * longer one regardless of content, which broke leading-PK range seeks.)
333
+ */
334
+ function encodeBlob(value) {
335
+ return writeEscapedWithTerminator(TYPE_BLOB, value);
336
+ }
337
+ /**
338
+ * Encode a JSON object/array as text with TYPE_OBJECT prefix.
339
+ * Uses the same encoding as TEXT for sort order (by JSON string representation).
340
+ *
341
+ * NOTE: the collation normalizer runs over the CANONICAL JSON STRING, not over the
342
+ * object's text leaves — under the default NOCASE that already lowercases object keys
343
+ * and string values inside the key bytes, and a normalizer that reorders or deletes
344
+ * characters can leave a string `decodeObject` cannot `JSON.parse`. Latent today:
345
+ * nothing in the row path decodes an object key (rows are serialized separately, and
346
+ * `decodeCompositeKey` has no `src/` caller). If an object-valued key ever has to be
347
+ * decoded, normalize the leaves before canonicalization rather than the string after.
348
+ *
349
+ * NOTE: no unpaired-surrogate guard here, unlike `encodeText`. `canonicalJsonString` ends
350
+ * in `JSON.stringify`, which is well-formed (ES2019): it escapes every lone surrogate to
351
+ * the seven ASCII characters `\ud800`, so the canonical string is always valid Unicode and
352
+ * its UTF-8 bytes stay injective. If the canonicalizer ever stops routing through
353
+ * `JSON.stringify` (or gains a `rawJSON` passthrough), that escaping is lost and this must
354
+ * call `assertNoUnpairedSurrogate` too.
355
+ */
356
+ function encodeObject(jsonString, collation, normalizers) {
357
+ const sortValue = normalizers(collation)(jsonString);
358
+ const utf8 = new TextEncoder().encode(sortValue);
359
+ return writeEscapedWithTerminator(TYPE_OBJECT, utf8);
264
360
  }
265
361
  // ============================================================================
266
362
  // Decoding functions
@@ -277,10 +373,8 @@ export function decodeValue(buffer, offset = 0, options) {
277
373
  switch (typePrefix) {
278
374
  case TYPE_NULL:
279
375
  return { value: null, bytesRead: 1 };
280
- case TYPE_INTEGER:
281
- return decodeInteger(buffer, offset);
282
- case TYPE_REAL:
283
- return decodeReal(buffer, offset);
376
+ case TYPE_NUMERIC:
377
+ return decodeNumeric(buffer, offset);
284
378
  case TYPE_TEXT:
285
379
  return decodeText(buffer, offset, options?.collation ?? 'NOCASE');
286
380
  case TYPE_BLOB:
@@ -307,40 +401,53 @@ export function decodeCompositeKey(buffer, expectedCount, options) {
307
401
  }
308
402
  return values;
309
403
  }
310
- function decodeInteger(buffer, offset) {
311
- if (offset + 9 > buffer.length) {
312
- throw new Error('Buffer underflow: expected 9 bytes for INTEGER');
313
- }
314
- // Copy and flip sign bit back
315
- const copy = buffer.slice(offset + 1, offset + 9);
316
- copy[0] ^= 0x80;
317
- const view = new DataView(copy.buffer, copy.byteOffset, 8);
318
- const value = view.getBigInt64(0, false);
319
- return { value, bytesRead: 9 };
320
- }
321
- function decodeReal(buffer, offset) {
322
- if (offset + 9 > buffer.length) {
323
- throw new Error('Buffer underflow: expected 9 bytes for REAL');
404
+ /**
405
+ * Decode a {@link encodeNumeric} key: reconstruct the primary double and the
406
+ * signed residual, then return the exact value. Integer-valued results return a
407
+ * `bigint`, non-integers a `number` — matching the pre-existing decode contract
408
+ * (integer-valued reals like `0.0` encode/roundtrip to `0n`). Every downstream
409
+ * comparator is numeric-class-tolerant (`5n` equals `5.0`), so the bigint/number
410
+ * choice never affects correctness.
411
+ */
412
+ function decodeNumeric(buffer, offset) {
413
+ if (offset + NUMERIC_KEY_LENGTH > buffer.length) {
414
+ throw new Error(`Buffer underflow: expected ${NUMERIC_KEY_LENGTH} bytes for NUMERIC`);
324
415
  }
325
- const copy = buffer.slice(offset + 1, offset + 9);
326
- // Check sign bit (before any flipping)
327
- const isNegative = (buffer[offset + 1] & 0x80) === 0;
328
- if (isNegative) {
329
- // Was negative: flip all bits back
330
- for (let i = 0; i < 8; i++) {
331
- copy[i] ^= 0xff;
332
- }
416
+ // Primary double: reverse the sortable-double sign manipulation. The encoder
417
+ // sets the top bit to 1 for non-negatives, so a cleared top bit ⇒ was negative.
418
+ const primaryBytes = buffer.slice(offset + 1, offset + 9);
419
+ const wasNegative = (buffer[offset + 1] & 0x80) === 0;
420
+ if (wasNegative) {
421
+ for (let i = 0; i < 8; i++)
422
+ primaryBytes[i] ^= 0xff;
333
423
  }
334
424
  else {
335
- // Was positive: flip just sign bit back
336
- copy[0] ^= 0x80;
425
+ primaryBytes[0] ^= 0x80;
426
+ }
427
+ const primary = new DataView(primaryBytes.buffer, primaryBytes.byteOffset, 8).getFloat64(0, false);
428
+ // Tie-break residual: reverse the sign-bit flip.
429
+ const offsetBytes = buffer.slice(offset + 9, offset + NUMERIC_KEY_LENGTH);
430
+ offsetBytes[0] ^= 0x80;
431
+ const residual = new DataView(offsetBytes.buffer, offsetBytes.byteOffset, 8).getBigInt64(0, false);
432
+ if (residual === 0n) {
433
+ // Exact value == primary double. Integer-valued (incl. large whole reals) ⇒
434
+ // bigint; fractional / non-finite ⇒ number.
435
+ return Number.isInteger(primary)
436
+ ? { value: BigInt(primary), bytesRead: NUMERIC_KEY_LENGTH }
437
+ : { value: primary, bytesRead: NUMERIC_KEY_LENGTH };
337
438
  }
338
- const view = new DataView(copy.buffer, copy.byteOffset, 8);
339
- const value = view.getFloat64(0, false);
340
- return { value, bytesRead: 9 };
439
+ // Non-zero residual a large integer whose nearest double is `primary` (always
440
+ // integer-valued here). Reconstruct the exact int64: primary + residual.
441
+ return { value: BigInt(primary) + residual, bytesRead: NUMERIC_KEY_LENGTH };
341
442
  }
342
- function decodeText(buffer, offset, _collation) {
343
- // Find null terminator, handling escapes
443
+ /**
444
+ * Decode a null-terminated escaped byte sequence written by
445
+ * {@link writeEscapedWithTerminator}, starting at the type byte at `offset`.
446
+ * Un-escapes `0x01 0x01` -> 0x00 and `0x01 0x02` -> 0x01, stops at the 0x00
447
+ * terminator, and returns the content bytes plus total bytes consumed (type tag
448
+ * and terminator included). Shared by TEXT, OBJECT, and BLOB.
449
+ */
450
+ function readEscapedUntilTerminator(buffer, offset) {
344
451
  const bytes = [];
345
452
  let i = offset + 1;
346
453
  while (i < buffer.length) {
@@ -370,68 +477,23 @@ function decodeText(buffer, offset, _collation) {
370
477
  i++;
371
478
  }
372
479
  }
373
- const decoder = new TextDecoder();
374
- const value = decoder.decode(new Uint8Array(bytes));
480
+ return { bytes: new Uint8Array(bytes), bytesRead: i - offset };
481
+ }
482
+ function decodeText(buffer, offset, _collation) {
483
+ const { bytes, bytesRead } = readEscapedUntilTerminator(buffer, offset);
484
+ const value = new TextDecoder().decode(bytes);
375
485
  // Note: We return the lowercase version if NOCASE was used during encoding.
376
486
  // The original case is preserved in the row value, not the key.
377
- return { value, bytesRead: i - offset };
487
+ return { value, bytesRead };
378
488
  }
379
489
  function decodeBlob(buffer, offset) {
380
- const { value: length, bytesRead: lengthBytes } = decodeVarInt(buffer, offset + 1);
381
- const dataStart = offset + 1 + lengthBytes;
382
- if (dataStart + length > buffer.length) {
383
- throw new Error('Buffer underflow: BLOB data truncated');
384
- }
385
- const value = buffer.slice(dataStart, dataStart + length);
386
- return { value, bytesRead: 1 + lengthBytes + length };
490
+ const { bytes, bytesRead } = readEscapedUntilTerminator(buffer, offset);
491
+ return { value: bytes, bytesRead };
387
492
  }
388
493
  function decodeObject(buffer, offset) {
389
- // Decode like TEXT (null-terminated with escapes) then parse JSON
390
- const bytes = [];
391
- let i = offset + 1;
392
- while (i < buffer.length) {
393
- const byte = buffer[i];
394
- if (byte === NULL_BYTE) {
395
- i++;
396
- break;
397
- }
398
- if (byte === ESCAPE_BYTE && i + 1 < buffer.length) {
399
- const next = buffer[i + 1];
400
- if (next === 0x01) {
401
- bytes.push(NULL_BYTE);
402
- i += 2;
403
- }
404
- else if (next === 0x02) {
405
- bytes.push(ESCAPE_BYTE);
406
- i += 2;
407
- }
408
- else {
409
- bytes.push(byte);
410
- i++;
411
- }
412
- }
413
- else {
414
- bytes.push(byte);
415
- i++;
416
- }
417
- }
418
- const decoder = new TextDecoder();
419
- const jsonString = decoder.decode(new Uint8Array(bytes));
494
+ const { bytes, bytesRead } = readEscapedUntilTerminator(buffer, offset);
495
+ const jsonString = new TextDecoder().decode(bytes);
420
496
  const value = JSON.parse(jsonString);
421
- return { value, bytesRead: i - offset };
422
- }
423
- function decodeVarInt(buffer, offset) {
424
- let value = 0;
425
- let shift = 0;
426
- let bytesRead = 0;
427
- while (offset + bytesRead < buffer.length) {
428
- const byte = buffer[offset + bytesRead];
429
- bytesRead++;
430
- value |= (byte & 0x7f) << shift;
431
- if ((byte & 0x80) === 0)
432
- break;
433
- shift += 7;
434
- }
435
497
  return { value, bytesRead };
436
498
  }
437
499
  //# sourceMappingURL=encoding.js.map