postgresdk 0.13.0 → 0.14.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
@@ -4,6 +4,50 @@
4
4
 
5
5
  Generate a typed server/client SDK from your PostgreSQL database schema.
6
6
 
7
+ ## See It In Action
8
+
9
+ **Your database:**
10
+ ```sql
11
+ CREATE TABLE users (
12
+ id SERIAL PRIMARY KEY,
13
+ name TEXT NOT NULL,
14
+ email TEXT UNIQUE NOT NULL
15
+ );
16
+
17
+ CREATE TABLE posts (
18
+ id SERIAL PRIMARY KEY,
19
+ user_id INTEGER REFERENCES users(id),
20
+ title TEXT NOT NULL,
21
+ published_at TIMESTAMPTZ
22
+ );
23
+ ```
24
+
25
+ **What you get:**
26
+ ```typescript
27
+ // ✨ Fully typed client with autocomplete
28
+ const user = await sdk.users.create({
29
+ name: "Alice",
30
+ email: "alice@example.com"
31
+ });
32
+ // ^ TypeScript knows: user.id is number, user.name is string
33
+
34
+ // 🔗 Automatic relationship loading
35
+ const users = await sdk.users.list({
36
+ include: { posts: true }
37
+ });
38
+ // ^ users[0].posts is fully typed Post[]
39
+
40
+ // 🎯 Advanced filtering with type safety
41
+ const filtered = await sdk.users.list({
42
+ where: {
43
+ email: { $ilike: '%@company.com' },
44
+ posts: { published_at: { $isNot: null } }
45
+ }
46
+ });
47
+ ```
48
+
49
+ **All generated automatically. Zero boilerplate.**
50
+
7
51
  ## Features
8
52
 
9
53
  - 🚀 **Instant SDK Generation** - Point at your PostgreSQL database and get a complete SDK
@@ -20,14 +64,23 @@ Generate a typed server/client SDK from your PostgreSQL database schema.
20
64
  npm install -g postgresdk
21
65
  # or
22
66
  npx postgresdk generate
67
+
68
+ # With Bun
69
+ bun install -g postgresdk
70
+ # or
71
+ bunx postgresdk generate
23
72
  ```
24
73
 
74
+ > **Note:** Currently only generates **Hono** server code. See [Supported Frameworks](#supported-frameworks) for details.
75
+
25
76
  ## Quick Start
26
77
 
27
78
  1. Initialize your project:
28
79
 
29
80
  ```bash
30
81
  npx postgresdk init
82
+ # or with Bun
83
+ bunx postgresdk init
31
84
  ```
32
85
 
33
86
  This creates a `postgresdk.config.ts` file with all available options documented.
@@ -45,12 +98,13 @@ export default {
45
98
 
46
99
  ```bash
47
100
  postgresdk generate
101
+ # or with Bun
102
+ bunx postgresdk generate
48
103
  ```
49
104
 
50
- 4. Use the generated SDK:
105
+ 4. Set up your server:
51
106
 
52
107
  ```typescript
53
- // Server (Hono)
54
108
  import { Hono } from "hono";
55
109
  import { Client } from "pg";
56
110
  import { createRouter } from "./api/server/router";
@@ -61,8 +115,11 @@ await pg.connect();
61
115
 
62
116
  const api = createRouter({ pg });
63
117
  app.route("/", api);
118
+ ```
119
+
120
+ 5. Use the client SDK:
64
121
 
65
- // Client
122
+ ```typescript
66
123
  import { SDK } from "./api/client";
67
124
 
68
125
  const sdk = new SDK({ baseUrl: "http://localhost:3000" });
@@ -82,13 +139,12 @@ Create a `postgresdk.config.ts` file in your project root:
82
139
  export default {
83
140
  // Required
84
141
  connectionString: process.env.DATABASE_URL || "postgres://user:pass@localhost:5432/dbname",
85
-
142
+
86
143
  // Optional (with defaults)
87
144
  schema: "public", // Database schema to introspect
88
- outServer: "./api/server", // Server code output directory
89
- outClient: "./api/client", // Client SDK output directory
145
+ outDir: "./api", // Output directory (or { client: "./sdk", server: "./api" })
90
146
  softDeleteColumn: null, // Column name for soft deletes (e.g., "deleted_at")
91
- includeMethodsDepth: 2, // Max depth for nested includes
147
+ includeMethodsDepth: 2, // Max depth for nested includes
92
148
  dateType: "date", // "date" | "string" - How to handle timestamps
93
149
  serverFramework: "hono", // Currently only hono is supported
94
150
  useJsExtensions: false, // Add .js to imports (for Vercel Edge, Deno)
@@ -97,7 +153,11 @@ export default {
97
153
  auth: {
98
154
  apiKey: process.env.API_KEY, // Simple API key auth
99
155
  // OR
100
- jwt: process.env.JWT_SECRET, // Simple JWT auth
156
+ jwt: { // JWT with multi-service support
157
+ services: [
158
+ { issuer: "my-app", secret: process.env.JWT_SECRET }
159
+ ]
160
+ }
101
161
  },
102
162
 
103
163
  // Test generation (optional)
@@ -282,14 +342,35 @@ const sdk = new SDK({
282
342
  export default {
283
343
  connectionString: "...",
284
344
  auth: {
285
- jwt: process.env.JWT_SECRET
345
+ strategy: "jwt-hs256",
346
+ jwt: {
347
+ services: [
348
+ { issuer: "my-app", secret: process.env.JWT_SECRET }
349
+ ],
350
+ audience: "my-api" // Optional
351
+ }
352
+ }
353
+ };
354
+
355
+ // Multi-service example (each service has its own secret)
356
+ export default {
357
+ connectionString: "...",
358
+ auth: {
359
+ strategy: "jwt-hs256",
360
+ jwt: {
361
+ services: [
362
+ { issuer: "web-app", secret: process.env.WEB_APP_SECRET },
363
+ { issuer: "mobile-app", secret: process.env.MOBILE_SECRET },
364
+ ],
365
+ audience: "my-api"
366
+ }
286
367
  }
287
368
  };
288
369
 
289
370
  // Client SDK usage
290
371
  const sdk = new SDK({
291
372
  baseUrl: "http://localhost:3000",
292
- auth: { jwt: "eyJhbGciOiJIUzI1NiIs..." }
373
+ auth: { jwt: "eyJhbGciOiJIUzI1NiIs..." } // JWT must include 'iss' claim
293
374
  });
294
375
  ```
295
376
 
@@ -378,6 +459,42 @@ app.route("/", apiRouter);
378
459
  serve({ fetch: app.fetch, port: 3000 });
379
460
  ```
380
461
 
462
+ ## Deployment
463
+
464
+ ### Serverless (Vercel, Netlify, Cloudflare Workers)
465
+
466
+ Use `max: 1` - each serverless instance should hold one connection:
467
+
468
+ ```typescript
469
+ import { Pool } from "@neondatabase/serverless";
470
+
471
+ const pool = new Pool({
472
+ connectionString: process.env.DATABASE_URL,
473
+ max: 1 // One connection per serverless instance
474
+ });
475
+
476
+ const apiRouter = createRouter({ pg: pool });
477
+ ```
478
+
479
+ **Why `max: 1`?** Serverless functions are ephemeral and isolated. Each instance handles one request at a time, so connection pooling provides no benefit and wastes database connections.
480
+
481
+ ### Traditional Servers (Railway, Render, VPS)
482
+
483
+ Use connection pooling to reuse connections across requests:
484
+
485
+ ```typescript
486
+ import { Pool } from "@neondatabase/serverless";
487
+
488
+ const pool = new Pool({
489
+ connectionString: process.env.DATABASE_URL,
490
+ max: 10 // Reuse connections across requests
491
+ });
492
+
493
+ const apiRouter = createRouter({ pg: pool });
494
+ ```
495
+
496
+ **Why `max: 10`?** Long-running servers handle many concurrent requests. Pooling prevents opening/closing connections for every request, significantly improving performance.
497
+
381
498
  ## SDK Distribution
382
499
 
383
500
  Your generated SDK can be pulled by client applications:
@@ -420,6 +537,9 @@ Run tests with the included Docker setup:
420
537
  ```bash
421
538
  chmod +x api/tests/run-tests.sh
422
539
  ./api/tests/run-tests.sh
540
+
541
+ # Or with Bun's built-in test runner (if framework: "bun")
542
+ bun test
423
543
  ```
424
544
 
425
545
  ## CLI Commands
@@ -437,8 +557,14 @@ Commands:
437
557
  Options:
438
558
  -c, --config <path> Path to config file (default: postgresdk.config.ts)
439
559
 
560
+ Init flags:
561
+ --api Generate API-side config (for database introspection)
562
+ --sdk Generate SDK-side config (for consuming remote SDK)
563
+
440
564
  Examples:
441
- postgresdk init
565
+ postgresdk init # Interactive prompt
566
+ postgresdk init --api # API-side config
567
+ postgresdk init --sdk # SDK-side config
442
568
  postgresdk generate
443
569
  postgresdk generate -c custom.config.ts
444
570
  postgresdk pull --from=https://api.com --output=./src/sdk
@@ -446,10 +572,32 @@ Examples:
446
572
 
447
573
  ## Requirements
448
574
 
449
- - Node.js 18+
575
+ - Node.js 18+
450
576
  - PostgreSQL 12+
451
577
  - TypeScript project (for using generated code)
452
578
 
579
+ ## Supported Frameworks
580
+
581
+ **Currently, postgresdk only generates server code for Hono.**
582
+
583
+ While the configuration accepts `serverFramework: "hono" | "express" | "fastify"`, only Hono is implemented at this time. Attempting to generate code with `express` or `fastify` will result in an error.
584
+
585
+ ### Why Hono?
586
+
587
+ Hono was chosen as the initial framework because:
588
+ - **Edge-first design** - Works seamlessly in serverless and edge environments (Cloudflare Workers, Vercel Edge, Deno Deploy)
589
+ - **Minimal dependencies** - Lightweight with excellent performance
590
+ - **Modern patterns** - Web Standard APIs (Request/Response), TypeScript-first
591
+ - **Framework compatibility** - Works across Node.js, Bun, Deno, and edge runtimes
592
+
593
+ ### Future Framework Support
594
+
595
+ The codebase architecture is designed to support multiple frameworks. Adding Express or Fastify support would require:
596
+ - Implementing framework-specific route emitters (`emit-routes-express.ts`, etc.)
597
+ - Implementing framework-specific router creators (`emit-router-express.ts`, etc.)
598
+
599
+ Contributions to add additional framework support are welcome.
600
+
453
601
  ## License
454
602
 
455
603
  MIT
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: `{ data: (${buildReturnType(baseTableName, newPath, newIsMany, newTargets, graph)})[]; total: number; limit: number; offset: number; hasMore: boolean; }`,
581
+ returnType: `PaginatedResponse<${buildReturnType(baseTableName, newPath, newIsMany, newTargets, graph)}>`,
582
582
  includeSpec: buildIncludeSpec(newPath)
583
583
  });
584
584
  methods.push({
@@ -618,7 +618,7 @@ function generateIncludeMethods(table, graph, opts, allTables) {
618
618
  path: combinedPath,
619
619
  isMany: [edge1.kind === "many", edge2.kind === "many"],
620
620
  targets: [edge1.target, edge2.target],
621
- returnType: `{ data: (Select${pascal(baseTableName)} & { ${type1}; ${type2} })[]; total: number; limit: number; offset: number; hasMore: boolean; }`,
621
+ returnType: `PaginatedResponse<Select${pascal(baseTableName)} & { ${type1}; ${type2} }>`,
622
622
  includeSpec: { [key1]: true, [key2]: true }
623
623
  });
624
624
  methods.push({
@@ -785,7 +785,7 @@ function generateResourceWithSDK(table, model, graph, config) {
785
785
  const endpoints = [];
786
786
  sdkMethods.push({
787
787
  name: "list",
788
- signature: `list(params?: ListParams): Promise<{ data: ${Type}[]; total: number; limit: number; offset: number; hasMore: boolean; }>`,
788
+ signature: `list(params?: ListParams): Promise<PaginatedResponse<${Type}>>`,
789
789
  description: `List ${tableName} with filtering, sorting, and pagination. Returns paginated results with metadata.`,
790
790
  example: `// Get all ${tableName}
791
791
  const result = await sdk.${tableName}.list();
@@ -812,7 +812,7 @@ const currentPage = Math.floor(filtered.offset / filtered.limit) + 1;`,
812
812
  path: basePath,
813
813
  description: `List all ${tableName} records with pagination metadata`,
814
814
  queryParameters: generateQueryParams(table, enums),
815
- responseBody: `{ data: ${Type}[]; total: number; limit: number; offset: number; hasMore: boolean; }`
815
+ responseBody: `PaginatedResponse<${Type}>`
816
816
  });
817
817
  if (hasSinglePK) {
818
818
  sdkMethods.push({
@@ -1951,12 +1951,14 @@ function getDefaultComplexBlock(key) {
1951
1951
  // process.env.API_KEY_1,
1952
1952
  // process.env.API_KEY_2,
1953
1953
  // ],
1954
- //
1954
+ //
1955
1955
  // // For JWT (HS256) authentication
1956
1956
  // jwt: {
1957
- // sharedSecret: process.env.JWT_SECRET, // Secret for signing/verifying
1958
- // issuer: "my-app", // Optional: validate 'iss' claim
1959
- // audience: "my-users", // Optional: validate 'aud' claim
1957
+ // services: [ // Array of services that can authenticate
1958
+ // { issuer: "web-app", secret: process.env.WEB_APP_SECRET },
1959
+ // { issuer: "mobile-app", secret: process.env.MOBILE_SECRET },
1960
+ // ],
1961
+ // audience: "my-api", // Optional: validate 'aud' claim
1960
1962
  // }
1961
1963
  // },`;
1962
1964
  case "pull":
@@ -1983,6 +1985,8 @@ async function initCommand(args) {
1983
1985
  console.log(`\uD83D\uDE80 Initializing postgresdk configuration...
1984
1986
  `);
1985
1987
  const forceError = args.includes("--force-error");
1988
+ const isApiSide = args.includes("--api");
1989
+ const isSdkSide = args.includes("--sdk");
1986
1990
  const configPath = resolve(process.cwd(), "postgresdk.config.ts");
1987
1991
  if (existsSync(configPath)) {
1988
1992
  if (forceError) {
@@ -2102,21 +2106,65 @@ async function initCommand(args) {
2102
2106
  }
2103
2107
  return;
2104
2108
  }
2109
+ let projectType;
2110
+ if (isApiSide && isSdkSide) {
2111
+ console.error("❌ Error: Cannot use both --api and --sdk flags");
2112
+ process.exit(1);
2113
+ } else if (isApiSide) {
2114
+ projectType = "api";
2115
+ } else if (isSdkSide) {
2116
+ projectType = "sdk";
2117
+ } else {
2118
+ const response = await prompts({
2119
+ type: "select",
2120
+ name: "projectType",
2121
+ message: "What type of project is this?",
2122
+ choices: [
2123
+ {
2124
+ title: "API-side (generating SDK from database)",
2125
+ value: "api",
2126
+ description: "You have a PostgreSQL database and want to generate an SDK"
2127
+ },
2128
+ {
2129
+ title: "SDK-side (consuming a remote SDK)",
2130
+ value: "sdk",
2131
+ description: "You want to pull and use an SDK from a remote API"
2132
+ }
2133
+ ],
2134
+ initial: 0
2135
+ });
2136
+ projectType = response.projectType;
2137
+ if (!projectType) {
2138
+ console.log(`
2139
+ ✅ Cancelled. No changes made.`);
2140
+ process.exit(0);
2141
+ }
2142
+ }
2143
+ const template = projectType === "api" ? CONFIG_TEMPLATE_API : CONFIG_TEMPLATE_SDK;
2105
2144
  const envPath = resolve(process.cwd(), ".env");
2106
2145
  const hasEnv = existsSync(envPath);
2107
2146
  try {
2108
- writeFileSync(configPath, CONFIG_TEMPLATE, "utf-8");
2147
+ writeFileSync(configPath, template, "utf-8");
2109
2148
  console.log("✅ Created postgresdk.config.ts");
2110
2149
  console.log(`
2111
2150
  \uD83D\uDCDD Next steps:`);
2112
- console.log(" 1. Edit postgresdk.config.ts with your database connection");
2113
- if (!hasEnv) {
2114
- console.log(" 2. Consider creating a .env file for sensitive values:");
2115
- console.log(" DATABASE_URL=postgres://user:pass@localhost:5432/mydb");
2116
- console.log(" API_KEY=your-secret-key");
2117
- console.log(" JWT_SECRET=your-jwt-secret");
2118
- }
2119
- console.log(" 3. Run 'postgresdk generate' to create your SDK");
2151
+ if (projectType === "api") {
2152
+ console.log(" 1. Edit postgresdk.config.ts with your database connection");
2153
+ if (!hasEnv) {
2154
+ console.log(" 2. Consider creating a .env file for sensitive values:");
2155
+ console.log(" DATABASE_URL=postgres://user:pass@localhost:5432/mydb");
2156
+ console.log(" API_KEY=your-secret-key");
2157
+ console.log(" JWT_SECRET=your-jwt-secret");
2158
+ }
2159
+ console.log(" 3. Run 'postgresdk generate' to create your SDK");
2160
+ } else {
2161
+ console.log(" 1. Edit postgresdk.config.ts with your API URL in pull.from");
2162
+ if (!hasEnv) {
2163
+ console.log(" 2. Consider creating a .env file if you need authentication:");
2164
+ console.log(" API_TOKEN=your-api-token");
2165
+ }
2166
+ console.log(" 3. Run 'postgresdk pull' to fetch your SDK");
2167
+ }
2120
2168
  console.log(`
2121
2169
  \uD83D\uDCA1 Tip: The config file has detailed comments for all options.`);
2122
2170
  console.log(" Uncomment the options you want to customize.");
@@ -2125,8 +2173,8 @@ async function initCommand(args) {
2125
2173
  process.exit(1);
2126
2174
  }
2127
2175
  }
2128
- var CONFIG_TEMPLATE = `/**
2129
- * PostgreSDK Configuration
2176
+ var CONFIG_TEMPLATE_API = `/**
2177
+ * PostgreSDK Configuration (API-Side)
2130
2178
  *
2131
2179
  * This file configures how postgresdk generates your type-safe API and SDK
2132
2180
  * from your PostgreSQL database schema.
@@ -2137,9 +2185,7 @@ var CONFIG_TEMPLATE = `/**
2137
2185
  * 3. Start using your generated SDK!
2138
2186
  *
2139
2187
  * CLI COMMANDS:
2140
- * postgresdk init Initialize this config file
2141
2188
  * postgresdk generate Generate API and SDK from your database
2142
- * postgresdk pull Pull SDK from a remote API
2143
2189
  * postgresdk help Show help and examples
2144
2190
  *
2145
2191
  * Environment variables are automatically loaded from .env files.
@@ -2165,16 +2211,17 @@ export default {
2165
2211
  // schema: "public",
2166
2212
 
2167
2213
  /**
2168
- * Output directory for server-side code (routes, validators, etc.)
2169
- * Default: "./api/server"
2170
- */
2171
- // outServer: "./api/server",
2172
-
2173
- /**
2174
- * Output directory for client SDK
2175
- * Default: "./api/client"
2214
+ * Output directory for generated code
2215
+ *
2216
+ * Simple usage (same directory for both):
2217
+ * outDir: "./api"
2218
+ *
2219
+ * Separate directories for client and server:
2220
+ * outDir: { client: "./sdk", server: "./api" }
2221
+ *
2222
+ * Default: { client: "./api/client", server: "./api/server" }
2176
2223
  */
2177
- // outClient: "./api/client",
2224
+ // outDir: "./api",
2178
2225
 
2179
2226
  // ========== ADVANCED OPTIONS ==========
2180
2227
 
@@ -2254,23 +2301,42 @@ export default {
2254
2301
  //
2255
2302
  // // For JWT (HS256) authentication
2256
2303
  // jwt: {
2257
- // sharedSecret: process.env.JWT_SECRET, // Secret for signing/verifying
2258
- // issuer: "my-app", // Optional: validate 'iss' claim
2259
- // audience: "my-users", // Optional: validate 'aud' claim
2304
+ // services: [ // Array of services that can authenticate
2305
+ // { issuer: "web-app", secret: process.env.WEB_APP_SECRET },
2306
+ // { issuer: "mobile-app", secret: process.env.MOBILE_SECRET },
2307
+ // ],
2308
+ // audience: "my-api", // Optional: validate 'aud' claim
2260
2309
  // }
2261
2310
  // },
2311
+ };
2312
+ `, CONFIG_TEMPLATE_SDK = `/**
2313
+ * PostgreSDK Configuration (SDK-Side)
2314
+ *
2315
+ * This file configures how postgresdk pulls a generated SDK from a remote API.
2316
+ *
2317
+ * QUICK START:
2318
+ * 1. Update the 'pull.from' URL below
2319
+ * 2. Run: postgresdk pull
2320
+ * 3. Import and use the SDK!
2321
+ *
2322
+ * CLI COMMANDS:
2323
+ * postgresdk pull Pull SDK from a remote API
2324
+ * postgresdk help Show help and examples
2325
+ *
2326
+ * Environment variables are automatically loaded from .env files.
2327
+ */
2262
2328
 
2263
- // ========== SDK DISTRIBUTION (Pull Configuration) ==========
2329
+ export default {
2330
+ // ========== SDK PULL CONFIGURATION ==========
2264
2331
 
2265
2332
  /**
2266
2333
  * Configuration for pulling SDK from a remote API
2267
- * Used when running: postgresdk pull
2268
2334
  */
2269
- // pull: {
2270
- // from: "https://api.myapp.com", // API URL to pull SDK from
2271
- // output: "./src/sdk", // Local directory for pulled SDK
2272
- // token: process.env.API_TOKEN, // Optional authentication token
2273
- // },
2335
+ pull: {
2336
+ from: "https://api.myapp.com", // API URL to pull SDK from
2337
+ output: "./src/sdk", // Local directory for pulled SDK
2338
+ // token: process.env.API_TOKEN, // Optional authentication token
2339
+ },
2274
2340
  };
2275
2341
  `;
2276
2342
  var init_cli_init = __esm(() => {
@@ -2333,7 +2399,7 @@ Example config file:`);
2333
2399
  console.log(`\uD83D\uDCC1 Output directory: ${config.output}`);
2334
2400
  try {
2335
2401
  const headers = config.token ? { Authorization: `Bearer ${config.token}` } : {};
2336
- const manifestRes = await fetch(`${config.from}/sdk/manifest`, { headers });
2402
+ const manifestRes = await fetch(`${config.from}/_psdk/sdk/manifest`, { headers });
2337
2403
  if (!manifestRes.ok) {
2338
2404
  throw new Error(`Failed to fetch SDK manifest: ${manifestRes.status} ${manifestRes.statusText}`);
2339
2405
  }
@@ -2341,7 +2407,7 @@ Example config file:`);
2341
2407
  console.log(`\uD83D\uDCE6 SDK version: ${manifest.version}`);
2342
2408
  console.log(`\uD83D\uDCC5 Generated: ${manifest.generated}`);
2343
2409
  console.log(`\uD83D\uDCC4 Files: ${manifest.files.length}`);
2344
- const sdkRes = await fetch(`${config.from}/sdk/download`, { headers });
2410
+ const sdkRes = await fetch(`${config.from}/_psdk/sdk/download`, { headers });
2345
2411
  if (!sdkRes.ok) {
2346
2412
  throw new Error(`Failed to download SDK: ${sdkRes.status} ${sdkRes.statusText}`);
2347
2413
  }
@@ -2735,6 +2801,31 @@ export type PaginationParams = z.infer<typeof PaginationParamsSchema>;
2735
2801
  `;
2736
2802
  }
2737
2803
 
2804
+ // src/emit-shared-types.ts
2805
+ function emitSharedTypes() {
2806
+ return `/**
2807
+ * Shared types used across all SDK operations
2808
+ */
2809
+
2810
+ /**
2811
+ * Paginated response structure returned by list operations
2812
+ * @template T - The type of records in the data array
2813
+ */
2814
+ export interface PaginatedResponse<T> {
2815
+ /** Array of records for the current page */
2816
+ data: T[];
2817
+ /** Total number of records matching the query (across all pages) */
2818
+ total: number;
2819
+ /** Maximum number of records per page */
2820
+ limit: number;
2821
+ /** Number of records skipped (for pagination) */
2822
+ offset: number;
2823
+ /** Whether there are more records available after this page */
2824
+ hasMore: boolean;
2825
+ }
2826
+ `;
2827
+ }
2828
+
2738
2829
  // src/emit-routes-hono.ts
2739
2830
  init_utils();
2740
2831
  function emitHonoRoutes(table, _graph, opts) {
@@ -2786,7 +2877,7 @@ const listSchema = z.object({
2786
2877
  * @param deps.onRequest - Optional hook that runs before each request (for audit logging, RLS, etc.)
2787
2878
  */
2788
2879
  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> }) {
2789
- const base = "/v1/${fileTableName}";
2880
+ const base = "${opts.apiPathPrefix}/${fileTableName}";
2790
2881
 
2791
2882
  // Create operation context
2792
2883
  const ctx: coreOps.OperationContext = {
@@ -3003,7 +3094,7 @@ function emitClient(table, graph, opts, model) {
3003
3094
  * @returns The record with nested ${method.path.join(" and ")} if found, null otherwise
3004
3095
  */
3005
3096
  async ${method.name}(pk: ${pkType}): Promise<${method.returnType}> {
3006
- const results = await this.post<{ data: ${baseReturnType}[]; total: number; limit: number; offset: number; hasMore: boolean; }>(\`\${this.resource}/list\`, {
3097
+ const results = await this.post<PaginatedResponse<${baseReturnType}>>(\`\${this.resource}/list\`, {
3007
3098
  where: ${pkWhere},
3008
3099
  include: ${JSON.stringify(method.includeSpec)},
3009
3100
  limit: 1
@@ -3034,6 +3125,7 @@ function emitClient(table, graph, opts, model) {
3034
3125
  */
3035
3126
  import { BaseClient } from "./base-client${ext}";
3036
3127
  import type { Where } from "./where-types${ext}";
3128
+ import type { PaginatedResponse } from "./types/shared${ext}";
3037
3129
  ${typeImports}
3038
3130
  ${otherTableImports.join(`
3039
3131
  `)}
@@ -3081,20 +3173,8 @@ export class ${Type}Client extends BaseClient {
3081
3173
  where?: Where<Select${Type}>;
3082
3174
  orderBy?: string | string[];
3083
3175
  order?: "asc" | "desc" | ("asc" | "desc")[];
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 ?? {});
3176
+ }): Promise<PaginatedResponse<Select${Type}>> {
3177
+ return this.post<PaginatedResponse<Select${Type}>>(\`\${this.resource}/list\`, params ?? {});
3098
3178
  }
3099
3179
 
3100
3180
  /**
@@ -3205,6 +3285,11 @@ export type { AuthConfig, HeaderMap, AuthHeadersProvider } from "./base-client${
3205
3285
  out += `export type { Insert${Type}, Update${Type}, Select${Type} } from "./types/${t.name}${ext}";
3206
3286
  `;
3207
3287
  }
3288
+ out += `
3289
+ // Shared types
3290
+ `;
3291
+ out += `export type { PaginatedResponse } from "./types/shared${ext}";
3292
+ `;
3208
3293
  return out;
3209
3294
  }
3210
3295
 
@@ -3845,14 +3930,12 @@ function emitAuth(cfgAuth) {
3845
3930
  const strategy = cfgAuth?.strategy ?? "none";
3846
3931
  const apiKeyHeader = cfgAuth?.apiKeyHeader ?? "x-api-key";
3847
3932
  const apiKeys = cfgAuth?.apiKeys ?? [];
3848
- const jwtShared = cfgAuth?.jwt?.sharedSecret ?? "";
3849
- const jwtIssuer = cfgAuth?.jwt?.issuer ?? undefined;
3933
+ const jwtServices = cfgAuth?.jwt?.services ?? [];
3850
3934
  const jwtAudience = cfgAuth?.jwt?.audience ?? undefined;
3851
3935
  const STRATEGY = JSON.stringify(strategy);
3852
3936
  const API_KEY_HEADER = JSON.stringify(apiKeyHeader);
3853
3937
  const RAW_API_KEYS = JSON.stringify(apiKeys);
3854
- const JWT_SHARED_SECRET = JSON.stringify(jwtShared);
3855
- const JWT_ISSUER = jwtIssuer === undefined ? "undefined" : JSON.stringify(jwtIssuer);
3938
+ const JWT_SERVICES = JSON.stringify(jwtServices);
3856
3939
  const JWT_AUDIENCE = jwtAudience === undefined ? "undefined" : JSON.stringify(jwtAudience);
3857
3940
  return `/**
3858
3941
  * AUTO-GENERATED FILE - DO NOT EDIT
@@ -3870,8 +3953,7 @@ const STRATEGY = ${STRATEGY} as "none" | "api-key" | "jwt-hs256";
3870
3953
  const API_KEY_HEADER = ${API_KEY_HEADER} as string;
3871
3954
  const RAW_API_KEYS = ${RAW_API_KEYS} as readonly string[];
3872
3955
 
3873
- const JWT_SHARED_SECRET = ${JWT_SHARED_SECRET} as string;
3874
- const JWT_ISSUER = ${JWT_ISSUER} as string | undefined;
3956
+ const JWT_SERVICES = ${JWT_SERVICES} as ReadonlyArray<{ issuer: string; secret: string }>;
3875
3957
  const JWT_AUDIENCE = ${JWT_AUDIENCE} as string | undefined;
3876
3958
  // -------------------------------------
3877
3959
 
@@ -3912,7 +3994,12 @@ function resolveKeys(keys: readonly string[]): string[] {
3912
3994
  }
3913
3995
 
3914
3996
  const API_KEYS = resolveKeys(RAW_API_KEYS);
3915
- const HS256_SECRET = resolveValue(JWT_SHARED_SECRET);
3997
+
3998
+ // Resolve JWT service secrets from env vars if needed
3999
+ const RESOLVED_JWT_SERVICES = JWT_SERVICES.map(svc => ({
4000
+ issuer: svc.issuer,
4001
+ secret: resolveValue(svc.secret)
4002
+ }));
3916
4003
 
3917
4004
  // Augment Hono context for DX
3918
4005
  declare module "hono" {
@@ -3956,21 +4043,43 @@ export async function authMiddleware(c: Context, next: Next) {
3956
4043
  }
3957
4044
  const token = m[1];
3958
4045
 
3959
- if (!HS256_SECRET) {
3960
- log.error("JWT strategy configured but JWT_SHARED_SECRET is empty");
4046
+ if (!RESOLVED_JWT_SERVICES.length) {
4047
+ log.error("JWT strategy configured but no services defined");
3961
4048
  return c.json({ error: "Unauthorized" }, 401);
3962
4049
  }
3963
4050
 
3964
4051
  // Lazy import 'jose' so projects not using JWT don't need it at runtime.
3965
4052
  try {
3966
- const { jwtVerify } = await import("jose");
3967
- const key = new TextEncoder().encode(HS256_SECRET);
3968
- log.debug("Verifying JWT with secret:", HS256_SECRET ? "present" : "missing");
3969
- log.debug("Expected issuer:", JWT_ISSUER);
4053
+ const { decodeJwt, jwtVerify } = await import("jose");
4054
+
4055
+ // Decode without verification to extract issuer claim
4056
+ const unverifiedPayload = decodeJwt(token);
4057
+ const issuer = unverifiedPayload.iss;
4058
+
4059
+ if (!issuer) {
4060
+ log.error("JWT missing required 'iss' (issuer) claim");
4061
+ return c.json({ error: "Unauthorized" }, 401);
4062
+ }
4063
+
4064
+ // Find matching service by issuer
4065
+ const service = RESOLVED_JWT_SERVICES.find(s => s.issuer === issuer);
4066
+ if (!service) {
4067
+ log.error("Unknown JWT issuer:", issuer);
4068
+ return c.json({ error: "Unauthorized" }, 401);
4069
+ }
4070
+
4071
+ if (!service.secret) {
4072
+ log.error("JWT service configured but secret is empty for issuer:", issuer);
4073
+ return c.json({ error: "Unauthorized" }, 401);
4074
+ }
4075
+
4076
+ // Verify JWT using the service's secret
4077
+ const key = new TextEncoder().encode(service.secret);
4078
+ log.debug("Verifying JWT from issuer:", issuer);
3970
4079
  log.debug("Expected audience:", JWT_AUDIENCE);
3971
-
4080
+
3972
4081
  const { payload } = await jwtVerify(token, key, {
3973
- issuer: JWT_ISSUER || undefined,
4082
+ issuer: issuer,
3974
4083
  audience: JWT_AUDIENCE || undefined,
3975
4084
  });
3976
4085
 
@@ -3980,7 +4089,7 @@ export async function authMiddleware(c: Context, next: Next) {
3980
4089
  sub: typeof payload.sub === "string" ? payload.sub : undefined,
3981
4090
  claims: payload as any,
3982
4091
  });
3983
- log.debug("JWT verified successfully");
4092
+ log.debug("JWT verified successfully for issuer:", issuer);
3984
4093
  return next();
3985
4094
  } catch (verifyError: any) {
3986
4095
  log.error("JWT verification failed:", verifyError?.message || verifyError);
@@ -4045,10 +4154,22 @@ ${hasAuth ? `export { authMiddleware } from "./auth${ext}";` : ""}
4045
4154
  * const pg = new Client({ connectionString: process.env.DATABASE_URL });
4046
4155
  * await pg.connect();
4047
4156
  *
4048
- * // OR using Neon driver (Edge-compatible)
4157
+ * // OR using Neon driver
4049
4158
  * import { Pool } from "@neondatabase/serverless";
4050
- * const pool = new Pool({ connectionString: process.env.DATABASE_URL! });
4051
- * const pg = pool; // Pool already has the compatible query method
4159
+ *
4160
+ * // For serverless (Vercel/Netlify) - one connection per instance
4161
+ * const pool = new Pool({
4162
+ * connectionString: process.env.DATABASE_URL!,
4163
+ * max: 1
4164
+ * });
4165
+ *
4166
+ * // For traditional servers - connection pooling
4167
+ * const pool = new Pool({
4168
+ * connectionString: process.env.DATABASE_URL!,
4169
+ * max: 10
4170
+ * });
4171
+ *
4172
+ * const pg = pool;
4052
4173
  *
4053
4174
  * // Mount all generated routes
4054
4175
  * const app = new Hono();
@@ -4080,21 +4201,21 @@ export function createRouter(
4080
4201
 
4081
4202
  // Register table routes
4082
4203
  ${registrations}
4083
-
4204
+
4084
4205
  // SDK distribution endpoints
4085
- router.get("/sdk/manifest", (c) => {
4206
+ router.get("/_psdk/sdk/manifest", (c) => {
4086
4207
  return c.json({
4087
4208
  version: SDK_MANIFEST.version,
4088
4209
  generated: SDK_MANIFEST.generated,
4089
4210
  files: Object.keys(SDK_MANIFEST.files)
4090
4211
  });
4091
4212
  });
4092
-
4093
- router.get("/sdk/download", (c) => {
4213
+
4214
+ router.get("/_psdk/sdk/download", (c) => {
4094
4215
  return c.json(SDK_MANIFEST);
4095
4216
  });
4096
-
4097
- router.get("/sdk/files/:path{.*}", (c) => {
4217
+
4218
+ router.get("/_psdk/sdk/files/:path{.*}", (c) => {
4098
4219
  const path = c.req.param("path");
4099
4220
  const content = SDK_MANIFEST.files[path as keyof typeof SDK_MANIFEST.files];
4100
4221
  if (!content) {
@@ -4104,25 +4225,25 @@ ${registrations}
4104
4225
  "Content-Type": "text/plain; charset=utf-8"
4105
4226
  });
4106
4227
  });
4107
-
4228
+
4108
4229
  // API Contract endpoints - describes the entire API
4109
- router.get("/api/contract", (c) => {
4230
+ router.get("/_psdk/contract", (c) => {
4110
4231
  const format = c.req.query("format") || "json";
4111
-
4232
+
4112
4233
  if (format === "markdown") {
4113
4234
  return c.text(getContract("markdown") as string, 200, {
4114
4235
  "Content-Type": "text/markdown; charset=utf-8"
4115
4236
  });
4116
4237
  }
4117
-
4238
+
4118
4239
  return c.json(getContract("json"));
4119
4240
  });
4120
-
4121
- router.get("/api/contract.json", (c) => {
4241
+
4242
+ router.get("/_psdk/contract.json", (c) => {
4122
4243
  return c.json(getContract("json"));
4123
4244
  });
4124
-
4125
- router.get("/api/contract.md", (c) => {
4245
+
4246
+ router.get("/_psdk/contract.md", (c) => {
4126
4247
  return c.text(getContract("markdown") as string, 200, {
4127
4248
  "Content-Type": "text/markdown; charset=utf-8"
4128
4249
  });
@@ -5378,17 +5499,10 @@ function normalizeAuthConfig(input) {
5378
5499
  };
5379
5500
  }
5380
5501
  if ("jwt" in input && input.jwt) {
5381
- if (typeof input.jwt === "string") {
5382
- return {
5383
- strategy: "jwt-hs256",
5384
- jwt: { sharedSecret: input.jwt }
5385
- };
5386
- } else {
5387
- return {
5388
- strategy: "jwt-hs256",
5389
- jwt: input.jwt
5390
- };
5391
- }
5502
+ return {
5503
+ strategy: "jwt-hs256",
5504
+ jwt: input.jwt
5505
+ };
5392
5506
  }
5393
5507
  return { strategy: "none" };
5394
5508
  }
@@ -5404,8 +5518,18 @@ async function generate(configPath) {
5404
5518
  const model = await introspect(cfg.connectionString, cfg.schema || "public");
5405
5519
  console.log("\uD83D\uDD17 Building relationship graph...");
5406
5520
  const graph = buildGraph(model);
5407
- const serverDir = cfg.outServer || "./api/server";
5408
- const originalClientDir = cfg.outClient || "./api/client";
5521
+ let serverDir;
5522
+ let originalClientDir;
5523
+ if (typeof cfg.outDir === "string") {
5524
+ serverDir = cfg.outDir;
5525
+ originalClientDir = cfg.outDir;
5526
+ } else if (cfg.outDir && typeof cfg.outDir === "object") {
5527
+ serverDir = cfg.outDir.server;
5528
+ originalClientDir = cfg.outDir.client;
5529
+ } else {
5530
+ serverDir = "./api/server";
5531
+ originalClientDir = "./api/client";
5532
+ }
5409
5533
  const sameDirectory = serverDir === originalClientDir;
5410
5534
  let clientDir = originalClientDir;
5411
5535
  if (sameDirectory) {
@@ -5439,6 +5563,7 @@ async function generate(configPath) {
5439
5563
  files.push({ path: join(serverDir, "include-spec.ts"), content: includeSpec });
5440
5564
  files.push({ path: join(clientDir, "include-spec.ts"), content: includeSpec });
5441
5565
  files.push({ path: join(clientDir, "params", "shared.ts"), content: emitSharedParamsZod() });
5566
+ files.push({ path: join(clientDir, "types", "shared.ts"), content: emitSharedTypes() });
5442
5567
  files.push({ path: join(clientDir, "base-client.ts"), content: emitBaseClient() });
5443
5568
  files.push({ path: join(clientDir, "where-types.ts"), content: emitWhereTypes() });
5444
5569
  files.push({
@@ -5475,7 +5600,8 @@ async function generate(configPath) {
5475
5600
  softDeleteColumn: cfg.softDeleteColumn || null,
5476
5601
  includeMethodsDepth: cfg.includeMethodsDepth || 2,
5477
5602
  authStrategy: normalizedAuth?.strategy,
5478
- useJsExtensions: cfg.useJsExtensions
5603
+ useJsExtensions: cfg.useJsExtensions,
5604
+ apiPathPrefix: cfg.apiPathPrefix || "/v1"
5479
5605
  });
5480
5606
  } else {
5481
5607
  throw new Error(`Framework "${serverFramework}" is not yet supported. Currently only "hono" is available.`);
@@ -5573,6 +5699,17 @@ async function generate(configPath) {
5573
5699
  console.log(` 2. Edit the script to configure your API server startup`);
5574
5700
  console.log(` 3. Run tests: ${testDir}/run-tests.sh`);
5575
5701
  }
5702
+ console.log(`
5703
+ \uD83D\uDCDA Usage:`);
5704
+ console.log(` Server (${serverFramework}):`);
5705
+ console.log(` import { createRouter } from "./${relative(process.cwd(), serverDir)}/router";`);
5706
+ console.log(` const api = createRouter({ pg });`);
5707
+ console.log(` app.route("/", api);`);
5708
+ console.log(`
5709
+ Client:`);
5710
+ console.log(` import { SDK } from "./${relative(process.cwd(), clientDir)}";`);
5711
+ console.log(` const sdk = new SDK({ baseUrl: "http://localhost:3000" });`);
5712
+ console.log(` const users = await sdk.users.list();`);
5576
5713
  }
5577
5714
 
5578
5715
  // src/cli.ts
@@ -8,4 +8,5 @@ export declare function emitHonoRoutes(table: Table, _graph: Graph, opts: {
8
8
  includeMethodsDepth: number;
9
9
  authStrategy?: string;
10
10
  useJsExtensions?: boolean;
11
+ apiPathPrefix: string;
11
12
  }): string;
@@ -0,0 +1 @@
1
+ export declare function emitSharedTypes(): string;
package/dist/index.js CHANGED
@@ -577,7 +577,7 @@ function generateIncludeMethods(table, graph, opts, allTables) {
577
577
  path: newPath,
578
578
  isMany: newIsMany,
579
579
  targets: newTargets,
580
- returnType: `{ data: (${buildReturnType(baseTableName, newPath, newIsMany, newTargets, graph)})[]; total: number; limit: number; offset: number; hasMore: boolean; }`,
580
+ returnType: `PaginatedResponse<${buildReturnType(baseTableName, newPath, newIsMany, newTargets, graph)}>`,
581
581
  includeSpec: buildIncludeSpec(newPath)
582
582
  });
583
583
  methods.push({
@@ -617,7 +617,7 @@ function generateIncludeMethods(table, graph, opts, allTables) {
617
617
  path: combinedPath,
618
618
  isMany: [edge1.kind === "many", edge2.kind === "many"],
619
619
  targets: [edge1.target, edge2.target],
620
- returnType: `{ data: (Select${pascal(baseTableName)} & { ${type1}; ${type2} })[]; total: number; limit: number; offset: number; hasMore: boolean; }`,
620
+ returnType: `PaginatedResponse<Select${pascal(baseTableName)} & { ${type1}; ${type2} }>`,
621
621
  includeSpec: { [key1]: true, [key2]: true }
622
622
  });
623
623
  methods.push({
@@ -784,7 +784,7 @@ function generateResourceWithSDK(table, model, graph, config) {
784
784
  const endpoints = [];
785
785
  sdkMethods.push({
786
786
  name: "list",
787
- signature: `list(params?: ListParams): Promise<{ data: ${Type}[]; total: number; limit: number; offset: number; hasMore: boolean; }>`,
787
+ signature: `list(params?: ListParams): Promise<PaginatedResponse<${Type}>>`,
788
788
  description: `List ${tableName} with filtering, sorting, and pagination. Returns paginated results with metadata.`,
789
789
  example: `// Get all ${tableName}
790
790
  const result = await sdk.${tableName}.list();
@@ -811,7 +811,7 @@ const currentPage = Math.floor(filtered.offset / filtered.limit) + 1;`,
811
811
  path: basePath,
812
812
  description: `List all ${tableName} records with pagination metadata`,
813
813
  queryParameters: generateQueryParams(table, enums),
814
- responseBody: `{ data: ${Type}[]; total: number; limit: number; offset: number; hasMore: boolean; }`
814
+ responseBody: `PaginatedResponse<${Type}>`
815
815
  });
816
816
  if (hasSinglePK) {
817
817
  sdkMethods.push({
@@ -1975,6 +1975,31 @@ export type PaginationParams = z.infer<typeof PaginationParamsSchema>;
1975
1975
  `;
1976
1976
  }
1977
1977
 
1978
+ // src/emit-shared-types.ts
1979
+ function emitSharedTypes() {
1980
+ return `/**
1981
+ * Shared types used across all SDK operations
1982
+ */
1983
+
1984
+ /**
1985
+ * Paginated response structure returned by list operations
1986
+ * @template T - The type of records in the data array
1987
+ */
1988
+ export interface PaginatedResponse<T> {
1989
+ /** Array of records for the current page */
1990
+ data: T[];
1991
+ /** Total number of records matching the query (across all pages) */
1992
+ total: number;
1993
+ /** Maximum number of records per page */
1994
+ limit: number;
1995
+ /** Number of records skipped (for pagination) */
1996
+ offset: number;
1997
+ /** Whether there are more records available after this page */
1998
+ hasMore: boolean;
1999
+ }
2000
+ `;
2001
+ }
2002
+
1978
2003
  // src/emit-routes-hono.ts
1979
2004
  init_utils();
1980
2005
  function emitHonoRoutes(table, _graph, opts) {
@@ -2026,7 +2051,7 @@ const listSchema = z.object({
2026
2051
  * @param deps.onRequest - Optional hook that runs before each request (for audit logging, RLS, etc.)
2027
2052
  */
2028
2053
  export function register${Type}Routes(app: Hono, deps: { pg: { query: (text: string, params?: any[]) => Promise<{ rows: any[] }> }, onRequest?: (c: Context, pg: { query: (text: string, params?: any[]) => Promise<{ rows: any[] }> }) => Promise<void> }) {
2029
- const base = "/v1/${fileTableName}";
2054
+ const base = "${opts.apiPathPrefix}/${fileTableName}";
2030
2055
 
2031
2056
  // Create operation context
2032
2057
  const ctx: coreOps.OperationContext = {
@@ -2243,7 +2268,7 @@ function emitClient(table, graph, opts, model) {
2243
2268
  * @returns The record with nested ${method.path.join(" and ")} if found, null otherwise
2244
2269
  */
2245
2270
  async ${method.name}(pk: ${pkType}): Promise<${method.returnType}> {
2246
- const results = await this.post<{ data: ${baseReturnType}[]; total: number; limit: number; offset: number; hasMore: boolean; }>(\`\${this.resource}/list\`, {
2271
+ const results = await this.post<PaginatedResponse<${baseReturnType}>>(\`\${this.resource}/list\`, {
2247
2272
  where: ${pkWhere},
2248
2273
  include: ${JSON.stringify(method.includeSpec)},
2249
2274
  limit: 1
@@ -2274,6 +2299,7 @@ function emitClient(table, graph, opts, model) {
2274
2299
  */
2275
2300
  import { BaseClient } from "./base-client${ext}";
2276
2301
  import type { Where } from "./where-types${ext}";
2302
+ import type { PaginatedResponse } from "./types/shared${ext}";
2277
2303
  ${typeImports}
2278
2304
  ${otherTableImports.join(`
2279
2305
  `)}
@@ -2321,20 +2347,8 @@ export class ${Type}Client extends BaseClient {
2321
2347
  where?: Where<Select${Type}>;
2322
2348
  orderBy?: string | string[];
2323
2349
  order?: "asc" | "desc" | ("asc" | "desc")[];
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 ?? {});
2350
+ }): Promise<PaginatedResponse<Select${Type}>> {
2351
+ return this.post<PaginatedResponse<Select${Type}>>(\`\${this.resource}/list\`, params ?? {});
2338
2352
  }
2339
2353
 
2340
2354
  /**
@@ -2445,6 +2459,11 @@ export type { AuthConfig, HeaderMap, AuthHeadersProvider } from "./base-client${
2445
2459
  out += `export type { Insert${Type}, Update${Type}, Select${Type} } from "./types/${t.name}${ext}";
2446
2460
  `;
2447
2461
  }
2462
+ out += `
2463
+ // Shared types
2464
+ `;
2465
+ out += `export type { PaginatedResponse } from "./types/shared${ext}";
2466
+ `;
2448
2467
  return out;
2449
2468
  }
2450
2469
 
@@ -3085,14 +3104,12 @@ function emitAuth(cfgAuth) {
3085
3104
  const strategy = cfgAuth?.strategy ?? "none";
3086
3105
  const apiKeyHeader = cfgAuth?.apiKeyHeader ?? "x-api-key";
3087
3106
  const apiKeys = cfgAuth?.apiKeys ?? [];
3088
- const jwtShared = cfgAuth?.jwt?.sharedSecret ?? "";
3089
- const jwtIssuer = cfgAuth?.jwt?.issuer ?? undefined;
3107
+ const jwtServices = cfgAuth?.jwt?.services ?? [];
3090
3108
  const jwtAudience = cfgAuth?.jwt?.audience ?? undefined;
3091
3109
  const STRATEGY = JSON.stringify(strategy);
3092
3110
  const API_KEY_HEADER = JSON.stringify(apiKeyHeader);
3093
3111
  const RAW_API_KEYS = JSON.stringify(apiKeys);
3094
- const JWT_SHARED_SECRET = JSON.stringify(jwtShared);
3095
- const JWT_ISSUER = jwtIssuer === undefined ? "undefined" : JSON.stringify(jwtIssuer);
3112
+ const JWT_SERVICES = JSON.stringify(jwtServices);
3096
3113
  const JWT_AUDIENCE = jwtAudience === undefined ? "undefined" : JSON.stringify(jwtAudience);
3097
3114
  return `/**
3098
3115
  * AUTO-GENERATED FILE - DO NOT EDIT
@@ -3110,8 +3127,7 @@ const STRATEGY = ${STRATEGY} as "none" | "api-key" | "jwt-hs256";
3110
3127
  const API_KEY_HEADER = ${API_KEY_HEADER} as string;
3111
3128
  const RAW_API_KEYS = ${RAW_API_KEYS} as readonly string[];
3112
3129
 
3113
- const JWT_SHARED_SECRET = ${JWT_SHARED_SECRET} as string;
3114
- const JWT_ISSUER = ${JWT_ISSUER} as string | undefined;
3130
+ const JWT_SERVICES = ${JWT_SERVICES} as ReadonlyArray<{ issuer: string; secret: string }>;
3115
3131
  const JWT_AUDIENCE = ${JWT_AUDIENCE} as string | undefined;
3116
3132
  // -------------------------------------
3117
3133
 
@@ -3152,7 +3168,12 @@ function resolveKeys(keys: readonly string[]): string[] {
3152
3168
  }
3153
3169
 
3154
3170
  const API_KEYS = resolveKeys(RAW_API_KEYS);
3155
- const HS256_SECRET = resolveValue(JWT_SHARED_SECRET);
3171
+
3172
+ // Resolve JWT service secrets from env vars if needed
3173
+ const RESOLVED_JWT_SERVICES = JWT_SERVICES.map(svc => ({
3174
+ issuer: svc.issuer,
3175
+ secret: resolveValue(svc.secret)
3176
+ }));
3156
3177
 
3157
3178
  // Augment Hono context for DX
3158
3179
  declare module "hono" {
@@ -3196,21 +3217,43 @@ export async function authMiddleware(c: Context, next: Next) {
3196
3217
  }
3197
3218
  const token = m[1];
3198
3219
 
3199
- if (!HS256_SECRET) {
3200
- log.error("JWT strategy configured but JWT_SHARED_SECRET is empty");
3220
+ if (!RESOLVED_JWT_SERVICES.length) {
3221
+ log.error("JWT strategy configured but no services defined");
3201
3222
  return c.json({ error: "Unauthorized" }, 401);
3202
3223
  }
3203
3224
 
3204
3225
  // Lazy import 'jose' so projects not using JWT don't need it at runtime.
3205
3226
  try {
3206
- const { jwtVerify } = await import("jose");
3207
- const key = new TextEncoder().encode(HS256_SECRET);
3208
- log.debug("Verifying JWT with secret:", HS256_SECRET ? "present" : "missing");
3209
- log.debug("Expected issuer:", JWT_ISSUER);
3227
+ const { decodeJwt, jwtVerify } = await import("jose");
3228
+
3229
+ // Decode without verification to extract issuer claim
3230
+ const unverifiedPayload = decodeJwt(token);
3231
+ const issuer = unverifiedPayload.iss;
3232
+
3233
+ if (!issuer) {
3234
+ log.error("JWT missing required 'iss' (issuer) claim");
3235
+ return c.json({ error: "Unauthorized" }, 401);
3236
+ }
3237
+
3238
+ // Find matching service by issuer
3239
+ const service = RESOLVED_JWT_SERVICES.find(s => s.issuer === issuer);
3240
+ if (!service) {
3241
+ log.error("Unknown JWT issuer:", issuer);
3242
+ return c.json({ error: "Unauthorized" }, 401);
3243
+ }
3244
+
3245
+ if (!service.secret) {
3246
+ log.error("JWT service configured but secret is empty for issuer:", issuer);
3247
+ return c.json({ error: "Unauthorized" }, 401);
3248
+ }
3249
+
3250
+ // Verify JWT using the service's secret
3251
+ const key = new TextEncoder().encode(service.secret);
3252
+ log.debug("Verifying JWT from issuer:", issuer);
3210
3253
  log.debug("Expected audience:", JWT_AUDIENCE);
3211
-
3254
+
3212
3255
  const { payload } = await jwtVerify(token, key, {
3213
- issuer: JWT_ISSUER || undefined,
3256
+ issuer: issuer,
3214
3257
  audience: JWT_AUDIENCE || undefined,
3215
3258
  });
3216
3259
 
@@ -3220,7 +3263,7 @@ export async function authMiddleware(c: Context, next: Next) {
3220
3263
  sub: typeof payload.sub === "string" ? payload.sub : undefined,
3221
3264
  claims: payload as any,
3222
3265
  });
3223
- log.debug("JWT verified successfully");
3266
+ log.debug("JWT verified successfully for issuer:", issuer);
3224
3267
  return next();
3225
3268
  } catch (verifyError: any) {
3226
3269
  log.error("JWT verification failed:", verifyError?.message || verifyError);
@@ -3285,10 +3328,22 @@ ${hasAuth ? `export { authMiddleware } from "./auth${ext}";` : ""}
3285
3328
  * const pg = new Client({ connectionString: process.env.DATABASE_URL });
3286
3329
  * await pg.connect();
3287
3330
  *
3288
- * // OR using Neon driver (Edge-compatible)
3331
+ * // OR using Neon driver
3289
3332
  * import { Pool } from "@neondatabase/serverless";
3290
- * const pool = new Pool({ connectionString: process.env.DATABASE_URL! });
3291
- * const pg = pool; // Pool already has the compatible query method
3333
+ *
3334
+ * // For serverless (Vercel/Netlify) - one connection per instance
3335
+ * const pool = new Pool({
3336
+ * connectionString: process.env.DATABASE_URL!,
3337
+ * max: 1
3338
+ * });
3339
+ *
3340
+ * // For traditional servers - connection pooling
3341
+ * const pool = new Pool({
3342
+ * connectionString: process.env.DATABASE_URL!,
3343
+ * max: 10
3344
+ * });
3345
+ *
3346
+ * const pg = pool;
3292
3347
  *
3293
3348
  * // Mount all generated routes
3294
3349
  * const app = new Hono();
@@ -3320,21 +3375,21 @@ export function createRouter(
3320
3375
 
3321
3376
  // Register table routes
3322
3377
  ${registrations}
3323
-
3378
+
3324
3379
  // SDK distribution endpoints
3325
- router.get("/sdk/manifest", (c) => {
3380
+ router.get("/_psdk/sdk/manifest", (c) => {
3326
3381
  return c.json({
3327
3382
  version: SDK_MANIFEST.version,
3328
3383
  generated: SDK_MANIFEST.generated,
3329
3384
  files: Object.keys(SDK_MANIFEST.files)
3330
3385
  });
3331
3386
  });
3332
-
3333
- router.get("/sdk/download", (c) => {
3387
+
3388
+ router.get("/_psdk/sdk/download", (c) => {
3334
3389
  return c.json(SDK_MANIFEST);
3335
3390
  });
3336
-
3337
- router.get("/sdk/files/:path{.*}", (c) => {
3391
+
3392
+ router.get("/_psdk/sdk/files/:path{.*}", (c) => {
3338
3393
  const path = c.req.param("path");
3339
3394
  const content = SDK_MANIFEST.files[path as keyof typeof SDK_MANIFEST.files];
3340
3395
  if (!content) {
@@ -3344,25 +3399,25 @@ ${registrations}
3344
3399
  "Content-Type": "text/plain; charset=utf-8"
3345
3400
  });
3346
3401
  });
3347
-
3402
+
3348
3403
  // API Contract endpoints - describes the entire API
3349
- router.get("/api/contract", (c) => {
3404
+ router.get("/_psdk/contract", (c) => {
3350
3405
  const format = c.req.query("format") || "json";
3351
-
3406
+
3352
3407
  if (format === "markdown") {
3353
3408
  return c.text(getContract("markdown") as string, 200, {
3354
3409
  "Content-Type": "text/markdown; charset=utf-8"
3355
3410
  });
3356
3411
  }
3357
-
3412
+
3358
3413
  return c.json(getContract("json"));
3359
3414
  });
3360
-
3361
- router.get("/api/contract.json", (c) => {
3415
+
3416
+ router.get("/_psdk/contract.json", (c) => {
3362
3417
  return c.json(getContract("json"));
3363
3418
  });
3364
-
3365
- router.get("/api/contract.md", (c) => {
3419
+
3420
+ router.get("/_psdk/contract.md", (c) => {
3366
3421
  return c.text(getContract("markdown") as string, 200, {
3367
3422
  "Content-Type": "text/markdown; charset=utf-8"
3368
3423
  });
@@ -4618,17 +4673,10 @@ function normalizeAuthConfig(input) {
4618
4673
  };
4619
4674
  }
4620
4675
  if ("jwt" in input && input.jwt) {
4621
- if (typeof input.jwt === "string") {
4622
- return {
4623
- strategy: "jwt-hs256",
4624
- jwt: { sharedSecret: input.jwt }
4625
- };
4626
- } else {
4627
- return {
4628
- strategy: "jwt-hs256",
4629
- jwt: input.jwt
4630
- };
4631
- }
4676
+ return {
4677
+ strategy: "jwt-hs256",
4678
+ jwt: input.jwt
4679
+ };
4632
4680
  }
4633
4681
  return { strategy: "none" };
4634
4682
  }
@@ -4644,8 +4692,18 @@ async function generate(configPath) {
4644
4692
  const model = await introspect(cfg.connectionString, cfg.schema || "public");
4645
4693
  console.log("\uD83D\uDD17 Building relationship graph...");
4646
4694
  const graph = buildGraph(model);
4647
- const serverDir = cfg.outServer || "./api/server";
4648
- const originalClientDir = cfg.outClient || "./api/client";
4695
+ let serverDir;
4696
+ let originalClientDir;
4697
+ if (typeof cfg.outDir === "string") {
4698
+ serverDir = cfg.outDir;
4699
+ originalClientDir = cfg.outDir;
4700
+ } else if (cfg.outDir && typeof cfg.outDir === "object") {
4701
+ serverDir = cfg.outDir.server;
4702
+ originalClientDir = cfg.outDir.client;
4703
+ } else {
4704
+ serverDir = "./api/server";
4705
+ originalClientDir = "./api/client";
4706
+ }
4649
4707
  const sameDirectory = serverDir === originalClientDir;
4650
4708
  let clientDir = originalClientDir;
4651
4709
  if (sameDirectory) {
@@ -4679,6 +4737,7 @@ async function generate(configPath) {
4679
4737
  files.push({ path: join(serverDir, "include-spec.ts"), content: includeSpec });
4680
4738
  files.push({ path: join(clientDir, "include-spec.ts"), content: includeSpec });
4681
4739
  files.push({ path: join(clientDir, "params", "shared.ts"), content: emitSharedParamsZod() });
4740
+ files.push({ path: join(clientDir, "types", "shared.ts"), content: emitSharedTypes() });
4682
4741
  files.push({ path: join(clientDir, "base-client.ts"), content: emitBaseClient() });
4683
4742
  files.push({ path: join(clientDir, "where-types.ts"), content: emitWhereTypes() });
4684
4743
  files.push({
@@ -4715,7 +4774,8 @@ async function generate(configPath) {
4715
4774
  softDeleteColumn: cfg.softDeleteColumn || null,
4716
4775
  includeMethodsDepth: cfg.includeMethodsDepth || 2,
4717
4776
  authStrategy: normalizedAuth?.strategy,
4718
- useJsExtensions: cfg.useJsExtensions
4777
+ useJsExtensions: cfg.useJsExtensions,
4778
+ apiPathPrefix: cfg.apiPathPrefix || "/v1"
4719
4779
  });
4720
4780
  } else {
4721
4781
  throw new Error(`Framework "${serverFramework}" is not yet supported. Currently only "hono" is available.`);
@@ -4813,6 +4873,17 @@ async function generate(configPath) {
4813
4873
  console.log(` 2. Edit the script to configure your API server startup`);
4814
4874
  console.log(` 3. Run tests: ${testDir}/run-tests.sh`);
4815
4875
  }
4876
+ console.log(`
4877
+ \uD83D\uDCDA Usage:`);
4878
+ console.log(` Server (${serverFramework}):`);
4879
+ console.log(` import { createRouter } from "./${relative(process.cwd(), serverDir)}/router";`);
4880
+ console.log(` const api = createRouter({ pg });`);
4881
+ console.log(` app.route("/", api);`);
4882
+ console.log(`
4883
+ Client:`);
4884
+ console.log(` import { SDK } from "./${relative(process.cwd(), clientDir)}";`);
4885
+ console.log(` const sdk = new SDK({ baseUrl: "http://localhost:3000" });`);
4886
+ console.log(` const users = await sdk.users.list();`);
4816
4887
  }
4817
4888
  export {
4818
4889
  generate
package/dist/types.d.ts CHANGED
@@ -3,8 +3,10 @@ export interface AuthConfig {
3
3
  apiKeyHeader?: string;
4
4
  apiKeys?: string[];
5
5
  jwt?: {
6
- sharedSecret?: string;
7
- issuer?: string;
6
+ services: Array<{
7
+ issuer: string;
8
+ secret: string;
9
+ }>;
8
10
  audience?: string;
9
11
  };
10
12
  }
@@ -12,22 +14,20 @@ export type AuthConfigInput = AuthConfig | {
12
14
  apiKey?: string;
13
15
  apiKeys?: string[];
14
16
  apiKeyHeader?: string;
15
- jwt?: string | {
16
- sharedSecret?: string;
17
- issuer?: string;
18
- audience?: string;
19
- };
20
17
  };
21
18
  export interface Config {
22
19
  connectionString: string;
23
20
  schema?: string;
24
- outServer?: string;
25
- outClient?: string;
21
+ outDir?: string | {
22
+ client: string;
23
+ server: string;
24
+ };
26
25
  softDeleteColumn?: string | null;
27
26
  dateType?: "date" | "string";
28
27
  includeMethodsDepth?: number;
29
28
  skipJunctionTables?: boolean;
30
29
  serverFramework?: "hono" | "express" | "fastify";
30
+ apiPathPrefix?: string;
31
31
  auth?: AuthConfigInput;
32
32
  pull?: PullConfig;
33
33
  useJsExtensions?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresdk",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "Generate a typed server/client SDK from a Postgres schema (includes, Zod, Hono).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,14 +45,14 @@
45
45
  "zod": "^4.0.15"
46
46
  },
47
47
  "devDependencies": {
48
+ "@hono/node-server": "^1.19.7",
48
49
  "@types/bun": "^1.2.20",
49
50
  "@types/node": "^20.0.0",
50
51
  "@types/pg": "^8.15.5",
51
52
  "drizzle-kit": "^0.31.4",
52
53
  "drizzle-orm": "^0.44.4",
53
54
  "jose": "^6.0.12",
54
- "typescript": "^5.5.0",
55
- "vitest": "^3.2.4"
55
+ "typescript": "^5.5.0"
56
56
  },
57
57
  "author": "Ben Honda <ben@theadpharm.com>",
58
58
  "license": "MIT",