From dca12f4f503ef547f3756555c2dd441d459d3d2f Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 13:08:01 -0700 Subject: [PATCH] =?UTF-8?q?feat(f8):=20SidecarRouter=20=E2=80=94=20keyOf?= =?UTF-8?q?=20+=20per-document=20routing=20(spec=20=C2=A76.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes by #24's isUnderRoot: in-workspace file: → CoauthorStore (repo-relative key, .threads/), out-of-folder file: + untitled: → GlobalSidecarStore (URI-string key). keyOf is the single document identity. vscode-free; CoauthorStore conforms structurally (unmodified, INV-2). Co-Authored-By: Claude Opus 4.8 --- src/sidecarRouter.ts | 89 ++++++++++++++++++++++++++++++++++++++ test/sidecarRouter.test.ts | 89 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 src/sidecarRouter.ts create mode 100644 test/sidecarRouter.test.ts diff --git a/src/sidecarRouter.ts b/src/sidecarRouter.ts new file mode 100644 index 0000000..dac326a --- /dev/null +++ b/src/sidecarRouter.ts @@ -0,0 +1,89 @@ +/** + * SidecarRouter — the per-document persistence façade the three authoring + * controllers depend on (F8 spec §6.2/§6.4). Implements SidecarStore and OWNS + * the routing: it computes the single document key (keyOf) and dispatches + * load/save/update/consumeSelfWrite/sidecarPath to the right implementation: + * - an in-workspace `file:` doc → CoauthorStore, keyed by its repo-relative + * path → `.threads/.json` (committable, INV-2; the only home F5's + * cross-rung ever sees). + * - an out-of-workspace `file:` or `untitled:` doc → GlobalSidecarStore, keyed + * by its URI string → global storage (INV-19/24/25; untitled in-memory). + * The membership predicate is `#24`'s isUnderRoot — here a ROUTING input, never + * an authoring gate (that became isAuthorable). vscode-free (takes the extracted + * identity, not a vscode.TextDocument) so it unit-tests. + */ +import * as path from "node:path"; +import { isUnderRoot } from "./workspacePath"; +import type { Artifact } from "./model"; +import type { SidecarStore } from "./sidecarStore"; + +/** The minimal document identity the router routes on (extracted from a TextDocument). */ +export interface DocIdentity { + /** `document.uri.toString()` — the URI string. */ + uri: string; + /** `document.uri.fsPath`. */ + fsPath: string; + /** `document.uri.scheme`. */ + scheme: string; +} + +/** A SidecarStore that can also report a sidecar's on-disk path (CoauthorStore, GlobalSidecarStore). */ +type LocatableStore = SidecarStore & { sidecarPath(key: string): string | undefined }; + +/** Extract the routing identity from anything URI-shaped (a real vscode.TextDocument fits). */ +export function docIdentity(doc: { uri: { toString(): string; fsPath: string; scheme: string } }): DocIdentity { + return { uri: doc.uri.toString(), fsPath: doc.uri.fsPath, scheme: doc.uri.scheme }; +} + +export class SidecarRouter implements SidecarStore { + constructor( + private readonly repo: LocatableStore | null, + private readonly global: LocatableStore, + private readonly root: string | undefined, + ) {} + + /** + * The single document key: an in-workspace `file:` doc → its repo-relative + * path (cross-rung-meaningful, the existing sidecar key, INV-2); everything + * else → its URI string (machine-local, self-consistent — INV-25). + */ + keyOf(id: DocIdentity): string { + if (id.scheme === "file" && this.root !== undefined && isUnderRoot(id.fsPath, this.root)) { + return path.relative(this.root, id.fsPath); + } + return id.uri; + } + + /** A key is a global (URI-string) key iff it carries a URI scheme prefix. */ + private isGlobalKey(key: string): boolean { + return key.startsWith("file://") || key.startsWith("untitled:"); + } + private storeFor(key: string): LocatableStore { + if (this.isGlobalKey(key) || this.repo === null) return this.global; + return this.repo; + } + + load(key: string): Artifact | null { + return this.storeFor(key).load(key); + } + save(key: string, artifact: Artifact): void { + this.storeFor(key).save(key, artifact); + } + update(key: string, mutate: (artifact: Artifact) => void): Artifact { + return this.storeFor(key).update(key, mutate); + } + /** Only the repo store self-writes (the `.threads/` watcher); global is a no-op. */ + consumeSelfWrite(fsPath: string): boolean { + return this.repo?.consumeSelfWrite(fsPath) ?? false; + } + + /** + * The sidecar's on-disk path: `.threads/.json` for a repo key, the hashed + * global path for an out-of-folder `file:` key, undefined for untitled. Used by + * the controllers' external-change handler (only repo sidecars are watched) and + * by the E2E to assert the storage home. + */ + sidecarPath(key: string): string | undefined { + return this.storeFor(key).sidecarPath(key); + } +} diff --git a/test/sidecarRouter.test.ts b/test/sidecarRouter.test.ts new file mode 100644 index 0000000..d3e0fe2 --- /dev/null +++ b/test/sidecarRouter.test.ts @@ -0,0 +1,89 @@ +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 { CoauthorStore } from "../src/store"; +import { GlobalSidecarStore } from "../src/globalSidecarStore"; +import { SidecarRouter, docIdentity } from "../src/sidecarRouter"; +import { emptyArtifact } from "../src/model"; + +let root: string; +let globalDir: string; +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "router-root-")); + globalDir = fs.mkdtempSync(path.join(os.tmpdir(), "router-global-")); +}); +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(globalDir, { recursive: true, force: true }); +}); + +function makeRouter(withRoot = true): SidecarRouter { + return new SidecarRouter( + withRoot ? new CoauthorStore(root) : null, + new GlobalSidecarStore(globalDir), + withRoot ? root : undefined, + ); +} +// minimal duck-typed stand-in for a vscode.TextDocument's identity inputs +const docOf = (uriString: string, fsPath: string, scheme: string) => ({ + uri: { toString: () => uriString, fsPath, scheme }, +}); +const hash = (uri: string) => createHash("sha256").update(uri).digest("hex"); + +describe("SidecarRouter.keyOf — one identity per document", () => { + it("an in-root file: doc → its repo-relative path", () => { + const fsPath = path.join(root, "docs", "x.md"); + const key = makeRouter().keyOf(docIdentity(docOf(`file://${fsPath}`, fsPath, "file"))); + expect(key).toBe(path.join("docs", "x.md")); + }); + it("an out-of-root file: doc → its URI string", () => { + const fsPath = "/elsewhere/y.md"; + const uri = `file://${fsPath}`; + expect(makeRouter().keyOf(docIdentity(docOf(uri, fsPath, "file")))).toBe(uri); + }); + it("an untitled: doc → its URI string", () => { + expect(makeRouter().keyOf(docIdentity(docOf("untitled:Untitled-1", "Untitled-1", "untitled")))).toBe( + "untitled:Untitled-1", + ); + }); + it("root undefined (no folder) → everything is the URI string", () => { + const fsPath = "/anything/z.md"; + const uri = `file://${fsPath}`; + expect(makeRouter(false).keyOf(docIdentity(docOf(uri, fsPath, "file")))).toBe(uri); + }); +}); + +describe("SidecarRouter routing — load/save/update dispatch by key home", () => { + it("an in-root key writes to the repo .threads/ sidecar, not global storage", () => { + const router = makeRouter(); + const key = path.join("docs", "x.md"); + router.save(key, emptyArtifact(key)); + expect(fs.existsSync(path.join(root, ".threads", "docs", "x.md.json"))).toBe(true); + expect(fs.existsSync(path.join(globalDir, "sidecars"))).toBe(false); + expect(router.sidecarPath(key)).toBe(path.join(root, ".threads", "docs", "x.md.json")); + }); + it("an out-of-root file: key writes to global storage, not the repo", () => { + const router = makeRouter(); + const key = "file:///elsewhere/y.md"; + router.save(key, emptyArtifact(key)); + expect(fs.existsSync(path.join(globalDir, "sidecars", `${hash(key)}.json`))).toBe(true); + expect(fs.existsSync(path.join(root, ".threads"))).toBe(false); + expect(router.sidecarPath(key)).toBe(path.join(globalDir, "sidecars", `${hash(key)}.json`)); + }); + it("an untitled: key is in-memory (global store), no disk anywhere", () => { + const router = makeRouter(); + const key = "untitled:Untitled-1"; + router.update(key, (a) => a.threads.push({ id: "t", anchorId: "an", status: "open", messages: [] })); + expect(router.load(key)!.threads.length).toBe(1); + expect(router.sidecarPath(key)).toBeUndefined(); + expect(fs.existsSync(path.join(globalDir, "sidecars"))).toBe(false); + }); + it("round-trips through load after update on an in-root key", () => { + const router = makeRouter(); + const key = path.join("docs", "x.md"); + router.update(key, (a) => a.threads.push({ id: "t", anchorId: "an", status: "open", messages: [] })); + expect(router.load(key)!.threads.length).toBe(1); + }); +});