From fb6df504733571aafce4580135e18513dc2915d2 Mon Sep 17 00:00:00 2001 From: pro Date: Mon, 19 Oct 2026 02:08:54 +0900 Subject: [PATCH 1/9] fix: isolate PGlite schema export connections --- src/generate.ts | 78 +++++++++---- src/init.ts | 2 +- tests/generate.concurrent.test.ts | 187 ++++++++++++++++++++++++++++++ tests/generate.pglite.test.ts | 42 +++++-- tests/init.test.ts | 2 +- 5 files changed, 282 insertions(+), 29 deletions(-) create mode 100644 tests/generate.concurrent.test.ts diff --git a/src/generate.ts b/src/generate.ts index f337094..3bdefc5 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -1,4 +1,6 @@ import * as zg from "zapatos/generate" +import { spawn } from "node:child_process" +import { createRequire } from "node:module" import { getConnectionStringFromEnv, getPgConnectionFromEnv, @@ -8,6 +10,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, @@ -64,28 +102,28 @@ export const generate = async ({ 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 zg.generate({ + db: { + connectionString, + }, + schemas: Object.fromEntries( + schemas.map((s) => [s, { include: "*", exclude: [] }]), + ), + outDir: dbDir, + }) - await zg.generate({ - db: { + await dumpTreeWithConnection( + { + targetDir: path.join(dbDir, "structure"), + defaultDatabase: "postgres", + schemas, + }, 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 + ) + } finally { + server.close() + } return } diff --git a/src/init.ts b/src/init.ts index b84f9cd..e396f28 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 = {} diff --git a/tests/generate.concurrent.test.ts b/tests/generate.concurrent.test.ts new file mode 100644 index 0000000..ccddaba --- /dev/null +++ b/tests/generate.concurrent.test.ts @@ -0,0 +1,187 @@ +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 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 {} })) +mock.module("pg-gateway/node", () => ({ fromNodeSocket: async () => {} })) +mock.module("node:net", () => ({ + createServer: () => { + const port = ++nextPort + return { + listen: (_port: number, callback: () => void) => callback(), + address: () => ({ port }), + close: () => { + closedServers++ + }, + } + }, +})) +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 + 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(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(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( + Object.fromEntries(keys.map((key) => [key, process.env[key]])), + ).toEqual(initial) + expect(closedServers).toBeGreaterThan(0) + }) +}) diff --git a/tests/generate.pglite.test.ts b/tests/generate.pglite.test.ts index 56dcd53..881d682 100644 --- a/tests/generate.pglite.test.ts +++ b/tests/generate.pglite.test.ts @@ -22,13 +22,41 @@ 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 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, + }) + + for (const key of connectionEnvKeys) { + expect(process.env[key]).toBe(externalConnectionString) + } + } finally { + for (const key of connectionEnvKeys) { + const previousValue = previousConnectionEnv[key] + if (previousValue === undefined) delete process.env[key] + else process.env[key] = previousValue + } + } const zapatosFile = path.join(tmp, "db", "zapatos", "schema.d.ts") const structureDir = path.join( diff --git a/tests/init.test.ts b/tests/init.test.ts index cd4ec4b..aabc7f7 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -25,6 +25,6 @@ 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") }) From 59ce24483db5cc0db330561a6d2c762fea36bce3 Mon Sep 17 00:00:00 2001 From: pro Date: Mon, 19 Oct 2026 02:14:11 +0900 Subject: [PATCH 2/9] test: isolate concurrent generation mocks --- tests/generate.concurrent.fixture.ts | 187 ++++++++++++++++++++++++++ tests/generate.concurrent.test.ts | 193 ++------------------------- 2 files changed, 198 insertions(+), 182 deletions(-) create mode 100644 tests/generate.concurrent.fixture.ts diff --git a/tests/generate.concurrent.fixture.ts b/tests/generate.concurrent.fixture.ts new file mode 100644 index 0000000..ccddaba --- /dev/null +++ b/tests/generate.concurrent.fixture.ts @@ -0,0 +1,187 @@ +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 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 {} })) +mock.module("pg-gateway/node", () => ({ fromNodeSocket: async () => {} })) +mock.module("node:net", () => ({ + createServer: () => { + const port = ++nextPort + return { + listen: (_port: number, callback: () => void) => callback(), + address: () => ({ port }), + close: () => { + closedServers++ + }, + } + }, +})) +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 + 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(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(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( + 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 index ccddaba..d14a507 100644 --- a/tests/generate.concurrent.test.ts +++ b/tests/generate.concurrent.test.ts @@ -1,187 +1,16 @@ -import { EventEmitter } from "node:events" -import { test, expect, mock } from "bun:test" -import { getConnectionStringFromEnv } from "pg-connection-from-env" +import { spawnSync } from "node:child_process" +import { fileURLToPath } from "node:url" +import { expect, test } from "bun:test" -let generateImpl: (options: any) => Promise = async () => {} -let dumpImpl: (options: any) => Promise = async () => {} -let nextPort = 24000 -let closedServers = 0 -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 {} })) -mock.module("pg-gateway/node", () => ({ fromNodeSocket: async () => {} })) -mock.module("node:net", () => ({ - createServer: () => { - const port = ++nextPort - return { - listen: (_port: number, callback: () => void) => callback(), - address: () => ({ port }), - close: () => { - closedServers++ - }, - } - }, -})) -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]]), +test("PGlite connection exports remain isolated across overlapping calls", () => { + const fixture = fileURLToPath( + new URL("./generate.concurrent.fixture.ts", import.meta.url), ) - 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 - 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(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 + const result = spawnSync(process.execPath, ["test", fixture], { + encoding: "utf8", }) -}) - -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(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( - Object.fromEntries(keys.map((key) => [key, process.env[key]])), - ).toEqual(initial) - expect(closedServers).toBeGreaterThan(0) - }) + process.stdout.write(result.stdout) + process.stderr.write(result.stderr) + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0) }) From 1981b7d4a5ded31d34c3215b4776bfe01956e788 Mon Sep 17 00:00:00 2001 From: Hysann <1932819331hy@gmail.com> Date: Mon, 19 Oct 2026 13:35:46 +0900 Subject: [PATCH 3/9] fix(pglite): bind gateway to loopback with per-run credentials --- src/generate.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/generate.ts b/src/generate.ts index 3bdefc5..1512986 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -1,5 +1,6 @@ import * as zg from "zapatos/generate" import { spawn } from "node:child_process" +import { randomBytes } from "node:crypto" import { createRequire } from "node:module" import { getConnectionStringFromEnv, @@ -65,6 +66,7 @@ export const generate = async ({ const net = await import("node:net") const db = new PGlite() + const gatewayPassword = randomBytes(24).toString("hex") await migrate({ client: db as any, @@ -80,8 +82,8 @@ export const generate = async ({ 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 @@ -98,9 +100,9 @@ export const generate = async ({ }) }) - await new Promise((resolve) => server.listen(0, resolve)) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) const port = (server.address() as any).port - const connectionString = `postgres://postgres:postgres@127.0.0.1:${port}/postgres` + const connectionString = `postgres://postgres:${gatewayPassword}@127.0.0.1:${port}/postgres` try { await zg.generate({ From e1f3e98a947ec2aad3af5dca7f2307b2117adf5c Mon Sep 17 00:00:00 2001 From: Hysann <1932819331hy@gmail.com> Date: Mon, 19 Oct 2026 13:36:08 +0900 Subject: [PATCH 4/9] test(pglite): prevent non-loopback bind and shared gateway secrets --- tests/generate.concurrent.fixture.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/generate.concurrent.fixture.ts b/tests/generate.concurrent.fixture.ts index ccddaba..8071f64 100644 --- a/tests/generate.concurrent.fixture.ts +++ b/tests/generate.concurrent.fixture.ts @@ -6,6 +6,7 @@ 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 @@ -25,7 +26,10 @@ mock.module("node:net", () => ({ createServer: () => { const port = ++nextPort return { - listen: (_port: number, callback: () => void) => callback(), + listen: (_port: number, host: string, callback: () => void) => { + boundAddresses.push(host) + callback() + }, address: () => ({ port }), close: () => { closedServers++ @@ -80,6 +84,7 @@ async function isolated(run: () => Promise) { 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 { @@ -122,6 +127,8 @@ test("external generate keeps its URI while a PGlite call is suspended", async ( 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) @@ -156,6 +163,10 @@ test("overlapping PGlite calls give each structure dump its own isolated URI", a 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) @@ -179,6 +190,7 @@ test("PGlite child export failures do not mutate the caller environment", async 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) From 27c48de59a2cc406e8254aaa6fe3dc3caab37a6d Mon Sep 17 00:00:00 2001 From: Hysann <1932819331hy@gmail.com> Date: Mon, 19 Oct 2026 14:44:02 +0900 Subject: [PATCH 5/9] test(pgstrap): restore env and clean temp fixture on failure --- tests/generate.pglite.test.ts | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/tests/generate.pglite.test.ts b/tests/generate.pglite.test.ts index 881d682..67adcb3 100644 --- a/tests/generate.pglite.test.ts +++ b/tests/generate.pglite.test.ts @@ -50,26 +50,24 @@ test("generate with pglite runs migrations and dumps structure", async () => { 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", + ) + + 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 }) } - - const zapatosFile = path.join(tmp, "db", "zapatos", "schema.d.ts") - const structureDir = path.join( - tmp, - "db", - "structure", - "public", - "tables", - "foo", - ) - - expect(fs.existsSync(zapatosFile)).toBe(true) - expect(fs.existsSync(path.join(structureDir, "table.sql"))).toBe(true) - - fs.rmSync(tmp, { recursive: true, force: true }) }) From 3b80946bcdd0d846ab881aa3889b778f874b071d Mon Sep 17 00:00:00 2001 From: Hysann <1932819331hy@gmail.com> Date: Mon, 19 Oct 2026 15:39:24 +0900 Subject: [PATCH 6/9] fix(generate): close PGlite resources and retain cleanup failures Track and destroy owned gateway sockets, await server and database cleanup on success and failure, and reject failed listener startup. Preserve original and cleanup errors together. Add real PGlite lifecycle and error-composition regressions. --- src/generate.ts | 55 +++++++--- tests/generate.cleanup.fixture.ts | 147 +++++++++++++++++++++++++++ tests/generate.cleanup.test.ts | 17 ++++ tests/generate.concurrent.fixture.ts | 13 ++- 4 files changed, 215 insertions(+), 17 deletions(-) create mode 100644 tests/generate.cleanup.fixture.ts create mode 100644 tests/generate.cleanup.test.ts diff --git a/src/generate.ts b/src/generate.ts index 1512986..fde907e 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -2,6 +2,7 @@ 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, @@ -67,16 +68,12 @@ export const generate = async ({ const db = new PGlite() const gatewayPassword = randomBytes(24).toString("hex") - - await migrate({ - client: db as any, - migrationsDir, - defaultDatabase, - cwd: process.cwd(), - schemas, - }) + 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: { @@ -100,11 +97,25 @@ export const generate = async ({ }) }) - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) - const port = (server.address() as any).port - const connectionString = `postgres://postgres:${gatewayPassword}@127.0.0.1:${port}/postgres` - try { + await migrate({ + client: db as any, + migrationsDir, + defaultDatabase, + cwd: process.cwd(), + schemas, + }) + + 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 zg.generate({ db: { connectionString, @@ -123,8 +134,26 @@ export const generate = async ({ }, connectionString, ) + } catch (error) { + errors.push(error) } finally { - server.close() + 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/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 index 8071f64..a5437d8 100644 --- a/tests/generate.concurrent.fixture.ts +++ b/tests/generate.concurrent.fixture.ts @@ -20,21 +20,26 @@ mock.module("pg-schema-dump", () => ({ dumpTree: (options: any) => dumpImpl(options), })) mock.module("../src/migrate", () => ({ migrate: async () => {} })) -mock.module("@electric-sql/pglite", () => ({ PGlite: class {} })) +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 { + return Object.assign(new EventEmitter(), { listen: (_port: number, host: string, callback: () => void) => { boundAddresses.push(host) callback() }, address: () => ({ port }), - close: () => { + close: (callback: () => void) => { closedServers++ + callback() }, - } + }) }, })) mock.module("node:child_process", () => ({ From 50bc79281b51e236118d180ee071f5063978e7ce Mon Sep 17 00:00:00 2001 From: Hysann <1932819331hy@gmail.com> Date: Mon, 19 Oct 2026 16:01:22 +0900 Subject: [PATCH 7/9] fix(generate): preserve PostgreSQL wire error responses Ask PGlite to return protocol ErrorResponse frames instead of throwing SQL errors into the generic gateway fallback. Add real simple and extended query regressions covering SQLSTATE, original messages and post-error recovery. --- src/generate.ts | 7 ++- tests/generate.gateway.fixture.ts | 80 +++++++++++++++++++++++++++++++ tests/generate.gateway.test.ts | 17 +++++++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 tests/generate.gateway.fixture.ts create mode 100644 tests/generate.gateway.test.ts diff --git a/src/generate.ts b/src/generate.ts index fde907e..ca6edbd 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -88,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 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) From f4f9818e9a77be1cf3e5a2fde5079596a0eed187 Mon Sep 17 00:00:00 2001 From: Hysann <1932819331hy@gmail.com> Date: Mon, 19 Oct 2026 16:41:23 +0900 Subject: [PATCH 8/9] fix(init): write configuration for the project module type Generate an ES module default export for type:module projects so the built Node CLI can load the initialized configuration. Preserve CommonJS defaults and existing configuration files. Add real Node import regressions for all three project modes. --- src/init.ts | 4 +++- tests/init.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/init.ts b/src/init.ts index e396f28..3818090 100644 --- a/src/init.ts +++ b/src/init.ts @@ -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/init.test.ts b/tests/init.test.ts index aabc7f7..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 @@ -28,3 +29,44 @@ test("initPgstrap writes scripts to package.json", async () => { 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) +}) From dcf621142c5aa028efba4e8ae84e3d2a259d06c1 Mon Sep 17 00:00:00 2001 From: Hysann <1932819331hy@gmail.com> Date: Mon, 19 Oct 2026 18:12:39 +0900 Subject: [PATCH 9/9] fix(config): import filesystem paths as file URLs Encode the project config path with pathToFileURL before dynamic import so literal #, ? and percent characters reach the correct file. Retain CommonJS and ES module loading. Add real Node loader regressions, excluding question-mark paths on Windows where those directory names are invalid. Related to seveibar/pgstrap#8. --- src/get-project-context.ts | 5 ++- tests/get-project-context.test.ts | 67 +++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 tests/get-project-context.test.ts 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/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 }) + }) + } +}