Compare commits

...

2 Commits

Author SHA1 Message Date
Quixos Subtree Publisher 3df72eb974 Publish camino-datatypes from 7c91cadcce482cce86e16d724fc6678d6d783d88 2026-07-13 02:57:27 +00:00
Timothy J. Aveni 9e44c722e5 Add Automerge CRDT datatype helpers 2026-07-12 19:57:27 -07:00
7 changed files with 180 additions and 1 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos.git",
"sourceCommit": "4667ef0994a4000bea6d30cc95a4dcecec5068b7",
"sourceCommit": "7c91cadcce482cce86e16d724fc6678d6d783d88",
"sourcePath": "quixos-instance/packages/camino-datatypes",
"exportName": "camino-datatypes",
"mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/camino-datatypes.git"
+7
View File
@@ -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"
}
}
+115
View File
@@ -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
View File
@@ -1 +1,2 @@
export * from "./field-semantics.js";
export * from "./crdt-automerge.js";
+47
View File
@@ -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,
});
});
+1
View File
@@ -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=="; };
+8
View File
@@ -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