postgresdk 0.6.5 → 0.6.7

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,35 +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);
2348
- const updateData = generateUpdateData(table);
2371
+ const hasForeignKeys = table.fks.length > 0;
2372
+ const foreignKeySetup = hasForeignKeys ? generateForeignKeySetup(table, clientPath) : null;
2373
+ const sampleData = generateSampleDataFromSchema(table, hasForeignKeys);
2374
+ const updateData = generateUpdateDataFromSchema(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
- * The test data is auto-generated and may need adjustment for your specific schema.
2384
+ * The test data is auto-generated based on your schema.
2358
2385
  *
2359
- * If tests fail due to validation errors:
2360
- * 1. Check which fields are required by your API
2361
- * 2. Update the test data below to match your schema requirements
2362
- * 3. Consider adding your own business logic tests in separate files
2386
+ * If tests fail:
2387
+ * 1. Check the error messages for missing required fields
2388
+ * 2. Update the test data below to match your business requirements
2389
+ * 3. Consider adding custom tests for business logic in separate files
2363
2390
  */
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
  }
@@ -2466,6 +2499,7 @@ version: '3.8'
2466
2499
  services:
2467
2500
  postgres:
2468
2501
  image: postgres:17-alpine
2502
+ container_name: postgresdk-test-database
2469
2503
  environment:
2470
2504
  POSTGRES_USER: testuser
2471
2505
  POSTGRES_PASSWORD: testpass
@@ -2504,7 +2538,39 @@ set -e
2504
2538
 
2505
2539
  SCRIPT_DIR="$( cd "$( dirname "\${BASH_SOURCE[0]}" )" && pwd )"
2506
2540
 
2507
- echo "\uD83D\uDC33 Starting test database..."
2541
+ # Cleanup function to ensure database is stopped
2542
+ cleanup() {
2543
+ echo ""
2544
+ echo "\uD83E\uDDF9 Cleaning up..."
2545
+ if [ ! -z "\${SERVER_PID}" ]; then
2546
+ echo " Stopping API server..."
2547
+ kill $SERVER_PID 2>/dev/null || true
2548
+ fi
2549
+ echo " Stopping test database..."
2550
+ docker-compose -f "$SCRIPT_DIR/docker-compose.yml" stop 2>/dev/null || true
2551
+ echo " Done!"
2552
+ }
2553
+
2554
+ # Set up cleanup trap
2555
+ trap cleanup EXIT INT TERM
2556
+
2557
+ # Check for existing PostgreSQL container or connection
2558
+ echo "\uD83D\uDD0D Checking for existing database connections..."
2559
+ if docker ps | grep -q "5432->5432"; then
2560
+ echo "⚠️ Found existing PostgreSQL container on port 5432"
2561
+ echo " Stopping existing container..."
2562
+ docker ps --filter "publish=5432" --format "{{.ID}}" | xargs -r docker stop
2563
+ sleep 2
2564
+ fi
2565
+
2566
+ # Clean up any existing test database container
2567
+ if docker ps -a | grep -q "postgresdk-test-database"; then
2568
+ echo "\uD83E\uDDF9 Cleaning up existing test database container..."
2569
+ docker-compose -f "$SCRIPT_DIR/docker-compose.yml" down -v
2570
+ sleep 2
2571
+ fi
2572
+
2573
+ echo "\uD83D\uDC33 Starting fresh test database..."
2508
2574
  cd "$SCRIPT_DIR"
2509
2575
  docker-compose up -d --wait
2510
2576
 
@@ -2526,11 +2592,11 @@ echo "⚠️ TODO: Uncomment and customize the API server startup command below
2526
2592
  echo ""
2527
2593
  echo " # Example for Node.js/Bun:"
2528
2594
  echo " # cd ../.. && npm run dev &"
2529
- echo " # SERVER_PID=$!"
2595
+ echo " # SERVER_PID=\\$!"
2530
2596
  echo ""
2531
2597
  echo " # Example for custom server file:"
2532
2598
  echo " # cd ../.. && node server.js &"
2533
- echo " # SERVER_PID=$!"
2599
+ echo " # SERVER_PID=\\$!"
2534
2600
  echo ""
2535
2601
  echo " Please edit this script to start your API server."
2536
2602
  echo ""
@@ -2548,12 +2614,6 @@ ${getTestCommand(framework, runCommand)}
2548
2614
 
2549
2615
  TEST_EXIT_CODE=$?
2550
2616
 
2551
- # Cleanup
2552
- # if [ ! -z "\${SERVER_PID}" ]; then
2553
- # echo "\uD83D\uDED1 Stopping API server..."
2554
- # kill $SERVER_PID 2>/dev/null || true
2555
- # fi
2556
-
2557
2617
  if [ $TEST_EXIT_CODE -eq 0 ]; then
2558
2618
  echo "✅ Tests completed successfully!"
2559
2619
  else
@@ -2564,12 +2624,76 @@ echo ""
2564
2624
  echo "\uD83D\uDCCA Test results saved to:"
2565
2625
  echo " $TEST_RESULTS_DIR/"
2566
2626
  echo ""
2567
- echo "To stop the test database, run:"
2568
- echo " cd $SCRIPT_DIR && docker-compose down"
2627
+ echo "\uD83D\uDCA1 Tips:"
2628
+ echo " - Database will be stopped automatically on script exit"
2629
+ echo " - To manually stop the database: docker-compose -f $SCRIPT_DIR/docker-compose.yml down"
2630
+ echo " - To reset the database: docker-compose -f $SCRIPT_DIR/docker-compose.yml down -v"
2569
2631
 
2570
2632
  exit $TEST_EXIT_CODE
2571
2633
  `;
2572
2634
  }
2635
+ function generateForeignKeySetup(table, clientPath) {
2636
+ const imports = [];
2637
+ const variables = [];
2638
+ const setupStatements = [];
2639
+ const cleanupStatements = [];
2640
+ const foreignTables = new Set;
2641
+ for (const fk of table.fks) {
2642
+ const foreignTable = fk.toTable;
2643
+ if (!foreignTables.has(foreignTable)) {
2644
+ foreignTables.add(foreignTable);
2645
+ const ForeignType = pascal(foreignTable);
2646
+ imports.push(`import type { Insert${ForeignType} } from '${clientPath}/types/${foreignTable}';`);
2647
+ variables.push(`let ${foreignTable}Id: string;`);
2648
+ setupStatements.push(`
2649
+ // Create parent ${foreignTable} record for foreign key reference
2650
+ const ${foreignTable}Data: Insert${ForeignType} = ${generateMinimalDataForTable(foreignTable)};
2651
+ const created${ForeignType} = await sdk.${foreignTable}.create(${foreignTable}Data);
2652
+ ${foreignTable}Id = created${ForeignType}.id;`);
2653
+ cleanupStatements.push(`
2654
+ // Clean up parent ${foreignTable} record
2655
+ if (${foreignTable}Id) {
2656
+ try {
2657
+ await sdk.${foreignTable}.delete(${foreignTable}Id);
2658
+ } catch (e) {
2659
+ // Parent might already be deleted due to cascading
2660
+ }
2661
+ }`);
2662
+ }
2663
+ }
2664
+ return {
2665
+ imports: imports.join(`
2666
+ `),
2667
+ variables: variables.join(`
2668
+ `),
2669
+ setup: setupStatements.join(""),
2670
+ cleanup: cleanupStatements.join("")
2671
+ };
2672
+ }
2673
+ function generateMinimalDataForTable(tableName) {
2674
+ if (tableName.includes("author")) {
2675
+ return `{ name: 'Test Author' }`;
2676
+ }
2677
+ if (tableName.includes("book")) {
2678
+ return `{ title: 'Test Book' }`;
2679
+ }
2680
+ if (tableName.includes("tag")) {
2681
+ return `{ name: 'Test Tag' }`;
2682
+ }
2683
+ if (tableName.includes("user")) {
2684
+ return `{ name: 'Test User', email: 'test@example.com' }`;
2685
+ }
2686
+ if (tableName.includes("category") || tableName.includes("categories")) {
2687
+ return `{ name: 'Test Category' }`;
2688
+ }
2689
+ if (tableName.includes("product")) {
2690
+ return `{ name: 'Test Product', price: 10.99 }`;
2691
+ }
2692
+ if (tableName.includes("order")) {
2693
+ return `{ total: 100.00, status: 'pending' }`;
2694
+ }
2695
+ return `{ name: 'Test ${pascal(tableName)}' }`;
2696
+ }
2573
2697
  function getTestCommand(framework, baseCommand) {
2574
2698
  switch (framework) {
2575
2699
  case "vitest":
@@ -2577,7 +2701,7 @@ function getTestCommand(framework, baseCommand) {
2577
2701
  case "jest":
2578
2702
  return `${baseCommand} --json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
2579
2703
  case "bun":
2580
- return `${baseCommand} "$@" 2>&1 | tee "$TEST_RESULTS_DIR/results-\${TIMESTAMP}.txt"`;
2704
+ return `NO_COLOR=1 ${baseCommand} "$@" 2>&1 | tee "$TEST_RESULTS_DIR/results-\${TIMESTAMP}.txt"`;
2581
2705
  default:
2582
2706
  return `${baseCommand} "$@"`;
2583
2707
  }
@@ -2594,22 +2718,45 @@ function getFrameworkImports(framework) {
2594
2718
  return "import { describe, it, expect, beforeAll, afterAll } from 'vitest';";
2595
2719
  }
2596
2720
  }
2597
- function generateSampleData(table) {
2721
+ function generateSampleDataFromSchema(table, hasForeignKeys = false) {
2598
2722
  const fields = [];
2599
- for (const col of table.columns) {
2600
- if (col.name === "id" && col.hasDefault) {
2601
- continue;
2723
+ const foreignKeyColumns = new Map;
2724
+ if (hasForeignKeys) {
2725
+ for (const fk of table.fks) {
2726
+ for (let i = 0;i < fk.from.length; i++) {
2727
+ const fromCol = fk.from[i];
2728
+ if (fromCol) {
2729
+ foreignKeyColumns.set(fromCol, fk.toTable);
2730
+ }
2731
+ }
2602
2732
  }
2603
- if ((col.name === "created_at" || col.name === "updated_at") && col.hasDefault) {
2604
- continue;
2733
+ }
2734
+ for (const col of table.columns) {
2735
+ if (col.hasDefault) {
2736
+ const autoGenerated = ["id", "created_at", "updated_at", "created", "updated", "modified_at"];
2737
+ if (autoGenerated.includes(col.name.toLowerCase())) {
2738
+ continue;
2739
+ }
2605
2740
  }
2606
- if (col.name === "deleted_at") {
2741
+ if (col.name === "deleted_at" || col.name === "deleted") {
2607
2742
  continue;
2608
2743
  }
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);
2612
- fields.push(` ${col.name}: ${value}`);
2744
+ if (!col.nullable) {
2745
+ const foreignTable = foreignKeyColumns.get(col.name);
2746
+ if (foreignTable) {
2747
+ fields.push(` ${col.name}: ${foreignTable}Id`);
2748
+ } else {
2749
+ const value = generateValueForColumn(col);
2750
+ fields.push(` ${col.name}: ${value}`);
2751
+ }
2752
+ } else {
2753
+ const foreignTable = foreignKeyColumns.get(col.name);
2754
+ if (foreignTable) {
2755
+ fields.push(` ${col.name}: ${foreignTable}Id`);
2756
+ } else if (shouldIncludeNullableColumn(col)) {
2757
+ const value = generateValueForColumn(col);
2758
+ fields.push(` ${col.name}: ${value}`);
2759
+ }
2613
2760
  }
2614
2761
  }
2615
2762
  return fields.length > 0 ? `{
@@ -2617,14 +2764,23 @@ ${fields.join(`,
2617
2764
  `)}
2618
2765
  }` : "{}";
2619
2766
  }
2620
- function generateUpdateData(table) {
2767
+ function generateUpdateDataFromSchema(table) {
2621
2768
  const fields = [];
2622
2769
  for (const col of table.columns) {
2623
- if (col.hasDefault || col.name === "id" || col.name === "created_at" || col.name === "updated_at") {
2770
+ if (table.pk.includes(col.name) || col.hasDefault) {
2771
+ const autoGenerated = ["id", "created_at", "updated_at", "created", "updated", "modified_at"];
2772
+ if (autoGenerated.includes(col.name.toLowerCase())) {
2773
+ continue;
2774
+ }
2775
+ }
2776
+ if (col.name.endsWith("_id")) {
2624
2777
  continue;
2625
2778
  }
2626
- if (!col.nullable && fields.length === 0) {
2627
- const value = getSampleValue(col.pgType, col.name, true);
2779
+ if (col.name === "deleted_at" || col.name === "deleted") {
2780
+ continue;
2781
+ }
2782
+ if (!col.nullable || shouldIncludeNullableColumn(col)) {
2783
+ const value = generateValueForColumn(col, true);
2628
2784
  fields.push(` ${col.name}: ${value}`);
2629
2785
  break;
2630
2786
  }
@@ -2634,59 +2790,107 @@ ${fields.join(`,
2634
2790
  `)}
2635
2791
  }` : "{}";
2636
2792
  }
2637
- function getSampleValue(type, name, isUpdate = false) {
2638
- const suffix = isUpdate ? ' + " (updated)"' : "";
2639
- if (name.endsWith("_id") || name.endsWith("_by")) {
2640
- return `'550e8400-e29b-41d4-a716-446655440000'`;
2641
- }
2793
+ function shouldIncludeNullableColumn(col) {
2794
+ const importantPatterns = [
2795
+ "_id",
2796
+ "_by",
2797
+ "email",
2798
+ "name",
2799
+ "title",
2800
+ "description",
2801
+ "phone",
2802
+ "address",
2803
+ "status",
2804
+ "type",
2805
+ "category",
2806
+ "price",
2807
+ "amount",
2808
+ "quantity",
2809
+ "url",
2810
+ "slug"
2811
+ ];
2812
+ const name = col.name.toLowerCase();
2813
+ return importantPatterns.some((pattern) => name.includes(pattern));
2814
+ }
2815
+ function generateValueForColumn(col, isUpdate = false) {
2816
+ const name = col.name.toLowerCase();
2817
+ const type = col.pgType.toLowerCase();
2642
2818
  if (name.includes("email")) {
2643
2819
  return `'test${isUpdate ? ".updated" : ""}@example.com'`;
2644
2820
  }
2645
- if (name === "color") {
2646
- return `'#${isUpdate ? "FF0000" : "0000FF"}'`;
2821
+ if (name.includes("phone")) {
2822
+ return `'555-${isUpdate ? "0200" : "0100"}'`;
2647
2823
  }
2648
- if (name === "gender") {
2649
- return `'${isUpdate ? "F" : "M"}'`;
2824
+ if (name.includes("url") || name.includes("website")) {
2825
+ return `'https://example.com${isUpdate ? "/updated" : ""}'`;
2650
2826
  }
2651
- if (name.includes("phone")) {
2652
- return `'${isUpdate ? "555-0200" : "555-0100"}'`;
2827
+ if (name.includes("password")) {
2828
+ return `'hashedPassword123'`;
2829
+ }
2830
+ if (name === "name" || name.includes("_name") || name.includes("name_")) {
2831
+ return `'Test ${pascal(col.name)}${isUpdate ? " Updated" : ""}'`;
2832
+ }
2833
+ if (name === "title" || name.includes("title")) {
2834
+ return `'Test Title${isUpdate ? " Updated" : ""}'`;
2835
+ }
2836
+ if (name.includes("description") || name === "bio" || name === "about") {
2837
+ return `'Test description${isUpdate ? " updated" : ""}'`;
2653
2838
  }
2654
- if (name.includes("address")) {
2655
- return `'123 ${isUpdate ? "Updated" : "Test"} Street'`;
2839
+ if (name === "slug") {
2840
+ return `'test-slug${isUpdate ? "-updated" : ""}'`;
2656
2841
  }
2657
- if (name === "type" || name === "status") {
2842
+ if (name === "status") {
2658
2843
  return `'${isUpdate ? "updated" : "active"}'`;
2659
2844
  }
2660
- if (name === "subject") {
2661
- return `'Test Subject${isUpdate ? " Updated" : ""}'`;
2845
+ if (name === "type" || name === "kind" || name === "category") {
2846
+ return `'${isUpdate ? "type2" : "type1"}'`;
2847
+ }
2848
+ if (name === "color" || name === "colour") {
2849
+ return `'${isUpdate ? "#FF0000" : "#0000FF"}'`;
2850
+ }
2851
+ if (name === "gender") {
2852
+ return `'${isUpdate ? "F" : "M"}'`;
2662
2853
  }
2663
- if (name.includes("name") || name.includes("title")) {
2664
- return `'Test ${pascal(name)}'${suffix}`;
2854
+ if (name.includes("price") || name === "cost" || name === "amount") {
2855
+ return isUpdate ? "99.99" : "10.50";
2665
2856
  }
2666
- if (name.includes("description") || name.includes("bio") || name.includes("content")) {
2667
- return `'Test description'${suffix}`;
2857
+ if (name === "quantity" || name === "count" || name.includes("qty")) {
2858
+ return isUpdate ? "5" : "1";
2668
2859
  }
2669
- if (name.includes("preferences") || name.includes("settings")) {
2670
- return `'Test preferences'${suffix}`;
2860
+ if (name === "age") {
2861
+ return isUpdate ? "30" : "25";
2671
2862
  }
2672
- if (name.includes("restrictions") || name.includes("dietary")) {
2673
- return `['vegetarian']`;
2863
+ if (name.includes("percent") || name === "rate" || name === "ratio") {
2864
+ return isUpdate ? "0.75" : "0.5";
2674
2865
  }
2675
- if (name.includes("location") || name.includes("clinic")) {
2676
- return `'Test Location'${suffix}`;
2866
+ if (name.includes("latitude") || name === "lat") {
2867
+ return "40.7128";
2677
2868
  }
2678
- if (name.includes("specialty")) {
2679
- return `'General'`;
2869
+ if (name.includes("longitude") || name === "lng" || name === "lon") {
2870
+ return "-74.0060";
2680
2871
  }
2681
- if (name.includes("tier")) {
2682
- return `'Standard'`;
2872
+ if (type.includes("date") || type.includes("timestamp")) {
2873
+ if (name.includes("birth") || name === "dob") {
2874
+ return `new Date('1990-01-01')`;
2875
+ }
2876
+ if (name.includes("end") || name.includes("expire")) {
2877
+ return `new Date('2025-12-31')`;
2878
+ }
2879
+ if (name.includes("start") || name.includes("begin")) {
2880
+ return `new Date('2024-01-01')`;
2881
+ }
2882
+ return `new Date()`;
2683
2883
  }
2684
2884
  switch (type) {
2685
2885
  case "text":
2686
2886
  case "varchar":
2687
2887
  case "char":
2688
- return `'test_value'${suffix}`;
2888
+ case "character varying":
2889
+ return `'test_value${isUpdate ? "_updated" : ""}'`;
2689
2890
  case "int":
2891
+ case "int2":
2892
+ case "int4":
2893
+ case "int8":
2690
2894
  case "integer":
2691
2895
  case "smallint":
2692
2896
  case "bigint":
@@ -2696,30 +2900,55 @@ function getSampleValue(type, name, isUpdate = false) {
2696
2900
  case "real":
2697
2901
  case "double precision":
2698
2902
  case "float":
2903
+ case "float4":
2904
+ case "float8":
2699
2905
  return isUpdate ? "99.99" : "10.50";
2700
2906
  case "boolean":
2701
2907
  case "bool":
2702
2908
  return isUpdate ? "false" : "true";
2703
- case "date":
2704
- return `'2024-01-01'`;
2705
- case "timestamp":
2706
- case "timestamptz":
2707
- return `new Date().toISOString()`;
2708
2909
  case "json":
2709
2910
  case "jsonb":
2710
- return `{ key: 'value' }`;
2911
+ return `{ key: '${isUpdate ? "updated" : "value"}' }`;
2711
2912
  case "uuid":
2712
2913
  return `'${isUpdate ? "550e8400-e29b-41d4-a716-446655440001" : "550e8400-e29b-41d4-a716-446655440000"}'`;
2713
- case "text[]":
2714
- case "varchar[]":
2715
- return `['item1', 'item2']`;
2914
+ case "inet":
2915
+ return `'${isUpdate ? "192.168.1.2" : "192.168.1.1"}'`;
2916
+ case "cidr":
2917
+ return `'192.168.1.0/24'`;
2918
+ case "macaddr":
2919
+ return `'08:00:2b:01:02:0${isUpdate ? "4" : "3"}'`;
2920
+ case "xml":
2921
+ return `'<root>${isUpdate ? "updated" : "value"}</root>'`;
2716
2922
  default:
2717
- return `'test'`;
2923
+ if (type.endsWith("[]")) {
2924
+ const baseType = type.slice(0, -2);
2925
+ if (baseType === "text" || baseType === "varchar") {
2926
+ return `['item1', 'item2${isUpdate ? "_updated" : ""}']`;
2927
+ }
2928
+ if (baseType === "int" || baseType === "integer") {
2929
+ return `[1, 2, ${isUpdate ? "3" : ""}]`;
2930
+ }
2931
+ return `[]`;
2932
+ }
2933
+ return `'test${isUpdate ? "_updated" : ""}'`;
2718
2934
  }
2719
2935
  }
2720
- function generateTestCases(table, sampleData, updateData) {
2936
+ function generateTestCases(table, sampleData, updateData, hasForeignKeys = false) {
2721
2937
  const Type = pascal(table.name);
2722
2938
  const hasData = sampleData !== "{}";
2939
+ const isJunctionTable = table.pk.length > 1 && table.columns.every((col) => table.pk.includes(col.name) || col.name.endsWith("_id"));
2940
+ if (isJunctionTable) {
2941
+ return `it('should create a ${table.name} relationship', async () => {
2942
+ // This is a junction table for M:N relationships
2943
+ // Test data depends on parent records created in other tests
2944
+ expect(true).toBe(true);
2945
+ });
2946
+
2947
+ it('should list ${table.name} relationships', async () => {
2948
+ const list = await sdk.${table.name}.list({ limit: 10 });
2949
+ expect(Array.isArray(list)).toBe(true);
2950
+ });`;
2951
+ }
2723
2952
  return `it('should create a ${table.name}', async () => {
2724
2953
  const data: Insert${Type} = ${sampleData};
2725
2954
  ${hasData ? `
@@ -2776,6 +3005,280 @@ function generateTestCases(table, sampleData, updateData) {
2776
3005
  });` : ""}`;
2777
3006
  }
2778
3007
 
3008
+ // src/emit-api-contract.ts
3009
+ function generateApiContract(model, config) {
3010
+ const resources = [];
3011
+ const relationships = [];
3012
+ for (const table of Object.values(model.tables)) {
3013
+ resources.push(generateResourceContract(table, model));
3014
+ for (const fk of table.fks) {
3015
+ relationships.push({
3016
+ from: table.name,
3017
+ to: fk.toTable,
3018
+ type: "many-to-one",
3019
+ description: `Each ${table.name} belongs to one ${fk.toTable}`
3020
+ });
3021
+ }
3022
+ }
3023
+ const contract = {
3024
+ version: "1.0.0",
3025
+ generatedAt: new Date().toISOString(),
3026
+ description: "Auto-generated API contract describing all available endpoints, resources, and their relationships",
3027
+ resources,
3028
+ relationships
3029
+ };
3030
+ if (config.auth?.strategy && config.auth.strategy !== "none") {
3031
+ contract.authentication = {
3032
+ type: config.auth.strategy,
3033
+ description: getAuthDescription(config.auth.strategy)
3034
+ };
3035
+ }
3036
+ return contract;
3037
+ }
3038
+ function generateResourceContract(table, model) {
3039
+ const Type = pascal(table.name);
3040
+ const basePath = `/v1/${table.name}`;
3041
+ const endpoints = [
3042
+ {
3043
+ method: "GET",
3044
+ path: basePath,
3045
+ description: `List all ${table.name} records with optional filtering, sorting, and pagination`,
3046
+ queryParameters: {
3047
+ limit: "number - Maximum number of records to return (default: 50)",
3048
+ offset: "number - Number of records to skip for pagination",
3049
+ order_by: "string - Field to sort by",
3050
+ order_dir: "string - Sort direction (asc or desc)",
3051
+ include: "string - Comma-separated list of related resources to include",
3052
+ ...generateFilterParams(table)
3053
+ },
3054
+ responseBody: `Array<${Type}>`
3055
+ },
3056
+ {
3057
+ method: "GET",
3058
+ path: `${basePath}/:id`,
3059
+ description: `Get a single ${table.name} record by ID`,
3060
+ queryParameters: {
3061
+ include: "string - Comma-separated list of related resources to include"
3062
+ },
3063
+ responseBody: `${Type}`
3064
+ },
3065
+ {
3066
+ method: "POST",
3067
+ path: basePath,
3068
+ description: `Create a new ${table.name} record`,
3069
+ requestBody: `Insert${Type}`,
3070
+ responseBody: `${Type}`
3071
+ },
3072
+ {
3073
+ method: "PATCH",
3074
+ path: `${basePath}/:id`,
3075
+ description: `Update an existing ${table.name} record`,
3076
+ requestBody: `Update${Type}`,
3077
+ responseBody: `${Type}`
3078
+ },
3079
+ {
3080
+ method: "DELETE",
3081
+ path: `${basePath}/:id`,
3082
+ description: `Delete a ${table.name} record`,
3083
+ responseBody: `${Type}`
3084
+ }
3085
+ ];
3086
+ const fields = table.columns.map((col) => generateFieldContract(col, table));
3087
+ return {
3088
+ name: Type,
3089
+ tableName: table.name,
3090
+ description: `Resource for managing ${table.name} records`,
3091
+ endpoints,
3092
+ fields
3093
+ };
3094
+ }
3095
+ function generateFieldContract(column, table) {
3096
+ const field = {
3097
+ name: column.name,
3098
+ type: postgresTypeToJsonType(column.pgType),
3099
+ required: !column.nullable && !column.hasDefault,
3100
+ description: generateFieldDescription(column, table)
3101
+ };
3102
+ const fk = table.fks.find((fk2) => fk2.from.length === 1 && fk2.from[0] === column.name);
3103
+ if (fk) {
3104
+ field.foreignKey = {
3105
+ table: fk.toTable,
3106
+ field: fk.to[0] || "id"
3107
+ };
3108
+ }
3109
+ return field;
3110
+ }
3111
+ function generateFieldDescription(column, table) {
3112
+ const descriptions = [];
3113
+ descriptions.push(`${postgresTypeToJsonType(column.pgType)} field`);
3114
+ if (column.name === "id") {
3115
+ descriptions.push("Unique identifier");
3116
+ } else if (column.name === "created_at") {
3117
+ descriptions.push("Timestamp when the record was created");
3118
+ } else if (column.name === "updated_at") {
3119
+ descriptions.push("Timestamp when the record was last updated");
3120
+ } else if (column.name === "deleted_at") {
3121
+ descriptions.push("Soft delete timestamp");
3122
+ } else if (column.name.endsWith("_id")) {
3123
+ const relatedTable = column.name.slice(0, -3);
3124
+ descriptions.push(`Reference to ${relatedTable}`);
3125
+ } else if (column.name.includes("email")) {
3126
+ descriptions.push("Email address");
3127
+ } else if (column.name.includes("phone")) {
3128
+ descriptions.push("Phone number");
3129
+ } else if (column.name.includes("name")) {
3130
+ descriptions.push("Name field");
3131
+ } else if (column.name.includes("description")) {
3132
+ descriptions.push("Description text");
3133
+ } else if (column.name.includes("status")) {
3134
+ descriptions.push("Status indicator");
3135
+ } else if (column.name.includes("price") || column.name.includes("amount") || column.name.includes("total")) {
3136
+ descriptions.push("Monetary value");
3137
+ }
3138
+ if (!column.nullable && !column.hasDefault) {
3139
+ descriptions.push("(required)");
3140
+ } else if (column.nullable) {
3141
+ descriptions.push("(optional)");
3142
+ }
3143
+ return descriptions.join(" - ");
3144
+ }
3145
+ function postgresTypeToJsonType(pgType) {
3146
+ switch (pgType) {
3147
+ case "int":
3148
+ case "integer":
3149
+ case "smallint":
3150
+ case "bigint":
3151
+ case "decimal":
3152
+ case "numeric":
3153
+ case "real":
3154
+ case "double precision":
3155
+ case "float":
3156
+ return "number";
3157
+ case "boolean":
3158
+ case "bool":
3159
+ return "boolean";
3160
+ case "date":
3161
+ case "timestamp":
3162
+ case "timestamptz":
3163
+ return "date/datetime";
3164
+ case "json":
3165
+ case "jsonb":
3166
+ return "object";
3167
+ case "uuid":
3168
+ return "uuid";
3169
+ case "text[]":
3170
+ case "varchar[]":
3171
+ return "array<string>";
3172
+ case "int[]":
3173
+ case "integer[]":
3174
+ return "array<number>";
3175
+ default:
3176
+ return "string";
3177
+ }
3178
+ }
3179
+ function generateFilterParams(table) {
3180
+ const filters = {};
3181
+ for (const col of table.columns) {
3182
+ const type = postgresTypeToJsonType(col.pgType);
3183
+ filters[col.name] = `${type} - Filter by exact ${col.name} value`;
3184
+ if (type === "number" || type === "date/datetime") {
3185
+ filters[`${col.name}_gt`] = `${type} - Filter where ${col.name} is greater than`;
3186
+ filters[`${col.name}_gte`] = `${type} - Filter where ${col.name} is greater than or equal`;
3187
+ filters[`${col.name}_lt`] = `${type} - Filter where ${col.name} is less than`;
3188
+ filters[`${col.name}_lte`] = `${type} - Filter where ${col.name} is less than or equal`;
3189
+ }
3190
+ if (type === "string") {
3191
+ filters[`${col.name}_like`] = `string - Filter where ${col.name} contains text (case-insensitive)`;
3192
+ }
3193
+ }
3194
+ return filters;
3195
+ }
3196
+ function getAuthDescription(strategy) {
3197
+ switch (strategy) {
3198
+ case "jwt":
3199
+ return "JWT Bearer token authentication. Include token in Authorization header: 'Bearer <token>'";
3200
+ case "apiKey":
3201
+ return "API Key authentication. Include key in the configured header (e.g., 'x-api-key')";
3202
+ default:
3203
+ return "Custom authentication strategy";
3204
+ }
3205
+ }
3206
+ function generateApiContractMarkdown(contract) {
3207
+ const lines = [];
3208
+ lines.push("# API Contract");
3209
+ lines.push("");
3210
+ lines.push(contract.description);
3211
+ lines.push("");
3212
+ lines.push(`**Version:** ${contract.version}`);
3213
+ lines.push(`**Generated:** ${new Date(contract.generatedAt).toLocaleString()}`);
3214
+ lines.push("");
3215
+ if (contract.authentication) {
3216
+ lines.push("## Authentication");
3217
+ lines.push("");
3218
+ lines.push(`**Type:** ${contract.authentication.type}`);
3219
+ lines.push("");
3220
+ lines.push(contract.authentication.description);
3221
+ lines.push("");
3222
+ }
3223
+ lines.push("## Resources");
3224
+ lines.push("");
3225
+ for (const resource of contract.resources) {
3226
+ lines.push(`### ${resource.name}`);
3227
+ lines.push("");
3228
+ lines.push(resource.description);
3229
+ lines.push("");
3230
+ lines.push("**Endpoints:**");
3231
+ lines.push("");
3232
+ for (const endpoint of resource.endpoints) {
3233
+ lines.push(`- \`${endpoint.method} ${endpoint.path}\` - ${endpoint.description}`);
3234
+ }
3235
+ lines.push("");
3236
+ lines.push("**Fields:**");
3237
+ lines.push("");
3238
+ for (const field of resource.fields) {
3239
+ const required = field.required ? " *(required)*" : "";
3240
+ const fk = field.foreignKey ? ` → ${field.foreignKey.table}` : "";
3241
+ lines.push(`- \`${field.name}\` (${field.type})${required}${fk} - ${field.description}`);
3242
+ }
3243
+ lines.push("");
3244
+ }
3245
+ if (contract.relationships.length > 0) {
3246
+ lines.push("## Relationships");
3247
+ lines.push("");
3248
+ for (const rel of contract.relationships) {
3249
+ lines.push(`- **${rel.from}** → **${rel.to}** (${rel.type}): ${rel.description}`);
3250
+ }
3251
+ lines.push("");
3252
+ }
3253
+ return lines.join(`
3254
+ `);
3255
+ }
3256
+ function emitApiContract(model, config) {
3257
+ const contract = generateApiContract(model, config);
3258
+ const contractJson = JSON.stringify(contract, null, 2);
3259
+ return `/**
3260
+ * API Contract
3261
+ *
3262
+ * This module exports the API contract that describes all available
3263
+ * endpoints, resources, and their relationships.
3264
+ */
3265
+
3266
+ export const apiContract = ${contractJson};
3267
+
3268
+ export const apiContractMarkdown = \`${generateApiContractMarkdown(contract).replace(/`/g, "\\`")}\`;
3269
+
3270
+ /**
3271
+ * Helper to get the contract in different formats
3272
+ */
3273
+ export function getApiContract(format: 'json' | 'markdown' = 'json') {
3274
+ if (format === 'markdown') {
3275
+ return apiContractMarkdown;
3276
+ }
3277
+ return apiContract;
3278
+ }
3279
+ `;
3280
+ }
3281
+
2779
3282
  // src/types.ts
2780
3283
  function normalizeAuthConfig(input) {
2781
3284
  if (!input)
@@ -2919,6 +3422,10 @@ async function generate(configPath) {
2919
3422
  path: join(serverDir, "sdk-bundle.ts"),
2920
3423
  content: emitSdkBundle(clientFiles, clientDir)
2921
3424
  });
3425
+ files.push({
3426
+ path: join(serverDir, "api-contract.ts"),
3427
+ content: emitApiContract(model, cfg)
3428
+ });
2922
3429
  if (generateTests) {
2923
3430
  console.log("\uD83E\uDDEA Generating tests...");
2924
3431
  const relativeClientPath = relative(testDir, clientDir);