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.
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::Exportand a newspawn_exporttask in the runtime that does the zip work on aspawn_blockingthread. - Default output:
<data-root>/YYYYMMDD-<projectname>-export-NN.zipwhereNNis a non-clobbering two-digit sequence found byarchive::next_export_sequence— caps at 99 same-day exports per project, returnsExportSequenceExhaustedpast 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.zipproduces<projectname>/project.yamletc. rather than scattering files. This is what makes the round-tripexport → importpleasant: 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*.tmpfiles, anyproject.yaml.v*.bakfiles..gitignoreIS 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 forcingas <target>for every collision is unnecessary friction. Absolute paths are NOT auto-suffixed — the user's explicitas <abs-path>is honored exactly or refused on collision. - After unpack: the runtime opens the new project and runs
rebuild_from_textto materializeplayground.dbfrom YAML+CSV.history.logstarts empty (it was excluded from the zip). - Switches to operating on the new project via the existing
perform_switch+SwitchRequest::Importpath, which means the unmodified-temp cleanup machinery from Iteration 4b also applies — the previous fresh-launch temp gets auto-deleted viasafely_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_projectis missing → "no previous project recorded under …".last_projectpoints at a path that no longer exists → "recorded project … no longer exists".
- No silent fallback to a fresh temp.
last_projectis 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 inhandle_project_switch), the in-memory navigable input history is hydrated from the tail of the project'shistory.log, capped at the same 1000-entry in-memory cap. App::seed_history(entries: Vec<String>)is the hydration entry point;Persistence::read_recent_historyis the loader (callshistory::read_recent_sources).- The hydration is delivered through
AppEvent::ProjectSwitched { history_entries, .. }for switch flows (sinceAppis owned byrun_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 viasplitn(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. MigratorRegistryis an ordered list ofMigrateFnfunction 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):- Reads the
version:field via a tinyserde_ymlwire type (VersionOnly { version: u32 }). - If
file_version == latest: returns body unchanged withmigrated_from = None. - If
file_version > latest: errors out (NewerThanSupported). - Otherwise: writes
<project_path>/project.yaml.v<file_version>.bak, runs each migrator in sequence, and verifies each step bumped theversion:field (catches forgetful migrators).
- Reads the
ensure_project_yaml_migrated(project_path, registry)is the runtime-facing wrapper that pairs migration with the read/write IO.- Wired into
runtime::run()andruntime::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
.bakwrite, 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 ofarchive.rsorpersistence/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::Exportruns on a tokiospawn_blockingtask, not the db worker. Export writes the zip directly from disk; auto-save guarantees the project's text sources are current. Thehistory.logentry for theexportcommand 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::Importrunsinspect_zipBEFORE 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.ProjectSwitchedis now a 3-field event. Tests that construct it directly need the extrahistory_entries: Vec::new()field. Iteration-4b had one such test; updated.- Migration runs inside
perform_switchAFTER the lock on the new project is acquired but BEFORE the database opens. Order matters: a migration that mutatesproject.yamlwhile another process holds the lock would corrupt the file; doing the migration after our own lock is held prevents that. migrate_to_latestwrites the.bakBEFORE running any migrator. If a migrator panics or returns an error mid-chain, the.bakis 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.--resumeerrors 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_projectwrite failures are non-fatal (logged viatracing::warn). Rationale: a failed write here surfaces on the next--resumeattempt with a clear message, which is preferable to refusing to launch the app over a stat / chmod hiccup.- The
zipcrate features are restricted todefault-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
- Read this file.
- Read
CLAUDE.mdfor the working-style rules. - Read
docs/requirements.mdfor granular progress. - 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. - Run
cargo testto confirm the 408-test green baseline. cargo run --release -- --helpto 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
--resumewith a missinglast_project→ stderr message.--resumewith a stale recorded path → stderr message.--resume <path>(combined with positional) →ResumeWithPatherror from arg parsing.exportwith no current data dir created yet (rare; data root resolution still works).import <zip-with-multiple-top-folders>→MultipleTopFolderserror in the output panel.import <random.zip>(no project.yaml in it) →NotAProjectArchiveerror.- After the scaffold migration framework: hand-edit a
project's
project.yamltoversion: 99, restart →migrate project.yamlcontext error in the run-time startup error path. (Or: write a real v1→v2 migrator and watch it execute.)