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 +535 -14
- package/dist/emit-api-contract.d.ts +60 -0
- package/dist/emit-tests.d.ts +8 -0
- package/dist/index.js +535 -14
- package/package.json +2 -1
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,30 +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
|
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
|
2085
2112
|
*
|
2086
2113
|
* These tests demonstrate basic CRUD operations.
|
2087
|
-
*
|
2114
|
+
* The test data is auto-generated and may need adjustment for your specific schema.
|
2115
|
+
*
|
2116
|
+
* If tests fail due to validation errors:
|
2117
|
+
* 1. Check which fields are required by your API
|
2118
|
+
* 2. Update the test data below to match your schema requirements
|
2119
|
+
* 3. Consider adding your own business logic tests in separate files
|
2088
2120
|
*/
|
2089
2121
|
describe('${Type} SDK Operations', () => {
|
2090
2122
|
let sdk: SDK;
|
2091
2123
|
let createdId: string;
|
2124
|
+
${foreignKeySetup?.variables || ""}
|
2092
2125
|
|
2093
|
-
beforeAll(() => {
|
2126
|
+
beforeAll(async () => {
|
2094
2127
|
sdk = new SDK({
|
2095
2128
|
baseUrl: process.env.API_URL || 'http://localhost:3000',
|
2096
2129
|
auth: process.env.API_KEY ? { apiKey: process.env.API_KEY } : undefined
|
2097
2130
|
});
|
2131
|
+
${foreignKeySetup?.setup || ""}
|
2098
2132
|
});
|
2099
2133
|
|
2100
|
-
${
|
2134
|
+
${hasForeignKeys && foreignKeySetup?.cleanup ? `afterAll(async () => {
|
2135
|
+
${foreignKeySetup.cleanup}
|
2136
|
+
});
|
2137
|
+
|
2138
|
+
` : ""}${generateTestCases(table, sampleData, updateData, hasForeignKeys)}
|
2101
2139
|
});
|
2102
2140
|
`;
|
2103
2141
|
}
|
@@ -2147,6 +2185,45 @@ export function randomDate(): Date {
|
|
2147
2185
|
}
|
2148
2186
|
`;
|
2149
2187
|
}
|
2188
|
+
function emitVitestConfig() {
|
2189
|
+
return `import { defineConfig } from 'vitest/config';
|
2190
|
+
|
2191
|
+
export default defineConfig({
|
2192
|
+
test: {
|
2193
|
+
globals: true,
|
2194
|
+
environment: 'node',
|
2195
|
+
testTimeout: 30000,
|
2196
|
+
hookTimeout: 30000,
|
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
|
+
}
|
2205
|
+
},
|
2206
|
+
});
|
2207
|
+
`;
|
2208
|
+
}
|
2209
|
+
function emitTestGitignore() {
|
2210
|
+
return `# Test results
|
2211
|
+
test-results/
|
2212
|
+
*.log
|
2213
|
+
|
2214
|
+
# Node modules (if tests have their own dependencies)
|
2215
|
+
node_modules/
|
2216
|
+
|
2217
|
+
# Environment files
|
2218
|
+
.env
|
2219
|
+
.env.local
|
2220
|
+
.env.test
|
2221
|
+
|
2222
|
+
# Coverage reports
|
2223
|
+
coverage/
|
2224
|
+
*.lcov
|
2225
|
+
`;
|
2226
|
+
}
|
2150
2227
|
function emitDockerCompose() {
|
2151
2228
|
return `# Docker Compose for Test Database
|
2152
2229
|
#
|
@@ -2205,6 +2282,10 @@ docker-compose up -d --wait
|
|
2205
2282
|
export TEST_DATABASE_URL="postgres://testuser:testpass@localhost:5432/testdb"
|
2206
2283
|
export TEST_API_URL="http://localhost:3000"
|
2207
2284
|
|
2285
|
+
# Force color output for better terminal experience
|
2286
|
+
export FORCE_COLOR=1
|
2287
|
+
export CI=""
|
2288
|
+
|
2208
2289
|
# Wait for database to be ready
|
2209
2290
|
echo "⏳ Waiting for database..."
|
2210
2291
|
sleep 3
|
@@ -2232,7 +2313,14 @@ echo ""
|
|
2232
2313
|
# sleep 3
|
2233
2314
|
|
2234
2315
|
echo "\uD83E\uDDEA Running tests..."
|
2235
|
-
|
2316
|
+
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
2317
|
+
TEST_RESULTS_DIR="$SCRIPT_DIR/test-results"
|
2318
|
+
mkdir -p "$TEST_RESULTS_DIR"
|
2319
|
+
|
2320
|
+
# Run tests with appropriate reporter based on framework
|
2321
|
+
${getTestCommand(framework, runCommand)}
|
2322
|
+
|
2323
|
+
TEST_EXIT_CODE=$?
|
2236
2324
|
|
2237
2325
|
# Cleanup
|
2238
2326
|
# if [ ! -z "\${SERVER_PID}" ]; then
|
@@ -2240,12 +2328,85 @@ ${runCommand} "$@"
|
|
2240
2328
|
# kill $SERVER_PID 2>/dev/null || true
|
2241
2329
|
# fi
|
2242
2330
|
|
2243
|
-
|
2331
|
+
if [ $TEST_EXIT_CODE -eq 0 ]; then
|
2332
|
+
echo "✅ Tests completed successfully!"
|
2333
|
+
else
|
2334
|
+
echo "❌ Tests failed with exit code $TEST_EXIT_CODE"
|
2335
|
+
fi
|
2336
|
+
|
2337
|
+
echo ""
|
2338
|
+
echo "\uD83D\uDCCA Test results saved to:"
|
2339
|
+
echo " $TEST_RESULTS_DIR/"
|
2244
2340
|
echo ""
|
2245
2341
|
echo "To stop the test database, run:"
|
2246
2342
|
echo " cd $SCRIPT_DIR && docker-compose down"
|
2343
|
+
|
2344
|
+
exit $TEST_EXIT_CODE
|
2247
2345
|
`;
|
2248
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
|
+
}
|
2398
|
+
function getTestCommand(framework, baseCommand) {
|
2399
|
+
switch (framework) {
|
2400
|
+
case "vitest":
|
2401
|
+
return `FORCE_COLOR=1 ${baseCommand} --reporter=default --reporter=json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
|
2402
|
+
case "jest":
|
2403
|
+
return `FORCE_COLOR=1 ${baseCommand} --colors --json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
|
2404
|
+
case "bun":
|
2405
|
+
return `FORCE_COLOR=1 ${baseCommand} "$@" 2>&1 | tee "$TEST_RESULTS_DIR/results-\${TIMESTAMP}.txt"`;
|
2406
|
+
default:
|
2407
|
+
return `FORCE_COLOR=1 ${baseCommand} "$@"`;
|
2408
|
+
}
|
2409
|
+
}
|
2249
2410
|
function getFrameworkImports(framework) {
|
2250
2411
|
switch (framework) {
|
2251
2412
|
case "vitest":
|
@@ -2258,17 +2419,38 @@ function getFrameworkImports(framework) {
|
|
2258
2419
|
return "import { describe, it, expect, beforeAll, afterAll } from 'vitest';";
|
2259
2420
|
}
|
2260
2421
|
}
|
2261
|
-
function generateSampleData(table) {
|
2422
|
+
function generateSampleData(table, hasForeignKeys = false) {
|
2262
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
|
+
}
|
2263
2432
|
for (const col of table.columns) {
|
2264
|
-
if (col.
|
2433
|
+
if (col.name === "id" && col.hasDefault) {
|
2434
|
+
continue;
|
2435
|
+
}
|
2436
|
+
if ((col.name === "created_at" || col.name === "updated_at") && col.hasDefault) {
|
2265
2437
|
continue;
|
2266
2438
|
}
|
2267
|
-
if (col.
|
2439
|
+
if (col.name === "deleted_at") {
|
2268
2440
|
continue;
|
2269
2441
|
}
|
2270
|
-
|
2271
|
-
|
2442
|
+
if (!col.nullable) {
|
2443
|
+
const foreignTable = foreignKeyColumns.get(col.name);
|
2444
|
+
const value = foreignTable ? `${foreignTable}Id` : getSampleValue(col.pgType, col.name);
|
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
|
+
}
|
2453
|
+
}
|
2272
2454
|
}
|
2273
2455
|
return fields.length > 0 ? `{
|
2274
2456
|
${fields.join(`,
|
@@ -2294,15 +2476,63 @@ ${fields.join(`,
|
|
2294
2476
|
}
|
2295
2477
|
function getSampleValue(type, name, isUpdate = false) {
|
2296
2478
|
const suffix = isUpdate ? ' + " (updated)"' : "";
|
2479
|
+
if (name.endsWith("_id") || name.endsWith("_by")) {
|
2480
|
+
return `'550e8400-e29b-41d4-a716-446655440000'`;
|
2481
|
+
}
|
2297
2482
|
if (name.includes("email")) {
|
2298
2483
|
return `'test${isUpdate ? ".updated" : ""}@example.com'`;
|
2299
2484
|
}
|
2485
|
+
if (name === "color") {
|
2486
|
+
return `'#${isUpdate ? "FF0000" : "0000FF"}'`;
|
2487
|
+
}
|
2488
|
+
if (name === "gender") {
|
2489
|
+
return `'${isUpdate ? "F" : "M"}'`;
|
2490
|
+
}
|
2491
|
+
if (name.includes("phone")) {
|
2492
|
+
return `'${isUpdate ? "555-0200" : "555-0100"}'`;
|
2493
|
+
}
|
2494
|
+
if (name.includes("address")) {
|
2495
|
+
return `'123 ${isUpdate ? "Updated" : "Test"} Street'`;
|
2496
|
+
}
|
2497
|
+
if (name === "type" || name === "status") {
|
2498
|
+
return `'${isUpdate ? "updated" : "active"}'`;
|
2499
|
+
}
|
2500
|
+
if (name === "subject") {
|
2501
|
+
return `'Test Subject${isUpdate ? " Updated" : ""}'`;
|
2502
|
+
}
|
2300
2503
|
if (name.includes("name") || name.includes("title")) {
|
2301
2504
|
return `'Test ${pascal(name)}'${suffix}`;
|
2302
2505
|
}
|
2303
2506
|
if (name.includes("description") || name.includes("bio") || name.includes("content")) {
|
2304
2507
|
return `'Test description'${suffix}`;
|
2305
2508
|
}
|
2509
|
+
if (name.includes("preferences") || name.includes("settings")) {
|
2510
|
+
return `'Test preferences'${suffix}`;
|
2511
|
+
}
|
2512
|
+
if (name.includes("restrictions") || name.includes("dietary")) {
|
2513
|
+
return `['vegetarian']`;
|
2514
|
+
}
|
2515
|
+
if (name.includes("location") || name.includes("clinic")) {
|
2516
|
+
return `'Test Location'${suffix}`;
|
2517
|
+
}
|
2518
|
+
if (name.includes("specialty")) {
|
2519
|
+
return `'General'`;
|
2520
|
+
}
|
2521
|
+
if (name.includes("tier")) {
|
2522
|
+
return `'Standard'`;
|
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
|
+
}
|
2306
2536
|
switch (type) {
|
2307
2537
|
case "text":
|
2308
2538
|
case "varchar":
|
@@ -2326,17 +2556,20 @@ function getSampleValue(type, name, isUpdate = false) {
|
|
2326
2556
|
return `'2024-01-01'`;
|
2327
2557
|
case "timestamp":
|
2328
2558
|
case "timestamptz":
|
2329
|
-
return `new Date()
|
2559
|
+
return `new Date()`;
|
2330
2560
|
case "json":
|
2331
2561
|
case "jsonb":
|
2332
2562
|
return `{ key: 'value' }`;
|
2333
2563
|
case "uuid":
|
2334
|
-
return `'${isUpdate ? "
|
2564
|
+
return `'${isUpdate ? "550e8400-e29b-41d4-a716-446655440001" : "550e8400-e29b-41d4-a716-446655440000"}'`;
|
2565
|
+
case "text[]":
|
2566
|
+
case "varchar[]":
|
2567
|
+
return `['item1', 'item2']`;
|
2335
2568
|
default:
|
2336
2569
|
return `'test'`;
|
2337
2570
|
}
|
2338
2571
|
}
|
2339
|
-
function generateTestCases(table, sampleData, updateData) {
|
2572
|
+
function generateTestCases(table, sampleData, updateData, hasForeignKeys = false) {
|
2340
2573
|
const Type = pascal(table.name);
|
2341
2574
|
const hasData = sampleData !== "{}";
|
2342
2575
|
return `it('should create a ${table.name}', async () => {
|
@@ -2395,6 +2628,280 @@ function generateTestCases(table, sampleData, updateData) {
|
|
2395
2628
|
});` : ""}`;
|
2396
2629
|
}
|
2397
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
|
+
|
2398
2905
|
// src/types.ts
|
2399
2906
|
function normalizeAuthConfig(input) {
|
2400
2907
|
if (!input)
|
@@ -2538,6 +3045,10 @@ async function generate(configPath) {
|
|
2538
3045
|
path: join(serverDir, "sdk-bundle.ts"),
|
2539
3046
|
content: emitSdkBundle(clientFiles, clientDir)
|
2540
3047
|
});
|
3048
|
+
files.push({
|
3049
|
+
path: join(serverDir, "api-contract.ts"),
|
3050
|
+
content: emitApiContract(model, cfg)
|
3051
|
+
});
|
2541
3052
|
if (generateTests) {
|
2542
3053
|
console.log("\uD83E\uDDEA Generating tests...");
|
2543
3054
|
const relativeClientPath = relative(testDir, clientDir);
|
@@ -2553,6 +3064,16 @@ async function generate(configPath) {
|
|
2553
3064
|
path: join(testDir, "run-tests.sh"),
|
2554
3065
|
content: emitTestScript(testFramework)
|
2555
3066
|
});
|
3067
|
+
files.push({
|
3068
|
+
path: join(testDir, ".gitignore"),
|
3069
|
+
content: emitTestGitignore()
|
3070
|
+
});
|
3071
|
+
if (testFramework === "vitest") {
|
3072
|
+
files.push({
|
3073
|
+
path: join(testDir, "vitest.config.ts"),
|
3074
|
+
content: emitVitestConfig()
|
3075
|
+
});
|
3076
|
+
}
|
2556
3077
|
for (const table of Object.values(model.tables)) {
|
2557
3078
|
files.push({
|
2558
3079
|
path: join(testDir, `${table.name}.test.ts`),
|
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "postgresdk",
|
3
|
-
"version": "0.6.
|
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",
|