@powerhousedao/switchboard 6.2.2-dev.2 → 6.2.2-dev.21
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/test/attachment-reference-read-model.test.d.ts +2 -0
- package/.tsbuild/test/attachment-reference-read-model.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/download-target.test.d.ts +2 -0
- package/.tsbuild/test/attachments/download-target.test.d.ts.map +1 -0
- package/.tsbuild/test/worker-pool.test.d.ts +2 -0
- package/.tsbuild/test/worker-pool.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/CHANGELOG.md +110 -0
- package/README.md +44 -0
- package/dist/index.mjs +1 -1
- package/dist/{server-DgBz3TJW.mjs → server-ChK0GLG8.mjs} +441 -25
- package/dist/server-ChK0GLG8.mjs.map +1 -0
- package/dist/server.d.mts +16 -10
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +3 -3
- package/package.json +12 -12
- package/test/attachment-reference-read-model.test.ts +425 -0
- package/test/attachments/auth.test.ts +138 -8
- package/test/attachments/download-target.test.ts +445 -0
- package/test/attachments/index.test.ts +37 -2
- package/test/attachments/routes-integration.test.ts +98 -1
- package/test/worker-pool.test.ts +232 -0
- package/dist/server-DgBz3TJW.mjs.map +0 -1
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="b1867ed0-059f-587c-9a5f-677da3666de7")}catch(e){}}();
|
|
3
3
|
import { n as addDefaultReactorDrive, r as isPostgresUrl, t as addDefaultDrive } from "./utils-Baw7rThP.mjs";
|
|
4
4
|
import { register } from "node:module";
|
|
5
5
|
import * as Sentry from "@sentry/node";
|
|
6
6
|
import { childLogger, documentModelDocumentModelModule, setLogLevel } from "document-model";
|
|
7
7
|
import dotenv from "dotenv";
|
|
8
8
|
import { getConfig } from "@powerhousedao/config/node";
|
|
9
|
-
import { promises } from "node:fs";
|
|
9
|
+
import { existsSync, promises, realpathSync } from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import { ReactorInstrumentation } from "@powerhousedao/opentelemetry-instrumentation-reactor";
|
|
12
12
|
import { AtomicNodeFs } from "@powerhousedao/pglite-fs";
|
|
13
|
-
import { ChannelScheme, DriveCollectionId, EventBus, REACTOR_SCHEMA, ReactorBuilder, ReactorClientBuilder, parseDriveUrl } from "@powerhousedao/reactor";
|
|
13
|
+
import { ChannelScheme, DriveCollectionId, EventBus, REACTOR_SCHEMA, ReactorBuilder, ReactorClientBuilder, parseDriveUrl, supportsLiveReadModelRegistration } from "@powerhousedao/reactor";
|
|
14
14
|
import { HttpPackageLoader, ImportPackageLoader, PackageManagementService, PackagesSubgraph, getUniqueDocumentModels, initializeAndStartAPI } from "@powerhousedao/reactor-api";
|
|
15
15
|
import { httpsHooksPath } from "@powerhousedao/reactor-api/https-hooks";
|
|
16
|
-
import { AttachmentAlreadyExists, AttachmentNotFound, AttachmentPending, HashMismatch, InvalidAttachmentRef, ReservationNotFound, SizeMismatch, UploadTooLarge, createRemoteAttachmentService } from "@powerhousedao/reactor-attachments";
|
|
16
|
+
import { AttachmentAlreadyExists, AttachmentNotFound, AttachmentPending, AttachmentReferenceReadModel, AttachmentSchemaCompiler, HashMismatch, InvalidAttachmentRef, ReservationNotFound, SizeMismatch, UploadTooLarge, createRef, createRemoteAttachmentService, parseAttachmentDownloadTarget } from "@powerhousedao/reactor-attachments";
|
|
17
17
|
import { DriveNodeView, NodeProcessor, ReactorDriveClient, createReactorDriveResolvers, reactorDriveDocumentModelModule, reactorDriveSubgraphTypeDefs } from "@powerhousedao/reactor-drive";
|
|
18
18
|
import { Kysely, PostgresDialect } from "kysely";
|
|
19
19
|
import net from "node:net";
|
|
@@ -21,6 +21,9 @@ import path$1 from "path";
|
|
|
21
21
|
import { Pool } from "pg";
|
|
22
22
|
import { Readable } from "node:stream";
|
|
23
23
|
import { driveDocumentModelModule } from "@powerhousedao/shared/document-drive";
|
|
24
|
+
import { readFile } from "node:fs/promises";
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
24
27
|
import { EnvVarProvider } from "@openfeature/env-var-provider";
|
|
25
28
|
import { OpenFeature } from "@openfeature/server-sdk";
|
|
26
29
|
import { PGliteDialect } from "kysely-pglite-dialect";
|
|
@@ -61,13 +64,20 @@ async function loadPgDump(major) {
|
|
|
61
64
|
}
|
|
62
65
|
//#endregion
|
|
63
66
|
//#region src/attachments/auth.ts
|
|
67
|
+
const ANONYMOUS_ACTOR = {
|
|
68
|
+
user: void 0,
|
|
69
|
+
authEnabled: false
|
|
70
|
+
};
|
|
64
71
|
/**
|
|
65
72
|
* Wrap a Node-style handler so that, when `authService` is provided and auth is
|
|
66
|
-
* enabled, the request must carry a verifiable Bearer token.
|
|
73
|
+
* enabled, the request must carry a verifiable Bearer token. The handler always
|
|
74
|
+
* receives an actor context: the verified bearer user when auth is enabled, or
|
|
75
|
+
* the anonymous context when it is disabled. With `allowAnonymous`, a missing
|
|
76
|
+
* bearer yields an anonymous actor with `authEnabled: true` instead of a 401.
|
|
67
77
|
*/
|
|
68
|
-
function requireAuth(authService, handler) {
|
|
69
|
-
if (!authService) return handler;
|
|
70
|
-
return async (req, res) => {
|
|
78
|
+
function requireAuth(authService, handler, options) {
|
|
79
|
+
if (!authService) return (req, res, body) => handler(req, res, body, ANONYMOUS_ACTOR);
|
|
80
|
+
return async (req, res, body) => {
|
|
71
81
|
let result;
|
|
72
82
|
try {
|
|
73
83
|
result = await authService.verifyBearer(req.headers.authorization);
|
|
@@ -85,25 +95,32 @@ function requireAuth(authService, handler) {
|
|
|
85
95
|
res.end(body);
|
|
86
96
|
return;
|
|
87
97
|
}
|
|
88
|
-
if (result.auth_enabled && !result.user) {
|
|
98
|
+
if (result.auth_enabled && !result.user && !options?.allowAnonymous) {
|
|
89
99
|
res.statusCode = 401;
|
|
90
100
|
res.setHeader("Content-Type", "application/json");
|
|
91
101
|
res.end(JSON.stringify({ error: "Authentication required" }));
|
|
92
102
|
return;
|
|
93
103
|
}
|
|
94
|
-
await handler(req, res
|
|
104
|
+
await handler(req, res, body, {
|
|
105
|
+
user: result.user,
|
|
106
|
+
authEnabled: result.auth_enabled
|
|
107
|
+
});
|
|
95
108
|
};
|
|
96
109
|
}
|
|
97
110
|
//#endregion
|
|
98
111
|
//#region src/attachments/mount-auth.ts
|
|
99
112
|
/**
|
|
100
113
|
* Mount a Node-style attachment route with `requireAuth` applied unconditionally.
|
|
101
|
-
* When `api.authService` is undefined (auth disabled),
|
|
102
|
-
*
|
|
103
|
-
*
|
|
114
|
+
* When `api.authService` is undefined (auth disabled), the handler still runs
|
|
115
|
+
* through `requireAuth` and receives the anonymous actor context — that is the
|
|
116
|
+
* only way to opt out of bearer verification. To register a route without auth
|
|
117
|
+
* wrapping you must call `api.httpAdapter.mountNodeRoute` directly.
|
|
118
|
+
*
|
|
119
|
+
* `allowAnonymous` is reserved for routes whose handlers make a per-document
|
|
120
|
+
* authorization decision themselves; identity-only routes must not use it.
|
|
104
121
|
*/
|
|
105
|
-
function mountAuthenticatedNodeRoute(api, method, path, handler) {
|
|
106
|
-
api.httpAdapter.mountNodeRoute(method, path, requireAuth(api.authService, handler));
|
|
122
|
+
function mountAuthenticatedNodeRoute(api, method, path, handler, options) {
|
|
123
|
+
api.httpAdapter.mountNodeRoute(method, path, requireAuth(api.authService, handler, options));
|
|
107
124
|
}
|
|
108
125
|
//#endregion
|
|
109
126
|
//#region src/attachments/routes.ts
|
|
@@ -213,7 +230,8 @@ function makeReserveHandler(attachments) {
|
|
|
213
230
|
sendJson(res, 201, {
|
|
214
231
|
reservationId: upload.reservationId,
|
|
215
232
|
ref: upload.ref,
|
|
216
|
-
expiresAtUtc: upload.expiresAtUtc
|
|
233
|
+
expiresAtUtc: upload.expiresAtUtc,
|
|
234
|
+
...upload.uploadTarget ? { uploadTarget: upload.uploadTarget } : {}
|
|
217
235
|
});
|
|
218
236
|
};
|
|
219
237
|
}
|
|
@@ -224,6 +242,10 @@ function makeUploadHandler(attachments) {
|
|
|
224
242
|
sendError(res, 400, "Missing reservationId");
|
|
225
243
|
return;
|
|
226
244
|
}
|
|
245
|
+
if (attachments.backend?.kind === "s3") {
|
|
246
|
+
sendError(res, 405, "Use the reservation uploadTarget for S3 uploads");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
227
249
|
let reservation;
|
|
228
250
|
try {
|
|
229
251
|
reservation = await attachments.reservations.get(reservationId);
|
|
@@ -370,6 +392,144 @@ function makeDeleteReservationHandler(attachments) {
|
|
|
370
392
|
function extractParam(req, name) {
|
|
371
393
|
return req.params?.[name];
|
|
372
394
|
}
|
|
395
|
+
const MAX_DOCUMENT_ID_LEN = 512;
|
|
396
|
+
const ATTACHMENT_NOT_FOUND_BODY = { error: "Attachment not found" };
|
|
397
|
+
const MAX_DOWNLOAD_TARGET_TTL_SECONDS = 10080 * 60;
|
|
398
|
+
/**
|
|
399
|
+
* Returns the single `documentId` query value, or null when it is missing,
|
|
400
|
+
* duplicated, blank, or oversized. Validation happens before authorization so
|
|
401
|
+
* malformed requests never reach the access service.
|
|
402
|
+
*/
|
|
403
|
+
function extractSingleDocumentId(req) {
|
|
404
|
+
if (!req.url) return null;
|
|
405
|
+
let url;
|
|
406
|
+
try {
|
|
407
|
+
url = new URL(req.url, "http://switchboard.invalid");
|
|
408
|
+
} catch {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
const values = url.searchParams.getAll("documentId");
|
|
412
|
+
if (values.length !== 1) return null;
|
|
413
|
+
const value = values[0];
|
|
414
|
+
if (value.trim().length === 0 || value.length > MAX_DOCUMENT_ID_LEN) return null;
|
|
415
|
+
return value;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Returns the requested target TTL in seconds: undefined when absent,
|
|
419
|
+
* "invalid" when malformed (duplicated, non-integer, or non-positive), and
|
|
420
|
+
* otherwise the value clamped to the presigning ceiling.
|
|
421
|
+
*/
|
|
422
|
+
function extractExpiresIn(req) {
|
|
423
|
+
if (!req.url) return void 0;
|
|
424
|
+
let url;
|
|
425
|
+
try {
|
|
426
|
+
url = new URL(req.url, "http://switchboard.invalid");
|
|
427
|
+
} catch {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const values = url.searchParams.getAll("expiresIn");
|
|
431
|
+
if (values.length === 0) return void 0;
|
|
432
|
+
if (values.length > 1) return "invalid";
|
|
433
|
+
const parsed = Number(values[0]);
|
|
434
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return "invalid";
|
|
435
|
+
return Math.min(parsed, MAX_DOWNLOAD_TARGET_TTL_SECONDS);
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Base URL of this Switchboard as seen by the caller, used to build
|
|
439
|
+
* filesystem `switchboard` download targets that point back at the existing
|
|
440
|
+
* authenticated byte route.
|
|
441
|
+
*/
|
|
442
|
+
function requestBaseUrl(req) {
|
|
443
|
+
const forwardedProto = req.headers["x-forwarded-proto"];
|
|
444
|
+
const proto = (Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto)?.split(",")[0]?.trim() || (req.socket.encrypted ? "https" : "http");
|
|
445
|
+
const forwardedHost = req.headers["x-forwarded-host"];
|
|
446
|
+
const host = (Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost) ?? req.headers.host;
|
|
447
|
+
if (!host) return null;
|
|
448
|
+
return `${proto}://${host}`;
|
|
449
|
+
}
|
|
450
|
+
function makeDownloadTargetHandler(attachments, attachmentAccess) {
|
|
451
|
+
return async (req, res, _body, actor) => {
|
|
452
|
+
res.setHeader("Cache-Control", "no-store");
|
|
453
|
+
const hash = extractParam(req, "hash");
|
|
454
|
+
if (!hash || !HASH_PATTERN.test(hash)) {
|
|
455
|
+
sendError(res, 400, "Invalid attachment hash");
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const documentId = extractSingleDocumentId(req);
|
|
459
|
+
if (documentId === null) {
|
|
460
|
+
sendError(res, 400, "documentId is required exactly once as a non-empty query parameter");
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const expiresIn = extractExpiresIn(req);
|
|
464
|
+
if (expiresIn === "invalid") {
|
|
465
|
+
sendError(res, 400, "expiresIn must be a single positive integer number of seconds");
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const canonicalHash = hash.toLowerCase();
|
|
469
|
+
let decision;
|
|
470
|
+
try {
|
|
471
|
+
decision = await attachmentAccess.canReadAttachment({
|
|
472
|
+
documentId,
|
|
473
|
+
attachmentRef: createRef(canonicalHash),
|
|
474
|
+
userAddress: actor?.user?.address
|
|
475
|
+
});
|
|
476
|
+
} catch (err) {
|
|
477
|
+
logger$1.error("Attachment access decision failed: @error", err);
|
|
478
|
+
sendError(res, 500, "Internal error");
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (decision.kind === "projection-unavailable") {
|
|
482
|
+
sendError(res, 503, "Attachment downloads are temporarily unavailable");
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (decision.kind === "denied") {
|
|
486
|
+
sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
let header;
|
|
490
|
+
try {
|
|
491
|
+
header = await attachments.store.stat(canonicalHash);
|
|
492
|
+
} catch (err) {
|
|
493
|
+
if (err instanceof AttachmentNotFound) {
|
|
494
|
+
sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
logger$1.error("Attachment metadata lookup failed: @error", err);
|
|
498
|
+
sendError(res, 500, "Internal error");
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (header.status !== "available") {
|
|
502
|
+
sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
let target;
|
|
506
|
+
if (attachments.backend && attachments.backend.kind !== "filesystem") try {
|
|
507
|
+
target = await attachments.backend.prepareDownloadTarget(canonicalHash, expiresIn);
|
|
508
|
+
} catch {
|
|
509
|
+
sendError(res, 502, "Attachment download target unavailable");
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
const base = requestBaseUrl(req);
|
|
514
|
+
if (!base) {
|
|
515
|
+
sendError(res, 500, "Internal error");
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
try {
|
|
519
|
+
target = parseAttachmentDownloadTarget({
|
|
520
|
+
kind: "switchboard",
|
|
521
|
+
method: "GET",
|
|
522
|
+
url: `${base}/attachments/${canonicalHash}`,
|
|
523
|
+
headers: {}
|
|
524
|
+
});
|
|
525
|
+
} catch {
|
|
526
|
+
sendError(res, 500, "Internal error");
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
sendJson(res, 200, target);
|
|
531
|
+
};
|
|
532
|
+
}
|
|
373
533
|
//#endregion
|
|
374
534
|
//#region src/attachments/index.ts
|
|
375
535
|
function registerAttachmentRoutes(api) {
|
|
@@ -379,9 +539,50 @@ function registerAttachmentRoutes(api) {
|
|
|
379
539
|
mountAuthenticatedNodeRoute(api, "DELETE", "/attachments/reservations/:reservationId", makeDeleteReservationHandler(attachments));
|
|
380
540
|
mountAuthenticatedNodeRoute(api, "PUT", "/attachments/reservations/:reservationId", makeUploadHandler(attachments));
|
|
381
541
|
mountAuthenticatedNodeRoute(api, "HEAD", "/attachments/:hash", makeStatHandler(attachments));
|
|
542
|
+
mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash/download-target", makeDownloadTargetHandler(attachments, api.attachmentAccess), { allowAnonymous: true });
|
|
382
543
|
mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash", makeDownloadHandler(attachments));
|
|
383
544
|
}
|
|
384
545
|
//#endregion
|
|
546
|
+
//#region src/attachment-reference-read-model.mts
|
|
547
|
+
const attachmentSchemaCompiler = new AttachmentSchemaCompiler();
|
|
548
|
+
function createAttachmentReferenceReadModel(baseKysely, dependencies, attachmentReferenceWriter) {
|
|
549
|
+
return new AttachmentReferenceReadModel(baseKysely.withSchema(REACTOR_SCHEMA), dependencies.operationIndex, dependencies.writeCache, dependencies.processorManagerConsistencyTracker, dependencies.documentModelRegistry, attachmentSchemaCompiler, attachmentReferenceWriter);
|
|
550
|
+
}
|
|
551
|
+
function registerAttachmentReferenceReadModel(reactorBuilder, registration) {
|
|
552
|
+
reactorBuilder.withReadModelFactory(async ({ documentModelRegistry, operationIndex, writeCache, processorManagerConsistencyTracker }) => {
|
|
553
|
+
const readModel = createAttachmentReferenceReadModel(registration.baseKysely, {
|
|
554
|
+
operationIndex,
|
|
555
|
+
writeCache,
|
|
556
|
+
processorManagerConsistencyTracker,
|
|
557
|
+
documentModelRegistry
|
|
558
|
+
}, registration.attachmentReferenceWriter);
|
|
559
|
+
await readModel.init();
|
|
560
|
+
return readModel;
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
async function registerAttachmentReferenceReadModelOnModule(clientModule, attachmentReferenceWriter) {
|
|
564
|
+
const reactorModule = clientModule.reactorModule;
|
|
565
|
+
if (!reactorModule) return {
|
|
566
|
+
status: "unavailable",
|
|
567
|
+
reason: "in-process-reactor-module-unavailable"
|
|
568
|
+
};
|
|
569
|
+
const coordinator = reactorModule.readModelCoordinator;
|
|
570
|
+
if (!supportsLiveReadModelRegistration(coordinator)) return {
|
|
571
|
+
status: "unavailable",
|
|
572
|
+
reason: "live-read-model-registration-unsupported"
|
|
573
|
+
};
|
|
574
|
+
const readModel = createAttachmentReferenceReadModel(reactorModule.database, {
|
|
575
|
+
operationIndex: reactorModule.operationIndex,
|
|
576
|
+
writeCache: reactorModule.writeCache,
|
|
577
|
+
processorManagerConsistencyTracker: reactorModule.processorManagerConsistencyTracker,
|
|
578
|
+
documentModelRegistry: reactorModule.documentModelRegistry
|
|
579
|
+
}, attachmentReferenceWriter);
|
|
580
|
+
await readModel.init();
|
|
581
|
+
coordinator.addReadModel(readModel, "pre_ready");
|
|
582
|
+
await readModel.init();
|
|
583
|
+
return { status: "available" };
|
|
584
|
+
}
|
|
585
|
+
//#endregion
|
|
385
586
|
//#region src/builder-defaults.mts
|
|
386
587
|
/**
|
|
387
588
|
* Apply switchboard's standard configuration to a reactor + client builder
|
|
@@ -392,14 +593,18 @@ function registerAttachmentRoutes(api) {
|
|
|
392
593
|
* Does NOT touch kysely or read models — callers wire those themselves
|
|
393
594
|
* (see `withKysely` / `withReadModelFactory` on the reactor builder).
|
|
394
595
|
*/
|
|
395
|
-
|
|
396
|
-
|
|
596
|
+
/** The models switchboard always registers alongside package models. */
|
|
597
|
+
function switchboardBaseDocumentModels() {
|
|
598
|
+
return [
|
|
397
599
|
documentModelDocumentModelModule,
|
|
398
600
|
driveDocumentModelModule,
|
|
399
601
|
reactorDriveDocumentModelModule
|
|
400
|
-
]
|
|
602
|
+
];
|
|
603
|
+
}
|
|
604
|
+
function applySwitchboardReactorDefaults(reactorBuilder, clientBuilder, options = {}) {
|
|
605
|
+
const baseModels = options.includeBaseModels !== false ? switchboardBaseDocumentModels() : [];
|
|
401
606
|
const extra = options.documentModels ?? [];
|
|
402
|
-
if (baseModels.length || extra.length) reactorBuilder.
|
|
607
|
+
if (baseModels.length || extra.length) reactorBuilder.withDocumentModelSources(getUniqueDocumentModels(baseModels, extra));
|
|
403
608
|
const scheme = options.channelScheme === void 0 ? ChannelScheme.SWITCHBOARD : options.channelScheme;
|
|
404
609
|
if (scheme !== false) reactorBuilder.withChannelScheme(scheme);
|
|
405
610
|
if (options.signalHandlers !== false) reactorBuilder.withSignalHandlers();
|
|
@@ -409,6 +614,188 @@ function applySwitchboardReactorDefaults(reactorBuilder, clientBuilder, options
|
|
|
409
614
|
if (options.signer) clientBuilder.withSigner(options.signer);
|
|
410
615
|
}
|
|
411
616
|
//#endregion
|
|
617
|
+
//#region src/worker-pool.mts
|
|
618
|
+
const DEFAULT_DB_POOL_SIZE_PER_WORKER = 2;
|
|
619
|
+
const DEFAULT_ACQUIRE_TIMEOUT_MS = 5e3;
|
|
620
|
+
const AUTO_RESERVED_CORES = 2;
|
|
621
|
+
const AUTO_WORKER_CAP = 8;
|
|
622
|
+
/** Worker count for "auto": always at least 1 — auto sizes the pool, it does not turn worker mode off. */
|
|
623
|
+
function autoWorkerCount(availableCores) {
|
|
624
|
+
return Math.max(1, Math.min(AUTO_WORKER_CAP, availableCores - AUTO_RESERVED_CORES));
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Effective worker-pool config from programmatic options (which win) and the
|
|
628
|
+
* REACTOR_* env vars; null when disabled (numWorkers 0, the default).
|
|
629
|
+
* `"auto"` sizes the pool from the machine's available cores.
|
|
630
|
+
*/
|
|
631
|
+
function resolveWorkerPoolOptions(input, env, availableCores = os.availableParallelism()) {
|
|
632
|
+
const requested = input?.numWorkers ?? parseWorkerCount(env.REACTOR_WORKERS) ?? 0;
|
|
633
|
+
const mode = requested === "auto" ? "auto" : "explicit";
|
|
634
|
+
const numWorkers = requested === "auto" ? autoWorkerCount(availableCores) : requested;
|
|
635
|
+
if (!Number.isInteger(numWorkers) || numWorkers < 0) throw new Error(`workerPool.numWorkers must be "auto" or a non-negative integer, got ${numWorkers}`);
|
|
636
|
+
if (numWorkers === 0) return null;
|
|
637
|
+
return {
|
|
638
|
+
numWorkers,
|
|
639
|
+
mode,
|
|
640
|
+
dbPoolSizePerWorker: input?.dbPoolSizePerWorker ?? parseNonNegativeInt(env.REACTOR_DB_POOL_SIZE_WORKER, "REACTOR_DB_POOL_SIZE_WORKER") ?? DEFAULT_DB_POOL_SIZE_PER_WORKER,
|
|
641
|
+
acquireTimeoutMs: input?.acquireTimeoutMs ?? parseNonNegativeInt(env.REACTOR_DB_ACQUIRE_TIMEOUT_MS, "REACTOR_DB_ACQUIRE_TIMEOUT_MS") ?? DEFAULT_ACQUIRE_TIMEOUT_MS
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* DbConfig for each worker's own pool, parsed from a postgres:// URL.
|
|
646
|
+
* Explicit host, database, and user are required — no pg-side defaults.
|
|
647
|
+
*/
|
|
648
|
+
function buildWorkerDbConfig(postgresUrl, options) {
|
|
649
|
+
let parsed;
|
|
650
|
+
try {
|
|
651
|
+
parsed = new URL(postgresUrl);
|
|
652
|
+
} catch {
|
|
653
|
+
throw new Error(`Worker pool requires a valid postgres:// URL for the reactor database, got "${postgresUrl}"`);
|
|
654
|
+
}
|
|
655
|
+
const database = decodeURIComponent(parsed.pathname.replace(/^\//, ""));
|
|
656
|
+
if (!parsed.hostname || !database) throw new Error(`Worker pool requires a postgres URL with a host and database name, got "${postgresUrl}"`);
|
|
657
|
+
if (!parsed.username) throw new Error(`Worker pool requires explicit credentials in the postgres URL (workers open their own connections), got "${postgresUrl}"`);
|
|
658
|
+
const sslmode = parsed.searchParams.get("sslmode");
|
|
659
|
+
const ssl = parsed.searchParams.get("ssl") === "true" || sslmode !== null && sslmode !== "disable";
|
|
660
|
+
return {
|
|
661
|
+
host: parsed.hostname,
|
|
662
|
+
port: parsed.port ? Number(parsed.port) : 5432,
|
|
663
|
+
database,
|
|
664
|
+
user: decodeURIComponent(parsed.username),
|
|
665
|
+
password: decodeURIComponent(parsed.password),
|
|
666
|
+
ssl,
|
|
667
|
+
applicationName: "switchboard-worker",
|
|
668
|
+
poolSize: options.dbPoolSizePerWorker,
|
|
669
|
+
connectionTimeoutMillis: options.acquireTimeoutMs
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
/** Specifiers of the base models switchboard always registers. */
|
|
673
|
+
const BASE_MODEL_SPECIFIERS = [
|
|
674
|
+
"document-model",
|
|
675
|
+
"@powerhousedao/shared/document-drive",
|
|
676
|
+
"@powerhousedao/reactor-drive"
|
|
677
|
+
];
|
|
678
|
+
/**
|
|
679
|
+
* Resolves base models and configured package identifiers to `{ filePath }`
|
|
680
|
+
* sources for the reactor builder. Resolution to absolute paths happens here
|
|
681
|
+
* because workers (and the builder) resolve bare specifiers from the reactor
|
|
682
|
+
* package's own dependency context, which cannot see switchboard or project
|
|
683
|
+
* packages. Unresolvable identifiers are skipped with a warning; the builder
|
|
684
|
+
* fails the boot if a registered model ends up without an importable source.
|
|
685
|
+
*/
|
|
686
|
+
async function resolveWorkerModelSources(packages, logger) {
|
|
687
|
+
const sources = [];
|
|
688
|
+
for (const identifier of [...BASE_MODEL_SPECIFIERS, ...packages]) {
|
|
689
|
+
const filePath = await resolveModelModuleFile(identifier);
|
|
690
|
+
if (!filePath) {
|
|
691
|
+
logger.warn(`Worker model sources: no importable document-models entry for "${identifier}", skipping`);
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
sources.push({ filePath });
|
|
695
|
+
}
|
|
696
|
+
return sources;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Absolute file path of a source's models barrel. Mirrors reactor-api's
|
|
700
|
+
* import-resolver: import.meta.resolve, then the project's node_modules via
|
|
701
|
+
* the package.json exports map / dist layout.
|
|
702
|
+
*/
|
|
703
|
+
async function resolveModelModuleFile(source) {
|
|
704
|
+
if (isFsPath(source)) return resolveFromPackageDir(source, "./document-models");
|
|
705
|
+
const specifier = BASE_MODEL_SPECIFIERS.includes(source) ? source : `${source}/document-models`;
|
|
706
|
+
try {
|
|
707
|
+
const resolved = import.meta.resolve(specifier);
|
|
708
|
+
if (resolved.startsWith("file:")) return fileURLToPath(resolved);
|
|
709
|
+
} catch {}
|
|
710
|
+
const { packageName, subpath } = splitSpecifier(specifier);
|
|
711
|
+
const packageDir = path.join(process.cwd(), "node_modules", packageName);
|
|
712
|
+
if (!existsSync(packageDir)) return null;
|
|
713
|
+
let realDir;
|
|
714
|
+
try {
|
|
715
|
+
realDir = realpathSync(packageDir);
|
|
716
|
+
} catch {
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
return resolveFromPackageDir(realDir, subpath);
|
|
720
|
+
}
|
|
721
|
+
function splitSpecifier(specifier) {
|
|
722
|
+
const parts = specifier.split("/");
|
|
723
|
+
const packageSegments = specifier.startsWith("@") ? 2 : 1;
|
|
724
|
+
const packageName = parts.slice(0, packageSegments).join("/");
|
|
725
|
+
const rest = parts.slice(packageSegments).join("/");
|
|
726
|
+
return {
|
|
727
|
+
packageName,
|
|
728
|
+
subpath: rest ? `./${rest}` : "."
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
/** Exports-map lookup (import condition), then conventional dist paths. */
|
|
732
|
+
async function resolveFromPackageDir(packageDir, subpath) {
|
|
733
|
+
const fromManifest = await resolveViaManifest(packageDir, subpath);
|
|
734
|
+
if (fromManifest) return fromManifest;
|
|
735
|
+
const candidates = subpath === "." ? [path.join(packageDir, "dist", "index.js")] : [
|
|
736
|
+
path.join(packageDir, "dist", subpath.slice(2), "index.js"),
|
|
737
|
+
path.join(packageDir, "dist", `${subpath.slice(2)}.js`),
|
|
738
|
+
path.join(packageDir, subpath.slice(2), "index.js")
|
|
739
|
+
];
|
|
740
|
+
for (const candidate of candidates) if (existsSync(candidate)) return candidate;
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
async function resolveViaManifest(packageDir, subpath) {
|
|
744
|
+
let raw;
|
|
745
|
+
try {
|
|
746
|
+
raw = await readFile(path.join(packageDir, "package.json"), "utf8");
|
|
747
|
+
} catch {
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
let manifest;
|
|
751
|
+
try {
|
|
752
|
+
manifest = JSON.parse(raw);
|
|
753
|
+
} catch {
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
let entry;
|
|
757
|
+
const exportsField = manifest.exports;
|
|
758
|
+
if (exportsField !== null && typeof exportsField === "object" && !Array.isArray(exportsField)) {
|
|
759
|
+
const map = exportsField;
|
|
760
|
+
entry = Object.keys(map).some((key) => key.startsWith(".")) ? map[subpath] : subpath === "." ? map : void 0;
|
|
761
|
+
} else if (typeof exportsField === "string" && subpath === ".") entry = exportsField;
|
|
762
|
+
let target = pickImportTarget(entry);
|
|
763
|
+
if (!target && subpath === "." && typeof manifest.main === "string") target = manifest.main;
|
|
764
|
+
if (!target) return null;
|
|
765
|
+
const resolved = path.join(packageDir, target);
|
|
766
|
+
return existsSync(resolved) ? resolved : null;
|
|
767
|
+
}
|
|
768
|
+
function pickImportTarget(entry) {
|
|
769
|
+
if (typeof entry === "string") return entry;
|
|
770
|
+
if (typeof entry !== "object" || entry === null) return null;
|
|
771
|
+
const conditions = entry;
|
|
772
|
+
for (const condition of [
|
|
773
|
+
"import",
|
|
774
|
+
"node",
|
|
775
|
+
"default"
|
|
776
|
+
]) {
|
|
777
|
+
const value = conditions[condition];
|
|
778
|
+
if (typeof value === "string") return value;
|
|
779
|
+
}
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
function isFsPath(identifier) {
|
|
783
|
+
return path.isAbsolute(identifier) || identifier.startsWith("./") || identifier.startsWith("../");
|
|
784
|
+
}
|
|
785
|
+
function parseWorkerCount(raw) {
|
|
786
|
+
if (raw === void 0 || raw.trim() === "") return;
|
|
787
|
+
if (raw.trim().toLowerCase() === "auto") return "auto";
|
|
788
|
+
const value = Number.parseInt(raw, 10);
|
|
789
|
+
if (!Number.isInteger(value) || value < 0 || String(value) !== raw.trim()) throw new Error(`REACTOR_WORKERS must be "auto" or a non-negative integer, got "${raw}"`);
|
|
790
|
+
return value;
|
|
791
|
+
}
|
|
792
|
+
function parseNonNegativeInt(raw, name) {
|
|
793
|
+
if (raw === void 0 || raw.trim() === "") return;
|
|
794
|
+
const value = Number.parseInt(raw, 10);
|
|
795
|
+
if (!Number.isInteger(value) || value < 0 || String(value) !== raw.trim()) throw new Error(`${name} must be a non-negative integer, got "${raw}"`);
|
|
796
|
+
return value;
|
|
797
|
+
}
|
|
798
|
+
//#endregion
|
|
412
799
|
//#region src/feature-flags.ts
|
|
413
800
|
async function initFeatureFlags() {
|
|
414
801
|
const provider = new EnvVarProvider();
|
|
@@ -697,6 +1084,15 @@ async function initServer(serverPort, options, renown) {
|
|
|
697
1084
|
}
|
|
698
1085
|
const reactorPgliteMajor = reactorPgliteDir ? resolvePgliteMajorForDir(reactorPgliteDir) : null;
|
|
699
1086
|
const readModelPgliteMajor = readModelPgliteDir ? resolvePgliteMajorForDir(readModelPgliteDir) : null;
|
|
1087
|
+
let workerPool = resolveWorkerPoolOptions(options.workerPool, process.env);
|
|
1088
|
+
if (workerPool && options.reactor) {
|
|
1089
|
+
logger.warn("Worker pool configuration ignored: the caller-provided reactor owns its own executor");
|
|
1090
|
+
workerPool = null;
|
|
1091
|
+
}
|
|
1092
|
+
if (workerPool) {
|
|
1093
|
+
if (dev) throw new Error("The executor worker pool (REACTOR_WORKERS) is not supported in dev mode: Vite-loaded document models cannot cross a worker-thread boundary");
|
|
1094
|
+
if (!reactorDbUrl || !isPostgresUrl(reactorDbUrl)) throw new Error("The executor worker pool (REACTOR_WORKERS) requires a Postgres reactor database — set PH_REACTOR_DATABASE_URL or PH_SWITCHBOARD_DATABASE_URL. PGlite cannot be shared across worker threads.");
|
|
1095
|
+
}
|
|
700
1096
|
const apiRef = { current: void 0 };
|
|
701
1097
|
let driveNodeView;
|
|
702
1098
|
const config = getConfig(options.configFile ?? path$1.join(process.cwd(), "powerhouse.config.json"));
|
|
@@ -713,13 +1109,17 @@ async function initServer(serverPort, options, renown) {
|
|
|
713
1109
|
});
|
|
714
1110
|
}
|
|
715
1111
|
const reactorLogger = logger.child(["reactor"]);
|
|
716
|
-
const initializeClient = async (documentModels) => {
|
|
1112
|
+
const initializeClient = async (documentModels, { attachmentReferenceWriter }) => {
|
|
717
1113
|
if (options.reactor) {
|
|
1114
|
+
const attachmentReferenceProjection = await registerAttachmentReferenceReadModelOnModule(options.reactor, attachmentReferenceWriter);
|
|
718
1115
|
if (options.reactor.reactorModule) {
|
|
719
1116
|
new ReactorInstrumentation(options.reactor.reactorModule).start();
|
|
720
1117
|
reactorLogger.info("Reactor metrics instrumentation started (using caller-provided reactor)");
|
|
721
1118
|
}
|
|
722
|
-
return {
|
|
1119
|
+
return {
|
|
1120
|
+
module: options.reactor,
|
|
1121
|
+
attachmentReferenceProjection
|
|
1122
|
+
};
|
|
723
1123
|
}
|
|
724
1124
|
const baseKysely = await createReactorKysely({
|
|
725
1125
|
reactorDbUrl,
|
|
@@ -732,6 +1132,7 @@ async function initServer(serverPort, options, renown) {
|
|
|
732
1132
|
const maxSkipThreshold = parseInt(process.env.MAX_SKIP_THRESHOLD ?? "", 10);
|
|
733
1133
|
const hasSkipThreshold = !isNaN(maxSkipThreshold) && maxSkipThreshold > 0;
|
|
734
1134
|
if (hasSkipThreshold) logger.info(`Reactor maxSkipThreshold set to ${maxSkipThreshold}`);
|
|
1135
|
+
if (hasSkipThreshold && workerPool) logger.warn("MAX_SKIP_THRESHOLD is not forwarded to executor workers and has no effect in worker-pool mode");
|
|
735
1136
|
const reactorBuilder = new ReactorBuilder().withEventBus(new EventBus()).withKysely(baseKysely);
|
|
736
1137
|
const clientBuilder = new ReactorClientBuilder().withReactorBuilder(reactorBuilder);
|
|
737
1138
|
const vetraDocumentModels = dev ? Object.values(await import("@powerhousedao/vetra/document-models")).filter((m) => typeof m === "object" && m !== null && "documentModel" in m && "reducer" in m) : [];
|
|
@@ -742,11 +1143,24 @@ async function initServer(serverPort, options, renown) {
|
|
|
742
1143
|
logger: reactorLogger,
|
|
743
1144
|
signer: renown ? getRenownSignerConfig(renown, options.identity?.requireSignatures) : void 0
|
|
744
1145
|
});
|
|
1146
|
+
if (workerPool) {
|
|
1147
|
+
if (!reactorDbUrl) throw new Error("unreachable: worker pool enabled without a reactor database URL");
|
|
1148
|
+
const workerSources = await resolveWorkerModelSources(packages, reactorLogger);
|
|
1149
|
+
reactorBuilder.withDocumentModelSources(workerSources).withWorkerPool({
|
|
1150
|
+
numWorkers: workerPool.numWorkers,
|
|
1151
|
+
db: buildWorkerDbConfig(reactorDbUrl, workerPool)
|
|
1152
|
+
});
|
|
1153
|
+
reactorLogger.info(`Executor worker pool enabled: ${workerPool.numWorkers} worker threads${workerPool.mode === "auto" ? " (auto-sized from cores)" : ""}`);
|
|
1154
|
+
}
|
|
745
1155
|
reactorBuilder.withReadModelFactory(async ({ operationIndex, writeCache, processorManagerConsistencyTracker }) => {
|
|
746
1156
|
const nodeProcessor = new NodeProcessor(baseKysely, REACTOR_SCHEMA, operationIndex, writeCache, processorManagerConsistencyTracker);
|
|
747
1157
|
await nodeProcessor.init();
|
|
748
1158
|
return nodeProcessor;
|
|
749
1159
|
});
|
|
1160
|
+
registerAttachmentReferenceReadModel(reactorBuilder, {
|
|
1161
|
+
baseKysely,
|
|
1162
|
+
attachmentReferenceWriter
|
|
1163
|
+
});
|
|
750
1164
|
reactorBuilder.withShutdownHook(async () => {
|
|
751
1165
|
if (apiRef.current) await apiRef.current.dispose();
|
|
752
1166
|
});
|
|
@@ -761,7 +1175,8 @@ async function initServer(serverPort, options, renown) {
|
|
|
761
1175
|
reactorDriveClient: new ReactorDriveClient({
|
|
762
1176
|
reactor: module.client,
|
|
763
1177
|
readModel: driveNodeView
|
|
764
|
-
})
|
|
1178
|
+
}),
|
|
1179
|
+
attachmentReferenceProjection: { status: "available" }
|
|
765
1180
|
};
|
|
766
1181
|
};
|
|
767
1182
|
let defaultDriveUrl = void 0;
|
|
@@ -892,6 +1307,7 @@ async function initServer(serverPort, options, renown) {
|
|
|
892
1307
|
api,
|
|
893
1308
|
reactor: client,
|
|
894
1309
|
attachmentService,
|
|
1310
|
+
attachmentReferenceProjection: api.attachmentReferenceProjection,
|
|
895
1311
|
renown,
|
|
896
1312
|
port: serverPort,
|
|
897
1313
|
shutdown: () => api.dispose()
|
|
@@ -949,5 +1365,5 @@ if (import.meta.main) await startSwitchboard();
|
|
|
949
1365
|
//#endregion
|
|
950
1366
|
export { parseForcePgVersion as a, applySwitchboardReactorDefaults as i, isPortAvailable as n, startSwitchboard as r, deriveAttachmentServiceConfig as t };
|
|
951
1367
|
|
|
952
|
-
//# sourceMappingURL=server-
|
|
953
|
-
//# debugId=
|
|
1368
|
+
//# sourceMappingURL=server-ChK0GLG8.mjs.map
|
|
1369
|
+
//# debugId=b1867ed0-059f-587c-9a5f-677da3666de7
|