@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.
@@ -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 'null';
117
+ return null;
25
118
  }
26
119
  switch (typeof value) {
27
120
  case 'boolean':
28
- return value ? 'true' : 'false';
121
+ case 'string':
122
+ return value;
29
123
  case 'number':
30
124
  if (!Number.isFinite(value)) {
31
- throw new NonSerializableInputError(path, String(value));
125
+ throw new NonSerializableInputError(pathOf(root, segments), String(value));
32
126
  }
33
- // -0 and 0 are the same input as far as a query is concerned.
34
- return Object.is(value, -0) ? '0' : String(value);
35
- case 'string':
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(path, typeof value);
133
+ throw new NonSerializableInputError(pathOf(root, segments), typeof value);
41
134
  }
42
135
  if (Array.isArray(value)) {
43
- const items = value.map((item, index) => canonical(item, `${path}[${index}]`));
44
- return `[${items.join(',')}]`;
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(path, name);
52
- }
53
- const entries = Object.entries(value)
54
- .filter(([, item]) => item !== undefined)
55
- .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
56
- const body = entries
57
- .map(([key, item]) => `${JSON.stringify(key)}:${canonical(item, `${path}.${key}`)}`)
58
- .join(',');
59
- return `{${body}}`;
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.0",
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": "4eacc5852543c296787a2f8b92795a1775de44fa"
91
+ "gitHead": "7a66897351723772e77140b8f99faed3ad968c7c"
92
92
  }