@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.
@@ -0,0 +1,2 @@
1
+
2
+ export { }
@@ -0,0 +1,304 @@
1
+ import {
2
+ QueryCache
3
+ } from "../chunk-X6F5CPJI.js";
4
+
5
+ // src/worker/data-worker.ts
6
+ import {
7
+ NodeStore,
8
+ MemoryNodeStorageAdapter
9
+ } from "@xnetjs/data";
10
+ import { expose, proxy, transfer } from "comlink";
11
+ import * as Y from "yjs";
12
+ var MAX_DOC_POOL_SIZE = 50;
13
+ var MIN_DOC_AGE_FOR_EVICTION = 6e4;
14
+ var DataWorker = class {
15
+ store = null;
16
+ storage = null;
17
+ subscriptions = /* @__PURE__ */ new Map();
18
+ cache = new QueryCache();
19
+ status = "disconnected";
20
+ statusHandlers = /* @__PURE__ */ new Set();
21
+ storeUnsubscribe = null;
22
+ // Y.Doc pool - the "source of truth" for all documents
23
+ docPool = /* @__PURE__ */ new Map();
24
+ // Client ID counter for Y.Doc instances
25
+ nextClientId = Math.floor(Math.random() * 2147483647);
26
+ async initialize(config) {
27
+ this.storage = new MemoryNodeStorageAdapter();
28
+ this.store = new NodeStore({
29
+ storage: this.storage,
30
+ authorDID: config.authorDID,
31
+ signingKey: new Uint8Array(config.signingKey)
32
+ });
33
+ await this.store.initialize();
34
+ this.storeUnsubscribe = this.store.subscribe((event) => {
35
+ this.handleStoreChange(event);
36
+ });
37
+ this.setStatus("connected");
38
+ }
39
+ async subscribe(queryId, schemaId, options, onDelta) {
40
+ if (!this.store) {
41
+ throw new Error("DataWorker not initialized");
42
+ }
43
+ const nodes = await this.loadQuery(schemaId, options);
44
+ this.subscriptions.set(queryId, {
45
+ schemaId,
46
+ options,
47
+ lastResult: nodes,
48
+ onDelta: proxy(onDelta)
49
+ });
50
+ return nodes;
51
+ }
52
+ async unsubscribe(queryId) {
53
+ this.subscriptions.delete(queryId);
54
+ }
55
+ async create(schemaId, data, id) {
56
+ if (!this.store) {
57
+ throw new Error("DataWorker not initialized");
58
+ }
59
+ return this.store.create({
60
+ id,
61
+ schemaId,
62
+ properties: data
63
+ });
64
+ }
65
+ async update(nodeId, changes) {
66
+ if (!this.store) {
67
+ throw new Error("DataWorker not initialized");
68
+ }
69
+ return this.store.update(nodeId, { properties: changes });
70
+ }
71
+ async delete(nodeId) {
72
+ if (!this.store) {
73
+ throw new Error("DataWorker not initialized");
74
+ }
75
+ await this.store.delete(nodeId);
76
+ }
77
+ async restore(nodeId) {
78
+ if (!this.store) {
79
+ throw new Error("DataWorker not initialized");
80
+ }
81
+ return this.store.restore(nodeId);
82
+ }
83
+ async get(nodeId) {
84
+ if (!this.store) {
85
+ throw new Error("DataWorker not initialized");
86
+ }
87
+ return this.store.get(nodeId);
88
+ }
89
+ // ─── Document Operations ────────────────────────────────────────────────────
90
+ async acquireDoc(nodeId, onUpdate) {
91
+ if (!this.storage) {
92
+ throw new Error("DataWorker not initialized");
93
+ }
94
+ let entry = this.docPool.get(nodeId);
95
+ if (entry) {
96
+ entry.lastAccessed = Date.now();
97
+ } else {
98
+ const doc = new Y.Doc({ guid: nodeId, gc: false });
99
+ const storedContent = await this.storage.getDocumentContent(nodeId);
100
+ if (storedContent && storedContent.length > 0) {
101
+ Y.applyUpdate(doc, storedContent, "storage");
102
+ }
103
+ entry = {
104
+ doc,
105
+ refCount: 0,
106
+ updateHandlers: /* @__PURE__ */ new Set(),
107
+ lastAccessed: Date.now()
108
+ };
109
+ this.evictOldDocs();
110
+ doc.on("update", (update, origin) => {
111
+ const content = Y.encodeStateAsUpdate(doc);
112
+ this.storage?.setDocumentContent(nodeId, content).catch((err) => {
113
+ console.error("[DataWorker] Failed to persist doc:", err);
114
+ });
115
+ if (origin === "remote") {
116
+ const handlers = Array.from(entry.updateHandlers);
117
+ for (let i = 0; i < handlers.length; i++) {
118
+ try {
119
+ const isLast = i === handlers.length - 1;
120
+ const updateToSend = isLast ? update : new Uint8Array(update);
121
+ if (isLast && update.buffer.byteLength === update.byteLength) {
122
+ handlers[i](
123
+ transfer(updateToSend, [updateToSend.buffer]),
124
+ "remote"
125
+ );
126
+ } else {
127
+ handlers[i](updateToSend, "remote");
128
+ }
129
+ } catch (err) {
130
+ console.error("[DataWorker] Update handler error:", err);
131
+ }
132
+ }
133
+ }
134
+ });
135
+ this.docPool.set(nodeId, entry);
136
+ }
137
+ const proxiedHandler = proxy(onUpdate);
138
+ entry.updateHandlers.add(proxiedHandler);
139
+ entry.refCount++;
140
+ const state = Y.encodeStateAsUpdate(entry.doc);
141
+ return transfer(
142
+ {
143
+ nodeId,
144
+ state,
145
+ clientId: this.nextClientId++
146
+ },
147
+ [state.buffer]
148
+ );
149
+ }
150
+ releaseDoc(nodeId) {
151
+ const entry = this.docPool.get(nodeId);
152
+ if (!entry) return;
153
+ entry.refCount--;
154
+ }
155
+ applyLocalUpdate(nodeId, update) {
156
+ const entry = this.docPool.get(nodeId);
157
+ if (!entry) {
158
+ console.warn("[DataWorker] applyLocalUpdate: doc not acquired:", nodeId);
159
+ return;
160
+ }
161
+ Y.applyUpdate(entry.doc, update, "local");
162
+ }
163
+ // ─── Status ─────────────────────────────────────────────────────────────────
164
+ getStatus() {
165
+ return this.status;
166
+ }
167
+ onStatusChange(handler) {
168
+ this.statusHandlers.add(proxy(handler));
169
+ }
170
+ async destroy() {
171
+ if (this.storeUnsubscribe) {
172
+ this.storeUnsubscribe();
173
+ this.storeUnsubscribe = null;
174
+ }
175
+ for (const entry of this.docPool.values()) {
176
+ entry.doc.destroy();
177
+ }
178
+ this.docPool.clear();
179
+ if (this.storage?.close) {
180
+ await this.storage.close();
181
+ }
182
+ this.storage = null;
183
+ this.store = null;
184
+ this.subscriptions.clear();
185
+ this.cache.clear();
186
+ this.statusHandlers.clear();
187
+ this.setStatus("disconnected");
188
+ }
189
+ // ─── Private Methods ─────────────────────────────────────────────────────────
190
+ async loadQuery(schemaId, options) {
191
+ if (!this.store) return [];
192
+ let nodes;
193
+ if (options.nodeId) {
194
+ const node = await this.store.get(options.nodeId);
195
+ nodes = node && node.schemaId === schemaId && !node.deleted ? [node] : [];
196
+ } else {
197
+ nodes = await this.store.list({
198
+ schemaId,
199
+ includeDeleted: options.includeDeleted,
200
+ limit: options.limit,
201
+ offset: options.offset
202
+ });
203
+ }
204
+ nodes = this.cache.filterNodes(nodes, options);
205
+ nodes = this.cache.sortNodes(nodes, options);
206
+ return nodes;
207
+ }
208
+ handleStoreChange(event) {
209
+ const { node, change } = event;
210
+ const schemaId = node?.schemaId ?? change.payload.schemaId;
211
+ if (!schemaId) return;
212
+ for (const [queryId, sub] of this.subscriptions) {
213
+ if (sub.schemaId !== schemaId) continue;
214
+ const delta = this.computeDelta(event, sub);
215
+ if (delta) {
216
+ this.applyDeltaToSubscription(queryId, delta);
217
+ sub.onDelta(delta);
218
+ }
219
+ }
220
+ }
221
+ computeDelta(event, sub) {
222
+ const { node, change } = event;
223
+ const passesFilter = node ? this.nodeMatchesFilter(node, sub.options) : false;
224
+ const existingIndex = sub.lastResult.findIndex((n) => n.id === change.payload.nodeId);
225
+ if (existingIndex >= 0) {
226
+ if (!node || node.deleted || !passesFilter) {
227
+ return { type: "remove", nodeId: change.payload.nodeId };
228
+ } else {
229
+ return { type: "update", nodeId: node.id, node };
230
+ }
231
+ } else {
232
+ if (node && !node.deleted && passesFilter) {
233
+ return { type: "add", node, index: sub.lastResult.length };
234
+ }
235
+ }
236
+ return null;
237
+ }
238
+ nodeMatchesFilter(node, options) {
239
+ if (node.deleted && !options.includeDeleted) {
240
+ return false;
241
+ }
242
+ if (options.where) {
243
+ for (const [key, value] of Object.entries(options.where)) {
244
+ if (node.properties[key] !== value) {
245
+ return false;
246
+ }
247
+ }
248
+ }
249
+ return true;
250
+ }
251
+ applyDeltaToSubscription(queryId, delta) {
252
+ const sub = this.subscriptions.get(queryId);
253
+ if (!sub) return;
254
+ switch (delta.type) {
255
+ case "add":
256
+ sub.lastResult = [...sub.lastResult, delta.node];
257
+ break;
258
+ case "remove":
259
+ sub.lastResult = sub.lastResult.filter((n) => n.id !== delta.nodeId);
260
+ break;
261
+ case "update":
262
+ sub.lastResult = sub.lastResult.map((n) => n.id === delta.nodeId ? delta.node : n);
263
+ break;
264
+ }
265
+ }
266
+ setStatus(status) {
267
+ this.status = status;
268
+ for (const handler of this.statusHandlers) {
269
+ handler(status);
270
+ }
271
+ }
272
+ /**
273
+ * Evict unused Y.Docs from the pool to manage memory.
274
+ * Only evicts docs with refCount=0 that haven't been accessed recently.
275
+ */
276
+ evictOldDocs() {
277
+ if (this.docPool.size < MAX_DOC_POOL_SIZE) return;
278
+ const now = Date.now();
279
+ const candidates = [];
280
+ for (const [nodeId, entry] of this.docPool) {
281
+ if (entry.refCount === 0 && now - entry.lastAccessed > MIN_DOC_AGE_FOR_EVICTION) {
282
+ candidates.push({ nodeId, lastAccessed: entry.lastAccessed });
283
+ }
284
+ }
285
+ if (candidates.length === 0) return;
286
+ candidates.sort((a, b) => a.lastAccessed - b.lastAccessed);
287
+ const targetSize = Math.floor(MAX_DOC_POOL_SIZE * 0.8);
288
+ const toEvict = this.docPool.size - targetSize;
289
+ for (let i = 0; i < Math.min(toEvict, candidates.length); i++) {
290
+ const nodeId = candidates[i].nodeId;
291
+ const entry = this.docPool.get(nodeId);
292
+ if (entry) {
293
+ if (this.storage) {
294
+ const content = Y.encodeStateAsUpdate(entry.doc);
295
+ this.storage.setDocumentContent(nodeId, content).catch(() => {
296
+ });
297
+ }
298
+ entry.doc.destroy();
299
+ this.docPool.delete(nodeId);
300
+ }
301
+ }
302
+ }
303
+ };
304
+ expose(new DataWorker());
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@xnetjs/data-bridge",
3
+ "version": "0.0.2",
4
+ "description": "DataBridge abstraction for off-main-thread data access in xNet",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/crs48/xNet"
9
+ },
10
+ "type": "module",
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "import": "./dist/index.js",
16
+ "types": "./dist/index.d.ts"
17
+ },
18
+ "./worker": {
19
+ "import": "./dist/worker/data-worker.js",
20
+ "types": "./dist/worker/data-worker.d.ts"
21
+ }
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "provenance": true
31
+ },
32
+ "dependencies": {
33
+ "comlink": "^4.4.2",
34
+ "y-protocols": "^1.0.6",
35
+ "yjs": "^13.6.24",
36
+ "@xnetjs/core": "0.0.2",
37
+ "@xnetjs/data": "0.0.2"
38
+ },
39
+ "devDependencies": {
40
+ "tsup": "^8.0.0",
41
+ "typescript": "^5.4.0",
42
+ "vitest": "^4.0.0",
43
+ "@xnetjs/crypto": "0.0.2",
44
+ "@xnetjs/identity": "0.0.2"
45
+ },
46
+ "scripts": {
47
+ "build": "tsup src/index.ts src/worker/data-worker.ts --format esm --dts",
48
+ "test": "vitest run",
49
+ "test:watch": "vitest",
50
+ "typecheck": "tsc --noEmit",
51
+ "clean": "rm -rf dist"
52
+ }
53
+ }