This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
<!-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 -->
|
||||
# Convex backups
|
||||
|
||||
The Convex profile runs a separate `backup` service. Compose keeps its Node runner alive; it invokes
|
||||
the supported `convex export` admin command, includes file storage, streams the archive to the project's ConusAI
|
||||
Blob service, and keeps the newest 30 archives. The interval and retention are configurable with
|
||||
`CONVEX_BACKUP_INTERVAL_HOURS` and `CONVEX_BACKUP_RETENTION`. The provider injects
|
||||
`CONVEX_SELF_HOSTED_URL` and `CONVEX_SELF_HOSTED_ADMIN_KEY`; the latter is server-only and is also
|
||||
used by the one-shot `convex-deploy` service before web starts. That service places the scoped
|
||||
`CONUSAI_API_URL`, `CONUSAI_API_TOKEN`, and `CONUSAI_ORG_SLUG` in the Convex backend env through a
|
||||
0600 temporary file before deploying functions. It uses the platform's project service credential,
|
||||
not a human owner credential. The provider provisions the backend but does not deploy app functions.
|
||||
The platform does not back up Convex deployments (AT-D16);
|
||||
monitor the backup service logs and Blob usage as part of operating the app.
|
||||
|
||||
Archives are stored at `convex-backups/<project-id>/<UTC timestamp>.zip`. An archive contains
|
||||
table documents and, when requested, file storage. It does not contain the deployed functions,
|
||||
schema, environment variables, or pending scheduled functions. Keep the source revision and
|
||||
deployment configuration available separately, and run restore drills against a disposable
|
||||
deployment before relying on a backup for recovery.
|
||||
|
||||
Restore into a new, empty Convex deployment before directing application traffic to it:
|
||||
|
||||
1. Pause the app's `backup` service and obtain the target deployment's admin key.
|
||||
2. Download the selected archive from Blob to a trusted operator machine. Keep it private: it
|
||||
contains the app's complete Convex data and uploaded file contents.
|
||||
3. Deploy the same app revision to the empty target first. This installs the app schema and its
|
||||
components (including the audit component tables) without adding application rows. Let the
|
||||
`convex-deploy` service install the target's scoped platform environment before deploying.
|
||||
4. Point the Convex CLI at that now-initialized, still-empty deployment and import the archive:
|
||||
|
||||
```sh
|
||||
CONVEX_SELF_HOSTED_URL="$TARGET_CONVEX_URL" \
|
||||
CONVEX_SELF_HOSTED_ADMIN_KEY="$TARGET_CONVEX_ADMIN_KEY" \
|
||||
pnpm exec convex import ./convex-export.zip
|
||||
```
|
||||
|
||||
5. Verify the notes list and attached files in the app before switching traffic. Resume the backup
|
||||
service after the target is serving traffic.
|
||||
|
||||
The import intentionally has no replace flag: it fails closed if the target already contains
|
||||
conflicting table data. Install the app schema/components first, then import into the empty app
|
||||
deployment and verify it before switching traffic. The
|
||||
profile gate exercises export, Blob upload, retention pruning, and import into an empty local
|
||||
backend. Convex currently documents CLI data import as a beta feature, so keep an independent
|
||||
recovery path and run restore drills before production use. See Convex's current
|
||||
[backup and restore guide](https://docs.convex.dev/database/backup-restore)
|
||||
and [data import documentation](https://docs.convex.dev/database/import-export/import) for archive
|
||||
contents, import behavior, and limitations.
|
||||
@@ -0,0 +1,620 @@
|
||||
<!-- 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).
|
||||
Reference in New Issue
Block a user