diff --git a/package.json b/package.json index ea1257a..03b3b5b 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,10 @@ "./field-semantics": { "types": "./src/field-semantics.ts", "default": "./src/field-semantics.ts" + }, + "./crdt-automerge": { + "types": "./src/crdt-automerge.ts", + "default": "./src/crdt-automerge.ts" } }, "scripts": { @@ -22,5 +26,8 @@ "devDependencies": { "@types/node": "^24", "typescript": "^5.9.3" + }, + "dependencies": { + "@automerge/automerge": "^3.3.0" } } diff --git a/src/crdt-automerge.ts b/src/crdt-automerge.ts new file mode 100644 index 0000000..a2dc9c3 --- /dev/null +++ b/src/crdt-automerge.ts @@ -0,0 +1,115 @@ +import * as Automerge from "@automerge/automerge"; + +export type CaminoCrdtEncoding = "base64"; + +export type CaminoCrdtEnvelope = { + $caminoCrdtType: string; + $caminoCrdtEncoding: CaminoCrdtEncoding; + $caminoCrdtPayload: string; +}; + +type AutomergeFieldDoc = { + value: unknown; +}; + +const bytesToBase64 = (value: Uint8Array) => + Buffer.from(value).toString("base64"); + +const base64ToBytes = (value: string) => Buffer.from(value, "base64"); + +export const isCaminoCrdtEnvelope = ( + value: unknown, +): value is CaminoCrdtEnvelope => + Boolean(value) && + typeof value === "object" && + !Array.isArray(value) && + typeof (value as { $caminoCrdtType?: unknown }).$caminoCrdtType === + "string" && + typeof (value as { $caminoCrdtPayload?: unknown }).$caminoCrdtPayload === + "string" && + ((value as { $caminoCrdtEncoding?: unknown }).$caminoCrdtEncoding === + undefined || + (value as { $caminoCrdtEncoding?: unknown }).$caminoCrdtEncoding === + "base64"); + +const assertEnvelopeType = (value: CaminoCrdtEnvelope, type: string) => { + if (value.$caminoCrdtType !== type) { + throw new Error( + `CRDT type mismatch: expected ${type}, got ${value.$caminoCrdtType}`, + ); + } +}; + +export const loadAutomergeFieldDoc = ( + value: CaminoCrdtEnvelope, + type: string, +): Automerge.Doc => { + assertEnvelopeType(value, type); + return Automerge.load( + base64ToBytes(value.$caminoCrdtPayload), + ); +}; + +export const saveAutomergeFieldDoc = ( + doc: Automerge.Doc, + type: string, +): CaminoCrdtEnvelope => ({ + $caminoCrdtType: type, + $caminoCrdtEncoding: "base64", + $caminoCrdtPayload: bytesToBase64(Automerge.save(doc)), +}); + +export const createAutomergeFieldValue = ( + value: unknown, + type: string, +): CaminoCrdtEnvelope => + saveAutomergeFieldDoc(Automerge.from({ value }), type); + +export const normalizeAutomergeFieldValue = ( + value: unknown, + type: string, +): CaminoCrdtEnvelope => { + if (isCaminoCrdtEnvelope(value)) { + return saveAutomergeFieldDoc(loadAutomergeFieldDoc(value, type), type); + } + return createAutomergeFieldValue(value, type); +}; + +export const materializeAutomergeFieldValue = ( + value: CaminoCrdtEnvelope, + type: string, +) => loadAutomergeFieldDoc(value, type).value; + +export type MergeAutomergeFieldValueParams = { + existing?: unknown; + incoming: unknown; + type: string; +}; + +export type MergeAutomergeFieldValueResult = { + stored: CaminoCrdtEnvelope; + materialized: unknown; +}; + +export const mergeAutomergeFieldValues = ({ + existing, + 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 stored = saveAutomergeFieldDoc(mergedDoc, type); + return { + stored, + materialized: mergedDoc.value, + }; +}; diff --git a/src/index.ts b/src/index.ts index f6f9996..fabe335 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ export * from "./field-semantics.js"; +export * from "./crdt-automerge.js"; diff --git a/test/crdt-automerge.test.ts b/test/crdt-automerge.test.ts new file mode 100644 index 0000000..28d7733 --- /dev/null +++ b/test/crdt-automerge.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import * as Automerge from "@automerge/automerge"; +import { + createAutomergeFieldValue, + loadAutomergeFieldDoc, + materializeAutomergeFieldValue, + mergeAutomergeFieldValues, + saveAutomergeFieldDoc, +} from "../src/crdt-automerge.js"; + +const type = "camino.CrdtJson"; + +test("Automerge CRDT fields lift plain values into stored envelopes", () => { + const stored = createAutomergeFieldValue({ title: "draft" }, type); + + assert.equal(stored.$caminoCrdtType, type); + assert.equal(stored.$caminoCrdtEncoding, "base64"); + assert.deepEqual(materializeAutomergeFieldValue(stored, type), { + title: "draft", + }); +}); + +test("Automerge CRDT fields merge divergent saved documents", () => { + const base = loadAutomergeFieldDoc( + createAutomergeFieldValue({ left: false, right: false }, type), + type, + ); + const left = Automerge.change(Automerge.clone(base, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), (doc) => { + (doc.value as { left: boolean }).left = true; + }); + const right = Automerge.change(Automerge.clone(base, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), (doc) => { + (doc.value as { right: boolean }).right = true; + }); + + const result = mergeAutomergeFieldValues({ + existing: saveAutomergeFieldDoc(left, type), + incoming: saveAutomergeFieldDoc(right, type), + type, + }); + + assert.deepEqual(result.materialized, { left: true, right: true }); + assert.deepEqual(materializeAutomergeFieldValue(result.stored, type), { + left: true, + right: true, + }); +}); diff --git a/yarn-project.nix b/yarn-project.nix index 6b20fb6..103af6b 100644 --- a/yarn-project.nix +++ b/yarn-project.nix @@ -156,6 +156,7 @@ let overriddenProject = optionalOverride overrideAttrs project; cacheEntries = { +"@automerge/automerge@npm:3.3.0" = { filename = "@automerge-automerge-npm-3.3.0-3f6661ca17-74b3a8ef9e.zip"; hash = "sha512-dLOo7548v0B2DomHJ+za1RKOUv0rBTgJRUrnsrhbTsZHw8uipjXNv4MfIxVzGtQl2jenhLeb8TvIbsKKfpKv8g=="; }; "@types/node@npm:24.13.3" = { filename = "@types-node-npm-24.13.3-b512a0bbeb-a5bc08f49b.zip"; hash = "sha512-pbwI9JuVgdzcqQ4CzXcZejx5mECAdmSULlzVFhpVPU0q6AZPfjKUQQ10jX0Ji1wYSL5/pQwG8pxBk6sWvk5Z6w=="; }; "typescript@npm:5.9.3" = { filename = "typescript-npm-5.9.3-48715be868-6bd7552ce3.zip"; hash = "sha512-a9dVLOOfl+cR21qgSPb5mVtT8cUvfYZnwavcFwDGinajCPV5zTCc5rU2Rt606aG+fIE6k7qvCijM1TajAnDhxQ=="; }; "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" = { filename = "typescript-patch-6fda4d02cf-ad09fdf7a7.zip"; hash = "sha512-rQn996dWgU3OZbxgwWV7QNREUTRoWO6iMOEPLpWiidkYO24y5cEelazAzMIUtPNiidytS/GIawrbhNcR0zakMA=="; }; diff --git a/yarn.lock b/yarn.lock index 0a384ed..8163127 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,10 +5,18 @@ __metadata: version: 9 cacheKey: 10c0 +"@automerge/automerge@npm:^3.3.0": + version: 3.3.0 + resolution: "@automerge/automerge@npm:3.3.0" + checksum: 10c0/74b3a8ef9e3cbf40760e898727ecdad5128e52fd2b053809454ae7b2b85b4ec647c3cba2a635cdbf831f2315731ad425da37a784b79bf13bc86ec28a7e92aff2 + languageName: node + linkType: hard + "@quixos/camino-datatypes@workspace:.": version: 0.0.0-use.local resolution: "@quixos/camino-datatypes@workspace:." dependencies: + "@automerge/automerge": "npm:^3.3.0" "@types/node": "npm:^24" typescript: "npm:^5.9.3" languageName: unknown