diff --git a/src/globalSidecarStore.ts b/src/globalSidecarStore.ts new file mode 100644 index 0000000..e7c9f4b --- /dev/null +++ b/src/globalSidecarStore.ts @@ -0,0 +1,89 @@ +/** + * GlobalSidecarStore — the out-of-workspace/untitled authoring sidecar (F8 spec + * §6.2/§6.4), mirroring BaselineStore (src/baselineStore.ts): vscode-free (Node + * fs/crypto only), one `Artifact` JSON per document in VS Code's per-extension + * GLOBAL storage, NEVER the repo (INV-19/24). The key passed in is the DOCUMENT + * KEY (the URI string for the docs this store serves): + * - `file:` URI → disk at `/sidecars/.json`. + * - `untitled:` → an in-memory Map only (no durable identity — the F6 + * degrade); lost on reload, and never read by mergeArtifacts (INV-25). + * `consumeSelfWrite` is a no-op (these artifacts are outside the `.threads/` + * watcher, so there is no self-write storm to suppress). + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { createHash } from "node:crypto"; +import { + SCHEMA_VERSION, + emptyArtifact, + isNewerMajor, + serializeArtifact, + type Artifact, +} from "./model"; +import type { SidecarStore } from "./sidecarStore"; + +export class GlobalSidecarStore implements SidecarStore { + /** untitled keys live here only — no durable identity to persist against. */ + private readonly memory = new Map(); + + /** @param storageDir absolute VS Code GLOBAL-storage dir (context.globalStorageUri.fsPath). */ + constructor(private readonly storageDir: string) {} + + private isUntitled(key: string): boolean { + return key.startsWith("untitled:"); + } + + /** Filesystem-safe storage key for a persistable (file:) doc: sha256 of its URI. */ + private storageKey(key: string): string { + return createHash("sha256").update(key).digest("hex"); + } + + /** Disk path for a `file:` key, or undefined for an in-memory untitled key. */ + sidecarPath(key: string): string | undefined { + if (this.isUntitled(key)) return undefined; + return path.join(this.storageDir, "sidecars", `${this.storageKey(key)}.json`); + } + + load(key: string): Artifact | null { + if (this.isUntitled(key)) return this.memory.get(key) ?? null; + const p = this.sidecarPath(key)!; + if (!fs.existsSync(p)) return null; + return JSON.parse(fs.readFileSync(p, "utf8")) as Artifact; + } + + save(key: string, artifact: Artifact): void { + if (this.isUntitled(key)) { + this.memory.set(key, artifact); + return; + } + const p = this.sidecarPath(key)!; + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, serializeArtifact(artifact), "utf8"); + } + + /** See CoauthorStore.update — same INV-16 throw + anchor prune, no self-write mark. */ + update(key: string, mutate: (artifact: Artifact) => void): Artifact { + const artifact = this.load(key) ?? emptyArtifact(key); + if (isNewerMajor(artifact)) { + throw new Error( + `refusing to write ${key}: 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(key, artifact); + return artifact; + } + + /** No-op: global artifacts are outside the `.threads/` watcher. */ + consumeSelfWrite(_fsPath: string): boolean { + return false; + } +} diff --git a/src/sidecarStore.ts b/src/sidecarStore.ts new file mode 100644 index 0000000..6116573 --- /dev/null +++ b/src/sidecarStore.ts @@ -0,0 +1,35 @@ +/** + * SidecarStore — the storage surface the three authoring controllers depend on + * (F8 spec §6.2/§6.4). One per-document `Artifact` keyed by a string `key` (the + * document key — repo-relative path in-workspace, URI string otherwise; the + * router's keyOf is the single source). Two implementations: + * - CoauthorStore (src/store.ts) — the committable repo `.threads/` sidecar, + * keyed by repo-relative path (INV-2, byte-for-byte unchanged; conforms + * structurally — not modified for F8). + * - GlobalSidecarStore (src/globalSidecarStore.ts) — out-of-workspace `file:` + * and `untitled:` docs, in VS Code GLOBAL storage keyed by sha256(uri) + * (INV-19/24/25; untitled in-memory only). + * SidecarRouter (src/sidecarRouter.ts) implements this AND adds keyOf/sidecarPath; + * the controllers receive the router. + */ +import type { Artifact } from "./model"; + +export interface SidecarStore { + /** Load the artifact for `key`, or null if none persisted. */ + load(key: string): Artifact | null; + /** Overwrite the artifact for `key` (newest wins, no history). */ + save(key: string, artifact: Artifact): void; + /** + * Read-modify-write: load (or empty), apply `mutate`, prune anchors referenced + * by no thread/attribution/proposal, persist, return the result. MUST be + * synchronous (the F3 co-ownership contract). Throws on a newer-major sidecar + * (INV-16 backstop). + */ + update(key: string, mutate: (artifact: Artifact) => void): Artifact; + /** + * Decrement-and-report whether `fsPath` is a sidecar this store just wrote + * (repo sidecars only; the global store is outside the `.threads/` watcher so + * its impl is a no-op returning false). + */ + consumeSelfWrite(fsPath: string): boolean; +} diff --git a/test/globalSidecarStore.test.ts b/test/globalSidecarStore.test.ts new file mode 100644 index 0000000..e3a0a4f --- /dev/null +++ b/test/globalSidecarStore.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { createHash } from "node:crypto"; +import { GlobalSidecarStore } from "../src/globalSidecarStore"; +import { emptyArtifact, SCHEMA_VERSION, type Artifact } from "../src/model"; + +let dir: string; +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "global-sidecar-")); +}); +afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); +}); + +const FILE_URI = "file:///abs/notes/chapter-1.md"; +const hash = (uri: string) => createHash("sha256").update(uri).digest("hex"); + +function withThread(uri: string): Artifact { + const a = emptyArtifact(uri); + a.anchors["an_1"] = { fingerprint: { text: "hi", before: "", after: "", lineHint: 0 } }; + a.threads.push({ id: "t_1", anchorId: "an_1", status: "open", messages: [] }); + return a; +} + +describe("GlobalSidecarStore — file: URIs persist to /sidecars/.json", () => { + it("returns null for a key with no sidecar", () => { + expect(new GlobalSidecarStore(dir).load(FILE_URI)).toBeNull(); + }); + + it("save then load round-trips the artifact at the hashed path", () => { + const store = new GlobalSidecarStore(dir); + const a = withThread(FILE_URI); + store.save(FILE_URI, a); + const onDisk = path.join(dir, "sidecars", `${hash(FILE_URI)}.json`); + expect(fs.existsSync(onDisk)).toBe(true); + expect(store.load(FILE_URI)).toEqual(a); + }); + + it("sidecarPath returns the hashed disk path for a file: URI", () => { + const store = new GlobalSidecarStore(dir); + expect(store.sidecarPath(FILE_URI)).toBe(path.join(dir, "sidecars", `${hash(FILE_URI)}.json`)); + }); + + it("overwrites in place (newest wins, no history)", () => { + const store = new GlobalSidecarStore(dir); + store.save(FILE_URI, withThread(FILE_URI)); + const empty = emptyArtifact(FILE_URI); + store.save(FILE_URI, empty); + expect(store.load(FILE_URI)).toEqual(empty); + }); + + it("consumeSelfWrite is a no-op returning false (outside the .threads watcher)", () => { + const store = new GlobalSidecarStore(dir); + store.save(FILE_URI, withThread(FILE_URI)); + expect(store.consumeSelfWrite(store.sidecarPath(FILE_URI)!)).toBe(false); + }); + + it("update applies the mutation, prunes unreferenced anchors, and persists", () => { + const store = new GlobalSidecarStore(dir); + const result = store.update(FILE_URI, (a) => { + a.anchors["orphan"] = { fingerprint: { text: "x", before: "", after: "", lineHint: 0 } }; + a.anchors["kept"] = { fingerprint: { text: "y", before: "", after: "", lineHint: 0 } }; + a.threads.push({ id: "t", anchorId: "kept", status: "open", messages: [] }); + }); + expect(Object.keys(result.anchors)).toEqual(["kept"]); // "orphan" pruned + expect(store.load(FILE_URI)!.threads.length).toBe(1); + }); + + it("update throws on a newer-major sidecar (INV-16 backstop)", () => { + const store = new GlobalSidecarStore(dir); + const future = { ...emptyArtifact(FILE_URI), schemaVersion: SCHEMA_VERSION + 1 }; + store.save(FILE_URI, future as Artifact); + expect(() => store.update(FILE_URI, () => {})).toThrow(/INV-16/); + }); +}); + +describe("GlobalSidecarStore — untitled: keys are in-memory only (no disk, lost on reload)", () => { + const UNTITLED = "untitled:Untitled-1"; + + it("save then load round-trips within the same instance (session)", () => { + const store = new GlobalSidecarStore(dir); + const a = emptyArtifact(UNTITLED); + a.threads.push({ id: "t", anchorId: "an", status: "open", messages: [] }); + a.anchors["an"] = { fingerprint: { text: "z", before: "", after: "", lineHint: 0 } }; + store.save(UNTITLED, a); + expect(store.load(UNTITLED)).toEqual(a); + }); + + it("writes NO disk file for an untitled key", () => { + const store = new GlobalSidecarStore(dir); + store.save(UNTITLED, emptyArtifact(UNTITLED)); + expect(store.sidecarPath(UNTITLED)).toBeUndefined(); + expect(fs.existsSync(path.join(dir, "sidecars"))).toBe(false); + }); + + it("a fresh instance (simulated reload) has no untitled state", () => { + const store = new GlobalSidecarStore(dir); + store.save(UNTITLED, emptyArtifact(UNTITLED)); + expect(new GlobalSidecarStore(dir).load(UNTITLED)).toBeNull(); + }); +});