@procedures/client 0.49.1 → 0.51.0
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/CHANGELOG.md +24 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/server/ndjson.d.ts.map +1 -1
- package/dist/adapters/http/server/ndjson.js +2 -1
- package/dist/adapters/http/server/ndjson.js.map +1 -1
- package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
- package/dist/adapters/websocket/server/transport.js +3 -2
- package/dist/adapters/websocket/server/transport.js.map +1 -1
- package/dist/browser.d.ts +1 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/events/index.d.ts +1 -1
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js +1 -1
- package/dist/events/index.js.map +1 -1
- package/dist/events/live-state.d.ts +36 -1
- package/dist/events/live-state.d.ts.map +1 -1
- package/dist/events/live-state.js +72 -11
- package/dist/events/live-state.js.map +1 -1
- package/dist/events/publisher.d.ts.map +1 -1
- package/dist/events/publisher.js +3 -1
- package/dist/events/publisher.js.map +1 -1
- package/dist/frame-encoding.d.ts +26 -0
- package/dist/frame-encoding.d.ts.map +1 -0
- package/dist/frame-encoding.js +101 -0
- package/dist/frame-encoding.js.map +1 -0
- package/dist/procedures/collection/storage.d.ts +2 -2
- package/package.json +2 -2
- package/src/adapters/http/server/ndjson.ts +2 -1
- package/src/adapters/websocket/server/transport.ts +3 -2
- package/src/browser.ts +1 -1
- package/src/events/index.ts +2 -0
- package/src/events/live-state.ts +89 -11
- package/src/events/publisher.ts +3 -1
- package/src/frame-encoding.ts +101 -0
- package/src/procedures/collection/storage.ts +2 -2
package/src/events/index.ts
CHANGED
|
@@ -63,7 +63,9 @@ export type { EventPublisherOptions, TopicRule, TopicCall } from "./publisher.js
|
|
|
63
63
|
export type { RightsChange, RightsScope, RightsScopes, SubscriptionRecheck } from "./recheck.js";
|
|
64
64
|
// The live state: keyed values in memory on a topic, with a snapshot and a time limit (LIVE-STATE-2026-11.md)
|
|
65
65
|
export {
|
|
66
|
+
DEFAULT_LIVE_BYTES,
|
|
66
67
|
DEFAULT_LIVE_KEYS,
|
|
68
|
+
DEFAULT_LIVE_TOPIC_BYTES,
|
|
67
69
|
DEFAULT_LIVE_TOPICS,
|
|
68
70
|
DEFAULT_LIVE_VALUE_BYTES,
|
|
69
71
|
LIVE_SET_PATH,
|
package/src/events/live-state.ts
CHANGED
|
@@ -40,6 +40,16 @@ export const DEFAULT_LIVE_KEYS = 1024;
|
|
|
40
40
|
export const DEFAULT_LIVE_VALUE_BYTES = 16384;
|
|
41
41
|
/** The default limit of the topics of one live state. */
|
|
42
42
|
export const DEFAULT_LIVE_TOPICS = 4096;
|
|
43
|
+
/**
|
|
44
|
+
* The default limit of the bytes of the values of one topic, as JSON. This limit bounds a snapshot and a window event.
|
|
45
|
+
* In the bench `live-limits` (run 42152574477), a full topic at this limit kept 1,075,659 bytes of heap.
|
|
46
|
+
*/
|
|
47
|
+
export const DEFAULT_LIVE_TOPIC_BYTES = 1_048_576;
|
|
48
|
+
/**
|
|
49
|
+
* The default limit of the bytes of all values of one live state, as JSON. In the bench `live-limits` (run 42152574477),
|
|
50
|
+
* a full state kept 68,842,177 bytes: 1.53 percent of a heap limit of 4,496,293,888 bytes.
|
|
51
|
+
*/
|
|
52
|
+
export const DEFAULT_LIVE_BYTES = 67_108_864;
|
|
43
53
|
|
|
44
54
|
/** The prefix of each live topic. */
|
|
45
55
|
const PREFIX = "live.";
|
|
@@ -169,6 +179,18 @@ export interface LiveStateOptions {
|
|
|
169
179
|
* @default DEFAULT_LIVE_TOPICS
|
|
170
180
|
*/
|
|
171
181
|
maxTopics?: number | undefined;
|
|
182
|
+
/**
|
|
183
|
+
* The limit of the bytes of the values of one topic, as JSON. A set that takes a topic above the limit gets
|
|
184
|
+
* `FORBIDDEN`. A set that makes a value smaller always goes. This limit bounds a snapshot and a window event.
|
|
185
|
+
* @default DEFAULT_LIVE_TOPIC_BYTES
|
|
186
|
+
*/
|
|
187
|
+
maxTopicBytes?: number | undefined;
|
|
188
|
+
/**
|
|
189
|
+
* The limit of the bytes of all values of this live state, as JSON. A set that takes the state above the limit gets
|
|
190
|
+
* `FORBIDDEN`. This limit bounds the memory of the state, because the product of the other limits does not.
|
|
191
|
+
* @default DEFAULT_LIVE_BYTES
|
|
192
|
+
*/
|
|
193
|
+
maxBytes?: number | undefined;
|
|
172
194
|
/**
|
|
173
195
|
* The clock of the deadlines and of the window. A test gives the `fakeClock` of
|
|
174
196
|
* `@mark1russell7/service/testing`.
|
|
@@ -233,6 +255,8 @@ interface Slot {
|
|
|
233
255
|
readonly state: TopicState;
|
|
234
256
|
readonly key: string;
|
|
235
257
|
entry: LiveEntry;
|
|
258
|
+
/** The bytes of the value, as JSON. */
|
|
259
|
+
bytes: number;
|
|
236
260
|
deadline: number;
|
|
237
261
|
owner: string | undefined;
|
|
238
262
|
}
|
|
@@ -249,6 +273,8 @@ interface TopicState {
|
|
|
249
273
|
readonly keys: Map<string, Slot>;
|
|
250
274
|
readonly stop: Unsubscribe;
|
|
251
275
|
readonly windowMs: number;
|
|
276
|
+
/** The bytes of the values of the keys, as JSON. */
|
|
277
|
+
bytes: number;
|
|
252
278
|
waiting: Waiting | undefined;
|
|
253
279
|
/** This field holds the timer of the open window. It is undefined when the topic is idle. */
|
|
254
280
|
window: Cancel | undefined;
|
|
@@ -261,6 +287,17 @@ function isLiveEvent(value: unknown): value is LiveEvent {
|
|
|
261
287
|
return typeof event.origin === "string" && Array.isArray(event.set) && Array.isArray(event.gone);
|
|
262
288
|
}
|
|
263
289
|
|
|
290
|
+
/** True when a text has a character outside ASCII. */
|
|
291
|
+
const NOT_ASCII = /[^\u0000-\u007f]/;
|
|
292
|
+
let encoder: InstanceType<typeof TextEncoder> | undefined;
|
|
293
|
+
|
|
294
|
+
/** This function gives the UTF-8 bytes of a text. An ASCII text has one byte for each character, and needs no encode. */
|
|
295
|
+
function utf8Bytes(text: string): number {
|
|
296
|
+
if (!NOT_ASCII.test(text)) return text.length;
|
|
297
|
+
encoder ??= new TextEncoder();
|
|
298
|
+
return encoder.encode(text).length;
|
|
299
|
+
}
|
|
300
|
+
|
|
264
301
|
/** This function gives a refusal of a set. */
|
|
265
302
|
function refused(message: string, reason: "write-not-allowed" | "partition-not-allowed", path: readonly string[] = LIVE_SET_PATH): InvocationError {
|
|
266
303
|
return new InvocationError("FORBIDDEN", message, [...path], reason);
|
|
@@ -298,6 +335,10 @@ export class LiveState {
|
|
|
298
335
|
private readonly maxKeys: number;
|
|
299
336
|
private readonly maxValueBytes: number;
|
|
300
337
|
private readonly maxTopics: number;
|
|
338
|
+
private readonly maxTopicBytes: number;
|
|
339
|
+
private readonly maxBytes: number;
|
|
340
|
+
/** The bytes of all values, as JSON. */
|
|
341
|
+
private totalBytes = 0;
|
|
301
342
|
private readonly origin = newOrigin();
|
|
302
343
|
private readonly topics = new Map<string, TopicState>();
|
|
303
344
|
/** Each key in the order of its last set. All keys have the same time, so this is the order of the deadlines. */
|
|
@@ -321,6 +362,8 @@ export class LiveState {
|
|
|
321
362
|
this.maxKeys = options.maxKeysPerTopic ?? DEFAULT_LIVE_KEYS;
|
|
322
363
|
this.maxValueBytes = options.maxValueBytes ?? DEFAULT_LIVE_VALUE_BYTES;
|
|
323
364
|
this.maxTopics = options.maxTopics ?? DEFAULT_LIVE_TOPICS;
|
|
365
|
+
this.maxTopicBytes = options.maxTopicBytes ?? DEFAULT_LIVE_TOPIC_BYTES;
|
|
366
|
+
this.maxBytes = options.maxBytes ?? DEFAULT_LIVE_BYTES;
|
|
324
367
|
this.procedures = [this.setProcedure(host.topicSchema), this.snapshotProcedure(host.topicSchema), this.leaveProcedure(host.topicSchema)];
|
|
325
368
|
}
|
|
326
369
|
|
|
@@ -329,6 +372,11 @@ export class LiveState {
|
|
|
329
372
|
return this.deadlines.size;
|
|
330
373
|
}
|
|
331
374
|
|
|
375
|
+
/** The bytes of the values of this state, as JSON. */
|
|
376
|
+
get bytes(): number {
|
|
377
|
+
return this.totalBytes;
|
|
378
|
+
}
|
|
379
|
+
|
|
332
380
|
/**
|
|
333
381
|
* The topic of the bus for a live topic of a call, or undefined for another topic. The publisher
|
|
334
382
|
* calls it for each subscription and each re-check, so a change of the partition ends a
|
|
@@ -355,19 +403,23 @@ export class LiveState {
|
|
|
355
403
|
*/
|
|
356
404
|
set(topic: string, key: string, value: unknown, options: LiveSetOptions = {}): number {
|
|
357
405
|
if (this.closed) throw refused("The live state is closed", "write-not-allowed");
|
|
358
|
-
this.checkValue(value);
|
|
406
|
+
const bytes = this.checkValue(value);
|
|
359
407
|
const state = this.stateOf(topic);
|
|
360
408
|
if (!state) throw refused(`The live state has its limit of ${this.maxTopics} topics`, "write-not-allowed");
|
|
409
|
+
let slot = state.keys.get(key);
|
|
410
|
+
if (!slot && state.keys.size >= this.maxKeys) throw refused(`The live topic "${topic}" has its limit of ${this.maxKeys} keys`, "write-not-allowed");
|
|
411
|
+
const more = bytes - (slot?.bytes ?? 0);
|
|
412
|
+
if (more > 0 && state.bytes + more > this.maxTopicBytes) throw refused(`The live topic "${topic}" has its limit of ${this.maxTopicBytes} bytes`, "write-not-allowed");
|
|
413
|
+
if (more > 0 && this.totalBytes + more > this.maxBytes) throw refused(`The live state has its limit of ${this.maxBytes} bytes`, "write-not-allowed");
|
|
361
414
|
const at = this.clock.now();
|
|
362
415
|
const entry: LiveEntry = options.by === undefined ? { key, value, at } : { key, value, by: options.by, at };
|
|
363
|
-
let slot = state.keys.get(key);
|
|
364
416
|
if (slot) {
|
|
365
417
|
slot.entry = entry;
|
|
366
418
|
} else {
|
|
367
|
-
|
|
368
|
-
slot = { state, key, entry, deadline: 0, owner: undefined };
|
|
419
|
+
slot = { state, key, entry, bytes: 0, deadline: 0, owner: undefined };
|
|
369
420
|
state.keys.set(key, slot);
|
|
370
421
|
}
|
|
422
|
+
this.resize(slot, bytes);
|
|
371
423
|
this.renew(slot, at);
|
|
372
424
|
this.own(slot, options.owner);
|
|
373
425
|
this.queue(state, (waiting) => {
|
|
@@ -421,15 +473,26 @@ export class LiveState {
|
|
|
421
473
|
this.topics.clear();
|
|
422
474
|
this.deadlines.clear();
|
|
423
475
|
this.owned.clear();
|
|
476
|
+
this.totalBytes = 0;
|
|
424
477
|
}
|
|
425
478
|
|
|
426
|
-
/**
|
|
427
|
-
|
|
479
|
+
/**
|
|
480
|
+
* This method refuses a value above the limit, and gives the bytes of a value that fits. A text has at least one
|
|
481
|
+
* byte for each UTF-16 unit, so a longer text needs no encode.
|
|
482
|
+
*/
|
|
483
|
+
private checkValue(value: unknown): number {
|
|
428
484
|
const text = JSON.stringify(value);
|
|
429
485
|
if (text === undefined) throw refused("A live value must have a JSON form", "write-not-allowed");
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
486
|
+
const bytes = text.length > this.maxValueBytes ? Infinity : utf8Bytes(text);
|
|
487
|
+
if (bytes > this.maxValueBytes) throw refused(`A live value has more than ${this.maxValueBytes} bytes`, "write-not-allowed");
|
|
488
|
+
return bytes;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** This method gives a key the bytes of its new value, and changes the sums of its topic and of the state. */
|
|
492
|
+
private resize(slot: Slot, bytes: number): void {
|
|
493
|
+
slot.state.bytes += bytes - slot.bytes;
|
|
494
|
+
this.totalBytes += bytes - slot.bytes;
|
|
495
|
+
slot.bytes = bytes;
|
|
433
496
|
}
|
|
434
497
|
|
|
435
498
|
/**
|
|
@@ -449,7 +512,7 @@ export class LiveState {
|
|
|
449
512
|
if (this.topics.size >= this.maxTopics) return undefined;
|
|
450
513
|
}
|
|
451
514
|
const stop = this.bus.on<unknown>(topic, (event) => this.heard(topic, event));
|
|
452
|
-
const state: TopicState = { topic, keys: new Map(), stop, windowMs: toMs(this.windowOf(topic), "window"), waiting: undefined, window: undefined };
|
|
515
|
+
const state: TopicState = { topic, keys: new Map(), stop, windowMs: toMs(this.windowOf(topic), "window"), bytes: 0, waiting: undefined, window: undefined };
|
|
453
516
|
this.topics.set(topic, state);
|
|
454
517
|
return state;
|
|
455
518
|
}
|
|
@@ -462,13 +525,21 @@ export class LiveState {
|
|
|
462
525
|
const now = this.clock.now();
|
|
463
526
|
for (const entry of event.set) {
|
|
464
527
|
let slot = state.keys.get(entry.key);
|
|
528
|
+
const bytes = this.bytesOf(entry.value);
|
|
529
|
+
const more = bytes - (slot?.bytes ?? 0);
|
|
530
|
+
if (bytes > this.maxValueBytes || (more > 0 && (state.bytes + more > this.maxTopicBytes || this.totalBytes + more > this.maxBytes))) {
|
|
531
|
+
// A value that does not fit here leaves no old value of the key: the subscribers got the new one from the bus
|
|
532
|
+
if (slot) this.end(slot, "expired", false);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
465
535
|
if (slot) {
|
|
466
536
|
slot.entry = entry;
|
|
467
537
|
} else {
|
|
468
538
|
if (state.keys.size >= this.maxKeys) continue;
|
|
469
|
-
slot = { state, key: entry.key, entry, deadline: 0, owner: undefined };
|
|
539
|
+
slot = { state, key: entry.key, entry, bytes: 0, deadline: 0, owner: undefined };
|
|
470
540
|
state.keys.set(entry.key, slot);
|
|
471
541
|
}
|
|
542
|
+
this.resize(slot, bytes);
|
|
472
543
|
// The connection of this key is on the other host: its deadline ends the key if that host dies
|
|
473
544
|
this.own(slot, undefined);
|
|
474
545
|
this.renew(slot, now);
|
|
@@ -479,6 +550,12 @@ export class LiveState {
|
|
|
479
550
|
}
|
|
480
551
|
}
|
|
481
552
|
|
|
553
|
+
/** The bytes of a value of another host, as JSON. A value with no JSON form has no place. */
|
|
554
|
+
private bytesOf(value: unknown): number {
|
|
555
|
+
const text = JSON.stringify(value);
|
|
556
|
+
return text === undefined ? Infinity : utf8Bytes(text);
|
|
557
|
+
}
|
|
558
|
+
|
|
482
559
|
/** This method moves a key to the end of the deadlines. */
|
|
483
560
|
private renew(slot: Slot, now: number): void {
|
|
484
561
|
slot.deadline = now + this.expiresMs;
|
|
@@ -507,6 +584,7 @@ export class LiveState {
|
|
|
507
584
|
this.own(slot, undefined);
|
|
508
585
|
this.deadlines.delete(slot);
|
|
509
586
|
slot.state.keys.delete(slot.key);
|
|
587
|
+
this.resize(slot, 0);
|
|
510
588
|
if (!publish) return;
|
|
511
589
|
this.queue(slot.state, (waiting) => {
|
|
512
590
|
waiting.set.delete(slot.key);
|
package/src/events/publisher.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { admittedValue, rowsOf } from "./rows.js";
|
|
|
48
48
|
import { realClock, type Clock } from "../clock.js";
|
|
49
49
|
import { SubscriptionRechecks, type RecheckCause, type RightsChange, type RightsScope, type RightsScopes, type SubscriptionRecheck } from "./recheck.js";
|
|
50
50
|
import { DROPPED, RowHintFilter, collectionInvocation, collectionRulesOf, markFilteredTopic, type HintFilterMode } from "../procedures/collection/hint-filter.js";
|
|
51
|
+
import { shareEncoding } from "../frame-encoding.js";
|
|
51
52
|
|
|
52
53
|
/** The number of topics that one subscription can name. */
|
|
53
54
|
export const MAX_SUBSCRIPTION_TOPICS = 64;
|
|
@@ -327,7 +328,8 @@ class TopicSubscription implements AsyncIterableIterator<EventItem> {
|
|
|
327
328
|
*/
|
|
328
329
|
private deliver(event: QueuedEvent): Promise<IteratorResult<EventItem>> {
|
|
329
330
|
const filtered = this.filters?.get(event.topic)?.apply(event.data);
|
|
330
|
-
|
|
331
|
+
// Each subscriber of the topic gets this same data, so a transport encodes it once (roadmap CLI-112)
|
|
332
|
+
if (!filtered) return this.item(event.topic, shareEncoding(event.data, this));
|
|
331
333
|
return filtered.then(
|
|
332
334
|
(data) => (data === DROPPED ? this.next() : this.item(event.topic, data)),
|
|
333
335
|
// A filter that fails: the subscriber reads the changes itself
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The encoding of a frame with a shared value (roadmap CLI-112). The publisher of events sends the same event to each
|
|
3
|
+
* subscriber. Each connection puts it in its own frame, with its own id and number. Without this module, each
|
|
4
|
+
* connection encodes the whole event again, so one event costs subscribers x event bytes.
|
|
5
|
+
*
|
|
6
|
+
* The publisher marks the data of an event as shared, once for each receiver of the event. `encodeFrame` encodes a
|
|
7
|
+
* marked value once, at its first frame, and puts that text in each later frame of the same event. The text of a frame
|
|
8
|
+
* is byte-equal to `JSON.stringify` of the frame at that first encode.
|
|
9
|
+
*
|
|
10
|
+
* A source can publish the same object again after a change. A receiver that gets the object a second time starts a
|
|
11
|
+
* new event, so the next frame encodes the object again. The text of one event is the value at its first encode.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** One event of a shared value: the receivers that got it, and its text after the first frame. */
|
|
15
|
+
interface Share {
|
|
16
|
+
readonly receivers: WeakSet<object>;
|
|
17
|
+
text?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The current event of each shared value. */
|
|
21
|
+
const shares = new WeakMap<object, Share>();
|
|
22
|
+
/** The count of the encodes of shared values, for the tests. */
|
|
23
|
+
let encodes = 0;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The depth of the frame that `encodeFrame` examines for a shared value. A WebSocket frame and an NDJSON line hold an
|
|
27
|
+
* event at depth 2: the frame, its `payload`, then the `data` of the payload.
|
|
28
|
+
*/
|
|
29
|
+
const DEPTH = 3;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* This function marks a value as shared by the frames of the receivers of one event, and gives the value back. When the
|
|
33
|
+
* receiver got the value before, the value starts a new event, and its first frame encodes the value again.
|
|
34
|
+
*/
|
|
35
|
+
export function shareEncoding<T>(value: T, receiver: object): T {
|
|
36
|
+
if (typeof value !== "object" || value === null) return value;
|
|
37
|
+
const share = shares.get(value);
|
|
38
|
+
if (share === undefined || share.receivers.has(receiver)) shares.set(value, { receivers: new WeakSet([receiver]) });
|
|
39
|
+
else share.receivers.add(receiver);
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** This function gives the count of the encodes of shared values since the start of the process. */
|
|
44
|
+
export function sharedEncodes(): number {
|
|
45
|
+
return encodes;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* This function gives the JSON text of a frame. The function encodes a shared plain object once for all frames. The
|
|
50
|
+
* object can be the frame, or a value in a plain object of the frame up to depth 3. The text is byte-equal to
|
|
51
|
+
* `JSON.stringify(frame)`.
|
|
52
|
+
*/
|
|
53
|
+
export function encodeFrame(frame: unknown): string {
|
|
54
|
+
const text = encode(frame, DEPTH);
|
|
55
|
+
// `JSON.stringify` gives undefined for a value with no JSON form. A frame is always an object
|
|
56
|
+
if (text === undefined) throw new TypeError("A frame must have a JSON form");
|
|
57
|
+
return text;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The text of a shared value, from the first encode of its current event. */
|
|
61
|
+
function sharedText(value: object, share: Share): string | undefined {
|
|
62
|
+
if (share.text !== undefined) return share.text;
|
|
63
|
+
encodes++;
|
|
64
|
+
const text = JSON.stringify(value);
|
|
65
|
+
if (text !== undefined) share.text = text;
|
|
66
|
+
return text;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** True for an object that `JSON.stringify` writes key by key: a plain object with no `toJSON`. */
|
|
70
|
+
function isPlain(value: object): value is Record<string, unknown> {
|
|
71
|
+
if (Array.isArray(value)) return false;
|
|
72
|
+
const prototype = Object.getPrototypeOf(value) as unknown;
|
|
73
|
+
if (prototype !== Object.prototype && prototype !== null) return false;
|
|
74
|
+
return typeof (value as { toJSON?: unknown }).toJSON !== "function";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The JSON text of the value of a key, as `JSON.stringify` writes it in an object. A `toJSON` method gets the key, as
|
|
79
|
+
* in the object.
|
|
80
|
+
*/
|
|
81
|
+
function asProperty(key: string, value: unknown): string | undefined {
|
|
82
|
+
const text = JSON.stringify({ [key]: value });
|
|
83
|
+
return text === "{}" ? undefined : text.slice(JSON.stringify(key).length + 2, -1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The JSON text of the value of a key, or undefined when the value has no JSON form, as `JSON.stringify` gives. */
|
|
87
|
+
function encode(value: unknown, depth: number, key = ""): string | undefined {
|
|
88
|
+
// Only an object and a bigint can have a `toJSON` method
|
|
89
|
+
if (typeof value === "bigint") return asProperty(key, value);
|
|
90
|
+
if (typeof value !== "object" || value === null) return JSON.stringify(value);
|
|
91
|
+
if (!isPlain(value)) return asProperty(key, value);
|
|
92
|
+
const share = shares.get(value);
|
|
93
|
+
if (share !== undefined) return sharedText(value, share);
|
|
94
|
+
if (depth === 0) return JSON.stringify(value);
|
|
95
|
+
const parts: string[] = [];
|
|
96
|
+
for (const name of Object.keys(value)) {
|
|
97
|
+
const part = encode(value[name], depth - 1, name);
|
|
98
|
+
if (part !== undefined) parts.push(`${JSON.stringify(name)}:${part}`);
|
|
99
|
+
}
|
|
100
|
+
return `{${parts.join(",")}}`;
|
|
101
|
+
}
|
|
@@ -186,8 +186,8 @@ export type StorageObserver<T> = (changes: StorageChangeBatch<T>) => void;
|
|
|
186
186
|
* - The order of a full result is the order of the entries. The order of a delta is the order of
|
|
187
187
|
* the last change of each id.
|
|
188
188
|
* - A result gives each write at least one time. A write can come again in two cases, with the same
|
|
189
|
-
* value or a later value. A full result takes its version
|
|
190
|
-
* hold a write above that version,
|
|
189
|
+
* value or a later value. A full result takes its version after it lists the items, and it can
|
|
190
|
+
* hold a write above that version, so the next delta gives the write again. A write that ends
|
|
191
191
|
* after its lease can come again in a later delta, with a new version. A consumer applies a change
|
|
192
192
|
* by its id, and does not count changes.
|
|
193
193
|
*/
|