feat(E0): shared sync primitives — per-uuid lock, ccHash, schema versions

Foundation for the live-sync epic plan (E0). Frozen-contract primitives that
gate E1a/E1b/E2, grounded in the real code shapes.

- synclock.ts: per-UUID bidirectional lock (push/pull/baseline) replacing the
  per-relPath inflight Set. acquire/release/withLock/isHeld/heldOps, skip|queue
  policy, reentrant-NO, relPath: fallback. Queue path retries until acquired or
  throws LockAcquireTimeout (no silent drop); fairness — acquire defers to
  queued waiters. SYNC_LOCK_ENABLED flag (default on; off = byte-identical
  legacy inflight).
- cchash.ts: ccHash(entry, inverse) + ccHashFromGet, frozen CC_HASH_CONTRACT.
  Contract corrected from epic prose: the body spans data.description +
  data.notes (CcData object, not a string), with ## Secrets re-inserted.
  Throws CcHashError on missing description (not coerced to ""). Three E1a-gate
  assumptions documented (sidebar exclusion, Secrets heading/case, section order).
- schema-version.ts: FLAGS_SCHEMA_VERSION / SYNC_STATE_SCHEMA_VERSION branded
  constants + parseSchemaVersion (branded discriminated union).
- server.ts: AutoSyncController refactored to use the lock (single read; uuid
  resolved from the same read used to gate — no stale-uuid gap; uuidCache powers
  the debounce pre-check without a file read). AutoSyncController/State exported,
  lock public for E2's poll path.
- 43 tests across 4 files (synclock, cchash, schema-version, server-lock
  integration incl. cross-direction block + flag-off byte-identical). tsc clean.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-22 22:01:57 +00:00
parent 37dceb9ac5
commit 8fd56a22d9
8 changed files with 1293 additions and 36 deletions

195
src/cchash.ts Normal file
View File

@@ -0,0 +1,195 @@
// E0.2 — ccHash compute wrapper with a frozen input contract.
//
// ccHash is a Foundry-side-identity hash: given a relay `/get` response (the
// full JournalEntry), derive a hash comparable to the Obsidian-side
// `foundry.ccHash` baseline so E1b's divergence guard (O→F) and E2's deep-pull
// compare (F→O) can detect "Foundry's stored content actually changed" without
// each re-deriving the hash input contract and without an extra `/get`.
//
// === CONTRACT CORRECTION (grounded in the real code, 2026-06-22) ===
// The epics' prose said the curated body HTML lives at
// `flags["campaign-codex"].data` (a string). The real shape (src/types.ts
// `CcData`, src/toFoundry.ts:185-190 `buildFoundryJson`) is that `data` is an
// OBJECT whose body content spans TWO HTML fields:
// - `data.description` — the two-column body (left = tagline/preface/
// sections as HTML; right = sidebar boxes from frontmatter).
// - `data.notes` — the `## Secrets` section body HTML ("" when absent).
// The Obsidian-side `contentHash(body)` (src/server.ts `baselineNote`) hashes
// the full refined body (tagline + preface + sections + `## Secrets`). For
// ccHash to be comparable it MUST capture both fields — hashing only
// `description` would make a Foundry-side edit to `## Secrets` invisible to the
// divergence guard, a real clobber hole.
//
// === THREE THINGS THE CONTRACT ASSUMES (E1a MUST VALIDATE) ===
// The forward transform (src/toFoundry.ts:153-179) does three things the frozen
// hash contract has to reverse, and each is a potential GO/NO-GO lever for the
// E1a spike. They are documented here so E1a knows what it must hold:
// 1. SIDEBAR EXCLUSION. `data.description`'s RIGHT column is sidebar
// (race/faction/region) sourced from FRONTMATTER, not the body. The
// Obsidian `contentHash(body)` excludes frontmatter. So the inverse of
// `data.description` must return ONLY the left-column body markdown
// (sidebar dropped). If E1a's `htmlToMarkdown` returns the full
// description incl. sidebar, ccHash ≠ contentHash(body) → NO-GO.
// 2. `## Secrets` RE-INSERTION. The forward transform STRIPS the `## Secrets`
// heading (src/toFoundry.ts:160 skips it from `description`) and stores
// only `secrets.body` in `data.notes` (line 179). ccHash therefore
// re-inserts `## Secrets\n\n` before `inverse(data.notes)` (when notes is
// non-empty). This assumes the project convention is EXACTLY `## Secrets`
// (case-sensitive after `canonicalize`, which does not normalize case).
// If the vault uses `## SECRETS` or `## secrets`, the round-trip breaks
// → NO-GO → E1b-alt (canonicalize Foundry HTML directly).
// 3. SECTION ORDER. The forward transform MOVES `## Secrets` to `data.notes`
// and concatenates the remaining sections in order. ccHash rejoins them
// as `inverse(description) + "\n\n## Secrets\n\n" + inverse(notes)` —
// i.e. it assumes `## Secrets` is the LAST section. If a note has sections
// AFTER `## Secrets`, the reconstruction reorders them vs. the raw body
// → NO-GO. (Project convention: `## Secrets` is last.)
// These three assumptions are the spike's job to confirm. E0.2 freezes the
// contract that encodes them; E1a's round-trip suite proves or refutes them.
//
// === DIRECTION-INVARIANCE ===
// `name` and `folder` are ALWAYS sourced from the JournalEntry
// (`liveEntry.name`, `liveEntry.folder`), NEVER from the Obsidian filename or
// vault-relative folder. A vault rename changes the filename but NOT
// `foundry.ccHash` until a push updates the live entry's `name` — correct,
// because a rename is a name-field update routed through `pushNote`'s
// `updatedName` path, not a content divergence (see E3.5).
//
// NOTE on `folder` naming: the contract uses `folder` = `liveEntry.folder`, a
// Foundry FOLDER ID (e.g. `Folder.gideon`). This is DISTINCT from the Obsidian
// `foundry.folder_path` field (a cc-type-derived path via
// `folderPathFromCcType`). Do not conflate them — the hash uses the Foundry
// folder ID, not the Obsidian path. Both ccHash sides use `liveEntry.folder`,
// so direction-invariance holds; a Foundry folder MOVE changes `liveEntry.folder`
// → ccHash changes → detected as F-changed (correct).
//
// This module does NOT depend on E1a's real `htmlToMarkdown` (a stub inverse is
// fine for tests), does NOT depend on E1b's `flagsSchemaVersion` migration, and
// does NOT wire itself into `AutoSyncController.process` or
// `baselineFoundryBlock` — that wiring is E1b's job. E0.2 only delivers the
// frozen primitive + tests.
import type { JournalEntry, CcData } from "./types.js";
import type { RelayClient } from "./relay/client.js";
import { contentHash, canonicalize } from "./normalize.js";
/**
* The inverse transform seam: Foundry HTML → refined markdown. Typed as an
* EXPLICIT parameter (not a module-level import) so E0.2 ships with a tested
* stub inverse and E1a swaps in the real linkedom-based `htmlToMarkdown`
* (src/fromFoundry.ts, per E1a.1) without touching `ccHash`. This is the
* contract boundary, frozen on landing.
*
* Applied to `data.description` AND `data.notes` separately. Per the contract
* assumptions above, `inverse(data.description)` must return ONLY the
* left-column body markdown (sidebar excluded); `inverse(data.notes)` returns
* the secrets BODY markdown (the `## Secrets` heading is re-inserted by ccHash,
* not by the inverse, so the same generic html→md function works for both).
*/
export type HtmlToMarkdown = (html: string) => string;
/**
* The frozen hash input contract, as a canonical string template. Pinned by a
* unit test (exact bytes) AND by a re-derivation test (the implementation is
* asserted to compute exactly this) so any drift — to the constant OR to the
* implementation — is a deliberate, reviewable change. This is the frozen
* contract E1b and E2 code against.
*
* `inverse` is the `HtmlToMarkdown` seam; `data` is `flags["campaign-codex"].data`;
* `name` is `liveEntry.name`; `folder` is `liveEntry.folder ?? ""`. `canonicalize`
* (wikilinks + whitespace) is applied to the reconstructed body; the final
* `contentHash` canonicalizes the WHOLE string (body + name + folder), so
* `name`/`folder` whitespace drift from relay serialization is normalized too.
*/
export const CC_HASH_CONTRACT =
'contentHash(canonicalize(inverse(data.description) + (data.notes ? "\\n\\n## Secrets\\n\\n" + inverse(data.notes) : "")) + "\\n" + name + "\\n" + folder)';
/** Typed error so E1b's divergence guard can distinguish "no Foundry-side
* content yet" (treat as fresh/seed) from "content changed" / relay errors. */
export class CcHashError extends Error {
readonly kind = "CcHashError";
constructor(message: string) {
super(message);
this.name = "CcHashError";
}
}
/** Whether a value is a CcHashError (narrowing helper for consumers). */
export function isCcHashError(e: unknown): e is CcHashError {
return e instanceof CcHashError;
}
/** Extract and validate `flags["campaign-codex"].data.description`. Throws a
* typed CcHashError when the flag, its data, OR its `description` field is
* absent/non-string — `description` is the required body field, and silently
* coercing a malformed entry to "" would create a stable-but-wrong baseline
* (the strictness the typed error exists to provide). `notes` is optional and
* defaults to "" at the call site. */
function extractCampaignCodexDescription(entry: JournalEntry): { data: CcData; description: string } {
const cc = entry.flags?.["campaign-codex"];
if (!cc || !cc.data) {
throw new CcHashError('missing campaign-codex data');
}
if (typeof cc.data.description !== "string") {
throw new CcHashError('missing campaign-codex data (description)');
}
return { data: cc.data, description: cc.data.description };
}
/**
* Compute the Foundry-side ccHash for a live `/get` entry, given an
* `HtmlToMarkdown` inverse. See `CC_HASH_CONTRACT` for the frozen input and the
* three assumptions (sidebar exclusion, `## Secrets` re-insertion, section
* order) E1a must validate.
*
* Throws `CcHashError` when `flags["campaign-codex"].data` (or its
* `description`) is absent — so callers can distinguish "no Foundry-side
* content yet" from a real content change. Relay connectivity failures are NOT
* wrapped here (see `ccHashFromGet`).
*/
export function ccHash(liveEntry: JournalEntry, inverse: HtmlToMarkdown): string {
const { data, description } = extractCampaignCodexDescription(liveEntry);
const notes = typeof data.notes === "string" ? data.notes : "";
const bodyMd = notes
? `${inverse(description)}\n\n## Secrets\n\n${inverse(notes)}`
: inverse(description);
const name = liveEntry.name ?? "";
const folder = liveEntry.folder ?? "";
const text = `${canonicalize(bodyMd)}\n${name}\n${folder}`;
return contentHash(text);
}
/** Result of `ccHashFromGet`: both the hash AND the live entry. */
export interface CcHashFromGetResult {
hash: string;
entry: JournalEntry;
}
/**
* Fetch a live entry via `relay.getEntry(uuid)` AND derive its ccHash in one
* call — for callers that do NOT already hold the entry (e.g. E2's deep-poll,
* which fetches to compare). Returns both so the caller can use the entry for
* the pull conversion without a second round-trip.
*
* Callers that ALREADY have the entry (notably `pushNote`, which fetches via
* `relay.getEntry` at src/push.ts:142) must NOT use this helper — that would
* make a SECOND `/get` and violate the FR-1.4 "no extra /get" ground rule.
* They should call `ccHash(entry, inverse)` directly on the entry they already
* hold. (This helper is for the fetch-and-hash case; `ccHash` is the reuse
* case.)
*
* Relay connectivity failures (the relay client's domain — `404 "Invalid client
* ID"`, `404 "No connected Foundry clients found"`, timeouts, network errors)
* are surfaced UNCHANGED: this helper does NOT wrap them as `CcHashError`. Only
* a present-but-malformed entry (missing `flags["campaign-codex"].data` or its
* `description`) throws `CcHashError`, after the relay call has succeeded.
*/
export async function ccHashFromGet(
relay: RelayClient,
uuid: string,
inverse: HtmlToMarkdown,
): Promise<CcHashFromGetResult> {
const entry = await relay.getEntry(uuid); // throws relay errors unchanged
const hash = ccHash(entry, inverse); // throws CcHashError on malformed entry
return { hash, entry };
}

60
src/schema-version.ts Normal file
View File

@@ -0,0 +1,60 @@
// E0.3 — Schema-version naming constants.
//
// The system has two unrelated schema-version concepts that must never collide
// on a single `schemaVersion` field:
// 1. Foundry-side flags shape — stored at `flags["campaign-codex"].schemaVersion`
// on the live JournalEntry returned by `relay.getEntry`. Owned by E1b
// (the `flagsSchemaVersion` migration).
// 2. Local sync-state shape — the on-disk `sync-state.json` record of last-
// synced hashes / timestamps / parity. Owned by E4 (the
// `syncStateSchemaVersion` migration).
//
// This module fixes the names + ownership contract up front so E1b and E4 cannot
// drift. It defines NO migration logic, NO on-disk shapes, and touches no
// call-site — it is the frozen naming reservation E1b/E4 import from.
/** Branded nominal type: a `schemaVersion` belonging to the Foundry flags shape.
* Distinct brand property name from SyncStateSchemaVersion so the two are not
* cross-assignable (compile-time guard). */
export type FlagsSchemaVersion = string & { readonly __flagsBrand: true };
/** Branded nominal type: a `schemaVersion` belonging to the local sync-state file.
* Distinct brand property name from FlagsSchemaVersion so the two are not
* cross-assignable (compile-time guard). */
export type SyncStateSchemaVersion = string & { readonly __syncBrand: true };
/**
* Current Foundry `flags["campaign-codex"]` schema version. Owner: E1b.
* Storage: `flags["campaign-codex"].schemaVersion` on the live entry.
* Migration policy: bump + migrate-on-read in E1b; E4 must NOT touch it.
*/
export const FLAGS_SCHEMA_VERSION = "flags-campaign-codex/v1" as FlagsSchemaVersion;
/**
* Current local sync-state file schema version. Owner: E4.
* Storage: the top-level `schemaVersion` field of `sync-state.json`.
* Migration policy: bump + migrate-on-read in E4; E1b must NOT touch it.
*/
export const SYNC_STATE_SCHEMA_VERSION = "sync-state/v1" as SyncStateSchemaVersion;
/** Discriminated parse result: which schema a raw `schemaVersion` string
* belongs to, with the `version` branded so a parsed flags-version cannot be
* assigned to a sync-state slot (and vice versa) — the brand protection
* extends to values loaded from disk, not just the two constants. */
export type ParsedSchemaVersion =
| { kind: "flags"; version: FlagsSchemaVersion }
| { kind: "sync-state"; version: SyncStateSchemaVersion };
/**
* Branch on the prefix to determine which schema an arbitrary `schemaVersion`
* field belongs to. Returns `null` for unknown prefixes so a reader handed an
* unversioned/foreign string can fall back to a default rather than guessing.
* The returned `version` is branded to its kind, so callers cannot accidentally
* feed a parsed flags-version into a sync-state-typed slot.
*/
export function parseSchemaVersion(raw: string): ParsedSchemaVersion | null {
if (typeof raw !== "string" || raw === "") return null;
if (raw.startsWith("flags-")) return { kind: "flags", version: raw as FlagsSchemaVersion };
if (raw.startsWith("sync-")) return { kind: "sync-state", version: raw as SyncStateSchemaVersion };
return null;
}

View File

@@ -20,6 +20,7 @@ import { pushNote } from "./push.js";
import { nameUuidIndexFromEntries, saveNameUuidIndex, loadNameUuidIndex, MapNameResolver, type NameResolver } from "./resolver.js";
import { splitFrontmatter, readFoundryBlock } from "./frontmatter.js";
import { contentHash } from "./normalize.js";
import { SyncLock, relPathLockKey } from "./synclock.js";
import { backupStamp } from "./write.js";
import type { RelayConfig, FoundryHostConfig } from "./config.js";
import type { Mode } from "./types.js";
@@ -27,6 +28,11 @@ import type { Mode } from "./types.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const DASHBOARD_PATH = join(__dirname, "dashboard.html");
// E0.1: per-UUID bidirectional sync lock. Default ON; set SYNC_LOCK_ENABLED=false
// to fall back to the legacy per-relPath `inflight` Set (byte-identical to pre-E0.1).
// When ON, `inflight` is not consulted at all — the two are mutually exclusive.
const SYNC_LOCK_ENABLED = process.env.SYNC_LOCK_ENABLED !== "false";
export interface ServerConfig {
journal: string;
refinedDir: string;
@@ -50,7 +56,7 @@ export interface ActionResult {
message: string;
}
interface State {
export interface State {
db: JournalDb;
cfg: ServerConfig;
index: IndexResult | null;
@@ -454,13 +460,27 @@ export interface AutoSyncEvent {
* in apply mode it lands in the real vault with a `.bak`. The push itself goes live via
* the relay regardless of mode.
*/
class AutoSyncController {
export class AutoSyncController {
enabled = false;
private recursive = false;
private root: FSWatcher | null = null;
private subs: { w: FSWatcher; prefix: string }[] = [];
private timers = new Map<string, NodeJS.Timeout>();
// Legacy per-relPath in-flight guard — SOLE guard when SYNC_LOCK_ENABLED=false
// (byte-identical to pre-E0.1). NOT consulted when the per-uuid lock is on
// (the two are mutually exclusive — no doubled collision surface).
private inflight = new Set<string>();
// E0.1: per-uuid bidirectional lock (push/pull/baseline). SOLE guard when
// SYNC_LOCK_ENABLED=true. Public because the F→O poll path (E2) shares this
// one lock instance — it gates uuid+resource, not direction.
readonly lock = new SyncLock();
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
// save WITHOUT a file read. Stale entries are safe: a relink updates the
// entry on the next `process`, and a stale lookup only misses a pre-check
// (never wrongly skips) — the authoritative gate is the per-uuid lock.
private readonly uuidCache = new Map<string, string>();
private queue: string[] = [];
private running = 0;
private readonly concurrency = 3;
@@ -565,12 +585,32 @@ class AutoSyncController {
if (prev) clearTimeout(prev);
this.timers.set(rel, setTimeout(() => {
this.timers.delete(rel);
if (this.inflight.has(rel)) return;
this.queue.push(rel);
this.drain();
void this.enqueueAfterDebounce(rel);
}, this.debounceMs));
}
/** Post-debounce enqueue: fast-skip if an op for this rel's uuid is already
* in flight, using the `uuidCache` (no file read). The authoritative gate
* is the per-uuid lock in `process`; this is just the redundant-save
* short-circuit. When the lock flag is off, this is the legacy per-relPath
* `inflight` check (byte-identical to pre-E0.1). */
private async enqueueAfterDebounce(rel: string): Promise<void> {
const name = basename(rel, extname(rel));
try {
if (this.syncLockEnabled) {
const cached = this.uuidCache.get(rel);
if (cached && this.lock.isHeld(cached)) { this.log(name, "skipped", "lock busy — skipped"); return; }
this.queue.push(rel);
} else {
if (this.inflight.has(rel)) return;
this.queue.push(rel);
}
this.drain();
} catch (e) {
this.log(name, "error", (e as Error).message);
}
}
private drain(): void {
while (this.running < this.concurrency && this.queue.length) {
const rel = this.queue.shift()!;
@@ -579,42 +619,86 @@ class AutoSyncController {
}
}
private async process(relPath: string): Promise<void> {
this.inflight.add(relPath);
const name = basename(relPath, extname(relPath));
try {
const abs = await resolveRefined(this.state, relPath);
let md: string;
try { md = await readFile(abs, "utf8"); } catch { this.log(name, "skipped", "file removed"); return; }
const { fm, body } = splitFrontmatter(md);
const fb = readFoundryBlock(fm);
if (!fb?.cc_uuid) { this.log(name, "skipped", "not linked — no foundry.cc_uuid (seed/link first)"); return; }
if (!fb.contentHash) { this.log(name, "skipped", "not seeded — no foundry.contentHash baseline"); return; }
const bodyHash = contentHash(body);
if (bodyHash === fb.contentHash) { this.log(name, "skipped", "unchanged since last push"); return; }
/** Resolve the lock key for a save: `foundry.cc_uuid` if linked, else the
* `relPath:` pseudo-uuid fallback (so unlinked files still get per-file
* mutual exclusion). */
private lockKeyFor(fb: Record<string, string> | undefined, relPath: string): string {
return fb?.cc_uuid ?? relPathLockKey(relPath);
}
const relay = relayClient(this.state);
const outcome = await pushNote({
notePath: abs,
noteName: name,
outDir: this.state.cfg.outDir,
relay,
foundryDataDir: this.state.cfg.foundryCfg?.dataDir ?? "",
world: this.state.cfg.foundryCfg?.world ?? "",
dryRun: false, // auto-sync always applies — the whole point is hands-off live push
log: () => {},
});
// Baseline foundry.contentHash to the new body hash so a re-save with no further
// edit (and the watcher's own baseline write) is a no-op. Idempotency lives here,
// not in pushNote (which always PUTs).
const baselined = await baselineNote(this.state, relPath, abs);
this.log(name, "pushed", `${outcome.ccUuid}${outcome.updatedName ? ` ("${outcome.updatedName}")` : ""}${baselined ? " · baselined" : ""}`);
private async process(relPath: string): Promise<void> {
const name = basename(relPath, extname(relPath));
const lockEnabled = this.syncLockEnabled;
// Flag-off path: byte-identical to pre-E0.1 (per-relPath inflight dance).
if (!lockEnabled) {
this.inflight.add(relPath);
try {
const abs = await resolveRefined(this.state, relPath);
let md: string;
try { md = await readFile(abs, "utf8"); } catch { this.log(name, "skipped", "file removed"); return; }
const { fm, body } = splitFrontmatter(md);
await this.runPushBody(relPath, name, abs, body, readFoundryBlock(fm));
} catch (e) {
this.log(name, "error", (e as Error).message);
} finally {
this.inflight.delete(relPath);
}
return;
}
// Flag-on path: ONE read (resolveRefined + readFile), resolve the uuid from
// the SAME read used to gate the lock (so the lock key and the content
// under it cannot disagree — no stale-uuid gap), cache the uuid for the
// debounce pre-check, then run the push under the per-uuid lock using the
// already-read content (no second read). withLock(skip) acquires
// synchronously right after the read, so the captured content is current at
// acquire time.
const abs = await resolveRefined(this.state, relPath);
let md: string;
try { md = await readFile(abs, "utf8"); } catch { this.log(name, "skipped", "file removed"); return; }
const { fm, body } = splitFrontmatter(md);
const fb = readFoundryBlock(fm);
const uuid = this.lockKeyFor(fb, relPath);
this.uuidCache.set(relPath, uuid);
try {
const ran = await this.lock.withLock<boolean>(uuid, "push", () => this.runPushBody(relPath, name, abs, body, fb), { policy: "skip" });
// undefined = lock busy (skip policy, uuid held at acquire); a ran-and-
// skipped result (file removed / unlinked / unseeded / unchanged) is
// false and already logged its own reason inside runPushBody.
if (ran === undefined) this.log(name, "skipped", "lock busy — skipped");
} catch (e) {
this.log(name, "error", (e as Error).message);
} finally {
this.inflight.delete(relPath);
}
}
/** The check + push + baseline body, run under the lock (flag-on) or inline
* (flag-off). Returns true on push, false on an internal skip (reason
* already logged). Throws on a relay/push error (caller logs "error"). */
private async runPushBody(relPath: string, name: string, abs: string, body: string, fb: Record<string, string> | undefined): Promise<boolean> {
if (!fb?.cc_uuid) { this.log(name, "skipped", "not linked — no foundry.cc_uuid (seed/link first)"); return false; }
if (!fb.contentHash) { this.log(name, "skipped", "not seeded — no foundry.contentHash baseline"); return false; }
const bodyHash = contentHash(body);
if (bodyHash === fb.contentHash) { this.log(name, "skipped", "unchanged since last push"); return false; }
const relay = relayClient(this.state);
const outcome = await pushNote({
notePath: abs,
noteName: name,
outDir: this.state.cfg.outDir,
relay,
foundryDataDir: this.state.cfg.foundryCfg?.dataDir ?? "",
world: this.state.cfg.foundryCfg?.world ?? "",
dryRun: false, // auto-sync always applies — the whole point is hands-off live push
log: () => {},
});
// Baseline foundry.contentHash to the new body hash so a re-save with no further
// edit (and the watcher's own baseline write) is a no-op. Idempotency lives here,
// not in pushNote (which always PUTs).
const baselined = await baselineNote(this.state, relPath, abs);
this.log(name, "pushed", `${outcome.ccUuid}${outcome.updatedName ? ` ("${outcome.updatedName}")` : ""}${baselined ? " · baselined" : ""}`);
return true;
}
}
export async function startServer(cfg: ServerConfig): Promise<{ server: Server; state: State }> {

235
src/synclock.ts Normal file
View File

@@ -0,0 +1,235 @@
// E0.1 — Per-UUID bidirectional sync lock.
//
// Replaces the per-relPath `inflight = new Set<string>()` in AutoSyncController
// with a single per-UUID lock that gates BOTH Obsidian→Foundry and
// Foundry→Obsidian operations on the same entry. The lock is keyed by Foundry
// uuid (the `foundry.cc_uuid` value, normalized to the `JournalEntry.<id>`
// form used by `relay.getEntry`) PLUS a resource tag (`"push" | "pull" |
// "baseline"`). The lock is per-uuid, NOT per-direction: while one direction is
// in flight for a uuid the other direction queues or skips, eliminating
// cross-direction clobber and TOCTOU races without two separate guard systems.
//
// Resolution of relPath→uuid happens OUTSIDE the lock (the watcher/debounce
// keys on relPath; the uuid is resolved by reading the note's `foundry.cc_uuid`
// frontmatter before `acquire` is called). For unlinked/un-keyed files a
// namespaced pseudo-uuid `"relPath:" + relPath` is used so the unlinked tail
// still gets per-file mutual exclusion (the F→O direction, which receives a
// uuid from the relay, never hits this fallback).
//
// Fairness: a queued waiter (policy "queue") is woken FIFO on release, and a
// fresh public `acquire` DEFERS when waiters are already queued for that uuid
// (returns `acquired:false, deferred:true`) so an opportunistic skip-policy
// caller (auto-sync) cannot jump the queue ahead of a waiting manual button.
// The woken waiter grabs via the internal acquire (no defer check). This is
// best-effort fairness for the lock's purpose (brief per-push holds); it is not
// a full fair mutex and does not need to be — auto-sync holds are short and
// skip-policy drops are idempotent (the save re-triggers).
//
// This primitive is a CONCURRENCY guard, NOT a self-write/re-entrancy guard.
// The baseline write fired by a successful push re-triggers the watcher; that
// self-write suppression is a separate mechanism (E1b.2). E0.1 owns only the
// cross-op exclusion contract. The lock surface is FROZEN on landing so E1b
// and E2 can code against it without re-coordination.
/** Resource tag: which kind of operation holds the lock for a uuid. */
export type LockOp = "push" | "pull" | "baseline";
/** What to do when a second op for an already-held uuid arrives. */
export type LockConflictPolicy = "skip" | "queue";
/** Result of a synchronous `acquire` check. */
export interface AcquireResult {
acquired: boolean;
/** The op currently holding the uuid, when `acquired` is false because it is held. */
heldOp?: LockOp;
/** True when the uuid is FREE but `acquire` deferred to queued waiters
* (fairness — see module doc). Distinct from `heldOp` so callers can tell
* "busy" from "yielding to a waiter". */
deferred?: boolean;
}
/** Options for `withLock`. */
export interface WithLockOptions {
/** Conflict policy when the uuid is already held (or deferred). Default `"skip"`. */
policy?: LockConflictPolicy;
/** Max wait in ms for the `"queue"` policy before throwing `LockAcquireTimeout`. Default 5000. */
maxWaitMs?: number;
}
/** Thrown when a `"queue"`-policy `withLock` cannot acquire within `maxWaitMs`.
* Loud by design — a queued (manual) op that cannot run is a user-visible
* failure, not a silent drop. */
export class LockAcquireTimeout extends Error {
readonly kind = "LockAcquireTimeout";
constructor(public readonly uuid: string, public readonly op: LockOp, public readonly maxWaitMs: number) {
super(`lock acquire timed out (${op} on ${uuid} after ${maxWaitMs}ms)`);
this.name = "LockAcquireTimeout";
}
}
interface Holder {
op: LockOp;
}
interface Waiter {
resolve: () => void;
}
/**
* Per-UUID bidirectional sync lock. One instance shared by the watcher (O→F)
* and the poll path (F→O) — the lock cares about uuid+resource, not direction.
*/
export class SyncLock {
private readonly held = new Map<string, Holder>();
private readonly waiters = new Map<string, Waiter[]>();
/**
* Synchronously attempt to acquire the lock for `(uuid, op)`.
* Returns `{ acquired: true }` when the uuid is free AND no waiters are
* queued for it. Returns `{ acquired: false, heldOp }` when the uuid is
* already held. Returns `{ acquired: false, deferred: true }` when the uuid
* is free but waiters are queued (fairness — defer to them).
*
* Reentrant-NO: a second `acquire` of the same uuid from inside a held
* `withLock` callback returns `{ acquired: false, heldOp }` (deadlock-safe).
*/
acquire(uuid: string, op: LockOp): AcquireResult {
const existing = this.held.get(uuid);
if (existing) return { acquired: false, heldOp: existing.op };
// Fairness: if waiters are queued, a fresh public acquire defers so the
// woken waiter (which grabs via acquireInternal) gets the slot.
if ((this.waiters.get(uuid)?.length ?? 0) > 0) return { acquired: false, deferred: true };
this.held.set(uuid, { op });
return { acquired: true };
}
/** Internal acquire that bypasses the defer-to-waiters fairness rule. Used
* by the woken queued waiter (already shifted out of the queue by wakeOne)
* so it can grab the slot it was promised. Returns true on success. */
private acquireInternal(uuid: string, op: LockOp): boolean {
if (this.held.has(uuid)) return false;
this.held.set(uuid, { op });
return true;
}
/**
* Release the lock for `(uuid, op)`. No-op (NOT a throw) if the uuid is not
* held by `op`, so error-path cleanup can't crash the watcher. Wakes one
* queued waiter (FIFO) if any.
*/
release(uuid: string, op: LockOp): void {
const h = this.held.get(uuid);
if (!h || h.op !== op) return; // not held by this op — no-op
this.held.delete(uuid);
this.wakeOne(uuid);
}
/** Whether the uuid is currently held by any op. */
isHeld(uuid: string): boolean {
return this.held.has(uuid);
}
/** Snapshot of currently-held uuid→op pairs, for diagnostics. */
heldOps(): Record<string, LockOp> {
const out: Record<string, LockOp> = {};
for (const [uuid, h] of this.held) out[uuid] = h.op;
return out;
}
/**
* Acquire → await `fn` → release in a `finally`.
*
* - `"skip"` policy (default): if the uuid is held OR deferred to waiters,
* returns `undefined` immediately without running `fn` (auto-sync semantics
* for redundant saves — the save re-triggers, so dropping is idempotent).
* - `"queue"` policy: waits for the holder (bounded by `maxWaitMs`), retrying
* on every release until it acquires. Throws `LockAcquireTimeout` if the
* wait elapses without acquiring (loud — a manual op that cannot run is a
* user-visible failure, not a silent drop).
*
* On `fn` rejection the lock is still released (release-on-throw) so the next
* acquire succeeds.
*/
async withLock<T>(
uuid: string,
op: LockOp,
fn: () => Promise<T>,
opts: WithLockOptions = {},
): Promise<T | undefined> {
const policy = opts.policy ?? "skip";
if (this.acquire(uuid, op).acquired) {
try {
return await fn();
} finally {
this.release(uuid, op);
}
}
if (policy === "skip") return undefined;
// queue: retry-loop until acquired or maxWait elapses. Re-waiting on every
// release means a racing fresh acquire does not silently make us give up —
// we keep trying until our deadline. On deadline, throw (loud).
const maxWait = opts.maxWaitMs ?? 5000;
const start = Date.now();
while (!this.acquireInternal(uuid, op)) {
const remaining = maxWait - (Date.now() - start);
if (remaining <= 0) throw new LockAcquireTimeout(uuid, op, maxWait);
const woke = await this.waitFor(uuid, remaining);
if (!woke) throw new LockAcquireTimeout(uuid, op, maxWait);
}
try {
return await fn();
} finally {
this.release(uuid, op);
}
}
/** Wait until `uuid` is released (waking one waiter FIFO) or `maxWaitMs`
* elapses. Resolves true on wake, false on timeout. If already free,
* resolves true immediately. The waiter removes itself from the queue on
* timeout so wakeOne does not call a dead resolver. */
private waitFor(uuid: string, maxWaitMs: number): Promise<boolean> {
return new Promise<boolean>((resolve) => {
if (!this.held.has(uuid)) { resolve(true); return; }
let done = false;
const wakeResolve = () => {
if (done) return;
done = true;
clearTimeout(timer);
resolve(true);
};
const timer = setTimeout(() => {
if (done) return;
done = true;
const q = this.waiters.get(uuid);
if (q) {
const idx = q.findIndex((w) => w.resolve === wakeResolve);
if (idx >= 0) q.splice(idx, 1);
if (q.length === 0) this.waiters.delete(uuid);
}
resolve(false);
}, maxWaitMs);
const q = this.waiters.get(uuid) ?? [];
q.push({ resolve: wakeResolve });
this.waiters.set(uuid, q);
});
}
/** Wake one queued waiter for `uuid` (FIFO) and shift it out of the queue,
* so a subsequent public `acquire` sees one fewer waiter (the woken one
* grabs via acquireInternal, not via the deferred public path). */
private wakeOne(uuid: string): void {
const q = this.waiters.get(uuid);
if (!q || q.length === 0) return;
const next = q.shift()!;
if (q.length === 0) this.waiters.delete(uuid);
next.resolve();
}
}
/** Pseudo-uuid for an unlinked/un-keyed vault file, so the unlinked tail still
* gets per-file mutual exclusion. The F→O direction (which receives a uuid
* from the relay) never hits this fallback. */
export function relPathLockKey(relPath: string): string {
return `relPath:${relPath}`;
}

215
tests/cchash.test.ts Normal file
View File

@@ -0,0 +1,215 @@
import { describe, it, expect } from "vitest";
import {
ccHash,
ccHashFromGet,
CC_HASH_CONTRACT,
CcHashError,
isCcHashError,
type HtmlToMarkdown,
} from "../src/cchash.js";
import { contentHash, canonicalize } from "../src/normalize.js";
import type { JournalEntry, CcData } from "../src/types.js";
import type { RelayClient } from "../src/relay/client.js";
// Tested stub inverse: tag-stripping regex. E1a swaps in the real linkedom
// htmlToMarkdown via the seam; ccHash itself is unchanged.
const stubInverse: HtmlToMarkdown = (html: string) => html.replace(/<[^>]+>/g, "");
interface EntryOpts {
name?: string;
folder?: string | null;
description?: string;
notes?: string;
data?: CcData; // exact override (for the missing-data tests)
noFlag?: boolean;
noData?: boolean;
}
function entry(opts: EntryOpts = {}): JournalEntry {
const cc = opts.noFlag
? undefined
: opts.noData
? { type: "npc" }
: { type: "npc", data: opts.data ?? { description: opts.description ?? "<p>The gunslinger.</p>", notes: opts.notes ?? "" } };
return {
name: opts.name ?? "Roland Deschain",
_id: "abc1",
// Default only on undefined (NOT null) so tests can pass `folder: null`
// to exercise the `folder ?? ""` branch in ccHash.
folder: opts.folder !== undefined ? opts.folder : "Folder.gideon",
flags: cc ? { "campaign-codex": cc } : {},
};
}
describe("ccHash contract + determinism (E0.2)", () => {
it("CC_HASH_CONTRACT pins the exact bytes of the frozen input contract", () => {
expect(CC_HASH_CONTRACT).toBe(
'contentHash(canonicalize(inverse(data.description) + (data.notes ? "\\n\\n## Secrets\\n\\n" + inverse(data.notes) : "")) + "\\n" + name + "\\n" + folder)',
);
});
it("implementation matches the frozen contract (re-derivation enforces it)", () => {
// Re-derive the hash from the contract steps and assert the implementation
// agrees — so drift between CC_HASH_CONTRACT and ccHash is caught, not just
// drift in the constant's own bytes.
const e = entry({ notes: "<p>He killed the boy.</p>" });
const data = e.flags!["campaign-codex"]!.data!;
const bodyMd = data.notes
? `${stubInverse(data.description!)}\n\n## Secrets\n\n${stubInverse(data.notes)}`
: stubInverse(data.description!);
const expected = contentHash(`${canonicalize(bodyMd)}\n${e.name}\n${e.folder ?? ""}`);
expect(ccHash(e, stubInverse)).toBe(expected);
});
it("the ## Secrets heading is part of the hash input (re-inserted, not just the notes body)", () => {
// The forward transform strips the ## Secrets heading when storing
// data.notes; ccHash must re-insert it. Prove the heading is in the input:
// with-heading vs without-heading recomputes differ, and ccHash matches
// the with-heading one.
const e = entry({ notes: "<p>He killed the boy.</p>" });
const data = e.flags!["campaign-codex"]!.data!;
const withHeading = contentHash(`${canonicalize(`${stubInverse(data.description!)}\n\n## Secrets\n\n${stubInverse(data.notes!)}`)}\n${e.name}\n${e.folder}`);
const withoutHeading = contentHash(`${canonicalize(`${stubInverse(data.description!)}\n\n${stubInverse(data.notes!)}`)}\n${e.name}\n${e.folder}`);
expect(withHeading).not.toBe(withoutHeading);
expect(ccHash(e, stubInverse)).toBe(withHeading);
});
it("is deterministic: same payload → same hash across runs", () => {
const a = ccHash(entry(), stubInverse);
const b = ccHash(entry(), stubInverse);
expect(a).toBe(b);
expect(a).toMatch(/^[0-9a-f]{64}$/); // sha256 hex
});
it("is sensitive: a one-char change to data.description yields a different hash", () => {
const a = ccHash(entry({ description: "<p>The gunslinger.</p>" }), stubInverse);
const b = ccHash(entry({ description: "<p>The gunslinger!</p>" }), stubInverse);
expect(a).not.toBe(b);
});
it("is sensitive: a change to data.notes (## Secrets) yields a different hash", () => {
// A Foundry-side edit to secrets MUST move ccHash, or the divergence guard
// would miss secrets-only edits (the clobber hole the contract correction closes).
const a = ccHash(entry({ notes: "" }), stubInverse);
const b = ccHash(entry({ notes: "<p>He killed the boy.</p>" }), stubInverse);
expect(a).not.toBe(b);
});
it("name changing alone yields a different hash (part of the hash input)", () => {
const a = ccHash(entry({ name: "Roland Deschain" }), stubInverse);
const b = ccHash(entry({ name: "Roland Deschain of Gilead" }), stubInverse);
expect(a).not.toBe(b);
});
it("folder changing alone yields a different hash (part of the hash input — Foundry folder ID)", () => {
const a = ccHash(entry({ folder: "Folder.gideon" }), stubInverse);
const b = ccHash(entry({ folder: "Folder.gilead" }), stubInverse);
expect(a).not.toBe(b);
});
it("absent folder is treated as empty string (matches Obsidian-side absence)", () => {
const withEmpty = ccHash(entry({ folder: "" }), stubInverse);
const absentFolder = ccHash(entry({ folder: null }), stubInverse);
expect(withEmpty).toBe(absentFolder);
});
it("trailing whitespace in name/folder is normalized (canonicalize via contentHash)", () => {
// name/folder are concatenated raw but the final contentHash canonicalizes
// the whole string, so relay serialization whitespace drift does not flap ccHash.
const a = ccHash(entry({ name: "Roland Deschain" }), stubInverse);
const b = ccHash(entry({ name: "Roland Deschain " }), stubInverse); // trailing spaces
expect(a).toBe(b);
});
});
describe("ccHash direction-invariance (E0.2)", () => {
it("same Foundry data+name+folder → same hash regardless of caller (E1b push vs E2 pull)", () => {
// E1b's push path and E2's pull path both compute the same value for the
// same Foundry entry: the hash is a function of the Foundry entry only.
const e = entry();
const fromPush = ccHash(e, stubInverse);
const fromPull = ccHash(e, stubInverse);
expect(fromPush).toBe(fromPull);
});
it("renaming the vault file (without changing the live entry) leaves ccHash unchanged", () => {
// The vault filename never enters the hash. A rename is a name-field
// update routed through pushNote's updatedName path, not a content
// divergence — so the stored foundry.ccHash is unaffected until a push
// updates liveEntry.name.
const e = entry();
const beforeRename = ccHash(e, stubInverse);
const afterVaultRename = ccHash(e, stubInverse); // liveEntry unchanged
expect(beforeRename).toBe(afterVaultRename);
});
it("a live entry name change (a real push) DOES change ccHash", () => {
// Contrast: when the push updates liveEntry.name, ccHash moves — pinning
// that name is sourced from the entry, not the vault filename.
const before = ccHash(entry({ name: "Roland" }), stubInverse);
const after = ccHash(entry({ name: "Roland Deschain" }), stubInverse);
expect(before).not.toBe(after);
});
});
describe("ccHash error handling (E0.2)", () => {
it("throws CcHashError when flags.campaign-codex is absent", () => {
try {
ccHash(entry({ noFlag: true }), stubInverse);
throw new Error("should have thrown");
} catch (err) {
expect(isCcHashError(err)).toBe(true);
expect((err as CcHashError).message).toBe("missing campaign-codex data");
}
});
it("throws CcHashError when flags.campaign-codex.data is absent", () => {
try {
ccHash(entry({ noData: true }), stubInverse);
throw new Error("should have thrown");
} catch (err) {
expect(isCcHashError(err)).toBe(true);
expect((err as CcHashError).message).toBe("missing campaign-codex data");
}
});
it("throws CcHashError when data.description is absent/non-string (NOT coerced to empty)", () => {
// A present-but-description-less entry must not silently hash "" — that
// would create a stable-but-wrong baseline, defeating the typed error's
// "no Foundry-side content yet" vs "content changed" distinction.
const e = entry({ data: { notes: "<p>orphan notes</p>" } as CcData });
expect(() => ccHash(e, stubInverse)).toThrow(CcHashError);
expect(() => ccHash(e, stubInverse)).toThrow(/description/);
});
it("ccHashFromGet surfaces relay errors unchanged (not wrapped as CcHashError)", async () => {
const relayErr = new Error('relay 404 GET /get: No connected Foundry clients found');
const fakeRelay = { getEntry: async (_uuid: string): Promise<JournalEntry> => { throw relayErr; } } as unknown as RelayClient;
try {
await ccHashFromGet(fakeRelay, "JournalEntry.abc1", stubInverse);
throw new Error("should have thrown");
} catch (err) {
expect(isCcHashError(err)).toBe(false);
expect(err).toBe(relayErr);
}
});
it("ccHashFromGet returns { hash, entry } on success and derives the hash from the same response", async () => {
const e = entry();
const fakeRelay = { getEntry: async (_uuid: string): Promise<JournalEntry> => e } as unknown as RelayClient;
const result = await ccHashFromGet(fakeRelay, "JournalEntry.abc1", stubInverse);
expect(result.entry).toBe(e);
expect(result.hash).toBe(ccHash(e, stubInverse));
});
it("ccHashFromGet throws CcHashError (not relay error) when the entry is malformed", async () => {
const malformed = entry({ noData: true });
const fakeRelay = { getEntry: async (): Promise<JournalEntry> => malformed } as unknown as RelayClient;
try {
await ccHashFromGet(fakeRelay, "JournalEntry.abc1", stubInverse);
throw new Error("should have thrown");
} catch (err) {
expect(isCcHashError(err)).toBe(true);
}
});
});

View File

@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import {
FLAGS_SCHEMA_VERSION,
SYNC_STATE_SCHEMA_VERSION,
parseSchemaVersion,
} from "../src/schema-version.js";
import type { FlagsSchemaVersion, SyncStateSchemaVersion } from "../src/schema-version.js";
describe("schema-version constants (E0.3)", () => {
it("are unequal strings with distinct prefixes", () => {
expect(FLAGS_SCHEMA_VERSION).not.toBe(SYNC_STATE_SCHEMA_VERSION);
expect(FLAGS_SCHEMA_VERSION.startsWith("flags-")).toBe(true);
expect(SYNC_STATE_SCHEMA_VERSION.startsWith("sync-")).toBe(true);
expect(typeof FLAGS_SCHEMA_VERSION).toBe("string");
expect(typeof SYNC_STATE_SCHEMA_VERSION).toBe("string");
});
it("parseSchemaVersion branches on prefix and returns null for unknown", () => {
expect(parseSchemaVersion(FLAGS_SCHEMA_VERSION)).toEqual({ kind: "flags", version: FLAGS_SCHEMA_VERSION });
expect(parseSchemaVersion(SYNC_STATE_SCHEMA_VERSION)).toEqual({ kind: "sync-state", version: SYNC_STATE_SCHEMA_VERSION });
expect(parseSchemaVersion("flags-campaign-codex/v2")).toEqual({ kind: "flags", version: "flags-campaign-codex/v2" });
expect(parseSchemaVersion("sync-state/v2")).toEqual({ kind: "sync-state", version: "sync-state/v2" });
expect(parseSchemaVersion("unknown/v1")).toBeNull();
expect(parseSchemaVersion("")).toBeNull();
});
it("branded types are not assignable to each other (compile-time guard)", () => {
// Nominal brands via distinct property names (__flagsBrand / __syncBrand).
// If a brand ever stops preventing cross-assignment, one of these conditional
// types flips to `true` and the matching `= false as const` line errors at
// compile time (true not assignable to false) — the guard is enforced by
// tsc, not just asserted in prose.
type FlagsExtendsSync = [FlagsSchemaVersion] extends [SyncStateSchemaVersion] ? true : false;
type SyncExtendsFlags = [SyncStateSchemaVersion] extends [FlagsSchemaVersion] ? true : false;
const flagsExtendsSync: FlagsExtendsSync = false as const;
const syncExtendsFlags: SyncExtendsFlags = false as const;
expect(flagsExtendsSync).toBe(false);
expect(syncExtendsFlags).toBe(false);
// Runtime sanity: the brands are still distinct string values at runtime.
expect(FLAGS_SCHEMA_VERSION).not.toBe(SYNC_STATE_SCHEMA_VERSION);
});
});

171
tests/server-lock.test.ts Normal file
View File

@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtemp, writeFile, mkdir, rm, readFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
// Mock pushNote so we can assert whether a PUT would have fired, without a
// relay. The controller's `runPushBody` calls `relayClient(state)` then
// `pushNote({...})`; with pushNote mocked, the relay is never contacted.
vi.mock("../src/push.js", () => ({
pushNote: vi.fn(async () => ({ dryRun: false, ccUuid: "JournalEntry.abc1", diff: {}, imageNote: "" })),
}));
import { pushNote } from "../src/push.js";
import { AutoSyncController } from "../src/server.js";
import type { State, ServerConfig } from "../src/server.js";
import { splitFrontmatter, readFoundryBlock } from "../src/frontmatter.js";
import { contentHash } from "../src/normalize.js";
const UUID = "JournalEntry.abc1";
const REL = "Roland.md";
interface Deferred<T = void> {
promise: Promise<T>;
resolve: (v: T) => void;
}
function deferred<T = void>(): Deferred<T> {
let resolve!: (v: T) => void;
const promise = new Promise<T>((res) => { resolve = res; });
return { promise, resolve };
}
function seededNote(body: string, contentHashBaseline: string): string {
return [
"---",
"type: npc",
"foundry:",
` cc_uuid: ${UUID}`,
" cc_type: npc",
" folder_path: NPCs",
` contentHash: ${contentHashBaseline}`,
" syncedAt: 2026-06-22T00:00:00.000Z",
"---",
body,
"",
].join("\n");
}
let dir: string;
let state: State;
beforeEach(async () => {
vi.mocked(pushNote).mockClear();
vi.mocked(pushNote).mockImplementation(async () => ({
dryRun: false, ccUuid: UUID, diff: {}, imageNote: "",
}));
dir = await mkdtemp(join(tmpdir(), "autosync-lock-"));
const refinedDir = join(dir, "refined");
const outDir = join(dir, "out");
await mkdir(refinedDir, { recursive: true });
const cfg: ServerConfig = {
journal: "",
refinedDir,
ccDir: "",
outDir,
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;
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
async function writeNote(body = "The gunslinger drew his revolver.\n"): Promise<void> {
// contentHash baseline deliberately WRONG (64 zeros) so bodyHash !== baseline
// and the "unchanged" skip does NOT fire — the push path is exercised.
await writeFile(join(state.cfg.refinedDir, REL), seededNote(body, "0".repeat(64)), "utf8");
}
/** Wait until the controller holds the lock for UUID (used to deterministically
* pin a concurrent op's start to the window where the lock is held). */
async function waitUntilHeld(controller: AutoSyncController, uuid: string): Promise<void> {
while (!controller.lock.isHeld(uuid)) await new Promise<void>((r) => setImmediate(r));
}
describe("AutoSyncController lock integration (E0.1)", () => {
it("a normal save acquires the push lock, calls pushNote once, baselines, and releases", async () => {
await writeNote();
const controller = new AutoSyncController(state);
await (controller as any).process(REL);
expect(vi.mocked(pushNote)).toHaveBeenCalledTimes(1);
expect(controller.lock.isHeld(UUID)).toBe(false); // released after push
// Baseline wrote foundry.contentHash = current body hash (idempotency).
const md = await readFile(join(state.cfg.refinedDir, REL), "utf8");
const { fm, body } = splitFrontmatter(md);
const fb = readFoundryBlock(fm);
expect(fb?.contentHash).toBe(contentHash(body));
expect(controller.events.some((e) => e.status === "pushed")).toBe(true);
});
it("cross-direction: a pre-held F→O ('pull') lock blocks the O→F push — no PUT (fail-safe)", async () => {
await writeNote();
const controller = new AutoSyncController(state);
// Simulate an in-flight F→O pull holding the uuid (E2's future path).
expect(controller.lock.acquire(UUID, "pull")).toEqual({ acquired: true });
await (controller as any).process(REL);
expect(vi.mocked(pushNote)).not.toHaveBeenCalled();
expect(controller.lock.isHeld(UUID)).toBe(true); // still held by pull
expect(controller.events.some((e) => e.message.includes("lock busy"))).toBe(true);
controller.lock.release(UUID, "pull");
});
it("two concurrent process() calls for the same uuid push exactly once (lock dedup)", async () => {
await writeNote();
const controller = new AutoSyncController(state);
// Gate the FIRST pushNote call so p1 holds the lock long enough for p2 to
// arrive and hit the lock-busy skip — deterministic dedup.
const gate = deferred<void>();
let calls = 0;
vi.mocked(pushNote).mockImplementation(async () => {
calls++;
if (calls === 1) await gate.promise;
return { dryRun: false, ccUuid: UUID, diff: {}, imageNote: "" };
});
const p1 = (controller as any).process(REL) as Promise<void>;
await waitUntilHeld(controller, UUID); // p1 acquired, now blocked in pushNote
const p2 = (controller as any).process(REL) as Promise<void>;
await p2; // p2 reads, withLock(skip) → held → skip (no push)
// p1 has invoked pushNote once (it is parked on the gate); p2 did NOT
// invoke it — so exactly one call so far, no duplicate.
expect(vi.mocked(pushNote)).toHaveBeenCalledTimes(1);
gate.resolve(void 0);
await p1; // p1 completes push + baseline
expect(vi.mocked(pushNote)).toHaveBeenCalledTimes(1); // still exactly one PUT
});
it("flag-off (SYNC_LOCK_ENABLED=false) uses the legacy per-relPath inflight guard, byte-identical behavior", async () => {
await writeNote();
const controller = new AutoSyncController(state);
(controller as any).syncLockEnabled = false; // simulate the flag-off path
const inflight = (controller as any).inflight as Set<string>;
await (controller as any).process(REL);
expect(vi.mocked(pushNote)).toHaveBeenCalledTimes(1);
expect(inflight.has(REL)).toBe(false); // cleaned up in finally
expect(controller.lock.isHeld(UUID)).toBe(false); // lock not consulted at all
});
it("an unlinked note (no foundry.cc_uuid) is skipped before any push, and the relPath fallback key is cached", async () => {
// No foundry block → unlinked. process should skip ("not linked") and NOT push.
await writeFile(join(state.cfg.refinedDir, REL), "---\ntype: npc\n---\nBody with no foundry block.\n", "utf8");
const controller = new AutoSyncController(state);
await (controller as any).process(REL);
expect(vi.mocked(pushNote)).not.toHaveBeenCalled();
expect(controller.events.some((e) => e.message.includes("not linked"))).toBe(true);
// The relPath fallback key was cached (so the debounce pre-check can use it).
expect((controller as any).uuidCache.get(REL)).toBe(`relPath:${REL}`);
});
});

254
tests/synclock.test.ts Normal file
View File

@@ -0,0 +1,254 @@
import { describe, it, expect } from "vitest";
import { SyncLock, relPathLockKey, LockAcquireTimeout } from "../src/synclock.js";
// Helper: a deferred promise the test controls, so a held `withLock` body can
// be kept alive until an assertion outside it has run.
interface Deferred<T = void> {
promise: Promise<T>;
resolve: (v: T) => void;
reject: (e: unknown) => void;
}
function deferred<T = void>(): Deferred<T> {
let resolve!: (v: T) => void;
let reject!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
describe("SyncLock acquire/release/isHeld/heldOps (E0.1)", () => {
it("acquires when free and reports held", () => {
const lock = new SyncLock();
expect(lock.acquire("u1", "push")).toEqual({ acquired: true });
expect(lock.isHeld("u1")).toBe(true);
expect(lock.heldOps()).toEqual({ u1: "push" });
lock.release("u1", "push");
expect(lock.isHeld("u1")).toBe(false);
expect(lock.heldOps()).toEqual({});
});
it("acquire on a held uuid returns false with the held op", () => {
const lock = new SyncLock();
lock.acquire("u1", "push");
expect(lock.acquire("u1", "pull")).toEqual({ acquired: false, heldOp: "push" });
});
it("release of an un-held uuid is a no-op (does not throw)", () => {
const lock = new SyncLock();
expect(() => lock.release("never", "push")).not.toThrow();
// wrong op on a held uuid is also a no-op
lock.acquire("u1", "push");
expect(() => lock.release("u1", "pull")).not.toThrow();
expect(lock.isHeld("u1")).toBe(true);
});
});
describe("SyncLock cross-direction exclusion (E0.1, FR-3.1/FR-5.5)", () => {
it("an O→F withLock running blocks an F→O acquire on the same uuid", async () => {
const lock = new SyncLock();
const gate = deferred<void>();
let pushStarted = false;
const pushDone = lock.withLock("u1", "push", async () => {
pushStarted = true;
await gate.promise;
});
// Wait until the push body is actually running.
await new Promise<void>((r) => setImmediate(r));
expect(pushStarted).toBe(true);
// While the push holds the uuid, an F→O acquire must be refused.
expect(lock.acquire("u1", "pull")).toEqual({ acquired: false, heldOp: "push" });
gate.resolve(void 0);
await pushDone;
// After release, the pull can acquire.
expect(lock.acquire("u1", "pull")).toEqual({ acquired: true });
});
});
describe("SyncLock burst serializes same-uuid ops (E0.1, NFR-3)", () => {
it("N concurrent queue-policy withLock(uuid,'push') calls execute strictly one at a time", async () => {
const lock = new SyncLock();
const N = 10;
let inFlight = 0;
let maxInFlight = 0;
const completed: number[] = [];
const tasks = Array.from({ length: N }, (_, i) =>
lock.withLock("u1", "push", async () => {
inFlight++;
maxInFlight = Math.max(maxInFlight, inFlight);
// Yield a few microtasks so other queued tasks would visibly overlap if allowed.
await new Promise<void>((r) => setImmediate(r));
await new Promise<void>((r) => setImmediate(r));
completed.push(i);
inFlight--;
return i;
}, { policy: "queue", maxWaitMs: 1000 }),
);
const results = await Promise.all(tasks);
expect(maxInFlight).toBe(1); // strictly one at a time
expect(results).toHaveLength(N);
expect(results.every((r) => r !== undefined)).toBe(true); // none skipped/timed out
expect(completed).toHaveLength(N);
});
it("skip-policy drops redundant same-uuid ops instead of queuing", async () => {
const lock = new SyncLock();
const gate = deferred<void>();
let ran = 0;
const first = lock.withLock("u1", "push", async () => { ran++; await gate.promise; }, { policy: "skip" });
await new Promise<void>((r) => setImmediate(r));
// Second skip-policy op while the first holds: dropped (undefined), does not run.
const second = await lock.withLock("u1", "push", async () => { ran++; }, { policy: "skip" });
expect(second).toBeUndefined();
gate.resolve(void 0);
await first;
expect(ran).toBe(1);
});
});
describe("SyncLock release-on-throw (E0.1)", () => {
it("a rejecting fn releases the lock so the next acquire succeeds", async () => {
const lock = new SyncLock();
await expect(lock.withLock("u1", "push", async () => { throw new Error("boom"); })).rejects.toThrow("boom");
expect(lock.isHeld("u1")).toBe(false);
expect(lock.acquire("u1", "push")).toEqual({ acquired: true });
});
it("a queued waiter still acquires after the holder rejects (slot is released)", async () => {
const lock = new SyncLock();
let waiterRan = false;
const holder = lock.withLock("u1", "push", async () => { throw new Error("holder fails"); });
const waiter = lock.withLock("u1", "pull", async () => { waiterRan = true; }, { policy: "queue", maxWaitMs: 1000 });
await expect(holder).rejects.toThrow("holder fails");
const result = await waiter;
expect(result).toBeUndefined(); // fn returned void → resolved to undefined
expect(waiterRan).toBe(true);
});
});
describe("SyncLock different-uuid concurrency (E0.1 — no global lock)", () => {
it("two different uuids proceed concurrently", async () => {
const lock = new SyncLock();
let maxInFlight = 0;
let inFlight = 0;
const both = Promise.all([
lock.withLock("u1", "push", async () => {
inFlight++; maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise<void>((r) => setImmediate(r));
inFlight--;
}),
lock.withLock("u2", "push", async () => {
inFlight++; maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise<void>((r) => setImmediate(r));
inFlight--;
}),
]);
await both;
expect(maxInFlight).toBe(2);
});
});
describe("SyncLock reentrant-NO (E0.1)", () => {
it("a second acquire of the same uuid from inside a held withLock returns false", async () => {
const lock = new SyncLock();
let innerAcquire: { acquired: boolean; heldOp?: string } | null = null;
await lock.withLock("u1", "push", async () => {
innerAcquire = lock.acquire("u1", "push"); // re-entrant attempt
});
expect(innerAcquire).toEqual({ acquired: false, heldOp: "push" });
});
it("a skip-policy nested withLock for the same uuid returns undefined (no deadlock)", async () => {
const lock = new SyncLock();
let nestedRan = false;
let nestedResult: unknown = "untouched";
await lock.withLock("u1", "push", async () => {
nestedResult = await lock.withLock("u1", "push", async () => { nestedRan = true; }, { policy: "skip" });
});
expect(nestedRan).toBe(false);
expect(nestedResult).toBeUndefined();
// Outer lock was still released.
expect(lock.isHeld("u1")).toBe(false);
});
});
describe("SyncLock queue timeout (E0.1)", () => {
it("a queue-policy op THROWS LockAcquireTimeout after maxWaitMs if it cannot acquire (not a silent drop)", async () => {
const lock = new SyncLock();
const gate = deferred<void>();
const holder = lock.withLock("u1", "push", async () => { await gate.promise; });
await new Promise<void>((r) => setImmediate(r));
await expect(
lock.withLock("u1", "pull", async () => "ran", { policy: "queue", maxWaitMs: 30 }),
).rejects.toBeInstanceOf(LockAcquireTimeout);
gate.resolve(void 0);
await holder;
});
it("a nested queue-policy withLock for the same uuid throws after maxWait (no indefinite stall)", async () => {
const lock = new SyncLock();
// Outer holds; an inner queue-policy withLock for the same uuid cannot ever
// acquire (the outer won't release until the inner returns) → throws after
// maxWait instead of hanging forever.
await expect(
lock.withLock("u1", "push", async () => {
await lock.withLock("u1", "push", async () => "inner", { policy: "queue", maxWaitMs: 20 });
}),
).rejects.toBeInstanceOf(LockAcquireTimeout);
expect(lock.isHeld("u1")).toBe(false); // outer released in its finally despite inner throw
});
});
describe("SyncLock fairness (E0.1)", () => {
it("a fresh public acquire DEFERS when waiters are queued (does not jump the queue)", async () => {
const lock = new SyncLock();
expect(lock.acquire("u1", "push")).toEqual({ acquired: true });
// Two queued waiters register while held.
const gate = deferred<void>();
const q1 = lock.withLock("u1", "pull", async () => { await gate.promise; return "q1"; }, { policy: "queue", maxWaitMs: 2000 });
const q2 = lock.withLock("u1", "baseline", async () => "q2", { policy: "queue", maxWaitMs: 2000 });
await new Promise<void>((r) => setImmediate(r)); // let both register in `waiters`
// Synchronously release, then synchronously acquire — between release and
// the woken waiter's microtask, the lock is FREE with q2 still queued, so a
// fresh public acquire must defer (not grab).
lock.release("u1", "push"); // wakeOne shifts q1 (sync); waiters=[q2]; held deleted
const fresh = lock.acquire("u1", "push");
expect(fresh).toEqual({ acquired: false, deferred: true });
// Let q1 wake, grab, and run (it blocks on gate). q1 now holds; q2 queued.
await new Promise<void>((r) => setImmediate(r));
expect(lock.isHeld("u1")).toBe(true);
gate.resolve(void 0);
expect(await q1).toBe("q1"); // q1 releases → wakeOne shifts q2
expect(await q2).toBe("q2"); // q2 grabs (FIFO after q1)
expect(lock.isHeld("u1")).toBe(false);
});
it("a queued waiter is served even if a fresh skip acquire is present (retry-loop, no silent give-up)", async () => {
const lock = new SyncLock();
const gate = deferred<void>();
const holder = lock.withLock("u1", "push", async () => { await gate.promise; });
const queued = lock.withLock("u1", "pull", async () => "served", { policy: "queue", maxWaitMs: 2000 });
await new Promise<void>((r) => setImmediate(r));
// A fresh skip op while held → drops (undefined), does not block the queue.
const skip = await lock.withLock("u1", "push", async () => "skip-ran", { policy: "skip" });
expect(skip).toBeUndefined();
gate.resolve(void 0);
expect(await queued).toBe("served");
await holder;
});
});
describe("relPathLockKey fallback (E0.1)", () => {
it("namespaces unlinked relPaths as a distinct pseudo-uuid", () => {
expect(relPathLockKey("npcs/Roland.md")).toBe("relPath:npcs/Roland.md");
expect(relPathLockKey("npcs/Roland.md")).not.toBe(relPathLockKey("npcs/Susan.md"));
// The fallback is a distinct key from a real uuid — the bidirectional
// claim holds for linked notes; the fallback only covers the unlinked tail.
expect(relPathLockKey("npcs/Roland.md")).not.toBe("JournalEntry.abc1");
});
});