jazz-tools 0.19.8 → 0.19.10

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 (80) hide show
  1. package/.turbo/turbo-build.log +44 -42
  2. package/CHANGELOG.md +19 -3
  3. package/dist/{chunk-2S3Z2CN6.js → chunk-FFEEPZEG.js} +367 -102
  4. package/dist/chunk-FFEEPZEG.js.map +1 -0
  5. package/dist/index.js +1 -1
  6. package/dist/react/hooks.d.ts +1 -1
  7. package/dist/react/hooks.d.ts.map +1 -1
  8. package/dist/react/index.d.ts +1 -1
  9. package/dist/react/index.d.ts.map +1 -1
  10. package/dist/react/index.js +5 -1
  11. package/dist/react/index.js.map +1 -1
  12. package/dist/react-core/hooks.d.ts +59 -0
  13. package/dist/react-core/hooks.d.ts.map +1 -1
  14. package/dist/react-core/index.js +124 -36
  15. package/dist/react-core/index.js.map +1 -1
  16. package/dist/react-core/tests/testUtils.d.ts +1 -0
  17. package/dist/react-core/tests/testUtils.d.ts.map +1 -1
  18. package/dist/react-core/tests/useSuspenseAccount.test.d.ts +2 -0
  19. package/dist/react-core/tests/useSuspenseAccount.test.d.ts.map +1 -0
  20. package/dist/react-core/tests/useSuspenseCoState.test.d.ts +2 -0
  21. package/dist/react-core/tests/useSuspenseCoState.test.d.ts.map +1 -0
  22. package/dist/react-core/use.d.ts +3 -0
  23. package/dist/react-core/use.d.ts.map +1 -0
  24. package/dist/react-native/index.js +5 -1
  25. package/dist/react-native/index.js.map +1 -1
  26. package/dist/react-native-core/crypto/RNCrypto.d.ts +2 -0
  27. package/dist/react-native-core/crypto/RNCrypto.d.ts.map +1 -0
  28. package/dist/react-native-core/crypto/RNCrypto.js +3 -0
  29. package/dist/react-native-core/crypto/RNCrypto.js.map +1 -0
  30. package/dist/react-native-core/hooks.d.ts +1 -1
  31. package/dist/react-native-core/hooks.d.ts.map +1 -1
  32. package/dist/react-native-core/index.js +5 -1
  33. package/dist/react-native-core/index.js.map +1 -1
  34. package/dist/react-native-core/platform.d.ts +2 -1
  35. package/dist/react-native-core/platform.d.ts.map +1 -1
  36. package/dist/testing.js +1 -1
  37. package/dist/testing.js.map +1 -1
  38. package/dist/tools/coValues/interfaces.d.ts +1 -1
  39. package/dist/tools/coValues/interfaces.d.ts.map +1 -1
  40. package/dist/tools/implementation/ContextManager.d.ts +3 -0
  41. package/dist/tools/implementation/ContextManager.d.ts.map +1 -1
  42. package/dist/tools/subscribe/CoValueCoreSubscription.d.ts +8 -22
  43. package/dist/tools/subscribe/CoValueCoreSubscription.d.ts.map +1 -1
  44. package/dist/tools/subscribe/SubscriptionCache.d.ts +51 -0
  45. package/dist/tools/subscribe/SubscriptionCache.d.ts.map +1 -0
  46. package/dist/tools/subscribe/SubscriptionScope.d.ts +17 -1
  47. package/dist/tools/subscribe/SubscriptionScope.d.ts.map +1 -1
  48. package/dist/tools/subscribe/utils.d.ts +9 -1
  49. package/dist/tools/subscribe/utils.d.ts.map +1 -1
  50. package/dist/tools/testing.d.ts +2 -2
  51. package/dist/tools/testing.d.ts.map +1 -1
  52. package/dist/tools/tests/SubscriptionCache.test.d.ts +2 -0
  53. package/dist/tools/tests/SubscriptionCache.test.d.ts.map +1 -0
  54. package/package.json +13 -6
  55. package/src/react/hooks.tsx +2 -0
  56. package/src/react/index.ts +1 -14
  57. package/src/react-core/hooks.ts +167 -18
  58. package/src/react-core/tests/createCoValueSubscriptionContext.test.tsx +18 -8
  59. package/src/react-core/tests/testUtils.tsx +67 -5
  60. package/src/react-core/tests/useCoState.test.ts +3 -7
  61. package/src/react-core/tests/useSubscriptionSelector.test.ts +3 -7
  62. package/src/react-core/tests/useSuspenseAccount.test.tsx +343 -0
  63. package/src/react-core/tests/useSuspenseCoState.test.tsx +1182 -0
  64. package/src/react-core/use.ts +46 -0
  65. package/src/react-native-core/crypto/RNCrypto.ts +1 -0
  66. package/src/react-native-core/hooks.tsx +2 -0
  67. package/src/react-native-core/platform.ts +2 -1
  68. package/src/tools/coValues/interfaces.ts +2 -3
  69. package/src/tools/implementation/ContextManager.ts +13 -0
  70. package/src/tools/subscribe/CoValueCoreSubscription.ts +71 -100
  71. package/src/tools/subscribe/SubscriptionCache.ts +272 -0
  72. package/src/tools/subscribe/SubscriptionScope.ts +113 -7
  73. package/src/tools/subscribe/utils.ts +77 -0
  74. package/src/tools/testing.ts +0 -3
  75. package/src/tools/tests/CoValueCoreSubscription.test.ts +46 -12
  76. package/src/tools/tests/ContextManager.test.ts +85 -0
  77. package/src/tools/tests/SubscriptionCache.test.ts +237 -0
  78. package/src/tools/tests/coMap.test.ts +5 -7
  79. package/tsup.config.ts +1 -0
  80. package/dist/chunk-2S3Z2CN6.js.map +0 -1
@@ -0,0 +1,46 @@
1
+ import React from "react";
2
+
3
+ // shim from https://github.com/pmndrs/jotai/blob/f287c5d665a807e676bc731e83174c62c1fe1fc9/src/react/useAtomValue.ts#L13C1-L56C1
4
+ const attachPromiseStatus = <T>(
5
+ promise: PromiseLike<T> & {
6
+ status?: "pending" | "fulfilled" | "rejected";
7
+ value?: T;
8
+ reason?: unknown;
9
+ },
10
+ ) => {
11
+ if (!promise.status) {
12
+ promise.status = "pending";
13
+ promise.then(
14
+ (v) => {
15
+ promise.status = "fulfilled";
16
+ promise.value = v;
17
+ },
18
+ (e) => {
19
+ promise.status = "rejected";
20
+ promise.reason = e;
21
+ },
22
+ );
23
+ }
24
+ };
25
+
26
+ export const use =
27
+ React.use ||
28
+ // A shim for older React versions
29
+ (<T>(
30
+ promise: PromiseLike<T> & {
31
+ status?: "pending" | "fulfilled" | "rejected";
32
+ value?: T;
33
+ reason?: unknown;
34
+ },
35
+ ): T => {
36
+ if (promise.status === "pending") {
37
+ throw promise;
38
+ } else if (promise.status === "fulfilled") {
39
+ return promise.value as T;
40
+ } else if (promise.status === "rejected") {
41
+ throw promise.reason;
42
+ } else {
43
+ attachPromiseStatus(promise);
44
+ throw promise;
45
+ }
46
+ });
@@ -0,0 +1 @@
1
+ export * from "cojson/crypto/RNCrypto";
@@ -19,6 +19,8 @@ export {
19
19
  useCoValueSubscription,
20
20
  useAccountSubscription,
21
21
  useSubscriptionSelector,
22
+ useSuspenseCoState,
23
+ useSuspenseAccount,
22
24
  } from "jazz-tools/react-core";
23
25
 
24
26
  export function useAcceptInviteNative<S extends CoValueClassOrSchema>({
@@ -24,12 +24,13 @@ import { KvStore, KvStoreContext } from "./storage/kv-store-context.js";
24
24
  import { SQLiteDatabaseDriverAsync } from "cojson";
25
25
  import { WebSocketPeerWithReconnection } from "cojson-transport-ws";
26
26
  import type { RNQuickCrypto } from "jazz-tools/react-native-core/crypto";
27
+ import type { RNCrypto } from "cojson/crypto/RNCrypto";
27
28
 
28
29
  export type BaseReactNativeContextOptions = {
29
30
  sync: SyncConfig;
30
31
  reconnectionTimeout?: number;
31
32
  storage?: SQLiteDatabaseDriverAsync | "disabled";
32
- CryptoProvider?: typeof PureJSCrypto | typeof RNQuickCrypto;
33
+ CryptoProvider?: typeof PureJSCrypto | typeof RNQuickCrypto | typeof RNCrypto;
33
34
  authSecretStorage: AuthSecretStorage;
34
35
  };
35
36
 
@@ -4,7 +4,7 @@ import {
4
4
  type CojsonInternalTypes,
5
5
  type RawCoValue,
6
6
  } from "cojson";
7
- import { AvailableCoValueCore } from "cojson/dist/coValueCore/coValueCore.js";
7
+ import { AvailableCoValueCore } from "cojson";
8
8
  import {
9
9
  Account,
10
10
  AnonymousJazzAgent,
@@ -23,7 +23,6 @@ import {
23
23
  ResolveQueryStrict,
24
24
  Resolved,
25
25
  SubscriptionScope,
26
- type SubscriptionValue,
27
26
  TypeSym,
28
27
  NotLoaded,
29
28
  activeAccountContext,
@@ -31,7 +30,7 @@ import {
31
30
  inspect,
32
31
  } from "../internal.js";
33
32
  import type { BranchDefinition } from "../subscribe/types.js";
34
- import { CoValueHeader } from "cojson/dist/coValueCore/verifiedState.js";
33
+ import { CoValueHeader } from "cojson";
35
34
 
36
35
  /** @category Abstract interfaces */
37
36
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -9,6 +9,7 @@ import { JazzContextType } from "../types.js";
9
9
  import { AnonymousJazzAgent } from "./anonymousJazzAgent.js";
10
10
  import { createAnonymousJazzContext } from "./createContext.js";
11
11
  import { InstanceOfSchema } from "./zodSchema/typeConverters/InstanceOfSchema.js";
12
+ import { SubscriptionCache } from "../subscribe/SubscriptionCache.js";
12
13
 
13
14
  export type JazzContextManagerAuthProps = {
14
15
  credentials?: AuthCredentials;
@@ -75,6 +76,7 @@ export class JazzContextManager<
75
76
  protected keepContextOpen = false;
76
77
  contextPromise: Promise<void> | undefined;
77
78
  protected authenticatingAccountID: string | null = null;
79
+ private subscriptionCache: SubscriptionCache;
78
80
 
79
81
  constructor(opts?: {
80
82
  useAnonymousFallback?: boolean;
@@ -82,6 +84,7 @@ export class JazzContextManager<
82
84
  }) {
83
85
  KvStoreContext.getInstance().initialize(this.getKvStore());
84
86
  this.authSecretStorage = new AuthSecretStorage(opts?.authSecretStorageKey);
87
+ this.subscriptionCache = new SubscriptionCache();
85
88
 
86
89
  if (opts?.useAnonymousFallback) {
87
90
  this.value = getAnonymousFallback();
@@ -130,6 +133,9 @@ export class JazzContextManager<
130
133
  context: PlatformSpecificContext<Acc>,
131
134
  authProps?: JazzContextManagerAuthProps,
132
135
  ) {
136
+ // Clear cache before updating context to prevent subscription leaks across authentication boundaries
137
+ this.subscriptionCache.clear();
138
+
133
139
  // When keepContextOpen we don't want to close the previous context
134
140
  // because we might need to handle the onAnonymousAccountDiscarded callback
135
141
  if (!this.keepContextOpen) {
@@ -178,6 +184,10 @@ export class JazzContextManager<
178
184
  return this.authenticatingAccountID;
179
185
  }
180
186
 
187
+ getSubscriptionScopeCache(): SubscriptionCache {
188
+ return this.subscriptionCache;
189
+ }
190
+
181
191
  logOut = async () => {
182
192
  if (!this.context || !this.props) {
183
193
  return;
@@ -185,6 +195,9 @@ export class JazzContextManager<
185
195
 
186
196
  this.authenticatingAccountID = null;
187
197
 
198
+ // Clear cache on logout to prevent subscription leaks across authentication boundaries
199
+ this.subscriptionCache.clear();
200
+
188
201
  await this.props.onLogOut?.();
189
202
 
190
203
  if (this.props.logOutReplacement) {
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  cojsonInternals,
3
3
  CoValueCore,
4
+ isRawCoID,
4
5
  LocalNode,
5
6
  RawCoID,
6
7
  RawCoValue,
@@ -67,121 +68,76 @@ export class CoValueCoreSubscription {
67
68
  private initializeSubscription(): void {
68
69
  const source = this.source;
69
70
 
70
- // If the CoValue is already available, handle it immediately
71
- if (source.isAvailable()) {
72
- this.handleAvailableSource();
71
+ // If the ID is not a valid raw CoID, we immediately emit an unavailable event
72
+ if (!isRawCoID(source.id)) {
73
+ this.emit(CoValueLoadingState.UNAVAILABLE);
73
74
  return;
74
75
  }
75
76
 
76
- // If a specific branch is requested while the source is not available, attempt to checkout that branch
77
+ // If we have a branch name, we handle branching
77
78
  if (this.branchName) {
78
- this.handleBranchCheckout();
79
+ this.handleBranching(this.branchName, this.branchOwnerId);
79
80
  return;
80
81
  }
81
82
 
82
- // If we don't have a branch requested, load the CoValue
83
- this.loadCoValue();
83
+ // If we don't have a branch name, we subscribe to the source directly
84
+ this.subscribe(this.source);
84
85
  }
85
86
 
86
- /**
87
- * Handles the case where the CoValue source is immediately available.
88
- * Either subscribes directly or attempts to get the requested branch.
89
- */
90
- private handleAvailableSource(): void {
91
- if (!this.branchName || !cojsonInternals.canBeBranched(this.source)) {
92
- this.subscribe(this.source.getCurrentContent());
87
+ private handleBranching(branchName: string, branchOwnerId?: RawCoID) {
88
+ const source = this.source;
89
+
90
+ // If the source is not available, we wait for it to become available and then try to branch
91
+ if (!source.isAvailable()) {
92
+ this.waitForSourceToBecomeAvailable(branchName, branchOwnerId);
93
93
  return;
94
94
  }
95
95
 
96
- // Try to get the specific branch from the available source
97
- const branch = this.source.getBranch(this.branchName, this.branchOwnerId);
98
-
99
- if (branch.isAvailable()) {
100
- // Branch is available, subscribe to it
101
- this.subscribe(branch.getCurrentContent());
96
+ // If the source is not branchable (e.g. it is a group), we subscribe to it directly
97
+ if (!cojsonInternals.canBeBranched(source)) {
98
+ this.subscribe(source);
102
99
  return;
103
- // If the branch hasn't been created, we create it directly so we can syncronously subscribe to it
104
- } else if (!this.source.hasBranch(this.branchName, this.branchOwnerId)) {
105
- this.source.createBranch(this.branchName, this.branchOwnerId);
106
- this.subscribe(branch.getCurrentContent());
107
- } else {
108
- // Branch not available, fall through to checkout logic
109
- this.handleBranchCheckout();
110
100
  }
111
- }
112
101
 
113
- /**
114
- * Attempts to checkout a specific branch of the CoValue.
115
- * This is called when the source isn't available but a branch is requested.
116
- */
117
- private handleBranchCheckout(): void {
118
- this.localNode
119
- .checkoutBranch(this.source.id, this.branchName!, this.branchOwnerId)
120
- .then((value) => {
121
- if (this.unsubscribed) return;
122
-
123
- if (value !== CoValueLoadingState.UNAVAILABLE) {
124
- // Branch checkout successful, subscribe to it
125
- this.subscribe(value);
126
- } else {
127
- // Branch checkout failed, handle the error
128
- this.handleUnavailableBranch();
129
- }
130
- })
131
- .catch((error) => {
132
- // Handle unexpected errors during branch checkout
133
- console.error(error);
102
+ // Try to get the specific branch from the available source
103
+ const branch = source.getBranch(branchName, branchOwnerId);
104
+
105
+ // If the branch hasn't been created, we create it directly so we can syncronously subscribe to it
106
+ if (!branch.isAvailable() && !source.hasBranch(branchName, branchOwnerId)) {
107
+ try {
108
+ source.createBranch(branchName, branchOwnerId);
109
+ } catch (error) {
110
+ // If the branch creation fails (provided group is not available), we emit an unavailable event
111
+ console.error("error creating branch", error);
134
112
  this.emit(CoValueLoadingState.UNAVAILABLE);
135
- });
136
- }
137
-
138
- /**
139
- * Handles the case where a branch checkout fails.
140
- * Determines whether to retry or report unavailability.
141
- */
142
- private handleUnavailableBranch(): void {
143
- const source = this.source;
144
- if (source.isAvailable()) {
145
- // This should be impossible - if source is available we can create the branch and it should be available
146
- throw new Error("Branch is unavailable");
113
+ return;
114
+ }
147
115
  }
148
116
 
149
- // Source isn't available either, subscribe to state changes and report unavailability
150
- this.subscribeToUnavailableSource();
151
- this.emit(CoValueLoadingState.UNAVAILABLE);
117
+ this.subscribe(branch);
152
118
  }
153
119
 
154
120
  /**
155
- * Loads the CoValue core from the network/storage.
156
- * This is the fallback strategy when immediate availability fails.
121
+ * Loads a CoValue core and emits an unavailable event if it is still unavailable after the retries.
157
122
  */
158
- private loadCoValue(): void {
123
+ load(value: CoValueCore) {
159
124
  this.localNode
160
- .loadCoValueCore(this.source.id, undefined, this.skipRetry)
161
- .then((value) => {
162
- if (this.unsubscribed) return;
163
-
164
- if (value.isAvailable()) {
165
- // Loading successful, subscribe to the loaded value
166
- this.subscribe(value.getCurrentContent());
167
- } else {
168
- // Loading failed, subscribe to state changes and report unavailability
169
- this.subscribeToUnavailableSource();
125
+ .loadCoValueCore(value.id, undefined, this.skipRetry)
126
+ .then(() => {
127
+ // If after the retries the value is still unavailable, we emit an unavailable event
128
+ if (!value.isAvailable()) {
170
129
  this.emit(CoValueLoadingState.UNAVAILABLE);
171
130
  }
172
- })
173
- .catch((error) => {
174
- // Handle unexpected errors during loading
175
- console.error(error);
176
- this.emit(CoValueLoadingState.UNAVAILABLE);
177
131
  });
178
132
  }
179
133
 
180
134
  /**
181
- * Subscribes to state changes of an unavailable CoValue source.
182
- * This allows the subscription to become active when the source becomes available after a first loading attempt.
135
+ * Waits for the source to become available and then tries to branch.
183
136
  */
184
- private subscribeToUnavailableSource(): void {
137
+ private waitForSourceToBecomeAvailable(
138
+ branchName: string,
139
+ branchOwnerId?: RawCoID,
140
+ ): void {
185
141
  const source = this.source;
186
142
 
187
143
  const handleStateChange = (
@@ -197,41 +153,60 @@ export class CoValueCoreSubscription {
197
153
 
198
154
  unsubFromStateChange();
199
155
 
200
- if (this.branchName) {
201
- // Branch was requested, attempt checkout again
202
- this.handleBranchCheckout();
203
- } else {
204
- // No branch requested, subscribe directly and cleanup state subscription
205
- this.subscribe(source.getCurrentContent());
206
- }
156
+ this.handleBranching(branchName, branchOwnerId);
207
157
  };
208
158
 
209
159
  // Subscribe to state changes and store the unsubscribe function
210
160
  this._unsubscribe = source.subscribe(handleStateChange);
161
+
162
+ this.load(source);
211
163
  }
212
164
 
213
165
  /**
214
166
  * Subscribes to a specific CoValue and notifies the listener.
215
167
  * This is the final step where we actually start receiving updates.
216
168
  */
217
- private subscribe(value: RawCoValue): void {
169
+ private subscribe(value: CoValueCore): void {
218
170
  if (this.unsubscribed) return;
219
171
 
220
172
  // Subscribe to the value and store the unsubscribe function
221
173
  this._unsubscribe = value.subscribe((value) => {
222
- this.emit(value);
174
+ if (value.isAvailable()) {
175
+ this.emit(value.getCurrentContent());
176
+ }
223
177
  });
178
+
179
+ if (!value.isAvailable()) {
180
+ this.load(value);
181
+ }
224
182
  }
225
183
 
184
+ lastState: CoValueLoadingState | undefined;
185
+
226
186
  emit(value: RawCoValue | typeof CoValueLoadingState.UNAVAILABLE): void {
227
187
  if (this.unsubscribed) return;
228
- if (!isReadyForEmit(value)) {
188
+ if (!this.isReadyForEmit(value)) {
229
189
  return;
230
190
  }
231
191
 
232
192
  this.listener(value);
233
193
  }
234
194
 
195
+ isReadyForEmit(
196
+ value: RawCoValue | typeof CoValueLoadingState.UNAVAILABLE,
197
+ ): boolean {
198
+ if (value === CoValueLoadingState.UNAVAILABLE) {
199
+ return true;
200
+ }
201
+
202
+ // If the value is not completely downloaded, we don't emit it to avoid providing partial data to the listener.
203
+ if (!isCompletelyDownloaded(value)) {
204
+ return false;
205
+ }
206
+
207
+ return true;
208
+ }
209
+
235
210
  /**
236
211
  * Unsubscribes from all active subscriptions and marks the instance as unsubscribed.
237
212
  * This prevents any further operations and ensures proper cleanup.
@@ -246,11 +221,7 @@ export class CoValueCoreSubscription {
246
221
  /**
247
222
  * This is true if the value is unavailable, or if the value is a binary coValue or a completely downloaded coValue.
248
223
  */
249
- function isReadyForEmit(value: RawCoValue | "unavailable") {
250
- if (value === "unavailable") {
251
- return true;
252
- }
253
-
224
+ function isCompletelyDownloaded(value: RawCoValue) {
254
225
  return (
255
226
  value.core.verified?.header.meta?.type === "binary" ||
256
227
  value.core.isCompletelyDownloaded()
@@ -0,0 +1,272 @@
1
+ import { LocalNode } from "cojson";
2
+ import type {
3
+ CoValue,
4
+ CoValueClassOrSchema,
5
+ RefEncoded,
6
+ RefsToResolve,
7
+ ResolveQuery,
8
+ } from "../internal.js";
9
+ import { coValueClassFromCoValueClassOrSchema } from "../internal.js";
10
+ import { SubscriptionScope } from "./SubscriptionScope.js";
11
+ import type { BranchDefinition } from "./types.js";
12
+ import { isEqualRefsToResolve } from "./utils.js";
13
+
14
+ interface CacheEntry {
15
+ subscriptionScope: SubscriptionScope<any>;
16
+ schema: CoValueClassOrSchema;
17
+ resolve: RefsToResolve<any>;
18
+ branch?: BranchDefinition;
19
+ subscriberCount: number;
20
+ cleanupTimeoutId?: ReturnType<typeof setTimeout>;
21
+ unsubscribeFromScope: () => void;
22
+ }
23
+
24
+ export class SubscriptionCache {
25
+ // Nested cache: outer map keyed by id, inner set of CacheEntry
26
+ private cache: Map<string, Set<CacheEntry>>;
27
+ private cleanupTimeout: number;
28
+
29
+ constructor(cleanupTimeout: number = 5000) {
30
+ this.cache = new Map();
31
+ this.cleanupTimeout = cleanupTimeout;
32
+ }
33
+
34
+ /**
35
+ * Get the inner set for a given id (read-only access)
36
+ */
37
+ private getIdSet(id: string): Set<CacheEntry> | undefined {
38
+ return this.cache.get(id);
39
+ }
40
+
41
+ /**
42
+ * Get the inner set for a given id, creating it if it doesn't exist
43
+ */
44
+ private getIdSetOrCreate(id: string): Set<CacheEntry> {
45
+ let idSet = this.cache.get(id);
46
+ if (!idSet) {
47
+ idSet = new Set();
48
+ this.cache.set(id, idSet);
49
+ }
50
+ return idSet;
51
+ }
52
+
53
+ /**
54
+ * Check if an entry matches the provided parameters
55
+ */
56
+ private matchesEntry(
57
+ entry: CacheEntry,
58
+ schema: CoValueClassOrSchema,
59
+ resolve: RefsToResolve<any>,
60
+ branch?: BranchDefinition,
61
+ ): boolean {
62
+ // Compare schema by object identity
63
+ if (entry.schema !== schema) {
64
+ return false;
65
+ }
66
+
67
+ // Compare resolve queries using isEqualRefsToResolve
68
+ if (!isEqualRefsToResolve(entry.resolve, resolve)) {
69
+ return false;
70
+ }
71
+
72
+ // Compare branch names by string equality
73
+ const branchName = branch?.name;
74
+ if (entry.branch?.name !== branchName) {
75
+ return false;
76
+ }
77
+
78
+ // Compare branch owner ids by string equality
79
+ const branchOwnerId = branch?.owner?.$jazz.id;
80
+ if (entry.branch?.owner?.$jazz.id !== branchOwnerId) {
81
+ return false;
82
+ }
83
+
84
+ return true;
85
+ }
86
+
87
+ /**
88
+ * Find a matching cache entry by comparing against entry properties
89
+ * Uses id-based nesting to quickly filter candidates
90
+ */
91
+ private findMatchingEntry(
92
+ schema: CoValueClassOrSchema,
93
+ id: string,
94
+ resolve: RefsToResolve<any>,
95
+ branch?: BranchDefinition,
96
+ ): CacheEntry | undefined {
97
+ // Get the inner set for this id (quick filter)
98
+ const idSet = this.getIdSet(id);
99
+ if (!idSet) {
100
+ return undefined;
101
+ }
102
+
103
+ // Search only within entries for this id
104
+ for (const entry of idSet) {
105
+ if (this.matchesEntry(entry, schema, resolve, branch)) {
106
+ return entry;
107
+ }
108
+ }
109
+
110
+ return undefined;
111
+ }
112
+
113
+ /**
114
+ * Handle subscriber count changes from SubscriptionScope
115
+ */
116
+ private handleSubscriberChange(entry: CacheEntry, count: number): void {
117
+ entry.subscriberCount = count;
118
+
119
+ if (count === 0) {
120
+ // Schedule cleanup when subscriber count reaches zero
121
+ this.scheduleCleanup(entry);
122
+ } else {
123
+ // Cancel cleanup if count increases from zero
124
+ this.cancelCleanup(entry);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Schedule cleanup timeout for an entry
130
+ */
131
+ private scheduleCleanup(entry: CacheEntry): void {
132
+ // Cancel any existing cleanup timeout
133
+ this.cancelCleanup(entry);
134
+
135
+ entry.cleanupTimeoutId = setTimeout(() => {
136
+ this.destroyEntry(entry);
137
+ }, this.cleanupTimeout);
138
+ }
139
+
140
+ /**
141
+ * Cancel pending cleanup timeout for an entry
142
+ */
143
+ private cancelCleanup(entry: CacheEntry): void {
144
+ if (entry.cleanupTimeoutId !== undefined) {
145
+ clearTimeout(entry.cleanupTimeoutId);
146
+ entry.cleanupTimeoutId = undefined;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Destroy a cache entry and its SubscriptionScope
152
+ */
153
+ private destroyEntry(entry: CacheEntry): void {
154
+ // Cancel any pending cleanup
155
+ this.cancelCleanup(entry);
156
+
157
+ // Unsubscribe from subscriber changes
158
+ entry.unsubscribeFromScope();
159
+
160
+ // Destroy the SubscriptionScope
161
+ try {
162
+ entry.subscriptionScope.destroy();
163
+ } catch (error) {
164
+ // Log error but don't throw - we still want to remove the entry
165
+ console.error("Error destroying SubscriptionScope:", error);
166
+ }
167
+
168
+ // Remove from nested cache structure
169
+ const id = entry.subscriptionScope.id;
170
+ const idSet = this.getIdSet(id);
171
+ if (idSet) {
172
+ idSet.delete(entry);
173
+ // Clean up empty inner set to prevent memory leaks
174
+ if (idSet.size === 0) {
175
+ this.cache.delete(id);
176
+ }
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Get or create a SubscriptionScope from the cache
182
+ */
183
+ getOrCreate<S extends CoValueClassOrSchema>(
184
+ node: LocalNode,
185
+ schema: S,
186
+ id: string,
187
+ resolve: ResolveQuery<S>,
188
+ skipRetry?: boolean,
189
+ bestEffortResolution?: boolean,
190
+ branch?: BranchDefinition,
191
+ ): SubscriptionScope<CoValue> {
192
+ // Handle undefined/null id case
193
+ if (!id) {
194
+ throw new Error("Cannot create subscription with undefined or null id");
195
+ }
196
+
197
+ // Search for matching entry
198
+ const matchingEntry = this.findMatchingEntry(schema, id, resolve, branch);
199
+
200
+ if (matchingEntry) {
201
+ // Found existing entry - cancel any pending cleanup since we're reusing it
202
+ this.cancelCleanup(matchingEntry);
203
+
204
+ return matchingEntry.subscriptionScope as SubscriptionScope<CoValue>;
205
+ }
206
+
207
+ // Create new SubscriptionScope
208
+ // Transform schema to RefEncoded format
209
+ const refEncoded: RefEncoded<CoValue> = {
210
+ ref: coValueClassFromCoValueClassOrSchema(schema) as any,
211
+ optional: true,
212
+ };
213
+
214
+ // Create new SubscriptionScope with all required parameters
215
+ const subscriptionScope = new SubscriptionScope<CoValue>(
216
+ node,
217
+ // @ts-expect-error the SubscriptionScope is too generic for TS to infer its instances are CoValues
218
+ resolve,
219
+ id,
220
+ refEncoded,
221
+ skipRetry ?? false,
222
+ bestEffortResolution ?? false,
223
+ branch,
224
+ );
225
+
226
+ const handleSubscriberChange = (count: number) => {
227
+ const idSet = this.getIdSet(id);
228
+ if (idSet && idSet.has(entry)) {
229
+ this.handleSubscriberChange(entry, count);
230
+ }
231
+ };
232
+
233
+ // Create cache entry with initial subscriber count (starts at 0)
234
+ const entry: CacheEntry = {
235
+ subscriptionScope,
236
+ schema,
237
+ resolve,
238
+ branch,
239
+ subscriberCount: subscriptionScope.subscribers.size,
240
+ unsubscribeFromScope: subscriptionScope.onSubscriberChange(
241
+ handleSubscriberChange,
242
+ ),
243
+ };
244
+
245
+ // Store in nested cache structure
246
+ const idSet = this.getIdSetOrCreate(id);
247
+ idSet.add(entry);
248
+
249
+ return subscriptionScope;
250
+ }
251
+
252
+ /**
253
+ * Clear all cache entries and destroy all SubscriptionScope instances
254
+ */
255
+ clear(): void {
256
+ // Collect all entries first to avoid iteration issues during deletion
257
+ const entriesToDestroy: CacheEntry[] = [];
258
+ for (const idSet of this.cache.values()) {
259
+ for (const entry of idSet) {
260
+ entriesToDestroy.push(entry);
261
+ }
262
+ }
263
+
264
+ // Destroy all entries
265
+ for (const entry of entriesToDestroy) {
266
+ this.destroyEntry(entry);
267
+ }
268
+
269
+ // Clear the cache map (should already be empty, but ensure it)
270
+ this.cache.clear();
271
+ }
272
+ }