@ironflow/browser 0.1.0-test.2 → 0.2.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.
- package/README.md +3 -3
- package/dist/client.d.ts +202 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +671 -7
- package/dist/client.js.map +1 -1
- package/dist/config-client.d.ts +43 -0
- package/dist/config-client.d.ts.map +1 -0
- package/dist/config-client.js +130 -0
- package/dist/config-client.js.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +3 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -4
- package/dist/index.js.map +1 -1
- package/dist/kv.d.ts +79 -0
- package/dist/kv.d.ts.map +1 -0
- package/dist/kv.js +289 -0
- package/dist/kv.js.map +1 -0
- package/dist/subscription.d.ts +2 -0
- package/dist/subscription.d.ts.map +1 -1
- package/dist/subscription.js +57 -18
- package/dist/subscription.js.map +1 -1
- package/dist/transport/connectrpc.d.ts.map +1 -1
- package/dist/transport/connectrpc.js +28 -7
- package/dist/transport/connectrpc.js.map +1 -1
- package/dist/transport/index.d.ts +2 -2
- package/dist/transport/index.d.ts.map +1 -1
- package/dist/transport/index.js +2 -2
- package/dist/transport/index.js.map +1 -1
- package/dist/transport/types.d.ts +4 -0
- package/dist/transport/types.d.ts.map +1 -1
- package/dist/transport/websocket.d.ts.map +1 -1
- package/dist/transport/websocket.js +38 -2
- package/dist/transport/websocket.js.map +1 -1
- package/package.json +10 -6
package/dist/client.js
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Singleton client for browser-based real-time interactions with Ironflow.
|
|
5
5
|
*/
|
|
6
|
-
import { NotConfiguredError, IronflowError, ValidationError, createLogger, createNoopLogger, DEFAULT_TIMEOUTS, TriggerResponseSchema, RunResponseSchema, ListRunsResponseSchema, RunStatusSchema, ErrorResponseSchema, safeJsonParse, patterns, } from "@ironflow/core";
|
|
6
|
+
import { NotConfiguredError, IronflowError, ValidationError, createLogger, createNoopLogger, DEFAULT_TIMEOUTS, HEADERS, TriggerResponseSchema, RunResponseSchema, ListRunsResponseSchema, RunStatusSchema, ErrorResponseSchema, safeJsonParse, patterns, } from "@ironflow/core";
|
|
7
7
|
import { mergeConfig } from "./config.js";
|
|
8
8
|
import { SubscriptionManager, } from "./subscription.js";
|
|
9
9
|
import { createWebSocketTransport } from "./transport/websocket.js";
|
|
10
10
|
import { createConnectRPCTransport } from "./transport/connectrpc.js";
|
|
11
|
+
import { BrowserKVClient } from "./kv.js";
|
|
12
|
+
import { BrowserConfigClient } from "./config-client.js";
|
|
11
13
|
/**
|
|
12
14
|
* Ironflow browser client singleton
|
|
13
15
|
*/
|
|
@@ -43,6 +45,7 @@ class IronflowClient {
|
|
|
43
45
|
reconnectDelay: this.config.reconnect.backoff.initial,
|
|
44
46
|
maxReconnectDelay: this.config.reconnect.backoff.max,
|
|
45
47
|
reconnectBackoff: this.config.reconnect.backoff.multiplier,
|
|
48
|
+
environment: this.config.environment,
|
|
46
49
|
};
|
|
47
50
|
// Create transport based on config (ConnectRPC by default)
|
|
48
51
|
if (this.config.transport === "websocket") {
|
|
@@ -85,11 +88,15 @@ class IronflowClient {
|
|
|
85
88
|
const serverUrl = this.config?.serverUrl ?? "http://localhost:9123";
|
|
86
89
|
try {
|
|
87
90
|
// Try to get server capabilities via ConnectRPC endpoint
|
|
91
|
+
const detectHeaders = {
|
|
92
|
+
"Content-Type": "application/json",
|
|
93
|
+
};
|
|
94
|
+
if (this.config?.environment) {
|
|
95
|
+
detectHeaders[HEADERS.ENVIRONMENT] = this.config.environment;
|
|
96
|
+
}
|
|
88
97
|
const response = await fetch(`${serverUrl}/ironflow.v1.IronflowService/GetCapabilities`, {
|
|
89
98
|
method: "POST",
|
|
90
|
-
headers:
|
|
91
|
-
"Content-Type": "application/json",
|
|
92
|
-
},
|
|
99
|
+
headers: detectHeaders,
|
|
93
100
|
body: "{}",
|
|
94
101
|
});
|
|
95
102
|
if (response.ok) {
|
|
@@ -263,6 +270,187 @@ class IronflowClient {
|
|
|
263
270
|
const response = await this.request(RunResponseSchema, "POST", "/ironflow.v1.IronflowService/CancelRun", { id: runId, reason });
|
|
264
271
|
return this.mapRunResponse(response);
|
|
265
272
|
}
|
|
273
|
+
/**
|
|
274
|
+
* Retry a failed run
|
|
275
|
+
*/
|
|
276
|
+
async retryRun(runId, fromStep) {
|
|
277
|
+
this.ensureConfigured();
|
|
278
|
+
const response = await this.request(RunResponseSchema, "POST", "/ironflow.v1.IronflowService/RetryRun", { id: runId, fromStep });
|
|
279
|
+
return this.mapRunResponse(response);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Patch a step's output (hot patching)
|
|
283
|
+
*/
|
|
284
|
+
async patchStep(stepId, output, reason) {
|
|
285
|
+
this.ensureConfigured();
|
|
286
|
+
const url = `${this.config.serverUrl}/api/v1/steps/patch`;
|
|
287
|
+
const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
|
|
288
|
+
const controller = new AbortController();
|
|
289
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
290
|
+
try {
|
|
291
|
+
const headers = {
|
|
292
|
+
"Content-Type": "application/json",
|
|
293
|
+
[HEADERS.ENVIRONMENT]: this.config.environment,
|
|
294
|
+
};
|
|
295
|
+
if (this.config.auth?.apiKey) {
|
|
296
|
+
headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
|
|
297
|
+
}
|
|
298
|
+
const response = await fetch(url, {
|
|
299
|
+
method: "POST",
|
|
300
|
+
headers,
|
|
301
|
+
body: JSON.stringify({ step_id: stepId, output, reason: reason || "" }),
|
|
302
|
+
signal: controller.signal,
|
|
303
|
+
});
|
|
304
|
+
if (!response.ok) {
|
|
305
|
+
const error = safeJsonParse(await response.text());
|
|
306
|
+
throw new IronflowError(error?.message || `Patch step failed: ${response.status}`, { code: error?.code || "PATCH_FAILED" });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
clearTimeout(timeoutId);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Resume a paused or failed run
|
|
315
|
+
*/
|
|
316
|
+
async resumeRun(runId, fromStep) {
|
|
317
|
+
this.ensureConfigured();
|
|
318
|
+
const url = `${this.config.serverUrl}/api/v1/runs/resume`;
|
|
319
|
+
const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
|
|
320
|
+
const controller = new AbortController();
|
|
321
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
322
|
+
try {
|
|
323
|
+
const headers = {
|
|
324
|
+
"Content-Type": "application/json",
|
|
325
|
+
[HEADERS.ENVIRONMENT]: this.config.environment,
|
|
326
|
+
};
|
|
327
|
+
if (this.config.auth?.apiKey) {
|
|
328
|
+
headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
|
|
329
|
+
}
|
|
330
|
+
const response = await fetch(url, {
|
|
331
|
+
method: "POST",
|
|
332
|
+
headers,
|
|
333
|
+
body: JSON.stringify({ run_id: runId, from_step: fromStep || "" }),
|
|
334
|
+
signal: controller.signal,
|
|
335
|
+
});
|
|
336
|
+
if (!response.ok) {
|
|
337
|
+
const error = safeJsonParse(await response.text());
|
|
338
|
+
throw new IronflowError(error?.message || `Resume run failed: ${response.status}`, { code: error?.code || "RESUME_FAILED" });
|
|
339
|
+
}
|
|
340
|
+
return response.json();
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
clearTimeout(timeoutId);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* List registered functions
|
|
348
|
+
*/
|
|
349
|
+
async listFunctions() {
|
|
350
|
+
this.ensureConfigured();
|
|
351
|
+
const url = `${this.config.serverUrl}/api/v1/functions`;
|
|
352
|
+
const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
|
|
353
|
+
const controller = new AbortController();
|
|
354
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
355
|
+
try {
|
|
356
|
+
const headers = {
|
|
357
|
+
[HEADERS.ENVIRONMENT]: this.config.environment,
|
|
358
|
+
};
|
|
359
|
+
if (this.config.auth?.apiKey) {
|
|
360
|
+
headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
|
|
361
|
+
}
|
|
362
|
+
const response = await fetch(url, {
|
|
363
|
+
method: "GET",
|
|
364
|
+
headers,
|
|
365
|
+
signal: controller.signal,
|
|
366
|
+
});
|
|
367
|
+
if (!response.ok) {
|
|
368
|
+
throw new IronflowError(`List functions failed: ${response.status}`, { code: "LIST_FUNCTIONS_FAILED" });
|
|
369
|
+
}
|
|
370
|
+
const data = await response.json();
|
|
371
|
+
return data.functions || [];
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
clearTimeout(timeoutId);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* List connected workers
|
|
379
|
+
*/
|
|
380
|
+
async listWorkers() {
|
|
381
|
+
this.ensureConfigured();
|
|
382
|
+
const url = `${this.config.serverUrl}/api/v1/workers`;
|
|
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
|
+
[HEADERS.ENVIRONMENT]: this.config.environment,
|
|
389
|
+
};
|
|
390
|
+
if (this.config.auth?.apiKey) {
|
|
391
|
+
headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
|
|
392
|
+
}
|
|
393
|
+
const response = await fetch(url, {
|
|
394
|
+
method: "GET",
|
|
395
|
+
headers,
|
|
396
|
+
signal: controller.signal,
|
|
397
|
+
});
|
|
398
|
+
if (!response.ok) {
|
|
399
|
+
throw new IronflowError(`List workers failed: ${response.status}`, { code: "LIST_WORKERS_FAILED" });
|
|
400
|
+
}
|
|
401
|
+
const data = await response.json();
|
|
402
|
+
return data.workers || [];
|
|
403
|
+
}
|
|
404
|
+
finally {
|
|
405
|
+
clearTimeout(timeoutId);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Health check
|
|
410
|
+
*/
|
|
411
|
+
async health() {
|
|
412
|
+
this.ensureConfigured();
|
|
413
|
+
const url = `${this.config.serverUrl}/health`;
|
|
414
|
+
const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
|
|
415
|
+
const controller = new AbortController();
|
|
416
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
417
|
+
try {
|
|
418
|
+
const response = await fetch(url, {
|
|
419
|
+
method: "GET",
|
|
420
|
+
signal: controller.signal,
|
|
421
|
+
});
|
|
422
|
+
if (!response.ok) {
|
|
423
|
+
throw new IronflowError(`Health check failed: ${response.status}`, { code: "HEALTH_FAILED" });
|
|
424
|
+
}
|
|
425
|
+
return response.json();
|
|
426
|
+
}
|
|
427
|
+
finally {
|
|
428
|
+
clearTimeout(timeoutId);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Get server capabilities
|
|
433
|
+
*/
|
|
434
|
+
async getCapabilities() {
|
|
435
|
+
this.ensureConfigured();
|
|
436
|
+
const url = `${this.config.serverUrl}/api/v1/capabilities`;
|
|
437
|
+
const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
|
|
438
|
+
const controller = new AbortController();
|
|
439
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
440
|
+
try {
|
|
441
|
+
const response = await fetch(url, {
|
|
442
|
+
method: "GET",
|
|
443
|
+
signal: controller.signal,
|
|
444
|
+
});
|
|
445
|
+
if (!response.ok) {
|
|
446
|
+
throw new IronflowError(`Get capabilities failed: ${response.status}`, { code: "CAPABILITIES_FAILED" });
|
|
447
|
+
}
|
|
448
|
+
return response.json();
|
|
449
|
+
}
|
|
450
|
+
finally {
|
|
451
|
+
clearTimeout(timeoutId);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
266
454
|
// ============================================================================
|
|
267
455
|
// Event Emission
|
|
268
456
|
// ============================================================================
|
|
@@ -282,6 +470,7 @@ class IronflowClient {
|
|
|
282
470
|
const response = await this.request(TriggerResponseSchema, "POST", "/ironflow.v1.PubSubService/Emit", {
|
|
283
471
|
event: eventName,
|
|
284
472
|
data,
|
|
473
|
+
...(options?.version ? { version: options.version } : {}),
|
|
285
474
|
idempotency_key: options?.idempotencyKey,
|
|
286
475
|
metadata: options?.metadata,
|
|
287
476
|
namespace: options?.namespace,
|
|
@@ -307,6 +496,417 @@ class IronflowClient {
|
|
|
307
496
|
return sub;
|
|
308
497
|
}
|
|
309
498
|
// ============================================================================
|
|
499
|
+
// Entity Streams
|
|
500
|
+
// ============================================================================
|
|
501
|
+
/**
|
|
502
|
+
* Entity stream operations
|
|
503
|
+
*
|
|
504
|
+
* @example
|
|
505
|
+
* ```typescript
|
|
506
|
+
* // Append an event to a stream
|
|
507
|
+
* const result = await ironflow.streams.append("order-123", {
|
|
508
|
+
* name: "order.created",
|
|
509
|
+
* data: { total: 100 },
|
|
510
|
+
* entityType: "order",
|
|
511
|
+
* });
|
|
512
|
+
*
|
|
513
|
+
* // Read events from a stream
|
|
514
|
+
* const { events } = await ironflow.streams.read("order-123", { limit: 10 });
|
|
515
|
+
*
|
|
516
|
+
* // Get stream info
|
|
517
|
+
* const info = await ironflow.streams.getInfo("order-123");
|
|
518
|
+
* ```
|
|
519
|
+
*/
|
|
520
|
+
streams = {
|
|
521
|
+
/**
|
|
522
|
+
* Append an event to an entity stream
|
|
523
|
+
*/
|
|
524
|
+
append: async (entityId, input, options) => {
|
|
525
|
+
this.ensureConfigured();
|
|
526
|
+
const response = await this.streamRequest("/ironflow.v1.EntityStreamService/AppendEvent", {
|
|
527
|
+
entity_id: entityId,
|
|
528
|
+
entity_type: input.entityType,
|
|
529
|
+
event_name: input.name,
|
|
530
|
+
data: input.data,
|
|
531
|
+
expected_version: options?.expectedVersion ?? -1,
|
|
532
|
+
idempotency_key: options?.idempotencyKey ?? "",
|
|
533
|
+
version: options?.version ?? 1,
|
|
534
|
+
});
|
|
535
|
+
return {
|
|
536
|
+
entityVersion: Number(response.entityVersion ?? 0),
|
|
537
|
+
eventId: response.eventId ?? "",
|
|
538
|
+
};
|
|
539
|
+
},
|
|
540
|
+
/**
|
|
541
|
+
* Read events from an entity stream
|
|
542
|
+
*/
|
|
543
|
+
read: async (entityId, options) => {
|
|
544
|
+
this.ensureConfigured();
|
|
545
|
+
const response = await this.streamRequest("/ironflow.v1.EntityStreamService/ReadStream", {
|
|
546
|
+
entity_id: entityId,
|
|
547
|
+
from_version: options?.fromVersion ?? 0,
|
|
548
|
+
limit: options?.limit ?? 0,
|
|
549
|
+
direction: options?.direction ?? "forward",
|
|
550
|
+
});
|
|
551
|
+
return {
|
|
552
|
+
events: (response.events ?? []).map((e) => ({
|
|
553
|
+
id: e.id,
|
|
554
|
+
name: e.name,
|
|
555
|
+
data: e.data ?? {},
|
|
556
|
+
entityVersion: Number(e.entityVersion ?? 0),
|
|
557
|
+
version: e.version,
|
|
558
|
+
timestamp: e.timestamp,
|
|
559
|
+
source: e.source,
|
|
560
|
+
metadata: e.metadata,
|
|
561
|
+
})),
|
|
562
|
+
totalCount: Number(response.totalCount ?? 0),
|
|
563
|
+
};
|
|
564
|
+
},
|
|
565
|
+
/**
|
|
566
|
+
* Get information about an entity stream
|
|
567
|
+
*/
|
|
568
|
+
getInfo: async (entityId) => {
|
|
569
|
+
this.ensureConfigured();
|
|
570
|
+
const response = await this.streamRequest("/ironflow.v1.EntityStreamService/GetStreamInfo", {
|
|
571
|
+
entity_id: entityId,
|
|
572
|
+
});
|
|
573
|
+
return {
|
|
574
|
+
entityId: response.entityId ?? "",
|
|
575
|
+
entityType: response.entityType ?? "",
|
|
576
|
+
version: Number(response.version ?? 0),
|
|
577
|
+
eventCount: Number(response.eventCount ?? 0),
|
|
578
|
+
createdAt: response.createdAt ?? "",
|
|
579
|
+
updatedAt: response.updatedAt ?? "",
|
|
580
|
+
};
|
|
581
|
+
},
|
|
582
|
+
/**
|
|
583
|
+
* Subscribe to real-time events for an entity stream
|
|
584
|
+
*
|
|
585
|
+
* @example
|
|
586
|
+
* ```typescript
|
|
587
|
+
* const sub = await ironflow.streams.subscribe("order-123", {
|
|
588
|
+
* entityType: "order",
|
|
589
|
+
* onEvent: (event) => console.log(event),
|
|
590
|
+
* replay: 100,
|
|
591
|
+
* });
|
|
592
|
+
*
|
|
593
|
+
* // Cleanup
|
|
594
|
+
* sub.unsubscribe();
|
|
595
|
+
* ```
|
|
596
|
+
*/
|
|
597
|
+
subscribe: async (entityId, options) => {
|
|
598
|
+
this.ensureConfigured();
|
|
599
|
+
const pattern = `entity:${options.entityType}.${entityId}.>`;
|
|
600
|
+
const sub = await this.subscribe(pattern, {
|
|
601
|
+
onEvent: (event) => {
|
|
602
|
+
const data = event.data;
|
|
603
|
+
const streamEvent = {
|
|
604
|
+
id: data.id ?? "",
|
|
605
|
+
name: data.name ?? "",
|
|
606
|
+
data: data.data ?? {},
|
|
607
|
+
entityVersion: data.entityVersion ?? 0,
|
|
608
|
+
version: data.version ?? 0,
|
|
609
|
+
timestamp: data.timestamp ?? "",
|
|
610
|
+
source: data.source,
|
|
611
|
+
metadata: data.metadata,
|
|
612
|
+
};
|
|
613
|
+
options.onEvent(streamEvent);
|
|
614
|
+
},
|
|
615
|
+
onError: options.onError
|
|
616
|
+
? (info) => options.onError(new Error(info.message))
|
|
617
|
+
: undefined,
|
|
618
|
+
replay: options.replay,
|
|
619
|
+
});
|
|
620
|
+
return sub;
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
// ============================================================================
|
|
624
|
+
// Projections
|
|
625
|
+
// ============================================================================
|
|
626
|
+
/**
|
|
627
|
+
* Get the current state of a projection
|
|
628
|
+
*
|
|
629
|
+
* @example
|
|
630
|
+
* ```typescript
|
|
631
|
+
* const result = await ironflow.getProjection<OrderStats>('order-stats');
|
|
632
|
+
* console.log(result.state); // { totalOrders: 42, ... }
|
|
633
|
+
*
|
|
634
|
+
* // With partition
|
|
635
|
+
* const result = await ironflow.getProjection('order-stats', { partition: 'customer-123' });
|
|
636
|
+
* ```
|
|
637
|
+
*/
|
|
638
|
+
async getProjection(name, options) {
|
|
639
|
+
this.ensureConfigured();
|
|
640
|
+
let url = `${this.config.serverUrl}/api/v1/projections/${encodeURIComponent(name)}`;
|
|
641
|
+
if (options?.partition) {
|
|
642
|
+
url += `?partition=${encodeURIComponent(options.partition)}`;
|
|
643
|
+
}
|
|
644
|
+
const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
|
|
645
|
+
const controller = new AbortController();
|
|
646
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
647
|
+
try {
|
|
648
|
+
const headers = {
|
|
649
|
+
[HEADERS.ENVIRONMENT]: this.config.environment,
|
|
650
|
+
};
|
|
651
|
+
if (this.config.auth?.apiKey) {
|
|
652
|
+
headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
|
|
653
|
+
}
|
|
654
|
+
const response = await fetch(url, {
|
|
655
|
+
method: "GET",
|
|
656
|
+
headers,
|
|
657
|
+
signal: controller.signal,
|
|
658
|
+
});
|
|
659
|
+
if (!response.ok) {
|
|
660
|
+
const error = safeJsonParse(await response.text());
|
|
661
|
+
throw new IronflowError(error?.message || `Get projection failed: ${response.status}`, { code: error?.code || "GET_PROJECTION_FAILED" });
|
|
662
|
+
}
|
|
663
|
+
const data = await response.json();
|
|
664
|
+
return {
|
|
665
|
+
name: data.name,
|
|
666
|
+
partition: data.state?.partition_key ?? "__global__",
|
|
667
|
+
state: data.state?.state ?? {},
|
|
668
|
+
lastEventId: data.state?.last_event_id ?? "",
|
|
669
|
+
lastEventTime: data.state?.last_event_time
|
|
670
|
+
? new Date(data.state.last_event_time)
|
|
671
|
+
: new Date(0),
|
|
672
|
+
version: data.state?.version ?? 0,
|
|
673
|
+
mode: data.mode,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
finally {
|
|
677
|
+
clearTimeout(timeoutId);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Get the status of a projection
|
|
682
|
+
*
|
|
683
|
+
* @example
|
|
684
|
+
* ```typescript
|
|
685
|
+
* const status = await ironflow.getProjectionStatus('order-stats');
|
|
686
|
+
* console.log(status.status); // 'active' | 'rebuilding' | 'paused' | 'error'
|
|
687
|
+
* ```
|
|
688
|
+
*/
|
|
689
|
+
async getProjectionStatus(name) {
|
|
690
|
+
this.ensureConfigured();
|
|
691
|
+
const url = `${this.config.serverUrl}/api/v1/projections/${encodeURIComponent(name)}/status`;
|
|
692
|
+
const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
|
|
693
|
+
const controller = new AbortController();
|
|
694
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
695
|
+
try {
|
|
696
|
+
const headers = {
|
|
697
|
+
[HEADERS.ENVIRONMENT]: this.config.environment,
|
|
698
|
+
};
|
|
699
|
+
if (this.config.auth?.apiKey) {
|
|
700
|
+
headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
|
|
701
|
+
}
|
|
702
|
+
const response = await fetch(url, {
|
|
703
|
+
method: "GET",
|
|
704
|
+
headers,
|
|
705
|
+
signal: controller.signal,
|
|
706
|
+
});
|
|
707
|
+
if (!response.ok) {
|
|
708
|
+
const error = safeJsonParse(await response.text());
|
|
709
|
+
throw new IronflowError(error?.message || `Get projection status failed: ${response.status}`, { code: error?.code || "GET_PROJECTION_STATUS_FAILED" });
|
|
710
|
+
}
|
|
711
|
+
const data = await response.json();
|
|
712
|
+
return {
|
|
713
|
+
name: data.name,
|
|
714
|
+
status: data.status,
|
|
715
|
+
mode: data.mode,
|
|
716
|
+
lastEventSeq: data.last_event_seq ?? 0,
|
|
717
|
+
lag: data.lag ?? 0,
|
|
718
|
+
errorMessage: data.error_message || undefined,
|
|
719
|
+
updatedAt: data.updated_at ? new Date(data.updated_at) : new Date(),
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
finally {
|
|
723
|
+
clearTimeout(timeoutId);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Trigger a rebuild of a projection
|
|
728
|
+
*
|
|
729
|
+
* @example
|
|
730
|
+
* ```typescript
|
|
731
|
+
* const result = await ironflow.rebuildProjection('order-stats');
|
|
732
|
+
* ```
|
|
733
|
+
*/
|
|
734
|
+
async rebuildProjection(name, options) {
|
|
735
|
+
this.ensureConfigured();
|
|
736
|
+
const url = `${this.config.serverUrl}/api/v1/projections/${encodeURIComponent(name)}/rebuild`;
|
|
737
|
+
const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
|
|
738
|
+
const controller = new AbortController();
|
|
739
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
740
|
+
try {
|
|
741
|
+
const headers = {
|
|
742
|
+
"Content-Type": "application/json",
|
|
743
|
+
[HEADERS.ENVIRONMENT]: this.config.environment,
|
|
744
|
+
};
|
|
745
|
+
if (this.config.auth?.apiKey) {
|
|
746
|
+
headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
|
|
747
|
+
}
|
|
748
|
+
const response = await fetch(url, {
|
|
749
|
+
method: "POST",
|
|
750
|
+
headers,
|
|
751
|
+
body: JSON.stringify({
|
|
752
|
+
partition: options?.partition,
|
|
753
|
+
from_event_id: options?.fromEventId,
|
|
754
|
+
dry_run: options?.dryRun,
|
|
755
|
+
}),
|
|
756
|
+
signal: controller.signal,
|
|
757
|
+
});
|
|
758
|
+
if (!response.ok) {
|
|
759
|
+
const error = safeJsonParse(await response.text());
|
|
760
|
+
throw new IronflowError(error?.message || `Rebuild projection failed: ${response.status}`, { code: error?.code || "REBUILD_PROJECTION_FAILED" });
|
|
761
|
+
}
|
|
762
|
+
return response.json();
|
|
763
|
+
}
|
|
764
|
+
finally {
|
|
765
|
+
clearTimeout(timeoutId);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* List all registered projections
|
|
770
|
+
*
|
|
771
|
+
* @example
|
|
772
|
+
* ```typescript
|
|
773
|
+
* const projections = await ironflow.listProjections();
|
|
774
|
+
* projections.forEach(p => console.log(p.name, p.status));
|
|
775
|
+
* ```
|
|
776
|
+
*/
|
|
777
|
+
async listProjections() {
|
|
778
|
+
this.ensureConfigured();
|
|
779
|
+
const url = `${this.config.serverUrl}/api/v1/projections`;
|
|
780
|
+
const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
|
|
781
|
+
const controller = new AbortController();
|
|
782
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
783
|
+
try {
|
|
784
|
+
const headers = {
|
|
785
|
+
[HEADERS.ENVIRONMENT]: this.config.environment,
|
|
786
|
+
};
|
|
787
|
+
if (this.config.auth?.apiKey) {
|
|
788
|
+
headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
|
|
789
|
+
}
|
|
790
|
+
const response = await fetch(url, {
|
|
791
|
+
method: "GET",
|
|
792
|
+
headers,
|
|
793
|
+
signal: controller.signal,
|
|
794
|
+
});
|
|
795
|
+
if (!response.ok) {
|
|
796
|
+
throw new IronflowError(`List projections failed: ${response.status}`, { code: "LIST_PROJECTIONS_FAILED" });
|
|
797
|
+
}
|
|
798
|
+
const data = await response.json();
|
|
799
|
+
const projections = data.projections || [];
|
|
800
|
+
return projections.map((p) => ({
|
|
801
|
+
name: p.name,
|
|
802
|
+
status: p.status,
|
|
803
|
+
mode: p.mode,
|
|
804
|
+
lastEventSeq: p.last_event_seq ?? 0,
|
|
805
|
+
lag: 0,
|
|
806
|
+
errorMessage: p.error_message || undefined,
|
|
807
|
+
updatedAt: p.updated_at ? new Date(p.updated_at) : new Date(),
|
|
808
|
+
}));
|
|
809
|
+
}
|
|
810
|
+
finally {
|
|
811
|
+
clearTimeout(timeoutId);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Subscribe to real-time updates for a projection
|
|
816
|
+
*
|
|
817
|
+
* @example
|
|
818
|
+
* ```typescript
|
|
819
|
+
* const sub = await ironflow.subscribeToProjection<OrderStats>('order-stats', {
|
|
820
|
+
* onUpdate: (state, event) => console.log('Updated:', state),
|
|
821
|
+
* onError: (error) => console.error(error),
|
|
822
|
+
* });
|
|
823
|
+
*
|
|
824
|
+
* // With partition
|
|
825
|
+
* const sub = await ironflow.subscribeToProjection('order-stats', {
|
|
826
|
+
* onUpdate: (state, event) => console.log('Updated:', state),
|
|
827
|
+
* }, { partition: 'customer-123' });
|
|
828
|
+
*
|
|
829
|
+
* // Cleanup
|
|
830
|
+
* sub.unsubscribe();
|
|
831
|
+
* ```
|
|
832
|
+
*/
|
|
833
|
+
async subscribeToProjection(name, callbacks, options) {
|
|
834
|
+
this.ensureConfigured();
|
|
835
|
+
// Build the subscription pattern
|
|
836
|
+
let pattern;
|
|
837
|
+
if (options?.partition) {
|
|
838
|
+
pattern = `system.projection.${name}.${options.partition}.updated`;
|
|
839
|
+
}
|
|
840
|
+
else {
|
|
841
|
+
pattern = `system.projection.${name}.>`;
|
|
842
|
+
}
|
|
843
|
+
const sub = await this.subscribe(pattern, {
|
|
844
|
+
onEvent: (event) => {
|
|
845
|
+
const payload = event.data;
|
|
846
|
+
const state = payload.state ?? {};
|
|
847
|
+
callbacks.onUpdate(state, {
|
|
848
|
+
id: payload.last_event_id ?? "",
|
|
849
|
+
name: payload.last_event_name ?? "",
|
|
850
|
+
});
|
|
851
|
+
},
|
|
852
|
+
onError: callbacks.onError
|
|
853
|
+
? (info) => callbacks.onError(new Error(info.message))
|
|
854
|
+
: undefined,
|
|
855
|
+
replay: options?.replay,
|
|
856
|
+
});
|
|
857
|
+
return sub;
|
|
858
|
+
}
|
|
859
|
+
// ============================================================================
|
|
860
|
+
// KV Store
|
|
861
|
+
// ============================================================================
|
|
862
|
+
/**
|
|
863
|
+
* KV store operations
|
|
864
|
+
*
|
|
865
|
+
* @example
|
|
866
|
+
* ```typescript
|
|
867
|
+
* const kv = ironflow.kv();
|
|
868
|
+
* const bucket = await kv.createBucket({ name: "sessions", ttlSeconds: 3600 });
|
|
869
|
+
* const handle = kv.bucket("sessions");
|
|
870
|
+
* const { revision } = await handle.put("user.123", { token: "abc" });
|
|
871
|
+
* const entry = await handle.get("user.123");
|
|
872
|
+
*
|
|
873
|
+
* // Watch for changes
|
|
874
|
+
* const watcher = handle.watch({
|
|
875
|
+
* onUpdate: (event) => console.log(event),
|
|
876
|
+
* }, { key: "user.*" });
|
|
877
|
+
* ```
|
|
878
|
+
*/
|
|
879
|
+
kv() {
|
|
880
|
+
this.ensureConfigured();
|
|
881
|
+
return new BrowserKVClient(this.config);
|
|
882
|
+
}
|
|
883
|
+
// ============================================================================
|
|
884
|
+
// Config Management
|
|
885
|
+
// ============================================================================
|
|
886
|
+
/**
|
|
887
|
+
* Config management operations
|
|
888
|
+
*
|
|
889
|
+
* @example
|
|
890
|
+
* ```typescript
|
|
891
|
+
* const cfg = ironflow.configManager();
|
|
892
|
+
* await cfg.set("app", { featureX: true, maxRetries: 3 });
|
|
893
|
+
* const { data, revision } = await cfg.get("app");
|
|
894
|
+
* await cfg.patch("app", { maxRetries: 5 });
|
|
895
|
+
* const configs = await cfg.list();
|
|
896
|
+
* await cfg.delete("app");
|
|
897
|
+
*
|
|
898
|
+
* // Watch for changes
|
|
899
|
+
* const sub = await cfg.watch("app", {
|
|
900
|
+
* onEvent: (config) => console.log(config),
|
|
901
|
+
* });
|
|
902
|
+
* sub.unsubscribe();
|
|
903
|
+
* ```
|
|
904
|
+
*/
|
|
905
|
+
configManager() {
|
|
906
|
+
this.ensureConfigured();
|
|
907
|
+
return new BrowserConfigClient(this.config, (pattern, callbacks) => this.subscribe(pattern, callbacks));
|
|
908
|
+
}
|
|
909
|
+
// ============================================================================
|
|
310
910
|
// Pattern Helpers (static)
|
|
311
911
|
// ============================================================================
|
|
312
912
|
/**
|
|
@@ -332,6 +932,15 @@ class IronflowClient {
|
|
|
332
932
|
}
|
|
333
933
|
this.transport = null;
|
|
334
934
|
}
|
|
935
|
+
/**
|
|
936
|
+
* Reset client state for testing. Not intended for production use.
|
|
937
|
+
* @internal
|
|
938
|
+
*/
|
|
939
|
+
_resetForTesting() {
|
|
940
|
+
this.cleanup();
|
|
941
|
+
this.config = null;
|
|
942
|
+
this.logger = createNoopLogger();
|
|
943
|
+
}
|
|
335
944
|
setupVisibilityHandling() {
|
|
336
945
|
this.visibilityHandler = () => {
|
|
337
946
|
if (document.hidden) {
|
|
@@ -355,6 +964,7 @@ class IronflowClient {
|
|
|
355
964
|
try {
|
|
356
965
|
const headers = {
|
|
357
966
|
"Content-Type": "application/json",
|
|
967
|
+
[HEADERS.ENVIRONMENT]: this.config.environment,
|
|
358
968
|
};
|
|
359
969
|
if (this.config.auth?.apiKey) {
|
|
360
970
|
headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
|
|
@@ -397,12 +1007,64 @@ class IronflowClient {
|
|
|
397
1007
|
throw error;
|
|
398
1008
|
}
|
|
399
1009
|
if (error instanceof Error && error.name === "AbortError") {
|
|
400
|
-
throw new IronflowError(`Request timeout after ${timeout}ms`, {
|
|
1010
|
+
throw new IronflowError(`Request timeout after ${timeout}ms for ${method} ${path}`, {
|
|
401
1011
|
code: "TIMEOUT",
|
|
402
1012
|
retryable: true,
|
|
403
1013
|
});
|
|
404
1014
|
}
|
|
405
|
-
throw new IronflowError(error instanceof Error
|
|
1015
|
+
throw new IronflowError(error instanceof Error
|
|
1016
|
+
? `${method} ${path} failed: ${error.message}`
|
|
1017
|
+
: `${method} ${path} failed`, {
|
|
1018
|
+
code: "REQUEST_FAILED",
|
|
1019
|
+
retryable: true,
|
|
1020
|
+
cause: error instanceof Error ? error : undefined,
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
finally {
|
|
1024
|
+
clearTimeout(timeoutId);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
async streamRequest(path, body) {
|
|
1028
|
+
const url = `${this.config.serverUrl}${path}`;
|
|
1029
|
+
const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
|
|
1030
|
+
const controller = new AbortController();
|
|
1031
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
1032
|
+
try {
|
|
1033
|
+
const headers = {
|
|
1034
|
+
"Content-Type": "application/json",
|
|
1035
|
+
[HEADERS.ENVIRONMENT]: this.config.environment,
|
|
1036
|
+
};
|
|
1037
|
+
if (this.config.auth?.apiKey) {
|
|
1038
|
+
headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
|
|
1039
|
+
}
|
|
1040
|
+
else if (this.config.auth?.token) {
|
|
1041
|
+
headers["Authorization"] = `Bearer ${this.config.auth.token}`;
|
|
1042
|
+
}
|
|
1043
|
+
const response = await fetch(url, {
|
|
1044
|
+
method: "POST",
|
|
1045
|
+
headers,
|
|
1046
|
+
body: JSON.stringify(body),
|
|
1047
|
+
signal: controller.signal,
|
|
1048
|
+
});
|
|
1049
|
+
if (!response.ok) {
|
|
1050
|
+
const errorBody = safeJsonParse(await response.text());
|
|
1051
|
+
throw new IronflowError(errorBody?.message ?? `Request failed: ${response.status}`, {
|
|
1052
|
+
code: errorBody?.code ?? `HTTP_${response.status}`,
|
|
1053
|
+
retryable: response.status >= 500,
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
return response.json();
|
|
1057
|
+
}
|
|
1058
|
+
catch (error) {
|
|
1059
|
+
if (error instanceof IronflowError) {
|
|
1060
|
+
throw error;
|
|
1061
|
+
}
|
|
1062
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
1063
|
+
throw new IronflowError(`Request timeout after ${timeout}ms for POST ${path}`, { code: "TIMEOUT", retryable: true });
|
|
1064
|
+
}
|
|
1065
|
+
throw new IronflowError(error instanceof Error
|
|
1066
|
+
? `POST ${path} failed: ${error.message}`
|
|
1067
|
+
: `POST ${path} failed`, {
|
|
406
1068
|
code: "REQUEST_FAILED",
|
|
407
1069
|
retryable: true,
|
|
408
1070
|
cause: error instanceof Error ? error : undefined,
|
|
@@ -413,7 +1075,9 @@ class IronflowClient {
|
|
|
413
1075
|
}
|
|
414
1076
|
}
|
|
415
1077
|
mapRunResponse(response) {
|
|
416
|
-
|
|
1078
|
+
// ConnectRPC returns proto enum strings like "RUN_STATUS_COMPLETED" — normalize to "completed"
|
|
1079
|
+
const rawStatus = response.status.toLowerCase().replace(/^run_status_/, "");
|
|
1080
|
+
const statusResult = RunStatusSchema.safeParse(rawStatus);
|
|
417
1081
|
const status = statusResult.success ? statusResult.data : "failed";
|
|
418
1082
|
return {
|
|
419
1083
|
id: response.id,
|