Files
obsidian-foundry-sync/tests/e4-1-syncstate.test.ts
Kaysser Kayyali fa4d36dbe4 feat(E4.1): persistent sync-state.json + boot reconcile (Slice 1 foundation)
The first Slice 1 story. A durable aggregate (on/off, mode, parity, activity,
last-sync) that survives restarts — the single status source E2/E3/E4 read
from. E4.1 only delivers load/save + the boot reconcile; the dashboard migration
to read sync-state.json is E4.2-E4.6 (gated behind features.syncStatus, defined
here, default off).

- src/sync-state.ts: loadSyncState/saveSyncState (atomic: tmp+rename, no
  truncation on crash), the shape {syncStateSchemaVersion, mode, autoSyncOn,
  lastSyncAt, parity, watchedDir, activity, updatedAt, conflict:null} (conflict
  reserved for E3 — E4 forces it null), appendActivity (newest-first, trimmed to
  200), defaultSyncState (fresh install: autoSyncOn=false, mode=PREP). Schema
  mismatch (≠ SYNC_STATE_SCHEMA_VERSION from E0.3) → back up to
  sync-state.json.bak-<stamp> + fresh defaults + an error event. (Reconciliation:
  E0.3 froze SYNC_STATE_SCHEMA_VERSION = "sync-state/v1" — E4.1's AC "= 1" is
  superseded; E4.1 persists autoSyncOn, superseding E1b.5's "no persistence until
  E3" — a fresh install still defaults OFF; a user who toggled ON is restored ON.)
- src/server.ts: features.syncStatus flag (env OFS_SYNC_STATUS=1, default off)
  on ServerConfig/State. startServer loads sync-state.json + reconciles
  AutoSyncController.enabled with autoSyncOn — if true, setEnabled(true); on
  throw (dev mode apply-gate / no relay) → autoSyncOn=false + persist + an
  "auto-sync could not resume" error event. State.syncState holds the aggregate.
- tests/e4-1-syncstate.test.ts: 10 tests — load creates defaults; atomic save
  (no .tmp left); schema-mismatch backup + fresh + error event; restart survival
  (autoSyncOn + 17 activity preserved); activity trimmed to 200; conflict forced
  null; boot reconcile (autoSyncOn=true + apply + relay → enabled restored; no
  relay → flipped off + error; dev mode → flipped off; fresh → stays off).

tsc clean; 222 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 01:42:13 +00:00

136 lines
7.0 KiB
TypeScript

// E4.1 — persistent sync-state.json (load/save/atomic/schema-mismatch/restart/reconcile).
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { ClassicLevel } from "classic-level";
import { mkdtemp, mkdir, rm, readFile, readdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { Server } from "node:http";
import { loadSyncState, saveSyncState, appendActivity, defaultSyncState, MAX_ACTIVITY, type SyncState } from "../src/sync-state.js";
import { SYNC_STATE_SCHEMA_VERSION } from "../src/schema-version.js";
import { startServer } from "../src/server.js";
let dir: string;
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "e4-1-")); await mkdir(join(dir, "refined"), { recursive: true }); });
afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
describe("E4.1 SyncState load/save", () => {
it("load creates defaults if absent (autoSyncOn=false, mode=PREP) + writes the file", async () => {
const { state, freshened } = await loadSyncState(dir, "/vault/refined");
expect(freshened).toBe(false);
expect(state.autoSyncOn).toBe(false);
expect(state.mode).toBe("PREP");
expect(state.syncStateSchemaVersion).toBe(SYNC_STATE_SCHEMA_VERSION);
expect(state.activity).toEqual([]);
expect(state.conflict).toBeNull();
expect(existsSync(join(dir, "sync-state.json"))).toBe(true);
});
it("save is atomic (tmp+rename — no .tmp left after)", async () => {
const { state } = await loadSyncState(dir, "/vault/refined");
state.autoSyncOn = true;
await saveSyncState(dir, state);
const files = await readdir(dir);
expect(files).toContain("sync-state.json");
expect(files.some((f) => f.endsWith(".tmp"))).toBe(false);
const reloaded = JSON.parse(await readFile(join(dir, "sync-state.json"), "utf8")) as SyncState;
expect(reloaded.autoSyncOn).toBe(true);
});
it("schema mismatch → backs up the old file + writes fresh defaults + an error event", async () => {
// Write a stale-version file.
const stale = { ...defaultSyncState("/vault/refined"), syncStateSchemaVersion: "sync-state/v0" };
await saveSyncState(dir, stale);
const { state, freshened } = await loadSyncState(dir, "/vault/refined");
expect(freshened).toBe(true);
expect(state.syncStateSchemaVersion).toBe(SYNC_STATE_SCHEMA_VERSION);
expect(state.autoSyncOn).toBe(false); // fresh defaults
expect(state.activity.length).toBe(1);
expect(state.activity[0].kind).toBe("error");
expect(state.activity[0].message).toMatch(/schema reset/);
// The old file was backed up (.bak-<stamp>).
const files = await readdir(dir);
expect(files.some((f) => f.startsWith("sync-state.json.bak-"))).toBe(true);
});
it("restart survival: autoSyncOn + activity preserved across reload", async () => {
const { state } = await loadSyncState(dir, "/vault/refined");
state.autoSyncOn = true;
state.lastSyncAt = "2026-06-23T01:00:00.000Z";
for (let i = 0; i < 17; i++) state.activity.push({ time: `2026-06-23T0:${i}:00.000Z`, kind: "push", name: `N${i}`, status: "pushed", message: `m${i}` });
await saveSyncState(dir, state);
// Reload — the persisted state is restored.
const { state: reloaded } = await loadSyncState(dir, "/vault/refined");
expect(reloaded.autoSyncOn).toBe(true);
expect(reloaded.lastSyncAt).toBe("2026-06-23T01:00:00.000Z");
expect(reloaded.activity.length).toBe(17);
});
it("activity is trimmed to MAX_ACTIVITY on append", async () => {
const { state } = await loadSyncState(dir, "/vault/refined");
for (let i = 0; i < MAX_ACTIVITY + 50; i++) {
await appendActivity(dir, state, { time: `t${i}`, kind: "push", name: `N${i}`, status: "pushed", message: `m${i}` });
}
expect(state.activity.length).toBe(MAX_ACTIVITY);
const reloaded = JSON.parse(await readFile(join(dir, "sync-state.json"), "utf8")) as SyncState;
expect(reloaded.activity.length).toBe(MAX_ACTIVITY);
});
it("conflict field is forced to null by E4 (reserved for E3)", async () => {
// Even if a file has a non-null conflict, E4 forces it null on load.
const { state } = await loadSyncState(dir, "/vault/refined");
(state as unknown as { conflict: unknown }).conflict = { foo: "bar" };
await saveSyncState(dir, state);
const { state: reloaded } = await loadSyncState(dir, "/vault/refined");
expect(reloaded.conflict).toBeNull();
});
});
describe("E4.1 boot reconcile (startServer restores autoSyncOn)", () => {
async function bootWithState(state: Partial<SyncState>, opts: { relay?: boolean; mode?: "dev" | "apply" } = {}): Promise<{ server: Server; enabledAfter: boolean; stateAfter: SyncState }> {
// Pre-write sync-state.json in outDir.
const outDir = join(dir, "out");
await mkdir(outDir, { recursive: true });
const full = { ...defaultSyncState(join(dir, "refined")), ...state } as SyncState;
await saveSyncState(outDir, full);
// Create an empty journal LevelDB.
const jdb = new ClassicLevel<string, string>(join(dir, "journal"));
await jdb.open(); await jdb.close();
const { server, state: srvState } = await startServer({
journal: join(dir, "journal"), refinedDir: join(dir, "refined"), ccDir: join(dir, "cc"),
outDir, mode: opts.mode ?? "apply", port: 0, host: "127.0.0.1",
relayCfg: opts.relay ? { url: "http://relay.test", apiKey: "k", clientId: "c" } : undefined,
});
const enabledAfter = srvState.autosync.enabled;
const stateAfter = JSON.parse(await readFile(join(outDir, "sync-state.json"), "utf8")) as SyncState;
// Clean up the watcher (auto-sync may be ON).
srvState.autosync.stop();
await new Promise<void>((r) => server.close(() => r()));
return { server, enabledAfter, stateAfter };
}
it("autoSyncOn=true + apply mode + relay → controller.enabled restored to true", async () => {
const { enabledAfter } = await bootWithState({ autoSyncOn: true }, { relay: true, mode: "apply" });
expect(enabledAfter).toBe(true);
});
it("autoSyncOn=true + no relay → autoSyncOn flipped to false + error event", async () => {
const { enabledAfter, stateAfter } = await bootWithState({ autoSyncOn: true }, { relay: false, mode: "apply" });
expect(enabledAfter).toBe(false); // start() threw (no relay) → flipped off
expect(stateAfter.autoSyncOn).toBe(false);
expect(stateAfter.activity.some((e) => e.message.includes("auto-sync could not resume"))).toBe(true);
});
it("autoSyncOn=true + dev mode (no apply) → autoSyncOn flipped to false (apply-mode gate)", async () => {
const { enabledAfter, stateAfter } = await bootWithState({ autoSyncOn: true }, { relay: true, mode: "dev" });
expect(enabledAfter).toBe(false); // start() threw (dev mode) → flipped off
expect(stateAfter.autoSyncOn).toBe(false);
});
it("autoSyncOn=false (fresh install) → stays off", async () => {
const { enabledAfter } = await bootWithState({ autoSyncOn: false }, { relay: true, mode: "apply" });
expect(enabledAfter).toBe(false);
});
});