@mengine/medeo-client 2.1.0 → 2.1.1-dsl.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/README.md +126 -25
- package/dist/base64-D-jd-dr5.js +16 -0
- package/dist/dsl-Dkecq_2c.js +2814 -0
- package/dist/dsl.d.ts +3 -0
- package/dist/dsl.js +3 -0
- package/dist/index-B7fC7q7P.d.ts +728 -0
- package/dist/index-DKDMTXUe.d.ts +605 -0
- package/dist/index-DQZUkrpg.d.ts +197 -0
- package/dist/index.d.ts +5 -1141
- package/dist/index.js +109 -2606
- package/dist/legacy.d.ts +1044 -0
- package/dist/legacy.js +4349 -0
- package/dist/{loro-relay-doc-cJSY-uau.js → loro-relay-doc-Bi6JKqtQ.js} +11 -7
- package/dist/relay.js +1 -1
- package/dist/schemas.d.ts +606 -0
- package/dist/schemas.js +442 -0
- package/dist/shared-iWnU2osE.js +39 -0
- package/dist/storage-BW3tI_ER.js +801 -0
- package/dist/testing.d.ts +22 -9
- package/dist/testing.js +168 -50
- package/dist/video-draft-types-DTXxvPt-.js +449 -0
- package/dist/video-draft-types-c_shaRyq.d.ts +809 -0
- package/package.json +13 -6
- package/dist/chunk-D7D4PA-g.js +0 -13
- package/dist/document-B_JQwrC5.js +0 -1630
- package/dist/index-CnZ9l3rb.d.ts +0 -2077
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
import { n as bytesToBase64, t as base64ToBytes } from "./base64-D-jd-dr5.js";
|
|
2
|
+
import { LoroDoc, VersionVector } from "loro-crdt";
|
|
3
|
+
import { EventBus, Task } from "@mengine/utils";
|
|
4
|
+
import { BaseDocStorage, DummyConnection } from "@mengine/storage";
|
|
5
|
+
//#region src/client/http-client.ts
|
|
6
|
+
/**
|
|
7
|
+
* Public route prefix of the mengine doc-collaboration API. Must stay in lockstep
|
|
8
|
+
* with `apps/mengine-server`'s `API_PREFIX` — the two form the private wire
|
|
9
|
+
* contract between this client and the relay server. Versioned, mengine-owned
|
|
10
|
+
* namespace (mirrors the cluster's `/oapi/v1` precedent for an independent
|
|
11
|
+
* subsystem) rather than folding into the shared `/api/v2/*` space.
|
|
12
|
+
*/
|
|
13
|
+
const MENGINE_API_PREFIX = "/api/mengine/v1";
|
|
14
|
+
var MengineHttpRequestError = class extends Error {
|
|
15
|
+
status;
|
|
16
|
+
payload;
|
|
17
|
+
constructor(status, payload) {
|
|
18
|
+
super(`mengine request failed: ${status}`);
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.payload = payload;
|
|
21
|
+
this.name = "MengineHttpRequestError";
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* A push the server refused on its merits (`kind: 'rejected'`), as opposed to a
|
|
26
|
+
* transport failure. Carries the server's machine `code` so callers can branch on
|
|
27
|
+
* *why* rather than on an HTTP status:
|
|
28
|
+
*
|
|
29
|
+
* - `missing_dependency` (server answers 409) — retryable: the update depends on
|
|
30
|
+
* ops the server log lacks, so catching up and re-exporting resolves it.
|
|
31
|
+
* - `corrupt_update` (server answers 422) — never valid, retrying cannot help.
|
|
32
|
+
*
|
|
33
|
+
* Distinct from {@link MengineHttpRequestError} (transport/status-level failure)
|
|
34
|
+
* and from a raw `fetch` rejection (network down): the three are separate classes
|
|
35
|
+
* so a caller can tell "the server said no" from "the server never answered".
|
|
36
|
+
*/
|
|
37
|
+
var MenginePushRejectedError = class extends Error {
|
|
38
|
+
code;
|
|
39
|
+
serverMessage;
|
|
40
|
+
serverVersion;
|
|
41
|
+
status;
|
|
42
|
+
constructor(code, serverMessage, serverVersion, status) {
|
|
43
|
+
super(`mengine rejected update: ${code}: ${serverMessage}`);
|
|
44
|
+
this.code = code;
|
|
45
|
+
this.serverMessage = serverMessage;
|
|
46
|
+
this.serverVersion = serverVersion;
|
|
47
|
+
this.status = status;
|
|
48
|
+
this.name = "MenginePushRejectedError";
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
var MengineHttpClient = class {
|
|
52
|
+
options;
|
|
53
|
+
fetchImpl;
|
|
54
|
+
constructor(options) {
|
|
55
|
+
this.options = options;
|
|
56
|
+
this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
57
|
+
}
|
|
58
|
+
async fetchSnapshot() {
|
|
59
|
+
return await this.requestJson("snapshot");
|
|
60
|
+
}
|
|
61
|
+
/** Read-only compatibility content and diagnostics at the same causal version. */
|
|
62
|
+
async fetchDraft() {
|
|
63
|
+
return this.requestJson("draft");
|
|
64
|
+
}
|
|
65
|
+
async fetchDraftAt(updateSeq) {
|
|
66
|
+
if (!Number.isSafeInteger(updateSeq) || updateSeq < 0) throw new Error("Invalid revision");
|
|
67
|
+
return this.requestJson(`revisions/${updateSeq}/draft`);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Loro VV-diff pull: send the caller's oplog `VersionVector.encode` as `from`
|
|
71
|
+
* (omit for a full pull) and receive exactly the updates it is missing plus
|
|
72
|
+
* the server's current VV. Replaces the old integer `after_update_id` cursor.
|
|
73
|
+
*/
|
|
74
|
+
async sync(fromVV) {
|
|
75
|
+
const query = fromVV != null ? `?from=${encodeURIComponent(bytesToBase64(fromVV))}` : "";
|
|
76
|
+
return await this.requestJson(`sync${query}`);
|
|
77
|
+
}
|
|
78
|
+
/** Audit trail: extracted metadata per accepted update, in log order. */
|
|
79
|
+
async audit() {
|
|
80
|
+
return await this.requestJson("audit");
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Append one Loro update to the server log.
|
|
84
|
+
*
|
|
85
|
+
* Resolves for both accepted outcomes and hands the verdict back verbatim —
|
|
86
|
+
* `ack` (appended, `update_seq` allocated) and `duplicate` (already known, no
|
|
87
|
+
* row appended). A `duplicate` is NOT an error, but it is also not an ack: it
|
|
88
|
+
* means the bytes contributed nothing, so a caller waiting for its own write to
|
|
89
|
+
* land must be able to tell them apart. Hence the verdict is returned rather
|
|
90
|
+
* than collapsed into `void`.
|
|
91
|
+
*
|
|
92
|
+
* Rejections raise {@link MenginePushRejectedError} carrying the server's
|
|
93
|
+
* machine `code`. The server answers them with 409/422, so the failure arrives
|
|
94
|
+
* as a non-ok response; this method re-reads the parsed body to recover `code` /
|
|
95
|
+
* `server_version` instead of leaving the caller a bare status. Transport-level
|
|
96
|
+
* failures stay {@link MengineHttpRequestError}, and a dead network keeps
|
|
97
|
+
* surfacing as the underlying `fetch` rejection.
|
|
98
|
+
*/
|
|
99
|
+
async pushUpdate(update) {
|
|
100
|
+
let response;
|
|
101
|
+
try {
|
|
102
|
+
response = await this.requestJson("updates", {
|
|
103
|
+
method: "POST",
|
|
104
|
+
body: JSON.stringify({ update: bytesToBase64(update) })
|
|
105
|
+
});
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw asPushRejection(error) ?? error;
|
|
108
|
+
}
|
|
109
|
+
if (response.kind === "rejected") throw new MenginePushRejectedError(response.code, response.message, response.server_version, void 0);
|
|
110
|
+
return response;
|
|
111
|
+
}
|
|
112
|
+
eventsUrl() {
|
|
113
|
+
return this.endpoint("events");
|
|
114
|
+
}
|
|
115
|
+
headers() {
|
|
116
|
+
const headers = new Headers();
|
|
117
|
+
headers.set("accept", "application/json");
|
|
118
|
+
headers.set("content-type", "application/json");
|
|
119
|
+
const authToken = typeof this.options.authToken === "function" ? this.options.authToken() : this.options.authToken;
|
|
120
|
+
if (authToken != null && authToken !== "") headers.set("authorization", `Bearer ${authToken}`);
|
|
121
|
+
const userId = typeof this.options.userId === "function" ? this.options.userId() : this.options.userId;
|
|
122
|
+
if (userId != null && userId !== "") headers.set("medeo-user-id", userId);
|
|
123
|
+
return headers;
|
|
124
|
+
}
|
|
125
|
+
async fetch(input, init) {
|
|
126
|
+
return await this.fetchImpl(input, init);
|
|
127
|
+
}
|
|
128
|
+
async requestJson(path, init = {}) {
|
|
129
|
+
const headers = this.headers();
|
|
130
|
+
new Headers(init.headers).forEach((value, key) => {
|
|
131
|
+
headers.set(key, value);
|
|
132
|
+
});
|
|
133
|
+
const response = await this.fetchImpl(this.endpoint(path), {
|
|
134
|
+
...init,
|
|
135
|
+
headers
|
|
136
|
+
});
|
|
137
|
+
const payload = await safeReadJson(response);
|
|
138
|
+
if (!response.ok) throw new MengineHttpRequestError(response.status, payload);
|
|
139
|
+
return payload;
|
|
140
|
+
}
|
|
141
|
+
endpoint(path) {
|
|
142
|
+
return `${this.options.httpOrigin.replace(/\/$/, "")}${MENGINE_API_PREFIX}/docs/${encodeURIComponent(this.options.docId)}/${path}`;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Recover a typed rejection from a failed request: the server sends `rejected`
|
|
147
|
+
* bodies with a 409/422 status, so they surface as {@link MengineHttpRequestError}
|
|
148
|
+
* whose payload still carries the machine `code`. Returns `undefined` for any
|
|
149
|
+
* other failure so the caller rethrows the original.
|
|
150
|
+
*/
|
|
151
|
+
function asPushRejection(error) {
|
|
152
|
+
if (!(error instanceof MengineHttpRequestError)) return void 0;
|
|
153
|
+
const payload = error.payload;
|
|
154
|
+
if (payload == null || typeof payload !== "object") return void 0;
|
|
155
|
+
const body = payload;
|
|
156
|
+
if (body.kind !== "rejected" || typeof body.code !== "string") return void 0;
|
|
157
|
+
return new MenginePushRejectedError(body.code, body.message ?? "", body.server_version, error.status);
|
|
158
|
+
}
|
|
159
|
+
async function safeReadJson(response) {
|
|
160
|
+
const text = await response.text();
|
|
161
|
+
if (text.length === 0) return null;
|
|
162
|
+
try {
|
|
163
|
+
return JSON.parse(text);
|
|
164
|
+
} catch {
|
|
165
|
+
return text;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
169
|
+
//#region src/client/sse.ts
|
|
170
|
+
async function readMengineEventStream(options) {
|
|
171
|
+
const response = await options.client.fetch(options.client.eventsUrl(), {
|
|
172
|
+
headers: options.client.headers(),
|
|
173
|
+
signal: options.signal
|
|
174
|
+
});
|
|
175
|
+
if (!response.ok) throw new Error(`mengine event stream failed: ${response.status}`);
|
|
176
|
+
const reader = response.body?.getReader();
|
|
177
|
+
if (reader == null) throw new Error("mengine event stream response has no readable body");
|
|
178
|
+
options.onOpen?.();
|
|
179
|
+
const decoder = new TextDecoder();
|
|
180
|
+
let buffer = "";
|
|
181
|
+
while (options.signal?.aborted !== true) {
|
|
182
|
+
const { done, value } = await reader.read();
|
|
183
|
+
if (done) return;
|
|
184
|
+
buffer += decoder.decode(value, { stream: true });
|
|
185
|
+
let splitAt = findSseFrameBoundary(buffer);
|
|
186
|
+
while (splitAt >= 0) {
|
|
187
|
+
const frame = buffer.slice(0, splitAt);
|
|
188
|
+
buffer = buffer.slice(buffer[splitAt] === "\r" ? splitAt + 4 : splitAt + 2);
|
|
189
|
+
handleSseFrame(frame, options);
|
|
190
|
+
splitAt = findSseFrameBoundary(buffer);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function handleSseFrame(frame, options) {
|
|
195
|
+
const event = parseSseFrame(frame);
|
|
196
|
+
if (event.event === "resync") {
|
|
197
|
+
options.onResync?.();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (event.event !== "update" || event.data.length === 0) return;
|
|
201
|
+
options.onUpdate(JSON.parse(event.data.join("\n")));
|
|
202
|
+
}
|
|
203
|
+
function parseSseFrame(frame) {
|
|
204
|
+
let event = null;
|
|
205
|
+
const data = [];
|
|
206
|
+
for (const line of frame.split(/\r?\n/)) if (line.startsWith("event:")) event = line.slice(6).trim();
|
|
207
|
+
else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
|
|
208
|
+
return {
|
|
209
|
+
event,
|
|
210
|
+
data
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function findSseFrameBoundary(buffer) {
|
|
214
|
+
const lf = buffer.indexOf("\n\n");
|
|
215
|
+
const crlf = buffer.indexOf("\r\n\r\n");
|
|
216
|
+
if (lf < 0) return crlf;
|
|
217
|
+
if (crlf < 0) return lf;
|
|
218
|
+
return Math.min(lf, crlf);
|
|
219
|
+
}
|
|
220
|
+
//#endregion
|
|
221
|
+
//#region src/manual-sync/doc-version-mark.ts
|
|
222
|
+
/**
|
|
223
|
+
* Encode a {@link DocVersionMark} for storage or transport.
|
|
224
|
+
*
|
|
225
|
+
* The mark stays opaque across the round trip — the string is not a version
|
|
226
|
+
* number and must not be compared, ordered, or parsed. Its only use is
|
|
227
|
+
* {@link decodeDocVersionMark} followed by `hasChangedSince`.
|
|
228
|
+
*
|
|
229
|
+
* Callers that persist this should know the encoded length grows with the number
|
|
230
|
+
* of peers that have ever written to the document (one counter each), and the FE
|
|
231
|
+
* mints a fresh peer per page load. Still small in practice (a few hundred bytes
|
|
232
|
+
* for dozens of peers), but it grows with document age rather than size; version
|
|
233
|
+
* vector compaction is deferred to a later phase.
|
|
234
|
+
*/
|
|
235
|
+
function encodeDocVersionMark(mark) {
|
|
236
|
+
return bytesToBase64(mark.encoded);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Rebuild a mark from {@link encodeDocVersionMark}'s output.
|
|
240
|
+
*
|
|
241
|
+
* Returns `undefined` for input this did not produce (a legacy integer version,
|
|
242
|
+
* a truncated value, an empty string). That is the honest answer — "I cannot
|
|
243
|
+
* establish what you last saw" — and callers should treat it as "no baseline"
|
|
244
|
+
* rather than as "unchanged". Decoding does not validate the bytes as a version
|
|
245
|
+
* vector; `hasChangedSince` reports "changed" for an undecodable mark, which is
|
|
246
|
+
* the conservative direction.
|
|
247
|
+
*/
|
|
248
|
+
function decodeDocVersionMark(encoded) {
|
|
249
|
+
if (encoded === "") return void 0;
|
|
250
|
+
try {
|
|
251
|
+
return {
|
|
252
|
+
__brand: "mengine-doc-version-mark",
|
|
253
|
+
encoded: base64ToBytes(encoded)
|
|
254
|
+
};
|
|
255
|
+
} catch {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
//#endregion
|
|
260
|
+
//#region src/manual-sync/version-coverage.ts
|
|
261
|
+
/**
|
|
262
|
+
* Version-vector coverage: "does `outer` contain everything in `inner`?"
|
|
263
|
+
*
|
|
264
|
+
* This one predicate answers three different questions in the manual-sync document, which
|
|
265
|
+
* is why it is factored out rather than inlined three times:
|
|
266
|
+
*
|
|
267
|
+
* | question | call |
|
|
268
|
+
* | ------------------------------------- | ------------------------------- |
|
|
269
|
+
* | is there anything left to push? | `covers(watermark, localOplog)` |
|
|
270
|
+
* | did someone else write concurrently? | `covers(localOplog, serverVV)` |
|
|
271
|
+
* | has the doc moved since I last read? | `covers(seenVersion, localOplog)`|
|
|
272
|
+
*
|
|
273
|
+
* `VersionVector.compare` cannot be used for any of them: it returns `undefined`
|
|
274
|
+
* for concurrent vectors, and concurrency is the NORMAL case here — the server
|
|
275
|
+
* routinely holds peers the local doc has never seen, and after a collaborative
|
|
276
|
+
* merge the local doc holds ops the watermark predates. Treating "concurrent" as
|
|
277
|
+
* "not covered" is right for some of these and wrong for others, so the per-peer
|
|
278
|
+
* counter check is the only formulation that stays correct for all three.
|
|
279
|
+
*
|
|
280
|
+
* Equality must NOT be used as a substitute either: once collaboration happens
|
|
281
|
+
* the watermark legitimately *leads* the local doc (it carries other peers'
|
|
282
|
+
* counters), so an equality test reports "still has ops to push" forever.
|
|
283
|
+
*/
|
|
284
|
+
function covers(outer, inner) {
|
|
285
|
+
for (const [peer, counter] of inner.toJSON()) if ((outer.get(peer) ?? 0) < counter) return false;
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
//#endregion
|
|
289
|
+
//#region src/manual-sync/manual-sync-transport.ts
|
|
290
|
+
/** Shared explicit pull/push lifecycle; model-specific editors never implement transport. */
|
|
291
|
+
var ManualSyncTransport = class {
|
|
292
|
+
client;
|
|
293
|
+
doc;
|
|
294
|
+
watermark;
|
|
295
|
+
disposed = false;
|
|
296
|
+
constructor(client, doc, watermark) {
|
|
297
|
+
this.client = client;
|
|
298
|
+
this.doc = doc;
|
|
299
|
+
this.watermark = watermark;
|
|
300
|
+
}
|
|
301
|
+
editorPeerId() {
|
|
302
|
+
this.assertActive();
|
|
303
|
+
return this.doc.peerIdStr;
|
|
304
|
+
}
|
|
305
|
+
assertActive() {
|
|
306
|
+
if (this.disposed) throw new Error("ManualSyncDoc is disposed");
|
|
307
|
+
}
|
|
308
|
+
dispose() {
|
|
309
|
+
if (this.disposed) return;
|
|
310
|
+
this.disposed = true;
|
|
311
|
+
this.doc.free();
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Mark the document state the caller has just observed, for a later
|
|
315
|
+
* {@link hasChangedSince}.
|
|
316
|
+
*
|
|
317
|
+
* This pair replaces the legacy integer-version comparison that
|
|
318
|
+
* `DraftVersionDetector` used to tell the LLM "the draft was modified
|
|
319
|
+
* externally, reload before editing". Comparing Loro version vectors covers
|
|
320
|
+
* all collaborative edits without a business revision field in the document.
|
|
321
|
+
*
|
|
322
|
+
* Same capability, not a stronger one: like the legacy detector, this only
|
|
323
|
+
* reports what changed between two moments the caller chose to sample.
|
|
324
|
+
*/
|
|
325
|
+
versionMark() {
|
|
326
|
+
this.assertActive();
|
|
327
|
+
return {
|
|
328
|
+
__brand: "mengine-doc-version-mark",
|
|
329
|
+
encoded: this.doc.oplogVersion().encode()
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Has the document moved since `mark` was taken?
|
|
334
|
+
*
|
|
335
|
+
* Reports any advance, whoever caused it — including this document's own edits.
|
|
336
|
+
* The caller decides what is interesting: a detector sampling once per turn is
|
|
337
|
+
* asking "did anything happen", and its own edits legitimately count.
|
|
338
|
+
*/
|
|
339
|
+
hasChangedSince(mark) {
|
|
340
|
+
this.assertActive();
|
|
341
|
+
let previous;
|
|
342
|
+
try {
|
|
343
|
+
previous = VersionVector.decode(mark.encoded);
|
|
344
|
+
} catch {
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
return !covers(previous, this.doc.oplogVersion());
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Fetch and merge everything the server has that this document lacks.
|
|
351
|
+
*
|
|
352
|
+
* Must run *before* the editor on each tool call. The editor validates
|
|
353
|
+
* against the local document, so editing a stale one validates against a world
|
|
354
|
+
* that no longer exists: the ADR 0015 spike confirmed that without pull-first
|
|
355
|
+
* an edit to a clip another writer had already deleted passes validation and is
|
|
356
|
+
* accepted by the server. Pulling afterwards cannot undo that.
|
|
357
|
+
*
|
|
358
|
+
* A failure is returned, not thrown — a transient network blip must not make
|
|
359
|
+
* the tool unusable (M2/A ruling; legacy tolerates read failures the same way).
|
|
360
|
+
* The caller proceeds on a possibly-stale document knowingly.
|
|
361
|
+
*/
|
|
362
|
+
async pull() {
|
|
363
|
+
this.assertActive();
|
|
364
|
+
const before = this.doc.oplogVersion();
|
|
365
|
+
try {
|
|
366
|
+
const response = await this.client.sync(before.encode());
|
|
367
|
+
this.assertActive();
|
|
368
|
+
const update = base64ToBytes(response.update);
|
|
369
|
+
if (update.byteLength > 0) this.doc.import(update);
|
|
370
|
+
return {
|
|
371
|
+
ok: true,
|
|
372
|
+
changed: !covers(before, this.doc.oplogVersion())
|
|
373
|
+
};
|
|
374
|
+
} catch (error) {
|
|
375
|
+
return {
|
|
376
|
+
ok: false,
|
|
377
|
+
reason: "failed",
|
|
378
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Push every local op the server has not confirmed, and report the verdict.
|
|
384
|
+
*
|
|
385
|
+
* The return value is the durability answer a tool needs before claiming
|
|
386
|
+
* success: only `ack` / `duplicate` mean the server holds the ops. This is why
|
|
387
|
+
* this class exists rather than an ack-waiter — with a direct call, "did it
|
|
388
|
+
* land" is simply the result.
|
|
389
|
+
*
|
|
390
|
+
* `duplicate` counts as durable: the bytes added nothing *because* the server
|
|
391
|
+
* already had them.
|
|
392
|
+
*/
|
|
393
|
+
async push() {
|
|
394
|
+
this.assertActive();
|
|
395
|
+
const localVersion = this.doc.oplogVersion();
|
|
396
|
+
if (covers(this.watermark, localVersion)) return {
|
|
397
|
+
kind: "nothing_to_push",
|
|
398
|
+
collaborated: false
|
|
399
|
+
};
|
|
400
|
+
const update = this.doc.export({
|
|
401
|
+
mode: "update",
|
|
402
|
+
from: this.watermark
|
|
403
|
+
});
|
|
404
|
+
try {
|
|
405
|
+
const response = await this.client.pushUpdate(update);
|
|
406
|
+
const serverVV = this.serverVVFrom(response.version?.server_vv);
|
|
407
|
+
if (serverVV != null) this.watermark = serverVV;
|
|
408
|
+
return {
|
|
409
|
+
kind: response.kind,
|
|
410
|
+
mengineUpdateSeq: response.version?.update_seq ?? response.update_seq ?? void 0,
|
|
411
|
+
updateSeq: response.update_seq ?? void 0,
|
|
412
|
+
collaborated: serverVV != null && !covers(localVersion, serverVV)
|
|
413
|
+
};
|
|
414
|
+
} catch (error) {
|
|
415
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
416
|
+
const rejected = error instanceof MenginePushRejectedError;
|
|
417
|
+
return {
|
|
418
|
+
kind: rejected ? "rejected" : "failed",
|
|
419
|
+
collaborated: false,
|
|
420
|
+
code: rejected ? error.code : void 0,
|
|
421
|
+
error: failure
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/** Decode a wire `server_vv`, tolerating absence/corruption (never throws). */
|
|
426
|
+
serverVVFrom(serverVV) {
|
|
427
|
+
if (serverVV == null || serverVV === "") return void 0;
|
|
428
|
+
try {
|
|
429
|
+
return VersionVector.decode(base64ToBytes(serverVV));
|
|
430
|
+
} catch {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
async function openManualDocument(options, create) {
|
|
436
|
+
const response = await options.client.fetchSnapshot();
|
|
437
|
+
const doc = new LoroDoc();
|
|
438
|
+
try {
|
|
439
|
+
if (options.peerId !== void 0) doc.setPeerId(options.peerId);
|
|
440
|
+
if (doc.import(base64ToBytes(response.snapshot)).pending != null) throw new Error("Initial mengine snapshot has missing dependencies");
|
|
441
|
+
let watermark = doc.oplogVersion();
|
|
442
|
+
try {
|
|
443
|
+
if (response.version.server_vv) watermark = VersionVector.decode(base64ToBytes(response.version.server_vv));
|
|
444
|
+
} catch {}
|
|
445
|
+
return create(doc, watermark);
|
|
446
|
+
} catch (error) {
|
|
447
|
+
doc.free();
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
//#endregion
|
|
452
|
+
//#region src/storage/medeo-http-doc-storage.ts
|
|
453
|
+
/**
|
|
454
|
+
* SSE-backed {@link Connection} for the mengine-server document stream.
|
|
455
|
+
*
|
|
456
|
+
* It owns the live SSE read loop and reflects its lifecycle as connection
|
|
457
|
+
* status: `connecting` while (re)establishing, `connected` once the stream is
|
|
458
|
+
* open, back to `connecting` on a drop (auto-reconnect), `closed` on
|
|
459
|
+
* `disconnect`. Reporting a drop as a status change is the whole point — the
|
|
460
|
+
* synchronizer watches `onStatusChanged` and, on any change, tears down and
|
|
461
|
+
* re-runs its connect cycle, which re-issues `getDocDiff(doc.version())` and so
|
|
462
|
+
* recovers whatever the stream missed while it was down. Recovery therefore
|
|
463
|
+
* lives in the synchronizer (keyed on the real doc version), not here.
|
|
464
|
+
*
|
|
465
|
+
* Update frames are forwarded via `onUpdate`, resync hints via `onResync`; this
|
|
466
|
+
* connection keeps no version cursor and does no catch-up of its own. Dedup is
|
|
467
|
+
* unnecessary because `LoroDoc.import` is idempotent by OpId/VV.
|
|
468
|
+
*/
|
|
469
|
+
var SseConnection = class {
|
|
470
|
+
client;
|
|
471
|
+
onUpdate;
|
|
472
|
+
onResync;
|
|
473
|
+
reconnectDelayMs;
|
|
474
|
+
inner = void 0;
|
|
475
|
+
event = new EventBus();
|
|
476
|
+
streamTask = null;
|
|
477
|
+
_status = "idle";
|
|
478
|
+
_error;
|
|
479
|
+
constructor(client, onUpdate, onResync, reconnectDelayMs) {
|
|
480
|
+
this.client = client;
|
|
481
|
+
this.onUpdate = onUpdate;
|
|
482
|
+
this.onResync = onResync;
|
|
483
|
+
this.reconnectDelayMs = reconnectDelayMs;
|
|
484
|
+
}
|
|
485
|
+
get status() {
|
|
486
|
+
return this._status;
|
|
487
|
+
}
|
|
488
|
+
get error() {
|
|
489
|
+
return this._error;
|
|
490
|
+
}
|
|
491
|
+
connect() {
|
|
492
|
+
if (this.streamTask != null) return;
|
|
493
|
+
const task = Task.spawn((scope) => this.runStreamLoop(scope.signal));
|
|
494
|
+
this.streamTask = task;
|
|
495
|
+
const release = () => {
|
|
496
|
+
if (this.streamTask === task) this.streamTask = null;
|
|
497
|
+
};
|
|
498
|
+
task.then(release, release);
|
|
499
|
+
}
|
|
500
|
+
disconnect() {
|
|
501
|
+
this.streamTask?.cancel();
|
|
502
|
+
this.streamTask = null;
|
|
503
|
+
this.setStatus("closed");
|
|
504
|
+
}
|
|
505
|
+
waitForConnected() {
|
|
506
|
+
return new Task((resolve, reject, scope) => {
|
|
507
|
+
if (this._status === "connected") {
|
|
508
|
+
resolve();
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const off = this.onStatusChanged((status, error) => {
|
|
512
|
+
if (status === "connected") {
|
|
513
|
+
off();
|
|
514
|
+
resolve();
|
|
515
|
+
} else if (status === "closed") {
|
|
516
|
+
off();
|
|
517
|
+
reject(error ?? /* @__PURE__ */ new Error("SSE connection closed"));
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
scope.disposer.add(off);
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
onStatusChanged(cb) {
|
|
524
|
+
return this.event.on("statusChanged", ({ status, error }) => cb(status, error));
|
|
525
|
+
}
|
|
526
|
+
setStatus(status, error) {
|
|
527
|
+
if (this._status === status && this._error === error) return;
|
|
528
|
+
this._status = status;
|
|
529
|
+
this._error = error;
|
|
530
|
+
this.event.emit("statusChanged", {
|
|
531
|
+
status,
|
|
532
|
+
error
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
async runStreamLoop(signal) {
|
|
536
|
+
while (!signal.aborted) {
|
|
537
|
+
this.setStatus("connecting");
|
|
538
|
+
try {
|
|
539
|
+
await readMengineEventStream({
|
|
540
|
+
client: this.client,
|
|
541
|
+
signal,
|
|
542
|
+
onOpen: () => this.setStatus("connected"),
|
|
543
|
+
onResync: this.onResync,
|
|
544
|
+
onUpdate: (event) => {
|
|
545
|
+
for (const update of event.updates) this.onUpdate(update);
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
if (!signal.aborted) this.setStatus("connecting", /* @__PURE__ */ new Error("SSE stream closed"));
|
|
549
|
+
} catch (error) {
|
|
550
|
+
if (signal.aborted) break;
|
|
551
|
+
this.setStatus("connecting", error instanceof Error ? error : new Error(String(error)));
|
|
552
|
+
}
|
|
553
|
+
if (signal.aborted) break;
|
|
554
|
+
try {
|
|
555
|
+
await Task.delay(this.reconnectDelayMs).abortOn(signal);
|
|
556
|
+
} catch {
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
/**
|
|
563
|
+
* Adapts the mengine-server HTTP/SSE protocol to the engine `DocStorage`
|
|
564
|
+
* contract so `ClientServerSynchronizer` can treat it as a remote peer.
|
|
565
|
+
*
|
|
566
|
+
* Deliberately thin (mirrors the socket `DocStorage` in the playground): it
|
|
567
|
+
* forwards live SSE updates and exposes a version-vector diff, and keeps NO
|
|
568
|
+
* sync state of its own.
|
|
569
|
+
*
|
|
570
|
+
* - `getDocDiff(docId, knownVersion)` pulls the server-computed VV-diff via
|
|
571
|
+
* `GET /sync?from=<vv>` — the synchronizer passes the real `doc.version()`, so
|
|
572
|
+
* the response carries exactly the ops the doc is missing. `getDoc` (full
|
|
573
|
+
* `/snapshot`) stays for cold start, when the caller holds no version yet.
|
|
574
|
+
* - `pushDocUpdate` forwards a Loro update; the server appends it.
|
|
575
|
+
* - `subscribeDocUpdate` registers a callback for live SSE updates. It does no
|
|
576
|
+
* catch-up and keeps no cursor: after an SSE drop the connection reports a
|
|
577
|
+
* status change, and the synchronizer re-runs its cycle to catch up via
|
|
578
|
+
* `getDocDiff(doc.version())`. `LoroDoc.import` is idempotent (OpId/VV), so
|
|
579
|
+
* re-forwarded or echoed updates are harmless.
|
|
580
|
+
*
|
|
581
|
+
* It is bound to a single `docId` because `MengineHttpClient` is per-document.
|
|
582
|
+
*/
|
|
583
|
+
var MedeoHttpDocStorage = class {
|
|
584
|
+
options;
|
|
585
|
+
connection;
|
|
586
|
+
client;
|
|
587
|
+
docId;
|
|
588
|
+
events = new EventBus();
|
|
589
|
+
constructor(options) {
|
|
590
|
+
this.options = options;
|
|
591
|
+
this.client = options.client;
|
|
592
|
+
this.docId = options.docId;
|
|
593
|
+
this.connection = new SseConnection(this.client, (update) => this.emitUpdate(update), () => this.events.emit("resync"), options.sseReconnectDelayMs ?? 500);
|
|
594
|
+
}
|
|
595
|
+
get isReadonly() {
|
|
596
|
+
return this.options.readonlyMode ?? false;
|
|
597
|
+
}
|
|
598
|
+
async getDoc(docId) {
|
|
599
|
+
this.assertDocId(docId);
|
|
600
|
+
const snapshot = await this.client.fetchSnapshot();
|
|
601
|
+
this.reportServerVersion(snapshot.version?.server_vv);
|
|
602
|
+
const now = /* @__PURE__ */ new Date();
|
|
603
|
+
return {
|
|
604
|
+
docId,
|
|
605
|
+
data: base64ToBytes(snapshot.snapshot),
|
|
606
|
+
createdAt: now,
|
|
607
|
+
updatedAt: now
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
async getDocDiff(docId, knownVersion) {
|
|
611
|
+
this.assertDocId(docId);
|
|
612
|
+
const response = await this.client.sync(knownVersion);
|
|
613
|
+
this.reportServerVersion(response.server_vv);
|
|
614
|
+
return {
|
|
615
|
+
docId,
|
|
616
|
+
missing: base64ToBytes(response.update),
|
|
617
|
+
version: base64ToBytes(response.server_vv)
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Forward one update to the server, returning what the server says it now holds.
|
|
622
|
+
*
|
|
623
|
+
* The returned `server_vv` is the server's own statement about itself, computed
|
|
624
|
+
* inside the write transaction. A caller tracking "what the remote has" can
|
|
625
|
+
* adopt it directly, which is strictly better than inferring that bound from
|
|
626
|
+
* the pushed blob: it also covers ops other peers wrote, so those stop being
|
|
627
|
+
* re-sent on every later push. `ack` and `duplicate` both carry it.
|
|
628
|
+
*
|
|
629
|
+
* The verdict itself stays on {@link subscribePushOutcome}, which reports
|
|
630
|
+
* failures too — a return value cannot. Previously the verdict was read and
|
|
631
|
+
* dropped, so a `duplicate` (bytes contributed nothing) was indistinguishable
|
|
632
|
+
* from a successful write.
|
|
633
|
+
*
|
|
634
|
+
* The error is still rethrown after being published: the synchronizer treats a
|
|
635
|
+
* throw as "retry this cycle", and swallowing it here would strand the update.
|
|
636
|
+
* Publishing is therefore additive observability, not error handling.
|
|
637
|
+
*/
|
|
638
|
+
async pushDocUpdate(update, _origin) {
|
|
639
|
+
this.assertDocId(update.docId);
|
|
640
|
+
if (this.isReadonly) throw new Error(`MedeoHttpDocStorage is readonly; refusing to push ${update.docId}`);
|
|
641
|
+
if (update.data.byteLength === 0) return {};
|
|
642
|
+
try {
|
|
643
|
+
const response = await this.client.pushUpdate(update.data);
|
|
644
|
+
const serverVV = this.reportServerVersion(response.version?.server_vv);
|
|
645
|
+
this.events.emit("pushOutcome", {
|
|
646
|
+
kind: response.kind,
|
|
647
|
+
updateSeq: response.update_seq ?? void 0,
|
|
648
|
+
serverVV
|
|
649
|
+
});
|
|
650
|
+
return { version: serverVV };
|
|
651
|
+
} catch (error) {
|
|
652
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
653
|
+
const rejected = error instanceof MenginePushRejectedError;
|
|
654
|
+
this.events.emit("pushOutcome", {
|
|
655
|
+
kind: rejected ? "rejected" : "failed",
|
|
656
|
+
code: rejected ? error.code : void 0,
|
|
657
|
+
error: failure
|
|
658
|
+
});
|
|
659
|
+
throw error;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Observe the server's verdict for every pushed update, including failures.
|
|
664
|
+
*
|
|
665
|
+
* This is the loud channel the `void`-returning `DocStorage.pushDocUpdate`
|
|
666
|
+
* cannot express. Consumers that need "did my write land" (the agent's
|
|
667
|
+
* tool-level ack wait) subscribe here.
|
|
668
|
+
*/
|
|
669
|
+
subscribePushOutcome(callback) {
|
|
670
|
+
return this.events.on("pushOutcome", callback);
|
|
671
|
+
}
|
|
672
|
+
/** Observe live hints that require VV catch-up without replacing the SSE connection. */
|
|
673
|
+
subscribeResync(callback) {
|
|
674
|
+
return this.events.on("resync", callback);
|
|
675
|
+
}
|
|
676
|
+
/** Authoritative HTTP reads and accepted pushes, never inferred from SSE bytes. */
|
|
677
|
+
subscribeServerVersion(callback) {
|
|
678
|
+
return this.events.on("serverVersion", callback);
|
|
679
|
+
}
|
|
680
|
+
reportServerVersion(encoded) {
|
|
681
|
+
const version = decodeServerVV(encoded);
|
|
682
|
+
if (version != null) this.events.emit("serverVersion", version);
|
|
683
|
+
return version;
|
|
684
|
+
}
|
|
685
|
+
async deleteDoc(_docId) {
|
|
686
|
+
throw new Error("MedeoHttpDocStorage does not support deleteDoc");
|
|
687
|
+
}
|
|
688
|
+
subscribeDocUpdate(callback) {
|
|
689
|
+
return this.events.on("update", ({ update, origin }) => callback(update, origin));
|
|
690
|
+
}
|
|
691
|
+
assertDocId(docId) {
|
|
692
|
+
if (docId !== this.docId) throw new Error(`MedeoHttpDocStorage is bound to ${this.docId}, received ${docId}`);
|
|
693
|
+
}
|
|
694
|
+
emitUpdate(base64Update) {
|
|
695
|
+
this.events.emit("update", {
|
|
696
|
+
update: {
|
|
697
|
+
docId: this.docId,
|
|
698
|
+
data: base64ToBytes(base64Update)
|
|
699
|
+
},
|
|
700
|
+
origin: void 0
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
/**
|
|
705
|
+
* Decode the wire `server_vv` into raw bytes, tolerating absence. A server that
|
|
706
|
+
* omits the version block still yields a usable outcome (the kind is the useful
|
|
707
|
+
* part); only the version-coverage wait degrades, so this must not throw.
|
|
708
|
+
*/
|
|
709
|
+
function decodeServerVV(serverVV) {
|
|
710
|
+
if (serverVV == null || serverVV === "") return void 0;
|
|
711
|
+
try {
|
|
712
|
+
return base64ToBytes(serverVV);
|
|
713
|
+
} catch {
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
//#endregion
|
|
718
|
+
//#region src/storage/memory-doc-storage.ts
|
|
719
|
+
/**
|
|
720
|
+
* Runtime-neutral local `DocStorage` backed by in-process memory.
|
|
721
|
+
*
|
|
722
|
+
* `IndexedDBDocStorage` is the browser-side local storage, but it requires
|
|
723
|
+
* `indexedDB`/`idb`, which is absent in Node (FE/agent tests, unit tests, SSR).
|
|
724
|
+
* The mengine runtime injects this implementation as the local peer in those
|
|
725
|
+
* environments so the same `DocManager` + `ClientServerSynchronizer` wiring
|
|
726
|
+
* works without a browser. It mirrors the merge-on-read and update-sequence
|
|
727
|
+
* behavior of `IndexedDBDocStorage` so sync semantics are identical.
|
|
728
|
+
*/
|
|
729
|
+
var MemoryDocStorage = class extends BaseDocStorage {
|
|
730
|
+
connection = new DummyConnection();
|
|
731
|
+
entries = /* @__PURE__ */ new Map();
|
|
732
|
+
constructor(options = {}) {
|
|
733
|
+
super(options);
|
|
734
|
+
}
|
|
735
|
+
async pushDocUpdate(update, origin) {
|
|
736
|
+
const entry = this.entry(update.docId);
|
|
737
|
+
if (entry.snapshot == null && entry.updates.length === 0) {
|
|
738
|
+
const now = this.now();
|
|
739
|
+
entry.snapshot = {
|
|
740
|
+
docId: update.docId,
|
|
741
|
+
data: update.data,
|
|
742
|
+
createdAt: now,
|
|
743
|
+
updatedAt: now
|
|
744
|
+
};
|
|
745
|
+
} else {
|
|
746
|
+
entry.seq += 1;
|
|
747
|
+
entry.updates.push({
|
|
748
|
+
docId: update.docId,
|
|
749
|
+
seq: entry.seq,
|
|
750
|
+
data: update.data,
|
|
751
|
+
createdAt: this.now()
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
this.event.emit("update", {
|
|
755
|
+
update: {
|
|
756
|
+
docId: update.docId,
|
|
757
|
+
data: update.data
|
|
758
|
+
},
|
|
759
|
+
origin
|
|
760
|
+
});
|
|
761
|
+
return {};
|
|
762
|
+
}
|
|
763
|
+
async deleteDoc(docId) {
|
|
764
|
+
this.entries.delete(docId);
|
|
765
|
+
}
|
|
766
|
+
async getDocSnapshot(docId) {
|
|
767
|
+
return this.entries.get(docId)?.snapshot ?? null;
|
|
768
|
+
}
|
|
769
|
+
async setDocSnapshot(snapshot) {
|
|
770
|
+
const entry = this.entry(snapshot.docId);
|
|
771
|
+
if (entry.snapshot == null || entry.snapshot.updatedAt <= snapshot.updatedAt) entry.snapshot = snapshot;
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
774
|
+
async getDocUpdates(docId) {
|
|
775
|
+
return [...this.entries.get(docId)?.updates ?? []];
|
|
776
|
+
}
|
|
777
|
+
async markUpdatesMerged(docId, updates) {
|
|
778
|
+
const entry = this.entries.get(docId);
|
|
779
|
+
if (!entry) return 0;
|
|
780
|
+
const merged = new Set(updates.map((update) => update.seq));
|
|
781
|
+
entry.updates = entry.updates.filter((update) => !merged.has(update.seq));
|
|
782
|
+
return merged.size;
|
|
783
|
+
}
|
|
784
|
+
entry(docId) {
|
|
785
|
+
let entry = this.entries.get(docId);
|
|
786
|
+
if (!entry) {
|
|
787
|
+
entry = {
|
|
788
|
+
snapshot: null,
|
|
789
|
+
updates: [],
|
|
790
|
+
seq: 0
|
|
791
|
+
};
|
|
792
|
+
this.entries.set(docId, entry);
|
|
793
|
+
}
|
|
794
|
+
return entry;
|
|
795
|
+
}
|
|
796
|
+
now() {
|
|
797
|
+
return /* @__PURE__ */ new Date();
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
//#endregion
|
|
801
|
+
export { covers as a, readMengineEventStream as c, MenginePushRejectedError as d, openManualDocument as i, MengineHttpClient as l, MedeoHttpDocStorage as n, decodeDocVersionMark as o, ManualSyncTransport as r, encodeDocVersionMark as s, MemoryDocStorage as t, MengineHttpRequestError as u };
|