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/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.