refactor: inject the storage backend into App (improvise-fuj)

The ticket named two effects reading files directly, but that was the visible
symptom. App had no storage backend at all, so Save, SaveAs, ExportCsv,
LoadModel and CSV import all hardwired the filesystem through persistence and
csv_parser.

App now holds a Box<dyn Storage> chosen by the host at construction. App::new
still supplies PhysicalFs, so every existing caller is untouched;
App::with_storage takes any other backend. A browser worker constructs App
with an OPFS backend and nothing else changes.

The alternatives on the ticket were a parameter on each effect, which spreads
per-session config through every signature, or a thread-local, which hides a
dependency App is the natural owner of — it already holds the keymap set and
terminal size.

improvise-io keeps its path-taking wrappers so the native CLI is unchanged,
each delegating to a backend- or bytes-taking variant: export_csv gains
export_csv_to over a render_csv that produces bytes, and
parse_csv_with_warnings gains parse_csv_bytes_with_warnings.

Production code in improvise-command now contains no std::fs.

The test is the point rather than the refactor: it drives SaveAs, ExportCsv,
LoadModel and StartImportWizard over a MemoryFs with no disk touched, then
inspects the backend to confirm the bytes landed there. Any of those effects
reaching for std::fs fails it — the property a browser backend depends on,
verified now instead of discovered later in wasm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V69PGQfMJuGRmtSWpfVAqn
This commit is contained in:
Edward Langley
2026-08-30 10:01:22 -07:00
parent 200c9955e4
commit cc3889d362
7 changed files with 183 additions and 38 deletions
+13
View File
@@ -206,3 +206,16 @@ wasm_non_hazard(flate2, 'compiles for wasm as-is').
invariant(compile_check_is_not_runtime_proof,'the wasm CI gate is cargo check, so it catches nothing that merely panics at runtime: Instant and std::fs both COMPILE for wasm32-unknown-unknown').
work_done('improvise-ywd',audit,'added wasm32-unknown-unknown to the flake toolchain, which was the real blocker; all four bundle crates then checked clean with zero source changes. Swapped Instant for web_time and added the CI wasm job.').
location(wasm_ci,'.github/workflows/ci.yml','wasm job').
% ── Storage injection (improvise-fuj) ───────────────────────────────────────
storage_injection_point('App::storage', 'Box<dyn Storage>', 'chosen by the host at construction').
storage_host(native_cli, physical_fs).
storage_host(tests, memory_fs).
storage_host(browser_worker, opfs, planned('improvise-i34')).
invariant(effects_never_touch_fs,'every document read or write in the command layer goes through App::storage; production code in improvise-command contains no std::fs').
invariant(io_keeps_path_wrappers,'improvise-io keeps path-taking wrappers (save, load, export_csv, parse_csv_with_warnings) delegating to _to/_from/_bytes variants, so the native CLI is unchanged while the byte-level path is backend-agnostic').
% Why the sync trait survives contact with OPFS.
opfs_api(promise_based, async, 'getDirectory, getFileHandle, createWritable').
opfs_api(sync_access_handle, sync, 'createSyncAccessHandle; worker-only; read/write/flush/close are blocking').
decision('improvise-i34',keep_storage_sync,'improvise-9x6 hosts App and persistence inside a browser worker, where FileSystemSyncAccessHandle is available. Async handle acquisition happens once at worker init; read/write are then synchronous, so the two-method sync trait stands and no async migration is needed. Caveat: asserted from API knowledge, untested here; the IndexedDB fallback IS async-only and would reopen the question.').
work_done('improvise-fuj',refactor,'App gained a storage backend; Save/SaveAs/ExportCsv/LoadModel/StartImportWizard and CSV import all route through it. Test drives save, export, load and wizard-seed over MemoryFs with no disk.').