@powerhousedao/switchboard 6.2.3-dev.5 → 6.2.3-dev.50
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/.tsbuild/test/attachments/byte-grants.test.d.ts +2 -0
- package/.tsbuild/test/attachments/byte-grants.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts +2 -0
- package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts +2 -0
- package/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/url-signer.test.d.ts +2 -0
- package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-create-signature-policy.test.d.ts +2 -0
- package/.tsbuild/test/boot-create-signature-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-mcp-enabled.test.d.ts +2 -0
- package/.tsbuild/test/boot-mcp-enabled.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-privacy.test.d.ts +2 -0
- package/.tsbuild/test/boot-privacy.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts +2 -0
- package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-without-renown.test.d.ts +2 -0
- package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
- package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
- package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
- package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
- package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
- package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/durable-node-fs-pg16.test.d.ts +2 -0
- package/.tsbuild/test/durable-node-fs-pg16.test.d.ts.map +1 -0
- package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
- package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
- package/.tsbuild/test/mcp-flag.test.d.ts +2 -0
- package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
- package/.tsbuild/test/observability-signals.test.d.ts +2 -0
- package/.tsbuild/test/observability-signals.test.d.ts.map +1 -0
- package/.tsbuild/test/pglite-preflight.test.d.ts +2 -0
- package/.tsbuild/test/pglite-preflight.test.d.ts.map +1 -0
- package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
- package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/snapshot-writer.d.ts +18 -0
- package/.tsbuild/test/snapshot-writer.d.ts.map +1 -0
- package/.tsbuild/test/unsupported-stored-documents.test.d.ts +2 -0
- package/.tsbuild/test/unsupported-stored-documents.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/harness.d.ts +56 -0
- package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
- package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
- package/.tsbuild/test/workflow/oauth.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-reactor-access.test.d.ts +2 -0
- package/.tsbuild/test/workflow-reactor-access.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts +2 -0
- package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
- package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/.tsbuild/tsdown.config.d.ts.map +1 -1
- package/.tsbuild/vitest.config.d.ts.map +1 -1
- package/Auth.md +94 -0
- package/CHANGELOG.md +509 -0
- package/README.md +35 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +48 -49
- package/dist/index.mjs.map +1 -1
- package/dist/install-packages.d.mts +2 -1
- package/dist/install-packages.mjs +2 -2
- package/dist/install-packages.mjs.map +1 -1
- package/dist/migrate.d.mts +2 -1
- package/dist/migrate.mjs +4 -3
- package/dist/migrate.mjs.map +1 -1
- package/dist/server-Zz7SqTwO.mjs +3335 -0
- package/dist/server-Zz7SqTwO.mjs.map +1 -0
- package/dist/server.d.mts +154 -30
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +4 -5
- package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
- package/dist/utils-uaBlzyjS.mjs.map +1 -0
- package/dist/utils.d.mts +4 -5
- package/dist/utils.d.mts.map +1 -1
- package/dist/utils.mjs +3 -3
- package/package.json +22 -18
- package/test/attachment-reference-read-model.test.ts +6 -1
- package/test/attachments/auth.test.ts +85 -0
- package/test/attachments/byte-grants.test.ts +290 -0
- package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
- package/test/attachments/download-target.test.ts +201 -32
- package/test/attachments/index.test.ts +34 -3
- package/test/attachments/reserve-metadata-parity.test.ts +96 -0
- package/test/attachments/routes-integration.test.ts +60 -4
- package/test/attachments/routes.test.ts +166 -23
- package/test/attachments/service-config.test.ts +13 -3
- package/test/attachments/url-signer.test.ts +105 -0
- package/test/boot-create-signature-policy.test.ts +108 -0
- package/test/boot-mcp-enabled.test.ts +121 -0
- package/test/boot-privacy.test.ts +243 -0
- package/test/boot-unsupported-stored-documents.test.ts +213 -0
- package/test/boot-without-renown.test.ts +81 -0
- package/test/boot-without-require-signatures.test.ts +74 -0
- package/test/boot-worker-trust-policy.test.ts +90 -0
- package/test/builder-defaults.test.ts +80 -0
- package/test/create-signature-policy.test.ts +69 -0
- package/test/durable-node-fs-pg16.test.ts +80 -0
- package/test/fatal-shutdown.test.ts +165 -0
- package/test/mcp-flag.test.ts +56 -0
- package/test/observability-signals.test.ts +131 -0
- package/test/pglite-dialect.test.ts +159 -2
- package/test/pglite-preflight.test.ts +313 -0
- package/test/pglite-version.test.ts +9 -1
- package/test/renown-trust-policy.test.ts +315 -0
- package/test/snapshot-writer.ts +94 -0
- package/test/unsupported-stored-documents.test.ts +72 -0
- package/test/worker-pool.test.ts +27 -1
- package/test/workflow/harness.ts +289 -0
- package/test/workflow/oauth.test.ts +214 -0
- package/test/workflow/resolvers.test.ts +322 -0
- package/test/workflow/webhook-dedupe.test.ts +295 -0
- package/test/workflow/webhook-signed.test.ts +509 -0
- package/test/workflow/webhook-trigger-test.test.ts +82 -0
- package/test/workflow/webhook-unsigned.test.ts +345 -0
- package/test/workflow-reactor-access.test.ts +384 -0
- package/test/workflow-reactor-enforcement.test.ts +805 -0
- package/test/workflow-read-gate.test.ts +210 -0
- package/test/workflow-runtime.test.ts +572 -0
- package/tsconfig.json +9 -3
- package/tsdown.config.ts +3 -1
- package/dist/server-CiKkVE6g.mjs +0 -1555
- package/dist/server-CiKkVE6g.mjs.map +0 -1
- package/dist/utils-Baw7rThP.mjs.map +0 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { installFatalErrorShutdown } from "../src/fatal-shutdown.mjs";
|
|
4
|
+
import { installObservabilitySignalHandlers } from "../src/observability-signals.mjs";
|
|
5
|
+
|
|
6
|
+
function makeProc() {
|
|
7
|
+
const emitter = new EventEmitter();
|
|
8
|
+
const proc = Object.assign(emitter, {
|
|
9
|
+
pid: 1234,
|
|
10
|
+
exitCode: undefined as number | undefined,
|
|
11
|
+
kill: vi.fn((_pid: number, signal: string) => {
|
|
12
|
+
emitter.emit(signal);
|
|
13
|
+
return true;
|
|
14
|
+
}),
|
|
15
|
+
exit: vi.fn(),
|
|
16
|
+
});
|
|
17
|
+
return proc;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const logger = {
|
|
21
|
+
error: vi.fn(),
|
|
22
|
+
} as unknown as Parameters<typeof installFatalErrorShutdown>[0];
|
|
23
|
+
|
|
24
|
+
/** Lets the finally callback behind the flush promise run. */
|
|
25
|
+
async function settleMicrotasks(): Promise<void> {
|
|
26
|
+
await Promise.resolve();
|
|
27
|
+
await Promise.resolve();
|
|
28
|
+
await Promise.resolve();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("installObservabilitySignalHandlers", () => {
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.useRealTimers();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it.each(["SIGINT", "SIGTERM"])(
|
|
37
|
+
"exits 0 after the flush on an ordinary %s",
|
|
38
|
+
async (signal) => {
|
|
39
|
+
const proc = makeProc();
|
|
40
|
+
const flush = vi.fn(() => Promise.resolve());
|
|
41
|
+
installObservabilitySignalHandlers(flush, proc as never);
|
|
42
|
+
|
|
43
|
+
proc.emit(signal);
|
|
44
|
+
await settleMicrotasks();
|
|
45
|
+
|
|
46
|
+
expect(flush).toHaveBeenCalledOnce();
|
|
47
|
+
expect(proc.exit).toHaveBeenCalledExactlyOnceWith(0);
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
it("does not exit before the flush settles", async () => {
|
|
52
|
+
const proc = makeProc();
|
|
53
|
+
let finish: () => void = () => undefined;
|
|
54
|
+
const flush = vi.fn(
|
|
55
|
+
() => new Promise<void>((resolve) => (finish = resolve)),
|
|
56
|
+
);
|
|
57
|
+
installObservabilitySignalHandlers(flush, proc as never);
|
|
58
|
+
|
|
59
|
+
proc.emit("SIGTERM");
|
|
60
|
+
await settleMicrotasks();
|
|
61
|
+
expect(proc.exit).not.toHaveBeenCalled();
|
|
62
|
+
|
|
63
|
+
finish();
|
|
64
|
+
await settleMicrotasks();
|
|
65
|
+
expect(proc.exit).toHaveBeenCalledExactlyOnceWith(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("keeps exit code 1 when a fatal error raised the SIGTERM", async () => {
|
|
69
|
+
vi.useFakeTimers();
|
|
70
|
+
const proc = makeProc();
|
|
71
|
+
const flush = vi.fn(() => Promise.resolve());
|
|
72
|
+
// Production order: observability's handlers install at module load,
|
|
73
|
+
// before the fatal handlers.
|
|
74
|
+
installObservabilitySignalHandlers(flush, proc as never);
|
|
75
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
76
|
+
|
|
77
|
+
proc.emit("uncaughtException", new Error("boom"));
|
|
78
|
+
expect(proc.exitCode).toBe(1);
|
|
79
|
+
await settleMicrotasks();
|
|
80
|
+
|
|
81
|
+
// The flush ran, but observability never lowered the code to 0; the
|
|
82
|
+
// process is still alive inside the fatal path's drain budget.
|
|
83
|
+
expect(flush).toHaveBeenCalledOnce();
|
|
84
|
+
expect(proc.exit).not.toHaveBeenCalled();
|
|
85
|
+
|
|
86
|
+
// Fatal-shutdown's backstop owns the exit and preserves code 1.
|
|
87
|
+
vi.advanceTimersByTime(15_000);
|
|
88
|
+
expect(proc.exit).toHaveBeenCalledExactlyOnceWith(1);
|
|
89
|
+
expect(proc.exit).not.toHaveBeenCalledWith(0);
|
|
90
|
+
expect(proc.exitCode).toBe(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("leaves the fatal drain budget intact even when the flush is slow", async () => {
|
|
94
|
+
vi.useFakeTimers();
|
|
95
|
+
const proc = makeProc();
|
|
96
|
+
let finish: () => void = () => undefined;
|
|
97
|
+
const flush = vi.fn(
|
|
98
|
+
() => new Promise<void>((resolve) => (finish = resolve)),
|
|
99
|
+
);
|
|
100
|
+
installObservabilitySignalHandlers(flush, proc as never);
|
|
101
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
102
|
+
|
|
103
|
+
proc.emit("uncaughtException", new Error("boom"));
|
|
104
|
+
|
|
105
|
+
// The flush gives up at its own ~5s budget (simulated by resolving it
|
|
106
|
+
// here); that settling must not force an exit ahead of the drain.
|
|
107
|
+
vi.advanceTimersByTime(5_000);
|
|
108
|
+
finish();
|
|
109
|
+
await settleMicrotasks();
|
|
110
|
+
expect(proc.exit).not.toHaveBeenCalled();
|
|
111
|
+
|
|
112
|
+
// Only the 15s fatal backstop ends the process, with code 1.
|
|
113
|
+
vi.advanceTimersByTime(9_999);
|
|
114
|
+
expect(proc.exit).not.toHaveBeenCalled();
|
|
115
|
+
vi.advanceTimersByTime(1);
|
|
116
|
+
expect(proc.exit).toHaveBeenCalledExactlyOnceWith(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("respects a non-zero exit code set by any other module", async () => {
|
|
120
|
+
const proc = makeProc();
|
|
121
|
+
proc.exitCode = 1;
|
|
122
|
+
const flush = vi.fn(() => Promise.resolve());
|
|
123
|
+
installObservabilitySignalHandlers(flush, proc as never);
|
|
124
|
+
|
|
125
|
+
proc.emit("SIGTERM");
|
|
126
|
+
await settleMicrotasks();
|
|
127
|
+
|
|
128
|
+
expect(flush).toHaveBeenCalledOnce();
|
|
129
|
+
expect(proc.exit).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -1,7 +1,54 @@
|
|
|
1
1
|
import { PGlite } from "@electric-sql/pglite";
|
|
2
2
|
import { Kysely, sql } from "kysely";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import type { ILogger } from "document-model";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { createReactorKysely } from "../src/server.mjs";
|
|
6
|
+
import { EventEmitter } from "node:events";
|
|
7
|
+
import { installFatalErrorShutdown } from "../src/fatal-shutdown.mjs";
|
|
8
|
+
import {
|
|
9
|
+
ClosablePGliteDialect,
|
|
10
|
+
reactorPgliteDialectOptions,
|
|
11
|
+
} from "../src/pglite-dialect.js";
|
|
12
|
+
|
|
13
|
+
type Row = { id: number };
|
|
14
|
+
type Schema = { t: Row };
|
|
15
|
+
|
|
16
|
+
function stubLogger(): ILogger {
|
|
17
|
+
const logger = {
|
|
18
|
+
verbose: vi.fn(),
|
|
19
|
+
debug: vi.fn(),
|
|
20
|
+
info: vi.fn(),
|
|
21
|
+
warn: vi.fn(),
|
|
22
|
+
error: vi.fn(),
|
|
23
|
+
child: vi.fn(),
|
|
24
|
+
};
|
|
25
|
+
logger.child.mockReturnValue(logger);
|
|
26
|
+
return logger as unknown as ILogger;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** COMMIT on an aborted transaction silently rolls back unless the dialect guards it. */
|
|
30
|
+
async function commitOfAnAbortedTransaction(
|
|
31
|
+
db: Kysely<Schema>,
|
|
32
|
+
): Promise<{ outcome: unknown; rows: Row[] }> {
|
|
33
|
+
await sql`create table t (id int primary key)`.execute(db);
|
|
34
|
+
const outcome = await db
|
|
35
|
+
.transaction()
|
|
36
|
+
.execute(async (trx) => {
|
|
37
|
+
await sql`insert into t (id) values (1)`.execute(trx);
|
|
38
|
+
try {
|
|
39
|
+
await sql`select 1 / 0`.execute(trx);
|
|
40
|
+
} catch {
|
|
41
|
+
/* swallowed, exactly as an unrelated caller would */
|
|
42
|
+
}
|
|
43
|
+
return "JOB-SUCCEEDED";
|
|
44
|
+
})
|
|
45
|
+
.then(
|
|
46
|
+
(value: unknown) => value,
|
|
47
|
+
(error: Error) => ({ error: error.message }),
|
|
48
|
+
);
|
|
49
|
+
const rows = await sql<Row>`select id from t`.execute(db);
|
|
50
|
+
return { outcome, rows: rows.rows };
|
|
51
|
+
}
|
|
5
52
|
|
|
6
53
|
describe("ClosablePGliteDialect", () => {
|
|
7
54
|
const created: PGlite[] = [];
|
|
@@ -26,6 +73,34 @@ describe("ClosablePGliteDialect", () => {
|
|
|
26
73
|
expect(pglite.closed).toBe(true);
|
|
27
74
|
});
|
|
28
75
|
|
|
76
|
+
it("tolerates a close that rejects because the runtime aborted", async () => {
|
|
77
|
+
const pglite = new PGlite();
|
|
78
|
+
created.push(pglite);
|
|
79
|
+
const db = new Kysely({ dialect: new ClosablePGliteDialect(pglite) });
|
|
80
|
+
await sql`select 1`.execute(db);
|
|
81
|
+
const realClose = pglite.close.bind(pglite);
|
|
82
|
+
pglite.close = () => {
|
|
83
|
+
pglite.close = realClose;
|
|
84
|
+
return Promise.reject(new Error("PGlite aborted: ENOSPC"));
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
await expect(db.destroy()).resolves.toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rethrows a close that fails for any other reason", async () => {
|
|
91
|
+
const pglite = new PGlite();
|
|
92
|
+
created.push(pglite);
|
|
93
|
+
const db = new Kysely({ dialect: new ClosablePGliteDialect(pglite) });
|
|
94
|
+
await sql`select 1`.execute(db);
|
|
95
|
+
const realClose = pglite.close.bind(pglite);
|
|
96
|
+
pglite.close = () => {
|
|
97
|
+
pglite.close = realClose;
|
|
98
|
+
return Promise.reject(new Error("disk gone"));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
await expect(db.destroy()).rejects.toThrow("disk gone");
|
|
102
|
+
});
|
|
103
|
+
|
|
29
104
|
it("is idempotent if the PGlite is already closed", async () => {
|
|
30
105
|
const pglite = new PGlite();
|
|
31
106
|
created.push(pglite);
|
|
@@ -37,4 +112,86 @@ describe("ClosablePGliteDialect", () => {
|
|
|
37
112
|
|
|
38
113
|
await expect(db.destroy()).resolves.toBeUndefined();
|
|
39
114
|
});
|
|
115
|
+
|
|
116
|
+
it("refuses to report a commit for an aborted transaction", async () => {
|
|
117
|
+
const pglite = new PGlite();
|
|
118
|
+
created.push(pglite);
|
|
119
|
+
const db = new Kysely<Schema>({
|
|
120
|
+
dialect: new ClosablePGliteDialect(pglite, {
|
|
121
|
+
onDiagnostic: () => undefined,
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const { outcome, rows } = await commitOfAnAbortedTransaction(db);
|
|
126
|
+
expect(outcome).not.toBe("JOB-SUCCEEDED");
|
|
127
|
+
expect(rows).toEqual([]);
|
|
128
|
+
|
|
129
|
+
await db.destroy();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("switchboard's reactor storage factory", () => {
|
|
134
|
+
it("builds a PGlite kysely with the commit guard", async () => {
|
|
135
|
+
const storage = await createReactorKysely({
|
|
136
|
+
reactorDbUrl: undefined,
|
|
137
|
+
reactorPgliteDir: ".ph/unused-by-the-in-memory-branch",
|
|
138
|
+
reactorPgliteMajor: 17,
|
|
139
|
+
inMemory: true,
|
|
140
|
+
hostPoolSize: () => {
|
|
141
|
+
throw new Error("the PGlite branch must not read the host pool size");
|
|
142
|
+
},
|
|
143
|
+
logger: stubLogger(),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const { outcome, rows } = await commitOfAnAbortedTransaction(
|
|
147
|
+
storage.kysely as unknown as Kysely<Schema>,
|
|
148
|
+
);
|
|
149
|
+
expect(outcome).not.toBe("JOB-SUCCEEDED");
|
|
150
|
+
expect(rows).toEqual([]);
|
|
151
|
+
expect(storage.poolInstrumentation).toBeUndefined();
|
|
152
|
+
|
|
153
|
+
await storage.kysely.destroy();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("a poisoned reactor PGlite session", () => {
|
|
158
|
+
it("takes the fatal shutdown path", async () => {
|
|
159
|
+
const emitter = new EventEmitter();
|
|
160
|
+
const proc = Object.assign(emitter, {
|
|
161
|
+
pid: 1234,
|
|
162
|
+
exitCode: undefined as number | undefined,
|
|
163
|
+
kill: vi.fn(() => true),
|
|
164
|
+
exit: vi.fn(),
|
|
165
|
+
});
|
|
166
|
+
const logger = stubLogger();
|
|
167
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
168
|
+
|
|
169
|
+
const pglite = new PGlite();
|
|
170
|
+
const dead = new Proxy(pglite, {
|
|
171
|
+
get(target, prop, receiver) {
|
|
172
|
+
if (prop === "query") {
|
|
173
|
+
return (text: string, params?: unknown[]) =>
|
|
174
|
+
/dead_call/.test(text)
|
|
175
|
+
? new Promise(() => undefined)
|
|
176
|
+
: target.query(text, params);
|
|
177
|
+
}
|
|
178
|
+
const value = Reflect.get(target, prop, receiver) as unknown;
|
|
179
|
+
return typeof value === "function"
|
|
180
|
+
? (value as (...args: unknown[]) => unknown).bind(target)
|
|
181
|
+
: value;
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
const db = new Kysely<Schema>({
|
|
185
|
+
dialect: new ClosablePGliteDialect(dead, {
|
|
186
|
+
...reactorPgliteDialectOptions(logger, proc as never),
|
|
187
|
+
statementTimeoutMs: 50,
|
|
188
|
+
}),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await expect(sql`select 1 as dead_call`.execute(db)).rejects.toThrow();
|
|
192
|
+
|
|
193
|
+
expect(proc.kill).toHaveBeenCalledWith(1234, "SIGTERM");
|
|
194
|
+
expect(proc.exitCode).toBe(1);
|
|
195
|
+
await pglite.close();
|
|
196
|
+
});
|
|
40
197
|
});
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { createDurableNodeFs } from "@powerhousedao/reactor-api/pglite-node";
|
|
2
|
+
import type { ILogger } from "document-model";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
afterAll,
|
|
8
|
+
afterEach,
|
|
9
|
+
beforeAll,
|
|
10
|
+
beforeEach,
|
|
11
|
+
describe,
|
|
12
|
+
expect,
|
|
13
|
+
it,
|
|
14
|
+
vi,
|
|
15
|
+
} from "vitest";
|
|
16
|
+
import { runPglitePreflight } from "../src/pglite-preflight.js";
|
|
17
|
+
import { loadNodeFsClass, loadPGliteModule } from "../src/pglite-version.js";
|
|
18
|
+
import { writeSnapshotFromDir } from "./snapshot-writer.js";
|
|
19
|
+
|
|
20
|
+
// PGlite initdb and pg_dump are several times slower on the Windows runner.
|
|
21
|
+
const BOOT_TIMEOUT = 120_000;
|
|
22
|
+
const RM = { recursive: true, force: true, maxRetries: 10 } as const;
|
|
23
|
+
const ROWS = 10;
|
|
24
|
+
const CONVERTED_LINE =
|
|
25
|
+
/^Converted PGlite snapshot at (.+): snapshot (\d+) bytes -> (\d+) bytes on disk, (\d+) entries, (\d+) ms$/;
|
|
26
|
+
|
|
27
|
+
type StubLogger = ILogger & {
|
|
28
|
+
debug: ReturnType<typeof vi.fn>;
|
|
29
|
+
info: ReturnType<typeof vi.fn>;
|
|
30
|
+
warn: ReturnType<typeof vi.fn>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function stubLogger(): StubLogger {
|
|
34
|
+
const logger = {
|
|
35
|
+
verbose: vi.fn(),
|
|
36
|
+
debug: vi.fn(),
|
|
37
|
+
info: vi.fn(),
|
|
38
|
+
warn: vi.fn(),
|
|
39
|
+
error: vi.fn(),
|
|
40
|
+
child: vi.fn(),
|
|
41
|
+
};
|
|
42
|
+
logger.child.mockReturnValue(logger);
|
|
43
|
+
return logger as unknown as StubLogger;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function exists(p: string): Promise<boolean> {
|
|
47
|
+
try {
|
|
48
|
+
await fs.lstat(p);
|
|
49
|
+
return true;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function pgVersion(dir: string): Promise<string> {
|
|
56
|
+
return (await fs.readFile(path.join(dir, "PG_VERSION"), "utf8")).trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Opens `dir` the way the server does and counts the seeded rows. */
|
|
60
|
+
async function countRows(dir: string, major: 16 | 17): Promise<number> {
|
|
61
|
+
const [{ PGlite: Pg }, Base] = await Promise.all([
|
|
62
|
+
loadPGliteModule(major),
|
|
63
|
+
loadNodeFsClass(major),
|
|
64
|
+
]);
|
|
65
|
+
const pg = new Pg({
|
|
66
|
+
fs: createDurableNodeFs(Base, dir, {
|
|
67
|
+
fsync: false,
|
|
68
|
+
maintenanceIntervalMs: 0,
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
try {
|
|
72
|
+
await pg.waitReady;
|
|
73
|
+
const { rows } = await pg.query<{ n: number }>(
|
|
74
|
+
'SELECT count(*)::int AS n FROM "Rows"',
|
|
75
|
+
);
|
|
76
|
+
return rows[0].n;
|
|
77
|
+
} finally {
|
|
78
|
+
await pg.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function convertedLines(logger: StubLogger): string[] {
|
|
83
|
+
return logger.info.mock.calls
|
|
84
|
+
.map((call) => String(call[0]))
|
|
85
|
+
.filter((line) => CONVERTED_LINE.test(line));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("runPglitePreflight", () => {
|
|
89
|
+
// A pure-snapshot PG16 store: snapshot.bin and nothing else, the layout
|
|
90
|
+
// AtomicNodeFs left for a store it created itself.
|
|
91
|
+
let template: string;
|
|
92
|
+
let root: string;
|
|
93
|
+
let logger: StubLogger;
|
|
94
|
+
|
|
95
|
+
beforeAll(async () => {
|
|
96
|
+
template = await fs.mkdtemp(path.join(os.tmpdir(), "preflight-template-"));
|
|
97
|
+
const loose = path.join(template, "loose");
|
|
98
|
+
const { PGlite: LegacyPGlite } = await loadPGliteModule(16);
|
|
99
|
+
const LegacyNodeFS = await loadNodeFsClass(16);
|
|
100
|
+
const pg = new LegacyPGlite({ fs: new LegacyNodeFS(loose) });
|
|
101
|
+
await pg.waitReady;
|
|
102
|
+
await pg.exec('CREATE TABLE "Rows" (id int PRIMARY KEY, payload text)');
|
|
103
|
+
await pg.exec(
|
|
104
|
+
`INSERT INTO "Rows" SELECT g, repeat('x', 100) FROM generate_series(1, ${ROWS}) g`,
|
|
105
|
+
);
|
|
106
|
+
await pg.close();
|
|
107
|
+
await fs.rm(path.join(loose, "postmaster.pid"), { force: true });
|
|
108
|
+
expect(await pgVersion(loose)).toBe("16");
|
|
109
|
+
|
|
110
|
+
const store = path.join(template, "store");
|
|
111
|
+
await fs.mkdir(store);
|
|
112
|
+
await writeSnapshotFromDir(loose, path.join(store, "snapshot.bin"));
|
|
113
|
+
await fs.rm(loose, RM);
|
|
114
|
+
}, BOOT_TIMEOUT);
|
|
115
|
+
|
|
116
|
+
afterAll(async () => {
|
|
117
|
+
await fs.rm(template, RM);
|
|
118
|
+
}, BOOT_TIMEOUT);
|
|
119
|
+
|
|
120
|
+
beforeEach(async () => {
|
|
121
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), "preflight-"));
|
|
122
|
+
logger = stubLogger();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
afterEach(async () => {
|
|
126
|
+
await fs.rm(root, RM);
|
|
127
|
+
}, BOOT_TIMEOUT);
|
|
128
|
+
|
|
129
|
+
async function snapshotDir(name: string): Promise<string> {
|
|
130
|
+
const dir = path.join(root, name);
|
|
131
|
+
await fs.cp(path.join(template, "store"), dir, { recursive: true });
|
|
132
|
+
return dir;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function looseDir(name: string, major = 17): Promise<string> {
|
|
136
|
+
const dir = path.join(root, name);
|
|
137
|
+
await fs.mkdir(path.join(dir, "pg_wal"), { recursive: true });
|
|
138
|
+
await fs.writeFile(path.join(dir, "PG_VERSION"), `${major}\n`);
|
|
139
|
+
return dir;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
it(
|
|
143
|
+
"converts before detecting the major, so a PG16 snapshot is migrated",
|
|
144
|
+
async () => {
|
|
145
|
+
const dir = await snapshotDir("reactor-storage");
|
|
146
|
+
|
|
147
|
+
const detected = await runPglitePreflight({
|
|
148
|
+
dirs: [dir],
|
|
149
|
+
migratePglite: true,
|
|
150
|
+
inMemory: false,
|
|
151
|
+
logger,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(detected.get(dir)).toBe(17);
|
|
155
|
+
expect(await pgVersion(dir)).toBe("17");
|
|
156
|
+
expect(await exists(path.join(dir, "snapshot.bin"))).toBe(false);
|
|
157
|
+
expect(await exists(`${dir}.converting`)).toBe(false);
|
|
158
|
+
expect(await exists(`${dir}.old`)).toBe(false);
|
|
159
|
+
expect(convertedLines(logger)).toHaveLength(1);
|
|
160
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
161
|
+
expect.stringContaining("Migrating"),
|
|
162
|
+
);
|
|
163
|
+
expect(await countRows(dir, 17)).toBe(ROWS);
|
|
164
|
+
},
|
|
165
|
+
BOOT_TIMEOUT,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// Pins the order alone: before this change a pure-snapshot dir had no loose
|
|
169
|
+
// PG_VERSION and the migration logged "No PG_VERSION; skipping". The case
|
|
170
|
+
// above also needs the legacy pg_dump to work.
|
|
171
|
+
it(
|
|
172
|
+
"attempts the migration of a PG16 snapshot instead of skipping it",
|
|
173
|
+
async () => {
|
|
174
|
+
const dir = await snapshotDir("reactor-storage");
|
|
175
|
+
|
|
176
|
+
await runPglitePreflight({
|
|
177
|
+
dirs: [dir],
|
|
178
|
+
migratePglite: true,
|
|
179
|
+
inMemory: false,
|
|
180
|
+
logger,
|
|
181
|
+
}).catch(() => undefined);
|
|
182
|
+
|
|
183
|
+
expect(convertedLines(logger)).toHaveLength(1);
|
|
184
|
+
expect(logger.info).not.toHaveBeenCalledWith(
|
|
185
|
+
expect.stringContaining("No PG_VERSION"),
|
|
186
|
+
);
|
|
187
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
188
|
+
expect.stringContaining(`Migrating ${dir} from PG16 to PG17`),
|
|
189
|
+
);
|
|
190
|
+
},
|
|
191
|
+
BOOT_TIMEOUT,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
it(
|
|
195
|
+
"boots a converted dir on the second start without converting",
|
|
196
|
+
async () => {
|
|
197
|
+
const dir = await snapshotDir("reactor-storage");
|
|
198
|
+
const first = { dirs: [dir], inMemory: false, logger };
|
|
199
|
+
await runPglitePreflight(first);
|
|
200
|
+
expect(convertedLines(logger)).toHaveLength(1);
|
|
201
|
+
expect(await pgVersion(dir)).toBe("16");
|
|
202
|
+
|
|
203
|
+
const second = stubLogger();
|
|
204
|
+
const detected = await runPglitePreflight({ ...first, logger: second });
|
|
205
|
+
|
|
206
|
+
expect(detected.get(dir)).toBe(16);
|
|
207
|
+
expect(convertedLines(second)).toHaveLength(0);
|
|
208
|
+
expect(await exists(`${dir}.converting`)).toBe(false);
|
|
209
|
+
expect(await exists(`${dir}.old`)).toBe(false);
|
|
210
|
+
expect(await countRows(dir, 16)).toBe(ROWS);
|
|
211
|
+
},
|
|
212
|
+
BOOT_TIMEOUT,
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
it("removes pg_wal/xlogtemp.*", async () => {
|
|
216
|
+
const dir = await looseDir("read-storage");
|
|
217
|
+
const segment = path.join(dir, "pg_wal", "000000010000000000000001");
|
|
218
|
+
await fs.writeFile(segment, "wal");
|
|
219
|
+
await fs.writeFile(path.join(dir, "pg_wal", "xlogtemp.42"), "");
|
|
220
|
+
await fs.writeFile(path.join(dir, "pg_wal", "xlogtemp.7"), "");
|
|
221
|
+
|
|
222
|
+
const detected = await runPglitePreflight({
|
|
223
|
+
dirs: [dir],
|
|
224
|
+
inMemory: false,
|
|
225
|
+
logger,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
expect(detected.get(dir)).toBe(17);
|
|
229
|
+
expect(await fs.readdir(path.join(dir, "pg_wal"))).toEqual([
|
|
230
|
+
"000000010000000000000001",
|
|
231
|
+
]);
|
|
232
|
+
expect(await exists(segment)).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("removes postmaster.pid without a warn call", async () => {
|
|
236
|
+
const dir = await looseDir("read-storage");
|
|
237
|
+
const lockfile = path.join(dir, "postmaster.pid");
|
|
238
|
+
await fs.writeFile(lockfile, "42\n");
|
|
239
|
+
|
|
240
|
+
await runPglitePreflight({ dirs: [dir], inMemory: false, logger });
|
|
241
|
+
|
|
242
|
+
expect(await exists(lockfile)).toBe(false);
|
|
243
|
+
expect(logger.warn).not.toHaveBeenCalled();
|
|
244
|
+
expect(logger.debug).toHaveBeenCalledWith(
|
|
245
|
+
expect.stringContaining(lockfile),
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it(
|
|
250
|
+
"logs one info line per converted dir with sizes and duration",
|
|
251
|
+
async () => {
|
|
252
|
+
const reactor = await snapshotDir("reactor-storage");
|
|
253
|
+
const readModel = await snapshotDir("read-storage");
|
|
254
|
+
|
|
255
|
+
await runPglitePreflight({
|
|
256
|
+
dirs: [reactor, readModel],
|
|
257
|
+
inMemory: false,
|
|
258
|
+
logger,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const lines = convertedLines(logger);
|
|
262
|
+
expect(lines).toHaveLength(2);
|
|
263
|
+
const byDir = new Map(
|
|
264
|
+
lines.map((line) => {
|
|
265
|
+
const match = CONVERTED_LINE.exec(line)!;
|
|
266
|
+
return [match[1], match.slice(2).map(Number)] as const;
|
|
267
|
+
}),
|
|
268
|
+
);
|
|
269
|
+
for (const dir of [reactor, readModel]) {
|
|
270
|
+
const [snapshotBytes, convertedBytes, entries] = byDir.get(dir)!;
|
|
271
|
+
expect(snapshotBytes).toBeGreaterThan(0);
|
|
272
|
+
expect(convertedBytes).toBeGreaterThan(0);
|
|
273
|
+
expect(entries).toBeGreaterThan(0);
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
BOOT_TIMEOUT,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
it("touches nothing in memory", async () => {
|
|
280
|
+
const dir = await snapshotDir("reactor-storage");
|
|
281
|
+
|
|
282
|
+
const detected = await runPglitePreflight({
|
|
283
|
+
dirs: [dir],
|
|
284
|
+
inMemory: true,
|
|
285
|
+
logger,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
expect(detected.size).toBe(0);
|
|
289
|
+
expect(await exists(path.join(dir, "snapshot.bin"))).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("wipes the dirs and their conversion siblings under forcePgVersion", async () => {
|
|
293
|
+
const dir = await snapshotDir("reactor-storage");
|
|
294
|
+
await fs.mkdir(`${dir}.old`);
|
|
295
|
+
await fs.mkdir(`${dir}.converting`);
|
|
296
|
+
|
|
297
|
+
const detected = await runPglitePreflight({
|
|
298
|
+
dirs: [dir],
|
|
299
|
+
forcePgVersion: 17,
|
|
300
|
+
migratePglite: true,
|
|
301
|
+
inMemory: false,
|
|
302
|
+
logger,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
expect(detected.size).toBe(0);
|
|
306
|
+
expect(await exists(dir)).toBe(false);
|
|
307
|
+
expect(await exists(`${dir}.old`)).toBe(false);
|
|
308
|
+
expect(await exists(`${dir}.converting`)).toBe(false);
|
|
309
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
310
|
+
expect.stringContaining("ignoring --migrate-pglite"),
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { parseForcePgVersion } from "../src/pglite-version.js";
|
|
2
|
+
import { openForVerify, parseForcePgVersion } from "../src/pglite-version.js";
|
|
3
3
|
|
|
4
4
|
describe("parseForcePgVersion", () => {
|
|
5
5
|
it("returns null for undefined", () => {
|
|
@@ -35,3 +35,11 @@ describe("parseForcePgVersion", () => {
|
|
|
35
35
|
expect(() => parseForcePgVersion("16.5")).toThrow(/PH_FORCE_PG_VERSION/);
|
|
36
36
|
});
|
|
37
37
|
});
|
|
38
|
+
|
|
39
|
+
describe("openForVerify", () => {
|
|
40
|
+
it("throws on an unsupported major before touching disk", async () => {
|
|
41
|
+
await expect(openForVerify(15, "/nonexistent/pglite-dir")).rejects.toThrow(
|
|
42
|
+
/PG_VERSION=15 is not supported/,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
});
|