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.
Files changed (116) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +63 -38
  3. package/dist/agent-tools.d.mts +2 -1
  4. package/dist/agent-tools.mjs +1 -1
  5. package/dist/agent-tools.mjs.map +1 -1
  6. package/dist/components/approvals/audit.mjs +1 -1
  7. package/dist/components/foundation/client.d.mts +16 -7
  8. package/dist/components/foundation/client.mjs +1 -1
  9. package/dist/components/foundation/client.mjs.map +1 -1
  10. package/dist/components/foundation/modules/command/command.d.mts +50 -10
  11. package/dist/components/foundation/modules/command/command.mjs +1 -1
  12. package/dist/components/foundation/modules/command/command.mjs.map +1 -1
  13. package/dist/components/foundation/modules/command/lifecycle.d.mts +81 -0
  14. package/dist/components/foundation/modules/command/lifecycle.mjs +2 -0
  15. package/dist/components/foundation/modules/command/lifecycle.mjs.map +1 -0
  16. package/dist/components/foundation/modules/observability/observability.d.mts +8 -0
  17. package/dist/components/foundation/modules/observability/observability.mjs +1 -1
  18. package/dist/components/foundation/modules/observability/observability.mjs.map +1 -1
  19. package/dist/components/foundation/operation.d.mts +6 -6
  20. package/dist/components/foundation/operation.mjs.map +1 -1
  21. package/dist/contracts.d.mts +4 -0
  22. package/dist/contracts.mjs +1 -0
  23. package/dist/crud.d.mts +2 -2
  24. package/dist/crud.mjs.map +1 -1
  25. package/dist/effect.d.mts +12 -0
  26. package/dist/effect.mjs +1 -0
  27. package/dist/errors.d.mts +2 -1
  28. package/dist/errors.mjs +1 -1
  29. package/dist/errors.mjs.map +1 -1
  30. package/dist/idempotency.d.mts +2 -0
  31. package/dist/idempotency.mjs +1 -0
  32. package/dist/modules/contracts/contract.d.mts +70 -0
  33. package/dist/modules/contracts/contract.mjs +2 -0
  34. package/dist/modules/contracts/contract.mjs.map +1 -0
  35. package/dist/modules/contracts/convex.d.mts +22 -0
  36. package/dist/modules/contracts/convex.mjs +2 -0
  37. package/dist/modules/contracts/convex.mjs.map +1 -0
  38. package/dist/modules/contracts/errors.d.mts +44 -0
  39. package/dist/modules/contracts/errors.mjs +2 -0
  40. package/dist/modules/contracts/errors.mjs.map +1 -0
  41. package/dist/modules/contracts/exposure.d.mts +105 -0
  42. package/dist/modules/contracts/exposure.mjs +2 -0
  43. package/dist/modules/contracts/exposure.mjs.map +1 -0
  44. package/dist/modules/contracts/idempotency.d.mts +87 -0
  45. package/dist/modules/contracts/idempotency.mjs +2 -0
  46. package/dist/modules/contracts/idempotency.mjs.map +1 -0
  47. package/dist/modules/contracts/standard.d.mts +16 -0
  48. package/dist/modules/contracts/standard.mjs +2 -0
  49. package/dist/modules/contracts/standard.mjs.map +1 -0
  50. package/dist/modules/contracts/workflow.d.mts +167 -0
  51. package/dist/modules/contracts/workflow.mjs +2 -0
  52. package/dist/modules/contracts/workflow.mjs.map +1 -0
  53. package/dist/modules/effect/agent-tools.d.mts +2 -0
  54. package/dist/modules/effect/agent-tools.mjs +1 -0
  55. package/dist/modules/effect/api-runtime.d.mts +38 -0
  56. package/dist/modules/effect/api-runtime.mjs +2 -0
  57. package/dist/modules/effect/api-runtime.mjs.map +1 -0
  58. package/dist/modules/effect/api-zod.d.mts +35 -0
  59. package/dist/modules/effect/api-zod.mjs +2 -0
  60. package/dist/modules/effect/api-zod.mjs.map +1 -0
  61. package/dist/modules/effect/api.d.mts +21 -0
  62. package/dist/modules/effect/api.mjs +2 -0
  63. package/dist/modules/effect/api.mjs.map +1 -0
  64. package/dist/modules/effect/client.d.mts +11 -0
  65. package/dist/modules/effect/client.mjs +1 -0
  66. package/dist/modules/effect/command.d.mts +63 -0
  67. package/dist/modules/effect/command.mjs +2 -0
  68. package/dist/modules/effect/command.mjs.map +1 -0
  69. package/dist/modules/effect/crud.d.mts +785 -0
  70. package/dist/modules/effect/crud.mjs +2 -0
  71. package/dist/modules/effect/crud.mjs.map +1 -0
  72. package/dist/modules/effect/errors.d.mts +8 -0
  73. package/dist/modules/effect/errors.mjs +2 -0
  74. package/dist/modules/effect/errors.mjs.map +1 -0
  75. package/dist/modules/effect/foundation.d.mts +21 -0
  76. package/dist/modules/effect/foundation.mjs +2 -0
  77. package/dist/modules/effect/foundation.mjs.map +1 -0
  78. package/dist/modules/effect/idempotency.d.mts +31 -0
  79. package/dist/modules/effect/idempotency.mjs +2 -0
  80. package/dist/modules/effect/idempotency.mjs.map +1 -0
  81. package/dist/modules/effect/normalize.d.mts +10 -0
  82. package/dist/modules/effect/normalize.mjs +2 -0
  83. package/dist/modules/effect/normalize.mjs.map +1 -0
  84. package/dist/modules/effect/operation.d.mts +141 -0
  85. package/dist/modules/effect/operation.mjs +2 -0
  86. package/dist/modules/effect/operation.mjs.map +1 -0
  87. package/dist/modules/effect/query.d.mts +69 -0
  88. package/dist/modules/effect/query.mjs +2 -0
  89. package/dist/modules/effect/query.mjs.map +1 -0
  90. package/dist/modules/effect/schema.d.mts +33 -0
  91. package/dist/modules/effect/schema.mjs +2 -0
  92. package/dist/modules/effect/schema.mjs.map +1 -0
  93. package/dist/modules/effect/workflow.d.mts +77 -0
  94. package/dist/modules/effect/workflow.mjs +2 -0
  95. package/dist/modules/effect/workflow.mjs.map +1 -0
  96. package/dist/node_modules/@standard-schema/spec/dist/index.d.mts +79 -0
  97. package/dist/workflow.d.mts +2 -0
  98. package/dist/workflow.mjs +1 -0
  99. package/docs/architecture.md +3 -3
  100. package/docs/auth.md +71 -0
  101. package/docs/command-context.md +150 -0
  102. package/docs/commands-legacy.md +511 -0
  103. package/docs/commands.md +143 -363
  104. package/docs/conventions.md +15 -5
  105. package/docs/crud.md +4 -0
  106. package/docs/domain-operations.md +615 -0
  107. package/docs/effect.md +395 -0
  108. package/docs/llms.md +69 -45
  109. package/docs/migration.md +1 -1
  110. package/docs/upgrading.md +9 -0
  111. package/package.json +33 -1
  112. package/scripts/convex-smoke-env.mjs +33 -0
  113. package/scripts/smoke-packed-components.mjs +9 -2
  114. package/scripts/smoke-packed-effect.mjs +439 -0
  115. package/scripts/test-packed-effect.mjs +460 -0
  116. package/scripts/test-packed-ids.mjs +1 -0
package/docs/commands.md CHANGED
@@ -1,393 +1,173 @@
1
- # `cvx-kit/components/foundation` — the audited command protocol
1
+ # Commands — declare handlers once, execute through Effect
2
2
 
3
- The Foundation is a Convex component that **owns zero tables and zero
4
- capability** — its schema is empty by design. What it provides is the
5
- application kernel: the `Command` protocol (validate → observe → execute →
6
- audit, in one transaction), the `Query` kernel, and payload-free
7
- `Observability`. It is declared **once** per app and everything destructures
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
- ## Mounting and declaring
9
+ Install the Effect peer before importing this facade:
11
10
 
12
- ```ts
13
- // convex/convex.config.ts
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
- ```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'
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
- 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
- })
35
- ```
22
+ ## Declare commands
36
23
 
37
- Host code consumes the Foundation **only through this facade** — never deep
38
- imports of the component's internal modules.
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
- ## Defining commands — per domain
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
- // convex/domain/documents/commands.ts
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
- const operations = {
57
- 'documents.rename': typed.operation({
58
- command: documents.commandInput.extend({ id: zid('documents') }),
59
- result: z.object({ ok: z.literal(true) }).strict(),
60
- classification: 'business',
61
- audit: ({ command }) => ({
62
- operation: 'documents.rename',
63
- actorId: command.actorId,
64
- aggregate: { type: 'document', id: command.id },
65
- metadata: { title: command.title },
66
- }),
67
- }),
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
- The public API layer then wraps the executor in an `authMutation` and passes
82
- `ctx.actor` in. Public functions stay thin adapters; domain logic lives behind
83
- `execute*` functions.
84
-
85
- ## What one execution does, in order
86
-
87
- 1. **Parse the command** through the operation's `command` schema (strict zod).
88
- 2. **Observe**: start the observation clock.
89
- 3. **Permission check** — if the operation declares `permission`, the
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
- audit: ({ command, result }, ctx) => ({
228
- operation: 'rename',
229
- actorId: ctx.actorId,
230
- aggregate: { type: 'document', id: String(command.title) },
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
- Use `prepare(ctx, parsedCommand)` for host-owned idempotency. It runs after
263
- permission and returns either `{ kind: 'replay', result: durableValue }` or
264
- `{ kind: 'execute', complete: async validatedResult => { ... } }`.
265
- The completion closure captures per-invocation state, including a fingerprint
266
- derived from the parsed command and authenticated actor. Do not cache that
267
- state in a shared context or module variable.
268
-
269
- ```ts
270
- prepare: async (ctx, command) => {
271
- const key = scopedKey(ctx.actorId, command.idempotencyKey)
272
- const fingerprint = fingerprintOf(command)
273
- const receipt = await loadReceipt(ctx, key)
274
- if (receipt) {
275
- if (receipt.fingerprint !== fingerprint) throw new Error('KEY_REUSED')
276
- return { kind: 'replay', result: receipt.result }
277
- }
278
- return {
279
- kind: 'execute',
280
- complete: async result => {
281
- await saveReceipt(ctx, { key, fingerprint, result })
282
- },
283
- }
284
- },
71
+ export const executeRename = (value: z.input<typeof input>, host: Host) =>
72
+ commands.exec('documents.rename', value, host)
285
73
  ```
286
74
 
287
- Storage, actor/tenant scoping, fingerprint comparison, retention, and conflict
288
- policy remain host responsibilities. Put authorization required on replay in
289
- the injected permission checker or prepare lookup; both guards are skipped
290
- on replay. A permission slug without a checker still fails closed. Ordinary
291
- middleware short-circuiting is not this replay path and still audits.
292
-
293
- Replays are parsed through `result` by default. If that schema transforms its
294
- input, store the completion callback's validated output and supply
295
- `replayResult` with an output validator (for example `z.number().int()` when
296
- `result` transforms a string into a number). This prevents reapplying an input
297
- transform to an already transformed durable result. An invalid replay fails
298
- inside observation; successful replay does not invoke completion again.
299
-
300
- Completion is awaited after result validation, audit resolution, aggregate
301
- validation, and audit writing, even on audit-null paths. Errors from prepare,
302
- replay validation, audit, or completion propagate and are passed to the host's
303
- error classifier. Observation success means this command finished; the
304
- enclosing mutation has not necessarily committed yet.
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
- The Query kernel takes the same shape: `new Query({ defaults, middleware,
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
- For atomic rollback, let failures escape the enclosing Convex mutation.
312
- Catching and swallowing a failure inside that mutation can commit earlier
313
- business, audit, and completion writes. Rethrow after effects, or use the
314
- effect-aware `executeResultBoundary` below. External I/O in actions is not
315
- rolled back; these transaction guarantees concern Convex mutation writes.
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
- ### Dynamic dispatch
318
-
319
- `exec` has a second overload for operation selection at runtime:
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
- export const executeAny = commands.exec({
323
- dispatcher: z.object({ kind: z.enum(OPERATION_KINDS) /* from constants.ts */ }),
324
- select: (input) => `documents.${input.kind}` as const,
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
- - `enabled` resolves to `true`,
337
- - `operation` and `classification` match `/^[A-Za-z][A-Za-z0-9_.-]{0,159}$/`,
338
- - `errorCode` (when present) matches `/^[A-Z][A-Z0-9_]{0,95}$/`.
339
-
340
- Practical consequence: **dotted lowercase operation names**
341
- (`documents.rename`) and **UPPER_SNAKE error codes** are effectively
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
- Semantics — read carefully, they are decided by **transaction state**, not by
358
- the error:
359
-
360
- - Success → `{ ok: true, value }`.
361
- - A failure the boundary recognizes (`dataOf(error)` non-undefined) is
362
- returned as `{ ok: false, error }` **only if the transaction has produced no
363
- effects yet** (nothing written, nothing scheduled — checked via
364
- `ctx.meta.getTransactionMetrics()`).
365
- - If anything was already written or scheduled, the error is **rethrown** so
366
- Convex rolls the transaction back — a returned failure after effects would
367
- commit those effects.
368
- - Unrecognized errors and unreadable metrics always rethrow.
369
-
370
- Use it at the outermost edge of a mutation when the client needs a typed
371
- `Result` over the wire instead of a thrown error. `projectResult(result,
372
- onFailure)` unwraps on the caller side.
373
-
374
- ## The `Query` kernel
375
-
376
- `Query` mirrors the pattern for reads: an injected `execute` receives
377
- `{ context, metadata, run }`, letting the host apply uniform read policy
378
- (authorization checks, tracing) around every domain query without the domains
379
- knowing. Metadata defaults are set at construction and merged per-executor.
380
-
381
- ## Rules and footguns
382
-
383
- 1. One `new Foundation(...)` per app, in `convex/foundation.ts`. Everything
384
- else destructures from it.
385
- 2. Never deep-import the component's `modules/*` or `result.ts` paths from app
386
- code — the `client.ts` facade is the contract.
387
- 3. Use `Command.withContext<MutationCtx>()` for typed callbacks and construct
388
- the Command with a compatible host context. Legacy `Command.operation`
389
- keeps its low-level callback signature. Commands with writes run in mutations.
390
- 4. Name operations `domain.verb` and error codes `UPPER_SNAKE` or your
391
- telemetry silently disappears (see regexes above).
392
- 5. Keep operation registries frozen (`as const`) in the domain's
393
- `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.
@@ -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 `new Foundation(...)`, destructured exports | command definitions |
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. The frozen operation registry: `Command.operation({ command, result,
195
- classification, audit })` per operation, keys `'<entity>.<verb>'`.
196
- 2. One `new Command<Ctx, typeof operations>(operations)`.
197
- 3. Exported executors: `export const execute<Verb><Entity> = commands.exec(...)`.
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