postgresdk 0.6.5 → 0.6.6

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/dist/cli.js CHANGED
@@ -2006,6 +2006,7 @@ function emitHonoRouter(tables, hasAuth, useJsExtensions) {
2006
2006
  return `/* Generated. Do not edit. */
2007
2007
  import { Hono } from "hono";
2008
2008
  import { SDK_MANIFEST } from "./sdk-bundle${ext}";
2009
+ import { getApiContract } from "./api-contract${ext}";
2009
2010
  ${imports}
2010
2011
  ${hasAuth ? `export { authMiddleware } from "./auth${ext}";` : ""}
2011
2012
 
@@ -2067,6 +2068,29 @@ ${registrations}
2067
2068
  });
2068
2069
  });
2069
2070
 
2071
+ // API Contract endpoints - describes the entire API
2072
+ router.get("/api/contract", (c) => {
2073
+ const format = c.req.query("format") || "json";
2074
+
2075
+ if (format === "markdown") {
2076
+ return c.text(getApiContract("markdown") as string, 200, {
2077
+ "Content-Type": "text/markdown; charset=utf-8"
2078
+ });
2079
+ }
2080
+
2081
+ return c.json(getApiContract("json"));
2082
+ });
2083
+
2084
+ router.get("/api/contract.json", (c) => {
2085
+ return c.json(getApiContract("json"));
2086
+ });
2087
+
2088
+ router.get("/api/contract.md", (c) => {
2089
+ return c.text(getApiContract("markdown") as string, 200, {
2090
+ "Content-Type": "text/markdown; charset=utf-8"
2091
+ });
2092
+ });
2093
+
2070
2094
  return router;
2071
2095
  }
2072
2096
 
@@ -2344,11 +2368,14 @@ function emitTableTest(table, clientPath, framework = "vitest") {
2344
2368
  const Type = pascal(table.name);
2345
2369
  const tableName = table.name;
2346
2370
  const imports = getFrameworkImports(framework);
2347
- const sampleData = generateSampleData(table);
2371
+ const hasForeignKeys = table.fks.length > 0;
2372
+ const foreignKeySetup = hasForeignKeys ? generateForeignKeySetup(table, clientPath) : null;
2373
+ const sampleData = generateSampleData(table, hasForeignKeys);
2348
2374
  const updateData = generateUpdateData(table);
2349
2375
  return `${imports}
2350
2376
  import { SDK } from '${clientPath}';
2351
2377
  import type { Insert${Type}, Update${Type}, Select${Type} } from '${clientPath}/types/${tableName}';
2378
+ ${foreignKeySetup?.imports || ""}
2352
2379
 
2353
2380
  /**
2354
2381
  * Basic tests for ${tableName} table operations
@@ -2364,15 +2391,21 @@ import type { Insert${Type}, Update${Type}, Select${Type} } from '${clientPath}/
2364
2391
  describe('${Type} SDK Operations', () => {
2365
2392
  let sdk: SDK;
2366
2393
  let createdId: string;
2394
+ ${foreignKeySetup?.variables || ""}
2367
2395
 
2368
- beforeAll(() => {
2396
+ beforeAll(async () => {
2369
2397
  sdk = new SDK({
2370
2398
  baseUrl: process.env.API_URL || 'http://localhost:3000',
2371
2399
  auth: process.env.API_KEY ? { apiKey: process.env.API_KEY } : undefined
2372
2400
  });
2401
+ ${foreignKeySetup?.setup || ""}
2402
+ });
2403
+
2404
+ ${hasForeignKeys && foreignKeySetup?.cleanup ? `afterAll(async () => {
2405
+ ${foreignKeySetup.cleanup}
2373
2406
  });
2374
2407
 
2375
- ${generateTestCases(table, sampleData, updateData)}
2408
+ ` : ""}${generateTestCases(table, sampleData, updateData, hasForeignKeys)}
2376
2409
  });
2377
2410
  `;
2378
2411
  }
@@ -2432,6 +2465,13 @@ export default defineConfig({
2432
2465
  testTimeout: 30000,
2433
2466
  hookTimeout: 30000,
2434
2467
  // The reporters are configured via CLI in the test script
2468
+ // Force color output in terminal
2469
+ pool: 'forks',
2470
+ poolOptions: {
2471
+ forks: {
2472
+ singleFork: true,
2473
+ }
2474
+ }
2435
2475
  },
2436
2476
  });
2437
2477
  `;
@@ -2512,6 +2552,10 @@ docker-compose up -d --wait
2512
2552
  export TEST_DATABASE_URL="postgres://testuser:testpass@localhost:5432/testdb"
2513
2553
  export TEST_API_URL="http://localhost:3000"
2514
2554
 
2555
+ # Force color output for better terminal experience
2556
+ export FORCE_COLOR=1
2557
+ export CI=""
2558
+
2515
2559
  # Wait for database to be ready
2516
2560
  echo "⏳ Waiting for database..."
2517
2561
  sleep 3
@@ -2570,16 +2614,67 @@ echo " cd $SCRIPT_DIR && docker-compose down"
2570
2614
  exit $TEST_EXIT_CODE
2571
2615
  `;
2572
2616
  }
2617
+ function generateForeignKeySetup(table, clientPath) {
2618
+ const imports = [];
2619
+ const variables = [];
2620
+ const setupStatements = [];
2621
+ const cleanupStatements = [];
2622
+ const foreignTables = new Set;
2623
+ for (const fk of table.fks) {
2624
+ const foreignTable = fk.toTable;
2625
+ if (!foreignTables.has(foreignTable)) {
2626
+ foreignTables.add(foreignTable);
2627
+ const ForeignType = pascal(foreignTable);
2628
+ imports.push(`import type { Insert${ForeignType} } from '${clientPath}/types/${foreignTable}';`);
2629
+ variables.push(`let ${foreignTable}Id: string;`);
2630
+ setupStatements.push(`
2631
+ // Create parent ${foreignTable} record for foreign key reference
2632
+ const ${foreignTable}Data = ${generateMinimalSampleData(foreignTable)};
2633
+ const created${ForeignType} = await sdk.${foreignTable}.create(${foreignTable}Data);
2634
+ ${foreignTable}Id = created${ForeignType}.id;`);
2635
+ cleanupStatements.push(`
2636
+ // Clean up parent ${foreignTable} record
2637
+ if (${foreignTable}Id) {
2638
+ try {
2639
+ await sdk.${foreignTable}.delete(${foreignTable}Id);
2640
+ } catch (e) {
2641
+ // Parent might already be deleted due to cascading
2642
+ }
2643
+ }`);
2644
+ }
2645
+ }
2646
+ return {
2647
+ imports: imports.join(`
2648
+ `),
2649
+ variables: variables.join(`
2650
+ `),
2651
+ setup: setupStatements.join(""),
2652
+ cleanup: cleanupStatements.join("")
2653
+ };
2654
+ }
2655
+ function generateMinimalSampleData(tableName) {
2656
+ const commonPatterns = {
2657
+ contacts: `{ name: 'Test Contact', email: 'test@example.com', gender: 'M', date_of_birth: new Date('1990-01-01'), emergency_contact: 'Emergency Contact', clinic_location: 'Main Clinic', specialty: 'General', fmv_tiering: 'Standard', flight_preferences: 'Economy', dietary_restrictions: [], special_accommodations: 'None' }`,
2658
+ tags: `{ name: 'Test Tag' }`,
2659
+ authors: `{ name: 'Test Author' }`,
2660
+ books: `{ title: 'Test Book' }`,
2661
+ users: `{ name: 'Test User', email: 'test@example.com' }`,
2662
+ categories: `{ name: 'Test Category' }`,
2663
+ products: `{ name: 'Test Product', price: 10.99 }`,
2664
+ orders: `{ total: 100.00 }`
2665
+ };
2666
+ return commonPatterns[tableName] || `{ name: 'Test ${pascal(tableName)}' }`;
2667
+ }
2573
2668
  function getTestCommand(framework, baseCommand) {
2574
2669
  switch (framework) {
2575
2670
  case "vitest":
2576
- return `${baseCommand} --reporter=default --reporter=json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
2671
+ return `FORCE_COLOR=1 ${baseCommand} --reporter=default --reporter=json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
2577
2672
  case "jest":
2578
- return `${baseCommand} --json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
2673
+ return `FORCE_COLOR=1 ${baseCommand} --colors --json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
2579
2674
  case "bun":
2580
- return `${baseCommand} "$@" 2>&1 | tee "$TEST_RESULTS_DIR/results-\${TIMESTAMP}.txt"`;
2675
+ return `FORCE_COLOR=1 ${baseCommand} "$@" 2>&1 | tee "$TEST_RESULTS_DIR/results-\${TIMESTAMP}.txt"`;
2581
2676
  default:
2582
- return `${baseCommand} "$@"`;
2677
+ return `FORCE_COLOR=1 ${baseCommand} "$@"`;
2583
2678
  }
2584
2679
  }
2585
2680
  function getFrameworkImports(framework) {
@@ -2594,8 +2689,16 @@ function getFrameworkImports(framework) {
2594
2689
  return "import { describe, it, expect, beforeAll, afterAll } from 'vitest';";
2595
2690
  }
2596
2691
  }
2597
- function generateSampleData(table) {
2692
+ function generateSampleData(table, hasForeignKeys = false) {
2598
2693
  const fields = [];
2694
+ const foreignKeyColumns = new Map;
2695
+ if (hasForeignKeys) {
2696
+ for (const fk of table.fks) {
2697
+ if (fk.from.length === 1 && fk.from[0]) {
2698
+ foreignKeyColumns.set(fk.from[0], fk.toTable);
2699
+ }
2700
+ }
2701
+ }
2599
2702
  for (const col of table.columns) {
2600
2703
  if (col.name === "id" && col.hasDefault) {
2601
2704
  continue;
@@ -2606,10 +2709,17 @@ function generateSampleData(table) {
2606
2709
  if (col.name === "deleted_at") {
2607
2710
  continue;
2608
2711
  }
2609
- const isImportant = col.name.endsWith("_id") || col.name.endsWith("_by") || col.name.includes("email") || col.name.includes("name") || col.name.includes("phone") || col.name.includes("address") || col.name.includes("description") || col.name.includes("color") || col.name.includes("type") || col.name.includes("status") || col.name.includes("subject");
2610
- if (!col.nullable || isImportant) {
2611
- const value = getSampleValue(col.pgType, col.name);
2712
+ if (!col.nullable) {
2713
+ const foreignTable = foreignKeyColumns.get(col.name);
2714
+ const value = foreignTable ? `${foreignTable}Id` : getSampleValue(col.pgType, col.name);
2612
2715
  fields.push(` ${col.name}: ${value}`);
2716
+ } else {
2717
+ const isImportant = col.name.endsWith("_id") || col.name.endsWith("_by") || col.name.includes("email") || col.name.includes("name") || col.name.includes("phone") || col.name.includes("address") || col.name.includes("description") || col.name.includes("color") || col.name.includes("type") || col.name.includes("status") || col.name.includes("subject");
2718
+ if (isImportant) {
2719
+ const foreignTable = foreignKeyColumns.get(col.name);
2720
+ const value = foreignTable ? `${foreignTable}Id` : getSampleValue(col.pgType, col.name);
2721
+ fields.push(` ${col.name}: ${value}`);
2722
+ }
2613
2723
  }
2614
2724
  }
2615
2725
  return fields.length > 0 ? `{
@@ -2681,6 +2791,18 @@ function getSampleValue(type, name, isUpdate = false) {
2681
2791
  if (name.includes("tier")) {
2682
2792
  return `'Standard'`;
2683
2793
  }
2794
+ if (name.includes("emergency")) {
2795
+ return `'Emergency Contact ${isUpdate ? "Updated" : "Name"}'`;
2796
+ }
2797
+ if (name === "date_of_birth" || name.includes("birth")) {
2798
+ return `new Date('1990-01-01')`;
2799
+ }
2800
+ if (name.includes("accommodations")) {
2801
+ return `'No special accommodations'`;
2802
+ }
2803
+ if (name.includes("flight")) {
2804
+ return `'Economy'`;
2805
+ }
2684
2806
  switch (type) {
2685
2807
  case "text":
2686
2808
  case "varchar":
@@ -2704,7 +2826,7 @@ function getSampleValue(type, name, isUpdate = false) {
2704
2826
  return `'2024-01-01'`;
2705
2827
  case "timestamp":
2706
2828
  case "timestamptz":
2707
- return `new Date().toISOString()`;
2829
+ return `new Date()`;
2708
2830
  case "json":
2709
2831
  case "jsonb":
2710
2832
  return `{ key: 'value' }`;
@@ -2717,7 +2839,7 @@ function getSampleValue(type, name, isUpdate = false) {
2717
2839
  return `'test'`;
2718
2840
  }
2719
2841
  }
2720
- function generateTestCases(table, sampleData, updateData) {
2842
+ function generateTestCases(table, sampleData, updateData, hasForeignKeys = false) {
2721
2843
  const Type = pascal(table.name);
2722
2844
  const hasData = sampleData !== "{}";
2723
2845
  return `it('should create a ${table.name}', async () => {
@@ -2776,6 +2898,280 @@ function generateTestCases(table, sampleData, updateData) {
2776
2898
  });` : ""}`;
2777
2899
  }
2778
2900
 
2901
+ // src/emit-api-contract.ts
2902
+ function generateApiContract(model, config) {
2903
+ const resources = [];
2904
+ const relationships = [];
2905
+ for (const table of Object.values(model.tables)) {
2906
+ resources.push(generateResourceContract(table, model));
2907
+ for (const fk of table.fks) {
2908
+ relationships.push({
2909
+ from: table.name,
2910
+ to: fk.toTable,
2911
+ type: "many-to-one",
2912
+ description: `Each ${table.name} belongs to one ${fk.toTable}`
2913
+ });
2914
+ }
2915
+ }
2916
+ const contract = {
2917
+ version: "1.0.0",
2918
+ generatedAt: new Date().toISOString(),
2919
+ description: "Auto-generated API contract describing all available endpoints, resources, and their relationships",
2920
+ resources,
2921
+ relationships
2922
+ };
2923
+ if (config.auth?.strategy && config.auth.strategy !== "none") {
2924
+ contract.authentication = {
2925
+ type: config.auth.strategy,
2926
+ description: getAuthDescription(config.auth.strategy)
2927
+ };
2928
+ }
2929
+ return contract;
2930
+ }
2931
+ function generateResourceContract(table, model) {
2932
+ const Type = pascal(table.name);
2933
+ const basePath = `/v1/${table.name}`;
2934
+ const endpoints = [
2935
+ {
2936
+ method: "GET",
2937
+ path: basePath,
2938
+ description: `List all ${table.name} records with optional filtering, sorting, and pagination`,
2939
+ queryParameters: {
2940
+ limit: "number - Maximum number of records to return (default: 50)",
2941
+ offset: "number - Number of records to skip for pagination",
2942
+ order_by: "string - Field to sort by",
2943
+ order_dir: "string - Sort direction (asc or desc)",
2944
+ include: "string - Comma-separated list of related resources to include",
2945
+ ...generateFilterParams(table)
2946
+ },
2947
+ responseBody: `Array<${Type}>`
2948
+ },
2949
+ {
2950
+ method: "GET",
2951
+ path: `${basePath}/:id`,
2952
+ description: `Get a single ${table.name} record by ID`,
2953
+ queryParameters: {
2954
+ include: "string - Comma-separated list of related resources to include"
2955
+ },
2956
+ responseBody: `${Type}`
2957
+ },
2958
+ {
2959
+ method: "POST",
2960
+ path: basePath,
2961
+ description: `Create a new ${table.name} record`,
2962
+ requestBody: `Insert${Type}`,
2963
+ responseBody: `${Type}`
2964
+ },
2965
+ {
2966
+ method: "PATCH",
2967
+ path: `${basePath}/:id`,
2968
+ description: `Update an existing ${table.name} record`,
2969
+ requestBody: `Update${Type}`,
2970
+ responseBody: `${Type}`
2971
+ },
2972
+ {
2973
+ method: "DELETE",
2974
+ path: `${basePath}/:id`,
2975
+ description: `Delete a ${table.name} record`,
2976
+ responseBody: `${Type}`
2977
+ }
2978
+ ];
2979
+ const fields = table.columns.map((col) => generateFieldContract(col, table));
2980
+ return {
2981
+ name: Type,
2982
+ tableName: table.name,
2983
+ description: `Resource for managing ${table.name} records`,
2984
+ endpoints,
2985
+ fields
2986
+ };
2987
+ }
2988
+ function generateFieldContract(column, table) {
2989
+ const field = {
2990
+ name: column.name,
2991
+ type: postgresTypeToJsonType(column.pgType),
2992
+ required: !column.nullable && !column.hasDefault,
2993
+ description: generateFieldDescription(column, table)
2994
+ };
2995
+ const fk = table.fks.find((fk2) => fk2.from.length === 1 && fk2.from[0] === column.name);
2996
+ if (fk) {
2997
+ field.foreignKey = {
2998
+ table: fk.toTable,
2999
+ field: fk.to[0] || "id"
3000
+ };
3001
+ }
3002
+ return field;
3003
+ }
3004
+ function generateFieldDescription(column, table) {
3005
+ const descriptions = [];
3006
+ descriptions.push(`${postgresTypeToJsonType(column.pgType)} field`);
3007
+ if (column.name === "id") {
3008
+ descriptions.push("Unique identifier");
3009
+ } else if (column.name === "created_at") {
3010
+ descriptions.push("Timestamp when the record was created");
3011
+ } else if (column.name === "updated_at") {
3012
+ descriptions.push("Timestamp when the record was last updated");
3013
+ } else if (column.name === "deleted_at") {
3014
+ descriptions.push("Soft delete timestamp");
3015
+ } else if (column.name.endsWith("_id")) {
3016
+ const relatedTable = column.name.slice(0, -3);
3017
+ descriptions.push(`Reference to ${relatedTable}`);
3018
+ } else if (column.name.includes("email")) {
3019
+ descriptions.push("Email address");
3020
+ } else if (column.name.includes("phone")) {
3021
+ descriptions.push("Phone number");
3022
+ } else if (column.name.includes("name")) {
3023
+ descriptions.push("Name field");
3024
+ } else if (column.name.includes("description")) {
3025
+ descriptions.push("Description text");
3026
+ } else if (column.name.includes("status")) {
3027
+ descriptions.push("Status indicator");
3028
+ } else if (column.name.includes("price") || column.name.includes("amount") || column.name.includes("total")) {
3029
+ descriptions.push("Monetary value");
3030
+ }
3031
+ if (!column.nullable && !column.hasDefault) {
3032
+ descriptions.push("(required)");
3033
+ } else if (column.nullable) {
3034
+ descriptions.push("(optional)");
3035
+ }
3036
+ return descriptions.join(" - ");
3037
+ }
3038
+ function postgresTypeToJsonType(pgType) {
3039
+ switch (pgType) {
3040
+ case "int":
3041
+ case "integer":
3042
+ case "smallint":
3043
+ case "bigint":
3044
+ case "decimal":
3045
+ case "numeric":
3046
+ case "real":
3047
+ case "double precision":
3048
+ case "float":
3049
+ return "number";
3050
+ case "boolean":
3051
+ case "bool":
3052
+ return "boolean";
3053
+ case "date":
3054
+ case "timestamp":
3055
+ case "timestamptz":
3056
+ return "date/datetime";
3057
+ case "json":
3058
+ case "jsonb":
3059
+ return "object";
3060
+ case "uuid":
3061
+ return "uuid";
3062
+ case "text[]":
3063
+ case "varchar[]":
3064
+ return "array<string>";
3065
+ case "int[]":
3066
+ case "integer[]":
3067
+ return "array<number>";
3068
+ default:
3069
+ return "string";
3070
+ }
3071
+ }
3072
+ function generateFilterParams(table) {
3073
+ const filters = {};
3074
+ for (const col of table.columns) {
3075
+ const type = postgresTypeToJsonType(col.pgType);
3076
+ filters[col.name] = `${type} - Filter by exact ${col.name} value`;
3077
+ if (type === "number" || type === "date/datetime") {
3078
+ filters[`${col.name}_gt`] = `${type} - Filter where ${col.name} is greater than`;
3079
+ filters[`${col.name}_gte`] = `${type} - Filter where ${col.name} is greater than or equal`;
3080
+ filters[`${col.name}_lt`] = `${type} - Filter where ${col.name} is less than`;
3081
+ filters[`${col.name}_lte`] = `${type} - Filter where ${col.name} is less than or equal`;
3082
+ }
3083
+ if (type === "string") {
3084
+ filters[`${col.name}_like`] = `string - Filter where ${col.name} contains text (case-insensitive)`;
3085
+ }
3086
+ }
3087
+ return filters;
3088
+ }
3089
+ function getAuthDescription(strategy) {
3090
+ switch (strategy) {
3091
+ case "jwt":
3092
+ return "JWT Bearer token authentication. Include token in Authorization header: 'Bearer <token>'";
3093
+ case "apiKey":
3094
+ return "API Key authentication. Include key in the configured header (e.g., 'x-api-key')";
3095
+ default:
3096
+ return "Custom authentication strategy";
3097
+ }
3098
+ }
3099
+ function generateApiContractMarkdown(contract) {
3100
+ const lines = [];
3101
+ lines.push("# API Contract");
3102
+ lines.push("");
3103
+ lines.push(contract.description);
3104
+ lines.push("");
3105
+ lines.push(`**Version:** ${contract.version}`);
3106
+ lines.push(`**Generated:** ${new Date(contract.generatedAt).toLocaleString()}`);
3107
+ lines.push("");
3108
+ if (contract.authentication) {
3109
+ lines.push("## Authentication");
3110
+ lines.push("");
3111
+ lines.push(`**Type:** ${contract.authentication.type}`);
3112
+ lines.push("");
3113
+ lines.push(contract.authentication.description);
3114
+ lines.push("");
3115
+ }
3116
+ lines.push("## Resources");
3117
+ lines.push("");
3118
+ for (const resource of contract.resources) {
3119
+ lines.push(`### ${resource.name}`);
3120
+ lines.push("");
3121
+ lines.push(resource.description);
3122
+ lines.push("");
3123
+ lines.push("**Endpoints:**");
3124
+ lines.push("");
3125
+ for (const endpoint of resource.endpoints) {
3126
+ lines.push(`- \`${endpoint.method} ${endpoint.path}\` - ${endpoint.description}`);
3127
+ }
3128
+ lines.push("");
3129
+ lines.push("**Fields:**");
3130
+ lines.push("");
3131
+ for (const field of resource.fields) {
3132
+ const required = field.required ? " *(required)*" : "";
3133
+ const fk = field.foreignKey ? ` → ${field.foreignKey.table}` : "";
3134
+ lines.push(`- \`${field.name}\` (${field.type})${required}${fk} - ${field.description}`);
3135
+ }
3136
+ lines.push("");
3137
+ }
3138
+ if (contract.relationships.length > 0) {
3139
+ lines.push("## Relationships");
3140
+ lines.push("");
3141
+ for (const rel of contract.relationships) {
3142
+ lines.push(`- **${rel.from}** → **${rel.to}** (${rel.type}): ${rel.description}`);
3143
+ }
3144
+ lines.push("");
3145
+ }
3146
+ return lines.join(`
3147
+ `);
3148
+ }
3149
+ function emitApiContract(model, config) {
3150
+ const contract = generateApiContract(model, config);
3151
+ const contractJson = JSON.stringify(contract, null, 2);
3152
+ return `/**
3153
+ * API Contract
3154
+ *
3155
+ * This module exports the API contract that describes all available
3156
+ * endpoints, resources, and their relationships.
3157
+ */
3158
+
3159
+ export const apiContract = ${contractJson};
3160
+
3161
+ export const apiContractMarkdown = \`${generateApiContractMarkdown(contract).replace(/`/g, "\\`")}\`;
3162
+
3163
+ /**
3164
+ * Helper to get the contract in different formats
3165
+ */
3166
+ export function getApiContract(format: 'json' | 'markdown' = 'json') {
3167
+ if (format === 'markdown') {
3168
+ return apiContractMarkdown;
3169
+ }
3170
+ return apiContract;
3171
+ }
3172
+ `;
3173
+ }
3174
+
2779
3175
  // src/types.ts
2780
3176
  function normalizeAuthConfig(input) {
2781
3177
  if (!input)
@@ -2919,6 +3315,10 @@ async function generate(configPath) {
2919
3315
  path: join(serverDir, "sdk-bundle.ts"),
2920
3316
  content: emitSdkBundle(clientFiles, clientDir)
2921
3317
  });
3318
+ files.push({
3319
+ path: join(serverDir, "api-contract.ts"),
3320
+ content: emitApiContract(model, cfg)
3321
+ });
2922
3322
  if (generateTests) {
2923
3323
  console.log("\uD83E\uDDEA Generating tests...");
2924
3324
  const relativeClientPath = relative(testDir, clientDir);
@@ -0,0 +1,60 @@
1
+ import type { Model } from "./introspect";
2
+ import type { Config, AuthConfig } from "./types";
3
+ export interface ApiContract {
4
+ version: string;
5
+ generatedAt: string;
6
+ description: string;
7
+ authentication?: {
8
+ type: string;
9
+ description: string;
10
+ };
11
+ resources: ResourceContract[];
12
+ relationships: RelationshipContract[];
13
+ }
14
+ export interface ResourceContract {
15
+ name: string;
16
+ tableName: string;
17
+ description: string;
18
+ endpoints: EndpointContract[];
19
+ fields: FieldContract[];
20
+ }
21
+ export interface EndpointContract {
22
+ method: string;
23
+ path: string;
24
+ description: string;
25
+ requestBody?: any;
26
+ responseBody?: any;
27
+ queryParameters?: any;
28
+ }
29
+ export interface FieldContract {
30
+ name: string;
31
+ type: string;
32
+ required: boolean;
33
+ description: string;
34
+ foreignKey?: {
35
+ table: string;
36
+ field: string;
37
+ };
38
+ }
39
+ export interface RelationshipContract {
40
+ from: string;
41
+ to: string;
42
+ type: "one-to-many" | "many-to-one" | "many-to-many";
43
+ description: string;
44
+ }
45
+ /**
46
+ * Generate a comprehensive API contract in JSON format
47
+ */
48
+ export declare function generateApiContract(model: Model, config: Config & {
49
+ auth?: AuthConfig;
50
+ }): ApiContract;
51
+ /**
52
+ * Generate a human-readable markdown version of the contract
53
+ */
54
+ export declare function generateApiContractMarkdown(contract: ApiContract): string;
55
+ /**
56
+ * Emit the API contract as TypeScript code that can be served as an endpoint
57
+ */
58
+ export declare function emitApiContract(model: Model, config: Config & {
59
+ auth?: AuthConfig;
60
+ }): string;
package/dist/index.js CHANGED
@@ -1736,6 +1736,7 @@ function emitHonoRouter(tables, hasAuth, useJsExtensions) {
1736
1736
  return `/* Generated. Do not edit. */
1737
1737
  import { Hono } from "hono";
1738
1738
  import { SDK_MANIFEST } from "./sdk-bundle${ext}";
1739
+ import { getApiContract } from "./api-contract${ext}";
1739
1740
  ${imports}
1740
1741
  ${hasAuth ? `export { authMiddleware } from "./auth${ext}";` : ""}
1741
1742
 
@@ -1797,6 +1798,29 @@ ${registrations}
1797
1798
  });
1798
1799
  });
1799
1800
 
1801
+ // API Contract endpoints - describes the entire API
1802
+ router.get("/api/contract", (c) => {
1803
+ const format = c.req.query("format") || "json";
1804
+
1805
+ if (format === "markdown") {
1806
+ return c.text(getApiContract("markdown") as string, 200, {
1807
+ "Content-Type": "text/markdown; charset=utf-8"
1808
+ });
1809
+ }
1810
+
1811
+ return c.json(getApiContract("json"));
1812
+ });
1813
+
1814
+ router.get("/api/contract.json", (c) => {
1815
+ return c.json(getApiContract("json"));
1816
+ });
1817
+
1818
+ router.get("/api/contract.md", (c) => {
1819
+ return c.text(getApiContract("markdown") as string, 200, {
1820
+ "Content-Type": "text/markdown; charset=utf-8"
1821
+ });
1822
+ });
1823
+
1800
1824
  return router;
1801
1825
  }
1802
1826
 
@@ -2074,11 +2098,14 @@ function emitTableTest(table, clientPath, framework = "vitest") {
2074
2098
  const Type = pascal(table.name);
2075
2099
  const tableName = table.name;
2076
2100
  const imports = getFrameworkImports(framework);
2077
- const sampleData = generateSampleData(table);
2101
+ const hasForeignKeys = table.fks.length > 0;
2102
+ const foreignKeySetup = hasForeignKeys ? generateForeignKeySetup(table, clientPath) : null;
2103
+ const sampleData = generateSampleData(table, hasForeignKeys);
2078
2104
  const updateData = generateUpdateData(table);
2079
2105
  return `${imports}
2080
2106
  import { SDK } from '${clientPath}';
2081
2107
  import type { Insert${Type}, Update${Type}, Select${Type} } from '${clientPath}/types/${tableName}';
2108
+ ${foreignKeySetup?.imports || ""}
2082
2109
 
2083
2110
  /**
2084
2111
  * Basic tests for ${tableName} table operations
@@ -2094,15 +2121,21 @@ import type { Insert${Type}, Update${Type}, Select${Type} } from '${clientPath}/
2094
2121
  describe('${Type} SDK Operations', () => {
2095
2122
  let sdk: SDK;
2096
2123
  let createdId: string;
2124
+ ${foreignKeySetup?.variables || ""}
2097
2125
 
2098
- beforeAll(() => {
2126
+ beforeAll(async () => {
2099
2127
  sdk = new SDK({
2100
2128
  baseUrl: process.env.API_URL || 'http://localhost:3000',
2101
2129
  auth: process.env.API_KEY ? { apiKey: process.env.API_KEY } : undefined
2102
2130
  });
2131
+ ${foreignKeySetup?.setup || ""}
2132
+ });
2133
+
2134
+ ${hasForeignKeys && foreignKeySetup?.cleanup ? `afterAll(async () => {
2135
+ ${foreignKeySetup.cleanup}
2103
2136
  });
2104
2137
 
2105
- ${generateTestCases(table, sampleData, updateData)}
2138
+ ` : ""}${generateTestCases(table, sampleData, updateData, hasForeignKeys)}
2106
2139
  });
2107
2140
  `;
2108
2141
  }
@@ -2162,6 +2195,13 @@ export default defineConfig({
2162
2195
  testTimeout: 30000,
2163
2196
  hookTimeout: 30000,
2164
2197
  // The reporters are configured via CLI in the test script
2198
+ // Force color output in terminal
2199
+ pool: 'forks',
2200
+ poolOptions: {
2201
+ forks: {
2202
+ singleFork: true,
2203
+ }
2204
+ }
2165
2205
  },
2166
2206
  });
2167
2207
  `;
@@ -2242,6 +2282,10 @@ docker-compose up -d --wait
2242
2282
  export TEST_DATABASE_URL="postgres://testuser:testpass@localhost:5432/testdb"
2243
2283
  export TEST_API_URL="http://localhost:3000"
2244
2284
 
2285
+ # Force color output for better terminal experience
2286
+ export FORCE_COLOR=1
2287
+ export CI=""
2288
+
2245
2289
  # Wait for database to be ready
2246
2290
  echo "⏳ Waiting for database..."
2247
2291
  sleep 3
@@ -2300,16 +2344,67 @@ echo " cd $SCRIPT_DIR && docker-compose down"
2300
2344
  exit $TEST_EXIT_CODE
2301
2345
  `;
2302
2346
  }
2347
+ function generateForeignKeySetup(table, clientPath) {
2348
+ const imports = [];
2349
+ const variables = [];
2350
+ const setupStatements = [];
2351
+ const cleanupStatements = [];
2352
+ const foreignTables = new Set;
2353
+ for (const fk of table.fks) {
2354
+ const foreignTable = fk.toTable;
2355
+ if (!foreignTables.has(foreignTable)) {
2356
+ foreignTables.add(foreignTable);
2357
+ const ForeignType = pascal(foreignTable);
2358
+ imports.push(`import type { Insert${ForeignType} } from '${clientPath}/types/${foreignTable}';`);
2359
+ variables.push(`let ${foreignTable}Id: string;`);
2360
+ setupStatements.push(`
2361
+ // Create parent ${foreignTable} record for foreign key reference
2362
+ const ${foreignTable}Data = ${generateMinimalSampleData(foreignTable)};
2363
+ const created${ForeignType} = await sdk.${foreignTable}.create(${foreignTable}Data);
2364
+ ${foreignTable}Id = created${ForeignType}.id;`);
2365
+ cleanupStatements.push(`
2366
+ // Clean up parent ${foreignTable} record
2367
+ if (${foreignTable}Id) {
2368
+ try {
2369
+ await sdk.${foreignTable}.delete(${foreignTable}Id);
2370
+ } catch (e) {
2371
+ // Parent might already be deleted due to cascading
2372
+ }
2373
+ }`);
2374
+ }
2375
+ }
2376
+ return {
2377
+ imports: imports.join(`
2378
+ `),
2379
+ variables: variables.join(`
2380
+ `),
2381
+ setup: setupStatements.join(""),
2382
+ cleanup: cleanupStatements.join("")
2383
+ };
2384
+ }
2385
+ function generateMinimalSampleData(tableName) {
2386
+ const commonPatterns = {
2387
+ contacts: `{ name: 'Test Contact', email: 'test@example.com', gender: 'M', date_of_birth: new Date('1990-01-01'), emergency_contact: 'Emergency Contact', clinic_location: 'Main Clinic', specialty: 'General', fmv_tiering: 'Standard', flight_preferences: 'Economy', dietary_restrictions: [], special_accommodations: 'None' }`,
2388
+ tags: `{ name: 'Test Tag' }`,
2389
+ authors: `{ name: 'Test Author' }`,
2390
+ books: `{ title: 'Test Book' }`,
2391
+ users: `{ name: 'Test User', email: 'test@example.com' }`,
2392
+ categories: `{ name: 'Test Category' }`,
2393
+ products: `{ name: 'Test Product', price: 10.99 }`,
2394
+ orders: `{ total: 100.00 }`
2395
+ };
2396
+ return commonPatterns[tableName] || `{ name: 'Test ${pascal(tableName)}' }`;
2397
+ }
2303
2398
  function getTestCommand(framework, baseCommand) {
2304
2399
  switch (framework) {
2305
2400
  case "vitest":
2306
- return `${baseCommand} --reporter=default --reporter=json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
2401
+ return `FORCE_COLOR=1 ${baseCommand} --reporter=default --reporter=json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
2307
2402
  case "jest":
2308
- return `${baseCommand} --json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
2403
+ return `FORCE_COLOR=1 ${baseCommand} --colors --json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
2309
2404
  case "bun":
2310
- return `${baseCommand} "$@" 2>&1 | tee "$TEST_RESULTS_DIR/results-\${TIMESTAMP}.txt"`;
2405
+ return `FORCE_COLOR=1 ${baseCommand} "$@" 2>&1 | tee "$TEST_RESULTS_DIR/results-\${TIMESTAMP}.txt"`;
2311
2406
  default:
2312
- return `${baseCommand} "$@"`;
2407
+ return `FORCE_COLOR=1 ${baseCommand} "$@"`;
2313
2408
  }
2314
2409
  }
2315
2410
  function getFrameworkImports(framework) {
@@ -2324,8 +2419,16 @@ function getFrameworkImports(framework) {
2324
2419
  return "import { describe, it, expect, beforeAll, afterAll } from 'vitest';";
2325
2420
  }
2326
2421
  }
2327
- function generateSampleData(table) {
2422
+ function generateSampleData(table, hasForeignKeys = false) {
2328
2423
  const fields = [];
2424
+ const foreignKeyColumns = new Map;
2425
+ if (hasForeignKeys) {
2426
+ for (const fk of table.fks) {
2427
+ if (fk.from.length === 1 && fk.from[0]) {
2428
+ foreignKeyColumns.set(fk.from[0], fk.toTable);
2429
+ }
2430
+ }
2431
+ }
2329
2432
  for (const col of table.columns) {
2330
2433
  if (col.name === "id" && col.hasDefault) {
2331
2434
  continue;
@@ -2336,10 +2439,17 @@ function generateSampleData(table) {
2336
2439
  if (col.name === "deleted_at") {
2337
2440
  continue;
2338
2441
  }
2339
- const isImportant = col.name.endsWith("_id") || col.name.endsWith("_by") || col.name.includes("email") || col.name.includes("name") || col.name.includes("phone") || col.name.includes("address") || col.name.includes("description") || col.name.includes("color") || col.name.includes("type") || col.name.includes("status") || col.name.includes("subject");
2340
- if (!col.nullable || isImportant) {
2341
- const value = getSampleValue(col.pgType, col.name);
2442
+ if (!col.nullable) {
2443
+ const foreignTable = foreignKeyColumns.get(col.name);
2444
+ const value = foreignTable ? `${foreignTable}Id` : getSampleValue(col.pgType, col.name);
2342
2445
  fields.push(` ${col.name}: ${value}`);
2446
+ } else {
2447
+ const isImportant = col.name.endsWith("_id") || col.name.endsWith("_by") || col.name.includes("email") || col.name.includes("name") || col.name.includes("phone") || col.name.includes("address") || col.name.includes("description") || col.name.includes("color") || col.name.includes("type") || col.name.includes("status") || col.name.includes("subject");
2448
+ if (isImportant) {
2449
+ const foreignTable = foreignKeyColumns.get(col.name);
2450
+ const value = foreignTable ? `${foreignTable}Id` : getSampleValue(col.pgType, col.name);
2451
+ fields.push(` ${col.name}: ${value}`);
2452
+ }
2343
2453
  }
2344
2454
  }
2345
2455
  return fields.length > 0 ? `{
@@ -2411,6 +2521,18 @@ function getSampleValue(type, name, isUpdate = false) {
2411
2521
  if (name.includes("tier")) {
2412
2522
  return `'Standard'`;
2413
2523
  }
2524
+ if (name.includes("emergency")) {
2525
+ return `'Emergency Contact ${isUpdate ? "Updated" : "Name"}'`;
2526
+ }
2527
+ if (name === "date_of_birth" || name.includes("birth")) {
2528
+ return `new Date('1990-01-01')`;
2529
+ }
2530
+ if (name.includes("accommodations")) {
2531
+ return `'No special accommodations'`;
2532
+ }
2533
+ if (name.includes("flight")) {
2534
+ return `'Economy'`;
2535
+ }
2414
2536
  switch (type) {
2415
2537
  case "text":
2416
2538
  case "varchar":
@@ -2434,7 +2556,7 @@ function getSampleValue(type, name, isUpdate = false) {
2434
2556
  return `'2024-01-01'`;
2435
2557
  case "timestamp":
2436
2558
  case "timestamptz":
2437
- return `new Date().toISOString()`;
2559
+ return `new Date()`;
2438
2560
  case "json":
2439
2561
  case "jsonb":
2440
2562
  return `{ key: 'value' }`;
@@ -2447,7 +2569,7 @@ function getSampleValue(type, name, isUpdate = false) {
2447
2569
  return `'test'`;
2448
2570
  }
2449
2571
  }
2450
- function generateTestCases(table, sampleData, updateData) {
2572
+ function generateTestCases(table, sampleData, updateData, hasForeignKeys = false) {
2451
2573
  const Type = pascal(table.name);
2452
2574
  const hasData = sampleData !== "{}";
2453
2575
  return `it('should create a ${table.name}', async () => {
@@ -2506,6 +2628,280 @@ function generateTestCases(table, sampleData, updateData) {
2506
2628
  });` : ""}`;
2507
2629
  }
2508
2630
 
2631
+ // src/emit-api-contract.ts
2632
+ function generateApiContract(model, config) {
2633
+ const resources = [];
2634
+ const relationships = [];
2635
+ for (const table of Object.values(model.tables)) {
2636
+ resources.push(generateResourceContract(table, model));
2637
+ for (const fk of table.fks) {
2638
+ relationships.push({
2639
+ from: table.name,
2640
+ to: fk.toTable,
2641
+ type: "many-to-one",
2642
+ description: `Each ${table.name} belongs to one ${fk.toTable}`
2643
+ });
2644
+ }
2645
+ }
2646
+ const contract = {
2647
+ version: "1.0.0",
2648
+ generatedAt: new Date().toISOString(),
2649
+ description: "Auto-generated API contract describing all available endpoints, resources, and their relationships",
2650
+ resources,
2651
+ relationships
2652
+ };
2653
+ if (config.auth?.strategy && config.auth.strategy !== "none") {
2654
+ contract.authentication = {
2655
+ type: config.auth.strategy,
2656
+ description: getAuthDescription(config.auth.strategy)
2657
+ };
2658
+ }
2659
+ return contract;
2660
+ }
2661
+ function generateResourceContract(table, model) {
2662
+ const Type = pascal(table.name);
2663
+ const basePath = `/v1/${table.name}`;
2664
+ const endpoints = [
2665
+ {
2666
+ method: "GET",
2667
+ path: basePath,
2668
+ description: `List all ${table.name} records with optional filtering, sorting, and pagination`,
2669
+ queryParameters: {
2670
+ limit: "number - Maximum number of records to return (default: 50)",
2671
+ offset: "number - Number of records to skip for pagination",
2672
+ order_by: "string - Field to sort by",
2673
+ order_dir: "string - Sort direction (asc or desc)",
2674
+ include: "string - Comma-separated list of related resources to include",
2675
+ ...generateFilterParams(table)
2676
+ },
2677
+ responseBody: `Array<${Type}>`
2678
+ },
2679
+ {
2680
+ method: "GET",
2681
+ path: `${basePath}/:id`,
2682
+ description: `Get a single ${table.name} record by ID`,
2683
+ queryParameters: {
2684
+ include: "string - Comma-separated list of related resources to include"
2685
+ },
2686
+ responseBody: `${Type}`
2687
+ },
2688
+ {
2689
+ method: "POST",
2690
+ path: basePath,
2691
+ description: `Create a new ${table.name} record`,
2692
+ requestBody: `Insert${Type}`,
2693
+ responseBody: `${Type}`
2694
+ },
2695
+ {
2696
+ method: "PATCH",
2697
+ path: `${basePath}/:id`,
2698
+ description: `Update an existing ${table.name} record`,
2699
+ requestBody: `Update${Type}`,
2700
+ responseBody: `${Type}`
2701
+ },
2702
+ {
2703
+ method: "DELETE",
2704
+ path: `${basePath}/:id`,
2705
+ description: `Delete a ${table.name} record`,
2706
+ responseBody: `${Type}`
2707
+ }
2708
+ ];
2709
+ const fields = table.columns.map((col) => generateFieldContract(col, table));
2710
+ return {
2711
+ name: Type,
2712
+ tableName: table.name,
2713
+ description: `Resource for managing ${table.name} records`,
2714
+ endpoints,
2715
+ fields
2716
+ };
2717
+ }
2718
+ function generateFieldContract(column, table) {
2719
+ const field = {
2720
+ name: column.name,
2721
+ type: postgresTypeToJsonType(column.pgType),
2722
+ required: !column.nullable && !column.hasDefault,
2723
+ description: generateFieldDescription(column, table)
2724
+ };
2725
+ const fk = table.fks.find((fk2) => fk2.from.length === 1 && fk2.from[0] === column.name);
2726
+ if (fk) {
2727
+ field.foreignKey = {
2728
+ table: fk.toTable,
2729
+ field: fk.to[0] || "id"
2730
+ };
2731
+ }
2732
+ return field;
2733
+ }
2734
+ function generateFieldDescription(column, table) {
2735
+ const descriptions = [];
2736
+ descriptions.push(`${postgresTypeToJsonType(column.pgType)} field`);
2737
+ if (column.name === "id") {
2738
+ descriptions.push("Unique identifier");
2739
+ } else if (column.name === "created_at") {
2740
+ descriptions.push("Timestamp when the record was created");
2741
+ } else if (column.name === "updated_at") {
2742
+ descriptions.push("Timestamp when the record was last updated");
2743
+ } else if (column.name === "deleted_at") {
2744
+ descriptions.push("Soft delete timestamp");
2745
+ } else if (column.name.endsWith("_id")) {
2746
+ const relatedTable = column.name.slice(0, -3);
2747
+ descriptions.push(`Reference to ${relatedTable}`);
2748
+ } else if (column.name.includes("email")) {
2749
+ descriptions.push("Email address");
2750
+ } else if (column.name.includes("phone")) {
2751
+ descriptions.push("Phone number");
2752
+ } else if (column.name.includes("name")) {
2753
+ descriptions.push("Name field");
2754
+ } else if (column.name.includes("description")) {
2755
+ descriptions.push("Description text");
2756
+ } else if (column.name.includes("status")) {
2757
+ descriptions.push("Status indicator");
2758
+ } else if (column.name.includes("price") || column.name.includes("amount") || column.name.includes("total")) {
2759
+ descriptions.push("Monetary value");
2760
+ }
2761
+ if (!column.nullable && !column.hasDefault) {
2762
+ descriptions.push("(required)");
2763
+ } else if (column.nullable) {
2764
+ descriptions.push("(optional)");
2765
+ }
2766
+ return descriptions.join(" - ");
2767
+ }
2768
+ function postgresTypeToJsonType(pgType) {
2769
+ switch (pgType) {
2770
+ case "int":
2771
+ case "integer":
2772
+ case "smallint":
2773
+ case "bigint":
2774
+ case "decimal":
2775
+ case "numeric":
2776
+ case "real":
2777
+ case "double precision":
2778
+ case "float":
2779
+ return "number";
2780
+ case "boolean":
2781
+ case "bool":
2782
+ return "boolean";
2783
+ case "date":
2784
+ case "timestamp":
2785
+ case "timestamptz":
2786
+ return "date/datetime";
2787
+ case "json":
2788
+ case "jsonb":
2789
+ return "object";
2790
+ case "uuid":
2791
+ return "uuid";
2792
+ case "text[]":
2793
+ case "varchar[]":
2794
+ return "array<string>";
2795
+ case "int[]":
2796
+ case "integer[]":
2797
+ return "array<number>";
2798
+ default:
2799
+ return "string";
2800
+ }
2801
+ }
2802
+ function generateFilterParams(table) {
2803
+ const filters = {};
2804
+ for (const col of table.columns) {
2805
+ const type = postgresTypeToJsonType(col.pgType);
2806
+ filters[col.name] = `${type} - Filter by exact ${col.name} value`;
2807
+ if (type === "number" || type === "date/datetime") {
2808
+ filters[`${col.name}_gt`] = `${type} - Filter where ${col.name} is greater than`;
2809
+ filters[`${col.name}_gte`] = `${type} - Filter where ${col.name} is greater than or equal`;
2810
+ filters[`${col.name}_lt`] = `${type} - Filter where ${col.name} is less than`;
2811
+ filters[`${col.name}_lte`] = `${type} - Filter where ${col.name} is less than or equal`;
2812
+ }
2813
+ if (type === "string") {
2814
+ filters[`${col.name}_like`] = `string - Filter where ${col.name} contains text (case-insensitive)`;
2815
+ }
2816
+ }
2817
+ return filters;
2818
+ }
2819
+ function getAuthDescription(strategy) {
2820
+ switch (strategy) {
2821
+ case "jwt":
2822
+ return "JWT Bearer token authentication. Include token in Authorization header: 'Bearer <token>'";
2823
+ case "apiKey":
2824
+ return "API Key authentication. Include key in the configured header (e.g., 'x-api-key')";
2825
+ default:
2826
+ return "Custom authentication strategy";
2827
+ }
2828
+ }
2829
+ function generateApiContractMarkdown(contract) {
2830
+ const lines = [];
2831
+ lines.push("# API Contract");
2832
+ lines.push("");
2833
+ lines.push(contract.description);
2834
+ lines.push("");
2835
+ lines.push(`**Version:** ${contract.version}`);
2836
+ lines.push(`**Generated:** ${new Date(contract.generatedAt).toLocaleString()}`);
2837
+ lines.push("");
2838
+ if (contract.authentication) {
2839
+ lines.push("## Authentication");
2840
+ lines.push("");
2841
+ lines.push(`**Type:** ${contract.authentication.type}`);
2842
+ lines.push("");
2843
+ lines.push(contract.authentication.description);
2844
+ lines.push("");
2845
+ }
2846
+ lines.push("## Resources");
2847
+ lines.push("");
2848
+ for (const resource of contract.resources) {
2849
+ lines.push(`### ${resource.name}`);
2850
+ lines.push("");
2851
+ lines.push(resource.description);
2852
+ lines.push("");
2853
+ lines.push("**Endpoints:**");
2854
+ lines.push("");
2855
+ for (const endpoint of resource.endpoints) {
2856
+ lines.push(`- \`${endpoint.method} ${endpoint.path}\` - ${endpoint.description}`);
2857
+ }
2858
+ lines.push("");
2859
+ lines.push("**Fields:**");
2860
+ lines.push("");
2861
+ for (const field of resource.fields) {
2862
+ const required = field.required ? " *(required)*" : "";
2863
+ const fk = field.foreignKey ? ` → ${field.foreignKey.table}` : "";
2864
+ lines.push(`- \`${field.name}\` (${field.type})${required}${fk} - ${field.description}`);
2865
+ }
2866
+ lines.push("");
2867
+ }
2868
+ if (contract.relationships.length > 0) {
2869
+ lines.push("## Relationships");
2870
+ lines.push("");
2871
+ for (const rel of contract.relationships) {
2872
+ lines.push(`- **${rel.from}** → **${rel.to}** (${rel.type}): ${rel.description}`);
2873
+ }
2874
+ lines.push("");
2875
+ }
2876
+ return lines.join(`
2877
+ `);
2878
+ }
2879
+ function emitApiContract(model, config) {
2880
+ const contract = generateApiContract(model, config);
2881
+ const contractJson = JSON.stringify(contract, null, 2);
2882
+ return `/**
2883
+ * API Contract
2884
+ *
2885
+ * This module exports the API contract that describes all available
2886
+ * endpoints, resources, and their relationships.
2887
+ */
2888
+
2889
+ export const apiContract = ${contractJson};
2890
+
2891
+ export const apiContractMarkdown = \`${generateApiContractMarkdown(contract).replace(/`/g, "\\`")}\`;
2892
+
2893
+ /**
2894
+ * Helper to get the contract in different formats
2895
+ */
2896
+ export function getApiContract(format: 'json' | 'markdown' = 'json') {
2897
+ if (format === 'markdown') {
2898
+ return apiContractMarkdown;
2899
+ }
2900
+ return apiContract;
2901
+ }
2902
+ `;
2903
+ }
2904
+
2509
2905
  // src/types.ts
2510
2906
  function normalizeAuthConfig(input) {
2511
2907
  if (!input)
@@ -2649,6 +3045,10 @@ async function generate(configPath) {
2649
3045
  path: join(serverDir, "sdk-bundle.ts"),
2650
3046
  content: emitSdkBundle(clientFiles, clientDir)
2651
3047
  });
3048
+ files.push({
3049
+ path: join(serverDir, "api-contract.ts"),
3050
+ content: emitApiContract(model, cfg)
3051
+ });
2652
3052
  if (generateTests) {
2653
3053
  console.log("\uD83E\uDDEA Generating tests...");
2654
3054
  const relativeClientPath = relative(testDir, clientDir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresdk",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "description": "Generate a typed server/client SDK from a Postgres schema (includes, Zod, Hono).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,6 +28,7 @@
28
28
  "test:gen-with-tests": "bun src/cli.ts generate -c test/test-with-tests.config.ts",
29
29
  "test:pull": "bun test/test-pull.ts",
30
30
  "test:typecheck": "bun test/test-typecheck.ts",
31
+ "typecheck": "tsc --noEmit",
31
32
  "prepublishOnly": "npm run build",
32
33
  "publish:patch": "./publish.sh",
33
34
  "publish:minor": "./publish.sh",