From 25a56a96c6921b85cef76b2df857061b28fe6772 Mon Sep 17 00:00:00 2001 From: Tony Ketcham Date: Fri, 17 Jul 2026 15:29:33 -0700 Subject: [PATCH] refactor(effort-graph): per-mutate snapshot + Decision lifecycle module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every EffortGraphIndex method re-scanned and re-parsed every .md file under all six collection dirs, so planning an AcceptDecision cost ≥2 full scans and a Write* with N edges cost 1 + N — with two scans mid-plan able to observe different disk states (consistency window). Decision lifecycle rules were inlined in planMutation's 290-line switch and testable only through the full writer+journal path. - New snapshot.ts: buildEffortGraphSnapshot scans once into frozen records with byId/byEffort/byKind lookups; getRawBytes supplies journal before-images; createEffortGraphSnapshot is an in-memory adapter so planner/lifecycle tests need no filesystem. EffortGraphWriterOptions.index is retyped to the injectable EffortGraphSnapshotSource. - writer.ts builds the snapshot exactly once per mutate — under the lock, after journal recovery, before planMutation — so a plan sees one consistent disk state; planMutation is now synchronous over the snapshot and never touches disk. - New decision-lifecycle.ts owns the semantic rules: accept validates proposed state, rejects proposed same-Effort siblings with rejected_by; supersede flips a Decision target to state: superseded. Planner keeps generic expansion to PlannedWrites. - index-store.ts keeps EffortGraphIndex for external readers, reimplemented over one snapshot build per lookup. Journal protocol, lock, and frontmatter byte-handling are untouched — journal.ts, lock.ts, frontmatter.ts and their crash-safety suites have zero diff. Test plan: new suites for snapshot (immutability, one-scan consistency), planner-against-snapshot (planned writes incl. beforeBytes asserted against snapshot raw bytes), and direct decision-lifecycle units (sibling rejection, supersede, no-siblings / already-rejected / non-proposed edges); writer.test.ts extended append-only with one-buildSnapshot-per-mutate proof. Full suite green: pnpm verify (243 AVA incl. all 61 effort-graph tests, 52 vitest). Co-authored-by: Cursor Change-Id: I1b760eb2c8bd404c59b087c28973a2f95decd9db --- .../src/__tests__/decision-lifecycle.test.ts | 126 +++++ .../src/__tests__/planner.test.ts | 436 ++++++++++++++++++ .../src/__tests__/snapshot.test.ts | 168 +++++++ .../effort-graph/src/__tests__/writer.test.ts | 83 +++- .../effort-graph/src/decision-lifecycle.ts | 60 +++ packages/effort-graph/src/index-store.ts | 66 +-- packages/effort-graph/src/index.ts | 2 + packages/effort-graph/src/planner.ts | 164 +++---- packages/effort-graph/src/snapshot.ts | 130 ++++++ packages/effort-graph/src/types.ts | 2 +- packages/effort-graph/src/writer.ts | 10 +- 11 files changed, 1116 insertions(+), 131 deletions(-) create mode 100644 packages/effort-graph/src/__tests__/decision-lifecycle.test.ts create mode 100644 packages/effort-graph/src/__tests__/planner.test.ts create mode 100644 packages/effort-graph/src/__tests__/snapshot.test.ts create mode 100644 packages/effort-graph/src/decision-lifecycle.ts create mode 100644 packages/effort-graph/src/snapshot.ts diff --git a/packages/effort-graph/src/__tests__/decision-lifecycle.test.ts b/packages/effort-graph/src/__tests__/decision-lifecycle.test.ts new file mode 100644 index 00000000..07528834 --- /dev/null +++ b/packages/effort-graph/src/__tests__/decision-lifecycle.test.ts @@ -0,0 +1,126 @@ +import test from 'ava'; +import { EffortGraphValidationError } from '../errors.js'; +import { createEffortGraphSnapshot } from '../snapshot.js'; +import { + acceptDecisionLifecycle, + supersedeDecisionLifecycle, +} from '../decision-lifecycle.js'; + +const effort = 'eff-one--0123456789abcdef'; +const ids = [ + 'dec-a--0123456789abcdef', + 'dec-b--0123456789abcdef', + 'dec-c--0123456789abcdef', +]; +function snapshot( + states = ['proposed', 'proposed', 'rejected'], + foreign = false +) { + return createEffortGraphSnapshot( + ids.map((id, i) => ({ + id, + kind: 'decision' as const, + path: `decisions/${id}.md`, + frontmatter: { + id, + effort: foreign && i === 2 ? 'eff-two--0123456789abcdef' : effort, + title: id, + created_at: '2025-01-01T00:00:00.000Z', + state: states[i], + }, + body: '', + rawBytes: Buffer.from(id), + })) + ); +} +test('21 acceptance rejects proposed siblings', (t) => { + const changes = acceptDecisionLifecycle(snapshot(), { + decisionId: ids[0], + rejectSiblings: true, + }); + t.deepEqual( + changes.map((x) => [ + x.record.id, + x.nextFrontmatter.state, + x.nextFrontmatter.rejected_by, + ]), + [ + [ids[0], 'accepted', undefined], + [ids[1], 'rejected', ids[0]], + ] + ); +}); +test('22 supersede flips a Decision target', (t) => { + const originalFrontmatter = { + id: ids[0], + effort, + title: ids[0], + created_at: '2025-01-01T00:00:00.000Z', + state: 'proposed', + }; + const change = supersedeDecisionLifecycle(snapshot(), ids[0]); + t.deepEqual(change.nextFrontmatter, { + ...originalFrontmatter, + state: 'superseded', + }); +}); +test('23 no siblings is valid', (t) => { + t.is( + acceptDecisionLifecycle(snapshot(['proposed', 'rejected', 'rejected']), { + decisionId: ids[0], + rejectSiblings: true, + }).length, + 1 + ); +}); +test('24 already-rejected siblings remain untouched', (t) => { + t.deepEqual( + acceptDecisionLifecycle(snapshot(), { + decisionId: ids[0], + rejectSiblings: true, + }).map((x) => x.record.id), + [ids[0], ids[1]] + ); +}); +test('25 accepting a non-proposed Decision throws', async (t) => { + await t.throwsAsync( + Promise.resolve().then(() => + acceptDecisionLifecycle(snapshot(['accepted', 'proposed', 'rejected']), { + decisionId: ids[0], + rejectSiblings: true, + }) + ), + { + instanceOf: EffortGraphValidationError, + message: 'Decision is not proposed', + } + ); + await t.throwsAsync( + Promise.resolve().then(() => + acceptDecisionLifecycle(snapshot(['rejected', 'proposed', 'rejected']), { + decisionId: ids[0], + rejectSiblings: true, + }) + ), + { + instanceOf: EffortGraphValidationError, + message: 'Decision is not proposed', + } + ); +}); +test('26 rejectSiblings false and foreign siblings', (t) => { + t.is( + acceptDecisionLifecycle( + snapshot(['proposed', 'proposed', 'proposed'], true), + { decisionId: ids[0], rejectSiblings: false } + ).length, + 1 + ); + t.deepEqual( + acceptDecisionLifecycle( + snapshot(['proposed', 'proposed', 'proposed'], true), + { decisionId: ids[0], rejectSiblings: true } + ).map((x) => x.record.id), + [ids[0], ids[1]] + ); +}); diff --git a/packages/effort-graph/src/__tests__/planner.test.ts b/packages/effort-graph/src/__tests__/planner.test.ts new file mode 100644 index 00000000..acae80d9 --- /dev/null +++ b/packages/effort-graph/src/__tests__/planner.test.ts @@ -0,0 +1,436 @@ +import test from 'ava'; +import { parseDocument, serializeDocument } from '../frontmatter.js'; +import { createEffortGraphSnapshot } from '../snapshot.js'; +import { planMutation } from '../planner.js'; +import type { PrimitiveKind } from '../types.js'; + +const E = 'eff-one--0123456789abcdef'; +const ids = { + issue: 'iss-one--0123456789abcdef', + finding: 'fnd-one--0123456789abcdef', + decision: 'dec-one--0123456789abcdef', + constraint: 'con-one--0123456789abcdef', + risk: 'rsk-one--0123456789abcdef', + decision2: 'dec-two--0123456789abcdef', +}; +function snap(extra: any[] = []) { + const base = [ + { + id: E, + kind: 'effort' as const, + path: `efforts/${E}.md`, + frontmatter: { + id: E, + title: 'E', + created_at: '2025-01-01T00:00:00.000Z', + status: 'active', + }, + body: 'eb', + }, + ]; + return createEffortGraphSnapshot( + [...base, ...extra].map((x) => ({ + ...x, + rawBytes: serializeDocument(x.body, x.frontmatter), + })) + ); +} +function record( + id: string, + kind: PrimitiveKind, + fm: Record, + body = '' +) { + return { id, kind, path: `${kind}s/${id}.md`, frontmatter: fm, body }; +} +const now = new Date('2025-02-01T00:00:00.000Z'); +const random = () => new Uint8Array(10); +function one( + t: any, + writes: any[], + id: string, + path: string, + op: string, + expected: Record +) { + t.is(writes.length, 1); + t.is(writes[0].id, id); + t.is(writes[0].relativePath, path); + t.is(writes[0].operation, op); + t.deepEqual( + parseDocument(writes[0].afterBytes, writes[0].kind).frontmatter, + expected + ); +} +test('8 CreateEffort', (t) => { + const id = 'eff-new--0000000000000000'; + const w = planMutation( + { type: 'CreateEffort', id, title: 'New', body: '' }, + snap(), + '/root', + now, + random + ); + one(t, w, id, `efforts/${id}.md`, 'create', { + id, + title: 'New', + status: 'active', + created_at: now.toISOString(), + }); + t.is(w[0].beforeBytes, undefined); +}); +test('9 SetEffortStatus', (t) => { + const s = snap(); + const w = planMutation( + { type: 'SetEffortStatus', effortId: E, status: 'paused' }, + s, + '/root', + now + ); + one(t, w, E, `efforts/${E}.md`, 'update', { + id: E, + title: 'E', + status: 'paused', + created_at: '2025-01-01T00:00:00.000Z', + }); + t.deepEqual(w[0].beforeBytes, s.getRawBytes(E)); +}); +test('10 WriteIssue', (t) => { + const i = ids.issue; + const w = planMutation( + { + type: 'WriteIssue', + id: i, + effort: E, + title: 'I', + body: '', + kind: 'question', + }, + snap(), + '/root', + now + ); + one(t, w, i, `issues/${i}.md`, 'create', { + id: i, + effort: E, + title: 'I', + kind: 'question', + created_at: now.toISOString(), + status: 'open', + }); +}); +test('11 WriteFinding with supersedes', (t) => { + const target = record(ids.finding, 'finding', { + id: ids.finding, + effort: E, + title: 'F', + kind: 'x', + created_at: '2025-01-01T00:00:00.000Z', + }); + const s = snap([target]); + const w = planMutation( + { + type: 'WriteFinding', + id: 'fnd-new--0123456789abcdef', + effort: E, + title: 'N', + body: '', + kind: 'x', + supersedes: [ids.finding], + }, + s, + '/root', + now + ); + t.deepEqual( + w.map((x) => x.id), + ['fnd-new--0123456789abcdef', ids.finding] + ); + t.deepEqual( + parseDocument(w[1].afterBytes, 'finding').frontmatter.superseded_by, + ['fnd-new--0123456789abcdef'] + ); + t.deepEqual(w[1].beforeBytes, s.getRawBytes(ids.finding)); +}); +test('12 WriteDecision with invalidates', (t) => { + const target = record(ids.finding, 'finding', { + id: ids.finding, + effort: E, + title: 'F', + kind: 'x', + created_at: '2025-01-01T00:00:00.000Z', + }); + const id = ids.decision; + const s = snap([target]); + const w = planMutation( + { + type: 'WriteDecision', + id, + effort: E, + title: 'D', + body: '', + invalidates: [ids.finding], + }, + s, + '/root', + now + ); + t.deepEqual( + w.map((x) => x.id), + [id, ids.finding] + ); + t.deepEqual( + parseDocument(w[1].afterBytes, 'finding').frontmatter.invalidated_by, + [id] + ); + t.deepEqual(w[1].beforeBytes, s.getRawBytes(ids.finding)); +}); +test('13 WriteConstraint', (t) => { + const id = ids.constraint; + const w = planMutation( + { + type: 'WriteConstraint', + id, + effort: E, + title: 'C', + body: '', + kind: 'hard', + }, + snap(), + '/root', + now + ); + one(t, w, id, `constraints/${id}.md`, 'create', { + id, + effort: E, + title: 'C', + kind: 'hard', + created_at: now.toISOString(), + }); +}); +test('14 WriteRisk', (t) => { + const id = ids.risk; + const w = planMutation( + { + type: 'WriteRisk', + id, + effort: E, + title: 'R', + body: '', + likelihood: 'low', + severity: 'high', + }, + snap(), + '/root', + now + ); + one(t, w, id, `risks/${id}.md`, 'create', { + id, + effort: E, + title: 'R', + likelihood: 'low', + severity: 'high', + created_at: now.toISOString(), + state: 'open', + }); +}); +test('15 Supersede', (t) => { + const a = record(ids.decision, 'decision', { + id: ids.decision, + effort: E, + title: 'A', + created_at: '2025-01-01T00:00:00.000Z', + state: 'proposed', + }); + const b = record(ids.decision2, 'decision', { + id: ids.decision2, + effort: E, + title: 'B', + created_at: '2025-01-01T00:00:00.000Z', + state: 'proposed', + }); + const s = snap([a, b]); + const w = planMutation( + { type: 'Supersede', supersederId: ids.decision2, targetId: ids.decision }, + s, + '/root', + now + ); + t.deepEqual( + w.map((x) => x.id), + [ids.decision2, ids.decision] + ); + t.is( + parseDocument(w[1].afterBytes, 'decision').frontmatter.state, + 'superseded' + ); + t.deepEqual( + parseDocument(w[1].afterBytes, 'decision').frontmatter.superseded_by, + [ids.decision2] + ); + t.deepEqual(w[0].beforeBytes, s.getRawBytes(ids.decision2)); + t.deepEqual(w[1].beforeBytes, s.getRawBytes(ids.decision)); +}); +test('16 Invalidate', (t) => { + const f = record(ids.finding, 'finding', { + id: ids.finding, + effort: E, + title: 'F', + kind: 'x', + created_at: '2025-01-01T00:00:00.000Z', + }); + const d = record(ids.decision, 'decision', { + id: ids.decision, + effort: E, + title: 'D', + created_at: '2025-01-01T00:00:00.000Z', + state: 'proposed', + }); + const s = snap([f, d]); + const w = planMutation( + { type: 'Invalidate', findingId: ids.finding, targetId: ids.decision }, + s, + '/root', + now + ); + t.deepEqual( + w.map((x) => x.id), + [ids.finding, ids.decision] + ); + t.is( + parseDocument(w[1].afterBytes, 'decision').frontmatter.state, + 'proposed' + ); + t.deepEqual(w[0].beforeBytes, s.getRawBytes(ids.finding)); + t.deepEqual(w[1].beforeBytes, s.getRawBytes(ids.decision)); +}); +test('17 ResolveIssue', (t) => { + const i = record(ids.issue, 'issue', { + id: ids.issue, + effort: E, + title: 'I', + kind: 'x', + created_at: '2025-01-01T00:00:00.000Z', + status: 'open', + }); + const f = record(ids.finding, 'finding', { + id: ids.finding, + effort: E, + title: 'F', + kind: 'x', + created_at: '2025-01-01T00:00:00.000Z', + }); + const s = snap([i, f]); + const w = planMutation( + { + type: 'ResolveIssue', + issueId: ids.issue, + resolution: 'resolved', + resolvedBy: [ids.finding], + }, + s, + '/root', + now + ); + t.is(parseDocument(w[0].afterBytes, 'issue').frontmatter.status, 'resolved'); + t.deepEqual(parseDocument(w[0].afterBytes, 'issue').frontmatter.resolved_by, [ + ids.finding, + ]); + t.deepEqual(w[0].beforeBytes, s.getRawBytes(ids.issue)); +}); +test('18 AcceptDecision', (t) => { + const a = record(ids.decision, 'decision', { + id: ids.decision, + effort: E, + title: 'A', + created_at: '2025-01-01T00:00:00.000Z', + state: 'proposed', + }); + const b = record(ids.decision2, 'decision', { + id: ids.decision2, + effort: E, + title: 'B', + created_at: '2025-01-01T00:00:00.000Z', + state: 'proposed', + }); + const w = planMutation( + { type: 'AcceptDecision', decisionId: ids.decision }, + snap([a, b]), + '/root', + now + ); + t.deepEqual( + w.map((x) => x.id), + [ids.decision, ids.decision2] + ); + t.is( + parseDocument(w[0].afterBytes, 'decision').frontmatter.state, + 'accepted' + ); + t.is( + parseDocument(w[1].afterBytes, 'decision').frontmatter.rejected_by, + ids.decision + ); +}); +test('19 MitigateRisk', (t) => { + const r = record(ids.risk, 'risk', { + id: ids.risk, + effort: E, + title: 'R', + created_at: '2025-01-01T00:00:00.000Z', + state: 'open', + likelihood: 'low', + severity: 'high', + }); + const d = record(ids.decision, 'decision', { + id: ids.decision, + effort: E, + title: 'D', + created_at: '2025-01-01T00:00:00.000Z', + state: 'accepted', + }); + const s = snap([r, d]); + const w = planMutation( + { type: 'MitigateRisk', riskId: ids.risk, decisionId: ids.decision }, + s, + '/root', + now + ); + t.is(parseDocument(w[0].afterBytes, 'risk').frontmatter.state, 'mitigated'); + t.deepEqual(w[0].beforeBytes, s.getRawBytes(ids.risk)); +}); +test('20 SetRiskState', (t) => { + const r = record(ids.risk, 'risk', { + id: ids.risk, + effort: E, + title: 'R', + created_at: '2025-01-01T00:00:00.000Z', + state: 'open', + likelihood: 'low', + severity: 'high', + }); + const f = record(ids.finding, 'finding', { + id: ids.finding, + effort: E, + title: 'F', + created_at: '2025-01-01T00:00:00.000Z', + kind: 'x', + }); + const s = snap([r, f]); + const w = planMutation( + { + type: 'SetRiskState', + riskId: ids.risk, + state: 'realized', + evidence: [ids.finding], + }, + s, + '/root', + now + ); + t.is(parseDocument(w[0].afterBytes, 'risk').frontmatter.state, 'realized'); + t.deepEqual(parseDocument(w[0].afterBytes, 'risk').frontmatter.evidence, [ + ids.finding, + ]); + t.deepEqual(w[0].beforeBytes, s.getRawBytes(ids.risk)); +}); diff --git a/packages/effort-graph/src/__tests__/snapshot.test.ts b/packages/effort-graph/src/__tests__/snapshot.test.ts new file mode 100644 index 00000000..86037481 --- /dev/null +++ b/packages/effort-graph/src/__tests__/snapshot.test.ts @@ -0,0 +1,168 @@ +import test from 'ava'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { EffortGraphValidationError } from '../errors.js'; +import { serializeDocument } from '../frontmatter.js'; +import { + buildEffortGraphSnapshot, + createEffortGraphSnapshot, +} from '../snapshot.js'; + +const e = 'eff-one--0123456789abcdef'; +const d = 'dec-one--0123456789abcdef'; +const e2 = 'eff-two--0123456789abcdef'; +const input = (id: string, kind: 'effort' | 'decision', effort?: string) => ({ + id, + kind, + path: `${kind}s/${id}.md`, + frontmatter: { + id, + title: id, + created_at: '2025-01-01T00:00:00.000Z', + ...(effort ? { effort, state: 'proposed' } : { status: 'active' }), + }, + body: `body-${id}`, + rawBytes: serializeDocument(`body-${id}`, { + id, + title: id, + created_at: '2025-01-01T00:00:00.000Z', + ...(effort ? { effort, state: 'proposed' } : { status: 'active' }), + }), +}); + +test('1 build capture survives later disk mutation', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'snap-')); + await mkdir(join(root, 'efforts')); + await mkdir(join(root, 'decisions')); + const eb = serializeDocument('old', { + id: e, + title: 'old', + created_at: '2025-01-01T00:00:00.000Z', + status: 'active', + }); + const db = serializeDocument('old', { + id: d, + effort: e, + title: 'old', + created_at: '2025-01-01T00:00:00.000Z', + state: 'proposed', + }); + await writeFile(join(root, 'efforts', `${e}.md`), eb); + await writeFile(join(root, 'decisions', `${d}.md`), db); + const snapshot = await buildEffortGraphSnapshot(root); + await writeFile( + join(root, 'efforts', `${e}.md`), + serializeDocument('new', { + id: e, + title: 'new', + created_at: '2025-01-01T00:00:00.000Z', + status: 'paused', + }) + ); + t.is(snapshot.getRecord(e)?.frontmatter.title, 'old'); + t.is(snapshot.recordsByEffort(e)[0].id, d); + t.deepEqual(snapshot.getRawBytes(e), eb); +}); + +test('2 duplicate IDs fail deterministically', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'snap-')); + await mkdir(join(root, 'efforts')); + await mkdir(join(root, 'decisions')); + const bytes = serializeDocument('', { + id: e, + title: 'x', + created_at: '2025-01-01T00:00:00.000Z', + status: 'active', + }); + await writeFile(join(root, 'efforts', 'a.md'), bytes); + await writeFile( + join(root, 'decisions', 'b.md'), + serializeDocument('', { + id: e, + effort: e, + title: 'x', + created_at: '2025-01-01T00:00:00.000Z', + state: 'proposed', + }) + ); + await t.throwsAsync(buildEffortGraphSnapshot(root), { + instanceOf: EffortGraphValidationError, + message: `Duplicate id ${e} at ${join(root, 'decisions/b.md')}`, + }); +}); + +test('3 missing and empty collection directories contribute no records', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'snap-')); + const a = await buildEffortGraphSnapshot(root); + t.false(a.hasId(e)); + t.deepEqual(a.recordsByKind('effort'), []); + await mkdir(join(root, 'efforts')); + const b = await buildEffortGraphSnapshot(root); + t.deepEqual(b.recordsByEffort(e), []); + t.is(b.getRecord(e), undefined); +}); + +test('4 byEffort filtering is exact', (t) => { + const s = createEffortGraphSnapshot([ + input(e, 'effort'), + input(e2, 'effort'), + input(d, 'decision', e), + ]); + t.deepEqual( + s.recordsByEffort(e).map((x) => x.id), + [d] + ); +}); +test('5 byKind filtering is exact', (t) => { + const s = createEffortGraphSnapshot([ + input(e, 'effort'), + input(e2, 'effort'), + input(d, 'decision', e), + ]); + t.deepEqual( + s.recordsByKind('decision').map((x) => x.id), + [d] + ); +}); +test('6 sibling filtering is exact', (t) => { + const s = createEffortGraphSnapshot([ + input(e, 'effort'), + input(d, 'decision', e), + { + ...input('dec-two--0123456789abcdef', 'decision', e), + frontmatter: { + ...input('dec-two--0123456789abcdef', 'decision', e).frontmatter, + state: 'accepted', + }, + }, + { + ...input('dec-three--0123456789abcdef', 'decision', e), + frontmatter: { + ...input('dec-three--0123456789abcdef', 'decision', e).frontmatter, + state: 'rejected', + }, + }, + input('dec-four--0123456789abcdef', 'decision', e2), + ]); + t.deepEqual( + s.siblingDecisions(e, { state: 'proposed', excludeId: d }).map((x) => x.id), + [] + ); +}); +test('7 constructor protects snapshot data', (t) => { + const fm = { ...input(d, 'decision', e).frontmatter }; + const bytes = Buffer.from('raw'); + const s = createEffortGraphSnapshot([ + { ...input(d, 'decision', e), frontmatter: fm, rawBytes: bytes }, + ]); + fm.title = 'mutated'; + bytes[0] = 0; + const records = s.recordsByKind('decision') as any[]; + records.push(input('x', 'decision', e) as any); + const copy = s.getRawBytes(d)!; + copy[0] = 0; + t.is(s.getRecord(d)?.frontmatter.title, d); + t.is(s.getRawBytes(d)?.toString(), 'raw'); + t.is(s.recordsByKind('decision').length, 1); +}); diff --git a/packages/effort-graph/src/__tests__/writer.test.ts b/packages/effort-graph/src/__tests__/writer.test.ts index a4085a2c..8a791563 100644 --- a/packages/effort-graph/src/__tests__/writer.test.ts +++ b/packages/effort-graph/src/__tests__/writer.test.ts @@ -1,5 +1,5 @@ import test from 'ava'; -import { mkdtemp, readFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import matter from 'gray-matter'; @@ -352,3 +352,84 @@ test('generation tokens increment across successive mutations', async (t) => { t.truthy(third.artifacts[0].frontmatter); t.is(third.artifacts[0].frontmatter.status, 'paused'); }); + +test('writer uses one injected snapshot source', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'eg-snapshot-writer-')); + const id = 'eff-active--0123456789abcdef'; + const snapshot = (await import('../snapshot.js')).createEffortGraphSnapshot([ + { + id, + kind: 'effort', + path: `efforts/${id}.md`, + frontmatter: { + id, + title: 'E', + status: 'active', + created_at: '2025-01-01T00:00:00.000Z', + }, + body: '', + rawBytes: Buffer.from('captured'), + }, + ]); + let count = 0; + const writer = createEffortGraphWriter({ + rootDir: root, + index: { + async buildSnapshot() { + count++; + return snapshot; + }, + }, + }); + const result = await writer.mutate({ + type: 'SetEffortStatus', + effortId: id, + status: 'paused', + }); + t.is(count, 1); + t.is(result.artifacts.length, 1); + t.is(result.artifacts[0].id, id); +}); + +test('snapshot before-image drives journal-compatible end-to-end output', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'eg-snapshot-before-')); + const id = 'eff-captured--0123456789abcdef'; + const captured = (await import('../snapshot.js')).createEffortGraphSnapshot([ + { + id, + kind: 'effort', + path: `efforts/${id}.md`, + frontmatter: { + id, + title: 'Captured', + status: 'active', + created_at: '2025-01-01T00:00:00.000Z', + }, + body: 'captured body', + rawBytes: Buffer.from('captured raw bytes'), + }, + ]); + const writer = createEffortGraphWriter({ + rootDir: root, + index: { + async buildSnapshot() { + await mkdir(join(root, 'efforts'), { recursive: true }); + await import('node:fs/promises').then(({ writeFile }) => + writeFile( + join(root, 'efforts', `${id}.md`), + 'distinguishable disk bytes' + ) + ); + return captured; + }, + }, + }); + const result = await writer.mutate({ + type: 'SetEffortStatus', + effortId: id, + status: 'paused', + }); + t.is(result.artifacts[0].frontmatter.title, 'Captured'); + t.is(result.artifacts[0].frontmatter.status, 'paused'); + t.is(result.artifacts[0].body, 'captured body\n'); +}); diff --git a/packages/effort-graph/src/decision-lifecycle.ts b/packages/effort-graph/src/decision-lifecycle.ts new file mode 100644 index 00000000..1771909e --- /dev/null +++ b/packages/effort-graph/src/decision-lifecycle.ts @@ -0,0 +1,60 @@ +import { EffortGraphValidationError } from './errors.js'; +import type { + EffortGraphSnapshot, + EffortGraphSnapshotArtifact, +} from './snapshot.js'; + +export interface DecisionLifecycleChange { + readonly record: EffortGraphSnapshotArtifact; + readonly nextFrontmatter: Readonly>; +} +export interface AcceptDecisionLifecycleInput { + readonly decisionId: string; + readonly rejectSiblings: boolean; +} +export function acceptDecisionLifecycle( + snapshot: EffortGraphSnapshot, + input: AcceptDecisionLifecycleInput +): readonly DecisionLifecycleChange[] { + const target = snapshot.getRecord(input.decisionId); + if (!target) + throw new EffortGraphValidationError( + `Unknown artifact ${input.decisionId}` + ); + if (target.kind !== 'decision' || target.frontmatter.state !== 'proposed') + throw new EffortGraphValidationError('Decision is not proposed'); + const changes: DecisionLifecycleChange[] = [ + { + record: target, + nextFrontmatter: { ...target.frontmatter, state: 'accepted' }, + }, + ]; + if (input.rejectSiblings) + for (const sibling of snapshot.siblingDecisions( + String(target.frontmatter.effort), + { state: 'proposed', excludeId: target.id } + )) + changes.push({ + record: sibling, + nextFrontmatter: { + ...sibling.frontmatter, + state: 'rejected', + rejected_by: target.id, + }, + }); + return changes; +} +export function supersedeDecisionLifecycle( + snapshot: EffortGraphSnapshot, + decisionId: string +): DecisionLifecycleChange { + const target = snapshot.getRecord(decisionId); + if (!target) + throw new EffortGraphValidationError(`Unknown artifact ${decisionId}`); + if (target.kind !== 'decision') + throw new EffortGraphValidationError('Not a Decision'); + return { + record: target, + nextFrontmatter: { ...target.frontmatter, state: 'superseded' }, + }; +} diff --git a/packages/effort-graph/src/index-store.ts b/packages/effort-graph/src/index-store.ts index 7403c79d..2c283c80 100644 --- a/packages/effort-graph/src/index-store.ts +++ b/packages/effort-graph/src/index-store.ts @@ -1,8 +1,5 @@ -import { readdir, readFile } from 'node:fs/promises'; -import { join, relative } from 'node:path'; -import { parseDocument } from './frontmatter.js'; -import { KIND_DIRECTORY, PREFIX_KIND } from './ids.js'; -import { EffortGraphValidationError } from './errors.js'; +import { PREFIX_KIND } from './ids.js'; +import { buildEffortGraphSnapshot } from './snapshot.js'; import type { EffortGraphIndex, IndexedArtifact, @@ -11,54 +8,37 @@ import type { export function createFilesystemEffortGraphIndex( rootDir: string ): EffortGraphIndex { - async function scan(): Promise { - const result: IndexedArtifact[] = []; - for (const kind of Object.keys(KIND_DIRECTORY) as PrimitiveKind[]) { - const dir = join(rootDir, KIND_DIRECTORY[kind]); - let names: string[] = []; - try { - names = await readdir(dir); - } catch { - continue; - } - for (const name of names.filter((n) => n.endsWith('.md'))) { - const path = join(dir, name); - const parsed = parseDocument(await readFile(path), kind); - const id = String(parsed.frontmatter.id); - if (result.some((x) => x.id === id)) - throw new EffortGraphValidationError(`Duplicate id ${id} at ${path}`); - result.push({ - id, - kind, - path: relative(rootDir, path), - frontmatter: parsed.frontmatter, - body: parsed.body, - }); - } - } - return result; - } + const convert = ( + r: import('./snapshot.js').EffortGraphSnapshotArtifact + ): IndexedArtifact => ({ + id: r.id, + kind: r.kind, + path: r.path, + frontmatter: { ...r.frontmatter }, + body: r.body, + }); return { async getRecord(id) { - return (await scan()).find((x) => x.id === id); + const r = (await buildEffortGraphSnapshot(rootDir)).getRecord(id); + return r && convert(r); }, async recordsByEffort(effortId) { - return (await scan()).filter((x) => x.frontmatter.effort === effortId); + return (await buildEffortGraphSnapshot(rootDir)) + .recordsByEffort(effortId) + .map(convert); }, async recordsByKind(kind) { - return (await scan()).filter((x) => x.kind === kind); + return (await buildEffortGraphSnapshot(rootDir)) + .recordsByKind(kind) + .map(convert); }, async siblingDecisions(effortId, o = {}) { - return (await scan()).filter( - (x) => - x.kind === 'decision' && - x.frontmatter.effort === effortId && - (!o.state || x.frontmatter.state === o.state) && - x.id !== o.excludeId - ); + return (await buildEffortGraphSnapshot(rootDir)) + .siblingDecisions(effortId, o) + .map(convert); }, async hasId(id) { - return !!(await scan()).find((x) => x.id === id); + return (await buildEffortGraphSnapshot(rootDir)).hasId(id); }, }; } diff --git a/packages/effort-graph/src/index.ts b/packages/effort-graph/src/index.ts index 794c6727..a70c3721 100644 --- a/packages/effort-graph/src/index.ts +++ b/packages/effort-graph/src/index.ts @@ -6,5 +6,7 @@ export * from './index-store.js'; export * from './writer.js'; export * from './preset.js'; export * from './errors.js'; +export * from './snapshot.js'; +export * from './decision-lifecycle.js'; export { acquireWriterLock } from './lock.js'; export { recoverJournal } from './journal.js'; diff --git a/packages/effort-graph/src/planner.ts b/packages/effort-graph/src/planner.ts index 7d54229a..c155901f 100644 --- a/packages/effort-graph/src/planner.ts +++ b/packages/effort-graph/src/planner.ts @@ -1,14 +1,19 @@ -import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { EffortGraphValidationError } from './errors.js'; +import { serializeDocument } from './frontmatter.js'; import { generateArtifactId, KIND_DIRECTORY, validateArtifactId, } from './ids.js'; -import { serializeDocument } from './frontmatter.js'; -import type { EffortGraphIndex, PlannedWrite, PrimitiveKind } from './types.js'; +import { + acceptDecisionLifecycle, + supersedeDecisionLifecycle, +} from './decision-lifecycle.js'; +import type { EffortGraphSnapshot } from './snapshot.js'; +import type { PlannedWrite, PrimitiveKind } from './types.js'; import type { EffortGraphMutation } from './schemas.js'; + const kinds: Record = { WriteIssue: 'issue', WriteFinding: 'finding', @@ -16,20 +21,21 @@ const kinds: Record = { WriteConstraint: 'constraint', WriteRisk: 'risk', }; -export async function planMutation( + +export function planMutation( input: EffortGraphMutation, - index: EffortGraphIndex, + snapshot: EffortGraphSnapshot, root: string, now: Date, - randomBytes?: (n: number) => Uint8Array -): Promise { + randomBytes?: (length: number) => Uint8Array +): PlannedWrite[] { const writes = new Map(); - const get = async (id: string) => { - const r = await index.getRecord(id); + const get = (id: string) => { + const r = snapshot.getRecord(id); if (!r) throw new EffortGraphValidationError(`Unknown artifact ${id}`); return r; }; - const add = async ( + const add = ( id: string, kind: PrimitiveKind, fm: Record, @@ -37,16 +43,18 @@ export async function planMutation( operation: 'create' | 'update' = 'update' ) => { const path = join(root, KIND_DIRECTORY[kind], `${id}.md`); - const before = - operation === 'update' - ? await readFile(path).catch(() => undefined) - : undefined; + const beforeBytes = + operation === 'update' ? snapshot.getRawBytes(id) : undefined; + if (operation === 'update' && !beforeBytes) + throw new EffortGraphValidationError( + `Missing snapshot bytes for update ${id}` + ); writes.set(path, { id, kind, absolutePath: path, relativePath: join(KIND_DIRECTORY[kind], `${id}.md`), - beforeBytes: before, + beforeBytes, afterBytes: serializeDocument(body, fm), operation, }); @@ -54,16 +62,18 @@ export async function planMutation( if (input.type === 'CreateEffort') { const id = input.id ?? generateArtifactId('effort', input.title, randomBytes); - if (!validateArtifactId(id, 'effort') || (await index.hasId(id))) + if (!validateArtifactId(id, 'effort') || snapshot.hasId(id)) throw new EffortGraphValidationError(`Invalid or duplicate id ${id}`); - if (input.slug) { - const existingEfforts = await index.recordsByKind('effort'); - if (existingEfforts.some((r) => r.frontmatter.slug === input.slug)) - throw new EffortGraphValidationError( - `Duplicate effort slug ${input.slug}` - ); - } - await add( + if ( + input.slug && + snapshot + .recordsByKind('effort') + .some((r) => r.frontmatter.slug === input.slug) + ) + throw new EffortGraphValidationError( + `Duplicate effort slug ${input.slug}` + ); + add( id, 'effort', { @@ -85,39 +95,38 @@ export async function planMutation( return [...writes.values()]; } if (input.type === 'SetEffortStatus') { - const r = await get(input.effortId); + const r = get(input.effortId); if (r.kind !== 'effort') throw new EffortGraphValidationError('Not an effort'); const old = String(r.frontmatter.status); if (old === input.status || old === 'completed' || old === 'abandoned') throw new EffortGraphValidationError('Illegal effort transition'); - await add(r.id, r.kind, { ...r.frontmatter, status: input.status }, r.body); + add(r.id, r.kind, { ...r.frontmatter, status: input.status }, r.body); return [...writes.values()]; } if (input.type in kinds) { const kind = kinds[input.type]; - const id = - (input as any).id ?? - generateArtifactId(kind, (input as any).title, randomBytes); - if (!validateArtifactId(id, kind) || (await index.hasId(id))) + const raw = input as any; + const id = raw.id ?? generateArtifactId(kind, raw.title, randomBytes); + if (!validateArtifactId(id, kind) || snapshot.hasId(id)) throw new EffortGraphValidationError('Invalid or duplicate id'); - const effort = await get((input as any).effort); + const effort = get(raw.effort); if (effort.kind !== 'effort') throw new EffortGraphValidationError('Invalid effort'); - const fm: any = { - ...input, + const fm: Record = { + ...raw, id, - created_at: (input as any).created_at ?? now.toISOString(), + created_at: raw.created_at ?? now.toISOString(), }; delete fm.type; delete fm.body; if (kind === 'issue') fm.status = 'open'; if (kind === 'decision') fm.state = 'proposed'; if (kind === 'risk') fm.state = 'open'; - await add(id, kind, fm, (input as any).body, 'create'); - for (const edge of ['supersedes', 'invalidates'] as const) { + add(id, kind, fm, raw.body, 'create'); + for (const edge of ['supersedes', 'invalidates'] as const) for (const targetId of (fm[edge] as string[] | undefined) ?? []) { - const target = await get(targetId); + const target = get(targetId); if (edge === 'supersedes' && target.kind !== kind) throw new EffortGraphValidationError( 'Supersedes must target the same kind' @@ -136,7 +145,7 @@ export async function planMutation( ); const reverse = edge === 'supersedes' ? 'superseded_by' : 'invalidated_by'; - await add( + add( target.id, target.kind, { @@ -149,14 +158,13 @@ export async function planMutation( target.body ); } - } return [...writes.values()]; } if (input.type === 'Supersede' || input.type === 'Invalidate') { - const a = await get( + const a = get( input.type === 'Supersede' ? input.supersederId : input.findingId ); - const b = await get(input.targetId); + const b = get(input.targetId); const edge = input.type === 'Supersede' ? 'supersedes' : 'invalidates'; const back = input.type === 'Supersede' ? 'superseded_by' : 'invalidated_by'; @@ -183,7 +191,7 @@ export async function planMutation( throw new EffortGraphValidationError('Target already superseded'); if (((a.frontmatter[edge] as string[] | undefined) ?? []).includes(b.id)) throw new EffortGraphValidationError('Duplicate edge'); - await add( + add( a.id, a.kind, { @@ -192,30 +200,29 @@ export async function planMutation( }, a.body ); - await add( - b.id, - b.kind, - { - ...b.frontmatter, - [back]: [...((b.frontmatter[back] as string[]) || []), a.id], - ...(input.type === 'Supersede' && b.kind === 'decision' - ? { state: 'superseded' } - : {}), - }, - b.body - ); + const targetFm = + input.type === 'Supersede' && b.kind === 'decision' + ? supersedeDecisionLifecycle(snapshot, b.id).nextFrontmatter + : { + ...b.frontmatter, + [back]: [...((b.frontmatter[back] as string[]) || []), a.id], + }; + if (input.type === 'Supersede' && b.kind === 'decision') + (targetFm as Record)[back] = [ + ...((b.frontmatter[back] as string[]) || []), + a.id, + ]; + add(b.id, b.kind, targetFm as Record, b.body); return [...writes.values()]; } if (input.type === 'ResolveIssue') { - const r = await get(input.issueId); + const r = get(input.issueId); if (r.kind !== 'issue' || r.frontmatter.status !== 'open') throw new EffortGraphValidationError('Issue is not open'); - for (const id of input.resolvedBy) { - const x = await get(id); - if (x.frontmatter.effort !== r.frontmatter.effort) + for (const id of input.resolvedBy) + if (get(id).frontmatter.effort !== r.frontmatter.effort) throw new EffortGraphValidationError('Different effort'); - } - await add( + add( r.id, r.kind, { @@ -228,26 +235,21 @@ export async function planMutation( return [...writes.values()]; } if (input.type === 'AcceptDecision') { - const r = await get(input.decisionId); - if (r.kind !== 'decision' || r.frontmatter.state !== 'proposed') - throw new EffortGraphValidationError('Decision is not proposed'); - await add(r.id, r.kind, { ...r.frontmatter, state: 'accepted' }, r.body); - if (input.rejectSiblings !== false) - for (const s of await index.siblingDecisions( - String(r.frontmatter.effort), - { state: 'proposed', excludeId: r.id } - )) - await add( - s.id, - s.kind, - { ...s.frontmatter, state: 'rejected', rejected_by: r.id }, - s.body - ); + for (const change of acceptDecisionLifecycle(snapshot, { + decisionId: input.decisionId, + rejectSiblings: input.rejectSiblings !== false, + })) + add( + change.record.id, + change.record.kind, + { ...change.nextFrontmatter }, + change.record.body + ); return [...writes.values()]; } if (input.type === 'MitigateRisk') { - const r = await get(input.riskId), - d = await get(input.decisionId); + const r = get(input.riskId), + d = get(input.decisionId); if ( r.kind !== 'risk' || r.frontmatter.state !== 'open' || @@ -256,7 +258,7 @@ export async function planMutation( r.frontmatter.effort !== d.frontmatter.effort ) throw new EffortGraphValidationError('Cannot mitigate risk'); - await add( + add( r.id, r.kind, { ...r.frontmatter, state: 'mitigated', mitigated_by: d.id }, @@ -265,10 +267,10 @@ export async function planMutation( return [...writes.values()]; } if (input.type === 'SetRiskState') { - const r = await get(input.riskId); + const r = get(input.riskId); if (r.kind !== 'risk' || r.frontmatter.state !== 'open') throw new EffortGraphValidationError('Risk is not open'); - const evidence = await Promise.all(input.evidence.map(get)); + const evidence = input.evidence.map(get); for (const x of evidence) if (x.frontmatter.effort !== r.frontmatter.effort) throw new EffortGraphValidationError('Different effort'); @@ -277,7 +279,7 @@ export async function planMutation( !evidence.some((x) => x.kind === 'finding') ) throw new EffortGraphValidationError('Realized risk requires finding'); - await add( + add( r.id, r.kind, { ...r.frontmatter, state: input.state, evidence: input.evidence }, diff --git a/packages/effort-graph/src/snapshot.ts b/packages/effort-graph/src/snapshot.ts new file mode 100644 index 00000000..ec470861 --- /dev/null +++ b/packages/effort-graph/src/snapshot.ts @@ -0,0 +1,130 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; +import { EffortGraphValidationError } from './errors.js'; +import { parseDocument } from './frontmatter.js'; +import { KIND_DIRECTORY } from './ids.js'; +import type { PrimitiveKind } from './types.js'; + +export interface EffortGraphSnapshotArtifact { + readonly id: string; + readonly kind: PrimitiveKind; + readonly path: string; + readonly frontmatter: Readonly>; + readonly body: string; +} +export interface EffortGraphSnapshotArtifactInput + extends Omit { + readonly frontmatter: Readonly>; + readonly rawBytes: Buffer; +} +export interface EffortGraphSnapshot { + getRecord(id: string): EffortGraphSnapshotArtifact | undefined; + hasId(id: string): boolean; + recordsByEffort(effortId: string): readonly EffortGraphSnapshotArtifact[]; + recordsByKind(kind: PrimitiveKind): readonly EffortGraphSnapshotArtifact[]; + siblingDecisions( + effortId: string, + options?: Readonly<{ state?: string; excludeId?: string }> + ): readonly EffortGraphSnapshotArtifact[]; + getRawBytes(id: string): Buffer | undefined; +} +export interface EffortGraphSnapshotSource { + buildSnapshot(rootDir: string): Promise; +} + +export function createEffortGraphSnapshot( + artifacts: readonly EffortGraphSnapshotArtifactInput[] +): EffortGraphSnapshot { + const byId = new Map(); + const raw = new Map(); + const byEffort = new Map(); + const byKind = new Map(); + for (const input of artifacts) { + if (byId.has(input.id)) + throw new EffortGraphValidationError( + `Duplicate id ${input.id} at ${input.path}` + ); + const record = Object.freeze({ + id: input.id, + kind: input.kind, + path: input.path, + frontmatter: Object.freeze({ ...input.frontmatter }), + body: input.body, + }); + byId.set(record.id, record); + raw.set(record.id, Buffer.from(input.rawBytes)); + const effort = record.frontmatter.effort; + if (typeof effort === 'string') { + const list = byEffort.get(effort) ?? []; + list.push(record); + byEffort.set(effort, list); + } + const kindList = byKind.get(record.kind) ?? []; + kindList.push(record); + byKind.set(record.kind, kindList); + } + const freezeLists = (map: Map) => { + for (const list of map.values()) Object.freeze(list); + }; + freezeLists(byEffort); + freezeLists(byKind); + return { + getRecord: (id) => byId.get(id), + hasId: (id) => byId.has(id), + recordsByEffort: (id) => [...(byEffort.get(id) ?? [])], + recordsByKind: (kind) => [...(byKind.get(kind) ?? [])], + siblingDecisions: (effortId, options = {}) => + (byEffort.get(effortId) ?? []).filter( + (r) => + r.kind === 'decision' && + (options.state === undefined || + r.frontmatter.state === options.state) && + r.id !== options.excludeId + ), + getRawBytes: (id) => { + const bytes = raw.get(id); + return bytes && Buffer.from(bytes); + }, + }; +} + +export async function buildEffortGraphSnapshot( + rootDir: string +): Promise { + const artifacts: EffortGraphSnapshotArtifactInput[] = []; + for (const kind of Object.keys(KIND_DIRECTORY) as PrimitiveKind[]) { + const dir = join(rootDir, KIND_DIRECTORY[kind]); + let names: string[]; + try { + names = await readdir(dir); + } catch { + continue; + } + for (const name of names.filter((n) => n.endsWith('.md'))) { + const absolutePath = join(dir, name); + const bytes = await readFile(absolutePath); + const parsed = parseDocument(bytes, kind); + artifacts.push({ + id: String(parsed.frontmatter.id), + kind, + path: relative(rootDir, absolutePath), + frontmatter: parsed.frontmatter, + body: parsed.body, + rawBytes: bytes, + }); + } + } + const seen = new Set(); + for (const artifact of artifacts) { + if (seen.has(artifact.id)) + throw new EffortGraphValidationError( + `Duplicate id ${artifact.id} at ${join(rootDir, artifact.path)}` + ); + seen.add(artifact.id); + } + return createEffortGraphSnapshot(artifacts); +} + +export const filesystemEffortGraphSnapshotSource: EffortGraphSnapshotSource = { + buildSnapshot: buildEffortGraphSnapshot, +}; diff --git a/packages/effort-graph/src/types.ts b/packages/effort-graph/src/types.ts index 89d48589..d44e6410 100644 --- a/packages/effort-graph/src/types.ts +++ b/packages/effort-graph/src/types.ts @@ -55,7 +55,7 @@ export interface WriterLockOptions { } export interface EffortGraphWriterOptions { rootDir: string; - index?: EffortGraphIndex; + index?: import('./snapshot.js').EffortGraphSnapshotSource; indexer?: EffortGraphIndexer; clock?: () => Date; randomBytes?: (length: number) => Uint8Array; diff --git a/packages/effort-graph/src/writer.ts b/packages/effort-graph/src/writer.ts index a3263f8e..74088a12 100644 --- a/packages/effort-graph/src/writer.ts +++ b/packages/effort-graph/src/writer.ts @@ -3,7 +3,7 @@ import { join } from 'node:path'; import { acquireWriterLock } from './lock.js'; import { commitJournal, recoverJournal } from './journal.js'; import { planMutation } from './planner.js'; -import { createFilesystemEffortGraphIndex } from './index-store.js'; +import { filesystemEffortGraphSnapshotSource } from './snapshot.js'; import { parseDocument } from './frontmatter.js'; import type { EffortGraphWriter, @@ -14,8 +14,7 @@ import type { export function createEffortGraphWriter( options: EffortGraphWriterOptions ): EffortGraphWriter { - const index = - options.index ?? createFilesystemEffortGraphIndex(options.rootDir), + const snapshotSource = options.index ?? filesystemEffortGraphSnapshotSource, indexer = options.indexer ?? { reindex: async () => {} }, clock = options.clock ?? (() => new Date()); async function recover() { @@ -40,9 +39,10 @@ export function createEffortGraphWriter( ) ).generation || 0; } catch {} - const writes = await planMutation( + const snapshot = await snapshotSource.buildSnapshot(options.rootDir); + const writes = planMutation( input, - index, + snapshot, options.rootDir, clock(), options.randomBytes