Menu
View as Markdown

Examples

CRM

A multi-page sales CRM — pipeline dashboard, contact book, and per-deal detail — over a real Drizzle/PGlite database. The source tabs show the derived + hand-written optimism mix that powers create/move/close-deal.

examples/crm/src/components/pipeline.tsxtsx
/** @jsxImportSource @kovojs/server */
import { component } from '@kovojs/core';
import { Button } from '@kovojs/ui/button';
import { Card } from '@kovojs/ui/card';
import * as style from '@kovojs/style';
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeaderCell,
  TableRow,
} from '@kovojs/ui/table';

import { createDeal } from '../mutations.js';
import {
  contactListQuery,
  openDealsQuery,
  pipelineByStageQuery,
  type ContactListResult,
  type ContactRow,
  type DealRow,
  type OpenDealsResult,
  type PipelineByStageResult,
  type PipelineStageBucket,
} from '../queries.js';
import { freshId, money, StageBadge } from '../components/chrome.js';

// Pipeline dashboard for `/`. A new deal refreshes the stage totals and open
// deals table.

const pipelineStyles = style.create({
  backLink: {
    alignItems: 'center',
    color: style.tokens.sys.color.onSurfaceVariant,
    display: 'inline-flex',
    fontSize: 14,
    gap: 4,
    textDecoration: 'none',
    ':hover': {
      color: style.tokens.sys.color.onSurface,
    },
  },
  formGrid: {
    display: 'grid',
    gap: 8,
    '@media (min-width: 640px)': {
      alignItems: 'start',
      gridTemplateColumns: '1fr auto 1fr auto',
    },
  },
  formPanel: {
    backgroundColor: style.tokens.sys.color.surfaceContainerLowest,
    borderColor: style.tokens.sys.color.outlineVariant,
    borderRadius: style.tokens.sys.shape.cornerMedium,
    borderStyle: 'solid',
    borderWidth: 1,
    padding: 16,
  },
  heading: {
    color: style.tokens.sys.color.onSurface,
    fontSize: 24,
    fontWeight: 700,
    letterSpacing: 0,
    lineHeight: 1.25,
    margin: 0,
  },
  input: {
    backgroundColor: style.tokens.sys.color.surfaceContainerLowest,
    borderColor: style.tokens.sys.color.outline,
    borderRadius: style.tokens.sys.shape.cornerSmall,
    borderStyle: 'solid',
    borderWidth: 1,
    boxSizing: 'border-box',
    color: style.tokens.sys.color.onSurface,
    fontSize: 14,
    paddingBlock: 8,
    paddingInline: 12,
    width: '100%',
  },
  muted: {
    color: style.tokens.sys.color.onSurfaceVariant,
    fontSize: 14,
  },
  sectionLabel: {
    color: style.tokens.sys.color.onSurfaceVariant,
    fontSize: 12,
    fontWeight: 600,
    letterSpacing: '0.025em',
    marginBlockEnd: 12,
    textTransform: 'uppercase',
  },
  stackLg: {
    display: 'grid',
    gap: 32,
  },
  stackSm: {
    display: 'grid',
    gap: 4,
  },
  stageGrid: {
    display: 'grid',
    gap: 12,
    gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
    '@media (min-width: 640px)': {
      gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
    },
    '@media (min-width: 1024px)': {
      gridTemplateColumns: 'repeat(6, minmax(0, 1fr))',
    },
  },
  stageText: {
    textTransform: 'capitalize',
  },
  tabular: {
    fontVariantNumeric: 'tabular-nums',
  },
  tabularStrong: {
    fontVariantNumeric: 'tabular-nums',
    fontWeight: 600,
  },
});

function renderStageCard(bucket: PipelineStageBucket): string {
  return (
    <Card>
      <div style={pipelineStyles.stackSm}>
        <div>
          <StageBadge stage={bucket.stage} />
        </div>
        <p style={pipelineStyles.tabularStrong}>{money(bucket.total)}</p>
      </div>
    </Card>
  );
}

function renderOpenDealsTable(openDeals: DealRow[], contactsById: Map<string, ContactRow>): string {
  return (
    <Table>
      <TableHead>
        <TableRow>
          <TableHeaderCell>Deal</TableHeaderCell>
          <TableHeaderCell>Contact</TableHeaderCell>
          <TableHeaderCell>Amount</TableHeaderCell>
        </TableRow>
      </TableHead>
      <TableBody>
        {openDeals.map((deal) => (
          <TableRow>
            <TableCell>
              <a style={pipelineStyles.backLink} href={`/deals/${deal.id}`}>
                {deal.id.toUpperCase()}
              </a>
            </TableCell>
            <TableCell>{contactsById.get(deal.contactId)?.name ?? deal.contactId}</TableCell>
            <TableCell>
              <span style={pipelineStyles.tabular}>{money(deal.amount)}</span>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

// Rendered as both the full page region and the pipeline fragment payload.
export const PipelineRegion = component({
  queries: {
    contactList: contactListQuery,
    openDeals: openDealsQuery,
    pipelineByStage: pipelineByStageQuery,
  },
  render: ({
    contactList,
    openDeals,
    pipelineByStage,
  }: {
    contactList: ContactListResult;
    openDeals: OpenDealsResult;
    pipelineByStage: PipelineByStageResult;
  }) => {
    const contacts = contactList.items;
    const buckets = pipelineByStage.buckets;
    const contactsById = new Map(contacts.map((contact) => [contact.id, contact]));
    const total = buckets.reduce((sum, bucket) => sum + bucket.total, 0);

    return (
      <div style={pipelineStyles.stackLg}>
        <div>
          <h1 style={pipelineStyles.heading}>Sales pipeline</h1>
          <p style={pipelineStyles.muted}>
            {money(total)} across {buckets.length} stages, <span>{openDeals.items.length}</span>{' '}
            deals open now.
          </p>
        </div>

        <section>
          <h2 style={pipelineStyles.sectionLabel}>By stage</h2>
          <div style={pipelineStyles.stageGrid}>
            {buckets.map((bucket) => renderStageCard(bucket))}
          </div>
        </section>

        {/* The refreshed fragment resets the form with a fresh deal id. */}
        <section>
          <h2 style={pipelineStyles.sectionLabel}>New deal</h2>
          <datalist id="crm-contact-options">
            {contacts.map((contact) => (
              <option value={contact.id}>{contact.name}</option>
            ))}
          </datalist>
          <form mutation={createDeal} enhance style={pipelineStyles.formPanel}>
            <input type="hidden" name="id" value={freshId('d')} />
            <div style={pipelineStyles.formGrid}>
              <input
                name="contactId"
                list="crm-contact-options"
                required
                placeholder="Contact"
                style={pipelineStyles.input}
              />
              <select name="stage" style={[pipelineStyles.input, pipelineStyles.stageText]}>
                <option value="lead">lead</option>
                <option value="qualified">qualified</option>
                <option value="open">open</option>
                <option value="proposal">proposal</option>
              </select>
              <input
                name="amount"
                type="number"
                min="0"
                required
                placeholder="Amount"
                style={pipelineStyles.input}
              />
              <Button type="submit" variant="primary">
                Create deal
              </Button>
            </div>
          </form>
        </section>

        <section>
          <h2 style={pipelineStyles.sectionLabel}>Open deals</h2>
          {renderOpenDealsTable(openDeals.items, contactsById)}
        </section>
      </div>
    );
  },
});
examples/crm/src/components/contacts.tsxtsx
/** @jsxImportSource @kovojs/server */
import { component, FormError } from '@kovojs/core';
import { Avatar, AvatarFallback } from '@kovojs/ui/avatar';
import { Badge } from '@kovojs/ui/badge';
import { Button } from '@kovojs/ui/button';
import { Card } from '@kovojs/ui/card';
import * as style from '@kovojs/style';

import { addContact } from '../mutations.js';
import { contactListQuery, type ContactListResult, type ContactRow } from '../queries.js';
import { freshId } from '../components/chrome.js';

// Contact book for `/contacts`. The add-contact form posts back to this region
// so the list refreshes with the new person.

function initials(name: string): string {
  return name
    .split(/\s+/)
    .slice(0, 2)
    .map((part) => part[0]?.toUpperCase() ?? '')
    .join('');
}

const contactStyles = style.create({
  cardBody: {
    flex: '1 1 0%',
    minWidth: 0,
  },
  cardBadge: {
    flexShrink: 0,
  },
  formGrid: {
    display: 'grid',
    gap: 8,
    '@media (min-width: 640px)': {
      alignItems: 'start',
      gridTemplateColumns: '1fr 1fr auto',
    },
  },
  formPanel: {
    backgroundColor: style.tokens.sys.color.surfaceContainerLowest,
    borderColor: style.tokens.sys.color.outlineVariant,
    borderRadius: style.tokens.sys.shape.cornerMedium,
    borderStyle: 'solid',
    borderWidth: 1,
    padding: 16,
  },
  heading: {
    color: style.tokens.sys.color.onSurface,
    fontSize: 24,
    fontWeight: 700,
    letterSpacing: 0,
    lineHeight: 1.25,
    margin: 0,
  },
  input: {
    backgroundColor: style.tokens.sys.color.surfaceContainerLowest,
    borderColor: style.tokens.sys.color.outline,
    borderRadius: style.tokens.sys.shape.cornerSmall,
    borderStyle: 'solid',
    borderWidth: 1,
    boxSizing: 'border-box',
    color: style.tokens.sys.color.onSurface,
    fontSize: 14,
    paddingBlock: 8,
    paddingInline: 12,
    width: '100%',
  },
  list: {
    display: 'grid',
    gap: 12,
    listStyle: 'none',
    margin: 0,
    padding: 0,
    '@media (min-width: 640px)': {
      gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
    },
  },
  muted: {
    color: style.tokens.sys.color.onSurfaceVariant,
    fontSize: 14,
  },
  row: {
    alignItems: 'center',
    display: 'flex',
    gap: 12,
  },
  stack: {
    display: 'grid',
    gap: 24,
  },
  tabularStrong: {
    fontVariantNumeric: 'tabular-nums',
    fontWeight: 600,
  },
});

function renderContactCard(contact: ContactRow): string {
  return (
    <Card>
      <div style={contactStyles.row}>
        <Avatar>
          <AvatarFallback>{initials(contact.name)}</AvatarFallback>
        </Avatar>
        <div style={contactStyles.cardBody}>
          <p style={contactStyles.tabularStrong}>{contact.name}</p>
          <p style={contactStyles.muted}>{contact.email}</p>
        </div>
        <span style={contactStyles.cardBadge}>
          <Badge variant={contact.dealCount > 0 ? 'success' : 'neutral'}>
            {contact.dealCount} {contact.dealCount === 1 ? 'deal' : 'deals'}
          </Badge>
        </span>
      </div>
    </Card>
  );
}

interface DuplicateEmailFailure {
  code: 'DUPLICATE_EMAIL';
  payload: { email: string };
}

// Rendered as both the full page region and the add-contact fragment payload.
export const ContactsRegion = component({
  mutations: { addContact },
  queries: { contactList: contactListQuery },
  render: ({ contactList }: { contactList: ContactListResult }, _state, slots) => {
    const contacts = contactList.items;

    return (
      <div style={contactStyles.stack}>
        <div>
          <h1 style={contactStyles.heading}>Contacts</h1>
          <p style={contactStyles.muted}>{contacts.length} people in the book.</p>
        </div>

        {/* The refreshed fragment resets the form with a fresh contact id. */}
        <form mutation={addContact} enhance style={contactStyles.formPanel}>
          <input type="hidden" name="id" value={freshId('c')} />
          <div style={contactStyles.formGrid}>
            <input name="name" required placeholder="Full name" style={contactStyles.input} />
            <input
              name="email"
              required
              type="email"
              placeholder="name@example.com"
              style={contactStyles.input}
            />
            <Button type="submit" variant="primary">
              Add contact
            </Button>
          </div>
          <FormError
            code="DUPLICATE_EMAIL"
            failure={slots.forms.addContact.failure}
            style={contactStyles.muted}
            message={(failure: DuplicateEmailFailure) =>
              `${failure.payload.email} is already in the contact book.`
            }
          />
        </form>

        <ul style={contactStyles.list}>
          {contacts.map((contact) => (
            <li>{renderContactCard(contact)}</li>
          ))}
        </ul>
      </div>
    );
  },
});
examples/crm/src/components/deal-detail.tsxtsx
/** @jsxImportSource @kovojs/server */
import { component } from '@kovojs/core';
import * as style from '@kovojs/style';

import { closeDeal, moveDeal } from '../mutations.js';
import {
  activityListQuery,
  contactListQuery,
  dealByIdQuery,
  type ActivityListResult,
  type ContactListResult,
  type DealDetailResult,
} from '../queries.js';
import { money, StageBadge } from '../components/chrome.js';

// Deal detail for `/deals/:id`. Moving or closing the deal refreshes this region
// with the server-updated stage and amount.

// `won` is reached through the close action because it applies commission.
const MOVE_STAGES = ['lead', 'qualified', 'open', 'proposal', 'lost'] as const;

const dealDetailStyles = style.create({
  activityList: {
    display: 'grid',
    gap: 8,
    listStyle: 'none',
    margin: 0,
    padding: 0,
  },
  backLink: {
    alignItems: 'center',
    color: style.tokens.sys.color.onSurfaceVariant,
    display: 'inline-flex',
    fontSize: 14,
    gap: 4,
    textDecoration: 'none',
    ':hover': {
      color: style.tokens.sys.color.onSurface,
    },
  },
  card: {
    backgroundColor: style.tokens.sys.color.surfaceContainerLowest,
    borderColor: style.tokens.sys.color.outlineVariant,
    borderRadius: style.tokens.sys.shape.cornerMedium,
    borderStyle: 'solid',
    borderWidth: 1,
    padding: 24,
  },
  dividerTop: {
    borderColor: style.tokens.sys.color.outlineVariant,
    borderTopStyle: 'solid',
    borderTopWidth: 1,
    paddingTop: 16,
  },
  heading: {
    color: style.tokens.sys.color.onSurface,
    fontSize: 24,
    fontWeight: 700,
    letterSpacing: 0,
    lineHeight: 1.25,
    margin: 0,
  },
  muted: {
    color: style.tokens.sys.color.onSurfaceVariant,
    fontSize: 14,
  },
  rowBetween: {
    alignItems: 'flex-start',
    display: 'flex',
    gap: 16,
    justifyContent: 'space-between',
  },
  sectionLabel: {
    color: style.tokens.sys.color.onSurfaceVariant,
    fontSize: 12,
    fontWeight: 600,
    letterSpacing: '0.025em',
    marginBlockEnd: 12,
    textTransform: 'uppercase',
  },
  stack: {
    display: 'grid',
    gap: 24,
  },
  stageMeta: {
    marginTop: 4,
  },
  stageSummary: {
    textAlign: 'right',
  },
  stageWrap: {
    display: 'flex',
    flexWrap: 'wrap',
    gap: 8,
  },
  stageButton: {
    borderColor: style.tokens.sys.color.outline,
    borderRadius: style.tokens.sys.shape.cornerSmall,
    borderStyle: 'solid',
    borderWidth: 1,
    color: style.tokens.sys.color.onSurfaceVariant,
    fontSize: 14,
    fontWeight: 500,
    paddingBlock: 6,
    paddingInline: 12,
    textTransform: 'capitalize',
    ':hover': {
      backgroundColor: style.tokens.sys.color.surfaceContainer,
    },
    ':disabled': {
      cursor: 'not-allowed',
      opacity: 0.4,
    },
  },
  stageButtonActive: {
    backgroundColor: style.tokens.sys.color.primary,
    borderColor: style.tokens.sys.color.primary,
    color: style.tokens.sys.color.onPrimary,
    cursor: 'default',
  },
  tabularStrong: {
    fontVariantNumeric: 'tabular-nums',
    fontWeight: 600,
  },
});

// Rendered as both the detail page region and the deal-action fragment payload.
export const DealDetailRegion = component({
  props: { dealId: String },
  queries: {
    activityList: activityListQuery,
    contactList: contactListQuery,
    deal: dealByIdQuery.args((props: { dealId: string }) => ({ id: props.dealId })),
  },
  render: ({
    activityList,
    contactList,
    deal,
    dealId,
  }: {
    activityList: ActivityListResult;
    contactList: ContactListResult;
    deal: DealDetailResult;
    dealId: string;
  }) => {
    const contact = contactList.items.find((item) => item.id === deal?.contactId);
    const activities = activityList.items.filter((item) => item.dealId === dealId);
    const closed = deal?.stage === 'won' || deal?.stage === 'lost';

    if (!deal) {
      return (
        <div style={dealDetailStyles.stack}>
          <a style={dealDetailStyles.backLink} href="/">
            &larr; Pipeline
          </a>
          <div style={dealDetailStyles.card}>
            <h1 style={dealDetailStyles.heading}>Unknown deal</h1>
            <p style={dealDetailStyles.muted}>
              Deal {dealId.toUpperCase()} does not exist in this demo database.
            </p>
          </div>
        </div>
      );
    }

    return (
      <div style={dealDetailStyles.stack}>
        <a style={dealDetailStyles.backLink} href="/">
          &larr; Pipeline
        </a>

        <div
          data-crm-amount={deal?.amount}
          data-crm-deal=""
          data-crm-stage={deal?.stage}
          style={dealDetailStyles.card}
        >
          <div style={dealDetailStyles.rowBetween}>
            <div>
              <h1 style={dealDetailStyles.heading}>Deal {deal.id.toUpperCase()}</h1>
              <p style={dealDetailStyles.muted}>
                {contact ? contact.name : deal.contactId} · owner {deal.ownerId}
              </p>
            </div>
            <div style={dealDetailStyles.stageSummary}>
              <p style={dealDetailStyles.tabularStrong}>{money(deal.amount)}</p>
              <div style={dealDetailStyles.stageMeta}>
                <StageBadge stage={deal.stage} />
              </div>
            </div>
          </div>
          {contact ? (
            <p style={[dealDetailStyles.dividerTop, dealDetailStyles.muted]}>
              <span style={dealDetailStyles.tabularStrong}>{contact.name}</span> · {contact.email}
            </p>
          ) : (
            ''
          )}
        </div>

        {/* Each stage button posts a tiny form and refreshes this region. */}
        <div style={dealDetailStyles.card}>
          <h2 style={dealDetailStyles.sectionLabel}>Move stage</h2>
          <div style={dealDetailStyles.stageWrap}>
            {MOVE_STAGES.map((stage) => (
              <form enhance mutation={moveDeal} key={stage}>
                <input type="hidden" name="dealId" value={deal?.id} />
                <input type="hidden" name="stage" value={stage} />
                {deal.stage === stage ? (
                  <button
                    type="submit"
                    disabled
                    style={[dealDetailStyles.stageButton, dealDetailStyles.stageButtonActive]}
                  >
                    {stage}
                  </button>
                ) : (
                  <button type="submit" disabled={closed} style={dealDetailStyles.stageButton}>
                    {stage}
                  </button>
                )}
              </form>
            ))}
          </div>
          <div style={dealDetailStyles.dividerTop}>
            {closed ? (
              <p style={dealDetailStyles.muted}>
                This deal is closed ({deal.stage}). Commission is final.
              </p>
            ) : (
              <form enhance mutation={closeDeal}>
                <input type="hidden" name="dealId" value={deal?.id} />
                <button
                  type="submit"
                  style={[dealDetailStyles.stageButton, dealDetailStyles.stageButtonActive]}
                >
                  Close won
                </button>
              </form>
            )}
          </div>
        </div>

        <section>
          <h2 style={dealDetailStyles.sectionLabel}>Activity</h2>
          {activities.length === 0 ? (
            <p style={[dealDetailStyles.card, dealDetailStyles.muted]}>No activity logged yet.</p>
          ) : (
            <ol style={dealDetailStyles.activityList}>
              {activities.map((activity) => (
                <li style={dealDetailStyles.card}>
                  <p style={dealDetailStyles.sectionLabel}>{activity.kind}</p>
                  <p style={dealDetailStyles.muted}>{activity.note}</p>
                </li>
              ))}
            </ol>
          )}
        </section>
      </div>
    );
  },
});
examples/crm/src/queries.tsts
import type { JsonValue } from '@kovojs/core';
import { s } from '@kovojs/server';
import { and, count, eq, sql } from 'drizzle-orm';

import { app } from './kovo.js';
import { deal } from './model.js';
import { activities, contacts, deals } from './schema.js';

// Drizzle reads are extracted from each loader and exposed as generated query-read registries during
// tests/runtime.
//
// SPEC §9.4/§10.3 (MARQUEE / KV433 Stage 1): `defineKovo({ db })` infers the framework-owned
// read-only managed handle at `context.db`; write verbs are absent at the type level and throw
// `KovoReadonlyHandleError` at runtime.

// Every CRM read returns the signed-in owner's pipeline/contacts, so each query is an
// authenticated surface with the session-presence guard that is its KV436 access decision
// (SPEC §10.2), matching the guarded mutations and routes.
// Keep the Drizzle selects inline so the graph emitter can read the same source
// the app runs.

export type ContactRow = {
  readonly [key: string]: JsonValue;
  id: string;
  name: string;
  email: string;
  ownerId: string;
  dealCount: number;
};

export type DealRow = {
  readonly [key: string]: JsonValue;
  id: string;
  contactId: string;
  stage: string;
  amount: number;
  ownerId: string;
};

export type ContactListResult = {
  readonly [key: string]: JsonValue;
  items: ContactRow[];
};

export type DealListResult = {
  readonly [key: string]: JsonValue;
  items: DealRow[];
};

export type DealDetailResult = DealRow | null;

export type ContactDealCountResult = {
  readonly [key: string]: JsonValue;
  count: number;
};

export type OpenDealsResult = {
  readonly [key: string]: JsonValue;
  items: DealRow[];
};

export type PipelineStageBucket = {
  readonly [key: string]: JsonValue;
  stage: string;
  total: number;
};

export type PipelineByStageResult = {
  readonly [key: string]: JsonValue;
  buckets: PipelineStageBucket[];
};

export type ActivityRow = {
  readonly [key: string]: JsonValue;
  id: number;
  dealId: string;
  kind: string;
  note: string;
};

export type ActivityListResult = {
  readonly [key: string]: JsonValue;
  items: ActivityRow[];
};

/** AGG(contacts) — the full contact book, ordered by id (a derivable rowset). */
export const contactListQuery = app.query({
  access: [app.authenticated],
  load: async (_input, context): Promise<ContactListResult> => {
    const db = context.db;
    const items = await db
      .select({
        id: contacts.id,
        name: contacts.name,
        email: contacts.email,
        ownerId: contacts.ownerId,
        dealCount: contacts.dealCount,
      })
      .from(contacts)
      .orderBy(contacts.id);
    return { items: items };
  },
});

/** AGG(deals) ordered by id — the full pipeline list (a derivable rowset). */
export const dealListQuery = app.query({
  access: [app.authenticated],
  load: async (_input, context): Promise<DealListResult> => {
    const db = context.db;
    const items = await db
      .select({
        id: deals.id,
        contactId: deals.contactId,
        stage: deals.stage,
        amount: deals.amount,
        ownerId: deals.ownerId,
      })
      .from(deals)
      .orderBy(deals.id);
    return { items: items };
  },
});

/**
 * One owner-scoped deal instance. The exact `{ id }` argument becomes the canonical browser
 * instance key used by query hydration, keyed optimism, and mutation settlement (SPEC §10.2).
 */
export const dealByIdQuery = app.query({
  access: [app.authenticated],
  args: s.object({ id: s.string() }),
  output: s.nullable(
    s.object({
      amount: s.number(),
      contactId: s.string(),
      id: s.string(),
      ownerId: s.string(),
      stage: s.string(),
    }),
  ),
  load: async (input, context): Promise<DealDetailResult> => {
    const db = context.db;
    const ownerId = context.request.session.user.id;
    const [item] = await db
      .select({
        id: deals.id,
        contactId: deals.contactId,
        stage: deals.stage,
        amount: deals.amount,
        ownerId: deals.ownerId,
      })
      .from(deals)
      .where(and(eq(deals.id, input.id), eq(deals.ownerId, ownerId)))
      .limit(1);
    return item ?? null;
  },
});

/** COUNT(deals) — the scalar count of deals across the pipeline (derivable). */
export const contactDealCountQuery = app.query({
  access: [app.authenticated],
  output: s.object({ count: s.number() }),
  reads: [deal],
  load: async (_input, context): Promise<ContactDealCountResult> => {
    const db = context.db;
    const rows = await db.select({ value: count() }).from(deals);
    return { count: Number(rows[0]?.value ?? 0) };
  },
});

/** AGG(deals WHERE stage = 'open') — the open pipeline (a filtered rowset). */
export const openDealsQuery = app.query({
  access: [app.authenticated],
  output: s.object({
    items: s.array(
      s.object({
        amount: s.number(),
        contactId: s.string(),
        id: s.string(),
        ownerId: s.string(),
        stage: s.string(),
      }),
    ),
  }),
  load: async (_input, context): Promise<OpenDealsResult> => {
    const db = context.db;
    const items = await db
      .select({
        id: deals.id,
        contactId: deals.contactId,
        stage: deals.stage,
        amount: deals.amount,
        ownerId: deals.ownerId,
      })
      .from(deals)
      .where(eq(deals.stage, 'open'))
      .orderBy(deals.id);
    return { items: items };
  },
});

/**
 * SUM(amount) GROUP BY stage — the pipeline value per stage.
 */
export const pipelineByStageQuery = app.query({
  access: [app.authenticated],
  output: s.object({
    buckets: s.array(s.object({ stage: s.string(), total: s.number() })),
  }),
  reads: [deal],
  load: async (_input, context): Promise<PipelineByStageResult> => {
    const db = context.db;
    const buckets = await db
      .select({ stage: deals.stage, total: sql<number>`coalesce(sum(${deals.amount}), 0)::int` })
      .from(deals)
      .groupBy(deals.stage)
      .orderBy(deals.stage);
    return { buckets: buckets };
  },
});

/** AGG(activities) ordered by id — timeline rows for deal-detail regions. */
export const activityListQuery = app.query({
  access: [app.authenticated],
  load: async (_input, context): Promise<ActivityListResult> => {
    const db = context.db;
    const items = await db
      .select({
        id: activities.id,
        dealId: activities.dealId,
        kind: activities.kind,
        note: activities.note,
      })
      .from(activities)
      .orderBy(activities.id);
    return { items: items };
  },
});

export const crmQueries = [
  contactListQuery,
  dealByIdQuery,
  dealListQuery,
  contactDealCountQuery,
  openDealsQuery,
  pipelineByStageQuery,
  activityListQuery,
];
examples/crm/src/mutations.tsts
import { queue, s, SchemaValidationError, type Schema } from '@kovojs/server';
import { and, eq, sql } from 'drizzle-orm';

import { app } from './kovo.js';
import {
  CRM_DEMO_USER_ID,
  CRM_STAGES,
  contact,
  deal,
  type AddContactInput,
  type CloseDealInput,
  type CreateDealInput,
  type CrmStage,
} from './model.js';
import {
  contactDealCountQuery,
  contactListQuery,
  dealByIdQuery,
  dealListQuery,
  openDealsQuery,
  pipelineByStageQuery,
} from './queries.js';
import type { ContactListResult, OpenDealsResult, PipelineByStageResult } from './queries.js';
import { contacts, deals } from './schema.js';

const duplicateEmailError = s.object({ email: s.string() });
const contactOwnershipError = s.object({ contactId: s.string() });
const dealOwnershipError = s.object({ dealId: s.string() });
const contactIdSchema = prefixedUuidSchema('c');
const dealIdSchema = prefixedUuidSchema('d');
const crmStageSchema: Schema<CrmStage> = {
  parse(input: unknown): CrmStage {
    if (typeof input !== 'string' || !isCrmStage(input)) {
      throw validationFailure('Expected CRM stage', []);
    }
    return input;
  },
};

// Every pipeline mutation can affect shared dashboard summaries, so they serialize through one
// conceptual queue. This is execution vocabulary, not a hand-maintained query registry.
const CRM_QUEUE = queue('crm');

const addContactInput = s.object({
  id: contactIdSchema,
  name: s.string(),
  email: s.string(),
});

export const addContact = app.mutation({
  access: [app.authenticated],
  errors: {
    DUPLICATE_EMAIL: duplicateEmailError,
  },
  input: addContactInput,
  optimistic: [contactListQuery.optimistic(addContactInput, predictAddContact)],
  queue: CRM_QUEUE,
  registry: { touches: [contact] },
  async handler({ id, name, email }, request, context) {
    const db = request.db;
    const ownerId = request.session.user.id;
    const [existing] = await db.select().from(contacts).where(eq(contacts.email, email)).limit(1);
    if (existing) {
      return context.fail('DUPLICATE_EMAIL', { email });
    }

    try {
      await db.insert(contacts).values({ id, name, email, ownerId, dealCount: 0 });
    } catch (error) {
      if (isUniqueConstraintError(error)) {
        return context.fail('DUPLICATE_EMAIL', { email });
      }
      throw error;
    }
    return { id };
  },
});

const createDealInput = s.object({
  id: dealIdSchema,
  contactId: s.string(),
  stage: crmStageSchema,
  amount: s.number().int().min(0),
});

export const createDeal = app.mutation({
  access: [app.authenticated],
  errors: {
    CONTACT_NOT_FOUND: contactOwnershipError,
  },
  input: createDealInput,
  optimistic: [
    contactDealCountQuery.optimistic(createDealInput, (value) => ({
      ...value,
      count: value.count + 1,
    })),
    contactListQuery.optimistic(createDealInput, predictCreateDealContacts),
    dealByIdQuery.optimistic(createDealInput, {
      keys: (input) => [{ id: input.id }],
      apply(_value, input) {
        return {
          amount: input.amount,
          contactId: input.contactId,
          id: input.id,
          ownerId: CRM_DEMO_USER_ID,
          stage: input.stage,
        };
      },
    }),
    dealListQuery.optimistic(createDealInput, (value, input) => ({
      ...value,
      items: [
        ...value.items,
        {
          amount: input.amount,
          contactId: input.contactId,
          id: input.id,
          ownerId: CRM_DEMO_USER_ID,
          stage: input.stage,
        },
      ].sort((left, right) => left.id.localeCompare(right.id)),
    })),
    openDealsQuery.optimistic(createDealInput, (value, input) => ({
      ...value,
      items:
        input.stage === 'open'
          ? [
              ...value.items,
              {
                amount: input.amount,
                contactId: input.contactId,
                id: input.id,
                ownerId: CRM_DEMO_USER_ID,
                stage: input.stage,
              },
            ].sort((left, right) => left.id.localeCompare(right.id))
          : value.items,
    })),
    pipelineByStageQuery.optimistic(createDealInput, predictCreateDealPipeline),
  ],
  queue: CRM_QUEUE,
  registry: { touches: [contact, deal] },
  async handler({ id, contactId, stage, amount }, request, context) {
    const db = request.db;
    const ownerId = request.session.user.id;
    const [ownedContact] = await db
      .select({ id: contacts.id })
      .from(contacts)
      .where(and(eq(contacts.id, contactId), eq(contacts.ownerId, ownerId)))
      .limit(1);
    if (!ownedContact) {
      return context.fail('CONTACT_NOT_FOUND', { contactId });
    }
    await db.insert(deals).values({ id, contactId, stage, amount, ownerId });
    await db
      .update(contacts)
      .set({ dealCount: sql`${contacts.dealCount} + 1` })
      .where(and(eq(contacts.id, contactId), eq(contacts.ownerId, ownerId)));
    return { id };
  },
});

const moveDealInput = s.object({
  dealId: s.string(),
  stage: crmStageSchema,
});

export const moveDeal = app.mutation({
  access: [app.authenticated],
  errors: {
    DEAL_NOT_FOUND: dealOwnershipError,
  },
  input: moveDealInput,
  optimistic: [
    contactDealCountQuery.optimistic(moveDealInput, (value) => value),
    dealByIdQuery.optimistic(moveDealInput, {
      keys: (input) => [{ id: input.dealId }],
      apply(value, input) {
        return value ? { ...value, stage: input.stage } : null;
      },
    }),
    dealListQuery.optimistic(moveDealInput, (value, input) => ({
      ...value,
      items: value.items.map((item) =>
        item.id === input.dealId ? { ...item, stage: input.stage } : item,
      ),
    })),
    // Moving a deal can change filtered and grouped views in ways that need row context. A no-op
    // prediction preserves the current value until the returned server fragment reconciles it.
    openDealsQuery.optimistic(moveDealInput, (value) => value),
    pipelineByStageQuery.optimistic(moveDealInput, (value) => value),
  ],
  queue: CRM_QUEUE,
  registry: { touches: [deal] },
  async handler({ dealId, stage }, request, context) {
    const db = request.db;
    const ownerId = request.session.user.id;
    const [ownedDeal] = await db
      .select({ id: deals.id })
      .from(deals)
      .where(and(eq(deals.id, dealId), eq(deals.ownerId, ownerId)))
      .limit(1);
    if (!ownedDeal) {
      return context.fail('DEAL_NOT_FOUND', { dealId });
    }
    await db
      .update(deals)
      .set({ stage })
      .where(and(eq(deals.id, dealId), eq(deals.ownerId, ownerId)));
    return { dealId };
  },
});

/**
 * Row-carrying helper for updating pipelineByStage when the old stage and amount are already known.
 */
export function applyMoveDealPipeline(
  current: { buckets: { stage: string; total: number }[] },
  movedDeal: { amount: number; fromStage: string; toStage: string },
): { buckets: { stage: string; total: number }[] } {
  const next = structuredClone(current);
  const from = next.buckets.find((entry) => entry.stage === movedDeal.fromStage);
  if (from) from.total -= movedDeal.amount;
  const to = next.buckets.find((entry) => entry.stage === movedDeal.toStage);
  if (to) to.total += movedDeal.amount;
  else next.buckets.push({ stage: movedDeal.toStage, total: movedDeal.amount });
  return {
    buckets: next.buckets
      .filter((entry) => entry.total !== 0)
      .sort((left, right) => left.stage.localeCompare(right.stage)),
  };
}

const closeDealInput = s.object({
  dealId: s.string(),
});

export const closeDeal = app.mutation({
  access: [app.authenticated],
  errors: {
    DEAL_NOT_FOUND: dealOwnershipError,
  },
  input: closeDealInput,
  optimistic: [
    contactDealCountQuery.optimistic(closeDealInput, (value) => value),
    // The commission is server-computed, but the detail instance can still predict its terminal
    // status immediately; the returned keyed query chunk replaces the amount with server truth.
    dealByIdQuery.optimistic(closeDealInput, {
      keys: (input) => [{ id: input.dealId }],
      apply(value) {
        return value ? { ...value, stage: 'won' } : null;
      },
    }),
    openDealsQuery.optimistic(closeDealInput, predictCloseDealOpenList),
    // Views that include the server-computed commission retain their current value until the
    // returned fragment supplies authoritative truth.
    dealListQuery.optimistic(closeDealInput, (value) => value),
    pipelineByStageQuery.optimistic(closeDealInput, (value) => value),
  ],
  queue: CRM_QUEUE,
  registry: { touches: [deal] },
  async handler({ dealId }, request, context) {
    const db = request.db;
    const ownerId = request.session.user.id;
    const [ownedDeal] = await db
      .select({ id: deals.id })
      .from(deals)
      .where(and(eq(deals.id, dealId), eq(deals.ownerId, ownerId)))
      .limit(1);
    if (!ownedDeal) {
      return context.fail('DEAL_NOT_FOUND', { dealId });
    }
    await db
      .update(deals)
      .set({ stage: 'won', amount: sql`compute_commission(${deals.amount})` })
      .where(and(eq(deals.id, dealId), eq(deals.ownerId, ownerId)));
    return { dealId };
  },
});

export const crmMutations = [addContact, createDeal, moveDeal, closeDeal];

export function predictAddContact(
  value: Readonly<ContactListResult>,
  input: AddContactInput,
): ContactListResult {
  const row = {
    dealCount: 0,
    email: input.email,
    id: input.id,
    name: input.name,
    ownerId: CRM_DEMO_USER_ID,
  };
  return {
    ...value,
    items: [...value.items, row].sort((left, right) => left.id.localeCompare(right.id)),
  };
}

export function predictCreateDealContacts(
  value: Readonly<ContactListResult>,
  input: CreateDealInput,
): ContactListResult {
  return {
    ...value,
    items: value.items.map((item) =>
      item.id === input.contactId ? { ...item, dealCount: item.dealCount + 1 } : item,
    ),
  };
}

export function predictCreateDealPipeline(
  value: Readonly<PipelineByStageResult>,
  input: CreateDealInput,
): PipelineByStageResult {
  const matching = value.buckets.find((entry) => entry.stage === input.stage);
  const buckets = matching
    ? value.buckets.map((entry) =>
        entry.stage === input.stage ? { ...entry, total: entry.total + input.amount } : entry,
      )
    : [...value.buckets, { stage: input.stage, total: input.amount }];
  return {
    ...value,
    buckets: buckets.toSorted((left, right) => left.stage.localeCompare(right.stage)),
  };
}

export function predictMoveDealPipeline(
  current: { buckets: { stage: string; total: number }[] },
  movedDeal: { amount: number; fromStage: string; toStage: string },
): { buckets: { stage: string; total: number }[] } {
  return applyMoveDealPipeline(current, movedDeal);
}

export function predictCloseDealOpenList(
  value: Readonly<OpenDealsResult>,
  input: CloseDealInput,
): OpenDealsResult {
  return {
    ...value,
    items: value.items.filter((item) => item.id !== input.dealId),
  };
}

function isUniqueConstraintError(error: unknown): boolean {
  if (typeof error !== 'object' || error === null) return false;
  const code = Reflect.get(error, 'code');
  if (code === '23505') return true;
  const message = Reflect.get(error, 'message');
  return (
    typeof message === 'string' &&
    /duplicate key|unique constraint|unique violation/iu.test(message)
  );
}

function isCrmStage(value: string): value is CrmStage {
  return CRM_STAGES.some((stage) => stage === value);
}

function prefixedUuidSchema(prefix: 'c' | 'd'): Schema<string> {
  const pattern = new RegExp(
    `^${prefix}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`,
    'i',
  );
  return {
    parse(input: unknown): string {
      if (typeof input !== 'string' || !pattern.test(input)) {
        throw validationFailure(`Expected ${prefix}-prefixed UUID`, ['id']);
      }
      return input;
    },
  };
}

function validationFailure(message: string, path: readonly string[]): SchemaValidationError {
  return new SchemaValidationError([{ message, path }]);
}
examples/crm/src/interactive-app.tsxtsx
/** @jsxImportSource @kovojs/server */
import { s } from '@kovojs/server';

import { ContactsRegion } from './components/contacts.js';
import { DealDetailRegion } from './components/deal-detail.js';
import { PipelineRegion } from './components/pipeline.js';
import { CrmShell } from './components/chrome.js';
import { app, crmStylesheets, resetCrmDatabase } from './kovo.js';
import { addContact, closeDeal, createDeal, moveDeal } from './mutations.js';
import {
  activityListQuery,
  contactDealCountQuery,
  contactListQuery,
  dealByIdQuery,
  dealListQuery,
  openDealsQuery,
  pipelineByStageQuery,
} from './queries.js';

// Interactive CRM app: pipeline, contacts, and deal detail pages backed by the
// demo database. Forms post to `/_m/*` and refresh query-backed regions.

const crmStaticDealPaths = [
  '/deals/d1',
  '/deals/d2',
  '/deals/d3',
  '/deals/d4',
  '/deals/d5',
  '/deals/d6',
  '/deals/d7',
  '/deals/d8',
  '/deals/d9',
  '/deals/d10',
] as const;

// Every CRM route shows the seeded owner's pipeline/contacts, so the layouts carry
// the session-presence guard each child route inherits as its access decision (KV436,
// SPEC §10.2). The guarded mutations already require the same session.
const PipelineLayout = app.layout({
  access: [app.authenticated],
  render: (_queries, _state, { children }) => <CrmShell active="pipeline">{children}</CrmShell>,
});

const ContactsLayout = app.layout({
  access: [app.authenticated],
  render: (_queries, _state, { children }) => <CrmShell active="contacts">{children}</CrmShell>,
});

/**
 * One parameterized detail route keeps newly created deals viewable.
 */
const dealDetailRoute = app.route('/deals/:id', {
  access: [app.authenticated],
  meta: { description: 'CRM deal detail.', title: 'Deal · Atlas CRM' },
  params: s.object({ id: s.string() }),
  staticPaths: crmStaticDealPaths,
  page({ params }) {
    return <DealDetailRegion dealId={params.id} />;
  },
  layout: PipelineLayout,
  stylesheets: crmStylesheets,
});

const pipelineRoute = app.route('/', {
  access: [app.authenticated],
  meta: {
    description: 'Sales pipeline by stage with open deals.',
    title: 'Pipeline · Atlas CRM',
  },
  page() {
    return <PipelineRegion />;
  },
  layout: PipelineLayout,
  stylesheets: crmStylesheets,
});

const contactsRoute = app.route('/contacts', {
  access: [app.authenticated],
  meta: { description: 'The CRM contact book.', title: 'Contacts · Atlas CRM' },
  page() {
    return <ContactsRegion />;
  },
  layout: ContactsLayout,
  stylesheets: crmStylesheets,
});

export const crmApp = app.assemble({
  layouts: [PipelineLayout, ContactsLayout],
  mutations: [addContact, createDeal, moveDeal, closeDeal],
  queries: [
    contactListQuery,
    dealByIdQuery,
    dealListQuery,
    contactDealCountQuery,
    openDealsQuery,
    pipelineByStageQuery,
    activityListQuery,
  ],
  routes: [pipelineRoute, contactsRoute, dealDetailRoute],
});

/**
 * Reset the direct-development/test database and return the already-closed app token.
 *
 * Public demo requests carry a dispatcher-owned session header, so their lazy databases remain
 * isolated without rebuilding declarations or assembling a second graph.
 */
export async function buildCrmInteractiveApp() {
  const db = await resetCrmDatabase();
  return { app: crmApp, db };
}

Read this example#

CRM is a sales dashboard over Drizzle/PGlite: pipeline, contacts, and per-deal detail. It demonstrates nested app shape, aggregate reads, parameterized routes, and a practical mix of compiler-derived and hand-written optimistic updates.

File Why it matters
examples/crm/src/interactive-app.tsx createApp(), shared layout(), routes, and app registration.
examples/crm/src/components/chrome.tsx Shared app frame and navigation.
examples/crm/src/components/pipeline.tsx Dashboard region and pipeline forms.
examples/crm/src/components/contacts.tsx Contact list and creation flow.
examples/crm/src/components/deal-detail.tsx Parameterized route/detail rendering.
examples/crm/src/queries.ts Aggregate and detail reads over Drizzle.
examples/crm/src/mutations.ts Create/move/close deal writes and optimistic behavior.
examples/crm/src/interactive-app.test.ts HTTP and mutation coverage over the real app shell.

Dashboard pattern#

Use this shape for operational apps: CRMs, admin dashboards, review queues, and internal tools where many regions depend on related data but only some regions should refresh after each write. Keep app chrome in a shared layout(); each route owns params, guards, page metadata, and the component that renders the route body.

Name queries by product region: pipeline summary, grouped deals, contacts, deal detail, and any activity stream. Aggregate queries are still first-class queries; avoid hiding them inside component helpers because the graph needs stable names for review and kovo explain query.

Dashboards usually need a mixed optimistic policy: derive direct write/read shapes, hand-write the product-specific summary updates, and declare 'await-fragment' when server truth should win before a region morphs.

sh
pnpm --filter @kovojs/example-crm dev
pnpm --filter @kovojs/example-crm test
pnpm --filter @kovojs/example-crm build
pnpm --filter @kovojs/example-crm start
pnpm --filter @kovojs/example-crm test -- src/interactive-app.test.ts