@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.
- package/README.md +486 -436
- package/dist/src/common/bytes.d.ts +4 -1
- package/dist/src/common/bytes.d.ts.map +1 -1
- package/dist/src/common/bytes.js +23 -2
- package/dist/src/common/bytes.js.map +1 -1
- package/dist/src/common/cached-kv-store.d.ts.map +1 -1
- package/dist/src/common/cached-kv-store.js +6 -13
- package/dist/src/common/cached-kv-store.js.map +1 -1
- package/dist/src/common/encoding.d.ts +69 -26
- package/dist/src/common/encoding.d.ts.map +1 -1
- package/dist/src/common/encoding.js +292 -230
- package/dist/src/common/encoding.js.map +1 -1
- package/dist/src/common/index.d.ts +1 -1
- package/dist/src/common/index.d.ts.map +1 -1
- package/dist/src/common/index.js +1 -1
- package/dist/src/common/index.js.map +1 -1
- package/dist/src/common/key-builder.d.ts +5 -1
- package/dist/src/common/key-builder.d.ts.map +1 -1
- package/dist/src/common/key-builder.js +30 -2
- package/dist/src/common/key-builder.js.map +1 -1
- package/dist/src/common/kv-store.d.ts +1 -1
- package/dist/src/common/memory-store.d.ts.map +1 -1
- package/dist/src/common/memory-store.js +21 -6
- package/dist/src/common/memory-store.js.map +1 -1
- package/dist/src/common/serialization.d.ts.map +1 -1
- package/dist/src/common/serialization.js +6 -3
- package/dist/src/common/serialization.js.map +1 -1
- package/dist/src/common/store-module.d.ts +293 -26
- package/dist/src/common/store-module.d.ts.map +1 -1
- package/dist/src/common/store-module.js +1543 -612
- package/dist/src/common/store-module.js.map +1 -1
- package/dist/src/common/store-table.d.ts +515 -36
- package/dist/src/common/store-table.d.ts.map +1 -1
- package/dist/src/common/store-table.js +873 -100
- package/dist/src/common/store-table.js.map +1 -1
- package/dist/src/common/transaction.d.ts +8 -5
- package/dist/src/common/transaction.d.ts.map +1 -1
- package/dist/src/common/transaction.js +32 -5
- package/dist/src/common/transaction.js.map +1 -1
- package/dist/src/testing/kv-conformance.d.ts +47 -0
- package/dist/src/testing/kv-conformance.d.ts.map +1 -0
- package/dist/src/testing/kv-conformance.js +418 -0
- package/dist/src/testing/kv-conformance.js.map +1 -0
- 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 -
|
|
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 (
|
|
11
|
+
* 0x04 - BLOB (raw bytes, escaped + null-terminated)
|
|
13
12
|
*
|
|
14
13
|
* Collation support:
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
import { canonicalJsonString, BUILTIN_NORMALIZERS, QuereusError, StatusCode } from '@quereus/quereus';
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// Encoding Options
|
|
34
|
+
// ============================================================================
|
|
28
35
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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' ||
|
|
72
|
-
return
|
|
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
|
|
97
|
+
return encodeNumeric(value ? 1n : 0n);
|
|
85
98
|
}
|
|
86
|
-
// JSON objects/arrays — serialize to
|
|
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(
|
|
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
|
|
131
|
-
*
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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
|
|
149
|
-
const buffer = new Uint8Array(
|
|
150
|
-
buffer[0] =
|
|
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
|
-
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
167
|
-
*
|
|
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
|
|
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
|
|
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 +
|
|
185
|
-
result[0] =
|
|
222
|
+
const result = new Uint8Array(1 + bytes.length + escapeCount + 1);
|
|
223
|
+
result[0] = typeTag;
|
|
186
224
|
let writePos = 1;
|
|
187
|
-
for (const byte of
|
|
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
|
-
*
|
|
205
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
217
|
-
*
|
|
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
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
250
|
-
*
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
281
|
-
return
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
326
|
-
//
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
for (let i = 0; i < 8; i++)
|
|
331
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
return { value, bytesRead:
|
|
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
|
-
|
|
343
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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
|
|
487
|
+
return { value, bytesRead };
|
|
378
488
|
}
|
|
379
489
|
function decodeBlob(buffer, offset) {
|
|
380
|
-
const {
|
|
381
|
-
|
|
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
|
-
|
|
390
|
-
const
|
|
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
|