@ironflow/browser 0.20.2 → 0.22.0

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 (66) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/index.d.ts +34 -0
  3. package/dist/agents/index.d.ts.map +1 -0
  4. package/dist/agents/index.js +24 -0
  5. package/dist/agents/index.js.map +1 -0
  6. package/dist/agents/invoke.d.ts +16 -0
  7. package/dist/agents/invoke.d.ts.map +1 -0
  8. package/dist/agents/invoke.js +222 -0
  9. package/dist/agents/invoke.js.map +1 -0
  10. package/dist/agents/readMemory.d.ts +18 -0
  11. package/dist/agents/readMemory.d.ts.map +1 -0
  12. package/dist/agents/readMemory.js +87 -0
  13. package/dist/agents/readMemory.js.map +1 -0
  14. package/dist/agents/subscribe.d.ts +23 -0
  15. package/dist/agents/subscribe.d.ts.map +1 -0
  16. package/dist/agents/subscribe.js +117 -0
  17. package/dist/agents/subscribe.js.map +1 -0
  18. package/dist/agents/types.d.ts +156 -0
  19. package/dist/agents/types.d.ts.map +1 -0
  20. package/dist/agents/types.js +7 -0
  21. package/dist/agents/types.js.map +1 -0
  22. package/dist/client.d.ts +844 -0
  23. package/dist/client.d.ts.map +1 -0
  24. package/dist/client.js +2243 -0
  25. package/dist/client.js.map +1 -0
  26. package/dist/config-client.d.ts +43 -0
  27. package/dist/config-client.d.ts.map +1 -0
  28. package/dist/config-client.js +134 -0
  29. package/dist/config-client.js.map +1 -0
  30. package/dist/config.d.ts +91 -0
  31. package/dist/config.d.ts.map +1 -0
  32. package/dist/config.js +55 -0
  33. package/dist/config.js.map +1 -0
  34. package/dist/index.d.ts +53 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +53 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/kv.d.ts +79 -0
  39. package/dist/kv.d.ts.map +1 -0
  40. package/dist/kv.js +289 -0
  41. package/dist/kv.js.map +1 -0
  42. package/dist/projection-stream.d.ts +42 -0
  43. package/dist/projection-stream.d.ts.map +1 -0
  44. package/dist/projection-stream.js +48 -0
  45. package/dist/projection-stream.js.map +1 -0
  46. package/dist/subscription.d.ts +107 -0
  47. package/dist/subscription.d.ts.map +1 -0
  48. package/dist/subscription.js +450 -0
  49. package/dist/subscription.js.map +1 -0
  50. package/dist/transport/connectrpc.d.ts +74 -0
  51. package/dist/transport/connectrpc.d.ts.map +1 -0
  52. package/dist/transport/connectrpc.js +393 -0
  53. package/dist/transport/connectrpc.js.map +1 -0
  54. package/dist/transport/index.d.ts +7 -0
  55. package/dist/transport/index.d.ts.map +1 -0
  56. package/dist/transport/index.js +6 -0
  57. package/dist/transport/index.js.map +1 -0
  58. package/dist/transport/types.d.ts +69 -0
  59. package/dist/transport/types.d.ts.map +1 -0
  60. package/dist/transport/types.js +5 -0
  61. package/dist/transport/types.js.map +1 -0
  62. package/dist/transport/websocket.d.ts +38 -0
  63. package/dist/transport/websocket.d.ts.map +1 -0
  64. package/dist/transport/websocket.js +276 -0
  65. package/dist/transport/websocket.js.map +1 -0
  66. package/package.json +3 -3
package/dist/client.js ADDED
@@ -0,0 +1,2243 @@
1
+ /**
2
+ * Ironflow Browser Client
3
+ *
4
+ * Singleton client for browser-based real-time interactions with Ironflow.
5
+ */
6
+ import { NotConfiguredError, IronflowError, RunFailedError, RunCancelledError, UnauthenticatedError, EnterpriseRequiredError, UnauthorizedError, ValidationError, createLogger, createNoopLogger, DEFAULT_TIMEOUTS, HEADERS, TriggerResponseSchema, TriggerSyncResponseSchema, RunResponseSchema, ListRunsResponseSchema, RunStatusSchema, ErrorResponseSchema, safeJsonParse, patterns, peelProjectionEnvelope, } from "@ironflow/core";
7
+ import { mergeConfig } from "./config.js";
8
+ import { SubscriptionManager, } from "./subscription.js";
9
+ import { createWebSocketTransport } from "./transport/websocket.js";
10
+ import { createConnectRPCTransport } from "./transport/connectrpc.js";
11
+ import { filterWaitStreamFrames } from "./projection-stream.js";
12
+ import { BrowserKVClient } from "./kv.js";
13
+ import { BrowserConfigClient } from "./config-client.js";
14
+ import { createAgentsNamespace } from "./agents/index.js";
15
+ /**
16
+ * Ironflow browser client singleton
17
+ */
18
+ class IronflowClient {
19
+ config = null;
20
+ logger = createNoopLogger();
21
+ transport = null;
22
+ subscriptionManager = null;
23
+ visibilityHandler = null;
24
+ /**
25
+ * `agents` namespace — browser helpers for `agent()` functions.
26
+ *
27
+ * @example
28
+ * ```typescript
29
+ * const result = await ironflow.agents.invoke("my-agent", { task: "..." });
30
+ * console.log(result.runId, result.output);
31
+ *
32
+ * const sub = await ironflow.agents.subscribe(runId, {
33
+ * onStep: (e) => console.log("step", e.stepId, e.type),
34
+ * onComplete: (r) => console.log("done", r.output),
35
+ * });
36
+ * ```
37
+ */
38
+ agents = createAgentsNamespace({
39
+ invoke: (functionId, options) => this.invoke(functionId, options),
40
+ subscribe: (pattern, cbs) => this.subscribe(pattern, cbs),
41
+ cancelRun: (runId, reason) => this.cancelRun(runId, reason),
42
+ getProjection: (name, options) => this.getProjection(name, options),
43
+ waitForProjectionCatchup: (name, opts) => this.waitForProjectionCatchup(name, opts),
44
+ });
45
+ /**
46
+ * Configure the client
47
+ *
48
+ * Must be called before any other operations.
49
+ */
50
+ configure(options = {}) {
51
+ this.config = mergeConfig(options);
52
+ // Set up logger
53
+ if (this.config.logger === false) {
54
+ this.logger = createNoopLogger();
55
+ }
56
+ else if (this.config.logger) {
57
+ this.logger = this.config.logger;
58
+ }
59
+ else {
60
+ this.logger = createLogger({ prefix: "[ironflow]" });
61
+ }
62
+ // Clean up existing resources
63
+ this.cleanup();
64
+ // Create transport
65
+ const transportOptions = {
66
+ auth: this.config.auth,
67
+ autoReconnect: this.config.reconnect.enabled,
68
+ reconnectDelay: this.config.reconnect.backoff.initial,
69
+ maxReconnectDelay: this.config.reconnect.backoff.max,
70
+ reconnectBackoff: this.config.reconnect.backoff.multiplier,
71
+ environment: this.config.environment,
72
+ };
73
+ // Create transport based on config (ConnectRPC by default)
74
+ if (this.config.transport === "websocket") {
75
+ this.transport = createWebSocketTransport(this.config.serverUrl, transportOptions);
76
+ }
77
+ else {
78
+ // Default to ConnectRPC
79
+ this.transport = createConnectRPCTransport(this.config.serverUrl, transportOptions);
80
+ }
81
+ // Create subscription manager
82
+ this.subscriptionManager = new SubscriptionManager(this.transport, this.config.logger);
83
+ // Set up visibility handling
84
+ if (this.config.visibility.pauseOnHidden && typeof document !== "undefined") {
85
+ this.setupVisibilityHandling();
86
+ }
87
+ this.logger.info("Ironflow client configured", {
88
+ serverUrl: this.config.serverUrl,
89
+ transport: this.config.transport,
90
+ });
91
+ }
92
+ /**
93
+ * Check if the client is configured
94
+ */
95
+ get isConfigured() {
96
+ return this.config !== null;
97
+ }
98
+ /**
99
+ * Detect which transport the server supports
100
+ *
101
+ * Returns the best available transport based on server capabilities.
102
+ * ConnectRPC is preferred over WebSocket.
103
+ *
104
+ * @example
105
+ * ```typescript
106
+ * const transport = await ironflow.detectTransport();
107
+ * // Returns 'connectrpc' | 'websocket'
108
+ * ```
109
+ */
110
+ async detectTransport() {
111
+ const serverUrl = this.config?.serverUrl ?? "http://localhost:9123";
112
+ try {
113
+ // Try to get server capabilities via ConnectRPC endpoint
114
+ const detectHeaders = {
115
+ "Content-Type": "application/json",
116
+ };
117
+ if (this.config?.environment) {
118
+ detectHeaders[HEADERS.ENVIRONMENT] = this.config.environment;
119
+ }
120
+ const response = await fetch(`${serverUrl}/ironflow.v1.IronflowService/GetCapabilities`, {
121
+ method: "POST",
122
+ headers: detectHeaders,
123
+ body: "{}",
124
+ });
125
+ if (response.ok) {
126
+ const data = await response.json();
127
+ const transports = data.transports;
128
+ // Check if server explicitly supports connectrpc
129
+ if (transports?.includes("connectrpc") || transports?.includes("grpc")) {
130
+ return "connectrpc";
131
+ }
132
+ // Server responded, so ConnectRPC endpoint works
133
+ return "connectrpc";
134
+ }
135
+ }
136
+ catch {
137
+ // ConnectRPC not available
138
+ }
139
+ // Fall back to WebSocket
140
+ return "websocket";
141
+ }
142
+ /**
143
+ * Get the current configuration
144
+ */
145
+ getConfig() {
146
+ if (!this.config) {
147
+ throw new NotConfiguredError();
148
+ }
149
+ return this.config;
150
+ }
151
+ // ============================================================================
152
+ // Connection Management
153
+ // ============================================================================
154
+ /**
155
+ * Connect to the Ironflow server
156
+ */
157
+ async connect() {
158
+ this.ensureConfigured();
159
+ await this.subscriptionManager.connect();
160
+ }
161
+ /**
162
+ * Disconnect from the Ironflow server
163
+ */
164
+ disconnect() {
165
+ if (this.subscriptionManager) {
166
+ this.subscriptionManager.disconnect();
167
+ }
168
+ }
169
+ /**
170
+ * Register a callback for connection state changes
171
+ */
172
+ onConnectionChange(callback) {
173
+ this.ensureConfigured();
174
+ return this.subscriptionManager.onConnectionChange(callback);
175
+ }
176
+ /**
177
+ * Get current connection state
178
+ */
179
+ get connectionState() {
180
+ if (!this.subscriptionManager) {
181
+ return "disconnected";
182
+ }
183
+ return this.subscriptionManager.connectionState;
184
+ }
185
+ // ============================================================================
186
+ // Subscriptions
187
+ // ============================================================================
188
+ /**
189
+ * Subscribe to events matching a pattern
190
+ *
191
+ * @example
192
+ * ```typescript
193
+ * // Basic subscription
194
+ * const sub = await ironflow.subscribe('events:order.*', {
195
+ * onEvent: (event) => console.log(event),
196
+ * });
197
+ *
198
+ * // Multiple patterns
199
+ * const sub = await ironflow.subscribe(['system.run.*', 'events:order.*'], {
200
+ * onEvent: (event) => { ... }
201
+ * });
202
+ *
203
+ * // With options
204
+ * const sub = await ironflow.subscribe('events:*', {
205
+ * onEvent: (e) => { ... },
206
+ * replay: 10,
207
+ * trackState: true,
208
+ * ackMode: 'manual',
209
+ * });
210
+ * ```
211
+ */
212
+ subscribe(pattern, callbacksAndOptions) {
213
+ this.ensureConfigured();
214
+ return this.subscriptionManager.subscribe(pattern, callbacksAndOptions);
215
+ }
216
+ /**
217
+ * Create a subscription group for batch management
218
+ *
219
+ * @example
220
+ * ```typescript
221
+ * const group = ironflow.subscriptionGroup();
222
+ * await group.add('system.run.*', { onEvent: handleRun });
223
+ * await group.add('events:payment.*', { onEvent: handlePayment });
224
+ * // Later:
225
+ * group.unsubscribeAll();
226
+ * ```
227
+ */
228
+ subscriptionGroup() {
229
+ this.ensureConfigured();
230
+ return this.subscriptionManager.createGroup();
231
+ }
232
+ /**
233
+ * Register a global error handler
234
+ */
235
+ onError(callback) {
236
+ this.ensureConfigured();
237
+ return this.subscriptionManager.onError(callback);
238
+ }
239
+ /**
240
+ * Number of currently active subscriptions. Useful for leak audits and
241
+ * diagnostics. Returns 0 if the client is not configured.
242
+ */
243
+ getActiveSubscriptionCount() {
244
+ return this.subscriptionManager?.activeSubscriptionCount ?? 0;
245
+ }
246
+ // ============================================================================
247
+ // Workflow Operations
248
+ // ============================================================================
249
+ /**
250
+ * Invoke a workflow function by ID
251
+ *
252
+ * @example
253
+ * ```typescript
254
+ * const run = await ironflow.invoke<OrderInput, OrderOutput>('process-order', {
255
+ * data: { orderId: '123' }
256
+ * });
257
+ * ```
258
+ */
259
+ async invoke(functionId, options) {
260
+ this.ensureConfigured();
261
+ const response = await this.request(TriggerResponseSchema, "POST", "/ironflow.v1.IronflowService/Trigger", {
262
+ event: functionId,
263
+ data: options.data,
264
+ idempotency_key: options.idempotencyKey,
265
+ });
266
+ return {
267
+ runIds: response.runIds ?? [],
268
+ eventId: response.eventId,
269
+ };
270
+ }
271
+ /**
272
+ * Get run status
273
+ */
274
+ async getRun(runId) {
275
+ this.ensureConfigured();
276
+ const response = await this.request(RunResponseSchema, "POST", "/ironflow.v1.IronflowService/GetRun", { id: runId });
277
+ return this.mapRunResponse(response);
278
+ }
279
+ /**
280
+ * List runs with filtering
281
+ */
282
+ async listRuns(options) {
283
+ this.ensureConfigured();
284
+ const response = await this.request(ListRunsResponseSchema, "POST", "/ironflow.v1.IronflowService/ListRuns", {
285
+ function_id: options?.functionId,
286
+ status: options?.status?.toUpperCase(),
287
+ limit: options?.limit,
288
+ cursor: options?.cursor,
289
+ });
290
+ return {
291
+ runs: (response.runs ?? []).map((r) => this.mapRunResponse(r)),
292
+ nextCursor: response.nextCursor,
293
+ totalCount: response.totalCount ?? 0,
294
+ };
295
+ }
296
+ /**
297
+ * Cancel a running run
298
+ */
299
+ async cancelRun(runId, reason) {
300
+ this.ensureConfigured();
301
+ const response = await this.request(RunResponseSchema, "POST", "/ironflow.v1.IronflowService/CancelRun", { id: runId, reason });
302
+ return this.mapRunResponse(response);
303
+ }
304
+ /**
305
+ * Retry a failed run
306
+ */
307
+ async retryRun(runId, fromStep) {
308
+ this.ensureConfigured();
309
+ const response = await this.request(RunResponseSchema, "POST", "/ironflow.v1.IronflowService/RetryRun", { id: runId, fromStep });
310
+ return this.mapRunResponse(response);
311
+ }
312
+ /**
313
+ * Patch a step's output (hot patching)
314
+ */
315
+ async patchStep(stepId, output, reason) {
316
+ this.ensureConfigured();
317
+ const url = `${this.config.serverUrl}/api/v1/steps/patch`;
318
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
319
+ const controller = new AbortController();
320
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
321
+ try {
322
+ const headers = {
323
+ "Content-Type": "application/json",
324
+ [HEADERS.ENVIRONMENT]: this.config.environment,
325
+ };
326
+ if (this.config.auth?.apiKey) {
327
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
328
+ }
329
+ const response = await fetch(url, {
330
+ method: "POST",
331
+ headers,
332
+ body: JSON.stringify({ step_id: stepId, output, reason: reason || "" }),
333
+ signal: controller.signal,
334
+ });
335
+ if (!response.ok) {
336
+ const error = safeJsonParse(await response.text());
337
+ throw new IronflowError(error?.message || `Patch step failed: ${response.status}`, { code: error?.code || "PATCH_FAILED" });
338
+ }
339
+ }
340
+ finally {
341
+ clearTimeout(timeoutId);
342
+ }
343
+ }
344
+ /**
345
+ * Resume a paused or failed run
346
+ */
347
+ async resumeRun(runId, fromStep) {
348
+ this.ensureConfigured();
349
+ const url = `${this.config.serverUrl}/api/v1/runs/resume`;
350
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
351
+ const controller = new AbortController();
352
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
353
+ try {
354
+ const headers = {
355
+ "Content-Type": "application/json",
356
+ [HEADERS.ENVIRONMENT]: this.config.environment,
357
+ };
358
+ if (this.config.auth?.apiKey) {
359
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
360
+ }
361
+ const response = await fetch(url, {
362
+ method: "POST",
363
+ headers,
364
+ body: JSON.stringify({ run_id: runId, from_step: fromStep || "" }),
365
+ signal: controller.signal,
366
+ });
367
+ if (!response.ok) {
368
+ const error = safeJsonParse(await response.text());
369
+ throw new IronflowError(error?.message || `Resume run failed: ${response.status}`, { code: error?.code || "RESUME_FAILED" });
370
+ }
371
+ return response.json();
372
+ }
373
+ finally {
374
+ clearTimeout(timeoutId);
375
+ }
376
+ }
377
+ /**
378
+ * Pause a running workflow at the next step boundary (scoped injection).
379
+ */
380
+ async pauseRun(runId) {
381
+ this.ensureConfigured();
382
+ const url = `${this.config.serverUrl}/ironflow.v1.IronflowService/PauseRun`;
383
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
384
+ const controller = new AbortController();
385
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
386
+ try {
387
+ const headers = {
388
+ "Content-Type": "application/json",
389
+ [HEADERS.ENVIRONMENT]: this.config.environment,
390
+ };
391
+ if (this.config.auth?.apiKey) {
392
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
393
+ }
394
+ const response = await fetch(url, {
395
+ method: "POST",
396
+ headers,
397
+ body: JSON.stringify({ run_id: runId }),
398
+ signal: controller.signal,
399
+ });
400
+ if (!response.ok) {
401
+ const error = safeJsonParse(await response.text());
402
+ throw new IronflowError(error?.message || `Pause run failed: ${response.status}`, { code: error?.code || "PAUSE_FAILED" });
403
+ }
404
+ return response.json();
405
+ }
406
+ finally {
407
+ clearTimeout(timeoutId);
408
+ }
409
+ }
410
+ /**
411
+ * Get the paused state of a run, including completed steps and next step hint.
412
+ */
413
+ async getPausedState(runId) {
414
+ this.ensureConfigured();
415
+ const url = `${this.config.serverUrl}/ironflow.v1.IronflowService/GetPausedState`;
416
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
417
+ const controller = new AbortController();
418
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
419
+ try {
420
+ const headers = {
421
+ "Content-Type": "application/json",
422
+ [HEADERS.ENVIRONMENT]: this.config.environment,
423
+ };
424
+ if (this.config.auth?.apiKey) {
425
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
426
+ }
427
+ const response = await fetch(url, {
428
+ method: "POST",
429
+ headers,
430
+ body: JSON.stringify({ run_id: runId }),
431
+ signal: controller.signal,
432
+ });
433
+ if (!response.ok) {
434
+ const error = safeJsonParse(await response.text());
435
+ throw new IronflowError(error?.message || `Get paused state failed: ${response.status}`, { code: error?.code || "GET_PAUSED_STATE_FAILED" });
436
+ }
437
+ const data = (await response.json());
438
+ return {
439
+ steps: (data.steps || []).map((s) => ({
440
+ id: s.id,
441
+ name: s.name,
442
+ output: s.output ? JSON.parse(s.output) : null,
443
+ injected: s.injected,
444
+ completedAt: s.completed_at,
445
+ })),
446
+ nextStepHint: data.next_step_hint,
447
+ pauseReason: data.pause_reason,
448
+ };
449
+ }
450
+ finally {
451
+ clearTimeout(timeoutId);
452
+ }
453
+ }
454
+ /**
455
+ * Inject new output for a step in a paused run (scoped injection).
456
+ */
457
+ async injectStepOutput(runId, stepId, newOutput, reason) {
458
+ this.ensureConfigured();
459
+ const url = `${this.config.serverUrl}/ironflow.v1.IronflowService/InjectStepOutput`;
460
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
461
+ const controller = new AbortController();
462
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
463
+ try {
464
+ const headers = {
465
+ "Content-Type": "application/json",
466
+ [HEADERS.ENVIRONMENT]: this.config.environment,
467
+ };
468
+ if (this.config.auth?.apiKey) {
469
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
470
+ }
471
+ const response = await fetch(url, {
472
+ method: "POST",
473
+ headers,
474
+ body: JSON.stringify({
475
+ run_id: runId,
476
+ step_id: stepId,
477
+ new_output: JSON.stringify(newOutput),
478
+ reason: reason ?? "",
479
+ }),
480
+ signal: controller.signal,
481
+ });
482
+ if (!response.ok) {
483
+ const error = safeJsonParse(await response.text());
484
+ throw new IronflowError(error?.message || `Inject step output failed: ${response.status}`, { code: error?.code || "INJECT_FAILED" });
485
+ }
486
+ const data = (await response.json());
487
+ return {
488
+ stepId: data.step_id,
489
+ previousOutput: data.previous_output
490
+ ? JSON.parse(data.previous_output)
491
+ : null,
492
+ };
493
+ }
494
+ finally {
495
+ clearTimeout(timeoutId);
496
+ }
497
+ }
498
+ // ==========================================================================
499
+ // Time-Travel Debugging
500
+ // ==========================================================================
501
+ /**
502
+ * Get the reconstructed state of a run at a specific timestamp.
503
+ */
504
+ async getRunStateAt(runId, timestamp) {
505
+ this.ensureConfigured();
506
+ const url = `${this.config.serverUrl}/ironflow.v1.TimeTravelService/GetRunStateAt`;
507
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
508
+ const controller = new AbortController();
509
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
510
+ try {
511
+ const headers = {
512
+ "Content-Type": "application/json",
513
+ [HEADERS.ENVIRONMENT]: this.config.environment,
514
+ };
515
+ if (this.config.auth?.apiKey) {
516
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
517
+ }
518
+ const response = await fetch(url, {
519
+ method: "POST",
520
+ headers,
521
+ body: JSON.stringify({
522
+ runId,
523
+ timestamp: timestamp.toISOString(),
524
+ }),
525
+ signal: controller.signal,
526
+ });
527
+ if (!response.ok) {
528
+ const error = safeJsonParse(await response.text());
529
+ throw new IronflowError(error?.message || `Get run state failed: ${response.status}`, { code: error?.code || "GET_RUN_STATE_FAILED" });
530
+ }
531
+ const data = (await response.json());
532
+ const s = data.snapshot;
533
+ return {
534
+ runId: s.runId,
535
+ functionId: s.functionId,
536
+ status: s.status,
537
+ input: s.input ? JSON.parse(atob(s.input)) : null,
538
+ steps: (s.steps || []).map((step) => ({
539
+ stepId: step.stepId,
540
+ name: step.name,
541
+ type: step.type,
542
+ sequence: step.sequence,
543
+ status: step.status,
544
+ output: step.output ? JSON.parse(atob(step.output)) : null,
545
+ error: step.error ? JSON.parse(atob(step.error)) : null,
546
+ originalOutput: step.originalOutput ? JSON.parse(atob(step.originalOutput)) : null,
547
+ startedAt: step.startedAt ? new Date(step.startedAt) : null,
548
+ completedAt: step.completedAt ? new Date(step.completedAt) : null,
549
+ durationMs: step.durationMs ?? null,
550
+ injected: step.injected,
551
+ patched: step.patched,
552
+ })),
553
+ timestamp: new Date(s.timestamp),
554
+ createdAt: s.createdAt ? new Date(s.createdAt) : null,
555
+ };
556
+ }
557
+ finally {
558
+ clearTimeout(timeoutId);
559
+ }
560
+ }
561
+ /**
562
+ * Get the timeline of audit events for a run.
563
+ */
564
+ async getRunTimeline(runId) {
565
+ this.ensureConfigured();
566
+ const url = `${this.config.serverUrl}/ironflow.v1.TimeTravelService/GetRunTimeline`;
567
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
568
+ const controller = new AbortController();
569
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
570
+ try {
571
+ const headers = {
572
+ "Content-Type": "application/json",
573
+ [HEADERS.ENVIRONMENT]: this.config.environment,
574
+ };
575
+ if (this.config.auth?.apiKey) {
576
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
577
+ }
578
+ const response = await fetch(url, {
579
+ method: "POST",
580
+ headers,
581
+ body: JSON.stringify({ runId }),
582
+ signal: controller.signal,
583
+ });
584
+ if (!response.ok) {
585
+ const error = safeJsonParse(await response.text());
586
+ throw new IronflowError(error?.message || `Get run timeline failed: ${response.status}`, { code: error?.code || "GET_TIMELINE_FAILED" });
587
+ }
588
+ const data = (await response.json());
589
+ return (data.events || []).map((e) => ({
590
+ id: e.id,
591
+ eventType: e.eventType,
592
+ stepId: e.stepId || "",
593
+ stepName: e.stepName || "",
594
+ summary: e.summary,
595
+ significant: e.significant,
596
+ timestamp: new Date(e.timestamp),
597
+ }));
598
+ }
599
+ finally {
600
+ clearTimeout(timeoutId);
601
+ }
602
+ }
603
+ /**
604
+ * Get the output of a specific step at a specific timestamp.
605
+ */
606
+ async getStepOutputAt(runId, stepId, timestamp) {
607
+ this.ensureConfigured();
608
+ const url = `${this.config.serverUrl}/ironflow.v1.TimeTravelService/GetStepOutputAt`;
609
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
610
+ const controller = new AbortController();
611
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
612
+ try {
613
+ const headers = {
614
+ "Content-Type": "application/json",
615
+ [HEADERS.ENVIRONMENT]: this.config.environment,
616
+ };
617
+ if (this.config.auth?.apiKey) {
618
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
619
+ }
620
+ const response = await fetch(url, {
621
+ method: "POST",
622
+ headers,
623
+ body: JSON.stringify({
624
+ runId,
625
+ stepId,
626
+ timestamp: timestamp.toISOString(),
627
+ }),
628
+ signal: controller.signal,
629
+ });
630
+ if (!response.ok) {
631
+ const error = safeJsonParse(await response.text());
632
+ throw new IronflowError(error?.message || `Get step output failed: ${response.status}`, { code: error?.code || "GET_STEP_OUTPUT_FAILED" });
633
+ }
634
+ const data = (await response.json());
635
+ return {
636
+ stepId: data.stepId,
637
+ status: data.status,
638
+ output: data.output ? JSON.parse(atob(data.output)) : null,
639
+ originalOutput: data.originalOutput ? JSON.parse(atob(data.originalOutput)) : null,
640
+ patched: data.patched,
641
+ injected: data.injected,
642
+ };
643
+ }
644
+ finally {
645
+ clearTimeout(timeoutId);
646
+ }
647
+ }
648
+ /**
649
+ * List registered functions
650
+ */
651
+ async listFunctions() {
652
+ this.ensureConfigured();
653
+ const url = `${this.config.serverUrl}/api/v1/functions`;
654
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
655
+ const controller = new AbortController();
656
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
657
+ try {
658
+ const headers = {
659
+ [HEADERS.ENVIRONMENT]: this.config.environment,
660
+ };
661
+ if (this.config.auth?.apiKey) {
662
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
663
+ }
664
+ const response = await fetch(url, {
665
+ method: "GET",
666
+ headers,
667
+ signal: controller.signal,
668
+ });
669
+ if (!response.ok) {
670
+ throw new IronflowError(`List functions failed: ${response.status}`, { code: "LIST_FUNCTIONS_FAILED" });
671
+ }
672
+ const data = await response.json();
673
+ return data.functions || [];
674
+ }
675
+ finally {
676
+ clearTimeout(timeoutId);
677
+ }
678
+ }
679
+ /**
680
+ * List connected workers
681
+ */
682
+ async listWorkers() {
683
+ this.ensureConfigured();
684
+ const url = `${this.config.serverUrl}/api/v1/workers`;
685
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
686
+ const controller = new AbortController();
687
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
688
+ try {
689
+ const headers = {
690
+ [HEADERS.ENVIRONMENT]: this.config.environment,
691
+ };
692
+ if (this.config.auth?.apiKey) {
693
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
694
+ }
695
+ const response = await fetch(url, {
696
+ method: "GET",
697
+ headers,
698
+ signal: controller.signal,
699
+ });
700
+ if (!response.ok) {
701
+ throw new IronflowError(`List workers failed: ${response.status}`, { code: "LIST_WORKERS_FAILED" });
702
+ }
703
+ const data = await response.json();
704
+ return data.workers || [];
705
+ }
706
+ finally {
707
+ clearTimeout(timeoutId);
708
+ }
709
+ }
710
+ /**
711
+ * Health check
712
+ */
713
+ async health() {
714
+ this.ensureConfigured();
715
+ const url = `${this.config.serverUrl}/health`;
716
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
717
+ const controller = new AbortController();
718
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
719
+ try {
720
+ const response = await fetch(url, {
721
+ method: "GET",
722
+ signal: controller.signal,
723
+ });
724
+ if (!response.ok) {
725
+ throw new IronflowError(`Health check failed: ${response.status}`, { code: "HEALTH_FAILED" });
726
+ }
727
+ return response.json();
728
+ }
729
+ finally {
730
+ clearTimeout(timeoutId);
731
+ }
732
+ }
733
+ /**
734
+ * Get server capabilities
735
+ */
736
+ async getCapabilities() {
737
+ this.ensureConfigured();
738
+ const url = `${this.config.serverUrl}/api/v1/capabilities`;
739
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
740
+ const controller = new AbortController();
741
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
742
+ try {
743
+ const response = await fetch(url, {
744
+ method: "GET",
745
+ signal: controller.signal,
746
+ });
747
+ if (!response.ok) {
748
+ throw new IronflowError(`Get capabilities failed: ${response.status}`, { code: "CAPABILITIES_FAILED" });
749
+ }
750
+ return response.json();
751
+ }
752
+ finally {
753
+ clearTimeout(timeoutId);
754
+ }
755
+ }
756
+ // ============================================================================
757
+ // Event Emission
758
+ // ============================================================================
759
+ /**
760
+ * Emit an event
761
+ *
762
+ * @example
763
+ * ```typescript
764
+ * await ironflow.emit('order.approved', {
765
+ * orderId: '123',
766
+ * approvedBy: 'user@example.com'
767
+ * });
768
+ * ```
769
+ */
770
+ async emit(eventName, data, options) {
771
+ this.ensureConfigured();
772
+ const response = await this.request(TriggerResponseSchema, "POST", "/ironflow.v1.PubSubService/Emit", {
773
+ event: eventName,
774
+ data,
775
+ ...(options?.version ? { version: options.version } : {}),
776
+ idempotency_key: options?.idempotencyKey,
777
+ metadata: options?.metadata,
778
+ namespace: options?.namespace,
779
+ });
780
+ return {
781
+ runIds: response.runIds ?? [],
782
+ eventId: response.eventId,
783
+ };
784
+ }
785
+ /**
786
+ * Emit an event synchronously — waits for the triggered run to complete and returns the result.
787
+ *
788
+ * Calls the TriggerSync endpoint, which blocks until the run finishes or the timeout elapses.
789
+ * Throws RunFailedError if the run fails, RunCancelledError if it is cancelled.
790
+ *
791
+ * @example
792
+ * ```typescript
793
+ * const result = await ironflow.emitSync("order.placed", { orderId: "123" });
794
+ * console.log("Output:", result.output);
795
+ * ```
796
+ */
797
+ async emitSync(eventName, data, options) {
798
+ this.ensureConfigured();
799
+ const timeout = options?.timeout ?? 30000;
800
+ const response = await this.request(TriggerSyncResponseSchema, "POST", "/ironflow.v1.IronflowService/TriggerSync", { event: eventName, data, timeout_ms: timeout });
801
+ const results = response.results;
802
+ if (!results?.length) {
803
+ throw new IronflowError("No results returned from TriggerSync", { code: "NO_RESULTS", retryable: false });
804
+ }
805
+ const result = results[0];
806
+ if (!result) {
807
+ throw new IronflowError("No results returned from TriggerSync", { code: "NO_RESULTS", retryable: false });
808
+ }
809
+ if (result.status === "failed") {
810
+ throw new RunFailedError(result.runId, result.error);
811
+ }
812
+ if (result.status === "cancelled") {
813
+ throw new RunCancelledError(result.runId);
814
+ }
815
+ return {
816
+ runId: result.runId,
817
+ functionId: result.functionId,
818
+ status: result.status,
819
+ output: result.output,
820
+ durationMs: result.durationMs,
821
+ };
822
+ }
823
+ // ============================================================================
824
+ // Consumer Groups
825
+ // ============================================================================
826
+ /**
827
+ * Join a consumer group for load-balanced event processing
828
+ */
829
+ async joinConsumerGroup(groupName, pattern, callbacksAndOptions) {
830
+ this.ensureConfigured();
831
+ const sub = await this.subscribe(pattern, {
832
+ ...callbacksAndOptions,
833
+ consumerGroup: groupName,
834
+ ackMode: "manual",
835
+ });
836
+ return sub;
837
+ }
838
+ // ============================================================================
839
+ // Entity Streams
840
+ // ============================================================================
841
+ /**
842
+ * Entity stream operations
843
+ *
844
+ * @example
845
+ * ```typescript
846
+ * // Append an event to a stream
847
+ * const result = await ironflow.streams.append("order-123", {
848
+ * name: "order.created",
849
+ * data: { total: 100 },
850
+ * entityType: "order",
851
+ * });
852
+ *
853
+ * // Read events from a stream
854
+ * const { events } = await ironflow.streams.read("order-123", { limit: 10 });
855
+ *
856
+ * // Get stream info
857
+ * const info = await ironflow.streams.getInfo("order-123");
858
+ * ```
859
+ */
860
+ streams = {
861
+ /**
862
+ * Append an event to an entity stream
863
+ */
864
+ append: async (entityId, input, options) => {
865
+ this.ensureConfigured();
866
+ const body = {
867
+ entity_id: entityId,
868
+ entity_type: input.entityType,
869
+ event_name: input.name,
870
+ data: input.data,
871
+ expected_version: options?.expectedVersion ?? -1,
872
+ idempotency_key: options?.idempotencyKey ?? "",
873
+ version: options?.version ?? 1,
874
+ };
875
+ if (options?.metadata !== undefined) {
876
+ body.metadata = options.metadata;
877
+ }
878
+ const response = await this.streamRequest("/ironflow.v1.EntityStreamService/AppendEvent", body);
879
+ return {
880
+ entityVersion: Number(response.entityVersion ?? 0),
881
+ eventId: response.eventId ?? "",
882
+ };
883
+ },
884
+ /**
885
+ * Read events from an entity stream
886
+ */
887
+ read: async (entityId, options) => {
888
+ this.ensureConfigured();
889
+ const response = await this.streamRequest("/ironflow.v1.EntityStreamService/ReadStream", {
890
+ entity_id: entityId,
891
+ from_version: options?.fromVersion ?? 0,
892
+ limit: options?.limit ?? 0,
893
+ direction: options?.direction ?? "forward",
894
+ });
895
+ return {
896
+ events: (response.events ?? []).map((e) => ({
897
+ id: e.id,
898
+ name: e.name,
899
+ data: e.data ?? {},
900
+ entityVersion: Number(e.entityVersion ?? 0),
901
+ version: e.version,
902
+ timestamp: e.timestamp,
903
+ source: e.source,
904
+ metadata: e.metadata,
905
+ })),
906
+ totalCount: Number(response.totalCount ?? 0),
907
+ };
908
+ },
909
+ /**
910
+ * Get information about an entity stream.
911
+ *
912
+ * Returns `null` if no events have been written to this stream yet — safe to
913
+ * pass `expectedVersion: 0` to `append()` in that case to create the first event.
914
+ *
915
+ * @example
916
+ * ```typescript
917
+ * const info = await ironflow.streams.getInfo("order-123");
918
+ * await ironflow.streams.append("order-123", event, {
919
+ * expectedVersion: info ? info.version : 0,
920
+ * });
921
+ * ```
922
+ */
923
+ getInfo: async (entityId) => {
924
+ this.ensureConfigured();
925
+ try {
926
+ const response = await this.streamRequest("/ironflow.v1.EntityStreamService/GetStreamInfo", {
927
+ entity_id: entityId,
928
+ });
929
+ return {
930
+ entityId: response.entityId ?? "",
931
+ entityType: response.entityType ?? "",
932
+ version: Number(response.version ?? 0),
933
+ eventCount: Number(response.eventCount ?? 0),
934
+ createdAt: response.createdAt ?? "",
935
+ updatedAt: response.updatedAt ?? "",
936
+ };
937
+ }
938
+ catch (err) {
939
+ if (err instanceof IronflowError && err.message === "stream not found") {
940
+ return null;
941
+ }
942
+ throw err;
943
+ }
944
+ },
945
+ /**
946
+ * Create a snapshot of the materialized state at a specific stream version.
947
+ */
948
+ createSnapshot: async (entityId, input) => {
949
+ this.ensureConfigured();
950
+ const response = await this.streamRequest("/ironflow.v1.EntityStreamService/CreateSnapshot", {
951
+ entity_id: entityId,
952
+ entity_type: input.entityType,
953
+ entity_version: input.entityVersion,
954
+ state: input.state,
955
+ });
956
+ return { snapshotId: response.snapshotId ?? "" };
957
+ },
958
+ /**
959
+ * Get the latest snapshot at or before a given version.
960
+ */
961
+ getSnapshot: async (entityId, options) => {
962
+ this.ensureConfigured();
963
+ const response = await this.streamRequest("/ironflow.v1.EntityStreamService/GetSnapshot", {
964
+ entity_id: entityId,
965
+ before_version: options?.beforeVersion ?? 0,
966
+ });
967
+ return {
968
+ snapshotId: response.snapshotId ?? "",
969
+ entityId: response.entityId ?? "",
970
+ entityType: response.entityType ?? "",
971
+ entityVersion: Number(response.entityVersion ?? 0),
972
+ state: response.state ?? {},
973
+ createdAt: response.createdAt ?? "",
974
+ };
975
+ },
976
+ /**
977
+ * Subscribe to real-time events for an entity stream
978
+ *
979
+ * @example
980
+ * ```typescript
981
+ * const sub = await ironflow.streams.subscribe("order-123", {
982
+ * entityType: "order",
983
+ * onEvent: (event) => console.log(event),
984
+ * replay: 100,
985
+ * });
986
+ *
987
+ * // Cleanup
988
+ * sub.unsubscribe();
989
+ * ```
990
+ */
991
+ subscribe: async (entityId, options) => {
992
+ this.ensureConfigured();
993
+ const pattern = `entity:${options.entityType}.${entityId}.>`;
994
+ const sub = await this.subscribe(pattern, {
995
+ onEvent: (event) => {
996
+ const data = event.data;
997
+ const streamEvent = {
998
+ id: data.id ?? "",
999
+ name: data.name ?? "",
1000
+ data: data.data ?? {},
1001
+ entityVersion: data.entityVersion ?? 0,
1002
+ version: data.version ?? 0,
1003
+ timestamp: data.timestamp ?? "",
1004
+ source: data.source,
1005
+ metadata: data.metadata,
1006
+ };
1007
+ options.onEvent(streamEvent);
1008
+ },
1009
+ onError: options.onError
1010
+ ? (info) => options.onError(new Error(info.message))
1011
+ : undefined,
1012
+ replay: options.replay,
1013
+ });
1014
+ return sub;
1015
+ },
1016
+ };
1017
+ // ============================================================================
1018
+ // Projections
1019
+ // ============================================================================
1020
+ /**
1021
+ * Get the current state of a projection
1022
+ *
1023
+ * @example
1024
+ * ```typescript
1025
+ * const result = await ironflow.getProjection<OrderStats>('order-stats');
1026
+ * console.log(result.state); // { totalOrders: 42, ... }
1027
+ *
1028
+ * // With partition
1029
+ * const result = await ironflow.getProjection('order-stats', { partition: 'customer-123' });
1030
+ * ```
1031
+ */
1032
+ async getProjection(name, options) {
1033
+ this.ensureConfigured();
1034
+ // Normalize empty-string partition to undefined so peel falls back to
1035
+ // "__global__" instead of returning empty-string partition.
1036
+ const partition = options?.partition ? options.partition : undefined;
1037
+ let url = `${this.config.serverUrl}/api/v1/projections/${encodeURIComponent(name)}`;
1038
+ if (partition) {
1039
+ url += `?partition=${encodeURIComponent(partition)}`;
1040
+ }
1041
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
1042
+ const controller = new AbortController();
1043
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
1044
+ try {
1045
+ const headers = {
1046
+ [HEADERS.ENVIRONMENT]: this.config.environment,
1047
+ };
1048
+ if (this.config.auth?.apiKey) {
1049
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
1050
+ }
1051
+ const response = await fetch(url, {
1052
+ method: "GET",
1053
+ headers,
1054
+ signal: controller.signal,
1055
+ });
1056
+ if (!response.ok) {
1057
+ const error = safeJsonParse(await response.text());
1058
+ throw new IronflowError(error?.message || `Get projection failed: ${response.status}`, { code: error?.code || "GET_PROJECTION_FAILED" });
1059
+ }
1060
+ const data = await response.json();
1061
+ return peelProjectionEnvelope(data, partition);
1062
+ }
1063
+ finally {
1064
+ clearTimeout(timeoutId);
1065
+ }
1066
+ }
1067
+ /**
1068
+ * Query a SQL-backed projection table with optional filtering, ordering, and pagination.
1069
+ *
1070
+ * @example
1071
+ * ```typescript
1072
+ * const result = await ironflow.querySQLProjection('board', {
1073
+ * where: "status = 'OPEN'",
1074
+ * orderBy: "title ASC",
1075
+ * limit: 50,
1076
+ * });
1077
+ * ```
1078
+ */
1079
+ async querySQLProjection(name, options) {
1080
+ this.ensureConfigured();
1081
+ const response = await this.streamRequest("/ironflow.v1.ProjectionService/QuerySQLProjection", {
1082
+ name,
1083
+ where: options?.where ?? "",
1084
+ order_by: options?.orderBy ?? "",
1085
+ limit: options?.limit ?? 100,
1086
+ offset: options?.offset ?? 0,
1087
+ });
1088
+ return {
1089
+ columns: response.columns ?? [],
1090
+ rows: (response.rows ?? []).map((r) => r.values),
1091
+ totalCount: Number(response.totalCount ?? 0),
1092
+ };
1093
+ }
1094
+ /**
1095
+ * Get the status of a projection
1096
+ *
1097
+ * @example
1098
+ * ```typescript
1099
+ * const status = await ironflow.getProjectionStatus('order-stats');
1100
+ * console.log(status.status); // 'active' | 'rebuilding' | 'paused' | 'error'
1101
+ * ```
1102
+ */
1103
+ async getProjectionStatus(name) {
1104
+ this.ensureConfigured();
1105
+ const url = `${this.config.serverUrl}/api/v1/projections/${encodeURIComponent(name)}/status`;
1106
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
1107
+ const controller = new AbortController();
1108
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
1109
+ try {
1110
+ const headers = {
1111
+ [HEADERS.ENVIRONMENT]: this.config.environment,
1112
+ };
1113
+ if (this.config.auth?.apiKey) {
1114
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
1115
+ }
1116
+ const response = await fetch(url, {
1117
+ method: "GET",
1118
+ headers,
1119
+ signal: controller.signal,
1120
+ });
1121
+ if (!response.ok) {
1122
+ const error = safeJsonParse(await response.text());
1123
+ throw new IronflowError(error?.message || `Get projection status failed: ${response.status}`, { code: error?.code || "GET_PROJECTION_STATUS_FAILED" });
1124
+ }
1125
+ const data = await response.json();
1126
+ return {
1127
+ name: data.name,
1128
+ status: data.status,
1129
+ mode: data.mode,
1130
+ lastEventSeq: data.last_event_seq ?? 0,
1131
+ lag: data.lag ?? 0,
1132
+ errorMessage: data.error_message || undefined,
1133
+ updatedAt: data.updated_at ? new Date(data.updated_at) : new Date(),
1134
+ };
1135
+ }
1136
+ finally {
1137
+ clearTimeout(timeoutId);
1138
+ }
1139
+ }
1140
+ /**
1141
+ * Trigger a rebuild of a projection
1142
+ *
1143
+ * @example
1144
+ * ```typescript
1145
+ * const result = await ironflow.rebuildProjection('order-stats');
1146
+ * ```
1147
+ */
1148
+ async rebuildProjection(name, options) {
1149
+ this.ensureConfigured();
1150
+ const url = `${this.config.serverUrl}/api/v1/projections/${encodeURIComponent(name)}/rebuild`;
1151
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
1152
+ const controller = new AbortController();
1153
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
1154
+ try {
1155
+ const headers = {
1156
+ "Content-Type": "application/json",
1157
+ [HEADERS.ENVIRONMENT]: this.config.environment,
1158
+ };
1159
+ if (this.config.auth?.apiKey) {
1160
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
1161
+ }
1162
+ const response = await fetch(url, {
1163
+ method: "POST",
1164
+ headers,
1165
+ body: JSON.stringify({
1166
+ partition: options?.partition,
1167
+ from_event_id: options?.fromEventId,
1168
+ dry_run: options?.dryRun,
1169
+ }),
1170
+ signal: controller.signal,
1171
+ });
1172
+ if (!response.ok) {
1173
+ const error = safeJsonParse(await response.text());
1174
+ throw new IronflowError(error?.message || `Rebuild projection failed: ${response.status}`, { code: error?.code || "REBUILD_PROJECTION_FAILED" });
1175
+ }
1176
+ return response.json();
1177
+ }
1178
+ finally {
1179
+ clearTimeout(timeoutId);
1180
+ }
1181
+ }
1182
+ /**
1183
+ * Wait until the named projection has processed events up to `minSeq`,
1184
+ * or the timeout elapses. Read-your-writes primitive for CQRS UIs:
1185
+ * pair with `sequence` from an `appendEntityEvent` response.
1186
+ *
1187
+ * Issue #473.
1188
+ */
1189
+ async waitForProjectionCatchup(name, opts) {
1190
+ this.ensureConfigured();
1191
+ const params = new URLSearchParams();
1192
+ params.set("minSeq", String(opts.minSeq));
1193
+ if (opts.timeoutMs !== undefined)
1194
+ params.set("timeout", String(opts.timeoutMs));
1195
+ if (opts.partition)
1196
+ params.set("partition", opts.partition);
1197
+ const url = `${this.config.serverUrl}/api/v1/projections/${encodeURIComponent(name)}/catchup?${params.toString()}`;
1198
+ // The wait timeout may exceed the normal client timeout — use the
1199
+ // opts.timeoutMs (plus a small grace period) so the client doesn't
1200
+ // abort the request before the server gets a chance to time out.
1201
+ const clientTimeoutMs = (opts.timeoutMs ?? 30_000) + 2_000;
1202
+ const controller = new AbortController();
1203
+ const timeoutId = setTimeout(() => controller.abort(), clientTimeoutMs);
1204
+ try {
1205
+ const headers = {
1206
+ [HEADERS.ENVIRONMENT]: this.config.environment,
1207
+ };
1208
+ if (this.config.auth?.apiKey) {
1209
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
1210
+ }
1211
+ const response = await fetch(url, {
1212
+ method: "GET",
1213
+ headers,
1214
+ signal: controller.signal,
1215
+ });
1216
+ if (!response.ok) {
1217
+ const error = safeJsonParse(await response.text());
1218
+ throw new IronflowError(error?.message || `Wait for projection catchup failed: ${response.status}`, { code: error?.code || "WAIT_FOR_PROJECTION_FAILED" });
1219
+ }
1220
+ return (await response.json());
1221
+ }
1222
+ finally {
1223
+ clearTimeout(timeoutId);
1224
+ }
1225
+ }
1226
+ /**
1227
+ * Streaming wait for a projection to catch up to `minSeq` (issue #476).
1228
+ *
1229
+ * Returns an `AsyncIterable<WaitProgress>` that yields a frame each time
1230
+ * the projection cursor advances, plus one terminal frame (`terminal:
1231
+ * true`) on catch-up, timeout, or error, after which iteration ends.
1232
+ * Heartbeats are filtered inside the SDK — callers never see them.
1233
+ *
1234
+ * Unlike the unary `waitForProjectionCatchup`, this path uses a
1235
+ * server-streaming ConnectRPC connection. The server caps the wait at
1236
+ * 300s regardless of `timeoutMs`. Pass `opts.signal` to cancel early
1237
+ * (e.g., on React unmount); breaking out of `for await` also aborts
1238
+ * the stream.
1239
+ *
1240
+ * Example:
1241
+ * ```ts
1242
+ * for await (const p of ironflow.waitForProjectionCatchupStream("order-view", { minSeq: 42 })) {
1243
+ * if (p.terminal) {
1244
+ * if (p.caughtUp) return;
1245
+ * throw new Error(`wait failed: timedOut=${p.timedOut} error=${p.error}`);
1246
+ * }
1247
+ * console.log(`progress: ${p.currentSeq}/${p.targetSeq} behind=${p.behindByEvents}`);
1248
+ * }
1249
+ * ```
1250
+ */
1251
+ async *waitForProjectionCatchupStream(name, opts) {
1252
+ this.ensureConfigured();
1253
+ // Lazy-load the generated projection client deps only when someone
1254
+ // actually calls the streaming API — keeps the tree-shaken bundle small
1255
+ // for apps that only use the unary wait.
1256
+ const { createClient } = await import("@connectrpc/connect");
1257
+ const { createConnectTransport } = await import("@connectrpc/connect-web");
1258
+ const { create } = await import("@bufbuild/protobuf");
1259
+ const { ProjectionService, WaitProjectionCatchupRequestSchema, WaitStreamFrameKind } = await import("@ironflow/core/gen");
1260
+ const transport = createConnectTransport({
1261
+ baseUrl: this.config.serverUrl,
1262
+ interceptors: [
1263
+ (next) => async (req) => {
1264
+ req.header.set(HEADERS.ENVIRONMENT, this.config.environment);
1265
+ const token = this.config.auth?.apiKey || this.config.auth?.token;
1266
+ if (token)
1267
+ req.header.set("Authorization", `Bearer ${token}`);
1268
+ return next(req);
1269
+ },
1270
+ ],
1271
+ });
1272
+ const client = createClient(ProjectionService, transport);
1273
+ const request = create(WaitProjectionCatchupRequestSchema, {
1274
+ name,
1275
+ minSeq: typeof opts.minSeq === "bigint" ? opts.minSeq : BigInt(opts.minSeq),
1276
+ partition: opts.partition ?? "",
1277
+ timeout: opts.timeoutMs !== undefined
1278
+ ? {
1279
+ $typeName: "google.protobuf.Duration",
1280
+ seconds: BigInt(Math.floor(opts.timeoutMs / 1000)),
1281
+ nanos: (opts.timeoutMs % 1000) * 1_000_000,
1282
+ }
1283
+ : undefined,
1284
+ });
1285
+ const stream = client.waitProjectionCatchupStream(request, { signal: opts.signal });
1286
+ yield* filterWaitStreamFrames(stream, WaitStreamFrameKind);
1287
+ }
1288
+ /**
1289
+ * Wait for a specific event to be processed by the given projection.
1290
+ * The server resolves `eventId` to its NATS sequence internally.
1291
+ *
1292
+ * Issue #473.
1293
+ */
1294
+ async waitForEvent(eventId, projection, opts = {}) {
1295
+ this.ensureConfigured();
1296
+ const url = `${this.config.serverUrl}/api/v1/projections/wait-for-event`;
1297
+ const clientTimeoutMs = (opts.timeoutMs ?? 30_000) + 2_000;
1298
+ const controller = new AbortController();
1299
+ const timeoutId = setTimeout(() => controller.abort(), clientTimeoutMs);
1300
+ try {
1301
+ const headers = {
1302
+ "Content-Type": "application/json",
1303
+ [HEADERS.ENVIRONMENT]: this.config.environment,
1304
+ };
1305
+ if (this.config.auth?.apiKey) {
1306
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
1307
+ }
1308
+ const body = { eventId, projection };
1309
+ if (opts.timeoutMs !== undefined)
1310
+ body.timeoutMs = opts.timeoutMs;
1311
+ if (opts.partition)
1312
+ body.partition = opts.partition;
1313
+ const response = await fetch(url, {
1314
+ method: "POST",
1315
+ headers,
1316
+ body: JSON.stringify(body),
1317
+ signal: controller.signal,
1318
+ });
1319
+ if (!response.ok) {
1320
+ const error = safeJsonParse(await response.text());
1321
+ throw new IronflowError(error?.message || `Wait for event failed: ${response.status}`, { code: error?.code || "WAIT_FOR_EVENT_FAILED" });
1322
+ }
1323
+ return (await response.json());
1324
+ }
1325
+ finally {
1326
+ clearTimeout(timeoutId);
1327
+ }
1328
+ }
1329
+ /**
1330
+ * List all registered projections
1331
+ *
1332
+ * @example
1333
+ * ```typescript
1334
+ * const projections = await ironflow.listProjections();
1335
+ * projections.forEach(p => console.log(p.name, p.status));
1336
+ * ```
1337
+ */
1338
+ async listProjections() {
1339
+ this.ensureConfigured();
1340
+ const url = `${this.config.serverUrl}/api/v1/projections`;
1341
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
1342
+ const controller = new AbortController();
1343
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
1344
+ try {
1345
+ const headers = {
1346
+ [HEADERS.ENVIRONMENT]: this.config.environment,
1347
+ };
1348
+ if (this.config.auth?.apiKey) {
1349
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
1350
+ }
1351
+ const response = await fetch(url, {
1352
+ method: "GET",
1353
+ headers,
1354
+ signal: controller.signal,
1355
+ });
1356
+ if (!response.ok) {
1357
+ throw new IronflowError(`List projections failed: ${response.status}`, { code: "LIST_PROJECTIONS_FAILED" });
1358
+ }
1359
+ const data = await response.json();
1360
+ const projections = data.projections || [];
1361
+ return projections.map((p) => ({
1362
+ name: p.name,
1363
+ status: p.status,
1364
+ mode: p.mode,
1365
+ lastEventSeq: p.last_event_seq ?? 0,
1366
+ lag: 0,
1367
+ errorMessage: p.error_message || undefined,
1368
+ updatedAt: p.updated_at ? new Date(p.updated_at) : new Date(),
1369
+ }));
1370
+ }
1371
+ finally {
1372
+ clearTimeout(timeoutId);
1373
+ }
1374
+ }
1375
+ /**
1376
+ * Subscribe to real-time updates for a projection
1377
+ *
1378
+ * @example
1379
+ * ```typescript
1380
+ * const sub = await ironflow.subscribeToProjection<OrderStats>('order-stats', {
1381
+ * onUpdate: (state, event) => console.log('Updated:', state),
1382
+ * onError: (error) => console.error(error),
1383
+ * });
1384
+ *
1385
+ * // With partition
1386
+ * const sub = await ironflow.subscribeToProjection('order-stats', {
1387
+ * onUpdate: (state, event) => console.log('Updated:', state),
1388
+ * }, { partition: 'customer-123' });
1389
+ *
1390
+ * // Cleanup
1391
+ * sub.unsubscribe();
1392
+ * ```
1393
+ */
1394
+ async subscribeToProjection(name, callbacks, options) {
1395
+ this.ensureConfigured();
1396
+ // Build the subscription pattern
1397
+ let pattern;
1398
+ if (options?.partition) {
1399
+ pattern = `system.projection.${name}.${options.partition}.updated`;
1400
+ }
1401
+ else {
1402
+ pattern = `system.projection.${name}.>`;
1403
+ }
1404
+ const sub = await this.subscribe(pattern, {
1405
+ onEvent: (event) => {
1406
+ const payload = event.data;
1407
+ const state = payload.state ?? {};
1408
+ callbacks.onUpdate(state, {
1409
+ id: payload.last_event_id ?? "",
1410
+ name: payload.last_event_name ?? "",
1411
+ });
1412
+ },
1413
+ onError: callbacks.onError
1414
+ ? (info) => callbacks.onError(new Error(info.message))
1415
+ : undefined,
1416
+ replay: options?.replay,
1417
+ });
1418
+ return sub;
1419
+ }
1420
+ // ============================================================================
1421
+ // KV Store
1422
+ // ============================================================================
1423
+ /**
1424
+ * KV store operations
1425
+ *
1426
+ * @example
1427
+ * ```typescript
1428
+ * const kv = ironflow.kv();
1429
+ * const bucket = await kv.createBucket({ name: "sessions", ttlSeconds: 3600 });
1430
+ * const handle = kv.bucket("sessions");
1431
+ * const { revision } = await handle.put("user.123", { token: "abc" });
1432
+ * const entry = await handle.get("user.123");
1433
+ *
1434
+ * // Watch for changes
1435
+ * const watcher = handle.watch({
1436
+ * onUpdate: (event) => console.log(event),
1437
+ * }, { key: "user.*" });
1438
+ * ```
1439
+ */
1440
+ kv() {
1441
+ this.ensureConfigured();
1442
+ return new BrowserKVClient(this.config);
1443
+ }
1444
+ // ============================================================================
1445
+ // Config Management
1446
+ // ============================================================================
1447
+ /**
1448
+ * Config management operations
1449
+ *
1450
+ * @example
1451
+ * ```typescript
1452
+ * const cfg = ironflow.configManager();
1453
+ * await cfg.set("app", { featureX: true, maxRetries: 3 });
1454
+ * const { data, revision } = await cfg.get("app");
1455
+ * await cfg.patch("app", { maxRetries: 5 });
1456
+ * const configs = await cfg.list();
1457
+ * await cfg.delete("app");
1458
+ *
1459
+ * // Watch for changes
1460
+ * const sub = await cfg.watch("app", {
1461
+ * onEvent: (config) => console.log(config),
1462
+ * });
1463
+ * sub.unsubscribe();
1464
+ * ```
1465
+ */
1466
+ configManager() {
1467
+ this.ensureConfigured();
1468
+ return new BrowserConfigClient(this.config, (pattern, callbacks) => this.subscribe(pattern, callbacks));
1469
+ }
1470
+ // ============================================================================
1471
+ // Auth Management
1472
+ // ============================================================================
1473
+ /**
1474
+ * API key management operations
1475
+ *
1476
+ * @example
1477
+ * ```typescript
1478
+ * // Create an API key
1479
+ * const key = await ironflow.apiKeys.create({ name: "my-key" });
1480
+ * console.log(key.key); // Only shown once
1481
+ *
1482
+ * // List API keys
1483
+ * const keys = await ironflow.apiKeys.list();
1484
+ *
1485
+ * // Delete an API key
1486
+ * await ironflow.apiKeys.delete(key.id);
1487
+ * ```
1488
+ */
1489
+ apiKeys = {
1490
+ /**
1491
+ * Create a new API key
1492
+ */
1493
+ create: async (input) => {
1494
+ this.ensureConfigured();
1495
+ return this.restRequest("POST", "/api/v1/apikeys", input);
1496
+ },
1497
+ /**
1498
+ * List all API keys
1499
+ */
1500
+ list: async () => {
1501
+ this.ensureConfigured();
1502
+ return this.restRequest("GET", "/api/v1/apikeys");
1503
+ },
1504
+ /**
1505
+ * Get an API key by ID
1506
+ */
1507
+ get: async (id) => {
1508
+ this.ensureConfigured();
1509
+ return this.restRequest("GET", `/api/v1/apikeys/${encodeURIComponent(id)}`);
1510
+ },
1511
+ /**
1512
+ * Delete an API key
1513
+ */
1514
+ delete: async (id) => {
1515
+ this.ensureConfigured();
1516
+ await this.restRequest("DELETE", `/api/v1/apikeys/${encodeURIComponent(id)}`);
1517
+ },
1518
+ /**
1519
+ * Rotate an API key, returning a new secret
1520
+ */
1521
+ rotate: async (id) => {
1522
+ this.ensureConfigured();
1523
+ return this.restRequest("POST", `/api/v1/apikeys/${encodeURIComponent(id)}/rotate`);
1524
+ },
1525
+ };
1526
+ /**
1527
+ * Organization management operations (enterprise-only)
1528
+ *
1529
+ * @example
1530
+ * ```typescript
1531
+ * // Create an organization
1532
+ * const org = await ironflow.orgs.create({ name: "Acme Corp" });
1533
+ *
1534
+ * // List organizations
1535
+ * const orgs = await ironflow.orgs.list();
1536
+ *
1537
+ * // Update an organization
1538
+ * await ironflow.orgs.update(org.id, { name: "Acme Inc" });
1539
+ * ```
1540
+ */
1541
+ orgs = {
1542
+ /**
1543
+ * Create a new organization
1544
+ */
1545
+ create: async (input) => {
1546
+ this.ensureConfigured();
1547
+ return this.restRequest("POST", "/api/v1/orgs", input);
1548
+ },
1549
+ /**
1550
+ * List all organizations
1551
+ */
1552
+ list: async () => {
1553
+ this.ensureConfigured();
1554
+ return this.restRequest("GET", "/api/v1/orgs");
1555
+ },
1556
+ /**
1557
+ * Get an organization by ID
1558
+ */
1559
+ get: async (id) => {
1560
+ this.ensureConfigured();
1561
+ return this.restRequest("GET", `/api/v1/orgs/${encodeURIComponent(id)}`);
1562
+ },
1563
+ /**
1564
+ * Update an organization
1565
+ */
1566
+ update: async (id, input) => {
1567
+ this.ensureConfigured();
1568
+ return this.restRequest("PATCH", `/api/v1/orgs/${encodeURIComponent(id)}`, input);
1569
+ },
1570
+ /**
1571
+ * Delete an organization
1572
+ */
1573
+ delete: async (id) => {
1574
+ this.ensureConfigured();
1575
+ await this.restRequest("DELETE", `/api/v1/orgs/${encodeURIComponent(id)}`);
1576
+ },
1577
+ };
1578
+ /**
1579
+ * Role management operations (enterprise-only)
1580
+ *
1581
+ * @example
1582
+ * ```typescript
1583
+ * // Create a role
1584
+ * const role = await ironflow.roles.create({ name: "editor", org_id: "org-1" });
1585
+ *
1586
+ * // Assign a policy to the role
1587
+ * await ironflow.roles.assignPolicy(role.id, policyId);
1588
+ *
1589
+ * // List roles
1590
+ * const roles = await ironflow.roles.list();
1591
+ * ```
1592
+ */
1593
+ roles = {
1594
+ /**
1595
+ * Create a new role
1596
+ */
1597
+ create: async (input) => {
1598
+ this.ensureConfigured();
1599
+ return this.restRequest("POST", "/api/v1/roles", input);
1600
+ },
1601
+ /**
1602
+ * List all roles
1603
+ */
1604
+ list: async (orgId) => {
1605
+ this.ensureConfigured();
1606
+ const query = orgId ? `?org_id=${encodeURIComponent(orgId)}` : "";
1607
+ return this.restRequest("GET", `/api/v1/roles${query}`);
1608
+ },
1609
+ /**
1610
+ * Get a role by ID
1611
+ */
1612
+ get: async (id) => {
1613
+ this.ensureConfigured();
1614
+ return this.restRequest("GET", `/api/v1/roles/${encodeURIComponent(id)}`);
1615
+ },
1616
+ /**
1617
+ * Update a role
1618
+ */
1619
+ update: async (id, input) => {
1620
+ this.ensureConfigured();
1621
+ return this.restRequest("PATCH", `/api/v1/roles/${encodeURIComponent(id)}`, input);
1622
+ },
1623
+ /**
1624
+ * Delete a role
1625
+ */
1626
+ delete: async (id) => {
1627
+ this.ensureConfigured();
1628
+ await this.restRequest("DELETE", `/api/v1/roles/${encodeURIComponent(id)}`);
1629
+ },
1630
+ /**
1631
+ * Assign a policy to a role
1632
+ */
1633
+ assignPolicy: async (roleId, policyId) => {
1634
+ this.ensureConfigured();
1635
+ await this.restRequest("POST", `/api/v1/roles/${encodeURIComponent(roleId)}/policies`, { policy_id: policyId });
1636
+ },
1637
+ /**
1638
+ * Remove a policy from a role
1639
+ */
1640
+ removePolicy: async (roleId, policyId) => {
1641
+ this.ensureConfigured();
1642
+ await this.restRequest("DELETE", `/api/v1/roles/${encodeURIComponent(roleId)}/policies/${encodeURIComponent(policyId)}`);
1643
+ },
1644
+ };
1645
+ /**
1646
+ * Policy management operations (enterprise-only)
1647
+ *
1648
+ * @example
1649
+ * ```typescript
1650
+ * // Create a policy
1651
+ * const policy = await ironflow.policies.create({
1652
+ * name: "allow-read",
1653
+ * effect: "allow",
1654
+ * actions: "read",
1655
+ * resources: "*",
1656
+ * org_id: "org-1",
1657
+ * });
1658
+ *
1659
+ * // List policies
1660
+ * const policies = await ironflow.policies.list();
1661
+ * ```
1662
+ */
1663
+ policies = {
1664
+ /**
1665
+ * Create a new policy
1666
+ */
1667
+ create: async (input) => {
1668
+ this.ensureConfigured();
1669
+ return this.restRequest("POST", "/api/v1/policies", input);
1670
+ },
1671
+ /**
1672
+ * List all policies
1673
+ */
1674
+ list: async (orgId) => {
1675
+ this.ensureConfigured();
1676
+ const query = orgId ? `?org_id=${encodeURIComponent(orgId)}` : "";
1677
+ return this.restRequest("GET", `/api/v1/policies${query}`);
1678
+ },
1679
+ /**
1680
+ * Get a policy by ID
1681
+ */
1682
+ get: async (id) => {
1683
+ this.ensureConfigured();
1684
+ return this.restRequest("GET", `/api/v1/policies/${encodeURIComponent(id)}`);
1685
+ },
1686
+ /**
1687
+ * Update a policy
1688
+ */
1689
+ update: async (id, input) => {
1690
+ this.ensureConfigured();
1691
+ return this.restRequest("PATCH", `/api/v1/policies/${encodeURIComponent(id)}`, input);
1692
+ },
1693
+ /**
1694
+ * Delete a policy
1695
+ */
1696
+ delete: async (id) => {
1697
+ this.ensureConfigured();
1698
+ await this.restRequest("DELETE", `/api/v1/policies/${encodeURIComponent(id)}`);
1699
+ },
1700
+ };
1701
+ /**
1702
+ * Event schema registry operations
1703
+ *
1704
+ * @example
1705
+ * ```typescript
1706
+ * // Register a schema
1707
+ * const schema = await ironflow.schemas.register({
1708
+ * name: "order.placed",
1709
+ * version: 1,
1710
+ * schema: { type: "object", properties: { orderId: { type: "string" } } },
1711
+ * });
1712
+ *
1713
+ * // List all schemas
1714
+ * const schemas = await ironflow.schemas.list();
1715
+ *
1716
+ * // Get latest version of a schema
1717
+ * const latest = await ironflow.schemas.get("order.placed");
1718
+ *
1719
+ * // Test an upcast transformation
1720
+ * const result = await ironflow.schemas.testUpcast({
1721
+ * eventName: "order.placed",
1722
+ * fromVersion: 1,
1723
+ * toVersion: 2,
1724
+ * data: { orderId: "123" },
1725
+ * });
1726
+ * ```
1727
+ */
1728
+ schemas = {
1729
+ /** Register a new event schema (or a new version of an existing schema) */
1730
+ register: async (input) => {
1731
+ this.ensureConfigured();
1732
+ return this.restRequest("POST", "/api/v1/events/schemas", {
1733
+ event_name: input.name,
1734
+ version: input.version,
1735
+ schema_json: JSON.stringify(input.schema),
1736
+ });
1737
+ },
1738
+ /** List all registered event schemas */
1739
+ list: async () => {
1740
+ this.ensureConfigured();
1741
+ const resp = await this.restRequest("GET", "/api/v1/events/schemas");
1742
+ return resp.schemas ?? [];
1743
+ },
1744
+ /** Get the latest version of an event schema by name */
1745
+ get: async (name) => {
1746
+ this.ensureConfigured();
1747
+ return this.restRequest("GET", `/api/v1/events/schemas/${encodeURIComponent(name)}`);
1748
+ },
1749
+ /** Get a specific version of an event schema */
1750
+ getVersion: async (name, version) => {
1751
+ this.ensureConfigured();
1752
+ return this.restRequest("GET", `/api/v1/events/schemas/${encodeURIComponent(name)}/${version}`);
1753
+ },
1754
+ /** Delete a specific version of an event schema */
1755
+ delete: async (name, version) => {
1756
+ this.ensureConfigured();
1757
+ await this.restRequest("DELETE", `/api/v1/events/schemas/${encodeURIComponent(name)}/${version}`);
1758
+ },
1759
+ /** Test an upcast transformation between two schema versions */
1760
+ testUpcast: async (input) => {
1761
+ this.ensureConfigured();
1762
+ return this.restRequest("POST", "/api/v1/events/upcast", input);
1763
+ },
1764
+ };
1765
+ // ============================================================================
1766
+ // Audit Trail
1767
+ // ============================================================================
1768
+ /**
1769
+ * Get the audit trail for a run.
1770
+ *
1771
+ * @param runId The run ID to retrieve the audit trail for
1772
+ * @param options Optional filtering options
1773
+ *
1774
+ * @example
1775
+ * ```typescript
1776
+ * const result = await ironflow.getAuditTrail("run-abc123");
1777
+ * for (const event of result.events) {
1778
+ * console.log(event.eventType, event.createdAt);
1779
+ * }
1780
+ * ```
1781
+ */
1782
+ async getAuditTrail(runId, options) {
1783
+ this.ensureConfigured();
1784
+ const response = await this.streamRequest("/ironflow.v1.AuditService/GetAuditTrail", {
1785
+ run_id: runId,
1786
+ event_type: options?.eventType ?? "",
1787
+ from_timestamp: options?.fromTimestamp ?? "",
1788
+ to_timestamp: options?.toTimestamp ?? "",
1789
+ limit: options?.limit ?? 0,
1790
+ cursor: options?.cursor ?? "",
1791
+ });
1792
+ return {
1793
+ events: (response.events ?? []).map((e) => ({
1794
+ id: e.id,
1795
+ runId: e.run_id,
1796
+ functionId: e.function_id,
1797
+ stepId: e.step_id,
1798
+ eventType: e.event_type,
1799
+ payload: e.payload ?? {},
1800
+ metadata: e.metadata,
1801
+ createdAt: e.created_at,
1802
+ })),
1803
+ totalCount: response.total_count ?? 0,
1804
+ nextCursor: response.next_cursor,
1805
+ };
1806
+ }
1807
+ // ============================================================================
1808
+ // Webhook Management
1809
+ // ============================================================================
1810
+ /**
1811
+ * Webhook management operations
1812
+ *
1813
+ * @example
1814
+ * ```typescript
1815
+ * // List all webhook sources
1816
+ * const sources = await ironflow.webhooks.listSources();
1817
+ *
1818
+ * // Delete a webhook source
1819
+ * await ironflow.webhooks.deleteSource("my-webhook");
1820
+ *
1821
+ * // List deliveries
1822
+ * const { deliveries } = await ironflow.webhooks.listDeliveries({ sourceId: "my-webhook" });
1823
+ * ```
1824
+ */
1825
+ webhooks = {
1826
+ /** Create a new webhook source */
1827
+ create: async (input) => {
1828
+ this.ensureConfigured();
1829
+ const response = await this.streamRequest("/ironflow.v1.WebhookService/CreateWebhookSource", {
1830
+ id: input.id,
1831
+ event_prefix: input.eventPrefix,
1832
+ verify_header: input.verifyHeader ?? "",
1833
+ verify_algorithm: input.verifyAlgorithm ?? "",
1834
+ verify_secret: input.verifySecret ?? "",
1835
+ metadata: input.metadata,
1836
+ });
1837
+ return {
1838
+ id: response.id,
1839
+ eventPrefix: response.event_prefix,
1840
+ verifyHeader: response.verify_header,
1841
+ verifyAlgorithm: response.verify_algorithm,
1842
+ sourceType: response.source_type,
1843
+ metadata: response.metadata,
1844
+ createdAt: response.created_at,
1845
+ updatedAt: response.updated_at,
1846
+ };
1847
+ },
1848
+ /** List all registered webhook sources */
1849
+ listSources: async () => {
1850
+ this.ensureConfigured();
1851
+ const response = await this.streamRequest("/ironflow.v1.WebhookService/ListWebhookSources", { limit: 0, offset: 0 });
1852
+ return (response.sources ?? []).map((s) => ({
1853
+ id: s.id,
1854
+ eventPrefix: s.event_prefix,
1855
+ verifyHeader: s.verify_header,
1856
+ verifyAlgorithm: s.verify_algorithm,
1857
+ sourceType: s.source_type,
1858
+ metadata: s.metadata,
1859
+ createdAt: s.created_at,
1860
+ updatedAt: s.updated_at,
1861
+ }));
1862
+ },
1863
+ /** Delete a webhook source by ID */
1864
+ deleteSource: async (id) => {
1865
+ this.ensureConfigured();
1866
+ await this.streamRequest("/ironflow.v1.WebhookService/DeleteWebhookSource", { id });
1867
+ },
1868
+ /** List webhook deliveries with optional filtering */
1869
+ listDeliveries: async (opts) => {
1870
+ this.ensureConfigured();
1871
+ const response = await this.streamRequest("/ironflow.v1.WebhookService/ListWebhookDeliveries", {
1872
+ source_id: opts?.sourceId ?? "",
1873
+ status: opts?.status ?? "",
1874
+ limit: opts?.limit ?? 0,
1875
+ offset: opts?.offset ?? 0,
1876
+ });
1877
+ return {
1878
+ deliveries: (response.deliveries ?? []).map((d) => ({
1879
+ id: d.id,
1880
+ sourceId: d.source_id,
1881
+ externalId: d.external_id,
1882
+ status: d.status,
1883
+ eventId: d.event_id,
1884
+ error: d.error,
1885
+ createdAt: d.created_at,
1886
+ })),
1887
+ totalCount: response.total_count ?? 0,
1888
+ };
1889
+ },
1890
+ };
1891
+ // ============================================================================
1892
+ // User Management
1893
+ // ============================================================================
1894
+ /**
1895
+ * User management operations
1896
+ *
1897
+ * @example
1898
+ * ```typescript
1899
+ * // List all users
1900
+ * const users = await ironflow.users.list();
1901
+ *
1902
+ * // Create a user
1903
+ * const user = await ironflow.users.create({
1904
+ * email: "alice@example.com",
1905
+ * password: "secret",
1906
+ * roles: ["admin"],
1907
+ * });
1908
+ *
1909
+ * // Update a user
1910
+ * await ironflow.users.update(user.id, { name: "Alice" });
1911
+ * ```
1912
+ */
1913
+ users = {
1914
+ /** Create a new user (admin only) */
1915
+ create: async (input) => {
1916
+ this.ensureConfigured();
1917
+ return this.restRequest("POST", "/api/v1/users", input);
1918
+ },
1919
+ /** List all users in the current organization (admin only) */
1920
+ list: async () => {
1921
+ this.ensureConfigured();
1922
+ return this.restRequest("GET", "/api/v1/users");
1923
+ },
1924
+ /** Get a user by ID */
1925
+ get: async (id) => {
1926
+ this.ensureConfigured();
1927
+ return this.restRequest("GET", `/api/v1/users/${encodeURIComponent(id)}`);
1928
+ },
1929
+ /** Update a user's profile (admin only) */
1930
+ update: async (id, input) => {
1931
+ this.ensureConfigured();
1932
+ return this.restRequest("PATCH", `/api/v1/users/${encodeURIComponent(id)}`, input);
1933
+ },
1934
+ /** Delete a user (admin only) */
1935
+ delete: async (id) => {
1936
+ this.ensureConfigured();
1937
+ await this.restRequest("DELETE", `/api/v1/users/${encodeURIComponent(id)}`);
1938
+ },
1939
+ };
1940
+ /**
1941
+ * Tenant management operations (enterprise-only)
1942
+ *
1943
+ * @example
1944
+ * ```typescript
1945
+ * // List all tenants
1946
+ * const tenants = await ironflow.tenants.list();
1947
+ * ```
1948
+ */
1949
+ tenants = {
1950
+ /** List all tenants (enterprise-only) */
1951
+ list: async () => {
1952
+ this.ensureConfigured();
1953
+ return this.restRequest("GET", "/api/v1/tenants");
1954
+ },
1955
+ };
1956
+ // ============================================================================
1957
+ // Pattern Helpers (static)
1958
+ // ============================================================================
1959
+ /**
1960
+ * Pattern helpers for building subscription patterns
1961
+ */
1962
+ static patterns = patterns;
1963
+ // ============================================================================
1964
+ // Internal Methods
1965
+ // ============================================================================
1966
+ ensureConfigured() {
1967
+ if (!this.config) {
1968
+ throw new NotConfiguredError();
1969
+ }
1970
+ }
1971
+ cleanup() {
1972
+ if (this.visibilityHandler) {
1973
+ document.removeEventListener("visibilitychange", this.visibilityHandler);
1974
+ this.visibilityHandler = null;
1975
+ }
1976
+ if (this.subscriptionManager) {
1977
+ this.subscriptionManager.disconnect();
1978
+ this.subscriptionManager = null;
1979
+ }
1980
+ this.transport = null;
1981
+ }
1982
+ /**
1983
+ * Reset client state for testing. Not intended for production use.
1984
+ * @internal
1985
+ */
1986
+ _resetForTesting() {
1987
+ this.cleanup();
1988
+ this.config = null;
1989
+ this.logger = createNoopLogger();
1990
+ }
1991
+ setupVisibilityHandling() {
1992
+ this.visibilityHandler = () => {
1993
+ if (document.hidden) {
1994
+ this.logger.debug("Tab hidden, pausing subscriptions");
1995
+ this.subscriptionManager?.pause();
1996
+ }
1997
+ else {
1998
+ if (this.config?.visibility.reconnectOnVisible) {
1999
+ this.logger.debug("Tab visible, resuming subscriptions");
2000
+ this.subscriptionManager?.resume();
2001
+ }
2002
+ }
2003
+ };
2004
+ document.addEventListener("visibilitychange", this.visibilityHandler);
2005
+ }
2006
+ async request(schema, method, path, body) {
2007
+ const url = `${this.config.serverUrl}${path}`;
2008
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
2009
+ const controller = new AbortController();
2010
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
2011
+ try {
2012
+ const headers = {
2013
+ "Content-Type": "application/json",
2014
+ [HEADERS.ENVIRONMENT]: this.config.environment,
2015
+ };
2016
+ if (this.config.auth?.apiKey) {
2017
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
2018
+ }
2019
+ else if (this.config.auth?.token) {
2020
+ headers["Authorization"] = `Bearer ${this.config.auth.token}`;
2021
+ }
2022
+ const response = await fetch(url, {
2023
+ method,
2024
+ headers,
2025
+ body: JSON.stringify(body),
2026
+ signal: controller.signal,
2027
+ });
2028
+ const responseBody = await response.text();
2029
+ if (!response.ok) {
2030
+ const errorResult = ErrorResponseSchema.safeParse(safeJsonParse(responseBody));
2031
+ const errorData = errorResult.success
2032
+ ? errorResult.data
2033
+ : { message: responseBody };
2034
+ throw new IronflowError(errorData.message ?? `Request failed: ${response.status}`, {
2035
+ code: errorData.code ?? `HTTP_${response.status}`,
2036
+ retryable: response.status >= 500,
2037
+ });
2038
+ }
2039
+ const parsed = safeJsonParse(responseBody);
2040
+ if (parsed === undefined) {
2041
+ throw new ValidationError("Invalid JSON response from server");
2042
+ }
2043
+ const result = schema.safeParse(parsed);
2044
+ if (!result.success) {
2045
+ const issues = result.error.issues
2046
+ .map((i) => `${i.path.join(".")}: ${i.message}`)
2047
+ .join(", ");
2048
+ throw new ValidationError(`Invalid response from server: ${issues}`);
2049
+ }
2050
+ return result.data;
2051
+ }
2052
+ catch (error) {
2053
+ if (error instanceof IronflowError || error instanceof ValidationError) {
2054
+ throw error;
2055
+ }
2056
+ if (error instanceof Error && error.name === "AbortError") {
2057
+ throw new IronflowError(`Request timeout after ${timeout}ms for ${method} ${path}`, {
2058
+ code: "TIMEOUT",
2059
+ retryable: true,
2060
+ });
2061
+ }
2062
+ throw new IronflowError(error instanceof Error
2063
+ ? `${method} ${path} failed: ${error.message}`
2064
+ : `${method} ${path} failed`, {
2065
+ code: "REQUEST_FAILED",
2066
+ retryable: true,
2067
+ cause: error instanceof Error ? error : undefined,
2068
+ });
2069
+ }
2070
+ finally {
2071
+ clearTimeout(timeoutId);
2072
+ }
2073
+ }
2074
+ async streamRequest(path, body) {
2075
+ const url = `${this.config.serverUrl}${path}`;
2076
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
2077
+ const controller = new AbortController();
2078
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
2079
+ try {
2080
+ const headers = {
2081
+ "Content-Type": "application/json",
2082
+ [HEADERS.ENVIRONMENT]: this.config.environment,
2083
+ };
2084
+ if (this.config.auth?.apiKey) {
2085
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
2086
+ }
2087
+ else if (this.config.auth?.token) {
2088
+ headers["Authorization"] = `Bearer ${this.config.auth.token}`;
2089
+ }
2090
+ const response = await fetch(url, {
2091
+ method: "POST",
2092
+ headers,
2093
+ body: JSON.stringify(body),
2094
+ signal: controller.signal,
2095
+ });
2096
+ if (!response.ok) {
2097
+ const errorBody = safeJsonParse(await response.text());
2098
+ throw new IronflowError(errorBody?.message ?? `Request failed: ${response.status}`, {
2099
+ code: errorBody?.code ?? `HTTP_${response.status}`,
2100
+ retryable: response.status >= 500,
2101
+ });
2102
+ }
2103
+ return response.json();
2104
+ }
2105
+ catch (error) {
2106
+ if (error instanceof IronflowError) {
2107
+ throw error;
2108
+ }
2109
+ if (error instanceof Error && error.name === "AbortError") {
2110
+ throw new IronflowError(`Request timeout after ${timeout}ms for POST ${path}`, { code: "TIMEOUT", retryable: true });
2111
+ }
2112
+ throw new IronflowError(error instanceof Error
2113
+ ? `POST ${path} failed: ${error.message}`
2114
+ : `POST ${path} failed`, {
2115
+ code: "REQUEST_FAILED",
2116
+ retryable: true,
2117
+ cause: error instanceof Error ? error : undefined,
2118
+ });
2119
+ }
2120
+ finally {
2121
+ clearTimeout(timeoutId);
2122
+ }
2123
+ }
2124
+ /**
2125
+ * REST request helper for auth management endpoints.
2126
+ *
2127
+ * Supports GET, POST, PATCH, DELETE methods with typed error mapping:
2128
+ * - 401 → UnauthenticatedError
2129
+ * - 402 → EnterpriseRequiredError
2130
+ * - 403 → UnauthorizedError
2131
+ */
2132
+ async restRequest(method, path, body) {
2133
+ const url = `${this.config.serverUrl}${path}`;
2134
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
2135
+ const controller = new AbortController();
2136
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
2137
+ try {
2138
+ const headers = {
2139
+ [HEADERS.ENVIRONMENT]: this.config.environment,
2140
+ };
2141
+ if (body !== undefined) {
2142
+ headers["Content-Type"] = "application/json";
2143
+ }
2144
+ if (this.config.auth?.apiKey) {
2145
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
2146
+ }
2147
+ else if (this.config.auth?.token) {
2148
+ headers["Authorization"] = `Bearer ${this.config.auth.token}`;
2149
+ }
2150
+ const fetchOptions = {
2151
+ method,
2152
+ headers,
2153
+ signal: controller.signal,
2154
+ };
2155
+ if (body !== undefined) {
2156
+ fetchOptions.body = JSON.stringify(body);
2157
+ }
2158
+ const response = await fetch(url, fetchOptions);
2159
+ // Handle 204 No Content
2160
+ if (response.status === 204) {
2161
+ return undefined;
2162
+ }
2163
+ if (!response.ok) {
2164
+ const errorText = await response.text();
2165
+ let errorMessage = `Request failed with status ${response.status}`;
2166
+ if (errorText) {
2167
+ const errorJson = safeJsonParse(errorText);
2168
+ if (errorJson?.message) {
2169
+ errorMessage = errorJson.message;
2170
+ }
2171
+ else if (errorText) {
2172
+ errorMessage = errorText;
2173
+ }
2174
+ }
2175
+ switch (response.status) {
2176
+ case 401:
2177
+ throw new UnauthenticatedError(errorMessage);
2178
+ case 402:
2179
+ throw new EnterpriseRequiredError(errorMessage);
2180
+ case 403:
2181
+ throw new UnauthorizedError(errorMessage);
2182
+ default:
2183
+ throw new IronflowError(errorMessage, {
2184
+ code: `HTTP_${response.status}`,
2185
+ retryable: response.status >= 500,
2186
+ });
2187
+ }
2188
+ }
2189
+ return response.json();
2190
+ }
2191
+ catch (error) {
2192
+ if (error instanceof IronflowError ||
2193
+ error instanceof UnauthenticatedError ||
2194
+ error instanceof EnterpriseRequiredError ||
2195
+ error instanceof UnauthorizedError) {
2196
+ throw error;
2197
+ }
2198
+ if (error instanceof Error && error.name === "AbortError") {
2199
+ throw new IronflowError(`Request timeout after ${timeout}ms for ${method} ${path}`, { code: "TIMEOUT", retryable: true });
2200
+ }
2201
+ throw new IronflowError(error instanceof Error
2202
+ ? `${method} ${path} failed: ${error.message}`
2203
+ : `${method} ${path} failed`, {
2204
+ code: "REQUEST_FAILED",
2205
+ retryable: true,
2206
+ cause: error instanceof Error ? error : undefined,
2207
+ });
2208
+ }
2209
+ finally {
2210
+ clearTimeout(timeoutId);
2211
+ }
2212
+ }
2213
+ mapRunResponse(response) {
2214
+ // ConnectRPC returns proto enum strings like "RUN_STATUS_COMPLETED" — normalize to "completed"
2215
+ const rawStatus = response.status.toLowerCase().replace(/^run_status_/, "");
2216
+ const statusResult = RunStatusSchema.safeParse(rawStatus);
2217
+ const status = statusResult.success ? statusResult.data : "failed";
2218
+ return {
2219
+ id: response.id,
2220
+ functionId: response.functionId,
2221
+ eventId: response.eventId,
2222
+ status,
2223
+ attempt: response.attempt,
2224
+ maxAttempts: response.maxAttempts,
2225
+ input: response.input,
2226
+ output: response.output,
2227
+ error: response.error,
2228
+ startedAt: response.startedAt ? new Date(response.startedAt) : undefined,
2229
+ endedAt: response.endedAt ? new Date(response.endedAt) : undefined,
2230
+ createdAt: new Date(response.createdAt),
2231
+ updatedAt: new Date(response.updatedAt),
2232
+ };
2233
+ }
2234
+ }
2235
+ /**
2236
+ * Singleton instance
2237
+ */
2238
+ export const ironflow = new IronflowClient();
2239
+ /**
2240
+ * Export the class for advanced usage
2241
+ */
2242
+ export { IronflowClient };
2243
+ //# sourceMappingURL=client.js.map