Queries & data binding
So far the catalog is hardcoded. In this chapter you add real data: a product list and a cart
badge. You declare two queries, and every downstream surface — dependency stamps, binding paths,
the JSON the page ships — comes from them. Step state: site/tutorial/steps/03-queries/.
Declare domains#
Domains are named groups of data that writes touch and reads depend on. They are the currency the invalidation graph trades in, so they come first:
export const cart = domain();
export const product = domain();The production path. The tutorial uses a plain in-memory store so every moving part stays visible. With
@kovojs/drizzle, this is derived from real tables instead of declared by hand — here, domains come from schema annotations and read sets are extracted from the query ASTs (the JOIN is the declaration). The data-layer guide is the home for that story.
The tutorial keeps a plain store:
export type ShopProduct = {
id: string;
name: string;
stock: number;
unitPrice: number;
};
export type CartItem = {
productId: string;
qty: number;
unitPrice: number;
};
export interface ShopDb {
cartItems: CartItem[];
products: Map<string, ShopProduct>;
}
export interface ShopRequest {
db: ShopDb;
}
export function createShopDb(): ShopDb {
return {
cartItems: [],
products: new Map([
['p1', { id: 'p1', name: 'Pour-over kettle', stock: 5, unitPrice: 1499 }],
['p2', { id: 'p2', name: 'Ceramic dripper', stock: 2, unitPrice: 2599 }],
['p3', { id: 'p3', name: 'Paper filters', stock: 8, unitPrice: 399 }],
]),
};
}Declare the reads once#
A query couples an explicit access decision, a loader, and the domains it reads. This tutorial's
catalog and single-cart fixture are intentionally public; publicAccess(...) records that review
decision instead of relying on an implicit allow. The read set is the entire invalidation
registration: no query subscribes to mutations, and no mutation enumerates queries.
Where the primitives live.
query,route,mutation,s,domain,guards, andsessionimport from@kovojs/server;componentandformimport from@kovojs/core. Server facts in@kovojs/server, the component/form model in@kovojs/core— that's the whole split.
export function loadCart(db: ShopDb): CartResult {
return { count: db.cartItems.reduce((total, item) => total + item.qty, 0) };
}
export function loadProducts(db: ShopDb): ProductsResult {
return {
items: [...db.products.values()].sort((left, right) => left.id.localeCompare(right.id)),
};
}export const cartQuery = query({
access: publicAccess('tutorial single-cart storefront read'),
load: (_input: unknown, context?: ShopQueryLoadContext) => loadCart(dbFrom(context)),
reads: [cart],
});
export const productsQuery = query({
access: publicAccess('tutorial public product catalog'),
load: (_input: unknown, context?: ShopQueryLoadContext) => loadProducts(dbFrom(context)),
reads: [product],
});A query's relationship to future writes is fixed by what it reads, not by anything you remember to wire up, so you can't forget a dependency here. Chapter 5 cashes this in.
Bind queries from components#
The cart badge consumes the cart query. Your TSX says only that:
export const CartBadge = component({
queries: { cart: cartQuery },
render: ({ cart }: { cart: CartResult }) => (
<cart-badge>
Cart: <span>{cart.count}</span>
</cart-badge>
),
});The product list is keyed. You author ordinary TSX key identity, and the compiler lowers it to
kovo-key in the emitted IR because item identity is shared by the morph layer (the runtime's DOM
patcher), template stamps, inferred fragment target suffixes, and optimistic reordering:
export const ProductList = component({
queries: { products: productsQuery },
render: ({ products }: { products: ProductsResult }) => (
<ul style={productListStyles.list}>
{products.items.map((item) => (
<li key={item.id}>
{item.name} — {formatPrice(item.unitPrice)} ({item.stock} in stock)
</li>
))}
</ul>
),
});The compiler derives the runtime wiring from these declarations: queries: becomes an kovo-deps
stamp on each island, and {cart.count} becomes a typed data-bind path. Binding paths
type-check against the query's inferred shape — rename count and every referencing template
goes red; bind through a nullable segment without ?. and you get a compile error. The step's
test pins all of it from the rendered page:
it('serves compiler-derived dependency and binding stamps', async () => {
const html = await renderShopRoute();
// SPEC §9.1/§10.2: query dependencies are semantic { name, key? } facts; HTML attribute order
// is not part of the wire contract. These unparameterized queries carry exact unkeyed names.
const cartAttrs = requiredOpeningTagAttributes(html, 'cart-badge');
expect(
decodeFrameworkQueryDependencyToken(requiredHtmlAttribute(cartAttrs, 'kovo-deps')),
).toEqual({ name: cartQuery.key });
expect(requiredHtmlAttribute(cartAttrs, 'kovo-fragment-target')).toBe('cart-badge');
expect(requiredHtmlAttribute(cartAttrs, 'kovo-live-component')).toBe(
'components/cart-badge/cart-badge',
);
const productListAttrs = requiredOpeningTagAttributes(html, 'ul');
expect(requiredHtmlAttribute(productListAttrs, 'kovo-c')).toBe('product-list');
expect(
decodeFrameworkQueryDependencyToken(requiredHtmlAttribute(productListAttrs, 'kovo-deps')),
).toEqual({ name: productsQuery.key });
// {cart.count} became a typed data-bind path the loader can re-run.
const countAttrs = requiredOpeningTagAttributes(html, 'span');
expect(requiredHtmlAttribute(countAttrs, 'data-bind')).toBe('cart.count');
expect(html).toMatch(/<span\b[^>]*>0<\/span>/u);
});Ship data once, as shared truth#
Query values are server-owned and shared: the page ships each value exactly once as a JSON script, and every island that depends on it reads from that single copy. There's no per-component fetch and no client cache with a lifecycle. When a value changes, the loader replaces it and walks the self-describing bindings under each dependent island:
export const homeRoute = route('/', {
access: publicAccess('tutorial storefront browsing'),
page(_input, _request: ShopRequest) {
return (
<html>
<head>
<title>Kovo Shop</title>
</head>
<body>
<main>
<h1>Kovo Shop</h1>
<CartBadge />
<ProductList />
</main>
</body>
</html>
);
},
});it('renders loaded query values through the declared components', async () => {
const db = createShopDb();
db.cartItems.push({ productId: 'p1', qty: 2, unitPrice: 1499 });
const html = await renderShopRoute(db);
expect(html).toContain('<span data-bind="cart.count">2</span>');
expect(html).toContain('Pour-over kettle — $14.99 (5 in stock)');
});Note what the page does not contain: no serialized component tree, no hydration script, no framework boot. The data is inspectable JSON, the dependencies are attributes, and the update plan is the DOM. The queries guide covers parameterized queries, instance keys, and the typed read endpoint when you need them.
Live data now flows, and every data-to-DOM dependency is an attribute you can read. Next: writes — and the form-shaped contract they ride in on.
Spec & diagnostics
Domains as invalidation currency: SPEC §10.1. Derived downstream surfaces and read-set
extraction: SPEC §10.2. Read set as the entire registration: Constitution #2 (no API requires
global knowledge at a local site). Derived kovo-deps/data-bind stamps and binding type-check:
SPEC §4.8; binding through a nullable segment without ?. is KV227. Authored key lowering to
runtime kovo-key: SPEC §4.8, §13.2. Explicit default-deny access decisions: SPEC §10.2,
KV436. Data shipped once as shared truth: SPEC §4.2.