@pgflow/client 0.0.0-array-map-steps-fixed-types-38a198ae-20251011160533 → 0.0.0-control-plane-a947cb71-20251121164755
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/CHANGELOG.md +75 -3
- package/README.md +5 -1
- package/dist/client/src/browser.d.ts +7 -0
- package/dist/client/src/browser.d.ts.map +1 -0
- package/dist/client/src/index.d.ts +6 -0
- package/dist/client/src/index.d.ts.map +1 -0
- package/dist/client/src/lib/FlowRun.d.ts +125 -0
- package/dist/client/src/lib/FlowRun.d.ts.map +1 -0
- package/dist/client/src/lib/FlowStep.d.ts +90 -0
- package/dist/client/src/lib/FlowStep.d.ts.map +1 -0
- package/dist/client/src/lib/PgflowClient.d.ts +76 -0
- package/dist/client/src/lib/PgflowClient.d.ts.map +1 -0
- package/dist/client/src/lib/SupabaseBroadcastAdapter.d.ts +66 -0
- package/dist/client/src/lib/SupabaseBroadcastAdapter.d.ts.map +1 -0
- package/dist/client/src/lib/eventAdapters.d.ts +21 -0
- package/dist/client/src/lib/eventAdapters.d.ts.map +1 -0
- package/dist/client/src/lib/types.d.ts +308 -0
- package/dist/client/src/lib/types.d.ts.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +401 -446
- package/dist/index.js.map +1 -1
- package/dist/package.json +3 -3
- package/dist/pgflow-client.browser.js +1 -1
- package/dist/pgflow-client.browser.js.map +1 -1
- package/dist/src/browser.d.ts +3 -3
- package/dist/src/browser.d.ts.map +1 -1
- package/dist/src/browser.js +10 -0
- package/dist/src/index.js +7 -0
- package/dist/src/lib/FlowRun.d.ts +11 -3
- package/dist/src/lib/FlowRun.d.ts.map +1 -1
- package/dist/src/lib/FlowRun.js +368 -0
- package/dist/src/lib/FlowStep.d.ts +11 -3
- package/dist/src/lib/FlowStep.d.ts.map +1 -1
- package/dist/src/lib/FlowStep.js +244 -0
- package/dist/src/lib/PgflowClient.d.ts +12 -10
- package/dist/src/lib/PgflowClient.d.ts.map +1 -1
- package/dist/src/lib/PgflowClient.js +227 -0
- package/dist/src/lib/SupabaseBroadcastAdapter.d.ts +4 -4
- package/dist/src/lib/SupabaseBroadcastAdapter.d.ts.map +1 -1
- package/dist/src/lib/SupabaseBroadcastAdapter.js +332 -0
- package/dist/src/lib/eventAdapters.d.ts +3 -4
- package/dist/src/lib/eventAdapters.js +142 -0
- package/dist/src/lib/types.d.ts +3 -4
- package/dist/src/lib/types.js +91 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/package.json +5 -5
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PgflowClient.d.ts","sourceRoot":"","sources":["../../../src/lib/PgflowClient.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAC5D,OAAO,KAAK,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"PgflowClient.d.ts","sourceRoot":"","sources":["../../../src/lib/PgflowClient.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAC5D,OAAO,KAAK,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAE7D,OAAO,KAAK,EACV,WAAW,EAEX,iBAAiB,EACjB,kBAAkB,EAClB,WAAW,EAEZ,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAGvC;;GAEG;AACH,qBAAa,YAAY,CAAC,KAAK,SAAS,OAAO,GAAG,OAAO,CAAE,YAAW,WAAW,CAAC,KAAK,CAAC;;IAOtF;;;;;OAKG;gBAED,cAAc,EAAE,cAAc,EAC9B,IAAI,GAAE;QACJ,4BAA4B,CAAC,EAAE,MAAM,CAAC;QACtC,QAAQ,CAAC,EAAE,OAAO,UAAU,CAAC;KACzB;IA4BR;;;;;;;OAOG;IACG,SAAS,CAAC,aAAa,SAAS,KAAK,EACzC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,gBAAgB,CAAC,aAAa,CAAC,EACtC,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC;IAwDlC;;;;OAIG;IACH,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAc5B;;OAEG;IACH,UAAU,IAAI,IAAI;IAQlB;;OAEG;IACG,mBAAmB,CAAC,SAAS,EAAE,MAAM;;;;IAI3C;;;OAGG;IACH,UAAU,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,GAAG,WAAW;IAIrE;;;OAGG;IACH,WAAW,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,IAAI,GAAG,WAAW;IAIvE;;OAEG;IACG,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC;IAIzD;;OAEG;IACG,gBAAgB,CAAC,MAAM,EAAE,MAAM;;;;IAIrC;;;;;OAKG;IACG,MAAM,CAAC,aAAa,SAAS,KAAK,GAAG,KAAK,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,aAAa,CAAC,GAAG,IAAI,CAAC;CA6E1G"}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
2
|
+
import { FlowRunStatus } from './types.js';
|
|
3
|
+
import { SupabaseBroadcastAdapter } from './SupabaseBroadcastAdapter.js';
|
|
4
|
+
import { FlowRun } from './FlowRun.js';
|
|
5
|
+
import { toTypedRunEvent, toTypedStepEvent } from './eventAdapters.js';
|
|
6
|
+
/**
|
|
7
|
+
* Client for interacting with pgflow
|
|
8
|
+
*/
|
|
9
|
+
export class PgflowClient {
|
|
10
|
+
#supabase;
|
|
11
|
+
#realtimeAdapter;
|
|
12
|
+
// Use the widest event type - keeps the compiler happy but
|
|
13
|
+
// still provides the structural API we need (updateState/step/...)
|
|
14
|
+
#runs = new Map();
|
|
15
|
+
/**
|
|
16
|
+
* Creates a new PgflowClient instance
|
|
17
|
+
*
|
|
18
|
+
* @param supabaseClient - Supabase client instance
|
|
19
|
+
* @param opts - Optional configuration
|
|
20
|
+
*/
|
|
21
|
+
constructor(supabaseClient, opts = {}) {
|
|
22
|
+
this.#supabase = supabaseClient;
|
|
23
|
+
this.#realtimeAdapter = new SupabaseBroadcastAdapter(supabaseClient, {
|
|
24
|
+
stabilizationDelayMs: opts.realtimeStabilizationDelayMs,
|
|
25
|
+
schedule: opts.schedule,
|
|
26
|
+
});
|
|
27
|
+
// Set up global event listeners - properly typed
|
|
28
|
+
this.#realtimeAdapter.onRunEvent((event) => {
|
|
29
|
+
const run = this.#runs.get(event.run_id);
|
|
30
|
+
if (run) {
|
|
31
|
+
// Convert broadcast event to typed event before updating state
|
|
32
|
+
run.updateState(toTypedRunEvent(event));
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
this.#realtimeAdapter.onStepEvent((event) => {
|
|
36
|
+
const run = this.#runs.get(event.run_id);
|
|
37
|
+
if (run) {
|
|
38
|
+
// Always materialize the step before updating to avoid event loss
|
|
39
|
+
// This ensures we cache all steps even if they were never explicitly requested
|
|
40
|
+
const stepSlug = event.step_slug;
|
|
41
|
+
run.step(stepSlug).updateState(toTypedStepEvent(event));
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Start a flow with optional run_id
|
|
47
|
+
*
|
|
48
|
+
* @param flow_slug - Flow slug to start
|
|
49
|
+
* @param input - Input data for the flow
|
|
50
|
+
* @param run_id - Optional run ID (will be generated if not provided)
|
|
51
|
+
* @returns Promise that resolves with the FlowRun instance
|
|
52
|
+
*/
|
|
53
|
+
async startFlow(flow_slug, input, run_id) {
|
|
54
|
+
// Generate a run_id if not provided
|
|
55
|
+
const id = run_id || uuidv4();
|
|
56
|
+
// Create initial state for the flow run
|
|
57
|
+
const initialState = {
|
|
58
|
+
run_id: id,
|
|
59
|
+
flow_slug,
|
|
60
|
+
status: FlowRunStatus.Started,
|
|
61
|
+
input: input,
|
|
62
|
+
output: null,
|
|
63
|
+
error: null,
|
|
64
|
+
error_message: null,
|
|
65
|
+
started_at: null,
|
|
66
|
+
completed_at: null,
|
|
67
|
+
failed_at: null,
|
|
68
|
+
remaining_steps: -1, // Use -1 to indicate unknown until first snapshot arrives
|
|
69
|
+
};
|
|
70
|
+
// Create the flow run instance
|
|
71
|
+
const run = new FlowRun(initialState);
|
|
72
|
+
// Store the run
|
|
73
|
+
this.#runs.set(id, run);
|
|
74
|
+
// Set up subscription for run and step events (wait for subscription confirmation)
|
|
75
|
+
await this.#realtimeAdapter.subscribeToRun(id);
|
|
76
|
+
// Start the flow with the predetermined run_id (only after subscription is ready)
|
|
77
|
+
const { data, error } = await this.#supabase.schema('pgflow').rpc('start_flow_with_states', {
|
|
78
|
+
flow_slug: flow_slug,
|
|
79
|
+
input: input,
|
|
80
|
+
run_id: id
|
|
81
|
+
});
|
|
82
|
+
if (error) {
|
|
83
|
+
// Clean up subscription and run instance
|
|
84
|
+
this.dispose(id);
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
// Apply the run state snapshot (no events)
|
|
88
|
+
if (data.run) {
|
|
89
|
+
run.applySnapshot(data.run);
|
|
90
|
+
}
|
|
91
|
+
// Apply step state snapshots (no events)
|
|
92
|
+
if (data.steps && Array.isArray(data.steps)) {
|
|
93
|
+
for (const stepState of data.steps) {
|
|
94
|
+
run.step(stepState.step_slug).applySnapshot(stepState);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return run;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Dispose a specific flow run
|
|
101
|
+
*
|
|
102
|
+
* @param runId - Run ID to dispose
|
|
103
|
+
*/
|
|
104
|
+
dispose(runId) {
|
|
105
|
+
const run = this.#runs.get(runId);
|
|
106
|
+
if (run) {
|
|
107
|
+
// First unsubscribe from the realtime adapter
|
|
108
|
+
this.#realtimeAdapter.unsubscribe(runId);
|
|
109
|
+
// Then dispose the run
|
|
110
|
+
run.dispose();
|
|
111
|
+
// Finally remove from the runs map
|
|
112
|
+
this.#runs.delete(runId);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Dispose all flow runs
|
|
117
|
+
*/
|
|
118
|
+
disposeAll() {
|
|
119
|
+
for (const runId of this.#runs.keys()) {
|
|
120
|
+
this.dispose(runId);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Delegate IFlowRealtime methods to the adapter
|
|
124
|
+
/**
|
|
125
|
+
* Fetch flow definition metadata
|
|
126
|
+
*/
|
|
127
|
+
async fetchFlowDefinition(flow_slug) {
|
|
128
|
+
return this.#realtimeAdapter.fetchFlowDefinition(flow_slug);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Register a callback for run events
|
|
132
|
+
* @returns Function to unsubscribe from the event
|
|
133
|
+
*/
|
|
134
|
+
onRunEvent(callback) {
|
|
135
|
+
return this.#realtimeAdapter.onRunEvent(callback);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Register a callback for step events
|
|
139
|
+
* @returns Function to unsubscribe from the event
|
|
140
|
+
*/
|
|
141
|
+
onStepEvent(callback) {
|
|
142
|
+
return this.#realtimeAdapter.onStepEvent(callback);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Subscribe to a flow run's events
|
|
146
|
+
*/
|
|
147
|
+
async subscribeToRun(run_id) {
|
|
148
|
+
return await this.#realtimeAdapter.subscribeToRun(run_id);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Fetch current state of a run and its steps
|
|
152
|
+
*/
|
|
153
|
+
async getRunWithStates(run_id) {
|
|
154
|
+
return this.#realtimeAdapter.getRunWithStates(run_id);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Get a flow run by ID
|
|
158
|
+
*
|
|
159
|
+
* @param run_id - ID of the run to get
|
|
160
|
+
* @returns Promise that resolves with the FlowRun instance or null if not found
|
|
161
|
+
*/
|
|
162
|
+
async getRun(run_id) {
|
|
163
|
+
// Check if we already have this run cached
|
|
164
|
+
const existingRun = this.#runs.get(run_id);
|
|
165
|
+
if (existingRun) {
|
|
166
|
+
return existingRun;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
// Fetch the run state from the database
|
|
170
|
+
const { run, steps } = await this.getRunWithStates(run_id);
|
|
171
|
+
if (!run) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
// Validate required fields
|
|
175
|
+
if (!run.run_id || !run.flow_slug || !run.status) {
|
|
176
|
+
throw new Error('Invalid run data: missing required fields');
|
|
177
|
+
}
|
|
178
|
+
// Validate status is a valid FlowRunStatus
|
|
179
|
+
const validStatuses = Object.values(FlowRunStatus);
|
|
180
|
+
if (!validStatuses.includes(run.status)) {
|
|
181
|
+
throw new Error(`Invalid run data: invalid status '${run.status}'`);
|
|
182
|
+
}
|
|
183
|
+
// Create flow run with minimal initial state
|
|
184
|
+
const initialState = {
|
|
185
|
+
run_id: run.run_id,
|
|
186
|
+
flow_slug: run.flow_slug,
|
|
187
|
+
status: run.status,
|
|
188
|
+
input: run.input,
|
|
189
|
+
output: null,
|
|
190
|
+
error: null,
|
|
191
|
+
error_message: null,
|
|
192
|
+
started_at: null,
|
|
193
|
+
completed_at: null,
|
|
194
|
+
failed_at: null,
|
|
195
|
+
remaining_steps: 0,
|
|
196
|
+
};
|
|
197
|
+
// Create the flow run instance
|
|
198
|
+
const flowRun = new FlowRun(initialState);
|
|
199
|
+
// Apply the complete state from database snapshot
|
|
200
|
+
flowRun.applySnapshot(run);
|
|
201
|
+
// Store the run
|
|
202
|
+
this.#runs.set(run_id, flowRun);
|
|
203
|
+
// Set up subscription for run and step events
|
|
204
|
+
await this.#realtimeAdapter.subscribeToRun(run_id);
|
|
205
|
+
// Initialize steps from snapshot
|
|
206
|
+
if (steps && Array.isArray(steps)) {
|
|
207
|
+
for (const stepState of steps) {
|
|
208
|
+
// Validate step has required fields
|
|
209
|
+
if (!stepState.step_slug || !stepState.status) {
|
|
210
|
+
throw new Error('Invalid step data: missing required fields');
|
|
211
|
+
}
|
|
212
|
+
// Apply snapshot state directly (no events)
|
|
213
|
+
flowRun.step(stepState.step_slug).applySnapshot(stepState);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return flowRun;
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
console.error('Error getting run:', error);
|
|
220
|
+
// Re-throw if it's a validation error
|
|
221
|
+
if (error instanceof Error && (error.message.includes('Invalid run data') || error.message.includes('Invalid step data'))) {
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { FlowRow, StepRow, RunRow, StepStateRow } from '
|
|
3
|
-
import {
|
|
4
|
-
|
|
1
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
+
import type { FlowRow, StepRow, RunRow, StepStateRow } from '@pgflow/core';
|
|
3
|
+
import type { IFlowRealtime, BroadcastRunEvent, BroadcastStepEvent, Unsubscribe } from './types.js';
|
|
5
4
|
/**
|
|
6
5
|
* Adapter to handle realtime communication with Supabase
|
|
7
6
|
*/
|
|
@@ -14,6 +13,7 @@ export declare class SupabaseBroadcastAdapter implements IFlowRealtime {
|
|
|
14
13
|
*/
|
|
15
14
|
constructor(supabase: SupabaseClient, opts?: {
|
|
16
15
|
reconnectDelayMs?: number;
|
|
16
|
+
stabilizationDelayMs?: number;
|
|
17
17
|
schedule?: typeof setTimeout;
|
|
18
18
|
});
|
|
19
19
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SupabaseBroadcastAdapter.d.ts","sourceRoot":"","sources":["../../../src/lib/SupabaseBroadcastAdapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAmB,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAC7E,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAE3E,OAAO,KAAK,EACV,aAAa,EACb,iBAAiB,EACjB,kBAAkB,EAClB,WAAW,EACZ,MAAM,YAAY,CAAC;AAQpB;;GAEG;AACH,qBAAa,wBAAyB,YAAW,aAAa;;
|
|
1
|
+
{"version":3,"file":"SupabaseBroadcastAdapter.d.ts","sourceRoot":"","sources":["../../../src/lib/SupabaseBroadcastAdapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAmB,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAC7E,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAE3E,OAAO,KAAK,EACV,aAAa,EACb,iBAAiB,EACjB,kBAAkB,EAClB,WAAW,EACZ,MAAM,YAAY,CAAC;AAQpB;;GAEG;AACH,qBAAa,wBAAyB,YAAW,aAAa;;IAS5D;;;;OAIG;gBAED,QAAQ,EAAE,cAAc,EACxB,IAAI,GAAE;QACJ,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,oBAAoB,CAAC,EAAE,MAAM,CAAC;QAC9B,QAAQ,CAAC,EAAE,OAAO,UAAU,CAAC;KACzB;IAmLR;;;;OAIG;IACG,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;QACpD,IAAI,EAAE,OAAO,CAAC;QACd,KAAK,EAAE,OAAO,EAAE,CAAC;KAClB,CAAC;IAiCF;;;;;OAKG;IACH,UAAU,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,GAAG,WAAW;IAYrE;;;;;OAKG;IACH,WAAW,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,IAAI,GAAG,WAAW;IAevE;;;;;OAKG;IACG,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC;IAgEzD;;;;OAIG;IACH,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAIjC;;;;OAIG;IACG,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC;QAC9C,GAAG,EAAE,MAAM,CAAC;QACZ,KAAK,EAAE,YAAY,EAAE,CAAC;KACvB,CAAC;CAiCH"}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { createNanoEvents } from 'nanoevents';
|
|
2
|
+
/**
|
|
3
|
+
* Adapter to handle realtime communication with Supabase
|
|
4
|
+
*/
|
|
5
|
+
export class SupabaseBroadcastAdapter {
|
|
6
|
+
#supabase;
|
|
7
|
+
#channels = new Map();
|
|
8
|
+
#emitter = createNanoEvents();
|
|
9
|
+
#reconnectionDelay;
|
|
10
|
+
#stabilizationDelay;
|
|
11
|
+
#schedule;
|
|
12
|
+
/**
|
|
13
|
+
* Creates a new instance of SupabaseBroadcastAdapter
|
|
14
|
+
*
|
|
15
|
+
* @param supabase - Supabase client instance
|
|
16
|
+
*/
|
|
17
|
+
constructor(supabase, opts = {}) {
|
|
18
|
+
this.#supabase = supabase;
|
|
19
|
+
this.#reconnectionDelay = opts.reconnectDelayMs ?? 2000;
|
|
20
|
+
this.#stabilizationDelay = opts.stabilizationDelayMs ?? 300;
|
|
21
|
+
this.#schedule = opts.schedule ?? setTimeout;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Handle broadcast messages from Supabase
|
|
25
|
+
* @param payload - The message payload
|
|
26
|
+
*/
|
|
27
|
+
#handleBroadcastMessage(msg) {
|
|
28
|
+
const { event, payload } = msg;
|
|
29
|
+
// run_id is already inside the payload coming from the database trigger
|
|
30
|
+
// so just preserve it without overwriting
|
|
31
|
+
const eventData = payload;
|
|
32
|
+
// Auto-parse JSON strings in broadcast data (realtime sends JSONB as strings)
|
|
33
|
+
this.#parseJsonFields(eventData);
|
|
34
|
+
if (event.startsWith('run:')) {
|
|
35
|
+
// Handle run events
|
|
36
|
+
this.#emitter.emit('runEvent', eventData);
|
|
37
|
+
}
|
|
38
|
+
else if (event.startsWith('step:')) {
|
|
39
|
+
// Handle step events
|
|
40
|
+
this.#emitter.emit('stepEvent', eventData);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Parse JSON string fields in broadcast event data
|
|
45
|
+
* @param eventData - The event data object to parse
|
|
46
|
+
*/
|
|
47
|
+
#parseJsonFields(eventData) {
|
|
48
|
+
// Parse output field if it's a JSON string
|
|
49
|
+
if ('output' in eventData && typeof eventData.output === 'string') {
|
|
50
|
+
try {
|
|
51
|
+
eventData.output = JSON.parse(eventData.output);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Keep as string if not valid JSON
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Parse input field if it's a JSON string
|
|
58
|
+
if ('input' in eventData && typeof eventData.input === 'string') {
|
|
59
|
+
try {
|
|
60
|
+
eventData.input = JSON.parse(eventData.input);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Keep as string if not valid JSON
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Handle channel errors and reconnection
|
|
69
|
+
* @param run_id - The run ID
|
|
70
|
+
* @param channelName - The channel name
|
|
71
|
+
* @param channel - The RealtimeChannel instance
|
|
72
|
+
* @param error - The error object
|
|
73
|
+
*/
|
|
74
|
+
async #handleChannelError(run_id, channelName, channel, error) {
|
|
75
|
+
console.error(`Channel ${channelName} error:`, error);
|
|
76
|
+
// Schedule reconnection attempt
|
|
77
|
+
this.#schedule(async () => {
|
|
78
|
+
if (this.#channels.has(run_id)) {
|
|
79
|
+
await this.#reconnectChannel(run_id, channelName);
|
|
80
|
+
}
|
|
81
|
+
}, this.#reconnectionDelay);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Creates and configures a channel for a run
|
|
85
|
+
* @param run_id - The run ID
|
|
86
|
+
* @param channelName - The channel name
|
|
87
|
+
* @returns The configured RealtimeChannel
|
|
88
|
+
*/
|
|
89
|
+
#createAndConfigureChannel(run_id, channelName) {
|
|
90
|
+
const channel = this.#supabase.channel(channelName);
|
|
91
|
+
// Listen to *all* broadcast messages; filter inside the handler.
|
|
92
|
+
// Using the 3-arg overload with event filter for proper Supabase v2 client compatibility.
|
|
93
|
+
channel.on('broadcast', { event: '*' }, this.#handleBroadcastMessage.bind(this));
|
|
94
|
+
// Note: Lifecycle event listeners (subscribed, closed, error) are handled
|
|
95
|
+
// by the calling code to avoid conflicts when multiple listeners try to
|
|
96
|
+
// handle the same events.
|
|
97
|
+
return channel;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Reconnect to a channel and refresh state
|
|
101
|
+
* @param run_id - The run ID
|
|
102
|
+
* @param channelName - The channel name
|
|
103
|
+
*/
|
|
104
|
+
async #reconnectChannel(run_id, channelName) {
|
|
105
|
+
console.log(`Attempting to reconnect to ${channelName}`);
|
|
106
|
+
try {
|
|
107
|
+
// Fetch current state to avoid missing events during disconnection
|
|
108
|
+
const currentState = await this.getRunWithStates(run_id);
|
|
109
|
+
// Update state based on current data
|
|
110
|
+
this.#refreshStateFromSnapshot(run_id, currentState);
|
|
111
|
+
// Create a new channel as the old one can't be reused
|
|
112
|
+
const newChannel = this.#createAndConfigureChannel(run_id, channelName);
|
|
113
|
+
// Set up lifecycle event handlers for reconnection
|
|
114
|
+
newChannel.on('system', { event: 'subscribed' }, () => {
|
|
115
|
+
console.log(`Reconnected and subscribed to channel ${channelName}`);
|
|
116
|
+
});
|
|
117
|
+
newChannel.on('system', { event: 'closed' }, () => {
|
|
118
|
+
console.log(`Reconnected channel ${channelName} closed`);
|
|
119
|
+
});
|
|
120
|
+
newChannel.on('system', { event: 'error' }, (payload) => this.#handleChannelError(run_id, channelName, newChannel, payload.error));
|
|
121
|
+
// Subscribe and update the channels map
|
|
122
|
+
newChannel.subscribe();
|
|
123
|
+
this.#channels.set(run_id, newChannel);
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
console.error(`Failed to reconnect to ${channelName}:`, e);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Refresh client state from a state snapshot
|
|
131
|
+
* @param run_id - The run ID
|
|
132
|
+
* @param state - The state snapshot
|
|
133
|
+
*/
|
|
134
|
+
#refreshStateFromSnapshot(run_id, state) {
|
|
135
|
+
if (!state || !state.run)
|
|
136
|
+
return;
|
|
137
|
+
// Create proper run event with correct event_type
|
|
138
|
+
const runEvent = {
|
|
139
|
+
event_type: `run:${state.run.status}`,
|
|
140
|
+
...state.run
|
|
141
|
+
};
|
|
142
|
+
// Emit run event
|
|
143
|
+
this.#emitter.emit('runEvent', runEvent);
|
|
144
|
+
// Emit events for each step state
|
|
145
|
+
if (state.steps && Array.isArray(state.steps)) {
|
|
146
|
+
for (const step of state.steps) {
|
|
147
|
+
// Create proper step event with correct event_type
|
|
148
|
+
const stepEvent = {
|
|
149
|
+
event_type: `step:${step.status}`,
|
|
150
|
+
...step
|
|
151
|
+
};
|
|
152
|
+
// Emit step event
|
|
153
|
+
this.#emitter.emit('stepEvent', stepEvent);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Fetches flow definition metadata from the database
|
|
159
|
+
*
|
|
160
|
+
* @param flow_slug - Flow slug to fetch
|
|
161
|
+
*/
|
|
162
|
+
async fetchFlowDefinition(flow_slug) {
|
|
163
|
+
// Fetch flow details and steps in parallel
|
|
164
|
+
const [flowResult, stepsResult] = await Promise.all([
|
|
165
|
+
this.#supabase
|
|
166
|
+
.schema('pgflow')
|
|
167
|
+
.from('flows')
|
|
168
|
+
.select('*')
|
|
169
|
+
.eq('flow_slug', flow_slug)
|
|
170
|
+
.single(),
|
|
171
|
+
this.#supabase
|
|
172
|
+
.schema('pgflow')
|
|
173
|
+
.from('steps')
|
|
174
|
+
.select('*')
|
|
175
|
+
.eq('flow_slug', flow_slug)
|
|
176
|
+
.order('step_index', { ascending: true })
|
|
177
|
+
]);
|
|
178
|
+
// Handle flow result
|
|
179
|
+
if (flowResult.error)
|
|
180
|
+
throw flowResult.error;
|
|
181
|
+
if (!flowResult.data)
|
|
182
|
+
throw new Error(`Flow "${flow_slug}" not found`);
|
|
183
|
+
// Handle steps result
|
|
184
|
+
if (stepsResult.error)
|
|
185
|
+
throw stepsResult.error;
|
|
186
|
+
// Ensure steps is always an array, even if it's null or undefined
|
|
187
|
+
const stepsArray = Array.isArray(stepsResult.data) ? stepsResult.data : [];
|
|
188
|
+
return {
|
|
189
|
+
flow: flowResult.data,
|
|
190
|
+
steps: stepsArray,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Registers a callback for run events
|
|
195
|
+
*
|
|
196
|
+
* @param callback - Function to call when run events are received
|
|
197
|
+
* @returns Function to unsubscribe from the event
|
|
198
|
+
*/
|
|
199
|
+
onRunEvent(callback) {
|
|
200
|
+
// Add a guard to prevent errors if called after emitter is deleted
|
|
201
|
+
const unsubscribe = this.#emitter.on('runEvent', callback);
|
|
202
|
+
return () => {
|
|
203
|
+
try {
|
|
204
|
+
unsubscribe();
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
console.warn('Could not unsubscribe from run event - emitter may have been disposed', e);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Registers a callback for step events
|
|
213
|
+
*
|
|
214
|
+
* @param callback - Function to call when step events are received
|
|
215
|
+
* @returns Function to unsubscribe from the event
|
|
216
|
+
*/
|
|
217
|
+
onStepEvent(callback) {
|
|
218
|
+
// Add a guard to prevent errors if called after emitter is deleted
|
|
219
|
+
const unsubscribe = this.#emitter.on('stepEvent', callback);
|
|
220
|
+
return () => {
|
|
221
|
+
try {
|
|
222
|
+
unsubscribe();
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
console.warn('Could not unsubscribe from step event - emitter may have been disposed', e);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
// Store unsubscribe functions per run ID for reference equality
|
|
230
|
+
#unsubscribeFunctions = new Map();
|
|
231
|
+
/**
|
|
232
|
+
* Subscribes to a flow run's events
|
|
233
|
+
*
|
|
234
|
+
* @param run_id - Run ID to subscribe to
|
|
235
|
+
* @returns Function to unsubscribe
|
|
236
|
+
*/
|
|
237
|
+
async subscribeToRun(run_id) {
|
|
238
|
+
const channelName = `pgflow:run:${run_id}`;
|
|
239
|
+
// If already subscribed, return the existing unsubscribe function
|
|
240
|
+
if (this.#channels.has(run_id)) {
|
|
241
|
+
const existingUnsubscribe = this.#unsubscribeFunctions.get(run_id);
|
|
242
|
+
if (existingUnsubscribe) {
|
|
243
|
+
return existingUnsubscribe;
|
|
244
|
+
}
|
|
245
|
+
// If channel exists but no unsubscribe function, something went wrong
|
|
246
|
+
// Let's clean up and recreate
|
|
247
|
+
this.#unsubscribe(run_id);
|
|
248
|
+
}
|
|
249
|
+
const channel = this.#supabase.channel(channelName);
|
|
250
|
+
// Listen to *all* broadcast messages; filter inside the handler.
|
|
251
|
+
// Using the 3-arg overload with event filter for proper Supabase v2 client compatibility.
|
|
252
|
+
channel.on('broadcast', { event: '*' }, this.#handleBroadcastMessage.bind(this));
|
|
253
|
+
// Set up error handling
|
|
254
|
+
channel.on('system', { event: 'closed' }, () => {
|
|
255
|
+
console.log(`Channel ${channelName} closed`);
|
|
256
|
+
});
|
|
257
|
+
channel.on('system', { event: 'error' }, (payload) => {
|
|
258
|
+
console.log(`Channel ${channelName} error:`, payload);
|
|
259
|
+
this.#handleChannelError(run_id, channelName, channel, payload.error);
|
|
260
|
+
});
|
|
261
|
+
// Subscribe to channel and wait for confirmation (like the working realtime-send test)
|
|
262
|
+
console.log(`Subscribing to channel ${channelName}...`);
|
|
263
|
+
const subscriptionPromise = new Promise((resolve, reject) => {
|
|
264
|
+
const timeout = setTimeout(() => {
|
|
265
|
+
reject(new Error(`Subscription timeout for channel ${channelName}`));
|
|
266
|
+
}, 20000); // Increased from 5s to 20s for slower CI environments
|
|
267
|
+
channel.subscribe((status) => {
|
|
268
|
+
console.log(`Channel ${channelName} subscription status:`, status);
|
|
269
|
+
if (status === 'SUBSCRIBED') {
|
|
270
|
+
clearTimeout(timeout);
|
|
271
|
+
resolve();
|
|
272
|
+
}
|
|
273
|
+
// Don't reject on CHANNEL_ERROR - it's a transient state
|
|
274
|
+
// Only reject on timeout
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
// Wait for the 'SUBSCRIBED' acknowledgment to avoid race conditions
|
|
278
|
+
await subscriptionPromise;
|
|
279
|
+
// Stabilization delay - known Supabase Realtime limitation
|
|
280
|
+
// The SUBSCRIBED event is emitted before backend routing is fully established.
|
|
281
|
+
// This delay ensures the backend can receive messages sent immediately after subscription.
|
|
282
|
+
// See: https://github.com/supabase/supabase-js/issues/1599
|
|
283
|
+
await new Promise(resolve => this.#schedule(resolve, this.#stabilizationDelay));
|
|
284
|
+
this.#channels.set(run_id, channel);
|
|
285
|
+
const unsubscribe = () => this.unsubscribe(run_id);
|
|
286
|
+
this.#unsubscribeFunctions.set(run_id, unsubscribe);
|
|
287
|
+
return unsubscribe;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Unsubscribes from a run's events
|
|
291
|
+
*
|
|
292
|
+
* @param run_id - Run ID to unsubscribe from
|
|
293
|
+
*/
|
|
294
|
+
unsubscribe(run_id) {
|
|
295
|
+
this.#unsubscribe(run_id);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Fetches current state of a run and its steps
|
|
299
|
+
*
|
|
300
|
+
* @param run_id - Run ID to fetch
|
|
301
|
+
*/
|
|
302
|
+
async getRunWithStates(run_id) {
|
|
303
|
+
// Call the RPC function which returns a JSONB object
|
|
304
|
+
const { data, error } = await this.#supabase
|
|
305
|
+
.schema('pgflow')
|
|
306
|
+
.rpc('get_run_with_states', { run_id });
|
|
307
|
+
if (error)
|
|
308
|
+
throw error;
|
|
309
|
+
if (!data)
|
|
310
|
+
throw new Error(`No data returned for run ${run_id}`);
|
|
311
|
+
return data;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Unsubscribes from a run's events
|
|
315
|
+
*
|
|
316
|
+
* @param run_id - Run ID to unsubscribe from
|
|
317
|
+
*/
|
|
318
|
+
#unsubscribe(run_id) {
|
|
319
|
+
const channel = this.#channels.get(run_id);
|
|
320
|
+
if (channel) {
|
|
321
|
+
// Close the channel
|
|
322
|
+
channel.unsubscribe();
|
|
323
|
+
this.#channels.delete(run_id);
|
|
324
|
+
// Also clean up the unsubscribe function reference
|
|
325
|
+
this.#unsubscribeFunctions.delete(run_id);
|
|
326
|
+
// We don't need to explicitly remove event listeners from the emitter
|
|
327
|
+
// as they will be garbage collected when no longer referenced.
|
|
328
|
+
// The event listeners are bound to specific callbacks provided by the client,
|
|
329
|
+
// which will retain references if they're still in use.
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { RunRow, StepStateRow } from '
|
|
3
|
-
import {
|
|
4
|
-
|
|
1
|
+
import type { AnyFlow, ExtractFlowSteps } from '@pgflow/dsl';
|
|
2
|
+
import type { RunRow, StepStateRow } from '@pgflow/core';
|
|
3
|
+
import type { BroadcastRunEvent, BroadcastStepEvent, FlowRunEvent, StepEvent } from './types.js';
|
|
5
4
|
/**
|
|
6
5
|
* Convert a broadcast run event to a typed run event
|
|
7
6
|
*/
|