@powerhousedao/switchboard 6.2.3-dev.50 → 6.2.3-dev.52
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/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/tsconfig.tsbuildinfo +1 -1
- package/CHANGELOG.md +66 -0
- package/dist/index.mjs +5 -4
- package/dist/index.mjs.map +1 -1
- package/dist/migrate.mjs +0 -0
- package/dist/{server-Zz7SqTwO.mjs → server-J9fpd42v.mjs} +262 -48
- package/dist/server-J9fpd42v.mjs.map +1 -0
- package/dist/server.d.mts +3 -2
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +3 -3
- package/package.json +15 -15
- package/scripts/install-packages.sh +0 -0
- package/test/boot-workflow-singleton-conflict.test.ts +107 -0
- package/test/boot-workflow-singleton-retry.test.ts +133 -0
- package/test/pglite-dialect.test.ts +58 -0
- package/test/workflow/resolvers.test.ts +35 -0
- package/test/workflow-reactor-enforcement.test.ts +3 -0
- package/test/workflow-read-gate.test.ts +3 -0
- package/test/workflow-runtime.test.ts +502 -7
- package/dist/server-Zz7SqTwO.mjs.map +0 -1
package/dist/server.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ILogger } from "document-model";
|
|
2
2
|
import "@powerhousedao/reactor-api/pglite-node";
|
|
3
|
-
import { ChannelScheme, Database, IDocumentModelLoader, IReactorClient, InProcessReactorClientModule, JwtHandler, ModelManifestEntry, PoolInstrumentation, ReactorBuilder, ReactorClientBuilder, ReactorFeatureFlags, SignerConfig, UnsupportedStoredDocuments, UnsupportedStoredProtocolError } from "@powerhousedao/reactor";
|
|
3
|
+
import { ChannelScheme, Database, IDocumentModelLoader, IReactorClient, InProcessReactorClientModule, JwtHandler, ModelManifestEntry, PoolInstrumentation, ReactorBuilder, ReactorClientBuilder, ReactorFeatureFlags, ReactorStorageFacts, SignerConfig, UnsupportedStoredDocuments, UnsupportedStoredProtocolError } from "@powerhousedao/reactor";
|
|
4
4
|
import { AttachmentReferenceProjectionCapability } from "@powerhousedao/reactor-api";
|
|
5
5
|
import { IAttachmentService } from "@powerhousedao/reactor-attachments";
|
|
6
6
|
import { IRenown } from "@renown/sdk/node";
|
|
@@ -33,7 +33,7 @@ type WorkflowTriggersCapability = {
|
|
|
33
33
|
status: "available";
|
|
34
34
|
} | {
|
|
35
35
|
status: "unavailable";
|
|
36
|
-
reason: "in-process-reactor-module-unavailable" | "live-read-model-registration-unsupported";
|
|
36
|
+
reason: "in-process-reactor-module-unavailable" | "live-read-model-registration-unsupported" | "workflow-singleton-lost";
|
|
37
37
|
};
|
|
38
38
|
//#endregion
|
|
39
39
|
//#region src/types.d.ts
|
|
@@ -312,6 +312,7 @@ export declare function isPortAvailable(port: number): Promise<boolean>;
|
|
|
312
312
|
type ReactorStorage = {
|
|
313
313
|
kysely: Kysely<Database>;
|
|
314
314
|
poolInstrumentation: PoolInstrumentation | undefined;
|
|
315
|
+
storageFacts: ReactorStorageFacts;
|
|
315
316
|
};
|
|
316
317
|
export declare function createReactorKysely(opts: {
|
|
317
318
|
reactorDbUrl: string | undefined;
|
package/dist/server.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.mts","names":[],"sources":["../src/pglite-version.ts","../src/privacy.mts","../src/workflow-runtime.mts","../src/types.ts","../src/builder-defaults.mts","../src/unsupported-stored-documents.mts","../src/server.mts"],"mappings":";;;;;;;;;;;;;;;cASa;KACD,2BAA2B;;;KCe3B;;EAEV;;EAEA;;EAEA,SAAS;;;;;;
|
|
1
|
+
{"version":3,"file":"server.d.mts","names":[],"sources":["../src/pglite-version.ts","../src/privacy.mts","../src/workflow-runtime.mts","../src/types.ts","../src/builder-defaults.mts","../src/unsupported-stored-documents.mts","../src/server.mts"],"mappings":";;;;;;;;;;;;;;;cASa;KACD,2BAA2B;;;KCe3B;;EAEV;;EAEA;;EAEA,SAAS;;;;;;KCkCC;EACN;;EAEA;EACA;;;;;;;;;;YC/CM;;YAKA,wBAAwB;EAClC,eAAe;;YAGL;EACV;EACA;EACA;;YAGU;;EAEV;;;;;EAKA;;EAGA;;YAGU;;;;;;;;;;;;;;;;;;;;;EAqBV,UAAU;;;;;;;;EAQV;;;;;;;EAOA;EACA;EACA;;;;;;EAMA;EACA;EACA;EACA,QAAQ;EACR;EACA;EACA;IAEM;IACA;;;;;;;EASN,WAAW;;EAEX;;;EAGA;;;EAGA;IACE;;;EAGF,UAAU;EACV,kBAAkB;EAClB;EACA;;;;;;EAMA;EACA,SAAS;;;;;;;;;;EAUT;;;;;;;;;;EAUA;;;;;;;;;EASA;IACE;IACA;IACA;;;;;;;;;;;EAWF;IACE;IACA;;;;;;;;;EASF,6BAA6B;;YAGnB;EACV;EACA,SAAS;;EAET,mBAAmB;;EAEnB,+BAA+B;;;EAG/B,mBAAmB;;EAEnB;;EAEA,iBAAiB;;EAEjB;IAAY,SAAS;;;EAErB;;EAEA,QAAQ;;;;;EAKR;;;;;;;;;;;;;EAaA,gBAAgB;;;;KC/MN;EAIV,iBAAiB;;EAEjB,mBAAmB;;EAEnB;;;;;;;EAOA,gBAAgB;;EAEhB;;EAEA;IACE;IACA,eAAe,QAAQ;;;EAGzB,sBAAsB;EACtB,SAAS;;;;;;EAMT,SAAS;;;;;EAKT,cAAc,KAAK;;EAEnB,wBAAwB;;wBAsBV,gCACd,gBAAgB,gBAChB,eAAe,sBACf,UAAS;;;;wBC5EK,kCACd,OAAO,wCACP,KAAK,OAAO,aACX;;qBAaU,oCAAoC;WACtC;IAAqB;IAAkB;;WACvC;EAET,YAAY,OAAO;SAcZ,QAAQ,iBAAiB,SAAS;;;;;;;;;wBCkL3B,gBAAgB,eAAe;;;;;;KA6D1C;EACH,QAAQ,OAAO;EACf,qBAAqB;EACrB,cAAc;;wBAGM,oBAAoB;EACxC;EACA;EACA,oBAAoB;EACpB;;;;;;;EAOA;EACA,QAAQ;IACN,QAAQ;;wBA4DI,8BACd,SAAS,KAAK,uDACd,oBACA,QAAQ;EACL;EAAmB,YAAY;;;;;;;;;;;;;;;;;;;qBAk/BvB,mBAAgB,UAClB,uBACR,QAAQ"}
|
package/dist/server.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as StoredDocumentsRefusedError, i as startSwitchboard, n as deriveAttachmentServiceConfig, o as resolveUnsupportedStoredDocuments, r as isPortAvailable, s as applySwitchboardReactorDefaults, t as createReactorKysely } from "./server-
|
|
2
|
+
import { a as StoredDocumentsRefusedError, i as startSwitchboard, n as deriveAttachmentServiceConfig, o as resolveUnsupportedStoredDocuments, r as isPortAvailable, s as applySwitchboardReactorDefaults, t as createReactorKysely } from "./server-J9fpd42v.mjs";
|
|
3
3
|
export { StoredDocumentsRefusedError, applySwitchboardReactorDefaults, createReactorKysely, deriveAttachmentServiceConfig, isPortAvailable, resolveUnsupportedStoredDocuments, startSwitchboard };
|
|
4
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
5
|
-
//# debugId=
|
|
4
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="b1cfc1e4-6226-5b66-81fa-c82cbc28b4ee")}catch(e){}}();
|
|
5
|
+
//# debugId=b1cfc1e4-6226-5b66-81fa-c82cbc28b4ee
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@powerhousedao/switchboard",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "6.2.3-dev.
|
|
4
|
+
"version": "6.2.3-dev.52",
|
|
5
5
|
"main": "dist/index.mjs",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"@openfeature/core": "1.9.1",
|
|
42
42
|
"@openfeature/env-var-provider": "0.3.1",
|
|
43
43
|
"@openfeature/server-sdk": "1.19.0",
|
|
44
|
-
"@powerhousedao/config": "6.2.3-dev.
|
|
44
|
+
"@powerhousedao/config": "6.2.3-dev.52",
|
|
45
45
|
"@opentelemetry/api": "^1.9.0",
|
|
46
46
|
"@opentelemetry/exporter-metrics-otlp-http": "^0.218.0",
|
|
47
47
|
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
|
|
@@ -55,22 +55,22 @@
|
|
|
55
55
|
"@opentelemetry/sdk-node": "^0.218.0",
|
|
56
56
|
"@opentelemetry/sdk-trace-base": "^2.7.1",
|
|
57
57
|
"@opentelemetry/semantic-conventions": "^1.41.1",
|
|
58
|
-
"@powerhousedao/opentelemetry-instrumentation-reactor": "6.2.3-dev.
|
|
59
|
-
"@powerhousedao/reactor": "6.2.3-dev.
|
|
60
|
-
"@powerhousedao/shared": "6.2.3-dev.
|
|
61
|
-
"@powerhousedao/vetra": "6.2.3-dev.
|
|
62
|
-
"@powerhousedao/reactor-api": "6.2.3-dev.
|
|
63
|
-
"@powerhousedao/reactor-attachments": "6.2.3-dev.
|
|
64
|
-
"@powerhousedao/reactor-drive": "6.2.3-dev.
|
|
65
|
-
"@powerhousedao/reactor-group": "6.2.3-dev.
|
|
66
|
-
"@powerhousedao/reactor-privacy": "6.2.3-dev.
|
|
67
|
-
"@powerhousedao/reactor-workflow": "6.2.3-dev.
|
|
68
|
-
"@powerhousedao/workflow": "6.2.3-dev.
|
|
58
|
+
"@powerhousedao/opentelemetry-instrumentation-reactor": "6.2.3-dev.52",
|
|
59
|
+
"@powerhousedao/reactor": "6.2.3-dev.52",
|
|
60
|
+
"@powerhousedao/shared": "6.2.3-dev.52",
|
|
61
|
+
"@powerhousedao/vetra": "6.2.3-dev.52",
|
|
62
|
+
"@powerhousedao/reactor-api": "6.2.3-dev.52",
|
|
63
|
+
"@powerhousedao/reactor-attachments": "6.2.3-dev.52",
|
|
64
|
+
"@powerhousedao/reactor-drive": "6.2.3-dev.52",
|
|
65
|
+
"@powerhousedao/reactor-group": "6.2.3-dev.52",
|
|
66
|
+
"@powerhousedao/reactor-privacy": "6.2.3-dev.52",
|
|
67
|
+
"@powerhousedao/reactor-workflow": "6.2.3-dev.52",
|
|
68
|
+
"@powerhousedao/workflow": "6.2.3-dev.52",
|
|
69
69
|
"@pyroscope/nodejs": "^0.4.5",
|
|
70
|
-
"@renown/sdk": "6.2.3-dev.
|
|
70
|
+
"@renown/sdk": "6.2.3-dev.52",
|
|
71
71
|
"@sentry/node": "^10.53.1",
|
|
72
72
|
"@sentry/opentelemetry": "^10.53.1",
|
|
73
|
-
"document-model": "6.2.3-dev.
|
|
73
|
+
"document-model": "6.2.3-dev.52",
|
|
74
74
|
"dotenv": "^16.4.7",
|
|
75
75
|
"express": "^4.22.1",
|
|
76
76
|
"graphql": "^16",
|
|
File without changes
|
|
@@ -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
|
+
);
|
|
@@ -2,6 +2,14 @@ import { PGlite } from "@electric-sql/pglite";
|
|
|
2
2
|
import { Kysely, sql } from "kysely";
|
|
3
3
|
import type { ILogger } from "document-model";
|
|
4
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";
|
|
5
13
|
import { createReactorKysely } from "../src/server.mjs";
|
|
6
14
|
import { EventEmitter } from "node:events";
|
|
7
15
|
import { installFatalErrorShutdown } from "../src/fatal-shutdown.mjs";
|
|
@@ -152,6 +160,56 @@ describe("switchboard's reactor storage factory", () => {
|
|
|
152
160
|
|
|
153
161
|
await storage.kysely.destroy();
|
|
154
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
|
+
});
|
|
155
213
|
});
|
|
156
214
|
|
|
157
215
|
describe("a poisoned reactor PGlite session", () => {
|
|
@@ -48,6 +48,9 @@ function fakeRuntime() {
|
|
|
48
48
|
testStep: vi.fn(() => Promise.resolve({})),
|
|
49
49
|
stepOutputTree: vi.fn(() => Promise.resolve({})),
|
|
50
50
|
blockResolutions: vi.fn(() => Promise.resolve([])),
|
|
51
|
+
searchPieces: vi.fn(() =>
|
|
52
|
+
Promise.resolve({ status: "ready", pieces: [], indexedPieces: 0 }),
|
|
53
|
+
),
|
|
51
54
|
reactorAccessDenial: vi.fn((): string | undefined => undefined),
|
|
52
55
|
testTrigger: vi.fn(() => Promise.resolve(null)),
|
|
53
56
|
cancelTriggerTestFor: vi.fn(() => Promise.resolve(true)),
|
|
@@ -320,3 +323,35 @@ describe("the runs page", () => {
|
|
|
320
323
|
expect(error.extensions?.code).toBe("BAD_USER_INPUT");
|
|
321
324
|
});
|
|
322
325
|
});
|
|
326
|
+
|
|
327
|
+
describe("piece search", () => {
|
|
328
|
+
it("passes a valid filter through", async () => {
|
|
329
|
+
const { runtime, queries } = build(false);
|
|
330
|
+
await queries.searchPieces(
|
|
331
|
+
{},
|
|
332
|
+
{ query: "send", kind: "trigger", sources: ["registry", "local"] },
|
|
333
|
+
CTX,
|
|
334
|
+
);
|
|
335
|
+
expect(runtime.searchPieces).toHaveBeenCalledWith("send", {
|
|
336
|
+
kind: "trigger",
|
|
337
|
+
sources: ["registry", "local"],
|
|
338
|
+
categories: undefined,
|
|
339
|
+
limit: undefined,
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("refuses a kind or source it does not know, rather than widen", () => {
|
|
344
|
+
const { runtime, queries } = build(false);
|
|
345
|
+
expect(() =>
|
|
346
|
+
queries.searchPieces({}, { query: "send", kind: "triggers" }, CTX),
|
|
347
|
+
).toThrow(/kind is "action" or "trigger"/);
|
|
348
|
+
expect(() =>
|
|
349
|
+
queries.searchPieces(
|
|
350
|
+
{},
|
|
351
|
+
{ query: "send", kind: "action", sources: ["Registry"] },
|
|
352
|
+
CTX,
|
|
353
|
+
),
|
|
354
|
+
).toThrow(/got Registry/);
|
|
355
|
+
expect(runtime.searchPieces).not.toHaveBeenCalled();
|
|
356
|
+
});
|
|
357
|
+
});
|
|
@@ -319,6 +319,9 @@ async function harness(authEnforcement: boolean): Promise<Harness> {
|
|
|
319
319
|
authorizationService: OPEN,
|
|
320
320
|
pieces: { getPieces: () => pieces, onPiecesChange: () => undefined },
|
|
321
321
|
logger,
|
|
322
|
+
// Two harnesses share one database on purpose; placement has its own
|
|
323
|
+
// suite.
|
|
324
|
+
singletonLease: false,
|
|
322
325
|
});
|
|
323
326
|
const subgraph = new runtime.subgraph({
|
|
324
327
|
reactorClient: built.client,
|
|
@@ -158,6 +158,9 @@ describe("the workflow runtime's access checks", () => {
|
|
|
158
158
|
attachments: {} as never,
|
|
159
159
|
authorizationService: authorizationService as never,
|
|
160
160
|
logger,
|
|
161
|
+
// This suite is about the read gate, not placement; the lease has its
|
|
162
|
+
// own suite in reactor-workflow.
|
|
163
|
+
singletonLease: false,
|
|
161
164
|
load: () =>
|
|
162
165
|
Promise.resolve({
|
|
163
166
|
...engine,
|