feat(f8): SidecarRouter — keyOf + per-document routing (spec §6.2)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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/<path>.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/<key>.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user