746071a585
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
95 lines
4.0 KiB
TypeScript
95 lines
4.0 KiB
TypeScript
/**
|
|
* 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<string, number>();
|
|
|
|
/** @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/<repo-relative-docPath>.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<string>([
|
|
...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;
|
|
}
|
|
}
|