ConusAI vendor v0.1.0-dev.20261027+9b1131fb
ci / gate (push) Waiting to run

This commit is contained in:
ConusAI vendor v0.1.0-dev.20261027+9b1131fb
2026-10-27 18:05:17 +00:00
commit 3102273ec7
538 changed files with 80203 additions and 0 deletions
+49
View File
@@ -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.
+620
View File
@@ -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).