Introduce MetadataCache class in metadata-cache.ts that persists extracted
session metadata to ~/.cache/session-viewer/metadata.json for fast warm
starts across server restarts.
Key features:
- Invalidation keyed on (mtimeMs, size): If either changes, entry is
re-extracted via Tier 3 parsing. This catches both content changes
and file truncation/corruption.
- Dirty-flag write-behind: Only writes to disk when entries have changed,
coalescing multiple discovery passes into a single write operation.
- Atomic writes: Uses temp file + rename pattern to prevent corruption
from crashes during write. Safe for concurrent server restarts.
- Stale entry pruning: Removes entries for files that no longer exist
on disk during the save operation.
- Graceful degradation: Missing or corrupt cache file triggers fallback
to Tier 3 extraction for all files (cache rebuilt on next save).
Cache file format:
{
"version": 1,
"entries": {
"/path/to/session.jsonl": {
"mtimeMs": 1234567890,
"size": 12345,
"messageCount": 42,
"firstPrompt": "...",
"summary": "...",
"firstTimestamp": "...",
"lastTimestamp": "..."
}
}
}
Test coverage includes:
- Cache hit/miss/invalidation behavior
- Dirty flag triggers write only when entries changed
- Concurrent save coalescing
- Stale entry pruning on save
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
175 lines
5.5 KiB
TypeScript
175 lines
5.5 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { MetadataCache } from "../../src/server/services/metadata-cache.js";
|
|
import type { CacheEntry } from "../../src/server/services/metadata-cache.js";
|
|
import fs from "fs/promises";
|
|
import path from "path";
|
|
import os from "os";
|
|
|
|
function makeCacheEntry(overrides: Partial<CacheEntry> = {}): CacheEntry {
|
|
return {
|
|
mtimeMs: 1700000000000,
|
|
size: 1024,
|
|
messageCount: 5,
|
|
firstPrompt: "Hello",
|
|
summary: "Session summary",
|
|
firstTimestamp: "2025-01-01T10:00:00Z",
|
|
lastTimestamp: "2025-01-01T11:00:00Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("MetadataCache", () => {
|
|
let tmpDir: string;
|
|
let cachePath: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = path.join(os.tmpdir(), `sv-cache-test-${Date.now()}`);
|
|
await fs.mkdir(tmpDir, { recursive: true });
|
|
cachePath = path.join(tmpDir, "metadata.json");
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("returns null for unknown file path", async () => {
|
|
const cache = new MetadataCache(cachePath);
|
|
await cache.load();
|
|
expect(cache.get("/unknown/path.jsonl", 1000, 500)).toBeNull();
|
|
});
|
|
|
|
it("returns entry when mtimeMs and size match", async () => {
|
|
const cache = new MetadataCache(cachePath);
|
|
await cache.load();
|
|
|
|
const entry = makeCacheEntry({ mtimeMs: 1000, size: 500 });
|
|
cache.set("/test/session.jsonl", entry);
|
|
|
|
const result = cache.get("/test/session.jsonl", 1000, 500);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.messageCount).toBe(5);
|
|
expect(result!.firstPrompt).toBe("Hello");
|
|
});
|
|
|
|
it("returns null when mtimeMs differs", async () => {
|
|
const cache = new MetadataCache(cachePath);
|
|
await cache.load();
|
|
|
|
const entry = makeCacheEntry({ mtimeMs: 1000, size: 500 });
|
|
cache.set("/test/session.jsonl", entry);
|
|
|
|
expect(cache.get("/test/session.jsonl", 2000, 500)).toBeNull();
|
|
});
|
|
|
|
it("returns null when size differs", async () => {
|
|
const cache = new MetadataCache(cachePath);
|
|
await cache.load();
|
|
|
|
const entry = makeCacheEntry({ mtimeMs: 1000, size: 500 });
|
|
cache.set("/test/session.jsonl", entry);
|
|
|
|
expect(cache.get("/test/session.jsonl", 1000, 999)).toBeNull();
|
|
});
|
|
|
|
it("save is no-op when not dirty", async () => {
|
|
const cache = new MetadataCache(cachePath);
|
|
await cache.load();
|
|
await cache.save();
|
|
|
|
// File should not exist since nothing was set
|
|
await expect(fs.access(cachePath)).rejects.toThrow();
|
|
});
|
|
|
|
it("save writes to disk when dirty", async () => {
|
|
const cache = new MetadataCache(cachePath);
|
|
await cache.load();
|
|
|
|
cache.set("/test/session.jsonl", makeCacheEntry());
|
|
await cache.save();
|
|
|
|
const raw = await fs.readFile(cachePath, "utf-8");
|
|
const parsed = JSON.parse(raw);
|
|
expect(parsed.version).toBe(1);
|
|
expect(parsed.entries["/test/session.jsonl"]).toBeDefined();
|
|
expect(parsed.entries["/test/session.jsonl"].messageCount).toBe(5);
|
|
});
|
|
|
|
it("save prunes entries not in existingPaths", async () => {
|
|
const cache = new MetadataCache(cachePath);
|
|
await cache.load();
|
|
|
|
cache.set("/test/a.jsonl", makeCacheEntry());
|
|
cache.set("/test/b.jsonl", makeCacheEntry());
|
|
cache.set("/test/c.jsonl", makeCacheEntry());
|
|
|
|
const existingPaths = new Set(["/test/a.jsonl", "/test/c.jsonl"]);
|
|
await cache.save(existingPaths);
|
|
|
|
const raw = await fs.readFile(cachePath, "utf-8");
|
|
const parsed = JSON.parse(raw);
|
|
expect(Object.keys(parsed.entries)).toHaveLength(2);
|
|
expect(parsed.entries["/test/b.jsonl"]).toBeUndefined();
|
|
});
|
|
|
|
it("load handles missing cache file", async () => {
|
|
const cache = new MetadataCache(
|
|
path.join(tmpDir, "nonexistent", "cache.json")
|
|
);
|
|
await cache.load();
|
|
expect(cache.get("/test/session.jsonl", 1000, 500)).toBeNull();
|
|
});
|
|
|
|
it("load handles corrupt cache file", async () => {
|
|
await fs.writeFile(cachePath, "not valid json {{{");
|
|
const cache = new MetadataCache(cachePath);
|
|
await cache.load();
|
|
expect(cache.get("/test/session.jsonl", 1000, 500)).toBeNull();
|
|
});
|
|
|
|
it("persists and reloads across instances", async () => {
|
|
const cache1 = new MetadataCache(cachePath);
|
|
await cache1.load();
|
|
cache1.set("/test/session.jsonl", makeCacheEntry({ mtimeMs: 42, size: 100 }));
|
|
await cache1.save();
|
|
|
|
const cache2 = new MetadataCache(cachePath);
|
|
await cache2.load();
|
|
const result = cache2.get("/test/session.jsonl", 42, 100);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.messageCount).toBe(5);
|
|
});
|
|
|
|
it("isDirty returns false initially, true after set", async () => {
|
|
const cache = new MetadataCache(cachePath);
|
|
await cache.load();
|
|
expect(cache.isDirty()).toBe(false);
|
|
|
|
cache.set("/test/session.jsonl", makeCacheEntry());
|
|
expect(cache.isDirty()).toBe(true);
|
|
});
|
|
|
|
it("isDirty resets to false after save", async () => {
|
|
const cache = new MetadataCache(cachePath);
|
|
await cache.load();
|
|
cache.set("/test/session.jsonl", makeCacheEntry());
|
|
expect(cache.isDirty()).toBe(true);
|
|
|
|
await cache.save();
|
|
expect(cache.isDirty()).toBe(false);
|
|
});
|
|
|
|
it("flush writes without pruning", async () => {
|
|
const cache = new MetadataCache(cachePath);
|
|
await cache.load();
|
|
|
|
cache.set("/test/a.jsonl", makeCacheEntry());
|
|
cache.set("/test/b.jsonl", makeCacheEntry());
|
|
await cache.flush();
|
|
|
|
const raw = await fs.readFile(cachePath, "utf-8");
|
|
const parsed = JSON.parse(raw);
|
|
// Both should be present (no pruning on flush)
|
|
expect(Object.keys(parsed.entries)).toHaveLength(2);
|
|
});
|
|
});
|