@mengine/medeo-client 2.1.1-dsl.24 → 2.1.1-dsl.26

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