721 lines
33 KiB
Markdown
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).
|