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.
Files changed (104) hide show
  1. package/.svelte-kit/__package__/client.d.ts.map +1 -1
  2. package/.svelte-kit/__package__/client.js +8 -1
  3. package/.svelte-kit/__package__/tests/client.test.js +67 -0
  4. package/.turbo/turbo-build.log +85 -85
  5. package/CHANGELOG.md +28 -0
  6. package/dist/better-auth/auth/client.d.ts.map +1 -1
  7. package/dist/better-auth/auth/client.js +12 -1
  8. package/dist/better-auth/auth/client.js.map +1 -1
  9. package/dist/browser/BrowserContextManager.d.ts +1 -0
  10. package/dist/browser/BrowserContextManager.d.ts.map +1 -1
  11. package/dist/browser/createBrowserContext.d.ts +1 -0
  12. package/dist/browser/createBrowserContext.d.ts.map +1 -1
  13. package/dist/browser/index.js +47 -13
  14. package/dist/browser/index.js.map +1 -1
  15. package/dist/browser/provideBrowserLockSession/BrowserSessionDurabilityMarker.d.ts +8 -0
  16. package/dist/browser/provideBrowserLockSession/BrowserSessionDurabilityMarker.d.ts.map +1 -0
  17. package/dist/browser/provideBrowserLockSession/BrowserSessionProvider.d.ts +1 -0
  18. package/dist/browser/provideBrowserLockSession/BrowserSessionProvider.d.ts.map +1 -1
  19. package/dist/browser/provideBrowserLockSession/index.d.ts +1 -0
  20. package/dist/browser/provideBrowserLockSession/index.d.ts.map +1 -1
  21. package/dist/{chunk-3BV3JUMV.js → chunk-VPK4IGIA.js} +113 -17
  22. package/dist/chunk-VPK4IGIA.js.map +1 -0
  23. package/dist/expo/index.js +22 -7
  24. package/dist/expo/index.js.map +1 -1
  25. package/dist/expo/storage/expo-sqlite-adapter.d.ts +9 -0
  26. package/dist/expo/storage/expo-sqlite-adapter.d.ts.map +1 -1
  27. package/dist/index.js +5 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/react/index.js +11 -3
  30. package/dist/react/index.js.map +1 -1
  31. package/dist/react/provider.d.ts +1 -1
  32. package/dist/react/provider.d.ts.map +1 -1
  33. package/dist/react-native/index.js +64 -11
  34. package/dist/react-native/index.js.map +1 -1
  35. package/dist/react-native-core/ReactNativeContextManager.d.ts +1 -0
  36. package/dist/react-native-core/ReactNativeContextManager.d.ts.map +1 -1
  37. package/dist/react-native-core/ReactNativeSessionDurabilityMarker.d.ts +8 -0
  38. package/dist/react-native-core/ReactNativeSessionDurabilityMarker.d.ts.map +1 -0
  39. package/dist/react-native-core/ReactNativeSessionProvider.d.ts +1 -0
  40. package/dist/react-native-core/ReactNativeSessionProvider.d.ts.map +1 -1
  41. package/dist/react-native-core/index.d.ts +1 -0
  42. package/dist/react-native-core/index.d.ts.map +1 -1
  43. package/dist/react-native-core/index.js +64 -11
  44. package/dist/react-native-core/index.js.map +1 -1
  45. package/dist/react-native-core/platform.d.ts +1 -0
  46. package/dist/react-native-core/platform.d.ts.map +1 -1
  47. package/dist/react-native-core/provider.d.ts +1 -1
  48. package/dist/react-native-core/provider.d.ts.map +1 -1
  49. package/dist/svelte/Provider.svelte +3 -0
  50. package/dist/svelte/Provider.svelte.d.ts.map +1 -1
  51. package/dist/testing.js +1 -1
  52. package/dist/tools/coValues/schemaUnion.d.ts +9 -0
  53. package/dist/tools/coValues/schemaUnion.d.ts.map +1 -1
  54. package/dist/tools/exports.d.ts +1 -1
  55. package/dist/tools/exports.d.ts.map +1 -1
  56. package/dist/tools/implementation/createContext.d.ts +15 -3
  57. package/dist/tools/implementation/createContext.d.ts.map +1 -1
  58. package/dist/tools/implementation/sessionDurabilityMarker.d.ts +38 -0
  59. package/dist/tools/implementation/sessionDurabilityMarker.d.ts.map +1 -0
  60. package/dist/tools/implementation/zodSchema/schemaTypes/CoMapSchema.d.ts.map +1 -1
  61. package/dist/tools/implementation/zodSchema/unionUtils.d.ts.map +1 -1
  62. package/dist/tools/internal.d.ts +1 -0
  63. package/dist/tools/internal.d.ts.map +1 -1
  64. package/dist/tools/subscribe/SubscriptionScope.d.ts.map +1 -1
  65. package/dist/tools/tests/sessionDurabilityMarker.test.d.ts +2 -0
  66. package/dist/tools/tests/sessionDurabilityMarker.test.d.ts.map +1 -0
  67. package/dist/worker/index.d.ts +1 -0
  68. package/dist/worker/index.d.ts.map +1 -1
  69. package/dist/worker/index.js +6 -2
  70. package/dist/worker/index.js.map +1 -1
  71. package/package.json +4 -4
  72. package/src/better-auth/auth/client.ts +15 -1
  73. package/src/better-auth/auth/tests/client.test.ts +92 -0
  74. package/src/browser/BrowserContextManager.ts +5 -0
  75. package/src/browser/createBrowserContext.ts +8 -0
  76. package/src/browser/provideBrowserLockSession/BrowserSessionDurabilityMarker.ts +20 -0
  77. package/src/browser/provideBrowserLockSession/BrowserSessionProvider.test.ts +97 -1
  78. package/src/browser/provideBrowserLockSession/BrowserSessionProvider.ts +37 -8
  79. package/src/browser/provideBrowserLockSession/index.ts +1 -0
  80. package/src/expo/storage/expo-sqlite-adapter.ts +24 -7
  81. package/src/expo/tests/expo-sqlite-adapter.test.ts +94 -0
  82. package/src/react/provider.tsx +9 -1
  83. package/src/react-native-core/ReactNativeContextManager.ts +5 -0
  84. package/src/react-native-core/ReactNativeSessionDurabilityMarker.ts +40 -0
  85. package/src/react-native-core/ReactNativeSessionProvider.ts +24 -1
  86. package/src/react-native-core/index.ts +1 -0
  87. package/src/react-native-core/platform.ts +8 -0
  88. package/src/react-native-core/provider.tsx +9 -1
  89. package/src/react-native-core/tests/ReactNativeSessionProvider.test.ts +42 -0
  90. package/src/svelte/Provider.svelte +3 -0
  91. package/src/tools/coValues/schemaUnion.ts +13 -0
  92. package/src/tools/exports.ts +3 -0
  93. package/src/tools/implementation/createContext.ts +44 -0
  94. package/src/tools/implementation/sessionDurabilityMarker.ts +93 -0
  95. package/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts +24 -8
  96. package/src/tools/implementation/zodSchema/unionUtils.ts +2 -1
  97. package/src/tools/internal.ts +1 -0
  98. package/src/tools/subscribe/SubscriptionScope.ts +23 -1
  99. package/src/tools/tests/coMap.test.ts +30 -0
  100. package/src/tools/tests/createContext.test.ts +64 -0
  101. package/src/tools/tests/schemaUnion.test.ts +19 -0
  102. package/src/tools/tests/sessionDurabilityMarker.test.ts +183 -0
  103. package/src/worker/index.ts +6 -0
  104. 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 Error(
122
+ throw new SchemaUnionNoMatchingVariantError(
122
123
  "co.discriminatedUnion() of collaborative types with no matching discriminator value found",
123
124
  );
124
125
  };
@@ -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
- const instance = instantiateRefEncodedFromRaw(this.schema, value);
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
+ });