postgresdk 0.6.4 → 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,30 +2368,44 @@ 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
2355
2382
  *
2356
2383
  * These tests demonstrate basic CRUD operations.
2357
- * Add your own business logic tests in separate files.
2384
+ * The test data is auto-generated and may need adjustment for your specific schema.
2385
+ *
2386
+ * If tests fail due to validation errors:
2387
+ * 1. Check which fields are required by your API
2388
+ * 2. Update the test data below to match your schema requirements
2389
+ * 3. Consider adding your own business logic tests in separate files
2358
2390
  */
2359
2391
  describe('${Type} SDK Operations', () => {
2360
2392
  let sdk: SDK;
2361
2393
  let createdId: string;
2394
+ ${foreignKeySetup?.variables || ""}
2362
2395
 
2363
- beforeAll(() => {
2396
+ beforeAll(async () => {
2364
2397
  sdk = new SDK({
2365
2398
  baseUrl: process.env.API_URL || 'http://localhost:3000',
2366
2399
  auth: process.env.API_KEY ? { apiKey: process.env.API_KEY } : undefined
2367
2400
  });
2401
+ ${foreignKeySetup?.setup || ""}
2402
+ });
2403
+
2404
+ ${hasForeignKeys && foreignKeySetup?.cleanup ? `afterAll(async () => {
2405
+ ${foreignKeySetup.cleanup}
2368
2406
  });
2369
2407
 
2370
- ${generateTestCases(table, sampleData, updateData)}
2408
+ ` : ""}${generateTestCases(table, sampleData, updateData, hasForeignKeys)}
2371
2409
  });
2372
2410
  `;
2373
2411
  }
@@ -2417,6 +2455,45 @@ export function randomDate(): Date {
2417
2455
  }
2418
2456
  `;
2419
2457
  }
2458
+ function emitVitestConfig() {
2459
+ return `import { defineConfig } from 'vitest/config';
2460
+
2461
+ export default defineConfig({
2462
+ test: {
2463
+ globals: true,
2464
+ environment: 'node',
2465
+ testTimeout: 30000,
2466
+ hookTimeout: 30000,
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
+ }
2475
+ },
2476
+ });
2477
+ `;
2478
+ }
2479
+ function emitTestGitignore() {
2480
+ return `# Test results
2481
+ test-results/
2482
+ *.log
2483
+
2484
+ # Node modules (if tests have their own dependencies)
2485
+ node_modules/
2486
+
2487
+ # Environment files
2488
+ .env
2489
+ .env.local
2490
+ .env.test
2491
+
2492
+ # Coverage reports
2493
+ coverage/
2494
+ *.lcov
2495
+ `;
2496
+ }
2420
2497
  function emitDockerCompose() {
2421
2498
  return `# Docker Compose for Test Database
2422
2499
  #
@@ -2475,6 +2552,10 @@ docker-compose up -d --wait
2475
2552
  export TEST_DATABASE_URL="postgres://testuser:testpass@localhost:5432/testdb"
2476
2553
  export TEST_API_URL="http://localhost:3000"
2477
2554
 
2555
+ # Force color output for better terminal experience
2556
+ export FORCE_COLOR=1
2557
+ export CI=""
2558
+
2478
2559
  # Wait for database to be ready
2479
2560
  echo "⏳ Waiting for database..."
2480
2561
  sleep 3
@@ -2502,7 +2583,14 @@ echo ""
2502
2583
  # sleep 3
2503
2584
 
2504
2585
  echo "\uD83E\uDDEA Running tests..."
2505
- ${runCommand} "$@"
2586
+ TIMESTAMP=$(date +%Y%m%d_%H%M%S)
2587
+ TEST_RESULTS_DIR="$SCRIPT_DIR/test-results"
2588
+ mkdir -p "$TEST_RESULTS_DIR"
2589
+
2590
+ # Run tests with appropriate reporter based on framework
2591
+ ${getTestCommand(framework, runCommand)}
2592
+
2593
+ TEST_EXIT_CODE=$?
2506
2594
 
2507
2595
  # Cleanup
2508
2596
  # if [ ! -z "\${SERVER_PID}" ]; then
@@ -2510,12 +2598,85 @@ ${runCommand} "$@"
2510
2598
  # kill $SERVER_PID 2>/dev/null || true
2511
2599
  # fi
2512
2600
 
2513
- echo "✅ Tests completed!"
2601
+ if [ $TEST_EXIT_CODE -eq 0 ]; then
2602
+ echo "✅ Tests completed successfully!"
2603
+ else
2604
+ echo "❌ Tests failed with exit code $TEST_EXIT_CODE"
2605
+ fi
2606
+
2607
+ echo ""
2608
+ echo "\uD83D\uDCCA Test results saved to:"
2609
+ echo " $TEST_RESULTS_DIR/"
2514
2610
  echo ""
2515
2611
  echo "To stop the test database, run:"
2516
2612
  echo " cd $SCRIPT_DIR && docker-compose down"
2613
+
2614
+ exit $TEST_EXIT_CODE
2517
2615
  `;
2518
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
+ }
2668
+ function getTestCommand(framework, baseCommand) {
2669
+ switch (framework) {
2670
+ case "vitest":
2671
+ return `FORCE_COLOR=1 ${baseCommand} --reporter=default --reporter=json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
2672
+ case "jest":
2673
+ return `FORCE_COLOR=1 ${baseCommand} --colors --json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
2674
+ case "bun":
2675
+ return `FORCE_COLOR=1 ${baseCommand} "$@" 2>&1 | tee "$TEST_RESULTS_DIR/results-\${TIMESTAMP}.txt"`;
2676
+ default:
2677
+ return `FORCE_COLOR=1 ${baseCommand} "$@"`;
2678
+ }
2679
+ }
2519
2680
  function getFrameworkImports(framework) {
2520
2681
  switch (framework) {
2521
2682
  case "vitest":
@@ -2528,17 +2689,38 @@ function getFrameworkImports(framework) {
2528
2689
  return "import { describe, it, expect, beforeAll, afterAll } from 'vitest';";
2529
2690
  }
2530
2691
  }
2531
- function generateSampleData(table) {
2692
+ function generateSampleData(table, hasForeignKeys = false) {
2532
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
+ }
2533
2702
  for (const col of table.columns) {
2534
- if (col.hasDefault || col.name === "id" || col.name === "created_at" || col.name === "updated_at") {
2703
+ if (col.name === "id" && col.hasDefault) {
2535
2704
  continue;
2536
2705
  }
2537
- if (col.nullable) {
2706
+ if ((col.name === "created_at" || col.name === "updated_at") && col.hasDefault) {
2538
2707
  continue;
2539
2708
  }
2540
- const value = getSampleValue(col.pgType, col.name);
2541
- fields.push(` ${col.name}: ${value}`);
2709
+ if (col.name === "deleted_at") {
2710
+ continue;
2711
+ }
2712
+ if (!col.nullable) {
2713
+ const foreignTable = foreignKeyColumns.get(col.name);
2714
+ const value = foreignTable ? `${foreignTable}Id` : getSampleValue(col.pgType, col.name);
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
+ }
2723
+ }
2542
2724
  }
2543
2725
  return fields.length > 0 ? `{
2544
2726
  ${fields.join(`,
@@ -2564,15 +2746,63 @@ ${fields.join(`,
2564
2746
  }
2565
2747
  function getSampleValue(type, name, isUpdate = false) {
2566
2748
  const suffix = isUpdate ? ' + " (updated)"' : "";
2749
+ if (name.endsWith("_id") || name.endsWith("_by")) {
2750
+ return `'550e8400-e29b-41d4-a716-446655440000'`;
2751
+ }
2567
2752
  if (name.includes("email")) {
2568
2753
  return `'test${isUpdate ? ".updated" : ""}@example.com'`;
2569
2754
  }
2755
+ if (name === "color") {
2756
+ return `'#${isUpdate ? "FF0000" : "0000FF"}'`;
2757
+ }
2758
+ if (name === "gender") {
2759
+ return `'${isUpdate ? "F" : "M"}'`;
2760
+ }
2761
+ if (name.includes("phone")) {
2762
+ return `'${isUpdate ? "555-0200" : "555-0100"}'`;
2763
+ }
2764
+ if (name.includes("address")) {
2765
+ return `'123 ${isUpdate ? "Updated" : "Test"} Street'`;
2766
+ }
2767
+ if (name === "type" || name === "status") {
2768
+ return `'${isUpdate ? "updated" : "active"}'`;
2769
+ }
2770
+ if (name === "subject") {
2771
+ return `'Test Subject${isUpdate ? " Updated" : ""}'`;
2772
+ }
2570
2773
  if (name.includes("name") || name.includes("title")) {
2571
2774
  return `'Test ${pascal(name)}'${suffix}`;
2572
2775
  }
2573
2776
  if (name.includes("description") || name.includes("bio") || name.includes("content")) {
2574
2777
  return `'Test description'${suffix}`;
2575
2778
  }
2779
+ if (name.includes("preferences") || name.includes("settings")) {
2780
+ return `'Test preferences'${suffix}`;
2781
+ }
2782
+ if (name.includes("restrictions") || name.includes("dietary")) {
2783
+ return `['vegetarian']`;
2784
+ }
2785
+ if (name.includes("location") || name.includes("clinic")) {
2786
+ return `'Test Location'${suffix}`;
2787
+ }
2788
+ if (name.includes("specialty")) {
2789
+ return `'General'`;
2790
+ }
2791
+ if (name.includes("tier")) {
2792
+ return `'Standard'`;
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
+ }
2576
2806
  switch (type) {
2577
2807
  case "text":
2578
2808
  case "varchar":
@@ -2596,17 +2826,20 @@ function getSampleValue(type, name, isUpdate = false) {
2596
2826
  return `'2024-01-01'`;
2597
2827
  case "timestamp":
2598
2828
  case "timestamptz":
2599
- return `new Date().toISOString()`;
2829
+ return `new Date()`;
2600
2830
  case "json":
2601
2831
  case "jsonb":
2602
2832
  return `{ key: 'value' }`;
2603
2833
  case "uuid":
2604
- return `'${isUpdate ? "b" : "a"}0e0e0e0-e0e0-e0e0-e0e0-e0e0e0e0e0e0'`;
2834
+ return `'${isUpdate ? "550e8400-e29b-41d4-a716-446655440001" : "550e8400-e29b-41d4-a716-446655440000"}'`;
2835
+ case "text[]":
2836
+ case "varchar[]":
2837
+ return `['item1', 'item2']`;
2605
2838
  default:
2606
2839
  return `'test'`;
2607
2840
  }
2608
2841
  }
2609
- function generateTestCases(table, sampleData, updateData) {
2842
+ function generateTestCases(table, sampleData, updateData, hasForeignKeys = false) {
2610
2843
  const Type = pascal(table.name);
2611
2844
  const hasData = sampleData !== "{}";
2612
2845
  return `it('should create a ${table.name}', async () => {
@@ -2665,6 +2898,280 @@ function generateTestCases(table, sampleData, updateData) {
2665
2898
  });` : ""}`;
2666
2899
  }
2667
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
+
2668
3175
  // src/types.ts
2669
3176
  function normalizeAuthConfig(input) {
2670
3177
  if (!input)
@@ -2808,6 +3315,10 @@ async function generate(configPath) {
2808
3315
  path: join(serverDir, "sdk-bundle.ts"),
2809
3316
  content: emitSdkBundle(clientFiles, clientDir)
2810
3317
  });
3318
+ files.push({
3319
+ path: join(serverDir, "api-contract.ts"),
3320
+ content: emitApiContract(model, cfg)
3321
+ });
2811
3322
  if (generateTests) {
2812
3323
  console.log("\uD83E\uDDEA Generating tests...");
2813
3324
  const relativeClientPath = relative(testDir, clientDir);
@@ -2823,6 +3334,16 @@ async function generate(configPath) {
2823
3334
  path: join(testDir, "run-tests.sh"),
2824
3335
  content: emitTestScript(testFramework)
2825
3336
  });
3337
+ files.push({
3338
+ path: join(testDir, ".gitignore"),
3339
+ content: emitTestGitignore()
3340
+ });
3341
+ if (testFramework === "vitest") {
3342
+ files.push({
3343
+ path: join(testDir, "vitest.config.ts"),
3344
+ content: emitVitestConfig()
3345
+ });
3346
+ }
2826
3347
  for (const table of Object.values(model.tables)) {
2827
3348
  files.push({
2828
3349
  path: join(testDir, `${table.name}.test.ts`),