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
|
@@ -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,
|
|
@@ -118,6 +130,7 @@ export async function createJazzContextFromExistingCredentials<
|
|
|
118
130
|
sessionProvider,
|
|
119
131
|
onLogOut,
|
|
120
132
|
asActiveAccount,
|
|
133
|
+
experimental_clockSyncFromServerPings,
|
|
121
134
|
}: {
|
|
122
135
|
credentials: Credentials;
|
|
123
136
|
peers: Peer[];
|
|
@@ -128,12 +141,25 @@ export async function createJazzContextFromExistingCredentials<
|
|
|
128
141
|
onLogOut?: () => void;
|
|
129
142
|
storage?: StorageAPI;
|
|
130
143
|
asActiveAccount: boolean;
|
|
144
|
+
experimental_clockSyncFromServerPings?: boolean;
|
|
131
145
|
}): Promise<JazzContextWithAccount<InstanceOfSchema<S>>> {
|
|
132
146
|
const { sessionID, sessionDone } = await sessionProvider.acquireSession(
|
|
133
147
|
credentials.accountID,
|
|
134
148
|
crypto,
|
|
135
149
|
);
|
|
136
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
|
+
|
|
137
163
|
const CurrentAccountSchema =
|
|
138
164
|
PropsAccountSchema ?? (RegisteredSchemas["Account"] as unknown as S);
|
|
139
165
|
|
|
@@ -149,6 +175,10 @@ export async function createJazzContextFromExistingCredentials<
|
|
|
149
175
|
crypto,
|
|
150
176
|
storage,
|
|
151
177
|
enableFullStorageReconciliation: !!storage,
|
|
178
|
+
experimental_clockSyncFromServerPings,
|
|
179
|
+
onLocalStoreDurabilityChange: makeDurabilityMarkerListener(
|
|
180
|
+
sessionProvider.durabilityMarker,
|
|
181
|
+
),
|
|
152
182
|
migration: async (rawAccount, _node, creationProps) => {
|
|
153
183
|
const account = AccountClass.fromRaw(rawAccount) as InstanceOfSchema<S>;
|
|
154
184
|
if (asActiveAccount) {
|
|
@@ -193,6 +223,7 @@ export async function createJazzContextForNewAccount<
|
|
|
193
223
|
onLogOut,
|
|
194
224
|
storage,
|
|
195
225
|
sessionProvider,
|
|
226
|
+
experimental_clockSyncFromServerPings,
|
|
196
227
|
}: {
|
|
197
228
|
creationProps: { name: string };
|
|
198
229
|
initialAgentSecret?: AgentSecret;
|
|
@@ -203,6 +234,7 @@ export async function createJazzContextForNewAccount<
|
|
|
203
234
|
onLogOut?: () => Promise<void>;
|
|
204
235
|
storage?: StorageAPI;
|
|
205
236
|
sessionProvider: SessionProvider;
|
|
237
|
+
experimental_clockSyncFromServerPings?: boolean;
|
|
206
238
|
}): Promise<JazzContextWithAccount<InstanceOfSchema<S>>> {
|
|
207
239
|
const CurrentAccountSchema =
|
|
208
240
|
PropsAccountSchema ?? (RegisteredSchemas["Account"] as unknown as S);
|
|
@@ -218,6 +250,10 @@ export async function createJazzContextForNewAccount<
|
|
|
218
250
|
initialAgentSecret,
|
|
219
251
|
storage,
|
|
220
252
|
enableFullStorageReconciliation: !!storage,
|
|
253
|
+
experimental_clockSyncFromServerPings,
|
|
254
|
+
onLocalStoreDurabilityChange: makeDurabilityMarkerListener(
|
|
255
|
+
sessionProvider.durabilityMarker,
|
|
256
|
+
),
|
|
221
257
|
migration: async (rawAccount, _node, creationProps) => {
|
|
222
258
|
const account = AccountClass.fromRaw(rawAccount) as InstanceOfSchema<S>;
|
|
223
259
|
activeAccountContext.set(account);
|
|
@@ -263,6 +299,7 @@ export async function createJazzContext<
|
|
|
263
299
|
sessionProvider: SessionProvider;
|
|
264
300
|
authSecretStorage: AuthSecretStorage;
|
|
265
301
|
storage?: StorageAPI;
|
|
302
|
+
experimental_clockSyncFromServerPings?: boolean;
|
|
266
303
|
}) {
|
|
267
304
|
const crypto = options.crypto;
|
|
268
305
|
|
|
@@ -294,6 +331,8 @@ export async function createJazzContext<
|
|
|
294
331
|
},
|
|
295
332
|
storage: options.storage,
|
|
296
333
|
asActiveAccount: true,
|
|
334
|
+
experimental_clockSyncFromServerPings:
|
|
335
|
+
options.experimental_clockSyncFromServerPings,
|
|
297
336
|
});
|
|
298
337
|
} else {
|
|
299
338
|
const secretSeed = options.crypto.newRandomSecretSeed();
|
|
@@ -318,6 +357,8 @@ export async function createJazzContext<
|
|
|
318
357
|
await authSecretStorage.clearWithoutNotify();
|
|
319
358
|
},
|
|
320
359
|
storage: options.storage,
|
|
360
|
+
experimental_clockSyncFromServerPings:
|
|
361
|
+
options.experimental_clockSyncFromServerPings,
|
|
321
362
|
});
|
|
322
363
|
|
|
323
364
|
if (!options.newAccountProps) {
|
|
@@ -341,11 +382,13 @@ export function createAnonymousJazzContext({
|
|
|
341
382
|
syncWhen,
|
|
342
383
|
crypto,
|
|
343
384
|
storage,
|
|
385
|
+
experimental_clockSyncFromServerPings,
|
|
344
386
|
}: {
|
|
345
387
|
peers: Peer[];
|
|
346
388
|
syncWhen?: SyncWhen;
|
|
347
389
|
crypto: CryptoProvider;
|
|
348
390
|
storage?: StorageAPI;
|
|
391
|
+
experimental_clockSyncFromServerPings?: boolean;
|
|
349
392
|
}): JazzContextWithAgent {
|
|
350
393
|
const agentSecret = crypto.newRandomAgentSecret();
|
|
351
394
|
|
|
@@ -355,6 +398,7 @@ export function createAnonymousJazzContext({
|
|
|
355
398
|
crypto,
|
|
356
399
|
syncWhen,
|
|
357
400
|
!!storage,
|
|
401
|
+
{ experimental_clockSyncFromServerPings },
|
|
358
402
|
);
|
|
359
403
|
|
|
360
404
|
for (const peer of peers) {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { LocalStoreDurabilityListener, SessionID } from "cojson";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Persists a per-session "dirty" flag that survives crashes.
|
|
5
|
+
*
|
|
6
|
+
* The flag is set while the session has transactions that were sent to a sync
|
|
7
|
+
* server but are not yet durably stored locally. If the process dies inside
|
|
8
|
+
* that window, local storage is behind what the server received for the
|
|
9
|
+
* session, and reusing it would fork the session's hash chain. Session
|
|
10
|
+
* providers must skip sessions whose flag is still set and mint a fresh
|
|
11
|
+
* session instead.
|
|
12
|
+
*/
|
|
13
|
+
export interface SessionDurabilityMarker {
|
|
14
|
+
/**
|
|
15
|
+
* Must be initiated synchronously: the write has to win the race against
|
|
16
|
+
* the network send that immediately follows it.
|
|
17
|
+
*/
|
|
18
|
+
set(sessionID: SessionID): void;
|
|
19
|
+
clear(sessionID: SessionID): void;
|
|
20
|
+
isSet(sessionID: SessionID): boolean | Promise<boolean>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Storage key under which a session's dirty flag is persisted. Shared by all
|
|
25
|
+
* platform marker implementations so the key format cannot silently diverge.
|
|
26
|
+
*/
|
|
27
|
+
export function sessionDurabilityMarkerKey(sessionID: SessionID) {
|
|
28
|
+
return `jazz_session_dirty_${sessionID}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const SESSION_DURABILITY_CLEAR_DEBOUNCE_MS = 200;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Adapts a SessionDurabilityMarker to LocalNode's onLocalStoreDurabilityChange
|
|
35
|
+
* hook. Setting is immediate (correctness-critical); clearing is debounced so
|
|
36
|
+
* the marker doesn't churn on every batch while the user is actively editing.
|
|
37
|
+
* A crash inside the debounce at worst abandons one session unnecessarily.
|
|
38
|
+
*
|
|
39
|
+
* Returns undefined when no marker is provided, matching the optional
|
|
40
|
+
* listener slot on LocalNode creation options.
|
|
41
|
+
*/
|
|
42
|
+
export function makeDurabilityMarkerListener(
|
|
43
|
+
marker: SessionDurabilityMarker,
|
|
44
|
+
clearDebounceMs?: number,
|
|
45
|
+
): LocalStoreDurabilityListener;
|
|
46
|
+
export function makeDurabilityMarkerListener(
|
|
47
|
+
marker: SessionDurabilityMarker | undefined,
|
|
48
|
+
clearDebounceMs?: number,
|
|
49
|
+
): LocalStoreDurabilityListener | undefined;
|
|
50
|
+
export function makeDurabilityMarkerListener(
|
|
51
|
+
marker: SessionDurabilityMarker | undefined,
|
|
52
|
+
clearDebounceMs: number = SESSION_DURABILITY_CLEAR_DEBOUNCE_MS,
|
|
53
|
+
): LocalStoreDurabilityListener | undefined {
|
|
54
|
+
if (!marker) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let clearTimer: ReturnType<typeof setTimeout> | undefined;
|
|
59
|
+
let markerIsSet = false;
|
|
60
|
+
|
|
61
|
+
return (hasPending, sessionID) => {
|
|
62
|
+
if (clearTimer !== undefined) {
|
|
63
|
+
clearTimeout(clearTimer);
|
|
64
|
+
clearTimer = undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (hasPending) {
|
|
68
|
+
// A window reopening within the clear debounce finds the marker still
|
|
69
|
+
// set: skip the write so steady-state editing costs no marker I/O
|
|
70
|
+
if (markerIsSet) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
marker.set(sessionID);
|
|
76
|
+
markerIsSet = true;
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.warn("Failed to set session durability marker", err);
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
clearTimer = setTimeout(() => {
|
|
82
|
+
clearTimer = undefined;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
marker.clear(sessionID);
|
|
86
|
+
markerIsSet = false;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.warn("Failed to clear session durability marker", err);
|
|
89
|
+
}
|
|
90
|
+
}, clearDebounceMs);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -524,11 +524,19 @@ export class CoMapSchema<
|
|
|
524
524
|
Owner,
|
|
525
525
|
DefaultResolveQuery
|
|
526
526
|
> {
|
|
527
|
+
const extendedShape = {} as z.core.util.Extend<Shape, ExtendShape>;
|
|
528
|
+
|
|
529
|
+
Object.defineProperties(
|
|
530
|
+
extendedShape,
|
|
531
|
+
Object.getOwnPropertyDescriptors(this.shape),
|
|
532
|
+
);
|
|
533
|
+
Object.defineProperties(
|
|
534
|
+
extendedShape,
|
|
535
|
+
Object.getOwnPropertyDescriptors(shape),
|
|
536
|
+
);
|
|
537
|
+
|
|
527
538
|
return this.copy({
|
|
528
|
-
shape:
|
|
529
|
-
...this.shape,
|
|
530
|
-
...shape,
|
|
531
|
-
} as z.core.util.Extend<Shape, ExtendShape>,
|
|
539
|
+
shape: extendedShape,
|
|
532
540
|
});
|
|
533
541
|
}
|
|
534
542
|
|
|
@@ -549,11 +557,19 @@ export class CoMapSchema<
|
|
|
549
557
|
Owner,
|
|
550
558
|
DefaultResolveQuery
|
|
551
559
|
> {
|
|
560
|
+
const extendedShape = {} as z.core.util.Extend<Shape, ExtendShape>;
|
|
561
|
+
|
|
562
|
+
Object.defineProperties(
|
|
563
|
+
extendedShape,
|
|
564
|
+
Object.getOwnPropertyDescriptors(this.shape),
|
|
565
|
+
);
|
|
566
|
+
Object.defineProperties(
|
|
567
|
+
extendedShape,
|
|
568
|
+
Object.getOwnPropertyDescriptors(shape),
|
|
569
|
+
);
|
|
570
|
+
|
|
552
571
|
return this.copy({
|
|
553
|
-
shape:
|
|
554
|
-
...this.shape,
|
|
555
|
-
...shape,
|
|
556
|
-
} as z.core.util.Extend<Shape, ExtendShape>,
|
|
572
|
+
shape: extendedShape,
|
|
557
573
|
});
|
|
558
574
|
}
|
|
559
575
|
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
DiscriminableCoValueSchemas,
|
|
9
9
|
DiscriminableCoreCoValueSchema,
|
|
10
10
|
SchemaUnionDiscriminator,
|
|
11
|
+
SchemaUnionNoMatchingVariantError,
|
|
11
12
|
createCoreCoMapSchema,
|
|
12
13
|
} from "../../internal.js";
|
|
13
14
|
import {
|
|
@@ -118,7 +119,7 @@ export function schemaUnionDiscriminatorFor(
|
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
throw new
|
|
122
|
+
throw new SchemaUnionNoMatchingVariantError(
|
|
122
123
|
"co.discriminatedUnion() of collaborative types with no matching discriminator value found",
|
|
123
124
|
);
|
|
124
125
|
};
|
package/src/tools/internal.ts
CHANGED
|
@@ -33,6 +33,7 @@ export * from "./lib/cache.js";
|
|
|
33
33
|
export * from "./lib/utils.js";
|
|
34
34
|
export * from "./lib/utilityTypes.js";
|
|
35
35
|
export * from "./implementation/createContext.js";
|
|
36
|
+
export * from "./implementation/sessionDurabilityMarker.js";
|
|
36
37
|
|
|
37
38
|
export * from "./types.js";
|
|
38
39
|
export * from "./implementation/zodSchema/zodSchema.js";
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
NotLoaded,
|
|
9
9
|
type RefEncoded,
|
|
10
10
|
type RefsToResolve,
|
|
11
|
+
SchemaUnionNoMatchingVariantError,
|
|
11
12
|
TypeSym,
|
|
12
13
|
createUnloadedCoValue,
|
|
13
14
|
instantiateRefEncodedFromRaw,
|
|
@@ -173,7 +174,28 @@ export class SubscriptionScope<D extends CoValue> {
|
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
this.migrating = true;
|
|
176
|
-
|
|
177
|
+
let instance: CoValue;
|
|
178
|
+
try {
|
|
179
|
+
instance = instantiateRefEncodedFromRaw(this.schema, value);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
// A discriminated union whose stored value doesn't match any
|
|
182
|
+
// declared variant would otherwise hang load() — the throw
|
|
183
|
+
// escapes into cojson's async update loop and nothing settles.
|
|
184
|
+
// Treat it as unavailable so load() resolves. Other
|
|
185
|
+
// instantiation errors (e.g. CoVector dimension mismatch) keep
|
|
186
|
+
// throwing loudly, as they indicate schema drift that callers
|
|
187
|
+
// should surface.
|
|
188
|
+
if (!(error instanceof SchemaUnionNoMatchingVariantError)) {
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
this.migrationFailed = true;
|
|
192
|
+
this.migrated = true;
|
|
193
|
+
console.error(
|
|
194
|
+
`Schema instantiation failed for ${this.id}: ${error.message}`,
|
|
195
|
+
);
|
|
196
|
+
this.handleUpdate(CoValueLoadingState.UNAVAILABLE);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
177
199
|
try {
|
|
178
200
|
applyCoValueMigrations(instance);
|
|
179
201
|
} catch (error) {
|
|
@@ -3057,6 +3057,21 @@ describe("co.map schema", () => {
|
|
|
3057
3057
|
|
|
3058
3058
|
expect(person.extraField).toEqual("extra");
|
|
3059
3059
|
});
|
|
3060
|
+
|
|
3061
|
+
test("handles circular references", () => {
|
|
3062
|
+
const BasePerson = co.map({
|
|
3063
|
+
name: z.string(),
|
|
3064
|
+
});
|
|
3065
|
+
|
|
3066
|
+
const Person = BasePerson.extend({
|
|
3067
|
+
get friends() {
|
|
3068
|
+
return co.list(Person);
|
|
3069
|
+
},
|
|
3070
|
+
});
|
|
3071
|
+
|
|
3072
|
+
expect(Person.shape.name).toBeDefined();
|
|
3073
|
+
expect(Person.shape.friends).toBeDefined();
|
|
3074
|
+
});
|
|
3060
3075
|
});
|
|
3061
3076
|
|
|
3062
3077
|
describe("safeExtend()", () => {
|
|
@@ -3101,6 +3116,21 @@ describe("co.map schema", () => {
|
|
|
3101
3116
|
|
|
3102
3117
|
expect(person.extraField).toEqual("extra");
|
|
3103
3118
|
});
|
|
3119
|
+
|
|
3120
|
+
test("handles circular references", () => {
|
|
3121
|
+
const BasePerson = co.map({
|
|
3122
|
+
name: z.string(),
|
|
3123
|
+
});
|
|
3124
|
+
|
|
3125
|
+
const Person = BasePerson.safeExtend({
|
|
3126
|
+
get friends(): co.List<any> {
|
|
3127
|
+
return co.list(Person);
|
|
3128
|
+
},
|
|
3129
|
+
});
|
|
3130
|
+
|
|
3131
|
+
expect(Person.shape.name).toBeDefined();
|
|
3132
|
+
expect(Person.shape.friends).toBeDefined();
|
|
3133
|
+
});
|
|
3104
3134
|
});
|
|
3105
3135
|
|
|
3106
3136
|
describe("schema configuration inheritance", () => {
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
createJazzContextForNewAccount,
|
|
17
17
|
createJazzContextFromExistingCredentials,
|
|
18
18
|
MockSessionProvider,
|
|
19
|
+
z,
|
|
19
20
|
} from "../exports";
|
|
20
21
|
import { activeAccountContext } from "../implementation/activeAccountContext";
|
|
21
22
|
import {
|
|
@@ -144,6 +145,69 @@ describe("createContext methods", () => {
|
|
|
144
145
|
);
|
|
145
146
|
});
|
|
146
147
|
|
|
148
|
+
test("rejects when the migration throws synchronously", async () => {
|
|
149
|
+
const CustomAccount = co.account().withMigration(() => {
|
|
150
|
+
throw new Error("migration failed");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const account = await createJazzTestAccount({
|
|
154
|
+
isCurrentActiveAccount: true,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const credentials: Credentials = {
|
|
158
|
+
accountID: account.$jazz.id,
|
|
159
|
+
secret: account.$jazz.localNode.getCurrentAgent().agentSecret,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
await expect(
|
|
163
|
+
createJazzContextFromExistingCredentials({
|
|
164
|
+
credentials,
|
|
165
|
+
peers: [getPeerConnectedToTestSyncServer()],
|
|
166
|
+
crypto: Crypto,
|
|
167
|
+
AccountSchema: CustomAccount,
|
|
168
|
+
sessionProvider: randomSessionProvider,
|
|
169
|
+
asActiveAccount: true,
|
|
170
|
+
}),
|
|
171
|
+
).rejects.toThrow("migration failed");
|
|
172
|
+
}, 2000);
|
|
173
|
+
|
|
174
|
+
test("does not hang when an account migration loads a discriminated union with no matching variant", async () => {
|
|
175
|
+
const TypeA = co.map({ type: z.literal("a"), data: z.string() });
|
|
176
|
+
const TypeB = co.map({ type: z.literal("b"), data: z.string() });
|
|
177
|
+
|
|
178
|
+
const account = await createJazzTestAccount({
|
|
179
|
+
isCurrentActiveAccount: true,
|
|
180
|
+
});
|
|
181
|
+
const typeAValue = TypeA.create({ type: "a", data: "hello" }, account);
|
|
182
|
+
|
|
183
|
+
// Schema only knows TypeB, so loading a TypeA value through it used
|
|
184
|
+
// to throw from the async subscription callback and leave load()
|
|
185
|
+
// unsettled, hanging the account migration and createContext with it.
|
|
186
|
+
const UnionBOnly = co.discriminatedUnion("type", [TypeB]);
|
|
187
|
+
|
|
188
|
+
const AccountSchema = co
|
|
189
|
+
.account()
|
|
190
|
+
.withMigration(async (loadedAccount) => {
|
|
191
|
+
await UnionBOnly.load(typeAValue.$jazz.id, {
|
|
192
|
+
loadAs: loadedAccount,
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const context = await createJazzContextFromExistingCredentials({
|
|
197
|
+
credentials: {
|
|
198
|
+
accountID: account.$jazz.id,
|
|
199
|
+
secret: account.$jazz.localNode.getCurrentAgent().agentSecret,
|
|
200
|
+
},
|
|
201
|
+
peers: [getPeerConnectedToTestSyncServer()],
|
|
202
|
+
crypto: Crypto,
|
|
203
|
+
AccountSchema,
|
|
204
|
+
sessionProvider: randomSessionProvider,
|
|
205
|
+
asActiveAccount: true,
|
|
206
|
+
});
|
|
207
|
+
expect(context.account).toBeDefined();
|
|
208
|
+
context.done();
|
|
209
|
+
}, 2000);
|
|
210
|
+
|
|
147
211
|
test("calls onLogOut callback when logging out", async () => {
|
|
148
212
|
const account = await createJazzTestAccount({
|
|
149
213
|
isCurrentActiveAccount: true,
|
|
@@ -2,6 +2,7 @@ import { WasmCrypto } from "cojson/crypto/WasmCrypto";
|
|
|
2
2
|
import { assert, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
3
3
|
import {
|
|
4
4
|
Account,
|
|
5
|
+
CoValueLoadingState,
|
|
5
6
|
CryptoProvider,
|
|
6
7
|
Loaded,
|
|
7
8
|
co,
|
|
@@ -96,6 +97,24 @@ describe("SchemaUnion", () => {
|
|
|
96
97
|
expect(loadedCheckboxWidget.type).toBe("checkbox");
|
|
97
98
|
});
|
|
98
99
|
|
|
100
|
+
it("load() settles as unavailable when the stored value matches no declared variant", async () => {
|
|
101
|
+
const slider = SliderWidget.create(
|
|
102
|
+
{ type: "slider", min: 0, max: 100 },
|
|
103
|
+
{ owner: me },
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Union that doesn't include "slider" — the runtime discriminator will
|
|
107
|
+
// throw SchemaUnionNoMatchingVariantError from inside the subscription
|
|
108
|
+
// callback. load() must settle as unavailable rather than hang (async
|
|
109
|
+
// path) or reject (sync path).
|
|
110
|
+
const CheckboxOnlyUnion = co.discriminatedUnion("type", [CheckboxWidget]);
|
|
111
|
+
const loaded = await CheckboxOnlyUnion.load(slider.$jazz.id, {
|
|
112
|
+
loadAs: me,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(loaded.$jazz.loadingState).toBe(CoValueLoadingState.UNAVAILABLE);
|
|
116
|
+
});
|
|
117
|
+
|
|
99
118
|
it("should integrate with subscribeToCoValue correctly", async () => {
|
|
100
119
|
const buttonWidget = BlueButtonWidget.create(
|
|
101
120
|
{ type: "button", color: "blue", label: "Submit", blueness: 100 },
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { SessionID } from "cojson";
|
|
2
|
+
import { WasmCrypto } from "cojson/crypto/WasmCrypto";
|
|
3
|
+
import { LocalNode } from "cojson";
|
|
4
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
5
|
+
import { makeDurabilityMarkerListener } from "../implementation/sessionDurabilityMarker.js";
|
|
6
|
+
import {
|
|
7
|
+
Credentials,
|
|
8
|
+
InMemoryKVStore,
|
|
9
|
+
KvStoreContext,
|
|
10
|
+
MockSessionProvider,
|
|
11
|
+
createJazzContextFromExistingCredentials,
|
|
12
|
+
} from "../exports.js";
|
|
13
|
+
import {
|
|
14
|
+
createJazzTestAccount,
|
|
15
|
+
getPeerConnectedToTestSyncServer,
|
|
16
|
+
setupJazzTestSync,
|
|
17
|
+
} from "../testing.js";
|
|
18
|
+
|
|
19
|
+
const sessionID = "co_ztest_session_ztest" as SessionID;
|
|
20
|
+
|
|
21
|
+
function mockMarker() {
|
|
22
|
+
return { set: vi.fn(), clear: vi.fn(), isSet: vi.fn(() => false) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.useRealTimers();
|
|
27
|
+
vi.restoreAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("makeDurabilityMarkerListener", () => {
|
|
31
|
+
test("sets the marker synchronously when the window opens", () => {
|
|
32
|
+
const marker = mockMarker();
|
|
33
|
+
const listener = makeDurabilityMarkerListener(marker);
|
|
34
|
+
|
|
35
|
+
listener(true, sessionID);
|
|
36
|
+
|
|
37
|
+
expect(marker.set).toHaveBeenCalledWith(sessionID);
|
|
38
|
+
expect(marker.clear).not.toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("clears the marker only after the debounce delay", () => {
|
|
42
|
+
vi.useFakeTimers();
|
|
43
|
+
const marker = mockMarker();
|
|
44
|
+
const listener = makeDurabilityMarkerListener(marker, 200);
|
|
45
|
+
|
|
46
|
+
listener(true, sessionID);
|
|
47
|
+
listener(false, sessionID);
|
|
48
|
+
expect(marker.clear).not.toHaveBeenCalled();
|
|
49
|
+
|
|
50
|
+
vi.advanceTimersByTime(200);
|
|
51
|
+
expect(marker.clear).toHaveBeenCalledWith(sessionID);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a new pending window cancels a scheduled clear", () => {
|
|
55
|
+
vi.useFakeTimers();
|
|
56
|
+
const marker = mockMarker();
|
|
57
|
+
const listener = makeDurabilityMarkerListener(marker, 200);
|
|
58
|
+
|
|
59
|
+
listener(true, sessionID);
|
|
60
|
+
listener(false, sessionID);
|
|
61
|
+
listener(true, sessionID); // window re-opens within the debounce
|
|
62
|
+
vi.advanceTimersByTime(500);
|
|
63
|
+
|
|
64
|
+
expect(marker.clear).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("consecutive clears don't leave an orphaned timer that clears a fresh set", () => {
|
|
68
|
+
vi.useFakeTimers();
|
|
69
|
+
const marker = mockMarker();
|
|
70
|
+
const listener = makeDurabilityMarkerListener(marker, 200);
|
|
71
|
+
|
|
72
|
+
listener(true, sessionID);
|
|
73
|
+
listener(false, sessionID);
|
|
74
|
+
listener(false, sessionID); // must supersede (not orphan) the first timer
|
|
75
|
+
listener(true, sessionID); // window re-opens: no clear may fire anymore
|
|
76
|
+
vi.advanceTimersByTime(500);
|
|
77
|
+
|
|
78
|
+
expect(marker.clear).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("a reopen within the debounce doesn't rewrite the still-set marker", () => {
|
|
82
|
+
vi.useFakeTimers();
|
|
83
|
+
const marker = mockMarker();
|
|
84
|
+
const listener = makeDurabilityMarkerListener(marker, 200);
|
|
85
|
+
|
|
86
|
+
listener(true, sessionID);
|
|
87
|
+
listener(false, sessionID);
|
|
88
|
+
listener(true, sessionID); // marker was never cleared: no write needed
|
|
89
|
+
|
|
90
|
+
expect(marker.set).toHaveBeenCalledTimes(1);
|
|
91
|
+
|
|
92
|
+
// ...but after an actual clear, the next window writes again
|
|
93
|
+
listener(false, sessionID);
|
|
94
|
+
vi.advanceTimersByTime(200);
|
|
95
|
+
listener(true, sessionID);
|
|
96
|
+
|
|
97
|
+
expect(marker.clear).toHaveBeenCalledTimes(1);
|
|
98
|
+
expect(marker.set).toHaveBeenCalledTimes(2);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("returns undefined when no marker is provided", () => {
|
|
102
|
+
expect(makeDurabilityMarkerListener(undefined)).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("marker errors are swallowed with a warning", () => {
|
|
106
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
107
|
+
const marker = mockMarker();
|
|
108
|
+
marker.set.mockImplementation(() => {
|
|
109
|
+
throw new Error("quota exceeded");
|
|
110
|
+
});
|
|
111
|
+
const listener = makeDurabilityMarkerListener(marker);
|
|
112
|
+
|
|
113
|
+
expect(() => listener(true, sessionID)).not.toThrow();
|
|
114
|
+
expect(warn).toHaveBeenCalled();
|
|
115
|
+
warn.mockRestore();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const Crypto = await WasmCrypto.create();
|
|
120
|
+
KvStoreContext.getInstance().initialize(new InMemoryKVStore());
|
|
121
|
+
|
|
122
|
+
describe("createContext wiring", () => {
|
|
123
|
+
test("passes a durability listener to LocalNode when the provider has a marker", async () => {
|
|
124
|
+
await setupJazzTestSync();
|
|
125
|
+
const account = await createJazzTestAccount({
|
|
126
|
+
isCurrentActiveAccount: true,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const credentials: Credentials = {
|
|
130
|
+
accountID: account.$jazz.id,
|
|
131
|
+
secret: account.$jazz.localNode.getCurrentAgent().agentSecret,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const spy = vi.spyOn(LocalNode, "withLoadedAccount");
|
|
135
|
+
|
|
136
|
+
const provider = new MockSessionProvider();
|
|
137
|
+
provider.durabilityMarker = mockMarker();
|
|
138
|
+
|
|
139
|
+
const context = await createJazzContextFromExistingCredentials({
|
|
140
|
+
credentials,
|
|
141
|
+
peers: [getPeerConnectedToTestSyncServer()],
|
|
142
|
+
crypto: Crypto,
|
|
143
|
+
sessionProvider: provider,
|
|
144
|
+
asActiveAccount: false,
|
|
145
|
+
});
|
|
146
|
+
context.done();
|
|
147
|
+
|
|
148
|
+
expect(spy).toHaveBeenCalledWith(
|
|
149
|
+
expect.objectContaining({
|
|
150
|
+
onLocalStoreDurabilityChange: expect.any(Function),
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("passes no listener when the provider has no marker", async () => {
|
|
156
|
+
await setupJazzTestSync();
|
|
157
|
+
const account = await createJazzTestAccount({
|
|
158
|
+
isCurrentActiveAccount: true,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const credentials: Credentials = {
|
|
162
|
+
accountID: account.$jazz.id,
|
|
163
|
+
secret: account.$jazz.localNode.getCurrentAgent().agentSecret,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const spy = vi.spyOn(LocalNode, "withLoadedAccount");
|
|
167
|
+
|
|
168
|
+
const context = await createJazzContextFromExistingCredentials({
|
|
169
|
+
credentials,
|
|
170
|
+
peers: [getPeerConnectedToTestSyncServer()],
|
|
171
|
+
crypto: Crypto,
|
|
172
|
+
sessionProvider: new MockSessionProvider(),
|
|
173
|
+
asActiveAccount: false,
|
|
174
|
+
});
|
|
175
|
+
context.done();
|
|
176
|
+
|
|
177
|
+
expect(spy).toHaveBeenCalledWith(
|
|
178
|
+
expect.objectContaining({
|
|
179
|
+
onLocalStoreDurabilityChange: undefined,
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
});
|