Files
template-app/docs/domain-start.md
T
2026-11-14 09:56:45 +00:00

721 lines
33 KiB
Markdown

<!-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 -->
# Start your domain
Where to add a module, an entity, a migration, a permission, a procedure, an audit event, a usage meter, a
job, a page and a message catalogue, with the commands. Everything below was done once, in a copy of this
template, as written here: the module is called `widgets`, it passed `pnpm kit gate`, and every code block is
a file of it.
You write modules. You do not edit `apps/`: the web shell and the worker find a module from
`modules/index.ts` and its manifest. (Rules the gates enforce: [AGENTS.md](../AGENTS.md).)
## Before you start
```bash
pnpm install
pnpm dev # Postgres, the fake platform, the demo data, the worker and the web shell
```
During migration or seeding, Ctrl+C or SIGTERM stops that startup command's process group,
including its children, and closes the fake platform before exiting. Cancellation does not start
the next startup step. Shutdown allows 35 seconds for graceful termination (including pg-boss's
30-second grace), then sends SIGKILL to any remaining owned groups and waits up to five more seconds
for termination. A failed startup command retains its nonzero exit code; inability to terminate owned
children is reported as failure. A permission-denied presence probe keeps the group pending until
absence is confirmed or the existing wait expires; it does not extend either deadline.
Sign in at the address it prints (`http://127.0.0.1:3000/auth/dev-login?email=ada@example.test`). The demo
data has Acme Review with a `general` workspace and a project called `Team notes`; a project of your own is
made from the workspace page (`/<org>/general`) by choosing your module in the **Module** list.
## Where each thing goes
| You add | Where | Command |
|---|---|---|
| A module | `modules/<id>/`, listed in `modules/index.ts`, with one screen of records in `ui/resource.tsx` | `pnpm new:module <id> [--uses files,proposals]` |
| An entity (a table) | `modules/<id>/contract/entities.ts`, `defineEntity` | |
| A migration | `modules/<id>/migrations/NNNN_name.sql`, forward only | `pnpm vitest run modules/<id> -u` writes the first |
| A permission | `permissions` of `modules/<id>/module.ts` | |
| A procedure | contract in `contract/procedures.ts`, handler in `adapters/procedures.ts`, rules in `core/` | |
| Its isolation fixture | `modules/<id>/__tests__/isolation.ts` | `pnpm test:isolation` |
| An audit event | `.audit("<id>.<event>")` on the contract, and `audit: [...]` in the manifest | |
| A usage meter | `.usage("<meter>")` on the contract, and `usage: { meters: [...] }` in the manifest | |
| A domain event and its subscriber | `defineEvent` in `contract/events.ts`, `tx.publish` in the use case, `subscribe(event, handler)` in `events.subscribes` of the manifest | |
| A job | `jobs` of the manifest, enqueued by a write procedure | |
| A page | `ui/pages.tsx`, one line in `modulePages` of `modules/index.ts`, a `ui.nav` entry | |
| A message catalogue | `modules/<id>/i18n/en.ts` and `lt.ts`, declared as `i18n: { en, lt }` | |
| The API document | `openapi.json` | `pnpm openapi` |
## 1. The module
```bash
pnpm new:module widgets --uses files
```
`--uses files` because the job below reads the scope of its run through the files module's token (step 7);
use `proposals` for a module that proposes changes a person approves. The command writes `modules/widgets/`
(`module.ts`, `index.ts`, `contract/`, `core/`, `i18n/`, a test), adds the module to `modules/package.json` and
the catalogue when it needs a kit module, and appends it to `domainModules` in `modules/index.ts`. A module with
no procedure passes the gate as it is; the steps below fill it in.
## 2. An entity
```ts
// modules/widgets/contract/entities.ts
// The tables of the widgets module. One, project-owned: `tenancy: "project"` gives the table its
// `org_id`, `workspace_id` and `project_id` columns and the row-level security policy on `org_id`.
import { defineEntity, idSchema } from "@conusai-kit/data"
import { z } from "zod"
export const widgets = defineEntity({
name: "widget",
table: "widgets_widget",
tenancy: "project",
fields: {
name: z.string().min(1).max(120),
createdBy: idSchema,
},
indexes: [["createdBy"]],
})
/** Every table this module owns, in the order a migration creates them. */
export const WIDGETS_ENTITIES = Object.freeze([widgets])
```
`tenancy: "project"` gives the table `org_id`, `workspace_id` and `project_id` and the row-level security policy;
use `"workspace"` or `"org"` for rows that belong higher up. Fields are JSON types.
## 3. A migration
```bash
pnpm vitest run modules/widgets -u
```
The test in `__tests__/migration.test.ts` snapshots `schemaStatements()` of the module's entities into
`migrations/0001_widgets.sql`, so the SQL is generated from the entity and cannot drift from it:
```ts
// modules/widgets/__tests__/migration.test.ts
// The module's migration is its own file and exactly `schemaStatements()` over its entities (decision
// AT-D20: the entity definitions are the one source of a column, so a hand-written column cannot drift
// from the schema the module validates its rows with). `pnpm vitest run modules/widgets -u` writes
// 0001_widgets.sql from it the first time; once that file has run, it is never edited -- a later change to
// the table is a new numbered file written by hand, and this test then compares the first file alone.
import { schemaStatements } from "@conusai-kit/data-kysely"
import { describe, expect, it } from "vitest"
import { WIDGETS_ENTITIES, widgetsModule } from "../index.js"
/** The statements for these tables alone: `schemaStatements` leads with the kernel's own. */
function tablesOf(entities: Parameters<typeof schemaStatements>[0]): string {
const kernel = schemaStatements([]).length
return `${schemaStatements(entities)
.slice(kernel)
.map((statement) => `${statement};`)
.join("\n\n")}\n`
}
describe("the migration of the widgets module", () => {
it("is its own file, exactly its table", async () => {
await expect(tablesOf(WIDGETS_ENTITIES)).toMatchFileSnapshot("../migrations/0001_widgets.sql")
})
it("is declared by the manifest, so the migrate job finds it, and the table is row-level secured", () => {
expect(widgetsModule.migrations).toEqual({ postgres: "migrations" })
const sql = tablesOf(WIDGETS_ENTITIES)
expect(sql).toContain('create table "widgets_widget"')
expect(sql).toContain('alter table "widgets_widget" force row level security')
})
})
```
Review the file and commit it. The manifest says where the folder is (`migrations: { postgres: "migrations" }`),
and the migrate job applies it after the app's own, in file-name order, and refuses a file that changed after it
ran. A later change to a table is a new numbered file you write (`0002_...sql`); `/api/ready` stays unready
until the database has every file this build ships.
## 4. A permission, and a procedure
Permissions are `<module>.<resource>.<action>` with the lowest role that holds them, declared in the manifest
(step 7 shows it whole). A procedure declares its kind, scope and permission, takes slugs and never an id of a
parent, and is a contract first:
```ts
// modules/widgets/contract/procedures.ts
// The procedure contracts of the widgets module: schemas and names, no handler and no port. Each
// declares its kind, its scope and its permission; the kernel checks all three before a handler runs.
// The input carries slugs, never an id of a parent: the resolver's own reads are the membership check.
import { procedure } from "@conusai-kit/core"
import { z } from "zod"
import { WIDGET_CREATED, WIDGETS_CREATED_METER } from "./constants.js"
const inProject = { org: z.string(), workspace: z.string(), project: z.string() }
export const widgetViewSchema = z.object({
widgetId: z.string(),
name: z.string(),
createdAt: z.string(),
})
export type WidgetView = z.output<typeof widgetViewSchema>
export const widgetList = procedure("widgets.widget.list")
.kind("read")
.scope("project")
.permission("widgets.widget.read")
.input(z.object(inProject))
.output(z.object({ widgets: z.array(widgetViewSchema) }))
.contract()
export const widgetCreate = procedure("widgets.widget.create")
.kind("write")
.scope("project")
.permission("widgets.widget.write")
.input(z.object({ ...inProject, name: z.string().min(1).max(120) }))
.output(widgetViewSchema)
// The transport records both once the call has succeeded: one audit event and one usage fact.
.audit(WIDGET_CREATED)
.usage(WIDGETS_CREATED_METER)
.contract()
```
The handler resolves its ports from the container the transport built and calls a use case. A `read` handler is
refused every write port by name -- the list handler below asks for the data source and nothing else:
```ts
// modules/widgets/adapters/procedures.ts
// The handlers of the module's procedures and its job: each resolves its ports from the container the
// transport built and calls one use case. No rule lives here.
import { type Container, implement, type JobContext } from "@conusai-kit/core"
import { DATA_SOURCE, JOB_QUEUE } from "@conusai-kit/data"
import { FILES_SCOPE } from "@conusai-kit/files"
import { widgetCreate, widgetList } from "../contract/index.js"
import { createWidget, listWidgets, tidyWidget, type WidgetScope, type WidgetsDeps } from "../core/index.js"
// A `read` procedure cannot resolve a write port: the kernel refuses `JOB_QUEUE` to it by name. The two
// handlers therefore ask for exactly what they use.
const readDeps = (container: Container): Pick<WidgetsDeps, "data"> => ({ data: container.resolve(DATA_SOURCE) })
const writeDeps = (container: Container): WidgetsDeps => ({
...readDeps(container),
jobs: container.resolve(JOB_QUEUE),
})
export const widgetListImpl = implement(widgetList, ({ ctx }) => listWidgets(readDeps(ctx.container), ctx.scope))
export const widgetCreateImpl = implement(widgetCreate, ({ input, ctx }) =>
createWidget(writeDeps(ctx.container), ctx.scope, { name: input.name }),
)
export const WIDGET_PROCEDURES = [widgetListImpl, widgetCreateImpl]
/**
* The job. The worker rebuilds the scope the job was enqueued in and binds it as `FILES_SCOPE`, the
* kernel's name for "the scope of this run": a module needs no binding of its own to read it.
*/
export async function runTidy(payload: { widgetId: string }, context: JobContext): Promise<void> {
const scope = context.container.resolve(FILES_SCOPE) as WidgetScope
await tidyWidget({ data: context.container.resolve(DATA_SOURCE) }, scope, payload.widgetId)
}
```
The use cases are pure TypeScript over those ports, so they run against the in-memory data source with no
database:
```ts
// modules/widgets/core/widgets.ts
// What the widgets module does, as pure TypeScript over the ports in this file: no driver, no SDK, no
// framework (the boundary gate refuses them), so every rule here is tested against the in-memory
// data source with no database.
import type { TenantScope } from "@conusai-kit/core"
import type { DataSource, JobQueue } from "@conusai-kit/data"
import type { z } from "zod"
import { TIDY_JOB, type WidgetView, widgets } from "../contract/index.js"
/** A project scope: the only level a widget can live at. */
export type WidgetScope = TenantScope & { workspaceId: string; projectId: string }
export interface WidgetsDeps {
readonly data: DataSource
readonly jobs: JobQueue
}
type WidgetRow = z.output<typeof widgets.schema>
const view = (row: WidgetRow): WidgetView => ({ widgetId: row.id, name: row.name, createdAt: row.createdAt })
export async function listWidgets(
deps: Pick<WidgetsDeps, "data">,
scope: WidgetScope,
): Promise<{ widgets: WidgetView[] }> {
// `forScope` is the only way to a repository: the tenant predicate is part of every query it runs.
const page = await deps.data
.forScope(scope)
.of(widgets)
.list({ order: [{ field: "createdAt", direction: "desc" }], limit: 200 })
return { widgets: page.items.map(view) }
}
export async function createWidget(
deps: WidgetsDeps,
scope: WidgetScope,
input: { name: string },
): Promise<WidgetView> {
const row = await deps.data.forScope(scope).of(widgets).insert({ name: input.name, createdBy: scope.actor.userId })
// Long work is a job: the request enqueues it and answers. The key makes a repeated enqueue one job.
await deps.jobs.enqueue(scope, TIDY_JOB, { widgetId: row.id }, { key: `tidy.${row.id}` })
return view(row)
}
/** The job: collapses the whitespace in a widget's name. A widget deleted since is nothing to do. */
export async function tidyWidget(deps: Pick<WidgetsDeps, "data">, scope: WidgetScope, widgetId: string): Promise<void> {
const repo = deps.data.forScope(scope).of(widgets)
const row = await repo.get(widgetId)
if (!row) return
const name = row.name.replace(/\s+/g, " ").trim()
if (name !== row.name) await repo.update(row.id, { name }, row.version)
}
```
### Its isolation fixture
The generated suite (`pnpm test:isolation`) calls every tenant-scoped procedure as a member of one organization
with another's slugs and ids and expects a refusal. A procedure with no fixture fails it by name:
```ts
// modules/widgets/__tests__/isolation.ts
// What the generated tenant-isolation suite (test/isolation.test.ts) needs of this module, from this
// folder: the rows it seeds in each tenant, and one fixture per tenant-scoped procedure. The suite finds
// this file by its place, so there is no line to add outside the module; a procedure with no fixture
// fails the suite by name.
//
// A fixture is built from the *other* tenant's ids: the call is made as a member of one organization
// with another's slugs and ids, and has to be refused -- for being another tenant's, not for naming
// nothing, which is why `seed` leaves a widget in every tenant.
import type { TransportCaller } from "@conusai-kit/core"
import type { IsolationFixture, IsolationTenant } from "@conusai-kit/testing"
/** What a module's isolation file exports: the shape the suite reads, so it needs no import of the suite. */
export interface ModuleIsolation {
seed(
call: TransportCaller,
inProject: { org: string; workspace: string; project: string },
): Promise<Record<string, string>>
fixtures: Record<string, IsolationFixture>
}
const inProjectOf = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug })
export const isolation: ModuleIsolation = {
async seed(call, inProject) {
const widget = (await call.callByName("widgets.widget.create", { ...inProject, name: "Seed widget" })) as {
widgetId: string
}
return { widgetId: widget.widgetId }
},
fixtures: {
"widgets.widget.list": { input: (t) => inProjectOf(t) },
"widgets.widget.create": { input: (t) => ({ ...inProjectOf(t), name: "Intruder" }) },
},
}
```
## 5. An audit event and a usage meter
Both are named on the procedure's contract (`.audit(...)`, `.usage(...)` in step 4) and declared in the
manifest (`audit`, `usage.meters` in step 7). The web transport records them once the call has succeeded: one
event in the platform's audit log, which the organization's administrators read at `/<org>/audit`, and one
usage fact against the ConusAI organization that owns the deployment, with the organization's slug as the
customer. No content goes in an audit event.
```ts
// modules/widgets/contract/constants.ts
// The module's names, in a file with no import: a browser bundle can take them without loading the
// data layer the schemas live beside.
/** An audit event (spec §9.4), declared in the manifest and named by the procedure that causes it. */
export const WIDGET_CREATED = "widgets.widget_created"
/** A usage meter: one fact per widget created, metered against the organization that owns it. */
export const WIDGETS_CREATED_METER = "widgets_created"
/** The job that tidies a widget after it is created. A request enqueues it and answers. */
export const TIDY_JOB = "widgets.tidy"
```
For an effect that must survive the request's process dying -- a note's audit event and usage fact are the
example -- publish a domain event on the same transaction (`tx.publish`, not `forScope(...).publish`) and subscribe
to it: `modules/notes` (`core/subscribers.ts`) is the worked pattern. A rolled-back write publishes nothing. What
delivery guarantees depends on the profile:
- **postgres, mariadb, mongodb** (the worker's outbox relay): each subscriber gets the event at least once, and
its writes through the delivery's own transaction (`JOB_REPOS`) land exactly once. A call to another service is
repeated on a redelivery unless it carries a key derived from the record, as the notes subscribers' audit and
usage calls do.
- **convex** (the cron relay): at least once only. A redelivery can repeat a subscriber's writes, so make them
idempotent.
A subscriber can tell people, too. `modules/notes/adapters/notifications.ts` is the example: `notes.NoteCreated`
puts one inbox row for each of the project's other named members, never the author, in each one's language, through
`deliverNotification` of `@conusai-kit/notifications` on the delivery's own transaction (`NOTIFICATIONS_REPOS`)
with the record-derived key `notes:<noteId>:created`; `modules/notes/__tests__/notification.test.ts` proves one row per member,
none after a second relay pass and none for a rolled-back write. A module subscribes once to an event: the relay
records a delivery per record and module, so a second subscription of the same module would never run, and
`notes` composes its audit event, usage fact and notification in one subscriber.
## 6. A job
Long work is a job. A write procedure enqueues it and answers (`createWidget` in step 4 does); the handler
runs in the worker, in a span of its own, with the scope the job was enqueued in:
```ts
// the handler, in adapters/procedures.ts
export async function runTidy(payload: { widgetId: string }, context: JobContext): Promise<void> {
const scope = context.container.resolve(FILES_SCOPE) as WidgetScope
await tidyWidget({ data: context.container.resolve(DATA_SOURCE) }, scope, payload.widgetId)
}
```
`FILES_SCOPE` is the kernel's name for "the scope of this run", bound once for every module. The test runs it as
the worker would (`app.runJobs()` in `test/modules/widgets/widgets-flow.test.ts`).
## 7. The manifest
```ts
// modules/widgets/module.ts
// The widgets module's manifest (spec §4.1): the one place it registers anything. `modules/index.ts`
// lists the manifest; nothing in apps/ changes.
import { defineModule } from "@conusai-kit/core"
import { repositoryBindings } from "@conusai-kit/data"
import { z } from "zod"
import { runTidy, WIDGET_PROCEDURES } from "./adapters/procedures.js"
import { TIDY_JOB, WIDGET_CREATED, WIDGETS_CREATED_METER, WIDGETS_ENTITIES } from "./contract/index.js"
import { en, lt } from "./i18n/index.js"
const tables = repositoryBindings(...WIDGETS_ENTITIES)
export const widgetsModule = defineModule({
id: "widgets",
version: "0.1.0",
kit: "^0.1.0",
requires: ["tenancy", "files"],
// <module>.<resource>.<action>, with the lowest role that holds it.
permissions: {
"widgets.widget.read": "viewer",
"widgets.widget.write": "editor",
},
procedures: WIDGET_PROCEDURES,
repositories: { postgres: tables, mariadb: tables, mongodb: tables, convex: tables },
// Forward-only SQL, one numbered file per change, run by the migrate job after the app's own.
migrations: { postgres: "migrations" },
jobs: { [TIDY_JOB]: { payload: z.object({ widgetId: z.string() }), handler: runTidy } },
audit: [WIDGET_CREATED],
usage: { meters: [WIDGETS_CREATED_METER] },
// The project-level entry the organization home and the project home list; `widgets` is the segment
// the web shell's module mount routes to `widgetsPage` (ui/pages.tsx), listed in modules/index.ts.
ui: { nav: [{ scope: "project", label: "widgets.title", href: "widgets", permission: "widgets.widget.read" }] },
i18n: { en, lt },
})
```
## 8. A page
One page per first path segment, `/<org>/<workspace>/<project>/widgets`, with no route file. `load` runs on the
server and returns plain data with every label translated; `View` renders that data:
```tsx
// modules/widgets/ui/pages.tsx
// The widgets module's page, on the shell's F10 contract: a loader the web shell runs on the server under
// `/<org>/<workspace>/<project>/widgets` (it finds the page in `modules/pages.ts`), and the view of what it
// returned. Every read is a procedure through the same caller the API uses; every write is a client component.
import type { Container, TransportCaller } from "@conusai-kit/core"
import type { I18n } from "@conusai-kit/i18n"
import { type ProjectPage, type ProjectPageViewProps, projectPage } from "@conusai-kit/ui"
import type { ReactElement } from "react"
import { widgetList } from "../contract/index.js"
import { WidgetForm, type WidgetFormLabels } from "./widget-form.js"
export interface WidgetsData {
readonly title: string
readonly intro: string
readonly empty: string
readonly count: string
readonly form: WidgetFormLabels
readonly widgets: readonly { readonly widgetId: string; readonly name: string }[]
}
export type WidgetsPage = ProjectPage<TransportCaller, I18n, Container, WidgetsData>
export const widgetsPage: WidgetsPage = projectPage({
async load({ slugs, caller, i18n }): Promise<WidgetsData> {
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`widgets.${key}`, values)
const { widgets } = await caller.call(widgetList, slugs)
return {
title: t("title"),
intro: t("intro"),
empty: t("empty"),
count: t("count", { count: widgets.length }),
form: { name: t("name"), submit: t("add"), failed: i18n.message("app.failed") },
widgets: widgets.map(({ widgetId, name }) => ({ widgetId, name })),
}
},
View({ slugs, data }: ProjectPageViewProps<WidgetsData>): ReactElement {
return (
<>
<h1>{data.title}</h1>
<p>{data.intro}</p>
<WidgetForm slugs={slugs} labels={data.form} />
{data.widgets.length === 0 ? (
<p>{data.empty}</p>
) : (
<>
<p>{data.count}</p>
<ul>
{data.widgets.map((widget) => (
<li key={widget.widgetId}>{widget.name}</li>
))}
</ul>
</>
)}
</>
)
},
})
```
Add it to `modules/pages.ts`, beside `notes`:
```ts
export const modulePages: Readonly<Record<string, () => Promise<ModuleProjectPage>>> = {
notes: async () => (await import("./notes/ui/pages.js")).notesPage,
widgets: async () => (await import("./widgets/ui/pages.js")).widgetsPage,
}
```
A client component writes through `useWrite` of `@conusai-kit/ui/data`, whose client carries the session and the
CSRF token (a list that follows other members' changes is a collection, read with `useLive`; see step 5). The `ui.nav` entry of the manifest makes the page appear on the project's home page and in the lists of
the workspace and the organization.
```tsx
// modules/widgets/ui/widget-form.tsx
"use client"
// Adds a widget through the data facade (`useWrite` of @conusai-kit/ui/data): the one API path every
// caller uses, with the session, the CSRF token and the problem+json refusals handled by the shell's
// data client. Every label is a message the server passed in. The form is the kit's `FormLayout`: it checks
// the fields' own constraints in the app's language and states a refusal in the form, with focus on it.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { describeError, useWrite } from "@conusai-kit/ui/data"
import { FormLayout, type FormValues, TextField } from "@conusai-kit/ui/patterns"
import type { ReactElement } from "react"
export interface WidgetFormLabels {
readonly name: string
readonly submit: string
/** The app's `app.failed` message, with `{detail}` still in it. */
readonly failed: string
}
export function WidgetForm({ slugs, labels }: { slugs: ProjectSlugs; labels: WidgetFormLabels }): ReactElement {
const create = useWrite<ProjectSlugs & { name: string }, unknown>("widgets.widget.create", { org: slugs.org })
const submit = async ({ name }: FormValues): Promise<void> => {
await create.mutate({ ...slugs, name: name ?? "" })
// Live collection or route loader invalidation updates the list without location.reload()
}
return (
<FormLayout
submitLabel={labels.submit}
onSubmit={submit}
describeError={(error) => labels.failed.replace("{detail}", describeError(error))}
>
<TextField name="name" label={labels.name} required maxLength={120} />
</FormLayout>
)
}
```
### A live list
A list that follows other members' changes without a reload is a **collection**. Three additions make one:
1. A batch-get read procedure beside the list, under the same permission, that takes `ids` (at most 100) and
answers `{ items }`; an id it does not answer is gone for that caller. Give it an isolation fixture like
any other procedure.
```ts
export const widgetGetMany = procedure("widgets.widget.getMany")
.kind("read").scope("project").permission("widgets.widget.read")
.input(z.object({ ...inProject, ids: z.array(z.string()).min(1).max(100) }))
.output(z.object({ items: z.array(widgetViewSchema) }))
.contract()
```
2. The declaration in the manifest (`pnpm kit check` refuses one without `getMany`; `buildRegistry` checks the
procedures exist, are reads at the collection's scope and share its permission):
```ts
collections: [{
entity: "widgets.widget", table: "widgets_widget", key: "widgetId", scopeLevel: "project",
read: "widgets.widget.read", list: "widgets.widget.list", getMany: "widgets.widget.getMany",
}],
```
The migrate job attaches the change capture to `widgets_widget` itself (a migration named
`widgets/live-capture-widget`); there is no SQL to write.
3. The page renders the first page on the server and hands it to a client island, which keeps it live:
```tsx
"use client"
import { liveCollection, useDataClient, useLive } from "@conusai-kit/ui/data"
export function LiveWidgets({ slugs, seed }: { slugs: ProjectSlugs; seed: WidgetView[] }) {
const client = useDataClient()
const [widgets] = useState(() => liveCollection<WidgetView>({
client, org: slugs.org, topic: { ws: slugs.workspace, project: slugs.project },
entity: "widgets.widget", key: "widgetId", version: "version", seed,
list: { procedure: "widgets.widget.list", input: { ...slugs }, rows: (o) => (o as { widgets: WidgetView[] }).widgets },
getMany: { procedure: "widgets.widget.getMany", input: { ...slugs }, rows: (o) => (o as { items: WidgetView[] }).items },
}))
const { data, isReady } = useLive(widgets, (q, rows) => q.from({ w: rows }).orderBy(({ w }) => w.createdAt, "desc"))
return <WidgetList widgets={isReady ? data : seed} />
}
```
A write through `useWrite("widgets.widget.update", { org: slugs.org })` resolves once the stream has seen it,
so every live list already shows the change. Live lists run on every profile; on Convex the change is recorded by
the writing mutation and `pnpm convex:gen` adds its log table, so there is nothing to add there either.
`useLiveNotes` in `modules/notes/ui/live-notes.ts` is the worked example; `modules/agent/ui/live-runs.tsx` and
`modules/knowledge/ui/live-documents.tsx` are two more.
4. A view that joins other tables -- a document with its lines and the counts of each status -- is not a row of
the collection. Read its procedure with `useRead(procedure, input, { live: { org, topic, entities: ["widgets.widget"] },
initial: seed })`: it is read again whenever a widget changes, and the component that made a write calls the
read's `refetch` once the write has answered (`modules/documents/ui/live-documents.ts`).
A module that declares a collection never re-runs its page's loader after a write (`navigation.refresh()`,
`router.invalidate()`), and no module reloads the document: `test/live-writes.test.ts` refuses both, by file and
line. A list whose rows live in a business service (CRM, Catalog, Inventory) is not in the app's live log, so
those screens still re-run their loader after a write.
## 9. A message catalogue
Every label is a message, in both languages. `lt.ts` is typed from `en.ts`, so a missing key is a type error,
and the scaffolded test fails when the two differ. Lithuanian has four plural forms (`one`, `few`, `many`,
`other`); the example has the three a count of things needs:
```ts
// modules/widgets/i18n/en.ts
// English messages of the widgets module (spec §10.4). Every string a page of this module shows is a key
// here and in lt.ts; `__tests__/module.test.ts` fails when the two differ.
export const en = {
title: "Widgets",
intro: "Widgets are the example this guide adds.",
name: "Name",
add: "Add widget",
empty: "No widgets yet.",
count: "{count, plural, one {# widget} other {# widgets}}",
} as const
```
```ts
// modules/widgets/i18n/lt.ts
// Lietuviški widgets modulio pranešimai. Kiekvienas puslapio tekstas yra raktas čia ir en.ts faile.
import type { en } from "./en.js"
type Messages<T> = { [K in keyof T]: T[K] extends string ? string : Messages<T[K]> }
export const lt: Messages<typeof en> = {
title: "Valdikliai",
intro: "Valdikliai yra pavyzdys, kurį prideda šis vadovas.",
name: "Pavadinimas",
add: "Pridėti valdiklį",
empty: "Valdiklių dar nėra.",
count: "{count, plural, one {# valdiklis} few {# valdikliai} other {# valdiklių}}",
}
```
## 10. Test and gate
```bash
pnpm vitest run modules/widgets test/modules/widgets # no database
pnpm format # Biome
pnpm openapi # a new procedure changes openapi.json; a test fails on drift
pnpm gate # typecheck, lint, boundaries, tests (isolation included), build
pnpm kit gate # the same, in a clean copy, installed from the registry
pnpm e2e # Chromium and axe against the whole app (Docker for Postgres)
```
```ts
// test/modules/widgets/widgets-flow.test.ts
// The widgets module against the whole app on the in-memory data source
// (test/support/notes-app.ts wires the registry, the queue and the outbox exactly as the apps do): a
// widget is created, listed, tidied by its job, audited and metered -- and a viewer may read and not write.
import { describe, expect, it } from "vitest"
import { makeNotesApp } from "../../support/notes-app.js"
describe("the widgets module", () => {
it("creates a widget, lists it, and tidies its name in the job it enqueued", async () => {
const app = makeNotesApp()
const project = await app.seedProject("acme", "owner@acme.test")
const created = (await project.call.callByName("widgets.widget.create", {
...project.slugs,
name: " Left handed widget ",
})) as { widgetId: string }
expect(app.queue.jobs.map((job) => job.job)).toEqual(["widgets.tidy"])
expect(await app.runJobs()).toBe(1)
const listed = (await project.call.callByName("widgets.widget.list", project.slugs)) as {
widgets: Array<{ widgetId: string; name: string }>
}
expect(listed.widgets).toEqual([
expect.objectContaining({ widgetId: created.widgetId, name: "Left handed widget" }),
])
})
it("declares the audit event and the usage meter the transport records for a create", () => {
const app = makeNotesApp()
const route = app.registry.router["widgets.widget.create"]
expect(route?.audit).toBe("widgets.widget_created")
expect(route?.usage).toBe("widgets_created")
expect(app.registry.audit["widgets.widget_created"]).toBe("widgets")
})
it("lets a viewer read and refuses them a write", async () => {
const app = makeNotesApp()
const project = await app.seedProject("acme", "owner@acme.test")
const viewer = await app.addMember(project, "viewer@acme.test", "viewer")
await expect(viewer.call.callByName("widgets.widget.list", project.slugs)).resolves.toEqual({ widgets: [] })
await expect(
viewer.call.callByName("widgets.widget.create", { ...project.slugs, name: "Nope" }),
).rejects.toMatchObject({ problem: { status: 404 } })
})
})
```
Your tests are yours, and live in two places: `modules/<id>/__tests__/` for what needs only the module (use cases against `createMemoryDataSource`, the migration, the isolation fixtures), and `test/modules/<id>/` for what needs the whole app, such as the flow below. Both are exempt from the template's licence header and its `TEMPLATE-FILES` list (`node scripts/licence.mjs --check`); a file you add anywhere else under `test/` or `apps/` is a template file and fails that check. A test under `modules/` may not import `test/support/notes-app.ts`, which reaches the worker's system actor, and the boundary gate says so. `test/support/notes-app.ts` is the whole app on the in-memory data source, the one to build on.
For a screen, copy the shape of `e2e/notes.spec.ts`: sign in with `signIn`, make an organization with
`makeOrganization`, open `/<org>/<workspace>/<project>/widgets`, and call `violations(page)` on every state.
## Demo data for your module
`apps/worker/src/seed.ts` writes the demo data of `pnpm dev` with the kit's own procedures. A step added to
`seedDemo` that calls your module's procedures as one of the demo people is the one edit outside `modules/` that
is meant for you; keep it idempotent (ask whether the row is there first).