@xnetjs/data-bridge 0.0.2 → 0.0.3
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/README.md +5 -0
- package/dist/chunk-25WNZV7W.js +831 -0
- package/dist/chunk-5GTIP33X.js +201 -0
- package/dist/chunk-EPNW4GGU.js +460 -0
- package/dist/index.d.ts +259 -449
- package/dist/index.js +1335 -575
- package/dist/native-bridge.d.ts +126 -0
- package/dist/native-bridge.js +13 -0
- package/dist/query-descriptor-D0k2gUQ0.d.ts +298 -0
- package/dist/types-BRvuTwEn.d.ts +547 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.js +0 -0
- package/dist/worker/data-worker.d.ts +161 -1
- package/dist/worker/data-worker.js +468 -144
- package/package.json +16 -6
- package/dist/chunk-X6F5CPJI.js +0 -386
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyNodeChangeToQueryResult,
|
|
3
|
+
createQueryDescriptor,
|
|
4
|
+
queryDescriptorToOptions,
|
|
5
|
+
serializeQueryDescriptor
|
|
6
|
+
} from "./chunk-5GTIP33X.js";
|
|
7
|
+
|
|
8
|
+
// src/query-cache.ts
|
|
9
|
+
var DEFAULT_MAX_SIZE = 100;
|
|
10
|
+
var MIN_AGE_FOR_EVICTION = 3e4;
|
|
11
|
+
var WEAK_REF_CLEANUP_INTERVAL = 6e4;
|
|
12
|
+
function isSameNodeData(previous, next) {
|
|
13
|
+
if (previous === next) return true;
|
|
14
|
+
if (!previous || !next) return false;
|
|
15
|
+
if (previous.length !== next.length) return false;
|
|
16
|
+
return next.every((node, index) => node === previous[index]);
|
|
17
|
+
}
|
|
18
|
+
var REMOTE_METADATA_SURFACES = [
|
|
19
|
+
"stream",
|
|
20
|
+
"completeness",
|
|
21
|
+
"staleness",
|
|
22
|
+
"verification",
|
|
23
|
+
"materialized"
|
|
24
|
+
];
|
|
25
|
+
function hasEquivalentRemoteSurfaces(previous, next) {
|
|
26
|
+
return REMOTE_METADATA_SURFACES.every((surface) => {
|
|
27
|
+
if (previous[surface] === next[surface]) return true;
|
|
28
|
+
return !previous[surface] && !next[surface];
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
var COMPARED_PAGE_INFO_FIELDS = [
|
|
32
|
+
"totalCount",
|
|
33
|
+
"countMode",
|
|
34
|
+
"hasMore",
|
|
35
|
+
"hasNextPage",
|
|
36
|
+
"hasPreviousPage",
|
|
37
|
+
"loadedCount",
|
|
38
|
+
"startCursor",
|
|
39
|
+
"endCursor"
|
|
40
|
+
];
|
|
41
|
+
function isSamePageInfo(previous, next) {
|
|
42
|
+
if (previous === next) return true;
|
|
43
|
+
if (!previous || !next) return false;
|
|
44
|
+
return COMPARED_PAGE_INFO_FIELDS.every((field) => previous[field] === next[field]);
|
|
45
|
+
}
|
|
46
|
+
function isQueryMetadataEquivalent(previous, next) {
|
|
47
|
+
if (previous === next) return true;
|
|
48
|
+
if (!previous || !next) return false;
|
|
49
|
+
return previous.source === next.source && previous.error === next.error && hasEquivalentRemoteSurfaces(previous, next) && isSamePageInfo(previous.pageInfo, next.pageInfo);
|
|
50
|
+
}
|
|
51
|
+
var QueryCache = class {
|
|
52
|
+
cache = /* @__PURE__ */ new Map();
|
|
53
|
+
maxSize;
|
|
54
|
+
cleanupInterval = null;
|
|
55
|
+
constructor(options) {
|
|
56
|
+
this.maxSize = options?.maxSize ?? DEFAULT_MAX_SIZE;
|
|
57
|
+
const enableCleanup = options?.enableWeakRefCleanup ?? typeof window !== "undefined";
|
|
58
|
+
if (enableCleanup) {
|
|
59
|
+
this.startWeakRefCleanup();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Start the interval that cleans up dead weak references.
|
|
64
|
+
*/
|
|
65
|
+
startWeakRefCleanup() {
|
|
66
|
+
if (this.cleanupInterval) return;
|
|
67
|
+
this.cleanupInterval = setInterval(() => {
|
|
68
|
+
this.cleanupDeadWeakRefs();
|
|
69
|
+
}, WEAK_REF_CLEANUP_INTERVAL);
|
|
70
|
+
if (typeof this.cleanupInterval === "object" && "unref" in this.cleanupInterval) {
|
|
71
|
+
this.cleanupInterval.unref();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Stop the weak reference cleanup interval.
|
|
76
|
+
*/
|
|
77
|
+
stopCleanup() {
|
|
78
|
+
if (this.cleanupInterval) {
|
|
79
|
+
clearInterval(this.cleanupInterval);
|
|
80
|
+
this.cleanupInterval = null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Clean up dead weak references from all cache entries.
|
|
85
|
+
* Returns the number of dead references removed.
|
|
86
|
+
*/
|
|
87
|
+
cleanupDeadWeakRefs() {
|
|
88
|
+
let removed = 0;
|
|
89
|
+
for (const entry of this.cache.values()) {
|
|
90
|
+
for (const [identity, ref] of entry.weakSubscribers) {
|
|
91
|
+
if (ref.deref() === void 0) {
|
|
92
|
+
entry.weakSubscribers.delete(identity);
|
|
93
|
+
removed++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return removed;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Compute a stable query ID from schema and options.
|
|
101
|
+
* Same query params should produce the same ID for deduplication.
|
|
102
|
+
*/
|
|
103
|
+
computeQueryId(schemaId, options) {
|
|
104
|
+
const descriptor = typeof schemaId === "string" ? createQueryDescriptor(schemaId, options) : schemaId;
|
|
105
|
+
return serializeQueryDescriptor(descriptor);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Get cached data for a query (synchronous for useSyncExternalStore).
|
|
109
|
+
* Updates lastAccessed for LRU tracking.
|
|
110
|
+
*/
|
|
111
|
+
get(queryId) {
|
|
112
|
+
const entry = this.cache.get(queryId);
|
|
113
|
+
if (entry) {
|
|
114
|
+
entry.lastAccessed = Date.now();
|
|
115
|
+
}
|
|
116
|
+
return entry?.data ?? null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Check if a query is in the cache.
|
|
120
|
+
*/
|
|
121
|
+
has(queryId) {
|
|
122
|
+
return this.cache.has(queryId);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Set cached data for a query and notify subscribers.
|
|
126
|
+
* Triggers LRU eviction if cache exceeds maxSize.
|
|
127
|
+
*/
|
|
128
|
+
set(queryId, data, schemaIdOrDescriptor, options, metadata, workingSet) {
|
|
129
|
+
const entry = this.cache.get(queryId);
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
const descriptor = typeof schemaIdOrDescriptor === "string" ? createQueryDescriptor(schemaIdOrDescriptor, options) : schemaIdOrDescriptor;
|
|
132
|
+
if (entry) {
|
|
133
|
+
const dataUnchanged = isSameNodeData(entry.data, data);
|
|
134
|
+
const metadataUnchanged = metadata === void 0 || isQueryMetadataEquivalent(entry.metadata, metadata);
|
|
135
|
+
entry.data = dataUnchanged ? entry.data : data;
|
|
136
|
+
entry.workingSet = workingSet ?? null;
|
|
137
|
+
if (descriptor) {
|
|
138
|
+
entry.schemaId = descriptor.schemaId;
|
|
139
|
+
entry.descriptor = descriptor;
|
|
140
|
+
entry.options = queryDescriptorToOptions(descriptor);
|
|
141
|
+
}
|
|
142
|
+
if (metadata !== void 0) {
|
|
143
|
+
entry.metadata = metadataUnchanged && entry.metadata ? entry.metadata : metadata;
|
|
144
|
+
}
|
|
145
|
+
entry.lastUpdated = now;
|
|
146
|
+
entry.lastAccessed = now;
|
|
147
|
+
if (dataUnchanged && metadataUnchanged) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
this.notifySubscribers(queryId);
|
|
151
|
+
} else {
|
|
152
|
+
if (!descriptor) {
|
|
153
|
+
throw new Error(`QueryCache.set requires a descriptor when creating "${queryId}"`);
|
|
154
|
+
}
|
|
155
|
+
this.evictIfNeeded();
|
|
156
|
+
this.cache.set(queryId, {
|
|
157
|
+
data,
|
|
158
|
+
workingSet: workingSet ?? null,
|
|
159
|
+
subscribers: /* @__PURE__ */ new Set(),
|
|
160
|
+
weakSubscribers: /* @__PURE__ */ new Map(),
|
|
161
|
+
schemaId: descriptor.schemaId,
|
|
162
|
+
descriptor,
|
|
163
|
+
options: queryDescriptorToOptions(descriptor),
|
|
164
|
+
metadata: metadata ?? null,
|
|
165
|
+
lastUpdated: now,
|
|
166
|
+
lastAccessed: now
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Initialize a cache entry (called when starting a subscription).
|
|
172
|
+
*/
|
|
173
|
+
initEntry(queryId, schemaIdOrDescriptor, options) {
|
|
174
|
+
if (!this.cache.has(queryId)) {
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
const descriptor = typeof schemaIdOrDescriptor === "string" ? createQueryDescriptor(schemaIdOrDescriptor, options) : schemaIdOrDescriptor;
|
|
177
|
+
this.cache.set(queryId, {
|
|
178
|
+
data: null,
|
|
179
|
+
workingSet: null,
|
|
180
|
+
subscribers: /* @__PURE__ */ new Set(),
|
|
181
|
+
weakSubscribers: /* @__PURE__ */ new Map(),
|
|
182
|
+
schemaId: descriptor.schemaId,
|
|
183
|
+
descriptor,
|
|
184
|
+
options: queryDescriptorToOptions(descriptor),
|
|
185
|
+
metadata: null,
|
|
186
|
+
lastUpdated: 0,
|
|
187
|
+
lastAccessed: now
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Subscribe to cache updates for a query.
|
|
193
|
+
* Uses strong references - callback will not be garbage collected until unsubscribed.
|
|
194
|
+
*/
|
|
195
|
+
subscribe(queryId, callback) {
|
|
196
|
+
const entry = this.cache.get(queryId);
|
|
197
|
+
if (entry) {
|
|
198
|
+
entry.subscribers.add(callback);
|
|
199
|
+
}
|
|
200
|
+
return () => {
|
|
201
|
+
const e = this.cache.get(queryId);
|
|
202
|
+
if (e) {
|
|
203
|
+
e.subscribers.delete(callback);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Subscribe with a weak reference.
|
|
209
|
+
* The callback can be garbage collected if the owning component is unmounted
|
|
210
|
+
* and no other references to the callback exist.
|
|
211
|
+
*
|
|
212
|
+
* This is useful for long-lived subscriptions where you want automatic cleanup
|
|
213
|
+
* without explicit unsubscribe calls. However, for React components, prefer
|
|
214
|
+
* the regular subscribe() with proper cleanup in useEffect.
|
|
215
|
+
*
|
|
216
|
+
* @param queryId - The query to subscribe to
|
|
217
|
+
* @param callback - The callback to invoke on updates
|
|
218
|
+
* @returns Unsubscribe function
|
|
219
|
+
*/
|
|
220
|
+
subscribeWeak(queryId, callback) {
|
|
221
|
+
const entry = this.cache.get(queryId);
|
|
222
|
+
if (entry) {
|
|
223
|
+
entry.weakSubscribers.set(callback, new WeakRef(callback));
|
|
224
|
+
}
|
|
225
|
+
return () => {
|
|
226
|
+
const e = this.cache.get(queryId);
|
|
227
|
+
if (e) {
|
|
228
|
+
e.weakSubscribers.delete(callback);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Notify all subscribers of a query that data has changed.
|
|
234
|
+
* Handles both strong and weak subscribers, cleaning up dead weak refs.
|
|
235
|
+
*/
|
|
236
|
+
notifySubscribers(queryId) {
|
|
237
|
+
const entry = this.cache.get(queryId);
|
|
238
|
+
if (!entry) return;
|
|
239
|
+
for (const callback of entry.subscribers) {
|
|
240
|
+
callback();
|
|
241
|
+
}
|
|
242
|
+
for (const [identity, ref] of entry.weakSubscribers) {
|
|
243
|
+
const callback = ref.deref();
|
|
244
|
+
if (callback) {
|
|
245
|
+
callback();
|
|
246
|
+
} else {
|
|
247
|
+
entry.weakSubscribers.delete(identity);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Get the number of active subscribers for a query.
|
|
253
|
+
* Includes both strong and live weak subscribers.
|
|
254
|
+
*/
|
|
255
|
+
getSubscriberCount(queryId) {
|
|
256
|
+
const entry = this.cache.get(queryId);
|
|
257
|
+
if (!entry) return 0;
|
|
258
|
+
let count = entry.subscribers.size;
|
|
259
|
+
for (const ref of entry.weakSubscribers.values()) {
|
|
260
|
+
if (ref.deref() !== void 0) {
|
|
261
|
+
count++;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return count;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Get the number of weak subscribers (including potentially dead ones).
|
|
268
|
+
* Useful for debugging.
|
|
269
|
+
*/
|
|
270
|
+
getWeakSubscriberCount(queryId) {
|
|
271
|
+
return this.cache.get(queryId)?.weakSubscribers.size ?? 0;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Remove a query from the cache.
|
|
275
|
+
*/
|
|
276
|
+
delete(queryId) {
|
|
277
|
+
this.cache.delete(queryId);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Get all query IDs that match a schema.
|
|
281
|
+
*/
|
|
282
|
+
getQueriesForSchema(schemaId) {
|
|
283
|
+
const matches = [];
|
|
284
|
+
for (const [queryId, entry] of this.cache) {
|
|
285
|
+
if (entry.descriptor.schemaId === schemaId) {
|
|
286
|
+
matches.push(queryId);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return matches;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Get all cached entries for a schema.
|
|
293
|
+
*/
|
|
294
|
+
getEntriesForSchema(schemaId) {
|
|
295
|
+
const matches = [];
|
|
296
|
+
for (const [queryId, entry] of this.cache) {
|
|
297
|
+
if (entry.descriptor.schemaId === schemaId) {
|
|
298
|
+
matches.push({
|
|
299
|
+
queryId,
|
|
300
|
+
descriptor: entry.descriptor,
|
|
301
|
+
data: entry.data,
|
|
302
|
+
workingSet: entry.workingSet
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return matches;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Get all cached entries.
|
|
310
|
+
*/
|
|
311
|
+
getEntries() {
|
|
312
|
+
return Array.from(this.cache.entries()).map(([queryId, entry]) => ({
|
|
313
|
+
queryId,
|
|
314
|
+
descriptor: entry.descriptor,
|
|
315
|
+
data: entry.data,
|
|
316
|
+
workingSet: entry.workingSet
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Get the bounded-query working set for a cached query, if any.
|
|
321
|
+
*/
|
|
322
|
+
getWorkingSet(queryId) {
|
|
323
|
+
return this.cache.get(queryId)?.workingSet ?? null;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Get the schema IRI for a cached query.
|
|
327
|
+
*/
|
|
328
|
+
getSchemaId(queryId) {
|
|
329
|
+
return this.cache.get(queryId)?.descriptor.schemaId;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Get the descriptor for a cached query.
|
|
333
|
+
*/
|
|
334
|
+
getDescriptor(queryId) {
|
|
335
|
+
return this.cache.get(queryId)?.descriptor;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Get the options for a cached query.
|
|
339
|
+
*/
|
|
340
|
+
getOptions(queryId) {
|
|
341
|
+
return this.cache.get(queryId)?.options;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Get the latest metadata for a cached query.
|
|
345
|
+
*/
|
|
346
|
+
getMetadata(queryId) {
|
|
347
|
+
return this.cache.get(queryId)?.metadata ?? null;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Set metadata without replacing query data.
|
|
351
|
+
*/
|
|
352
|
+
setMetadata(queryId, metadata) {
|
|
353
|
+
const entry = this.cache.get(queryId);
|
|
354
|
+
if (!entry) return;
|
|
355
|
+
entry.metadata = metadata;
|
|
356
|
+
entry.lastUpdated = Date.now();
|
|
357
|
+
this.notifySubscribers(queryId);
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Clear the entire cache and stop cleanup interval.
|
|
361
|
+
*/
|
|
362
|
+
clear() {
|
|
363
|
+
this.stopCleanup();
|
|
364
|
+
this.cache.clear();
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Destroy the cache, stopping all cleanup intervals.
|
|
368
|
+
*/
|
|
369
|
+
destroy() {
|
|
370
|
+
this.stopCleanup();
|
|
371
|
+
this.cache.clear();
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Get the number of cached queries.
|
|
375
|
+
*/
|
|
376
|
+
get size() {
|
|
377
|
+
return this.cache.size;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Get the maximum cache size.
|
|
381
|
+
*/
|
|
382
|
+
get maxCacheSize() {
|
|
383
|
+
return this.maxSize;
|
|
384
|
+
}
|
|
385
|
+
// ─── LRU Eviction ──────────────────────────────────────────────────────────
|
|
386
|
+
/**
|
|
387
|
+
* Check if an entry has any active subscribers (strong or live weak).
|
|
388
|
+
*/
|
|
389
|
+
hasActiveSubscribers(entry) {
|
|
390
|
+
if (entry.subscribers.size > 0) return true;
|
|
391
|
+
for (const ref of entry.weakSubscribers.values()) {
|
|
392
|
+
if (ref.deref() !== void 0) return true;
|
|
393
|
+
}
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Evict least-recently-used entries if cache exceeds maxSize.
|
|
398
|
+
* Only evicts entries with no active subscribers and older than MIN_AGE_FOR_EVICTION.
|
|
399
|
+
*/
|
|
400
|
+
evictIfNeeded() {
|
|
401
|
+
if (this.cache.size < this.maxSize) return;
|
|
402
|
+
const now = Date.now();
|
|
403
|
+
const candidates = [];
|
|
404
|
+
for (const [queryId, entry] of this.cache) {
|
|
405
|
+
if (!this.hasActiveSubscribers(entry) && now - entry.lastAccessed > MIN_AGE_FOR_EVICTION) {
|
|
406
|
+
candidates.push({ queryId, lastAccessed: entry.lastAccessed });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (candidates.length === 0) return;
|
|
410
|
+
candidates.sort((a, b) => a.lastAccessed - b.lastAccessed);
|
|
411
|
+
const targetSize = Math.floor(this.maxSize * 0.8);
|
|
412
|
+
const toEvict = this.cache.size - targetSize;
|
|
413
|
+
for (let i = 0; i < Math.min(toEvict, candidates.length); i++) {
|
|
414
|
+
this.cache.delete(candidates[i].queryId);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Manually trigger eviction (for testing or explicit cleanup).
|
|
419
|
+
*/
|
|
420
|
+
evict() {
|
|
421
|
+
const sizeBefore = this.cache.size;
|
|
422
|
+
this.evictIfNeeded();
|
|
423
|
+
return sizeBefore - this.cache.size;
|
|
424
|
+
}
|
|
425
|
+
// ─── Helpers for filtering and sorting ─────────────────────────────────────
|
|
426
|
+
/**
|
|
427
|
+
* Filter nodes based on query options.
|
|
428
|
+
*/
|
|
429
|
+
filterNodes(nodes, options) {
|
|
430
|
+
if (!options) return nodes;
|
|
431
|
+
let result = nodes;
|
|
432
|
+
if (options.where) {
|
|
433
|
+
result = result.filter((node) => {
|
|
434
|
+
for (const [key, value] of Object.entries(options.where)) {
|
|
435
|
+
if (node.properties[key] !== value) {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return true;
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
if (!options.includeDeleted) {
|
|
443
|
+
result = result.filter((node) => !node.deleted);
|
|
444
|
+
}
|
|
445
|
+
return result;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Sort nodes based on query options.
|
|
449
|
+
*/
|
|
450
|
+
sortNodes(nodes, options) {
|
|
451
|
+
if (!options?.orderBy) return nodes;
|
|
452
|
+
const entries = Object.entries(options.orderBy);
|
|
453
|
+
if (entries.length === 0) return nodes;
|
|
454
|
+
return [...nodes].sort((a, b) => {
|
|
455
|
+
for (const [key, direction] of entries) {
|
|
456
|
+
const keyStr = key;
|
|
457
|
+
let aVal;
|
|
458
|
+
let bVal;
|
|
459
|
+
if (keyStr === "createdAt" || keyStr === "updatedAt") {
|
|
460
|
+
aVal = a[keyStr];
|
|
461
|
+
bVal = b[keyStr];
|
|
462
|
+
} else {
|
|
463
|
+
aVal = a.properties[keyStr];
|
|
464
|
+
bVal = b.properties[keyStr];
|
|
465
|
+
}
|
|
466
|
+
if (aVal === bVal) continue;
|
|
467
|
+
if (aVal == null) return direction === "asc" ? 1 : -1;
|
|
468
|
+
if (bVal == null) return direction === "asc" ? -1 : 1;
|
|
469
|
+
const comparison = aVal < bVal ? -1 : 1;
|
|
470
|
+
return direction === "asc" ? comparison : -comparison;
|
|
471
|
+
}
|
|
472
|
+
return 0;
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Apply pagination to nodes.
|
|
477
|
+
*/
|
|
478
|
+
paginateNodes(nodes, options) {
|
|
479
|
+
if (!options) return nodes;
|
|
480
|
+
let result = nodes;
|
|
481
|
+
if (options.offset !== void 0 && options.offset > 0) {
|
|
482
|
+
result = result.slice(options.offset);
|
|
483
|
+
}
|
|
484
|
+
if (options.limit !== void 0 && options.limit > 0) {
|
|
485
|
+
result = result.slice(0, options.limit);
|
|
486
|
+
}
|
|
487
|
+
return result;
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
// src/query-metadata.ts
|
|
492
|
+
import {
|
|
493
|
+
encodeNodeQueryCursor
|
|
494
|
+
} from "@xnetjs/data";
|
|
495
|
+
function getOffset(descriptor) {
|
|
496
|
+
return descriptor.offset ?? 0;
|
|
497
|
+
}
|
|
498
|
+
function getPageInfo(input) {
|
|
499
|
+
const { descriptor, nodes, totalCount, countMode } = input;
|
|
500
|
+
const loadedCount = nodes.length;
|
|
501
|
+
const offset = getOffset(descriptor);
|
|
502
|
+
const hasCursor = descriptor.after !== void 0;
|
|
503
|
+
const startCursor = loadedCount > 0 ? encodeNodeQueryCursor(descriptor, nodes[0]) : void 0;
|
|
504
|
+
const endCursor = loadedCount > 0 ? encodeNodeQueryCursor(descriptor, nodes[loadedCount - 1]) : void 0;
|
|
505
|
+
const hasPreviousPage = offset > 0 || hasCursor;
|
|
506
|
+
const hasMore = descriptor.limit !== void 0 ? totalCount === null || hasCursor ? loadedCount >= descriptor.limit : offset + loadedCount < totalCount : false;
|
|
507
|
+
return {
|
|
508
|
+
totalCount,
|
|
509
|
+
countMode,
|
|
510
|
+
hasMore,
|
|
511
|
+
hasNextPage: hasMore,
|
|
512
|
+
hasPreviousPage,
|
|
513
|
+
...startCursor ? { startCursor } : {},
|
|
514
|
+
...endCursor ? { endCursor } : {},
|
|
515
|
+
loadedCount
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
function getCountMetadata(descriptor, nodes, result) {
|
|
519
|
+
if (descriptor.count === "none") {
|
|
520
|
+
return { totalCount: null, countMode: "none" };
|
|
521
|
+
}
|
|
522
|
+
if (result?.plan.materializedRowCount !== void 0) {
|
|
523
|
+
return { totalCount: result.plan.materializedRowCount, countMode: "exact" };
|
|
524
|
+
}
|
|
525
|
+
if (result?.totalCount !== void 0) {
|
|
526
|
+
return { totalCount: result.totalCount, countMode: "exact" };
|
|
527
|
+
}
|
|
528
|
+
if (descriptor.count === "estimate") {
|
|
529
|
+
return {
|
|
530
|
+
totalCount: result?.plan.candidateNodeCount ?? nodes.length,
|
|
531
|
+
countMode: "estimate"
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
const isUnbounded = descriptor.limit === void 0 && getOffset(descriptor) === 0 && descriptor.after === void 0;
|
|
535
|
+
return isUnbounded ? { totalCount: nodes.length, countMode: "exact" } : { totalCount: null, countMode: "none" };
|
|
536
|
+
}
|
|
537
|
+
function createQueryMetadata(input) {
|
|
538
|
+
const { descriptor, result, source } = input;
|
|
539
|
+
const count = getCountMetadata(descriptor, result.nodes, result);
|
|
540
|
+
const materializedViewId = result.plan.materializedViewId;
|
|
541
|
+
return {
|
|
542
|
+
source,
|
|
543
|
+
updatedAt: Date.now(),
|
|
544
|
+
pageInfo: getPageInfo({ descriptor, nodes: result.nodes, ...count }),
|
|
545
|
+
plan: result.plan,
|
|
546
|
+
...materializedViewId ? {
|
|
547
|
+
materialized: {
|
|
548
|
+
viewId: materializedViewId,
|
|
549
|
+
cacheHit: result.plan.materializedCacheHit ?? false,
|
|
550
|
+
generatedAt: result.plan.materializedGeneratedAt ?? Date.now(),
|
|
551
|
+
...result.plan.materializedInvalidatedAt !== void 0 ? { invalidatedAt: result.plan.materializedInvalidatedAt } : {},
|
|
552
|
+
rowCount: result.plan.materializedRowCount ?? result.nodes.length
|
|
553
|
+
}
|
|
554
|
+
} : {}
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
function createQueryErrorMetadata(input) {
|
|
558
|
+
return {
|
|
559
|
+
source: input.source,
|
|
560
|
+
updatedAt: Date.now(),
|
|
561
|
+
pageInfo: getPageInfo({
|
|
562
|
+
descriptor: input.descriptor,
|
|
563
|
+
nodes: [],
|
|
564
|
+
totalCount: 0,
|
|
565
|
+
countMode: "exact"
|
|
566
|
+
}),
|
|
567
|
+
error: input.error.message
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
function createQuerySnapshotMetadata(input) {
|
|
571
|
+
return {
|
|
572
|
+
source: input.source,
|
|
573
|
+
updatedAt: Date.now(),
|
|
574
|
+
pageInfo: getPageInfo({
|
|
575
|
+
descriptor: input.descriptor,
|
|
576
|
+
nodes: input.nodes,
|
|
577
|
+
...getCountMetadata(input.descriptor, input.nodes)
|
|
578
|
+
})
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// src/native-bridge.ts
|
|
583
|
+
var NativeBridge = class {
|
|
584
|
+
store;
|
|
585
|
+
cache;
|
|
586
|
+
statusListeners = /* @__PURE__ */ new Set();
|
|
587
|
+
storeUnsubscribe = null;
|
|
588
|
+
storeBatchUnsubscribe = null;
|
|
589
|
+
storageAdapter;
|
|
590
|
+
_status = "disconnected";
|
|
591
|
+
destroyed = false;
|
|
592
|
+
constructor(config) {
|
|
593
|
+
this.store = config.store;
|
|
594
|
+
this.storageAdapter = config.storageAdapter;
|
|
595
|
+
this.cache = new QueryCache();
|
|
596
|
+
this.storeUnsubscribe = this.store.subscribe((event) => {
|
|
597
|
+
this.handleStoreChange(event);
|
|
598
|
+
});
|
|
599
|
+
this.storeBatchUnsubscribe = this.store.subscribeToBatchChanges((event) => {
|
|
600
|
+
this.handleStoreBatchChange(event);
|
|
601
|
+
});
|
|
602
|
+
this._status = "connected";
|
|
603
|
+
}
|
|
604
|
+
// ─── Queries ────────────────────────────────────────────
|
|
605
|
+
query(schema, options) {
|
|
606
|
+
const descriptor = createQueryDescriptor(schema._schemaId, options);
|
|
607
|
+
const queryId = serializeQueryDescriptor(descriptor);
|
|
608
|
+
this.cache.initEntry(queryId, descriptor);
|
|
609
|
+
if (this.cache.get(queryId) === null) {
|
|
610
|
+
void this.loadQuery(queryId, descriptor);
|
|
611
|
+
}
|
|
612
|
+
return {
|
|
613
|
+
getSnapshot: () => this.cache.get(queryId),
|
|
614
|
+
getMetadata: () => this.cache.getMetadata(queryId),
|
|
615
|
+
subscribe: (callback) => this.cache.subscribe(queryId, callback)
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
async reloadQuery(descriptor) {
|
|
619
|
+
await this.loadQuery(serializeQueryDescriptor(descriptor), descriptor);
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Load query data from the store and update cache.
|
|
623
|
+
*/
|
|
624
|
+
async loadQuery(queryId, descriptor) {
|
|
625
|
+
if (this.destroyed) return;
|
|
626
|
+
try {
|
|
627
|
+
const result = await this.store.query(descriptor);
|
|
628
|
+
if (!this.destroyed) {
|
|
629
|
+
this.cache.set(
|
|
630
|
+
queryId,
|
|
631
|
+
result.nodes,
|
|
632
|
+
descriptor,
|
|
633
|
+
void 0,
|
|
634
|
+
createQueryMetadata({ descriptor, result, source: "local" })
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
} catch (err) {
|
|
638
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
639
|
+
console.error("[NativeBridge] Failed to load query:", error);
|
|
640
|
+
if (!this.destroyed) {
|
|
641
|
+
this.cache.set(
|
|
642
|
+
queryId,
|
|
643
|
+
[],
|
|
644
|
+
descriptor,
|
|
645
|
+
void 0,
|
|
646
|
+
createQueryErrorMetadata({ descriptor, source: "local", error })
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Handle store changes and invalidate affected caches.
|
|
653
|
+
*/
|
|
654
|
+
handleStoreChange(event) {
|
|
655
|
+
if (this.destroyed) return;
|
|
656
|
+
const { node, change } = event;
|
|
657
|
+
const schemaId = node?.schemaId ?? change.payload.schemaId;
|
|
658
|
+
if (!schemaId) return;
|
|
659
|
+
for (const entry of this.cache.getEntriesForSchema(schemaId)) {
|
|
660
|
+
if (entry.data === null) {
|
|
661
|
+
void this.loadQuery(entry.queryId, entry.descriptor);
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
const delta = applyNodeChangeToQueryResult({
|
|
665
|
+
descriptor: entry.descriptor,
|
|
666
|
+
currentData: entry.data,
|
|
667
|
+
nodeId: change.payload.nodeId,
|
|
668
|
+
nextNode: node
|
|
669
|
+
});
|
|
670
|
+
if (delta.kind === "reload") {
|
|
671
|
+
void this.loadQuery(entry.queryId, entry.descriptor);
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
if (delta.kind === "set") {
|
|
675
|
+
this.cache.set(entry.queryId, delta.data);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
handleStoreBatchChange(event) {
|
|
680
|
+
if (this.destroyed) return;
|
|
681
|
+
for (const schemaId of event.schemaIds) {
|
|
682
|
+
for (const entry of this.cache.getEntriesForSchema(schemaId)) {
|
|
683
|
+
void this.loadQuery(entry.queryId, entry.descriptor);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// ─── Mutations ──────────────────────────────────────────
|
|
688
|
+
async create(schema, data, id) {
|
|
689
|
+
if (this.destroyed) {
|
|
690
|
+
throw new Error("NativeBridge has been destroyed");
|
|
691
|
+
}
|
|
692
|
+
return this.store.create({
|
|
693
|
+
id,
|
|
694
|
+
schemaId: schema._schemaId,
|
|
695
|
+
properties: data
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
async update(nodeId, changes) {
|
|
699
|
+
if (this.destroyed) {
|
|
700
|
+
throw new Error("NativeBridge has been destroyed");
|
|
701
|
+
}
|
|
702
|
+
return this.store.update(nodeId, { properties: changes });
|
|
703
|
+
}
|
|
704
|
+
async delete(nodeId) {
|
|
705
|
+
if (this.destroyed) {
|
|
706
|
+
throw new Error("NativeBridge has been destroyed");
|
|
707
|
+
}
|
|
708
|
+
await this.store.delete(nodeId);
|
|
709
|
+
}
|
|
710
|
+
async restore(nodeId) {
|
|
711
|
+
if (this.destroyed) {
|
|
712
|
+
throw new Error("NativeBridge has been destroyed");
|
|
713
|
+
}
|
|
714
|
+
return this.store.restore(nodeId);
|
|
715
|
+
}
|
|
716
|
+
async bulkWrite(input) {
|
|
717
|
+
if (this.destroyed) {
|
|
718
|
+
throw new Error("NativeBridge has been destroyed");
|
|
719
|
+
}
|
|
720
|
+
return this.store.batchWrite(input);
|
|
721
|
+
}
|
|
722
|
+
async transaction(operations) {
|
|
723
|
+
if (this.destroyed) {
|
|
724
|
+
throw new Error("NativeBridge has been destroyed");
|
|
725
|
+
}
|
|
726
|
+
const tx = await this.store.transaction(operations);
|
|
727
|
+
return { batchId: tx.batchId, results: tx.results, tempIds: tx.tempIds };
|
|
728
|
+
}
|
|
729
|
+
// ─── Documents ─────────────────────────────────────────
|
|
730
|
+
/**
|
|
731
|
+
* Acquire a Y.Doc for editing.
|
|
732
|
+
*
|
|
733
|
+
* Note: Y.Doc management in React Native is limited compared to web.
|
|
734
|
+
* For now, this throws an error. Future versions will support Y.Doc
|
|
735
|
+
* via native WebSocket sync or JSI bindings.
|
|
736
|
+
*/
|
|
737
|
+
async acquireDoc(_nodeId) {
|
|
738
|
+
throw new Error(
|
|
739
|
+
"Y.Doc editing is not yet supported in NativeBridge. Use a WebView-based editor or wait for Turbo Module support."
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Release a Y.Doc when no longer editing.
|
|
744
|
+
*/
|
|
745
|
+
releaseDoc(_nodeId) {
|
|
746
|
+
}
|
|
747
|
+
// ─── Lifecycle ──────────────────────────────────────────
|
|
748
|
+
/**
|
|
749
|
+
* Initialize the bridge.
|
|
750
|
+
* Opens storage adapter if provided.
|
|
751
|
+
*/
|
|
752
|
+
async initialize(_config) {
|
|
753
|
+
if (this.storageAdapter) {
|
|
754
|
+
await this.storageAdapter.open();
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
destroy() {
|
|
758
|
+
this.destroyed = true;
|
|
759
|
+
if (this.storeUnsubscribe) {
|
|
760
|
+
this.storeUnsubscribe();
|
|
761
|
+
this.storeUnsubscribe = null;
|
|
762
|
+
}
|
|
763
|
+
if (this.storeBatchUnsubscribe) {
|
|
764
|
+
this.storeBatchUnsubscribe();
|
|
765
|
+
this.storeBatchUnsubscribe = null;
|
|
766
|
+
}
|
|
767
|
+
if (this.storageAdapter) {
|
|
768
|
+
this.storageAdapter.close().catch((err) => {
|
|
769
|
+
console.error("[NativeBridge] Failed to close storage:", err);
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
this.cache.clear();
|
|
773
|
+
this.statusListeners.clear();
|
|
774
|
+
this._status = "disconnected";
|
|
775
|
+
}
|
|
776
|
+
// ─── Status ─────────────────────────────────────────────
|
|
777
|
+
get status() {
|
|
778
|
+
return this._status;
|
|
779
|
+
}
|
|
780
|
+
on(event, handler) {
|
|
781
|
+
if (event === "status") {
|
|
782
|
+
this.statusListeners.add(handler);
|
|
783
|
+
return () => {
|
|
784
|
+
this.statusListeners.delete(handler);
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
return () => {
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
setStatus(status) {
|
|
791
|
+
if (this._status !== status) {
|
|
792
|
+
this._status = status;
|
|
793
|
+
for (const listener of this.statusListeners) {
|
|
794
|
+
listener(status);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
// ─── Direct Store Access (compatibility) ────────────────
|
|
799
|
+
get nodeStore() {
|
|
800
|
+
return this.store;
|
|
801
|
+
}
|
|
802
|
+
subscribeToChanges(listener) {
|
|
803
|
+
return this.store.subscribe(listener);
|
|
804
|
+
}
|
|
805
|
+
async get(nodeId) {
|
|
806
|
+
return this.store.get(nodeId);
|
|
807
|
+
}
|
|
808
|
+
async list(options) {
|
|
809
|
+
return this.store.list(options);
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
function createNativeBridge(config) {
|
|
813
|
+
return new NativeBridge(config);
|
|
814
|
+
}
|
|
815
|
+
function isReactNative() {
|
|
816
|
+
return typeof navigator !== "undefined" && navigator.product === "ReactNative";
|
|
817
|
+
}
|
|
818
|
+
function isExpo() {
|
|
819
|
+
return typeof global !== "undefined" && global.expo !== void 0;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
export {
|
|
823
|
+
QueryCache,
|
|
824
|
+
createQueryMetadata,
|
|
825
|
+
createQueryErrorMetadata,
|
|
826
|
+
createQuerySnapshotMetadata,
|
|
827
|
+
NativeBridge,
|
|
828
|
+
createNativeBridge,
|
|
829
|
+
isReactNative,
|
|
830
|
+
isExpo
|
|
831
|
+
};
|