Invalidation & optimistic updates
Chapter 4's mutation committed writes, but the response carried no fresh query data — you never
said what cart/add touches. In this chapter you close that loop: declare the touch set, derive
which queries re-run, and make the cart badge tick instantly with an exhaustiveness-checked,
property-tested optimistic transform. Step state: site/tutorial/steps/05-optimistic/.
Declare the touches, derive the invalidation#
There is no invalidate() call in the happy path. A write's touch set — which domains it writes,
keyed how — meets each query's read set from chapter 3, and the intersection is the invalidation
graph. The tutorial's plain store has no ASTs, so you declare the touches by hand — the floor every
adapter shares:
The production path. With
@kovojs/drizzle, touch sites are extracted from the write ASTs and committed as a reviewable graph instead of declared by hand. See the data-layer guide.
// SPEC.md section 11.1: with the blessed @kovojs/drizzle adapter these touch
// sites are extracted from the write ASTs and committed as a reviewable
// graph. The tutorial's plain in-memory db has no ASTs to analyze, so it
// declares the touches — the SPEC.md section 14 v1 floor — and chapter 7
// runtime-verifies the declaration against observed writes.
export const addToCartTouches = [
{
domain: cart.key,
keys: null,
site: 'site/tutorial/steps/05-optimistic/src/app.tsx:addToCart',
via: 'cart_items',
},
{
domain: product.key,
keys: 'arg:productId',
predicate: 'eq',
site: 'site/tutorial/steps/05-optimistic/src/app.tsx:addToCart',
via: 'products',
},
] as const;The mutation registers its touches and the queries it may affect. The loaders now read the
per-request database, so post-commit reruns render what the transaction just committed — the
lifecycle orders COMMIT before query re-runs so responses can never show pre-commit data:
export const cartQuery = app.query({
access: app.publicAccess('tutorial single-cart storefront read'),
load: (_input, context) => loadCart(tutorialShopDb(context.request).query.snapshot()),
reads: [cart],
});
export const productsQuery = app.query({
access: app.publicAccess('tutorial public product catalog'),
load: (_input, context) => loadProducts(tutorialShopDb(context.request).query.snapshot()),
reads: [product],
});Now the enhanced response carries server truth for every invalidated query alongside the
fragments — plus Kovo-Changes, the sanitized write summary (domains and keys, never input
values):
it('derives the queries to re-run and ships server truth on the wire', async () => {
const request = shopRequest();
const response = await submitAddToCart(
formInput(request, { productId: 'p1', quantity: '2' }),
request,
{
'Kovo-Fragment': 'true',
'Kovo-Live-Targets':
'cart-badge#components/cart-badge/cart-badge:{}; product-list#components/product-list/product-list:{}',
'Kovo-Targets': `cart-badge=${cartQuery.key}; product-list=${productsQuery.key}`,
},
);
expect(response.status).toBe(200);
// Server truth for every invalidated query, as readable chunks: the
// loader replaces each value and runs its update plan (SPEC.md §9.1).
expect(response.body).toContain(`<kovo-query name="${cartQuery.key}">{"count":2}</kovo-query>`);
expect(response.body).toContain(`<kovo-query name="${productsQuery.key}">`);
expect(response.body).toContain('<kovo-fragment target="cart-badge">');
expect(response.body).toContain('<kovo-fragment target="product-list">');
// The sanitized write summary: domains and keys, never input values.
expect(response.headers['Kovo-Changes']).toBe(
`[{"domain":"${cart.key}"},{"domain":"${product.key}"}]`,
);
});The loader's side is mechanical: each <kovo-query> replaces the shared value and runs that
query's update plan — the bindings and stamps chapter 3 derived — across every dependent island.
Key optimism to queries, not islands#
A one-round-trip wait is fine for the product list, but the cart badge should tick instantly. You declare optimism per (mutation × invalidated query) — never per island — so every island consuming the query updates from one transform, including islands written months from now:
// SPEC.md section 10.4: optimism is keyed to queries, never islands. The
// cart count is predictable from the input alone — a pure value transform.
// The product list depends on server truth (stock math lives in the handler),
// so it explicitly accepts the 1-RTT fragment: 'await-fragment' is a recorded
// decision, not an omission. Query handles bind both decisions to their
// exact result types; kovo check proves coverage against invalidation.
queue: 'cart',
optimistic: [
cartQuery.optimistic(addToCartInput, predictCart),
productsQuery.optimistic('await-fragment'),
],Two deliberate choices are visible here:
cartis hand-written because the prediction is closed over the input: count goes up byquantity. The same coverage slot can also be satisfied by a compiler-derived transform when the write dataflow is provable from the mutation input and query shape.productsis'await-fragment'— a recorded decision, not an omission. The stock math lives in the handler; predicting it client-side would mean duplicating server logic, so you accept the round-trip latency in writing.
Exhaustiveness is the point. App source binds each decision through the exact query handle:
cartQuery.optimistic(addToCartInput, predictCart) carries the cart result and mutation-input
types, while productsQuery.optimistic('await-fragment') records the deliberate wait. The
compiler derives the invalidation registry from those source identities; the app does not augment
generated registry interfaces.
Delete the productsQuery binding and kovo check optimistic reports the uncovered invalidated
query — a forgotten optimistic update is never a silently stale badge.
it('predicts the cart count with the hand-written transform', () => {
expect(predictCart({ count: 1 }, { productId: 'p1', quantity: 2 })).toEqual({ count: 3 });
});Property-test the prediction#
A wrong prediction is worse than none: the runtime reconciles server truth over it — a right guess is a near-no-op morph, a wrong guess a visible correction. So the step proves the transform commutes with the real handler over generated states: predicting then observing equals applying then shaping:
// The real write effect, restated over plain state: what the handler commits.
function applyAddToCart(state: ShopPropertyState, input: AddToCartInput): ShopPropertyState {
const found = state.products[input.productId];
if (!found || found.stock < input.quantity) {
throw new Error(`invalid property case for ${input.productId}`);
}
return {
cartItems: [...state.cartItems, { productId: input.productId, qty: input.quantity }],
products: {
...state.products,
[input.productId]: { stock: found.stock - input.quantity },
},
};
}
// What the cart query ships to the client for a given state.
function shapeCartQuery(state: ShopPropertyState): { count: number } {
return { count: state.cartItems.reduce((total, item) => total + item.qty, 0) };
}it('proves prediction ⊆ eventual truth over generated states', () => {
expect(
propertyTest<ShopPropertyState, AddToCartInput, { count: number }>({
apply(state, input) {
return applyAddToCart(state, input);
},
cases: propertyCases(),
predict(state, input) {
return predictCart(shapeCartQuery(state), input);
},
shape(state) {
return shapeCartQuery(state);
},
}),
).toEqual({ cases: 18 });
});Eighteen generated cases, zero browsers. The optimistic updates guide covers queues, rebase-on-arrival, and multi-tab behavior.
The loop is closed: declared touches, derived invalidation, a proven instant badge. Next, first render learns the same trick — streaming expensive fragments without blocking the shell.
Spec & diagnostics
No invalidate() in the happy path; transaction lifecycle orders COMMIT before re-runs: SPEC §10.3. Touch extraction committed as a reviewable graph: SPEC §11.1. Declared touches as the
adapter floor: SPEC §14. Kovo-Changes sanitized write summary: SPEC §9.1. Derived update plan
across islands: SPEC §4.8. Optimism keyed per (mutation × query): SPEC §10.4. Derived
transforms and explicit punts: SPEC §10.5. Exhaustiveness requirement: SPEC §10.6; a missing
optimistic binding is KV310 in kovo check; query-handle typing keeps each authored predictor
aligned with its query result and exact mutation schema. Generated registries: SPEC §6.1.
Transform/handler commutation proven over generated states: SPEC §11.4.