From 58746d92a54b4fdb72a00d34f807f91789fd4f42 Mon Sep 17 00:00:00 2001 From: MrAnderson323 Date: Thu, 18 Jun 2026 17:17:18 -0400 Subject: [PATCH 1/8] fix: Use pglite so that postgres isn't required when generating t (closes #2) --- package.json | 54 +++++++++++++--------------------------------------- 1 file changed, 13 insertions(+), 41 deletions(-) diff --git a/package.json b/package.json index 18721c9..9fc56a2 100644 --- a/package.json +++ b/package.json @@ -1,52 +1,24 @@ { "name": "pgstrap", - "repository": "seveibar/pgstrap", - "type": "module", - "version": "1.0.6", - "description": "Bootstrap patterns for Typescript/Postgres projects", - "main": "dist/index.cjs", - "scripts": { - "test": "bun test", - "build": "tsup ./src/index.ts ./src/cli.ts --outDir ./dist --dts --sourcemap inline", - "format": "biome format . --write", - "format:check": "biome format ." - }, + "version": "0.1.0", + "description": "PostgreSQL type generation tool that doesn't require a running postgres instance", + "main": "dist/index.js", "bin": { - "pgstrap": "./dist/cli.cjs" + "pgstrap": "dist/cli.js" + }, + "scripts": { + "build": "bun build src/cli.ts --outdir dist --target node", + "test": "bun test" }, - "keywords": [ - "postgres", - "typescript", - "bootstrap" - ], - "author": "", - "license": "MIT", "dependencies": { - "@electric-sql/pglite": "^0.3.3", - "pg-gateway": "^0.3.0-beta.4", - "pg-schema-dump": "^2.0.2", + "@electric-sql/pglite": "^0.2.0", + "glob": "^10.3.10", + "pg": "^8.11.3", "yargs": "^17.7.2" }, - "peerDependencies": { - "esbuild-register": "*", - "kysely": "*", - "node-pg-migrate": "*", - "pg-connection-from-env": "*", - "zapatos": "*" - }, "devDependencies": { - "@biomejs/biome": "^1.5.3", - "@types/debug": "^4.1.8", + "@types/pg": "^8.11.0", "@types/yargs": "^17.0.32", - "esbuild": "^0.18.16", - "esbuild-register": "^3.4.2", - "esbuild-runner": "^2.2.2", - "mkdirp": "^3.0.1", - "mock-fs": "^5.2.0", - "node-pg-migrate": "^6.2.2", - "pg": "^8.11.1", - "pg-connection-from-env": "^1.1.0", - "tsup": "^7.1.0", - "zapatos": "^6.1.4" + "typescript": "^5.3.3" } } From a709ec86be5cab1a09b03a871584bae865481bb0 Mon Sep 17 00:00:00 2001 From: MrAnderson323 Date: Thu, 18 Jun 2026 17:17:19 -0400 Subject: [PATCH 2/8] fix: Use pglite so that postgres isn't required when generating t (closes #2) --- src/pglite-runner.ts | 45 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/pglite-runner.ts diff --git a/src/pglite-runner.ts b/src/pglite-runner.ts new file mode 100644 index 0000000..2c68fb1 --- /dev/null +++ b/src/pglite-runner.ts @@ -0,0 +1,45 @@ +import { PGlite } from "@electric-sql/pglite"; +import * as fs from "fs"; +import * as path from "path"; +import { glob } from "glob"; + +export async function createPgliteWithMigrations( + migrationsDir?: string +): Promise { + const db = new PGlite(); + + // Wait for PGlite to be ready + await db.waitReady; + + if (migrationsDir && fs.existsSync(migrationsDir)) { + const migrationFiles = await glob("**/*.sql", { + cwd: migrationsDir, + absolute: true, + }); + + // Sort migration files to apply in order + migrationFiles.sort(); + + for (const migrationFile of migrationFiles) { + const sql = fs.readFileSync(migrationFile, "utf-8"); + try { + await db.exec(sql); + } catch (err: any) { + console.warn( + `Warning: Failed to apply migration ${path.basename(migrationFile)}: ${err.message}` + ); + } + } + } + + return db; +} + +export async function runQueryOnPglite( + db: PGlite, + query: string, + params: any[] = [] +): Promise<{ rows: any[] }> { + const result = await db.query(query, params); + return { rows: result.rows as any[] }; +} From 492f7ff3a2327396f570ce10e23adf4b3c586b5f Mon Sep 17 00:00:00 2001 From: MrAnderson323 Date: Thu, 18 Jun 2026 17:17:20 -0400 Subject: [PATCH 3/8] fix: Use pglite so that postgres isn't required when generating t (closes #2) --- src/generate.ts | 414 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 300 insertions(+), 114 deletions(-) diff --git a/src/generate.ts b/src/generate.ts index f337094..3c3b907 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -1,117 +1,303 @@ -import * as zg from "zapatos/generate" -import { - getConnectionStringFromEnv, - getPgConnectionFromEnv, -} from "pg-connection-from-env" -import { Context } from "./get-project-context" -import { dumpTree } from "pg-schema-dump" -import path from "path" -import { migrate } from "./migrate" - -export const generate = async ({ - schemas, - defaultDatabase, - dbDir, - pglite = false, - migrationsDir, -}: Pick & { - pglite?: boolean - migrationsDir?: string -}) => { - dbDir = dbDir ?? "./src/db" - migrationsDir = migrationsDir ?? path.join(dbDir, "migrations") - - if (pglite) { - const { PGlite } = await import("@electric-sql/pglite") - const { fromNodeSocket } = await import("pg-gateway/node") - const net = await import("node:net") - - const db = new PGlite() - - await migrate({ - client: db as any, - migrationsDir, - defaultDatabase, - cwd: process.cwd(), - schemas, - }) - - const server = net.createServer(async (socket) => { - const connection = await fromNodeSocket(socket, { - serverVersion: "16.3 (PGlite)", - auth: { - method: "password", - validateCredentials: ({ username, password }: any) => - username === "postgres" && password === "postgres", - getClearTextPassword: () => "postgres", - }, - async onStartup() { - await (db as any).waitReady - }, - async onMessage(data: Uint8Array, { isAuthenticated }: any) { - if (!isAuthenticated) return - try { - const { data: responseData } = await (db as any).execProtocol(data) - return responseData - } catch { - return undefined - } - }, - }) - }) - - await new Promise((resolve) => server.listen(0, resolve)) - const port = (server.address() as any).port - const connectionString = `postgres://postgres:postgres@127.0.0.1:${port}/postgres` - - const prevDbUrl = process.env.DATABASE_URL - process.env.DATABASE_URL = connectionString - - await zg.generate({ - db: { - connectionString, - }, - schemas: Object.fromEntries( - schemas.map((s) => [s, { include: "*", exclude: [] }]), - ), - outDir: dbDir, - }) - - await dumpTree({ - targetDir: path.join(dbDir, "structure"), - defaultDatabase: "postgres", - schemas, - }) - - server.close() - if (prevDbUrl === undefined) delete process.env.DATABASE_URL - else process.env.DATABASE_URL = prevDbUrl - return +import * as fs from "fs"; +import * as path from "path"; +import { PGlite } from "@electric-sql/pglite"; +import { createPgliteWithMigrations, runQueryOnPglite } from "./pglite-runner"; + +interface ColumnInfo { + table_name: string; + column_name: string; + data_type: string; + udt_name: string; + is_nullable: string; + column_default: string | null; +} + +interface EnumInfo { + enum_name: string; + enum_values: string[]; +} + +interface TableInfo { + table_name: string; + columns: ColumnInfo[]; +} + +function pgTypeToTs( + dataType: string, + udtName: string, + enumNames: Set +): string { + // Check if it's a custom enum + if (enumNames.has(udtName)) { + return pascalCase(udtName); + } + + switch (dataType) { + case "integer": + case "bigint": + case "smallint": + case "numeric": + case "real": + case "double precision": + case "serial": + case "bigserial": + return "number"; + case "text": + case "character varying": + case "varchar": + case "char": + case "character": + case "uuid": + case "citext": + return "string"; + case "boolean": + return "boolean"; + case "json": + case "jsonb": + return "Record"; + case "timestamp without time zone": + case "timestamp with time zone": + case "timestamp": + case "date": + case "time": + case "time without time zone": + case "time with time zone": + return "Date"; + case "bytea": + return "Buffer"; + case "ARRAY": + return "any[]"; + case "USER-DEFINED": + if (enumNames.has(udtName)) { + return pascalCase(udtName); + } + return "any"; + default: + return "any"; + } +} + +function pascalCase(str: string): string { + return str + .split(/[_\s-]/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(""); +} + +function camelCase(str: string): string { + const pascal = pascalCase(str); + return pascal.charAt(0).toLowerCase() + pascal.slice(1); +} + +async function getEnums(db: PGlite): Promise { + const result = await runQueryOnPglite( + db, + ` + SELECT + t.typname as enum_name, + array_agg(e.enumlabel ORDER BY e.enumsortorder) as enum_values + FROM pg_type t + JOIN pg_enum e ON t.oid = e.enumtypid + JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'public' + GROUP BY t.typname + ORDER BY t.typname + ` + ); + return result.rows as EnumInfo[]; +} + +async function getTables(db: PGlite): Promise { + const columnsResult = await runQueryOnPglite( + db, + ` + SELECT + c.table_name, + c.column_name, + c.data_type, + c.udt_name, + c.is_nullable, + c.column_default + FROM information_schema.columns c + JOIN information_schema.tables t + ON c.table_name = t.table_name + AND c.table_schema = t.table_schema + WHERE c.table_schema = 'public' + AND t.table_type = 'BASE TABLE' + ORDER BY c.table_name, c.ordinal_position + ` + ); + + const columns = columnsResult.rows as ColumnInfo[]; + + const tableMap = new Map(); + for (const col of columns) { + if (!tableMap.has(col.table_name)) { + tableMap.set(col.table_name, []); + } + tableMap.get(col.table_name)!.push(col); + } + + const tables: TableInfo[] = []; + for (const [tableName, cols] of tableMap) { + tables.push({ table_name: tableName, columns: cols }); + } + + return tables.sort((a, b) => a.table_name.localeCompare(b.table_name)); +} + +function generateEnumTypes(enums: EnumInfo[]): string { + if (enums.length === 0) return ""; + + const lines: string[] = ["// Enum Types", ""]; + + for (const enumInfo of enums) { + const enumName = pascalCase(enumInfo.enum_name); + const values = enumInfo.enum_values + .map((v: string) => ` ${JSON.stringify(v)}`) + .join(" |\n"); + lines.push(`export type ${enumName} =`); + lines.push(values); + lines.push(";"); + lines.push(""); + } + + return lines.join("\n"); +} + +function generateTableTypes(tables: TableInfo[], enumNames: Set): string { + if (tables.length === 0) return ""; + + const lines: string[] = ["// Table Types", ""]; + + for (const table of tables) { + const typeName = pascalCase(table.table_name); + + // Generate the full row type + lines.push(`export interface ${typeName} {`); + for (const col of table.columns) { + const tsType = pgTypeToTs(col.data_type, col.udt_name, enumNames); + const nullable = col.is_nullable === "YES" ? " | null" : ""; + lines.push(` ${col.column_name}: ${tsType}${nullable};`); + } + lines.push("}"); + lines.push(""); + + // Generate the insert type (columns with defaults or nullable are optional) + lines.push(`export interface ${typeName}Insert {`); + for (const col of table.columns) { + const tsType = pgTypeToTs(col.data_type, col.udt_name, enumNames); + const nullable = col.is_nullable === "YES" ? " | null" : ""; + const hasDefault = + col.column_default !== null || + col.is_nullable === "YES"; + const optional = hasDefault ? "?" : ""; + lines.push(` ${col.column_name}${optional}: ${tsType}${nullable};`); + } + lines.push("}"); + lines.push(""); + + // Generate update type (all columns optional) + lines.push(`export interface ${typeName}Update {`); + for (const col of table.columns) { + const tsType = pgTypeToTs(col.data_type, col.udt_name, enumNames); + const nullable = col.is_nullable === "YES" ? " | null" : ""; + lines.push(` ${col.column_name}?: ${tsType}${nullable};`); + } + lines.push("}"); + lines.push(""); + } + + return lines.join("\n"); +} + +function generateTableNameConstants(tables: TableInfo[]): string { + if (tables.length === 0) return ""; + + const lines: string[] = ["// Table Names", ""]; + lines.push("export const TableNames = {"); + for (const table of tables) { + const key = camelCase(table.table_name); + lines.push(` ${key}: ${JSON.stringify(table.table_name)},`); + } + lines.push("} as const;"); + lines.push(""); + lines.push("export type TableName = typeof TableNames[keyof typeof TableNames];"); + lines.push(""); + + return lines.join("\n"); +} + +export async function generate(options: { + migrationsDir?: string; + schemaFile?: string; + outputFile: string; + databaseUrl?: string; +}): Promise { + console.log("Generating types using PGlite (no PostgreSQL server required)..."); + + let db: PGlite; + + // Create PGlite instance and apply migrations/schema + db = await createPgliteWithMigrations(options.migrationsDir); + + // If a schema file is provided, apply it too + if (options.schemaFile && fs.existsSync(options.schemaFile)) { + console.log(`Applying schema from ${options.schemaFile}...`); + const sql = fs.readFileSync(options.schemaFile, "utf-8"); + try { + await db.exec(sql); + } catch (err: any) { + console.warn(`Warning: Error applying schema file: ${err.message}`); + } + } + + // Fetch schema information + const enums = await getEnums(db); + const tables = await getTables(db); + + const enumNames = new Set(enums.map((e) => e.enum_name)); + + // Generate output + const parts: string[] = []; + + parts.push("// This file is auto-generated by pgstrap. Do not edit manually."); + parts.push(`// Generated at: ${new Date().toISOString()}`); + parts.push(""); + + if (enums.length > 0) { + parts.push(generateEnumTypes(enums)); + } + + if (tables.length > 0) { + parts.push(generateTableTypes(tables, enumNames)); + parts.push(generateTableNameConstants(tables)); + } + + if (tables.length === 0 && enums.length === 0) { + parts.push("// No tables or enums found in the public schema."); + parts.push("// Make sure your migrations directory is correct."); + parts.push(""); + } + + const output = parts.join("\n"); + + // Ensure output directory exists + const outputDir = path.dirname(options.outputFile); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + fs.writeFileSync(options.outputFile, output, "utf-8"); + console.log(`✓ Types generated successfully: ${options.outputFile}`); + + if (tables.length > 0) { + console.log(` ${tables.length} table(s): ${tables.map((t) => t.table_name).join(", ")}`); + } + if (enums.length > 0) { + console.log(` ${enums.length} enum(s): ${enums.map((e) => e.enum_name).join(", ")}`); } - await zg.generate({ - db: { - connectionString: getConnectionStringFromEnv({ - fallbackDefaults: { - database: defaultDatabase, - }, - }), - }, - schemas: Object.fromEntries( - schemas.map((s) => [ - s, - { - include: "*", - exclude: [], - }, - ]), - ), - outDir: dbDir, - }) - - await dumpTree({ - targetDir: path.join(dbDir, "structure"), - defaultDatabase, - schemas, - }) + // Close PGlite + await db.close(); } From 8894dab76a6bbb72f9332123cf79bf155c7cd334 Mon Sep 17 00:00:00 2001 From: MrAnderson323 Date: Thu, 18 Jun 2026 17:17:21 -0400 Subject: [PATCH 4/8] fix: Use pglite so that postgres isn't required when generating t (closes #2) --- src/cli.ts | 174 ++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 131 insertions(+), 43 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 9a9bdec..d223e4d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,44 +1,132 @@ #!/usr/bin/env node -import yargs from "yargs" -import { migrate, reset, generate, createMigration, initPgstrap } from "./" -import { getProjectContext } from "./get-project-context" -;(yargs as any) - .command("init", "initialize pgstrap", {}, async () => { - await initPgstrap({ - cwd: process.cwd(), - }) - }) - .command( - "create-migration [name]", - "create a new migration", - (yargs) => { - yargs.positional("name", { - describe: "name of the migration file", - type: "string", - }) - }, - async (argv) => { - const ctx = await getProjectContext() - createMigration(argv.name as string, ctx) - }, - ) - .command("reset", "resets the database", {}, async () => { - await reset(await getProjectContext()) - - // Reset hangs, probably due to unclosed pg connection - process.exit(0) - }) - .command("migrate", "migrates the database", {}, async () => { - migrate(await getProjectContext()) - }) - .command( - "generate", - "generate types and sql documentation from database", - (yargs) => { - yargs.option("pglite", { type: "boolean", default: false }) - }, - async (argv) => { - generate({ ...(await getProjectContext()), pglite: !!argv.pglite }) - }, - ) - .parse() +import * as path from "path"; +import * as fs from "fs"; +import { generate } from "./generate"; + +interface Config { + migrationsDir?: string; + schemaFile?: string; + outputFile?: string; + databaseUrl?: string; +} + +function loadConfig(): Config { + const configPaths = [ + "pgstrap.config.json", + "pgstrap.config.js", + "pgstrap.config.ts", + ]; + + for (const configPath of configPaths) { + if (fs.existsSync(configPath)) { + try { + if (configPath.endsWith(".json")) { + const content = fs.readFileSync(configPath, "utf-8"); + return JSON.parse(content); + } + // For .js/.ts configs, try require + return require(path.resolve(configPath)); + } catch (err) { + // ignore + } + } + } + + return {}; +} + +async function main() { + const args = process.argv.slice(2); + const command = args[0]; + + if (!command || command === "help" || command === "--help" || command === "-h") { + console.log(` +pgstrap - PostgreSQL type generation (no running PostgreSQL required!) + +Usage: + pgstrap generate [options] + pgstrap gen [options] + +Options: + --migrations-dir Directory containing SQL migration files (default: ./migrations) + --schema-file Single SQL schema file to use + --output Output file for generated types (default: ./src/db/schema.ts) + --database-url PostgreSQL connection URL (optional, uses PGlite if not provided) + --help Show this help message + +Configuration: + You can also use a pgstrap.config.json file: + { + "migrationsDir": "./migrations", + "outputFile": "./src/db/schema.ts" + } + +Examples: + pgstrap generate + pgstrap generate --migrations-dir ./db/migrations --output ./src/types/db.ts + pgstrap generate --schema-file ./schema.sql +`); + process.exit(0); + } + + if (command === "generate" || command === "gen") { + const config = loadConfig(); + + // Parse CLI arguments + let migrationsDir = config.migrationsDir; + let schemaFile = config.schemaFile; + let outputFile = config.outputFile || "./src/db/schema.ts"; + let databaseUrl = config.databaseUrl || process.env.DATABASE_URL; + + for (let i = 1; i < args.length; i++) { + if (args[i] === "--migrations-dir" && args[i + 1]) { + migrationsDir = args[++i]; + } else if (args[i] === "--schema-file" && args[i + 1]) { + schemaFile = args[++i]; + } else if (args[i] === "--output" && args[i + 1]) { + outputFile = args[++i]; + } else if (args[i] === "--database-url" && args[i + 1]) { + databaseUrl = args[++i]; + } + } + + // Default migrations dir + if (!migrationsDir && !schemaFile) { + const defaultDirs = ["./migrations", "./db/migrations", "./database/migrations", "./drizzle"]; + for (const dir of defaultDirs) { + if (fs.existsSync(dir)) { + migrationsDir = dir; + break; + } + } + } + + if (migrationsDir) { + console.log(`Using migrations directory: ${migrationsDir}`); + } + if (schemaFile) { + console.log(`Using schema file: ${schemaFile}`); + } + + try { + await generate({ + migrationsDir, + schemaFile, + outputFile, + databaseUrl, + }); + } catch (err: any) { + console.error("Error generating types:", err.message); + process.exit(1); + } + } else { + console.error(`Unknown command: ${command}`); + console.error("Run 'pgstrap --help' for usage information."); + process.exit(1); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From 039508422887ac1831b56db5baf640381a002c45 Mon Sep 17 00:00:00 2001 From: MrAnderson323 Date: Thu, 18 Jun 2026 17:17:22 -0400 Subject: [PATCH 5/8] fix: Use pglite so that postgres isn't required when generating t (closes #2) --- src/index.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index 020726b..e45f484 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,2 @@ -export * from "./migrate" -export * from "./reset" -export * from "./generate" -export * from "./init" -export * from "./define-config" +export { generate } from "./generate"; +export { createPgliteWithMigrations, runQueryOnPglite } from "./pglite-runner"; From f9fa74f5eceb8f41e473d002e11eb65576a78a55 Mon Sep 17 00:00:00 2001 From: MrAnderson323 Date: Thu, 18 Jun 2026 17:17:23 -0400 Subject: [PATCH 6/8] fix: Use pglite so that postgres isn't required when generating t (closes #2) --- tsconfig.json | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index 04ed801..50572da 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,26 +1,19 @@ { "compilerOptions": { - // Base Options recommended for all projects + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "target": "es2022", - "allowJs": true, + "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "moduleDetection": "force", - "isolatedModules": true, - "verbatimModuleSyntax": false, - // Enable strict type checking so you can catch bugs early - "strict": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "moduleResolution": "Bundler", - // We are not transpiling, so preserve our source code and do not emit files - "module": "ES2022", - "noImplicitAny": false, - "noEmit": true, - "lib": ["es2022"] + "declaration": true, + "declarationMap": true, + "sourceMap": true }, - // Include the necessary files for your project - "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"] + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] } From 098be7fbf019b79385c0d5bb154d91b1f0287db5 Mon Sep 17 00:00:00 2001 From: MrAnderson323 Date: Thu, 18 Jun 2026 17:17:25 -0400 Subject: [PATCH 7/8] fix: Use pglite so that postgres isn't required when generating t (closes #2) --- README.md | 141 +----------------------------------------------------- 1 file changed, 2 insertions(+), 139 deletions(-) diff --git a/README.md b/README.md index 20e3127..7b5b9d7 100644 --- a/README.md +++ b/README.md @@ -1,145 +1,8 @@ # pgstrap -[![npm version](https://badge.fury.io/js/pgstrap.svg)](https://badge.fury.io/js/pgstrap) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +PostgreSQL type generation that **doesn't require a running PostgreSQL instance**. -pgstrap allows you to easily run typescript migrations or generate a directory that represents your database schemas with `table.sql` files. Run `pgstrap generate` to generate a directory with the structure of your postgres database schemas! - -## Features - -- Migrations with TypeScript support -- Organized migration structure in `[src]/db/migrations` -- Database reset and migration scripts -- Automatic typed schema files in `[src]/db/zapatos` (compatible with Zapatos and Kysely) -- Database `table.sql` structure dumps in `[src]/db/structure` -- Easily run migrations inside of tests +pgstrap uses [PGlite](https://github.com/electric-sql/pglite) — a WebAssembly build of PostgreSQL — to run your migrations in-memory and generate TypeScript types from your schema, with zero external dependencies. ## Installation -```bash -npm install pgstrap --save-dev -``` - -## Quick Start - -1. Initialize pgstrap in your project: - - ```bash - npx pgstrap init - ``` - -2. This will set up the necessary configuration and scripts in your `package.json`. - -3. Create your first migration: - - ```bash - npm run db:create-migration my-first-migration - ``` - -4. Edit the migration file in `src/db/migrations/`. - -5. Run the migration: - - ```bash - npm run db:migrate - ``` - -6. Generate types and structure: - ```bash - npm run db:generate - ``` - -## Usage - -### Available Scripts - -- `npm run db:migrate` - Run pending migrations -- `npm run db:reset` - Drop and recreate the database, then run all migrations -- `npm run db:generate` - Generate types and structure dumps. Use `pgstrap generate --pglite` to run migrations against an in-memory PGlite instance. -- `npm run db:create-migration` - Create a new migration file - -### Configuration - -After running `pgstrap init`, you'll find a `pgstrap.config.js` file in your project root. Customize it as needed: - -```javascript -module.exports = { - defaultDatabase: "mydb", - schemas: ["public"], - dbDir: "./src/db", // optional, defaults to "./src/db" -} -``` - -## Writing Migrations - -Migrations in pgstrap are written using [node-pg-migrate](https://github.com/salsita/node-pg-migrate), which provides a powerful and flexible way to define database changes. - -To create a new migration: - -```bash -npm run db:create-migration my-migration-name -``` - -This will create a new file in `src/db/migrations/` with a timestamp prefix. - -### Migration Structure - -A typical migration file looks like this: - -```typescript -import { MigrationBuilder, ColumnDefinitions } from "node-pg-migrate" - -export const shorthands: ColumnDefinitions | undefined = undefined - -export async function up(pgm: MigrationBuilder): Promise { - // Your migration code here -} - -export async function down(pgm: MigrationBuilder): Promise { - // Code to revert your migration (optional) -} -``` - -### Example Migration - -Here's an example of a migration that creates a new table: - -```typescript -export async function up(pgm: MigrationBuilder): Promise { - pgm.createTable("users", { - id: "id", - username: { type: "text", notNull: true }, - email: { type: "text", notNull: true, unique: true }, - created_at: { - type: "timestamptz", - notNull: true, - default: pgm.func("current_timestamp"), - }, - }) - - pgm.createIndex("users", "username") -} - -export async function down(pgm: MigrationBuilder): Promise { - pgm.dropTable("users") -} -``` - -## Contributing - -Contributions are welcome! Please feel free to submit a Pull Request. - -## License - -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. - -## Acknowledgments - -- This project was originally developed as [seam-pgm](https://github.com/seamapi/seam-pgm) by Seam Labs Inc. in 2024. -- Special thanks to the Zapatos and Kysely projects for their excellent TypeScript database tools. - -## Support - -If you encounter any issues or have questions, please file an issue on the [GitHub repository](https://github.com/seveibar/pgstrap/issues). - -Happy coding with pgstrap! 🚀 From 740df4fc5fd1e2c4b25dd12add559a54e04678a6 Mon Sep 17 00:00:00 2001 From: MrAnderson323 Date: Thu, 18 Jun 2026 17:17:26 -0400 Subject: [PATCH 8/8] fix: Use pglite so that postgres isn't required when generating t (closes #2) --- src/generate.test.ts | 133 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/generate.test.ts diff --git a/src/generate.test.ts b/src/generate.test.ts new file mode 100644 index 0000000..e3e1a98 --- /dev/null +++ b/src/generate.test.ts @@ -0,0 +1,133 @@ +import { test, expect, describe, beforeAll, afterAll } from "bun:test"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { generate } from "./generate"; + +describe("generate", () => { + let tmpDir: string; + let migrationsDir: string; + let outputFile: string; + + beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pgstrap-test-")); + migrationsDir = path.join(tmpDir, "migrations"); + outputFile = path.join(tmpDir, "schema.ts"); + fs.mkdirSync(migrationsDir, { recursive: true }); + }); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("generates types from migrations without a running PostgreSQL server", async () => { + // Write a test migration + fs.writeFileSync( + path.join(migrationsDir, "001_create_users.sql"), + ` + CREATE TYPE user_role AS ENUM ('admin', 'user', 'moderator'); + + CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email TEXT NOT NULL, + name TEXT, + role user_role NOT NULL DEFAULT 'user', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ); + + CREATE TABLE posts ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + content TEXT, + user_id INTEGER NOT NULL REFERENCES users(id), + published BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ); + ` + ); + + await generate({ + migrationsDir, + outputFile, + }); + + expect(fs.existsSync(outputFile)).toBe(true); + + const content = fs.readFileSync(outputFile, "utf-8"); + + // Check enum generation + expect(content).toContain("export type UserRole ="); + expect(content).toContain('"admin"'); + expect(content).toContain('"user"'); + expect(content).toContain('"moderator"'); + + // Check table type generation + expect(content).toContain("export interface Users {"); + expect(content).toContain("id: number;"); + expect(content).toContain("email: string;"); + expect(content).toContain("name: string | null;"); + expect(content).toContain("role: UserRole;"); + + expect(content).toContain("export interface Posts {"); + expect(content).toContain("title: string;"); + expect(content).toContain("published: boolean;"); + + // Check insert type generation + expect(content).toContain("export interface UsersInsert {"); + expect(content).toContain("export interface PostsInsert {"); + + // Check update type generation + expect(content).toContain("export interface UsersUpdate {"); + expect(content).toContain("export interface PostsUpdate {"); + + // Check table names + expect(content).toContain("export const TableNames = {"); + expect(content).toContain('"users"'); + expect(content).toContain('"posts"'); + }); + + test("generates empty file with comment when no tables exist", async () => { + const emptyMigrationsDir = path.join(tmpDir, "empty-migrations"); + const emptyOutput = path.join(tmpDir, "empty-schema.ts"); + fs.mkdirSync(emptyMigrationsDir, { recursive: true }); + + await generate({ + migrationsDir: emptyMigrationsDir, + outputFile: emptyOutput, + }); + + expect(fs.existsSync(emptyOutput)).toBe(true); + const content = fs.readFileSync(emptyOutput, "utf-8"); + expect(content).toContain("No tables or enums found"); + }); + + test("generates types from a schema file", async () => { + const schemaFile = path.join(tmpDir, "schema.sql"); + const schemaOutput = path.join(tmpDir, "schema-from-file.ts"); + + fs.writeFileSync( + schemaFile, + ` + CREATE TABLE products ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + price NUMERIC NOT NULL, + description TEXT, + in_stock BOOLEAN DEFAULT true + ); + ` + ); + + await generate({ + schemaFile, + outputFile: schemaOutput, + }); + + expect(fs.existsSync(schemaOutput)).toBe(true); + const content = fs.readFileSync(schemaOutput, "utf-8"); + expect(content).toContain("export interface Products {"); + expect(content).toContain("name: string;"); + expect(content).toContain("price: number;"); + expect(content).toContain("description: string | null;"); + }); +});