The deep poll fetches each linked note's full /get document and compares a derived ccHash to the stored foundry.ccHash baseline, detecting content edits and folder moves. Supersedes ADR-005's ~800-calls/min concern: mapPool bounds concurrency to 4, cadence 5min → ≤48 calls/min regardless of N. Gated by foundryPoll (default off). - src/foundry-poll.ts: deep poll on a separate timer (5min cadence, ±20% jitter, FOUNDRY_DEEP_POLL_CADENCE_MS / FOUNDRY_DEEP_POLL_CONCURRENCY env). Candidate list = intersection of shallow-poll snapshot uuids + linked index uuids. mapPool concurrency 4, each: relay.getEntry(uuid) → ccHash(liveEntry) vs foundry.ccHash baseline (from the note's frontmatter) + liveEntry.folder vs foundry.folder_path. ccHash mismatch → "edited"; folder change on a legacy note (no ccHash) → "moved". Changes recorded to sync-state.json.fPending. Persistent error (404 No connected clients) → abort round + halt timer. Transient error → inline retry (3 attempts, backoff 500/1500/4500ms ±20%, retryBackoffs field for test override); final failure → fPending recorded (round continues). Overlap guard (deepSkipCounter). Load ceiling documented in status() (loadCeilingCallsPerMin = concurrency / round_seconds ≈ 48/min). stop()/setEnabled manage BOTH shallow + deep timers. - tests/e2-2-deep-poll.test.ts: 6 tests — ccHash mismatch → "edited"; ccHash match → no change; folder move on legacy note → "moved"; persistent 404 → halts; transient 504 → retries + fPending recorded; status() exposes load ceiling + deep poll info. tsc clean; 253 passing project-wide (18 pre-existing fixture-missing unchanged). Co-Authored-By: Claude <noreply@anthropic.com>
151 lines
7.0 KiB
TypeScript
151 lines
7.0 KiB
TypeScript
// E2.2 — deep poll: per-linked-note /get + ccHash compare + folder move detection.
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import { mkdtemp, mkdir, rm, readFile, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
|
|
import { FoundryPollController, type PendingFChange } from "../src/foundry-poll.js";
|
|
import type { State, ServerConfig } from "../src/server.js";
|
|
import { defaultSyncState, saveSyncState, type SyncState } from "../src/sync-state.js";
|
|
import { ccHash } from "../src/cchash.js";
|
|
import type { JournalEntry } from "../src/types.js";
|
|
|
|
let dir: string;
|
|
let state: State;
|
|
const realFetch = globalThis.fetch;
|
|
|
|
function liveEntry(description: string, folder = "Folder.test"): JournalEntry {
|
|
return { name: "Roland", _id: "aaa", folder, flags: { "campaign-codex": { type: "npc", data: { description, notes: "" } } } };
|
|
}
|
|
|
|
function makeState(): State {
|
|
const cfg: ServerConfig = {
|
|
journal: "", refinedDir: join(dir, "refined"), ccDir: "", outDir: join(dir, "out"),
|
|
mode: "apply", port: 0, host: "127.0.0.1",
|
|
relayCfg: { url: "http://relay.test", apiKey: "k", clientId: "c" },
|
|
features: { syncStatus: true, foundryPoll: true },
|
|
};
|
|
const s = { db: {} as State["db"], cfg, index: null, autosync: null as unknown as State["autosync"] } as unknown as State;
|
|
s.syncState = { ...defaultSyncState(cfg.refinedDir) } as SyncState;
|
|
return s;
|
|
}
|
|
|
|
async function writeNote(ccHashBaseline: string | null, folderPath: string): Promise<void> {
|
|
const lines = ["---", "type: npc", "foundry:", " cc_uuid: JournalEntry.aaa", " cc_type: npc", ` folder_path: ${folderPath}`, ` contentHash: ${"0".repeat(64)}`];
|
|
if (ccHashBaseline !== null) lines.push(` ccHash: ${ccHashBaseline}`);
|
|
lines.push(" syncedAt: 2026-06-22T00:00:00.000Z", "---", "Roland body.", "");
|
|
await writeFile(join(dir, "refined", "Roland.md"), lines.join("\n"), "utf8");
|
|
}
|
|
|
|
function mockGetEntry(entry: JournalEntry): void {
|
|
globalThis.fetch = vi.fn(async (url: string) => {
|
|
if (String(url).includes("/get?")) return { ok: true, status: 200, text: async () => JSON.stringify({ data: entry }) } as unknown as Response;
|
|
if (String(url).includes("/search")) return { ok: true, status: 200, text: async () => JSON.stringify({ results: [{ uuid: "JournalEntry.aaa", id: "aaa", name: "Roland", documentType: "JournalEntry" }] }) } as unknown as Response;
|
|
return { ok: false, status: 404, text: async () => '{"error":"not found"}' } as unknown as Response;
|
|
}) as unknown as typeof fetch;
|
|
}
|
|
|
|
function mockGetError(status: number, error: string): void {
|
|
globalThis.fetch = vi.fn(async () => ({ ok: false, status, text: async () => JSON.stringify({ error }) }) as unknown as Response) as unknown as typeof fetch;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
dir = await mkdtemp(join(tmpdir(), "e2-2-"));
|
|
await mkdir(join(dir, "refined"), { recursive: true });
|
|
await mkdir(join(dir, "out"), { recursive: true });
|
|
state = makeState();
|
|
await saveSyncState(state.cfg.outDir, state.syncState!);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
globalThis.fetch = realFetch;
|
|
await rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
function setupController(): FoundryPollController {
|
|
const controller = new FoundryPollController(state);
|
|
// Set the prevSnapshot so the deep poll's candidate list includes the uuid.
|
|
(controller as any).prevSnapshot.set("JournalEntry.aaa", { name: "Roland", img: null });
|
|
// Mock the index with a linked entry.
|
|
(state as any).index = {
|
|
matched: [{ entry: { _id: "aaa", name: "Roland" }, refinedPath: join(dir, "refined", "Roland.md"), name: "Roland" }],
|
|
ccOnly: [], refinedOnly: [],
|
|
};
|
|
(controller as any).retryBackoffs = [1, 1, 1]; // fast retries for tests
|
|
return controller;
|
|
}
|
|
|
|
async function readFPending(): Promise<PendingFChange[]> {
|
|
const saved = JSON.parse(await readFile(join(dir, "out", "sync-state.json"), "utf8")) as SyncState & { fPending?: PendingFChange[] };
|
|
return saved.fPending ?? [];
|
|
}
|
|
|
|
describe("E2.2 deep poll detection", () => {
|
|
it("ccHash mismatch → F-changed 'edited'", async () => {
|
|
const live = liveEntry("<p>Foundry-edited body.</p>");
|
|
mockGetEntry(live);
|
|
// Note's ccHash baseline = a DIFFERENT value (the original body's ccHash).
|
|
await writeNote("0".repeat(64), "Folder.test");
|
|
const controller = setupController();
|
|
await (controller as any).deepPoll();
|
|
const fPending = await readFPending();
|
|
expect(fPending.some((e) => e.uuid === "JournalEntry.aaa" && e.change === "edited")).toBe(true);
|
|
controller.stop();
|
|
});
|
|
|
|
it("ccHash match → no change (left untouched)", async () => {
|
|
const live = liveEntry("<p>Original body.</p>");
|
|
mockGetEntry(live);
|
|
// Note's ccHash baseline = ccHash(live) (matches).
|
|
await writeNote(ccHash(live), "Folder.test");
|
|
const controller = setupController();
|
|
await (controller as any).deepPoll();
|
|
const fPending = await readFPending();
|
|
expect(fPending.length).toBe(0); // no changes
|
|
controller.stop();
|
|
});
|
|
|
|
it("folder move on a legacy note (no ccHash) → 'moved'", async () => {
|
|
const live = liveEntry("<p>Original body.</p>", "Folder.new");
|
|
mockGetEntry(live);
|
|
// Note has NO ccHash baseline (legacy) but has folder_path = "Folder.test".
|
|
// Live folder = "Folder.new" → "moved".
|
|
await writeNote(null, "Folder.test");
|
|
const controller = setupController();
|
|
await (controller as any).deepPoll();
|
|
const fPending = await readFPending();
|
|
expect(fPending.some((e) => e.uuid === "JournalEntry.aaa" && e.change === "moved")).toBe(true);
|
|
controller.stop();
|
|
});
|
|
|
|
it("persistent error (404 No connected Foundry clients) → aborts + halts", async () => {
|
|
mockGetError(404, "No connected Foundry clients found");
|
|
await writeNote(ccHash(liveEntry("<p>x</p>")), "Folder.test");
|
|
const controller = setupController();
|
|
await controller.setEnabled(true);
|
|
// deepTick catches the persistent error from deepPoll + calls stop() (halt).
|
|
await (controller as any).deepTick();
|
|
expect(controller.enabled).toBe(false); // halted by deepTick's catch
|
|
controller.stop();
|
|
});
|
|
|
|
it("transient error (504) → retries, final failure → fPending recorded (round continues)", async () => {
|
|
// Mock /get to always 504 (transient → retries → exhaustion).
|
|
mockGetError(504, "Request timed out");
|
|
await writeNote(ccHash(liveEntry("<p>x</p>")), "Folder.test");
|
|
const controller = setupController();
|
|
await (controller as any).deepPoll(); // should NOT throw (transient exhaustion is recorded, not thrown)
|
|
const fPending = await readFPending();
|
|
expect(fPending.some((e) => e.uuid === "JournalEntry.aaa" && e.change === "edited")).toBe(true); // recorded as fPending
|
|
controller.stop();
|
|
});
|
|
|
|
it("status() exposes loadCeilingCallsPerMin + deep poll info", () => {
|
|
const controller = new FoundryPollController(state);
|
|
const s = controller.status();
|
|
expect(s.loadCeilingCallsPerMin).toBeGreaterThan(0);
|
|
expect(s.deepCadenceMs).toBeGreaterThanOrEqual(30000);
|
|
expect(s.deepInFlight).toBe(false);
|
|
});
|
|
}); |