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/docs/llms.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# cvx-kit — condensed reference for LLMs and coding agents
|
|
2
|
+
|
|
3
|
+
> Paste this file (installed at `node_modules/cvx-kit/docs/`) into an agent's
|
|
4
|
+
> context when it works on a cvx-kit application. Detailed docs:
|
|
5
|
+
> `architecture.md`, `conventions.md`, `zod-table.md`, `auth.md`,
|
|
6
|
+
> `commands.md`, `triggers.md`, `approvals.md`, `maintainability.md`,
|
|
7
|
+
> `migration.md` (same directory).
|
|
8
|
+
> Structure, file anatomy, and naming rules → `conventions.md` is
|
|
9
|
+
> authoritative. Restructuring an existing raw project → `migration.md`.
|
|
10
|
+
|
|
11
|
+
> **Placeholders.** Names like `documents`, `users`, `history`, `title`,
|
|
12
|
+
> `ownerId`, and `documentPublish` in the examples below are illustrative
|
|
13
|
+
> only — replace them with the application's own entities and fields. No
|
|
14
|
+
> specific table, entity, field, or workflow is required by the kit. Names in
|
|
15
|
+
> `<angle brackets>` are always placeholders.
|
|
16
|
+
|
|
17
|
+
cvx-kit is a reusable Convex application kit: zod table boundaries, auth-aware
|
|
18
|
+
function constructors, a trigger registry, an audited command protocol
|
|
19
|
+
(Foundation component), and a declarative approvals component. Peer deps:
|
|
20
|
+
`convex ^1.43`, `zod ^4`.
|
|
21
|
+
|
|
22
|
+
## Non-negotiable rules
|
|
23
|
+
|
|
24
|
+
1. **One `zodTable` per entity**, in `domain/<entity>/schema.ts`. Never write
|
|
25
|
+
`defineTable` inline or a second zod object for the same entity.
|
|
26
|
+
2. **Raw `query`/`mutation`/`action`/`internal*` builders appear only in
|
|
27
|
+
`convex/functions.ts`** (the `createAuthFunctions` call). All other
|
|
28
|
+
functions use `authQuery/authMutation/authAction`, `roleQuery/...`,
|
|
29
|
+
`adminQuery/...` (public) or `systemMutation/systemAction` (internal).
|
|
30
|
+
This is what guarantees auth, triggers, and bounded reads.
|
|
31
|
+
3. **Every public query returns DTOs** via `<table>.toPublicDto(row)` —
|
|
32
|
+
runtime redaction, not just types.
|
|
33
|
+
4. **Every read is bounded**: `ctx.include(ctx.db.query('t')).matching(...)
|
|
34
|
+
.execute(limit)` with `1 ≤ limit ≤ 100`. `.resolve()` falls back to a full
|
|
35
|
+
table scan — avoid it.
|
|
36
|
+
5. **State changes are commands**: an operations registry with mandatory
|
|
37
|
+
`classification` and `audit()` per operation, executed via
|
|
38
|
+
`commands.exec({ operation, handler })`. Audit writes happen in the same
|
|
39
|
+
transaction.
|
|
40
|
+
6. **Never write timestamps by hand.** `createdAt`/`updatedAt` are maintained
|
|
41
|
+
by the `timestamps` trigger; `archivedAt` is the app-controlled soft-delete
|
|
42
|
+
marker.
|
|
43
|
+
7. **Vocabularies are named readonly tuples in `constants.ts`** —
|
|
44
|
+
`z.enum([...])` with an inline literal is banned in kit-style codebases.
|
|
45
|
+
8. **Components are consumed only through their client facades**
|
|
46
|
+
(`Foundation`, `Approvals`) — no deep imports, no touching component
|
|
47
|
+
tables, no reaching private children (`components.approvals.workflow`).
|
|
48
|
+
9. **Operation names are `domain.verb` lowercase-dotted; error codes are
|
|
49
|
+
UPPER_SNAKE** — otherwise observability silently drops the events.
|
|
50
|
+
|
|
51
|
+
## Exports map
|
|
52
|
+
|
|
53
|
+
| Import | Provides |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `cvx-kit` | everything below re-exported (except components' defaults) |
|
|
56
|
+
| `cvx-kit/zod-table` | `zodTable`, `zodVariantTable`, `jsonSafeZid`, `TIMESTAMP_FIELDS` |
|
|
57
|
+
| `cvx-kit/auth` | `createAuthFunctions`, `createInclude`, `defaultRoleMap` |
|
|
58
|
+
| `cvx-kit/triggers` | `createTriggers`, `timestamps`, `appendOnly`, `noDelete`, `Triggers` |
|
|
59
|
+
| `cvx-kit/errors` | `KitError`, `defaultErrors`, `ErrorFactory` |
|
|
60
|
+
| `cvx-kit/components/foundation` | `Foundation`, `executeResultBoundary`, `projectResult`, `Observability`; default export = component config for `app.use` |
|
|
61
|
+
| `cvx-kit/components/approvals` | `Approvals` client; default export = component config for `app.use` |
|
|
62
|
+
| `cvx-kit/test` | `registerFoundation(t)`, `registerApprovals(t)` for convex-test |
|
|
63
|
+
|
|
64
|
+
## Minimal app wiring (the five root files)
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
// convex/convex.config.ts
|
|
68
|
+
import { defineApp } from 'convex/server'
|
|
69
|
+
import foundation from 'cvx-kit/components/foundation'
|
|
70
|
+
import approvals from 'cvx-kit/components/approvals'
|
|
71
|
+
const app = defineApp()
|
|
72
|
+
app.use(foundation)
|
|
73
|
+
app.use(approvals)
|
|
74
|
+
export default app
|
|
75
|
+
|
|
76
|
+
// convex/triggers.ts
|
|
77
|
+
import { createTriggers, timestamps, appendOnly } from 'cvx-kit/triggers'
|
|
78
|
+
export const triggers = createTriggers<DataModel>()
|
|
79
|
+
timestamps(triggers, 'documents')
|
|
80
|
+
appendOnly(triggers, 'history')
|
|
81
|
+
|
|
82
|
+
// convex/functions.ts
|
|
83
|
+
export const { authQuery, authMutation, authAction, adminQuery, adminMutation,
|
|
84
|
+
adminAction, roleQuery, roleMutation, roleAction, systemMutation,
|
|
85
|
+
systemAction, include } = createAuthFunctions<DataModel>({
|
|
86
|
+
query, mutation, action, internalMutation, internalAction, // from ./_generated/server
|
|
87
|
+
getAuthUser: (ctx) => authKit.getAuthUser(ctx),
|
|
88
|
+
mapRole: defaultRoleMap, // 'member'→'writer'; reader|writer|admin pass
|
|
89
|
+
adminRoles: ['admin'],
|
|
90
|
+
triggers,
|
|
91
|
+
verifyMembership: async ({ userId, organizationId }) => { /* live check; actions only */ },
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// convex/foundation.ts
|
|
95
|
+
export const { Command, Query, observability } = new Foundation(
|
|
96
|
+
components.foundation,
|
|
97
|
+
{ observability: {
|
|
98
|
+
enabled: () => process.env.OBS === 'true',
|
|
99
|
+
classifyError, // → { outcome: 'denied'|'failed', errorCode }
|
|
100
|
+
writeAudit: (ctx, entry) => writeAuditEntry(ctx, entry),
|
|
101
|
+
} },
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
// convex/approvals.ts
|
|
105
|
+
export const approvals = new Approvals(components.approvals)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Entity pattern
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
// domain/documents/schema.ts
|
|
112
|
+
export const documents = zodTable('documents', (id) => ({
|
|
113
|
+
title: z.string(), ownerId: id('users'), secretNote: z.string(),
|
|
114
|
+
}), {
|
|
115
|
+
commandFields: ['title'], // what a command may say
|
|
116
|
+
publicFields: ['title', 'ownerId'], // the DTO allowlist
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// convex/schema.ts (via domain/table.ts)
|
|
120
|
+
defineSchema({ documents: documents.table.index('by_owner', ['ownerId']) })
|
|
121
|
+
|
|
122
|
+
// domain/documents/commands.ts
|
|
123
|
+
const operations = {
|
|
124
|
+
'documents.rename': Command.operation({
|
|
125
|
+
command: documents.commandInput.extend({ id: zid('documents'), actorId: z.string() }),
|
|
126
|
+
result: z.object({ ok: z.literal(true) }).strict(),
|
|
127
|
+
classification: 'business',
|
|
128
|
+
audit: ({ command }) => ({ operation: 'documents.rename', actorId: command.actorId,
|
|
129
|
+
aggregate: { type: 'document', id: command.id } }),
|
|
130
|
+
}),
|
|
131
|
+
} as const
|
|
132
|
+
const commands = new Command<MutationCtx, typeof operations>(operations)
|
|
133
|
+
export const executeRename = commands.exec({ operation: 'documents.rename',
|
|
134
|
+
handler: async (ctx, cmd) => { await ctx.db.patch(cmd.id, { title: cmd.title }); return { ok: true } } })
|
|
135
|
+
|
|
136
|
+
// api/documents.ts — thin public adapter
|
|
137
|
+
export const rename = authMutation({
|
|
138
|
+
args: documents.commandInput.extend({ id: zid('documents') }),
|
|
139
|
+
returns: z.object({ ok: z.literal(true) }).strict(),
|
|
140
|
+
handler: (ctx, args) => executeRename(ctx, { ...args, actorId: ctx.actor.userId }),
|
|
141
|
+
})
|
|
142
|
+
export const list = authQuery({
|
|
143
|
+
args: { limit: z.number() },
|
|
144
|
+
returns: z.array(documents.publicDto),
|
|
145
|
+
handler: (ctx, { limit }) =>
|
|
146
|
+
ctx.include(ctx.db.query('documents'))
|
|
147
|
+
.matching('by_owner', (ix) => ix.eq('ownerId', ctx.actor.userId))
|
|
148
|
+
.execute(limit, (rows) => rows.map(documents.toPublicDto)),
|
|
149
|
+
})
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Auth ctx contents
|
|
153
|
+
|
|
154
|
+
Authenticated handlers receive frozen `ctx.identity` (JWT), `ctx.user.id`,
|
|
155
|
+
`ctx.org = { organizationId, role }`, `ctx.role`, `ctx.actor = { userId,
|
|
156
|
+
organizationId, role }`, and `ctx.include`. Queries/mutations trust the JWT;
|
|
157
|
+
**actions live-verify membership and fail closed**. Use `ctx.actor` as the
|
|
158
|
+
canonical actor for audits/approvals.
|
|
159
|
+
|
|
160
|
+
## Approvals in one block
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
export const publishApproval = approvals.define({ name: 'documentPublish', steps: [
|
|
164
|
+
approvals.decision('managerDecision', { decisions: ['approved','rejected'],
|
|
165
|
+
quorum: { kind: 'count', approvals: 1 }, makerChecker: true, expiresAfterMs: 604_800_000 }),
|
|
166
|
+
approvals.branch('applyDecision', { approvedStepKey: 'publish', rejectedStepKey: 'notify' }),
|
|
167
|
+
approvals.mutation('publish', { handler: internal.domain.documents.approval_functions.applyDecision }),
|
|
168
|
+
approvals.notify('notify', { handler: internal.domain.documents.approval_functions.notifyRejected }),
|
|
169
|
+
]})
|
|
170
|
+
// start(ctx, { scopeRef, resourceType, resourceRef, requester: ctx.actor, metadata? })
|
|
171
|
+
// decide(ctx, { runId, decision, reason? }, ctx.actor) · status/evidence/list/cancel/restart
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Callback handlers are `systemMutation`/`systemAction`, receive opaque strings,
|
|
175
|
+
and must `normalizeId` + re-check `resourceType` + verify the resource still
|
|
176
|
+
points at `runId`.
|
|
177
|
+
|
|
178
|
+
## Footguns (top 8)
|
|
179
|
+
|
|
180
|
+
1. `t.run(ctx => ctx.db...)` in convex-test and raw builders bypass ALL
|
|
181
|
+
triggers — seeding only.
|
|
182
|
+
2. `executeResultBoundary` returns a typed failure **only when the
|
|
183
|
+
transaction has no effects yet**; after any write/schedule it rethrows for
|
|
184
|
+
rollback. Handled-vs-thrown is decided by transaction state.
|
|
185
|
+
3. Observability drops events on identifier-regex mismatch — see rule 9.
|
|
186
|
+
4. Mis-declared `serverFields` silently make server-owned fields
|
|
187
|
+
client-writable; masks are the security boundary.
|
|
188
|
+
5. `include(...).resolve()` silently falls back to `fullTableScan()`.
|
|
189
|
+
6. Audit callback ctx is untyped (`never`) — commands must run in mutations;
|
|
190
|
+
wiring a query ctx fails at runtime.
|
|
191
|
+
7. Changing approval steps without bumping `compatibilityKey` lets old runs
|
|
192
|
+
be driven by an incompatible shape.
|
|
193
|
+
8. When composing ctx manually, spread `wrapDB(ctx)` first — later spreads
|
|
194
|
+
carrying `db` restore the unwrapped database.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Maintaining a Convex application built on cvx-kit
|
|
2
|
+
|
|
3
|
+
Practices that keep a cvx-kit backend maintainable over years, taken from the
|
|
4
|
+
origin application. The theme throughout: make every rule either *structural*
|
|
5
|
+
(the code can't be written any other way) or *executable* (a test fails when
|
|
6
|
+
it's violated). Conventions that live only in documentation decay.
|
|
7
|
+
|
|
8
|
+
## Schema and indexes
|
|
9
|
+
|
|
10
|
+
- `convex/schema.ts` stays at ~4 lines: `defineSchema(domainTables)`.
|
|
11
|
+
Per-domain `table.ts` files attach indexes to `<zodTable>.table` and
|
|
12
|
+
`domain/table.ts` merges the maps. Shapes (zod) and topology (indexes) are
|
|
13
|
+
deliberately separate files.
|
|
14
|
+
- **Index naming**: `by_<field>_and_<field>...`, camelCase field names in
|
|
15
|
+
declaration order, nested paths flattened with `_`
|
|
16
|
+
(`by_assignedToUserId_and_state_status` for `['assignedToUserId',
|
|
17
|
+
'state.status']`). A reader should reconstruct the index definition from its
|
|
18
|
+
name.
|
|
19
|
+
- Every read goes through `ctx.include(...)` with an explicit limit ≤ 100.
|
|
20
|
+
If a query can't be served by an index, that's a schema problem, not a
|
|
21
|
+
reason to `fullTableScan`.
|
|
22
|
+
|
|
23
|
+
## Schema evolution without downtime
|
|
24
|
+
|
|
25
|
+
Convex validates the whole table against the schema on push, so evolution is
|
|
26
|
+
additive:
|
|
27
|
+
|
|
28
|
+
1. **New fields arrive `.optional()`** with a comment explaining the epoch:
|
|
29
|
+
`// Optional only for documents created before priority was frozen.`
|
|
30
|
+
2. Deploy code that writes the new field on every new write and tolerates its
|
|
31
|
+
absence on reads.
|
|
32
|
+
3. Backfill if needed (a `systemMutation` batch or `@convex-dev/migrations`),
|
|
33
|
+
then — and only then — consider tightening the schema.
|
|
34
|
+
4. Never rename in place: add the new field, dual-write, backfill, drop the
|
|
35
|
+
old one release(s) later.
|
|
36
|
+
|
|
37
|
+
zod-table makes step 1 cheap: change the shape once and every boundary mask
|
|
38
|
+
updates together, with `tsc` pointing at every consumer that must adapt.
|
|
39
|
+
|
|
40
|
+
## Function hygiene
|
|
41
|
+
|
|
42
|
+
- Raw `query`/`mutation`/`action` builders appear **only** in `functions.ts`.
|
|
43
|
+
Everything public is `auth*`/`role*`/`admin*`; everything
|
|
44
|
+
internal/scheduled is `system*`. This is what makes triggers, auth, and
|
|
45
|
+
bounded reads impossible to forget.
|
|
46
|
+
- Public functions in `api/` are thin: parse args (zod-table masks), call the
|
|
47
|
+
domain executor with `ctx.actor`, project through `toPublicDto`. Business
|
|
48
|
+
logic that lives in an adapter can't be reused by workflows or callbacks —
|
|
49
|
+
keep it in the domain.
|
|
50
|
+
- Vocabularies (states, kinds, decisions) are single-owner readonly tuples in
|
|
51
|
+
`constants.ts`. Grep for the tuple name to find every consumer.
|
|
52
|
+
|
|
53
|
+
## Audit and observability as maintenance tools
|
|
54
|
+
|
|
55
|
+
- Every state change is a command with a mandatory classification and audit
|
|
56
|
+
derivation — so "what happened to this record" is a query over the audit
|
|
57
|
+
log, not archaeology. Keep operation names stable (`domain.verb`); they are
|
|
58
|
+
your telemetry and audit vocabulary.
|
|
59
|
+
- Observability is payload-free by construction — you can leave it on in
|
|
60
|
+
production and grep durable one-line JSON events
|
|
61
|
+
(`event: 'command.execution'`) without PII review.
|
|
62
|
+
- Set retention deliberately: audit entries carry the classification as their
|
|
63
|
+
retention category; the approvals component ships `cleanupAudit` for its own
|
|
64
|
+
trail.
|
|
65
|
+
|
|
66
|
+
## Testing strategy
|
|
67
|
+
|
|
68
|
+
Three test layers, all in-memory via `convex-test` on `edge-runtime`:
|
|
69
|
+
|
|
70
|
+
1. **Behavioral tests** — drive functions through the real constructors
|
|
71
|
+
(`t.withIdentity(...)`), assert DTO shapes and trigger effects. Remember:
|
|
72
|
+
`t.run(ctx => ctx.db...)` bypasses triggers — use it for seeding, not for
|
|
73
|
+
asserting write behavior.
|
|
74
|
+
2. **Architecture tests** — glob the source and assert the conventions
|
|
75
|
+
(no inline tables/enums, no deep imports, no sibling-domain imports, one
|
|
76
|
+
`app.use` per component, no raw builders outside `functions.ts`). Prove
|
|
77
|
+
each rule against a synthetic violating fixture first.
|
|
78
|
+
3. **Boundary/copyability tests** — components contain no host imports and no
|
|
79
|
+
vendor/env references; optionally `cp -r` the component to a tmpdir and
|
|
80
|
+
run `tsc` to prove copy-portability.
|
|
81
|
+
|
|
82
|
+
Consumers register the kit's components on their test instance:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { convexTest } from 'convex-test'
|
|
86
|
+
import { registerApprovals, registerFoundation } from 'cvx-kit/test'
|
|
87
|
+
|
|
88
|
+
const t = convexTest(schema, modules)
|
|
89
|
+
registerFoundation(t)
|
|
90
|
+
registerApprovals(t)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
(`cvx-kit/test` ships as source and needs a vitest-compatible runner —
|
|
94
|
+
`import.meta.glob`.) Nested components used by approvals register their own
|
|
95
|
+
test modules the same way (`@convex-dev/workflow/test`,
|
|
96
|
+
`convex-audit-log/test`). Stub required env vars (declared on
|
|
97
|
+
`defineApp({ env })`) in test setup.
|
|
98
|
+
|
|
99
|
+
## Operational habits
|
|
100
|
+
|
|
101
|
+
- **Env flags over redeploys**: observability is toggled by an env var read
|
|
102
|
+
at call time (`enabled: () => env.OBS === 'true'`).
|
|
103
|
+
- **Fail closed**: membership verification errors reject; unknown roles
|
|
104
|
+
reject; unreadable transaction metrics rethrow. Preserve this bias in app
|
|
105
|
+
code.
|
|
106
|
+
- **Soft deletes** (`archivedAt` + `noDelete`) for anything a human might ask
|
|
107
|
+
about later; `appendOnly` for evidence tables. Hard deletes are a data-loss
|
|
108
|
+
bug you opt into per table.
|
|
109
|
+
- **Component upgrades**: components own their tables; upgrade them like
|
|
110
|
+
services — read the component's migration notes, bump, run codegen, run the
|
|
111
|
+
suite. Never write to a component's tables from the host.
|
|
112
|
+
|
|
113
|
+
## Checklist for a new entity
|
|
114
|
+
|
|
115
|
+
1. `domain/<entity>/schema.ts` — `zodTable` with the three masks thought
|
|
116
|
+
through (`serverFields`, `commandFields`, `publicFields`).
|
|
117
|
+
2. `domain/<entity>/table.ts` — indexes, named `by_...`.
|
|
118
|
+
3. Register in `domain/table.ts`; add `timestamps(triggers, '<table>')` and
|
|
119
|
+
any `appendOnly`/`noDelete` discipline.
|
|
120
|
+
4. `constants.ts` — vocabularies as readonly tuples.
|
|
121
|
+
5. `commands.ts` — operation registry (`domain.verb`, classification, audit)
|
|
122
|
+
+ executors.
|
|
123
|
+
6. `api/<entity>.ts` — thin `auth*` adapters, DTO projection.
|
|
124
|
+
7. Tests: behavior + add the entity to the architecture globs.
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# Migrating an existing Convex project to the cvx-kit architecture
|
|
2
|
+
|
|
3
|
+
This guide takes a "raw" Convex project — the typical shape where every file at
|
|
4
|
+
the root of `convex/` exports public queries and mutations, tables are defined
|
|
5
|
+
inline in `schema.ts`, and validators are `v.*` objects repeated per function —
|
|
6
|
+
and restructures it into the cvx-kit architecture.
|
|
7
|
+
|
|
8
|
+
This is an **opinionated infrastructure**. The rules below are not
|
|
9
|
+
suggestions; they are the contract. Where the kit makes something structural
|
|
10
|
+
(auth, triggers, bounded reads, mandatory audit), the migration's job is to
|
|
11
|
+
remove every code path that circumvents the structure. Names in `<angle
|
|
12
|
+
brackets>` are placeholders for your own vocabulary — no specific entity,
|
|
13
|
+
table, field, or workflow is mandated, only the roles and rules.
|
|
14
|
+
|
|
15
|
+
Migrate incrementally: each step leaves the app deployable, and old and new
|
|
16
|
+
shapes can coexist while you move entity by entity.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 1. The target structure — complete
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
convex/
|
|
24
|
+
convex.config.ts # component mounting. Nothing else.
|
|
25
|
+
schema.ts # defineSchema(domainTables). Nothing else.
|
|
26
|
+
functions.ts # the single createAuthFunctions(...) call
|
|
27
|
+
triggers.ts # the single trigger registry + helper registrations
|
|
28
|
+
foundation.ts # the single new Foundation(...) facade
|
|
29
|
+
<component>.ts # one facade per mounted component (audit.ts, approvals.ts, pool.ts, workflows.ts, ...)
|
|
30
|
+
auth.ts # identity-provider client wiring
|
|
31
|
+
auth.config.ts # Convex auth providers config
|
|
32
|
+
http.ts # HTTP router (webhook endpoints delegate to domains)
|
|
33
|
+
crons.ts # cron declarations only; targets are internal domain functions
|
|
34
|
+
|
|
35
|
+
api/ # ═══ THE ONLY PUBLIC SURFACE ═══
|
|
36
|
+
<entity>.ts # thin adapters: auth* constructors → domain executors → DTOs
|
|
37
|
+
|
|
38
|
+
domain/ # ═══ ALL BUSINESS LOGIC ═══
|
|
39
|
+
table.ts # merges per-entity table maps into domainTables
|
|
40
|
+
shared/ # genuinely cross-entity code ONLY
|
|
41
|
+
schema.ts # cross-entity zod helpers (if any)
|
|
42
|
+
<provider>.ts # one configured vendor client per provider (sql, cache, email, ...)
|
|
43
|
+
<entity>/ # one directory per entity/aggregate
|
|
44
|
+
schema.ts # the zodTable declaration — shapes and masks, nothing else
|
|
45
|
+
table.ts # index topology attached to <zodTable>.table
|
|
46
|
+
constants.ts # finite vocabularies: UPPER_SNAKE readonly tuples
|
|
47
|
+
commands.ts # operation registry + command executors
|
|
48
|
+
queries.ts # domain read logic
|
|
49
|
+
rules.ts # pure domain invariants/predicates (no ctx, no db)
|
|
50
|
+
shared.ts # helpers shared by multiple files of THIS entity
|
|
51
|
+
<workflow>.ts # approval/workflow definitions this entity owns
|
|
52
|
+
<workflow>_functions.ts # internal callback targets (system* constructors)
|
|
53
|
+
actions.ts # external-side-effect logic (via shared provider clients)
|
|
54
|
+
__tests__/ # behavioral tests for this entity
|
|
55
|
+
|
|
56
|
+
components/ # only if the app authors its own portable components
|
|
57
|
+
<name>/ # own convex.config.ts, schema.ts, _generated/, __tests__/
|
|
58
|
+
|
|
59
|
+
__tests__/ # ═══ APP-WIDE GUARDRAILS ═══
|
|
60
|
+
setup.ts # env stubs for vars declared on defineApp({ env })
|
|
61
|
+
architecture.test.ts # the executable conventions (see §6)
|
|
62
|
+
facades.test.ts # each app.use exactly once; no private-child access
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### The root rule
|
|
66
|
+
|
|
67
|
+
**The root of `convex/` contains configuration and facades only.** Every root
|
|
68
|
+
file is a *declaration point* — the single place one piece of infrastructure
|
|
69
|
+
is configured — and none of them exports a public function or contains
|
|
70
|
+
business logic. If a client can call it, it lives in `api/`. If it decides
|
|
71
|
+
anything about the business, it lives in `domain/`. If `ls convex/*.ts` shows
|
|
72
|
+
anything else, the migration is not done.
|
|
73
|
+
|
|
74
|
+
### Hard prohibitions (grep-able, test-enforceable)
|
|
75
|
+
|
|
76
|
+
These patterns must not exist anywhere after migration — each one is a hole in
|
|
77
|
+
a structural guarantee:
|
|
78
|
+
|
|
79
|
+
| Forbidden | Where it's allowed instead | What it breaks otherwise |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `query(`, `mutation(`, `action(`, `internalMutation(`, `internalAction(` from `_generated/server` | `functions.ts` only | auth, triggers, bounded reads |
|
|
82
|
+
| `defineTable(` | `domain/<entity>/table.ts` via `<zodTable>.table` | single source of truth per entity |
|
|
83
|
+
| `defineSchema(` | root `schema.ts` only | schema assembly |
|
|
84
|
+
| inline `z.enum([...])` / repeated literal unions | tuple in `constants.ts` | vocabulary ownership |
|
|
85
|
+
| `.collect()` / unbounded reads in public paths | `ctx.include(...).execute(limit)` | read bounds |
|
|
86
|
+
| `makeFunctionReference` | generated `internal` / `api` | refactoring safety |
|
|
87
|
+
| deep imports of a component's internals | the component's client facade | component boundary |
|
|
88
|
+
| sibling-domain imports (`domain/a` → `domain/b`) | `domain/shared/` or the public API | ownership |
|
|
89
|
+
| `ctx.db.insert/patch/delete` on a mounted component's tables | the component's client methods | component persistence |
|
|
90
|
+
| vendor SDK instantiation inside a domain | one shared client in `domain/shared/<provider>.ts` | connection discipline |
|
|
91
|
+
| `new Date().toISOString()` / hand-written timestamp fields | the `timestamps` trigger | server-owned lifecycle |
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## 2. Where each kind of existing code goes
|
|
96
|
+
|
|
97
|
+
A raw project's root files usually mix several kinds of code. Pull them apart:
|
|
98
|
+
|
|
99
|
+
| You have today | It becomes |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `export const get/list = query({...})` at root | adapter in `api/<entity>.ts` + read logic in `domain/<entity>/queries.ts` |
|
|
102
|
+
| `export const create/update = mutation({...})` at root | adapter in `api/<entity>.ts` + operation in `domain/<entity>/commands.ts` |
|
|
103
|
+
| `internalMutation`/`internalAction` helpers | `system*` functions in `domain/<entity>/<purpose>_functions.ts` |
|
|
104
|
+
| inline `defineTable({...})` in `schema.ts` | `zodTable` in `domain/<entity>/schema.ts` + indexes in `domain/<entity>/table.ts` |
|
|
105
|
+
| `v.union(v.literal(...))` / inline `z.enum` repeated around | one `UPPER_SNAKE` tuple in `domain/<entity>/constants.ts` |
|
|
106
|
+
| validation/business `if`-chains inside handlers | pure predicates in `domain/<entity>/rules.ts` |
|
|
107
|
+
| shared grab-bag (`helpers.ts`, `utils.ts`, `lib.ts`) | split by owner into `domain/<entity>/shared.ts`; only genuinely cross-entity code into `domain/shared/` |
|
|
108
|
+
| ad-hoc vendor clients (HTTP SDKs, caches, SQL) | one configured client per provider in `domain/shared/<provider>.ts` |
|
|
109
|
+
| webhook handlers with logic in `http.ts` | route in `http.ts`, logic in `domain/<entity>/actions.ts` |
|
|
110
|
+
| cron handlers with logic in `crons.ts` | declaration in `crons.ts`, target `system*` function in the domain |
|
|
111
|
+
| audit/log/history tables mirroring component state | the component's own persistence via its facade |
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 3. Naming — the full convention set
|
|
116
|
+
|
|
117
|
+
Naming is part of the architecture: several kit mechanisms key off it at
|
|
118
|
+
runtime (observability drops non-conforming events), and the rest makes the
|
|
119
|
+
codebase greppable.
|
|
120
|
+
|
|
121
|
+
- **Directories**: `domain/<entity>/` uses the plural camelCase table name.
|
|
122
|
+
One entity, one directory — no `domain/misc/`.
|
|
123
|
+
- **Tables**: plural camelCase (`<entities>`).
|
|
124
|
+
- **Indexes**: `by_<field>` / `by_<field>_and_<field>`, fields in declaration
|
|
125
|
+
order, nested paths flattened with `_` (`by_ownerId_and_state_status` for
|
|
126
|
+
`['ownerId', 'state.status']`). A reader must be able to reconstruct the
|
|
127
|
+
index definition from its name.
|
|
128
|
+
- **Command operations**: `<entity>.<verb>` lowercase dotted
|
|
129
|
+
(`invoices.approve`). Must match `/^[A-Za-z][A-Za-z0-9_.-]{0,159}$/` or
|
|
130
|
+
observability drops the events.
|
|
131
|
+
- **Error codes**: `UPPER_SNAKE`, matching `/^[A-Z][A-Z0-9_]{0,95}$/`. Define
|
|
132
|
+
the full set once (one owner), not per callsite.
|
|
133
|
+
- **Vocabulary tuples**: `UPPER_SNAKE_CASE` exported `as const`; derived type
|
|
134
|
+
`PascalCase` singular (`const <ENTITY>_STATES = [...] as const;
|
|
135
|
+
type <Entity>State = (typeof <ENTITY>_STATES)[number]`).
|
|
136
|
+
- **Domain executors**: `execute<Verb><Entity>` (`executeApproveInvoice`) —
|
|
137
|
+
the grep-able seam between adapters and domain.
|
|
138
|
+
- **Internal callback files**: `<purpose>_functions.ts` next to the
|
|
139
|
+
`<purpose>.ts` that references them.
|
|
140
|
+
- **Public function names** are `api/<entity>:fn` — clients never reference a
|
|
141
|
+
root-level path.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## 4. Step-by-step migration
|
|
146
|
+
|
|
147
|
+
### Step 0 — Inventory
|
|
148
|
+
|
|
149
|
+
List every exported function and classify it: public read, public write,
|
|
150
|
+
internal, cron target, HTTP handler. List every table, every index, every
|
|
151
|
+
literal string union, every vendor client instantiation. This is the
|
|
152
|
+
checklist; entities with the fewest dependencies migrate first.
|
|
153
|
+
|
|
154
|
+
### Step 1 — Mount the kit, declare the root files
|
|
155
|
+
|
|
156
|
+
Install `cvx-kit`, mount `foundation` (and `approvals` if needed) in
|
|
157
|
+
`convex.config.ts`, run codegen. Create `functions.ts`, `triggers.ts`,
|
|
158
|
+
`foundation.ts`, and one facade per mounted component. Declare required env
|
|
159
|
+
vars on `defineApp({ env: { ... } })` so deploys fail fast, and stub them in
|
|
160
|
+
`__tests__/setup.ts`. Nothing breaks: raw functions keep working alongside
|
|
161
|
+
the new constructors.
|
|
162
|
+
|
|
163
|
+
### Step 2 — Convert tables entity by entity
|
|
164
|
+
|
|
165
|
+
For each table:
|
|
166
|
+
|
|
167
|
+
1. `domain/<entity>/schema.ts`: a `zodTable` mirroring the current shape,
|
|
168
|
+
`v.*` converted to zod. Decide the three masks **deliberately** —
|
|
169
|
+
`serverFields` (server-assigned), `commandFields` (what a write request
|
|
170
|
+
may say), `publicFields` (what a client may see). This is where implicit
|
|
171
|
+
trust becomes explicit policy; a mis-declared mask is a security bug, so
|
|
172
|
+
review masks like auth code.
|
|
173
|
+
2. `domain/<entity>/table.ts`: move the indexes, renamed to the convention.
|
|
174
|
+
3. Register in `domain/table.ts`; shrink root `schema.ts` toward
|
|
175
|
+
`defineSchema(domainTables)`.
|
|
176
|
+
4. The kit's timestamps are `.optional()`, so existing rows validate without
|
|
177
|
+
backfill. Register `timestamps(triggers, '<table>')`; new writes maintain
|
|
178
|
+
`createdAt`/`updatedAt`. Backfill later if needed. Existing hand-rolled
|
|
179
|
+
timestamp fields (e.g. ISO strings) stay in the shape for now — migrate
|
|
180
|
+
the data separately; never couple data migration to restructuring.
|
|
181
|
+
5. Apply write discipline per table while you're here: `appendOnly` for
|
|
182
|
+
evidence tables (votes, history), `noDelete` + `archivedAt` for anything a
|
|
183
|
+
human might ask about later. Hard deletes are opt-in per table, not the
|
|
184
|
+
default.
|
|
185
|
+
|
|
186
|
+
### Step 3 — Move vocabularies to `constants.ts`
|
|
187
|
+
|
|
188
|
+
Every reusable finite vocabulary gets one owner: an exported tuple in the
|
|
189
|
+
owning entity's `constants.ts`. Schemas, validators, filters, transition
|
|
190
|
+
maps, and tests consume the tuple. Subsets and ordered presentations derive
|
|
191
|
+
from the canonical tuple, typed against its value type so an invalid literal
|
|
192
|
+
cannot compile. A file-local tuple is acceptable **only** for private
|
|
193
|
+
implementation mechanics that can never appear in another schema, contract,
|
|
194
|
+
persisted document, or UI state — and it is still `UPPER_SNAKE_CASE`.
|
|
195
|
+
|
|
196
|
+
### Step 4 — Split public functions into adapter + domain logic
|
|
197
|
+
|
|
198
|
+
For each public function at the root:
|
|
199
|
+
|
|
200
|
+
1. **Extract the domain logic.** Reads → `domain/<entity>/queries.ts`.
|
|
201
|
+
Writes → an operation in the entity's command registry with
|
|
202
|
+
classification and audit derivation (see `commands.md`) — deciding *what
|
|
203
|
+
gets audited and as what* is a product decision; make it explicitly, not
|
|
204
|
+
as an afterthought. Pure conditions the handler checks
|
|
205
|
+
(state-transition legality, permission-beyond-role predicates) →
|
|
206
|
+
`rules.ts`, so they're unit-testable without a ctx.
|
|
207
|
+
2. **Write the adapter** in `api/<entity>.ts` with `authQuery`/
|
|
208
|
+
`authMutation`/`role*`/`admin*`. Adapters do exactly four things: parse
|
|
209
|
+
args with the entity's boundary schemas, authorize via the constructor
|
|
210
|
+
choice, call the domain executor with `ctx.actor`, project through
|
|
211
|
+
`toPublicDto`. **Adapter budget: if it exceeds ~10 lines of glue or grows
|
|
212
|
+
a private helper, logic is leaking — move it into the domain.**
|
|
213
|
+
3. **Retire the old path.** Public names change to `api/<entity>:fn`; update
|
|
214
|
+
client call sites per entity, or keep a temporary re-export file at the
|
|
215
|
+
old path and delete it in the final step.
|
|
216
|
+
|
|
217
|
+
Anything "public-but-really-internal" (only called by your own scheduled
|
|
218
|
+
functions or other backend code) must stop being public: convert to `system*`
|
|
219
|
+
in the domain.
|
|
220
|
+
|
|
221
|
+
### Step 5 — Convert internal functions and the wiring files
|
|
222
|
+
|
|
223
|
+
- Replace raw `internalMutation`/`internalAction` with `systemMutation`/
|
|
224
|
+
`systemAction` from `functions.ts`, located in the owning domain's
|
|
225
|
+
`<purpose>_functions.ts`. Scheduled and callback writes now run under the
|
|
226
|
+
same trigger regime as user writes.
|
|
227
|
+
- `crons.ts` becomes declarations only, targeting `internal.domain.<entity>.
|
|
228
|
+
<purpose>_functions.*`.
|
|
229
|
+
- `http.ts` becomes routing only: verify the webhook signature, parse the
|
|
230
|
+
payload at the boundary, delegate to a domain action/function.
|
|
231
|
+
|
|
232
|
+
### Step 6 — Bound the reads
|
|
233
|
+
|
|
234
|
+
Replace `.collect()` and unbounded `.take(n)` in public paths with
|
|
235
|
+
`ctx.include(...).execute(limit)` (limit ≤ 100). Where a query can't be
|
|
236
|
+
served by an index, **add the index** — a scan is a schema problem, not a
|
|
237
|
+
query-code problem. Paginated surfaces cross the boundary with the convex
|
|
238
|
+
pagination validators bridged into zod (`convexToZod(paginationOptsValidator)`
|
|
239
|
+
and a `paginationResultValidator` built from the row validator), not with
|
|
240
|
+
hand-rolled cursor objects.
|
|
241
|
+
|
|
242
|
+
### Step 7 — Enforce everything with tests
|
|
243
|
+
|
|
244
|
+
Port the executable-architecture pattern. `__tests__/architecture.test.ts`
|
|
245
|
+
globs all production sources and asserts the Hard Prohibitions table above;
|
|
246
|
+
**each rule is proved against a synthetic violating fixture first** so a
|
|
247
|
+
broken checker cannot silently pass. `facades.test.ts` asserts each
|
|
248
|
+
`app.use(x)` appears exactly once and no file reaches a component's private
|
|
249
|
+
children. From this point the migration cannot regress — and neither can
|
|
250
|
+
future contributors or coding agents.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## 5. File conduct — how every source file is organized
|
|
255
|
+
|
|
256
|
+
From the origin project's code conduct. Apply to new code and files being
|
|
257
|
+
substantially changed; don't mechanically rewrite untouched files.
|
|
258
|
+
|
|
259
|
+
**File order** — readable from public purpose to private implementation:
|
|
260
|
+
|
|
261
|
+
1. Imports.
|
|
262
|
+
2. Exported types, schemas, constants, and public functions — the API.
|
|
263
|
+
3. File-local types and constants supporting the implementation.
|
|
264
|
+
4. Private helpers, as **named function declarations**, at the bottom.
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
import { parseInput } from './schema'
|
|
268
|
+
|
|
269
|
+
export async function createProjection(input: unknown) {
|
|
270
|
+
return buildProjection(parseInput(input))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function buildProjection(input: ProjectionInput) {
|
|
274
|
+
// implementation
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Never place private helper implementations above the exported API. Prefer
|
|
279
|
+
`function` declarations over helper arrow-constants so helpers can live at
|
|
280
|
+
the bottom.
|
|
281
|
+
|
|
282
|
+
**Ownership.** Put behavior beside the entity that owns it. Consumers (api
|
|
283
|
+
adapters, cron targets, workflow callbacks) stay thin: validate, authorize,
|
|
284
|
+
call the owning operation, translate the result. A consumer accumulating
|
|
285
|
+
entity rules or a pile of private helpers is logic asking to move into the
|
|
286
|
+
domain.
|
|
287
|
+
|
|
288
|
+
**No convenience re-exports.** Import a function from the file that owns it;
|
|
289
|
+
never re-export to shorten a path. Barrel files (`index.ts` re-exporting a
|
|
290
|
+
directory) are banned inside `convex/` — they blur ownership and create
|
|
291
|
+
import cycles.
|
|
292
|
+
|
|
293
|
+
**Components own their persistence.** Never mirror a mounted component's
|
|
294
|
+
internal state (audit events, workflow steps, job results) into application
|
|
295
|
+
tables unless the product domain independently requires a durable business
|
|
296
|
+
record. Log through the facade; don't build a receipts table for it.
|
|
297
|
+
|
|
298
|
+
**Comments state constraints, not narration.** The accepted pattern for
|
|
299
|
+
schema evolution is a one-line epoch comment on an `.optional()` field
|
|
300
|
+
explaining why it's optional. Comments that restate the next line are noise.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## 6. Documentation the migrated repo must carry
|
|
305
|
+
|
|
306
|
+
An opinionated codebase documents its opinions, or they die at the first
|
|
307
|
+
new contributor. Minimum set:
|
|
308
|
+
|
|
309
|
+
- **`CODE_CONDUCT.md`** (or equivalent) — the repo's own conventions: file
|
|
310
|
+
order, ownership layout, vocabulary rules, naming. This guide's §3 and §5
|
|
311
|
+
are the starting template; adapt, don't link.
|
|
312
|
+
- **`CONCEPTS.md`** — the domain glossary: every entity, state, and
|
|
313
|
+
vocabulary word with its business meaning. LLMs and new hires read this
|
|
314
|
+
first; keep entries one paragraph.
|
|
315
|
+
- **`CLAUDE.md` / `AGENTS.md`** — thin pointers: the stack, the three
|
|
316
|
+
commands that matter (test, typecheck, deploy), and links to
|
|
317
|
+
`CODE_CONDUCT.md` + `node_modules/cvx-kit/src/docs/llms.md`. Agent files
|
|
318
|
+
that duplicate the conduct doc drift; keep them as an index.
|
|
319
|
+
- **Architecture tests as documentation** — the test names in
|
|
320
|
+
`architecture.test.ts` are the enforceable summary of the conduct doc;
|
|
321
|
+
a rule that exists in prose but not in the suite is a request, not a rule.
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## 7. Definition of done
|
|
326
|
+
|
|
327
|
+
The migration is complete when every box checks:
|
|
328
|
+
|
|
329
|
+
- [ ] `ls convex/*.ts` shows only configuration and facades (§1 table).
|
|
330
|
+
- [ ] Every public function lives in `api/` and uses `auth*`/`role*`/`admin*`.
|
|
331
|
+
- [ ] Every internal function uses `system*` and lives in its domain.
|
|
332
|
+
- [ ] `functions.ts` is the only file importing `_generated/server` builders.
|
|
333
|
+
- [ ] Every table is a `zodTable` with deliberately reviewed masks; indexes
|
|
334
|
+
named by convention; timestamps trigger registered.
|
|
335
|
+
- [ ] Every vocabulary is a single-owner tuple in a `constants.ts`.
|
|
336
|
+
- [ ] Every state change is a command operation with classification + audit.
|
|
337
|
+
- [ ] Every public read is bounded (`include` + limit, or bridged pagination).
|
|
338
|
+
- [ ] No entry in the Hard Prohibitions table greps positive.
|
|
339
|
+
- [ ] `architecture.test.ts` + `facades.test.ts` exist, each rule fixture-proved.
|
|
340
|
+
- [ ] `CODE_CONDUCT.md` and `CONCEPTS.md` exist; agent files point at them.
|
|
341
|
+
- [ ] Transitional re-exports deleted; clients call `api/<entity>:fn` paths.
|
|
342
|
+
|
|
343
|
+
## Migration order that works
|
|
344
|
+
|
|
345
|
+
1. Root files + kit mounting (step 1) — zero behavioral change.
|
|
346
|
+
2. The smallest, least-referenced entity end-to-end (steps 2–6) — shakes out
|
|
347
|
+
masks, role mapping, and conventions on a low-stakes surface.
|
|
348
|
+
3. Architecture tests (step 7) — lock the rules in **before** the bulk.
|
|
349
|
+
4. Remaining entities in dependency order, one PR each.
|
|
350
|
+
5. Delete transitional re-exports and remaining root function files; run the
|
|
351
|
+
definition-of-done checklist.
|