@carno.js/live 1.8.0 → 1.8.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/dist/LiveEngine.d.ts +14 -0
- package/dist/LiveEngine.js +73 -8
- package/dist/LivePlugin.d.ts +9 -0
- package/dist/LivePlugin.js +12 -0
- package/dist/config.d.ts +12 -1
- package/dist/config.js +1 -0
- package/dist/graph/DependencyGraph.d.ts +25 -0
- package/dist/graph/DependencyGraph.js +65 -6
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -1
- package/dist/resource/ResourceRegistry.d.ts +3 -0
- package/dist/resource/ResourceRegistry.js +6 -0
- package/dist/scope-warning.d.ts +28 -0
- package/dist/scope-warning.js +48 -0
- package/dist/shared/canonical.d.ts +24 -0
- package/dist/shared/canonical.js +209 -19
- package/package.json +2 -2
- package/src/LiveEngine.ts +806 -730
- package/src/LivePlugin.ts +276 -253
- package/src/config.ts +61 -49
- package/src/graph/DependencyGraph.ts +223 -147
- package/src/index.ts +83 -81
- package/src/resource/ResourceRegistry.ts +185 -178
- package/src/scope-warning.ts +58 -0
- package/src/shared/canonical.ts +301 -63
- package/test/canonical-equivalence.test.ts +290 -0
- package/test/dependency-graph-index.test.ts +206 -0
- package/test/recompute-concurrency.test.ts +183 -0
- package/test/scope-warning.test.ts +157 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/shared/canonical.ts
CHANGED
|
@@ -1,63 +1,301 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Deterministic JSON canonicalization, shared verbatim by client and server.
|
|
3
|
-
*
|
|
4
|
-
* Both sides MUST produce byte-identical output for the same logical value:
|
|
5
|
-
* the instance id and the content hash are derived from it, so a divergence
|
|
6
|
-
* silently breaks subscription dedupe and the hydration handshake instead of
|
|
7
|
-
* failing loudly.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic JSON canonicalization, shared verbatim by client and server.
|
|
3
|
+
*
|
|
4
|
+
* Both sides MUST produce byte-identical output for the same logical value:
|
|
5
|
+
* the instance id and the content hash are derived from it, so a divergence
|
|
6
|
+
* silently breaks subscription dedupe and the hydration handshake instead of
|
|
7
|
+
* failing loudly.
|
|
8
|
+
*
|
|
9
|
+
* It also runs on every recompute, over the whole payload, which makes it the
|
|
10
|
+
* most expensive step of a recompute that changes nothing. Building the string
|
|
11
|
+
* in JavaScript loses to `JSON.stringify` by roughly an order of magnitude, so
|
|
12
|
+
* the common case does not build it: one walk normalizes the value into the
|
|
13
|
+
* shape `JSON.stringify` would already render canonically -- keys sorted,
|
|
14
|
+
* nothing unserializable left in it -- and the native serializer does the rest.
|
|
15
|
+
*
|
|
16
|
+
* What that walk has to do, and `JSON.stringify` cannot:
|
|
17
|
+
*
|
|
18
|
+
* - order object keys, which is the whole point;
|
|
19
|
+
* - refuse values with no agreed wire form, which `JSON.stringify` accepts
|
|
20
|
+
* silently (a Date becomes a string, a Map becomes `{}`, a NaN becomes null).
|
|
21
|
+
*
|
|
22
|
+
* What it deliberately leaves to `JSON.stringify`, which already agrees:
|
|
23
|
+
* string escaping, number formatting including negative zero, dropping
|
|
24
|
+
* undefined properties, and rendering an array hole as null.
|
|
25
|
+
*
|
|
26
|
+
* The escape hatch is `writeCanonical` below. A JavaScript object cannot hold
|
|
27
|
+
* an integer-like key anywhere but the front -- `{ '': 1, '1': 2 }` always
|
|
28
|
+
* enumerates as `1` then `''` -- so an object carrying one cannot be emitted
|
|
29
|
+
* in lexicographic order at all. Those values fall back to building the string
|
|
30
|
+
* here, where the order is ours to choose. Both paths are held to the same
|
|
31
|
+
* output by the differential test.
|
|
32
|
+
*/
|
|
33
|
+
export class NonSerializableInputError extends Error {
|
|
34
|
+
constructor(
|
|
35
|
+
public readonly path: string,
|
|
36
|
+
public readonly received: string
|
|
37
|
+
) {
|
|
38
|
+
super(`Live input at "${path}" is not serializable (received ${received}).`);
|
|
39
|
+
this.name = 'NonSerializableInputError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Thrown by the fast path to hand the value to `writeCanonical` instead. */
|
|
44
|
+
const INDEX_KEY = Symbol('carno:live:index-key');
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The last key list seen, with its sorted form.
|
|
48
|
+
*
|
|
49
|
+
* Every row of a collection carries the same keys in the same order, so one
|
|
50
|
+
* sort serves the whole list: comparing the key arrays element-wise is a run
|
|
51
|
+
* of interned-string pointer comparisons, far cheaper than sorting again. It
|
|
52
|
+
* is a memo and nothing else -- a miss costs a sort, never a wrong answer --
|
|
53
|
+
* and the pair is replaced in a single assignment so a reentrant walk can
|
|
54
|
+
* never observe keys from one object beside the sorted keys of another.
|
|
55
|
+
*/
|
|
56
|
+
let shape: { keys: string[]; sorted: string[] } = { keys: [], sorted: [] };
|
|
57
|
+
|
|
58
|
+
function sortedKeysOf(value: object): string[] {
|
|
59
|
+
const keys = Object.keys(value);
|
|
60
|
+
const memo = shape;
|
|
61
|
+
|
|
62
|
+
if (keys.length === memo.keys.length) {
|
|
63
|
+
let same = true;
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < keys.length; i++) {
|
|
66
|
+
if (keys[i] !== memo.keys[i]) {
|
|
67
|
+
same = false;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (same) {
|
|
73
|
+
return memo.sorted;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const sorted = keys.slice().sort();
|
|
78
|
+
shape = { keys, sorted };
|
|
79
|
+
|
|
80
|
+
return sorted;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* True for any key an object might reorder: every array index starts with a
|
|
85
|
+
* digit. Deliberately coarse -- `'1abc'` is not an index but is treated as
|
|
86
|
+
* one -- because the only cost of a false positive is taking the slow path,
|
|
87
|
+
* and the check runs once per key on the hot path. An empty key yields NaN,
|
|
88
|
+
* which fails both comparisons, and is correctly not an index.
|
|
89
|
+
*/
|
|
90
|
+
function mayReorder(key: string): boolean {
|
|
91
|
+
const first = key.charCodeAt(0);
|
|
92
|
+
|
|
93
|
+
return first >= 48 && first <= 57;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Assemble `$.filters[0].since` from the walk's segment stack, on throw only. */
|
|
97
|
+
function pathOf(root: string, segments: (string | number)[]): string {
|
|
98
|
+
let path = root;
|
|
99
|
+
|
|
100
|
+
for (const segment of segments) {
|
|
101
|
+
path += typeof segment === 'number' ? `[${segment}]` : `.${segment}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return path;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function canonical(value: unknown, path: string = '$'): string {
|
|
108
|
+
try {
|
|
109
|
+
// `normalize` never yields undefined, so this never yields undefined.
|
|
110
|
+
return JSON.stringify(normalize(value, [], path)) as string;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error !== INDEX_KEY) {
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const chunks: string[] = [];
|
|
118
|
+
writeCanonical(value, chunks, [], path);
|
|
119
|
+
|
|
120
|
+
return chunks.join('');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Rebuild `value` as the equivalent JSON value with every object's keys in
|
|
125
|
+
* sorted order, rejecting anything that has no canonical wire form.
|
|
126
|
+
*/
|
|
127
|
+
function normalize(
|
|
128
|
+
value: unknown,
|
|
129
|
+
segments: (string | number)[],
|
|
130
|
+
root: string
|
|
131
|
+
): unknown {
|
|
132
|
+
if (value === null || value === undefined) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
switch (typeof value) {
|
|
137
|
+
case 'boolean':
|
|
138
|
+
case 'string':
|
|
139
|
+
return value;
|
|
140
|
+
case 'number':
|
|
141
|
+
if (!Number.isFinite(value)) {
|
|
142
|
+
throw new NonSerializableInputError(pathOf(root, segments), String(value));
|
|
143
|
+
}
|
|
144
|
+
// Negative zero needs no special case: JSON.stringify renders it
|
|
145
|
+
// as `0`, which is what a query means by it.
|
|
146
|
+
return value;
|
|
147
|
+
case 'bigint':
|
|
148
|
+
case 'function':
|
|
149
|
+
case 'symbol':
|
|
150
|
+
throw new NonSerializableInputError(pathOf(root, segments), typeof value);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (Array.isArray(value)) {
|
|
154
|
+
const out = new Array(value.length);
|
|
155
|
+
|
|
156
|
+
for (let index = 0; index < value.length; index++) {
|
|
157
|
+
segments.push(index);
|
|
158
|
+
// Reading by index turns a hole into undefined, and so into null,
|
|
159
|
+
// which is what JSON.stringify would have rendered for the hole.
|
|
160
|
+
out[index] = normalize(value[index], segments, root);
|
|
161
|
+
segments.pop();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const proto = Object.getPrototypeOf(value);
|
|
168
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
169
|
+
// Date, Map, Set, class instances: no agreed wire form, so refuse
|
|
170
|
+
// rather than guess one the client would canonicalize differently.
|
|
171
|
+
const name = (value as object).constructor?.name ?? 'object';
|
|
172
|
+
throw new NonSerializableInputError(pathOf(root, segments), name);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const keys = sortedKeysOf(value as object);
|
|
176
|
+
// A literal, so the result never carries a prototype, a toJSON, or the
|
|
177
|
+
// insertion order of the value it came from.
|
|
178
|
+
const out: Record<string, unknown> = {};
|
|
179
|
+
|
|
180
|
+
for (let i = 0; i < keys.length; i++) {
|
|
181
|
+
const key = keys[i];
|
|
182
|
+
|
|
183
|
+
if (mayReorder(key)) {
|
|
184
|
+
throw INDEX_KEY;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const item = (value as Record<string, unknown>)[key];
|
|
188
|
+
|
|
189
|
+
if (item === undefined) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
segments.push(key);
|
|
194
|
+
out[key] = normalize(item, segments, root);
|
|
195
|
+
segments.pop();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Printable ASCII minus the only two characters JSON escapes in that range:
|
|
203
|
+
* `"` (0x22) and `\` (0x5c). A string that matches needs no escaping at all,
|
|
204
|
+
* so it can be quoted by concatenation. Anything else -- control characters,
|
|
205
|
+
* quotes, backslashes, non-ASCII, lone surrogates -- falls back to
|
|
206
|
+
* `JSON.stringify`, which keeps the output exact rather than merely fast.
|
|
207
|
+
*/
|
|
208
|
+
const NO_ESCAPES = /^[\x20-\x21\x23-\x5b\x5d-\x7e]*$/;
|
|
209
|
+
|
|
210
|
+
function quote(value: string): string {
|
|
211
|
+
return NO_ESCAPES.test(value) ? `"${value}"` : JSON.stringify(value);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The order-preserving path, for values `normalize` cannot express.
|
|
216
|
+
*
|
|
217
|
+
* Held to the same output as the fast path by the differential test, which is
|
|
218
|
+
* what keeps the two from drifting apart.
|
|
219
|
+
*/
|
|
220
|
+
function writeCanonical(
|
|
221
|
+
value: unknown,
|
|
222
|
+
chunks: string[],
|
|
223
|
+
segments: (string | number)[],
|
|
224
|
+
root: string
|
|
225
|
+
): void {
|
|
226
|
+
if (value === null || value === undefined) {
|
|
227
|
+
chunks.push('null');
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
switch (typeof value) {
|
|
232
|
+
case 'boolean':
|
|
233
|
+
chunks.push(value ? 'true' : 'false');
|
|
234
|
+
return;
|
|
235
|
+
case 'number':
|
|
236
|
+
if (!Number.isFinite(value)) {
|
|
237
|
+
throw new NonSerializableInputError(pathOf(root, segments), String(value));
|
|
238
|
+
}
|
|
239
|
+
// -0 and 0 are the same input as far as a query is concerned.
|
|
240
|
+
chunks.push(Object.is(value, -0) ? '0' : String(value));
|
|
241
|
+
return;
|
|
242
|
+
case 'string':
|
|
243
|
+
chunks.push(quote(value));
|
|
244
|
+
return;
|
|
245
|
+
case 'bigint':
|
|
246
|
+
case 'function':
|
|
247
|
+
case 'symbol':
|
|
248
|
+
throw new NonSerializableInputError(pathOf(root, segments), typeof value);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (Array.isArray(value)) {
|
|
252
|
+
chunks.push('[');
|
|
253
|
+
|
|
254
|
+
for (let index = 0; index < value.length; index++) {
|
|
255
|
+
if (index > 0) {
|
|
256
|
+
chunks.push(',');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
segments.push(index);
|
|
260
|
+
writeCanonical(value[index], chunks, segments, root);
|
|
261
|
+
segments.pop();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
chunks.push(']');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const proto = Object.getPrototypeOf(value);
|
|
269
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
270
|
+
const name = (value as object).constructor?.name ?? 'object';
|
|
271
|
+
throw new NonSerializableInputError(pathOf(root, segments), name);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const keys = sortedKeysOf(value as object);
|
|
275
|
+
|
|
276
|
+
chunks.push('{');
|
|
277
|
+
|
|
278
|
+
let first = true;
|
|
279
|
+
|
|
280
|
+
for (let i = 0; i < keys.length; i++) {
|
|
281
|
+
const key = keys[i];
|
|
282
|
+
const item = (value as Record<string, unknown>)[key];
|
|
283
|
+
|
|
284
|
+
if (item === undefined) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!first) {
|
|
289
|
+
chunks.push(',');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
first = false;
|
|
293
|
+
chunks.push(quote(key), ':');
|
|
294
|
+
|
|
295
|
+
segments.push(key);
|
|
296
|
+
writeCanonical(item, chunks, segments, root);
|
|
297
|
+
segments.pop();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
chunks.push('}');
|
|
301
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { canonical, NonSerializableInputError } from '../src/shared/canonical';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The implementation that shipped before the rewrite, kept verbatim as an
|
|
6
|
+
* oracle. `canonical` feeds the instance id and the content hash, and client
|
|
7
|
+
* and server derive them independently, so a byte of drift breaks subscription
|
|
8
|
+
* dedupe and the hydration handshake without raising anything. Asserting
|
|
9
|
+
* equality against the previous implementation is the only check that covers
|
|
10
|
+
* that; hand-written expectations only cover what we thought of.
|
|
11
|
+
*/
|
|
12
|
+
function reference(value: unknown, path: string = '$'): string {
|
|
13
|
+
if (value === null || value === undefined) {
|
|
14
|
+
return 'null';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
switch (typeof value) {
|
|
18
|
+
case 'boolean':
|
|
19
|
+
return value ? 'true' : 'false';
|
|
20
|
+
case 'number':
|
|
21
|
+
if (!Number.isFinite(value)) {
|
|
22
|
+
throw new NonSerializableInputError(path, String(value));
|
|
23
|
+
}
|
|
24
|
+
return Object.is(value, -0) ? '0' : String(value);
|
|
25
|
+
case 'string':
|
|
26
|
+
return JSON.stringify(value);
|
|
27
|
+
case 'bigint':
|
|
28
|
+
case 'function':
|
|
29
|
+
case 'symbol':
|
|
30
|
+
throw new NonSerializableInputError(path, typeof value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (Array.isArray(value)) {
|
|
34
|
+
// Index reads, not `.map()`: the implementation this replaced skipped
|
|
35
|
+
// holes and joined them into `[,1]`, which is not JSON. A hole is
|
|
36
|
+
// undefined, and canonical renders undefined as null.
|
|
37
|
+
const items: string[] = [];
|
|
38
|
+
|
|
39
|
+
for (let index = 0; index < value.length; index++) {
|
|
40
|
+
items.push(reference(value[index], `${path}[${index}]`));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return `[${items.join(',')}]`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const proto = Object.getPrototypeOf(value);
|
|
47
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
48
|
+
const name = (value as object).constructor?.name ?? 'object';
|
|
49
|
+
throw new NonSerializableInputError(path, name);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
53
|
+
.filter(([, item]) => item !== undefined)
|
|
54
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
|
55
|
+
|
|
56
|
+
const body = entries
|
|
57
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${reference(item, `${path}.${key}`)}`)
|
|
58
|
+
.join(',');
|
|
59
|
+
|
|
60
|
+
return `{${body}}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Compare both implementations on a value: the output, or the thrown error. */
|
|
64
|
+
function agree(value: unknown): void {
|
|
65
|
+
let expected: { ok: true; text: string } | { ok: false; path: string; received: string };
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
expected = { ok: true, text: reference(value) };
|
|
69
|
+
} catch (error) {
|
|
70
|
+
const failure = error as NonSerializableInputError;
|
|
71
|
+
expected = { ok: false, path: failure.path, received: failure.received };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (expected.ok) {
|
|
75
|
+
expect(canonical(value)).toBe(expected.text);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let thrown: unknown;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
canonical(value);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
thrown = error;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
expect(thrown).toBeInstanceOf(NonSerializableInputError);
|
|
88
|
+
|
|
89
|
+
const failure = thrown as NonSerializableInputError;
|
|
90
|
+
expect(failure.path).toBe(expected.path);
|
|
91
|
+
expect(failure.received).toBe(expected.received);
|
|
92
|
+
expect(failure.message).toBe(
|
|
93
|
+
`Live input at "${expected.path}" is not serializable (received ${expected.received}).`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Deterministic PRNG, so a failure is reproducible from its seed alone. */
|
|
98
|
+
function rng(seed: number): () => number {
|
|
99
|
+
let state = seed >>> 0;
|
|
100
|
+
|
|
101
|
+
return () => {
|
|
102
|
+
state = (state * 1664525 + 1013904223) >>> 0;
|
|
103
|
+
return state / 0x100000000;
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const TAB = String.fromCharCode(9);
|
|
108
|
+
const NEWLINE = String.fromCharCode(10);
|
|
109
|
+
const NUL = String.fromCharCode(0);
|
|
110
|
+
const LONE_SURROGATE = String.fromCharCode(0xd800);
|
|
111
|
+
const EMOJI = String.fromCodePoint(0x1f642);
|
|
112
|
+
|
|
113
|
+
const STRINGS = [
|
|
114
|
+
'', 'plain', 'with space', 'sym_$-.', 'quote"inside', 'back\\slash',
|
|
115
|
+
`tab${TAB}char`, `line${NEWLINE}break`, `${NUL}control`, 'acentuacao',
|
|
116
|
+
'ação', 'zhongwen-中文', EMOJI, LONE_SURROGATE, 'a'.repeat(300)
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
const SCALARS: unknown[] = [
|
|
120
|
+
null, undefined, true, false, 0, -0, 1, -1, 1.5, 1e21, 1e-7,
|
|
121
|
+
Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER, ...STRINGS
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
const REJECTED: unknown[] = [
|
|
125
|
+
Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY,
|
|
126
|
+
10n, () => 1, Symbol('s'), new Date(), new Map(), new Set(), /re/,
|
|
127
|
+
new (class Widget { constructor(public a = 1) {} })()
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
const KEYS = [
|
|
131
|
+
'a', 'b', 'z', 'A', 'key with space', 'ç', 'quote"key', '_x', '',
|
|
132
|
+
// Integer-like keys force the order-preserving fallback; they are the
|
|
133
|
+
// shape the fast path provably cannot express.
|
|
134
|
+
'1', '0', '10', '2', '1abc'
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
function grow(next: () => number, depth: number, allowRejected: boolean): unknown {
|
|
138
|
+
const roll = next();
|
|
139
|
+
|
|
140
|
+
if (depth <= 0 || roll < 0.45) {
|
|
141
|
+
if (allowRejected && next() < 0.08) {
|
|
142
|
+
return REJECTED[Math.floor(next() * REJECTED.length)];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return SCALARS[Math.floor(next() * SCALARS.length)];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (roll < 0.72) {
|
|
149
|
+
const length = Math.floor(next() * 5);
|
|
150
|
+
const array = Array.from({ length }, () => grow(next, depth - 1, allowRejected));
|
|
151
|
+
|
|
152
|
+
// Punch a hole sometimes: `Array.from` never produces a sparse array,
|
|
153
|
+
// and holes are exactly where the old implementation was wrong.
|
|
154
|
+
if (length > 0 && next() < 0.15) {
|
|
155
|
+
delete array[Math.floor(next() * length)];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return array;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const object: Record<string, unknown> = {};
|
|
162
|
+
const count = Math.floor(next() * 5);
|
|
163
|
+
|
|
164
|
+
for (let i = 0; i < count; i++) {
|
|
165
|
+
object[KEYS[Math.floor(next() * KEYS.length)]] = grow(next, depth - 1, allowRejected);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return object;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
describe('canonical matches the implementation it replaced', () => {
|
|
172
|
+
test('on every scalar, including the ones that must be rejected', () => {
|
|
173
|
+
for (const value of [...SCALARS, ...REJECTED]) {
|
|
174
|
+
agree(value);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('on objects whose keys need escaping, ordering or dropping', () => {
|
|
179
|
+
agree({ b: 1, a: 2 });
|
|
180
|
+
agree({ 'quote"key': 1, 'back\\slash': 2, 'ç': 3, '': 4 });
|
|
181
|
+
agree({ a: undefined, b: null, c: undefined });
|
|
182
|
+
agree({ z: 1, a: 2, A: 3, '1': 4, _: 5 });
|
|
183
|
+
agree(Object.create(null));
|
|
184
|
+
agree({ nested: { deep: { deeper: [1, { x: 'y' }] } } });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('renders an array hole as null, as JSON.stringify does', () => {
|
|
188
|
+
const holed = new Array(3);
|
|
189
|
+
holed[1] = 'x';
|
|
190
|
+
|
|
191
|
+
// The implementation this replaced emitted `[,1]` and `[,"x",]` here,
|
|
192
|
+
// which no JSON parser accepts. This is the one deliberate change.
|
|
193
|
+
expect(canonical([, 1])).toBe('[null,1]');
|
|
194
|
+
expect(canonical([1, , 2])).toBe('[1,null,2]');
|
|
195
|
+
expect(canonical(holed)).toBe('[null,"x",null]');
|
|
196
|
+
expect(() => JSON.parse(canonical(holed))).not.toThrow();
|
|
197
|
+
agree([, 1]);
|
|
198
|
+
agree({ rows: [1, , 3] });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('reports the same path for a rejected value in place', () => {
|
|
202
|
+
agree({ filters: [{ since: new Date() }] });
|
|
203
|
+
agree([[[Number.NaN]]]);
|
|
204
|
+
agree({ a: { b: [0, 1, { c: () => 1 }] } });
|
|
205
|
+
agree({ 'key with space': new Map() });
|
|
206
|
+
agree([{ ok: 1 }, { bad: 10n }]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('on 4000 generated values across 8 seeds', () => {
|
|
210
|
+
for (let seed = 1; seed <= 8; seed++) {
|
|
211
|
+
const next = rng(seed * 7919);
|
|
212
|
+
|
|
213
|
+
for (let i = 0; i < 500; i++) {
|
|
214
|
+
agree(grow(next, 4, i % 3 === 0));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('the order-preserving fallback', () => {
|
|
221
|
+
test('sorts lexicographically where an object literal cannot', () => {
|
|
222
|
+
// A JS object always enumerates '1' before '', whatever the insertion
|
|
223
|
+
// order, so this can only come out right off the fallback path.
|
|
224
|
+
expect(canonical({ '1': 1, '': 2 })).toBe('{"":2,"1":1}');
|
|
225
|
+
expect(canonical({ '10': 'a', '2': 'b', name: 'c' }))
|
|
226
|
+
.toBe('{"10":"a","2":"b","name":"c"}');
|
|
227
|
+
agree({ '1': 1, '': 2 });
|
|
228
|
+
agree({ '10': 'a', '2': 'b', name: 'c' });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('a map keyed by id sorts as strings, not as numbers', () => {
|
|
232
|
+
const byId: Record<string, unknown> = {};
|
|
233
|
+
|
|
234
|
+
for (const id of [3, 20, 100, 1]) {
|
|
235
|
+
byId[String(id)] = { id };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
expect(canonical(byId)).toBe('{"1":{"id":1},"100":{"id":100},"20":{"id":20},"3":{"id":3}}');
|
|
239
|
+
agree(byId);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('still rejects, and at the same path, once it has taken over', () => {
|
|
243
|
+
agree({ '1': new Date() });
|
|
244
|
+
agree({ rows: { '2': { at: new Date() } } });
|
|
245
|
+
agree({ '0': [Number.NaN] });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('an integer-like key deep in the value moves the whole value over', () => {
|
|
249
|
+
agree({ outer: { inner: [{ '7': 1, a: 2 }] }, sibling: 'kept' });
|
|
250
|
+
expect(canonical({ outer: { inner: [{ '7': 1, a: 2 }] }, sibling: 'kept' }))
|
|
251
|
+
.toBe('{"outer":{"inner":[{"7":1,"a":2}]},"sibling":"kept"}');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('a key that only looks like an index is handled the same way', () => {
|
|
255
|
+
// '1abc' is not an array index, but the digit check is coarse on
|
|
256
|
+
// purpose: it must still come out right, only slower.
|
|
257
|
+
agree({ '1abc': 1, a: 2 });
|
|
258
|
+
expect(canonical({ '1abc': 1, a: 2 })).toBe('{"1abc":1,"a":2}');
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe('the rewrite keeps its own invariants', () => {
|
|
263
|
+
test('a key beyond the quoting cache is still quoted correctly', () => {
|
|
264
|
+
const wide: Record<string, unknown> = {};
|
|
265
|
+
|
|
266
|
+
// Past MAX_CACHED_KEYS quoting stops being cached, and must not change.
|
|
267
|
+
for (let i = 0; i < 700; i++) {
|
|
268
|
+
wide[`k${i}"x`] = i;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
agree(wide);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('is reentrant: a getter that canonicalizes does not corrupt the outer walk', () => {
|
|
275
|
+
const inner = { b: 2, a: 1 };
|
|
276
|
+
let seen = '';
|
|
277
|
+
|
|
278
|
+
const outer = {
|
|
279
|
+
plain: 1,
|
|
280
|
+
get tricky() {
|
|
281
|
+
seen = canonical(inner);
|
|
282
|
+
return 'value';
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// A module-level accumulator would interleave the two walks here.
|
|
287
|
+
expect(canonical(outer)).toBe('{"plain":1,"tricky":"value"}');
|
|
288
|
+
expect(seen).toBe('{"a":1,"b":2}');
|
|
289
|
+
});
|
|
290
|
+
});
|