@powerhousedao/switchboard 6.2.3-dev.5 → 6.2.3-dev.51
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/boot-workflow-singleton-conflict.test.d.ts +2 -0
- package/.tsbuild/test/boot-workflow-singleton-conflict.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-workflow-singleton-retry.test.d.ts +2 -0
- package/.tsbuild/test/boot-workflow-singleton-retry.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 +541 -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-J9fpd42v.mjs +3549 -0
- package/dist/server-J9fpd42v.mjs.map +1 -0
- package/dist/server.d.mts +155 -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/boot-workflow-singleton-conflict.test.ts +107 -0
- package/test/boot-workflow-singleton-retry.test.ts +133 -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 +217 -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 +357 -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 +808 -0
- package/test/workflow-read-gate.test.ts +213 -0
- package/test/workflow-runtime.test.ts +1067 -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,165 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
installFatalErrorShutdown,
|
|
5
|
+
triggerFatalShutdown,
|
|
6
|
+
} from "../src/fatal-shutdown.mjs";
|
|
7
|
+
|
|
8
|
+
function makeProc() {
|
|
9
|
+
const emitter = new EventEmitter();
|
|
10
|
+
const proc = Object.assign(emitter, {
|
|
11
|
+
pid: 1234,
|
|
12
|
+
exitCode: undefined as number | undefined,
|
|
13
|
+
kill: vi.fn((_pid: number, signal: string) => {
|
|
14
|
+
emitter.emit(signal);
|
|
15
|
+
return true;
|
|
16
|
+
}),
|
|
17
|
+
exit: vi.fn(),
|
|
18
|
+
});
|
|
19
|
+
return proc;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const logger = {
|
|
23
|
+
error: vi.fn(),
|
|
24
|
+
} as unknown as Parameters<typeof installFatalErrorShutdown>[0];
|
|
25
|
+
|
|
26
|
+
describe("installFatalErrorShutdown", () => {
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.useRealTimers();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it.each(["uncaughtException", "unhandledRejection"])(
|
|
32
|
+
"routes %s through SIGTERM with exit code 1",
|
|
33
|
+
(event) => {
|
|
34
|
+
const proc = makeProc();
|
|
35
|
+
const onSigterm = vi.fn();
|
|
36
|
+
proc.on("SIGTERM", onSigterm);
|
|
37
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
38
|
+
|
|
39
|
+
proc.emit(event, new Error("boom"));
|
|
40
|
+
|
|
41
|
+
expect(proc.kill).toHaveBeenCalledWith(1234, "SIGTERM");
|
|
42
|
+
expect(onSigterm).toHaveBeenCalledOnce();
|
|
43
|
+
expect(proc.exitCode).toBe(1);
|
|
44
|
+
expect(proc.exit).not.toHaveBeenCalled();
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
it("leaves an unhandled rejection to another listener, such as Sentry's", () => {
|
|
49
|
+
const proc = makeProc();
|
|
50
|
+
const other = vi.fn();
|
|
51
|
+
proc.on("unhandledRejection", other);
|
|
52
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
53
|
+
|
|
54
|
+
proc.emit("unhandledRejection", new Error("boom"));
|
|
55
|
+
|
|
56
|
+
expect(other).toHaveBeenCalledOnce();
|
|
57
|
+
expect(proc.kill).not.toHaveBeenCalled();
|
|
58
|
+
expect(proc.exitCode).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("still shuts down on an uncaught exception when another listener exists", () => {
|
|
62
|
+
const proc = makeProc();
|
|
63
|
+
proc.on("uncaughtException", vi.fn());
|
|
64
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
65
|
+
|
|
66
|
+
proc.emit("uncaughtException", new Error("boom"));
|
|
67
|
+
|
|
68
|
+
expect(proc.kill).toHaveBeenCalledWith(1234, "SIGTERM");
|
|
69
|
+
expect(proc.exitCode).toBe(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("signals once when a second fatal error arrives during shutdown", () => {
|
|
73
|
+
const proc = makeProc();
|
|
74
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
75
|
+
|
|
76
|
+
proc.emit("unhandledRejection", new Error("first"));
|
|
77
|
+
proc.emit("uncaughtException", new Error("second"));
|
|
78
|
+
|
|
79
|
+
expect(proc.kill).toHaveBeenCalledOnce();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("forces exit 1 through the original exit when shutdown hangs", () => {
|
|
83
|
+
vi.useFakeTimers();
|
|
84
|
+
const proc = makeProc();
|
|
85
|
+
const originalExit = proc.exit;
|
|
86
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
87
|
+
// The builder swaps process.exit for a recording shim during shutdown.
|
|
88
|
+
proc.exit = vi.fn();
|
|
89
|
+
|
|
90
|
+
proc.emit("unhandledRejection", new Error("boom"));
|
|
91
|
+
vi.advanceTimersByTime(15_000);
|
|
92
|
+
|
|
93
|
+
expect(originalExit).toHaveBeenCalledWith(1);
|
|
94
|
+
expect(proc.exit).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("still reaches exit when the logger itself throws EPIPE inside onFatal", () => {
|
|
98
|
+
vi.useFakeTimers();
|
|
99
|
+
const proc = makeProc();
|
|
100
|
+
const onSigterm = vi.fn();
|
|
101
|
+
proc.on("SIGTERM", onSigterm);
|
|
102
|
+
const error = vi.fn(() => {
|
|
103
|
+
throw Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
|
|
104
|
+
});
|
|
105
|
+
const throwingLogger = { error } as unknown as Parameters<
|
|
106
|
+
typeof installFatalErrorShutdown
|
|
107
|
+
>[0];
|
|
108
|
+
installFatalErrorShutdown(throwingLogger, proc as never);
|
|
109
|
+
|
|
110
|
+
// Without the guard the EPIPE propagates out of the uncaughtException
|
|
111
|
+
// listener, which in a real process re-enters fatal handling instead of
|
|
112
|
+
// running the shutdown.
|
|
113
|
+
expect(() =>
|
|
114
|
+
proc.emit("uncaughtException", new Error("boom")),
|
|
115
|
+
).not.toThrow();
|
|
116
|
+
|
|
117
|
+
expect(proc.kill).toHaveBeenCalledOnce();
|
|
118
|
+
expect(onSigterm).toHaveBeenCalledOnce();
|
|
119
|
+
expect(proc.exitCode).toBe(1);
|
|
120
|
+
|
|
121
|
+
vi.advanceTimersByTime(15_000);
|
|
122
|
+
|
|
123
|
+
expect(proc.exit).toHaveBeenCalledTimes(1);
|
|
124
|
+
expect(proc.exit).toHaveBeenCalledWith(1);
|
|
125
|
+
// Exactly the two guarded log attempts: the fatal report and the
|
|
126
|
+
// forced-exit notice. A re-entered handler would log again.
|
|
127
|
+
expect(error).toHaveBeenCalledTimes(2);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("routes a triggered fatal error through SIGTERM with exit code 1", () => {
|
|
131
|
+
const proc = makeProc();
|
|
132
|
+
const onSigterm = vi.fn();
|
|
133
|
+
proc.on("SIGTERM", onSigterm);
|
|
134
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
135
|
+
|
|
136
|
+
expect(
|
|
137
|
+
triggerFatalShutdown("Flush failed", new Error("ENOSPC"), proc as never),
|
|
138
|
+
).toBe(true);
|
|
139
|
+
triggerFatalShutdown("Flush failed", new Error("ENOSPC"), proc as never);
|
|
140
|
+
proc.emit("uncaughtException", new Error("later"));
|
|
141
|
+
|
|
142
|
+
expect(proc.kill).toHaveBeenCalledOnce();
|
|
143
|
+
expect(onSigterm).toHaveBeenCalledOnce();
|
|
144
|
+
expect(proc.exitCode).toBe(1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("ignores a trigger when shutdown on fatal errors is not installed", () => {
|
|
148
|
+
const proc = makeProc();
|
|
149
|
+
|
|
150
|
+
expect(
|
|
151
|
+
triggerFatalShutdown("Flush failed", new Error("ENOSPC"), proc as never),
|
|
152
|
+
).toBe(false);
|
|
153
|
+
expect(proc.kill).not.toHaveBeenCalled();
|
|
154
|
+
expect(proc.exitCode).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("installs its listeners once per process", () => {
|
|
158
|
+
const proc = makeProc();
|
|
159
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
160
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
161
|
+
|
|
162
|
+
expect(proc.listenerCount("uncaughtException")).toBe(1);
|
|
163
|
+
expect(proc.listenerCount("unhandledRejection")).toBe(1);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { initFeatureFlags } from "../src/feature-flags.js";
|
|
3
|
+
import { MCP_ENABLED, resolveMcpEnabled } from "../src/mcp-flag.mjs";
|
|
4
|
+
import type { BooleanFlagSource } from "../src/workflow-runtime.mjs";
|
|
5
|
+
|
|
6
|
+
describe("resolveMcpEnabled", () => {
|
|
7
|
+
let featureFlags: BooleanFlagSource;
|
|
8
|
+
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
featureFlags = await initFeatureFlags();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
delete process.env[MCP_ENABLED];
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("defaults to on", async () => {
|
|
18
|
+
await expect(resolveMcpEnabled({ featureFlags })).resolves.toBe(true);
|
|
19
|
+
await expect(
|
|
20
|
+
resolveMcpEnabled({ featureFlags, option: true }),
|
|
21
|
+
).resolves.toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('is turned off by MCP_ENABLED="false"', async () => {
|
|
25
|
+
process.env[MCP_ENABLED] = "false";
|
|
26
|
+
|
|
27
|
+
await expect(resolveMcpEnabled({ featureFlags })).resolves.toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// `ph vetra` passes `mcp: true` explicitly; the env var still wins.
|
|
31
|
+
it("turns off a host that asked for MCP", async () => {
|
|
32
|
+
process.env[MCP_ENABLED] = "false";
|
|
33
|
+
|
|
34
|
+
await expect(
|
|
35
|
+
resolveMcpEnabled({ featureFlags, option: true }),
|
|
36
|
+
).resolves.toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("never turns on a host that switched MCP off", async () => {
|
|
40
|
+
process.env[MCP_ENABLED] = "true";
|
|
41
|
+
|
|
42
|
+
await expect(
|
|
43
|
+
resolveMcpEnabled({ featureFlags, option: false }),
|
|
44
|
+
).resolves.toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("leaves MCP on for any value but the string false", async () => {
|
|
48
|
+
for (const raw of ["true", "0", "no", ""]) {
|
|
49
|
+
process.env[MCP_ENABLED] = raw;
|
|
50
|
+
await expect(
|
|
51
|
+
resolveMcpEnabled({ featureFlags }),
|
|
52
|
+
`MCP_ENABLED=${JSON.stringify(raw)}`,
|
|
53
|
+
).resolves.toBe(true);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -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,62 @@
|
|
|
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 {
|
|
6
|
+
IN_MEMORY_PGLITE_STORAGE_FACTS,
|
|
7
|
+
PGLITE_PATH_STORAGE_FACTS,
|
|
8
|
+
POSTGRES_STORAGE_FACTS,
|
|
9
|
+
} from "@powerhousedao/reactor";
|
|
10
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { createReactorKysely } from "../src/server.mjs";
|
|
14
|
+
import { EventEmitter } from "node:events";
|
|
15
|
+
import { installFatalErrorShutdown } from "../src/fatal-shutdown.mjs";
|
|
16
|
+
import {
|
|
17
|
+
ClosablePGliteDialect,
|
|
18
|
+
reactorPgliteDialectOptions,
|
|
19
|
+
} from "../src/pglite-dialect.js";
|
|
20
|
+
|
|
21
|
+
type Row = { id: number };
|
|
22
|
+
type Schema = { t: Row };
|
|
23
|
+
|
|
24
|
+
function stubLogger(): ILogger {
|
|
25
|
+
const logger = {
|
|
26
|
+
verbose: vi.fn(),
|
|
27
|
+
debug: vi.fn(),
|
|
28
|
+
info: vi.fn(),
|
|
29
|
+
warn: vi.fn(),
|
|
30
|
+
error: vi.fn(),
|
|
31
|
+
child: vi.fn(),
|
|
32
|
+
};
|
|
33
|
+
logger.child.mockReturnValue(logger);
|
|
34
|
+
return logger as unknown as ILogger;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** COMMIT on an aborted transaction silently rolls back unless the dialect guards it. */
|
|
38
|
+
async function commitOfAnAbortedTransaction(
|
|
39
|
+
db: Kysely<Schema>,
|
|
40
|
+
): Promise<{ outcome: unknown; rows: Row[] }> {
|
|
41
|
+
await sql`create table t (id int primary key)`.execute(db);
|
|
42
|
+
const outcome = await db
|
|
43
|
+
.transaction()
|
|
44
|
+
.execute(async (trx) => {
|
|
45
|
+
await sql`insert into t (id) values (1)`.execute(trx);
|
|
46
|
+
try {
|
|
47
|
+
await sql`select 1 / 0`.execute(trx);
|
|
48
|
+
} catch {
|
|
49
|
+
/* swallowed, exactly as an unrelated caller would */
|
|
50
|
+
}
|
|
51
|
+
return "JOB-SUCCEEDED";
|
|
52
|
+
})
|
|
53
|
+
.then(
|
|
54
|
+
(value: unknown) => value,
|
|
55
|
+
(error: Error) => ({ error: error.message }),
|
|
56
|
+
);
|
|
57
|
+
const rows = await sql<Row>`select id from t`.execute(db);
|
|
58
|
+
return { outcome, rows: rows.rows };
|
|
59
|
+
}
|
|
5
60
|
|
|
6
61
|
describe("ClosablePGliteDialect", () => {
|
|
7
62
|
const created: PGlite[] = [];
|
|
@@ -26,6 +81,34 @@ describe("ClosablePGliteDialect", () => {
|
|
|
26
81
|
expect(pglite.closed).toBe(true);
|
|
27
82
|
});
|
|
28
83
|
|
|
84
|
+
it("tolerates a close that rejects because the runtime aborted", async () => {
|
|
85
|
+
const pglite = new PGlite();
|
|
86
|
+
created.push(pglite);
|
|
87
|
+
const db = new Kysely({ dialect: new ClosablePGliteDialect(pglite) });
|
|
88
|
+
await sql`select 1`.execute(db);
|
|
89
|
+
const realClose = pglite.close.bind(pglite);
|
|
90
|
+
pglite.close = () => {
|
|
91
|
+
pglite.close = realClose;
|
|
92
|
+
return Promise.reject(new Error("PGlite aborted: ENOSPC"));
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
await expect(db.destroy()).resolves.toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("rethrows a close that fails for any other reason", async () => {
|
|
99
|
+
const pglite = new PGlite();
|
|
100
|
+
created.push(pglite);
|
|
101
|
+
const db = new Kysely({ dialect: new ClosablePGliteDialect(pglite) });
|
|
102
|
+
await sql`select 1`.execute(db);
|
|
103
|
+
const realClose = pglite.close.bind(pglite);
|
|
104
|
+
pglite.close = () => {
|
|
105
|
+
pglite.close = realClose;
|
|
106
|
+
return Promise.reject(new Error("disk gone"));
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
await expect(db.destroy()).rejects.toThrow("disk gone");
|
|
110
|
+
});
|
|
111
|
+
|
|
29
112
|
it("is idempotent if the PGlite is already closed", async () => {
|
|
30
113
|
const pglite = new PGlite();
|
|
31
114
|
created.push(pglite);
|
|
@@ -37,4 +120,136 @@ describe("ClosablePGliteDialect", () => {
|
|
|
37
120
|
|
|
38
121
|
await expect(db.destroy()).resolves.toBeUndefined();
|
|
39
122
|
});
|
|
123
|
+
|
|
124
|
+
it("refuses to report a commit for an aborted transaction", async () => {
|
|
125
|
+
const pglite = new PGlite();
|
|
126
|
+
created.push(pglite);
|
|
127
|
+
const db = new Kysely<Schema>({
|
|
128
|
+
dialect: new ClosablePGliteDialect(pglite, {
|
|
129
|
+
onDiagnostic: () => undefined,
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const { outcome, rows } = await commitOfAnAbortedTransaction(db);
|
|
134
|
+
expect(outcome).not.toBe("JOB-SUCCEEDED");
|
|
135
|
+
expect(rows).toEqual([]);
|
|
136
|
+
|
|
137
|
+
await db.destroy();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("switchboard's reactor storage factory", () => {
|
|
142
|
+
it("builds a PGlite kysely with the commit guard", async () => {
|
|
143
|
+
const storage = await createReactorKysely({
|
|
144
|
+
reactorDbUrl: undefined,
|
|
145
|
+
reactorPgliteDir: ".ph/unused-by-the-in-memory-branch",
|
|
146
|
+
reactorPgliteMajor: 17,
|
|
147
|
+
inMemory: true,
|
|
148
|
+
hostPoolSize: () => {
|
|
149
|
+
throw new Error("the PGlite branch must not read the host pool size");
|
|
150
|
+
},
|
|
151
|
+
logger: stubLogger(),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const { outcome, rows } = await commitOfAnAbortedTransaction(
|
|
155
|
+
storage.kysely as unknown as Kysely<Schema>,
|
|
156
|
+
);
|
|
157
|
+
expect(outcome).not.toBe("JOB-SUCCEEDED");
|
|
158
|
+
expect(rows).toEqual([]);
|
|
159
|
+
expect(storage.poolInstrumentation).toBeUndefined();
|
|
160
|
+
|
|
161
|
+
await storage.kysely.destroy();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("storage facts", () => {
|
|
165
|
+
let dataDir: string | undefined;
|
|
166
|
+
|
|
167
|
+
afterEach(async () => {
|
|
168
|
+
if (dataDir) await rm(dataDir, { recursive: true, force: true });
|
|
169
|
+
dataDir = undefined;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const open = (opts: Partial<Parameters<typeof createReactorKysely>[0]>) =>
|
|
173
|
+
createReactorKysely({
|
|
174
|
+
reactorDbUrl: undefined,
|
|
175
|
+
reactorPgliteDir: ".ph/unused-by-the-in-memory-branch",
|
|
176
|
+
reactorPgliteMajor: 17,
|
|
177
|
+
inMemory: false,
|
|
178
|
+
hostPoolSize: () => 1,
|
|
179
|
+
logger: stubLogger(),
|
|
180
|
+
...opts,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Kysely closes only a driver that has run a query; an unopened PGlite
|
|
184
|
+
// would still be initialising its data dir when afterEach removes it.
|
|
185
|
+
const openAndDestroy = async (
|
|
186
|
+
kysely: Awaited<ReturnType<typeof createReactorKysely>>["kysely"],
|
|
187
|
+
) => {
|
|
188
|
+
await sql`select 1`.execute(kysely);
|
|
189
|
+
await kysely.destroy();
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
it("reports in-memory PGlite as not durable", async () => {
|
|
193
|
+
const storage = await open({ inMemory: true });
|
|
194
|
+
expect(storage.storageFacts).toEqual(IN_MEMORY_PGLITE_STORAGE_FACTS);
|
|
195
|
+
await openAndDestroy(storage.kysely);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("reports a PGlite data directory as durable on a path", async () => {
|
|
199
|
+
dataDir = await mkdtemp(join(tmpdir(), "sb-facts-"));
|
|
200
|
+
const storage = await open({ reactorPgliteDir: dataDir });
|
|
201
|
+
expect(storage.storageFacts).toEqual(PGLITE_PATH_STORAGE_FACTS);
|
|
202
|
+
await openAndDestroy(storage.kysely);
|
|
203
|
+
}, 30_000);
|
|
204
|
+
|
|
205
|
+
it("reports a Postgres url as a durable server", async () => {
|
|
206
|
+
const storage = await open({
|
|
207
|
+
reactorDbUrl: "postgres://postgres:postgres@127.0.0.1:1/never-dialed",
|
|
208
|
+
});
|
|
209
|
+
expect(storage.storageFacts).toEqual(POSTGRES_STORAGE_FACTS);
|
|
210
|
+
await storage.kysely.destroy();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("a poisoned reactor PGlite session", () => {
|
|
216
|
+
it("takes the fatal shutdown path", async () => {
|
|
217
|
+
const emitter = new EventEmitter();
|
|
218
|
+
const proc = Object.assign(emitter, {
|
|
219
|
+
pid: 1234,
|
|
220
|
+
exitCode: undefined as number | undefined,
|
|
221
|
+
kill: vi.fn(() => true),
|
|
222
|
+
exit: vi.fn(),
|
|
223
|
+
});
|
|
224
|
+
const logger = stubLogger();
|
|
225
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
226
|
+
|
|
227
|
+
const pglite = new PGlite();
|
|
228
|
+
const dead = new Proxy(pglite, {
|
|
229
|
+
get(target, prop, receiver) {
|
|
230
|
+
if (prop === "query") {
|
|
231
|
+
return (text: string, params?: unknown[]) =>
|
|
232
|
+
/dead_call/.test(text)
|
|
233
|
+
? new Promise(() => undefined)
|
|
234
|
+
: target.query(text, params);
|
|
235
|
+
}
|
|
236
|
+
const value = Reflect.get(target, prop, receiver) as unknown;
|
|
237
|
+
return typeof value === "function"
|
|
238
|
+
? (value as (...args: unknown[]) => unknown).bind(target)
|
|
239
|
+
: value;
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
const db = new Kysely<Schema>({
|
|
243
|
+
dialect: new ClosablePGliteDialect(dead, {
|
|
244
|
+
...reactorPgliteDialectOptions(logger, proc as never),
|
|
245
|
+
statementTimeoutMs: 50,
|
|
246
|
+
}),
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await expect(sql`select 1 as dead_call`.execute(db)).rejects.toThrow();
|
|
250
|
+
|
|
251
|
+
expect(proc.kill).toHaveBeenCalledWith(1234, "SIGTERM");
|
|
252
|
+
expect(proc.exitCode).toBe(1);
|
|
253
|
+
await pglite.close();
|
|
254
|
+
});
|
|
40
255
|
});
|