Add optimistic revisions, keep dirty state on failed saves, quarantine corrupt data
Template tests / tests (pull_request) Failing after 33s
Template tests / tests (pull_request) Failing after 33s
Phase 1 of the improvement plan (PR 4 of the sequence): stop concurrent
whole-object saves from losing edits, stop failed saves from reporting clean,
and stop corrupt user data from silently vanishing.
Revisions (core/schema.js, core/store.js):
- Every guide and step carries a monotonic `revision`, bumped on each store
write. Legacy v1 data without the field reads as revision 0 and upgrades on
its next save — no migration pass, no data rewrite.
- saveGuide/saveStep accept { expectedRevision } for compare-and-swap saves;
a mismatch throws RevisionConflictError instead of clobbering. Direct user
edits pass no expectation (the user is the authority); background writers
must pass one.
Stale AI responses (app/text-intel.js):
- generateStepPatch snapshots the step revision before the (slow) model call,
re-reads the step after it, and saves with the original expectedRevision. A
user edit made during generation now surfaces as "the step changed while AI
was generating; nothing was overwritten" — previously the AI response
silently overwrote the newer edit.
Autosave truthfulness (app/renderer/editor.js):
- flushStep/flushGuide cleared the dirty flag BEFORE awaiting the IPC save,
so a rejected save (invoked via a debounce that never handled rejections)
lost the visible dirty state. The flag is now cleared only after a durable
save; failures keep it dirty, surface a persistent saveError in editor
meta, toast the user, and retry on the next edit or explicit save.
- Navigating away from the editor flushes pending debounced saves so the
last edit can never be dropped by a view switch.
Corruption quarantine (core/store.js):
- listGuides/listSteps used to silently skip unreadable entries — a corrupt
guide just vanished from the library. Corrupt guide/step directories are
now moved to library/quarantine (original bytes preserved) and recorded in
a recovery report (store.getRecoveryReport()) for the UI. Empty in-progress
directories are still skipped quietly — absence of guide.json is not
corruption.
Tests: revision increments, stale-CAS rejection with user edit surviving,
CAS success path, guide CAS, v1 no-revision upgrade, guide/step quarantine
with preserved bytes + recovery report, empty-dir non-quarantine, AI
stale-write rejection end-to-end (user edit mid-generation survives) and the
clean-apply path. 240 unit tests pass; startup smoke and sample-artifact
E2E pass.
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { GuideStore, RevisionConflictError } = require('../../core/store');
|
||||
const { makeTmpDir, rmrf, TINY_PNG } = require('./helpers');
|
||||
|
||||
// ---- revisions --------------------------------------------------------------
|
||||
|
||||
test('revisions start at 0 and increment on every save', (t) => {
|
||||
const root = makeTmpDir('store-rev');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
|
||||
const guide = store.createGuide({ title: 'G' });
|
||||
assert.equal(guide.revision, 0);
|
||||
|
||||
const step = store.addStep(guide.guideId, { title: 'S' }, TINY_PNG, { width: 1, height: 1 });
|
||||
const r0 = store.getStep(guide.guideId, step.stepId).revision;
|
||||
|
||||
const saved1 = store.saveStep(guide.guideId, { ...step, title: 'S1' });
|
||||
assert.equal(saved1.revision, r0 + 1);
|
||||
const saved2 = store.saveStep(guide.guideId, { ...saved1, title: 'S2' });
|
||||
assert.equal(saved2.revision, r0 + 2);
|
||||
});
|
||||
|
||||
test('compare-and-swap: a stale expectedRevision is rejected, not clobbered', (t) => {
|
||||
const root = makeTmpDir('store-cas');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'G' });
|
||||
const step = store.addStep(guide.guideId, { title: 'S' }, TINY_PNG, { width: 1, height: 1 });
|
||||
|
||||
const base = store.getStep(guide.guideId, step.stepId);
|
||||
// A user edit lands first (no expectedRevision -> last-write-wins).
|
||||
store.saveStep(guide.guideId, { ...base, title: 'user edit' });
|
||||
|
||||
// A background writer that read `base` tries to save with the stale revision.
|
||||
assert.throws(
|
||||
() => store.saveStep(guide.guideId, { ...base, title: 'stale background' }, { expectedRevision: base.revision }),
|
||||
(err) => err instanceof RevisionConflictError && err.code === 'STEPFORGE_REVISION_CONFLICT'
|
||||
);
|
||||
// The user edit survived.
|
||||
assert.equal(store.getStep(guide.guideId, step.stepId).title, 'user edit');
|
||||
});
|
||||
|
||||
test('compare-and-swap succeeds when the revision still matches', (t) => {
|
||||
const root = makeTmpDir('store-cas-ok');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'G' });
|
||||
const step = store.addStep(guide.guideId, { title: 'S' }, TINY_PNG, { width: 1, height: 1 });
|
||||
const base = store.getStep(guide.guideId, step.stepId);
|
||||
|
||||
const saved = store.saveStep(guide.guideId, { ...base, title: 'ok' }, { expectedRevision: base.revision });
|
||||
assert.equal(saved.title, 'ok');
|
||||
assert.equal(saved.revision, base.revision + 1);
|
||||
});
|
||||
|
||||
test('guide saves are revision-aware too', (t) => {
|
||||
const root = makeTmpDir('store-guide-cas');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'G' });
|
||||
const base = store.getGuide(guide.guideId);
|
||||
store.saveGuide({ ...base, title: 'first' });
|
||||
assert.throws(
|
||||
() => store.saveGuide({ ...base, title: 'stale' }, { expectedRevision: base.revision }),
|
||||
RevisionConflictError
|
||||
);
|
||||
});
|
||||
|
||||
test('v1 data without a revision field reads as revision 0 and upgrades on save', (t) => {
|
||||
const root = makeTmpDir('store-v1');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'G' });
|
||||
|
||||
// Simulate legacy on-disk data: strip the revision field.
|
||||
const file = path.join(store.guidesDir, guide.guideId, 'guide.json');
|
||||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
delete raw.revision;
|
||||
fs.writeFileSync(file, JSON.stringify(raw));
|
||||
|
||||
const loaded = store.getGuide(guide.guideId);
|
||||
assert.equal(loaded.revision, 0);
|
||||
const saved = store.saveGuide(loaded);
|
||||
assert.equal(saved.revision, 1);
|
||||
});
|
||||
|
||||
// ---- corruption quarantine --------------------------------------------------
|
||||
|
||||
test('a corrupt guide is quarantined and reported, not silently dropped', (t) => {
|
||||
const root = makeTmpDir('store-quarantine-guide');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const good = store.createGuide({ title: 'Good' });
|
||||
const bad = store.createGuide({ title: 'Bad' });
|
||||
|
||||
// Corrupt the bad guide's JSON.
|
||||
fs.writeFileSync(path.join(store.guidesDir, bad.guideId, 'guide.json'), '{ not valid json');
|
||||
|
||||
const listed = store.listGuides();
|
||||
assert.deepEqual(listed.map((g) => g.guideId), [good.guideId]);
|
||||
|
||||
const report = store.getRecoveryReport();
|
||||
assert.equal(report.length, 1);
|
||||
assert.equal(report[0].kind, 'guide');
|
||||
// The original bytes are preserved in quarantine, not deleted.
|
||||
assert.ok(fs.existsSync(report[0].quarantined));
|
||||
// The bad guide dir is gone from the live library.
|
||||
assert.equal(fs.existsSync(path.join(store.guidesDir, bad.guideId)), false);
|
||||
});
|
||||
|
||||
test('a corrupt step is quarantined and reported', (t) => {
|
||||
const root = makeTmpDir('store-quarantine-step');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'G' });
|
||||
const good = store.addStep(guide.guideId, { title: 'good' }, TINY_PNG, { width: 1, height: 1 });
|
||||
const bad = store.addStep(guide.guideId, { title: 'bad' }, TINY_PNG, { width: 1, height: 1 });
|
||||
|
||||
fs.writeFileSync(path.join(store.stepDir(guide.guideId, bad.stepId), 'step.json'), 'nonsense');
|
||||
|
||||
const steps = store.listSteps(guide.guideId);
|
||||
assert.ok(steps.has(good.stepId));
|
||||
assert.equal(steps.has(bad.stepId), false);
|
||||
const report = store.getRecoveryReport();
|
||||
assert.equal(report.some((r) => r.kind === 'step'), true);
|
||||
});
|
||||
|
||||
test('an empty/in-progress guide directory is not treated as corruption', (t) => {
|
||||
const root = makeTmpDir('store-empty-dir');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
// A directory with no guide.json (e.g. mid-create) must be skipped quietly.
|
||||
fs.mkdirSync(path.join(store.guidesDir, 'orphan-dir'), { recursive: true });
|
||||
assert.doesNotThrow(() => store.listGuides());
|
||||
assert.equal(store.getRecoveryReport().length, 0);
|
||||
});
|
||||
Reference in New Issue
Block a user