From e72b3c5577d0752b046f45a1a43bd1bece490930 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Sun, 12 Jul 2026 20:15:51 -0700 Subject: [PATCH] Encode Automerge docs for Camino CRDT writes --- dist/src/crdt-automerge.d.ts | 5 ++++ dist/src/crdt-automerge.js | 41 +++++++++++++++++++++++++-- dist/src/crdt-automerge.js.map | 2 +- src/crdt-automerge.ts | 52 +++++++++++++++++++++++++++++++--- test/crdt-automerge.test.ts | 20 +++++++++++++ 5 files changed, 112 insertions(+), 8 deletions(-) diff --git a/dist/src/crdt-automerge.d.ts b/dist/src/crdt-automerge.d.ts index 7ae4067..0a47818 100644 --- a/dist/src/crdt-automerge.d.ts +++ b/dist/src/crdt-automerge.d.ts @@ -8,10 +8,15 @@ export type CaminoCrdtEnvelope = { type AutomergeFieldDoc = { value: unknown; }; +export type CaminoAutomergeDoc = Automerge.Doc<{ + value: T; +}>; export declare const isCaminoCrdtEnvelope: (value: unknown) => value is CaminoCrdtEnvelope; export declare const loadAutomergeFieldDoc: (value: CaminoCrdtEnvelope, type: string) => Automerge.Doc; 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 encodeAutomergeFieldWrite: (value: unknown, type: string) => CaminoCrdtEnvelope; export declare const normalizeAutomergeFieldValue: (value: unknown, type: string) => CaminoCrdtEnvelope; export declare const materializeAutomergeFieldValue: (value: CaminoCrdtEnvelope, type: string) => unknown; export type MergeAutomergeFieldValueParams = { diff --git a/dist/src/crdt-automerge.js b/dist/src/crdt-automerge.js index cc8c637..57557a9 100644 --- a/dist/src/crdt-automerge.js +++ b/dist/src/crdt-automerge.js @@ -1,6 +1,25 @@ import * as Automerge from "@automerge/automerge"; -const bytesToBase64 = (value) => Buffer.from(value).toString("base64"); -const base64ToBytes = (value) => Buffer.from(value, "base64"); +const bytesToBase64 = (value) => { + if (typeof Buffer !== "undefined") { + return Buffer.from(value).toString("base64"); + } + let binary = ""; + for (const byte of value) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +}; +const base64ToBytes = (value) => { + if (typeof Buffer !== "undefined") { + return Buffer.from(value, "base64"); + } + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +}; export const isCaminoCrdtEnvelope = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value) && @@ -27,12 +46,28 @@ export const saveAutomergeFieldDoc = (doc, type) => ({ $caminoCrdtPayload: bytesToBase64(Automerge.save(doc)), }); export const createAutomergeFieldValue = (value, type) => saveAutomergeFieldDoc(Automerge.from({ value }), type); -export const normalizeAutomergeFieldValue = (value, type) => { +export const isAutomergeFieldDoc = (value) => { + if (!value || typeof value !== "object") { + return false; + } + try { + Automerge.getHeads(value); + return true; + } + catch { + return false; + } +}; +export const encodeAutomergeFieldWrite = (value, type) => { if (isCaminoCrdtEnvelope(value)) { return saveAutomergeFieldDoc(loadAutomergeFieldDoc(value, type), type); } + if (isAutomergeFieldDoc(value)) { + return saveAutomergeFieldDoc(value, type); + } return createAutomergeFieldValue(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); diff --git a/dist/src/crdt-automerge.js.map b/dist/src/crdt-automerge.js.map index 3c29f04..33e9f9d 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 a2dc9c3..00ca01b 100644 --- a/src/crdt-automerge.ts +++ b/src/crdt-automerge.ts @@ -12,10 +12,32 @@ type AutomergeFieldDoc = { value: unknown; }; -const bytesToBase64 = (value: Uint8Array) => - Buffer.from(value).toString("base64"); +export type CaminoAutomergeDoc = Automerge.Doc<{ + value: T; +}>; -const base64ToBytes = (value: string) => Buffer.from(value, "base64"); +const bytesToBase64 = (value: Uint8Array) => { + if (typeof Buffer !== "undefined") { + return Buffer.from(value).toString("base64"); + } + let binary = ""; + for (const byte of value) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +}; + +const base64ToBytes = (value: string) => { + if (typeof Buffer !== "undefined") { + return Buffer.from(value, "base64"); + } + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +}; export const isCaminoCrdtEnvelope = ( value: unknown, @@ -65,16 +87,38 @@ export const createAutomergeFieldValue = ( ): CaminoCrdtEnvelope => saveAutomergeFieldDoc(Automerge.from({ value }), type); -export const normalizeAutomergeFieldValue = ( +export const isAutomergeFieldDoc = ( + value: unknown, +): value is Automerge.Doc => { + if (!value || typeof value !== "object") { + return false; + } + try { + Automerge.getHeads(value as Automerge.Doc); + return true; + } catch { + return false; + } +}; + +export const encodeAutomergeFieldWrite = ( value: unknown, type: string, ): CaminoCrdtEnvelope => { if (isCaminoCrdtEnvelope(value)) { return saveAutomergeFieldDoc(loadAutomergeFieldDoc(value, type), type); } + if (isAutomergeFieldDoc(value)) { + return saveAutomergeFieldDoc(value, type); + } return createAutomergeFieldValue(value, type); }; +export const normalizeAutomergeFieldValue = ( + value: unknown, + type: string, +): CaminoCrdtEnvelope => encodeAutomergeFieldWrite(value, type); + export const materializeAutomergeFieldValue = ( value: CaminoCrdtEnvelope, type: string, diff --git a/test/crdt-automerge.test.ts b/test/crdt-automerge.test.ts index 28d7733..43bc4dc 100644 --- a/test/crdt-automerge.test.ts +++ b/test/crdt-automerge.test.ts @@ -3,6 +3,8 @@ import test from "node:test"; import * as Automerge from "@automerge/automerge"; import { createAutomergeFieldValue, + encodeAutomergeFieldWrite, + isAutomergeFieldDoc, loadAutomergeFieldDoc, materializeAutomergeFieldValue, mergeAutomergeFieldValues, @@ -21,6 +23,24 @@ test("Automerge CRDT fields lift plain values into stored envelopes", () => { }); }); +test("Automerge CRDT fields encode rehydrated docs for writes", () => { + const base = loadAutomergeFieldDoc( + createAutomergeFieldValue({ title: "draft" }, type), + type, + ); + const changed = Automerge.change(base, (doc) => { + (doc.value as { title: string }).title = "edited"; + }); + + assert.equal(isAutomergeFieldDoc(changed), true); + + const stored = encodeAutomergeFieldWrite(changed, type); + + assert.deepEqual(materializeAutomergeFieldValue(stored, type), { + title: "edited", + }); +}); + test("Automerge CRDT fields merge divergent saved documents", () => { const base = loadAutomergeFieldDoc( createAutomergeFieldValue({ left: false, right: false }, type),