When the deep poll detects a F-changed note AND the Obsidian side is unchanged, the note is pulled from Foundry into the vault: /get → entryToObsidian (via rePullRow) → writeWithBackup → dual re-baseline (contentHash + ccHash). Removes from fPending. If O-side also changed → "conflict" (left in fPending for E2.4). - src/server.ts AutoSyncController.pullFChanged(uuid, liveEntry): finds the matching row, checks O-side unchanged (contentHash(body) === foundry.contentHash), acquires the per-uuid "pull" lock, calls rePullRow (batch.ts — converts the live entry to refined markdown + injects the foundry block), writes via writeWithBackup (dev: mirror; apply: real vault + .bak), re-baselines both contentHash + ccHash(liveEntry) via baselineNote, removes from fPending + updates parity, logs "pulled ← <uuid> · baselined (content+cc)". Returns "pulled" | "conflict" | "skipped". - src/foundry-poll.ts deepPoll: for each F-changed note, calls pullFChanged(uuid, liveEntry). "pulled" → removed from fPending (no change recorded). "conflict"/"skipped" → recorded in fPending for E2.4 / retry. Guard: if autosync isn't available (E2.2 tests), falls back to recording only. - tests/e2-3-pull.test.ts: 5 tests — F-changed + O-unchanged → pulled + re-baselined + fPending cleared; F-changed + O-changed → "conflict" (note untouched); no matching row → "skipped"; note missing → "skipped"; unseeded → "skipped". tsc clean; 258 passing project-wide (18 pre-existing fixture-missing unchanged). Co-Authored-By: Claude <noreply@anthropic.com>
131 lines
6.1 KiB
TypeScript
131 lines
6.1 KiB
TypeScript
// E2.3 — F→O pull for F-changed + O-unchanged notes.
|
|
|
|
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 { AutoSyncController } from "../src/server.js";
|
|
import type { State, ServerConfig } from "../src/server.js";
|
|
import { ccHash } from "../src/cchash.js";
|
|
import { contentHash } from "../src/normalize.js";
|
|
import { splitFrontmatter, readFoundryBlock } from "../src/frontmatter.js";
|
|
import { saveSyncState, defaultSyncState, type SyncState } from "../src/sync-state.js";
|
|
import type { JournalEntry } from "../src/types.js";
|
|
import type { FileRow } from "../src/batch.js";
|
|
|
|
const UUID = "JournalEntry.aaa";
|
|
|
|
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: "" } } } };
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
dir = await mkdtemp(join(tmpdir(), "e2-3-"));
|
|
await mkdir(join(dir, "refined"), { recursive: true });
|
|
await mkdir(join(dir, "out"), { recursive: true });
|
|
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 },
|
|
};
|
|
state = { db: {} as State["db"], cfg, index: null, autosync: null as unknown as State["autosync"] } as unknown as State;
|
|
state.syncState = { ...defaultSyncState(cfg.refinedDir) } as SyncState;
|
|
await saveSyncState(cfg.outDir, state.syncState);
|
|
});
|
|
|
|
afterEach(async () => { globalThis.fetch = realFetch; await rm(dir, { recursive: true, force: true }); });
|
|
|
|
async function writeNote(body: string, contentHashBaseline: string, ccHashBaseline: string): Promise<void> {
|
|
const lines = [
|
|
"---", "type: npc", "foundry:", ` cc_uuid: ${UUID}`, " cc_type: npc",
|
|
` folder_path: Folder.test`, ` contentHash: ${contentHashBaseline}`,
|
|
` ccHash: ${ccHashBaseline}`, " syncedAt: 2026-06-22T00:00:00.000Z",
|
|
"---", body, "",
|
|
];
|
|
await writeFile(join(dir, "refined", "Roland.md"), lines.join("\n"), "utf8");
|
|
}
|
|
|
|
function setupIndex(): void {
|
|
const row: FileRow = {
|
|
name: "Roland", basename: "Roland", status: "matched" as never,
|
|
refinedPath: join(dir, "refined", "Roland.md"), ccPath: null, ccId: UUID, ccType: "npc",
|
|
curatedType: null, entry: { _id: "aaa", name: "Roland" } as JournalEntry, recommendation: "in-sync" as never,
|
|
refinedChanged: false, ccChanged: false, refinedMtime: null, ccMtime: null,
|
|
storedRefinedHash: null, storedCcHash: null,
|
|
};
|
|
(state as any).index = { matched: [row], ccOnly: [], refinedOnly: [], counts: { matched: 1, ccOnly: 0, refinedOnly: 0, unlinked: 0 } };
|
|
}
|
|
|
|
describe("E2.3 pullFChanged", () => {
|
|
it("F-changed + O-unchanged → pulls, re-baselines, removes from fPending", async () => {
|
|
const live = liveEntry("<p>Foundry-edited body.</p>");
|
|
const liveCc = ccHash(live);
|
|
// Note's contentHash = contentHash("Roland body.\n") (the O-side is unchanged —
|
|
// the body hasn't been edited in Obsidian since the last sync).
|
|
const body = "Roland body.\n";
|
|
await writeNote(body, contentHash(body), "0".repeat(64)); // ccHash = stale (≠ liveCc)
|
|
setupIndex();
|
|
const controller = new AutoSyncController(state);
|
|
(controller as any).retryBackoffs = [1, 1, 1];
|
|
|
|
const result = await controller.pullFChanged(UUID, live);
|
|
expect(result).toBe("pulled");
|
|
|
|
// The note was re-baselined: contentHash = the pulled body hash, ccHash = ccHash(live).
|
|
const md = await readFile(join(dir, "refined", "Roland.md"), "utf8");
|
|
const fb = readFoundryBlock(splitFrontmatter(md).fm);
|
|
expect(fb?.ccHash).toBe(liveCc);
|
|
// The body was pulled from Foundry (entryToObsidian converts the live entry to markdown).
|
|
// The contentHash should match the NEW body (the pulled content).
|
|
const { body: pulledBody } = splitFrontmatter(md);
|
|
expect(fb?.contentHash).toBe(contentHash(pulledBody));
|
|
// Removed from fPending.
|
|
const saved = JSON.parse(await readFile(join(dir, "out", "sync-state.json"), "utf8")) as SyncState & { fPending?: unknown[] };
|
|
expect((saved.fPending ?? []).length).toBe(0);
|
|
});
|
|
|
|
it("F-changed + O-CHANGED → 'conflict' (left in fPending for E2.4)", async () => {
|
|
const live = liveEntry("<p>Foundry-edited body.</p>");
|
|
// Note's contentHash = "0"*64 (stale) — the body was edited in Obsidian too
|
|
// (bodyHash ≠ contentHash → O-side changed).
|
|
await writeNote("Edited in Obsidian.\n", "0".repeat(64), "0".repeat(64));
|
|
setupIndex();
|
|
const controller = new AutoSyncController(state);
|
|
|
|
const result = await controller.pullFChanged(UUID, live);
|
|
expect(result).toBe("conflict");
|
|
// The note was NOT pulled (content unchanged).
|
|
const md = await readFile(join(dir, "refined", "Roland.md"), "utf8");
|
|
const fb = readFoundryBlock(splitFrontmatter(md).fm);
|
|
expect(fb?.ccHash).toBe("0".repeat(64)); // unchanged — not baselined
|
|
});
|
|
|
|
it("no matching row → 'skipped'", async () => {
|
|
// No index → no matching row.
|
|
const controller = new AutoSyncController(state);
|
|
const result = await controller.pullFChanged(UUID, liveEntry("<p>x</p>"));
|
|
expect(result).toBe("skipped");
|
|
});
|
|
|
|
it("note missing from disk → 'skipped'", async () => {
|
|
setupIndex(); // index has a row pointing at a non-existent file
|
|
const controller = new AutoSyncController(state);
|
|
const result = await controller.pullFChanged(UUID, liveEntry("<p>x</p>"));
|
|
expect(result).toBe("skipped");
|
|
});
|
|
|
|
it("unseeded note (no contentHash) → 'skipped'", async () => {
|
|
// Write a note without contentHash.
|
|
await writeFile(join(dir, "refined", "Roland.md"), "---\ntype: npc\nfoundry:\n cc_uuid: JournalEntry.aaa\n---\nbody\n", "utf8");
|
|
setupIndex();
|
|
const controller = new AutoSyncController(state);
|
|
const result = await controller.pullFChanged(UUID, liveEntry("<p>x</p>"));
|
|
expect(result).toBe("skipped");
|
|
});
|
|
}); |