@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/dist/shared/canonical.js
CHANGED
|
@@ -9,6 +9,30 @@ exports.canonical = canonical;
|
|
|
9
9
|
* the instance id and the content hash are derived from it, so a divergence
|
|
10
10
|
* silently breaks subscription dedupe and the hydration handshake instead of
|
|
11
11
|
* failing loudly.
|
|
12
|
+
*
|
|
13
|
+
* It also runs on every recompute, over the whole payload, which makes it the
|
|
14
|
+
* most expensive step of a recompute that changes nothing. Building the string
|
|
15
|
+
* in JavaScript loses to `JSON.stringify` by roughly an order of magnitude, so
|
|
16
|
+
* the common case does not build it: one walk normalizes the value into the
|
|
17
|
+
* shape `JSON.stringify` would already render canonically -- keys sorted,
|
|
18
|
+
* nothing unserializable left in it -- and the native serializer does the rest.
|
|
19
|
+
*
|
|
20
|
+
* What that walk has to do, and `JSON.stringify` cannot:
|
|
21
|
+
*
|
|
22
|
+
* - order object keys, which is the whole point;
|
|
23
|
+
* - refuse values with no agreed wire form, which `JSON.stringify` accepts
|
|
24
|
+
* silently (a Date becomes a string, a Map becomes `{}`, a NaN becomes null).
|
|
25
|
+
*
|
|
26
|
+
* What it deliberately leaves to `JSON.stringify`, which already agrees:
|
|
27
|
+
* string escaping, number formatting including negative zero, dropping
|
|
28
|
+
* undefined properties, and rendering an array hole as null.
|
|
29
|
+
*
|
|
30
|
+
* The escape hatch is `writeCanonical` below. A JavaScript object cannot hold
|
|
31
|
+
* an integer-like key anywhere but the front -- `{ '': 1, '1': 2 }` always
|
|
32
|
+
* enumerates as `1` then `''` -- so an object carrying one cannot be emitted
|
|
33
|
+
* in lexicographic order at all. Those values fall back to building the string
|
|
34
|
+
* here, where the order is ours to choose. Both paths are held to the same
|
|
35
|
+
* output by the differential test.
|
|
12
36
|
*/
|
|
13
37
|
class NonSerializableInputError extends Error {
|
|
14
38
|
constructor(path, received) {
|
|
@@ -19,42 +43,208 @@ class NonSerializableInputError extends Error {
|
|
|
19
43
|
}
|
|
20
44
|
}
|
|
21
45
|
exports.NonSerializableInputError = NonSerializableInputError;
|
|
46
|
+
/** Thrown by the fast path to hand the value to `writeCanonical` instead. */
|
|
47
|
+
const INDEX_KEY = Symbol('carno:live:index-key');
|
|
48
|
+
/**
|
|
49
|
+
* The last key list seen, with its sorted form.
|
|
50
|
+
*
|
|
51
|
+
* Every row of a collection carries the same keys in the same order, so one
|
|
52
|
+
* sort serves the whole list: comparing the key arrays element-wise is a run
|
|
53
|
+
* of interned-string pointer comparisons, far cheaper than sorting again. It
|
|
54
|
+
* is a memo and nothing else -- a miss costs a sort, never a wrong answer --
|
|
55
|
+
* and the pair is replaced in a single assignment so a reentrant walk can
|
|
56
|
+
* never observe keys from one object beside the sorted keys of another.
|
|
57
|
+
*/
|
|
58
|
+
let shape = { keys: [], sorted: [] };
|
|
59
|
+
function sortedKeysOf(value) {
|
|
60
|
+
const keys = Object.keys(value);
|
|
61
|
+
const memo = shape;
|
|
62
|
+
if (keys.length === memo.keys.length) {
|
|
63
|
+
let same = true;
|
|
64
|
+
for (let i = 0; i < keys.length; i++) {
|
|
65
|
+
if (keys[i] !== memo.keys[i]) {
|
|
66
|
+
same = false;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (same) {
|
|
71
|
+
return memo.sorted;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const sorted = keys.slice().sort();
|
|
75
|
+
shape = { keys, sorted };
|
|
76
|
+
return sorted;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* True for any key an object might reorder: every array index starts with a
|
|
80
|
+
* digit. Deliberately coarse -- `'1abc'` is not an index but is treated as
|
|
81
|
+
* one -- because the only cost of a false positive is taking the slow path,
|
|
82
|
+
* and the check runs once per key on the hot path. An empty key yields NaN,
|
|
83
|
+
* which fails both comparisons, and is correctly not an index.
|
|
84
|
+
*/
|
|
85
|
+
function mayReorder(key) {
|
|
86
|
+
const first = key.charCodeAt(0);
|
|
87
|
+
return first >= 48 && first <= 57;
|
|
88
|
+
}
|
|
89
|
+
/** Assemble `$.filters[0].since` from the walk's segment stack, on throw only. */
|
|
90
|
+
function pathOf(root, segments) {
|
|
91
|
+
let path = root;
|
|
92
|
+
for (const segment of segments) {
|
|
93
|
+
path += typeof segment === 'number' ? `[${segment}]` : `.${segment}`;
|
|
94
|
+
}
|
|
95
|
+
return path;
|
|
96
|
+
}
|
|
22
97
|
function canonical(value, path = '$') {
|
|
98
|
+
try {
|
|
99
|
+
// `normalize` never yields undefined, so this never yields undefined.
|
|
100
|
+
return JSON.stringify(normalize(value, [], path));
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error !== INDEX_KEY) {
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const chunks = [];
|
|
108
|
+
writeCanonical(value, chunks, [], path);
|
|
109
|
+
return chunks.join('');
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Rebuild `value` as the equivalent JSON value with every object's keys in
|
|
113
|
+
* sorted order, rejecting anything that has no canonical wire form.
|
|
114
|
+
*/
|
|
115
|
+
function normalize(value, segments, root) {
|
|
23
116
|
if (value === null || value === undefined) {
|
|
24
|
-
return
|
|
117
|
+
return null;
|
|
25
118
|
}
|
|
26
119
|
switch (typeof value) {
|
|
27
120
|
case 'boolean':
|
|
28
|
-
|
|
121
|
+
case 'string':
|
|
122
|
+
return value;
|
|
29
123
|
case 'number':
|
|
30
124
|
if (!Number.isFinite(value)) {
|
|
31
|
-
throw new NonSerializableInputError(
|
|
125
|
+
throw new NonSerializableInputError(pathOf(root, segments), String(value));
|
|
32
126
|
}
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return JSON.stringify(value);
|
|
127
|
+
// Negative zero needs no special case: JSON.stringify renders it
|
|
128
|
+
// as `0`, which is what a query means by it.
|
|
129
|
+
return value;
|
|
37
130
|
case 'bigint':
|
|
38
131
|
case 'function':
|
|
39
132
|
case 'symbol':
|
|
40
|
-
throw new NonSerializableInputError(
|
|
133
|
+
throw new NonSerializableInputError(pathOf(root, segments), typeof value);
|
|
41
134
|
}
|
|
42
135
|
if (Array.isArray(value)) {
|
|
43
|
-
const
|
|
44
|
-
|
|
136
|
+
const out = new Array(value.length);
|
|
137
|
+
for (let index = 0; index < value.length; index++) {
|
|
138
|
+
segments.push(index);
|
|
139
|
+
// Reading by index turns a hole into undefined, and so into null,
|
|
140
|
+
// which is what JSON.stringify would have rendered for the hole.
|
|
141
|
+
out[index] = normalize(value[index], segments, root);
|
|
142
|
+
segments.pop();
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
45
145
|
}
|
|
46
146
|
const proto = Object.getPrototypeOf(value);
|
|
47
147
|
if (proto !== Object.prototype && proto !== null) {
|
|
48
148
|
// Date, Map, Set, class instances: no agreed wire form, so refuse
|
|
49
149
|
// rather than guess one the client would canonicalize differently.
|
|
50
150
|
const name = value.constructor?.name ?? 'object';
|
|
51
|
-
throw new NonSerializableInputError(
|
|
52
|
-
}
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
151
|
+
throw new NonSerializableInputError(pathOf(root, segments), name);
|
|
152
|
+
}
|
|
153
|
+
const keys = sortedKeysOf(value);
|
|
154
|
+
// A literal, so the result never carries a prototype, a toJSON, or the
|
|
155
|
+
// insertion order of the value it came from.
|
|
156
|
+
const out = {};
|
|
157
|
+
for (let i = 0; i < keys.length; i++) {
|
|
158
|
+
const key = keys[i];
|
|
159
|
+
if (mayReorder(key)) {
|
|
160
|
+
throw INDEX_KEY;
|
|
161
|
+
}
|
|
162
|
+
const item = value[key];
|
|
163
|
+
if (item === undefined) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
segments.push(key);
|
|
167
|
+
out[key] = normalize(item, segments, root);
|
|
168
|
+
segments.pop();
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Printable ASCII minus the only two characters JSON escapes in that range:
|
|
174
|
+
* `"` (0x22) and `\` (0x5c). A string that matches needs no escaping at all,
|
|
175
|
+
* so it can be quoted by concatenation. Anything else -- control characters,
|
|
176
|
+
* quotes, backslashes, non-ASCII, lone surrogates -- falls back to
|
|
177
|
+
* `JSON.stringify`, which keeps the output exact rather than merely fast.
|
|
178
|
+
*/
|
|
179
|
+
const NO_ESCAPES = /^[\x20-\x21\x23-\x5b\x5d-\x7e]*$/;
|
|
180
|
+
function quote(value) {
|
|
181
|
+
return NO_ESCAPES.test(value) ? `"${value}"` : JSON.stringify(value);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The order-preserving path, for values `normalize` cannot express.
|
|
185
|
+
*
|
|
186
|
+
* Held to the same output as the fast path by the differential test, which is
|
|
187
|
+
* what keeps the two from drifting apart.
|
|
188
|
+
*/
|
|
189
|
+
function writeCanonical(value, chunks, segments, root) {
|
|
190
|
+
if (value === null || value === undefined) {
|
|
191
|
+
chunks.push('null');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
switch (typeof value) {
|
|
195
|
+
case 'boolean':
|
|
196
|
+
chunks.push(value ? 'true' : 'false');
|
|
197
|
+
return;
|
|
198
|
+
case 'number':
|
|
199
|
+
if (!Number.isFinite(value)) {
|
|
200
|
+
throw new NonSerializableInputError(pathOf(root, segments), String(value));
|
|
201
|
+
}
|
|
202
|
+
// -0 and 0 are the same input as far as a query is concerned.
|
|
203
|
+
chunks.push(Object.is(value, -0) ? '0' : String(value));
|
|
204
|
+
return;
|
|
205
|
+
case 'string':
|
|
206
|
+
chunks.push(quote(value));
|
|
207
|
+
return;
|
|
208
|
+
case 'bigint':
|
|
209
|
+
case 'function':
|
|
210
|
+
case 'symbol':
|
|
211
|
+
throw new NonSerializableInputError(pathOf(root, segments), typeof value);
|
|
212
|
+
}
|
|
213
|
+
if (Array.isArray(value)) {
|
|
214
|
+
chunks.push('[');
|
|
215
|
+
for (let index = 0; index < value.length; index++) {
|
|
216
|
+
if (index > 0) {
|
|
217
|
+
chunks.push(',');
|
|
218
|
+
}
|
|
219
|
+
segments.push(index);
|
|
220
|
+
writeCanonical(value[index], chunks, segments, root);
|
|
221
|
+
segments.pop();
|
|
222
|
+
}
|
|
223
|
+
chunks.push(']');
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const proto = Object.getPrototypeOf(value);
|
|
227
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
228
|
+
const name = value.constructor?.name ?? 'object';
|
|
229
|
+
throw new NonSerializableInputError(pathOf(root, segments), name);
|
|
230
|
+
}
|
|
231
|
+
const keys = sortedKeysOf(value);
|
|
232
|
+
chunks.push('{');
|
|
233
|
+
let first = true;
|
|
234
|
+
for (let i = 0; i < keys.length; i++) {
|
|
235
|
+
const key = keys[i];
|
|
236
|
+
const item = value[key];
|
|
237
|
+
if (item === undefined) {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (!first) {
|
|
241
|
+
chunks.push(',');
|
|
242
|
+
}
|
|
243
|
+
first = false;
|
|
244
|
+
chunks.push(quote(key), ':');
|
|
245
|
+
segments.push(key);
|
|
246
|
+
writeCanonical(item, chunks, segments, root);
|
|
247
|
+
segments.pop();
|
|
248
|
+
}
|
|
249
|
+
chunks.push('}');
|
|
60
250
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carno.js/live",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.1",
|
|
4
4
|
"description": "Server-owned reactive state for Carno.js: live resources, dependency-graph invalidation, and framework-native client stores",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -88,5 +88,5 @@
|
|
|
88
88
|
"publishConfig": {
|
|
89
89
|
"access": "public"
|
|
90
90
|
},
|
|
91
|
-
"gitHead": "
|
|
91
|
+
"gitHead": "7a66897351723772e77140b8f99faed3ad968c7c"
|
|
92
92
|
}
|