feat(f8): SidecarStore interface + GlobalSidecarStore (sha256 key, untitled in-memory)

Spec §6.2/§6.4: out-of-workspace file: → <globalStorage>/sidecars/<sha256(uri)>.json
(INV-19/24); untitled: → in-memory only (F6 degrade). update() carries the INV-16
newer-major throw + anchor prune; consumeSelfWrite is a no-op (outside the .threads
watcher). Mirrors src/baselineStore.ts; vscode-free, unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-11 13:06:08 -07:00
parent 568139d548
commit 515acb4868
3 changed files with 227 additions and 0 deletions
+89
View File
@@ -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 `<dir>/sidecars/<sha256(uri)>.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<string, Artifact>();
/** @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<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(key, artifact);
return artifact;
}
/** No-op: global artifacts are outside the `.threads/` watcher. */
consumeSelfWrite(_fsPath: string): boolean {
return false;
}
}
+35
View File
@@ -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;
}