@xnetjs/data-bridge 0.0.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris Smothers
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # @xnetjs/data-bridge
2
+
3
+ DataBridge abstractions for moving xNet data operations off the main thread.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @xnetjs/data-bridge
9
+ ```
10
+
11
+ ## What It Provides
12
+
13
+ - `MainThreadBridge` for direct NodeStore-backed access
14
+ - `WorkerBridge` for Web Worker execution via Comlink
15
+ - `createDataBridge()` factory with automatic bridge selection
16
+ - Shared bridge types (`DataBridge`, `QueryOptions`, `DataBridgeConfig`)
17
+ - Utilities for query caching, debouncing, and binary state transfer
18
+
19
+ ## Usage
20
+
21
+ ```ts
22
+ import { createDataBridge } from '@xnetjs/data-bridge'
23
+
24
+ const bridge = await createDataBridge({
25
+ nodeStore,
26
+ config: {
27
+ authorDID,
28
+ signingKey,
29
+ dbName: 'xnet'
30
+ },
31
+ workerUrl: new URL('@xnetjs/data-bridge/worker', import.meta.url),
32
+ mode: 'auto'
33
+ })
34
+
35
+ const subscription = bridge.query(TaskSchema, {
36
+ where: { status: 'todo' },
37
+ orderBy: { createdAt: 'desc' }
38
+ })
39
+ ```
40
+
41
+ ## Exports
42
+
43
+ - Types: `DataBridge`, `QueryOptions`, `DataBridgeConfig`, `AcquiredDoc`, `SyncStatus`
44
+ - Implementations: `MainThreadBridge`, `WorkerBridge`, `NativeBridge`
45
+ - Factories: `createDataBridge`, `createMainThreadBridgeSync`, `createWorkerBridgeSync`
46
+ - Worker helpers: `isWorkerSupported`, `isNodeEnvironment`
47
+
48
+ ## Testing
49
+
50
+ ```bash
51
+ pnpm --filter @xnetjs/data-bridge test
52
+ ```
@@ -0,0 +1,386 @@
1
+ // src/query-cache.ts
2
+ var DEFAULT_MAX_SIZE = 100;
3
+ var MIN_AGE_FOR_EVICTION = 3e4;
4
+ var WEAK_REF_CLEANUP_INTERVAL = 6e4;
5
+ var QueryCache = class {
6
+ cache = /* @__PURE__ */ new Map();
7
+ maxSize;
8
+ cleanupInterval = null;
9
+ constructor(options) {
10
+ this.maxSize = options?.maxSize ?? DEFAULT_MAX_SIZE;
11
+ const enableCleanup = options?.enableWeakRefCleanup ?? typeof window !== "undefined";
12
+ if (enableCleanup) {
13
+ this.startWeakRefCleanup();
14
+ }
15
+ }
16
+ /**
17
+ * Start the interval that cleans up dead weak references.
18
+ */
19
+ startWeakRefCleanup() {
20
+ if (this.cleanupInterval) return;
21
+ this.cleanupInterval = setInterval(() => {
22
+ this.cleanupDeadWeakRefs();
23
+ }, WEAK_REF_CLEANUP_INTERVAL);
24
+ if (typeof this.cleanupInterval === "object" && "unref" in this.cleanupInterval) {
25
+ this.cleanupInterval.unref();
26
+ }
27
+ }
28
+ /**
29
+ * Stop the weak reference cleanup interval.
30
+ */
31
+ stopCleanup() {
32
+ if (this.cleanupInterval) {
33
+ clearInterval(this.cleanupInterval);
34
+ this.cleanupInterval = null;
35
+ }
36
+ }
37
+ /**
38
+ * Clean up dead weak references from all cache entries.
39
+ * Returns the number of dead references removed.
40
+ */
41
+ cleanupDeadWeakRefs() {
42
+ let removed = 0;
43
+ for (const entry of this.cache.values()) {
44
+ for (const [identity, ref] of entry.weakSubscribers) {
45
+ if (ref.deref() === void 0) {
46
+ entry.weakSubscribers.delete(identity);
47
+ removed++;
48
+ }
49
+ }
50
+ }
51
+ return removed;
52
+ }
53
+ /**
54
+ * Compute a stable query ID from schema and options.
55
+ * Same query params should produce the same ID for deduplication.
56
+ */
57
+ computeQueryId(schemaId, options) {
58
+ const parts = [schemaId];
59
+ if (options?.nodeId) {
60
+ parts.push(`id:${options.nodeId}`);
61
+ }
62
+ if (options?.where) {
63
+ const sortedWhere = Object.keys(options.where).sort().map((k) => `${k}:${JSON.stringify(options.where[k])}`).join(",");
64
+ parts.push(`where:{${sortedWhere}}`);
65
+ }
66
+ if (options?.includeDeleted) {
67
+ parts.push("deleted:true");
68
+ }
69
+ if (options?.orderBy) {
70
+ const sortedOrder = Object.entries(options.orderBy).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}:${v}`).join(",");
71
+ parts.push(`order:{${sortedOrder}}`);
72
+ }
73
+ if (options?.limit !== void 0) {
74
+ parts.push(`limit:${options.limit}`);
75
+ }
76
+ if (options?.offset !== void 0) {
77
+ parts.push(`offset:${options.offset}`);
78
+ }
79
+ return parts.join("|");
80
+ }
81
+ /**
82
+ * Get cached data for a query (synchronous for useSyncExternalStore).
83
+ * Updates lastAccessed for LRU tracking.
84
+ */
85
+ get(queryId) {
86
+ const entry = this.cache.get(queryId);
87
+ if (entry) {
88
+ entry.lastAccessed = Date.now();
89
+ }
90
+ return entry?.data ?? null;
91
+ }
92
+ /**
93
+ * Check if a query is in the cache.
94
+ */
95
+ has(queryId) {
96
+ return this.cache.has(queryId);
97
+ }
98
+ /**
99
+ * Set cached data for a query and notify subscribers.
100
+ * Triggers LRU eviction if cache exceeds maxSize.
101
+ */
102
+ set(queryId, data, schemaId, options) {
103
+ const entry = this.cache.get(queryId);
104
+ const now = Date.now();
105
+ if (entry) {
106
+ entry.data = data;
107
+ entry.lastUpdated = now;
108
+ entry.lastAccessed = now;
109
+ this.notifySubscribers(queryId);
110
+ } else {
111
+ this.evictIfNeeded();
112
+ this.cache.set(queryId, {
113
+ data,
114
+ subscribers: /* @__PURE__ */ new Set(),
115
+ weakSubscribers: /* @__PURE__ */ new Map(),
116
+ schemaId,
117
+ options,
118
+ lastUpdated: now,
119
+ lastAccessed: now
120
+ });
121
+ }
122
+ }
123
+ /**
124
+ * Initialize a cache entry (called when starting a subscription).
125
+ */
126
+ initEntry(queryId, schemaId, options) {
127
+ if (!this.cache.has(queryId)) {
128
+ const now = Date.now();
129
+ this.cache.set(queryId, {
130
+ data: null,
131
+ subscribers: /* @__PURE__ */ new Set(),
132
+ weakSubscribers: /* @__PURE__ */ new Map(),
133
+ schemaId,
134
+ options,
135
+ lastUpdated: 0,
136
+ lastAccessed: now
137
+ });
138
+ }
139
+ }
140
+ /**
141
+ * Subscribe to cache updates for a query.
142
+ * Uses strong references - callback will not be garbage collected until unsubscribed.
143
+ */
144
+ subscribe(queryId, callback) {
145
+ const entry = this.cache.get(queryId);
146
+ if (entry) {
147
+ entry.subscribers.add(callback);
148
+ }
149
+ return () => {
150
+ const e = this.cache.get(queryId);
151
+ if (e) {
152
+ e.subscribers.delete(callback);
153
+ }
154
+ };
155
+ }
156
+ /**
157
+ * Subscribe with a weak reference.
158
+ * The callback can be garbage collected if the owning component is unmounted
159
+ * and no other references to the callback exist.
160
+ *
161
+ * This is useful for long-lived subscriptions where you want automatic cleanup
162
+ * without explicit unsubscribe calls. However, for React components, prefer
163
+ * the regular subscribe() with proper cleanup in useEffect.
164
+ *
165
+ * @param queryId - The query to subscribe to
166
+ * @param callback - The callback to invoke on updates
167
+ * @returns Unsubscribe function
168
+ */
169
+ subscribeWeak(queryId, callback) {
170
+ const entry = this.cache.get(queryId);
171
+ if (entry) {
172
+ entry.weakSubscribers.set(callback, new WeakRef(callback));
173
+ }
174
+ return () => {
175
+ const e = this.cache.get(queryId);
176
+ if (e) {
177
+ e.weakSubscribers.delete(callback);
178
+ }
179
+ };
180
+ }
181
+ /**
182
+ * Notify all subscribers of a query that data has changed.
183
+ * Handles both strong and weak subscribers, cleaning up dead weak refs.
184
+ */
185
+ notifySubscribers(queryId) {
186
+ const entry = this.cache.get(queryId);
187
+ if (!entry) return;
188
+ for (const callback of entry.subscribers) {
189
+ callback();
190
+ }
191
+ for (const [identity, ref] of entry.weakSubscribers) {
192
+ const callback = ref.deref();
193
+ if (callback) {
194
+ callback();
195
+ } else {
196
+ entry.weakSubscribers.delete(identity);
197
+ }
198
+ }
199
+ }
200
+ /**
201
+ * Get the number of active subscribers for a query.
202
+ * Includes both strong and live weak subscribers.
203
+ */
204
+ getSubscriberCount(queryId) {
205
+ const entry = this.cache.get(queryId);
206
+ if (!entry) return 0;
207
+ let count = entry.subscribers.size;
208
+ for (const ref of entry.weakSubscribers.values()) {
209
+ if (ref.deref() !== void 0) {
210
+ count++;
211
+ }
212
+ }
213
+ return count;
214
+ }
215
+ /**
216
+ * Get the number of weak subscribers (including potentially dead ones).
217
+ * Useful for debugging.
218
+ */
219
+ getWeakSubscriberCount(queryId) {
220
+ return this.cache.get(queryId)?.weakSubscribers.size ?? 0;
221
+ }
222
+ /**
223
+ * Remove a query from the cache.
224
+ */
225
+ delete(queryId) {
226
+ this.cache.delete(queryId);
227
+ }
228
+ /**
229
+ * Get all query IDs that match a schema.
230
+ */
231
+ getQueriesForSchema(schemaId) {
232
+ const matches = [];
233
+ for (const [queryId, entry] of this.cache) {
234
+ if (entry.schemaId === schemaId) {
235
+ matches.push(queryId);
236
+ }
237
+ }
238
+ return matches;
239
+ }
240
+ /**
241
+ * Get the schema IRI for a cached query.
242
+ */
243
+ getSchemaId(queryId) {
244
+ return this.cache.get(queryId)?.schemaId;
245
+ }
246
+ /**
247
+ * Get the options for a cached query.
248
+ */
249
+ getOptions(queryId) {
250
+ return this.cache.get(queryId)?.options;
251
+ }
252
+ /**
253
+ * Clear the entire cache and stop cleanup interval.
254
+ */
255
+ clear() {
256
+ this.stopCleanup();
257
+ this.cache.clear();
258
+ }
259
+ /**
260
+ * Destroy the cache, stopping all cleanup intervals.
261
+ */
262
+ destroy() {
263
+ this.stopCleanup();
264
+ this.cache.clear();
265
+ }
266
+ /**
267
+ * Get the number of cached queries.
268
+ */
269
+ get size() {
270
+ return this.cache.size;
271
+ }
272
+ /**
273
+ * Get the maximum cache size.
274
+ */
275
+ get maxCacheSize() {
276
+ return this.maxSize;
277
+ }
278
+ // ─── LRU Eviction ──────────────────────────────────────────────────────────
279
+ /**
280
+ * Check if an entry has any active subscribers (strong or live weak).
281
+ */
282
+ hasActiveSubscribers(entry) {
283
+ if (entry.subscribers.size > 0) return true;
284
+ for (const ref of entry.weakSubscribers.values()) {
285
+ if (ref.deref() !== void 0) return true;
286
+ }
287
+ return false;
288
+ }
289
+ /**
290
+ * Evict least-recently-used entries if cache exceeds maxSize.
291
+ * Only evicts entries with no active subscribers and older than MIN_AGE_FOR_EVICTION.
292
+ */
293
+ evictIfNeeded() {
294
+ if (this.cache.size < this.maxSize) return;
295
+ const now = Date.now();
296
+ const candidates = [];
297
+ for (const [queryId, entry] of this.cache) {
298
+ if (!this.hasActiveSubscribers(entry) && now - entry.lastAccessed > MIN_AGE_FOR_EVICTION) {
299
+ candidates.push({ queryId, lastAccessed: entry.lastAccessed });
300
+ }
301
+ }
302
+ if (candidates.length === 0) return;
303
+ candidates.sort((a, b) => a.lastAccessed - b.lastAccessed);
304
+ const targetSize = Math.floor(this.maxSize * 0.8);
305
+ const toEvict = this.cache.size - targetSize;
306
+ for (let i = 0; i < Math.min(toEvict, candidates.length); i++) {
307
+ this.cache.delete(candidates[i].queryId);
308
+ }
309
+ }
310
+ /**
311
+ * Manually trigger eviction (for testing or explicit cleanup).
312
+ */
313
+ evict() {
314
+ const sizeBefore = this.cache.size;
315
+ this.evictIfNeeded();
316
+ return sizeBefore - this.cache.size;
317
+ }
318
+ // ─── Helpers for filtering and sorting ─────────────────────────────────────
319
+ /**
320
+ * Filter nodes based on query options.
321
+ */
322
+ filterNodes(nodes, options) {
323
+ if (!options) return nodes;
324
+ let result = nodes;
325
+ if (options.where) {
326
+ result = result.filter((node) => {
327
+ for (const [key, value] of Object.entries(options.where)) {
328
+ if (node.properties[key] !== value) {
329
+ return false;
330
+ }
331
+ }
332
+ return true;
333
+ });
334
+ }
335
+ if (!options.includeDeleted) {
336
+ result = result.filter((node) => !node.deleted);
337
+ }
338
+ return result;
339
+ }
340
+ /**
341
+ * Sort nodes based on query options.
342
+ */
343
+ sortNodes(nodes, options) {
344
+ if (!options?.orderBy) return nodes;
345
+ const entries = Object.entries(options.orderBy);
346
+ if (entries.length === 0) return nodes;
347
+ return [...nodes].sort((a, b) => {
348
+ for (const [key, direction] of entries) {
349
+ const keyStr = key;
350
+ let aVal;
351
+ let bVal;
352
+ if (keyStr === "createdAt" || keyStr === "updatedAt") {
353
+ aVal = a[keyStr];
354
+ bVal = b[keyStr];
355
+ } else {
356
+ aVal = a.properties[keyStr];
357
+ bVal = b.properties[keyStr];
358
+ }
359
+ if (aVal === bVal) continue;
360
+ if (aVal == null) return direction === "asc" ? 1 : -1;
361
+ if (bVal == null) return direction === "asc" ? -1 : 1;
362
+ const comparison = aVal < bVal ? -1 : 1;
363
+ return direction === "asc" ? comparison : -comparison;
364
+ }
365
+ return 0;
366
+ });
367
+ }
368
+ /**
369
+ * Apply pagination to nodes.
370
+ */
371
+ paginateNodes(nodes, options) {
372
+ if (!options) return nodes;
373
+ let result = nodes;
374
+ if (options.offset !== void 0 && options.offset > 0) {
375
+ result = result.slice(options.offset);
376
+ }
377
+ if (options.limit !== void 0 && options.limit > 0) {
378
+ result = result.slice(0, options.limit);
379
+ }
380
+ return result;
381
+ }
382
+ };
383
+
384
+ export {
385
+ QueryCache
386
+ };