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.
Files changed (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +200 -0
  3. package/dist/_virtual/_rolldown/runtime.mjs +1 -0
  4. package/dist/auth.d.mts +353 -0
  5. package/dist/auth.mjs +2 -0
  6. package/dist/auth.mjs.map +1 -0
  7. package/dist/components/approvals/_generated/api.d.mts +47 -0
  8. package/dist/components/approvals/_generated/api.mjs +2 -0
  9. package/dist/components/approvals/_generated/api.mjs.map +1 -0
  10. package/dist/components/approvals/_generated/component.d.mts +297 -0
  11. package/dist/components/approvals/_generated/component.mjs +1 -0
  12. package/dist/components/approvals/_generated/dataModel.d.mts +41 -0
  13. package/dist/components/approvals/_generated/dataModel.mjs +1 -0
  14. package/dist/components/approvals/_generated/server.d.mts +116 -0
  15. package/dist/components/approvals/_generated/server.mjs +2 -0
  16. package/dist/components/approvals/_generated/server.mjs.map +1 -0
  17. package/dist/components/approvals/audit.d.mts +40 -0
  18. package/dist/components/approvals/audit.mjs +2 -0
  19. package/dist/components/approvals/audit.mjs.map +1 -0
  20. package/dist/components/approvals/client.d.mts +98 -0
  21. package/dist/components/approvals/client.mjs +2 -0
  22. package/dist/components/approvals/client.mjs.map +1 -0
  23. package/dist/components/approvals/constants.d.mts +31 -0
  24. package/dist/components/approvals/constants.mjs +2 -0
  25. package/dist/components/approvals/constants.mjs.map +1 -0
  26. package/dist/components/approvals/convex.config.d.mts +5 -0
  27. package/dist/components/approvals/convex.config.mjs +2 -0
  28. package/dist/components/approvals/convex.config.mjs.map +1 -0
  29. package/dist/components/approvals/decisions.d.mts +61 -0
  30. package/dist/components/approvals/decisions.mjs +2 -0
  31. package/dist/components/approvals/decisions.mjs.map +1 -0
  32. package/dist/components/approvals/functions.d.mts +9 -0
  33. package/dist/components/approvals/functions.mjs +2 -0
  34. package/dist/components/approvals/functions.mjs.map +1 -0
  35. package/dist/components/approvals/requests.d.mts +323 -0
  36. package/dist/components/approvals/requests.mjs +2 -0
  37. package/dist/components/approvals/requests.mjs.map +1 -0
  38. package/dist/components/approvals/schema.d.mts +376 -0
  39. package/dist/components/approvals/schema.mjs +2 -0
  40. package/dist/components/approvals/schema.mjs.map +1 -0
  41. package/dist/components/approvals/validators.d.mts +302 -0
  42. package/dist/components/approvals/validators.mjs +2 -0
  43. package/dist/components/approvals/validators.mjs.map +1 -0
  44. package/dist/components/approvals/workflow.d.mts +11 -0
  45. package/dist/components/approvals/workflow.mjs +2 -0
  46. package/dist/components/approvals/workflow.mjs.map +1 -0
  47. package/dist/components/approvals/workflow_steps.d.mts +83 -0
  48. package/dist/components/approvals/workflow_steps.mjs +2 -0
  49. package/dist/components/approvals/workflow_steps.mjs.map +1 -0
  50. package/dist/components/foundation/_generated/api.d.mts +40 -0
  51. package/dist/components/foundation/_generated/api.mjs +2 -0
  52. package/dist/components/foundation/_generated/api.mjs.map +1 -0
  53. package/dist/components/foundation/_generated/component.d.mts +21 -0
  54. package/dist/components/foundation/_generated/component.mjs +1 -0
  55. package/dist/components/foundation/_generated/dataModel.d.mts +41 -0
  56. package/dist/components/foundation/_generated/dataModel.mjs +1 -0
  57. package/dist/components/foundation/_generated/server.d.mts +116 -0
  58. package/dist/components/foundation/_generated/server.mjs +2 -0
  59. package/dist/components/foundation/_generated/server.mjs.map +1 -0
  60. package/dist/components/foundation/client.d.mts +93 -0
  61. package/dist/components/foundation/client.mjs +2 -0
  62. package/dist/components/foundation/client.mjs.map +1 -0
  63. package/dist/components/foundation/convex.config.d.mts +5 -0
  64. package/dist/components/foundation/convex.config.mjs +2 -0
  65. package/dist/components/foundation/convex.config.mjs.map +1 -0
  66. package/dist/components/foundation/functions.d.mts +8 -0
  67. package/dist/components/foundation/functions.mjs +2 -0
  68. package/dist/components/foundation/functions.mjs.map +1 -0
  69. package/dist/components/foundation/modules/command/command.d.mts +47 -0
  70. package/dist/components/foundation/modules/command/command.mjs +2 -0
  71. package/dist/components/foundation/modules/command/command.mjs.map +1 -0
  72. package/dist/components/foundation/modules/observability/observability.d.mts +31 -0
  73. package/dist/components/foundation/modules/observability/observability.mjs +2 -0
  74. package/dist/components/foundation/modules/observability/observability.mjs.map +1 -0
  75. package/dist/components/foundation/modules/query/query.d.mts +24 -0
  76. package/dist/components/foundation/modules/query/query.mjs +2 -0
  77. package/dist/components/foundation/modules/query/query.mjs.map +1 -0
  78. package/dist/components/foundation/result.d.mts +27 -0
  79. package/dist/components/foundation/result.mjs +2 -0
  80. package/dist/components/foundation/result.mjs.map +1 -0
  81. package/dist/components/foundation/schema.d.mts +6 -0
  82. package/dist/components/foundation/schema.mjs +2 -0
  83. package/dist/components/foundation/schema.mjs.map +1 -0
  84. package/dist/components/foundation/telemetry.d.mts +9 -0
  85. package/dist/components/foundation/telemetry.mjs +2 -0
  86. package/dist/components/foundation/telemetry.mjs.map +1 -0
  87. package/dist/errors.d.mts +24 -0
  88. package/dist/errors.mjs +2 -0
  89. package/dist/errors.mjs.map +1 -0
  90. package/dist/index.d.mts +6 -0
  91. package/dist/index.mjs +1 -0
  92. package/dist/triggers.d.mts +27 -0
  93. package/dist/triggers.mjs +2 -0
  94. package/dist/triggers.mjs.map +1 -0
  95. package/dist/zod-table.d.mts +124 -0
  96. package/dist/zod-table.mjs +2 -0
  97. package/dist/zod-table.mjs.map +1 -0
  98. package/package.json +108 -0
  99. package/src/auth.ts +360 -0
  100. package/src/components/approvals/_generated/api.ts +69 -0
  101. package/src/components/approvals/_generated/component.ts +335 -0
  102. package/src/components/approvals/_generated/dataModel.ts +60 -0
  103. package/src/components/approvals/_generated/server.ts +156 -0
  104. package/src/components/approvals/audit.ts +148 -0
  105. package/src/components/approvals/client.ts +321 -0
  106. package/src/components/approvals/constants.ts +51 -0
  107. package/src/components/approvals/convex.config.ts +10 -0
  108. package/src/components/approvals/decisions.ts +376 -0
  109. package/src/components/approvals/functions.ts +27 -0
  110. package/src/components/approvals/requests.ts +240 -0
  111. package/src/components/approvals/schema.ts +23 -0
  112. package/src/components/approvals/validators.ts +272 -0
  113. package/src/components/approvals/workflow.ts +204 -0
  114. package/src/components/approvals/workflow_steps.ts +189 -0
  115. package/src/components/foundation/_generated/api.ts +62 -0
  116. package/src/components/foundation/_generated/component.ts +29 -0
  117. package/src/components/foundation/_generated/dataModel.ts +60 -0
  118. package/src/components/foundation/_generated/server.ts +156 -0
  119. package/src/components/foundation/client.ts +190 -0
  120. package/src/components/foundation/convex.config.ts +3 -0
  121. package/src/components/foundation/functions.ts +10 -0
  122. package/src/components/foundation/modules/command/__tests__/command.test.ts +106 -0
  123. package/src/components/foundation/modules/command/command.ts +154 -0
  124. package/src/components/foundation/modules/observability/__tests__/observability.test.ts +79 -0
  125. package/src/components/foundation/modules/observability/observability.ts +95 -0
  126. package/src/components/foundation/modules/query/__tests__/query.test.ts +25 -0
  127. package/src/components/foundation/modules/query/query.ts +47 -0
  128. package/src/components/foundation/result.ts +77 -0
  129. package/src/components/foundation/schema.ts +4 -0
  130. package/src/components/foundation/telemetry.ts +23 -0
  131. package/src/errors.ts +25 -0
  132. package/src/index.ts +41 -0
  133. package/src/test.ts +36 -0
  134. package/src/triggers.ts +90 -0
  135. 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
+ }