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