Compare commits

...

12 Commits

Author SHA1 Message Date
Quixos Subtree Publisher 70f9f45eb7 Publish camino-datatypes from ce2c8b6ed1926da7b3b3fe4aa3f1bbd8f327470a 2026-09-03 16:39:22 +00:00
Timothy J. Aveni 1a4f3ab8cc Rebase pending Automerge field edits 2026-09-03 09:39:22 -07:00
Quixos Subtree Publisher 235c2ba075 Publish camino-datatypes from 3f984faf58a1e412467255f478e0736fdf1c1496 2026-09-03 16:35:28 +00:00
Timothy J. Aveni a5899e6b57 Add incremental Automerge field changes 2026-09-03 09:35:28 -07:00
Quixos Subtree Publisher e061e29070 Publish camino-datatypes from 5ae087c6c8f9c945d116858341ec882ab4c72636 2026-07-13 04:14:05 +00:00
Timothy J. Aveni fceb49bb2c Refresh Camino datatypes pins 2026-07-12 21:14:05 -07:00
Quixos Subtree Publisher 7a622dd028 Publish camino-datatypes from 1ed3d2fd04afba64ffda2125175704a6fdb4f2ad 2026-07-13 04:04:20 +00:00
Timothy J. Aveni cef58a7d48 Make Todo notes use CRDT docs directly 2026-07-12 21:04:20 -07:00
Quixos Subtree Publisher 709990b5f2 Publish camino-datatypes from 3a81f75f5da1be812d8bbc4dec9180c8d2b9fd0e 2026-07-13 03:44:22 +00:00
Timothy J. Aveni a9a6ef2cf4 Bump Camino package helper pins 2026-07-12 20:44:22 -07:00
Quixos Subtree Publisher fa50782559 Publish camino-datatypes from 4e9cbccd3882cded26ec198c0299435a8bfa6f99 2026-07-13 03:15:51 +00:00
Timothy J. Aveni e72b3c5577 Encode Automerge docs for Camino CRDT writes 2026-07-12 20:15:51 -07:00
8 changed files with 420 additions and 50 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos.git",
"sourceCommit": "a38d55b0e88bf087a55f4db515b769cfbb3c6545",
"sourceCommit": "ce2c8b6ed1926da7b3b3fe4aa3f1bbd8f327470a",
"sourcePath": "quixos-instance/packages/camino-datatypes",
"exportName": "camino-datatypes",
"mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/camino-datatypes.git"
+10 -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;
@@ -8,10 +10,17 @@ export type CaminoCrdtEnvelope = {
type AutomergeFieldDoc = {
value: unknown;
};
export type CaminoAutomergeDoc<T = unknown> = Automerge.Doc<{
value: T;
}>;
export declare const isCaminoCrdtEnvelope: (value: unknown) => value is CaminoCrdtEnvelope;
export declare const loadAutomergeFieldDoc: (value: CaminoCrdtEnvelope, type: string) => Automerge.Doc<AutomergeFieldDoc>;
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 reconcileAutomergeFieldDocs: <T>(base: CaminoAutomergeDoc<T>, desired: CaminoAutomergeDoc<T>) => CaminoAutomergeDoc<T>;
export declare const createAutomergeFieldChanges: (base: Automerge.Doc<AutomergeFieldDoc>, desired: 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;
export type MergeAutomergeFieldValueParams = {
+125 -15
View File
@@ -1,6 +1,30 @@
import * as Automerge from "@automerge/automerge";
const bytesToBase64 = (value) => Buffer.from(value).toString("base64");
const base64ToBytes = (value) => Buffer.from(value, "base64");
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");
}
let binary = "";
for (const byte of value) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
};
const base64ToBytes = (value) => {
if (typeof Buffer !== "undefined") {
return new Uint8Array(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;
};
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) &&
@@ -8,37 +32,123 @@ 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);
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;
}
};
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 reconcileAutomergeFieldDocs = (base, desired) => Automerge.merge(Automerge.clone(base), Automerge.clone(desired));
export const createAutomergeFieldChanges = (base, desired, type) => {
const next = reconcileAutomergeFieldDocs(base, desired);
return {
$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);
}
return createAutomergeFieldValue(value, type);
if (isAutomergeFieldDoc(value)) {
return saveAutomergeFieldDoc(value, type);
}
throw new Error("CRDT field writes must use an Automerge document or Camino CRDT envelope");
};
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
Generated
+5 -5
View File
@@ -37,17 +37,17 @@
"quixosNixHelpers": {
"flake": false,
"locked": {
"lastModified": 1783886666,
"narHash": "sha256-y52TlYwH9RBzzyhpkY/eB21t85BE84idpsg8618MIHg=",
"lastModified": 1783914102,
"narHash": "sha256-A9EnXxKaODNrSUpw1IlUBR8fe4px5I+wNJFUFGbZY7c=",
"ref": "refs/heads/exported",
"rev": "81e55657d9ce0ba092cffd69ee15179ea02aa598",
"revCount": 2,
"rev": "1f0c39b01501d646fe97dfc6e5777ccab0bde2f1",
"revCount": 6,
"type": "git",
"url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git"
},
"original": {
"ref": "refs/heads/exported",
"rev": "81e55657d9ce0ba092cffd69ee15179ea02aa598",
"rev": "1f0c39b01501d646fe97dfc6e5777ccab0bde2f1",
"type": "git",
"url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git"
}
+1 -1
View File
@@ -5,7 +5,7 @@
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
quixosNixHelpers = {
url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git?ref=refs/heads/exported&rev=81e55657d9ce0ba092cffd69ee15179ea02aa598";
url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git?ref=refs/heads/exported&rev=1f0c39b01501d646fe97dfc6e5777ccab0bde2f1";
flake = false;
};
};
+172 -24
View File
@@ -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;
@@ -12,10 +19,37 @@ type AutomergeFieldDoc = {
value: unknown;
};
const bytesToBase64 = (value: Uint8Array) =>
Buffer.from(value).toString("base64");
export type CaminoAutomergeDoc<T = unknown> = 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 new Uint8Array(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;
};
const isCrdtEncoding = (value: unknown): value is CaminoCrdtEncoding =>
value === "base64" ||
value === AUTOMERGE_SNAPSHOT_ENCODING ||
value === AUTOMERGE_CHANGES_ENCODING;
export const isCaminoCrdtEnvelope = (
value: unknown,
@@ -27,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) {
@@ -40,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<AutomergeFieldDoc> => {
assertEnvelopeType(value, type);
const snapshot = requireSnapshotEnvelope(value, type);
return Automerge.load<AutomergeFieldDoc>(
base64ToBytes(value.$caminoCrdtPayload),
base64ToBytes(snapshot.$caminoCrdtPayload),
);
};
@@ -55,7 +97,7 @@ export const saveAutomergeFieldDoc = (
type: string,
): CaminoCrdtEnvelope => ({
$caminoCrdtType: type,
$caminoCrdtEncoding: "base64",
$caminoCrdtEncoding: AUTOMERGE_SNAPSHOT_ENCODING,
$caminoCrdtPayload: bytesToBase64(Automerge.save(doc)),
});
@@ -65,16 +107,104 @@ export const createAutomergeFieldValue = (
): CaminoCrdtEnvelope =>
saveAutomergeFieldDoc(Automerge.from<AutomergeFieldDoc>({ value }), type);
export const normalizeAutomergeFieldValue = (
export const isAutomergeFieldDoc = (
value: unknown,
): value is Automerge.Doc<AutomergeFieldDoc> => {
if (!value || typeof value !== "object") {
return false;
}
try {
Automerge.getHeads(value as Automerge.Doc<AutomergeFieldDoc>);
return true;
} catch {
return false;
}
};
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 reconcileAutomergeFieldDocs = <T>(
base: CaminoAutomergeDoc<T>,
desired: CaminoAutomergeDoc<T>,
): CaminoAutomergeDoc<T> =>
Automerge.merge(Automerge.clone(base), Automerge.clone(desired));
export const createAutomergeFieldChanges = (
base: Automerge.Doc<AutomergeFieldDoc>,
desired: Automerge.Doc<AutomergeFieldDoc>,
type: string,
): CaminoCrdtEnvelope => {
const next = reconcileAutomergeFieldDocs(base, desired);
return {
$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,
): CaminoCrdtEnvelope => {
if (isCaminoCrdtEnvelope(value)) {
return saveAutomergeFieldDoc(loadAutomergeFieldDoc(value, type), type);
}
return createAutomergeFieldValue(value, type);
if (isAutomergeFieldDoc(value)) {
return saveAutomergeFieldDoc(value, type);
}
throw new Error(
"CRDT field writes must use an Automerge document or Camino CRDT envelope",
);
};
export const normalizeAutomergeFieldValue = (
value: unknown,
type: string,
): CaminoCrdtEnvelope => encodeAutomergeFieldWrite(value, type);
export const materializeAutomergeFieldValue = (
value: CaminoCrdtEnvelope,
type: string,
@@ -96,17 +226,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<AutomergeFieldDoc>;
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,
+105 -2
View File
@@ -2,25 +2,56 @@ 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,
materializeAutomergeFieldValue,
mergeAutomergeFieldValues,
reconcileAutomergeFieldDocs,
saveAutomergeFieldDoc,
} from "../src/crdt-automerge.js";
const type = "camino.CrdtJson";
test("Automerge CRDT fields lift plain values into stored envelopes", () => {
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",
});
});
test("Automerge CRDT field writes reject plain values", () => {
assert.throws(
() => encodeAutomergeFieldWrite({ title: "draft" }, type),
/CRDT field writes must use an Automerge document/,
);
});
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),
@@ -45,3 +76,75 @@ 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 changes retain remote history while local writes are pending", () => {
const stored = createAutomergeFieldValue({ local: false, remote: false }, type);
const base = loadAutomergeFieldDoc(stored, type);
const local = Automerge.change(
Automerge.clone(base, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
(doc) => {
(doc.value as { local: boolean }).local = true;
},
);
const remote = Automerge.change(
Automerge.clone(base, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
(doc) => {
(doc.value as { remote: boolean }).remote = true;
},
);
const rebased = reconcileAutomergeFieldDocs(remote, local);
const update = createAutomergeFieldChanges(remote, rebased, type);
const result = mergeAutomergeFieldValues({
existing: saveAutomergeFieldDoc(remote, type),
incoming: update,
type,
});
assert.deepEqual(result.materialized, { local: true, remote: true });
});
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/,
);
});