621 lines
26 KiB
Markdown
621 lines
26 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
|
|
```
|
|
|
|
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` | `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 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 event that must happen exactly once even if the request's process dies -- a note's audit event and usage
|
|
fact are the example -- publish a domain event in the same transaction and subscribe to it: `modules/notes`
|
|
(`core/subscribers.ts`) is the worked pattern.
|
|
|
|
## 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
|
|
|
|
A server component per path under `/<org>/<workspace>/<project>/widgets`, with no route file:
|
|
|
|
```tsx
|
|
// modules/widgets/ui/pages.tsx
|
|
// The widgets module's pages, as the one server component the web shell mounts under
|
|
// `/<org>/<workspace>/<project>/widgets` (apps/web's module mount reads it from `modules/index.ts`).
|
|
// 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 } from "@conusai-kit/ui"
|
|
import type { ReactElement } from "react"
|
|
import { widgetList } from "../contract/index.js"
|
|
import { WidgetForm } from "./widget-form.js"
|
|
|
|
export type WidgetsPage = ProjectPage<TransportCaller, I18n, Container>
|
|
|
|
export const widgetsPage: WidgetsPage = async ({ slugs, caller, i18n }): Promise<ReactElement> => {
|
|
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`widgets.${key}`, values)
|
|
const { widgets } = await caller.call(widgetList, slugs)
|
|
return (
|
|
<>
|
|
<h1>{t("title")}</h1>
|
|
<p>{t("intro")}</p>
|
|
<WidgetForm slugs={slugs} labels={{ name: t("name"), submit: t("add"), failed: i18n.message("app.failed") }} />
|
|
{widgets.length === 0 ? (
|
|
<p>{t("empty")}</p>
|
|
) : (
|
|
<>
|
|
<p>{t("count", { count: widgets.length })}</p>
|
|
<ul>
|
|
{widgets.map((widget) => (
|
|
<li key={widget.widgetId}>{widget.name}</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
```
|
|
|
|
Add it to `modules/index.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 asks `useTransport()` of `@conusai-kit/ui` for `call`, which carries the session and the CSRF
|
|
token. 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. A client component asks `useTransport()` of @conusai-kit/ui for `call`: the one API
|
|
// path every caller uses, with the session, the CSRF token and the problem+json refusals handled by
|
|
// the shell. Every label is a message the server passed in.
|
|
|
|
import { type ProjectSlugs, useTransport } from "@conusai-kit/ui"
|
|
import { type FormEvent, type ReactElement, useId, useState } 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 { call, describe } = useTransport()
|
|
const nameId = useId()
|
|
const [problem, setProblem] = useState<string | undefined>(undefined)
|
|
|
|
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
|
event.preventDefault()
|
|
const name = String(new FormData(event.currentTarget).get("name") ?? "").trim()
|
|
if (name.length === 0) return
|
|
setProblem(undefined)
|
|
try {
|
|
await call("widgets.widget.create", { ...slugs, name })
|
|
window.location.reload()
|
|
} catch (error) {
|
|
setProblem(labels.failed.replace("{detail}", describe(error)))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={submit}>
|
|
<div className="kit-field">
|
|
<label htmlFor={nameId}>{labels.name}</label>
|
|
<input id={nameId} name="name" type="text" required maxLength={120} />
|
|
</div>
|
|
<button type="submit">{labels.submit}</button>
|
|
{problem ? (
|
|
<p className="app-error" role="alert">
|
|
{problem}
|
|
</p>
|
|
) : null}
|
|
</form>
|
|
)
|
|
}
|
|
```
|
|
|
|
## 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).
|