@newhomestar/sdk 0.6.5 → 0.6.8

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
@@ -235,7 +235,7 @@ export type { ZodTypeAny as SchemaAny, ZodTypeAny };
235
235
  export { parseNovaSpec } from "./parseSpec.js";
236
236
  export type { NovaSpec } from "./parseSpec.js";
237
237
  export { defineIntegration, validateIntegration, integrationSchema, integrationEvent, integrationFunction, schema, event, IntegrationDefSchema, } from "./integration.js";
238
- export type { IntegrationDef, IntegrationSchemaDef, IntegrationEventDef, IntegrationFunctionDef, ValidationResult, SchemaType, ParamMeta, ParamIn, ParamUiType, } from "./integration.js";
238
+ export type { IntegrationDef, IntegrationSchemaDef, IntegrationEventDef, IntegrationFunctionDef, ValidationResult, SchemaType, ParamMeta, ParamIn, ParamUiType, SyncMappingDef, SyncMappingFieldDef, } from "./integration.js";
239
239
  export { parseIntegrationSpec, IntegrationSpecSchema } from "./integrationSpec.js";
240
240
  export type { IntegrationSpec } from "./integrationSpec.js";
241
241
  export type WebhookCapability = {
@@ -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.
@@ -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.
package/dist/next.d.ts ADDED
@@ -0,0 +1,336 @@
1
+ /**
2
+ * @newhomestar/sdk/next
3
+ *
4
+ * Utilities for building Nova service endpoints in Next.js App Router.
5
+ * Each route file exports a `nova` constant using `novaEndpoint()`.
6
+ *
7
+ * `nova services push` scans all route files for this export and
8
+ * auto-registers the endpoint in the platform DB (app_service_endpoints).
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * // src/app/api/hris/employees/route.ts
13
+ * import { novaEndpoint } from '@newhomestar/sdk/next';
14
+ * import { z } from 'zod';
15
+ *
16
+ * export const nova = novaEndpoint({
17
+ * method: 'GET',
18
+ * path: '/hris/employees',
19
+ * category: 'employees',
20
+ * input: z.object({ page_size: z.coerce.number().default(25) }),
21
+ * output: z.object({ results: z.array(z.any()), next: z.string().nullable() }),
22
+ * });
23
+ *
24
+ * export async function GET(req: Request) {
25
+ * const input = nova.parseQuery(req);
26
+ * return nova.respond({ results: [], next: null });
27
+ * }
28
+ * ```
29
+ */
30
+ import type { ZodTypeAny, ZodObject } from 'zod';
31
+ import { z } from 'zod';
32
+ export type ParamIn = 'path' | 'query' | 'body' | 'header';
33
+ export type ParamUiType = 'text' | 'textarea' | 'number' | 'integer' | 'boolean' | 'date' | 'datetime' | 'select' | 'multiselect' | 'password' | 'email' | 'url' | 'uuid' | 'json' | 'hidden';
34
+ export interface ParamMeta {
35
+ in: ParamIn;
36
+ uiType?: ParamUiType;
37
+ label?: string;
38
+ description?: string;
39
+ placeholder?: string;
40
+ required?: boolean;
41
+ defaultValue?: unknown;
42
+ options?: Array<{
43
+ label: string;
44
+ value: string | number | boolean;
45
+ }>;
46
+ min?: number;
47
+ max?: number;
48
+ step?: number;
49
+ pattern?: string;
50
+ order?: number;
51
+ group?: string;
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
+ }
105
+ export interface NovaEndpointDef<I extends ZodTypeAny = ZodTypeAny, O extends ZodTypeAny = ZodTypeAny> {
106
+ /** HTTP method */
107
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
108
+ /** REST path template. Use :param for path params. e.g. "/hris/employees/:id" */
109
+ path: string;
110
+ /** Human-readable name shown in Odyssey UI endpoint list */
111
+ name?: string;
112
+ /** Human-readable description shown in Odyssey UI endpoint tester */
113
+ description?: string;
114
+ /** Endpoint group for Odyssey UI organization */
115
+ category?: string;
116
+ /** Reference to a service schema slug for the response */
117
+ responseSchema?: string;
118
+ /** Zod schema for query/path params — converted to JSON Schema on push */
119
+ input: I;
120
+ /** Zod schema for the response body — converted to JSON Schema on push */
121
+ output: O;
122
+ /** Per-field parameter metadata */
123
+ params?: Record<string, ParamMeta>;
124
+ /** Whether this endpoint requires a valid JWT (default: true) */
125
+ requiresAuth?: boolean;
126
+ /** Whether this endpoint returns sensitive data (SSN, DOB, etc.) */
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[];
154
+ }
155
+ export type NovaEndpoint<I extends ZodTypeAny = ZodTypeAny, O extends ZodTypeAny = ZodTypeAny> = NovaEndpointDef<I, O> & {
156
+ /**
157
+ * Parse and validate URL query string params against the input schema.
158
+ * Uses z.coerce for string→number/boolean conversion automatically.
159
+ */
160
+ parseQuery(req: Request): ReturnType<I['parse']>;
161
+ /**
162
+ * Parse and validate JSON request body against the input schema.
163
+ */
164
+ parseBody(req: Request): Promise<ReturnType<I['parse']>>;
165
+ /**
166
+ * Validate output data and serialize to a Response.
167
+ */
168
+ respond(data: ReturnType<O['parse']>, status?: number): Response;
169
+ };
170
+ /**
171
+ * novaEndpoint() — define a Nova service endpoint co-located with its route.
172
+ *
173
+ * Export the result as `nova` and `nova services push` will auto-discover
174
+ * and register this endpoint in the platform DB.
175
+ *
176
+ * @example
177
+ * ```ts
178
+ * export const nova = novaEndpoint({
179
+ * method: 'GET',
180
+ * path: '/hris/employees',
181
+ * input: z.object({ page_size: z.coerce.number().default(25) }),
182
+ * output: z.object({ results: z.array(EmployeeZod), next: z.string().nullable() }),
183
+ * });
184
+ * ```
185
+ */
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>;
package/dist/next.js ADDED
@@ -0,0 +1,240 @@
1
+ /**
2
+ * @newhomestar/sdk/next
3
+ *
4
+ * Utilities for building Nova service endpoints in Next.js App Router.
5
+ * Each route file exports a `nova` constant using `novaEndpoint()`.
6
+ *
7
+ * `nova services push` scans all route files for this export and
8
+ * auto-registers the endpoint in the platform DB (app_service_endpoints).
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * // src/app/api/hris/employees/route.ts
13
+ * import { novaEndpoint } from '@newhomestar/sdk/next';
14
+ * import { z } from 'zod';
15
+ *
16
+ * export const nova = novaEndpoint({
17
+ * method: 'GET',
18
+ * path: '/hris/employees',
19
+ * category: 'employees',
20
+ * input: z.object({ page_size: z.coerce.number().default(25) }),
21
+ * output: z.object({ results: z.array(z.any()), next: z.string().nullable() }),
22
+ * });
23
+ *
24
+ * export async function GET(req: Request) {
25
+ * const input = nova.parseQuery(req);
26
+ * return nova.respond({ results: [], next: null });
27
+ * }
28
+ * ```
29
+ */
30
+ import { z } from 'zod';
31
+ // ─── Factory function ─────────────────────────────────────────────────────────
32
+ /**
33
+ * novaEndpoint() — define a Nova service endpoint co-located with its route.
34
+ *
35
+ * Export the result as `nova` and `nova services push` will auto-discover
36
+ * and register this endpoint in the platform DB.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * export const nova = novaEndpoint({
41
+ * method: 'GET',
42
+ * path: '/hris/employees',
43
+ * input: z.object({ page_size: z.coerce.number().default(25) }),
44
+ * output: z.object({ results: z.array(EmployeeZod), next: z.string().nullable() }),
45
+ * });
46
+ * ```
47
+ */
48
+ export function novaEndpoint(cfg) {
49
+ return {
50
+ ...cfg,
51
+ parseQuery(req) {
52
+ const url = new URL(req.url);
53
+ const raw = {};
54
+ url.searchParams.forEach((value, key) => {
55
+ raw[key] = value;
56
+ });
57
+ return cfg.input.parse(raw);
58
+ },
59
+ async parseBody(req) {
60
+ const body = await req.json();
61
+ return cfg.input.parse(body);
62
+ },
63
+ respond(data, init) {
64
+ const status = typeof init === 'number' ? init : (init?.status ?? 200);
65
+ const validated = cfg.output.parse(data);
66
+ return Response.json(validated, { status });
67
+ },
68
+ };
69
+ }
70
+ /**
71
+ * Encode a record into an opaque base64url cursor string.
72
+ * The cursor encodes `id` and `created_at` for stable keyset pagination.
73
+ */
74
+ export function encodeCursor(record) {
75
+ const payload = { id: record.id, created_at: record.created_at };
76
+ return Buffer.from(JSON.stringify(payload)).toString('base64url');
77
+ }
78
+ /**
79
+ * Decode a cursor string back into its `id` and `created_at` fields.
80
+ * Throws a descriptive error if the cursor is malformed.
81
+ */
82
+ export function parseCursor(cursor) {
83
+ try {
84
+ const json = Buffer.from(cursor, 'base64url').toString('utf-8');
85
+ const parsed = JSON.parse(json);
86
+ if (!parsed.id || !parsed.created_at)
87
+ throw new Error('Missing required cursor fields');
88
+ return parsed;
89
+ }
90
+ catch {
91
+ throw new Error(`Invalid cursor: "${cursor}". Cursors must be generated by encodeCursor().`);
92
+ }
93
+ }
94
+ /**
95
+ * Build the unified `PagedResponse` from a DB result set.
96
+ *
97
+ * @param results Mapped row objects for this page
98
+ * @param total Total count of ALL records (pre-filter)
99
+ * @param filteredTotal Count after search/filters applied (pass same as `total` if no filter active)
100
+ * @param pageSize The page_size / limit that was requested
101
+ */
102
+ export function buildPageResponse(results, total, filteredTotal, pageSize) {
103
+ return {
104
+ results,
105
+ total,
106
+ filtered_total: filteredTotal,
107
+ next: results.length === pageSize ? encodeCursor(results[results.length - 1]) : null,
108
+ };
109
+ }
110
+ // ─── Zod schemas ─────────────────────────────────────────────────────────────
111
+ /**
112
+ * Standard input schema for cursor-based list endpoints (programmatic APIs).
113
+ * Spread into `novaEndpoint({ input: CursorPageInput.extend({ ... }) })` to add resource filters.
114
+ *
115
+ * @example
116
+ * ```ts
117
+ * input: CursorPageInput.extend({ employee_id: z.string().uuid().optional() })
118
+ * ```
119
+ */
120
+ export const CursorPageInput = z.object({
121
+ page_size: z.coerce.number().min(1).max(100).default(25),
122
+ cursor: z.string().optional(),
123
+ modified_after: z.string().datetime().optional(),
124
+ include_deleted: z.coerce.boolean().default(false),
125
+ });
126
+ /**
127
+ * Standard input schema for offset-based list endpoints (UI-facing browsing).
128
+ * Matches what `ParquetDataTable` / Odyssey UI data browsers send.
129
+ *
130
+ * @example
131
+ * ```ts
132
+ * input: OffsetPageInput.extend({ status: StatusEnum.optional() })
133
+ * ```
134
+ */
135
+ export const OffsetPageInput = z.object({
136
+ limit: z.coerce.number().min(1).max(500).default(20),
137
+ offset: z.coerce.number().min(0).default(0),
138
+ search: z.string().optional(),
139
+ sort: z.string().optional(),
140
+ sort_dir: z.enum(['asc', 'desc']).default('desc'),
141
+ filters: z.string().optional(), // JSON-encoded FilterCondition[]
142
+ });
143
+ /**
144
+ * Build the Zod output schema for any paginated list endpoint.
145
+ * Always produces `{ results, total, filtered_total, next }`.
146
+ *
147
+ * @example
148
+ * ```ts
149
+ * output: paginatedOutput(HrisEmployeeZod)
150
+ * ```
151
+ */
152
+ export function paginatedOutput(itemSchema) {
153
+ return z.object({
154
+ results: z.array(itemSchema),
155
+ total: z.number().describe('Total record count (unfiltered)'),
156
+ filtered_total: z.number().describe('Record count after active search/filters'),
157
+ next: z.string().nullable().describe('Cursor for next page; null if last page'),
158
+ });
159
+ }
160
+ // ─── Prisma helpers ───────────────────────────────────────────────────────────
161
+ /**
162
+ * Build Prisma `where`, `take`, and `orderBy` from a `CursorPageInput`.
163
+ * Handles cursor keyset pagination and `modified_after` filtering.
164
+ * Merge with resource-specific filters before passing to Prisma.
165
+ *
166
+ * @example
167
+ * ```ts
168
+ * const page = buildPrismaPage(input);
169
+ * const [data, total] = await Promise.all([
170
+ * db.hrisEmployee.findMany({ where: { remoteWasDeleted: input.include_deleted, ...page.where }, take: page.take, orderBy: page.orderBy }),
171
+ * db.hrisEmployee.count({ where: { remoteWasDeleted: input.include_deleted, ...page.where } }),
172
+ * ]);
173
+ * return nova.respond(buildPageResponse(data.map(mapRow), total, total, input.page_size));
174
+ * ```
175
+ */
176
+ export function buildPrismaPage(input) {
177
+ const where = {};
178
+ if (input.modified_after) {
179
+ where.modifiedAt = { gte: new Date(input.modified_after) };
180
+ }
181
+ if (input.cursor) {
182
+ const { id, created_at } = parseCursor(input.cursor);
183
+ where.OR = [
184
+ { createdAt: { lt: new Date(created_at) } },
185
+ { createdAt: new Date(created_at), id: { lt: id } },
186
+ ];
187
+ }
188
+ return {
189
+ where,
190
+ take: input.page_size,
191
+ orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
192
+ };
193
+ }
194
+ /**
195
+ * Build Prisma `skip`, `take`, and `orderBy` from an `OffsetPageInput`.
196
+ * Handles offset pagination and sort direction.
197
+ * Does NOT handle `search` or `filters` — apply those to the `where` clause manually.
198
+ *
199
+ * @example
200
+ * ```ts
201
+ * const page = buildPrismaOffsetPage(input);
202
+ * const data = await db.example.findMany({ where, skip: page.skip, take: page.take, orderBy: page.orderBy });
203
+ * ```
204
+ */
205
+ export function buildPrismaOffsetPage(input, defaultSort = 'createdAt') {
206
+ const col = input.sort ?? defaultSort;
207
+ return {
208
+ skip: input.offset,
209
+ take: input.limit,
210
+ orderBy: { [col]: input.sort_dir },
211
+ };
212
+ }
213
+ // ─── Standard param metadata ──────────────────────────────────────────────────
214
+ /**
215
+ * Standard `params` metadata for cursor-based pagination fields.
216
+ * Spread into `novaEndpoint({ params: { ...CURSOR_PAGE_PARAMS, ...yourParams } })`.
217
+ */
218
+ export const CURSOR_PAGE_PARAMS = {
219
+ page_size: { in: 'query', uiType: 'number', label: 'Page Size', description: 'Records per page (1–100, default 25)' },
220
+ cursor: { in: 'query', uiType: 'text', label: 'Cursor', description: 'Opaque pagination cursor from previous response' },
221
+ modified_after: { in: 'query', uiType: 'datetime', label: 'Modified After', description: 'Return records modified after this ISO 8601 timestamp' },
222
+ include_deleted: { in: 'query', uiType: 'boolean', label: 'Include Deleted', description: 'Include soft-deleted records (default false)' },
223
+ };
224
+ /**
225
+ * Standard `params` metadata for offset-based pagination fields.
226
+ * Spread into `novaEndpoint({ params: { ...OFFSET_PAGE_PARAMS, ...yourParams } })`.
227
+ */
228
+ export const OFFSET_PAGE_PARAMS = {
229
+ limit: { in: 'query', uiType: 'number', label: 'Limit', description: 'Records per page (1–500, default 20)' },
230
+ offset: { in: 'query', uiType: 'number', label: 'Offset', description: 'Number of records to skip' },
231
+ search: { in: 'query', uiType: 'text', label: 'Search', description: 'Free-text search across indexed fields' },
232
+ sort: { in: 'query', uiType: 'text', label: 'Sort By', description: 'Column name to sort by' },
233
+ sort_dir: { in: 'query', uiType: 'select', label: 'Sort Dir', description: 'asc or desc', options: [{ label: 'Ascending', value: 'asc' }, { label: 'Descending', value: 'desc' }] },
234
+ filters: { in: 'query', uiType: 'json', label: 'Filters', description: 'JSON array of FilterCondition objects' },
235
+ };
236
+ // ─── Legacy re-exports (backward compat) ─────────────────────────────────────
237
+ /** @deprecated Use `buildPageResponse` instead */
238
+ export function buildPaginatedResponse(data, count, pageSize) {
239
+ return buildPageResponse(data, count ?? 0, count ?? 0, pageSize);
240
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newhomestar/sdk",
3
- "version": "0.6.5",
3
+ "version": "0.6.8",
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": {
@@ -19,6 +19,10 @@
19
19
  ".": {
20
20
  "import": "./dist/index.js",
21
21
  "types": "./dist/index.d.ts"
22
+ },
23
+ "./next": {
24
+ "import": "./dist/next.js",
25
+ "types": "./dist/next.d.ts"
22
26
  }
23
27
  },
24
28
  "files": [