cvx-kit 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/docs/approvals.md +152 -0
- package/docs/architecture.md +124 -0
- package/docs/auth.md +157 -0
- package/docs/commands.md +177 -0
- package/docs/conventions.md +270 -0
- package/docs/convex-architecture-explainer.html +460 -0
- package/docs/llms.md +194 -0
- package/docs/maintainability.md +124 -0
- package/docs/migration.md +351 -0
- package/docs/triggers.md +70 -0
- package/docs/zod-table.md +150 -0
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -23,6 +23,22 @@ bun add cvx-kit
|
|
|
23
23
|
target ES2025, minified, deps external, publint-verified
|
|
24
24
|
- `bun run build:codegen` — re-run `convex codegen` for both components, then build
|
|
25
25
|
|
|
26
|
+
## Documentation
|
|
27
|
+
|
|
28
|
+
Full docs ship with the package — `node_modules/cvx-kit/docs/` when installed,
|
|
29
|
+
authored in [`src/docs/`](./src/docs/):
|
|
30
|
+
|
|
31
|
+
- [`architecture.md`](./src/docs/architecture.md) — the layered application shape the kit encodes
|
|
32
|
+
- [`conventions.md`](./src/docs/conventions.md) — the doctrine: folder structure, file anatomy, naming grammar, import rules
|
|
33
|
+
- [`zod-table.md`](./src/docs/zod-table.md) — one zod shape per entity, masked into every boundary
|
|
34
|
+
- [`auth.md`](./src/docs/auth.md) — auth-aware function constructors from injected policy
|
|
35
|
+
- [`commands.md`](./src/docs/commands.md) — Foundation, the audited command protocol, observability
|
|
36
|
+
- [`triggers.md`](./src/docs/triggers.md) — trigger registry, timestamps/append-only/no-delete
|
|
37
|
+
- [`approvals.md`](./src/docs/approvals.md) — declarative approval workflows
|
|
38
|
+
- [`maintainability.md`](./src/docs/maintainability.md) — keeping a Convex app maintainable long-term
|
|
39
|
+
- [`migration.md`](./src/docs/migration.md) — restructuring an existing raw Convex project into this architecture
|
|
40
|
+
- [`llms.md`](./src/docs/llms.md) — condensed reference to paste into an LLM/agent context
|
|
41
|
+
|
|
26
42
|
## Modules
|
|
27
43
|
|
|
28
44
|
### `cvx-kit/zod-table`
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# `cvx-kit/components/approvals` — declarative approval workflows
|
|
2
|
+
|
|
3
|
+
A self-contained Convex component for human-in-the-loop approval flows:
|
|
4
|
+
declarative step lists (decision / branch / mutation / action / notify) with
|
|
5
|
+
count quorum, maker-checker, expiry, compatibility keys, durable execution via
|
|
6
|
+
a **nested** `@convex-dev/workflow`, and its own audit trail via a **nested**
|
|
7
|
+
`convex-audit-log`. The component never imports host code: tenancy is an
|
|
8
|
+
opaque `scopeRef`, resources are opaque `resourceType`/`resourceRef` strings,
|
|
9
|
+
and callbacks cross the boundary as **function handles**.
|
|
10
|
+
|
|
11
|
+
## Mounting
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// convex/convex.config.ts
|
|
15
|
+
import approvals from 'cvx-kit/components/approvals'
|
|
16
|
+
app.use(approvals)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The component mounts its own private `workflow` and `auditLog` children. Do
|
|
20
|
+
**not** re-register those children at the app root under the same identity,
|
|
21
|
+
and never reach into `components.approvals.workflow` / `.auditLog` from host
|
|
22
|
+
code — the `Approvals` client class is the only contract.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// convex/approvals.ts — host facade, declared once
|
|
26
|
+
import { Approvals } from 'cvx-kit/components/approvals'
|
|
27
|
+
import { components } from './_generated/api'
|
|
28
|
+
|
|
29
|
+
export const approvals = new Approvals(components.approvals)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Defining a workflow — in the owning domain
|
|
33
|
+
|
|
34
|
+
Workflow *definitions* are business logic and live in the domain that owns the
|
|
35
|
+
resource, not in the root facade:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// convex/domain/documents/approval.ts
|
|
39
|
+
import { approvals } from '../../approvals'
|
|
40
|
+
import { internal } from '../../_generated/api'
|
|
41
|
+
|
|
42
|
+
export const publishApproval = approvals.define({
|
|
43
|
+
name: 'documentPublish',
|
|
44
|
+
steps: [
|
|
45
|
+
approvals.decision('managerDecision', {
|
|
46
|
+
decisions: ['approved', 'rejected'],
|
|
47
|
+
quorum: { kind: 'count', approvals: 1 },
|
|
48
|
+
makerChecker: true, // requester cannot decide
|
|
49
|
+
expiresAfterMs: 7 * 24 * 60 * 60 * 1000,
|
|
50
|
+
}),
|
|
51
|
+
approvals.branch('applyDecision', {
|
|
52
|
+
approvedStepKey: 'publishDocument',
|
|
53
|
+
rejectedStepKey: 'notifyRejected',
|
|
54
|
+
}),
|
|
55
|
+
approvals.mutation('publishDocument', {
|
|
56
|
+
handler: internal.domain.documents.approval_functions.applyDecision,
|
|
57
|
+
}),
|
|
58
|
+
approvals.notify('notifyRejected', {
|
|
59
|
+
handler: internal.domain.documents.approval_functions.notifyRejected,
|
|
60
|
+
retry: true,
|
|
61
|
+
}),
|
|
62
|
+
],
|
|
63
|
+
})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Step kinds
|
|
67
|
+
|
|
68
|
+
| Step | Runs | Notes |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `decision(key, { decisions, quorum, makerChecker?, expiresAfterMs? })` | waits for humans | quorum is `{ kind: 'count', approvals: n }`; expiry resolves the run as `expired` |
|
|
71
|
+
| `branch(key, { approvedStepKey, rejectedStepKey })` | routing | jumps by the preceding decision's outcome |
|
|
72
|
+
| `mutation(key, { handler, retry? })` | internal mutation handle | transactional apply step |
|
|
73
|
+
| `action(key, { handler, retry? })` | internal action handle | side effects (third parties) |
|
|
74
|
+
| `notify(key, { handler, retry? })` | internal action handle | semantically a notification; same contract as action |
|
|
75
|
+
|
|
76
|
+
Limits (enforced by the component's validators): ≤ 32 steps per workflow,
|
|
77
|
+
≤ 8 decisions per decision step, names ≤ 96 chars, references ≤ 256 chars,
|
|
78
|
+
metadata ≤ 32 entries (keys ≤ 64, values ≤ 512 chars).
|
|
79
|
+
|
|
80
|
+
### Compatibility keys
|
|
81
|
+
|
|
82
|
+
`compatibilityKey` (defaults to `name`) versions the workflow shape. Every
|
|
83
|
+
`decide`/`cancel`/`status`/`restart` call sends it, so a run started under an
|
|
84
|
+
old definition cannot be driven by an incompatible new one. When you change a
|
|
85
|
+
workflow's steps in a breaking way, change the key.
|
|
86
|
+
|
|
87
|
+
## Driving a run
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
// start — from an authMutation, with ctx.actor as the requester
|
|
91
|
+
const { runId } = await publishApproval.start(ctx, {
|
|
92
|
+
scopeRef: ctx.org.organizationId, // opaque tenancy
|
|
93
|
+
resourceType: 'document',
|
|
94
|
+
resourceRef: documentId, // opaque to the component
|
|
95
|
+
requester: ctx.actor,
|
|
96
|
+
metadata: { title }, // bounded string map
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// decide — a manager approves or rejects
|
|
100
|
+
await publishApproval.decide(ctx, { runId, decision: 'approved', reason }, ctx.actor)
|
|
101
|
+
|
|
102
|
+
// observe
|
|
103
|
+
await publishApproval.status(ctx, runId) // pending | approved | rejected | expired | canceled
|
|
104
|
+
await publishApproval.evidence(ctx, runId) // per-step decision evidence
|
|
105
|
+
await publishApproval.list(ctx, { scopeRef, ... })
|
|
106
|
+
|
|
107
|
+
// manage
|
|
108
|
+
await publishApproval.cancel(ctx, runId, ctx.actor)
|
|
109
|
+
await publishApproval.restart(ctx, runId)
|
|
110
|
+
await approvals.cleanupAudit(ctx, { olderThanDays: 365, batchSize: 100 })
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Callback handlers — validate everything coming back
|
|
114
|
+
|
|
115
|
+
Step handlers are **internal** functions (build them with `systemMutation` /
|
|
116
|
+
`systemAction`). They receive `ApprovalCallbackInput`: `{ runId, scopeRef,
|
|
117
|
+
resourceType, resourceRef, metadata?, decision? }` — all opaque strings.
|
|
118
|
+
Because the component cannot know your tables, the handler must re-establish
|
|
119
|
+
trust:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
export const applyDecision = systemMutation({
|
|
123
|
+
args: approvalCallbackArgs,
|
|
124
|
+
handler: async (ctx, input) => {
|
|
125
|
+
if (input.resourceType !== 'document') throw new Error('WRONG_RESOURCE')
|
|
126
|
+
const id = ctx.db.normalizeId('documents', input.resourceRef)
|
|
127
|
+
if (!id) throw new Error('BAD_REF')
|
|
128
|
+
const document = await ctx.db.get(id)
|
|
129
|
+
// guard against stale runs: the doc must still point at this run
|
|
130
|
+
if (document?.approvalRunId !== input.runId) return
|
|
131
|
+
await ctx.db.patch(id, { reviewState: 'published' })
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
That `approvalRunId` check is the idempotency/staleness guard from the origin
|
|
137
|
+
app — copy it.
|
|
138
|
+
|
|
139
|
+
## Rules and footguns
|
|
140
|
+
|
|
141
|
+
1. Root facade (`convex/approvals.ts`) instantiates the client; workflow
|
|
142
|
+
*definitions* live in the owning domain.
|
|
143
|
+
2. Handlers get opaque strings — always `normalizeId`, re-check
|
|
144
|
+
`resourceType`, and verify the resource still references the run.
|
|
145
|
+
3. `makerChecker: true` means the requester's decisions are rejected — design
|
|
146
|
+
your UI accordingly.
|
|
147
|
+
4. The component keeps its own audit trail (retention category
|
|
148
|
+
`approval-protocol`, cleanup via `cleanupAudit`, retention 1–3650 days,
|
|
149
|
+
batch ≤ 100). Host-side business audit still goes through your command
|
|
150
|
+
protocol — they are different trails.
|
|
151
|
+
5. Changing steps without changing `compatibilityKey` lets old runs be driven
|
|
152
|
+
by a new incompatible shape; bump the key on breaking changes.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Architecture — the shape of a cvx-kit application
|
|
2
|
+
|
|
3
|
+
cvx-kit was extracted from a production Convex application (~12k LOC backend).
|
|
4
|
+
This document describes the architecture that application follows and that the
|
|
5
|
+
kit is designed to reproduce: strict ownership boundaries, injected policy,
|
|
6
|
+
portable components, and conventions that are *executable* (enforced by tests)
|
|
7
|
+
rather than aspirational.
|
|
8
|
+
|
|
9
|
+
## The layers
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
convex/
|
|
13
|
+
convex.config.ts # component mounting — each app.use(x) exactly once
|
|
14
|
+
schema.ts # defineSchema(domainTables) — nothing else
|
|
15
|
+
functions.ts # createAuthFunctions(...) — the ONLY place raw builders appear
|
|
16
|
+
foundation.ts # const { Command, Query, observability } = new Foundation(...)
|
|
17
|
+
approvals.ts # const approvals = new Approvals(components.approvals)
|
|
18
|
+
audit.ts # writeAuditEntry over convex-audit-log + admin readers
|
|
19
|
+
triggers.ts # the single Triggers registry + helper registrations
|
|
20
|
+
auth.ts / auth.config.ts / http.ts # identity provider wiring (e.g. WorkOS AuthKit)
|
|
21
|
+
api/ # PUBLIC surface — thin adapters only
|
|
22
|
+
<documents>.ts # authQuery/authMutation wrapping domain executors
|
|
23
|
+
domain/
|
|
24
|
+
table.ts # merges per-domain table maps into domainTables
|
|
25
|
+
shared/ # genuinely cross-entity helpers only
|
|
26
|
+
<entity>/ # one directory per entity/aggregate
|
|
27
|
+
schema.ts # zodTable declaration (shapes only)
|
|
28
|
+
table.ts # indexes attached to <zodTable>.table
|
|
29
|
+
constants.ts # finite vocabularies (UPPER_SNAKE readonly tuples)
|
|
30
|
+
commands.ts # operation registry + executors (new Command(...))
|
|
31
|
+
queries.ts # domain read logic
|
|
32
|
+
approval.ts # approvals.define(...) workflow definitions
|
|
33
|
+
*_functions.ts # internal callbacks (systemMutation/systemAction)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Layer rules
|
|
37
|
+
|
|
38
|
+
1. **`api/` is the only public surface.** It contains no bare `query(` or
|
|
39
|
+
`mutation(` — only `auth*`/`role*`/`admin*` constructors delegating to
|
|
40
|
+
domain `execute*` functions. Public functions are adapters: parse, pass
|
|
41
|
+
`ctx.actor`, project DTOs.
|
|
42
|
+
2. **`domain/<entity>/` owns everything about the entity** — its zod shape,
|
|
43
|
+
indexes, vocabularies, commands, queries, workflows. Cross-domain reuse
|
|
44
|
+
goes through `domain/shared/`, never sibling imports of another domain's
|
|
45
|
+
internals.
|
|
46
|
+
3. **Root files are facades over mounted components.** `foundation.ts`,
|
|
47
|
+
`approvals.ts`, `audit.ts` configure infrastructure; business definitions
|
|
48
|
+
stay in the owning domain.
|
|
49
|
+
4. **Internal functions use `systemMutation`/`systemAction`** — never raw
|
|
50
|
+
`internalMutation(`/`internalAction(` — so triggers and `include` apply to
|
|
51
|
+
scheduled/callback work too.
|
|
52
|
+
|
|
53
|
+
## Injection, not import
|
|
54
|
+
|
|
55
|
+
The kit's central design rule: **library code never imports app singletons**.
|
|
56
|
+
Every kit surface takes its policy as a parameter:
|
|
57
|
+
|
|
58
|
+
- `createAuthFunctions` receives the generated builders, `getAuthUser`,
|
|
59
|
+
`mapRole`, `verifyMembership`, `triggers`, `errors`.
|
|
60
|
+
- `Foundation` receives `classifyError`, `writeAudit`, the `enabled` flag.
|
|
61
|
+
- `Approvals` receives only its component reference; host data crosses as
|
|
62
|
+
opaque validated strings.
|
|
63
|
+
|
|
64
|
+
The reverse rule holds for components: a portable component must not import
|
|
65
|
+
host domains, generated host APIs, environment configuration, or vendor SDKs
|
|
66
|
+
(WorkOS, Drizzle, Redis…). Host values cross its public API only as
|
|
67
|
+
**validated, bounded, opaque data** — `scopeRef`, `resourceRef`, function
|
|
68
|
+
handles. Component schemas contain only their own records and never mirror a
|
|
69
|
+
private child component's tables.
|
|
70
|
+
|
|
71
|
+
## Boundaries as schemas
|
|
72
|
+
|
|
73
|
+
Data crossing any boundary is a zod schema derived from one `zodTable`
|
|
74
|
+
declaration per entity (see `zod-table.md`):
|
|
75
|
+
|
|
76
|
+
- storage → `defineTable` via `zodToConvex`
|
|
77
|
+
- writes → `insertSchema`/`updateSchema` (server fields and timestamps
|
|
78
|
+
excluded)
|
|
79
|
+
- commands → `commandInput` (explicit allowlist)
|
|
80
|
+
- clients → `publicDto` + `toPublicDto` (runtime redaction)
|
|
81
|
+
- LLM tools → `tools.*` and `jsonSafeZid`
|
|
82
|
+
|
|
83
|
+
Everything is `.strict()`. There is exactly one source of truth per entity and
|
|
84
|
+
no boundary that "just trusts" its input.
|
|
85
|
+
|
|
86
|
+
## Writes as commands
|
|
87
|
+
|
|
88
|
+
State changes flow through the audited command protocol (see `commands.md`):
|
|
89
|
+
parse input → observe → run handler → parse output → write audit — one
|
|
90
|
+
transaction, rollback-consistent. Classifications and audit derivation are
|
|
91
|
+
type-required per operation. Human-gated changes use the approvals component;
|
|
92
|
+
its apply steps are commands too.
|
|
93
|
+
|
|
94
|
+
## Conventions worth enforcing with tests
|
|
95
|
+
|
|
96
|
+
The origin app enforces its architecture with a test suite that raw-globs the
|
|
97
|
+
source and asserts violations — each rule proved against a synthetic fixture
|
|
98
|
+
first so the checker can't silently pass. Highly recommended for consumers:
|
|
99
|
+
|
|
100
|
+
- **No inline tables/schemas** — `defineTable`/`defineSchema` only in
|
|
101
|
+
`table.ts`/`schema.ts`.
|
|
102
|
+
- **No inline enums** — `z.enum([...])` with an inline array literal is
|
|
103
|
+
banned; vocabularies are named `UPPER_SNAKE` tuples in `constants.ts`.
|
|
104
|
+
- **No deep server imports** — components consumed only through their
|
|
105
|
+
`client.ts` facade; explicit allowlist for exceptions.
|
|
106
|
+
- **No handwritten references** — `makeFunctionReference` banned; use
|
|
107
|
+
generated `internal`/`api`.
|
|
108
|
+
- **No sibling-domain imports** — a domain reaches another only via
|
|
109
|
+
`domain/shared/` or the public API.
|
|
110
|
+
- **Facades mount once** — every `app.use(x)` appears exactly once; facades
|
|
111
|
+
never reach a component's private children.
|
|
112
|
+
- **Component boundary tests** — no host imports, no
|
|
113
|
+
`process.env`/vendor-SDK references inside `components/*`.
|
|
114
|
+
- **Copyability** — copy the component to a tmpdir with only its package
|
|
115
|
+
deps, run `tsc`: proves portability continuously.
|
|
116
|
+
|
|
117
|
+
## Component mounting order
|
|
118
|
+
|
|
119
|
+
In `convex.config.ts`, mount infrastructure before consumers: auth kit →
|
|
120
|
+
foundation → audit log → approvals → named workflow/workpool instances. Use
|
|
121
|
+
`app.use(x, { name })` when mounting the same component twice; `components.
|
|
122
|
+
<name>` keys off that name. Required env vars can be declared on
|
|
123
|
+
`defineApp({ env: { ... } })` so deploys fail fast — remember to stub them in
|
|
124
|
+
test setup.
|
package/docs/auth.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# `cvx-kit/auth` — auth-aware function constructors from injected policy
|
|
2
|
+
|
|
3
|
+
`createAuthFunctions` builds the function constructors your whole backend uses
|
|
4
|
+
in place of raw `query`/`mutation`/`action`. Nothing in the kit imports your
|
|
5
|
+
app's singletons — identity resolution, role vocabulary, membership
|
|
6
|
+
verification, trigger wrapping, and error policy are all **injected** by the
|
|
7
|
+
host. The kit provides the structure; the app provides the policy.
|
|
8
|
+
|
|
9
|
+
## Setup — declared once per app
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// convex/functions.ts (the single place constructors are built)
|
|
13
|
+
import { createAuthFunctions, defaultRoleMap } from 'cvx-kit/auth'
|
|
14
|
+
import {
|
|
15
|
+
action, internalAction, internalMutation, mutation, query,
|
|
16
|
+
} from './_generated/server'
|
|
17
|
+
import type { DataModel } from './_generated/dataModel'
|
|
18
|
+
import { triggers } from './triggers'
|
|
19
|
+
import { authKit } from './auth'
|
|
20
|
+
|
|
21
|
+
export const {
|
|
22
|
+
authQuery, authMutation, authAction,
|
|
23
|
+
roleQuery, roleMutation, roleAction,
|
|
24
|
+
adminQuery, adminMutation, adminAction,
|
|
25
|
+
systemMutation, systemAction,
|
|
26
|
+
include, authenticatedUser,
|
|
27
|
+
} = createAuthFunctions<DataModel>({
|
|
28
|
+
query, mutation, action, internalMutation, internalAction,
|
|
29
|
+
getAuthUser: (ctx) => authKit.getAuthUser(ctx),
|
|
30
|
+
mapRole: defaultRoleMap, // or your own vocabulary (see below)
|
|
31
|
+
adminRoles: ['admin'],
|
|
32
|
+
triggers, // every mutation write runs through wrapDB
|
|
33
|
+
verifyMembership: async ({ userId, organizationId }) => {
|
|
34
|
+
const memberships = await authKit.workos.userManagement
|
|
35
|
+
.listOrganizationMemberships({ organizationId, userId, statuses: ['active'] })
|
|
36
|
+
const m = memberships.data.find((c) => c.userId === userId)
|
|
37
|
+
return m ? { organizationId: m.organizationId, roleSlug: m.role.slug } : null
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Every function in the app is then built from these — raw `query`/`mutation`
|
|
43
|
+
imports from `_generated/server` appear **only** in this file.
|
|
44
|
+
|
|
45
|
+
## The constructor families
|
|
46
|
+
|
|
47
|
+
| Constructor | Visibility | Auth | Extra |
|
|
48
|
+
|---|---|---|---|
|
|
49
|
+
| `authQuery` / `authMutation` / `authAction` | public | any authenticated org member | actions live-verify membership |
|
|
50
|
+
| `roleQuery(...roles)` / `roleMutation(...)` / `roleAction(...)` | public | listed roles only | factory — call with your roles |
|
|
51
|
+
| `adminQuery` / `adminMutation` / `adminAction` | public | `config.adminRoles` | pre-built `role*(...adminRoles)` |
|
|
52
|
+
| `systemMutation` | internal | none (trusted caller) | still trigger-wrapped |
|
|
53
|
+
| `systemAction` | internal | none | plain internal action |
|
|
54
|
+
|
|
55
|
+
All are `zCustom*` constructors from convex-helpers, so `args` and `returns`
|
|
56
|
+
take zod schemas directly and compose with `zodTable` masks.
|
|
57
|
+
|
|
58
|
+
## What handlers receive on `ctx`
|
|
59
|
+
|
|
60
|
+
Authenticated constructors extend the ctx with a frozen auth bundle:
|
|
61
|
+
|
|
62
|
+
- `ctx.identity` — the raw `UserIdentity` (JWT claims)
|
|
63
|
+
- `ctx.user.id` — the synchronized principal id (from `getAuthUser`)
|
|
64
|
+
- `ctx.org` — `{ organizationId, role }`
|
|
65
|
+
- `ctx.role` — the mapped role
|
|
66
|
+
- `ctx.actor` — `{ userId, organizationId, role }` — pass this to audits,
|
|
67
|
+
approvals, and commands as the canonical actor reference
|
|
68
|
+
- `ctx.include` — the bounded query builder (below)
|
|
69
|
+
|
|
70
|
+
`systemMutation` gets `include` and trigger wrapping but no auth bundle.
|
|
71
|
+
|
|
72
|
+
## Role vocabulary is per-app
|
|
73
|
+
|
|
74
|
+
`mapRole` maps the identity's role slug onto **your** role union; returning
|
|
75
|
+
`null` rejects with `FORBIDDEN`, so unknown roles never pass. The default
|
|
76
|
+
(`defaultRoleMap`) is WorkOS-flavored: `member → writer`, and
|
|
77
|
+
`reader`/`writer`/`admin` pass through.
|
|
78
|
+
|
|
79
|
+
Supply your own vocabulary via the generic:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
createAuthFunctions<DataModel, 'viewer' | 'editor' | 'owner'>({
|
|
83
|
+
mapRole: (slug) => (slug === 'owner' ? 'owner' : slug === 'editor' ? 'editor' : slug ? 'viewer' : null),
|
|
84
|
+
adminRoles: ['owner'],
|
|
85
|
+
...
|
|
86
|
+
})
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Queries/mutations trust the JWT; actions re-verify
|
|
90
|
+
|
|
91
|
+
Queries and mutations read org and role from the identity token. Actions —
|
|
92
|
+
which can run long and call third parties — additionally call
|
|
93
|
+
`verifyMembership` **live** before the handler runs, and the fresher
|
|
94
|
+
role/organization from that check replaces the JWT's. Verification **fails
|
|
95
|
+
closed**: any error or org mismatch throws `FORBIDDEN`. Omit
|
|
96
|
+
`verifyMembership` to let actions trust the JWT like queries do.
|
|
97
|
+
|
|
98
|
+
## Mutations are structurally trigger-wrapped
|
|
99
|
+
|
|
100
|
+
When `triggers` (or `wrapDB`) is configured, every `authMutation`,
|
|
101
|
+
`roleMutation`, and `systemMutation` runs its writes through
|
|
102
|
+
`triggers.wrapDB(ctx)`. Trigger enforcement (timestamps, append-only,
|
|
103
|
+
no-delete, denormalization) is therefore **structural** — a handler cannot
|
|
104
|
+
forget it, because it never sees an unwrapped `ctx.db`. This is the reason raw
|
|
105
|
+
`mutation` must not be used outside `functions.ts`.
|
|
106
|
+
|
|
107
|
+
## `include()` — every read is bounded
|
|
108
|
+
|
|
109
|
+
`include` wraps a table query in a fluent selector that (a) picks the first
|
|
110
|
+
matching indexed query and (b) refuses unbounded reads. `execute(limit)`
|
|
111
|
+
rejects limits outside `1..maxRows` (default 100, configurable via
|
|
112
|
+
`maxIncludedQueryRows`).
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
export const list = authQuery({
|
|
116
|
+
args: { ownerId: zid('users').optional(), limit: z.number() },
|
|
117
|
+
returns: z.array(documents.publicDto),
|
|
118
|
+
handler: (ctx, args) =>
|
|
119
|
+
ctx.include(ctx.db.query('documents'))
|
|
120
|
+
.when(args.ownerId, (q, ownerId) =>
|
|
121
|
+
q.withIndex('by_owner', (ix) => ix.eq('ownerId', ownerId)))
|
|
122
|
+
.otherwise((q) => q.withIndex('by_owner'))
|
|
123
|
+
.execute(args.limit, (rows) => rows.map(documents.toPublicDto)),
|
|
124
|
+
})
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Selector methods:
|
|
128
|
+
|
|
129
|
+
- `.when(value, select)` — if `value` is non-null and nothing selected yet,
|
|
130
|
+
use `select(query, value)`.
|
|
131
|
+
- `.matching(indexName, range?, shouldMatch?)` — index selection with an
|
|
132
|
+
optional guard boolean.
|
|
133
|
+
- `.otherwise(select)` — fallback, returns the raw `Query`.
|
|
134
|
+
- `.resolve()` — selected query or full table scan (escape hatch).
|
|
135
|
+
- `.execute(limit, transform?)` — bounded `take` + optional projection. This
|
|
136
|
+
is the normal terminal.
|
|
137
|
+
|
|
138
|
+
## Errors
|
|
139
|
+
|
|
140
|
+
All rejections go through the injected `ErrorFactory` (default: throws
|
|
141
|
+
`KitError` with codes `UNAUTHENTICATED`, `FORBIDDEN`,
|
|
142
|
+
`INVALID_REQUEST_BOUNDARY`). Hosts with their own error taxonomy adapt it:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
errors: { throw: (input) => { throw App.errors.from(input) } }
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Rules of thumb
|
|
149
|
+
|
|
150
|
+
1. Build constructors once in `convex/functions.ts`; import them everywhere
|
|
151
|
+
else. Raw `_generated/server` builders appear nowhere else.
|
|
152
|
+
2. Public functions: `auth*`/`role*`/`admin*`. Scheduled/internal work:
|
|
153
|
+
`system*`. Never expose an unauthenticated public function unless it is
|
|
154
|
+
deliberately anonymous (and then document why).
|
|
155
|
+
3. Reads go through `ctx.include(...)` with an explicit limit.
|
|
156
|
+
4. Use `ctx.actor` as the actor reference in audits and approvals — never
|
|
157
|
+
re-derive identity in a handler.
|
package/docs/commands.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# `cvx-kit/components/foundation` — the audited command protocol
|
|
2
|
+
|
|
3
|
+
The Foundation is a Convex component that **owns zero tables and zero
|
|
4
|
+
capability** — its schema is empty by design. What it provides is the
|
|
5
|
+
application kernel: the `Command` protocol (validate → observe → execute →
|
|
6
|
+
audit, in one transaction), the `Query` kernel, and payload-free
|
|
7
|
+
`Observability`. It is declared **once** per app and everything destructures
|
|
8
|
+
from it; there is no separate command import.
|
|
9
|
+
|
|
10
|
+
## Mounting and declaring
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
// convex/convex.config.ts
|
|
14
|
+
import { defineApp } from 'convex/server'
|
|
15
|
+
import foundation from 'cvx-kit/components/foundation'
|
|
16
|
+
|
|
17
|
+
const app = defineApp()
|
|
18
|
+
app.use(foundation)
|
|
19
|
+
export default app
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// convex/foundation.ts — declared once, the single kernel source
|
|
24
|
+
import { Foundation } from 'cvx-kit/components/foundation'
|
|
25
|
+
import { components } from './_generated/api'
|
|
26
|
+
import { writeAuditEntry } from './audit'
|
|
27
|
+
|
|
28
|
+
export const { Command, Query, observability } = new Foundation(
|
|
29
|
+
components.foundation,
|
|
30
|
+
{
|
|
31
|
+
observability: {
|
|
32
|
+
enabled: () => process.env.COMMAND_OBSERVABILITY_ENABLED === 'true',
|
|
33
|
+
classifyError: (error) => classify(error), // → { outcome: 'denied' | 'failed', errorCode }
|
|
34
|
+
writeAudit: (ctx, entry) => writeAuditEntry(ctx, entry),
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Host code consumes the Foundation **only through this facade** — never deep
|
|
41
|
+
imports of the component's internal modules.
|
|
42
|
+
|
|
43
|
+
## Defining commands — per domain
|
|
44
|
+
|
|
45
|
+
Each domain declares a frozen registry of operations and derives typed
|
|
46
|
+
executors from it. `classification` and `audit()` are **mandatory per
|
|
47
|
+
operation** — auditing is type-enforced, not opt-in.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// convex/domain/documents/commands.ts
|
|
51
|
+
import { z } from 'zod'
|
|
52
|
+
import { Command } from '../../foundation'
|
|
53
|
+
import { documents } from './schema'
|
|
54
|
+
|
|
55
|
+
const operations = {
|
|
56
|
+
'documents.rename': Command.operation({
|
|
57
|
+
command: documents.commandInput.extend({ id: zid('documents') }),
|
|
58
|
+
result: z.object({ ok: z.literal(true) }).strict(),
|
|
59
|
+
classification: 'business',
|
|
60
|
+
audit: ({ command }) => ({
|
|
61
|
+
operation: 'documents.rename',
|
|
62
|
+
actorId: command.actorId,
|
|
63
|
+
aggregate: { type: 'document', id: command.id },
|
|
64
|
+
metadata: { title: command.title },
|
|
65
|
+
}),
|
|
66
|
+
}),
|
|
67
|
+
} as const
|
|
68
|
+
|
|
69
|
+
const commands = new Command<MutationCtx, typeof operations>(operations)
|
|
70
|
+
|
|
71
|
+
export const executeRename = commands.exec({
|
|
72
|
+
operation: 'documents.rename',
|
|
73
|
+
handler: async (ctx, command) => {
|
|
74
|
+
await ctx.db.patch(command.id, { title: command.title })
|
|
75
|
+
return { ok: true }
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The public API layer then wraps the executor in an `authMutation` and passes
|
|
81
|
+
`ctx.actor` in. Public functions stay thin adapters; domain logic lives behind
|
|
82
|
+
`execute*` functions.
|
|
83
|
+
|
|
84
|
+
## What one execution does, in order
|
|
85
|
+
|
|
86
|
+
1. **Parse the command** through the operation's `command` schema (strict zod).
|
|
87
|
+
2. **Observe**: start the observation clock.
|
|
88
|
+
3. **Run the handler**; its return value is parsed through the `result` schema
|
|
89
|
+
— outputs are validated too.
|
|
90
|
+
4. **Audit**: call the operation's `audit({ command, result }, ctx)`. If it
|
|
91
|
+
returns non-null, the entry (plus the operation's `classification`) is
|
|
92
|
+
written through the injected `writeAudit` — **in the same transaction** as
|
|
93
|
+
the handler's writes. Returning `null` skips the audit (the ontology
|
|
94
|
+
convention: failures are not audited).
|
|
95
|
+
5. **Emit the observation**: `{ operation, classification, outcome,
|
|
96
|
+
errorCode?, durationMs }` — completed, denied, or failed per
|
|
97
|
+
`classifyError`.
|
|
98
|
+
|
|
99
|
+
If any step throws, the Convex transaction rolls back — handler writes and
|
|
100
|
+
audit entry together. Audit and effects can never disagree.
|
|
101
|
+
|
|
102
|
+
### Dynamic dispatch
|
|
103
|
+
|
|
104
|
+
`exec` has a second overload for operation selection at runtime:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
export const executeAny = commands.exec({
|
|
108
|
+
dispatcher: z.object({ kind: z.enum(OPERATION_KINDS) /* from constants.ts */ }),
|
|
109
|
+
select: (input) => `documents.${input.kind}` as const,
|
|
110
|
+
handler,
|
|
111
|
+
})
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
An unconfigured operation throws `COMMAND_OPERATION_NOT_CONFIGURED`.
|
|
115
|
+
|
|
116
|
+
## Observability — payload-free by construction
|
|
117
|
+
|
|
118
|
+
Observations carry identifiers and a duration, never payloads. Events are
|
|
119
|
+
silently dropped unless:
|
|
120
|
+
|
|
121
|
+
- `enabled` resolves to `true`,
|
|
122
|
+
- `operation` and `classification` match `/^[A-Za-z][A-Za-z0-9_.-]{0,159}$/`,
|
|
123
|
+
- `errorCode` (when present) matches `/^[A-Z][A-Z0-9_]{0,95}$/`.
|
|
124
|
+
|
|
125
|
+
Practical consequence: **dotted lowercase operation names**
|
|
126
|
+
(`documents.rename`) and **UPPER_SNAKE error codes** are effectively
|
|
127
|
+
mandatory. Every telemetry path is wrapped in try/catch — observability is
|
|
128
|
+
behaviorally inert and can never change command behavior or mask the original
|
|
129
|
+
failure. Default sink is one JSON line on `console.info`
|
|
130
|
+
(`event: 'command.execution'`); inject `emit` to redirect.
|
|
131
|
+
|
|
132
|
+
## `executeResultBoundary` — typed failures, but only while it's safe
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { executeResultBoundary } from 'cvx-kit/components/foundation'
|
|
136
|
+
|
|
137
|
+
const result = await executeResultBoundary(ctx, () => run(), boundary)
|
|
138
|
+
// Result<Value, Failure> = { ok: true, value } | { ok: false, error }
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Semantics — read carefully, they are decided by **transaction state**, not by
|
|
142
|
+
the error:
|
|
143
|
+
|
|
144
|
+
- Success → `{ ok: true, value }`.
|
|
145
|
+
- A failure the boundary recognizes (`dataOf(error)` non-undefined) is
|
|
146
|
+
returned as `{ ok: false, error }` **only if the transaction has produced no
|
|
147
|
+
effects yet** (nothing written, nothing scheduled — checked via
|
|
148
|
+
`ctx.meta.getTransactionMetrics()`).
|
|
149
|
+
- If anything was already written or scheduled, the error is **rethrown** so
|
|
150
|
+
Convex rolls the transaction back — a returned failure after effects would
|
|
151
|
+
commit those effects.
|
|
152
|
+
- Unrecognized errors and unreadable metrics always rethrow.
|
|
153
|
+
|
|
154
|
+
Use it at the outermost edge of a mutation when the client needs a typed
|
|
155
|
+
`Result` over the wire instead of a thrown error. `projectResult(result,
|
|
156
|
+
onFailure)` unwraps on the caller side.
|
|
157
|
+
|
|
158
|
+
## The `Query` kernel
|
|
159
|
+
|
|
160
|
+
`Query` mirrors the pattern for reads: an injected `execute` receives
|
|
161
|
+
`{ context, metadata, run }`, letting the host apply uniform read policy
|
|
162
|
+
(authorization checks, tracing) around every domain query without the domains
|
|
163
|
+
knowing. Metadata defaults are set at construction and merged per-executor.
|
|
164
|
+
|
|
165
|
+
## Rules and footguns
|
|
166
|
+
|
|
167
|
+
1. One `new Foundation(...)` per app, in `convex/foundation.ts`. Everything
|
|
168
|
+
else destructures from it.
|
|
169
|
+
2. Never deep-import the component's `modules/*` or `result.ts` paths from app
|
|
170
|
+
code — the `client.ts` facade is the contract.
|
|
171
|
+
3. The audit callback receives the ctx untyped (`never`); wiring a query ctx
|
|
172
|
+
into an audited command fails at runtime, not compile time. Commands run in
|
|
173
|
+
mutations.
|
|
174
|
+
4. Name operations `domain.verb` and error codes `UPPER_SNAKE` or your
|
|
175
|
+
telemetry silently disappears (see regexes above).
|
|
176
|
+
5. Keep operation registries frozen (`as const`) in the domain's
|
|
177
|
+
`commands.ts`; vocabulary tuples live in `constants.ts`.
|