Menu

Tutorial

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.

ts
// 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:

ts
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):

ts
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:

ts
// 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:

  • cart is hand-written because the prediction is closed over the input: count goes up by quantity. (v2 derives transforms like this from the write's dataflow, and the hand-written form shares that IR, so adoption is incremental.)
  • products is '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:

ts
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.

ts
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:

ts
// 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) };
}
ts
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.