@newhomestar/sdk 0.7.13 → 0.7.15
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/credentials.d.ts +9 -1
- package/dist/credentials.js +31 -17
- package/dist/events.d.ts +64 -0
- package/dist/events.js +190 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +38 -3
- package/dist/next.d.ts +14 -0
- package/dist/next.js +17 -1
- package/package.json +58 -58
package/dist/credentials.d.ts
CHANGED
|
@@ -45,6 +45,12 @@ export declare class TokenExchangeError extends Error {
|
|
|
45
45
|
* Reads from PLATFORM_SUPABASE_URL + PLATFORM_SUPABASE_SERVICE_ROLE_KEY.
|
|
46
46
|
*/
|
|
47
47
|
export declare function createPlatformClient(): SupabaseClient;
|
|
48
|
+
/**
|
|
49
|
+
* Evict a cached token for the given integration slug and optional userId.
|
|
50
|
+
* Call this when an API returns 401 so the next resolveCredentials() call
|
|
51
|
+
* fetches a fresh token instead of re-using the revoked one.
|
|
52
|
+
*/
|
|
53
|
+
export declare function clearTokenCache(slug: string, userId?: string): void;
|
|
48
54
|
/**
|
|
49
55
|
* Resolves an access token for an integration, handling all auth modes.
|
|
50
56
|
*
|
|
@@ -90,7 +96,9 @@ export declare function emitPlatformEvent(platformDB: SupabaseClient, topic: str
|
|
|
90
96
|
* @param bearerToken - JWT to authenticate with the auth server
|
|
91
97
|
* @param userId - Optional user ID (for cache key differentiation)
|
|
92
98
|
*/
|
|
93
|
-
export declare function resolveCredentialsViaHttp(authBaseUrl: string, slug: string, bearerToken: string, userId?: string
|
|
99
|
+
export declare function resolveCredentialsViaHttp(authBaseUrl: string, slug: string, bearerToken: string, userId?: string, options?: {
|
|
100
|
+
forceRefresh?: boolean;
|
|
101
|
+
}): Promise<ResolvedCredentials>;
|
|
94
102
|
/**
|
|
95
103
|
* Detect which credential resolution strategy to use.
|
|
96
104
|
* Returns 'db' if PLATFORM_SUPABASE_* are available, 'http' if AUTH_ISSUER_BASE_URL
|
package/dist/credentials.js
CHANGED
|
@@ -78,6 +78,16 @@ const SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
|
|
|
78
78
|
function getCacheKey(integrationSlug, userId) {
|
|
79
79
|
return `${integrationSlug}:${userId ?? SYSTEM_USER_ID}`;
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Evict a cached token for the given integration slug and optional userId.
|
|
83
|
+
* Call this when an API returns 401 so the next resolveCredentials() call
|
|
84
|
+
* fetches a fresh token instead of re-using the revoked one.
|
|
85
|
+
*/
|
|
86
|
+
export function clearTokenCache(slug, userId) {
|
|
87
|
+
const key = getCacheKey(slug, userId);
|
|
88
|
+
const deleted = _tokenCache.delete(key);
|
|
89
|
+
console.log(`[nova-sdk] 🗑️ Cleared in-memory token cache for "${slug}" (key=${key}, existed=${deleted})`);
|
|
90
|
+
}
|
|
81
91
|
function getCachedToken(key) {
|
|
82
92
|
const cached = _tokenCache.get(key);
|
|
83
93
|
if (!cached)
|
|
@@ -613,20 +623,23 @@ function buildMtlsAgentFromPem(slug, certPem, keyPem) {
|
|
|
613
623
|
* Requires a valid JWT Bearer token (the same one that authenticated the
|
|
614
624
|
* inbound request to this container).
|
|
615
625
|
*
|
|
616
|
-
* @param authBaseUrl
|
|
617
|
-
* @param slug
|
|
618
|
-
* @param bearerToken
|
|
626
|
+
* @param authBaseUrl - Auth server base URL (e.g., "http://localhost:3001")
|
|
627
|
+
* @param slug - Integration slug (e.g., "adp")
|
|
628
|
+
* @param bearerToken - JWT to forward as Authorization header
|
|
629
|
+
* @param forceRefresh - When true, sends X-Nova-Token-Invalid: true to trigger a forced refresh
|
|
619
630
|
*/
|
|
620
|
-
async function fetchCredentialsFromAuthServer(authBaseUrl, slug, bearerToken) {
|
|
631
|
+
async function fetchCredentialsFromAuthServer(authBaseUrl, slug, bearerToken, forceRefresh = false) {
|
|
621
632
|
const url = `${authBaseUrl}/api/integrations/${encodeURIComponent(slug)}/credentials`;
|
|
622
|
-
console.log(`[nova-sdk] 🌐 Fetching credentials via HTTP: GET ${url}`);
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
633
|
+
console.log(`[nova-sdk] 🌐 Fetching credentials via HTTP: GET ${url}${forceRefresh ? " (force-refresh)" : ""}`);
|
|
634
|
+
const headers = {
|
|
635
|
+
Authorization: `Bearer ${bearerToken}`,
|
|
636
|
+
Accept: "application/json",
|
|
637
|
+
};
|
|
638
|
+
// Signal the auth server to discard its stored token and force a refresh
|
|
639
|
+
if (forceRefresh) {
|
|
640
|
+
headers["X-Nova-Token-Invalid"] = "true";
|
|
641
|
+
}
|
|
642
|
+
const res = await fetch(url, { method: "GET", headers });
|
|
630
643
|
if (!res.ok) {
|
|
631
644
|
const errBody = await res.text().catch(() => "");
|
|
632
645
|
if (res.status === 401) {
|
|
@@ -656,12 +669,13 @@ async function fetchCredentialsFromAuthServer(authBaseUrl, slug, bearerToken) {
|
|
|
656
669
|
* @param bearerToken - JWT to authenticate with the auth server
|
|
657
670
|
* @param userId - Optional user ID (for cache key differentiation)
|
|
658
671
|
*/
|
|
659
|
-
export async function resolveCredentialsViaHttp(authBaseUrl, slug, bearerToken, userId) {
|
|
672
|
+
export async function resolveCredentialsViaHttp(authBaseUrl, slug, bearerToken, userId, options) {
|
|
660
673
|
const effectiveUserId = userId ?? SYSTEM_USER_ID;
|
|
661
674
|
const cacheKey = getCacheKey(slug, effectiveUserId);
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
675
|
+
const forceRefresh = options?.forceRefresh ?? false;
|
|
676
|
+
console.log(`[nova-sdk] 🔑 Resolving credentials for "${slug}" via HTTP (userId: ${effectiveUserId}${forceRefresh ? ", forceRefresh=true" : ""})`);
|
|
677
|
+
// ── Step 1: Check in-memory cache (skip if forcing refresh) ──────────
|
|
678
|
+
const cached = forceRefresh ? null : getCachedToken(cacheKey);
|
|
665
679
|
if (cached) {
|
|
666
680
|
// Derive authMode: use stored value if available, otherwise infer from httpsAgent
|
|
667
681
|
const cachedAuthMode = cached.authMode ?? (cached.httpsAgent ? "mtls" : "client_credentials");
|
|
@@ -675,7 +689,7 @@ export async function resolveCredentialsViaHttp(authBaseUrl, slug, bearerToken,
|
|
|
675
689
|
};
|
|
676
690
|
}
|
|
677
691
|
// ── Step 2: Fetch credentials from auth server ────────────────────────
|
|
678
|
-
const creds = await fetchCredentialsFromAuthServer(authBaseUrl, slug, bearerToken);
|
|
692
|
+
const creds = await fetchCredentialsFromAuthServer(authBaseUrl, slug, bearerToken, forceRefresh);
|
|
679
693
|
// ── Step 2b: Standard auth — use pre-resolved access token if available ──
|
|
680
694
|
if (creds.authMode === "standard") {
|
|
681
695
|
if (creds.accessToken) {
|
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
|
@@ -6,7 +6,7 @@ import { createServer } from "node:http";
|
|
|
6
6
|
import { os } from "@orpc/server";
|
|
7
7
|
import { RPCHandler } from "@orpc/server/node";
|
|
8
8
|
import { CORSPlugin } from "@orpc/server/plugins";
|
|
9
|
-
import { createPlatformClient, resolveCredentials as _resolveCredentials, resolveCredentialsViaHttp as _resolveCredentialsViaHttp, detectCredentialStrategy, integrationFetch as _integrationFetch, } from "./credentials.js";
|
|
9
|
+
import { createPlatformClient, resolveCredentials as _resolveCredentials, resolveCredentialsViaHttp as _resolveCredentialsViaHttp, detectCredentialStrategy, integrationFetch as _integrationFetch, clearTokenCache as _clearTokenCache, } from "./credentials.js";
|
|
10
10
|
if (!process.env.RUNTIME_SUPABASE_URL) {
|
|
11
11
|
// local dev – read .env.local
|
|
12
12
|
dotenv.config({ path: ".env.local", override: true });
|
|
@@ -580,7 +580,42 @@ function buildCredentialCtx(defaultSlug, authToken) {
|
|
|
580
580
|
};
|
|
581
581
|
const ctxFetch = async (url, options, credentials) => {
|
|
582
582
|
const creds = credentials ?? _lastCreds ?? await ctxResolveCredentials();
|
|
583
|
-
|
|
583
|
+
const response = await _integrationFetch(url, creds, options);
|
|
584
|
+
// ── 401 auto-retry: token may have been revoked ──────────────────────────
|
|
585
|
+
// If the API returns 401 AND the caller didn't supply explicit credentials
|
|
586
|
+
// (i.e., we used the cached/resolved ones), clear the in-memory cache and
|
|
587
|
+
// force-refresh from the auth server, then retry once.
|
|
588
|
+
// This handles the "user re-authenticated but container still holds the old
|
|
589
|
+
// revoked token" scenario.
|
|
590
|
+
if (response.status === 401 && !credentials) {
|
|
591
|
+
console.warn(`[nova-sdk] ⚠️ 401 received from ${url} — clearing cache and force-refreshing credentials for "${defaultSlug}"`);
|
|
592
|
+
_clearTokenCache(defaultSlug);
|
|
593
|
+
_lastCreds = null;
|
|
594
|
+
let freshCreds;
|
|
595
|
+
if (strategy === "http") {
|
|
596
|
+
// HTTP strategy: signal auth server to bypass its stored token and refresh
|
|
597
|
+
const authBaseUrl = process.env.AUTH_ISSUER_BASE_URL;
|
|
598
|
+
if (!authToken) {
|
|
599
|
+
console.error("[nova-sdk] ❌ Cannot force-refresh: no authToken available");
|
|
600
|
+
return response; // return original 401
|
|
601
|
+
}
|
|
602
|
+
freshCreds = await _resolveCredentialsViaHttp(authBaseUrl, defaultSlug, authToken, undefined, { forceRefresh: true });
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
// DB strategy: just re-resolve (DB always reads latest from vault)
|
|
606
|
+
freshCreds = await ctxResolveCredentials();
|
|
607
|
+
}
|
|
608
|
+
_lastCreds = freshCreds;
|
|
609
|
+
// Only retry if we got a different (fresh) token — avoid an infinite loop
|
|
610
|
+
if (freshCreds.accessToken !== creds.accessToken) {
|
|
611
|
+
console.log(`[nova-sdk] 🔄 Retrying request with refreshed token for "${defaultSlug}"`);
|
|
612
|
+
return _integrationFetch(url, freshCreds, options);
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
console.warn(`[nova-sdk] ⚠️ Force-refresh returned the same token for "${defaultSlug}" — not retrying`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return response;
|
|
584
619
|
};
|
|
585
620
|
return { resolveCredentials: ctxResolveCredentials, fetch: ctxFetch };
|
|
586
621
|
}
|
|
@@ -865,7 +900,7 @@ export function runDualMode(def, opts = {}) {
|
|
|
865
900
|
/*──────────────── Event Outbox (re-exports for convenience) ───────────────*/
|
|
866
901
|
// Full API is also available via '@newhomestar/sdk/events' subpath.
|
|
867
902
|
// These re-exports allow import { withServiceEventOutbox } from '@newhomestar/sdk'.
|
|
868
|
-
export { withServiceEventOutbox, withEventOutbox, isIntegrationSync, queueEvent, logEvent, startOutboxRelay, NovaEventsClient, } from './events.js';
|
|
903
|
+
export { withServiceEventOutbox, withEventOutbox, isIntegrationSync, queueEvent, logEvent, startOutboxRelay, startInboundConsumer, startPollConsumer, NovaEventsClient, } from './events.js';
|
|
869
904
|
// YAML spec parsing utility
|
|
870
905
|
export { parseNovaSpec } from "./parseSpec.js";
|
|
871
906
|
// Integration definition API
|
package/dist/next.d.ts
CHANGED
|
@@ -303,9 +303,23 @@ export declare function buildPrismaPage(input: CursorPageInputType): {
|
|
|
303
303
|
id: 'desc';
|
|
304
304
|
}>;
|
|
305
305
|
};
|
|
306
|
+
/**
|
|
307
|
+
* Convert a snake_case string to camelCase.
|
|
308
|
+
* Used internally to normalise sort column names from API query params
|
|
309
|
+
* (snake_case) to Prisma model field names (camelCase).
|
|
310
|
+
*
|
|
311
|
+
* @example
|
|
312
|
+
* ```ts
|
|
313
|
+
* snakeToCamel('modified_at') // → 'modifiedAt'
|
|
314
|
+
* snakeToCamel('createdAt') // → 'createdAt' (no-op)
|
|
315
|
+
* ```
|
|
316
|
+
*/
|
|
317
|
+
export declare function snakeToCamel(s: string): string;
|
|
306
318
|
/**
|
|
307
319
|
* Build Prisma `skip`, `take`, and `orderBy` from an `OffsetPageInput`.
|
|
308
320
|
* Handles offset pagination and sort direction.
|
|
321
|
+
* Sort column names are automatically converted from snake_case (API convention)
|
|
322
|
+
* to camelCase (Prisma convention) via `snakeToCamel()`.
|
|
309
323
|
* Does NOT handle `search` or `filters` — apply those to the `where` clause manually.
|
|
310
324
|
*
|
|
311
325
|
* @example
|
package/dist/next.js
CHANGED
|
@@ -191,9 +191,25 @@ export function buildPrismaPage(input) {
|
|
|
191
191
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
|
192
192
|
};
|
|
193
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Convert a snake_case string to camelCase.
|
|
196
|
+
* Used internally to normalise sort column names from API query params
|
|
197
|
+
* (snake_case) to Prisma model field names (camelCase).
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* ```ts
|
|
201
|
+
* snakeToCamel('modified_at') // → 'modifiedAt'
|
|
202
|
+
* snakeToCamel('createdAt') // → 'createdAt' (no-op)
|
|
203
|
+
* ```
|
|
204
|
+
*/
|
|
205
|
+
export function snakeToCamel(s) {
|
|
206
|
+
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
207
|
+
}
|
|
194
208
|
/**
|
|
195
209
|
* Build Prisma `skip`, `take`, and `orderBy` from an `OffsetPageInput`.
|
|
196
210
|
* Handles offset pagination and sort direction.
|
|
211
|
+
* Sort column names are automatically converted from snake_case (API convention)
|
|
212
|
+
* to camelCase (Prisma convention) via `snakeToCamel()`.
|
|
197
213
|
* Does NOT handle `search` or `filters` — apply those to the `where` clause manually.
|
|
198
214
|
*
|
|
199
215
|
* @example
|
|
@@ -203,7 +219,7 @@ export function buildPrismaPage(input) {
|
|
|
203
219
|
* ```
|
|
204
220
|
*/
|
|
205
221
|
export function buildPrismaOffsetPage(input, defaultSort = 'createdAt') {
|
|
206
|
-
const col = input.sort ?? defaultSort;
|
|
222
|
+
const col = snakeToCamel(input.sort ?? defaultSort);
|
|
207
223
|
return {
|
|
208
224
|
skip: input.offset,
|
|
209
225
|
take: input.limit,
|
package/package.json
CHANGED
|
@@ -1,58 +1,58 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@newhomestar/sdk",
|
|
3
|
-
"version": "0.7.
|
|
4
|
-
"description": "Type-safe SDK for building Nova pipelines (workers & functions)",
|
|
5
|
-
"homepage": "https://github.com/newhomestar/nova-node-sdk#readme",
|
|
6
|
-
"bugs": {
|
|
7
|
-
"url": "https://github.com/newhomestar/nova-node-sdk/issues"
|
|
8
|
-
},
|
|
9
|
-
"repository": {
|
|
10
|
-
"type": "git",
|
|
11
|
-
"url": "git+https://github.com/newhomestar/nova-node-sdk.git"
|
|
12
|
-
},
|
|
13
|
-
"license": "ISC",
|
|
14
|
-
"author": "Christian Gomez",
|
|
15
|
-
"type": "module",
|
|
16
|
-
"main": "dist/index.js",
|
|
17
|
-
"types": "dist/index.d.ts",
|
|
18
|
-
"exports": {
|
|
19
|
-
".": {
|
|
20
|
-
"import": "./dist/index.js",
|
|
21
|
-
"types": "./dist/index.d.ts"
|
|
22
|
-
},
|
|
23
|
-
"./next": {
|
|
24
|
-
"import": "./dist/next.js",
|
|
25
|
-
"types": "./dist/next.d.ts"
|
|
26
|
-
},
|
|
27
|
-
"./events": {
|
|
28
|
-
"import": "./dist/events.js",
|
|
29
|
-
"types": "./dist/events.d.ts"
|
|
30
|
-
}
|
|
31
|
-
},
|
|
32
|
-
"files": [
|
|
33
|
-
"dist"
|
|
34
|
-
],
|
|
35
|
-
"scripts": {
|
|
36
|
-
"build": "tsc"
|
|
37
|
-
},
|
|
38
|
-
"dependencies": {
|
|
39
|
-
"@openfga/sdk": "^0.9.0",
|
|
40
|
-
"@orpc/openapi": "1.7.4",
|
|
41
|
-
"@orpc/server": "1.7.4",
|
|
42
|
-
"@supabase/supabase-js": "^2.39.0",
|
|
43
|
-
"body-parser": "^1.20.2",
|
|
44
|
-
"dotenv": "^16.4.3",
|
|
45
|
-
"express": "^4.18.2",
|
|
46
|
-
"express-oauth2-jwt-bearer": "^1.7.4",
|
|
47
|
-
"undici": "^7.24.4",
|
|
48
|
-
"yaml": "^2.7.1"
|
|
49
|
-
},
|
|
50
|
-
"peerDependencies": {
|
|
51
|
-
"zod": ">=4.0.0"
|
|
52
|
-
},
|
|
53
|
-
"devDependencies": {
|
|
54
|
-
"@types/node": "^20.11.17",
|
|
55
|
-
"typescript": "^5.4.4",
|
|
56
|
-
"zod": "^4.3.0"
|
|
57
|
-
}
|
|
58
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@newhomestar/sdk",
|
|
3
|
+
"version": "0.7.15",
|
|
4
|
+
"description": "Type-safe SDK for building Nova pipelines (workers & functions)",
|
|
5
|
+
"homepage": "https://github.com/newhomestar/nova-node-sdk#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/newhomestar/nova-node-sdk/issues"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/newhomestar/nova-node-sdk.git"
|
|
12
|
+
},
|
|
13
|
+
"license": "ISC",
|
|
14
|
+
"author": "Christian Gomez",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "dist/index.js",
|
|
17
|
+
"types": "dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"import": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts"
|
|
22
|
+
},
|
|
23
|
+
"./next": {
|
|
24
|
+
"import": "./dist/next.js",
|
|
25
|
+
"types": "./dist/next.d.ts"
|
|
26
|
+
},
|
|
27
|
+
"./events": {
|
|
28
|
+
"import": "./dist/events.js",
|
|
29
|
+
"types": "./dist/events.d.ts"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@openfga/sdk": "^0.9.0",
|
|
40
|
+
"@orpc/openapi": "1.7.4",
|
|
41
|
+
"@orpc/server": "1.7.4",
|
|
42
|
+
"@supabase/supabase-js": "^2.39.0",
|
|
43
|
+
"body-parser": "^1.20.2",
|
|
44
|
+
"dotenv": "^16.4.3",
|
|
45
|
+
"express": "^4.18.2",
|
|
46
|
+
"express-oauth2-jwt-bearer": "^1.7.4",
|
|
47
|
+
"undici": "^7.24.4",
|
|
48
|
+
"yaml": "^2.7.1"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"zod": ">=4.0.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^20.11.17",
|
|
55
|
+
"typescript": "^5.4.4",
|
|
56
|
+
"zod": "^4.3.0"
|
|
57
|
+
}
|
|
58
|
+
}
|