diff --git a/src/generate.ts b/src/generate.ts index f337094..ca6edbd 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -1,4 +1,8 @@ import * as zg from "zapatos/generate" +import { spawn } from "node:child_process" +import { randomBytes } from "node:crypto" +import { createRequire } from "node:module" +import type { Socket } from "node:net" import { getConnectionStringFromEnv, getPgConnectionFromEnv, @@ -8,6 +12,42 @@ import { dumpTree } from "pg-schema-dump" import path from "path" import { migrate } from "./migrate" +const dumpTreeModulePath = createRequire(__filename).resolve("pg-schema-dump") + +const dumpTreeWithConnection = async ( + options: Parameters[0], + connectionString: string, +) => { + const script = `const dumpTree = require(process.argv[1]).dumpTree; +dumpTree(JSON.parse(process.argv[2])).catch((error) => { + console.error(error) + process.exit(1) +})` + + await new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + ["-e", script, dumpTreeModulePath, JSON.stringify(options)], + { + cwd: process.cwd(), + env: { + ...process.env, + POSTGRES_URI: connectionString, + PG_URI: connectionString, + DATABASE_URL: connectionString, + }, + stdio: "inherit", + }, + ) + + child.once("error", reject) + child.once("exit", (code, signal) => { + if (code === 0) resolve() + else reject(new Error(`pg-schema-dump exited with ${signal ?? code}`)) + }) + }) +} + export const generate = async ({ schemas, defaultDatabase, @@ -27,23 +67,20 @@ export const generate = async ({ const net = await import("node:net") const db = new PGlite() - - await migrate({ - client: db as any, - migrationsDir, - defaultDatabase, - cwd: process.cwd(), - schemas, - }) + const gatewayPassword = randomBytes(24).toString("hex") + const sockets = new Set() + const errors: unknown[] = [] const server = net.createServer(async (socket) => { + sockets.add(socket) + socket.once("close", () => sockets.delete(socket)) const connection = await fromNodeSocket(socket, { serverVersion: "16.3 (PGlite)", auth: { method: "password", validateCredentials: ({ username, password }: any) => - username === "postgres" && password === "postgres", - getClearTextPassword: () => "postgres", + username === "postgres" && password === gatewayPassword, + getClearTextPassword: () => gatewayPassword, }, async onStartup() { await (db as any).waitReady @@ -51,7 +88,12 @@ export const generate = async ({ async onMessage(data: Uint8Array, { isAuthenticated }: any) { if (!isAuthenticated) return try { - const { data: responseData } = await (db as any).execProtocol(data) + const { data: responseData } = await (db as any).execProtocol( + data, + { + throwOnError: false, + }, + ) return responseData } catch { return undefined @@ -60,32 +102,64 @@ export const generate = async ({ }) }) - 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 + try { + await migrate({ + client: db as any, + migrationsDir, + defaultDatabase, + cwd: process.cwd(), + schemas, + }) - await zg.generate({ - db: { - connectionString, - }, - schemas: Object.fromEntries( - schemas.map((s) => [s, { include: "*", exclude: [] }]), - ), - outDir: dbDir, - }) + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", () => { + server.removeListener("error", reject) + resolve() + }) + }) + const port = (server.address() as any).port + const connectionString = `postgres://postgres:${gatewayPassword}@127.0.0.1:${port}/postgres` - await dumpTree({ - targetDir: path.join(dbDir, "structure"), - defaultDatabase: "postgres", - schemas, - }) + 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 dumpTreeWithConnection( + { + targetDir: path.join(dbDir, "structure"), + defaultDatabase: "postgres", + schemas, + }, + connectionString, + ) + } catch (error) { + errors.push(error) + } finally { + try { + await new Promise((resolve) => { + server.close(() => resolve()) + for (const socket of sockets) socket.destroy() + }) + } catch (error) { + errors.push(error) + } + try { + await db.close() + } catch (error) { + errors.push(error) + } + } + if (errors.length === 1) throw errors[0] + if (errors.length > 1) { + throw new AggregateError(errors, "PGlite generation and cleanup failed") + } return } diff --git a/src/get-project-context.ts b/src/get-project-context.ts index 8d01d80..10f5ed4 100644 --- a/src/get-project-context.ts +++ b/src/get-project-context.ts @@ -1,5 +1,6 @@ import fs from "fs" import path from "path" +import { pathToFileURL } from "node:url" import { PgstrapConfig } from "./define-config" export interface Context extends PgstrapConfig { @@ -13,7 +14,9 @@ export const getProjectContext = async (): Promise => { ) } - const config = await import(path.join(process.cwd(), "pgstrap.config.js")) + const config = await import( + pathToFileURL(path.join(process.cwd(), "pgstrap.config.js")).href + ) return { cwd: process.cwd(), diff --git a/src/init.ts b/src/init.ts index b84f9cd..3818090 100644 --- a/src/init.ts +++ b/src/init.ts @@ -16,7 +16,7 @@ export const initPgstrap = async (ctx: Pick) => { 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:create-migration"] = "pgstrap create-migration" if (!pkg.devDependencies) pkg.devDependencies = {} @@ -26,9 +26,11 @@ export const initPgstrap = async (ctx: Pick) => { } if (!fs.existsSync(path.join(cwd, "pgstrap.config.js"))) { + const configExport = + pkg.type === "module" ? "export default " : "module.exports = " fs.writeFileSync( path.join(cwd, "pgstrap.config.js"), - `module.exports = ${JSON.stringify( + `${configExport}${JSON.stringify( { defaultDatabase: "my_service_name", schemas: ["public"], diff --git a/tests/generate.cleanup.fixture.ts b/tests/generate.cleanup.fixture.ts new file mode 100644 index 0000000..0b77e8e --- /dev/null +++ b/tests/generate.cleanup.fixture.ts @@ -0,0 +1,147 @@ +import { expect, mock, test } from "bun:test" +import { PGlite } from "@electric-sql/pglite" +import * as net from "node:net" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" + +const nativeNet = { ...net } +const databases: PGlite[] = [] +const servers: net.Server[] = [] +const sockets: net.Socket[] = [] +let closeFailure: Error | undefined + +class TrackedPGlite extends PGlite { + constructor() { + super() + databases.push(this) + } + + override async close() { + if (closeFailure) throw closeFailure + await super.close() + } +} + +mock.module("@electric-sql/pglite", () => ({ PGlite: TrackedPGlite })) +mock.module("node:net", () => ({ + ...nativeNet, + createServer: (listener: (socket: net.Socket) => void) => { + const server = nativeNet.createServer(listener) + servers.push(server) + server.on("connection", (socket) => sockets.push(socket)) + server.once("listening", () => { + const address = server.address() as net.AddressInfo + const idleClient = nativeNet.createConnection({ + host: "127.0.0.1", + port: address.port, + }) + idleClient.on("error", () => {}) + sockets.push(idleClient) + }) + return server + }, +})) + +const { generate } = await import("../src/generate") + +async function isolated( + migration: string, + run: (options: Parameters[0]) => Promise, +) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pgstrap-cleanup-")) + const migrationsDir = path.join(tmp, "migrations") + fs.mkdirSync(migrationsDir) + fs.writeFileSync(path.join(migrationsDir, "001_probe.js"), migration) + databases.length = 0 + servers.length = 0 + sockets.length = 0 + try { + await run({ + schemas: ["public"], + defaultDatabase: "postgres", + dbDir: path.join(tmp, "db"), + migrationsDir, + pglite: true, + }) + } finally { + closeFailure = undefined + for (const socket of sockets) socket.destroy() + for (const server of servers) { + if (server.listening) { + await new Promise((resolve) => server.close(() => resolve())) + } + } + for (const database of databases) { + if (!database.closed) await database.close() + } + fs.rmSync(tmp, { recursive: true, force: true }) + } +} + +test("generation closes its PGlite database and idle gateway connections", async () => { + await isolated( + "exports.up = (pgm) => pgm.createTable('probe', { id: 'id' })", + async (options) => { + await generate(options) + const remainingConnections = await new Promise( + (resolve, reject) => + servers[0].getConnections((error, count) => + error ? reject(error) : resolve(count), + ), + ) + expect({ + databaseClosed: databases[0].closed, + gatewayListening: servers[0].listening, + remainingConnections, + }).toEqual({ + databaseClosed: true, + gatewayListening: false, + remainingConnections: 0, + }) + }, + ) +}) + +test("migration failures close the temporary PGlite database", async () => { + await isolated( + "exports.up = () => { throw new Error('expected migration failure') }", + async (options) => { + await expect(generate(options)).rejects.toThrow( + "expected migration failure", + ) + expect(databases[0].closed).toBe(true) + expect(servers.every((server) => !server.listening)).toBe(true) + }, + ) +}) + +test("generation and cleanup failures both remain available to the caller", async () => { + await isolated( + "exports.up = () => { throw new Error('expected migration failure') }", + async (options) => { + closeFailure = new Error("expected cleanup failure") + let failure: unknown + try { + await generate(options) + } catch (error) { + failure = error + } + expect(failure).toBeInstanceOf(AggregateError) + const errors = (failure as AggregateError).errors + expect(errors).toHaveLength(2) + expect(errors[0].message).toBe("expected migration failure") + expect(errors[1]).toBe(closeFailure) + }, + ) +}) + +test("cleanup failure rejects an otherwise successful generation", async () => { + await isolated( + "exports.up = (pgm) => pgm.createTable('probe', { id: 'id' })", + async (options) => { + closeFailure = new Error("expected cleanup failure") + await expect(generate(options)).rejects.toBe(closeFailure) + }, + ) +}) diff --git a/tests/generate.cleanup.test.ts b/tests/generate.cleanup.test.ts new file mode 100644 index 0000000..4439262 --- /dev/null +++ b/tests/generate.cleanup.test.ts @@ -0,0 +1,17 @@ +import { spawnSync } from "node:child_process" +import { fileURLToPath } from "node:url" +import { expect, test } from "bun:test" + +test("PGlite resources are released after generation and migration errors", () => { + const fixture = fileURLToPath( + new URL("./generate.cleanup.fixture.ts", import.meta.url), + ) + const result = spawnSync(process.execPath, ["test", fixture], { + encoding: "utf8", + timeout: 30000, + }) + + process.stdout.write(result.stdout) + process.stderr.write(result.stderr) + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0) +}, 30000) diff --git a/tests/generate.concurrent.fixture.ts b/tests/generate.concurrent.fixture.ts new file mode 100644 index 0000000..a5437d8 --- /dev/null +++ b/tests/generate.concurrent.fixture.ts @@ -0,0 +1,204 @@ +import { EventEmitter } from "node:events" +import { test, expect, mock } from "bun:test" +import { getConnectionStringFromEnv } from "pg-connection-from-env" + +let generateImpl: (options: any) => Promise = async () => {} +let dumpImpl: (options: any) => Promise = async () => {} +let nextPort = 24000 +let closedServers = 0 +const boundAddresses: string[] = [] +const children: Array<{ + env: NodeJS.ProcessEnv + child: EventEmitter + exit: (code: number, signal?: string | null) => void +}> = [] + +mock.module("zapatos/generate", () => ({ + generate: (options: any) => generateImpl(options), +})) +mock.module("pg-schema-dump", () => ({ + dumpTree: (options: any) => dumpImpl(options), +})) +mock.module("../src/migrate", () => ({ migrate: async () => {} })) +mock.module("@electric-sql/pglite", () => ({ + PGlite: class { + async close() {} + }, +})) +mock.module("pg-gateway/node", () => ({ fromNodeSocket: async () => {} })) +mock.module("node:net", () => ({ + createServer: () => { + const port = ++nextPort + return Object.assign(new EventEmitter(), { + listen: (_port: number, host: string, callback: () => void) => { + boundAddresses.push(host) + callback() + }, + address: () => ({ port }), + close: (callback: () => void) => { + closedServers++ + callback() + }, + }) + }, +})) +mock.module("node:child_process", () => ({ + spawn: ( + _executable: string, + _args: string[], + options: { env: NodeJS.ProcessEnv }, + ) => { + const child = new EventEmitter() + children.push({ + env: options.env, + child, + exit: (code, signal = null) => child.emit("exit", code, signal), + }) + return child + }, +})) + +const { generate } = await import("../src/generate") +const keys = ["POSTGRES_URI", "PG_URI", "DATABASE_URL", "DATABASE_URI"] as const +const externalUri = "postgres://user:pass@127.0.0.1:1/external" +const options = (dbDir: string, pglite = true) => ({ + schemas: ["public"], + defaultDatabase: "postgres", + dbDir, + pglite, +}) +const deferred = () => { + let resolve!: () => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} +async function waitForChildCount(count: number) { + while (children.length < count) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +async function isolated(run: () => Promise) { + const previous = Object.fromEntries( + keys.map((key) => [key, process.env[key]]), + ) + process.env.POSTGRES_URI = externalUri + delete process.env.PG_URI + delete process.env.DATABASE_URL + process.env.DATABASE_URI = "postgres://lower:priority@127.0.0.1:2/other" + children.length = 0 + boundAddresses.length = 0 + try { + await run() + } finally { + for (const key of keys) { + if (previous[key] === undefined) delete process.env[key] + else process.env[key] = previous[key] + } + generateImpl = async () => {} + dumpImpl = async () => {} + } +} + +test("external generate keeps its URI while a PGlite call is suspended", async () => { + await isolated(async () => { + const enteredLocal = deferred() + const releaseLocal = deferred() + let externalTypesUri = "" + let externalStructureUri = "" + let localTypesUri = "" + const expectedExternalUri = getConnectionStringFromEnv() + generateImpl = async (opts) => { + if (opts.outDir === "local") { + localTypesUri = opts.db.connectionString + enteredLocal.resolve() + await releaseLocal.promise + } else { + externalTypesUri = opts.db.connectionString + } + } + dumpImpl = async () => { + externalStructureUri = getConnectionStringFromEnv() + } + + const local = generate(options("local")) + await enteredLocal.promise + await generate(options("external", false)) + expect(externalTypesUri).toBe(expectedExternalUri) + expect(externalStructureUri).toBe(expectedExternalUri) + expect(process.env.POSTGRES_URI).toBe(externalUri) + releaseLocal.resolve() + await waitForChildCount(1) + expect(children).toHaveLength(1) + expect(boundAddresses).toEqual(["127.0.0.1"]) + expect(new URL(localTypesUri).password).toMatch(/^[a-f0-9]{48}$/) + expect(children[0].env.POSTGRES_URI).toBe(localTypesUri) + expect(children[0].env.PG_URI).toBe(localTypesUri) + expect(children[0].env.DATABASE_URL).toBe(localTypesUri) + children[0].exit(0) + await local + }) +}) + +test("overlapping PGlite calls give each structure dump its own isolated URI", async () => { + await isolated(async () => { + const enteredA = deferred() + const enteredB = deferred() + const releaseA = deferred() + const releaseB = deferred() + const ownUris: Record = {} + generateImpl = async (opts) => { + ownUris[opts.outDir] = opts.db.connectionString + if (opts.outDir === "a") { + enteredA.resolve() + await releaseA.promise + } else { + enteredB.resolve() + await releaseB.promise + } + } + + const a = generate(options("a")) + const b = generate(options("b")) + await Promise.all([enteredA.promise, enteredB.promise]) + releaseA.resolve() + releaseB.resolve() + await waitForChildCount(2) + + expect(children).toHaveLength(2) + expect(boundAddresses).toEqual(["127.0.0.1", "127.0.0.1"]) + expect(new URL(ownUris.a).password).toMatch(/^[a-f0-9]{48}$/) + expect(new URL(ownUris.b).password).toMatch(/^[a-f0-9]{48}$/) + expect(new URL(ownUris.a).password).not.toBe(new URL(ownUris.b).password) + expect(children[0].env.POSTGRES_URI).toBe(ownUris.a) + expect(children[0].env.PG_URI).toBe(ownUris.a) + expect(children[0].env.DATABASE_URL).toBe(ownUris.a) + expect(children[1].env.POSTGRES_URI).toBe(ownUris.b) + expect(children[1].env.PG_URI).toBe(ownUris.b) + expect(children[1].env.DATABASE_URL).toBe(ownUris.b) + expect(process.env.POSTGRES_URI).toBe(externalUri) + children.forEach((child) => child.exit(0)) + await Promise.all([a, b]) + }) +}) + +test("PGlite child export failures do not mutate the caller environment", async () => { + await isolated(async () => { + const initial = Object.fromEntries( + keys.map((key) => [key, process.env[key]]), + ) + generateImpl = async () => {} + const run = generate(options("failure")) + while (children.length === 0) + await new Promise((resolve) => setTimeout(resolve, 0)) + children[0].exit(1) + await expect(run).rejects.toThrow("pg-schema-dump exited with 1") + expect(boundAddresses).toEqual(["127.0.0.1"]) + expect( + Object.fromEntries(keys.map((key) => [key, process.env[key]])), + ).toEqual(initial) + expect(closedServers).toBeGreaterThan(0) + }) +}) diff --git a/tests/generate.concurrent.test.ts b/tests/generate.concurrent.test.ts new file mode 100644 index 0000000..d14a507 --- /dev/null +++ b/tests/generate.concurrent.test.ts @@ -0,0 +1,16 @@ +import { spawnSync } from "node:child_process" +import { fileURLToPath } from "node:url" +import { expect, test } from "bun:test" + +test("PGlite connection exports remain isolated across overlapping calls", () => { + const fixture = fileURLToPath( + new URL("./generate.concurrent.fixture.ts", import.meta.url), + ) + const result = spawnSync(process.execPath, ["test", fixture], { + encoding: "utf8", + }) + + process.stdout.write(result.stdout) + process.stderr.write(result.stderr) + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0) +}) diff --git a/tests/generate.gateway.fixture.ts b/tests/generate.gateway.fixture.ts new file mode 100644 index 0000000..85d3a06 --- /dev/null +++ b/tests/generate.gateway.fixture.ts @@ -0,0 +1,80 @@ +import { expect, mock, test } from "bun:test" +import { Client } from "pg" +import * as zapatos from "zapatos/generate" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" + +const generateTypes = zapatos.generate +let inspectGateway: (connectionString: string) => Promise = async () => {} + +mock.module("zapatos/generate", () => ({ + generate: async (options: Parameters[0]) => { + await inspectGateway(options.db.connectionString!) + await generateTypes(options) + }, +})) + +const { generate } = await import("../src/generate") + +test("gateway preserves PostgreSQL errors and supports subsequent queries", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pgstrap-gateway-")) + const migrationsDir = path.join(tmp, "migrations") + fs.mkdirSync(migrationsDir) + fs.writeFileSync( + path.join(migrationsDir, "001_probe.js"), + "exports.up = (pgm) => pgm.createTable('probe', { id: 'id' })", + ) + + inspectGateway = async (connectionString) => { + const client = new Client({ + connectionString, + connectionTimeoutMillis: 1000, + query_timeout: 1000, + }) + try { + await client.connect() + await expect( + client.query("SELECT * FROM deliberately_missing_gateway_table"), + ).rejects.toMatchObject({ + code: "42P01", + message: 'relation "deliberately_missing_gateway_table" does not exist', + }) + expect((await client.query("SELECT 42 AS answer")).rows).toEqual([ + { answer: 42 }, + ]) + + // Parameters exercise the extended query protocol and its Sync recovery. + await expect( + client.query("SELECT 1 / $1::integer", [0]), + ).rejects.toMatchObject({ + code: "22012", + message: "division by zero", + }) + expect( + (await client.query("SELECT $1::integer AS answer", [43])).rows, + ).toEqual([{ answer: 43 }]) + } finally { + await client.end() + } + } + + try { + await generate({ + schemas: ["public"], + defaultDatabase: "postgres", + dbDir: path.join(tmp, "db"), + migrationsDir, + pglite: true, + }) + expect(fs.existsSync(path.join(tmp, "db/zapatos/schema.d.ts"))).toBe(true) + expect( + fs.existsSync( + path.join(tmp, "db/structure/public/tables/probe/table.sql"), + ), + ).toBe(true) + } finally { + inspectGateway = async () => {} + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) diff --git a/tests/generate.gateway.test.ts b/tests/generate.gateway.test.ts new file mode 100644 index 0000000..174f7a2 --- /dev/null +++ b/tests/generate.gateway.test.ts @@ -0,0 +1,17 @@ +import { spawnSync } from "node:child_process" +import { fileURLToPath } from "node:url" +import { expect, test } from "bun:test" + +test("PGlite gateway preserves query failures through the wire protocol", () => { + const fixture = fileURLToPath( + new URL("./generate.gateway.fixture.ts", import.meta.url), + ) + const result = spawnSync(process.execPath, ["test", fixture], { + encoding: "utf8", + timeout: 30000, + }) + + process.stdout.write(result.stdout) + process.stderr.write(result.stderr) + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0) +}, 30000) diff --git a/tests/generate.pglite.test.ts b/tests/generate.pglite.test.ts index 56dcd53..67adcb3 100644 --- a/tests/generate.pglite.test.ts +++ b/tests/generate.pglite.test.ts @@ -22,26 +22,52 @@ test("generate with pglite runs migrations and dumps structure", async () => { migrationFile, ) - await generate({ - schemas: ["public"], - defaultDatabase: "postgres", - dbDir: path.join(tmp, "db"), - migrationsDir, - pglite: true, - }) - - const zapatosFile = path.join(tmp, "db", "zapatos", "schema.d.ts") - const structureDir = path.join( - tmp, - "db", - "structure", - "public", - "tables", - "foo", + const connectionEnvKeys = [ + "POSTGRES_URI", + "PG_URI", + "DATABASE_URL", + "DATABASE_URI", + ] as const + const previousConnectionEnv = Object.fromEntries( + connectionEnvKeys.map((key) => [key, process.env[key]]), ) + const externalConnectionString = + "postgres://invalid:invalid@127.0.0.1:1/unavailable" + + try { + for (const key of connectionEnvKeys) { + process.env[key] = externalConnectionString + } + + await generate({ + schemas: ["public"], + defaultDatabase: "postgres", + dbDir: path.join(tmp, "db"), + migrationsDir, + pglite: true, + }) - expect(fs.existsSync(zapatosFile)).toBe(true) - expect(fs.existsSync(path.join(structureDir, "table.sql"))).toBe(true) + for (const key of connectionEnvKeys) { + expect(process.env[key]).toBe(externalConnectionString) + } + const zapatosFile = path.join(tmp, "db", "zapatos", "schema.d.ts") + const structureDir = path.join( + tmp, + "db", + "structure", + "public", + "tables", + "foo", + ) - fs.rmSync(tmp, { recursive: true, force: true }) + expect(fs.existsSync(zapatosFile)).toBe(true) + expect(fs.existsSync(path.join(structureDir, "table.sql"))).toBe(true) + } finally { + for (const key of connectionEnvKeys) { + const previousValue = previousConnectionEnv[key] + if (previousValue === undefined) delete process.env[key] + else process.env[key] = previousValue + } + fs.rmSync(tmp, { recursive: true, force: true }) + } }) diff --git a/tests/get-project-context.test.ts b/tests/get-project-context.test.ts new file mode 100644 index 0000000..e029e3c --- /dev/null +++ b/tests/get-project-context.test.ts @@ -0,0 +1,67 @@ +import { afterAll, beforeAll, expect, test } from "bun:test" +import fs from "fs" +import os from "os" +import path from "path" +import { spawnSync } from "node:child_process" + +let testDir: string + +beforeAll(async () => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), "pgstrap-config-")) + const build = await Bun.build({ + entrypoints: [ + path.resolve(import.meta.dir, "../src/get-project-context.ts"), + ], + target: "node", + format: "esm", + }) + if (!build.success) throw new Error("Could not build config loader") + fs.writeFileSync( + path.join(testDir, "context.mjs"), + await build.outputs[0]!.text(), + ) + fs.writeFileSync( + path.join(testDir, "load.mjs"), + 'import { getProjectContext } from "./context.mjs"; console.log(JSON.stringify(await getProjectContext()))', + ) +}) + +afterAll(() => { + fs.rmSync(testDir, { recursive: true, force: true }) +}) + +for (const type of ["commonjs", "module"]) { + for (const directory of [ + "plain", + "project#hash", + "project?query", + "project%25literal", + ]) { + // Windows does not permit question marks in directory names. + if (process.platform === "win32" && directory === "project?query") continue + + test(`getProjectContext loads ${type} config in ${directory}`, () => { + const cwd = path.join(testDir, `${type}-${directory}`) + fs.mkdirSync(cwd) + fs.writeFileSync(path.join(cwd, "package.json"), JSON.stringify({ type })) + const config = { + defaultDatabase: "configured_database", + schemas: ["custom"], + dbDir: "./generated db", + } + const configExport = + type === "module" ? "export default " : "module.exports = " + fs.writeFileSync( + path.join(cwd, "pgstrap.config.js"), + `${configExport}${JSON.stringify(config)}`, + ) + const result = spawnSync("node", [path.join(testDir, "load.mjs")], { + cwd, + encoding: "utf8", + timeout: 10000, + }) + expect(result.status, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ cwd, ...config }) + }) + } +} diff --git a/tests/init.test.ts b/tests/init.test.ts index cd4ec4b..19a36d6 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -2,6 +2,7 @@ import { beforeEach, afterEach, test, expect } from "bun:test" import fs from "fs" import os from "os" import path from "path" +import { spawnSync } from "node:child_process" import { initPgstrap } from "../src/init" let testDir: string @@ -25,6 +26,47 @@ 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:create-migration"]).toBe("pgstrap create-migration") }) + +for (const type of [undefined, "commonjs", "module"]) { + test(`initPgstrap creates a Node-loadable config for ${type ?? "default"} projects`, async () => { + fs.writeFileSync( + path.join(testDir, "package.json"), + JSON.stringify({ name: "some-package", type }), + ) + + await initPgstrap({ cwd: testDir }) + + const loaderPath = path.join(testDir, "load-config.mjs") + fs.writeFileSync( + loaderPath, + 'import config from "./pgstrap.config.js"; console.log(JSON.stringify(config))', + ) + const result = spawnSync("node", [loaderPath], { + encoding: "utf8", + timeout: 10000, + }) + expect(result.status, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ + defaultDatabase: "my_service_name", + schemas: ["public"], + }) + }) +} + +test("initPgstrap preserves an existing ES module config", async () => { + fs.writeFileSync( + path.join(testDir, "package.json"), + JSON.stringify({ name: "some-package", type: "module" }), + ) + const configPath = path.join(testDir, "pgstrap.config.js") + const existing = + 'export default { defaultDatabase: "existing", schemas: ["custom"] }\n' + fs.writeFileSync(configPath, existing) + + await initPgstrap({ cwd: testDir }) + + expect(fs.readFileSync(configPath, "utf8")).toBe(existing) +})