/** * CoauthorStore — load/save the per-document `.threads/` sidecar (spec §6.4). * Git-native, serverless, plain pretty JSON (INV-2). vscode-free (Node fs only), * so it is unit-testable; the SHARED FileSystemWatcher that triggers * re-anchoring on external change is wired in the vscode layer (extension.ts), * with self-write suppression centralized here because two controllers * (ThreadController, AttributionController) co-own the sidecar (F3 §6.2). */ import * as fs from "node:fs"; import * as path from "node:path"; import { SCHEMA_VERSION, emptyArtifact, isNewerMajor, serializeArtifact, type Artifact } from "./model"; export class CoauthorStore { /** * Pending-write counts keyed by absolute sidecar path. A Map (not Set) because * one doc save can trigger TWO store.update() calls (ThreadController then * AttributionController), both writing the same sidecar — a Set would let the * second watcher event leak through as "external" (comment-UI flicker). */ private readonly selfWrites = new Map(); /** @param rootDir absolute workspace-folder root that owns the `.threads/` tree. */ constructor(private readonly rootDir: string) {} /** * Decrement-and-report: true iff `fsPath` was a sidecar this store just * wrote via `update`. Each successful `update` increments the counter once; * each call here decrements it (deleting the key at zero). The shared watcher * (extension.ts) calls this to suppress self-write events before fanning out * to the controllers. */ consumeSelfWrite(fsPath: string): boolean { const count = this.selfWrites.get(fsPath); if (!count) return false; if (count === 1) { this.selfWrites.delete(fsPath); } else { this.selfWrites.set(fsPath, count - 1); } return true; } /** `.threads/.json` (spec §6.3). */ sidecarPath(docPath: string): string { return path.join(this.rootDir, ".threads", `${docPath}.json`); } load(docPath: string): Artifact | null { const p = this.sidecarPath(docPath); if (!fs.existsSync(p)) return null; return JSON.parse(fs.readFileSync(p, "utf8")) as Artifact; } save(docPath: string, artifact: Artifact): void { const p = this.sidecarPath(docPath); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, serializeArtifact(artifact), "utf8"); } /** * Read-modify-write for sidecar co-ownership (spec F3 §6.2): each controller * mutates only its own section against a FRESH load, so ThreadController and * AttributionController never clobber each other. After the mutation, anchors * referenced by no thread, attribution, OR proposal are pruned (threads, * attributions, and proposals all keep their anchors — F4). `mutate` MUST be synchronous: the * read-modify-write (and the self-write mark) completes within this call. */ update(docPath: string, mutate: (artifact: Artifact) => void): Artifact { const artifact = this.load(docPath) ?? emptyArtifact(docPath); if (isNewerMajor(artifact)) { // INV-16 backstop: the controllers gate first (VersionGuard); this throw // guarantees no missed write path can ever destroy a newer rung's data. throw new Error( `refusing to write ${docPath}: sidecar schemaVersion ${artifact.schemaVersion} > supported ${SCHEMA_VERSION} (INV-16)`, ); } mutate(artifact); const referenced = new Set([ ...artifact.threads.map((t) => t.anchorId), ...artifact.attributions.map((a) => a.anchorId), ...artifact.proposals.map((p) => p.anchorId), ]); for (const id of Object.keys(artifact.anchors)) { if (!referenced.has(id)) delete artifact.anchors[id]; } this.save(docPath, artifact); // Increment AFTER the synchronous write: fs.writeFileSync completes before // this line executes, and watcher events are delivered async, so marking // post-write is race-free for suppression purposes. const p = this.sidecarPath(docPath); this.selfWrites.set(p, (this.selfWrites.get(p) ?? 0) + 1); return artifact; } }