Foundation for the live-sync epic plan (E0). Frozen-contract primitives that gate E1a/E1b/E2, grounded in the real code shapes. - synclock.ts: per-UUID bidirectional lock (push/pull/baseline) replacing the per-relPath inflight Set. acquire/release/withLock/isHeld/heldOps, skip|queue policy, reentrant-NO, relPath: fallback. Queue path retries until acquired or throws LockAcquireTimeout (no silent drop); fairness — acquire defers to queued waiters. SYNC_LOCK_ENABLED flag (default on; off = byte-identical legacy inflight). - cchash.ts: ccHash(entry, inverse) + ccHashFromGet, frozen CC_HASH_CONTRACT. Contract corrected from epic prose: the body spans data.description + data.notes (CcData object, not a string), with ## Secrets re-inserted. Throws CcHashError on missing description (not coerced to ""). Three E1a-gate assumptions documented (sidebar exclusion, Secrets heading/case, section order). - schema-version.ts: FLAGS_SCHEMA_VERSION / SYNC_STATE_SCHEMA_VERSION branded constants + parseSchemaVersion (branded discriminated union). - server.ts: AutoSyncController refactored to use the lock (single read; uuid resolved from the same read used to gate — no stale-uuid gap; uuidCache powers the debounce pre-check without a file read). AutoSyncController/State exported, lock public for E2's poll path. - 43 tests across 4 files (synclock, cchash, schema-version, server-lock integration incl. cross-direction block + flag-off byte-identical). tsc clean. Co-Authored-By: Claude <noreply@anthropic.com>
171 lines
6.9 KiB
TypeScript
171 lines
6.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { mkdtemp, writeFile, mkdir, rm, readFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
|
|
// Mock pushNote so we can assert whether a PUT would have fired, without a
|
|
// relay. The controller's `runPushBody` calls `relayClient(state)` then
|
|
// `pushNote({...})`; with pushNote mocked, the relay is never contacted.
|
|
vi.mock("../src/push.js", () => ({
|
|
pushNote: vi.fn(async () => ({ dryRun: false, ccUuid: "JournalEntry.abc1", diff: {}, imageNote: "" })),
|
|
}));
|
|
|
|
import { pushNote } from "../src/push.js";
|
|
import { AutoSyncController } from "../src/server.js";
|
|
import type { State, ServerConfig } from "../src/server.js";
|
|
import { splitFrontmatter, readFoundryBlock } from "../src/frontmatter.js";
|
|
import { contentHash } from "../src/normalize.js";
|
|
|
|
const UUID = "JournalEntry.abc1";
|
|
const REL = "Roland.md";
|
|
|
|
interface Deferred<T = void> {
|
|
promise: Promise<T>;
|
|
resolve: (v: T) => void;
|
|
}
|
|
function deferred<T = void>(): Deferred<T> {
|
|
let resolve!: (v: T) => void;
|
|
const promise = new Promise<T>((res) => { resolve = res; });
|
|
return { promise, resolve };
|
|
}
|
|
|
|
function seededNote(body: string, contentHashBaseline: string): string {
|
|
return [
|
|
"---",
|
|
"type: npc",
|
|
"foundry:",
|
|
` cc_uuid: ${UUID}`,
|
|
" cc_type: npc",
|
|
" folder_path: NPCs",
|
|
` contentHash: ${contentHashBaseline}`,
|
|
" syncedAt: 2026-06-22T00:00:00.000Z",
|
|
"---",
|
|
body,
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
let dir: string;
|
|
let state: State;
|
|
|
|
beforeEach(async () => {
|
|
vi.mocked(pushNote).mockClear();
|
|
vi.mocked(pushNote).mockImplementation(async () => ({
|
|
dryRun: false, ccUuid: UUID, diff: {}, imageNote: "",
|
|
}));
|
|
dir = await mkdtemp(join(tmpdir(), "autosync-lock-"));
|
|
const refinedDir = join(dir, "refined");
|
|
const outDir = join(dir, "out");
|
|
await mkdir(refinedDir, { recursive: true });
|
|
const cfg: ServerConfig = {
|
|
journal: "",
|
|
refinedDir,
|
|
ccDir: "",
|
|
outDir,
|
|
mode: "apply",
|
|
port: 0,
|
|
host: "",
|
|
relayCfg: { url: "http://relay.test", apiKey: "k", clientId: "c" },
|
|
};
|
|
state = { db: {} as State["db"], cfg, index: null, autosync: null as unknown as State["autosync"] } as unknown as State;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function writeNote(body = "The gunslinger drew his revolver.\n"): Promise<void> {
|
|
// contentHash baseline deliberately WRONG (64 zeros) so bodyHash !== baseline
|
|
// and the "unchanged" skip does NOT fire — the push path is exercised.
|
|
await writeFile(join(state.cfg.refinedDir, REL), seededNote(body, "0".repeat(64)), "utf8");
|
|
}
|
|
|
|
/** Wait until the controller holds the lock for UUID (used to deterministically
|
|
* pin a concurrent op's start to the window where the lock is held). */
|
|
async function waitUntilHeld(controller: AutoSyncController, uuid: string): Promise<void> {
|
|
while (!controller.lock.isHeld(uuid)) await new Promise<void>((r) => setImmediate(r));
|
|
}
|
|
|
|
describe("AutoSyncController lock integration (E0.1)", () => {
|
|
it("a normal save acquires the push lock, calls pushNote once, baselines, and releases", async () => {
|
|
await writeNote();
|
|
const controller = new AutoSyncController(state);
|
|
await (controller as any).process(REL);
|
|
|
|
expect(vi.mocked(pushNote)).toHaveBeenCalledTimes(1);
|
|
expect(controller.lock.isHeld(UUID)).toBe(false); // released after push
|
|
|
|
// Baseline wrote foundry.contentHash = current body hash (idempotency).
|
|
const md = await readFile(join(state.cfg.refinedDir, REL), "utf8");
|
|
const { fm, body } = splitFrontmatter(md);
|
|
const fb = readFoundryBlock(fm);
|
|
expect(fb?.contentHash).toBe(contentHash(body));
|
|
expect(controller.events.some((e) => e.status === "pushed")).toBe(true);
|
|
});
|
|
|
|
it("cross-direction: a pre-held F→O ('pull') lock blocks the O→F push — no PUT (fail-safe)", async () => {
|
|
await writeNote();
|
|
const controller = new AutoSyncController(state);
|
|
// Simulate an in-flight F→O pull holding the uuid (E2's future path).
|
|
expect(controller.lock.acquire(UUID, "pull")).toEqual({ acquired: true });
|
|
|
|
await (controller as any).process(REL);
|
|
|
|
expect(vi.mocked(pushNote)).not.toHaveBeenCalled();
|
|
expect(controller.lock.isHeld(UUID)).toBe(true); // still held by pull
|
|
expect(controller.events.some((e) => e.message.includes("lock busy"))).toBe(true);
|
|
controller.lock.release(UUID, "pull");
|
|
});
|
|
|
|
it("two concurrent process() calls for the same uuid push exactly once (lock dedup)", async () => {
|
|
await writeNote();
|
|
const controller = new AutoSyncController(state);
|
|
|
|
// Gate the FIRST pushNote call so p1 holds the lock long enough for p2 to
|
|
// arrive and hit the lock-busy skip — deterministic dedup.
|
|
const gate = deferred<void>();
|
|
let calls = 0;
|
|
vi.mocked(pushNote).mockImplementation(async () => {
|
|
calls++;
|
|
if (calls === 1) await gate.promise;
|
|
return { dryRun: false, ccUuid: UUID, diff: {}, imageNote: "" };
|
|
});
|
|
|
|
const p1 = (controller as any).process(REL) as Promise<void>;
|
|
await waitUntilHeld(controller, UUID); // p1 acquired, now blocked in pushNote
|
|
const p2 = (controller as any).process(REL) as Promise<void>;
|
|
await p2; // p2 reads, withLock(skip) → held → skip (no push)
|
|
// p1 has invoked pushNote once (it is parked on the gate); p2 did NOT
|
|
// invoke it — so exactly one call so far, no duplicate.
|
|
expect(vi.mocked(pushNote)).toHaveBeenCalledTimes(1);
|
|
|
|
gate.resolve(void 0);
|
|
await p1; // p1 completes push + baseline
|
|
expect(vi.mocked(pushNote)).toHaveBeenCalledTimes(1); // still exactly one PUT
|
|
});
|
|
|
|
it("flag-off (SYNC_LOCK_ENABLED=false) uses the legacy per-relPath inflight guard, byte-identical behavior", async () => {
|
|
await writeNote();
|
|
const controller = new AutoSyncController(state);
|
|
(controller as any).syncLockEnabled = false; // simulate the flag-off path
|
|
const inflight = (controller as any).inflight as Set<string>;
|
|
|
|
await (controller as any).process(REL);
|
|
|
|
expect(vi.mocked(pushNote)).toHaveBeenCalledTimes(1);
|
|
expect(inflight.has(REL)).toBe(false); // cleaned up in finally
|
|
expect(controller.lock.isHeld(UUID)).toBe(false); // lock not consulted at all
|
|
});
|
|
|
|
it("an unlinked note (no foundry.cc_uuid) is skipped before any push, and the relPath fallback key is cached", async () => {
|
|
// No foundry block → unlinked. process should skip ("not linked") and NOT push.
|
|
await writeFile(join(state.cfg.refinedDir, REL), "---\ntype: npc\n---\nBody with no foundry block.\n", "utf8");
|
|
const controller = new AutoSyncController(state);
|
|
await (controller as any).process(REL);
|
|
|
|
expect(vi.mocked(pushNote)).not.toHaveBeenCalled();
|
|
expect(controller.events.some((e) => e.message.includes("not linked"))).toBe(true);
|
|
// The relPath fallback key was cached (so the debounce pre-check can use it).
|
|
expect((controller as any).uuidCache.get(REL)).toBe(`relPath:${REL}`);
|
|
});
|
|
}); |