@newhomestar/sdk 0.6.8 → 0.6.9

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.
@@ -0,0 +1,226 @@
1
+ /** Full request body for POST /events/queue */
2
+ export interface QueueEventPayload {
3
+ entity_type: string;
4
+ action: string;
5
+ /** Auto-stamped from NOVA_SERVICE_SLUG if not provided */
6
+ source_service?: string;
7
+ /** ISO 8601 — when the event actually happened (defaults to now()) */
8
+ occurred_at?: string;
9
+ /** UUID linking a chain of related events across services */
10
+ correlation_id?: string;
11
+ /** The event_id that directly caused this event */
12
+ causation_id?: string;
13
+ /**
14
+ * Deduplication key from the outbox row.
15
+ * Auto-set by withEventOutbox(). Prevents double-posting on retries.
16
+ */
17
+ idempotency_key?: string;
18
+ /** Payload schema version (default: 1) */
19
+ event_version?: number;
20
+ entity_id?: string;
21
+ integration_id?: string;
22
+ diff?: {
23
+ before: Record<string, unknown>;
24
+ after: Record<string, unknown>;
25
+ changed_fields: string[];
26
+ };
27
+ attributes?: Record<string, unknown>;
28
+ metadata?: Record<string, unknown>;
29
+ performed_by_id?: string;
30
+ affected_user_id?: string;
31
+ }
32
+ /** Request body for POST /events (audit-only, no fan-out) */
33
+ export interface LogEventPayload extends QueueEventPayload {
34
+ }
35
+ /** Options for startOutboxRelay() */
36
+ export interface OutboxRelayOptions {
37
+ /** How often to poll for undelivered outbox rows (default: 60_000ms) */
38
+ intervalMs?: number;
39
+ /** Rows with attempts >= maxAttempts are left for manual reconciliation (default: 5) */
40
+ maxAttempts?: number;
41
+ /** Events service URL — defaults to NOVA_EVENTS_SERVICE_URL env var */
42
+ eventsUrl?: string;
43
+ /** Service JWT — defaults to NOVA_SERVICE_TOKEN env var */
44
+ serviceToken?: string;
45
+ }
46
+ /**
47
+ * Returns `true` when a request originated from an integration sync or worker,
48
+ * indicated by the `x-source: integration_sync` header.
49
+ *
50
+ * **Producer side** — pass `req` to `withServiceEventOutbox()` and this check is
51
+ * done for you automatically. Use this helper directly only when you need
52
+ * conditional logic beyond event emission (e.g. skipping a side-effect).
53
+ *
54
+ * **Consumer side** — integration workers MUST check this before writing back to
55
+ * the source system to avoid infinite sync loops:
56
+ *
57
+ * @example
58
+ * // In a worker handler — don't write back to BambooHR if BambooHR caused the event
59
+ * if (isIntegrationSync(event)) return { status: 'skipped' };
60
+ * await ctx.fetch(`https://api.bamboohr.com/...`, { method: 'PUT', ... });
61
+ *
62
+ * @example
63
+ * // In a Next.js route handler (producer side) — pass req instead of calling this
64
+ * const employee = await withServiceEventOutbox(db, req, async (tx, emit) => { ... });
65
+ */
66
+ export declare function isIntegrationSync(reqOrEvent: Request | Record<string, any>): boolean;
67
+ /** Emit function passed to the withServiceEventOutbox callback */
68
+ export type ServiceEmitFn = (topic: string, payload: Record<string, unknown>, idempotencyKey?: string) => void;
69
+ /**
70
+ * Run a Prisma transaction that atomically writes business data + outbox rows,
71
+ * then immediately relays events to the Nova Events Service.
72
+ *
73
+ * This is the **preferred** high-level API for emitting events from service
74
+ * route handlers. It is source-aware: if the incoming request carries
75
+ * `x-source: integration_sync` or `x-integration-id: <slug>`, those values are
76
+ * automatically stamped on every event's `metadata` field. This lets downstream
77
+ * consumers self-filter to prevent sync loops without suppressing events for
78
+ * other subscribers.
79
+ *
80
+ * Events **always fan out** to all subscriber queues — no events are silently
81
+ * dropped. An integration worker that receives an event it caused should check
82
+ * `isIntegrationSync(event)` and skip processing if true.
83
+ *
84
+ * @param db - Prisma client instance (must have `eventOutbox` model)
85
+ * @param reqOrCallback - Either the inbound `Request` (for source tagging) OR
86
+ * the callback directly (if source context is not needed)
87
+ * @param maybeCallback - The callback when `req` is provided as the second arg
88
+ *
89
+ * @example
90
+ * // Route handler — source metadata auto-stamped from request headers
91
+ * export async function POST(req: Request) {
92
+ * const employee = await withServiceEventOutbox(db, req, async (tx, emit) => {
93
+ * const row = await tx.hrisEmployee.create({ data });
94
+ * emit('employee.created', { id: row.id, firstName: row.firstName });
95
+ * return row;
96
+ * });
97
+ * return NextResponse.json(employee);
98
+ * }
99
+ *
100
+ * @example
101
+ * // Without a request (background job, scheduled task)
102
+ * const result = await withServiceEventOutbox(db, async (tx, emit) => {
103
+ * const row = await tx.hrisEmployee.update({ where: { id }, data });
104
+ * emit('employee.updated', { id, ...changes });
105
+ * return row;
106
+ * });
107
+ */
108
+ export declare function withServiceEventOutbox<R>(db: any, reqOrCallback: Request | ((tx: any, emit: ServiceEmitFn) => Promise<R>), maybeCallback?: (tx: any, emit: ServiceEmitFn) => Promise<R>): Promise<R>;
109
+ /**
110
+ * Run a Prisma transaction that atomically writes business data + an outbox row,
111
+ * then immediately relays the event to the Nova Events Service.
112
+ *
113
+ * If the relay fails (network error or non-2xx), the outbox row is preserved and
114
+ * startOutboxRelay() will retry it with exponential backoff.
115
+ *
116
+ * @param db - Prisma client instance (must have `eventOutbox` model)
117
+ * @param callback - Receives the transaction `tx`; must return `{ events, result }`
118
+ *
119
+ * @example
120
+ * const employee = await withEventOutbox(db, async (tx) => {
121
+ * const row = await tx.hrisEmployee.update({ where: { id }, data: updates });
122
+ * return {
123
+ * events: [{
124
+ * entity_type: 'employee',
125
+ * action: 'updated',
126
+ * entity_id: id,
127
+ * diff: { before: old, after: mapRow(row), changed_fields },
128
+ * }],
129
+ * result: row,
130
+ * };
131
+ * });
132
+ */
133
+ export declare function withEventOutbox<R>(db: any, callback: (tx: any) => Promise<{
134
+ events: QueueEventPayload[];
135
+ result: R;
136
+ }>): Promise<R>;
137
+ /**
138
+ * POST /events — Record an event in the audit log WITHOUT queue fan-out.
139
+ *
140
+ * Use for:
141
+ * • Inbound sync audits (metadata.source = "integration_sync")
142
+ * • Read events ("employee.viewed")
143
+ * • System events that don't need integration write-back
144
+ *
145
+ * For events that need downstream processing, use queueEvent() or withEventOutbox().
146
+ */
147
+ export declare function logEvent(payload: LogEventPayload): Promise<{
148
+ event_id: string;
149
+ status: string;
150
+ }>;
151
+ /**
152
+ * POST /events/queue — Log an event AND fan-out to all active subscriber queues.
153
+ *
154
+ * Use when there is NO database transaction context (e.g. a scheduled job,
155
+ * a webhook handler that doesn't update its own DB first).
156
+ *
157
+ * When you have a DB transaction, prefer withEventOutbox() for atomicity.
158
+ *
159
+ * @throws if the events service returns non-2xx (no retry logic)
160
+ */
161
+ export declare function queueEvent(payload: QueueEventPayload): Promise<{
162
+ event_id: string;
163
+ subscriber_count: number;
164
+ status: string;
165
+ }>;
166
+ /**
167
+ * Start a background interval that retries undelivered outbox rows.
168
+ *
169
+ * Call ONCE at service startup (e.g. in layout.tsx or the service entry point).
170
+ * Returns the interval handle so you can clear it in tests or graceful shutdown.
171
+ *
172
+ * Retry schedule (exponential backoff): 2^attempts * 5s
173
+ * attempt 0 → 5s, attempt 1 → 10s, attempt 2 → 20s,
174
+ * attempt 3 → 40s, attempt 4 → 80s
175
+ *
176
+ * Rows with attempts >= maxAttempts (default 5) are skipped and left for
177
+ * a manual reconciler — they won't be retried automatically.
178
+ *
179
+ * @param db - Prisma client instance (must have `eventOutbox` model)
180
+ * @param options - Relay configuration
181
+ *
182
+ * @example
183
+ * // src/app/layout.tsx
184
+ * import { startOutboxRelay } from '@newhomestar/sdk/events';
185
+ * import { db } from '@/lib/db';
186
+ *
187
+ * startOutboxRelay(db);
188
+ */
189
+ export declare function startOutboxRelay(db: any, options?: OutboxRelayOptions): ReturnType<typeof setInterval>;
190
+ /**
191
+ * Class-based client when you need more control than the top-level functions.
192
+ * Useful when you want to pass configuration explicitly rather than relying on env vars.
193
+ *
194
+ * @example
195
+ * const client = new NovaEventsClient({ serviceSlug: 'my-service' });
196
+ * await client.queueEvent({ entity_type: 'employee', action: 'updated', ... });
197
+ * client.startOutboxRelay(db);
198
+ */
199
+ export declare class NovaEventsClient {
200
+ private readonly eventsUrl;
201
+ private readonly serviceToken;
202
+ private readonly serviceSlug?;
203
+ constructor(options?: {
204
+ eventsUrl?: string;
205
+ serviceToken?: string;
206
+ serviceSlug?: string;
207
+ });
208
+ /** POST /events — audit log only */
209
+ logEvent(payload: LogEventPayload): Promise<{
210
+ event_id: string;
211
+ status: string;
212
+ }>;
213
+ /** POST /events/queue — log + fan-out to all subscriber queues */
214
+ queueEvent(payload: QueueEventPayload): Promise<{
215
+ event_id: string;
216
+ subscriber_count: number;
217
+ status: string;
218
+ }>;
219
+ /** Transactional outbox helper (use in services with a Prisma DB) */
220
+ withOutbox<R>(db: any, callback: (tx: any) => Promise<{
221
+ events: QueueEventPayload[];
222
+ result: R;
223
+ }>): Promise<R>;
224
+ /** Start the background outbox retry relay */
225
+ startOutboxRelay(db: any, options?: Omit<OutboxRelayOptions, 'eventsUrl' | 'serviceToken'>): NodeJS.Timeout;
226
+ }
package/dist/events.js ADDED
@@ -0,0 +1,503 @@
1
+ // @newhomestar/sdk/events — Transactional outbox + HTTP event client
2
+ // =====================================================================
3
+ // Producer-side helpers for emitting platform events safely.
4
+ //
5
+ // The central problem: a service can write to its DB successfully and
6
+ // then fail to call POST /events/queue, leaving state changed but the
7
+ // event missing. The transactional outbox solves this by writing data
8
+ // + outbox row in ONE atomic transaction — they succeed or fail together.
9
+ //
10
+ // ─── Usage ───────────────────────────────────────────────────────────
11
+ //
12
+ // Producer service (e.g. HRIS):
13
+ //
14
+ // import { withEventOutbox, startOutboxRelay } from '@newhomestar/sdk/events';
15
+ //
16
+ // // In a route handler — atomically writes data + emits event:
17
+ // const employee = await withEventOutbox(db, async (tx) => {
18
+ // const row = await tx.hrisEmployee.update({ where: { id }, data });
19
+ // return {
20
+ // events: [{ entity_type: 'employee', action: 'updated', entity_id: id, ... }],
21
+ // result: row,
22
+ // };
23
+ // });
24
+ //
25
+ // // In layout.tsx — starts the retry loop for any failed relays:
26
+ // startOutboxRelay(db);
27
+ //
28
+ // ─── Required env vars ───────────────────────────────────────────────
29
+ // NOVA_EVENTS_SERVICE_URL — URL of the nova-events-service
30
+ // NOVA_SERVICE_TOKEN — JWT with scope to POST /events/queue
31
+ // NOVA_SERVICE_SLUG — Stamped as source_service on every event
32
+ // =====================================================================
33
+ import dotenv from 'dotenv';
34
+ // Load .env.local in dev if NOVA_EVENTS_SERVICE_URL not already set
35
+ if (!process.env.NOVA_EVENTS_SERVICE_URL) {
36
+ dotenv.config({ path: '.env.local', override: false });
37
+ }
38
+ // ── Internal helpers ───────────────────────────────────────────────────────────
39
+ function _getEnvOrThrow(key, context) {
40
+ const val = process.env[key];
41
+ if (!val)
42
+ throw new Error(`[${context}] ${key} env var is required but not set`);
43
+ return val;
44
+ }
45
+ /**
46
+ * Relay a single outbox row to the events service and update the outbox.
47
+ * Used by both withEventOutbox (immediate relay) and startOutboxRelay (retry loop).
48
+ */
49
+ async function _relayRow(db, row, eventsUrl, serviceToken) {
50
+ let lastError = null;
51
+ try {
52
+ const body = {
53
+ ...row.payload,
54
+ idempotency_key: row.idempotencyKey,
55
+ };
56
+ const res = await fetch(`${eventsUrl}/events/queue`, {
57
+ method: 'POST',
58
+ headers: {
59
+ 'Authorization': `Bearer ${serviceToken}`,
60
+ 'Content-Type': 'application/json',
61
+ 'Idempotency-Key': row.idempotencyKey,
62
+ },
63
+ body: JSON.stringify(body),
64
+ });
65
+ if (res.ok) {
66
+ await db.eventOutbox.update({
67
+ where: { id: row.id },
68
+ data: { delivered: true, deliveredAt: new Date() },
69
+ });
70
+ return;
71
+ }
72
+ // Non-2xx: treat as transient failure, update error fields
73
+ const text = await res.text().catch(() => `HTTP ${res.status}`);
74
+ lastError = `HTTP ${res.status}: ${text}`.slice(0, 500);
75
+ }
76
+ catch (err) {
77
+ lastError = String(err).slice(0, 500);
78
+ }
79
+ // Relay failed — record the attempt
80
+ await db.eventOutbox.update({
81
+ where: { id: row.id },
82
+ data: {
83
+ attempts: { increment: 1 },
84
+ lastAttemptAt: new Date(),
85
+ lastError,
86
+ },
87
+ }).catch((updateErr) => {
88
+ console.error('[nova/events] Failed to update outbox row after relay failure:', updateErr);
89
+ });
90
+ }
91
+ // ── isIntegrationSync ─────────────────────────────────────────────────────────
92
+ /**
93
+ * Returns `true` when a request originated from an integration sync or worker,
94
+ * indicated by the `x-source: integration_sync` header.
95
+ *
96
+ * **Producer side** — pass `req` to `withServiceEventOutbox()` and this check is
97
+ * done for you automatically. Use this helper directly only when you need
98
+ * conditional logic beyond event emission (e.g. skipping a side-effect).
99
+ *
100
+ * **Consumer side** — integration workers MUST check this before writing back to
101
+ * the source system to avoid infinite sync loops:
102
+ *
103
+ * @example
104
+ * // In a worker handler — don't write back to BambooHR if BambooHR caused the event
105
+ * if (isIntegrationSync(event)) return { status: 'skipped' };
106
+ * await ctx.fetch(`https://api.bamboohr.com/...`, { method: 'PUT', ... });
107
+ *
108
+ * @example
109
+ * // In a Next.js route handler (producer side) — pass req instead of calling this
110
+ * const employee = await withServiceEventOutbox(db, req, async (tx, emit) => { ... });
111
+ */
112
+ export function isIntegrationSync(reqOrEvent) {
113
+ // Next.js / fetch Request object
114
+ if (typeof reqOrEvent.headers?.get === 'function') {
115
+ return reqOrEvent.headers.get('x-source') === 'integration_sync';
116
+ }
117
+ // Plain event/message object (consumer side: check metadata stamped at emit time)
118
+ const meta = reqOrEvent?.metadata;
119
+ return meta?.source === 'integration_sync';
120
+ }
121
+ /**
122
+ * Run a Prisma transaction that atomically writes business data + outbox rows,
123
+ * then immediately relays events to the Nova Events Service.
124
+ *
125
+ * This is the **preferred** high-level API for emitting events from service
126
+ * route handlers. It is source-aware: if the incoming request carries
127
+ * `x-source: integration_sync` or `x-integration-id: <slug>`, those values are
128
+ * automatically stamped on every event's `metadata` field. This lets downstream
129
+ * consumers self-filter to prevent sync loops without suppressing events for
130
+ * other subscribers.
131
+ *
132
+ * Events **always fan out** to all subscriber queues — no events are silently
133
+ * dropped. An integration worker that receives an event it caused should check
134
+ * `isIntegrationSync(event)` and skip processing if true.
135
+ *
136
+ * @param db - Prisma client instance (must have `eventOutbox` model)
137
+ * @param reqOrCallback - Either the inbound `Request` (for source tagging) OR
138
+ * the callback directly (if source context is not needed)
139
+ * @param maybeCallback - The callback when `req` is provided as the second arg
140
+ *
141
+ * @example
142
+ * // Route handler — source metadata auto-stamped from request headers
143
+ * export async function POST(req: Request) {
144
+ * const employee = await withServiceEventOutbox(db, req, async (tx, emit) => {
145
+ * const row = await tx.hrisEmployee.create({ data });
146
+ * emit('employee.created', { id: row.id, firstName: row.firstName });
147
+ * return row;
148
+ * });
149
+ * return NextResponse.json(employee);
150
+ * }
151
+ *
152
+ * @example
153
+ * // Without a request (background job, scheduled task)
154
+ * const result = await withServiceEventOutbox(db, async (tx, emit) => {
155
+ * const row = await tx.hrisEmployee.update({ where: { id }, data });
156
+ * emit('employee.updated', { id, ...changes });
157
+ * return row;
158
+ * });
159
+ */
160
+ export async function withServiceEventOutbox(db, reqOrCallback, maybeCallback) {
161
+ // ── Resolve overloads ────────────────────────────────────────────────────
162
+ const req = typeof reqOrCallback !== 'function' ? reqOrCallback : undefined;
163
+ const callback = (typeof reqOrCallback === 'function' ? reqOrCallback : maybeCallback);
164
+ const eventsUrl = process.env.NOVA_EVENTS_SERVICE_URL;
165
+ const serviceToken = process.env.NOVA_SERVICE_TOKEN;
166
+ const serviceSlug = process.env.NOVA_SERVICE_SLUG;
167
+ if (!eventsUrl || !serviceToken) {
168
+ throw new Error('[withServiceEventOutbox] NOVA_EVENTS_SERVICE_URL and NOVA_SERVICE_TOKEN must be set. ' +
169
+ 'Add them to your .env.local or deployment environment.');
170
+ }
171
+ // ── Extract source metadata from request headers ─────────────────────────
172
+ // x-source: set by integration sync handlers (value: "integration_sync")
173
+ // x-integration-id: set by integration workers to identify the source system
174
+ const xSource = req?.headers?.get('x-source') ?? 'user';
175
+ const integrationId = req?.headers?.get('x-integration-id') ?? undefined;
176
+ const stagedEmits = [];
177
+ const outboxRows = [];
178
+ // ── Atomic: callback + outbox INSERTs in one transaction ─────────────────
179
+ const result = await db.$transaction(async (tx) => {
180
+ // The emit fn is synchronous — events are staged and written after callback returns
181
+ const emit = (topic, payload, idempotencyKey) => {
182
+ stagedEmits.push({ topic, payload, idempotencyKey });
183
+ };
184
+ const callbackResult = await callback(tx, emit);
185
+ // Write one outbox row per staged emit
186
+ for (const staged of stagedEmits) {
187
+ // ── Derive entity_type + action from topic string ──────────────────
188
+ // Supports dot notation ("employee.created" → entity_type=employee, action=created)
189
+ // dotted path ("hris.employee.created" → entity_type=hris.employee, action=created)
190
+ // UPPER_SNAKE ("EMPLOYEE_CREATED" → entity_type=employee, action=created)
191
+ // bare string ("employee" → entity_type=employee, action=event)
192
+ let entity_type;
193
+ let action;
194
+ if (staged.topic.includes('.')) {
195
+ const lastDot = staged.topic.lastIndexOf('.');
196
+ entity_type = staged.topic.slice(0, lastDot);
197
+ action = staged.topic.slice(lastDot + 1);
198
+ }
199
+ else if (staged.topic.includes('_')) {
200
+ const firstUnderscore = staged.topic.indexOf('_');
201
+ entity_type = staged.topic.slice(0, firstUnderscore).toLowerCase();
202
+ action = staged.topic.slice(firstUnderscore + 1).toLowerCase().replace(/_/g, '.');
203
+ }
204
+ else {
205
+ entity_type = staged.topic.toLowerCase();
206
+ action = 'event';
207
+ }
208
+ const fullPayload = {
209
+ entity_type,
210
+ action,
211
+ source_service: serviceSlug ?? undefined,
212
+ attributes: staged.payload,
213
+ metadata: {
214
+ source: xSource,
215
+ ...(integrationId ? { integration_id: integrationId } : {}),
216
+ },
217
+ };
218
+ const row = await tx.eventOutbox.create({
219
+ data: {
220
+ eventType: staged.topic,
221
+ payload: fullPayload,
222
+ ...(staged.idempotencyKey
223
+ ? { idempotencyKey: staged.idempotencyKey }
224
+ : {}),
225
+ },
226
+ select: { id: true, idempotencyKey: true },
227
+ });
228
+ outboxRows.push({
229
+ id: row.id,
230
+ idempotencyKey: row.idempotencyKey,
231
+ payload: fullPayload,
232
+ });
233
+ }
234
+ return callbackResult;
235
+ });
236
+ // ── Immediate relay: best-effort POST after commit ────────────────────────
237
+ await Promise.allSettled(outboxRows.map(row => _relayRow(db, row, eventsUrl, serviceToken)));
238
+ return result;
239
+ }
240
+ // ── withEventOutbox ────────────────────────────────────────────────────────────
241
+ /**
242
+ * Run a Prisma transaction that atomically writes business data + an outbox row,
243
+ * then immediately relays the event to the Nova Events Service.
244
+ *
245
+ * If the relay fails (network error or non-2xx), the outbox row is preserved and
246
+ * startOutboxRelay() will retry it with exponential backoff.
247
+ *
248
+ * @param db - Prisma client instance (must have `eventOutbox` model)
249
+ * @param callback - Receives the transaction `tx`; must return `{ events, result }`
250
+ *
251
+ * @example
252
+ * const employee = await withEventOutbox(db, async (tx) => {
253
+ * const row = await tx.hrisEmployee.update({ where: { id }, data: updates });
254
+ * return {
255
+ * events: [{
256
+ * entity_type: 'employee',
257
+ * action: 'updated',
258
+ * entity_id: id,
259
+ * diff: { before: old, after: mapRow(row), changed_fields },
260
+ * }],
261
+ * result: row,
262
+ * };
263
+ * });
264
+ */
265
+ export async function withEventOutbox(db, callback) {
266
+ const eventsUrl = process.env.NOVA_EVENTS_SERVICE_URL;
267
+ const serviceToken = process.env.NOVA_SERVICE_TOKEN;
268
+ const serviceSlug = process.env.NOVA_SERVICE_SLUG;
269
+ if (!eventsUrl || !serviceToken) {
270
+ throw new Error('[withEventOutbox] NOVA_EVENTS_SERVICE_URL and NOVA_SERVICE_TOKEN must be set. ' +
271
+ 'Add them to your .env.local or deployment environment.');
272
+ }
273
+ // Rows inserted in the transaction — relayed after commit
274
+ const outboxRows = [];
275
+ // ── Atomic: business write + outbox INSERT in one transaction ──────────────
276
+ const result = await db.$transaction(async (tx) => {
277
+ const { events, result: callbackResult } = await callback(tx);
278
+ for (const event of events) {
279
+ const fullPayload = {
280
+ ...event,
281
+ source_service: event.source_service ?? serviceSlug ?? undefined,
282
+ };
283
+ const row = await tx.eventOutbox.create({
284
+ data: {
285
+ eventType: `${event.entity_type}.${event.action}`,
286
+ payload: fullPayload,
287
+ },
288
+ select: { id: true, idempotencyKey: true },
289
+ });
290
+ outboxRows.push({
291
+ id: row.id,
292
+ idempotencyKey: row.idempotencyKey,
293
+ payload: fullPayload,
294
+ });
295
+ }
296
+ return callbackResult;
297
+ });
298
+ // ── Immediate relay: best-effort POST after commit ─────────────────────────
299
+ // Failures are recorded in the outbox and retried by startOutboxRelay().
300
+ // We fire all relays concurrently (most calls have a single event anyway).
301
+ await Promise.allSettled(outboxRows.map(row => _relayRow(db, row, eventsUrl, serviceToken)));
302
+ return result;
303
+ }
304
+ // ── logEvent ───────────────────────────────────────────────────────────────────
305
+ /**
306
+ * POST /events — Record an event in the audit log WITHOUT queue fan-out.
307
+ *
308
+ * Use for:
309
+ * • Inbound sync audits (metadata.source = "integration_sync")
310
+ * • Read events ("employee.viewed")
311
+ * • System events that don't need integration write-back
312
+ *
313
+ * For events that need downstream processing, use queueEvent() or withEventOutbox().
314
+ */
315
+ export async function logEvent(payload) {
316
+ const eventsUrl = _getEnvOrThrow('NOVA_EVENTS_SERVICE_URL', 'logEvent');
317
+ const serviceToken = _getEnvOrThrow('NOVA_SERVICE_TOKEN', 'logEvent');
318
+ const serviceSlug = process.env.NOVA_SERVICE_SLUG;
319
+ const res = await fetch(`${eventsUrl}/events`, {
320
+ method: 'POST',
321
+ headers: {
322
+ 'Authorization': `Bearer ${serviceToken}`,
323
+ 'Content-Type': 'application/json',
324
+ },
325
+ body: JSON.stringify({
326
+ ...payload,
327
+ source_service: payload.source_service ?? serviceSlug,
328
+ }),
329
+ });
330
+ if (!res.ok) {
331
+ const text = await res.text().catch(() => '');
332
+ throw new Error(`[logEvent] HTTP ${res.status}: ${text}`);
333
+ }
334
+ return res.json();
335
+ }
336
+ // ── queueEvent ─────────────────────────────────────────────────────────────────
337
+ /**
338
+ * POST /events/queue — Log an event AND fan-out to all active subscriber queues.
339
+ *
340
+ * Use when there is NO database transaction context (e.g. a scheduled job,
341
+ * a webhook handler that doesn't update its own DB first).
342
+ *
343
+ * When you have a DB transaction, prefer withEventOutbox() for atomicity.
344
+ *
345
+ * @throws if the events service returns non-2xx (no retry logic)
346
+ */
347
+ export async function queueEvent(payload) {
348
+ const eventsUrl = _getEnvOrThrow('NOVA_EVENTS_SERVICE_URL', 'queueEvent');
349
+ const serviceToken = _getEnvOrThrow('NOVA_SERVICE_TOKEN', 'queueEvent');
350
+ const serviceSlug = process.env.NOVA_SERVICE_SLUG;
351
+ const res = await fetch(`${eventsUrl}/events/queue`, {
352
+ method: 'POST',
353
+ headers: {
354
+ 'Authorization': `Bearer ${serviceToken}`,
355
+ 'Content-Type': 'application/json',
356
+ },
357
+ body: JSON.stringify({
358
+ ...payload,
359
+ source_service: payload.source_service ?? serviceSlug,
360
+ }),
361
+ });
362
+ if (!res.ok) {
363
+ const text = await res.text().catch(() => '');
364
+ throw new Error(`[queueEvent] HTTP ${res.status}: ${text}`);
365
+ }
366
+ return res.json();
367
+ }
368
+ // ── startOutboxRelay ───────────────────────────────────────────────────────────
369
+ /**
370
+ * Start a background interval that retries undelivered outbox rows.
371
+ *
372
+ * Call ONCE at service startup (e.g. in layout.tsx or the service entry point).
373
+ * Returns the interval handle so you can clear it in tests or graceful shutdown.
374
+ *
375
+ * Retry schedule (exponential backoff): 2^attempts * 5s
376
+ * attempt 0 → 5s, attempt 1 → 10s, attempt 2 → 20s,
377
+ * attempt 3 → 40s, attempt 4 → 80s
378
+ *
379
+ * Rows with attempts >= maxAttempts (default 5) are skipped and left for
380
+ * a manual reconciler — they won't be retried automatically.
381
+ *
382
+ * @param db - Prisma client instance (must have `eventOutbox` model)
383
+ * @param options - Relay configuration
384
+ *
385
+ * @example
386
+ * // src/app/layout.tsx
387
+ * import { startOutboxRelay } from '@newhomestar/sdk/events';
388
+ * import { db } from '@/lib/db';
389
+ *
390
+ * startOutboxRelay(db);
391
+ */
392
+ export function startOutboxRelay(db, options = {}) {
393
+ const { intervalMs = 60_000, maxAttempts = 5, eventsUrl = process.env.NOVA_EVENTS_SERVICE_URL, serviceToken = process.env.NOVA_SERVICE_TOKEN, } = options;
394
+ if (!eventsUrl || !serviceToken) {
395
+ console.warn('[nova/events] startOutboxRelay: NOVA_EVENTS_SERVICE_URL or NOVA_SERVICE_TOKEN not set — ' +
396
+ 'outbox relay is DISABLED. Set these env vars to enable automatic retry.');
397
+ // Return a no-op interval that never fires
398
+ return setInterval(() => { }, 2_147_483_647);
399
+ }
400
+ console.log(`[nova/events] Outbox relay started (interval: ${intervalMs}ms, maxAttempts: ${maxAttempts})`);
401
+ const handle = setInterval(async () => {
402
+ try {
403
+ const now = new Date();
404
+ // Find undelivered rows where the backoff window has elapsed.
405
+ // The query fetches rows where last_attempt_at is old enough for the
406
+ // minimum backoff (5s for attempt 0). The per-row check below applies
407
+ // the precise 2^attempts * 5 calculation to skip rows still in cooldown.
408
+ const undelivered = await db.eventOutbox.findMany({
409
+ where: {
410
+ delivered: false,
411
+ attempts: { lt: maxAttempts },
412
+ OR: [
413
+ { lastAttemptAt: null },
414
+ { lastAttemptAt: { lt: new Date(now.getTime() - 5_000) } },
415
+ ],
416
+ },
417
+ orderBy: [{ attempts: 'asc' }, { createdAt: 'asc' }],
418
+ take: 50,
419
+ });
420
+ for (const row of undelivered) {
421
+ // Precise per-row backoff gate: skip if cooldown hasn't elapsed
422
+ if (row.lastAttemptAt != null) {
423
+ const backoffMs = Math.pow(2, row.attempts) * 5_000;
424
+ if (now.getTime() - row.lastAttemptAt.getTime() < backoffMs) {
425
+ continue;
426
+ }
427
+ }
428
+ await _relayRow(db, row, eventsUrl, serviceToken);
429
+ }
430
+ // Cleanup: delete delivered rows older than 7 days (keep audit trail brief)
431
+ await db.eventOutbox.deleteMany({
432
+ where: {
433
+ delivered: true,
434
+ deliveredAt: { lt: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1_000) },
435
+ },
436
+ });
437
+ }
438
+ catch (err) {
439
+ console.error('[nova/events] Outbox relay cycle error:', err);
440
+ }
441
+ }, intervalMs);
442
+ return handle;
443
+ }
444
+ // ── NovaEventsClient ───────────────────────────────────────────────────────────
445
+ /**
446
+ * Class-based client when you need more control than the top-level functions.
447
+ * Useful when you want to pass configuration explicitly rather than relying on env vars.
448
+ *
449
+ * @example
450
+ * const client = new NovaEventsClient({ serviceSlug: 'my-service' });
451
+ * await client.queueEvent({ entity_type: 'employee', action: 'updated', ... });
452
+ * client.startOutboxRelay(db);
453
+ */
454
+ export class NovaEventsClient {
455
+ eventsUrl;
456
+ serviceToken;
457
+ serviceSlug;
458
+ constructor(options) {
459
+ this.eventsUrl = options?.eventsUrl ?? process.env.NOVA_EVENTS_SERVICE_URL ?? '';
460
+ this.serviceToken = options?.serviceToken ?? process.env.NOVA_SERVICE_TOKEN ?? '';
461
+ this.serviceSlug = options?.serviceSlug ?? process.env.NOVA_SERVICE_SLUG;
462
+ }
463
+ /** POST /events — audit log only */
464
+ async logEvent(payload) {
465
+ return logEvent({ ...payload, source_service: payload.source_service ?? this.serviceSlug });
466
+ }
467
+ /** POST /events/queue — log + fan-out to all subscriber queues */
468
+ async queueEvent(payload) {
469
+ return queueEvent({ ...payload, source_service: payload.source_service ?? this.serviceSlug });
470
+ }
471
+ /** Transactional outbox helper (use in services with a Prisma DB) */
472
+ async withOutbox(db, callback) {
473
+ // Temporarily override env for the helpers
474
+ const original = {
475
+ eventsUrl: process.env.NOVA_EVENTS_SERVICE_URL,
476
+ serviceToken: process.env.NOVA_SERVICE_TOKEN,
477
+ serviceSlug: process.env.NOVA_SERVICE_SLUG,
478
+ };
479
+ process.env.NOVA_EVENTS_SERVICE_URL = this.eventsUrl;
480
+ process.env.NOVA_SERVICE_TOKEN = this.serviceToken;
481
+ if (this.serviceSlug)
482
+ process.env.NOVA_SERVICE_SLUG = this.serviceSlug;
483
+ try {
484
+ return await withEventOutbox(db, callback);
485
+ }
486
+ finally {
487
+ if (original.eventsUrl !== undefined)
488
+ process.env.NOVA_EVENTS_SERVICE_URL = original.eventsUrl;
489
+ if (original.serviceToken !== undefined)
490
+ process.env.NOVA_SERVICE_TOKEN = original.serviceToken;
491
+ if (original.serviceSlug !== undefined)
492
+ process.env.NOVA_SERVICE_SLUG = original.serviceSlug;
493
+ }
494
+ }
495
+ /** Start the background outbox retry relay */
496
+ startOutboxRelay(db, options) {
497
+ return startOutboxRelay(db, {
498
+ ...options,
499
+ eventsUrl: this.eventsUrl,
500
+ serviceToken: this.serviceToken,
501
+ });
502
+ }
503
+ }
package/dist/index.d.ts CHANGED
@@ -75,6 +75,20 @@ export interface JWTPayload {
75
75
  export interface ActionCtx {
76
76
  jobId: string;
77
77
  progress: (percent: number, meta?: unknown) => void;
78
+ /**
79
+ * Number of times this message has been delivered/retried.
80
+ * Populated in SSE worker mode; undefined in HTTP server mode.
81
+ * Use for alerting when read_ct is high (approaching DLQ threshold).
82
+ */
83
+ read_ct?: number;
84
+ /**
85
+ * Extend the message visibility lease for long-running handlers.
86
+ * Call periodically to prevent the message from re-appearing in the queue
87
+ * before processing is complete. SSE worker mode only.
88
+ *
89
+ * @param extend_by - Seconds to extend the lease (default: 30)
90
+ */
91
+ heartbeat?: (extend_by?: number) => Promise<void>;
78
92
  /** Raw HTTP headers from the inbound request (HTTP mode only) */
79
93
  headers?: Record<string, string | string[] | undefined>;
80
94
  /** Bearer token extracted from the Authorization header (HTTP mode only) */
@@ -231,6 +245,8 @@ export declare function runHttpServer<T extends WorkerDef>(def: T, opts?: HttpSe
231
245
  export declare function runDualMode<T extends WorkerDef>(def: T, opts?: {
232
246
  port?: number;
233
247
  }): void;
248
+ export { withServiceEventOutbox, withEventOutbox, isIntegrationSync, queueEvent, logEvent, startOutboxRelay, NovaEventsClient, } from './events.js';
249
+ export type { QueueEventPayload, LogEventPayload, OutboxRelayOptions, ServiceEmitFn, } from './events.js';
234
250
  export type { ZodTypeAny as SchemaAny, ZodTypeAny };
235
251
  export { parseNovaSpec } from "./parseSpec.js";
236
252
  export type { NovaSpec } from "./parseSpec.js";
@@ -268,5 +284,25 @@ export type StreamCapability = {
268
284
  consumerGroup?: string;
269
285
  };
270
286
  export type Capability = WebhookCapability | ScheduledCapability | QueueCapability | StreamCapability;
287
+ /**
288
+ * Event trigger — subscribes the worker to one or more Nova event topics.
289
+ * Topics are matched against `message.topic` / `message.event_type` in SSE frames.
290
+ */
291
+ export type EventTrigger = {
292
+ type: 'event';
293
+ /** Nova event topic strings (e.g. "hris.employee.created") */
294
+ events: string[];
295
+ };
296
+ /**
297
+ * Schedule trigger — runs the worker on a cron schedule.
298
+ */
299
+ export type ScheduleTrigger = {
300
+ type: 'schedule';
301
+ cron: string;
302
+ timezone?: string;
303
+ description?: string;
304
+ };
305
+ /** Union of all trigger variants */
306
+ export type Trigger = EventTrigger | ScheduleTrigger;
271
307
  export { createPlatformClient, resolveCredentials, resolveCredentialsViaHttp, detectCredentialStrategy, integrationFetch, emitPlatformEvent, IntegrationNotFoundError, IntegrationDisabledError, CredentialsNotConfiguredError, ConnectionNotFoundError, TokenExchangeError, } from "./credentials.js";
272
308
  export type { ResolvedCredentials, IntegrationConfig, AuthMode, } from "./credentials.js";
package/dist/index.js CHANGED
@@ -141,7 +141,46 @@ function buildTopicActionMap(def) {
141
141
  }
142
142
  return topicMap;
143
143
  }
144
+ /**
145
+ * Build a topic → actionDef map from the new `triggers` API (and legacy `capabilities`).
146
+ * Used by runWorkerSSE() to route incoming SSE messages to the correct handler.
147
+ */
148
+ function buildTriggerMap(def) {
149
+ const map = new Map();
150
+ for (const [, actionDef] of Object.entries(def.actions)) {
151
+ // New: triggers API (preferred)
152
+ const triggers = actionDef.triggers;
153
+ if (triggers) {
154
+ for (const trigger of triggers) {
155
+ if (trigger.type === 'event' && Array.isArray(trigger.events)) {
156
+ for (const topic of trigger.events) {
157
+ map.set(topic, actionDef);
158
+ }
159
+ }
160
+ }
161
+ }
162
+ // Legacy: capabilities API (backward compat)
163
+ if (actionDef.capabilities) {
164
+ for (const cap of actionDef.capabilities) {
165
+ if (cap.type === 'queue' && cap.topics) {
166
+ for (const topic of cap.topics) {
167
+ if (!map.has(topic))
168
+ map.set(topic, actionDef); // triggers take precedence
169
+ }
170
+ }
171
+ }
172
+ }
173
+ }
174
+ return map;
175
+ }
144
176
  export async function runWorker(def) {
177
+ // ── SSE mode (preferred): NOVA_EVENTS_SERVICE_URL + NOVA_SERVICE_TOKEN ─────
178
+ // When these env vars are set the worker connects via SSE to the events service.
179
+ // No Supabase connection is required on the worker side.
180
+ if (process.env.NOVA_EVENTS_SERVICE_URL) {
181
+ return runWorkerSSE(def);
182
+ }
183
+ // ── Legacy mode: RUNTIME_SUPABASE_* + pgmq_public RPC ────────────────────
145
184
  if (!runtime)
146
185
  throw new Error("RUNTIME_SUPABASE_* env vars not configured");
147
186
  // Build topic-to-action mapping for capability-based routing
@@ -277,6 +316,177 @@ async function nack(id, q) {
277
316
  await runtime.schema("pgmq_public").rpc("nack", { queue_name: q, message_id: id });
278
317
  }
279
318
  function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
319
+ /*──────────────── SSE Worker Mode ───────────────*/
320
+ const _EVENTS_BASE = () => (process.env.NOVA_EVENTS_SERVICE_URL ?? '').replace(/\/$/, '');
321
+ const _EVENTS_TOKEN = () => process.env.NOVA_SERVICE_TOKEN ?? '';
322
+ async function _ackSSE(queue, msgId) {
323
+ const res = await fetch(`${_EVENTS_BASE()}/events/queue/ack`, {
324
+ method: 'POST',
325
+ headers: {
326
+ 'Content-Type': 'application/json',
327
+ Authorization: `Bearer ${_EVENTS_TOKEN()}`,
328
+ },
329
+ body: JSON.stringify({ queue, msg_id: msgId }),
330
+ });
331
+ if (!res.ok)
332
+ console.error(`[nova] ack failed for msg_id=${msgId}: ${await res.text()}`);
333
+ }
334
+ async function _nackSSE(queue, msgId, readCt) {
335
+ const res = await fetch(`${_EVENTS_BASE()}/events/queue/nack`, {
336
+ method: 'POST',
337
+ headers: {
338
+ 'Content-Type': 'application/json',
339
+ Authorization: `Bearer ${_EVENTS_TOKEN()}`,
340
+ },
341
+ body: JSON.stringify({ queue, msg_id: msgId, read_ct: readCt }),
342
+ });
343
+ if (!res.ok)
344
+ console.error(`[nova] nack failed for msg_id=${msgId}: ${await res.text()}`);
345
+ }
346
+ async function _heartbeatSSE(queue, msgId, extendBy = 30) {
347
+ const res = await fetch(`${_EVENTS_BASE()}/events/queue/heartbeat`, {
348
+ method: 'POST',
349
+ headers: {
350
+ 'Content-Type': 'application/json',
351
+ Authorization: `Bearer ${_EVENTS_TOKEN()}`,
352
+ },
353
+ body: JSON.stringify({ queue, msg_id: msgId, extend_by: extendBy }),
354
+ });
355
+ if (!res.ok)
356
+ console.error(`[nova] heartbeat failed for msg_id=${msgId}: ${await res.text()}`);
357
+ }
358
+ async function runWorkerSSE(def) {
359
+ const baseUrl = _EVENTS_BASE();
360
+ const token = _EVENTS_TOKEN();
361
+ if (!baseUrl)
362
+ throw new Error('[nova] NOVA_EVENTS_SERVICE_URL is required for SSE worker mode');
363
+ if (!token)
364
+ throw new Error('[nova] NOVA_SERVICE_TOKEN is required for SSE worker mode');
365
+ // ── Ensure the worker's queue exists before connecting ──────────────────
366
+ const ensureRes = await fetch(`${baseUrl}/events/queue/ensure`, {
367
+ method: 'POST',
368
+ headers: {
369
+ 'Content-Type': 'application/json',
370
+ Authorization: `Bearer ${token}`,
371
+ },
372
+ body: JSON.stringify({ queue: def.queue }),
373
+ });
374
+ if (!ensureRes.ok) {
375
+ throw new Error(`[nova] Failed to ensure queue '${def.queue}': ${await ensureRes.text()}`);
376
+ }
377
+ console.log(`[nova] ✅ Queue '${def.queue}' ready`);
378
+ const triggerMap = buildTriggerMap(def);
379
+ console.log(`[nova] SSE worker '${def.name}' → ${baseUrl}/events/queue/stream?queue=${def.queue}`);
380
+ console.log(`[nova] Trigger map:`, Object.fromEntries(triggerMap));
381
+ let reconnectDelay = 1_000; // start 1 s, double up to 30 s
382
+ // ── Outer reconnect loop ─────────────────────────────────────────────────
383
+ while (true) {
384
+ try {
385
+ const streamRes = await fetch(`${baseUrl}/events/queue/stream?queue=${encodeURIComponent(def.queue)}`, { headers: { Authorization: `Bearer ${token}` } });
386
+ if (!streamRes.ok || !streamRes.body) {
387
+ const errText = await streamRes.text().catch(() => '');
388
+ console.error(`[nova] SSE HTTP ${streamRes.status}: ${errText}`);
389
+ await delay(reconnectDelay);
390
+ reconnectDelay = Math.min(reconnectDelay * 2, 30_000);
391
+ continue;
392
+ }
393
+ // Successful connection — reset backoff
394
+ reconnectDelay = 1_000;
395
+ console.log(`[nova] 📡 SSE stream connected`);
396
+ // ── Stream reader ──────────────────────────────────────────────────
397
+ const reader = streamRes.body.getReader();
398
+ const decoder = new TextDecoder();
399
+ let buf = '';
400
+ let currentEvent = null;
401
+ let currentData = null;
402
+ readLoop: while (true) {
403
+ const { done, value } = await reader.read();
404
+ if (done) {
405
+ console.warn(`[nova] SSE stream closed — will reconnect`);
406
+ break readLoop;
407
+ }
408
+ buf += decoder.decode(value, { stream: true });
409
+ const lines = buf.split('\n');
410
+ buf = lines.pop() ?? '';
411
+ for (const line of lines) {
412
+ if (line.startsWith('event:')) {
413
+ currentEvent = line.slice(6).trim();
414
+ }
415
+ else if (line.startsWith('data:')) {
416
+ currentData = line.slice(5).trim();
417
+ }
418
+ else if (line === '') {
419
+ // ── End of SSE frame ────────────────────────────────────────
420
+ if (currentData) {
421
+ try {
422
+ const frame = JSON.parse(currentData);
423
+ // Server-side DLQ event — already moved; just log
424
+ if (currentEvent === 'dlq') {
425
+ console.warn(`[nova] ⚠️ Message ${frame.msg_id} moved to DLQ (read_ct=${frame.read_ct})`);
426
+ currentEvent = null;
427
+ currentData = null;
428
+ continue;
429
+ }
430
+ // Keepalive heartbeat — ignore
431
+ if (frame.type === 'keepalive') {
432
+ currentEvent = null;
433
+ currentData = null;
434
+ continue;
435
+ }
436
+ // ── Normal message ─────────────────────────────────────
437
+ const { msg_id, read_ct, message } = frame;
438
+ const topic = message?.topic ?? message?.event_type ?? message?.type ?? '';
439
+ const actionDef = triggerMap.get(topic);
440
+ if (!actionDef) {
441
+ console.error(`[nova] ❌ No handler for topic '${topic}' — nacking (available: ${[...triggerMap.keys()].join(', ')})`);
442
+ await _nackSSE(def.queue, msg_id, read_ct ?? 0);
443
+ currentEvent = null;
444
+ currentData = null;
445
+ continue;
446
+ }
447
+ console.log(`[nova] 🚀 topic='${topic}' msg_id=${msg_id} read_ct=${read_ct}`);
448
+ // ── Invoke handler ─────────────────────────────────────
449
+ try {
450
+ const rawPayload = message?.payload ?? message;
451
+ const parsedInput = actionDef.input.parse(rawPayload);
452
+ const credCtx = buildCredentialCtx(def.name);
453
+ const ctx = {
454
+ jobId: `sse-${msg_id}`,
455
+ read_ct: read_ct ?? 0,
456
+ progress: (percent, meta) => {
457
+ console.log(`[nova] progress ${percent}%`, meta ?? '');
458
+ },
459
+ heartbeat: (extendBy = 30) => _heartbeatSSE(def.queue, msg_id, extendBy),
460
+ ...credCtx,
461
+ };
462
+ const result = await actionDef.handler(parsedInput, ctx);
463
+ actionDef.output.parse(result);
464
+ await _ackSSE(def.queue, msg_id);
465
+ console.log(`[nova] ✅ ack msg_id=${msg_id}`);
466
+ }
467
+ catch (handlerErr) {
468
+ console.error(`[nova] ❌ handler error msg_id=${msg_id}:`, handlerErr);
469
+ await _nackSSE(def.queue, msg_id, read_ct ?? 0);
470
+ }
471
+ }
472
+ catch (parseErr) {
473
+ console.error(`[nova] Failed to parse SSE frame:`, parseErr);
474
+ }
475
+ }
476
+ currentEvent = null;
477
+ currentData = null;
478
+ }
479
+ }
480
+ }
481
+ }
482
+ catch (connErr) {
483
+ console.error(`[nova] SSE connection error:`, connErr);
484
+ }
485
+ console.log(`[nova] Reconnecting in ${reconnectDelay}ms…`);
486
+ await delay(reconnectDelay);
487
+ reconnectDelay = Math.min(reconnectDelay * 2, 30_000);
488
+ }
489
+ }
280
490
  /*──────────────── NEW: OpenAPI Spec Generation ───────────────*/
281
491
  export async function generateOpenAPISpec(def) {
282
492
  // This would use oRPC's built-in OpenAPI generation
@@ -610,6 +820,10 @@ export function runDualMode(def, opts = {}) {
610
820
  console.log(`💡 Worker running in HTTP-only mode. Set RUNTIME_SUPABASE_URL and RUNTIME_SUPABASE_SERVICE_ROLE_KEY to enable event processing`);
611
821
  }
612
822
  }
823
+ /*──────────────── Event Outbox (re-exports for convenience) ───────────────*/
824
+ // Full API is also available via '@newhomestar/sdk/events' subpath.
825
+ // These re-exports allow import { withServiceEventOutbox } from '@newhomestar/sdk'.
826
+ export { withServiceEventOutbox, withEventOutbox, isIntegrationSync, queueEvent, logEvent, startOutboxRelay, NovaEventsClient, } from './events.js';
613
827
  // YAML spec parsing utility
614
828
  export { parseNovaSpec } from "./parseSpec.js";
615
829
  // Integration definition API
@@ -90,7 +90,7 @@ export declare const IntegrationSpecSchema: z.ZodObject<{
90
90
  streamName: z.ZodString;
91
91
  eventTypes: z.ZodOptional<z.ZodArray<z.ZodString>>;
92
92
  consumerGroup: z.ZodOptional<z.ZodString>;
93
- }, z.core.$strip>]>>>;
93
+ }, z.core.$strip>], "type">>>;
94
94
  }, z.core.$strip>>;
95
95
  capabilities: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
96
96
  type: z.ZodLiteral<"webhook">;
@@ -122,7 +122,7 @@ export declare const IntegrationSpecSchema: z.ZodObject<{
122
122
  streamName: z.ZodString;
123
123
  eventTypes: z.ZodOptional<z.ZodArray<z.ZodString>>;
124
124
  consumerGroup: z.ZodOptional<z.ZodString>;
125
- }, z.core.$strip>]>>>;
125
+ }, z.core.$strip>], "type">>>;
126
126
  schemas: z.ZodOptional<z.ZodArray<z.ZodObject<{
127
127
  slug: z.ZodString;
128
128
  name: z.ZodString;
@@ -73,7 +73,7 @@ export declare const NovaSpecSchema: z.ZodObject<{
73
73
  streamName: z.ZodString;
74
74
  eventTypes: z.ZodOptional<z.ZodArray<z.ZodString>>;
75
75
  consumerGroup: z.ZodOptional<z.ZodString>;
76
- }, z.core.$strip>]>>>;
76
+ }, z.core.$strip>], "type">>>;
77
77
  }, z.core.$strip>>;
78
78
  capabilities: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
79
79
  type: z.ZodLiteral<"webhook">;
@@ -105,7 +105,7 @@ export declare const NovaSpecSchema: z.ZodObject<{
105
105
  streamName: z.ZodString;
106
106
  eventTypes: z.ZodOptional<z.ZodArray<z.ZodString>>;
107
107
  consumerGroup: z.ZodOptional<z.ZodString>;
108
- }, z.core.$strip>]>>>;
108
+ }, z.core.$strip>], "type">>>;
109
109
  }, z.core.$strip>;
110
110
  build: z.ZodOptional<z.ZodObject<{
111
111
  dockerfile: z.ZodString;
@@ -83,6 +83,15 @@ export declare const WorkerDefSchema: z.ZodObject<{
83
83
  resourceIdKey: z.ZodOptional<z.ZodString>;
84
84
  policy: z.ZodOptional<z.ZodString>;
85
85
  }, z.core.$strip>>;
86
+ triggers: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
87
+ type: z.ZodLiteral<"event">;
88
+ events: z.ZodArray<z.ZodString>;
89
+ }, z.core.$strip>, z.ZodObject<{
90
+ type: z.ZodLiteral<"schedule">;
91
+ cron: z.ZodString;
92
+ timezone: z.ZodOptional<z.ZodString>;
93
+ description: z.ZodOptional<z.ZodString>;
94
+ }, z.core.$strip>]>>>;
86
95
  capabilities: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
87
96
  type: z.ZodLiteral<"webhook">;
88
97
  eventTypes: z.ZodArray<z.ZodString>;
@@ -61,7 +61,24 @@ export const WorkerDefSchema = z.object({
61
61
  resourceIdKey: z.string().optional(),
62
62
  policy: z.string().optional(),
63
63
  }).optional(),
64
- // NEW: Capabilities array for event subscriptions and external triggers
64
+ // NEW: Trigger-based routing (preferred over capabilities for SSE-based workers)
65
+ // Use triggers to declaratively map event topics and cron schedules to this action.
66
+ // `nova workers push` / `nova integrations push` reads these to register
67
+ // event_subscriptions and ensure PGMQ queues exist.
68
+ triggers: z.array(z.union([
69
+ z.object({
70
+ type: z.literal('event'),
71
+ events: z.array(z.string()), // e.g. ['employee.updated', 'employee.created']
72
+ }),
73
+ z.object({
74
+ type: z.literal('schedule'),
75
+ cron: z.string(), // standard 5-field cron e.g. '0 8 * * *'
76
+ timezone: z.string().optional(),
77
+ description: z.string().optional(),
78
+ }),
79
+ ])).optional(),
80
+ // LEGACY: Capabilities array — still supported for backward compatibility.
81
+ // New code should use `triggers` instead.
65
82
  capabilities: z.array(z.union([
66
83
  z.object({
67
84
  type: z.literal('webhook'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newhomestar/sdk",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
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": {
@@ -23,6 +23,10 @@
23
23
  "./next": {
24
24
  "import": "./dist/next.js",
25
25
  "types": "./dist/next.d.ts"
26
+ },
27
+ "./events": {
28
+ "import": "./dist/events.js",
29
+ "types": "./dist/events.d.ts"
26
30
  }
27
31
  },
28
32
  "files": [
@@ -40,11 +44,14 @@
40
44
  "dotenv": "^16.4.3",
41
45
  "express": "^4.18.2",
42
46
  "express-oauth2-jwt-bearer": "^1.7.4",
43
- "yaml": "^2.7.1",
44
- "zod": "^4.0.5"
47
+ "yaml": "^2.7.1"
48
+ },
49
+ "peerDependencies": {
50
+ "zod": ">=4.0.0"
45
51
  },
46
52
  "devDependencies": {
47
53
  "@types/node": "^20.11.17",
48
- "typescript": "^5.4.4"
54
+ "typescript": "^5.4.4",
55
+ "zod": "^4.3.0"
49
56
  }
50
57
  }