Add incremental Automerge field changes

This commit is contained in:
Timothy J. Aveni
2026-09-03 09:35:28 -07:00
parent fceb49bb2c
commit a5899e6b57
5 changed files with 251 additions and 35 deletions
+4 -1
View File
@@ -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<AutomergeFieldDoc>, type: string) => CaminoCrdtEnvelope;
export declare const createAutomergeFieldValue: (value: unknown, type: string) => CaminoCrdtEnvelope;
export declare const isAutomergeFieldDoc: (value: unknown) => value is Automerge.Doc<AutomergeFieldDoc>;
export declare const createAutomergeFieldChanges: (base: Automerge.Doc<AutomergeFieldDoc>, next: Automerge.Doc<AutomergeFieldDoc>, 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;
+83 -12
View File
@@ -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,
+1 -1
View File
File diff suppressed because one or more lines are too long