@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.
- package/README.md +22 -0
- package/dist/define-runtime.cjs +50 -0
- package/dist/define-runtime.cjs.map +1 -0
- package/dist/define-runtime.d.cts +16 -0
- package/dist/define-runtime.d.ts +16 -0
- package/dist/define-runtime.js +48 -0
- package/dist/define-runtime.js.map +1 -0
- package/dist/in-memory-store.cjs +457 -0
- package/dist/in-memory-store.cjs.map +1 -0
- package/dist/in-memory-store.d.cts +8 -0
- package/dist/in-memory-store.d.ts +8 -0
- package/dist/in-memory-store.js +457 -0
- package/dist/in-memory-store.js.map +1 -0
- package/dist/index.cjs +14 -0
- package/dist/index.d.cts +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/run-store-adapter.cjs +30 -0
- package/dist/run-store-adapter.cjs.map +1 -0
- package/dist/run-store-adapter.d.cts +7 -0
- package/dist/run-store-adapter.d.ts +7 -0
- package/dist/run-store-adapter.js +29 -0
- package/dist/run-store-adapter.js.map +1 -0
- package/dist/runtime-driver.cjs +334 -0
- package/dist/runtime-driver.cjs.map +1 -0
- package/dist/runtime-driver.d.cts +12 -0
- package/dist/runtime-driver.d.ts +12 -0
- package/dist/runtime-driver.js +334 -0
- package/dist/runtime-driver.js.map +1 -0
- package/dist/schedule-materializer.cjs +156 -0
- package/dist/schedule-materializer.cjs.map +1 -0
- package/dist/schedule-materializer.d.cts +28 -0
- package/dist/schedule-materializer.d.ts +28 -0
- package/dist/schedule-materializer.js +155 -0
- package/dist/schedule-materializer.js.map +1 -0
- package/dist/types.cjs +0 -0
- package/dist/types.d.cts +375 -0
- package/dist/types.d.ts +375 -0
- package/dist/types.js +1 -0
- package/package.json +60 -0
- package/src/define-runtime.ts +46 -0
- package/src/in-memory-store.ts +607 -0
- package/src/index.ts +74 -0
- package/src/run-store-adapter.ts +49 -0
- package/src/runtime-driver.ts +536 -0
- package/src/schedule-materializer.ts +272 -0
- 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
|
+
}
|