@xnetjs/runtime 0.4.0 → 0.5.1
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/dist/index.d.ts +36 -1
- package/dist/index.js +56 -6
- package/package.json +10 -10
package/dist/index.d.ts
CHANGED
|
@@ -164,6 +164,19 @@ interface SyncManagerConfig {
|
|
|
164
164
|
onDocEvict?: (nodeId: string, doc: Y.Doc) => void;
|
|
165
165
|
/** Optional room for node-change relay (enables NodeStore sync via hub) */
|
|
166
166
|
nodeSyncRoom?: string;
|
|
167
|
+
/**
|
|
168
|
+
* Production Yjs history capture (exploration 0329): wired to
|
|
169
|
+
* `DocumentHistoryEngine` (duck-typed to avoid a hard coupling). Steady
|
|
170
|
+
* debounce persists go through the engine's own min-interval throttle
|
|
171
|
+
* (`captureSnapshot`); session boundaries (evict/flush/destroy) force a
|
|
172
|
+
* capture so a doc never leaves memory without a restorable snapshot.
|
|
173
|
+
*/
|
|
174
|
+
documentHistory?: DocumentHistoryCapture;
|
|
175
|
+
}
|
|
176
|
+
/** Duck-typed slice of DocumentHistoryEngine used for capture wiring. */
|
|
177
|
+
interface DocumentHistoryCapture {
|
|
178
|
+
captureSnapshot(nodeId: string, doc: Y.Doc): Promise<unknown>;
|
|
179
|
+
forceCapture(nodeId: string, doc: Y.Doc): Promise<unknown>;
|
|
167
180
|
}
|
|
168
181
|
type SyncReconciliationOptions = {
|
|
169
182
|
/** Optional node subset. Defaults to all joined rooms. */
|
|
@@ -716,6 +729,14 @@ interface NodePoolConfig {
|
|
|
716
729
|
onDocUpdate?: (nodeId: string, doc: Y.Doc) => void;
|
|
717
730
|
/** Optional callback before a doc is evicted */
|
|
718
731
|
onDocEvict?: (nodeId: string, doc: Y.Doc) => void;
|
|
732
|
+
/**
|
|
733
|
+
* Fired after a doc's state was successfully persisted to `yjs_state`
|
|
734
|
+
* (exploration 0329: the production Yjs history capture hook). `context`
|
|
735
|
+
* distinguishes the steady-state debounce from session boundaries
|
|
736
|
+
* (evict/flush/destroy) so capture policies can throttle the former and
|
|
737
|
+
* force the latter. Must never throw — persist correctness wins.
|
|
738
|
+
*/
|
|
739
|
+
onDocPersist?: (nodeId: string, doc: Y.Doc, context: 'debounce' | 'evict' | 'flush' | 'destroy') => void;
|
|
719
740
|
/**
|
|
720
741
|
* Predicate marking a node's Y.Doc as **ephemeral** — never persisted to
|
|
721
742
|
* `yjs_state` and never cold-loaded from it. Workspace presence is the
|
|
@@ -997,6 +1018,13 @@ declare class NodeStoreSyncProvider {
|
|
|
997
1018
|
private store;
|
|
998
1019
|
private room;
|
|
999
1020
|
private subscribeOnly;
|
|
1021
|
+
/**
|
|
1022
|
+
* Outbound exclusion (exploration 0329): return false to keep a change
|
|
1023
|
+
* out of this room entirely (live subscribe AND cursor backfill both
|
|
1024
|
+
* funnel through `enqueueChange`). Used to keep device-local draft
|
|
1025
|
+
* clones out of the personal node-sync room.
|
|
1026
|
+
*/
|
|
1027
|
+
private shouldPublish?;
|
|
1000
1028
|
/** Confirmed, persisted high-water mark (advanced from the hub's response). */
|
|
1001
1029
|
private lastSyncedLamport;
|
|
1002
1030
|
/** Optimistic in-memory cursor: lamport of the last change actually sent. */
|
|
@@ -1026,7 +1054,14 @@ declare class NodeStoreSyncProvider {
|
|
|
1026
1054
|
* sync-response high-water mark, and author lamports across members are not
|
|
1027
1055
|
* mutually ordered.
|
|
1028
1056
|
*/
|
|
1029
|
-
constructor(store: NodeStore, room: string, subscribeOnly?: boolean
|
|
1057
|
+
constructor(store: NodeStore, room: string, subscribeOnly?: boolean,
|
|
1058
|
+
/**
|
|
1059
|
+
* Outbound exclusion (exploration 0329): return false to keep a change
|
|
1060
|
+
* out of this room entirely (live subscribe AND cursor backfill both
|
|
1061
|
+
* funnel through `enqueueChange`). Used to keep device-local draft
|
|
1062
|
+
* clones out of the personal node-sync room.
|
|
1063
|
+
*/
|
|
1064
|
+
shouldPublish?: ((change: NodeChange) => boolean) | undefined);
|
|
1030
1065
|
/**
|
|
1031
1066
|
* Mark the first remote apply once. Platform-agnostic and defensive: a
|
|
1032
1067
|
* missing `performance` global, or a throw, is a no-op — instrumentation
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
import { getSigningPublicKeyFromPrivate, sign, verify } from "@xnetjs/crypto";
|
|
3
3
|
import { MemoryNodeStorageAdapter, NodeStore } from "@xnetjs/data";
|
|
4
4
|
import { createMainThreadBridgeSync } from "@xnetjs/data-bridge";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
DocumentHistoryEngine,
|
|
7
|
+
UndoManager,
|
|
8
|
+
rehydrateDraftPrivacy
|
|
9
|
+
} from "@xnetjs/history";
|
|
6
10
|
import { PluginRegistry } from "@xnetjs/plugins";
|
|
7
11
|
|
|
8
12
|
// src/sync/sync-manager.ts
|
|
@@ -750,6 +754,13 @@ function createNodePool(config) {
|
|
|
750
754
|
const isEphemeral = config.isEphemeral ?? defaultIsEphemeral;
|
|
751
755
|
const largeDocWarnBytes = config.largeDocWarnBytes ?? DEFAULT_LARGE_DOC_WARN_BYTES;
|
|
752
756
|
let firstAcquireMarked = false;
|
|
757
|
+
function notifyPersist(nodeId, doc, context) {
|
|
758
|
+
try {
|
|
759
|
+
config.onDocPersist?.(nodeId, doc, context);
|
|
760
|
+
} catch (err) {
|
|
761
|
+
console.warn(`[NodePool] onDocPersist hook failed for ${nodeId}:`, err);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
753
764
|
function markFirstAcquire() {
|
|
754
765
|
if (firstAcquireMarked) return;
|
|
755
766
|
firstAcquireMarked = true;
|
|
@@ -795,6 +806,7 @@ function createNodePool(config) {
|
|
|
795
806
|
const content = Y.encodeStateAsUpdate(entry.doc);
|
|
796
807
|
await config.storage.setDocumentContent(nodeId, content);
|
|
797
808
|
entry.dirty = false;
|
|
809
|
+
notifyPersist(nodeId, entry.doc, "debounce");
|
|
798
810
|
}
|
|
799
811
|
}, persistDelay)
|
|
800
812
|
);
|
|
@@ -822,6 +834,7 @@ function createNodePool(config) {
|
|
|
822
834
|
);
|
|
823
835
|
}
|
|
824
836
|
await config.storage.setDocumentContent(id, content);
|
|
837
|
+
notifyPersist(id, entry.doc, "evict");
|
|
825
838
|
}
|
|
826
839
|
} catch (err) {
|
|
827
840
|
console.error(`[NodePool] Failed to persist document ${id} during eviction:`, err);
|
|
@@ -898,6 +911,7 @@ function createNodePool(config) {
|
|
|
898
911
|
promises.push(
|
|
899
912
|
config.storage.setDocumentContent(id, content).then(() => {
|
|
900
913
|
entry.dirty = false;
|
|
914
|
+
notifyPersist(id, entry.doc, "flush");
|
|
901
915
|
})
|
|
902
916
|
);
|
|
903
917
|
}
|
|
@@ -944,10 +958,11 @@ var NodeStoreSyncProvider = class {
|
|
|
944
958
|
* sync-response high-water mark, and author lamports across members are not
|
|
945
959
|
* mutually ordered.
|
|
946
960
|
*/
|
|
947
|
-
constructor(store, room, subscribeOnly = false) {
|
|
961
|
+
constructor(store, room, subscribeOnly = false, shouldPublish) {
|
|
948
962
|
this.store = store;
|
|
949
963
|
this.room = room;
|
|
950
964
|
this.subscribeOnly = subscribeOnly;
|
|
965
|
+
this.shouldPublish = shouldPublish;
|
|
951
966
|
}
|
|
952
967
|
/** Confirmed, persisted high-water mark (advanced from the hub's response). */
|
|
953
968
|
lastSyncedLamport = 0;
|
|
@@ -1311,6 +1326,7 @@ var NodeStoreSyncProvider = class {
|
|
|
1311
1326
|
/** Queue a change for throttled broadcast (deduped by hash). */
|
|
1312
1327
|
enqueueChange(change) {
|
|
1313
1328
|
if (this.outboundHalted) return;
|
|
1329
|
+
if (this.shouldPublish && !this.shouldPublish(change)) return;
|
|
1314
1330
|
if (change.lamport <= this.pushedThrough) return;
|
|
1315
1331
|
if (this.queuedHashes.has(change.hash)) return;
|
|
1316
1332
|
this.queuedHashes.add(change.hash);
|
|
@@ -1701,7 +1717,7 @@ function hasEnvelope(data) {
|
|
|
1701
1717
|
function extractBlobCids(doc) {
|
|
1702
1718
|
const cids = [];
|
|
1703
1719
|
const seen = /* @__PURE__ */ new Set();
|
|
1704
|
-
for (const name of ["default", "prosemirror", ""]) {
|
|
1720
|
+
for (const name of ["content-v4", "content", "default", "prosemirror", ""]) {
|
|
1705
1721
|
let fragment;
|
|
1706
1722
|
try {
|
|
1707
1723
|
fragment = doc.getXmlFragment(name);
|
|
@@ -1775,7 +1791,14 @@ function createSyncManager(config) {
|
|
|
1775
1791
|
onDocEvict: (nodeId, doc) => {
|
|
1776
1792
|
broadcastDocs.delete(nodeId);
|
|
1777
1793
|
config.onDocEvict?.(nodeId, doc);
|
|
1778
|
-
}
|
|
1794
|
+
},
|
|
1795
|
+
onDocPersist: config.documentHistory ? (nodeId, doc, context) => {
|
|
1796
|
+
const history = config.documentHistory;
|
|
1797
|
+
const capture = context === "debounce" ? history.captureSnapshot(nodeId, doc) : history.forceCapture(nodeId, doc);
|
|
1798
|
+
void capture.catch(
|
|
1799
|
+
(err) => console.warn(`[SyncManager] Yjs snapshot capture failed for ${nodeId}:`, err)
|
|
1800
|
+
);
|
|
1801
|
+
} : void 0
|
|
1779
1802
|
});
|
|
1780
1803
|
const registry = createRegistry({
|
|
1781
1804
|
storage: createRegistryStorageAdapter(config.storage),
|
|
@@ -1841,7 +1864,14 @@ function createSyncManager(config) {
|
|
|
1841
1864
|
storage: config.storage
|
|
1842
1865
|
});
|
|
1843
1866
|
let offlineQueueReady = Promise.resolve();
|
|
1844
|
-
const nodeSyncProvider = config.nodeSyncRoom ? new NodeStoreSyncProvider(
|
|
1867
|
+
const nodeSyncProvider = config.nodeSyncRoom ? new NodeStoreSyncProvider(
|
|
1868
|
+
config.nodeStore,
|
|
1869
|
+
config.nodeSyncRoom,
|
|
1870
|
+
false,
|
|
1871
|
+
// Draft privacy (0329): device-local draft nodes/clones never publish
|
|
1872
|
+
// to the personal node-sync room.
|
|
1873
|
+
(change) => !config.nodeStore.isDraftPrivate(change.payload.nodeId)
|
|
1874
|
+
) : null;
|
|
1845
1875
|
const shareRoomProviders = /* @__PURE__ */ new Map();
|
|
1846
1876
|
let blobSync = null;
|
|
1847
1877
|
if (config.blobStore) {
|
|
@@ -2530,6 +2560,15 @@ function buildNodeStore(options, storage) {
|
|
|
2530
2560
|
telemetry: options.telemetry
|
|
2531
2561
|
});
|
|
2532
2562
|
}
|
|
2563
|
+
function buildDocumentHistory(storage) {
|
|
2564
|
+
const snapshotStorage = storage;
|
|
2565
|
+
if (typeof snapshotStorage.saveYjsSnapshot !== "function" || typeof snapshotStorage.getYjsSnapshots !== "function" || typeof snapshotStorage.deleteYjsSnapshots !== "function") {
|
|
2566
|
+
return void 0;
|
|
2567
|
+
}
|
|
2568
|
+
return new DocumentHistoryEngine(snapshotStorage, {
|
|
2569
|
+
pins: storage.pins
|
|
2570
|
+
});
|
|
2571
|
+
}
|
|
2533
2572
|
function buildSyncManager(store, storage, options) {
|
|
2534
2573
|
const sync = options.sync;
|
|
2535
2574
|
if (!sync) return null;
|
|
@@ -2537,6 +2576,7 @@ function buildSyncManager(store, storage, options) {
|
|
|
2537
2576
|
return createSyncManager({
|
|
2538
2577
|
nodeStore: store,
|
|
2539
2578
|
storage,
|
|
2579
|
+
documentHistory: buildDocumentHistory(storage),
|
|
2540
2580
|
signalingUrl,
|
|
2541
2581
|
authorDID: options.authorDID,
|
|
2542
2582
|
signingKey: options.signingKey,
|
|
@@ -2558,6 +2598,14 @@ function startSyncManager(syncManager, bridge, sync, telemetry) {
|
|
|
2558
2598
|
if (sync.autoStart === false) return;
|
|
2559
2599
|
syncManager.start().catch((err) => reportCrash(telemetry, err, "runtime.syncManager.start"));
|
|
2560
2600
|
}
|
|
2601
|
+
async function startSyncManagerWithDraftPrivacy(store, syncManager, bridge, sync, telemetry) {
|
|
2602
|
+
try {
|
|
2603
|
+
await rehydrateDraftPrivacy(store);
|
|
2604
|
+
} catch (err) {
|
|
2605
|
+
reportCrash(telemetry, err, "runtime.drafts.rehydratePrivacy");
|
|
2606
|
+
}
|
|
2607
|
+
startSyncManager(syncManager, bridge, sync, telemetry);
|
|
2608
|
+
}
|
|
2561
2609
|
function buildPlugins(store, options) {
|
|
2562
2610
|
const plugins = options.plugins;
|
|
2563
2611
|
if (!plugins) return null;
|
|
@@ -2601,7 +2649,9 @@ async function createXNetClient(options) {
|
|
|
2601
2649
|
const bridgeCreatedInternally = !dataBridge;
|
|
2602
2650
|
const bridge = dataBridge ?? createMainThreadBridgeSync(store, bridgeOptions);
|
|
2603
2651
|
const syncManager = buildSyncManager(store, storage, options);
|
|
2604
|
-
if (syncManager && sync)
|
|
2652
|
+
if (syncManager && sync) {
|
|
2653
|
+
void startSyncManagerWithDraftPrivacy(store, syncManager, bridge, sync, telemetry);
|
|
2654
|
+
}
|
|
2605
2655
|
const pluginRegistry = buildPlugins(store, options);
|
|
2606
2656
|
const undoManager = buildUndo(store, options);
|
|
2607
2657
|
const publicKey = getSigningPublicKeyFromPrivate(signingKey);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xnetjs/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Framework-agnostic xNet runtime: createXNetClient() and sync orchestration, usable from any framework, a CLI, a worker, or a Node service",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -28,15 +28,15 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"y-protocols": "^1.0.6",
|
|
30
30
|
"yjs": "^13.6.24",
|
|
31
|
-
"@xnetjs/core": "1.0
|
|
32
|
-
"@xnetjs/crypto": "1.0
|
|
33
|
-
"@xnetjs/data": "1.0
|
|
34
|
-
"@xnetjs/data-bridge": "1.0
|
|
35
|
-
"@xnetjs/history": "1.0
|
|
36
|
-
"@xnetjs/identity": "1.0
|
|
37
|
-
"@xnetjs/plugins": "1.0
|
|
38
|
-
"@xnetjs/storage": "1.0
|
|
39
|
-
"@xnetjs/sync": "1.0
|
|
31
|
+
"@xnetjs/core": "2.1.0",
|
|
32
|
+
"@xnetjs/crypto": "2.1.0",
|
|
33
|
+
"@xnetjs/data": "2.1.0",
|
|
34
|
+
"@xnetjs/data-bridge": "2.1.0",
|
|
35
|
+
"@xnetjs/history": "2.1.0",
|
|
36
|
+
"@xnetjs/identity": "2.1.0",
|
|
37
|
+
"@xnetjs/plugins": "2.1.0",
|
|
38
|
+
"@xnetjs/storage": "2.1.0",
|
|
39
|
+
"@xnetjs/sync": "2.1.0"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"jsdom": "^26.0.0",
|