Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
352 lines
13 KiB
TypeScript
352 lines
13 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
|
|
import type { Draft, Op } from "../src/delta/index.ts";
|
|
import {
|
|
type Context,
|
|
type ReplicatedStateSource,
|
|
type ReplicatedStateSourceAttachment,
|
|
type ReplicatedStateSourceFrame,
|
|
replicatedState,
|
|
} from "../src/index.ts";
|
|
|
|
describe("transactional replicated state", () => {
|
|
it("publishes one immutable structurally shared revision", () => {
|
|
const initial = { changed: { value: 1 }, retained: { value: 2 } };
|
|
const state = replicatedState(initial);
|
|
const previous = state.value;
|
|
const deliveries: number[] = [];
|
|
state.subscribe((_value, _context, delivery) => deliveries.push(delivery.sequence));
|
|
state.change(BACKGROUND_CONTEXT, (draft) => {
|
|
draft.changed.value = 3;
|
|
draft.changed.value = 4;
|
|
});
|
|
expect(state.value).toEqual({ changed: { value: 4 }, retained: { value: 2 } });
|
|
expect(state.value).not.toBe(previous);
|
|
expect(state.value.changed).not.toBe(previous.changed);
|
|
expect(state.value.retained).toBe(previous.retained);
|
|
expect(Object.isFrozen(state.value)).toBe(false);
|
|
expect(deliveries).toEqual([0, 1]);
|
|
});
|
|
|
|
it("rolls back callback failures and revokes escaped drafts", () => {
|
|
const state = replicatedState({ nested: { value: 1 } });
|
|
const previous = state.value;
|
|
let escaped: Draft<{ value: number }> | undefined;
|
|
expect(() =>
|
|
state.change(BACKGROUND_CONTEXT, (draft) => {
|
|
escaped = draft.nested;
|
|
draft.nested.value = 2;
|
|
throw new Error("stop");
|
|
}),
|
|
).toThrow("stop");
|
|
expect(state.value).toBe(previous);
|
|
expect(() => escaped?.value).toThrow(TypeError);
|
|
});
|
|
|
|
it("rejects PromiseLike callbacks and aborts their draft", () => {
|
|
const state = replicatedState({ value: 0 });
|
|
const previous = state.value;
|
|
expect(() =>
|
|
state.change(BACKGROUND_CONTEXT, ((draft: Draft<{ value: number }>) => {
|
|
draft.value = 1;
|
|
return Promise.resolve();
|
|
}) as (draft: Draft<{ value: number }>) => void),
|
|
).toThrow(/synchronous/);
|
|
expect(state.value).toBe(previous);
|
|
});
|
|
|
|
it("rejects nested changes and replacements without losing the outer rollback", () => {
|
|
const state = replicatedState({ left: 0, right: 0 });
|
|
expect(() =>
|
|
state.change(BACKGROUND_CONTEXT, (draft) => {
|
|
draft.left = 1;
|
|
state.change(BACKGROUND_CONTEXT, (nested) => {
|
|
nested.right = 2;
|
|
});
|
|
}),
|
|
).toThrow(/reentrantly/);
|
|
expect(state.value).toEqual({ left: 0, right: 0 });
|
|
expect(() =>
|
|
state.change(BACKGROUND_CONTEXT, () => {
|
|
state.replace(BACKGROUND_CONTEXT, { left: 1, right: 2 });
|
|
}),
|
|
).toThrow(/change callback/);
|
|
expect(state.value).toEqual({ left: 0, right: 0 });
|
|
});
|
|
|
|
it("queues listener-triggered changes in sequence order", () => {
|
|
const state = replicatedState({ value: 0 });
|
|
const deliveries: Array<{ value: number; sequence: number }> = [];
|
|
const lateDeliveries: Array<{ kind: string; sequence: number }> = [];
|
|
let nested = false;
|
|
state.subscribe((_value, _context, delivery) => {
|
|
if (delivery.kind !== "update" || nested) return;
|
|
nested = true;
|
|
state.change(BACKGROUND_CONTEXT, (draft) => {
|
|
draft.value = 2;
|
|
});
|
|
state.subscribe((_value, _context, delivery) => lateDeliveries.push(delivery));
|
|
});
|
|
state.subscribe((value, _context, delivery) => {
|
|
if (delivery.kind === "update") deliveries.push({ value: value.value, sequence: delivery.sequence });
|
|
});
|
|
state.change(BACKGROUND_CONTEXT, (draft) => {
|
|
draft.value = 1;
|
|
});
|
|
expect(deliveries).toEqual([
|
|
{ value: 1, sequence: 1 },
|
|
{ value: 2, sequence: 2 },
|
|
]);
|
|
expect(lateDeliveries).toEqual([{ kind: "hydrate", sequence: 2 }]);
|
|
});
|
|
|
|
it("isolates listener failures after committing the revision", () => {
|
|
const reports: (() => void)[] = [];
|
|
const spy = vi.spyOn(globalThis, "queueMicrotask").mockImplementation((report) => {
|
|
reports.push(report);
|
|
});
|
|
try {
|
|
const state = replicatedState({ value: 0 });
|
|
const received: number[] = [];
|
|
state.subscribe((_value, _context, delivery) => {
|
|
if (delivery.kind === "update") throw new Error("listener failed");
|
|
});
|
|
state.subscribe((_value, _context, delivery) => received.push(delivery.sequence));
|
|
expect(() =>
|
|
state.change(BACKGROUND_CONTEXT, (draft) => {
|
|
draft.value = 1;
|
|
}),
|
|
).not.toThrow();
|
|
expect(state.value).toEqual({ value: 1 });
|
|
expect(received).toEqual([0, 1]);
|
|
expect(reports).toHaveLength(1);
|
|
expect(reports[0]).toThrow("listener failed");
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("copies assigned values by value", () => {
|
|
const external = { value: 1 };
|
|
const state = replicatedState<{ left: { value: number } | null; right: { value: number } | null }>({
|
|
left: null,
|
|
right: null,
|
|
});
|
|
state.change(BACKGROUND_CONTEXT, (draft) => {
|
|
draft.left = external;
|
|
draft.right = external;
|
|
draft.left.value = 2;
|
|
});
|
|
expect(external.value).toBe(1);
|
|
expect(state.value).toEqual({ left: { value: 2 }, right: { value: 1 } });
|
|
expect(state.value.left).not.toBe(state.value.right);
|
|
});
|
|
|
|
it("takes immutable ownership of alias-free replacements", () => {
|
|
const state = replicatedState({ left: { value: 1 }, right: { value: 2 } });
|
|
const replacement = { left: { ...state.value.left }, right: { ...state.value.left } };
|
|
state.replace(BACKGROUND_CONTEXT, replacement);
|
|
expect(state.value).toBe(replacement);
|
|
expect(state.value.left).not.toBe(state.value.right);
|
|
state.change(BACKGROUND_CONTEXT, (draft) => {
|
|
draft.left.value = 9;
|
|
});
|
|
expect(state.value).toEqual({ left: { value: 9 }, right: { value: 1 } });
|
|
});
|
|
|
|
it("replaces atomically and ignores deeply equal replacements", () => {
|
|
const state = replicatedState({ value: { nested: 1 }, retained: { nested: 2 } });
|
|
const previous = state.value;
|
|
state.replace(BACKGROUND_CONTEXT, { value: { nested: 1 }, retained: { nested: 2 } });
|
|
expect(state.value).toBe(previous);
|
|
state.replace(BACKGROUND_CONTEXT, { ...state.value, value: { nested: 2 } });
|
|
expect(state.value).toEqual({ value: { nested: 2 }, retained: { nested: 2 } });
|
|
expect(state.value.retained).toBe(previous.retained);
|
|
});
|
|
});
|
|
|
|
type SourceValue = Readonly<{ value: number }>;
|
|
|
|
class TestSource<T> implements ReplicatedStateSource<T> {
|
|
readonly attachments = new Set<TestSourceAttachment<T>>();
|
|
onAttach: (() => void) | undefined;
|
|
#value: T;
|
|
#cursor: number;
|
|
|
|
constructor(value: T, cursor = 0) {
|
|
this.#value = value;
|
|
this.#cursor = cursor;
|
|
}
|
|
|
|
attach(): ReplicatedStateSourceAttachment<T> {
|
|
const attachment = new TestSourceAttachment(Object.freeze({ value: this.#value, cursor: this.#cursor }), () =>
|
|
this.attachments.delete(attachment),
|
|
);
|
|
this.attachments.add(attachment);
|
|
this.onAttach?.();
|
|
return attachment;
|
|
}
|
|
|
|
commit(value: T, ops: readonly Op[], context: Context = BACKGROUND_CONTEXT, cursor = this.#cursor + 1): void {
|
|
this.#value = value;
|
|
this.#cursor = cursor;
|
|
const frame = Object.freeze({ cursor, value, ops, context });
|
|
for (const attachment of [...this.attachments]) attachment.publish(frame);
|
|
}
|
|
}
|
|
|
|
class TestSourceAttachment<T> implements ReplicatedStateSourceAttachment<T> {
|
|
readonly snapshot: { readonly value: T; readonly cursor: number };
|
|
readonly #onDispose: () => void;
|
|
readonly #buffer: ReplicatedStateSourceFrame<T>[] = [];
|
|
#listener: ((frame: ReplicatedStateSourceFrame<T>) => void) | undefined;
|
|
#activated = false;
|
|
#disposed = false;
|
|
|
|
constructor(snapshot: { readonly value: T; readonly cursor: number }, onDispose: () => void) {
|
|
this.snapshot = snapshot;
|
|
this.#onDispose = onDispose;
|
|
}
|
|
|
|
activate(listener: (frame: ReplicatedStateSourceFrame<T>) => void): void {
|
|
if (this.#activated) throw new Error("attachment is already active");
|
|
this.#activated = true;
|
|
this.#listener = listener;
|
|
for (const frame of this.#buffer.splice(0)) listener(frame);
|
|
}
|
|
|
|
publish(frame: ReplicatedStateSourceFrame<T>): void {
|
|
if (this.#disposed) return;
|
|
if (this.#listener === undefined) this.#buffer.push(frame);
|
|
else this.#listener(frame);
|
|
}
|
|
|
|
dispose(): void {
|
|
if (this.#disposed) return;
|
|
this.#disposed = true;
|
|
this.#buffer.length = 0;
|
|
this.#onDispose();
|
|
}
|
|
}
|
|
|
|
describe("authoritative replicated state sources", () => {
|
|
it("captures before activation and drains queued commits in order", () => {
|
|
const initial = Object.freeze({ value: 0 });
|
|
const first = Object.freeze({ value: 1 });
|
|
const second = Object.freeze({ value: 2 });
|
|
const firstOps = Object.freeze([["s", ["value"], 1] as const]);
|
|
const secondOps = Object.freeze([["s", ["value"], 2] as const]);
|
|
const source = new TestSource<SourceValue>(initial, 10);
|
|
source.onAttach = () => {
|
|
source.commit(first, firstOps);
|
|
source.commit(second, secondOps);
|
|
};
|
|
|
|
const state = replicatedState(source);
|
|
const deliveries: Array<{ value: SourceValue; sequence: number }> = [];
|
|
state.subscribe((value, _context, delivery) => deliveries.push({ value, sequence: delivery.sequence }));
|
|
expect(state.value).toBe(second);
|
|
expect(deliveries).toEqual([{ value: second, sequence: 2 }]);
|
|
});
|
|
|
|
it("hydrates at sequence zero when attaching after existing commits", () => {
|
|
const initial = Object.freeze({ value: 0 });
|
|
const current = Object.freeze({ value: 1 });
|
|
const source = new TestSource<SourceValue>(initial, 40);
|
|
source.commit(current, Object.freeze([["s", ["value"], 1] as const]));
|
|
const state = replicatedState(source);
|
|
const deliveries: number[] = [];
|
|
state.subscribe((_value, _context, delivery) => deliveries.push(delivery.sequence));
|
|
expect(state.value).toBe(current);
|
|
expect(deliveries).toEqual([0]);
|
|
});
|
|
|
|
it("publishes exact source values without applying or re-diffing", () => {
|
|
const source = new TestSource<SourceValue>(Object.freeze({ value: 0 }));
|
|
const state = replicatedState(source);
|
|
const next = Object.freeze({ value: 1 });
|
|
// Deliberately not the operation that produces `next`: Chord must publish the source value as is.
|
|
const ops = Object.freeze([["s", ["value"], 7] as const]);
|
|
let publishedValue: SourceValue | undefined;
|
|
state.subscribe((value, _context, delivery) => {
|
|
if (delivery.kind === "update") publishedValue = value;
|
|
});
|
|
|
|
source.commit(next, ops);
|
|
expect(state.value).toBe(next);
|
|
expect(publishedValue).toBe(next);
|
|
});
|
|
|
|
it("buffers reentrant frames and skips updates covered by a late hydration", () => {
|
|
const source = new TestSource<SourceValue>(Object.freeze({ value: 0 }));
|
|
const state = replicatedState(source);
|
|
const received: number[] = [];
|
|
const late: Array<{ kind: string; sequence: number; value: number }> = [];
|
|
let nested = false;
|
|
state.subscribe((_value, _context, delivery) => {
|
|
if (delivery.kind !== "update" || delivery.sequence !== 1 || nested) return;
|
|
nested = true;
|
|
source.commit(Object.freeze({ value: 2 }), Object.freeze([["s", ["value"], 2] as const]));
|
|
state.subscribe((value, _context, delivery) => late.push({ ...delivery, value: value.value }));
|
|
});
|
|
state.subscribe((value, _context, delivery) => {
|
|
if (delivery.kind === "update") received.push(value.value);
|
|
});
|
|
|
|
source.commit(Object.freeze({ value: 1 }), Object.freeze([["s", ["value"], 1] as const]));
|
|
expect(received).toEqual([1, 2]);
|
|
expect(late).toEqual([{ kind: "hydrate", sequence: 2, value: 2 }]);
|
|
});
|
|
|
|
it("reports listener failures without throwing them into the source", () => {
|
|
const source = new TestSource<SourceValue>(Object.freeze({ value: 0 }));
|
|
const errors: Error[] = [];
|
|
const state = replicatedState(source, { onError: (error) => errors.push(error) });
|
|
const received: number[] = [];
|
|
state.subscribe((_value, _context, delivery) => {
|
|
if (delivery.kind === "update") throw new Error("listener failed");
|
|
});
|
|
state.subscribe((value, _context, delivery) => {
|
|
if (delivery.kind === "update") received.push(value.value);
|
|
});
|
|
|
|
expect(() =>
|
|
source.commit(Object.freeze({ value: 1 }), Object.freeze([["s", ["value"], 1] as const])),
|
|
).not.toThrow();
|
|
source.commit(Object.freeze({ value: 2 }), Object.freeze([["s", ["value"], 2] as const]));
|
|
expect(received).toEqual([1, 2]);
|
|
expect(errors.map((error) => error.message)).toEqual(["listener failed", "listener failed"]);
|
|
});
|
|
|
|
it("reports cursor gaps, disposes the broken attachment, and ignores later frames", () => {
|
|
const source = new TestSource<SourceValue>(Object.freeze({ value: 0 }), 5);
|
|
const errors: Error[] = [];
|
|
const state = replicatedState(source, { onError: (error) => errors.push(error) });
|
|
source.commit(Object.freeze({ value: 2 }), Object.freeze([["s", ["value"], 2] as const]), BACKGROUND_CONTEXT, 7);
|
|
expect(errors[0]?.message).toContain("expected 6, received 7");
|
|
expect(source.attachments.size).toBe(0);
|
|
expect(state.value).toEqual({ value: 0 });
|
|
source.commit(Object.freeze({ value: 3 }), Object.freeze([["s", ["value"], 3] as const]), BACKGROUND_CONTEXT, 8);
|
|
expect(state.value).toEqual({ value: 0 });
|
|
});
|
|
|
|
it("keeps attachments independent and disposes each idempotently", () => {
|
|
const source = new TestSource<SourceValue>(Object.freeze({ value: 0 }));
|
|
const first = replicatedState(source);
|
|
const second = replicatedState(source);
|
|
expect(source.attachments.size).toBe(2);
|
|
source.commit(Object.freeze({ value: 1 }), Object.freeze([["s", ["value"], 1] as const]));
|
|
expect(first.value.value).toBe(1);
|
|
expect(second.value.value).toBe(1);
|
|
|
|
first.dispose();
|
|
first.dispose();
|
|
expect(source.attachments.size).toBe(1);
|
|
source.commit(Object.freeze({ value: 2 }), Object.freeze([["s", ["value"], 2] as const]));
|
|
expect(first.value.value).toBe(1);
|
|
expect(second.value.value).toBe(2);
|
|
second.dispose();
|
|
expect(source.attachments.size).toBe(0);
|
|
});
|
|
});
|