cvx-kit 0.1.2 → 0.1.4
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/CHANGELOG.md +38 -0
- package/README.md +69 -24
- package/dist/agent-tools.d.mts +28 -12
- package/dist/agent-tools.mjs +1 -1
- package/dist/agent-tools.mjs.map +1 -1
- package/dist/auth.d.mts +4 -2
- package/dist/auth.mjs +1 -1
- package/dist/auth.mjs.map +1 -1
- package/dist/components/approvals/_generated/api.d.mts +1 -1
- package/dist/components/approvals/audit.mjs +1 -1
- package/dist/components/approvals/audit.mjs.map +1 -1
- package/dist/components/approvals/client.d.mts +1 -1
- package/dist/components/approvals/client.mjs +1 -1
- package/dist/components/approvals/client.mjs.map +1 -1
- package/dist/components/approvals/constants.mjs.map +1 -1
- package/dist/components/approvals/decisions.d.mts +6 -7
- package/dist/components/approvals/decisions.mjs +1 -1
- package/dist/components/approvals/decisions.mjs.map +1 -1
- package/dist/components/approvals/functions.mjs +1 -1
- package/dist/components/approvals/functions.mjs.map +1 -1
- package/dist/components/approvals/health.mjs.map +1 -1
- package/dist/components/approvals/requests.mjs.map +1 -1
- package/dist/components/approvals/validators.mjs.map +1 -1
- package/dist/components/approvals/workflow.d.mts +1 -1
- package/dist/components/approvals/workflow.mjs +1 -1
- package/dist/components/approvals/workflow.mjs.map +1 -1
- package/dist/components/approvals/workflow_steps.d.mts +3 -3
- package/dist/components/approvals/workflow_steps.mjs +1 -1
- package/dist/components/approvals/workflow_steps.mjs.map +1 -1
- package/dist/components/foundation/client.d.mts +27 -28
- package/dist/components/foundation/client.mjs +1 -1
- package/dist/components/foundation/client.mjs.map +1 -1
- package/dist/components/foundation/modules/command/command.d.mts +17 -8
- package/dist/components/foundation/modules/command/command.mjs +1 -1
- package/dist/components/foundation/modules/command/command.mjs.map +1 -1
- package/dist/components/foundation/modules/observability/observability.d.mts +1 -1
- package/dist/components/foundation/modules/observability/observability.mjs +1 -1
- package/dist/components/foundation/modules/observability/observability.mjs.map +1 -1
- package/dist/components/foundation/modules/query/query.d.mts +8 -8
- package/dist/components/foundation/modules/query/query.mjs +1 -1
- package/dist/components/foundation/modules/query/query.mjs.map +1 -1
- package/dist/components/foundation/operation.d.mts +74 -0
- package/dist/components/foundation/operation.mjs +2 -0
- package/dist/components/foundation/operation.mjs.map +1 -0
- package/dist/components/foundation/result.d.mts +1 -1
- package/dist/components/foundation/result.mjs.map +1 -1
- package/dist/components/foundation/telemetry.mjs +1 -1
- package/dist/components/foundation/telemetry.mjs.map +1 -1
- package/dist/crud.d.mts +177 -16
- package/dist/crud.mjs +1 -1
- package/dist/crud.mjs.map +1 -1
- package/dist/middleware.d.mts +7 -7
- package/dist/middleware.mjs +1 -1
- package/dist/middleware.mjs.map +1 -1
- package/dist/oxlint.d.mts +9 -0
- package/dist/oxlint.mjs +2 -0
- package/dist/oxlint.mjs.map +1 -0
- package/dist/tenancy.mjs +1 -1
- package/dist/tenancy.mjs.map +1 -1
- package/dist/triggers.mjs +1 -1
- package/dist/triggers.mjs.map +1 -1
- package/dist/webhooks.d.mts +17 -106
- package/dist/webhooks.mjs.map +1 -1
- package/dist/zod-table.d.mts +47 -91
- package/dist/zod-table.mjs +1 -1
- package/dist/zod-table.mjs.map +1 -1
- package/docs/approvals.md +57 -56
- package/docs/architecture.md +1 -1
- package/docs/auth.md +57 -33
- package/docs/commands.md +159 -44
- package/docs/conventions.md +40 -40
- package/docs/crud.md +5 -5
- package/docs/llms.md +117 -71
- package/docs/maintainability.md +4 -4
- package/docs/migration.md +33 -33
- package/docs/tenancy.md +5 -8
- package/docs/triggers.md +4 -2
- package/docs/upgrading.md +74 -7
- package/docs/zod-table.md +70 -45
- package/package.json +41 -28
- package/scripts/convex-config-bridge.mjs +7 -18
- package/scripts/extract-changelog.mjs +1 -1
- package/scripts/smoke-packed-components.mjs +35 -75
- package/scripts/test-packed-ids.mjs +149 -0
- package/scripts/test-packed-oxlint.mjs +121 -0
- package/src/test.ts +9 -17
- package/docs/plans/2026-08-14-001-feat-v0.1.0-feature-set-plan.md +0 -461
- package/docs/plans/2026-08-21-0930-feat-resolve-organization-hook-plan.md +0 -178
package/docs/auth.md
CHANGED
|
@@ -12,30 +12,52 @@ host. The kit provides the structure; the app provides the policy.
|
|
|
12
12
|
// convex/functions.ts (the single place constructors are built)
|
|
13
13
|
import { createAuthFunctions, defaultRoleMap } from 'cvx-kit/auth'
|
|
14
14
|
import {
|
|
15
|
-
|
|
15
|
+
action,
|
|
16
|
+
internalAction,
|
|
17
|
+
internalMutation,
|
|
18
|
+
internalQuery,
|
|
19
|
+
mutation,
|
|
20
|
+
query,
|
|
16
21
|
} from './_generated/server'
|
|
17
22
|
import type { DataModel } from './_generated/dataModel'
|
|
18
23
|
import { triggers } from './triggers'
|
|
19
24
|
import { authKit } from './auth'
|
|
20
25
|
|
|
21
26
|
export const {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
authQuery,
|
|
28
|
+
authMutation,
|
|
29
|
+
authAction,
|
|
30
|
+
roleQuery,
|
|
31
|
+
roleMutation,
|
|
32
|
+
roleAction,
|
|
33
|
+
adminQuery,
|
|
34
|
+
adminMutation,
|
|
35
|
+
adminAction,
|
|
36
|
+
systemQuery,
|
|
37
|
+
systemMutation,
|
|
38
|
+
systemAction,
|
|
39
|
+
include,
|
|
40
|
+
authenticatedUser,
|
|
27
41
|
} = createAuthFunctions<DataModel>({
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
42
|
+
query,
|
|
43
|
+
mutation,
|
|
44
|
+
action,
|
|
45
|
+
internalQuery,
|
|
46
|
+
internalMutation,
|
|
47
|
+
internalAction,
|
|
48
|
+
getAuthUser: (ctx) => authKit.getAuthUser(ctx),
|
|
49
|
+
mapRole: defaultRoleMap, // or your own vocabulary (see below)
|
|
50
|
+
adminRoles: ['admin'],
|
|
51
|
+
triggers, // every mutation write runs through wrapDB
|
|
52
|
+
verifyMembership: async ({ userId, organizationId }) => {
|
|
53
|
+
const memberships = await authKit.workos.userManagement.listOrganizationMemberships({
|
|
54
|
+
organizationId,
|
|
55
|
+
userId,
|
|
56
|
+
statuses: ['active'],
|
|
57
|
+
})
|
|
58
|
+
const m = memberships.data.find((c) => c.userId === userId)
|
|
59
|
+
return m ? { organizationId: m.organizationId, roleSlug: m.role.slug } : null
|
|
60
|
+
},
|
|
39
61
|
})
|
|
40
62
|
```
|
|
41
63
|
|
|
@@ -44,14 +66,14 @@ imports from `_generated/server` appear **only** in this file.
|
|
|
44
66
|
|
|
45
67
|
## The constructor families
|
|
46
68
|
|
|
47
|
-
| Constructor
|
|
48
|
-
|
|
49
|
-
| `authQuery` / `authMutation` / `authAction`
|
|
50
|
-
| `roleQuery(...roles)` / `roleMutation(...)` / `roleAction(...)` | public
|
|
51
|
-
| `adminQuery` / `adminMutation` / `adminAction`
|
|
52
|
-
| `systemQuery`
|
|
53
|
-
| `systemMutation`
|
|
54
|
-
| `systemAction`
|
|
69
|
+
| Constructor | Visibility | Auth | Extra |
|
|
70
|
+
| --------------------------------------------------------------- | ---------- | ---------------------------- | -------------------------------- |
|
|
71
|
+
| `authQuery` / `authMutation` / `authAction` | public | any authenticated org member | actions live-verify membership |
|
|
72
|
+
| `roleQuery(...roles)` / `roleMutation(...)` / `roleAction(...)` | public | listed roles only | factory — call with your roles |
|
|
73
|
+
| `adminQuery` / `adminMutation` / `adminAction` | public | `config.adminRoles` | pre-built `role*(...adminRoles)` |
|
|
74
|
+
| `systemQuery` | internal | none (trusted caller) | include-equipped, RLS-unwrapped |
|
|
75
|
+
| `systemMutation` | internal | none (trusted caller) | still trigger-wrapped |
|
|
76
|
+
| `systemAction` | internal | none | plain internal action |
|
|
55
77
|
|
|
56
78
|
All are `zCustom*` constructors from convex-helpers, so `args` and `returns`
|
|
57
79
|
take zod schemas directly and compose with `zodTable` masks.
|
|
@@ -164,14 +186,16 @@ rejects limits outside `1..maxRows` (default 100, configurable via
|
|
|
164
186
|
|
|
165
187
|
```ts
|
|
166
188
|
export const list = authQuery({
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
189
|
+
args: { ownerId: zid('users').optional(), limit: z.number() },
|
|
190
|
+
returns: z.array(documents.publicDto),
|
|
191
|
+
handler: (ctx, args) =>
|
|
192
|
+
ctx
|
|
193
|
+
.include(ctx.db.query('documents'))
|
|
194
|
+
.when(args.ownerId, (q, ownerId) =>
|
|
195
|
+
q.withIndex('by_owner', (ix) => ix.eq('ownerId', ownerId)),
|
|
196
|
+
)
|
|
197
|
+
.otherwise((q) => q.withIndex('by_owner'))
|
|
198
|
+
.execute(args.limit, (rows) => rows.map(documents.toPublicDto)),
|
|
175
199
|
})
|
|
176
200
|
```
|
|
177
201
|
|
package/docs/commands.md
CHANGED
|
@@ -25,16 +25,13 @@ import { Foundation } from 'cvx-kit/components/foundation'
|
|
|
25
25
|
import { components } from './_generated/api'
|
|
26
26
|
import { writeAuditEntry } from './audit'
|
|
27
27
|
|
|
28
|
-
export const { Command, Query, observability } = new Foundation(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
},
|
|
36
|
-
},
|
|
37
|
-
)
|
|
28
|
+
export const { Command, Query, observability } = new Foundation(components.foundation, {
|
|
29
|
+
observability: {
|
|
30
|
+
enabled: () => process.env.COMMAND_OBSERVABILITY_ENABLED === 'true',
|
|
31
|
+
classifyError: (error) => classify(error), // → { outcome: 'denied' | 'failed', errorCode }
|
|
32
|
+
writeAudit: (ctx, entry) => writeAuditEntry(ctx, entry),
|
|
33
|
+
},
|
|
34
|
+
})
|
|
38
35
|
```
|
|
39
36
|
|
|
40
37
|
Host code consumes the Foundation **only through this facade** — never deep
|
|
@@ -51,29 +48,33 @@ operation** — auditing is type-enforced, not opt-in.
|
|
|
51
48
|
import { z } from 'zod'
|
|
52
49
|
import { Command } from '../../foundation'
|
|
53
50
|
import { documents } from './schema'
|
|
51
|
+
import { zid } from 'cvx-kit/zod-table'
|
|
52
|
+
import type { MutationCtx } from '../../_generated/server'
|
|
53
|
+
|
|
54
|
+
const typed = Command.withContext<MutationCtx>()
|
|
54
55
|
|
|
55
56
|
const operations = {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
57
|
+
'documents.rename': typed.operation({
|
|
58
|
+
command: documents.commandInput.extend({ id: zid('documents') }),
|
|
59
|
+
result: z.object({ ok: z.literal(true) }).strict(),
|
|
60
|
+
classification: 'business',
|
|
61
|
+
audit: ({ command }) => ({
|
|
62
|
+
operation: 'documents.rename',
|
|
63
|
+
actorId: command.actorId,
|
|
64
|
+
aggregate: { type: 'document', id: command.id },
|
|
65
|
+
metadata: { title: command.title },
|
|
66
|
+
}),
|
|
67
|
+
}),
|
|
67
68
|
} as const
|
|
68
69
|
|
|
69
70
|
const commands = new Command<MutationCtx, typeof operations>(operations)
|
|
70
71
|
|
|
71
72
|
export const executeRename = commands.exec({
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
operation: 'documents.rename',
|
|
74
|
+
handler: async (ctx, command) => {
|
|
75
|
+
await ctx.db.patch(command.id, { title: command.title })
|
|
76
|
+
return { ok: true }
|
|
77
|
+
},
|
|
77
78
|
})
|
|
78
79
|
```
|
|
79
80
|
|
|
@@ -89,14 +90,20 @@ The public API layer then wraps the executor in an `authMutation` and passes
|
|
|
89
90
|
Foundation's injected `checkPermission(ctx, { permission, operation })`
|
|
90
91
|
runs; throw to deny. Declaring a permission with no injected checker
|
|
91
92
|
**fails closed** (`COMMAND_PERMISSION_NOT_CONFIGURED`).
|
|
92
|
-
4. **
|
|
93
|
+
4. **Prepare**, when configured: obtain an invocation-local replay decision
|
|
94
|
+
or completion closure. Replay validates its durable result, then goes
|
|
95
|
+
directly to observation, skipping middleware, both guards, handler and audit.
|
|
96
|
+
5. **Middleware and guards** — registry middleware wraps operation middleware,
|
|
97
|
+
which wraps both guards and the handler. The default guard is the
|
|
98
|
+
registry-wide guard passed as the Command's second
|
|
93
99
|
argument (`new Command(operations, { guard })`), if any.
|
|
94
|
-
|
|
100
|
+
Then the operation's own `guard(ctx, command)` checks
|
|
95
101
|
preconditions like state-machine legality, ownership beyond roles, or
|
|
96
102
|
invariants over the parsed command. Throw to deny — nothing has run yet,
|
|
97
103
|
so a denial is always clean.
|
|
98
|
-
6. **Run the handler
|
|
99
|
-
|
|
104
|
+
6. **Run the handler and unwind middleware**; parse the final chain output
|
|
105
|
+
through the `result` schema exactly once. Middleware sees schema inputs;
|
|
106
|
+
audit, completion, and the executor receive validated schema outputs.
|
|
100
107
|
7. **Audit**: call the operation's `audit({ command, result }, ctx)`. If it
|
|
101
108
|
returns non-null, the entry (plus the operation's `classification`) is
|
|
102
109
|
written through the injected `writeAudit` — **in the same transaction** as
|
|
@@ -106,8 +113,10 @@ The public API layer then wraps the executor in an `authMutation` and passes
|
|
|
106
113
|
allowlist throws (`COMMAND_AGGREGATE_NOT_DECLARED`) — the audit
|
|
107
114
|
vocabulary is enforced, not advisory. The per-operation catalog is
|
|
108
115
|
introspectable as `commands.aggregates` for tests.
|
|
109
|
-
8. **
|
|
110
|
-
|
|
116
|
+
8. **Complete**: await the closure returned by `prepare`, including when audit
|
|
117
|
+
returns null. A failure propagates inside the observation boundary.
|
|
118
|
+
9. **Emit the observation**: `{ operation, classification, outcome,
|
|
119
|
+
errorCode?, durationMs }` — completed, denied, or failed per
|
|
111
120
|
`classifyError`.
|
|
112
121
|
|
|
113
122
|
### Guards and permissions
|
|
@@ -150,11 +159,11 @@ a handler in disguise.
|
|
|
150
159
|
|
|
151
160
|
### Middleware — composable, next()-based
|
|
152
161
|
|
|
153
|
-
For wrap-around concerns (timing, tracing, context enrichment
|
|
154
|
-
|
|
162
|
+
For wrap-around concerns (timing, tracing, context enrichment) that two
|
|
163
|
+
disconnected callbacks can't express, both kernels take
|
|
155
164
|
Express/TanStack-style middleware. The chain runs **inside** the pipeline's
|
|
156
165
|
invariants: after the permission check, around [guards → handler], before the
|
|
157
|
-
result-schema
|
|
166
|
+
result-schema parse, aggregate allowlist, and audit — so middleware can
|
|
158
167
|
never skip authorization, return an invalid result, or desynchronize audit
|
|
159
168
|
from effects.
|
|
160
169
|
|
|
@@ -188,16 +197,122 @@ guard → operation guard → handler. `next()` returns the downstream result;
|
|
|
188
197
|
middleware, guards, and the handler. Skipping `next()` short-circuits
|
|
189
198
|
(guards and handler never run — the returned value still must satisfy the
|
|
190
199
|
result schema); calling it twice throws `COMMAND_MIDDLEWARE_NEXT_REUSED`.
|
|
191
|
-
Whatever leaves the chain is
|
|
200
|
+
Whatever leaves the chain is parsed through the operation's strict result
|
|
192
201
|
schema — a middleware cannot fabricate an invalid result.
|
|
193
202
|
|
|
203
|
+
### Schema-inferred callbacks and context extensions (0.1.3)
|
|
204
|
+
|
|
205
|
+
`Command.withContext<Ctx>()` binds the host context for operation definitions.
|
|
206
|
+
Its `operation()` infers parsed command fields, validated audit result fields,
|
|
207
|
+
and the declared aggregate vocabulary. Operation middleware is contextual:
|
|
208
|
+
write inline callbacks without `Command.middleware`, whose legacy low-level
|
|
209
|
+
command and result boundary intentionally remains unknown.
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
const typed = Command.withContext<Ctx>()
|
|
213
|
+
const operations = {
|
|
214
|
+
rename: typed.operation({
|
|
215
|
+
command: z.object({ title: z.string().transform((value) => value.length) }),
|
|
216
|
+
result: z.object({ ok: z.boolean() }),
|
|
217
|
+
classification: 'business',
|
|
218
|
+
aggregates: ['document'],
|
|
219
|
+
guard: (ctx, command) => requireTitleLength(ctx, command.title), // number
|
|
220
|
+
middleware: [
|
|
221
|
+
async ({ command, next }) => {
|
|
222
|
+
const result = await next() // { ok: boolean }, before validation
|
|
223
|
+
logLength(command.title)
|
|
224
|
+
return result
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
audit: ({ command, result }, ctx) => ({
|
|
228
|
+
operation: 'rename',
|
|
229
|
+
actorId: ctx.actorId,
|
|
230
|
+
aggregate: { type: 'document', id: String(command.title) },
|
|
231
|
+
metadata: { ok: result.ok },
|
|
232
|
+
}),
|
|
233
|
+
}),
|
|
234
|
+
}
|
|
235
|
+
const middleware = typed.registryMiddleware(operations, async (input) => {
|
|
236
|
+
if (input.operation === 'rename') {
|
|
237
|
+
input.command.title.toFixed() // discriminator retains schema correlation
|
|
238
|
+
}
|
|
239
|
+
return input.next()
|
|
240
|
+
})
|
|
241
|
+
const commands = new Command<Ctx, typeof operations>(operations, {
|
|
242
|
+
middleware: [middleware],
|
|
243
|
+
})
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Bind registry middleware to the same registry it describes. A heterogeneous
|
|
247
|
+
registry callback returns a union; narrowing `input.operation` preserves
|
|
248
|
+
the matching `input.command` and `input.next()` result type.
|
|
249
|
+
Reusing it with a different operation definition throws
|
|
250
|
+
`COMMAND_MIDDLEWARE_REGISTRY_MISMATCH` before the typed callback runs.
|
|
251
|
+
|
|
252
|
+
To propagate a required middleware extension to guards and handlers, use
|
|
253
|
+
`Command.withContext<Ctx, { traceId: string }>()`. Its operations require a
|
|
254
|
+
nonempty middleware tuple; the first layer must pass the declared extension
|
|
255
|
+
to `next({ context: { traceId } })` whenever it runs the downstream chain.
|
|
256
|
+
Later layers, guards and handlers see `Ctx & { traceId: string }`.
|
|
257
|
+
Audit and prepare receive the original context. The original context object
|
|
258
|
+
is not mutated. Short circuits still undergo final result validation.
|
|
259
|
+
|
|
260
|
+
### Transactional completion and replay (0.1.3)
|
|
261
|
+
|
|
262
|
+
Use `prepare(ctx, parsedCommand)` for host-owned idempotency. It runs after
|
|
263
|
+
permission and returns either `{ kind: 'replay', result: durableValue }` or
|
|
264
|
+
`{ kind: 'execute', complete: async validatedResult => { ... } }`.
|
|
265
|
+
The completion closure captures per-invocation state, including a fingerprint
|
|
266
|
+
derived from the parsed command and authenticated actor. Do not cache that
|
|
267
|
+
state in a shared context or module variable.
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
prepare: async (ctx, command) => {
|
|
271
|
+
const key = scopedKey(ctx.actorId, command.idempotencyKey)
|
|
272
|
+
const fingerprint = fingerprintOf(command)
|
|
273
|
+
const receipt = await loadReceipt(ctx, key)
|
|
274
|
+
if (receipt) {
|
|
275
|
+
if (receipt.fingerprint !== fingerprint) throw new Error('KEY_REUSED')
|
|
276
|
+
return { kind: 'replay', result: receipt.result }
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
kind: 'execute',
|
|
280
|
+
complete: async result => {
|
|
281
|
+
await saveReceipt(ctx, { key, fingerprint, result })
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Storage, actor/tenant scoping, fingerprint comparison, retention, and conflict
|
|
288
|
+
policy remain host responsibilities. Put authorization required on replay in
|
|
289
|
+
the injected permission checker or prepare lookup; both guards are skipped
|
|
290
|
+
on replay. A permission slug without a checker still fails closed. Ordinary
|
|
291
|
+
middleware short-circuiting is not this replay path and still audits.
|
|
292
|
+
|
|
293
|
+
Replays are parsed through `result` by default. If that schema transforms its
|
|
294
|
+
input, store the completion callback's validated output and supply
|
|
295
|
+
`replayResult` with an output validator (for example `z.number().int()` when
|
|
296
|
+
`result` transforms a string into a number). This prevents reapplying an input
|
|
297
|
+
transform to an already transformed durable result. An invalid replay fails
|
|
298
|
+
inside observation; successful replay does not invoke completion again.
|
|
299
|
+
|
|
300
|
+
Completion is awaited after result validation, audit resolution, aggregate
|
|
301
|
+
validation, and audit writing, even on audit-null paths. Errors from prepare,
|
|
302
|
+
replay validation, audit, or completion propagate and are passed to the host's
|
|
303
|
+
error classifier. Observation success means this command finished; the
|
|
304
|
+
enclosing mutation has not necessarily committed yet.
|
|
305
|
+
|
|
194
306
|
The Query kernel takes the same shape: `new Query({ defaults, middleware,
|
|
195
307
|
execute })` plus per-executor `middleware: [...]`, with `Query.middleware`
|
|
196
308
|
as the typing helper. Kernel middleware runs before executor middleware,
|
|
197
309
|
inside the host's injected `execute` policy.
|
|
198
310
|
|
|
199
|
-
|
|
200
|
-
|
|
311
|
+
For atomic rollback, let failures escape the enclosing Convex mutation.
|
|
312
|
+
Catching and swallowing a failure inside that mutation can commit earlier
|
|
313
|
+
business, audit, and completion writes. Rethrow after effects, or use the
|
|
314
|
+
effect-aware `executeResultBoundary` below. External I/O in actions is not
|
|
315
|
+
rolled back; these transaction guarantees concern Convex mutation writes.
|
|
201
316
|
|
|
202
317
|
### Dynamic dispatch
|
|
203
318
|
|
|
@@ -205,9 +320,9 @@ audit entry together. Audit and effects can never disagree.
|
|
|
205
320
|
|
|
206
321
|
```ts
|
|
207
322
|
export const executeAny = commands.exec({
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
323
|
+
dispatcher: z.object({ kind: z.enum(OPERATION_KINDS) /* from constants.ts */ }),
|
|
324
|
+
select: (input) => `documents.${input.kind}` as const,
|
|
325
|
+
handler,
|
|
211
326
|
})
|
|
212
327
|
```
|
|
213
328
|
|
|
@@ -269,9 +384,9 @@ knowing. Metadata defaults are set at construction and merged per-executor.
|
|
|
269
384
|
else destructures from it.
|
|
270
385
|
2. Never deep-import the component's `modules/*` or `result.ts` paths from app
|
|
271
386
|
code — the `client.ts` facade is the contract.
|
|
272
|
-
3.
|
|
273
|
-
|
|
274
|
-
mutations.
|
|
387
|
+
3. Use `Command.withContext<MutationCtx>()` for typed callbacks and construct
|
|
388
|
+
the Command with a compatible host context. Legacy `Command.operation`
|
|
389
|
+
keeps its low-level callback signature. Commands with writes run in mutations.
|
|
275
390
|
4. Name operations `domain.verb` and error codes `UPPER_SNAKE` or your
|
|
276
391
|
telemetry silently disappears (see regexes above).
|
|
277
392
|
5. Keep operation registries frozen (`as const`) in the domain's
|
package/docs/conventions.md
CHANGED
|
@@ -99,16 +99,16 @@ another file's function to shorten an import.
|
|
|
99
99
|
|
|
100
100
|
### Root files
|
|
101
101
|
|
|
102
|
-
| File
|
|
103
|
-
|
|
104
|
-
| `convex.config.ts` | `defineApp()`, one `app.use(...)` per component, `defineApp({ env })` declarations, `export default app`
|
|
105
|
-
| `schema.ts`
|
|
106
|
-
| `functions.ts`
|
|
107
|
-
| `triggers.ts`
|
|
108
|
-
| `foundation.ts`
|
|
109
|
-
| `<component>.ts`
|
|
110
|
-
| `http.ts`
|
|
111
|
-
| `crons.ts`
|
|
102
|
+
| File | Contains | Never contains |
|
|
103
|
+
| ------------------ | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
|
104
|
+
| `convex.config.ts` | `defineApp()`, one `app.use(...)` per component, `defineApp({ env })` declarations, `export default app` | anything else |
|
|
105
|
+
| `schema.ts` | `defineSchema(domainTables)` | table definitions, validators |
|
|
106
|
+
| `functions.ts` | the single `createAuthFunctions<DataModel>()` call and its exported constructors | handlers, business policy beyond the injected config |
|
|
107
|
+
| `triggers.ts` | `createTriggers()`, `timestamps`/`appendOnly`/`noDelete` registrations, calls to per-entity `register<Entity>Triggers` | trigger _logic_ for a specific entity (that lives in the entity) |
|
|
108
|
+
| `foundation.ts` | the single `new Foundation(...)`, destructured exports | command definitions |
|
|
109
|
+
| `<component>.ts` | `new <Client>(components.<name>)` + minimal admin plumbing | workflow/business definitions |
|
|
110
|
+
| `http.ts` | routes: verify signature → parse payload → delegate to a domain function | webhook business logic |
|
|
111
|
+
| `crons.ts` | `cronJobs()` declarations targeting `internal.domain.<entity>...` | handler logic |
|
|
112
112
|
|
|
113
113
|
### `api/<entity>.ts` — the adapter file
|
|
114
114
|
|
|
@@ -176,7 +176,7 @@ compile. Numeric limits and durations the entity owns also live here.
|
|
|
176
176
|
### `domain/<entity>/commands.ts` — every state change
|
|
177
177
|
|
|
178
178
|
1. The frozen operation registry: `Command.operation({ command, result,
|
|
179
|
-
|
|
179
|
+
classification, audit })` per operation, keys `'<entity>.<verb>'`.
|
|
180
180
|
2. One `new Command<Ctx, typeof operations>(operations)`.
|
|
181
181
|
3. Exported executors: `export const execute<Verb><Entity> = commands.exec(...)`.
|
|
182
182
|
4. Private handler helpers at the bottom.
|
|
@@ -197,7 +197,7 @@ Handlers call rules; rules never call handlers.
|
|
|
197
197
|
|
|
198
198
|
### `domain/<entity>/shared.ts` — entity-private helpers
|
|
199
199
|
|
|
200
|
-
Code shared by two or more files
|
|
200
|
+
Code shared by two or more files _of this entity_. If a second entity needs
|
|
201
201
|
it, it moves to `domain/shared/` — never a sibling import.
|
|
202
202
|
|
|
203
203
|
### `domain/<entity>/actions.ts` — external side effects
|
|
@@ -234,25 +234,25 @@ ground: code with one consumer moves back to that consumer.
|
|
|
234
234
|
|
|
235
235
|
## 3. Naming grammar
|
|
236
236
|
|
|
237
|
-
| Thing
|
|
238
|
-
|
|
239
|
-
| Entity directory
|
|
240
|
-
| Table
|
|
241
|
-
| Fields
|
|
242
|
-
| Index
|
|
243
|
-
| Command operation
|
|
244
|
-
| Error code
|
|
245
|
-
| Vocabulary tuple
|
|
246
|
-
| Domain executor
|
|
247
|
-
| Rule predicate
|
|
248
|
-
| Trigger registration
|
|
249
|
-
| Internal-callback file | `<purpose>_functions.ts`, sibling of `<purpose>.ts`
|
|
250
|
-
| zodTable export
|
|
251
|
-
| DTO schema
|
|
252
|
-
| Zod schema value
|
|
253
|
-
| Type
|
|
254
|
-
| Test file
|
|
255
|
-
| Public function path
|
|
237
|
+
| Thing | Convention | Example shape |
|
|
238
|
+
| ---------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
|
|
239
|
+
| Entity directory | plural camelCase, matches table name | `domain/<entities>/` |
|
|
240
|
+
| Table | plural camelCase | `<entities>` |
|
|
241
|
+
| Fields | camelCase; foreign keys `<other>Id`; timestamps only via the kit | `ownerId`, `archivedAt` |
|
|
242
|
+
| Index | `by_<field>` / `by_<field>_and_<field>`, declaration order, nested paths flattened with `_` | `by_ownerId_and_state_status` |
|
|
243
|
+
| Command operation | `<entity>.<verb>` lowercase dotted; must match `/^[A-Za-z][A-Za-z0-9_.-]{0,159}$/` | `<entities>.approve` |
|
|
244
|
+
| Error code | `UPPER_SNAKE`, matching `/^[A-Z][A-Z0-9_]{0,95}$/`, defined in one owner | `INVALID_REQUEST_BOUNDARY` |
|
|
245
|
+
| Vocabulary tuple | `UPPER_SNAKE_CASE` `as const`; derived type PascalCase singular | `<ENTITY>_STATES` / `<Entity>State` |
|
|
246
|
+
| Domain executor | `execute<Verb><Entity>` | `executeApprove<Entity>` |
|
|
247
|
+
| Rule predicate | `can<Verb>` / `is<Condition>` | `canApprove`, `isTransitionLegal` |
|
|
248
|
+
| Trigger registration | `register<Entity>Triggers(triggers)` | exported from the entity, called in root `triggers.ts` |
|
|
249
|
+
| Internal-callback file | `<purpose>_functions.ts`, sibling of `<purpose>.ts` | `approval_functions.ts` |
|
|
250
|
+
| zodTable export | camelCase plural, same as table | `export const <entities> = zodTable(...)` |
|
|
251
|
+
| DTO schema | `<entity><Purpose>Dto` | `<entity>SummaryDto` |
|
|
252
|
+
| Zod schema value | camelCase noun | `commandInput`, `publicDto` |
|
|
253
|
+
| Type | PascalCase; no `I`/`T` prefixes | `<Entity>State` |
|
|
254
|
+
| Test file | `<subject>.test.ts` in `__tests__/` | `commands.test.ts` |
|
|
255
|
+
| Public function path | `api/<entity>:<fn>` — clients never call a root path | — |
|
|
256
256
|
|
|
257
257
|
The two regexes are enforced at runtime: observability silently drops events
|
|
258
258
|
whose operation/classification/errorCode don't match. The rest is enforced by
|
|
@@ -264,14 +264,14 @@ the architecture tests — a naming rule without a test is a wish.
|
|
|
264
264
|
|
|
265
265
|
Allowed, per zone (anything not listed is forbidden):
|
|
266
266
|
|
|
267
|
-
| From
|
|
268
|
-
|
|
269
|
-
| `api/<entity>.ts`
|
|
270
|
-
| `domain/<entity>/*`
|
|
271
|
-
| `domain/shared/*`
|
|
272
|
-
| root facades
|
|
273
|
-
| `functions.ts`
|
|
274
|
-
| `components/<name>/**` | its own directory + npm deps only — no host imports, no `process.env`
|
|
267
|
+
| From | May import |
|
|
268
|
+
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
269
|
+
| `api/<entity>.ts` | `functions.ts`, `domain/<entity>/*`, `domain/shared/*`, zod |
|
|
270
|
+
| `domain/<entity>/*` | `functions.ts`, `foundation.ts`, component facades, `domain/shared/*`, own directory, generated `internal`/`api` (references only), kit modules |
|
|
271
|
+
| `domain/shared/*` | kit modules, vendor SDKs (this is the ONLY home for vendor clients) |
|
|
272
|
+
| root facades | kit modules, `components` from generated api |
|
|
273
|
+
| `functions.ts` | `_generated/server` (the only file allowed to) |
|
|
274
|
+
| `components/<name>/**` | its own directory + npm deps only — no host imports, no `process.env` |
|
|
275
275
|
|
|
276
276
|
Forbidden everywhere: sibling-entity imports, `api/` from `domain/`,
|
|
277
277
|
component internals (anything below a component's client facade),
|
|
@@ -286,6 +286,6 @@ component internals (anything below a component's client facade),
|
|
|
286
286
|
- Zod objects that cross a boundary are `.strict()` — always.
|
|
287
287
|
- `Object.freeze` for registries and long-lived configuration objects.
|
|
288
288
|
- Comments state constraints the code can't (`// Optional only for rows
|
|
289
|
-
|
|
289
|
+
created before <epoch>.`) — never narration of the next line.
|
|
290
290
|
- One entity concept per file; when a file serves two purposes, split it
|
|
291
291
|
along the anatomy in §2.
|
package/docs/crud.md
CHANGED
|
@@ -36,11 +36,11 @@ export const create = authMutation({
|
|
|
36
36
|
|
|
37
37
|
## What gets generated
|
|
38
38
|
|
|
39
|
-
| Operation
|
|
40
|
-
|
|
41
|
-
| `<table>.create`
|
|
42
|
-
| `<table>.update`
|
|
43
|
-
| `<table>.archive` | `{ id }`
|
|
39
|
+
| Operation | Input | Effect | Audit aggregate id |
|
|
40
|
+
| ----------------- | ----------------------------------------------- | ------------------------------------ | ------------------ |
|
|
41
|
+
| `<table>.create` | `commandInput` (strict) + `enrich` stamps | insert | the new row id |
|
|
42
|
+
| `<table>.update` | `{ id, data: commandInput.partial() }` (strict) | patch | the row id |
|
|
43
|
+
| `<table>.archive` | `{ id }` | sets `archivedAt` (soft delete ONLY) | the row id |
|
|
44
44
|
|
|
45
45
|
No hard-delete operation is generated — kit doctrine (`noDelete` +
|
|
46
46
|
`archivedAt`).
|