4cd574b909
The input mode always started in simple; a learner who quit in advanced had to re-toggle every launch. Store the mode per-project in project.yaml (project.mode:, optional, default simple) and restore it on every open. Mode is live UI state, not schema: the worker stamps the current mode into project.yaml on every write, so a later command rewrites the live value rather than clobbering it — no db round-trip needed. The mode is persisted on unload (quit + project switch) so the mode you leave a project in is always what reopens; the `mode` command also persists immediately. A switch saves the outgoing mode, then restores the incoming project's stored mode. New --mode simple|advanced CLI flag (precedence --mode > stored > simple; combines with --resume). A teacher can ship a project that opens in advanced mode and export it to students (the mode travels in the zip). ADR-0015 Amendment 1; ADR-0003 note; help banner; requirements L1b.
691 lines
29 KiB
Markdown
691 lines
29 KiB
Markdown
# ADR-0015: Project storage runtime
|
|
|
|
## Status
|
|
|
|
Accepted. Amends ADR-0004 (project file format) and ADR-0007
|
|
(sharing and export); see the "Relationship to earlier ADRs"
|
|
section at the end for the exact deltas.
|
|
|
|
## Context
|
|
|
|
ADR-0004 defined the on-disk shape of a project — `project.yaml`
|
|
+ `data/<table>.csv` + `history.log`, with `playground.db` as a
|
|
derived artifact. It deliberately did not specify runtime
|
|
semantics: when a project comes into existence, where it lives,
|
|
how the on-disk files are kept consistent with the running
|
|
SQLite database, what happens on load, on failure, on
|
|
concurrent open, and how the canonical app-level commands
|
|
(`save`, `load`, `new`, `export`, `import`) are scoped.
|
|
|
|
Track 1 of the application built everything against an
|
|
in-memory SQLite database. Every quit lost all work. This is
|
|
the largest single UX gap left in the project, and the next
|
|
useful feature (replay/undo, ADR-0006) depends on the
|
|
`history.log` written here.
|
|
|
|
This ADR fills the runtime gap. It commits to a single
|
|
persistence model — *every successful command writes through
|
|
to all targets immediately, and validation gates everything* —
|
|
and works the resulting design through to file naming, the
|
|
load picker, the failure model, and concurrent-open behaviour.
|
|
|
|
## Decision
|
|
|
|
### 1. Lifecycle and locations
|
|
|
|
There is no in-memory database in normal operation. Every
|
|
session is backed by a project on disk.
|
|
|
|
- **Startup with no CLI argument:** the application creates a
|
|
new temporary project under the OS data directory (see
|
|
below), opens it, and runs against it.
|
|
- **Startup with a CLI argument** (`rdbms-playground <path>`,
|
|
requirement L1): the application opens the project at that
|
|
path. If the path does not exist or does not look like a
|
|
project (no `project.yaml`), it refuses with a friendly
|
|
error.
|
|
- **`save` / `save as`** elevate or copy a project to a chosen
|
|
location.
|
|
- **`load`** opens a different project (see section 7).
|
|
- **`new`** creates a fresh temp project from inside the
|
|
running application, after closing the current one.
|
|
|
|
The OS data root is platform-standard:
|
|
|
|
- Linux: `$XDG_DATA_HOME/rdbms-playground` (defaulting to
|
|
`~/.local/share/rdbms-playground` when `XDG_DATA_HOME` is
|
|
unset).
|
|
- macOS: `~/Library/Application Support/rdbms-playground`.
|
|
- Windows: `%APPDATA%\rdbms-playground`.
|
|
|
|
Inside the data root: `projects/` holds projects — both
|
|
auto-generated temp ones and ones the user has saved with a
|
|
name of their choosing. There is no requirement that named
|
|
projects move *out* of the data root, and no encouragement to
|
|
do so: keeping a saved project right alongside the temp ones
|
|
is the easiest workflow and is fully supported. Users who
|
|
prefer a different home (a course directory, a shared drive,
|
|
a git working tree) save there instead. The application
|
|
prescribes nothing.
|
|
|
|
The data root also carries a small state file
|
|
`last_project` (a single line containing the absolute path of
|
|
the most recently opened project). It exists to support
|
|
`--resume` (section 7).
|
|
|
|
A `--data-dir` CLI flag fully replaces the OS-standard data
|
|
root for the duration of that run; both project creation and
|
|
the load picker's listing use the supplied directory and only
|
|
that directory. The `last_project` state file is read and
|
|
written under the active data root, so a user with multiple
|
|
data roots gets independent resume histories per root, which
|
|
is the intuitive behaviour.
|
|
|
|
### 2. Project naming and display name
|
|
|
|
Temp project directory names follow the pattern
|
|
`<YYYYMMDD>-<word>-<word>-<word>`, where the words are drawn
|
|
from a small built-in wordlist compiled into the binary (no
|
|
external file or network call). Example:
|
|
`20260507-water-buffalo-skating`. The leading date keeps the
|
|
file listing chronologically sortable; the words give learners
|
|
something nameable to refer to.
|
|
|
|
Named projects use whatever directory name the user chose at
|
|
`save` time.
|
|
|
|
**Collision handling.**
|
|
|
|
- For auto-generated temp names: before creating the
|
|
directory, the application checks for an existing entry of
|
|
the same name in the data root and regenerates the
|
|
three-word slug if one is found. The wordlist is large
|
|
enough (multiple categories, dozens of words each) that
|
|
collisions are essentially never observed in practice; the
|
|
check is cheap and removes the failure mode entirely.
|
|
- For user-supplied names at `save` / `save as` / `import`:
|
|
if the target directory already exists (whether it
|
|
contains a project or anything else), the operation is
|
|
refused with a friendly error. The user picks a different
|
|
name or moves/removes the existing directory first. We
|
|
deliberately do not auto-suffix or merge — silently
|
|
changing the name the user typed, or writing into someone
|
|
else's directory, is worse than asking them to pick again.
|
|
|
|
The application carries a *display name* derived from the
|
|
project directory name by a small prettifier:
|
|
|
|
- Strip a leading `YYYYMMDD-` if present (temp projects).
|
|
- Split on `-` (kebab-case), `_` (snake_case), or case
|
|
boundaries (camelCase / PascalCase).
|
|
- Title-case each word.
|
|
|
|
So `20260507-water-buffalo-skating` displays as
|
|
"Water Buffalo Skating"; `MyOrders` displays as "My Orders";
|
|
`customer_demo` displays as "Customer Demo".
|
|
|
|
The display name is shown in the bottom status bar at all
|
|
times, prefixed with `Project:` so it's unambiguous. This is
|
|
how the user knows which project they are editing.
|
|
|
|
### 3. `project.yaml` shape
|
|
|
|
Flat ordered lists. Tables and columns preserve declaration
|
|
order; relationships preserve creation order.
|
|
|
|
```yaml
|
|
version: 1
|
|
project:
|
|
created_at: 2026-05-07T14:30:12Z
|
|
tables:
|
|
- name: Customers
|
|
primary_key: [id]
|
|
columns:
|
|
- { name: id, type: serial }
|
|
- { name: Name, type: text }
|
|
relationships:
|
|
- name: Customers_id_to_Orders_CustId
|
|
parent: { table: Customers, column: id }
|
|
child: { table: Orders, column: CustId }
|
|
on_delete: cascade
|
|
on_update: no_action
|
|
```
|
|
|
|
The `version: 1` field is required. Migrators (section 9)
|
|
upgrade older versions on load. The project's name is
|
|
**not** stored in `project.yaml`; the directory name on disk
|
|
is the canonical name. Recording it twice would create an
|
|
opportunity for the two to drift if the user renamed the
|
|
directory by hand; with one source of truth, that question
|
|
doesn't arise.
|
|
|
|
### 4. CSV encoding
|
|
|
|
One file per table, `data/<TableName>.csv`, UTF-8, RFC 4180
|
|
quoting, header row carrying column names in declaration
|
|
order.
|
|
|
|
Per-type encoding:
|
|
|
|
| Type | CSV form |
|
|
|------------|---------------------------------------|
|
|
| `text` | RFC 4180 string |
|
|
| `int` | decimal integer |
|
|
| `real` | shortest-round-trip decimal |
|
|
| `decimal` | string form already validated by `value.rs` |
|
|
| `bool` | `true` / `false` |
|
|
| `date` | `YYYY-MM-DD` |
|
|
| `datetime` | ISO 8601 with `T` and a `Z` or offset |
|
|
| `blob` | base64 (standard alphabet, padded) |
|
|
| `serial` | integer |
|
|
| `shortid` | base58 string |
|
|
|
|
NULL is the empty unquoted field; the empty quoted field
|
|
(`""`) is an empty string. The distinction is preserved
|
|
because SQL preserves it and the playground is meant to teach
|
|
SQL.
|
|
|
|
### 5. `history.log` format
|
|
|
|
Append-only, one record per line, three pipe-separated fields:
|
|
|
|
```
|
|
2026-05-07T14:30:12Z|ok|create table Customers with pk id:serial
|
|
2026-05-07T14:30:30Z|ok|insert into Customers ('Alice')
|
|
```
|
|
|
|
- **Timestamp** in ISO 8601 with `Z`.
|
|
- **Status** is always `ok` in v1, because failed commands
|
|
are not recorded — this matches ADR-0006's "successfully
|
|
executed command" wording and keeps the log directly
|
|
replayable. The status field is kept in the line format
|
|
anyway so future use cases (audit logs that record
|
|
attempts, validation diagnostics, distinguishing
|
|
user-issued from imported commands) can carry additional
|
|
values without a format break.
|
|
- **Command** is the user's input as typed. Newlines (when
|
|
multi-line input arrives, requirement I1) are escaped as
|
|
literal `\n`.
|
|
|
|
`history.log` is **not** included in `export` (see section 11
|
|
and the ADR-0007 amendment). It is private to the user's
|
|
working copy.
|
|
|
|
### 6. Persistence ordering
|
|
|
|
A successful user command produces effects in four targets:
|
|
the SQLite database, `project.yaml`, the relevant
|
|
`data/<table>.csv` file(s), and `history.log`. INV-2 from the
|
|
Phase-1 record requires that the **combined db persistence
|
|
logic** — validation, metadata-table handling, the SQLite
|
|
mutations — gate everything else.
|
|
|
|
The implementation order inside a command is:
|
|
|
|
1. **Validate and stage in the database.** Open a SQLite
|
|
transaction. Perform validation, schema/metadata
|
|
mutations, data mutations. Do not commit yet.
|
|
2. **Stage text targets.** Write `project.yaml` (if schema or
|
|
relationships changed) and affected `data/<table>.csv`
|
|
files (if rows changed) to temp files inside the project
|
|
directory. Append the new line for `history.log` to a
|
|
temp copy. `fsync` each.
|
|
3. **Rename text targets.** Atomic rename each temp file to
|
|
its final path (POSIX `rename(2)`; on Windows
|
|
`MoveFileEx(REPLACE_EXISTING)`).
|
|
4. **Commit the SQLite transaction.**
|
|
|
|
Failure handling:
|
|
|
|
- Failure in step 1 or 2 → roll back the SQLite transaction;
|
|
no rename happens; on-disk state is unchanged. Surface the
|
|
failure (see section 8) and quit.
|
|
- Failure in step 3 (rename fails after `fsync`) → roll back
|
|
the SQLite transaction; orphan temp files remain in the
|
|
project directory and are cleaned up on next open. On-disk
|
|
semantic state is unchanged. Surface and quit.
|
|
- Failure in step 4 (commit fails after rename succeeded) →
|
|
rare; on next launch the on-disk text is ahead of the
|
|
`playground.db`. The user sees stale data and runs
|
|
`rebuild` (section 7) to recover. Documented edge case;
|
|
acceptable for v1.
|
|
|
|
This ordering is "commit db last so a fatal failure leaves
|
|
disk state recoverable via `rebuild`."
|
|
|
|
### 7. Load and rebuild
|
|
|
|
**Load on startup or via the `load` command.** If
|
|
`playground.db` exists in the project directory, it is opened
|
|
as-is. If it does not exist, it is rebuilt silently from
|
|
`project.yaml` + `data/<table>.csv`. There is no automatic
|
|
detection of drift between the database and the text sources
|
|
on load; that's what `rebuild` is for.
|
|
|
|
**`--resume` CLI option.** Equivalent to passing the path
|
|
recorded in the `<data-root>/last_project` state file as the
|
|
positional CLI argument. If `last_project` is missing or
|
|
points at a path that no longer exists, `--resume` exits
|
|
with an error pointing the user at the absent project; it
|
|
does **not** silently fall back to creating a new temp
|
|
project, because the user's intent ("resume what I had") is
|
|
clear and silent fallback would mask the problem. `--resume`
|
|
and an explicit positional path are mutually exclusive; the
|
|
combination errors out.
|
|
|
|
The `last_project` file is rewritten on every successful
|
|
project open (startup, `load`, `new`, `save as`, `import`).
|
|
A clean exit doesn't clear it — that's the whole point of
|
|
`--resume` after a quit.
|
|
|
|
**CSV row-load failure during rebuild.** When rebuilding
|
|
`playground.db` from `project.yaml` + `data/<table>.csv`,
|
|
each row insert can fail (malformed CSV, type-validation
|
|
failure, FK violation, NOT NULL violation, etc.). The
|
|
behaviour mirrors the persistence failure model (section 8):
|
|
the rebuild stops at the first failing row and surfaces a
|
|
fatal error of the form
|
|
|
|
> Unable to load row *N* from `data/<table>.csv` into table
|
|
> `<table>`: *<diagnosis from the value/FK/constraint
|
|
> validator>*
|
|
|
|
The application then quits. There is no realistic case
|
|
where a CSV produced by a previous well-behaved session
|
|
contains an unloadable row; if one does, something has gone
|
|
wrong (hand edit, partial git merge, file corruption) and
|
|
the user should fix the file or restore an earlier copy.
|
|
Continuing past the bad row would either lose data
|
|
silently (skip it) or load partial state (stop but keep
|
|
what loaded), both of which leave the user in a worse
|
|
position than a clear error message.
|
|
|
|
**`rebuild` app-level command.** Discards the current
|
|
`playground.db` and reconstructs it from `project.yaml` +
|
|
`data/`. Always shows a confirmation prompt with a summary
|
|
("12 tables, 47 rows will be reconstructed; existing
|
|
`playground.db` will be replaced") before doing the work.
|
|
Useful when:
|
|
|
|
- The user pulled new YAML/CSV from git over an old `.db`.
|
|
- A prior persistence failure left the `.db` behind the text
|
|
(section 6, step-4 failure mode).
|
|
- The user hand-edited the YAML or CSV outside the app.
|
|
|
|
**Load picker UX.** The `load` command opens an in-TUI modal
|
|
listing temp projects from the data dir, sorted newest
|
|
first, with the prettified display name and creation
|
|
timestamp. Arrow keys select; Enter loads; Esc cancels;
|
|
pressing `b` (for "browse") switches the modal to a
|
|
path-entry prompt for projects outside the data dir. This
|
|
covers both common (pick a recent temp) and uncommon (open a
|
|
named project at a custom path) cases without forcing the
|
|
user into a fully manual path entry up front.
|
|
|
|
### 8. Failure model
|
|
|
|
Persistence failures are fatal. The application surfaces a
|
|
banner with the operation, the path, and the OS error
|
|
message, then quits cleanly so the banner remains visible
|
|
above the shell prompt. The user investigates (disk full,
|
|
permission denied, network filesystem hiccup) and restarts.
|
|
|
|
This is the right model because the realistic failure modes
|
|
for a local data directory do not heal transiently. Showing a
|
|
warning and continuing risks silent loss when the user later
|
|
quits the app while the failure window is still open.
|
|
|
|
The persistence ordering in section 6 ensures that "fatal
|
|
failure → quit" never leaves the disk in a state that cannot
|
|
be recovered: it is either unchanged (the common case) or
|
|
recoverable via `rebuild` (the rare step-4 failure).
|
|
|
|
The "quit on failure" mode is also not anticipated to be
|
|
particularly disruptive in practice. Even if a transient
|
|
issue (a network drive timing out, an antivirus scanner
|
|
holding a file briefly) does cause a fatal failure, the
|
|
user's path back into the session is just
|
|
`rdbms-playground --resume`. With section 6's ordering
|
|
guaranteeing recoverable disk state and `--resume`
|
|
guaranteeing one-command return, the cost of erring on the
|
|
side of "stop and let the user investigate" is small enough
|
|
that the safety benefit dominates.
|
|
|
|
### 9. Migration framework (F3)
|
|
|
|
`project.yaml` carries `version: 1` from the outset. Future
|
|
format changes bump the version and add a registered
|
|
migrator function:
|
|
|
|
```rust
|
|
fn migrate_v1_to_v2(raw: &mut RawProject) -> Result<(), MigrateError> { ... }
|
|
```
|
|
|
|
Migrators are stored in an ordered list keyed by source
|
|
version. On load, the application:
|
|
|
|
1. Reads the file's `version`.
|
|
2. If `version < latest_known`, copies the original file to
|
|
`project.yaml.v<N>.bak` (where `<N>` is the original
|
|
version).
|
|
3. Runs each migrator in sequence from `version + 1` to
|
|
`latest_known`.
|
|
4. Writes the upgraded YAML back at the new version.
|
|
5. If any migrator fails, restores the `.bak` and surfaces
|
|
the failure as a fatal load error.
|
|
|
|
The framework is built in v1 even though no migrator exists
|
|
yet. The first real migrator (when v2 lands) exercises it.
|
|
|
|
### 10. Concurrency
|
|
|
|
A lock file `<project>/.rdbms-playground.lock` is written
|
|
when a project is opened, containing the PID and hostname of
|
|
the owning process. On open:
|
|
|
|
- If no lock file exists: take the lock and proceed.
|
|
- If a lock file exists with a live PID on this host: refuse
|
|
with a friendly error pointing the user at the running
|
|
instance.
|
|
- If a lock file exists but the PID is dead (or it lists a
|
|
different hostname): take the lock (clean handover from a
|
|
crashed prior instance).
|
|
|
|
The lock is removed on clean exit. Crashes leave it behind;
|
|
the next open reclaims it.
|
|
|
|
The lock blocks only other rdbms-playground TUI instances.
|
|
External read-only tooling (`sqlite3 playground.db -readonly`,
|
|
text editors looking at `project.yaml`, etc.) is not
|
|
prevented. The user is on their own if they fiddle with the
|
|
project files concurrently with the running app — that's a
|
|
power-user workflow we don't get in the way of.
|
|
|
|
### 11. App-level commands
|
|
|
|
The track 2 command set, all available in both modes per
|
|
ADR-0003:
|
|
|
|
- **`save`** — for a temp project, prompts for a target
|
|
directory and elevates to a named project (effectively
|
|
identical to `save as`). For a named project, reports
|
|
"auto-saved; use `save as` to copy to a new location."
|
|
- **`save as`** — prompts for a target directory; copies
|
|
the entire project there and switches to operating on the
|
|
copy.
|
|
- **`load`** — opens the load picker (section 7).
|
|
- **`new`** — creates a fresh temp project; closes the
|
|
current one cleanly first (auto-save guarantees the
|
|
current state is on disk).
|
|
- **`rebuild`** — section 7.
|
|
- **`export`** — produces a zip per ADR-0007, *excluding*
|
|
both `playground.db` and `history.log` (see ADR-0007
|
|
amendment below). The zip preserves the project's directory
|
|
name as a single top-level folder (so unzipping creates
|
|
`<projectname>/project.yaml` etc. rather than scattering
|
|
files into the recipient's CWD). Default output is the
|
|
active data root with the filename pattern
|
|
`YYYYMMDD-<projectname>-export-NN.zip`, where `NN` is a
|
|
two-digit zero-padded counter that skips taken slots in
|
|
the same directory on the same day. `export <path>` overrides
|
|
the default; relative `<path>` resolves under the active data
|
|
root, absolute paths are used verbatim. Refuses if the final
|
|
zip path already exists.
|
|
- **`import`** — accepts an exported zip and switches to
|
|
the resulting project. Grammar: `import <zip> [as <target>]`.
|
|
The destination basename is taken from the zip's single
|
|
top-level folder by default; an explicit `as <target>`
|
|
overrides it. Relative `<target>` resolves under
|
|
`<data-root>/projects/`; absolute paths are used verbatim.
|
|
**Collision behaviour (amended)**: if the resolved
|
|
destination already exists, `import` auto-suffixes the
|
|
basename `-02`, `-03`, … up to `-99` to find a free slot,
|
|
rather than refusing as the §2 collision rule prescribes
|
|
for `save` / `save as`. Rationale: round-tripping zips
|
|
between users (export → email → import → re-export → re-import)
|
|
is a normal workflow, and forcing the recipient to type
|
|
`as <target>` for every collision is unnecessary friction.
|
|
Absolute `as <path>` is the user's explicit choice and is
|
|
not auto-suffixed — the operation refuses on collision so
|
|
the user gets exactly the path they asked for or a clear
|
|
error. The exported zip has no `playground.db` and no
|
|
`history.log`, so the imported project starts with neither;
|
|
the runtime rebuilds `playground.db` from YAML+CSV on
|
|
open, and `history.log` begins empty.
|
|
|
|
The `.gitignore` template (F2) is created in every new
|
|
project directory and excludes:
|
|
|
|
```
|
|
/playground.db
|
|
/.rdbms-playground.lock
|
|
/project.yaml.v*.bak
|
|
```
|
|
|
|
`playground.db` is rebuildable; the lock file is
|
|
per-process; migration backup files are local recovery aids
|
|
that don't belong in shared history. The `data/` directory
|
|
and `project.yaml` itself are *not* ignored — they are the
|
|
shared source of truth.
|
|
|
|
`history.log` is **not** ignored by default. Whether to
|
|
commit one's working log is a per-user, per-project taste
|
|
question — some learners will treat the log as part of the
|
|
audit trail and want it in git; others will prefer to keep
|
|
it private. The export zip handles the "share with
|
|
strangers" case (ADR-0007 amendment 1); committing to git
|
|
is a different decision and we leave it to the user.
|
|
|
|
### 12. Persistent input history (I2-persist)
|
|
|
|
The in-memory navigable input history (Up/Down arrows,
|
|
draft preservation, consecutive-duplicate dedup) gains a
|
|
loader: on project open, the history navigation seed is
|
|
populated from the project's `history.log` (latest N entries,
|
|
where N is the same in-memory cap as today). New successful
|
|
commands append to `history.log` and are pushed onto the
|
|
in-memory stack as they are now.
|
|
|
|
Project-scoped only. A separate global rolling history is
|
|
deferred to a future ADR (OOS-6).
|
|
|
|
### 13. Out of scope
|
|
|
|
The following are tracked but not part of this ADR:
|
|
|
|
- **OOS-1.** Snapshot ring buffer and `undo` (U1, U2,
|
|
ADR-0006).
|
|
- **OOS-2.** `replay` command (U4). The `history.log`
|
|
format is replay-compatible; the command itself ships
|
|
later.
|
|
- **OOS-3.** Multi-tab output / V4 session log work.
|
|
- **OOS-4.** Tab completion or syntax highlighting for the
|
|
new commands' arguments.
|
|
- **OOS-5.** L2 (submitting a command alongside project
|
|
load).
|
|
- **OOS-6.** Global rolling input history.
|
|
|
|
## Relationship to earlier ADRs
|
|
|
|
This ADR amends two earlier ADRs in place rather than
|
|
superseding them outright; the earlier ADRs remain the
|
|
canonical reference for everything outside the amended
|
|
clauses.
|
|
|
|
- **ADR-0004 — Project file format.** The "playground.db is a
|
|
derived artifact" framing remains correct for *recovery*
|
|
(the database can be reconstructed from text sources at any
|
|
time). It does not describe runtime data flow: at write
|
|
time, all four targets (db, yaml, csv, history.log) share a
|
|
single source — the user's command — and are written
|
|
alongside one another per section 6 here. The "rebuild
|
|
with confirmation when `.db` exists" semantics are
|
|
reframed: there is no automatic drift detection on load;
|
|
the rebuild path is the explicit `rebuild` command, which
|
|
prompts for confirmation when invoked.
|
|
- **ADR-0007 — Sharing and export.** The export contents are
|
|
now `project.yaml` + `data/`, *excluding* both
|
|
`playground.db` (as before) and `history.log` (new).
|
|
Rationale: the history is the user's working log and may
|
|
contain commands they don't want to share. Export remains
|
|
zip-based; default filename pattern is unchanged.
|
|
|
|
The amendments are made in place in those ADR files, with a
|
|
note pointing to this ADR.
|
|
|
|
## Consequences
|
|
|
|
- The biggest UX gap closes: quitting no longer loses work.
|
|
- A failed command leaves the disk unchanged. A succeeded
|
|
command is durable on disk before the application
|
|
acknowledges it, with one documented edge case that the
|
|
`rebuild` command exists to fix.
|
|
- The persistence path runs four file writes per command in
|
|
the common case. At teaching scale this is invisible; at
|
|
bulk-insert scale (thousands of rows in tight loops) it
|
|
could matter, and a future "batch" command will be the
|
|
remedy. Premature debouncing is rejected (it would create
|
|
a real inconsistency window for negligible gain at this
|
|
scale).
|
|
- The "commit db last" ordering is the load-bearing
|
|
invariant for failure recovery. Future contributors
|
|
changing the persistence flow must preserve it.
|
|
- The display-name prettifier is small and lives close to
|
|
the project loader; future filename conventions
|
|
(instructor-supplied lesson kits, perhaps) plug into it.
|
|
- The lock file is a small piece of state that survives
|
|
crashes; the "live PID on this host" check is the
|
|
load-bearing piece of its correctness. Cross-host network
|
|
filesystems will give us false positives there; we accept
|
|
that and document it if real users hit it.
|
|
- `history.log` becomes the persistent history surface.
|
|
Once `replay` (OOS-2) and `undo` (OOS-1) land, they read
|
|
from the same file with no schema changes.
|
|
|
|
## Amendment 1 — Persist & restore the input mode (2026-05-31, issue #14)
|
|
|
|
### Problem
|
|
|
|
`--resume` (and any project open) always started in the default
|
|
`simple` input mode. A learner who quit in advanced mode had to
|
|
re-toggle on every launch. The mode the user was in is a real,
|
|
restorable preference; losing it each session is a small but
|
|
repeated UX cost.
|
|
|
|
### Decision
|
|
|
|
The **input mode is per-project state stored in `project.yaml`**,
|
|
restored on every open and persisted as it changes. A teacher can
|
|
prepare a project that opens ready in advanced mode and hand it to
|
|
students; a learner who works across projects has each one's mode
|
|
restored when it loads ("loading triggers the mode switch each
|
|
time" — the user's framing). This is a deliberate, useful form of
|
|
the "mode travels with the project" property, *not* an accidental
|
|
leak (the alternative — a private per-user file — was rejected for
|
|
exactly that reason: it would prevent the teacher-prep use case).
|
|
|
|
**1. Storage — `project.yaml`, alongside `created_at`.** A new
|
|
optional `mode:` key under the `project:` mapping:
|
|
|
|
```yaml
|
|
version: 1
|
|
project:
|
|
created_at: 2026-05-31T00:00:00Z
|
|
mode: advanced
|
|
```
|
|
|
|
It is **project metadata, not schema** — `rebuild` ignores it
|
|
(reconstructs the db from tables/data; the mode plays no part).
|
|
The field is **optional with a `simple` default**: pre-#14 files
|
|
(no `mode:`) parse unchanged, no version bump, no migrator — the
|
|
same backward-compatible pattern as the `unique` index flag. It
|
|
**does** travel in an `export` zip (it is part of `project.yaml`),
|
|
which is the intended teacher→student behaviour.
|
|
|
|
**2. Mode is live UI state, not stored in the database.** Unlike
|
|
`created_at` (whose source of truth is gone after creation, so it
|
|
round-trips through the internal metadata table), the mode's
|
|
source of truth is always live — it is `App.mode`. So it is **not**
|
|
put in the database. Instead the persistence handle carries the
|
|
**current mode**, and the worker **stamps it into `project.yaml`
|
|
on every write**. Because every schema-mutating command rewrites
|
|
the whole file, writing the *current* mode each time means a later
|
|
command can never clobber it back to the default — there is
|
|
nothing to "preserve", only the live value to write. (This
|
|
replaced an initial over-engineered design that mirrored the mode
|
|
in the db metadata table; the simpler "write what we're in"
|
|
approach is correct because the mode is never reconstructed from
|
|
text.)
|
|
|
|
**3. Restore precedence: `--mode` > stored > `simple`.** A new
|
|
CLI flag `--mode simple|advanced` overrides the stored mode at
|
|
startup; it combines with `--resume` and a positional path (not
|
|
mutually exclusive — on collision the flag wins). At boot the
|
|
runtime reads the stored mode (`Persistence::read_stored_mode`,
|
|
which returns `None` for an absent field so "no preference" stays
|
|
distinct from an explicit `simple`), applies the flag, sets
|
|
`App.mode`, and seeds the persistence handle so the resolved mode
|
|
is what subsequent writes record. The `--mode` override applies
|
|
**only at boot** — a later project switch restores that project's
|
|
own stored mode.
|
|
|
|
**4. Mid-session changes.** The `mode` command emits a new
|
|
`Action::PersistMode`; the runtime records it through the worker
|
|
(`Database::set_mode`), which updates the live mode and writes
|
|
`project.yaml` immediately (crash-safe). Persisting the mode is
|
|
**best-effort** throughout: a failure must never escalate a UI
|
|
action into a fatal (the in-memory mode has already changed).
|
|
|
|
**5. Persist on unload (the deciding rule).** The mode is written
|
|
whenever the current project is **unloaded** — on quit and on a
|
|
project switch (load / new / save-as / import), the runtime calls
|
|
`set_mode(App.mode)` on the outgoing database before it is dropped.
|
|
This is what makes the stored mode **deterministic and
|
|
non-confusing**: by the time you leave a project, the mode you were
|
|
in is always recorded — including a bare `--mode` override or a
|
|
read-only session that ran no command. (An earlier "persist only on
|
|
the `mode` command or a schema-changing command" rule was rejected
|
|
as confusingly *selective* — whether a `--mode` override stuck
|
|
depended on whether you happened to run a DDL. "On unload" was
|
|
chosen over "on every command" to avoid rewriting `project.yaml` —
|
|
and bumping its mtime, which orders the load picker — on every
|
|
read-only `select`/`show data`.) On a switch the **outgoing**
|
|
project's mode is saved first, then the **incoming** project's
|
|
stored mode is restored and carried to the `App` via the
|
|
`ProjectSwitched` event ("loading triggers the mode switch each
|
|
time").
|
|
|
|
### Scope / non-changes
|
|
|
|
- No new project file, no database schema change, no migration.
|
|
- `rebuild` is unaffected (mode is not schema; the round-trip
|
|
through text never touches it).
|
|
- The default for a brand-new project is unchanged (`simple`).
|
|
- Coverage: `mode.rs` keyword parse/round-trip +
|
|
`resolve_startup_applies_flag_then_stored_then_default`
|
|
(precedence helper);
|
|
`yaml.rs::{mode_round_trips_through_serialize_and_parse,
|
|
parse_schema_defaults_mode_to_simple_when_field_absent,
|
|
parse_stored_mode_distinguishes_absent_from_explicit,
|
|
parse_stored_mode_falls_back_to_none_on_unknown_value}`;
|
|
`persistence::{read_stored_mode_round_trips_a_written_project_yaml,
|
|
read_stored_mode_is_none_for_a_missing_project_yaml}`;
|
|
`db::{set_mode_persists_and_survives_a_later_ddl_command (the
|
|
core no-clobber guarantee),
|
|
set_mode_persists_even_with_no_prior_command (the persist-on-unload
|
|
guarantee)}`; `archive::export_carries_the_stored_input_mode`
|
|
(the teacher-export round-trip); `cli` `--mode` parse/precedence;
|
|
`app::{mode_command_changes_mode_and_emits_persist_action,
|
|
mode_command_via_one_shot_escape_persists_advanced,
|
|
project_switched_event_restores_the_stored_mode}`. The runtime's
|
|
unload call sites (quit + `handle_project_switch`) are thin
|
|
wiring over the tested `Database::set_mode`.
|
|
|
|
### Relationship to the Iteration 6 backlog
|
|
|
|
Issue #14 named this an Iteration 6 piece (persistent input
|
|
history / `--resume`). `--resume` itself already shipped; this
|
|
amendment adds the mode dimension. It is independent of the
|
|
`history.log`-based input-history hydration (§12), which remains
|
|
its own piece of Iteration 6.
|