@pgflow/core 0.0.0-pgflow-installer-ea527282-20251211105757 → 0.0.0-pgflow-installer-45a8ec76-20251211182851

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 (51) hide show
  1. package/dist/CHANGELOG.md +2 -2
  2. package/dist/migrations/_generated/m_20250429164909.d.ts +3 -0
  3. package/dist/migrations/_generated/m_20250429164909.d.ts.map +1 -0
  4. package/dist/migrations/_generated/m_20250429164909.js +584 -0
  5. package/dist/migrations/_generated/m_20250517072017.d.ts +3 -0
  6. package/dist/migrations/_generated/m_20250517072017.d.ts.map +1 -0
  7. package/dist/migrations/_generated/m_20250517072017.js +106 -0
  8. package/dist/migrations/_generated/m_20250609105135.d.ts +3 -0
  9. package/dist/migrations/_generated/m_20250609105135.d.ts.map +1 -0
  10. package/dist/migrations/_generated/m_20250609105135.js +376 -0
  11. package/dist/migrations/_generated/m_20250610180554.d.ts +3 -0
  12. package/dist/migrations/_generated/m_20250610180554.d.ts.map +1 -0
  13. package/dist/migrations/_generated/m_20250610180554.js +132 -0
  14. package/dist/migrations/_generated/m_20250614124241.d.ts +3 -0
  15. package/dist/migrations/_generated/m_20250614124241.d.ts.map +1 -0
  16. package/dist/migrations/_generated/m_20250614124241.js +506 -0
  17. package/dist/migrations/_generated/m_20250619195327.d.ts +3 -0
  18. package/dist/migrations/_generated/m_20250619195327.d.ts.map +1 -0
  19. package/dist/migrations/_generated/m_20250619195327.js +190 -0
  20. package/dist/migrations/_generated/m_20250627090700.d.ts +3 -0
  21. package/dist/migrations/_generated/m_20250627090700.d.ts.map +1 -0
  22. package/dist/migrations/_generated/m_20250627090700.js +11 -0
  23. package/dist/migrations/_generated/m_20250707210212.d.ts +3 -0
  24. package/dist/migrations/_generated/m_20250707210212.d.ts.map +1 -0
  25. package/dist/migrations/_generated/m_20250707210212.js +108 -0
  26. package/dist/migrations/_generated/m_20250719205006.d.ts +3 -0
  27. package/dist/migrations/_generated/m_20250719205006.d.ts.map +1 -0
  28. package/dist/migrations/_generated/m_20250719205006.js +7 -0
  29. package/dist/migrations/_generated/m_20251006073122.d.ts +3 -0
  30. package/dist/migrations/_generated/m_20251006073122.d.ts.map +1 -0
  31. package/dist/migrations/_generated/m_20251006073122.js +1249 -0
  32. package/dist/migrations/_generated/m_20251103222045.d.ts +3 -0
  33. package/dist/migrations/_generated/m_20251103222045.d.ts.map +1 -0
  34. package/dist/migrations/_generated/m_20251103222045.js +627 -0
  35. package/dist/migrations/_generated/m_20251104080523.d.ts +3 -0
  36. package/dist/migrations/_generated/m_20251104080523.d.ts.map +1 -0
  37. package/dist/migrations/_generated/m_20251104080523.js +98 -0
  38. package/dist/migrations/_generated/m_20251130000000.d.ts +3 -0
  39. package/dist/migrations/_generated/m_20251130000000.d.ts.map +1 -0
  40. package/dist/migrations/_generated/m_20251130000000.js +273 -0
  41. package/dist/migrations/_generated/m_20251209074533.d.ts +3 -0
  42. package/dist/migrations/_generated/m_20251209074533.d.ts.map +1 -0
  43. package/dist/migrations/_generated/m_20251209074533.js +278 -0
  44. package/dist/migrations/index.d.ts +11 -0
  45. package/dist/migrations/index.d.ts.map +1 -0
  46. package/dist/migrations/index.js +39 -0
  47. package/dist/migrations/types.d.ts +12 -0
  48. package/dist/migrations/types.d.ts.map +1 -0
  49. package/dist/migrations/types.js +1 -0
  50. package/dist/package.json +5 -1
  51. package/package.json +6 -2
@@ -0,0 +1,3 @@
1
+ import type { Migration } from '../types.js';
2
+ export declare const migration: Migration;
3
+ //# sourceMappingURL=m_20251103222045.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"m_20251103222045.d.ts","sourceRoot":"","sources":["../../../src/migrations/_generated/m_20251103222045.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7C,eAAO,MAAM,SAAS,EAAE,SAknBvB,CAAC"}
@@ -0,0 +1,627 @@
1
+ export const migration = {
2
+ timestamp: '20251103222045',
3
+ filename: '20251103222045_pgflow_fix_broadcast_order_and_timestamp_handling.sql',
4
+ content: `-- Modify "cascade_complete_taskless_steps" function
5
+ CREATE OR REPLACE FUNCTION "pgflow"."cascade_complete_taskless_steps" ("run_id" uuid) RETURNS integer LANGUAGE plpgsql AS $$
6
+ DECLARE
7
+ v_total_completed int := 0;
8
+ v_iteration_completed int;
9
+ v_iterations int := 0;
10
+ v_max_iterations int := 50;
11
+ BEGIN
12
+ -- ==========================================
13
+ -- ITERATIVE CASCADE COMPLETION
14
+ -- ==========================================
15
+ -- Completes taskless steps in waves until none remain
16
+ LOOP
17
+ -- ---------- Safety check ----------
18
+ v_iterations := v_iterations + 1;
19
+ IF v_iterations > v_max_iterations THEN
20
+ RAISE EXCEPTION 'Cascade loop exceeded safety limit of % iterations', v_max_iterations;
21
+ END IF;
22
+
23
+ -- ==========================================
24
+ -- COMPLETE READY TASKLESS STEPS
25
+ -- ==========================================
26
+ WITH
27
+ -- ---------- Find steps to complete in topological order ----------
28
+ steps_to_complete AS (
29
+ SELECT ss.run_id, ss.step_slug
30
+ FROM pgflow.step_states ss
31
+ JOIN pgflow.steps s ON s.flow_slug = ss.flow_slug AND s.step_slug = ss.step_slug
32
+ WHERE ss.run_id = cascade_complete_taskless_steps.run_id
33
+ AND ss.status = 'created'
34
+ AND ss.remaining_deps = 0
35
+ AND ss.initial_tasks = 0
36
+ -- Process in topological order to ensure proper cascade
37
+ ORDER BY s.step_index
38
+ ),
39
+ completed AS (
40
+ -- ---------- Complete taskless steps ----------
41
+ -- Steps with initial_tasks=0 and no remaining deps
42
+ UPDATE pgflow.step_states ss
43
+ SET status = 'completed',
44
+ started_at = now(),
45
+ completed_at = now(),
46
+ remaining_tasks = 0
47
+ FROM steps_to_complete stc
48
+ WHERE ss.run_id = stc.run_id
49
+ AND ss.step_slug = stc.step_slug
50
+ RETURNING
51
+ ss.*,
52
+ -- Broadcast step:completed event atomically with the UPDATE
53
+ -- Using RETURNING ensures this executes during row processing
54
+ -- and cannot be optimized away by the query planner
55
+ realtime.send(
56
+ jsonb_build_object(
57
+ 'event_type', 'step:completed',
58
+ 'run_id', ss.run_id,
59
+ 'step_slug', ss.step_slug,
60
+ 'status', 'completed',
61
+ 'started_at', ss.started_at,
62
+ 'completed_at', ss.completed_at,
63
+ 'remaining_tasks', 0,
64
+ 'remaining_deps', 0,
65
+ 'output', '[]'::jsonb
66
+ ),
67
+ concat('step:', ss.step_slug, ':completed'),
68
+ concat('pgflow:run:', ss.run_id),
69
+ false
70
+ ) as _broadcast_result -- Prefix with _ to indicate internal use only
71
+ ),
72
+ -- ---------- Update dependent steps ----------
73
+ -- Propagate completion and empty arrays to dependents
74
+ dep_updates AS (
75
+ UPDATE pgflow.step_states ss
76
+ SET remaining_deps = ss.remaining_deps - dep_count.count,
77
+ -- If the dependent is a map step and its dependency completed with 0 tasks,
78
+ -- set its initial_tasks to 0 as well
79
+ initial_tasks = CASE
80
+ WHEN s.step_type = 'map' AND dep_count.has_zero_tasks
81
+ THEN 0 -- Empty array propagation
82
+ ELSE ss.initial_tasks -- Keep existing value (including NULL)
83
+ END
84
+ FROM (
85
+ -- Aggregate dependency updates per dependent step
86
+ SELECT
87
+ d.flow_slug,
88
+ d.step_slug as dependent_slug,
89
+ COUNT(*) as count,
90
+ BOOL_OR(c.initial_tasks = 0) as has_zero_tasks
91
+ FROM completed c
92
+ JOIN pgflow.deps d ON d.flow_slug = c.flow_slug
93
+ AND d.dep_slug = c.step_slug
94
+ GROUP BY d.flow_slug, d.step_slug
95
+ ) dep_count,
96
+ pgflow.steps s
97
+ WHERE ss.run_id = cascade_complete_taskless_steps.run_id
98
+ AND ss.flow_slug = dep_count.flow_slug
99
+ AND ss.step_slug = dep_count.dependent_slug
100
+ AND s.flow_slug = ss.flow_slug
101
+ AND s.step_slug = ss.step_slug
102
+ ),
103
+ -- ---------- Update run counters ----------
104
+ -- Only decrement remaining_steps; let maybe_complete_run handle finalization
105
+ run_updates AS (
106
+ UPDATE pgflow.runs r
107
+ SET remaining_steps = r.remaining_steps - c.completed_count
108
+ FROM (SELECT COUNT(*) AS completed_count FROM completed) c
109
+ WHERE r.run_id = cascade_complete_taskless_steps.run_id
110
+ AND c.completed_count > 0
111
+ )
112
+ -- ---------- Check iteration results ----------
113
+ SELECT COUNT(*) INTO v_iteration_completed FROM completed;
114
+
115
+ EXIT WHEN v_iteration_completed = 0; -- No more steps to complete
116
+ v_total_completed := v_total_completed + v_iteration_completed;
117
+ END LOOP;
118
+
119
+ RETURN v_total_completed;
120
+ END;
121
+ $$;
122
+ -- Modify "start_ready_steps" function
123
+ CREATE OR REPLACE FUNCTION "pgflow"."start_ready_steps" ("run_id" uuid) RETURNS void LANGUAGE plpgsql SET "search_path" = '' AS $$
124
+ begin
125
+ -- ==========================================
126
+ -- GUARD: No mutations on failed runs
127
+ -- ==========================================
128
+ IF EXISTS (SELECT 1 FROM pgflow.runs WHERE pgflow.runs.run_id = start_ready_steps.run_id AND pgflow.runs.status = 'failed') THEN
129
+ RETURN;
130
+ END IF;
131
+
132
+ -- ==========================================
133
+ -- HANDLE EMPTY ARRAY MAPS (initial_tasks = 0)
134
+ -- ==========================================
135
+ -- These complete immediately without spawning tasks
136
+ WITH empty_map_steps AS (
137
+ SELECT step_state.*
138
+ FROM pgflow.step_states AS step_state
139
+ JOIN pgflow.steps AS step
140
+ ON step.flow_slug = step_state.flow_slug
141
+ AND step.step_slug = step_state.step_slug
142
+ WHERE step_state.run_id = start_ready_steps.run_id
143
+ AND step_state.status = 'created'
144
+ AND step_state.remaining_deps = 0
145
+ AND step.step_type = 'map'
146
+ AND step_state.initial_tasks = 0
147
+ ORDER BY step_state.step_slug
148
+ FOR UPDATE OF step_state
149
+ ),
150
+ -- ---------- Complete empty map steps ----------
151
+ completed_empty_steps AS (
152
+ UPDATE pgflow.step_states
153
+ SET status = 'completed',
154
+ started_at = now(),
155
+ completed_at = now(),
156
+ remaining_tasks = 0
157
+ FROM empty_map_steps
158
+ WHERE pgflow.step_states.run_id = start_ready_steps.run_id
159
+ AND pgflow.step_states.step_slug = empty_map_steps.step_slug
160
+ RETURNING
161
+ pgflow.step_states.*,
162
+ -- Broadcast step:completed event atomically with the UPDATE
163
+ -- Using RETURNING ensures this executes during row processing
164
+ -- and cannot be optimized away by the query planner
165
+ realtime.send(
166
+ jsonb_build_object(
167
+ 'event_type', 'step:completed',
168
+ 'run_id', pgflow.step_states.run_id,
169
+ 'step_slug', pgflow.step_states.step_slug,
170
+ 'status', 'completed',
171
+ 'started_at', pgflow.step_states.started_at,
172
+ 'completed_at', pgflow.step_states.completed_at,
173
+ 'remaining_tasks', 0,
174
+ 'remaining_deps', 0,
175
+ 'output', '[]'::jsonb
176
+ ),
177
+ concat('step:', pgflow.step_states.step_slug, ':completed'),
178
+ concat('pgflow:run:', pgflow.step_states.run_id),
179
+ false
180
+ ) as _broadcast_completed -- Prefix with _ to indicate internal use only
181
+ ),
182
+
183
+ -- ==========================================
184
+ -- HANDLE NORMAL STEPS (initial_tasks > 0)
185
+ -- ==========================================
186
+ -- ---------- Find ready steps ----------
187
+ -- Steps with no remaining deps and known task count
188
+ ready_steps AS (
189
+ SELECT *
190
+ FROM pgflow.step_states AS step_state
191
+ WHERE step_state.run_id = start_ready_steps.run_id
192
+ AND step_state.status = 'created'
193
+ AND step_state.remaining_deps = 0
194
+ AND step_state.initial_tasks IS NOT NULL -- NEW: Cannot start with unknown count
195
+ AND step_state.initial_tasks > 0 -- Don't start taskless steps
196
+ -- Exclude empty map steps already handled
197
+ AND NOT EXISTS (
198
+ SELECT 1 FROM empty_map_steps
199
+ WHERE empty_map_steps.run_id = step_state.run_id
200
+ AND empty_map_steps.step_slug = step_state.step_slug
201
+ )
202
+ ORDER BY step_state.step_slug
203
+ FOR UPDATE
204
+ ),
205
+ -- ---------- Mark steps as started ----------
206
+ started_step_states AS (
207
+ UPDATE pgflow.step_states
208
+ SET status = 'started',
209
+ started_at = now(),
210
+ remaining_tasks = ready_steps.initial_tasks -- Copy initial_tasks to remaining_tasks when starting
211
+ FROM ready_steps
212
+ WHERE pgflow.step_states.run_id = start_ready_steps.run_id
213
+ AND pgflow.step_states.step_slug = ready_steps.step_slug
214
+ RETURNING pgflow.step_states.*,
215
+ -- Broadcast step:started event atomically with the UPDATE
216
+ -- Using RETURNING ensures this executes during row processing
217
+ -- and cannot be optimized away by the query planner
218
+ realtime.send(
219
+ jsonb_build_object(
220
+ 'event_type', 'step:started',
221
+ 'run_id', pgflow.step_states.run_id,
222
+ 'step_slug', pgflow.step_states.step_slug,
223
+ 'status', 'started',
224
+ 'started_at', pgflow.step_states.started_at,
225
+ 'remaining_tasks', pgflow.step_states.remaining_tasks,
226
+ 'remaining_deps', pgflow.step_states.remaining_deps
227
+ ),
228
+ concat('step:', pgflow.step_states.step_slug, ':started'),
229
+ concat('pgflow:run:', pgflow.step_states.run_id),
230
+ false
231
+ ) as _broadcast_result -- Prefix with _ to indicate internal use only
232
+ ),
233
+
234
+ -- ==========================================
235
+ -- TASK GENERATION AND QUEUE MESSAGES
236
+ -- ==========================================
237
+ -- ---------- Generate tasks and batch messages ----------
238
+ -- Single steps: 1 task (index 0)
239
+ -- Map steps: N tasks (indices 0..N-1)
240
+ message_batches AS (
241
+ SELECT
242
+ started_step.flow_slug,
243
+ started_step.run_id,
244
+ started_step.step_slug,
245
+ COALESCE(step.opt_start_delay, 0) as delay,
246
+ array_agg(
247
+ jsonb_build_object(
248
+ 'flow_slug', started_step.flow_slug,
249
+ 'run_id', started_step.run_id,
250
+ 'step_slug', started_step.step_slug,
251
+ 'task_index', task_idx.task_index
252
+ ) ORDER BY task_idx.task_index
253
+ ) AS messages,
254
+ array_agg(task_idx.task_index ORDER BY task_idx.task_index) AS task_indices
255
+ FROM started_step_states AS started_step
256
+ JOIN pgflow.steps AS step
257
+ ON step.flow_slug = started_step.flow_slug
258
+ AND step.step_slug = started_step.step_slug
259
+ -- Generate task indices from 0 to initial_tasks-1
260
+ CROSS JOIN LATERAL generate_series(0, started_step.initial_tasks - 1) AS task_idx(task_index)
261
+ GROUP BY started_step.flow_slug, started_step.run_id, started_step.step_slug, step.opt_start_delay
262
+ ),
263
+ -- ---------- Send messages to queue ----------
264
+ -- Uses batch sending for performance with large arrays
265
+ sent_messages AS (
266
+ SELECT
267
+ mb.flow_slug,
268
+ mb.run_id,
269
+ mb.step_slug,
270
+ task_indices.task_index,
271
+ msg_ids.msg_id
272
+ FROM message_batches mb
273
+ CROSS JOIN LATERAL unnest(mb.task_indices) WITH ORDINALITY AS task_indices(task_index, idx_ord)
274
+ CROSS JOIN LATERAL pgmq.send_batch(mb.flow_slug, mb.messages, mb.delay) WITH ORDINALITY AS msg_ids(msg_id, msg_ord)
275
+ WHERE task_indices.idx_ord = msg_ids.msg_ord
276
+ )
277
+
278
+ -- ==========================================
279
+ -- RECORD TASKS IN DATABASE
280
+ -- ==========================================
281
+ INSERT INTO pgflow.step_tasks (flow_slug, run_id, step_slug, task_index, message_id)
282
+ SELECT
283
+ sent_messages.flow_slug,
284
+ sent_messages.run_id,
285
+ sent_messages.step_slug,
286
+ sent_messages.task_index,
287
+ sent_messages.msg_id
288
+ FROM sent_messages;
289
+
290
+ -- ==========================================
291
+ -- BROADCAST REALTIME EVENTS
292
+ -- ==========================================
293
+ -- Note: Both step:completed events for empty maps and step:started events
294
+ -- are now broadcast atomically in their respective CTEs using RETURNING pattern.
295
+ -- This ensures correct ordering, prevents duplicate broadcasts, and guarantees
296
+ -- that events are sent for exactly the rows that were updated.
297
+
298
+ end;
299
+ $$;
300
+ -- Modify "complete_task" function
301
+ CREATE OR REPLACE FUNCTION "pgflow"."complete_task" ("run_id" uuid, "step_slug" text, "task_index" integer, "output" jsonb) RETURNS SETOF "pgflow"."step_tasks" LANGUAGE plpgsql SET "search_path" = '' AS $$
302
+ declare
303
+ v_step_state pgflow.step_states%ROWTYPE;
304
+ v_dependent_map_slug text;
305
+ v_run_record pgflow.runs%ROWTYPE;
306
+ v_step_record pgflow.step_states%ROWTYPE;
307
+ begin
308
+
309
+ -- ==========================================
310
+ -- GUARD: No mutations on failed runs
311
+ -- ==========================================
312
+ IF EXISTS (SELECT 1 FROM pgflow.runs WHERE pgflow.runs.run_id = complete_task.run_id AND pgflow.runs.status = 'failed') THEN
313
+ RETURN QUERY SELECT * FROM pgflow.step_tasks
314
+ WHERE pgflow.step_tasks.run_id = complete_task.run_id
315
+ AND pgflow.step_tasks.step_slug = complete_task.step_slug
316
+ AND pgflow.step_tasks.task_index = complete_task.task_index;
317
+ RETURN;
318
+ END IF;
319
+
320
+ -- ==========================================
321
+ -- LOCK ACQUISITION AND TYPE VALIDATION
322
+ -- ==========================================
323
+ -- Acquire locks first to prevent race conditions
324
+ SELECT * INTO v_run_record FROM pgflow.runs
325
+ WHERE pgflow.runs.run_id = complete_task.run_id
326
+ FOR UPDATE;
327
+
328
+ SELECT * INTO v_step_record FROM pgflow.step_states
329
+ WHERE pgflow.step_states.run_id = complete_task.run_id
330
+ AND pgflow.step_states.step_slug = complete_task.step_slug
331
+ FOR UPDATE;
332
+
333
+ -- Check for type violations AFTER acquiring locks
334
+ SELECT child_step.step_slug INTO v_dependent_map_slug
335
+ FROM pgflow.deps dependency
336
+ JOIN pgflow.steps child_step ON child_step.flow_slug = dependency.flow_slug
337
+ AND child_step.step_slug = dependency.step_slug
338
+ JOIN pgflow.steps parent_step ON parent_step.flow_slug = dependency.flow_slug
339
+ AND parent_step.step_slug = dependency.dep_slug
340
+ JOIN pgflow.step_states child_state ON child_state.flow_slug = child_step.flow_slug
341
+ AND child_state.step_slug = child_step.step_slug
342
+ WHERE dependency.dep_slug = complete_task.step_slug -- parent is the completing step
343
+ AND dependency.flow_slug = v_run_record.flow_slug
344
+ AND parent_step.step_type = 'single' -- Only validate single steps
345
+ AND child_step.step_type = 'map'
346
+ AND child_state.run_id = complete_task.run_id
347
+ AND child_state.initial_tasks IS NULL
348
+ AND (complete_task.output IS NULL OR jsonb_typeof(complete_task.output) != 'array')
349
+ LIMIT 1;
350
+
351
+ -- Handle type violation if detected
352
+ IF v_dependent_map_slug IS NOT NULL THEN
353
+ -- Mark run as failed immediately
354
+ UPDATE pgflow.runs
355
+ SET status = 'failed',
356
+ failed_at = now()
357
+ WHERE pgflow.runs.run_id = complete_task.run_id;
358
+
359
+ -- Broadcast run:failed event
360
+ -- Uses PERFORM pattern to ensure execution (proven reliable pattern in this function)
361
+ PERFORM realtime.send(
362
+ jsonb_build_object(
363
+ 'event_type', 'run:failed',
364
+ 'run_id', complete_task.run_id,
365
+ 'flow_slug', v_run_record.flow_slug,
366
+ 'status', 'failed',
367
+ 'failed_at', now()
368
+ ),
369
+ 'run:failed',
370
+ concat('pgflow:run:', complete_task.run_id),
371
+ false
372
+ );
373
+
374
+ -- Archive all active messages (both queued and started) to prevent orphaned messages
375
+ PERFORM pgmq.archive(
376
+ v_run_record.flow_slug,
377
+ array_agg(st.message_id)
378
+ )
379
+ FROM pgflow.step_tasks st
380
+ WHERE st.run_id = complete_task.run_id
381
+ AND st.status IN ('queued', 'started')
382
+ AND st.message_id IS NOT NULL
383
+ HAVING count(*) > 0; -- Only call archive if there are messages to archive
384
+
385
+ -- Mark current task as failed and store the output
386
+ UPDATE pgflow.step_tasks
387
+ SET status = 'failed',
388
+ failed_at = now(),
389
+ output = complete_task.output, -- Store the output that caused the violation
390
+ error_message = '[TYPE_VIOLATION] Produced ' ||
391
+ CASE WHEN complete_task.output IS NULL THEN 'null'
392
+ ELSE jsonb_typeof(complete_task.output) END ||
393
+ ' instead of array'
394
+ WHERE pgflow.step_tasks.run_id = complete_task.run_id
395
+ AND pgflow.step_tasks.step_slug = complete_task.step_slug
396
+ AND pgflow.step_tasks.task_index = complete_task.task_index;
397
+
398
+ -- Mark step state as failed
399
+ UPDATE pgflow.step_states
400
+ SET status = 'failed',
401
+ failed_at = now(),
402
+ error_message = '[TYPE_VIOLATION] Map step ' || v_dependent_map_slug ||
403
+ ' expects array input but dependency ' || complete_task.step_slug ||
404
+ ' produced ' || CASE WHEN complete_task.output IS NULL THEN 'null'
405
+ ELSE jsonb_typeof(complete_task.output) END
406
+ WHERE pgflow.step_states.run_id = complete_task.run_id
407
+ AND pgflow.step_states.step_slug = complete_task.step_slug;
408
+
409
+ -- Broadcast step:failed event
410
+ -- Uses PERFORM pattern to ensure execution (proven reliable pattern in this function)
411
+ PERFORM realtime.send(
412
+ jsonb_build_object(
413
+ 'event_type', 'step:failed',
414
+ 'run_id', complete_task.run_id,
415
+ 'step_slug', complete_task.step_slug,
416
+ 'status', 'failed',
417
+ 'error_message', '[TYPE_VIOLATION] Map step ' || v_dependent_map_slug ||
418
+ ' expects array input but dependency ' || complete_task.step_slug ||
419
+ ' produced ' || CASE WHEN complete_task.output IS NULL THEN 'null'
420
+ ELSE jsonb_typeof(complete_task.output) END,
421
+ 'failed_at', now()
422
+ ),
423
+ concat('step:', complete_task.step_slug, ':failed'),
424
+ concat('pgflow:run:', complete_task.run_id),
425
+ false
426
+ );
427
+
428
+ -- Archive the current task's message (it was started, now failed)
429
+ PERFORM pgmq.archive(
430
+ v_run_record.flow_slug,
431
+ st.message_id -- Single message, use scalar form
432
+ )
433
+ FROM pgflow.step_tasks st
434
+ WHERE st.run_id = complete_task.run_id
435
+ AND st.step_slug = complete_task.step_slug
436
+ AND st.task_index = complete_task.task_index
437
+ AND st.message_id IS NOT NULL;
438
+
439
+ -- Return empty result
440
+ RETURN QUERY SELECT * FROM pgflow.step_tasks WHERE false;
441
+ RETURN;
442
+ END IF;
443
+
444
+ -- ==========================================
445
+ -- MAIN CTE CHAIN: Update task and propagate changes
446
+ -- ==========================================
447
+ WITH
448
+ -- ---------- Task completion ----------
449
+ -- Update the task record with completion status and output
450
+ task AS (
451
+ UPDATE pgflow.step_tasks
452
+ SET
453
+ status = 'completed',
454
+ completed_at = now(),
455
+ output = complete_task.output
456
+ WHERE pgflow.step_tasks.run_id = complete_task.run_id
457
+ AND pgflow.step_tasks.step_slug = complete_task.step_slug
458
+ AND pgflow.step_tasks.task_index = complete_task.task_index
459
+ AND pgflow.step_tasks.status = 'started'
460
+ RETURNING *
461
+ ),
462
+ -- ---------- Step state update ----------
463
+ -- Decrement remaining_tasks and potentially mark step as completed
464
+ step_state AS (
465
+ UPDATE pgflow.step_states
466
+ SET
467
+ status = CASE
468
+ WHEN pgflow.step_states.remaining_tasks = 1 THEN 'completed' -- Will be 0 after decrement
469
+ ELSE 'started'
470
+ END,
471
+ completed_at = CASE
472
+ WHEN pgflow.step_states.remaining_tasks = 1 THEN now() -- Will be 0 after decrement
473
+ ELSE NULL
474
+ END,
475
+ remaining_tasks = pgflow.step_states.remaining_tasks - 1
476
+ FROM task
477
+ WHERE pgflow.step_states.run_id = complete_task.run_id
478
+ AND pgflow.step_states.step_slug = complete_task.step_slug
479
+ RETURNING pgflow.step_states.*
480
+ ),
481
+ -- ---------- Dependency resolution ----------
482
+ -- Find all child steps that depend on the completed parent step (only if parent completed)
483
+ child_steps AS (
484
+ SELECT deps.step_slug AS child_step_slug
485
+ FROM pgflow.deps deps
486
+ JOIN step_state parent_state ON parent_state.status = 'completed' AND deps.flow_slug = parent_state.flow_slug
487
+ WHERE deps.dep_slug = complete_task.step_slug -- dep_slug is the parent, step_slug is the child
488
+ ORDER BY deps.step_slug -- Ensure consistent ordering
489
+ ),
490
+ -- ---------- Lock child steps ----------
491
+ -- Acquire locks on all child steps before updating them
492
+ child_steps_lock AS (
493
+ SELECT * FROM pgflow.step_states
494
+ WHERE pgflow.step_states.run_id = complete_task.run_id
495
+ AND pgflow.step_states.step_slug IN (SELECT child_step_slug FROM child_steps)
496
+ FOR UPDATE
497
+ ),
498
+ -- ---------- Update child steps ----------
499
+ -- Decrement remaining_deps and resolve NULL initial_tasks for map steps
500
+ child_steps_update AS (
501
+ UPDATE pgflow.step_states child_state
502
+ SET remaining_deps = child_state.remaining_deps - 1,
503
+ -- Resolve NULL initial_tasks for child map steps
504
+ -- This is where child maps learn their array size from the parent
505
+ -- This CTE only runs when the parent step is complete (see child_steps JOIN)
506
+ initial_tasks = CASE
507
+ WHEN child_step.step_type = 'map' AND child_state.initial_tasks IS NULL THEN
508
+ CASE
509
+ WHEN parent_step.step_type = 'map' THEN
510
+ -- Map->map: Count all completed tasks from parent map
511
+ -- We add 1 because the current task is being completed in this transaction
512
+ -- but isn't yet visible as 'completed' in the step_tasks table
513
+ -- TODO: Refactor to use future column step_states.total_tasks
514
+ -- Would eliminate the COUNT query and just use parent_state.total_tasks
515
+ (SELECT COUNT(*)::int + 1
516
+ FROM pgflow.step_tasks parent_tasks
517
+ WHERE parent_tasks.run_id = complete_task.run_id
518
+ AND parent_tasks.step_slug = complete_task.step_slug
519
+ AND parent_tasks.status = 'completed'
520
+ AND parent_tasks.task_index != complete_task.task_index)
521
+ ELSE
522
+ -- Single->map: Use output array length (single steps complete immediately)
523
+ CASE
524
+ WHEN complete_task.output IS NOT NULL
525
+ AND jsonb_typeof(complete_task.output) = 'array' THEN
526
+ jsonb_array_length(complete_task.output)
527
+ ELSE NULL -- Keep NULL if not an array
528
+ END
529
+ END
530
+ ELSE child_state.initial_tasks -- Keep existing value (including NULL)
531
+ END
532
+ FROM child_steps children
533
+ JOIN pgflow.steps child_step ON child_step.flow_slug = (SELECT r.flow_slug FROM pgflow.runs r WHERE r.run_id = complete_task.run_id)
534
+ AND child_step.step_slug = children.child_step_slug
535
+ JOIN pgflow.steps parent_step ON parent_step.flow_slug = (SELECT r.flow_slug FROM pgflow.runs r WHERE r.run_id = complete_task.run_id)
536
+ AND parent_step.step_slug = complete_task.step_slug
537
+ WHERE child_state.run_id = complete_task.run_id
538
+ AND child_state.step_slug = children.child_step_slug
539
+ )
540
+ -- ---------- Update run remaining_steps ----------
541
+ -- Decrement the run's remaining_steps counter if step completed
542
+ UPDATE pgflow.runs
543
+ SET remaining_steps = pgflow.runs.remaining_steps - 1
544
+ FROM step_state
545
+ WHERE pgflow.runs.run_id = complete_task.run_id
546
+ AND step_state.status = 'completed';
547
+
548
+ -- ==========================================
549
+ -- POST-COMPLETION ACTIONS
550
+ -- ==========================================
551
+
552
+ -- ---------- Get updated state for broadcasting ----------
553
+ SELECT * INTO v_step_state FROM pgflow.step_states
554
+ WHERE pgflow.step_states.run_id = complete_task.run_id AND pgflow.step_states.step_slug = complete_task.step_slug;
555
+
556
+ -- ---------- Handle step completion ----------
557
+ IF v_step_state.status = 'completed' THEN
558
+ -- Broadcast step:completed event FIRST (before cascade)
559
+ -- This ensures parent broadcasts before its dependent children
560
+ -- For map steps, aggregate all task outputs; for single steps, use the task output
561
+ PERFORM realtime.send(
562
+ jsonb_build_object(
563
+ 'event_type', 'step:completed',
564
+ 'run_id', complete_task.run_id,
565
+ 'step_slug', complete_task.step_slug,
566
+ 'status', 'completed',
567
+ 'output', CASE
568
+ WHEN (SELECT s.step_type FROM pgflow.steps s
569
+ WHERE s.flow_slug = v_step_state.flow_slug
570
+ AND s.step_slug = complete_task.step_slug) = 'map' THEN
571
+ -- Aggregate all task outputs for map steps
572
+ (SELECT COALESCE(jsonb_agg(st.output ORDER BY st.task_index), '[]'::jsonb)
573
+ FROM pgflow.step_tasks st
574
+ WHERE st.run_id = complete_task.run_id
575
+ AND st.step_slug = complete_task.step_slug
576
+ AND st.status = 'completed')
577
+ ELSE
578
+ -- Single step: use the individual task output
579
+ complete_task.output
580
+ END,
581
+ 'completed_at', v_step_state.completed_at
582
+ ),
583
+ concat('step:', complete_task.step_slug, ':completed'),
584
+ concat('pgflow:run:', complete_task.run_id),
585
+ false
586
+ );
587
+
588
+ -- THEN cascade complete any taskless steps that are now ready
589
+ -- This ensures dependent children broadcast AFTER their parent
590
+ PERFORM pgflow.cascade_complete_taskless_steps(complete_task.run_id);
591
+ END IF;
592
+
593
+ -- ---------- Archive completed task message ----------
594
+ -- Move message from active queue to archive table
595
+ PERFORM (
596
+ WITH completed_tasks AS (
597
+ SELECT r.flow_slug, st.message_id
598
+ FROM pgflow.step_tasks st
599
+ JOIN pgflow.runs r ON st.run_id = r.run_id
600
+ WHERE st.run_id = complete_task.run_id
601
+ AND st.step_slug = complete_task.step_slug
602
+ AND st.task_index = complete_task.task_index
603
+ AND st.status = 'completed'
604
+ )
605
+ SELECT pgmq.archive(ct.flow_slug, ct.message_id)
606
+ FROM completed_tasks ct
607
+ WHERE EXISTS (SELECT 1 FROM completed_tasks)
608
+ );
609
+
610
+ -- ---------- Trigger next steps ----------
611
+ -- Start any steps that are now ready (deps satisfied)
612
+ PERFORM pgflow.start_ready_steps(complete_task.run_id);
613
+
614
+ -- Check if the entire run is complete
615
+ PERFORM pgflow.maybe_complete_run(complete_task.run_id);
616
+
617
+ -- ---------- Return completed task ----------
618
+ RETURN QUERY SELECT *
619
+ FROM pgflow.step_tasks AS step_task
620
+ WHERE step_task.run_id = complete_task.run_id
621
+ AND step_task.step_slug = complete_task.step_slug
622
+ AND step_task.task_index = complete_task.task_index;
623
+
624
+ end;
625
+ $$;
626
+ `,
627
+ };
@@ -0,0 +1,3 @@
1
+ import type { Migration } from '../types.js';
2
+ export declare const migration: Migration;
3
+ //# sourceMappingURL=m_20251104080523.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"m_20251104080523.d.ts","sourceRoot":"","sources":["../../../src/migrations/_generated/m_20251104080523.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7C,eAAO,MAAM,SAAS,EAAE,SAiGvB,CAAC"}