fix(app): hold input until the schema-cache refresh lands (#39)
A simple-mode Form-B insert (`insert into T values (…)`) submitted faster than the post-DDL schema-cache refresh was validated against the stale cache and wrongly rejected as "trying to write SQL?". The cache is refreshed asynchronously after the worker applies a command, posting `SchemaCacheRefreshed` on the same FIFO channel as key events; under faster-than-human input the next Enter is processed before the refresh. Add a submission gate: `dispatch_dsl` arms `awaiting_schema_refresh` on every `ExecuteDsl` dispatch, holds further DSL submissions in a `held_submissions` FIFO while armed, and the `SchemaCacheRefreshed` handler drains them against the fresh cache in submission order. App-lifecycle commands bypass the gate. Arm-on-every-dispatch keeps exactly one DSL command in flight, so the boolean is provably correct. Interactive-only: the replay/batch path already re-snapshots the schema synchronously per line. No interactive-user impact; held input is never lost (a refresh always follows a dispatch). Tests (both verified red→green): a Tier-1 deterministic ordering test and a Tier-4 PTY back-to-back regression (the unpaced inverse of flow 3). Records the design as ADR-0022 Amendment 8.
This commit is contained in:
@@ -824,6 +824,63 @@ of issue #4; no `AmbientHint` / renderer change. Covered by
|
||||
seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given,
|
||||
seed_count_hint_also_fires_after_a_column_fill_target}`.
|
||||
|
||||
## Amendment 8 — submission gate: hold input until the schema-cache refresh lands (2026-06-22)
|
||||
|
||||
§9 established the schema cache as the source of truth for the
|
||||
walker's schema-aware dispatch, "refreshed by the runtime … after
|
||||
successful DDL". That refresh is **asynchronous**: the runtime applies
|
||||
a command on the worker thread (ADR-0010) and only afterwards posts a
|
||||
`SchemaCacheRefreshed` event back through the **same FIFO channel that
|
||||
carries key events**. Between dispatching a DDL command and that event
|
||||
landing, `schema_cache` is stale w.r.t. the command just run.
|
||||
|
||||
Validation in `App::update` is pure-sync (a core invariant — it cannot
|
||||
do a DB round-trip), so it can only consult that cache. Under
|
||||
**faster-than-human input** (paste, scripted input, an unpaced PTY
|
||||
driver), the next submission's Enter is already queued *ahead* of the
|
||||
refresh event, so it is validated against the stale cache. A simple-mode
|
||||
**Form-B insert** (`insert into T values (…)`, columns derived from the
|
||||
cache) submitted right after `add column` then sees the pre-DDL columns,
|
||||
its value arity can't match, and the friendly layer tags it *"trying to
|
||||
write SQL?"* — even though the identical line succeeds at human speed
|
||||
(issue **#39**).
|
||||
|
||||
**Decision — gate submission on the pending refresh.** `App` carries
|
||||
`awaiting_schema_refresh: bool` + a `held_submissions` FIFO queue.
|
||||
`dispatch_dsl` arms the flag on **every** `ExecuteDsl` dispatch; while
|
||||
armed, a new DSL submission is **held** (queued in submission order)
|
||||
rather than validated. The `SchemaCacheRefreshed` handler clears the
|
||||
flag and **drains** the queue against the now-fresh cache, stopping as
|
||||
soon as a drained command re-arms the gate (so the remainder wait for
|
||||
*its* refresh — order preserved). A held command that doesn't dispatch
|
||||
(parse error / pre-flight rejection) leaves the gate open and the loop
|
||||
continues. App-lifecycle commands (`quit` / `help` / `load` / `undo` /
|
||||
`rebuild`) route through `dispatch_app_command` *before* `dispatch_dsl`
|
||||
and so are never held.
|
||||
|
||||
**Why arm on every dispatch, not just DDL.** It keeps at most one DSL
|
||||
command in flight, so refreshes are strictly one-per-dispatch and in
|
||||
order — making the gate a provably-correct boolean. Arming only on
|
||||
schema-mutating commands would let a *preceding* non-DDL command's
|
||||
refresh clear the gate early and drain a held Form-B insert against a
|
||||
cache that predates the DDL — reintroducing the bug in a corner case.
|
||||
(App-lifecycle commands also refresh but bypass the gate; they are
|
||||
modal/picker-gated and so cannot overlap a rapid DSL paste, the only
|
||||
thing this guards.)
|
||||
|
||||
**Scope.** Interactive input only. The `replay` / history-log / startup
|
||||
rebuild-from-text batch path already re-snapshots the schema
|
||||
**synchronously, inline, before every line** (`run_replay`,
|
||||
`build_schema_cache`), so it has always had this ordering guarantee;
|
||||
this amendment brings the interactive path in line with it. No
|
||||
interactive-user impact (the gate clears in milliseconds); held input is
|
||||
never lost because the runtime sends a `SchemaCacheRefreshed` after
|
||||
*every* dispatch, success or failure. Covered by
|
||||
`app::tests::form_b_insert_after_ddl_is_held_until_refresh_then_dispatched`
|
||||
(Tier-1, deterministic event ordering) and the Tier-4 PTY regression
|
||||
`e2e_pty::back_to_back_insert_after_ddl_still_succeeds` (the unpaced
|
||||
inverse of flow 3).
|
||||
|
||||
## Out of scope
|
||||
|
||||
Deliberately deferred to keep this ADR shippable as a single
|
||||
|
||||
Reference in New Issue
Block a user