Menu

Tutorial

View as Markdown

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();
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:

ts
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, 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({
  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:

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 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', 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:

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