mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Roll back import atomically on storage-quota failure (M6, §08)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user