postgresdk 0.12.1 → 0.13.1

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
@@ -121,7 +121,8 @@ const user = await sdk.users.create({ name: "Bob", email: "bob@example.com" });
121
121
 
122
122
  // Read
123
123
  const user = await sdk.users.getByPk(123);
124
- const users = await sdk.users.list();
124
+ const result = await sdk.users.list();
125
+ const users = result.data; // Array of users
125
126
 
126
127
  // Update
127
128
  const updated = await sdk.users.update(123, { name: "Robert" });
@@ -136,17 +137,19 @@ Automatically handles relationships with the `include` parameter:
136
137
 
137
138
  ```typescript
138
139
  // 1:N relationship - Get authors with their books
139
- const authors = await sdk.authors.list({
140
+ const authorsResult = await sdk.authors.list({
140
141
  include: { books: true }
141
142
  });
143
+ const authors = authorsResult.data;
142
144
 
143
145
  // M:N relationship - Get books with their tags
144
- const books = await sdk.books.list({
146
+ const booksResult = await sdk.books.list({
145
147
  include: { tags: true }
146
148
  });
149
+ const books = booksResult.data;
147
150
 
148
151
  // Nested includes - Get authors with books and their tags
149
- const authors = await sdk.authors.list({
152
+ const nestedResult = await sdk.authors.list({
150
153
  include: {
151
154
  books: {
152
155
  include: {
@@ -155,13 +158,15 @@ const authors = await sdk.authors.list({
155
158
  }
156
159
  }
157
160
  });
161
+ const authorsWithBooksAndTags = nestedResult.data;
158
162
  ```
159
163
 
160
164
  ### Filtering & Pagination
161
165
 
166
+ All `list()` methods return pagination metadata:
167
+
162
168
  ```typescript
163
- // Simple equality filtering with single-column sorting
164
- const users = await sdk.users.list({
169
+ const result = await sdk.users.list({
165
170
  where: { status: "active" },
166
171
  orderBy: "created_at",
167
172
  order: "desc",
@@ -169,6 +174,17 @@ const users = await sdk.users.list({
169
174
  offset: 40
170
175
  });
171
176
 
177
+ // Access results
178
+ result.data; // User[] - array of records
179
+ result.total; // number - total matching records
180
+ result.limit; // number - page size used
181
+ result.offset; // number - offset used
182
+ result.hasMore; // boolean - more pages available
183
+
184
+ // Calculate pagination info
185
+ const totalPages = Math.ceil(result.total / result.limit);
186
+ const currentPage = Math.floor(result.offset / result.limit) + 1;
187
+
172
188
  // Multi-column sorting
173
189
  const sorted = await sdk.users.list({
174
190
  orderBy: ["status", "created_at"],
@@ -184,6 +200,7 @@ const filtered = await sdk.users.list({
184
200
  deleted_at: { $is: null } // NULL checks
185
201
  }
186
202
  });
203
+ // filtered.total respects WHERE clause for accurate counts
187
204
 
188
205
  // OR logic - match any condition
189
206
  const results = await sdk.users.list({
@@ -221,6 +238,17 @@ const nested = await sdk.users.list({
221
238
  ]
222
239
  }
223
240
  });
241
+
242
+ // Pagination with filtered results
243
+ let allResults = [];
244
+ let offset = 0;
245
+ const limit = 50;
246
+ do {
247
+ const page = await sdk.users.list({ where: { status: 'active' }, limit, offset });
248
+ allResults = allResults.concat(page.data);
249
+ offset += limit;
250
+ if (!page.hasMore) break;
251
+ } while (true);
224
252
  ```
225
253
 
226
254
  See the generated SDK documentation for all available operators: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$like`, `$ilike`, `$is`, `$isNot`, `$or`, `$and`.
package/dist/cli.js CHANGED
@@ -578,7 +578,7 @@ function generateIncludeMethods(table, graph, opts, allTables) {
578
578
  path: newPath,
579
579
  isMany: newIsMany,
580
580
  targets: newTargets,
581
- returnType: `(${buildReturnType(baseTableName, newPath, newIsMany, newTargets, graph)})[]`,
581
+ returnType: `PaginatedResponse<${buildReturnType(baseTableName, newPath, newIsMany, newTargets, graph)}>`,
582
582
  includeSpec: buildIncludeSpec(newPath)
583
583
  });
584
584
  methods.push({
@@ -618,7 +618,7 @@ function generateIncludeMethods(table, graph, opts, allTables) {
618
618
  path: combinedPath,
619
619
  isMany: [edge1.kind === "many", edge2.kind === "many"],
620
620
  targets: [edge1.target, edge2.target],
621
- returnType: `(Select${pascal(baseTableName)} & { ${type1}; ${type2} })[]`,
621
+ returnType: `PaginatedResponse<Select${pascal(baseTableName)} & { ${type1}; ${type2} }>`,
622
622
  includeSpec: { [key1]: true, [key2]: true }
623
623
  });
624
624
  methods.push({
@@ -785,10 +785,13 @@ function generateResourceWithSDK(table, model, graph, config) {
785
785
  const endpoints = [];
786
786
  sdkMethods.push({
787
787
  name: "list",
788
- signature: `list(params?: ListParams): Promise<${Type}[]>`,
789
- description: `List ${tableName} with filtering, sorting, and pagination`,
788
+ signature: `list(params?: ListParams): Promise<PaginatedResponse<${Type}>>`,
789
+ description: `List ${tableName} with filtering, sorting, and pagination. Returns paginated results with metadata.`,
790
790
  example: `// Get all ${tableName}
791
- const items = await sdk.${tableName}.list();
791
+ const result = await sdk.${tableName}.list();
792
+ console.log(result.data); // array of records
793
+ console.log(result.total); // total matching records
794
+ console.log(result.hasMore); // true if more pages available
792
795
 
793
796
  // With filters and pagination
794
797
  const filtered = await sdk.${tableName}.list({
@@ -797,15 +800,19 @@ const filtered = await sdk.${tableName}.list({
797
800
  where: { ${table.columns[0]?.name || "field"}: { $like: '%search%' } },
798
801
  orderBy: '${table.columns[0]?.name || "created_at"}',
799
802
  order: 'desc'
800
- });`,
803
+ });
804
+
805
+ // Calculate total pages
806
+ const totalPages = Math.ceil(filtered.total / filtered.limit);
807
+ const currentPage = Math.floor(filtered.offset / filtered.limit) + 1;`,
801
808
  correspondsTo: `GET ${basePath}`
802
809
  });
803
810
  endpoints.push({
804
811
  method: "GET",
805
812
  path: basePath,
806
- description: `List all ${tableName} records`,
813
+ description: `List all ${tableName} records with pagination metadata`,
807
814
  queryParameters: generateQueryParams(table, enums),
808
- responseBody: `${Type}[]`
815
+ responseBody: `PaginatedResponse<${Type}>`
809
816
  });
810
817
  if (hasSinglePK) {
811
818
  sdkMethods.push({
@@ -895,7 +902,10 @@ console.log('Deleted:', deleted);`,
895
902
  }, allTables);
896
903
  for (const method of includeMethods) {
897
904
  const isGetByPk = method.name.startsWith("getByPk");
898
- const exampleCall = isGetByPk ? `const result = await sdk.${tableName}.${method.name}('123e4567-e89b-12d3-a456-426614174000');` : `const results = await sdk.${tableName}.${method.name}();
905
+ const exampleCall = isGetByPk ? `const result = await sdk.${tableName}.${method.name}('123e4567-e89b-12d3-a456-426614174000');` : `const result = await sdk.${tableName}.${method.name}();
906
+ console.log(result.data); // array of records with includes
907
+ console.log(result.total); // total count
908
+ console.log(result.hasMore); // more pages available
899
909
 
900
910
  // With filters and pagination
901
911
  const filtered = await sdk.${tableName}.${method.name}({
@@ -2725,6 +2735,31 @@ export type PaginationParams = z.infer<typeof PaginationParamsSchema>;
2725
2735
  `;
2726
2736
  }
2727
2737
 
2738
+ // src/emit-shared-types.ts
2739
+ function emitSharedTypes() {
2740
+ return `/**
2741
+ * Shared types used across all SDK operations
2742
+ */
2743
+
2744
+ /**
2745
+ * Paginated response structure returned by list operations
2746
+ * @template T - The type of records in the data array
2747
+ */
2748
+ export interface PaginatedResponse<T> {
2749
+ /** Array of records for the current page */
2750
+ data: T[];
2751
+ /** Total number of records matching the query (across all pages) */
2752
+ total: number;
2753
+ /** Maximum number of records per page */
2754
+ limit: number;
2755
+ /** Number of records skipped (for pagination) */
2756
+ offset: number;
2757
+ /** Whether there are more records available after this page */
2758
+ hasMore: boolean;
2759
+ }
2760
+ `;
2761
+ }
2762
+
2728
2763
  // src/emit-routes-hono.ts
2729
2764
  init_utils();
2730
2765
  function emitHonoRoutes(table, _graph, opts) {
@@ -2768,6 +2803,13 @@ const listSchema = z.object({
2768
2803
  order: z.union([z.enum(["asc", "desc"]), z.array(z.enum(["asc", "desc"]))]).optional()
2769
2804
  });
2770
2805
 
2806
+ /**
2807
+ * Register all CRUD routes for the ${fileTableName} table
2808
+ * @param app - Hono application instance
2809
+ * @param deps - Dependencies including database client and optional request hook
2810
+ * @param deps.pg - PostgreSQL client with query method
2811
+ * @param deps.onRequest - Optional hook that runs before each request (for audit logging, RLS, etc.)
2812
+ */
2771
2813
  export function register${Type}Routes(app: Hono, deps: { pg: { query: (text: string, params?: any[]) => Promise<{ rows: any[] }> }, onRequest?: (c: Context, pg: { query: (text: string, params?: any[]) => Promise<{ rows: any[] }> }) => Promise<void> }) {
2772
2814
  const base = "/v1/${fileTableName}";
2773
2815
 
@@ -2847,34 +2889,50 @@ ${hasAuth ? `
2847
2889
  if (result.needsIncludes && result.includeSpec) {
2848
2890
  try {
2849
2891
  const stitched = await loadIncludes(
2850
- "${fileTableName}",
2851
- result.data,
2852
- result.includeSpec,
2853
- deps.pg,
2892
+ "${fileTableName}",
2893
+ result.data,
2894
+ result.includeSpec,
2895
+ deps.pg,
2854
2896
  ${opts.includeMethodsDepth}
2855
2897
  );
2856
- return c.json(stitched);
2898
+ return c.json({
2899
+ data: stitched,
2900
+ total: result.total,
2901
+ limit: result.limit,
2902
+ offset: result.offset,
2903
+ hasMore: result.hasMore
2904
+ });
2857
2905
  } catch (e: any) {
2858
2906
  const strict = process.env.SDK_STRICT_INCLUDE === "1";
2859
2907
  if (strict) {
2860
- return c.json({
2861
- error: "include-stitch-failed",
2908
+ return c.json({
2909
+ error: "include-stitch-failed",
2862
2910
  message: e?.message,
2863
2911
  ...(process.env.SDK_DEBUG === "1" ? { stack: e?.stack } : {})
2864
2912
  }, 500);
2865
2913
  }
2866
2914
  // Non-strict: return base rows with error metadata
2867
- return c.json({
2868
- data: result.data,
2869
- includeError: {
2915
+ return c.json({
2916
+ data: result.data,
2917
+ total: result.total,
2918
+ limit: result.limit,
2919
+ offset: result.offset,
2920
+ hasMore: result.hasMore,
2921
+ includeError: {
2870
2922
  message: e?.message,
2871
2923
  ...(process.env.SDK_DEBUG === "1" ? { stack: e?.stack } : {})
2872
2924
  }
2873
2925
  }, 200);
2874
2926
  }
2875
2927
  }
2876
-
2877
- return c.json(result.data, result.status as any);
2928
+
2929
+ return c.json({
2930
+ data: result.data,
2931
+ total: result.total,
2932
+ limit: result.limit,
2933
+ offset: result.offset,
2934
+ hasMore: result.hasMore
2935
+ }, result.status as any);
2878
2936
  });
2879
2937
 
2880
2938
  // UPDATE
@@ -2955,21 +3013,36 @@ function emitClient(table, graph, opts, model) {
2955
3013
  for (const method of includeMethods) {
2956
3014
  const isGetByPk = method.name.startsWith("getByPk");
2957
3015
  const baseParams = isGetByPk ? "" : `params?: Omit<{ limit?: number; offset?: number; where?: Where<Select${Type}>; orderBy?: string | string[]; order?: "asc" | "desc" | ("asc" | "desc")[]; }, "include">`;
3016
+ const relationshipDesc = method.path.map((p, i) => {
3017
+ const isLast = i === method.path.length - 1;
3018
+ const relation = method.isMany[i] ? "many" : "one";
3019
+ return isLast ? p : `${p} -> `;
3020
+ }).join("");
2958
3021
  if (isGetByPk) {
2959
3022
  const pkWhere = hasCompositePk ? `{ ${safePk.map((col) => `${col}: pk.${col}`).join(", ")} }` : `{ ${safePk[0] || "id"}: pk }`;
2960
3023
  const baseReturnType = method.returnType.replace(" | null", "");
2961
3024
  includeMethodsCode += `
3025
+ /**
3026
+ * Get a ${table.name} record by primary key with included related ${relationshipDesc}
3027
+ * @param pk - The primary key value${hasCompositePk ? "s" : ""}
3028
+ * @returns The record with nested ${method.path.join(" and ")} if found, null otherwise
3029
+ */
2962
3030
  async ${method.name}(pk: ${pkType}): Promise<${method.returnType}> {
2963
- const results = await this.post<${baseReturnType}[]>(\`\${this.resource}/list\`, {
3031
+ const results = await this.post<PaginatedResponse<${baseReturnType}>>(\`\${this.resource}/list\`, {
2964
3032
  where: ${pkWhere},
2965
3033
  include: ${JSON.stringify(method.includeSpec)},
2966
- limit: 1
3034
+ limit: 1
2967
3035
  });
2968
- return (results[0] as ${baseReturnType}) ?? null;
3036
+ return (results.data[0] as ${baseReturnType}) ?? null;
2969
3037
  }
2970
3038
  `;
2971
3039
  } else {
2972
3040
  includeMethodsCode += `
3041
+ /**
3042
+ * List ${table.name} records with included related ${relationshipDesc}
3043
+ * @param params - Query parameters (where, orderBy, order, limit, offset)
3044
+ * @returns Paginated results with nested ${method.path.join(" and ")} included
3045
+ */
2973
3046
  async ${method.name}(${baseParams}): Promise<${method.returnType}> {
2974
3047
  return this.post<${method.returnType}>(\`\${this.resource}/list\`, { ...params, include: ${JSON.stringify(method.includeSpec)} });
2975
3048
  }
@@ -2986,6 +3059,7 @@ function emitClient(table, graph, opts, model) {
2986
3059
  */
2987
3060
  import { BaseClient } from "./base-client${ext}";
2988
3061
  import type { Where } from "./where-types${ext}";
3062
+ import type { PaginatedResponse } from "./types/shared${ext}";
2989
3063
  ${typeImports}
2990
3064
  ${otherTableImports.join(`
2991
3065
  `)}
@@ -2996,15 +3070,36 @@ ${otherTableImports.join(`
2996
3070
  export class ${Type}Client extends BaseClient {
2997
3071
  private readonly resource = "/v1/${table.name}";
2998
3072
 
3073
+ /**
3074
+ * Create a new ${table.name} record
3075
+ * @param data - The data to insert
3076
+ * @returns The created record
3077
+ */
2999
3078
  async create(data: Insert${Type}): Promise<Select${Type}> {
3000
3079
  return this.post<Select${Type}>(this.resource, data);
3001
3080
  }
3002
3081
 
3082
+ /**
3083
+ * Get a ${table.name} record by primary key
3084
+ * @param pk - The primary key value${hasCompositePk ? "s" : ""}
3085
+ * @returns The record if found, null otherwise
3086
+ */
3003
3087
  async getByPk(pk: ${pkType}): Promise<Select${Type} | null> {
3004
3088
  const path = ${pkPathExpr};
3005
3089
  return this.get<Select${Type} | null>(\`\${this.resource}/\${path}\`);
3006
3090
  }
3007
3091
 
3092
+ /**
3093
+ * List ${table.name} records with pagination and filtering
3094
+ * @param params - Query parameters
3095
+ * @param params.where - Filter conditions using operators like $eq, $gt, $in, $like, etc.
3096
+ * @param params.orderBy - Column(s) to sort by
3097
+ * @param params.order - Sort direction(s): "asc" or "desc"
3098
+ * @param params.limit - Maximum number of records to return (default: 50, max: 100)
3099
+ * @param params.offset - Number of records to skip for pagination
3100
+ * @param params.include - Related records to include (see listWith* methods for typed includes)
3101
+ * @returns Paginated results with data, total count, and hasMore flag
3102
+ */
3008
3103
  async list(params?: {
3009
3104
  include?: any;
3010
3105
  limit?: number;
@@ -3012,15 +3107,26 @@ export class ${Type}Client extends BaseClient {
3012
3107
  where?: Where<Select${Type}>;
3013
3108
  orderBy?: string | string[];
3014
3109
  order?: "asc" | "desc" | ("asc" | "desc")[];
3015
- }): Promise<Select${Type}[]> {
3016
- return this.post<Select${Type}[]>(\`\${this.resource}/list\`, params ?? {});
3110
+ }): Promise<PaginatedResponse<Select${Type}>> {
3111
+ return this.post<PaginatedResponse<Select${Type}>>(\`\${this.resource}/list\`, params ?? {});
3017
3112
  }
3018
3113
 
3114
+ /**
3115
+ * Update a ${table.name} record by primary key
3116
+ * @param pk - The primary key value${hasCompositePk ? "s" : ""}
3117
+ * @param patch - Partial data to update
3118
+ * @returns The updated record if found, null otherwise
3119
+ */
3019
3120
  async update(pk: ${pkType}, patch: Update${Type}): Promise<Select${Type} | null> {
3020
3121
  const path = ${pkPathExpr};
3021
3122
  return this.patch<Select${Type} | null>(\`\${this.resource}/\${path}\`, patch);
3022
3123
  }
3023
3124
 
3125
+ /**
3126
+ * Delete a ${table.name} record by primary key
3127
+ * @param pk - The primary key value${hasCompositePk ? "s" : ""}
3128
+ * @returns The deleted record if found, null otherwise
3129
+ */
3024
3130
  async delete(pk: ${pkType}): Promise<Select${Type} | null> {
3025
3131
  const path = ${pkPathExpr};
3026
3132
  return this.del<Select${Type} | null>(\`\${this.resource}/\${path}\`);
@@ -3113,6 +3219,11 @@ export type { AuthConfig, HeaderMap, AuthHeadersProvider } from "./base-client${
3113
3219
  out += `export type { Insert${Type}, Update${Type}, Select${Type} } from "./types/${t.name}${ext}";
3114
3220
  `;
3115
3221
  }
3222
+ out += `
3223
+ // Shared types
3224
+ `;
3225
+ out += `export type { PaginatedResponse } from "./types/shared${ext}";
3226
+ `;
3116
3227
  return out;
3117
3228
  }
3118
3229
 
@@ -3611,12 +3722,25 @@ function emitTypes(table, opts, enums) {
3611
3722
  *
3612
3723
  * To make changes, modify your schema or configuration and regenerate.
3613
3724
  */
3725
+
3726
+ /**
3727
+ * Type for inserting a new ${table.name} record.
3728
+ * Fields with defaults or nullable columns are optional.
3729
+ */
3614
3730
  export type Insert${Type} = {
3615
3731
  ${insertFields}
3616
3732
  };
3617
3733
 
3734
+ /**
3735
+ * Type for updating an existing ${table.name} record.
3736
+ * All fields are optional, allowing partial updates.
3737
+ */
3618
3738
  export type Update${Type} = Partial<Insert${Type}>;
3619
3739
 
3740
+ /**
3741
+ * Type representing a ${table.name} record from the database.
3742
+ * All fields are included as returned by SELECT queries.
3743
+ */
3620
3744
  export type Select${Type} = {
3621
3745
  ${selectFields}
3622
3746
  };
@@ -4349,7 +4473,7 @@ function buildWhereClause(
4349
4473
  export async function listRecords(
4350
4474
  ctx: OperationContext,
4351
4475
  params: { where?: any; limit?: number; offset?: number; include?: any; orderBy?: string | string[]; order?: "asc" | "desc" | ("asc" | "desc")[] }
4352
- ): Promise<{ data?: any; error?: string; issues?: any; needsIncludes?: boolean; includeSpec?: any; status: number }> {
4476
+ ): Promise<{ data?: any; total?: number; limit?: number; offset?: number; hasMore?: boolean; error?: string; issues?: any; needsIncludes?: boolean; includeSpec?: any; status: number }> {
4353
4477
  try {
4354
4478
  const { where: whereClause, limit = 50, offset = 0, include, orderBy, order } = params;
4355
4479
 
@@ -4394,20 +4518,34 @@ export async function listRecords(
4394
4518
  const offsetParam = \`$\${paramIndex + 1}\`;
4395
4519
  const allParams = [...whereParams, limit, offset];
4396
4520
 
4521
+ // Get total count for pagination
4522
+ const countText = \`SELECT COUNT(*) FROM "\${ctx.table}" \${whereSQL}\`;
4523
+ log.debug(\`LIST \${ctx.table} COUNT SQL:\`, countText, "params:", whereParams);
4524
+ const countResult = await ctx.pg.query(countText, whereParams);
4525
+ const total = parseInt(countResult.rows[0].count, 10);
4526
+
4527
+ // Get paginated data
4397
4528
  const text = \`SELECT * FROM "\${ctx.table}" \${whereSQL} \${orderBySQL} LIMIT \${limitParam} OFFSET \${offsetParam}\`;
4398
4529
  log.debug(\`LIST \${ctx.table} SQL:\`, text, "params:", allParams);
4399
4530
 
4400
4531
  const { rows } = await ctx.pg.query(text, allParams);
4401
4532
 
4402
- if (!include) {
4403
- log.debug(\`LIST \${ctx.table} rows:\`, rows.length);
4404
- return { data: rows, status: 200 };
4405
- }
4533
+ // Calculate hasMore
4534
+ const hasMore = offset + limit < total;
4535
+
4536
+ const metadata = {
4537
+ data: rows,
4538
+ total,
4539
+ limit,
4540
+ offset,
4541
+ hasMore,
4542
+ needsIncludes: !!include,
4543
+ includeSpec: include,
4544
+ status: 200
4545
+ };
4406
4546
 
4407
- // Include logic will be handled by the include-loader
4408
- // For now, just return the rows with a note that includes need to be applied
4409
- log.debug(\`LIST \${ctx.table} include spec:\`, include);
4410
- return { data: rows, needsIncludes: true, includeSpec: include, status: 200 };
4547
+ log.debug(\`LIST \${ctx.table} result: \${rows.length} rows, \${total} total, hasMore=\${hasMore}\`);
4548
+ return metadata;
4411
4549
  } catch (e: any) {
4412
4550
  log.error(\`LIST \${ctx.table} error:\`, e?.stack ?? e);
4413
4551
  return {
@@ -4549,8 +4687,11 @@ describe('${Type} SDK Operations', () => {
4549
4687
  });
4550
4688
 
4551
4689
  it('should list ${tableName} relationships', async () => {
4552
- const list = await sdk.${tableName}.list({ limit: 10 });
4553
- expect(Array.isArray(list)).toBe(true);
4690
+ const result = await sdk.${tableName}.list({ limit: 10 });
4691
+ expect(result).toBeDefined();
4692
+ expect(Array.isArray(result.data)).toBe(true);
4693
+ expect(typeof result.total).toBe('number');
4694
+ expect(typeof result.hasMore).toBe('boolean');
4554
4695
  });
4555
4696
  });
4556
4697
  `;
@@ -5186,8 +5327,11 @@ function generateTestCases(table, sampleData, updateData, hasForeignKeys = false
5186
5327
  });
5187
5328
 
5188
5329
  it('should list ${table.name}', async () => {
5189
- const list = await sdk.${table.name}.list({ limit: 10 });
5190
- expect(Array.isArray(list)).toBe(true);
5330
+ const result = await sdk.${table.name}.list({ limit: 10 });
5331
+ expect(result).toBeDefined();
5332
+ expect(Array.isArray(result.data)).toBe(true);
5333
+ expect(typeof result.total).toBe('number');
5334
+ expect(typeof result.hasMore).toBe('boolean');
5191
5335
  });
5192
5336
 
5193
5337
  ${hasData && hasSinglePK ? `it('should get ${table.name} by id', async () => {
@@ -5314,6 +5458,7 @@ async function generate(configPath) {
5314
5458
  files.push({ path: join(serverDir, "include-spec.ts"), content: includeSpec });
5315
5459
  files.push({ path: join(clientDir, "include-spec.ts"), content: includeSpec });
5316
5460
  files.push({ path: join(clientDir, "params", "shared.ts"), content: emitSharedParamsZod() });
5461
+ files.push({ path: join(clientDir, "types", "shared.ts"), content: emitSharedTypes() });
5317
5462
  files.push({ path: join(clientDir, "base-client.ts"), content: emitBaseClient() });
5318
5463
  files.push({ path: join(clientDir, "where-types.ts"), content: emitWhereTypes() });
5319
5464
  files.push({
@@ -0,0 +1 @@
1
+ export declare function emitSharedTypes(): string;
package/dist/index.js CHANGED
@@ -577,7 +577,7 @@ function generateIncludeMethods(table, graph, opts, allTables) {
577
577
  path: newPath,
578
578
  isMany: newIsMany,
579
579
  targets: newTargets,
580
- returnType: `(${buildReturnType(baseTableName, newPath, newIsMany, newTargets, graph)})[]`,
580
+ returnType: `PaginatedResponse<${buildReturnType(baseTableName, newPath, newIsMany, newTargets, graph)}>`,
581
581
  includeSpec: buildIncludeSpec(newPath)
582
582
  });
583
583
  methods.push({
@@ -617,7 +617,7 @@ function generateIncludeMethods(table, graph, opts, allTables) {
617
617
  path: combinedPath,
618
618
  isMany: [edge1.kind === "many", edge2.kind === "many"],
619
619
  targets: [edge1.target, edge2.target],
620
- returnType: `(Select${pascal(baseTableName)} & { ${type1}; ${type2} })[]`,
620
+ returnType: `PaginatedResponse<Select${pascal(baseTableName)} & { ${type1}; ${type2} }>`,
621
621
  includeSpec: { [key1]: true, [key2]: true }
622
622
  });
623
623
  methods.push({
@@ -784,10 +784,13 @@ function generateResourceWithSDK(table, model, graph, config) {
784
784
  const endpoints = [];
785
785
  sdkMethods.push({
786
786
  name: "list",
787
- signature: `list(params?: ListParams): Promise<${Type}[]>`,
788
- description: `List ${tableName} with filtering, sorting, and pagination`,
787
+ signature: `list(params?: ListParams): Promise<PaginatedResponse<${Type}>>`,
788
+ description: `List ${tableName} with filtering, sorting, and pagination. Returns paginated results with metadata.`,
789
789
  example: `// Get all ${tableName}
790
- const items = await sdk.${tableName}.list();
790
+ const result = await sdk.${tableName}.list();
791
+ console.log(result.data); // array of records
792
+ console.log(result.total); // total matching records
793
+ console.log(result.hasMore); // true if more pages available
791
794
 
792
795
  // With filters and pagination
793
796
  const filtered = await sdk.${tableName}.list({
@@ -796,15 +799,19 @@ const filtered = await sdk.${tableName}.list({
796
799
  where: { ${table.columns[0]?.name || "field"}: { $like: '%search%' } },
797
800
  orderBy: '${table.columns[0]?.name || "created_at"}',
798
801
  order: 'desc'
799
- });`,
802
+ });
803
+
804
+ // Calculate total pages
805
+ const totalPages = Math.ceil(filtered.total / filtered.limit);
806
+ const currentPage = Math.floor(filtered.offset / filtered.limit) + 1;`,
800
807
  correspondsTo: `GET ${basePath}`
801
808
  });
802
809
  endpoints.push({
803
810
  method: "GET",
804
811
  path: basePath,
805
- description: `List all ${tableName} records`,
812
+ description: `List all ${tableName} records with pagination metadata`,
806
813
  queryParameters: generateQueryParams(table, enums),
807
- responseBody: `${Type}[]`
814
+ responseBody: `PaginatedResponse<${Type}>`
808
815
  });
809
816
  if (hasSinglePK) {
810
817
  sdkMethods.push({
@@ -894,7 +901,10 @@ console.log('Deleted:', deleted);`,
894
901
  }, allTables);
895
902
  for (const method of includeMethods) {
896
903
  const isGetByPk = method.name.startsWith("getByPk");
897
- const exampleCall = isGetByPk ? `const result = await sdk.${tableName}.${method.name}('123e4567-e89b-12d3-a456-426614174000');` : `const results = await sdk.${tableName}.${method.name}();
904
+ const exampleCall = isGetByPk ? `const result = await sdk.${tableName}.${method.name}('123e4567-e89b-12d3-a456-426614174000');` : `const result = await sdk.${tableName}.${method.name}();
905
+ console.log(result.data); // array of records with includes
906
+ console.log(result.total); // total count
907
+ console.log(result.hasMore); // more pages available
898
908
 
899
909
  // With filters and pagination
900
910
  const filtered = await sdk.${tableName}.${method.name}({
@@ -1965,6 +1975,31 @@ export type PaginationParams = z.infer<typeof PaginationParamsSchema>;
1965
1975
  `;
1966
1976
  }
1967
1977
 
1978
+ // src/emit-shared-types.ts
1979
+ function emitSharedTypes() {
1980
+ return `/**
1981
+ * Shared types used across all SDK operations
1982
+ */
1983
+
1984
+ /**
1985
+ * Paginated response structure returned by list operations
1986
+ * @template T - The type of records in the data array
1987
+ */
1988
+ export interface PaginatedResponse<T> {
1989
+ /** Array of records for the current page */
1990
+ data: T[];
1991
+ /** Total number of records matching the query (across all pages) */
1992
+ total: number;
1993
+ /** Maximum number of records per page */
1994
+ limit: number;
1995
+ /** Number of records skipped (for pagination) */
1996
+ offset: number;
1997
+ /** Whether there are more records available after this page */
1998
+ hasMore: boolean;
1999
+ }
2000
+ `;
2001
+ }
2002
+
1968
2003
  // src/emit-routes-hono.ts
1969
2004
  init_utils();
1970
2005
  function emitHonoRoutes(table, _graph, opts) {
@@ -2008,6 +2043,13 @@ const listSchema = z.object({
2008
2043
  order: z.union([z.enum(["asc", "desc"]), z.array(z.enum(["asc", "desc"]))]).optional()
2009
2044
  });
2010
2045
 
2046
+ /**
2047
+ * Register all CRUD routes for the ${fileTableName} table
2048
+ * @param app - Hono application instance
2049
+ * @param deps - Dependencies including database client and optional request hook
2050
+ * @param deps.pg - PostgreSQL client with query method
2051
+ * @param deps.onRequest - Optional hook that runs before each request (for audit logging, RLS, etc.)
2052
+ */
2011
2053
  export function register${Type}Routes(app: Hono, deps: { pg: { query: (text: string, params?: any[]) => Promise<{ rows: any[] }> }, onRequest?: (c: Context, pg: { query: (text: string, params?: any[]) => Promise<{ rows: any[] }> }) => Promise<void> }) {
2012
2054
  const base = "/v1/${fileTableName}";
2013
2055
 
@@ -2087,34 +2129,50 @@ ${hasAuth ? `
2087
2129
  if (result.needsIncludes && result.includeSpec) {
2088
2130
  try {
2089
2131
  const stitched = await loadIncludes(
2090
- "${fileTableName}",
2091
- result.data,
2092
- result.includeSpec,
2093
- deps.pg,
2132
+ "${fileTableName}",
2133
+ result.data,
2134
+ result.includeSpec,
2135
+ deps.pg,
2094
2136
  ${opts.includeMethodsDepth}
2095
2137
  );
2096
- return c.json(stitched);
2138
+ return c.json({
2139
+ data: stitched,
2140
+ total: result.total,
2141
+ limit: result.limit,
2142
+ offset: result.offset,
2143
+ hasMore: result.hasMore
2144
+ });
2097
2145
  } catch (e: any) {
2098
2146
  const strict = process.env.SDK_STRICT_INCLUDE === "1";
2099
2147
  if (strict) {
2100
- return c.json({
2101
- error: "include-stitch-failed",
2148
+ return c.json({
2149
+ error: "include-stitch-failed",
2102
2150
  message: e?.message,
2103
2151
  ...(process.env.SDK_DEBUG === "1" ? { stack: e?.stack } : {})
2104
2152
  }, 500);
2105
2153
  }
2106
2154
  // Non-strict: return base rows with error metadata
2107
- return c.json({
2108
- data: result.data,
2109
- includeError: {
2155
+ return c.json({
2156
+ data: result.data,
2157
+ total: result.total,
2158
+ limit: result.limit,
2159
+ offset: result.offset,
2160
+ hasMore: result.hasMore,
2161
+ includeError: {
2110
2162
  message: e?.message,
2111
2163
  ...(process.env.SDK_DEBUG === "1" ? { stack: e?.stack } : {})
2112
2164
  }
2113
2165
  }, 200);
2114
2166
  }
2115
2167
  }
2116
-
2117
- return c.json(result.data, result.status as any);
2168
+
2169
+ return c.json({
2170
+ data: result.data,
2171
+ total: result.total,
2172
+ limit: result.limit,
2173
+ offset: result.offset,
2174
+ hasMore: result.hasMore
2175
+ }, result.status as any);
2118
2176
  });
2119
2177
 
2120
2178
  // UPDATE
@@ -2195,21 +2253,36 @@ function emitClient(table, graph, opts, model) {
2195
2253
  for (const method of includeMethods) {
2196
2254
  const isGetByPk = method.name.startsWith("getByPk");
2197
2255
  const baseParams = isGetByPk ? "" : `params?: Omit<{ limit?: number; offset?: number; where?: Where<Select${Type}>; orderBy?: string | string[]; order?: "asc" | "desc" | ("asc" | "desc")[]; }, "include">`;
2256
+ const relationshipDesc = method.path.map((p, i) => {
2257
+ const isLast = i === method.path.length - 1;
2258
+ const relation = method.isMany[i] ? "many" : "one";
2259
+ return isLast ? p : `${p} -> `;
2260
+ }).join("");
2198
2261
  if (isGetByPk) {
2199
2262
  const pkWhere = hasCompositePk ? `{ ${safePk.map((col) => `${col}: pk.${col}`).join(", ")} }` : `{ ${safePk[0] || "id"}: pk }`;
2200
2263
  const baseReturnType = method.returnType.replace(" | null", "");
2201
2264
  includeMethodsCode += `
2265
+ /**
2266
+ * Get a ${table.name} record by primary key with included related ${relationshipDesc}
2267
+ * @param pk - The primary key value${hasCompositePk ? "s" : ""}
2268
+ * @returns The record with nested ${method.path.join(" and ")} if found, null otherwise
2269
+ */
2202
2270
  async ${method.name}(pk: ${pkType}): Promise<${method.returnType}> {
2203
- const results = await this.post<${baseReturnType}[]>(\`\${this.resource}/list\`, {
2271
+ const results = await this.post<PaginatedResponse<${baseReturnType}>>(\`\${this.resource}/list\`, {
2204
2272
  where: ${pkWhere},
2205
2273
  include: ${JSON.stringify(method.includeSpec)},
2206
- limit: 1
2274
+ limit: 1
2207
2275
  });
2208
- return (results[0] as ${baseReturnType}) ?? null;
2276
+ return (results.data[0] as ${baseReturnType}) ?? null;
2209
2277
  }
2210
2278
  `;
2211
2279
  } else {
2212
2280
  includeMethodsCode += `
2281
+ /**
2282
+ * List ${table.name} records with included related ${relationshipDesc}
2283
+ * @param params - Query parameters (where, orderBy, order, limit, offset)
2284
+ * @returns Paginated results with nested ${method.path.join(" and ")} included
2285
+ */
2213
2286
  async ${method.name}(${baseParams}): Promise<${method.returnType}> {
2214
2287
  return this.post<${method.returnType}>(\`\${this.resource}/list\`, { ...params, include: ${JSON.stringify(method.includeSpec)} });
2215
2288
  }
@@ -2226,6 +2299,7 @@ function emitClient(table, graph, opts, model) {
2226
2299
  */
2227
2300
  import { BaseClient } from "./base-client${ext}";
2228
2301
  import type { Where } from "./where-types${ext}";
2302
+ import type { PaginatedResponse } from "./types/shared${ext}";
2229
2303
  ${typeImports}
2230
2304
  ${otherTableImports.join(`
2231
2305
  `)}
@@ -2236,15 +2310,36 @@ ${otherTableImports.join(`
2236
2310
  export class ${Type}Client extends BaseClient {
2237
2311
  private readonly resource = "/v1/${table.name}";
2238
2312
 
2313
+ /**
2314
+ * Create a new ${table.name} record
2315
+ * @param data - The data to insert
2316
+ * @returns The created record
2317
+ */
2239
2318
  async create(data: Insert${Type}): Promise<Select${Type}> {
2240
2319
  return this.post<Select${Type}>(this.resource, data);
2241
2320
  }
2242
2321
 
2322
+ /**
2323
+ * Get a ${table.name} record by primary key
2324
+ * @param pk - The primary key value${hasCompositePk ? "s" : ""}
2325
+ * @returns The record if found, null otherwise
2326
+ */
2243
2327
  async getByPk(pk: ${pkType}): Promise<Select${Type} | null> {
2244
2328
  const path = ${pkPathExpr};
2245
2329
  return this.get<Select${Type} | null>(\`\${this.resource}/\${path}\`);
2246
2330
  }
2247
2331
 
2332
+ /**
2333
+ * List ${table.name} records with pagination and filtering
2334
+ * @param params - Query parameters
2335
+ * @param params.where - Filter conditions using operators like $eq, $gt, $in, $like, etc.
2336
+ * @param params.orderBy - Column(s) to sort by
2337
+ * @param params.order - Sort direction(s): "asc" or "desc"
2338
+ * @param params.limit - Maximum number of records to return (default: 50, max: 100)
2339
+ * @param params.offset - Number of records to skip for pagination
2340
+ * @param params.include - Related records to include (see listWith* methods for typed includes)
2341
+ * @returns Paginated results with data, total count, and hasMore flag
2342
+ */
2248
2343
  async list(params?: {
2249
2344
  include?: any;
2250
2345
  limit?: number;
@@ -2252,15 +2347,26 @@ export class ${Type}Client extends BaseClient {
2252
2347
  where?: Where<Select${Type}>;
2253
2348
  orderBy?: string | string[];
2254
2349
  order?: "asc" | "desc" | ("asc" | "desc")[];
2255
- }): Promise<Select${Type}[]> {
2256
- return this.post<Select${Type}[]>(\`\${this.resource}/list\`, params ?? {});
2350
+ }): Promise<PaginatedResponse<Select${Type}>> {
2351
+ return this.post<PaginatedResponse<Select${Type}>>(\`\${this.resource}/list\`, params ?? {});
2257
2352
  }
2258
2353
 
2354
+ /**
2355
+ * Update a ${table.name} record by primary key
2356
+ * @param pk - The primary key value${hasCompositePk ? "s" : ""}
2357
+ * @param patch - Partial data to update
2358
+ * @returns The updated record if found, null otherwise
2359
+ */
2259
2360
  async update(pk: ${pkType}, patch: Update${Type}): Promise<Select${Type} | null> {
2260
2361
  const path = ${pkPathExpr};
2261
2362
  return this.patch<Select${Type} | null>(\`\${this.resource}/\${path}\`, patch);
2262
2363
  }
2263
2364
 
2365
+ /**
2366
+ * Delete a ${table.name} record by primary key
2367
+ * @param pk - The primary key value${hasCompositePk ? "s" : ""}
2368
+ * @returns The deleted record if found, null otherwise
2369
+ */
2264
2370
  async delete(pk: ${pkType}): Promise<Select${Type} | null> {
2265
2371
  const path = ${pkPathExpr};
2266
2372
  return this.del<Select${Type} | null>(\`\${this.resource}/\${path}\`);
@@ -2353,6 +2459,11 @@ export type { AuthConfig, HeaderMap, AuthHeadersProvider } from "./base-client${
2353
2459
  out += `export type { Insert${Type}, Update${Type}, Select${Type} } from "./types/${t.name}${ext}";
2354
2460
  `;
2355
2461
  }
2462
+ out += `
2463
+ // Shared types
2464
+ `;
2465
+ out += `export type { PaginatedResponse } from "./types/shared${ext}";
2466
+ `;
2356
2467
  return out;
2357
2468
  }
2358
2469
 
@@ -2851,12 +2962,25 @@ function emitTypes(table, opts, enums) {
2851
2962
  *
2852
2963
  * To make changes, modify your schema or configuration and regenerate.
2853
2964
  */
2965
+
2966
+ /**
2967
+ * Type for inserting a new ${table.name} record.
2968
+ * Fields with defaults or nullable columns are optional.
2969
+ */
2854
2970
  export type Insert${Type} = {
2855
2971
  ${insertFields}
2856
2972
  };
2857
2973
 
2974
+ /**
2975
+ * Type for updating an existing ${table.name} record.
2976
+ * All fields are optional, allowing partial updates.
2977
+ */
2858
2978
  export type Update${Type} = Partial<Insert${Type}>;
2859
2979
 
2980
+ /**
2981
+ * Type representing a ${table.name} record from the database.
2982
+ * All fields are included as returned by SELECT queries.
2983
+ */
2860
2984
  export type Select${Type} = {
2861
2985
  ${selectFields}
2862
2986
  };
@@ -3589,7 +3713,7 @@ function buildWhereClause(
3589
3713
  export async function listRecords(
3590
3714
  ctx: OperationContext,
3591
3715
  params: { where?: any; limit?: number; offset?: number; include?: any; orderBy?: string | string[]; order?: "asc" | "desc" | ("asc" | "desc")[] }
3592
- ): Promise<{ data?: any; error?: string; issues?: any; needsIncludes?: boolean; includeSpec?: any; status: number }> {
3716
+ ): Promise<{ data?: any; total?: number; limit?: number; offset?: number; hasMore?: boolean; error?: string; issues?: any; needsIncludes?: boolean; includeSpec?: any; status: number }> {
3593
3717
  try {
3594
3718
  const { where: whereClause, limit = 50, offset = 0, include, orderBy, order } = params;
3595
3719
 
@@ -3634,20 +3758,34 @@ export async function listRecords(
3634
3758
  const offsetParam = \`$\${paramIndex + 1}\`;
3635
3759
  const allParams = [...whereParams, limit, offset];
3636
3760
 
3761
+ // Get total count for pagination
3762
+ const countText = \`SELECT COUNT(*) FROM "\${ctx.table}" \${whereSQL}\`;
3763
+ log.debug(\`LIST \${ctx.table} COUNT SQL:\`, countText, "params:", whereParams);
3764
+ const countResult = await ctx.pg.query(countText, whereParams);
3765
+ const total = parseInt(countResult.rows[0].count, 10);
3766
+
3767
+ // Get paginated data
3637
3768
  const text = \`SELECT * FROM "\${ctx.table}" \${whereSQL} \${orderBySQL} LIMIT \${limitParam} OFFSET \${offsetParam}\`;
3638
3769
  log.debug(\`LIST \${ctx.table} SQL:\`, text, "params:", allParams);
3639
3770
 
3640
3771
  const { rows } = await ctx.pg.query(text, allParams);
3641
3772
 
3642
- if (!include) {
3643
- log.debug(\`LIST \${ctx.table} rows:\`, rows.length);
3644
- return { data: rows, status: 200 };
3645
- }
3773
+ // Calculate hasMore
3774
+ const hasMore = offset + limit < total;
3775
+
3776
+ const metadata = {
3777
+ data: rows,
3778
+ total,
3779
+ limit,
3780
+ offset,
3781
+ hasMore,
3782
+ needsIncludes: !!include,
3783
+ includeSpec: include,
3784
+ status: 200
3785
+ };
3646
3786
 
3647
- // Include logic will be handled by the include-loader
3648
- // For now, just return the rows with a note that includes need to be applied
3649
- log.debug(\`LIST \${ctx.table} include spec:\`, include);
3650
- return { data: rows, needsIncludes: true, includeSpec: include, status: 200 };
3787
+ log.debug(\`LIST \${ctx.table} result: \${rows.length} rows, \${total} total, hasMore=\${hasMore}\`);
3788
+ return metadata;
3651
3789
  } catch (e: any) {
3652
3790
  log.error(\`LIST \${ctx.table} error:\`, e?.stack ?? e);
3653
3791
  return {
@@ -3789,8 +3927,11 @@ describe('${Type} SDK Operations', () => {
3789
3927
  });
3790
3928
 
3791
3929
  it('should list ${tableName} relationships', async () => {
3792
- const list = await sdk.${tableName}.list({ limit: 10 });
3793
- expect(Array.isArray(list)).toBe(true);
3930
+ const result = await sdk.${tableName}.list({ limit: 10 });
3931
+ expect(result).toBeDefined();
3932
+ expect(Array.isArray(result.data)).toBe(true);
3933
+ expect(typeof result.total).toBe('number');
3934
+ expect(typeof result.hasMore).toBe('boolean');
3794
3935
  });
3795
3936
  });
3796
3937
  `;
@@ -4426,8 +4567,11 @@ function generateTestCases(table, sampleData, updateData, hasForeignKeys = false
4426
4567
  });
4427
4568
 
4428
4569
  it('should list ${table.name}', async () => {
4429
- const list = await sdk.${table.name}.list({ limit: 10 });
4430
- expect(Array.isArray(list)).toBe(true);
4570
+ const result = await sdk.${table.name}.list({ limit: 10 });
4571
+ expect(result).toBeDefined();
4572
+ expect(Array.isArray(result.data)).toBe(true);
4573
+ expect(typeof result.total).toBe('number');
4574
+ expect(typeof result.hasMore).toBe('boolean');
4431
4575
  });
4432
4576
 
4433
4577
  ${hasData && hasSinglePK ? `it('should get ${table.name} by id', async () => {
@@ -4554,6 +4698,7 @@ async function generate(configPath) {
4554
4698
  files.push({ path: join(serverDir, "include-spec.ts"), content: includeSpec });
4555
4699
  files.push({ path: join(clientDir, "include-spec.ts"), content: includeSpec });
4556
4700
  files.push({ path: join(clientDir, "params", "shared.ts"), content: emitSharedParamsZod() });
4701
+ files.push({ path: join(clientDir, "types", "shared.ts"), content: emitSharedTypes() });
4557
4702
  files.push({ path: join(clientDir, "base-client.ts"), content: emitBaseClient() });
4558
4703
  files.push({ path: join(clientDir, "where-types.ts"), content: emitWhereTypes() });
4559
4704
  files.push({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresdk",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
4
4
  "description": "Generate a typed server/client SDK from a Postgres schema (includes, Zod, Hono).",
5
5
  "type": "module",
6
6
  "bin": {