cojson 0.20.1 → 0.20.3

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 (47) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +19 -7
  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/coValueCore/coValueCore.d.ts +23 -3
  8. package/dist/coValueCore/coValueCore.d.ts.map +1 -1
  9. package/dist/coValueCore/coValueCore.js +116 -36
  10. package/dist/coValueCore/coValueCore.js.map +1 -1
  11. package/dist/localNode.d.ts +12 -0
  12. package/dist/localNode.d.ts.map +1 -1
  13. package/dist/localNode.js +51 -3
  14. package/dist/localNode.js.map +1 -1
  15. package/dist/permissions.d.ts.map +1 -1
  16. package/dist/permissions.js +5 -0
  17. package/dist/permissions.js.map +1 -1
  18. package/dist/sync.d.ts.map +1 -1
  19. package/dist/sync.js +13 -10
  20. package/dist/sync.js.map +1 -1
  21. package/dist/tests/coValueCore.loadFromStorage.test.js +87 -0
  22. package/dist/tests/coValueCore.loadFromStorage.test.js.map +1 -1
  23. package/dist/tests/group.parentGroupCache.test.js +2 -2
  24. package/dist/tests/group.parentGroupCache.test.js.map +1 -1
  25. package/dist/tests/knownState.lazyLoading.test.js +44 -0
  26. package/dist/tests/knownState.lazyLoading.test.js.map +1 -1
  27. package/dist/tests/permissions.test.js +83 -2
  28. package/dist/tests/permissions.test.js.map +1 -1
  29. package/dist/tests/sync.garbageCollection.test.js +87 -3
  30. package/dist/tests/sync.garbageCollection.test.js.map +1 -1
  31. package/dist/tests/sync.multipleServers.test.js +0 -62
  32. package/dist/tests/sync.multipleServers.test.js.map +1 -1
  33. package/dist/tests/sync.peerReconciliation.test.js +156 -0
  34. package/dist/tests/sync.peerReconciliation.test.js.map +1 -1
  35. package/package.json +4 -4
  36. package/src/GarbageCollector.ts +4 -3
  37. package/src/coValueCore/coValueCore.ts +129 -39
  38. package/src/localNode.ts +65 -4
  39. package/src/permissions.ts +6 -0
  40. package/src/sync.ts +11 -9
  41. package/src/tests/coValueCore.loadFromStorage.test.ts +108 -0
  42. package/src/tests/group.parentGroupCache.test.ts +2 -2
  43. package/src/tests/knownState.lazyLoading.test.ts +52 -0
  44. package/src/tests/permissions.test.ts +118 -1
  45. package/src/tests/sync.garbageCollection.test.ts +115 -3
  46. package/src/tests/sync.multipleServers.test.ts +0 -65
  47. package/src/tests/sync.peerReconciliation.test.ts +199 -0
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
 
@@ -235,6 +235,7 @@ function determineValidTransactionsForGroup(
235
235
  const writeOnlyKeys: Record<RawAccountID | AgentID, KeyID> = {};
236
236
  const writeKeys = new Set<string>();
237
237
  const memberRoleResolver = new MemberRoleResolver();
238
+ const isGroup = coValue.isGroup();
238
239
 
239
240
  for (const transaction of coValue.verifiedTransactions) {
240
241
  const transactor = transaction.author;
@@ -247,6 +248,11 @@ function determineValidTransactionsForGroup(
247
248
  const tx = transaction.tx;
248
249
 
249
250
  if (tx.privacy === "private") {
251
+ if (isGroup) {
252
+ transaction.markInvalid("Can't make private transactions in groups");
253
+ continue;
254
+ }
255
+
250
256
  if (transactorRole === "admin") {
251
257
  transaction.markValid();
252
258
  continue;
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 (!coValue.isAvailable()) {
405
- // If the coValue is unavailable and we never tried this peer
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();
@@ -140,7 +140,7 @@ describe("Parent Group Cache", () => {
140
140
  value: "revoked",
141
141
  },
142
142
  ],
143
- "private",
143
+ "trusting",
144
144
  undefined,
145
145
  t2,
146
146
  );
@@ -153,7 +153,7 @@ describe("Parent Group Cache", () => {
153
153
  value: "extend",
154
154
  },
155
155
  ],
156
- "private",
156
+ "trusting",
157
157
  undefined,
158
158
  t1,
159
159
  );
@@ -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,4 +1,4 @@
1
- import { expect, test, vi } from "vitest";
1
+ import { beforeEach, describe, expect, test, vi } from "vitest";
2
2
  import { expectMap } from "../coValue.js";
3
3
  import { ControlledAgent } from "../coValues/account.js";
4
4
  import { WasmCrypto } from "../crypto/WasmCrypto.js";
@@ -13,6 +13,8 @@ import {
13
13
  loadCoValueOrFail,
14
14
  newGroup,
15
15
  newGroupHighLevel,
16
+ setupTestAccount,
17
+ setupTestNode,
16
18
  waitFor,
17
19
  } from "./testUtils.js";
18
20
  import { Role } from "../permissions.js";
@@ -2727,3 +2729,118 @@ test("Can revoke read permission from 'everyone'", async () => {
2727
2729
  // Verify the new account cannot read after revocation
2728
2730
  expect(childContent2.get("foo")).toEqual("bar");
2729
2731
  });
2732
+
2733
+ describe("Private transactions in groups", () => {
2734
+ beforeEach(async () => {
2735
+ setupTestNode({ isSyncServer: true });
2736
+ });
2737
+
2738
+ test("Admins can make private transactions directly in accounts", async () => {
2739
+ const alice = await setupTestAccount();
2740
+
2741
+ const group = alice.node.createGroup();
2742
+ const map = group.createMap();
2743
+
2744
+ // Count valid transactions before attempting private transaction
2745
+ const validTransactionsBefore = alice.account.core.getValidTransactions();
2746
+ const countBefore = validTransactionsBefore.length;
2747
+
2748
+ alice.account.set("root", map.id, "private");
2749
+
2750
+ const validTransactions = alice.account.core.getValidTransactions();
2751
+ expect(validTransactions).toHaveLength(countBefore + 1);
2752
+
2753
+ expect(alice.account.get("root")).toEqual(map.id);
2754
+ expect(alice.account.core.getCurrentReadKey()).not.toBeUndefined();
2755
+ });
2756
+
2757
+ test("Admins cannot make private transactions directly in groups", async () => {
2758
+ const alice = await setupTestAccount();
2759
+
2760
+ const group = alice.node.createGroup();
2761
+ const map = group.createMap();
2762
+
2763
+ // Count valid transactions before attempting private transaction
2764
+ const validTransactionsBefore = group.core.getValidTransactions();
2765
+ const countBefore = validTransactionsBefore.length;
2766
+
2767
+ // Attempt to make a private transaction directly on the group
2768
+ group.set("root", map.id, "private");
2769
+
2770
+ // Verify the transaction is marked invalid (count should not increase)
2771
+ const validTransactions = group.core.getValidTransactions();
2772
+ expect(validTransactions).toHaveLength(countBefore);
2773
+
2774
+ // Verify the change didn't take effect
2775
+ expect(group.get("root")).toBeUndefined();
2776
+ });
2777
+
2778
+ test("Writers cannot make private transactions directly in groups", async () => {
2779
+ const alice = await setupTestAccount({ connected: true });
2780
+ const bob = await setupTestAccount({ connected: true });
2781
+
2782
+ const group = alice.node.createGroup();
2783
+ const map = group.createMap();
2784
+
2785
+ group.addMember(bob.account, "writer");
2786
+
2787
+ // Count valid transactions before attempting private transaction
2788
+ const validTransactionsBefore = group.core.getValidTransactions();
2789
+ const countBefore = validTransactionsBefore.length;
2790
+
2791
+ const groupAsBob = await loadCoValueOrFail(bob.node, group.id);
2792
+
2793
+ // Attempt to make a private transaction directly on the group
2794
+ groupAsBob.set("root", map.id, "private");
2795
+
2796
+ // Verify the transaction is marked invalid (count should not increase)
2797
+ const validTransactions = groupAsBob.core.getValidTransactions();
2798
+ expect(validTransactions).toHaveLength(countBefore);
2799
+
2800
+ // Verify the change didn't take effect
2801
+ expect(groupAsBob.get("root")).toBeUndefined();
2802
+ });
2803
+
2804
+ test("Managers cannot make private transactions directly in groups", async () => {
2805
+ const alice = await setupTestAccount({ connected: true });
2806
+ const bob = await setupTestAccount({ connected: true });
2807
+
2808
+ const group = alice.node.createGroup();
2809
+ const map = group.createMap();
2810
+
2811
+ group.addMember(bob.account, "manager");
2812
+
2813
+ // Count valid transactions before attempting private transaction
2814
+ const validTransactionsBefore = group.core.getValidTransactions();
2815
+ const countBefore = validTransactionsBefore.length;
2816
+
2817
+ const groupAsBob = await loadCoValueOrFail(bob.node, group.id);
2818
+
2819
+ // Attempt to make a private transaction directly on the group
2820
+ groupAsBob.set("root", map.id, "private");
2821
+
2822
+ // Verify the transaction is marked invalid (count should not increase)
2823
+ const validTransactions = groupAsBob.core.getValidTransactions();
2824
+ expect(validTransactions).toHaveLength(countBefore);
2825
+
2826
+ // Verify the change didn't take effect
2827
+ expect(groupAsBob.get("root")).toBeUndefined();
2828
+ });
2829
+
2830
+ test("A Group with a private transaction can be loaded without issues", async () => {
2831
+ const alice = await setupTestAccount({ connected: true });
2832
+ const bob = await setupTestAccount({ connected: true });
2833
+
2834
+ const group = alice.node.createGroup();
2835
+ const map = group.createMap();
2836
+
2837
+ // Attempt to make a private transaction directly on the group
2838
+ group.set("root", map.id, "private");
2839
+ group.addMember(bob.account, "reader");
2840
+
2841
+ const groupAsBob = await loadCoValueOrFail(bob.node, group.id);
2842
+
2843
+ expect(groupAsBob.myRole()).toBe("reader");
2844
+ expect(groupAsBob.core.getCurrentReadKey()).not.toBeUndefined();
2845
+ });
2846
+ });
@@ -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: empty",
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 | LOAD Map sessions: empty",
194
- "storage -> server | KNOWN Map sessions: empty",
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
  });