@convex-dev/crons 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 (94) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +56 -0
  3. package/dist/commonjs/client/index.d.ts +50 -0
  4. package/dist/commonjs/client/index.d.ts.map +1 -0
  5. package/dist/commonjs/client/index.js +99 -0
  6. package/dist/commonjs/client/index.js.map +1 -0
  7. package/dist/commonjs/client/utils.d.ts +15 -0
  8. package/dist/commonjs/client/utils.d.ts.map +1 -0
  9. package/dist/commonjs/client/utils.js +3 -0
  10. package/dist/commonjs/client/utils.js.map +1 -0
  11. package/dist/commonjs/component/_generated/api.d.ts +14 -0
  12. package/dist/commonjs/component/_generated/api.d.ts.map +1 -0
  13. package/dist/commonjs/component/_generated/api.js +24 -0
  14. package/dist/commonjs/component/_generated/api.js.map +1 -0
  15. package/dist/commonjs/component/_generated/server.d.ts +64 -0
  16. package/dist/commonjs/component/_generated/server.d.ts.map +1 -0
  17. package/dist/commonjs/component/_generated/server.js +76 -0
  18. package/dist/commonjs/component/_generated/server.js.map +1 -0
  19. package/dist/commonjs/component/convex.config.d.ts +3 -0
  20. package/dist/commonjs/component/convex.config.d.ts.map +1 -0
  21. package/dist/commonjs/component/convex.config.js +3 -0
  22. package/dist/commonjs/component/convex.config.js.map +1 -0
  23. package/dist/commonjs/component/index.d.ts +135 -0
  24. package/dist/commonjs/component/index.d.ts.map +1 -0
  25. package/dist/commonjs/component/index.js +289 -0
  26. package/dist/commonjs/component/index.js.map +1 -0
  27. package/dist/commonjs/component/parseArgs.d.ts +11 -0
  28. package/dist/commonjs/component/parseArgs.d.ts.map +1 -0
  29. package/dist/commonjs/component/parseArgs.js +28 -0
  30. package/dist/commonjs/component/parseArgs.js.map +1 -0
  31. package/dist/commonjs/component/public.d.ts +98 -0
  32. package/dist/commonjs/component/public.d.ts.map +1 -0
  33. package/dist/commonjs/component/public.js +225 -0
  34. package/dist/commonjs/component/public.js.map +1 -0
  35. package/dist/commonjs/component/schema.d.ts +45 -0
  36. package/dist/commonjs/component/schema.d.ts.map +1 -0
  37. package/dist/commonjs/component/schema.js +20 -0
  38. package/dist/commonjs/component/schema.js.map +1 -0
  39. package/dist/commonjs/frontend/index.d.ts +2 -0
  40. package/dist/commonjs/frontend/index.d.ts.map +1 -0
  41. package/dist/commonjs/frontend/index.js +8 -0
  42. package/dist/commonjs/frontend/index.js.map +1 -0
  43. package/dist/esm/client/index.d.ts +50 -0
  44. package/dist/esm/client/index.d.ts.map +1 -0
  45. package/dist/esm/client/index.js +99 -0
  46. package/dist/esm/client/index.js.map +1 -0
  47. package/dist/esm/client/utils.d.ts +15 -0
  48. package/dist/esm/client/utils.d.ts.map +1 -0
  49. package/dist/esm/client/utils.js +3 -0
  50. package/dist/esm/client/utils.js.map +1 -0
  51. package/dist/esm/component/_generated/api.d.ts +14 -0
  52. package/dist/esm/component/_generated/api.d.ts.map +1 -0
  53. package/dist/esm/component/_generated/api.js +24 -0
  54. package/dist/esm/component/_generated/api.js.map +1 -0
  55. package/dist/esm/component/_generated/server.d.ts +64 -0
  56. package/dist/esm/component/_generated/server.d.ts.map +1 -0
  57. package/dist/esm/component/_generated/server.js +76 -0
  58. package/dist/esm/component/_generated/server.js.map +1 -0
  59. package/dist/esm/component/convex.config.d.ts +3 -0
  60. package/dist/esm/component/convex.config.d.ts.map +1 -0
  61. package/dist/esm/component/convex.config.js +3 -0
  62. package/dist/esm/component/convex.config.js.map +1 -0
  63. package/dist/esm/component/index.d.ts +135 -0
  64. package/dist/esm/component/index.d.ts.map +1 -0
  65. package/dist/esm/component/index.js +289 -0
  66. package/dist/esm/component/index.js.map +1 -0
  67. package/dist/esm/component/parseArgs.d.ts +11 -0
  68. package/dist/esm/component/parseArgs.d.ts.map +1 -0
  69. package/dist/esm/component/parseArgs.js +28 -0
  70. package/dist/esm/component/parseArgs.js.map +1 -0
  71. package/dist/esm/component/public.d.ts +98 -0
  72. package/dist/esm/component/public.d.ts.map +1 -0
  73. package/dist/esm/component/public.js +225 -0
  74. package/dist/esm/component/public.js.map +1 -0
  75. package/dist/esm/component/schema.d.ts +45 -0
  76. package/dist/esm/component/schema.d.ts.map +1 -0
  77. package/dist/esm/component/schema.js +20 -0
  78. package/dist/esm/component/schema.js.map +1 -0
  79. package/dist/esm/frontend/index.d.ts +2 -0
  80. package/dist/esm/frontend/index.d.ts.map +1 -0
  81. package/dist/esm/frontend/index.js +8 -0
  82. package/dist/esm/frontend/index.js.map +1 -0
  83. package/dist/esm/package.json +3 -0
  84. package/package.json +56 -0
  85. package/src/client/index.ts +128 -0
  86. package/src/client/utils.ts +44 -0
  87. package/src/component/_generated/api.d.ts +98 -0
  88. package/src/component/_generated/api.js +27 -0
  89. package/src/component/_generated/dataModel.d.ts +64 -0
  90. package/src/component/_generated/server.d.ts +153 -0
  91. package/src/component/_generated/server.js +94 -0
  92. package/src/component/convex.config.ts +3 -0
  93. package/src/component/public.ts +287 -0
  94. package/src/component/schema.ts +23 -0
@@ -0,0 +1,287 @@
1
+ // Implementation of crons in user space.
2
+ //
3
+ // See ../client/index.ts for the public API.
4
+
5
+ import { FunctionHandle } from "convex/server";
6
+ import { v } from "convex/values";
7
+ import {
8
+ MutationCtx,
9
+ mutation,
10
+ query,
11
+ internalMutation,
12
+ } from "./_generated/server.js";
13
+ import { internal } from "./_generated/api.js";
14
+ import { Doc, Id } from "./_generated/dataModel.js";
15
+ import parser from "cron-parser";
16
+ import schema from "./schema.js";
17
+
18
+ export type Schedule =
19
+ | { kind: "cron"; cronspec: string }
20
+ | { kind: "interval"; ms: number };
21
+ const scheduleValidator = schema.tables.crons.validator.fields.schedule;
22
+
23
+ export type CronInfo = {
24
+ id: string;
25
+ name?: string;
26
+ functionHandle: FunctionHandle<"mutation" | "action">;
27
+ args: Record<string, unknown>;
28
+ schedule: Schedule;
29
+ };
30
+ const cronInfoValidator = v.object({
31
+ id: v.id("crons"),
32
+ name: v.optional(v.string()),
33
+ functionHandle: v.string(),
34
+ args: v.record(v.string(), v.any()),
35
+ schedule: scheduleValidator,
36
+ });
37
+
38
+ /**
39
+ * Schedule a mutation or action to run on a cron schedule or interval.
40
+ *
41
+ * @param name - Optional unique name for the job. Will throw if a name is
42
+ * provided and a job with the same name already exists.
43
+ * @param schedule - Either a cron specification string or an interval in
44
+ * milliseconds. For intervals, ms must be >= 1000.
45
+ * @param functionHandle - A {@link FunctionHandle} string for the function to
46
+ * schedule.
47
+ * @param args - The arguments to the function.
48
+ * @returns The ID of the scheduled job.
49
+ */
50
+ export const register = mutation({
51
+ args: {
52
+ name: v.optional(v.string()),
53
+ schedule: scheduleValidator,
54
+ functionHandle: v.string(),
55
+ args: v.record(v.string(), v.any()),
56
+ },
57
+ returns: v.id("crons"),
58
+ handler: async (ctx, { name, schedule, functionHandle, args }) => {
59
+ if (
60
+ name &&
61
+ (await ctx.db
62
+ .query("crons")
63
+ .withIndex("name", (q) => q.eq("name", name))
64
+ .unique())
65
+ ) {
66
+ throw new Error(`Cron with name "${name}" already exists`);
67
+ }
68
+ validateSchedule(schedule);
69
+
70
+ const id = await ctx.db.insert("crons", {
71
+ functionHandle,
72
+ args,
73
+ name,
74
+ schedule,
75
+ });
76
+ console.log(
77
+ `Scheduling cron "${name}" (${id}) on schedule ${JSON.stringify(schedule)}`
78
+ );
79
+
80
+ await scheduleNextRun(ctx, id, new Date(), schedule);
81
+ return id;
82
+ },
83
+ });
84
+
85
+ function validateSchedule(schedule: Schedule) {
86
+ if (schedule.kind === "interval" && schedule.ms < 1000) {
87
+ throw new Error("Interval must be >= 1000ms");
88
+ }
89
+ if (schedule.kind === "cron") {
90
+ try {
91
+ parser.parseExpression(schedule.cronspec);
92
+ } catch {
93
+ throw new Error(`Invalid cronspec: "${schedule.cronspec}"`);
94
+ }
95
+ }
96
+ }
97
+
98
+ async function scheduleNextRun(
99
+ ctx: MutationCtx,
100
+ id: Id<"crons">,
101
+ lastScheduled: Date,
102
+ schedule: Schedule
103
+ ) {
104
+ const nextRun = calculateNextRun(lastScheduled, schedule);
105
+ const schedulerJobId = await ctx.scheduler.runAt(
106
+ nextRun,
107
+ internal.public.rescheduler,
108
+ { id }
109
+ );
110
+ await ctx.db.patch(id, { schedulerJobId });
111
+ }
112
+
113
+ function calculateNextRun(lastScheduled: Date, schedule: Schedule): Date {
114
+ if (schedule.kind === "interval") {
115
+ return new Date(lastScheduled.getTime() + schedule.ms);
116
+ } else {
117
+ const cron = parser.parseExpression(schedule.cronspec, {
118
+ currentDate: lastScheduled,
119
+ });
120
+ return cron.next().toDate();
121
+ }
122
+ }
123
+
124
+ /**
125
+ * List all user space cron jobs.
126
+ *
127
+ * @returns List of `cron` table rows.
128
+ */
129
+ export const list = query({
130
+ args: {},
131
+ returns: v.array(cronInfoValidator),
132
+ handler: async (ctx) => {
133
+ const crons = await ctx.db.query("crons").collect();
134
+ return crons.map((cron) => ({
135
+ id: cron._id,
136
+ ...(cron.name && { name: cron.name }),
137
+ functionHandle: cron.functionHandle,
138
+ args: cron.args,
139
+ schedule: cron.schedule,
140
+ }));
141
+ },
142
+ });
143
+
144
+ /**
145
+ * Get an existing cron job by id or name.
146
+ *
147
+ * @param identifier - Either the ID or name of the cron job.
148
+ * @returns Cron job document or null if not found.
149
+ */
150
+ export const get = query({
151
+ args: {
152
+ identifier: v.union(
153
+ v.object({ id: v.id("crons") }),
154
+ v.object({ name: v.string() })
155
+ ),
156
+ },
157
+ returns: v.union(cronInfoValidator, v.null()),
158
+ handler: async (ctx, { identifier }) => {
159
+ const cron =
160
+ "id" in identifier
161
+ ? await ctx.db.get(identifier.id)
162
+ : await ctx.db
163
+ .query("crons")
164
+ .withIndex("name", (q) => q.eq("name", identifier.name))
165
+ .unique();
166
+ if (!cron) return null;
167
+ return {
168
+ id: cron._id,
169
+ ...(cron.name && { name: cron.name }),
170
+ functionHandle: cron.functionHandle,
171
+ args: cron.args,
172
+ schedule: cron.schedule,
173
+ };
174
+ },
175
+ });
176
+
177
+ /**
178
+ * Delete and deschedule a cron job by id or name.
179
+ *
180
+ * @param identifier - Either the ID or name of the cron job.
181
+ */
182
+ export const del = mutation({
183
+ args: {
184
+ identifier: v.union(
185
+ v.object({ id: v.id("crons") }),
186
+ v.object({ name: v.string() })
187
+ ),
188
+ },
189
+ returns: v.null(),
190
+ handler: async (ctx, { identifier }) => {
191
+ let cron: Doc<"crons"> | null;
192
+ if ("id" in identifier) {
193
+ cron = await ctx.db.get(identifier.id);
194
+ if (!cron) {
195
+ throw new Error(`Cron ${identifier.id} not found`);
196
+ }
197
+ } else {
198
+ cron = await ctx.db
199
+ .query("crons")
200
+ .withIndex("name", (q) => q.eq("name", identifier.name))
201
+ .unique();
202
+ if (!cron) {
203
+ throw new Error(`Cron "${identifier.name}" not found`);
204
+ }
205
+ }
206
+ if (!cron.schedulerJobId) {
207
+ throw new Error(`Cron ${cron._id} not scheduled`);
208
+ }
209
+ console.log(`Canceling scheduler job ${cron.schedulerJobId}`);
210
+ await ctx.scheduler.cancel(cron.schedulerJobId);
211
+ if (cron.executionJobId) {
212
+ console.log(`Canceling execution job ${cron.executionJobId}`);
213
+ await ctx.scheduler.cancel(cron.executionJobId);
214
+ }
215
+ console.log(`Deleting cron ${cron._id}`);
216
+ await ctx.db.delete(cron._id);
217
+ },
218
+ });
219
+
220
+ // Continue rescheduling a cron job.
221
+ //
222
+ // This is the main worker function that does the scheduling but also schedules
223
+ // the target function so that it runs in a different context. As a result this
224
+ // function probably *shouldn't* fail since it isn't doing much, but under heavy
225
+ // OCC contention it's possible it may eventually fail. In this case the cron
226
+ // will be lost and we'll need a janitor job to recover it.
227
+ export const rescheduler = internalMutation({
228
+ args: {
229
+ id: v.id("crons"),
230
+ },
231
+ returns: v.null(),
232
+ handler: async (ctx, { id }) => {
233
+ // Cron job is the logical concept we're rescheduling repeatedly.
234
+ const cronJob = await ctx.db.get(id);
235
+ if (!cronJob) {
236
+ throw Error(`Cron ${id} not found`);
237
+ }
238
+ if (!cronJob.schedulerJobId) {
239
+ throw Error(`Cron ${id} not scheduled`);
240
+ }
241
+
242
+ // Scheduler job is the job that's running right now, that we use to trigger
243
+ // repeated executions.
244
+ const schedulerJob = await ctx.db.system.get(cronJob.schedulerJobId);
245
+ if (!schedulerJob) {
246
+ throw Error(`Scheduler job ${cronJob.schedulerJobId} not found`);
247
+ }
248
+ if (
249
+ schedulerJob.state.kind !== "pending" &&
250
+ schedulerJob.state.kind !== "inProgress"
251
+ ) {
252
+ throw Error(
253
+ `We are running in job ${schedulerJob._id} but state is ${schedulerJob.state.kind}`
254
+ );
255
+ }
256
+
257
+ // Execution job is the previous job used to actually do the work of the cron.
258
+ let stillRunning = false;
259
+ if (cronJob.executionJobId) {
260
+ const executionJob = await ctx.db.system.get(cronJob.executionJobId);
261
+ if (
262
+ executionJob &&
263
+ (executionJob.state.kind === "pending" ||
264
+ executionJob.state.kind === "inProgress")
265
+ ) {
266
+ stillRunning = true;
267
+ }
268
+ }
269
+ if (stillRunning) {
270
+ console.log(`Cron ${cronJob._id} still running, skipping this run.`);
271
+ } else {
272
+ console.log(`Running cron ${cronJob._id}.`);
273
+ await ctx.scheduler.runAfter(
274
+ 0,
275
+ cronJob.functionHandle as FunctionHandle<"mutation" | "action">,
276
+ cronJob.args
277
+ );
278
+ }
279
+
280
+ await scheduleNextRun(
281
+ ctx,
282
+ id,
283
+ new Date(schedulerJob.scheduledTime),
284
+ cronJob.schedule
285
+ );
286
+ },
287
+ });
@@ -0,0 +1,23 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+
4
+ export default defineSchema({
5
+ // User space crons.
6
+ crons: defineTable({
7
+ name: v.optional(v.string()), // optional
8
+ functionHandle: v.string(),
9
+ args: v.record(v.string(), v.any()),
10
+ schedule: v.union(
11
+ v.object({
12
+ kind: v.literal("interval"),
13
+ ms: v.float64(), // milliseconds
14
+ }),
15
+ v.object({
16
+ kind: v.literal("cron"),
17
+ cronspec: v.string(), // "* * * * *"
18
+ })
19
+ ),
20
+ schedulerJobId: v.optional(v.id("_scheduled_functions")), // job to wait for the next execution
21
+ executionJobId: v.optional(v.id("_scheduled_functions")), // async job to run the function
22
+ }).index("name", ["name"]),
23
+ });