@powerhousedao/switchboard 6.2.3-dev.9 → 6.2.3

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 (108) hide show
  1. package/.tsbuild/test/attachments/byte-grants.test.d.ts +2 -0
  2. package/.tsbuild/test/attachments/byte-grants.test.d.ts.map +1 -0
  3. package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts +2 -0
  4. package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts.map +1 -0
  5. package/.tsbuild/test/attachments/url-signer.test.d.ts +2 -0
  6. package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
  7. package/.tsbuild/test/boot-create-signature-policy.test.d.ts +2 -0
  8. package/.tsbuild/test/boot-create-signature-policy.test.d.ts.map +1 -0
  9. package/.tsbuild/test/boot-mcp-enabled.test.d.ts +2 -0
  10. package/.tsbuild/test/boot-mcp-enabled.test.d.ts.map +1 -0
  11. package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts +2 -0
  12. package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts.map +1 -0
  13. package/.tsbuild/test/boot-without-renown.test.d.ts +2 -0
  14. package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
  15. package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
  16. package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
  17. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
  18. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
  19. package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
  20. package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
  21. package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
  22. package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
  23. package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
  24. package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
  25. package/.tsbuild/test/mcp-flag.test.d.ts +2 -0
  26. package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
  27. package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
  28. package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
  29. package/.tsbuild/test/unsupported-stored-documents.test.d.ts +2 -0
  30. package/.tsbuild/test/unsupported-stored-documents.test.d.ts.map +1 -0
  31. package/.tsbuild/test/workflow/harness.d.ts +56 -0
  32. package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
  33. package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
  34. package/.tsbuild/test/workflow/oauth.test.d.ts.map +1 -0
  35. package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
  36. package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
  37. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
  38. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
  39. package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
  40. package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
  41. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts +2 -0
  42. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts.map +1 -0
  43. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
  44. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
  45. package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
  46. package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
  47. package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
  48. package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
  49. package/.tsbuild/tsconfig.tsbuildinfo +1 -1
  50. package/.tsbuild/tsdown.config.d.ts.map +1 -1
  51. package/.tsbuild/vitest.config.d.ts.map +1 -1
  52. package/Auth.md +94 -0
  53. package/CHANGELOG.md +302 -0
  54. package/README.md +35 -0
  55. package/dist/index.d.mts +2 -1
  56. package/dist/index.mjs +10 -8
  57. package/dist/index.mjs.map +1 -1
  58. package/dist/install-packages.d.mts +2 -1
  59. package/dist/install-packages.mjs +2 -2
  60. package/dist/install-packages.mjs.map +1 -1
  61. package/dist/migrate.d.mts +2 -1
  62. package/dist/migrate.mjs +4 -3
  63. package/dist/migrate.mjs.map +1 -1
  64. package/dist/{server-CiKkVE6g.mjs → server-sQoWp21G.mjs} +1454 -70
  65. package/dist/server-sQoWp21G.mjs.map +1 -0
  66. package/dist/server.d.mts +103 -29
  67. package/dist/server.d.mts.map +1 -1
  68. package/dist/server.mjs +4 -5
  69. package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
  70. package/dist/utils-uaBlzyjS.mjs.map +1 -0
  71. package/dist/utils.d.mts +4 -5
  72. package/dist/utils.d.mts.map +1 -1
  73. package/dist/utils.mjs +3 -3
  74. package/package.json +21 -17
  75. package/test/attachments/auth.test.ts +85 -0
  76. package/test/attachments/byte-grants.test.ts +287 -0
  77. package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
  78. package/test/attachments/download-target.test.ts +194 -31
  79. package/test/attachments/index.test.ts +34 -3
  80. package/test/attachments/routes-integration.test.ts +59 -4
  81. package/test/attachments/routes.test.ts +165 -23
  82. package/test/attachments/service-config.test.ts +13 -3
  83. package/test/attachments/url-signer.test.ts +105 -0
  84. package/test/boot-create-signature-policy.test.ts +108 -0
  85. package/test/boot-mcp-enabled.test.ts +121 -0
  86. package/test/boot-unsupported-stored-documents.test.ts +210 -0
  87. package/test/boot-without-renown.test.ts +81 -0
  88. package/test/boot-without-require-signatures.test.ts +74 -0
  89. package/test/boot-worker-trust-policy.test.ts +90 -0
  90. package/test/builder-defaults.test.ts +80 -0
  91. package/test/create-signature-policy.test.ts +69 -0
  92. package/test/fatal-shutdown.test.ts +102 -0
  93. package/test/mcp-flag.test.ts +56 -0
  94. package/test/renown-trust-policy.test.ts +315 -0
  95. package/test/unsupported-stored-documents.test.ts +72 -0
  96. package/test/workflow/harness.ts +289 -0
  97. package/test/workflow/oauth.test.ts +214 -0
  98. package/test/workflow/resolvers.test.ts +296 -0
  99. package/test/workflow/webhook-dedupe.test.ts +295 -0
  100. package/test/workflow/webhook-signed.test.ts +509 -0
  101. package/test/workflow/webhook-trigger-test.test.ts +82 -0
  102. package/test/workflow/webhook-unsigned.test.ts +345 -0
  103. package/test/workflow-read-gate.test.ts +145 -0
  104. package/test/workflow-runtime.test.ts +486 -0
  105. package/tsconfig.json +6 -0
  106. package/tsdown.config.ts +3 -1
  107. package/dist/server-CiKkVE6g.mjs.map +0 -1
  108. package/dist/utils-Baw7rThP.mjs.map +0 -1
@@ -0,0 +1,486 @@
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 {
14
+ BaseSubgraph,
15
+ type GraphQLManager,
16
+ type PackagePieceEntry,
17
+ } from "@powerhousedao/reactor-api";
18
+ import { PieceRegistry } from "@powerhousedao/reactor-workflow";
19
+ import type { OperationWithContext } from "@powerhousedao/shared/document-model";
20
+ import type { ILogger } from "document-model";
21
+ import { Kysely } from "kysely";
22
+ import { PGliteDialect } from "kysely-pglite-dialect";
23
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
24
+ import { initFeatureFlags } from "../src/feature-flags.js";
25
+ import { startSwitchboard } from "../src/server.mjs";
26
+ import {
27
+ PH_WORKFLOWS_ENABLED,
28
+ bindPackagePieces,
29
+ composeWorkflowRuntime,
30
+ assertWorkflowPackageLoadable,
31
+ resolveWorkflowsEnabled,
32
+ type BooleanFlagSource,
33
+ } from "../src/workflow-runtime.mjs";
34
+
35
+ function stubLogger(): ILogger & {
36
+ info: ReturnType<typeof vi.fn>;
37
+ error: ReturnType<typeof vi.fn>;
38
+ } {
39
+ const logger = {
40
+ verbose: vi.fn(),
41
+ debug: vi.fn(),
42
+ info: vi.fn(),
43
+ warn: vi.fn(),
44
+ error: vi.fn(),
45
+ child: vi.fn(),
46
+ };
47
+ logger.child.mockReturnValue(logger);
48
+ return logger as unknown as ILogger & {
49
+ info: ReturnType<typeof vi.fn>;
50
+ error: ReturnType<typeof vi.fn>;
51
+ };
52
+ }
53
+
54
+ // The engine, faked at the seam the host loads it through: everything below
55
+ // belongs to @powerhousedao/reactor-workflow's own tests.
56
+ function fakeEngine() {
57
+ const runtime = {
58
+ registerWebhookEndpoint: vi.fn(() => Promise.resolve()),
59
+ startTriggerSupervisor: vi.fn(),
60
+ shutdown: vi.fn(),
61
+ onOperations: vi.fn((_operations: OperationWithContext[]) =>
62
+ Promise.resolve(),
63
+ ),
64
+ };
65
+ const constructed: {
66
+ db: unknown;
67
+ runtime: unknown;
68
+ init: number;
69
+ }[] = [];
70
+
71
+ class FakeWorkflowTriggersReadModel {
72
+ readonly name = "workflow-triggers";
73
+ readonly #record: (typeof constructed)[number];
74
+
75
+ constructor(
76
+ db: unknown,
77
+ _operationIndex: unknown,
78
+ _writeCache: unknown,
79
+ _consistencyTracker: unknown,
80
+ boundRuntime: unknown,
81
+ ) {
82
+ this.#record = { db, runtime: boundRuntime, init: 0 };
83
+ constructed.push(this.#record);
84
+ }
85
+
86
+ init(): Promise<void> {
87
+ this.#record.init += 1;
88
+ return Promise.resolve();
89
+ }
90
+
91
+ indexOperations(operations: OperationWithContext[]): Promise<void> {
92
+ return (this.#record.runtime as typeof runtime).onOperations(operations);
93
+ }
94
+ }
95
+
96
+ return {
97
+ runtime,
98
+ constructed,
99
+ module: {
100
+ WORKFLOW_PACKAGE_NAME: "@powerhousedao/workflow",
101
+ WORKFLOW_TRIGGERS_READ_MODEL: "workflow-triggers",
102
+ WORKFLOW_TRIGGERS_READ_MODEL_STAGE:
103
+ "post_ready" as ReadModelRegistrationStage,
104
+ WorkflowTriggersReadModel: FakeWorkflowTriggersReadModel,
105
+ setPieceRegistryUrl: vi.fn((_url: string | undefined) => undefined),
106
+ createWorkflowRuntime: vi.fn((_deps: Record<string, unknown>) => runtime),
107
+ },
108
+ };
109
+ }
110
+
111
+ function compose(
112
+ engine: ReturnType<typeof fakeEngine>,
113
+ logger: ILogger,
114
+ overrides: Record<string, unknown> = {},
115
+ ) {
116
+ return composeWorkflowRuntime({
117
+ reactorClient: {} as never,
118
+ relationalDb: { id: "relational-db" } as never,
119
+ attachments: { id: "attachments" } as never,
120
+ webhooks: { id: "webhooks" } as never,
121
+ authorizationService: {} as never,
122
+ logger,
123
+ load: () => Promise.resolve(engine.module as never),
124
+ ...overrides,
125
+ });
126
+ }
127
+
128
+ describe("resolveWorkflowsEnabled", () => {
129
+ let featureFlags: BooleanFlagSource;
130
+
131
+ beforeAll(async () => {
132
+ featureFlags = await initFeatureFlags();
133
+ });
134
+
135
+ afterEach(() => {
136
+ delete process.env[PH_WORKFLOWS_ENABLED];
137
+ });
138
+
139
+ it("defaults to off", async () => {
140
+ await expect(resolveWorkflowsEnabled({ featureFlags })).resolves.toBe(
141
+ false,
142
+ );
143
+ });
144
+
145
+ it("lets the host's option win over the env var", async () => {
146
+ process.env[PH_WORKFLOWS_ENABLED] = "false";
147
+
148
+ await expect(
149
+ resolveWorkflowsEnabled({ featureFlags, override: true }),
150
+ ).resolves.toBe(true);
151
+
152
+ process.env[PH_WORKFLOWS_ENABLED] = "true";
153
+
154
+ await expect(
155
+ resolveWorkflowsEnabled({ featureFlags, override: false }),
156
+ ).resolves.toBe(false);
157
+ });
158
+
159
+ it("reads the env var in every form reactor-api accepted", async () => {
160
+ for (const [raw, expected] of [
161
+ ["true", true],
162
+ ["1", true],
163
+ ["false", false],
164
+ ["0", false],
165
+ ] as const) {
166
+ process.env[PH_WORKFLOWS_ENABLED] = raw;
167
+ await expect(
168
+ resolveWorkflowsEnabled({ featureFlags, configEnabled: !expected }),
169
+ ).resolves.toBe(expected);
170
+ }
171
+ });
172
+
173
+ it("falls back to the config file, then off", async () => {
174
+ await expect(
175
+ resolveWorkflowsEnabled({ featureFlags, configEnabled: true }),
176
+ ).resolves.toBe(true);
177
+ await expect(
178
+ resolveWorkflowsEnabled({ featureFlags, configEnabled: false }),
179
+ ).resolves.toBe(false);
180
+ });
181
+
182
+ // The engine must not be imported at all when workflows are off, so the
183
+ // decision is the flag's alone and is taken before compose is ever called.
184
+ it("is what gates composition", async () => {
185
+ const composeIfEnabled = async () =>
186
+ (await resolveWorkflowsEnabled({ featureFlags }))
187
+ ? "composed"
188
+ : "skipped";
189
+
190
+ expect(await composeIfEnabled()).toBe("skipped");
191
+ process.env[PH_WORKFLOWS_ENABLED] = "true";
192
+ expect(await composeIfEnabled()).toBe("composed");
193
+ });
194
+ });
195
+
196
+ describe("assertWorkflowPackageLoadable", () => {
197
+ it("passes when the package resolves", async () => {
198
+ await expect(
199
+ assertWorkflowPackageLoadable(() => Promise.resolve({})),
200
+ ).resolves.toBeUndefined();
201
+ });
202
+
203
+ it("names the package a host would have to install when the load fails", async () => {
204
+ const cause = new Error("Cannot find module");
205
+ await expect(
206
+ assertWorkflowPackageLoadable(() => Promise.reject(cause)),
207
+ ).rejects.toMatchObject({ cause });
208
+ });
209
+ });
210
+
211
+ describe("composeWorkflowRuntime", () => {
212
+ let database: Kysely<unknown> | undefined;
213
+ let reactor: Awaited<ReturnType<ReactorBuilder["buildModule"]>> | undefined;
214
+
215
+ afterEach(async () => {
216
+ const shutdown = reactor?.reactor.kill();
217
+ await shutdown?.completed;
218
+ await database?.destroy();
219
+ reactor = undefined;
220
+ database = undefined;
221
+ });
222
+
223
+ async function buildReactorModule(
224
+ coordinator?: IReadModelCoordinator,
225
+ ): Promise<InProcessReactorClientModule> {
226
+ database = new Kysely<unknown>({
227
+ dialect: new PGliteDialect(new PGlite()),
228
+ });
229
+ const builder = new ReactorBuilder().withKysely(
230
+ database as unknown as Kysely<Database>,
231
+ );
232
+ if (coordinator) builder.withReadModelCoordinator(coordinator);
233
+ reactor = await builder.buildModule();
234
+ return { reactorModule: reactor } as InProcessReactorClientModule;
235
+ }
236
+
237
+ it("hands the engine the host surfaces it declares", async () => {
238
+ const engine = fakeEngine();
239
+ await compose(engine, stubLogger());
240
+
241
+ expect(engine.module.createWorkflowRuntime).toHaveBeenCalledTimes(1);
242
+ const [deps] = engine.module.createWorkflowRuntime.mock.calls[0]!;
243
+ expect(deps.relationalDb).toEqual({ id: "relational-db" });
244
+ expect(deps.attachments).toEqual({ id: "attachments" });
245
+ expect(deps.webhooks).toEqual({ id: "webhooks" });
246
+ expect(typeof deps.assertCanRead).toBe("function");
247
+ expect(typeof deps.assertCanWrite).toBe("function");
248
+ expect(typeof deps.canReadAttachmentRef).toBe("function");
249
+ });
250
+
251
+ it("serves a subgraph the GraphQL manager can construct", async () => {
252
+ const engine = fakeEngine();
253
+ const { subgraph } = await compose(engine, stubLogger());
254
+
255
+ expect(subgraph.prototype).toBeInstanceOf(BaseSubgraph);
256
+ });
257
+
258
+ it("registers the trigger read model post_ready and feeds the runtime", async () => {
259
+ const engine = fakeEngine();
260
+ const clientModule = await buildReactorModule();
261
+ const coordinator = clientModule.reactorModule!
262
+ .readModelCoordinator as ILiveReadModelCoordinator;
263
+ const addReadModel = vi.spyOn(coordinator, "addReadModel");
264
+ const logger = stubLogger();
265
+
266
+ const workflows = await compose(engine, logger, { clientModule });
267
+
268
+ expect(workflows.triggers).toEqual({ status: "available" });
269
+ expect(addReadModel).toHaveBeenCalledTimes(1);
270
+ expect(addReadModel.mock.calls[0]![1]).toBe("post_ready");
271
+ // Constructed once, initialised before it was registered, bound to the
272
+ // runtime this composition built.
273
+ expect(engine.constructed).toHaveLength(1);
274
+ expect(engine.constructed[0]!.init).toBe(1);
275
+ expect(engine.constructed[0]!.runtime).toBe(engine.runtime);
276
+
277
+ const registered = addReadModel.mock.calls[0]![0];
278
+ const operations = [] as OperationWithContext[];
279
+ await registered.indexOperations(operations);
280
+ expect(engine.runtime.onOperations).toHaveBeenCalledWith(operations);
281
+ expect(
282
+ coordinator.readModels.filter(({ name }) => name === "workflow-triggers"),
283
+ ).toHaveLength(1);
284
+ });
285
+
286
+ it("reports the intake unavailable and says so loudly", async () => {
287
+ const engine = fakeEngine();
288
+ const customCoordinator: IReadModelCoordinator = {
289
+ readModels: [],
290
+ start: vi.fn(),
291
+ stop: vi.fn(),
292
+ drain: vi.fn().mockResolvedValue(undefined),
293
+ getChainDepth: vi.fn().mockReturnValue(0),
294
+ };
295
+ const clientModule = await buildReactorModule(customCoordinator);
296
+ const logger = stubLogger();
297
+
298
+ const workflows = await compose(engine, logger, { clientModule });
299
+
300
+ expect(workflows.triggers).toEqual({
301
+ status: "unavailable",
302
+ reason: "live-read-model-registration-unsupported",
303
+ });
304
+ expect(engine.constructed).toHaveLength(0);
305
+ expect(customCoordinator.readModels).toEqual([]);
306
+ expect(logger.error).toHaveBeenCalledTimes(1);
307
+ expect(logger.error.mock.calls[0]![0]).toContain("NOT armed");
308
+ });
309
+
310
+ it("reports the intake unavailable without an in-process reactor", async () => {
311
+ const engine = fakeEngine();
312
+ const logger = stubLogger();
313
+
314
+ const workflows = await compose(engine, logger, {
315
+ clientModule: {} as InProcessReactorClientModule,
316
+ });
317
+
318
+ expect(workflows.triggers).toEqual({
319
+ status: "unavailable",
320
+ reason: "in-process-reactor-module-unavailable",
321
+ });
322
+ expect(logger.error).toHaveBeenCalledTimes(1);
323
+ });
324
+
325
+ it("arms the webhooks and the supervisor on start", async () => {
326
+ const engine = fakeEngine();
327
+ const workflows = await compose(engine, stubLogger());
328
+
329
+ await workflows.start();
330
+
331
+ expect(engine.runtime.registerWebhookEndpoint).toHaveBeenCalledTimes(1);
332
+ expect(engine.runtime.startTriggerSupervisor).toHaveBeenCalledTimes(1);
333
+ });
334
+
335
+ it("shuts the runtime down once on stop", async () => {
336
+ const engine = fakeEngine();
337
+ const workflows = await compose(engine, stubLogger());
338
+ await workflows.start();
339
+
340
+ await workflows.stop();
341
+ await workflows.stop();
342
+
343
+ expect(engine.runtime.shutdown).toHaveBeenCalledTimes(1);
344
+ });
345
+
346
+ it("names the package a host would have to install when the load fails", async () => {
347
+ const cause = new Error("Cannot find module");
348
+ const failing = compose(fakeEngine(), stubLogger(), {
349
+ load: () => Promise.reject(cause),
350
+ });
351
+
352
+ await expect(failing).rejects.toThrow("@powerhousedao/reactor-workflow");
353
+ await expect(failing).rejects.toMatchObject({ cause });
354
+ });
355
+ });
356
+
357
+ // The GraphQL manager rides on the boot result without being on its public
358
+ // type, and the subgraph registers late: poll rather than race it.
359
+ async function pollWorkflowSubgraph(
360
+ switchboard: Awaited<ReturnType<typeof startSwitchboard>>,
361
+ ): Promise<{ name: string } | undefined> {
362
+ const { graphqlManager } = (
363
+ switchboard as unknown as {
364
+ api: { graphqlManager: GraphQLManager };
365
+ }
366
+ ).api;
367
+ for (let attempt = 0; attempt < 100; attempt++) {
368
+ const subgraph = graphqlManager.getSubgraphByName("workflow-runtime");
369
+ if (subgraph) return subgraph;
370
+ await new Promise((resolve) => setTimeout(resolve, 50));
371
+ }
372
+ return undefined;
373
+ }
374
+
375
+ describe("booting Switchboard with workflows on", () => {
376
+ it("arms the intake and registers the workflow document models", async () => {
377
+ const tempRoot = await mkdtemp(join(tmpdir(), "switchboard-workflows-"));
378
+ const previousReactorDb = process.env.PH_REACTOR_DATABASE_URL;
379
+ process.env.PH_REACTOR_DATABASE_URL = join(tempRoot, "reactor-storage");
380
+ let switchboard: Awaited<ReturnType<typeof startSwitchboard>> | undefined;
381
+
382
+ try {
383
+ switchboard = await startSwitchboard({
384
+ workflows: { enabled: true },
385
+ dbPath: join(tempRoot, "read-model"),
386
+ port: 0,
387
+ mcp: false,
388
+ disableLocalPackages: true,
389
+ identity: { keypairPath: join(tempRoot, "identity.json") },
390
+ logger: stubLogger(),
391
+ });
392
+
393
+ expect(switchboard.workflowTriggers).toEqual({ status: "available" });
394
+ const { results } = await switchboard.reactor.getDocumentModelModules();
395
+ expect(
396
+ results.map(({ documentModel }) => documentModel.global.id),
397
+ ).toContain("powerhouse/workflow");
398
+ // The subgraph is registered late, so the schema it joins arrives after
399
+ // the boot resolves; poll rather than race it.
400
+ await expect(pollWorkflowSubgraph(switchboard)).resolves.toMatchObject({
401
+ name: "workflow-runtime",
402
+ });
403
+
404
+ await switchboard.shutdown();
405
+ switchboard = undefined;
406
+ } finally {
407
+ await switchboard?.shutdown();
408
+ if (previousReactorDb === undefined) {
409
+ delete process.env.PH_REACTOR_DATABASE_URL;
410
+ } else {
411
+ process.env.PH_REACTOR_DATABASE_URL = previousReactorDb;
412
+ }
413
+ await rm(tempRoot, { recursive: true, force: true });
414
+ }
415
+ }, 60_000);
416
+ });
417
+
418
+ describe("bindPackagePieces", () => {
419
+ // The manager's shape, no more of it than the binding uses.
420
+ function stubSource(initial: Map<string, PackagePieceEntry[]>) {
421
+ const handlers: ((pieces: Map<string, PackagePieceEntry[]>) => void)[] = [];
422
+ return {
423
+ getPieces: () => initial,
424
+ onPiecesChange(
425
+ handler: (pieces: Map<string, PackagePieceEntry[]>) => void,
426
+ ) {
427
+ handlers.push(handler);
428
+ },
429
+ emit(pieces: Map<string, PackagePieceEntry[]>) {
430
+ for (const handler of handlers) handler(pieces);
431
+ },
432
+ };
433
+ }
434
+
435
+ const entry = (name: string, version: string): PackagePieceEntry => ({
436
+ name,
437
+ version,
438
+ bundleDir: `/pkg/dist/node/pieces/${name}`,
439
+ });
440
+
441
+ it("fills the registry from what the manager already loaded", () => {
442
+ const registry = new PieceRegistry();
443
+ const source = stubSource(
444
+ new Map([
445
+ ["@acme/pack", [entry("@acme/piece-a", "1.0.0")]],
446
+ ["/srv/project", [entry("@acme/piece-b", "2.0.0")]],
447
+ ]),
448
+ );
449
+
450
+ bindPackagePieces(registry, source);
451
+
452
+ expect(registry.versions()).toEqual({
453
+ "@acme/piece-a": "1.0.0",
454
+ "@acme/piece-b": "2.0.0",
455
+ });
456
+ });
457
+
458
+ it("refills on every change, so a rebuilt package needs no restart", () => {
459
+ const registry = new PieceRegistry();
460
+ const source = stubSource(new Map());
461
+
462
+ bindPackagePieces(registry, source);
463
+ expect(registry.entries()).toEqual([]);
464
+
465
+ source.emit(new Map([["/srv/project", [entry("@acme/piece-b", "2.0.0")]]]));
466
+ expect(registry.versions()).toEqual({ "@acme/piece-b": "2.0.0" });
467
+
468
+ // And a package that stops shipping one loses it.
469
+ source.emit(new Map());
470
+ expect(registry.entries()).toEqual([]);
471
+ });
472
+
473
+ it("gives a piece two packages ship to the first by package name, whatever the load order", () => {
474
+ const registry = new PieceRegistry();
475
+ const source = stubSource(
476
+ new Map([
477
+ ["@zeta/pack", [entry("@acme/piece-a", "9.0.0")]],
478
+ ["@alpha/pack", [entry("@acme/piece-a", "1.0.0")]],
479
+ ]),
480
+ );
481
+
482
+ bindPackagePieces(registry, source);
483
+
484
+ expect(registry.versions()).toEqual({ "@acme/piece-a": "1.0.0" });
485
+ });
486
+ });
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
  }
package/tsdown.config.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { defineConfig } from "tsdown";
2
+ import { dtsExportList } from "../../tsdown.dts.mjs";
2
3
 
3
4
  export default defineConfig({
4
5
  entry: [
@@ -11,6 +12,7 @@ export default defineConfig({
11
12
  platform: "node",
12
13
  outDir: "dist",
13
14
  clean: true,
14
- dts: true,
15
+ dts: { generator: "tsgo" },
16
+ plugins: [dtsExportList()],
15
17
  sourcemap: true,
16
18
  });