jazz-tools 0.20.17 → 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/.svelte-kit/__package__/client.d.ts.map +1 -1
- package/.svelte-kit/__package__/client.js +8 -1
- package/.svelte-kit/__package__/tests/client.test.js +67 -0
- package/.turbo/turbo-build.log +85 -85
- package/CHANGELOG.md +28 -0
- package/dist/better-auth/auth/client.d.ts.map +1 -1
- package/dist/better-auth/auth/client.js +12 -1
- package/dist/better-auth/auth/client.js.map +1 -1
- package/dist/browser/BrowserContextManager.d.ts +1 -0
- package/dist/browser/BrowserContextManager.d.ts.map +1 -1
- package/dist/browser/createBrowserContext.d.ts +1 -0
- package/dist/browser/createBrowserContext.d.ts.map +1 -1
- package/dist/browser/index.js +47 -13
- 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-3BV3JUMV.js → chunk-VPK4IGIA.js} +113 -17
- 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/index.js +11 -3
- package/dist/react/index.js.map +1 -1
- package/dist/react/provider.d.ts +1 -1
- package/dist/react/provider.d.ts.map +1 -1
- package/dist/react-native/index.js +64 -11
- package/dist/react-native/index.js.map +1 -1
- package/dist/react-native-core/ReactNativeContextManager.d.ts +1 -0
- package/dist/react-native-core/ReactNativeContextManager.d.ts.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 +64 -11
- package/dist/react-native-core/index.js.map +1 -1
- package/dist/react-native-core/platform.d.ts +1 -0
- package/dist/react-native-core/platform.d.ts.map +1 -1
- package/dist/react-native-core/provider.d.ts +1 -1
- package/dist/react-native-core/provider.d.ts.map +1 -1
- package/dist/svelte/Provider.svelte +3 -0
- package/dist/svelte/Provider.svelte.d.ts.map +1 -1
- package/dist/testing.js +1 -1
- package/dist/tools/coValues/schemaUnion.d.ts +9 -0
- package/dist/tools/coValues/schemaUnion.d.ts.map +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 +15 -3
- 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/implementation/zodSchema/schemaTypes/CoMapSchema.d.ts.map +1 -1
- package/dist/tools/implementation/zodSchema/unionUtils.d.ts.map +1 -1
- package/dist/tools/internal.d.ts +1 -0
- package/dist/tools/internal.d.ts.map +1 -1
- package/dist/tools/subscribe/SubscriptionScope.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/dist/worker/index.d.ts +1 -0
- package/dist/worker/index.d.ts.map +1 -1
- package/dist/worker/index.js +6 -2
- package/dist/worker/index.js.map +1 -1
- package/package.json +4 -4
- package/src/better-auth/auth/client.ts +15 -1
- package/src/better-auth/auth/tests/client.test.ts +92 -0
- package/src/browser/BrowserContextManager.ts +5 -0
- package/src/browser/createBrowserContext.ts +8 -0
- 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/provider.tsx +9 -1
- package/src/react-native-core/ReactNativeContextManager.ts +5 -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/platform.ts +8 -0
- package/src/react-native-core/provider.tsx +9 -1
- package/src/react-native-core/tests/ReactNativeSessionProvider.test.ts +42 -0
- package/src/svelte/Provider.svelte +3 -0
- package/src/tools/coValues/schemaUnion.ts +13 -0
- package/src/tools/exports.ts +3 -0
- package/src/tools/implementation/createContext.ts +44 -0
- package/src/tools/implementation/sessionDurabilityMarker.ts +93 -0
- package/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts +24 -8
- package/src/tools/implementation/zodSchema/unionUtils.ts +2 -1
- package/src/tools/internal.ts +1 -0
- package/src/tools/subscribe/SubscriptionScope.ts +23 -1
- package/src/tools/tests/coMap.test.ts +30 -0
- package/src/tools/tests/createContext.test.ts +64 -0
- package/src/tools/tests/schemaUnion.test.ts +19 -0
- package/src/tools/tests/sessionDurabilityMarker.test.ts +183 -0
- package/src/worker/index.ts +6 -0
- package/dist/chunk-3BV3JUMV.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
|
});
|
package/src/react/provider.tsx
CHANGED
|
@@ -47,6 +47,7 @@ export function JazzReactProvider<
|
|
|
47
47
|
enableSSR,
|
|
48
48
|
fallback = null,
|
|
49
49
|
authSecretStorageKey,
|
|
50
|
+
experimental_clockSyncFromServerPings,
|
|
50
51
|
}: JazzProviderProps<S>) {
|
|
51
52
|
if (useContext(JazzContext)) {
|
|
52
53
|
throw new Error(
|
|
@@ -82,8 +83,15 @@ export function JazzReactProvider<
|
|
|
82
83
|
onAnonymousAccountDiscarded: onAnonymousAccountDiscarded
|
|
83
84
|
? onAnonymousAccountDiscardedRefCallback
|
|
84
85
|
: undefined,
|
|
86
|
+
experimental_clockSyncFromServerPings,
|
|
85
87
|
} satisfies JazzContextManagerProps<S>;
|
|
86
|
-
}, [
|
|
88
|
+
}, [
|
|
89
|
+
guestMode,
|
|
90
|
+
sync.peer,
|
|
91
|
+
sync.when,
|
|
92
|
+
storage,
|
|
93
|
+
experimental_clockSyncFromServerPings,
|
|
94
|
+
]);
|
|
87
95
|
|
|
88
96
|
if (contextManager.propsChanged(props) && typeof window !== "undefined") {
|
|
89
97
|
contextManager.createContext(props).catch((error) => {
|
|
@@ -33,6 +33,7 @@ export type JazzContextManagerProps<
|
|
|
33
33
|
onAnonymousAccountDiscarded?: (
|
|
34
34
|
anonymousAccount: InstanceOfSchema<S>,
|
|
35
35
|
) => Promise<void>;
|
|
36
|
+
experimental_clockSyncFromServerPings?: boolean;
|
|
36
37
|
};
|
|
37
38
|
|
|
38
39
|
export class ReactNativeContextManager<
|
|
@@ -49,6 +50,8 @@ export class ReactNativeContextManager<
|
|
|
49
50
|
sync: props.sync,
|
|
50
51
|
storage: props.storage,
|
|
51
52
|
authSecretStorage: this.authSecretStorage,
|
|
53
|
+
experimental_clockSyncFromServerPings:
|
|
54
|
+
props.experimental_clockSyncFromServerPings,
|
|
52
55
|
});
|
|
53
56
|
} else {
|
|
54
57
|
return createJazzReactNativeContext<S>({
|
|
@@ -59,6 +62,8 @@ export class ReactNativeContextManager<
|
|
|
59
62
|
newAccountProps: authProps?.newAccountProps,
|
|
60
63
|
defaultProfileName: props.defaultProfileName,
|
|
61
64
|
authSecretStorage: this.authSecretStorage,
|
|
65
|
+
experimental_clockSyncFromServerPings:
|
|
66
|
+
props.experimental_clockSyncFromServerPings,
|
|
62
67
|
});
|
|
63
68
|
}
|
|
64
69
|
}
|
|
@@ -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);
|
|
@@ -26,6 +26,7 @@ export type BaseReactNativeContextOptions = {
|
|
|
26
26
|
reconnectionTimeout?: number;
|
|
27
27
|
storage?: SQLiteDatabaseDriverAsync | "disabled";
|
|
28
28
|
authSecretStorage: AuthSecretStorage;
|
|
29
|
+
experimental_clockSyncFromServerPings?: boolean;
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
class ReactNativeWebSocketPeerWithReconnection extends WebSocketPeerWithReconnection {
|
|
@@ -73,6 +74,9 @@ async function setupPeers(options: BaseReactNativeContextOptions) {
|
|
|
73
74
|
removePeer: (peer) => {
|
|
74
75
|
peers.splice(peers.indexOf(peer), 1);
|
|
75
76
|
},
|
|
77
|
+
onPingReceived: (sample) => {
|
|
78
|
+
node?.clockOffset.addSample(sample);
|
|
79
|
+
},
|
|
76
80
|
});
|
|
77
81
|
|
|
78
82
|
function toggleNetwork(enabled: boolean) {
|
|
@@ -128,6 +132,8 @@ export async function createJazzReactNativeGuestContext(
|
|
|
128
132
|
peers,
|
|
129
133
|
syncWhen,
|
|
130
134
|
storage,
|
|
135
|
+
experimental_clockSyncFromServerPings:
|
|
136
|
+
options.experimental_clockSyncFromServerPings,
|
|
131
137
|
});
|
|
132
138
|
|
|
133
139
|
setNode(context.agent.node);
|
|
@@ -211,6 +217,8 @@ export async function createJazzReactNativeContext<
|
|
|
211
217
|
sessionProvider,
|
|
212
218
|
authSecretStorage: options.authSecretStorage,
|
|
213
219
|
storage,
|
|
220
|
+
experimental_clockSyncFromServerPings:
|
|
221
|
+
options.experimental_clockSyncFromServerPings,
|
|
214
222
|
});
|
|
215
223
|
|
|
216
224
|
setNode(context.node);
|
|
@@ -47,6 +47,7 @@ export function JazzProviderCore<
|
|
|
47
47
|
kvStore,
|
|
48
48
|
authSecretStorageKey,
|
|
49
49
|
fallback = null,
|
|
50
|
+
experimental_clockSyncFromServerPings,
|
|
50
51
|
}: JazzProviderProps<S>) {
|
|
51
52
|
if (useContext(JazzContext)) {
|
|
52
53
|
throw new Error(
|
|
@@ -80,8 +81,15 @@ export function JazzProviderCore<
|
|
|
80
81
|
onAnonymousAccountDiscarded: onAnonymousAccountDiscarded
|
|
81
82
|
? onAnonymousAccountDiscardedRefCallback
|
|
82
83
|
: undefined,
|
|
84
|
+
experimental_clockSyncFromServerPings,
|
|
83
85
|
} satisfies JazzContextManagerProps<S>;
|
|
84
|
-
}, [
|
|
86
|
+
}, [
|
|
87
|
+
guestMode,
|
|
88
|
+
sync.peer,
|
|
89
|
+
sync.when,
|
|
90
|
+
storage,
|
|
91
|
+
experimental_clockSyncFromServerPings,
|
|
92
|
+
]);
|
|
85
93
|
|
|
86
94
|
if (contextManager.propsChanged(props)) {
|
|
87
95
|
contextManager.createContext(props).catch((error) => {
|
|
@@ -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
|
});
|
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
props.sync.peer;
|
|
78
78
|
props.storage;
|
|
79
79
|
props.guestMode;
|
|
80
|
+
props.experimental_clockSyncFromServerPings;
|
|
80
81
|
return untrack(() => {
|
|
81
82
|
if (!props.sync) return;
|
|
82
83
|
|
|
@@ -89,6 +90,8 @@
|
|
|
89
90
|
defaultProfileName: props.defaultProfileName,
|
|
90
91
|
onAnonymousAccountDiscarded: props.onAnonymousAccountDiscarded,
|
|
91
92
|
onLogOut: props.onLogOut,
|
|
93
|
+
experimental_clockSyncFromServerPings:
|
|
94
|
+
props.experimental_clockSyncFromServerPings,
|
|
92
95
|
})
|
|
93
96
|
.catch((error) => {
|
|
94
97
|
console.error("Error creating Jazz browser context:", error);
|
|
@@ -22,6 +22,19 @@ import {
|
|
|
22
22
|
subscribeToCoValueWithoutMe,
|
|
23
23
|
} from "../internal.js";
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Thrown by a discriminated union's `fromRaw` when the stored value doesn't
|
|
27
|
+
* match any declared variant. Caught at the subscription layer so that
|
|
28
|
+
* `load()` can settle as unavailable instead of hanging, since the throw
|
|
29
|
+
* would otherwise disappear into cojson's async update loop.
|
|
30
|
+
*/
|
|
31
|
+
export class SchemaUnionNoMatchingVariantError extends Error {
|
|
32
|
+
constructor(message: string) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "SchemaUnionNoMatchingVariantError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
25
38
|
/**
|
|
26
39
|
* Extends `SchemaUnion` with a non-abstract constructor.
|
|
27
40
|
*/
|
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,
|