@@ -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,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.
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user