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>
136 lines
7.0 KiB
TypeScript
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);
|
|
});
|
|
}); |