cvx-kit 0.0.1
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/LICENSE +21 -0
- package/README.md +200 -0
- package/dist/_virtual/_rolldown/runtime.mjs +1 -0
- package/dist/auth.d.mts +353 -0
- package/dist/auth.mjs +2 -0
- package/dist/auth.mjs.map +1 -0
- package/dist/components/approvals/_generated/api.d.mts +47 -0
- package/dist/components/approvals/_generated/api.mjs +2 -0
- package/dist/components/approvals/_generated/api.mjs.map +1 -0
- package/dist/components/approvals/_generated/component.d.mts +297 -0
- package/dist/components/approvals/_generated/component.mjs +1 -0
- package/dist/components/approvals/_generated/dataModel.d.mts +41 -0
- package/dist/components/approvals/_generated/dataModel.mjs +1 -0
- package/dist/components/approvals/_generated/server.d.mts +116 -0
- package/dist/components/approvals/_generated/server.mjs +2 -0
- package/dist/components/approvals/_generated/server.mjs.map +1 -0
- package/dist/components/approvals/audit.d.mts +40 -0
- package/dist/components/approvals/audit.mjs +2 -0
- package/dist/components/approvals/audit.mjs.map +1 -0
- package/dist/components/approvals/client.d.mts +98 -0
- package/dist/components/approvals/client.mjs +2 -0
- package/dist/components/approvals/client.mjs.map +1 -0
- package/dist/components/approvals/constants.d.mts +31 -0
- package/dist/components/approvals/constants.mjs +2 -0
- package/dist/components/approvals/constants.mjs.map +1 -0
- package/dist/components/approvals/convex.config.d.mts +5 -0
- package/dist/components/approvals/convex.config.mjs +2 -0
- package/dist/components/approvals/convex.config.mjs.map +1 -0
- package/dist/components/approvals/decisions.d.mts +61 -0
- package/dist/components/approvals/decisions.mjs +2 -0
- package/dist/components/approvals/decisions.mjs.map +1 -0
- package/dist/components/approvals/functions.d.mts +9 -0
- package/dist/components/approvals/functions.mjs +2 -0
- package/dist/components/approvals/functions.mjs.map +1 -0
- package/dist/components/approvals/requests.d.mts +323 -0
- package/dist/components/approvals/requests.mjs +2 -0
- package/dist/components/approvals/requests.mjs.map +1 -0
- package/dist/components/approvals/schema.d.mts +376 -0
- package/dist/components/approvals/schema.mjs +2 -0
- package/dist/components/approvals/schema.mjs.map +1 -0
- package/dist/components/approvals/validators.d.mts +302 -0
- package/dist/components/approvals/validators.mjs +2 -0
- package/dist/components/approvals/validators.mjs.map +1 -0
- package/dist/components/approvals/workflow.d.mts +11 -0
- package/dist/components/approvals/workflow.mjs +2 -0
- package/dist/components/approvals/workflow.mjs.map +1 -0
- package/dist/components/approvals/workflow_steps.d.mts +83 -0
- package/dist/components/approvals/workflow_steps.mjs +2 -0
- package/dist/components/approvals/workflow_steps.mjs.map +1 -0
- package/dist/components/foundation/_generated/api.d.mts +40 -0
- package/dist/components/foundation/_generated/api.mjs +2 -0
- package/dist/components/foundation/_generated/api.mjs.map +1 -0
- package/dist/components/foundation/_generated/component.d.mts +21 -0
- package/dist/components/foundation/_generated/component.mjs +1 -0
- package/dist/components/foundation/_generated/dataModel.d.mts +41 -0
- package/dist/components/foundation/_generated/dataModel.mjs +1 -0
- package/dist/components/foundation/_generated/server.d.mts +116 -0
- package/dist/components/foundation/_generated/server.mjs +2 -0
- package/dist/components/foundation/_generated/server.mjs.map +1 -0
- package/dist/components/foundation/client.d.mts +93 -0
- package/dist/components/foundation/client.mjs +2 -0
- package/dist/components/foundation/client.mjs.map +1 -0
- package/dist/components/foundation/convex.config.d.mts +5 -0
- package/dist/components/foundation/convex.config.mjs +2 -0
- package/dist/components/foundation/convex.config.mjs.map +1 -0
- package/dist/components/foundation/functions.d.mts +8 -0
- package/dist/components/foundation/functions.mjs +2 -0
- package/dist/components/foundation/functions.mjs.map +1 -0
- package/dist/components/foundation/modules/command/command.d.mts +47 -0
- package/dist/components/foundation/modules/command/command.mjs +2 -0
- package/dist/components/foundation/modules/command/command.mjs.map +1 -0
- package/dist/components/foundation/modules/observability/observability.d.mts +31 -0
- package/dist/components/foundation/modules/observability/observability.mjs +2 -0
- package/dist/components/foundation/modules/observability/observability.mjs.map +1 -0
- package/dist/components/foundation/modules/query/query.d.mts +24 -0
- package/dist/components/foundation/modules/query/query.mjs +2 -0
- package/dist/components/foundation/modules/query/query.mjs.map +1 -0
- package/dist/components/foundation/result.d.mts +27 -0
- package/dist/components/foundation/result.mjs +2 -0
- package/dist/components/foundation/result.mjs.map +1 -0
- package/dist/components/foundation/schema.d.mts +6 -0
- package/dist/components/foundation/schema.mjs +2 -0
- package/dist/components/foundation/schema.mjs.map +1 -0
- package/dist/components/foundation/telemetry.d.mts +9 -0
- package/dist/components/foundation/telemetry.mjs +2 -0
- package/dist/components/foundation/telemetry.mjs.map +1 -0
- package/dist/errors.d.mts +24 -0
- package/dist/errors.mjs +2 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/index.d.mts +6 -0
- package/dist/index.mjs +1 -0
- package/dist/triggers.d.mts +27 -0
- package/dist/triggers.mjs +2 -0
- package/dist/triggers.mjs.map +1 -0
- package/dist/zod-table.d.mts +124 -0
- package/dist/zod-table.mjs +2 -0
- package/dist/zod-table.mjs.map +1 -0
- package/package.json +108 -0
- package/src/auth.ts +360 -0
- package/src/components/approvals/_generated/api.ts +69 -0
- package/src/components/approvals/_generated/component.ts +335 -0
- package/src/components/approvals/_generated/dataModel.ts +60 -0
- package/src/components/approvals/_generated/server.ts +156 -0
- package/src/components/approvals/audit.ts +148 -0
- package/src/components/approvals/client.ts +321 -0
- package/src/components/approvals/constants.ts +51 -0
- package/src/components/approvals/convex.config.ts +10 -0
- package/src/components/approvals/decisions.ts +376 -0
- package/src/components/approvals/functions.ts +27 -0
- package/src/components/approvals/requests.ts +240 -0
- package/src/components/approvals/schema.ts +23 -0
- package/src/components/approvals/validators.ts +272 -0
- package/src/components/approvals/workflow.ts +204 -0
- package/src/components/approvals/workflow_steps.ts +189 -0
- package/src/components/foundation/_generated/api.ts +62 -0
- package/src/components/foundation/_generated/component.ts +29 -0
- package/src/components/foundation/_generated/dataModel.ts +60 -0
- package/src/components/foundation/_generated/server.ts +156 -0
- package/src/components/foundation/client.ts +190 -0
- package/src/components/foundation/convex.config.ts +3 -0
- package/src/components/foundation/functions.ts +10 -0
- package/src/components/foundation/modules/command/__tests__/command.test.ts +106 -0
- package/src/components/foundation/modules/command/command.ts +154 -0
- package/src/components/foundation/modules/observability/__tests__/observability.test.ts +79 -0
- package/src/components/foundation/modules/observability/observability.ts +95 -0
- package/src/components/foundation/modules/query/__tests__/query.test.ts +25 -0
- package/src/components/foundation/modules/query/query.ts +47 -0
- package/src/components/foundation/result.ts +77 -0
- package/src/components/foundation/schema.ts +4 -0
- package/src/components/foundation/telemetry.ts +23 -0
- package/src/errors.ts +25 -0
- package/src/index.ts +41 -0
- package/src/test.ts +36 -0
- package/src/triggers.ts +90 -0
- package/src/zod-table.ts +197 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { WorkflowManager, type WorkflowId } from '@convex-dev/workflow'
|
|
2
|
+
import { zodToConvex } from 'convex-helpers/server/zod4'
|
|
3
|
+
import { v } from 'convex/values'
|
|
4
|
+
|
|
5
|
+
import { components, internal } from './_generated/api'
|
|
6
|
+
import {
|
|
7
|
+
internalMutation,
|
|
8
|
+
internalQuery,
|
|
9
|
+
mutation,
|
|
10
|
+
query,
|
|
11
|
+
type QueryCtx,
|
|
12
|
+
} from './_generated/server'
|
|
13
|
+
import { logApprovalTransition } from './audit'
|
|
14
|
+
import {
|
|
15
|
+
APPROVAL_DECISIONS,
|
|
16
|
+
APPROVAL_RUN_STATES,
|
|
17
|
+
type ApprovalRunState,
|
|
18
|
+
} from './constants'
|
|
19
|
+
import { canTransitionApprovalRun, classifyPendingRunAt } from './functions'
|
|
20
|
+
import { approvalActor, approvalDecision, approvalReason } from './validators'
|
|
21
|
+
import {
|
|
22
|
+
approvalDecisionEventName,
|
|
23
|
+
evaluateDecisionOutcome,
|
|
24
|
+
} from './workflow_steps'
|
|
25
|
+
|
|
26
|
+
export const decide = mutation({
|
|
27
|
+
args: {
|
|
28
|
+
runId: v.id('approvalRuns'),
|
|
29
|
+
decision: zodToConvex(approvalDecision),
|
|
30
|
+
reason: v.optional(zodToConvex(approvalReason)),
|
|
31
|
+
actor: zodToConvex(approvalActor),
|
|
32
|
+
compatibilityKey: v.string(),
|
|
33
|
+
},
|
|
34
|
+
returns: v.object({
|
|
35
|
+
state: v.union(
|
|
36
|
+
v.literal(APPROVAL_RUN_STATES[0]),
|
|
37
|
+
v.literal(APPROVAL_RUN_STATES[1]),
|
|
38
|
+
v.literal(APPROVAL_RUN_STATES[2]),
|
|
39
|
+
v.literal(APPROVAL_RUN_STATES[3]),
|
|
40
|
+
v.literal(APPROVAL_RUN_STATES[4]),
|
|
41
|
+
),
|
|
42
|
+
}),
|
|
43
|
+
handler: async (ctx, input) => {
|
|
44
|
+
const run = await ctx.db.get(input.runId)
|
|
45
|
+
if (!run) throw new Error('Approval request not found')
|
|
46
|
+
assertCompatible(run.workflow.compatibilityKey, input.compatibilityKey)
|
|
47
|
+
const step = run.workflow.steps.find(
|
|
48
|
+
(candidate) =>
|
|
49
|
+
candidate.kind === 'decision' && candidate.key === run.currentStepKey,
|
|
50
|
+
)
|
|
51
|
+
if (!step || step.kind !== 'decision')
|
|
52
|
+
throw new Error('Approval request is not waiting for a decision')
|
|
53
|
+
|
|
54
|
+
const prior = await ctx.db
|
|
55
|
+
.query('approvalDecisions')
|
|
56
|
+
.withIndex('by_runId_and_stepKey_and_actor_actorRef', (query) =>
|
|
57
|
+
query
|
|
58
|
+
.eq('runId', run._id)
|
|
59
|
+
.eq('stepKey', step.key)
|
|
60
|
+
.eq('actor.actorRef', input.actor.actorRef),
|
|
61
|
+
)
|
|
62
|
+
.unique()
|
|
63
|
+
if (prior) {
|
|
64
|
+
if (prior.decision !== input.decision)
|
|
65
|
+
throw new Error('Actor already submitted a contradictory decision')
|
|
66
|
+
return {
|
|
67
|
+
state: run.state as 'pending' | 'approved' | 'rejected' | 'expired',
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (run.state !== 'pending')
|
|
71
|
+
throw new Error(`Approval request is already ${run.state}`)
|
|
72
|
+
if (
|
|
73
|
+
run.expiresAt !== undefined &&
|
|
74
|
+
classifyPendingRunAt(Date.now(), run.expiresAt) === 'expired'
|
|
75
|
+
)
|
|
76
|
+
return claimExpiry(ctx, run, step.key)
|
|
77
|
+
if (!step.decisions.includes(input.decision))
|
|
78
|
+
throw new Error('Decision is not allowed by this workflow step')
|
|
79
|
+
if (step.makerChecker && input.actor.actorRef === run.requester.actorRef)
|
|
80
|
+
throw new Error('Requester cannot decide this approval step')
|
|
81
|
+
|
|
82
|
+
const decidedAt = Date.now()
|
|
83
|
+
await ctx.db.insert('approvalDecisions', {
|
|
84
|
+
runId: run._id,
|
|
85
|
+
stepKey: step.key,
|
|
86
|
+
actor: input.actor,
|
|
87
|
+
decision: input.decision,
|
|
88
|
+
...(input.reason === undefined ? {} : { reason: input.reason }),
|
|
89
|
+
decidedAt,
|
|
90
|
+
})
|
|
91
|
+
const evidence = await ctx.db
|
|
92
|
+
.query('approvalDecisions')
|
|
93
|
+
.withIndex('by_runId_and_stepKey_and_decidedAt', (query) =>
|
|
94
|
+
query.eq('runId', run._id).eq('stepKey', step.key),
|
|
95
|
+
)
|
|
96
|
+
.collect()
|
|
97
|
+
const outcome = evaluateDecisionOutcome(
|
|
98
|
+
evidence.map((item) => ({
|
|
99
|
+
actorRef: item.actor.actorRef,
|
|
100
|
+
decision: item.decision,
|
|
101
|
+
})),
|
|
102
|
+
step.quorum.approvals,
|
|
103
|
+
)
|
|
104
|
+
await logApprovalTransition(ctx, {
|
|
105
|
+
action: `approval.decision.${input.decision}`,
|
|
106
|
+
actorRef: input.actor.actorRef,
|
|
107
|
+
runId: run._id,
|
|
108
|
+
resourceType: run.resourceType,
|
|
109
|
+
resourceRef: run.resourceRef,
|
|
110
|
+
transition: `${step.key}:${input.actor.actorRef}:${input.decision}`,
|
|
111
|
+
})
|
|
112
|
+
if (!outcome) return { state: 'pending' as const }
|
|
113
|
+
const terminal = isTerminalDecision(run, step.key, outcome)
|
|
114
|
+
await finishDecision(ctx, run, step.key, outcome, terminal, {
|
|
115
|
+
actorRef: input.actor.actorRef,
|
|
116
|
+
decision: input.decision,
|
|
117
|
+
...(input.reason === undefined ? {} : { reason: input.reason }),
|
|
118
|
+
decidedAt,
|
|
119
|
+
})
|
|
120
|
+
return terminal ? { state: outcome } : { state: 'pending' as const }
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
export const list = query({
|
|
125
|
+
args: { runId: v.id('approvalRuns') },
|
|
126
|
+
returns: v.array(
|
|
127
|
+
v.object({
|
|
128
|
+
stepKey: v.string(),
|
|
129
|
+
actorRef: v.string(),
|
|
130
|
+
decision: zodToConvex(approvalDecision),
|
|
131
|
+
reason: v.optional(zodToConvex(approvalReason)),
|
|
132
|
+
decidedAt: v.number(),
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
135
|
+
handler: (ctx, input) => listDecisionEvidence(ctx, input.runId),
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
export const listForWorkflow = internalQuery({
|
|
139
|
+
args: { runId: v.id('approvalRuns'), stepKey: v.string() },
|
|
140
|
+
returns: v.array(
|
|
141
|
+
v.object({
|
|
142
|
+
stepKey: v.string(),
|
|
143
|
+
actorRef: v.string(),
|
|
144
|
+
decision: zodToConvex(approvalDecision),
|
|
145
|
+
reason: v.optional(zodToConvex(approvalReason)),
|
|
146
|
+
decidedAt: v.number(),
|
|
147
|
+
}),
|
|
148
|
+
),
|
|
149
|
+
handler: (ctx, input) =>
|
|
150
|
+
listDecisionEvidenceForStep(ctx, input.runId, input.stepKey),
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
export const enter = internalMutation({
|
|
154
|
+
args: {
|
|
155
|
+
runId: v.id('approvalRuns'),
|
|
156
|
+
stepKey: v.string(),
|
|
157
|
+
workflowId: v.string(),
|
|
158
|
+
expiresAfterMs: v.optional(v.number()),
|
|
159
|
+
},
|
|
160
|
+
returns: v.object({
|
|
161
|
+
state: v.union(
|
|
162
|
+
v.literal(APPROVAL_RUN_STATES[0]),
|
|
163
|
+
v.literal(APPROVAL_RUN_STATES[1]),
|
|
164
|
+
v.literal(APPROVAL_RUN_STATES[2]),
|
|
165
|
+
v.literal(APPROVAL_RUN_STATES[3]),
|
|
166
|
+
v.literal(APPROVAL_RUN_STATES[4]),
|
|
167
|
+
),
|
|
168
|
+
expiresAt: v.optional(v.number()),
|
|
169
|
+
}),
|
|
170
|
+
handler: async (ctx, input) => {
|
|
171
|
+
const run = await ctx.db.get(input.runId)
|
|
172
|
+
if (!run) throw new Error('Approval request not found')
|
|
173
|
+
if (run.workflowId !== input.workflowId)
|
|
174
|
+
throw new Error('Approval workflow linkage is incomplete')
|
|
175
|
+
if (run.state !== 'pending')
|
|
176
|
+
return {
|
|
177
|
+
state: run.state as ApprovalRunState,
|
|
178
|
+
expiresAt: run.expiresAt,
|
|
179
|
+
}
|
|
180
|
+
const configuredExpiresAt = Number(run.metadata?.approvalExpiresAt)
|
|
181
|
+
const expiresAt = Number.isFinite(configuredExpiresAt)
|
|
182
|
+
? configuredExpiresAt
|
|
183
|
+
: input.expiresAfterMs === undefined
|
|
184
|
+
? undefined
|
|
185
|
+
: Date.now() + input.expiresAfterMs
|
|
186
|
+
await ctx.db.patch(run._id, {
|
|
187
|
+
currentStepKey: input.stepKey,
|
|
188
|
+
expiresAt,
|
|
189
|
+
updatedAt: Date.now(),
|
|
190
|
+
})
|
|
191
|
+
await logApprovalTransition(ctx, {
|
|
192
|
+
action: 'approval.waiting',
|
|
193
|
+
runId: run._id,
|
|
194
|
+
resourceType: run.resourceType,
|
|
195
|
+
resourceRef: run.resourceRef,
|
|
196
|
+
transition: `${input.stepKey}:waiting`,
|
|
197
|
+
})
|
|
198
|
+
if (expiresAt !== undefined)
|
|
199
|
+
await ctx.scheduler.runAt(expiresAt, internal.decisions.expire, {
|
|
200
|
+
runId: run._id,
|
|
201
|
+
stepKey: input.stepKey,
|
|
202
|
+
})
|
|
203
|
+
return {
|
|
204
|
+
state: 'pending' as const,
|
|
205
|
+
...(expiresAt === undefined ? {} : { expiresAt }),
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
export const expire = internalMutation({
|
|
211
|
+
args: { runId: v.id('approvalRuns'), stepKey: v.string() },
|
|
212
|
+
returns: v.object({
|
|
213
|
+
state: v.union(
|
|
214
|
+
v.literal('missing'),
|
|
215
|
+
v.literal(APPROVAL_RUN_STATES[0]),
|
|
216
|
+
v.literal(APPROVAL_RUN_STATES[1]),
|
|
217
|
+
v.literal(APPROVAL_RUN_STATES[2]),
|
|
218
|
+
v.literal(APPROVAL_RUN_STATES[3]),
|
|
219
|
+
v.literal(APPROVAL_RUN_STATES[4]),
|
|
220
|
+
),
|
|
221
|
+
}),
|
|
222
|
+
handler: async (ctx, input) => {
|
|
223
|
+
const run = await ctx.db.get(input.runId)
|
|
224
|
+
if (!run) return { state: 'missing' as const }
|
|
225
|
+
if (
|
|
226
|
+
run.state !== 'pending' ||
|
|
227
|
+
run.currentStepKey !== input.stepKey ||
|
|
228
|
+
run.expiresAt === undefined ||
|
|
229
|
+
classifyPendingRunAt(Date.now(), run.expiresAt) === 'pending'
|
|
230
|
+
)
|
|
231
|
+
return { state: run.state as ApprovalRunState }
|
|
232
|
+
return claimExpiry(ctx, run, input.stepKey)
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
const approvalWorkflow = new WorkflowManager(components.workflow)
|
|
237
|
+
|
|
238
|
+
async function claimExpiry(
|
|
239
|
+
ctx: import('./_generated/server').MutationCtx,
|
|
240
|
+
run: import('./_generated/dataModel').Doc<'approvalRuns'>,
|
|
241
|
+
stepKey: string,
|
|
242
|
+
): Promise<{ state: 'expired' }> {
|
|
243
|
+
if (!canTransitionApprovalRun(run.state, 'expired'))
|
|
244
|
+
throw new Error(`Approval request cannot transition from ${run.state}`)
|
|
245
|
+
const now = Date.now()
|
|
246
|
+
await ctx.db.patch(run._id, {
|
|
247
|
+
state: 'expired',
|
|
248
|
+
expiresAt: undefined,
|
|
249
|
+
updatedAt: now,
|
|
250
|
+
terminalAt: now,
|
|
251
|
+
})
|
|
252
|
+
await logApprovalTransition(ctx, {
|
|
253
|
+
action: 'approval.expired',
|
|
254
|
+
runId: run._id,
|
|
255
|
+
resourceType: run.resourceType,
|
|
256
|
+
resourceRef: run.resourceRef,
|
|
257
|
+
transition: `${stepKey}:expired`,
|
|
258
|
+
})
|
|
259
|
+
if (!run.workflowId) throw new Error('Approval workflow is not linked')
|
|
260
|
+
await approvalWorkflow.sendEvent(ctx, {
|
|
261
|
+
workflowId: run.workflowId as WorkflowId,
|
|
262
|
+
name: approvalDecisionEventName(run._id, stepKey),
|
|
263
|
+
validator: v.object({ outcome: v.literal(APPROVAL_RUN_STATES[3]) }),
|
|
264
|
+
value: { outcome: 'expired' },
|
|
265
|
+
})
|
|
266
|
+
return { state: 'expired' }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function finishDecision(
|
|
270
|
+
ctx: import('./_generated/server').MutationCtx,
|
|
271
|
+
run: import('./_generated/dataModel').Doc<'approvalRuns'>,
|
|
272
|
+
stepKey: string,
|
|
273
|
+
outcome: 'approved' | 'rejected',
|
|
274
|
+
terminal: boolean,
|
|
275
|
+
terminalEvidence: {
|
|
276
|
+
actorRef: string
|
|
277
|
+
decision: 'approved' | 'rejected'
|
|
278
|
+
reason?: string
|
|
279
|
+
decidedAt: number
|
|
280
|
+
},
|
|
281
|
+
): Promise<void> {
|
|
282
|
+
if (terminal) {
|
|
283
|
+
if (!canTransitionApprovalRun(run.state, outcome))
|
|
284
|
+
throw new Error(`Approval request cannot transition from ${run.state}`)
|
|
285
|
+
const now = Date.now()
|
|
286
|
+
await ctx.db.patch(run._id, {
|
|
287
|
+
state: outcome,
|
|
288
|
+
expiresAt: undefined,
|
|
289
|
+
updatedAt: now,
|
|
290
|
+
terminalAt: now,
|
|
291
|
+
})
|
|
292
|
+
await logApprovalTransition(ctx, {
|
|
293
|
+
action: `approval.${outcome}`,
|
|
294
|
+
runId: run._id,
|
|
295
|
+
resourceType: run.resourceType,
|
|
296
|
+
resourceRef: run.resourceRef,
|
|
297
|
+
transition: `${stepKey}:terminal:${outcome}`,
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
if (!run.workflowId) throw new Error('Approval workflow is not linked')
|
|
301
|
+
await approvalWorkflow.sendEvent(ctx, {
|
|
302
|
+
workflowId: run.workflowId as WorkflowId,
|
|
303
|
+
name: approvalDecisionEventName(run._id, stepKey),
|
|
304
|
+
validator: v.object({
|
|
305
|
+
outcome: v.union(
|
|
306
|
+
v.literal(APPROVAL_DECISIONS[0]),
|
|
307
|
+
v.literal(APPROVAL_DECISIONS[1]),
|
|
308
|
+
),
|
|
309
|
+
terminalEvidence: v.object({
|
|
310
|
+
actorRef: v.string(),
|
|
311
|
+
decision: zodToConvex(approvalDecision),
|
|
312
|
+
reason: v.optional(zodToConvex(approvalReason)),
|
|
313
|
+
decidedAt: v.number(),
|
|
314
|
+
}),
|
|
315
|
+
}),
|
|
316
|
+
value: { outcome, terminalEvidence },
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function assertCompatible(actual: string, expected: string): void {
|
|
321
|
+
if (actual !== expected)
|
|
322
|
+
throw new Error(
|
|
323
|
+
`Approval workflow compatibility mismatch: expected ${expected}, received ${actual}`,
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function listDecisionEvidence(
|
|
328
|
+
ctx: QueryCtx,
|
|
329
|
+
runId: import('./_generated/dataModel').Id<'approvalRuns'>,
|
|
330
|
+
) {
|
|
331
|
+
const decisions = await ctx.db
|
|
332
|
+
.query('approvalDecisions')
|
|
333
|
+
.withIndex('by_runId_and_decidedAt', (query) => query.eq('runId', runId))
|
|
334
|
+
.collect()
|
|
335
|
+
return decisions.map((decision) => ({
|
|
336
|
+
stepKey: decision.stepKey,
|
|
337
|
+
actorRef: decision.actor.actorRef,
|
|
338
|
+
decision: decision.decision,
|
|
339
|
+
...(decision.reason === undefined ? {} : { reason: decision.reason }),
|
|
340
|
+
decidedAt: decision.decidedAt,
|
|
341
|
+
}))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function listDecisionEvidenceForStep(
|
|
345
|
+
ctx: QueryCtx,
|
|
346
|
+
runId: import('./_generated/dataModel').Id<'approvalRuns'>,
|
|
347
|
+
stepKey: string,
|
|
348
|
+
) {
|
|
349
|
+
const decisions = await ctx.db
|
|
350
|
+
.query('approvalDecisions')
|
|
351
|
+
.withIndex('by_runId_and_stepKey_and_decidedAt', (query) =>
|
|
352
|
+
query.eq('runId', runId).eq('stepKey', stepKey),
|
|
353
|
+
)
|
|
354
|
+
.collect()
|
|
355
|
+
return decisions.map((decision) => ({
|
|
356
|
+
stepKey: decision.stepKey,
|
|
357
|
+
actorRef: decision.actor.actorRef,
|
|
358
|
+
decision: decision.decision,
|
|
359
|
+
...(decision.reason === undefined ? {} : { reason: decision.reason }),
|
|
360
|
+
decidedAt: decision.decidedAt,
|
|
361
|
+
}))
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function isTerminalDecision(
|
|
365
|
+
run: import('./_generated/dataModel').Doc<'approvalRuns'>,
|
|
366
|
+
stepKey: string,
|
|
367
|
+
outcome: 'approved' | 'rejected',
|
|
368
|
+
): boolean {
|
|
369
|
+
if (outcome === 'rejected') return true
|
|
370
|
+
const currentIndex = run.workflow.steps.findIndex(
|
|
371
|
+
(step) => step.kind === 'decision' && step.key === stepKey,
|
|
372
|
+
)
|
|
373
|
+
return !run.workflow.steps
|
|
374
|
+
.slice(currentIndex + 1)
|
|
375
|
+
.some((step) => step.kind === 'decision')
|
|
376
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ApprovalRunState } from './constants'
|
|
2
|
+
|
|
3
|
+
export function canTransitionApprovalRun(
|
|
4
|
+
from: ApprovalRunState,
|
|
5
|
+
to: ApprovalRunState,
|
|
6
|
+
): boolean {
|
|
7
|
+
return (
|
|
8
|
+
LEGAL_APPROVAL_RUN_TRANSITIONS[from] as readonly ApprovalRunState[]
|
|
9
|
+
).includes(to)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function classifyPendingRunAt(
|
|
13
|
+
now: number,
|
|
14
|
+
expiresAt: number,
|
|
15
|
+
): 'pending' | 'expired' {
|
|
16
|
+
return now >= expiresAt ? 'expired' : 'pending'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const LEGAL_APPROVAL_RUN_TRANSITIONS = {
|
|
20
|
+
pending: ['approved', 'rejected', 'expired', 'canceled'],
|
|
21
|
+
approved: [],
|
|
22
|
+
rejected: [],
|
|
23
|
+
expired: [],
|
|
24
|
+
canceled: [],
|
|
25
|
+
} as const satisfies Readonly<
|
|
26
|
+
Record<ApprovalRunState, readonly ApprovalRunState[]>
|
|
27
|
+
>
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { WorkflowManager, type WorkflowId } from '@convex-dev/workflow'
|
|
2
|
+
import { zodToConvex } from 'convex-helpers/server/zod4'
|
|
3
|
+
import {
|
|
4
|
+
paginationOptsValidator,
|
|
5
|
+
paginationResultValidator,
|
|
6
|
+
} from 'convex/server'
|
|
7
|
+
import { v } from 'convex/values'
|
|
8
|
+
|
|
9
|
+
import { components, internal } from './_generated/api'
|
|
10
|
+
import {
|
|
11
|
+
internalMutation,
|
|
12
|
+
internalQuery,
|
|
13
|
+
mutation,
|
|
14
|
+
query,
|
|
15
|
+
} from './_generated/server'
|
|
16
|
+
import { logApprovalTransition } from './audit'
|
|
17
|
+
import { APPROVAL_EXECUTION_STATES, APPROVAL_RUN_STATES } from './constants'
|
|
18
|
+
import { canTransitionApprovalRun } from './functions'
|
|
19
|
+
import {
|
|
20
|
+
approvalActor,
|
|
21
|
+
approvalMetadata,
|
|
22
|
+
approvalName,
|
|
23
|
+
approvalReference,
|
|
24
|
+
approvalRunDocument,
|
|
25
|
+
approvalRunState,
|
|
26
|
+
approvalWorkflowDescriptor,
|
|
27
|
+
} from './validators'
|
|
28
|
+
|
|
29
|
+
export const start = mutation({
|
|
30
|
+
args: {
|
|
31
|
+
scopeRef: zodToConvex(approvalReference),
|
|
32
|
+
resourceType: zodToConvex(approvalReference),
|
|
33
|
+
resourceRef: zodToConvex(approvalReference),
|
|
34
|
+
requester: zodToConvex(approvalActor),
|
|
35
|
+
metadata: v.optional(zodToConvex(approvalMetadata)),
|
|
36
|
+
workflow: zodToConvex(approvalWorkflowDescriptor),
|
|
37
|
+
},
|
|
38
|
+
returns: v.object({ runId: v.id('approvalRuns') }),
|
|
39
|
+
handler: async (ctx, input) => {
|
|
40
|
+
const now = Date.now()
|
|
41
|
+
const runId = await ctx.db.insert('approvalRuns', {
|
|
42
|
+
...input,
|
|
43
|
+
state: 'pending',
|
|
44
|
+
createdAt: now,
|
|
45
|
+
updatedAt: now,
|
|
46
|
+
})
|
|
47
|
+
const workflowId: WorkflowId = await approvalWorkflow.start(
|
|
48
|
+
ctx,
|
|
49
|
+
internal.workflow.run,
|
|
50
|
+
{ runId },
|
|
51
|
+
{ startAsync: true },
|
|
52
|
+
)
|
|
53
|
+
await ctx.db.patch(runId, { workflowId, updatedAt: Date.now() })
|
|
54
|
+
await logApprovalTransition(ctx, {
|
|
55
|
+
action: 'approval.requested',
|
|
56
|
+
actorRef: input.requester.actorRef,
|
|
57
|
+
runId,
|
|
58
|
+
resourceType: input.resourceType,
|
|
59
|
+
resourceRef: input.resourceRef,
|
|
60
|
+
transition: 'requested',
|
|
61
|
+
})
|
|
62
|
+
return { runId }
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
export const cancel = mutation({
|
|
67
|
+
args: {
|
|
68
|
+
runId: v.id('approvalRuns'),
|
|
69
|
+
actor: zodToConvex(approvalActor),
|
|
70
|
+
compatibilityKey: v.string(),
|
|
71
|
+
},
|
|
72
|
+
returns: v.object({ state: v.literal(APPROVAL_RUN_STATES[4]) }),
|
|
73
|
+
handler: async (ctx, input) => {
|
|
74
|
+
const run = await ctx.db.get(input.runId)
|
|
75
|
+
if (!run) throw new Error('Approval request not found')
|
|
76
|
+
assertCompatible(run.workflow.compatibilityKey, input.compatibilityKey)
|
|
77
|
+
if (run.state !== 'pending') {
|
|
78
|
+
if (run.state === 'canceled') return { state: 'canceled' as const }
|
|
79
|
+
throw new Error(`Approval request is already ${run.state}`)
|
|
80
|
+
}
|
|
81
|
+
if (!run.workflowId) throw new Error('Approval workflow is not linked')
|
|
82
|
+
await approvalWorkflow.cancel(ctx, run.workflowId as WorkflowId)
|
|
83
|
+
if (!canTransitionApprovalRun(run.state, 'canceled'))
|
|
84
|
+
throw new Error(`Approval request cannot transition from ${run.state}`)
|
|
85
|
+
const now = Date.now()
|
|
86
|
+
await ctx.db.patch(run._id, {
|
|
87
|
+
state: 'canceled',
|
|
88
|
+
expiresAt: undefined,
|
|
89
|
+
updatedAt: now,
|
|
90
|
+
terminalAt: now,
|
|
91
|
+
})
|
|
92
|
+
await logApprovalTransition(ctx, {
|
|
93
|
+
action: 'approval.canceled',
|
|
94
|
+
actorRef: input.actor.actorRef,
|
|
95
|
+
runId: run._id,
|
|
96
|
+
resourceType: run.resourceType,
|
|
97
|
+
resourceRef: run.resourceRef,
|
|
98
|
+
transition: 'canceled',
|
|
99
|
+
})
|
|
100
|
+
return { state: 'canceled' as const }
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
export const restart = mutation({
|
|
105
|
+
args: { runId: v.id('approvalRuns'), compatibilityKey: v.string() },
|
|
106
|
+
returns: v.null(),
|
|
107
|
+
handler: async (ctx, input) => {
|
|
108
|
+
const run = await ctx.db.get(input.runId)
|
|
109
|
+
if (!run) throw new Error('Approval request not found')
|
|
110
|
+
assertCompatible(run.workflow.compatibilityKey, input.compatibilityKey)
|
|
111
|
+
if (!run.workflowId) throw new Error('Approval workflow is not linked')
|
|
112
|
+
if (!run.executionFailedStepKey)
|
|
113
|
+
throw new Error('Approval workflow has no failed step to restart')
|
|
114
|
+
await approvalWorkflow.restart(ctx, run.workflowId as WorkflowId, {
|
|
115
|
+
from: run.executionFailedStepKey,
|
|
116
|
+
startAsync: true,
|
|
117
|
+
})
|
|
118
|
+
return null
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
export const status = query({
|
|
123
|
+
args: { runId: v.id('approvalRuns'), compatibilityKey: v.string() },
|
|
124
|
+
returns: v.union(v.null(), approvalStatusValidator()),
|
|
125
|
+
handler: async (ctx, input) => {
|
|
126
|
+
const run = await ctx.db.get(input.runId)
|
|
127
|
+
if (!run) return null
|
|
128
|
+
assertCompatible(run.workflow.compatibilityKey, input.compatibilityKey)
|
|
129
|
+
const workflowStatus = run.workflowId
|
|
130
|
+
? await approvalWorkflow.status(ctx, run.workflowId as WorkflowId)
|
|
131
|
+
: null
|
|
132
|
+
const execution = normalizeWorkflowStatus(workflowStatus)
|
|
133
|
+
return { run, execution }
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
export const list = query({
|
|
138
|
+
args: {
|
|
139
|
+
scopeRef: zodToConvex(approvalReference),
|
|
140
|
+
state: v.optional(zodToConvex(approvalRunState)),
|
|
141
|
+
paginationOpts: paginationOptsValidator,
|
|
142
|
+
},
|
|
143
|
+
returns: paginationResultValidator(approvalRunValidator()),
|
|
144
|
+
handler: (ctx, input) =>
|
|
145
|
+
ctx.db
|
|
146
|
+
.query('approvalRuns')
|
|
147
|
+
.withIndex('by_scopeRef_and_state', (query) =>
|
|
148
|
+
input.state === undefined
|
|
149
|
+
? query.eq('scopeRef', input.scopeRef)
|
|
150
|
+
: query.eq('scopeRef', input.scopeRef).eq('state', input.state),
|
|
151
|
+
)
|
|
152
|
+
.order('desc')
|
|
153
|
+
.paginate(input.paginationOpts),
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
export const getForWorkflow = internalQuery({
|
|
157
|
+
args: { runId: v.id('approvalRuns') },
|
|
158
|
+
returns: v.union(v.null(), approvalRunValidator()),
|
|
159
|
+
handler: (ctx, input) => ctx.db.get(input.runId),
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
export const recordExecutionTerminal = internalMutation({
|
|
163
|
+
args: {
|
|
164
|
+
runId: v.id('approvalRuns'),
|
|
165
|
+
outcome: v.union(
|
|
166
|
+
v.literal(APPROVAL_EXECUTION_STATES[1]),
|
|
167
|
+
v.literal(APPROVAL_EXECUTION_STATES[3]),
|
|
168
|
+
),
|
|
169
|
+
failedStepKey: v.optional(zodToConvex(approvalName)),
|
|
170
|
+
},
|
|
171
|
+
returns: v.null(),
|
|
172
|
+
handler: async (ctx, input) => {
|
|
173
|
+
const run = await ctx.db.get(input.runId)
|
|
174
|
+
if (!run) throw new Error('Approval request not found')
|
|
175
|
+
await ctx.db.patch(run._id, {
|
|
176
|
+
executionFailedStepKey:
|
|
177
|
+
input.outcome === 'failed' ? input.failedStepKey : undefined,
|
|
178
|
+
updatedAt: Date.now(),
|
|
179
|
+
})
|
|
180
|
+
await logApprovalTransition(ctx, {
|
|
181
|
+
action: `approval.execution.${input.outcome}`,
|
|
182
|
+
runId: run._id,
|
|
183
|
+
resourceType: run.resourceType,
|
|
184
|
+
resourceRef: run.resourceRef,
|
|
185
|
+
transition: `execution:${input.outcome}`,
|
|
186
|
+
severity: input.outcome === 'failed' ? 'error' : 'info',
|
|
187
|
+
})
|
|
188
|
+
return null
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
const approvalWorkflow = new WorkflowManager(components.workflow)
|
|
193
|
+
|
|
194
|
+
function normalizeWorkflowStatus(
|
|
195
|
+
status: Awaited<ReturnType<typeof approvalWorkflow.status>> | null,
|
|
196
|
+
) {
|
|
197
|
+
if (!status) return null
|
|
198
|
+
switch (status.type) {
|
|
199
|
+
case 'failed':
|
|
200
|
+
return { type: 'failed' as const, error: status.error }
|
|
201
|
+
case 'inProgress':
|
|
202
|
+
case 'completed':
|
|
203
|
+
case 'canceled':
|
|
204
|
+
return { type: status.type }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function approvalRunValidator() {
|
|
209
|
+
return v.object({
|
|
210
|
+
_id: v.id('approvalRuns'),
|
|
211
|
+
_creationTime: v.number(),
|
|
212
|
+
...zodToConvex(approvalRunDocument).fields,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function workflowStatusValidator() {
|
|
217
|
+
return v.union(
|
|
218
|
+
v.object({ type: v.literal(APPROVAL_EXECUTION_STATES[0]) }),
|
|
219
|
+
v.object({ type: v.literal(APPROVAL_EXECUTION_STATES[1]) }),
|
|
220
|
+
v.object({ type: v.literal(APPROVAL_EXECUTION_STATES[2]) }),
|
|
221
|
+
v.object({
|
|
222
|
+
type: v.literal(APPROVAL_EXECUTION_STATES[3]),
|
|
223
|
+
error: v.string(),
|
|
224
|
+
}),
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function approvalStatusValidator() {
|
|
229
|
+
return v.object({
|
|
230
|
+
run: approvalRunValidator(),
|
|
231
|
+
execution: v.union(v.null(), workflowStatusValidator()),
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function assertCompatible(actual: string, expected: string): void {
|
|
236
|
+
if (actual !== expected)
|
|
237
|
+
throw new Error(
|
|
238
|
+
`Approval workflow compatibility mismatch: expected ${expected}, received ${actual}`,
|
|
239
|
+
)
|
|
240
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { zodToConvex } from 'convex-helpers/server/zod4'
|
|
2
|
+
import { defineSchema, defineTable } from 'convex/server'
|
|
3
|
+
|
|
4
|
+
import { approvalDecisionDocument, approvalRunDocument } from './validators'
|
|
5
|
+
|
|
6
|
+
export default defineSchema({
|
|
7
|
+
approvalRuns: defineTable(zodToConvex(approvalRunDocument))
|
|
8
|
+
.index('by_resourceType_and_resourceRef', ['resourceType', 'resourceRef'])
|
|
9
|
+
.index('by_scopeRef_and_state', ['scopeRef', 'state'])
|
|
10
|
+
.index('by_state_and_expiresAt', ['state', 'expiresAt']),
|
|
11
|
+
approvalDecisions: defineTable(zodToConvex(approvalDecisionDocument))
|
|
12
|
+
.index('by_runId_and_stepKey_and_actor_actorRef', [
|
|
13
|
+
'runId',
|
|
14
|
+
'stepKey',
|
|
15
|
+
'actor.actorRef',
|
|
16
|
+
])
|
|
17
|
+
.index('by_runId_and_stepKey_and_decidedAt', [
|
|
18
|
+
'runId',
|
|
19
|
+
'stepKey',
|
|
20
|
+
'decidedAt',
|
|
21
|
+
])
|
|
22
|
+
.index('by_runId_and_decidedAt', ['runId', 'decidedAt']),
|
|
23
|
+
})
|