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