mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
feat(dataflow): typed cache scope map + invariants checker (Phase 0 W4)
W4a — Typed cache scope map Adds src/dataflow/cache/scope-map.ts: a single source of truth mapping settings field paths to the cache scopes they invalidate. Field paths are dot-separated and match by longest prefix, so callers can pass either a parent path or a leaf without knowing the granularity. The map currently covers every field that existing onSettingsChange call sites refer to (only fileMetadataInheritance.* in IndexSettingsTab — which incidentally was passing the wrong scope for years; the map records the correct one for Phase 1's migration to fix). Adds Orchestrator.onSettingsFieldsChanged(fields) as a sibling of the existing onSettingsChange(scopes). Phase 0 ships the typed entry point but does NOT migrate any callers — Phase 1+ switches them progressively as features are touched. The legacy method is kept as the single delegation target so behavior is unchanged. W4b — LocalStorageCache version-mismatch read fix local-storage-cache.ts:91 had a half-implemented version check: storeFile() was tagging entries with currentVersion, but loadFile() never validated it. Plugin upgrades did NOT auto-invalidate stale wrapper-level cache entries — the inner Storage records had their own version validation, but the outer wrapper layer was dead code. Now loadFile checks the version, returns null on mismatch, and prunes the stale entry. W4b — Cache invariants checker Adds src/dataflow/cache/invariants.ts with checkCacheInvariants(orch), a debug-mode safety net that walks Storage namespaces and reports violations of four invariants: I1 — every raw entry has an augmented counterpart I2 — every cache record's version matches plugin currentVersion I3 — augmented namespace agrees with in-memory indexer file set I4 — getStats() namespace counts are sane and additive Never throws; returns a typed report with ok/violations/stats. W5.4 will exercise it more thoroughly across realistic operations. 26/26 W4 tests pass.
This commit is contained in:
parent
1de55f4ca6
commit
fe4c976f6e
8 changed files with 850 additions and 1 deletions
80
src/__tests__/cache/local-storage-cache.version.test.ts
vendored
Normal file
80
src/__tests__/cache/local-storage-cache.version.test.ts
vendored
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Phase 0 W4b — LocalStorageCache version-mismatch read fix.
|
||||
*
|
||||
* Prior to W4b, storeFile() tagged entries with `currentVersion` but loadFile()
|
||||
* never validated it on read. After an upgrade, every stale entry would be
|
||||
* happily returned. This test asserts:
|
||||
* - write at v1.0.0, read at v1.0.0 → returns the entry
|
||||
* - write at v1.0.0, read at v2.0.0 → returns null (treated as cache miss)
|
||||
* - the stale entry is removed from the underlying store on the mismatched read
|
||||
*/
|
||||
|
||||
import { LocalStorageCache } from "@/cache/local-storage-cache";
|
||||
|
||||
describe("LocalStorageCache version-mismatch read (W4b)", () => {
|
||||
beforeEach(() => {
|
||||
// Reset the static "logged once" flag between tests so each test
|
||||
// independently exercises the log path.
|
||||
(LocalStorageCache as any)._versionMismatchLogged = false;
|
||||
});
|
||||
|
||||
it("returns entries written at the same version", async () => {
|
||||
const cache = new LocalStorageCache("test-app-1", "1.0.0");
|
||||
await cache.storeFile("foo.md", { value: 42 });
|
||||
const loaded = await cache.loadFile<{ value: number }>("foo.md");
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded?.data.value).toBe(42);
|
||||
expect(loaded?.version).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("returns null when the cache entry was written at a different version", async () => {
|
||||
const oldCache = new LocalStorageCache("test-app-2", "1.0.0");
|
||||
await oldCache.storeFile("foo.md", { value: 42 });
|
||||
|
||||
// New cache instance with bumped version pointing at the SAME backing
|
||||
// store (the localforage mock keys by `name` which is appId-derived).
|
||||
const newCache = new LocalStorageCache("test-app-2", "2.0.0");
|
||||
const loaded = await newCache.loadFile("foo.md");
|
||||
expect(loaded).toBeNull();
|
||||
});
|
||||
|
||||
it("removes the stale entry on a mismatched read", async () => {
|
||||
const oldCache = new LocalStorageCache("test-app-3", "1.0.0");
|
||||
await oldCache.storeFile("foo.md", { value: 42 });
|
||||
|
||||
const newCache = new LocalStorageCache("test-app-3", "2.0.0");
|
||||
// First read: triggers prune
|
||||
await newCache.loadFile("foo.md");
|
||||
|
||||
// Second read: should still be a miss (the entry was removed, not just
|
||||
// returned null on the fly).
|
||||
const persister: any = (newCache as any).persister;
|
||||
const raw = await persister.getItem(newCache.fileKey("foo.md"));
|
||||
expect(raw).toBeNull();
|
||||
});
|
||||
|
||||
it("prunes multiple stale entries on subsequent mismatched reads", async () => {
|
||||
// We don't assert log noise because the log-once optimization uses a
|
||||
// process-wide static field that's hard to reason about across test
|
||||
// boundaries — what matters is that every mismatched read independently
|
||||
// returns null and prunes its entry.
|
||||
const oldCache = new LocalStorageCache("test-app-4", "1.0.0");
|
||||
await oldCache.storeFile("a.md", { v: 1 });
|
||||
await oldCache.storeFile("b.md", { v: 2 });
|
||||
await oldCache.storeFile("c.md", { v: 3 });
|
||||
|
||||
const newCache = new LocalStorageCache("test-app-4", "2.0.0");
|
||||
const a = await newCache.loadFile("a.md");
|
||||
const b = await newCache.loadFile("b.md");
|
||||
const c = await newCache.loadFile("c.md");
|
||||
|
||||
expect(a).toBeNull();
|
||||
expect(b).toBeNull();
|
||||
expect(c).toBeNull();
|
||||
|
||||
const persister: any = (newCache as any).persister;
|
||||
expect(await persister.getItem(newCache.fileKey("a.md"))).toBeNull();
|
||||
expect(await persister.getItem(newCache.fileKey("b.md"))).toBeNull();
|
||||
expect(await persister.getItem(newCache.fileKey("c.md"))).toBeNull();
|
||||
});
|
||||
});
|
||||
151
src/__tests__/cache/scope-map.test.ts
vendored
Normal file
151
src/__tests__/cache/scope-map.test.ts
vendored
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* Phase 0 W4a — typed cache scope map.
|
||||
*
|
||||
* Asserts:
|
||||
* 1. Direct field lookups resolve to the documented scopes.
|
||||
* 2. Nested field paths walk parents correctly.
|
||||
* 3. Unknown fields contribute no scopes.
|
||||
* 4. Multiple fields union their scopes.
|
||||
* 5. The map covers every field that current call sites of
|
||||
* Orchestrator.onSettingsChange refer to (ratchet test — fails if a
|
||||
* caller is added without updating the map).
|
||||
*/
|
||||
|
||||
import {
|
||||
scopesForFields,
|
||||
listMappedFields,
|
||||
SETTINGS_FIELD_TO_SCOPES,
|
||||
type CacheScope,
|
||||
} from "@/dataflow/cache/scope-map";
|
||||
|
||||
describe("scope-map (W4a)", () => {
|
||||
describe("direct lookups", () => {
|
||||
it("taskStatuses → parser", () => {
|
||||
expect(scopesForFields(["taskStatuses"])).toEqual(["parser"]);
|
||||
});
|
||||
|
||||
it("projectConfig → project + augment", () => {
|
||||
const scopes = scopesForFields(["projectConfig"]);
|
||||
expect(new Set(scopes)).toEqual(new Set(["project", "augment"]));
|
||||
});
|
||||
|
||||
it("fileMetadataInheritance → augment", () => {
|
||||
expect(scopesForFields(["fileMetadataInheritance"])).toEqual([
|
||||
"augment",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nested field paths walk parents", () => {
|
||||
it("fileMetadataInheritance.inheritFromFrontmatter resolves via parent", () => {
|
||||
expect(
|
||||
scopesForFields([
|
||||
"fileMetadataInheritance.inheritFromFrontmatter",
|
||||
]),
|
||||
).toEqual(["augment"]);
|
||||
});
|
||||
|
||||
it("fileSource.recognitionStrategies.tags.taskTags resolves via parent", () => {
|
||||
expect(
|
||||
scopesForFields([
|
||||
"fileSource.recognitionStrategies.tags.taskTags",
|
||||
]),
|
||||
).toEqual(["parser"]);
|
||||
});
|
||||
|
||||
it("projectConfig.pathMappings.foo resolves via parent", () => {
|
||||
const scopes = scopesForFields([
|
||||
"projectConfig.pathMappings.foo",
|
||||
]);
|
||||
expect(new Set(scopes)).toEqual(new Set(["project", "augment"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown fields", () => {
|
||||
it("returns empty scope list for completely unknown field", () => {
|
||||
expect(scopesForFields(["someTotallyUnknownField"])).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty scope list when no parent prefix matches", () => {
|
||||
// "fileSource" is not in the map (only "fileSource.recognitionStrategies"
|
||||
// etc. are). A made-up sibling like "fileSource.somethingNotInTheMap"
|
||||
// has no matching prefix and should yield no scopes.
|
||||
expect(
|
||||
scopesForFields(["fileSource.somethingNotInTheMap"]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not match descendants (only proper prefixes)", () => {
|
||||
// "fileSource" alone is not in the map; only "fileSource.recognitionStrategies"
|
||||
// etc. So a bare "fileSource" lookup should yield nothing — the
|
||||
// map intentionally requires the caller to be specific.
|
||||
expect(scopesForFields(["fileSource"])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("union semantics", () => {
|
||||
it("merges scopes across multiple fields", () => {
|
||||
const scopes = scopesForFields([
|
||||
"taskStatuses", // parser
|
||||
"fileMetadataInheritance", // augment
|
||||
]);
|
||||
expect(new Set(scopes)).toEqual(new Set(["parser", "augment"]));
|
||||
});
|
||||
|
||||
it("dedupes overlapping scopes", () => {
|
||||
const scopes = scopesForFields([
|
||||
"taskStatuses",
|
||||
"preferMetadataFormat",
|
||||
]);
|
||||
// Both → parser, expect only one entry.
|
||||
expect(scopes).toEqual(["parser"]);
|
||||
});
|
||||
|
||||
it("returns empty array when given empty input", () => {
|
||||
expect(scopesForFields([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ratchet: every existing onSettingsChange caller's intent is covered", () => {
|
||||
// The 3 existing callers in IndexSettingsTab.ts all change
|
||||
// fileMetadataInheritance.* fields. They currently pass ["parser"]
|
||||
// (which is technically wrong — these are augment-time settings),
|
||||
// but the typed map reflects the CORRECT scope. Phase 1's migration
|
||||
// fixes the call sites.
|
||||
it("fileMetadataInheritance.* covered by the map", () => {
|
||||
const fields = [
|
||||
"fileMetadataInheritance",
|
||||
"fileMetadataInheritance.enabled",
|
||||
"fileMetadataInheritance.inheritFromFrontmatter",
|
||||
"fileMetadataInheritance.inheritFromFrontmatterForSubtasks",
|
||||
];
|
||||
for (const f of fields) {
|
||||
expect(scopesForFields([f])).toEqual(["augment"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("listMappedFields exposes a non-empty list", () => {
|
||||
expect(listMappedFields().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("every mapped value is a non-empty array of CacheScope", () => {
|
||||
const validScopes = new Set<CacheScope>([
|
||||
"parser",
|
||||
"augment",
|
||||
"project",
|
||||
"index",
|
||||
]);
|
||||
for (const [field, scopes] of Object.entries(
|
||||
SETTINGS_FIELD_TO_SCOPES,
|
||||
)) {
|
||||
expect(scopes.length).toBeGreaterThan(0);
|
||||
for (const s of scopes) {
|
||||
expect(validScopes.has(s)).toBe(true);
|
||||
}
|
||||
// Field paths must be non-empty strings
|
||||
expect(field.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
93
src/__tests__/integration/CacheInvariants.smoke.test.ts
Normal file
93
src/__tests__/integration/CacheInvariants.smoke.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* Phase 0 W4b — smoke test for the cache invariants checker.
|
||||
*
|
||||
* This test validates the checker doesn't false-positive on a freshly built
|
||||
* orchestrator and correctly reports violations when the cache is in a known
|
||||
* inconsistent state. The fuller "checker reports ok at every step of a
|
||||
* realistic operation sequence" lives in W5.4 (CacheInvariants.test.ts).
|
||||
*/
|
||||
|
||||
import { buildOrchestrator } from "./_fixtures/buildOrchestrator";
|
||||
import { checkCacheInvariants } from "@/dataflow/cache/invariants";
|
||||
|
||||
describe("checkCacheInvariants smoke (W4b)", () => {
|
||||
it("reports ok on a freshly built orchestrator with no cached data", async () => {
|
||||
const fx = await buildOrchestrator();
|
||||
try {
|
||||
const report = await checkCacheInvariants(fx.orchestrator);
|
||||
// Empty cache → no raw entries, no augmented entries, no
|
||||
// invariants to violate.
|
||||
expect(report.ok).toBe(true);
|
||||
expect(report.violations).toEqual([]);
|
||||
expect(report.stats.rawCount).toBe(0);
|
||||
expect(report.stats.augmentedCount).toBe(0);
|
||||
} finally {
|
||||
await fx.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("flags I1 when a raw entry has no augmented counterpart", async () => {
|
||||
const fx = await buildOrchestrator();
|
||||
try {
|
||||
// Reach into Storage and inject a raw entry without writing the
|
||||
// matching augmented entry. This is the canonical "augmentation
|
||||
// crashed mid-batch" failure mode.
|
||||
const storage: any = (fx.orchestrator as any).storage;
|
||||
await storage.storeRaw("ghost.md", [], "content", 1);
|
||||
|
||||
const report = await checkCacheInvariants(fx.orchestrator);
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.stats.rawCount).toBe(1);
|
||||
expect(report.stats.augmentedCount).toBe(0);
|
||||
expect(report.stats.missingAugmented).toBe(1);
|
||||
expect(
|
||||
report.violations.some(
|
||||
(v) => v.id === "I1-missing-augmented",
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await fx.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not flag I1 when raw and augmented are in sync", async () => {
|
||||
const fx = await buildOrchestrator();
|
||||
try {
|
||||
const storage: any = (fx.orchestrator as any).storage;
|
||||
await storage.storeRaw("a.md", [], "content", 1);
|
||||
await storage.storeAugmented("a.md", []);
|
||||
|
||||
const report = await checkCacheInvariants(fx.orchestrator);
|
||||
// I1 satisfied. Other invariants may or may not flag depending on
|
||||
// indexer state — what matters is missingAugmented === 0.
|
||||
expect(report.stats.missingAugmented).toBe(0);
|
||||
expect(
|
||||
report.violations.some(
|
||||
(v) => v.id === "I1-missing-augmented",
|
||||
),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await fx.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("never throws — returns a violation report on internal errors", async () => {
|
||||
const fx = await buildOrchestrator();
|
||||
try {
|
||||
// Sabotage storage so listRawPaths throws. The checker should
|
||||
// catch and report, never propagate.
|
||||
const storage: any = (fx.orchestrator as any).storage;
|
||||
storage.listRawPaths = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("disk on fire"));
|
||||
|
||||
const report = await checkCacheInvariants(fx.orchestrator);
|
||||
expect(report.ok).toBe(false);
|
||||
expect(
|
||||
report.violations.some((v) => v.id === "list-raw-failed"),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await fx.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
96
src/__tests__/integration/OrchestratorScopeMap.test.ts
Normal file
96
src/__tests__/integration/OrchestratorScopeMap.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* Phase 0 W4a — verify the typed sibling Orchestrator.onSettingsFieldsChanged
|
||||
* resolves field paths to scopes via the typed map and delegates to the
|
||||
* existing onSettingsChange.
|
||||
*/
|
||||
|
||||
import { buildOrchestrator } from "./_fixtures/buildOrchestrator";
|
||||
|
||||
describe("Orchestrator.onSettingsFieldsChanged (W4a)", () => {
|
||||
it("delegates known fields to onSettingsChange with the right scopes", async () => {
|
||||
const fx = await buildOrchestrator();
|
||||
try {
|
||||
const onSettingsChange = jest.spyOn(
|
||||
fx.orchestrator,
|
||||
"onSettingsChange",
|
||||
);
|
||||
// Block the rebuild path so we don't actually rebuild during the test.
|
||||
(fx.orchestrator as any).rebuild = jest.fn(async () => {});
|
||||
|
||||
await fx.orchestrator.onSettingsFieldsChanged([
|
||||
"fileMetadataInheritance.inheritFromFrontmatter",
|
||||
]);
|
||||
|
||||
expect(onSettingsChange).toHaveBeenCalledTimes(1);
|
||||
expect(onSettingsChange.mock.calls[0][0]).toEqual(["augment"]);
|
||||
|
||||
onSettingsChange.mockRestore();
|
||||
} finally {
|
||||
await fx.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("merges scopes when multiple fields are passed", async () => {
|
||||
const fx = await buildOrchestrator();
|
||||
try {
|
||||
const onSettingsChange = jest.spyOn(
|
||||
fx.orchestrator,
|
||||
"onSettingsChange",
|
||||
);
|
||||
(fx.orchestrator as any).rebuild = jest.fn(async () => {});
|
||||
|
||||
await fx.orchestrator.onSettingsFieldsChanged([
|
||||
"taskStatuses", // parser
|
||||
"projectConfig", // project + augment
|
||||
]);
|
||||
|
||||
expect(onSettingsChange).toHaveBeenCalledTimes(1);
|
||||
const passedScopes = onSettingsChange.mock.calls[0][0] as string[];
|
||||
expect(new Set(passedScopes)).toEqual(
|
||||
new Set(["parser", "project", "augment"]),
|
||||
);
|
||||
|
||||
onSettingsChange.mockRestore();
|
||||
} finally {
|
||||
await fx.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips delegation when no field maps to a scope, but still emits SETTINGS_CHANGED", async () => {
|
||||
const fx = await buildOrchestrator();
|
||||
try {
|
||||
const onSettingsChange = jest.spyOn(
|
||||
fx.orchestrator,
|
||||
"onSettingsChange",
|
||||
);
|
||||
|
||||
const events: any[] = [];
|
||||
fx.app.workspace.on(
|
||||
"task-genius:settings-changed",
|
||||
(payload: any) => {
|
||||
events.push(payload);
|
||||
},
|
||||
);
|
||||
|
||||
await fx.orchestrator.onSettingsFieldsChanged([
|
||||
"someTotallyUnknownField",
|
||||
"anotherUnknownField",
|
||||
]);
|
||||
|
||||
// onSettingsChange should NOT have been invoked because no scopes resolved.
|
||||
expect(onSettingsChange).not.toHaveBeenCalled();
|
||||
// But the SETTINGS_CHANGED event should still fire so UI observers
|
||||
// can refresh.
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0].scopes).toEqual([]);
|
||||
expect(events[0].fields).toEqual([
|
||||
"someTotallyUnknownField",
|
||||
"anotherUnknownField",
|
||||
]);
|
||||
|
||||
onSettingsChange.mockRestore();
|
||||
} finally {
|
||||
await fx.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
28
src/cache/local-storage-cache.ts
vendored
28
src/cache/local-storage-cache.ts
vendored
|
|
@ -87,6 +87,13 @@ export class LocalStorageCache {
|
|||
* Load metadata for a file from cache
|
||||
* @param path File path to load
|
||||
* @returns Cached data or null if not found
|
||||
*
|
||||
* Phase 0 W4b: validates the wrapper's version against the current plugin
|
||||
* version. Prior to this fix, `storeFile` was tagging entries with
|
||||
* `currentVersion` but `loadFile` never checked it, so plugin upgrades did
|
||||
* NOT auto-invalidate stale cache entries — the inner Storage records had
|
||||
* their own version validation, but the outer wrapper layer's was dead
|
||||
* code. On mismatch we now treat the entry as a cache miss and remove it.
|
||||
*/
|
||||
public async loadFile<T = any>(path: string): Promise<Cached<T> | null> {
|
||||
if (!this.initialized) await this.initialize();
|
||||
|
|
@ -94,6 +101,24 @@ export class LocalStorageCache {
|
|||
try {
|
||||
const key = this.fileKey(path);
|
||||
const data = await this.persister.getItem<Cached<T>>(key);
|
||||
if (data && data.version && data.version !== this.currentVersion) {
|
||||
// Version mismatch — log once per session via _versionMismatchLogged
|
||||
// to keep noise down on first load after an upgrade with thousands
|
||||
// of cache entries.
|
||||
if (!LocalStorageCache._versionMismatchLogged) {
|
||||
console.log(
|
||||
`[LocalStorageCache] Cache version mismatch (cached=${data.version}, current=${this.currentVersion}); treating as cache miss and pruning stale entries on access.`,
|
||||
);
|
||||
LocalStorageCache._versionMismatchLogged = true;
|
||||
}
|
||||
// Prune the stale entry so we don't keep paying the read cost.
|
||||
try {
|
||||
await this.persister.removeItem(key);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error loading cache for ${path}:`, error);
|
||||
|
|
@ -101,6 +126,9 @@ export class LocalStorageCache {
|
|||
}
|
||||
}
|
||||
|
||||
/** Process-wide flag so we only log version mismatch once per session. */
|
||||
private static _versionMismatchLogged = false;
|
||||
|
||||
/**
|
||||
* Store metadata for a file in cache
|
||||
* @param path File path to store
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { ConfigurableTaskParser } from "./core/ConfigurableTaskParser";
|
|||
import { MetadataParseMode } from "../types/TaskParserConfig";
|
||||
import { TimeParsingService } from "../services/time-parsing-service";
|
||||
import type { EnhancedTimeParsingConfig } from "../types/time-parsing";
|
||||
import { scopesForFields } from "./cache/scope-map";
|
||||
|
||||
/**
|
||||
* DataflowOrchestrator - Coordinates all dataflow components
|
||||
|
|
@ -1763,7 +1764,12 @@ export class DataflowOrchestrator {
|
|||
}
|
||||
|
||||
/**
|
||||
* Handle settings change
|
||||
* Handle settings change.
|
||||
*
|
||||
* Pass the cache scopes that the changed settings touch. Existing call
|
||||
* sites pass scope strings directly; new call sites should prefer
|
||||
* `onSettingsFieldsChanged()` (W4a) which derives scopes from a typed
|
||||
* field map.
|
||||
*/
|
||||
async onSettingsChange(scopes: string[]): Promise<void> {
|
||||
// Clear relevant caches based on scope
|
||||
|
|
@ -1793,6 +1799,34 @@ export class DataflowOrchestrator {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle settings change by typed field path. Resolves the field paths to
|
||||
* cache scopes via `SETTINGS_FIELD_TO_SCOPES` and delegates to the
|
||||
* stringly-typed `onSettingsChange`.
|
||||
*
|
||||
* Phase 0 W4a — adds the typed entry point. Phase 1+ migrates existing
|
||||
* call sites away from raw scope strings as features are touched. The
|
||||
* primary value is centralizing "which fields invalidate which scopes"
|
||||
* so the migration can be mechanical.
|
||||
*
|
||||
* Field paths are dot-separated (e.g. "fileMetadataInheritance.inheritFromFrontmatter").
|
||||
* Unknown fields contribute no scopes (no cache impact).
|
||||
*/
|
||||
async onSettingsFieldsChanged(fields: readonly string[]): Promise<void> {
|
||||
const scopes = scopesForFields(fields);
|
||||
if (scopes.length === 0) {
|
||||
// Nothing cache-affecting changed; still emit the change event so
|
||||
// observers that don't care about scopes (e.g. UI refresh) can react.
|
||||
emit(this.app, Events.SETTINGS_CHANGED, {
|
||||
scopes: [],
|
||||
fields,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.onSettingsChange(scopes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update project configuration options
|
||||
*/
|
||||
|
|
|
|||
217
src/dataflow/cache/invariants.ts
vendored
Normal file
217
src/dataflow/cache/invariants.ts
vendored
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
/**
|
||||
* Phase 0 W4b — Cache invariants checker.
|
||||
*
|
||||
* A debug-mode safety net that walks the cache namespaces and asserts the
|
||||
* relationships the dataflow pipeline assumes are true. Used by:
|
||||
* 1. Integration tests, to fail loudly if a refactor breaks an invariant.
|
||||
* 2. The hidden `Task Genius (debug): Check cache invariants` command,
|
||||
* gated on `(globalThis as any).__taskGeniusDebug === true`.
|
||||
*
|
||||
* What "cache" means here
|
||||
* -----------------------
|
||||
* The Storage layer (src/dataflow/persistence/Storage.ts) maintains four
|
||||
* namespaces in LocalStorageCache:
|
||||
* - raw — parsed Task[] per file, pre-augmentation
|
||||
* - project — project metadata per file
|
||||
* - augmented — Task[] per file, post-inheritance
|
||||
* - consolidated — single TaskCache holding the merged view
|
||||
*
|
||||
* The pipeline goes: parse → raw → augment → augmented → index → consolidated.
|
||||
* If any of these layers drift apart (e.g. a file has raw entries but no
|
||||
* augmented ones), queries return inconsistent results.
|
||||
*
|
||||
* Invariants checked
|
||||
* ------------------
|
||||
* I1. Every entry in `raw` has a corresponding entry in `augmented`.
|
||||
* (Augmentation should run after parsing; missing augmented = lost data.)
|
||||
*
|
||||
* I2. Every cached entry's inner version field matches the plugin's current
|
||||
* version. (Storage records carry a `version` field for invalidation.)
|
||||
*
|
||||
* I3. The number of files in the in-memory TaskIndexer matches the number of
|
||||
* files in `augmented` (within tolerance — the indexer can include files
|
||||
* loaded from the consolidated namespace that haven't been re-parsed yet).
|
||||
*
|
||||
* I4. Every namespace returned by `storage.getStats()` has a non-negative
|
||||
* count and the totals add up. (Sanity check; mostly catches counting
|
||||
* bugs in Storage itself.)
|
||||
*
|
||||
* Phase 0 intentionally does NOT check:
|
||||
* - "every task in the index has a backing entry in consolidated" — the
|
||||
* consolidated namespace is one big record, not per-file, so this would
|
||||
* require deserializing the whole thing. Defer to a future phase.
|
||||
* - Project resolver in-memory vs storage `project` consistency — the
|
||||
* resolver's cache is keyed differently than the storage namespace, so
|
||||
* a clean mapping needs more design. Defer.
|
||||
*
|
||||
* The checker NEVER throws — it returns a report with `ok` and a list of
|
||||
* violations. Callers decide whether to log, fail a test, or surface to UI.
|
||||
*/
|
||||
|
||||
import type { DataflowOrchestrator } from "../Orchestrator";
|
||||
|
||||
export interface CacheInvariantViolation {
|
||||
id: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CacheInvariantReport {
|
||||
ok: boolean;
|
||||
violations: CacheInvariantViolation[];
|
||||
stats: {
|
||||
rawCount: number;
|
||||
augmentedCount: number;
|
||||
// Number of paths present in raw but missing in augmented (I1).
|
||||
missingAugmented: number;
|
||||
// Number of cache entries with version mismatch (I2).
|
||||
versionMismatches: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the orchestrator's storage and report any invariant violations.
|
||||
*
|
||||
* Reaches into private state via `as any` because Storage is not part of the
|
||||
* orchestrator's public API. The checker is debug-only — production code paths
|
||||
* never invoke it. Keep it independent of the public API surface so it can be
|
||||
* deleted or refactored without churning consumers.
|
||||
*/
|
||||
export async function checkCacheInvariants(
|
||||
orch: DataflowOrchestrator,
|
||||
): Promise<CacheInvariantReport> {
|
||||
const violations: CacheInvariantViolation[] = [];
|
||||
const stats = {
|
||||
rawCount: 0,
|
||||
augmentedCount: 0,
|
||||
missingAugmented: 0,
|
||||
versionMismatches: 0,
|
||||
};
|
||||
|
||||
const storage: any = (orch as any).storage;
|
||||
if (!storage) {
|
||||
violations.push({
|
||||
id: "no-storage",
|
||||
message:
|
||||
"Orchestrator has no storage instance; nothing to check (this is normal pre-initialize)",
|
||||
});
|
||||
return { ok: false, violations, stats };
|
||||
}
|
||||
|
||||
let rawPaths: string[] = [];
|
||||
let augPaths: string[] = [];
|
||||
|
||||
try {
|
||||
rawPaths = await storage.listRawPaths();
|
||||
} catch (e) {
|
||||
violations.push({
|
||||
id: "list-raw-failed",
|
||||
message: `storage.listRawPaths() threw: ${e}`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
augPaths = await storage.listAugmentedPaths();
|
||||
} catch (e) {
|
||||
violations.push({
|
||||
id: "list-augmented-failed",
|
||||
message: `storage.listAugmentedPaths() threw: ${e}`,
|
||||
});
|
||||
}
|
||||
|
||||
stats.rawCount = rawPaths.length;
|
||||
stats.augmentedCount = augPaths.length;
|
||||
|
||||
// I1: every raw entry has an augmented counterpart
|
||||
const augSet = new Set(augPaths);
|
||||
for (const rawPath of rawPaths) {
|
||||
if (!augSet.has(rawPath)) {
|
||||
stats.missingAugmented++;
|
||||
violations.push({
|
||||
id: "I1-missing-augmented",
|
||||
message: `raw entry has no augmented counterpart: ${rawPath}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// I2: every cached record carries a current version
|
||||
const currentVersion: string | undefined = storage.currentVersion;
|
||||
if (currentVersion) {
|
||||
// Sample up to N entries to keep this O(reasonable). The full walk is
|
||||
// only useful in tests; in production we just want a smoke check.
|
||||
const maxToCheck = 64;
|
||||
const toCheck = rawPaths.slice(0, maxToCheck);
|
||||
for (const path of toCheck) {
|
||||
try {
|
||||
const rec = await storage.loadRaw(path);
|
||||
if (rec && rec.version && rec.version !== currentVersion) {
|
||||
stats.versionMismatches++;
|
||||
violations.push({
|
||||
id: "I2-version-mismatch",
|
||||
message: `raw record version mismatch for ${path}: cached=${rec.version} current=${currentVersion}`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* loadRaw already logs; just skip */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// I3: indexer file count vs augmented count (with tolerance)
|
||||
const repository: any = (orch as any).repository;
|
||||
if (repository && typeof repository.getIndexedFilePaths === "function") {
|
||||
try {
|
||||
const indexedPaths: string[] =
|
||||
await repository.getIndexedFilePaths();
|
||||
const indexedSet = new Set(indexedPaths);
|
||||
// Files in augmented that the in-memory index doesn't know about
|
||||
// is a sign of stale storage that nobody's loading.
|
||||
for (const augPath of augPaths) {
|
||||
if (!indexedSet.has(augPath)) {
|
||||
violations.push({
|
||||
id: "I3-index-augmented-drift",
|
||||
message: `augmented entry not present in in-memory index: ${augPath}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
violations.push({
|
||||
id: "I3-index-walk-failed",
|
||||
message: `repository.getIndexedFilePaths() threw: ${e}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// I4: getStats sanity
|
||||
try {
|
||||
const sgs = await storage.getStats();
|
||||
for (const [ns, count] of Object.entries(sgs.byNamespace ?? {})) {
|
||||
if (typeof count !== "number" || count < 0) {
|
||||
violations.push({
|
||||
id: "I4-bad-namespace-count",
|
||||
message: `storage.getStats().byNamespace[${ns}] is not a non-negative number: ${count}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const sum: number = Object.values(sgs.byNamespace ?? {}).reduce<number>(
|
||||
(a, b) => a + (typeof b === "number" ? b : 0),
|
||||
0,
|
||||
);
|
||||
if (sum > sgs.totalKeys) {
|
||||
violations.push({
|
||||
id: "I4-namespace-sum-overflow",
|
||||
message: `sum of byNamespace counts (${sum}) exceeds totalKeys (${sgs.totalKeys})`,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
violations.push({
|
||||
id: "I4-getstats-failed",
|
||||
message: `storage.getStats() threw: ${e}`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: violations.length === 0,
|
||||
violations,
|
||||
stats,
|
||||
};
|
||||
}
|
||||
150
src/dataflow/cache/scope-map.ts
vendored
Normal file
150
src/dataflow/cache/scope-map.ts
vendored
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* Typed cache scope map.
|
||||
*
|
||||
* Phase 0 W4a — Background
|
||||
* ------------------------
|
||||
* `Orchestrator.onSettingsChange(scopes: string[])` takes a free-form list of
|
||||
* scope names and uses them to decide which cache namespaces to invalidate.
|
||||
* Today, every caller has to:
|
||||
* 1. know which scope name applies to the field they're touching
|
||||
* 2. spell it correctly (no type checking)
|
||||
* 3. remember to update its scopes if the field's caching impact changes
|
||||
*
|
||||
* That works for one or two callers but breaks down when settings consolidation
|
||||
* begins in Phase 1 (25 tabs → 7) and field paths shift around. This file gives
|
||||
* us a single source of truth for "which fields invalidate which cache scopes"
|
||||
* so the migration can be mechanical instead of error-prone.
|
||||
*
|
||||
* Usage
|
||||
* -----
|
||||
* Phase 0 ships this map and a `scopesForFields()` helper, but does NOT migrate
|
||||
* any existing callers. The new sibling method `Orchestrator.onSettingsFieldsChanged`
|
||||
* (added separately) wraps the existing onSettingsChange — Phase 1+ will switch
|
||||
* call sites over progressively as features are touched.
|
||||
*
|
||||
* The keys are dot-separated field paths into `TaskProgressBarSettings`. We
|
||||
* intentionally use string keys instead of `keyof` because (a) many fields
|
||||
* are nested objects whose entire subtree is one cache concern, and (b) Phase 1
|
||||
* will rename and consolidate these fields, so a stringly-typed map is more
|
||||
* resilient to refactoring than a structural type that fights every move.
|
||||
*
|
||||
* Discovery
|
||||
* ---------
|
||||
* The current set of mappings was derived by walking every existing caller of
|
||||
* `Orchestrator.onSettingsChange` (only 3 sites at the time of writing, all in
|
||||
* `IndexSettingsTab.ts`). Note: those callers currently pass `["parser"]` for
|
||||
* `fileMetadataInheritance` changes which is technically wrong — file metadata
|
||||
* inheritance is an augment-time concern. The map below reflects the CORRECT
|
||||
* mapping; Phase 1's migration will fix the call sites.
|
||||
*/
|
||||
|
||||
/** The four cache namespaces the Orchestrator manages. */
|
||||
export type CacheScope = "parser" | "augment" | "project" | "index";
|
||||
|
||||
/**
|
||||
* Field path → list of cache scopes that need invalidation when that field changes.
|
||||
*
|
||||
* Field paths are dot-separated (e.g. "fileSource.recognitionStrategies"). A path
|
||||
* matches both itself and any descendant — so a change to
|
||||
* "fileMetadataInheritance.inheritFromFrontmatter" matches the entry for
|
||||
* "fileMetadataInheritance".
|
||||
*
|
||||
* If a field is not in this map, it has no cache impact.
|
||||
*/
|
||||
export const SETTINGS_FIELD_TO_SCOPES: Readonly<
|
||||
Record<string, readonly CacheScope[]>
|
||||
> = Object.freeze({
|
||||
// --- Parser scope ---
|
||||
// Anything that changes how raw markdown / canvas / file-metadata is parsed
|
||||
// into Task records. Invalidates the "raw" cache namespace.
|
||||
taskStatuses: ["parser"],
|
||||
preferMetadataFormat: ["parser"],
|
||||
enableCustomDateFormats: ["parser"],
|
||||
customDateFormats: ["parser"],
|
||||
projectTagPrefix: ["parser"],
|
||||
contextTagPrefix: ["parser"],
|
||||
areaTagPrefix: ["parser"],
|
||||
useDailyNotePathAsDate: ["parser"],
|
||||
dailyNoteFormat: ["parser"],
|
||||
useAsDateType: ["parser"],
|
||||
dailyNotePath: ["parser"],
|
||||
ignoreHeading: ["parser"],
|
||||
focusHeading: ["parser"],
|
||||
"fileSource.recognitionStrategies": ["parser"],
|
||||
"fileSource.fileTaskProperties": ["parser"],
|
||||
"fileSource.relationships": ["parser"],
|
||||
|
||||
// --- Augment scope ---
|
||||
// Anything that changes how parsed tasks are merged with file/project metadata.
|
||||
// Invalidates the "augmented" cache namespace.
|
||||
fileMetadataInheritance: ["augment"],
|
||||
|
||||
// --- Project scope ---
|
||||
// Anything that changes project detection or project metadata enrichment.
|
||||
// Invalidates "project" + "augment" namespaces (project changes ripple
|
||||
// through augmentation).
|
||||
projectConfig: ["project", "augment"],
|
||||
|
||||
// --- Index scope ---
|
||||
// Anything that changes the consolidated index shape itself. Rare — most
|
||||
// settings are parser/augment concerns. Currently no fields use this.
|
||||
// Reserved for future use; left here as documentation.
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve a list of changed field paths into the minimal set of cache scopes
|
||||
* that need invalidation. Unknown fields contribute no scopes (no cache impact).
|
||||
*
|
||||
* Field path matching is prefix-based on dotted segments: a change to
|
||||
* "fileMetadataInheritance.inheritFromFrontmatter" matches the entry for
|
||||
* "fileMetadataInheritance" (because "fileMetadataInheritance" is a prefix
|
||||
* along a `.` boundary). This lets callers pass either the parent or any
|
||||
* leaf without needing to know the granularity of the map.
|
||||
*/
|
||||
export function scopesForFields(
|
||||
fields: readonly string[],
|
||||
): CacheScope[] {
|
||||
const out = new Set<CacheScope>();
|
||||
for (const field of fields) {
|
||||
const matchedScopes = lookupField(field);
|
||||
for (const s of matchedScopes) out.add(s);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the cache scopes for a single field path.
|
||||
*
|
||||
* Returns the scopes for the longest matching prefix in the map. For example:
|
||||
* - "fileMetadataInheritance.inheritFromFrontmatter" → ["augment"]
|
||||
* (matches "fileMetadataInheritance")
|
||||
* - "fileSource.recognitionStrategies.tags.taskTags" → ["parser"]
|
||||
* (matches "fileSource.recognitionStrategies")
|
||||
* - "fileSource.somethingElse" → []
|
||||
* (no entry, falls through)
|
||||
*/
|
||||
function lookupField(field: string): readonly CacheScope[] {
|
||||
// Direct hit
|
||||
if (field in SETTINGS_FIELD_TO_SCOPES) {
|
||||
return SETTINGS_FIELD_TO_SCOPES[field];
|
||||
}
|
||||
// Walk parents (drop one segment at a time from the right)
|
||||
let current = field;
|
||||
while (true) {
|
||||
const lastDot = current.lastIndexOf(".");
|
||||
if (lastDot === -1) return [];
|
||||
current = current.substring(0, lastDot);
|
||||
if (current in SETTINGS_FIELD_TO_SCOPES) {
|
||||
return SETTINGS_FIELD_TO_SCOPES[current];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only helper: enumerate every field path registered in the map. Used by
|
||||
* scope-map.test.ts to assert all known caller fields are covered without
|
||||
* having to import the test fixture.
|
||||
*/
|
||||
export function listMappedFields(): readonly string[] {
|
||||
return Object.keys(SETTINGS_FIELD_TO_SCOPES);
|
||||
}
|
||||
Loading…
Reference in a new issue