@mengine/sync 0.1.0
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 +221 -0
- package/dist/index.js +660 -0
- package/package.json +48 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { LoroDoc } from "loro-crdt";
|
|
2
|
+
import { Disposable } from "@mengine/utils";
|
|
3
|
+
import { DocStorage } from "@mengine/storage";
|
|
4
|
+
|
|
5
|
+
//#region src/sync/api.d.ts
|
|
6
|
+
interface SynchronizingDocState {
|
|
7
|
+
docId: string;
|
|
8
|
+
syncing: boolean;
|
|
9
|
+
retrying: boolean;
|
|
10
|
+
synced: boolean;
|
|
11
|
+
error: string | null;
|
|
12
|
+
}
|
|
13
|
+
interface SynchronizerState {
|
|
14
|
+
syncing: boolean;
|
|
15
|
+
retrying: boolean;
|
|
16
|
+
error: string | null;
|
|
17
|
+
}
|
|
18
|
+
interface Synchronizer {
|
|
19
|
+
/**
|
|
20
|
+
* Start automatically updates synchronization.
|
|
21
|
+
*/
|
|
22
|
+
start(): void;
|
|
23
|
+
/**
|
|
24
|
+
* Stop automatically updates synchronization.
|
|
25
|
+
*/
|
|
26
|
+
stop(): void;
|
|
27
|
+
/**
|
|
28
|
+
* Get the state of the synchronizer.
|
|
29
|
+
*/
|
|
30
|
+
getState(): SynchronizerState;
|
|
31
|
+
/**
|
|
32
|
+
* Subscribe to the state changes of the synchronizer.
|
|
33
|
+
*/
|
|
34
|
+
onStateChange(callback: (state: SynchronizerState) => void): () => void;
|
|
35
|
+
/**
|
|
36
|
+
* Get the state of a document.
|
|
37
|
+
*/
|
|
38
|
+
getDocState(docId: string): SynchronizingDocState;
|
|
39
|
+
/**
|
|
40
|
+
* Subscribe to the state changes of a document.
|
|
41
|
+
*/
|
|
42
|
+
onDocStateChange(docId: string, callback: (state: SynchronizingDocState) => void): () => void;
|
|
43
|
+
/**
|
|
44
|
+
* Add a document synchronization job delegation to the Synchronizer.
|
|
45
|
+
*/
|
|
46
|
+
connectDoc(docId: string, doc: LoroDoc): void;
|
|
47
|
+
/**
|
|
48
|
+
* Remove a document synchronization job delegation from the Synchronizer.
|
|
49
|
+
*/
|
|
50
|
+
disconnectDoc(docId: string): void;
|
|
51
|
+
/**
|
|
52
|
+
* Request a one-off sync for a connected document.
|
|
53
|
+
*
|
|
54
|
+
* The apply path imports single updates that may arrive out of causal order;
|
|
55
|
+
* Loro buffers such an update as `pending` and resolves it only once its
|
|
56
|
+
* missing dependency arrives. Nothing on the apply path fetches that
|
|
57
|
+
* dependency, so a lone out-of-order update would stay pending indefinitely.
|
|
58
|
+
* Callers invoke this after observing a pending import to schedule a
|
|
59
|
+
* version-vector diff that pulls the missing causal closure. No-ops if the
|
|
60
|
+
* document is not connected.
|
|
61
|
+
*/
|
|
62
|
+
requestSync(docId: string): void;
|
|
63
|
+
}
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/sync/synchronizers/client-server.d.ts
|
|
66
|
+
declare class ClientServerSynchronizer implements Synchronizer {
|
|
67
|
+
private readonly local;
|
|
68
|
+
private readonly server;
|
|
69
|
+
readonly id: string;
|
|
70
|
+
private readonly event;
|
|
71
|
+
private abort;
|
|
72
|
+
private docs;
|
|
73
|
+
/**
|
|
74
|
+
* Signal fired whenever a new job is scheduled. The connect cycle blocks on
|
|
75
|
+
* this promise after draining the job queue so the loop does not spin while
|
|
76
|
+
* there is nothing to do.
|
|
77
|
+
*/
|
|
78
|
+
private wakeUp;
|
|
79
|
+
private status;
|
|
80
|
+
constructor(local: DocStorage, server: DocStorage);
|
|
81
|
+
start(): void;
|
|
82
|
+
stop(): void;
|
|
83
|
+
getState(): SynchronizerState;
|
|
84
|
+
onStateChange(callback: (state: SynchronizerState) => void): () => void;
|
|
85
|
+
getDocState(docId: string): SynchronizingDocState;
|
|
86
|
+
onDocStateChange(docId: string, callback: (state: SynchronizingDocState) => void): () => void;
|
|
87
|
+
connectDoc(docId: string, doc: LoroDoc): void;
|
|
88
|
+
disconnectDoc(docId: string): void;
|
|
89
|
+
requestSync(docId: string): void;
|
|
90
|
+
private mainLoop;
|
|
91
|
+
private connectLoop;
|
|
92
|
+
private schedule;
|
|
93
|
+
/** Returns true when at least one watched doc has pending jobs. */
|
|
94
|
+
private hasPendingJobs;
|
|
95
|
+
/**
|
|
96
|
+
* Fire any waiter currently parked on the wake up signal and install a fresh
|
|
97
|
+
* one for the next round of waiters.
|
|
98
|
+
*/
|
|
99
|
+
private wake;
|
|
100
|
+
/**
|
|
101
|
+
* Resolve when either a new job arrives or the cycle is aborted. Short
|
|
102
|
+
* circuits synchronously if jobs are already pending so we never miss a
|
|
103
|
+
* push that happened between draining and re-arming the waiter.
|
|
104
|
+
*/
|
|
105
|
+
private waitForJobs;
|
|
106
|
+
private consumeJobs;
|
|
107
|
+
private updateStatus;
|
|
108
|
+
private waitConnectionReady;
|
|
109
|
+
private pushUpdatesToRemote;
|
|
110
|
+
private pullUpdatesFromRemote;
|
|
111
|
+
private saveDocUpdates;
|
|
112
|
+
private syncWithRemote;
|
|
113
|
+
}
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/sync/synchronizers/dummy.d.ts
|
|
116
|
+
declare class DummySynchronizer implements Synchronizer {
|
|
117
|
+
getState(): SynchronizerState;
|
|
118
|
+
onStateChange(_callback: (state: SynchronizerState) => void): () => void;
|
|
119
|
+
start(): void;
|
|
120
|
+
stop(): void;
|
|
121
|
+
getDocState(docId: string): SynchronizingDocState;
|
|
122
|
+
onDocStateChange(_docId: string, _callback: (state: SynchronizingDocState) => void): () => void;
|
|
123
|
+
connectDoc(_docId: string, _doc: LoroDoc): void;
|
|
124
|
+
disconnectDoc(_docId: string): void;
|
|
125
|
+
requestSync(_docId: string): void;
|
|
126
|
+
}
|
|
127
|
+
//#endregion
|
|
128
|
+
//#region src/manager/job.d.ts
|
|
129
|
+
type ManagerJobs = {
|
|
130
|
+
type: 'apply';
|
|
131
|
+
payload: {
|
|
132
|
+
update: Uint8Array;
|
|
133
|
+
};
|
|
134
|
+
} | {
|
|
135
|
+
type: 'load';
|
|
136
|
+
payload: null;
|
|
137
|
+
} | {
|
|
138
|
+
type: 'save';
|
|
139
|
+
payload: {
|
|
140
|
+
update: Uint8Array;
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
declare class ManagerJob<T extends ManagerJobs['type'] = ManagerJobs['type']> {
|
|
144
|
+
readonly type: T;
|
|
145
|
+
readonly payload: Extract<ManagerJobs, {
|
|
146
|
+
type: T;
|
|
147
|
+
}>['payload'];
|
|
148
|
+
constructor(type: T, payload: Extract<ManagerJobs, {
|
|
149
|
+
type: T;
|
|
150
|
+
}>['payload']);
|
|
151
|
+
static apply(update: Uint8Array): ManagerJob<"apply">;
|
|
152
|
+
static load(): ManagerJob<"load">;
|
|
153
|
+
static save(update: Uint8Array): ManagerJob<"save">;
|
|
154
|
+
}
|
|
155
|
+
//#endregion
|
|
156
|
+
//#region src/manager/doc.d.ts
|
|
157
|
+
interface ManagedDocState {
|
|
158
|
+
docId: string;
|
|
159
|
+
loaded: boolean;
|
|
160
|
+
error: string | null;
|
|
161
|
+
}
|
|
162
|
+
declare class ManagedDoc extends Disposable {
|
|
163
|
+
readonly docId: string;
|
|
164
|
+
readonly doc: LoroDoc;
|
|
165
|
+
readonly state: ManagedDocState;
|
|
166
|
+
readonly jobs: ManagerJob[];
|
|
167
|
+
private readonly eventBus;
|
|
168
|
+
private readonly disposables;
|
|
169
|
+
constructor(docId: string, doc: LoroDoc);
|
|
170
|
+
dispose(): void;
|
|
171
|
+
setState(state: Partial<ManagedDocState>): void;
|
|
172
|
+
onStateChanged(callback: (state: ManagedDocState) => void): () => void;
|
|
173
|
+
onDocChanged(callback: (update: Uint8Array) => void): () => void;
|
|
174
|
+
}
|
|
175
|
+
//#endregion
|
|
176
|
+
//#region src/manager/index.d.ts
|
|
177
|
+
type DocState = ManagedDocState & SynchronizingDocState;
|
|
178
|
+
declare class DocManager {
|
|
179
|
+
readonly storage: DocStorage;
|
|
180
|
+
readonly synchronizer: Synchronizer;
|
|
181
|
+
readonly origin: string;
|
|
182
|
+
readonly docs: Map<string, ManagedDoc>;
|
|
183
|
+
private abort;
|
|
184
|
+
private event;
|
|
185
|
+
private wakeUp;
|
|
186
|
+
constructor(storage: DocStorage, synchronizer: Synchronizer);
|
|
187
|
+
start(): void;
|
|
188
|
+
stop(): void;
|
|
189
|
+
connectDoc(id: string): LoroDoc;
|
|
190
|
+
disconnectDoc(id: string): void;
|
|
191
|
+
getDocState(docId: string): DocState;
|
|
192
|
+
onDocStateChange(docId: string, callback: (state: DocState) => void): () => void;
|
|
193
|
+
private getManagedDoc;
|
|
194
|
+
private mainLoop;
|
|
195
|
+
private bindDoc;
|
|
196
|
+
private wake;
|
|
197
|
+
private schedule;
|
|
198
|
+
private hasPendingJobs;
|
|
199
|
+
private waitForJobs;
|
|
200
|
+
private consumeJobs;
|
|
201
|
+
/**
|
|
202
|
+
* Handling [load] jobs.
|
|
203
|
+
*
|
|
204
|
+
* The [load] jobs only come from new doc managed by [DocManager].
|
|
205
|
+
*/
|
|
206
|
+
private loadDoc;
|
|
207
|
+
/**
|
|
208
|
+
* Handling [save] jobs.
|
|
209
|
+
*
|
|
210
|
+
* The [save] jobs only come from local changes.
|
|
211
|
+
*/
|
|
212
|
+
private saveDoc;
|
|
213
|
+
/**
|
|
214
|
+
* Handling [apply] jobs.
|
|
215
|
+
*
|
|
216
|
+
* The [apply] jobs only come from local storage changes(cross-tab updates, server-side updates, etc.).
|
|
217
|
+
*/
|
|
218
|
+
private applyDocUpdate;
|
|
219
|
+
}
|
|
220
|
+
//#endregion
|
|
221
|
+
export { ClientServerSynchronizer, DocManager, DocState, DummySynchronizer, Synchronizer, SynchronizerState, SynchronizingDocState };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
import { remove } from "lodash-es";
|
|
2
|
+
import { LoroDoc, VersionVector } from "loro-crdt";
|
|
3
|
+
import { nanoid } from "nanoid";
|
|
4
|
+
import { Disposable, DisposableSet, EventBus, MANUALLY_STOP, Task, runWithCheckpoint } from "@mengine/utils";
|
|
5
|
+
//#region src/utils.ts
|
|
6
|
+
function createWakeUpSignal() {
|
|
7
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
8
|
+
return {
|
|
9
|
+
promise,
|
|
10
|
+
wake: resolve
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/manager/job.ts
|
|
15
|
+
var ManagerJob = class ManagerJob {
|
|
16
|
+
type;
|
|
17
|
+
payload;
|
|
18
|
+
constructor(type, payload) {
|
|
19
|
+
this.type = type;
|
|
20
|
+
this.payload = payload;
|
|
21
|
+
}
|
|
22
|
+
static apply(update) {
|
|
23
|
+
return new ManagerJob("apply", { update });
|
|
24
|
+
}
|
|
25
|
+
static load() {
|
|
26
|
+
return new ManagerJob("load", null);
|
|
27
|
+
}
|
|
28
|
+
static save(update) {
|
|
29
|
+
return new ManagerJob("save", { update });
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/manager/doc.ts
|
|
34
|
+
var ManagedDoc = class extends Disposable {
|
|
35
|
+
docId;
|
|
36
|
+
doc;
|
|
37
|
+
state;
|
|
38
|
+
jobs = [];
|
|
39
|
+
eventBus = new EventBus();
|
|
40
|
+
disposables = new DisposableSet();
|
|
41
|
+
constructor(docId, doc) {
|
|
42
|
+
super();
|
|
43
|
+
this.docId = docId;
|
|
44
|
+
this.doc = doc;
|
|
45
|
+
this.state = {
|
|
46
|
+
docId: this.docId,
|
|
47
|
+
loaded: false,
|
|
48
|
+
error: null
|
|
49
|
+
};
|
|
50
|
+
this.disposables.add(() => {
|
|
51
|
+
this.jobs.splice(0, this.jobs.length);
|
|
52
|
+
});
|
|
53
|
+
this.disposables.add(this.doc.subscribeLocalUpdates((update) => {
|
|
54
|
+
this.eventBus.emit("docChanged", { update });
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
dispose() {
|
|
58
|
+
this.disposables.dispose();
|
|
59
|
+
}
|
|
60
|
+
setState(state) {
|
|
61
|
+
Object.assign(this.state, state);
|
|
62
|
+
this.eventBus.emit("stateChanged", { state: this.state });
|
|
63
|
+
}
|
|
64
|
+
onStateChanged(callback) {
|
|
65
|
+
const off = this.eventBus.on("stateChanged", ({ state }) => {
|
|
66
|
+
callback(state);
|
|
67
|
+
});
|
|
68
|
+
this.disposables.add(off);
|
|
69
|
+
return off;
|
|
70
|
+
}
|
|
71
|
+
onDocChanged(callback) {
|
|
72
|
+
const off = this.eventBus.on("docChanged", ({ update }) => {
|
|
73
|
+
callback(update);
|
|
74
|
+
});
|
|
75
|
+
this.disposables.add(off);
|
|
76
|
+
return off;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/manager/index.ts
|
|
81
|
+
const hasUpdateData$1 = (update) => {
|
|
82
|
+
return update.byteLength > 0;
|
|
83
|
+
};
|
|
84
|
+
var DocManager = class {
|
|
85
|
+
storage;
|
|
86
|
+
synchronizer;
|
|
87
|
+
origin = `DocManager:${nanoid(10)}`;
|
|
88
|
+
docs = /* @__PURE__ */ new Map();
|
|
89
|
+
abort = null;
|
|
90
|
+
event = new EventBus();
|
|
91
|
+
wakeUp = createWakeUpSignal();
|
|
92
|
+
constructor(storage, synchronizer) {
|
|
93
|
+
this.storage = storage;
|
|
94
|
+
this.synchronizer = synchronizer;
|
|
95
|
+
}
|
|
96
|
+
start() {
|
|
97
|
+
if (this.abort) return;
|
|
98
|
+
this.abort = new AbortController();
|
|
99
|
+
this.mainLoop(this.abort.signal).catch((e) => {
|
|
100
|
+
if (e === MANUALLY_STOP) return;
|
|
101
|
+
console.error(e);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
stop() {
|
|
105
|
+
this.abort?.abort(MANUALLY_STOP);
|
|
106
|
+
this.abort = null;
|
|
107
|
+
}
|
|
108
|
+
connectDoc(id) {
|
|
109
|
+
let managedDoc = this.docs.get(id);
|
|
110
|
+
if (!managedDoc) {
|
|
111
|
+
managedDoc = new ManagedDoc(id, new LoroDoc());
|
|
112
|
+
this.docs.set(id, managedDoc);
|
|
113
|
+
this.bindDoc(managedDoc);
|
|
114
|
+
}
|
|
115
|
+
return managedDoc.doc;
|
|
116
|
+
}
|
|
117
|
+
disconnectDoc(id) {
|
|
118
|
+
const managedDoc = this.getManagedDoc(id);
|
|
119
|
+
if (!managedDoc) return;
|
|
120
|
+
this.synchronizer.disconnectDoc(id);
|
|
121
|
+
managedDoc.dispose();
|
|
122
|
+
this.docs.delete(id);
|
|
123
|
+
}
|
|
124
|
+
getDocState(docId) {
|
|
125
|
+
const managedDoc = this.getManagedDoc(docId);
|
|
126
|
+
if (!managedDoc) return {
|
|
127
|
+
docId,
|
|
128
|
+
loaded: false,
|
|
129
|
+
syncing: false,
|
|
130
|
+
retrying: false,
|
|
131
|
+
synced: false,
|
|
132
|
+
error: null
|
|
133
|
+
};
|
|
134
|
+
return {
|
|
135
|
+
...managedDoc.state,
|
|
136
|
+
...this.synchronizer.getDocState(docId)
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
onDocStateChange(docId, callback) {
|
|
140
|
+
const unsubscribeSyncWatcher = this.synchronizer.onDocStateChange(docId, (state) => {
|
|
141
|
+
const managedDoc = this.getManagedDoc(docId);
|
|
142
|
+
if (!managedDoc) return;
|
|
143
|
+
callback({
|
|
144
|
+
...managedDoc.state,
|
|
145
|
+
...state
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
const unsubscribeManagerWatcher = this.event.on("managedDocStateChanged", (state) => {
|
|
149
|
+
if (state.docId !== docId) return;
|
|
150
|
+
callback({
|
|
151
|
+
...state,
|
|
152
|
+
...this.synchronizer.getDocState(docId)
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
return () => {
|
|
156
|
+
unsubscribeSyncWatcher();
|
|
157
|
+
unsubscribeManagerWatcher();
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
getManagedDoc(docId) {
|
|
161
|
+
return this.docs.get(docId) ?? null;
|
|
162
|
+
}
|
|
163
|
+
async mainLoop(signal) {
|
|
164
|
+
await this.storage.connection.waitForConnected().abortOn(signal);
|
|
165
|
+
const dispose = this.storage.subscribeDocUpdate((update, origin) => {
|
|
166
|
+
if (origin === this.origin) return;
|
|
167
|
+
this.schedule(update.docId, ManagerJob.apply(update.data));
|
|
168
|
+
});
|
|
169
|
+
try {
|
|
170
|
+
while (true) {
|
|
171
|
+
await this.consumeJobs(signal);
|
|
172
|
+
await this.waitForJobs().abortOn(signal);
|
|
173
|
+
}
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (signal.aborted) return;
|
|
176
|
+
console.warn("manager main loop error", err);
|
|
177
|
+
} finally {
|
|
178
|
+
dispose();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
bindDoc(doc) {
|
|
182
|
+
this.schedule(doc.docId, ManagerJob.load());
|
|
183
|
+
doc.onDocChanged((update) => {
|
|
184
|
+
this.schedule(doc.docId, ManagerJob.save(update));
|
|
185
|
+
});
|
|
186
|
+
doc.onStateChanged((state) => {
|
|
187
|
+
this.event.emit("managedDocStateChanged", state);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
wake() {
|
|
191
|
+
const previous = this.wakeUp;
|
|
192
|
+
this.wakeUp = createWakeUpSignal();
|
|
193
|
+
previous.wake();
|
|
194
|
+
}
|
|
195
|
+
schedule(id, job) {
|
|
196
|
+
let doc = this.docs.get(id);
|
|
197
|
+
if (!doc) return;
|
|
198
|
+
doc.jobs.push(job);
|
|
199
|
+
this.wake();
|
|
200
|
+
}
|
|
201
|
+
hasPendingJobs() {
|
|
202
|
+
for (const doc of this.docs.values()) if (doc.jobs.length > 0) return true;
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
waitForJobs() {
|
|
206
|
+
return Task.spawn(async () => {
|
|
207
|
+
if (this.hasPendingJobs()) return;
|
|
208
|
+
await this.wakeUp.promise;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
async consumeJobs(signal) {
|
|
212
|
+
return runWithCheckpoint(signal, async () => {
|
|
213
|
+
for (const doc of this.docs.values()) {
|
|
214
|
+
const jobs = doc.jobs;
|
|
215
|
+
if (!jobs.length) continue;
|
|
216
|
+
if (remove(jobs, (j) => j.type === "load").length) await Task.spawn(() => this.loadDoc(doc.docId)).abortOn(signal);
|
|
217
|
+
const apply = remove(jobs, (j) => j.type === "apply");
|
|
218
|
+
for (const job of apply) await Task.spawn(() => this.applyDocUpdate(doc.docId, job.payload.update)).abortOn(signal);
|
|
219
|
+
const save = remove(jobs, (j) => j.type === "save");
|
|
220
|
+
for (const job of save) await Task.spawn(() => this.saveDoc(doc.docId, job.payload.update)).abortOn(signal);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Handling [load] jobs.
|
|
226
|
+
*
|
|
227
|
+
* The [load] jobs only come from new doc managed by [DocManager].
|
|
228
|
+
*/
|
|
229
|
+
async loadDoc(docId) {
|
|
230
|
+
const managedDoc = this.docs.get(docId);
|
|
231
|
+
if (!managedDoc) return;
|
|
232
|
+
if (managedDoc.doc.changeCount() > 0) this.schedule(docId, ManagerJob.save(managedDoc.doc.export({ mode: "update" })));
|
|
233
|
+
const snapshot = await this.storage.getDoc(docId);
|
|
234
|
+
if (snapshot && hasUpdateData$1(snapshot.data)) managedDoc.doc.import(snapshot.data);
|
|
235
|
+
this.synchronizer.connectDoc(docId, managedDoc.doc);
|
|
236
|
+
managedDoc.setState({ loaded: true });
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Handling [save] jobs.
|
|
240
|
+
*
|
|
241
|
+
* The [save] jobs only come from local changes.
|
|
242
|
+
*/
|
|
243
|
+
async saveDoc(docId, update) {
|
|
244
|
+
if (!hasUpdateData$1(update)) return;
|
|
245
|
+
await this.storage.pushDocUpdate({
|
|
246
|
+
docId,
|
|
247
|
+
data: update
|
|
248
|
+
}, this.origin);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Handling [apply] jobs.
|
|
252
|
+
*
|
|
253
|
+
* The [apply] jobs only come from local storage changes(cross-tab updates, server-side updates, etc.).
|
|
254
|
+
*/
|
|
255
|
+
async applyDocUpdate(docId, update) {
|
|
256
|
+
if (!hasUpdateData$1(update)) return;
|
|
257
|
+
const managedDoc = this.docs.get(docId);
|
|
258
|
+
if (!managedDoc) return;
|
|
259
|
+
if (managedDoc.doc.import(update).pending != null) this.synchronizer.requestSync(docId);
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
//#endregion
|
|
263
|
+
//#region src/sync/job.ts
|
|
264
|
+
var SynchronizerJob = class SynchronizerJob {
|
|
265
|
+
type;
|
|
266
|
+
payload;
|
|
267
|
+
constructor(type, payload) {
|
|
268
|
+
this.type = type;
|
|
269
|
+
this.payload = payload;
|
|
270
|
+
}
|
|
271
|
+
static push(update) {
|
|
272
|
+
return new SynchronizerJob("push", { update });
|
|
273
|
+
}
|
|
274
|
+
static pull() {
|
|
275
|
+
return new SynchronizerJob("pull", null);
|
|
276
|
+
}
|
|
277
|
+
static sync() {
|
|
278
|
+
return new SynchronizerJob("sync", null);
|
|
279
|
+
}
|
|
280
|
+
static save(update) {
|
|
281
|
+
return new SynchronizerJob("save", { update });
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
//#endregion
|
|
285
|
+
//#region src/sync/doc.ts
|
|
286
|
+
var SynchronizingDoc = class extends Disposable {
|
|
287
|
+
docId;
|
|
288
|
+
doc;
|
|
289
|
+
state;
|
|
290
|
+
jobs = [];
|
|
291
|
+
eventBus = new EventBus();
|
|
292
|
+
disposables = new DisposableSet();
|
|
293
|
+
constructor(docId, doc) {
|
|
294
|
+
super();
|
|
295
|
+
this.docId = docId;
|
|
296
|
+
this.doc = doc;
|
|
297
|
+
this.state = {
|
|
298
|
+
docId,
|
|
299
|
+
syncing: false,
|
|
300
|
+
retrying: false,
|
|
301
|
+
synced: false,
|
|
302
|
+
error: null
|
|
303
|
+
};
|
|
304
|
+
this.disposables.add(() => {
|
|
305
|
+
this.jobs.splice(0, this.jobs.length);
|
|
306
|
+
this.eventBus.clearAll();
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
setState(state) {
|
|
310
|
+
Object.assign(this.state, state);
|
|
311
|
+
this.eventBus.emit("stateChanged", { state: this.state });
|
|
312
|
+
}
|
|
313
|
+
onStateChanged(callback) {
|
|
314
|
+
const off = this.eventBus.on("stateChanged", ({ state }) => {
|
|
315
|
+
callback(state);
|
|
316
|
+
});
|
|
317
|
+
this.disposables.add(off);
|
|
318
|
+
return off;
|
|
319
|
+
}
|
|
320
|
+
dispose() {
|
|
321
|
+
this.disposables.dispose();
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
//#endregion
|
|
325
|
+
//#region src/sync/synchronizers/client-server.ts
|
|
326
|
+
const hasUpdateData = (update) => {
|
|
327
|
+
return update.byteLength > 0;
|
|
328
|
+
};
|
|
329
|
+
const SYNCHRONIZER_ORIGIN_PREFIX = "ClientServerSynchronizer:";
|
|
330
|
+
const isSynchronizerOrigin = (origin) => {
|
|
331
|
+
return typeof origin === "string" && origin.startsWith(SYNCHRONIZER_ORIGIN_PREFIX);
|
|
332
|
+
};
|
|
333
|
+
var ClientServerSynchronizer = class {
|
|
334
|
+
local;
|
|
335
|
+
server;
|
|
336
|
+
id = `${SYNCHRONIZER_ORIGIN_PREFIX}${nanoid(10)}`;
|
|
337
|
+
event = new EventBus();
|
|
338
|
+
abort = null;
|
|
339
|
+
docs = /* @__PURE__ */ new Map();
|
|
340
|
+
/**
|
|
341
|
+
* Signal fired whenever a new job is scheduled. The connect cycle blocks on
|
|
342
|
+
* this promise after draining the job queue so the loop does not spin while
|
|
343
|
+
* there is nothing to do.
|
|
344
|
+
*/
|
|
345
|
+
wakeUp = createWakeUpSignal();
|
|
346
|
+
status = {
|
|
347
|
+
syncing: false,
|
|
348
|
+
retrying: false,
|
|
349
|
+
error: null
|
|
350
|
+
};
|
|
351
|
+
constructor(local, server) {
|
|
352
|
+
this.local = local;
|
|
353
|
+
this.server = server;
|
|
354
|
+
}
|
|
355
|
+
start() {
|
|
356
|
+
if (this.abort) return;
|
|
357
|
+
this.abort = new AbortController();
|
|
358
|
+
this.mainLoop(this.abort.signal).catch((e) => {
|
|
359
|
+
if (e === MANUALLY_STOP) return;
|
|
360
|
+
console.error(e);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
stop() {
|
|
364
|
+
this.abort?.abort(MANUALLY_STOP);
|
|
365
|
+
this.abort = null;
|
|
366
|
+
this.updateStatus({
|
|
367
|
+
syncing: false,
|
|
368
|
+
retrying: false,
|
|
369
|
+
error: null
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
getState() {
|
|
373
|
+
return this.status;
|
|
374
|
+
}
|
|
375
|
+
onStateChange(callback) {
|
|
376
|
+
return this.event.on("stateChanged", callback);
|
|
377
|
+
}
|
|
378
|
+
getDocState(docId) {
|
|
379
|
+
const doc = this.docs.get(docId);
|
|
380
|
+
if (!doc) return {
|
|
381
|
+
docId,
|
|
382
|
+
syncing: false,
|
|
383
|
+
retrying: false,
|
|
384
|
+
synced: false,
|
|
385
|
+
error: null
|
|
386
|
+
};
|
|
387
|
+
return doc.state;
|
|
388
|
+
}
|
|
389
|
+
onDocStateChange(docId, callback) {
|
|
390
|
+
return this.event.on("docStateChanged", (state) => {
|
|
391
|
+
if (state.docId === docId) callback(state);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
connectDoc(docId, doc) {
|
|
395
|
+
if (!this.docs.has(docId)) {
|
|
396
|
+
const synchronizingDoc = new SynchronizingDoc(docId, doc);
|
|
397
|
+
synchronizingDoc.onStateChanged((state) => {
|
|
398
|
+
this.event.emit("docStateChanged", state);
|
|
399
|
+
});
|
|
400
|
+
this.docs.set(docId, synchronizingDoc);
|
|
401
|
+
}
|
|
402
|
+
this.schedule(docId, SynchronizerJob.sync());
|
|
403
|
+
}
|
|
404
|
+
disconnectDoc(docId) {
|
|
405
|
+
let doc = this.docs.get(docId);
|
|
406
|
+
if (doc) {
|
|
407
|
+
doc.dispose();
|
|
408
|
+
this.docs.delete(docId);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
requestSync(docId) {
|
|
412
|
+
if (!this.docs.has(docId)) return;
|
|
413
|
+
this.schedule(docId, SynchronizerJob.sync());
|
|
414
|
+
}
|
|
415
|
+
async mainLoop(signal) {
|
|
416
|
+
while (true) {
|
|
417
|
+
try {
|
|
418
|
+
await this.connectLoop(signal);
|
|
419
|
+
} catch (err) {
|
|
420
|
+
if (signal.aborted) return;
|
|
421
|
+
console.warn("synchronizer main loop error, retry in 5s", err);
|
|
422
|
+
this.updateStatus({
|
|
423
|
+
syncing: false,
|
|
424
|
+
retrying: true,
|
|
425
|
+
error: err instanceof Error ? err.message : String(err)
|
|
426
|
+
});
|
|
427
|
+
} finally {
|
|
428
|
+
this.updateStatus({
|
|
429
|
+
syncing: false,
|
|
430
|
+
retrying: true
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
await Task.delay(5e3).abortOn(signal);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async connectLoop(signal) {
|
|
437
|
+
return runWithCheckpoint(signal, async () => {
|
|
438
|
+
const abort = new AbortController();
|
|
439
|
+
const propagateAbort = () => {
|
|
440
|
+
abort.abort(signal.reason);
|
|
441
|
+
};
|
|
442
|
+
if (signal.aborted) propagateAbort();
|
|
443
|
+
else signal.addEventListener("abort", propagateAbort);
|
|
444
|
+
const cycleSignal = abort.signal;
|
|
445
|
+
const disposes = [];
|
|
446
|
+
try {
|
|
447
|
+
await this.waitConnectionReady(cycleSignal);
|
|
448
|
+
console.info("Remote synchronization started");
|
|
449
|
+
this.updateStatus({
|
|
450
|
+
syncing: true,
|
|
451
|
+
retrying: false
|
|
452
|
+
});
|
|
453
|
+
disposes.push(this.local.connection.onStatusChanged((status, error) => {
|
|
454
|
+
abort.abort(/* @__PURE__ */ new Error(`Local connection status changed to ${status}, error: ${error?.message}`));
|
|
455
|
+
}));
|
|
456
|
+
disposes.push(this.server.connection.onStatusChanged((status, error) => {
|
|
457
|
+
abort.abort(/* @__PURE__ */ new Error(`Server connection status changed to ${status}, error: ${error?.message}`));
|
|
458
|
+
}));
|
|
459
|
+
disposes.push(this.local.subscribeDocUpdate((docRecord, origin) => {
|
|
460
|
+
if (isSynchronizerOrigin(origin)) return;
|
|
461
|
+
this.schedule(docRecord.docId, SynchronizerJob.push(docRecord.data));
|
|
462
|
+
}));
|
|
463
|
+
disposes.push(this.server.subscribeDocUpdate((docRecord, origin) => {
|
|
464
|
+
if (origin === this.id) return;
|
|
465
|
+
this.schedule(docRecord.docId, SynchronizerJob.save(docRecord.data));
|
|
466
|
+
}));
|
|
467
|
+
for (const docId of this.docs.keys()) this.schedule(docId, SynchronizerJob.sync());
|
|
468
|
+
while (!cycleSignal.aborted) {
|
|
469
|
+
await this.consumeJobs(cycleSignal);
|
|
470
|
+
await this.waitForJobs().abortOn(cycleSignal);
|
|
471
|
+
}
|
|
472
|
+
} finally {
|
|
473
|
+
signal.removeEventListener("abort", propagateAbort);
|
|
474
|
+
for (const dispose of disposes) try {
|
|
475
|
+
dispose();
|
|
476
|
+
} catch (err) {
|
|
477
|
+
console.error("Failed to dispose synchronizer resource", err);
|
|
478
|
+
}
|
|
479
|
+
this.updateStatus({ syncing: false });
|
|
480
|
+
console.info("Remote synchronization stopped");
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
schedule(docId, job) {
|
|
485
|
+
const docState = this.docs.get(docId);
|
|
486
|
+
if (!docState) {
|
|
487
|
+
console.warn(`Doc ${docId} not added`);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
docState.setState({ synced: false });
|
|
491
|
+
docState.jobs.push(job);
|
|
492
|
+
this.wake();
|
|
493
|
+
}
|
|
494
|
+
/** Returns true when at least one watched doc has pending jobs. */
|
|
495
|
+
hasPendingJobs() {
|
|
496
|
+
for (const doc of this.docs.values()) if (doc.jobs.length > 0) return true;
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Fire any waiter currently parked on the wake up signal and install a fresh
|
|
501
|
+
* one for the next round of waiters.
|
|
502
|
+
*/
|
|
503
|
+
wake() {
|
|
504
|
+
const previous = this.wakeUp;
|
|
505
|
+
this.wakeUp = createWakeUpSignal();
|
|
506
|
+
previous.wake();
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Resolve when either a new job arrives or the cycle is aborted. Short
|
|
510
|
+
* circuits synchronously if jobs are already pending so we never miss a
|
|
511
|
+
* push that happened between draining and re-arming the waiter.
|
|
512
|
+
*/
|
|
513
|
+
waitForJobs() {
|
|
514
|
+
return Task.spawn(async () => {
|
|
515
|
+
if (this.hasPendingJobs()) return;
|
|
516
|
+
await this.wakeUp.promise;
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
async consumeJobs(signal) {
|
|
520
|
+
return runWithCheckpoint(signal, async () => {
|
|
521
|
+
for (const doc of this.docs.values()) {
|
|
522
|
+
const jobs = doc.jobs;
|
|
523
|
+
if (!doc.jobs.length) continue;
|
|
524
|
+
doc.setState({
|
|
525
|
+
syncing: true,
|
|
526
|
+
retrying: false,
|
|
527
|
+
synced: false,
|
|
528
|
+
error: null
|
|
529
|
+
});
|
|
530
|
+
try {
|
|
531
|
+
if (remove(jobs, (j) => j.type === "sync").length) await this.syncWithRemote(doc, signal);
|
|
532
|
+
const push = remove(jobs, (j) => j.type === "push");
|
|
533
|
+
if (push.length) await this.pushUpdatesToRemote(doc, push.map((j) => j.payload.update), signal);
|
|
534
|
+
if (remove(jobs, (j) => j.type === "pull").length) await this.pullUpdatesFromRemote(doc, signal);
|
|
535
|
+
const save = remove(jobs, (j) => j.type === "save");
|
|
536
|
+
if (save.length) await this.saveDocUpdates(doc, save.map((j) => j.payload.update), signal);
|
|
537
|
+
doc.setState({
|
|
538
|
+
syncing: false,
|
|
539
|
+
retrying: false,
|
|
540
|
+
synced: true,
|
|
541
|
+
error: null
|
|
542
|
+
});
|
|
543
|
+
} catch (error) {
|
|
544
|
+
doc.setState({
|
|
545
|
+
syncing: false,
|
|
546
|
+
retrying: true,
|
|
547
|
+
synced: false,
|
|
548
|
+
error: error instanceof Error ? error.message : String(error)
|
|
549
|
+
});
|
|
550
|
+
throw error;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
updateStatus(status) {
|
|
556
|
+
this.status = {
|
|
557
|
+
...this.status,
|
|
558
|
+
...status
|
|
559
|
+
};
|
|
560
|
+
this.event.emit("stateChanged", this.status);
|
|
561
|
+
}
|
|
562
|
+
async waitConnectionReady(signal) {
|
|
563
|
+
return runWithCheckpoint(signal, async () => {
|
|
564
|
+
const localConnected = this.local.connection.waitForConnected();
|
|
565
|
+
const serverConnected = this.server.connection.waitForConnected();
|
|
566
|
+
await Task.all([localConnected, serverConnected]).timeout(30 * 1e3).abortOn(signal);
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
async pushUpdatesToRemote(docState, updates, signal) {
|
|
570
|
+
return runWithCheckpoint(signal, async () => {
|
|
571
|
+
await Promise.allSettled(updates.filter(hasUpdateData).map((update) => {
|
|
572
|
+
return this.server.pushDocUpdate({
|
|
573
|
+
docId: docState.docId,
|
|
574
|
+
data: update
|
|
575
|
+
}, this.id);
|
|
576
|
+
}));
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
async pullUpdatesFromRemote(docState, signal) {
|
|
580
|
+
return runWithCheckpoint(signal, async () => {
|
|
581
|
+
const doc = docState.doc;
|
|
582
|
+
const serverDoc = await this.server.getDocDiff(docState.docId, doc.version().encode());
|
|
583
|
+
if (!serverDoc) return;
|
|
584
|
+
const { missing } = serverDoc;
|
|
585
|
+
if (hasUpdateData(missing)) {
|
|
586
|
+
doc.import(missing);
|
|
587
|
+
await this.local.pushDocUpdate({
|
|
588
|
+
docId: docState.docId,
|
|
589
|
+
data: missing
|
|
590
|
+
}, this.id);
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
async saveDocUpdates(docState, updates, signal) {
|
|
595
|
+
return runWithCheckpoint(signal, async () => {
|
|
596
|
+
for (const update of updates.filter(hasUpdateData)) await this.local.pushDocUpdate({
|
|
597
|
+
docId: docState.docId,
|
|
598
|
+
data: update
|
|
599
|
+
}, this.id);
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
async syncWithRemote(docState, signal) {
|
|
603
|
+
return runWithCheckpoint(signal, async () => {
|
|
604
|
+
const localDoc = docState.doc;
|
|
605
|
+
const response = await this.server.getDocDiff(docState.docId, localDoc.version().encode());
|
|
606
|
+
let serverMissing = null;
|
|
607
|
+
if (response) {
|
|
608
|
+
const { missing, version } = response;
|
|
609
|
+
if (hasUpdateData(missing)) {
|
|
610
|
+
localDoc.import(missing);
|
|
611
|
+
await this.local.pushDocUpdate({
|
|
612
|
+
docId: docState.docId,
|
|
613
|
+
data: missing
|
|
614
|
+
}, this.id);
|
|
615
|
+
}
|
|
616
|
+
serverMissing = localDoc.export({
|
|
617
|
+
mode: "update",
|
|
618
|
+
from: VersionVector.decode(version)
|
|
619
|
+
});
|
|
620
|
+
} else serverMissing = localDoc.export({ mode: "update" });
|
|
621
|
+
if (serverMissing && hasUpdateData(serverMissing)) await this.server.pushDocUpdate({
|
|
622
|
+
docId: docState.docId,
|
|
623
|
+
data: serverMissing
|
|
624
|
+
}, this.id);
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
//#endregion
|
|
629
|
+
//#region src/sync/synchronizers/dummy.ts
|
|
630
|
+
var DummySynchronizer = class {
|
|
631
|
+
getState() {
|
|
632
|
+
return {
|
|
633
|
+
syncing: false,
|
|
634
|
+
retrying: false,
|
|
635
|
+
error: null
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
onStateChange(_callback) {
|
|
639
|
+
return () => {};
|
|
640
|
+
}
|
|
641
|
+
start() {}
|
|
642
|
+
stop() {}
|
|
643
|
+
getDocState(docId) {
|
|
644
|
+
return {
|
|
645
|
+
docId,
|
|
646
|
+
syncing: false,
|
|
647
|
+
retrying: false,
|
|
648
|
+
synced: false,
|
|
649
|
+
error: null
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
onDocStateChange(_docId, _callback) {
|
|
653
|
+
return () => {};
|
|
654
|
+
}
|
|
655
|
+
connectDoc(_docId, _doc) {}
|
|
656
|
+
disconnectDoc(_docId) {}
|
|
657
|
+
requestSync(_docId) {}
|
|
658
|
+
};
|
|
659
|
+
//#endregion
|
|
660
|
+
export { ClientServerSynchronizer, DocManager, DummySynchronizer };
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mengine/sync",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "UNLICENSED",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/one2x-ai/medeo-engine.git",
|
|
8
|
+
"directory": "packages/connector"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./src/index.ts",
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./dist/index.js",
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"access": "public",
|
|
24
|
+
"registry": "https://registry.npmjs.org/"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "vp pack",
|
|
28
|
+
"dev": "vp pack --watch",
|
|
29
|
+
"test": "vp test",
|
|
30
|
+
"prepublishOnly": "vp pack"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"nanoid": "catalog:"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/lodash-es": "catalog:",
|
|
37
|
+
"@types/node": "catalog:",
|
|
38
|
+
"@typescript/native-preview": "catalog:",
|
|
39
|
+
"typescript": "catalog:",
|
|
40
|
+
"vite-plus": "catalog:"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@mengine/storage": "workspace:*",
|
|
44
|
+
"@mengine/utils": "workspace:*",
|
|
45
|
+
"lodash-es": "catalog:",
|
|
46
|
+
"loro-crdt": "catalog:"
|
|
47
|
+
}
|
|
48
|
+
}
|