Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/loro-js-json-movable-elem-id.md
Original file line number Diff line number Diff line change
@@ -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`.
25 changes: 15 additions & 10 deletions loro-js/src/runtime/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import type {
JsonChange,
JsonContainerID,
JsonDiff,
JsonIdLp,
JsonOp,
JsonOpContent,
JsonSchema,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions loro-js/src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
33 changes: 33 additions & 0 deletions loro-js/tests/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading