@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/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;
@@ -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;;;;;;KCiCC;EACN;;EAEA;EACA;;;;;;;;;;YC9CM;;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;;;;;;;;;wBC0K3B,gBAAgB,eAAe;;;;;;KA6D1C;EACH,QAAQ,OAAO;EACf,qBAAqB;;wBAGD,oBAAoB;EACxC;EACA;EACA,oBAAoB;EACpB;;;;;;;EAOA;EACA,QAAQ;IACN,QAAQ;;wBAwDI,8BACd,SAAS,KAAK,uDACd,oBACA,QAAQ;EACL;EAAmB,YAAY;;;;;;;;;;;;;;;;;;;qBAy7BvB,mBAAgB,UAClB,uBACR,QAAQ"}
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-Zz7SqTwO.mjs";
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]="ecf769b3-3c50-5e91-915e-6761e372127f")}catch(e){}}();
5
- //# debugId=ecf769b3-3c50-5e91-915e-6761e372127f
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.50",
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.50",
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.50",
59
- "@powerhousedao/reactor": "6.2.3-dev.50",
60
- "@powerhousedao/shared": "6.2.3-dev.50",
61
- "@powerhousedao/vetra": "6.2.3-dev.50",
62
- "@powerhousedao/reactor-api": "6.2.3-dev.50",
63
- "@powerhousedao/reactor-attachments": "6.2.3-dev.50",
64
- "@powerhousedao/reactor-drive": "6.2.3-dev.50",
65
- "@powerhousedao/reactor-group": "6.2.3-dev.50",
66
- "@powerhousedao/reactor-privacy": "6.2.3-dev.50",
67
- "@powerhousedao/reactor-workflow": "6.2.3-dev.50",
68
- "@powerhousedao/workflow": "6.2.3-dev.50",
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.50",
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.50",
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,