@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 +41 -2
- package/dist/index.js +43 -10
- package/dist/next.d.ts +99 -0
- package/dist/next.js +90 -0
- package/package.json +1 -1
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
|
-
|
|
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(
|
|
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
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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(`⚠️
|
|
820
|
-
|
|
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