cvx-kit 0.1.6 → 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/CHANGELOG.md +23 -0
- package/README.md +63 -38
- package/dist/agent-tools.d.mts +2 -1
- package/dist/agent-tools.mjs +1 -1
- package/dist/agent-tools.mjs.map +1 -1
- package/dist/components/approvals/audit.mjs +1 -1
- package/dist/components/foundation/client.d.mts +16 -7
- 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 +50 -10
- 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/command/lifecycle.d.mts +81 -0
- package/dist/components/foundation/modules/command/lifecycle.mjs +2 -0
- package/dist/components/foundation/modules/command/lifecycle.mjs.map +1 -0
- package/dist/components/foundation/modules/observability/observability.d.mts +8 -0
- 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/operation.d.mts +6 -6
- package/dist/components/foundation/operation.mjs.map +1 -1
- package/dist/contracts.d.mts +4 -0
- package/dist/contracts.mjs +1 -0
- package/dist/crud.d.mts +2 -2
- package/dist/crud.mjs.map +1 -1
- package/dist/effect.d.mts +12 -0
- package/dist/effect.mjs +1 -0
- package/dist/errors.d.mts +2 -1
- package/dist/errors.mjs +1 -1
- package/dist/errors.mjs.map +1 -1
- package/dist/idempotency.d.mts +2 -0
- package/dist/idempotency.mjs +1 -0
- package/dist/modules/contracts/contract.d.mts +70 -0
- package/dist/modules/contracts/contract.mjs +2 -0
- package/dist/modules/contracts/contract.mjs.map +1 -0
- package/dist/modules/contracts/convex.d.mts +22 -0
- package/dist/modules/contracts/convex.mjs +2 -0
- package/dist/modules/contracts/convex.mjs.map +1 -0
- package/dist/modules/contracts/errors.d.mts +44 -0
- package/dist/modules/contracts/errors.mjs +2 -0
- package/dist/modules/contracts/errors.mjs.map +1 -0
- package/dist/modules/contracts/exposure.d.mts +105 -0
- package/dist/modules/contracts/exposure.mjs +2 -0
- package/dist/modules/contracts/exposure.mjs.map +1 -0
- package/dist/modules/contracts/idempotency.d.mts +87 -0
- package/dist/modules/contracts/idempotency.mjs +2 -0
- package/dist/modules/contracts/idempotency.mjs.map +1 -0
- package/dist/modules/contracts/standard.d.mts +16 -0
- package/dist/modules/contracts/standard.mjs +2 -0
- package/dist/modules/contracts/standard.mjs.map +1 -0
- package/dist/modules/contracts/workflow.d.mts +167 -0
- package/dist/modules/contracts/workflow.mjs +2 -0
- package/dist/modules/contracts/workflow.mjs.map +1 -0
- package/dist/modules/effect/agent-tools.d.mts +2 -0
- package/dist/modules/effect/agent-tools.mjs +1 -0
- package/dist/modules/effect/api-runtime.d.mts +38 -0
- package/dist/modules/effect/api-runtime.mjs +2 -0
- package/dist/modules/effect/api-runtime.mjs.map +1 -0
- package/dist/modules/effect/api-zod.d.mts +35 -0
- package/dist/modules/effect/api-zod.mjs +2 -0
- package/dist/modules/effect/api-zod.mjs.map +1 -0
- package/dist/modules/effect/api.d.mts +21 -0
- package/dist/modules/effect/api.mjs +2 -0
- package/dist/modules/effect/api.mjs.map +1 -0
- package/dist/modules/effect/client.d.mts +11 -0
- package/dist/modules/effect/client.mjs +1 -0
- package/dist/modules/effect/command.d.mts +63 -0
- package/dist/modules/effect/command.mjs +2 -0
- package/dist/modules/effect/command.mjs.map +1 -0
- package/dist/modules/effect/crud.d.mts +785 -0
- package/dist/modules/effect/crud.mjs +2 -0
- package/dist/modules/effect/crud.mjs.map +1 -0
- package/dist/modules/effect/errors.d.mts +8 -0
- package/dist/modules/effect/errors.mjs +2 -0
- package/dist/modules/effect/errors.mjs.map +1 -0
- package/dist/modules/effect/foundation.d.mts +21 -0
- package/dist/modules/effect/foundation.mjs +2 -0
- package/dist/modules/effect/foundation.mjs.map +1 -0
- package/dist/modules/effect/idempotency.d.mts +31 -0
- package/dist/modules/effect/idempotency.mjs +2 -0
- package/dist/modules/effect/idempotency.mjs.map +1 -0
- package/dist/modules/effect/normalize.d.mts +10 -0
- package/dist/modules/effect/normalize.mjs +2 -0
- package/dist/modules/effect/normalize.mjs.map +1 -0
- package/dist/modules/effect/operation.d.mts +141 -0
- package/dist/modules/effect/operation.mjs +2 -0
- package/dist/modules/effect/operation.mjs.map +1 -0
- package/dist/modules/effect/query.d.mts +69 -0
- package/dist/modules/effect/query.mjs +2 -0
- package/dist/modules/effect/query.mjs.map +1 -0
- package/dist/modules/effect/schema.d.mts +33 -0
- package/dist/modules/effect/schema.mjs +2 -0
- package/dist/modules/effect/schema.mjs.map +1 -0
- package/dist/modules/effect/workflow.d.mts +77 -0
- package/dist/modules/effect/workflow.mjs +2 -0
- package/dist/modules/effect/workflow.mjs.map +1 -0
- package/dist/node_modules/@standard-schema/spec/dist/index.d.mts +79 -0
- package/dist/workflow.d.mts +2 -0
- package/dist/workflow.mjs +1 -0
- package/docs/architecture.md +3 -3
- package/docs/auth.md +71 -0
- package/docs/command-context.md +150 -0
- package/docs/commands-legacy.md +511 -0
- package/docs/commands.md +143 -363
- package/docs/conventions.md +15 -5
- package/docs/crud.md +4 -0
- package/docs/domain-operations.md +615 -0
- package/docs/effect.md +395 -0
- package/docs/llms.md +69 -45
- package/docs/migration.md +1 -1
- package/docs/upgrading.md +9 -0
- package/package.json +33 -1
- package/scripts/convex-smoke-env.mjs +33 -0
- package/scripts/smoke-packed-components.mjs +9 -2
- package/scripts/smoke-packed-effect.mjs +439 -0
- package/scripts/test-packed-effect.mjs +460 -0
- package/scripts/test-packed-ids.mjs +1 -0
package/docs/commands.md
CHANGED
|
@@ -1,393 +1,173 @@
|
|
|
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
|
|
11
|
+
```sh
|
|
12
|
+
bun add effect@'>=4.0.1 <5'
|
|
20
13
|
```
|
|
21
14
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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.
|
|
27
21
|
|
|
28
|
-
|
|
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
|
-
})
|
|
35
|
-
```
|
|
22
|
+
## Declare commands
|
|
36
23
|
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
39
28
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
Each domain declares a frozen registry of operations and derives typed
|
|
43
|
-
executors from it. `classification` and `audit()` are **mandatory per
|
|
44
|
-
operation** — auditing is type-enforced, not opt-in.
|
|
29
|
+
<!-- packed-effect-example -->
|
|
45
30
|
|
|
46
31
|
```ts
|
|
47
|
-
|
|
32
|
+
import { type AuditEntryInput } from 'cvx-kit/components/foundation'
|
|
33
|
+
import { createEffectFoundation } from 'cvx-kit/effect'
|
|
48
34
|
import { z } from 'zod'
|
|
49
|
-
import { Command } from '../../foundation'
|
|
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>()
|
|
55
35
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}),
|
|
67
|
-
}),
|
|
68
|
-
} as const
|
|
69
|
-
|
|
70
|
-
const commands = new Command<MutationCtx, typeof operations>(operations)
|
|
71
|
-
|
|
72
|
-
export const executeRename = commands.exec({
|
|
73
|
-
operation: 'documents.rename',
|
|
74
|
-
handler: async (ctx, command) => {
|
|
75
|
-
await ctx.db.patch(command.id, { title: command.title })
|
|
76
|
-
return { ok: true }
|
|
36
|
+
type Host = {
|
|
37
|
+
actorId: string
|
|
38
|
+
rename: (id: string, title: string) => Promise<void>
|
|
39
|
+
appendAudit: (entry: AuditEntryInput) => Promise<void>
|
|
40
|
+
}
|
|
41
|
+
const input = z.object({ id: z.string(), title: z.string() }).strict()
|
|
42
|
+
const result = z.object({ ok: z.literal(true) }).strict()
|
|
43
|
+
const foundation = createEffectFoundation({
|
|
44
|
+
observability: {
|
|
45
|
+
enabled: false,
|
|
46
|
+
classifyError: () => ({ outcome: 'failed', errorCode: 'RENAME_FAILED' }),
|
|
77
47
|
},
|
|
48
|
+
writeAudit: (host: Host, entry) => host.appendAudit(entry),
|
|
78
49
|
})
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
Foundation's injected `checkPermission(ctx, { permission, operation })`
|
|
91
|
-
runs; throw to deny. Declaring a permission with no injected checker
|
|
92
|
-
**fails closed** (`COMMAND_PERMISSION_NOT_CONFIGURED`).
|
|
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
|
|
99
|
-
argument (`new Command(operations, { guard })`), if any.
|
|
100
|
-
Then the operation's own `guard(ctx, command)` checks
|
|
101
|
-
preconditions like state-machine legality, ownership beyond roles, or
|
|
102
|
-
invariants over the parsed command. Throw to deny — nothing has run yet,
|
|
103
|
-
so a denial is always clean.
|
|
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.
|
|
107
|
-
7. **Audit**: call the operation's `audit({ command, result }, ctx)`. If it
|
|
108
|
-
returns non-null, the entry (plus the operation's `classification`) is
|
|
109
|
-
written through the injected `writeAudit` — **in the same transaction** as
|
|
110
|
-
the handler's writes. Returning `null` skips the audit (the ontology
|
|
111
|
-
convention: failures are not audited). When the operation declares
|
|
112
|
-
`aggregates: [...]`, an audit whose `aggregate.type` is not in that
|
|
113
|
-
allowlist throws (`COMMAND_AGGREGATE_NOT_DECLARED`) — the audit
|
|
114
|
-
vocabulary is enforced, not advisory. The per-operation catalog is
|
|
115
|
-
introspectable as `commands.aggregates` for tests.
|
|
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
|
|
120
|
-
`classifyError`.
|
|
121
|
-
|
|
122
|
-
### Guards and permissions
|
|
123
|
-
|
|
124
|
-
```ts
|
|
125
|
-
// convex/foundation.ts — permission semantics are host policy, injected once
|
|
126
|
-
export const { Command, Query, observability } = new Foundation(
|
|
127
|
-
components.foundation,
|
|
128
|
-
{
|
|
129
|
-
observability: { ... },
|
|
130
|
-
checkPermission: (ctx, { permission }) =>
|
|
131
|
-
requirePermission(ctx.identity, permission), // throw to deny
|
|
132
|
-
},
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
// domain/<module>/commands.ts
|
|
136
|
-
const operations = {
|
|
137
|
-
'<entities>.publish': Command.operation({
|
|
138
|
-
command: <entities>.commandInput.extend({ id: zid('<entities>') }),
|
|
139
|
-
result: z.object({ ok: z.literal(true) }).strict(),
|
|
140
|
-
classification: 'business',
|
|
141
|
-
permission: '<domain>.manage', // checked first
|
|
142
|
-
aggregates: ['<aggregate-type>'], // audit vocabulary allowlist
|
|
143
|
-
guard: async (ctx, command) => { // precondition, pre-handler
|
|
144
|
-
const row = await requireTenantReference(ctx.tenant, () => ctx.db.get(command.id))
|
|
145
|
-
if (row.state !== 'draft') errors.throw({ code: 'INVALID_STATE' })
|
|
146
|
-
},
|
|
147
|
-
audit: ({ command }) => ({ ... }),
|
|
148
|
-
}),
|
|
149
|
-
} as const
|
|
150
|
-
|
|
151
|
-
const commands = new Command<MutationCtx, typeof operations>(operations, {
|
|
152
|
-
guard: (ctx) => assertNotReadonlyWindow(ctx), // registry-wide default
|
|
153
|
-
})
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
Guards deny by throwing; a denial before the handler never needs rollback
|
|
157
|
-
because nothing has executed. Keep guards read-only — a guard that writes is
|
|
158
|
-
a handler in disguise.
|
|
159
|
-
|
|
160
|
-
### Middleware — composable, next()-based
|
|
161
|
-
|
|
162
|
-
For wrap-around concerns (timing, tracing, context enrichment) that two
|
|
163
|
-
disconnected callbacks can't express, both kernels take
|
|
164
|
-
Express/TanStack-style middleware. The chain runs **inside** the pipeline's
|
|
165
|
-
invariants: after the permission check, around [guards → handler], before the
|
|
166
|
-
result-schema parse, aggregate allowlist, and audit — so middleware can
|
|
167
|
-
never skip authorization, return an invalid result, or desynchronize audit
|
|
168
|
-
from effects.
|
|
169
|
-
|
|
170
|
-
```ts
|
|
171
|
-
const timing = Command.middleware(async ({ operation, next }) => {
|
|
172
|
-
const started = Date.now()
|
|
173
|
-
const result = await next() // inner middleware → guards → handler
|
|
174
|
-
observability.observe // (or your own sink)
|
|
175
|
-
console.info(`${operation} took ${Date.now() - started}ms`)
|
|
176
|
-
return result
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
const withVendor = Command.middleware(async ({ context, next }) =>
|
|
180
|
-
next({ context: { vendor: await loadVendor(context) } })) // enriches downstream ctx
|
|
181
|
-
|
|
182
|
-
const commands = new Command<Ctx, typeof operations>(operations, {
|
|
183
|
-
guard: (ctx) => assertNotReadonlyWindow(ctx),
|
|
184
|
-
middleware: [timing], // registry-wide, outermost
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
'<entities>.publish': Command.operation({
|
|
188
|
-
...,
|
|
189
|
-
middleware: [withVendor], // per-operation, inside registry chain
|
|
190
|
-
guard: (ctx, command) => { /* ctx.vendor available here */ },
|
|
191
|
-
})
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
Order: registry middleware (array order) → operation middleware → default
|
|
195
|
-
guard → operation guard → handler. `next()` returns the downstream result;
|
|
196
|
-
`next({ context })` merges enrichment into the context handed to inner
|
|
197
|
-
middleware, guards, and the handler. Skipping `next()` short-circuits
|
|
198
|
-
(guards and handler never run — the returned value still must satisfy the
|
|
199
|
-
result schema); calling it twice throws `COMMAND_MIDDLEWARE_NEXT_REUSED`.
|
|
200
|
-
Whatever leaves the chain is parsed through the operation's strict result
|
|
201
|
-
schema — a middleware cannot fabricate an invalid result.
|
|
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
|
|
50
|
+
export const commands = foundation.Command({
|
|
51
|
+
context: (host: Host) => host,
|
|
52
|
+
operations: ({ command }) => ({
|
|
53
|
+
'documents.rename': command({
|
|
54
|
+
input,
|
|
55
|
+
result,
|
|
56
|
+
classification: 'business',
|
|
57
|
+
aggregates: ['document'],
|
|
58
|
+
handler: async (input, context) => {
|
|
59
|
+
await context.rename(input.id, input.title)
|
|
60
|
+
return { ok: true as const }
|
|
225
61
|
},
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
metadata: { ok: result.ok },
|
|
62
|
+
audit: ({ command }, context) => ({
|
|
63
|
+
operation: 'documents.rename',
|
|
64
|
+
actorId: context.actorId,
|
|
65
|
+
aggregate: { type: 'document', id: command.id },
|
|
66
|
+
}),
|
|
232
67
|
}),
|
|
233
68
|
}),
|
|
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
69
|
})
|
|
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
70
|
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
},
|
|
71
|
+
export const executeRename = (value: z.input<typeof input>, host: Host) =>
|
|
72
|
+
commands.exec('documents.rename', value, host)
|
|
285
73
|
```
|
|
286
74
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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.
|
|
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.
|
|
305
81
|
|
|
306
|
-
|
|
307
|
-
execute })` plus per-executor `middleware: [...]`, with `Query.middleware`
|
|
308
|
-
as the typing helper. Kernel middleware runs before executor middleware,
|
|
309
|
-
inside the host's injected `execute` policy.
|
|
82
|
+
## Execute at the API boundary
|
|
310
83
|
|
|
311
|
-
|
|
312
|
-
|
|
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.
|
|
84
|
+
Wrap the application's trusted `authMutation` once with `effectZodApiBuilder`
|
|
85
|
+
in `convex/functions.ts`, then return the command Effect from the endpoint:
|
|
316
86
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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:
|
|
320
90
|
|
|
321
91
|
```ts
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
handler,
|
|
92
|
+
// convex/functions.ts
|
|
93
|
+
export const authMutation = effectZodApiBuilder(base.authMutation, {
|
|
94
|
+
context: (ctx) => ({ commands: commands.withContext(toCommandContext(ctx)) }),
|
|
326
95
|
})
|
|
327
|
-
```
|
|
328
|
-
|
|
329
|
-
An unconfigured operation throws `COMMAND_OPERATION_NOT_CONFIGURED`.
|
|
330
|
-
|
|
331
|
-
## Observability — payload-free by construction
|
|
332
|
-
|
|
333
|
-
Observations carry identifiers and a duration, never payloads. Events are
|
|
334
|
-
silently dropped unless:
|
|
335
96
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
mandatory. Every telemetry path is wrapped in try/catch — observability is
|
|
343
|
-
behaviorally inert and can never change command behavior or mask the original
|
|
344
|
-
failure. Default sink is one JSON line on `console.info`
|
|
345
|
-
(`event: 'command.execution'`); inject `emit` to redirect.
|
|
346
|
-
|
|
347
|
-
## `executeResultBoundary` — typed failures, but only while it's safe
|
|
348
|
-
|
|
349
|
-
```ts
|
|
350
|
-
// destructured from the Foundation facade, like everything else
|
|
351
|
-
const { executeResultBoundary, projectResult } = new Foundation(...)
|
|
352
|
-
|
|
353
|
-
const result = await executeResultBoundary(ctx, () => run(), boundary)
|
|
354
|
-
// Result<Value, Failure> = { ok: true, value } | { ok: false, error }
|
|
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),
|
|
102
|
+
})
|
|
355
103
|
```
|
|
356
104
|
|
|
357
|
-
|
|
358
|
-
the
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
`
|
|
377
|
-
|
|
378
|
-
(
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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).
|
package/docs/crud.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# `cvx-kit/crud` — the CRUD command factory
|
|
2
2
|
|
|
3
|
+
> This factory uses the deprecated Promise command facade and remains available
|
|
4
|
+
> for existing consumers. For new Effect commands, use `createEffectCrud` from
|
|
5
|
+
> `cvx-kit/effect`; see [domain integrations](./domain-operations.md).
|
|
6
|
+
|
|
3
7
|
`createCrudCommands` generates the three standard operations every entity
|
|
4
8
|
starts with — create, update, archive — from one zodTable declaration,
|
|
5
9
|
**fully inside the audited command pipeline**: strict command inputs from
|