Skip to content

Mutation input layer: typed inputs, Form lens, result feedback, targeted re-read - #13

Merged
aaltshuler merged 2 commits into
mainfrom
mutation-input-button
Jul 3, 2026
Merged

aaltshuler merged 2 commits into
mainfrom
mutation-input-button

Conversation

@aaltshuler

@aaltshuler aaltshuler commented Jul 3, 2026 •

Copy link
Copy Markdown
Collaborator

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 / NumberInput controls — two-way $bindState, like Select/Toggle (web editable, TUI read-only parity).
  • Button.mutation fires a non-row mutation directly; requires: [/ptr, …] disables submit until the state pointers resolve; runtime-injected saving flag.
  • MutationParams.row/rowKey now optional (non-row dispatch).

2. Form lens — one card = fields + submit

  • 6 field kinds (text/number/select/toggle/textarea/date), prefilled from the cell query's first row (column mapping); key_column remounts on row-identity change so stale edits can't fire against a new entity.
  • Dirty tracking: per-field mutations dispatch only the fields you changed (omnigraph mutations require all declared params → per-field mutations are the honest "send only what changed"). Form-level mutations dispatch together on submit (create-flow: add_comment + link_comment in one go).
  • Runtime batch: sequential independent commits, stop-at-first-error, "(n/m fields saved)" aggregates, one saving flag, ONE final re-read.
  • New {$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 drops default, so the prefill row rides the batch instead.
  • Inline cell.controls accepts any control kind → the demo's Assign/Filter tabs collapse to single cards.

3. Result feedback + no-op detection + targeted re-read

  • MutationResult now carries the server's affected counts (previously discarded — a write matching 0 rows reported success).
  • Success toast ("Saved — 2 fields, 3 rows", auto-dismiss 4s); only success paths write snapshot.mutationFeedback, so errors structurally cannot toast. TUI: green ✓ Saved line.
  • No-op = failed write: 0 affected → optimistic overlay rolled back + persistent inline warning at the origin (per-row / Button / Form / global fallback). No toast, no re-read.
  • MutationSpec.invalidates: [query refs] — after success, re-read only cells whose query.ref matches (plus the origin); omitted → conservative re-read-all. Verified from the server log: assign_task now refreshes 1 cell instead of 10.

Fixes folded in

  • Capability-gate hole: compatibility + validate only walked ActionList actions[*].mutation — Button props.mutation, Form fields/mutations, and all inline cell.controls mutations were invisible (rawGq escaped its gate). Every mutation shape is now walked; validate also checks invalidates entries resolve to catalog READ queries, with exact error paths.
  • Select __any__ leak: the ""↔sentinel mapping 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.sh bootstraps (validate→import→apply→load, --serve on :8090).
  • Tests 122 → 164 (core 92, client 26, tui 1, web 45): batch happy/partial/total failure, $input/$row resolution, no-op parking at all three origins, targeted-re-read unions, dirty-only submit (happy-dom), toast bridge, validate negative paths.
  • Verified live (headed browser) against the RustFS cluster: dirty-only Form submits (3 of 4 field mutations dispatched), toast + targeted read set confirmed from the server log, where-miss no-op (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/NumberInput controls and a mutation Button; a Form lens supporting edit-form (dirty-only per-field dispatch) and create-form (form-level mutations) shapes; and result feedback via MutationResult.affected counts, no-op detection with inline warnings, targeted re-read via invalidates, and a toast bridge. A capability-gate hole (mutations in Form fields, mutation Buttons, and inline cell.controls were invisible to validate/compatibility) is also closed.

  • Mutation input layer: TextInput/NumberInput controls added; Button.mutation fires non-row mutations with a requires submit guard; {$input: field} param marker added for Form dispatch.
  • Form lens: Two write shapes — edit-form (dirty fields only, per-field mutations) and create-form (form-level mutations fired on every submit); committedKeys deduplication prevents non-idempotent re-execution on partial-failure retry.
  • Result feedback + targeted re-read: affected_nodes/affected_edges counts surfaced; no-op (affected=0) rolls back optimistic overlays; MutationSpec.invalidates scopes post-success re-reads; success toasts via SuccessToastBridge keyed by monotonic seq.

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

Filename Overview
packages/core/src/runtime/runtime.ts Core dispatch logic: adds dispatchMutationBatch for Form submits, no-op detection via affected=0, committedKeys for safe partial-failure retry, inflightCells saving flag, and invalidationTargets for scoped re-reads.
packages/web/src/components/Form.tsx Edit+create form with sparse dirty tracking, key_column remount for identity safety, and batch dispatch. required:true on a toggle field never blocks submit because isEmpty(false) === false.
packages/core/src/runtime/mutations.ts New cellMutations replaces actionListMutations, walking all mutation shapes; invalidationTargets computes targeted re-read set correctly.
packages/core/src/catalog/lenses/form.ts Clean Zod schema for Form with runtime validation: duplicate field names rejected, missing-mutation guard, select-requires-options check.
packages/client/src/source.ts Now surfaces affected_nodes/affected_edges from both invokeMutation and rawGq paths instead of discarding the result; enables no-op detection upstream.
packages/cli/src/commands/validate.ts Capability-gate hole closed: validateMutationRef now applied to all mutation shapes. invalidates entries validated against catalog read queries.
packages/web/src/components/ui/toast.tsx Hand-rolled toast surface, 4s auto-dismiss, 3-item stack, cleanup on unmount. SuccessToastBridge in App.tsx gates on distinct seq.
packages/core/src/spec/index.ts Adds MutationBatchParamsSchema, MutationDispatchSchema, $input marker docs, MutationResult.affected, and MutationSpec.invalidates.

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
Loading
%%{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 R
Loading

Reviews (2): Last reviewed commit: "Batch retry can't double-commit: skip al..." | Re-trigger Greptile

…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
Comment on lines +145 to +155
});
};

const setField = (name: string, v: unknown): void =>
setValues((prev) => ({ ...prev, [name]: v }));

return (
<form
className="flex flex-col gap-3"
onSubmit={(e) => {
e.preventDefault();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Fix in Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread packages/web/src/App.tsx
Comment on lines 541 to 543
/** 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). */

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
/** 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!

Fix in Claude Code

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
@aaltshuler
aaltshuler merged commit 4c7b0d8 into main Jul 3, 2026
2 checks passed
@aaltshuler
aaltshuler deleted the mutation-input-button branch July 3, 2026 13:32
aaltshuler added a commit that referenced this pull request Jul 4, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant