@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,74 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { ILogger } from "document-model";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { startSwitchboard } from "../src/server.mjs";
|
|
7
|
+
|
|
8
|
+
function stubLogger(): ILogger & { info: ReturnType<typeof vi.fn> } {
|
|
9
|
+
const logger = {
|
|
10
|
+
verbose: vi.fn(),
|
|
11
|
+
debug: vi.fn(),
|
|
12
|
+
info: vi.fn(),
|
|
13
|
+
warn: vi.fn(),
|
|
14
|
+
error: vi.fn(),
|
|
15
|
+
child: vi.fn(),
|
|
16
|
+
};
|
|
17
|
+
logger.child.mockReturnValue(logger);
|
|
18
|
+
return logger as unknown as ILogger & { info: ReturnType<typeof vi.fn> };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("booting Switchboard", () => {
|
|
22
|
+
it("has no REQUIRE_SIGNATURES flag, and ignores the variable", async () => {
|
|
23
|
+
const tempRoot = await mkdtemp(join(tmpdir(), "switchboard-signatures-"));
|
|
24
|
+
const previous = {
|
|
25
|
+
reactorDb: process.env.PH_REACTOR_DATABASE_URL,
|
|
26
|
+
requireSignatures: process.env.REQUIRE_SIGNATURES,
|
|
27
|
+
};
|
|
28
|
+
process.env.PH_REACTOR_DATABASE_URL = join(tempRoot, "reactor-storage");
|
|
29
|
+
process.env.REQUIRE_SIGNATURES = "true";
|
|
30
|
+
const logger = stubLogger();
|
|
31
|
+
let switchboard: Awaited<ReturnType<typeof startSwitchboard>> | undefined;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
switchboard = await startSwitchboard({
|
|
35
|
+
workflows: { enabled: false },
|
|
36
|
+
dbPath: join(tempRoot, "read-model"),
|
|
37
|
+
port: 0,
|
|
38
|
+
mcp: false,
|
|
39
|
+
disableLocalPackages: true,
|
|
40
|
+
identity: { keypairPath: join(tempRoot, "identity.json") },
|
|
41
|
+
logger,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const flagLog = logger.info.mock.calls.find(
|
|
45
|
+
([message]) => message === "Feature flags: @flags",
|
|
46
|
+
);
|
|
47
|
+
expect(flagLog).toBeDefined();
|
|
48
|
+
expect(JSON.parse(flagLog![1] as string)).not.toHaveProperty(
|
|
49
|
+
"REQUIRE_SIGNATURES",
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const created = await switchboard.reactor.createEmpty(
|
|
53
|
+
"powerhouse/document-model",
|
|
54
|
+
);
|
|
55
|
+
expect(created.header.id).toBeTruthy();
|
|
56
|
+
|
|
57
|
+
await switchboard.shutdown();
|
|
58
|
+
switchboard = undefined;
|
|
59
|
+
} finally {
|
|
60
|
+
await switchboard?.shutdown();
|
|
61
|
+
for (const [name, value] of [
|
|
62
|
+
["PH_REACTOR_DATABASE_URL", previous.reactorDb],
|
|
63
|
+
["REQUIRE_SIGNATURES", previous.requireSignatures],
|
|
64
|
+
] as const) {
|
|
65
|
+
if (value === undefined) {
|
|
66
|
+
delete process.env[name];
|
|
67
|
+
} else {
|
|
68
|
+
process.env[name] = value;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
}, 60_000);
|
|
74
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ILogger } from "document-model";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type * as RenownModule from "../src/renown.js";
|
|
7
|
+
import { startSwitchboard } from "../src/server.mjs";
|
|
8
|
+
|
|
9
|
+
vi.mock("../src/renown.js", async (importOriginal) => ({
|
|
10
|
+
...(await importOriginal<typeof RenownModule>()),
|
|
11
|
+
initRenown: vi.fn().mockRejectedValue(new Error("Renown is unreachable")),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const TRUST_FAILURE =
|
|
15
|
+
/REACTOR_WORKERS[\s\S]*REACTOR_AUTH_ENFORCEMENT[\s\S]*RENOWN_SOURCE/;
|
|
16
|
+
const ENV_KEYS = [
|
|
17
|
+
"PH_REACTOR_DATABASE_URL",
|
|
18
|
+
"RENOWN_SOURCE",
|
|
19
|
+
"REACTOR_DOCUMENT_DECISIONS",
|
|
20
|
+
"REACTOR_AUTH_ENFORCEMENT",
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
function stubLogger(): ILogger {
|
|
24
|
+
const logger = {
|
|
25
|
+
verbose: vi.fn(),
|
|
26
|
+
debug: vi.fn(),
|
|
27
|
+
info: vi.fn(),
|
|
28
|
+
warn: vi.fn(),
|
|
29
|
+
error: vi.fn(),
|
|
30
|
+
child: vi.fn(),
|
|
31
|
+
};
|
|
32
|
+
logger.child.mockReturnValue(logger);
|
|
33
|
+
return logger as unknown as ILogger;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("booting with pooled workers under authEnforcement", () => {
|
|
37
|
+
let tempRoot: string;
|
|
38
|
+
let previous: Partial<Record<(typeof ENV_KEYS)[number], string>>;
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
tempRoot = await mkdtemp(join(tmpdir(), "switchboard-worker-trust-"));
|
|
42
|
+
previous = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
|
43
|
+
process.env.PH_REACTOR_DATABASE_URL = join(tempRoot, "reactor-storage");
|
|
44
|
+
process.env.REACTOR_DOCUMENT_DECISIONS = "true";
|
|
45
|
+
process.env.REACTOR_AUTH_ENFORCEMENT = "true";
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
for (const key of ENV_KEYS) {
|
|
50
|
+
if (previous[key] === undefined) {
|
|
51
|
+
delete process.env[key];
|
|
52
|
+
} else {
|
|
53
|
+
process.env[key] = previous[key];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function boot(numWorkers: number) {
|
|
60
|
+
return startSwitchboard({
|
|
61
|
+
workflows: { enabled: false },
|
|
62
|
+
dbPath: join(tempRoot, "read-model"),
|
|
63
|
+
port: 0,
|
|
64
|
+
mcp: false,
|
|
65
|
+
disableLocalPackages: true,
|
|
66
|
+
logger: stubLogger(),
|
|
67
|
+
workerPool: { numWorkers },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
it("fails with a self Renown source", async () => {
|
|
72
|
+
process.env.RENOWN_SOURCE = "self";
|
|
73
|
+
|
|
74
|
+
await expect(boot(2)).rejects.toThrow(TRUST_FAILURE);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Postgres is the next requirement a pooled boot has; reaching it passes the check.
|
|
78
|
+
it("passes the check with a remote Renown source", async () => {
|
|
79
|
+
process.env.RENOWN_SOURCE = "remote";
|
|
80
|
+
|
|
81
|
+
await expect(boot(2)).rejects.toThrow(/requires a Postgres/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// No loaded package serves the renown read model here; reaching that check passes this one.
|
|
85
|
+
it("passes the check without workers", async () => {
|
|
86
|
+
process.env.RENOWN_SOURCE = "self";
|
|
87
|
+
|
|
88
|
+
await expect(boot(0)).rejects.toThrow(/no loaded package serves/);
|
|
89
|
+
}, 60_000);
|
|
90
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Losing the workflow singleton claim must not take the whole API down.
|
|
2
|
+
//
|
|
3
|
+
// It used to: WorkflowSingletonConflictError propagated out of startSwitchboard
|
|
4
|
+
// and aborted the boot. With the old random per-process owner name, an unclean
|
|
5
|
+
// kill left a lease nobody could re-claim, so every restart inside the 60s TTL
|
|
6
|
+
// crash-looped the entire Switchboard — inspection, GraphQL, sync and all —
|
|
7
|
+
// over a component this host is merely not allowed to run.
|
|
8
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import type * as WorkflowEngine from "@powerhousedao/reactor-workflow";
|
|
12
|
+
import type { ILogger } from "document-model";
|
|
13
|
+
import { describe, expect, it, vi } from "vitest";
|
|
14
|
+
|
|
15
|
+
const HOLDER = "another-live-replica";
|
|
16
|
+
const HELD_UNTIL = "2030-01-01T00:00:00.000Z";
|
|
17
|
+
|
|
18
|
+
// The lease itself is the engine's to test (reactor-workflow,
|
|
19
|
+
// singleton-lease.test.ts). What is under test here is what THIS host does
|
|
20
|
+
// when the claim is refused, so only the claim is faked.
|
|
21
|
+
vi.mock("@powerhousedao/reactor-workflow", async (importOriginal) => {
|
|
22
|
+
const actual = await importOriginal<typeof WorkflowEngine>();
|
|
23
|
+
return {
|
|
24
|
+
...actual,
|
|
25
|
+
acquireWorkflowSingletonLease: () => {
|
|
26
|
+
const error = new Error(
|
|
27
|
+
`Workflow execution is a singleton and "${HOLDER}" holds it until ${HELD_UNTIL}`,
|
|
28
|
+
);
|
|
29
|
+
error.name = "WorkflowSingletonConflictError";
|
|
30
|
+
return Promise.reject(
|
|
31
|
+
Object.assign(error, {
|
|
32
|
+
owner: HOLDER,
|
|
33
|
+
expiresAt: HELD_UNTIL,
|
|
34
|
+
wouldBe: "this-host",
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const { startSwitchboard } = await import("../src/server.mjs");
|
|
42
|
+
|
|
43
|
+
function stubLogger(): ILogger & { warn: ReturnType<typeof vi.fn> } {
|
|
44
|
+
const logger = {
|
|
45
|
+
verbose: vi.fn(),
|
|
46
|
+
debug: vi.fn(),
|
|
47
|
+
info: vi.fn(),
|
|
48
|
+
warn: vi.fn(),
|
|
49
|
+
error: vi.fn(),
|
|
50
|
+
child: vi.fn(),
|
|
51
|
+
};
|
|
52
|
+
logger.child.mockReturnValue(logger);
|
|
53
|
+
return logger as unknown as ILogger & { warn: ReturnType<typeof vi.fn> };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("booting Switchboard when another process holds the singleton", () => {
|
|
57
|
+
it("boots the API without the workflow runtime, naming the owner", async () => {
|
|
58
|
+
const tempRoot = await mkdtemp(join(tmpdir(), "switchboard-wf-conflict-"));
|
|
59
|
+
const previousReactorDb = process.env.PH_REACTOR_DATABASE_URL;
|
|
60
|
+
process.env.PH_REACTOR_DATABASE_URL = join(tempRoot, "reactor-storage");
|
|
61
|
+
const logger = stubLogger();
|
|
62
|
+
let switchboard: Awaited<ReturnType<typeof startSwitchboard>> | undefined;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
// The boot RESOLVES. That is the whole finding.
|
|
66
|
+
switchboard = await startSwitchboard({
|
|
67
|
+
workflows: { enabled: true },
|
|
68
|
+
dbPath: join(tempRoot, "read-model"),
|
|
69
|
+
port: 0,
|
|
70
|
+
mcp: false,
|
|
71
|
+
disableLocalPackages: true,
|
|
72
|
+
identity: { keypairPath: join(tempRoot, "identity.json") },
|
|
73
|
+
logger,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// The API is up and serving: the reactor answers, the document models
|
|
77
|
+
// are registered, everything but workflows works.
|
|
78
|
+
const { results } = await switchboard.reactor.getDocumentModelModules();
|
|
79
|
+
expect(results.length).toBeGreaterThan(0);
|
|
80
|
+
|
|
81
|
+
// And the runtime is absent, said so rather than half-present.
|
|
82
|
+
expect(switchboard.workflowTriggers).toBeUndefined();
|
|
83
|
+
|
|
84
|
+
// Warned, not errored into silence, and the warning names who holds it
|
|
85
|
+
// and how to get workflows back.
|
|
86
|
+
const warning = logger.warn.mock.calls
|
|
87
|
+
.map((call) => String(call[0]))
|
|
88
|
+
.find((line) => line.includes("workflow singleton"));
|
|
89
|
+
expect(warning).toBeDefined();
|
|
90
|
+
expect(warning).toContain(HOLDER);
|
|
91
|
+
expect(warning).toContain(HELD_UNTIL);
|
|
92
|
+
expect(warning).toContain("WITHOUT the workflow runtime");
|
|
93
|
+
expect(warning).toContain("PH_WORKFLOWS_SINGLETON_OWNER");
|
|
94
|
+
|
|
95
|
+
await switchboard.shutdown();
|
|
96
|
+
switchboard = undefined;
|
|
97
|
+
} finally {
|
|
98
|
+
await switchboard?.shutdown();
|
|
99
|
+
if (previousReactorDb === undefined) {
|
|
100
|
+
delete process.env.PH_REACTOR_DATABASE_URL;
|
|
101
|
+
} else {
|
|
102
|
+
process.env.PH_REACTOR_DATABASE_URL = previousReactorDb;
|
|
103
|
+
}
|
|
104
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
}, 60_000);
|
|
107
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// A Switchboard refused the workflow singleton at boot retries the claim and
|
|
2
|
+
// starts workflows once the holder lets go. The runtime it composes then has
|
|
3
|
+
// to be reachable: its GraphQL face mounted, not only registered.
|
|
4
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
5
|
+
import { createServer } from "node:net";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
acquireWorkflowSingletonLease,
|
|
10
|
+
type WorkflowSingletonLease,
|
|
11
|
+
} from "@powerhousedao/reactor-workflow";
|
|
12
|
+
import {
|
|
13
|
+
createRelationalDb,
|
|
14
|
+
type IRelationalDb,
|
|
15
|
+
} from "@powerhousedao/shared/processors";
|
|
16
|
+
import type { ILogger } from "document-model";
|
|
17
|
+
import { Kysely, PostgresDialect } from "kysely";
|
|
18
|
+
import pg from "pg";
|
|
19
|
+
import { describe, expect, it, vi } from "vitest";
|
|
20
|
+
import { startSwitchboard } from "../src/server.mjs";
|
|
21
|
+
|
|
22
|
+
const PG_URL = process.env.REACTOR_TEST_PG_URL;
|
|
23
|
+
const DATABASE = "switchboard_workflow_retry";
|
|
24
|
+
|
|
25
|
+
function stubLogger(): ILogger {
|
|
26
|
+
const logger = {
|
|
27
|
+
verbose: vi.fn(),
|
|
28
|
+
debug: vi.fn(),
|
|
29
|
+
info: vi.fn(),
|
|
30
|
+
warn: vi.fn(),
|
|
31
|
+
error: vi.fn(),
|
|
32
|
+
child: vi.fn(),
|
|
33
|
+
};
|
|
34
|
+
logger.child.mockReturnValue(logger);
|
|
35
|
+
return logger as unknown as ILogger;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function freePort(): Promise<number> {
|
|
39
|
+
const server = createServer();
|
|
40
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
41
|
+
const address = server.address();
|
|
42
|
+
await new Promise((resolve) => server.close(resolve));
|
|
43
|
+
if (address === null || typeof address === "string") {
|
|
44
|
+
throw new Error("no port");
|
|
45
|
+
}
|
|
46
|
+
return address.port;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function queryHealth(port: number): Promise<string | undefined> {
|
|
50
|
+
const response = await fetch(`http://127.0.0.1:${port}/graphql`, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: { "content-type": "application/json" },
|
|
53
|
+
body: JSON.stringify({ query: "{ workflowRuntime { health } }" }),
|
|
54
|
+
});
|
|
55
|
+
const body = (await response.json()) as {
|
|
56
|
+
data?: { workflowRuntime?: { health?: string } };
|
|
57
|
+
};
|
|
58
|
+
return body.data?.workflowRuntime?.health;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe.skipIf(!PG_URL)(
|
|
62
|
+
"a Switchboard that claims the workflow singleton after boot [Postgres]",
|
|
63
|
+
() => {
|
|
64
|
+
it("serves the workflow GraphQL API once it has the claim", async () => {
|
|
65
|
+
const admin = new pg.Pool({ connectionString: PG_URL });
|
|
66
|
+
await admin.query(`DROP DATABASE IF EXISTS "${DATABASE}" WITH (FORCE)`);
|
|
67
|
+
await admin.query(`CREATE DATABASE "${DATABASE}"`);
|
|
68
|
+
const url = new URL(PG_URL!);
|
|
69
|
+
url.pathname = `/${DATABASE}`;
|
|
70
|
+
const readModelUrl = url.toString();
|
|
71
|
+
const holderDb = new Kysely<unknown>({
|
|
72
|
+
dialect: new PostgresDialect({
|
|
73
|
+
pool: new pg.Pool({ connectionString: readModelUrl, max: 2 }),
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
const tempRoot = await mkdtemp(join(tmpdir(), "switchboard-wf-retry-"));
|
|
77
|
+
const previousReactorDb = process.env.PH_REACTOR_DATABASE_URL;
|
|
78
|
+
process.env.PH_REACTOR_DATABASE_URL = join(tempRoot, "reactor-storage");
|
|
79
|
+
let holder: WorkflowSingletonLease | undefined;
|
|
80
|
+
let switchboard: Awaited<ReturnType<typeof startSwitchboard>> | undefined;
|
|
81
|
+
try {
|
|
82
|
+
// Another live host holds the singleton over the same journal.
|
|
83
|
+
holder = await acquireWorkflowSingletonLease({
|
|
84
|
+
relationalDb: createRelationalDb(holderDb) as IRelationalDb,
|
|
85
|
+
logger: stubLogger(),
|
|
86
|
+
owner: "the-other-host",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
switchboard = await startSwitchboard({
|
|
90
|
+
workflows: { enabled: true },
|
|
91
|
+
dbPath: readModelUrl,
|
|
92
|
+
port: await freePort(),
|
|
93
|
+
strictPort: true,
|
|
94
|
+
mcp: false,
|
|
95
|
+
disableLocalPackages: true,
|
|
96
|
+
identity: { keypairPath: join(tempRoot, "identity.json") },
|
|
97
|
+
logger: stubLogger(),
|
|
98
|
+
});
|
|
99
|
+
expect(switchboard.workflowTriggers).toBeUndefined();
|
|
100
|
+
expect(await queryHealth(switchboard.port)).toBeUndefined();
|
|
101
|
+
|
|
102
|
+
await holder.release();
|
|
103
|
+
holder = undefined;
|
|
104
|
+
|
|
105
|
+
const port = switchboard.port;
|
|
106
|
+
await vi.waitFor(
|
|
107
|
+
async () => expect(await queryHealth(port)).toBe("ok"),
|
|
108
|
+
{ timeout: 60_000, interval: 500 },
|
|
109
|
+
);
|
|
110
|
+
expect(switchboard.workflowTriggers).toEqual({ status: "available" });
|
|
111
|
+
|
|
112
|
+
await switchboard.shutdown();
|
|
113
|
+
switchboard = undefined;
|
|
114
|
+
} finally {
|
|
115
|
+
await holder?.release();
|
|
116
|
+
await switchboard?.shutdown();
|
|
117
|
+
await holderDb.destroy();
|
|
118
|
+
if (previousReactorDb === undefined) {
|
|
119
|
+
delete process.env.PH_REACTOR_DATABASE_URL;
|
|
120
|
+
} else {
|
|
121
|
+
process.env.PH_REACTOR_DATABASE_URL = previousReactorDb;
|
|
122
|
+
}
|
|
123
|
+
// Not forced: terminating a pool's idle connections under it raises
|
|
124
|
+
// an unhandled error. A database left behind is dropped next run.
|
|
125
|
+
await admin
|
|
126
|
+
.query(`DROP DATABASE IF EXISTS "${DATABASE}"`)
|
|
127
|
+
.catch(() => undefined);
|
|
128
|
+
await admin.end();
|
|
129
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
}, 120_000);
|
|
132
|
+
},
|
|
133
|
+
);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ISigner } from "@powerhousedao/shared/document-model";
|
|
2
|
+
import {
|
|
3
|
+
ReactorBuilder,
|
|
4
|
+
ReactorClientBuilder,
|
|
5
|
+
type FactorySpec,
|
|
6
|
+
} from "@powerhousedao/reactor";
|
|
7
|
+
import type { IRenown } from "@renown/sdk/node";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { describe, expect, it, vi } from "vitest";
|
|
11
|
+
import { applySwitchboardReactorDefaults } from "../src/builder-defaults.mjs";
|
|
12
|
+
import {
|
|
13
|
+
getRenownSignerConfig,
|
|
14
|
+
getRenownWorkerSignerSpec,
|
|
15
|
+
} from "../src/renown.js";
|
|
16
|
+
|
|
17
|
+
const USER = { address: "0xswitchboard", networkId: "eip155", chainId: 1 };
|
|
18
|
+
|
|
19
|
+
function stubRenown(user = USER): IRenown {
|
|
20
|
+
const signer = {
|
|
21
|
+
user,
|
|
22
|
+
app: { name: "switchboard", key: "did:key:zSwitchboard" },
|
|
23
|
+
} as unknown as ISigner;
|
|
24
|
+
return { signer } as unknown as IRenown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("applySwitchboardReactorDefaults", () => {
|
|
28
|
+
it("gives the reactor the signer and its worker spec", () => {
|
|
29
|
+
const reactorBuilder = new ReactorBuilder();
|
|
30
|
+
const clientBuilder = new ReactorClientBuilder();
|
|
31
|
+
const reactorWithSigner = vi.spyOn(reactorBuilder, "withSigner");
|
|
32
|
+
const clientWithSigner = vi.spyOn(clientBuilder, "withSigner");
|
|
33
|
+
const signer = getRenownSignerConfig(stubRenown(), ".ph/key.json");
|
|
34
|
+
|
|
35
|
+
applySwitchboardReactorDefaults(reactorBuilder, clientBuilder, {
|
|
36
|
+
includeBaseModels: false,
|
|
37
|
+
signalHandlers: false,
|
|
38
|
+
signer,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(clientWithSigner).toHaveBeenCalledWith(signer);
|
|
42
|
+
expect(reactorWithSigner).toHaveBeenCalledWith(
|
|
43
|
+
signer.signer,
|
|
44
|
+
signer.workerSigner,
|
|
45
|
+
);
|
|
46
|
+
expect(reactorBuilder.hasSigner()).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("getRenownWorkerSignerSpec", () => {
|
|
51
|
+
it("points workers at createNodeRenownSigner over the stored key", () => {
|
|
52
|
+
const spec: FactorySpec = getRenownWorkerSignerSpec(
|
|
53
|
+
stubRenown(),
|
|
54
|
+
".ph/key.json",
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(spec.module.exportName).toBe("createNodeRenownSigner");
|
|
58
|
+
const filePath = "filePath" in spec.module ? spec.module.filePath : "";
|
|
59
|
+
expect(existsSync(filePath)).toBe(true);
|
|
60
|
+
expect(spec.initArgs).toEqual({
|
|
61
|
+
appName: "switchboard",
|
|
62
|
+
keypairPath: resolve(".ph/key.json"),
|
|
63
|
+
did: "did:key:zSwitchboard",
|
|
64
|
+
user: USER,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("leaves the user out when none is logged in", () => {
|
|
69
|
+
const renown = stubRenown();
|
|
70
|
+
(renown.signer as { user?: unknown }).user = undefined;
|
|
71
|
+
|
|
72
|
+
const spec = getRenownWorkerSignerSpec(renown);
|
|
73
|
+
|
|
74
|
+
expect(spec.initArgs).toEqual({
|
|
75
|
+
appName: "switchboard",
|
|
76
|
+
keypairPath: resolve(".ph/.keypair.json"),
|
|
77
|
+
did: "did:key:zSwitchboard",
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { ReactorBuilder, ReactorClientBuilder } from "@powerhousedao/reactor";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { applySwitchboardReactorDefaults } from "../src/builder-defaults.mjs";
|
|
4
|
+
import { resolveCreateSignaturePolicy } from "../src/create-signature-policy.mjs";
|
|
5
|
+
|
|
6
|
+
describe("resolveCreateSignaturePolicy", () => {
|
|
7
|
+
it("defaults to v2-required with a signer", () => {
|
|
8
|
+
expect(resolveCreateSignaturePolicy({}, { hasSigner: true })).toBe(
|
|
9
|
+
"v2-required",
|
|
10
|
+
);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("reads CREATE_SIGNATURE_POLICY", () => {
|
|
14
|
+
expect(
|
|
15
|
+
resolveCreateSignaturePolicy(
|
|
16
|
+
{ CREATE_SIGNATURE_POLICY: "legacy" },
|
|
17
|
+
{ hasSigner: true },
|
|
18
|
+
),
|
|
19
|
+
).toBe("legacy");
|
|
20
|
+
expect(
|
|
21
|
+
resolveCreateSignaturePolicy(
|
|
22
|
+
{ CREATE_SIGNATURE_POLICY: " v2-required " },
|
|
23
|
+
{ hasSigner: true },
|
|
24
|
+
),
|
|
25
|
+
).toBe("v2-required");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("refuses an unknown value", () => {
|
|
29
|
+
expect(() =>
|
|
30
|
+
resolveCreateSignaturePolicy(
|
|
31
|
+
{ CREATE_SIGNATURE_POLICY: "v2" },
|
|
32
|
+
{ hasSigner: true },
|
|
33
|
+
),
|
|
34
|
+
).toThrow(/CREATE_SIGNATURE_POLICY/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("falls back to legacy with a warning when there is no signer", () => {
|
|
38
|
+
const warn = vi.fn();
|
|
39
|
+
|
|
40
|
+
expect(
|
|
41
|
+
resolveCreateSignaturePolicy({}, { hasSigner: false, logger: { warn } }),
|
|
42
|
+
).toBe("legacy");
|
|
43
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
44
|
+
|
|
45
|
+
warn.mockClear();
|
|
46
|
+
expect(
|
|
47
|
+
resolveCreateSignaturePolicy(
|
|
48
|
+
{ CREATE_SIGNATURE_POLICY: "legacy" },
|
|
49
|
+
{ hasSigner: false, logger: { warn } },
|
|
50
|
+
),
|
|
51
|
+
).toBe("legacy");
|
|
52
|
+
expect(warn).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("applySwitchboardReactorDefaults", () => {
|
|
57
|
+
it("hands the creation policy to the client builder", () => {
|
|
58
|
+
const clientBuilder = new ReactorClientBuilder();
|
|
59
|
+
const withPolicy = vi.spyOn(clientBuilder, "withCreateSignaturePolicy");
|
|
60
|
+
|
|
61
|
+
applySwitchboardReactorDefaults(new ReactorBuilder(), clientBuilder, {
|
|
62
|
+
includeBaseModels: false,
|
|
63
|
+
signalHandlers: false,
|
|
64
|
+
createSignaturePolicy: "legacy",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(withPolicy).toHaveBeenCalledWith("legacy");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createDurableNodeFs } from "@powerhousedao/reactor-api/pglite-node";
|
|
2
|
+
import nodeFs, { promises as fs } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { loadNodeFsClass, loadPGliteModule } from "../src/pglite-version.js";
|
|
7
|
+
|
|
8
|
+
// A fresh initdb takes ~10 s under suite load; the Windows runner is slower.
|
|
9
|
+
const BOOT = 180_000;
|
|
10
|
+
const COMMITS = 10;
|
|
11
|
+
const RM = { recursive: true, force: true, maxRetries: 10 } as const;
|
|
12
|
+
|
|
13
|
+
describe("durable NodeFS over pglite-legacy-02 (PG16)", () => {
|
|
14
|
+
const tempDirs: string[] = [];
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
for (const dir of tempDirs.splice(0)) {
|
|
18
|
+
await fs.rm(dir, RM);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it(
|
|
23
|
+
"opens a PG16 dir over pglite-legacy-02 with the durable fs",
|
|
24
|
+
async () => {
|
|
25
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "durable-pg16-"));
|
|
26
|
+
tempDirs.push(dir);
|
|
27
|
+
const counts = { fsync: 0, fdatasync: 0 };
|
|
28
|
+
const hostFs = {
|
|
29
|
+
fsyncSync: (fd: number) => {
|
|
30
|
+
counts.fsync++;
|
|
31
|
+
nodeFs.fsyncSync(fd);
|
|
32
|
+
},
|
|
33
|
+
fdatasyncSync: (fd: number) => {
|
|
34
|
+
counts.fdatasync++;
|
|
35
|
+
nodeFs.fdatasyncSync(fd);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const [{ PGlite }, NodeFS] = await Promise.all([
|
|
39
|
+
loadPGliteModule(16),
|
|
40
|
+
loadNodeFsClass(16),
|
|
41
|
+
]);
|
|
42
|
+
const pg = new PGlite({
|
|
43
|
+
fs: createDurableNodeFs(NodeFS, dir, {
|
|
44
|
+
hostFs,
|
|
45
|
+
maintenanceIntervalMs: 0,
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
try {
|
|
49
|
+
await pg.waitReady;
|
|
50
|
+
const version = await pg.query<{ v: string }>(
|
|
51
|
+
"SELECT current_setting('server_version_num') AS v",
|
|
52
|
+
);
|
|
53
|
+
expect(version.rows[0].v.startsWith("16")).toBe(true);
|
|
54
|
+
await pg.exec("CREATE TABLE t (v int)");
|
|
55
|
+
const before = counts.fdatasync;
|
|
56
|
+
for (let i = 0; i < COMMITS; i++) {
|
|
57
|
+
await pg.exec(`INSERT INTO t VALUES (${i})`);
|
|
58
|
+
}
|
|
59
|
+
const perCommit = counts.fdatasync - before;
|
|
60
|
+
const fsyncBefore = counts.fsync;
|
|
61
|
+
await pg.exec("CHECKPOINT");
|
|
62
|
+
const checkpointSyncs = counts.fsync - fsyncBefore;
|
|
63
|
+
console.info(
|
|
64
|
+
`pglite-legacy-02: ${perCommit} host fdatasync over ${COMMITS} commits, ${checkpointSyncs} host fsync on CHECKPOINT, ${fsyncBefore} host fsync since open`,
|
|
65
|
+
);
|
|
66
|
+
// Pins decision 5's outcome on 0.2.17: WAL goes through
|
|
67
|
+
// open_datasync, which never reaches the fdatasync hook, so commits
|
|
68
|
+
// are not synced; the stream-op patch does take (CHECKPOINT syncs).
|
|
69
|
+
expect(perCommit).toBe(0);
|
|
70
|
+
expect(checkpointSyncs).toBeGreaterThan(0);
|
|
71
|
+
} finally {
|
|
72
|
+
await pg.close();
|
|
73
|
+
}
|
|
74
|
+
expect(
|
|
75
|
+
(await fs.readFile(path.join(dir, "PG_VERSION"), "utf8")).trim(),
|
|
76
|
+
).toBe("16");
|
|
77
|
+
},
|
|
78
|
+
BOOT,
|
|
79
|
+
);
|
|
80
|
+
});
|