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.
- package/.turbo/turbo-build.log +93 -93
- package/CHANGELOG.md +15 -0
- package/dist/browser/index.js +36 -9
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/provideBrowserLockSession/BrowserSessionDurabilityMarker.d.ts +8 -0
- package/dist/browser/provideBrowserLockSession/BrowserSessionDurabilityMarker.d.ts.map +1 -0
- package/dist/browser/provideBrowserLockSession/BrowserSessionProvider.d.ts +1 -0
- package/dist/browser/provideBrowserLockSession/BrowserSessionProvider.d.ts.map +1 -1
- package/dist/browser/provideBrowserLockSession/index.d.ts +1 -0
- package/dist/browser/provideBrowserLockSession/index.d.ts.map +1 -1
- package/dist/{chunk-MIPBSAS7.js → chunk-VPK4IGIA.js} +57 -1
- package/dist/chunk-VPK4IGIA.js.map +1 -0
- package/dist/expo/index.js +22 -7
- package/dist/expo/index.js.map +1 -1
- package/dist/expo/storage/expo-sqlite-adapter.d.ts +9 -0
- package/dist/expo/storage/expo-sqlite-adapter.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/react-native/index.js +42 -4
- package/dist/react-native/index.js.map +1 -1
- package/dist/react-native-core/ReactNativeSessionDurabilityMarker.d.ts +8 -0
- package/dist/react-native-core/ReactNativeSessionDurabilityMarker.d.ts.map +1 -0
- package/dist/react-native-core/ReactNativeSessionProvider.d.ts +1 -0
- package/dist/react-native-core/ReactNativeSessionProvider.d.ts.map +1 -1
- package/dist/react-native-core/index.d.ts +1 -0
- package/dist/react-native-core/index.d.ts.map +1 -1
- package/dist/react-native-core/index.js +42 -4
- package/dist/react-native-core/index.js.map +1 -1
- package/dist/testing.js +1 -1
- package/dist/tools/exports.d.ts +1 -1
- package/dist/tools/exports.d.ts.map +1 -1
- package/dist/tools/implementation/createContext.d.ts +8 -0
- package/dist/tools/implementation/createContext.d.ts.map +1 -1
- package/dist/tools/implementation/sessionDurabilityMarker.d.ts +38 -0
- package/dist/tools/implementation/sessionDurabilityMarker.d.ts.map +1 -0
- package/dist/tools/internal.d.ts +1 -0
- package/dist/tools/internal.d.ts.map +1 -1
- package/dist/tools/tests/sessionDurabilityMarker.test.d.ts +2 -0
- package/dist/tools/tests/sessionDurabilityMarker.test.d.ts.map +1 -0
- package/package.json +4 -4
- package/src/browser/provideBrowserLockSession/BrowserSessionDurabilityMarker.ts +20 -0
- package/src/browser/provideBrowserLockSession/BrowserSessionProvider.test.ts +97 -1
- package/src/browser/provideBrowserLockSession/BrowserSessionProvider.ts +37 -8
- package/src/browser/provideBrowserLockSession/index.ts +1 -0
- package/src/expo/storage/expo-sqlite-adapter.ts +24 -7
- package/src/expo/tests/expo-sqlite-adapter.test.ts +94 -0
- package/src/react-native-core/ReactNativeSessionDurabilityMarker.ts +40 -0
- package/src/react-native-core/ReactNativeSessionProvider.ts +24 -1
- package/src/react-native-core/index.ts +1 -0
- package/src/react-native-core/tests/ReactNativeSessionProvider.test.ts +42 -0
- package/src/tools/exports.ts +3 -0
- package/src/tools/implementation/createContext.ts +30 -0
- package/src/tools/implementation/sessionDurabilityMarker.ts +93 -0
- package/src/tools/internal.ts +1 -0
- package/src/tools/tests/sessionDurabilityMarker.test.ts +183 -0
- 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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -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
|
});
|
package/src/tools/exports.ts
CHANGED
|
@@ -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);
|