@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,49 @@
1
+ import type {
2
+ DeleteReason,
3
+ RunState,
4
+ WorkflowEvent,
5
+ } from '@tanstack/workflow-core'
6
+ import type {
7
+ WorkflowRunStoreAdapter,
8
+ WorkflowRunStoreAdapterStore,
9
+ } from './types'
10
+
11
+ export function createRunStoreAdapter(
12
+ store: WorkflowRunStoreAdapterStore,
13
+ ): WorkflowRunStoreAdapter {
14
+ return {
15
+ getRunState(runId) {
16
+ return store.loadRunState(runId)
17
+ },
18
+
19
+ setRunState(_runId: string, state: RunState) {
20
+ return store.saveRunState({ state })
21
+ },
22
+
23
+ deleteRun(runId: string, reason: DeleteReason) {
24
+ return store.deleteRun(runId, reason)
25
+ },
26
+
27
+ async appendEvent(
28
+ runId: string,
29
+ expectedNextIndex: number,
30
+ event: WorkflowEvent,
31
+ ) {
32
+ await store.appendEvents({
33
+ runId,
34
+ expectedNextIndex,
35
+ events: [event],
36
+ })
37
+ },
38
+
39
+ async getEvents(runId: string) {
40
+ const events = await store.readEvents({ runId })
41
+ return events.map((event) => event.event)
42
+ },
43
+
44
+ subscribe: store.subscribeEvents
45
+ ? (runId, fromIndex, onEvent) =>
46
+ store.subscribeEvents!(runId, fromIndex, onEvent)
47
+ : undefined,
48
+ }
49
+ }
@@ -0,0 +1,536 @@
1
+ import { runWorkflow } from '@tanstack/workflow-core'
2
+ import { createRunStoreAdapter } from './run-store-adapter'
3
+ import type {
4
+ AnyWorkflowDefinition,
5
+ WorkflowEvent,
6
+ } from '@tanstack/workflow-core'
7
+ import type {
8
+ DeliverApprovalResult,
9
+ DeliverSignalResult,
10
+ TimerWakeup,
11
+ WorkflowExecution,
12
+ WorkflowRegistration,
13
+ WorkflowRuntimeConfig,
14
+ WorkflowRuntimeDeliverApprovalArgs,
15
+ WorkflowRuntimeDeliverSignalArgs,
16
+ WorkflowRuntimeRunResult,
17
+ WorkflowRuntimeRunResultKind,
18
+ WorkflowRuntimeStartRunArgs,
19
+ WorkflowRuntimeSweepArgs,
20
+ WorkflowRuntimeSweepResult,
21
+ } from './types'
22
+
23
+ const DEFAULT_LEASE_MS = 30_000
24
+ const DEFAULT_SWEEP_LIMIT = 25
25
+
26
+ export function createRuntimeDriver<
27
+ TWorkflows extends Record<string, WorkflowRegistration>,
28
+ >(config: WorkflowRuntimeConfig<TWorkflows>) {
29
+ return {
30
+ startRun(args: WorkflowRuntimeStartRunArgs) {
31
+ return startRun(config, args)
32
+ },
33
+ deliverSignal<TPayload = unknown>(
34
+ args: WorkflowRuntimeDeliverSignalArgs<TPayload>,
35
+ ) {
36
+ return deliverSignal(config, args)
37
+ },
38
+ deliverApproval(args: WorkflowRuntimeDeliverApprovalArgs) {
39
+ return deliverApproval(config, args)
40
+ },
41
+ sweep(args: WorkflowRuntimeSweepArgs = {}) {
42
+ return sweep(config, args)
43
+ },
44
+ }
45
+ }
46
+
47
+ async function startRun<
48
+ TWorkflows extends Record<string, WorkflowRegistration>,
49
+ >(
50
+ config: WorkflowRuntimeConfig<TWorkflows>,
51
+ args: WorkflowRuntimeStartRunArgs,
52
+ ): Promise<WorkflowRuntimeRunResult> {
53
+ const now = args.now ?? Date.now()
54
+ const workflow = await loadWorkflow(config, args.workflowId)
55
+ const workflowVersion = workflow.version
56
+ await config.store.createRun({
57
+ runId: args.runId,
58
+ workflowId: args.workflowId,
59
+ workflowVersion,
60
+ input: args.input,
61
+ now,
62
+ })
63
+
64
+ return driveClaimedRun(config, {
65
+ workflow,
66
+ workflowId: args.workflowId,
67
+ runId: args.runId,
68
+ input: args.input,
69
+ now,
70
+ leaseOwner: args.leaseOwner,
71
+ leaseMs: args.leaseMs,
72
+ threadId: args.threadId,
73
+ includeEvents: args.includeEvents,
74
+ maxEvents: args.maxEvents,
75
+ })
76
+ }
77
+
78
+ async function deliverSignal<
79
+ TWorkflows extends Record<string, WorkflowRegistration>,
80
+ TPayload,
81
+ >(
82
+ config: WorkflowRuntimeConfig<TWorkflows>,
83
+ args: WorkflowRuntimeDeliverSignalArgs<TPayload>,
84
+ ): Promise<WorkflowRuntimeRunResult> {
85
+ const now = args.now ?? Date.now()
86
+ const delivery = {
87
+ signalId: args.signalId,
88
+ name: args.name,
89
+ payload: args.payload,
90
+ }
91
+ const delivered = await config.store.deliverSignal({
92
+ runId: args.runId,
93
+ delivery,
94
+ now,
95
+ })
96
+ if (delivered.kind !== 'delivered') {
97
+ return resultFromSignalDelivery(args.runId, delivered)
98
+ }
99
+
100
+ const workflow = await loadWorkflow(config, delivered.run.workflowId)
101
+ return driveClaimedRun(config, {
102
+ workflow,
103
+ workflowId: delivered.run.workflowId,
104
+ runId: args.runId,
105
+ signalDelivery: delivery,
106
+ now,
107
+ leaseOwner: args.leaseOwner,
108
+ leaseMs: args.leaseMs,
109
+ threadId: args.threadId,
110
+ includeEvents: args.includeEvents,
111
+ maxEvents: args.maxEvents,
112
+ })
113
+ }
114
+
115
+ async function deliverApproval<
116
+ TWorkflows extends Record<string, WorkflowRegistration>,
117
+ >(
118
+ config: WorkflowRuntimeConfig<TWorkflows>,
119
+ args: WorkflowRuntimeDeliverApprovalArgs,
120
+ ): Promise<WorkflowRuntimeRunResult> {
121
+ const now = args.now ?? Date.now()
122
+ const delivered = await config.store.deliverApproval({
123
+ runId: args.runId,
124
+ approval: args.approval,
125
+ now,
126
+ })
127
+ if (delivered.kind !== 'delivered') {
128
+ return resultFromApprovalDelivery(args.runId, delivered)
129
+ }
130
+
131
+ const workflow = await loadWorkflow(config, delivered.run.workflowId)
132
+ return driveClaimedRun(config, {
133
+ workflow,
134
+ workflowId: delivered.run.workflowId,
135
+ runId: args.runId,
136
+ approval: args.approval,
137
+ now,
138
+ leaseOwner: args.leaseOwner,
139
+ leaseMs: args.leaseMs,
140
+ threadId: args.threadId,
141
+ includeEvents: args.includeEvents,
142
+ maxEvents: args.maxEvents,
143
+ })
144
+ }
145
+
146
+ async function sweep<TWorkflows extends Record<string, WorkflowRegistration>>(
147
+ config: WorkflowRuntimeConfig<TWorkflows>,
148
+ args: WorkflowRuntimeSweepArgs,
149
+ ): Promise<WorkflowRuntimeSweepResult> {
150
+ const now = args.now ?? Date.now()
151
+ const startedAt = Date.now()
152
+ const maxScheduledRuns = normalizeSweepLimit(
153
+ args.maxScheduledRuns ?? args.limit,
154
+ DEFAULT_SWEEP_LIMIT,
155
+ 'maxScheduledRuns',
156
+ )
157
+ const maxTimers = normalizeSweepLimit(
158
+ args.maxTimers ?? args.limit,
159
+ DEFAULT_SWEEP_LIMIT,
160
+ 'maxTimers',
161
+ )
162
+ const leaseOwner = args.leaseOwner ?? `sweep:${now}`
163
+ const leaseMs = args.leaseMs ?? config.defaultLeaseMs ?? DEFAULT_LEASE_MS
164
+ const scheduled: Array<WorkflowRuntimeRunResult> = []
165
+ const timers: Array<WorkflowRuntimeRunResult> = []
166
+ let deadlineReached = false
167
+
168
+ while (scheduled.length < maxScheduledRuns) {
169
+ if (isPastSweepDeadline(startedAt, args.maxDurationMs)) {
170
+ deadlineReached = true
171
+ break
172
+ }
173
+
174
+ const buckets = await config.store.claimDueScheduleBuckets({
175
+ now,
176
+ limit: 1,
177
+ leaseOwner,
178
+ leaseMs,
179
+ })
180
+ const bucket = buckets[0]
181
+ if (!bucket) break
182
+
183
+ const result = await startRun(config, {
184
+ workflowId: bucket.workflowId,
185
+ runId: bucket.runId,
186
+ input: bucket.input,
187
+ now,
188
+ leaseOwner,
189
+ leaseMs,
190
+ includeEvents: args.includeEvents,
191
+ maxEvents: args.maxEvents,
192
+ })
193
+ if (result.kind !== 'not-claimable' && result.kind !== 'not-found') {
194
+ await config.store.markScheduleBucketStarted({
195
+ scheduleId: bucket.scheduleId,
196
+ bucketId: bucket.bucketId,
197
+ runId: bucket.runId,
198
+ now,
199
+ })
200
+ }
201
+ scheduled.push(result)
202
+ }
203
+
204
+ while (timers.length < maxTimers) {
205
+ if (isPastSweepDeadline(startedAt, args.maxDurationMs)) {
206
+ deadlineReached = true
207
+ break
208
+ }
209
+
210
+ const dueTimers = await config.store.claimDueTimers({
211
+ now,
212
+ limit: 1,
213
+ leaseOwner,
214
+ leaseMs,
215
+ })
216
+ const timer = dueTimers[0]
217
+ if (!timer) break
218
+
219
+ timers.push(
220
+ await deliverTimer(config, {
221
+ timer,
222
+ now,
223
+ leaseOwner,
224
+ leaseMs,
225
+ includeEvents: args.includeEvents,
226
+ maxEvents: args.maxEvents,
227
+ }),
228
+ )
229
+ }
230
+
231
+ return {
232
+ scheduled,
233
+ timers,
234
+ summary: summarizeSweep(scheduled, timers),
235
+ deadlineReached,
236
+ remainingMayExist:
237
+ deadlineReached ||
238
+ scheduled.length >= maxScheduledRuns ||
239
+ timers.length >= maxTimers,
240
+ }
241
+ }
242
+
243
+ async function deliverTimer<
244
+ TWorkflows extends Record<string, WorkflowRegistration>,
245
+ >(
246
+ config: WorkflowRuntimeConfig<TWorkflows>,
247
+ args: {
248
+ timer: TimerWakeup
249
+ now: number
250
+ leaseOwner: string
251
+ leaseMs: number
252
+ includeEvents?: boolean
253
+ maxEvents?: number
254
+ },
255
+ ) {
256
+ return deliverSignal(config, {
257
+ runId: args.timer.runId,
258
+ signalId: args.timer.signalId,
259
+ name: '__timer',
260
+ payload: undefined,
261
+ now: args.now,
262
+ leaseOwner: args.leaseOwner,
263
+ leaseMs: args.leaseMs,
264
+ includeEvents: args.includeEvents,
265
+ maxEvents: args.maxEvents,
266
+ })
267
+ }
268
+
269
+ async function driveClaimedRun<
270
+ TWorkflows extends Record<string, WorkflowRegistration>,
271
+ >(
272
+ config: WorkflowRuntimeConfig<TWorkflows>,
273
+ args: {
274
+ workflow: AnyWorkflowDefinition
275
+ workflowId: string
276
+ runId: string
277
+ input?: unknown
278
+ signalDelivery?: Parameters<typeof runWorkflow>[0]['signalDelivery']
279
+ approval?: Parameters<typeof runWorkflow>[0]['approval']
280
+ now: number
281
+ leaseOwner?: string
282
+ leaseMs?: number
283
+ threadId?: string
284
+ includeEvents?: boolean
285
+ maxEvents?: number
286
+ },
287
+ ): Promise<WorkflowRuntimeRunResult> {
288
+ const leaseOwner = args.leaseOwner ?? `runtime:${args.runId}`
289
+ const leaseMs = args.leaseMs ?? config.defaultLeaseMs ?? DEFAULT_LEASE_MS
290
+ const claim = await config.store.claimRun({
291
+ runId: args.runId,
292
+ leaseOwner,
293
+ leaseMs,
294
+ now: args.now,
295
+ })
296
+
297
+ if (claim.kind === 'not-found') {
298
+ return {
299
+ kind: 'not-found',
300
+ runId: args.runId,
301
+ workflowId: args.workflowId,
302
+ eventCount: 0,
303
+ events: [],
304
+ }
305
+ }
306
+ if (claim.kind === 'not-claimable') {
307
+ return {
308
+ kind: 'not-claimable',
309
+ runId: args.runId,
310
+ workflowId: args.workflowId,
311
+ run: claim.run,
312
+ eventCount: 0,
313
+ events: [],
314
+ }
315
+ }
316
+
317
+ const runStore = createRunStoreAdapter(config.store)
318
+ const collected = await collectWorkflowEvents(
319
+ runWorkflow({
320
+ workflow: args.workflow,
321
+ runStore,
322
+ runId: args.runId,
323
+ input: args.input,
324
+ signalDelivery: args.signalDelivery,
325
+ approval: args.approval,
326
+ threadId: args.threadId,
327
+ }),
328
+ {
329
+ includeEvents: args.includeEvents ?? true,
330
+ maxEvents: args.maxEvents,
331
+ },
332
+ )
333
+
334
+ await syncTimerFromRunState(config, args.runId, args.workflowId, args.now)
335
+ await config.store.releaseRunLease({ runId: args.runId, leaseOwner })
336
+
337
+ const run = await config.store.loadRun(args.runId)
338
+ return {
339
+ kind: classifyRun(run, collected.eventCount),
340
+ runId: args.runId,
341
+ workflowId: args.workflowId,
342
+ run,
343
+ events: collected.events,
344
+ eventCount: collected.eventCount,
345
+ eventsTruncated: collected.eventsTruncated || undefined,
346
+ }
347
+ }
348
+
349
+ async function syncTimerFromRunState<
350
+ TWorkflows extends Record<string, WorkflowRegistration>,
351
+ >(
352
+ config: WorkflowRuntimeConfig<TWorkflows>,
353
+ runId: string,
354
+ workflowId: string,
355
+ now: number,
356
+ ) {
357
+ const state = await config.store.loadRunState(runId)
358
+ const deadline = state?.waitingFor?.deadline
359
+ if (state?.waitingFor?.signalName !== '__timer' || deadline === undefined) {
360
+ return
361
+ }
362
+
363
+ await config.store.scheduleTimer({
364
+ runId,
365
+ workflowId,
366
+ workflowVersion: state.workflowVersion,
367
+ wakeAt: deadline,
368
+ signalId: `timer:${runId}:${deadline}`,
369
+ now,
370
+ })
371
+ }
372
+
373
+ async function loadWorkflow<
374
+ TWorkflows extends Record<string, WorkflowRegistration>,
375
+ >(
376
+ config: WorkflowRuntimeConfig<TWorkflows>,
377
+ workflowId: string,
378
+ ): Promise<AnyWorkflowDefinition> {
379
+ const registration = config.workflows[workflowId]
380
+ if (!registration) {
381
+ throw new Error(`Workflow "${workflowId}" is not registered.`)
382
+ }
383
+
384
+ const workflow = normalizeWorkflowLoaderResult(await registration.load())
385
+ const previousVersions = []
386
+ for (const loadPrevious of Object.values(
387
+ registration.previousVersions ?? {},
388
+ )) {
389
+ previousVersions.push(normalizeWorkflowLoaderResult(await loadPrevious()))
390
+ }
391
+
392
+ if (registration.version || previousVersions.length > 0) {
393
+ return {
394
+ ...workflow,
395
+ version: registration.version ?? workflow.version,
396
+ previousVersions: [
397
+ ...(workflow.previousVersions ?? []),
398
+ ...previousVersions,
399
+ ],
400
+ }
401
+ }
402
+
403
+ return workflow
404
+ }
405
+
406
+ function normalizeWorkflowLoaderResult(
407
+ result: Awaited<ReturnType<WorkflowRegistration['load']>>,
408
+ ): AnyWorkflowDefinition {
409
+ if ('__kind' in result) return result
410
+ if ('default' in result) return result.default
411
+ return result.workflow
412
+ }
413
+
414
+ function resultFromSignalDelivery(
415
+ runId: string,
416
+ result: Exclude<DeliverSignalResult, { kind: 'delivered' }>,
417
+ ): WorkflowRuntimeRunResult {
418
+ return {
419
+ kind: result.kind,
420
+ runId,
421
+ run: 'run' in result ? result.run : undefined,
422
+ workflowId: 'run' in result ? result.run.workflowId : undefined,
423
+ events: [],
424
+ eventCount: 0,
425
+ }
426
+ }
427
+
428
+ function resultFromApprovalDelivery(
429
+ runId: string,
430
+ result: Exclude<DeliverApprovalResult, { kind: 'delivered' }>,
431
+ ): WorkflowRuntimeRunResult {
432
+ return {
433
+ kind: result.kind,
434
+ runId,
435
+ run: 'run' in result ? result.run : undefined,
436
+ workflowId: 'run' in result ? result.run.workflowId : undefined,
437
+ events: [],
438
+ eventCount: 0,
439
+ }
440
+ }
441
+
442
+ function classifyRun(
443
+ run: WorkflowExecution | undefined,
444
+ eventCount: number,
445
+ ): WorkflowRuntimeRunResult['kind'] {
446
+ if (run?.status === 'finished') return 'completed'
447
+ if (run?.status === 'paused') return 'paused'
448
+ if (run?.status === 'errored' || run?.status === 'aborted') return 'errored'
449
+ if (run?.status === 'running' || run?.status === 'queued') return 'running'
450
+ return eventCount > 0 ? 'running' : 'not-found'
451
+ }
452
+
453
+ function normalizeSweepLimit(
454
+ value: number | undefined,
455
+ fallback: number,
456
+ label: string,
457
+ ) {
458
+ const limit = value ?? fallback
459
+ if (!Number.isInteger(limit) || limit < 0) {
460
+ throw new Error(`Workflow sweep ${label} must be a non-negative integer.`)
461
+ }
462
+ return limit
463
+ }
464
+
465
+ function isPastSweepDeadline(
466
+ startedAt: number,
467
+ maxDurationMs: number | undefined,
468
+ ) {
469
+ return maxDurationMs !== undefined && Date.now() - startedAt >= maxDurationMs
470
+ }
471
+
472
+ function summarizeSweep(
473
+ scheduled: ReadonlyArray<WorkflowRuntimeRunResult>,
474
+ timers: ReadonlyArray<WorkflowRuntimeRunResult>,
475
+ ): WorkflowRuntimeSweepResult['summary'] {
476
+ return {
477
+ scheduled: countRunKinds(scheduled),
478
+ timers: countRunKinds(timers),
479
+ eventCount: sumEventCounts(scheduled) + sumEventCounts(timers),
480
+ returnedEventCount:
481
+ sumReturnedEventCounts(scheduled) + sumReturnedEventCounts(timers),
482
+ }
483
+ }
484
+
485
+ function countRunKinds(runs: ReadonlyArray<WorkflowRuntimeRunResult>) {
486
+ const counts: Partial<Record<WorkflowRuntimeRunResultKind, number>> = {}
487
+ for (const run of runs) {
488
+ counts[run.kind] = (counts[run.kind] ?? 0) + 1
489
+ }
490
+ return counts
491
+ }
492
+
493
+ function sumEventCounts(runs: ReadonlyArray<WorkflowRuntimeRunResult>) {
494
+ return runs.reduce((sum, run) => sum + run.eventCount, 0)
495
+ }
496
+
497
+ function sumReturnedEventCounts(runs: ReadonlyArray<WorkflowRuntimeRunResult>) {
498
+ return runs.reduce((sum, run) => sum + run.events.length, 0)
499
+ }
500
+
501
+ async function collectWorkflowEvents(
502
+ iterable: AsyncIterable<WorkflowEvent>,
503
+ options: {
504
+ includeEvents: boolean
505
+ maxEvents?: number
506
+ },
507
+ ) {
508
+ if (
509
+ options.maxEvents !== undefined &&
510
+ (!Number.isInteger(options.maxEvents) || options.maxEvents < 0)
511
+ ) {
512
+ throw new Error(
513
+ 'Workflow event collection maxEvents must be a non-negative integer.',
514
+ )
515
+ }
516
+
517
+ const events: Array<WorkflowEvent> = []
518
+ let eventCount = 0
519
+ let eventsTruncated = false
520
+
521
+ for await (const event of iterable) {
522
+ eventCount++
523
+ if (!options.includeEvents) continue
524
+ if (options.maxEvents === undefined || events.length < options.maxEvents) {
525
+ events.push(event)
526
+ } else {
527
+ eventsTruncated = true
528
+ }
529
+ }
530
+
531
+ return {
532
+ events,
533
+ eventCount,
534
+ eventsTruncated,
535
+ }
536
+ }