Add Automerge CRDT datatype helpers
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AutomergeFieldDoc> => {
|
||||
assertEnvelopeType(value, type);
|
||||
return Automerge.load<AutomergeFieldDoc>(
|
||||
base64ToBytes(value.$caminoCrdtPayload),
|
||||
);
|
||||
};
|
||||
|
||||
export const saveAutomergeFieldDoc = (
|
||||
doc: Automerge.Doc<AutomergeFieldDoc>,
|
||||
type: string,
|
||||
): CaminoCrdtEnvelope => ({
|
||||
$caminoCrdtType: type,
|
||||
$caminoCrdtEncoding: "base64",
|
||||
$caminoCrdtPayload: bytesToBase64(Automerge.save(doc)),
|
||||
});
|
||||
|
||||
export const createAutomergeFieldValue = (
|
||||
value: unknown,
|
||||
type: string,
|
||||
): CaminoCrdtEnvelope =>
|
||||
saveAutomergeFieldDoc(Automerge.from<AutomergeFieldDoc>({ 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,
|
||||
};
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./field-semantics.js";
|
||||
export * from "./crdt-automerge.js";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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<compat/typescript>::version=5.9.3&hash=5786d5" = { filename = "typescript-patch-6fda4d02cf-ad09fdf7a7.zip"; hash = "sha512-rQn996dWgU3OZbxgwWV7QNREUTRoWO6iMOEPLpWiidkYO24y5cEelazAzMIUtPNiidytS/GIawrbhNcR0zakMA=="; };
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user