@powerhousedao/switchboard 6.2.3-dev.44 → 6.2.3-dev.46

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.
Files changed (33) hide show
  1. package/.tsbuild/test/durable-node-fs-pg16.test.d.ts +2 -0
  2. package/.tsbuild/test/durable-node-fs-pg16.test.d.ts.map +1 -0
  3. package/.tsbuild/test/pglite-preflight.test.d.ts +2 -0
  4. package/.tsbuild/test/pglite-preflight.test.d.ts.map +1 -0
  5. package/.tsbuild/test/snapshot-writer.d.ts +18 -0
  6. package/.tsbuild/test/snapshot-writer.d.ts.map +1 -0
  7. package/.tsbuild/test/workflow-reactor-access.test.d.ts +2 -0
  8. package/.tsbuild/test/workflow-reactor-access.test.d.ts.map +1 -0
  9. package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts +2 -0
  10. package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts.map +1 -0
  11. package/.tsbuild/tsconfig.tsbuildinfo +1 -1
  12. package/CHANGELOG.md +46 -0
  13. package/dist/index.mjs +1 -1
  14. package/dist/{server-Bk_IZzCn.mjs → server-Dvw8_NeM.mjs} +256 -77
  15. package/dist/server-Dvw8_NeM.mjs.map +1 -0
  16. package/dist/server.d.mts +3 -1
  17. package/dist/server.d.mts.map +1 -1
  18. package/dist/server.mjs +3 -3
  19. package/package.json +15 -16
  20. package/test/boot-unsupported-stored-documents.test.ts +5 -5
  21. package/test/durable-node-fs-pg16.test.ts +80 -0
  22. package/test/pglite-dialect.test.ts +28 -0
  23. package/test/pglite-preflight.test.ts +313 -0
  24. package/test/pglite-version.test.ts +9 -1
  25. package/test/snapshot-writer.ts +94 -0
  26. package/test/worker-pool.test.ts +27 -1
  27. package/test/workflow/resolvers.test.ts +26 -0
  28. package/test/workflow-reactor-access.test.ts +384 -0
  29. package/test/workflow-reactor-enforcement.test.ts +805 -0
  30. package/test/workflow-read-gate.test.ts +125 -60
  31. package/test/workflow-runtime.test.ts +158 -169
  32. package/tsconfig.json +0 -3
  33. package/dist/server-Bk_IZzCn.mjs.map +0 -1
@@ -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";
@@ -29,8 +33,11 @@ import {
29
33
  canReadAttachmentRef,
30
34
  composeWorkflowRuntime,
31
35
  assertWorkflowPackageLoadable,
36
+ hostPrincipalOf,
37
+ reactorAccessOf,
32
38
  resolveWorkflowsEnabled,
33
39
  type BooleanFlagSource,
40
+ type ComposedWorkflowRuntime,
34
41
  } from "../src/workflow-runtime.mjs";
35
42
 
36
43
  function stubLogger(): ILogger & {
@@ -52,80 +59,6 @@ function stubLogger(): ILogger & {
52
59
  };
53
60
  }
54
61
 
55
- // The engine, faked at the seam the host loads it through: everything below
56
- // belongs to @powerhousedao/reactor-workflow's own tests.
57
- function fakeEngine() {
58
- const runtime = {
59
- registerWebhookEndpoint: vi.fn(() => Promise.resolve()),
60
- startTriggerSupervisor: vi.fn(),
61
- shutdown: vi.fn(),
62
- onOperations: vi.fn((_operations: OperationWithContext[]) =>
63
- Promise.resolve(),
64
- ),
65
- };
66
- const constructed: {
67
- db: unknown;
68
- runtime: unknown;
69
- init: number;
70
- }[] = [];
71
-
72
- class FakeWorkflowTriggersReadModel {
73
- readonly name = "workflow-triggers";
74
- readonly #record: (typeof constructed)[number];
75
-
76
- constructor(
77
- db: unknown,
78
- _operationIndex: unknown,
79
- _writeCache: unknown,
80
- _consistencyTracker: unknown,
81
- boundRuntime: unknown,
82
- ) {
83
- this.#record = { db, runtime: boundRuntime, init: 0 };
84
- constructed.push(this.#record);
85
- }
86
-
87
- init(): Promise<void> {
88
- this.#record.init += 1;
89
- return Promise.resolve();
90
- }
91
-
92
- indexOperations(operations: OperationWithContext[]): Promise<void> {
93
- return (this.#record.runtime as typeof runtime).onOperations(operations);
94
- }
95
- }
96
-
97
- return {
98
- runtime,
99
- constructed,
100
- module: {
101
- WORKFLOW_PACKAGE_NAME: "@powerhousedao/workflow",
102
- WORKFLOW_TRIGGERS_READ_MODEL: "workflow-triggers",
103
- WORKFLOW_TRIGGERS_READ_MODEL_STAGE:
104
- "post_ready" as ReadModelRegistrationStage,
105
- WorkflowTriggersReadModel: FakeWorkflowTriggersReadModel,
106
- setPieceRegistryUrl: vi.fn((_url: string | undefined) => undefined),
107
- createWorkflowRuntime: vi.fn((_deps: Record<string, unknown>) => runtime),
108
- },
109
- };
110
- }
111
-
112
- function compose(
113
- engine: ReturnType<typeof fakeEngine>,
114
- logger: ILogger,
115
- overrides: Record<string, unknown> = {},
116
- ) {
117
- return composeWorkflowRuntime({
118
- reactorClient: {} as never,
119
- relationalDb: { id: "relational-db" } as never,
120
- attachments: { id: "attachments" } as never,
121
- webhooks: { id: "webhooks" } as never,
122
- authorizationService: {} as never,
123
- logger,
124
- load: () => Promise.resolve(engine.module as never),
125
- ...overrides,
126
- });
127
- }
128
-
129
62
  describe("resolveWorkflowsEnabled", () => {
130
63
  let featureFlags: BooleanFlagSource;
131
64
 
@@ -179,19 +112,6 @@ describe("resolveWorkflowsEnabled", () => {
179
112
  resolveWorkflowsEnabled({ featureFlags, configEnabled: false }),
180
113
  ).resolves.toBe(false);
181
114
  });
182
-
183
- // The engine must not be imported at all when workflows are off, so the
184
- // decision is the flag's alone and is taken before compose is ever called.
185
- it("is what gates composition", async () => {
186
- const composeIfEnabled = async () =>
187
- (await resolveWorkflowsEnabled({ featureFlags }))
188
- ? "composed"
189
- : "skipped";
190
-
191
- expect(await composeIfEnabled()).toBe("skipped");
192
- process.env[PH_WORKFLOWS_ENABLED] = "true";
193
- expect(await composeIfEnabled()).toBe("composed");
194
- });
195
115
  });
196
116
 
197
117
  describe("assertWorkflowPackageLoadable", () => {
@@ -203,51 +123,85 @@ describe("assertWorkflowPackageLoadable", () => {
203
123
 
204
124
  it("names the package a host would have to install when the load fails", async () => {
205
125
  const cause = new Error("Cannot find module");
206
- await expect(
207
- assertWorkflowPackageLoadable(() => Promise.reject(cause)),
208
- ).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 });
209
129
  });
210
130
  });
211
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
+
212
154
  describe("composeWorkflowRuntime", () => {
213
- let database: Kysely<unknown> | undefined;
214
- let reactor: Awaited<ReturnType<ReactorBuilder["buildModule"]>> | undefined;
155
+ let databases: Kysely<unknown>[] = [];
156
+ let module: InProcessReactorClientModule | undefined;
157
+ let composed: ComposedWorkflowRuntime | undefined;
215
158
 
216
159
  afterEach(async () => {
217
- const shutdown = reactor?.reactor.kill();
218
- await shutdown?.completed;
219
- await database?.destroy();
220
- reactor = undefined;
221
- 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 = [];
222
166
  });
223
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
+
224
176
  async function buildReactorModule(
225
177
  coordinator?: IReadModelCoordinator,
226
178
  ): Promise<InProcessReactorClientModule> {
227
- database = new Kysely<unknown>({
228
- dialect: new PGliteDialect(new PGlite()),
229
- });
230
179
  const builder = new ReactorBuilder().withKysely(
231
- database as unknown as Kysely<Database>,
180
+ pglite() as unknown as Kysely<Database>,
232
181
  );
233
182
  if (coordinator) builder.withReadModelCoordinator(coordinator);
234
- reactor = await builder.buildModule();
235
- return { reactorModule: reactor } as InProcessReactorClientModule;
183
+ module = await new ReactorClientBuilder()
184
+ .withReactorBuilder(builder)
185
+ .buildModule();
186
+ return module;
236
187
  }
237
188
 
238
- it("hands the engine the host surfaces it declares", async () => {
239
- const engine = fakeEngine();
240
- await compose(engine, stubLogger());
241
-
242
- expect(engine.module.createWorkflowRuntime).toHaveBeenCalledTimes(1);
243
- const [deps] = engine.module.createWorkflowRuntime.mock.calls[0]!;
244
- expect(deps.relationalDb).toEqual({ id: "relational-db" });
245
- expect(deps.attachments).toEqual({ id: "attachments" });
246
- expect(deps.webhooks).toEqual({ id: "webhooks" });
247
- expect(typeof deps.assertCanRead).toBe("function");
248
- expect(typeof deps.assertCanWrite).toBe("function");
249
- expect(deps.canReadAttachmentRef).toBe(canReadAttachmentRef);
250
- });
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
+ }
251
205
 
252
206
  it("lets a step read any well-formed attachment ref", async () => {
253
207
  const hash = "a".repeat(64);
@@ -262,42 +216,25 @@ describe("composeWorkflowRuntime", () => {
262
216
  });
263
217
 
264
218
  it("serves a subgraph the GraphQL manager can construct", async () => {
265
- const engine = fakeEngine();
266
- const { subgraph } = await compose(engine, stubLogger());
219
+ const { subgraph } = await compose(await buildReactorModule());
267
220
 
268
221
  expect(subgraph.prototype).toBeInstanceOf(BaseSubgraph);
269
222
  });
270
223
 
271
- it("registers the trigger read model post_ready and feeds the runtime", async () => {
272
- const engine = fakeEngine();
224
+ it("registers the trigger read model on the reactor's coordinator", async () => {
273
225
  const clientModule = await buildReactorModule();
274
- const coordinator = clientModule.reactorModule!
275
- .readModelCoordinator as ILiveReadModelCoordinator;
276
- const addReadModel = vi.spyOn(coordinator, "addReadModel");
277
- const logger = stubLogger();
278
226
 
279
- const workflows = await compose(engine, logger, { clientModule });
227
+ const workflows = await compose(clientModule);
280
228
 
281
229
  expect(workflows.triggers).toEqual({ status: "available" });
282
- expect(addReadModel).toHaveBeenCalledTimes(1);
283
- expect(addReadModel.mock.calls[0]![1]).toBe("post_ready");
284
- // Constructed once, initialised before it was registered, bound to the
285
- // runtime this composition built.
286
- expect(engine.constructed).toHaveLength(1);
287
- expect(engine.constructed[0]!.init).toBe(1);
288
- expect(engine.constructed[0]!.runtime).toBe(engine.runtime);
289
-
290
- const registered = addReadModel.mock.calls[0]![0];
291
- const operations = [] as OperationWithContext[];
292
- await registered.indexOperations(operations);
293
- expect(engine.runtime.onOperations).toHaveBeenCalledWith(operations);
294
230
  expect(
295
- coordinator.readModels.filter(({ name }) => name === "workflow-triggers"),
231
+ clientModule.reactorModule!.readModelCoordinator.readModels.filter(
232
+ ({ name }) => name === "workflow-triggers",
233
+ ),
296
234
  ).toHaveLength(1);
297
235
  });
298
236
 
299
- it("reports the intake unavailable and says so loudly", async () => {
300
- const engine = fakeEngine();
237
+ it("reports the intake unavailable on a coordinator without live registration", async () => {
301
238
  const customCoordinator: IReadModelCoordinator = {
302
239
  readModels: [],
303
240
  start: vi.fn(),
@@ -305,26 +242,22 @@ describe("composeWorkflowRuntime", () => {
305
242
  drain: vi.fn().mockResolvedValue(undefined),
306
243
  getChainDepth: vi.fn().mockReturnValue(0),
307
244
  };
308
- const clientModule = await buildReactorModule(customCoordinator);
309
- const logger = stubLogger();
310
245
 
311
- const workflows = await compose(engine, logger, { clientModule });
246
+ const workflows = await compose(
247
+ await buildReactorModule(customCoordinator),
248
+ );
312
249
 
313
250
  expect(workflows.triggers).toEqual({
314
251
  status: "unavailable",
315
252
  reason: "live-read-model-registration-unsupported",
316
253
  });
317
- expect(engine.constructed).toHaveLength(0);
318
254
  expect(customCoordinator.readModels).toEqual([]);
319
- expect(logger.error).toHaveBeenCalledTimes(1);
320
- expect(logger.error.mock.calls[0]![0]).toContain("NOT armed");
321
255
  });
322
256
 
323
257
  it("reports the intake unavailable without an in-process reactor", async () => {
324
- const engine = fakeEngine();
325
- const logger = stubLogger();
258
+ const clientModule = await buildReactorModule();
326
259
 
327
- const workflows = await compose(engine, logger, {
260
+ const workflows = await compose(clientModule, {
328
261
  clientModule: {} as InProcessReactorClientModule,
329
262
  });
330
263
 
@@ -332,33 +265,38 @@ describe("composeWorkflowRuntime", () => {
332
265
  status: "unavailable",
333
266
  reason: "in-process-reactor-module-unavailable",
334
267
  });
335
- expect(logger.error).toHaveBeenCalledTimes(1);
336
268
  });
337
269
 
338
- it("arms the webhooks and the supervisor on start", async () => {
339
- const engine = fakeEngine();
340
- const workflows = await compose(engine, stubLogger());
341
-
342
- 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
+ });
343
275
 
344
- expect(engine.runtime.registerWebhookEndpoint).toHaveBeenCalledTimes(1);
345
- expect(engine.runtime.startTriggerSupervisor).toHaveBeenCalledTimes(1);
346
- });
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();
347
280
 
348
- it("shuts the runtime down once on stop", async () => {
349
- const engine = fakeEngine();
350
- const workflows = await compose(engine, stubLogger());
351
- 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);
352
287
 
353
- await workflows.stop();
354
- await workflows.stop();
288
+ await workflows.stop();
289
+ await workflows.stop();
355
290
 
356
- expect(engine.runtime.shutdown).toHaveBeenCalledTimes(1);
291
+ expect(vi.getTimerCount()).toBe(0);
292
+ } finally {
293
+ vi.useRealTimers();
294
+ }
357
295
  });
358
296
 
359
297
  it("names the package a host would have to install when the load fails", async () => {
360
298
  const cause = new Error("Cannot find module");
361
- const failing = compose(fakeEngine(), stubLogger(), {
299
+ const failing = compose(await buildReactorModule(), {
362
300
  load: () => Promise.reject(cause),
363
301
  });
364
302
 
@@ -367,6 +305,48 @@ describe("composeWorkflowRuntime", () => {
367
305
  });
368
306
  });
369
307
 
308
+ describe("the host's reactor access", () => {
309
+ const signer = { app: { key: "did:key:zHost" }, user: { address: "0xhost" } };
310
+ const moduleWith = (flags?: Record<string, boolean>) =>
311
+ ({
312
+ signer,
313
+ reactorModule: flags ? { featureFlags: flags } : undefined,
314
+ }) as unknown as InProcessReactorClientModule;
315
+
316
+ it("grants the host by key under auth conditions, else by address", () => {
317
+ const byKey = reactorAccessOf(
318
+ moduleWith({ authEnforcement: true, authConditions: true }),
319
+ );
320
+ expect(byKey).toEqual({
321
+ authEnforcement: true,
322
+ authConditions: true,
323
+ identity: { address: "0xhost", key: "did:key:zHost" },
324
+ });
325
+ expect(hostPrincipalOf(byKey)).toEqual({
326
+ match: { eq: [{ attr: "subject.key" }, { lit: "did:key:zHost" }] },
327
+ });
328
+
329
+ const byAddress = reactorAccessOf(
330
+ moduleWith({ authEnforcement: false, authConditions: false }),
331
+ );
332
+ expect(byAddress.authEnforcement).toBe(false);
333
+ expect(hostPrincipalOf(byAddress)).toEqual({ address: "0xhost" });
334
+ });
335
+
336
+ it("reads unknown flags as enforced and a keyless signer as no identity", () => {
337
+ const access = reactorAccessOf({
338
+ signer: {},
339
+ reactorModule: undefined,
340
+ } as unknown as InProcessReactorClientModule);
341
+ expect(access).toEqual({
342
+ authEnforcement: true,
343
+ authConditions: false,
344
+ identity: null,
345
+ });
346
+ expect(hostPrincipalOf(access)).toBeUndefined();
347
+ });
348
+ });
349
+
370
350
  // The GraphQL manager rides on the boot result without being on its public
371
351
  // type, and the subgraph registers late: poll rather than race it.
372
352
  async function pollWorkflowSubgraph(
@@ -408,6 +388,15 @@ describe("booting Switchboard with workflows on", () => {
408
388
  expect(
409
389
  results.map(({ documentModel }) => documentModel.global.id),
410
390
  ).toContain("powerhouse/workflow");
391
+ // With no worker pool or projection worker, workflows alone resolve them.
392
+ expect(
393
+ switchboard.modelManifest().map((entry) => entry.documentType),
394
+ ).toEqual(
395
+ expect.arrayContaining([
396
+ "powerhouse/workflow",
397
+ "powerhouse/connection",
398
+ ]),
399
+ );
411
400
  // The subgraph is registered late, so the schema it joins arrives after
412
401
  // the boot resolves; poll rather than race it.
413
402
  await expect(pollWorkflowSubgraph(switchboard)).resolves.toMatchObject({
package/tsconfig.json CHANGED
@@ -15,9 +15,6 @@
15
15
  {
16
16
  "path": "../../packages/opentelemetry-instrumentation-reactor"
17
17
  },
18
- {
19
- "path": "../../packages/pglite-fs"
20
- },
21
18
  {
22
19
  "path": "../../packages/reactor"
23
20
  },