@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 +152 -0
- package/dist/events.js +384 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +10 -1
- package/package.json +1 -1
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