jazz-tools 0.20.18 → 0.20.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.
Files changed (56) hide show
  1. package/.turbo/turbo-build.log +93 -93
  2. package/CHANGELOG.md +15 -0
  3. package/dist/browser/index.js +36 -9
  4. package/dist/browser/index.js.map +1 -1
  5. package/dist/browser/provideBrowserLockSession/BrowserSessionDurabilityMarker.d.ts +8 -0
  6. package/dist/browser/provideBrowserLockSession/BrowserSessionDurabilityMarker.d.ts.map +1 -0
  7. package/dist/browser/provideBrowserLockSession/BrowserSessionProvider.d.ts +1 -0
  8. package/dist/browser/provideBrowserLockSession/BrowserSessionProvider.d.ts.map +1 -1
  9. package/dist/browser/provideBrowserLockSession/index.d.ts +1 -0
  10. package/dist/browser/provideBrowserLockSession/index.d.ts.map +1 -1
  11. package/dist/{chunk-MIPBSAS7.js → chunk-VPK4IGIA.js} +57 -1
  12. package/dist/chunk-VPK4IGIA.js.map +1 -0
  13. package/dist/expo/index.js +22 -7
  14. package/dist/expo/index.js.map +1 -1
  15. package/dist/expo/storage/expo-sqlite-adapter.d.ts +9 -0
  16. package/dist/expo/storage/expo-sqlite-adapter.d.ts.map +1 -1
  17. package/dist/index.js +5 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/react-native/index.js +42 -4
  20. package/dist/react-native/index.js.map +1 -1
  21. package/dist/react-native-core/ReactNativeSessionDurabilityMarker.d.ts +8 -0
  22. package/dist/react-native-core/ReactNativeSessionDurabilityMarker.d.ts.map +1 -0
  23. package/dist/react-native-core/ReactNativeSessionProvider.d.ts +1 -0
  24. package/dist/react-native-core/ReactNativeSessionProvider.d.ts.map +1 -1
  25. package/dist/react-native-core/index.d.ts +1 -0
  26. package/dist/react-native-core/index.d.ts.map +1 -1
  27. package/dist/react-native-core/index.js +42 -4
  28. package/dist/react-native-core/index.js.map +1 -1
  29. package/dist/testing.js +1 -1
  30. package/dist/tools/exports.d.ts +1 -1
  31. package/dist/tools/exports.d.ts.map +1 -1
  32. package/dist/tools/implementation/createContext.d.ts +8 -0
  33. package/dist/tools/implementation/createContext.d.ts.map +1 -1
  34. package/dist/tools/implementation/sessionDurabilityMarker.d.ts +38 -0
  35. package/dist/tools/implementation/sessionDurabilityMarker.d.ts.map +1 -0
  36. package/dist/tools/internal.d.ts +1 -0
  37. package/dist/tools/internal.d.ts.map +1 -1
  38. package/dist/tools/tests/sessionDurabilityMarker.test.d.ts +2 -0
  39. package/dist/tools/tests/sessionDurabilityMarker.test.d.ts.map +1 -0
  40. package/package.json +4 -4
  41. package/src/browser/provideBrowserLockSession/BrowserSessionDurabilityMarker.ts +20 -0
  42. package/src/browser/provideBrowserLockSession/BrowserSessionProvider.test.ts +97 -1
  43. package/src/browser/provideBrowserLockSession/BrowserSessionProvider.ts +37 -8
  44. package/src/browser/provideBrowserLockSession/index.ts +1 -0
  45. package/src/expo/storage/expo-sqlite-adapter.ts +24 -7
  46. package/src/expo/tests/expo-sqlite-adapter.test.ts +94 -0
  47. package/src/react-native-core/ReactNativeSessionDurabilityMarker.ts +40 -0
  48. package/src/react-native-core/ReactNativeSessionProvider.ts +24 -1
  49. package/src/react-native-core/index.ts +1 -0
  50. package/src/react-native-core/tests/ReactNativeSessionProvider.test.ts +42 -0
  51. package/src/tools/exports.ts +3 -0
  52. package/src/tools/implementation/createContext.ts +30 -0
  53. package/src/tools/implementation/sessionDurabilityMarker.ts +93 -0
  54. package/src/tools/internal.ts +1 -0
  55. package/src/tools/tests/sessionDurabilityMarker.test.ts +183 -0
  56. package/dist/chunk-MIPBSAS7.js.map +0 -1
@@ -1,10 +1,11 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import { WasmCrypto } from "cojson/crypto/WasmCrypto";
4
- import { SessionID } from "cojson";
4
+ import { RawAccountID, SessionID } from "cojson";
5
5
  import { beforeEach, describe, expect, test, vi } from "vitest";
6
6
  import { BrowserSessionProvider } from "./BrowserSessionProvider.js";
7
7
  import { SessionIDStorage } from "./SessionIDStorage.js";
8
+ import { BrowserSessionDurabilityMarker } from "./BrowserSessionDurabilityMarker.js";
8
9
  import { createJazzTestAccount } from "jazz-tools/testing";
9
10
  import type { CryptoProvider } from "jazz-tools";
10
11
 
@@ -403,4 +404,99 @@ describe("BrowserSessionProvider", () => {
403
404
  expect(result.sessionID).toBe(sessionID);
404
405
  });
405
406
  });
407
+
408
+ describe("session durability marker", () => {
409
+ test("acquireSession skips a dirty session and reclaims its slot", async () => {
410
+ const provider = new BrowserSessionProvider();
411
+ const accountID = account.$jazz.id;
412
+
413
+ // A previously stored session that crashed inside the durability window
414
+ const dirtySession = Crypto.newRandomSessionID(
415
+ accountID as unknown as RawAccountID,
416
+ ) as SessionID;
417
+ SessionIDStorage.storeSessionID(accountID, dirtySession, 0);
418
+ BrowserSessionDurabilityMarker.set(dirtySession);
419
+
420
+ const { sessionID, sessionDone } = await provider.acquireSession(
421
+ accountID,
422
+ Crypto as CryptoProvider,
423
+ );
424
+
425
+ // A fresh session was minted instead of reusing the dirty one
426
+ expect(sessionID).not.toBe(dirtySession);
427
+ // The dirty session's slot was overwritten (list does not grow)
428
+ expect(SessionIDStorage.getSessionsList(accountID)).toEqual([sessionID]);
429
+ // The old marker was cleaned up
430
+ expect(BrowserSessionDurabilityMarker.isSet(dirtySession)).toBe(false);
431
+
432
+ sessionDone();
433
+ });
434
+
435
+ test("acquireSession still reuses a clean stored session", async () => {
436
+ const provider = new BrowserSessionProvider();
437
+ const accountID = account.$jazz.id;
438
+
439
+ const cleanSession = Crypto.newRandomSessionID(
440
+ accountID as unknown as RawAccountID,
441
+ ) as SessionID;
442
+ SessionIDStorage.storeSessionID(accountID, cleanSession, 0);
443
+
444
+ const { sessionID, sessionDone } = await provider.acquireSession(
445
+ accountID,
446
+ Crypto as CryptoProvider,
447
+ );
448
+
449
+ expect(sessionID).toBe(cleanSession);
450
+ sessionDone();
451
+ });
452
+
453
+ test("marker set/clear/isSet round-trips through localStorage", () => {
454
+ const id = "co_zx_session_zy" as SessionID;
455
+ expect(BrowserSessionDurabilityMarker.isSet(id)).toBe(false);
456
+ BrowserSessionDurabilityMarker.set(id);
457
+ expect(BrowserSessionDurabilityMarker.isSet(id)).toBe(true);
458
+ BrowserSessionDurabilityMarker.clear(id);
459
+ expect(BrowserSessionDurabilityMarker.isSet(id)).toBe(false);
460
+ });
461
+
462
+ test("concurrent acquireSession reclaims dirty slot without orphaning sessions", async () => {
463
+ const provider = new BrowserSessionProvider();
464
+ const accountID = account.$jazz.id;
465
+
466
+ // A dirty session left behind at slot 0
467
+ const dirtySession = Crypto.newRandomSessionID(
468
+ accountID as unknown as RawAccountID,
469
+ ) as SessionID;
470
+ SessionIDStorage.storeSessionID(accountID, dirtySession, 0);
471
+ BrowserSessionDurabilityMarker.set(dirtySession);
472
+
473
+ // Two concurrent acquireSession calls for the same account
474
+ const [result1, result2] = await Promise.all([
475
+ provider.acquireSession(accountID, Crypto as CryptoProvider),
476
+ provider.acquireSession(accountID, Crypto as CryptoProvider),
477
+ ]);
478
+
479
+ const { sessionID: sid1, sessionDone: done1 } = result1;
480
+ const { sessionID: sid2, sessionDone: done2 } = result2;
481
+
482
+ // Both sessions differ from the dirty one
483
+ expect(sid1).not.toBe(dirtySession);
484
+ expect(sid2).not.toBe(dirtySession);
485
+
486
+ // The two new sessions are distinct
487
+ expect(sid1).not.toBe(sid2);
488
+
489
+ // The dirty marker is cleared
490
+ expect(BrowserSessionDurabilityMarker.isSet(dirtySession)).toBe(false);
491
+
492
+ // Session list has exactly 2 entries (slot 0 replaced + one appended)
493
+ const sessionsList = SessionIDStorage.getSessionsList(accountID);
494
+ expect(sessionsList).toHaveLength(2);
495
+ expect(sessionsList).toContain(sid1);
496
+ expect(sessionsList).toContain(sid2);
497
+
498
+ done1();
499
+ done2();
500
+ });
501
+ });
406
502
  });
@@ -1,8 +1,11 @@
1
1
  import { AgentID, CryptoProvider, RawAccountID, SessionID } from "cojson";
2
2
  import { ID, Account, SessionProvider } from "jazz-tools";
3
3
  import { SessionIDStorage } from "./SessionIDStorage";
4
+ import { BrowserSessionDurabilityMarker } from "./BrowserSessionDurabilityMarker";
4
5
 
5
6
  export class BrowserSessionProvider implements SessionProvider {
7
+ readonly durabilityMarker = BrowserSessionDurabilityMarker;
8
+
6
9
  async acquireSession(
7
10
  accountID: ID<Account> | AgentID,
8
11
  crypto: CryptoProvider,
@@ -11,8 +14,16 @@ export class BrowserSessionProvider implements SessionProvider {
11
14
 
12
15
  // Get the list of sessions for the account, to try to acquire an existing session
13
16
  const sessionsList = SessionIDStorage.getSessionsList(accountID);
17
+ let dirtySlot: { index: number; sessionID: SessionID } | undefined;
14
18
 
15
19
  for (const [index, sessionID] of sessionsList.entries()) {
20
+ // If the session crashed while it had sent-but-unpersisted transactions,
21
+ // reusing it could fork the session's hash chain on the server.
22
+ if (BrowserSessionDurabilityMarker.isSet(sessionID)) {
23
+ dirtySlot ??= { index, sessionID };
24
+ continue;
25
+ }
26
+
16
27
  const sessionAcquired = await tryToAcquireSession(
17
28
  sessionID,
18
29
  sessionPromise,
@@ -31,8 +42,14 @@ export class BrowserSessionProvider implements SessionProvider {
31
42
  accountID as RawAccountID | AgentID,
32
43
  );
33
44
 
34
- // Acquire exclusively the session to store the new session ID for reuse in future sessions
35
- await lockAndStoreSession(accountID, newSessionID, sessionPromise);
45
+ // Acquire exclusively the session to store the new session ID for reuse in future sessions.
46
+ // If a dirty slot was found, reclaim it so the list doesn't grow across crashes.
47
+ await lockAndStoreSession(
48
+ accountID,
49
+ newSessionID,
50
+ sessionPromise,
51
+ dirtySlot,
52
+ );
36
53
 
37
54
  console.log("Created new session", newSessionID); // This log is used in the e2e tests to verify the correctness of the feature
38
55
 
@@ -61,6 +78,7 @@ async function lockAndStoreSession(
61
78
  accountID: ID<Account> | AgentID,
62
79
  sessionID: SessionID,
63
80
  sessionPromise: Promise<void>,
81
+ replaceSlot?: { index: number; sessionID: SessionID },
64
82
  ) {
65
83
  const sessionAcquired = await tryToAcquireSession(sessionID, sessionPromise);
66
84
 
@@ -70,7 +88,7 @@ async function lockAndStoreSession(
70
88
  }
71
89
 
72
90
  // We don't need to wait for this to finish, we only need to acquire the lock on the new session
73
- storeSessionID(accountID, sessionID);
91
+ storeSessionID(accountID, sessionID, replaceSlot);
74
92
  }
75
93
 
76
94
  function tryToAcquireSession(
@@ -100,6 +118,7 @@ function tryToAcquireSession(
100
118
  function storeSessionID(
101
119
  accountID: ID<Account> | AgentID,
102
120
  sessionID: SessionID,
121
+ replaceSlot?: { index: number; sessionID: SessionID },
103
122
  ) {
104
123
  return navigator.locks.request(
105
124
  `store_session_${accountID}`,
@@ -110,11 +129,21 @@ function storeSessionID(
110
129
  }
111
130
 
112
131
  const sessionsList = SessionIDStorage.getSessionsList(accountID);
113
- SessionIDStorage.storeSessionID(
114
- accountID,
115
- sessionID,
116
- sessionsList.length,
117
- );
132
+
133
+ // Reclaim the abandoned dirty session's slot so the list doesn't grow
134
+ // across crashes — unless a concurrent tab already took it, in which
135
+ // case append. The stale marker is dropped only after the slot is
136
+ // written, so a crash in between leaves the session safely marked.
137
+ const index =
138
+ replaceSlot && sessionsList[replaceSlot.index] === replaceSlot.sessionID
139
+ ? replaceSlot.index
140
+ : sessionsList.length;
141
+
142
+ SessionIDStorage.storeSessionID(accountID, sessionID, index);
143
+
144
+ if (replaceSlot) {
145
+ BrowserSessionDurabilityMarker.clear(replaceSlot.sessionID);
146
+ }
118
147
  },
119
148
  );
120
149
  }
@@ -1,5 +1,6 @@
1
1
  import { MockSessionProvider } from "jazz-tools";
2
2
  import { BrowserSessionProvider } from "./BrowserSessionProvider";
3
+ export { BrowserSessionDurabilityMarker } from "./BrowserSessionDurabilityMarker";
3
4
 
4
5
  export function getBrowserLockSessionProvider() {
5
6
  if (typeof navigator === "undefined" || !navigator.locks?.request) {
@@ -7,6 +7,15 @@ export class ExpoSQLiteAdapter implements SQLiteDatabaseDriverAsync {
7
7
  private db: SQLiteDatabase | null = null;
8
8
  private initializing: Promise<SQLiteDatabase> | null = null;
9
9
  private dbName: string;
10
+ /**
11
+ * Serializes transactions at the connection level. The adapter is shared
12
+ * across providers/contexts (see `getInstance`), each with its own storage
13
+ * client and transaction queue, and `withTransactionAsync` is not exclusive:
14
+ * without serialization here, a second BEGIN on the same connection fails
15
+ * with "cannot start a transaction within a transaction" and its ROLLBACK
16
+ * aborts the other caller's active transaction.
17
+ */
18
+ private txQueue: Promise<unknown> = Promise.resolve();
10
19
 
11
20
  static withDB(db: SQLiteDatabase): ExpoSQLiteAdapter {
12
21
  const adapter = new ExpoSQLiteAdapter();
@@ -89,14 +98,22 @@ export class ExpoSQLiteAdapter implements SQLiteDatabaseDriverAsync {
89
98
  await this.db.runAsync(sql, params?.map((p) => p as SQLiteBindValue) ?? []);
90
99
  }
91
100
 
92
- public async transaction(callback: (tx: ExpoSQLiteAdapter) => unknown) {
93
- if (!this.db) {
94
- throw new Error("Database not initialized");
95
- }
101
+ public transaction(callback: (tx: ExpoSQLiteAdapter) => unknown) {
102
+ const run = () => {
103
+ const db = this.db;
104
+
105
+ if (!db) {
106
+ throw new Error("Database not initialized");
107
+ }
108
+
109
+ return db.withTransactionAsync(async () => {
110
+ await callback(ExpoSQLiteAdapter.withDB(db));
111
+ });
112
+ };
96
113
 
97
- await this.db.withTransactionAsync(async () => {
98
- await callback(ExpoSQLiteAdapter.withDB(this.db!));
99
- });
114
+ const next = this.txQueue.then(run, run);
115
+ this.txQueue = next;
116
+ return next;
100
117
  }
101
118
 
102
119
  /**
@@ -9,6 +9,48 @@ vi.mock("expo-sqlite", () => ({
9
9
 
10
10
  import { ExpoSQLiteAdapter } from "../storage/expo-sqlite-adapter.js";
11
11
 
12
+ /**
13
+ * Reproduces expo-sqlite's `withTransactionAsync` semantics: transactions are
14
+ * not exclusive, a nested BEGIN throws, and a failed BEGIN still triggers a
15
+ * ROLLBACK that aborts whichever transaction is currently active.
16
+ */
17
+ function createFakeTransactionalDb() {
18
+ let inTransaction = false;
19
+
20
+ const execAsync = vi.fn(async (sql: string) => {
21
+ if (sql === "BEGIN") {
22
+ if (inTransaction) {
23
+ throw new Error("cannot start a transaction within a transaction");
24
+ }
25
+ inTransaction = true;
26
+ } else if (sql === "COMMIT") {
27
+ if (!inTransaction) {
28
+ throw new Error("cannot commit - no transaction is active");
29
+ }
30
+ inTransaction = false;
31
+ } else if (sql === "ROLLBACK") {
32
+ if (!inTransaction) {
33
+ throw new Error("cannot rollback - no transaction is active");
34
+ }
35
+ inTransaction = false;
36
+ }
37
+ });
38
+
39
+ return {
40
+ execAsync,
41
+ async withTransactionAsync(task: () => Promise<void>) {
42
+ try {
43
+ await execAsync("BEGIN");
44
+ await task();
45
+ await execAsync("COMMIT");
46
+ } catch (e) {
47
+ await execAsync("ROLLBACK");
48
+ throw e;
49
+ }
50
+ },
51
+ };
52
+ }
53
+
12
54
  describe("ExpoSQLiteAdapter", () => {
13
55
  describe("getInstance", () => {
14
56
  it("returns the same instance for the same database name", async () => {
@@ -38,4 +80,56 @@ describe("ExpoSQLiteAdapter", () => {
38
80
  expect(adapter1.db?.execAsync).toHaveBeenCalledTimes(1);
39
81
  });
40
82
  });
83
+
84
+ describe("transaction", () => {
85
+ it("serializes concurrent transactions on the same connection", async () => {
86
+ const db = createFakeTransactionalDb();
87
+ const adapter = ExpoSQLiteAdapter.withDB(
88
+ db as unknown as Parameters<typeof ExpoSQLiteAdapter.withDB>[0],
89
+ );
90
+
91
+ const order: string[] = [];
92
+
93
+ // Without serialization, the interleaved BEGINs throw
94
+ // "cannot start a transaction within a transaction" and the resulting
95
+ // ROLLBACK aborts the other transaction.
96
+ await Promise.all(
97
+ Array.from({ length: 5 }, (_, i) =>
98
+ adapter.transaction(async () => {
99
+ order.push(`start-${i}`);
100
+ // Yield so a concurrent transaction would interleave here
101
+ await new Promise((resolve) => setTimeout(resolve, (5 - i) * 2));
102
+ order.push(`end-${i}`);
103
+ }),
104
+ ),
105
+ );
106
+
107
+ for (let i = 0; i < 5; i++) {
108
+ expect(order[i * 2]).toBe(`start-${i}`);
109
+ expect(order[i * 2 + 1]).toBe(`end-${i}`);
110
+ }
111
+ });
112
+
113
+ it("keeps processing transactions after one fails", async () => {
114
+ const db = createFakeTransactionalDb();
115
+ const adapter = ExpoSQLiteAdapter.withDB(
116
+ db as unknown as Parameters<typeof ExpoSQLiteAdapter.withDB>[0],
117
+ );
118
+
119
+ const failing = adapter.transaction(async () => {
120
+ throw new Error("boom");
121
+ });
122
+ const succeeding = adapter.transaction(async () => {});
123
+
124
+ await expect(failing).rejects.toThrow("boom");
125
+ await expect(succeeding).resolves.not.toThrow();
126
+
127
+ // The failed transaction rolled back exactly once, without touching
128
+ // the following transaction.
129
+ const rollbacks = db.execAsync.mock.calls.filter(
130
+ ([sql]) => sql === "ROLLBACK",
131
+ );
132
+ expect(rollbacks).toHaveLength(1);
133
+ });
134
+ });
41
135
  });
@@ -0,0 +1,40 @@
1
+ import type { SessionID } from "cojson";
2
+ import type { SessionDurabilityMarker } from "jazz-tools";
3
+ // KvStoreContext must come from "jazz-tools" (not the sibling
4
+ // ./storage/kv-store-context.js module): the repo currently has two identical
5
+ // KvStoreContext classes with separate singleton state, and the session
6
+ // provider that reads this marker uses the "jazz-tools" one
7
+ import {
8
+ KvStoreContext,
9
+ sessionDurabilityMarkerKey as markerKey,
10
+ } from "jazz-tools";
11
+
12
+ /**
13
+ * KvStore-backed durability marker. KvStore writes are async, so `set` can
14
+ * only be *initiated* before the network send — a best-effort guarantee
15
+ * (accepted limitation).
16
+ */
17
+ export const ReactNativeSessionDurabilityMarker: SessionDurabilityMarker = {
18
+ set(sessionID: SessionID) {
19
+ KvStoreContext.getInstance()
20
+ .getStorage()
21
+ .set(markerKey(sessionID), "1")
22
+ .catch((err) =>
23
+ console.warn("Failed to set session durability marker", err),
24
+ );
25
+ },
26
+ clear(sessionID: SessionID) {
27
+ KvStoreContext.getInstance()
28
+ .getStorage()
29
+ .delete(markerKey(sessionID))
30
+ .catch((err) =>
31
+ console.warn("Failed to clear session durability marker", err),
32
+ );
33
+ },
34
+ async isSet(sessionID: SessionID) {
35
+ const value = await KvStoreContext.getInstance()
36
+ .getStorage()
37
+ .get(markerKey(sessionID));
38
+ return value !== null;
39
+ },
40
+ };
@@ -5,22 +5,45 @@ import {
5
5
  SessionProvider,
6
6
  } from "jazz-tools";
7
7
  import { AgentID, RawAccountID } from "cojson";
8
+ import { ReactNativeSessionDurabilityMarker } from "./ReactNativeSessionDurabilityMarker.js";
8
9
 
9
10
  const lockedSessions = new Set<SessionID>();
10
11
 
11
12
  export class ReactNativeSessionProvider implements SessionProvider {
13
+ readonly durabilityMarker = ReactNativeSessionDurabilityMarker;
14
+
12
15
  async acquireSession(
13
16
  accountID: string,
14
17
  crypto: CryptoProvider,
15
18
  ): Promise<{ sessionID: SessionID; sessionDone: () => void }> {
16
19
  const kvStore = KvStoreContext.getInstance().getStorage();
17
- const existingSession = await kvStore.get(accountID as string);
20
+ let existingSession = await kvStore.get(accountID as string);
21
+
22
+ let abandonedDirtySession: SessionID | undefined;
23
+
24
+ if (
25
+ existingSession &&
26
+ (await ReactNativeSessionDurabilityMarker.isSet(
27
+ existingSession as SessionID,
28
+ ))
29
+ ) {
30
+ // The previous run crashed while it had transactions sent to a sync
31
+ // server but not yet persisted locally: reusing this session could fork
32
+ // its hash chain. Abandon it and fall through to minting a fresh one.
33
+ // The marker is cleared only AFTER the new session overwrites the kv
34
+ // entry, so a crash in between still leaves the session marked dirty.
35
+ abandonedDirtySession = existingSession as SessionID;
36
+ existingSession = null;
37
+ }
18
38
 
19
39
  if (!existingSession) {
20
40
  const newSessionID = crypto.newRandomSessionID(
21
41
  accountID as RawAccountID | AgentID,
22
42
  );
23
43
  await kvStore.set(accountID, newSessionID);
44
+ if (abandonedDirtySession) {
45
+ ReactNativeSessionDurabilityMarker.clear(abandonedDirtySession);
46
+ }
24
47
  lockedSessions.add(newSessionID);
25
48
 
26
49
  console.log("Created new session", newSessionID);
@@ -19,3 +19,4 @@ export {
19
19
  ReactNativeContextManager,
20
20
  type JazzContextManagerProps,
21
21
  } from "./ReactNativeContextManager.js";
22
+ export { ReactNativeSessionDurabilityMarker } from "./ReactNativeSessionDurabilityMarker.js";
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, test } from "vitest";
4
4
  import { InMemoryKVStore } from "jazz-tools";
5
5
  import { KvStoreContext, type KvStore } from "jazz-tools";
6
6
  import { ReactNativeSessionProvider } from "../ReactNativeSessionProvider.js";
7
+ import { ReactNativeSessionDurabilityMarker } from "../ReactNativeSessionDurabilityMarker.js";
7
8
  import { createJazzTestAccount } from "jazz-tools/testing";
8
9
  import type { CryptoProvider } from "jazz-tools";
9
10
 
@@ -293,4 +294,45 @@ describe("ReactNativeSessionProvider", () => {
293
294
  result.sessionDone();
294
295
  });
295
296
  });
297
+
298
+ describe("session durability marker", () => {
299
+ test("acquireSession mints a new session when the stored one is dirty", async () => {
300
+ const accountID = account.$jazz.id;
301
+
302
+ // Store a session, then mark it dirty (as a crash inside the window would leave it)
303
+ const dirtySession = Crypto.newRandomSessionID(
304
+ accountID as unknown as RawAccountID,
305
+ );
306
+ await kvStore.set(accountID, dirtySession);
307
+ ReactNativeSessionDurabilityMarker.set(dirtySession);
308
+ await new Promise((r) => setTimeout(r, 0));
309
+
310
+ const result = await sessionProvider.acquireSession(
311
+ accountID,
312
+ Crypto as CryptoProvider,
313
+ );
314
+
315
+ expect(result.sessionID).not.toBe(dirtySession);
316
+ // The kv entry was overwritten with the new session
317
+ expect(await kvStore.get(accountID)).toBe(result.sessionID);
318
+ // The stale marker was removed
319
+ expect(await ReactNativeSessionDurabilityMarker.isSet(dirtySession)).toBe(
320
+ false,
321
+ );
322
+
323
+ result.sessionDone();
324
+ });
325
+
326
+ test("marker set/clear/isSet round-trips through the KvStore", async () => {
327
+ const id = "co_zx_session_zy" as SessionID;
328
+ expect(await ReactNativeSessionDurabilityMarker.isSet(id)).toBe(false);
329
+ ReactNativeSessionDurabilityMarker.set(id);
330
+ // set() is fire-and-forget; flush microtasks before asserting
331
+ await Promise.resolve();
332
+ expect(await ReactNativeSessionDurabilityMarker.isSet(id)).toBe(true);
333
+ ReactNativeSessionDurabilityMarker.clear(id);
334
+ await Promise.resolve();
335
+ expect(await ReactNativeSessionDurabilityMarker.isSet(id)).toBe(false);
336
+ });
337
+ });
296
338
  });
@@ -105,6 +105,9 @@ export {
105
105
  createJazzContext,
106
106
  SessionProvider,
107
107
  MockSessionProvider,
108
+ type SessionDurabilityMarker,
109
+ makeDurabilityMarkerListener,
110
+ sessionDurabilityMarkerKey,
108
111
  type AuthResult,
109
112
  type Credentials,
110
113
  type JazzContextWithAccount,
@@ -23,6 +23,10 @@ import {
23
23
  import { AuthCredentials, NewAccountProps } from "../types.js";
24
24
  import { activeAccountContext } from "./activeAccountContext.js";
25
25
  import { AnonymousJazzAgent } from "./anonymousJazzAgent.js";
26
+ import {
27
+ type SessionDurabilityMarker,
28
+ makeDurabilityMarkerListener,
29
+ } from "./sessionDurabilityMarker.js";
26
30
 
27
31
  export type Credentials = {
28
32
  accountID: ID<Account>;
@@ -38,9 +42,17 @@ export interface SessionProvider {
38
42
  accountID: ID<Account>,
39
43
  sessionID: SessionID,
40
44
  ) => Promise<{ sessionDone: () => void }>;
45
+ /**
46
+ * When present, the context marks sessions as unsafe-to-reuse while they
47
+ * have transactions sent to a sync server but not yet persisted locally,
48
+ * and the provider must skip marked sessions in acquireSession.
49
+ */
50
+ durabilityMarker?: SessionDurabilityMarker;
41
51
  }
42
52
 
43
53
  export class MockSessionProvider implements SessionProvider {
54
+ durabilityMarker?: SessionDurabilityMarker;
55
+
44
56
  async acquireSession(
45
57
  accountID: ID<Account>,
46
58
  crypto: CryptoProvider,
@@ -136,6 +148,18 @@ export async function createJazzContextFromExistingCredentials<
136
148
  crypto,
137
149
  );
138
150
 
151
+ // Defense in depth: acquireSession implementations must never return a
152
+ // session that is still marked unsafe to reuse (see SessionProvider docs)
153
+ if (
154
+ sessionProvider.durabilityMarker &&
155
+ (await sessionProvider.durabilityMarker.isSet(sessionID))
156
+ ) {
157
+ console.warn(
158
+ "Session provider returned a session still marked unsafe to reuse; a crash may fork it",
159
+ sessionID,
160
+ );
161
+ }
162
+
139
163
  const CurrentAccountSchema =
140
164
  PropsAccountSchema ?? (RegisteredSchemas["Account"] as unknown as S);
141
165
 
@@ -152,6 +176,9 @@ export async function createJazzContextFromExistingCredentials<
152
176
  storage,
153
177
  enableFullStorageReconciliation: !!storage,
154
178
  experimental_clockSyncFromServerPings,
179
+ onLocalStoreDurabilityChange: makeDurabilityMarkerListener(
180
+ sessionProvider.durabilityMarker,
181
+ ),
155
182
  migration: async (rawAccount, _node, creationProps) => {
156
183
  const account = AccountClass.fromRaw(rawAccount) as InstanceOfSchema<S>;
157
184
  if (asActiveAccount) {
@@ -224,6 +251,9 @@ export async function createJazzContextForNewAccount<
224
251
  storage,
225
252
  enableFullStorageReconciliation: !!storage,
226
253
  experimental_clockSyncFromServerPings,
254
+ onLocalStoreDurabilityChange: makeDurabilityMarkerListener(
255
+ sessionProvider.durabilityMarker,
256
+ ),
227
257
  migration: async (rawAccount, _node, creationProps) => {
228
258
  const account = AccountClass.fromRaw(rawAccount) as InstanceOfSchema<S>;
229
259
  activeAccountContext.set(account);