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:
195
src/cchash.ts
Normal file
195
src/cchash.ts
Normal 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
60
src/schema-version.ts
Normal 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;
|
||||
}
|
||||
156
src/server.ts
156
src/server.ts
@@ -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
235
src/synclock.ts
Normal 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
215
tests/cchash.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
43
tests/schema-version.test.ts
Normal file
43
tests/schema-version.test.ts
Normal 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
171
tests/server-lock.test.ts
Normal 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
254
tests/synclock.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user