Menu

Tutorial

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:

ts
export const cart = domain('cart');
export const product = domain('product');

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:

ts
export interface ShopProduct {
  id: string;
  name: string;
  stock: number;
  unitPrice: number;
}

export interface CartItem {
  productId: string;
  qty: number;
  unitPrice: number;
}

export interface ShopDb {
  cartItems: CartItem[];
  products: Map<string, ShopProduct>;
}

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 a key, a loader, and the domains it reads. That read set is the entire registration: no query subscribes to mutations, and no mutation enumerates queries.

Where the primitives live. query, route, mutation, s, domain, guards, and session import from @kovojs/server; component and form import from @kovojs/core. Server facts in @kovojs/server, the component/form model in @kovojs/core — that's the whole split.

ts
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)),
  };
}
ts
export const cartQuery = query('cart', {
  load: (_input: unknown) => loadCart(createShopDb()),
  reads: [cart],
});

export const productsQuery = query('products', {
  load: (_input: unknown) => loadProducts(createShopDb()),
  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:

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

tsx
export const ProductList = component({
  queries: { products: productsQuery },
  render: ({ products }: { products: ProductsResult }) => (
    <ul style={productListStyles.list}>
      {products.items.map((item) => (
        <li kovo-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:

ts
it('serves compiler-derived dependency and binding stamps', () => {
  const html = renderShopPage();

  // The queries declaration became kovo-deps plus inferred refresh target metadata.
  expect(html).toContain(
    '<cart-badge kovo-deps="cart" kovo-fragment-target="cart-badge" kovo-live-component="components/cart-badge/cart-badge">',
  );
  expect(html).toContain('kovo-c="product-list"');
  expect(html).toContain('kovo-deps="products"');

  // {cart.count} became a typed data-bind path the loader can re-run.
  expect(html).toContain('<span data-bind="cart.count">0</span>');
});

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:

ts
export function renderShopPage(db: ShopDb = createShopDb()): string {
  const cart = loadCart(db);
  const products = loadProducts(db);

  return `<!doctype html><html><head><title>Kovo Shop</title></head><body><main><h1>Kovo Shop</h1>${CartBadge.definition.render({ cart })}${ProductList.definition.render({ products })}</main></body></html>`;
}
ts
it('renders loaded query values through the declared components', () => {
  const db = createShopDb();
  db.cartItems.push({ productId: 'p1', qty: 2, unitPrice: 1499 });
  const html = renderShopPage(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. Data shipped once as shared truth: SPEC §4.2.