From 5a54c0ba887016e1c14831e0fcd2c0fc1f0a500c Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 28 Sep 2026 01:01:52 +0800 Subject: [PATCH] fix(loro.js): use the Rust `L{lamport}@{peer}` format for movable-list JSON element IDs Rust rejected loro.js JSON updates containing movable-list moves or sets, and loro.js failed on Rust's. The unprefixed form is still accepted on import. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/loro-js-json-movable-elem-id.md | 5 ++++ loro-js/src/runtime/document.ts | 25 +++++++++------- loro-js/src/runtime/types.ts | 6 ++-- loro-js/tests/runtime.test.ts | 33 ++++++++++++++++++++++ 4 files changed, 57 insertions(+), 12 deletions(-) create mode 100644 .changeset/loro-js-json-movable-elem-id.md diff --git a/.changeset/loro-js-json-movable-elem-id.md b/.changeset/loro-js-json-movable-elem-id.md new file mode 100644 index 000000000..630bbf0ce --- /dev/null +++ b/.changeset/loro-js-json-movable-elem-id.md @@ -0,0 +1,5 @@ +--- +"loro.js": patch +--- + +`exportJsonUpdates` now writes movable-list move and set element IDs as `L{lamport}@{peer}`, the format Rust and `loro-crdt` use, and `importJsonUpdates` accepts it. Before, `loro.js` wrote `{lamport}@{peer}`, which Rust rejects, and failed on Rust-produced JSON with `counter is out of range: NaN`. The old form is still accepted on import. The `elem_id` field type is now `JsonIdLp`. diff --git a/loro-js/src/runtime/document.ts b/loro-js/src/runtime/document.ts index 9b5decf69..29c9f7d6c 100644 --- a/loro-js/src/runtime/document.ts +++ b/loro-js/src/runtime/document.ts @@ -113,6 +113,7 @@ import type { JsonChange, JsonContainerID, JsonDiff, + JsonIdLp, JsonOp, JsonOpContent, JsonSchema, @@ -7115,19 +7116,13 @@ function decodedOperationToJson( type: "move", from: content.from, to: content.to, - elem_id: formatJsonOpId( - { peer: content.elementId.peer, counter: content.elementId.lamport }, - peerMap, - ), + elem_id: formatJsonIdLp(content.elementId, peerMap), }; break; case "movable-list-set": json = { type: "set", - elem_id: formatJsonOpId( - { peer: content.elementId.peer, counter: content.elementId.lamport }, - peerMap, - ), + elem_id: formatJsonIdLp(content.elementId, peerMap), value: changeLoroValueToJson(content.value, keys, operationId, peerMap), }; break; @@ -7230,6 +7225,13 @@ function formatJsonOpId(id: CodecId, peerMap?: JsonPeerMap): `${number}@${PeerID return `${id.counter}@${(peerMap?.get(id.peer) ?? id.peer).toString()}` as `${number}@${PeerID}`; } +function formatJsonIdLp( + id: { readonly peer: bigint; readonly lamport: number }, + peerMap?: JsonPeerMap, +): JsonIdLp { + return `L${id.lamport}@${(peerMap?.get(id.peer) ?? id.peer).toString()}` as JsonIdLp; +} + function formatJsonTreeId(id: CodecId, peerMap?: JsonPeerMap): TreeID { return formatJsonOpId(id, peerMap) as TreeID; } @@ -7323,6 +7325,9 @@ function jsonOperationToDecoded( const parsed = parseTreeId(value as TreeID); return { peer: resolvePeer(parsed.peer), counter: parsed.counter }; }; + // Rust writes `L{lamport}@{peer}`; loro.js 0.2.1 and earlier omitted the `L`. + const parseIdLp = (value: unknown): CodecId => + parseId(typeof value === "string" && value.startsWith("L") ? value.slice(1) : value); const parseParent = (value: unknown): CodecId | undefined => value === null || value === undefined ? undefined : parseId(value); const encodeValue = (value: unknown, id = operationId): ChangeLoroValue => @@ -7443,7 +7448,7 @@ function jsonOperationToDecoded( }; } if (movable && content.type === "move") { - const elementId = parseId(content.elem_id); + const elementId = parseIdLp(content.elem_id); return { container, counter: operation.counter, @@ -7457,7 +7462,7 @@ function jsonOperationToDecoded( }; } if (movable && content.type === "set") { - const elementId = parseId(content.elem_id); + const elementId = parseIdLp(content.elem_id); return { container, counter: operation.counter, diff --git a/loro-js/src/runtime/types.ts b/loro-js/src/runtime/types.ts index 19e5bc412..70a1db0ce 100644 --- a/loro-js/src/runtime/types.ts +++ b/loro-js/src/runtime/types.ts @@ -63,6 +63,8 @@ export interface Change { } export type JsonOpID = `${number}@${PeerID}`; +/** A lamport-based element ID, `L{lamport}@{peer}`, as used by movable-list moves and sets. */ +export type JsonIdLp = `L${number}@${PeerID}`; export type JsonValue = | string | number @@ -96,9 +98,9 @@ export type JsonOpContent = readonly type: "move"; readonly from: number; readonly to: number; - readonly elem_id: JsonOpID; + readonly elem_id: JsonIdLp; } - | { readonly type: "set"; readonly elem_id: JsonOpID; readonly value: JsonValue } + | { readonly type: "set"; readonly elem_id: JsonIdLp; readonly value: JsonValue } | { readonly type: "create" | "move"; readonly target: TreeID; diff --git a/loro-js/tests/runtime.test.ts b/loro-js/tests/runtime.test.ts index eb24e33c7..10878be8d 100644 --- a/loro-js/tests/runtime.test.ts +++ b/loro-js/tests/runtime.test.ts @@ -812,6 +812,39 @@ describe("loro-wasm-compatible runtime", () => { } }); + test("reads and writes movable-list element IDs in the Rust JSON format", () => { + // Rust writes `L{lamport}@{peer}`; this fixture contains moves and sets. + const rustJson = readFileSync( + new URL("./fixtures/rust/updates.json", import.meta.url), + "utf8", + ); + // The JSON schema stores binary values as number arrays, in Rust as well. + const withoutBinary = (doc: LoroDoc): unknown => + JSON.parse( + JSON.stringify(doc.toJSON(), (_key, value: unknown) => + value instanceof Uint8Array ? [...value] : value, + ), + ); + const fromJson = new LoroDoc(); + fromJson.importJsonUpdates(rustJson); + const fromBinary = new LoroDoc(); + fromBinary.import(fixture("updates.blob")); + expect(withoutBinary(fromJson)).toEqual(withoutBinary(fromBinary)); + + const elementIds = fromBinary + .exportJsonUpdates() + .changes.flatMap((change) => change.ops) + .flatMap(({ content }) => ("elem_id" in content ? [content.elem_id] : [])); + expect(elementIds.length).toBeGreaterThan(0); + for (const id of elementIds) expect(id).toMatch(/^L\d+@\d+$/u); + + // loro.js 0.2.1 and earlier wrote the ID without the `L` prefix. + const legacy = JSON.parse(rustJson.replaceAll(/"elem_id": "L/gu, '"elem_id": "')); + const fromLegacy = new LoroDoc(); + fromLegacy.importJsonUpdates(legacy); + expect(withoutBinary(fromLegacy)).toEqual(withoutBinary(fromBinary)); + }); + test("redacts JSON update content while preserving child-container structure", () => { const source = new LoroDoc(); source.setPeerId(1);