@sentry/junior-scheduler 0.107.1 → 0.109.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.
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Owns model-facing schedule intent and materializes it against the trusted
3
+ * server clock into the canonical schedule persisted by the scheduler.
4
+ */
5
+ import { z } from "zod";
6
+ import {
7
+ getFirstRunAtMs,
8
+ getZonedDateTimeParts,
9
+ resolveLocalScheduleAtMs,
10
+ } from "./cadence";
11
+ import type {
12
+ ScheduledLocalTime,
13
+ ScheduledTaskRecurrence,
14
+ ScheduledTaskSchedule,
15
+ } from "./types";
16
+
17
+ const localDateSchema = z
18
+ .string()
19
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "Use a local date in YYYY-MM-DD format.");
20
+ const localTimeSchema = z
21
+ .string()
22
+ .regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Use local time in HH:MM format.");
23
+ const timezoneSchema = z
24
+ .string()
25
+ .min(1)
26
+ .max(80)
27
+ .describe(
28
+ "IANA timezone, for example America/Los_Angeles. Omit or use null for the scheduler default.",
29
+ )
30
+ .nullable()
31
+ .optional();
32
+ const weekdaySchema = z.enum([
33
+ "sunday",
34
+ "monday",
35
+ "tuesday",
36
+ "wednesday",
37
+ "thursday",
38
+ "friday",
39
+ "saturday",
40
+ ]);
41
+
42
+ const oneOffScheduleIntentSchema = z
43
+ .object({
44
+ kind: z.literal("one_off").describe("A schedule that runs once."),
45
+ timezone: timezoneSchema,
46
+ timing: z
47
+ .discriminatedUnion("type", [
48
+ z
49
+ .object({
50
+ type: z
51
+ .literal("after")
52
+ .describe("Run after a relative delay from the server clock."),
53
+ value: z.number().int().positive().max(100_000),
54
+ unit: z.enum(["second", "minute", "hour", "day", "week"]),
55
+ })
56
+ .strict(),
57
+ z
58
+ .object({
59
+ type: z
60
+ .literal("at")
61
+ .describe("Run at an explicit local calendar date and time."),
62
+ date: localDateSchema.describe(
63
+ "Requested local calendar date in YYYY-MM-DD format.",
64
+ ),
65
+ time: localTimeSchema.describe(
66
+ "Requested local wall-clock time in HH:MM format.",
67
+ ),
68
+ })
69
+ .strict(),
70
+ ])
71
+ .describe("Relative delay or explicit local time for the one-off run."),
72
+ })
73
+ .strict();
74
+
75
+ const recurringScheduleIntentSchema = z
76
+ .object({
77
+ kind: z.literal("recurring").describe("A repeating calendar schedule."),
78
+ frequency: z
79
+ .enum(["daily", "weekly", "monthly", "yearly"])
80
+ .describe("Recurring tasks can run at most once per day."),
81
+ interval: z
82
+ .number()
83
+ .int()
84
+ .positive()
85
+ .max(365)
86
+ .describe("Repeat every N frequency units. Defaults to 1.")
87
+ .nullable()
88
+ .optional(),
89
+ time: localTimeSchema.describe(
90
+ "Local wall-clock time for each occurrence in HH:MM format.",
91
+ ),
92
+ weekdays: z
93
+ .array(weekdaySchema)
94
+ .min(1)
95
+ .max(7)
96
+ .describe("Required for weekly schedules.")
97
+ .nullable()
98
+ .optional(),
99
+ day_of_month: z
100
+ .number()
101
+ .int()
102
+ .min(1)
103
+ .max(31)
104
+ .describe("Required for monthly and yearly schedules.")
105
+ .nullable()
106
+ .optional(),
107
+ month: z
108
+ .number()
109
+ .int()
110
+ .min(1)
111
+ .max(12)
112
+ .describe("Required for yearly schedules, where January is 1.")
113
+ .nullable()
114
+ .optional(),
115
+ start_date: localDateSchema
116
+ .describe(
117
+ "Optional local calendar date that anchors the recurrence. Omit to start with the next matching occurrence.",
118
+ )
119
+ .nullable()
120
+ .optional(),
121
+ timezone: timezoneSchema,
122
+ })
123
+ .strict()
124
+ .superRefine((schedule, context) => {
125
+ if (schedule.frequency === "weekly" && schedule.weekdays == null) {
126
+ context.addIssue({
127
+ code: "custom",
128
+ message: "Weekly schedules require weekdays.",
129
+ path: ["weekdays"],
130
+ });
131
+ }
132
+ if (
133
+ (schedule.frequency === "monthly" || schedule.frequency === "yearly") &&
134
+ schedule.day_of_month == null
135
+ ) {
136
+ context.addIssue({
137
+ code: "custom",
138
+ message: `${schedule.frequency} schedules require day_of_month.`,
139
+ path: ["day_of_month"],
140
+ });
141
+ }
142
+ if (schedule.frequency === "yearly" && schedule.month == null) {
143
+ context.addIssue({
144
+ code: "custom",
145
+ message: "Yearly schedules require month.",
146
+ path: ["month"],
147
+ });
148
+ }
149
+ if (schedule.frequency !== "weekly" && schedule.weekdays != null) {
150
+ context.addIssue({
151
+ code: "custom",
152
+ message: "weekdays applies only to weekly schedules.",
153
+ path: ["weekdays"],
154
+ });
155
+ }
156
+ if (
157
+ schedule.frequency !== "monthly" &&
158
+ schedule.frequency !== "yearly" &&
159
+ schedule.day_of_month != null
160
+ ) {
161
+ context.addIssue({
162
+ code: "custom",
163
+ message: "day_of_month applies only to monthly or yearly schedules.",
164
+ path: ["day_of_month"],
165
+ });
166
+ }
167
+ if (schedule.frequency !== "yearly" && schedule.month != null) {
168
+ context.addIssue({
169
+ code: "custom",
170
+ message: "month applies only to yearly schedules.",
171
+ path: ["month"],
172
+ });
173
+ }
174
+ });
175
+
176
+ export const scheduleIntentSchema = z.union([
177
+ oneOffScheduleIntentSchema,
178
+ recurringScheduleIntentSchema,
179
+ ]);
180
+ export type ScheduleIntent = z.infer<typeof scheduleIntentSchema>;
181
+
182
+ const WEEKDAY_INDEX = {
183
+ sunday: 0,
184
+ monday: 1,
185
+ tuesday: 2,
186
+ wednesday: 3,
187
+ thursday: 4,
188
+ friday: 5,
189
+ saturday: 6,
190
+ } as const;
191
+
192
+ const DURATION_MS = {
193
+ second: 1000,
194
+ minute: 60 * 1000,
195
+ hour: 60 * 60 * 1000,
196
+ day: 24 * 60 * 60 * 1000,
197
+ week: 7 * 24 * 60 * 60 * 1000,
198
+ } as const;
199
+
200
+ function parseLocalTime(value: string): ScheduledLocalTime {
201
+ const [hour, minute] = value.split(":").map(Number);
202
+ return { hour: hour!, minute: minute! };
203
+ }
204
+
205
+ function localDateAt(timestampMs: number, timezone: string): string {
206
+ const parts = getZonedDateTimeParts(timestampMs, timezone);
207
+ return [parts.year, parts.month, parts.day]
208
+ .map((value, index) =>
209
+ index === 0
210
+ ? String(value).padStart(4, "0")
211
+ : String(value).padStart(2, "0"),
212
+ )
213
+ .join("-");
214
+ }
215
+
216
+ function isValidTimeZone(timezone: string): boolean {
217
+ try {
218
+ new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format();
219
+ return true;
220
+ } catch {
221
+ return false;
222
+ }
223
+ }
224
+
225
+ function plural(value: number, unit: string): string {
226
+ return value === 1 ? unit : `${unit}s`;
227
+ }
228
+
229
+ function formatWeekdays(weekdays: readonly string[]): string {
230
+ const labels = weekdays.map(
231
+ (weekday) => `${weekday[0]!.toUpperCase()}${weekday.slice(1)}`,
232
+ );
233
+ if (labels.length < 2) {
234
+ return labels[0] ?? "";
235
+ }
236
+ return `${labels.slice(0, -1).join(", ")} and ${labels.at(-1)}`;
237
+ }
238
+
239
+ function recurringDescription(
240
+ schedule: z.infer<typeof recurringScheduleIntentSchema>,
241
+ timezone: string,
242
+ ): string {
243
+ const interval = schedule.interval ?? 1;
244
+ const cadenceUnit = {
245
+ daily: "day",
246
+ weekly: "week",
247
+ monthly: "month",
248
+ yearly: "year",
249
+ }[schedule.frequency];
250
+ const cadence = interval === 1 ? cadenceUnit : `${interval} ${cadenceUnit}s`;
251
+ let detail = "";
252
+ if (schedule.frequency === "weekly") {
253
+ detail = ` on ${formatWeekdays(schedule.weekdays!)}`;
254
+ } else if (schedule.frequency === "monthly") {
255
+ detail = ` on day ${schedule.day_of_month}`;
256
+ } else if (schedule.frequency === "yearly") {
257
+ detail = ` on ${String(schedule.month).padStart(2, "0")}-${String(
258
+ schedule.day_of_month,
259
+ ).padStart(2, "0")}`;
260
+ }
261
+ return `Every ${cadence}${detail} at ${schedule.time} (${timezone})`;
262
+ }
263
+
264
+ export interface CompiledScheduleIntent {
265
+ nextRunAtMs: number;
266
+ schedule: ScheduledTaskSchedule;
267
+ }
268
+
269
+ export class ScheduleIntentError extends Error {}
270
+
271
+ /** Materialize model-interpreted schedule intent against the trusted server clock. */
272
+ export function compileScheduleIntent(args: {
273
+ defaultTimezone: string;
274
+ intent: ScheduleIntent;
275
+ nowMs: number;
276
+ }): CompiledScheduleIntent {
277
+ const timezone = args.intent.timezone ?? args.defaultTimezone;
278
+ if (!isValidTimeZone(timezone)) {
279
+ throw new ScheduleIntentError("timezone must be a valid IANA time zone.");
280
+ }
281
+
282
+ if (args.intent.kind === "one_off") {
283
+ if (args.intent.timing.type === "after") {
284
+ const nextRunAtMs =
285
+ args.nowMs +
286
+ args.intent.timing.value * DURATION_MS[args.intent.timing.unit];
287
+ return {
288
+ nextRunAtMs,
289
+ schedule: {
290
+ description: `In ${args.intent.timing.value} ${plural(
291
+ args.intent.timing.value,
292
+ args.intent.timing.unit,
293
+ )}`,
294
+ kind: "one_off",
295
+ timezone,
296
+ },
297
+ };
298
+ }
299
+
300
+ const nextRunAtMs = resolveLocalScheduleAtMs({
301
+ date: args.intent.timing.date,
302
+ time: parseLocalTime(args.intent.timing.time),
303
+ timezone,
304
+ });
305
+ if (nextRunAtMs === undefined) {
306
+ throw new ScheduleIntentError(
307
+ "The requested local date or time does not exist in that timezone.",
308
+ );
309
+ }
310
+ if (nextRunAtMs <= args.nowMs) {
311
+ throw new ScheduleIntentError(
312
+ "The requested one-off schedule must be in the future.",
313
+ );
314
+ }
315
+ return {
316
+ nextRunAtMs,
317
+ schedule: {
318
+ description: `Once on ${args.intent.timing.date} at ${args.intent.timing.time} (${timezone})`,
319
+ kind: "one_off",
320
+ timezone,
321
+ },
322
+ };
323
+ }
324
+
325
+ const recurrence: ScheduledTaskRecurrence = {
326
+ frequency: args.intent.frequency,
327
+ interval: args.intent.interval ?? 1,
328
+ startDate: args.intent.start_date ?? localDateAt(args.nowMs, timezone),
329
+ time: parseLocalTime(args.intent.time),
330
+ ...(args.intent.frequency === "weekly"
331
+ ? {
332
+ weekdays: [
333
+ ...new Set(
334
+ args.intent.weekdays!.map((weekday) => WEEKDAY_INDEX[weekday]),
335
+ ),
336
+ ].sort((left, right) => left - right),
337
+ }
338
+ : {}),
339
+ ...(args.intent.frequency === "monthly" ||
340
+ args.intent.frequency === "yearly"
341
+ ? { dayOfMonth: args.intent.day_of_month ?? undefined }
342
+ : {}),
343
+ ...(args.intent.frequency === "yearly"
344
+ ? { month: args.intent.month ?? undefined }
345
+ : {}),
346
+ };
347
+ const searchRecurrence = args.intent.start_date
348
+ ? recurrence
349
+ : { ...recurrence, interval: 1 };
350
+ const nextRunAtMs = getFirstRunAtMs({
351
+ afterMs: args.nowMs,
352
+ recurrence: searchRecurrence,
353
+ timezone,
354
+ });
355
+ if (nextRunAtMs === undefined) {
356
+ throw new ScheduleIntentError(
357
+ "The recurring schedule has no valid future occurrence.",
358
+ );
359
+ }
360
+ const materializedRecurrence = args.intent.start_date
361
+ ? recurrence
362
+ : { ...recurrence, startDate: localDateAt(nextRunAtMs, timezone) };
363
+ return {
364
+ nextRunAtMs,
365
+ schedule: {
366
+ description: recurringDescription(args.intent, timezone),
367
+ kind: "recurring",
368
+ recurrence: materializedRecurrence,
369
+ timezone,
370
+ },
371
+ };
372
+ }
package/src/store.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import {
2
- pluginCredentialSubjectSchema,
3
2
  destinationSchema,
4
3
  isSlackDestination,
5
4
  slackActorSchema,
@@ -114,13 +113,6 @@ const taskRecordSchema = z
114
113
  credentialMode: scheduledTaskCredentialModeSchema,
115
114
  })
116
115
  .strict();
117
- // TODO(v0.101.0): Remove parsing for scheduler task records without credentialMode.
118
- const legacyTaskRecordSchema = z
119
- .object({
120
- ...taskRecordFields,
121
- credentialSubject: pluginCredentialSubjectSchema.optional(),
122
- })
123
- .strict();
124
116
  const runRecordSchema = z
125
117
  .object({
126
118
  id: z.string(),
@@ -148,6 +140,7 @@ const runRecordSchema = z
148
140
 
149
141
  export interface SchedulerStore {
150
142
  claimDueRun(args: { nowMs: number }): Promise<ScheduledRun | undefined>;
143
+ createTask(task: ScheduledTask): Promise<ScheduledTask>;
151
144
  getRun(runId: string): Promise<ScheduledRun | undefined>;
152
145
  getTask(taskId: string): Promise<ScheduledTask | undefined>;
153
146
  listIncompleteRuns(): Promise<ScheduledRun[]>;
@@ -236,6 +229,13 @@ function unique(values: string[]): string[] {
236
229
  return [...new Set(values.filter(Boolean))];
237
230
  }
238
231
 
232
+ const schedulerTaskIndexSchema = z.array(z.string().min(1));
233
+
234
+ /** Parse the persisted scheduler task index without repairing malformed state. */
235
+ function parseStringIndex(value: unknown): string[] {
236
+ return value === undefined ? [] : schedulerTaskIndexSchema.parse(value);
237
+ }
238
+
239
239
  async function withLock<T>(
240
240
  state: PluginState,
241
241
  key: string,
@@ -250,9 +250,7 @@ async function addToIndex(
250
250
  taskId: string,
251
251
  ): Promise<void> {
252
252
  await withLock(state, indexLockKey(key), async () => {
253
- const current = ((await state.get<string[]>(key)) ?? []).filter(
254
- (value): value is string => typeof value === "string",
255
- );
253
+ const current = unique(parseStringIndex(await state.get(key)));
256
254
  await state.set(key, unique([...current, taskId]), SCHEDULER_RECORD_TTL_MS);
257
255
  });
258
256
  }
@@ -263,11 +261,7 @@ async function removeFromIndex(
263
261
  taskId: string,
264
262
  ): Promise<void> {
265
263
  await withLock(state, indexLockKey(key), async () => {
266
- const current = unique(
267
- ((await state.get<string[]>(key)) ?? []).filter(
268
- (value): value is string => typeof value === "string",
269
- ),
270
- );
264
+ const current = unique(parseStringIndex(await state.get(key)));
271
265
  const next = current.filter((value) => value !== taskId);
272
266
  if (next.length === current.length) {
273
267
  return;
@@ -284,10 +278,7 @@ async function getIndex(
284
278
  state: PluginReadState,
285
279
  key: string,
286
280
  ): Promise<string[]> {
287
- const values = (await state.get<string[]>(key)) ?? [];
288
- return unique(
289
- values.filter((value): value is string => typeof value === "string"),
290
- );
281
+ return unique(parseStringIndex(await state.get(key)));
291
282
  }
292
283
 
293
284
  async function clearActiveRun(
@@ -493,25 +484,6 @@ function parseStoredTask(value: unknown): ScheduledTask | undefined {
493
484
  return parsed.success ? stripLegacyTaskFields(parsed.data) : undefined;
494
485
  }
495
486
 
496
- /** Decode pre-credential-mode tasks only for the state-to-SQL migration. */
497
- function parseLegacyStoredTaskForMigration(
498
- value: unknown,
499
- ): ScheduledTask | undefined {
500
- const parsed = legacyTaskRecordSchema.safeParse(parseJsonRecord(value));
501
- if (!parsed.success) {
502
- return undefined;
503
- }
504
- const {
505
- credentialSubject: _credentialSubject,
506
- version: _version,
507
- ...task
508
- } = parsed.data;
509
- return {
510
- ...task,
511
- credentialMode: "system",
512
- };
513
- }
514
-
515
487
  /** Decode retained scheduler run state, skipping invalid legacy records. */
516
488
  function parseStoredRun(value: unknown): ScheduledRun | undefined {
517
489
  const parsed = runRecordSchema.safeParse(parseJsonRecord(value));
@@ -634,6 +606,18 @@ class PluginStateSchedulerStore implements SchedulerStore {
634
606
  this.state = state;
635
607
  }
636
608
 
609
+ async createTask(task: ScheduledTask): Promise<ScheduledTask> {
610
+ const next = requireStoredTask(task);
611
+ return await withLock(this.state, taskLockKey(task.id), async () => {
612
+ const current = await getTaskFromState(this.state, task.id);
613
+ if (current) {
614
+ return current;
615
+ }
616
+ await this.saveTaskRecord(next, undefined);
617
+ return next;
618
+ });
619
+ }
620
+
637
621
  async saveTask(task: ScheduledTask): Promise<void> {
638
622
  const next = requireStoredTask(task);
639
623
  await withLock(this.state, taskLockKey(task.id), async () => {
@@ -1255,6 +1239,18 @@ async function listIncompleteRunsForTasksFromSql(
1255
1239
  class SqlSchedulerStore implements SchedulerStore, SchedulerOperationalStore {
1256
1240
  constructor(private readonly db: SchedulerDb) {}
1257
1241
 
1242
+ async createTask(task: ScheduledTask): Promise<ScheduledTask> {
1243
+ const next = requireStoredTask(task);
1244
+ return await withSqlLock(this.db, taskLockKey(task.id), async (db) => {
1245
+ const current = await getTaskFromSql(db, task.id);
1246
+ if (current) {
1247
+ return current;
1248
+ }
1249
+ await this.saveTaskRecord(db, next, undefined);
1250
+ return next;
1251
+ });
1252
+ }
1253
+
1258
1254
  async saveTask(task: ScheduledTask): Promise<void> {
1259
1255
  const next = requireStoredTask(task);
1260
1256
  await withSqlLock(this.db, taskLockKey(task.id), async (db) => {
@@ -1692,58 +1688,3 @@ export function createSchedulerOperationalSqlStore(
1692
1688
  ): SchedulerOperationalStore {
1693
1689
  return new SqlSchedulerStore(db);
1694
1690
  }
1695
-
1696
- /** Copy retained scheduler plugin-state records into the scheduler SQL tables. */
1697
- export async function migrateSchedulerStateToSql(args: {
1698
- db: SchedulerDb;
1699
- state: PluginState;
1700
- }): Promise<{
1701
- existing: number;
1702
- migrated: number;
1703
- missing: number;
1704
- scanned: number;
1705
- }> {
1706
- const store = createSchedulerSqlStore(args.db);
1707
- const ids = await getIndex(args.state, globalTaskIndexKey());
1708
- let existing = 0;
1709
- let migrated = 0;
1710
- let missing = 0;
1711
- const migratedTasks: ScheduledTask[] = [];
1712
-
1713
- for (const id of ids) {
1714
- const rawTask = await args.state.get(taskKey(id));
1715
- const task =
1716
- parseStoredTask(rawTask) ?? parseLegacyStoredTaskForMigration(rawTask);
1717
- if (!task) {
1718
- missing += 1;
1719
- continue;
1720
- }
1721
- migratedTasks.push(task);
1722
- if (await store.getTask(task.id)) {
1723
- existing += 1;
1724
- continue;
1725
- }
1726
- await store.saveTask(task);
1727
- migrated += 1;
1728
- }
1729
-
1730
- const runs = await listIncompleteRunsForTasksFromState(
1731
- args.state,
1732
- migratedTasks,
1733
- );
1734
- for (const run of runs) {
1735
- if (await store.getRun(run.id)) {
1736
- existing += 1;
1737
- continue;
1738
- }
1739
- await upsertSqlRun(args.db, run);
1740
- migrated += 1;
1741
- }
1742
-
1743
- return {
1744
- existing,
1745
- migrated,
1746
- missing,
1747
- scanned: ids.length + runs.length,
1748
- };
1749
- }