postgresdk 0.11.0 → 0.12.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
@@ -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
@@ -160,7 +160,7 @@ const authors = await sdk.authors.list({
160
160
  ### Filtering & Pagination
161
161
 
162
162
  ```typescript
163
- // Simple equality filtering
163
+ // Simple equality filtering with single-column sorting
164
164
  const users = await sdk.users.list({
165
165
  where: { status: "active" },
166
166
  orderBy: "created_at",
@@ -169,6 +169,12 @@ const users = await sdk.users.list({
169
169
  offset: 40
170
170
  });
171
171
 
172
+ // Multi-column sorting
173
+ const sorted = await sdk.users.list({
174
+ orderBy: ["status", "created_at"],
175
+ order: ["asc", "desc"] // or use single direction: order: "asc"
176
+ });
177
+
172
178
  // Advanced WHERE operators
173
179
  const filtered = await sdk.users.list({
174
180
  where: {
package/dist/cli.js CHANGED
@@ -780,6 +780,7 @@ 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({
@@ -793,9 +794,9 @@ const items = await sdk.${tableName}.list();
793
794
  const filtered = await sdk.${tableName}.list({
794
795
  limit: 20,
795
796
  offset: 0,
796
- ${table.columns[0]?.name || "field"}_like: 'search',
797
- order_by: '${table.columns[0]?.name || "created_at"}',
798
- order_dir: 'desc'
797
+ where: { ${table.columns[0]?.name || "field"}: { $like: '%search%' } },
798
+ orderBy: '${table.columns[0]?.name || "created_at"}',
799
+ order: 'desc'
799
800
  });`,
800
801
  correspondsTo: `GET ${basePath}`
801
802
  });
@@ -803,7 +804,7 @@ const filtered = await sdk.${tableName}.list({
803
804
  method: "GET",
804
805
  path: basePath,
805
806
  description: `List all ${tableName} records`,
806
- queryParameters: generateQueryParams(table),
807
+ queryParameters: generateQueryParams(table, enums),
807
808
  responseBody: `${Type}[]`
808
809
  });
809
810
  if (hasSinglePK) {
@@ -911,7 +912,7 @@ const filtered = await sdk.${tableName}.${method.name}({
911
912
  });
912
913
  }
913
914
  }
914
- const fields = table.columns.map((col) => generateFieldContract(col, table));
915
+ const fields = table.columns.map((col) => generateFieldContract(col, table, enums));
915
916
  return {
916
917
  name: Type,
917
918
  tableName,
@@ -926,11 +927,11 @@ const filtered = await sdk.${tableName}.${method.name}({
926
927
  fields
927
928
  };
928
929
  }
929
- function generateFieldContract(column, table) {
930
+ function generateFieldContract(column, table, enums) {
930
931
  const field = {
931
932
  name: column.name,
932
- type: postgresTypeToJsonType(column.pgType),
933
- tsType: postgresTypeToTsType(column),
933
+ type: postgresTypeToJsonType(column.pgType, enums),
934
+ tsType: postgresTypeToTsType(column, enums),
934
935
  required: !column.nullable && !column.hasDefault,
935
936
  description: generateFieldDescription(column, table)
936
937
  };
@@ -943,16 +944,41 @@ function generateFieldContract(column, table) {
943
944
  }
944
945
  return field;
945
946
  }
946
- function postgresTypeToTsType(column) {
947
+ function postgresTypeToTsType(column, enums) {
948
+ const pgType = column.pgType.toLowerCase();
949
+ if (enums[pgType]) {
950
+ const enumType = enums[pgType].map((v) => `"${v}"`).join(" | ");
951
+ if (column.nullable) {
952
+ return `${enumType} | null`;
953
+ }
954
+ return enumType;
955
+ }
956
+ if (pgType.startsWith("_")) {
957
+ const enumName = pgType.slice(1);
958
+ const enumValues = enums[enumName];
959
+ if (enumValues) {
960
+ const enumType = enumValues.map((v) => `"${v}"`).join(" | ");
961
+ const arrayType = `(${enumType})[]`;
962
+ if (column.nullable) {
963
+ return `${arrayType} | null`;
964
+ }
965
+ return arrayType;
966
+ }
967
+ }
947
968
  const baseType = (() => {
948
- switch (column.pgType) {
969
+ switch (pgType) {
949
970
  case "int":
971
+ case "int2":
972
+ case "int4":
973
+ case "int8":
950
974
  case "integer":
951
975
  case "smallint":
952
976
  case "bigint":
953
977
  case "decimal":
954
978
  case "numeric":
955
979
  case "real":
980
+ case "float4":
981
+ case "float8":
956
982
  case "double precision":
957
983
  case "float":
958
984
  return "number";
@@ -970,9 +996,16 @@ function postgresTypeToTsType(column) {
970
996
  return "string";
971
997
  case "text[]":
972
998
  case "varchar[]":
999
+ case "_text":
1000
+ case "_varchar":
973
1001
  return "string[]";
974
1002
  case "int[]":
975
1003
  case "integer[]":
1004
+ case "_int":
1005
+ case "_int2":
1006
+ case "_int4":
1007
+ case "_int8":
1008
+ case "_integer":
976
1009
  return "number[]";
977
1010
  default:
978
1011
  return "string";
@@ -1054,18 +1087,18 @@ function generateExampleValue(column) {
1054
1087
  return `'example value'`;
1055
1088
  }
1056
1089
  }
1057
- function generateQueryParams(table) {
1090
+ function generateQueryParams(table, enums) {
1058
1091
  const params = {
1059
1092
  limit: "number - Max records to return (default: 50)",
1060
1093
  offset: "number - Records to skip",
1061
- order_by: "string - Field to sort by",
1062
- order_dir: "'asc' | 'desc' - Sort direction"
1094
+ orderBy: "string | string[] - Field(s) to sort by",
1095
+ order: "'asc' | 'desc' | ('asc' | 'desc')[] - Sort direction(s)"
1063
1096
  };
1064
1097
  let filterCount = 0;
1065
1098
  for (const col of table.columns) {
1066
1099
  if (filterCount >= 3)
1067
1100
  break;
1068
- const type = postgresTypeToJsonType(col.pgType);
1101
+ const type = postgresTypeToJsonType(col.pgType, enums);
1069
1102
  params[col.name] = `${type} - Filter by ${col.name}`;
1070
1103
  if (type === "string") {
1071
1104
  params[`${col.name}_like`] = `string - Search in ${col.name}`;
@@ -1078,15 +1111,27 @@ function generateQueryParams(table) {
1078
1111
  params["..."] = "Additional filters for all fields";
1079
1112
  return params;
1080
1113
  }
1081
- function postgresTypeToJsonType(pgType) {
1082
- switch (pgType) {
1114
+ function postgresTypeToJsonType(pgType, enums) {
1115
+ const t = pgType.toLowerCase();
1116
+ if (enums[t]) {
1117
+ return t;
1118
+ }
1119
+ if (t.startsWith("_") && enums[t.slice(1)]) {
1120
+ return `${t.slice(1)}[]`;
1121
+ }
1122
+ switch (t) {
1083
1123
  case "int":
1124
+ case "int2":
1125
+ case "int4":
1126
+ case "int8":
1084
1127
  case "integer":
1085
1128
  case "smallint":
1086
1129
  case "bigint":
1087
1130
  case "decimal":
1088
1131
  case "numeric":
1089
1132
  case "real":
1133
+ case "float4":
1134
+ case "float8":
1090
1135
  case "double precision":
1091
1136
  case "float":
1092
1137
  return "number";
@@ -1104,9 +1149,16 @@ function postgresTypeToJsonType(pgType) {
1104
1149
  return "uuid";
1105
1150
  case "text[]":
1106
1151
  case "varchar[]":
1152
+ case "_text":
1153
+ case "_varchar":
1107
1154
  return "string[]";
1108
1155
  case "int[]":
1109
1156
  case "integer[]":
1157
+ case "_int":
1158
+ case "_int2":
1159
+ case "_int4":
1160
+ case "_int8":
1161
+ case "_integer":
1110
1162
  return "number[]";
1111
1163
  default:
1112
1164
  return "string";
@@ -1351,6 +1403,64 @@ function generateUnifiedContractMarkdown(contract) {
1351
1403
  lines.push("");
1352
1404
  lines.push("**Note:** The WHERE clause types are fully type-safe. TypeScript will only allow operators that are valid for each field type.");
1353
1405
  lines.push("");
1406
+ lines.push("## Sorting");
1407
+ lines.push("");
1408
+ lines.push("Sort query results using the `orderBy` and `order` parameters. Supports both single and multi-column sorting.");
1409
+ lines.push("");
1410
+ lines.push("### Single Column Sorting");
1411
+ lines.push("");
1412
+ lines.push("```typescript");
1413
+ lines.push("// Sort by one column ascending");
1414
+ lines.push("const users = await sdk.users.list({");
1415
+ lines.push(" orderBy: 'created_at',");
1416
+ lines.push(" order: 'asc'");
1417
+ lines.push("});");
1418
+ lines.push("");
1419
+ lines.push("// Sort descending");
1420
+ lines.push("const latest = await sdk.users.list({");
1421
+ lines.push(" orderBy: 'created_at',");
1422
+ lines.push(" order: 'desc'");
1423
+ lines.push("});");
1424
+ lines.push("");
1425
+ lines.push("// Order defaults to 'asc' if not specified");
1426
+ lines.push("const sorted = await sdk.users.list({");
1427
+ lines.push(" orderBy: 'name'");
1428
+ lines.push("});");
1429
+ lines.push("```");
1430
+ lines.push("");
1431
+ lines.push("### Multi-Column Sorting");
1432
+ lines.push("");
1433
+ lines.push("```typescript");
1434
+ lines.push("// Sort by multiple columns (all same direction)");
1435
+ lines.push("const users = await sdk.users.list({");
1436
+ lines.push(" orderBy: ['status', 'created_at'],");
1437
+ lines.push(" order: 'desc'");
1438
+ lines.push("});");
1439
+ lines.push("");
1440
+ lines.push("// Different direction per column");
1441
+ lines.push("const sorted = await sdk.users.list({");
1442
+ lines.push(" orderBy: ['status', 'created_at'],");
1443
+ lines.push(" order: ['asc', 'desc'] // status ASC, created_at DESC");
1444
+ lines.push("});");
1445
+ lines.push("```");
1446
+ lines.push("");
1447
+ lines.push("### Combining Sorting with Filters");
1448
+ lines.push("");
1449
+ lines.push("```typescript");
1450
+ lines.push("const results = await sdk.users.list({");
1451
+ lines.push(" where: {");
1452
+ lines.push(" status: 'active',");
1453
+ lines.push(" age: { $gte: 18 }");
1454
+ lines.push(" },");
1455
+ lines.push(" orderBy: 'created_at',");
1456
+ lines.push(" order: 'desc',");
1457
+ lines.push(" limit: 50,");
1458
+ lines.push(" offset: 0");
1459
+ lines.push("});");
1460
+ lines.push("```");
1461
+ lines.push("");
1462
+ lines.push("**Note:** Column names are validated by Zod schemas. Only valid table columns are accepted, preventing SQL injection.");
1463
+ lines.push("");
1354
1464
  lines.push("## Resources");
1355
1465
  lines.push("");
1356
1466
  for (const resource of contract.resources) {
@@ -2508,23 +2618,28 @@ export const buildWithFor = (t: TableName) =>
2508
2618
 
2509
2619
  // src/emit-zod.ts
2510
2620
  init_utils();
2511
- function emitZod(table, opts) {
2621
+ function emitZod(table, opts, enums) {
2512
2622
  const Type = pascal(table.name);
2513
2623
  const zFor = (pg) => {
2514
- if (pg === "uuid")
2624
+ const t = pg.toLowerCase();
2625
+ if (enums[t]) {
2626
+ const values = enums[t].map((v) => `"${v}"`).join(", ");
2627
+ return `z.enum([${values}])`;
2628
+ }
2629
+ if (t === "uuid")
2515
2630
  return `z.string()`;
2516
- if (pg === "bool" || pg === "boolean")
2631
+ if (t === "bool" || t === "boolean")
2517
2632
  return `z.boolean()`;
2518
- if (pg === "int2" || pg === "int4" || pg === "int8")
2633
+ if (t === "int2" || t === "int4" || t === "int8")
2519
2634
  return opts.numericMode === "number" ? `z.number()` : `z.string()`;
2520
- if (pg === "numeric" || pg === "float4" || pg === "float8")
2635
+ if (t === "numeric" || t === "float4" || t === "float8")
2521
2636
  return opts.numericMode === "number" ? `z.number()` : `z.string()`;
2522
- if (pg === "jsonb" || pg === "json")
2637
+ if (t === "jsonb" || t === "json")
2523
2638
  return `z.unknown()`;
2524
- if (pg === "date" || pg.startsWith("timestamp"))
2639
+ if (t === "date" || t.startsWith("timestamp"))
2525
2640
  return `z.string()`;
2526
- if (pg.startsWith("_"))
2527
- return `z.array(${zFor(pg.slice(1))})`;
2641
+ if (t.startsWith("_"))
2642
+ return `z.array(${zFor(t.slice(1))})`;
2528
2643
  return `z.string()`;
2529
2644
  };
2530
2645
  const selectFields = table.columns.map((c) => {
@@ -2625,6 +2740,7 @@ function emitHonoRoutes(table, _graph, opts) {
2625
2740
  const hasAuth = opts.authStrategy && opts.authStrategy !== "none";
2626
2741
  const ext = opts.useJsExtensions ? ".js" : "";
2627
2742
  const authImport = hasAuth ? `import { authMiddleware } from "../auth${ext}";` : "";
2743
+ const columnNames = table.columns.map((c) => `"${c.name}"`).join(", ");
2628
2744
  return `/**
2629
2745
  * AUTO-GENERATED FILE - DO NOT EDIT
2630
2746
  *
@@ -2641,12 +2757,15 @@ import { loadIncludes } from "../include-loader${ext}";
2641
2757
  import * as coreOps from "../core/operations${ext}";
2642
2758
  ${authImport}
2643
2759
 
2760
+ const columnEnum = z.enum([${columnNames}]);
2761
+
2644
2762
  const listSchema = z.object({
2645
2763
  where: z.any().optional(),
2646
2764
  include: z.any().optional(),
2647
2765
  limit: z.number().int().positive().max(100).optional(),
2648
2766
  offset: z.number().int().min(0).optional(),
2649
- orderBy: z.any().optional()
2767
+ orderBy: z.union([columnEnum, z.array(columnEnum)]).optional(),
2768
+ order: z.union([z.enum(["asc", "desc"]), z.array(z.enum(["asc", "desc"]))]).optional()
2650
2769
  });
2651
2770
 
2652
2771
  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> }) {
@@ -2835,7 +2954,7 @@ function emitClient(table, graph, opts, model) {
2835
2954
  let includeMethodsCode = "";
2836
2955
  for (const method of includeMethods) {
2837
2956
  const isGetByPk = method.name.startsWith("getByPk");
2838
- const baseParams = isGetByPk ? "" : `params?: Omit<{ limit?: number; offset?: number; where?: Where<Select${Type}>; orderBy?: string; order?: "asc" | "desc"; }, "include">`;
2957
+ const baseParams = isGetByPk ? "" : `params?: Omit<{ limit?: number; offset?: number; where?: Where<Select${Type}>; orderBy?: string | string[]; order?: "asc" | "desc" | ("asc" | "desc")[]; }, "include">`;
2839
2958
  if (isGetByPk) {
2840
2959
  const pkWhere = hasCompositePk ? `{ ${safePk.map((col) => `${col}: pk.${col}`).join(", ")} }` : `{ ${safePk[0] || "id"}: pk }`;
2841
2960
  const baseReturnType = method.returnType.replace(" | null", "");
@@ -2891,8 +3010,8 @@ export class ${Type}Client extends BaseClient {
2891
3010
  limit?: number;
2892
3011
  offset?: number;
2893
3012
  where?: Where<Select${Type}>;
2894
- orderBy?: string;
2895
- order?: "asc" | "desc";
3013
+ orderBy?: string | string[];
3014
+ order?: "asc" | "desc" | ("asc" | "desc")[];
2896
3015
  }): Promise<Select${Type}[]> {
2897
3016
  return this.post<Select${Type}[]>(\`\${this.resource}/list\`, params ?? {});
2898
3017
  }
@@ -3448,10 +3567,13 @@ export async function loadIncludes(
3448
3567
  }
3449
3568
 
3450
3569
  // src/emit-types.ts
3451
- function tsTypeFor(pgType, opts) {
3570
+ function tsTypeFor(pgType, opts, enums) {
3452
3571
  const t = pgType.toLowerCase();
3572
+ if (enums[t]) {
3573
+ return enums[t].map((v) => `"${v}"`).join(" | ");
3574
+ }
3453
3575
  if (t.startsWith("_"))
3454
- return `${tsTypeFor(t.slice(1), opts)}[]`;
3576
+ return `(${tsTypeFor(t.slice(1), opts, enums)})[]`;
3455
3577
  if (t === "uuid")
3456
3578
  return "string";
3457
3579
  if (t === "bool" || t === "boolean")
@@ -3466,17 +3588,17 @@ function tsTypeFor(pgType, opts) {
3466
3588
  return "string";
3467
3589
  }
3468
3590
  var pascal2 = (s) => s.split(/[_\s-]+/).map((w) => w?.[0] ? w[0].toUpperCase() + w.slice(1) : "").join("");
3469
- function emitTypes(table, opts) {
3591
+ function emitTypes(table, opts, enums) {
3470
3592
  const Type = pascal2(table.name);
3471
3593
  const insertFields = table.columns.map((col) => {
3472
- const base = tsTypeFor(col.pgType, opts);
3594
+ const base = tsTypeFor(col.pgType, opts, enums);
3473
3595
  const optional = col.hasDefault || col.nullable ? "?" : "";
3474
3596
  const valueType = col.nullable ? `${base} | null` : base;
3475
3597
  return ` ${col.name}${optional}: ${valueType};`;
3476
3598
  }).join(`
3477
3599
  `);
3478
3600
  const selectFields = table.columns.map((col) => {
3479
- const base = tsTypeFor(col.pgType, opts);
3601
+ const base = tsTypeFor(col.pgType, opts, enums);
3480
3602
  const valueType = col.nullable ? `${base} | null` : base;
3481
3603
  return ` ${col.name}: ${valueType};`;
3482
3604
  }).join(`
@@ -4226,10 +4348,10 @@ function buildWhereClause(
4226
4348
  */
4227
4349
  export async function listRecords(
4228
4350
  ctx: OperationContext,
4229
- params: { where?: any; limit?: number; offset?: number; include?: any }
4351
+ params: { where?: any; limit?: number; offset?: number; include?: any; orderBy?: string | string[]; order?: "asc" | "desc" | ("asc" | "desc")[] }
4230
4352
  ): Promise<{ data?: any; error?: string; issues?: any; needsIncludes?: boolean; includeSpec?: any; status: number }> {
4231
4353
  try {
4232
- const { where: whereClause, limit = 50, offset = 0, include } = params;
4354
+ const { where: whereClause, limit = 50, offset = 0, include, orderBy, order } = params;
4233
4355
 
4234
4356
  // Build WHERE clause
4235
4357
  let paramIndex = 1;
@@ -4253,12 +4375,26 @@ export async function listRecords(
4253
4375
 
4254
4376
  const whereSQL = whereParts.length > 0 ? \`WHERE \${whereParts.join(" AND ")}\` : "";
4255
4377
 
4378
+ // Build ORDER BY clause
4379
+ let orderBySQL = "";
4380
+ if (orderBy) {
4381
+ const columns = Array.isArray(orderBy) ? orderBy : [orderBy];
4382
+ const directions = Array.isArray(order) ? order : (order ? Array(columns.length).fill(order) : Array(columns.length).fill("asc"));
4383
+
4384
+ const orderParts = columns.map((col, i) => {
4385
+ const dir = (directions[i] || "asc").toUpperCase();
4386
+ return \`"\${col}" \${dir}\`;
4387
+ });
4388
+
4389
+ orderBySQL = \`ORDER BY \${orderParts.join(", ")}\`;
4390
+ }
4391
+
4256
4392
  // Add limit and offset params
4257
4393
  const limitParam = \`$\${paramIndex}\`;
4258
4394
  const offsetParam = \`$\${paramIndex + 1}\`;
4259
4395
  const allParams = [...whereParams, limit, offset];
4260
4396
 
4261
- const text = \`SELECT * FROM "\${ctx.table}" \${whereSQL} LIMIT \${limitParam} OFFSET \${offsetParam}\`;
4397
+ const text = \`SELECT * FROM "\${ctx.table}" \${whereSQL} \${orderBySQL} LIMIT \${limitParam} OFFSET \${offsetParam}\`;
4262
4398
  log.debug(\`LIST \${ctx.table} SQL:\`, text, "params:", allParams);
4263
4399
 
4264
4400
  const { rows } = await ctx.pg.query(text, allParams);
@@ -5200,10 +5336,10 @@ async function generate(configPath) {
5200
5336
  console.log(`[Index] About to process ${Object.keys(model.tables || {}).length} tables for generation`);
5201
5337
  }
5202
5338
  for (const table of Object.values(model.tables)) {
5203
- const typesSrc = emitTypes(table, { numericMode: "string" });
5339
+ const typesSrc = emitTypes(table, { numericMode: "string" }, model.enums);
5204
5340
  files.push({ path: join(serverDir, "types", `${table.name}.ts`), content: typesSrc });
5205
5341
  files.push({ path: join(clientDir, "types", `${table.name}.ts`), content: typesSrc });
5206
- const zodSrc = emitZod(table, { numericMode: "string" });
5342
+ const zodSrc = emitZod(table, { numericMode: "string" }, model.enums);
5207
5343
  files.push({ path: join(serverDir, "zod", `${table.name}.ts`), content: zodSrc });
5208
5344
  files.push({ path: join(clientDir, "zod", `${table.name}.ts`), content: zodSrc });
5209
5345
  const paramsZodSrc = emitParamsZod(table, graph);
@@ -39,6 +39,8 @@ export declare function listRecords(ctx: OperationContext, params: {
39
39
  limit?: number;
40
40
  offset?: number;
41
41
  include?: any;
42
+ orderBy?: string | string[];
43
+ order?: "asc" | "desc" | ("asc" | "desc")[];
42
44
  }): Promise<{
43
45
  data?: any;
44
46
  error?: string;
@@ -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
@@ -779,6 +779,7 @@ 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({
@@ -792,9 +793,9 @@ const items = await sdk.${tableName}.list();
792
793
  const filtered = await sdk.${tableName}.list({
793
794
  limit: 20,
794
795
  offset: 0,
795
- ${table.columns[0]?.name || "field"}_like: 'search',
796
- order_by: '${table.columns[0]?.name || "created_at"}',
797
- order_dir: 'desc'
796
+ where: { ${table.columns[0]?.name || "field"}: { $like: '%search%' } },
797
+ orderBy: '${table.columns[0]?.name || "created_at"}',
798
+ order: 'desc'
798
799
  });`,
799
800
  correspondsTo: `GET ${basePath}`
800
801
  });
@@ -802,7 +803,7 @@ const filtered = await sdk.${tableName}.list({
802
803
  method: "GET",
803
804
  path: basePath,
804
805
  description: `List all ${tableName} records`,
805
- queryParameters: generateQueryParams(table),
806
+ queryParameters: generateQueryParams(table, enums),
806
807
  responseBody: `${Type}[]`
807
808
  });
808
809
  if (hasSinglePK) {
@@ -910,7 +911,7 @@ const filtered = await sdk.${tableName}.${method.name}({
910
911
  });
911
912
  }
912
913
  }
913
- const fields = table.columns.map((col) => generateFieldContract(col, table));
914
+ const fields = table.columns.map((col) => generateFieldContract(col, table, enums));
914
915
  return {
915
916
  name: Type,
916
917
  tableName,
@@ -925,11 +926,11 @@ const filtered = await sdk.${tableName}.${method.name}({
925
926
  fields
926
927
  };
927
928
  }
928
- function generateFieldContract(column, table) {
929
+ function generateFieldContract(column, table, enums) {
929
930
  const field = {
930
931
  name: column.name,
931
- type: postgresTypeToJsonType(column.pgType),
932
- tsType: postgresTypeToTsType(column),
932
+ type: postgresTypeToJsonType(column.pgType, enums),
933
+ tsType: postgresTypeToTsType(column, enums),
933
934
  required: !column.nullable && !column.hasDefault,
934
935
  description: generateFieldDescription(column, table)
935
936
  };
@@ -942,16 +943,41 @@ function generateFieldContract(column, table) {
942
943
  }
943
944
  return field;
944
945
  }
945
- function postgresTypeToTsType(column) {
946
+ function postgresTypeToTsType(column, enums) {
947
+ const pgType = column.pgType.toLowerCase();
948
+ if (enums[pgType]) {
949
+ const enumType = enums[pgType].map((v) => `"${v}"`).join(" | ");
950
+ if (column.nullable) {
951
+ return `${enumType} | null`;
952
+ }
953
+ return enumType;
954
+ }
955
+ if (pgType.startsWith("_")) {
956
+ const enumName = pgType.slice(1);
957
+ const enumValues = enums[enumName];
958
+ if (enumValues) {
959
+ const enumType = enumValues.map((v) => `"${v}"`).join(" | ");
960
+ const arrayType = `(${enumType})[]`;
961
+ if (column.nullable) {
962
+ return `${arrayType} | null`;
963
+ }
964
+ return arrayType;
965
+ }
966
+ }
946
967
  const baseType = (() => {
947
- switch (column.pgType) {
968
+ switch (pgType) {
948
969
  case "int":
970
+ case "int2":
971
+ case "int4":
972
+ case "int8":
949
973
  case "integer":
950
974
  case "smallint":
951
975
  case "bigint":
952
976
  case "decimal":
953
977
  case "numeric":
954
978
  case "real":
979
+ case "float4":
980
+ case "float8":
955
981
  case "double precision":
956
982
  case "float":
957
983
  return "number";
@@ -969,9 +995,16 @@ function postgresTypeToTsType(column) {
969
995
  return "string";
970
996
  case "text[]":
971
997
  case "varchar[]":
998
+ case "_text":
999
+ case "_varchar":
972
1000
  return "string[]";
973
1001
  case "int[]":
974
1002
  case "integer[]":
1003
+ case "_int":
1004
+ case "_int2":
1005
+ case "_int4":
1006
+ case "_int8":
1007
+ case "_integer":
975
1008
  return "number[]";
976
1009
  default:
977
1010
  return "string";
@@ -1053,18 +1086,18 @@ function generateExampleValue(column) {
1053
1086
  return `'example value'`;
1054
1087
  }
1055
1088
  }
1056
- function generateQueryParams(table) {
1089
+ function generateQueryParams(table, enums) {
1057
1090
  const params = {
1058
1091
  limit: "number - Max records to return (default: 50)",
1059
1092
  offset: "number - Records to skip",
1060
- order_by: "string - Field to sort by",
1061
- order_dir: "'asc' | 'desc' - Sort direction"
1093
+ orderBy: "string | string[] - Field(s) to sort by",
1094
+ order: "'asc' | 'desc' | ('asc' | 'desc')[] - Sort direction(s)"
1062
1095
  };
1063
1096
  let filterCount = 0;
1064
1097
  for (const col of table.columns) {
1065
1098
  if (filterCount >= 3)
1066
1099
  break;
1067
- const type = postgresTypeToJsonType(col.pgType);
1100
+ const type = postgresTypeToJsonType(col.pgType, enums);
1068
1101
  params[col.name] = `${type} - Filter by ${col.name}`;
1069
1102
  if (type === "string") {
1070
1103
  params[`${col.name}_like`] = `string - Search in ${col.name}`;
@@ -1077,15 +1110,27 @@ function generateQueryParams(table) {
1077
1110
  params["..."] = "Additional filters for all fields";
1078
1111
  return params;
1079
1112
  }
1080
- function postgresTypeToJsonType(pgType) {
1081
- switch (pgType) {
1113
+ function postgresTypeToJsonType(pgType, enums) {
1114
+ const t = pgType.toLowerCase();
1115
+ if (enums[t]) {
1116
+ return t;
1117
+ }
1118
+ if (t.startsWith("_") && enums[t.slice(1)]) {
1119
+ return `${t.slice(1)}[]`;
1120
+ }
1121
+ switch (t) {
1082
1122
  case "int":
1123
+ case "int2":
1124
+ case "int4":
1125
+ case "int8":
1083
1126
  case "integer":
1084
1127
  case "smallint":
1085
1128
  case "bigint":
1086
1129
  case "decimal":
1087
1130
  case "numeric":
1088
1131
  case "real":
1132
+ case "float4":
1133
+ case "float8":
1089
1134
  case "double precision":
1090
1135
  case "float":
1091
1136
  return "number";
@@ -1103,9 +1148,16 @@ function postgresTypeToJsonType(pgType) {
1103
1148
  return "uuid";
1104
1149
  case "text[]":
1105
1150
  case "varchar[]":
1151
+ case "_text":
1152
+ case "_varchar":
1106
1153
  return "string[]";
1107
1154
  case "int[]":
1108
1155
  case "integer[]":
1156
+ case "_int":
1157
+ case "_int2":
1158
+ case "_int4":
1159
+ case "_int8":
1160
+ case "_integer":
1109
1161
  return "number[]";
1110
1162
  default:
1111
1163
  return "string";
@@ -1350,6 +1402,64 @@ function generateUnifiedContractMarkdown(contract) {
1350
1402
  lines.push("");
1351
1403
  lines.push("**Note:** The WHERE clause types are fully type-safe. TypeScript will only allow operators that are valid for each field type.");
1352
1404
  lines.push("");
1405
+ lines.push("## Sorting");
1406
+ lines.push("");
1407
+ lines.push("Sort query results using the `orderBy` and `order` parameters. Supports both single and multi-column sorting.");
1408
+ lines.push("");
1409
+ lines.push("### Single Column Sorting");
1410
+ lines.push("");
1411
+ lines.push("```typescript");
1412
+ lines.push("// Sort by one column ascending");
1413
+ lines.push("const users = await sdk.users.list({");
1414
+ lines.push(" orderBy: 'created_at',");
1415
+ lines.push(" order: 'asc'");
1416
+ lines.push("});");
1417
+ lines.push("");
1418
+ lines.push("// Sort descending");
1419
+ lines.push("const latest = await sdk.users.list({");
1420
+ lines.push(" orderBy: 'created_at',");
1421
+ lines.push(" order: 'desc'");
1422
+ lines.push("});");
1423
+ lines.push("");
1424
+ lines.push("// Order defaults to 'asc' if not specified");
1425
+ lines.push("const sorted = await sdk.users.list({");
1426
+ lines.push(" orderBy: 'name'");
1427
+ lines.push("});");
1428
+ lines.push("```");
1429
+ lines.push("");
1430
+ lines.push("### Multi-Column Sorting");
1431
+ lines.push("");
1432
+ lines.push("```typescript");
1433
+ lines.push("// Sort by multiple columns (all same direction)");
1434
+ lines.push("const users = await sdk.users.list({");
1435
+ lines.push(" orderBy: ['status', 'created_at'],");
1436
+ lines.push(" order: 'desc'");
1437
+ lines.push("});");
1438
+ lines.push("");
1439
+ lines.push("// Different direction per column");
1440
+ lines.push("const sorted = await sdk.users.list({");
1441
+ lines.push(" orderBy: ['status', 'created_at'],");
1442
+ lines.push(" order: ['asc', 'desc'] // status ASC, created_at DESC");
1443
+ lines.push("});");
1444
+ lines.push("```");
1445
+ lines.push("");
1446
+ lines.push("### Combining Sorting with Filters");
1447
+ lines.push("");
1448
+ lines.push("```typescript");
1449
+ lines.push("const results = await sdk.users.list({");
1450
+ lines.push(" where: {");
1451
+ lines.push(" status: 'active',");
1452
+ lines.push(" age: { $gte: 18 }");
1453
+ lines.push(" },");
1454
+ lines.push(" orderBy: 'created_at',");
1455
+ lines.push(" order: 'desc',");
1456
+ lines.push(" limit: 50,");
1457
+ lines.push(" offset: 0");
1458
+ lines.push("});");
1459
+ lines.push("```");
1460
+ lines.push("");
1461
+ lines.push("**Note:** Column names are validated by Zod schemas. Only valid table columns are accepted, preventing SQL injection.");
1462
+ lines.push("");
1353
1463
  lines.push("## Resources");
1354
1464
  lines.push("");
1355
1465
  for (const resource of contract.resources) {
@@ -1748,23 +1858,28 @@ export const buildWithFor = (t: TableName) =>
1748
1858
 
1749
1859
  // src/emit-zod.ts
1750
1860
  init_utils();
1751
- function emitZod(table, opts) {
1861
+ function emitZod(table, opts, enums) {
1752
1862
  const Type = pascal(table.name);
1753
1863
  const zFor = (pg) => {
1754
- if (pg === "uuid")
1864
+ const t = pg.toLowerCase();
1865
+ if (enums[t]) {
1866
+ const values = enums[t].map((v) => `"${v}"`).join(", ");
1867
+ return `z.enum([${values}])`;
1868
+ }
1869
+ if (t === "uuid")
1755
1870
  return `z.string()`;
1756
- if (pg === "bool" || pg === "boolean")
1871
+ if (t === "bool" || t === "boolean")
1757
1872
  return `z.boolean()`;
1758
- if (pg === "int2" || pg === "int4" || pg === "int8")
1873
+ if (t === "int2" || t === "int4" || t === "int8")
1759
1874
  return opts.numericMode === "number" ? `z.number()` : `z.string()`;
1760
- if (pg === "numeric" || pg === "float4" || pg === "float8")
1875
+ if (t === "numeric" || t === "float4" || t === "float8")
1761
1876
  return opts.numericMode === "number" ? `z.number()` : `z.string()`;
1762
- if (pg === "jsonb" || pg === "json")
1877
+ if (t === "jsonb" || t === "json")
1763
1878
  return `z.unknown()`;
1764
- if (pg === "date" || pg.startsWith("timestamp"))
1879
+ if (t === "date" || t.startsWith("timestamp"))
1765
1880
  return `z.string()`;
1766
- if (pg.startsWith("_"))
1767
- return `z.array(${zFor(pg.slice(1))})`;
1881
+ if (t.startsWith("_"))
1882
+ return `z.array(${zFor(t.slice(1))})`;
1768
1883
  return `z.string()`;
1769
1884
  };
1770
1885
  const selectFields = table.columns.map((c) => {
@@ -1865,6 +1980,7 @@ function emitHonoRoutes(table, _graph, opts) {
1865
1980
  const hasAuth = opts.authStrategy && opts.authStrategy !== "none";
1866
1981
  const ext = opts.useJsExtensions ? ".js" : "";
1867
1982
  const authImport = hasAuth ? `import { authMiddleware } from "../auth${ext}";` : "";
1983
+ const columnNames = table.columns.map((c) => `"${c.name}"`).join(", ");
1868
1984
  return `/**
1869
1985
  * AUTO-GENERATED FILE - DO NOT EDIT
1870
1986
  *
@@ -1881,12 +1997,15 @@ import { loadIncludes } from "../include-loader${ext}";
1881
1997
  import * as coreOps from "../core/operations${ext}";
1882
1998
  ${authImport}
1883
1999
 
2000
+ const columnEnum = z.enum([${columnNames}]);
2001
+
1884
2002
  const listSchema = z.object({
1885
2003
  where: z.any().optional(),
1886
2004
  include: z.any().optional(),
1887
2005
  limit: z.number().int().positive().max(100).optional(),
1888
2006
  offset: z.number().int().min(0).optional(),
1889
- orderBy: z.any().optional()
2007
+ orderBy: z.union([columnEnum, z.array(columnEnum)]).optional(),
2008
+ order: z.union([z.enum(["asc", "desc"]), z.array(z.enum(["asc", "desc"]))]).optional()
1890
2009
  });
1891
2010
 
1892
2011
  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> }) {
@@ -2075,7 +2194,7 @@ function emitClient(table, graph, opts, model) {
2075
2194
  let includeMethodsCode = "";
2076
2195
  for (const method of includeMethods) {
2077
2196
  const isGetByPk = method.name.startsWith("getByPk");
2078
- const baseParams = isGetByPk ? "" : `params?: Omit<{ limit?: number; offset?: number; where?: Where<Select${Type}>; orderBy?: string; order?: "asc" | "desc"; }, "include">`;
2197
+ const baseParams = isGetByPk ? "" : `params?: Omit<{ limit?: number; offset?: number; where?: Where<Select${Type}>; orderBy?: string | string[]; order?: "asc" | "desc" | ("asc" | "desc")[]; }, "include">`;
2079
2198
  if (isGetByPk) {
2080
2199
  const pkWhere = hasCompositePk ? `{ ${safePk.map((col) => `${col}: pk.${col}`).join(", ")} }` : `{ ${safePk[0] || "id"}: pk }`;
2081
2200
  const baseReturnType = method.returnType.replace(" | null", "");
@@ -2131,8 +2250,8 @@ export class ${Type}Client extends BaseClient {
2131
2250
  limit?: number;
2132
2251
  offset?: number;
2133
2252
  where?: Where<Select${Type}>;
2134
- orderBy?: string;
2135
- order?: "asc" | "desc";
2253
+ orderBy?: string | string[];
2254
+ order?: "asc" | "desc" | ("asc" | "desc")[];
2136
2255
  }): Promise<Select${Type}[]> {
2137
2256
  return this.post<Select${Type}[]>(\`\${this.resource}/list\`, params ?? {});
2138
2257
  }
@@ -2688,10 +2807,13 @@ export async function loadIncludes(
2688
2807
  }
2689
2808
 
2690
2809
  // src/emit-types.ts
2691
- function tsTypeFor(pgType, opts) {
2810
+ function tsTypeFor(pgType, opts, enums) {
2692
2811
  const t = pgType.toLowerCase();
2812
+ if (enums[t]) {
2813
+ return enums[t].map((v) => `"${v}"`).join(" | ");
2814
+ }
2693
2815
  if (t.startsWith("_"))
2694
- return `${tsTypeFor(t.slice(1), opts)}[]`;
2816
+ return `(${tsTypeFor(t.slice(1), opts, enums)})[]`;
2695
2817
  if (t === "uuid")
2696
2818
  return "string";
2697
2819
  if (t === "bool" || t === "boolean")
@@ -2706,17 +2828,17 @@ function tsTypeFor(pgType, opts) {
2706
2828
  return "string";
2707
2829
  }
2708
2830
  var pascal2 = (s) => s.split(/[_\s-]+/).map((w) => w?.[0] ? w[0].toUpperCase() + w.slice(1) : "").join("");
2709
- function emitTypes(table, opts) {
2831
+ function emitTypes(table, opts, enums) {
2710
2832
  const Type = pascal2(table.name);
2711
2833
  const insertFields = table.columns.map((col) => {
2712
- const base = tsTypeFor(col.pgType, opts);
2834
+ const base = tsTypeFor(col.pgType, opts, enums);
2713
2835
  const optional = col.hasDefault || col.nullable ? "?" : "";
2714
2836
  const valueType = col.nullable ? `${base} | null` : base;
2715
2837
  return ` ${col.name}${optional}: ${valueType};`;
2716
2838
  }).join(`
2717
2839
  `);
2718
2840
  const selectFields = table.columns.map((col) => {
2719
- const base = tsTypeFor(col.pgType, opts);
2841
+ const base = tsTypeFor(col.pgType, opts, enums);
2720
2842
  const valueType = col.nullable ? `${base} | null` : base;
2721
2843
  return ` ${col.name}: ${valueType};`;
2722
2844
  }).join(`
@@ -3466,10 +3588,10 @@ function buildWhereClause(
3466
3588
  */
3467
3589
  export async function listRecords(
3468
3590
  ctx: OperationContext,
3469
- params: { where?: any; limit?: number; offset?: number; include?: any }
3591
+ params: { where?: any; limit?: number; offset?: number; include?: any; orderBy?: string | string[]; order?: "asc" | "desc" | ("asc" | "desc")[] }
3470
3592
  ): Promise<{ data?: any; error?: string; issues?: any; needsIncludes?: boolean; includeSpec?: any; status: number }> {
3471
3593
  try {
3472
- const { where: whereClause, limit = 50, offset = 0, include } = params;
3594
+ const { where: whereClause, limit = 50, offset = 0, include, orderBy, order } = params;
3473
3595
 
3474
3596
  // Build WHERE clause
3475
3597
  let paramIndex = 1;
@@ -3493,12 +3615,26 @@ export async function listRecords(
3493
3615
 
3494
3616
  const whereSQL = whereParts.length > 0 ? \`WHERE \${whereParts.join(" AND ")}\` : "";
3495
3617
 
3618
+ // Build ORDER BY clause
3619
+ let orderBySQL = "";
3620
+ if (orderBy) {
3621
+ const columns = Array.isArray(orderBy) ? orderBy : [orderBy];
3622
+ const directions = Array.isArray(order) ? order : (order ? Array(columns.length).fill(order) : Array(columns.length).fill("asc"));
3623
+
3624
+ const orderParts = columns.map((col, i) => {
3625
+ const dir = (directions[i] || "asc").toUpperCase();
3626
+ return \`"\${col}" \${dir}\`;
3627
+ });
3628
+
3629
+ orderBySQL = \`ORDER BY \${orderParts.join(", ")}\`;
3630
+ }
3631
+
3496
3632
  // Add limit and offset params
3497
3633
  const limitParam = \`$\${paramIndex}\`;
3498
3634
  const offsetParam = \`$\${paramIndex + 1}\`;
3499
3635
  const allParams = [...whereParams, limit, offset];
3500
3636
 
3501
- const text = \`SELECT * FROM "\${ctx.table}" \${whereSQL} LIMIT \${limitParam} OFFSET \${offsetParam}\`;
3637
+ const text = \`SELECT * FROM "\${ctx.table}" \${whereSQL} \${orderBySQL} LIMIT \${limitParam} OFFSET \${offsetParam}\`;
3502
3638
  log.debug(\`LIST \${ctx.table} SQL:\`, text, "params:", allParams);
3503
3639
 
3504
3640
  const { rows } = await ctx.pg.query(text, allParams);
@@ -4440,10 +4576,10 @@ async function generate(configPath) {
4440
4576
  console.log(`[Index] About to process ${Object.keys(model.tables || {}).length} tables for generation`);
4441
4577
  }
4442
4578
  for (const table of Object.values(model.tables)) {
4443
- const typesSrc = emitTypes(table, { numericMode: "string" });
4579
+ const typesSrc = emitTypes(table, { numericMode: "string" }, model.enums);
4444
4580
  files.push({ path: join(serverDir, "types", `${table.name}.ts`), content: typesSrc });
4445
4581
  files.push({ path: join(clientDir, "types", `${table.name}.ts`), content: typesSrc });
4446
- const zodSrc = emitZod(table, { numericMode: "string" });
4582
+ const zodSrc = emitZod(table, { numericMode: "string" }, model.enums);
4447
4583
  files.push({ path: join(serverDir, "zod", `${table.name}.ts`), content: zodSrc });
4448
4584
  files.push({ path: join(clientDir, "zod", `${table.name}.ts`), content: zodSrc });
4449
4585
  const paramsZodSrc = emitParamsZod(table, graph);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresdk",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
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",