@powerhousedao/switchboard 6.2.2-dev.2 → 6.2.2-dev.20

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.
@@ -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]="8a8055ab-2ceb-59fe-9007-f1d1fde8a019")}catch(e){}}();
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), `requireAuth` returns the
102
- * handler unchanged that is the only way to opt out. To register a route
103
- * without auth wrapping you must call `api.httpAdapter.mountNodeRoute` directly.
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
- function applySwitchboardReactorDefaults(reactorBuilder, clientBuilder, options = {}) {
396
- const baseModels = options.includeBaseModels !== false ? [
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.withDocumentModels(getUniqueDocumentModels(baseModels, extra));
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 { module: options.reactor };
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-DgBz3TJW.mjs.map
953
- //# debugId=8a8055ab-2ceb-59fe-9007-f1d1fde8a019
1368
+ //# sourceMappingURL=server-ChK0GLG8.mjs.map
1369
+ //# debugId=b1867ed0-059f-587c-9a5f-677da3666de7