cvx-kit 0.0.1 → 0.0.3

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,206 @@
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. Optional per-operation `permission` (checked via the
40
+ Foundation's injected `checkPermission`) and `guard(ctx, command)`
41
+ preconditions run BEFORE the handler, plus a registry-wide default guard
42
+ (`new Command(operations, { guard })`). Order: permission → default guard
43
+ → operation guard → handler → audit.
44
+ 6. **Never write timestamps by hand.** `createdAt`/`updatedAt` are maintained
45
+ by the `timestamps` trigger; `archivedAt` is the app-controlled soft-delete
46
+ marker.
47
+ 7. **Vocabularies are named readonly tuples in `constants.ts`** —
48
+ `z.enum([...])` with an inline literal is banned in kit-style codebases.
49
+ 8. **Components are consumed only through their client facades**
50
+ (`Foundation`, `Approvals`) — no deep imports, no touching component
51
+ tables, no reaching private children (`components.approvals.workflow`).
52
+ 9. **Operation names are `domain.verb` lowercase-dotted; error codes are
53
+ UPPER_SNAKE** — otherwise observability silently drops the events.
54
+ 10. **Row-level security is configured, not hand-rolled**: the optional
55
+ `security` config on `createAuthFunctions` takes role-level `rules`
56
+ (works standalone) and/or `tenancy` (a table registry; adds
57
+ `ctx.tenant`, deny-default isolation, and pairs with `tenantTable` +
58
+ `tenantOwnership`). Multi-tenant apps stamp inserts with
59
+ `tenant: ctx.tenant` and re-verify client ids with
60
+ `requireTenantReference`. See `tenancy.md`.
61
+
62
+ ## Exports map
63
+
64
+ | Import | Provides |
65
+ |---|---|
66
+ | `cvx-kit` | everything below re-exported (except components' defaults) |
67
+ | `cvx-kit/zod-table` | `zodTable`, `tenantTable`, `createModule`, `zodVariantTable`, `jsonSafeZid`, `TIMESTAMP_FIELDS` |
68
+ | `cvx-kit/auth` | `createAuthFunctions` (incl. optional `security` RLS config), `createInclude`, `defaultRoleMap` |
69
+ | `cvx-kit/tenancy` | `createTenantRules`, `composeRules`, `requireTenantReference`, `assertTenantOwned`, `TENANT_FIELD` |
70
+ | `cvx-kit/triggers` | `createTriggers`, `timestamps`, `appendOnly`, `noDelete`, `tenantOwnership`, `Triggers` |
71
+ | `cvx-kit/errors` | `KitError`, `defaultErrors`, `ErrorFactory` |
72
+ | `cvx-kit/components/foundation` | `Foundation`, `executeResultBoundary`, `projectResult`, `Observability`; default export = component config for `app.use` |
73
+ | `cvx-kit/components/approvals` | `Approvals` client; default export = component config for `app.use` |
74
+ | `cvx-kit/test` | `registerFoundation(t)`, `registerApprovals(t)` for convex-test |
75
+
76
+ ## Minimal app wiring (the five root files)
77
+
78
+ ```ts
79
+ // convex/convex.config.ts
80
+ import { defineApp } from 'convex/server'
81
+ import foundation from 'cvx-kit/components/foundation'
82
+ import approvals from 'cvx-kit/components/approvals'
83
+ const app = defineApp()
84
+ app.use(foundation)
85
+ app.use(approvals)
86
+ export default app
87
+
88
+ // convex/triggers.ts
89
+ import { createTriggers, timestamps, appendOnly } from 'cvx-kit/triggers'
90
+ export const triggers = createTriggers<DataModel>()
91
+ timestamps(triggers, 'documents')
92
+ appendOnly(triggers, 'history')
93
+
94
+ // convex/functions.ts
95
+ export const { authQuery, authMutation, authAction, adminQuery, adminMutation,
96
+ adminAction, roleQuery, roleMutation, roleAction, systemMutation,
97
+ systemAction, include } = createAuthFunctions<DataModel>({
98
+ query, mutation, action, internalMutation, internalAction, // from ./_generated/server
99
+ getAuthUser: (ctx) => authKit.getAuthUser(ctx),
100
+ mapRole: defaultRoleMap, // 'member'→'writer'; reader|writer|admin pass
101
+ adminRoles: ['admin'],
102
+ triggers,
103
+ verifyMembership: async ({ userId, organizationId }) => { /* live check; actions only */ },
104
+ })
105
+
106
+ // convex/foundation.ts
107
+ export const { Command, Query, observability } = new Foundation(
108
+ components.foundation,
109
+ { observability: {
110
+ enabled: () => process.env.OBS === 'true',
111
+ classifyError, // → { outcome: 'denied'|'failed', errorCode }
112
+ writeAudit: (ctx, entry) => writeAuditEntry(ctx, entry),
113
+ } },
114
+ )
115
+
116
+ // convex/approvals.ts
117
+ export const approvals = new Approvals(components.approvals)
118
+ ```
119
+
120
+ ## Entity pattern
121
+
122
+ ```ts
123
+ // domain/documents/schema.ts
124
+ export const documents = zodTable('documents', (id) => ({
125
+ title: z.string(), ownerId: id('users'), secretNote: z.string(),
126
+ }), {
127
+ commandFields: ['title'], // what a command may say
128
+ publicFields: ['title', 'ownerId'], // the DTO allowlist
129
+ })
130
+
131
+ // convex/schema.ts (via domain/table.ts)
132
+ defineSchema({ documents: documents.table.index('by_owner', ['ownerId']) })
133
+
134
+ // domain/documents/commands.ts
135
+ const operations = {
136
+ 'documents.rename': Command.operation({
137
+ command: documents.commandInput.extend({ id: zid('documents'), actorId: z.string() }),
138
+ result: z.object({ ok: z.literal(true) }).strict(),
139
+ classification: 'business',
140
+ audit: ({ command }) => ({ operation: 'documents.rename', actorId: command.actorId,
141
+ aggregate: { type: 'document', id: command.id } }),
142
+ }),
143
+ } as const
144
+ const commands = new Command<MutationCtx, typeof operations>(operations)
145
+ export const executeRename = commands.exec({ operation: 'documents.rename',
146
+ handler: async (ctx, cmd) => { await ctx.db.patch(cmd.id, { title: cmd.title }); return { ok: true } } })
147
+
148
+ // api/documents.ts — thin public adapter
149
+ export const rename = authMutation({
150
+ args: documents.commandInput.extend({ id: zid('documents') }),
151
+ returns: z.object({ ok: z.literal(true) }).strict(),
152
+ handler: (ctx, args) => executeRename(ctx, { ...args, actorId: ctx.actor.userId }),
153
+ })
154
+ export const list = authQuery({
155
+ args: { limit: z.number() },
156
+ returns: z.array(documents.publicDto),
157
+ handler: (ctx, { limit }) =>
158
+ ctx.include(ctx.db.query('documents'))
159
+ .matching('by_owner', (ix) => ix.eq('ownerId', ctx.actor.userId))
160
+ .execute(limit, (rows) => rows.map(documents.toPublicDto)),
161
+ })
162
+ ```
163
+
164
+ ## Auth ctx contents
165
+
166
+ Authenticated handlers receive frozen `ctx.identity` (JWT), `ctx.user.id`,
167
+ `ctx.org = { organizationId, role }`, `ctx.role`, `ctx.actor = { userId,
168
+ organizationId, role }`, and `ctx.include`. Queries/mutations trust the JWT;
169
+ **actions live-verify membership and fail closed**. Use `ctx.actor` as the
170
+ canonical actor for audits/approvals.
171
+
172
+ ## Approvals in one block
173
+
174
+ ```ts
175
+ export const publishApproval = approvals.define({ name: 'documentPublish', steps: [
176
+ approvals.decision('managerDecision', { decisions: ['approved','rejected'],
177
+ quorum: { kind: 'count', approvals: 1 }, makerChecker: true, expiresAfterMs: 604_800_000 }),
178
+ approvals.branch('applyDecision', { approvedStepKey: 'publish', rejectedStepKey: 'notify' }),
179
+ approvals.mutation('publish', { handler: internal.domain.documents.approval_functions.applyDecision }),
180
+ approvals.notify('notify', { handler: internal.domain.documents.approval_functions.notifyRejected }),
181
+ ]})
182
+ // start(ctx, { scopeRef, resourceType, resourceRef, requester: ctx.actor, metadata? })
183
+ // decide(ctx, { runId, decision, reason? }, ctx.actor) · status/evidence/list/cancel/restart
184
+ ```
185
+
186
+ Callback handlers are `systemMutation`/`systemAction`, receive opaque strings,
187
+ and must `normalizeId` + re-check `resourceType` + verify the resource still
188
+ points at `runId`.
189
+
190
+ ## Footguns (top 8)
191
+
192
+ 1. `t.run(ctx => ctx.db...)` in convex-test and raw builders bypass ALL
193
+ triggers — seeding only.
194
+ 2. `executeResultBoundary` returns a typed failure **only when the
195
+ transaction has no effects yet**; after any write/schedule it rethrows for
196
+ rollback. Handled-vs-thrown is decided by transaction state.
197
+ 3. Observability drops events on identifier-regex mismatch — see rule 9.
198
+ 4. Mis-declared `serverFields` silently make server-owned fields
199
+ client-writable; masks are the security boundary.
200
+ 5. `include(...).resolve()` silently falls back to `fullTableScan()`.
201
+ 6. Audit callback ctx is untyped (`never`) — commands must run in mutations;
202
+ wiring a query ctx fails at runtime.
203
+ 7. Changing approval steps without bumping `compatibilityKey` lets old runs
204
+ be driven by an incompatible shape.
205
+ 8. When composing ctx manually, spread `wrapDB(ctx)` first — later spreads
206
+ 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.