Queries & invalidation
Use a query when a component needs server data that should refresh after a mutation. You write the loader once. Kovo reads the Drizzle tables it uses, maps those tables to domains, and refreshes the components that depend on the query after a matching write commits.
Add a query#
Load through context.db. That handle is the framework-managed read handle for loaders.
// Source: examples/commerce/src/queries.ts
import { publicAccess, query, s } from '@kovojs/server';
const cartSummaryDefinition = {
access: publicAccess('cart badge is visible to anonymous shoppers'),
output: s.object({ count: s.number() }),
load: async (_input, context): Promise<{ count: number }> => {
const rows = (await context?.db.select({ quantity: cartItems.quantity }).from(cartItems)) ?? [];
return { count: rows.reduce((total, row) => total + row.quantity, 0) };
},
};
export const cartSummary = query(cartSummaryDefinition);Every request-reachable query needs an access decision. A guard counts. A public query uses
publicAccess('reason') so the public set is visible in kovo explain access.
Render it#
Bind the query from the component that needs it:
import { component } from '@kovojs/core';
export const CartBadge = component({
queries: { cart: cartSummary },
render: ({ cart }: { cart: { count: number } }) => <span>Cart: {cart.count}</span>,
});The rendered page carries the query value and the dependency stamp:
<span kovo-deps="cartSummary">Cart: <span data-bind="cart.count">2</span></span>
<script type="application/json" kovo-query="cartSummary">
{ "count": 2 }
</script>The stamp is what lets a mutation response target the right fragments without a client cache.
Run it#
Load the page, then use View Source instead of the Elements panel. You should see both the stamped HTML and the serialized query payload the server sent for first paint:
<span kovo-deps="cartSummary">Cart: <span data-bind="cart.count">2</span></span>
<script type="application/json" kovo-query="cartSummary">
{ "count": 2 }
</script>That pairing is the proof moment: the visible text and the hydration frame came from the same query.
Let writes refresh it#
On the Drizzle path, invalidation comes from the SQL that actually runs:
// Source: examples/commerce/src/domain.ts
const commitAddToCartRows = async (_db: unknown, _input: unknown) => {};
export const addToCart = mutation({
access: publicAccess('demo cart mutation'),
csrf: cartCsrf,
input: s.object({ productId: s.string(), quantity: s.number().int().min(1) }),
registry: { touches: [cart, product] },
async handler(input, request) {
await commitAddToCartRows(request.db, input);
return { ok: true };
},
});The write still goes through a named helper. kovo check reads those helper writes, maps the tables
through the schema's kovo(() => ({ domain })) annotations, intersects them with visible query read
sets, and reruns the stale queries after the transaction commits.
Declare opaque writes#
If the analyzer cannot see the tables, declare the mutation registry facts explicitly:
// Source: examples/commerce/src/domain.ts
const mergeCartRows = async (_db: unknown, _cartId: string) => {};
export const mergeCart = mutation({
access: publicAccess('demo cart merge mutation'),
csrf: cartCsrf,
input: s.object({ cartId: s.string() }),
registry: {
tables: ['cart_items'],
touches: [cart],
},
async handler(input, request) {
await mergeCartRows(request.db, input.cartId);
return { ok: true };
},
});tables is the helper's raw-SQL table allowlist. touches is the domain set to invalidate if the
write is opaque.
Check the graph#
Run the graph check before you ship:
kovo checkThat is the command that reports the data-plane graph verdict for opaque reads, exempt-table reads,
and opaque writes. Keep kovo check in CI for type/lint wiring and current-source proof when you want
the graph result itself.
Handle failure#
There are two common failure classes on this surface:
- A loader that can fail at runtime should render a deliberate error state in the component that owns it, not an empty value that looks like success.
- A loader whose dataflow facts are missing fails under
kovo checkbefore deploy.
Those diagnostics are the ones you fix first:
ERROR KV410 productQuery Opaque projection requires an output schema.
ERROR KV411 auditLogQuery Query reads from exempt table "audit_log".Add output when the query shape is not inferable, and add reads only when the SQL path is
opaque enough that the analyzer cannot see the read set directly.
Next#
- Pagination - keep one held list instance and append only the new rows.
- Caching — make a public typed read cacheable and verify the headers.
- Data layer — annotate tables and understand Drizzle extraction.
- Mutations & forms — post forms and return fresh fragments.
Spec & diagnostics
Queries: SPEC §10.2 and §9.4. Access decisions: SPEC §10.2 default-deny access decisions and KV436. Opaque reads: KV410. Exempt table reads: KV411. Opaque writes: SPEC §10.3 and KV406. Direct mutation-handler writes are tracked by KV330: "Direct db access in a mutation handler; route through domain."
API reference: @kovojs/core, @kovojs/server.