@mengine/medeo-client 1.0.1-alpha.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,6 @@
1
1
  import { Mirror, schema } from "loro-mirror";
2
2
  import { z } from "zod";
3
3
  import { LoroDoc } from "loro-crdt";
4
- import { produce } from "immer";
5
4
  //#region src/client/base64.ts
6
5
  function bytesToBase64(bytes) {
7
6
  let binary = "";
@@ -536,7 +535,12 @@ function cascadeAfterVideoClipChanges(doc, makeEmptyPart) {
536
535
  function solveVideoDocument(document) {
537
536
  const doc = videoDocumentToTimelineDoc(document);
538
537
  let counter = 0;
539
- cascadeAfterVideoClipChanges(doc, () => ({ partId: `empty_${counter++}` }));
538
+ const derivedFillerPartIds = /* @__PURE__ */ new Set();
539
+ cascadeAfterVideoClipChanges(doc, () => {
540
+ const partId = `empty_${counter++}`;
541
+ derivedFillerPartIds.add(partId);
542
+ return { partId };
543
+ });
540
544
  const absByPartId = /* @__PURE__ */ new Map();
541
545
  for (const item of doc.main_track) absByPartId.set(item.part_id, item.abs_time_position);
542
546
  for (const item of doc.speech_track) absByPartId.set(item.part_id, item.abs_time_position);
@@ -552,7 +556,8 @@ function solveVideoDocument(document) {
552
556
  }))
553
557
  })),
554
558
  durationMs: doc.timeline.duration_ms,
555
- partLibrary: doc.part_library
559
+ partLibrary: doc.part_library,
560
+ derivedFillerPartIds
556
561
  };
557
562
  }
558
563
  function videoDocumentToTimelineDoc(document) {
@@ -876,11 +881,59 @@ function validateVideoDocument(document) {
876
881
  validatePartValues(partId, part, issues);
877
882
  }
878
883
  for (const [idx, track] of (doc.tracks ?? []).entries()) validateTrack(track, `tracks/${idx}`, partLibrary, issues, track.parts_kind === "video_clip");
884
+ validateLaneTrackUniqueness(doc, issues);
879
885
  validateSpeechCaptionReferences(partLibrary, issues);
880
886
  validatePositionReferences(doc, partLibrary, issues);
881
887
  return issues;
882
888
  }
883
889
  /**
890
+ * Report a duplicated lane: two tracks sharing the same `id`, or two secondary
891
+ * tracks of the same `parts_kind`.
892
+ *
893
+ * Found by the Phase 6 M0/S2 concurrency probe. `ensureLaneTrack` locates a lane
894
+ * by `parts_kind` and mints `<kind>_track` when absent, so two replicas that both
895
+ * start without (say) a caption track each create one; `tracks` is a
896
+ * `LoroMovableList`, so the merge keeps BOTH. The lane is then split across two
897
+ * same-id tracks, and the projection's `find`-by-kind returns only one of them —
898
+ * so an item can be authoritative yet invisible in the pane a reader looks at.
899
+ *
900
+ * Flagged **recoverable**, not `error`, for two reasons. It is a reachable
901
+ * outcome of ordinary concurrent editing, and hard-rejecting would make an
902
+ * unavoidable merge result un-projectable — the same argument §11.1 makes for
903
+ * orphans. And the projection already tolerates it: `solveVideoDocument`
904
+ * concatenates every track of a kind, so the cascade sees all items regardless.
905
+ * The defect is a *reader-visible* split, so the right response is to surface it
906
+ * (loudly, in a channel someone reads), not to block the document.
907
+ *
908
+ * The main track is exempt from the kind check: `parts_kind === 'video_clip'` may
909
+ * legitimately appear on several tracks (the projection treats only the first as
910
+ * the main pane), so only its `id` collision is reported.
911
+ */
912
+ function validateLaneTrackUniqueness(doc, issues) {
913
+ const tracks = doc.tracks ?? [];
914
+ const seenIds = /* @__PURE__ */ new Set();
915
+ const seenSecondaryKinds = /* @__PURE__ */ new Set();
916
+ for (const [idx, track] of tracks.entries()) {
917
+ const id = track?.id;
918
+ if (id != null && id !== "") if (seenIds.has(id)) issues.push({
919
+ code: "duplicate_lane_track",
920
+ path: `/tracks/${idx}/id`,
921
+ message: `Track "tracks/${idx}" reuses track id "${id}"`,
922
+ severity: "recoverable"
923
+ });
924
+ else seenIds.add(id);
925
+ const kind = track?.parts_kind;
926
+ if (kind == null || kind === "video_clip") continue;
927
+ if (seenSecondaryKinds.has(kind)) issues.push({
928
+ code: "duplicate_lane_track",
929
+ path: `/tracks/${idx}/parts_kind`,
930
+ message: `Lane "${kind}" is split across more than one track (at "tracks/${idx}")`,
931
+ severity: "recoverable"
932
+ });
933
+ else seenSecondaryKinds.add(kind);
934
+ }
935
+ }
936
+ /**
884
937
  * An `anchored` item whose `anchorPartId` no longer exists in the library is the
885
938
  * orphan condition (RFC 02 §4/§11.1) — e.g. a speech whose host video, or a
886
939
  * caption whose host speech, was concurrently deleted while this item was being
@@ -1102,7 +1155,7 @@ function toAuthoritativePartLibrary(partLibrary) {
1102
1155
  const { duration_ms, rest } = splitDurationMs(part.caption);
1103
1156
  out[partId] = { caption: {
1104
1157
  ...rest,
1105
- initial_duration_ms: duration_ms
1158
+ initial_duration_ms: rest.initial_duration_ms ?? duration_ms
1106
1159
  } };
1107
1160
  } else if (part.bgm != null) out[partId] = { bgm: omitDurationMs(part.bgm) };
1108
1161
  }
@@ -1209,7 +1262,7 @@ function fromVideoDocument(document) {
1209
1262
  above_main_tracks: aboveTracks.map((t) => draftTrack(t, view.absByPartId)),
1210
1263
  below_main_tracks: belowTracks.map((t) => draftTrack(t, view.absByPartId)),
1211
1264
  part_aggregations: view.aggregations,
1212
- part_library: toReadViewPartLibrary(view.partLibrary, view.durationMs),
1265
+ part_library: toReadViewPartLibrary(view.partLibrary, view.durationMs, view.derivedFillerPartIds),
1213
1266
  version: document.meta.version
1214
1267
  };
1215
1268
  }
@@ -1225,31 +1278,38 @@ function fromVideoDocument(document) {
1225
1278
  *
1226
1279
  * Parts are deep-cloned; the engine extensions (`media_duration_ms`,
1227
1280
  * `initial_duration_ms`) are kept alongside the injected `duration_ms`.
1281
+ *
1282
+ * `derivedFillerPartIds` are skipped: the solve minted them to lay the track out
1283
+ * and they are not part of the document (see `fromVideoDocument`). An empty clip a
1284
+ * writer placed deliberately is NOT in that set and is projected normally.
1228
1285
  */
1229
- function toReadViewPartLibrary(partLibrary, durationMs) {
1286
+ function toReadViewPartLibrary(partLibrary, durationMs, derivedFillerPartIds) {
1230
1287
  const out = {};
1231
- for (const [partId, part] of Object.entries(partLibrary)) if (part.video_clip != null) {
1232
- const clip = clone(part.video_clip);
1233
- out[partId] = { video_clip: {
1234
- ...clip,
1235
- duration_ms: effectiveVideoClipDurationMs(clip)
1236
- } };
1237
- } else if (part.speech != null) {
1238
- const speech = clone(part.speech);
1239
- out[partId] = { speech: {
1240
- ...speech,
1241
- duration_ms: speech.media_duration_ms
1242
- } };
1243
- } else if (part.caption != null) {
1244
- const caption = clone(part.caption);
1245
- out[partId] = { caption: {
1246
- ...caption,
1247
- duration_ms: caption.initial_duration_ms
1288
+ for (const [partId, part] of Object.entries(partLibrary)) {
1289
+ if (derivedFillerPartIds.has(partId)) continue;
1290
+ if (part.video_clip != null) {
1291
+ const clip = clone(part.video_clip);
1292
+ out[partId] = { video_clip: {
1293
+ ...clip,
1294
+ duration_ms: effectiveVideoClipDurationMs(clip)
1295
+ } };
1296
+ } else if (part.speech != null) {
1297
+ const speech = clone(part.speech);
1298
+ out[partId] = { speech: {
1299
+ ...speech,
1300
+ duration_ms: speech.media_duration_ms
1301
+ } };
1302
+ } else if (part.caption != null) {
1303
+ const caption = clone(part.caption);
1304
+ out[partId] = { caption: {
1305
+ ...caption,
1306
+ duration_ms: caption.initial_duration_ms
1307
+ } };
1308
+ } else if (part.bgm != null) out[partId] = { bgm: {
1309
+ ...clone(part.bgm),
1310
+ duration_ms: durationMs
1248
1311
  } };
1249
- } else if (part.bgm != null) out[partId] = { bgm: {
1250
- ...clone(part.bgm),
1251
- duration_ms: durationMs
1252
- } };
1312
+ }
1253
1313
  return out;
1254
1314
  }
1255
1315
  /** Deep-clone a part payload, dropping the derived read-view `duration_ms`. */
@@ -1422,7 +1482,8 @@ var MirrorVideoDocumentAdapter = class {
1422
1482
  message: JSON.stringify({
1423
1483
  semantic_op: audit.kind,
1424
1484
  payload: audit.payload,
1425
- intent: audit.intent ?? null
1485
+ intent: audit.intent ?? null,
1486
+ actor: audit.actor ?? null
1426
1487
  })
1427
1488
  });
1428
1489
  }
@@ -1436,7 +1497,7 @@ function createMirrorVideoDocument(document, options = {}) {
1436
1497
  doc,
1437
1498
  schema: videoDocumentMirrorSchema
1438
1499
  }).setState((draft) => {
1439
- writeVideoDocumentToDraft(draft, document);
1500
+ seedDraft(draft, document);
1440
1501
  }, { origin: options.origin ?? "mengine.bootstrap" });
1441
1502
  return doc;
1442
1503
  }
@@ -1444,8 +1505,8 @@ function createMirrorVideoDocument(document, options = {}) {
1444
1505
  function createMirrorVideoDocumentAdapter(document, options) {
1445
1506
  return new MirrorVideoDocumentAdapter(createMirrorVideoDocument(document, options));
1446
1507
  }
1447
- /** Write a whole `VideoDocument` into a draft (seed a fresh doc / plain-memory state). */
1448
- function writeVideoDocumentToDraft(draft, document) {
1508
+ /** Write a whole `VideoDocument` into the mirror draft (used to seed a fresh doc). */
1509
+ function seedDraft(draft, document) {
1449
1510
  draft.meta = {
1450
1511
  schema_version: document.meta.schema_version,
1451
1512
  draft_id: document.meta.draft_id ?? void 0,
@@ -1475,119 +1536,60 @@ function toTrackRow(track) {
1475
1536
  };
1476
1537
  }
1477
1538
  //#endregion
1478
- //#region src/editor/id-gen.ts
1539
+ //#region src/relay/loro-relay-doc.ts
1479
1540
  /**
1480
- * Part-id generation, aligned with the online ecosystem.
1541
+ * Loro OpLog-relay primitives: the doc shape and update classification an update
1542
+ * *host* needs, as opposed to an editing client.
1481
1543
  *
1482
- * The authoritative online producers agent-harness (`@harness/shared`
1483
- * `genObjId`) and director.v2 (`common/obj_id.py` `gen_obj_id`) both mint part
1484
- * ids as `` `${prefix}_${ulid()}` ``, and real captured drafts use exactly that
1485
- * shape (`clip_…` / `spe_…` / `cap_…` / `bgm_…`, each a 26-char ULID). The engine
1486
- * previously emitted `vc_<base36 timestamp><6 random>`, a different prefix AND a
1487
- * different encoding — the sole cross-repo id divergence. This module removes it
1488
- * by emitting the same `<prefix>_<ULID>` bytes.
1544
+ * A relay never materializes DocState (`docs/concepts/oplog_docstate` §Relay
1545
+ * Server) it keeps a detached `LoroDoc` that validates and accumulates updates
1546
+ * and exports diffs by version vector.
1489
1547
  *
1490
- * The ULID is generated inline (Crockford Base32, 48-bit time + 80-bit random)
1491
- * rather than pulling the `ulid` npm package: the randomness class matches the
1492
- * old generator (both `Math.random`-based) and it keeps `@mengine/medeo-client`
1493
- * dependency-free for a purely mechanical id string. Part ids only need to be
1494
- * unique and lexicographically time-sortable, which this satisfies.
1548
+ * These live in `medeo-client` rather than in `mengine-server` because two
1549
+ * separate hosts must classify identically: the real server (`apps/mengine-server`,
1550
+ * both storage implementations) and the in-process test double
1551
+ * (`testing/in-memory-mengine-server.ts`) that client-side tests push against. A
1552
+ * double with its own verdict logic drifts from the server it stands in for, and
1553
+ * the four push outcomes it produces are exactly what those tests assert on — so
1554
+ * the classification is shared code, not duplicated code.
1495
1555
  */
1496
- /** Crockford Base32 alphabet (no I, L, O, U), per the ULID spec. */
1497
- const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
1498
- const TIME_LEN = 10;
1499
- const RANDOM_LEN = 16;
1500
- function encodeTime(now) {
1501
- let out = "";
1502
- let ms = now;
1503
- for (let i = TIME_LEN - 1; i >= 0; i--) {
1504
- const mod = ms % 32;
1505
- out = CROCKFORD[mod] + out;
1506
- ms = (ms - mod) / 32;
1507
- }
1508
- return out;
1509
- }
1510
- function encodeRandom() {
1511
- let out = "";
1512
- for (let i = 0; i < RANDOM_LEN; i++) out += CROCKFORD[Math.floor(Math.random() * 32)];
1513
- return out;
1556
+ /** Build a fresh detached relay doc (no DocState materialization). */
1557
+ function newRelayDoc() {
1558
+ const doc = new LoroDoc();
1559
+ doc.detach();
1560
+ return doc;
1514
1561
  }
1515
- /** A 26-char Crockford Base32 ULID (10-char time + 16-char random). */
1516
- function ulid() {
1517
- return encodeTime(Date.now()) + encodeRandom();
1562
+ /** Load a detached relay doc from a stored snapshot. */
1563
+ function relayDocFromSnapshot(snapshot) {
1564
+ const doc = newRelayDoc();
1565
+ doc.import(snapshot);
1566
+ return doc;
1518
1567
  }
1519
- function generatePartId(prefix) {
1520
- return `${prefix}_${ulid()}`;
1568
+ /** A full-oplog clone (via snapshot round-trip) for non-destructive probing. */
1569
+ function cloneRelayDoc(doc) {
1570
+ return relayDocFromSnapshot(doc.export({ mode: "snapshot" }));
1521
1571
  }
1522
- //#endregion
1523
- //#region src/document/plain-memory-adapter.ts
1524
1572
  /**
1525
- * Pure in-memory `SemanticDocumentAdapter` no Loro/WASM. Holds a
1526
- * `VideoDocumentDraft` object and applies each `transact` via immer `produce`,
1527
- * journaling every audit that actually mutated state (plus any ids minted
1528
- * during that transact).
1573
+ * Classify an incoming update against `doc` without mutating it.
1529
1574
  *
1530
- * Known benign difference vs `MirrorVideoDocumentAdapter`: the editor's
1531
- * `setPart` assigns a fresh part object on every call, so a same-value rewrite
1532
- * produces a new immer state and IS journaled here, while the mirror's deep
1533
- * diff emits no commit. Terminal `snapshot()` stays equal; replay is idempotent.
1575
+ * Deliberately version-delta based, not `ImportStatus`-shape based: in loro-crdt
1576
+ * 1.13.x the JS `import()` returns `success`/`pending` as objects whose ranges
1577
+ * are not reliably populated, and a pending (out-of-order) import still buffers
1578
+ * the orphan op into the oplog. So this imports into a throwaway snapshot-clone
1579
+ * and inspects whether the oplog frontiers advanced — letting the caller import
1580
+ * into its canonical doc only on `accepted`, keeping orphan bytes out of the log.
1534
1581
  */
1535
- var PlainMemoryAdapter = class {
1536
- state;
1537
- _journal = [];
1538
- baseIdFactory;
1539
- /** Non-null only while a `transact` edit callback is running. */
1540
- pendingIds = null;
1541
- /**
1542
- * Recording wrapper around the underlying factory. Callers (sandbox editor)
1543
- * use this so every minted id is appended to the current transact's list.
1544
- */
1545
- idFactory;
1546
- constructor(document, options) {
1547
- assertValidVideoDocument(document);
1548
- const draft = {};
1549
- writeVideoDocumentToDraft(draft, document);
1550
- this.state = draft;
1551
- this.baseIdFactory = options?.idFactory ?? generatePartId;
1552
- this.idFactory = (prefix) => {
1553
- const id = this.baseIdFactory(prefix);
1554
- if (this.pendingIds != null) this.pendingIds.push(id);
1555
- return id;
1556
- };
1557
- }
1558
- get journal() {
1559
- return this._journal;
1582
+ function classifyUpdate(doc, update) {
1583
+ const probe = cloneRelayDoc(doc);
1584
+ let status;
1585
+ try {
1586
+ status = probe.import(update);
1587
+ } catch {
1588
+ return "corrupt_update";
1560
1589
  }
1561
- hasContent() {
1562
- return (this.state.meta?.schema_version ?? "") !== "";
1563
- }
1564
- snapshot() {
1565
- return readVideoDocumentFromDraft(this.state);
1566
- }
1567
- /**
1568
- * Apply one op via immer. A throw in `edit` discards the draft (state and
1569
- * journal unchanged). When `produce` returns the same reference, there was
1570
- * no structural change — skip journal, matching mirror "no change, no commit".
1571
- */
1572
- transact(edit, audit) {
1573
- this.pendingIds = [];
1574
- try {
1575
- const next = produce(this.state, edit);
1576
- if (next !== this.state) {
1577
- this.state = next;
1578
- this._journal.push({
1579
- ...audit,
1580
- generated_ids: this.pendingIds
1581
- });
1582
- }
1583
- } finally {
1584
- this.pendingIds = null;
1585
- }
1586
- }
1587
- };
1588
- /** Build a `PlainMemoryAdapter` seeded with `document`. */
1589
- function createPlainMemoryAdapter(document, options) {
1590
- return new PlainMemoryAdapter(document, options);
1590
+ if (status?.pending != null) return "missing_dependency";
1591
+ if (probe.cmpWithFrontiers(doc.oplogFrontiers()) !== 1) return "duplicate";
1592
+ return "accepted";
1591
1593
  }
1592
1594
  //#endregion
1593
- export { isEmptyVideoClip as A, bytesToBase64 as B, fillMainTrackTimeGaps as C, resolveSpeechOverlapByShiftingVideos as D, resolveAllSpeechOverlaps as E, speedOf as F, partUnionToDraft as I, recordEntries as L, safeDurationMs as M, VIDEO_DOCUMENT_SCHEMA_VERSION as N, syncAggregatedClipsTimePosition as O, effectiveVideoClipDurationMs as P, videoDocumentMirrorSchema as R, arrangeMainTrackSeamlessly as S, recalculateTimelineDuration as T, videoDocumentSchema as _, createMirrorVideoDocument as a, solveVideoDocument as b, readVideoDocumentFromDraft as c, fromVideoDocument as d, toVideoDocument as f, partUnionSchema as g, validateVideoDocument as h, MirrorVideoDocumentAdapter as i, partDurationMs as j, TIMELINE_SKELETON_DURATION_MS as k, buildSpeechHostMap as l, assertValidVideoDocument as m, createPlainMemoryAdapter as n, createMirrorVideoDocumentAdapter as o, VideoDocumentValidationError as p, generatePartId as r, writeVideoDocumentToDraft as s, PlainMemoryAdapter as t, derivePositionFromAbs as u, ensureLaneTrack as v, reassignSpeechesToVideoClipsByTime as w, cascadeAfterVideoClipChanges as x, findLaneTrack as y, base64ToBytes as z };
1595
+ export { partDurationMs as A, reassignSpeechesToVideoClipsByTime as C, syncAggregatedClipsTimePosition as D, resolveSpeechOverlapByShiftingVideos as E, recordEntries as F, videoDocumentMirrorSchema as I, base64ToBytes as L, VIDEO_DOCUMENT_SCHEMA_VERSION as M, effectiveVideoClipDurationMs as N, TIMELINE_SKELETON_DURATION_MS as O, partUnionToDraft as P, bytesToBase64 as R, fillMainTrackTimeGaps as S, resolveAllSpeechOverlaps as T, ensureLaneTrack as _, createMirrorVideoDocument as a, cascadeAfterVideoClipChanges as b, buildSpeechHostMap as c, toVideoDocument as d, VideoDocumentValidationError as f, videoDocumentSchema as g, partUnionSchema as h, MirrorVideoDocumentAdapter as i, safeDurationMs as j, isEmptyVideoClip as k, derivePositionFromAbs as l, validateVideoDocument as m, newRelayDoc as n, createMirrorVideoDocumentAdapter as o, assertValidVideoDocument as p, relayDocFromSnapshot as r, readVideoDocumentFromDraft as s, classifyUpdate as t, fromVideoDocument as u, findLaneTrack as v, recalculateTimelineDuration as w, arrangeMainTrackSeamlessly as x, solveVideoDocument as y };
package/dist/testing.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { c as MirrorVideoDocumentAdapter, it as VideoDraft } from "./index-DRUGsbm2.js";
1
+ import { $ as VideoDraft, i as MirrorVideoDocumentAdapter } from "./index-DLchEQG7.js";
2
2
 
3
3
  //#region src/testing/in-memory-mengine-server.d.ts
4
4
  /**
@@ -29,6 +29,21 @@ declare class InMemoryMengineServer {
29
29
  private handleSnapshot;
30
30
  private handleBootstrap;
31
31
  private handleSync;
32
+ /**
33
+ * Push one update, classifying it exactly as the real server does.
34
+ *
35
+ * This shares `classifyUpdate` with `apps/mengine-server` rather than assuming
36
+ * success, because the four push verdicts are the contract clients are written
37
+ * against: a double that always acks makes `duplicate` / `rejected` untestable
38
+ * above the storage layer, so an ack-waiter or a doc handle could mishandle
39
+ * them and still show green. It also mirrors the real server's two subtler
40
+ * behaviors — a non-accepted update is NOT imported (orphan bytes stay out of
41
+ * the log), and `duplicate` / `rejected` report the version from *before* the
42
+ * push, since nothing was appended.
43
+ *
44
+ * Status codes match the wire contract: `missing_dependency` is 409 (retryable
45
+ * after catch-up), `corrupt_update` is 422 (never retryable).
46
+ */
32
47
  private handlePush;
33
48
  private handleEvents;
34
49
  }
package/dist/testing.js CHANGED
@@ -1,4 +1,4 @@
1
- import { B as bytesToBase64, N as VIDEO_DOCUMENT_SCHEMA_VERSION, o as createMirrorVideoDocumentAdapter, z as base64ToBytes } from "./document-DgffKwRw.js";
1
+ import { L as base64ToBytes, M as VIDEO_DOCUMENT_SCHEMA_VERSION, R as bytesToBase64, o as createMirrorVideoDocumentAdapter, t as classifyUpdate } from "./loro-relay-doc-Br-ZJBHa.js";
2
2
  import { LoroDoc, VersionVector, encodeFrontiers } from "loro-crdt";
3
3
  //#region src/testing/in-memory-mengine-server.ts
4
4
  /**
@@ -35,10 +35,6 @@ var InMemoryMengineServer = class {
35
35
  if (path.startsWith("sync") && method === "GET") return this.handleSync(url);
36
36
  if (path === "updates" && method === "POST") return this.handlePush(init);
37
37
  if (path === "events" && method === "GET") return this.handleEvents(init);
38
- if (path === "audit" && method === "GET") return Response.json({ entries: this.updates.map((u) => ({
39
- ...u.meta,
40
- update_seq: u.updateSeq
41
- })) });
42
38
  return new Response("not found", { status: 404 });
43
39
  };
44
40
  parsePath(url) {
@@ -85,6 +81,21 @@ var InMemoryMengineServer = class {
85
81
  server_vv: bytesToBase64(this.doc.oplogVersion().encode())
86
82
  });
87
83
  }
84
+ /**
85
+ * Push one update, classifying it exactly as the real server does.
86
+ *
87
+ * This shares `classifyUpdate` with `apps/mengine-server` rather than assuming
88
+ * success, because the four push verdicts are the contract clients are written
89
+ * against: a double that always acks makes `duplicate` / `rejected` untestable
90
+ * above the storage layer, so an ack-waiter or a doc handle could mishandle
91
+ * them and still show green. It also mirrors the real server's two subtler
92
+ * behaviors — a non-accepted update is NOT imported (orphan bytes stay out of
93
+ * the log), and `duplicate` / `rejected` report the version from *before* the
94
+ * push, since nothing was appended.
95
+ *
96
+ * Status codes match the wire contract: `missing_dependency` is 409 (retryable
97
+ * after catch-up), `corrupt_update` is 422 (never retryable).
98
+ */
88
99
  async handlePush(init) {
89
100
  const body = JSON.parse(typeof init?.body === "string" ? init.body : "{}");
90
101
  if (body.update == null) return Response.json({
@@ -94,6 +105,25 @@ var InMemoryMengineServer = class {
94
105
  }, { status: 400 });
95
106
  const data = base64ToBytes(body.update);
96
107
  const baseVV = this.doc.oplogVersion();
108
+ const serverVersion = this.version();
109
+ const verdict = classifyUpdate(this.doc, data);
110
+ if (verdict === "corrupt_update") return Response.json({
111
+ kind: "rejected",
112
+ code: "corrupt_update",
113
+ message: "update failed to decode",
114
+ server_version: serverVersion
115
+ }, { status: 422 });
116
+ if (verdict === "missing_dependency") return Response.json({
117
+ kind: "rejected",
118
+ code: "missing_dependency",
119
+ message: "update depends on operations that are not in the server log",
120
+ server_version: serverVersion
121
+ }, { status: 409 });
122
+ if (verdict === "duplicate") return Response.json({
123
+ kind: "duplicate",
124
+ update_seq: null,
125
+ version: serverVersion
126
+ });
97
127
  this.doc.import(data);
98
128
  this.hasSnapshot = true;
99
129
  this.seq += 1;
@@ -161,6 +191,7 @@ function extractMeta(doc, baseVV) {
161
191
  semantic_op: null,
162
192
  payload: null,
163
193
  intent: null,
194
+ actor: null,
164
195
  message: null,
165
196
  parse_error: false,
166
197
  peer: "0",
@@ -171,11 +202,12 @@ function extractMeta(doc, baseVV) {
171
202
  };
172
203
  const [counterPart, peerPart] = change.id.split("@");
173
204
  const message = change.msg ?? change.message ?? null;
174
- const { semantic_op, payload, intent, parse_error } = parseMessage(message);
205
+ const { semantic_op, payload, intent, actor, parse_error } = parseMessage(message);
175
206
  return {
176
207
  semantic_op,
177
208
  payload,
178
209
  intent,
210
+ actor,
179
211
  message,
180
212
  parse_error,
181
213
  peer: peers[Number(peerPart)] ?? peerPart ?? "0",
@@ -185,11 +217,17 @@ function extractMeta(doc, baseVV) {
185
217
  frontiers
186
218
  };
187
219
  }
220
+ /**
221
+ * Mirrors `apps/mengine-server`'s `parseMessage` / `parseActor`. Kept in step
222
+ * deliberately: this double is what harness tests push against, so a divergence
223
+ * here would let them assert metadata the real server never produces.
224
+ */
188
225
  function parseMessage(message) {
189
226
  if (message == null || message === "") return {
190
227
  semantic_op: null,
191
228
  payload: null,
192
229
  intent: null,
230
+ actor: null,
193
231
  parse_error: false
194
232
  };
195
233
  try {
@@ -198,12 +236,14 @@ function parseMessage(message) {
198
236
  semantic_op: null,
199
237
  payload: null,
200
238
  intent: null,
239
+ actor: null,
201
240
  parse_error: true
202
241
  };
203
242
  return {
204
243
  semantic_op: typeof parsed.semantic_op === "string" ? parsed.semantic_op : null,
205
244
  payload: parsed.payload ?? null,
206
245
  intent: typeof parsed.intent === "string" ? parsed.intent : null,
246
+ actor: parseActor(parsed.actor),
207
247
  parse_error: false
208
248
  };
209
249
  } catch {
@@ -211,10 +251,21 @@ function parseMessage(message) {
211
251
  semantic_op: null,
212
252
  payload: null,
213
253
  intent: null,
254
+ actor: null,
214
255
  parse_error: true
215
256
  };
216
257
  }
217
258
  }
259
+ /** A malformed `actor` yields null for the author alone, never `parse_error`. */
260
+ function parseActor(actor) {
261
+ if (actor == null || typeof actor !== "object") return null;
262
+ const { user_id: userId, role } = actor;
263
+ if (typeof userId !== "string" || userId === "" || typeof role !== "string" || role === "") return null;
264
+ return {
265
+ user_id: userId,
266
+ role
267
+ };
268
+ }
218
269
  //#endregion
219
270
  //#region src/testing/index.ts
220
271
  const ACTOR_TEST = "1001";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mengine/medeo-client",
3
- "version": "1.0.1-alpha.1",
3
+ "version": "1.0.1",
4
4
  "license": "UNLICENSED",
5
5
  "repository": {
6
6
  "type": "git",
@@ -14,7 +14,6 @@
14
14
  "type": "module",
15
15
  "exports": {
16
16
  ".": "./dist/index.js",
17
- "./schemas": "./dist/schemas.js",
18
17
  "./testing": "./dist/testing.js",
19
18
  "./package.json": "./package.json"
20
19
  },
@@ -23,18 +22,16 @@
23
22
  "registry": "https://registry.npmjs.org/"
24
23
  },
25
24
  "dependencies": {
26
- "immer": "^10.2.0",
27
25
  "loro-mirror": "^2.2.0",
28
26
  "zod": "^4.4.3",
29
- "@mengine/storage": "1.0.1-alpha.1",
30
- "@mengine/sync": "1.0.1-alpha.1",
31
- "@mengine/utils": "1.0.1-alpha.1"
27
+ "@mengine/storage": "1.0.1",
28
+ "@mengine/sync": "1.0.1",
29
+ "@mengine/utils": "1.0.1"
32
30
  },
33
31
  "devDependencies": {
34
32
  "@types/node": "^25.9.1",
35
33
  "@typescript/native-preview": "7.0.0-dev.20260521.1",
36
34
  "loro-crdt": "^1.13.6",
37
- "tsx": "^4.22.3",
38
35
  "typescript": "^6.0.3",
39
36
  "vite-plugin-wasm": "^3.6.0",
40
37
  "vite-plus": "^0.1.23",