cojson 0.20.1 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +9 -0
- package/dist/GarbageCollector.d.ts +3 -3
- package/dist/GarbageCollector.d.ts.map +1 -1
- package/dist/GarbageCollector.js +4 -4
- package/dist/GarbageCollector.js.map +1 -1
- package/dist/coValueCore/coValueCore.d.ts +23 -3
- package/dist/coValueCore/coValueCore.d.ts.map +1 -1
- package/dist/coValueCore/coValueCore.js +102 -31
- package/dist/coValueCore/coValueCore.js.map +1 -1
- package/dist/localNode.d.ts +12 -0
- package/dist/localNode.d.ts.map +1 -1
- package/dist/localNode.js +51 -3
- package/dist/localNode.js.map +1 -1
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +13 -10
- package/dist/sync.js.map +1 -1
- package/dist/tests/coValueCore.loadFromStorage.test.js +87 -0
- package/dist/tests/coValueCore.loadFromStorage.test.js.map +1 -1
- package/dist/tests/knownState.lazyLoading.test.js +44 -0
- package/dist/tests/knownState.lazyLoading.test.js.map +1 -1
- package/dist/tests/sync.garbageCollection.test.js +87 -3
- package/dist/tests/sync.garbageCollection.test.js.map +1 -1
- package/dist/tests/sync.multipleServers.test.js +0 -62
- package/dist/tests/sync.multipleServers.test.js.map +1 -1
- package/dist/tests/sync.peerReconciliation.test.js +156 -0
- package/dist/tests/sync.peerReconciliation.test.js.map +1 -1
- package/package.json +4 -4
- package/src/GarbageCollector.ts +4 -3
- package/src/coValueCore/coValueCore.ts +114 -34
- package/src/localNode.ts +65 -4
- package/src/sync.ts +11 -9
- package/src/tests/coValueCore.loadFromStorage.test.ts +108 -0
- package/src/tests/knownState.lazyLoading.test.ts +52 -0
- package/src/tests/sync.garbageCollection.test.ts +115 -3
- package/src/tests/sync.multipleServers.test.ts +0 -65
- package/src/tests/sync.peerReconciliation.test.ts +199 -0
|
@@ -282,6 +282,15 @@ export class CoValueCore {
|
|
|
282
282
|
}
|
|
283
283
|
>();
|
|
284
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
|
+
|
|
285
294
|
// cached state and listeners
|
|
286
295
|
private _cachedContent?: RawCoValue;
|
|
287
296
|
readonly listeners: Set<(core: CoValueCore, unsub: () => void) => void> =
|
|
@@ -308,14 +317,26 @@ export class CoValueCore {
|
|
|
308
317
|
get loadingState() {
|
|
309
318
|
if (this.verified) {
|
|
310
319
|
return "available";
|
|
311
|
-
} else if (this.loadingStatuses.size === 0) {
|
|
312
|
-
return "unknown";
|
|
313
320
|
}
|
|
314
321
|
|
|
322
|
+
// Check for pending peers FIRST - loading takes priority over other states
|
|
315
323
|
for (const peer of this.loadingStatuses.values()) {
|
|
316
324
|
if (peer.type === "pending") {
|
|
317
325
|
return "loading";
|
|
318
|
-
}
|
|
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") {
|
|
319
340
|
return "unknown";
|
|
320
341
|
}
|
|
321
342
|
}
|
|
@@ -429,32 +450,22 @@ export class CoValueCore {
|
|
|
429
450
|
}
|
|
430
451
|
|
|
431
452
|
/**
|
|
432
|
-
* 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.
|
|
433
455
|
*
|
|
434
456
|
* @returns true if the coValue was successfully unmounted, false otherwise
|
|
435
457
|
*/
|
|
436
458
|
unmount(): boolean {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
return false;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
for (const dependant of this.dependant) {
|
|
443
|
-
if (this.node.hasCoValue(dependant)) {
|
|
444
|
-
// Another in-memory coValue depends on this coValue
|
|
445
|
-
return false;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
if (!this.node.syncManager.isSyncedToServerPeers(this.id)) {
|
|
450
|
-
return false;
|
|
451
|
-
}
|
|
459
|
+
return this.node.internalUnmountCoValue(this.id);
|
|
460
|
+
}
|
|
452
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() {
|
|
453
468
|
this.counter.add(-1, { state: this.loadingState });
|
|
454
|
-
|
|
455
|
-
this.node.internalDeleteCoValue(this.id);
|
|
456
|
-
|
|
457
|
-
return true;
|
|
458
469
|
}
|
|
459
470
|
|
|
460
471
|
markNotFoundInPeer(peerId: PeerID) {
|
|
@@ -470,6 +481,35 @@ export class CoValueCore {
|
|
|
470
481
|
this.scheduleNotifyUpdate();
|
|
471
482
|
}
|
|
472
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
|
+
|
|
473
513
|
missingDependencies = new Set<RawCoID>();
|
|
474
514
|
|
|
475
515
|
isCircularDependency(dependency: CoValueCore) {
|
|
@@ -576,6 +616,10 @@ export class CoValueCore {
|
|
|
576
616
|
header,
|
|
577
617
|
new SessionMap(this.id, this.node.crypto, streamingKnownState),
|
|
578
618
|
);
|
|
619
|
+
// Clean up if transitioning from garbageCollected/onlyKnownState
|
|
620
|
+
if (this.isAvailable()) {
|
|
621
|
+
this.cleanupLastKnownState();
|
|
622
|
+
}
|
|
579
623
|
|
|
580
624
|
return true;
|
|
581
625
|
}
|
|
@@ -606,10 +650,11 @@ export class CoValueCore {
|
|
|
606
650
|
* Used to correctly manage the content & subscriptions during the content streaming process
|
|
607
651
|
*/
|
|
608
652
|
knownStateWithStreaming(): CoValueKnownState {
|
|
609
|
-
|
|
610
|
-
this.verified
|
|
611
|
-
|
|
612
|
-
|
|
653
|
+
if (this.verified) {
|
|
654
|
+
return this.verified.immutableKnownStateWithStreaming();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return this.knownState();
|
|
613
658
|
}
|
|
614
659
|
|
|
615
660
|
/**
|
|
@@ -618,9 +663,22 @@ export class CoValueCore {
|
|
|
618
663
|
* The return value identity is going to be stable as long as the CoValue is not modified.
|
|
619
664
|
*
|
|
620
665
|
* On change the knownState is invalidated and a new object is returned.
|
|
666
|
+
*
|
|
667
|
+
* For garbageCollected/onlyKnownState CoValues, returns the cached knownState.
|
|
621
668
|
*/
|
|
622
669
|
knownState(): CoValueKnownState {
|
|
623
|
-
|
|
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);
|
|
624
682
|
}
|
|
625
683
|
|
|
626
684
|
/**
|
|
@@ -1891,7 +1949,16 @@ export class CoValueCore {
|
|
|
1891
1949
|
return;
|
|
1892
1950
|
}
|
|
1893
1951
|
|
|
1894
|
-
if
|
|
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
|
+
) {
|
|
1895
1962
|
done?.(currentState === "available");
|
|
1896
1963
|
return;
|
|
1897
1964
|
}
|
|
@@ -1916,7 +1983,8 @@ export class CoValueCore {
|
|
|
1916
1983
|
* Lazily load only the knownState from storage without loading full transaction data.
|
|
1917
1984
|
* This is useful for checking if a peer needs new content before committing to a full load.
|
|
1918
1985
|
*
|
|
1919
|
-
*
|
|
1986
|
+
* If found in storage, marks the CoValue as onlyKnownState and caches the knownState.
|
|
1987
|
+
* This enables accurate LOAD requests during peer reconciliation.
|
|
1920
1988
|
*
|
|
1921
1989
|
* @param done - Callback with the storage knownState, or undefined if not found in storage
|
|
1922
1990
|
*/
|
|
@@ -1928,14 +1996,26 @@ export class CoValueCore {
|
|
|
1928
1996
|
return;
|
|
1929
1997
|
}
|
|
1930
1998
|
|
|
1931
|
-
// If already
|
|
1932
|
-
|
|
1933
|
-
|
|
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);
|
|
1934
2004
|
return;
|
|
1935
2005
|
}
|
|
1936
2006
|
|
|
1937
2007
|
// Delegate to storage - caching is handled at storage level
|
|
1938
|
-
this.node.storage.loadKnownState(this.id,
|
|
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
|
+
});
|
|
1939
2019
|
}
|
|
1940
2020
|
|
|
1941
2021
|
loadFromPeers(peers: PeerState[], mode?: LoadMode) {
|
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
|
|
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
|
-
|
|
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
|
|
package/src/sync.ts
CHANGED
|
@@ -401,16 +401,18 @@ export class SyncManager {
|
|
|
401
401
|
};
|
|
402
402
|
|
|
403
403
|
for (const coValue of this.local.allCoValues()) {
|
|
404
|
-
if (
|
|
405
|
-
//
|
|
406
|
-
// we try to load it from the peer
|
|
407
|
-
if (!peer.loadRequestSent.has(coValue.id)) {
|
|
408
|
-
peer.sendLoadRequest(coValue, "low-priority");
|
|
409
|
-
}
|
|
410
|
-
} else {
|
|
411
|
-
// Build the list of coValues ordered by dependency
|
|
412
|
-
// so we can send the load message in the correct order
|
|
404
|
+
if (coValue.isAvailable()) {
|
|
405
|
+
// In memory - build ordered list for dependency-aware sending
|
|
413
406
|
buildOrderedCoValueList(coValue);
|
|
407
|
+
} else if (coValue.loadingState === "unknown") {
|
|
408
|
+
// Skip unknown CoValues - we never tried to load them, so don't
|
|
409
|
+
// restore a subscription we never had. This prevents loading
|
|
410
|
+
// content for CoValues we don't actually care about.
|
|
411
|
+
continue;
|
|
412
|
+
} else if (!peer.loadRequestSent.has(coValue.id)) {
|
|
413
|
+
// For garbageCollected/onlyKnownState: knownState() returns lastKnownState
|
|
414
|
+
// For unavailable/loading/errored: knownState() returns empty state
|
|
415
|
+
peer.sendLoadRequest(coValue, "low-priority");
|
|
414
416
|
}
|
|
415
417
|
|
|
416
418
|
// Fill the missing known states with empty known states
|
|
@@ -470,6 +470,114 @@ describe("CoValueCore.loadFromStorage", () => {
|
|
|
470
470
|
});
|
|
471
471
|
});
|
|
472
472
|
|
|
473
|
+
describe("when state is garbageCollected", () => {
|
|
474
|
+
test("should load from storage even if storage state is not unknown", () => {
|
|
475
|
+
const { state, node, header, id } = setup();
|
|
476
|
+
const loadSpy = vi.fn();
|
|
477
|
+
const storage = createMockStorage({ load: loadSpy });
|
|
478
|
+
node.setStorage(storage);
|
|
479
|
+
|
|
480
|
+
// First, simulate that storage was previously accessed and marked available
|
|
481
|
+
state.markFoundInPeer("storage", state.loadingState);
|
|
482
|
+
|
|
483
|
+
// Then set the CoValue to garbageCollected state (simulating GC)
|
|
484
|
+
// This is what happens when a GC'd CoValueCore shell is created
|
|
485
|
+
state.setGarbageCollectedState({
|
|
486
|
+
id,
|
|
487
|
+
header: true,
|
|
488
|
+
sessions: {},
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// Verify we're in garbageCollected state
|
|
492
|
+
expect(state.loadingState).toBe("garbageCollected");
|
|
493
|
+
|
|
494
|
+
// Now call loadFromStorage - it should proceed to load
|
|
495
|
+
state.loadFromStorage();
|
|
496
|
+
|
|
497
|
+
// Should have called storage.load because we need full content
|
|
498
|
+
expect(loadSpy).toHaveBeenCalledTimes(1);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("should load from storage when garbageCollected and storage state is unknown", () => {
|
|
502
|
+
const { state, node, id } = setup();
|
|
503
|
+
const loadSpy = vi.fn();
|
|
504
|
+
const storage = createMockStorage({ load: loadSpy });
|
|
505
|
+
node.setStorage(storage);
|
|
506
|
+
|
|
507
|
+
// Set the CoValue to garbageCollected state
|
|
508
|
+
state.setGarbageCollectedState({
|
|
509
|
+
id,
|
|
510
|
+
header: true,
|
|
511
|
+
sessions: {},
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
expect(state.loadingState).toBe("garbageCollected");
|
|
515
|
+
expect(state.getLoadingStateForPeer("storage")).toBe("unknown");
|
|
516
|
+
|
|
517
|
+
state.loadFromStorage();
|
|
518
|
+
|
|
519
|
+
expect(loadSpy).toHaveBeenCalledTimes(1);
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
describe("when state is onlyKnownState", () => {
|
|
524
|
+
test("should load from storage to get full content", () => {
|
|
525
|
+
const { state, node, id } = setup();
|
|
526
|
+
const loadSpy = vi.fn();
|
|
527
|
+
const storage = createMockStorage({
|
|
528
|
+
load: loadSpy,
|
|
529
|
+
loadKnownState: (id, callback) => {
|
|
530
|
+
// Simulate storage finding knownState
|
|
531
|
+
callback({
|
|
532
|
+
id,
|
|
533
|
+
header: true,
|
|
534
|
+
sessions: { session1: 5 },
|
|
535
|
+
});
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
node.setStorage(storage);
|
|
539
|
+
|
|
540
|
+
// First, call getKnownStateFromStorage to set onlyKnownState
|
|
541
|
+
state.getKnownStateFromStorage((knownState) => {
|
|
542
|
+
expect(knownState).toBeDefined();
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// Verify we're in onlyKnownState
|
|
546
|
+
expect(state.loadingState).toBe("onlyKnownState");
|
|
547
|
+
|
|
548
|
+
// Now call loadFromStorage - it should proceed to load full content
|
|
549
|
+
state.loadFromStorage();
|
|
550
|
+
|
|
551
|
+
expect(loadSpy).toHaveBeenCalledTimes(1);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("should load from storage when onlyKnownState and storage state is unknown", () => {
|
|
555
|
+
const { state, node, id } = setup();
|
|
556
|
+
const loadSpy = vi.fn();
|
|
557
|
+
const storage = createMockStorage({
|
|
558
|
+
load: loadSpy,
|
|
559
|
+
loadKnownState: (id, callback) => {
|
|
560
|
+
callback({
|
|
561
|
+
id,
|
|
562
|
+
header: true,
|
|
563
|
+
sessions: {},
|
|
564
|
+
});
|
|
565
|
+
},
|
|
566
|
+
});
|
|
567
|
+
node.setStorage(storage);
|
|
568
|
+
|
|
569
|
+
// Set onlyKnownState via getKnownStateFromStorage
|
|
570
|
+
state.getKnownStateFromStorage(() => {});
|
|
571
|
+
|
|
572
|
+
expect(state.loadingState).toBe("onlyKnownState");
|
|
573
|
+
expect(state.getLoadingStateForPeer("storage")).toBe("unknown");
|
|
574
|
+
|
|
575
|
+
state.loadFromStorage();
|
|
576
|
+
|
|
577
|
+
expect(loadSpy).toHaveBeenCalledTimes(1);
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
473
581
|
describe("edge cases and integration", () => {
|
|
474
582
|
test("should handle transition from unknown to pending to available", async () => {
|
|
475
583
|
const { state, node, header } = setup();
|
|
@@ -222,4 +222,56 @@ describe("CoValueCore.getKnownStateFromStorage", () => {
|
|
|
222
222
|
|
|
223
223
|
expect(doneSpy).toHaveBeenCalledWith(undefined);
|
|
224
224
|
});
|
|
225
|
+
|
|
226
|
+
test("sets onlyKnownState and caches lastKnownState when storage returns data", () => {
|
|
227
|
+
const { node, coValue, id } = setup();
|
|
228
|
+
const storageKnownState = {
|
|
229
|
+
id,
|
|
230
|
+
header: true,
|
|
231
|
+
sessions: { session1: 5 },
|
|
232
|
+
};
|
|
233
|
+
const loadKnownStateSpy = vi.fn((id, callback) => {
|
|
234
|
+
callback(storageKnownState);
|
|
235
|
+
});
|
|
236
|
+
const storage = createMockStorage({ loadKnownState: loadKnownStateSpy });
|
|
237
|
+
node.setStorage(storage);
|
|
238
|
+
|
|
239
|
+
// Initially unknown
|
|
240
|
+
expect(coValue.loadingState).toBe("unknown");
|
|
241
|
+
|
|
242
|
+
const doneSpy = vi.fn();
|
|
243
|
+
coValue.getKnownStateFromStorage(doneSpy);
|
|
244
|
+
|
|
245
|
+
// After storage returns data, should be onlyKnownState
|
|
246
|
+
expect(coValue.loadingState).toBe("onlyKnownState");
|
|
247
|
+
|
|
248
|
+
// knownState() should return the cached lastKnownState
|
|
249
|
+
expect(coValue.knownState()).toEqual(storageKnownState);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("returns cached lastKnownState on subsequent calls without hitting storage", () => {
|
|
253
|
+
const { node, coValue, id } = setup();
|
|
254
|
+
const storageKnownState = {
|
|
255
|
+
id,
|
|
256
|
+
header: true,
|
|
257
|
+
sessions: { session1: 5 },
|
|
258
|
+
};
|
|
259
|
+
const loadKnownStateSpy = vi.fn((id, callback) => {
|
|
260
|
+
callback(storageKnownState);
|
|
261
|
+
});
|
|
262
|
+
const storage = createMockStorage({ loadKnownState: loadKnownStateSpy });
|
|
263
|
+
node.setStorage(storage);
|
|
264
|
+
|
|
265
|
+
// First call - hits storage
|
|
266
|
+
const doneSpy1 = vi.fn();
|
|
267
|
+
coValue.getKnownStateFromStorage(doneSpy1);
|
|
268
|
+
expect(loadKnownStateSpy).toHaveBeenCalledTimes(1);
|
|
269
|
+
expect(doneSpy1).toHaveBeenCalledWith(storageKnownState);
|
|
270
|
+
|
|
271
|
+
// Second call - should use cached lastKnownState, not hit storage
|
|
272
|
+
const doneSpy2 = vi.fn();
|
|
273
|
+
coValue.getKnownStateFromStorage(doneSpy2);
|
|
274
|
+
expect(loadKnownStateSpy).toHaveBeenCalledTimes(1); // Still 1, not 2
|
|
275
|
+
expect(doneSpy2).toHaveBeenCalledWith(storageKnownState);
|
|
276
|
+
});
|
|
225
277
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, test } from "vitest";
|
|
2
2
|
|
|
3
|
+
import { expectMap } from "../coValue";
|
|
3
4
|
import { setGarbageCollectorMaxAge } from "../config";
|
|
4
5
|
import {
|
|
5
6
|
SyncMessagesLog,
|
|
@@ -177,6 +178,8 @@ describe("sync after the garbage collector has run", () => {
|
|
|
177
178
|
const mapOnServer = await loadCoValueOrFail(jazzCloud.node, map.id);
|
|
178
179
|
expect(mapOnServer.get("hello")).toEqual("updated");
|
|
179
180
|
|
|
181
|
+
// With garbageCollected shells, client uses cached knownState (header/1)
|
|
182
|
+
// which is more accurate than asking storage (which returns empty)
|
|
180
183
|
expect(
|
|
181
184
|
SyncMessagesLog.getMessages({
|
|
182
185
|
Group: group.core,
|
|
@@ -184,14 +187,14 @@ describe("sync after the garbage collector has run", () => {
|
|
|
184
187
|
}),
|
|
185
188
|
).toMatchInlineSnapshot(`
|
|
186
189
|
[
|
|
187
|
-
"client -> server | LOAD Map sessions:
|
|
190
|
+
"client -> server | LOAD Map sessions: header/1",
|
|
188
191
|
"client -> server | LOAD Group sessions: header/3",
|
|
189
192
|
"client -> storage | CONTENT Group header: true new: After: 0 New: 3",
|
|
190
193
|
"client -> server | CONTENT Group header: true new: After: 0 New: 3",
|
|
191
194
|
"client -> storage | CONTENT Map header: true new: After: 0 New: 1",
|
|
192
195
|
"client -> server | CONTENT Map header: true new: After: 0 New: 1",
|
|
193
|
-
"server -> storage |
|
|
194
|
-
"storage -> server |
|
|
196
|
+
"server -> storage | GET_KNOWN_STATE Map",
|
|
197
|
+
"storage -> server | GET_KNOWN_STATE_RESULT Map sessions: empty",
|
|
195
198
|
"server -> client | KNOWN Map sessions: empty",
|
|
196
199
|
"server -> storage | GET_KNOWN_STATE Group",
|
|
197
200
|
"storage -> server | GET_KNOWN_STATE_RESULT Group sessions: empty",
|
|
@@ -203,4 +206,113 @@ describe("sync after the garbage collector has run", () => {
|
|
|
203
206
|
]
|
|
204
207
|
`);
|
|
205
208
|
});
|
|
209
|
+
|
|
210
|
+
test("knownStateWithStreaming returns lastKnownState for garbageCollected CoValues", async () => {
|
|
211
|
+
// This test verifies that knownStateWithStreaming() returns the cached lastKnownState
|
|
212
|
+
// for garbage-collected CoValues, not an empty state. This is important for peer
|
|
213
|
+
// reconciliation where we want to send the last known state to minimize data transfer.
|
|
214
|
+
|
|
215
|
+
const client = setupTestNode();
|
|
216
|
+
client.addStorage({ ourName: "client" });
|
|
217
|
+
client.node.enableGarbageCollector();
|
|
218
|
+
|
|
219
|
+
const group = client.node.createGroup();
|
|
220
|
+
const map = group.createMap();
|
|
221
|
+
map.set("hello", "world", "trusting");
|
|
222
|
+
|
|
223
|
+
// Sync to server
|
|
224
|
+
client.connectToSyncServer();
|
|
225
|
+
await client.node.syncManager.waitForAllCoValuesSync();
|
|
226
|
+
|
|
227
|
+
// Capture known state before GC
|
|
228
|
+
const originalKnownState = map.core.knownState();
|
|
229
|
+
const originalKnownStateWithStreaming = map.core.knownStateWithStreaming();
|
|
230
|
+
|
|
231
|
+
// For available CoValues, both should be equal (no streaming in progress)
|
|
232
|
+
expect(originalKnownState).toEqual(originalKnownStateWithStreaming);
|
|
233
|
+
expect(originalKnownState.header).toBe(true);
|
|
234
|
+
expect(Object.values(originalKnownState.sessions)[0]).toBe(1);
|
|
235
|
+
|
|
236
|
+
// Disconnect before GC
|
|
237
|
+
client.disconnect();
|
|
238
|
+
|
|
239
|
+
// Run GC to create garbageCollected shell
|
|
240
|
+
client.node.garbageCollector?.collect();
|
|
241
|
+
client.node.garbageCollector?.collect();
|
|
242
|
+
|
|
243
|
+
const gcCoValue = client.node.getCoValue(map.id);
|
|
244
|
+
expect(gcCoValue.loadingState).toBe("garbageCollected");
|
|
245
|
+
|
|
246
|
+
// Key assertion: knownStateWithStreaming() should return lastKnownState, not empty state
|
|
247
|
+
const gcKnownState = gcCoValue.knownState();
|
|
248
|
+
const gcKnownStateWithStreaming = gcCoValue.knownStateWithStreaming();
|
|
249
|
+
|
|
250
|
+
// Both should equal the original known state (the cached lastKnownState)
|
|
251
|
+
expect(gcKnownState).toEqual(originalKnownState);
|
|
252
|
+
expect(gcKnownStateWithStreaming).toEqual(originalKnownState);
|
|
253
|
+
|
|
254
|
+
// Specifically verify it's NOT an empty state
|
|
255
|
+
expect(gcKnownStateWithStreaming.header).toBe(true);
|
|
256
|
+
expect(
|
|
257
|
+
Object.keys(gcKnownStateWithStreaming.sessions).length,
|
|
258
|
+
).toBeGreaterThan(0);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("garbageCollected CoValues read from verified content after reload", async () => {
|
|
262
|
+
// This test verifies that after reloading a GC'd CoValue:
|
|
263
|
+
// 1. lastKnownState is cleared
|
|
264
|
+
// 2. knownState() returns data from verified content (not cached)
|
|
265
|
+
// We prove this by adding a transaction after reload and verifying knownState() updates
|
|
266
|
+
|
|
267
|
+
const client = setupTestNode();
|
|
268
|
+
client.addStorage({ ourName: "client" });
|
|
269
|
+
client.node.enableGarbageCollector();
|
|
270
|
+
|
|
271
|
+
const group = client.node.createGroup();
|
|
272
|
+
const map = group.createMap();
|
|
273
|
+
map.set("hello", "world", "trusting");
|
|
274
|
+
|
|
275
|
+
// Sync to server
|
|
276
|
+
client.connectToSyncServer();
|
|
277
|
+
await client.node.syncManager.waitForAllCoValuesSync();
|
|
278
|
+
|
|
279
|
+
// Capture known state before GC (has 1 transaction)
|
|
280
|
+
const originalKnownState = map.core.knownState();
|
|
281
|
+
const originalSessionCount = Object.values(originalKnownState.sessions)[0];
|
|
282
|
+
expect(originalSessionCount).toBe(1);
|
|
283
|
+
|
|
284
|
+
// Disconnect before GC
|
|
285
|
+
client.disconnect();
|
|
286
|
+
|
|
287
|
+
// Run GC to create garbageCollected shell
|
|
288
|
+
client.node.garbageCollector?.collect();
|
|
289
|
+
client.node.garbageCollector?.collect();
|
|
290
|
+
|
|
291
|
+
const gcMap = client.node.getCoValue(map.id);
|
|
292
|
+
expect(gcMap.loadingState).toBe("garbageCollected");
|
|
293
|
+
|
|
294
|
+
// Verify knownState() returns lastKnownState (still shows 1 transaction)
|
|
295
|
+
expect(gcMap.knownState()).toEqual(originalKnownState);
|
|
296
|
+
|
|
297
|
+
// Reconnect and reload
|
|
298
|
+
client.connectToSyncServer();
|
|
299
|
+
const reloadedCore = await client.node.loadCoValueCore(map.id);
|
|
300
|
+
|
|
301
|
+
// Verify CoValue is now available
|
|
302
|
+
expect(reloadedCore.loadingState).toBe("available");
|
|
303
|
+
expect(reloadedCore.isAvailable()).toBe(true);
|
|
304
|
+
|
|
305
|
+
// At this point, knownState() should be reading from verified content
|
|
306
|
+
// To prove this, we add a new transaction and verify knownState() updates
|
|
307
|
+
const reloadedContent = expectMap(reloadedCore.getCurrentContent());
|
|
308
|
+
reloadedContent.set("hello", "updated locally", "trusting");
|
|
309
|
+
|
|
310
|
+
// Verify knownState() now shows 2 transactions
|
|
311
|
+
// This proves we're reading from verified content, not cached lastKnownState
|
|
312
|
+
const newKnownState = reloadedCore.knownState();
|
|
313
|
+
const newSessionCount = Object.values(newKnownState.sessions)[0];
|
|
314
|
+
|
|
315
|
+
expect(newSessionCount).toBe(2);
|
|
316
|
+
expect(newKnownState).not.toEqual(originalKnownState);
|
|
317
|
+
});
|
|
206
318
|
});
|