Problem
We currently manually document/configure which commands invalidate which queries via invalidatesQueries, InvalidationSet.add, or client-side queryInvalidation callbacks.
That works, but it is easy to forget, and the mapping often duplicates information the server already knows:
- queries read from repositories
- commands mutate repositories
- if a command mutates a repository that a cached query read from, that query should usually be invalidated
A quick look at ~/pj/macs/scanner shows this correlation clearly. For example, several resource files manually invalidate query classes while the corresponding controllers/services read or mutate the same repos. The interesting dependency information exists, but it is currently spread across resource declarations, controllers, services, and helper functions.
Goal
Make cache invalidation simpler, easier to maintain, and harder to forget.
Prefer deriving invalidation from actual data dependencies over manually listing every affected query.
Proposed approach
Add request-scoped dependency recording at the repository/store boundary.
For queries:
- record repo reads during query handler execution
- include the read dependency set in the RPC response metadata
- client stores that set on the TanStack query metadata
For commands:
- record repo writes during command handler execution
- include the write dependency set in command response metadata
- client invalidates cached queries whose recorded read deps intersect the command write deps
Conceptually:
query reads repo A
command writes repo A
=> invalidate query
Start at repository granularity. That may refetch a bit more than necessary, but it should be correct and much harder to forget. Entity-level deps can come later if needed.
Why runtime recording instead of resource subscriptions?
Configuring resources to subscribe to repositories is better than today, but still manual and easy to drift.
Static derivation from resource/controller source is useful as an audit tool, but likely too brittle as the source of truth because dependencies often flow through:
- helper services
- domain services
- conditional branches
- projections and raw queries
CurrentSettings, CurrentUser, time, environment, feature flags
- background queues and async continuations
Runtime recording at the repository boundary sees the actual dependency path taken by the handler.
Existing pieces to build on
- Request classes already support manual
invalidatesQueries callbacks.
- Vue mutation invalidation already merges client-declared invalidation with server-driven invalidation keys.
- Server-driven invalidation already exists via
Invalidation.InvalidationSet.
- Repositories already know their model names through
Model.makeRepo / RepositoryRegistry.
- Store implementations already classify operations such as
all, find, filter, queryRaw, set, batchSet, bulkSet, and batchRemove.
Suggested design
Introduce a request-scoped dependency recorder service, roughly:
type DataDependency =
| { readonly type: "repo"; readonly name: string }
| { readonly type: "signal"; readonly name: string }
interface DependencyRecorder {
readonly read: (dep: DataDependency) => Effect.Effect<void>
readonly write: (dep: DataDependency) => Effect.Effect<void>
readonly getReads: Effect.Effect<ReadonlySet<DataDependency>>
readonly getWrites: Effect.Effect<ReadonlySet<DataDependency>>
}
Repository/store operations record dependencies automatically:
- read ops:
all, find, filter, query, queryRaw
- write ops:
save, set, batchSet, bulkSet, batchRemove, queryAndSave*, byIdAndSave*
RPC metadata carries:
- query responses:
readDependencies
- command responses:
writeDependencies
Client invalidation then becomes predicate-based:
invalidate cached query if intersects(query.meta.readDependencies, command.writeDependencies)
Manual invalidation remains available for cases that are not repo-backed or need domain-specific behavior.
Escape hatches / explicit deps
Keep explicit APIs for:
- external side effects
- background queues
- non-repository signals like settings/current user/time
- intentionally broad invalidation
- intentionally narrow invalidation
- command branches where invalidation depends on success payload or input
Potential API shape:
yield* Dependencies.read({ type: "signal", name: "CurrentSettings" })
yield* Dependencies.write({ type: "signal", name: "CurrentSettings" })
or integrate with existing InvalidationSet for compatibility.
Migration path
- Add dependency recorder service with no-op default.
- Instrument repository/store read/write operations.
- Include read deps in query response metadata.
- Include write deps in command response metadata.
- Store query read deps in TanStack query metadata.
- Invalidate by dependency intersection after mutations.
- Keep existing
invalidatesQueries / InvalidationSet behavior and merge it with derived invalidation.
- Use the scanner repo as an audit/migration aid to compare manual invalidation against derived repo deps.
Open questions
- Should dependency metadata live alongside existing invalidation metadata, or become a separate RPC metadata channel?
- What is the stable dependency identity: repository model name, service tag, namespace, or a dedicated
dependencyId?
- Do we need entity-level dependencies in v1, or is repo-level invalidation enough?
- How should stream queries and stream commands drain dependency metadata?
- How do we represent writes performed in background fibers after the command has returned?
- Should
queryRaw conservatively mark the owning repo, or require explicit extra deps?
Problem
We currently manually document/configure which commands invalidate which queries via
invalidatesQueries,InvalidationSet.add, or client-sidequeryInvalidationcallbacks.That works, but it is easy to forget, and the mapping often duplicates information the server already knows:
A quick look at
~/pj/macs/scannershows this correlation clearly. For example, several resource files manually invalidate query classes while the corresponding controllers/services read or mutate the same repos. The interesting dependency information exists, but it is currently spread across resource declarations, controllers, services, and helper functions.Goal
Make cache invalidation simpler, easier to maintain, and harder to forget.
Prefer deriving invalidation from actual data dependencies over manually listing every affected query.
Proposed approach
Add request-scoped dependency recording at the repository/store boundary.
For queries:
For commands:
Conceptually:
Start at repository granularity. That may refetch a bit more than necessary, but it should be correct and much harder to forget. Entity-level deps can come later if needed.
Why runtime recording instead of resource subscriptions?
Configuring resources to subscribe to repositories is better than today, but still manual and easy to drift.
Static derivation from resource/controller source is useful as an audit tool, but likely too brittle as the source of truth because dependencies often flow through:
CurrentSettings,CurrentUser, time, environment, feature flagsRuntime recording at the repository boundary sees the actual dependency path taken by the handler.
Existing pieces to build on
invalidatesQueriescallbacks.Invalidation.InvalidationSet.Model.makeRepo/RepositoryRegistry.all,find,filter,queryRaw,set,batchSet,bulkSet, andbatchRemove.Suggested design
Introduce a request-scoped dependency recorder service, roughly:
Repository/store operations record dependencies automatically:
all,find,filter,query,queryRawsave,set,batchSet,bulkSet,batchRemove,queryAndSave*,byIdAndSave*RPC metadata carries:
readDependencieswriteDependenciesClient invalidation then becomes predicate-based:
Manual invalidation remains available for cases that are not repo-backed or need domain-specific behavior.
Escape hatches / explicit deps
Keep explicit APIs for:
Potential API shape:
or integrate with existing
InvalidationSetfor compatibility.Migration path
invalidatesQueries/InvalidationSetbehavior and merge it with derived invalidation.Open questions
dependencyId?queryRawconservatively mark the owning repo, or require explicit extra deps?