F8: out-of-workspace authoring — hybrid sidecar persistence (#25) #26
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 <dir>/sidecars/<sha256(uri)>.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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user