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',
keys: null,
site: 'site/tutorial/steps/05-optimistic/src/app.ts:addToCart',
via: 'cart_items',
},
{
domain: 'product',
keys: 'arg:productId',
predicate: 'eq',
site: 'site/tutorial/steps/05-optimistic/src/app.ts: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 = query('cart', {
load: (_input: unknown, context?: QueryLoadContext<ShopRequest>) => loadCart(dbFrom(context)),
reads: [cart],
});
export const productsQuery = query('products', {
load: (_input: unknown, context?: QueryLoadContext<ShopRequest>) => loadProducts(dbFrom(context)),
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=cart; product-list=products',
},
);
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="cart">{"count":2}</kovo-query>');
expect(response.body).toContain('<kovo-query name="products">');
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"},{"domain":"product","keys":["p1"]}]',
);
});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 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. tsc requires an entry per invalidated query
// (section 10.6) — delete one and this satisfies clause turns red.
export const addToCartOptimistic = {
queue: 'cart',
transforms: {
cart(current, input) {
return {
count: (current?.count ?? 0) + input.quantity,
};
},
products: 'await-fragment',
},
} satisfies OptimisticFor<typeof addToCartForm>;Two deliberate choices are visible here:
cartis hand-written because the prediction is closed over the input: count goes up byquantity. (v2 derives transforms like this from the write's dataflow, and the hand-written form shares that IR, so adoption is incremental.)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. The step declares its invalidation sets in the registry interfaces — generated files in a real app, inline here so you can see the mechanism:
declare module '@kovojs/core' {
interface QueryRegistry {
cart: CartResult;
products: ProductsResult;
}
interface MutationRegistry {
'cart/add': typeof import('./app.js').addToCart;
}
interface InvalidationSets {
'cart/add': 'cart' | 'products';
}
}Because of that declaration, OptimisticFor<typeof addToCartForm> requires an entry per
invalidated query. Delete the products line and tsc goes red — a forgotten optimistic update
is a compile error, never a silently stale badge.
it('predicts the cart count with the hand-written transform', () => {
expect(addToCartOptimistic.queue).toBe('cart');
expect(
addToCartOptimistic.transforms.cart({ count: 1 }, { productId: 'p1', quantity: 2 }),
).toEqual({ count: 3 });
// Every invalidated query has an explicit status (SPEC.md §10.6).
expect(Object.keys(addToCartOptimistic.transforms).sort()).toEqual(['cart', 'products']);
expect(addToCartOptimistic.transforms.products).toBe('await-fragment');
});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 addToCartOptimistic.transforms.cart(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. Future-derived
transforms sharing the IR: SPEC §10.5. Exhaustiveness requirement: SPEC §10.6; a missing
optimistic entry is KV310 at the editor and in kovo check. Registries as generated files:
SPEC §6.1. Transform/handler commutation proven over generated states: SPEC §11.4.