convex-effect-workflows 0.1.0

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 (190) hide show
  1. package/README.md +107 -0
  2. package/dist/client/ConvexCtx.d.ts +12 -0
  3. package/dist/client/ConvexCtx.d.ts.map +1 -0
  4. package/dist/client/ConvexCtx.js +6 -0
  5. package/dist/client/ConvexCtx.js.map +1 -0
  6. package/dist/client/ConvexLogger.d.ts +7 -0
  7. package/dist/client/ConvexLogger.d.ts.map +1 -0
  8. package/dist/client/ConvexLogger.js +39 -0
  9. package/dist/client/ConvexLogger.js.map +1 -0
  10. package/dist/client/ConvexTracer.d.ts +7 -0
  11. package/dist/client/ConvexTracer.d.ts.map +1 -0
  12. package/dist/client/ConvexTracer.js +60 -0
  13. package/dist/client/ConvexTracer.js.map +1 -0
  14. package/dist/client/ConvexWorkflowEngine.d.ts +308 -0
  15. package/dist/client/ConvexWorkflowEngine.d.ts.map +1 -0
  16. package/dist/client/ConvexWorkflowEngine.js +88 -0
  17. package/dist/client/ConvexWorkflowEngine.js.map +1 -0
  18. package/dist/client/activityWorker.d.ts +23 -0
  19. package/dist/client/activityWorker.d.ts.map +1 -0
  20. package/dist/client/activityWorker.js +41 -0
  21. package/dist/client/activityWorker.js.map +1 -0
  22. package/dist/client/boundaries.d.ts +27 -0
  23. package/dist/client/boundaries.d.ts.map +1 -0
  24. package/dist/client/boundaries.js +17 -0
  25. package/dist/client/boundaries.js.map +1 -0
  26. package/dist/client/encoded.d.ts +22 -0
  27. package/dist/client/encoded.d.ts.map +1 -0
  28. package/dist/client/encoded.js +276 -0
  29. package/dist/client/encoded.js.map +1 -0
  30. package/dist/client/index.d.ts +13 -0
  31. package/dist/client/index.d.ts.map +1 -0
  32. package/dist/client/index.js +11 -0
  33. package/dist/client/index.js.map +1 -0
  34. package/dist/client/registry.d.ts +17 -0
  35. package/dist/client/registry.d.ts.map +1 -0
  36. package/dist/client/registry.js +21 -0
  37. package/dist/client/registry.js.map +1 -0
  38. package/dist/client/runner.d.ts +27 -0
  39. package/dist/client/runner.d.ts.map +1 -0
  40. package/dist/client/runner.js +90 -0
  41. package/dist/client/runner.js.map +1 -0
  42. package/dist/client/runtime.d.ts +10 -0
  43. package/dist/client/runtime.d.ts.map +1 -0
  44. package/dist/client/runtime.js +15 -0
  45. package/dist/client/runtime.js.map +1 -0
  46. package/dist/component/_generated/api.d.ts +148 -0
  47. package/dist/component/_generated/api.d.ts.map +1 -0
  48. package/dist/component/_generated/api.js +31 -0
  49. package/dist/component/_generated/api.js.map +1 -0
  50. package/dist/component/_generated/component.d.ts +921 -0
  51. package/dist/component/_generated/component.d.ts.map +1 -0
  52. package/dist/component/_generated/component.js +11 -0
  53. package/dist/component/_generated/component.js.map +1 -0
  54. package/dist/component/_generated/dataModel.d.ts +46 -0
  55. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  56. package/dist/component/_generated/dataModel.js +11 -0
  57. package/dist/component/_generated/dataModel.js.map +1 -0
  58. package/dist/component/_generated/server.d.ts +121 -0
  59. package/dist/component/_generated/server.d.ts.map +1 -0
  60. package/dist/component/_generated/server.js +78 -0
  61. package/dist/component/_generated/server.js.map +1 -0
  62. package/dist/component/activityCompletions.d.ts +27 -0
  63. package/dist/component/activityCompletions.d.ts.map +1 -0
  64. package/dist/component/activityCompletions.js +70 -0
  65. package/dist/component/activityCompletions.js.map +1 -0
  66. package/dist/component/boundaries.d.ts +20 -0
  67. package/dist/component/boundaries.d.ts.map +1 -0
  68. package/dist/component/boundaries.js +17 -0
  69. package/dist/component/boundaries.js.map +1 -0
  70. package/dist/component/cleanup.d.ts +11 -0
  71. package/dist/component/cleanup.d.ts.map +1 -0
  72. package/dist/component/cleanup.js +163 -0
  73. package/dist/component/cleanup.js.map +1 -0
  74. package/dist/component/clocks.d.ts +12 -0
  75. package/dist/component/clocks.d.ts.map +1 -0
  76. package/dist/component/clocks.js +26 -0
  77. package/dist/component/clocks.js.map +1 -0
  78. package/dist/component/config.d.ts +25 -0
  79. package/dist/component/config.d.ts.map +1 -0
  80. package/dist/component/config.js +110 -0
  81. package/dist/component/config.js.map +1 -0
  82. package/dist/component/convex.config.d.ts +3 -0
  83. package/dist/component/convex.config.d.ts.map +1 -0
  84. package/dist/component/convex.config.js +6 -0
  85. package/dist/component/convex.config.js.map +1 -0
  86. package/dist/component/dashboard.d.ts +268 -0
  87. package/dist/component/dashboard.d.ts.map +1 -0
  88. package/dist/component/dashboard.js +622 -0
  89. package/dist/component/dashboard.js.map +1 -0
  90. package/dist/component/deferreds.d.ts +31 -0
  91. package/dist/component/deferreds.d.ts.map +1 -0
  92. package/dist/component/deferreds.js +138 -0
  93. package/dist/component/deferreds.js.map +1 -0
  94. package/dist/component/executions.d.ts +77 -0
  95. package/dist/component/executions.d.ts.map +1 -0
  96. package/dist/component/executions.js +186 -0
  97. package/dist/component/executions.js.map +1 -0
  98. package/dist/component/journalSteps.d.ts +261 -0
  99. package/dist/component/journalSteps.d.ts.map +1 -0
  100. package/dist/component/journalSteps.js +203 -0
  101. package/dist/component/journalSteps.js.map +1 -0
  102. package/dist/component/logs.d.ts +68 -0
  103. package/dist/component/logs.d.ts.map +1 -0
  104. package/dist/component/logs.js +123 -0
  105. package/dist/component/logs.js.map +1 -0
  106. package/dist/component/onComplete.d.ts +31 -0
  107. package/dist/component/onComplete.d.ts.map +1 -0
  108. package/dist/component/onComplete.js +146 -0
  109. package/dist/component/onComplete.js.map +1 -0
  110. package/dist/component/payloads.d.ts +26 -0
  111. package/dist/component/payloads.d.ts.map +1 -0
  112. package/dist/component/payloads.js +57 -0
  113. package/dist/component/payloads.js.map +1 -0
  114. package/dist/component/queries.d.ts +2 -0
  115. package/dist/component/queries.d.ts.map +1 -0
  116. package/dist/component/queries.js +2 -0
  117. package/dist/component/queries.js.map +1 -0
  118. package/dist/component/runner.d.ts +31 -0
  119. package/dist/component/runner.d.ts.map +1 -0
  120. package/dist/component/runner.js +87 -0
  121. package/dist/component/runner.js.map +1 -0
  122. package/dist/component/schema.d.ts +282 -0
  123. package/dist/component/schema.d.ts.map +1 -0
  124. package/dist/component/schema.js +119 -0
  125. package/dist/component/schema.js.map +1 -0
  126. package/dist/component/spans.d.ts +105 -0
  127. package/dist/component/spans.d.ts.map +1 -0
  128. package/dist/component/spans.js +190 -0
  129. package/dist/component/spans.js.map +1 -0
  130. package/dist/component/utils.d.ts +15 -0
  131. package/dist/component/utils.d.ts.map +1 -0
  132. package/dist/component/utils.js +53 -0
  133. package/dist/component/utils.js.map +1 -0
  134. package/dist/shared/constants.d.ts +12 -0
  135. package/dist/shared/constants.d.ts.map +1 -0
  136. package/dist/shared/constants.js +12 -0
  137. package/dist/shared/constants.js.map +1 -0
  138. package/dist/shared/validators.d.ts +69 -0
  139. package/dist/shared/validators.d.ts.map +1 -0
  140. package/dist/shared/validators.js +30 -0
  141. package/dist/shared/validators.js.map +1 -0
  142. package/package.json +74 -0
  143. package/src/client/ConvexCtx.ts +21 -0
  144. package/src/client/ConvexLogger.ts +52 -0
  145. package/src/client/ConvexTracer.ts +75 -0
  146. package/src/client/ConvexWorkflowEngine.test.ts +124 -0
  147. package/src/client/ConvexWorkflowEngine.ts +209 -0
  148. package/src/client/activityWorker.ts +62 -0
  149. package/src/client/boundaries.test.ts +83 -0
  150. package/src/client/boundaries.ts +79 -0
  151. package/src/client/encoded.lifecycle.test.ts +336 -0
  152. package/src/client/encoded.test.ts +153 -0
  153. package/src/client/encoded.ts +484 -0
  154. package/src/client/index.ts +47 -0
  155. package/src/client/registry.ts +35 -0
  156. package/src/client/runner.ts +165 -0
  157. package/src/client/runtime.ts +30 -0
  158. package/src/component/_generated/api.ts +179 -0
  159. package/src/component/_generated/component.ts +1216 -0
  160. package/src/component/_generated/dataModel.ts +60 -0
  161. package/src/component/_generated/server.ts +156 -0
  162. package/src/component/activityCompletions.ts +73 -0
  163. package/src/component/boundaries.ts +55 -0
  164. package/src/component/cleanup.test.ts +219 -0
  165. package/src/component/cleanup.ts +218 -0
  166. package/src/component/clocks.ts +26 -0
  167. package/src/component/config.test.ts +159 -0
  168. package/src/component/config.ts +145 -0
  169. package/src/component/convex.config.ts +7 -0
  170. package/src/component/core.test.ts +829 -0
  171. package/src/component/dashboard.scaling.test.ts +268 -0
  172. package/src/component/dashboard.ts +743 -0
  173. package/src/component/deferreds.ts +162 -0
  174. package/src/component/executions.ts +225 -0
  175. package/src/component/journalSteps.ts +252 -0
  176. package/src/component/logs.ts +152 -0
  177. package/src/component/onComplete.ts +170 -0
  178. package/src/component/payloads.ts +83 -0
  179. package/src/component/queries.ts +8 -0
  180. package/src/component/runner.ts +122 -0
  181. package/src/component/schema.ts +155 -0
  182. package/src/component/setup.test.ts +15 -0
  183. package/src/component/spans.ts +241 -0
  184. package/src/component/utils.test.ts +32 -0
  185. package/src/component/utils.ts +73 -0
  186. package/src/shared/constants.test.ts +14 -0
  187. package/src/shared/constants.ts +15 -0
  188. package/src/shared/validators.ts +98 -0
  189. package/src/test.d.ts +8 -0
  190. package/src/test.ts +17 -0
@@ -0,0 +1,829 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ import { describe, expect, test, vi } from "vitest";
4
+ import { api } from "./_generated/api.js";
5
+ import { initConvexTest } from "./setup.test.js";
6
+
7
+ import { MAX_INLINE_PAYLOAD_BYTES } from "../shared/constants.js";
8
+
9
+ describe("component core invariants", () => {
10
+ test("createExecution is idempotent by executionId", async () => {
11
+ const t = initConvexTest();
12
+
13
+ const first = await t.mutation(api.executions.createExecution, {
14
+ workflowName: "testWorkflow",
15
+ executionId: "exec-1",
16
+ payload: { a: 1 },
17
+ });
18
+
19
+ const second = await t.mutation(api.executions.createExecution, {
20
+ workflowName: "testWorkflow",
21
+ executionId: "exec-1",
22
+ payload: { a: 2 },
23
+ });
24
+
25
+ expect(first.created).toBe(true);
26
+ expect(second.created).toBe(false);
27
+ expect(first.executionId).toBe(second.executionId);
28
+ });
29
+
30
+ test("journal step enforces unique (executionId, stepNumber)", async () => {
31
+ const t = initConvexTest();
32
+
33
+ await t.mutation(api.executions.createExecution, {
34
+ workflowName: "testWorkflow",
35
+ executionId: "exec-2",
36
+ payload: {},
37
+ });
38
+
39
+ await t.mutation(api.journalSteps.appendJournalStep, {
40
+ executionId: "exec-2",
41
+ stepNumber: 0,
42
+ kind: "activity",
43
+ name: "stepA",
44
+ signature: {
45
+ kind: "activity",
46
+ opName: "stepA",
47
+ },
48
+ input: { x: 1 },
49
+ });
50
+
51
+ await expect(
52
+ t.mutation(api.journalSteps.appendJournalStep, {
53
+ executionId: "exec-2",
54
+ stepNumber: 0,
55
+ kind: "activity",
56
+ name: "stepA",
57
+ signature: {
58
+ kind: "activity",
59
+ opName: "stepA",
60
+ },
61
+ input: { x: 2 },
62
+ }),
63
+ ).rejects.toThrow(/Duplicate journal step/);
64
+ });
65
+
66
+ test("onComplete is idempotent for duplicate workId", async () => {
67
+ const t = initConvexTest();
68
+
69
+ await t.mutation(api.executions.createExecution, {
70
+ workflowName: "testWorkflow",
71
+ executionId: "exec-3",
72
+ payload: {},
73
+ });
74
+
75
+ await t.mutation(api.journalSteps.appendJournalStep, {
76
+ executionId: "exec-3",
77
+ stepNumber: 0,
78
+ kind: "activity",
79
+ name: "activityA",
80
+ signature: {
81
+ kind: "activity",
82
+ opName: "activityA",
83
+ },
84
+ input: null,
85
+ });
86
+
87
+ await t.mutation(api.journalSteps.suspendJournalStep, {
88
+ executionId: "exec-3",
89
+ stepNumber: 0,
90
+ workId: "work-1",
91
+ });
92
+
93
+ const first = await t.mutation(api.onComplete.handleActivityCompletion, {
94
+ workId: "work-1",
95
+ context: {
96
+ executionId: "exec-3",
97
+ stepNumber: 0,
98
+ generation: 0,
99
+ },
100
+ result: {
101
+ kind: "success",
102
+ returnValue: { ok: true },
103
+ },
104
+ });
105
+
106
+ const second = await t.mutation(api.onComplete.handleActivityCompletion, {
107
+ workId: "work-1",
108
+ context: {
109
+ executionId: "exec-3",
110
+ stepNumber: 0,
111
+ generation: 0,
112
+ },
113
+ result: {
114
+ kind: "success",
115
+ returnValue: { ok: true },
116
+ },
117
+ });
118
+
119
+ expect(first.processed).toBe(true);
120
+ expect(second.processed).toBe(false);
121
+ });
122
+
123
+ test("stale generation callbacks are ignored", async () => {
124
+ const t = initConvexTest();
125
+
126
+ await t.mutation(api.executions.createExecution, {
127
+ workflowName: "testWorkflow",
128
+ executionId: "exec-stale",
129
+ payload: {},
130
+ });
131
+
132
+ await t.mutation(api.journalSteps.appendJournalStep, {
133
+ executionId: "exec-stale",
134
+ stepNumber: 0,
135
+ kind: "activity",
136
+ name: "activityA",
137
+ signature: {
138
+ kind: "activity",
139
+ opName: "activityA",
140
+ },
141
+ input: null,
142
+ });
143
+
144
+ await t.mutation(api.journalSteps.suspendJournalStep, {
145
+ executionId: "exec-stale",
146
+ stepNumber: 0,
147
+ workId: "work-stale",
148
+ });
149
+
150
+ await t.mutation(api.executions.interruptExecution, {
151
+ executionId: "exec-stale",
152
+ reason: "manual",
153
+ });
154
+
155
+ const result = await t.mutation(api.onComplete.handleActivityCompletion, {
156
+ workId: "work-stale",
157
+ context: {
158
+ executionId: "exec-stale",
159
+ stepNumber: 0,
160
+ generation: 0,
161
+ },
162
+ result: {
163
+ kind: "success",
164
+ returnValue: { ok: true },
165
+ },
166
+ });
167
+
168
+ const step = await t.query(api.journalSteps.getJournalStep, {
169
+ executionId: "exec-stale",
170
+ stepNumber: 0,
171
+ });
172
+
173
+ expect(result.stale).toBe(true);
174
+ expect(step).toBeTruthy();
175
+ expect(step!.state).toBe("suspended");
176
+ });
177
+
178
+ test("interruptExecution is a no-op for terminal executions", async () => {
179
+ const t = initConvexTest();
180
+
181
+ await t.mutation(api.executions.createExecution, {
182
+ workflowName: "testWorkflow",
183
+ executionId: "exec-interrupt-completed",
184
+ payload: {},
185
+ });
186
+
187
+ await t.mutation(api.executions.completeExecution, {
188
+ executionId: "exec-interrupt-completed",
189
+ generation: 0,
190
+ kind: "success",
191
+ result: { ok: true },
192
+ });
193
+
194
+ const beforeCompleted = await t.query(api.executions.getExecution, {
195
+ executionId: "exec-interrupt-completed",
196
+ });
197
+
198
+ const completedInterrupt = await t.mutation(api.executions.interruptExecution, {
199
+ executionId: "exec-interrupt-completed",
200
+ reason: "late-interrupt",
201
+ });
202
+
203
+ const afterCompleted = await t.query(api.executions.getExecution, {
204
+ executionId: "exec-interrupt-completed",
205
+ });
206
+
207
+ expect(beforeCompleted).toBeTruthy();
208
+ expect(afterCompleted).toBeTruthy();
209
+ expect(completedInterrupt.generation).toBe(beforeCompleted!.generation);
210
+ expect(afterCompleted!.status).toBe("completed");
211
+ expect(afterCompleted!.generation).toBe(beforeCompleted!.generation);
212
+ expect(afterCompleted!.completedAt).toBe(beforeCompleted!.completedAt);
213
+ expect(afterCompleted!.error).toBe(beforeCompleted!.error);
214
+
215
+ await t.mutation(api.executions.createExecution, {
216
+ workflowName: "testWorkflow",
217
+ executionId: "exec-interrupt-interrupted",
218
+ payload: {},
219
+ });
220
+
221
+ const firstInterrupt = await t.mutation(api.executions.interruptExecution, {
222
+ executionId: "exec-interrupt-interrupted",
223
+ reason: "first",
224
+ });
225
+
226
+ const beforeSecondInterrupt = await t.query(api.executions.getExecution, {
227
+ executionId: "exec-interrupt-interrupted",
228
+ });
229
+
230
+ const secondInterrupt = await t.mutation(api.executions.interruptExecution, {
231
+ executionId: "exec-interrupt-interrupted",
232
+ reason: "second",
233
+ });
234
+
235
+ const afterSecondInterrupt = await t.query(api.executions.getExecution, {
236
+ executionId: "exec-interrupt-interrupted",
237
+ });
238
+
239
+ expect(firstInterrupt.generation).toBe(1);
240
+ expect(beforeSecondInterrupt).toBeTruthy();
241
+ expect(afterSecondInterrupt).toBeTruthy();
242
+ expect(secondInterrupt.generation).toBe(beforeSecondInterrupt!.generation);
243
+ expect(afterSecondInterrupt!.status).toBe("interrupted");
244
+ expect(afterSecondInterrupt!.generation).toBe(beforeSecondInterrupt!.generation);
245
+ expect(afterSecondInterrupt!.completedAt).toBe(beforeSecondInterrupt!.completedAt);
246
+ expect(afterSecondInterrupt!.error).toBe(beforeSecondInterrupt!.error);
247
+ });
248
+
249
+ test("large execution payload is offloaded", async () => {
250
+ const t = initConvexTest();
251
+ const huge = "x".repeat(MAX_INLINE_PAYLOAD_BYTES + 2048);
252
+
253
+ await t.mutation(api.executions.createExecution, {
254
+ workflowName: "testWorkflow",
255
+ executionId: "exec-large",
256
+ payload: { huge },
257
+ });
258
+
259
+ const execution = await t.query(api.executions.getExecution, {
260
+ executionId: "exec-large",
261
+ });
262
+
263
+ expect(execution).toBeTruthy();
264
+ expect(execution!.payloadRef).toBeDefined();
265
+ expect(execution!.payloadSize).toBeGreaterThan(MAX_INLINE_PAYLOAD_BYTES);
266
+ });
267
+
268
+ test("retry attempts record distinct journal rows and spans", async () => {
269
+ const t = initConvexTest();
270
+
271
+ await t.mutation(api.executions.createExecution, {
272
+ workflowName: "testWorkflow",
273
+ executionId: "exec-retry",
274
+ payload: {},
275
+ });
276
+
277
+ const execution = await t.query(api.executions.getExecution, {
278
+ executionId: "exec-retry",
279
+ });
280
+ expect(execution).toBeTruthy();
281
+
282
+ await t.mutation(api.journalSteps.appendJournalStep, {
283
+ executionId: "exec-retry",
284
+ stepNumber: 0,
285
+ kind: "activity",
286
+ name: "chargeCard",
287
+ attempt: 1,
288
+ signature: {
289
+ kind: "activity",
290
+ opName: "chargeCard",
291
+ attempt: 1,
292
+ },
293
+ input: { amount: 100 },
294
+ });
295
+
296
+ await t.mutation(api.spans.createSpan, {
297
+ executionId: "exec-retry",
298
+ traceId: execution!.traceId,
299
+ name: "chargeCard",
300
+ kind: "activity",
301
+ stepNumber: 0,
302
+ attempt: 1,
303
+ });
304
+
305
+ await t.mutation(api.journalSteps.appendJournalStep, {
306
+ executionId: "exec-retry",
307
+ stepNumber: 1,
308
+ kind: "activity",
309
+ name: "chargeCard",
310
+ attempt: 2,
311
+ signature: {
312
+ kind: "activity",
313
+ opName: "chargeCard",
314
+ attempt: 2,
315
+ },
316
+ input: { amount: 100 },
317
+ });
318
+
319
+ await t.mutation(api.spans.createSpan, {
320
+ executionId: "exec-retry",
321
+ traceId: execution!.traceId,
322
+ name: "chargeCard",
323
+ kind: "activity",
324
+ stepNumber: 1,
325
+ attempt: 2,
326
+ });
327
+
328
+ const steps = await t.query(api.journalSteps.listJournalSteps, {
329
+ executionId: "exec-retry",
330
+ limit: 10,
331
+ });
332
+ const spans = await t.query(api.spans.listExecutionSpans, {
333
+ executionId: "exec-retry",
334
+ limit: 10,
335
+ });
336
+
337
+ expect(steps.page).toHaveLength(2);
338
+ expect(steps.page.map((s: any) => s.attempt)).toEqual([1, 2]);
339
+ expect(spans.page).toHaveLength(2);
340
+ expect(spans.page.map((s: any) => s.attempt)).toEqual([1, 2]);
341
+ });
342
+
343
+ test("determinism mismatch is explicit", async () => {
344
+ const t = initConvexTest();
345
+
346
+ await t.mutation(api.executions.createExecution, {
347
+ workflowName: "testWorkflow",
348
+ executionId: "exec-determinism",
349
+ payload: {},
350
+ });
351
+
352
+ await t.mutation(api.journalSteps.appendJournalStep, {
353
+ executionId: "exec-determinism",
354
+ stepNumber: 0,
355
+ kind: "activity",
356
+ name: "activityA",
357
+ signature: {
358
+ kind: "activity",
359
+ opName: "activityA",
360
+ },
361
+ input: null,
362
+ });
363
+
364
+ await expect(
365
+ t.query(api.journalSteps.assertReplayStepSignature, {
366
+ executionId: "exec-determinism",
367
+ stepNumber: 0,
368
+ expectedSignature: {
369
+ kind: "activity",
370
+ opName: "activityA_changed",
371
+ },
372
+ }),
373
+ ).rejects.toThrow(/Determinism violation/);
374
+ });
375
+
376
+ test("deferred result transitions from unresolved to resolved once", async () => {
377
+ const t = initConvexTest();
378
+
379
+ await t.mutation(api.deferreds.registerDeferred, {
380
+ executionId: "exec-deferred",
381
+ deferredName: "waitForApproval",
382
+ });
383
+
384
+ const before = await t.query(api.deferreds.deferredResult, {
385
+ executionId: "exec-deferred",
386
+ deferredName: "waitForApproval",
387
+ });
388
+ expect(before).toBeNull();
389
+
390
+ const first = await t.mutation(api.deferreds.deferredDone, {
391
+ executionId: "exec-deferred",
392
+ deferredName: "waitForApproval",
393
+ exit: { ok: true },
394
+ });
395
+ const second = await t.mutation(api.deferreds.deferredDone, {
396
+ executionId: "exec-deferred",
397
+ deferredName: "waitForApproval",
398
+ exit: { ok: true },
399
+ });
400
+
401
+ const after = await t.query(api.deferreds.deferredResult, {
402
+ executionId: "exec-deferred",
403
+ deferredName: "waitForApproval",
404
+ });
405
+
406
+ expect(first.changed).toBe(true);
407
+ expect(second.changed).toBe(false);
408
+ expect(after).toEqual({ ok: true });
409
+ });
410
+
411
+ test("scheduled deferred wake resolves after timers advance", async () => {
412
+ vi.useFakeTimers();
413
+ try {
414
+ const t = initConvexTest();
415
+
416
+ await t.mutation(api.clocks.scheduleDeferredWake, {
417
+ executionId: "exec-clock",
418
+ deferredName: "clock/wake",
419
+ delayMs: 5_000,
420
+ exit: { woke: true },
421
+ });
422
+
423
+ const before = await t.query(api.deferreds.deferredResult, {
424
+ executionId: "exec-clock",
425
+ deferredName: "clock/wake",
426
+ });
427
+ expect(before).toBeNull();
428
+
429
+ await t.finishAllScheduledFunctions(() => {
430
+ vi.runAllTimers();
431
+ });
432
+
433
+ const after = await t.query(api.deferreds.deferredResult, {
434
+ executionId: "exec-clock",
435
+ deferredName: "clock/wake",
436
+ });
437
+ expect(after).toEqual({ woke: true });
438
+ } finally {
439
+ vi.useRealTimers();
440
+ }
441
+ });
442
+
443
+ test("execution metrics aggregate statuses and durations", async () => {
444
+ const t = initConvexTest();
445
+
446
+ await t.mutation(api.executions.createExecution, {
447
+ workflowName: "metricsWorkflow",
448
+ executionId: "exec-metrics-1",
449
+ payload: {},
450
+ });
451
+ await t.mutation(api.executions.createExecution, {
452
+ workflowName: "metricsWorkflow",
453
+ executionId: "exec-metrics-2",
454
+ payload: {},
455
+ });
456
+
457
+ await t.mutation(api.executions.completeExecution, {
458
+ executionId: "exec-metrics-1",
459
+ generation: 0,
460
+ kind: "success",
461
+ result: { ok: true },
462
+ });
463
+ await t.mutation(api.executions.completeExecution, {
464
+ executionId: "exec-metrics-2",
465
+ generation: 0,
466
+ kind: "failure",
467
+ error: "boom",
468
+ });
469
+
470
+ const metrics = await t.query(api.dashboard.executionMetrics, {
471
+ workflowName: "metricsWorkflow",
472
+ });
473
+
474
+ expect(metrics.total).toBe(2);
475
+ expect(metrics.completed).toBe(1);
476
+ expect(metrics.failed).toBe(1);
477
+ expect(metrics.successRate).toBe(0.5);
478
+ });
479
+
480
+ test("execution list applies status/tenant/time filters", async () => {
481
+ const t = initConvexTest();
482
+
483
+ await t.mutation(api.executions.createExecution, {
484
+ workflowName: "filterWorkflow",
485
+ executionId: "exec-filter-1",
486
+ payload: {},
487
+ tenantId: "tenant-a",
488
+ });
489
+ await t.mutation(api.executions.createExecution, {
490
+ workflowName: "filterWorkflow",
491
+ executionId: "exec-filter-2",
492
+ payload: {},
493
+ tenantId: "tenant-b",
494
+ });
495
+
496
+ await t.mutation(api.executions.completeExecution, {
497
+ executionId: "exec-filter-1",
498
+ generation: 0,
499
+ kind: "success",
500
+ result: { ok: true },
501
+ });
502
+ await t.mutation(api.executions.completeExecution, {
503
+ executionId: "exec-filter-2",
504
+ generation: 0,
505
+ kind: "failure",
506
+ error: "x",
507
+ });
508
+
509
+ const now = Date.now();
510
+
511
+ const byStatus = await t.query(api.dashboard.listExecutions, {
512
+ workflowName: "filterWorkflow",
513
+ status: "completed",
514
+ limit: 10,
515
+ });
516
+ expect(byStatus.page).toHaveLength(1);
517
+ expect(byStatus.page[0].executionId).toBe("exec-filter-1");
518
+
519
+ const byTenant = await t.query(api.dashboard.listExecutions, {
520
+ workflowName: "filterWorkflow",
521
+ tenantId: "tenant-b",
522
+ limit: 10,
523
+ });
524
+ expect(byTenant.page).toHaveLength(1);
525
+ expect(byTenant.page[0].executionId).toBe("exec-filter-2");
526
+
527
+ const byTime = await t.query(api.dashboard.listExecutions, {
528
+ workflowName: "filterWorkflow",
529
+ startedAfter: now - 60_000,
530
+ startedBefore: now + 60_000,
531
+ limit: 10,
532
+ });
533
+ expect(byTime.page.length).toBeGreaterThanOrEqual(2);
534
+ });
535
+
536
+ test("log list filters by level/source/span", async () => {
537
+ const t = initConvexTest();
538
+
539
+ await t.mutation(api.executions.createExecution, {
540
+ workflowName: "logWorkflow",
541
+ executionId: "exec-logs",
542
+ payload: {},
543
+ });
544
+
545
+ await t.mutation(api.logs.appendLog, {
546
+ executionId: "exec-logs",
547
+ level: "info",
548
+ source: "workflow",
549
+ message: "workflow started",
550
+ spanId: "span-1",
551
+ });
552
+ await t.mutation(api.logs.appendLog, {
553
+ executionId: "exec-logs",
554
+ level: "error",
555
+ source: "activity",
556
+ message: "activity failed",
557
+ spanId: "span-2",
558
+ });
559
+
560
+ const levelFiltered = await t.query(api.dashboard.listExecutionLogs, {
561
+ executionId: "exec-logs",
562
+ level: "error",
563
+ limit: 10,
564
+ });
565
+ expect(levelFiltered.page).toHaveLength(1);
566
+ expect(levelFiltered.page[0].message).toBe("activity failed");
567
+
568
+ const sourceFiltered = await t.query(api.dashboard.listExecutionLogs, {
569
+ executionId: "exec-logs",
570
+ source: "workflow",
571
+ limit: 10,
572
+ });
573
+ expect(sourceFiltered.page).toHaveLength(1);
574
+ expect(sourceFiltered.page[0].message).toBe("workflow started");
575
+
576
+ const spanFiltered = await t.query(api.dashboard.listExecutionLogs, {
577
+ executionId: "exec-logs",
578
+ spanId: "span-2",
579
+ limit: 10,
580
+ });
581
+ expect(spanFiltered.page).toHaveLength(1);
582
+ expect(spanFiltered.page[0].message).toBe("activity failed");
583
+ });
584
+
585
+ test("step list applies state filter with cursor progression", async () => {
586
+ const t = initConvexTest();
587
+
588
+ await t.mutation(api.executions.createExecution, {
589
+ workflowName: "stepsWorkflow",
590
+ executionId: "exec-steps-cursor",
591
+ payload: {},
592
+ });
593
+
594
+ for (let stepNumber = 0; stepNumber < 4; stepNumber++) {
595
+ await t.mutation(api.journalSteps.appendJournalStep, {
596
+ executionId: "exec-steps-cursor",
597
+ stepNumber,
598
+ kind: "activity",
599
+ name: `step-${stepNumber}`,
600
+ signature: {
601
+ kind: "activity",
602
+ opName: `step-${stepNumber}`,
603
+ },
604
+ input: {},
605
+ });
606
+ }
607
+
608
+ await t.mutation(api.journalSteps.completeJournalStep, {
609
+ executionId: "exec-steps-cursor",
610
+ stepNumber: 0,
611
+ runResult: { kind: "success", valuePreview: { ok: true } },
612
+ });
613
+ await t.mutation(api.journalSteps.completeJournalStep, {
614
+ executionId: "exec-steps-cursor",
615
+ stepNumber: 2,
616
+ runResult: { kind: "success", valuePreview: { ok: true } },
617
+ });
618
+
619
+ const firstPage = await t.query(api.dashboard.listExecutionSteps, {
620
+ executionId: "exec-steps-cursor",
621
+ state: "completed",
622
+ limit: 1,
623
+ });
624
+
625
+ expect(firstPage.page).toHaveLength(1);
626
+ expect(firstPage.page[0].stepNumber).toBe(0);
627
+ expect(firstPage.cursor).toBe(0);
628
+
629
+ expect(firstPage.cursor).not.toBeNull();
630
+
631
+ const secondPage = await t.query(api.dashboard.listExecutionSteps, {
632
+ executionId: "exec-steps-cursor",
633
+ state: "completed",
634
+ afterStepNumber: firstPage.cursor ?? undefined,
635
+ limit: 1,
636
+ });
637
+
638
+ expect(secondPage.page).toHaveLength(1);
639
+ expect(secondPage.page[0].stepNumber).toBe(2);
640
+ expect(secondPage.isDone).toBe(true);
641
+ });
642
+
643
+ test("span list keeps stable pagination when startTime collides", async () => {
644
+ vi.useFakeTimers();
645
+ try {
646
+ vi.setSystemTime(new Date("2026-01-03T00:00:00.000Z"));
647
+ const t = initConvexTest();
648
+
649
+ await t.mutation(api.executions.createExecution, {
650
+ workflowName: "spansWorkflow",
651
+ executionId: "exec-spans-cursor",
652
+ payload: {},
653
+ });
654
+
655
+ const execution = await t.query(api.executions.getExecution, {
656
+ executionId: "exec-spans-cursor",
657
+ });
658
+ expect(execution).toBeTruthy();
659
+
660
+ const activityA = await t.mutation(api.spans.createSpan, {
661
+ executionId: "exec-spans-cursor",
662
+ traceId: execution!.traceId,
663
+ name: "activity-a",
664
+ kind: "activity",
665
+ });
666
+ const activityB = await t.mutation(api.spans.createSpan, {
667
+ executionId: "exec-spans-cursor",
668
+ traceId: execution!.traceId,
669
+ name: "activity-b",
670
+ kind: "activity",
671
+ });
672
+ await t.mutation(api.spans.createSpan, {
673
+ executionId: "exec-spans-cursor",
674
+ traceId: execution!.traceId,
675
+ name: "workflow-root",
676
+ kind: "workflow",
677
+ });
678
+
679
+ const firstPage = await t.query(api.dashboard.listExecutionSpans, {
680
+ executionId: "exec-spans-cursor",
681
+ kind: "activity",
682
+ status: "started",
683
+ limit: 1,
684
+ });
685
+
686
+ expect(firstPage.page).toHaveLength(1);
687
+ expect(firstPage.cursor).toBeTruthy();
688
+
689
+ expect(firstPage.cursor).not.toBeNull();
690
+
691
+ const secondPage = await t.query(api.dashboard.listExecutionSpans, {
692
+ executionId: "exec-spans-cursor",
693
+ kind: "activity",
694
+ status: "started",
695
+ limit: 1,
696
+ afterStartTime: firstPage.cursor!.startTime,
697
+ afterSpanId: firstPage.cursor!.spanId,
698
+ });
699
+
700
+ expect(secondPage.page).toHaveLength(1);
701
+ expect([activityA.spanId, activityB.spanId]).toContain(secondPage.page[0].spanId);
702
+ expect(secondPage.page[0].spanId).not.toBe(firstPage.page[0].spanId);
703
+ expect(secondPage.isDone).toBe(true);
704
+ } finally {
705
+ vi.useRealTimers();
706
+ }
707
+ });
708
+
709
+ test("log list keeps stable cursor with duplicate timestamps", async () => {
710
+ const t = initConvexTest();
711
+
712
+ await t.mutation(api.executions.createExecution, {
713
+ workflowName: "logsWorkflow",
714
+ executionId: "exec-logs-cursor",
715
+ payload: {},
716
+ });
717
+
718
+ for (let i = 0; i < 3; i++) {
719
+ await t.mutation(api.logs.appendLog, {
720
+ executionId: "exec-logs-cursor",
721
+ level: "info",
722
+ source: "activity",
723
+ spanId: "shared-span",
724
+ timestamp: 1_700_000_000_000,
725
+ message: `log-${i}`,
726
+ });
727
+ }
728
+ await t.mutation(api.logs.appendLog, {
729
+ executionId: "exec-logs-cursor",
730
+ level: "error",
731
+ source: "activity",
732
+ spanId: "shared-span",
733
+ timestamp: 1_700_000_000_000,
734
+ message: "ignore-me",
735
+ });
736
+
737
+ const firstPage = await t.query(api.dashboard.listExecutionLogs, {
738
+ executionId: "exec-logs-cursor",
739
+ level: "info",
740
+ source: "activity",
741
+ spanId: "shared-span",
742
+ order: "asc",
743
+ limit: 1,
744
+ });
745
+
746
+ expect(firstPage.page).toHaveLength(1);
747
+ expect(firstPage.cursor).toBeTruthy();
748
+
749
+ expect(firstPage.cursor).not.toBeNull();
750
+
751
+ const secondPage = await t.query(api.dashboard.listExecutionLogs, {
752
+ executionId: "exec-logs-cursor",
753
+ level: "info",
754
+ source: "activity",
755
+ spanId: "shared-span",
756
+ order: "asc",
757
+ limit: 1,
758
+ afterTimestamp: firstPage.cursor!.timestamp,
759
+ afterLogId: firstPage.cursor!.logId,
760
+ });
761
+
762
+ expect(secondPage.page).toHaveLength(1);
763
+ expect(secondPage.page[0]._id).not.toBe(firstPage.page[0]._id);
764
+
765
+ expect(secondPage.cursor).not.toBeNull();
766
+
767
+ const thirdPage = await t.query(api.dashboard.listExecutionLogs, {
768
+ executionId: "exec-logs-cursor",
769
+ level: "info",
770
+ source: "activity",
771
+ spanId: "shared-span",
772
+ order: "asc",
773
+ limit: 2,
774
+ afterTimestamp: secondPage.cursor!.timestamp,
775
+ afterLogId: secondPage.cursor!.logId,
776
+ });
777
+
778
+ expect(thirdPage.page).toHaveLength(1);
779
+ expect(thirdPage.isDone).toBe(true);
780
+ });
781
+
782
+ test("execution list supports stable cursor pagination with same startedAt", async () => {
783
+ vi.useFakeTimers();
784
+ try {
785
+ vi.setSystemTime(new Date("2026-01-02T00:00:00.000Z"));
786
+ const t = initConvexTest();
787
+
788
+ for (let i = 0; i < 4; i++) {
789
+ await t.mutation(api.executions.createExecution, {
790
+ workflowName: "cursorWorkflow",
791
+ executionId: `exec-page-${i}`,
792
+ payload: { i },
793
+ tenantId: "tenant-cursor",
794
+ });
795
+ }
796
+
797
+ const firstPage = await t.query(api.dashboard.listExecutions, {
798
+ workflowName: "cursorWorkflow",
799
+ status: "pending",
800
+ tenantId: "tenant-cursor",
801
+ limit: 2,
802
+ order: "asc",
803
+ });
804
+
805
+ expect(firstPage.page).toHaveLength(2);
806
+ expect(firstPage.cursor).toBeTruthy();
807
+
808
+ expect(firstPage.cursor).not.toBeNull();
809
+
810
+ const secondPage = await t.query(api.dashboard.listExecutions, {
811
+ workflowName: "cursorWorkflow",
812
+ status: "pending",
813
+ tenantId: "tenant-cursor",
814
+ limit: 2,
815
+ order: "asc",
816
+ cursorStartedAt: firstPage.cursor!.startedAt,
817
+ cursorExecutionId: firstPage.cursor!.executionId,
818
+ });
819
+
820
+ expect(secondPage.page).toHaveLength(2);
821
+ const firstIds = firstPage.page.map((row: any) => row.executionId);
822
+ const secondIds = secondPage.page.map((row: any) => row.executionId);
823
+ expect(secondIds.some((id: string) => firstIds.includes(id))).toBe(false);
824
+ expect(secondPage.isDone).toBe(true);
825
+ } finally {
826
+ vi.useRealTimers();
827
+ }
828
+ });
829
+ });