cojson-storage-indexeddb 0.19.17 → 0.19.19

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/src/idbNode.ts CHANGED
@@ -9,7 +9,7 @@ export function internal_setDatabaseName(name: string) {
9
9
 
10
10
  export async function getIndexedDBStorage(name = DATABASE_NAME) {
11
11
  const dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
12
- const request = indexedDB.open(name, 4);
12
+ const request = indexedDB.open(name, 5);
13
13
  request.onerror = () => {
14
14
  reject(request.error);
15
15
  };
@@ -47,6 +47,20 @@ export async function getIndexedDBStorage(name = DATABASE_NAME) {
47
47
  keyPath: ["ses", "idx"],
48
48
  });
49
49
  }
50
+ if (ev.oldVersion <= 4) {
51
+ const unsyncedCoValues = db.createObjectStore("unsyncedCoValues", {
52
+ autoIncrement: true,
53
+ keyPath: "rowID",
54
+ });
55
+ unsyncedCoValues.createIndex("byCoValueId", "coValueId");
56
+ unsyncedCoValues.createIndex(
57
+ "uniqueUnsyncedCoValues",
58
+ ["coValueId", "peerId"],
59
+ {
60
+ unique: true,
61
+ },
62
+ );
63
+ }
50
64
  };
51
65
  });
52
66
 
@@ -23,6 +23,17 @@ describe("CoJsonIDBTransaction", () => {
23
23
  });
24
24
  db.createObjectStore("transactions", { keyPath: "id" });
25
25
  db.createObjectStore("signatureAfter", { keyPath: "id" });
26
+ const unsyncedCoValues = db.createObjectStore("unsyncedCoValues", {
27
+ keyPath: "rowID",
28
+ });
29
+ unsyncedCoValues.createIndex("byCoValueId", "coValueId");
30
+ unsyncedCoValues.createIndex(
31
+ "uniqueUnsyncedCoValues",
32
+ ["coValueId", "peerId"],
33
+ {
34
+ unique: true,
35
+ },
36
+ );
26
37
  };
27
38
 
28
39
  request.onsuccess = () => {
@@ -167,4 +178,80 @@ describe("CoJsonIDBTransaction", () => {
167
178
 
168
179
  expect(badTx.failed).toBe(true);
169
180
  });
181
+
182
+ test("transaction with custom stores only includes specified stores", async () => {
183
+ const tx = new CoJsonIDBTransaction(db, ["coValues", "sessions"]);
184
+
185
+ // Should work with included stores
186
+ await tx.handleRequest((tx) =>
187
+ tx.getObjectStore("coValues").put({
188
+ id: "test1",
189
+ value: "hello",
190
+ }),
191
+ );
192
+
193
+ await tx.handleRequest((tx) =>
194
+ tx.getObjectStore("sessions").put({
195
+ id: "session1",
196
+ data: "session data",
197
+ }),
198
+ );
199
+
200
+ // Should fail when trying to access a store not included in transaction
201
+ await expect(
202
+ tx.handleRequest((tx) =>
203
+ tx.getObjectStore("transactions").put({
204
+ id: "tx1",
205
+ data: "tx data",
206
+ }),
207
+ ),
208
+ ).rejects.toThrow(
209
+ "Failed to execute 'objectStore' on 'IDBTransaction': The specified object store was not found.",
210
+ );
211
+ });
212
+
213
+ test("if no custom stores are provided, transaction uses default stores", async () => {
214
+ const tx = new CoJsonIDBTransaction(db);
215
+
216
+ await tx.handleRequest((tx) =>
217
+ tx.getObjectStore("coValues").put({
218
+ id: "test1",
219
+ value: "hello",
220
+ }),
221
+ );
222
+
223
+ await tx.handleRequest((tx) =>
224
+ tx.getObjectStore("sessions").put({
225
+ id: "session1",
226
+ data: "session data",
227
+ }),
228
+ );
229
+
230
+ await tx.handleRequest((tx) =>
231
+ tx.getObjectStore("transactions").put({
232
+ id: "tx1",
233
+ data: "tx data",
234
+ }),
235
+ );
236
+
237
+ await tx.handleRequest((tx) =>
238
+ tx.getObjectStore("signatureAfter").put({
239
+ id: "sig1",
240
+ data: "sig data",
241
+ }),
242
+ );
243
+
244
+ // Should fail when trying to access unsyncedCoValues (not in default)
245
+ await expect(
246
+ tx.handleRequest((tx) =>
247
+ tx.getObjectStore("unsyncedCoValues").put({
248
+ rowID: 1,
249
+ coValueId: "coValue1",
250
+ peerId: "peer1",
251
+ }),
252
+ ),
253
+ ).rejects.toThrow(
254
+ "Failed to execute 'objectStore' on 'IDBTransaction': The specified object store was not found.",
255
+ );
256
+ });
170
257
  });
@@ -1,29 +1,37 @@
1
- import { LocalNode, StorageApiAsync } from "cojson";
1
+ import { LocalNode, StorageApiAsync, cojsonInternals } from "cojson";
2
2
  import { WasmCrypto } from "cojson/crypto/WasmCrypto";
3
- import { afterEach, beforeEach, expect, test, vi } from "vitest";
4
- import { getIndexedDBStorage } from "../index.js";
3
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
4
+ import { getIndexedDBStorage, internal_setDatabaseName } from "../index.js";
5
5
  import { toSimplifiedMessages } from "./messagesTestUtils.js";
6
- import { trackMessages, waitFor } from "./testUtils.js";
6
+ import {
7
+ clearObjectStore,
8
+ connectToSyncServer,
9
+ createTestNode,
10
+ trackMessages,
11
+ waitFor,
12
+ } from "./testUtils.js";
7
13
 
8
14
  const Crypto = await WasmCrypto.create();
9
15
  let syncMessages: ReturnType<typeof trackMessages>;
10
16
 
17
+ const DATABASE_NAME = "jazz-storage";
18
+ internal_setDatabaseName(DATABASE_NAME);
19
+
11
20
  beforeEach(() => {
12
21
  syncMessages = trackMessages();
22
+ cojsonInternals.setSyncStateTrackingBatchDelay(0);
23
+ cojsonInternals.setCoValueLoadingRetryDelay(10);
13
24
  });
14
25
 
15
- afterEach(() => {
26
+ afterEach(async () => {
16
27
  syncMessages.restore();
28
+ cojsonInternals.setSyncStateTrackingBatchDelay(1000);
29
+
30
+ await clearObjectStore(DATABASE_NAME, "unsyncedCoValues");
17
31
  });
18
32
 
19
33
  test("should sync and load data from storage", async () => {
20
- const agentSecret = Crypto.newRandomAgentSecret();
21
-
22
- const node1 = new LocalNode(
23
- agentSecret,
24
- Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
25
- Crypto,
26
- );
34
+ const node1 = createTestNode();
27
35
  node1.setStorage(await getIndexedDBStorage());
28
36
 
29
37
  const group = node1.createGroup();
@@ -51,12 +59,7 @@ test("should sync and load data from storage", async () => {
51
59
  node1.gracefulShutdown();
52
60
  syncMessages.clear();
53
61
 
54
- const node2 = new LocalNode(
55
- agentSecret,
56
- Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
57
- Crypto,
58
- );
59
-
62
+ const node2 = createTestNode({ secret: node1.agentSecret });
60
63
  node2.setStorage(await getIndexedDBStorage());
61
64
 
62
65
  const map2 = await node2.load(map.id);
@@ -84,13 +87,7 @@ test("should sync and load data from storage", async () => {
84
87
  });
85
88
 
86
89
  test("should send an empty content message if there is no content", async () => {
87
- const agentSecret = Crypto.newRandomAgentSecret();
88
-
89
- const node1 = new LocalNode(
90
- agentSecret,
91
- Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
92
- Crypto,
93
- );
90
+ const node1 = createTestNode();
94
91
 
95
92
  node1.setStorage(await getIndexedDBStorage());
96
93
 
@@ -117,11 +114,7 @@ test("should send an empty content message if there is no content", async () =>
117
114
  syncMessages.clear();
118
115
  node1.gracefulShutdown();
119
116
 
120
- const node2 = new LocalNode(
121
- agentSecret,
122
- Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
123
- Crypto,
124
- );
117
+ const node2 = createTestNode({ secret: node1.agentSecret });
125
118
 
126
119
  node2.setStorage(await getIndexedDBStorage());
127
120
 
@@ -148,13 +141,7 @@ test("should send an empty content message if there is no content", async () =>
148
141
  });
149
142
 
150
143
  test("should load dependencies correctly (group inheritance)", async () => {
151
- const agentSecret = Crypto.newRandomAgentSecret();
152
-
153
- const node1 = new LocalNode(
154
- agentSecret,
155
- Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
156
- Crypto,
157
- );
144
+ const node1 = createTestNode();
158
145
 
159
146
  node1.setStorage(await getIndexedDBStorage());
160
147
  const group = node1.createGroup();
@@ -189,11 +176,7 @@ test("should load dependencies correctly (group inheritance)", async () => {
189
176
  syncMessages.clear();
190
177
  node1.gracefulShutdown();
191
178
 
192
- const node2 = new LocalNode(
193
- agentSecret,
194
- Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
195
- Crypto,
196
- );
179
+ const node2 = createTestNode({ secret: node1.agentSecret });
197
180
 
198
181
  node2.setStorage(await getIndexedDBStorage());
199
182
 
@@ -223,13 +206,7 @@ test("should load dependencies correctly (group inheritance)", async () => {
223
206
  });
224
207
 
225
208
  test("should not send the same dependency value twice", async () => {
226
- const agentSecret = Crypto.newRandomAgentSecret();
227
-
228
- const node1 = new LocalNode(
229
- agentSecret,
230
- Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
231
- Crypto,
232
- );
209
+ const node1 = createTestNode();
233
210
 
234
211
  node1.setStorage(await getIndexedDBStorage());
235
212
 
@@ -250,11 +227,7 @@ test("should not send the same dependency value twice", async () => {
250
227
  syncMessages.clear();
251
228
  node1.gracefulShutdown();
252
229
 
253
- const node2 = new LocalNode(
254
- agentSecret,
255
- Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
256
- Crypto,
257
- );
230
+ const node2 = createTestNode({ secret: node1.agentSecret });
258
231
 
259
232
  node2.setStorage(await getIndexedDBStorage());
260
233
 
@@ -289,13 +262,7 @@ test("should not send the same dependency value twice", async () => {
289
262
  });
290
263
 
291
264
  test("should recover from data loss", async () => {
292
- const agentSecret = Crypto.newRandomAgentSecret();
293
-
294
- const node1 = new LocalNode(
295
- agentSecret,
296
- Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
297
- Crypto,
298
- );
265
+ const node1 = createTestNode();
299
266
 
300
267
  const storage = await getIndexedDBStorage();
301
268
  node1.setStorage(storage);
@@ -347,11 +314,7 @@ test("should recover from data loss", async () => {
347
314
  syncMessages.clear();
348
315
  node1.gracefulShutdown();
349
316
 
350
- const node2 = new LocalNode(
351
- agentSecret,
352
- Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
353
- Crypto,
354
- );
317
+ const node2 = createTestNode({ secret: node1.agentSecret });
355
318
 
356
319
  node2.setStorage(await getIndexedDBStorage());
357
320
 
@@ -386,13 +349,7 @@ test("should recover from data loss", async () => {
386
349
  });
387
350
 
388
351
  test("should sync multiple sessions in a single content message", async () => {
389
- const agentSecret = Crypto.newRandomAgentSecret();
390
-
391
- const node1 = new LocalNode(
392
- agentSecret,
393
- Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
394
- Crypto,
395
- );
352
+ const node1 = createTestNode();
396
353
 
397
354
  node1.setStorage(await getIndexedDBStorage());
398
355
 
@@ -406,11 +363,7 @@ test("should sync multiple sessions in a single content message", async () => {
406
363
 
407
364
  node1.gracefulShutdown();
408
365
 
409
- const node2 = new LocalNode(
410
- agentSecret,
411
- Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
412
- Crypto,
413
- );
366
+ const node2 = createTestNode({ secret: node1.agentSecret });
414
367
 
415
368
  node2.setStorage(await getIndexedDBStorage());
416
369
 
@@ -427,11 +380,7 @@ test("should sync multiple sessions in a single content message", async () => {
427
380
 
428
381
  node2.gracefulShutdown();
429
382
 
430
- const node3 = new LocalNode(
431
- agentSecret,
432
- Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
433
- Crypto,
434
- );
383
+ const node3 = createTestNode({ secret: node1.agentSecret });
435
384
 
436
385
  syncMessages.clear();
437
386
 
@@ -462,13 +411,7 @@ test("should sync multiple sessions in a single content message", async () => {
462
411
  });
463
412
 
464
413
  test("large coValue upload streaming", async () => {
465
- const agentSecret = Crypto.newRandomAgentSecret();
466
-
467
- const node1 = new LocalNode(
468
- agentSecret,
469
- Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
470
- Crypto,
471
- );
414
+ const node1 = createTestNode();
472
415
 
473
416
  node1.setStorage(await getIndexedDBStorage());
474
417
 
@@ -494,11 +437,7 @@ test("large coValue upload streaming", async () => {
494
437
 
495
438
  node1.gracefulShutdown();
496
439
 
497
- const node2 = new LocalNode(
498
- agentSecret,
499
- Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
500
- Crypto,
501
- );
440
+ const node2 = createTestNode({ secret: node1.agentSecret });
502
441
 
503
442
  syncMessages.clear();
504
443
 
@@ -603,3 +542,115 @@ test("should sync and load accounts from storage", async () => {
603
542
 
604
543
  expect(node2.getCoValue(accountID).isAvailable()).toBeTruthy();
605
544
  });
545
+
546
+ describe("sync state persistence", () => {
547
+ test("unsynced coValues are asynchronously persisted to storage", async () => {
548
+ // Client is not connected to a sync server, so sync will not be completed
549
+ const client = createTestNode();
550
+ client.setStorage(await getIndexedDBStorage());
551
+
552
+ const group = client.createGroup();
553
+ const map = group.createMap();
554
+ map.set("key", "value");
555
+
556
+ // Wait for the unsynced coValues to be persisted to storage
557
+ await new Promise<void>((resolve) => setTimeout(resolve, 500));
558
+
559
+ const unsyncedCoValueIDs = await new Promise((resolve) =>
560
+ client.storage?.getUnsyncedCoValueIDs(resolve),
561
+ );
562
+ expect(unsyncedCoValueIDs).toHaveLength(2);
563
+ expect(unsyncedCoValueIDs).toContain(map.id);
564
+ expect(unsyncedCoValueIDs).toContain(group.id);
565
+
566
+ await client.gracefulShutdown();
567
+ });
568
+
569
+ test("synced coValues are removed from storage", async () => {
570
+ const syncServer = createTestNode();
571
+ const client = createTestNode();
572
+ client.setStorage(await getIndexedDBStorage());
573
+
574
+ connectToSyncServer(client, syncServer);
575
+
576
+ const group = client.createGroup();
577
+ const map = group.createMap();
578
+ map.set("key", "value");
579
+
580
+ // Wait enough time for the coValue to be synced
581
+ await new Promise<void>((resolve) => setTimeout(resolve, 500));
582
+
583
+ const unsyncedCoValueIDs = await new Promise((resolve) =>
584
+ client.storage?.getUnsyncedCoValueIDs(resolve),
585
+ );
586
+ expect(unsyncedCoValueIDs).toHaveLength(0);
587
+ expect(client.syncManager.unsyncedTracker.has(map.id)).toBe(false);
588
+
589
+ await client.gracefulShutdown();
590
+ await syncServer.gracefulShutdown();
591
+ });
592
+
593
+ test("unsynced coValues are persisted to storage when the node is shutdown", async () => {
594
+ const client = createTestNode();
595
+ client.setStorage(await getIndexedDBStorage());
596
+
597
+ const group = client.createGroup();
598
+ const map = group.createMap();
599
+ map.set("key", "value");
600
+
601
+ // Wait for local transaction to trigger sync
602
+ await new Promise<void>((resolve) => queueMicrotask(resolve));
603
+
604
+ await client.gracefulShutdown();
605
+
606
+ const unsyncedCoValueIDs = await new Promise((resolve) =>
607
+ client.storage?.getUnsyncedCoValueIDs(resolve),
608
+ );
609
+ expect(unsyncedCoValueIDs).toHaveLength(2);
610
+ expect(unsyncedCoValueIDs).toContain(map.id);
611
+ expect(unsyncedCoValueIDs).toContain(group.id);
612
+ });
613
+ });
614
+
615
+ describe("sync resumption", () => {
616
+ test("unsynced coValues are resumed when the node is restarted", async () => {
617
+ // Client is not connected to a sync server, so sync will not be completed
618
+ const node1 = createTestNode();
619
+ const storage = await getIndexedDBStorage();
620
+ node1.setStorage(storage);
621
+
622
+ const getUnsyncedCoValueIDsFromStorage = async () =>
623
+ new Promise<string[]>((resolve) =>
624
+ node1.storage?.getUnsyncedCoValueIDs(resolve),
625
+ );
626
+
627
+ const group = node1.createGroup();
628
+ const map = group.createMap();
629
+ map.set("key", "value");
630
+
631
+ // Wait for the unsynced coValues to be persisted to storage
632
+ await new Promise<void>((resolve) => setTimeout(resolve, 100));
633
+
634
+ const unsyncedTracker = node1.syncManager.unsyncedTracker;
635
+ expect(unsyncedTracker.has(map.id)).toBe(true);
636
+ expect(await getUnsyncedCoValueIDsFromStorage()).toHaveLength(2);
637
+
638
+ node1.gracefulShutdown();
639
+
640
+ // Create second node with the same storage
641
+ const node2 = createTestNode();
642
+ node2.setStorage(storage);
643
+
644
+ // Connect to sync server
645
+ const syncServer = createTestNode();
646
+ connectToSyncServer(node2, syncServer);
647
+
648
+ await node2.syncManager.waitForAllCoValuesSync();
649
+ // Wait for sync to resume & complete
650
+ await waitFor(
651
+ async () => (await getUnsyncedCoValueIDsFromStorage()).length === 0,
652
+ );
653
+
654
+ await node2.gracefulShutdown();
655
+ });
656
+ });
@@ -1,5 +1,11 @@
1
- import type { RawCoID, SyncMessage } from "cojson";
2
- import { StorageApiAsync } from "cojson";
1
+ import type { AgentSecret, RawCoID, SessionID, SyncMessage } from "cojson";
2
+ import {
3
+ cojsonInternals,
4
+ ControlledAgent,
5
+ LocalNode,
6
+ StorageApiAsync,
7
+ } from "cojson";
8
+ import { WasmCrypto } from "cojson/crypto/WasmCrypto";
3
9
  import { onTestFinished } from "vitest";
4
10
 
5
11
  export function trackMessages() {
@@ -115,3 +121,78 @@ export function waitFor(
115
121
  }, 100);
116
122
  });
117
123
  }
124
+
125
+ export async function clearObjectStore(
126
+ dbName: string,
127
+ storeName: string,
128
+ ): Promise<void> {
129
+ return new Promise((resolve, reject) => {
130
+ const openReq = indexedDB.open(dbName);
131
+
132
+ openReq.onerror = () => reject(openReq.error);
133
+
134
+ openReq.onsuccess = () => {
135
+ const db = openReq.result;
136
+
137
+ if (!db.objectStoreNames.contains(storeName)) {
138
+ db.close();
139
+ resolve();
140
+ return;
141
+ }
142
+
143
+ const tx = db.transaction(storeName, "readwrite");
144
+ const store = tx.objectStore(storeName);
145
+
146
+ const clearReq = store.clear();
147
+
148
+ clearReq.onerror = () => reject(clearReq.error);
149
+
150
+ tx.oncomplete = () => {
151
+ db.close();
152
+ resolve();
153
+ };
154
+
155
+ tx.onerror = () => {
156
+ db.close();
157
+ reject(tx.error);
158
+ };
159
+
160
+ tx.onabort = () => {
161
+ db.close();
162
+ reject(tx.error || new Error("Transaction aborted"));
163
+ };
164
+ };
165
+ });
166
+ }
167
+
168
+ const Crypto = await WasmCrypto.create();
169
+
170
+ export function getAgentAndSessionID(
171
+ secret: AgentSecret = Crypto.newRandomAgentSecret(),
172
+ ): [ControlledAgent, SessionID] {
173
+ const sessionID = Crypto.newRandomSessionID(Crypto.getAgentID(secret));
174
+ return [new ControlledAgent(secret, Crypto), sessionID];
175
+ }
176
+
177
+ export function createTestNode(opts?: { secret?: AgentSecret }) {
178
+ const [admin, session] = getAgentAndSessionID(opts?.secret);
179
+ return new LocalNode(admin.agentSecret, session, Crypto);
180
+ }
181
+
182
+ export function connectToSyncServer(
183
+ client: LocalNode,
184
+ syncServer: LocalNode,
185
+ ): void {
186
+ const [clientPeer, serverPeer] = cojsonInternals.connectedPeers(
187
+ client.currentSessionID,
188
+ syncServer.currentSessionID,
189
+ {
190
+ peer1role: "client",
191
+ peer2role: "server",
192
+ persistent: true,
193
+ },
194
+ );
195
+
196
+ client.syncManager.addPeer(serverPeer);
197
+ syncServer.syncManager.addPeer(clientPeer);
198
+ }
package/vitest.config.ts CHANGED
@@ -7,7 +7,12 @@ export default defineProject({
7
7
  browser: {
8
8
  enabled: true,
9
9
  provider: playwright(),
10
- instances: [{ browser: "chromium", headless: true }],
10
+ instances: [
11
+ {
12
+ headless: process.env.HEADLESS !== "false",
13
+ browser: "chromium",
14
+ },
15
+ ],
11
16
  },
12
17
  include: ["src/**/*.test.ts"],
13
18
  },