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/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,35 +2098,44 @@ 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);
2078
- const updateData = generateUpdateData(table);
2101
+ const hasForeignKeys = table.fks.length > 0;
2102
+ const foreignKeySetup = hasForeignKeys ? generateForeignKeySetup(table, clientPath) : null;
2103
+ const sampleData = generateSampleDataFromSchema(table, hasForeignKeys);
2104
+ const updateData = generateUpdateDataFromSchema(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
2085
2112
  *
2086
2113
  * These tests demonstrate basic CRUD operations.
2087
- * The test data is auto-generated and may need adjustment for your specific schema.
2114
+ * The test data is auto-generated based on your schema.
2088
2115
  *
2089
- * If tests fail due to validation errors:
2090
- * 1. Check which fields are required by your API
2091
- * 2. Update the test data below to match your schema requirements
2092
- * 3. Consider adding your own business logic tests in separate files
2116
+ * If tests fail:
2117
+ * 1. Check the error messages for missing required fields
2118
+ * 2. Update the test data below to match your business requirements
2119
+ * 3. Consider adding custom tests for business logic in separate files
2093
2120
  */
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
  }
@@ -2196,6 +2229,7 @@ version: '3.8'
2196
2229
  services:
2197
2230
  postgres:
2198
2231
  image: postgres:17-alpine
2232
+ container_name: postgresdk-test-database
2199
2233
  environment:
2200
2234
  POSTGRES_USER: testuser
2201
2235
  POSTGRES_PASSWORD: testpass
@@ -2234,7 +2268,39 @@ set -e
2234
2268
 
2235
2269
  SCRIPT_DIR="$( cd "$( dirname "\${BASH_SOURCE[0]}" )" && pwd )"
2236
2270
 
2237
- echo "\uD83D\uDC33 Starting test database..."
2271
+ # Cleanup function to ensure database is stopped
2272
+ cleanup() {
2273
+ echo ""
2274
+ echo "\uD83E\uDDF9 Cleaning up..."
2275
+ if [ ! -z "\${SERVER_PID}" ]; then
2276
+ echo " Stopping API server..."
2277
+ kill $SERVER_PID 2>/dev/null || true
2278
+ fi
2279
+ echo " Stopping test database..."
2280
+ docker-compose -f "$SCRIPT_DIR/docker-compose.yml" stop 2>/dev/null || true
2281
+ echo " Done!"
2282
+ }
2283
+
2284
+ # Set up cleanup trap
2285
+ trap cleanup EXIT INT TERM
2286
+
2287
+ # Check for existing PostgreSQL container or connection
2288
+ echo "\uD83D\uDD0D Checking for existing database connections..."
2289
+ if docker ps | grep -q "5432->5432"; then
2290
+ echo "⚠️ Found existing PostgreSQL container on port 5432"
2291
+ echo " Stopping existing container..."
2292
+ docker ps --filter "publish=5432" --format "{{.ID}}" | xargs -r docker stop
2293
+ sleep 2
2294
+ fi
2295
+
2296
+ # Clean up any existing test database container
2297
+ if docker ps -a | grep -q "postgresdk-test-database"; then
2298
+ echo "\uD83E\uDDF9 Cleaning up existing test database container..."
2299
+ docker-compose -f "$SCRIPT_DIR/docker-compose.yml" down -v
2300
+ sleep 2
2301
+ fi
2302
+
2303
+ echo "\uD83D\uDC33 Starting fresh test database..."
2238
2304
  cd "$SCRIPT_DIR"
2239
2305
  docker-compose up -d --wait
2240
2306
 
@@ -2256,11 +2322,11 @@ echo "⚠️ TODO: Uncomment and customize the API server startup command below
2256
2322
  echo ""
2257
2323
  echo " # Example for Node.js/Bun:"
2258
2324
  echo " # cd ../.. && npm run dev &"
2259
- echo " # SERVER_PID=$!"
2325
+ echo " # SERVER_PID=\\$!"
2260
2326
  echo ""
2261
2327
  echo " # Example for custom server file:"
2262
2328
  echo " # cd ../.. && node server.js &"
2263
- echo " # SERVER_PID=$!"
2329
+ echo " # SERVER_PID=\\$!"
2264
2330
  echo ""
2265
2331
  echo " Please edit this script to start your API server."
2266
2332
  echo ""
@@ -2278,12 +2344,6 @@ ${getTestCommand(framework, runCommand)}
2278
2344
 
2279
2345
  TEST_EXIT_CODE=$?
2280
2346
 
2281
- # Cleanup
2282
- # if [ ! -z "\${SERVER_PID}" ]; then
2283
- # echo "\uD83D\uDED1 Stopping API server..."
2284
- # kill $SERVER_PID 2>/dev/null || true
2285
- # fi
2286
-
2287
2347
  if [ $TEST_EXIT_CODE -eq 0 ]; then
2288
2348
  echo "✅ Tests completed successfully!"
2289
2349
  else
@@ -2294,12 +2354,76 @@ echo ""
2294
2354
  echo "\uD83D\uDCCA Test results saved to:"
2295
2355
  echo " $TEST_RESULTS_DIR/"
2296
2356
  echo ""
2297
- echo "To stop the test database, run:"
2298
- echo " cd $SCRIPT_DIR && docker-compose down"
2357
+ echo "\uD83D\uDCA1 Tips:"
2358
+ echo " - Database will be stopped automatically on script exit"
2359
+ echo " - To manually stop the database: docker-compose -f $SCRIPT_DIR/docker-compose.yml down"
2360
+ echo " - To reset the database: docker-compose -f $SCRIPT_DIR/docker-compose.yml down -v"
2299
2361
 
2300
2362
  exit $TEST_EXIT_CODE
2301
2363
  `;
2302
2364
  }
2365
+ function generateForeignKeySetup(table, clientPath) {
2366
+ const imports = [];
2367
+ const variables = [];
2368
+ const setupStatements = [];
2369
+ const cleanupStatements = [];
2370
+ const foreignTables = new Set;
2371
+ for (const fk of table.fks) {
2372
+ const foreignTable = fk.toTable;
2373
+ if (!foreignTables.has(foreignTable)) {
2374
+ foreignTables.add(foreignTable);
2375
+ const ForeignType = pascal(foreignTable);
2376
+ imports.push(`import type { Insert${ForeignType} } from '${clientPath}/types/${foreignTable}';`);
2377
+ variables.push(`let ${foreignTable}Id: string;`);
2378
+ setupStatements.push(`
2379
+ // Create parent ${foreignTable} record for foreign key reference
2380
+ const ${foreignTable}Data: Insert${ForeignType} = ${generateMinimalDataForTable(foreignTable)};
2381
+ const created${ForeignType} = await sdk.${foreignTable}.create(${foreignTable}Data);
2382
+ ${foreignTable}Id = created${ForeignType}.id;`);
2383
+ cleanupStatements.push(`
2384
+ // Clean up parent ${foreignTable} record
2385
+ if (${foreignTable}Id) {
2386
+ try {
2387
+ await sdk.${foreignTable}.delete(${foreignTable}Id);
2388
+ } catch (e) {
2389
+ // Parent might already be deleted due to cascading
2390
+ }
2391
+ }`);
2392
+ }
2393
+ }
2394
+ return {
2395
+ imports: imports.join(`
2396
+ `),
2397
+ variables: variables.join(`
2398
+ `),
2399
+ setup: setupStatements.join(""),
2400
+ cleanup: cleanupStatements.join("")
2401
+ };
2402
+ }
2403
+ function generateMinimalDataForTable(tableName) {
2404
+ if (tableName.includes("author")) {
2405
+ return `{ name: 'Test Author' }`;
2406
+ }
2407
+ if (tableName.includes("book")) {
2408
+ return `{ title: 'Test Book' }`;
2409
+ }
2410
+ if (tableName.includes("tag")) {
2411
+ return `{ name: 'Test Tag' }`;
2412
+ }
2413
+ if (tableName.includes("user")) {
2414
+ return `{ name: 'Test User', email: 'test@example.com' }`;
2415
+ }
2416
+ if (tableName.includes("category") || tableName.includes("categories")) {
2417
+ return `{ name: 'Test Category' }`;
2418
+ }
2419
+ if (tableName.includes("product")) {
2420
+ return `{ name: 'Test Product', price: 10.99 }`;
2421
+ }
2422
+ if (tableName.includes("order")) {
2423
+ return `{ total: 100.00, status: 'pending' }`;
2424
+ }
2425
+ return `{ name: 'Test ${pascal(tableName)}' }`;
2426
+ }
2303
2427
  function getTestCommand(framework, baseCommand) {
2304
2428
  switch (framework) {
2305
2429
  case "vitest":
@@ -2307,7 +2431,7 @@ function getTestCommand(framework, baseCommand) {
2307
2431
  case "jest":
2308
2432
  return `${baseCommand} --json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
2309
2433
  case "bun":
2310
- return `${baseCommand} "$@" 2>&1 | tee "$TEST_RESULTS_DIR/results-\${TIMESTAMP}.txt"`;
2434
+ return `NO_COLOR=1 ${baseCommand} "$@" 2>&1 | tee "$TEST_RESULTS_DIR/results-\${TIMESTAMP}.txt"`;
2311
2435
  default:
2312
2436
  return `${baseCommand} "$@"`;
2313
2437
  }
@@ -2324,22 +2448,45 @@ function getFrameworkImports(framework) {
2324
2448
  return "import { describe, it, expect, beforeAll, afterAll } from 'vitest';";
2325
2449
  }
2326
2450
  }
2327
- function generateSampleData(table) {
2451
+ function generateSampleDataFromSchema(table, hasForeignKeys = false) {
2328
2452
  const fields = [];
2329
- for (const col of table.columns) {
2330
- if (col.name === "id" && col.hasDefault) {
2331
- continue;
2453
+ const foreignKeyColumns = new Map;
2454
+ if (hasForeignKeys) {
2455
+ for (const fk of table.fks) {
2456
+ for (let i = 0;i < fk.from.length; i++) {
2457
+ const fromCol = fk.from[i];
2458
+ if (fromCol) {
2459
+ foreignKeyColumns.set(fromCol, fk.toTable);
2460
+ }
2461
+ }
2332
2462
  }
2333
- if ((col.name === "created_at" || col.name === "updated_at") && col.hasDefault) {
2334
- continue;
2463
+ }
2464
+ for (const col of table.columns) {
2465
+ if (col.hasDefault) {
2466
+ const autoGenerated = ["id", "created_at", "updated_at", "created", "updated", "modified_at"];
2467
+ if (autoGenerated.includes(col.name.toLowerCase())) {
2468
+ continue;
2469
+ }
2335
2470
  }
2336
- if (col.name === "deleted_at") {
2471
+ if (col.name === "deleted_at" || col.name === "deleted") {
2337
2472
  continue;
2338
2473
  }
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);
2342
- fields.push(` ${col.name}: ${value}`);
2474
+ if (!col.nullable) {
2475
+ const foreignTable = foreignKeyColumns.get(col.name);
2476
+ if (foreignTable) {
2477
+ fields.push(` ${col.name}: ${foreignTable}Id`);
2478
+ } else {
2479
+ const value = generateValueForColumn(col);
2480
+ fields.push(` ${col.name}: ${value}`);
2481
+ }
2482
+ } else {
2483
+ const foreignTable = foreignKeyColumns.get(col.name);
2484
+ if (foreignTable) {
2485
+ fields.push(` ${col.name}: ${foreignTable}Id`);
2486
+ } else if (shouldIncludeNullableColumn(col)) {
2487
+ const value = generateValueForColumn(col);
2488
+ fields.push(` ${col.name}: ${value}`);
2489
+ }
2343
2490
  }
2344
2491
  }
2345
2492
  return fields.length > 0 ? `{
@@ -2347,14 +2494,23 @@ ${fields.join(`,
2347
2494
  `)}
2348
2495
  }` : "{}";
2349
2496
  }
2350
- function generateUpdateData(table) {
2497
+ function generateUpdateDataFromSchema(table) {
2351
2498
  const fields = [];
2352
2499
  for (const col of table.columns) {
2353
- if (col.hasDefault || col.name === "id" || col.name === "created_at" || col.name === "updated_at") {
2500
+ if (table.pk.includes(col.name) || col.hasDefault) {
2501
+ const autoGenerated = ["id", "created_at", "updated_at", "created", "updated", "modified_at"];
2502
+ if (autoGenerated.includes(col.name.toLowerCase())) {
2503
+ continue;
2504
+ }
2505
+ }
2506
+ if (col.name.endsWith("_id")) {
2354
2507
  continue;
2355
2508
  }
2356
- if (!col.nullable && fields.length === 0) {
2357
- const value = getSampleValue(col.pgType, col.name, true);
2509
+ if (col.name === "deleted_at" || col.name === "deleted") {
2510
+ continue;
2511
+ }
2512
+ if (!col.nullable || shouldIncludeNullableColumn(col)) {
2513
+ const value = generateValueForColumn(col, true);
2358
2514
  fields.push(` ${col.name}: ${value}`);
2359
2515
  break;
2360
2516
  }
@@ -2364,59 +2520,107 @@ ${fields.join(`,
2364
2520
  `)}
2365
2521
  }` : "{}";
2366
2522
  }
2367
- function getSampleValue(type, name, isUpdate = false) {
2368
- const suffix = isUpdate ? ' + " (updated)"' : "";
2369
- if (name.endsWith("_id") || name.endsWith("_by")) {
2370
- return `'550e8400-e29b-41d4-a716-446655440000'`;
2371
- }
2523
+ function shouldIncludeNullableColumn(col) {
2524
+ const importantPatterns = [
2525
+ "_id",
2526
+ "_by",
2527
+ "email",
2528
+ "name",
2529
+ "title",
2530
+ "description",
2531
+ "phone",
2532
+ "address",
2533
+ "status",
2534
+ "type",
2535
+ "category",
2536
+ "price",
2537
+ "amount",
2538
+ "quantity",
2539
+ "url",
2540
+ "slug"
2541
+ ];
2542
+ const name = col.name.toLowerCase();
2543
+ return importantPatterns.some((pattern) => name.includes(pattern));
2544
+ }
2545
+ function generateValueForColumn(col, isUpdate = false) {
2546
+ const name = col.name.toLowerCase();
2547
+ const type = col.pgType.toLowerCase();
2372
2548
  if (name.includes("email")) {
2373
2549
  return `'test${isUpdate ? ".updated" : ""}@example.com'`;
2374
2550
  }
2375
- if (name === "color") {
2376
- return `'#${isUpdate ? "FF0000" : "0000FF"}'`;
2551
+ if (name.includes("phone")) {
2552
+ return `'555-${isUpdate ? "0200" : "0100"}'`;
2377
2553
  }
2378
- if (name === "gender") {
2379
- return `'${isUpdate ? "F" : "M"}'`;
2554
+ if (name.includes("url") || name.includes("website")) {
2555
+ return `'https://example.com${isUpdate ? "/updated" : ""}'`;
2380
2556
  }
2381
- if (name.includes("phone")) {
2382
- return `'${isUpdate ? "555-0200" : "555-0100"}'`;
2557
+ if (name.includes("password")) {
2558
+ return `'hashedPassword123'`;
2559
+ }
2560
+ if (name === "name" || name.includes("_name") || name.includes("name_")) {
2561
+ return `'Test ${pascal(col.name)}${isUpdate ? " Updated" : ""}'`;
2562
+ }
2563
+ if (name === "title" || name.includes("title")) {
2564
+ return `'Test Title${isUpdate ? " Updated" : ""}'`;
2565
+ }
2566
+ if (name.includes("description") || name === "bio" || name === "about") {
2567
+ return `'Test description${isUpdate ? " updated" : ""}'`;
2383
2568
  }
2384
- if (name.includes("address")) {
2385
- return `'123 ${isUpdate ? "Updated" : "Test"} Street'`;
2569
+ if (name === "slug") {
2570
+ return `'test-slug${isUpdate ? "-updated" : ""}'`;
2386
2571
  }
2387
- if (name === "type" || name === "status") {
2572
+ if (name === "status") {
2388
2573
  return `'${isUpdate ? "updated" : "active"}'`;
2389
2574
  }
2390
- if (name === "subject") {
2391
- return `'Test Subject${isUpdate ? " Updated" : ""}'`;
2575
+ if (name === "type" || name === "kind" || name === "category") {
2576
+ return `'${isUpdate ? "type2" : "type1"}'`;
2577
+ }
2578
+ if (name === "color" || name === "colour") {
2579
+ return `'${isUpdate ? "#FF0000" : "#0000FF"}'`;
2580
+ }
2581
+ if (name === "gender") {
2582
+ return `'${isUpdate ? "F" : "M"}'`;
2392
2583
  }
2393
- if (name.includes("name") || name.includes("title")) {
2394
- return `'Test ${pascal(name)}'${suffix}`;
2584
+ if (name.includes("price") || name === "cost" || name === "amount") {
2585
+ return isUpdate ? "99.99" : "10.50";
2395
2586
  }
2396
- if (name.includes("description") || name.includes("bio") || name.includes("content")) {
2397
- return `'Test description'${suffix}`;
2587
+ if (name === "quantity" || name === "count" || name.includes("qty")) {
2588
+ return isUpdate ? "5" : "1";
2398
2589
  }
2399
- if (name.includes("preferences") || name.includes("settings")) {
2400
- return `'Test preferences'${suffix}`;
2590
+ if (name === "age") {
2591
+ return isUpdate ? "30" : "25";
2401
2592
  }
2402
- if (name.includes("restrictions") || name.includes("dietary")) {
2403
- return `['vegetarian']`;
2593
+ if (name.includes("percent") || name === "rate" || name === "ratio") {
2594
+ return isUpdate ? "0.75" : "0.5";
2404
2595
  }
2405
- if (name.includes("location") || name.includes("clinic")) {
2406
- return `'Test Location'${suffix}`;
2596
+ if (name.includes("latitude") || name === "lat") {
2597
+ return "40.7128";
2407
2598
  }
2408
- if (name.includes("specialty")) {
2409
- return `'General'`;
2599
+ if (name.includes("longitude") || name === "lng" || name === "lon") {
2600
+ return "-74.0060";
2410
2601
  }
2411
- if (name.includes("tier")) {
2412
- return `'Standard'`;
2602
+ if (type.includes("date") || type.includes("timestamp")) {
2603
+ if (name.includes("birth") || name === "dob") {
2604
+ return `new Date('1990-01-01')`;
2605
+ }
2606
+ if (name.includes("end") || name.includes("expire")) {
2607
+ return `new Date('2025-12-31')`;
2608
+ }
2609
+ if (name.includes("start") || name.includes("begin")) {
2610
+ return `new Date('2024-01-01')`;
2611
+ }
2612
+ return `new Date()`;
2413
2613
  }
2414
2614
  switch (type) {
2415
2615
  case "text":
2416
2616
  case "varchar":
2417
2617
  case "char":
2418
- return `'test_value'${suffix}`;
2618
+ case "character varying":
2619
+ return `'test_value${isUpdate ? "_updated" : ""}'`;
2419
2620
  case "int":
2621
+ case "int2":
2622
+ case "int4":
2623
+ case "int8":
2420
2624
  case "integer":
2421
2625
  case "smallint":
2422
2626
  case "bigint":
@@ -2426,30 +2630,55 @@ function getSampleValue(type, name, isUpdate = false) {
2426
2630
  case "real":
2427
2631
  case "double precision":
2428
2632
  case "float":
2633
+ case "float4":
2634
+ case "float8":
2429
2635
  return isUpdate ? "99.99" : "10.50";
2430
2636
  case "boolean":
2431
2637
  case "bool":
2432
2638
  return isUpdate ? "false" : "true";
2433
- case "date":
2434
- return `'2024-01-01'`;
2435
- case "timestamp":
2436
- case "timestamptz":
2437
- return `new Date().toISOString()`;
2438
2639
  case "json":
2439
2640
  case "jsonb":
2440
- return `{ key: 'value' }`;
2641
+ return `{ key: '${isUpdate ? "updated" : "value"}' }`;
2441
2642
  case "uuid":
2442
2643
  return `'${isUpdate ? "550e8400-e29b-41d4-a716-446655440001" : "550e8400-e29b-41d4-a716-446655440000"}'`;
2443
- case "text[]":
2444
- case "varchar[]":
2445
- return `['item1', 'item2']`;
2644
+ case "inet":
2645
+ return `'${isUpdate ? "192.168.1.2" : "192.168.1.1"}'`;
2646
+ case "cidr":
2647
+ return `'192.168.1.0/24'`;
2648
+ case "macaddr":
2649
+ return `'08:00:2b:01:02:0${isUpdate ? "4" : "3"}'`;
2650
+ case "xml":
2651
+ return `'<root>${isUpdate ? "updated" : "value"}</root>'`;
2446
2652
  default:
2447
- return `'test'`;
2653
+ if (type.endsWith("[]")) {
2654
+ const baseType = type.slice(0, -2);
2655
+ if (baseType === "text" || baseType === "varchar") {
2656
+ return `['item1', 'item2${isUpdate ? "_updated" : ""}']`;
2657
+ }
2658
+ if (baseType === "int" || baseType === "integer") {
2659
+ return `[1, 2, ${isUpdate ? "3" : ""}]`;
2660
+ }
2661
+ return `[]`;
2662
+ }
2663
+ return `'test${isUpdate ? "_updated" : ""}'`;
2448
2664
  }
2449
2665
  }
2450
- function generateTestCases(table, sampleData, updateData) {
2666
+ function generateTestCases(table, sampleData, updateData, hasForeignKeys = false) {
2451
2667
  const Type = pascal(table.name);
2452
2668
  const hasData = sampleData !== "{}";
2669
+ const isJunctionTable = table.pk.length > 1 && table.columns.every((col) => table.pk.includes(col.name) || col.name.endsWith("_id"));
2670
+ if (isJunctionTable) {
2671
+ return `it('should create a ${table.name} relationship', async () => {
2672
+ // This is a junction table for M:N relationships
2673
+ // Test data depends on parent records created in other tests
2674
+ expect(true).toBe(true);
2675
+ });
2676
+
2677
+ it('should list ${table.name} relationships', async () => {
2678
+ const list = await sdk.${table.name}.list({ limit: 10 });
2679
+ expect(Array.isArray(list)).toBe(true);
2680
+ });`;
2681
+ }
2453
2682
  return `it('should create a ${table.name}', async () => {
2454
2683
  const data: Insert${Type} = ${sampleData};
2455
2684
  ${hasData ? `
@@ -2506,6 +2735,280 @@ function generateTestCases(table, sampleData, updateData) {
2506
2735
  });` : ""}`;
2507
2736
  }
2508
2737
 
2738
+ // src/emit-api-contract.ts
2739
+ function generateApiContract(model, config) {
2740
+ const resources = [];
2741
+ const relationships = [];
2742
+ for (const table of Object.values(model.tables)) {
2743
+ resources.push(generateResourceContract(table, model));
2744
+ for (const fk of table.fks) {
2745
+ relationships.push({
2746
+ from: table.name,
2747
+ to: fk.toTable,
2748
+ type: "many-to-one",
2749
+ description: `Each ${table.name} belongs to one ${fk.toTable}`
2750
+ });
2751
+ }
2752
+ }
2753
+ const contract = {
2754
+ version: "1.0.0",
2755
+ generatedAt: new Date().toISOString(),
2756
+ description: "Auto-generated API contract describing all available endpoints, resources, and their relationships",
2757
+ resources,
2758
+ relationships
2759
+ };
2760
+ if (config.auth?.strategy && config.auth.strategy !== "none") {
2761
+ contract.authentication = {
2762
+ type: config.auth.strategy,
2763
+ description: getAuthDescription(config.auth.strategy)
2764
+ };
2765
+ }
2766
+ return contract;
2767
+ }
2768
+ function generateResourceContract(table, model) {
2769
+ const Type = pascal(table.name);
2770
+ const basePath = `/v1/${table.name}`;
2771
+ const endpoints = [
2772
+ {
2773
+ method: "GET",
2774
+ path: basePath,
2775
+ description: `List all ${table.name} records with optional filtering, sorting, and pagination`,
2776
+ queryParameters: {
2777
+ limit: "number - Maximum number of records to return (default: 50)",
2778
+ offset: "number - Number of records to skip for pagination",
2779
+ order_by: "string - Field to sort by",
2780
+ order_dir: "string - Sort direction (asc or desc)",
2781
+ include: "string - Comma-separated list of related resources to include",
2782
+ ...generateFilterParams(table)
2783
+ },
2784
+ responseBody: `Array<${Type}>`
2785
+ },
2786
+ {
2787
+ method: "GET",
2788
+ path: `${basePath}/:id`,
2789
+ description: `Get a single ${table.name} record by ID`,
2790
+ queryParameters: {
2791
+ include: "string - Comma-separated list of related resources to include"
2792
+ },
2793
+ responseBody: `${Type}`
2794
+ },
2795
+ {
2796
+ method: "POST",
2797
+ path: basePath,
2798
+ description: `Create a new ${table.name} record`,
2799
+ requestBody: `Insert${Type}`,
2800
+ responseBody: `${Type}`
2801
+ },
2802
+ {
2803
+ method: "PATCH",
2804
+ path: `${basePath}/:id`,
2805
+ description: `Update an existing ${table.name} record`,
2806
+ requestBody: `Update${Type}`,
2807
+ responseBody: `${Type}`
2808
+ },
2809
+ {
2810
+ method: "DELETE",
2811
+ path: `${basePath}/:id`,
2812
+ description: `Delete a ${table.name} record`,
2813
+ responseBody: `${Type}`
2814
+ }
2815
+ ];
2816
+ const fields = table.columns.map((col) => generateFieldContract(col, table));
2817
+ return {
2818
+ name: Type,
2819
+ tableName: table.name,
2820
+ description: `Resource for managing ${table.name} records`,
2821
+ endpoints,
2822
+ fields
2823
+ };
2824
+ }
2825
+ function generateFieldContract(column, table) {
2826
+ const field = {
2827
+ name: column.name,
2828
+ type: postgresTypeToJsonType(column.pgType),
2829
+ required: !column.nullable && !column.hasDefault,
2830
+ description: generateFieldDescription(column, table)
2831
+ };
2832
+ const fk = table.fks.find((fk2) => fk2.from.length === 1 && fk2.from[0] === column.name);
2833
+ if (fk) {
2834
+ field.foreignKey = {
2835
+ table: fk.toTable,
2836
+ field: fk.to[0] || "id"
2837
+ };
2838
+ }
2839
+ return field;
2840
+ }
2841
+ function generateFieldDescription(column, table) {
2842
+ const descriptions = [];
2843
+ descriptions.push(`${postgresTypeToJsonType(column.pgType)} field`);
2844
+ if (column.name === "id") {
2845
+ descriptions.push("Unique identifier");
2846
+ } else if (column.name === "created_at") {
2847
+ descriptions.push("Timestamp when the record was created");
2848
+ } else if (column.name === "updated_at") {
2849
+ descriptions.push("Timestamp when the record was last updated");
2850
+ } else if (column.name === "deleted_at") {
2851
+ descriptions.push("Soft delete timestamp");
2852
+ } else if (column.name.endsWith("_id")) {
2853
+ const relatedTable = column.name.slice(0, -3);
2854
+ descriptions.push(`Reference to ${relatedTable}`);
2855
+ } else if (column.name.includes("email")) {
2856
+ descriptions.push("Email address");
2857
+ } else if (column.name.includes("phone")) {
2858
+ descriptions.push("Phone number");
2859
+ } else if (column.name.includes("name")) {
2860
+ descriptions.push("Name field");
2861
+ } else if (column.name.includes("description")) {
2862
+ descriptions.push("Description text");
2863
+ } else if (column.name.includes("status")) {
2864
+ descriptions.push("Status indicator");
2865
+ } else if (column.name.includes("price") || column.name.includes("amount") || column.name.includes("total")) {
2866
+ descriptions.push("Monetary value");
2867
+ }
2868
+ if (!column.nullable && !column.hasDefault) {
2869
+ descriptions.push("(required)");
2870
+ } else if (column.nullable) {
2871
+ descriptions.push("(optional)");
2872
+ }
2873
+ return descriptions.join(" - ");
2874
+ }
2875
+ function postgresTypeToJsonType(pgType) {
2876
+ switch (pgType) {
2877
+ case "int":
2878
+ case "integer":
2879
+ case "smallint":
2880
+ case "bigint":
2881
+ case "decimal":
2882
+ case "numeric":
2883
+ case "real":
2884
+ case "double precision":
2885
+ case "float":
2886
+ return "number";
2887
+ case "boolean":
2888
+ case "bool":
2889
+ return "boolean";
2890
+ case "date":
2891
+ case "timestamp":
2892
+ case "timestamptz":
2893
+ return "date/datetime";
2894
+ case "json":
2895
+ case "jsonb":
2896
+ return "object";
2897
+ case "uuid":
2898
+ return "uuid";
2899
+ case "text[]":
2900
+ case "varchar[]":
2901
+ return "array<string>";
2902
+ case "int[]":
2903
+ case "integer[]":
2904
+ return "array<number>";
2905
+ default:
2906
+ return "string";
2907
+ }
2908
+ }
2909
+ function generateFilterParams(table) {
2910
+ const filters = {};
2911
+ for (const col of table.columns) {
2912
+ const type = postgresTypeToJsonType(col.pgType);
2913
+ filters[col.name] = `${type} - Filter by exact ${col.name} value`;
2914
+ if (type === "number" || type === "date/datetime") {
2915
+ filters[`${col.name}_gt`] = `${type} - Filter where ${col.name} is greater than`;
2916
+ filters[`${col.name}_gte`] = `${type} - Filter where ${col.name} is greater than or equal`;
2917
+ filters[`${col.name}_lt`] = `${type} - Filter where ${col.name} is less than`;
2918
+ filters[`${col.name}_lte`] = `${type} - Filter where ${col.name} is less than or equal`;
2919
+ }
2920
+ if (type === "string") {
2921
+ filters[`${col.name}_like`] = `string - Filter where ${col.name} contains text (case-insensitive)`;
2922
+ }
2923
+ }
2924
+ return filters;
2925
+ }
2926
+ function getAuthDescription(strategy) {
2927
+ switch (strategy) {
2928
+ case "jwt":
2929
+ return "JWT Bearer token authentication. Include token in Authorization header: 'Bearer <token>'";
2930
+ case "apiKey":
2931
+ return "API Key authentication. Include key in the configured header (e.g., 'x-api-key')";
2932
+ default:
2933
+ return "Custom authentication strategy";
2934
+ }
2935
+ }
2936
+ function generateApiContractMarkdown(contract) {
2937
+ const lines = [];
2938
+ lines.push("# API Contract");
2939
+ lines.push("");
2940
+ lines.push(contract.description);
2941
+ lines.push("");
2942
+ lines.push(`**Version:** ${contract.version}`);
2943
+ lines.push(`**Generated:** ${new Date(contract.generatedAt).toLocaleString()}`);
2944
+ lines.push("");
2945
+ if (contract.authentication) {
2946
+ lines.push("## Authentication");
2947
+ lines.push("");
2948
+ lines.push(`**Type:** ${contract.authentication.type}`);
2949
+ lines.push("");
2950
+ lines.push(contract.authentication.description);
2951
+ lines.push("");
2952
+ }
2953
+ lines.push("## Resources");
2954
+ lines.push("");
2955
+ for (const resource of contract.resources) {
2956
+ lines.push(`### ${resource.name}`);
2957
+ lines.push("");
2958
+ lines.push(resource.description);
2959
+ lines.push("");
2960
+ lines.push("**Endpoints:**");
2961
+ lines.push("");
2962
+ for (const endpoint of resource.endpoints) {
2963
+ lines.push(`- \`${endpoint.method} ${endpoint.path}\` - ${endpoint.description}`);
2964
+ }
2965
+ lines.push("");
2966
+ lines.push("**Fields:**");
2967
+ lines.push("");
2968
+ for (const field of resource.fields) {
2969
+ const required = field.required ? " *(required)*" : "";
2970
+ const fk = field.foreignKey ? ` → ${field.foreignKey.table}` : "";
2971
+ lines.push(`- \`${field.name}\` (${field.type})${required}${fk} - ${field.description}`);
2972
+ }
2973
+ lines.push("");
2974
+ }
2975
+ if (contract.relationships.length > 0) {
2976
+ lines.push("## Relationships");
2977
+ lines.push("");
2978
+ for (const rel of contract.relationships) {
2979
+ lines.push(`- **${rel.from}** → **${rel.to}** (${rel.type}): ${rel.description}`);
2980
+ }
2981
+ lines.push("");
2982
+ }
2983
+ return lines.join(`
2984
+ `);
2985
+ }
2986
+ function emitApiContract(model, config) {
2987
+ const contract = generateApiContract(model, config);
2988
+ const contractJson = JSON.stringify(contract, null, 2);
2989
+ return `/**
2990
+ * API Contract
2991
+ *
2992
+ * This module exports the API contract that describes all available
2993
+ * endpoints, resources, and their relationships.
2994
+ */
2995
+
2996
+ export const apiContract = ${contractJson};
2997
+
2998
+ export const apiContractMarkdown = \`${generateApiContractMarkdown(contract).replace(/`/g, "\\`")}\`;
2999
+
3000
+ /**
3001
+ * Helper to get the contract in different formats
3002
+ */
3003
+ export function getApiContract(format: 'json' | 'markdown' = 'json') {
3004
+ if (format === 'markdown') {
3005
+ return apiContractMarkdown;
3006
+ }
3007
+ return apiContract;
3008
+ }
3009
+ `;
3010
+ }
3011
+
2509
3012
  // src/types.ts
2510
3013
  function normalizeAuthConfig(input) {
2511
3014
  if (!input)
@@ -2649,6 +3152,10 @@ async function generate(configPath) {
2649
3152
  path: join(serverDir, "sdk-bundle.ts"),
2650
3153
  content: emitSdkBundle(clientFiles, clientDir)
2651
3154
  });
3155
+ files.push({
3156
+ path: join(serverDir, "api-contract.ts"),
3157
+ content: emitApiContract(model, cfg)
3158
+ });
2652
3159
  if (generateTests) {
2653
3160
  console.log("\uD83E\uDDEA Generating tests...");
2654
3161
  const relativeClientPath = relative(testDir, clientDir);