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.
- package/.turbo/turbo-build.log +44 -42
- package/CHANGELOG.md +19 -3
- package/dist/{chunk-2S3Z2CN6.js → chunk-FFEEPZEG.js} +367 -102
- package/dist/chunk-FFEEPZEG.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/react/hooks.d.ts +1 -1
- package/dist/react/hooks.d.ts.map +1 -1
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +5 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react-core/hooks.d.ts +59 -0
- package/dist/react-core/hooks.d.ts.map +1 -1
- package/dist/react-core/index.js +124 -36
- package/dist/react-core/index.js.map +1 -1
- package/dist/react-core/tests/testUtils.d.ts +1 -0
- package/dist/react-core/tests/testUtils.d.ts.map +1 -1
- package/dist/react-core/tests/useSuspenseAccount.test.d.ts +2 -0
- package/dist/react-core/tests/useSuspenseAccount.test.d.ts.map +1 -0
- package/dist/react-core/tests/useSuspenseCoState.test.d.ts +2 -0
- package/dist/react-core/tests/useSuspenseCoState.test.d.ts.map +1 -0
- package/dist/react-core/use.d.ts +3 -0
- package/dist/react-core/use.d.ts.map +1 -0
- package/dist/react-native/index.js +5 -1
- package/dist/react-native/index.js.map +1 -1
- package/dist/react-native-core/crypto/RNCrypto.d.ts +2 -0
- package/dist/react-native-core/crypto/RNCrypto.d.ts.map +1 -0
- package/dist/react-native-core/crypto/RNCrypto.js +3 -0
- package/dist/react-native-core/crypto/RNCrypto.js.map +1 -0
- package/dist/react-native-core/hooks.d.ts +1 -1
- package/dist/react-native-core/hooks.d.ts.map +1 -1
- package/dist/react-native-core/index.js +5 -1
- package/dist/react-native-core/index.js.map +1 -1
- package/dist/react-native-core/platform.d.ts +2 -1
- package/dist/react-native-core/platform.d.ts.map +1 -1
- package/dist/testing.js +1 -1
- package/dist/testing.js.map +1 -1
- package/dist/tools/coValues/interfaces.d.ts +1 -1
- package/dist/tools/coValues/interfaces.d.ts.map +1 -1
- package/dist/tools/implementation/ContextManager.d.ts +3 -0
- package/dist/tools/implementation/ContextManager.d.ts.map +1 -1
- package/dist/tools/subscribe/CoValueCoreSubscription.d.ts +8 -22
- package/dist/tools/subscribe/CoValueCoreSubscription.d.ts.map +1 -1
- package/dist/tools/subscribe/SubscriptionCache.d.ts +51 -0
- package/dist/tools/subscribe/SubscriptionCache.d.ts.map +1 -0
- package/dist/tools/subscribe/SubscriptionScope.d.ts +17 -1
- package/dist/tools/subscribe/SubscriptionScope.d.ts.map +1 -1
- package/dist/tools/subscribe/utils.d.ts +9 -1
- package/dist/tools/subscribe/utils.d.ts.map +1 -1
- package/dist/tools/testing.d.ts +2 -2
- package/dist/tools/testing.d.ts.map +1 -1
- package/dist/tools/tests/SubscriptionCache.test.d.ts +2 -0
- package/dist/tools/tests/SubscriptionCache.test.d.ts.map +1 -0
- package/package.json +13 -6
- package/src/react/hooks.tsx +2 -0
- package/src/react/index.ts +1 -14
- package/src/react-core/hooks.ts +167 -18
- package/src/react-core/tests/createCoValueSubscriptionContext.test.tsx +18 -8
- package/src/react-core/tests/testUtils.tsx +67 -5
- package/src/react-core/tests/useCoState.test.ts +3 -7
- package/src/react-core/tests/useSubscriptionSelector.test.ts +3 -7
- package/src/react-core/tests/useSuspenseAccount.test.tsx +343 -0
- package/src/react-core/tests/useSuspenseCoState.test.tsx +1182 -0
- package/src/react-core/use.ts +46 -0
- package/src/react-native-core/crypto/RNCrypto.ts +1 -0
- package/src/react-native-core/hooks.tsx +2 -0
- package/src/react-native-core/platform.ts +2 -1
- package/src/tools/coValues/interfaces.ts +2 -3
- package/src/tools/implementation/ContextManager.ts +13 -0
- package/src/tools/subscribe/CoValueCoreSubscription.ts +71 -100
- package/src/tools/subscribe/SubscriptionCache.ts +272 -0
- package/src/tools/subscribe/SubscriptionScope.ts +113 -7
- package/src/tools/subscribe/utils.ts +77 -0
- package/src/tools/testing.ts +0 -3
- package/src/tools/tests/CoValueCoreSubscription.test.ts +46 -12
- package/src/tools/tests/ContextManager.test.ts +85 -0
- package/src/tools/tests/SubscriptionCache.test.ts +237 -0
- package/src/tools/tests/coMap.test.ts +5 -7
- package/tsup.config.ts +1 -0
- 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";
|
|
@@ -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
|
|
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
|
|
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
|
|
71
|
-
if (source.
|
|
72
|
-
this.
|
|
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
|
|
77
|
+
// If we have a branch name, we handle branching
|
|
77
78
|
if (this.branchName) {
|
|
78
|
-
this.
|
|
79
|
+
this.handleBranching(this.branchName, this.branchOwnerId);
|
|
79
80
|
return;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
// If we don't have a branch
|
|
83
|
-
this.
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
//
|
|
97
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
150
|
-
this.subscribeToUnavailableSource();
|
|
151
|
-
this.emit(CoValueLoadingState.UNAVAILABLE);
|
|
117
|
+
this.subscribe(branch);
|
|
152
118
|
}
|
|
153
119
|
|
|
154
120
|
/**
|
|
155
|
-
* Loads
|
|
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
|
-
|
|
123
|
+
load(value: CoValueCore) {
|
|
159
124
|
this.localNode
|
|
160
|
-
.loadCoValueCore(
|
|
161
|
-
.then((
|
|
162
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|