@effectstream/db 0.104.1 → 0.200.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,6 +55,53 @@ small executable subpaths - used directly by the orchestrator.
55
55
  > `acquireDBMutex(name)` / `releaseDBMutex(name)` so concurrent generators
56
56
  > don't trample each other.
57
57
 
58
+ ### PgLite gateway lifecycle
59
+
60
+ `startPglite(0)` binds only to `127.0.0.1` and reports the actual assigned port.
61
+ Its handle has two shutdown modes:
62
+
63
+ ```typescript
64
+ const database = await startPglite(0);
65
+
66
+ // Compatibility default: stop accepting, preserve accepted client sockets.
67
+ await database.close();
68
+
69
+ // Explicit owner shutdown: destroy any stragglers and wait for teardown.
70
+ await database.close({ force: true });
71
+ ```
72
+
73
+ The no-argument close is bounded even if a consumer deliberately retains a raw
74
+ `pg.Client`: it stops new accepts, unrefs and preserves accepted sockets, and
75
+ settles without waiting for those clients. Existing clients may finish work
76
+ while they remain connected. PGlite database cleanup is deferred until the last
77
+ accepted socket drains, so a client retained forever can defer that cleanup
78
+ forever. A resolved default-close promise therefore does not mean database
79
+ cleanup has completed. Consumers remain responsible for their client lifecycle.
80
+
81
+ Use `{ force: true }` only when your framework owns every connected client.
82
+ Forced close destroys tracked sockets and waits for listener/socket teardown,
83
+ so raw clients can emit their normal `Connection terminated unexpectedly`
84
+ error; owners must end clients first or handle that event.
85
+
86
+ Every call returns the same stored cleanup promise. The first call selects the
87
+ mode, including when default and force calls race; a later force call cannot
88
+ escalate cleanup already started in default mode. Startup listen failure still
89
+ attempts database cleanup. Forced shutdown and default shutdown with no retained
90
+ socket attempt database cleanup even if the listener fails to close. When both
91
+ operations fail, the promise rejects with an `AggregateError` in
92
+ listener-then-database order. If default shutdown deferred database cleanup, a
93
+ later cleanup failure is observed and logged, but cannot change the already
94
+ settled close promise.
95
+
96
+ Migration note: forced socket destruction became the implicit default in
97
+ `0.104.0` and was observed downstream during an upgrade to `0.200.1`. The
98
+ corrected no-argument close restores the non-destructive compatibility default;
99
+ PGlite cleanup waits for the last accepted socket, and forced teardown is
100
+ explicit. The useful `0.104.0` changes remain: IPv4 loopback binding, startup
101
+ rejection, address validation, actual-port reporting, and listener-close error
102
+ propagation. Cleanup idempotency is strengthened from a boolean early return to
103
+ a shared promise.
104
+
58
105
  ## Inside EffectStream
59
106
 
60
107
  `@effectstream/db` is the canonical access path. The runtime gets a
@@ -91,7 +138,8 @@ Dynamic table / event helpers:
91
138
 
92
139
  Subpath entry points (executable scripts):
93
140
 
94
- - `@effectstream/db/start-pglite`: boot a PgLite instance.
141
+ - `@effectstream/db/start-pglite`: boot a PgLite instance and choose bounded
142
+ default or explicit forced teardown as described above.
95
143
  - `@effectstream/db/apply-migrations` - apply SQL migrations against the active DB.
96
144
  - `@effectstream/db/db-wait`: block until the DB accepts a connection.
97
145
  - `@effectstream/db/pgtyped-update`: regenerate pgtyped types.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectstream/db",
3
- "version": "0.104.1",
3
+ "version": "0.200.2",
4
4
  "license": "(MIT OR Apache-2.0)",
5
5
  "homepage": "https://effectstream.github.io/docs/",
6
6
  "repository": {
@@ -21,11 +21,11 @@
21
21
  "./version": "./migrations/system-version.ts"
22
22
  },
23
23
  "dependencies": {
24
- "@effectstream/coroutine": "0.104.1",
25
- "@effectstream/log": "0.104.1",
26
- "@effectstream/runtime": "0.104.1",
27
- "@effectstream/sync": "0.104.1",
28
- "@effectstream/utils": "0.104.1",
24
+ "@effectstream/coroutine": "0.200.2",
25
+ "@effectstream/log": "0.200.2",
26
+ "@effectstream/runtime": "0.200.2",
27
+ "@effectstream/sync": "0.200.2",
28
+ "@effectstream/utils": "0.200.2",
29
29
  "@electric-sql/pglite": "^0.3.14",
30
30
  "@pgtyped/runtime": "2.4.2",
31
31
  "@pgtyped/parser": "2.4.2",
@@ -4,7 +4,9 @@
4
4
  */
5
5
 
6
6
  import { spawn } from "node:child_process";
7
- import { resolve } from "node:path";
7
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { join, resolve } from "node:path";
8
10
  import { existsSync } from "node:fs";
9
11
 
10
12
  import pg from "pg";
@@ -34,6 +36,47 @@ export interface PgtypedUpdateOptions {
34
36
  userMigrations?: UserMigration[];
35
37
  pgtypedConfigPath?: string;
36
38
  pgtypedBin?: string;
39
+ pgtypedTimeoutMs?: number;
40
+ }
41
+
42
+ async function preparePgtypedConfig(
43
+ configPath: string,
44
+ port: number,
45
+ ): Promise<{ cleanup: () => Promise<void>; path: string }> {
46
+ const parsed = JSON.parse(await readFile(configPath, "utf8")) as unknown;
47
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
48
+ throw new Error("Pgtyped config must be a JSON object.");
49
+ }
50
+
51
+ const config = parsed as Record<string, unknown>;
52
+ if (config.db !== undefined && (
53
+ !config.db || typeof config.db !== "object" || Array.isArray(config.db)
54
+ )) {
55
+ throw new Error("Pgtyped config db field must be an object.");
56
+ }
57
+ const { dbUrl: _ignoredDbUrl, ...configWithoutUri } = config;
58
+ const directory = await mkdtemp(join(tmpdir(), "effectstream-pgtyped-"));
59
+ const derivedPath = join(directory, "pgtypedconfig.json");
60
+ try {
61
+ await writeFile(derivedPath, JSON.stringify({
62
+ ...configWithoutUri,
63
+ db: {
64
+ ...(config.db as Record<string, unknown> | undefined),
65
+ dbName: "postgres",
66
+ host: "127.0.0.1",
67
+ port,
68
+ user: "postgres",
69
+ },
70
+ }));
71
+ } catch (error) {
72
+ await rm(directory, { force: true, recursive: true });
73
+ throw error;
74
+ }
75
+
76
+ return {
77
+ path: derivedPath,
78
+ cleanup: () => rm(directory, { force: true, recursive: true }),
79
+ };
37
80
  }
38
81
 
39
82
  async function resolveUserMigrations(): Promise<UserMigration[]> {
@@ -49,7 +92,7 @@ async function resolveUserMigrations(): Promise<UserMigration[]> {
49
92
  return mod.migrationTable ?? [];
50
93
  }
51
94
 
52
- async function applyUserMigrations(migrations: UserMigration[]): Promise<void> {
95
+ async function applyUserMigrations(migrations: UserMigration[], port: number): Promise<void> {
53
96
  const prefix = createPrefix("user-migrations");
54
97
 
55
98
  if (migrations.length === 0) {
@@ -58,14 +101,14 @@ async function applyUserMigrations(migrations: UserMigration[]): Promise<void> {
58
101
  }
59
102
 
60
103
  const client = new pg.Client({
61
- host: "localhost",
62
- port: 5432,
104
+ host: "127.0.0.1",
105
+ port,
63
106
  user: "postgres",
64
107
  database: "postgres",
65
108
  });
66
- await client.connect();
67
109
 
68
110
  try {
111
+ await client.connect();
69
112
  for (const migration of migrations) {
70
113
  console.log(`${prefix}Applying ${migration.name}`);
71
114
  await client.query(migration.sql);
@@ -76,67 +119,121 @@ async function applyUserMigrations(migrations: UserMigration[]): Promise<void> {
76
119
  }
77
120
  }
78
121
 
79
- async function applySystemMigrations(): Promise<void> {
122
+ async function applySystemMigrations(port: number): Promise<void> {
80
123
  const prefix = createPrefix("apply-migrations");
81
- const db = getConnection();
82
- const migrations = await getMigrations();
83
- for (const migration of migrations) {
84
- await applyMigrations(db as any, 0, migration.version, migration.sql, true);
124
+ const db = getConnection({
125
+ host: "127.0.0.1",
126
+ port,
127
+ user: "postgres",
128
+ database: "postgres",
129
+ max: 1,
130
+ });
131
+ try {
132
+ const migrations = await getMigrations();
133
+ for (const migration of migrations) {
134
+ await applyMigrations(db as any, 0, migration.version, migration.sql, true);
135
+ }
136
+ console.log(`${prefix}✅ System migrations applied`);
137
+ } finally {
138
+ await db.end();
85
139
  }
86
- console.log(`${prefix}✅ System migrations applied`);
87
- await db.end();
88
140
  }
89
141
 
90
- async function runPgtyped(pgtypedBin: string, pgtypedConfigPath: string): Promise<void> {
142
+ async function runPgtyped(
143
+ pgtypedBin: string,
144
+ pgtypedConfigPath: string,
145
+ port: number,
146
+ timeoutMs: number,
147
+ ): Promise<void> {
91
148
  const prefix = createPrefix("pgtyped");
92
149
  console.log(`${prefix}Starting...`);
93
150
 
94
- return new Promise((resolve, reject) => {
95
- const child = spawn("node", [pgtypedBin, "-c", pgtypedConfigPath], {
96
- stdio: ["ignore", "pipe", "pipe"],
97
- shell: false,
98
- });
151
+ const preparedConfig = await preparePgtypedConfig(pgtypedConfigPath, port);
152
+ try {
153
+ await new Promise<void>((resolve, reject) => {
154
+ const childEnv = {
155
+ ...process.env,
156
+ PGDATABASE: "postgres",
157
+ PGHOST: "127.0.0.1",
158
+ PGPORT: String(port),
159
+ PGUSER: "postgres",
160
+ };
161
+ delete childEnv.DATABASE_URL;
162
+ delete childEnv.PGURI;
163
+
164
+ const child = spawn("node", [
165
+ pgtypedBin,
166
+ "-c",
167
+ preparedConfig.path,
168
+ ], {
169
+ env: childEnv,
170
+ stdio: ["ignore", "pipe", "pipe"],
171
+ shell: false,
172
+ });
99
173
 
100
- child.stdout?.on("data", (chunk: Buffer | string) => {
101
- chunk.toString().split("\n").forEach((line) => {
102
- if (line.trim()) console.log(`${prefix}${line}`);
174
+ child.stdout?.on("data", (chunk: Buffer | string) => {
175
+ chunk.toString().split("\n").forEach((line) => {
176
+ if (line.trim()) console.log(`${prefix}${line}`);
177
+ });
103
178
  });
104
- });
105
179
 
106
- child.stderr?.on("data", (chunk: Buffer | string) => {
107
- chunk.toString().split("\n").forEach((line) => {
108
- if (line.trim()) console.error(`${prefix}${line}`);
180
+ child.stderr?.on("data", (chunk: Buffer | string) => {
181
+ chunk.toString().split("\n").forEach((line) => {
182
+ if (line.trim()) console.error(`${prefix}${line}`);
183
+ });
109
184
  });
110
- });
111
185
 
112
- child.on("close", (code) => {
113
- if (code === 0) {
114
- console.log(`${prefix}✅ Completed successfully`);
115
- resolve();
116
- } else {
117
- reject(new Error(`pgtyped failed with exit code ${code}`));
118
- }
186
+ let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
187
+ let settled = false;
188
+ let timedOut = false;
189
+ const timeout = setTimeout(() => {
190
+ timedOut = true;
191
+ child.kill("SIGTERM");
192
+ forceKillTimer = setTimeout(() => child.kill("SIGKILL"), 5_000);
193
+ }, timeoutMs);
194
+ const settle = (operation: () => void) => {
195
+ if (settled) return;
196
+ settled = true;
197
+ clearTimeout(timeout);
198
+ if (forceKillTimer) clearTimeout(forceKillTimer);
199
+ operation();
200
+ };
201
+
202
+ child.once("error", (error) => settle(() => reject(error)));
203
+ child.once("close", (code) => settle(() => {
204
+ if (timedOut) {
205
+ reject(new Error(`pgtyped timed out after ${timeoutMs}ms`));
206
+ } else if (code === 0) {
207
+ console.log(`${prefix}✅ Completed successfully`);
208
+ resolve();
209
+ } else {
210
+ reject(new Error(`pgtyped failed with exit code ${code}`));
211
+ }
212
+ }));
119
213
  });
120
- });
214
+ } finally {
215
+ await preparedConfig.cleanup();
216
+ }
121
217
  }
122
218
 
123
219
  export async function main(options?: PgtypedUpdateOptions) {
124
220
  const userMigrations = options?.userMigrations ?? await resolveUserMigrations();
125
221
  const pgtypedConfigPath = options?.pgtypedConfigPath ?? "./pgtypedconfig.json";
126
222
  const pgtypedBin = options?.pgtypedBin ?? resolve(process.cwd(), "node_modules/@paima/pgtyped-cli/lib/index.js");
223
+ const pgtypedTimeoutMs = options?.pgtypedTimeoutMs ?? 600_000;
127
224
 
128
225
  console.log("🚀 Starting pgtyped-update...\n");
129
226
 
130
227
  let pglite: PgliteHandle | undefined;
131
228
  try {
132
- pglite = await startPglite();
133
- await waitForDb();
134
- await applySystemMigrations();
135
- await applyUserMigrations(userMigrations);
136
- await runPgtyped(pgtypedBin, pgtypedConfigPath);
229
+ pglite = await startPglite(0);
230
+ await waitForDb(pglite.port, "127.0.0.1");
231
+ await applySystemMigrations(pglite.port);
232
+ await applyUserMigrations(userMigrations, pglite.port);
233
+ await runPgtyped(pgtypedBin, pgtypedConfigPath, pglite.port, pgtypedTimeoutMs);
137
234
  console.log("\n✅ All steps completed successfully");
138
235
  } finally {
139
- await pglite?.close();
236
+ await pglite?.close({ force: true });
140
237
  }
141
238
  }
142
239
 
@@ -11,7 +11,104 @@ export interface PgliteHandle {
11
11
  server: net.Server;
12
12
  db: PGlite;
13
13
  port: number;
14
- close: () => Promise<void>;
14
+ close: (options?: PgliteCloseOptions) => Promise<void>;
15
+ }
16
+
17
+ export interface PgliteCloseOptions {
18
+ /**
19
+ * Destroy accepted client sockets before closing the gateway. Use this only
20
+ * when the caller owns those clients and has installed their error handling.
21
+ */
22
+ force?: boolean;
23
+ }
24
+
25
+ interface ListenerCloseLifecycle {
26
+ complete: () => boolean;
27
+ completion: Promise<void>;
28
+ }
29
+
30
+ function throwCleanupErrors(errors: unknown[], message: string): void {
31
+ if (errors.length === 1) throw errors[0];
32
+ if (errors.length > 1) throw new AggregateError(errors, message);
33
+ }
34
+
35
+ async function destroyTrackedSockets(sockets: Set<net.Socket>): Promise<void> {
36
+ while (sockets.size > 0) {
37
+ const tracked = [...sockets];
38
+ const closed = tracked.map((socket) => new Promise<void>((resolve) => {
39
+ socket.once("close", resolve);
40
+ }));
41
+ for (const socket of tracked) socket.destroy();
42
+ await Promise.all(closed);
43
+ }
44
+ }
45
+
46
+ async function closeListener(
47
+ server: net.Server,
48
+ sockets: Set<net.Socket>,
49
+ force: boolean,
50
+ lifecycle: ListenerCloseLifecycle,
51
+ waitForAcceptedSockets = true,
52
+ ): Promise<void> {
53
+ const socketTeardown = force ? destroyTrackedSockets(sockets) : undefined;
54
+
55
+ if (!server.listening) {
56
+ await socketTeardown;
57
+ if (force && !lifecycle.complete()) await lifecycle.completion;
58
+ return;
59
+ }
60
+
61
+ if (!waitForAcceptedSockets) {
62
+ // `server.close(callback)` keeps Bun's listener-close lifecycle referenced
63
+ // until every preserved socket drains. The compatibility path cannot await
64
+ // that lifecycle, so initiate close without a callback after unrefing the
65
+ // listener and accepted sockets. Synchronous initiation failures still
66
+ // reject this operation.
67
+ server.close();
68
+ return;
69
+ }
70
+
71
+ let listenerError: unknown;
72
+ try {
73
+ await new Promise<void>((resolve, reject) => {
74
+ try {
75
+ server.close((error) => {
76
+ if (error) reject(error);
77
+ else resolve();
78
+ });
79
+ } catch (error) {
80
+ reject(error);
81
+ }
82
+ });
83
+ } catch (error) {
84
+ listenerError = error;
85
+ }
86
+
87
+ await socketTeardown;
88
+ if (listenerError !== undefined) throw listenerError;
89
+ }
90
+
91
+ async function closeResources(
92
+ server: net.Server,
93
+ db: PGlite,
94
+ sockets: Set<net.Socket>,
95
+ force: boolean,
96
+ lifecycle: ListenerCloseLifecycle,
97
+ ): Promise<void> {
98
+ const errors: unknown[] = [];
99
+ try {
100
+ await closeListener(server, sockets, force, lifecycle);
101
+ } catch (error) {
102
+ errors.push(error);
103
+ }
104
+
105
+ try {
106
+ await db.close();
107
+ } catch (error) {
108
+ errors.push(error);
109
+ }
110
+
111
+ throwCleanupErrors(errors, "Failed to close the PGlite gateway and database.");
15
112
  }
16
113
 
17
114
  export async function startPglite(port = 5432): Promise<PgliteHandle> {
@@ -68,44 +165,145 @@ export async function startPglite(port = 5432): Promise<PgliteHandle> {
68
165
  });
69
166
  });
70
167
 
168
+ let listenerCloseComplete = false;
169
+ const listenerCloseCompletion = new Promise<void>((resolve) => {
170
+ server.once("close", () => {
171
+ listenerCloseComplete = true;
172
+ resolve();
173
+ });
174
+ });
175
+ const listenerLifecycle: ListenerCloseLifecycle = {
176
+ complete: () => listenerCloseComplete,
177
+ completion: listenerCloseCompletion,
178
+ };
179
+
71
180
  const sockets = new Set<net.Socket>();
181
+ let defaultCleanupDeferred = false;
182
+ let databaseCleanupPromise: Promise<void> | undefined;
183
+ let deferredCleanupObserved = false;
184
+
185
+ const closeDatabase = (): Promise<void> => {
186
+ databaseCleanupPromise ??= Promise.resolve().then(async () => {
187
+ // Once no accepted socket can deliver more ingress, finish the final live
188
+ // serialized operation before closing its PGlite backend.
189
+ await queue;
190
+ await db.close();
191
+ });
192
+ return databaseCleanupPromise;
193
+ };
194
+
195
+ const observeDeferredDatabaseCleanup = (): void => {
196
+ if (deferredCleanupObserved) return;
197
+ deferredCleanupObserved = true;
198
+ void closeDatabase().catch((error) => {
199
+ console.error("database: deferred PGlite cleanup failed", error);
200
+ });
201
+ };
202
+
72
203
  server.on("connection", (socket) => {
73
204
  sockets.add(socket);
74
- socket.once("close", () => sockets.delete(socket));
205
+ if (defaultCleanupDeferred) socket.unref();
206
+ socket.once("close", () => {
207
+ sockets.delete(socket);
208
+ if (defaultCleanupDeferred && sockets.size === 0) {
209
+ observeDeferredDatabaseCleanup();
210
+ }
211
+ });
75
212
  });
76
213
 
77
- await new Promise<void>((resolve, reject) => {
78
- server.once("error", reject);
79
- server.listen(port, "127.0.0.1", () => {
80
- server.off("error", reject);
81
- resolve();
214
+ try {
215
+ await new Promise<void>((resolve, reject) => {
216
+ server.once("error", reject);
217
+ server.listen(port, "127.0.0.1", () => {
218
+ server.off("error", reject);
219
+ resolve();
220
+ });
82
221
  });
83
- });
222
+ } catch (listenError) {
223
+ const errors: unknown[] = [listenError];
224
+ try {
225
+ await db.close();
226
+ } catch (dbError) {
227
+ errors.push(dbError);
228
+ }
229
+ throwCleanupErrors(errors, "Failed to start the PGlite gateway and close its database.");
230
+ throw listenError;
231
+ }
84
232
 
85
233
  const address = server.address();
86
234
  if (!address || typeof address === "string") {
87
- await db.close();
88
- throw new Error("Unable to determine the PGlite gateway port.");
235
+ const addressError = new Error("Unable to determine the PGlite gateway port.");
236
+ const errors: unknown[] = [addressError];
237
+ try {
238
+ await closeResources(server, db, sockets, true, listenerLifecycle);
239
+ } catch (cleanupError) {
240
+ if (cleanupError instanceof AggregateError) errors.push(...cleanupError.errors);
241
+ else errors.push(cleanupError);
242
+ }
243
+ throwCleanupErrors(errors, "Unable to determine and clean up the PGlite gateway.");
244
+ throw addressError;
89
245
  }
90
246
  const actualPort = address.port;
91
247
  console.info(`database: server listening on port ${actualPort}`);
92
248
 
93
- let closed = false;
249
+ let cleanupPromise: Promise<void> | undefined;
250
+
251
+ const closeDefault = async (): Promise<void> => {
252
+ const errors: unknown[] = [];
253
+ const deferDatabaseCleanup = sockets.size > 0;
254
+ if (deferDatabaseCleanup) {
255
+ defaultCleanupDeferred = true;
256
+ server.unref();
257
+ for (const socket of sockets) socket.unref();
258
+ }
259
+
260
+ try {
261
+ await closeListener(
262
+ server,
263
+ sockets,
264
+ false,
265
+ listenerLifecycle,
266
+ !deferDatabaseCleanup,
267
+ );
268
+ } catch (error) {
269
+ errors.push(error);
270
+ }
271
+
272
+ if (!deferDatabaseCleanup) {
273
+ try {
274
+ await closeDatabase();
275
+ } catch (error) {
276
+ errors.push(error);
277
+ }
278
+ } else {
279
+ if (sockets.size === 0) observeDeferredDatabaseCleanup();
280
+ }
281
+
282
+ throwCleanupErrors(errors, "Failed to close the PGlite gateway and database.");
283
+ };
284
+
285
+ const closeForced = async (): Promise<void> => {
286
+ const errors: unknown[] = [];
287
+ try {
288
+ await closeListener(server, sockets, true, listenerLifecycle);
289
+ } catch (error) {
290
+ errors.push(error);
291
+ }
292
+ try {
293
+ await closeDatabase();
294
+ } catch (error) {
295
+ errors.push(error);
296
+ }
297
+ throwCleanupErrors(errors, "Failed to close the PGlite gateway and database.");
298
+ };
94
299
 
95
300
  return {
96
301
  server,
97
302
  db,
98
303
  port: actualPort,
99
- close: async () => {
100
- if (closed) return;
101
- closed = true;
102
- for (const socket of sockets) socket.destroy();
103
- if (server.listening) {
104
- await new Promise<void>((resolve, reject) => {
105
- server.close((error) => error ? reject(error) : resolve());
106
- });
107
- }
108
- await db.close();
304
+ close: (options = {}) => {
305
+ cleanupPromise ??= options.force === true ? closeForced() : closeDefault();
306
+ return cleanupPromise;
109
307
  },
110
308
  };
111
309
  }
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
- import { spawn } from "node:child_process";
3
- import { args, exit } from "@effectstream/utils/runtime";
2
+ import net from "node:net";
3
+ import { args } from "@effectstream/utils/runtime";
4
4
 
5
5
  // Get port from arguments.
6
6
  const portArgName = "--port";
@@ -12,28 +12,34 @@ if (isNaN(port)) {
12
12
  throw new Error(`Port argument ${portArgName} is not a number`);
13
13
  }
14
14
 
15
- async function waitForDb() {
16
- try {
17
- console.log("waiting for db on port", port);
18
- const child = spawn("npx", ["wait-on", `tcp:${port}`], {
19
- stdio: "inherit",
20
- shell: true,
21
- });
22
-
23
- const code = await new Promise<number | null>((resolve) => {
24
- child.on("close", (code, _sig) => resolve(code));
25
- });
26
-
27
- if (code === 0) {
28
- console.log("✅ Database is ready on port 5432");
29
- } else {
30
- console.error("❌ Failed to connect to database on port 5432");
31
- exit(code ?? 1);
15
+ async function waitForDb(
16
+ targetPort = port,
17
+ host = "127.0.0.1",
18
+ timeoutMs = 10_000,
19
+ ): Promise<void> {
20
+ console.log(`waiting for db on ${host}:${targetPort}`);
21
+ const deadline = Date.now() + timeoutMs;
22
+ let lastError: unknown;
23
+ while (Date.now() < deadline) {
24
+ try {
25
+ await new Promise<void>((resolve, reject) => {
26
+ const socket = net.connect({ host, port: targetPort });
27
+ socket.once("connect", () => {
28
+ socket.destroy();
29
+ resolve();
30
+ });
31
+ socket.once("error", reject);
32
+ });
33
+ console.log(`✅ Database is ready on ${host}:${targetPort}`);
34
+ return;
35
+ } catch (error) {
36
+ lastError = error;
37
+ await new Promise((resolve) => setTimeout(resolve, 100));
32
38
  }
33
- } catch (error) {
34
- console.error("❌ Error waiting for database:", error);
35
- exit(1);
36
39
  }
40
+ throw new Error(`Failed to connect to database at ${host}:${targetPort}.`, {
41
+ cause: lastError,
42
+ });
37
43
  }
38
44
 
39
45
  export { waitForDb };
@@ -0,0 +1,10 @@
1
+ import { expect, test } from "bun:test";
2
+
3
+ if (process.env.EFFECTSTREAM_PGLITE_MULTIFILE_FIXTURE !== "1") {
4
+ test.skip("multi-file sentinel fixture runs only through its parent", () => {});
5
+ } else {
6
+ console.info("PGLITE_SENTINEL_REGISTERED");
7
+ test("the following file still registers", () => {
8
+ expect(true).toBe(true);
9
+ });
10
+ }