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,272 @@
|
|
|
1
|
+
import { zid } from 'convex-helpers/server/zod4'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
APPROVAL_CALLBACK_KINDS,
|
|
6
|
+
APPROVAL_DECISIONS,
|
|
7
|
+
APPROVAL_DESCRIPTOR_SCHEMA_VERSION,
|
|
8
|
+
APPROVAL_QUORUM_KINDS,
|
|
9
|
+
APPROVAL_RUN_STATES,
|
|
10
|
+
APPROVAL_STEP_KINDS,
|
|
11
|
+
MAX_APPROVAL_CAPABILITIES,
|
|
12
|
+
MAX_APPROVAL_DECISIONS_PER_STEP,
|
|
13
|
+
MAX_APPROVAL_METADATA_ENTRIES,
|
|
14
|
+
MAX_APPROVAL_METADATA_KEY_LENGTH,
|
|
15
|
+
MAX_APPROVAL_METADATA_VALUE_LENGTH,
|
|
16
|
+
MAX_APPROVAL_NAME_LENGTH,
|
|
17
|
+
MAX_APPROVAL_REASON_LENGTH,
|
|
18
|
+
MAX_APPROVAL_REFERENCE_LENGTH,
|
|
19
|
+
MAX_APPROVAL_WORKFLOW_STEPS,
|
|
20
|
+
} from './constants'
|
|
21
|
+
|
|
22
|
+
export const approvalRunState = z.enum(APPROVAL_RUN_STATES)
|
|
23
|
+
export const approvalDecision = z.enum(APPROVAL_DECISIONS)
|
|
24
|
+
export const approvalStepKind = z.enum(APPROVAL_STEP_KINDS)
|
|
25
|
+
export const approvalCallbackKind = z.enum(APPROVAL_CALLBACK_KINDS)
|
|
26
|
+
export const approvalQuorumKind = z.enum(APPROVAL_QUORUM_KINDS)
|
|
27
|
+
|
|
28
|
+
export const approvalReference = z
|
|
29
|
+
.string()
|
|
30
|
+
.trim()
|
|
31
|
+
.min(1)
|
|
32
|
+
.max(MAX_APPROVAL_REFERENCE_LENGTH)
|
|
33
|
+
|
|
34
|
+
export const approvalName = z
|
|
35
|
+
.string()
|
|
36
|
+
.trim()
|
|
37
|
+
.min(1)
|
|
38
|
+
.max(MAX_APPROVAL_NAME_LENGTH)
|
|
39
|
+
.regex(/^[A-Za-z][A-Za-z0-9._-]*$/)
|
|
40
|
+
|
|
41
|
+
export const approvalReason = z
|
|
42
|
+
.string()
|
|
43
|
+
.trim()
|
|
44
|
+
.min(1)
|
|
45
|
+
.max(MAX_APPROVAL_REASON_LENGTH)
|
|
46
|
+
|
|
47
|
+
export const approvalMetadata = z
|
|
48
|
+
.record(
|
|
49
|
+
z
|
|
50
|
+
.string()
|
|
51
|
+
.min(1)
|
|
52
|
+
.max(MAX_APPROVAL_METADATA_KEY_LENGTH)
|
|
53
|
+
.regex(/^(?![$_])[\x20-\x7E]+$/),
|
|
54
|
+
z.string().max(MAX_APPROVAL_METADATA_VALUE_LENGTH),
|
|
55
|
+
)
|
|
56
|
+
.refine(
|
|
57
|
+
(value) => Object.keys(value).length <= MAX_APPROVAL_METADATA_ENTRIES,
|
|
58
|
+
`Metadata may contain at most ${MAX_APPROVAL_METADATA_ENTRIES} entries`,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
export const approvalActor = z
|
|
62
|
+
.object({
|
|
63
|
+
actorRef: approvalReference,
|
|
64
|
+
capabilities: z.array(approvalName).max(MAX_APPROVAL_CAPABILITIES),
|
|
65
|
+
metadata: approvalMetadata.optional(),
|
|
66
|
+
})
|
|
67
|
+
.strict()
|
|
68
|
+
|
|
69
|
+
export const approvalMutationCallback = z
|
|
70
|
+
.object({
|
|
71
|
+
kind: z.literal('mutation'),
|
|
72
|
+
handle: approvalReference,
|
|
73
|
+
retry: z.boolean(),
|
|
74
|
+
})
|
|
75
|
+
.strict()
|
|
76
|
+
|
|
77
|
+
export const approvalActionCallback = z
|
|
78
|
+
.object({
|
|
79
|
+
kind: z.literal('action'),
|
|
80
|
+
handle: approvalReference,
|
|
81
|
+
retry: z.boolean(),
|
|
82
|
+
})
|
|
83
|
+
.strict()
|
|
84
|
+
|
|
85
|
+
export const approvalCallback = z.discriminatedUnion('kind', [
|
|
86
|
+
approvalMutationCallback,
|
|
87
|
+
approvalActionCallback,
|
|
88
|
+
])
|
|
89
|
+
|
|
90
|
+
export const approvalQuorum = z
|
|
91
|
+
.object({
|
|
92
|
+
kind: approvalQuorumKind,
|
|
93
|
+
approvals: z.number().int().positive().max(MAX_APPROVAL_DECISIONS_PER_STEP),
|
|
94
|
+
})
|
|
95
|
+
.strict()
|
|
96
|
+
|
|
97
|
+
export const approvalCallbackStep = z.discriminatedUnion('kind', [
|
|
98
|
+
z
|
|
99
|
+
.object({
|
|
100
|
+
kind: z.literal('mutation'),
|
|
101
|
+
key: approvalName,
|
|
102
|
+
callback: approvalMutationCallback,
|
|
103
|
+
})
|
|
104
|
+
.strict(),
|
|
105
|
+
z
|
|
106
|
+
.object({
|
|
107
|
+
kind: z.literal('action'),
|
|
108
|
+
key: approvalName,
|
|
109
|
+
callback: approvalActionCallback,
|
|
110
|
+
})
|
|
111
|
+
.strict(),
|
|
112
|
+
])
|
|
113
|
+
|
|
114
|
+
export const approvalNotifyStep = z
|
|
115
|
+
.object({
|
|
116
|
+
kind: z.literal('notify'),
|
|
117
|
+
key: approvalName,
|
|
118
|
+
callback: approvalActionCallback,
|
|
119
|
+
})
|
|
120
|
+
.strict()
|
|
121
|
+
|
|
122
|
+
export const approvalDecisionStep = z
|
|
123
|
+
.object({
|
|
124
|
+
kind: z.literal('decision'),
|
|
125
|
+
key: approvalName,
|
|
126
|
+
decisions: z
|
|
127
|
+
.array(approvalDecision)
|
|
128
|
+
.min(1)
|
|
129
|
+
.max(MAX_APPROVAL_DECISIONS_PER_STEP),
|
|
130
|
+
quorum: approvalQuorum,
|
|
131
|
+
makerChecker: z.boolean(),
|
|
132
|
+
expiresAfterMs: z.number().int().positive().optional(),
|
|
133
|
+
})
|
|
134
|
+
.strict()
|
|
135
|
+
|
|
136
|
+
export const approvalBranchStep = z
|
|
137
|
+
.object({
|
|
138
|
+
kind: z.literal('branch'),
|
|
139
|
+
key: approvalName,
|
|
140
|
+
approvedStepKey: approvalName,
|
|
141
|
+
rejectedStepKey: approvalName,
|
|
142
|
+
})
|
|
143
|
+
.strict()
|
|
144
|
+
|
|
145
|
+
export const approvalWorkflowStep = z.discriminatedUnion('kind', [
|
|
146
|
+
approvalCallbackStep,
|
|
147
|
+
approvalNotifyStep,
|
|
148
|
+
approvalDecisionStep,
|
|
149
|
+
approvalBranchStep,
|
|
150
|
+
])
|
|
151
|
+
|
|
152
|
+
export const approvalWorkflowDescriptor = z
|
|
153
|
+
.object({
|
|
154
|
+
schemaVersion: z.literal(APPROVAL_DESCRIPTOR_SCHEMA_VERSION),
|
|
155
|
+
compatibilityKey: approvalName,
|
|
156
|
+
name: approvalName,
|
|
157
|
+
steps: z
|
|
158
|
+
.array(approvalWorkflowStep)
|
|
159
|
+
.min(1)
|
|
160
|
+
.max(MAX_APPROVAL_WORKFLOW_STEPS),
|
|
161
|
+
})
|
|
162
|
+
.strict()
|
|
163
|
+
.superRefine((descriptor, context) => {
|
|
164
|
+
const keys = new Set<string>()
|
|
165
|
+
const stepsByKey = new Map(
|
|
166
|
+
descriptor.steps.map((step) => [step.key, step] as const),
|
|
167
|
+
)
|
|
168
|
+
const branchTargetOwners = new Map<string, number>()
|
|
169
|
+
for (const [index, step] of descriptor.steps.entries()) {
|
|
170
|
+
if (keys.has(step.key))
|
|
171
|
+
context.addIssue({
|
|
172
|
+
code: 'custom',
|
|
173
|
+
message: `Duplicate workflow step key: ${step.key}`,
|
|
174
|
+
path: ['steps', index, 'key'],
|
|
175
|
+
})
|
|
176
|
+
keys.add(step.key)
|
|
177
|
+
if (
|
|
178
|
+
step.kind === 'decision' &&
|
|
179
|
+
new Set(step.decisions).size !== step.decisions.length
|
|
180
|
+
)
|
|
181
|
+
context.addIssue({
|
|
182
|
+
code: 'custom',
|
|
183
|
+
message: `Duplicate decision value in step: ${step.key}`,
|
|
184
|
+
path: ['steps', index, 'decisions'],
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
for (const [index, step] of descriptor.steps.entries()) {
|
|
188
|
+
if (step.kind !== 'branch') continue
|
|
189
|
+
if (descriptor.steps[index - 1]?.kind !== 'decision')
|
|
190
|
+
context.addIssue({
|
|
191
|
+
code: 'custom',
|
|
192
|
+
message: `Branch must immediately follow a decision: ${step.key}`,
|
|
193
|
+
path: ['steps', index],
|
|
194
|
+
})
|
|
195
|
+
for (const target of [step.approvedStepKey, step.rejectedStepKey]) {
|
|
196
|
+
const targetStep = stepsByKey.get(target)
|
|
197
|
+
const targetIndex = descriptor.steps.findIndex(
|
|
198
|
+
(candidate) => candidate.key === target,
|
|
199
|
+
)
|
|
200
|
+
if (!targetStep)
|
|
201
|
+
context.addIssue({
|
|
202
|
+
code: 'custom',
|
|
203
|
+
message: `Unknown branch target: ${target}`,
|
|
204
|
+
path: ['steps', index],
|
|
205
|
+
})
|
|
206
|
+
else if (
|
|
207
|
+
targetStep.kind !== 'mutation' &&
|
|
208
|
+
targetStep.kind !== 'action' &&
|
|
209
|
+
targetStep.kind !== 'notify'
|
|
210
|
+
)
|
|
211
|
+
context.addIssue({
|
|
212
|
+
code: 'custom',
|
|
213
|
+
message: `Branch target must be a callback step: ${target}`,
|
|
214
|
+
path: ['steps', index],
|
|
215
|
+
})
|
|
216
|
+
else if (targetIndex <= index)
|
|
217
|
+
context.addIssue({
|
|
218
|
+
code: 'custom',
|
|
219
|
+
message: `Branch target must be declared after its branch: ${target}`,
|
|
220
|
+
path: ['steps', index],
|
|
221
|
+
})
|
|
222
|
+
branchTargetOwners.set(
|
|
223
|
+
target,
|
|
224
|
+
(branchTargetOwners.get(target) ?? 0) + 1,
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
for (const [target, owners] of branchTargetOwners) {
|
|
229
|
+
if (owners !== 1)
|
|
230
|
+
context.addIssue({
|
|
231
|
+
code: 'custom',
|
|
232
|
+
message: `Branch callback must belong to exactly one branch: ${target}`,
|
|
233
|
+
path: ['steps'],
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
export const approvalRunDocument = z
|
|
239
|
+
.object({
|
|
240
|
+
scopeRef: approvalReference,
|
|
241
|
+
resourceType: approvalName,
|
|
242
|
+
resourceRef: approvalReference,
|
|
243
|
+
requester: approvalActor,
|
|
244
|
+
metadata: approvalMetadata.optional(),
|
|
245
|
+
workflow: approvalWorkflowDescriptor,
|
|
246
|
+
state: approvalRunState,
|
|
247
|
+
currentStepKey: approvalName.optional(),
|
|
248
|
+
executionFailedStepKey: approvalName.optional(),
|
|
249
|
+
expiresAt: z.number().optional(),
|
|
250
|
+
workflowId: approvalReference.optional(),
|
|
251
|
+
createdAt: z.number(),
|
|
252
|
+
updatedAt: z.number(),
|
|
253
|
+
terminalAt: z.number().optional(),
|
|
254
|
+
})
|
|
255
|
+
.strict()
|
|
256
|
+
|
|
257
|
+
export const approvalDecisionDocument = z
|
|
258
|
+
.object({
|
|
259
|
+
runId: zid('approvalRuns'),
|
|
260
|
+
stepKey: approvalName,
|
|
261
|
+
actor: approvalActor,
|
|
262
|
+
decision: approvalDecision,
|
|
263
|
+
reason: approvalReason.optional(),
|
|
264
|
+
decidedAt: z.number(),
|
|
265
|
+
})
|
|
266
|
+
.strict()
|
|
267
|
+
|
|
268
|
+
export type ApprovalWorkflowDescriptor = z.infer<
|
|
269
|
+
typeof approvalWorkflowDescriptor
|
|
270
|
+
>
|
|
271
|
+
export type ApprovalActor = z.infer<typeof approvalActor>
|
|
272
|
+
export type ApprovalMetadata = z.infer<typeof approvalMetadata>
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { WorkflowManager, type WorkflowCtx } from '@convex-dev/workflow'
|
|
2
|
+
import { v } from 'convex/values'
|
|
3
|
+
|
|
4
|
+
import { components, internal } from './_generated/api'
|
|
5
|
+
import type { Doc } from './_generated/dataModel'
|
|
6
|
+
import { APPROVAL_DECISIONS, APPROVAL_RUN_STATES } from './constants'
|
|
7
|
+
import {
|
|
8
|
+
approvalDecisionEventName,
|
|
9
|
+
callbackReference,
|
|
10
|
+
resolveWorkflowStepIndex,
|
|
11
|
+
type ApprovalCallbackInput,
|
|
12
|
+
} from './workflow_steps'
|
|
13
|
+
|
|
14
|
+
export const approvalWorkflow = new WorkflowManager(components.workflow)
|
|
15
|
+
|
|
16
|
+
export const run = approvalWorkflow.define({
|
|
17
|
+
args: { runId: v.id('approvalRuns') },
|
|
18
|
+
returns: v.null(),
|
|
19
|
+
handler: async (step, input) => {
|
|
20
|
+
let failedStepKey: string | undefined
|
|
21
|
+
try {
|
|
22
|
+
const run: Doc<'approvalRuns'> | null = await step.runQuery(
|
|
23
|
+
internal.requests.getForWorkflow,
|
|
24
|
+
{ runId: input.runId },
|
|
25
|
+
{ name: 'loadLinkedApproval' },
|
|
26
|
+
)
|
|
27
|
+
if (!run) throw new Error('Approval request not found')
|
|
28
|
+
if (run.workflowId !== step.workflowId)
|
|
29
|
+
throw new Error('Approval workflow linkage is incomplete')
|
|
30
|
+
|
|
31
|
+
const callbackInput = buildCallbackInput(run)
|
|
32
|
+
const stepKeys = run.workflow.steps.map(
|
|
33
|
+
(workflowStep) => workflowStep.key,
|
|
34
|
+
)
|
|
35
|
+
const branchTargets = new Set(
|
|
36
|
+
run.workflow.steps.flatMap((workflowStep) =>
|
|
37
|
+
workflowStep.kind === 'branch'
|
|
38
|
+
? [workflowStep.approvedStepKey, workflowStep.rejectedStepKey]
|
|
39
|
+
: [],
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
let lastDecision: 'approved' | 'rejected' | 'expired' | null = null
|
|
43
|
+
let lastDecisionStepKey: string | null = null
|
|
44
|
+
let terminalEvidence:
|
|
45
|
+
| {
|
|
46
|
+
actorRef: string
|
|
47
|
+
decision: 'approved' | 'rejected'
|
|
48
|
+
reason?: string
|
|
49
|
+
decidedAt: number
|
|
50
|
+
}
|
|
51
|
+
| undefined
|
|
52
|
+
let index = 0
|
|
53
|
+
while (index < run.workflow.steps.length) {
|
|
54
|
+
const workflowStep = run.workflow.steps[index]
|
|
55
|
+
if (
|
|
56
|
+
workflowStep.kind === 'mutation' ||
|
|
57
|
+
workflowStep.kind === 'action' ||
|
|
58
|
+
workflowStep.kind === 'notify'
|
|
59
|
+
) {
|
|
60
|
+
if (branchTargets.has(workflowStep.key)) {
|
|
61
|
+
index += 1
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
failedStepKey = workflowStep.key
|
|
65
|
+
await executeCallback(step, workflowStep, callbackInput)
|
|
66
|
+
failedStepKey = undefined
|
|
67
|
+
index += 1
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
if (workflowStep.kind === 'decision') {
|
|
71
|
+
await step.runMutation(
|
|
72
|
+
internal.decisions.enter,
|
|
73
|
+
{
|
|
74
|
+
runId: run._id,
|
|
75
|
+
stepKey: workflowStep.key,
|
|
76
|
+
workflowId: step.workflowId,
|
|
77
|
+
...(workflowStep.expiresAfterMs === undefined
|
|
78
|
+
? {}
|
|
79
|
+
: { expiresAfterMs: workflowStep.expiresAfterMs }),
|
|
80
|
+
},
|
|
81
|
+
{ name: `enter:${workflowStep.key}` },
|
|
82
|
+
)
|
|
83
|
+
const event = await step.awaitEvent({
|
|
84
|
+
name: approvalDecisionEventName(run._id, workflowStep.key),
|
|
85
|
+
validator: v.object({
|
|
86
|
+
outcome: v.union(
|
|
87
|
+
v.literal(APPROVAL_DECISIONS[0]),
|
|
88
|
+
v.literal(APPROVAL_DECISIONS[1]),
|
|
89
|
+
v.literal(APPROVAL_RUN_STATES[3]),
|
|
90
|
+
),
|
|
91
|
+
terminalEvidence: v.optional(
|
|
92
|
+
v.object({
|
|
93
|
+
actorRef: v.string(),
|
|
94
|
+
decision: v.union(
|
|
95
|
+
v.literal(APPROVAL_DECISIONS[0]),
|
|
96
|
+
v.literal(APPROVAL_DECISIONS[1]),
|
|
97
|
+
),
|
|
98
|
+
reason: v.optional(v.string()),
|
|
99
|
+
decidedAt: v.number(),
|
|
100
|
+
}),
|
|
101
|
+
),
|
|
102
|
+
}),
|
|
103
|
+
})
|
|
104
|
+
lastDecision = event.outcome
|
|
105
|
+
terminalEvidence = event.terminalEvidence
|
|
106
|
+
lastDecisionStepKey = workflowStep.key
|
|
107
|
+
if (
|
|
108
|
+
lastDecision !== 'approved' &&
|
|
109
|
+
run.workflow.steps[index + 1]?.kind !== 'branch'
|
|
110
|
+
)
|
|
111
|
+
break
|
|
112
|
+
index += 1
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
if (!lastDecision)
|
|
116
|
+
throw new Error('Branch requires a preceding decision step')
|
|
117
|
+
const targetKey =
|
|
118
|
+
lastDecision === 'approved'
|
|
119
|
+
? workflowStep.approvedStepKey
|
|
120
|
+
: workflowStep.rejectedStepKey
|
|
121
|
+
const targetIndex = resolveWorkflowStepIndex(stepKeys, targetKey)
|
|
122
|
+
const target = run.workflow.steps[targetIndex]
|
|
123
|
+
if (
|
|
124
|
+
target.kind !== 'mutation' &&
|
|
125
|
+
target.kind !== 'action' &&
|
|
126
|
+
target.kind !== 'notify'
|
|
127
|
+
)
|
|
128
|
+
throw new Error('Approval branch target must be a callback step')
|
|
129
|
+
const evidence = await step.runQuery(
|
|
130
|
+
internal.decisions.listForWorkflow,
|
|
131
|
+
{ runId: run._id, stepKey: lastDecisionStepKey ?? workflowStep.key },
|
|
132
|
+
{ name: `evidence:${workflowStep.key}` },
|
|
133
|
+
)
|
|
134
|
+
failedStepKey = target.key
|
|
135
|
+
await executeCallback(step, target, {
|
|
136
|
+
...callbackInput,
|
|
137
|
+
decision: {
|
|
138
|
+
stepKey: lastDecisionStepKey ?? workflowStep.key,
|
|
139
|
+
outcome: lastDecision,
|
|
140
|
+
evidence,
|
|
141
|
+
...(terminalEvidence === undefined ? {} : { terminalEvidence }),
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
failedStepKey = undefined
|
|
145
|
+
if (lastDecision === 'rejected' || lastDecision === 'expired') break
|
|
146
|
+
index += 1
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await step.runMutation(
|
|
151
|
+
internal.requests.recordExecutionTerminal,
|
|
152
|
+
{ runId: run._id, outcome: 'completed' },
|
|
153
|
+
{ name: 'recordExecutionCompleted' },
|
|
154
|
+
)
|
|
155
|
+
return null
|
|
156
|
+
} catch (error) {
|
|
157
|
+
await step.runMutation(
|
|
158
|
+
internal.requests.recordExecutionTerminal,
|
|
159
|
+
{
|
|
160
|
+
runId: input.runId,
|
|
161
|
+
outcome: 'failed',
|
|
162
|
+
...(failedStepKey === undefined ? {} : { failedStepKey }),
|
|
163
|
+
},
|
|
164
|
+
{ name: 'recordExecutionFailed' },
|
|
165
|
+
)
|
|
166
|
+
throw error
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
type CallbackStep = Extract<
|
|
172
|
+
Doc<'approvalRuns'>['workflow']['steps'][number],
|
|
173
|
+
{ kind: 'mutation' | 'action' | 'notify' }
|
|
174
|
+
>
|
|
175
|
+
|
|
176
|
+
function buildCallbackInput(run: Doc<'approvalRuns'>): ApprovalCallbackInput {
|
|
177
|
+
return {
|
|
178
|
+
runId: run._id,
|
|
179
|
+
scopeRef: run.scopeRef,
|
|
180
|
+
resourceType: run.resourceType,
|
|
181
|
+
resourceRef: run.resourceRef,
|
|
182
|
+
...(run.metadata === undefined ? {} : { metadata: run.metadata }),
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function executeCallback(
|
|
187
|
+
step: WorkflowCtx,
|
|
188
|
+
workflowStep: CallbackStep,
|
|
189
|
+
input: ApprovalCallbackInput,
|
|
190
|
+
): Promise<void> {
|
|
191
|
+
if (workflowStep.callback.kind === 'mutation') {
|
|
192
|
+
await step.runMutation(
|
|
193
|
+
callbackReference<'mutation'>(workflowStep.callback.handle),
|
|
194
|
+
input,
|
|
195
|
+
{ name: workflowStep.key },
|
|
196
|
+
)
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
await step.runAction(
|
|
200
|
+
callbackReference<'action'>(workflowStep.callback.handle),
|
|
201
|
+
input,
|
|
202
|
+
{ name: workflowStep.key, retry: workflowStep.callback.retry },
|
|
203
|
+
)
|
|
204
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createFunctionHandle,
|
|
3
|
+
type FunctionReference,
|
|
4
|
+
type FunctionType,
|
|
5
|
+
} from 'convex/server'
|
|
6
|
+
|
|
7
|
+
import type { ApprovalDecision } from './constants'
|
|
8
|
+
import {
|
|
9
|
+
approvalWorkflowDescriptor,
|
|
10
|
+
type ApprovalWorkflowDescriptor,
|
|
11
|
+
} from './validators'
|
|
12
|
+
|
|
13
|
+
export type ApprovalMutationStep = Readonly<{
|
|
14
|
+
kind: 'mutation'
|
|
15
|
+
key: string
|
|
16
|
+
handler: FunctionReference<'mutation', 'internal'>
|
|
17
|
+
retry: boolean
|
|
18
|
+
}>
|
|
19
|
+
|
|
20
|
+
export type ApprovalActionStep = Readonly<{
|
|
21
|
+
kind: 'action'
|
|
22
|
+
key: string
|
|
23
|
+
handler: FunctionReference<'action', 'internal'>
|
|
24
|
+
retry: boolean
|
|
25
|
+
}>
|
|
26
|
+
|
|
27
|
+
export type ApprovalNotifyStep = Readonly<{
|
|
28
|
+
kind: 'notify'
|
|
29
|
+
key: string
|
|
30
|
+
handler: FunctionReference<'action', 'internal'>
|
|
31
|
+
retry: boolean
|
|
32
|
+
}>
|
|
33
|
+
|
|
34
|
+
export type ApprovalDecisionStep = Readonly<{
|
|
35
|
+
kind: 'decision'
|
|
36
|
+
key: string
|
|
37
|
+
decisions: readonly ApprovalDecision[]
|
|
38
|
+
quorum: Readonly<{ kind: 'count'; approvals: number }>
|
|
39
|
+
makerChecker: boolean
|
|
40
|
+
expiresAfterMs?: number
|
|
41
|
+
}>
|
|
42
|
+
|
|
43
|
+
export type ApprovalBranchStep = Readonly<{
|
|
44
|
+
kind: 'branch'
|
|
45
|
+
key: string
|
|
46
|
+
approvedStepKey: string
|
|
47
|
+
rejectedStepKey: string
|
|
48
|
+
}>
|
|
49
|
+
|
|
50
|
+
export type ApprovalWorkflowStep =
|
|
51
|
+
| ApprovalMutationStep
|
|
52
|
+
| ApprovalActionStep
|
|
53
|
+
| ApprovalNotifyStep
|
|
54
|
+
| ApprovalDecisionStep
|
|
55
|
+
| ApprovalBranchStep
|
|
56
|
+
|
|
57
|
+
export type ApprovalDefinition = Readonly<{
|
|
58
|
+
name: string
|
|
59
|
+
compatibilityKey: string
|
|
60
|
+
steps: readonly ApprovalWorkflowStep[]
|
|
61
|
+
}>
|
|
62
|
+
|
|
63
|
+
export type CreateApprovalHandle = (
|
|
64
|
+
reference: FunctionReference<'mutation' | 'action', 'internal'>,
|
|
65
|
+
) => Promise<string>
|
|
66
|
+
|
|
67
|
+
export type ApprovalDecisionEvidence = Readonly<{
|
|
68
|
+
actorRef: string
|
|
69
|
+
decision: ApprovalDecision
|
|
70
|
+
}>
|
|
71
|
+
|
|
72
|
+
export type ApprovalCallbackInput = Readonly<{
|
|
73
|
+
runId: string
|
|
74
|
+
scopeRef: string
|
|
75
|
+
resourceType: string
|
|
76
|
+
resourceRef: string
|
|
77
|
+
metadata?: Readonly<Record<string, string>>
|
|
78
|
+
decision?: Readonly<{
|
|
79
|
+
stepKey: string
|
|
80
|
+
outcome: ApprovalDecision | 'expired'
|
|
81
|
+
evidence: readonly Readonly<{
|
|
82
|
+
actorRef: string
|
|
83
|
+
decision: ApprovalDecision
|
|
84
|
+
reason?: string
|
|
85
|
+
decidedAt: number
|
|
86
|
+
}>[]
|
|
87
|
+
terminalEvidence?: Readonly<{
|
|
88
|
+
actorRef: string
|
|
89
|
+
decision: ApprovalDecision
|
|
90
|
+
reason?: string
|
|
91
|
+
decidedAt: number
|
|
92
|
+
}>
|
|
93
|
+
}>
|
|
94
|
+
}>
|
|
95
|
+
|
|
96
|
+
export function approvalDecisionEventName(
|
|
97
|
+
runId: string,
|
|
98
|
+
stepKey: string,
|
|
99
|
+
): string {
|
|
100
|
+
return `approval:${runId}:${stepKey}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function evaluateDecisionOutcome(
|
|
104
|
+
decisions: readonly ApprovalDecisionEvidence[],
|
|
105
|
+
approvalsRequired: number,
|
|
106
|
+
): ApprovalDecision | null {
|
|
107
|
+
if (decisions.some((decision) => decision.decision === 'rejected'))
|
|
108
|
+
return 'rejected'
|
|
109
|
+
return decisions.filter((decision) => decision.decision === 'approved')
|
|
110
|
+
.length >= approvalsRequired
|
|
111
|
+
? 'approved'
|
|
112
|
+
: null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function resolveWorkflowStepIndex(
|
|
116
|
+
stepKeys: readonly string[],
|
|
117
|
+
targetKey: string,
|
|
118
|
+
): number {
|
|
119
|
+
const index = stepKeys.indexOf(targetKey)
|
|
120
|
+
if (index === -1)
|
|
121
|
+
throw new Error(`Unknown approval workflow step: ${targetKey}`)
|
|
122
|
+
return index
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function callbackReference<Type extends FunctionType>(
|
|
126
|
+
handle: string,
|
|
127
|
+
): FunctionReference<Type, 'internal', ApprovalCallbackInput, unknown> {
|
|
128
|
+
return handle as unknown as FunctionReference<
|
|
129
|
+
Type,
|
|
130
|
+
'internal',
|
|
131
|
+
ApprovalCallbackInput,
|
|
132
|
+
unknown
|
|
133
|
+
>
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function compileApprovalDescriptor(
|
|
137
|
+
definition: ApprovalDefinition,
|
|
138
|
+
createHandle: CreateApprovalHandle = createFunctionHandle,
|
|
139
|
+
): Promise<Readonly<ApprovalWorkflowDescriptor>> {
|
|
140
|
+
const steps = await Promise.all(
|
|
141
|
+
definition.steps.map(async (step) => {
|
|
142
|
+
if (step.kind === 'mutation' || step.kind === 'action') {
|
|
143
|
+
return {
|
|
144
|
+
kind: step.kind,
|
|
145
|
+
key: step.key,
|
|
146
|
+
callback: {
|
|
147
|
+
kind: step.kind,
|
|
148
|
+
handle: await createHandle(step.handler),
|
|
149
|
+
retry: step.retry,
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (step.kind === 'notify') {
|
|
154
|
+
return {
|
|
155
|
+
kind: step.kind,
|
|
156
|
+
key: step.key,
|
|
157
|
+
callback: {
|
|
158
|
+
kind: 'action' as const,
|
|
159
|
+
handle: await createHandle(step.handler),
|
|
160
|
+
retry: step.retry,
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (step.kind === 'decision') {
|
|
165
|
+
return {
|
|
166
|
+
...step,
|
|
167
|
+
decisions: [...step.decisions],
|
|
168
|
+
quorum: { ...step.quorum },
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { ...step }
|
|
172
|
+
}),
|
|
173
|
+
)
|
|
174
|
+
const descriptor = approvalWorkflowDescriptor.parse({
|
|
175
|
+
schemaVersion: 1,
|
|
176
|
+
compatibilityKey: definition.compatibilityKey,
|
|
177
|
+
name: definition.name,
|
|
178
|
+
steps,
|
|
179
|
+
})
|
|
180
|
+
return deepFreeze(descriptor)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function deepFreeze<Value>(value: Value): Readonly<Value> {
|
|
184
|
+
if (value && typeof value === 'object') {
|
|
185
|
+
Object.freeze(value)
|
|
186
|
+
for (const nested of Object.values(value)) deepFreeze(nested)
|
|
187
|
+
}
|
|
188
|
+
return value
|
|
189
|
+
}
|