515acb4868
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>
90 lines
3.4 KiB
TypeScript
90 lines
3.4 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
}
|