mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
Part 1 of #643 (local sessions only; remote follows as its own PR). An opt-in, per-device header button opens a panel with the active session's edits, newest first: every Edit/Write/MultiEdit/NotebookEdit of the main agent and its subagents with its diff, plus images a Bash call rendered or the agent sent. Remote, Docker and non-claude sessions answer `unsupported`. - Reads only on request: `?sync=1` when the panel opens; while it is open on that session it re-reads after an edit-capable `transcript:tool_end` or a `transcript:complete` from the existing transcript watcher. No hook, timer, second watcher or new SSE event; a wiring guard pins it. - Main transcript: the path the transcript watcher follows (hook-reported), else `<id>.jsonl` under the session's own CLAUDE_CONFIG_DIR override or the server's config dir. A config dir set outside Codeman is found with the next hook; until then the panel says so. - Store: dataPath('edit-history')/<id>.json, 0600 via tmp+rename, capped at 500 entries and 4 MiB of diff text per session, deleted with the session, blocked in isSensitivePath, never fed to /api/search. Every route goes through findSessionOrFail with req. - Images only through existing file surfaces: file-raw inside the workspace, the attachment route with notify:false outside it. - textContent rendering, bounded byte-offset reads, marker-class header button, hidden on phones (locked in the mobile policy test). Docs: Working-With-Files, Settings-Reference, architecture invariants, API reference, CLAUDE.md key pattern. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
446 lines
19 KiB
TypeScript
446 lines
19 KiB
TypeScript
/**
|
||
* Edit history store: byte ranges, cursors, dedupe, cap, persistence.
|
||
* Transcript bytes are built from JSONL lines shaped like real claude records.
|
||
*/
|
||
import { mkdtempSync, rmSync, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||
import {
|
||
EditHistoryStore,
|
||
entryBytes,
|
||
MAIN_SOURCE,
|
||
MAX_AGENT_META_CHARS,
|
||
MAX_ENTRIES,
|
||
MAX_STORE_BYTES,
|
||
parseAgentMeta,
|
||
} from '../src/edit-history-store.js';
|
||
|
||
let dir: string;
|
||
beforeEach(() => {
|
||
dir = mkdtempSync(join(tmpdir(), 'edit-history-store-'));
|
||
});
|
||
afterEach(() => {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
const T0 = Date.parse('2026-10-15T19:00:00.000Z');
|
||
const iso = (s: number) => new Date(T0 + s * 1000).toISOString();
|
||
|
||
function editLine(id: string, sec: number, path = `/work/${id}.ts`, extra: Record<string, unknown> = {}): string {
|
||
return JSON.stringify({
|
||
type: 'user',
|
||
timestamp: iso(sec),
|
||
cwd: '/work',
|
||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
||
toolUseResult: {
|
||
filePath: path,
|
||
oldString: 'a',
|
||
newString: 'b',
|
||
originalFile: 'a',
|
||
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
|
||
userModified: false,
|
||
replaceAll: false,
|
||
},
|
||
...extra,
|
||
});
|
||
}
|
||
|
||
/** An Edit whose diff carries `size` bytes of added text. */
|
||
function bigEditLine(id: string, sec: number, size: number): string {
|
||
const line = JSON.parse(editLine(id, sec));
|
||
line.toolUseResult.structuredPatch[0].lines = ['-a', '+' + 'x'.repeat(size)];
|
||
return JSON.stringify(line);
|
||
}
|
||
|
||
function bashUse(id: string, sec: number, command: string): string {
|
||
return JSON.stringify({
|
||
type: 'assistant',
|
||
timestamp: iso(sec),
|
||
cwd: '/work',
|
||
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
|
||
});
|
||
}
|
||
|
||
function bashResult(id: string, sec: number): string {
|
||
return JSON.stringify({
|
||
type: 'user',
|
||
timestamp: iso(sec),
|
||
cwd: '/work',
|
||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: '' }] },
|
||
toolUseResult: { stdout: '', stderr: '', interrupted: false, isImage: false },
|
||
});
|
||
}
|
||
|
||
const jsonl = (lines: string[]) => Buffer.from(lines.map((l) => l + '\n').join(''));
|
||
|
||
/** Read like the sync will: ask the store for a range, slice it out of `file`, apply. */
|
||
function syncForward(store: EditHistoryStore, file: Buffer, budget?: number, source = MAIN_SOURCE, tid = 'conv-1') {
|
||
const range = store.nextRange(source, file.length, tid, budget);
|
||
if (!range) return null;
|
||
const chunk = file.subarray(range.offset, range.offset + range.len);
|
||
return store.apply({ transcriptId: tid, source, startOffset: range.offset, chunk, size: file.length });
|
||
}
|
||
|
||
function syncOlder(store: EditHistoryStore, file: Buffer, budget?: number, source = MAIN_SOURCE, tid = 'conv-1') {
|
||
const range = store.olderRange(source, budget);
|
||
if (!range) return null;
|
||
const chunk = file.subarray(range.offset, range.offset + range.len);
|
||
return store.applyOlder({ transcriptId: tid, source, startOffset: range.offset, chunk, size: file.length });
|
||
}
|
||
|
||
const ids = (store: EditHistoryStore) => store.list().map((e) => e.id);
|
||
|
||
describe('EditHistoryStore: forward reads', () => {
|
||
it('reads a small file whole, then only what was appended', () => {
|
||
const store = new EditHistoryStore('s1', { dir });
|
||
let file = jsonl([editLine('e1', 1), editLine('e2', 2)]);
|
||
expect(syncForward(store, file)?.added).toBe(2);
|
||
expect(store.getCursor(MAIN_SOURCE)).toEqual({ offset: file.length, floor: 0 });
|
||
expect(syncForward(store, file)).toBeNull();
|
||
file = Buffer.concat([file, jsonl([editLine('e3', 3)])]);
|
||
expect(syncForward(store, file)?.added).toBe(1);
|
||
expect(ids(store)).toEqual(['e3', 'e2', 'e1']);
|
||
expect(store.backfillTruncated).toBe(false);
|
||
});
|
||
|
||
it('leaves a partial last line (still being written) for the next read', () => {
|
||
const store = new EditHistoryStore('s1', { dir });
|
||
const full = jsonl([editLine('e1', 1), editLine('e2', 2)]);
|
||
const firstLineEnd = full.indexOf(0x0a) + 1;
|
||
const partial = full.subarray(0, firstLineEnd + 30);
|
||
expect(syncForward(store, partial)?.added).toBe(1);
|
||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(firstLineEnd);
|
||
expect(syncForward(store, full)?.added).toBe(1);
|
||
expect(ids(store)).toEqual(['e2', 'e1']);
|
||
});
|
||
|
||
it('deduplicates by tool_use_id', () => {
|
||
const store = new EditHistoryStore('s1', { dir });
|
||
const file = jsonl([editLine('e1', 1), editLine('e1', 1)]);
|
||
expect(syncForward(store, file)?.added).toBe(1);
|
||
expect(store.list()).toHaveLength(1);
|
||
});
|
||
|
||
it('starts a large file near its end, drops the cut line and keeps the floor there', () => {
|
||
const store = new EditHistoryStore('s1', { dir });
|
||
const lines = Array.from({ length: 20 }, (_, i) => editLine(`e${i}`, i));
|
||
const file = jsonl(lines);
|
||
const budget = Math.floor(file.length / 3);
|
||
syncForward(store, file, budget);
|
||
const cursor = store.getCursor(MAIN_SOURCE)!;
|
||
expect(cursor.offset).toBe(file.length);
|
||
expect(cursor.floor).toBeGreaterThan(file.length - budget);
|
||
expect(file[cursor.floor - 1]).toBe(0x0a);
|
||
expect(store.backfillTruncated).toBe(true);
|
||
expect(ids(store)[0]).toBe('e19');
|
||
expect(ids(store)).not.toContain('e0');
|
||
});
|
||
|
||
it('reads a backlog in budget-sized forward steps without losing a line', () => {
|
||
const store = new EditHistoryStore('s1', { dir });
|
||
const first = jsonl([editLine('e0', 0)]);
|
||
syncForward(store, first);
|
||
const more = Array.from({ length: 30 }, (_, i) => editLine(`e${i + 1}`, i + 1));
|
||
const file = Buffer.concat([first, jsonl(more)]);
|
||
const budget = 700;
|
||
for (let i = 0; i < 100 && syncForward(store, file, budget); i++);
|
||
expect(store.list()).toHaveLength(31);
|
||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(file.length);
|
||
});
|
||
|
||
it('skips a line longer than the budget instead of re-reading it forever', () => {
|
||
const store = new EditHistoryStore('s1', { dir });
|
||
const huge = editLine('big', 1, '/work/big.ts', { pad: 'x'.repeat(5000) });
|
||
const first = jsonl([editLine('e0', 0)]);
|
||
syncForward(store, first);
|
||
const file = Buffer.concat([first, jsonl([huge, editLine('e2', 2)])]);
|
||
for (let i = 0; i < 50 && syncForward(store, file, 1000); i++);
|
||
expect(ids(store)).toEqual(['e2', 'e0']);
|
||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(file.length);
|
||
});
|
||
|
||
it('keeps a multibyte character split by the chunk boundary intact', () => {
|
||
const store = new EditHistoryStore('s1', { dir });
|
||
const path = '/work/Größe – Ümlaut 😀.ts';
|
||
const file = jsonl([editLine('e1', 1), editLine('e2', 2, path)]);
|
||
const emoji = file.indexOf(Buffer.from('😀'));
|
||
// Cut inside the 4-byte emoji, then continue.
|
||
const cut = file.subarray(0, emoji + 2);
|
||
syncForward(store, cut);
|
||
syncForward(store, file);
|
||
expect(store.get('e2')?.path).toBe(path);
|
||
});
|
||
|
||
it('restarts a file that shrank (replaced) from its end', () => {
|
||
const store = new EditHistoryStore('s1', { dir });
|
||
syncForward(store, jsonl([editLine('e1', 1), editLine('e2', 2)]));
|
||
const replaced = jsonl([editLine('n1', 3)]);
|
||
expect(syncForward(store, replaced)?.added).toBe(1);
|
||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(replaced.length);
|
||
});
|
||
|
||
it('ignores a chunk that does not start at the cursor', () => {
|
||
const store = new EditHistoryStore('s1', { dir });
|
||
const file = jsonl([editLine('e1', 1), editLine('e2', 2)]);
|
||
syncForward(store, file);
|
||
const res = store.apply({ transcriptId: 'conv-1', source: MAIN_SOURCE, startOffset: 3, chunk: file, size: 999 });
|
||
expect(res.added).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('EditHistoryStore: conversations and sources', () => {
|
||
it('starts new cursors for a new transcriptId and keeps the old entries', () => {
|
||
const store = new EditHistoryStore('s1', { dir });
|
||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||
const second = jsonl([editLine('f1', 5)]);
|
||
expect(store.nextRange(MAIN_SOURCE, second.length, 'conv-2')).toEqual({ offset: 0, len: second.length });
|
||
syncForward(store, second, undefined, MAIN_SOURCE, 'conv-2');
|
||
expect(store.getTranscriptId()).toBe('conv-2');
|
||
expect(ids(store)).toEqual(['f1', 'e1']);
|
||
});
|
||
|
||
it('keeps one cursor per source and the subagent id on its entries', () => {
|
||
const store = new EditHistoryStore('s1', { dir });
|
||
const main = jsonl([editLine('m1', 1)]);
|
||
const sub = jsonl([editLine('s1', 2, '/work/s.ts', { isSidechain: true, agentId: 'a1' })]);
|
||
const subSource = 'subagents/agent-a1.jsonl';
|
||
syncForward(store, main);
|
||
syncForward(store, sub, undefined, subSource);
|
||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(main.length);
|
||
expect(store.getCursor(subSource)?.offset).toBe(sub.length);
|
||
expect(store.get('s1')?.agentId).toBe('a1');
|
||
});
|
||
|
||
it('records agent meta from meta.json text', () => {
|
||
const store = new EditHistoryStore('s1', { dir });
|
||
store.setAgentMeta(
|
||
'a1',
|
||
JSON.stringify({
|
||
agentType: 'coding',
|
||
description: 'Impl: fix',
|
||
worktreePath: '/w/.claude/worktrees/agent-a1',
|
||
worktreeBranch: 'worktree-agent-a1',
|
||
toolUseId: 'toolu_x',
|
||
})
|
||
);
|
||
store.setAgentMeta('a2', 'not json');
|
||
expect(store.getAgents()).toEqual({
|
||
a1: {
|
||
description: 'Impl: fix',
|
||
agentType: 'coding',
|
||
worktreePath: '/w/.claude/worktrees/agent-a1',
|
||
worktreeBranch: 'worktree-agent-a1',
|
||
},
|
||
});
|
||
expect(store.hasAgentMeta('a2')).toBe(false);
|
||
expect(parseAgentMeta('{"description":"d"}')).toEqual({ description: 'd', agentType: '' });
|
||
});
|
||
});
|
||
|
||
describe('EditHistoryStore: older blocks', () => {
|
||
it('loads older lines block by block down to the start, without duplicates at the seam', () => {
|
||
const store = new EditHistoryStore('s1', { dir });
|
||
const file = jsonl(Array.from({ length: 30 }, (_, i) => editLine(`e${i}`, i)));
|
||
const budget = Math.floor(file.length / 4);
|
||
syncForward(store, file, budget);
|
||
const before = store.list().length;
|
||
expect(before).toBeLessThan(30);
|
||
let rounds = 0;
|
||
while (syncOlder(store, file, budget) && rounds < 20) rounds++;
|
||
expect(rounds).toBeGreaterThan(1);
|
||
expect(store.list()).toHaveLength(30);
|
||
expect(store.getCursor(MAIN_SOURCE)?.floor).toBe(0);
|
||
expect(store.backfillTruncated).toBe(false);
|
||
expect(store.olderRange(MAIN_SOURCE, budget)).toBeNull();
|
||
});
|
||
|
||
it('refuses an older chunk that does not end at the floor', () => {
|
||
const store = new EditHistoryStore('s1', { dir });
|
||
const file = jsonl(Array.from({ length: 10 }, (_, i) => editLine(`e${i}`, i)));
|
||
syncForward(store, file, 300);
|
||
const res = store.applyOlder({
|
||
transcriptId: 'conv-1',
|
||
source: MAIN_SOURCE,
|
||
startOffset: 0,
|
||
chunk: file.subarray(0, 10),
|
||
size: file.length,
|
||
});
|
||
expect(res.added).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('EditHistoryStore: Bash images', () => {
|
||
it('pairs a Bash call and its result across a chunk boundary', () => {
|
||
const store = new EditHistoryStore('s1', { dir });
|
||
const part1 = jsonl([bashUse('b1', 1, 'python render.py -o out/frame.png')]);
|
||
const file = Buffer.concat([part1, jsonl([bashResult('b1', 4)])]);
|
||
expect(syncForward(store, part1)?.candidates).toEqual([]);
|
||
const res = syncForward(store, file)!;
|
||
expect(res.candidates).toEqual([
|
||
{ toolUseId: 'b1', start: T0 + 1000, end: T0 + 4000, paths: ['/work/out/frame.png'] },
|
||
]);
|
||
});
|
||
|
||
it('adds confirmed images as entries without a diff, once', () => {
|
||
const store = new EditHistoryStore('s1', { dir });
|
||
const img = { toolUseId: 'b1', path: '/work/out/frame.png', ts: T0 + 4000, agentId: 'a1' };
|
||
expect(store.addImageEntries([img, img])).toBe(1);
|
||
expect(store.addImageEntries([img])).toBe(0);
|
||
expect(store.list()[0]).toEqual({
|
||
id: 'b1:/work/out/frame.png',
|
||
ts: T0 + 4000,
|
||
path: '/work/out/frame.png',
|
||
kind: 'image',
|
||
tool: 'bash',
|
||
created: false,
|
||
added: 0,
|
||
removed: 0,
|
||
hunks: [],
|
||
truncated: false,
|
||
agentId: 'a1',
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('EditHistoryStore: cap', () => {
|
||
it(`keeps the newest ${MAX_ENTRIES} entries`, () => {
|
||
const store = new EditHistoryStore('s1', { dir });
|
||
const file = jsonl(Array.from({ length: MAX_ENTRIES + 20 }, (_, i) => editLine(`e${i}`, i)));
|
||
const res = syncForward(store, file, file.length);
|
||
expect(res?.added).toBe(MAX_ENTRIES);
|
||
expect(store.list()).toHaveLength(MAX_ENTRIES);
|
||
expect(store.get('e19')).toBeUndefined();
|
||
expect(store.get('e20')).toBeDefined();
|
||
// Older than everything kept: counts as nothing added.
|
||
expect(store.addImageEntries([{ toolUseId: 'old', path: '/a.png', ts: T0 - 1000 }])).toBe(0);
|
||
});
|
||
|
||
it('offers no older block once the cap is full (it would be dropped on read)', () => {
|
||
const store = new EditHistoryStore('s1', { dir });
|
||
const lines = Array.from({ length: MAX_ENTRIES + 20 }, (_, i) => editLine(`e${i}`, i));
|
||
const file = jsonl(lines);
|
||
const tail = jsonl(lines.slice(-MAX_ENTRIES)).length;
|
||
syncForward(store, file, tail + 10);
|
||
expect(store.list()).toHaveLength(MAX_ENTRIES);
|
||
expect(store.getCursor(MAIN_SOURCE)!.floor).toBeGreaterThan(0);
|
||
expect(store.backfillTruncated).toBe(false);
|
||
});
|
||
|
||
it(`keeps at most ${MAX_STORE_BYTES} bytes of diff text, dropping the oldest`, () => {
|
||
const store = new EditHistoryStore('s1', { dir });
|
||
const n = 300;
|
||
const file = jsonl(Array.from({ length: n }, (_, i) => bigEditLine(`e${i}`, i, 20_000)));
|
||
syncForward(store, file, file.length);
|
||
const kept = store.list();
|
||
expect(kept.length).toBeLessThan(n);
|
||
expect(kept.reduce((sum, e) => sum + entryBytes(e), 0)).toBeLessThanOrEqual(MAX_STORE_BYTES);
|
||
expect(store.get(`e${n - 1}`)).toBeDefined();
|
||
expect(store.get('e0')).toBeUndefined();
|
||
});
|
||
|
||
it('offers no older block once the byte cap is full', () => {
|
||
const store = new EditHistoryStore('s1', { dir });
|
||
const file = jsonl(Array.from({ length: 300 }, (_, i) => bigEditLine(`e${i}`, i, 20_000)));
|
||
syncForward(store, file, 5_000_000);
|
||
expect(store.list().length).toBeLessThan(MAX_ENTRIES);
|
||
expect(store.getCursor(MAIN_SOURCE)!.floor).toBeGreaterThan(0);
|
||
expect(store.backfillTruncated).toBe(false);
|
||
});
|
||
|
||
it('caps the agent meta strings', () => {
|
||
const meta = parseAgentMeta(JSON.stringify({ description: 'd'.repeat(5000), agentType: 'coding' }));
|
||
expect(meta?.description).toHaveLength(MAX_AGENT_META_CHARS);
|
||
expect(meta?.agentType).toBe('coding');
|
||
});
|
||
});
|
||
|
||
describe('EditHistoryStore: persistence', () => {
|
||
it('reloads entries, cursors, pending Bash calls and agents from disk', async () => {
|
||
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
|
||
const file = jsonl([editLine('e1', 1), bashUse('b1', 2, 'render > a.png')]);
|
||
syncForward(store, file);
|
||
store.setAgentMeta('a1', '{"description":"d","agentType":"coding"}');
|
||
await store.flush();
|
||
const loaded = await EditHistoryStore.load('s1', { dir });
|
||
expect(ids(loaded)).toEqual(['e1']);
|
||
expect(loaded.getCursor(MAIN_SOURCE)).toEqual(store.getCursor(MAIN_SOURCE));
|
||
expect(loaded.getTranscriptId()).toBe('conv-1');
|
||
expect(loaded.getAgents()).toEqual({ a1: { description: 'd', agentType: 'coding' } });
|
||
const done = Buffer.concat([file, jsonl([bashResult('b1', 3)])]);
|
||
expect(syncForward(loaded, done)?.candidates[0]?.paths).toEqual(['/work/a.png']);
|
||
});
|
||
|
||
it('writes the file 0600 in a 0700 directory, also over a wider existing file', async () => {
|
||
const sub = join(dir, 'edit-history');
|
||
const store = new EditHistoryStore('s1', { dir: sub, debounceMs: 60_000 });
|
||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||
await store.flush();
|
||
expect(statSync(sub).mode & 0o777).toBe(0o700);
|
||
expect(statSync(join(sub, 's1.json')).mode & 0o777).toBe(0o600);
|
||
writeFileSync(join(sub, 's1.json'), '{}', { mode: 0o644 });
|
||
await store.flush();
|
||
expect(statSync(join(sub, 's1.json')).mode & 0o777).toBe(0o600);
|
||
});
|
||
|
||
it('applies the caps and drops malformed entries when loading', async () => {
|
||
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
|
||
syncForward(store, jsonl(Array.from({ length: MAX_ENTRIES }, (_, i) => editLine(`e${i}`, i))));
|
||
await store.flush();
|
||
const data = JSON.parse(readFileSync(join(dir, 's1.json'), 'utf-8'));
|
||
const extra = JSON.parse(editLine('late', MAX_ENTRIES + 1));
|
||
data.entries.push(
|
||
{ ...store.get('e0')!, id: 'newest', ts: T0 + 10_000_000 },
|
||
{ id: 'broken', path: 7, hunks: [] },
|
||
{ id: 'nohunks', path: '/a.ts' },
|
||
extra
|
||
);
|
||
writeFileSync(join(dir, 's1.json'), JSON.stringify(data));
|
||
const loaded = await EditHistoryStore.load('s1', { dir });
|
||
expect(loaded.list()).toHaveLength(MAX_ENTRIES);
|
||
expect(loaded.get('newest')).toBeDefined();
|
||
expect(loaded.get('e0')).toBeUndefined();
|
||
expect(loaded.get('broken')).toBeUndefined();
|
||
expect(loaded.get('nohunks')).toBeUndefined();
|
||
});
|
||
|
||
it('ignores a file of an unknown version', async () => {
|
||
writeFileSync(join(dir, 'v9.json'), JSON.stringify({ version: 9, entries: [{ id: 'x' }] }));
|
||
expect((await EditHistoryStore.load('v9', { dir })).list()).toEqual([]);
|
||
});
|
||
|
||
it('writes debounced', async () => {
|
||
const store = new EditHistoryStore('s1', { dir, debounceMs: 20 });
|
||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||
const path = join(dir, 's1.json');
|
||
expect(existsSync(path)).toBe(false);
|
||
await new Promise((r) => setTimeout(r, 80));
|
||
expect(JSON.parse(readFileSync(path, 'utf-8')).entries).toHaveLength(1);
|
||
});
|
||
|
||
it('starts empty from a missing or corrupt file', async () => {
|
||
expect((await EditHistoryStore.load('none', { dir })).list()).toEqual([]);
|
||
const store = new EditHistoryStore('bad', { dir });
|
||
await store.flush();
|
||
writeFileSync(join(dir, 'bad.json'), '{broken');
|
||
expect((await EditHistoryStore.load('bad', { dir })).list()).toEqual([]);
|
||
});
|
||
|
||
it('remove() deletes the file and stops later writes', async () => {
|
||
const store = new EditHistoryStore('s1', { dir, debounceMs: 10 });
|
||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||
await store.flush();
|
||
await store.remove();
|
||
expect(existsSync(join(dir, 's1.json'))).toBe(false);
|
||
syncForward(store, jsonl([editLine('e1', 1), editLine('e2', 2)]));
|
||
await new Promise((r) => setTimeout(r, 40));
|
||
await store.flush();
|
||
expect(existsSync(join(dir, 's1.json'))).toBe(false);
|
||
});
|
||
|
||
it('defaults to the data directory', () => {
|
||
const store = new EditHistoryStore('s1');
|
||
expect((store as unknown as { file: string }).file).toMatch(/edit-history[\\/]s1\.json$/);
|
||
});
|
||
});
|