@powerhousedao/switchboard 6.2.3-dev.11 → 6.2.3-dev.12

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.
@@ -0,0 +1,412 @@
1
+ import { PGlite } from "@electric-sql/pglite";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ ReactorBuilder,
7
+ type Database,
8
+ type ILiveReadModelCoordinator,
9
+ type InProcessReactorClientModule,
10
+ type IReadModelCoordinator,
11
+ type ReadModelRegistrationStage,
12
+ } from "@powerhousedao/reactor";
13
+ import { BaseSubgraph, type GraphQLManager } from "@powerhousedao/reactor-api";
14
+ import type { OperationWithContext } from "@powerhousedao/shared/document-model";
15
+ import type { ILogger } from "document-model";
16
+ import { Kysely } from "kysely";
17
+ import { PGliteDialect } from "kysely-pglite-dialect";
18
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
19
+ import { initFeatureFlags } from "../src/feature-flags.js";
20
+ import { startSwitchboard } from "../src/server.mjs";
21
+ import {
22
+ PH_WORKFLOWS_ENABLED,
23
+ composeWorkflowRuntime,
24
+ loadWorkflowDocumentModels,
25
+ resolveWorkflowsEnabled,
26
+ type BooleanFlagSource,
27
+ } from "../src/workflow-runtime.mjs";
28
+
29
+ function stubLogger(): ILogger & {
30
+ info: ReturnType<typeof vi.fn>;
31
+ error: ReturnType<typeof vi.fn>;
32
+ } {
33
+ const logger = {
34
+ verbose: vi.fn(),
35
+ debug: vi.fn(),
36
+ info: vi.fn(),
37
+ warn: vi.fn(),
38
+ error: vi.fn(),
39
+ child: vi.fn(),
40
+ };
41
+ logger.child.mockReturnValue(logger);
42
+ return logger as unknown as ILogger & {
43
+ info: ReturnType<typeof vi.fn>;
44
+ error: ReturnType<typeof vi.fn>;
45
+ };
46
+ }
47
+
48
+ // The engine, faked at the seam the host loads it through: everything below
49
+ // belongs to @powerhousedao/reactor-workflow's own tests.
50
+ function fakeEngine() {
51
+ const runtime = {
52
+ registerWebhookEndpoint: vi.fn(() => Promise.resolve()),
53
+ startTriggerSupervisor: vi.fn(),
54
+ shutdown: vi.fn(),
55
+ onOperations: vi.fn((_operations: OperationWithContext[]) =>
56
+ Promise.resolve(),
57
+ ),
58
+ };
59
+ const constructed: {
60
+ db: unknown;
61
+ runtime: unknown;
62
+ init: number;
63
+ }[] = [];
64
+
65
+ class FakeWorkflowTriggersReadModel {
66
+ readonly name = "workflow-triggers";
67
+ readonly #record: (typeof constructed)[number];
68
+
69
+ constructor(
70
+ db: unknown,
71
+ _operationIndex: unknown,
72
+ _writeCache: unknown,
73
+ _consistencyTracker: unknown,
74
+ boundRuntime: unknown,
75
+ ) {
76
+ this.#record = { db, runtime: boundRuntime, init: 0 };
77
+ constructed.push(this.#record);
78
+ }
79
+
80
+ init(): Promise<void> {
81
+ this.#record.init += 1;
82
+ return Promise.resolve();
83
+ }
84
+
85
+ indexOperations(operations: OperationWithContext[]): Promise<void> {
86
+ return (this.#record.runtime as typeof runtime).onOperations(operations);
87
+ }
88
+ }
89
+
90
+ return {
91
+ runtime,
92
+ constructed,
93
+ module: {
94
+ WORKFLOW_PACKAGE_NAME: "@powerhousedao/workflow",
95
+ WORKFLOW_TRIGGERS_READ_MODEL: "workflow-triggers",
96
+ WORKFLOW_TRIGGERS_READ_MODEL_STAGE:
97
+ "post_ready" as ReadModelRegistrationStage,
98
+ WorkflowTriggersReadModel: FakeWorkflowTriggersReadModel,
99
+ createWorkflowRuntime: vi.fn((_deps: Record<string, unknown>) => runtime),
100
+ },
101
+ };
102
+ }
103
+
104
+ function compose(
105
+ engine: ReturnType<typeof fakeEngine>,
106
+ logger: ILogger,
107
+ overrides: Record<string, unknown> = {},
108
+ ) {
109
+ return composeWorkflowRuntime({
110
+ reactorClient: {} as never,
111
+ relationalDb: { id: "relational-db" } as never,
112
+ attachments: { id: "attachments" } as never,
113
+ webhooks: { id: "webhooks" } as never,
114
+ authorizationService: {} as never,
115
+ logger,
116
+ load: () => Promise.resolve(engine.module as never),
117
+ ...overrides,
118
+ });
119
+ }
120
+
121
+ describe("resolveWorkflowsEnabled", () => {
122
+ let featureFlags: BooleanFlagSource;
123
+
124
+ beforeAll(async () => {
125
+ featureFlags = await initFeatureFlags();
126
+ });
127
+
128
+ afterEach(() => {
129
+ delete process.env[PH_WORKFLOWS_ENABLED];
130
+ });
131
+
132
+ it("defaults to off", async () => {
133
+ await expect(resolveWorkflowsEnabled({ featureFlags })).resolves.toBe(
134
+ false,
135
+ );
136
+ });
137
+
138
+ it("lets the host's option win over the env var", async () => {
139
+ process.env[PH_WORKFLOWS_ENABLED] = "false";
140
+
141
+ await expect(
142
+ resolveWorkflowsEnabled({ featureFlags, override: true }),
143
+ ).resolves.toBe(true);
144
+
145
+ process.env[PH_WORKFLOWS_ENABLED] = "true";
146
+
147
+ await expect(
148
+ resolveWorkflowsEnabled({ featureFlags, override: false }),
149
+ ).resolves.toBe(false);
150
+ });
151
+
152
+ it("reads the env var in every form reactor-api accepted", async () => {
153
+ for (const [raw, expected] of [
154
+ ["true", true],
155
+ ["1", true],
156
+ ["false", false],
157
+ ["0", false],
158
+ ] as const) {
159
+ process.env[PH_WORKFLOWS_ENABLED] = raw;
160
+ await expect(
161
+ resolveWorkflowsEnabled({ featureFlags, configEnabled: !expected }),
162
+ ).resolves.toBe(expected);
163
+ }
164
+ });
165
+
166
+ it("falls back to the config file, then off", async () => {
167
+ await expect(
168
+ resolveWorkflowsEnabled({ featureFlags, configEnabled: true }),
169
+ ).resolves.toBe(true);
170
+ await expect(
171
+ resolveWorkflowsEnabled({ featureFlags, configEnabled: false }),
172
+ ).resolves.toBe(false);
173
+ });
174
+
175
+ // The engine must not be imported at all when workflows are off, so the
176
+ // decision is the flag's alone and is taken before compose is ever called.
177
+ it("is what gates composition", async () => {
178
+ const composeIfEnabled = async () =>
179
+ (await resolveWorkflowsEnabled({ featureFlags }))
180
+ ? "composed"
181
+ : "skipped";
182
+
183
+ expect(await composeIfEnabled()).toBe("skipped");
184
+ process.env[PH_WORKFLOWS_ENABLED] = "true";
185
+ expect(await composeIfEnabled()).toBe("composed");
186
+ });
187
+ });
188
+
189
+ describe("loadWorkflowDocumentModels", () => {
190
+ it("keeps only the document model modules the export names", async () => {
191
+ const module = { documentModel: {}, reducer: () => undefined };
192
+ const models = await loadWorkflowDocumentModels(() =>
193
+ Promise.resolve({ module, notAModel: { documentModel: {} }, nope: 3 }),
194
+ );
195
+
196
+ expect(models).toEqual([module]);
197
+ });
198
+
199
+ it("names the package a host would have to install when the load fails", async () => {
200
+ const cause = new Error("Cannot find module");
201
+ await expect(
202
+ loadWorkflowDocumentModels(() => Promise.reject(cause)),
203
+ ).rejects.toMatchObject({ cause });
204
+ });
205
+ });
206
+
207
+ describe("composeWorkflowRuntime", () => {
208
+ let database: Kysely<unknown> | undefined;
209
+ let reactor: Awaited<ReturnType<ReactorBuilder["buildModule"]>> | undefined;
210
+
211
+ afterEach(async () => {
212
+ const shutdown = reactor?.reactor.kill();
213
+ await shutdown?.completed;
214
+ await database?.destroy();
215
+ reactor = undefined;
216
+ database = undefined;
217
+ });
218
+
219
+ async function buildReactorModule(
220
+ coordinator?: IReadModelCoordinator,
221
+ ): Promise<InProcessReactorClientModule> {
222
+ database = new Kysely<unknown>({
223
+ dialect: new PGliteDialect(new PGlite()),
224
+ });
225
+ const builder = new ReactorBuilder().withKysely(
226
+ database as unknown as Kysely<Database>,
227
+ );
228
+ if (coordinator) builder.withReadModelCoordinator(coordinator);
229
+ reactor = await builder.buildModule();
230
+ return { reactorModule: reactor } as InProcessReactorClientModule;
231
+ }
232
+
233
+ it("hands the engine the host surfaces it declares", async () => {
234
+ const engine = fakeEngine();
235
+ await compose(engine, stubLogger());
236
+
237
+ expect(engine.module.createWorkflowRuntime).toHaveBeenCalledTimes(1);
238
+ const [deps] = engine.module.createWorkflowRuntime.mock.calls[0]!;
239
+ expect(deps.relationalDb).toEqual({ id: "relational-db" });
240
+ expect(deps.attachments).toEqual({ id: "attachments" });
241
+ expect(deps.webhooks).toEqual({ id: "webhooks" });
242
+ expect(typeof deps.assertCanRead).toBe("function");
243
+ expect(typeof deps.assertCanWrite).toBe("function");
244
+ expect(typeof deps.canReadAttachmentRef).toBe("function");
245
+ });
246
+
247
+ it("serves a subgraph the GraphQL manager can construct", async () => {
248
+ const engine = fakeEngine();
249
+ const { subgraph } = await compose(engine, stubLogger());
250
+
251
+ expect(subgraph.prototype).toBeInstanceOf(BaseSubgraph);
252
+ });
253
+
254
+ it("registers the trigger read model post_ready and feeds the runtime", async () => {
255
+ const engine = fakeEngine();
256
+ const clientModule = await buildReactorModule();
257
+ const coordinator = clientModule.reactorModule!
258
+ .readModelCoordinator as ILiveReadModelCoordinator;
259
+ const addReadModel = vi.spyOn(coordinator, "addReadModel");
260
+ const logger = stubLogger();
261
+
262
+ const workflows = await compose(engine, logger, { clientModule });
263
+
264
+ expect(workflows.triggers).toEqual({ status: "available" });
265
+ expect(addReadModel).toHaveBeenCalledTimes(1);
266
+ expect(addReadModel.mock.calls[0]![1]).toBe("post_ready");
267
+ // Constructed once, initialised before it was registered, bound to the
268
+ // runtime this composition built.
269
+ expect(engine.constructed).toHaveLength(1);
270
+ expect(engine.constructed[0]!.init).toBe(1);
271
+ expect(engine.constructed[0]!.runtime).toBe(engine.runtime);
272
+
273
+ const registered = addReadModel.mock.calls[0]![0];
274
+ const operations = [] as OperationWithContext[];
275
+ await registered.indexOperations(operations);
276
+ expect(engine.runtime.onOperations).toHaveBeenCalledWith(operations);
277
+ expect(
278
+ coordinator.readModels.filter(({ name }) => name === "workflow-triggers"),
279
+ ).toHaveLength(1);
280
+ });
281
+
282
+ it("reports the intake unavailable and says so loudly", async () => {
283
+ const engine = fakeEngine();
284
+ const customCoordinator: IReadModelCoordinator = {
285
+ readModels: [],
286
+ start: vi.fn(),
287
+ stop: vi.fn(),
288
+ drain: vi.fn().mockResolvedValue(undefined),
289
+ getChainDepth: vi.fn().mockReturnValue(0),
290
+ };
291
+ const clientModule = await buildReactorModule(customCoordinator);
292
+ const logger = stubLogger();
293
+
294
+ const workflows = await compose(engine, logger, { clientModule });
295
+
296
+ expect(workflows.triggers).toEqual({
297
+ status: "unavailable",
298
+ reason: "live-read-model-registration-unsupported",
299
+ });
300
+ expect(engine.constructed).toHaveLength(0);
301
+ expect(customCoordinator.readModels).toEqual([]);
302
+ expect(logger.error).toHaveBeenCalledTimes(1);
303
+ expect(logger.error.mock.calls[0]![0]).toContain("NOT armed");
304
+ });
305
+
306
+ it("reports the intake unavailable without an in-process reactor", async () => {
307
+ const engine = fakeEngine();
308
+ const logger = stubLogger();
309
+
310
+ const workflows = await compose(engine, logger, {
311
+ clientModule: {} as InProcessReactorClientModule,
312
+ });
313
+
314
+ expect(workflows.triggers).toEqual({
315
+ status: "unavailable",
316
+ reason: "in-process-reactor-module-unavailable",
317
+ });
318
+ expect(logger.error).toHaveBeenCalledTimes(1);
319
+ });
320
+
321
+ it("arms the webhooks and the supervisor on start", async () => {
322
+ const engine = fakeEngine();
323
+ const workflows = await compose(engine, stubLogger());
324
+
325
+ await workflows.start();
326
+
327
+ expect(engine.runtime.registerWebhookEndpoint).toHaveBeenCalledTimes(1);
328
+ expect(engine.runtime.startTriggerSupervisor).toHaveBeenCalledTimes(1);
329
+ });
330
+
331
+ it("shuts the runtime down once on stop", async () => {
332
+ const engine = fakeEngine();
333
+ const workflows = await compose(engine, stubLogger());
334
+ await workflows.start();
335
+
336
+ await workflows.stop();
337
+ await workflows.stop();
338
+
339
+ expect(engine.runtime.shutdown).toHaveBeenCalledTimes(1);
340
+ });
341
+
342
+ it("names the package a host would have to install when the load fails", async () => {
343
+ const cause = new Error("Cannot find module");
344
+ const failing = compose(fakeEngine(), stubLogger(), {
345
+ load: () => Promise.reject(cause),
346
+ });
347
+
348
+ await expect(failing).rejects.toThrow("@powerhousedao/reactor-workflow");
349
+ await expect(failing).rejects.toMatchObject({ cause });
350
+ });
351
+ });
352
+
353
+ // The GraphQL manager rides on the boot result without being on its public
354
+ // type, and the subgraph registers late: poll rather than race it.
355
+ async function pollWorkflowSubgraph(
356
+ switchboard: Awaited<ReturnType<typeof startSwitchboard>>,
357
+ ): Promise<{ name: string } | undefined> {
358
+ const { graphqlManager } = (
359
+ switchboard as unknown as {
360
+ api: { graphqlManager: GraphQLManager };
361
+ }
362
+ ).api;
363
+ for (let attempt = 0; attempt < 100; attempt++) {
364
+ const subgraph = graphqlManager.getSubgraphByName("workflow-runtime");
365
+ if (subgraph) return subgraph;
366
+ await new Promise((resolve) => setTimeout(resolve, 50));
367
+ }
368
+ return undefined;
369
+ }
370
+
371
+ describe("booting Switchboard with workflows on", () => {
372
+ it("arms the intake and registers the workflow document models", async () => {
373
+ const tempRoot = await mkdtemp(join(tmpdir(), "switchboard-workflows-"));
374
+ const previousReactorDb = process.env.PH_REACTOR_DATABASE_URL;
375
+ process.env.PH_REACTOR_DATABASE_URL = join(tempRoot, "reactor-storage");
376
+ let switchboard: Awaited<ReturnType<typeof startSwitchboard>> | undefined;
377
+
378
+ try {
379
+ switchboard = await startSwitchboard({
380
+ workflows: { enabled: true },
381
+ dbPath: join(tempRoot, "read-model"),
382
+ port: 0,
383
+ mcp: false,
384
+ disableLocalPackages: true,
385
+ identity: { keypairPath: join(tempRoot, "identity.json") },
386
+ logger: stubLogger(),
387
+ });
388
+
389
+ expect(switchboard.workflowTriggers).toEqual({ status: "available" });
390
+ const { results } = await switchboard.reactor.getDocumentModelModules();
391
+ expect(
392
+ results.map(({ documentModel }) => documentModel.global.id),
393
+ ).toContain("powerhouse/workflow");
394
+ // The subgraph is registered late, so the schema it joins arrives after
395
+ // the boot resolves; poll rather than race it.
396
+ await expect(pollWorkflowSubgraph(switchboard)).resolves.toMatchObject({
397
+ name: "workflow-runtime",
398
+ });
399
+
400
+ await switchboard.shutdown();
401
+ switchboard = undefined;
402
+ } finally {
403
+ await switchboard?.shutdown();
404
+ if (previousReactorDb === undefined) {
405
+ delete process.env.PH_REACTOR_DATABASE_URL;
406
+ } else {
407
+ process.env.PH_REACTOR_DATABASE_URL = previousReactorDb;
408
+ }
409
+ await rm(tempRoot, { recursive: true, force: true });
410
+ }
411
+ }, 60_000);
412
+ });
package/tsconfig.json CHANGED
@@ -33,6 +33,9 @@
33
33
  {
34
34
  "path": "../../packages/reactor-group"
35
35
  },
36
+ {
37
+ "path": "../../packages/reactor-workflow"
38
+ },
36
39
  {
37
40
  "path": "../../packages/renown"
38
41
  },
@@ -41,6 +44,9 @@
41
44
  },
42
45
  {
43
46
  "path": "../../packages/vetra"
47
+ },
48
+ {
49
+ "path": "../../packages/workflow"
44
50
  }
45
51
  ]
46
52
  }