Skip to content

Declare cache effects on the API and dispatch them in lite - #15272

Merged
mtsgrd merged 2 commits into
masterfrom
watcher-staleness-declared-in-rust
Aug 12, 2026
Merged

Declare cache effects on the API and dispatch them in lite#15272
mtsgrd merged 2 commits into
masterfrom
watcher-staleness-declared-in-rust

Conversation

@mtsgrd

@mtsgrd mtsgrd commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

What this does

lite decided which caches to refresh in two hand-written places: a table of
which watcher events make each query stale, and per-mutation invalidation
lists in every mutation hook. Both restate facts the backend owns. A query or
mutation nobody remembered to add silently never refreshed.

Both are now derived from one vocabulary defined in Rust. A CacheTag names
one kind of cached state — Reviews, Branches, WorktreeChanges — and
three declarations say everything that happens to it:

#[but_api(napi, provides = [Reviews])]        // a read: what its result is made of
pub async fn list_reviews(...)

#[but_api(napi, invalidates = [Reviews])]     // a mutation: what it makes stale
pub async fn publish_review(...)

WatcherEventKind::GitFetch.invalidates()      // an event: what it makes stale

The SDK exports all three tables as cache-tags, and lite derives every
refresh from them. On an event, queries whose tags the event invalidates are
refreshed. On a mutation, the endpoint's declared tags are applied by one
mutation-cache hook — the hand lists in the hooks are deleted.

The rules

  • A mutation that only writes to the repository declares nothing. The watcher
    observes the repository, and the event carries the invalidation. Forge, app,
    and config writes declare, because nothing watches those.
  • An endpoint either provides or invalidates, never both. Naming a tag that
    does not exist is a compile error, checked against the enum.
  • Omitting a declaration stays distinguishable from declaring []:
    unclassified is not the same answer as "no tag".

What remains in the mutation hooks is remedy machinery, not bookkeeping:
optimistic patches and their rollbacks. Same for events — worktreeChanges
pushes the changes it already carries, and target commits are re-read only
once the review refresh has landed.

Parity

Checked by replaying both dispatch paths against the previous behaviour:

  • Every event invalidates exactly what it did before, except dry-run
    previews, which are no longer refreshed by repository events (see below).
  • Every mutation invalidates exactly what its hook did, with one deliberate
    delta: publishReview now also refreshes the single-review cache, because
    the listing and the single review both provide Reviews.

Those checks hard-code the old behaviour, so they were run and removed. The
kept tests assert the durable parts: declared tags reach every provider,
a tag no query provides fails, the two event exceptions behave, and every
declared query refreshes after each event that invalidates its tags.

Notes for review

  • 15 cache keys are renamed to their endpoint names (reviews becomes
    listReviews, and so on), so tag-to-query derivation is a lookup.
  • projectQueryKeys stays hand-written on purpose: it is what lite caches,
    which the backend does not know.
  • Dry runs are outside the system: a dry run is an imperative measurement,
    memoized under a key carrying the operation and changes it measured, and
    nothing refreshes it in place. dryRun is client state beside the drafts.
  • watcher.ts becomes project-events.ts: from lite's side the file watching
    is a backend detail; what the renderer has is a per-project subscription.
  • The endpoint names themselves are inconsistent (branchList vs
    listReviews); renaming them touches every consumer, so it is left as a
    follow-up.

@github-actions github-actions Bot added the rust Pull requests that update Rust code label Aug 10, 2026
@mtsgrd
mtsgrd force-pushed the watcher-staleness-declared-in-rust branch from d2a4422 to 92f6ae9 Compare August 10, 2026 18:35
@mtsgrd mtsgrd changed the title Declare cache staleness on the API and dispatch it in lite Declare cache effects on the API and dispatch them in lite Aug 10, 2026
@krlvi

krlvi commented Aug 10, 2026

Copy link
Copy Markdown
Member

this stale_after tag on the macro is neat

@mtsgrd
mtsgrd force-pushed the watcher-staleness-declared-in-rust branch 3 times, most recently from 06e60c5 to f973827 Compare August 10, 2026 20:13
@mtsgrd

mtsgrd commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

@samhh here's an alternative. Wdyt?

@mtsgrd
mtsgrd force-pushed the watcher-staleness-declared-in-rust branch from f973827 to dd9bc8f Compare August 10, 2026 20:31
@mtsgrd

mtsgrd commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

this stale_after tag on the macro is neat

Sorry didn't see this until now! Yeah that worked.. for events, but now I've updated the PR and effectively moved tags to the rust side so that invalidations for both events and mutations could live in rust.

This pr is based on a conversation I had with Sam yesterday, I don't have a strong feeling for or against yet, just wanted to put up a prototype so we could feel it out.

@mtsgrd
mtsgrd force-pushed the watcher-staleness-declared-in-rust branch from dd9bc8f to 3ffda51 Compare August 11, 2026 21:52
@mtsgrd

mtsgrd commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

@samhh took your point one step further in the second commit (aae9695). The manual mutationKey your patch left behind is itself a hand-written copy of a fact the mutation already carries — its mutationFn. The mutation cache now resolves the endpoint by function identity:

const endpointByFn = new Map(Object.entries(window.lite).map(([name, fn]) => [fn, name]));

new MutationCache({
	onSuccess: (_data, variables, _ctx, mutation) =>
		invalidateDeclared(client, endpointOf(mutation.options.mutationFn), variables),
});

So apiMutation is gone as you proposed, and the keys mostly went with it — any mutation whose mutationFn is a declared endpoint invalidates automatically, with nothing to forget. The four hooks that wrapped their endpoint (to strip a cache-keying reviewId) now take it as a hook argument, so identity holds everywhere. That also fixed a live instance of the gap: removeCommentReaction declared invalidations in Rust but was written keyless, so they never ran.

The same commit applies the move three more times: failure toasts are declared as meta.failureTitle and shown by the mutation cache (dissolving two dozen identical onError handlers), and projectQueryKeys / exposedEndpoints — both hand-written copies of generated declarations — are now derived from them.

@mtsgrd
mtsgrd force-pushed the watcher-staleness-declared-in-rust branch from 3ffda51 to bf72b4f Compare August 11, 2026 22:20
@mtsgrd
mtsgrd force-pushed the watcher-staleness-declared-in-rust branch from bf72b4f to 37b883c Compare August 11, 2026 22:50
@mtsgrd
mtsgrd force-pushed the watcher-staleness-declared-in-rust branch from d28e0a5 to b5487da Compare August 12, 2026 08:27
@mtsgrd
mtsgrd force-pushed the watcher-staleness-declared-in-rust branch 2 times, most recently from 4d1a3a4 to aae9695 Compare August 12, 2026 09:30
lite decided which caches to refresh in two hand-written places: a table of
which watcher events make each query stale, and per-mutation invalidation
lists in every mutation hook. Both restate facts the backend owns, in another
language, where a forgotten entry silently never refreshes.

Both are now derived from one vocabulary, `CacheTag` in but-api: a tag names
one kind of cached state, and three declarations say everything that happens
to it.

* A read declares what its result is made of: `#[but_api(provides = [Reviews])]`
* A mutation declares what it makes stale: `#[but_api(invalidates = [Reviews])]`
* An event declares what it makes stale: `WatcherEventKind::invalidates`

A mutation that only writes to the repository declares nothing -- the watcher
observes the repository and the event carries the invalidation. Forge, app,
and config writes declare, because nothing watches those. An endpoint either
provides or invalidates, never both; naming a tag that does not exist is a
compile error; and `None` stays distinguishable from `[]`, since "unclassified"
is not the same answer as "no tag".

The SDK carries all three tables as `cache-tags` (`apiProvides`,
`apiInvalidates`, `watcherInvalidates`, and the `CacheTag` union), replacing
`apiStaleAfter`. In lite, `api/tags.ts` connects them to the query cache:
events meet queries where their tags intersect, and a mutation carrying its
endpoint as `mutationKey` -- arranged by `apiMutation` -- has its declared
tags applied by one mutation-cache hook. The hand lists in the mutation hooks
are deleted; what remains locally is remedy machinery: optimistic patches,
their rollbacks, and the two event exceptions (worktreeChanges pushes the
changes it carries, target commits re-read only after the review refresh).

15 cache keys are renamed to their endpoint names so tag-to-query derivation
is a lookup, and `projectQueryKeys` stays hand-written on purpose: it is what
lite caches, which the backend does not know. Every tag has at least one
provider and every project query one declaration.

Dry runs are outside the system, as a ruling: a dry run is an imperative
measurement, memoized under a key carrying the operation and changes it was
measured against, and nothing refreshes it in place -- users do not expect a
hover preview to update itself, and mutations serialize behind the project
lock anyway. `dryRun` is a `LocalQueryKey` beside the drafts, and events no
longer touch it.

Parity, checked by replaying both dispatch paths against the previous
behaviour, leaves two deliberate deltas: publishReview also refreshes the
single-review cache, since both listings provide `Reviews`; and dry-run
previews are no longer refreshed by repository events.

`watcher.ts` becomes `project-events.ts`, since from lite's side the file
watching is a backend detail and what the renderer has is a per-project event
subscription.
@mtsgrd
mtsgrd force-pushed the watcher-staleness-declared-in-rust branch from aae9695 to a420c53 Compare August 12, 2026 11:19
Comment thread apps/lite/ui/src/api/tags.ts Outdated
* The rare mutation carrying a key (for pending-state lookups) names the
* endpoint it calls, so a key naming no endpoint is a type error.
*/
mutationKey: readonly [keyof typeof window.lite];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This may require a notion of a "local" mutation key in the future, like queries.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Dissolved by the reply below: the last key is gone, so the augmentation went with it. If keys ever come back, a LocalMutationKey union like the query one is the shape I'd reach for.

* They are stale — the exception is about the remedy, not the diagnosis — so
* the `satisfies` holds them to that: naming a query the event does not make
* stale is a type error rather than a disagreement.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We might be able to remove this complexity for at least worktree changes. IIUC it'll cause a redundant invalidation in each case, though it shouldn't cause any further issues beyond the RQ boundary.

I believe this relates to "duplicate work" here: https://linear.app/gitbutler/issue/GB-1523/lite-ipcmutation-response-data-and-timestamp. It's a workaround for a known architectural issue.

Having said that I haven't looked at refreshIntegratedReviews yet, that may justify this.

(Not a blocker.)

Comment thread apps/lite/ui/src/api/mutations.ts Outdated
@mtsgrd
mtsgrd force-pushed the watcher-staleness-declared-in-rust branch from a420c53 to c42bb69 Compare August 12, 2026 11:32
@mtsgrd
mtsgrd force-pushed the watcher-staleness-declared-in-rust branch from c42bb69 to f3c13fb Compare August 12, 2026 15:47
@mtsgrd
mtsgrd force-pushed the watcher-staleness-declared-in-rust branch from f3c13fb to d111bcb Compare August 12, 2026 15:47
@mtsgrd

mtsgrd commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @samhh. I'll mark this ready for review to get copilot to weigh in as well, and then merge once I am satisfied all comments have been addressed.

@mtsgrd
mtsgrd marked this pull request as ready for review August 12, 2026 16:47
Copilot AI lite review requested due to automatic review settings August 12, 2026 16:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR centralizes cache invalidation semantics by introducing a Rust-owned CacheTag vocabulary and exporting three derived maps (API provides, API invalidates, watcher invalidates) through but-sdk, then updating the Lite app to drive query refresh/invalidation purely from those declarations instead of hand-maintained lists.

Changes:

  • Add CacheTag vocabulary + provides/invalidates declarations on #[but_api] endpoints, with macro validation and UI tests for invalid declarations.
  • Generate and export cache-tags maps in but-sdk (linear + graph variants) from Rust declarations and watcher event kinds.
  • Update Lite to (a) derive event-driven invalidations from watcherInvalidates + apiProvides, and (b) apply mutation invalidations centrally via a shared MutationCache + declared apiInvalidates.

Reviewed changes

Copilot reviewed 47 out of 51 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/but-sdk/src/generated/linear/cacheTags.js Adds generated cache-tag maps for the linear SDK build.
packages/but-sdk/src/generated/linear/cacheTags.d.ts Adds types for cache-tag maps + CacheTag union for the linear SDK build.
packages/but-sdk/src/generated/graph/cacheTags.js Adds generated cache-tag maps for the graph SDK build.
packages/but-sdk/src/generated/graph/cacheTags.d.ts Adds types for cache-tag maps + CacheTag union for the graph SDK build.
packages/but-sdk/package.json Exports the new cache-tags entrypoints and includes generated files in published package files.
crates/but-ts/src/main.rs Extends the TS generator to emit cache-tag maps/types from Rust declarations and watcher events.
crates/but-schemars/src/lib.rs Extends ApiFnEntry inventory metadata to include provides/invalidates declarations.
crates/but-api/src/workspace.rs Annotates workspace endpoints with provides tags (including explicit [] where intended).
crates/but-api/src/watcher.rs Introduces WatcherEventKind with declared tag invalidations for SDK generation.
crates/but-api/src/target_commits.rs Declares workspace_target_commits as providing TargetCommits.
crates/but-api/src/tags.rs Adds the Rust-side CacheTag vocabulary definition.
crates/but-api/src/resolve/mod.rs Declares commit_conflicts as explicitly providing no tags (provides = []).
crates/but-api/src/lib.rs Exposes the new tags module from but-api.
crates/but-api/src/legacy/workspace.rs Adds cache-tag declarations to legacy workspace endpoints/mutations.
crates/but-api/src/legacy/repo.rs Adds SigningSettings provides tag declaration.
crates/but-api/src/legacy/projects.rs Adds Projects invalidation declarations for project mutations.
crates/but-api/src/legacy/git.rs Declares delete_all_data invalidates Projects.
crates/but-api/src/legacy/forge.rs Adds provides/invalidates declarations across forge endpoints.
crates/but-api/src/legacy/config.rs Declares config read/write cache effects via tags.
crates/but-api/src/legacy/absorb.rs Declares absorption_plan provides AbsorptionPlan.
crates/but-api/src/gitlab.rs Declares GitLab account mutations invalidate ForgeAccounts/ForgeLogin.
crates/but-api/src/github.rs Declares GitHub account mutations invalidate ForgeAccounts/ForgeLogin.
crates/but-api/src/diff.rs Declares diff/commit-related endpoints provide Diffs/Commits/WorktreeChanges.
crates/but-api/src/comments.rs Declares comments_list provides Comments.
crates/but-api/src/branch.rs Declares branch endpoints provide Branches.
crates/but-api/src/bitbucket.rs Declares Bitbucket account mutations invalidate ForgeAccounts/ForgeLogin.
crates/but-api-macros/tests/tests/ui/fail/napi_provides_duplicated.stderr Adds UI-test expected output for duplicate provides detection.
crates/but-api-macros/tests/tests/ui/fail/napi_provides_duplicated.rs Adds UI test ensuring duplicate provides fails.
crates/but-api-macros/tests/tests/ui/fail/napi_provides_and_invalidates.stderr Adds UI-test expected output for provides+invalidates exclusivity.
crates/but-api-macros/tests/tests/ui/fail/napi_provides_and_invalidates.rs Adds UI test ensuring provides and invalidates together fails.
crates/but-api-macros/tests/tests/ui/fail/napi_list_attr_unsupported.stderr Updates macro parse error message to mention new supported keys.
crates/but-api-macros/tests/tests/ui/fail/base_provides_requires_napi.stderr Adds UI-test expected output for requiring napi with tag lists.
crates/but-api-macros/tests/tests/ui/fail/base_provides_requires_napi.rs Adds UI test ensuring provides without napi fails.
crates/but-api-macros/tests/tests/ui/fail/base_invalid_attr_key.stderr Updates macro parse error message to mention new supported keys.
crates/but-api-macros/tests/src/lib.rs Adds a minimal tags::CacheTag for macro UI tests to compile tag checks.
crates/but-api-macros/src/lib.rs Implements provides/invalidates options, validation, and inventory emission.
apps/lite/ui/src/watcher.ts Removes the old hand-maintained watcher invalidation table/dispatch.
apps/lite/ui/src/routes/project/$id/workspace/Settings/github-oauth.ts Switches explicit query invalidation to tag-based invalidation (ForgeAccounts).
apps/lite/ui/src/routes/project/$id/workspace/PullRequestComments.tsx Updates comment reaction hooks to pass reviewId separately from mutation payload.
apps/lite/ui/src/routes/project/$id/workspace/CommitForm.tsx Updates “amend pending” detection to use mutationFn identity (mutationKey removed).
apps/lite/ui/src/routes/project/$id/route.tsx Routes watcher subscription events through the new handleProjectEvent.
apps/lite/ui/src/project-events.ts Adds event-to-tag-to-query derived invalidation logic for project subscriptions.
apps/lite/ui/src/project-events.test.ts Adds tests asserting event-driven refresh behavior derived from declared tags.
apps/lite/ui/src/operations/operation.ts Aligns dryRun query key typing with new query-key registration.
apps/lite/ui/src/main.tsx Centralizes mutation invalidation + error toasts via MutationCache + declared endpoints.
apps/lite/ui/src/api/tags.ts Implements tag-to-query mapping + helpers to invalidate tags and declared endpoint invalidations.
apps/lite/ui/src/api/tags.test.ts Adds tests for tag invalidation scoping and for declared mutation tags mapping to providers.
apps/lite/ui/src/api/queries.ts Derives ProjectQueryKey from generated apiProvides and renames keys to endpoint names.
apps/lite/ui/src/api/mutations.ts Removes per-mutation invalidation lists and switches to meta-driven failure toasts + centralized invalidation.
apps/lite/electron/src/main.ts Updates type narrowing helper to use the renamed endpoint key type.
apps/lite/electron/src/ipc.ts Derives exposed IPC endpoints from generated apiParamNames instead of a hand list.
Suppressed comments (2)

apps/lite/ui/src/api/mutations.ts:368

  • Same ordering issue as above: invalidating before restoring the rollback snapshots can kick off refetches while the cache is still in the optimistic (incorrect) state. Apply the snapshot rollback first, then invalidate to refetch.
			void ctx.client.invalidateQueries({ queryKey: reactionsKey });
			void ctx.client.invalidateQueries({ queryKey: commentsKey });
			if (prev?.prevReactions) ctx.client.setQueryData(reactionsKey, prev.prevReactions);
			if (prev?.prevComments) ctx.client.setQueryData(commentsKey, prev.prevComments);

apps/lite/ui/src/api/mutations.ts:466

  • In the rollback path you invalidate getReview before restoring prevSingle. That can start a refetch while the single-review cache is still in the optimistic state, and then the subsequent setQueryData overwrites the cache again. Restore prevSingle first, then invalidate the relevant query roots.
			void ctx.client.invalidateQueries({ queryKey: ["listReviews", input.projectId] });
			void ctx.client.invalidateQueries({ queryKey: ["getReview", input.projectId] });
			if (prev?.prevSingle) {

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/lite/ui/src/api/mutations.ts Outdated
Sam's review argued the apiMutation wrapper was needless indirection.
Following that through: the manual mutation key it replaced is also a
hand-written copy of a fact every mutation already carries — its
mutationFn. The mutation cache now resolves the endpoint by function
identity (a lazy map over window.lite) and applies the endpoint's
declared invalidations. There is no second declaration to forget, so
the forget hole is closed by construction rather than by a test. This
fixes removeCommentReaction, whose declared invalidations never ran:
it was written keyless. The four comment hooks that wrapped their
endpoint to strip a cache-keying reviewId take it as a hook argument
instead, so every mutationFn is the endpoint function itself. The last
pending-state lookup filters by the same function identity, so no
mutation keys remain at all.

The same move, three more times:

- A mutation's failure toast is declared as meta.failureTitle and shown
  by the mutation cache, which also logs every error once. Hooks write
  onError only for their own work — rollbacks and dynamic wording —
  which dissolves two dozen identical toast handlers.
- projectQueryKeys was character-for-character the keys of the
  generated apiProvides; derive the type and list from it, so a query
  name the backend doesn't declare is a type error and there is no
  list to keep in step.
- exposedEndpoints looked like an allowlist but wasn't one: the 15
  endpoints it left out are merely unused, not more sensitive than
  what it let through. Derive the exposed set from apiParamNames; the
  sender-frame validation in main stays the security boundary.
Copilot AI review requested due to automatic review settings August 12, 2026 16:54
@mtsgrd
mtsgrd force-pushed the watcher-staleness-declared-in-rust branch from d111bcb to b9a6fe7 Compare August 12, 2026 16:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 47 out of 51 changed files in this pull request and generated no new comments.

@mtsgrd
mtsgrd merged commit cc920e9 into master Aug 12, 2026
73 of 74 checks passed
@mtsgrd
mtsgrd deleted the watcher-staleness-declared-in-rust branch August 12, 2026 17:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

rust Pull requests that update Rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants