Roll back import atomically on storage-quota failure (M6, §08)

This commit is contained in:
2026-06-07 20:03:47 +03:00
parent 1bead4d004
commit 30ff7ae357
3 changed files with 211 additions and 6 deletions
+64 -6
View File
@@ -8,6 +8,14 @@
* Order matters on import (spec §08 → Merge): datasets are committed **before**
* snippets so a snippet's by-name reference resolves against the just-added
* dataset (whose name may have been auto-suffixed to avoid a clash).
*
* Atomicity (spec §08 → Storage limit handling / spec §10 → Non-destructive
* import): snippet writes are performed directly to IDB before touching the
* Zustand store. If any write fails the already-written IDB records are deleted
* and the just-added datasets are rolled back from the store, so neither the
* store nor IDB retains a partial import. The write-through subscriber sees
* store changes only after all IDB writes succeed; because `put` is idempotent
* the second (subscriber) write of each record is a harmless no-op.
*/
import { buildExportEnvelope, exportFilename, exportSummaryMessage } from '@core/export-envelope';
@@ -20,6 +28,7 @@ import {
} from '@core/import-normalize';
import { snippetSizeBytes } from '@core/snippet';
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
import { deleteSnippet, saveSnippet, StorageQuotaError } from '../infrastructure/snippet-store';
import { notify } from '../stores/NotificationStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useSnippetStore } from '../stores/SnippetStore';
@@ -122,6 +131,9 @@ export async function importWorkspace(file: File): Promise<void> {
const finalSnippets = reassignCollidingSnippetIds(existingSnippetIds, renamedSnippets);
// Commit datasets BEFORE snippets so by-name references resolve (spec §08).
// Record which dataset ids existed before the add so we can roll them back if
// the subsequent snippet writes fail.
const datasetIdsBefore = new Set(useDatasetStore.getState().datasets.map((d) => d.id));
useDatasetStore.getState().addDatasets(dedupedDatasets);
// Storage budget pre-check (spec §08 → Storage limit handling): warn on overage
@@ -132,12 +144,58 @@ export async function importWorkspace(file: File): Promise<void> {
const incomingBytes = finalSnippets.reduce((n, s) => n + snippetSizeBytes(s), 0);
const overage = existingBytes + incomingBytes - SNIPPET_BUDGET_BYTES;
// TODO (spec §08 → Storage limit handling): a *hard* quota failure on save
// should commit no partial snippet import. The write-through subscriber
// currently surfaces such a failure as a per-record error toast, but the
// in-memory store keeps the records (they vanish on reload). True atomic
// rollback needs a write-before-store import path — a persistence-architecture
// change deferred until the M6 storage monitor (web.dev quota estimate) lands.
// Atomic snippet write (spec §08 → Storage limit handling / spec §10 →
// Non-destructive import): write every snippet to IDB before updating the
// Zustand store. A quota failure mid-write is caught here, the already-written
// records are deleted, the just-added datasets are removed from the store, and
// we surface a clear actionable error — leaving the workspace exactly as before.
// After all writes succeed, `addSnippets` makes them visible in the store; the
// write-through subscriber re-runs `saveSnippet` for each, but `put` is
// idempotent so those second writes are harmless.
const writtenSnippetIds: string[] = [];
try {
for (const snippet of finalSnippets) {
await saveSnippet(snippet);
writtenSnippetIds.push(snippet.id);
}
} catch (err) {
// Roll back: delete every IDB record we already wrote.
await Promise.allSettled(writtenSnippetIds.map((id) => deleteSnippet(id)));
// Roll back: remove the datasets we just added to the store so neither
// store nor IDB retains any trace of this import.
const addedDatasets = useDatasetStore
.getState()
.datasets.filter((d) => !datasetIdsBefore.has(d.id));
for (const d of addedDatasets) {
useDatasetStore.getState().remove(d.id);
}
// Surface a clear, actionable error (spec §08 "Quota failure"; NN/g #9 /
// GOV.UK plain language — no codes, tell the user what to do next).
if (err instanceof StorageQuotaError) {
notify({
kind: 'error',
title: 'Import failed — storage full',
message:
'The import could not be saved because snippet storage is full. ' +
'Delete snippets you no longer need to free up space, then try importing again.',
});
} else {
notify({
kind: 'error',
title: 'Import failed',
message:
'A storage error prevented the import from completing. The workspace was not changed. ' +
'If this keeps happening, your browser may be blocking local storage.',
detail:
err instanceof Error ? `Snippet save failed: ${err.name}: ${err.message}` : String(err),
});
}
return;
}
// All IDB writes succeeded — now make the snippets visible in the store.
useSnippetStore.getState().addSnippets(finalSnippets);
// Feedback (spec §08 → Feedback): one summary toast; a warning when datasets were