postgresdk 0.16.10 → 0.16.13

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/README.md CHANGED
@@ -146,6 +146,7 @@ export default {
146
146
  schema: "public", // Database schema to introspect
147
147
  outDir: "./api", // Output directory (or { client: "./sdk", server: "./api" })
148
148
  softDeleteColumn: null, // Column name for soft deletes (e.g., "deleted_at")
149
+ numericMode: "auto", // "auto" | "number" | "string" - How to type numeric columns
149
150
  includeMethodsDepth: 2, // Max depth for nested includes
150
151
  dateType: "date", // "date" | "string" - How to handle timestamps
151
152
  serverFramework: "hono", // Currently only hono is supported
@@ -175,6 +176,14 @@ export default {
175
176
  };
176
177
  ```
177
178
 
179
+ #### Type Mapping (numericMode)
180
+
181
+ Controls how PostgreSQL numeric types map to TypeScript:
182
+
183
+ - **`"auto"` (default)**: `int2`/`int4`/floats → `number`, `int8`/`numeric` → `string`
184
+ - **`"number"`**: All numeric → `number` (⚠️ unsafe for bigint - JS can't handle values > 2^53)
185
+ - **`"string"`**: All numeric → `string` (safe but requires parsing)
186
+
178
187
  ### Database Drivers
179
188
 
180
189
  The generated code works with any PostgreSQL client that implements a simple `query` interface:
@@ -584,6 +593,58 @@ const nestedResult = await sdk.authors.list({
584
593
  const authorsWithBooksAndTags = nestedResult.data;
585
594
  ```
586
595
 
596
+ **Typed Include Methods:**
597
+
598
+ For convenience and better type safety, the SDK generates `listWith*` and `getByPkWith*` methods for common include patterns:
599
+
600
+ ```typescript
601
+ // Typed methods provide full autocomplete and type safety
602
+ const result = await sdk.authors.listWithBooks();
603
+ // result.data[0].books is typed as SelectBooks[]
604
+
605
+ // Control included relations with options
606
+ const topAuthors = await sdk.authors.listWithBooks({
607
+ limit: 10,
608
+ booksInclude: {
609
+ orderBy: 'published_at',
610
+ order: 'desc',
611
+ limit: 5 // Only get top 5 books per author
612
+ }
613
+ });
614
+
615
+ // Parallel includes (multiple relations at once)
616
+ const result2 = await sdk.books.listWithAuthorAndTags({
617
+ tagsInclude: {
618
+ orderBy: 'name',
619
+ limit: 3
620
+ }
621
+ // author is included automatically (one-to-one)
622
+ });
623
+
624
+ // Nested includes with control at each level
625
+ const result3 = await sdk.authors.listWithBooksAndTags({
626
+ booksInclude: {
627
+ orderBy: 'title',
628
+ limit: 10,
629
+ include: {
630
+ tags: {
631
+ orderBy: 'name',
632
+ order: 'asc',
633
+ limit: 5
634
+ }
635
+ }
636
+ }
637
+ });
638
+
639
+ // Works with getByPk too
640
+ const author = await sdk.authors.getByPkWithBooks('author-id', {
641
+ booksInclude: {
642
+ orderBy: 'published_at',
643
+ limit: 3
644
+ }
645
+ });
646
+ ```
647
+
587
648
  #### Filtering & Pagination
588
649
 
589
650
  All `list()` methods return pagination metadata:
package/dist/cli.js CHANGED
@@ -1751,6 +1751,15 @@ function extractConfigFields(configContent) {
1751
1751
  isCommented: !!depthMatch[1]
1752
1752
  });
1753
1753
  }
1754
+ const numericModeMatch = configContent.match(/^\s*(\/\/)?\s*numericMode:\s*"(.+)"/m);
1755
+ if (numericModeMatch) {
1756
+ fields.push({
1757
+ key: "numericMode",
1758
+ value: numericModeMatch[2],
1759
+ description: "How to type numeric columns in TypeScript",
1760
+ isCommented: !!numericModeMatch[1]
1761
+ });
1762
+ }
1754
1763
  const frameworkMatch = configContent.match(/^\s*(\/\/)?\s*serverFramework:\s*"(.+)"/m);
1755
1764
  if (frameworkMatch) {
1756
1765
  fields.push({
@@ -1918,7 +1927,16 @@ export default {
1918
1927
  * @example "deleted_at"
1919
1928
  */
1920
1929
  ${getFieldLine("softDeleteColumn", existingFields, mergeStrategy, "null", userChoices)}
1921
-
1930
+
1931
+ /**
1932
+ * How to type numeric columns in TypeScript
1933
+ * - "auto": int2/int4/float → number, int8/numeric → string (recommended)
1934
+ * - "number": All numeric types become TypeScript number (unsafe for bigint)
1935
+ * - "string": All numeric types become TypeScript string (legacy)
1936
+ * @default "auto"
1937
+ */
1938
+ ${getFieldLine("numericMode", existingFields, mergeStrategy, '"auto"', userChoices)}
1939
+
1922
1940
  /**
1923
1941
  * Maximum depth for nested relationship includes to prevent infinite loops
1924
1942
  * @default 2
@@ -2347,6 +2365,20 @@ export default {
2347
2365
  */
2348
2366
  // softDeleteColumn: null,
2349
2367
 
2368
+ /**
2369
+ * How to type numeric columns in TypeScript
2370
+ * Options:
2371
+ * - "auto": int2/int4/float → number, int8/numeric → string (recommended, default)
2372
+ * - "number": All numeric types become TypeScript number (unsafe for bigint)
2373
+ * - "string": All numeric types become TypeScript string (legacy behavior)
2374
+ *
2375
+ * Auto mode is safest - keeps JavaScript-safe integers as numbers,
2376
+ * but preserves precision for bigint/numeric by using strings.
2377
+ *
2378
+ * Default: "auto"
2379
+ */
2380
+ // numericMode: "auto",
2381
+
2350
2382
  /**
2351
2383
  * Maximum depth for nested relationship includes to prevent infinite loops
2352
2384
  * Default: 2
@@ -2810,7 +2842,7 @@ function emitIncludeSpec(graph) {
2810
2842
  `;
2811
2843
  for (const [relKey, edge] of entries) {
2812
2844
  if (edge.kind === "many") {
2813
- out += ` ${relKey}?: boolean | { include?: ${toPascal(edge.target)}IncludeSpec; limit?: number; offset?: number; };
2845
+ out += ` ${relKey}?: boolean | { include?: ${toPascal(edge.target)}IncludeSpec; limit?: number; offset?: number; orderBy?: string; order?: "asc" | "desc"; };
2814
2846
  `;
2815
2847
  } else {
2816
2848
  out += ` ${relKey}?: boolean | ${toPascal(edge.target)}IncludeSpec;
@@ -2879,10 +2911,20 @@ function emitZod(table, opts, enums) {
2879
2911
  return `z.string()`;
2880
2912
  if (t === "bool" || t === "boolean")
2881
2913
  return `z.boolean()`;
2882
- if (t === "int2" || t === "int4" || t === "int8")
2883
- return opts.numericMode === "number" ? `z.number()` : `z.string()`;
2884
- if (t === "numeric" || t === "float4" || t === "float8")
2885
- return opts.numericMode === "number" ? `z.number()` : `z.string()`;
2914
+ if (t === "int2" || t === "int4" || t === "int8") {
2915
+ if (opts.numericMode === "number")
2916
+ return `z.number()`;
2917
+ if (opts.numericMode === "string")
2918
+ return `z.string()`;
2919
+ return t === "int2" || t === "int4" ? `z.number()` : `z.string()`;
2920
+ }
2921
+ if (t === "numeric" || t === "float4" || t === "float8") {
2922
+ if (opts.numericMode === "number")
2923
+ return `z.number()`;
2924
+ if (opts.numericMode === "string")
2925
+ return `z.string()`;
2926
+ return t === "float4" || t === "float8" ? `z.number()` : `z.string()`;
2927
+ }
2886
2928
  if (t === "jsonb" || t === "json")
2887
2929
  return `z.unknown()`;
2888
2930
  if (t === "date" || t.startsWith("timestamp"))
@@ -3255,6 +3297,24 @@ function isJsonbType(pgType) {
3255
3297
  const t = pgType.toLowerCase();
3256
3298
  return t === "json" || t === "jsonb";
3257
3299
  }
3300
+ function toIncludeParamName(relationKey) {
3301
+ return `${relationKey}Include`;
3302
+ }
3303
+ function analyzeIncludeSpec(includeSpec) {
3304
+ const keys = Object.keys(includeSpec);
3305
+ if (keys.length > 1) {
3306
+ return { type: "parallel", keys };
3307
+ }
3308
+ const key = keys[0];
3309
+ if (!key) {
3310
+ return { type: "single", keys: [] };
3311
+ }
3312
+ const value = includeSpec[key];
3313
+ if (typeof value === "object" && value !== null) {
3314
+ return { type: "nested", keys: [key], nestedKey: key, nestedValue: value };
3315
+ }
3316
+ return { type: "single", keys: [key] };
3317
+ }
3258
3318
  function emitClient(table, graph, opts, model) {
3259
3319
  const Type = pascal(table.name);
3260
3320
  const ext = opts.useJsExtensions ? ".js" : "";
@@ -3293,39 +3353,157 @@ function emitClient(table, graph, opts, model) {
3293
3353
  let includeMethodsCode = "";
3294
3354
  for (const method of includeMethods) {
3295
3355
  const isGetByPk = method.name.startsWith("getByPk");
3296
- const baseParams = isGetByPk ? "" : `params?: Omit<{ limit?: number; offset?: number; where?: Where<Select${Type}>; orderBy?: string | string[]; order?: "asc" | "desc" | ("asc" | "desc")[]; }, "include">`;
3356
+ const pattern = analyzeIncludeSpec(method.includeSpec);
3297
3357
  const relationshipDesc = method.path.map((p, i) => {
3298
3358
  const isLast = i === method.path.length - 1;
3299
3359
  const relation = method.isMany[i] ? "many" : "one";
3300
3360
  return isLast ? p : `${p} -> `;
3301
3361
  }).join("");
3362
+ let paramsType = "";
3363
+ const includeParamNames = [];
3364
+ if (pattern.type === "single") {
3365
+ const key = pattern.keys[0];
3366
+ if (key) {
3367
+ const paramName = toIncludeParamName(key);
3368
+ includeParamNames.push(paramName);
3369
+ paramsType = `{
3370
+ limit?: number;
3371
+ offset?: number;
3372
+ where?: Where<Select${Type}>;
3373
+ orderBy?: string | string[];
3374
+ order?: "asc" | "desc" | ("asc" | "desc")[];
3375
+ ${paramName}?: {
3376
+ orderBy?: string | string[];
3377
+ order?: "asc" | "desc";
3378
+ limit?: number;
3379
+ offset?: number;
3380
+ };
3381
+ }`;
3382
+ }
3383
+ } else if (pattern.type === "parallel") {
3384
+ const includeParams = pattern.keys.map((key) => {
3385
+ const paramName = toIncludeParamName(key);
3386
+ includeParamNames.push(paramName);
3387
+ return `${paramName}?: {
3388
+ orderBy?: string | string[];
3389
+ order?: "asc" | "desc";
3390
+ limit?: number;
3391
+ offset?: number;
3392
+ }`;
3393
+ }).join(`;
3394
+ `);
3395
+ paramsType = `{
3396
+ limit?: number;
3397
+ offset?: number;
3398
+ where?: Where<Select${Type}>;
3399
+ orderBy?: string | string[];
3400
+ order?: "asc" | "desc" | ("asc" | "desc")[];
3401
+ ${includeParams};
3402
+ }`;
3403
+ } else if (pattern.type === "nested" && pattern.nestedKey) {
3404
+ const paramName = toIncludeParamName(pattern.nestedKey);
3405
+ includeParamNames.push(paramName);
3406
+ paramsType = `{
3407
+ limit?: number;
3408
+ offset?: number;
3409
+ where?: Where<Select${Type}>;
3410
+ orderBy?: string | string[];
3411
+ order?: "asc" | "desc" | ("asc" | "desc")[];
3412
+ ${paramName}?: {
3413
+ orderBy?: string | string[];
3414
+ order?: "asc" | "desc";
3415
+ limit?: number;
3416
+ offset?: number;
3417
+ include?: any;
3418
+ };
3419
+ }`;
3420
+ }
3302
3421
  if (isGetByPk) {
3303
3422
  const pkWhere = hasCompositePk ? `{ ${safePk.map((col) => `${col}: pk.${col}`).join(", ")} }` : `{ ${safePk[0] || "id"}: pk }`;
3304
3423
  const baseReturnType = method.returnType.replace(" | null", "");
3424
+ let transformCode = "";
3425
+ if (includeParamNames.length > 0) {
3426
+ const destructure = includeParamNames.map((name) => name).join(", ");
3427
+ if (pattern.type === "single") {
3428
+ const key = pattern.keys[0];
3429
+ const paramName = includeParamNames[0];
3430
+ transformCode = `
3431
+ const { ${destructure} } = params ?? {};
3432
+ const includeSpec = ${paramName} ? { ${key}: ${paramName} } : ${JSON.stringify(method.includeSpec)};`;
3433
+ } else if (pattern.type === "parallel") {
3434
+ const includeSpecCode = pattern.keys.map((key, idx) => {
3435
+ const paramName = includeParamNames[idx];
3436
+ return `${key}: ${paramName} ?? true`;
3437
+ }).join(", ");
3438
+ transformCode = `
3439
+ const { ${destructure} } = params ?? {};
3440
+ const includeSpec = { ${includeSpecCode} };`;
3441
+ } else if (pattern.type === "nested" && pattern.nestedKey) {
3442
+ const key = pattern.nestedKey;
3443
+ const paramName = includeParamNames[0];
3444
+ transformCode = `
3445
+ const { ${destructure} } = params ?? {};
3446
+ const includeSpec = ${paramName} ? { ${key}: ${paramName} } : ${JSON.stringify(method.includeSpec)};`;
3447
+ }
3448
+ } else {
3449
+ transformCode = `
3450
+ const includeSpec = ${JSON.stringify(method.includeSpec)};`;
3451
+ }
3305
3452
  includeMethodsCode += `
3306
3453
  /**
3307
3454
  * Get a ${table.name} record by primary key with included related ${relationshipDesc}
3308
3455
  * @param pk - The primary key value${hasCompositePk ? "s" : ""}
3456
+ * @param params - Optional include options
3309
3457
  * @returns The record with nested ${method.path.join(" and ")} if found, null otherwise
3310
3458
  */
3311
- async ${method.name}(pk: ${pkType}): Promise<${method.returnType}> {
3459
+ async ${method.name}(pk: ${pkType}, params?: ${paramsType}): Promise<${method.returnType}> {${transformCode}
3312
3460
  const results = await this.post<PaginatedResponse<${baseReturnType}>>(\`\${this.resource}/list\`, {
3313
3461
  where: ${pkWhere},
3314
- include: ${JSON.stringify(method.includeSpec)},
3462
+ include: includeSpec,
3315
3463
  limit: 1
3316
3464
  });
3317
3465
  return (results.data[0] as ${baseReturnType}) ?? null;
3318
3466
  }
3319
3467
  `;
3320
3468
  } else {
3469
+ let transformCode = "";
3470
+ if (includeParamNames.length > 0) {
3471
+ const destructure = includeParamNames.map((name) => name).join(", ");
3472
+ if (pattern.type === "single") {
3473
+ const key = pattern.keys[0];
3474
+ const paramName = includeParamNames[0];
3475
+ transformCode = `
3476
+ const { ${destructure}, ...baseParams } = params ?? {};
3477
+ const includeSpec = ${paramName} ? { ${key}: ${paramName} } : ${JSON.stringify(method.includeSpec)};
3478
+ return this.post<${method.returnType}>(\`\${this.resource}/list\`, { ...baseParams, include: includeSpec });`;
3479
+ } else if (pattern.type === "parallel") {
3480
+ const includeSpecCode = pattern.keys.map((key, idx) => {
3481
+ const paramName = includeParamNames[idx];
3482
+ return `${key}: ${paramName} ?? true`;
3483
+ }).join(", ");
3484
+ transformCode = `
3485
+ const { ${destructure}, ...baseParams } = params ?? {};
3486
+ const includeSpec = { ${includeSpecCode} };
3487
+ return this.post<${method.returnType}>(\`\${this.resource}/list\`, { ...baseParams, include: includeSpec });`;
3488
+ } else if (pattern.type === "nested" && pattern.nestedKey) {
3489
+ const key = pattern.nestedKey;
3490
+ const paramName = includeParamNames[0];
3491
+ transformCode = `
3492
+ const { ${destructure}, ...baseParams } = params ?? {};
3493
+ const includeSpec = ${paramName} ? { ${key}: ${paramName} } : ${JSON.stringify(method.includeSpec)};
3494
+ return this.post<${method.returnType}>(\`\${this.resource}/list\`, { ...baseParams, include: includeSpec });`;
3495
+ }
3496
+ } else {
3497
+ transformCode = `
3498
+ return this.post<${method.returnType}>(\`\${this.resource}/list\`, { ...params, include: ${JSON.stringify(method.includeSpec)} });`;
3499
+ }
3321
3500
  includeMethodsCode += `
3322
3501
  /**
3323
3502
  * List ${table.name} records with included related ${relationshipDesc}
3324
- * @param params - Query parameters (where, orderBy, order, limit, offset)
3503
+ * @param params - Query parameters (where, orderBy, order, limit, offset) and include options
3325
3504
  * @returns Paginated results with nested ${method.path.join(" and ")} included
3326
3505
  */
3327
- async ${method.name}(${baseParams}): Promise<${method.returnType}> {
3328
- return this.post<${method.returnType}>(\`\${this.resource}/list\`, { ...params, include: ${JSON.stringify(method.includeSpec)} });
3506
+ async ${method.name}(params?: ${paramsType}): Promise<${method.returnType}> {${transformCode}
3329
3507
  }
3330
3508
  `;
3331
3509
  }
@@ -3402,7 +3580,7 @@ ${hasJsonbColumns ? ` /**
3402
3580
  * @param params.where - Filter conditions using operators like $eq, $gt, $in, $like, etc.
3403
3581
  * @param params.orderBy - Column(s) to sort by
3404
3582
  * @param params.order - Sort direction(s): "asc" or "desc"
3405
- * @param params.limit - Maximum number of records to return (default: 50, max: 100)
3583
+ * @param params.limit - Maximum number of records to return (default: 50, max: 1000)
3406
3584
  * @param params.offset - Number of records to skip for pagination
3407
3585
  * @param params.include - Related records to include (see listWith* methods for typed includes)
3408
3586
  * @returns Paginated results with data, total count, and hasMore flag
@@ -3431,7 +3609,7 @@ ${hasJsonbColumns ? ` /**
3431
3609
  * @param params.where - Filter conditions using operators like $eq, $gt, $in, $like, etc.
3432
3610
  * @param params.orderBy - Column(s) to sort by
3433
3611
  * @param params.order - Sort direction(s): "asc" or "desc"
3434
- * @param params.limit - Maximum number of records to return (default: 50, max: 100)
3612
+ * @param params.limit - Maximum number of records to return (default: 50, max: 1000)
3435
3613
  * @param params.offset - Number of records to skip for pagination
3436
3614
  * @param params.include - Related records to include (see listWith* methods for typed includes)
3437
3615
  * @returns Paginated results with data, total count, and hasMore flag
@@ -3768,6 +3946,13 @@ type Graph = typeof RELATION_GRAPH;
3768
3946
  type TableName = keyof Graph;
3769
3947
  type IncludeSpec = any;
3770
3948
 
3949
+ type RelationOptions = {
3950
+ limit?: number;
3951
+ offset?: number;
3952
+ orderBy?: string;
3953
+ order?: "asc" | "desc";
3954
+ };
3955
+
3771
3956
  // Debug helpers (enabled with SDK_DEBUG=1)
3772
3957
  const DEBUG = process.env.SDK_DEBUG === "1" || process.env.SDK_DEBUG === "true";
3773
3958
  const log = {
@@ -3869,14 +4054,43 @@ export async function loadIncludes(
3869
4054
  // Safely run each loader; never let one bad edge 500 the route
3870
4055
  if (rel.via) {
3871
4056
  // M:N via junction
4057
+ const specValue = s[key];
4058
+ const options: RelationOptions = {};
4059
+ let childSpec: any = undefined;
4060
+
4061
+ if (specValue && typeof specValue === "object" && specValue !== true) {
4062
+ // Extract options
4063
+ if (specValue.limit !== undefined) options.limit = specValue.limit;
4064
+ if (specValue.offset !== undefined) options.offset = specValue.offset;
4065
+ if (specValue.orderBy !== undefined) options.orderBy = specValue.orderBy;
4066
+ if (specValue.order !== undefined) options.order = specValue.order;
4067
+
4068
+ // Extract nested spec - support both formats:
4069
+ // New: { limit: 3, include: { tags: true } }
4070
+ // Old: { tags: true } (backward compatibility)
4071
+ if (specValue.include !== undefined) {
4072
+ childSpec = specValue.include;
4073
+ } else {
4074
+ // Build childSpec from non-option keys
4075
+ const nonOptionKeys = Object.keys(specValue).filter(
4076
+ k => k !== 'limit' && k !== 'offset' && k !== 'orderBy' && k !== 'order'
4077
+ );
4078
+ if (nonOptionKeys.length > 0) {
4079
+ childSpec = {};
4080
+ for (const k of nonOptionKeys) {
4081
+ childSpec[k] = specValue[k];
4082
+ }
4083
+ }
4084
+ }
4085
+ }
4086
+
3872
4087
  try {
3873
- await loadManyToMany(table, target, rel.via as string, rows, key);
4088
+ await loadManyToMany(table, target, rel.via as string, rows, key, options);
3874
4089
  } catch (e: any) {
3875
4090
  log.error("loadManyToMany failed", { table, key, via: rel.via, target }, e?.message ?? e);
3876
4091
  for (const r of rows) r[key] = [];
3877
4092
  }
3878
- // Recurse if nested include specified
3879
- const childSpec = s[key] && typeof s[key] === "object" ? s[key] : undefined;
4093
+
3880
4094
  if (childSpec) {
3881
4095
  const children = rows.flatMap(r => (r[key] ?? []));
3882
4096
  try {
@@ -3890,13 +4104,43 @@ export async function loadIncludes(
3890
4104
 
3891
4105
  if (rel.kind === "many") {
3892
4106
  // 1:N target has FK to current
4107
+ const specValue = s[key];
4108
+ const options: RelationOptions = {};
4109
+ let childSpec: any = undefined;
4110
+
4111
+ if (specValue && typeof specValue === "object" && specValue !== true) {
4112
+ // Extract options
4113
+ if (specValue.limit !== undefined) options.limit = specValue.limit;
4114
+ if (specValue.offset !== undefined) options.offset = specValue.offset;
4115
+ if (specValue.orderBy !== undefined) options.orderBy = specValue.orderBy;
4116
+ if (specValue.order !== undefined) options.order = specValue.order;
4117
+
4118
+ // Extract nested spec - support both formats:
4119
+ // New: { limit: 3, include: { tags: true } }
4120
+ // Old: { tags: true } (backward compatibility)
4121
+ if (specValue.include !== undefined) {
4122
+ childSpec = specValue.include;
4123
+ } else {
4124
+ // Build childSpec from non-option keys
4125
+ const nonOptionKeys = Object.keys(specValue).filter(
4126
+ k => k !== 'limit' && k !== 'offset' && k !== 'orderBy' && k !== 'order'
4127
+ );
4128
+ if (nonOptionKeys.length > 0) {
4129
+ childSpec = {};
4130
+ for (const k of nonOptionKeys) {
4131
+ childSpec[k] = specValue[k];
4132
+ }
4133
+ }
4134
+ }
4135
+ }
4136
+
3893
4137
  try {
3894
- await loadOneToMany(table, target, rows, key);
4138
+ await loadOneToMany(table, target, rows, key, options);
3895
4139
  } catch (e: any) {
3896
4140
  log.error("loadOneToMany failed", { table, key, target }, e?.message ?? e);
3897
4141
  for (const r of rows) r[key] = [];
3898
4142
  }
3899
- const childSpec = s[key] && typeof s[key] === "object" ? s[key] : undefined;
4143
+
3900
4144
  if (childSpec) {
3901
4145
  const children = rows.flatMap(r => (r[key] ?? []));
3902
4146
  try {
@@ -3984,7 +4228,7 @@ export async function loadIncludes(
3984
4228
  }
3985
4229
  }
3986
4230
 
3987
- async function loadOneToMany(curr: TableName, target: TableName, rows: any[], key: string) {
4231
+ async function loadOneToMany(curr: TableName, target: TableName, rows: any[], key: string, options: RelationOptions = {}) {
3988
4232
  // target has FK cols referencing current PK
3989
4233
  const fk = (FK_INDEX as any)[target].find((f: any) => f.toTable === curr);
3990
4234
  if (!fk) { for (const r of rows) r[key] = []; return; }
@@ -3995,18 +4239,50 @@ export async function loadIncludes(
3995
4239
 
3996
4240
  const where = buildOrAndPredicate(fk.from, tuples.length, 1);
3997
4241
  const params = tuples.flat();
3998
- const sql = \`SELECT * FROM "\${target}" WHERE \${where}\`;
3999
- log.debug("oneToMany SQL", { curr, target, key, sql, paramsCount: params.length });
4242
+
4243
+ // Build SQL with optional ORDER BY, LIMIT, OFFSET
4244
+ let sql = \`SELECT * FROM "\${target}" WHERE \${where}\`;
4245
+
4246
+ // If limit/offset are needed, use window functions to limit per parent
4247
+ if (options.limit !== undefined || options.offset !== undefined) {
4248
+ const orderByClause = options.orderBy
4249
+ ? \`ORDER BY "\${options.orderBy}" \${options.order === 'desc' ? 'DESC' : 'ASC'}\`
4250
+ : 'ORDER BY (SELECT NULL)';
4251
+
4252
+ const partitionCols = fk.from.map((c: string) => \`"\${c}"\`).join(', ');
4253
+ const offset = options.offset ?? 0;
4254
+ const limit = options.limit ?? 999999999;
4255
+
4256
+ sql = \`
4257
+ SELECT * FROM (
4258
+ SELECT *, ROW_NUMBER() OVER (PARTITION BY \${partitionCols} \${orderByClause}) as __rn
4259
+ FROM "\${target}"
4260
+ WHERE \${where}
4261
+ ) __sub
4262
+ WHERE __rn > \${offset} AND __rn <= \${offset + limit}
4263
+ \`;
4264
+ } else if (options.orderBy) {
4265
+ // Just ORDER BY without limit/offset
4266
+ sql += \` ORDER BY "\${options.orderBy}" \${options.order === 'desc' ? 'DESC' : 'ASC'}\`;
4267
+ }
4268
+
4269
+ log.debug("oneToMany SQL", { curr, target, key, sql, paramsCount: params.length, options });
4000
4270
  const { rows: children } = await pg.query(sql, params);
4001
4271
 
4002
- const groups = groupByTuple(children, fk.from);
4272
+ // Remove __rn column if it exists
4273
+ const cleanChildren = children.map((row: any) => {
4274
+ const { __rn, ...rest } = row;
4275
+ return rest;
4276
+ });
4277
+
4278
+ const groups = groupByTuple(cleanChildren, fk.from);
4003
4279
  for (const r of rows) {
4004
4280
  const keyStr = JSON.stringify(pkCols.map((c: string) => r[c]));
4005
4281
  r[key] = groups.get(keyStr) ?? [];
4006
4282
  }
4007
4283
  }
4008
4284
 
4009
- async function loadManyToMany(curr: TableName, target: TableName, via: string, rows: any[], key: string) {
4285
+ async function loadManyToMany(curr: TableName, target: TableName, via: string, rows: any[], key: string, options: RelationOptions = {}) {
4010
4286
  // via has two FKs: one to curr, one to target
4011
4287
  const toCurr = (FK_INDEX as any)[via].find((f: any) => f.toTable === curr);
4012
4288
  const toTarget = (FK_INDEX as any)[via].find((f: any) => f.toTable === target);
@@ -4016,31 +4292,83 @@ export async function loadIncludes(
4016
4292
  const tuples = distinctTuples(rows, pkCols).filter(t => t.every((v: any) => v != null));
4017
4293
  if (!tuples.length) { for (const r of rows) r[key] = []; return; }
4018
4294
 
4019
- // 1) Load junction rows for current parents
4020
4295
  const whereVia = buildOrAndPredicate(toCurr.from, tuples.length, 1);
4021
- const sqlVia = \`SELECT * FROM "\${via}" WHERE \${whereVia}\`;
4022
- const paramsVia = tuples.flat();
4023
- log.debug("manyToMany junction SQL", { curr, target, via, key, sql: sqlVia, paramsCount: paramsVia.length });
4024
- const { rows: jrows } = await pg.query(sqlVia, paramsVia);
4025
-
4026
- if (!jrows.length) { for (const r of rows) r[key] = []; return; }
4296
+ const params = tuples.flat();
4027
4297
 
4028
- // 2) Load targets by distinct target fk tuples in junction
4029
- const tTuples = distinctTuples(jrows, toTarget.from);
4030
- const whereT = buildOrAndPredicate((PKS as any)[target], tTuples.length, 1);
4031
- const sqlT = \`SELECT * FROM "\${target}" WHERE \${whereT}\`;
4032
- const paramsT = tTuples.flat();
4033
- log.debug("manyToMany target SQL", { curr, target, via, key, sql: sqlT, paramsCount: paramsT.length });
4034
- const { rows: targets } = await pg.query(sqlT, paramsT);
4298
+ // If we have limit/offset/orderBy, use a JOIN with window functions
4299
+ if (options.limit !== undefined || options.offset !== undefined || options.orderBy) {
4300
+ const orderByClause = options.orderBy
4301
+ ? \`ORDER BY t."\${options.orderBy}" \${options.order === 'desc' ? 'DESC' : 'ASC'}\`
4302
+ : 'ORDER BY (SELECT NULL)';
4303
+
4304
+ const partitionCols = toCurr.from.map((c: string) => \`j."\${c}"\`).join(', ');
4305
+ const offset = options.offset ?? 0;
4306
+ const limit = options.limit ?? 999999999;
4307
+
4308
+ const targetPkCols = (PKS as any)[target] as string[];
4309
+ const joinConditions = toTarget.from.map((jCol: string, i: number) => {
4310
+ return \`j."\${jCol}" = t."\${targetPkCols[i]}"\`;
4311
+ }).join(' AND ');
4312
+
4313
+ const sql = \`
4314
+ SELECT __numbered.*
4315
+ FROM (
4316
+ SELECT t.*,
4317
+ ROW_NUMBER() OVER (PARTITION BY \${partitionCols} \${orderByClause}) as __rn,
4318
+ \${toCurr.from.map((c: string) => \`j."\${c}"\`).join(' || \\',\\' || ')} as __parent_fk
4319
+ FROM "\${via}" j
4320
+ INNER JOIN "\${target}" t ON \${joinConditions}
4321
+ WHERE \${whereVia}
4322
+ ) __numbered
4323
+ WHERE __numbered.__rn > \${offset} AND __numbered.__rn <= \${offset + limit}
4324
+ \`;
4325
+
4326
+ log.debug("manyToMany SQL with options", { curr, target, via, key, sql, paramsCount: params.length, options });
4327
+ const { rows: results } = await pg.query(sql, params);
4328
+
4329
+ // Clean and group results
4330
+ const cleanResults = results.map((row: any) => {
4331
+ const { __rn, __parent_fk, ...rest } = row;
4332
+ return { ...rest, __parent_fk };
4333
+ });
4035
4334
 
4036
- const tIdx = indexByTuple(targets, (PKS as any)[target]);
4335
+ const grouped = new Map<string, any[]>();
4336
+ for (const row of cleanResults) {
4337
+ const { __parent_fk, ...cleanRow } = row;
4338
+ const arr = grouped.get(__parent_fk) ?? [];
4339
+ arr.push(cleanRow);
4340
+ grouped.set(__parent_fk, arr);
4341
+ }
4037
4342
 
4038
- // 3) Group junction rows by current pk tuple, map to target rows
4039
- const byCurr = groupByTuple(jrows, toCurr.from);
4040
- for (const r of rows) {
4041
- const currKey = JSON.stringify(pkCols.map((c: string) => r[c]));
4042
- const j = byCurr.get(currKey) ?? [];
4043
- r[key] = j.map(jr => tIdx.get(JSON.stringify(toTarget.from.map((c: string) => jr[c])))).filter(Boolean);
4343
+ for (const r of rows) {
4344
+ const currKey = pkCols.map((c: string) => r[c]).join(',');
4345
+ r[key] = grouped.get(currKey) ?? [];
4346
+ }
4347
+ } else {
4348
+ // Original logic without options
4349
+ const sqlVia = \`SELECT * FROM "\${via}" WHERE \${whereVia}\`;
4350
+ log.debug("manyToMany junction SQL", { curr, target, via, key, sql: sqlVia, paramsCount: params.length });
4351
+ const { rows: jrows } = await pg.query(sqlVia, params);
4352
+
4353
+ if (!jrows.length) { for (const r of rows) r[key] = []; return; }
4354
+
4355
+ // 2) Load targets by distinct target fk tuples in junction
4356
+ const tTuples = distinctTuples(jrows, toTarget.from);
4357
+ const whereT = buildOrAndPredicate((PKS as any)[target], tTuples.length, 1);
4358
+ const sqlT = \`SELECT * FROM "\${target}" WHERE \${whereT}\`;
4359
+ const paramsT = tTuples.flat();
4360
+ log.debug("manyToMany target SQL", { curr, target, via, key, sql: sqlT, paramsCount: paramsT.length });
4361
+ const { rows: targets } = await pg.query(sqlT, paramsT);
4362
+
4363
+ const tIdx = indexByTuple(targets, (PKS as any)[target]);
4364
+
4365
+ // 3) Group junction rows by current pk tuple, map to target rows
4366
+ const byCurr = groupByTuple(jrows, toCurr.from);
4367
+ for (const r of rows) {
4368
+ const currKey = JSON.stringify(pkCols.map((c: string) => r[c]));
4369
+ const j = byCurr.get(currKey) ?? [];
4370
+ r[key] = j.map(jr => tIdx.get(JSON.stringify(toTarget.from.map((c: string) => jr[c])))).filter(Boolean);
4371
+ }
4044
4372
  }
4045
4373
  }
4046
4374
  }
@@ -4060,7 +4388,11 @@ function tsTypeFor(pgType, opts, enums) {
4060
4388
  if (t === "bool" || t === "boolean")
4061
4389
  return "boolean";
4062
4390
  if (t === "int2" || t === "int4" || t === "int8" || t === "float4" || t === "float8" || t === "numeric") {
4063
- return opts.numericMode === "number" ? "number" : "string";
4391
+ if (opts.numericMode === "number")
4392
+ return "number";
4393
+ if (opts.numericMode === "string")
4394
+ return "string";
4395
+ return t === "int2" || t === "int4" || t === "float4" || t === "float8" ? "number" : "string";
4064
4396
  }
4065
4397
  if (t === "date" || t.startsWith("timestamp"))
4066
4398
  return "string";
@@ -6265,10 +6597,11 @@ async function generate(configPath) {
6265
6597
  console.log(`[Index] About to process ${Object.keys(model.tables || {}).length} tables for generation`);
6266
6598
  }
6267
6599
  for (const table of Object.values(model.tables)) {
6268
- const typesSrc = emitTypes(table, { numericMode: "string" }, model.enums);
6600
+ const numericMode = cfg.numericMode ?? "auto";
6601
+ const typesSrc = emitTypes(table, { numericMode }, model.enums);
6269
6602
  files.push({ path: join(serverDir, "types", `${table.name}.ts`), content: typesSrc });
6270
6603
  files.push({ path: join(clientDir, "types", `${table.name}.ts`), content: typesSrc });
6271
- const zodSrc = emitZod(table, { numericMode: "string" }, model.enums);
6604
+ const zodSrc = emitZod(table, { numericMode }, model.enums);
6272
6605
  files.push({ path: join(serverDir, "zod", `${table.name}.ts`), content: zodSrc });
6273
6606
  files.push({ path: join(clientDir, "zod", `${table.name}.ts`), content: zodSrc });
6274
6607
  const paramsZodSrc = emitParamsZod(table, graph);
@@ -1,4 +1,4 @@
1
1
  import type { Table } from "./introspect";
2
2
  export declare function emitTypes(table: Table, opts: {
3
- numericMode: "string" | "number";
3
+ numericMode: "string" | "number" | "auto";
4
4
  }, enums: Record<string, string[]>): string;
@@ -1,4 +1,4 @@
1
1
  import type { Table } from "./introspect";
2
2
  export declare function emitZod(table: Table, opts: {
3
- numericMode: "string" | "number";
3
+ numericMode: "string" | "number" | "auto";
4
4
  }, enums: Record<string, string[]>): string;
package/dist/index.js CHANGED
@@ -1909,7 +1909,7 @@ function emitIncludeSpec(graph) {
1909
1909
  `;
1910
1910
  for (const [relKey, edge] of entries) {
1911
1911
  if (edge.kind === "many") {
1912
- out += ` ${relKey}?: boolean | { include?: ${toPascal(edge.target)}IncludeSpec; limit?: number; offset?: number; };
1912
+ out += ` ${relKey}?: boolean | { include?: ${toPascal(edge.target)}IncludeSpec; limit?: number; offset?: number; orderBy?: string; order?: "asc" | "desc"; };
1913
1913
  `;
1914
1914
  } else {
1915
1915
  out += ` ${relKey}?: boolean | ${toPascal(edge.target)}IncludeSpec;
@@ -1978,10 +1978,20 @@ function emitZod(table, opts, enums) {
1978
1978
  return `z.string()`;
1979
1979
  if (t === "bool" || t === "boolean")
1980
1980
  return `z.boolean()`;
1981
- if (t === "int2" || t === "int4" || t === "int8")
1982
- return opts.numericMode === "number" ? `z.number()` : `z.string()`;
1983
- if (t === "numeric" || t === "float4" || t === "float8")
1984
- return opts.numericMode === "number" ? `z.number()` : `z.string()`;
1981
+ if (t === "int2" || t === "int4" || t === "int8") {
1982
+ if (opts.numericMode === "number")
1983
+ return `z.number()`;
1984
+ if (opts.numericMode === "string")
1985
+ return `z.string()`;
1986
+ return t === "int2" || t === "int4" ? `z.number()` : `z.string()`;
1987
+ }
1988
+ if (t === "numeric" || t === "float4" || t === "float8") {
1989
+ if (opts.numericMode === "number")
1990
+ return `z.number()`;
1991
+ if (opts.numericMode === "string")
1992
+ return `z.string()`;
1993
+ return t === "float4" || t === "float8" ? `z.number()` : `z.string()`;
1994
+ }
1985
1995
  if (t === "jsonb" || t === "json")
1986
1996
  return `z.unknown()`;
1987
1997
  if (t === "date" || t.startsWith("timestamp"))
@@ -2354,6 +2364,24 @@ function isJsonbType(pgType) {
2354
2364
  const t = pgType.toLowerCase();
2355
2365
  return t === "json" || t === "jsonb";
2356
2366
  }
2367
+ function toIncludeParamName(relationKey) {
2368
+ return `${relationKey}Include`;
2369
+ }
2370
+ function analyzeIncludeSpec(includeSpec) {
2371
+ const keys = Object.keys(includeSpec);
2372
+ if (keys.length > 1) {
2373
+ return { type: "parallel", keys };
2374
+ }
2375
+ const key = keys[0];
2376
+ if (!key) {
2377
+ return { type: "single", keys: [] };
2378
+ }
2379
+ const value = includeSpec[key];
2380
+ if (typeof value === "object" && value !== null) {
2381
+ return { type: "nested", keys: [key], nestedKey: key, nestedValue: value };
2382
+ }
2383
+ return { type: "single", keys: [key] };
2384
+ }
2357
2385
  function emitClient(table, graph, opts, model) {
2358
2386
  const Type = pascal(table.name);
2359
2387
  const ext = opts.useJsExtensions ? ".js" : "";
@@ -2392,39 +2420,157 @@ function emitClient(table, graph, opts, model) {
2392
2420
  let includeMethodsCode = "";
2393
2421
  for (const method of includeMethods) {
2394
2422
  const isGetByPk = method.name.startsWith("getByPk");
2395
- const baseParams = isGetByPk ? "" : `params?: Omit<{ limit?: number; offset?: number; where?: Where<Select${Type}>; orderBy?: string | string[]; order?: "asc" | "desc" | ("asc" | "desc")[]; }, "include">`;
2423
+ const pattern = analyzeIncludeSpec(method.includeSpec);
2396
2424
  const relationshipDesc = method.path.map((p, i) => {
2397
2425
  const isLast = i === method.path.length - 1;
2398
2426
  const relation = method.isMany[i] ? "many" : "one";
2399
2427
  return isLast ? p : `${p} -> `;
2400
2428
  }).join("");
2429
+ let paramsType = "";
2430
+ const includeParamNames = [];
2431
+ if (pattern.type === "single") {
2432
+ const key = pattern.keys[0];
2433
+ if (key) {
2434
+ const paramName = toIncludeParamName(key);
2435
+ includeParamNames.push(paramName);
2436
+ paramsType = `{
2437
+ limit?: number;
2438
+ offset?: number;
2439
+ where?: Where<Select${Type}>;
2440
+ orderBy?: string | string[];
2441
+ order?: "asc" | "desc" | ("asc" | "desc")[];
2442
+ ${paramName}?: {
2443
+ orderBy?: string | string[];
2444
+ order?: "asc" | "desc";
2445
+ limit?: number;
2446
+ offset?: number;
2447
+ };
2448
+ }`;
2449
+ }
2450
+ } else if (pattern.type === "parallel") {
2451
+ const includeParams = pattern.keys.map((key) => {
2452
+ const paramName = toIncludeParamName(key);
2453
+ includeParamNames.push(paramName);
2454
+ return `${paramName}?: {
2455
+ orderBy?: string | string[];
2456
+ order?: "asc" | "desc";
2457
+ limit?: number;
2458
+ offset?: number;
2459
+ }`;
2460
+ }).join(`;
2461
+ `);
2462
+ paramsType = `{
2463
+ limit?: number;
2464
+ offset?: number;
2465
+ where?: Where<Select${Type}>;
2466
+ orderBy?: string | string[];
2467
+ order?: "asc" | "desc" | ("asc" | "desc")[];
2468
+ ${includeParams};
2469
+ }`;
2470
+ } else if (pattern.type === "nested" && pattern.nestedKey) {
2471
+ const paramName = toIncludeParamName(pattern.nestedKey);
2472
+ includeParamNames.push(paramName);
2473
+ paramsType = `{
2474
+ limit?: number;
2475
+ offset?: number;
2476
+ where?: Where<Select${Type}>;
2477
+ orderBy?: string | string[];
2478
+ order?: "asc" | "desc" | ("asc" | "desc")[];
2479
+ ${paramName}?: {
2480
+ orderBy?: string | string[];
2481
+ order?: "asc" | "desc";
2482
+ limit?: number;
2483
+ offset?: number;
2484
+ include?: any;
2485
+ };
2486
+ }`;
2487
+ }
2401
2488
  if (isGetByPk) {
2402
2489
  const pkWhere = hasCompositePk ? `{ ${safePk.map((col) => `${col}: pk.${col}`).join(", ")} }` : `{ ${safePk[0] || "id"}: pk }`;
2403
2490
  const baseReturnType = method.returnType.replace(" | null", "");
2491
+ let transformCode = "";
2492
+ if (includeParamNames.length > 0) {
2493
+ const destructure = includeParamNames.map((name) => name).join(", ");
2494
+ if (pattern.type === "single") {
2495
+ const key = pattern.keys[0];
2496
+ const paramName = includeParamNames[0];
2497
+ transformCode = `
2498
+ const { ${destructure} } = params ?? {};
2499
+ const includeSpec = ${paramName} ? { ${key}: ${paramName} } : ${JSON.stringify(method.includeSpec)};`;
2500
+ } else if (pattern.type === "parallel") {
2501
+ const includeSpecCode = pattern.keys.map((key, idx) => {
2502
+ const paramName = includeParamNames[idx];
2503
+ return `${key}: ${paramName} ?? true`;
2504
+ }).join(", ");
2505
+ transformCode = `
2506
+ const { ${destructure} } = params ?? {};
2507
+ const includeSpec = { ${includeSpecCode} };`;
2508
+ } else if (pattern.type === "nested" && pattern.nestedKey) {
2509
+ const key = pattern.nestedKey;
2510
+ const paramName = includeParamNames[0];
2511
+ transformCode = `
2512
+ const { ${destructure} } = params ?? {};
2513
+ const includeSpec = ${paramName} ? { ${key}: ${paramName} } : ${JSON.stringify(method.includeSpec)};`;
2514
+ }
2515
+ } else {
2516
+ transformCode = `
2517
+ const includeSpec = ${JSON.stringify(method.includeSpec)};`;
2518
+ }
2404
2519
  includeMethodsCode += `
2405
2520
  /**
2406
2521
  * Get a ${table.name} record by primary key with included related ${relationshipDesc}
2407
2522
  * @param pk - The primary key value${hasCompositePk ? "s" : ""}
2523
+ * @param params - Optional include options
2408
2524
  * @returns The record with nested ${method.path.join(" and ")} if found, null otherwise
2409
2525
  */
2410
- async ${method.name}(pk: ${pkType}): Promise<${method.returnType}> {
2526
+ async ${method.name}(pk: ${pkType}, params?: ${paramsType}): Promise<${method.returnType}> {${transformCode}
2411
2527
  const results = await this.post<PaginatedResponse<${baseReturnType}>>(\`\${this.resource}/list\`, {
2412
2528
  where: ${pkWhere},
2413
- include: ${JSON.stringify(method.includeSpec)},
2529
+ include: includeSpec,
2414
2530
  limit: 1
2415
2531
  });
2416
2532
  return (results.data[0] as ${baseReturnType}) ?? null;
2417
2533
  }
2418
2534
  `;
2419
2535
  } else {
2536
+ let transformCode = "";
2537
+ if (includeParamNames.length > 0) {
2538
+ const destructure = includeParamNames.map((name) => name).join(", ");
2539
+ if (pattern.type === "single") {
2540
+ const key = pattern.keys[0];
2541
+ const paramName = includeParamNames[0];
2542
+ transformCode = `
2543
+ const { ${destructure}, ...baseParams } = params ?? {};
2544
+ const includeSpec = ${paramName} ? { ${key}: ${paramName} } : ${JSON.stringify(method.includeSpec)};
2545
+ return this.post<${method.returnType}>(\`\${this.resource}/list\`, { ...baseParams, include: includeSpec });`;
2546
+ } else if (pattern.type === "parallel") {
2547
+ const includeSpecCode = pattern.keys.map((key, idx) => {
2548
+ const paramName = includeParamNames[idx];
2549
+ return `${key}: ${paramName} ?? true`;
2550
+ }).join(", ");
2551
+ transformCode = `
2552
+ const { ${destructure}, ...baseParams } = params ?? {};
2553
+ const includeSpec = { ${includeSpecCode} };
2554
+ return this.post<${method.returnType}>(\`\${this.resource}/list\`, { ...baseParams, include: includeSpec });`;
2555
+ } else if (pattern.type === "nested" && pattern.nestedKey) {
2556
+ const key = pattern.nestedKey;
2557
+ const paramName = includeParamNames[0];
2558
+ transformCode = `
2559
+ const { ${destructure}, ...baseParams } = params ?? {};
2560
+ const includeSpec = ${paramName} ? { ${key}: ${paramName} } : ${JSON.stringify(method.includeSpec)};
2561
+ return this.post<${method.returnType}>(\`\${this.resource}/list\`, { ...baseParams, include: includeSpec });`;
2562
+ }
2563
+ } else {
2564
+ transformCode = `
2565
+ return this.post<${method.returnType}>(\`\${this.resource}/list\`, { ...params, include: ${JSON.stringify(method.includeSpec)} });`;
2566
+ }
2420
2567
  includeMethodsCode += `
2421
2568
  /**
2422
2569
  * List ${table.name} records with included related ${relationshipDesc}
2423
- * @param params - Query parameters (where, orderBy, order, limit, offset)
2570
+ * @param params - Query parameters (where, orderBy, order, limit, offset) and include options
2424
2571
  * @returns Paginated results with nested ${method.path.join(" and ")} included
2425
2572
  */
2426
- async ${method.name}(${baseParams}): Promise<${method.returnType}> {
2427
- return this.post<${method.returnType}>(\`\${this.resource}/list\`, { ...params, include: ${JSON.stringify(method.includeSpec)} });
2573
+ async ${method.name}(params?: ${paramsType}): Promise<${method.returnType}> {${transformCode}
2428
2574
  }
2429
2575
  `;
2430
2576
  }
@@ -2501,7 +2647,7 @@ ${hasJsonbColumns ? ` /**
2501
2647
  * @param params.where - Filter conditions using operators like $eq, $gt, $in, $like, etc.
2502
2648
  * @param params.orderBy - Column(s) to sort by
2503
2649
  * @param params.order - Sort direction(s): "asc" or "desc"
2504
- * @param params.limit - Maximum number of records to return (default: 50, max: 100)
2650
+ * @param params.limit - Maximum number of records to return (default: 50, max: 1000)
2505
2651
  * @param params.offset - Number of records to skip for pagination
2506
2652
  * @param params.include - Related records to include (see listWith* methods for typed includes)
2507
2653
  * @returns Paginated results with data, total count, and hasMore flag
@@ -2530,7 +2676,7 @@ ${hasJsonbColumns ? ` /**
2530
2676
  * @param params.where - Filter conditions using operators like $eq, $gt, $in, $like, etc.
2531
2677
  * @param params.orderBy - Column(s) to sort by
2532
2678
  * @param params.order - Sort direction(s): "asc" or "desc"
2533
- * @param params.limit - Maximum number of records to return (default: 50, max: 100)
2679
+ * @param params.limit - Maximum number of records to return (default: 50, max: 1000)
2534
2680
  * @param params.offset - Number of records to skip for pagination
2535
2681
  * @param params.include - Related records to include (see listWith* methods for typed includes)
2536
2682
  * @returns Paginated results with data, total count, and hasMore flag
@@ -2867,6 +3013,13 @@ type Graph = typeof RELATION_GRAPH;
2867
3013
  type TableName = keyof Graph;
2868
3014
  type IncludeSpec = any;
2869
3015
 
3016
+ type RelationOptions = {
3017
+ limit?: number;
3018
+ offset?: number;
3019
+ orderBy?: string;
3020
+ order?: "asc" | "desc";
3021
+ };
3022
+
2870
3023
  // Debug helpers (enabled with SDK_DEBUG=1)
2871
3024
  const DEBUG = process.env.SDK_DEBUG === "1" || process.env.SDK_DEBUG === "true";
2872
3025
  const log = {
@@ -2968,14 +3121,43 @@ export async function loadIncludes(
2968
3121
  // Safely run each loader; never let one bad edge 500 the route
2969
3122
  if (rel.via) {
2970
3123
  // M:N via junction
3124
+ const specValue = s[key];
3125
+ const options: RelationOptions = {};
3126
+ let childSpec: any = undefined;
3127
+
3128
+ if (specValue && typeof specValue === "object" && specValue !== true) {
3129
+ // Extract options
3130
+ if (specValue.limit !== undefined) options.limit = specValue.limit;
3131
+ if (specValue.offset !== undefined) options.offset = specValue.offset;
3132
+ if (specValue.orderBy !== undefined) options.orderBy = specValue.orderBy;
3133
+ if (specValue.order !== undefined) options.order = specValue.order;
3134
+
3135
+ // Extract nested spec - support both formats:
3136
+ // New: { limit: 3, include: { tags: true } }
3137
+ // Old: { tags: true } (backward compatibility)
3138
+ if (specValue.include !== undefined) {
3139
+ childSpec = specValue.include;
3140
+ } else {
3141
+ // Build childSpec from non-option keys
3142
+ const nonOptionKeys = Object.keys(specValue).filter(
3143
+ k => k !== 'limit' && k !== 'offset' && k !== 'orderBy' && k !== 'order'
3144
+ );
3145
+ if (nonOptionKeys.length > 0) {
3146
+ childSpec = {};
3147
+ for (const k of nonOptionKeys) {
3148
+ childSpec[k] = specValue[k];
3149
+ }
3150
+ }
3151
+ }
3152
+ }
3153
+
2971
3154
  try {
2972
- await loadManyToMany(table, target, rel.via as string, rows, key);
3155
+ await loadManyToMany(table, target, rel.via as string, rows, key, options);
2973
3156
  } catch (e: any) {
2974
3157
  log.error("loadManyToMany failed", { table, key, via: rel.via, target }, e?.message ?? e);
2975
3158
  for (const r of rows) r[key] = [];
2976
3159
  }
2977
- // Recurse if nested include specified
2978
- const childSpec = s[key] && typeof s[key] === "object" ? s[key] : undefined;
3160
+
2979
3161
  if (childSpec) {
2980
3162
  const children = rows.flatMap(r => (r[key] ?? []));
2981
3163
  try {
@@ -2989,13 +3171,43 @@ export async function loadIncludes(
2989
3171
 
2990
3172
  if (rel.kind === "many") {
2991
3173
  // 1:N target has FK to current
3174
+ const specValue = s[key];
3175
+ const options: RelationOptions = {};
3176
+ let childSpec: any = undefined;
3177
+
3178
+ if (specValue && typeof specValue === "object" && specValue !== true) {
3179
+ // Extract options
3180
+ if (specValue.limit !== undefined) options.limit = specValue.limit;
3181
+ if (specValue.offset !== undefined) options.offset = specValue.offset;
3182
+ if (specValue.orderBy !== undefined) options.orderBy = specValue.orderBy;
3183
+ if (specValue.order !== undefined) options.order = specValue.order;
3184
+
3185
+ // Extract nested spec - support both formats:
3186
+ // New: { limit: 3, include: { tags: true } }
3187
+ // Old: { tags: true } (backward compatibility)
3188
+ if (specValue.include !== undefined) {
3189
+ childSpec = specValue.include;
3190
+ } else {
3191
+ // Build childSpec from non-option keys
3192
+ const nonOptionKeys = Object.keys(specValue).filter(
3193
+ k => k !== 'limit' && k !== 'offset' && k !== 'orderBy' && k !== 'order'
3194
+ );
3195
+ if (nonOptionKeys.length > 0) {
3196
+ childSpec = {};
3197
+ for (const k of nonOptionKeys) {
3198
+ childSpec[k] = specValue[k];
3199
+ }
3200
+ }
3201
+ }
3202
+ }
3203
+
2992
3204
  try {
2993
- await loadOneToMany(table, target, rows, key);
3205
+ await loadOneToMany(table, target, rows, key, options);
2994
3206
  } catch (e: any) {
2995
3207
  log.error("loadOneToMany failed", { table, key, target }, e?.message ?? e);
2996
3208
  for (const r of rows) r[key] = [];
2997
3209
  }
2998
- const childSpec = s[key] && typeof s[key] === "object" ? s[key] : undefined;
3210
+
2999
3211
  if (childSpec) {
3000
3212
  const children = rows.flatMap(r => (r[key] ?? []));
3001
3213
  try {
@@ -3083,7 +3295,7 @@ export async function loadIncludes(
3083
3295
  }
3084
3296
  }
3085
3297
 
3086
- async function loadOneToMany(curr: TableName, target: TableName, rows: any[], key: string) {
3298
+ async function loadOneToMany(curr: TableName, target: TableName, rows: any[], key: string, options: RelationOptions = {}) {
3087
3299
  // target has FK cols referencing current PK
3088
3300
  const fk = (FK_INDEX as any)[target].find((f: any) => f.toTable === curr);
3089
3301
  if (!fk) { for (const r of rows) r[key] = []; return; }
@@ -3094,18 +3306,50 @@ export async function loadIncludes(
3094
3306
 
3095
3307
  const where = buildOrAndPredicate(fk.from, tuples.length, 1);
3096
3308
  const params = tuples.flat();
3097
- const sql = \`SELECT * FROM "\${target}" WHERE \${where}\`;
3098
- log.debug("oneToMany SQL", { curr, target, key, sql, paramsCount: params.length });
3309
+
3310
+ // Build SQL with optional ORDER BY, LIMIT, OFFSET
3311
+ let sql = \`SELECT * FROM "\${target}" WHERE \${where}\`;
3312
+
3313
+ // If limit/offset are needed, use window functions to limit per parent
3314
+ if (options.limit !== undefined || options.offset !== undefined) {
3315
+ const orderByClause = options.orderBy
3316
+ ? \`ORDER BY "\${options.orderBy}" \${options.order === 'desc' ? 'DESC' : 'ASC'}\`
3317
+ : 'ORDER BY (SELECT NULL)';
3318
+
3319
+ const partitionCols = fk.from.map((c: string) => \`"\${c}"\`).join(', ');
3320
+ const offset = options.offset ?? 0;
3321
+ const limit = options.limit ?? 999999999;
3322
+
3323
+ sql = \`
3324
+ SELECT * FROM (
3325
+ SELECT *, ROW_NUMBER() OVER (PARTITION BY \${partitionCols} \${orderByClause}) as __rn
3326
+ FROM "\${target}"
3327
+ WHERE \${where}
3328
+ ) __sub
3329
+ WHERE __rn > \${offset} AND __rn <= \${offset + limit}
3330
+ \`;
3331
+ } else if (options.orderBy) {
3332
+ // Just ORDER BY without limit/offset
3333
+ sql += \` ORDER BY "\${options.orderBy}" \${options.order === 'desc' ? 'DESC' : 'ASC'}\`;
3334
+ }
3335
+
3336
+ log.debug("oneToMany SQL", { curr, target, key, sql, paramsCount: params.length, options });
3099
3337
  const { rows: children } = await pg.query(sql, params);
3100
3338
 
3101
- const groups = groupByTuple(children, fk.from);
3339
+ // Remove __rn column if it exists
3340
+ const cleanChildren = children.map((row: any) => {
3341
+ const { __rn, ...rest } = row;
3342
+ return rest;
3343
+ });
3344
+
3345
+ const groups = groupByTuple(cleanChildren, fk.from);
3102
3346
  for (const r of rows) {
3103
3347
  const keyStr = JSON.stringify(pkCols.map((c: string) => r[c]));
3104
3348
  r[key] = groups.get(keyStr) ?? [];
3105
3349
  }
3106
3350
  }
3107
3351
 
3108
- async function loadManyToMany(curr: TableName, target: TableName, via: string, rows: any[], key: string) {
3352
+ async function loadManyToMany(curr: TableName, target: TableName, via: string, rows: any[], key: string, options: RelationOptions = {}) {
3109
3353
  // via has two FKs: one to curr, one to target
3110
3354
  const toCurr = (FK_INDEX as any)[via].find((f: any) => f.toTable === curr);
3111
3355
  const toTarget = (FK_INDEX as any)[via].find((f: any) => f.toTable === target);
@@ -3115,31 +3359,83 @@ export async function loadIncludes(
3115
3359
  const tuples = distinctTuples(rows, pkCols).filter(t => t.every((v: any) => v != null));
3116
3360
  if (!tuples.length) { for (const r of rows) r[key] = []; return; }
3117
3361
 
3118
- // 1) Load junction rows for current parents
3119
3362
  const whereVia = buildOrAndPredicate(toCurr.from, tuples.length, 1);
3120
- const sqlVia = \`SELECT * FROM "\${via}" WHERE \${whereVia}\`;
3121
- const paramsVia = tuples.flat();
3122
- log.debug("manyToMany junction SQL", { curr, target, via, key, sql: sqlVia, paramsCount: paramsVia.length });
3123
- const { rows: jrows } = await pg.query(sqlVia, paramsVia);
3124
-
3125
- if (!jrows.length) { for (const r of rows) r[key] = []; return; }
3363
+ const params = tuples.flat();
3126
3364
 
3127
- // 2) Load targets by distinct target fk tuples in junction
3128
- const tTuples = distinctTuples(jrows, toTarget.from);
3129
- const whereT = buildOrAndPredicate((PKS as any)[target], tTuples.length, 1);
3130
- const sqlT = \`SELECT * FROM "\${target}" WHERE \${whereT}\`;
3131
- const paramsT = tTuples.flat();
3132
- log.debug("manyToMany target SQL", { curr, target, via, key, sql: sqlT, paramsCount: paramsT.length });
3133
- const { rows: targets } = await pg.query(sqlT, paramsT);
3365
+ // If we have limit/offset/orderBy, use a JOIN with window functions
3366
+ if (options.limit !== undefined || options.offset !== undefined || options.orderBy) {
3367
+ const orderByClause = options.orderBy
3368
+ ? \`ORDER BY t."\${options.orderBy}" \${options.order === 'desc' ? 'DESC' : 'ASC'}\`
3369
+ : 'ORDER BY (SELECT NULL)';
3370
+
3371
+ const partitionCols = toCurr.from.map((c: string) => \`j."\${c}"\`).join(', ');
3372
+ const offset = options.offset ?? 0;
3373
+ const limit = options.limit ?? 999999999;
3374
+
3375
+ const targetPkCols = (PKS as any)[target] as string[];
3376
+ const joinConditions = toTarget.from.map((jCol: string, i: number) => {
3377
+ return \`j."\${jCol}" = t."\${targetPkCols[i]}"\`;
3378
+ }).join(' AND ');
3379
+
3380
+ const sql = \`
3381
+ SELECT __numbered.*
3382
+ FROM (
3383
+ SELECT t.*,
3384
+ ROW_NUMBER() OVER (PARTITION BY \${partitionCols} \${orderByClause}) as __rn,
3385
+ \${toCurr.from.map((c: string) => \`j."\${c}"\`).join(' || \\',\\' || ')} as __parent_fk
3386
+ FROM "\${via}" j
3387
+ INNER JOIN "\${target}" t ON \${joinConditions}
3388
+ WHERE \${whereVia}
3389
+ ) __numbered
3390
+ WHERE __numbered.__rn > \${offset} AND __numbered.__rn <= \${offset + limit}
3391
+ \`;
3392
+
3393
+ log.debug("manyToMany SQL with options", { curr, target, via, key, sql, paramsCount: params.length, options });
3394
+ const { rows: results } = await pg.query(sql, params);
3395
+
3396
+ // Clean and group results
3397
+ const cleanResults = results.map((row: any) => {
3398
+ const { __rn, __parent_fk, ...rest } = row;
3399
+ return { ...rest, __parent_fk };
3400
+ });
3134
3401
 
3135
- const tIdx = indexByTuple(targets, (PKS as any)[target]);
3402
+ const grouped = new Map<string, any[]>();
3403
+ for (const row of cleanResults) {
3404
+ const { __parent_fk, ...cleanRow } = row;
3405
+ const arr = grouped.get(__parent_fk) ?? [];
3406
+ arr.push(cleanRow);
3407
+ grouped.set(__parent_fk, arr);
3408
+ }
3136
3409
 
3137
- // 3) Group junction rows by current pk tuple, map to target rows
3138
- const byCurr = groupByTuple(jrows, toCurr.from);
3139
- for (const r of rows) {
3140
- const currKey = JSON.stringify(pkCols.map((c: string) => r[c]));
3141
- const j = byCurr.get(currKey) ?? [];
3142
- r[key] = j.map(jr => tIdx.get(JSON.stringify(toTarget.from.map((c: string) => jr[c])))).filter(Boolean);
3410
+ for (const r of rows) {
3411
+ const currKey = pkCols.map((c: string) => r[c]).join(',');
3412
+ r[key] = grouped.get(currKey) ?? [];
3413
+ }
3414
+ } else {
3415
+ // Original logic without options
3416
+ const sqlVia = \`SELECT * FROM "\${via}" WHERE \${whereVia}\`;
3417
+ log.debug("manyToMany junction SQL", { curr, target, via, key, sql: sqlVia, paramsCount: params.length });
3418
+ const { rows: jrows } = await pg.query(sqlVia, params);
3419
+
3420
+ if (!jrows.length) { for (const r of rows) r[key] = []; return; }
3421
+
3422
+ // 2) Load targets by distinct target fk tuples in junction
3423
+ const tTuples = distinctTuples(jrows, toTarget.from);
3424
+ const whereT = buildOrAndPredicate((PKS as any)[target], tTuples.length, 1);
3425
+ const sqlT = \`SELECT * FROM "\${target}" WHERE \${whereT}\`;
3426
+ const paramsT = tTuples.flat();
3427
+ log.debug("manyToMany target SQL", { curr, target, via, key, sql: sqlT, paramsCount: paramsT.length });
3428
+ const { rows: targets } = await pg.query(sqlT, paramsT);
3429
+
3430
+ const tIdx = indexByTuple(targets, (PKS as any)[target]);
3431
+
3432
+ // 3) Group junction rows by current pk tuple, map to target rows
3433
+ const byCurr = groupByTuple(jrows, toCurr.from);
3434
+ for (const r of rows) {
3435
+ const currKey = JSON.stringify(pkCols.map((c: string) => r[c]));
3436
+ const j = byCurr.get(currKey) ?? [];
3437
+ r[key] = j.map(jr => tIdx.get(JSON.stringify(toTarget.from.map((c: string) => jr[c])))).filter(Boolean);
3438
+ }
3143
3439
  }
3144
3440
  }
3145
3441
  }
@@ -3159,7 +3455,11 @@ function tsTypeFor(pgType, opts, enums) {
3159
3455
  if (t === "bool" || t === "boolean")
3160
3456
  return "boolean";
3161
3457
  if (t === "int2" || t === "int4" || t === "int8" || t === "float4" || t === "float8" || t === "numeric") {
3162
- return opts.numericMode === "number" ? "number" : "string";
3458
+ if (opts.numericMode === "number")
3459
+ return "number";
3460
+ if (opts.numericMode === "string")
3461
+ return "string";
3462
+ return t === "int2" || t === "int4" || t === "float4" || t === "float8" ? "number" : "string";
3163
3463
  }
3164
3464
  if (t === "date" || t.startsWith("timestamp"))
3165
3465
  return "string";
@@ -5364,10 +5664,11 @@ async function generate(configPath) {
5364
5664
  console.log(`[Index] About to process ${Object.keys(model.tables || {}).length} tables for generation`);
5365
5665
  }
5366
5666
  for (const table of Object.values(model.tables)) {
5367
- const typesSrc = emitTypes(table, { numericMode: "string" }, model.enums);
5667
+ const numericMode = cfg.numericMode ?? "auto";
5668
+ const typesSrc = emitTypes(table, { numericMode }, model.enums);
5368
5669
  files.push({ path: join(serverDir, "types", `${table.name}.ts`), content: typesSrc });
5369
5670
  files.push({ path: join(clientDir, "types", `${table.name}.ts`), content: typesSrc });
5370
- const zodSrc = emitZod(table, { numericMode: "string" }, model.enums);
5671
+ const zodSrc = emitZod(table, { numericMode }, model.enums);
5371
5672
  files.push({ path: join(serverDir, "zod", `${table.name}.ts`), content: zodSrc });
5372
5673
  files.push({ path: join(clientDir, "zod", `${table.name}.ts`), content: zodSrc });
5373
5674
  const paramsZodSrc = emitParamsZod(table, graph);
package/dist/types.d.ts CHANGED
@@ -24,6 +24,7 @@ export interface Config {
24
24
  };
25
25
  softDeleteColumn?: string | null;
26
26
  dateType?: "date" | "string";
27
+ numericMode?: "string" | "number" | "auto";
27
28
  includeMethodsDepth?: number;
28
29
  skipJunctionTables?: boolean;
29
30
  serverFramework?: "hono" | "express" | "fastify";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresdk",
3
- "version": "0.16.10",
3
+ "version": "0.16.13",
4
4
  "description": "Generate a typed server/client SDK from a Postgres schema (includes, Zod, Hono).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "scripts": {
24
24
  "build": "bun build src/cli.ts src/index.ts --outdir dist --target node --format esm --external=pg --external=zod --external=hono --external=prompts --external=node:* && tsc -p tsconfig.build.json --emitDeclarationOnly",
25
- "test": "bun test:init && bun test:gen && bun test test/test-where-clause.test.ts && bun test test/test-where-or-and.test.ts && bun test:gen-with-tests && bun test:pull && bun test:enums && bun test:typecheck && bun test:drizzle-e2e",
25
+ "test": "bun test:init && bun test:gen && bun test test/test-where-clause.test.ts && bun test test/test-where-or-and.test.ts && bun test test/test-nested-include-options.test.ts && bun test test/test-include-methods-with-options.test.ts && bun test:gen-with-tests && bun test:pull && bun test:enums && bun test:typecheck && bun test:drizzle-e2e && bun test test/test-numeric-mode-integration.test.ts",
26
26
  "test:init": "bun test/test-init.ts",
27
27
  "test:gen": "bun test/test-gen.ts",
28
28
  "test:gen-with-tests": "bun test/test-gen-with-tests.ts",