ConusAI vendor v0.1.0-beta.114
ci / gate (push) Waiting to run

This commit is contained in:
ConusAI vendor v0.1.0-beta.114
2026-11-14 09:56:45 +00:00
commit 1ce8d23c35
867 changed files with 122104 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.
+279
View File
@@ -0,0 +1,279 @@
<!-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 -->
# Business service modules
ConusAI Cloud runs business services next to the platform: Inventory, CRM, Catalog, Activities, Invoicing,
Ledger and Archive. Each keeps its data in your organization on the platform and is called through the
platform API with a generated client, `@conusai-sdk/<service>`. This template ships optional modules that use
them -- `modules/inventory` (items, stock, reservations), `modules/crm` (parties and how to reach them),
`modules/catalog` (products and their imports) and `modules/archive` (documents, versions and retention) --
plus `modules/activities` (project tasks, discussion and timeline). All ship **off**.
Source status: request-bound adapters and simulated contract checks exist. The gateway-backed
request composition, final release and live acceptance are still being integrated. Including a
module does not establish readiness or authorize processing.
## Turn a module on
```bash
pnpm kit module:on inventory # or crm, catalog, archive, activities; `pnpm kit module:off <id>` turns it off again
pnpm openapi # the module's operations join openapi.json
pnpm format # reflow edited configuration before the lint gate
pnpm gate
```
`module:on` does two things and prints what it changed:
1. adds the id to `selected` and `everAdopted` in `modules/inclusion.json`: the module joins the
registry, its procedures the API, its page the project's navigation;
2. asks the deployment token for the service's scopes in `conusai.json` (`token.permissions`, and every
supported profile's copy (the Convex preview is refused)):
| Module | Scopes |
|---|---|
| `inventory` | `inventory.items:read`, `inventory.items:write`, `inventory.stock:read`, `inventory.reservations:read`, `inventory.reservations:write` |
| `crm` | `crm.parties:read`, `crm.parties:write`, `crm.contacts:write` |
| `catalog` | `catalog.products:read`, `catalog.imports:read`, `catalog.imports:write` |
| `activities` | `activities.activities:read`, `activities.activities:write`, `activities.comments:write` |
| `archive` | `archive.documents:read`, `archive.documents:write`; hold, retention and disposal administration need separate reviewed grants |
The deployment requests the scopes; an organization owner/admin must approve the permission delta separately. The service must also be **enabled for the
deployment's organization** on the platform (Settings, Modules, or `conusai modules enable <service> --org
<slug>`); until it is, the module's page says so instead of failing.
`module:off` removes selection while retaining historical custody and existing scope requests for
cleanup. It does not revoke an approved credential. The request ownership record remains
`.conusai/module-scopes.json`; customer-owned and historical untracked requests remain untouched.
Physical source stays and is type-checked either way. Removing never-adopted source requires review;
an adopted module must retain its original schema and rights/retention implementation.
## What the modules do
| Module | Page | Procedures (kind, app permission) |
|---|---|---|
| `inventory` | `/<org>/<workspace>/<project>/inventory`: the organization's items, a form that adds one, this project's open reservations with a release button; `/inventory/<itemId>`: the item's stock by location and a form that reserves some for this project | `inventory.item.list` (read, `inventory.item.read`), `inventory.item.create` (effect, `inventory.item.write`), `inventory.item.availability` (read, `inventory.item.read`), `inventory.reservation.list` (read, `inventory.item.read`), `inventory.stock.reserve` and `inventory.stock.release` (effect, `inventory.stock.reserve`) |
| `crm` | `/<org>/<workspace>/<project>/crm`: the organization's parties and a form that adds one; `/crm/<partyId>`: the party's contact points and a form that adds one | `crm.party.list`, `crm.party.get` (read, `crm.party.read`), `crm.party.create`, `crm.contact.add` (effect, `crm.party.write`) |
| `catalog` | `/<org>/<workspace>/<project>/catalog`: the organization's products and a search; `/catalog/<productId>`: one product; `/catalog/imports`: the imports so far and a form that applies a CSV file | `catalog.product.list`, `catalog.product.search`, `catalog.product.get`, `catalog.import.list` (read, `catalog.product.read`), `catalog.import.apply` (effect, `catalog.product.write`) |
| `archive` | `/<org>/<workspace>/<project>/archive`: the organization's documents and a form that registers one; `/archive/<documentId>`: the document's versions, a form that adds one, its legal hold and disposal; `/archive/retention`: the retention classes, a form that adds one, and what is due for disposal | `archive.document.list`, `archive.document.get`, `archive.retention.list`, `archive.disposal.list` (read, `archive.document.read`), `archive.document.register`, `archive.version.add` (effect, `archive.document.write`), `archive.document.hold`, `archive.document.dispose`, `archive.retention.create` (effect, `archive.retention.manage`) |
`inventory.item.read`, `crm.party.read`, `catalog.product.read` and `archive.document.read` are held by every
member (viewer); `archive.retention.manage` by administrators, because a hold, a disposal and a retention policy
change what the organization keeps; the others by editors. A change is an `effect` procedure: it calls a service and
writes nothing of this app's own, so none of these modules has a table, a migration, a job or an event.
**Reservations belong to a project.** The service holds a reservation against a reference, and the module
uses the project's: `conusai://app-partitions/{binding UUID}/projects/{resolved app project UUID}`. A project lists only its
own reservations, and releasing another project's answers not-found. A form sends a request id the module
turns into the service's idempotency key, so a retried request reserves (or creates) once.
**An import is one file.** The form reads a CSV file with the columns `code`, `name` and `base_unit` (`description`
and `revision` are optional), hashes it, and sends it in chunks of 200 rows, each with the import's revision from
the chunk before. A row the service refuses is listed with its reason and the others stay applied; sending the same
file again applies nothing twice. To replace a product that exists, give its revision, or the row is refused as a
conflict.
**A document is made from a file the project holds.** `archive.document.register` and `archive.version.add` take a
`fileId` of the files module. The hash, size and type are the ones the store measured, the service keeps a Blob
reference that names the file and nothing else of it, and a file of another project or organization is simply not
there. A disposed document changes no more. The files module does not know a file is archived: deleting the file
there removes the bytes a version points at, so an app that archives should not let members delete those files.
**A person can be linked to a member.** "This is me" on the party form stores the member's subject reference
(`actorRef(bindingId, memberId)` of `@conusai-kit/business`) as an identifier of scheme
`conusai.subject`. It must match the gateway's attributed app actor. The partition-aware privacy
worker must resolve that exact member and binding; an unsupported callback leaves the obligation
pending and cannot certify erasure.
## Your own codes and fields on a party
A party can carry what the service has no field for, and your own code for it.
- **The credential is scoped to an immutable app/environment binding.** The server resolves current
app membership and permission before preparing the request. The gateway validates the deployment
credential, binding and attributed actor. Rotating a credential does not remap retained data.
- **Use the binding's namespace.** An extension uses `app_` followed by the binding UUID's 32 lower-case
hexadecimal digits. The service validates that namespace against the signed partition. Request
`crm.extensions:write` separately and obtain an explicit grant before defining a field; module
inclusion does not grant this administrative scope. Existing app fields need a reviewed adoption
plan; do not rename or copy customer records automatically.
- **Your code is an identifier.** A scheme such as `app.<binding namespace>.party_code` belongs to that
partition. Its code is unique among that partition's active parties. Use exact source values;
another app tenant or environment has independent uniqueness and cannot adopt this namespace.
- **A person you store is a reference.** Use `actorRef(bindingId, resolvedMemberId)` for an attributed
app member, subject to the service's typed field and subject rules. Raw names, addresses and user IDs
in arbitrary extension text need explicit subject handling; they cannot be inferred safely by an
erasure worker.
Importing a file takes `crm.parties:import`, reading its ledger `crm.imports:read`, and roles
`crm.roles:write`.
## Where the data is, and who sees it
The serving path requires the current server-prepared `RequestBusinessBinding`. It includes the
immutable binding, app tenant, environment and verified member attribution. A missing or unverified
binding is refused before network dispatch; no organization-wide environment fallback or deployment
singleton is created. The gateway and service must enforce the same tuple under nonowner database
roles before an app flow can be accepted. Explicit test ports and synthetic readiness observations
are isolated test fixtures, not proof of production tenant isolation. Existing service data is
retained; legacy organization-only rows are not silently reassigned to an app partition.
CRM parties of kind person and their contact points are personal data, kept and erased by the platform's CRM
service; the app stores none of it. An archived document's title and its versions are the Archive service's. The
module registers a document with no subject, so the platform's export or erasure of a person does not reach it: do
not put a person's name in a title unless your app also records them as the document's subject.
## How a call is made
Each business module declares `transport: "business"` explicitly. Native Blob, OCR, AI and
Knowledge requirements declare `transport: "platform"` and use their own observer and scope grammar.
Existing apps must review and add this discriminator to their own requirements before adopting
the matching kit release; package updates do not rewrite their module code or grant permissions.
Each module has a capability-limited port and a generated SDK adapter. Without an injected test
port, the adapter resolves the prepared request binding from the authenticated dispatch's child
container. It pins the credential, binding and actor, refuses another origin and redirects, and
preserves the SDK's exact int64 codec. A `read` procedure receives only read operations. A manifest
states supported API versions, profiles and exact per-operation scopes; an unavailable observation,
provider or dependency is a refusal rather than implicit readiness.
The app composition creates these hooks lazily: modules that remain off need no extra environment
metadata, and a missing business-service configuration refuses only the affected dispatch.
`@conusai-kit/business` is a runtime dependency, including after production dependency pruning.
A refusal of the service becomes a typed error through the kit, with no mapping in the module: the adapter throws
`businessAppError(toBusinessError(error, response))` of `@conusai-kit/business`. An admission refusal is kit-core's
`service_<reason>` (below), which the page shows in place of itself with `serviceNotice(error,
businessServiceNotices(i18n.locale))`. Every other refusal is `business_<refusal>` with the service's status:
`business_not_found` (404), `business_invalid` (400), `business_conflict` and the service's own reasons
(`business_insufficient_stock`, `business_unbalanced`, `business_legal_hold`, ...; 409), and
`business_unavailable` (503) when the service did not answer. A form shows the sentence for any of them with
`serviceNotice(error, labels.refusals)?.text`, where the loader put `businessRefusalNotices(i18n.locale)` (one
sentence per refusal code in LT and EN, a module's own wording passed as overrides) in the labels. The admission codes:
| Code | Status | When |
|---|---|---|
| `service_not_installed`, `service_not_enabled` | 404 | Install the service or intentionally enable it for the organization. |
| `service_permission_missing`, `service_not_entitled`, `service_partition_revoked` | 403 | Review the binding's approved scopes, entitlement or revocation with an administrator. |
| `service_partition_missing`, `service_incompatible`, `service_profile_unsupported` | 409 | Configure the exact app/environment binding or review the supported release/profile. |
| `service_configuration_missing` | 503 | Complete the required provider/service configuration. |
| `service_dependency_unready`, `service_migrating`, `service_degraded` | 503 | Inspect the dependency, migration or health diagnostics. |
| `service_observation_missing`, `service_observation_stale`, `service_generation_stale`, `service_signature_rejected` | 503 | Refresh verified status or ask the operator to reconcile the release. |
Remote error details are discarded, and a problem type a service does not answer with is `about:blank`; the UI
uses the kit's bounded translated sentences. Amounts, quantities and dates are exact values of
`@conusai-kit/money` and are shown through `i18n.money`, `i18n.quantity` and `i18n.day`
([docs/how-to/use-money-and-refusals-in-an-app.md](../../../docs/how-to/use-money-and-refusals-in-an-app.md)).
## Testing
`fakeBusinessServices({ inventory, crm })` of `@conusai-kit/testing` simulates the gateway and services as a
`fetch`: bind a real client to it (`businessClient(env).setConfig({ fetch: fake.fetch })`) and the port to the
client (`sdkInventoryService`, `sdkCrmService`). `modules/<id>/__tests__/app.ts` does exactly that; the
modules' tests drive every procedure, the not-enabled and missing-scope states, a viewer's refused write, and
the app membership isolation suite. Injected ports do not establish external partition enforcement.
Activities additionally tests generated wire encoding, prefixed resource IDs, opaque cursors, exact
revision digits, comment receipts and preservation of untouched fields using synthetic HTTP.
Real gateway/service/runtime-role/browser/API/MCP acceptance remains mandatory and separate.
`pnpm dev` runs against the fake platform, which serves the business gateway and the services the way the
real gateway does: binding lookup, the partition admission of each operation, and forwarding to a fake of
CRM, Catalog, Inventory, Archive or Activities in the binding's own partition. The seed binds the demo
organizations with the scopes the selected modules declare; an organization created later shows
`service_partition_missing` until it is bound, as in Cloud. `test/business-fake-platform.test.ts` runs a
procedure through that path. Invoicing and Ledger have no fake: their pages show `service_not_installed`
there. The platform's guide is `docs/how-to/test-business-services-locally.md` in the conusai-cloud repository.
## Invoicing adoption
`kit module:on invoicing` adds the optional organization-scoped invoice client. It requests only
`invoicing.invoices:read`, `invoicing.invoices:write` and `invoicing.series:read`. An administrator
must intentionally approve `invoicing.invoices:issue`, `invoicing.invoices:void` or
`invoicing.series:write` before the corresponding fiscal/configuration controls can succeed.
The app's fiscal actions also require its administrator role. Credit uses invoice-write scope
but remains an app administrator operation. No privacy/delete scope is requested by inclusion.
Configure active numbering series and actual seller/buyer terms before drafting. CRM references
are non-authorizing links; no CRM service access is inferred. Invoice data belongs to the app
organization, including when its page is opened from a project. Existing applications adopt the
new module and updated package pins explicitly, reviewing their own code; an update does not
replace their modules, grant new scopes, enable the service or send invoices automatically.
Issued data and numbering survive module disablement and require service-owned correction,
retention and recovery procedures. See [the module](../modules/invoicing/README.md).
## Optional inclusion and retained data
The shipped starter selects Agent (including the assistant), Guides and Support and records all
three in `everAdopted`. Documents, Knowledge, Activities, Archive, Catalog, CRM, Inventory,
Invoicing and Ledger start unselected and never adopted. Physical source stays available. A
separately authored new app may begin with empty selection and adoption lists; that valid source
contract is not the shipped default or a reset mode for an existing app. From the app root,
explicitly select or stop a module:
```sh
pnpm kit module:on documents
pnpm kit module:off documents
```
The commands update `modules/inclusion.json` and deployment scope requests atomically. Review the
diff, regenerate OpenAPI with `pnpm openapi`, run the app gates and deploy the reviewed revision.
Selection requests only that module's operation scopes. It does not approve permissions, enable a
Cloud organization/service, configure a provider or authorize costs. Configure those separately.
The shipped starter already declares the native Guides bundle. When enabling Guides from an off
configuration, publication needs a separate explicit deployment change (`guides.bundle` and the
publishing service setting); selecting Guides alone does not restore publication. Support channel clients are
lazy. Agent still needs independent model/tool policy and approval.
`selected` controls interactive/processing admission; `everAdopted` records custody under the
original module identity and never shrinks automatically. Off keeps original schema/data and
account-rights hooks, Knowledge removal and Agent/assistant retention. It keeps existing scope
requests, including broad grants that cleanup currently needs, and adds no scope. It does not
revoke an already approved credential. Do not delete data, migrations, the custody record or
cleanup credentials merely because selection is off. Scope narrowing/removal requires separately
verified obligation settlement and supported privacy APIs.
Old apps without this versioned record refuse automated toggling. Review their existing module
composition, scope ownership, data and migrations, then explicitly adopt both the new composition
and accurate selected/ever-adopted lists on a review branch. Do not seed an empty custody list for
an app that previously used a module. Ordinary package/template upgrade does not perform this
sensitive adoption. Convex remains preview; its separate composition needs coordinated adoption
and unsupported module selection remains refused.
`module:off guides` also removes the top-level bundle declaration and per-service Guides publish
roles from every deployment configuration, preserving unrelated fields and formatting. Re-enable
requests runtime scopes again but does not restore publishing: opt in to publication separately.
## Human-triggered business jobs
Declare the job's exact service scopes on its owning module's `serviceRequirements`, and configure
`createPostgresWorker` with `businessJobIntentPermissions: { "example.dispatch":
["example.dispatch.send"] }`. Permissions come from trusted worker composition, not payloads or
business service scopes. The Postgres worker owns the business job's retained claim, prepares its
binding and admits current grants before invoking the handler. These handlers do not call
`ctx.execution.run` again. The binding remains available through `requestBusinessBinding`.
Business clients recheck the original member's current intent, partition, grants, claim and real
transaction fence before every request, and combine request cancellation with claim loss. For a
separate mail/provider adapter, immediately before sending call
`assertClaimedJobBusinessAdmission(ctx.container)` from `@conusai-kit/business/server` and use
`AbortSignal.any([ctx.execution.signal, timeoutSignal])`. Keep stable idempotency keys and retries;
already-started effects cannot be undone. Raw `fetch` calls are not automatically guarded.
Only project-scoped human-triggered Postgres business jobs are supported. Missing original users,
revoked membership/permissions/grants/bindings, foreign scope, unsupported profile and missing or
stale claim/fence refuse. Native jobs without business declarations keep their existing admission
and execution contract. See the [canonical API and proof limits](../../../docs/reference/app-kit.md#claimed-business-jobs-on-postgres).
### Source-unit variant verification
From the platform checkout, run
`node scripts/vendor/test-template-module-variants.mjs templates/app /absolute/sdk/node_modules /new/evidence/directory`
with a compatible installed SDK/kit dependency tree. The helper constructs a separate new
never-adopted source fixture with base-only permission requests before writing any adoption record;
it does not erase an existing app's history. It executes the original admission, help/client and
provider-construction assertions, including invalid-record and accidental-provider negative controls.
Both fixture deployment configurations must pass the original generated platform-schema assertion.
Each child suite has a two-minute deadline; timeout is a failed run, with no automatic retry.
A second adopted fixture uses the public `kit module:off guides` command and verifies that Guides
publication disappears across deployment profiles while adoption history remains unchanged.
These are source-unit configuration checks with synthetic provider ports, not browser, database
or deployed-platform acceptance. The shipped starter has no command to reset adoption history.
+720
View File
@@ -0,0 +1,720 @@
<!-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 -->
# Start your domain
Where to add a module, an entity, a migration, a permission, a procedure, an audit event, a usage meter, a
job, a page and a message catalogue, with the commands. Everything below was done once, in a copy of this
template, as written here: the module is called `widgets`, it passed `pnpm kit gate`, and every code block is
a file of it.
You write modules. You do not edit `apps/`: the web shell and the worker find a module from
`modules/index.ts` and its manifest. (Rules the gates enforce: [AGENTS.md](../AGENTS.md).)
## Before you start
```bash
pnpm install
pnpm dev # Postgres, the fake platform, the demo data, the worker and the web shell
```
During migration or seeding, Ctrl+C or SIGTERM stops that startup command's process group,
including its children, and closes the fake platform before exiting. Cancellation does not start
the next startup step. Shutdown allows 35 seconds for graceful termination (including pg-boss's
30-second grace), then sends SIGKILL to any remaining owned groups and waits up to five more seconds
for termination. A failed startup command retains its nonzero exit code; inability to terminate owned
children is reported as failure. A permission-denied presence probe keeps the group pending until
absence is confirmed or the existing wait expires; it does not extend either deadline.
Sign in at the address it prints (`http://127.0.0.1:3000/auth/dev-login?email=ada@example.test`). The demo
data has Acme Review with a `general` workspace and a project called `Team notes`; a project of your own is
made from the workspace page (`/<org>/general`) by choosing your module in the **Module** list.
## Where each thing goes
| You add | Where | Command |
|---|---|---|
| A module | `modules/<id>/`, listed in `modules/index.ts`, with one screen of records in `ui/resource.tsx` | `pnpm new:module <id> [--uses files,proposals]` |
| An entity (a table) | `modules/<id>/contract/entities.ts`, `defineEntity` | |
| A migration | `modules/<id>/migrations/NNNN_name.sql`, forward only | `pnpm vitest run modules/<id> -u` writes the first |
| A permission | `permissions` of `modules/<id>/module.ts` | |
| A procedure | contract in `contract/procedures.ts`, handler in `adapters/procedures.ts`, rules in `core/` | |
| Its isolation fixture | `modules/<id>/__tests__/isolation.ts` | `pnpm test:isolation` |
| An audit event | `.audit("<id>.<event>")` on the contract, and `audit: [...]` in the manifest | |
| A usage meter | `.usage("<meter>")` on the contract, and `usage: { meters: [...] }` in the manifest | |
| A domain event and its subscriber | `defineEvent` in `contract/events.ts`, `tx.publish` in the use case, `subscribe(event, handler)` in `events.subscribes` of the manifest | |
| A job | `jobs` of the manifest, enqueued by a write procedure | |
| A page | `ui/pages.tsx`, one line in `modulePages` of `modules/index.ts`, a `ui.nav` entry | |
| A message catalogue | `modules/<id>/i18n/en.ts` and `lt.ts`, declared as `i18n: { en, lt }` | |
| The API document | `openapi.json` | `pnpm openapi` |
## 1. The module
```bash
pnpm new:module widgets --uses files
```
`--uses files` because the job below reads the scope of its run through the files module's token (step 7);
use `proposals` for a module that proposes changes a person approves. The command writes `modules/widgets/`
(`module.ts`, `index.ts`, `contract/`, `core/`, `i18n/`, a test), adds the module to `modules/package.json` and
the catalogue when it needs a kit module, and appends it to `domainModules` in `modules/index.ts`. A module with
no procedure passes the gate as it is; the steps below fill it in.
## 2. An entity
```ts
// modules/widgets/contract/entities.ts
// The tables of the widgets module. One, project-owned: `tenancy: "project"` gives the table its
// `org_id`, `workspace_id` and `project_id` columns and the row-level security policy on `org_id`.
import { defineEntity, idSchema } from "@conusai-kit/data"
import { z } from "zod"
export const widgets = defineEntity({
name: "widget",
table: "widgets_widget",
tenancy: "project",
fields: {
name: z.string().min(1).max(120),
createdBy: idSchema,
},
indexes: [["createdBy"]],
})
/** Every table this module owns, in the order a migration creates them. */
export const WIDGETS_ENTITIES = Object.freeze([widgets])
```
`tenancy: "project"` gives the table `org_id`, `workspace_id` and `project_id` and the row-level security policy;
use `"workspace"` or `"org"` for rows that belong higher up. Fields are JSON types.
## 3. A migration
```bash
pnpm vitest run modules/widgets -u
```
The test in `__tests__/migration.test.ts` snapshots `schemaStatements()` of the module's entities into
`migrations/0001_widgets.sql`, so the SQL is generated from the entity and cannot drift from it:
```ts
// modules/widgets/__tests__/migration.test.ts
// The module's migration is its own file and exactly `schemaStatements()` over its entities (decision
// AT-D20: the entity definitions are the one source of a column, so a hand-written column cannot drift
// from the schema the module validates its rows with). `pnpm vitest run modules/widgets -u` writes
// 0001_widgets.sql from it the first time; once that file has run, it is never edited -- a later change to
// the table is a new numbered file written by hand, and this test then compares the first file alone.
import { schemaStatements } from "@conusai-kit/data-kysely"
import { describe, expect, it } from "vitest"
import { WIDGETS_ENTITIES, widgetsModule } from "../index.js"
/** The statements for these tables alone: `schemaStatements` leads with the kernel's own. */
function tablesOf(entities: Parameters<typeof schemaStatements>[0]): string {
const kernel = schemaStatements([]).length
return `${schemaStatements(entities)
.slice(kernel)
.map((statement) => `${statement};`)
.join("\n\n")}\n`
}
describe("the migration of the widgets module", () => {
it("is its own file, exactly its table", async () => {
await expect(tablesOf(WIDGETS_ENTITIES)).toMatchFileSnapshot("../migrations/0001_widgets.sql")
})
it("is declared by the manifest, so the migrate job finds it, and the table is row-level secured", () => {
expect(widgetsModule.migrations).toEqual({ postgres: "migrations" })
const sql = tablesOf(WIDGETS_ENTITIES)
expect(sql).toContain('create table "widgets_widget"')
expect(sql).toContain('alter table "widgets_widget" force row level security')
})
})
```
Review the file and commit it. The manifest says where the folder is (`migrations: { postgres: "migrations" }`),
and the migrate job applies it after the app's own, in file-name order, and refuses a file that changed after it
ran. A later change to a table is a new numbered file you write (`0002_...sql`); `/api/ready` stays unready
until the database has every file this build ships.
## 4. A permission, and a procedure
Permissions are `<module>.<resource>.<action>` with the lowest role that holds them, declared in the manifest
(step 7 shows it whole). A procedure declares its kind, scope and permission, takes slugs and never an id of a
parent, and is a contract first:
```ts
// modules/widgets/contract/procedures.ts
// The procedure contracts of the widgets module: schemas and names, no handler and no port. Each
// declares its kind, its scope and its permission; the kernel checks all three before a handler runs.
// The input carries slugs, never an id of a parent: the resolver's own reads are the membership check.
import { procedure } from "@conusai-kit/core"
import { z } from "zod"
import { WIDGET_CREATED, WIDGETS_CREATED_METER } from "./constants.js"
const inProject = { org: z.string(), workspace: z.string(), project: z.string() }
export const widgetViewSchema = z.object({
widgetId: z.string(),
name: z.string(),
createdAt: z.string(),
})
export type WidgetView = z.output<typeof widgetViewSchema>
export const widgetList = procedure("widgets.widget.list")
.kind("read")
.scope("project")
.permission("widgets.widget.read")
.input(z.object(inProject))
.output(z.object({ widgets: z.array(widgetViewSchema) }))
.contract()
export const widgetCreate = procedure("widgets.widget.create")
.kind("write")
.scope("project")
.permission("widgets.widget.write")
.input(z.object({ ...inProject, name: z.string().min(1).max(120) }))
.output(widgetViewSchema)
// The transport records both once the call has succeeded: one audit event and one usage fact.
.audit(WIDGET_CREATED)
.usage(WIDGETS_CREATED_METER)
.contract()
```
The handler resolves its ports from the container the transport built and calls a use case. A `read` handler is
refused every write port by name -- the list handler below asks for the data source and nothing else:
```ts
// modules/widgets/adapters/procedures.ts
// The handlers of the module's procedures and its job: each resolves its ports from the container the
// transport built and calls one use case. No rule lives here.
import { type Container, implement, type JobContext } from "@conusai-kit/core"
import { DATA_SOURCE, JOB_QUEUE } from "@conusai-kit/data"
import { FILES_SCOPE } from "@conusai-kit/files"
import { widgetCreate, widgetList } from "../contract/index.js"
import { createWidget, listWidgets, tidyWidget, type WidgetScope, type WidgetsDeps } from "../core/index.js"
// A `read` procedure cannot resolve a write port: the kernel refuses `JOB_QUEUE` to it by name. The two
// handlers therefore ask for exactly what they use.
const readDeps = (container: Container): Pick<WidgetsDeps, "data"> => ({ data: container.resolve(DATA_SOURCE) })
const writeDeps = (container: Container): WidgetsDeps => ({
...readDeps(container),
jobs: container.resolve(JOB_QUEUE),
})
export const widgetListImpl = implement(widgetList, ({ ctx }) => listWidgets(readDeps(ctx.container), ctx.scope))
export const widgetCreateImpl = implement(widgetCreate, ({ input, ctx }) =>
createWidget(writeDeps(ctx.container), ctx.scope, { name: input.name }),
)
export const WIDGET_PROCEDURES = [widgetListImpl, widgetCreateImpl]
/**
* The job. The worker rebuilds the scope the job was enqueued in and binds it as `FILES_SCOPE`, the
* kernel's name for "the scope of this run": a module needs no binding of its own to read it.
*/
export async function runTidy(payload: { widgetId: string }, context: JobContext): Promise<void> {
const scope = context.container.resolve(FILES_SCOPE) as WidgetScope
await tidyWidget({ data: context.container.resolve(DATA_SOURCE) }, scope, payload.widgetId)
}
```
The use cases are pure TypeScript over those ports, so they run against the in-memory data source with no
database:
```ts
// modules/widgets/core/widgets.ts
// What the widgets module does, as pure TypeScript over the ports in this file: no driver, no SDK, no
// framework (the boundary gate refuses them), so every rule here is tested against the in-memory
// data source with no database.
import type { TenantScope } from "@conusai-kit/core"
import type { DataSource, JobQueue } from "@conusai-kit/data"
import type { z } from "zod"
import { TIDY_JOB, type WidgetView, widgets } from "../contract/index.js"
/** A project scope: the only level a widget can live at. */
export type WidgetScope = TenantScope & { workspaceId: string; projectId: string }
export interface WidgetsDeps {
readonly data: DataSource
readonly jobs: JobQueue
}
type WidgetRow = z.output<typeof widgets.schema>
const view = (row: WidgetRow): WidgetView => ({ widgetId: row.id, name: row.name, createdAt: row.createdAt })
export async function listWidgets(
deps: Pick<WidgetsDeps, "data">,
scope: WidgetScope,
): Promise<{ widgets: WidgetView[] }> {
// `forScope` is the only way to a repository: the tenant predicate is part of every query it runs.
const page = await deps.data
.forScope(scope)
.of(widgets)
.list({ order: [{ field: "createdAt", direction: "desc" }], limit: 200 })
return { widgets: page.items.map(view) }
}
export async function createWidget(
deps: WidgetsDeps,
scope: WidgetScope,
input: { name: string },
): Promise<WidgetView> {
const row = await deps.data.forScope(scope).of(widgets).insert({ name: input.name, createdBy: scope.actor.userId })
// Long work is a job: the request enqueues it and answers. The key makes a repeated enqueue one job.
await deps.jobs.enqueue(scope, TIDY_JOB, { widgetId: row.id }, { key: `tidy.${row.id}` })
return view(row)
}
/** The job: collapses the whitespace in a widget's name. A widget deleted since is nothing to do. */
export async function tidyWidget(deps: Pick<WidgetsDeps, "data">, scope: WidgetScope, widgetId: string): Promise<void> {
const repo = deps.data.forScope(scope).of(widgets)
const row = await repo.get(widgetId)
if (!row) return
const name = row.name.replace(/\s+/g, " ").trim()
if (name !== row.name) await repo.update(row.id, { name }, row.version)
}
```
### Its isolation fixture
The generated suite (`pnpm test:isolation`) calls every tenant-scoped procedure as a member of one organization
with another's slugs and ids and expects a refusal. A procedure with no fixture fails it by name:
```ts
// modules/widgets/__tests__/isolation.ts
// What the generated tenant-isolation suite (test/isolation.test.ts) needs of this module, from this
// folder: the rows it seeds in each tenant, and one fixture per tenant-scoped procedure. The suite finds
// this file by its place, so there is no line to add outside the module; a procedure with no fixture
// fails the suite by name.
//
// A fixture is built from the *other* tenant's ids: the call is made as a member of one organization
// with another's slugs and ids, and has to be refused -- for being another tenant's, not for naming
// nothing, which is why `seed` leaves a widget in every tenant.
import type { TransportCaller } from "@conusai-kit/core"
import type { IsolationFixture, IsolationTenant } from "@conusai-kit/testing"
/** What a module's isolation file exports: the shape the suite reads, so it needs no import of the suite. */
export interface ModuleIsolation {
seed(
call: TransportCaller,
inProject: { org: string; workspace: string; project: string },
): Promise<Record<string, string>>
fixtures: Record<string, IsolationFixture>
}
const inProjectOf = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug })
export const isolation: ModuleIsolation = {
async seed(call, inProject) {
const widget = (await call.callByName("widgets.widget.create", { ...inProject, name: "Seed widget" })) as {
widgetId: string
}
return { widgetId: widget.widgetId }
},
fixtures: {
"widgets.widget.list": { input: (t) => inProjectOf(t) },
"widgets.widget.create": { input: (t) => ({ ...inProjectOf(t), name: "Intruder" }) },
},
}
```
## 5. An audit event and a usage meter
Both are named on the procedure's contract (`.audit(...)`, `.usage(...)` in step 4) and declared in the
manifest (`audit`, `usage.meters` in step 7). The web transport records them once the call has succeeded: one
event in the platform's audit log, which the organization's administrators read at `/<org>/audit`, and one
usage fact against the ConusAI organization that owns the deployment, with the organization's slug as the
customer. No content goes in an audit event.
```ts
// modules/widgets/contract/constants.ts
// The module's names, in a file with no import: a browser bundle can take them without loading the
// data layer the schemas live beside.
/** An audit event (spec §9.4), declared in the manifest and named by the procedure that causes it. */
export const WIDGET_CREATED = "widgets.widget_created"
/** A usage meter: one fact per widget created, metered against the organization that owns it. */
export const WIDGETS_CREATED_METER = "widgets_created"
/** The job that tidies a widget after it is created. A request enqueues it and answers. */
export const TIDY_JOB = "widgets.tidy"
```
For an effect that must survive the request's process dying -- a note's audit event and usage fact are the
example -- publish a domain event on the same transaction (`tx.publish`, not `forScope(...).publish`) and subscribe
to it: `modules/notes` (`core/subscribers.ts`) is the worked pattern. A rolled-back write publishes nothing. What
delivery guarantees depends on the profile:
- **postgres, mariadb, mongodb** (the worker's outbox relay): each subscriber gets the event at least once, and
its writes through the delivery's own transaction (`JOB_REPOS`) land exactly once. A call to another service is
repeated on a redelivery unless it carries a key derived from the record, as the notes subscribers' audit and
usage calls do.
- **convex** (the cron relay): at least once only. A redelivery can repeat a subscriber's writes, so make them
idempotent.
A subscriber can tell people, too. `modules/notes/adapters/notifications.ts` is the example: `notes.NoteCreated`
puts one inbox row for each of the project's other named members, never the author, in each one's language, through
`deliverNotification` of `@conusai-kit/notifications` on the delivery's own transaction (`NOTIFICATIONS_REPOS`)
with the record-derived key `notes:<noteId>:created`; `modules/notes/__tests__/notification.test.ts` proves one row per member,
none after a second relay pass and none for a rolled-back write. A module subscribes once to an event: the relay
records a delivery per record and module, so a second subscription of the same module would never run, and
`notes` composes its audit event, usage fact and notification in one subscriber.
## 6. A job
Long work is a job. A write procedure enqueues it and answers (`createWidget` in step 4 does); the handler
runs in the worker, in a span of its own, with the scope the job was enqueued in:
```ts
// the handler, in adapters/procedures.ts
export async function runTidy(payload: { widgetId: string }, context: JobContext): Promise<void> {
const scope = context.container.resolve(FILES_SCOPE) as WidgetScope
await tidyWidget({ data: context.container.resolve(DATA_SOURCE) }, scope, payload.widgetId)
}
```
`FILES_SCOPE` is the kernel's name for "the scope of this run", bound once for every module. The test runs it as
the worker would (`app.runJobs()` in `test/modules/widgets/widgets-flow.test.ts`).
## 7. The manifest
```ts
// modules/widgets/module.ts
// The widgets module's manifest (spec §4.1): the one place it registers anything. `modules/index.ts`
// lists the manifest; nothing in apps/ changes.
import { defineModule } from "@conusai-kit/core"
import { repositoryBindings } from "@conusai-kit/data"
import { z } from "zod"
import { runTidy, WIDGET_PROCEDURES } from "./adapters/procedures.js"
import { TIDY_JOB, WIDGET_CREATED, WIDGETS_CREATED_METER, WIDGETS_ENTITIES } from "./contract/index.js"
import { en, lt } from "./i18n/index.js"
const tables = repositoryBindings(...WIDGETS_ENTITIES)
export const widgetsModule = defineModule({
id: "widgets",
version: "0.1.0",
kit: "^0.1.0",
requires: ["tenancy", "files"],
// <module>.<resource>.<action>, with the lowest role that holds it.
permissions: {
"widgets.widget.read": "viewer",
"widgets.widget.write": "editor",
},
procedures: WIDGET_PROCEDURES,
repositories: { postgres: tables, mariadb: tables, mongodb: tables, convex: tables },
// Forward-only SQL, one numbered file per change, run by the migrate job after the app's own.
migrations: { postgres: "migrations" },
jobs: { [TIDY_JOB]: { payload: z.object({ widgetId: z.string() }), handler: runTidy } },
audit: [WIDGET_CREATED],
usage: { meters: [WIDGETS_CREATED_METER] },
// The project-level entry the organization home and the project home list; `widgets` is the segment
// the web shell's module mount routes to `widgetsPage` (ui/pages.tsx), listed in modules/index.ts.
ui: { nav: [{ scope: "project", label: "widgets.title", href: "widgets", permission: "widgets.widget.read" }] },
i18n: { en, lt },
})
```
## 8. A page
One page per first path segment, `/<org>/<workspace>/<project>/widgets`, with no route file. `load` runs on the
server and returns plain data with every label translated; `View` renders that data:
```tsx
// modules/widgets/ui/pages.tsx
// The widgets module's page, on the shell's F10 contract: a loader the web shell runs on the server under
// `/<org>/<workspace>/<project>/widgets` (it finds the page in `modules/pages.ts`), and the view of what it
// returned. Every read is a procedure through the same caller the API uses; every write is a client component.
import type { Container, TransportCaller } from "@conusai-kit/core"
import type { I18n } from "@conusai-kit/i18n"
import { type ProjectPage, type ProjectPageViewProps, projectPage } from "@conusai-kit/ui"
import type { ReactElement } from "react"
import { widgetList } from "../contract/index.js"
import { WidgetForm, type WidgetFormLabels } from "./widget-form.js"
export interface WidgetsData {
readonly title: string
readonly intro: string
readonly empty: string
readonly count: string
readonly form: WidgetFormLabels
readonly widgets: readonly { readonly widgetId: string; readonly name: string }[]
}
export type WidgetsPage = ProjectPage<TransportCaller, I18n, Container, WidgetsData>
export const widgetsPage: WidgetsPage = projectPage({
async load({ slugs, caller, i18n }): Promise<WidgetsData> {
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`widgets.${key}`, values)
const { widgets } = await caller.call(widgetList, slugs)
return {
title: t("title"),
intro: t("intro"),
empty: t("empty"),
count: t("count", { count: widgets.length }),
form: { name: t("name"), submit: t("add"), failed: i18n.message("app.failed") },
widgets: widgets.map(({ widgetId, name }) => ({ widgetId, name })),
}
},
View({ slugs, data }: ProjectPageViewProps<WidgetsData>): ReactElement {
return (
<>
<h1>{data.title}</h1>
<p>{data.intro}</p>
<WidgetForm slugs={slugs} labels={data.form} />
{data.widgets.length === 0 ? (
<p>{data.empty}</p>
) : (
<>
<p>{data.count}</p>
<ul>
{data.widgets.map((widget) => (
<li key={widget.widgetId}>{widget.name}</li>
))}
</ul>
</>
)}
</>
)
},
})
```
Add it to `modules/pages.ts`, beside `notes`:
```ts
export const modulePages: Readonly<Record<string, () => Promise<ModuleProjectPage>>> = {
notes: async () => (await import("./notes/ui/pages.js")).notesPage,
widgets: async () => (await import("./widgets/ui/pages.js")).widgetsPage,
}
```
A client component writes through `useWrite` of `@conusai-kit/ui/data`, whose client carries the session and the
CSRF token (a list that follows other members' changes is a collection, read with `useLive`; see step 5). The `ui.nav` entry of the manifest makes the page appear on the project's home page and in the lists of
the workspace and the organization.
```tsx
// modules/widgets/ui/widget-form.tsx
"use client"
// Adds a widget through the data facade (`useWrite` of @conusai-kit/ui/data): the one API path every
// caller uses, with the session, the CSRF token and the problem+json refusals handled by the shell's
// data client. Every label is a message the server passed in. The form is the kit's `FormLayout`: it checks
// the fields' own constraints in the app's language and states a refusal in the form, with focus on it.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { describeError, useWrite } from "@conusai-kit/ui/data"
import { FormLayout, type FormValues, TextField } from "@conusai-kit/ui/patterns"
import type { ReactElement } from "react"
export interface WidgetFormLabels {
readonly name: string
readonly submit: string
/** The app's `app.failed` message, with `{detail}` still in it. */
readonly failed: string
}
export function WidgetForm({ slugs, labels }: { slugs: ProjectSlugs; labels: WidgetFormLabels }): ReactElement {
const create = useWrite<ProjectSlugs & { name: string }, unknown>("widgets.widget.create", { org: slugs.org })
const submit = async ({ name }: FormValues): Promise<void> => {
await create.mutate({ ...slugs, name: name ?? "" })
// Live collection or route loader invalidation updates the list without location.reload()
}
return (
<FormLayout
submitLabel={labels.submit}
onSubmit={submit}
describeError={(error) => labels.failed.replace("{detail}", describeError(error))}
>
<TextField name="name" label={labels.name} required maxLength={120} />
</FormLayout>
)
}
```
### A live list
A list that follows other members' changes without a reload is a **collection**. Three additions make one:
1. A batch-get read procedure beside the list, under the same permission, that takes `ids` (at most 100) and
answers `{ items }`; an id it does not answer is gone for that caller. Give it an isolation fixture like
any other procedure.
```ts
export const widgetGetMany = procedure("widgets.widget.getMany")
.kind("read").scope("project").permission("widgets.widget.read")
.input(z.object({ ...inProject, ids: z.array(z.string()).min(1).max(100) }))
.output(z.object({ items: z.array(widgetViewSchema) }))
.contract()
```
2. The declaration in the manifest (`pnpm kit check` refuses one without `getMany`; `buildRegistry` checks the
procedures exist, are reads at the collection's scope and share its permission):
```ts
collections: [{
entity: "widgets.widget", table: "widgets_widget", key: "widgetId", scopeLevel: "project",
read: "widgets.widget.read", list: "widgets.widget.list", getMany: "widgets.widget.getMany",
}],
```
The migrate job attaches the change capture to `widgets_widget` itself (a migration named
`widgets/live-capture-widget`); there is no SQL to write.
3. The page renders the first page on the server and hands it to a client island, which keeps it live:
```tsx
"use client"
import { liveCollection, useDataClient, useLive } from "@conusai-kit/ui/data"
export function LiveWidgets({ slugs, seed }: { slugs: ProjectSlugs; seed: WidgetView[] }) {
const client = useDataClient()
const [widgets] = useState(() => liveCollection<WidgetView>({
client, org: slugs.org, topic: { ws: slugs.workspace, project: slugs.project },
entity: "widgets.widget", key: "widgetId", version: "version", seed,
list: { procedure: "widgets.widget.list", input: { ...slugs }, rows: (o) => (o as { widgets: WidgetView[] }).widgets },
getMany: { procedure: "widgets.widget.getMany", input: { ...slugs }, rows: (o) => (o as { items: WidgetView[] }).items },
}))
const { data, isReady } = useLive(widgets, (q, rows) => q.from({ w: rows }).orderBy(({ w }) => w.createdAt, "desc"))
return <WidgetList widgets={isReady ? data : seed} />
}
```
A write through `useWrite("widgets.widget.update", { org: slugs.org })` resolves once the stream has seen it,
so every live list already shows the change. Live lists run on every profile; on Convex the change is recorded by
the writing mutation and `pnpm convex:gen` adds its log table, so there is nothing to add there either.
`useLiveNotes` in `modules/notes/ui/live-notes.ts` is the worked example; `modules/agent/ui/live-runs.tsx` and
`modules/knowledge/ui/live-documents.tsx` are two more.
4. A view that joins other tables -- a document with its lines and the counts of each status -- is not a row of
the collection. Read its procedure with `useRead(procedure, input, { live: { org, topic, entities: ["widgets.widget"] },
initial: seed })`: it is read again whenever a widget changes, and the component that made a write calls the
read's `refetch` once the write has answered (`modules/documents/ui/live-documents.ts`).
A module that declares a collection never re-runs its page's loader after a write (`navigation.refresh()`,
`router.invalidate()`), and no module reloads the document: `test/live-writes.test.ts` refuses both, by file and
line. A list whose rows live in a business service (CRM, Catalog, Inventory) is not in the app's live log, so
those screens still re-run their loader after a write.
## 9. A message catalogue
Every label is a message, in both languages. `lt.ts` is typed from `en.ts`, so a missing key is a type error,
and the scaffolded test fails when the two differ. Lithuanian has four plural forms (`one`, `few`, `many`,
`other`); the example has the three a count of things needs:
```ts
// modules/widgets/i18n/en.ts
// English messages of the widgets module (spec §10.4). Every string a page of this module shows is a key
// here and in lt.ts; `__tests__/module.test.ts` fails when the two differ.
export const en = {
title: "Widgets",
intro: "Widgets are the example this guide adds.",
name: "Name",
add: "Add widget",
empty: "No widgets yet.",
count: "{count, plural, one {# widget} other {# widgets}}",
} as const
```
```ts
// modules/widgets/i18n/lt.ts
// Lietuviški widgets modulio pranešimai. Kiekvienas puslapio tekstas yra raktas čia ir en.ts faile.
import type { en } from "./en.js"
type Messages<T> = { [K in keyof T]: T[K] extends string ? string : Messages<T[K]> }
export const lt: Messages<typeof en> = {
title: "Valdikliai",
intro: "Valdikliai yra pavyzdys, kurį prideda šis vadovas.",
name: "Pavadinimas",
add: "Pridėti valdiklį",
empty: "Valdiklių dar nėra.",
count: "{count, plural, one {# valdiklis} few {# valdikliai} other {# valdiklių}}",
}
```
## 10. Test and gate
```bash
pnpm vitest run modules/widgets test/modules/widgets # no database
pnpm format # Biome
pnpm openapi # a new procedure changes openapi.json; a test fails on drift
pnpm gate # typecheck, lint, boundaries, tests (isolation included), build
pnpm kit gate # the same, in a clean copy, installed from the registry
pnpm e2e # Chromium and axe against the whole app (Docker for Postgres)
```
```ts
// test/modules/widgets/widgets-flow.test.ts
// The widgets module against the whole app on the in-memory data source
// (test/support/notes-app.ts wires the registry, the queue and the outbox exactly as the apps do): a
// widget is created, listed, tidied by its job, audited and metered -- and a viewer may read and not write.
import { describe, expect, it } from "vitest"
import { makeNotesApp } from "../../support/notes-app.js"
describe("the widgets module", () => {
it("creates a widget, lists it, and tidies its name in the job it enqueued", async () => {
const app = makeNotesApp()
const project = await app.seedProject("acme", "owner@acme.test")
const created = (await project.call.callByName("widgets.widget.create", {
...project.slugs,
name: " Left handed widget ",
})) as { widgetId: string }
expect(app.queue.jobs.map((job) => job.job)).toEqual(["widgets.tidy"])
expect(await app.runJobs()).toBe(1)
const listed = (await project.call.callByName("widgets.widget.list", project.slugs)) as {
widgets: Array<{ widgetId: string; name: string }>
}
expect(listed.widgets).toEqual([
expect.objectContaining({ widgetId: created.widgetId, name: "Left handed widget" }),
])
})
it("declares the audit event and the usage meter the transport records for a create", () => {
const app = makeNotesApp()
const route = app.registry.router["widgets.widget.create"]
expect(route?.audit).toBe("widgets.widget_created")
expect(route?.usage).toBe("widgets_created")
expect(app.registry.audit["widgets.widget_created"]).toBe("widgets")
})
it("lets a viewer read and refuses them a write", async () => {
const app = makeNotesApp()
const project = await app.seedProject("acme", "owner@acme.test")
const viewer = await app.addMember(project, "viewer@acme.test", "viewer")
await expect(viewer.call.callByName("widgets.widget.list", project.slugs)).resolves.toEqual({ widgets: [] })
await expect(
viewer.call.callByName("widgets.widget.create", { ...project.slugs, name: "Nope" }),
).rejects.toMatchObject({ problem: { status: 404 } })
})
})
```
Your tests are yours, and live in two places: `modules/<id>/__tests__/` for what needs only the module (use cases against `createMemoryDataSource`, the migration, the isolation fixtures), and `test/modules/<id>/` for what needs the whole app, such as the flow below. Both are exempt from the template's licence header and its `TEMPLATE-FILES` list (`node scripts/licence.mjs --check`); a file you add anywhere else under `test/` or `apps/` is a template file and fails that check. A test under `modules/` may not import `test/support/notes-app.ts`, which reaches the worker's system actor, and the boundary gate says so. `test/support/notes-app.ts` is the whole app on the in-memory data source, the one to build on.
For a screen, copy the shape of `e2e/notes.spec.ts`: sign in with `signIn`, make an organization with
`makeOrganization`, open `/<org>/<workspace>/<project>/widgets`, and call `violations(page)` on every state.
## Demo data for your module
`apps/worker/src/seed.ts` writes the demo data of `pnpm dev` with the kit's own procedures. A step added to
`seedDemo` that calls your module's procedures as one of the demo people is the one edit outside `modules/` that
is meant for you; keep it idempotent (ask whether the row is there first).