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
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
# Deprecated Promise command API — compatibility reference
|
|
2
|
+
|
|
3
|
+
> The `Foundation.Command` facade and its `exec({ operation, handler })` API
|
|
4
|
+
> are deprecated. Existing consumers continue to work; removal is not part of
|
|
5
|
+
> this change. Use [Effect commands](./commands.md) for new implementations.
|
|
6
|
+
> The examples below describe the old API for maintaining and migrating existing
|
|
7
|
+
> code. The Foundation component and its other capabilities are not deprecated.
|
|
8
|
+
|
|
9
|
+
The Foundation is a Convex component that **owns zero tables and zero
|
|
10
|
+
capability** — its schema is empty by design. What it provides is the
|
|
11
|
+
application kernel: the `Command` protocol (validate → observe → execute →
|
|
12
|
+
audit, in one transaction), the `Query` kernel, and payload-free
|
|
13
|
+
`Observability`. It is declared **once** per app and everything destructures
|
|
14
|
+
from it; there is no separate command import.
|
|
15
|
+
|
|
16
|
+
## Mounting and declaring
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// convex/convex.config.ts
|
|
20
|
+
import { defineApp } from 'convex/server'
|
|
21
|
+
import foundation from 'cvx-kit/components/foundation/convex.config'
|
|
22
|
+
|
|
23
|
+
const app = defineApp()
|
|
24
|
+
app.use(foundation)
|
|
25
|
+
export default app
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// convex/foundation.ts — declared once, the single kernel source
|
|
30
|
+
import { Foundation } from 'cvx-kit/components/foundation'
|
|
31
|
+
import { components } from './_generated/api'
|
|
32
|
+
import { writeAuditEntry } from './audit'
|
|
33
|
+
|
|
34
|
+
export const { Command, Query, observability } = new Foundation(components.foundation, {
|
|
35
|
+
observability: {
|
|
36
|
+
enabled: () => process.env.COMMAND_OBSERVABILITY_ENABLED === 'true',
|
|
37
|
+
classifyError: (error) => classify(error), // → { outcome: 'denied' | 'failed', errorCode }
|
|
38
|
+
writeAudit: (ctx, entry) => writeAuditEntry(ctx, entry),
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Host code consumes the Foundation **only through this facade** — never deep
|
|
44
|
+
imports of the component's internal modules.
|
|
45
|
+
|
|
46
|
+
For new domains, [the optional Effect facade](./effect.md) offers cohesive
|
|
47
|
+
command/query entries with handlers in the declaration and injectable context.
|
|
48
|
+
The complete guide covers ordinary, `Effect.gen`, and `Effect.fn` handlers,
|
|
49
|
+
nested domain integrations, and shared API execution.
|
|
50
|
+
|
|
51
|
+
## Incremental migration to Effect
|
|
52
|
+
|
|
53
|
+
The existing Foundation keeps separate operation and handler declarations,
|
|
54
|
+
context-first handler arguments, and Promise executors. No existing consumer
|
|
55
|
+
needs to change when another domain adopts `cvx-kit/effect`.
|
|
56
|
+
|
|
57
|
+
This legacy factory accepts the mounted Foundation component and defines all
|
|
58
|
+
of its domain dependencies through the host context:
|
|
59
|
+
|
|
60
|
+
<!-- packed-effect-example -->
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { Foundation, type AuditEntryInput } from 'cvx-kit/components/foundation'
|
|
64
|
+
import { z } from 'zod'
|
|
65
|
+
|
|
66
|
+
type Host = {
|
|
67
|
+
actorId: string
|
|
68
|
+
rename: (id: string, title: string) => Promise<void>
|
|
69
|
+
appendAudit: (entry: AuditEntryInput) => Promise<void>
|
|
70
|
+
}
|
|
71
|
+
const input = z.object({ id: z.string(), title: z.string() }).strict()
|
|
72
|
+
const result = z.object({ ok: z.literal(true) }).strict()
|
|
73
|
+
|
|
74
|
+
export function createLegacyRename(component: ConstructorParameters<typeof Foundation>[0]) {
|
|
75
|
+
const { Command } = new Foundation(component, {
|
|
76
|
+
observability: {
|
|
77
|
+
enabled: false,
|
|
78
|
+
classifyError: () => ({ outcome: 'failed', errorCode: 'RENAME_FAILED' }),
|
|
79
|
+
writeAudit: (host: Host, entry) => host.appendAudit(entry),
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
const typed = Command.withContext<Host>()
|
|
83
|
+
const operations = {
|
|
84
|
+
rename: typed.operation({
|
|
85
|
+
command: input,
|
|
86
|
+
result,
|
|
87
|
+
classification: 'business',
|
|
88
|
+
audit: ({ command }, host) => ({
|
|
89
|
+
operation: 'documents.rename',
|
|
90
|
+
actorId: host.actorId,
|
|
91
|
+
aggregate: { type: 'document', id: command.id },
|
|
92
|
+
}),
|
|
93
|
+
}),
|
|
94
|
+
}
|
|
95
|
+
const commands = new Command<Host, typeof operations>(operations)
|
|
96
|
+
return commands.exec({
|
|
97
|
+
operation: 'rename',
|
|
98
|
+
handler: async (host, command) => {
|
|
99
|
+
await host.rename(command.id, command.title)
|
|
100
|
+
return { ok: true }
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The replacement keeps the handler beside its schemas and audit policy. It
|
|
107
|
+
uses input-first, context-second callbacks and returns a lazy Effect from
|
|
108
|
+
`exec(operation, input, host)`, including when the handler is ordinary async:
|
|
109
|
+
|
|
110
|
+
<!-- packed-effect-example -->
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { type AuditEntryInput } from 'cvx-kit/components/foundation'
|
|
114
|
+
import { createEffectFoundation } from 'cvx-kit/effect'
|
|
115
|
+
import { z } from 'zod'
|
|
116
|
+
|
|
117
|
+
type Host = {
|
|
118
|
+
actorId: string
|
|
119
|
+
rename: (id: string, title: string) => Promise<void>
|
|
120
|
+
appendAudit: (entry: AuditEntryInput) => Promise<void>
|
|
121
|
+
}
|
|
122
|
+
const input = z.object({ id: z.string(), title: z.string() }).strict()
|
|
123
|
+
const result = z.object({ ok: z.literal(true) }).strict()
|
|
124
|
+
const foundation = createEffectFoundation({
|
|
125
|
+
observability: {
|
|
126
|
+
enabled: false,
|
|
127
|
+
classifyError: () => ({ outcome: 'failed', errorCode: 'RENAME_FAILED' }),
|
|
128
|
+
},
|
|
129
|
+
writeAudit: (host: Host, entry) => host.appendAudit(entry),
|
|
130
|
+
})
|
|
131
|
+
export const commands = foundation.Command({
|
|
132
|
+
context: (host: Host) => host,
|
|
133
|
+
operations: ({ command }) => ({
|
|
134
|
+
rename: command({
|
|
135
|
+
input,
|
|
136
|
+
result,
|
|
137
|
+
classification: 'business',
|
|
138
|
+
handler: async (input, context) => {
|
|
139
|
+
await context.rename(input.id, input.title)
|
|
140
|
+
return { ok: true as const }
|
|
141
|
+
},
|
|
142
|
+
audit: ({ command }, context) => ({
|
|
143
|
+
operation: 'documents.rename',
|
|
144
|
+
actorId: context.actorId,
|
|
145
|
+
aggregate: { type: 'document', id: command.id },
|
|
146
|
+
}),
|
|
147
|
+
}),
|
|
148
|
+
}),
|
|
149
|
+
})
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Pass the resulting Effect to a handler registered through a shared
|
|
153
|
+
`effectApiBuilder` or `effectZodApiBuilder`; see [the complete domain-to-API
|
|
154
|
+
example](./effect.md). Domain composition does not start a separate runner.
|
|
155
|
+
Ordinary throws and rejected Promises become defects; use `Effect.fail` or
|
|
156
|
+
`Effect.tryPromise` for an inferred typed error channel. The native Convex
|
|
157
|
+
reference retains argument/result types, but does not transport the server's
|
|
158
|
+
Effect error and service types.
|
|
159
|
+
|
|
160
|
+
Both facades share command lifecycle ordering, validation, audit, and replay
|
|
161
|
+
rules. Commands that write remain inside a Convex mutation, and failures must
|
|
162
|
+
escape that mutation to roll its writes back.
|
|
163
|
+
|
|
164
|
+
## Defining commands — per domain
|
|
165
|
+
|
|
166
|
+
Each domain declares a frozen registry of operations and derives typed
|
|
167
|
+
executors from it. `classification` and `audit()` are **mandatory per
|
|
168
|
+
operation** — auditing is type-enforced, not opt-in.
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
// convex/domain/documents/commands.ts
|
|
172
|
+
import { z } from 'zod'
|
|
173
|
+
import { Command } from '../../foundation'
|
|
174
|
+
import { documents } from './schema'
|
|
175
|
+
import { zid } from 'cvx-kit/zod-table'
|
|
176
|
+
import type { MutationCtx } from '../../_generated/server'
|
|
177
|
+
|
|
178
|
+
const typed = Command.withContext<MutationCtx>()
|
|
179
|
+
|
|
180
|
+
const operations = {
|
|
181
|
+
'documents.rename': typed.operation({
|
|
182
|
+
command: documents.commandInput.extend({ id: zid('documents') }),
|
|
183
|
+
result: z.object({ ok: z.literal(true) }).strict(),
|
|
184
|
+
classification: 'business',
|
|
185
|
+
audit: ({ command }) => ({
|
|
186
|
+
operation: 'documents.rename',
|
|
187
|
+
actorId: command.actorId,
|
|
188
|
+
aggregate: { type: 'document', id: command.id },
|
|
189
|
+
metadata: { title: command.title },
|
|
190
|
+
}),
|
|
191
|
+
}),
|
|
192
|
+
} as const
|
|
193
|
+
|
|
194
|
+
const commands = new Command<MutationCtx, typeof operations>(operations)
|
|
195
|
+
|
|
196
|
+
export const executeRename = commands.exec({
|
|
197
|
+
operation: 'documents.rename',
|
|
198
|
+
handler: async (ctx, command) => {
|
|
199
|
+
await ctx.db.patch(command.id, { title: command.title })
|
|
200
|
+
return { ok: true }
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The public API layer then wraps the executor in an `authMutation` and passes
|
|
206
|
+
`ctx.actor` in. Public functions stay thin adapters; domain logic lives behind
|
|
207
|
+
`execute*` functions.
|
|
208
|
+
|
|
209
|
+
## What one execution does, in order
|
|
210
|
+
|
|
211
|
+
1. **Parse the command** through the operation's `command` schema (strict zod).
|
|
212
|
+
2. **Observe**: start the observation clock.
|
|
213
|
+
3. **Permission check** — if the operation declares `permission`, the
|
|
214
|
+
Foundation's injected `checkPermission(ctx, { permission, operation })`
|
|
215
|
+
runs; throw to deny. Declaring a permission with no injected checker
|
|
216
|
+
**fails closed** (`COMMAND_PERMISSION_NOT_CONFIGURED`).
|
|
217
|
+
4. **Prepare**, when configured: obtain an invocation-local replay decision
|
|
218
|
+
or completion closure. Replay validates its durable result, then goes
|
|
219
|
+
directly to observation, skipping middleware, both guards, handler and audit.
|
|
220
|
+
5. **Middleware and guards** — registry middleware wraps operation middleware,
|
|
221
|
+
which wraps both guards and the handler. The default guard is the
|
|
222
|
+
registry-wide guard passed as the Command's second
|
|
223
|
+
argument (`new Command(operations, { guard })`), if any.
|
|
224
|
+
Then the operation's own `guard(ctx, command)` checks
|
|
225
|
+
preconditions like state-machine legality, ownership beyond roles, or
|
|
226
|
+
invariants over the parsed command. Throw to deny. Prepare or middleware
|
|
227
|
+
may already have written, so let the denial escape for rollback or use
|
|
228
|
+
the transaction-metrics-aware result boundary below.
|
|
229
|
+
6. **Run the handler and unwind middleware**; parse the final chain output
|
|
230
|
+
through the `result` schema exactly once. Middleware sees schema inputs;
|
|
231
|
+
audit, completion, and the executor receive validated schema outputs.
|
|
232
|
+
7. **Audit**: call the operation's `audit({ command, result }, ctx)`. If it
|
|
233
|
+
returns non-null, the entry (plus the operation's `classification`) is
|
|
234
|
+
written through the injected `writeAudit` — **in the same transaction** as
|
|
235
|
+
the handler's writes. Returning `null` skips the audit (the ontology
|
|
236
|
+
convention: failures are not audited). When the operation declares
|
|
237
|
+
`aggregates: [...]`, an audit whose `aggregate.type` is not in that
|
|
238
|
+
allowlist throws (`COMMAND_AGGREGATE_NOT_DECLARED`) — the audit
|
|
239
|
+
vocabulary is enforced, not advisory. The per-operation catalog is
|
|
240
|
+
introspectable as `commands.aggregates` for tests.
|
|
241
|
+
8. **Complete**: await the closure returned by `prepare`, including when audit
|
|
242
|
+
returns null. A failure propagates inside the observation boundary.
|
|
243
|
+
9. **Emit the observation**: `{ operation, classification, outcome,
|
|
244
|
+
errorCode?, durationMs }` — completed, denied, or failed per
|
|
245
|
+
`classifyError`.
|
|
246
|
+
|
|
247
|
+
### Guards and permissions
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
// convex/foundation.ts — permission semantics are host policy, injected once
|
|
251
|
+
export const { Command, Query, observability } = new Foundation(
|
|
252
|
+
components.foundation,
|
|
253
|
+
{
|
|
254
|
+
observability: { ... },
|
|
255
|
+
checkPermission: (ctx, { permission }) =>
|
|
256
|
+
requirePermission(ctx.identity, permission), // throw to deny
|
|
257
|
+
},
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
// domain/<module>/commands.ts
|
|
261
|
+
const operations = {
|
|
262
|
+
'<entities>.publish': Command.operation({
|
|
263
|
+
command: <entities>.commandInput.extend({ id: zid('<entities>') }),
|
|
264
|
+
result: z.object({ ok: z.literal(true) }).strict(),
|
|
265
|
+
classification: 'business',
|
|
266
|
+
permission: '<domain>.manage', // checked first
|
|
267
|
+
aggregates: ['<aggregate-type>'], // audit vocabulary allowlist
|
|
268
|
+
guard: async (ctx, command) => { // precondition, pre-handler
|
|
269
|
+
const row = await requireTenantReference(ctx.tenant, () => ctx.db.get(command.id))
|
|
270
|
+
if (row.state !== 'draft') errors.throw({ code: 'INVALID_STATE' })
|
|
271
|
+
},
|
|
272
|
+
audit: ({ command }) => ({ ... }),
|
|
273
|
+
}),
|
|
274
|
+
} as const
|
|
275
|
+
|
|
276
|
+
const commands = new Command<MutationCtx, typeof operations>(operations, {
|
|
277
|
+
guard: (ctx) => assertNotReadonlyWindow(ctx), // registry-wide default
|
|
278
|
+
})
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Guards deny by throwing. A denial before the handler may still need rollback
|
|
282
|
+
if prepare or middleware already wrote. Let failures escape the mutation, or
|
|
283
|
+
use the transaction-metrics-aware result boundary below. Keep guards read-only.
|
|
284
|
+
|
|
285
|
+
### Middleware — composable, next()-based
|
|
286
|
+
|
|
287
|
+
For wrap-around concerns (timing, tracing, context enrichment) that two
|
|
288
|
+
disconnected callbacks can't express, both kernels take
|
|
289
|
+
Express/TanStack-style middleware. The chain runs **inside** the pipeline's
|
|
290
|
+
invariants: after the permission check, around [guards → handler], before the
|
|
291
|
+
result-schema parse, aggregate allowlist, and audit — so middleware can
|
|
292
|
+
never skip authorization, return an invalid result, or desynchronize audit
|
|
293
|
+
from effects.
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
const timing = Command.middleware(async ({ operation, next }) => {
|
|
297
|
+
const started = Date.now()
|
|
298
|
+
const result = await next() // inner middleware → guards → handler
|
|
299
|
+
observability.observe // (or your own sink)
|
|
300
|
+
console.info(`${operation} took ${Date.now() - started}ms`)
|
|
301
|
+
return result
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
const withVendor = Command.middleware(async ({ context, next }) =>
|
|
305
|
+
next({ context: { vendor: await loadVendor(context) } })) // enriches downstream ctx
|
|
306
|
+
|
|
307
|
+
const commands = new Command<Ctx, typeof operations>(operations, {
|
|
308
|
+
guard: (ctx) => assertNotReadonlyWindow(ctx),
|
|
309
|
+
middleware: [timing], // registry-wide, outermost
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
'<entities>.publish': Command.operation({
|
|
313
|
+
...,
|
|
314
|
+
middleware: [withVendor], // per-operation, inside registry chain
|
|
315
|
+
guard: (ctx, command) => { /* ctx.vendor available here */ },
|
|
316
|
+
})
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Order: registry middleware (array order) → operation middleware → default
|
|
320
|
+
guard → operation guard → handler. `next()` returns the downstream result;
|
|
321
|
+
`next({ context })` merges enrichment into the context handed to inner
|
|
322
|
+
middleware, guards, and the handler. Skipping `next()` short-circuits
|
|
323
|
+
(guards and handler never run — the returned value still must satisfy the
|
|
324
|
+
result schema); calling it twice throws `COMMAND_MIDDLEWARE_NEXT_REUSED`.
|
|
325
|
+
Whatever leaves the chain is parsed through the operation's strict result
|
|
326
|
+
schema — a middleware cannot fabricate an invalid result.
|
|
327
|
+
|
|
328
|
+
### Schema-inferred callbacks and context extensions (0.1.3)
|
|
329
|
+
|
|
330
|
+
`Command.withContext<Ctx>()` binds the host context for operation definitions.
|
|
331
|
+
Its `operation()` infers parsed command fields, validated audit result fields,
|
|
332
|
+
and the declared aggregate vocabulary. Operation middleware is contextual:
|
|
333
|
+
write inline callbacks without `Command.middleware`, whose legacy low-level
|
|
334
|
+
command and result boundary intentionally remains unknown.
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
const typed = Command.withContext<Ctx>()
|
|
338
|
+
const operations = {
|
|
339
|
+
rename: typed.operation({
|
|
340
|
+
command: z.object({ title: z.string().transform((value) => value.length) }),
|
|
341
|
+
result: z.object({ ok: z.boolean() }),
|
|
342
|
+
classification: 'business',
|
|
343
|
+
aggregates: ['document'],
|
|
344
|
+
guard: (ctx, command) => requireTitleLength(ctx, command.title), // number
|
|
345
|
+
middleware: [
|
|
346
|
+
async ({ command, next }) => {
|
|
347
|
+
const result = await next() // { ok: boolean }, before validation
|
|
348
|
+
logLength(command.title)
|
|
349
|
+
return result
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
audit: ({ command, result }, ctx) => ({
|
|
353
|
+
operation: 'rename',
|
|
354
|
+
actorId: ctx.actorId,
|
|
355
|
+
aggregate: { type: 'document', id: String(command.title) },
|
|
356
|
+
metadata: { ok: result.ok },
|
|
357
|
+
}),
|
|
358
|
+
}),
|
|
359
|
+
}
|
|
360
|
+
const middleware = typed.registryMiddleware(operations, async (input) => {
|
|
361
|
+
if (input.operation === 'rename') {
|
|
362
|
+
input.command.title.toFixed() // discriminator retains schema correlation
|
|
363
|
+
}
|
|
364
|
+
return input.next()
|
|
365
|
+
})
|
|
366
|
+
const commands = new Command<Ctx, typeof operations>(operations, {
|
|
367
|
+
middleware: [middleware],
|
|
368
|
+
})
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Bind registry middleware to the same registry it describes. A heterogeneous
|
|
372
|
+
registry callback returns a union; narrowing `input.operation` preserves
|
|
373
|
+
the matching `input.command` and `input.next()` result type.
|
|
374
|
+
Reusing it with a different operation definition throws
|
|
375
|
+
`COMMAND_MIDDLEWARE_REGISTRY_MISMATCH` before the typed callback runs.
|
|
376
|
+
|
|
377
|
+
To propagate a required middleware extension to guards and handlers, use
|
|
378
|
+
`Command.withContext<Ctx, { traceId: string }>()`. Its operations require a
|
|
379
|
+
nonempty middleware tuple; the first layer must pass the declared extension
|
|
380
|
+
to `next({ context: { traceId } })` whenever it runs the downstream chain.
|
|
381
|
+
Later layers, guards and handlers see `Ctx & { traceId: string }`.
|
|
382
|
+
Audit and prepare receive the original context. The original context object
|
|
383
|
+
is not mutated. Short circuits still undergo final result validation.
|
|
384
|
+
|
|
385
|
+
### Transactional completion and replay (0.1.3)
|
|
386
|
+
|
|
387
|
+
Use `prepare(ctx, parsedCommand)` for host-owned idempotency. It runs after
|
|
388
|
+
permission and returns either `{ kind: 'replay', result: durableValue }` or
|
|
389
|
+
`{ kind: 'execute', complete: async validatedResult => { ... } }`.
|
|
390
|
+
The completion closure captures per-invocation state, including a fingerprint
|
|
391
|
+
derived from the parsed command and authenticated actor. Do not cache that
|
|
392
|
+
state in a shared context or module variable.
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
prepare: async (ctx, command) => {
|
|
396
|
+
const key = scopedKey(ctx.actorId, command.idempotencyKey)
|
|
397
|
+
const fingerprint = fingerprintOf(command)
|
|
398
|
+
const receipt = await loadReceipt(ctx, key)
|
|
399
|
+
if (receipt) {
|
|
400
|
+
if (receipt.fingerprint !== fingerprint) throw new Error('KEY_REUSED')
|
|
401
|
+
return { kind: 'replay', result: receipt.result }
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
kind: 'execute',
|
|
405
|
+
complete: async result => {
|
|
406
|
+
await saveReceipt(ctx, { key, fingerprint, result })
|
|
407
|
+
},
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Storage, actor/tenant scoping, fingerprint comparison, retention, and conflict
|
|
413
|
+
policy remain host responsibilities. Put authorization required on replay in
|
|
414
|
+
the injected permission checker or prepare lookup; both guards are skipped
|
|
415
|
+
on replay. A permission slug without a checker still fails closed. Ordinary
|
|
416
|
+
middleware short-circuiting is not this replay path and still audits.
|
|
417
|
+
|
|
418
|
+
Replays are parsed through `result` by default. If that schema transforms its
|
|
419
|
+
input, store the completion callback's validated output and supply
|
|
420
|
+
`replayResult` with an output validator (for example `z.number().int()` when
|
|
421
|
+
`result` transforms a string into a number). This prevents reapplying an input
|
|
422
|
+
transform to an already transformed durable result. An invalid replay fails
|
|
423
|
+
inside observation; successful replay does not invoke completion again.
|
|
424
|
+
|
|
425
|
+
Completion is awaited after result validation, audit resolution, aggregate
|
|
426
|
+
validation, and audit writing, even on audit-null paths. Errors from prepare,
|
|
427
|
+
replay validation, audit, or completion propagate and are passed to the host's
|
|
428
|
+
error classifier. Observation success means this command finished; the
|
|
429
|
+
enclosing mutation has not necessarily committed yet.
|
|
430
|
+
|
|
431
|
+
The Query kernel takes the same shape: `new Query({ defaults, middleware,
|
|
432
|
+
execute })` plus per-executor `middleware: [...]`, with `Query.middleware`
|
|
433
|
+
as the typing helper. Kernel middleware runs before executor middleware,
|
|
434
|
+
inside the host's injected `execute` policy.
|
|
435
|
+
|
|
436
|
+
For atomic rollback, let failures escape the enclosing Convex mutation.
|
|
437
|
+
Catching and swallowing a failure inside that mutation can commit earlier
|
|
438
|
+
business, audit, and completion writes. Rethrow after effects, or use the
|
|
439
|
+
effect-aware `executeResultBoundary` below. External I/O in actions is not
|
|
440
|
+
rolled back; these transaction guarantees concern Convex mutation writes.
|
|
441
|
+
|
|
442
|
+
### Operation selection
|
|
443
|
+
|
|
444
|
+
The public Promise facade accepts fixed `exec({ operation, handler })`
|
|
445
|
+
executors. Select between those executors in host code when maintaining a
|
|
446
|
+
legacy dispatcher. The internal kernel's dynamic overload is not exposed by
|
|
447
|
+
`Foundation.Command`; do not deep-import it.
|
|
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`.
|