# Session handoff — 2026-05-08 (3) Third handover. Continues track 2's ADR-0015 work: this session completed Iterations 5 and 6 (export/import, `--resume`, persistent input-history hydration, migration framework scaffold), closing out the remaining track-2 backlog from the previous handoff. ## State at handoff **Branch:** `main`. Working tree dirty (this handoff doc + the iteration changes). The track-2 commits since handoff-2 are pending the user's commit approval. **Tests:** 408 passing, 0 failing, 0 skipped (up from 345 at the previous handoff). Breakdown: ``` unit (lib) 295 (272 + 23 new) project_lifecycle.rs 0 walking_skeleton.rs 5 iteration2_persistence.rs 6 iteration3_rebuild.rs 9 iteration4a_rebuild_command.rs 17 iteration4b_lifecycle_commands.rs 27 iteration5_export_import.rs 14 (new) iteration6_resume_history.rs 15 (new) --- 408 total ``` **Clippy:** clean with the nursery lint group enabled. **Release build:** ~6.9MB single binary (up from 6.3MB at handoff-2; the increase is the `zip` + `flate2` + `zlib-rs` chain pulled in for export/import). ## What's implemented (delta vs. handoff-2) ### Iteration 5 — `export` / `import` (ADR-0015 §11 + ### ADR-0007 amendment 1) **`export []` command:** - Available in both modes per ADR-0003. Wired through `Action::Export` and a new `spawn_export` task in the runtime that does the zip work on a `spawn_blocking` thread. - Default output: `/YYYYMMDD--export-NN.zip` where `NN` is a non-clobbering two-digit sequence found by `archive::next_export_sequence` — caps at 99 same-day exports per project, returns `ExportSequenceExhausted` past that. - `export ` overrides the default. Relative paths resolve under the active data root (the user's stated preference: "use the data-dir as the target folder, so the export file sits 'next to' the project folder, not inside it"); absolute paths are used verbatim. Refuses if the final zip path already exists. - **Zip layout:** the project's directory name is preserved as the single top-level folder inside the archive, so `unzip foo.zip` produces `/project.yaml` etc. rather than scattering files. This is what makes the round-trip `export → import` pleasant: the recipient doesn't need to know the original name; it lives in the zip's structure. - **Excluded** from the zip: `playground.db`, `history.log`, `.rdbms-playground.lock`, any `*.tmp` files, any `project.yaml.v*.bak` files. `.gitignore` IS included (sensible default for the recipient). **`import [as ]` command:** - Grammar: separator is the literal ` as ` (space-as-space), so a zip path containing the substring "as" without surrounding spaces is treated as a path, not a syntax marker. - Default target: the zip's single top-level folder (verified by `archive::inspect_zip`). Relative `` resolves under `/projects/`; absolute paths used verbatim. - **Collision behaviour:** if the resolved relative target already exists, the basename auto-suffixes `-02`, `-03`, … up to `-99`. This is a deliberate deviation from the §2 collision rule (which refuses), recorded as an amendment to ADR-0015 §11. Rationale: round-tripping zips (export → email → import → re-export → re-import) is a normal workflow and forcing `as ` for every collision is unnecessary friction. Absolute paths are NOT auto-suffixed — the user's explicit `as ` is honored exactly or refused on collision. - After unpack: the runtime opens the new project and runs `rebuild_from_text` to materialize `playground.db` from YAML+CSV. `history.log` starts empty (it was excluded from the zip). - Switches to operating on the new project via the existing `perform_switch` + `SwitchRequest::Import` path, which means the unmodified-temp cleanup machinery from Iteration 4b also applies — the previous fresh-launch temp gets auto-deleted via `safely_delete_temp_project`. **Path-traversal protection** in `archive::extract_into`: - `entry.enclosed_name()` rejects `..` segments and absolute paths. - The resolved extraction path is re-validated to start with `target_dir` (defence-in-depth). - Top-folder match is enforced (the inspection step recorded the single top-level folder; extraction refuses any entry under a different top folder). **Module:** `src/archive.rs` (new, ~480 lines + 11 unit tests). Public API: `default_export_filename`, `next_export_sequence`, `export_project`, `inspect_zip`, `resolve_import_target`, `extract_into`, plus `ZipInspection` and `ArchiveError`. Dep added: `zip = "5"` with `default-features = false, features = ["deflate"]`. ### Iteration 6 — `--resume` + persistent input history + ### migration framework **`--resume` CLI flag (L1a, ADR-0015 §7):** - Reads `/last_project` (a single-line file containing the absolute project path). - Mutually exclusive with a positional `` (`ArgsError::ResumeWithPath`). - Errors cleanly via stderr (printed BEFORE the alternate screen is entered, so the message lands directly above the shell prompt) if: - `last_project` is missing → "no previous project recorded under …". - `last_project` points at a path that no longer exists → "recorded project … no longer exists". - No silent fallback to a fresh temp. - `last_project` is rewritten on every successful project open: startup (resume / positional path / fresh temp), `load`, `new`, `save as`, `import`. Atomic write via temp + rename. - Helpers: `project::read_last_project`, `project::write_last_project`. Both round-trip through disk and handle the missing-data-root case (the runtime's first launch). **Persistent input history (I2-persist, ADR-0015 §12):** - On project open (initial in `run()` and on every switch in `handle_project_switch`), the in-memory navigable input history is hydrated from the tail of the project's `history.log`, capped at the same 1000-entry in-memory cap. - `App::seed_history(entries: Vec)` is the hydration entry point; `Persistence::read_recent_history` is the loader (calls `history::read_recent_sources`). - The hydration is delivered through `AppEvent::ProjectSwitched { history_entries, .. }` for switch flows (since `App` is owned by `run_loop`); for the startup flow it's called inline. - Up/Down recall jumps to the most-recent seeded entry first, matching the in-session navigation semantics. - Format-tolerant parser: `|ok|` lines are parsed via `splitn(3, '|')` so pipes inside the source are preserved; unknown escape sequences in the source are passed through literally. **Migration framework scaffold (F3, ADR-0015 §9):** - New module `src/persistence/migrations.rs`. - `MigratorRegistry` is an ordered list of `MigrateFn` function pointers, indexed by source version. `production()` returns an empty registry (latest_version = 1). New versions register their migrators here. - `migrate_to_latest(body, registry, project_path)`: 1. Reads the `version:` field via a tiny `serde_yml` wire type (`VersionOnly { version: u32 }`). 2. If `file_version == latest`: returns body unchanged with `migrated_from = None`. 3. If `file_version > latest`: errors out (`NewerThanSupported`). 4. Otherwise: writes `/project.yaml.v.bak`, runs each migrator in sequence, and verifies each step bumped the `version:` field (catches forgetful migrators). - `ensure_project_yaml_migrated(project_path, registry)` is the runtime-facing wrapper that pairs migration with the read/write IO. - Wired into `runtime::run()` and `runtime::perform_switch()` so every project open runs through the (currently no-op) migration step before the database opens. - Tests inject a fake v1→v2 migrator to exercise the registry plumbing, the `.bak` write, the forgot-to-bump-version check, the newer-than-supported guard, and a propagated migrator error. ## ADR / docs updates - **ADR-0015 §11** — amended to record the export zip layout (top-level folder = project name) and the import auto-suffix collision behaviour (deviates from §2's refuse-on-collision rule for `save` / `save as`). - **`docs/requirements.md`** — A1 / I2 / F3 / E1 / L1a flipped to `[x]` with implementation notes; test baseline updated to 408 passing. - **`CLAUDE.md`** — not touched this session; the rules are unchanged. The repo-layout map there is slightly out-of-date (no mention of `archive.rs` or `persistence/migrations.rs`) — a quick fix-up is fair game for the next session. ## Repository layout (delta vs. handoff-2) ``` src/ archive.rs — new (Iteration 5) persistence/ mod.rs — read_recent_history added migrations.rs — new (Iteration 6 / F3) project/ mod.rs — read_last_project + write_last_project added cli.rs — --resume flag + ResumeWithPath error variant app.rs — export/import dispatch in submit(); seed_history; ExportSucceeded/Failed event handlers; ProjectSwitched carries history_entries action.rs — Action::Export, Action::Import event.rs — AppEvent::ExportSucceeded / ExportFailed; ProjectSwitched + history_entries runtime.rs — spawn_export, do_export, resolve_import_destination, read_history_seed, SwitchRequest::Import, --resume / last_project / migration wiring tests/ iteration5_export_import.rs — new (Iteration 5) iteration6_resume_history.rs — new (Iteration 6) ``` ## Sharp edges and subtleties (delta vs. handoff-2) The previous handoff's sharp edges all still apply. New ones: - **`Action::Export` runs on a tokio `spawn_blocking` task, not the db worker.** Export writes the zip directly from disk; auto-save guarantees the project's text sources are current. The `history.log` entry for the `export` command is appended synchronously from the dispatching arm BEFORE the spawn (so the user-issued command lands in history even if the export task itself fails). - **`SwitchRequest::Import` runs `inspect_zip` BEFORE dropping the current project.** A failed inspection (zip not a project, multiple top folders, traversal entry, etc.) leaves the user where they were. The actual extraction also runs before the drop. Only after extraction succeeds do we drop and reopen. - **`ProjectSwitched` is now a 3-field event.** Tests that construct it directly need the extra `history_entries: Vec::new()` field. Iteration-4b had one such test; updated. - **Migration runs inside `perform_switch` AFTER the lock on the new project is acquired but BEFORE the database opens.** Order matters: a migration that mutates `project.yaml` while another process holds the lock would corrupt the file; doing the migration after our own lock is held prevents that. - **`migrate_to_latest` writes the `.bak` BEFORE running any migrator.** If a migrator panics or returns an error mid-chain, the `.bak` is the only intact copy of the original. The runtime currently does not auto-restore on failure — that's part of "future work" once a real migrator lands. - **`--resume` errors print to stderr BEFORE the terminal is set up.** If the user is debugging by reading `--log-file`, the resume error is in the shell, not the log. - **`last_project` write failures are non-fatal** (logged via `tracing::warn`). Rationale: a failed write here surfaces on the *next* `--resume` attempt with a clear message, which is preferable to refusing to launch the app over a stat / chmod hiccup. - **The `zip` crate features are restricted** to `default-features = false, features = ["deflate"]` to hold the binary-size cost down. A future cipher / compression demand can revisit. ## Pending — proposed next moves (in order) Track-2's iteration backlog is now empty; ADR-0015 ships the runtime as designed. The remaining items are the deferred features called out in handoff-2's "Other deferred items" list: ### 1. Complex WHERE expressions (C5a) AND/OR/comparison operators/LIKE in UPDATE/DELETE/show-data filters. The natural progression from DSL fluency into real SQL. Needs a small ADR for the operator subset. ### 2. Indexes (C3 partial) + EXPLAIN QUERY PLAN (QA1) Strong teaching demo. `add index on ()` / `drop index `, plus rendering the `EXPLAIN QUERY PLAN` output as an annotated tree (QA2 covers the tree rendering specifics in its own ADR). ### 3. Column drops/renames/type changes (B2 / C2 partial) The `rebuild_table` primitive already exists (ADR-0013). The grammar additions and metadata updates are straightforward; the work is mostly tests covering the data-preservation invariants. ### 4. Friendly error layer (H1) Translate raw SQLite messages to learner-friendly equivalents. Partial today (FK errors are enriched both ways); full SQL → English translation is the open work. ### 5. `replay` (U4) The `history.log` format is already replay-compatible. `replay ` runs commands from a `history.log` or `.commands` file. The framework lands here; the U-series items (snapshot/undo/redo, ADR-0006) follow. ### 6. CI (TT5) Test infrastructure is in place; the GitHub Actions workflow file (or equivalent) is not. ### 7. Bigger UX work V4 session log + Markdown export, S2 indexes in the items list, V1/V2 pretty rendering, H1a strong syntax-help. All have their entries in `docs/requirements.md` and remain deferred behind their respective ADRs. ## How to take over 1. Read this file. 2. Read `CLAUDE.md` for the working-style rules. 3. Read `docs/requirements.md` for granular progress. 4. Skim `docs/adr/README.md`; read ADR-0015 in full (especially §11 with the import-collision amendment) if you'll touch the project storage runtime, the archive module, or the migration framework. 5. Run `cargo test` to confirm the 408-test green baseline. 6. `cargo run --release -- --help` to see the updated CLI banner. ### End-to-end smoke test Verifies export, import, --resume, and persistent history. Same data-dir flag throughout so the test is contained. ``` # Set up: launch under a clean data dir. $ rm -rf /tmp/rdbms-iter5-iter6-smoke $ rdbms-playground --data-dir /tmp/rdbms-iter5-iter6-smoke # Inside the app: help -- new commands listed create table Customers with pk id:serial add column Customers: Name (text) insert into Customers ('Alice') insert into Customers ('Bob') save -- name it "MyOrders" export -- writes -- /tmp/rdbms-iter5-iter6-smoke/ -- 20260508-MyOrders-export-01.zip quit # Verify the zip on disk: $ unzip -l /tmp/rdbms-iter5-iter6-smoke/*.zip # Should show: # MyOrders/project.yaml # MyOrders/data/Customers.csv # MyOrders/.gitignore # and NOT: # MyOrders/playground.db # MyOrders/history.log # Re-open via --resume and verify history is hydrated: $ rdbms-playground --data-dir /tmp/rdbms-iter5-iter6-smoke --resume # Up arrow should walk back through the export, save, # inserts, add column, create table — all from the previous # session. # Inside the app: import /tmp/rdbms-iter5-iter6-smoke/20260508-MyOrders-export-01.zip -- creates MyOrders-02 -- (auto-suffix because -- MyOrders already exists), -- switches to it, -- rebuilds .db from text. show data Customers -- 'Alice' and 'Bob' present. quit # Final clean-up: $ rm -rf /tmp/rdbms-iter5-iter6-smoke ``` If anything in that sequence fails, something is wrong. ### Manual spot-checks worth running - `--resume` with a missing `last_project` → stderr message. - `--resume` with a stale recorded path → stderr message. - `--resume ` (combined with positional) → `ResumeWithPath` error from arg parsing. - `export` with no current data dir created yet (rare; data root resolution still works). - `import ` → `MultipleTopFolders` error in the output panel. - `import ` (no project.yaml in it) → `NotAProjectArchive` error. - After the scaffold migration framework: hand-edit a project's `project.yaml` to `version: 99`, restart → `migrate project.yaml` context error in the run-time startup error path. (Or: write a real v1→v2 migrator and watch it execute.)