@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.
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/CHANGELOG.md +22 -0
- package/package.json +15 -15
- package/test/workflow-read-gate.test.ts +125 -60
- package/test/workflow-runtime.test.ts +105 -169
|
@@ -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
|
|
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
|
-
|
|
209
|
-
|
|
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
|
|
216
|
-
let
|
|
155
|
+
let databases: Kysely<unknown>[] = [];
|
|
156
|
+
let module: InProcessReactorClientModule | undefined;
|
|
157
|
+
let composed: ComposedWorkflowRuntime | undefined;
|
|
217
158
|
|
|
218
159
|
afterEach(async () => {
|
|
219
|
-
|
|
220
|
-
await
|
|
221
|
-
await database
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
180
|
+
pglite() as unknown as Kysely<Database>,
|
|
234
181
|
);
|
|
235
182
|
if (coordinator) builder.withReadModelCoordinator(coordinator);
|
|
236
|
-
|
|
237
|
-
|
|
183
|
+
module = await new ReactorClientBuilder()
|
|
184
|
+
.withReactorBuilder(builder)
|
|
185
|
+
.buildModule();
|
|
186
|
+
return module;
|
|
238
187
|
}
|
|
239
188
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
327
|
-
const logger = stubLogger();
|
|
258
|
+
const clientModule = await buildReactorModule();
|
|
328
259
|
|
|
329
|
-
const workflows = await compose(
|
|
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
|
|
342
|
-
const workflows = await compose(
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
347
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
356
|
-
|
|
288
|
+
await workflows.stop();
|
|
289
|
+
await workflows.stop();
|
|
357
290
|
|
|
358
|
-
|
|
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(
|
|
299
|
+
const failing = compose(await buildReactorModule(), {
|
|
364
300
|
load: () => Promise.reject(cause),
|
|
365
301
|
});
|
|
366
302
|
|