postgresdk 0.12.0 → 0.13.0

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
@@ -7,7 +7,7 @@ Generate a typed server/client SDK from your PostgreSQL database schema.
7
7
  ## Features
8
8
 
9
9
  - 🚀 **Instant SDK Generation** - Point at your PostgreSQL database and get a complete SDK
10
- - 🔒 **Type Safety** - Full TypeScript types derived from your database schema
10
+ - 🔒 **Type Safety** - Full TypeScript types derived from your database schema (including enum types)
11
11
  - ✅ **Runtime Validation** - Zod schemas for request/response validation
12
12
  - 🔗 **Smart Relationships** - Automatic handling of 1:N and M:N relationships with eager loading
13
13
  - 🔐 **Built-in Auth** - API key and JWT authentication
@@ -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: `{ data: (${buildReturnType(baseTableName, newPath, newIsMany, newTargets, graph)})[]; total: number; limit: number; offset: number; hasMore: boolean; }`,
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: `{ data: (Select${pascal(baseTableName)} & { ${type1}; ${type2} })[]; total: number; limit: number; offset: number; hasMore: boolean; }`,
622
622
  includeSpec: { [key1]: true, [key2]: true }
623
623
  });
624
624
  methods.push({
@@ -780,14 +780,18 @@ function generateResourceWithSDK(table, model, graph, config) {
780
780
  const basePath = `/v1/${tableName}`;
781
781
  const hasSinglePK = table.pk.length === 1;
782
782
  const pkField = hasSinglePK ? table.pk[0] : "id";
783
+ const enums = model.enums || {};
783
784
  const sdkMethods = [];
784
785
  const endpoints = [];
785
786
  sdkMethods.push({
786
787
  name: "list",
787
- signature: `list(params?: ListParams): Promise<${Type}[]>`,
788
- description: `List ${tableName} with filtering, sorting, and pagination`,
788
+ signature: `list(params?: ListParams): Promise<{ data: ${Type}[]; total: number; limit: number; offset: number; hasMore: boolean; }>`,
789
+ description: `List ${tableName} with filtering, sorting, and pagination. Returns paginated results with metadata.`,
789
790
  example: `// Get all ${tableName}
790
- 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
791
795
 
792
796
  // With filters and pagination
793
797
  const filtered = await sdk.${tableName}.list({
@@ -796,15 +800,19 @@ const filtered = await sdk.${tableName}.list({
796
800
  where: { ${table.columns[0]?.name || "field"}: { $like: '%search%' } },
797
801
  orderBy: '${table.columns[0]?.name || "created_at"}',
798
802
  order: 'desc'
799
- });`,
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;`,
800
808
  correspondsTo: `GET ${basePath}`
801
809
  });
802
810
  endpoints.push({
803
811
  method: "GET",
804
812
  path: basePath,
805
- description: `List all ${tableName} records`,
806
- queryParameters: generateQueryParams(table),
807
- responseBody: `${Type}[]`
813
+ description: `List all ${tableName} records with pagination metadata`,
814
+ queryParameters: generateQueryParams(table, enums),
815
+ responseBody: `{ data: ${Type}[]; total: number; limit: number; offset: number; hasMore: boolean; }`
808
816
  });
809
817
  if (hasSinglePK) {
810
818
  sdkMethods.push({
@@ -894,7 +902,10 @@ console.log('Deleted:', deleted);`,
894
902
  }, allTables);
895
903
  for (const method of includeMethods) {
896
904
  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}();
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
898
909
 
899
910
  // With filters and pagination
900
911
  const filtered = await sdk.${tableName}.${method.name}({
@@ -911,7 +922,7 @@ const filtered = await sdk.${tableName}.${method.name}({
911
922
  });
912
923
  }
913
924
  }
914
- const fields = table.columns.map((col) => generateFieldContract(col, table));
925
+ const fields = table.columns.map((col) => generateFieldContract(col, table, enums));
915
926
  return {
916
927
  name: Type,
917
928
  tableName,
@@ -926,11 +937,11 @@ const filtered = await sdk.${tableName}.${method.name}({
926
937
  fields
927
938
  };
928
939
  }
929
- function generateFieldContract(column, table) {
940
+ function generateFieldContract(column, table, enums) {
930
941
  const field = {
931
942
  name: column.name,
932
- type: postgresTypeToJsonType(column.pgType),
933
- tsType: postgresTypeToTsType(column),
943
+ type: postgresTypeToJsonType(column.pgType, enums),
944
+ tsType: postgresTypeToTsType(column, enums),
934
945
  required: !column.nullable && !column.hasDefault,
935
946
  description: generateFieldDescription(column, table)
936
947
  };
@@ -943,16 +954,41 @@ function generateFieldContract(column, table) {
943
954
  }
944
955
  return field;
945
956
  }
946
- function postgresTypeToTsType(column) {
957
+ function postgresTypeToTsType(column, enums) {
958
+ const pgType = column.pgType.toLowerCase();
959
+ if (enums[pgType]) {
960
+ const enumType = enums[pgType].map((v) => `"${v}"`).join(" | ");
961
+ if (column.nullable) {
962
+ return `${enumType} | null`;
963
+ }
964
+ return enumType;
965
+ }
966
+ if (pgType.startsWith("_")) {
967
+ const enumName = pgType.slice(1);
968
+ const enumValues = enums[enumName];
969
+ if (enumValues) {
970
+ const enumType = enumValues.map((v) => `"${v}"`).join(" | ");
971
+ const arrayType = `(${enumType})[]`;
972
+ if (column.nullable) {
973
+ return `${arrayType} | null`;
974
+ }
975
+ return arrayType;
976
+ }
977
+ }
947
978
  const baseType = (() => {
948
- switch (column.pgType) {
979
+ switch (pgType) {
949
980
  case "int":
981
+ case "int2":
982
+ case "int4":
983
+ case "int8":
950
984
  case "integer":
951
985
  case "smallint":
952
986
  case "bigint":
953
987
  case "decimal":
954
988
  case "numeric":
955
989
  case "real":
990
+ case "float4":
991
+ case "float8":
956
992
  case "double precision":
957
993
  case "float":
958
994
  return "number";
@@ -970,9 +1006,16 @@ function postgresTypeToTsType(column) {
970
1006
  return "string";
971
1007
  case "text[]":
972
1008
  case "varchar[]":
1009
+ case "_text":
1010
+ case "_varchar":
973
1011
  return "string[]";
974
1012
  case "int[]":
975
1013
  case "integer[]":
1014
+ case "_int":
1015
+ case "_int2":
1016
+ case "_int4":
1017
+ case "_int8":
1018
+ case "_integer":
976
1019
  return "number[]";
977
1020
  default:
978
1021
  return "string";
@@ -1054,7 +1097,7 @@ function generateExampleValue(column) {
1054
1097
  return `'example value'`;
1055
1098
  }
1056
1099
  }
1057
- function generateQueryParams(table) {
1100
+ function generateQueryParams(table, enums) {
1058
1101
  const params = {
1059
1102
  limit: "number - Max records to return (default: 50)",
1060
1103
  offset: "number - Records to skip",
@@ -1065,7 +1108,7 @@ function generateQueryParams(table) {
1065
1108
  for (const col of table.columns) {
1066
1109
  if (filterCount >= 3)
1067
1110
  break;
1068
- const type = postgresTypeToJsonType(col.pgType);
1111
+ const type = postgresTypeToJsonType(col.pgType, enums);
1069
1112
  params[col.name] = `${type} - Filter by ${col.name}`;
1070
1113
  if (type === "string") {
1071
1114
  params[`${col.name}_like`] = `string - Search in ${col.name}`;
@@ -1078,15 +1121,27 @@ function generateQueryParams(table) {
1078
1121
  params["..."] = "Additional filters for all fields";
1079
1122
  return params;
1080
1123
  }
1081
- function postgresTypeToJsonType(pgType) {
1082
- switch (pgType) {
1124
+ function postgresTypeToJsonType(pgType, enums) {
1125
+ const t = pgType.toLowerCase();
1126
+ if (enums[t]) {
1127
+ return t;
1128
+ }
1129
+ if (t.startsWith("_") && enums[t.slice(1)]) {
1130
+ return `${t.slice(1)}[]`;
1131
+ }
1132
+ switch (t) {
1083
1133
  case "int":
1134
+ case "int2":
1135
+ case "int4":
1136
+ case "int8":
1084
1137
  case "integer":
1085
1138
  case "smallint":
1086
1139
  case "bigint":
1087
1140
  case "decimal":
1088
1141
  case "numeric":
1089
1142
  case "real":
1143
+ case "float4":
1144
+ case "float8":
1090
1145
  case "double precision":
1091
1146
  case "float":
1092
1147
  return "number";
@@ -1104,9 +1159,16 @@ function postgresTypeToJsonType(pgType) {
1104
1159
  return "uuid";
1105
1160
  case "text[]":
1106
1161
  case "varchar[]":
1162
+ case "_text":
1163
+ case "_varchar":
1107
1164
  return "string[]";
1108
1165
  case "int[]":
1109
1166
  case "integer[]":
1167
+ case "_int":
1168
+ case "_int2":
1169
+ case "_int4":
1170
+ case "_int8":
1171
+ case "_integer":
1110
1172
  return "number[]";
1111
1173
  default:
1112
1174
  return "string";
@@ -2566,23 +2628,28 @@ export const buildWithFor = (t: TableName) =>
2566
2628
 
2567
2629
  // src/emit-zod.ts
2568
2630
  init_utils();
2569
- function emitZod(table, opts) {
2631
+ function emitZod(table, opts, enums) {
2570
2632
  const Type = pascal(table.name);
2571
2633
  const zFor = (pg) => {
2572
- if (pg === "uuid")
2634
+ const t = pg.toLowerCase();
2635
+ if (enums[t]) {
2636
+ const values = enums[t].map((v) => `"${v}"`).join(", ");
2637
+ return `z.enum([${values}])`;
2638
+ }
2639
+ if (t === "uuid")
2573
2640
  return `z.string()`;
2574
- if (pg === "bool" || pg === "boolean")
2641
+ if (t === "bool" || t === "boolean")
2575
2642
  return `z.boolean()`;
2576
- if (pg === "int2" || pg === "int4" || pg === "int8")
2643
+ if (t === "int2" || t === "int4" || t === "int8")
2577
2644
  return opts.numericMode === "number" ? `z.number()` : `z.string()`;
2578
- if (pg === "numeric" || pg === "float4" || pg === "float8")
2645
+ if (t === "numeric" || t === "float4" || t === "float8")
2579
2646
  return opts.numericMode === "number" ? `z.number()` : `z.string()`;
2580
- if (pg === "jsonb" || pg === "json")
2647
+ if (t === "jsonb" || t === "json")
2581
2648
  return `z.unknown()`;
2582
- if (pg === "date" || pg.startsWith("timestamp"))
2649
+ if (t === "date" || t.startsWith("timestamp"))
2583
2650
  return `z.string()`;
2584
- if (pg.startsWith("_"))
2585
- return `z.array(${zFor(pg.slice(1))})`;
2651
+ if (t.startsWith("_"))
2652
+ return `z.array(${zFor(t.slice(1))})`;
2586
2653
  return `z.string()`;
2587
2654
  };
2588
2655
  const selectFields = table.columns.map((c) => {
@@ -2711,6 +2778,13 @@ const listSchema = z.object({
2711
2778
  order: z.union([z.enum(["asc", "desc"]), z.array(z.enum(["asc", "desc"]))]).optional()
2712
2779
  });
2713
2780
 
2781
+ /**
2782
+ * Register all CRUD routes for the ${fileTableName} table
2783
+ * @param app - Hono application instance
2784
+ * @param deps - Dependencies including database client and optional request hook
2785
+ * @param deps.pg - PostgreSQL client with query method
2786
+ * @param deps.onRequest - Optional hook that runs before each request (for audit logging, RLS, etc.)
2787
+ */
2714
2788
  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> }) {
2715
2789
  const base = "/v1/${fileTableName}";
2716
2790
 
@@ -2790,34 +2864,50 @@ ${hasAuth ? `
2790
2864
  if (result.needsIncludes && result.includeSpec) {
2791
2865
  try {
2792
2866
  const stitched = await loadIncludes(
2793
- "${fileTableName}",
2794
- result.data,
2795
- result.includeSpec,
2796
- deps.pg,
2867
+ "${fileTableName}",
2868
+ result.data,
2869
+ result.includeSpec,
2870
+ deps.pg,
2797
2871
  ${opts.includeMethodsDepth}
2798
2872
  );
2799
- return c.json(stitched);
2873
+ return c.json({
2874
+ data: stitched,
2875
+ total: result.total,
2876
+ limit: result.limit,
2877
+ offset: result.offset,
2878
+ hasMore: result.hasMore
2879
+ });
2800
2880
  } catch (e: any) {
2801
2881
  const strict = process.env.SDK_STRICT_INCLUDE === "1";
2802
2882
  if (strict) {
2803
- return c.json({
2804
- error: "include-stitch-failed",
2883
+ return c.json({
2884
+ error: "include-stitch-failed",
2805
2885
  message: e?.message,
2806
2886
  ...(process.env.SDK_DEBUG === "1" ? { stack: e?.stack } : {})
2807
2887
  }, 500);
2808
2888
  }
2809
2889
  // Non-strict: return base rows with error metadata
2810
- return c.json({
2811
- data: result.data,
2812
- includeError: {
2890
+ return c.json({
2891
+ data: result.data,
2892
+ total: result.total,
2893
+ limit: result.limit,
2894
+ offset: result.offset,
2895
+ hasMore: result.hasMore,
2896
+ includeError: {
2813
2897
  message: e?.message,
2814
2898
  ...(process.env.SDK_DEBUG === "1" ? { stack: e?.stack } : {})
2815
2899
  }
2816
2900
  }, 200);
2817
2901
  }
2818
2902
  }
2819
-
2820
- return c.json(result.data, result.status as any);
2903
+
2904
+ return c.json({
2905
+ data: result.data,
2906
+ total: result.total,
2907
+ limit: result.limit,
2908
+ offset: result.offset,
2909
+ hasMore: result.hasMore
2910
+ }, result.status as any);
2821
2911
  });
2822
2912
 
2823
2913
  // UPDATE
@@ -2898,21 +2988,36 @@ function emitClient(table, graph, opts, model) {
2898
2988
  for (const method of includeMethods) {
2899
2989
  const isGetByPk = method.name.startsWith("getByPk");
2900
2990
  const baseParams = isGetByPk ? "" : `params?: Omit<{ limit?: number; offset?: number; where?: Where<Select${Type}>; orderBy?: string | string[]; order?: "asc" | "desc" | ("asc" | "desc")[]; }, "include">`;
2991
+ const relationshipDesc = method.path.map((p, i) => {
2992
+ const isLast = i === method.path.length - 1;
2993
+ const relation = method.isMany[i] ? "many" : "one";
2994
+ return isLast ? p : `${p} -> `;
2995
+ }).join("");
2901
2996
  if (isGetByPk) {
2902
2997
  const pkWhere = hasCompositePk ? `{ ${safePk.map((col) => `${col}: pk.${col}`).join(", ")} }` : `{ ${safePk[0] || "id"}: pk }`;
2903
2998
  const baseReturnType = method.returnType.replace(" | null", "");
2904
2999
  includeMethodsCode += `
3000
+ /**
3001
+ * Get a ${table.name} record by primary key with included related ${relationshipDesc}
3002
+ * @param pk - The primary key value${hasCompositePk ? "s" : ""}
3003
+ * @returns The record with nested ${method.path.join(" and ")} if found, null otherwise
3004
+ */
2905
3005
  async ${method.name}(pk: ${pkType}): Promise<${method.returnType}> {
2906
- const results = await this.post<${baseReturnType}[]>(\`\${this.resource}/list\`, {
3006
+ const results = await this.post<{ data: ${baseReturnType}[]; total: number; limit: number; offset: number; hasMore: boolean; }>(\`\${this.resource}/list\`, {
2907
3007
  where: ${pkWhere},
2908
3008
  include: ${JSON.stringify(method.includeSpec)},
2909
- limit: 1
3009
+ limit: 1
2910
3010
  });
2911
- return (results[0] as ${baseReturnType}) ?? null;
3011
+ return (results.data[0] as ${baseReturnType}) ?? null;
2912
3012
  }
2913
3013
  `;
2914
3014
  } else {
2915
3015
  includeMethodsCode += `
3016
+ /**
3017
+ * List ${table.name} records with included related ${relationshipDesc}
3018
+ * @param params - Query parameters (where, orderBy, order, limit, offset)
3019
+ * @returns Paginated results with nested ${method.path.join(" and ")} included
3020
+ */
2916
3021
  async ${method.name}(${baseParams}): Promise<${method.returnType}> {
2917
3022
  return this.post<${method.returnType}>(\`\${this.resource}/list\`, { ...params, include: ${JSON.stringify(method.includeSpec)} });
2918
3023
  }
@@ -2939,15 +3044,36 @@ ${otherTableImports.join(`
2939
3044
  export class ${Type}Client extends BaseClient {
2940
3045
  private readonly resource = "/v1/${table.name}";
2941
3046
 
3047
+ /**
3048
+ * Create a new ${table.name} record
3049
+ * @param data - The data to insert
3050
+ * @returns The created record
3051
+ */
2942
3052
  async create(data: Insert${Type}): Promise<Select${Type}> {
2943
3053
  return this.post<Select${Type}>(this.resource, data);
2944
3054
  }
2945
3055
 
3056
+ /**
3057
+ * Get a ${table.name} record by primary key
3058
+ * @param pk - The primary key value${hasCompositePk ? "s" : ""}
3059
+ * @returns The record if found, null otherwise
3060
+ */
2946
3061
  async getByPk(pk: ${pkType}): Promise<Select${Type} | null> {
2947
3062
  const path = ${pkPathExpr};
2948
3063
  return this.get<Select${Type} | null>(\`\${this.resource}/\${path}\`);
2949
3064
  }
2950
3065
 
3066
+ /**
3067
+ * List ${table.name} records with pagination and filtering
3068
+ * @param params - Query parameters
3069
+ * @param params.where - Filter conditions using operators like $eq, $gt, $in, $like, etc.
3070
+ * @param params.orderBy - Column(s) to sort by
3071
+ * @param params.order - Sort direction(s): "asc" or "desc"
3072
+ * @param params.limit - Maximum number of records to return (default: 50, max: 100)
3073
+ * @param params.offset - Number of records to skip for pagination
3074
+ * @param params.include - Related records to include (see listWith* methods for typed includes)
3075
+ * @returns Paginated results with data, total count, and hasMore flag
3076
+ */
2951
3077
  async list(params?: {
2952
3078
  include?: any;
2953
3079
  limit?: number;
@@ -2955,15 +3081,38 @@ export class ${Type}Client extends BaseClient {
2955
3081
  where?: Where<Select${Type}>;
2956
3082
  orderBy?: string | string[];
2957
3083
  order?: "asc" | "desc" | ("asc" | "desc")[];
2958
- }): Promise<Select${Type}[]> {
2959
- return this.post<Select${Type}[]>(\`\${this.resource}/list\`, params ?? {});
3084
+ }): Promise<{
3085
+ data: Select${Type}[];
3086
+ total: number;
3087
+ limit: number;
3088
+ offset: number;
3089
+ hasMore: boolean;
3090
+ }> {
3091
+ return this.post<{
3092
+ data: Select${Type}[];
3093
+ total: number;
3094
+ limit: number;
3095
+ offset: number;
3096
+ hasMore: boolean;
3097
+ }>(\`\${this.resource}/list\`, params ?? {});
2960
3098
  }
2961
3099
 
3100
+ /**
3101
+ * Update a ${table.name} record by primary key
3102
+ * @param pk - The primary key value${hasCompositePk ? "s" : ""}
3103
+ * @param patch - Partial data to update
3104
+ * @returns The updated record if found, null otherwise
3105
+ */
2962
3106
  async update(pk: ${pkType}, patch: Update${Type}): Promise<Select${Type} | null> {
2963
3107
  const path = ${pkPathExpr};
2964
3108
  return this.patch<Select${Type} | null>(\`\${this.resource}/\${path}\`, patch);
2965
3109
  }
2966
3110
 
3111
+ /**
3112
+ * Delete a ${table.name} record by primary key
3113
+ * @param pk - The primary key value${hasCompositePk ? "s" : ""}
3114
+ * @returns The deleted record if found, null otherwise
3115
+ */
2967
3116
  async delete(pk: ${pkType}): Promise<Select${Type} | null> {
2968
3117
  const path = ${pkPathExpr};
2969
3118
  return this.del<Select${Type} | null>(\`\${this.resource}/\${path}\`);
@@ -3510,10 +3659,13 @@ export async function loadIncludes(
3510
3659
  }
3511
3660
 
3512
3661
  // src/emit-types.ts
3513
- function tsTypeFor(pgType, opts) {
3662
+ function tsTypeFor(pgType, opts, enums) {
3514
3663
  const t = pgType.toLowerCase();
3664
+ if (enums[t]) {
3665
+ return enums[t].map((v) => `"${v}"`).join(" | ");
3666
+ }
3515
3667
  if (t.startsWith("_"))
3516
- return `${tsTypeFor(t.slice(1), opts)}[]`;
3668
+ return `(${tsTypeFor(t.slice(1), opts, enums)})[]`;
3517
3669
  if (t === "uuid")
3518
3670
  return "string";
3519
3671
  if (t === "bool" || t === "boolean")
@@ -3528,17 +3680,17 @@ function tsTypeFor(pgType, opts) {
3528
3680
  return "string";
3529
3681
  }
3530
3682
  var pascal2 = (s) => s.split(/[_\s-]+/).map((w) => w?.[0] ? w[0].toUpperCase() + w.slice(1) : "").join("");
3531
- function emitTypes(table, opts) {
3683
+ function emitTypes(table, opts, enums) {
3532
3684
  const Type = pascal2(table.name);
3533
3685
  const insertFields = table.columns.map((col) => {
3534
- const base = tsTypeFor(col.pgType, opts);
3686
+ const base = tsTypeFor(col.pgType, opts, enums);
3535
3687
  const optional = col.hasDefault || col.nullable ? "?" : "";
3536
3688
  const valueType = col.nullable ? `${base} | null` : base;
3537
3689
  return ` ${col.name}${optional}: ${valueType};`;
3538
3690
  }).join(`
3539
3691
  `);
3540
3692
  const selectFields = table.columns.map((col) => {
3541
- const base = tsTypeFor(col.pgType, opts);
3693
+ const base = tsTypeFor(col.pgType, opts, enums);
3542
3694
  const valueType = col.nullable ? `${base} | null` : base;
3543
3695
  return ` ${col.name}: ${valueType};`;
3544
3696
  }).join(`
@@ -3551,12 +3703,25 @@ function emitTypes(table, opts) {
3551
3703
  *
3552
3704
  * To make changes, modify your schema or configuration and regenerate.
3553
3705
  */
3706
+
3707
+ /**
3708
+ * Type for inserting a new ${table.name} record.
3709
+ * Fields with defaults or nullable columns are optional.
3710
+ */
3554
3711
  export type Insert${Type} = {
3555
3712
  ${insertFields}
3556
3713
  };
3557
3714
 
3715
+ /**
3716
+ * Type for updating an existing ${table.name} record.
3717
+ * All fields are optional, allowing partial updates.
3718
+ */
3558
3719
  export type Update${Type} = Partial<Insert${Type}>;
3559
3720
 
3721
+ /**
3722
+ * Type representing a ${table.name} record from the database.
3723
+ * All fields are included as returned by SELECT queries.
3724
+ */
3560
3725
  export type Select${Type} = {
3561
3726
  ${selectFields}
3562
3727
  };
@@ -4289,7 +4454,7 @@ function buildWhereClause(
4289
4454
  export async function listRecords(
4290
4455
  ctx: OperationContext,
4291
4456
  params: { where?: any; limit?: number; offset?: number; include?: any; orderBy?: string | string[]; order?: "asc" | "desc" | ("asc" | "desc")[] }
4292
- ): Promise<{ data?: any; error?: string; issues?: any; needsIncludes?: boolean; includeSpec?: any; status: number }> {
4457
+ ): Promise<{ data?: any; total?: number; limit?: number; offset?: number; hasMore?: boolean; error?: string; issues?: any; needsIncludes?: boolean; includeSpec?: any; status: number }> {
4293
4458
  try {
4294
4459
  const { where: whereClause, limit = 50, offset = 0, include, orderBy, order } = params;
4295
4460
 
@@ -4334,20 +4499,34 @@ export async function listRecords(
4334
4499
  const offsetParam = \`$\${paramIndex + 1}\`;
4335
4500
  const allParams = [...whereParams, limit, offset];
4336
4501
 
4502
+ // Get total count for pagination
4503
+ const countText = \`SELECT COUNT(*) FROM "\${ctx.table}" \${whereSQL}\`;
4504
+ log.debug(\`LIST \${ctx.table} COUNT SQL:\`, countText, "params:", whereParams);
4505
+ const countResult = await ctx.pg.query(countText, whereParams);
4506
+ const total = parseInt(countResult.rows[0].count, 10);
4507
+
4508
+ // Get paginated data
4337
4509
  const text = \`SELECT * FROM "\${ctx.table}" \${whereSQL} \${orderBySQL} LIMIT \${limitParam} OFFSET \${offsetParam}\`;
4338
4510
  log.debug(\`LIST \${ctx.table} SQL:\`, text, "params:", allParams);
4339
4511
 
4340
4512
  const { rows } = await ctx.pg.query(text, allParams);
4341
4513
 
4342
- if (!include) {
4343
- log.debug(\`LIST \${ctx.table} rows:\`, rows.length);
4344
- return { data: rows, status: 200 };
4345
- }
4514
+ // Calculate hasMore
4515
+ const hasMore = offset + limit < total;
4516
+
4517
+ const metadata = {
4518
+ data: rows,
4519
+ total,
4520
+ limit,
4521
+ offset,
4522
+ hasMore,
4523
+ needsIncludes: !!include,
4524
+ includeSpec: include,
4525
+ status: 200
4526
+ };
4346
4527
 
4347
- // Include logic will be handled by the include-loader
4348
- // For now, just return the rows with a note that includes need to be applied
4349
- log.debug(\`LIST \${ctx.table} include spec:\`, include);
4350
- return { data: rows, needsIncludes: true, includeSpec: include, status: 200 };
4528
+ log.debug(\`LIST \${ctx.table} result: \${rows.length} rows, \${total} total, hasMore=\${hasMore}\`);
4529
+ return metadata;
4351
4530
  } catch (e: any) {
4352
4531
  log.error(\`LIST \${ctx.table} error:\`, e?.stack ?? e);
4353
4532
  return {
@@ -4489,8 +4668,11 @@ describe('${Type} SDK Operations', () => {
4489
4668
  });
4490
4669
 
4491
4670
  it('should list ${tableName} relationships', async () => {
4492
- const list = await sdk.${tableName}.list({ limit: 10 });
4493
- expect(Array.isArray(list)).toBe(true);
4671
+ const result = await sdk.${tableName}.list({ limit: 10 });
4672
+ expect(result).toBeDefined();
4673
+ expect(Array.isArray(result.data)).toBe(true);
4674
+ expect(typeof result.total).toBe('number');
4675
+ expect(typeof result.hasMore).toBe('boolean');
4494
4676
  });
4495
4677
  });
4496
4678
  `;
@@ -5126,8 +5308,11 @@ function generateTestCases(table, sampleData, updateData, hasForeignKeys = false
5126
5308
  });
5127
5309
 
5128
5310
  it('should list ${table.name}', async () => {
5129
- const list = await sdk.${table.name}.list({ limit: 10 });
5130
- expect(Array.isArray(list)).toBe(true);
5311
+ const result = await sdk.${table.name}.list({ limit: 10 });
5312
+ expect(result).toBeDefined();
5313
+ expect(Array.isArray(result.data)).toBe(true);
5314
+ expect(typeof result.total).toBe('number');
5315
+ expect(typeof result.hasMore).toBe('boolean');
5131
5316
  });
5132
5317
 
5133
5318
  ${hasData && hasSinglePK ? `it('should get ${table.name} by id', async () => {
@@ -5276,10 +5461,10 @@ async function generate(configPath) {
5276
5461
  console.log(`[Index] About to process ${Object.keys(model.tables || {}).length} tables for generation`);
5277
5462
  }
5278
5463
  for (const table of Object.values(model.tables)) {
5279
- const typesSrc = emitTypes(table, { numericMode: "string" });
5464
+ const typesSrc = emitTypes(table, { numericMode: "string" }, model.enums);
5280
5465
  files.push({ path: join(serverDir, "types", `${table.name}.ts`), content: typesSrc });
5281
5466
  files.push({ path: join(clientDir, "types", `${table.name}.ts`), content: typesSrc });
5282
- const zodSrc = emitZod(table, { numericMode: "string" });
5467
+ const zodSrc = emitZod(table, { numericMode: "string" }, model.enums);
5283
5468
  files.push({ path: join(serverDir, "zod", `${table.name}.ts`), content: zodSrc });
5284
5469
  files.push({ path: join(clientDir, "zod", `${table.name}.ts`), content: zodSrc });
5285
5470
  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
3
  numericMode: "string" | "number";
4
- }): string;
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
3
  numericMode: "string" | "number";
4
- }): string;
4
+ }, enums: Record<string, string[]>): 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: `{ data: (${buildReturnType(baseTableName, newPath, newIsMany, newTargets, graph)})[]; total: number; limit: number; offset: number; hasMore: boolean; }`,
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: `{ data: (Select${pascal(baseTableName)} & { ${type1}; ${type2} })[]; total: number; limit: number; offset: number; hasMore: boolean; }`,
621
621
  includeSpec: { [key1]: true, [key2]: true }
622
622
  });
623
623
  methods.push({
@@ -779,14 +779,18 @@ function generateResourceWithSDK(table, model, graph, config) {
779
779
  const basePath = `/v1/${tableName}`;
780
780
  const hasSinglePK = table.pk.length === 1;
781
781
  const pkField = hasSinglePK ? table.pk[0] : "id";
782
+ const enums = model.enums || {};
782
783
  const sdkMethods = [];
783
784
  const endpoints = [];
784
785
  sdkMethods.push({
785
786
  name: "list",
786
- signature: `list(params?: ListParams): Promise<${Type}[]>`,
787
- description: `List ${tableName} with filtering, sorting, and pagination`,
787
+ signature: `list(params?: ListParams): Promise<{ data: ${Type}[]; total: number; limit: number; offset: number; hasMore: boolean; }>`,
788
+ description: `List ${tableName} with filtering, sorting, and pagination. Returns paginated results with metadata.`,
788
789
  example: `// Get all ${tableName}
789
- 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
790
794
 
791
795
  // With filters and pagination
792
796
  const filtered = await sdk.${tableName}.list({
@@ -795,15 +799,19 @@ const filtered = await sdk.${tableName}.list({
795
799
  where: { ${table.columns[0]?.name || "field"}: { $like: '%search%' } },
796
800
  orderBy: '${table.columns[0]?.name || "created_at"}',
797
801
  order: 'desc'
798
- });`,
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;`,
799
807
  correspondsTo: `GET ${basePath}`
800
808
  });
801
809
  endpoints.push({
802
810
  method: "GET",
803
811
  path: basePath,
804
- description: `List all ${tableName} records`,
805
- queryParameters: generateQueryParams(table),
806
- responseBody: `${Type}[]`
812
+ description: `List all ${tableName} records with pagination metadata`,
813
+ queryParameters: generateQueryParams(table, enums),
814
+ responseBody: `{ data: ${Type}[]; total: number; limit: number; offset: number; hasMore: boolean; }`
807
815
  });
808
816
  if (hasSinglePK) {
809
817
  sdkMethods.push({
@@ -893,7 +901,10 @@ console.log('Deleted:', deleted);`,
893
901
  }, allTables);
894
902
  for (const method of includeMethods) {
895
903
  const isGetByPk = method.name.startsWith("getByPk");
896
- 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
897
908
 
898
909
  // With filters and pagination
899
910
  const filtered = await sdk.${tableName}.${method.name}({
@@ -910,7 +921,7 @@ const filtered = await sdk.${tableName}.${method.name}({
910
921
  });
911
922
  }
912
923
  }
913
- const fields = table.columns.map((col) => generateFieldContract(col, table));
924
+ const fields = table.columns.map((col) => generateFieldContract(col, table, enums));
914
925
  return {
915
926
  name: Type,
916
927
  tableName,
@@ -925,11 +936,11 @@ const filtered = await sdk.${tableName}.${method.name}({
925
936
  fields
926
937
  };
927
938
  }
928
- function generateFieldContract(column, table) {
939
+ function generateFieldContract(column, table, enums) {
929
940
  const field = {
930
941
  name: column.name,
931
- type: postgresTypeToJsonType(column.pgType),
932
- tsType: postgresTypeToTsType(column),
942
+ type: postgresTypeToJsonType(column.pgType, enums),
943
+ tsType: postgresTypeToTsType(column, enums),
933
944
  required: !column.nullable && !column.hasDefault,
934
945
  description: generateFieldDescription(column, table)
935
946
  };
@@ -942,16 +953,41 @@ function generateFieldContract(column, table) {
942
953
  }
943
954
  return field;
944
955
  }
945
- function postgresTypeToTsType(column) {
956
+ function postgresTypeToTsType(column, enums) {
957
+ const pgType = column.pgType.toLowerCase();
958
+ if (enums[pgType]) {
959
+ const enumType = enums[pgType].map((v) => `"${v}"`).join(" | ");
960
+ if (column.nullable) {
961
+ return `${enumType} | null`;
962
+ }
963
+ return enumType;
964
+ }
965
+ if (pgType.startsWith("_")) {
966
+ const enumName = pgType.slice(1);
967
+ const enumValues = enums[enumName];
968
+ if (enumValues) {
969
+ const enumType = enumValues.map((v) => `"${v}"`).join(" | ");
970
+ const arrayType = `(${enumType})[]`;
971
+ if (column.nullable) {
972
+ return `${arrayType} | null`;
973
+ }
974
+ return arrayType;
975
+ }
976
+ }
946
977
  const baseType = (() => {
947
- switch (column.pgType) {
978
+ switch (pgType) {
948
979
  case "int":
980
+ case "int2":
981
+ case "int4":
982
+ case "int8":
949
983
  case "integer":
950
984
  case "smallint":
951
985
  case "bigint":
952
986
  case "decimal":
953
987
  case "numeric":
954
988
  case "real":
989
+ case "float4":
990
+ case "float8":
955
991
  case "double precision":
956
992
  case "float":
957
993
  return "number";
@@ -969,9 +1005,16 @@ function postgresTypeToTsType(column) {
969
1005
  return "string";
970
1006
  case "text[]":
971
1007
  case "varchar[]":
1008
+ case "_text":
1009
+ case "_varchar":
972
1010
  return "string[]";
973
1011
  case "int[]":
974
1012
  case "integer[]":
1013
+ case "_int":
1014
+ case "_int2":
1015
+ case "_int4":
1016
+ case "_int8":
1017
+ case "_integer":
975
1018
  return "number[]";
976
1019
  default:
977
1020
  return "string";
@@ -1053,7 +1096,7 @@ function generateExampleValue(column) {
1053
1096
  return `'example value'`;
1054
1097
  }
1055
1098
  }
1056
- function generateQueryParams(table) {
1099
+ function generateQueryParams(table, enums) {
1057
1100
  const params = {
1058
1101
  limit: "number - Max records to return (default: 50)",
1059
1102
  offset: "number - Records to skip",
@@ -1064,7 +1107,7 @@ function generateQueryParams(table) {
1064
1107
  for (const col of table.columns) {
1065
1108
  if (filterCount >= 3)
1066
1109
  break;
1067
- const type = postgresTypeToJsonType(col.pgType);
1110
+ const type = postgresTypeToJsonType(col.pgType, enums);
1068
1111
  params[col.name] = `${type} - Filter by ${col.name}`;
1069
1112
  if (type === "string") {
1070
1113
  params[`${col.name}_like`] = `string - Search in ${col.name}`;
@@ -1077,15 +1120,27 @@ function generateQueryParams(table) {
1077
1120
  params["..."] = "Additional filters for all fields";
1078
1121
  return params;
1079
1122
  }
1080
- function postgresTypeToJsonType(pgType) {
1081
- switch (pgType) {
1123
+ function postgresTypeToJsonType(pgType, enums) {
1124
+ const t = pgType.toLowerCase();
1125
+ if (enums[t]) {
1126
+ return t;
1127
+ }
1128
+ if (t.startsWith("_") && enums[t.slice(1)]) {
1129
+ return `${t.slice(1)}[]`;
1130
+ }
1131
+ switch (t) {
1082
1132
  case "int":
1133
+ case "int2":
1134
+ case "int4":
1135
+ case "int8":
1083
1136
  case "integer":
1084
1137
  case "smallint":
1085
1138
  case "bigint":
1086
1139
  case "decimal":
1087
1140
  case "numeric":
1088
1141
  case "real":
1142
+ case "float4":
1143
+ case "float8":
1089
1144
  case "double precision":
1090
1145
  case "float":
1091
1146
  return "number";
@@ -1103,9 +1158,16 @@ function postgresTypeToJsonType(pgType) {
1103
1158
  return "uuid";
1104
1159
  case "text[]":
1105
1160
  case "varchar[]":
1161
+ case "_text":
1162
+ case "_varchar":
1106
1163
  return "string[]";
1107
1164
  case "int[]":
1108
1165
  case "integer[]":
1166
+ case "_int":
1167
+ case "_int2":
1168
+ case "_int4":
1169
+ case "_int8":
1170
+ case "_integer":
1109
1171
  return "number[]";
1110
1172
  default:
1111
1173
  return "string";
@@ -1806,23 +1868,28 @@ export const buildWithFor = (t: TableName) =>
1806
1868
 
1807
1869
  // src/emit-zod.ts
1808
1870
  init_utils();
1809
- function emitZod(table, opts) {
1871
+ function emitZod(table, opts, enums) {
1810
1872
  const Type = pascal(table.name);
1811
1873
  const zFor = (pg) => {
1812
- if (pg === "uuid")
1874
+ const t = pg.toLowerCase();
1875
+ if (enums[t]) {
1876
+ const values = enums[t].map((v) => `"${v}"`).join(", ");
1877
+ return `z.enum([${values}])`;
1878
+ }
1879
+ if (t === "uuid")
1813
1880
  return `z.string()`;
1814
- if (pg === "bool" || pg === "boolean")
1881
+ if (t === "bool" || t === "boolean")
1815
1882
  return `z.boolean()`;
1816
- if (pg === "int2" || pg === "int4" || pg === "int8")
1883
+ if (t === "int2" || t === "int4" || t === "int8")
1817
1884
  return opts.numericMode === "number" ? `z.number()` : `z.string()`;
1818
- if (pg === "numeric" || pg === "float4" || pg === "float8")
1885
+ if (t === "numeric" || t === "float4" || t === "float8")
1819
1886
  return opts.numericMode === "number" ? `z.number()` : `z.string()`;
1820
- if (pg === "jsonb" || pg === "json")
1887
+ if (t === "jsonb" || t === "json")
1821
1888
  return `z.unknown()`;
1822
- if (pg === "date" || pg.startsWith("timestamp"))
1889
+ if (t === "date" || t.startsWith("timestamp"))
1823
1890
  return `z.string()`;
1824
- if (pg.startsWith("_"))
1825
- return `z.array(${zFor(pg.slice(1))})`;
1891
+ if (t.startsWith("_"))
1892
+ return `z.array(${zFor(t.slice(1))})`;
1826
1893
  return `z.string()`;
1827
1894
  };
1828
1895
  const selectFields = table.columns.map((c) => {
@@ -1951,6 +2018,13 @@ const listSchema = z.object({
1951
2018
  order: z.union([z.enum(["asc", "desc"]), z.array(z.enum(["asc", "desc"]))]).optional()
1952
2019
  });
1953
2020
 
2021
+ /**
2022
+ * Register all CRUD routes for the ${fileTableName} table
2023
+ * @param app - Hono application instance
2024
+ * @param deps - Dependencies including database client and optional request hook
2025
+ * @param deps.pg - PostgreSQL client with query method
2026
+ * @param deps.onRequest - Optional hook that runs before each request (for audit logging, RLS, etc.)
2027
+ */
1954
2028
  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> }) {
1955
2029
  const base = "/v1/${fileTableName}";
1956
2030
 
@@ -2030,34 +2104,50 @@ ${hasAuth ? `
2030
2104
  if (result.needsIncludes && result.includeSpec) {
2031
2105
  try {
2032
2106
  const stitched = await loadIncludes(
2033
- "${fileTableName}",
2034
- result.data,
2035
- result.includeSpec,
2036
- deps.pg,
2107
+ "${fileTableName}",
2108
+ result.data,
2109
+ result.includeSpec,
2110
+ deps.pg,
2037
2111
  ${opts.includeMethodsDepth}
2038
2112
  );
2039
- return c.json(stitched);
2113
+ return c.json({
2114
+ data: stitched,
2115
+ total: result.total,
2116
+ limit: result.limit,
2117
+ offset: result.offset,
2118
+ hasMore: result.hasMore
2119
+ });
2040
2120
  } catch (e: any) {
2041
2121
  const strict = process.env.SDK_STRICT_INCLUDE === "1";
2042
2122
  if (strict) {
2043
- return c.json({
2044
- error: "include-stitch-failed",
2123
+ return c.json({
2124
+ error: "include-stitch-failed",
2045
2125
  message: e?.message,
2046
2126
  ...(process.env.SDK_DEBUG === "1" ? { stack: e?.stack } : {})
2047
2127
  }, 500);
2048
2128
  }
2049
2129
  // Non-strict: return base rows with error metadata
2050
- return c.json({
2051
- data: result.data,
2052
- includeError: {
2130
+ return c.json({
2131
+ data: result.data,
2132
+ total: result.total,
2133
+ limit: result.limit,
2134
+ offset: result.offset,
2135
+ hasMore: result.hasMore,
2136
+ includeError: {
2053
2137
  message: e?.message,
2054
2138
  ...(process.env.SDK_DEBUG === "1" ? { stack: e?.stack } : {})
2055
2139
  }
2056
2140
  }, 200);
2057
2141
  }
2058
2142
  }
2059
-
2060
- return c.json(result.data, result.status as any);
2143
+
2144
+ return c.json({
2145
+ data: result.data,
2146
+ total: result.total,
2147
+ limit: result.limit,
2148
+ offset: result.offset,
2149
+ hasMore: result.hasMore
2150
+ }, result.status as any);
2061
2151
  });
2062
2152
 
2063
2153
  // UPDATE
@@ -2138,21 +2228,36 @@ function emitClient(table, graph, opts, model) {
2138
2228
  for (const method of includeMethods) {
2139
2229
  const isGetByPk = method.name.startsWith("getByPk");
2140
2230
  const baseParams = isGetByPk ? "" : `params?: Omit<{ limit?: number; offset?: number; where?: Where<Select${Type}>; orderBy?: string | string[]; order?: "asc" | "desc" | ("asc" | "desc")[]; }, "include">`;
2231
+ const relationshipDesc = method.path.map((p, i) => {
2232
+ const isLast = i === method.path.length - 1;
2233
+ const relation = method.isMany[i] ? "many" : "one";
2234
+ return isLast ? p : `${p} -> `;
2235
+ }).join("");
2141
2236
  if (isGetByPk) {
2142
2237
  const pkWhere = hasCompositePk ? `{ ${safePk.map((col) => `${col}: pk.${col}`).join(", ")} }` : `{ ${safePk[0] || "id"}: pk }`;
2143
2238
  const baseReturnType = method.returnType.replace(" | null", "");
2144
2239
  includeMethodsCode += `
2240
+ /**
2241
+ * Get a ${table.name} record by primary key with included related ${relationshipDesc}
2242
+ * @param pk - The primary key value${hasCompositePk ? "s" : ""}
2243
+ * @returns The record with nested ${method.path.join(" and ")} if found, null otherwise
2244
+ */
2145
2245
  async ${method.name}(pk: ${pkType}): Promise<${method.returnType}> {
2146
- const results = await this.post<${baseReturnType}[]>(\`\${this.resource}/list\`, {
2246
+ const results = await this.post<{ data: ${baseReturnType}[]; total: number; limit: number; offset: number; hasMore: boolean; }>(\`\${this.resource}/list\`, {
2147
2247
  where: ${pkWhere},
2148
2248
  include: ${JSON.stringify(method.includeSpec)},
2149
- limit: 1
2249
+ limit: 1
2150
2250
  });
2151
- return (results[0] as ${baseReturnType}) ?? null;
2251
+ return (results.data[0] as ${baseReturnType}) ?? null;
2152
2252
  }
2153
2253
  `;
2154
2254
  } else {
2155
2255
  includeMethodsCode += `
2256
+ /**
2257
+ * List ${table.name} records with included related ${relationshipDesc}
2258
+ * @param params - Query parameters (where, orderBy, order, limit, offset)
2259
+ * @returns Paginated results with nested ${method.path.join(" and ")} included
2260
+ */
2156
2261
  async ${method.name}(${baseParams}): Promise<${method.returnType}> {
2157
2262
  return this.post<${method.returnType}>(\`\${this.resource}/list\`, { ...params, include: ${JSON.stringify(method.includeSpec)} });
2158
2263
  }
@@ -2179,15 +2284,36 @@ ${otherTableImports.join(`
2179
2284
  export class ${Type}Client extends BaseClient {
2180
2285
  private readonly resource = "/v1/${table.name}";
2181
2286
 
2287
+ /**
2288
+ * Create a new ${table.name} record
2289
+ * @param data - The data to insert
2290
+ * @returns The created record
2291
+ */
2182
2292
  async create(data: Insert${Type}): Promise<Select${Type}> {
2183
2293
  return this.post<Select${Type}>(this.resource, data);
2184
2294
  }
2185
2295
 
2296
+ /**
2297
+ * Get a ${table.name} record by primary key
2298
+ * @param pk - The primary key value${hasCompositePk ? "s" : ""}
2299
+ * @returns The record if found, null otherwise
2300
+ */
2186
2301
  async getByPk(pk: ${pkType}): Promise<Select${Type} | null> {
2187
2302
  const path = ${pkPathExpr};
2188
2303
  return this.get<Select${Type} | null>(\`\${this.resource}/\${path}\`);
2189
2304
  }
2190
2305
 
2306
+ /**
2307
+ * List ${table.name} records with pagination and filtering
2308
+ * @param params - Query parameters
2309
+ * @param params.where - Filter conditions using operators like $eq, $gt, $in, $like, etc.
2310
+ * @param params.orderBy - Column(s) to sort by
2311
+ * @param params.order - Sort direction(s): "asc" or "desc"
2312
+ * @param params.limit - Maximum number of records to return (default: 50, max: 100)
2313
+ * @param params.offset - Number of records to skip for pagination
2314
+ * @param params.include - Related records to include (see listWith* methods for typed includes)
2315
+ * @returns Paginated results with data, total count, and hasMore flag
2316
+ */
2191
2317
  async list(params?: {
2192
2318
  include?: any;
2193
2319
  limit?: number;
@@ -2195,15 +2321,38 @@ export class ${Type}Client extends BaseClient {
2195
2321
  where?: Where<Select${Type}>;
2196
2322
  orderBy?: string | string[];
2197
2323
  order?: "asc" | "desc" | ("asc" | "desc")[];
2198
- }): Promise<Select${Type}[]> {
2199
- return this.post<Select${Type}[]>(\`\${this.resource}/list\`, params ?? {});
2324
+ }): Promise<{
2325
+ data: Select${Type}[];
2326
+ total: number;
2327
+ limit: number;
2328
+ offset: number;
2329
+ hasMore: boolean;
2330
+ }> {
2331
+ return this.post<{
2332
+ data: Select${Type}[];
2333
+ total: number;
2334
+ limit: number;
2335
+ offset: number;
2336
+ hasMore: boolean;
2337
+ }>(\`\${this.resource}/list\`, params ?? {});
2200
2338
  }
2201
2339
 
2340
+ /**
2341
+ * Update a ${table.name} record by primary key
2342
+ * @param pk - The primary key value${hasCompositePk ? "s" : ""}
2343
+ * @param patch - Partial data to update
2344
+ * @returns The updated record if found, null otherwise
2345
+ */
2202
2346
  async update(pk: ${pkType}, patch: Update${Type}): Promise<Select${Type} | null> {
2203
2347
  const path = ${pkPathExpr};
2204
2348
  return this.patch<Select${Type} | null>(\`\${this.resource}/\${path}\`, patch);
2205
2349
  }
2206
2350
 
2351
+ /**
2352
+ * Delete a ${table.name} record by primary key
2353
+ * @param pk - The primary key value${hasCompositePk ? "s" : ""}
2354
+ * @returns The deleted record if found, null otherwise
2355
+ */
2207
2356
  async delete(pk: ${pkType}): Promise<Select${Type} | null> {
2208
2357
  const path = ${pkPathExpr};
2209
2358
  return this.del<Select${Type} | null>(\`\${this.resource}/\${path}\`);
@@ -2750,10 +2899,13 @@ export async function loadIncludes(
2750
2899
  }
2751
2900
 
2752
2901
  // src/emit-types.ts
2753
- function tsTypeFor(pgType, opts) {
2902
+ function tsTypeFor(pgType, opts, enums) {
2754
2903
  const t = pgType.toLowerCase();
2904
+ if (enums[t]) {
2905
+ return enums[t].map((v) => `"${v}"`).join(" | ");
2906
+ }
2755
2907
  if (t.startsWith("_"))
2756
- return `${tsTypeFor(t.slice(1), opts)}[]`;
2908
+ return `(${tsTypeFor(t.slice(1), opts, enums)})[]`;
2757
2909
  if (t === "uuid")
2758
2910
  return "string";
2759
2911
  if (t === "bool" || t === "boolean")
@@ -2768,17 +2920,17 @@ function tsTypeFor(pgType, opts) {
2768
2920
  return "string";
2769
2921
  }
2770
2922
  var pascal2 = (s) => s.split(/[_\s-]+/).map((w) => w?.[0] ? w[0].toUpperCase() + w.slice(1) : "").join("");
2771
- function emitTypes(table, opts) {
2923
+ function emitTypes(table, opts, enums) {
2772
2924
  const Type = pascal2(table.name);
2773
2925
  const insertFields = table.columns.map((col) => {
2774
- const base = tsTypeFor(col.pgType, opts);
2926
+ const base = tsTypeFor(col.pgType, opts, enums);
2775
2927
  const optional = col.hasDefault || col.nullable ? "?" : "";
2776
2928
  const valueType = col.nullable ? `${base} | null` : base;
2777
2929
  return ` ${col.name}${optional}: ${valueType};`;
2778
2930
  }).join(`
2779
2931
  `);
2780
2932
  const selectFields = table.columns.map((col) => {
2781
- const base = tsTypeFor(col.pgType, opts);
2933
+ const base = tsTypeFor(col.pgType, opts, enums);
2782
2934
  const valueType = col.nullable ? `${base} | null` : base;
2783
2935
  return ` ${col.name}: ${valueType};`;
2784
2936
  }).join(`
@@ -2791,12 +2943,25 @@ function emitTypes(table, opts) {
2791
2943
  *
2792
2944
  * To make changes, modify your schema or configuration and regenerate.
2793
2945
  */
2946
+
2947
+ /**
2948
+ * Type for inserting a new ${table.name} record.
2949
+ * Fields with defaults or nullable columns are optional.
2950
+ */
2794
2951
  export type Insert${Type} = {
2795
2952
  ${insertFields}
2796
2953
  };
2797
2954
 
2955
+ /**
2956
+ * Type for updating an existing ${table.name} record.
2957
+ * All fields are optional, allowing partial updates.
2958
+ */
2798
2959
  export type Update${Type} = Partial<Insert${Type}>;
2799
2960
 
2961
+ /**
2962
+ * Type representing a ${table.name} record from the database.
2963
+ * All fields are included as returned by SELECT queries.
2964
+ */
2800
2965
  export type Select${Type} = {
2801
2966
  ${selectFields}
2802
2967
  };
@@ -3529,7 +3694,7 @@ function buildWhereClause(
3529
3694
  export async function listRecords(
3530
3695
  ctx: OperationContext,
3531
3696
  params: { where?: any; limit?: number; offset?: number; include?: any; orderBy?: string | string[]; order?: "asc" | "desc" | ("asc" | "desc")[] }
3532
- ): Promise<{ data?: any; error?: string; issues?: any; needsIncludes?: boolean; includeSpec?: any; status: number }> {
3697
+ ): Promise<{ data?: any; total?: number; limit?: number; offset?: number; hasMore?: boolean; error?: string; issues?: any; needsIncludes?: boolean; includeSpec?: any; status: number }> {
3533
3698
  try {
3534
3699
  const { where: whereClause, limit = 50, offset = 0, include, orderBy, order } = params;
3535
3700
 
@@ -3574,20 +3739,34 @@ export async function listRecords(
3574
3739
  const offsetParam = \`$\${paramIndex + 1}\`;
3575
3740
  const allParams = [...whereParams, limit, offset];
3576
3741
 
3742
+ // Get total count for pagination
3743
+ const countText = \`SELECT COUNT(*) FROM "\${ctx.table}" \${whereSQL}\`;
3744
+ log.debug(\`LIST \${ctx.table} COUNT SQL:\`, countText, "params:", whereParams);
3745
+ const countResult = await ctx.pg.query(countText, whereParams);
3746
+ const total = parseInt(countResult.rows[0].count, 10);
3747
+
3748
+ // Get paginated data
3577
3749
  const text = \`SELECT * FROM "\${ctx.table}" \${whereSQL} \${orderBySQL} LIMIT \${limitParam} OFFSET \${offsetParam}\`;
3578
3750
  log.debug(\`LIST \${ctx.table} SQL:\`, text, "params:", allParams);
3579
3751
 
3580
3752
  const { rows } = await ctx.pg.query(text, allParams);
3581
3753
 
3582
- if (!include) {
3583
- log.debug(\`LIST \${ctx.table} rows:\`, rows.length);
3584
- return { data: rows, status: 200 };
3585
- }
3754
+ // Calculate hasMore
3755
+ const hasMore = offset + limit < total;
3756
+
3757
+ const metadata = {
3758
+ data: rows,
3759
+ total,
3760
+ limit,
3761
+ offset,
3762
+ hasMore,
3763
+ needsIncludes: !!include,
3764
+ includeSpec: include,
3765
+ status: 200
3766
+ };
3586
3767
 
3587
- // Include logic will be handled by the include-loader
3588
- // For now, just return the rows with a note that includes need to be applied
3589
- log.debug(\`LIST \${ctx.table} include spec:\`, include);
3590
- return { data: rows, needsIncludes: true, includeSpec: include, status: 200 };
3768
+ log.debug(\`LIST \${ctx.table} result: \${rows.length} rows, \${total} total, hasMore=\${hasMore}\`);
3769
+ return metadata;
3591
3770
  } catch (e: any) {
3592
3771
  log.error(\`LIST \${ctx.table} error:\`, e?.stack ?? e);
3593
3772
  return {
@@ -3729,8 +3908,11 @@ describe('${Type} SDK Operations', () => {
3729
3908
  });
3730
3909
 
3731
3910
  it('should list ${tableName} relationships', async () => {
3732
- const list = await sdk.${tableName}.list({ limit: 10 });
3733
- expect(Array.isArray(list)).toBe(true);
3911
+ const result = await sdk.${tableName}.list({ limit: 10 });
3912
+ expect(result).toBeDefined();
3913
+ expect(Array.isArray(result.data)).toBe(true);
3914
+ expect(typeof result.total).toBe('number');
3915
+ expect(typeof result.hasMore).toBe('boolean');
3734
3916
  });
3735
3917
  });
3736
3918
  `;
@@ -4366,8 +4548,11 @@ function generateTestCases(table, sampleData, updateData, hasForeignKeys = false
4366
4548
  });
4367
4549
 
4368
4550
  it('should list ${table.name}', async () => {
4369
- const list = await sdk.${table.name}.list({ limit: 10 });
4370
- expect(Array.isArray(list)).toBe(true);
4551
+ const result = await sdk.${table.name}.list({ limit: 10 });
4552
+ expect(result).toBeDefined();
4553
+ expect(Array.isArray(result.data)).toBe(true);
4554
+ expect(typeof result.total).toBe('number');
4555
+ expect(typeof result.hasMore).toBe('boolean');
4371
4556
  });
4372
4557
 
4373
4558
  ${hasData && hasSinglePK ? `it('should get ${table.name} by id', async () => {
@@ -4516,10 +4701,10 @@ async function generate(configPath) {
4516
4701
  console.log(`[Index] About to process ${Object.keys(model.tables || {}).length} tables for generation`);
4517
4702
  }
4518
4703
  for (const table of Object.values(model.tables)) {
4519
- const typesSrc = emitTypes(table, { numericMode: "string" });
4704
+ const typesSrc = emitTypes(table, { numericMode: "string" }, model.enums);
4520
4705
  files.push({ path: join(serverDir, "types", `${table.name}.ts`), content: typesSrc });
4521
4706
  files.push({ path: join(clientDir, "types", `${table.name}.ts`), content: typesSrc });
4522
- const zodSrc = emitZod(table, { numericMode: "string" });
4707
+ const zodSrc = emitZod(table, { numericMode: "string" }, model.enums);
4523
4708
  files.push({ path: join(serverDir, "zod", `${table.name}.ts`), content: zodSrc });
4524
4709
  files.push({ path: join(clientDir, "zod", `${table.name}.ts`), content: zodSrc });
4525
4710
  const paramsZodSrc = emitParamsZod(table, graph);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresdk",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Generate a typed server/client SDK from a Postgres schema (includes, Zod, Hono).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,11 +22,12 @@
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: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:gen-with-tests && bun test:pull && bun test:enums && bun test:typecheck && bun test:drizzle-e2e",
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",
29
29
  "test:pull": "bun test/test-pull.ts",
30
+ "test:enums": "bun test/test-enums.ts",
30
31
  "test:typecheck": "bun test/test-typecheck.ts",
31
32
  "test:drizzle-e2e": "bun test/test-drizzle-e2e.ts",
32
33
  "typecheck": "tsc --noEmit",