Files
obsidian-foundry-sync/tests/server-lock.test.ts
Kaysser Kayyali 8fd56a22d9 feat(E0): shared sync primitives — per-uuid lock, ccHash, schema versions
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>
2026-06-22 22:01:57 +00:00

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}`);
});
});