@newhomestar/sdk 0.7.13 → 0.7.14

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
@@ -339,6 +339,70 @@ export declare function withInboundEvent(db: any, msg: InboundMessage, handler:
339
339
  * process.on('SIGTERM', () => consumer.abort());
340
340
  */
341
341
  export declare function startInboundConsumer(db: any, options: InboundConsumerOptions): AbortController;
342
+ /** Options for startPollConsumer() — extends InboundConsumerOptions */
343
+ export interface PollConsumerOptions extends InboundConsumerOptions {
344
+ /**
345
+ * Messages to request per poll (default: 5).
346
+ * All messages in the batch are processed sequentially before the next poll.
347
+ * At-least-once: messages not ACKed within `vt` seconds become visible again.
348
+ */
349
+ batch?: number;
350
+ /**
351
+ * Milliseconds to sleep between polls when the queue is empty (default: 2000).
352
+ * Prevents hot-polling against the Events Service.
353
+ */
354
+ idleDelayMs?: number;
355
+ }
356
+ /**
357
+ * Start a worker-pull consumer that polls POST /events/queue/poll, processes
358
+ * each message via withInboundEvent(), and ACKs on success.
359
+ *
360
+ * **This is the recommended pattern for DO App Platform** (and any platform that
361
+ * doesn't support long-lived SSE connections). Unlike startInboundConsumer (SSE),
362
+ * each poll is a short HTTP request — no persistent connections, no proxy timeouts.
363
+ *
364
+ * ## Loop flow (sequential, never overlaps):
365
+ * 1. POST /events/queue/poll → up to `batch` messages
366
+ * 2. For each message (in order):
367
+ * a. withInboundEvent(db, msg, handler) → ACID tx + ACK
368
+ * b. On failure: NACK → PGMQ re-delivers after VT expires
369
+ * 3. Sleep `idleDelayMs` if queue was empty, 100ms pause between batches
370
+ * 4. Repeat — next poll only fires after the current batch is fully processed
371
+ *
372
+ * ## ACID / at-least-once guarantee:
373
+ * Same as startInboundConsumer — withInboundEvent() wraps each message in a
374
+ * Prisma transaction (inbound_events INSERT + handler). If the handler fails,
375
+ * no ACK is sent — PGMQ re-delivers after VT. If ACK fails after a successful
376
+ * transaction, PGMQ re-delivers but the UNIQUE(msg_id) constraint prevents
377
+ * double-processing (duplicate is ACKed and skipped).
378
+ *
379
+ * ## Competing consumers:
380
+ * Multiple replicas can all call startPollConsumer() on the same queue.
381
+ * PGMQ's FOR UPDATE SKIP LOCKED ensures each replica gets different messages.
382
+ *
383
+ * @param db - Prisma client (must have `inboundEvent` model)
384
+ * @param options - Consumer configuration
385
+ * @returns - AbortController — call `.abort()` for graceful shutdown
386
+ *
387
+ * @example
388
+ * // Drop-in replacement for startInboundConsumer:
389
+ * const consumer = startPollConsumer(db, {
390
+ * queueName: 'jira_queue',
391
+ * batch: 5, // process up to 5 messages per poll
392
+ * vt: 60, // 60s visibility timeout — must be > max handler time
393
+ * idleDelayMs: 2000, // sleep 2s when queue is empty
394
+ * handlers: {
395
+ * 'nova_ticketing_service.ticket_created': async (tx, event) => {
396
+ * await tx.jiraIssue.upsert({ ... });
397
+ * return { status: 'processed' };
398
+ * },
399
+ * },
400
+ * });
401
+ *
402
+ * // Graceful shutdown
403
+ * process.on('SIGTERM', () => consumer.abort());
404
+ */
405
+ export declare function startPollConsumer(db: any, options: PollConsumerOptions): AbortController;
342
406
  /**
343
407
  * Class-based client when you need more control than the top-level functions.
344
408
  * Useful when you want to pass configuration explicitly rather than relying on env vars.
package/dist/events.js CHANGED
@@ -883,6 +883,196 @@ export function startInboundConsumer(db, options) {
883
883
  });
884
884
  return abort;
885
885
  }
886
+ /**
887
+ * Start a worker-pull consumer that polls POST /events/queue/poll, processes
888
+ * each message via withInboundEvent(), and ACKs on success.
889
+ *
890
+ * **This is the recommended pattern for DO App Platform** (and any platform that
891
+ * doesn't support long-lived SSE connections). Unlike startInboundConsumer (SSE),
892
+ * each poll is a short HTTP request — no persistent connections, no proxy timeouts.
893
+ *
894
+ * ## Loop flow (sequential, never overlaps):
895
+ * 1. POST /events/queue/poll → up to `batch` messages
896
+ * 2. For each message (in order):
897
+ * a. withInboundEvent(db, msg, handler) → ACID tx + ACK
898
+ * b. On failure: NACK → PGMQ re-delivers after VT expires
899
+ * 3. Sleep `idleDelayMs` if queue was empty, 100ms pause between batches
900
+ * 4. Repeat — next poll only fires after the current batch is fully processed
901
+ *
902
+ * ## ACID / at-least-once guarantee:
903
+ * Same as startInboundConsumer — withInboundEvent() wraps each message in a
904
+ * Prisma transaction (inbound_events INSERT + handler). If the handler fails,
905
+ * no ACK is sent — PGMQ re-delivers after VT. If ACK fails after a successful
906
+ * transaction, PGMQ re-delivers but the UNIQUE(msg_id) constraint prevents
907
+ * double-processing (duplicate is ACKed and skipped).
908
+ *
909
+ * ## Competing consumers:
910
+ * Multiple replicas can all call startPollConsumer() on the same queue.
911
+ * PGMQ's FOR UPDATE SKIP LOCKED ensures each replica gets different messages.
912
+ *
913
+ * @param db - Prisma client (must have `inboundEvent` model)
914
+ * @param options - Consumer configuration
915
+ * @returns - AbortController — call `.abort()` for graceful shutdown
916
+ *
917
+ * @example
918
+ * // Drop-in replacement for startInboundConsumer:
919
+ * const consumer = startPollConsumer(db, {
920
+ * queueName: 'jira_queue',
921
+ * batch: 5, // process up to 5 messages per poll
922
+ * vt: 60, // 60s visibility timeout — must be > max handler time
923
+ * idleDelayMs: 2000, // sleep 2s when queue is empty
924
+ * handlers: {
925
+ * 'nova_ticketing_service.ticket_created': async (tx, event) => {
926
+ * await tx.jiraIssue.upsert({ ... });
927
+ * return { status: 'processed' };
928
+ * },
929
+ * },
930
+ * });
931
+ *
932
+ * // Graceful shutdown
933
+ * process.on('SIGTERM', () => consumer.abort());
934
+ */
935
+ export function startPollConsumer(db, options) {
936
+ const { queueName, handlers, defaultHandler, vt = 30, batch = 5, idleDelayMs = 2_000, } = options;
937
+ const eventsUrl = options.eventsUrl ?? process.env.NOVA_EVENTS_SERVICE_URL;
938
+ const serviceToken = options.serviceToken ?? process.env.NOVA_SERVICE_TOKEN;
939
+ if (!eventsUrl || !serviceToken) {
940
+ console.error('[nova/events] startPollConsumer: NOVA_EVENTS_SERVICE_URL and NOVA_SERVICE_TOKEN ' +
941
+ 'must be set. Poll consumer will NOT start.');
942
+ return new AbortController();
943
+ }
944
+ const abort = new AbortController();
945
+ /** Derive the event topic from a raw PGMQ message payload */
946
+ function _deriveEventType(payload) {
947
+ if (typeof payload.topic === 'string')
948
+ return payload.topic;
949
+ if (typeof payload.entity_type === 'string' && typeof payload.action === 'string') {
950
+ return `${payload.entity_type}.${payload.action}`;
951
+ }
952
+ return 'unknown.event';
953
+ }
954
+ /** Send a NACK to the events service to release the visibility timeout early */
955
+ async function _nack(msgId, readCt) {
956
+ try {
957
+ await fetch(`${eventsUrl}/events/queue/nack`, {
958
+ method: 'POST',
959
+ headers: {
960
+ 'Authorization': `Bearer ${serviceToken}`,
961
+ 'Content-Type': 'application/json',
962
+ },
963
+ body: JSON.stringify({ queue: queueName, msg_id: msgId }),
964
+ });
965
+ }
966
+ catch (err) {
967
+ console.error(`[nova/events] NACK failed for msg_id=${msgId} read_ct=${readCt}:`, String(err).slice(0, 200));
968
+ }
969
+ }
970
+ /** Sleep for ms, returning early if the consumer is aborted */
971
+ function _sleep(ms) {
972
+ return new Promise((resolve) => {
973
+ const t = setTimeout(resolve, ms);
974
+ abort.signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });
975
+ });
976
+ }
977
+ /** Sequential poll loop — one batch at a time, never overlaps */
978
+ async function _pollLoop() {
979
+ const tokenSuffix = serviceToken ? `***${serviceToken.slice(-4)}` : 'MISSING';
980
+ console.log(`[nova/events] Poll consumer starting — url=${eventsUrl} ` +
981
+ `token=${tokenSuffix} queue=${queueName} vt=${vt}s batch=${batch} idleDelay=${idleDelayMs}ms`);
982
+ while (!abort.signal.aborted) {
983
+ let messages = [];
984
+ // ── 1. Poll for a batch of messages ────────────────────────────────────
985
+ try {
986
+ const res = await fetch(`${eventsUrl}/events/queue/poll`, {
987
+ method: 'POST',
988
+ headers: {
989
+ 'Authorization': `Bearer ${serviceToken}`,
990
+ 'Content-Type': 'application/json',
991
+ },
992
+ body: JSON.stringify({ queue: queueName, vt, batch }),
993
+ signal: abort.signal,
994
+ });
995
+ if (!res.ok) {
996
+ const text = await res.text().catch(() => '');
997
+ console.error(`[nova/events] Poll failed for queue "${queueName}": HTTP ${res.status} — ${text.slice(0, 200)}`);
998
+ await _sleep(idleDelayMs);
999
+ continue;
1000
+ }
1001
+ const data = await res.json();
1002
+ messages = data.messages ?? [];
1003
+ }
1004
+ catch (err) {
1005
+ if (abort.signal.aborted)
1006
+ return;
1007
+ const isAbortError = err?.name === 'AbortError' || err?.code === 20;
1008
+ if (isAbortError)
1009
+ return;
1010
+ console.error(`[nova/events] Poll network error for queue "${queueName}":`, String(err).slice(0, 500));
1011
+ await _sleep(idleDelayMs);
1012
+ continue;
1013
+ }
1014
+ // Queue empty — sleep before polling again
1015
+ if (messages.length === 0) {
1016
+ await _sleep(idleDelayMs);
1017
+ continue;
1018
+ }
1019
+ // ── 2. Process each message sequentially ───────────────────────────────
1020
+ // Processing is intentionally sequential (not concurrent) so that:
1021
+ // a) Messages for the same entity are handled in order
1022
+ // b) No poll fires until the current batch is fully processed
1023
+ for (const raw of messages) {
1024
+ if (abort.signal.aborted)
1025
+ return;
1026
+ // DLQ-routed messages — already moved to DLQ by the server; just log
1027
+ if (raw.event === 'dlq') {
1028
+ console.warn(`[nova/events] msg_id=${raw.msg_id} moved to DLQ "${raw.dlq_queue}" ` +
1029
+ `(read_ct=${raw.read_ct}) — skipping processing`);
1030
+ continue;
1031
+ }
1032
+ const payload = raw.message;
1033
+ const eventType = _deriveEventType(payload);
1034
+ const handler = handlers[eventType] ?? defaultHandler;
1035
+ const msg = {
1036
+ msg_id: raw.msg_id,
1037
+ queue: queueName,
1038
+ payload,
1039
+ read_ct: raw.read_ct,
1040
+ };
1041
+ if (!handler) {
1042
+ // No handler registered and no default — ACK to unblock the queue
1043
+ console.log(`[nova/events] No handler for event type "${eventType}" (msg_id=${raw.msg_id}) — ACK and skip`);
1044
+ try {
1045
+ await fetch(`${eventsUrl}/events/queue/ack`, {
1046
+ method: 'POST',
1047
+ headers: { 'Authorization': `Bearer ${serviceToken}`, 'Content-Type': 'application/json' },
1048
+ body: JSON.stringify({ queue: queueName, msg_id: raw.msg_id }),
1049
+ });
1050
+ }
1051
+ catch { /* best-effort */ }
1052
+ continue;
1053
+ }
1054
+ try {
1055
+ const outcome = await withInboundEvent(db, msg, handler);
1056
+ console.log(`[nova/events] msg_id=${raw.msg_id} event="${eventType}" status=${outcome.status} read_ct=${raw.read_ct}`);
1057
+ }
1058
+ catch (err) {
1059
+ console.error(`[nova/events] Handler failed for msg_id=${raw.msg_id} event="${eventType}" read_ct=${raw.read_ct}:`, err);
1060
+ // NACK — extends visibility timeout so PGMQ will re-deliver
1061
+ await _nack(raw.msg_id, raw.read_ct);
1062
+ }
1063
+ }
1064
+ // Short pause between non-empty batches — breathing room for the
1065
+ // Prisma connection pool before the next poll
1066
+ await _sleep(100);
1067
+ }
1068
+ console.log(`[nova/events] Poll consumer for queue "${queueName}" shut down gracefully`);
1069
+ }
1070
+ // Start the poll loop (non-blocking — does not await)
1071
+ _pollLoop().catch((err) => {
1072
+ console.error(`[nova/events] startPollConsumer fatal error for queue "${queueName}":`, err);
1073
+ });
1074
+ return abort;
1075
+ }
886
1076
  // ── NovaEventsClient ───────────────────────────────────────────────────────────
887
1077
  /**
888
1078
  * Class-based client when you need more control than the top-level functions.
package/dist/index.d.ts CHANGED
@@ -293,8 +293,8 @@ export declare function runHttpServer<T extends WorkerDef>(def: T, opts?: HttpSe
293
293
  export declare function runDualMode<T extends WorkerDef>(def: T, opts?: {
294
294
  port?: number;
295
295
  }): void;
296
- export { withServiceEventOutbox, withEventOutbox, isIntegrationSync, queueEvent, logEvent, startOutboxRelay, NovaEventsClient, } from './events.js';
297
- export type { QueueEventPayload, LogEventPayload, OutboxRelayOptions, ServiceEmitFn, } from './events.js';
296
+ export { withServiceEventOutbox, withEventOutbox, isIntegrationSync, queueEvent, logEvent, startOutboxRelay, startInboundConsumer, startPollConsumer, NovaEventsClient, } from './events.js';
297
+ export type { QueueEventPayload, LogEventPayload, OutboxRelayOptions, ServiceEmitFn, InboundConsumerOptions, PollConsumerOptions, } from './events.js';
298
298
  export type { ZodTypeAny as SchemaAny, ZodTypeAny };
299
299
  export { parseNovaSpec } from "./parseSpec.js";
300
300
  export type { NovaSpec } from "./parseSpec.js";
package/dist/index.js CHANGED
@@ -865,7 +865,7 @@ export function runDualMode(def, opts = {}) {
865
865
  /*──────────────── Event Outbox (re-exports for convenience) ───────────────*/
866
866
  // Full API is also available via '@newhomestar/sdk/events' subpath.
867
867
  // These re-exports allow import { withServiceEventOutbox } from '@newhomestar/sdk'.
868
- export { withServiceEventOutbox, withEventOutbox, isIntegrationSync, queueEvent, logEvent, startOutboxRelay, NovaEventsClient, } from './events.js';
868
+ export { withServiceEventOutbox, withEventOutbox, isIntegrationSync, queueEvent, logEvent, startOutboxRelay, startInboundConsumer, startPollConsumer, NovaEventsClient, } from './events.js';
869
869
  // YAML spec parsing utility
870
870
  export { parseNovaSpec } from "./parseSpec.js";
871
871
  // Integration definition API
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newhomestar/sdk",
3
- "version": "0.7.13",
3
+ "version": "0.7.14",
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": {