cojson 0.20.0 → 0.20.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.
Files changed (91) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +21 -0
  3. package/dist/GarbageCollector.d.ts +3 -3
  4. package/dist/GarbageCollector.d.ts.map +1 -1
  5. package/dist/GarbageCollector.js +4 -4
  6. package/dist/GarbageCollector.js.map +1 -1
  7. package/dist/PeerState.d.ts +6 -1
  8. package/dist/PeerState.d.ts.map +1 -1
  9. package/dist/PeerState.js +18 -3
  10. package/dist/PeerState.js.map +1 -1
  11. package/dist/coValueCore/coValueCore.d.ts +26 -5
  12. package/dist/coValueCore/coValueCore.d.ts.map +1 -1
  13. package/dist/coValueCore/coValueCore.js +115 -50
  14. package/dist/coValueCore/coValueCore.js.map +1 -1
  15. package/dist/coValues/coList.d.ts +1 -0
  16. package/dist/coValues/coList.d.ts.map +1 -1
  17. package/dist/coValues/coList.js +3 -0
  18. package/dist/coValues/coList.js.map +1 -1
  19. package/dist/config.d.ts +2 -2
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/config.js +4 -4
  22. package/dist/config.js.map +1 -1
  23. package/dist/exports.d.ts +3 -3
  24. package/dist/exports.d.ts.map +1 -1
  25. package/dist/exports.js +2 -2
  26. package/dist/exports.js.map +1 -1
  27. package/dist/localNode.d.ts +12 -0
  28. package/dist/localNode.d.ts.map +1 -1
  29. package/dist/localNode.js +51 -3
  30. package/dist/localNode.js.map +1 -1
  31. package/dist/queue/LinkedList.d.ts +9 -3
  32. package/dist/queue/LinkedList.d.ts.map +1 -1
  33. package/dist/queue/LinkedList.js +30 -1
  34. package/dist/queue/LinkedList.js.map +1 -1
  35. package/dist/queue/OutgoingLoadQueue.d.ts +95 -0
  36. package/dist/queue/OutgoingLoadQueue.d.ts.map +1 -0
  37. package/dist/queue/OutgoingLoadQueue.js +240 -0
  38. package/dist/queue/OutgoingLoadQueue.js.map +1 -0
  39. package/dist/sync.d.ts.map +1 -1
  40. package/dist/sync.js +34 -41
  41. package/dist/sync.js.map +1 -1
  42. package/dist/tests/LinkedList.test.js +90 -0
  43. package/dist/tests/LinkedList.test.js.map +1 -1
  44. package/dist/tests/OutgoingLoadQueue.test.d.ts +2 -0
  45. package/dist/tests/OutgoingLoadQueue.test.d.ts.map +1 -0
  46. package/dist/tests/OutgoingLoadQueue.test.js +814 -0
  47. package/dist/tests/OutgoingLoadQueue.test.js.map +1 -0
  48. package/dist/tests/coValueCore.loadFromStorage.test.js +87 -0
  49. package/dist/tests/coValueCore.loadFromStorage.test.js.map +1 -1
  50. package/dist/tests/knownState.lazyLoading.test.js +44 -0
  51. package/dist/tests/knownState.lazyLoading.test.js.map +1 -1
  52. package/dist/tests/sync.concurrentLoad.test.d.ts +2 -0
  53. package/dist/tests/sync.concurrentLoad.test.d.ts.map +1 -0
  54. package/dist/tests/sync.concurrentLoad.test.js +481 -0
  55. package/dist/tests/sync.concurrentLoad.test.js.map +1 -0
  56. package/dist/tests/sync.garbageCollection.test.js +87 -3
  57. package/dist/tests/sync.garbageCollection.test.js.map +1 -1
  58. package/dist/tests/sync.multipleServers.test.js +0 -62
  59. package/dist/tests/sync.multipleServers.test.js.map +1 -1
  60. package/dist/tests/sync.peerReconciliation.test.js +156 -0
  61. package/dist/tests/sync.peerReconciliation.test.js.map +1 -1
  62. package/dist/tests/sync.storage.test.js +1 -1
  63. package/dist/tests/testStorage.d.ts.map +1 -1
  64. package/dist/tests/testStorage.js +3 -1
  65. package/dist/tests/testStorage.js.map +1 -1
  66. package/dist/tests/testUtils.d.ts +1 -0
  67. package/dist/tests/testUtils.d.ts.map +1 -1
  68. package/dist/tests/testUtils.js +2 -1
  69. package/dist/tests/testUtils.js.map +1 -1
  70. package/package.json +4 -4
  71. package/src/GarbageCollector.ts +4 -3
  72. package/src/PeerState.ts +26 -3
  73. package/src/coValueCore/coValueCore.ts +129 -53
  74. package/src/coValues/coList.ts +4 -0
  75. package/src/config.ts +4 -4
  76. package/src/exports.ts +2 -2
  77. package/src/localNode.ts +65 -4
  78. package/src/queue/LinkedList.ts +34 -4
  79. package/src/queue/OutgoingLoadQueue.ts +307 -0
  80. package/src/sync.ts +37 -43
  81. package/src/tests/LinkedList.test.ts +111 -0
  82. package/src/tests/OutgoingLoadQueue.test.ts +1129 -0
  83. package/src/tests/coValueCore.loadFromStorage.test.ts +108 -0
  84. package/src/tests/knownState.lazyLoading.test.ts +52 -0
  85. package/src/tests/sync.concurrentLoad.test.ts +650 -0
  86. package/src/tests/sync.garbageCollection.test.ts +115 -3
  87. package/src/tests/sync.multipleServers.test.ts +0 -65
  88. package/src/tests/sync.peerReconciliation.test.ts +199 -0
  89. package/src/tests/sync.storage.test.ts +1 -1
  90. package/src/tests/testStorage.ts +3 -1
  91. package/src/tests/testUtils.ts +3 -1
@@ -1,6 +1,7 @@
1
1
  import { CoValueCore } from "./coValueCore/coValueCore.js";
2
2
  import { GARBAGE_COLLECTOR_CONFIG } from "./config.js";
3
3
  import { RawCoID } from "./ids.js";
4
+ import type { LocalNode } from "./localNode.js";
4
5
 
5
6
  /**
6
7
  * TTL-based garbage collector for removing unused CoValues from memory.
@@ -8,7 +9,7 @@ import { RawCoID } from "./ids.js";
8
9
  export class GarbageCollector {
9
10
  private readonly interval: ReturnType<typeof setInterval>;
10
11
 
11
- constructor(private readonly coValues: Map<RawCoID, CoValueCore>) {
12
+ constructor(private readonly node: LocalNode) {
12
13
  this.interval = setInterval(() => {
13
14
  this.collect();
14
15
  }, GARBAGE_COLLECTOR_CONFIG.INTERVAL);
@@ -26,7 +27,7 @@ export class GarbageCollector {
26
27
 
27
28
  collect() {
28
29
  const currentTime = this.getCurrentTime();
29
- for (const coValue of this.coValues.values()) {
30
+ for (const coValue of this.node.allCoValues()) {
30
31
  const { verified } = coValue;
31
32
 
32
33
  if (!verified?.lastAccessed) {
@@ -36,7 +37,7 @@ export class GarbageCollector {
36
37
  const timeSinceLastAccessed = currentTime - verified.lastAccessed;
37
38
 
38
39
  if (timeSinceLastAccessed > GARBAGE_COLLECTOR_CONFIG.MAX_AGE) {
39
- coValue.unmount();
40
+ this.node.internalUnmountCoValue(coValue.id);
40
41
  }
41
42
  }
42
43
  }
package/src/PeerState.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { PeerKnownState } from "./coValueCore/PeerKnownState.js";
2
+ import { CoValueCore } from "./exports.js";
2
3
  import { RawCoID } from "./ids.js";
3
4
  import { CoValueKnownState } from "./knownState.js";
4
5
  import { logger } from "./logger.js";
6
+ import { type LoadMode, OutgoingLoadQueue } from "./queue/OutgoingLoadQueue.js";
5
7
  import { Peer, SyncMessage } from "./sync.js";
6
8
 
7
9
  export class PeerState {
@@ -17,6 +19,7 @@ export class PeerState {
17
19
  knownStates: Map<RawCoID, PeerKnownState> | undefined,
18
20
  ) {
19
21
  this._knownStates = knownStates ?? new Map();
22
+ this.loadQueue = new OutgoingLoadQueue(peer.id);
20
23
  }
21
24
 
22
25
  getKnownState(id: RawCoID) {
@@ -54,10 +57,29 @@ export class PeerState {
54
57
 
55
58
  readonly toldKnownState: Set<RawCoID> = new Set();
56
59
  readonly loadRequestSent: Set<RawCoID> = new Set();
60
+ private loadQueue: OutgoingLoadQueue;
57
61
 
58
- trackLoadRequestSent(id: RawCoID) {
59
- this.toldKnownState.add(id);
60
- this.loadRequestSent.add(id);
62
+ sendLoadRequest(coValue: CoValueCore, mode?: LoadMode): void {
63
+ this.toldKnownState.add(coValue.id);
64
+ this.loadRequestSent.add(coValue.id);
65
+ this.loadQueue.enqueue(
66
+ coValue,
67
+ () => {
68
+ this.pushOutgoingMessage({
69
+ action: "load",
70
+ ...coValue.knownStateWithStreaming(),
71
+ });
72
+ },
73
+ mode,
74
+ );
75
+ }
76
+
77
+ trackLoadRequestUpdate(coValue: CoValueCore) {
78
+ this.loadQueue.trackUpdate(coValue);
79
+ }
80
+
81
+ trackLoadRequestComplete(coValue: CoValueCore) {
82
+ this.loadQueue.trackComplete(coValue);
61
83
  }
62
84
 
63
85
  trackToldKnownState(id: RawCoID) {
@@ -192,6 +214,7 @@ export class PeerState {
192
214
  });
193
215
 
194
216
  this.closed = true;
217
+ this.loadQueue.clear();
195
218
  this.peer.outgoing.push("Disconnected");
196
219
  this.peer.outgoing.close();
197
220
  this.peer.incoming.close();
@@ -1,6 +1,7 @@
1
1
  import { UpDownCounter, ValueType, metrics } from "@opentelemetry/api";
2
2
  import type { PeerState } from "../PeerState.js";
3
3
  import type { RawCoValue } from "../coValue.js";
4
+ import type { LoadMode } from "../queue/OutgoingLoadQueue.js";
4
5
  import {
5
6
  RawAccount,
6
7
  type ControlledAccountOrAgent,
@@ -281,6 +282,15 @@ export class CoValueCore {
281
282
  }
282
283
  >();
283
284
 
285
+ // Tracks why we have lastKnownState (separate from loadingStatuses)
286
+ // - "garbageCollected": was in memory, got GC'd
287
+ // - "onlyKnownState": checked storage, found knownState, but didn't load full content
288
+ #lastKnownStateSource?: "garbageCollected" | "onlyKnownState";
289
+
290
+ // Cache the knownState when transitioning to garbageCollected/onlyKnownState
291
+ // Used during peer reconciliation to send accurate LOAD requests
292
+ #lastKnownState?: CoValueKnownState;
293
+
284
294
  // cached state and listeners
285
295
  private _cachedContent?: RawCoValue;
286
296
  readonly listeners: Set<(core: CoValueCore, unsub: () => void) => void> =
@@ -307,14 +317,26 @@ export class CoValueCore {
307
317
  get loadingState() {
308
318
  if (this.verified) {
309
319
  return "available";
310
- } else if (this.loadingStatuses.size === 0) {
311
- return "unknown";
312
320
  }
313
321
 
322
+ // Check for pending peers FIRST - loading takes priority over other states
314
323
  for (const peer of this.loadingStatuses.values()) {
315
324
  if (peer.type === "pending") {
316
325
  return "loading";
317
- } else if (peer.type === "unknown") {
326
+ }
327
+ }
328
+
329
+ // Check for lastKnownStateSource (garbageCollected or onlyKnownState)
330
+ if (this.#lastKnownStateSource) {
331
+ return this.#lastKnownStateSource;
332
+ }
333
+
334
+ if (this.loadingStatuses.size === 0) {
335
+ return "unknown";
336
+ }
337
+
338
+ for (const peer of this.loadingStatuses.values()) {
339
+ if (peer.type === "unknown") {
318
340
  return "unknown";
319
341
  }
320
342
  }
@@ -428,32 +450,22 @@ export class CoValueCore {
428
450
  }
429
451
 
430
452
  /**
431
- * Removes the CoValue from memory.
453
+ * Removes the CoValue content from memory but keeps a shell with cached knownState.
454
+ * This enables accurate LOAD requests during peer reconciliation.
432
455
  *
433
456
  * @returns true if the coValue was successfully unmounted, false otherwise
434
457
  */
435
458
  unmount(): boolean {
436
- if (this.listeners.size > 0) {
437
- // The coValue is still in use
438
- return false;
439
- }
440
-
441
- for (const dependant of this.dependant) {
442
- if (this.node.hasCoValue(dependant)) {
443
- // Another in-memory coValue depends on this coValue
444
- return false;
445
- }
446
- }
447
-
448
- if (!this.node.syncManager.isSyncedToServerPeers(this.id)) {
449
- return false;
450
- }
459
+ return this.node.internalUnmountCoValue(this.id);
460
+ }
451
461
 
462
+ /**
463
+ * Decrements the counter for the current loading state.
464
+ * Used during unmount to properly track state transitions.
465
+ * @internal
466
+ */
467
+ decrementLoadingStateCounter() {
452
468
  this.counter.add(-1, { state: this.loadingState });
453
-
454
- this.node.internalDeleteCoValue(this.id);
455
-
456
- return true;
457
469
  }
458
470
 
459
471
  markNotFoundInPeer(peerId: PeerID) {
@@ -469,6 +481,35 @@ export class CoValueCore {
469
481
  this.scheduleNotifyUpdate();
470
482
  }
471
483
 
484
+ /**
485
+ * Clean up cached state when CoValue becomes available.
486
+ * Called after the CoValue transitions from garbageCollected/onlyKnownState to available.
487
+ */
488
+ private cleanupLastKnownState() {
489
+ // Clear both fields - in-memory verified state is now authoritative
490
+ this.#lastKnownStateSource = undefined;
491
+ this.#lastKnownState = undefined;
492
+ }
493
+
494
+ /**
495
+ * Initialize this CoValueCore as a garbageCollected shell.
496
+ * Called when creating a replacement CoValueCore after unmounting.
497
+ */
498
+ setGarbageCollectedState(knownState: CoValueKnownState) {
499
+ // Only set garbageCollected state if storage is active
500
+ // Without storage, we can't reload the CoValue anyway
501
+ if (!this.node.storage) {
502
+ return;
503
+ }
504
+
505
+ // Transition counter from 'unknown' (set by constructor) to 'garbageCollected'
506
+ // previousState will be 'unknown', newState will be 'garbageCollected'
507
+ const previousState = this.loadingState;
508
+ this.#lastKnownStateSource = "garbageCollected";
509
+ this.#lastKnownState = knownState;
510
+ this.updateCounter(previousState);
511
+ }
512
+
472
513
  missingDependencies = new Set<RawCoID>();
473
514
 
474
515
  isCircularDependency(dependency: CoValueCore) {
@@ -575,6 +616,10 @@ export class CoValueCore {
575
616
  header,
576
617
  new SessionMap(this.id, this.node.crypto, streamingKnownState),
577
618
  );
619
+ // Clean up if transitioning from garbageCollected/onlyKnownState
620
+ if (this.isAvailable()) {
621
+ this.cleanupLastKnownState();
622
+ }
578
623
 
579
624
  return true;
580
625
  }
@@ -605,10 +650,11 @@ export class CoValueCore {
605
650
  * Used to correctly manage the content & subscriptions during the content streaming process
606
651
  */
607
652
  knownStateWithStreaming(): CoValueKnownState {
608
- return (
609
- this.verified?.immutableKnownStateWithStreaming() ??
610
- emptyKnownState(this.id)
611
- );
653
+ if (this.verified) {
654
+ return this.verified.immutableKnownStateWithStreaming();
655
+ }
656
+
657
+ return this.knownState();
612
658
  }
613
659
 
614
660
  /**
@@ -617,9 +663,22 @@ export class CoValueCore {
617
663
  * The return value identity is going to be stable as long as the CoValue is not modified.
618
664
  *
619
665
  * On change the knownState is invalidated and a new object is returned.
666
+ *
667
+ * For garbageCollected/onlyKnownState CoValues, returns the cached knownState.
620
668
  */
621
669
  knownState(): CoValueKnownState {
622
- return this.verified?.immutableKnownState() ?? emptyKnownState(this.id);
670
+ // 1. If we have verified content in memory, use that (authoritative)
671
+ if (this.verified) {
672
+ return this.verified.immutableKnownState();
673
+ }
674
+
675
+ // 2. If we have last known state (GC'd or onlyKnownState), use that
676
+ if (this.#lastKnownState) {
677
+ return this.#lastKnownState;
678
+ }
679
+
680
+ // 3. Fallback to empty state (truly unknown CoValue)
681
+ return emptyKnownState(this.id);
623
682
  }
624
683
 
625
684
  /**
@@ -1847,11 +1906,11 @@ export class CoValueCore {
1847
1906
  return this.node.syncManager.waitForSync(this.id, options?.timeout);
1848
1907
  }
1849
1908
 
1850
- load(peers: PeerState[]) {
1909
+ load(peers: PeerState[], mode?: LoadMode) {
1851
1910
  this.loadFromStorage((found) => {
1852
1911
  // When found the load is triggered by handleNewContent
1853
1912
  if (!found) {
1854
- this.loadFromPeers(peers);
1913
+ this.loadFromPeers(peers, mode);
1855
1914
  }
1856
1915
  });
1857
1916
  }
@@ -1890,7 +1949,16 @@ export class CoValueCore {
1890
1949
  return;
1891
1950
  }
1892
1951
 
1893
- if (currentState !== "unknown") {
1952
+ // Check if we need to load from storage:
1953
+ // - If storage state is not unknown (already tried), AND
1954
+ // - Overall state is not garbageCollected/onlyKnownState (which need full content)
1955
+ // Then return early
1956
+ const overallState = this.loadingState;
1957
+ if (
1958
+ currentState !== "unknown" &&
1959
+ overallState !== "garbageCollected" &&
1960
+ overallState !== "onlyKnownState"
1961
+ ) {
1894
1962
  done?.(currentState === "available");
1895
1963
  return;
1896
1964
  }
@@ -1915,7 +1983,8 @@ export class CoValueCore {
1915
1983
  * Lazily load only the knownState from storage without loading full transaction data.
1916
1984
  * This is useful for checking if a peer needs new content before committing to a full load.
1917
1985
  *
1918
- * Caching and deduplication are handled at the storage layer.
1986
+ * If found in storage, marks the CoValue as onlyKnownState and caches the knownState.
1987
+ * This enables accurate LOAD requests during peer reconciliation.
1919
1988
  *
1920
1989
  * @param done - Callback with the storage knownState, or undefined if not found in storage
1921
1990
  */
@@ -1927,17 +1996,29 @@ export class CoValueCore {
1927
1996
  return;
1928
1997
  }
1929
1998
 
1930
- // If already available in memory, return the current knownState
1931
- if (this.isAvailable()) {
1932
- done(this.knownState());
1999
+ // If we already have knowledge about this CoValue (in memory or cached), return it
2000
+ // knownState() returns verified state, lastKnownState, or empty state
2001
+ const knownState = this.knownState();
2002
+ if (knownState.header) {
2003
+ done(knownState);
1933
2004
  return;
1934
2005
  }
1935
2006
 
1936
2007
  // Delegate to storage - caching is handled at storage level
1937
- this.node.storage.loadKnownState(this.id, done);
2008
+ this.node.storage.loadKnownState(this.id, (knownState) => {
2009
+ // The coValue could become available in the meantime.
2010
+ if (knownState && !this.isAvailable()) {
2011
+ // Cache the knownState and mark as onlyKnownState
2012
+ const previousState = this.loadingState;
2013
+ this.#lastKnownStateSource = "onlyKnownState";
2014
+ this.#lastKnownState = knownState;
2015
+ this.updateCounter(previousState);
2016
+ }
2017
+ done(knownState);
2018
+ });
1938
2019
  }
1939
2020
 
1940
- loadFromPeers(peers: PeerState[]) {
2021
+ loadFromPeers(peers: PeerState[], mode?: LoadMode) {
1941
2022
  if (peers.length === 0) {
1942
2023
  return;
1943
2024
  }
@@ -1947,29 +2028,17 @@ export class CoValueCore {
1947
2028
 
1948
2029
  if (currentState === "unknown" || currentState === "unavailable") {
1949
2030
  this.markPending(peer.id);
1950
- this.internalLoadFromPeer(peer);
2031
+ this.internalLoadFromPeer(peer, mode);
1951
2032
  }
1952
2033
  }
1953
2034
  }
1954
2035
 
1955
- private internalLoadFromPeer(peer: PeerState) {
2036
+ private internalLoadFromPeer(peer: PeerState, mode?: LoadMode) {
1956
2037
  if (peer.closed && !peer.persistent) {
1957
2038
  this.markNotFoundInPeer(peer.id);
1958
2039
  return;
1959
2040
  }
1960
2041
 
1961
- /**
1962
- * On reconnection persistent peers will automatically fire the load request
1963
- * as part of the reconnection process.
1964
- */
1965
- if (!peer.closed) {
1966
- peer.pushOutgoingMessage({
1967
- action: "load",
1968
- ...this.knownState(),
1969
- });
1970
- peer.trackLoadRequestSent(this.id);
1971
- }
1972
-
1973
2042
  const markNotFound = () => {
1974
2043
  if (this.getLoadingStateForPeer(peer.id) === "pending") {
1975
2044
  logger.warn("Timeout waiting for peer to load coValue", {
@@ -1980,11 +2049,19 @@ export class CoValueCore {
1980
2049
  }
1981
2050
  };
1982
2051
 
1983
- const timeout = setTimeout(markNotFound, CO_VALUE_LOADING_CONFIG.TIMEOUT);
2052
+ // Close listener for non-persistent peers
1984
2053
  const removeCloseListener = peer.persistent
1985
2054
  ? undefined
1986
2055
  : peer.addCloseListener(markNotFound);
1987
2056
 
2057
+ /**
2058
+ * On reconnection persistent peers will automatically fire the load request
2059
+ * as part of the reconnection process.
2060
+ */
2061
+ if (!peer.closed) {
2062
+ peer.sendLoadRequest(this, mode);
2063
+ }
2064
+
1988
2065
  this.subscribe((state, unsubscribe) => {
1989
2066
  const peerState = state.getLoadingStateForPeer(peer.id);
1990
2067
  if (
@@ -1995,7 +2072,6 @@ export class CoValueCore {
1995
2072
  ) {
1996
2073
  unsubscribe();
1997
2074
  removeCloseListener?.();
1998
- clearTimeout(timeout);
1999
2075
  }
2000
2076
  }, true);
2001
2077
  }
@@ -378,6 +378,10 @@ export class RawCoList<
378
378
  return arr;
379
379
  }
380
380
 
381
+ length() {
382
+ return this.entries().length;
383
+ }
384
+
381
385
  /** @internal */
382
386
  entriesUncached(): {
383
387
  value: Item;
package/src/config.ts CHANGED
@@ -15,8 +15,9 @@ export function setMaxRecommendedTxSize(size: number) {
15
15
 
16
16
  export const CO_VALUE_LOADING_CONFIG = {
17
17
  MAX_RETRIES: 1,
18
- TIMEOUT: 30_000,
18
+ TIMEOUT: 60_000,
19
19
  RETRY_DELAY: 3000,
20
+ MAX_IN_FLIGHT_LOADS_PER_PEER: 1000,
20
21
  };
21
22
 
22
23
  export function setCoValueLoadingMaxRetries(maxRetries: number) {
@@ -54,13 +55,12 @@ export function setGarbageCollectorInterval(interval: number) {
54
55
 
55
56
  export const WEBSOCKET_CONFIG = {
56
57
  MAX_OUTGOING_MESSAGES_CHUNK_BYTES: 25_000,
57
- OUTGOING_MESSAGES_CHUNK_DELAY: 5,
58
58
  };
59
59
 
60
60
  export function setMaxOutgoingMessagesChunkBytes(bytes: number) {
61
61
  WEBSOCKET_CONFIG.MAX_OUTGOING_MESSAGES_CHUNK_BYTES = bytes;
62
62
  }
63
63
 
64
- export function setOutgoingMessagesChunkDelay(delay: number) {
65
- WEBSOCKET_CONFIG.OUTGOING_MESSAGES_CHUNK_DELAY = delay;
64
+ export function setMaxInFlightLoadsPerPeer(limit: number) {
65
+ CO_VALUE_LOADING_CONFIG.MAX_IN_FLIGHT_LOADS_PER_PEER = limit;
66
66
  }
package/src/exports.ts CHANGED
@@ -88,8 +88,8 @@ import {
88
88
  setCoValueLoadingRetryDelay,
89
89
  setCoValueLoadingTimeout,
90
90
  setIncomingMessagesTimeBudget,
91
+ setMaxInFlightLoadsPerPeer,
91
92
  setMaxOutgoingMessagesChunkBytes,
92
- setOutgoingMessagesChunkDelay,
93
93
  setMaxRecommendedTxSize,
94
94
  } from "./config.js";
95
95
  import { LogLevel, logger } from "./logger.js";
@@ -142,7 +142,7 @@ export const cojsonInternals = {
142
142
  canBeBranched,
143
143
  WEBSOCKET_CONFIG,
144
144
  setMaxOutgoingMessagesChunkBytes,
145
- setOutgoingMessagesChunkDelay,
145
+ setMaxInFlightLoadsPerPeer,
146
146
  };
147
147
 
148
148
  export {
package/src/localNode.ts CHANGED
@@ -39,7 +39,7 @@ import { accountOrAgentIDfromSessionID } from "./typeUtils/accountOrAgentIDfromS
39
39
  import { expectGroup } from "./typeUtils/expectGroup.js";
40
40
  import { canBeBranched } from "./coValueCore/branching.js";
41
41
  import { connectedPeers } from "./streamUtils.js";
42
- import { emptyKnownState } from "./knownState.js";
42
+ import { CoValueKnownState, emptyKnownState } from "./knownState.js";
43
43
 
44
44
  /** A `LocalNode` represents a local view of a set of loaded `CoValue`s, from the perspective of a particular account (or primitive cryptographic agent).
45
45
 
@@ -87,7 +87,7 @@ export class LocalNode {
87
87
  return;
88
88
  }
89
89
 
90
- this.garbageCollector = new GarbageCollector(this.coValues);
90
+ this.garbageCollector = new GarbageCollector(this);
91
91
  }
92
92
 
93
93
  setStorage(storage: StorageAPI) {
@@ -123,7 +123,13 @@ export class LocalNode {
123
123
  return false;
124
124
  }
125
125
 
126
- return coValue.loadingState !== "unknown";
126
+ const state = coValue.loadingState;
127
+ // garbageCollected and onlyKnownState shells don't have actual content loaded
128
+ return (
129
+ state !== "unknown" &&
130
+ state !== "garbageCollected" &&
131
+ state !== "onlyKnownState"
132
+ );
127
133
  }
128
134
 
129
135
  getCoValue(id: RawCoID) {
@@ -143,11 +149,64 @@ export class LocalNode {
143
149
  return this.coValues.values();
144
150
  }
145
151
 
152
+ /**
153
+ * Simple delete of a CoValue from memory.
154
+ * Used for testing and forced cleanup scenarios.
155
+ * @internal
156
+ */
146
157
  internalDeleteCoValue(id: RawCoID) {
147
158
  this.coValues.delete(id);
148
159
  this.storage?.onCoValueUnmounted(id);
149
160
  }
150
161
 
162
+ /**
163
+ * Unmount a CoValue from memory, keeping a shell with cached knownState.
164
+ * This enables accurate LOAD requests during peer reconciliation.
165
+ *
166
+ * @returns true if the coValue was successfully unmounted, false otherwise
167
+ */
168
+ internalUnmountCoValue(id: RawCoID): boolean {
169
+ const coValue = this.coValues.get(id);
170
+
171
+ if (!coValue) {
172
+ return false;
173
+ }
174
+
175
+ if (coValue.listeners.size > 0) {
176
+ // The coValue is still in use
177
+ return false;
178
+ }
179
+
180
+ for (const dependant of coValue.dependant) {
181
+ if (this.hasCoValue(dependant)) {
182
+ // Another in-memory coValue depends on this coValue
183
+ return false;
184
+ }
185
+ }
186
+
187
+ if (!this.syncManager.isSyncedToServerPeers(id)) {
188
+ return false;
189
+ }
190
+
191
+ // Cache the knownState before replacing
192
+ const lastKnownState = coValue.knownState();
193
+
194
+ // Handle counter: decrement old coValue's state
195
+ coValue.decrementLoadingStateCounter();
196
+
197
+ // Create new shell CoValueCore in garbageCollected state
198
+ const shell = new CoValueCore(id, this);
199
+ shell.setGarbageCollectedState(lastKnownState);
200
+
201
+ // Single map update (replacing old with shell)
202
+ this.coValues.set(id, shell);
203
+
204
+ // Notify storage
205
+ this.storage?.onCoValueUnmounted(id);
206
+
207
+ return true;
208
+ }
209
+
151
210
  getCurrentAccountOrAgentID(): RawAccountID | AgentID {
152
211
  return accountOrAgentIDfromSessionID(this.currentSessionID);
153
212
  }
@@ -449,7 +508,9 @@ export class LocalNode {
449
508
 
450
509
  if (
451
510
  coValue.loadingState === "unknown" ||
452
- coValue.loadingState === "unavailable"
511
+ coValue.loadingState === "unavailable" ||
512
+ coValue.loadingState === "garbageCollected" ||
513
+ coValue.loadingState === "onlyKnownState"
453
514
  ) {
454
515
  const peers = this.syncManager.getServerPeers(id, skipLoadingFromPeer);
455
516
 
@@ -10,8 +10,9 @@ type Tuple<T, N extends number, A extends unknown[] = []> = A extends {
10
10
  ? A
11
11
  : Tuple<T, N, [...A, T]>;
12
12
  export type QueueTuple = Tuple<LinkedList<SyncMessage>, 3>;
13
- type LinkedListNode<T> = {
13
+ export type LinkedListNode<T> = {
14
14
  value: T;
15
+ prev: LinkedListNode<T> | undefined;
15
16
  next: LinkedListNode<T> | undefined;
16
17
  };
17
18
  /**
@@ -26,13 +27,14 @@ export class LinkedList<T> {
26
27
  tail: LinkedListNode<T> | undefined = undefined;
27
28
  length = 0;
28
29
 
29
- push(value: T) {
30
- const node = { value, next: undefined };
30
+ push(value: T): LinkedListNode<T> {
31
+ const node: LinkedListNode<T> = { value, prev: undefined, next: undefined };
31
32
 
32
33
  if (this.head === undefined) {
33
34
  this.head = node;
34
35
  this.tail = node;
35
36
  } else if (this.tail) {
37
+ node.prev = this.tail;
36
38
  this.tail.next = node;
37
39
  this.tail = node;
38
40
  } else {
@@ -41,6 +43,7 @@ export class LinkedList<T> {
41
43
 
42
44
  this.length++;
43
45
  this.meter?.push();
46
+ return node;
44
47
  }
45
48
 
46
49
  shift() {
@@ -55,6 +58,8 @@ export class LinkedList<T> {
55
58
 
56
59
  if (this.head === undefined) {
57
60
  this.tail = undefined;
61
+ } else {
62
+ this.head.prev = undefined;
58
63
  }
59
64
 
60
65
  this.length--;
@@ -63,6 +68,31 @@ export class LinkedList<T> {
63
68
  return value;
64
69
  }
65
70
 
71
+ /**
72
+ * Remove a specific node from the list in O(1) time.
73
+ * The node must be a valid node that was returned by push().
74
+ */
75
+ remove(node: LinkedListNode<T>): void {
76
+ if (node.prev) {
77
+ node.prev.next = node.next;
78
+ } else {
79
+ // Node is the head
80
+ this.head = node.next;
81
+ }
82
+
83
+ if (node.next) {
84
+ node.next.prev = node.prev;
85
+ } else {
86
+ // Node is the tail
87
+ this.tail = node.prev;
88
+ }
89
+
90
+ node.prev = undefined;
91
+ node.next = undefined;
92
+ this.length--;
93
+ this.meter?.pull();
94
+ }
95
+
66
96
  isEmpty() {
67
97
  return this.head === undefined;
68
98
  }
@@ -108,7 +138,7 @@ class QueueMeter {
108
138
  }
109
139
  }
110
140
  export function meteredList<T>(
111
- type: "incoming" | "outgoing" | "storage-streaming",
141
+ type: "incoming" | "outgoing" | "storage-streaming" | "load-requests-queue",
112
142
  attrs?: Record<string, string | number>,
113
143
  ) {
114
144
  return new LinkedList<T>(new QueueMeter("jazz.messagequeue." + type, attrs));