diff --git a/dist/src/crdt-automerge.d.ts b/dist/src/crdt-automerge.d.ts index 0a47818..3a030ed 100644 --- a/dist/src/crdt-automerge.d.ts +++ b/dist/src/crdt-automerge.d.ts @@ -1,5 +1,7 @@ import * as Automerge from "@automerge/automerge"; -export type CaminoCrdtEncoding = "base64"; +export declare const AUTOMERGE_SNAPSHOT_ENCODING = "automerge-snapshot-v1"; +export declare const AUTOMERGE_CHANGES_ENCODING = "automerge-changes-v1"; +export type CaminoCrdtEncoding = "base64" | typeof AUTOMERGE_SNAPSHOT_ENCODING | typeof AUTOMERGE_CHANGES_ENCODING; export type CaminoCrdtEnvelope = { $caminoCrdtType: string; $caminoCrdtEncoding: CaminoCrdtEncoding; @@ -16,6 +18,7 @@ export declare const loadAutomergeFieldDoc: (value: CaminoCrdtEnvelope, type: st export declare const saveAutomergeFieldDoc: (doc: Automerge.Doc, type: string) => CaminoCrdtEnvelope; export declare const createAutomergeFieldValue: (value: unknown, type: string) => CaminoCrdtEnvelope; export declare const isAutomergeFieldDoc: (value: unknown) => value is Automerge.Doc; +export declare const createAutomergeFieldChanges: (base: Automerge.Doc, next: Automerge.Doc, type: string) => CaminoCrdtEnvelope; export declare const encodeAutomergeFieldWrite: (value: unknown, type: string) => CaminoCrdtEnvelope; export declare const normalizeAutomergeFieldValue: (value: unknown, type: string) => CaminoCrdtEnvelope; export declare const materializeAutomergeFieldValue: (value: CaminoCrdtEnvelope, type: string) => unknown; diff --git a/dist/src/crdt-automerge.js b/dist/src/crdt-automerge.js index 3211a14..495dde4 100644 --- a/dist/src/crdt-automerge.js +++ b/dist/src/crdt-automerge.js @@ -1,4 +1,6 @@ import * as Automerge from "@automerge/automerge"; +export const AUTOMERGE_SNAPSHOT_ENCODING = "automerge-snapshot-v1"; +export const AUTOMERGE_CHANGES_ENCODING = "automerge-changes-v1"; const bytesToBase64 = (value) => { if (typeof Buffer !== "undefined") { return Buffer.from(value).toString("base64"); @@ -11,7 +13,7 @@ const bytesToBase64 = (value) => { }; const base64ToBytes = (value) => { if (typeof Buffer !== "undefined") { - return Buffer.from(value, "base64"); + return new Uint8Array(Buffer.from(value, "base64")); } const binary = atob(value); const bytes = new Uint8Array(binary.length); @@ -20,6 +22,9 @@ const base64ToBytes = (value) => { } return bytes; }; +const isCrdtEncoding = (value) => value === "base64" || + value === AUTOMERGE_SNAPSHOT_ENCODING || + value === AUTOMERGE_CHANGES_ENCODING; export const isCaminoCrdtEnvelope = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value) && @@ -27,22 +32,27 @@ export const isCaminoCrdtEnvelope = (value) => Boolean(value) && "string" && typeof value.$caminoCrdtPayload === "string" && - (value.$caminoCrdtEncoding === - undefined || - value.$caminoCrdtEncoding === - "base64"); + isCrdtEncoding(value.$caminoCrdtEncoding ?? + "base64"); const assertEnvelopeType = (value, type) => { if (value.$caminoCrdtType !== type) { throw new Error(`CRDT type mismatch: expected ${type}, got ${value.$caminoCrdtType}`); } }; -export const loadAutomergeFieldDoc = (value, type) => { +const requireSnapshotEnvelope = (value, type) => { assertEnvelopeType(value, type); - return Automerge.load(base64ToBytes(value.$caminoCrdtPayload)); + if (value.$caminoCrdtEncoding === AUTOMERGE_CHANGES_ENCODING) { + throw new Error("An Automerge change batch cannot be loaded without a base document"); + } + return value; +}; +export const loadAutomergeFieldDoc = (value, type) => { + const snapshot = requireSnapshotEnvelope(value, type); + return Automerge.load(base64ToBytes(snapshot.$caminoCrdtPayload)); }; export const saveAutomergeFieldDoc = (doc, type) => ({ $caminoCrdtType: type, - $caminoCrdtEncoding: "base64", + $caminoCrdtEncoding: AUTOMERGE_SNAPSHOT_ENCODING, $caminoCrdtPayload: bytesToBase64(Automerge.save(doc)), }); export const createAutomergeFieldValue = (value, type) => saveAutomergeFieldDoc(Automerge.from({ value }), type); @@ -58,6 +68,52 @@ export const isAutomergeFieldDoc = (value) => { return false; } }; +const encodeChanges = (changes) => { + const totalLength = changes.reduce((total, change) => total + 4 + change.length, 4); + const payload = new Uint8Array(totalLength); + const view = new DataView(payload.buffer); + view.setUint32(0, changes.length); + let offset = 4; + for (const change of changes) { + view.setUint32(offset, change.length); + offset += 4; + payload.set(change, offset); + offset += change.length; + } + return payload; +}; +const decodeChanges = (payload) => { + if (payload.length < 4) { + throw new Error("Invalid Automerge change batch: missing change count"); + } + const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + const count = view.getUint32(0); + const changes = []; + let offset = 4; + for (let index = 0; index < count; index += 1) { + if (offset + 4 > payload.length) { + throw new Error("Invalid Automerge change batch: missing change length"); + } + const length = view.getUint32(offset); + offset += 4; + if (offset + length > payload.length) { + throw new Error("Invalid Automerge change batch: truncated change"); + } + changes.push(payload.slice(offset, offset + length)); + offset += length; + } + if (offset !== payload.length) { + throw new Error("Invalid Automerge change batch: trailing bytes"); + } + return changes; +}; +export const createAutomergeFieldChanges = (base, next, type) => ({ + $caminoCrdtType: type, + $caminoCrdtEncoding: AUTOMERGE_CHANGES_ENCODING, + $caminoCrdtPayload: bytesToBase64(encodeChanges(Automerge.getChanges(base, next))), +}); +// Full snapshots remain an explicit escape hatch for initialization, import, +// and recovery when a client cannot establish a shared Automerge history. export const encodeAutomergeFieldWrite = (value, type) => { if (isCaminoCrdtEnvelope(value)) { return saveAutomergeFieldDoc(loadAutomergeFieldDoc(value, type), type); @@ -70,10 +126,25 @@ export const encodeAutomergeFieldWrite = (value, type) => { export const normalizeAutomergeFieldValue = (value, type) => encodeAutomergeFieldWrite(value, type); export const materializeAutomergeFieldValue = (value, type) => loadAutomergeFieldDoc(value, type).value; export const mergeAutomergeFieldValues = ({ existing, incoming, type, }) => { - const incomingDoc = loadAutomergeFieldDoc(normalizeAutomergeFieldValue(incoming, type), type); - const mergedDoc = existing === undefined || existing === null - ? incomingDoc - : Automerge.merge(loadAutomergeFieldDoc(normalizeAutomergeFieldValue(existing, type), type), incomingDoc); + const incomingEnvelope = isCaminoCrdtEnvelope(incoming) + ? incoming + : encodeAutomergeFieldWrite(incoming, type); + assertEnvelopeType(incomingEnvelope, type); + let mergedDoc; + if (incomingEnvelope.$caminoCrdtEncoding === AUTOMERGE_CHANGES_ENCODING) { + if (existing === undefined || existing === null) { + throw new Error("Cannot apply an Automerge change batch to a missing field"); + } + const existingDoc = loadAutomergeFieldDoc(encodeAutomergeFieldWrite(existing, type), type); + [mergedDoc] = Automerge.applyChanges(existingDoc, decodeChanges(base64ToBytes(incomingEnvelope.$caminoCrdtPayload))); + } + else { + const incomingDoc = loadAutomergeFieldDoc(incomingEnvelope, type); + mergedDoc = + existing === undefined || existing === null + ? incomingDoc + : Automerge.merge(loadAutomergeFieldDoc(encodeAutomergeFieldWrite(existing, type), type), incomingDoc); + } const stored = saveAutomergeFieldDoc(mergedDoc, type); return { stored, diff --git a/dist/src/crdt-automerge.js.map b/dist/src/crdt-automerge.js.map index 10a7b5e..abc7652 100644 --- a/dist/src/crdt-automerge.js.map +++ b/dist/src/crdt-automerge.js.map @@ -1 +1 @@ -{"version":3,"file":"crdt-automerge.js","sourceRoot":"","sources":["../../src/crdt-automerge.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"crdt-automerge.js","sourceRoot":"","sources":["../../src/crdt-automerge.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/crdt-automerge.ts b/src/crdt-automerge.ts index d89071d..bb4124b 100644 --- a/src/crdt-automerge.ts +++ b/src/crdt-automerge.ts @@ -1,6 +1,13 @@ import * as Automerge from "@automerge/automerge"; -export type CaminoCrdtEncoding = "base64"; +export const AUTOMERGE_SNAPSHOT_ENCODING = "automerge-snapshot-v1"; +export const AUTOMERGE_CHANGES_ENCODING = "automerge-changes-v1"; + +// `base64` was the original encoding label. Its payload was an Automerge save. +export type CaminoCrdtEncoding = + | "base64" + | typeof AUTOMERGE_SNAPSHOT_ENCODING + | typeof AUTOMERGE_CHANGES_ENCODING; export type CaminoCrdtEnvelope = { $caminoCrdtType: string; @@ -29,7 +36,7 @@ const bytesToBase64 = (value: Uint8Array) => { const base64ToBytes = (value: string) => { if (typeof Buffer !== "undefined") { - return Buffer.from(value, "base64"); + return new Uint8Array(Buffer.from(value, "base64")); } const binary = atob(value); const bytes = new Uint8Array(binary.length); @@ -39,6 +46,11 @@ const base64ToBytes = (value: string) => { return bytes; }; +const isCrdtEncoding = (value: unknown): value is CaminoCrdtEncoding => + value === "base64" || + value === AUTOMERGE_SNAPSHOT_ENCODING || + value === AUTOMERGE_CHANGES_ENCODING; + export const isCaminoCrdtEnvelope = ( value: unknown, ): value is CaminoCrdtEnvelope => @@ -49,10 +61,10 @@ export const isCaminoCrdtEnvelope = ( "string" && typeof (value as { $caminoCrdtPayload?: unknown }).$caminoCrdtPayload === "string" && - ((value as { $caminoCrdtEncoding?: unknown }).$caminoCrdtEncoding === - undefined || - (value as { $caminoCrdtEncoding?: unknown }).$caminoCrdtEncoding === - "base64"); + isCrdtEncoding( + (value as { $caminoCrdtEncoding?: unknown }).$caminoCrdtEncoding ?? + "base64", + ); const assertEnvelopeType = (value: CaminoCrdtEnvelope, type: string) => { if (value.$caminoCrdtType !== type) { @@ -62,13 +74,21 @@ const assertEnvelopeType = (value: CaminoCrdtEnvelope, type: string) => { } }; +const requireSnapshotEnvelope = (value: CaminoCrdtEnvelope, type: string) => { + assertEnvelopeType(value, type); + if (value.$caminoCrdtEncoding === AUTOMERGE_CHANGES_ENCODING) { + throw new Error("An Automerge change batch cannot be loaded without a base document"); + } + return value; +}; + export const loadAutomergeFieldDoc = ( value: CaminoCrdtEnvelope, type: string, ): Automerge.Doc => { - assertEnvelopeType(value, type); + const snapshot = requireSnapshotEnvelope(value, type); return Automerge.load( - base64ToBytes(value.$caminoCrdtPayload), + base64ToBytes(snapshot.$caminoCrdtPayload), ); }; @@ -77,7 +97,7 @@ export const saveAutomergeFieldDoc = ( type: string, ): CaminoCrdtEnvelope => ({ $caminoCrdtType: type, - $caminoCrdtEncoding: "base64", + $caminoCrdtEncoding: AUTOMERGE_SNAPSHOT_ENCODING, $caminoCrdtPayload: bytesToBase64(Automerge.save(doc)), }); @@ -101,6 +121,61 @@ export const isAutomergeFieldDoc = ( } }; +const encodeChanges = (changes: Automerge.Change[]): Uint8Array => { + const totalLength = changes.reduce((total, change) => total + 4 + change.length, 4); + const payload = new Uint8Array(totalLength); + const view = new DataView(payload.buffer); + view.setUint32(0, changes.length); + let offset = 4; + for (const change of changes) { + view.setUint32(offset, change.length); + offset += 4; + payload.set(change, offset); + offset += change.length; + } + return payload; +}; + +const decodeChanges = (payload: Uint8Array): Automerge.Change[] => { + if (payload.length < 4) { + throw new Error("Invalid Automerge change batch: missing change count"); + } + const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + const count = view.getUint32(0); + const changes: Automerge.Change[] = []; + let offset = 4; + for (let index = 0; index < count; index += 1) { + if (offset + 4 > payload.length) { + throw new Error("Invalid Automerge change batch: missing change length"); + } + const length = view.getUint32(offset); + offset += 4; + if (offset + length > payload.length) { + throw new Error("Invalid Automerge change batch: truncated change"); + } + changes.push(payload.slice(offset, offset + length)); + offset += length; + } + if (offset !== payload.length) { + throw new Error("Invalid Automerge change batch: trailing bytes"); + } + return changes; +}; + +export const createAutomergeFieldChanges = ( + base: Automerge.Doc, + next: Automerge.Doc, + type: string, +): CaminoCrdtEnvelope => ({ + $caminoCrdtType: type, + $caminoCrdtEncoding: AUTOMERGE_CHANGES_ENCODING, + $caminoCrdtPayload: bytesToBase64( + encodeChanges(Automerge.getChanges(base, next)), + ), +}); + +// Full snapshots remain an explicit escape hatch for initialization, import, +// and recovery when a client cannot establish a shared Automerge history. export const encodeAutomergeFieldWrite = ( value: unknown, type: string, @@ -142,17 +217,35 @@ export const mergeAutomergeFieldValues = ({ incoming, type, }: MergeAutomergeFieldValueParams): MergeAutomergeFieldValueResult => { - const incomingDoc = loadAutomergeFieldDoc( - normalizeAutomergeFieldValue(incoming, type), - type, - ); - const mergedDoc = - existing === undefined || existing === null - ? incomingDoc - : Automerge.merge( - loadAutomergeFieldDoc(normalizeAutomergeFieldValue(existing, type), type), - incomingDoc, - ); + const incomingEnvelope = isCaminoCrdtEnvelope(incoming) + ? incoming + : encodeAutomergeFieldWrite(incoming, type); + assertEnvelopeType(incomingEnvelope, type); + + let mergedDoc: Automerge.Doc; + if (incomingEnvelope.$caminoCrdtEncoding === AUTOMERGE_CHANGES_ENCODING) { + if (existing === undefined || existing === null) { + throw new Error("Cannot apply an Automerge change batch to a missing field"); + } + const existingDoc = loadAutomergeFieldDoc( + encodeAutomergeFieldWrite(existing, type), + type, + ); + [mergedDoc] = Automerge.applyChanges( + existingDoc, + decodeChanges(base64ToBytes(incomingEnvelope.$caminoCrdtPayload)), + ); + } else { + const incomingDoc = loadAutomergeFieldDoc(incomingEnvelope, type); + mergedDoc = + existing === undefined || existing === null + ? incomingDoc + : Automerge.merge( + loadAutomergeFieldDoc(encodeAutomergeFieldWrite(existing, type), type), + incomingDoc, + ); + } + const stored = saveAutomergeFieldDoc(mergedDoc, type); return { stored, diff --git a/test/crdt-automerge.test.ts b/test/crdt-automerge.test.ts index 95cfc0c..7d09e45 100644 --- a/test/crdt-automerge.test.ts +++ b/test/crdt-automerge.test.ts @@ -2,7 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import * as Automerge from "@automerge/automerge"; import { + AUTOMERGE_CHANGES_ENCODING, + AUTOMERGE_SNAPSHOT_ENCODING, createAutomergeFieldValue, + createAutomergeFieldChanges, encodeAutomergeFieldWrite, isAutomergeFieldDoc, loadAutomergeFieldDoc, @@ -17,7 +20,7 @@ test("Automerge CRDT fields explicitly create stored envelopes", () => { const stored = createAutomergeFieldValue({ title: "draft" }, type); assert.equal(stored.$caminoCrdtType, type); - assert.equal(stored.$caminoCrdtEncoding, "base64"); + assert.equal(stored.$caminoCrdtEncoding, AUTOMERGE_SNAPSHOT_ENCODING); assert.deepEqual(materializeAutomergeFieldValue(stored, type), { title: "draft", }); @@ -72,3 +75,49 @@ test("Automerge CRDT fields merge divergent saved documents", () => { right: true, }); }); + +test("Automerge CRDT fields encode and apply incremental changes", () => { + const stored = createAutomergeFieldValue({ title: "draft" }, type); + const base = loadAutomergeFieldDoc(stored, type); + const changed = Automerge.change(base, (doc) => { + (doc.value as { title: string }).title = "edited"; + }); + const update = createAutomergeFieldChanges(base, changed, type); + + assert.equal(update.$caminoCrdtEncoding, AUTOMERGE_CHANGES_ENCODING); + assert.ok(update.$caminoCrdtPayload.length < saveAutomergeFieldDoc(changed, type).$caminoCrdtPayload.length); + + const result = mergeAutomergeFieldValues({ existing: stored, incoming: update, type }); + assert.deepEqual(result.materialized, { title: "edited" }); + assert.equal(result.stored.$caminoCrdtEncoding, AUTOMERGE_SNAPSHOT_ENCODING); +}); + +test("Automerge change application is idempotent", () => { + const stored = createAutomergeFieldValue({ count: 0 }, type); + const base = loadAutomergeFieldDoc(stored, type); + const changed = Automerge.change(base, (doc) => { + (doc.value as { count: number }).count = 1; + }); + const update = createAutomergeFieldChanges(base, changed, type); + + const once = mergeAutomergeFieldValues({ existing: stored, incoming: update, type }); + const twice = mergeAutomergeFieldValues({ existing: once.stored, incoming: update, type }); + assert.deepEqual(twice.materialized, { count: 1 }); +}); + +test("Automerge change batches reject malformed framing", () => { + const stored = createAutomergeFieldValue({ count: 0 }, type); + assert.throws( + () => + mergeAutomergeFieldValues({ + existing: stored, + incoming: { + $caminoCrdtType: type, + $caminoCrdtEncoding: AUTOMERGE_CHANGES_ENCODING, + $caminoCrdtPayload: Buffer.from([0, 0, 0, 1]).toString("base64"), + }, + type, + }), + /missing change length/, + ); +});