Files
suaveplan/packages/foundation/flow/crdt/src/crdts/mv-register/mv-register.ts
2026-05-27 23:47:53 +01:00

125 lines
3.8 KiB
TypeScript

import { VectorClock } from "../../core/vector-clock/vector-clock.js";
import { CRDT_CODES, CrdtError } from "../../errors/crdt-error.js";
import type { CRDT, MVRegisterState } from "../../types/crdt.types.js";
/**
* Multi-Value Register CRDT.
*
* On concurrent writes from different nodes, all concurrent values are
* preserved. `get()` returns all current values; if only one remains,
* there is no conflict.
*/
export class MVRegister<T> implements CRDT<T[], MVRegisterState<T>> {
public readonly id: string;
protected nodeId: string;
protected entries: Array<{
value: T;
vectorClock: Record<string, number>;
}> = [];
protected vc: VectorClock;
public constructor(nodeId: string, id = crypto.randomUUID()) {
this.nodeId = nodeId;
this.id = id;
this.vc = new VectorClock();
}
public set(value: T): void {
this.vc.increment(this.nodeId);
// Replace all existing entries with the new value at the current clock
this.entries = [{ value, vectorClock: this.vc.state() }];
}
public get(): T[] {
return this.entries.map((e) => e.value);
}
public conflict(): boolean {
return this.entries.length > 1;
}
public value(): T[] {
return this.get();
}
public merge(remote: MVRegisterState<T>): void {
const local = this.entries;
const merged: Array<{ value: T; vectorClock: Record<string, number> }> =
[];
// Keep entries that are not dominated by remote
for (const le of local) {
const dominated = remote.entries.some((re) => {
const rc = new VectorClock(re.vectorClock);
return rc.compare(le.vectorClock) === "after";
});
if (!dominated) merged.push(le);
}
// Add remote entries that are not dominated by local
for (const re of remote.entries) {
const dominated = local.some((le) => {
const lc = new VectorClock(le.vectorClock);
return lc.compare(re.vectorClock) === "after";
});
if (!dominated) {
const alreadyPresent = merged.some((e) => {
const ec = new VectorClock(e.vectorClock);
return ec.compare(re.vectorClock) === "equal";
});
if (!alreadyPresent) merged.push({ ...re });
}
}
this.entries = merged;
// Advance our VC to cover the merged state
for (const entry of merged) {
this.vc.merge(entry.vectorClock);
}
}
public state(): MVRegisterState<T> {
return {
type: "mv-register",
entries: this.entries.map((e) => ({
value: e.value,
vectorClock: { ...e.vectorClock },
})),
};
}
public toJSON(): string {
return JSON.stringify(this.state());
}
public fromJSON(json: string): void {
try {
const parsed = JSON.parse(json) as MVRegisterState<T>;
if (parsed.type !== "mv-register") throw new Error("Invalid type");
this.entries = parsed.entries.map((e) => ({
value: e.value,
vectorClock: { ...e.vectorClock },
}));
} catch (err) {
throw new CrdtError(
CRDT_CODES.DESERIALIZE_FAILED,
"Invalid MVRegister JSON",
{ cause: err as Error }
);
}
}
public clone(): this {
const c = new MVRegister<T>(
this.nodeId,
this.id as `${string}-${string}-${string}-${string}-${string}`
) as this;
c.entries = this.entries.map((e) => ({
value: e.value,
vectorClock: { ...e.vectorClock },
}));
c.vc = this.vc.clone();
return c;
}
}