-
-
Notifications
You must be signed in to change notification settings - Fork 8
repository derived query invalidation
Historically, resource clients relied on manual invalidation lists:
const options = {
queryInvalidation: (queryKey) => [
{ filters: { queryKey } },
SomeOtherQuery
]
}That works, but it is easy to forget a query when a command starts touching another repository. The derived invalidation path records what data a query read and what data a command wrote, then invalidates cached queries whose recorded reads intersect the command writes.
Manual invalidation still exists. Derived invalidation is an additional safety net for data dependencies that can be observed at runtime.
The unit of dependency is a DataDependency:
type DataDependency =
| { readonly type: "repo"; readonly name: string }
| { readonly type: "signal"; readonly name: string }-
repo(name)represents a repository namespace, usually the name passed tomakeRepo(name, ...). -
signal(name)is available for non-repository data that still needs a stable invalidation topic.
Each request has a DataDependencyRecorder in context. Code can record:
yield * DataDependencies.read(DataDependencies.repo("PickList"))
yield * DataDependencies.write(DataDependencies.repo("PickList"))Repository operations do this automatically, so most resource handlers do not
need to call DataDependencies.read or DataDependencies.write directly.
- A query runs through the Vue query helper or RPC client.
- The request receives a fresh
DataDependencyRecorder. - Repository reads record
repo(<repository name>)as a read dependency. - The query result is returned with dependency metadata.
- The client stores the query's read dependencies next to the TanStack Query cache entry.
- A command runs through the mutation helper or RPC client.
- Repository writes record
repo(<repository name>)as a write dependency. - The command response metadata carries the write dependencies back to the client.
- Vue mutation invalidation scans the query cache and invalidates every query whose recorded reads intersect the command writes.
In short:
query reads RepoA -> query cache remembers RepoA
command writes RepoA -> cached RepoA readers are invalidated
command writes RepoB -> cached RepoA readers are left alonemakeRepo records dependencies at the repository boundary:
| Repository operation | Dependency recorded |
|---|---|
all |
read |
find |
read |
query |
read |
queryRaw |
read |
mapped all
|
read |
mapped find
|
read |
saveAndPublish |
write |
removeAndPublish |
write |
removeById |
write |
mapped save
|
write |
The dependency name is the repository name:
const Products = makeRepo("Product", Product, {})Any query that reads Products records:
{ type: "repo", name: "Product" }Any command that writes Products records the same dependency as a write.
The RPC layer wraps payloads internally so dependency metadata can cross the network without changing handler APIs.
Commands still return their plain success value to callers, but the transport envelope includes:
{
invalidateQueries: InvalidationKey[],
dataDependencies: {
reads: DataDependency[],
writes: DataDependency[]
}
}Queries similarly return their plain payload to callers, while the RPC success schema carries:
{
payload: A,
metadata: {
dataDependencies: {
reads: DataDependency[],
writes: DataDependency[]
}
}
}The client unwraps these envelopes and forwards the dependency metadata into
the local DataDependencyRecorder. This makes dependency propagation work for
both direct RPC clients and the Vue query/mutation helpers.
Stream commands emit dependency metadata in the same metadata chunks already
used for server-driven invalidation keys. @effect-app/vue makeStreamMutation2
flushes write-deps once when the first write arrives (so a created job
refreshes GetActiveJob while the stream is still open) and again when the
stream settles. Server invalidation keys stay settlement-only — flushing them
per chunk refetched live list queries on every item.
makeQuery runs each query under a fresh dependency recorder. After the handler
returns, it stores the query's read dependencies next to matching TanStack Query
cache entries.
The implementation uses a WeakMap<Query, DataDependencies> rather than
query.meta. TanStack Vue Query clones and reapplies observer options during
fetching, so runtime-learned metadata written into query.meta can be
overwritten by the observer's original options. The WeakMap is keyed by the
actual cache entry, disappears when the query is garbage-collected, and does
not affect TanStack's public options.
makeMutation records command writes and combines three invalidation sources:
- client-side
queryInvalidationoptions, - server-provided invalidation keys,
- derived dependency invalidation.
All targets are grouped into predicate-based invalidateQueries calls so the
cache is not invalidated once per target when the options are equivalent.
Derived invalidation only sees what is recorded. Keep manual invalidation when:
- a command changes data outside a repository,
- a command affects an external service,
- a query reads data that is not represented by a repository operation,
- the relationship is semantic rather than data-access based,
- a command should invalidate a broader UI namespace than the actual writes.
Example:
const save = useMutation(SaveProduct, {
queryInvalidation: (queryKey) => [
{ filters: { queryKey } },
DashboardSummary
]
})The command will still derive repository-based invalidation. The manual
DashboardSummary entry is added on top.
Use signal(name) for data that has no repository boundary:
const ExchangeRates = DataDependencies.signal("ExchangeRates")
const getPrices = Effect.fnUntraced(function*() {
yield* DataDependencies.read(ExchangeRates)
return yield* fetchPrices
})
const refreshRates = Effect.fnUntraced(function*() {
yield* updateRates
yield* DataDependencies.write(ExchangeRates)
})Any cached query that read ExchangeRates will be invalidated after a command
writes ExchangeRates.
Prefer repository dependencies when data is stored in a repository. Use signals
for external APIs, computed projections, caches, feature flags, or other shared
state that does not naturally pass through makeRepo.
The important subscription is between a query cache entry and the dependencies
it read at runtime. In practice, resources do not need a separate static
"subscribe to repository" configuration if they actually access repositories
through makeRepo.
Static configuration can still be useful for virtual resources:
- A query composes several external sources.
- A command writes via a service that cannot record dependencies internally.
- A resource's dependency is known but not visible from its implementation.
In those cases, call DataDependencies.read(signalOrRepo) or
DataDependencies.write(signalOrRepo) in the handler or service method. That
keeps the dependency close to the real boundary instead of maintaining a
separate invalidation list beside the resource declaration.
Repository dependencies are intentionally repository-level, not row-level.
If a query reads one Product, and a command writes a different Product, the
query is invalidated because both depend on repo("Product"). This is less
precise than row-level invalidation, but it is:
- easy to derive reliably,
- stable across query shapes,
- hard to forget,
- compatible with existing manual invalidation for broader or special cases.
Row-level dependencies can be introduced later by adding another dependency
shape, for example { type: "repo-item", name, id }, but repository-level
tracking gives most of the maintainability win without making every query key
encode storage details.
-
packages/effect-app/src/DataDependencies.ts- dependency schemas,
- recorder service,
- helpers for
repo,signal,read,write, andintersects.
-
packages/effect-app/src/Model/Repository/internal/internal.ts- automatic repository read/write recording.
-
packages/effect-app/src/rpc/Invalidation.ts- metadata schemas for command/query/stream envelopes.
-
packages/infra/src/routing.ts- per-request recorder wiring on the server.
-
packages/effect-app/src/client/apiClientFactory.ts- client unwrapping and metadata forwarding.
-
packages/vue/src/query.ts- query read dependency capture.
-
packages/vue/src/dependencyMetadata.ts- WeakMap storage for TanStack Query cache entries.
-
packages/vue/src/mutate.ts- derived invalidation from command writes.
The main coverage is:
-
packages/infra/test/repository-ext.test.ts- repository operations record expected reads and writes.
-
packages/infra/test/rpc-e2e-invalidation.test.ts- dependency metadata survives the real HTTP/RPC client and server path.
-
packages/vue/test/dependencyInvalidation.test.ts- Vue queries that recorded reads are invalidated when a mutation writes an intersecting dependency.
Useful focused commands:
pnpm --filter @effect-app/infra test -- rpc-e2e-invalidation.test.ts repository-ext.test.ts --runInBand
pnpm --filter @effect-app/vue test -- dependencyInvalidation.test.ts --runInBand
pnpm check