@mengine/medeo-client 2.1.1-dsl.9 → 3.0.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.js CHANGED
@@ -1,10 +1,456 @@
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-BW3tI_ER.js";
3
- import { _t as tupleHash, a as documentFormat, c as isInvalidAtomic, gt as resourceId, ht as relationId, i as assertDslDocument, mt as defaultIdFactory, o as medeoDslLoroShape, r as UnsupportedDocumentFormatError, s as captionContentCodec, t as DEFAULT_UNIT_TIME_MS, u as timeAnchorCodec } from "./video-draft-types-BnbfWrBx.js";
4
- import { a as DslEditor, c as DslProjectionError, i as createInitialMedeoDsl, n as toVideoDraft, o as MedeoDsl, r as toAudioScriptViews, s as resolveDslLayout, t as fromVideoDraft } from "./dsl-COyzz7Fp.js";
5
- import { UndoManager, VersionVector, decodeImportBlobMeta } from "loro-crdt";
2
+ import { _ as defaultIdFactory, a as DslEditor, b as tupleHash, c as UnsupportedDocumentFormatError, d as medeoDslLoroShape, f as captionContentCodec, g as DslProjectionError, h as resolveDslLayout, i as createInitialMedeoDsl, l as assertDslDocument, m as timeAnchorCodec, n as toVideoDraft, o as toAudioScriptViews, p as isInvalidAtomic, r as DEFAULT_UNIT_TIME_MS, s as MedeoDsl, t as fromVideoDraft, u as documentFormat, v as relationId, y as resourceId } from "./dsl-Cpk8WxcZ.js";
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
+ constructor(client, doc, watermark) {
299
+ this.client = client;
300
+ this.doc = doc;
301
+ this.watermark = watermark;
302
+ }
303
+ editorPeerId() {
304
+ this.assertActive();
305
+ return this.doc.peerIdStr;
306
+ }
307
+ assertActive() {
308
+ if (this.disposed) throw new Error("ManualSyncDoc is disposed");
309
+ }
310
+ dispose() {
311
+ if (this.disposed) return;
312
+ this.disposed = true;
313
+ this.doc.free();
314
+ }
315
+ /**
316
+ * Mark the document state the caller has just observed, for a later
317
+ * {@link hasChangedSince}.
318
+ *
319
+ * This pair replaces the legacy integer-version comparison that
320
+ * `DraftVersionDetector` used to tell the LLM "the draft was modified
321
+ * externally, reload before editing". Comparing Loro version vectors covers
322
+ * all collaborative edits without a business revision field in the document.
323
+ *
324
+ * Same capability, not a stronger one: like the legacy detector, this only
325
+ * reports what changed between two moments the caller chose to sample.
326
+ */
327
+ versionMark() {
328
+ this.assertActive();
329
+ return {
330
+ __brand: "mengine-doc-version-mark",
331
+ encoded: this.doc.oplogVersion().encode()
332
+ };
333
+ }
334
+ /**
335
+ * Has the document moved since `mark` was taken?
336
+ *
337
+ * Reports any advance, whoever caused it — including this document's own edits.
338
+ * The caller decides what is interesting: a detector sampling once per turn is
339
+ * asking "did anything happen", and its own edits legitimately count.
340
+ */
341
+ hasChangedSince(mark) {
342
+ this.assertActive();
343
+ let previous;
344
+ try {
345
+ previous = VersionVector.decode(mark.encoded);
346
+ } catch {
347
+ return true;
348
+ }
349
+ return !covers(previous, this.doc.oplogVersion());
350
+ }
351
+ /**
352
+ * Fetch and merge everything the server has that this document lacks.
353
+ *
354
+ * Must run *before* the editor on each tool call. The editor validates
355
+ * against the local document, so editing a stale one validates against a world
356
+ * that no longer exists: the ADR 0015 spike confirmed that without pull-first
357
+ * an edit to a clip another writer had already deleted passes validation and is
358
+ * accepted by the server. Pulling afterwards cannot undo that.
359
+ *
360
+ * A failure is returned, not thrown — a transient network blip must not make
361
+ * the tool unusable (M2/A ruling; legacy tolerates read failures the same way).
362
+ * The caller proceeds on a possibly-stale document knowingly.
363
+ */
364
+ async pull() {
365
+ this.assertActive();
366
+ const before = this.doc.oplogVersion();
367
+ try {
368
+ const response = await this.client.sync(before.encode());
369
+ this.assertActive();
370
+ const update = base64ToBytes(response.update);
371
+ if (update.byteLength > 0) this.doc.import(update);
372
+ return {
373
+ ok: true,
374
+ changed: !covers(before, this.doc.oplogVersion())
375
+ };
376
+ } catch (error) {
377
+ return {
378
+ ok: false,
379
+ reason: "failed",
380
+ error: error instanceof Error ? error : new Error(String(error))
381
+ };
382
+ }
383
+ }
384
+ /**
385
+ * Push every local op the server has not confirmed, and report the verdict.
386
+ *
387
+ * The return value is the durability answer a tool needs before claiming
388
+ * success: only `ack` / `duplicate` mean the server holds the ops. This is why
389
+ * this class exists rather than an ack-waiter — with a direct call, "did it
390
+ * land" is simply the result.
391
+ *
392
+ * `duplicate` counts as durable: the bytes added nothing *because* the server
393
+ * already had them.
394
+ */
395
+ async push() {
396
+ this.assertActive();
397
+ const localVersion = this.doc.oplogVersion();
398
+ if (covers(this.watermark, localVersion)) return {
399
+ kind: "nothing_to_push",
400
+ collaborated: false
401
+ };
402
+ const update = this.doc.export({
403
+ mode: "update",
404
+ from: this.watermark
405
+ });
406
+ try {
407
+ const response = await this.client.pushUpdate(update);
408
+ const serverVV = this.serverVVFrom(response.version?.server_vv);
409
+ if (serverVV != null) this.watermark = serverVV;
410
+ return {
411
+ kind: response.kind,
412
+ mengineUpdateSeq: response.version?.update_seq ?? response.update_seq ?? void 0,
413
+ updateSeq: response.update_seq ?? void 0,
414
+ collaborated: serverVV != null && !covers(localVersion, serverVV)
415
+ };
416
+ } catch (error) {
417
+ const failure = error instanceof Error ? error : new Error(String(error));
418
+ const rejected = error instanceof MenginePushRejectedError;
419
+ return {
420
+ kind: rejected ? "rejected" : "failed",
421
+ collaborated: false,
422
+ code: rejected ? error.code : void 0,
423
+ error: failure
424
+ };
425
+ }
426
+ }
427
+ /** Decode a wire `server_vv`, tolerating absence/corruption (never throws). */
428
+ serverVVFrom(serverVV) {
429
+ if (serverVV == null || serverVV === "") return void 0;
430
+ try {
431
+ return VersionVector.decode(base64ToBytes(serverVV));
432
+ } catch {
433
+ return;
434
+ }
435
+ }
436
+ };
437
+ async function openManualDocument(options, create) {
438
+ const response = await options.client.fetchSnapshot();
439
+ const doc = new LoroDoc();
440
+ try {
441
+ if (options.peerId !== void 0) doc.setPeerId(options.peerId);
442
+ if (doc.import(base64ToBytes(response.snapshot)).pending != null) throw new Error("Initial mengine snapshot has missing dependencies");
443
+ let watermark = doc.oplogVersion();
444
+ try {
445
+ if (response.version.server_vv) watermark = VersionVector.decode(base64ToBytes(response.version.server_vv));
446
+ } catch {}
447
+ return create(doc, watermark);
448
+ } catch (error) {
449
+ doc.free();
450
+ throw error;
451
+ }
452
+ }
453
+ //#endregion
8
454
  //#region src/manual-sync/manual-sync-doc.ts
9
455
  /** Explicit synchronization over the same DSL/editor used by the live Session. */
10
456
  var ManualSyncDoc = class ManualSyncDoc extends ManualSyncTransport {
@@ -33,6 +479,355 @@ var ManualSyncDoc = class ManualSyncDoc extends ManualSyncTransport {
33
479
  }
34
480
  };
35
481
  //#endregion
482
+ //#region src/storage/medeo-http-doc-storage.ts
483
+ /**
484
+ * SSE-backed {@link Connection} for the mengine-server document stream.
485
+ *
486
+ * It owns the live SSE read loop and reflects its lifecycle as connection
487
+ * status: `connecting` while (re)establishing, `connected` once the stream is
488
+ * open, back to `connecting` on a drop (auto-reconnect), `closed` on
489
+ * `disconnect`. Reporting a drop as a status change is the whole point — the
490
+ * synchronizer watches `onStatusChanged` and, on any change, tears down and
491
+ * re-runs its connect cycle, which re-issues `getDocDiff(doc.version())` and so
492
+ * recovers whatever the stream missed while it was down. Recovery therefore
493
+ * lives in the synchronizer (keyed on the real doc version), not here.
494
+ *
495
+ * Update frames are forwarded via `onUpdate`, resync hints via `onResync`; this
496
+ * connection keeps no version cursor and does no catch-up of its own. Dedup is
497
+ * unnecessary because `LoroDoc.import` is idempotent by OpId/VV.
498
+ */
499
+ var SseConnection = class {
500
+ client;
501
+ onUpdate;
502
+ onResync;
503
+ reconnectDelayMs;
504
+ inner = void 0;
505
+ event = new EventBus();
506
+ streamTask = null;
507
+ _status = "idle";
508
+ _error;
509
+ constructor(client, onUpdate, onResync, reconnectDelayMs) {
510
+ this.client = client;
511
+ this.onUpdate = onUpdate;
512
+ this.onResync = onResync;
513
+ this.reconnectDelayMs = reconnectDelayMs;
514
+ }
515
+ get status() {
516
+ return this._status;
517
+ }
518
+ get error() {
519
+ return this._error;
520
+ }
521
+ connect() {
522
+ if (this.streamTask != null) return;
523
+ const task = Task.spawn((scope) => this.runStreamLoop(scope.signal));
524
+ this.streamTask = task;
525
+ const release = () => {
526
+ if (this.streamTask === task) this.streamTask = null;
527
+ };
528
+ task.then(release, release);
529
+ }
530
+ disconnect() {
531
+ this.streamTask?.cancel();
532
+ this.streamTask = null;
533
+ this.setStatus("closed");
534
+ }
535
+ waitForConnected() {
536
+ return new Task((resolve, reject, scope) => {
537
+ if (this._status === "connected") {
538
+ resolve();
539
+ return;
540
+ }
541
+ const off = this.onStatusChanged((status, error) => {
542
+ if (status === "connected") {
543
+ off();
544
+ resolve();
545
+ } else if (status === "closed") {
546
+ off();
547
+ reject(error ?? /* @__PURE__ */ new Error("SSE connection closed"));
548
+ }
549
+ });
550
+ scope.disposer.add(off);
551
+ });
552
+ }
553
+ onStatusChanged(cb) {
554
+ return this.event.on("statusChanged", ({ status, error }) => cb(status, error));
555
+ }
556
+ setStatus(status, error) {
557
+ if (this._status === status && this._error === error) return;
558
+ this._status = status;
559
+ this._error = error;
560
+ this.event.emit("statusChanged", {
561
+ status,
562
+ error
563
+ });
564
+ }
565
+ async runStreamLoop(signal) {
566
+ while (!signal.aborted) {
567
+ this.setStatus("connecting");
568
+ try {
569
+ await readMengineEventStream({
570
+ client: this.client,
571
+ signal,
572
+ onOpen: () => this.setStatus("connected"),
573
+ onResync: this.onResync,
574
+ onUpdate: (event) => {
575
+ for (const update of event.updates) this.onUpdate(update);
576
+ }
577
+ });
578
+ if (!signal.aborted) this.setStatus("connecting", /* @__PURE__ */ new Error("SSE stream closed"));
579
+ } catch (error) {
580
+ if (signal.aborted) break;
581
+ this.setStatus("connecting", error instanceof Error ? error : new Error(String(error)));
582
+ }
583
+ if (signal.aborted) break;
584
+ try {
585
+ await Task.delay(this.reconnectDelayMs).abortOn(signal);
586
+ } catch {
587
+ break;
588
+ }
589
+ }
590
+ }
591
+ };
592
+ /**
593
+ * Adapts the mengine-server HTTP/SSE protocol to the engine `DocStorage`
594
+ * contract so `ClientServerSynchronizer` can treat it as a remote peer.
595
+ *
596
+ * Deliberately thin (mirrors the socket `DocStorage` in the playground): it
597
+ * forwards live SSE updates and exposes a version-vector diff, and keeps NO
598
+ * sync state of its own.
599
+ *
600
+ * - `getDocDiff(docId, knownVersion)` pulls the server-computed VV-diff via
601
+ * `GET /sync?from=<vv>` — the synchronizer passes the real `doc.version()`, so
602
+ * the response carries exactly the ops the doc is missing. `getDoc` (full
603
+ * `/snapshot`) stays for cold start, when the caller holds no version yet.
604
+ * - `pushDocUpdate` forwards a Loro update; the server appends it.
605
+ * - `subscribeDocUpdate` registers a callback for live SSE updates. It does no
606
+ * catch-up and keeps no cursor: after an SSE drop the connection reports a
607
+ * status change, and the synchronizer re-runs its cycle to catch up via
608
+ * `getDocDiff(doc.version())`. `LoroDoc.import` is idempotent (OpId/VV), so
609
+ * re-forwarded or echoed updates are harmless.
610
+ *
611
+ * It is bound to a single `docId` because `MengineHttpClient` is per-document.
612
+ */
613
+ var MedeoHttpDocStorage = class {
614
+ options;
615
+ connection;
616
+ client;
617
+ docId;
618
+ events = new EventBus();
619
+ constructor(options) {
620
+ this.options = options;
621
+ this.client = options.client;
622
+ this.docId = options.docId;
623
+ this.connection = new SseConnection(this.client, (update) => this.emitUpdate(update), () => this.events.emit("resync"), options.sseReconnectDelayMs ?? 500);
624
+ }
625
+ get isReadonly() {
626
+ return this.options.readonlyMode ?? false;
627
+ }
628
+ async getDoc(docId) {
629
+ this.assertDocId(docId);
630
+ const snapshot = await this.client.fetchSnapshot();
631
+ this.reportServerVersion(snapshot.version?.server_vv);
632
+ const now = /* @__PURE__ */ new Date();
633
+ return {
634
+ docId,
635
+ data: base64ToBytes(snapshot.snapshot),
636
+ createdAt: now,
637
+ updatedAt: now
638
+ };
639
+ }
640
+ async getDocDiff(docId, knownVersion) {
641
+ this.assertDocId(docId);
642
+ const response = await this.client.sync(knownVersion);
643
+ this.reportServerVersion(response.server_vv);
644
+ return {
645
+ docId,
646
+ missing: base64ToBytes(response.update),
647
+ version: base64ToBytes(response.server_vv)
648
+ };
649
+ }
650
+ /**
651
+ * Forward one update to the server, returning what the server says it now holds.
652
+ *
653
+ * The returned `server_vv` is the server's own statement about itself, computed
654
+ * inside the write transaction. A caller tracking "what the remote has" can
655
+ * adopt it directly, which is strictly better than inferring that bound from
656
+ * the pushed blob: it also covers ops other peers wrote, so those stop being
657
+ * re-sent on every later push. `ack` and `duplicate` both carry it.
658
+ *
659
+ * The verdict itself stays on {@link subscribePushOutcome}, which reports
660
+ * failures too — a return value cannot. Previously the verdict was read and
661
+ * dropped, so a `duplicate` (bytes contributed nothing) was indistinguishable
662
+ * from a successful write.
663
+ *
664
+ * The error is still rethrown after being published: the synchronizer treats a
665
+ * throw as "retry this cycle", and swallowing it here would strand the update.
666
+ * Publishing is therefore additive observability, not error handling.
667
+ */
668
+ async pushDocUpdate(update, _origin) {
669
+ this.assertDocId(update.docId);
670
+ if (this.isReadonly) throw new Error(`MedeoHttpDocStorage is readonly; refusing to push ${update.docId}`);
671
+ if (update.data.byteLength === 0) return {};
672
+ try {
673
+ const response = await this.client.pushUpdate(update.data);
674
+ const serverVV = this.reportServerVersion(response.version?.server_vv);
675
+ this.events.emit("pushOutcome", {
676
+ kind: response.kind,
677
+ updateSeq: response.update_seq ?? void 0,
678
+ serverVV
679
+ });
680
+ return { version: serverVV };
681
+ } catch (error) {
682
+ const failure = error instanceof Error ? error : new Error(String(error));
683
+ const rejected = error instanceof MenginePushRejectedError;
684
+ this.events.emit("pushOutcome", {
685
+ kind: rejected ? "rejected" : "failed",
686
+ code: rejected ? error.code : void 0,
687
+ error: failure
688
+ });
689
+ throw error;
690
+ }
691
+ }
692
+ /**
693
+ * Observe the server's verdict for every pushed update, including failures.
694
+ *
695
+ * This is the loud channel the `void`-returning `DocStorage.pushDocUpdate`
696
+ * cannot express. Consumers that need "did my write land" (the agent's
697
+ * tool-level ack wait) subscribe here.
698
+ */
699
+ subscribePushOutcome(callback) {
700
+ return this.events.on("pushOutcome", callback);
701
+ }
702
+ /** Observe live hints that require VV catch-up without replacing the SSE connection. */
703
+ subscribeResync(callback) {
704
+ return this.events.on("resync", callback);
705
+ }
706
+ /** Authoritative HTTP reads and accepted pushes, never inferred from SSE bytes. */
707
+ subscribeServerVersion(callback) {
708
+ return this.events.on("serverVersion", callback);
709
+ }
710
+ reportServerVersion(encoded) {
711
+ const version = decodeServerVV(encoded);
712
+ if (version != null) this.events.emit("serverVersion", version);
713
+ return version;
714
+ }
715
+ async deleteDoc(_docId) {
716
+ throw new Error("MedeoHttpDocStorage does not support deleteDoc");
717
+ }
718
+ subscribeDocUpdate(callback) {
719
+ return this.events.on("update", ({ update, origin }) => callback(update, origin));
720
+ }
721
+ assertDocId(docId) {
722
+ if (docId !== this.docId) throw new Error(`MedeoHttpDocStorage is bound to ${this.docId}, received ${docId}`);
723
+ }
724
+ emitUpdate(base64Update) {
725
+ this.events.emit("update", {
726
+ update: {
727
+ docId: this.docId,
728
+ data: base64ToBytes(base64Update)
729
+ },
730
+ origin: void 0
731
+ });
732
+ }
733
+ };
734
+ /**
735
+ * Decode the wire `server_vv` into raw bytes, tolerating absence. A server that
736
+ * omits the version block still yields a usable outcome (the kind is the useful
737
+ * part); only the version-coverage wait degrades, so this must not throw.
738
+ */
739
+ function decodeServerVV(serverVV) {
740
+ if (serverVV == null || serverVV === "") return void 0;
741
+ try {
742
+ return base64ToBytes(serverVV);
743
+ } catch {
744
+ return;
745
+ }
746
+ }
747
+ //#endregion
748
+ //#region src/storage/memory-doc-storage.ts
749
+ /**
750
+ * Runtime-neutral local `DocStorage` backed by in-process memory.
751
+ *
752
+ * `IndexedDBDocStorage` is the browser-side local storage, but it requires
753
+ * `indexedDB`/`idb`, which is absent in Node (FE/agent tests, unit tests, SSR).
754
+ * The mengine runtime injects this implementation as the local peer in those
755
+ * environments so the same `DocManager` + `ClientServerSynchronizer` wiring
756
+ * works without a browser. It mirrors the merge-on-read and update-sequence
757
+ * behavior of `IndexedDBDocStorage` so sync semantics are identical.
758
+ */
759
+ var MemoryDocStorage = class extends BaseDocStorage {
760
+ connection = new DummyConnection();
761
+ entries = /* @__PURE__ */ new Map();
762
+ constructor(options = {}) {
763
+ super(options);
764
+ }
765
+ async pushDocUpdate(update, origin) {
766
+ const entry = this.entry(update.docId);
767
+ if (entry.snapshot == null && entry.updates.length === 0) {
768
+ const now = this.now();
769
+ entry.snapshot = {
770
+ docId: update.docId,
771
+ data: update.data,
772
+ createdAt: now,
773
+ updatedAt: now
774
+ };
775
+ } else {
776
+ entry.seq += 1;
777
+ entry.updates.push({
778
+ docId: update.docId,
779
+ seq: entry.seq,
780
+ data: update.data,
781
+ createdAt: this.now()
782
+ });
783
+ }
784
+ this.event.emit("update", {
785
+ update: {
786
+ docId: update.docId,
787
+ data: update.data
788
+ },
789
+ origin
790
+ });
791
+ return {};
792
+ }
793
+ async deleteDoc(docId) {
794
+ this.entries.delete(docId);
795
+ }
796
+ async getDocSnapshot(docId) {
797
+ return this.entries.get(docId)?.snapshot ?? null;
798
+ }
799
+ async setDocSnapshot(snapshot) {
800
+ const entry = this.entry(snapshot.docId);
801
+ if (entry.snapshot == null || entry.snapshot.updatedAt <= snapshot.updatedAt) entry.snapshot = snapshot;
802
+ return true;
803
+ }
804
+ async getDocUpdates(docId) {
805
+ return [...this.entries.get(docId)?.updates ?? []];
806
+ }
807
+ async markUpdatesMerged(docId, updates) {
808
+ const entry = this.entries.get(docId);
809
+ if (!entry) return 0;
810
+ const merged = new Set(updates.map((update) => update.seq));
811
+ entry.updates = entry.updates.filter((update) => !merged.has(update.seq));
812
+ return merged.size;
813
+ }
814
+ entry(docId) {
815
+ let entry = this.entries.get(docId);
816
+ if (!entry) {
817
+ entry = {
818
+ snapshot: null,
819
+ updates: [],
820
+ seq: 0
821
+ };
822
+ this.entries.set(docId, entry);
823
+ }
824
+ return entry;
825
+ }
826
+ now() {
827
+ return /* @__PURE__ */ new Date();
828
+ }
829
+ };
830
+ //#endregion
36
831
  //#region src/session/document-mutation-guard.ts
37
832
  /**
38
833
  * @internal
@@ -209,6 +1004,7 @@ var MengineAckFailedError = class extends Error {
209
1004
  var DocumentSession = class {
210
1005
  options;
211
1006
  model;
1007
+ validateDocument;
212
1008
  docId;
213
1009
  local;
214
1010
  server;
@@ -235,9 +1031,10 @@ var DocumentSession = class {
235
1031
  * doc (e.g. nothing was edited since the last confirmed push).
236
1032
  */
237
1033
  serverVVValue = null;
238
- constructor(options, model) {
1034
+ constructor(options, model, validateDocument) {
239
1035
  this.options = options;
240
1036
  this.model = model;
1037
+ this.validateDocument = validateDocument;
241
1038
  this.mutationGuard = new DocumentMutationGuard(options.readonlyMode);
242
1039
  this.docId = options.docId;
243
1040
  this.local = options.localStorage ?? new MemoryDocStorage();
@@ -441,6 +1238,17 @@ var DocumentSession = class {
441
1238
  await this.local.connection.waitForConnected().abortOn(this.lifetime.signal);
442
1239
  if (this.destroyed) throw new Error("mengine doc session destroyed");
443
1240
  if (this.options.initialSnapshot) {
1241
+ const preview = new LoroDoc();
1242
+ try {
1243
+ if (preview.import(this.options.initialSnapshot).pending) throw new Error("Snapshot has missing dependencies");
1244
+ this.validateDocument(preview);
1245
+ const cached = await this.local.getDoc(this.docId);
1246
+ if (cached && preview.import(cached.data).pending) throw new Error("Cached document has missing dependencies");
1247
+ this.validateDocument(preview);
1248
+ } finally {
1249
+ preview.free();
1250
+ }
1251
+ if (this.destroyed) throw new Error("mengine doc session destroyed");
444
1252
  await this.local.pushDocUpdate({
445
1253
  docId: this.docId,
446
1254
  data: this.options.initialSnapshot
@@ -637,7 +1445,7 @@ var MengineDocSession = class extends DocumentSession {
637
1445
  subscribe: (listener) => dsl.subscribe(listener),
638
1446
  dispose: () => dsl.dispose()
639
1447
  };
640
- });
1448
+ }, assertDslDocument);
641
1449
  }
642
1450
  get dsl() {
643
1451
  return this.document;