test(dataflow): add integration test fixtures for orchestrator

Phase 0 W0 of the v10 refactor: introduce a buildOrchestrator()
helper that boots a real DataflowOrchestrator with mock App/Vault/
MetadataCache, plus an InMemoryStorage double and a localforage
jest mock so the cache layer can construct under jsdom.

This is the foundation that subsequent stability work (lifecycle
hazard fix, worker timeout, cache invariants, migration tombstones,
critical-path integration tests) depends on. Smoke tests verify
fixture construction, vault event plumbing, and clean dispose.

12/12 smoke tests pass; existing suite unchanged.
This commit is contained in:
Quorafind 2026-04-07 09:19:08 +08:00
parent ee821b0625
commit dd3fe89a9f
5 changed files with 958 additions and 0 deletions

View file

@ -7,6 +7,7 @@ module.exports = {
moduleNameMapper: {
"^obsidian$": "<rootDir>/src/__mocks__/obsidian.ts",
"^moment$": "<rootDir>/src/__mocks__/moment.js",
"^localforage$": "<rootDir>/src/__mocks__/localforage.ts",
"^@codemirror/state$": "<rootDir>/src/__mocks__/codemirror-state.ts",
"^@codemirror/view$": "<rootDir>/src/__mocks__/codemirror-view.ts",
"^@codemirror/language$":

View file

@ -0,0 +1,120 @@
/**
* In-memory mock for `localforage` used in jest tests.
*
* The real localforage requires a browser IndexedDB or localStorage runtime
* which jsdom does not reliably provide. This mock implements the minimum
* surface area used by `src/cache/local-storage-cache.ts`:
*
* localforage.createInstance({name})
* localforage.dropInstance({name})
* localforage.INDEXEDDB / LOCALSTORAGE / WEBSQL (driver constants)
*
* Each instance backs its data in a Map keyed by `${name}::${key}`. State is
* shared across instances with the same name (matching real localforage
* semantics) and persists for the lifetime of the test process.
*
* To reset state between tests, call `(localforage as any).__resetAll()`.
*/
const stores = new Map<string, Map<string, any>>();
function getStore(name: string): Map<string, any> {
let store = stores.get(name);
if (!store) {
store = new Map();
stores.set(name, store);
}
return store;
}
interface MockInstanceOptions {
name: string;
driver?: any;
description?: string;
storeName?: string;
}
function createInstance(options: MockInstanceOptions) {
const name = options.name;
const store = getStore(name);
return {
__name: name,
async setItem<T>(key: string, value: T): Promise<T> {
store.set(key, value);
return value;
},
async getItem<T>(key: string): Promise<T | null> {
return store.has(key) ? (store.get(key) as T) : null;
},
async removeItem(key: string): Promise<void> {
store.delete(key);
},
async clear(): Promise<void> {
store.clear();
},
async length(): Promise<number> {
return store.size;
},
async keys(): Promise<string[]> {
return [...store.keys()];
},
async iterate<T>(
fn: (value: T, key: string, iterationNumber: number) => any,
): Promise<void> {
let i = 0;
for (const [k, v] of store.entries()) {
const result = fn(v as T, k, ++i);
if (result !== undefined) return;
}
},
// localforage instances expose ready() returning a resolved promise.
async ready(): Promise<void> {},
// Driver introspection no-ops.
driver(): string {
return "MOCK";
},
setDriver(): Promise<void> {
return Promise.resolve();
},
// Configuration is a no-op in the mock.
config(): boolean {
return true;
},
};
}
async function dropInstance(options: { name: string }): Promise<void> {
stores.delete(options.name);
}
const localforage = {
createInstance,
dropInstance,
// Driver constants — values don't matter, only that they're truthy strings.
INDEXEDDB: "asyncStorage",
LOCALSTORAGE: "localStorageWrapper",
WEBSQL: "webSQLStorage",
// Module-level shortcuts (rarely used by our code, but localforage exports them).
async setItem<T>(key: string, value: T): Promise<T> {
return createInstance({ name: "__default__" }).setItem(key, value);
},
async getItem<T>(key: string): Promise<T | null> {
return createInstance({ name: "__default__" }).getItem<T>(key);
},
async removeItem(key: string): Promise<void> {
return createInstance({ name: "__default__" }).removeItem(key);
},
async clear(): Promise<void> {
return createInstance({ name: "__default__" }).clear();
},
// Test helper: clear all instances. NOT part of real localforage.
__resetAll(): void {
stores.clear();
},
__instanceCount(): number {
return stores.size;
},
};
export default localforage;

View file

@ -0,0 +1,165 @@
/**
* Smoke test for the buildOrchestrator fixture itself.
*
* This is W0 in the v10 Phase 0 plan: prove the fixture constructs a real
* DataflowOrchestrator with mock dependencies, exposes the documented API,
* and disposes cleanly. Subsequent integration tests (W5) build on top.
*
* This is intentionally NOT a test of orchestrator behavior just plumbing.
*/
import { buildOrchestrator } from "./buildOrchestrator";
import { InMemoryStorage } from "./inMemoryStorage";
describe("buildOrchestrator fixture (W0 smoke)", () => {
it("constructs an orchestrator with no files", async () => {
const fx = await buildOrchestrator();
try {
expect(fx.orchestrator).toBeDefined();
expect(fx.plugin).toBeDefined();
expect(fx.plugin.dataflowOrchestrator).toBe(fx.orchestrator);
expect(fx.app.appId).toMatch(/^test-app-/);
expect(fx.vault.getMarkdownFiles()).toHaveLength(0);
} finally {
await fx.dispose();
}
});
it("seeds vault files from the files option", async () => {
const fx = await buildOrchestrator({
files: {
"notes/a.md": "- [ ] task one #x",
"notes/b.md": "- [x] task two",
"notes/c.txt": "not markdown",
},
});
try {
const md = fx.vault.getMarkdownFiles();
expect(md.map((f) => f.path).sort()).toEqual([
"notes/a.md",
"notes/b.md",
]);
expect(await fx.vault.read({ path: "notes/a.md" })).toBe(
"- [ ] task one #x",
);
} finally {
await fx.dispose();
}
});
it("merges settings overrides onto DEFAULT_SETTINGS", async () => {
const fx = await buildOrchestrator({
settings: { preferMetadataFormat: "dataview" },
});
try {
expect(fx.plugin.settings.preferMetadataFormat).toBe("dataview");
// Other defaults still present
expect(fx.plugin.settings.taskStatuses).toBeDefined();
} finally {
await fx.dispose();
}
});
it("writeFile triggers vault modify event", async () => {
const fx = await buildOrchestrator({
files: { "a.md": "- [ ] one" },
});
try {
const events: string[] = [];
fx.vault.on("modify", (file: any) => {
events.push(`modify:${file.path}`);
});
await fx.writeFile("a.md", "- [x] one");
expect(events).toEqual(["modify:a.md"]);
expect(await fx.vault.read({ path: "a.md" })).toBe("- [x] one");
} finally {
await fx.dispose();
}
});
it("createFile triggers vault create event", async () => {
const fx = await buildOrchestrator();
try {
const events: string[] = [];
fx.vault.on("create", (file: any) => {
events.push(`create:${file.path}`);
});
await fx.createFile("new.md", "- [ ] new");
expect(events).toEqual(["create:new.md"]);
} finally {
await fx.dispose();
}
});
it("deleteFile triggers vault delete event", async () => {
const fx = await buildOrchestrator({ files: { "doomed.md": "x" } });
try {
const events: string[] = [];
fx.vault.on("delete", (file: any) => {
events.push(`delete:${file.path}`);
});
await fx.deleteFile("doomed.md");
expect(events).toEqual(["delete:doomed.md"]);
expect(fx.vault.getFileByPath("doomed.md")).toBeNull();
} finally {
await fx.dispose();
}
});
it("dispose calls orchestrator.cleanup() and does not throw", async () => {
const fx = await buildOrchestrator();
// Spy on orchestrator.cleanup to make sure dispose actually invokes it.
const spy = jest.spyOn(fx.orchestrator, "cleanup");
await fx.dispose();
expect(spy).toHaveBeenCalledTimes(1);
expect(fx.plugin.dataflowOrchestrator).toBeUndefined();
spy.mockRestore();
});
it("can call dispose without files or initialization", async () => {
const fx = await buildOrchestrator();
await expect(fx.dispose()).resolves.not.toThrow();
});
});
describe("InMemoryStorage (W0)", () => {
it("stores and retrieves raw records", async () => {
const s = new InMemoryStorage();
await s.storeRaw("a.md", [], "content", 100);
const r = await s.loadRaw("a.md");
expect(r).not.toBeNull();
expect(r?.mtime).toBe(100);
expect(r?.data).toEqual([]);
});
it("clearNamespace only clears matching prefix", async () => {
const s = new InMemoryStorage();
await s.storeRaw("a.md", []);
await s.storeAugmented("a.md", []);
await s.storeProject("a.md", { enhancedMetadata: {} });
const before = await s.getStats();
expect(before.byNamespace.raw).toBe(1);
expect(before.byNamespace.augmented).toBe(1);
expect(before.byNamespace.project).toBe(1);
await s.clearNamespace("raw");
const after = await s.getStats();
expect(after.byNamespace.raw).toBe(0);
expect(after.byNamespace.augmented).toBe(1);
expect(after.byNamespace.project).toBe(1);
});
it("version mismatch on read invalidates the record", async () => {
const s = new InMemoryStorage("1.0.0");
await s.storeRaw("a.md", []);
expect(await s.loadRaw("a.md")).not.toBeNull();
s.__setVersion("2.0.0");
expect(await s.loadRaw("a.md")).toBeNull();
});
it("meta save/load round-trips", async () => {
const s = new InMemoryStorage();
await s.saveMeta("k", { foo: 1 });
expect(await s.loadMeta("k")).toEqual({ foo: 1 });
});
});

View file

@ -0,0 +1,419 @@
/**
* Integration test fixture for booting a real DataflowOrchestrator
* with mock Obsidian app/vault/metadataCache and a fake plugin instance.
*
* Used by Phase 0 stability tests (lifecycle, settings change, worker fallback,
* cache invariants, migration tombstones). The fixture is intentionally narrow:
* provide files and settings, get back an orchestrator + dispose.
*
* Construction is lazy workers do not spawn until the orchestrator's
* `initialize()` is called or a query/process method runs. This means a fixture
* that only tests cleanup paths can avoid pulling worker construction into the
* test environment.
*/
import { DataflowOrchestrator } from "@/dataflow/Orchestrator";
import { DEFAULT_SETTINGS } from "@/common/setting-definition";
import type { TaskProgressBarSettings } from "@/common/setting-definition";
export interface VaultFile {
path: string;
content: string;
mtime?: number;
extension?: string;
}
export interface BuildOrchestratorOptions {
/** Map of path → file content (or full VaultFile records). */
files?: Record<string, string> | VaultFile[];
/** Partial settings overrides merged onto DEFAULT_SETTINGS. */
settings?: Partial<TaskProgressBarSettings>;
/** Plugin manifest version. Defaults to "test-1.0.0". */
version?: string;
/** Whether to call orchestrator.initialize() after construction. Default false. */
initialize?: boolean;
}
export interface OrchestratorFixture {
orchestrator: DataflowOrchestrator;
plugin: FakePlugin;
app: FakeApp;
vault: FakeVault;
metadataCache: FakeMetadataCache;
/**
* Update the contents of a file and trigger a vault `modify` event.
* Returns a promise that resolves once vault listeners have been notified.
*/
writeFile(path: string, content: string): Promise<void>;
/** Delete a file and trigger a vault `delete` event. */
deleteFile(path: string): Promise<void>;
/** Create a new file and trigger a vault `create` event. */
createFile(path: string, content: string): Promise<void>;
/**
* Tear down the orchestrator. Awaits all async cleanup. Tests that need
* to assert no leaked timers should wrap the test in jest.useFakeTimers()
* and check `jest.getTimerCount() === 0` AFTER dispose returns.
*/
dispose(): Promise<void>;
}
// ---------------------------------------------------------------------------
// Minimal fakes — only what Orchestrator and its sub-components actually call.
// ---------------------------------------------------------------------------
class FakeTFile {
path: string;
name: string;
basename: string;
extension: string;
stat: { mtime: number; ctime: number; size: number };
parent: any = null;
constructor(path: string, content: string, mtime: number = Date.now()) {
this.path = path;
const segs = path.split("/");
this.name = segs[segs.length - 1];
const dot = this.name.lastIndexOf(".");
this.basename = dot >= 0 ? this.name.substring(0, dot) : this.name;
this.extension = dot >= 0 ? this.name.substring(dot + 1) : "";
this.stat = { mtime, ctime: mtime, size: content.length };
}
}
class EventBus {
private handlers = new Map<string, Set<(...args: any[]) => void>>();
on(name: string, handler: (...args: any[]) => void) {
if (!this.handlers.has(name)) this.handlers.set(name, new Set());
this.handlers.get(name)!.add(handler);
return { name, handler } as any;
}
off(name: string, handler: (...args: any[]) => void) {
this.handlers.get(name)?.delete(handler);
}
offref(ref: any) {
if (ref && ref.name && ref.handler) this.off(ref.name, ref.handler);
}
trigger(name: string, ...args: any[]) {
const set = this.handlers.get(name);
if (!set) return;
for (const h of [...set]) {
try {
h(...args);
} catch (e) {
// Don't let one listener break the others; surface in console for debugging.
// eslint-disable-next-line no-console
console.error(`[buildOrchestrator] listener for ${name} threw:`, e);
}
}
}
clear() {
this.handlers.clear();
}
listenerCount(): number {
let n = 0;
for (const s of this.handlers.values()) n += s.size;
return n;
}
}
export class FakeVault {
private fileMap = new Map<string, FakeTFile>();
private contentMap = new Map<string, string>();
private bus = new EventBus();
configDir = ".obsidian";
constructor(initialFiles?: Record<string, string> | VaultFile[]) {
if (Array.isArray(initialFiles)) {
for (const f of initialFiles) this.__seed(f.path, f.content, f.mtime);
} else if (initialFiles) {
for (const [path, content] of Object.entries(initialFiles)) {
this.__seed(path, content);
}
}
}
__seed(path: string, content: string, mtime?: number): FakeTFile {
const file = new FakeTFile(path, content, mtime ?? Date.now());
this.fileMap.set(path, file);
this.contentMap.set(path, content);
return file;
}
getMarkdownFiles(): FakeTFile[] {
return [...this.fileMap.values()].filter((f) => f.extension === "md");
}
getFiles(): FakeTFile[] {
return [...this.fileMap.values()];
}
getAbstractFileByPath(path: string): FakeTFile | null {
return this.fileMap.get(path) ?? null;
}
getFileByPath(path: string): FakeTFile | null {
return this.fileMap.get(path) ?? null;
}
async read(file: FakeTFile | { path: string }): Promise<string> {
return this.contentMap.get(file.path) ?? "";
}
async cachedRead(file: FakeTFile | { path: string }): Promise<string> {
return this.contentMap.get(file.path) ?? "";
}
async modify(file: FakeTFile | { path: string }, content: string): Promise<void> {
this.contentMap.set(file.path, content);
const real = this.fileMap.get(file.path);
if (real) {
real.stat.mtime = Date.now();
real.stat.size = content.length;
this.bus.trigger("modify", real);
}
}
async create(path: string, content: string): Promise<FakeTFile> {
const f = this.__seed(path, content);
this.bus.trigger("create", f);
return f;
}
async delete(file: FakeTFile | { path: string }): Promise<void> {
const real = this.fileMap.get(file.path);
this.fileMap.delete(file.path);
this.contentMap.delete(file.path);
if (real) this.bus.trigger("delete", real);
}
async rename(file: FakeTFile, newPath: string): Promise<void> {
const oldPath = file.path;
const content = this.contentMap.get(oldPath) ?? "";
this.fileMap.delete(oldPath);
this.contentMap.delete(oldPath);
const renamed = this.__seed(newPath, content);
this.bus.trigger("rename", renamed, oldPath);
}
on(name: string, handler: (...args: any[]) => void) {
return this.bus.on(name, handler);
}
off(name: string, handler: (...args: any[]) => void) {
this.bus.off(name, handler);
}
offref(ref: any) {
this.bus.offref(ref);
}
trigger(name: string, ...args: any[]) {
this.bus.trigger(name, ...args);
}
getConfig(key: string): any {
if (key === "tabSize") return 4;
if (key === "useTab") return false;
return null;
}
__listenerCount(): number {
return this.bus.listenerCount();
}
__bus(): EventBus {
return this.bus;
}
}
export class FakeMetadataCache {
private bus = new EventBus();
private cache = new Map<string, any>();
getFileCache(file: { path: string }): any {
return this.cache.get(file.path) ?? null;
}
getCache(path: string): any {
return this.cache.get(path) ?? null;
}
__set(path: string, value: any) {
this.cache.set(path, value);
}
on(name: string, handler: (...args: any[]) => void) {
return this.bus.on(name, handler);
}
off(name: string, handler: (...args: any[]) => void) {
this.bus.off(name, handler);
}
offref(ref: any) {
this.bus.offref(ref);
}
trigger(name: string, ...args: any[]) {
this.bus.trigger(name, ...args);
}
__listenerCount(): number {
return this.bus.listenerCount();
}
}
export class FakeApp {
appId = "test-app-" + Math.random().toString(36).slice(2);
vault: FakeVault;
metadataCache: FakeMetadataCache;
workspace: {
on: (n: string, h: (...args: any[]) => void) => any;
off: (n: string, h: (...args: any[]) => void) => void;
offref: (ref: any) => void;
trigger: (n: string, ...args: any[]) => void;
onLayoutReady: (cb: () => void) => void;
getActiveFile: () => any;
getLeaf: () => any;
__bus: EventBus;
__listenerCount: () => number;
};
fileManager = {
generateMarkdownLink: () => "[[link]]",
};
plugins = {
enabledPlugins: new Set<string>(),
plugins: {} as Record<string, any>,
};
constructor(vault: FakeVault, metadataCache: FakeMetadataCache) {
this.vault = vault;
this.metadataCache = metadataCache;
const bus = new EventBus();
this.workspace = {
on: (n, h) => bus.on(n, h),
off: (n, h) => bus.off(n, h),
offref: (ref) => bus.offref(ref),
trigger: (n, ...args) => bus.trigger(n, ...args),
onLayoutReady: (cb) => cb(),
getActiveFile: () => null,
getLeaf: () => ({ openFile: () => {} }),
__bus: bus,
__listenerCount: () => bus.listenerCount(),
};
}
__totalListenerCount(): number {
return (
this.workspace.__listenerCount() +
this.vault.__listenerCount() +
this.metadataCache.__listenerCount()
);
}
}
export interface FakePlugin {
app: FakeApp;
settings: TaskProgressBarSettings;
manifest: { id: string; name: string; version: string };
dataflowOrchestrator?: DataflowOrchestrator;
getIcsManager(): undefined;
saveSettings(): Promise<void>;
registerEvent(ref: any): void;
}
// ---------------------------------------------------------------------------
// Builder
// ---------------------------------------------------------------------------
export async function buildOrchestrator(
opts: BuildOrchestratorOptions = {},
): Promise<OrchestratorFixture> {
const vault = new FakeVault(opts.files);
const metadataCache = new FakeMetadataCache();
const app = new FakeApp(vault, metadataCache);
const settings: TaskProgressBarSettings = {
...(DEFAULT_SETTINGS as TaskProgressBarSettings),
...(opts.settings ?? {}),
};
const plugin: FakePlugin = {
app,
settings,
manifest: {
id: "task-genius-test",
name: "Task Genius (test)",
version: opts.version ?? "test-1.0.0",
},
getIcsManager: () => undefined,
saveSettings: async () => {},
registerEvent: () => {},
};
// Construct the real Orchestrator. Heavy sub-components (workers, ICS source
// retry loop, file source) are constructed lazily by their managers and only
// fully boot when `initialize()` is called or a parse path runs.
const orchestrator = new DataflowOrchestrator(
app as any,
vault as any,
metadataCache as any,
plugin,
);
plugin.dataflowOrchestrator = orchestrator;
if (opts.initialize) {
await orchestrator.initialize();
}
const fx: OrchestratorFixture = {
orchestrator,
plugin,
app,
vault,
metadataCache,
async writeFile(path, content) {
const existing = vault.getFileByPath(path);
if (existing) {
await vault.modify(existing, content);
} else {
await vault.create(path, content);
}
// Yield to microtasks so any awaiting subscribers progress.
await Promise.resolve();
},
async deleteFile(path) {
const f = vault.getFileByPath(path);
if (f) await vault.delete(f);
await Promise.resolve();
},
async createFile(path, content) {
await vault.create(path, content);
await Promise.resolve();
},
async dispose() {
try {
await orchestrator.cleanup();
} catch (e) {
// Surface unexpected cleanup errors so tests can react.
// eslint-disable-next-line no-console
console.error("[buildOrchestrator.dispose] cleanup threw:", e);
throw e;
} finally {
plugin.dataflowOrchestrator = undefined;
}
},
};
return fx;
}

View file

@ -0,0 +1,253 @@
/**
* In-memory Storage test double.
*
* Implements the public surface of `src/dataflow/persistence/Storage.ts` that the
* Orchestrator, Repository, and integration tests actually call. Backed by a Map,
* so cache tests don't need to touch IndexedDB / localStorage / localforage.
*
* Storage in production code is constructed internally by Repository and is not
* injectable. This double exists for tests that exercise the cache layer directly
* (e.g. cache invariants, scope-map verification) and for any future refactor
* that makes Storage injectable. Keep the surface narrow add methods only when
* a test needs them.
*/
import type { Task, TaskCache } from "../../../types/task";
import type {
RawRecord,
ProjectRecord,
AugmentedRecord,
ConsolidatedRecord,
} from "../../../dataflow/persistence/Storage";
type Namespace = "raw" | "project" | "augmented" | "consolidated";
const PREFIX: Record<Namespace, string> = {
raw: "tasks.raw:",
project: "project.data:",
augmented: "tasks.augmented:",
consolidated: "consolidated:",
};
const META_PREFIX = "meta:";
export class InMemoryStorage {
private map = new Map<string, any>();
private currentVersion: string;
private schemaVersion: number = 1;
constructor(version: string = "1.0.0") {
this.currentVersion = version;
}
// --- raw ---
async loadRaw(path: string): Promise<RawRecord | null> {
const rec = this.map.get(PREFIX.raw + path) as RawRecord | undefined;
if (!rec) return null;
if (!this.versionOk(rec)) {
this.map.delete(PREFIX.raw + path);
return null;
}
return rec;
}
async storeRaw(
path: string,
tasks: Task[],
fileContent?: string,
mtime?: number,
): Promise<void> {
const rec: RawRecord = {
hash: this.hash(fileContent || tasks),
time: Date.now(),
version: this.currentVersion,
schema: this.schemaVersion,
data: tasks,
mtime,
};
this.map.set(PREFIX.raw + path, rec);
}
isRawValid(
_path: string,
record: RawRecord,
fileContent?: string,
mtime?: number,
): boolean {
if (!this.versionOk(record)) return false;
if (mtime !== undefined && record.mtime !== undefined && record.mtime !== mtime)
return false;
if (fileContent && record.hash !== this.hash(fileContent)) return false;
return true;
}
// --- project ---
async loadProject(path: string): Promise<ProjectRecord | null> {
const rec = this.map.get(PREFIX.project + path) as ProjectRecord | undefined;
if (!rec) return null;
if (!this.versionOk(rec)) {
this.map.delete(PREFIX.project + path);
return null;
}
return rec;
}
async storeProject(
path: string,
data: { tgProject?: any; enhancedMetadata: Record<string, any> },
): Promise<void> {
const rec: ProjectRecord = {
hash: this.hash(data),
time: Date.now(),
version: this.currentVersion,
schema: this.schemaVersion,
data,
};
this.map.set(PREFIX.project + path, rec);
}
// --- augmented ---
async loadAugmented(path: string): Promise<AugmentedRecord | null> {
const rec = this.map.get(PREFIX.augmented + path) as AugmentedRecord | undefined;
if (!rec) return null;
if (!this.versionOk(rec)) {
this.map.delete(PREFIX.augmented + path);
return null;
}
return rec;
}
async storeAugmented(path: string, tasks: Task[]): Promise<void> {
const rec: AugmentedRecord = {
hash: this.hash(tasks),
time: Date.now(),
version: this.currentVersion,
schema: this.schemaVersion,
data: tasks,
};
this.map.set(PREFIX.augmented + path, rec);
}
// --- consolidated ---
async loadConsolidated(): Promise<ConsolidatedRecord | null> {
const rec = this.map.get(PREFIX.consolidated + "taskIndex") as
| ConsolidatedRecord
| undefined;
if (!rec) return null;
if (!this.versionOk(rec)) {
this.map.delete(PREFIX.consolidated + "taskIndex");
return null;
}
return rec;
}
async storeConsolidated(taskCache: TaskCache): Promise<void> {
const rec: ConsolidatedRecord = {
time: Date.now(),
version: this.currentVersion,
schema: this.schemaVersion,
data: taskCache,
};
this.map.set(PREFIX.consolidated + "taskIndex", rec);
}
// --- meta ---
async saveMeta<T = any>(key: string, value: T): Promise<void> {
this.map.set(META_PREFIX + key, value);
}
async loadMeta<T = any>(key: string): Promise<T | null> {
return (this.map.get(META_PREFIX + key) as T) ?? null;
}
// --- listing / lifecycle ---
async listRawPaths(): Promise<string[]> {
const out: string[] = [];
for (const k of this.map.keys()) {
if (k.startsWith(PREFIX.raw)) out.push(k.substring(PREFIX.raw.length));
}
return out;
}
async listAugmentedPaths(): Promise<string[]> {
const out: string[] = [];
for (const k of this.map.keys()) {
if (k.startsWith(PREFIX.augmented))
out.push(k.substring(PREFIX.augmented.length));
}
return out;
}
async clearFile(path: string): Promise<void> {
this.map.delete(PREFIX.raw + path);
this.map.delete(PREFIX.project + path);
this.map.delete(PREFIX.augmented + path);
}
async clear(): Promise<void> {
this.map.clear();
}
async clearNamespace(namespace: Namespace): Promise<void> {
const prefix = PREFIX[namespace];
const toDelete: string[] = [];
for (const k of this.map.keys()) {
if (k.startsWith(prefix)) toDelete.push(k);
}
for (const k of toDelete) this.map.delete(k);
}
async getStats(): Promise<{
totalKeys: number;
byNamespace: Record<string, number>;
}> {
const byNamespace: Record<string, number> = {
raw: 0,
project: 0,
augmented: 0,
consolidated: 0,
meta: 0,
};
for (const k of this.map.keys()) {
if (k.startsWith(PREFIX.raw)) byNamespace.raw++;
else if (k.startsWith(PREFIX.project)) byNamespace.project++;
else if (k.startsWith(PREFIX.augmented)) byNamespace.augmented++;
else if (k.startsWith(PREFIX.consolidated)) byNamespace.consolidated++;
else if (k.startsWith(META_PREFIX)) byNamespace.meta++;
}
return { totalKeys: this.map.size, byNamespace };
}
// --- test helpers (not on real Storage) ---
/** Direct access to underlying map for invariant checks. */
__inspect(): ReadonlyMap<string, any> {
return this.map;
}
__setVersion(version: string): void {
this.currentVersion = version;
}
// --- private ---
private versionOk(rec: { version?: string; schema?: number }): boolean {
return rec.version === this.currentVersion && rec.schema === this.schemaVersion;
}
private hash(content: any): string {
const str = JSON.stringify(content);
let h = 0;
for (let i = 0; i < str.length; i++) {
h = (h << 5) - h + str.charCodeAt(i);
h = h & h;
}
return Math.abs(h).toString(16);
}
}