@newhomestar/sdk 0.7.1 → 0.7.4

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/index.d.ts CHANGED
@@ -25,6 +25,40 @@ export interface ActionDef<I extends ZodTypeAny, O extends ZodTypeAny> {
25
25
  * - UI type inferred from Zod type (string→text, number→number, boolean→boolean, enum→select)
26
26
  */
27
27
  params?: Record<string, import("./integration.js").ParamMeta>;
28
+ /**
29
+ * Expandable relations for this action — enables ?expand=field1,field2 query param.
30
+ *
31
+ * When a caller sends `?expand=author,contact`, the HTTP server automatically:
32
+ * 1. Collects all unique foreign-key UUIDs from the response
33
+ * 2. Calls each resolver once with the batch of IDs (no N+1)
34
+ * 3. Replaces UUIDs with full objects inline before returning the response
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * expandable: {
39
+ * author: {
40
+ * model: 'ticketing_user',
41
+ * resolver: async (ids, ctx) => {
42
+ * const users = await db.ticketingUser.findMany({ where: { id: { in: ids } } });
43
+ * return new Map(users.map(u => [u.id, u]));
44
+ * },
45
+ * },
46
+ * contact: {
47
+ * model: 'contact',
48
+ * resolver: async (ids, ctx) => {
49
+ * const contacts = await db.contact.findMany({ where: { id: { in: ids } } });
50
+ * return new Map(contacts.map(c => [c.id, c]));
51
+ * },
52
+ * },
53
+ * },
54
+ * ```
55
+ */
56
+ expandable?: Record<string, {
57
+ /** The entity/model slug being referenced (for documentation only) */
58
+ model: string;
59
+ /** Batch resolver — given an array of unique IDs, return a Map<id, full_object>. Called once per field. */
60
+ resolver: (ids: string[], ctx: ActionCtx) => Promise<Map<string, Record<string, unknown>>>;
61
+ }>;
28
62
  capabilities?: Array<{
29
63
  type: 'webhook';
30
64
  eventTypes: string[];
@@ -239,8 +273,13 @@ export interface HttpServerOptions {
239
273
  */
240
274
  export declare function runHttpServer<T extends WorkerDef>(def: T, opts?: HttpServerOptions): void;
241
275
  /**
242
- * Run both HTTP server and queue consumer concurrently
243
- * This gives you the best of both worlds: direct API access AND event processing
276
+ * Run both HTTP server and queue consumer concurrently.
277
+ * This gives you the best of both worlds: direct API access AND event processing.
278
+ *
279
+ * Queue consumer mode is selected automatically by runWorker():
280
+ * - SSE mode (preferred): NOVA_EVENTS_SERVICE_URL + NOVA_SERVICE_TOKEN are set
281
+ * - Legacy mode: RUNTIME_SUPABASE_* env vars are set (pgmq_public RPC)
282
+ * - HTTP-only: neither set — queue consumer is skipped with a warning
244
283
  */
245
284
  export declare function runDualMode<T extends WorkerDef>(def: T, opts?: {
246
285
  port?: number;
package/dist/index.js CHANGED
@@ -761,9 +761,37 @@ export function runHttpServer(def, opts = {}) {
761
761
  : ' [no auth]';
762
762
  console.log(`[nova] ▶ ${method.toUpperCase()} ${route} → ${actionName} (${jobId})${authLabel}`);
763
763
  const out = await act.handler(input, ctx);
764
- act.output.parse(out);
764
+ // ── Expand support: ?expand=field1,field2 ──────────────────────────────
765
+ // Batch-resolves foreign-key UUID fields to full objects (no N+1).
766
+ // Declare expandable fields on the ActionDef to opt-in.
767
+ let finalOut = out;
768
+ if (act.expandable && typeof out === 'object' && out !== null) {
769
+ const rawExpand = req.query?.expand ?? '';
770
+ const expandFields = rawExpand.split(',').map((s) => s.trim()).filter(Boolean);
771
+ if (expandFields.length > 0) {
772
+ const rows = Array.isArray(out) ? out : (out.results ?? []);
773
+ for (const field of expandFields) {
774
+ const cfg = act.expandable[field];
775
+ if (!cfg || rows.length === 0)
776
+ continue;
777
+ // Collect unique non-null string IDs
778
+ const ids = [...new Set(rows.map((r) => r[field]).filter((v) => typeof v === 'string' && v))];
779
+ if (ids.length === 0)
780
+ continue;
781
+ const map = await cfg.resolver(ids, ctx);
782
+ // Replace UUIDs with full objects inline (mutates rows copy)
783
+ for (const row of rows) {
784
+ if (typeof row[field] === 'string' && map.has(row[field])) {
785
+ row[field] = map.get(row[field]);
786
+ }
787
+ }
788
+ }
789
+ finalOut = Array.isArray(out) ? rows : { ...out, results: rows };
790
+ }
791
+ }
792
+ act.output.parse(finalOut);
765
793
  console.log(`[nova] ✅ ${actionName} completed (${jobId})`);
766
- res.json(out);
794
+ res.json(finalOut);
767
795
  }
768
796
  catch (err) {
769
797
  console.error(`[nova] ❌ ${actionName} failed:`, err.message);
@@ -798,26 +826,31 @@ export function runHttpServer(def, opts = {}) {
798
826
  }
799
827
  /*──────────────── Dual Mode: HTTP + Queue Consumer ───────────────*/
800
828
  /**
801
- * Run both HTTP server and queue consumer concurrently
802
- * This gives you the best of both worlds: direct API access AND event processing
829
+ * Run both HTTP server and queue consumer concurrently.
830
+ * This gives you the best of both worlds: direct API access AND event processing.
831
+ *
832
+ * Queue consumer mode is selected automatically by runWorker():
833
+ * - SSE mode (preferred): NOVA_EVENTS_SERVICE_URL + NOVA_SERVICE_TOKEN are set
834
+ * - Legacy mode: RUNTIME_SUPABASE_* env vars are set (pgmq_public RPC)
835
+ * - HTTP-only: neither set — queue consumer is skipped with a warning
803
836
  */
804
837
  export function runDualMode(def, opts = {}) {
805
838
  console.log(`🚀 Worker "${def.name}" starting in DUAL MODE`);
806
839
  console.log(`📡 HTTP API + 🎯 Event Queue Consumer`);
807
840
  // Start HTTP server
808
841
  runHttpServer(def, opts);
809
- // Start queue consumer (if runtime env vars are available)
810
- if (process.env.RUNTIME_SUPABASE_URL && process.env.RUNTIME_SUPABASE_KEY) {
811
- console.log(`🎯 Starting queue consumer for events...`);
812
- // Run queue consumer in background (don't await)
842
+ // Start queue consumer in background (don't await).
843
+ // runWorker() selects the correct mode (SSE vs. legacy) based on env vars.
844
+ if (process.env.NOVA_EVENTS_SERVICE_URL || process.env.RUNTIME_SUPABASE_URL) {
845
+ console.log(`🎯 Starting queue consumer for "${def.name}" (queue: ${def.queue})…`);
813
846
  runWorker(def).catch((error) => {
814
847
  console.error(`[nova] ❌ Queue consumer error:`, error);
815
848
  console.error(`[nova] 💡 HTTP server continues running for direct API access`);
816
849
  });
817
850
  }
818
851
  else {
819
- console.warn(`⚠️ RUNTIME_SUPABASE_* env vars not set - queue consumer disabled`);
820
- console.log(`💡 Worker running in HTTP-only mode. Set RUNTIME_SUPABASE_URL and RUNTIME_SUPABASE_SERVICE_ROLE_KEY to enable event processing`);
852
+ console.warn(`⚠️ No event consumer env vars found queue consumer disabled.\n` +
853
+ ` Set NOVA_EVENTS_SERVICE_URL + NOVA_SERVICE_TOKEN to enable SSE mode.`);
821
854
  }
822
855
  }
823
856
  /*──────────────── Event Outbox (re-exports for convenience) ───────────────*/
package/dist/next.d.ts CHANGED
@@ -319,6 +319,105 @@ export declare function buildPrismaOffsetPage(input: OffsetPageInputType, defaul
319
319
  take: number;
320
320
  orderBy: Record<string, 'asc' | 'desc'>;
321
321
  };
322
+ /**
323
+ * Configuration for a single expandable relation on a list or detail endpoint.
324
+ *
325
+ * The `resolver` receives an array of unique foreign-key IDs collected from the
326
+ * response and must return a `Map<id, expanded_object>` so the framework can
327
+ * replace UUIDs with full objects in a single batch (no N+1 queries).
328
+ *
329
+ * @example
330
+ * ```ts
331
+ * const expandConfig: Record<string, ExpandConfig> = {
332
+ * author: {
333
+ * foreignKey: 'author',
334
+ * resolver: async (ids) => {
335
+ * const users = await db.ticketingUser.findMany({ where: { id: { in: ids } } });
336
+ * return new Map(users.map(u => [u.id, u]));
337
+ * },
338
+ * },
339
+ * contact: {
340
+ * foreignKey: 'contact',
341
+ * resolver: async (ids) => {
342
+ * const contacts = await db.contact.findMany({ where: { id: { in: ids } } });
343
+ * return new Map(contacts.map(c => [c.id, c]));
344
+ * },
345
+ * },
346
+ * };
347
+ * ```
348
+ */
349
+ export interface ExpandConfig {
350
+ /**
351
+ * The field name on the parent record that holds the foreign-key UUID.
352
+ * Usually the same as the expand key name (e.g., `"author"` → `record.author`).
353
+ */
354
+ foreignKey: string;
355
+ /**
356
+ * Batch resolver — given an array of unique IDs, return a Map<id, full_object>.
357
+ * Called once per expand field; never called with an empty array.
358
+ *
359
+ * Use a single `findMany` query to batch-resolve all IDs at once.
360
+ */
361
+ resolver: (ids: string[]) => Promise<Map<string, Record<string, unknown>>>;
362
+ }
363
+ /**
364
+ * Parse the `?expand=` query parameter into an array of field names.
365
+ *
366
+ * Accepts comma-separated values: `?expand=author,contact` → `["author", "contact"]`
367
+ * Returns an empty array when no expand param is present.
368
+ *
369
+ * @example
370
+ * ```ts
371
+ * const expands = parseExpand(input.expand); // ["author", "contact"]
372
+ * ```
373
+ */
374
+ export declare function parseExpand(raw?: string | null): string[];
375
+ /**
376
+ * Apply inline expansion to an array of records.
377
+ *
378
+ * For each field in `expandFields` that has a matching entry in `expandConfig`,
379
+ * this function:
380
+ * 1. Collects all unique foreign-key values from the records
381
+ * 2. Calls the resolver once with the batch of IDs
382
+ * 3. Replaces each UUID value with the full resolved object in-place
383
+ *
384
+ * Records that have `null` / `undefined` for the field are left unchanged.
385
+ * Unknown expand fields (not in `expandConfig`) are silently ignored.
386
+ *
387
+ * @param results Array of records to expand (mutated in-place)
388
+ * @param expandFields List of field names to expand (from `parseExpand()`)
389
+ * @param expandConfig Map of expand configurations keyed by field name
390
+ * @returns The same `results` array with UUIDs replaced by full objects
391
+ *
392
+ * @example
393
+ * ```ts
394
+ * // In a route handler:
395
+ * const expands = parseExpand(input.expand);
396
+ * const expanded = await applyExpand(data, expands, {
397
+ * author: {
398
+ * foreignKey: 'author',
399
+ * resolver: async (ids) => {
400
+ * const users = await db.ticketingUser.findMany({ where: { id: { in: ids } } });
401
+ * return new Map(users.map(u => [u.id, u]));
402
+ * },
403
+ * },
404
+ * });
405
+ * return nova.respond({ results: expanded, next, total_count: count ?? 0 });
406
+ * ```
407
+ */
408
+ export declare function applyExpand<T extends Record<string, any>>(results: T[], expandFields: string[], expandConfig: Record<string, ExpandConfig>): Promise<T[]>;
409
+ /**
410
+ * Standard `params` metadata for the `?expand=` query parameter.
411
+ * Spread into `novaEndpoint({ params: { ...EXPAND_PARAM, ...yourParams } })`.
412
+ *
413
+ * @example
414
+ * ```ts
415
+ * export const nova = novaEndpoint({
416
+ * params: { ...CURSOR_PAGE_PARAMS, ...EXPAND_PARAM, ...myParams },
417
+ * });
418
+ * ```
419
+ */
420
+ export declare const EXPAND_PARAM: Record<string, ParamMeta>;
322
421
  /**
323
422
  * Standard `params` metadata for cursor-based pagination fields.
324
423
  * Spread into `novaEndpoint({ params: { ...CURSOR_PAGE_PARAMS, ...yourParams } })`.
package/dist/next.js CHANGED
@@ -210,7 +210,97 @@ export function buildPrismaOffsetPage(input, defaultSort = 'createdAt') {
210
210
  orderBy: { [col]: input.sort_dir },
211
211
  };
212
212
  }
213
+ /**
214
+ * Parse the `?expand=` query parameter into an array of field names.
215
+ *
216
+ * Accepts comma-separated values: `?expand=author,contact` → `["author", "contact"]`
217
+ * Returns an empty array when no expand param is present.
218
+ *
219
+ * @example
220
+ * ```ts
221
+ * const expands = parseExpand(input.expand); // ["author", "contact"]
222
+ * ```
223
+ */
224
+ export function parseExpand(raw) {
225
+ if (!raw)
226
+ return [];
227
+ return raw.split(',').map(s => s.trim()).filter(Boolean);
228
+ }
229
+ /**
230
+ * Apply inline expansion to an array of records.
231
+ *
232
+ * For each field in `expandFields` that has a matching entry in `expandConfig`,
233
+ * this function:
234
+ * 1. Collects all unique foreign-key values from the records
235
+ * 2. Calls the resolver once with the batch of IDs
236
+ * 3. Replaces each UUID value with the full resolved object in-place
237
+ *
238
+ * Records that have `null` / `undefined` for the field are left unchanged.
239
+ * Unknown expand fields (not in `expandConfig`) are silently ignored.
240
+ *
241
+ * @param results Array of records to expand (mutated in-place)
242
+ * @param expandFields List of field names to expand (from `parseExpand()`)
243
+ * @param expandConfig Map of expand configurations keyed by field name
244
+ * @returns The same `results` array with UUIDs replaced by full objects
245
+ *
246
+ * @example
247
+ * ```ts
248
+ * // In a route handler:
249
+ * const expands = parseExpand(input.expand);
250
+ * const expanded = await applyExpand(data, expands, {
251
+ * author: {
252
+ * foreignKey: 'author',
253
+ * resolver: async (ids) => {
254
+ * const users = await db.ticketingUser.findMany({ where: { id: { in: ids } } });
255
+ * return new Map(users.map(u => [u.id, u]));
256
+ * },
257
+ * },
258
+ * });
259
+ * return nova.respond({ results: expanded, next, total_count: count ?? 0 });
260
+ * ```
261
+ */
262
+ export async function applyExpand(results, expandFields, expandConfig) {
263
+ for (const field of expandFields) {
264
+ const config = expandConfig[field];
265
+ if (!config)
266
+ continue; // unknown expand field — silently skip
267
+ // Collect unique non-null IDs from the foreign-key field
268
+ const ids = [...new Set(results.map(r => r[config.foreignKey]).filter((v) => Boolean(v)))];
269
+ if (ids.length === 0)
270
+ continue;
271
+ // Single batch call — no N+1
272
+ const resolved = await config.resolver(ids);
273
+ // Replace UUID → full object in-place
274
+ for (const item of results) {
275
+ const fkValue = item[config.foreignKey];
276
+ if (typeof fkValue === 'string' && resolved.has(fkValue)) {
277
+ item[config.foreignKey] = resolved.get(fkValue);
278
+ }
279
+ }
280
+ }
281
+ return results;
282
+ }
213
283
  // ─── Standard param metadata ──────────────────────────────────────────────────
284
+ /**
285
+ * Standard `params` metadata for the `?expand=` query parameter.
286
+ * Spread into `novaEndpoint({ params: { ...EXPAND_PARAM, ...yourParams } })`.
287
+ *
288
+ * @example
289
+ * ```ts
290
+ * export const nova = novaEndpoint({
291
+ * params: { ...CURSOR_PAGE_PARAMS, ...EXPAND_PARAM, ...myParams },
292
+ * });
293
+ * ```
294
+ */
295
+ export const EXPAND_PARAM = {
296
+ expand: {
297
+ in: 'query',
298
+ uiType: 'text',
299
+ label: 'Expand',
300
+ description: 'Comma-separated list of relations to expand inline (e.g., "author,contact"). Returns full objects instead of UUIDs.',
301
+ placeholder: 'author,contact',
302
+ },
303
+ };
214
304
  /**
215
305
  * Standard `params` metadata for cursor-based pagination fields.
216
306
  * Spread into `novaEndpoint({ params: { ...CURSOR_PAGE_PARAMS, ...yourParams } })`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newhomestar/sdk",
3
- "version": "0.7.1",
3
+ "version": "0.7.4",
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": {