cvx-kit 0.1.14 → 0.1.16

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 (51) hide show
  1. package/README.md +64 -45
  2. package/dist/querying/cursors.d.mts +5 -0
  3. package/dist/querying/cursors.mjs +1 -1
  4. package/dist/querying/cursors.mjs.map +1 -1
  5. package/dist/querying.d.mts +20 -6
  6. package/dist/querying.mjs +1 -1
  7. package/dist/querying.mjs.map +1 -1
  8. package/dist/relations.mjs +1 -1
  9. package/dist/relations.mjs.map +1 -1
  10. package/dist/zod-table.d.mts +59 -2
  11. package/dist/zod-table.mjs +1 -1
  12. package/dist/zod-table.mjs.map +1 -1
  13. package/package.json +10 -3
  14. package/AGENTS.md +0 -47
  15. package/CHANGELOG.md +0 -324
  16. package/ROADMAP.md +0 -126
  17. package/docs/agent-tools.md +0 -41
  18. package/docs/approvals.md +0 -201
  19. package/docs/architecture.md +0 -124
  20. package/docs/auth.md +0 -443
  21. package/docs/command-context.md +0 -150
  22. package/docs/commands-legacy.md +0 -513
  23. package/docs/commands.md +0 -173
  24. package/docs/conventions.md +0 -345
  25. package/docs/crud.md +0 -66
  26. package/docs/domain-contracts.md +0 -148
  27. package/docs/domain-operations.md +0 -615
  28. package/docs/effect.md +0 -403
  29. package/docs/llms.md +0 -324
  30. package/docs/maintainability.md +0 -124
  31. package/docs/migration.md +0 -351
  32. package/docs/oxlint.md +0 -254
  33. package/docs/querying.md +0 -293
  34. package/docs/tenancy.md +0 -176
  35. package/docs/triggers.md +0 -77
  36. package/docs/upgrading.md +0 -287
  37. package/docs/webhooks.md +0 -58
  38. package/docs/zod-table.md +0 -278
  39. package/scripts/convex-config-bridge.mjs +0 -44
  40. package/scripts/convex-smoke-env.mjs +0 -33
  41. package/scripts/extract-changelog.mjs +0 -29
  42. package/scripts/owned-process.mjs +0 -140
  43. package/scripts/owned-process.test.mjs +0 -107
  44. package/scripts/smoke-packed-components.mjs +0 -341
  45. package/scripts/smoke-packed-effect.mjs +0 -439
  46. package/scripts/smoke-packed-querying.mjs +0 -296
  47. package/scripts/test-packed-auth.mjs +0 -72
  48. package/scripts/test-packed-effect.mjs +0 -482
  49. package/scripts/test-packed-ids.mjs +0 -150
  50. package/scripts/test-packed-oxlint.mjs +0 -167
  51. package/scripts/test-packed-querying.mjs +0 -185
package/docs/auth.md DELETED
@@ -1,443 +0,0 @@
1
- # `cvx-kit/auth` — auth-aware function constructors from injected policy
2
-
3
- `createAuthFunctions` builds the function constructors your whole backend uses
4
- in place of raw `query`/`mutation`/`action`. Nothing in the kit imports your
5
- app's singletons — identity resolution, role vocabulary, membership
6
- verification, trigger wrapping, and error policy are all **injected** by the
7
- host. The kit provides the structure; the app provides the policy.
8
-
9
- ## Setup — declared once per app
10
-
11
- ```ts
12
- // convex/functions.ts (the single place constructors are built)
13
- import { createAuthFunctions, defaultRoleMap } from 'cvx-kit/auth'
14
- import {
15
- action,
16
- internalAction,
17
- internalMutation,
18
- internalQuery,
19
- mutation,
20
- query,
21
- } from './_generated/server'
22
- import type { DataModel } from './_generated/dataModel'
23
- import { triggers } from './triggers'
24
- import { authKit } from './auth'
25
-
26
- export const {
27
- authQuery,
28
- authMutation,
29
- authAction,
30
- roleQuery,
31
- roleMutation,
32
- roleAction,
33
- adminQuery,
34
- adminMutation,
35
- adminAction,
36
- systemQuery,
37
- systemMutation,
38
- systemAction,
39
- include,
40
- authenticatedUser,
41
- } = createAuthFunctions<DataModel>({
42
- query,
43
- mutation,
44
- action,
45
- internalQuery,
46
- internalMutation,
47
- internalAction,
48
- getAuthUser: (ctx) => authKit.getAuthUser(ctx),
49
- mapRole: defaultRoleMap, // or your own vocabulary (see below)
50
- adminRoles: ['admin'],
51
- triggers, // every mutation write runs through wrapDB
52
- verifyMembership: async ({ userId, organizationId }) => {
53
- const memberships = await authKit.workos.userManagement.listOrganizationMemberships({
54
- organizationId,
55
- userId,
56
- statuses: ['active'],
57
- })
58
- const m = memberships.data.find((c) => c.userId === userId)
59
- return m ? { organizationId: m.organizationId, roleSlug: m.role.slug } : null
60
- },
61
- })
62
- ```
63
-
64
- Every function in the app is then built from these — raw `query`/`mutation`
65
- imports from `_generated/server` appear **only** in this file.
66
-
67
- ## One shared Effect function foundation
68
-
69
- Use `createEffectAuthFunctions` from `cvx-kit/effect` in `convex/functions.ts`
70
- to configure all twelve constructors once. Its first argument is the same
71
- `AuthFunctionsConfig` used by `createAuthFunctions`; its second argument has
72
- six explicit policies: `query`, `mutation`, `action`, `systemQuery`,
73
- `systemMutation`, and `systemAction`. Each accepts `services`, `context`, and
74
- `mapError`, like `effectZodApiBuilder`. Pass `{}` when no additions are needed.
75
-
76
- Type the auth config with your generated `DataModel` and role vocabulary,
77
- then let the factory infer its generics from both arguments. Do not supply
78
- partial generic arguments, which would fix the remaining inference defaults.
79
- The following public-package example defines a reusable app factory; the
80
- host passes its auth, membership, trigger, and RLS policy as `config`.
81
-
82
- <!-- packed-effect-example -->
83
-
84
- ```ts
85
- import {
86
- defineSchema,
87
- defineTable,
88
- makeFunctionReference,
89
- type DataModelFromSchemaDefinition,
90
- type GenericQueryCtx,
91
- type GenericMutationCtx,
92
- } from 'convex/server'
93
- import { v } from 'convex/values'
94
- import { Effect } from 'effect'
95
- import { z } from 'zod'
96
- import type { AuthFunctionsConfig, AuthBundle } from 'cvx-kit/auth'
97
- import { createEffectAuthFunctions, createEffectFoundation } from 'cvx-kit/effect'
98
-
99
- const schema = defineSchema({ notes: defineTable({ tenant: v.string(), title: v.string() }) })
100
- type Model = DataModelFromSchemaDefinition<typeof schema>
101
- type Role = 'owner' | 'viewer'
102
- const input = z.object({ title: z.string().trim().min(1) })
103
- const saveRef = makeFunctionReference<'mutation', z.input<typeof input>, null>('notes:save')
104
-
105
- export function createApplicationFunctions(config: AuthFunctionsConfig<Model, Role>) {
106
- const foundation = createEffectFoundation({
107
- observability: {
108
- enabled: false,
109
- classifyError: () => ({ outcome: 'failed', errorCode: 'FAILED' }),
110
- },
111
- writeAudit: () => undefined, // supply the app's transactional audit writer in production
112
- })
113
- const queries = foundation.Query({
114
- context: (ctx: (GenericQueryCtx<Model> | GenericMutationCtx<Model>) & AuthBundle<Role>) => ctx,
115
- operations: ({ query }) => ({
116
- actor: query({
117
- input: z.object({}),
118
- result: z.string(),
119
- handler: (_input, ctx) => ctx.actor.userId,
120
- }),
121
- }),
122
- })
123
- const commands = foundation.Command({
124
- context: (ctx: GenericMutationCtx<Model> & AuthBundle<Role>) => ctx,
125
- operations: ({ command }) => ({
126
- save: command({
127
- input,
128
- result: z.null(),
129
- classification: 'business',
130
- audit: () => null,
131
- handler: (args, ctx) =>
132
- Effect.promise(async () => {
133
- await ctx.db.insert('notes', { title: args.title, tenant: ctx.tenant })
134
- return null
135
- }),
136
- }),
137
- }),
138
- })
139
- const functions = createEffectAuthFunctions(config, {
140
- query: { context: (ctx) => ({ queries: queries.withContext(ctx) }) },
141
- mutation: {
142
- context: (ctx) => ({
143
- queries: queries.withContext(ctx),
144
- commands: commands.withContext(ctx),
145
- }),
146
- },
147
- action: {
148
- context: (ctx) => ({
149
- commands: {
150
- save: (args: z.input<typeof input>) =>
151
- Effect.promise(() => ctx.runMutation(saveRef, args)),
152
- },
153
- }),
154
- },
155
- systemQuery: {},
156
- systemMutation: {},
157
- systemAction: {},
158
- })
159
- const save = functions.roleMutation('owner')({
160
- args: input.shape,
161
- returns: z.null(),
162
- handler: (ctx, args) => ctx.commands.exec('save', args),
163
- })
164
- return { ...functions, save }
165
- }
166
- ```
167
-
168
- Export the returned constructors once from `convex/functions.ts`, and export
169
- `save` at the registered `notes:save` mutation path. Endpoint modules can then
170
- use `ctx.commands` or `ctx.queries` directly. The callbacks run per invocation
171
- after auth, role checks, triggers/RLS wrapping, and live action membership
172
- refresh. Binding is lazy; do not cache an authenticated `withContext` handle
173
- at module scope. Denied requests never run the callbacks.
174
-
175
- | Policy | Available authority and capabilities |
176
- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
177
- | `query` | Trusted auth bundle, secured DB reader, `include`; bind read registries only. |
178
- | `mutation` | Trusted auth bundle, secured triggered DB writer, `include`; bind queries and mutation commands. |
179
- | `action` | Live-verified auth bundle, `runQuery`/`runMutation`; use action-compatible registries or forward to registered functions. No DB or `include`. |
180
- | `systemQuery` / `systemMutation` / `systemAction` | Existing internal authority, configured separately. No fabricated actor or inherited public handles; system mutations retain triggers and bypass public RLS as before. |
181
-
182
- Every auth/role/admin constructor of a kind shares that kind's policy. System
183
- policies are deliberately explicit; choose their trusted capabilities rather
184
- than synthesizing public credentials. An action forwarding to `runMutation`
185
- creates a separate registered mutation transaction; it does not turn the
186
- whole action into a transaction or carry its live role into that mutation's
187
- JWT-based auth policy.
188
-
189
- Additions cannot replace native or auth context keys: static checks reject
190
- known collisions and runtime checks also reject inherited keys before the
191
- handler executes. Put class-based handles under named properties in the
192
- returned plain record. Scoped services, required Effect services, Zod raw and
193
- parsed argument/result types, function references, and error projection retain
194
- the existing adapter's behavior. Let failures escape registered mutations so
195
- nested command writes, triggers, and audit writes roll back together.
196
-
197
- ## Shared Effect execution after authentication
198
-
199
- For an individual constructor, wrap the builder returned by `createAuthFunctions` with
200
- `effectZodApiBuilder` from `cvx-kit/effect`. The original constructor resolves
201
- authentication, authorization, tenant policy, and the wrapped database before
202
- the service provider runs. Keep that ordering when adding Effect; use the Zod
203
- adapter for these custom constructors.
204
-
205
- This factory accepts the configured `authMutation` from the setup above. The
206
- actor service comes from trusted `ctx.actor`, with no actor or organization
207
- argument from callers:
208
-
209
- <!-- packed-effect-example -->
210
-
211
- ```ts
212
- import type { GenericDataModel } from 'convex/server'
213
- import { createAuthFunctions } from 'cvx-kit/auth'
214
- import { effectZodApiBuilder } from 'cvx-kit/effect'
215
- import { Context, Effect } from 'effect'
216
- import { z } from 'zod'
217
-
218
- class Actor extends Context.Service<
219
- Actor,
220
- { userId: string; organizationId: string; role: string }
221
- >()('app/Actor') {}
222
-
223
- export function createEffectAccountApi<DataModel extends GenericDataModel>(
224
- baseAuthMutation: ReturnType<typeof createAuthFunctions<DataModel>>['authMutation'],
225
- ) {
226
- const authMutation = effectZodApiBuilder(baseAuthMutation, {
227
- services: (ctx) => Context.make(Actor, ctx.actor),
228
- })
229
- const currentActor = authMutation({
230
- args: {},
231
- returns: z.object({ userId: z.string(), organizationId: z.string() }),
232
- handler: () =>
233
- Effect.gen(function* () {
234
- const actor = yield* Actor
235
- return { userId: actor.userId, organizationId: actor.organizationId }
236
- }),
237
- })
238
- return { authMutation, currentActor }
239
- }
240
- ```
241
-
242
- Call `createEffectAccountApi(authMutation)` with the constructor exported by
243
- your `convex/functions.ts`, then export `currentActor` from the account API
244
- module. The factory preserves the configured auth policy; it does not choose
245
- an identity provider or membership authority.
246
-
247
- Declare the provider once per constructor, then reuse the builder across
248
- endpoints. Database services should capture the provider's `ctx.db`, so
249
- trigger and RLS enforcement also applies to Effect-driven writes. Services
250
- are created per invocation; do not cache an authenticated context in a module
251
- singleton. Query providers receive readers; HTTP integrations belong in
252
- actions.
253
-
254
- The adapter runs the composed Effect within an invocation scope and awaits
255
- finalizers before returning. Domain errors can be mapped with handler pipes
256
- or shared `mapError`; they remain thrown failures at the native boundary so
257
- mutation writes roll back. Native argument/result validators remain in place.
258
- Effect's inferred server-side error and service channels are not a typed
259
- transport error contract. See [the Effect guide](./effect.md) for complete
260
- domain registries, error mapping, scoped services, and tracing.
261
-
262
- ## The constructor families
263
-
264
- | Constructor | Visibility | Auth | Extra |
265
- | --------------------------------------------------------------- | ---------- | ---------------------------- | -------------------------------- |
266
- | `authQuery` / `authMutation` / `authAction` | public | any authenticated org member | actions live-verify membership |
267
- | `roleQuery(...roles)` / `roleMutation(...)` / `roleAction(...)` | public | listed roles only | factory — call with your roles |
268
- | `adminQuery` / `adminMutation` / `adminAction` | public | `config.adminRoles` | pre-built `role*(...adminRoles)` |
269
- | `systemQuery` | internal | none (trusted caller) | include-equipped, RLS-unwrapped |
270
- | `systemMutation` | internal | none (trusted caller) | still trigger-wrapped |
271
- | `systemAction` | internal | none | plain internal action |
272
-
273
- All are `zCustom*` constructors from convex-helpers, so `args` and `returns`
274
- take zod schemas directly and compose with `zodTable` masks.
275
-
276
- ## What handlers receive on `ctx`
277
-
278
- Authenticated constructors extend the ctx with a frozen auth bundle:
279
-
280
- - `ctx.identity` — the raw `UserIdentity` (JWT claims)
281
- - `ctx.user.id` — the synchronized principal id (from `getAuthUser`)
282
- - `ctx.org` — `{ organizationId, role }`
283
- - `ctx.role` — the mapped role
284
- - `ctx.actor` — `{ userId, organizationId, role }` — pass this to audits,
285
- approvals, and commands as the canonical actor reference
286
- - `ctx.include` — the bounded query builder (below)
287
-
288
- `systemQuery` and `systemMutation` get `include` (plus trigger wrapping for the
289
- mutation) but no auth bundle.
290
-
291
- ## Role vocabulary is per-app
292
-
293
- `mapRole` maps the identity's role slug onto **your** role union; returning
294
- `null` rejects with `FORBIDDEN`, so unknown roles never pass. The default
295
- (`defaultRoleMap`) is WorkOS-flavored: `member → writer`, and
296
- `reader`/`writer`/`admin` pass through.
297
-
298
- Supply your own vocabulary via the generic:
299
-
300
- ```ts
301
- createAuthFunctions<DataModel, 'viewer' | 'editor' | 'owner'>({
302
- mapRole: (slug) => (slug === 'owner' ? 'owner' : slug === 'editor' ? 'editor' : slug ? 'viewer' : null),
303
- adminRoles: ['owner'],
304
- ...
305
- })
306
- ```
307
-
308
- ## Queries/mutations trust the JWT; actions re-verify
309
-
310
- Queries and mutations read org and role from the identity token. Actions —
311
- which can run long and call third parties — additionally call
312
- `verifyMembership` **live** before the handler runs, and the fresher
313
- role/organization from that check replaces the JWT's. Verification **fails
314
- closed**: any error or org mismatch throws `FORBIDDEN`. Omit
315
- `verifyMembership` to let actions trust the JWT like queries do.
316
-
317
- ## Resolving org/role from the database (`resolveOrganization`)
318
-
319
- Some identity providers issue tokens that carry **no org claims** — custom
320
- credentials, magic codes, or apps that keep memberships in their own tables.
321
- For those, configure `resolveOrganization` to derive organization authority
322
- from the app's own data instead of the JWT.
323
-
324
- When configured, the hook is the organization authority — claim parsing is
325
- skipped entirely. Returning `null` **or throwing** rejects with `FORBIDDEN`
326
- (fail closed); a missing identity or user is still `UNAUTHENTICATED`; the
327
- returned `roleSlug` still passes through `mapRole` like a claim would.
328
-
329
- The handler must be **action-safe**: in actions the ctx has no `db`, so branch
330
- on its presence and fall back to an internal query:
331
-
332
- ```ts
333
- import { internal } from './_generated/api'
334
-
335
- resolveOrganization: async ({ ctx, user }) => {
336
- const membership =
337
- 'db' in ctx
338
- ? await ctx.db
339
- .query('companyUsers')
340
- .withIndex('by_user', (q) => q.eq('userId', user.id))
341
- .first()
342
- : await ctx.runQuery(internal.memberships.byUser, { userId: user.id })
343
- if (!membership || !membership.active) return null // revoked ⇒ FORBIDDEN
344
- return { organizationId: membership.orgId, roleSlug: membership.roleSlug }
345
- },
346
- ```
347
-
348
- Resolver obligations:
349
-
350
- - Bind the lookup to the **verified** user — `user.id` (or
351
- `identity.subject`), never a caller-influenced or non-unique attribute.
352
- - Resolve revoked/inactive/expired memberships to `null`, so revocation
353
- actually takes effect fail-closed.
354
-
355
- Composition: `mapRole` still applies to the returned slug; with tenancy
356
- configured, `ctx.tenant` derives from the hook's org
357
- (`security.tenancy.resolve` is unchanged); `verifyMembership` for actions is
358
- unchanged and receives the hook-resolved `organizationId` — in actions its
359
- live result supersedes the hook's role/org, exactly as it supersedes claims.
360
-
361
- Cost: one DB read per authenticated call (claims are free); actions with
362
- `verifyMembership` configured also pay the live check. When debugging, note
363
- that a misconfigured hook surfaces as blanket `FORBIDDEN` — errors are
364
- swallowed fail-closed, never rethrown.
365
-
366
- ## Mutations are structurally trigger-wrapped
367
-
368
- When `triggers` (or `wrapDB`) is configured, every `authMutation`,
369
- `roleMutation`, and `systemMutation` runs its writes through
370
- `triggers.wrapDB(ctx)`. Trigger enforcement (timestamps, append-only,
371
- no-delete, denormalization) is therefore **structural** — a handler cannot
372
- forget it, because it never sees an unwrapped `ctx.db`. This is the reason raw
373
- `mutation` must not be used outside `functions.ts`.
374
-
375
- ## `include()` — every read is bounded
376
-
377
- `include` wraps a table query in a fluent selector that (a) picks the first
378
- matching indexed query and (b) refuses unbounded reads. `execute(limit)`
379
- rejects limits outside `1..maxRows` (default 100, configurable via
380
- `maxIncludedQueryRows`).
381
-
382
- ```ts
383
- export const list = authQuery({
384
- args: { ownerId: zid('users').optional(), limit: z.number() },
385
- returns: z.array(documents.publicDto),
386
- handler: (ctx, args) =>
387
- ctx
388
- .include(ctx.db.query('documents'))
389
- .when(args.ownerId, (q, ownerId) =>
390
- q.withIndex('by_owner', (ix) => ix.eq('ownerId', ownerId)),
391
- )
392
- .otherwise((q) => q.withIndex('by_owner'))
393
- .execute(args.limit, (rows) => rows.map(documents.toPublicDto)),
394
- })
395
- ```
396
-
397
- Selector methods:
398
-
399
- - `.when(value, select)` — if `value` is non-null and nothing selected yet,
400
- use `select(query, value)`.
401
- - `.matching(indexName, range?, shouldMatch?)` — index selection with an
402
- optional guard boolean.
403
- - `.otherwise(select)` — fallback, returns the raw `Query`.
404
- - `.resolve()` — selected query or full table scan (escape hatch).
405
- - `.execute(limit, transform?)` — bounded `take` + optional projection. This
406
- is the normal terminal.
407
-
408
- ## Row-level security and tenancy (optional)
409
-
410
- The `security` config adds structural RLS to every `auth*`/`role*`/`admin*`
411
- constructor — role-level rules (`security.rules`, standalone), tenant
412
- isolation from a table registry (`security.tenancy`, opt-in), or both
413
- AND-composed. Queries get a wrapped reader; mutations wrap triggers first,
414
- then the RLS writer; `system*` stays unwrapped. With tenancy configured, ctx
415
- gains `ctx.tenant` (actions re-derive it from live verification). Full
416
- treatment: `tenancy.md`.
417
-
418
- ## Errors
419
-
420
- All rejections go through the injected `ErrorFactory` (default: throws
421
- `KitError` with codes `UNAUTHENTICATED`, `FORBIDDEN`,
422
- `INVALID_REQUEST_BOUNDARY`). Hosts with their own error taxonomy adapt it:
423
-
424
- ```ts
425
- errors: { throw: (input) => { throw App.errors.from(input) } }
426
- ```
427
-
428
- ## Rules of thumb
429
-
430
- 1. Build constructors once in `convex/functions.ts`; import them everywhere
431
- else. Raw `_generated/server` builders appear nowhere else.
432
- 2. Public functions: `auth*`/`role*`/`admin*`. Scheduled/internal work:
433
- `system*`. Never expose an unauthenticated public function unless it is
434
- deliberately anonymous (and then document why).
435
- 3. Reads go through `ctx.include(...)` with an explicit limit.
436
- 4. Use `ctx.actor` as the actor reference in audits and approvals — never
437
- re-derive identity in a handler.
438
-
439
- Optional request context can be added through the existing Effect API adapter's
440
- `context` callback. Bind selected registries with `commands.withContext(value)`
441
- and consume `ctx.commands.exec(name, input)` through the application's existing
442
- function helper names. Keep dependencies local to each feature; explicit execution
443
- and input-only command handlers remain supported. See [optional context injection](./command-context.md).
@@ -1,150 +0,0 @@
1
- # Optional command context injection
2
-
3
- Bind context once at a function boundary when it makes endpoint code clearer.
4
- `withContext(value)` accepts whatever the registry's resolver accepts: a number,
5
- a service bundle, a request context, or another bound command group. It returns
6
- a small handle whose `exec(name, input)` still produces a lazy Effect. Binding
7
- neither resolves context nor executes a command. Explicit
8
- `commands.exec(name, input, context)` remains supported.
9
-
10
- ## Keep existing function names
11
-
12
- Add context through `effectZodApiBuilder` around the existing custom constructor.
13
- Export it under your application's ordinary `authMutation`, `adminQuery`,
14
- `organizationMutation`, or other function name. These are application choices;
15
- the library does not require a new helper family. The same option works on
16
- `effectApiBuilder` for native builders. `services` is optional when your Effects
17
- need no services.
18
-
19
- The complete example below uses a custom authenticated mutation to make the
20
- context flow standalone. A kit application can supply
21
- `createAuthFunctions(...).authMutation` instead. Its authenticated, secured
22
- context is constructed before the additions callback runs.
23
-
24
- <!-- packed-effect-example -->
25
-
26
- ```ts
27
- import { Effect } from 'effect'
28
- import { mutationGeneric } from 'convex/server'
29
- import { zCustomMutation } from 'convex-helpers/server/zod4'
30
- import { createEffectFoundation, effectZodApiBuilder } from 'cvx-kit/effect'
31
- import { z } from 'zod'
32
-
33
- const foundation = createEffectFoundation({
34
- observability: {
35
- enabled: false,
36
- classifyError: () => ({ outcome: 'failed', errorCode: 'COMMAND_FAILED' }),
37
- },
38
- // Standalone demonstration only: production applications provide their audit writer.
39
- writeAudit: () => undefined,
40
- })
41
-
42
- const notifications = foundation.Command({
43
- context: (dependencies: { format: (message: string) => string }) => dependencies,
44
- operations: ({ command }) => ({
45
- format: command({
46
- input: z.string(), result: z.string(), classification: 'business',
47
- handler: (message, context) => context.format(message),
48
- audit: () => null,
49
- }),
50
- }),
51
- })
52
-
53
- const tasks = foundation.Command({
54
- context: (dependencies: {
55
- nextNumber: () => number,
56
- notifications: ReturnType<typeof notifications.withContext>,
57
- }) => dependencies,
58
- operations: ({ command }) => ({
59
- create: command({
60
- input: z.string(), result: z.string(), classification: 'business',
61
- handler: (title, context) => context.notifications.exec(
62
- 'format', `${context.nextNumber()}:${title}`,
63
- ),
64
- audit: () => null,
65
- }),
66
- }),
67
- })
68
-
69
- const baseAuthMutation = zCustomMutation(mutationGeneric, {
70
- args: {},
71
- input: async (ctx) => {
72
- const identity = await ctx.auth.getUserIdentity()
73
- if (!identity) throw Error('UNAUTHORIZED')
74
- return { ctx: { actor: identity.subject }, args: {} }
75
- },
76
- })
77
-
78
- // In a real app, keep this composition in the tasks feature's functions module.
79
- export const authMutation = effectZodApiBuilder(baseAuthMutation, {
80
- context: (ctx) => {
81
- const messages = notifications.withContext({
82
- format: (message) => `${ctx.actor}:${message}`,
83
- })
84
- return {
85
- tasks: tasks.withContext({ nextNumber: () => 42, notifications: messages }),
86
- }
87
- },
88
- })
89
-
90
- export const createTask = authMutation({
91
- args: { title: z.string() },
92
- returns: z.string(),
93
- handler: (ctx, input) => ctx.tasks.exec('create', input.title),
94
- })
95
-
96
- // An input-only command needs no domain dependencies.
97
- const echo = foundation.Command({
98
- context: () => undefined,
99
- operations: ({ command }) => ({
100
- echo: command({
101
- input: z.string(), result: z.string(), classification: 'business',
102
- handler: (input) => input, audit: () => null,
103
- }),
104
- }),
105
- })
106
- export const echoEffect = echo.exec('echo', 'hello', undefined)
107
- export const boundEchoEffect = echo.withContext(undefined).exec('echo', 'hello')
108
- // A program can compose further work; the API adapter owns execution.
109
- export const composedEcho = Effect.map(boundEchoEffect, (value) => value.length)
110
- ```
111
-
112
- Context additions are optional and cannot replace existing fields, including
113
- `db`, `auth`, or a custom constructor's authenticated actor. Type checking rejects
114
- known collisions with declared fields. Runtime checking also protects inherited
115
- properties (such as `toString`) and keys from broadly typed records or erased
116
- types; those collisions cannot always be determined from the declared context
117
- type. Additions can
118
- return a plain record of own properties, or a Promise or Effect of that record.
119
- Wrap class instances and services in named properties, such as `{ pricingService }`,
120
- rather than returning an instance as the additions object. The adapter provides services first, then
121
- runs initialization and the handler in the same invocation scope. Missing
122
- initializer services are rejected when registering the endpoint, just like
123
- missing handler services. Initialization failures use the configured error
124
- projection and release acquired resources.
125
-
126
- ## Applications with 25–50 entities
127
-
128
- Organize by feature or workflow rather than making every entity a property of a
129
- single global context. A tasks helper can bind tasks and notifications; a billing
130
- helper can bind billing and pricing. Each registry declares its own narrow
131
- resolver input. Share stable service implementations at module scope, and build
132
- request-dependent handles within the context callback.
133
-
134
- Keep shared identity or tenant information small. Inject a business function or
135
- service only into the features that use it. Pass bound commands only where a
136
- workflow calls them; avoid mutual command dependencies. Bindings hold references
137
- and do not initialize the referenced registry. Large dependency graphs are
138
- application architecture choices, not a required library container.
139
-
140
- If a feature needs no injected commands, omit `context` additions. If an endpoint
141
- benefits from explicit execution, pass its context directly. An input-only
142
- handler can ignore domain context, but permission and audit policies may still
143
- need the original trusted invocation context. Preserve those policies when
144
- choosing the registry resolver and binding input.
145
-
146
- Nested calls return Effects and compose in the current program. Do not call
147
- `Effect.runPromise` inside command handlers; the function adapter runs the
148
- complete program once. Command schema checks, permissions, audit, and completion
149
- remain active for each nested command. Keep database mutation work in a mutation
150
- boundary so Convex can roll it back if execution or result validation fails.