cvx-kit 0.1.7 → 0.1.8
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 +59 -43
- package/dist/components/foundation/client.d.mts +8 -0
- package/dist/components/foundation/client.mjs.map +1 -1
- package/dist/modules/effect/api-runtime.d.mts +14 -4
- package/dist/modules/effect/api-runtime.mjs +1 -1
- package/dist/modules/effect/api-runtime.mjs.map +1 -1
- package/dist/modules/effect/api-zod.d.mts +5 -5
- package/dist/modules/effect/api-zod.mjs.map +1 -1
- package/dist/modules/effect/api.d.mts +6 -6
- package/dist/modules/effect/api.mjs.map +1 -1
- package/dist/modules/effect/command.d.mts +6 -2
- package/dist/modules/effect/command.mjs +1 -1
- package/dist/modules/effect/command.mjs.map +1 -1
- package/dist/modules/effect/query.d.mts +5 -1
- package/dist/modules/effect/query.mjs +1 -1
- package/dist/modules/effect/query.mjs.map +1 -1
- package/docs/architecture.md +3 -3
- package/docs/auth.md +10 -4
- package/docs/command-context.md +150 -0
- package/docs/commands-legacy.md +511 -0
- package/docs/commands.md +113 -451
- package/docs/conventions.md +15 -5
- package/docs/crud.md +4 -0
- package/docs/domain-operations.md +1 -1
- package/docs/effect.md +14 -3
- package/docs/llms.md +69 -45
- package/docs/migration.md +1 -1
- package/docs/upgrading.md +9 -0
- package/package.json +1 -1
- package/scripts/test-packed-effect.mjs +3 -0
package/docs/commands.md
CHANGED
|
@@ -1,105 +1,30 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Commands — declare handlers once, execute through Effect
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
from it; there is no separate command import.
|
|
3
|
+
Use `createEffectFoundation` from `cvx-kit/effect` for application commands.
|
|
4
|
+
The Foundation binds observability, permissions, and audit writing once. Each
|
|
5
|
+
registry injects its domain context and declares schemas, handler, and hooks
|
|
6
|
+
inside `command({...})`. `exec(operation, input, host)` returns a lazy Effect;
|
|
7
|
+
execution does not accept a replacement handler.
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
Install the Effect peer before importing this facade:
|
|
11
10
|
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
import { defineApp } from 'convex/server'
|
|
15
|
-
import foundation from 'cvx-kit/components/foundation/convex.config'
|
|
16
|
-
|
|
17
|
-
const app = defineApp()
|
|
18
|
-
app.use(foundation)
|
|
19
|
-
export default app
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
```ts
|
|
23
|
-
// convex/foundation.ts — declared once, the single kernel source
|
|
24
|
-
import { Foundation } from 'cvx-kit/components/foundation'
|
|
25
|
-
import { components } from './_generated/api'
|
|
26
|
-
import { writeAuditEntry } from './audit'
|
|
27
|
-
|
|
28
|
-
export const { Command, Query, observability } = new Foundation(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
|
-
})
|
|
11
|
+
```sh
|
|
12
|
+
bun add effect@'>=4.0.1 <5'
|
|
35
13
|
```
|
|
36
14
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
nested domain integrations, and shared API execution.
|
|
44
|
-
|
|
45
|
-
## Incremental migration to Effect
|
|
15
|
+
The old `Foundation.Command` constructor and `exec({ operation, handler })`
|
|
16
|
+
API are **deprecated**, retained for compatibility with existing consumers.
|
|
17
|
+
Their [compatibility reference](./commands-legacy.md) is for migration and
|
|
18
|
+
maintenance. The Foundation component, Query kernel, observability, and
|
|
19
|
+
transaction result helpers remain available. No component mount is required
|
|
20
|
+
by `createEffectFoundation` itself.
|
|
46
21
|
|
|
47
|
-
|
|
48
|
-
context-first handler arguments, and Promise executors. No existing consumer
|
|
49
|
-
needs to change when another domain adopts `cvx-kit/effect`.
|
|
50
|
-
|
|
51
|
-
This legacy factory accepts the mounted Foundation component and defines all
|
|
52
|
-
of its domain dependencies through the host context:
|
|
53
|
-
|
|
54
|
-
<!-- packed-effect-example -->
|
|
55
|
-
|
|
56
|
-
```ts
|
|
57
|
-
import { Foundation, type AuditEntryInput } from 'cvx-kit/components/foundation'
|
|
58
|
-
import { z } from 'zod'
|
|
59
|
-
|
|
60
|
-
type Host = {
|
|
61
|
-
actorId: string
|
|
62
|
-
rename: (id: string, title: string) => Promise<void>
|
|
63
|
-
appendAudit: (entry: AuditEntryInput) => Promise<void>
|
|
64
|
-
}
|
|
65
|
-
const input = z.object({ id: z.string(), title: z.string() }).strict()
|
|
66
|
-
const result = z.object({ ok: z.literal(true) }).strict()
|
|
67
|
-
|
|
68
|
-
export function createLegacyRename(component: ConstructorParameters<typeof Foundation>[0]) {
|
|
69
|
-
const { Command } = new Foundation(component, {
|
|
70
|
-
observability: {
|
|
71
|
-
enabled: false,
|
|
72
|
-
classifyError: () => ({ outcome: 'failed', errorCode: 'RENAME_FAILED' }),
|
|
73
|
-
writeAudit: (host: Host, entry) => host.appendAudit(entry),
|
|
74
|
-
},
|
|
75
|
-
})
|
|
76
|
-
const typed = Command.withContext<Host>()
|
|
77
|
-
const operations = {
|
|
78
|
-
rename: typed.operation({
|
|
79
|
-
command: input,
|
|
80
|
-
result,
|
|
81
|
-
classification: 'business',
|
|
82
|
-
audit: ({ command }, host) => ({
|
|
83
|
-
operation: 'documents.rename',
|
|
84
|
-
actorId: host.actorId,
|
|
85
|
-
aggregate: { type: 'document', id: command.id },
|
|
86
|
-
}),
|
|
87
|
-
}),
|
|
88
|
-
}
|
|
89
|
-
const commands = new Command<Host, typeof operations>(operations)
|
|
90
|
-
return commands.exec({
|
|
91
|
-
operation: 'rename',
|
|
92
|
-
handler: async (host, command) => {
|
|
93
|
-
await host.rename(command.id, command.title)
|
|
94
|
-
return { ok: true }
|
|
95
|
-
},
|
|
96
|
-
})
|
|
97
|
-
}
|
|
98
|
-
```
|
|
22
|
+
## Declare commands
|
|
99
23
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
`
|
|
24
|
+
This complete example uses an ordinary async handler. It still executes through
|
|
25
|
+
the Effect lifecycle, with input-first, context-second handler arguments.
|
|
26
|
+
`context` resolves per invocation; shared registries must not capture an
|
|
27
|
+
authenticated request context.
|
|
103
28
|
|
|
104
29
|
<!-- packed-effect-example -->
|
|
105
30
|
|
|
@@ -125,10 +50,11 @@ const foundation = createEffectFoundation({
|
|
|
125
50
|
export const commands = foundation.Command({
|
|
126
51
|
context: (host: Host) => host,
|
|
127
52
|
operations: ({ command }) => ({
|
|
128
|
-
rename: command({
|
|
53
|
+
'documents.rename': command({
|
|
129
54
|
input,
|
|
130
55
|
result,
|
|
131
56
|
classification: 'business',
|
|
57
|
+
aggregates: ['document'],
|
|
132
58
|
handler: async (input, context) => {
|
|
133
59
|
await context.rename(input.id, input.title)
|
|
134
60
|
return { ok: true as const }
|
|
@@ -141,371 +67,107 @@ export const commands = foundation.Command({
|
|
|
141
67
|
}),
|
|
142
68
|
}),
|
|
143
69
|
})
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
Pass the resulting Effect to a handler registered through a shared
|
|
147
|
-
`effectApiBuilder` or `effectZodApiBuilder`; see [the complete domain-to-API
|
|
148
|
-
example](./effect.md). Domain composition does not start a separate runner.
|
|
149
|
-
Ordinary throws and rejected Promises become defects; use `Effect.fail` or
|
|
150
|
-
`Effect.tryPromise` for an inferred typed error channel. The native Convex
|
|
151
|
-
reference retains argument/result types, but does not transport the server's
|
|
152
|
-
Effect error and service types.
|
|
153
|
-
|
|
154
|
-
Both facades share command lifecycle ordering, validation, audit, and replay
|
|
155
|
-
rules. Commands that write remain inside a Convex mutation, and failures must
|
|
156
|
-
escape that mutation to roll its writes back.
|
|
157
|
-
|
|
158
|
-
## Defining commands — per domain
|
|
159
70
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
operation** — auditing is type-enforced, not opt-in.
|
|
163
|
-
|
|
164
|
-
```ts
|
|
165
|
-
// convex/domain/documents/commands.ts
|
|
166
|
-
import { z } from 'zod'
|
|
167
|
-
import { Command } from '../../foundation'
|
|
168
|
-
import { documents } from './schema'
|
|
169
|
-
import { zid } from 'cvx-kit/zod-table'
|
|
170
|
-
import type { MutationCtx } from '../../_generated/server'
|
|
171
|
-
|
|
172
|
-
const typed = Command.withContext<MutationCtx>()
|
|
173
|
-
|
|
174
|
-
const operations = {
|
|
175
|
-
'documents.rename': typed.operation({
|
|
176
|
-
command: documents.commandInput.extend({ id: zid('documents') }),
|
|
177
|
-
result: z.object({ ok: z.literal(true) }).strict(),
|
|
178
|
-
classification: 'business',
|
|
179
|
-
audit: ({ command }) => ({
|
|
180
|
-
operation: 'documents.rename',
|
|
181
|
-
actorId: command.actorId,
|
|
182
|
-
aggregate: { type: 'document', id: command.id },
|
|
183
|
-
metadata: { title: command.title },
|
|
184
|
-
}),
|
|
185
|
-
}),
|
|
186
|
-
} as const
|
|
187
|
-
|
|
188
|
-
const commands = new Command<MutationCtx, typeof operations>(operations)
|
|
189
|
-
|
|
190
|
-
export const executeRename = commands.exec({
|
|
191
|
-
operation: 'documents.rename',
|
|
192
|
-
handler: async (ctx, command) => {
|
|
193
|
-
await ctx.db.patch(command.id, { title: command.title })
|
|
194
|
-
return { ok: true }
|
|
195
|
-
},
|
|
196
|
-
})
|
|
71
|
+
export const executeRename = (value: z.input<typeof input>, host: Host) =>
|
|
72
|
+
commands.exec('documents.rename', value, host)
|
|
197
73
|
```
|
|
198
74
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
75
|
+
`classification` and `audit` are mandatory. Input decoding precedes the handler;
|
|
76
|
+
final result decoding follows middleware. Handlers return the result schema's
|
|
77
|
+
input; audit and completion receive its validated output. Ordinary values,
|
|
78
|
+
Promises, `Effect.gen`, and `Effect.fn` all work inside the declaration. Throws
|
|
79
|
+
and rejected Promises become defects; use `Effect.fail` or `Effect.tryPromise`
|
|
80
|
+
for expected failures in the inferred error channel.
|
|
202
81
|
|
|
203
|
-
##
|
|
82
|
+
## Execute at the API boundary
|
|
204
83
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
3. **Permission check** — if the operation declares `permission`, the
|
|
208
|
-
Foundation's injected `checkPermission(ctx, { permission, operation })`
|
|
209
|
-
runs; throw to deny. Declaring a permission with no injected checker
|
|
210
|
-
**fails closed** (`COMMAND_PERMISSION_NOT_CONFIGURED`).
|
|
211
|
-
4. **Prepare**, when configured: obtain an invocation-local replay decision
|
|
212
|
-
or completion closure. Replay validates its durable result, then goes
|
|
213
|
-
directly to observation, skipping middleware, both guards, handler and audit.
|
|
214
|
-
5. **Middleware and guards** — registry middleware wraps operation middleware,
|
|
215
|
-
which wraps both guards and the handler. The default guard is the
|
|
216
|
-
registry-wide guard passed as the Command's second
|
|
217
|
-
argument (`new Command(operations, { guard })`), if any.
|
|
218
|
-
Then the operation's own `guard(ctx, command)` checks
|
|
219
|
-
preconditions like state-machine legality, ownership beyond roles, or
|
|
220
|
-
invariants over the parsed command. Throw to deny — nothing has run yet,
|
|
221
|
-
so a denial is always clean.
|
|
222
|
-
6. **Run the handler and unwind middleware**; parse the final chain output
|
|
223
|
-
through the `result` schema exactly once. Middleware sees schema inputs;
|
|
224
|
-
audit, completion, and the executor receive validated schema outputs.
|
|
225
|
-
7. **Audit**: call the operation's `audit({ command, result }, ctx)`. If it
|
|
226
|
-
returns non-null, the entry (plus the operation's `classification`) is
|
|
227
|
-
written through the injected `writeAudit` — **in the same transaction** as
|
|
228
|
-
the handler's writes. Returning `null` skips the audit (the ontology
|
|
229
|
-
convention: failures are not audited). When the operation declares
|
|
230
|
-
`aggregates: [...]`, an audit whose `aggregate.type` is not in that
|
|
231
|
-
allowlist throws (`COMMAND_AGGREGATE_NOT_DECLARED`) — the audit
|
|
232
|
-
vocabulary is enforced, not advisory. The per-operation catalog is
|
|
233
|
-
introspectable as `commands.aggregates` for tests.
|
|
234
|
-
8. **Complete**: await the closure returned by `prepare`, including when audit
|
|
235
|
-
returns null. A failure propagates inside the observation boundary.
|
|
236
|
-
9. **Emit the observation**: `{ operation, classification, outcome,
|
|
237
|
-
errorCode?, durationMs }` — completed, denied, or failed per
|
|
238
|
-
`classifyError`.
|
|
84
|
+
Wrap the application's trusted `authMutation` once with `effectZodApiBuilder`
|
|
85
|
+
in `convex/functions.ts`, then return the command Effect from the endpoint:
|
|
239
86
|
|
|
240
|
-
|
|
87
|
+
This wiring sketch assumes the existing authenticated constructor, command
|
|
88
|
+
registry, schemas, and a mapping to the registry's dependencies. Keep the
|
|
89
|
+
application's existing function export name:
|
|
241
90
|
|
|
242
91
|
```ts
|
|
243
|
-
// convex/
|
|
244
|
-
export const
|
|
245
|
-
|
|
246
|
-
{
|
|
247
|
-
observability: { ... },
|
|
248
|
-
checkPermission: (ctx, { permission }) =>
|
|
249
|
-
requirePermission(ctx.identity, permission), // throw to deny
|
|
250
|
-
},
|
|
251
|
-
)
|
|
252
|
-
|
|
253
|
-
// domain/<module>/commands.ts
|
|
254
|
-
const operations = {
|
|
255
|
-
'<entities>.publish': Command.operation({
|
|
256
|
-
command: <entities>.commandInput.extend({ id: zid('<entities>') }),
|
|
257
|
-
result: z.object({ ok: z.literal(true) }).strict(),
|
|
258
|
-
classification: 'business',
|
|
259
|
-
permission: '<domain>.manage', // checked first
|
|
260
|
-
aggregates: ['<aggregate-type>'], // audit vocabulary allowlist
|
|
261
|
-
guard: async (ctx, command) => { // precondition, pre-handler
|
|
262
|
-
const row = await requireTenantReference(ctx.tenant, () => ctx.db.get(command.id))
|
|
263
|
-
if (row.state !== 'draft') errors.throw({ code: 'INVALID_STATE' })
|
|
264
|
-
},
|
|
265
|
-
audit: ({ command }) => ({ ... }),
|
|
266
|
-
}),
|
|
267
|
-
} as const
|
|
268
|
-
|
|
269
|
-
const commands = new Command<MutationCtx, typeof operations>(operations, {
|
|
270
|
-
guard: (ctx) => assertNotReadonlyWindow(ctx), // registry-wide default
|
|
92
|
+
// convex/functions.ts
|
|
93
|
+
export const authMutation = effectZodApiBuilder(base.authMutation, {
|
|
94
|
+
context: (ctx) => ({ commands: commands.withContext(toCommandContext(ctx)) }),
|
|
271
95
|
})
|
|
272
|
-
```
|
|
273
|
-
|
|
274
|
-
Guards deny by throwing; a denial before the handler never needs rollback
|
|
275
|
-
because nothing has executed. Keep guards read-only — a guard that writes is
|
|
276
|
-
a handler in disguise.
|
|
277
|
-
|
|
278
|
-
### Middleware — composable, next()-based
|
|
279
96
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
never skip authorization, return an invalid result, or desynchronize audit
|
|
286
|
-
from effects.
|
|
287
|
-
|
|
288
|
-
```ts
|
|
289
|
-
const timing = Command.middleware(async ({ operation, next }) => {
|
|
290
|
-
const started = Date.now()
|
|
291
|
-
const result = await next() // inner middleware → guards → handler
|
|
292
|
-
observability.observe // (or your own sink)
|
|
293
|
-
console.info(`${operation} took ${Date.now() - started}ms`)
|
|
294
|
-
return result
|
|
295
|
-
})
|
|
296
|
-
|
|
297
|
-
const withVendor = Command.middleware(async ({ context, next }) =>
|
|
298
|
-
next({ context: { vendor: await loadVendor(context) } })) // enriches downstream ctx
|
|
299
|
-
|
|
300
|
-
const commands = new Command<Ctx, typeof operations>(operations, {
|
|
301
|
-
guard: (ctx) => assertNotReadonlyWindow(ctx),
|
|
302
|
-
middleware: [timing], // registry-wide, outermost
|
|
303
|
-
})
|
|
304
|
-
|
|
305
|
-
'<entities>.publish': Command.operation({
|
|
306
|
-
...,
|
|
307
|
-
middleware: [withVendor], // per-operation, inside registry chain
|
|
308
|
-
guard: (ctx, command) => { /* ctx.vendor available here */ },
|
|
97
|
+
// convex/api/documents.ts
|
|
98
|
+
export const rename = authMutation({
|
|
99
|
+
args: renameInput,
|
|
100
|
+
returns: renameResult,
|
|
101
|
+
handler: (ctx, input) => ctx.commands.exec('documents.rename', input),
|
|
309
102
|
})
|
|
310
103
|
```
|
|
311
104
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
`
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
`
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
permission and returns either `{ kind: 'replay', result: durableValue }` or
|
|
382
|
-
`{ kind: 'execute', complete: async validatedResult => { ... } }`.
|
|
383
|
-
The completion closure captures per-invocation state, including a fingerprint
|
|
384
|
-
derived from the parsed command and authenticated actor. Do not cache that
|
|
385
|
-
state in a shared context or module variable.
|
|
386
|
-
|
|
387
|
-
```ts
|
|
388
|
-
prepare: async (ctx, command) => {
|
|
389
|
-
const key = scopedKey(ctx.actorId, command.idempotencyKey)
|
|
390
|
-
const fingerprint = fingerprintOf(command)
|
|
391
|
-
const receipt = await loadReceipt(ctx, key)
|
|
392
|
-
if (receipt) {
|
|
393
|
-
if (receipt.fingerprint !== fingerprint) throw new Error('KEY_REUSED')
|
|
394
|
-
return { kind: 'replay', result: receipt.result }
|
|
395
|
-
}
|
|
396
|
-
return {
|
|
397
|
-
kind: 'execute',
|
|
398
|
-
complete: async result => {
|
|
399
|
-
await saveReceipt(ctx, { key, fingerprint, result })
|
|
400
|
-
},
|
|
401
|
-
}
|
|
402
|
-
},
|
|
403
|
-
```
|
|
404
|
-
|
|
405
|
-
Storage, actor/tenant scoping, fingerprint comparison, retention, and conflict
|
|
406
|
-
policy remain host responsibilities. Put authorization required on replay in
|
|
407
|
-
the injected permission checker or prepare lookup; both guards are skipped
|
|
408
|
-
on replay. A permission slug without a checker still fails closed. Ordinary
|
|
409
|
-
middleware short-circuiting is not this replay path and still audits.
|
|
410
|
-
|
|
411
|
-
Replays are parsed through `result` by default. If that schema transforms its
|
|
412
|
-
input, store the completion callback's validated output and supply
|
|
413
|
-
`replayResult` with an output validator (for example `z.number().int()` when
|
|
414
|
-
`result` transforms a string into a number). This prevents reapplying an input
|
|
415
|
-
transform to an already transformed durable result. An invalid replay fails
|
|
416
|
-
inside observation; successful replay does not invoke completion again.
|
|
417
|
-
|
|
418
|
-
Completion is awaited after result validation, audit resolution, aggregate
|
|
419
|
-
validation, and audit writing, even on audit-null paths. Errors from prepare,
|
|
420
|
-
replay validation, audit, or completion propagate and are passed to the host's
|
|
421
|
-
error classifier. Observation success means this command finished; the
|
|
422
|
-
enclosing mutation has not necessarily committed yet.
|
|
423
|
-
|
|
424
|
-
The Query kernel takes the same shape: `new Query({ defaults, middleware,
|
|
425
|
-
execute })` plus per-executor `middleware: [...]`, with `Query.middleware`
|
|
426
|
-
as the typing helper. Kernel middleware runs before executor middleware,
|
|
427
|
-
inside the host's injected `execute` policy.
|
|
428
|
-
|
|
429
|
-
For atomic rollback, let failures escape the enclosing Convex mutation.
|
|
430
|
-
Catching and swallowing a failure inside that mutation can commit earlier
|
|
431
|
-
business, audit, and completion writes. Rethrow after effects, or use the
|
|
432
|
-
effect-aware `executeResultBoundary` below. External I/O in actions is not
|
|
433
|
-
rolled back; these transaction guarantees concern Convex mutation writes.
|
|
434
|
-
|
|
435
|
-
### Dynamic dispatch
|
|
436
|
-
|
|
437
|
-
`exec` has a second overload for operation selection at runtime:
|
|
438
|
-
|
|
439
|
-
```ts
|
|
440
|
-
export const executeAny = commands.exec({
|
|
441
|
-
dispatcher: z.object({ kind: z.enum(OPERATION_KINDS) /* from constants.ts */ }),
|
|
442
|
-
select: (input) => `documents.${input.kind}` as const,
|
|
443
|
-
handler,
|
|
444
|
-
})
|
|
445
|
-
```
|
|
446
|
-
|
|
447
|
-
An unconfigured operation throws `COMMAND_OPERATION_NOT_CONFIGURED`.
|
|
448
|
-
|
|
449
|
-
## Observability — payload-free by construction
|
|
450
|
-
|
|
451
|
-
Observations carry identifiers and a duration, never payloads. Events are
|
|
452
|
-
silently dropped unless:
|
|
453
|
-
|
|
454
|
-
- `enabled` resolves to `true`,
|
|
455
|
-
- `operation` and `classification` match `/^[A-Za-z][A-Za-z0-9_.-]{0,159}$/`,
|
|
456
|
-
- `errorCode` (when present) matches `/^[A-Z][A-Z0-9_]{0,95}$/`.
|
|
457
|
-
|
|
458
|
-
Practical consequence: **dotted lowercase operation names**
|
|
459
|
-
(`documents.rename`) and **UPPER_SNAKE error codes** are effectively
|
|
460
|
-
mandatory. Every telemetry path is wrapped in try/catch — observability is
|
|
461
|
-
behaviorally inert and can never change command behavior or mask the original
|
|
462
|
-
failure. Default sink is one JSON line on `console.info`
|
|
463
|
-
(`event: 'command.execution'`); inject `emit` to redirect.
|
|
464
|
-
|
|
465
|
-
## `executeResultBoundary` — typed failures, but only while it's safe
|
|
466
|
-
|
|
467
|
-
```ts
|
|
468
|
-
// destructured from the Foundation facade, like everything else
|
|
469
|
-
const { executeResultBoundary, projectResult } = new Foundation(...)
|
|
470
|
-
|
|
471
|
-
const result = await executeResultBoundary(ctx, () => run(), boundary)
|
|
472
|
-
// Result<Value, Failure> = { ok: true, value } | { ok: false, error }
|
|
473
|
-
```
|
|
474
|
-
|
|
475
|
-
Semantics — read carefully, they are decided by **transaction state**, not by
|
|
476
|
-
the error:
|
|
477
|
-
|
|
478
|
-
- Success → `{ ok: true, value }`.
|
|
479
|
-
- A failure the boundary recognizes (`dataOf(error)` non-undefined) is
|
|
480
|
-
returned as `{ ok: false, error }` **only if the transaction has produced no
|
|
481
|
-
effects yet** (nothing written, nothing scheduled — checked via
|
|
482
|
-
`ctx.meta.getTransactionMetrics()`).
|
|
483
|
-
- If anything was already written or scheduled, the error is **rethrown** so
|
|
484
|
-
Convex rolls the transaction back — a returned failure after effects would
|
|
485
|
-
commit those effects.
|
|
486
|
-
- Unrecognized errors and unreadable metrics always rethrow.
|
|
487
|
-
|
|
488
|
-
Use it at the outermost edge of a mutation when the client needs a typed
|
|
489
|
-
`Result` over the wire instead of a thrown error. `projectResult(result,
|
|
490
|
-
onFailure)` unwraps on the caller side.
|
|
491
|
-
|
|
492
|
-
## The `Query` kernel
|
|
493
|
-
|
|
494
|
-
`Query` mirrors the pattern for reads: an injected `execute` receives
|
|
495
|
-
`{ context, metadata, run }`, letting the host apply uniform read policy
|
|
496
|
-
(authorization checks, tracing) around every domain query without the domains
|
|
497
|
-
knowing. Metadata defaults are set at construction and merged per-executor.
|
|
498
|
-
|
|
499
|
-
## Rules and footguns
|
|
500
|
-
|
|
501
|
-
1. One `new Foundation(...)` per app, in `convex/foundation.ts`. Everything
|
|
502
|
-
else destructures from it.
|
|
503
|
-
2. Never deep-import the component's `modules/*` or `result.ts` paths from app
|
|
504
|
-
code — the `client.ts` facade is the contract.
|
|
505
|
-
3. Use `Command.withContext<MutationCtx>()` for typed callbacks and construct
|
|
506
|
-
the Command with a compatible host context. Legacy `Command.operation`
|
|
507
|
-
keeps its low-level callback signature. Commands with writes run in mutations.
|
|
508
|
-
4. Name operations `domain.verb` and error codes `UPPER_SNAKE` or your
|
|
509
|
-
telemetry silently disappears (see regexes above).
|
|
510
|
-
5. Keep operation registries frozen (`as const`) in the domain's
|
|
511
|
-
`commands.ts`; vocabulary tuples live in `constants.ts`.
|
|
105
|
+
Injection is optional. Use explicit `exec(name, input, context)` when appropriate.
|
|
106
|
+
The value supplied to `withContext` matches the registry's resolver, and can be
|
|
107
|
+
any declared dependency type. See [optional context injection](./command-context.md)
|
|
108
|
+
for a complete compiled example, input-only handlers, multiple command groups,
|
|
109
|
+
and feature organization for applications with many entities. Use authenticated
|
|
110
|
+
constructor fields for identity and tenant information, rather than client
|
|
111
|
+
arguments. Services may also be provided by the adapter when the program uses
|
|
112
|
+
Effect services. See [the complete domain-to-API example](./effect.md) and
|
|
113
|
+
[authenticated builder wiring](./auth.md).
|
|
114
|
+
|
|
115
|
+
The builder runs the Effect in an invocation scope and awaits finalizers.
|
|
116
|
+
Nested commands and queries compose into the same Effect; do not start an
|
|
117
|
+
inner `Effect.runPromise`. Native Convex references retain argument/result
|
|
118
|
+
types, but do not transport Effect's server-side error and service channels.
|
|
119
|
+
|
|
120
|
+
## Lifecycle and hooks
|
|
121
|
+
|
|
122
|
+
1. Decode `input`, start observation, then resolve invocation-local context.
|
|
123
|
+
2. Check a declared `permission` through the Foundation's injected
|
|
124
|
+
`checkPermission(host, { permission, operation })`. A missing checker fails
|
|
125
|
+
closed. The policy receives the original host.
|
|
126
|
+
3. Run `prepare(context, input)` if configured. Replay validates the stored
|
|
127
|
+
result and skips middleware, guards, handler, audit writing, and completion.
|
|
128
|
+
4. Run the operation's `middleware`, wrapping `defaults.guard(context)`, the
|
|
129
|
+
operation's `guard(context, input)`, and `handler(input, context)`.
|
|
130
|
+
5. Decode the middleware chain's final result, derive `audit({ command, result },
|
|
131
|
+
context)`, enforce `aggregates`, and call the injected `writeAudit(host, entry)`
|
|
132
|
+
when the audit is non-null.
|
|
133
|
+
6. Await the completion closure from `prepare`, then finish observation.
|
|
134
|
+
|
|
135
|
+
Effect middleware is a callback returning a value, Promise, or Effect, rather
|
|
136
|
+
than the legacy facade's middleware arrays. `next()` returns an Effect;
|
|
137
|
+
`next({ context })` merges a context extension for downstream guards and the
|
|
138
|
+
handler. Prepare and audit receive the original domain context. Short circuits
|
|
139
|
+
still validate and audit; calling `next()` twice fails. Defaults support a
|
|
140
|
+
registry guard; declare middleware per operation.
|
|
141
|
+
|
|
142
|
+
Guards should be read-only. Put authorization required on replay in the
|
|
143
|
+
permission policy or prepare lookup, because replay skips both guards. When
|
|
144
|
+
`result` transforms its input, store the validated output in completion and
|
|
145
|
+
supply a `replayResult` output validator to avoid applying the transform twice.
|
|
146
|
+
Use `effectTransactionalIdempotency` for receipt-backed replay;
|
|
147
|
+
[domain integrations](./domain-operations.md) cover idempotency, workflows,
|
|
148
|
+
CRUD, and selected operation tools.
|
|
149
|
+
|
|
150
|
+
Commands that write run inside Convex mutations. Let failures escape the
|
|
151
|
+
mutation so Convex rolls back business, audit, and completion writes. Returning
|
|
152
|
+
a failure value after a write can commit that write. Observation completion is
|
|
153
|
+
command completion, not proof that the enclosing transaction committed.
|
|
154
|
+
|
|
155
|
+
## Migrate the deprecated API
|
|
156
|
+
|
|
157
|
+
- Replace the command facade from `new Foundation(...)` with
|
|
158
|
+
`createEffectFoundation(...)`. Move `writeAudit` out of the legacy
|
|
159
|
+
`observability` options into the Effect Foundation's top-level options.
|
|
160
|
+
- Replace `new Command(operations, defaults)` with
|
|
161
|
+
`foundation.Command({ context, operations: ({ command }) => ({ ... }), defaults })`.
|
|
162
|
+
- Use `input` instead of the old schema property `command`. Move each handler
|
|
163
|
+
from executor options into its operation's `command({...})` declaration.
|
|
164
|
+
- Change handlers from `(context, input)` to `(input, context)`. Hooks retain
|
|
165
|
+
their documented context-first or resolution-first signatures.
|
|
166
|
+
- Replace Promise executor factories with `exec(operation, input, host)` and
|
|
167
|
+
return the resulting Effect through a shared API builder. Keep authentication,
|
|
168
|
+
tenant policy, triggers, and native validators on that builder.
|
|
169
|
+
|
|
170
|
+
Existing consumers can migrate one domain at a time. Only the Effect API is
|
|
171
|
+
recommended for new command code; the deprecated facade remains callable until
|
|
172
|
+
a separately announced breaking release. See the [old API reference](./commands-legacy.md)
|
|
173
|
+
when maintaining a domain that has not migrated yet.
|
package/docs/conventions.md
CHANGED
|
@@ -121,7 +121,7 @@ another file's function to shorten an import.
|
|
|
121
121
|
| `schema.ts` | `defineSchema(domainTables)` | table definitions, validators |
|
|
122
122
|
| `functions.ts` | the single `createAuthFunctions<DataModel>()` call and its exported constructors | handlers, business policy beyond the injected config |
|
|
123
123
|
| `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) |
|
|
124
|
-
| `foundation.ts` | the single `
|
|
124
|
+
| `foundation.ts` | the single `createEffectFoundation(...)`, bound command/query exports | command definitions |
|
|
125
125
|
| `<component>.ts` | `new <Client>(components.<name>)` + minimal admin plumbing | workflow/business definitions |
|
|
126
126
|
| `http.ts` | route registration delegating request handling to the owning domain | webhook business logic |
|
|
127
127
|
| `crons.ts` | `cronJobs()` declarations targeting `internal.domain.<entity>...` | handler logic |
|
|
@@ -191,14 +191,18 @@ compile. Numeric limits and durations the entity owns also live here.
|
|
|
191
191
|
|
|
192
192
|
### `domain/<entity>/commands.ts` — every state change
|
|
193
193
|
|
|
194
|
-
1.
|
|
195
|
-
|
|
196
|
-
2.
|
|
197
|
-
|
|
194
|
+
1. One `Command({ context, operations: ({ command }) => ({ ... }) })` using
|
|
195
|
+
the bound constructor from `createEffectFoundation` (`cvx-kit/effect`).
|
|
196
|
+
2. Each entry declares `command({ input, result, classification, handler, audit })`,
|
|
197
|
+
with keys `'<entity>.<verb>'`. Handlers receive `(input, context)`.
|
|
198
|
+
3. Exported domain functions return `commands.exec(operation, input, host)`;
|
|
199
|
+
the shared Effect API builder executes that Effect at the authenticated boundary.
|
|
198
200
|
4. Private handler helpers at the bottom.
|
|
199
201
|
|
|
200
202
|
Audit derivation lives here, next to the operation it describes. No reads
|
|
201
203
|
that belong in `queries.ts`; no pure predicates that belong in `rules.ts`.
|
|
204
|
+
The Promise command facade is deprecated; see `commands-legacy.md` only for
|
|
205
|
+
maintaining existing code. New implementations follow `commands.md`.
|
|
202
206
|
|
|
203
207
|
### `domain/<entity>/queries.ts` — domain reads
|
|
204
208
|
|
|
@@ -317,3 +321,9 @@ component internals (anything below a component's client facade),
|
|
|
317
321
|
created before <epoch>.`) — never narration of the next line.
|
|
318
322
|
- One entity concept per file; when a file serves two purposes, split it
|
|
319
323
|
along the anatomy in §2.
|
|
324
|
+
|
|
325
|
+
Optional request context can be added through the existing Effect API adapter's
|
|
326
|
+
`context` callback. Bind selected registries with `commands.withContext(value)`
|
|
327
|
+
and consume `ctx.commands.exec(name, input)` through the application's existing
|
|
328
|
+
function helper names. Keep dependencies local to each feature; explicit execution
|
|
329
|
+
and input-only command handlers remain supported. See [optional context injection](./command-context.md).
|