@powerhousedao/switchboard 6.2.3-dev.45 → 6.2.3-dev.47

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.
@@ -4,11 +4,10 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import {
6
6
  ReactorBuilder,
7
+ ReactorClientBuilder,
7
8
  type Database,
8
- type ILiveReadModelCoordinator,
9
9
  type InProcessReactorClientModule,
10
10
  type IReadModelCoordinator,
11
- type ReadModelRegistrationStage,
12
11
  } from "@powerhousedao/reactor";
13
12
  import {
14
13
  BaseSubgraph,
@@ -16,7 +15,12 @@ import {
16
15
  type PackagePieceEntry,
17
16
  } from "@powerhousedao/reactor-api";
18
17
  import { PieceRegistry } from "@powerhousedao/reactor-workflow";
19
- import type { OperationWithContext } from "@powerhousedao/shared/document-model";
18
+ import {
19
+ createRelationalDb,
20
+ type IRelationalDb,
21
+ type IWebhookScope,
22
+ type WebhookSpec,
23
+ } from "@powerhousedao/shared/processors";
20
24
  import type { ILogger } from "document-model";
21
25
  import { Kysely } from "kysely";
22
26
  import { PGliteDialect } from "kysely-pglite-dialect";
@@ -33,6 +37,7 @@ import {
33
37
  reactorAccessOf,
34
38
  resolveWorkflowsEnabled,
35
39
  type BooleanFlagSource,
40
+ type ComposedWorkflowRuntime,
36
41
  } from "../src/workflow-runtime.mjs";
37
42
 
38
43
  function stubLogger(): ILogger & {
@@ -54,80 +59,6 @@ function stubLogger(): ILogger & {
54
59
  };
55
60
  }
56
61
 
57
- // The engine, faked at the seam the host loads it through: everything below
58
- // belongs to @powerhousedao/reactor-workflow's own tests.
59
- function fakeEngine() {
60
- const runtime = {
61
- registerWebhookEndpoint: vi.fn(() => Promise.resolve()),
62
- startTriggerSupervisor: vi.fn(),
63
- shutdown: vi.fn(),
64
- onOperations: vi.fn((_operations: OperationWithContext[]) =>
65
- Promise.resolve(),
66
- ),
67
- };
68
- const constructed: {
69
- db: unknown;
70
- runtime: unknown;
71
- init: number;
72
- }[] = [];
73
-
74
- class FakeWorkflowTriggersReadModel {
75
- readonly name = "workflow-triggers";
76
- readonly #record: (typeof constructed)[number];
77
-
78
- constructor(
79
- db: unknown,
80
- _operationIndex: unknown,
81
- _writeCache: unknown,
82
- _consistencyTracker: unknown,
83
- boundRuntime: unknown,
84
- ) {
85
- this.#record = { db, runtime: boundRuntime, init: 0 };
86
- constructed.push(this.#record);
87
- }
88
-
89
- init(): Promise<void> {
90
- this.#record.init += 1;
91
- return Promise.resolve();
92
- }
93
-
94
- indexOperations(operations: OperationWithContext[]): Promise<void> {
95
- return (this.#record.runtime as typeof runtime).onOperations(operations);
96
- }
97
- }
98
-
99
- return {
100
- runtime,
101
- constructed,
102
- module: {
103
- WORKFLOW_PACKAGE_NAME: "@powerhousedao/workflow",
104
- WORKFLOW_TRIGGERS_READ_MODEL: "workflow-triggers",
105
- WORKFLOW_TRIGGERS_READ_MODEL_STAGE:
106
- "post_ready" as ReadModelRegistrationStage,
107
- WorkflowTriggersReadModel: FakeWorkflowTriggersReadModel,
108
- setPieceRegistryUrl: vi.fn((_url: string | undefined) => undefined),
109
- createWorkflowRuntime: vi.fn((_deps: Record<string, unknown>) => runtime),
110
- },
111
- };
112
- }
113
-
114
- function compose(
115
- engine: ReturnType<typeof fakeEngine>,
116
- logger: ILogger,
117
- overrides: Record<string, unknown> = {},
118
- ) {
119
- return composeWorkflowRuntime({
120
- reactorClient: {} as never,
121
- relationalDb: { id: "relational-db" } as never,
122
- attachments: { id: "attachments" } as never,
123
- webhooks: { id: "webhooks" } as never,
124
- authorizationService: {} as never,
125
- logger,
126
- load: () => Promise.resolve(engine.module as never),
127
- ...overrides,
128
- });
129
- }
130
-
131
62
  describe("resolveWorkflowsEnabled", () => {
132
63
  let featureFlags: BooleanFlagSource;
133
64
 
@@ -181,19 +112,6 @@ describe("resolveWorkflowsEnabled", () => {
181
112
  resolveWorkflowsEnabled({ featureFlags, configEnabled: false }),
182
113
  ).resolves.toBe(false);
183
114
  });
184
-
185
- // The engine must not be imported at all when workflows are off, so the
186
- // decision is the flag's alone and is taken before compose is ever called.
187
- it("is what gates composition", async () => {
188
- const composeIfEnabled = async () =>
189
- (await resolveWorkflowsEnabled({ featureFlags }))
190
- ? "composed"
191
- : "skipped";
192
-
193
- expect(await composeIfEnabled()).toBe("skipped");
194
- process.env[PH_WORKFLOWS_ENABLED] = "true";
195
- expect(await composeIfEnabled()).toBe("composed");
196
- });
197
115
  });
198
116
 
199
117
  describe("assertWorkflowPackageLoadable", () => {
@@ -205,51 +123,85 @@ describe("assertWorkflowPackageLoadable", () => {
205
123
 
206
124
  it("names the package a host would have to install when the load fails", async () => {
207
125
  const cause = new Error("Cannot find module");
208
- await expect(
209
- assertWorkflowPackageLoadable(() => Promise.reject(cause)),
210
- ).rejects.toMatchObject({ cause });
126
+ const failing = assertWorkflowPackageLoadable(() => Promise.reject(cause));
127
+ await expect(failing).rejects.toThrow("@powerhousedao/workflow");
128
+ await expect(failing).rejects.toMatchObject({ cause });
211
129
  });
212
130
  });
213
131
 
132
+ // The endpoint family as the host's webhook service holds it, in memory.
133
+ function memoryWebhooks() {
134
+ const families: WebhookSpec[] = [];
135
+ const scope: IWebhookScope = {
136
+ hasPublicOrigin: true,
137
+ register: (spec) => {
138
+ families.push(spec);
139
+ return Promise.resolve({
140
+ endpointFor: (key) =>
141
+ Promise.resolve({
142
+ token: key,
143
+ url: `https://hooks.test/${key}`,
144
+ createdAt: new Date().toISOString(),
145
+ }),
146
+ revoke: () => Promise.resolve(),
147
+ list: () => Promise.resolve([]),
148
+ });
149
+ },
150
+ };
151
+ return { families, scope };
152
+ }
153
+
214
154
  describe("composeWorkflowRuntime", () => {
215
- let database: Kysely<unknown> | undefined;
216
- let reactor: Awaited<ReturnType<ReactorBuilder["buildModule"]>> | undefined;
155
+ let databases: Kysely<unknown>[] = [];
156
+ let module: InProcessReactorClientModule | undefined;
157
+ let composed: ComposedWorkflowRuntime | undefined;
217
158
 
218
159
  afterEach(async () => {
219
- const shutdown = reactor?.reactor.kill();
220
- await shutdown?.completed;
221
- await database?.destroy();
222
- reactor = undefined;
223
- database = undefined;
160
+ await composed?.stop();
161
+ await module?.reactor.kill().completed;
162
+ for (const database of databases) await database.destroy();
163
+ composed = undefined;
164
+ module = undefined;
165
+ databases = [];
224
166
  });
225
167
 
168
+ function pglite(): Kysely<unknown> {
169
+ const database = new Kysely<unknown>({
170
+ dialect: new PGliteDialect(new PGlite()),
171
+ });
172
+ databases.push(database);
173
+ return database;
174
+ }
175
+
226
176
  async function buildReactorModule(
227
177
  coordinator?: IReadModelCoordinator,
228
178
  ): Promise<InProcessReactorClientModule> {
229
- database = new Kysely<unknown>({
230
- dialect: new PGliteDialect(new PGlite()),
231
- });
232
179
  const builder = new ReactorBuilder().withKysely(
233
- database as unknown as Kysely<Database>,
180
+ pglite() as unknown as Kysely<Database>,
234
181
  );
235
182
  if (coordinator) builder.withReadModelCoordinator(coordinator);
236
- reactor = await builder.buildModule();
237
- return { reactorModule: reactor } as InProcessReactorClientModule;
183
+ module = await new ReactorClientBuilder()
184
+ .withReactorBuilder(builder)
185
+ .buildModule();
186
+ return module;
238
187
  }
239
188
 
240
- it("hands the engine the host surfaces it declares", async () => {
241
- const engine = fakeEngine();
242
- await compose(engine, stubLogger());
243
-
244
- expect(engine.module.createWorkflowRuntime).toHaveBeenCalledTimes(1);
245
- const [deps] = engine.module.createWorkflowRuntime.mock.calls[0]!;
246
- expect(deps.relationalDb).toEqual({ id: "relational-db" });
247
- expect(deps.attachments).toEqual({ id: "attachments" });
248
- expect(deps.webhooks).toEqual({ id: "webhooks" });
249
- expect(typeof deps.assertCanRead).toBe("function");
250
- expect(typeof deps.assertCanWrite).toBe("function");
251
- expect(deps.canReadAttachmentRef).toBe(canReadAttachmentRef);
252
- });
189
+ // The real engine, loaded as production loads it.
190
+ async function compose(
191
+ clientModule: InProcessReactorClientModule,
192
+ overrides: Partial<Parameters<typeof composeWorkflowRuntime>[0]> = {},
193
+ ): Promise<ComposedWorkflowRuntime> {
194
+ composed = await composeWorkflowRuntime({
195
+ reactorClient: clientModule.client,
196
+ clientModule,
197
+ relationalDb: createRelationalDb(pglite()) as IRelationalDb,
198
+ attachments: {} as never,
199
+ authorizationService: {} as never,
200
+ logger: stubLogger(),
201
+ ...overrides,
202
+ });
203
+ return composed;
204
+ }
253
205
 
254
206
  it("lets a step read any well-formed attachment ref", async () => {
255
207
  const hash = "a".repeat(64);
@@ -264,42 +216,25 @@ describe("composeWorkflowRuntime", () => {
264
216
  });
265
217
 
266
218
  it("serves a subgraph the GraphQL manager can construct", async () => {
267
- const engine = fakeEngine();
268
- const { subgraph } = await compose(engine, stubLogger());
219
+ const { subgraph } = await compose(await buildReactorModule());
269
220
 
270
221
  expect(subgraph.prototype).toBeInstanceOf(BaseSubgraph);
271
222
  });
272
223
 
273
- it("registers the trigger read model post_ready and feeds the runtime", async () => {
274
- const engine = fakeEngine();
224
+ it("registers the trigger read model on the reactor's coordinator", async () => {
275
225
  const clientModule = await buildReactorModule();
276
- const coordinator = clientModule.reactorModule!
277
- .readModelCoordinator as ILiveReadModelCoordinator;
278
- const addReadModel = vi.spyOn(coordinator, "addReadModel");
279
- const logger = stubLogger();
280
226
 
281
- const workflows = await compose(engine, logger, { clientModule });
227
+ const workflows = await compose(clientModule);
282
228
 
283
229
  expect(workflows.triggers).toEqual({ status: "available" });
284
- expect(addReadModel).toHaveBeenCalledTimes(1);
285
- expect(addReadModel.mock.calls[0]![1]).toBe("post_ready");
286
- // Constructed once, initialised before it was registered, bound to the
287
- // runtime this composition built.
288
- expect(engine.constructed).toHaveLength(1);
289
- expect(engine.constructed[0]!.init).toBe(1);
290
- expect(engine.constructed[0]!.runtime).toBe(engine.runtime);
291
-
292
- const registered = addReadModel.mock.calls[0]![0];
293
- const operations = [] as OperationWithContext[];
294
- await registered.indexOperations(operations);
295
- expect(engine.runtime.onOperations).toHaveBeenCalledWith(operations);
296
230
  expect(
297
- coordinator.readModels.filter(({ name }) => name === "workflow-triggers"),
231
+ clientModule.reactorModule!.readModelCoordinator.readModels.filter(
232
+ ({ name }) => name === "workflow-triggers",
233
+ ),
298
234
  ).toHaveLength(1);
299
235
  });
300
236
 
301
- it("reports the intake unavailable and says so loudly", async () => {
302
- const engine = fakeEngine();
237
+ it("reports the intake unavailable on a coordinator without live registration", async () => {
303
238
  const customCoordinator: IReadModelCoordinator = {
304
239
  readModels: [],
305
240
  start: vi.fn(),
@@ -307,26 +242,22 @@ describe("composeWorkflowRuntime", () => {
307
242
  drain: vi.fn().mockResolvedValue(undefined),
308
243
  getChainDepth: vi.fn().mockReturnValue(0),
309
244
  };
310
- const clientModule = await buildReactorModule(customCoordinator);
311
- const logger = stubLogger();
312
245
 
313
- const workflows = await compose(engine, logger, { clientModule });
246
+ const workflows = await compose(
247
+ await buildReactorModule(customCoordinator),
248
+ );
314
249
 
315
250
  expect(workflows.triggers).toEqual({
316
251
  status: "unavailable",
317
252
  reason: "live-read-model-registration-unsupported",
318
253
  });
319
- expect(engine.constructed).toHaveLength(0);
320
254
  expect(customCoordinator.readModels).toEqual([]);
321
- expect(logger.error).toHaveBeenCalledTimes(1);
322
- expect(logger.error.mock.calls[0]![0]).toContain("NOT armed");
323
255
  });
324
256
 
325
257
  it("reports the intake unavailable without an in-process reactor", async () => {
326
- const engine = fakeEngine();
327
- const logger = stubLogger();
258
+ const clientModule = await buildReactorModule();
328
259
 
329
- const workflows = await compose(engine, logger, {
260
+ const workflows = await compose(clientModule, {
330
261
  clientModule: {} as InProcessReactorClientModule,
331
262
  });
332
263
 
@@ -334,33 +265,38 @@ describe("composeWorkflowRuntime", () => {
334
265
  status: "unavailable",
335
266
  reason: "in-process-reactor-module-unavailable",
336
267
  });
337
- expect(logger.error).toHaveBeenCalledTimes(1);
338
268
  });
339
269
 
340
- it("arms the webhooks and the supervisor on start", async () => {
341
- const engine = fakeEngine();
342
- const workflows = await compose(engine, stubLogger());
343
-
344
- await workflows.start();
270
+ it("arms the webhooks and the supervisor on start, and stops them once", async () => {
271
+ const webhooks = memoryWebhooks();
272
+ const workflows = await compose(await buildReactorModule(), {
273
+ webhooks: webhooks.scope,
274
+ });
345
275
 
346
- expect(engine.runtime.registerWebhookEndpoint).toHaveBeenCalledTimes(1);
347
- expect(engine.runtime.startTriggerSupervisor).toHaveBeenCalledTimes(1);
348
- });
276
+ // Intervals only, from here on: the supervisor's tick is the runtime's one.
277
+ vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
278
+ try {
279
+ await workflows.start();
349
280
 
350
- it("shuts the runtime down once on stop", async () => {
351
- const engine = fakeEngine();
352
- const workflows = await compose(engine, stubLogger());
353
- await workflows.start();
281
+ expect(webhooks.families.map(({ name }) => name)).toEqual(["trigger"]);
282
+ // The real runtime answers for it: an unknown workflow is not armed.
283
+ await expect(
284
+ webhooks.families[0]!.policyFor?.("wf-unknown"),
285
+ ).resolves.toBeUndefined();
286
+ expect(vi.getTimerCount()).toBe(1);
354
287
 
355
- await workflows.stop();
356
- await workflows.stop();
288
+ await workflows.stop();
289
+ await workflows.stop();
357
290
 
358
- expect(engine.runtime.shutdown).toHaveBeenCalledTimes(1);
291
+ expect(vi.getTimerCount()).toBe(0);
292
+ } finally {
293
+ vi.useRealTimers();
294
+ }
359
295
  });
360
296
 
361
297
  it("names the package a host would have to install when the load fails", async () => {
362
298
  const cause = new Error("Cannot find module");
363
- const failing = compose(fakeEngine(), stubLogger(), {
299
+ const failing = compose(await buildReactorModule(), {
364
300
  load: () => Promise.reject(cause),
365
301
  });
366
302