125 lines
3.8 KiB
TypeScript
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;
|
|
}
|
|
}
|