Compare commits
20 Commits
1491d66187
...
exported
| Author | SHA1 | Date | |
|---|---|---|---|
| 70f9f45eb7 | |||
| 1a4f3ab8cc | |||
| 235c2ba075 | |||
| a5899e6b57 | |||
| e061e29070 | |||
| fceb49bb2c | |||
| 7a622dd028 | |||
| cef58a7d48 | |||
| 709990b5f2 | |||
| a9a6ef2cf4 | |||
| fa50782559 | |||
| e72b3c5577 | |||
| da3e43d059 | |||
| 864f8f5de6 | |||
| 01fdee1873 | |||
| 016f471c81 | |||
| 06072c2143 | |||
| 05f9add1eb | |||
| 3df72eb974 | |||
| 9e44c722e5 |
+3
-1
@@ -1,3 +1,5 @@
|
||||
dist/
|
||||
dist/*
|
||||
!dist/src/
|
||||
!dist/src/**
|
||||
node_modules/
|
||||
.yarn/install-state.gz
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos.git",
|
||||
"sourceCommit": "4667ef0994a4000bea6d30cc95a4dcecec5068b7",
|
||||
"sourceCommit": "ce2c8b6ed1926da7b3b3fe4aa3f1bbd8f327470a",
|
||||
"sourcePath": "quixos-instance/packages/camino-datatypes",
|
||||
"exportName": "camino-datatypes",
|
||||
"mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/camino-datatypes.git"
|
||||
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
import * as Automerge from "@automerge/automerge";
|
||||
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;
|
||||
$caminoCrdtPayload: string;
|
||||
};
|
||||
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 = {
|
||||
existing?: unknown;
|
||||
incoming: unknown;
|
||||
type: string;
|
||||
};
|
||||
export type MergeAutomergeFieldValueResult = {
|
||||
stored: CaminoCrdtEnvelope;
|
||||
materialized: unknown;
|
||||
};
|
||||
export declare const mergeAutomergeFieldValues: ({ existing, incoming, type, }: MergeAutomergeFieldValueParams) => MergeAutomergeFieldValueResult;
|
||||
export {};
|
||||
Vendored
+158
@@ -0,0 +1,158 @@
|
||||
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");
|
||||
}
|
||||
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) &&
|
||||
typeof value.$caminoCrdtType ===
|
||||
"string" &&
|
||||
typeof value.$caminoCrdtPayload ===
|
||||
"string" &&
|
||||
isCrdtEncoding(value.$caminoCrdtEncoding ??
|
||||
"base64");
|
||||
const assertEnvelopeType = (value, type) => {
|
||||
if (value.$caminoCrdtType !== type) {
|
||||
throw new Error(`CRDT type mismatch: expected ${type}, got ${value.$caminoCrdtType}`);
|
||||
}
|
||||
};
|
||||
const requireSnapshotEnvelope = (value, type) => {
|
||||
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, type) => {
|
||||
const snapshot = requireSnapshotEnvelope(value, type);
|
||||
return Automerge.load(base64ToBytes(snapshot.$caminoCrdtPayload));
|
||||
};
|
||||
export const saveAutomergeFieldDoc = (doc, type) => ({
|
||||
$caminoCrdtType: type,
|
||||
$caminoCrdtEncoding: AUTOMERGE_SNAPSHOT_ENCODING,
|
||||
$caminoCrdtPayload: bytesToBase64(Automerge.save(doc)),
|
||||
});
|
||||
export const createAutomergeFieldValue = (value, type) => saveAutomergeFieldDoc(Automerge.from({ 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);
|
||||
}
|
||||
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 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,
|
||||
materialized: mergedDoc.value,
|
||||
};
|
||||
};
|
||||
//# sourceMappingURL=crdt-automerge.js.map
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+38
@@ -0,0 +1,38 @@
|
||||
export type UnknownFieldClock = {
|
||||
kind: "unknown";
|
||||
};
|
||||
export type RevisionFieldClock = {
|
||||
kind: "revision";
|
||||
revision: bigint;
|
||||
};
|
||||
export type FieldClock = UnknownFieldClock | RevisionFieldClock;
|
||||
export type CanonicalFieldValue<T> = {
|
||||
value: T;
|
||||
clock: FieldClock;
|
||||
};
|
||||
export type PendingFieldWrite<T> = {
|
||||
clientMutationId: string;
|
||||
sequence: number;
|
||||
value: T;
|
||||
};
|
||||
export type RegisterFieldState<T> = {
|
||||
canonical: CanonicalFieldValue<T>;
|
||||
visible: T;
|
||||
pending: PendingFieldWrite<T>[];
|
||||
nextPendingSequence: number;
|
||||
};
|
||||
export type FieldApplyResult = {
|
||||
accepted: boolean;
|
||||
canonicalChanged: boolean;
|
||||
pendingChanged: boolean;
|
||||
visibleChanged: boolean;
|
||||
};
|
||||
export declare const unknownFieldClock: () => FieldClock;
|
||||
export declare const revisionFieldClock: (revision: string | number | bigint | undefined | null) => FieldClock;
|
||||
export declare const fieldClockToRevisionString: (clock: FieldClock) => string;
|
||||
export declare const compareFieldClocks: (left: FieldClock, right: FieldClock) => "older" | "same" | "newer" | "unknown";
|
||||
export declare const sameFieldClock: (left: FieldClock, right: FieldClock) => boolean;
|
||||
export declare const createRegisterFieldState: <T>(initial: CanonicalFieldValue<T>) => RegisterFieldState<T>;
|
||||
export declare const applyCanonicalFieldValue: <T>(state: RegisterFieldState<T>, incoming: CanonicalFieldValue<T>, clientMutationId?: string) => FieldApplyResult;
|
||||
export declare const beginLocalFieldWrite: <T>(state: RegisterFieldState<T>, clientMutationId: string, value: T) => FieldApplyResult;
|
||||
export declare const failLocalFieldWrite: <T>(state: RegisterFieldState<T>, clientMutationId: string) => FieldApplyResult;
|
||||
Vendored
+104
@@ -0,0 +1,104 @@
|
||||
export const unknownFieldClock = () => ({ kind: "unknown" });
|
||||
export const revisionFieldClock = (revision) => {
|
||||
if (typeof revision === "bigint") {
|
||||
return { kind: "revision", revision };
|
||||
}
|
||||
if (typeof revision === "number" && Number.isInteger(revision) && revision >= 0) {
|
||||
return { kind: "revision", revision: BigInt(revision) };
|
||||
}
|
||||
if (typeof revision === "string" && /^\d+$/.test(revision)) {
|
||||
return { kind: "revision", revision: BigInt(revision) };
|
||||
}
|
||||
return unknownFieldClock();
|
||||
};
|
||||
export const fieldClockToRevisionString = (clock) => clock.kind === "revision" ? clock.revision.toString() : "";
|
||||
export const compareFieldClocks = (left, right) => {
|
||||
if (left.kind === "revision" && right.kind === "revision") {
|
||||
if (left.revision < right.revision) {
|
||||
return "older";
|
||||
}
|
||||
if (left.revision > right.revision) {
|
||||
return "newer";
|
||||
}
|
||||
return "same";
|
||||
}
|
||||
if (left.kind === "unknown" && right.kind === "unknown") {
|
||||
return "same";
|
||||
}
|
||||
return "unknown";
|
||||
};
|
||||
export const sameFieldClock = (left, right) => compareFieldClocks(left, right) === "same";
|
||||
const shouldAcceptCanonical = (incoming, current) => {
|
||||
const order = compareFieldClocks(incoming, current);
|
||||
if (order === "older") {
|
||||
return false;
|
||||
}
|
||||
if (order === "unknown" && current.kind !== "unknown") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const latestPendingValue = (state) => state.pending.at(-1)?.value;
|
||||
const recomputeVisible = (state) => {
|
||||
const pendingValue = latestPendingValue(state);
|
||||
return pendingValue === undefined ? state.canonical.value : pendingValue;
|
||||
};
|
||||
export const createRegisterFieldState = (initial) => ({
|
||||
canonical: initial,
|
||||
visible: initial.value,
|
||||
pending: [],
|
||||
nextPendingSequence: 1,
|
||||
});
|
||||
export const applyCanonicalFieldValue = (state, incoming, clientMutationId) => {
|
||||
const visibleBefore = state.visible;
|
||||
const pendingBefore = state.pending.length;
|
||||
if (!shouldAcceptCanonical(incoming.clock, state.canonical.clock)) {
|
||||
return {
|
||||
accepted: false,
|
||||
canonicalChanged: false,
|
||||
pendingChanged: false,
|
||||
visibleChanged: false,
|
||||
};
|
||||
}
|
||||
const canonicalChanged = !Object.is(state.canonical.value, incoming.value) ||
|
||||
!sameFieldClock(state.canonical.clock, incoming.clock);
|
||||
state.canonical = incoming;
|
||||
if (clientMutationId) {
|
||||
state.pending = state.pending.filter((entry) => entry.clientMutationId !== clientMutationId);
|
||||
}
|
||||
state.visible = recomputeVisible(state);
|
||||
return {
|
||||
accepted: true,
|
||||
canonicalChanged,
|
||||
pendingChanged: state.pending.length !== pendingBefore,
|
||||
visibleChanged: !Object.is(state.visible, visibleBefore),
|
||||
};
|
||||
};
|
||||
export const beginLocalFieldWrite = (state, clientMutationId, value) => {
|
||||
const visibleBefore = state.visible;
|
||||
state.pending.push({
|
||||
clientMutationId,
|
||||
sequence: state.nextPendingSequence++,
|
||||
value,
|
||||
});
|
||||
state.visible = value;
|
||||
return {
|
||||
accepted: true,
|
||||
canonicalChanged: false,
|
||||
pendingChanged: true,
|
||||
visibleChanged: !Object.is(state.visible, visibleBefore),
|
||||
};
|
||||
};
|
||||
export const failLocalFieldWrite = (state, clientMutationId) => {
|
||||
const visibleBefore = state.visible;
|
||||
const pendingBefore = state.pending.length;
|
||||
state.pending = state.pending.filter((entry) => entry.clientMutationId !== clientMutationId);
|
||||
state.visible = recomputeVisible(state);
|
||||
return {
|
||||
accepted: true,
|
||||
canonicalChanged: false,
|
||||
pendingChanged: state.pending.length !== pendingBefore,
|
||||
visibleChanged: !Object.is(state.visible, visibleBefore),
|
||||
};
|
||||
};
|
||||
//# sourceMappingURL=field-semantics.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"field-semantics.js","sourceRoot":"","sources":["../../src/field-semantics.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
export * from "./field-semantics.js";
|
||||
export * from "./crdt-automerge.js";
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
export * from "./field-semantics.js";
|
||||
export * from "./crdt-automerge.js";
|
||||
//# sourceMappingURL=index.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC"}
|
||||
Generated
+5
-5
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
+12
-4
@@ -6,21 +6,29 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"default": "./dist/src/index.js"
|
||||
},
|
||||
"./field-semantics": {
|
||||
"types": "./src/field-semantics.ts",
|
||||
"default": "./src/field-semantics.ts"
|
||||
"types": "./dist/src/field-semantics.d.ts",
|
||||
"default": "./dist/src/field-semantics.js"
|
||||
},
|
||||
"./crdt-automerge": {
|
||||
"types": "./dist/src/crdt-automerge.d.ts",
|
||||
"default": "./dist/src/crdt-automerge.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"prepare": "yarn build",
|
||||
"test": "yarn build && node --test dist/test/*.test.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@automerge/automerge": "^3.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import * as Automerge from "@automerge/automerge";
|
||||
|
||||
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;
|
||||
$caminoCrdtEncoding: CaminoCrdtEncoding;
|
||||
$caminoCrdtPayload: string;
|
||||
};
|
||||
|
||||
type AutomergeFieldDoc = {
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export type CaminoAutomergeDoc<T = unknown> = Automerge.Doc<{
|
||||
value: T;
|
||||
}>;
|
||||
|
||||
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,
|
||||
): 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" &&
|
||||
isCrdtEncoding(
|
||||
(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}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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> => {
|
||||
const snapshot = requireSnapshotEnvelope(value, type);
|
||||
return Automerge.load<AutomergeFieldDoc>(
|
||||
base64ToBytes(snapshot.$caminoCrdtPayload),
|
||||
);
|
||||
};
|
||||
|
||||
export const saveAutomergeFieldDoc = (
|
||||
doc: Automerge.Doc<AutomergeFieldDoc>,
|
||||
type: string,
|
||||
): CaminoCrdtEnvelope => ({
|
||||
$caminoCrdtType: type,
|
||||
$caminoCrdtEncoding: AUTOMERGE_SNAPSHOT_ENCODING,
|
||||
$caminoCrdtPayload: bytesToBase64(Automerge.save(doc)),
|
||||
});
|
||||
|
||||
export const createAutomergeFieldValue = (
|
||||
value: unknown,
|
||||
type: string,
|
||||
): CaminoCrdtEnvelope =>
|
||||
saveAutomergeFieldDoc(Automerge.from<AutomergeFieldDoc>({ value }), type);
|
||||
|
||||
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);
|
||||
}
|
||||
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,
|
||||
) => 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 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,
|
||||
materialized: mergedDoc.value,
|
||||
};
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./field-semantics.js";
|
||||
export * from "./crdt-automerge.js";
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
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 explicitly create stored envelopes", () => {
|
||||
const stored = createAutomergeFieldValue({ title: "draft" }, type);
|
||||
|
||||
assert.equal(stored.$caminoCrdtType, type);
|
||||
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),
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
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/,
|
||||
);
|
||||
});
|
||||
@@ -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