Declare cache effects on the API and dispatch them in lite - #15272
Conversation
d2a4422 to
92f6ae9
Compare
|
this |
06e60c5 to
f973827
Compare
|
@samhh here's an alternative. Wdyt? |
f973827 to
dd9bc8f
Compare
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. |
dd9bc8f to
3ffda51
Compare
|
@samhh took your point one step further in the second commit (aae9695). The manual 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 The same commit applies the move three more times: failure toasts are declared as |
3ffda51 to
bf72b4f
Compare
bf72b4f to
37b883c
Compare
d28e0a5 to
b5487da
Compare
4d1a3a4 to
aae9695
Compare
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.
aae9695 to
a420c53
Compare
| * 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]; |
There was a problem hiding this comment.
This may require a notion of a "local" mutation key in the future, like queries.
There was a problem hiding this comment.
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. | ||
| */ |
There was a problem hiding this comment.
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.)
a420c53 to
c42bb69
Compare
c42bb69 to
f3c13fb
Compare
f3c13fb to
d111bcb
Compare
|
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. |
There was a problem hiding this comment.
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
CacheTagvocabulary +provides/invalidatesdeclarations on#[but_api]endpoints, with macro validation and UI tests for invalid declarations. - Generate and export
cache-tagsmaps inbut-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 sharedMutationCache+ declaredapiInvalidates.
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
getReviewbefore restoringprevSingle. That can start a refetch while the single-review cache is still in the optimistic state, and then the subsequentsetQueryDataoverwrites the cache again. RestoreprevSinglefirst, 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.
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.
d111bcb to
b9a6fe7
Compare
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
CacheTagnamesone kind of cached state —
Reviews,Branches,WorktreeChanges— andthree declarations say everything that happens to it:
The SDK exports all three tables as
cache-tags, and lite derives everyrefresh 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
observes the repository, and the event carries the invalidation. Forge, app,
and config writes declare, because nothing watches those.
does not exist is a compile error, checked against the enum.
[]: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 —
worktreeChangespushes 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:
previews, which are no longer refreshed by repository events (see below).
delta:
publishReviewnow also refreshes the single-review cache, becausethe 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
reviewsbecomeslistReviews, and so on), so tag-to-query derivation is a lookup.projectQueryKeysstays hand-written on purpose: it is what lite caches,which the backend does not know.
memoized under a key carrying the operation and changes it measured, and
nothing refreshes it in place.
dryRunis client state beside the drafts.watcher.tsbecomesproject-events.ts: from lite's side the file watchingis a backend detail; what the renderer has is a per-project subscription.
branchListvslistReviews); renaming them touches every consumer, so it is left as afollow-up.