@newhomestar/sdk 0.7.4 → 0.7.6

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/dist/events.d.ts CHANGED
@@ -187,6 +187,158 @@ export declare function queueEvent(payload: QueueEventPayload): Promise<{
187
187
  * startOutboxRelay(db);
188
188
  */
189
189
  export declare function startOutboxRelay(db: any, options?: OutboxRelayOptions): ReturnType<typeof setInterval>;
190
+ /**
191
+ * A single PGMQ message received from the Nova Events Service SSE stream.
192
+ * Passed to InboundHandler and withInboundEvent().
193
+ */
194
+ export interface InboundMessage {
195
+ /** PGMQ message ID — used for ACK/NACK and as the idempotency key in inbound_events */
196
+ msg_id: number | bigint;
197
+ /** Name of the PGMQ queue this message was read from (e.g. 'jira_queue') */
198
+ queue: string;
199
+ /** Full message payload: includes topic, entity_type, action, attributes, metadata */
200
+ payload: Record<string, unknown>;
201
+ /** PGMQ read count — how many times this message has been delivered (1 = first delivery) */
202
+ read_ct?: number;
203
+ }
204
+ /**
205
+ * Handler function for a specific inbound event topic.
206
+ *
207
+ * - `tx` — Prisma transaction client. Use for all local DB writes.
208
+ * - `event` — The inbound PGMQ message.
209
+ *
210
+ * Return `{ status: 'processed' }` on success or `{ status: 'skipped' }` if
211
+ * this handler intentionally ignores the event (e.g. loop prevention).
212
+ *
213
+ * **Do NOT call external HTTP APIs inside the handler** — Prisma transactions
214
+ * have a default 5s timeout. External write-backs (e.g., Jira API calls) should
215
+ * happen after `withInboundEvent()` returns.
216
+ */
217
+ export type InboundHandler = (tx: any, event: InboundMessage) => Promise<{
218
+ status: 'processed' | 'skipped';
219
+ result?: unknown;
220
+ }>;
221
+ /** Options for startInboundConsumer() */
222
+ export interface InboundConsumerOptions {
223
+ /** PGMQ queue name to consume from (e.g. 'jira_queue') */
224
+ queueName: string;
225
+ /**
226
+ * Map of event topic → handler function.
227
+ * Keys are full event topic strings, e.g. 'nova_ticketing_service.ticket_created'.
228
+ * The topic is derived from payload.topic, or payload.entity_type + '.' + payload.action.
229
+ */
230
+ handlers: Record<string, InboundHandler>;
231
+ /**
232
+ * Fallback handler for unrecognized event types.
233
+ * Default behavior: skip + ACK (prevents unhandled events from blocking the queue).
234
+ */
235
+ defaultHandler?: InboundHandler;
236
+ /** Events Service base URL (default: NOVA_EVENTS_SERVICE_URL env var) */
237
+ eventsUrl?: string;
238
+ /** Service JWT for authentication (default: NOVA_SERVICE_TOKEN env var) */
239
+ serviceToken?: string;
240
+ /**
241
+ * Visibility timeout in seconds requested when reading from PGMQ.
242
+ * Must be long enough for your handler to complete. Default: 30s.
243
+ */
244
+ vt?: number;
245
+ /**
246
+ * Maximum delay between reconnect attempts in ms.
247
+ * Reconnects use exponential backoff: 1s → 2s → 4s → ... → maxReconnectDelay.
248
+ * Default: 30_000 (30 seconds).
249
+ */
250
+ maxReconnectDelay?: number;
251
+ }
252
+ /**
253
+ * Atomically store an inbound PGMQ message + run the handler in one Prisma
254
+ * `$transaction`, then send ACK to the Events Service post-commit.
255
+ *
256
+ * ## ACID Guarantee
257
+ *
258
+ * The `inbound_events` INSERT and all handler DB writes are committed together
259
+ * in a single Prisma `$transaction`. After commit, ACK is sent to the Events
260
+ * Service. This gives us exactly-once processing semantics:
261
+ *
262
+ * - Transaction fails → rolls back → no ACK → PGMQ re-delivers → full retry ✓
263
+ * - Transaction succeeds, ACK fails → PGMQ re-delivers → UNIQUE(msg_id) blocks
264
+ * re-processing → ACK sent again on the duplicate delivery ✓
265
+ *
266
+ * ## Requirements
267
+ *
268
+ * Your Prisma schema must include the `InboundEvent` model with:
269
+ * ```prisma
270
+ * model InboundEvent {
271
+ * id BigInt @id @default(autoincrement())
272
+ * msgId BigInt @unique @map("msg_id")
273
+ * ...
274
+ * @@map("inbound_events")
275
+ * }
276
+ * ```
277
+ *
278
+ * @param db - Prisma client instance (must have `inboundEvent` model)
279
+ * @param msg - The PGMQ message received from the SSE stream
280
+ * @param handler - Business logic callback; receives `(tx, event)`, runs inside the transaction
281
+ * @returns - `{ status: 'processed' | 'skipped' | 'duplicate' }`
282
+ * @throws - Re-throws handler errors so the SSE consumer can NACK
283
+ *
284
+ * @example
285
+ * const outcome = await withInboundEvent(db, msg, async (tx, event) => {
286
+ * await tx.jiraIssue.upsert({ where: { ... }, create: { ... }, update: { ... } });
287
+ * return { status: 'processed' };
288
+ * });
289
+ */
290
+ export declare function withInboundEvent(db: any, msg: InboundMessage, handler: InboundHandler): Promise<{
291
+ status: 'processed' | 'skipped' | 'duplicate';
292
+ }>;
293
+ /**
294
+ * Start an SSE consumer that reads from the integration's PGMQ queue,
295
+ * routes events to handler functions, and manages ACK/NACK/reconnect automatically.
296
+ *
297
+ * Each message is processed via `withInboundEvent()` — ACID guarantee with
298
+ * exactly-once processing even across re-deliveries and crashes.
299
+ *
300
+ * ## Handler routing
301
+ *
302
+ * Events are routed by topic string, derived from `payload.topic` or
303
+ * `payload.entity_type + '.' + payload.action`. If no matching handler is
304
+ * found, `defaultHandler` is called (default: skip + ACK).
305
+ *
306
+ * ## Connection lifecycle
307
+ *
308
+ * - Opens `GET /events/queue/stream?queue_name={queueName}` (SSE)
309
+ * - On message: routes → withInboundEvent() → ACK (or NACK on failure)
310
+ * - On keepalive (`: keepalive`): no action needed
311
+ * - On close/error: reconnects with exponential backoff (1s → 2s → 4s → ... → max)
312
+ * - On `AbortController.abort()`: graceful shutdown, no reconnect
313
+ *
314
+ * @param db - Prisma client (must have `inboundEvent` model)
315
+ * @param options - Consumer configuration
316
+ * @returns - AbortController — call `.abort()` for graceful shutdown
317
+ *
318
+ * @example
319
+ * // At service boot
320
+ * const consumer = startInboundConsumer(db, {
321
+ * queueName: 'jira_queue',
322
+ * handlers: {
323
+ * 'nova_ticketing_service.ticket_created': async (tx, event) => {
324
+ * await tx.jiraIssue.upsert({ ... });
325
+ * return { status: 'processed' };
326
+ * },
327
+ * 'nova_ticketing_service.ticket_updated': async (tx, event) => {
328
+ * await tx.jiraIssue.update({ ... });
329
+ * return { status: 'processed' };
330
+ * },
331
+ * },
332
+ * defaultHandler: async (_tx, event) => {
333
+ * console.log(`[jira] Skipping unhandled: ${(event.payload as any).topic}`);
334
+ * return { status: 'skipped' };
335
+ * },
336
+ * });
337
+ *
338
+ * // Graceful shutdown
339
+ * process.on('SIGTERM', () => consumer.abort());
340
+ */
341
+ export declare function startInboundConsumer(db: any, options: InboundConsumerOptions): AbortController;
190
342
  /**
191
343
  * Class-based client when you need more control than the top-level functions.
192
344
  * Useful when you want to pass configuration explicitly rather than relying on env vars.
package/dist/events.js CHANGED
@@ -441,6 +441,390 @@ export function startOutboxRelay(db, options = {}) {
441
441
  }, intervalMs);
442
442
  return handle;
443
443
  }
444
+ // ── withInboundEvent ───────────────────────────────────────────────────────────
445
+ /**
446
+ * Atomically store an inbound PGMQ message + run the handler in one Prisma
447
+ * `$transaction`, then send ACK to the Events Service post-commit.
448
+ *
449
+ * ## ACID Guarantee
450
+ *
451
+ * The `inbound_events` INSERT and all handler DB writes are committed together
452
+ * in a single Prisma `$transaction`. After commit, ACK is sent to the Events
453
+ * Service. This gives us exactly-once processing semantics:
454
+ *
455
+ * - Transaction fails → rolls back → no ACK → PGMQ re-delivers → full retry ✓
456
+ * - Transaction succeeds, ACK fails → PGMQ re-delivers → UNIQUE(msg_id) blocks
457
+ * re-processing → ACK sent again on the duplicate delivery ✓
458
+ *
459
+ * ## Requirements
460
+ *
461
+ * Your Prisma schema must include the `InboundEvent` model with:
462
+ * ```prisma
463
+ * model InboundEvent {
464
+ * id BigInt @id @default(autoincrement())
465
+ * msgId BigInt @unique @map("msg_id")
466
+ * ...
467
+ * @@map("inbound_events")
468
+ * }
469
+ * ```
470
+ *
471
+ * @param db - Prisma client instance (must have `inboundEvent` model)
472
+ * @param msg - The PGMQ message received from the SSE stream
473
+ * @param handler - Business logic callback; receives `(tx, event)`, runs inside the transaction
474
+ * @returns - `{ status: 'processed' | 'skipped' | 'duplicate' }`
475
+ * @throws - Re-throws handler errors so the SSE consumer can NACK
476
+ *
477
+ * @example
478
+ * const outcome = await withInboundEvent(db, msg, async (tx, event) => {
479
+ * await tx.jiraIssue.upsert({ where: { ... }, create: { ... }, update: { ... } });
480
+ * return { status: 'processed' };
481
+ * });
482
+ */
483
+ export async function withInboundEvent(db, msg, handler) {
484
+ const eventsUrl = process.env.NOVA_EVENTS_SERVICE_URL;
485
+ const serviceToken = process.env.NOVA_SERVICE_TOKEN;
486
+ if (!eventsUrl || !serviceToken) {
487
+ throw new Error('[withInboundEvent] NOVA_EVENTS_SERVICE_URL and NOVA_SERVICE_TOKEN must be set.');
488
+ }
489
+ const msgId = BigInt(msg.msg_id);
490
+ const queueName = msg.queue;
491
+ // Derive event_type from the payload
492
+ const eventType = msg.payload?.topic ??
493
+ (msg.payload?.entity_type && msg.payload?.action
494
+ ? `${msg.payload.entity_type}.${msg.payload.action}`
495
+ : 'unknown.event');
496
+ // ── Phase 1: Atomic local write + handler in one transaction ───────────────
497
+ let outcome;
498
+ try {
499
+ outcome = await db.$transaction(async (tx) => {
500
+ // Idempotency check: have we already processed this msg_id?
501
+ const existing = await tx.inboundEvent.findUnique({
502
+ where: { msgId },
503
+ select: { id: true, status: true },
504
+ });
505
+ if (existing) {
506
+ // Already processed — ACK will be sent post-transaction regardless
507
+ console.log(`[nova/events] withInboundEvent: duplicate msg_id=${msgId} ` +
508
+ `(status=${existing.status}) — skipping handler, will re-ACK`);
509
+ return { status: 'duplicate' };
510
+ }
511
+ // Insert the inbound_events row at 'processing' status
512
+ await tx.inboundEvent.create({
513
+ data: {
514
+ msgId,
515
+ queueName,
516
+ eventType,
517
+ payload: msg.payload,
518
+ status: 'processing',
519
+ attempts: 1,
520
+ },
521
+ });
522
+ // Run the handler inside the same transaction
523
+ const handlerResult = await handler(tx, msg);
524
+ // Update status to processed or skipped
525
+ await tx.inboundEvent.update({
526
+ where: { msgId },
527
+ data: {
528
+ status: handlerResult.status,
529
+ processedAt: new Date(),
530
+ },
531
+ });
532
+ return { status: handlerResult.status };
533
+ });
534
+ }
535
+ catch (err) {
536
+ // Transaction failed — record the error (best-effort, outside tx)
537
+ // Do NOT ACK — let the message be re-delivered by PGMQ after VT expires
538
+ try {
539
+ await db.inboundEvent.upsert({
540
+ where: { msgId },
541
+ create: {
542
+ msgId,
543
+ queueName,
544
+ eventType,
545
+ payload: msg.payload,
546
+ status: 'failed',
547
+ error: String(err).slice(0, 500),
548
+ attempts: 1,
549
+ },
550
+ update: {
551
+ status: 'failed',
552
+ error: String(err).slice(0, 500),
553
+ attempts: { increment: 1 },
554
+ },
555
+ });
556
+ }
557
+ catch (recordErr) {
558
+ console.error('[nova/events] withInboundEvent: failed to record error:', recordErr);
559
+ }
560
+ // Re-throw so the SSE consumer can NACK
561
+ throw err;
562
+ }
563
+ // ── Phase 2: ACK on Events Service (post-commit) ────────────────────────────
564
+ // Safe because: if ACK fails, PGMQ re-delivers, UNIQUE(msg_id) blocks
565
+ // re-processing, and we just ACK again on the duplicate delivery.
566
+ try {
567
+ const ackRes = await fetch(`${eventsUrl}/events/queue/ack`, {
568
+ method: 'POST',
569
+ headers: {
570
+ 'Authorization': `Bearer ${serviceToken}`,
571
+ 'Content-Type': 'application/json',
572
+ },
573
+ body: JSON.stringify({
574
+ queue_name: queueName,
575
+ msg_id: Number(msgId),
576
+ }),
577
+ });
578
+ if (ackRes.ok) {
579
+ // Best-effort: record ack timestamp
580
+ await db.inboundEvent.update({
581
+ where: { msgId },
582
+ data: { ackedAt: new Date() },
583
+ }).catch(() => { });
584
+ }
585
+ else {
586
+ const text = await ackRes.text().catch(() => '');
587
+ console.warn(`[nova/events] ACK failed for msg_id=${msgId} (${ackRes.status}: ${text.slice(0, 200)}) — ` +
588
+ `message will be re-delivered; idempotency guard prevents double-processing`);
589
+ }
590
+ }
591
+ catch (ackErr) {
592
+ console.warn(`[nova/events] ACK network error for msg_id=${msgId}: ${String(ackErr).slice(0, 200)} — ` +
593
+ `message will be re-delivered; idempotency guard prevents double-processing`);
594
+ }
595
+ return outcome;
596
+ }
597
+ // ── startInboundConsumer ───────────────────────────────────────────────────────
598
+ /**
599
+ * Start an SSE consumer that reads from the integration's PGMQ queue,
600
+ * routes events to handler functions, and manages ACK/NACK/reconnect automatically.
601
+ *
602
+ * Each message is processed via `withInboundEvent()` — ACID guarantee with
603
+ * exactly-once processing even across re-deliveries and crashes.
604
+ *
605
+ * ## Handler routing
606
+ *
607
+ * Events are routed by topic string, derived from `payload.topic` or
608
+ * `payload.entity_type + '.' + payload.action`. If no matching handler is
609
+ * found, `defaultHandler` is called (default: skip + ACK).
610
+ *
611
+ * ## Connection lifecycle
612
+ *
613
+ * - Opens `GET /events/queue/stream?queue_name={queueName}` (SSE)
614
+ * - On message: routes → withInboundEvent() → ACK (or NACK on failure)
615
+ * - On keepalive (`: keepalive`): no action needed
616
+ * - On close/error: reconnects with exponential backoff (1s → 2s → 4s → ... → max)
617
+ * - On `AbortController.abort()`: graceful shutdown, no reconnect
618
+ *
619
+ * @param db - Prisma client (must have `inboundEvent` model)
620
+ * @param options - Consumer configuration
621
+ * @returns - AbortController — call `.abort()` for graceful shutdown
622
+ *
623
+ * @example
624
+ * // At service boot
625
+ * const consumer = startInboundConsumer(db, {
626
+ * queueName: 'jira_queue',
627
+ * handlers: {
628
+ * 'nova_ticketing_service.ticket_created': async (tx, event) => {
629
+ * await tx.jiraIssue.upsert({ ... });
630
+ * return { status: 'processed' };
631
+ * },
632
+ * 'nova_ticketing_service.ticket_updated': async (tx, event) => {
633
+ * await tx.jiraIssue.update({ ... });
634
+ * return { status: 'processed' };
635
+ * },
636
+ * },
637
+ * defaultHandler: async (_tx, event) => {
638
+ * console.log(`[jira] Skipping unhandled: ${(event.payload as any).topic}`);
639
+ * return { status: 'skipped' };
640
+ * },
641
+ * });
642
+ *
643
+ * // Graceful shutdown
644
+ * process.on('SIGTERM', () => consumer.abort());
645
+ */
646
+ export function startInboundConsumer(db, options) {
647
+ const { queueName, handlers, defaultHandler, vt = 30, maxReconnectDelay = 30_000, } = options;
648
+ const eventsUrl = options.eventsUrl ?? process.env.NOVA_EVENTS_SERVICE_URL;
649
+ const serviceToken = options.serviceToken ?? process.env.NOVA_SERVICE_TOKEN;
650
+ if (!eventsUrl || !serviceToken) {
651
+ console.error('[nova/events] startInboundConsumer: NOVA_EVENTS_SERVICE_URL and NOVA_SERVICE_TOKEN ' +
652
+ 'must be set. Inbound consumer will NOT start.');
653
+ // Return a no-op AbortController
654
+ return new AbortController();
655
+ }
656
+ const abort = new AbortController();
657
+ let reconnectAttempt = 0;
658
+ /** Derive the event topic from a raw PGMQ message payload */
659
+ function _deriveEventType(payload) {
660
+ if (typeof payload.topic === 'string')
661
+ return payload.topic;
662
+ if (typeof payload.entity_type === 'string' && typeof payload.action === 'string') {
663
+ return `${payload.entity_type}.${payload.action}`;
664
+ }
665
+ return 'unknown.event';
666
+ }
667
+ /** Send a NACK to extend the visibility timeout with exponential backoff */
668
+ async function _nack(msgId, readCt) {
669
+ try {
670
+ await fetch(`${eventsUrl}/events/queue/nack`, {
671
+ method: 'POST',
672
+ headers: {
673
+ 'Authorization': `Bearer ${serviceToken}`,
674
+ 'Content-Type': 'application/json',
675
+ },
676
+ body: JSON.stringify({ queue_name: queueName, msg_id: msgId }),
677
+ });
678
+ }
679
+ catch (err) {
680
+ console.error(`[nova/events] NACK failed for msg_id=${msgId} read_ct=${readCt}:`, String(err).slice(0, 200));
681
+ }
682
+ }
683
+ /** Process a single parsed SSE message object */
684
+ async function _processMessage(raw) {
685
+ const msgId = raw.msg_id;
686
+ const readCt = raw.read_ct ?? 1;
687
+ const payload = (raw.message ?? raw.payload ?? raw);
688
+ if (msgId == null) {
689
+ console.warn('[nova/events] SSE message missing msg_id — skipping:', JSON.stringify(raw).slice(0, 200));
690
+ return;
691
+ }
692
+ const eventType = _deriveEventType(payload);
693
+ const handler = handlers[eventType] ?? defaultHandler;
694
+ const msg = {
695
+ msg_id: msgId,
696
+ queue: queueName,
697
+ payload,
698
+ read_ct: readCt,
699
+ };
700
+ if (!handler) {
701
+ // No handler registered and no default — skip + ACK to unblock queue
702
+ console.log(`[nova/events] No handler for event type "${eventType}" (msg_id=${msgId}) — ACK and skip`);
703
+ try {
704
+ await fetch(`${eventsUrl}/events/queue/ack`, {
705
+ method: 'POST',
706
+ headers: { 'Authorization': `Bearer ${serviceToken}`, 'Content-Type': 'application/json' },
707
+ body: JSON.stringify({ queue_name: queueName, msg_id: msgId }),
708
+ });
709
+ }
710
+ catch { /* best-effort */ }
711
+ return;
712
+ }
713
+ try {
714
+ const outcome = await withInboundEvent(db, msg, handler);
715
+ console.log(`[nova/events] msg_id=${msgId} event="${eventType}" status=${outcome.status} read_ct=${readCt}`);
716
+ }
717
+ catch (err) {
718
+ console.error(`[nova/events] Handler failed for msg_id=${msgId} event="${eventType}" read_ct=${readCt}:`, err);
719
+ // NACK — extends visibility timeout so PGMQ will re-deliver
720
+ await _nack(msgId, readCt);
721
+ }
722
+ }
723
+ /** Parse and consume an SSE response body, yielding full message objects */
724
+ async function _consumeStream(response) {
725
+ if (!response.body)
726
+ throw new Error('SSE response has no body');
727
+ const reader = response.body.getReader();
728
+ const decoder = new TextDecoder();
729
+ let buffer = '';
730
+ let dataLines = [];
731
+ try {
732
+ while (true) {
733
+ const { value, done } = await reader.read();
734
+ if (done)
735
+ break;
736
+ if (abort.signal.aborted)
737
+ break;
738
+ buffer += decoder.decode(value, { stream: true });
739
+ // Process complete lines
740
+ const lines = buffer.split('\n');
741
+ buffer = lines.pop() ?? ''; // last element may be incomplete
742
+ for (const raw of lines) {
743
+ const line = raw.trimEnd();
744
+ if (line === '') {
745
+ // Empty line = end of SSE message block
746
+ if (dataLines.length > 0) {
747
+ const joined = dataLines.join('\n');
748
+ dataLines = [];
749
+ try {
750
+ const parsed = JSON.parse(joined);
751
+ // Fire-and-forget within the stream loop; errors caught inside _processMessage
752
+ _processMessage(parsed).catch((err) => {
753
+ console.error('[nova/events] _processMessage uncaught error:', err);
754
+ });
755
+ }
756
+ catch {
757
+ // Keepalives and comments sometimes have no data
758
+ }
759
+ }
760
+ continue;
761
+ }
762
+ if (line.startsWith(':')) {
763
+ // SSE comment/keepalive (e.g., ": keepalive") — ignore
764
+ continue;
765
+ }
766
+ if (line.startsWith('data: ')) {
767
+ dataLines.push(line.slice(6));
768
+ }
769
+ // event:, id:, retry: fields — not needed for our use case
770
+ }
771
+ }
772
+ }
773
+ finally {
774
+ reader.releaseLock();
775
+ }
776
+ }
777
+ /** Main loop: connect, consume, reconnect */
778
+ async function _connect() {
779
+ while (!abort.signal.aborted) {
780
+ const streamUrl = `${eventsUrl}/events/queue/stream?queue_name=${encodeURIComponent(queueName)}&vt=${vt}`;
781
+ try {
782
+ console.log(`[nova/events] Connecting to SSE queue "${queueName}" ` +
783
+ `(attempt ${reconnectAttempt + 1})…`);
784
+ const response = await fetch(streamUrl, {
785
+ headers: { 'Authorization': `Bearer ${serviceToken}`, 'Accept': 'text/event-stream' },
786
+ signal: abort.signal,
787
+ });
788
+ if (!response.ok) {
789
+ const text = await response.text().catch(() => '');
790
+ throw new Error(`SSE connect failed: HTTP ${response.status} — ${text.slice(0, 200)}`);
791
+ }
792
+ // Successfully connected — reset reconnect counter
793
+ reconnectAttempt = 0;
794
+ console.log(`[nova/events] SSE consumer connected to queue "${queueName}"`);
795
+ await _consumeStream(response);
796
+ if (abort.signal.aborted) {
797
+ console.log(`[nova/events] SSE consumer for queue "${queueName}" shut down gracefully`);
798
+ return;
799
+ }
800
+ console.log(`[nova/events] SSE stream for queue "${queueName}" closed — reconnecting…`);
801
+ }
802
+ catch (err) {
803
+ if (abort.signal.aborted) {
804
+ console.log(`[nova/events] SSE consumer for queue "${queueName}" aborted`);
805
+ return;
806
+ }
807
+ const isAbortError = err?.name === 'AbortError' || err?.code === 20;
808
+ if (isAbortError)
809
+ return;
810
+ console.error(`[nova/events] SSE connection error for queue "${queueName}":`, String(err).slice(0, 300));
811
+ }
812
+ // Exponential backoff: 1s, 2s, 4s, 8s, ... capped at maxReconnectDelay
813
+ const delayMs = Math.min(1_000 * Math.pow(2, reconnectAttempt), maxReconnectDelay);
814
+ reconnectAttempt++;
815
+ console.log(`[nova/events] Reconnecting queue "${queueName}" in ${delayMs}ms…`);
816
+ await new Promise((resolve) => {
817
+ const t = setTimeout(resolve, delayMs);
818
+ abort.signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });
819
+ });
820
+ }
821
+ }
822
+ // Start the consumer loop (non-blocking)
823
+ _connect().catch((err) => {
824
+ console.error(`[nova/events] startInboundConsumer fatal error for queue "${queueName}":`, err);
825
+ });
826
+ return abort;
827
+ }
444
828
  // ── NovaEventsClient ───────────────────────────────────────────────────────────
445
829
  /**
446
830
  * Class-based client when you need more control than the top-level functions.
package/dist/index.d.ts CHANGED
@@ -167,6 +167,15 @@ export declare function action<I extends ZodTypeAny, O extends ZodTypeAny>(cfg:
167
167
  path?: string;
168
168
  scopes?: string[];
169
169
  category?: string;
170
+ /**
171
+ * Shorthand for subscribing to Nova event topics.
172
+ * Equivalent to `triggers: [{ type: 'event', events: [...] }]` but more concise.
173
+ *
174
+ * @example
175
+ * events: 'nova_ticketing_service.ticket_created'
176
+ * events: ['nova_ticketing_service.ticket_created', 'nova_ticketing_service.ticket_updated']
177
+ */
178
+ events?: string | string[];
170
179
  /**
171
180
  * Per-field parameter metadata — tells the platform where each input field
172
181
  * goes (path, query, body, header) and what UI widget to render.
package/dist/index.js CHANGED
@@ -159,6 +159,15 @@ function buildTriggerMap(def) {
159
159
  }
160
160
  }
161
161
  }
162
+ // Shorthand: events: string | string[]
163
+ const eventsShorthand = actionDef.events;
164
+ if (eventsShorthand) {
165
+ const eventList = Array.isArray(eventsShorthand) ? eventsShorthand : [eventsShorthand];
166
+ for (const topic of eventList) {
167
+ if (!map.has(topic))
168
+ map.set(topic, actionDef); // triggers take precedence over shorthand
169
+ }
170
+ }
162
171
  // Legacy: capabilities API (backward compat)
163
172
  if (actionDef.capabilities) {
164
173
  for (const cap of actionDef.capabilities) {
@@ -377,7 +386,7 @@ async function runWorkerSSE(def) {
377
386
  console.log(`[nova] ✅ Queue '${def.queue}' ready`);
378
387
  const triggerMap = buildTriggerMap(def);
379
388
  console.log(`[nova] SSE worker '${def.name}' → ${baseUrl}/events/queue/stream?queue=${def.queue}`);
380
- console.log(`[nova] Trigger map:`, Object.fromEntries(triggerMap));
389
+ console.log(`[nova] Trigger map:`, Object.fromEntries([...triggerMap.entries()].map(([topic, a]) => [topic, `${a.method ?? 'POST'} ${a.path ?? '/'}`])));
381
390
  let reconnectDelay = 1_000; // start 1 s, double up to 30 s
382
391
  // ── Outer reconnect loop ─────────────────────────────────────────────────
383
392
  while (true) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newhomestar/sdk",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "description": "Type-safe SDK for building Nova pipelines (workers & functions)",
5
5
  "homepage": "https://github.com/newhomestar/nova-node-sdk#readme",
6
6
  "bugs": {