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,218 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, type MutationCtx } from "./_generated/server.js";
3
+ import type { Doc, Id } from "./_generated/dataModel.js";
4
+ import { DEFAULT_RETENTION_DAYS } from "../shared/constants.js";
5
+ import { scheduleCleanupOldExecutions } from "./boundaries.js";
6
+
7
+ const TERMINAL_STATUSES = ["completed", "failed", "interrupted"] as const;
8
+
9
+ function clamp(value: number, min: number, max: number) {
10
+ return Math.max(min, Math.min(value, max));
11
+ }
12
+
13
+ function assertPositiveInteger(value: number | undefined, field: string) {
14
+ if (value === undefined) {
15
+ return;
16
+ }
17
+ if (!Number.isInteger(value) || value <= 0) {
18
+ throw new Error(`${field} must be a positive integer`);
19
+ }
20
+ }
21
+
22
+ async function resolveRetentionDays(
23
+ ctx: MutationCtx,
24
+ requestedRetentionDays: number | undefined,
25
+ ): Promise<number> {
26
+ if (requestedRetentionDays !== undefined) {
27
+ return requestedRetentionDays;
28
+ }
29
+
30
+ const config = await ctx.db
31
+ .query("config")
32
+ .withIndex("by_key", (q) => q.eq("key", "default"))
33
+ .unique();
34
+
35
+ return config?.retentionDays ?? DEFAULT_RETENTION_DAYS;
36
+ }
37
+
38
+ async function collectOldTerminalExecutions(
39
+ ctx: MutationCtx,
40
+ cutoffTimestamp: number,
41
+ fetchLimit: number,
42
+ ): Promise<Array<Doc<"executions">>> {
43
+ const collected: Array<Doc<"executions">> = [];
44
+
45
+ for (const status of TERMINAL_STATUSES) {
46
+ if (collected.length >= fetchLimit) {
47
+ break;
48
+ }
49
+
50
+ const remaining = fetchLimit - collected.length;
51
+ const rows = await ctx.db
52
+ .query("executions")
53
+ .withIndex("by_status_started", (q) =>
54
+ q.eq("status", status).lt("startedAt", cutoffTimestamp),
55
+ )
56
+ .take(remaining);
57
+
58
+ collected.push(...rows);
59
+ }
60
+
61
+ collected.sort((a, b) => a.startedAt - b.startedAt);
62
+ return collected;
63
+ }
64
+
65
+ function collectExecutionPayloadRefs(
66
+ execution: Doc<"executions">,
67
+ payloadIds: Set<Id<"payloads">>,
68
+ ) {
69
+ if (execution.payloadRef) {
70
+ payloadIds.add(execution.payloadRef);
71
+ }
72
+ if (execution.resultRef) {
73
+ payloadIds.add(execution.resultRef);
74
+ }
75
+ }
76
+
77
+ function collectJournalPayloadRefs(
78
+ step: Doc<"journalSteps">,
79
+ payloadIds: Set<Id<"payloads">>,
80
+ ) {
81
+ if (step.inputRef) {
82
+ payloadIds.add(step.inputRef);
83
+ }
84
+ if (step.runResult && step.runResult.kind !== "canceled" && step.runResult.valueRef) {
85
+ payloadIds.add(step.runResult.valueRef);
86
+ }
87
+ }
88
+
89
+ function collectSpanPayloadRefs(
90
+ span: Doc<"spans">,
91
+ payloadIds: Set<Id<"payloads">>,
92
+ ) {
93
+ if (span.inputRef) {
94
+ payloadIds.add(span.inputRef);
95
+ }
96
+ if (span.outputRef) {
97
+ payloadIds.add(span.outputRef);
98
+ }
99
+ }
100
+
101
+ function collectDeferredPayloadRefs(
102
+ deferred: Doc<"deferreds">,
103
+ payloadIds: Set<Id<"payloads">>,
104
+ ) {
105
+ if (deferred.exitRef) {
106
+ payloadIds.add(deferred.exitRef);
107
+ }
108
+ }
109
+
110
+ async function deleteExecutionArtifacts(ctx: MutationCtx, execution: Doc<"executions">) {
111
+ const payloadIds = new Set<Id<"payloads">>();
112
+ const workIds = new Set<string>();
113
+
114
+ collectExecutionPayloadRefs(execution, payloadIds);
115
+
116
+ const journalSteps = await ctx.db
117
+ .query("journalSteps")
118
+ .withIndex("by_execution_step", (q) => q.eq("executionId", execution.executionId))
119
+ .collect();
120
+
121
+ for (const step of journalSteps) {
122
+ collectJournalPayloadRefs(step, payloadIds);
123
+ if (step.workId) {
124
+ workIds.add(step.workId);
125
+ }
126
+ await ctx.db.delete(step._id);
127
+ }
128
+
129
+ for (const workId of workIds) {
130
+ const completion = await ctx.db
131
+ .query("activityCompletions")
132
+ .withIndex("by_work_id", (q) => q.eq("workId", workId))
133
+ .unique();
134
+ if (completion) {
135
+ await ctx.db.delete(completion._id);
136
+ }
137
+ }
138
+
139
+ const spans = await ctx.db
140
+ .query("spans")
141
+ .withIndex("by_execution_start", (q) => q.eq("executionId", execution.executionId))
142
+ .collect();
143
+
144
+ for (const span of spans) {
145
+ collectSpanPayloadRefs(span, payloadIds);
146
+ await ctx.db.delete(span._id);
147
+ }
148
+
149
+ const logs = await ctx.db
150
+ .query("logs")
151
+ .withIndex("by_execution_time", (q) => q.eq("executionId", execution.executionId))
152
+ .collect();
153
+
154
+ for (const log of logs) {
155
+ await ctx.db.delete(log._id);
156
+ }
157
+
158
+ const deferreds = await ctx.db
159
+ .query("deferreds")
160
+ .withIndex("by_execution_name", (q) => q.eq("executionId", execution.executionId))
161
+ .collect();
162
+
163
+ for (const deferred of deferreds) {
164
+ collectDeferredPayloadRefs(deferred, payloadIds);
165
+ await ctx.db.delete(deferred._id);
166
+ }
167
+
168
+ for (const payloadId of payloadIds) {
169
+ const payload = await ctx.db.get(payloadId);
170
+ if (payload) {
171
+ await ctx.db.delete(payload._id);
172
+ }
173
+ }
174
+
175
+ await ctx.db.delete(execution._id);
176
+ }
177
+
178
+ /**
179
+ * Delete old terminal executions and all associated observability/state records.
180
+ */
181
+ export const cleanupOldExecutions = mutation({
182
+ args: {
183
+ retentionDays: v.optional(v.number()),
184
+ limit: v.optional(v.number()),
185
+ },
186
+ returns: v.object({
187
+ deletedCount: v.number(),
188
+ hasMore: v.boolean(),
189
+ }),
190
+ handler: async (ctx, args) => {
191
+ assertPositiveInteger(args.retentionDays, "retentionDays");
192
+ assertPositiveInteger(args.limit, "limit");
193
+
194
+ const retentionDays = await resolveRetentionDays(ctx, args.retentionDays);
195
+ const limit = clamp(args.limit ?? 50, 1, 200);
196
+ const cutoffTimestamp = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
197
+
198
+ const fetched = await collectOldTerminalExecutions(ctx, cutoffTimestamp, limit + 1);
199
+ const targets = fetched.slice(0, limit);
200
+ const hasMore = fetched.length > limit;
201
+
202
+ for (const execution of targets) {
203
+ await deleteExecutionArtifacts(ctx, execution);
204
+ }
205
+
206
+ if (hasMore) {
207
+ await scheduleCleanupOldExecutions(ctx.scheduler, {
208
+ retentionDays,
209
+ limit,
210
+ });
211
+ }
212
+
213
+ return {
214
+ deletedCount: targets.length,
215
+ hasMore,
216
+ };
217
+ },
218
+ });
@@ -0,0 +1,26 @@
1
+ import { v } from "convex/values";
2
+ import { mutation } from "./_generated/server.js";
3
+ import { scheduleDeferredDone } from "./boundaries.js";
4
+
5
+ /**
6
+ * Schedule a durable deferred completion wake-up.
7
+ *
8
+ * This models clock scheduling for workflow sleeps.
9
+ */
10
+ export const scheduleDeferredWake = mutation({
11
+ args: {
12
+ executionId: v.string(),
13
+ deferredName: v.string(),
14
+ delayMs: v.number(),
15
+ exit: v.optional(v.any()),
16
+ },
17
+ returns: v.id("_scheduled_functions"),
18
+ handler: async (ctx, args) => {
19
+ const delayMs = Math.max(0, args.delayMs);
20
+ return await scheduleDeferredDone(ctx.scheduler, delayMs, {
21
+ executionId: args.executionId,
22
+ deferredName: args.deferredName,
23
+ exit: args.exit ?? null,
24
+ });
25
+ },
26
+ });
@@ -0,0 +1,159 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ import { describe, expect, test } from "vitest";
4
+ import { api } from "./_generated/api.js";
5
+ import { initConvexTest } from "./setup.test.js";
6
+ import {
7
+ MAX_INLINE_PAYLOAD_BYTES,
8
+ MAX_LOG_DATA_BYTES,
9
+ MAX_SPAN_ATTR_BYTES,
10
+ DEFAULT_RETENTION_DAYS,
11
+ } from "../shared/constants.js";
12
+
13
+ describe("config API", () => {
14
+ test("getConfig returns defaults when unset", async () => {
15
+ const t = initConvexTest();
16
+
17
+ const config = await t.query(api.config.getConfig, {});
18
+
19
+ expect(config).toEqual({
20
+ maxInlineBytes: MAX_INLINE_PAYLOAD_BYTES,
21
+ maxLogDataBytes: MAX_LOG_DATA_BYTES,
22
+ maxSpanAttrBytes: MAX_SPAN_ATTR_BYTES,
23
+ retentionDays: DEFAULT_RETENTION_DAYS,
24
+ defaultLogLevel: "info",
25
+ });
26
+ });
27
+
28
+ test("setConfig performs partial updates and merges with defaults", async () => {
29
+ const t = initConvexTest();
30
+
31
+ // Set partial config
32
+ const result1 = await t.mutation(api.config.setConfig, {
33
+ retentionDays: 60,
34
+ defaultLogLevel: "debug",
35
+ });
36
+
37
+ expect(result1).toEqual({
38
+ maxInlineBytes: MAX_INLINE_PAYLOAD_BYTES,
39
+ maxLogDataBytes: MAX_LOG_DATA_BYTES,
40
+ maxSpanAttrBytes: MAX_SPAN_ATTR_BYTES,
41
+ retentionDays: 60,
42
+ defaultLogLevel: "debug",
43
+ });
44
+
45
+ // Verify with getConfig
46
+ const fetched = await t.query(api.config.getConfig, {});
47
+ expect(fetched).toEqual(result1);
48
+
49
+ // Update more fields (should merge, not replace)
50
+ const result2 = await t.mutation(api.config.setConfig, {
51
+ maxInlineBytes: 128_000,
52
+ });
53
+
54
+ expect(result2).toEqual({
55
+ maxInlineBytes: 128_000,
56
+ maxLogDataBytes: MAX_LOG_DATA_BYTES,
57
+ maxSpanAttrBytes: MAX_SPAN_ATTR_BYTES,
58
+ retentionDays: 60,
59
+ defaultLogLevel: "debug",
60
+ });
61
+ });
62
+
63
+ test("setConfig rejects invalid numeric values", async () => {
64
+ const t = initConvexTest();
65
+
66
+ // Zero should be rejected
67
+ await expect(
68
+ t.mutation(api.config.setConfig, { maxInlineBytes: 0 }),
69
+ ).rejects.toThrow("maxInlineBytes must be a positive integer");
70
+
71
+ // Negative should be rejected
72
+ await expect(
73
+ t.mutation(api.config.setConfig, { retentionDays: -5 }),
74
+ ).rejects.toThrow("retentionDays must be a positive integer");
75
+
76
+ // Non-integer should be rejected
77
+ await expect(
78
+ t.mutation(api.config.setConfig, { maxLogDataBytes: 12.5 }),
79
+ ).rejects.toThrow("maxLogDataBytes must be a positive integer");
80
+
81
+ // NaN should be rejected
82
+ await expect(
83
+ t.mutation(api.config.setConfig, { maxSpanAttrBytes: NaN }),
84
+ ).rejects.toThrow("maxSpanAttrBytes must be a positive integer");
85
+ });
86
+
87
+ test("setConfig validates all numeric fields independently", async () => {
88
+ const t = initConvexTest();
89
+
90
+ // Multiple invalid fields - should fail on first validation error
91
+ await expect(
92
+ t.mutation(api.config.setConfig, {
93
+ maxInlineBytes: -1,
94
+ retentionDays: 0,
95
+ }),
96
+ ).rejects.toThrow("maxInlineBytes must be a positive integer");
97
+
98
+ // After fixing first error, second should be caught
99
+ await expect(
100
+ t.mutation(api.config.setConfig, {
101
+ maxInlineBytes: 1000,
102
+ retentionDays: 0,
103
+ }),
104
+ ).rejects.toThrow("retentionDays must be a positive integer");
105
+ });
106
+
107
+ test("setConfig with valid positive integers succeeds", async () => {
108
+ const t = initConvexTest();
109
+
110
+ const result = await t.mutation(api.config.setConfig, {
111
+ maxInlineBytes: 1,
112
+ maxLogDataBytes: 1,
113
+ maxSpanAttrBytes: 1,
114
+ retentionDays: 1,
115
+ });
116
+
117
+ expect(result).toEqual({
118
+ maxInlineBytes: 1,
119
+ maxLogDataBytes: 1,
120
+ maxSpanAttrBytes: 1,
121
+ retentionDays: 1,
122
+ defaultLogLevel: "info",
123
+ });
124
+ });
125
+
126
+ test("defaultLogLevel accepts all valid values", async () => {
127
+ const t = initConvexTest();
128
+
129
+ const validLevels: Array<"debug" | "info" | "warn" | "error"> = [
130
+ "debug",
131
+ "info",
132
+ "warn",
133
+ "error",
134
+ ];
135
+
136
+ for (const level of validLevels) {
137
+ const result = await t.mutation(api.config.setConfig, {
138
+ defaultLogLevel: level,
139
+ });
140
+ expect(result.defaultLogLevel).toBe(level);
141
+ }
142
+ });
143
+
144
+ test("setConfig returns resolved config after update", async () => {
145
+ const t = initConvexTest();
146
+
147
+ const initial = await t.mutation(api.config.setConfig, {
148
+ maxInlineBytes: 500_000,
149
+ retentionDays: 90,
150
+ });
151
+
152
+ // Verify the returned config includes all fields with correct values
153
+ expect(initial.maxInlineBytes).toBe(500_000);
154
+ expect(initial.retentionDays).toBe(90);
155
+ expect(initial.defaultLogLevel).toBe("info"); // default
156
+ expect(initial.maxLogDataBytes).toBe(MAX_LOG_DATA_BYTES); // preserved default
157
+ expect(initial.maxSpanAttrBytes).toBe(MAX_SPAN_ATTR_BYTES); // preserved default
158
+ });
159
+ });
@@ -0,0 +1,145 @@
1
+ import { v } from "convex/values";
2
+ import {
3
+ mutation,
4
+ query,
5
+ type MutationCtx,
6
+ type QueryCtx,
7
+ } from "./_generated/server.js";
8
+ import {
9
+ MAX_INLINE_PAYLOAD_BYTES,
10
+ MAX_LOG_DATA_BYTES,
11
+ MAX_SPAN_ATTR_BYTES,
12
+ DEFAULT_RETENTION_DAYS,
13
+ } from "../shared/constants.js";
14
+ import { vLogLevel } from "../shared/validators.js";
15
+ import type { LogLevel } from "../shared/validators.js";
16
+ import type { Doc } from "./_generated/dataModel.js";
17
+
18
+ type ConfigDoc = Doc<"config">;
19
+
20
+ type ResolvedConfig = {
21
+ maxInlineBytes: number;
22
+ maxLogDataBytes: number;
23
+ maxSpanAttrBytes: number;
24
+ retentionDays: number;
25
+ defaultLogLevel: LogLevel;
26
+ };
27
+
28
+ const DEFAULT_CONFIG: ResolvedConfig = {
29
+ maxInlineBytes: MAX_INLINE_PAYLOAD_BYTES,
30
+ maxLogDataBytes: MAX_LOG_DATA_BYTES,
31
+ maxSpanAttrBytes: MAX_SPAN_ATTR_BYTES,
32
+ retentionDays: DEFAULT_RETENTION_DAYS,
33
+ defaultLogLevel: "info",
34
+ };
35
+
36
+ function validatePositiveInteger(
37
+ value: number | undefined | null,
38
+ fieldName: string,
39
+ ): void {
40
+ if (value !== undefined && value !== null) {
41
+ if (!Number.isInteger(value) || value <= 0) {
42
+ throw new Error(`${fieldName} must be a positive integer`);
43
+ }
44
+ }
45
+ }
46
+
47
+ type DbReaderCtx = Pick<QueryCtx, "db"> | Pick<MutationCtx, "db">;
48
+
49
+ async function getConfigDoc(ctx: DbReaderCtx): Promise<ConfigDoc | null> {
50
+ return await ctx.db
51
+ .query("config")
52
+ .withIndex("by_key", (q) => q.eq("key", "default"))
53
+ .unique();
54
+ }
55
+
56
+ function resolveConfig(doc: ConfigDoc | null): ResolvedConfig {
57
+ if (!doc) {
58
+ return { ...DEFAULT_CONFIG };
59
+ }
60
+ return {
61
+ maxInlineBytes: doc.maxInlineBytes ?? DEFAULT_CONFIG.maxInlineBytes,
62
+ maxLogDataBytes: doc.maxLogDataBytes ?? DEFAULT_CONFIG.maxLogDataBytes,
63
+ maxSpanAttrBytes: doc.maxSpanAttrBytes ?? DEFAULT_CONFIG.maxSpanAttrBytes,
64
+ retentionDays: doc.retentionDays ?? DEFAULT_CONFIG.retentionDays,
65
+ defaultLogLevel: doc.defaultLogLevel ?? DEFAULT_CONFIG.defaultLogLevel,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Get the resolved configuration with defaults applied.
71
+ */
72
+ export const getConfig = query({
73
+ args: {},
74
+ returns: v.object({
75
+ maxInlineBytes: v.number(),
76
+ maxLogDataBytes: v.number(),
77
+ maxSpanAttrBytes: v.number(),
78
+ retentionDays: v.number(),
79
+ defaultLogLevel: vLogLevel,
80
+ }),
81
+ handler: async (ctx) => {
82
+ const doc = await getConfigDoc(ctx);
83
+ return resolveConfig(doc);
84
+ },
85
+ });
86
+
87
+ /**
88
+ * Set configuration via partial update merge/upsert.
89
+ * All numeric values must be positive integers if provided.
90
+ */
91
+ export const setConfig = mutation({
92
+ args: {
93
+ maxInlineBytes: v.optional(v.number()),
94
+ maxLogDataBytes: v.optional(v.number()),
95
+ maxSpanAttrBytes: v.optional(v.number()),
96
+ retentionDays: v.optional(v.number()),
97
+ defaultLogLevel: v.optional(vLogLevel),
98
+ },
99
+ returns: v.object({
100
+ maxInlineBytes: v.number(),
101
+ maxLogDataBytes: v.number(),
102
+ maxSpanAttrBytes: v.number(),
103
+ retentionDays: v.number(),
104
+ defaultLogLevel: vLogLevel,
105
+ }),
106
+ handler: async (ctx, args) => {
107
+ validatePositiveInteger(args.maxInlineBytes, "maxInlineBytes");
108
+ validatePositiveInteger(args.maxLogDataBytes, "maxLogDataBytes");
109
+ validatePositiveInteger(args.maxSpanAttrBytes, "maxSpanAttrBytes");
110
+ validatePositiveInteger(args.retentionDays, "retentionDays");
111
+
112
+ const existing = await getConfigDoc(ctx);
113
+
114
+ const patch: Partial<ConfigDoc> = {};
115
+ if (args.maxInlineBytes !== undefined) {
116
+ patch.maxInlineBytes = args.maxInlineBytes;
117
+ }
118
+ if (args.maxLogDataBytes !== undefined) {
119
+ patch.maxLogDataBytes = args.maxLogDataBytes;
120
+ }
121
+ if (args.maxSpanAttrBytes !== undefined) {
122
+ patch.maxSpanAttrBytes = args.maxSpanAttrBytes;
123
+ }
124
+ if (args.retentionDays !== undefined) {
125
+ patch.retentionDays = args.retentionDays;
126
+ }
127
+ if (args.defaultLogLevel !== undefined) {
128
+ patch.defaultLogLevel = args.defaultLogLevel;
129
+ }
130
+
131
+ if (existing) {
132
+ if (Object.keys(patch).length > 0) {
133
+ await ctx.db.patch(existing._id, patch);
134
+ }
135
+ } else {
136
+ await ctx.db.insert("config", {
137
+ key: "default",
138
+ ...patch,
139
+ });
140
+ }
141
+
142
+ const updated = await getConfigDoc(ctx);
143
+ return resolveConfig(updated);
144
+ },
145
+ });
@@ -0,0 +1,7 @@
1
+ import { defineComponent } from "convex/server";
2
+ import workpool from "@convex-dev/workpool/convex.config";
3
+
4
+ const component = defineComponent("effectWorkflows");
5
+ component.use(workpool);
6
+
7
+ export default component;