@tanstack/workflow-runtime 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 (47) hide show
  1. package/README.md +22 -0
  2. package/dist/define-runtime.cjs +50 -0
  3. package/dist/define-runtime.cjs.map +1 -0
  4. package/dist/define-runtime.d.cts +16 -0
  5. package/dist/define-runtime.d.ts +16 -0
  6. package/dist/define-runtime.js +48 -0
  7. package/dist/define-runtime.js.map +1 -0
  8. package/dist/in-memory-store.cjs +457 -0
  9. package/dist/in-memory-store.cjs.map +1 -0
  10. package/dist/in-memory-store.d.cts +8 -0
  11. package/dist/in-memory-store.d.ts +8 -0
  12. package/dist/in-memory-store.js +457 -0
  13. package/dist/in-memory-store.js.map +1 -0
  14. package/dist/index.cjs +14 -0
  15. package/dist/index.d.cts +7 -0
  16. package/dist/index.d.ts +7 -0
  17. package/dist/index.js +7 -0
  18. package/dist/run-store-adapter.cjs +30 -0
  19. package/dist/run-store-adapter.cjs.map +1 -0
  20. package/dist/run-store-adapter.d.cts +7 -0
  21. package/dist/run-store-adapter.d.ts +7 -0
  22. package/dist/run-store-adapter.js +29 -0
  23. package/dist/run-store-adapter.js.map +1 -0
  24. package/dist/runtime-driver.cjs +334 -0
  25. package/dist/runtime-driver.cjs.map +1 -0
  26. package/dist/runtime-driver.d.cts +12 -0
  27. package/dist/runtime-driver.d.ts +12 -0
  28. package/dist/runtime-driver.js +334 -0
  29. package/dist/runtime-driver.js.map +1 -0
  30. package/dist/schedule-materializer.cjs +156 -0
  31. package/dist/schedule-materializer.cjs.map +1 -0
  32. package/dist/schedule-materializer.d.cts +28 -0
  33. package/dist/schedule-materializer.d.ts +28 -0
  34. package/dist/schedule-materializer.js +155 -0
  35. package/dist/schedule-materializer.js.map +1 -0
  36. package/dist/types.cjs +0 -0
  37. package/dist/types.d.cts +375 -0
  38. package/dist/types.d.ts +375 -0
  39. package/dist/types.js +1 -0
  40. package/package.json +60 -0
  41. package/src/define-runtime.ts +46 -0
  42. package/src/in-memory-store.ts +607 -0
  43. package/src/index.ts +74 -0
  44. package/src/run-store-adapter.ts +49 -0
  45. package/src/runtime-driver.ts +536 -0
  46. package/src/schedule-materializer.ts +272 -0
  47. package/src/types.ts +462 -0
@@ -0,0 +1,272 @@
1
+ import type {
2
+ ScheduleId,
3
+ WorkflowRegistrationMap,
4
+ WorkflowRuntimeDefinition,
5
+ WorkflowScheduleDefinition,
6
+ WorkflowScheduleSpec,
7
+ } from './types'
8
+
9
+ const DEFAULT_CRON_LOOKBACK_MS = 32 * 24 * 60 * 60 * 1000
10
+
11
+ export interface MaterializeWorkflowSchedulesOptions {
12
+ now?: number
13
+ cronLookbackMs?: number
14
+ }
15
+
16
+ export type MaterializedWorkflowSchedule =
17
+ | {
18
+ kind: 'materialized'
19
+ workflowId: string
20
+ scheduleId: ScheduleId
21
+ fireAt: number
22
+ schedule: WorkflowScheduleSpec
23
+ }
24
+ | {
25
+ kind: 'disabled'
26
+ workflowId: string
27
+ scheduleId: ScheduleId
28
+ schedule: WorkflowScheduleSpec
29
+ }
30
+ | {
31
+ kind: 'not-due'
32
+ workflowId: string
33
+ scheduleId: ScheduleId
34
+ schedule: WorkflowScheduleSpec
35
+ }
36
+
37
+ export async function materializeWorkflowSchedules<
38
+ TWorkflows extends WorkflowRegistrationMap,
39
+ >(
40
+ runtime: WorkflowRuntimeDefinition<TWorkflows>,
41
+ options: MaterializeWorkflowSchedulesOptions = {},
42
+ ): Promise<Array<MaterializedWorkflowSchedule>> {
43
+ const now = options.now ?? Date.now()
44
+ const cronLookbackMs = options.cronLookbackMs ?? DEFAULT_CRON_LOOKBACK_MS
45
+ const materialized: Array<MaterializedWorkflowSchedule> = []
46
+
47
+ if (!Number.isFinite(cronLookbackMs) || cronLookbackMs < 0) {
48
+ throw new Error('Workflow cron lookback must be a non-negative number.')
49
+ }
50
+
51
+ for (const [workflowId, registration] of Object.entries(runtime.workflows)) {
52
+ const schedules = registration.schedules ?? []
53
+ for (let index = 0; index < schedules.length; index++) {
54
+ const definition = schedules[index]!
55
+ const scheduleId = getScheduleId(workflowId, definition, index)
56
+
57
+ if (definition.enabled === false) {
58
+ await runtime.store.upsertSchedule({
59
+ scheduleId,
60
+ workflowId,
61
+ workflowVersion: registration.version,
62
+ schedule: definition.schedule,
63
+ overlapPolicy: definition.overlapPolicy ?? 'skip',
64
+ input: undefined,
65
+ nextFireAt: undefined,
66
+ enabled: false,
67
+ now,
68
+ })
69
+ materialized.push({
70
+ kind: 'disabled',
71
+ workflowId,
72
+ scheduleId,
73
+ schedule: definition.schedule,
74
+ })
75
+ continue
76
+ }
77
+
78
+ const fireAt = getDueFireAt(definition.schedule, now, cronLookbackMs)
79
+ if (fireAt === undefined) {
80
+ materialized.push({
81
+ kind: 'not-due',
82
+ workflowId,
83
+ scheduleId,
84
+ schedule: definition.schedule,
85
+ })
86
+ continue
87
+ }
88
+
89
+ await runtime.store.upsertSchedule({
90
+ scheduleId,
91
+ workflowId,
92
+ workflowVersion: registration.version,
93
+ schedule: definition.schedule,
94
+ overlapPolicy: definition.overlapPolicy ?? 'skip',
95
+ input: await resolveScheduleInput(definition.input),
96
+ nextFireAt: fireAt,
97
+ enabled: true,
98
+ now,
99
+ })
100
+ materialized.push({
101
+ kind: 'materialized',
102
+ workflowId,
103
+ scheduleId,
104
+ fireAt,
105
+ schedule: definition.schedule,
106
+ })
107
+ }
108
+ }
109
+
110
+ return materialized
111
+ }
112
+
113
+ function getScheduleId(
114
+ workflowId: string,
115
+ definition: WorkflowScheduleDefinition,
116
+ index: number,
117
+ ): ScheduleId {
118
+ return definition.id ?? `${workflowId}:${index}`
119
+ }
120
+
121
+ async function resolveScheduleInput(
122
+ input: WorkflowScheduleDefinition['input'],
123
+ ) {
124
+ return typeof input === 'function' ? await input() : input
125
+ }
126
+
127
+ function getDueFireAt(
128
+ schedule: WorkflowScheduleSpec,
129
+ now: number,
130
+ cronLookbackMs: number,
131
+ ) {
132
+ if (schedule.kind === 'interval') {
133
+ if (!Number.isFinite(schedule.everyMs) || schedule.everyMs <= 0) {
134
+ throw new Error(
135
+ 'Interval workflow schedules must use a positive everyMs.',
136
+ )
137
+ }
138
+ return Math.floor(now / schedule.everyMs) * schedule.everyMs
139
+ }
140
+
141
+ return getPreviousCronFireAt(schedule, now, cronLookbackMs)
142
+ }
143
+
144
+ function getPreviousCronFireAt(
145
+ schedule: Extract<WorkflowScheduleSpec, { kind: 'cron' }>,
146
+ now: number,
147
+ lookbackMs: number,
148
+ ) {
149
+ if (schedule.timezone && schedule.timezone !== 'UTC') {
150
+ throw new Error(
151
+ `Workflow cron schedules are materialized in UTC. Received timezone "${schedule.timezone}".`,
152
+ )
153
+ }
154
+
155
+ const cron = parseCronExpression(schedule.expression)
156
+ const start = floorToMinute(now)
157
+ const end = start - lookbackMs
158
+
159
+ for (let timestamp = start; timestamp >= end; timestamp -= 60_000) {
160
+ if (matchesCron(cron, new Date(timestamp))) return timestamp
161
+ }
162
+
163
+ return undefined
164
+ }
165
+
166
+ interface ParsedCronExpression {
167
+ minute: ParsedCronField
168
+ hour: ParsedCronField
169
+ dayOfMonth: ParsedCronField
170
+ month: ParsedCronField
171
+ dayOfWeek: ParsedCronField
172
+ }
173
+
174
+ interface ParsedCronField {
175
+ wildcard: boolean
176
+ values: ReadonlySet<number>
177
+ }
178
+
179
+ function parseCronExpression(expression: string): ParsedCronExpression {
180
+ const fields = expression.trim().split(/\s+/)
181
+ if (fields.length !== 5) {
182
+ throw new Error(
183
+ `Workflow cron schedules must use five fields. Received "${expression}".`,
184
+ )
185
+ }
186
+
187
+ return {
188
+ minute: parseCronField(fields[0]!, 0, 59),
189
+ hour: parseCronField(fields[1]!, 0, 23),
190
+ dayOfMonth: parseCronField(fields[2]!, 1, 31),
191
+ month: parseCronField(fields[3]!, 1, 12),
192
+ dayOfWeek: parseCronField(fields[4]!, 0, 7, normalizeDayOfWeek),
193
+ }
194
+ }
195
+
196
+ function parseCronField(
197
+ field: string,
198
+ min: number,
199
+ max: number,
200
+ normalize: (value: number) => number = (value) => value,
201
+ ): ParsedCronField {
202
+ const values = new Set<number>()
203
+ const parts = field.split(',')
204
+
205
+ for (const part of parts) {
206
+ const [rangePart, stepPart] = part.split('/')
207
+ const step = stepPart === undefined ? 1 : Number(stepPart)
208
+ if (!Number.isInteger(step) || step <= 0) {
209
+ throw new Error(`Invalid cron step "${part}".`)
210
+ }
211
+
212
+ const range = parseCronRange(rangePart!, min, max)
213
+ for (let value = range.start; value <= range.end; value += step) {
214
+ values.add(normalize(value))
215
+ }
216
+ }
217
+
218
+ return {
219
+ wildcard: field === '*',
220
+ values,
221
+ }
222
+ }
223
+
224
+ function parseCronRange(range: string, min: number, max: number) {
225
+ if (range === '*') return { start: min, end: max }
226
+
227
+ const bounds = range.split('-')
228
+ if (bounds.length === 1) {
229
+ const value = parseCronNumber(bounds[0]!, min, max)
230
+ return { start: value, end: value }
231
+ }
232
+ if (bounds.length === 2) {
233
+ const start = parseCronNumber(bounds[0]!, min, max)
234
+ const end = parseCronNumber(bounds[1]!, min, max)
235
+ if (end < start) throw new Error(`Invalid cron range "${range}".`)
236
+ return { start, end }
237
+ }
238
+
239
+ throw new Error(`Invalid cron range "${range}".`)
240
+ }
241
+
242
+ function parseCronNumber(value: string, min: number, max: number) {
243
+ const parsed = Number(value)
244
+ if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
245
+ throw new Error(`Invalid cron value "${value}".`)
246
+ }
247
+ return parsed
248
+ }
249
+
250
+ function normalizeDayOfWeek(value: number) {
251
+ return value === 7 ? 0 : value
252
+ }
253
+
254
+ function matchesCron(cron: ParsedCronExpression, date: Date) {
255
+ const dayOfMonthMatches = cron.dayOfMonth.values.has(date.getUTCDate())
256
+ const dayOfWeekMatches = cron.dayOfWeek.values.has(date.getUTCDay())
257
+ const dayMatches =
258
+ !cron.dayOfMonth.wildcard && !cron.dayOfWeek.wildcard
259
+ ? dayOfMonthMatches || dayOfWeekMatches
260
+ : dayOfMonthMatches && dayOfWeekMatches
261
+
262
+ return (
263
+ cron.minute.values.has(date.getUTCMinutes()) &&
264
+ cron.hour.values.has(date.getUTCHours()) &&
265
+ dayMatches &&
266
+ cron.month.values.has(date.getUTCMonth() + 1)
267
+ )
268
+ }
269
+
270
+ function floorToMinute(timestamp: number) {
271
+ return Math.floor(timestamp / 60_000) * 60_000
272
+ }