feat(E1b.4): per-uuid pre-push backup cache + retention + "Revert last push"
Before any auto/manual push, the pre-push Foundry entry is cached so a wrong
push is one-click undoable from the dashboard (no shell, no hunting backups).
- src/push.ts: optional backupPath in PushDeps (writes there with mkdir -p;
else the default flat <outDir>/bak/<name>.<stamp>.json for manual push).
PushOutcome.backupPath returns the actual path.
- src/server.ts runPushBody: auto-sync passes the per-uuid
<outDir>/foundry-backups/<uuid>/<iso>.json layout (so retention is per-entry,
distinct from manual push's flat bak/). After a successful push: records a
last-push {uuid,name,backupPath,time,relPath} in an in-memory map + as
controller.lastPush (drives the dashboard button); prunes the per-uuid backup
dir to the last N (AUTOSYNC_BACKUP_RETAIN default 10, by mtime, awaited so the
post-push state is deterministic). GET /api/autosync/last-push?uuid=… returns
the record or 404; status() exposes lastPush + conflictCount.
- src/server.ts AutoSyncController.revert(uuid): acquires the per-uuid lock in
the "pull" direction (queues behind an in-flight push, 5s max); reads the
backup JSON (400 if no last-push, 409 if the file is missing/corrupt); calls
relay.updateEntry(uuid, fullBackupDoc) — a FULL /update (not a diff — the one
place a full PUT is correct, to restore _id/pages/ownership/flags exactly);
re-baselines the note (contentHash=body, ccHash=ccHash(backupDoc)) via the
E1b.2 path + records self-write suppression; clears any TOCTOU conflict for
the uuid. POST /api/autosync/revert endpoint (unguarded — E7 not landed).
Gated by AUTOSYNC_FOUNDRY_GUARD (404 when off).
- src/dashboard.html: "Revert last push" button in the auto-sync panel (shown
when a recent push exists + guard on), wired to POST /api/autosync/revert
with a confirm stating the note keeps the edit while Foundry reverts.
- tests/e1b4-revert.test.ts: 6 tests — per-uuid backup written + last-push
recorded; retention prunes to N; revert full-PUTs the backup + re-baselines;
400 (no last-push); 409 (missing backup); 404 (guard off).
tsc clean; 135 passing project-wide (18 pre-existing fixture-missing unchanged).
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -93,6 +93,7 @@
|
||||
</header>
|
||||
<details id="autoSyncPanel" class="autosync-panel" open style="display:none;margin:0 12px;border-top:1px solid #ddd;padding:6px 12px;background:#fafafa">
|
||||
<summary style="cursor:pointer">Auto-sync activity <span id="autoSyncCounts" class="meta"></span><span id="autoSyncNote" class="meta" style="margin-left:8px"></span></summary>
|
||||
<div id="revertBar" style="margin:6px 0;display:none"><button id="revertBtn" class="bad" onclick="revertLastPush()" title="Restore Foundry to the state captured BEFORE the most recent auto-sync push (a full /update), then re-baseline the note. Use this to undo a wrong push."></button></div>
|
||||
<pre id="autoSyncLog" class="autosync-log" style="max-height:180px;overflow:auto;margin:6px 0 8px;font-size:12px;background:#fff;border:1px solid #eee;padding:6px">(no activity yet)</pre>
|
||||
</details>
|
||||
<main>
|
||||
@@ -353,6 +354,29 @@ async function refreshAutosync(){
|
||||
: '(no activity yet — save a linked, seeded note in your vault to trigger a push)';
|
||||
if (r.enabled && !autoPoll) autoPoll = setInterval(refreshAutosync, 2000);
|
||||
if (!r.enabled && autoPoll) { clearInterval(autoPoll); autoPoll = null; }
|
||||
// E1b.4: "Revert last push" button — shown when there's a recent push and
|
||||
// the guard is on (revert is meaningless without the per-uuid backups).
|
||||
const revertBar = document.getElementById('revertBar');
|
||||
const revertBtn = document.getElementById('revertBtn');
|
||||
if (r.lastPush && r.enabled) {
|
||||
revertBar.style.display = '';
|
||||
revertBtn.textContent = `Revert last push: ${r.lastPush.name}`;
|
||||
revertBtn.dataset.uuid = r.lastPush.uuid;
|
||||
} else {
|
||||
revertBar.style.display = 'none';
|
||||
}
|
||||
}
|
||||
async function revertLastPush(){
|
||||
const btn = document.getElementById('revertBtn');
|
||||
const uuid = btn.dataset.uuid;
|
||||
if (!uuid) return;
|
||||
const noteName = btn.textContent.replace('Revert last push: ', '');
|
||||
if (!confirm(`Revert the last push of "${noteName}"?\n\nThis restores Foundry to the state captured BEFORE the push (a full /update — the one place a full PUT is correct) and re-baselines the note. The note keeps your edit; Foundry reverts.`)) return;
|
||||
toast('reverting last push…');
|
||||
const r = await fetch('/api/autosync/revert', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({uuid})}).then(r=>r.json()).catch(()=>null);
|
||||
if (r && r.ok) toast(`reverted — Foundry restored to pre-push state ("${r.restoredName ?? noteName}")`);
|
||||
else toast(`revert failed: ${r?.error || 'unknown'}`);
|
||||
refreshAutosync();
|
||||
}
|
||||
async function toggleAutosync(){
|
||||
const want = !(AUTO && AUTO.enabled);
|
||||
|
||||
16
src/push.ts
16
src/push.ts
@@ -1,5 +1,5 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { join, dirname } from "node:path";
|
||||
import { obsidianToFoundryJsonLive } from "./toFoundry.js";
|
||||
import { splitFrontmatter, readFoundryBlock } from "./frontmatter.js";
|
||||
import { RelayClient } from "./relay/client.js";
|
||||
@@ -50,6 +50,12 @@ export interface PushDeps {
|
||||
* (fail-safe: a Foundry-side edit since the last baseline must not be
|
||||
* overwritten). Return `{ abort: false }` (or omit the guard) to proceed. */
|
||||
prePushGuard?: (liveEntry: JournalEntry) => { abort: boolean; reason?: string };
|
||||
/** E1b.4: override the backup file path. If set, the pre-push live-entry
|
||||
* snapshot is written here (mkdir -p the dirname) instead of the default
|
||||
* `<outDir>/bak/<noteName>.<stamp>.json` (which manual push keeps). Auto-sync
|
||||
* uses the per-uuid `foundry-backups/<uuid>/<iso>.json` layout so retention is
|
||||
* per-entry. */
|
||||
backupPath?: string;
|
||||
}
|
||||
|
||||
export interface PushOutcome {
|
||||
@@ -205,10 +211,10 @@ export async function pushNote(deps: PushDeps): Promise<PushOutcome> {
|
||||
}
|
||||
|
||||
// Apply: snapshot the live entry first (reversible), then PUT the diff.
|
||||
const bakDir = join(deps.outDir, "bak");
|
||||
await mkdir(bakDir, { recursive: true });
|
||||
const stamp = backupStamp();
|
||||
const backupPath = join(bakDir, `${deps.noteName}.${stamp}.json`);
|
||||
// E1b.4: auto-sync passes a per-uuid backupPath (foundry-backups/<uuid>/<iso>.json);
|
||||
// manual push keeps the default flat <outDir>/bak/<noteName>.<stamp>.json.
|
||||
const backupPath = deps.backupPath ?? join(deps.outDir, "bak", `${deps.noteName}.${backupStamp()}.json`);
|
||||
await mkdir(dirname(backupPath), { recursive: true });
|
||||
await writeFile(backupPath, JSON.stringify(liveEntry, null, 2) + "\n", "utf8");
|
||||
log(`push: backed up live entry -> ${backupPath}`);
|
||||
|
||||
|
||||
104
src/server.ts
104
src/server.ts
@@ -9,7 +9,7 @@
|
||||
// unless the server was started with --apply.
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse, type Server } from "node:http";
|
||||
import { readFile, writeFile, mkdir, copyFile, access, stat } from "node:fs/promises";
|
||||
import { readFile, writeFile, mkdir, copyFile, access, stat, readdir, unlink } from "node:fs/promises";
|
||||
import { watch, readdirSync, statSync, type FSWatcher } from "node:fs";
|
||||
import { join, dirname, relative, basename, extname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -287,6 +287,17 @@ async function handleRefresh(state: State, req: IncomingMessage, res: ServerResp
|
||||
}
|
||||
}
|
||||
|
||||
/** E1b.4: POST /api/autosync/revert {uuid} — restore Foundry to the pre-push
|
||||
* backup and re-baseline. Thin wrapper over AutoSyncController.revert. */
|
||||
async function handleRevert(state: State, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const body = await readJsonBody(req);
|
||||
if (body === null) return send(res, 400, { error: "bad json" });
|
||||
const uuid = String(body.uuid ?? "");
|
||||
if (!uuid) return send(res, 400, { error: "uuid required" });
|
||||
const result = await state.autosync.revert(uuid);
|
||||
send(res, result.code, result.body);
|
||||
}
|
||||
|
||||
interface PushAllItem {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
@@ -538,6 +549,14 @@ export class AutoSyncController {
|
||||
// successful push. Exposed via GET /api/autosync/conflicts.
|
||||
conflicts: ConflictRow[] = [];
|
||||
private readonly maxConflicts = 50;
|
||||
// E1b.4: last-push record per uuid, pointing at the per-uuid backup file
|
||||
// (foundry-backups/<uuid>/<iso>.json) pushNote wrote. Drives the "Revert last
|
||||
// push" action. Exposed via GET /api/autosync/last-push?uuid=…
|
||||
lastPushes = new Map<string, { uuid: string; name: string; backupPath: string; time: string; relPath: string }>();
|
||||
private readonly backupRetain = Math.max(1, Number(process.env.AUTOSYNC_BACKUP_RETAIN ?? 10));
|
||||
/** E1b.4: the most-recent push record (drives the dashboard "Revert last push"
|
||||
* button). Updated on every successful push. */
|
||||
lastPush: { uuid: string; name: string; backupPath: string; time: string; relPath: string } | null = null;
|
||||
private readonly syncLockEnabled = SYNC_LOCK_ENABLED;
|
||||
// relPath → last-resolved lock key (foundry.cc_uuid or relPath: fallback),
|
||||
// populated by `process` so the debounce pre-check can fast-skip a redundant
|
||||
@@ -571,6 +590,8 @@ export class AutoSyncController {
|
||||
// a push and needs manual reconciliation.
|
||||
conflictCount: this.conflicts.length,
|
||||
conflicts: this.conflicts,
|
||||
// E1b.4: the most-recent push record (drives the "Revert last push" button).
|
||||
lastPush: this.lastPush,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -800,6 +821,9 @@ export class AutoSyncController {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// E1b.4: per-uuid backup path (foundry-backups/<uuid>/<iso>.json) so retention
|
||||
// is per-entry and "Revert last push" can restore the pre-push Foundry state.
|
||||
const backupPath = join(this.state.cfg.outDir, "foundry-backups", fb!.cc_uuid, `${backupStamp()}.json`);
|
||||
const outcome = await pushNote({
|
||||
notePath: abs,
|
||||
noteName: name,
|
||||
@@ -810,6 +834,7 @@ export class AutoSyncController {
|
||||
dryRun: false, // auto-sync always applies — the whole point is hands-off live push
|
||||
log: () => {},
|
||||
prePushGuard,
|
||||
backupPath,
|
||||
});
|
||||
|
||||
// E1b.1: guard aborted the push (Foundry-side drift or unreadable). No PUT,
|
||||
@@ -903,15 +928,80 @@ export class AutoSyncController {
|
||||
if (this.conflicts.some((c) => c.uuid === fb!.cc_uuid)) {
|
||||
this.conflicts = this.conflicts.filter((c) => c.uuid !== fb!.cc_uuid);
|
||||
}
|
||||
// E1b.4: record the last-push (pointing at the per-uuid backup) for "Revert
|
||||
// last push", and prune the per-uuid backup dir to the last N (by mtime).
|
||||
this.lastPushes.set(fb!.cc_uuid, { uuid: fb!.cc_uuid, name, backupPath, time: new Date().toISOString(), relPath });
|
||||
this.lastPush = this.lastPushes.get(fb!.cc_uuid) ?? null;
|
||||
// E1b.4: prune the per-uuid backup dir to the last N (awaited so the
|
||||
// post-push state is deterministic — tests assert the retention count).
|
||||
await this.pruneBackups(fb!.cc_uuid).catch((e) => this.log(name, "error", `backup prune failed: ${(e as Error).message}`));
|
||||
this.log(name, "pushed", `→ ${outcome.ccUuid}${outcome.updatedName ? ` ("${outcome.updatedName}")` : ""}${baselined ? " · baselined (content+cc)" : ""}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** E1b.4: keep only the last `backupRetain` backups for a uuid (newest by mtime),
|
||||
* deleting older files. Deletions are logged. Best-effort (never throws). */
|
||||
private async pruneBackups(uuid: string): Promise<void> {
|
||||
const dir = join(this.state.cfg.outDir, "foundry-backups", uuid);
|
||||
let files: string[];
|
||||
try { files = await readdir(dir); } catch { return; } // dir missing — nothing to prune
|
||||
const stamped = await Promise.all(files.map(async (f) => {
|
||||
try { return { f, mtime: (await stat(join(dir, f))).mtimeMs }; } catch { return null; }
|
||||
}));
|
||||
const ok = stamped.filter(Boolean) as { f: string; mtime: number }[];
|
||||
if (ok.length <= this.backupRetain) return;
|
||||
ok.sort((a, b) => b.mtime - a.mtime); // newest first
|
||||
const toDelete = ok.slice(this.backupRetain);
|
||||
for (const { f } of toDelete) {
|
||||
try { await unlink(join(dir, f)); this.log(uuid, "skipped", `backup pruned: ${f}`); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** E1b.3: record a TOCTOU conflict (newest first, bounded to maxConflicts). */
|
||||
private recordConflict(row: ConflictRow): void {
|
||||
this.conflicts.unshift(row);
|
||||
if (this.conflicts.length > this.maxConflicts) this.conflicts.length = this.maxConflicts;
|
||||
}
|
||||
|
||||
/** E1b.4: revert the last push for a uuid — restore Foundry to the pre-push
|
||||
* backup (a FULL /update, NOT a diff — the one place a full PUT is correct, so
|
||||
* _id/pages/ownership/flags are restored exactly), then re-baseline the note
|
||||
* to the restored Foundry state. Acquires the per-uuid lock in the "pull"
|
||||
* direction (a Foundry→vault reconciliation), queuing behind an in-flight push.
|
||||
* Gated by AUTOSYNC_FOUNDRY_GUARD (404 when off). */
|
||||
async revert(uuid: string): Promise<{ code: number; body: unknown }> {
|
||||
if (!this.foundryGuardEnabled) return { code: 404, body: { error: "revert disabled (AUTOSYNC_FOUNDRY_GUARD off)" } };
|
||||
const rec = this.lastPushes.get(uuid);
|
||||
if (!rec) return { code: 400, body: { error: `no last push for ${uuid}` } };
|
||||
let backupJson: string;
|
||||
try { backupJson = await readFile(rec.backupPath, "utf8"); }
|
||||
catch { return { code: 409, body: { error: `backup file missing: ${rec.backupPath}` } }; }
|
||||
let backupDoc: JournalEntry;
|
||||
try { backupDoc = JSON.parse(backupJson) as JournalEntry; }
|
||||
catch { return { code: 409, body: { error: `backup file corrupt: ${rec.backupPath}` } }; }
|
||||
const result = await this.lock.withLock<{ code: number; body: unknown }>(uuid, "pull", async () => {
|
||||
try {
|
||||
const relay = relayClient(this.state);
|
||||
const updated = await relay.updateEntry(uuid, backupDoc as unknown as Record<string, unknown>);
|
||||
const abs = await resolveRefined(this.state, rec.relPath);
|
||||
const restoredCcHash = ccHash(backupDoc);
|
||||
this.ccHashBaselines.set(uuid, restoredCcHash);
|
||||
await baselineNote(this.state, rec.relPath, abs, restoredCcHash);
|
||||
try {
|
||||
const st = statSync(join(this.state.cfg.refinedDir, rec.relPath));
|
||||
this.recentlyBaselined.set(rec.relPath, { mtime: st.mtimeMs, baselineHash: restoredCcHash, expires: Date.now() + this.baselineSuppressMs });
|
||||
} catch { /* skip self-write suppression */ }
|
||||
// Revert resolves any open TOCTOU conflict for this uuid.
|
||||
if (this.conflicts.some((c) => c.uuid === uuid)) this.conflicts = this.conflicts.filter((c) => c.uuid !== uuid);
|
||||
this.log(rec.name, "pushed", `reverted → ${uuid} (restored pre-push Foundry state) · baselined (content+cc)`);
|
||||
return { code: 200, body: { ok: true, uuid, name: rec.name, restoredName: updated.name } };
|
||||
} catch (e) {
|
||||
this.log(rec.name, "error", `revert failed: ${(e as Error).message}`);
|
||||
return { code: 500, body: { error: (e as Error).message } };
|
||||
}
|
||||
}, { policy: "queue", maxWaitMs: 5000 });
|
||||
return result ?? { code: 409, body: { error: "lock busy — another operation holds this uuid; try again" } };
|
||||
}
|
||||
}
|
||||
|
||||
export async function startServer(cfg: ServerConfig): Promise<{ server: Server; state: State }> {
|
||||
@@ -985,6 +1075,18 @@ export async function startServer(cfg: ServerConfig): Promise<{ server: Server;
|
||||
if (req.method === "GET" && url.pathname === "/api/autosync/conflicts") {
|
||||
return send(res, 200, { conflicts: state.autosync.conflicts });
|
||||
}
|
||||
// E1b.4: last-push record per uuid (drives the "Revert last push" button).
|
||||
if (req.method === "GET" && url.pathname === "/api/autosync/last-push") {
|
||||
const uuid = url.searchParams.get("uuid") ?? "";
|
||||
const rec = state.autosync.lastPushes.get(uuid);
|
||||
if (!rec) return send(res, 404, { error: `no last push for ${uuid}` });
|
||||
return send(res, 200, rec);
|
||||
}
|
||||
// E1b.4: revert the last push (restore Foundry to the pre-push backup +
|
||||
// re-baseline). Registered unguarded (E7 not landed yet).
|
||||
if (req.method === "POST" && url.pathname === "/api/autosync/revert") {
|
||||
return handleRevert(state, req, res);
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/autosync") {
|
||||
const body = await readJsonBody(req);
|
||||
if (body === null) return send(res, 400, { error: "bad json" });
|
||||
|
||||
181
tests/e1b4-revert.test.ts
Normal file
181
tests/e1b4-revert.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
// E1b.4 — per-uuid backup cache + retention + "Revert last push".
|
||||
//
|
||||
// Exercises the REAL pushNote + controller against a MOCKED relay (behaves like
|
||||
// Foundry: /get returns current state, /update applies the diff or, for revert,
|
||||
// receives the FULL backup doc). Covers:
|
||||
// 1. a clean push writes foundry-backups/<uuid>/<iso>.json + records last-push.
|
||||
// 2. retention keeps the last N backups per uuid (older files pruned).
|
||||
// 3. revert restores Foundry to the backup (FULL /update, not a diff) +
|
||||
// re-baselines the note (ccHash = ccHash(backupDoc)).
|
||||
// 4. revert with no last-push record → 400.
|
||||
// 5. revert with a missing backup file → 409.
|
||||
// 6. revert disabled when AUTOSYNC_FOUNDRY_GUARD is off → 404.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtemp, writeFile, mkdir, rm, readFile, readdir } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
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 { obsidianToFoundryJsonLive } from "../src/toFoundry.js";
|
||||
import { splitFrontmatter, readFoundryBlock } from "../src/frontmatter.js";
|
||||
import type { JournalEntry } from "../src/types.js";
|
||||
import type { NameResolver } from "../src/resolver.js";
|
||||
|
||||
const UUID = "JournalEntry.abc1";
|
||||
const REL = "Roland.md";
|
||||
const EMPTY_RESOLVER: NameResolver = { nameOf: () => undefined, uuidOf: () => undefined };
|
||||
|
||||
function liveEntry(description: string): JournalEntry {
|
||||
return { name: "Roland", _id: "abc1", folder: "Folder.test", flags: { "campaign-codex": { type: "npc", data: { description, notes: "" } } } };
|
||||
}
|
||||
function seededNote(ccHashBaseline: string, body: string): string {
|
||||
return [
|
||||
"---", "type: npc", "foundry:", ` cc_uuid: ${UUID}`, " cc_type: npc", " folder_path: NPCs",
|
||||
` contentHash: ${"0".repeat(64)}`, ` ccHash: ${ccHashBaseline}`, " syncedAt: 2026-06-22T00:00:00.000Z",
|
||||
"---", body, "",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
let state: State;
|
||||
let putCalls: number;
|
||||
let currentState: JournalEntry;
|
||||
let lastUpdateBody: unknown; // the body sent to the last /update (to assert full vs diff)
|
||||
const realFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "e1b4-"));
|
||||
await mkdir(join(dir, "refined"), { recursive: true });
|
||||
putCalls = 0;
|
||||
currentState = liveEntry("<p>Original body.</p>");
|
||||
lastUpdateBody = null;
|
||||
const cfg: ServerConfig = {
|
||||
journal: "", refinedDir: join(dir, "refined"), ccDir: "", outDir: join(dir, "out"),
|
||||
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;
|
||||
let getCalls = 0;
|
||||
globalThis.fetch = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const u = String(url); const method = init?.method ?? "GET";
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
||||
const resp = (o: unknown, ok = true, status = 200) => ({ ok, status, text: async () => (typeof o === "string" ? o : JSON.stringify(o)) });
|
||||
if (method === "GET" && u.includes("/get?")) { const re = getCalls === 1; getCalls++; return resp({ data: re ? currentState : currentState }); }
|
||||
if (method === "GET" && u.includes("/search")) return resp({ results: [] });
|
||||
if (method === "PUT" && u.includes("/update?")) {
|
||||
putCalls++; lastUpdateBody = body;
|
||||
const diff = body?.data ?? {};
|
||||
currentState = { ...currentState, name: diff.name ?? currentState.name, flags: { ...currentState.flags, "campaign-codex": diff["flags.campaign-codex"] ?? currentState.flags?.["campaign-codex"] } };
|
||||
return resp({ entity: [currentState] });
|
||||
}
|
||||
return resp({ error: "not found" }, false, 404);
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
globalThis.fetch = realFetch;
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeNote(baseline: string, body: string): Promise<void> {
|
||||
await writeFile(join(state.cfg.refinedDir, REL), seededNote(baseline, body), "utf8");
|
||||
}
|
||||
|
||||
function expectedPushedEntry(body: string, base: JournalEntry): JournalEntry {
|
||||
return obsidianToFoundryJsonLive(body, "Roland", base, EMPTY_RESOLVER);
|
||||
}
|
||||
|
||||
describe("E1b.4a per-uuid backup + retention", () => {
|
||||
it("a clean push writes foundry-backups/<uuid>/<iso>.json + records last-push", async () => {
|
||||
const body = "The gunslinger drew his revolver.\n";
|
||||
const prePush = currentState;
|
||||
await writeNote(ccHash(prePush), body);
|
||||
const controller = new AutoSyncController(state);
|
||||
await (controller as any).process(REL);
|
||||
expect(putCalls).toBe(1);
|
||||
|
||||
const rec = controller.lastPushes.get(UUID);
|
||||
expect(rec).toBeTruthy();
|
||||
expect(rec!.uuid).toBe(UUID);
|
||||
expect(rec!.relPath).toBe(REL);
|
||||
// The backup file exists at the per-uuid path.
|
||||
expect(existsSync(rec!.backupPath)).toBe(true);
|
||||
expect(rec!.backupPath).toContain(join("foundry-backups", UUID));
|
||||
// The backup content is the pre-push live entry.
|
||||
const backup = JSON.parse(await readFile(rec!.backupPath, "utf8")) as JournalEntry;
|
||||
expect(backup.flags?.["campaign-codex"]?.data?.description).toBe("<p>Original body.</p>");
|
||||
});
|
||||
|
||||
it("retention keeps only the last N backups per uuid (older pruned)", async () => {
|
||||
const controller = new AutoSyncController(state);
|
||||
(controller as any).backupRetain = 2; // shrink so the test is quick
|
||||
let prePush = currentState;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const body = `Body version ${i}.\n`;
|
||||
await writeNote(ccHash(currentState), body); // baseline = current Foundry state (guard passes)
|
||||
prePush = currentState;
|
||||
await (controller as any).process(REL);
|
||||
// After the push, currentState is the pushed entry; next iteration baselines to it.
|
||||
}
|
||||
expect(putCalls).toBe(3);
|
||||
const backupDir = join(state.cfg.outDir, "foundry-backups", UUID);
|
||||
const files = await readdir(backupDir);
|
||||
expect(files.length).toBe(2); // pruned to last N (2)
|
||||
});
|
||||
});
|
||||
|
||||
describe("E1b.4b revert last push", () => {
|
||||
it("revert restores Foundry to the backup (FULL /update) + re-baselines the note", async () => {
|
||||
const body = "The gunslinger drew his revolver.\n";
|
||||
const prePush = currentState;
|
||||
await writeNote(ccHash(prePush), body);
|
||||
const controller = new AutoSyncController(state);
|
||||
await (controller as any).process(REL); // push 1: records last-push + backup
|
||||
expect(putCalls).toBe(1);
|
||||
const rec = controller.lastPushes.get(UUID)!;
|
||||
const backupDoc = JSON.parse(await readFile(rec.backupPath, "utf8")) as JournalEntry;
|
||||
|
||||
const result = await controller.revert(UUID);
|
||||
expect(result.code).toBe(200);
|
||||
expect(putCalls).toBe(2); // the revert PUT fired
|
||||
// The revert sent the FULL backup doc (with _id/pages/ownership), not a diff.
|
||||
expect((lastUpdateBody as any)?.data).toEqual(backupDoc);
|
||||
// The note re-baselined to the restored Foundry state (ccHash = ccHash(backupDoc)).
|
||||
const fb = readFoundryBlock(splitFrontmatter(await readFile(join(state.cfg.refinedDir, REL), "utf8")).fm);
|
||||
expect(fb?.ccHash).toBe(ccHash(backupDoc));
|
||||
// The in-memory ccHash baseline also reflects the restored state.
|
||||
expect((controller as any).ccHashBaselines.get(UUID)).toBe(ccHash(backupDoc));
|
||||
expect(controller.events.some((e) => e.message.includes("reverted"))).toBe(true);
|
||||
});
|
||||
|
||||
it("revert with no last-push record → 400", async () => {
|
||||
const controller = new AutoSyncController(state);
|
||||
const result = await controller.revert(UUID);
|
||||
expect(result.code).toBe(400);
|
||||
expect((result.body as { error: string }).error).toMatch(/no last push/);
|
||||
});
|
||||
|
||||
it("revert with a missing backup file → 409", async () => {
|
||||
const body = "The gunslinger drew his revolver.\n";
|
||||
await writeNote(ccHash(currentState), body);
|
||||
const controller = new AutoSyncController(state);
|
||||
await (controller as any).process(REL); // records last-push + writes backup
|
||||
const rec = controller.lastPushes.get(UUID)!;
|
||||
await rm(rec.backupPath, { force: true }); // manual cleanup deletes the backup
|
||||
const result = await controller.revert(UUID);
|
||||
expect(result.code).toBe(409);
|
||||
expect((result.body as { error: string }).error).toMatch(/backup file missing/);
|
||||
});
|
||||
|
||||
it("revert disabled when AUTOSYNC_FOUNDRY_GUARD is off → 404", async () => {
|
||||
const body = "The gunslinger drew his revolver.\n";
|
||||
await writeNote(ccHash(currentState), body);
|
||||
const controller = new AutoSyncController(state);
|
||||
(controller as any).foundryGuardEnabled = false;
|
||||
const result = await controller.revert(UUID);
|
||||
expect(result.code).toBe(404);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user