@tanstack/workflow-store-drizzle-postgres 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/src/store.ts ADDED
@@ -0,0 +1,1278 @@
1
+ import { LogConflictError } from '@tanstack/workflow-core'
2
+ import { sql } from 'drizzle-orm'
3
+ import type { RunState, WorkflowEvent } from '@tanstack/workflow-core'
4
+ import type { SQL } from 'drizzle-orm'
5
+ import type {
6
+ AppendEventsArgs,
7
+ AppendEventsResult,
8
+ ClaimDueScheduleBucketsArgs,
9
+ ClaimDueTimersArgs,
10
+ ClaimRunArgs,
11
+ ClaimRunResult,
12
+ ClaimStaleRunsArgs,
13
+ CreateRunArgs,
14
+ CreateRunResult,
15
+ DeliverApprovalArgs,
16
+ DeliverApprovalResult,
17
+ DeliverSignalArgs,
18
+ DeliverSignalResult,
19
+ HeartbeatRunLeaseArgs,
20
+ ListRunsArgs,
21
+ LoadedExecution,
22
+ MarkRunErroredArgs,
23
+ MarkRunFinishedArgs,
24
+ MarkRunPausedArgs,
25
+ MarkScheduleBucketStartedArgs,
26
+ ReadEventsArgs,
27
+ ReleaseRunLeaseArgs,
28
+ RunClaim,
29
+ RunId,
30
+ RunSummary,
31
+ RunTimeline,
32
+ SaveRunStateArgs,
33
+ ScheduleBucket,
34
+ ScheduleBucketId,
35
+ ScheduleId,
36
+ ScheduleTimerArgs,
37
+ StoredWorkflowEvent,
38
+ TimerWakeup,
39
+ UpsertScheduleArgs,
40
+ WorkflowExecution,
41
+ WorkflowExecutionStatus,
42
+ WorkflowExecutionStore,
43
+ WorkflowRunStoreAdapterStore,
44
+ } from '@tanstack/workflow-runtime'
45
+
46
+ export interface DrizzlePostgresDatabase {
47
+ execute: (query: SQL | string) => PromiseLike<unknown>
48
+ transaction?: <TResult>(
49
+ callback: (tx: DrizzlePostgresDatabase) => Promise<TResult>,
50
+ ) => Promise<TResult>
51
+ }
52
+
53
+ export interface DrizzlePostgresWorkflowStoreTables {
54
+ runs: string
55
+ runStates: string
56
+ eventLocks: string
57
+ events: string
58
+ timers: string
59
+ signalDeliveries: string
60
+ schedules: string
61
+ scheduleBuckets: string
62
+ }
63
+
64
+ export interface DrizzlePostgresWorkflowStoreOptions {
65
+ db: DrizzlePostgresDatabase
66
+ schema?: string
67
+ tables?: Partial<DrizzlePostgresWorkflowStoreTables>
68
+ }
69
+
70
+ export type DrizzlePostgresWorkflowStore = WorkflowExecutionStore &
71
+ WorkflowRunStoreAdapterStore & {
72
+ ensureSchema: () => Promise<void>
73
+ }
74
+
75
+ export const defaultDrizzlePostgresWorkflowStoreTables: DrizzlePostgresWorkflowStoreTables =
76
+ {
77
+ runs: 'workflow_runs',
78
+ runStates: 'workflow_run_states',
79
+ eventLocks: 'workflow_event_locks',
80
+ events: 'workflow_events',
81
+ timers: 'workflow_timers',
82
+ signalDeliveries: 'workflow_signal_deliveries',
83
+ schedules: 'workflow_schedules',
84
+ scheduleBuckets: 'workflow_schedule_buckets',
85
+ }
86
+
87
+ export function createDrizzlePostgresWorkflowStore(
88
+ options: DrizzlePostgresWorkflowStoreOptions,
89
+ ): DrizzlePostgresWorkflowStore {
90
+ const tableNames = {
91
+ ...defaultDrizzlePostgresWorkflowStoreTables,
92
+ ...options.tables,
93
+ }
94
+ const tableSql = tableSqls(options.schema, tableNames)
95
+ const db = options.db
96
+
97
+ return {
98
+ async ensureSchema() {
99
+ for (const statement of schemaStatements(options.schema, tableNames)) {
100
+ await db.execute(sql.raw(statement))
101
+ }
102
+ },
103
+
104
+ async createRun(args: CreateRunArgs): Promise<CreateRunResult> {
105
+ const rows = await queryRows<RunRow>(
106
+ db,
107
+ sql`
108
+ insert into ${tableSql.runs} (
109
+ run_id,
110
+ workflow_id,
111
+ workflow_version,
112
+ status,
113
+ input,
114
+ created_at,
115
+ updated_at
116
+ )
117
+ values (
118
+ ${args.runId},
119
+ ${args.workflowId},
120
+ ${args.workflowVersion ?? null},
121
+ 'queued',
122
+ ${encodeJson(args.input)}::jsonb,
123
+ ${args.now},
124
+ ${args.now}
125
+ )
126
+ on conflict (run_id) do nothing
127
+ returning *
128
+ `,
129
+ )
130
+ if (rows[0]) return { kind: 'created', run: runFromRow(rows[0]) }
131
+
132
+ const existing = await loadRunById(db, tableSql, args.runId)
133
+ if (!existing) {
134
+ throw new Error(`Run "${args.runId}" was not inserted or loaded.`)
135
+ }
136
+ return { kind: 'existing', run: existing }
137
+ },
138
+
139
+ loadRun(runId: RunId) {
140
+ return loadRunById(db, tableSql, runId)
141
+ },
142
+
143
+ async loadExecution(runId: RunId): Promise<LoadedExecution | undefined> {
144
+ const run = await loadRunById(db, tableSql, runId)
145
+ if (!run) return undefined
146
+ return {
147
+ run,
148
+ events: await readStoredEvents(db, tableSql, { runId }),
149
+ }
150
+ },
151
+
152
+ async loadRunState(runId: RunId) {
153
+ return loadRunStateById(db, tableSql, runId)
154
+ },
155
+
156
+ async saveRunState(args: SaveRunStateArgs) {
157
+ const state = args.state
158
+ await withTransaction(db, async (tx) => {
159
+ await tx.execute(sql`
160
+ insert into ${tableSql.runStates} (
161
+ run_id,
162
+ workflow_id,
163
+ workflow_version,
164
+ status,
165
+ input,
166
+ output,
167
+ error,
168
+ waiting_for,
169
+ pending_approval,
170
+ created_at,
171
+ updated_at
172
+ )
173
+ values (
174
+ ${state.runId},
175
+ ${state.workflowId},
176
+ ${state.workflowVersion ?? null},
177
+ ${state.status},
178
+ ${encodeJson(state.input)}::jsonb,
179
+ ${encodeJsonOrNull(state.output)}::jsonb,
180
+ ${encodeJsonOrNull(state.error)}::jsonb,
181
+ ${encodeJsonOrNull(state.waitingFor)}::jsonb,
182
+ ${encodeJsonOrNull(state.pendingApproval)}::jsonb,
183
+ ${state.createdAt},
184
+ ${state.updatedAt}
185
+ )
186
+ on conflict (run_id) do update set
187
+ workflow_id = excluded.workflow_id,
188
+ workflow_version = excluded.workflow_version,
189
+ status = excluded.status,
190
+ input = excluded.input,
191
+ output = excluded.output,
192
+ error = excluded.error,
193
+ waiting_for = excluded.waiting_for,
194
+ pending_approval = excluded.pending_approval,
195
+ created_at = excluded.created_at,
196
+ updated_at = excluded.updated_at
197
+ `)
198
+ await tx.execute(sql`
199
+ insert into ${tableSql.runs} (
200
+ run_id,
201
+ workflow_id,
202
+ workflow_version,
203
+ status,
204
+ input,
205
+ output,
206
+ error,
207
+ waiting_for,
208
+ pending_approval,
209
+ wake_at,
210
+ created_at,
211
+ updated_at
212
+ )
213
+ values (
214
+ ${state.runId},
215
+ ${state.workflowId},
216
+ ${state.workflowVersion ?? null},
217
+ ${state.status},
218
+ ${encodeJson(state.input)}::jsonb,
219
+ ${encodeJsonOrNull(state.output)}::jsonb,
220
+ ${encodeJsonOrNull(state.error)}::jsonb,
221
+ ${encodeJsonOrNull(state.waitingFor)}::jsonb,
222
+ ${encodeJsonOrNull(state.pendingApproval)}::jsonb,
223
+ ${
224
+ state.waitingFor?.signalName === '__timer'
225
+ ? state.waitingFor.deadline
226
+ : null
227
+ },
228
+ ${state.createdAt},
229
+ ${state.updatedAt}
230
+ )
231
+ on conflict (run_id) do update set
232
+ workflow_id = excluded.workflow_id,
233
+ workflow_version = excluded.workflow_version,
234
+ status = excluded.status,
235
+ input = excluded.input,
236
+ output = excluded.output,
237
+ error = excluded.error,
238
+ waiting_for = excluded.waiting_for,
239
+ pending_approval = excluded.pending_approval,
240
+ wake_at = excluded.wake_at,
241
+ created_at = excluded.created_at,
242
+ updated_at = excluded.updated_at
243
+ `)
244
+ })
245
+ },
246
+
247
+ async deleteRun(runId, _reason) {
248
+ await withTransaction(db, async (tx) => {
249
+ await tx.execute(sql`
250
+ delete from ${tableSql.runStates}
251
+ where run_id = ${runId}
252
+ `)
253
+ await tx.execute(sql`
254
+ delete from ${tableSql.eventLocks}
255
+ where run_id = ${runId}
256
+ `)
257
+ await tx.execute(sql`
258
+ delete from ${tableSql.signalDeliveries}
259
+ where run_id = ${runId}
260
+ `)
261
+ await tx.execute(sql`
262
+ delete from ${tableSql.timers}
263
+ where run_id = ${runId}
264
+ `)
265
+ await tx.execute(sql`
266
+ delete from ${tableSql.events}
267
+ where run_id = ${runId}
268
+ `)
269
+ await tx.execute(sql`
270
+ delete from ${tableSql.runs}
271
+ where run_id = ${runId}
272
+ `)
273
+ })
274
+ },
275
+
276
+ async appendEvents(args: AppendEventsArgs): Promise<AppendEventsResult> {
277
+ return withTransaction(db, async (tx) => {
278
+ await tx.execute(sql`
279
+ insert into ${tableSql.eventLocks} (run_id, created_at)
280
+ values (${args.runId}, ${Date.now()})
281
+ on conflict (run_id) do nothing
282
+ `)
283
+ await queryRows<{ run_id: string }>(
284
+ tx,
285
+ sql`
286
+ select run_id
287
+ from ${tableSql.eventLocks}
288
+ where run_id = ${args.runId}
289
+ for update
290
+ `,
291
+ )
292
+
293
+ const countRows = await queryRows<{ count: number | string }>(
294
+ tx,
295
+ sql`
296
+ select count(*)::int as count
297
+ from ${tableSql.events}
298
+ where run_id = ${args.runId}
299
+ `,
300
+ )
301
+ const currentCount = Number(countRows[0]?.count ?? 0)
302
+
303
+ if (currentCount !== args.expectedNextIndex) {
304
+ const conflict = await queryRows<EventRow>(
305
+ tx,
306
+ sql`
307
+ select *
308
+ from ${tableSql.events}
309
+ where run_id = ${args.runId}
310
+ and event_index = ${args.expectedNextIndex}
311
+ limit 1
312
+ `,
313
+ )
314
+ throw new LogConflictError(
315
+ args.runId,
316
+ args.expectedNextIndex,
317
+ conflict[0] ? eventFromRow(conflict[0]).event : undefined,
318
+ )
319
+ }
320
+
321
+ let nextIndex = args.expectedNextIndex
322
+ for (const event of args.events) {
323
+ await tx.execute(sql`
324
+ insert into ${tableSql.events} (
325
+ run_id,
326
+ event_index,
327
+ event_type,
328
+ step_id,
329
+ event,
330
+ created_at
331
+ )
332
+ values (
333
+ ${args.runId},
334
+ ${nextIndex},
335
+ ${event.type},
336
+ ${getStepId(event) ?? null},
337
+ ${encodeJson(event)}::jsonb,
338
+ ${event.ts}
339
+ )
340
+ `)
341
+ nextIndex++
342
+ }
343
+
344
+ return { nextIndex }
345
+ })
346
+ },
347
+
348
+ readEvents(args: ReadEventsArgs) {
349
+ return readStoredEvents(db, tableSql, args)
350
+ },
351
+
352
+ async claimRun(args: ClaimRunArgs): Promise<ClaimRunResult> {
353
+ const rows = await queryRows<RunRow>(
354
+ db,
355
+ sql`
356
+ update ${tableSql.runs}
357
+ set
358
+ status = 'running',
359
+ lease_owner = ${args.leaseOwner},
360
+ lease_expires_at = ${args.now + args.leaseMs},
361
+ updated_at = ${args.now}
362
+ where run_id = ${args.runId}
363
+ and status not in ('finished', 'errored', 'aborted')
364
+ and (
365
+ lease_owner is null
366
+ or lease_owner = ${args.leaseOwner}
367
+ or lease_expires_at <= ${args.now}
368
+ )
369
+ returning *
370
+ `,
371
+ )
372
+ if (rows[0]) return { kind: 'claimed', run: runFromRow(rows[0]) }
373
+
374
+ const run = await loadRunById(db, tableSql, args.runId)
375
+ return run ? { kind: 'not-claimable', run } : { kind: 'not-found' }
376
+ },
377
+
378
+ async heartbeatRunLease(args: HeartbeatRunLeaseArgs) {
379
+ await db.execute(sql`
380
+ update ${tableSql.runs}
381
+ set
382
+ lease_expires_at = ${args.now + args.leaseMs},
383
+ updated_at = ${args.now}
384
+ where run_id = ${args.runId}
385
+ and lease_owner = ${args.leaseOwner}
386
+ `)
387
+ },
388
+
389
+ async releaseRunLease(args: ReleaseRunLeaseArgs) {
390
+ await db.execute(sql`
391
+ update ${tableSql.runs}
392
+ set
393
+ lease_owner = null,
394
+ lease_expires_at = null
395
+ where run_id = ${args.runId}
396
+ and lease_owner = ${args.leaseOwner}
397
+ `)
398
+ },
399
+
400
+ async markRunPaused(args: MarkRunPausedArgs) {
401
+ await db.execute(sql`
402
+ update ${tableSql.runs}
403
+ set
404
+ status = 'paused',
405
+ waiting_for = ${encodeJsonOrNull(args.waitingFor)}::jsonb,
406
+ pending_approval = ${encodeJsonOrNull(args.pendingApproval)}::jsonb,
407
+ wake_at = ${args.wakeAt ?? null},
408
+ lease_owner = null,
409
+ lease_expires_at = null,
410
+ updated_at = ${args.now}
411
+ where run_id = ${args.runId}
412
+ `)
413
+ },
414
+
415
+ async markRunFinished(args: MarkRunFinishedArgs) {
416
+ await db.execute(sql`
417
+ update ${tableSql.runs}
418
+ set
419
+ status = 'finished',
420
+ output = ${encodeJson(args.output)}::jsonb,
421
+ waiting_for = null,
422
+ pending_approval = null,
423
+ wake_at = null,
424
+ lease_owner = null,
425
+ lease_expires_at = null,
426
+ updated_at = ${args.now}
427
+ where run_id = ${args.runId}
428
+ `)
429
+ },
430
+
431
+ async markRunErrored(args: MarkRunErroredArgs) {
432
+ void args.code
433
+ await db.execute(sql`
434
+ update ${tableSql.runs}
435
+ set
436
+ status = 'errored',
437
+ error = ${encodeJson(args.error)}::jsonb,
438
+ waiting_for = null,
439
+ pending_approval = null,
440
+ wake_at = null,
441
+ lease_owner = null,
442
+ lease_expires_at = null,
443
+ updated_at = ${args.now}
444
+ where run_id = ${args.runId}
445
+ `)
446
+ },
447
+
448
+ async scheduleTimer(args: ScheduleTimerArgs) {
449
+ await withTransaction(db, async (tx) => {
450
+ await tx.execute(sql`
451
+ insert into ${tableSql.timers} (
452
+ run_id,
453
+ signal_id,
454
+ workflow_id,
455
+ workflow_version,
456
+ wake_at
457
+ )
458
+ values (
459
+ ${args.runId},
460
+ ${args.signalId},
461
+ ${args.workflowId},
462
+ ${args.workflowVersion ?? null},
463
+ ${args.wakeAt}
464
+ )
465
+ on conflict (run_id, signal_id) do update set
466
+ workflow_id = excluded.workflow_id,
467
+ workflow_version = excluded.workflow_version,
468
+ wake_at = excluded.wake_at,
469
+ lease_owner = null,
470
+ lease_expires_at = null
471
+ `)
472
+ await tx.execute(sql`
473
+ update ${tableSql.runs}
474
+ set
475
+ wake_at = ${args.wakeAt},
476
+ updated_at = ${args.now}
477
+ where run_id = ${args.runId}
478
+ `)
479
+ })
480
+ },
481
+
482
+ async claimDueTimers(args: ClaimDueTimersArgs) {
483
+ const rows = await queryRows<TimerRow>(
484
+ db,
485
+ sql`
486
+ with due as (
487
+ select run_id, signal_id
488
+ from ${tableSql.timers}
489
+ where wake_at <= ${args.now}
490
+ and (
491
+ lease_owner is null
492
+ or lease_owner = ${args.leaseOwner}
493
+ or lease_expires_at <= ${args.now}
494
+ )
495
+ order by wake_at asc, run_id asc, signal_id asc
496
+ limit ${args.limit}
497
+ for update skip locked
498
+ )
499
+ update ${tableSql.timers} timer
500
+ set
501
+ lease_owner = ${args.leaseOwner},
502
+ lease_expires_at = ${args.now + args.leaseMs}
503
+ from due
504
+ where timer.run_id = due.run_id
505
+ and timer.signal_id = due.signal_id
506
+ returning timer.*
507
+ `,
508
+ )
509
+ return rows.map(timerFromRow)
510
+ },
511
+
512
+ async deliverSignal<TPayload>(
513
+ args: DeliverSignalArgs<TPayload>,
514
+ ): Promise<DeliverSignalResult> {
515
+ return withTransaction(db, async (tx) => {
516
+ const run = await loadRunById(tx, tableSql, args.runId)
517
+ if (!run) return { kind: 'not-found' }
518
+
519
+ const existingDelivery = await loadSignalDelivery(
520
+ tx,
521
+ tableSql,
522
+ args.runId,
523
+ args.delivery.signalId,
524
+ )
525
+ if (existingDelivery) return { kind: 'duplicate', run }
526
+
527
+ if (run.waitingFor?.signalName !== args.delivery.name) {
528
+ return { kind: 'not-waiting', run }
529
+ }
530
+
531
+ const inserted = await insertSignalDelivery(
532
+ tx,
533
+ tableSql,
534
+ args.runId,
535
+ args.delivery.signalId,
536
+ args.now,
537
+ )
538
+ if (!inserted) return { kind: 'duplicate', run }
539
+
540
+ await tx.execute(sql`
541
+ delete from ${tableSql.timers}
542
+ where run_id = ${args.runId}
543
+ and signal_id = ${args.delivery.signalId}
544
+ `)
545
+
546
+ const rows = await queryRows<RunRow>(
547
+ tx,
548
+ sql`
549
+ update ${tableSql.runs}
550
+ set
551
+ status = 'queued',
552
+ waiting_for = null,
553
+ pending_approval = null,
554
+ wake_at = null,
555
+ updated_at = ${args.now}
556
+ where run_id = ${args.runId}
557
+ returning *
558
+ `,
559
+ )
560
+
561
+ return { kind: 'delivered', run: runFromRow(rows[0]!) }
562
+ })
563
+ },
564
+
565
+ async deliverApproval(
566
+ args: DeliverApprovalArgs,
567
+ ): Promise<DeliverApprovalResult> {
568
+ return withTransaction(db, async (tx) => {
569
+ const run = await loadRunById(tx, tableSql, args.runId)
570
+ if (!run) return { kind: 'not-found' }
571
+
572
+ const signalId = `approval:${args.approval.approvalId}`
573
+ const existingDelivery = await loadSignalDelivery(
574
+ tx,
575
+ tableSql,
576
+ args.runId,
577
+ signalId,
578
+ )
579
+ if (existingDelivery) return { kind: 'duplicate', run }
580
+
581
+ if (run.pendingApproval?.approvalId !== args.approval.approvalId) {
582
+ return { kind: 'not-waiting', run }
583
+ }
584
+
585
+ const inserted = await insertSignalDelivery(
586
+ tx,
587
+ tableSql,
588
+ args.runId,
589
+ signalId,
590
+ args.now,
591
+ )
592
+ if (!inserted) return { kind: 'duplicate', run }
593
+
594
+ const rows = await queryRows<RunRow>(
595
+ tx,
596
+ sql`
597
+ update ${tableSql.runs}
598
+ set
599
+ status = 'queued',
600
+ waiting_for = null,
601
+ pending_approval = null,
602
+ wake_at = null,
603
+ updated_at = ${args.now}
604
+ where run_id = ${args.runId}
605
+ returning *
606
+ `,
607
+ )
608
+
609
+ return { kind: 'delivered', run: runFromRow(rows[0]!) }
610
+ })
611
+ },
612
+
613
+ async upsertSchedule(args: UpsertScheduleArgs) {
614
+ await db.execute(sql`
615
+ insert into ${tableSql.schedules} (
616
+ schedule_id,
617
+ workflow_id,
618
+ workflow_version,
619
+ schedule,
620
+ overlap_policy,
621
+ input,
622
+ next_fire_at,
623
+ enabled,
624
+ updated_at
625
+ )
626
+ values (
627
+ ${args.scheduleId},
628
+ ${args.workflowId},
629
+ ${args.workflowVersion ?? null},
630
+ ${encodeJson(args.schedule)}::jsonb,
631
+ ${args.overlapPolicy},
632
+ ${encodeJsonOrNull(args.input)}::jsonb,
633
+ ${args.nextFireAt ?? null},
634
+ ${args.enabled},
635
+ ${args.now}
636
+ )
637
+ on conflict (schedule_id) do update set
638
+ workflow_id = excluded.workflow_id,
639
+ workflow_version = excluded.workflow_version,
640
+ schedule = excluded.schedule,
641
+ overlap_policy = excluded.overlap_policy,
642
+ input = excluded.input,
643
+ next_fire_at = excluded.next_fire_at,
644
+ enabled = excluded.enabled,
645
+ updated_at = excluded.updated_at
646
+ `)
647
+ },
648
+
649
+ async claimDueScheduleBuckets(args: ClaimDueScheduleBucketsArgs) {
650
+ const schedules = await queryRows<ScheduleRow>(
651
+ db,
652
+ sql`
653
+ select *
654
+ from ${tableSql.schedules}
655
+ where enabled = true
656
+ and next_fire_at is not null
657
+ and next_fire_at <= ${args.now}
658
+ order by next_fire_at asc, schedule_id asc
659
+ limit ${args.limit}
660
+ `,
661
+ )
662
+ const buckets: Array<ScheduleBucket> = []
663
+
664
+ for (const scheduleRow of schedules) {
665
+ if (buckets.length >= args.limit) break
666
+
667
+ const schedule = scheduleFromRow(scheduleRow)
668
+ const bucketId = `${schedule.nextFireAt}` satisfies ScheduleBucketId
669
+ const runId = `${schedule.workflowId}:${schedule.scheduleId}:${bucketId}`
670
+
671
+ await db.execute(sql`
672
+ insert into ${tableSql.scheduleBuckets} (
673
+ schedule_id,
674
+ bucket_id,
675
+ workflow_id,
676
+ workflow_version,
677
+ run_id,
678
+ fire_at,
679
+ input,
680
+ overlap_policy,
681
+ status
682
+ )
683
+ values (
684
+ ${schedule.scheduleId},
685
+ ${bucketId},
686
+ ${schedule.workflowId},
687
+ ${schedule.workflowVersion ?? null},
688
+ ${runId},
689
+ ${schedule.nextFireAt},
690
+ ${encodeJsonOrNull(schedule.input)}::jsonb,
691
+ ${schedule.overlapPolicy},
692
+ 'claimed'
693
+ )
694
+ on conflict (schedule_id, bucket_id) do nothing
695
+ `)
696
+
697
+ const rows = await queryRows<ScheduleBucketRow>(
698
+ db,
699
+ sql`
700
+ update ${tableSql.scheduleBuckets}
701
+ set
702
+ lease_owner = ${args.leaseOwner},
703
+ lease_expires_at = ${args.now + args.leaseMs}
704
+ where schedule_id = ${schedule.scheduleId}
705
+ and bucket_id = ${bucketId}
706
+ and status <> 'started'
707
+ and (
708
+ lease_owner is null
709
+ or lease_owner = ${args.leaseOwner}
710
+ or lease_expires_at <= ${args.now}
711
+ )
712
+ returning *
713
+ `,
714
+ )
715
+ if (rows[0]) buckets.push(scheduleBucketFromRow(rows[0]))
716
+ }
717
+
718
+ return buckets
719
+ },
720
+
721
+ async markScheduleBucketStarted(args: MarkScheduleBucketStartedArgs) {
722
+ await db.execute(sql`
723
+ update ${tableSql.scheduleBuckets}
724
+ set
725
+ run_id = ${args.runId},
726
+ status = 'started',
727
+ started_at = ${args.now}
728
+ where schedule_id = ${args.scheduleId}
729
+ and bucket_id = ${args.bucketId}
730
+ `)
731
+ },
732
+
733
+ async claimStaleRuns(args: ClaimStaleRunsArgs) {
734
+ const rows = await queryRows<RunRow>(
735
+ db,
736
+ sql`
737
+ with stale as (
738
+ select run_id
739
+ from ${tableSql.runs}
740
+ where status = 'running'
741
+ and lease_expires_at is not null
742
+ and lease_expires_at <= ${args.now}
743
+ order by updated_at asc, run_id asc
744
+ limit ${args.limit}
745
+ for update skip locked
746
+ )
747
+ update ${tableSql.runs} run
748
+ set
749
+ lease_owner = ${args.leaseOwner},
750
+ lease_expires_at = ${args.now + args.leaseMs},
751
+ updated_at = ${args.now}
752
+ from stale
753
+ where run.run_id = stale.run_id
754
+ returning run.*
755
+ `,
756
+ )
757
+
758
+ return rows.map((row): RunClaim => {
759
+ const run = runFromRow(row)
760
+ return { run, lease: run.lease! }
761
+ })
762
+ },
763
+
764
+ async listRuns(args: ListRunsArgs) {
765
+ const offset = args.cursor ? Number(args.cursor) : 0
766
+ const start = Number.isFinite(offset) && offset > 0 ? offset : 0
767
+ const rows = await queryRows<RunRow>(
768
+ db,
769
+ sql`
770
+ select *
771
+ from ${tableSql.runs}
772
+ where (${args.workflowId ?? null}::text is null or workflow_id = ${args.workflowId ?? null})
773
+ and (${args.status ?? null}::text is null or status = ${args.status ?? null})
774
+ order by updated_at desc, run_id asc
775
+ limit ${args.limit}
776
+ offset ${start}
777
+ `,
778
+ )
779
+
780
+ return rows.map(toRunSummary)
781
+ },
782
+
783
+ async getRunTimeline(runId: RunId): Promise<RunTimeline | undefined> {
784
+ const run = await loadRunById(db, tableSql, runId)
785
+ if (!run) return undefined
786
+ return {
787
+ run,
788
+ events: await readStoredEvents(db, tableSql, { runId }),
789
+ }
790
+ },
791
+ }
792
+ }
793
+
794
+ interface TableSqls {
795
+ runs: SQL
796
+ runStates: SQL
797
+ eventLocks: SQL
798
+ events: SQL
799
+ timers: SQL
800
+ signalDeliveries: SQL
801
+ schedules: SQL
802
+ scheduleBuckets: SQL
803
+ }
804
+
805
+ interface RunRow {
806
+ run_id: string
807
+ workflow_id: string
808
+ workflow_version: string | null
809
+ status: WorkflowExecutionStatus
810
+ input: unknown
811
+ output: unknown
812
+ error: unknown
813
+ waiting_for: unknown
814
+ pending_approval: unknown
815
+ wake_at: number | string | null
816
+ lease_owner: string | null
817
+ lease_expires_at: number | string | null
818
+ created_at: number | string
819
+ updated_at: number | string
820
+ }
821
+
822
+ interface RunStateRow {
823
+ run_id: string
824
+ workflow_id: string
825
+ workflow_version: string | null
826
+ status: RunState['status']
827
+ input: unknown
828
+ output: unknown
829
+ error: unknown
830
+ waiting_for: unknown
831
+ pending_approval: unknown
832
+ created_at: number | string
833
+ updated_at: number | string
834
+ }
835
+
836
+ interface EventRow {
837
+ run_id: string
838
+ event_index: number | string
839
+ event_type: WorkflowEvent['type']
840
+ step_id: string | null
841
+ event: unknown
842
+ created_at: number | string
843
+ }
844
+
845
+ interface TimerRow {
846
+ run_id: string
847
+ workflow_id: string
848
+ workflow_version: string | null
849
+ wake_at: number | string
850
+ signal_id: string
851
+ }
852
+
853
+ interface ScheduleRow {
854
+ schedule_id: string
855
+ workflow_id: string
856
+ workflow_version: string | null
857
+ input: unknown
858
+ overlap_policy: ScheduleBucket['overlapPolicy']
859
+ next_fire_at: number | string
860
+ }
861
+
862
+ interface ScheduleBucketRow {
863
+ schedule_id: string
864
+ bucket_id: string
865
+ workflow_id: string
866
+ workflow_version: string | null
867
+ run_id: string
868
+ fire_at: number | string
869
+ input: unknown
870
+ overlap_policy: ScheduleBucket['overlapPolicy']
871
+ }
872
+
873
+ function tableSqls(
874
+ schema: string | undefined,
875
+ tables: DrizzlePostgresWorkflowStoreTables,
876
+ ): TableSqls {
877
+ return {
878
+ runs: sql.raw(qualifiedTableName(schema, tables.runs)),
879
+ runStates: sql.raw(qualifiedTableName(schema, tables.runStates)),
880
+ eventLocks: sql.raw(qualifiedTableName(schema, tables.eventLocks)),
881
+ events: sql.raw(qualifiedTableName(schema, tables.events)),
882
+ timers: sql.raw(qualifiedTableName(schema, tables.timers)),
883
+ signalDeliveries: sql.raw(
884
+ qualifiedTableName(schema, tables.signalDeliveries),
885
+ ),
886
+ schedules: sql.raw(qualifiedTableName(schema, tables.schedules)),
887
+ scheduleBuckets: sql.raw(
888
+ qualifiedTableName(schema, tables.scheduleBuckets),
889
+ ),
890
+ }
891
+ }
892
+
893
+ function schemaStatements(
894
+ schema: string | undefined,
895
+ tables: DrizzlePostgresWorkflowStoreTables,
896
+ ): Array<string> {
897
+ const runs = qualifiedTableName(schema, tables.runs)
898
+ const runStates = qualifiedTableName(schema, tables.runStates)
899
+ const eventLocks = qualifiedTableName(schema, tables.eventLocks)
900
+ const events = qualifiedTableName(schema, tables.events)
901
+ const timers = qualifiedTableName(schema, tables.timers)
902
+ const signalDeliveries = qualifiedTableName(schema, tables.signalDeliveries)
903
+ const schedules = qualifiedTableName(schema, tables.schedules)
904
+ const scheduleBuckets = qualifiedTableName(schema, tables.scheduleBuckets)
905
+
906
+ return [
907
+ ...(schema ? [`create schema if not exists ${quoteIdent(schema)}`] : []),
908
+ `create table if not exists ${runs} (
909
+ run_id text primary key,
910
+ workflow_id text not null,
911
+ workflow_version text,
912
+ status text not null,
913
+ input jsonb not null,
914
+ output jsonb,
915
+ error jsonb,
916
+ waiting_for jsonb,
917
+ pending_approval jsonb,
918
+ wake_at bigint,
919
+ lease_owner text,
920
+ lease_expires_at bigint,
921
+ created_at bigint not null,
922
+ updated_at bigint not null
923
+ )`,
924
+ `create index if not exists ${quoteIdent(`${tables.runs}_status_idx`)}
925
+ on ${runs} (status, updated_at)`,
926
+ `create index if not exists ${quoteIdent(`${tables.runs}_lease_idx`)}
927
+ on ${runs} (status, lease_expires_at)`,
928
+ `create table if not exists ${runStates} (
929
+ run_id text primary key,
930
+ workflow_id text not null,
931
+ workflow_version text,
932
+ status text not null,
933
+ input jsonb not null,
934
+ output jsonb,
935
+ error jsonb,
936
+ waiting_for jsonb,
937
+ pending_approval jsonb,
938
+ created_at bigint not null,
939
+ updated_at bigint not null
940
+ )`,
941
+ `create table if not exists ${eventLocks} (
942
+ run_id text primary key,
943
+ created_at bigint not null
944
+ )`,
945
+ `create table if not exists ${events} (
946
+ run_id text not null,
947
+ event_index integer not null,
948
+ event_type text not null,
949
+ step_id text,
950
+ event jsonb not null,
951
+ created_at bigint not null,
952
+ primary key (run_id, event_index)
953
+ )`,
954
+ `create index if not exists ${quoteIdent(`${tables.events}_type_idx`)}
955
+ on ${events} (run_id, event_type)`,
956
+ `create table if not exists ${timers} (
957
+ run_id text not null,
958
+ signal_id text not null,
959
+ workflow_id text not null,
960
+ workflow_version text,
961
+ wake_at bigint not null,
962
+ lease_owner text,
963
+ lease_expires_at bigint,
964
+ primary key (run_id, signal_id)
965
+ )`,
966
+ `create index if not exists ${quoteIdent(`${tables.timers}_due_idx`)}
967
+ on ${timers} (wake_at, lease_expires_at)`,
968
+ `create table if not exists ${signalDeliveries} (
969
+ run_id text not null,
970
+ signal_id text not null,
971
+ created_at bigint not null,
972
+ primary key (run_id, signal_id)
973
+ )`,
974
+ `create table if not exists ${schedules} (
975
+ schedule_id text primary key,
976
+ workflow_id text not null,
977
+ workflow_version text,
978
+ schedule jsonb not null,
979
+ overlap_policy text not null,
980
+ input jsonb,
981
+ next_fire_at bigint,
982
+ enabled boolean not null,
983
+ updated_at bigint not null
984
+ )`,
985
+ `create index if not exists ${quoteIdent(`${tables.schedules}_due_idx`)}
986
+ on ${schedules} (enabled, next_fire_at)`,
987
+ `create table if not exists ${scheduleBuckets} (
988
+ schedule_id text not null,
989
+ bucket_id text not null,
990
+ workflow_id text not null,
991
+ workflow_version text,
992
+ run_id text not null,
993
+ fire_at bigint not null,
994
+ input jsonb,
995
+ overlap_policy text not null,
996
+ status text not null,
997
+ lease_owner text,
998
+ lease_expires_at bigint,
999
+ started_at bigint,
1000
+ primary key (schedule_id, bucket_id)
1001
+ )`,
1002
+ `create index if not exists ${quoteIdent(
1003
+ `${tables.scheduleBuckets}_lease_idx`,
1004
+ )}
1005
+ on ${scheduleBuckets} (status, fire_at, lease_expires_at)`,
1006
+ ]
1007
+ }
1008
+
1009
+ async function loadRunById(
1010
+ db: DrizzlePostgresDatabase,
1011
+ tables: TableSqls,
1012
+ runId: RunId,
1013
+ ) {
1014
+ const rows = await queryRows<RunRow>(
1015
+ db,
1016
+ sql`
1017
+ select *
1018
+ from ${tables.runs}
1019
+ where run_id = ${runId}
1020
+ limit 1
1021
+ `,
1022
+ )
1023
+ return rows[0] ? runFromRow(rows[0]) : undefined
1024
+ }
1025
+
1026
+ async function loadRunStateById(
1027
+ db: DrizzlePostgresDatabase,
1028
+ tables: TableSqls,
1029
+ runId: RunId,
1030
+ ): Promise<RunState | undefined> {
1031
+ const rows = await queryRows<RunStateRow>(
1032
+ db,
1033
+ sql`
1034
+ select *
1035
+ from ${tables.runStates}
1036
+ where run_id = ${runId}
1037
+ limit 1
1038
+ `,
1039
+ )
1040
+ return rows[0] ? runStateFromRow(rows[0]) : undefined
1041
+ }
1042
+
1043
+ async function readStoredEvents(
1044
+ db: DrizzlePostgresDatabase,
1045
+ tables: TableSqls,
1046
+ args: ReadEventsArgs,
1047
+ ): Promise<ReadonlyArray<StoredWorkflowEvent>> {
1048
+ const rows = await queryRows<EventRow>(
1049
+ db,
1050
+ sql`
1051
+ select *
1052
+ from ${tables.events}
1053
+ where run_id = ${args.runId}
1054
+ and event_index >= ${args.fromIndex ?? 0}
1055
+ order by event_index asc
1056
+ `,
1057
+ )
1058
+ return rows.map(eventFromRow)
1059
+ }
1060
+
1061
+ async function loadSignalDelivery(
1062
+ db: DrizzlePostgresDatabase,
1063
+ tables: TableSqls,
1064
+ runId: RunId,
1065
+ signalId: string,
1066
+ ) {
1067
+ const rows = await queryRows<{ run_id: string }>(
1068
+ db,
1069
+ sql`
1070
+ select run_id
1071
+ from ${tables.signalDeliveries}
1072
+ where run_id = ${runId}
1073
+ and signal_id = ${signalId}
1074
+ limit 1
1075
+ `,
1076
+ )
1077
+ return Boolean(rows[0])
1078
+ }
1079
+
1080
+ async function insertSignalDelivery(
1081
+ db: DrizzlePostgresDatabase,
1082
+ tables: TableSqls,
1083
+ runId: RunId,
1084
+ signalId: string,
1085
+ now: number,
1086
+ ) {
1087
+ const rows = await queryRows<{ run_id: string }>(
1088
+ db,
1089
+ sql`
1090
+ insert into ${tables.signalDeliveries} (run_id, signal_id, created_at)
1091
+ values (${runId}, ${signalId}, ${now})
1092
+ on conflict (run_id, signal_id) do nothing
1093
+ returning run_id
1094
+ `,
1095
+ )
1096
+ return Boolean(rows[0])
1097
+ }
1098
+
1099
+ async function withTransaction<TResult>(
1100
+ db: DrizzlePostgresDatabase,
1101
+ callback: (tx: DrizzlePostgresDatabase) => Promise<TResult>,
1102
+ ) {
1103
+ if (db.transaction) return db.transaction(callback)
1104
+
1105
+ await db.execute(sql.raw('begin'))
1106
+ try {
1107
+ const result = await callback(db)
1108
+ await db.execute(sql.raw('commit'))
1109
+ return result
1110
+ } catch (error) {
1111
+ await db.execute(sql.raw('rollback'))
1112
+ throw error
1113
+ }
1114
+ }
1115
+
1116
+ async function queryRows<TRow>(
1117
+ db: DrizzlePostgresDatabase,
1118
+ query: SQL,
1119
+ ): Promise<Array<TRow>> {
1120
+ const result = await db.execute(query)
1121
+ return rowsFromResult<TRow>(result)
1122
+ }
1123
+
1124
+ function rowsFromResult<TRow>(result: unknown): Array<TRow> {
1125
+ if (Array.isArray(result)) return result as Array<TRow>
1126
+
1127
+ if (isObjectWithRows(result) && Array.isArray(result.rows)) {
1128
+ return result.rows as Array<TRow>
1129
+ }
1130
+
1131
+ return []
1132
+ }
1133
+
1134
+ function isObjectWithRows(value: unknown): value is { rows: unknown } {
1135
+ return typeof value === 'object' && value !== null && 'rows' in value
1136
+ }
1137
+
1138
+ function runFromRow(row: RunRow): WorkflowExecution {
1139
+ return {
1140
+ runId: row.run_id,
1141
+ workflowId: row.workflow_id,
1142
+ workflowVersion: row.workflow_version ?? undefined,
1143
+ status: row.status,
1144
+ input: decodeJson(row.input),
1145
+ output: decodeJsonOrUndefined(row.output),
1146
+ error: decodeJsonOrUndefined(row.error),
1147
+ waitingFor: decodeJsonOrUndefined(row.waiting_for),
1148
+ pendingApproval: decodeJsonOrUndefined(row.pending_approval),
1149
+ wakeAt: numberOrUndefined(row.wake_at),
1150
+ lease:
1151
+ row.lease_owner && row.lease_expires_at !== null
1152
+ ? {
1153
+ owner: row.lease_owner,
1154
+ expiresAt: Number(row.lease_expires_at),
1155
+ }
1156
+ : undefined,
1157
+ createdAt: Number(row.created_at),
1158
+ updatedAt: Number(row.updated_at),
1159
+ }
1160
+ }
1161
+
1162
+ function runStateFromRow(row: RunStateRow): RunState {
1163
+ return {
1164
+ runId: row.run_id,
1165
+ workflowId: row.workflow_id,
1166
+ workflowVersion: row.workflow_version ?? undefined,
1167
+ status: row.status,
1168
+ input: decodeJson(row.input),
1169
+ output: decodeJsonOrUndefined(row.output),
1170
+ error: decodeJsonOrUndefined(row.error),
1171
+ waitingFor: decodeJsonOrUndefined(row.waiting_for),
1172
+ pendingApproval: decodeJsonOrUndefined(row.pending_approval),
1173
+ createdAt: Number(row.created_at),
1174
+ updatedAt: Number(row.updated_at),
1175
+ }
1176
+ }
1177
+
1178
+ function eventFromRow(row: EventRow): StoredWorkflowEvent {
1179
+ return {
1180
+ runId: row.run_id,
1181
+ eventIndex: Number(row.event_index),
1182
+ eventType: row.event_type,
1183
+ stepId: row.step_id ?? undefined,
1184
+ event: decodeJson(row.event) as WorkflowEvent,
1185
+ createdAt: Number(row.created_at),
1186
+ }
1187
+ }
1188
+
1189
+ function timerFromRow(row: TimerRow): TimerWakeup {
1190
+ return {
1191
+ runId: row.run_id,
1192
+ workflowId: row.workflow_id,
1193
+ workflowVersion: row.workflow_version ?? undefined,
1194
+ wakeAt: Number(row.wake_at),
1195
+ signalId: row.signal_id,
1196
+ }
1197
+ }
1198
+
1199
+ function scheduleFromRow(row: ScheduleRow) {
1200
+ return {
1201
+ scheduleId: row.schedule_id satisfies ScheduleId,
1202
+ workflowId: row.workflow_id,
1203
+ workflowVersion: row.workflow_version ?? undefined,
1204
+ input: decodeJsonOrUndefined(row.input),
1205
+ overlapPolicy: row.overlap_policy,
1206
+ nextFireAt: Number(row.next_fire_at),
1207
+ }
1208
+ }
1209
+
1210
+ function scheduleBucketFromRow(row: ScheduleBucketRow): ScheduleBucket {
1211
+ return {
1212
+ scheduleId: row.schedule_id,
1213
+ bucketId: row.bucket_id,
1214
+ workflowId: row.workflow_id,
1215
+ workflowVersion: row.workflow_version ?? undefined,
1216
+ runId: row.run_id,
1217
+ fireAt: Number(row.fire_at),
1218
+ input: decodeJsonOrUndefined(row.input),
1219
+ overlapPolicy: row.overlap_policy,
1220
+ }
1221
+ }
1222
+
1223
+ function toRunSummary(row: RunRow): RunSummary {
1224
+ const run = runFromRow(row)
1225
+ return {
1226
+ runId: run.runId,
1227
+ workflowId: run.workflowId,
1228
+ workflowVersion: run.workflowVersion,
1229
+ status: run.status,
1230
+ waitingFor: run.waitingFor,
1231
+ pendingApproval: run.pendingApproval,
1232
+ wakeAt: run.wakeAt,
1233
+ createdAt: run.createdAt,
1234
+ updatedAt: run.updatedAt,
1235
+ }
1236
+ }
1237
+
1238
+ function encodeJson(value: unknown) {
1239
+ return JSON.stringify(value ?? null)
1240
+ }
1241
+
1242
+ function encodeJsonOrNull(value: unknown) {
1243
+ return value === undefined ? null : JSON.stringify(value)
1244
+ }
1245
+
1246
+ function decodeJson(value: unknown): unknown {
1247
+ if (typeof value !== 'string') return value
1248
+ try {
1249
+ return JSON.parse(value)
1250
+ } catch {
1251
+ return value
1252
+ }
1253
+ }
1254
+
1255
+ function decodeJsonOrUndefined<TValue = unknown>(
1256
+ value: unknown,
1257
+ ): TValue | undefined {
1258
+ if (value === null || value === undefined) return undefined
1259
+ return decodeJson(value) as TValue
1260
+ }
1261
+
1262
+ function numberOrUndefined(value: number | string | null) {
1263
+ return value === null ? undefined : Number(value)
1264
+ }
1265
+
1266
+ function getStepId(event: WorkflowEvent) {
1267
+ return 'stepId' in event ? event.stepId : undefined
1268
+ }
1269
+
1270
+ function qualifiedTableName(schema: string | undefined, table: string) {
1271
+ return schema
1272
+ ? `${quoteIdent(schema)}.${quoteIdent(table)}`
1273
+ : quoteIdent(table)
1274
+ }
1275
+
1276
+ function quoteIdent(identifier: string) {
1277
+ return `"${identifier.replaceAll('"', '""')}"`
1278
+ }