Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ npm install pgstrap --save-dev

- `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:generate` - Generate types and structure dumps. Generation runs migrations against an in-memory PGlite instance by default, so no running Postgres is required. To generate from an external Postgres instead (via `DATABASE_URL`), use `pgstrap generate --no-pglite`, also scaffolded by `pgstrap init` as `npm run db:generate:postgres`.
- `npm run db:create-migration` - Create a new migration file

### Configuration
Expand Down
9 changes: 7 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,15 @@ import { getProjectContext } from "./get-project-context"
"generate",
"generate types and sql documentation from database",
(yargs) => {
yargs.option("pglite", { type: "boolean", default: false })
yargs.option("pglite", {
type: "boolean",
default: true,
describe:
"generate from migrations using an in-memory PGlite instance, so no running Postgres is required; use --no-pglite to generate from an external Postgres via DATABASE_URL",
})
},
async (argv) => {
generate({ ...(await getProjectContext()), pglite: !!argv.pglite })
await generate({ ...(await getProjectContext()), pglite: !!argv.pglite })
},
)
.parse()
69 changes: 38 additions & 31 deletions src/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const generate = async ({
schemas,
defaultDatabase,
dbDir,
pglite = false,
pglite = true,
migrationsDir,
}: Pick<Context, "schemas" | "defaultDatabase" | "dbDir"> & {
pglite?: boolean
Expand All @@ -28,14 +28,6 @@ export const generate = async ({

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)",
Expand All @@ -60,32 +52,47 @@ export const generate = async ({
})
})

await new Promise<void>((resolve) => server.listen(0, resolve))
const port = (server.address() as any).port
const connectionString = `postgres://postgres:postgres@127.0.0.1:${port}/postgres`
try {
await migrate({
client: db as any,
migrationsDir,
defaultDatabase,
cwd: process.cwd(),
schemas,
})

const prevDbUrl = process.env.DATABASE_URL
process.env.DATABASE_URL = connectionString
await new Promise<void>((resolve) => server.listen(0, resolve))
const port = (server.address() as any).port
const connectionString = `postgres://postgres:postgres@127.0.0.1:${port}/postgres`

await zg.generate({
db: {
connectionString,
},
schemas: Object.fromEntries(
schemas.map((s) => [s, { include: "*", exclude: [] }]),
),
outDir: dbDir,
})
const prevDbUrl = process.env.DATABASE_URL
process.env.DATABASE_URL = connectionString

await dumpTree({
targetDir: path.join(dbDir, "structure"),
defaultDatabase: "postgres",
schemas,
})
try {
await zg.generate({
db: {
connectionString,
},
schemas: Object.fromEntries(
schemas.map((s) => [s, { include: "*", exclude: [] }]),
),
outDir: dbDir,
})

server.close()
if (prevDbUrl === undefined) delete process.env.DATABASE_URL
else process.env.DATABASE_URL = prevDbUrl
await dumpTree({
targetDir: path.join(dbDir, "structure"),
defaultDatabase: "postgres",
schemas,
})
} finally {
if (prevDbUrl === undefined) delete process.env.DATABASE_URL
else process.env.DATABASE_URL = prevDbUrl
}
} finally {
;(server as any).closeAllConnections?.()
await new Promise<void>((resolve) => server.close(() => resolve()))
await db.close()
}
return
}

Expand Down
3 changes: 2 additions & 1 deletion src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export const initPgstrap = async (ctx: Pick<Context, "cwd">) => {

pkg.scripts["db:migrate"] = "pgstrap migrate"
pkg.scripts["db:reset"] = "pgstrap reset"
pkg.scripts["db:generate"] = "pgstrap generate"
pkg.scripts["db:generate"] = "pgstrap generate --pglite"
pkg.scripts["db:generate:postgres"] = "pgstrap generate --no-pglite"
pkg.scripts["db:create-migration"] = "pgstrap create-migration"

if (!pkg.devDependencies) pkg.devDependencies = {}
Expand Down
139 changes: 139 additions & 0 deletions tests/generate.default-pglite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { afterEach, test, expect } from "bun:test"
import fs from "fs"
import os from "os"
import path from "path"
import { generate } from "../src/generate"

const migrationFile = `
exports.up = async (pgm) => {
pgm.createTable('foo', { id: 'id' })
}
exports.down = async (pgm) => {
pgm.dropTable('foo')
}
`

// Nothing listens on port 1, so any attempt to use this database fails fast.
// Setting DATABASE_URL to it proves which code path generation took: the
// pglite path never touches it, the external postgres path fails against it.
const UNREACHABLE_DB_URL =
"postgres://postgres:postgres@127.0.0.1:1/unreachable"

const originalDbUrl = process.env.DATABASE_URL

afterEach(() => {
if (originalDbUrl === undefined) delete process.env.DATABASE_URL
else process.env.DATABASE_URL = originalDbUrl
})

const setupProject = () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pgstrap-generate-"))
const migrationsDir = path.join(tmp, "migrations")
fs.mkdirSync(migrationsDir, { recursive: true })
fs.writeFileSync(
path.join(migrationsDir, "001_create_table.js"),
migrationFile,
)
return { tmp, migrationsDir }
}

test("generate uses pglite by default, without a running postgres", async () => {
const { tmp, migrationsDir } = setupProject()
process.env.DATABASE_URL = UNREACHABLE_DB_URL

// No `pglite` option passed: this is the default `pgstrap generate` path
// used by the scaffolded `db:generate` script.
await generate({
schemas: ["public"],
defaultDatabase: "postgres",
dbDir: path.join(tmp, "db"),
migrationsDir,
})

const zapatosFile = path.join(tmp, "db", "zapatos", "schema.d.ts")
const structureFile = path.join(
tmp,
"db",
"structure",
"public",
"tables",
"foo",
"table.sql",
)

expect(fs.existsSync(zapatosFile)).toBe(true)
expect(fs.existsSync(structureFile)).toBe(true)
// The caller's DATABASE_URL must be restored afterwards.
expect(process.env.DATABASE_URL).toBe(UNREACHABLE_DB_URL)

fs.rmSync(tmp, { recursive: true, force: true })
}, 30_000)

test("generate with pglite: false uses the external postgres from DATABASE_URL", async () => {
const { tmp, migrationsDir } = setupProject()
const dbDir = path.join(tmp, "db")

// The opt-out must really take the external path: with an unreachable
// database it fails instead of silently falling back to pglite. This
// runs in a subprocess because zapatos' connection pool surfaces
// connection failures as unhandled rejections, which would kill the
// test runner process itself (pre-existing behavior of the external
// path) — the subprocess's exit code is the observable contract.
const scriptPath = path.join(tmp, "run-generate.ts")
fs.writeFileSync(
scriptPath,
`import { generate } from ${JSON.stringify(
path.join(import.meta.dir, "..", "src", "generate"),
)}
await generate({
schemas: ["public"],
defaultDatabase: "postgres",
dbDir: process.env.PGSTRAP_TEST_DB_DIR!,
migrationsDir: process.env.PGSTRAP_TEST_MIGRATIONS_DIR!,
pglite: false,
})
`,
)

const proc = Bun.spawnSync(["bun", "run", scriptPath], {
cwd: path.join(import.meta.dir, ".."),
env: {
...process.env,
DATABASE_URL: UNREACHABLE_DB_URL,
PGSTRAP_TEST_DB_DIR: dbDir,
PGSTRAP_TEST_MIGRATIONS_DIR: migrationsDir,
},
})

expect(proc.exitCode).not.toBe(0)
expect(proc.stdout.toString() + proc.stderr.toString()).toContain(
"ECONNREFUSED",
)
expect(fs.existsSync(path.join(dbDir, "zapatos", "schema.d.ts"))).toBe(false)

fs.rmSync(tmp, { recursive: true, force: true })
}, 30_000)

test("generate restores DATABASE_URL when generation fails", async () => {
const { tmp, migrationsDir } = setupProject()
process.env.DATABASE_URL = UNREACHABLE_DB_URL

// A dbDir inside a regular file cannot be written to, so generation fails
// after the temporary pglite connection string has been installed in
// DATABASE_URL. The original value must still be restored.
const blocker = path.join(tmp, "blocker")
fs.writeFileSync(blocker, "not a directory")

await expect(
generate({
schemas: ["public"],
defaultDatabase: "postgres",
dbDir: path.join(blocker, "db"),
migrationsDir,
}),
).rejects.toThrow()

expect(process.env.DATABASE_URL).toBe(UNREACHABLE_DB_URL)

fs.rmSync(tmp, { recursive: true, force: true })
}, 30_000)
5 changes: 4 additions & 1 deletion tests/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ test("initPgstrap writes scripts to package.json", async () => {
)
expect(pkg.scripts["db:migrate"]).toBe("pgstrap migrate")
expect(pkg.scripts["db:reset"]).toBe("pgstrap reset")
expect(pkg.scripts["db:generate"]).toBe("pgstrap generate")
expect(pkg.scripts["db:generate"]).toBe("pgstrap generate --pglite")
expect(pkg.scripts["db:generate:postgres"]).toBe(
"pgstrap generate --no-pglite",
)
expect(pkg.scripts["db:create-migration"]).toBe("pgstrap create-migration")
})