@effectstream/db 0.200.1 → 0.200.4
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 +49 -1
- package/package.json +6 -6
- package/scripts/pgtyped-update.ts +137 -40
- package/scripts/start-pglite.ts +219 -21
- package/scripts/wait-for-db.ts +28 -22
- package/test/fixtures/pglite-close/01-sentinel.test.ts +10 -0
- package/test/fixtures/pglite-close/02-live-client.test.ts +27 -0
- package/test/fixtures/pglite-close/process-exit.ts +10 -0
- package/test/fixtures/pgtyped/failure.mjs +17 -0
- package/test/fixtures/pgtyped/hang.mjs +12 -0
- package/test/fixtures/pgtyped/success.mjs +30 -0
- package/test/pgtyped-update.test.ts +132 -0
- package/test/start-pglite-multifile.test.ts +33 -0
- package/test/start-pglite.test.ts +670 -0
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.200.
|
|
3
|
+
"version": "0.200.4",
|
|
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.200.
|
|
25
|
-
"@effectstream/log": "0.200.
|
|
26
|
-
"@effectstream/runtime": "0.200.
|
|
27
|
-
"@effectstream/sync": "0.200.
|
|
28
|
-
"@effectstream/utils": "0.200.
|
|
24
|
+
"@effectstream/coroutine": "0.200.4",
|
|
25
|
+
"@effectstream/log": "0.200.4",
|
|
26
|
+
"@effectstream/runtime": "0.200.4",
|
|
27
|
+
"@effectstream/sync": "0.200.4",
|
|
28
|
+
"@effectstream/utils": "0.200.4",
|
|
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 {
|
|
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: "
|
|
62
|
-
port
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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(
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
package/scripts/start-pglite.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
server.
|
|
81
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
|
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:
|
|
100
|
-
|
|
101
|
-
|
|
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
|
}
|
package/scripts/wait-for-db.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import
|
|
3
|
-
import { args
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
+
}
|