@newhomestar/sdk 0.6.7 → 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.
- package/dist/events.d.ts +226 -0
- package/dist/events.js +503 -0
- package/dist/index.d.ts +37 -1
- package/dist/index.js +214 -0
- package/dist/integration.d.ts +81 -0
- package/dist/integration.js +13 -0
- package/dist/integrationSpec.d.ts +2 -2
- package/dist/next.d.ts +232 -1
- package/dist/next.js +174 -1
- package/dist/parseSpec.d.ts +2 -2
- package/dist/workerSchema.d.ts +9 -0
- package/dist/workerSchema.js +18 -1
- package/package.json +11 -4
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
|
package/dist/integration.d.ts
CHANGED
|
@@ -203,6 +203,48 @@ export interface IntegrationFunctionDef {
|
|
|
203
203
|
* ```
|
|
204
204
|
*/
|
|
205
205
|
export declare function integrationFunction(cfg: IntegrationFunctionDef): IntegrationFunctionDef;
|
|
206
|
+
/**
|
|
207
|
+
* A single field mapping rule: one source field → one target field,
|
|
208
|
+
* with an optional JSONata transform expression.
|
|
209
|
+
*
|
|
210
|
+
* Seeded into `integration_field_mappings` by `nova integrations push`.
|
|
211
|
+
*/
|
|
212
|
+
export interface SyncMappingFieldDef {
|
|
213
|
+
/** Top-level field name from the provider payload (e.g., "workEmail") */
|
|
214
|
+
source: string;
|
|
215
|
+
/**
|
|
216
|
+
* Optional dot-path for nested extraction.
|
|
217
|
+
* e.g., ["status", "status"] extracts payload.status.status
|
|
218
|
+
* When set, takes precedence over `source` for deep access.
|
|
219
|
+
*/
|
|
220
|
+
sourcePath?: string[];
|
|
221
|
+
/** Normalized field name on the target Nova service schema (e.g., "work_email") */
|
|
222
|
+
target: string;
|
|
223
|
+
/**
|
|
224
|
+
* Optional JSONata expression applied after extraction.
|
|
225
|
+
* null / undefined = pass-through (no transform).
|
|
226
|
+
* @example "status = 'Active' ? 'ACTIVE' : 'INACTIVE'"
|
|
227
|
+
* @example "firstName & ' ' & lastName"
|
|
228
|
+
* @example "$lowercase(workEmail)"
|
|
229
|
+
*/
|
|
230
|
+
transform?: string;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Declares how one integration entity maps to one Nova service schema.
|
|
234
|
+
* E.g.: `bamboohr.employee → hris.employee`
|
|
235
|
+
*
|
|
236
|
+
* Seeded into `integration_sync_pairs` by `nova integrations push`.
|
|
237
|
+
*/
|
|
238
|
+
export interface SyncMappingDef {
|
|
239
|
+
/** Target Nova service slug (e.g., "hris") */
|
|
240
|
+
service: string;
|
|
241
|
+
/** Target schema slug on the service side (e.g., "employee") */
|
|
242
|
+
targetSchema: string;
|
|
243
|
+
/** Direction of data flow. Defaults to "integration_to_service". */
|
|
244
|
+
direction?: 'integration_to_service' | 'service_to_integration' | 'bidirectional';
|
|
245
|
+
/** Per-field mapping rules */
|
|
246
|
+
fields: SyncMappingFieldDef[];
|
|
247
|
+
}
|
|
206
248
|
/**
|
|
207
249
|
* Full integration definition — the single source of truth.
|
|
208
250
|
*
|
|
@@ -272,6 +314,30 @@ export interface IntegrationDef {
|
|
|
272
314
|
events: Record<string, IntegrationEventDef>;
|
|
273
315
|
/** Function definitions — describe external API endpoints */
|
|
274
316
|
functions: Record<string, IntegrationFunctionDef>;
|
|
317
|
+
/**
|
|
318
|
+
* Sync mapping declarations — seeded into `integration_sync_pairs` +
|
|
319
|
+
* `integration_field_mappings` by `nova integrations push`.
|
|
320
|
+
*
|
|
321
|
+
* Key = source entity slug (e.g., "employee", "time_off_request").
|
|
322
|
+
* Value = target service/schema + field-level mapping rules.
|
|
323
|
+
*
|
|
324
|
+
* @example
|
|
325
|
+
* ```ts
|
|
326
|
+
* syncMappings: {
|
|
327
|
+
* employee: {
|
|
328
|
+
* service: "hris",
|
|
329
|
+
* targetSchema: "employee",
|
|
330
|
+
* direction: "integration_to_service",
|
|
331
|
+
* fields: [
|
|
332
|
+
* { source: "workEmail", target: "work_email" },
|
|
333
|
+
* { source: "status", target: "employment_status",
|
|
334
|
+
* transform: "status = 'Active' ? 'ACTIVE' : 'INACTIVE'" },
|
|
335
|
+
* ],
|
|
336
|
+
* },
|
|
337
|
+
* }
|
|
338
|
+
* ```
|
|
339
|
+
*/
|
|
340
|
+
syncMappings?: Record<string, SyncMappingDef>;
|
|
275
341
|
}
|
|
276
342
|
export declare const IntegrationDefSchema: z.ZodObject<{
|
|
277
343
|
slug: z.ZodString;
|
|
@@ -360,6 +426,21 @@ export declare const IntegrationDefSchema: z.ZodObject<{
|
|
|
360
426
|
category: z.ZodOptional<z.ZodString>;
|
|
361
427
|
capabilities: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>]>>>;
|
|
362
428
|
}, z.core.$strip>>;
|
|
429
|
+
syncMappings: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
430
|
+
service: z.ZodString;
|
|
431
|
+
targetSchema: z.ZodString;
|
|
432
|
+
direction: z.ZodOptional<z.ZodEnum<{
|
|
433
|
+
bidirectional: "bidirectional";
|
|
434
|
+
integration_to_service: "integration_to_service";
|
|
435
|
+
service_to_integration: "service_to_integration";
|
|
436
|
+
}>>;
|
|
437
|
+
fields: z.ZodArray<z.ZodObject<{
|
|
438
|
+
source: z.ZodString;
|
|
439
|
+
sourcePath: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
440
|
+
target: z.ZodString;
|
|
441
|
+
transform: z.ZodOptional<z.ZodString>;
|
|
442
|
+
}, z.core.$strip>>;
|
|
443
|
+
}, z.core.$strip>>>;
|
|
363
444
|
}, z.core.$strip>;
|
|
364
445
|
/**
|
|
365
446
|
* Result of validating an integration definition before build/push.
|
package/dist/integration.js
CHANGED
|
@@ -154,6 +154,18 @@ const IntegrationFunctionDefSchema = z.object({
|
|
|
154
154
|
category: z.string().optional(),
|
|
155
155
|
capabilities: z.array(z.union([z.string(), z.record(z.string(), z.unknown())])).optional(),
|
|
156
156
|
});
|
|
157
|
+
const SyncMappingFieldDefSchema = z.object({
|
|
158
|
+
source: z.string(),
|
|
159
|
+
sourcePath: z.array(z.string()).optional(),
|
|
160
|
+
target: z.string(),
|
|
161
|
+
transform: z.string().optional(),
|
|
162
|
+
});
|
|
163
|
+
const SyncMappingDefSchema = z.object({
|
|
164
|
+
service: z.string(),
|
|
165
|
+
targetSchema: z.string(),
|
|
166
|
+
direction: z.enum(['integration_to_service', 'service_to_integration', 'bidirectional']).optional(),
|
|
167
|
+
fields: z.array(SyncMappingFieldDefSchema),
|
|
168
|
+
});
|
|
157
169
|
export const IntegrationDefSchema = z.object({
|
|
158
170
|
slug: z.string().regex(/^[a-z][a-z0-9_]*$/, 'Integration slug must be snake_case'),
|
|
159
171
|
name: z.string(),
|
|
@@ -190,6 +202,7 @@ export const IntegrationDefSchema = z.object({
|
|
|
190
202
|
schemas: z.record(z.string(), IntegrationSchemaDefSchema),
|
|
191
203
|
events: z.record(z.string(), IntegrationEventDefSchema),
|
|
192
204
|
functions: z.record(z.string(), IntegrationFunctionDefSchema),
|
|
205
|
+
syncMappings: z.record(z.string(), SyncMappingDefSchema).optional(),
|
|
193
206
|
});
|
|
194
207
|
/**
|
|
195
208
|
* Validate an integration definition before build or push.
|
|
@@ -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;
|
package/dist/next.d.ts
CHANGED
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
* }
|
|
28
28
|
* ```
|
|
29
29
|
*/
|
|
30
|
-
import type { ZodTypeAny } from 'zod';
|
|
30
|
+
import type { ZodTypeAny, ZodObject } from 'zod';
|
|
31
|
+
import { z } from 'zod';
|
|
31
32
|
export type ParamIn = 'path' | 'query' | 'body' | 'header';
|
|
32
33
|
export type ParamUiType = 'text' | 'textarea' | 'number' | 'integer' | 'boolean' | 'date' | 'datetime' | 'select' | 'multiselect' | 'password' | 'email' | 'url' | 'uuid' | 'json' | 'hidden';
|
|
33
34
|
export interface ParamMeta {
|
|
@@ -49,11 +50,65 @@ export interface ParamMeta {
|
|
|
49
50
|
order?: number;
|
|
50
51
|
group?: string;
|
|
51
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* An event type emitted by a service endpoint.
|
|
55
|
+
* Defined inline in `novaEndpoint()` and auto-registered in the platform
|
|
56
|
+
* event_types registry when `nova services push` is run.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```ts
|
|
60
|
+
* events: [
|
|
61
|
+
* {
|
|
62
|
+
* slug: 'EMPLOYEE_UPDATED',
|
|
63
|
+
* name: 'Employee Updated',
|
|
64
|
+
* description: 'Fired when an employee record is updated',
|
|
65
|
+
* category: 'hris',
|
|
66
|
+
* entity_type: 'EMPLOYEE',
|
|
67
|
+
* action: 'UPDATED',
|
|
68
|
+
* priority_level: 'medium',
|
|
69
|
+
* }
|
|
70
|
+
* ]
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export interface NovaEndpointEventDef {
|
|
74
|
+
/**
|
|
75
|
+
* Unique event slug in ENTITY_ACTION format (e.g. "EMPLOYEE_UPDATED").
|
|
76
|
+
* Used as the event identifier in workers and integrations.
|
|
77
|
+
* If entity_type and action are not provided, they are derived from this slug
|
|
78
|
+
* by splitting on the last underscore.
|
|
79
|
+
*/
|
|
80
|
+
slug: string;
|
|
81
|
+
/** Human-readable event name (e.g. "Employee Updated") */
|
|
82
|
+
name: string;
|
|
83
|
+
/** Description of when this event fires */
|
|
84
|
+
description?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Event category for grouping in Odyssey UI (e.g. "hris", "payroll").
|
|
87
|
+
* Defaults to the service slug if omitted.
|
|
88
|
+
*/
|
|
89
|
+
category?: string;
|
|
90
|
+
/**
|
|
91
|
+
* Entity type portion of the slug (e.g. "EMPLOYEE").
|
|
92
|
+
* Auto-derived from slug if omitted.
|
|
93
|
+
*/
|
|
94
|
+
entity_type?: string;
|
|
95
|
+
/**
|
|
96
|
+
* Action portion of the slug (e.g. "UPDATED", "CREATED", "DELETED").
|
|
97
|
+
* Auto-derived from slug if omitted.
|
|
98
|
+
*/
|
|
99
|
+
action?: string;
|
|
100
|
+
/** Event priority for notification routing. Defaults to "medium". */
|
|
101
|
+
priority_level?: 'low' | 'medium' | 'high';
|
|
102
|
+
/** Whether this event should trigger a push notification. Defaults to false. */
|
|
103
|
+
triggers_notification?: boolean;
|
|
104
|
+
}
|
|
52
105
|
export interface NovaEndpointDef<I extends ZodTypeAny = ZodTypeAny, O extends ZodTypeAny = ZodTypeAny> {
|
|
53
106
|
/** HTTP method */
|
|
54
107
|
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
55
108
|
/** REST path template. Use :param for path params. e.g. "/hris/employees/:id" */
|
|
56
109
|
path: string;
|
|
110
|
+
/** Human-readable name shown in Odyssey UI endpoint list */
|
|
111
|
+
name?: string;
|
|
57
112
|
/** Human-readable description shown in Odyssey UI endpoint tester */
|
|
58
113
|
description?: string;
|
|
59
114
|
/** Endpoint group for Odyssey UI organization */
|
|
@@ -70,6 +125,32 @@ export interface NovaEndpointDef<I extends ZodTypeAny = ZodTypeAny, O extends Zo
|
|
|
70
125
|
requiresAuth?: boolean;
|
|
71
126
|
/** Whether this endpoint returns sensitive data (SSN, DOB, etc.) */
|
|
72
127
|
sensitiveFields?: boolean;
|
|
128
|
+
/**
|
|
129
|
+
* Permission slug(s) required to call this endpoint.
|
|
130
|
+
* e.g. `'hris_admin:view'` or `['hris_admin:view', 'hris_admin:edit']`
|
|
131
|
+
*
|
|
132
|
+
* `nova services push` auto-discovers all unique slugs across route files
|
|
133
|
+
* and upserts the corresponding IAM resources + permissions — no manual
|
|
134
|
+
* `iam:` section upkeep needed.
|
|
135
|
+
*
|
|
136
|
+
* Convention (HRIS example):
|
|
137
|
+
* GET → `'hris_admin:view'`
|
|
138
|
+
* POST / PATCH / PUT → `'hris_admin:edit'`
|
|
139
|
+
* DELETE → `'hris_admin:manage'`
|
|
140
|
+
*/
|
|
141
|
+
requiredPermissions?: string | string[];
|
|
142
|
+
/**
|
|
143
|
+
* Event types emitted by this endpoint.
|
|
144
|
+
* Defined here and auto-registered in the platform event_types registry
|
|
145
|
+
* when `nova services push` is run. Use `nova events list` to see all
|
|
146
|
+
* registered events.
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* ```ts
|
|
150
|
+
* events: [{ slug: 'EMPLOYEE_UPDATED', name: 'Employee Updated', category: 'hris' }]
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
events?: NovaEndpointEventDef[];
|
|
73
154
|
}
|
|
74
155
|
export type NovaEndpoint<I extends ZodTypeAny = ZodTypeAny, O extends ZodTypeAny = ZodTypeAny> = NovaEndpointDef<I, O> & {
|
|
75
156
|
/**
|
|
@@ -103,3 +184,153 @@ export type NovaEndpoint<I extends ZodTypeAny = ZodTypeAny, O extends ZodTypeAny
|
|
|
103
184
|
* ```
|
|
104
185
|
*/
|
|
105
186
|
export declare function novaEndpoint<I extends ZodTypeAny, O extends ZodTypeAny>(cfg: NovaEndpointDef<I, O>): NovaEndpoint<I, O>;
|
|
187
|
+
export interface CursorPayload {
|
|
188
|
+
id: string;
|
|
189
|
+
created_at: string;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Encode a record into an opaque base64url cursor string.
|
|
193
|
+
* The cursor encodes `id` and `created_at` for stable keyset pagination.
|
|
194
|
+
*/
|
|
195
|
+
export declare function encodeCursor(record: {
|
|
196
|
+
id: string;
|
|
197
|
+
created_at: string;
|
|
198
|
+
}): string;
|
|
199
|
+
/**
|
|
200
|
+
* Decode a cursor string back into its `id` and `created_at` fields.
|
|
201
|
+
* Throws a descriptive error if the cursor is malformed.
|
|
202
|
+
*/
|
|
203
|
+
export declare function parseCursor(cursor: string): CursorPayload;
|
|
204
|
+
/**
|
|
205
|
+
* Standard paginated response envelope used by all Nova list endpoints.
|
|
206
|
+
*
|
|
207
|
+
* - `results` — the page of records
|
|
208
|
+
* - `total` — total count of ALL records (ignores active search/filters)
|
|
209
|
+
* - `filtered_total` — count after search/filters applied (equals `total` when none are active)
|
|
210
|
+
* - `next` — opaque cursor for next page; `null` if this is the last page or offset-based
|
|
211
|
+
*/
|
|
212
|
+
export interface PagedResponse<T> {
|
|
213
|
+
results: T[];
|
|
214
|
+
total: number;
|
|
215
|
+
filtered_total: number;
|
|
216
|
+
next: string | null;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Build the unified `PagedResponse` from a DB result set.
|
|
220
|
+
*
|
|
221
|
+
* @param results Mapped row objects for this page
|
|
222
|
+
* @param total Total count of ALL records (pre-filter)
|
|
223
|
+
* @param filteredTotal Count after search/filters applied (pass same as `total` if no filter active)
|
|
224
|
+
* @param pageSize The page_size / limit that was requested
|
|
225
|
+
*/
|
|
226
|
+
export declare function buildPageResponse<T extends {
|
|
227
|
+
id: string;
|
|
228
|
+
created_at: string;
|
|
229
|
+
}>(results: T[], total: number, filteredTotal: number, pageSize: number): PagedResponse<T>;
|
|
230
|
+
/**
|
|
231
|
+
* Standard input schema for cursor-based list endpoints (programmatic APIs).
|
|
232
|
+
* Spread into `novaEndpoint({ input: CursorPageInput.extend({ ... }) })` to add resource filters.
|
|
233
|
+
*
|
|
234
|
+
* @example
|
|
235
|
+
* ```ts
|
|
236
|
+
* input: CursorPageInput.extend({ employee_id: z.string().uuid().optional() })
|
|
237
|
+
* ```
|
|
238
|
+
*/
|
|
239
|
+
export declare const CursorPageInput: ZodObject<{
|
|
240
|
+
page_size: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
241
|
+
cursor: z.ZodOptional<z.ZodString>;
|
|
242
|
+
modified_after: z.ZodOptional<z.ZodString>;
|
|
243
|
+
include_deleted: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
|
|
244
|
+
}, z.core.$strip>;
|
|
245
|
+
export type CursorPageInputType = z.infer<typeof CursorPageInput>;
|
|
246
|
+
/**
|
|
247
|
+
* Standard input schema for offset-based list endpoints (UI-facing browsing).
|
|
248
|
+
* Matches what `ParquetDataTable` / Odyssey UI data browsers send.
|
|
249
|
+
*
|
|
250
|
+
* @example
|
|
251
|
+
* ```ts
|
|
252
|
+
* input: OffsetPageInput.extend({ status: StatusEnum.optional() })
|
|
253
|
+
* ```
|
|
254
|
+
*/
|
|
255
|
+
export declare const OffsetPageInput: ZodObject<{
|
|
256
|
+
limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
257
|
+
offset: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
258
|
+
search: z.ZodOptional<z.ZodString>;
|
|
259
|
+
sort: z.ZodOptional<z.ZodString>;
|
|
260
|
+
sort_dir: z.ZodDefault<z.ZodEnum<{
|
|
261
|
+
asc: "asc";
|
|
262
|
+
desc: "desc";
|
|
263
|
+
}>>;
|
|
264
|
+
filters: z.ZodOptional<z.ZodString>;
|
|
265
|
+
}, z.core.$strip>;
|
|
266
|
+
export type OffsetPageInputType = z.infer<typeof OffsetPageInput>;
|
|
267
|
+
/**
|
|
268
|
+
* Build the Zod output schema for any paginated list endpoint.
|
|
269
|
+
* Always produces `{ results, total, filtered_total, next }`.
|
|
270
|
+
*
|
|
271
|
+
* @example
|
|
272
|
+
* ```ts
|
|
273
|
+
* output: paginatedOutput(HrisEmployeeZod)
|
|
274
|
+
* ```
|
|
275
|
+
*/
|
|
276
|
+
export declare function paginatedOutput<T extends ZodTypeAny>(itemSchema: T): ZodObject<{
|
|
277
|
+
results: z.ZodArray<T>;
|
|
278
|
+
total: z.ZodNumber;
|
|
279
|
+
filtered_total: z.ZodNumber;
|
|
280
|
+
next: z.ZodNullable<z.ZodString>;
|
|
281
|
+
}, z.core.$strip>;
|
|
282
|
+
/**
|
|
283
|
+
* Build Prisma `where`, `take`, and `orderBy` from a `CursorPageInput`.
|
|
284
|
+
* Handles cursor keyset pagination and `modified_after` filtering.
|
|
285
|
+
* Merge with resource-specific filters before passing to Prisma.
|
|
286
|
+
*
|
|
287
|
+
* @example
|
|
288
|
+
* ```ts
|
|
289
|
+
* const page = buildPrismaPage(input);
|
|
290
|
+
* const [data, total] = await Promise.all([
|
|
291
|
+
* db.hrisEmployee.findMany({ where: { remoteWasDeleted: input.include_deleted, ...page.where }, take: page.take, orderBy: page.orderBy }),
|
|
292
|
+
* db.hrisEmployee.count({ where: { remoteWasDeleted: input.include_deleted, ...page.where } }),
|
|
293
|
+
* ]);
|
|
294
|
+
* return nova.respond(buildPageResponse(data.map(mapRow), total, total, input.page_size));
|
|
295
|
+
* ```
|
|
296
|
+
*/
|
|
297
|
+
export declare function buildPrismaPage(input: CursorPageInputType): {
|
|
298
|
+
where: Record<string, unknown>;
|
|
299
|
+
take: number;
|
|
300
|
+
orderBy: Array<{
|
|
301
|
+
createdAt: 'desc';
|
|
302
|
+
} | {
|
|
303
|
+
id: 'desc';
|
|
304
|
+
}>;
|
|
305
|
+
};
|
|
306
|
+
/**
|
|
307
|
+
* Build Prisma `skip`, `take`, and `orderBy` from an `OffsetPageInput`.
|
|
308
|
+
* Handles offset pagination and sort direction.
|
|
309
|
+
* Does NOT handle `search` or `filters` — apply those to the `where` clause manually.
|
|
310
|
+
*
|
|
311
|
+
* @example
|
|
312
|
+
* ```ts
|
|
313
|
+
* const page = buildPrismaOffsetPage(input);
|
|
314
|
+
* const data = await db.example.findMany({ where, skip: page.skip, take: page.take, orderBy: page.orderBy });
|
|
315
|
+
* ```
|
|
316
|
+
*/
|
|
317
|
+
export declare function buildPrismaOffsetPage(input: OffsetPageInputType, defaultSort?: string): {
|
|
318
|
+
skip: number;
|
|
319
|
+
take: number;
|
|
320
|
+
orderBy: Record<string, 'asc' | 'desc'>;
|
|
321
|
+
};
|
|
322
|
+
/**
|
|
323
|
+
* Standard `params` metadata for cursor-based pagination fields.
|
|
324
|
+
* Spread into `novaEndpoint({ params: { ...CURSOR_PAGE_PARAMS, ...yourParams } })`.
|
|
325
|
+
*/
|
|
326
|
+
export declare const CURSOR_PAGE_PARAMS: Record<string, ParamMeta>;
|
|
327
|
+
/**
|
|
328
|
+
* Standard `params` metadata for offset-based pagination fields.
|
|
329
|
+
* Spread into `novaEndpoint({ params: { ...OFFSET_PAGE_PARAMS, ...yourParams } })`.
|
|
330
|
+
*/
|
|
331
|
+
export declare const OFFSET_PAGE_PARAMS: Record<string, ParamMeta>;
|
|
332
|
+
/** @deprecated Use `buildPageResponse` instead */
|
|
333
|
+
export declare function buildPaginatedResponse<T extends {
|
|
334
|
+
id: string;
|
|
335
|
+
created_at: string;
|
|
336
|
+
}>(data: T[], count: number | null, pageSize: number): PagedResponse<T>;
|