@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.
- package/dist/{index-DRUGsbm2.d.ts → index-DLchEQG7.d.ts} +723 -112
- package/dist/index.d.ts +562 -75
- package/dist/index.js +1307 -145
- package/dist/{document-DgffKwRw.js → loro-relay-doc-Br-ZJBHa.js} +136 -134
- package/dist/testing.d.ts +16 -1
- package/dist/testing.js +57 -6
- package/package.json +4 -7
- package/dist/index-6e5cbdM3.d.ts +0 -475
- package/dist/schemas.d.ts +0 -2
- package/dist/schemas.js +0 -378
|
@@ -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
|
-
|
|
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))
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
|
1448
|
-
function
|
|
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/
|
|
1539
|
+
//#region src/relay/loro-relay-doc.ts
|
|
1479
1540
|
/**
|
|
1480
|
-
*
|
|
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
|
-
*
|
|
1483
|
-
*
|
|
1484
|
-
*
|
|
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
|
-
*
|
|
1491
|
-
*
|
|
1492
|
-
*
|
|
1493
|
-
*
|
|
1494
|
-
*
|
|
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
|
-
/**
|
|
1497
|
-
|
|
1498
|
-
const
|
|
1499
|
-
|
|
1500
|
-
|
|
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
|
-
/**
|
|
1516
|
-
function
|
|
1517
|
-
|
|
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
|
-
|
|
1520
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1531
|
-
*
|
|
1532
|
-
*
|
|
1533
|
-
*
|
|
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
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
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
|
-
|
|
1562
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
|
30
|
-
"@mengine/sync": "1.0.1
|
|
31
|
-
"@mengine/utils": "1.0.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",
|