Files
rdbms-playground/docs/handoff/20260508-handoff-3.md
claude@clouddev1 67d68db5f8 Iteration 6: --resume + persistent input history + migration scaffold
Closes out track 2's ADR-0015 backlog.

* `--resume` CLI flag (L1a, ADR-0015 §7) opens the most-
  recently-used project, tracked in <data-root>/last_project.
  Mutually exclusive with a positional <project-path>; errors
  cleanly to stderr (above the shell prompt) on missing file
  or stale recorded path. last_project is rewritten on every
  successful project open (startup, load, new, save as,
  import).
* Persistent input history (I2-persist, ADR-0015 §12). On
  project open, the in-memory navigable history is hydrated
  from the tail of history.log (capped at the in-memory cap).
  ProjectSwitched gains a `history_entries` payload field;
  App::seed_history is the entry point. Pipes inside source
  text round-trip via splitn(3); unknown escape sequences are
  passed through literally.
* Migration framework scaffold (F3, ADR-0015 §9). New
  persistence::migrations module with MigratorRegistry +
  migrate_to_latest + ensure_project_yaml_migrated. Empty
  in v1 (production registry has no migrators); the loader
  runs through it on every project open and is exercised by
  tests with a fake v1→v2 migrator. Writes
  project.yaml.v<N>.bak before any migrator runs; verifies
  each step bumps the version field.

Refreshes docs/requirements.md (A1 / I2 / F3 / E1 / L1a /
test baseline) and adds docs/handoff/20260508-handoff-3.md
covering both Iter 5 and Iter 6.

Total tests: 408 passing, 0 failing, 0 skipped (up from 345
at handoff-2). Clippy clean.
2026-05-08 08:27:50 +00:00

17 KiB

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 [<path>] 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: <data-root>/YYYYMMDD-<projectname>-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 <path> 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 <projectname>/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 <zip> [as <target>] 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 <target> resolves under <data-root>/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 <target> for every collision is unnecessary friction. Absolute paths are NOT auto-suffixed — the user's explicit as <abs-path> 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 <data-root>/last_project (a single-line file containing the absolute project path).
  • Mutually exclusive with a positional <project-path> (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<String>) 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: <ts>|ok|<source> 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_path>/project.yaml.v<file_version>.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 <name> on <T>(<col>) / drop index <name>, 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 <path> 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 <path> (combined with positional) → ResumeWithPath error from arg parsing.
  • export with no current data dir created yet (rare; data root resolution still works).
  • import <zip-with-multiple-top-folders>MultipleTopFolders error in the output panel.
  • import <random.zip> (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.)