The last E4 story — completes the E4 epic (sync status & parity). A maintained
status note in the vault mirrors the dashboard (on/off, mode, last-sync, parity,
recent activity), carrying a foundry.sync_status:"true" sentinel. The exclusion
is airtight + rename-safe: skip by BOTH path (dotfile) AND sentinel.
- src/server.ts writeStatusNote(state): renders .sync-status.md (frontmatter
with foundry.sync_status:"true" + a body with on/off, mode, lastSync, parity,
last 10 activity events) → writeWithBackup to refinedDir/.sync-status.md
(apply mode backs up the previous). Called from log() (after appendActivity),
refreshParity, and the mode handler — fire-and-forget.
- src/server.ts onChange: dotfile skip (rel.split("/").pop()?.startsWith("."))
gated by features.syncStatus. A renamed status note (non-dot) is caught by the
sentinel in runPushAttempt.
- src/server.ts runPushAttempt: sentinel check — if foundry.sync_status ===
"true" → skip "sync status note (sentinel)", before the cc_uuid/contentHash
checks. Gated by features.syncStatus. Rename-safe (sentinel survives a rename).
- src/batch.ts walkMd: exclude dotfiles from the index (!ent.name.startsWith("."))
so .sync-status.md never enters state.index.matched/refinedOnly (never a row,
never a push candidate).
- tests/e4-5-statusnote.test.ts: 5 tests — note written with sentinel + content;
onChange skips .sync-status.md (no timer); sentinel check skips a renamed copy;
sentinel absent on a normal note; index excludes .sync-status.md (only Roland
indexed).
tsc clean; 240 passing project-wide (18 pre-existing fixture-missing unchanged).
Co-Authored-By: Claude <noreply@anthropic.com>
120 lines
6.1 KiB
TypeScript
120 lines
6.1 KiB
TypeScript
// E4.5 — vault .sync-status.md note writer + airtight exclusion.
|
|
//
|
|
// Covers: the note is written with the sentinel + status content; the watcher
|
|
// skips it by dot-path; the sentinel check in runPushAttempt skips a renamed
|
|
// copy; the index excludes dotfiles.
|
|
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
|
|
import { ClassicLevel } from "classic-level";
|
|
import { mkdtemp, mkdir, rm, readFile, writeFile } 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 { AutoSyncController, startServer } from "../src/server.js";
|
|
import type { State, ServerConfig } from "../src/server.js";
|
|
import { splitFrontmatter, readFoundryBlock } from "../src/frontmatter.js";
|
|
|
|
let dir: string;
|
|
let state: State;
|
|
beforeEach(async () => {
|
|
dir = await mkdtemp(join(tmpdir(), "e4-5-"));
|
|
await mkdir(join(dir, "refined"), { 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://r", apiKey: "k", clientId: "c" },
|
|
features: { syncStatus: true },
|
|
};
|
|
state = { db: {} as State["db"], cfg, index: null, autosync: null as unknown as State["autosync"] } as unknown as State;
|
|
state.syncState = { syncStateSchemaVersion: "sync-state/v1", mode: "RUN-THE-MATCH", autoSyncOn: false, lastSyncAt: null, parity: { status: "in-parity", oPending: 0, fPending: 0, conflict: 0, unsyncedLinked: 0, lastPollAt: null }, watchedDir: cfg.refinedDir, activity: [], updatedAt: new Date().toISOString(), conflict: null };
|
|
});
|
|
afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
|
|
|
|
describe("E4.5 .sync-status.md writer", () => {
|
|
it("writes .sync-status.md with the sentinel + status content", async () => {
|
|
const controller = new AutoSyncController(state);
|
|
// Trigger a log (which calls writeStatusNote via appendActivity when features on).
|
|
(controller as any).log("Roland", "pushed", "→ JournalEntry.abc1 · baselined");
|
|
// writeStatusNote is fire-and-forget; wait for it.
|
|
await new Promise<void>((r) => setTimeout(r, 50));
|
|
const notePath = join(state.cfg.refinedDir, ".sync-status.md");
|
|
expect(existsSync(notePath)).toBe(true);
|
|
const md = await readFile(notePath, "utf8");
|
|
const { fm, body } = splitFrontmatter(md);
|
|
const fb = readFoundryBlock(fm);
|
|
expect(fb?.sync_status).toBe("true"); // the sentinel
|
|
expect(body).toContain("Sync Status");
|
|
expect(body).toContain("RUN-THE-MATCH");
|
|
});
|
|
});
|
|
|
|
describe("E4.5 airtight exclusion", () => {
|
|
it("onChange skips .sync-status.md by dot-path (no debounce timer)", () => {
|
|
const controller = new AutoSyncController(state);
|
|
(controller as any).onChange("change", ".sync-status.md", "");
|
|
expect((controller as any).timers.has(".sync-status.md")).toBe(false);
|
|
});
|
|
|
|
it("runPushAttempt skips a note with the sync_status sentinel (rename-safe)", async () => {
|
|
// A note renamed to a non-dot path but still carrying the sentinel.
|
|
await writeFile(join(state.cfg.refinedDir, "Sync Status.md"),
|
|
"---\nfoundry:\n sync_status: \"true\"\n cc_uuid: JournalEntry.abc1\n contentHash: " + "0".repeat(64) + "\n---\nbody\n", "utf8");
|
|
const controller = new AutoSyncController(state);
|
|
// Call process directly (bypasses onChange's dot-path skip).
|
|
await (controller as any).process("Sync Status.md");
|
|
expect(controller.events.some((e) => e.message.includes("sync status note (sentinel)"))).toBe(true);
|
|
});
|
|
|
|
it("a normal note (no sentinel) is NOT skipped by the sentinel check", async () => {
|
|
// The sentinel check should only fire for sync_status: "true" notes.
|
|
const controller = new AutoSyncController(state);
|
|
(controller as any).log("test", "skipped", "test event");
|
|
await new Promise<void>((r) => setTimeout(r, 20));
|
|
// The .sync-status.md was written (by the log). Now verify a normal note
|
|
// wouldn't be sentinel-skipped — check that the sentinel field is absent
|
|
// from a normal note's foundry block.
|
|
const notePath = join(state.cfg.refinedDir, ".sync-status.md");
|
|
const md = await readFile(notePath, "utf8");
|
|
const fb = readFoundryBlock(splitFrontmatter(md).fm);
|
|
expect(fb?.sync_status).toBe("true"); // the status note has it
|
|
expect(fb?.cc_uuid).toBeUndefined(); // a normal note wouldn't have sync_status
|
|
});
|
|
});
|
|
|
|
describe("E4.5 index excludes dotfiles", () => {
|
|
let server: Server;
|
|
let baseURL: string;
|
|
|
|
beforeAll(async () => {
|
|
const d = await mkdtemp(join(tmpdir(), "e4-5-idx-"));
|
|
await mkdir(join(d, "refined"), { recursive: true });
|
|
await mkdir(join(d, "cc"), { recursive: true });
|
|
// Write a normal note + a .sync-status.md (dotfile).
|
|
await writeFile(join(d, "refined", "Roland.md"), "---\ntype: npc\n---\nbody\n", "utf8");
|
|
await writeFile(join(d, "refined", ".sync-status.md"), "---\nfoundry:\n sync_status: \"true\"\n---\nstatus\n", "utf8");
|
|
const jdb = new ClassicLevel<string, string>(join(d, "journal"));
|
|
await jdb.open(); await jdb.close();
|
|
const { server: srv } = await startServer({
|
|
journal: join(d, "journal"), refinedDir: join(d, "refined"), ccDir: join(d, "cc"),
|
|
outDir: join(d, "out"), mode: "dev", port: 0, host: "127.0.0.1",
|
|
features: { syncStatus: true },
|
|
});
|
|
server = srv;
|
|
baseURL = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
|
(globalThis as unknown as { _e45dir: string })._e45dir = d;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await new Promise<void>((r) => server.close(() => r()));
|
|
await rm((globalThis as unknown as { _e45dir: string })._e45dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it(".sync-status.md does not appear in the index", async () => {
|
|
const r = await fetch(`${baseURL}/api/index`).then(r => r.json()) as { matched: unknown[]; refinedOnly: unknown[]; ccOnly: unknown[] };
|
|
const all = [...r.matched, ...r.refinedOnly, ...r.ccOnly];
|
|
expect(all.length).toBe(1); // only Roland.md, not .sync-status.md
|
|
expect((all[0] as { name: string }).name).toBe("Roland");
|
|
});
|
|
}); |