Repository navigation
Mutation input layer: typed inputs, Form lens, result feedback, targeted re-read - #13
Conversation
…ted re-read
Three slices of the mutation epic (P1/P2 core + P3), one arc: from "the only
write trigger is an ActionList row button" to a full input → write → feedback
loop, YAML-declared end to end.
Typed inputs + mutation Button
- TextInput/NumberInput controls ($bindState, like Select/Toggle); web inputs
+ read-only TUI parity
- Button.mutation fires a non-row mutation directly; `requires` guard disables
until state pointers resolve; runtime-injected saving flag (inflightCells)
- MutationParams.row/rowKey now optional (non-row dispatch)
Form lens (one card = fields + submit)
- 6 field kinds (text/number/select/toggle/textarea/date), query-prefilled via
per-field `column`, key_column remount resets edits on row-identity change
- Dirty tracking, required guard; per-field mutations dispatch ONLY dirty
fields (edit-form); form-level `mutations` dispatch together (create-form,
e.g. add_comment + link_comment in one submit)
- Runtime batch dispatch: sequential independent commits, stop-at-first-error,
"(n/m fields saved)" aggregates, one saving flag, ONE final re-read
- New `{$input: field}` param marker (resolved from submitted values, mirrors
$row); Form identity via `{$row: col}` — render-time $state resolution drops
`default`, so the prefill row rides the batch
- Inline `cell.controls` now carries any control kind — Assign/Filter tabs
collapse to single cards
Result feedback + no-op detection + targeted re-read
- MutationResult carries the server's affected counts (was discarded — a write
matching 0 rows reported success)
- Success → snapshot.mutationFeedback → bottom-right toast ("Saved — 2 fields,
3 rows", 4s); errors structurally cannot toast
- No-op (0 affected) = failed write: optimistic overlay rolled back, persistent
inline warning at the origin (per-row / Button / Form / global fallback)
- MutationSpec.invalidates: [query refs] — successful dispatch re-reads only
matching cells + origin; omitted → conservative re-read-all
- TUI: green "✓ Saved" status line
Fixes folded in
- Select: `""↔__any__` sentinel applied without a "" option printed the raw
sentinel in the trigger — placeholder path instead
- compatibility + validate missed Button props.mutation, Form fields/mutations,
and ALL inline cell.controls mutations — rawGq escaped its capability gate;
now every mutation shape is walked (validate also checks `invalidates`
entries resolve to catalog READ queries)
- validate/cellNeedsCatalog, $input in marker detection
Demo: examples/mutation-demo/ — RustFS (S3) backed cluster (storage: s3://…,
the one-line difference from a filesystem cluster), task-review-queue graph,
5-tab notebook exercising every primitive; scripts/server-demo-s3.sh
bootstraps it (validate→import→apply→load, --serve on :8090). mutation-epic.md
documents the write-path state of affairs and the remaining roadmap.
Tests: 122 → 164 (core 92, client 26, tui 1, web 45). Verified live against
the RustFS cluster: dirty-only Form submits, toast + targeted read set from
the server log, where-miss no-op (affected: 0), validate negative paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh
| }); | ||
| }; | ||
|
|
||
| const setField = (name: string, v: unknown): void => | ||
| setValues((prev) => ({ ...prev, [name]: v })); | ||
|
|
||
| return ( | ||
| <form | ||
| className="flex flex-col gap-3" | ||
| onSubmit={(e) => { | ||
| e.preventDefault(); |
There was a problem hiding this comment.
Create-form partial-failure retry duplicates non-idempotent form-level mutations
After a batch that partially commits (e.g. add_comment succeeds, link_comment fails), the Form component has no record of which form-level entries already landed on the server. Because a create-form has no query to re-read (and therefore no baseline change to mark those entries clean), all p.mutations entries re-enter the batch on the next submit. If add_comment is not idempotent, the retry creates a second entity. For the demo's add_comment + link_comment pattern this is a concrete duplicate-record risk.
Per-field mutations on edit-forms avoid this because the post-commit re-read updates baseline, causing saved fields to drop out of dirty. Form-level mutations have no equivalent cleanup signal.
There was a problem hiding this comment.
Fixed in 11e20a2 — the parked batch failure now carries the committed entries' identities (mutation + resolved params, cumulative across repeated failures); the next dispatch from that cell skips them. Editing a field between retries changes the resolved params, so that entry re-fires as a new write. Covered by two runtime tests (skip across repeated failures; edited-input re-fire).
| /** App shell: centered column with an optional left cell-nav and header. */ | ||
| /** App shell: a single full-width centered column. The header is sticky; cell | ||
| * navigation lives in the ⌘K palette (no always-on sidebar). */ |
There was a problem hiding this comment.
Duplicate JSDoc comment — the first one (
centered column with an optional left cell-nav) is stale and was superseded by the second. Remove it to avoid confusing future readers.
| /** App shell: centered column with an optional left cell-nav and header. */ | |
| /** App shell: a single full-width centered column. The header is sticky; cell | |
| * navigation lives in the ⌘K palette (no always-on sidebar). */ | |
| /** App shell: a single full-width centered column. The header is sticky; cell | |
| * navigation lives in the ⌘K palette (no always-on sidebar). */ |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Greptile P1: after a partial batch failure (add_comment committed, link_comment failed), re-submitting re-ran ALL form-level mutations — edit-form fields self-heal via the baseline re-read, but form-level create mutations had no committed signal, so a retry duplicated the insert. The parked inflightCells failure now carries the committed entries' identities (mutation name + RESOLVED params, accumulated across repeated failures); the next batch from that cell skips matching entries. Identity includes resolved params, so editing a field between retries re-fires the entry — a different write, as intended. The "(n/m fields saved)" aggregate counts skipped entries as saved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh
…e-form clear (#14) * CRUD completion: confirm guard, delete with optimistic removal, create-form clear P4 of the mutation epic — the three gaps left in the shipped write path: destructive actions fired instantly, delete didn't exist (overlays were field-level only), and a create-form kept its values after a successful submit. Confirm guard (inline two-step) - MutationSpec.confirm: true | "armed label" — first activation arms the trigger (label swap + cancel, 4s auto-disarm), second fires. Works on ActionList row actions, mutation Buttons (incl. inline cell.controls), and the TUI (Enter arms [ Confirm? ], Esc/focus-move disarms). Delete + optimistic row removal - optimistic: { remove: true } (OptimisticSpec now { set?, remove? }) — patchesFromMutation emits a row-removal patch; applyOptimisticPatches filters the row out while the delete is in flight. Success + re-read keeps it gone (no flicker); failure/no-op deletes the owned patch, so the row is restored (with the parked no-op warning on it). Create-form clears on success - New runtime successCells map (inflightCells entries are deleted on success, so the seq needs its own home) → Form runtime props gain last_success_seq → a create-form (no prefill row) keys its remount on it, wiping entered values. Edit-forms are unaffected. Demo: del_task / del_comment / unassign_task (node deletes cascade edges; edge delete removes ALL AssignedTo from the task). Delete on the review queue, comment list converted to an ActionList with a confirmed delete, "Unassign all" inline on the Assign card. Also: replace a literal NUL byte that shipped in batchEntryKey's template string (11e20a2) with an explicit backslash-u0000 escape — the raw byte made runtime.ts register as BINARY to git/grep; and drop a stale duplicate JSDoc in App.tsx (Greptile P2 from #13). Tests: 166 -> 177 (core 100, client 26, tui 1, web 50). Verified live on the RustFS demo: arm-cancel fires nothing, arm-confirm deletes with the row vanishing optimistically + edge cascade, partial-failure retains form values with "(1/2 fields saved)" inline, clean success wipes the form. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh * confirm accepts literal true only — `confirm: false` would arm the guard Greptile: z.boolean() admitted `false`, and every renderer gates on `confirm !== undefined`, so an author writing `confirm: false` to disable the guard would get a two-step arm instead. z.literal(true) rejects it at parse time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh * TUI confirm arms by stable row id, not positional index Greptile: the armed-state key used focusedRow, so a background re-read reordering rows between arm and fire could confirm the mutation against a different row. Key on the row's id_column value (like the web renderer) — after a reorder the key no longer matches, so Enter re-arms the row now under focus instead of firing at the wrong target. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Three slices of the mutation epic (P1/P2 core + P3), one arc: from "the only write trigger is an ActionList row button" to a full input → write → feedback loop, YAML-declared end to end. Companion doc:
mutation-epic.md(state-of-affairs + remaining roadmap).1. Typed inputs + mutation Button
TextInput/NumberInputcontrols — two-way$bindState, like Select/Toggle (web editable, TUI read-only parity).Button.mutationfires a non-row mutation directly;requires: [/ptr, …]disables submit until the state pointers resolve; runtime-injectedsavingflag.MutationParams.row/rowKeynow optional (non-row dispatch).2. Form lens — one card = fields + submit
text/number/select/toggle/textarea/date), prefilled from the cell query's first row (columnmapping);key_columnremounts on row-identity change so stale edits can't fire against a new entity.mutationsdispatch together on submit (create-flow:add_comment+link_commentin one go)."(n/m fields saved)"aggregates, one saving flag, ONE final re-read.{$input: field}param marker (dispatch-resolved from submitted values, mirrors$row). Identity uses{$row: col}— the renderer resolves{$state}in props at render time and silently dropsdefault, so the prefill row rides the batch instead.cell.controlsaccepts any control kind → the demo's Assign/Filter tabs collapse to single cards.3. Result feedback + no-op detection + targeted re-read
MutationResultnow carries the server's affected counts (previously discarded — a write matching 0 rows reported success).snapshot.mutationFeedback, so errors structurally cannot toast. TUI: green✓ Savedline.MutationSpec.invalidates: [query refs]— after success, re-read only cells whosequery.refmatches (plus the origin); omitted → conservative re-read-all. Verified from the server log:assign_tasknow refreshes 1 cell instead of 10.Fixes folded in
actions[*].mutation— Buttonprops.mutation, Form fields/mutations, and all inlinecell.controlsmutations were invisible (rawGq escaped its gate). Every mutation shape is now walked;validatealso checksinvalidatesentries resolve to catalog READ queries, with exact error paths.__any__leak: the""↔sentinelmapping applied even when""wasn't an option → Base UI stringified the raw sentinel into the trigger. Placeholder path instead.Demo + verification
examples/mutation-demo/— RustFS (S3-backed) cluster (storage: s3://…— the one-line difference from a filesystem cluster), task-review-queue graph, 5-tab notebook exercising every mutation primitive;scripts/server-demo-s3.shbootstraps (validate→import→apply→load,--serveon :8090).$input/$rowresolution, no-op parking at all three origins, targeted-re-read unions, dirty-only submit (happy-dom), toast bridge, validate negative paths.affected: 0).🤖 Generated with Claude Code
https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh
Greptile Summary
This PR delivers three layers of the mutation epic: typed
TextInput/NumberInputcontrols and a mutationButton; aFormlens supporting edit-form (dirty-only per-field dispatch) and create-form (form-level mutations) shapes; and result feedback viaMutationResult.affectedcounts, no-op detection with inline warnings, targeted re-read viainvalidates, and a toast bridge. A capability-gate hole (mutations in Form fields, mutation Buttons, and inlinecell.controlswere invisible tovalidate/compatibility) is also closed.TextInput/NumberInputcontrols added;Button.mutationfires non-row mutations with arequiressubmit guard;{$input: field}param marker added for Form dispatch.mutationsfired on every submit);committedKeysdeduplication prevents non-idempotent re-execution on partial-failure retry.affected_nodes/affected_edgescounts surfaced; no-op (affected=0) rolls back optimistic overlays;MutationSpec.invalidatesscopes post-success re-reads; success toasts viaSuccessToastBridgekeyed by monotonicseq.Confidence Score: 4/5
Safe to merge — batch dispatch, committedKeys deduplication, and targeted re-read all behave correctly in normal and partial-failure paths.
The required:true toggle field silently having no effect is a minor footgun for form authors, and the all-skipped retry producing no feedback is a small UX gap. Neither represents wrong data or a broken code path.
packages/web/src/components/Form.tsx — the missingRequired guard and toggle kind interaction are worth a second look before the Form spec stabilises.
Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant F as Form (web) participant R as NotebookRuntime participant S as ServerSource participant Srv as omnigraph-server F->>R: dispatch mutate batch with mutations, input, row, cell_id activate R R->>R: "inflightCells.set saving=true" R-->>F: rebuildSpecs - Submit disabled loop for each entry, skip priorCommitted keys R->>S: mutate spec with resolvedParams S->>Srv: POST /graphs/g/queries/ref Srv-->>S: affected_nodes + affected_edges S-->>R: ok with affected counts R->>R: check affected count end alt failure - no-op or error R->>R: park error in inflightCells with committedKeys R-->>F: rebuildSpecs - inline error shown else "success committed > 0" R->>R: delete inflightCells entry R->>R: set mutationFeedback with seq R-->>F: SuccessToastBridge fires toast R->>R: rerunCells via invalidationTargets R-->>F: fresh baseline - dirty fields become clean end deactivate R%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant F as Form (web) participant R as NotebookRuntime participant S as ServerSource participant Srv as omnigraph-server F->>R: dispatch mutate batch with mutations, input, row, cell_id activate R R->>R: "inflightCells.set saving=true" R-->>F: rebuildSpecs - Submit disabled loop for each entry, skip priorCommitted keys R->>S: mutate spec with resolvedParams S->>Srv: POST /graphs/g/queries/ref Srv-->>S: affected_nodes + affected_edges S-->>R: ok with affected counts R->>R: check affected count end alt failure - no-op or error R->>R: park error in inflightCells with committedKeys R-->>F: rebuildSpecs - inline error shown else "success committed > 0" R->>R: delete inflightCells entry R->>R: set mutationFeedback with seq R-->>F: SuccessToastBridge fires toast R->>R: rerunCells via invalidationTargets R-->>F: fresh baseline - dirty fields become clean end deactivate RReviews (2): Last reviewed commit: "Batch retry can't double-commit: skip al..." | Re-trigger Greptile