cvx-kit 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +63 -38
- package/dist/agent-tools.d.mts +2 -1
- package/dist/agent-tools.mjs +1 -1
- package/dist/agent-tools.mjs.map +1 -1
- package/dist/components/approvals/audit.mjs +1 -1
- package/dist/components/foundation/client.d.mts +16 -7
- package/dist/components/foundation/client.mjs +1 -1
- package/dist/components/foundation/client.mjs.map +1 -1
- package/dist/components/foundation/modules/command/command.d.mts +50 -10
- package/dist/components/foundation/modules/command/command.mjs +1 -1
- package/dist/components/foundation/modules/command/command.mjs.map +1 -1
- package/dist/components/foundation/modules/command/lifecycle.d.mts +81 -0
- package/dist/components/foundation/modules/command/lifecycle.mjs +2 -0
- package/dist/components/foundation/modules/command/lifecycle.mjs.map +1 -0
- package/dist/components/foundation/modules/observability/observability.d.mts +8 -0
- package/dist/components/foundation/modules/observability/observability.mjs +1 -1
- package/dist/components/foundation/modules/observability/observability.mjs.map +1 -1
- package/dist/components/foundation/operation.d.mts +6 -6
- package/dist/components/foundation/operation.mjs.map +1 -1
- package/dist/contracts.d.mts +4 -0
- package/dist/contracts.mjs +1 -0
- package/dist/crud.d.mts +2 -2
- package/dist/crud.mjs.map +1 -1
- package/dist/effect.d.mts +12 -0
- package/dist/effect.mjs +1 -0
- package/dist/errors.d.mts +2 -1
- package/dist/errors.mjs +1 -1
- package/dist/errors.mjs.map +1 -1
- package/dist/idempotency.d.mts +2 -0
- package/dist/idempotency.mjs +1 -0
- package/dist/modules/contracts/contract.d.mts +70 -0
- package/dist/modules/contracts/contract.mjs +2 -0
- package/dist/modules/contracts/contract.mjs.map +1 -0
- package/dist/modules/contracts/convex.d.mts +22 -0
- package/dist/modules/contracts/convex.mjs +2 -0
- package/dist/modules/contracts/convex.mjs.map +1 -0
- package/dist/modules/contracts/errors.d.mts +44 -0
- package/dist/modules/contracts/errors.mjs +2 -0
- package/dist/modules/contracts/errors.mjs.map +1 -0
- package/dist/modules/contracts/exposure.d.mts +105 -0
- package/dist/modules/contracts/exposure.mjs +2 -0
- package/dist/modules/contracts/exposure.mjs.map +1 -0
- package/dist/modules/contracts/idempotency.d.mts +87 -0
- package/dist/modules/contracts/idempotency.mjs +2 -0
- package/dist/modules/contracts/idempotency.mjs.map +1 -0
- package/dist/modules/contracts/standard.d.mts +16 -0
- package/dist/modules/contracts/standard.mjs +2 -0
- package/dist/modules/contracts/standard.mjs.map +1 -0
- package/dist/modules/contracts/workflow.d.mts +167 -0
- package/dist/modules/contracts/workflow.mjs +2 -0
- package/dist/modules/contracts/workflow.mjs.map +1 -0
- package/dist/modules/effect/agent-tools.d.mts +2 -0
- package/dist/modules/effect/agent-tools.mjs +1 -0
- package/dist/modules/effect/api-runtime.d.mts +38 -0
- package/dist/modules/effect/api-runtime.mjs +2 -0
- package/dist/modules/effect/api-runtime.mjs.map +1 -0
- package/dist/modules/effect/api-zod.d.mts +35 -0
- package/dist/modules/effect/api-zod.mjs +2 -0
- package/dist/modules/effect/api-zod.mjs.map +1 -0
- package/dist/modules/effect/api.d.mts +21 -0
- package/dist/modules/effect/api.mjs +2 -0
- package/dist/modules/effect/api.mjs.map +1 -0
- package/dist/modules/effect/client.d.mts +11 -0
- package/dist/modules/effect/client.mjs +1 -0
- package/dist/modules/effect/command.d.mts +63 -0
- package/dist/modules/effect/command.mjs +2 -0
- package/dist/modules/effect/command.mjs.map +1 -0
- package/dist/modules/effect/crud.d.mts +785 -0
- package/dist/modules/effect/crud.mjs +2 -0
- package/dist/modules/effect/crud.mjs.map +1 -0
- package/dist/modules/effect/errors.d.mts +8 -0
- package/dist/modules/effect/errors.mjs +2 -0
- package/dist/modules/effect/errors.mjs.map +1 -0
- package/dist/modules/effect/foundation.d.mts +21 -0
- package/dist/modules/effect/foundation.mjs +2 -0
- package/dist/modules/effect/foundation.mjs.map +1 -0
- package/dist/modules/effect/idempotency.d.mts +31 -0
- package/dist/modules/effect/idempotency.mjs +2 -0
- package/dist/modules/effect/idempotency.mjs.map +1 -0
- package/dist/modules/effect/normalize.d.mts +10 -0
- package/dist/modules/effect/normalize.mjs +2 -0
- package/dist/modules/effect/normalize.mjs.map +1 -0
- package/dist/modules/effect/operation.d.mts +141 -0
- package/dist/modules/effect/operation.mjs +2 -0
- package/dist/modules/effect/operation.mjs.map +1 -0
- package/dist/modules/effect/query.d.mts +69 -0
- package/dist/modules/effect/query.mjs +2 -0
- package/dist/modules/effect/query.mjs.map +1 -0
- package/dist/modules/effect/schema.d.mts +33 -0
- package/dist/modules/effect/schema.mjs +2 -0
- package/dist/modules/effect/schema.mjs.map +1 -0
- package/dist/modules/effect/workflow.d.mts +77 -0
- package/dist/modules/effect/workflow.mjs +2 -0
- package/dist/modules/effect/workflow.mjs.map +1 -0
- package/dist/node_modules/@standard-schema/spec/dist/index.d.mts +79 -0
- package/dist/workflow.d.mts +2 -0
- package/dist/workflow.mjs +1 -0
- package/docs/architecture.md +3 -3
- package/docs/auth.md +71 -0
- package/docs/command-context.md +150 -0
- package/docs/commands-legacy.md +511 -0
- package/docs/commands.md +143 -363
- package/docs/conventions.md +15 -5
- package/docs/crud.md +4 -0
- package/docs/domain-operations.md +615 -0
- package/docs/effect.md +395 -0
- package/docs/llms.md +69 -45
- package/docs/migration.md +1 -1
- package/docs/upgrading.md +9 -0
- package/package.json +33 -1
- package/scripts/convex-smoke-env.mjs +33 -0
- package/scripts/smoke-packed-components.mjs +9 -2
- package/scripts/smoke-packed-effect.mjs +439 -0
- package/scripts/test-packed-effect.mjs +460 -0
- package/scripts/test-packed-ids.mjs +1 -0
package/docs/effect.md
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
# Effect domain commands and queries
|
|
2
|
+
|
|
3
|
+
`cvx-kit/effect` adds cohesive command/query declarations and a shared API
|
|
4
|
+
runner. Handlers can return ordinary values, Promises, or Effects. Each
|
|
5
|
+
registry returns a lazy Effect from `exec(operation, input, host)`; the API
|
|
6
|
+
adapter provides request services and runs it once.
|
|
7
|
+
|
|
8
|
+
This is the recommended command API. The older `Foundation.Command` facade
|
|
9
|
+
and `exec({ operation, handler })` executor API are deprecated and retained for
|
|
10
|
+
compatibility. Declare handlers inside `command({...})` for new command code.
|
|
11
|
+
|
|
12
|
+
Install Effect explicitly:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
bun add effect@4.0.1
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The optional peer range is `>=4.0.1 <5`. Existing imports and Promise-based
|
|
19
|
+
Foundation consumers work without Effect installed. Use the public facade;
|
|
20
|
+
the internal module paths are not supported imports.
|
|
21
|
+
|
|
22
|
+
## A complete domain-to-API example
|
|
23
|
+
|
|
24
|
+
These four TypeScript sections form one module and are compiled together
|
|
25
|
+
against the packed public package. The example uses Convex's generic builders
|
|
26
|
+
with a concrete schema so it is standalone. In an app, put the schema in
|
|
27
|
+
`convex/schema.ts`, import your generated `query`, `mutation`, `QueryCtx`, and
|
|
28
|
+
`MutationCtx` from `./_generated/server`, and split the remaining code into
|
|
29
|
+
services, domain registries, shared API setup, and endpoint modules.
|
|
30
|
+
|
|
31
|
+
The API obtains the actor from authenticated Convex identity. Caller arguments
|
|
32
|
+
contain only a document key and title. Both services constrain document reads
|
|
33
|
+
and writes by that actor; the application should also enforce its own roles
|
|
34
|
+
and tenant policies. The small permission checker below only requires a
|
|
35
|
+
nonempty trusted actor.
|
|
36
|
+
|
|
37
|
+
### Contracts and injectable capabilities
|
|
38
|
+
|
|
39
|
+
The reader has no write methods. The mutation provider will add the writer;
|
|
40
|
+
a native query cannot satisfy a command's writer requirement. Expected errors
|
|
41
|
+
are values in the Effect error channel, rather than thrown exceptions.
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { Context, Effect } from 'effect'
|
|
45
|
+
import {
|
|
46
|
+
defineSchema,
|
|
47
|
+
defineTable,
|
|
48
|
+
queryGeneric,
|
|
49
|
+
mutationGeneric,
|
|
50
|
+
type DataModelFromSchemaDefinition,
|
|
51
|
+
type QueryBuilder,
|
|
52
|
+
type MutationBuilder,
|
|
53
|
+
type GenericQueryCtx,
|
|
54
|
+
type GenericMutationCtx,
|
|
55
|
+
} from 'convex/server'
|
|
56
|
+
import { ConvexError, v } from 'convex/values'
|
|
57
|
+
import { z } from 'zod'
|
|
58
|
+
import { createEffectFoundation, effectApiBuilder } from 'cvx-kit/effect'
|
|
59
|
+
import type { AuditEntryInput } from 'cvx-kit/components/foundation'
|
|
60
|
+
|
|
61
|
+
export const schema = defineSchema({
|
|
62
|
+
documents: defineTable({ key: v.string(), owner: v.string(), title: v.string() }).index(
|
|
63
|
+
'by_owner_key',
|
|
64
|
+
['owner', 'key'],
|
|
65
|
+
),
|
|
66
|
+
audits: defineTable({ actor: v.string(), operation: v.string(), documentKey: v.string() }),
|
|
67
|
+
})
|
|
68
|
+
type DataModel = DataModelFromSchemaDefinition<typeof schema>
|
|
69
|
+
type QueryCtx = GenericQueryCtx<DataModel>
|
|
70
|
+
type MutationCtx = GenericMutationCtx<DataModel>
|
|
71
|
+
const nativeQuery: QueryBuilder<DataModel, 'public'> = queryGeneric
|
|
72
|
+
const nativeMutation: MutationBuilder<DataModel, 'public'> = mutationGeneric
|
|
73
|
+
|
|
74
|
+
type Host = { actor: string }
|
|
75
|
+
class DocumentMissing {
|
|
76
|
+
readonly _tag = 'DocumentMissing'
|
|
77
|
+
}
|
|
78
|
+
class RenameRejected {
|
|
79
|
+
readonly _tag = 'RenameRejected'
|
|
80
|
+
}
|
|
81
|
+
class AccessDenied {
|
|
82
|
+
readonly _tag = 'AccessDenied'
|
|
83
|
+
}
|
|
84
|
+
class DocumentReader extends Context.Service<
|
|
85
|
+
DocumentReader,
|
|
86
|
+
{ title: (key: string, actor: string) => Effect.Effect<string, DocumentMissing> }
|
|
87
|
+
>()('DocumentReader') {}
|
|
88
|
+
class DocumentWriter extends Context.Service<
|
|
89
|
+
DocumentWriter,
|
|
90
|
+
{
|
|
91
|
+
rename: (key: string, title: string, actor: string) => Effect.Effect<void, DocumentMissing>
|
|
92
|
+
audit: (actor: string, entry: AuditEntryInput) => Effect.Effect<void>
|
|
93
|
+
}
|
|
94
|
+
>()('DocumentWriter') {}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Cohesive domain declarations
|
|
98
|
+
|
|
99
|
+
`label` is an ordinary async handler, and `preview` uses a generator. `title` uses `Effect.fn` with explicit input
|
|
100
|
+
and host annotations. `rename` returns an `Effect.gen` program, yields the
|
|
101
|
+
query directly, then uses the injected writer. Its pipe converts the query's
|
|
102
|
+
missing-document error into a domain-specific rename rejection before adding
|
|
103
|
+
a span. The audit entry sits beside the command, and the shared audit policy
|
|
104
|
+
uses the same invocation's writer.
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
const foundation = createEffectFoundation({
|
|
108
|
+
observability: {
|
|
109
|
+
enabled: false,
|
|
110
|
+
classifyError: () => ({ outcome: 'failed' as const, errorCode: 'DOCUMENT_OPERATION_FAILED' }),
|
|
111
|
+
},
|
|
112
|
+
checkPermission: (host: Host) =>
|
|
113
|
+
host.actor.length > 0 ? Effect.void : Effect.fail(new AccessDenied()),
|
|
114
|
+
writeAudit: (host: Host, entry: AuditEntryInput) =>
|
|
115
|
+
Effect.gen(function* () {
|
|
116
|
+
const writer = yield* DocumentWriter
|
|
117
|
+
yield* writer.audit(host.actor, entry)
|
|
118
|
+
}),
|
|
119
|
+
})
|
|
120
|
+
const keyInput = z.object({ key: z.string().min(1) })
|
|
121
|
+
const renameInput = keyInput.extend({ title: z.string().trim().min(1) })
|
|
122
|
+
const titleResult = z.string().transform((title) => ({ title }))
|
|
123
|
+
|
|
124
|
+
export const documents = foundation.Query({
|
|
125
|
+
context: (host: Host) => host,
|
|
126
|
+
operations: ({ query }) => ({
|
|
127
|
+
label: query({
|
|
128
|
+
input: keyInput,
|
|
129
|
+
result: z.string(),
|
|
130
|
+
handler: async (input) => `Document ${input.key}`,
|
|
131
|
+
}),
|
|
132
|
+
preview: query({
|
|
133
|
+
input: keyInput,
|
|
134
|
+
result: titleResult,
|
|
135
|
+
permission: 'documents:read',
|
|
136
|
+
handler: (input, host) =>
|
|
137
|
+
Effect.gen(function* () {
|
|
138
|
+
const reader = yield* DocumentReader
|
|
139
|
+
return yield* reader.title(input.key, host.actor)
|
|
140
|
+
}),
|
|
141
|
+
}),
|
|
142
|
+
title: query({
|
|
143
|
+
input: keyInput,
|
|
144
|
+
result: titleResult,
|
|
145
|
+
permission: 'documents:read',
|
|
146
|
+
handler: Effect.fn('documents.title')(function* (
|
|
147
|
+
input: z.output<typeof keyInput>,
|
|
148
|
+
host: Host,
|
|
149
|
+
) {
|
|
150
|
+
const reader = yield* DocumentReader
|
|
151
|
+
return yield* reader.title(input.key, host.actor)
|
|
152
|
+
}),
|
|
153
|
+
}),
|
|
154
|
+
}),
|
|
155
|
+
})
|
|
156
|
+
export const documentCommands = foundation.Command({
|
|
157
|
+
context: (host: Host) => host,
|
|
158
|
+
operations: ({ command }) => ({
|
|
159
|
+
rename: command({
|
|
160
|
+
input: renameInput,
|
|
161
|
+
result: titleResult,
|
|
162
|
+
classification: 'business',
|
|
163
|
+
permission: 'documents:write',
|
|
164
|
+
handler: (input, host) =>
|
|
165
|
+
Effect.gen(function* () {
|
|
166
|
+
// A nested query stays in the same Effect; no inner runner.
|
|
167
|
+
yield* documents.exec('title', { key: input.key }, host)
|
|
168
|
+
const writer = yield* DocumentWriter
|
|
169
|
+
yield* writer.rename(input.key, input.title, host.actor)
|
|
170
|
+
return input.title
|
|
171
|
+
}).pipe(
|
|
172
|
+
Effect.catchTag('DocumentMissing', () => Effect.fail(new RenameRejected())),
|
|
173
|
+
Effect.withSpan('documents.rename'),
|
|
174
|
+
),
|
|
175
|
+
guard: (host) => (host.actor.length > 0 ? Effect.void : Effect.fail(new AccessDenied())),
|
|
176
|
+
middleware: ({ next }) => next().pipe(Effect.withSpan('documents.command')),
|
|
177
|
+
audit: ({ command: input, result }, host) => ({
|
|
178
|
+
actorId: host.actor,
|
|
179
|
+
operation: 'documents.rename',
|
|
180
|
+
aggregate: { type: 'document', id: input.key },
|
|
181
|
+
metadata: { title: result.title },
|
|
182
|
+
}),
|
|
183
|
+
}),
|
|
184
|
+
}),
|
|
185
|
+
})
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Request services and shared API setup
|
|
189
|
+
|
|
190
|
+
Construct the services once in each shared builder provider. Endpoint handlers
|
|
191
|
+
do not call `Context.make`, `Effect.runPromise`, or an extra execution helper.
|
|
192
|
+
The database Promise wrappers below intentionally preserve unexpected database
|
|
193
|
+
failures as defects. For external integrations with expected failures, use
|
|
194
|
+
`Effect.tryPromise` and return your own typed error.
|
|
195
|
+
|
|
196
|
+
`mapError` projects an expected failure to a small `ConvexError` payload. It
|
|
197
|
+
receives `unknown`, so narrow it before projection. Defects, interruption,
|
|
198
|
+
and combined causes do not become expected failures through this hook; the
|
|
199
|
+
adapter preserves those failure causes. The registered function still fails,
|
|
200
|
+
which is required for mutation rollback.
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
function readerFor(ctx: QueryCtx): Context.Context<DocumentReader> {
|
|
204
|
+
return Context.make(DocumentReader, {
|
|
205
|
+
title: (key, actor) =>
|
|
206
|
+
Effect.gen(function* () {
|
|
207
|
+
const document = yield* Effect.promise(() =>
|
|
208
|
+
ctx.db
|
|
209
|
+
.query('documents')
|
|
210
|
+
.withIndex('by_owner_key', (q) => q.eq('owner', actor).eq('key', key))
|
|
211
|
+
.unique(),
|
|
212
|
+
)
|
|
213
|
+
if (!document) return yield* Effect.fail(new DocumentMissing())
|
|
214
|
+
return document.title
|
|
215
|
+
}),
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
function writerFor(ctx: MutationCtx): Context.Context<DocumentWriter> {
|
|
219
|
+
return Context.make(DocumentWriter, {
|
|
220
|
+
rename: (key, title, actor) =>
|
|
221
|
+
Effect.gen(function* () {
|
|
222
|
+
const document = yield* Effect.promise(() =>
|
|
223
|
+
ctx.db
|
|
224
|
+
.query('documents')
|
|
225
|
+
.withIndex('by_owner_key', (q) => q.eq('owner', actor).eq('key', key))
|
|
226
|
+
.unique(),
|
|
227
|
+
)
|
|
228
|
+
if (!document) return yield* Effect.fail(new DocumentMissing())
|
|
229
|
+
yield* Effect.promise(() => ctx.db.patch(document._id, { title }))
|
|
230
|
+
}),
|
|
231
|
+
audit: (actor, entry) =>
|
|
232
|
+
Effect.promise(() =>
|
|
233
|
+
ctx.db.insert('audits', {
|
|
234
|
+
actor,
|
|
235
|
+
operation: entry.operation,
|
|
236
|
+
documentKey: entry.aggregate.id,
|
|
237
|
+
}),
|
|
238
|
+
).pipe(Effect.asVoid),
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
function authenticatedHost(ctx: QueryCtx) {
|
|
242
|
+
return Effect.gen(function* () {
|
|
243
|
+
const identity = yield* Effect.promise(() => ctx.auth.getUserIdentity())
|
|
244
|
+
if (!identity) return yield* Effect.fail(new AccessDenied())
|
|
245
|
+
return { actor: identity.tokenIdentifier }
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
function projectExpectedError(error: unknown) {
|
|
249
|
+
if (error instanceof DocumentMissing || error instanceof RenameRejected)
|
|
250
|
+
return new ConvexError({ code: 'DOCUMENT_NOT_FOUND' })
|
|
251
|
+
if (error instanceof AccessDenied) return new ConvexError({ code: 'ACCESS_DENIED' })
|
|
252
|
+
return new Error('Document operation failed', { cause: error })
|
|
253
|
+
}
|
|
254
|
+
const queryApi = effectApiBuilder(nativeQuery, {
|
|
255
|
+
services: readerFor,
|
|
256
|
+
mapError: projectExpectedError,
|
|
257
|
+
})
|
|
258
|
+
const mutationApi = effectApiBuilder(nativeMutation, {
|
|
259
|
+
services: (ctx) => Context.merge(readerFor(ctx), writerFor(ctx)),
|
|
260
|
+
mapError: projectExpectedError,
|
|
261
|
+
})
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Consume the domain operations in the API
|
|
265
|
+
|
|
266
|
+
The Convex callback remains `(ctx, input)`; the domain callback and executor
|
|
267
|
+
use input before context/host. Both endpoints are normal native registrations
|
|
268
|
+
with argument and return validators. Each handler authenticates the request
|
|
269
|
+
and returns the domain Effect. The adapter supplies the services required by
|
|
270
|
+
the complete nested program.
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
export const documentTitle = queryApi({
|
|
274
|
+
args: { key: v.string() },
|
|
275
|
+
returns: v.object({ title: v.string() }),
|
|
276
|
+
handler: (ctx, input) =>
|
|
277
|
+
Effect.gen(function* () {
|
|
278
|
+
const host = yield* authenticatedHost(ctx)
|
|
279
|
+
return yield* documents.exec('title', input, host)
|
|
280
|
+
}),
|
|
281
|
+
})
|
|
282
|
+
export const renameDocument = mutationApi({
|
|
283
|
+
args: { key: v.string(), title: v.string() },
|
|
284
|
+
returns: v.object({ title: v.string() }),
|
|
285
|
+
handler: (ctx, input) =>
|
|
286
|
+
Effect.gen(function* () {
|
|
287
|
+
const host = yield* authenticatedHost(ctx)
|
|
288
|
+
return yield* documentCommands.exec('rename', input, host)
|
|
289
|
+
}),
|
|
290
|
+
})
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## Types and schema boundaries
|
|
294
|
+
|
|
295
|
+
The command accepts `z.input<typeof renameInput>`. Its handler sees
|
|
296
|
+
`z.output<typeof renameInput>`, so the title has already been trimmed. The
|
|
297
|
+
handler returns a string, which is the input of `titleResult`; the registry's
|
|
298
|
+
final result is `{ title: string }`, the output of that transform. Middleware
|
|
299
|
+
`next()` returns the handler result before the final result parser, so wrap
|
|
300
|
+
or transform that value according to the result schema's input contract.
|
|
301
|
+
|
|
302
|
+
TypeScript preserves the selected operation's success, expected error, and
|
|
303
|
+
service requirements. The title query requires `DocumentReader`; rename
|
|
304
|
+
requires `DocumentReader | DocumentWriter` and can fail with `AccessDenied`
|
|
305
|
+
or `RenameRejected`. The API builder checks that its provider supplies those
|
|
306
|
+
services. A reader-only provider cannot register rename, and an incompatible
|
|
307
|
+
handler result cannot pass its declared native return validator.
|
|
308
|
+
|
|
309
|
+
This is server-side Effect composition. Generated Convex references retain
|
|
310
|
+
native argument/result types and visibility; they do not carry Effect's typed
|
|
311
|
+
error or service channels over the transport. Clients receive ordinary Convex
|
|
312
|
+
results or errors.
|
|
313
|
+
|
|
314
|
+
For inferred declarations, put `handler` and `guard` before `middleware`, as
|
|
315
|
+
above. Middleware-first declarations need explicit callback and error/service
|
|
316
|
+
annotations where inference cannot recover the later handler's channels.
|
|
317
|
+
`Effect.fn` accepts typed function arguments; `Effect.gen` accepts a generator
|
|
318
|
+
whose yielded Effects determine its channels. Use `(input, context) =>
|
|
319
|
+
Effect.gen(function* () { ... })` when those values come from the declaration.
|
|
320
|
+
A constructed `Effect.gen` program cannot itself receive handler arguments.
|
|
321
|
+
|
|
322
|
+
## Hooks, replay, and execution scope
|
|
323
|
+
|
|
324
|
+
A command runs input parsing, observation setup and context resolution, then
|
|
325
|
+
permission checking, preparation, middleware, shared and operation guards,
|
|
326
|
+
handler, final result parsing, audit construction, aggregate checking, audit
|
|
327
|
+
writing, and completion. Observation records the outcome. Query permission
|
|
328
|
+
checking runs before its context resolver, followed by middleware, guards,
|
|
329
|
+
handler, and result parsing. Command audit/completion hooks do not exist on
|
|
330
|
+
queries.
|
|
331
|
+
|
|
332
|
+
Optional command `prepare(context, input)` returns `{ kind: 'execute',
|
|
333
|
+
complete }` or `{ kind: 'replay', result }`. The completion callback receives
|
|
334
|
+
the parsed final result. Permission checking still precedes replay. A replay
|
|
335
|
+
result is parsed with `replayResult` when supplied, otherwise with `result`,
|
|
336
|
+
and skips middleware, guards, handler, audit writing, and completion.
|
|
337
|
+
Middleware receives `operation`, parsed `input` (also `command`), `context`,
|
|
338
|
+
and `next`; it may provide an enriched context to `next({ context })`.
|
|
339
|
+
Each execution allows `next` once. Audit construction sees the original domain
|
|
340
|
+
context, and the audit writer sees the original trusted host.
|
|
341
|
+
|
|
342
|
+
The provider may return a `Context`, a Promise of a Context, or an Effect that
|
|
343
|
+
builds a Context. It runs separately for every native invocation. The shared
|
|
344
|
+
runner opens an Effect scope, evaluates the provider and handler, and awaits
|
|
345
|
+
scope finalizers before returning or throwing. Use `Effect.acquireRelease`
|
|
346
|
+
inside a provider for resources that actually need cleanup. Nested domain
|
|
347
|
+
operations share that runner and scope; do not run them independently inside
|
|
348
|
+
handlers. Cleanup failure remains an endpoint failure, including when it
|
|
349
|
+
combines with a handler failure.
|
|
350
|
+
|
|
351
|
+
## Convex boundaries and observed support
|
|
352
|
+
|
|
353
|
+
A domain query is a reusable read operation; its name does not choose the
|
|
354
|
+
native function kind. A native Convex query must perform deterministic database
|
|
355
|
+
reads. Native mutations can write, and a failed command, result validator,
|
|
356
|
+
audit writer, completion hook, or awaited cleanup rolls back that mutation's
|
|
357
|
+
writes. Actions are the place for HTTP and other external integrations.
|
|
358
|
+
An action's separate `ctx.runMutation` calls are separate transactions: a
|
|
359
|
+
later failure does not undo an earlier successful mutation.
|
|
360
|
+
|
|
361
|
+
The packed public facade was compiled and executed under npm and Bun with
|
|
362
|
+
Effect 4.0.1 and `convex-helpers` 0.1.123. Legacy packed consumers also ran with
|
|
363
|
+
Effect absent. Local Convex 1.45.0 checks exercised ordinary, generator, and
|
|
364
|
+
named-function query composition; request cleanup; expected and combined
|
|
365
|
+
failures; mutation validation and lifecycle rollback; and an action using
|
|
366
|
+
loopback HTTP with separate mutation transactions. `Effect.withSpan` was
|
|
367
|
+
accepted in those programs; this does not establish exported telemetry,
|
|
368
|
+
background fibers, arbitrary concurrency, or every Effect runtime capability
|
|
369
|
+
in Convex.
|
|
370
|
+
|
|
371
|
+
The standalone packed compiler includes `ESNext.Disposable` in its library
|
|
372
|
+
set for Effect 4 declarations. If your project's TypeScript environment does
|
|
373
|
+
not already provide those globals, include that library as well.
|
|
374
|
+
|
|
375
|
+
## Adopt one domain at a time
|
|
376
|
+
|
|
377
|
+
Keep existing Promise registries and API endpoints while adding the optional
|
|
378
|
+
facade for a new domain. Move each operation's handler beside its schemas and
|
|
379
|
+
hooks, switch domain callbacks to input-first arguments, and return the new
|
|
380
|
+
registry's lazy Effect to a shared API adapter. A normal async handler works
|
|
381
|
+
in the cohesive declaration without a generator; wrap expected Promise failures
|
|
382
|
+
only when you want a typed error channel.
|
|
383
|
+
|
|
384
|
+
The [command guide](./commands.md) documents the current lifecycle and migration
|
|
385
|
+
steps; the [deprecated API reference](./commands-legacy.md) retains old examples.
|
|
386
|
+
The [authentication guide](./auth.md)
|
|
387
|
+
shows how to wrap trusted custom builders with `effectZodApiBuilder`, so auth,
|
|
388
|
+
tenancy, validators, row-level policies, and triggers run through their
|
|
389
|
+
existing builder before Effect request services are provisioned.
|
|
390
|
+
|
|
391
|
+
Optional request context can be added through the existing Effect API adapter's
|
|
392
|
+
`context` callback. Bind selected registries with `commands.withContext(value)`
|
|
393
|
+
and consume `ctx.commands.exec(name, input)` through the application's existing
|
|
394
|
+
function helper names. Keep dependencies local to each feature; explicit execution
|
|
395
|
+
and input-only command handlers remain supported. See [optional context injection](./command-context.md).
|
package/docs/llms.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> Paste this file (installed at `node_modules/cvx-kit/docs/`) into an agent's
|
|
4
4
|
> context when it works on a cvx-kit application. Detailed docs:
|
|
5
5
|
> `architecture.md`, `conventions.md`, `zod-table.md`, `auth.md`,
|
|
6
|
-
> `commands.md`, `triggers.md`, `approvals.md`, `maintainability.md`,
|
|
6
|
+
> `commands.md`, `effect.md`, `domain-operations.md`, `triggers.md`, `approvals.md`, `maintainability.md`,
|
|
7
7
|
> `migration.md` (same directory).
|
|
8
8
|
> Structure, file anatomy, and naming rules → `conventions.md` is
|
|
9
9
|
> authoritative. Restructuring an existing raw project → `migration.md`.
|
|
@@ -22,7 +22,13 @@
|
|
|
22
22
|
cvx-kit is a reusable Convex application kit: zod table boundaries, auth-aware
|
|
23
23
|
function constructors, a trigger registry, an audited command protocol
|
|
24
24
|
(Foundation component), and a declarative approvals component. Peer deps:
|
|
25
|
-
`convex ^1.
|
|
25
|
+
`convex ^1.45.0`, `zod ^4.5.4`; command implementations also install
|
|
26
|
+
`effect >=4.0.1 <5` and import `cvx-kit/effect`.
|
|
27
|
+
|
|
28
|
+
**Command API selection:** use `createEffectFoundation` and declare handlers
|
|
29
|
+
inside `command({...})`. The older `Foundation.Command` Promise facade is
|
|
30
|
+
deprecated. Its compatibility examples live in `commands-legacy.md`; do not
|
|
31
|
+
copy that API into new implementations.
|
|
26
32
|
|
|
27
33
|
## Non-negotiable rules
|
|
28
34
|
|
|
@@ -31,27 +37,27 @@ function constructors, a trigger registry, an audited command protocol
|
|
|
31
37
|
2. **Raw `query`/`mutation`/`action`/`internal*` builders appear only in
|
|
32
38
|
`convex/functions.ts`** (the `createAuthFunctions` call). All other
|
|
33
39
|
functions use `authQuery/authMutation/authAction`, `roleQuery/...`,
|
|
34
|
-
`adminQuery/...` (public) or `systemQuery/systemMutation/systemAction` (internal)
|
|
40
|
+
`adminQuery/...` (public) or `systemQuery/systemMutation/systemAction` (internal),
|
|
41
|
+
wrapped once with `effectZodApiBuilder` when their handlers return Effects.
|
|
35
42
|
This is what guarantees auth, triggers, and bounded reads.
|
|
36
43
|
3. **Every public query returns DTOs** via `<table>.toPublicDto(row)` —
|
|
37
44
|
runtime redaction, not just types.
|
|
38
45
|
4. **Every read is bounded**: `ctx.include(ctx.db.query('t')).matching(...)
|
|
39
46
|
.execute(limit)` with `1 ≤ limit ≤ 100`. `.resolve()` falls back to a full
|
|
40
47
|
table scan — avoid it.
|
|
41
|
-
5. **State changes are commands**:
|
|
42
|
-
`
|
|
43
|
-
`
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
→ operation guard → handler → result parse → aggregates → audit.
|
|
48
|
+
5. **State changes are Effect commands**: declare
|
|
49
|
+
`Command({ context, operations: ({ command }) => ({ ... }), defaults })`.
|
|
50
|
+
Each `command({ input, result, classification, handler, audit })` owns its
|
|
51
|
+
handler; handlers receive `(input, context)`. Execute with
|
|
52
|
+
`commands.exec(operation, input, host)` and return the lazy Effect through a
|
|
53
|
+
shared API builder. Never pass a handler to `exec` or run a nested
|
|
54
|
+
`Effect.runPromise`. Permission uses the injected `checkPermission(host, ...)`;
|
|
55
|
+
guards receive `(context, input)`. Per-operation middleware is one callback,
|
|
56
|
+
`next()` returns an Effect, and `next({ context })` enriches downstream ctx.
|
|
57
|
+
Order: input → context → permission → prepare/replay → middleware → default
|
|
58
|
+
guard → operation guard → handler → result parse → aggregates/audit → completion.
|
|
59
|
+
Audit writes stay in the mutation transaction. Failures must escape the
|
|
60
|
+
mutation for rollback; replay skips middleware, guards, handler, and audit.
|
|
55
61
|
6. **Never write timestamps by hand.** `createdAt`/`updatedAt` are maintained
|
|
56
62
|
by the `timestamps` trigger; `archivedAt` is the app-controlled soft-delete
|
|
57
63
|
marker.
|
|
@@ -74,18 +80,19 @@ function constructors, a trigger registry, an audited command protocol
|
|
|
74
80
|
|
|
75
81
|
| Import | Provides |
|
|
76
82
|
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
77
|
-
| `cvx-kit` |
|
|
83
|
+
| `cvx-kit` | Non-Effect helpers and component clients; import the optional Effect facade explicitly from `cvx-kit/effect`. |
|
|
78
84
|
| `cvx-kit/zod-table` | `zodTable`, `tenantTable`, `createModule`, `paginated`, `zodVariantTable`, `jsonSafeZid`, `TIMESTAMP_FIELDS` |
|
|
85
|
+
| `cvx-kit/effect` | Recommended command/query API: `createEffectFoundation`, `effectApiBuilder`, `effectZodApiBuilder`, `createEffectCrud`, idempotency/workflow adapters and operation tools. |
|
|
79
86
|
| `cvx-kit/auth` | `createAuthFunctions` (incl. optional `security` RLS config), `createInclude`, `defaultRoleMap` |
|
|
80
87
|
| `cvx-kit/tenancy` | `createTenantRules`, `composeRules`, `requireTenantReference`, `assertTenantOwned`, `TENANT_FIELD` |
|
|
81
|
-
| `cvx-kit/crud` |
|
|
88
|
+
| `cvx-kit/crud` | Compatibility CRUD factory using the deprecated Promise command facade; use `createEffectCrud` from `cvx-kit/effect` for new domains. |
|
|
82
89
|
| `cvx-kit/state-machine` | `createStateMachine` — typed transitions from constants tuples; `assert` drops into command guards |
|
|
83
90
|
| `cvx-kit/middleware` | `rateLimit` — packaged middleware over an injected rate-limiter instance; keyed by `ctx.tenant`, missing key = config error |
|
|
84
91
|
| `cvx-kit/webhooks` | `createWebhookBoundary`, `recordWebhookEvent`, `webhookEventsTable` — raw-body verify, natural-key dedup in the mutation |
|
|
85
92
|
| `cvx-kit/agent-tools` | `createAgentTools` — tool records from table masks; mutation handlers route through command executors |
|
|
86
93
|
| `cvx-kit/triggers` | `createTriggers`, `timestamps`, `appendOnly`, `noDelete`, `tenantOwnership`, `Triggers` |
|
|
87
94
|
| `cvx-kit/errors` | `KitError`, `defaultErrors`, `ErrorFactory` |
|
|
88
|
-
| `cvx-kit/components/foundation` |
|
|
95
|
+
| `cvx-kit/components/foundation` | Component client and types; `Foundation.Command` is deprecated. The component and other capabilities remain available. New command registries use `cvx-kit/effect`. |
|
|
89
96
|
| `cvx-kit/components/approvals` | `Approvals` client; default export = component config for `app.use` |
|
|
90
97
|
| `cvx-kit/test` | `registerFoundation(t)`, `registerApprovals(t)` for convex-test |
|
|
91
98
|
|
|
@@ -110,7 +117,7 @@ appendOnly(triggers, 'history')
|
|
|
110
117
|
// convex/functions.ts
|
|
111
118
|
export const {
|
|
112
119
|
authQuery,
|
|
113
|
-
authMutation,
|
|
120
|
+
authMutation: baseAuthMutation,
|
|
114
121
|
authAction,
|
|
115
122
|
adminQuery,
|
|
116
123
|
adminMutation,
|
|
@@ -141,13 +148,23 @@ export const {
|
|
|
141
148
|
},
|
|
142
149
|
})
|
|
143
150
|
|
|
151
|
+
// convex/functions.ts — wrap configured auth constructors once, after auth/RLS/triggers
|
|
152
|
+
// Import Context from effect and effectZodApiBuilder from cvx-kit/effect.
|
|
153
|
+
// Place this after the registry declaration or import the registry from its module.
|
|
154
|
+
export const authMutation = effectZodApiBuilder(baseAuthMutation, {
|
|
155
|
+
context: (ctx) => ({ commands: commands.withContext(ctx) }),
|
|
156
|
+
})
|
|
157
|
+
|
|
144
158
|
// convex/foundation.ts
|
|
145
|
-
|
|
159
|
+
// Import createEffectFoundation from cvx-kit/effect.
|
|
160
|
+
import type { MutationCtx } from './_generated/server'
|
|
161
|
+
export type CommandHost = MutationCtx & { actor: { userId: string } }
|
|
162
|
+
export const { Command, Query, observability } = createEffectFoundation({
|
|
146
163
|
observability: {
|
|
147
164
|
enabled: () => process.env.OBS === 'true',
|
|
148
165
|
classifyError, // → { outcome: 'denied'|'failed', errorCode }
|
|
149
|
-
writeAudit: (ctx, entry) => writeAuditEntry(ctx, entry),
|
|
150
166
|
},
|
|
167
|
+
writeAudit: (ctx: CommandHost, entry) => writeAuditEntry(ctx, entry),
|
|
151
168
|
})
|
|
152
169
|
|
|
153
170
|
// convex/approvals.ts
|
|
@@ -175,32 +192,33 @@ export const documents = zodTable(
|
|
|
175
192
|
defineSchema({ documents: documents.table.index('by_owner', ['ownerId']) })
|
|
176
193
|
|
|
177
194
|
// domain/documents/commands.ts
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
195
|
+
export const renameInput = documents.commandInput.extend({ id: zid('documents') })
|
|
196
|
+
export const renameResult = z.object({ ok: z.literal(true) }).strict()
|
|
197
|
+
export const commands = Command({
|
|
198
|
+
context: (host: CommandHost) => host,
|
|
199
|
+
operations: ({ command }) => ({
|
|
200
|
+
'documents.rename': command({
|
|
201
|
+
input: renameInput,
|
|
202
|
+
result: renameResult,
|
|
203
|
+
classification: 'business',
|
|
204
|
+
handler: async (input, ctx) => {
|
|
205
|
+
await ctx.db.patch(input.id, { title: input.title })
|
|
206
|
+
return { ok: true as const }
|
|
207
|
+
},
|
|
208
|
+
audit: ({ command }, ctx) => ({
|
|
209
|
+
operation: 'documents.rename',
|
|
210
|
+
actorId: ctx.actor.userId,
|
|
211
|
+
aggregate: { type: 'document', id: command.id },
|
|
212
|
+
}),
|
|
187
213
|
}),
|
|
188
214
|
}),
|
|
189
|
-
} as const
|
|
190
|
-
const commands = new Command<MutationCtx, typeof operations>(operations)
|
|
191
|
-
export const executeRename = commands.exec({
|
|
192
|
-
operation: 'documents.rename',
|
|
193
|
-
handler: async (ctx, cmd) => {
|
|
194
|
-
await ctx.db.patch(cmd.id, { title: cmd.title })
|
|
195
|
-
return { ok: true }
|
|
196
|
-
},
|
|
197
215
|
})
|
|
198
216
|
|
|
199
217
|
// api/documents.ts — thin public adapter
|
|
200
218
|
export const rename = authMutation({
|
|
201
|
-
args:
|
|
202
|
-
returns:
|
|
203
|
-
handler: (ctx,
|
|
219
|
+
args: renameInput,
|
|
220
|
+
returns: renameResult,
|
|
221
|
+
handler: (ctx, input) => ctx.commands.exec('documents.rename', input),
|
|
204
222
|
})
|
|
205
223
|
export const list = authQuery({
|
|
206
224
|
args: { limit: z.number() },
|
|
@@ -261,9 +279,15 @@ points at `runId`.
|
|
|
261
279
|
4. Mis-declared `serverFields` silently make server-owned fields
|
|
262
280
|
client-writable; masks are the security boundary.
|
|
263
281
|
5. `include(...).resolve()` silently falls back to `fullTableScan()`.
|
|
264
|
-
6.
|
|
265
|
-
|
|
282
|
+
6. Command audit actors come from trusted host context, never client input.
|
|
283
|
+
Commands with writes must run through mutation builders with wrapped `ctx.db`.
|
|
266
284
|
7. Changing approval steps without bumping `compatibilityKey` lets old runs
|
|
267
285
|
be driven by an incompatible shape.
|
|
268
286
|
8. When composing ctx manually, spread `wrapDB(ctx)` first — later spreads
|
|
269
287
|
carrying `db` restore the unwrapped database.
|
|
288
|
+
|
|
289
|
+
Optional request context can be added through the existing Effect API adapter's
|
|
290
|
+
`context` callback. Bind selected registries with `commands.withContext(value)`
|
|
291
|
+
and consume `ctx.commands.exec(name, input)` through the application's existing
|
|
292
|
+
function helper names. Keep dependencies local to each feature; explicit execution
|
|
293
|
+
and input-only command handlers remain supported. See [optional context injection](./command-context.md).
|
package/docs/migration.md
CHANGED
|
@@ -25,7 +25,7 @@ convex/
|
|
|
25
25
|
schema.ts # defineSchema(domainTables). Nothing else.
|
|
26
26
|
functions.ts # the single createAuthFunctions(...) call
|
|
27
27
|
triggers.ts # the single trigger registry + helper registrations
|
|
28
|
-
foundation.ts # the single
|
|
28
|
+
foundation.ts # the single createEffectFoundation(...) facade
|
|
29
29
|
<component>.ts # one facade per mounted component (audit.ts, approvals.ts, pool.ts, workflows.ts, ...)
|
|
30
30
|
auth.ts # identity-provider client wiring
|
|
31
31
|
auth.config.ts # Convex auth providers config
|
package/docs/upgrading.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Upgrading to 0.1.0 — the new way of things
|
|
2
2
|
|
|
3
|
+
## Current command API — Effect declarations
|
|
4
|
+
|
|
5
|
+
`Foundation.Command` and its separate `exec({ operation, handler })` API are
|
|
6
|
+
deprecated. For new command code, use `createEffectFoundation` from
|
|
7
|
+
`cvx-kit/effect`, declare handlers inside `command({...})`, and return
|
|
8
|
+
`exec(operation, input, host)` through a shared Effect API builder.
|
|
9
|
+
See [commands.md](./commands.md) for migration steps. Existing consumers remain
|
|
10
|
+
compatible; older release sections below describe their historical APIs.
|
|
11
|
+
|
|
3
12
|
## 0.1.6 — stricter architecture checks
|
|
4
13
|
|
|
5
14
|
Enable `project-structure`, `root-wiring-only`, `application-orchestration`,
|