jazz-tools 0.19.8 → 0.19.11
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 +56 -50
- package/CHANGELOG.md +30 -3
- package/dist/{chunk-2S3Z2CN6.js → chunk-HX5S6W5E.js} +372 -103
- package/dist/chunk-HX5S6W5E.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/inspector/account-switcher.d.ts +4 -0
- package/dist/inspector/account-switcher.d.ts.map +1 -0
- package/dist/inspector/chunk-C6BJPHBQ.js +4096 -0
- package/dist/inspector/chunk-C6BJPHBQ.js.map +1 -0
- package/dist/inspector/contexts/node.d.ts +19 -0
- package/dist/inspector/contexts/node.d.ts.map +1 -0
- package/dist/inspector/{custom-element-P76EIWEV.js → custom-element-GJVBPZES.js} +1011 -884
- package/dist/inspector/custom-element-GJVBPZES.js.map +1 -0
- package/dist/inspector/{viewer/new-app.d.ts → in-app.d.ts} +3 -3
- package/dist/inspector/in-app.d.ts.map +1 -0
- package/dist/inspector/index.d.ts +0 -11
- package/dist/inspector/index.d.ts.map +1 -1
- package/dist/inspector/index.js +56 -3910
- package/dist/inspector/index.js.map +1 -1
- package/dist/inspector/pages/home.d.ts +2 -0
- package/dist/inspector/pages/home.d.ts.map +1 -0
- package/dist/inspector/register-custom-element.js +1 -1
- package/dist/inspector/router/context.d.ts +12 -0
- package/dist/inspector/router/context.d.ts.map +1 -0
- package/dist/inspector/router/hash-router.d.ts +7 -0
- package/dist/inspector/router/hash-router.d.ts.map +1 -0
- package/dist/inspector/router/in-memory-router.d.ts +7 -0
- package/dist/inspector/router/in-memory-router.d.ts.map +1 -0
- package/dist/inspector/router/index.d.ts +5 -0
- package/dist/inspector/router/index.d.ts.map +1 -0
- package/dist/inspector/standalone.d.ts +6 -0
- package/dist/inspector/standalone.d.ts.map +1 -0
- package/dist/inspector/standalone.js +420 -0
- package/dist/inspector/standalone.js.map +1 -0
- package/dist/inspector/tests/router/hash-router.test.d.ts +2 -0
- package/dist/inspector/tests/router/hash-router.test.d.ts.map +1 -0
- package/dist/inspector/tests/router/in-memory-router.test.d.ts +2 -0
- package/dist/inspector/tests/router/in-memory-router.test.d.ts.map +1 -0
- package/dist/inspector/ui/modal.d.ts +1 -0
- package/dist/inspector/ui/modal.d.ts.map +1 -1
- package/dist/inspector/viewer/breadcrumbs.d.ts +1 -7
- package/dist/inspector/viewer/breadcrumbs.d.ts.map +1 -1
- package/dist/inspector/viewer/header.d.ts +7 -0
- package/dist/inspector/viewer/header.d.ts.map +1 -0
- package/dist/inspector/viewer/page-stack.d.ts +4 -13
- package/dist/inspector/viewer/page-stack.d.ts.map +1 -1
- package/dist/inspector/viewer/page.d.ts.map +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/account.d.ts +7 -1
- package/dist/tools/coValues/account.d.ts.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/implementation/zodSchema/schemaTypes/AccountSchema.d.ts +8 -1
- package/dist/tools/implementation/zodSchema/schemaTypes/AccountSchema.d.ts.map +1 -1
- package/dist/tools/implementation/zodSchema/zodCo.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 +18 -6
- package/src/inspector/account-switcher.tsx +440 -0
- package/src/inspector/contexts/node.tsx +129 -0
- package/src/inspector/custom-element.tsx +2 -2
- package/src/inspector/in-app.tsx +61 -0
- package/src/inspector/index.tsx +2 -22
- package/src/inspector/pages/home.tsx +77 -0
- package/src/inspector/router/context.ts +21 -0
- package/src/inspector/router/hash-router.tsx +128 -0
- package/src/inspector/{viewer/use-page-path.ts → router/in-memory-router.tsx} +31 -29
- package/src/inspector/router/index.ts +4 -0
- package/src/inspector/standalone.tsx +60 -0
- package/src/inspector/tests/router/hash-router.test.tsx +847 -0
- package/src/inspector/tests/router/in-memory-router.test.tsx +724 -0
- package/src/inspector/ui/modal.tsx +5 -2
- package/src/inspector/viewer/breadcrumbs.tsx +5 -11
- package/src/inspector/viewer/header.tsx +67 -0
- package/src/inspector/viewer/page-stack.tsx +18 -26
- package/src/inspector/viewer/page.tsx +0 -1
- 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/account.ts +13 -2
- package/src/tools/coValues/interfaces.ts +2 -3
- package/src/tools/implementation/ContextManager.ts +13 -0
- package/src/tools/implementation/zodSchema/schemaTypes/AccountSchema.ts +8 -1
- 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/account.test.ts +11 -4
- package/src/tools/tests/coMap.test.ts +5 -7
- package/src/tools/tests/schema.resolved.test.ts +3 -3
- package/tsup.config.ts +2 -0
- package/dist/chunk-2S3Z2CN6.js.map +0 -1
- package/dist/inspector/custom-element-P76EIWEV.js.map +0 -1
- package/dist/inspector/viewer/new-app.d.ts.map +0 -1
- package/dist/inspector/viewer/use-page-path.d.ts +0 -10
- package/dist/inspector/viewer/use-page-path.d.ts.map +0 -1
- package/src/inspector/viewer/new-app.tsx +0 -156
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { LocalNode, RawCoValue } from "cojson";
|
|
2
2
|
import {
|
|
3
3
|
CoFeed,
|
|
4
4
|
CoList,
|
|
@@ -23,11 +23,18 @@ import type {
|
|
|
23
23
|
SubscriptionValueLoading,
|
|
24
24
|
} from "./types.js";
|
|
25
25
|
import { CoValueLoadingState, NotLoadedCoValueState } from "./types.js";
|
|
26
|
-
import { createCoValue, myRoleForRawValue } from "./utils.js";
|
|
27
26
|
import {
|
|
28
27
|
captureError,
|
|
29
28
|
isCustomErrorReportingEnabled,
|
|
30
29
|
} from "./errorReporting.js";
|
|
30
|
+
import {
|
|
31
|
+
createCoValue,
|
|
32
|
+
isEqualRefsToResolve,
|
|
33
|
+
myRoleForRawValue,
|
|
34
|
+
PromiseWithStatus,
|
|
35
|
+
rejectedPromise,
|
|
36
|
+
resolvedPromise,
|
|
37
|
+
} from "./utils.js";
|
|
31
38
|
|
|
32
39
|
export class SubscriptionScope<D extends CoValue> {
|
|
33
40
|
childNodes = new Map<string, SubscriptionScope<CoValue>>();
|
|
@@ -88,6 +95,7 @@ export class SubscriptionScope<D extends CoValue> {
|
|
|
88
95
|
| RawCoValue
|
|
89
96
|
| typeof CoValueLoadingState.UNAVAILABLE
|
|
90
97
|
| undefined;
|
|
98
|
+
|
|
91
99
|
this.subscription = new CoValueCoreSubscription(
|
|
92
100
|
node,
|
|
93
101
|
id,
|
|
@@ -301,6 +309,66 @@ export class SubscriptionScope<D extends CoValue> {
|
|
|
301
309
|
|
|
302
310
|
unloadedValue: NotLoaded<D> | undefined;
|
|
303
311
|
|
|
312
|
+
lastPromise:
|
|
313
|
+
| {
|
|
314
|
+
value: MaybeLoaded<D> | undefined;
|
|
315
|
+
promise: PromiseWithStatus<MaybeLoaded<D>>;
|
|
316
|
+
}
|
|
317
|
+
| undefined;
|
|
318
|
+
|
|
319
|
+
cachePromise(value: MaybeLoaded<D>, callback: () => PromiseWithStatus<D>) {
|
|
320
|
+
if (this.lastPromise?.value === value) {
|
|
321
|
+
return this.lastPromise.promise;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const promise = callback();
|
|
325
|
+
this.lastPromise = { value, promise };
|
|
326
|
+
|
|
327
|
+
return promise;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
getPromise() {
|
|
331
|
+
const currentValue = this.getCurrentValue();
|
|
332
|
+
|
|
333
|
+
if (currentValue.$isLoaded) {
|
|
334
|
+
return resolvedPromise(currentValue);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (currentValue.$jazz.loadingState !== CoValueLoadingState.LOADING) {
|
|
338
|
+
const error = this.getError();
|
|
339
|
+
return rejectedPromise(
|
|
340
|
+
new Error(error?.toString() ?? "Unknown error", {
|
|
341
|
+
cause: this.callerStack,
|
|
342
|
+
}),
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// We need to cache rejected promises to make React Suspense happy
|
|
347
|
+
return this.cachePromise(currentValue, () => {
|
|
348
|
+
return new Promise<D>((resolve, reject) => {
|
|
349
|
+
const unsubscribe = this.subscribe(() => {
|
|
350
|
+
const currentValue = this.getCurrentValue();
|
|
351
|
+
|
|
352
|
+
if (currentValue.$jazz.loadingState === CoValueLoadingState.LOADING) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (currentValue.$isLoaded) {
|
|
357
|
+
resolve(currentValue);
|
|
358
|
+
} else {
|
|
359
|
+
reject(
|
|
360
|
+
new Error(this.getError()?.toString() ?? "Unknown error", {
|
|
361
|
+
cause: this.callerStack,
|
|
362
|
+
}),
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
unsubscribe();
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
304
372
|
private getUnloadedValue(reason: NotLoadedCoValueState): NotLoaded<D> {
|
|
305
373
|
if (this.unloadedValue?.$jazz.loadingState === reason) {
|
|
306
374
|
return this.unloadedValue;
|
|
@@ -384,19 +452,21 @@ export class SubscriptionScope<D extends CoValue> {
|
|
|
384
452
|
return result;
|
|
385
453
|
}
|
|
386
454
|
|
|
387
|
-
|
|
388
|
-
let error: JazzError | undefined;
|
|
389
|
-
|
|
455
|
+
getError() {
|
|
390
456
|
if (
|
|
391
457
|
this.value.type === CoValueLoadingState.UNAUTHORIZED ||
|
|
392
458
|
this.value.type === CoValueLoadingState.UNAVAILABLE
|
|
393
459
|
) {
|
|
394
|
-
|
|
460
|
+
return this.value;
|
|
395
461
|
}
|
|
396
462
|
|
|
397
463
|
if (this.errorFromChildren) {
|
|
398
|
-
|
|
464
|
+
return this.errorFromChildren;
|
|
399
465
|
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
logError() {
|
|
469
|
+
const error = this.getError();
|
|
400
470
|
|
|
401
471
|
if (!error || this.lastErrorLogged === error) {
|
|
402
472
|
return;
|
|
@@ -437,16 +507,45 @@ export class SubscriptionScope<D extends CoValue> {
|
|
|
437
507
|
}
|
|
438
508
|
|
|
439
509
|
subscribers = new Set<(value: SubscriptionValue<D, any>) => void>();
|
|
510
|
+
subscriberChangeCallbacks = new Set<(count: number) => void>();
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Subscribe to subscriber count changes
|
|
514
|
+
* Callback receives the total number of subscribers
|
|
515
|
+
* Returns an unsubscribe function
|
|
516
|
+
*/
|
|
517
|
+
onSubscriberChange(callback: (count: number) => void): () => void {
|
|
518
|
+
this.subscriberChangeCallbacks.add(callback);
|
|
519
|
+
|
|
520
|
+
return () => {
|
|
521
|
+
this.subscriberChangeCallbacks.delete(callback);
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private notifySubscriberChange() {
|
|
526
|
+
const count = this.subscribers.size;
|
|
527
|
+
this.subscriberChangeCallbacks.forEach((callback) => {
|
|
528
|
+
callback(count);
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
440
532
|
subscribe(listener: (value: SubscriptionValue<D, any>) => void) {
|
|
441
533
|
this.subscribers.add(listener);
|
|
534
|
+
this.notifySubscriberChange();
|
|
442
535
|
|
|
443
536
|
return () => {
|
|
444
537
|
this.subscribers.delete(listener);
|
|
538
|
+
this.notifySubscriberChange();
|
|
445
539
|
};
|
|
446
540
|
}
|
|
447
541
|
|
|
448
542
|
setListener(listener: (value: SubscriptionValue<D, any>) => void) {
|
|
543
|
+
const hadListener = this.subscribers.has(listener);
|
|
449
544
|
this.subscribers.add(listener);
|
|
545
|
+
// Only notify if this is a new listener (count actually changed)
|
|
546
|
+
if (!hadListener) {
|
|
547
|
+
this.notifySubscriberChange();
|
|
548
|
+
}
|
|
450
549
|
this.triggerUpdate();
|
|
451
550
|
}
|
|
452
551
|
|
|
@@ -835,7 +934,14 @@ export class SubscriptionScope<D extends CoValue> {
|
|
|
835
934
|
this.closed = true;
|
|
836
935
|
|
|
837
936
|
this.subscription.unsubscribe();
|
|
937
|
+
const hadSubscribers = this.subscribers.size > 0;
|
|
838
938
|
this.subscribers.clear();
|
|
939
|
+
// Notify callbacks that subscriber count is now 0 if there were subscribers before
|
|
940
|
+
if (hadSubscribers) {
|
|
941
|
+
this.notifySubscriberChange();
|
|
942
|
+
}
|
|
943
|
+
// Clear subscriber change callbacks to prevent memory leaks
|
|
944
|
+
this.subscriberChangeCallbacks.clear();
|
|
839
945
|
this.childNodes.forEach((child) => child.destroy());
|
|
840
946
|
}
|
|
841
947
|
}
|
|
@@ -3,6 +3,7 @@ import { RegisteredSchemas } from "../coValues/registeredSchemas.js";
|
|
|
3
3
|
import {
|
|
4
4
|
CoValue,
|
|
5
5
|
RefEncoded,
|
|
6
|
+
RefsToResolve,
|
|
6
7
|
accountOrGroupToGroup,
|
|
7
8
|
instantiateRefEncodedFromRaw,
|
|
8
9
|
} from "../internal.js";
|
|
@@ -42,3 +43,79 @@ export function createCoValue<D extends CoValue>(
|
|
|
42
43
|
id: subscriptionScope.id,
|
|
43
44
|
};
|
|
44
45
|
}
|
|
46
|
+
|
|
47
|
+
export type PromiseWithStatus<T> = PromiseLike<T> & {
|
|
48
|
+
status?: "pending" | "fulfilled" | "rejected";
|
|
49
|
+
value?: T;
|
|
50
|
+
reason?: unknown;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function resolvedPromise<T>(value: T): PromiseWithStatus<T> {
|
|
54
|
+
const promise = Promise.resolve(value) as PromiseWithStatus<T>;
|
|
55
|
+
promise.status = "fulfilled";
|
|
56
|
+
promise.value = value;
|
|
57
|
+
return promise;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function rejectedPromise<T>(reason: unknown): PromiseWithStatus<T> {
|
|
61
|
+
const promise = Promise.reject(reason) as PromiseWithStatus<T>;
|
|
62
|
+
promise.status = "rejected";
|
|
63
|
+
promise.reason = reason;
|
|
64
|
+
return promise;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isEqualRefsToResolve(
|
|
68
|
+
a: RefsToResolve<any>,
|
|
69
|
+
b: RefsToResolve<any>,
|
|
70
|
+
) {
|
|
71
|
+
// Fast path: same reference
|
|
72
|
+
if (a === b) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Fast path: both are boolean
|
|
77
|
+
if (typeof a === "boolean" && typeof b === "boolean") {
|
|
78
|
+
return a === b;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// One is boolean, the other is not
|
|
82
|
+
if (typeof a === "boolean" || typeof b === "boolean") {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Both must be objects at this point
|
|
87
|
+
if (
|
|
88
|
+
typeof a !== "object" ||
|
|
89
|
+
typeof b !== "object" ||
|
|
90
|
+
a === null ||
|
|
91
|
+
b === null
|
|
92
|
+
) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Get all keys from both objects
|
|
97
|
+
const keysA = Object.keys(a);
|
|
98
|
+
const keysB = Object.keys(b);
|
|
99
|
+
|
|
100
|
+
// Different number of keys means not equal
|
|
101
|
+
if (keysA.length !== keysB.length) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Check each key
|
|
106
|
+
for (const key of keysA) {
|
|
107
|
+
if (!(key in b)) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const valueA = (a as any)[key];
|
|
112
|
+
const valueB = (b as any)[key];
|
|
113
|
+
|
|
114
|
+
// Recursively compare nested RefsToResolve values
|
|
115
|
+
if (!isEqualRefsToResolve(valueA, valueB)) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return true;
|
|
121
|
+
}
|
package/src/tools/testing.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
setupJazzTestSync,
|
|
15
15
|
} from "../testing.js";
|
|
16
16
|
import { waitFor } from "../tests/utils.js";
|
|
17
|
+
import { cojsonInternals } from "cojson";
|
|
17
18
|
|
|
18
19
|
beforeEach(async () => {
|
|
19
20
|
await setupJazzTestSync();
|
|
@@ -25,6 +26,8 @@ beforeEach(async () => {
|
|
|
25
26
|
});
|
|
26
27
|
});
|
|
27
28
|
|
|
29
|
+
cojsonInternals.setCoValueLoadingRetryDelay(10);
|
|
30
|
+
|
|
28
31
|
describe("CoValueCoreSubscription", async () => {
|
|
29
32
|
/**
|
|
30
33
|
* Tests scenarios where the CoValue is immediately available
|
|
@@ -444,7 +447,7 @@ describe("CoValueCoreSubscription", async () => {
|
|
|
444
447
|
});
|
|
445
448
|
|
|
446
449
|
describe("error handling scenarios", () => {
|
|
447
|
-
test("should
|
|
450
|
+
test("should synchronously emit unavailable when an invalid id is provided", async () => {
|
|
448
451
|
const bob = await createJazzTestAccount();
|
|
449
452
|
const invalidId = "invalid-co-value-id";
|
|
450
453
|
|
|
@@ -461,13 +464,50 @@ describe("CoValueCoreSubscription", async () => {
|
|
|
461
464
|
},
|
|
462
465
|
);
|
|
463
466
|
|
|
464
|
-
// Should
|
|
465
|
-
expect(listener).
|
|
467
|
+
// Should call listener synchronously since ID is invalid (doesn't start with "co_z")
|
|
468
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
469
|
+
expect(lastResult).toBe(CoValueLoadingState.UNAVAILABLE);
|
|
470
|
+
|
|
471
|
+
subscription.unsubscribe();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("should emit unavailable when subscribing to a CoValue that has already been marked as unavailable", async () => {
|
|
475
|
+
const Person = co.map({
|
|
476
|
+
name: z.string(),
|
|
477
|
+
age: z.number(),
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// Create a person that bob can access
|
|
481
|
+
const person = Person.create(
|
|
482
|
+
{ name: "John", age: 30 },
|
|
483
|
+
Group.create().makePublic("writer"),
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
// Create a new sync server, this way the CoValue is not available for new accounts
|
|
487
|
+
await setupJazzTestSync();
|
|
488
|
+
const bob = await createJazzTestAccount();
|
|
489
|
+
|
|
490
|
+
// Try to load it first, to mark the value as unavailable
|
|
491
|
+
await Person.load(person.$jazz.id, {
|
|
492
|
+
loadAs: bob,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
let lastResult: any = null;
|
|
496
|
+
const listener = vi.fn();
|
|
497
|
+
|
|
498
|
+
// Subscribe to the unavailable CoValue
|
|
499
|
+
const subscription = new CoValueCoreSubscription(
|
|
500
|
+
bob.$jazz.localNode,
|
|
501
|
+
person.$jazz.id,
|
|
502
|
+
(result) => {
|
|
503
|
+
lastResult = result;
|
|
504
|
+
listener(result);
|
|
505
|
+
},
|
|
506
|
+
);
|
|
466
507
|
|
|
467
|
-
// Wait for the error handling to complete
|
|
468
508
|
await waitFor(() => expect(listener).toHaveBeenCalled());
|
|
469
509
|
|
|
470
|
-
|
|
510
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
471
511
|
expect(lastResult).toBe(CoValueLoadingState.UNAVAILABLE);
|
|
472
512
|
|
|
473
513
|
subscription.unsubscribe();
|
|
@@ -492,13 +532,7 @@ describe("CoValueCoreSubscription", async () => {
|
|
|
492
532
|
{ name: "main", owner: bob },
|
|
493
533
|
);
|
|
494
534
|
|
|
495
|
-
|
|
496
|
-
expect(listener).not.toHaveBeenCalled();
|
|
497
|
-
|
|
498
|
-
// Wait for the error handling to complete
|
|
499
|
-
await waitFor(() => expect(listener).toHaveBeenCalled());
|
|
500
|
-
|
|
501
|
-
// Should report unavailable when loading fails
|
|
535
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
502
536
|
expect(lastResult).toBe(CoValueLoadingState.UNAVAILABLE);
|
|
503
537
|
|
|
504
538
|
subscription.unsubscribe();
|