@powerhousedao/switchboard 6.2.3-dev.5 → 6.2.3-dev.51
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/attachments/byte-grants.test.d.ts +2 -0
- package/.tsbuild/test/attachments/byte-grants.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts +2 -0
- package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts +2 -0
- package/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/url-signer.test.d.ts +2 -0
- package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-create-signature-policy.test.d.ts +2 -0
- package/.tsbuild/test/boot-create-signature-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-mcp-enabled.test.d.ts +2 -0
- package/.tsbuild/test/boot-mcp-enabled.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-privacy.test.d.ts +2 -0
- package/.tsbuild/test/boot-privacy.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts +2 -0
- package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-without-renown.test.d.ts +2 -0
- package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
- package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
- package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-workflow-singleton-conflict.test.d.ts +2 -0
- package/.tsbuild/test/boot-workflow-singleton-conflict.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-workflow-singleton-retry.test.d.ts +2 -0
- package/.tsbuild/test/boot-workflow-singleton-retry.test.d.ts.map +1 -0
- package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
- package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
- package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
- package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/durable-node-fs-pg16.test.d.ts +2 -0
- package/.tsbuild/test/durable-node-fs-pg16.test.d.ts.map +1 -0
- package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
- package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
- package/.tsbuild/test/mcp-flag.test.d.ts +2 -0
- package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
- package/.tsbuild/test/observability-signals.test.d.ts +2 -0
- package/.tsbuild/test/observability-signals.test.d.ts.map +1 -0
- package/.tsbuild/test/pglite-preflight.test.d.ts +2 -0
- package/.tsbuild/test/pglite-preflight.test.d.ts.map +1 -0
- package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
- package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/snapshot-writer.d.ts +18 -0
- package/.tsbuild/test/snapshot-writer.d.ts.map +1 -0
- package/.tsbuild/test/unsupported-stored-documents.test.d.ts +2 -0
- package/.tsbuild/test/unsupported-stored-documents.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/harness.d.ts +56 -0
- package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
- package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
- package/.tsbuild/test/workflow/oauth.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-reactor-access.test.d.ts +2 -0
- package/.tsbuild/test/workflow-reactor-access.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts +2 -0
- package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
- package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/.tsbuild/tsdown.config.d.ts.map +1 -1
- package/.tsbuild/vitest.config.d.ts.map +1 -1
- package/Auth.md +94 -0
- package/CHANGELOG.md +541 -0
- package/README.md +35 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +48 -49
- package/dist/index.mjs.map +1 -1
- package/dist/install-packages.d.mts +2 -1
- package/dist/install-packages.mjs +2 -2
- package/dist/install-packages.mjs.map +1 -1
- package/dist/migrate.d.mts +2 -1
- package/dist/migrate.mjs +4 -3
- package/dist/migrate.mjs.map +1 -1
- package/dist/server-J9fpd42v.mjs +3549 -0
- package/dist/server-J9fpd42v.mjs.map +1 -0
- package/dist/server.d.mts +155 -30
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +4 -5
- package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
- package/dist/utils-uaBlzyjS.mjs.map +1 -0
- package/dist/utils.d.mts +4 -5
- package/dist/utils.d.mts.map +1 -1
- package/dist/utils.mjs +3 -3
- package/package.json +22 -18
- package/test/attachment-reference-read-model.test.ts +6 -1
- package/test/attachments/auth.test.ts +85 -0
- package/test/attachments/byte-grants.test.ts +290 -0
- package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
- package/test/attachments/download-target.test.ts +201 -32
- package/test/attachments/index.test.ts +34 -3
- package/test/attachments/reserve-metadata-parity.test.ts +96 -0
- package/test/attachments/routes-integration.test.ts +60 -4
- package/test/attachments/routes.test.ts +166 -23
- package/test/attachments/service-config.test.ts +13 -3
- package/test/attachments/url-signer.test.ts +105 -0
- package/test/boot-create-signature-policy.test.ts +108 -0
- package/test/boot-mcp-enabled.test.ts +121 -0
- package/test/boot-privacy.test.ts +243 -0
- package/test/boot-unsupported-stored-documents.test.ts +213 -0
- package/test/boot-without-renown.test.ts +81 -0
- package/test/boot-without-require-signatures.test.ts +74 -0
- package/test/boot-worker-trust-policy.test.ts +90 -0
- package/test/boot-workflow-singleton-conflict.test.ts +107 -0
- package/test/boot-workflow-singleton-retry.test.ts +133 -0
- package/test/builder-defaults.test.ts +80 -0
- package/test/create-signature-policy.test.ts +69 -0
- package/test/durable-node-fs-pg16.test.ts +80 -0
- package/test/fatal-shutdown.test.ts +165 -0
- package/test/mcp-flag.test.ts +56 -0
- package/test/observability-signals.test.ts +131 -0
- package/test/pglite-dialect.test.ts +217 -2
- package/test/pglite-preflight.test.ts +313 -0
- package/test/pglite-version.test.ts +9 -1
- package/test/renown-trust-policy.test.ts +315 -0
- package/test/snapshot-writer.ts +94 -0
- package/test/unsupported-stored-documents.test.ts +72 -0
- package/test/worker-pool.test.ts +27 -1
- package/test/workflow/harness.ts +289 -0
- package/test/workflow/oauth.test.ts +214 -0
- package/test/workflow/resolvers.test.ts +357 -0
- package/test/workflow/webhook-dedupe.test.ts +295 -0
- package/test/workflow/webhook-signed.test.ts +509 -0
- package/test/workflow/webhook-trigger-test.test.ts +82 -0
- package/test/workflow/webhook-unsigned.test.ts +345 -0
- package/test/workflow-reactor-access.test.ts +384 -0
- package/test/workflow-reactor-enforcement.test.ts +808 -0
- package/test/workflow-read-gate.test.ts +213 -0
- package/test/workflow-runtime.test.ts +1067 -0
- package/tsconfig.json +9 -3
- package/tsdown.config.ts +3 -1
- package/dist/server-CiKkVE6g.mjs +0 -1555
- package/dist/server-CiKkVE6g.mjs.map +0 -1
- package/dist/utils-Baw7rThP.mjs.map +0 -1
package/dist/server-CiKkVE6g.mjs
DELETED
|
@@ -1,1555 +0,0 @@
|
|
|
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]="e2c62b7b-68fd-59c2-ab82-7d035e11698b")}catch(e){}}();
|
|
3
|
-
import { n as addDefaultReactorDrive, r as isPostgresUrl, t as addDefaultDrive } from "./utils-Baw7rThP.mjs";
|
|
4
|
-
import { register } from "node:module";
|
|
5
|
-
import { ReactorInstrumentation } from "@powerhousedao/opentelemetry-instrumentation-reactor";
|
|
6
|
-
import * as Sentry from "@sentry/node";
|
|
7
|
-
import { childLogger, documentModelDocumentModelModule, setLogLevel } from "document-model";
|
|
8
|
-
import dotenv from "dotenv";
|
|
9
|
-
import { getConfig } from "@powerhousedao/config/node";
|
|
10
|
-
import { existsSync, promises, realpathSync } from "node:fs";
|
|
11
|
-
import path from "node:path";
|
|
12
|
-
import { AtomicNodeFs } from "@powerhousedao/pglite-fs";
|
|
13
|
-
import { ChannelScheme, DriveCollectionId, EventBus, REACTOR_SCHEMA, ReactorBuilder, ReactorClientBuilder, createHybridProjectionCoordinatorFactory, instrumentPgPool, parseDriveUrl, supportsLiveReadModelRegistration } from "@powerhousedao/reactor";
|
|
14
|
-
import { HttpPackageLoader, ImportPackageLoader, PGLITE_UTC_PARSERS, PackageManagementService, PackagesSubgraph, getUniqueDocumentModels, initializeAndStartAPI, resolveRenownConfig } from "@powerhousedao/reactor-api";
|
|
15
|
-
import { httpsHooksPath } from "@powerhousedao/reactor-api/https-hooks";
|
|
16
|
-
import { AttachmentAlreadyExists, AttachmentNotFound, AttachmentPending, AttachmentReferenceReadModel, AttachmentSchemaCompiler, HashMismatch, InvalidAttachmentRef, ReservationNotFound, SizeMismatch, UploadTooLarge, createRef, createRemoteAttachmentService, parseAttachmentDownloadTarget } from "@powerhousedao/reactor-attachments";
|
|
17
|
-
import { DriveNodeView, NodeProcessor, ReactorDriveClient, createReactorDriveResolvers, reactorDriveDocumentModelModule, reactorDriveSubgraphTypeDefs } from "@powerhousedao/reactor-drive";
|
|
18
|
-
import { DEFAULT_RENOWN_URL, NodeKeyStorage, RENOWN_READ_MODEL_SUBGRAPH, RenownBuilder, RenownCryptoBuilder, createLocalCredentialVerifier, createSignatureVerifier } from "@renown/sdk/node";
|
|
19
|
-
import { Kysely, PostgresDialect } from "kysely";
|
|
20
|
-
import net from "node:net";
|
|
21
|
-
import path$1 from "path";
|
|
22
|
-
import { Pool } from "pg";
|
|
23
|
-
import { Readable } from "node:stream";
|
|
24
|
-
import { ReactorGroupV1 } from "@powerhousedao/reactor-group";
|
|
25
|
-
import { driveDocumentModelModule } from "@powerhousedao/shared/document-drive";
|
|
26
|
-
import { readFile } from "node:fs/promises";
|
|
27
|
-
import os from "node:os";
|
|
28
|
-
import { fileURLToPath } from "node:url";
|
|
29
|
-
import { EnvVarProvider } from "@openfeature/env-var-provider";
|
|
30
|
-
import { OpenFeature } from "@openfeature/server-sdk";
|
|
31
|
-
import { PGliteDialect } from "kysely-pglite-dialect";
|
|
32
|
-
//#region src/pglite-version.ts
|
|
33
|
-
const SUPPORTED_PG_MAJORS = [16, 17];
|
|
34
|
-
async function readPgVersionFile(dataDir) {
|
|
35
|
-
try {
|
|
36
|
-
const raw = await promises.readFile(path.join(dataDir, "PG_VERSION"), "utf8");
|
|
37
|
-
const major = parseInt(raw.trim(), 10);
|
|
38
|
-
return Number.isFinite(major) ? major : null;
|
|
39
|
-
} catch {
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
function isSupportedMajor(major) {
|
|
44
|
-
return SUPPORTED_PG_MAJORS.includes(major);
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* Parses the `PH_FORCE_PG_VERSION` env var. Returns the validated major, or
|
|
48
|
-
* `null` when the var is unset/empty. Throws on any value that is not a
|
|
49
|
-
* supported major — invalid configuration must fail before the server starts
|
|
50
|
-
* touching disk.
|
|
51
|
-
*/
|
|
52
|
-
function parseForcePgVersion(raw) {
|
|
53
|
-
if (raw === void 0 || raw.trim() === "") return null;
|
|
54
|
-
const parsed = Number(raw);
|
|
55
|
-
if (Number.isInteger(parsed) && isSupportedMajor(parsed)) return parsed;
|
|
56
|
-
throw new Error(`PH_FORCE_PG_VERSION must be one of: ${SUPPORTED_PG_MAJORS.join(", ")} (got: ${raw})`);
|
|
57
|
-
}
|
|
58
|
-
async function loadPGliteModule(major) {
|
|
59
|
-
if (major === 16) return await import("pglite-legacy-02");
|
|
60
|
-
return import("@electric-sql/pglite");
|
|
61
|
-
}
|
|
62
|
-
async function loadPgDump(major) {
|
|
63
|
-
if (major === 16) return (await import("pglite-tools-legacy-02/pg_dump")).pgDump;
|
|
64
|
-
return (await import("@electric-sql/pglite-tools/pg_dump")).pgDump;
|
|
65
|
-
}
|
|
66
|
-
//#endregion
|
|
67
|
-
//#region src/attachments/auth.ts
|
|
68
|
-
const ANONYMOUS_ACTOR = {
|
|
69
|
-
user: void 0,
|
|
70
|
-
authEnabled: false
|
|
71
|
-
};
|
|
72
|
-
/**
|
|
73
|
-
* Wrap a Node-style handler so that, when `authService` is provided and auth is
|
|
74
|
-
* enabled, the request must carry a verifiable Bearer token. The handler always
|
|
75
|
-
* receives an actor context: the verified bearer user when auth is enabled, or
|
|
76
|
-
* the anonymous context when it is disabled. With `allowAnonymous`, a missing
|
|
77
|
-
* bearer yields an anonymous actor with `authEnabled: true` instead of a 401.
|
|
78
|
-
*/
|
|
79
|
-
function requireAuth(authService, handler, options) {
|
|
80
|
-
if (!authService) return (req, res, body) => handler(req, res, body, ANONYMOUS_ACTOR);
|
|
81
|
-
return async (req, res, body) => {
|
|
82
|
-
let result;
|
|
83
|
-
try {
|
|
84
|
-
result = await authService.verifyBearer(req.headers.authorization);
|
|
85
|
-
} catch {
|
|
86
|
-
res.statusCode = 500;
|
|
87
|
-
res.setHeader("Content-Type", "application/json");
|
|
88
|
-
res.end(JSON.stringify({ error: "Internal authentication error" }));
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
if (result instanceof Response) {
|
|
92
|
-
const body = await result.text();
|
|
93
|
-
res.statusCode = result.status;
|
|
94
|
-
const contentType = result.headers.get("content-type");
|
|
95
|
-
if (contentType) res.setHeader("Content-Type", contentType);
|
|
96
|
-
res.end(body);
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
if (result.auth_enabled && !result.user && !options?.allowAnonymous) {
|
|
100
|
-
res.statusCode = 401;
|
|
101
|
-
res.setHeader("Content-Type", "application/json");
|
|
102
|
-
res.end(JSON.stringify({ error: "Authentication required" }));
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
await handler(req, res, body, {
|
|
106
|
-
user: result.user,
|
|
107
|
-
authEnabled: result.auth_enabled
|
|
108
|
-
});
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
//#endregion
|
|
112
|
-
//#region src/attachments/mount-auth.ts
|
|
113
|
-
/**
|
|
114
|
-
* Mount a Node-style attachment route with `requireAuth` applied unconditionally.
|
|
115
|
-
* When `api.authService` is undefined (auth disabled), the handler still runs
|
|
116
|
-
* through `requireAuth` and receives the anonymous actor context — that is the
|
|
117
|
-
* only way to opt out of bearer verification. To register a route without auth
|
|
118
|
-
* wrapping you must call `api.httpAdapter.mountNodeRoute` directly.
|
|
119
|
-
*
|
|
120
|
-
* `allowAnonymous` is reserved for routes whose handlers make a per-document
|
|
121
|
-
* authorization decision themselves; identity-only routes must not use it.
|
|
122
|
-
*/
|
|
123
|
-
function mountAuthenticatedNodeRoute(api, method, path, handler, options) {
|
|
124
|
-
api.httpAdapter.mountNodeRoute(method, path, requireAuth(api.authService, handler, options));
|
|
125
|
-
}
|
|
126
|
-
//#endregion
|
|
127
|
-
//#region src/attachments/routes.ts
|
|
128
|
-
const logger$1 = childLogger(["switchboard", "attachments"]);
|
|
129
|
-
const RETRY_AFTER_SECONDS = 5;
|
|
130
|
-
const HASH_PATTERN = /^[a-f0-9]{64}$/i;
|
|
131
|
-
const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
|
|
132
|
-
const MIME_TYPE_PATTERN = /^[!#$%&'*+\-.^_`|~\w]+\/[!#$%&'*+\-.^_`|~\w]+(?:\s*;\s*[!#$%&'*+\-.^_`|~\w]+=(?:[!#$%&'*+\-.^_`|~\w]+|"(?:[^"\\\r\n]|\\[^\r\n])*"))*$/;
|
|
133
|
-
const MAX_FILENAME_LEN = 255;
|
|
134
|
-
const MAX_MIMETYPE_LEN = 255;
|
|
135
|
-
function sendJson(res, status, body) {
|
|
136
|
-
res.statusCode = status;
|
|
137
|
-
res.setHeader("Content-Type", "application/json");
|
|
138
|
-
res.end(JSON.stringify(body));
|
|
139
|
-
}
|
|
140
|
-
function sendError(res, status, message) {
|
|
141
|
-
sendJson(res, status, { error: message });
|
|
142
|
-
}
|
|
143
|
-
function statusForError(err) {
|
|
144
|
-
if (err instanceof AttachmentNotFound) return 404;
|
|
145
|
-
if (err instanceof ReservationNotFound) return 404;
|
|
146
|
-
if (err instanceof InvalidAttachmentRef) return 400;
|
|
147
|
-
if (err instanceof UploadTooLarge) return 413;
|
|
148
|
-
return 500;
|
|
149
|
-
}
|
|
150
|
-
function sendErrorFromException(res, err) {
|
|
151
|
-
const status = statusForError(err);
|
|
152
|
-
if (status >= 500) {
|
|
153
|
-
logger$1.error("Attachment route error: @error", err);
|
|
154
|
-
sendError(res, status, "Internal error");
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
sendError(res, status, err instanceof Error ? err.message : String(err));
|
|
158
|
-
}
|
|
159
|
-
async function readJsonBody(req, body) {
|
|
160
|
-
if (body !== void 0 && body !== null && typeof body === "object") return body;
|
|
161
|
-
const chunks = [];
|
|
162
|
-
for await (const chunk of req) chunks.push(chunk);
|
|
163
|
-
if (chunks.length === 0) return void 0;
|
|
164
|
-
const text = Buffer.concat(chunks).toString("utf8");
|
|
165
|
-
if (text.length === 0) return void 0;
|
|
166
|
-
return JSON.parse(text);
|
|
167
|
-
}
|
|
168
|
-
function parseReserveOptions(input) {
|
|
169
|
-
if (input === null || typeof input !== "object") return null;
|
|
170
|
-
const obj = input;
|
|
171
|
-
if (typeof obj.mimeType !== "string" || obj.mimeType.length === 0 || obj.mimeType.length > MAX_MIMETYPE_LEN || !MIME_TYPE_PATTERN.test(obj.mimeType)) return null;
|
|
172
|
-
if (typeof obj.fileName !== "string" || obj.fileName.length === 0 || obj.fileName.length > MAX_FILENAME_LEN || CONTROL_CHARS.test(obj.fileName)) return null;
|
|
173
|
-
let extension = null;
|
|
174
|
-
if (typeof obj.extension === "string") {
|
|
175
|
-
if (obj.extension.length === 0 || /[\\/]/.test(obj.extension)) return null;
|
|
176
|
-
extension = obj.extension;
|
|
177
|
-
} else if (obj.extension !== void 0 && obj.extension !== null) return null;
|
|
178
|
-
if (obj.clientHash !== void 0) {
|
|
179
|
-
if (typeof obj.clientHash !== "string" || !HASH_PATTERN.test(obj.clientHash)) return null;
|
|
180
|
-
if (typeof obj.sizeBytes !== "number" || !Number.isInteger(obj.sizeBytes) || obj.sizeBytes <= 0 || !Number.isSafeInteger(obj.sizeBytes)) return null;
|
|
181
|
-
return {
|
|
182
|
-
mimeType: obj.mimeType,
|
|
183
|
-
fileName: obj.fileName,
|
|
184
|
-
extension,
|
|
185
|
-
clientHash: obj.clientHash.toLowerCase(),
|
|
186
|
-
sizeBytes: obj.sizeBytes
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
return {
|
|
190
|
-
mimeType: obj.mimeType,
|
|
191
|
-
fileName: obj.fileName,
|
|
192
|
-
extension
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
function quoteFilename(name) {
|
|
196
|
-
return `"${name.replace(/[\\"]/g, "\\$&")}"`;
|
|
197
|
-
}
|
|
198
|
-
function buildContentDisposition(fileName) {
|
|
199
|
-
const ascii = fileName.replace(/[^\x20-\x21\x23-\x5b\x5d-\x7e]/g, "_");
|
|
200
|
-
const encoded = encodeURIComponent(fileName).replace(/['()*!]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
201
|
-
return `attachment; filename=${quoteFilename(ascii)}; filename*=UTF-8''${encoded}`;
|
|
202
|
-
}
|
|
203
|
-
function makeReserveHandler(attachments) {
|
|
204
|
-
return async (req, res, body) => {
|
|
205
|
-
let parsed;
|
|
206
|
-
try {
|
|
207
|
-
parsed = await readJsonBody(req, body);
|
|
208
|
-
} catch {
|
|
209
|
-
sendError(res, 400, "Invalid JSON body");
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
const opts = parseReserveOptions(parsed);
|
|
213
|
-
if (!opts) {
|
|
214
|
-
sendError(res, 400, "Body must be { mimeType: string (type/subtype), fileName: string (no control characters, max 255 chars), extension?: string|null, clientHash?: string (64 hex chars), sizeBytes?: number (required with clientHash) }");
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
let upload;
|
|
218
|
-
try {
|
|
219
|
-
upload = await attachments.service.reserve(opts);
|
|
220
|
-
} catch (err) {
|
|
221
|
-
if (err instanceof AttachmentAlreadyExists) {
|
|
222
|
-
sendJson(res, 409, {
|
|
223
|
-
error: "already_exists",
|
|
224
|
-
ref: err.ref
|
|
225
|
-
});
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
sendErrorFromException(res, err);
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
sendJson(res, 201, {
|
|
232
|
-
reservationId: upload.reservationId,
|
|
233
|
-
ref: upload.ref,
|
|
234
|
-
expiresAtUtc: upload.expiresAtUtc,
|
|
235
|
-
...upload.uploadTarget ? { uploadTarget: upload.uploadTarget } : {}
|
|
236
|
-
});
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
function makeUploadHandler(attachments) {
|
|
240
|
-
return async (req, res) => {
|
|
241
|
-
const reservationId = extractParam(req, "reservationId");
|
|
242
|
-
if (!reservationId) {
|
|
243
|
-
sendError(res, 400, "Missing reservationId");
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
if (attachments.backend?.kind === "s3") {
|
|
247
|
-
sendError(res, 405, "Use the reservation uploadTarget for S3 uploads");
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
let reservation;
|
|
251
|
-
try {
|
|
252
|
-
reservation = await attachments.reservations.get(reservationId);
|
|
253
|
-
} catch (err) {
|
|
254
|
-
sendErrorFromException(res, err);
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
const upload = attachments.uploadFactory.createUpload(reservation);
|
|
258
|
-
const webStream = Readable.toWeb(req);
|
|
259
|
-
try {
|
|
260
|
-
sendJson(res, 200, await upload.send(webStream));
|
|
261
|
-
} catch (err) {
|
|
262
|
-
if (err instanceof HashMismatch) {
|
|
263
|
-
sendJson(res, 422, {
|
|
264
|
-
error: "hash_mismatch",
|
|
265
|
-
claimed: err.claimed,
|
|
266
|
-
actual: err.actual
|
|
267
|
-
});
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
if (err instanceof SizeMismatch) {
|
|
271
|
-
sendJson(res, 422, {
|
|
272
|
-
error: "size_mismatch",
|
|
273
|
-
declared: err.declared,
|
|
274
|
-
actual: err.actual
|
|
275
|
-
});
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
sendErrorFromException(res, err);
|
|
279
|
-
}
|
|
280
|
-
};
|
|
281
|
-
}
|
|
282
|
-
function makeDownloadHandler(attachments) {
|
|
283
|
-
return async (req, res) => {
|
|
284
|
-
const hash = extractParam(req, "hash");
|
|
285
|
-
if (!hash || !HASH_PATTERN.test(hash)) {
|
|
286
|
-
sendError(res, 400, "Invalid attachment hash");
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
const controller = new AbortController();
|
|
290
|
-
req.once("close", () => controller.abort());
|
|
291
|
-
const canonicalHash = hash.toLowerCase();
|
|
292
|
-
let response;
|
|
293
|
-
try {
|
|
294
|
-
response = await attachments.store.get(canonicalHash, controller.signal);
|
|
295
|
-
} catch (err) {
|
|
296
|
-
if (err instanceof AttachmentPending) {
|
|
297
|
-
res.statusCode = 202;
|
|
298
|
-
res.setHeader("Retry-After", String(RETRY_AFTER_SECONDS));
|
|
299
|
-
res.setHeader("Attachment-Pending", JSON.stringify({
|
|
300
|
-
expiresAtUtc: err.expiresAtUtc,
|
|
301
|
-
...err.metadata ?? {}
|
|
302
|
-
}));
|
|
303
|
-
res.end();
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
sendErrorFromException(res, err);
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
const { header, body } = response;
|
|
310
|
-
res.statusCode = 200;
|
|
311
|
-
res.setHeader("Content-Type", header.mimeType);
|
|
312
|
-
res.setHeader("Content-Length", String(header.sizeBytes));
|
|
313
|
-
res.setHeader("Content-Disposition", buildContentDisposition(header.fileName));
|
|
314
|
-
res.setHeader("Attachment-Metadata", buildMetadataHeader(header));
|
|
315
|
-
Readable.fromWeb(body).pipe(res);
|
|
316
|
-
};
|
|
317
|
-
}
|
|
318
|
-
function buildMetadataHeader(header) {
|
|
319
|
-
return JSON.stringify({
|
|
320
|
-
mimeType: header.mimeType,
|
|
321
|
-
fileName: header.fileName,
|
|
322
|
-
sizeBytes: header.sizeBytes,
|
|
323
|
-
extension: header.extension,
|
|
324
|
-
createdAtUtc: header.createdAtUtc,
|
|
325
|
-
lastAccessedAtUtc: header.lastAccessedAtUtc
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
function makeStatHandler(attachments) {
|
|
329
|
-
return async (req, res) => {
|
|
330
|
-
const hash = extractParam(req, "hash");
|
|
331
|
-
if (!hash || !HASH_PATTERN.test(hash)) {
|
|
332
|
-
sendError(res, 400, "Invalid attachment hash");
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
const canonicalHash = hash.toLowerCase();
|
|
336
|
-
let header;
|
|
337
|
-
try {
|
|
338
|
-
header = await attachments.store.stat(canonicalHash);
|
|
339
|
-
} catch (err) {
|
|
340
|
-
sendErrorFromException(res, err);
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
if (header.status === "pending") {
|
|
344
|
-
res.statusCode = 202;
|
|
345
|
-
res.setHeader("Retry-After", String(RETRY_AFTER_SECONDS));
|
|
346
|
-
res.setHeader("Attachment-Pending", JSON.stringify({
|
|
347
|
-
expiresAtUtc: header.expiresAtUtc,
|
|
348
|
-
mimeType: header.mimeType,
|
|
349
|
-
fileName: header.fileName,
|
|
350
|
-
sizeBytes: header.sizeBytes
|
|
351
|
-
}));
|
|
352
|
-
res.end();
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
res.statusCode = 200;
|
|
356
|
-
res.setHeader("Content-Type", header.mimeType);
|
|
357
|
-
res.setHeader("Content-Length", String(header.sizeBytes));
|
|
358
|
-
res.setHeader("Content-Disposition", buildContentDisposition(header.fileName));
|
|
359
|
-
res.setHeader("Attachment-Metadata", buildMetadataHeader(header));
|
|
360
|
-
res.end();
|
|
361
|
-
};
|
|
362
|
-
}
|
|
363
|
-
function makeGetReservationHandler(attachments) {
|
|
364
|
-
return async (req, res) => {
|
|
365
|
-
const reservationId = extractParam(req, "reservationId");
|
|
366
|
-
if (!reservationId) {
|
|
367
|
-
sendError(res, 400, "Missing reservationId");
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
try {
|
|
371
|
-
sendJson(res, 200, await attachments.reservations.get(reservationId));
|
|
372
|
-
} catch (err) {
|
|
373
|
-
sendErrorFromException(res, err);
|
|
374
|
-
}
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
|
-
function makeDeleteReservationHandler(attachments) {
|
|
378
|
-
return async (req, res) => {
|
|
379
|
-
const reservationId = extractParam(req, "reservationId");
|
|
380
|
-
if (!reservationId) {
|
|
381
|
-
sendError(res, 400, "Missing reservationId");
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
try {
|
|
385
|
-
await attachments.reservations.delete(reservationId);
|
|
386
|
-
res.statusCode = 204;
|
|
387
|
-
res.end();
|
|
388
|
-
} catch (err) {
|
|
389
|
-
sendErrorFromException(res, err);
|
|
390
|
-
}
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
function extractParam(req, name) {
|
|
394
|
-
return req.params?.[name];
|
|
395
|
-
}
|
|
396
|
-
const MAX_DOCUMENT_ID_LEN = 512;
|
|
397
|
-
const ATTACHMENT_NOT_FOUND_BODY = { error: "Attachment not found" };
|
|
398
|
-
const MAX_DOWNLOAD_TARGET_TTL_SECONDS = 10080 * 60;
|
|
399
|
-
/**
|
|
400
|
-
* Returns the single `documentId` query value, or null when it is missing,
|
|
401
|
-
* duplicated, blank, or oversized. Validation happens before authorization so
|
|
402
|
-
* malformed requests never reach the access service.
|
|
403
|
-
*/
|
|
404
|
-
function extractSingleDocumentId(req) {
|
|
405
|
-
if (!req.url) return null;
|
|
406
|
-
let url;
|
|
407
|
-
try {
|
|
408
|
-
url = new URL(req.url, "http://switchboard.invalid");
|
|
409
|
-
} catch {
|
|
410
|
-
return null;
|
|
411
|
-
}
|
|
412
|
-
const values = url.searchParams.getAll("documentId");
|
|
413
|
-
if (values.length !== 1) return null;
|
|
414
|
-
const value = values[0];
|
|
415
|
-
if (value.trim().length === 0 || value.length > MAX_DOCUMENT_ID_LEN) return null;
|
|
416
|
-
return value;
|
|
417
|
-
}
|
|
418
|
-
/**
|
|
419
|
-
* Returns the requested target TTL in seconds: undefined when absent,
|
|
420
|
-
* "invalid" when malformed (duplicated, non-integer, or non-positive), and
|
|
421
|
-
* otherwise the value clamped to the presigning ceiling.
|
|
422
|
-
*/
|
|
423
|
-
function extractExpiresIn(req) {
|
|
424
|
-
if (!req.url) return void 0;
|
|
425
|
-
let url;
|
|
426
|
-
try {
|
|
427
|
-
url = new URL(req.url, "http://switchboard.invalid");
|
|
428
|
-
} catch {
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
const values = url.searchParams.getAll("expiresIn");
|
|
432
|
-
if (values.length === 0) return void 0;
|
|
433
|
-
if (values.length > 1) return "invalid";
|
|
434
|
-
const parsed = Number(values[0]);
|
|
435
|
-
if (!Number.isInteger(parsed) || parsed <= 0) return "invalid";
|
|
436
|
-
return Math.min(parsed, MAX_DOWNLOAD_TARGET_TTL_SECONDS);
|
|
437
|
-
}
|
|
438
|
-
/**
|
|
439
|
-
* Base URL of this Switchboard as seen by the caller, used to build
|
|
440
|
-
* filesystem `switchboard` download targets that point back at the existing
|
|
441
|
-
* authenticated byte route.
|
|
442
|
-
*/
|
|
443
|
-
function requestBaseUrl(req) {
|
|
444
|
-
const forwardedProto = req.headers["x-forwarded-proto"];
|
|
445
|
-
const proto = (Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto)?.split(",")[0]?.trim() || (req.socket.encrypted ? "https" : "http");
|
|
446
|
-
const forwardedHost = req.headers["x-forwarded-host"];
|
|
447
|
-
const host = (Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost) ?? req.headers.host;
|
|
448
|
-
if (!host) return null;
|
|
449
|
-
return `${proto}://${host}`;
|
|
450
|
-
}
|
|
451
|
-
function makeDownloadTargetHandler(attachments, attachmentAccess) {
|
|
452
|
-
return async (req, res, _body, actor) => {
|
|
453
|
-
res.setHeader("Cache-Control", "no-store");
|
|
454
|
-
const hash = extractParam(req, "hash");
|
|
455
|
-
if (!hash || !HASH_PATTERN.test(hash)) {
|
|
456
|
-
sendError(res, 400, "Invalid attachment hash");
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
const documentId = extractSingleDocumentId(req);
|
|
460
|
-
if (documentId === null) {
|
|
461
|
-
sendError(res, 400, "documentId is required exactly once as a non-empty query parameter");
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
const expiresIn = extractExpiresIn(req);
|
|
465
|
-
if (expiresIn === "invalid") {
|
|
466
|
-
sendError(res, 400, "expiresIn must be a single positive integer number of seconds");
|
|
467
|
-
return;
|
|
468
|
-
}
|
|
469
|
-
const canonicalHash = hash.toLowerCase();
|
|
470
|
-
let decision;
|
|
471
|
-
try {
|
|
472
|
-
decision = await attachmentAccess.canReadAttachment({
|
|
473
|
-
documentId,
|
|
474
|
-
attachmentRef: createRef(canonicalHash),
|
|
475
|
-
userAddress: actor?.user?.address
|
|
476
|
-
});
|
|
477
|
-
} catch (err) {
|
|
478
|
-
logger$1.error("Attachment access decision failed: @error", err);
|
|
479
|
-
sendError(res, 500, "Internal error");
|
|
480
|
-
return;
|
|
481
|
-
}
|
|
482
|
-
if (decision.kind === "projection-unavailable") {
|
|
483
|
-
sendError(res, 503, "Attachment downloads are temporarily unavailable");
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
if (decision.kind === "denied") {
|
|
487
|
-
sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
|
|
488
|
-
return;
|
|
489
|
-
}
|
|
490
|
-
let header;
|
|
491
|
-
try {
|
|
492
|
-
header = await attachments.store.stat(canonicalHash);
|
|
493
|
-
} catch (err) {
|
|
494
|
-
if (err instanceof AttachmentNotFound) {
|
|
495
|
-
sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
|
|
496
|
-
return;
|
|
497
|
-
}
|
|
498
|
-
logger$1.error("Attachment metadata lookup failed: @error", err);
|
|
499
|
-
sendError(res, 500, "Internal error");
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
if (header.status !== "available") {
|
|
503
|
-
sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
506
|
-
let target;
|
|
507
|
-
if (attachments.backend && attachments.backend.kind !== "filesystem") try {
|
|
508
|
-
target = await attachments.backend.prepareDownloadTarget(canonicalHash, expiresIn);
|
|
509
|
-
} catch {
|
|
510
|
-
sendError(res, 502, "Attachment download target unavailable");
|
|
511
|
-
return;
|
|
512
|
-
}
|
|
513
|
-
else {
|
|
514
|
-
const base = requestBaseUrl(req);
|
|
515
|
-
if (!base) {
|
|
516
|
-
sendError(res, 500, "Internal error");
|
|
517
|
-
return;
|
|
518
|
-
}
|
|
519
|
-
try {
|
|
520
|
-
target = parseAttachmentDownloadTarget({
|
|
521
|
-
kind: "switchboard",
|
|
522
|
-
method: "GET",
|
|
523
|
-
url: `${base}/attachments/${canonicalHash}`,
|
|
524
|
-
headers: {}
|
|
525
|
-
});
|
|
526
|
-
} catch {
|
|
527
|
-
sendError(res, 500, "Internal error");
|
|
528
|
-
return;
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
sendJson(res, 200, target);
|
|
532
|
-
};
|
|
533
|
-
}
|
|
534
|
-
//#endregion
|
|
535
|
-
//#region src/attachments/index.ts
|
|
536
|
-
function registerAttachmentRoutes(api) {
|
|
537
|
-
const { attachments } = api;
|
|
538
|
-
mountAuthenticatedNodeRoute(api, "POST", "/attachments/reservations", makeReserveHandler(attachments));
|
|
539
|
-
mountAuthenticatedNodeRoute(api, "GET", "/attachments/reservations/:reservationId", makeGetReservationHandler(attachments));
|
|
540
|
-
mountAuthenticatedNodeRoute(api, "DELETE", "/attachments/reservations/:reservationId", makeDeleteReservationHandler(attachments));
|
|
541
|
-
mountAuthenticatedNodeRoute(api, "PUT", "/attachments/reservations/:reservationId", makeUploadHandler(attachments));
|
|
542
|
-
mountAuthenticatedNodeRoute(api, "HEAD", "/attachments/:hash", makeStatHandler(attachments));
|
|
543
|
-
mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash/download-target", makeDownloadTargetHandler(attachments, api.attachmentAccess), { allowAnonymous: true });
|
|
544
|
-
mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash", makeDownloadHandler(attachments));
|
|
545
|
-
}
|
|
546
|
-
//#endregion
|
|
547
|
-
//#region src/attachment-reference-read-model.mts
|
|
548
|
-
const attachmentSchemaCompiler = new AttachmentSchemaCompiler();
|
|
549
|
-
function createAttachmentReferenceReadModel(baseKysely, dependencies, attachmentReferenceWriter) {
|
|
550
|
-
return new AttachmentReferenceReadModel(baseKysely.withSchema(REACTOR_SCHEMA), dependencies.operationIndex, dependencies.writeCache, dependencies.processorManagerConsistencyTracker, dependencies.documentModelRegistry, attachmentSchemaCompiler, attachmentReferenceWriter);
|
|
551
|
-
}
|
|
552
|
-
function registerAttachmentReferenceReadModel(reactorBuilder, registration) {
|
|
553
|
-
reactorBuilder.withReadModelFactory(async ({ documentModelRegistry, operationIndex, writeCache, processorManagerConsistencyTracker }) => {
|
|
554
|
-
const readModel = createAttachmentReferenceReadModel(registration.baseKysely, {
|
|
555
|
-
operationIndex,
|
|
556
|
-
writeCache,
|
|
557
|
-
processorManagerConsistencyTracker,
|
|
558
|
-
documentModelRegistry
|
|
559
|
-
}, registration.attachmentReferenceWriter);
|
|
560
|
-
await readModel.init();
|
|
561
|
-
return readModel;
|
|
562
|
-
});
|
|
563
|
-
}
|
|
564
|
-
async function registerAttachmentReferenceReadModelOnModule(clientModule, attachmentReferenceWriter) {
|
|
565
|
-
const reactorModule = clientModule.reactorModule;
|
|
566
|
-
if (!reactorModule) return {
|
|
567
|
-
status: "unavailable",
|
|
568
|
-
reason: "in-process-reactor-module-unavailable"
|
|
569
|
-
};
|
|
570
|
-
const coordinator = reactorModule.readModelCoordinator;
|
|
571
|
-
if (!supportsLiveReadModelRegistration(coordinator)) return {
|
|
572
|
-
status: "unavailable",
|
|
573
|
-
reason: "live-read-model-registration-unsupported"
|
|
574
|
-
};
|
|
575
|
-
const readModel = createAttachmentReferenceReadModel(reactorModule.database, {
|
|
576
|
-
operationIndex: reactorModule.operationIndex,
|
|
577
|
-
writeCache: reactorModule.writeCache,
|
|
578
|
-
processorManagerConsistencyTracker: reactorModule.processorManagerConsistencyTracker,
|
|
579
|
-
documentModelRegistry: reactorModule.documentModelRegistry
|
|
580
|
-
}, attachmentReferenceWriter);
|
|
581
|
-
await readModel.init();
|
|
582
|
-
coordinator.addReadModel(readModel, "pre_ready");
|
|
583
|
-
await readModel.init();
|
|
584
|
-
return { status: "available" };
|
|
585
|
-
}
|
|
586
|
-
//#endregion
|
|
587
|
-
//#region src/builder-defaults.mts
|
|
588
|
-
/**
|
|
589
|
-
* Apply switchboard's standard configuration to a reactor + client builder
|
|
590
|
-
* pair. Each piece is opt-out via the options object; defaults mirror what
|
|
591
|
-
* `startSwitchboard` does when building a reactor itself. Mutates both
|
|
592
|
-
* builders in place.
|
|
593
|
-
*
|
|
594
|
-
* Does NOT touch kysely or read models — callers wire those themselves
|
|
595
|
-
* (see `withKysely` / `withReadModelFactory` on the reactor builder).
|
|
596
|
-
*/
|
|
597
|
-
/** The models switchboard always registers alongside package models. */
|
|
598
|
-
function switchboardBaseDocumentModels() {
|
|
599
|
-
return [
|
|
600
|
-
documentModelDocumentModelModule,
|
|
601
|
-
driveDocumentModelModule,
|
|
602
|
-
reactorDriveDocumentModelModule,
|
|
603
|
-
ReactorGroupV1
|
|
604
|
-
];
|
|
605
|
-
}
|
|
606
|
-
function applySwitchboardReactorDefaults(reactorBuilder, clientBuilder, options = {}) {
|
|
607
|
-
const baseModels = options.includeBaseModels !== false ? switchboardBaseDocumentModels() : [];
|
|
608
|
-
const extra = options.documentModels ?? [];
|
|
609
|
-
if (baseModels.length || extra.length) reactorBuilder.withDocumentModelSources(getUniqueDocumentModels(baseModels, extra));
|
|
610
|
-
if (options.upgradeManifests?.length) reactorBuilder.withUpgradeManifests(options.upgradeManifests);
|
|
611
|
-
const scheme = options.channelScheme === void 0 ? ChannelScheme.SWITCHBOARD : options.channelScheme;
|
|
612
|
-
if (scheme !== false) reactorBuilder.withChannelScheme(scheme);
|
|
613
|
-
if (options.signalHandlers !== false) reactorBuilder.withSignalHandlers();
|
|
614
|
-
if (options.executorConfig?.maxSkipThreshold !== void 0 || options.executorConfig?.featureFlags !== void 0) reactorBuilder.withExecutorConfig({
|
|
615
|
-
...options.executorConfig.maxSkipThreshold !== void 0 ? { maxSkipThreshold: options.executorConfig.maxSkipThreshold } : {},
|
|
616
|
-
...options.executorConfig.featureFlags !== void 0 ? { featureFlags: options.executorConfig.featureFlags } : {}
|
|
617
|
-
});
|
|
618
|
-
if (options.documentModelLoader) reactorBuilder.withDocumentModelLoader(options.documentModelLoader);
|
|
619
|
-
if (options.logger) reactorBuilder.withLogger(options.logger);
|
|
620
|
-
if (options.signer) clientBuilder.withSigner(options.signer);
|
|
621
|
-
}
|
|
622
|
-
//#endregion
|
|
623
|
-
//#region src/worker-pool.mts
|
|
624
|
-
const DEFAULT_DB_POOL_SIZE_PER_WORKER = 2;
|
|
625
|
-
const DEFAULT_ACQUIRE_TIMEOUT_MS = 5e3;
|
|
626
|
-
const DEFAULT_DB_POOL_SIZE_HOST = 16;
|
|
627
|
-
const AUTO_RESERVED_CORES = 2;
|
|
628
|
-
const AUTO_WORKER_CAP = 8;
|
|
629
|
-
/** Worker count for "auto": always at least 1 — auto sizes the pool, it does not turn worker mode off. */
|
|
630
|
-
function autoWorkerCount(availableCores) {
|
|
631
|
-
return Math.max(1, Math.min(AUTO_WORKER_CAP, availableCores - AUTO_RESERVED_CORES));
|
|
632
|
-
}
|
|
633
|
-
/**
|
|
634
|
-
* Effective worker-pool config from programmatic options (which win) and the
|
|
635
|
-
* REACTOR_* env vars; null when disabled (numWorkers 0, the default).
|
|
636
|
-
* `"auto"` sizes the pool from the machine's available cores.
|
|
637
|
-
*/
|
|
638
|
-
function resolveWorkerPoolOptions(input, env, availableCores = os.availableParallelism()) {
|
|
639
|
-
const requested = input?.numWorkers ?? parseWorkerCount(env.REACTOR_WORKERS) ?? 0;
|
|
640
|
-
const mode = requested === "auto" ? "auto" : "explicit";
|
|
641
|
-
const numWorkers = requested === "auto" ? autoWorkerCount(availableCores) : requested;
|
|
642
|
-
if (!Number.isInteger(numWorkers) || numWorkers < 0) throw new Error(`workerPool.numWorkers must be "auto" or a non-negative integer, got ${numWorkers}`);
|
|
643
|
-
if (numWorkers === 0) return null;
|
|
644
|
-
return {
|
|
645
|
-
numWorkers,
|
|
646
|
-
mode,
|
|
647
|
-
dbPoolSizePerWorker: input?.dbPoolSizePerWorker ?? parseNonNegativeInt(env.REACTOR_DB_POOL_SIZE_WORKER, "REACTOR_DB_POOL_SIZE_WORKER") ?? DEFAULT_DB_POOL_SIZE_PER_WORKER,
|
|
648
|
-
acquireTimeoutMs: input?.acquireTimeoutMs ?? parseNonNegativeInt(env.REACTOR_DB_ACQUIRE_TIMEOUT_MS, "REACTOR_DB_ACQUIRE_TIMEOUT_MS") ?? DEFAULT_ACQUIRE_TIMEOUT_MS
|
|
649
|
-
};
|
|
650
|
-
}
|
|
651
|
-
/**
|
|
652
|
-
* Size of the reactor's own (host) Postgres pool. Unlike the worker pool there
|
|
653
|
-
* is no "disabled" state — the host pool is the only Postgres pool in the
|
|
654
|
-
* server path, backing both reactor storage and the read models that reach it
|
|
655
|
-
* via `withSchema` — so 0 is rejected rather than read as "off".
|
|
656
|
-
*
|
|
657
|
-
* The acquire timeout is deliberately not configurable here: the host pool
|
|
658
|
-
* waits indefinitely today, and making it finite converts saturation from a
|
|
659
|
-
* latency problem into a thrown error on the read-model path. That is a
|
|
660
|
-
* behavior change worth making separately, once the retry semantics of a
|
|
661
|
-
* rejected `pool.connect()` inside the read-model coordinator are settled.
|
|
662
|
-
*/
|
|
663
|
-
function resolveHostPoolSize(env) {
|
|
664
|
-
const poolSize = parseNonNegativeInt(env.REACTOR_DB_POOL_SIZE_HOST, "REACTOR_DB_POOL_SIZE_HOST") ?? DEFAULT_DB_POOL_SIZE_HOST;
|
|
665
|
-
if (poolSize < 1) throw new Error("REACTOR_DB_POOL_SIZE_HOST must be at least 1; the host pool cannot be disabled");
|
|
666
|
-
return poolSize;
|
|
667
|
-
}
|
|
668
|
-
/**
|
|
669
|
-
* DbConfig for each worker's own pool, parsed from a postgres:// URL.
|
|
670
|
-
* Explicit host, database, and user are required — no pg-side defaults.
|
|
671
|
-
*/
|
|
672
|
-
function buildWorkerDbConfig(postgresUrl, options) {
|
|
673
|
-
let parsed;
|
|
674
|
-
try {
|
|
675
|
-
parsed = new URL(postgresUrl);
|
|
676
|
-
} catch {
|
|
677
|
-
throw new Error(`Worker pool requires a valid postgres:// URL for the reactor database, got "${postgresUrl}"`);
|
|
678
|
-
}
|
|
679
|
-
const database = decodeURIComponent(parsed.pathname.replace(/^\//, ""));
|
|
680
|
-
if (!parsed.hostname || !database) throw new Error(`Worker pool requires a postgres URL with a host and database name, got "${postgresUrl}"`);
|
|
681
|
-
if (!parsed.username) throw new Error(`Worker pool requires explicit credentials in the postgres URL (workers open their own connections), got "${postgresUrl}"`);
|
|
682
|
-
const sslmode = parsed.searchParams.get("sslmode");
|
|
683
|
-
const ssl = parsed.searchParams.get("ssl") === "true" || sslmode !== null && sslmode !== "disable";
|
|
684
|
-
return {
|
|
685
|
-
host: parsed.hostname,
|
|
686
|
-
port: parsed.port ? Number(parsed.port) : 5432,
|
|
687
|
-
database,
|
|
688
|
-
user: decodeURIComponent(parsed.username),
|
|
689
|
-
password: decodeURIComponent(parsed.password),
|
|
690
|
-
ssl,
|
|
691
|
-
applicationName: "switchboard-worker",
|
|
692
|
-
poolSize: options.dbPoolSizePerWorker,
|
|
693
|
-
connectionTimeoutMillis: options.acquireTimeoutMs
|
|
694
|
-
};
|
|
695
|
-
}
|
|
696
|
-
/** Specifiers of the base models switchboard always registers. */
|
|
697
|
-
const BASE_MODEL_SPECIFIERS = [
|
|
698
|
-
"document-model",
|
|
699
|
-
"@powerhousedao/shared/document-drive",
|
|
700
|
-
"@powerhousedao/reactor-drive",
|
|
701
|
-
"@powerhousedao/reactor-group/document-models"
|
|
702
|
-
];
|
|
703
|
-
/**
|
|
704
|
-
* Resolves base models and configured package identifiers to `{ filePath }`
|
|
705
|
-
* sources for the reactor builder. Resolution to absolute paths happens here
|
|
706
|
-
* because workers (and the builder) resolve bare specifiers from the reactor
|
|
707
|
-
* package's own dependency context, which cannot see switchboard or project
|
|
708
|
-
* packages. Unresolvable identifiers are skipped with a warning; the builder
|
|
709
|
-
* fails the boot if a registered model ends up without an importable source.
|
|
710
|
-
*/
|
|
711
|
-
async function resolveWorkerModelSources(packages, logger) {
|
|
712
|
-
const sources = [];
|
|
713
|
-
for (const identifier of [...BASE_MODEL_SPECIFIERS, ...packages]) {
|
|
714
|
-
const filePath = await resolveModelModuleFile(identifier);
|
|
715
|
-
if (!filePath) {
|
|
716
|
-
logger.warn(`Worker model sources: no importable document-models entry for "${identifier}", skipping`);
|
|
717
|
-
continue;
|
|
718
|
-
}
|
|
719
|
-
sources.push({ filePath });
|
|
720
|
-
}
|
|
721
|
-
return sources;
|
|
722
|
-
}
|
|
723
|
-
/**
|
|
724
|
-
* Absolute file path of a source's models barrel. Mirrors reactor-api's
|
|
725
|
-
* import-resolver: import.meta.resolve, then the project's node_modules via
|
|
726
|
-
* the package.json exports map / dist layout.
|
|
727
|
-
*/
|
|
728
|
-
async function resolveModelModuleFile(source) {
|
|
729
|
-
if (isFsPath(source)) return resolveFromPackageDir(source, "./document-models");
|
|
730
|
-
const specifier = BASE_MODEL_SPECIFIERS.includes(source) ? source : `${source}/document-models`;
|
|
731
|
-
try {
|
|
732
|
-
const resolved = import.meta.resolve(specifier);
|
|
733
|
-
if (resolved.startsWith("file:")) return fileURLToPath(resolved);
|
|
734
|
-
} catch {}
|
|
735
|
-
const { packageName, subpath } = splitSpecifier(specifier);
|
|
736
|
-
const packageDir = path.join(process.cwd(), "node_modules", packageName);
|
|
737
|
-
if (!existsSync(packageDir)) return null;
|
|
738
|
-
let realDir;
|
|
739
|
-
try {
|
|
740
|
-
realDir = realpathSync(packageDir);
|
|
741
|
-
} catch {
|
|
742
|
-
return null;
|
|
743
|
-
}
|
|
744
|
-
return resolveFromPackageDir(realDir, subpath);
|
|
745
|
-
}
|
|
746
|
-
function splitSpecifier(specifier) {
|
|
747
|
-
const parts = specifier.split("/");
|
|
748
|
-
const packageSegments = specifier.startsWith("@") ? 2 : 1;
|
|
749
|
-
const packageName = parts.slice(0, packageSegments).join("/");
|
|
750
|
-
const rest = parts.slice(packageSegments).join("/");
|
|
751
|
-
return {
|
|
752
|
-
packageName,
|
|
753
|
-
subpath: rest ? `./${rest}` : "."
|
|
754
|
-
};
|
|
755
|
-
}
|
|
756
|
-
/** Exports-map lookup (import condition), then conventional dist paths. */
|
|
757
|
-
async function resolveFromPackageDir(packageDir, subpath) {
|
|
758
|
-
const fromManifest = await resolveViaManifest(packageDir, subpath);
|
|
759
|
-
if (fromManifest) return fromManifest;
|
|
760
|
-
const candidates = subpath === "." ? [path.join(packageDir, "dist", "index.js")] : [
|
|
761
|
-
path.join(packageDir, "dist", subpath.slice(2), "index.js"),
|
|
762
|
-
path.join(packageDir, "dist", `${subpath.slice(2)}.js`),
|
|
763
|
-
path.join(packageDir, subpath.slice(2), "index.js")
|
|
764
|
-
];
|
|
765
|
-
for (const candidate of candidates) if (existsSync(candidate)) return candidate;
|
|
766
|
-
return null;
|
|
767
|
-
}
|
|
768
|
-
async function resolveViaManifest(packageDir, subpath) {
|
|
769
|
-
let raw;
|
|
770
|
-
try {
|
|
771
|
-
raw = await readFile(path.join(packageDir, "package.json"), "utf8");
|
|
772
|
-
} catch {
|
|
773
|
-
return null;
|
|
774
|
-
}
|
|
775
|
-
let manifest;
|
|
776
|
-
try {
|
|
777
|
-
manifest = JSON.parse(raw);
|
|
778
|
-
} catch {
|
|
779
|
-
return null;
|
|
780
|
-
}
|
|
781
|
-
let entry;
|
|
782
|
-
const exportsField = manifest.exports;
|
|
783
|
-
if (exportsField !== null && typeof exportsField === "object" && !Array.isArray(exportsField)) {
|
|
784
|
-
const map = exportsField;
|
|
785
|
-
entry = Object.keys(map).some((key) => key.startsWith(".")) ? map[subpath] : subpath === "." ? map : void 0;
|
|
786
|
-
} else if (typeof exportsField === "string" && subpath === ".") entry = exportsField;
|
|
787
|
-
let target = pickImportTarget(entry);
|
|
788
|
-
if (!target && subpath === "." && typeof manifest.main === "string") target = manifest.main;
|
|
789
|
-
if (!target) return null;
|
|
790
|
-
const resolved = path.join(packageDir, target);
|
|
791
|
-
return existsSync(resolved) ? resolved : null;
|
|
792
|
-
}
|
|
793
|
-
function pickImportTarget(entry) {
|
|
794
|
-
if (typeof entry === "string") return entry;
|
|
795
|
-
if (typeof entry !== "object" || entry === null) return null;
|
|
796
|
-
const conditions = entry;
|
|
797
|
-
for (const condition of [
|
|
798
|
-
"import",
|
|
799
|
-
"node",
|
|
800
|
-
"default"
|
|
801
|
-
]) {
|
|
802
|
-
const value = conditions[condition];
|
|
803
|
-
if (typeof value === "string") return value;
|
|
804
|
-
}
|
|
805
|
-
return null;
|
|
806
|
-
}
|
|
807
|
-
function isFsPath(identifier) {
|
|
808
|
-
return path.isAbsolute(identifier) || identifier.startsWith("./") || identifier.startsWith("../");
|
|
809
|
-
}
|
|
810
|
-
function parseWorkerCount(raw) {
|
|
811
|
-
if (raw === void 0 || raw.trim() === "") return;
|
|
812
|
-
if (raw.trim().toLowerCase() === "auto") return "auto";
|
|
813
|
-
const value = Number.parseInt(raw, 10);
|
|
814
|
-
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}"`);
|
|
815
|
-
return value;
|
|
816
|
-
}
|
|
817
|
-
function parseNonNegativeInt(raw, name) {
|
|
818
|
-
if (raw === void 0 || raw.trim() === "") return;
|
|
819
|
-
const value = Number.parseInt(raw, 10);
|
|
820
|
-
if (!Number.isInteger(value) || value < 0 || String(value) !== raw.trim()) throw new Error(`${name} must be a non-negative integer, got "${raw}"`);
|
|
821
|
-
return value;
|
|
822
|
-
}
|
|
823
|
-
//#endregion
|
|
824
|
-
//#region src/projection-worker.mts
|
|
825
|
-
const DEFAULT_DB_POOL_SIZE_PROJECTION = 8;
|
|
826
|
-
const ON_TOKENS = [
|
|
827
|
-
"1",
|
|
828
|
-
"true",
|
|
829
|
-
"on",
|
|
830
|
-
"yes"
|
|
831
|
-
];
|
|
832
|
-
const OFF_TOKENS = [
|
|
833
|
-
"0",
|
|
834
|
-
"false",
|
|
835
|
-
"off",
|
|
836
|
-
"no"
|
|
837
|
-
];
|
|
838
|
-
/**
|
|
839
|
-
* Enabled by REACTOR_PROJECTION_WORKER (1/true/on/yes, case-insensitive) or
|
|
840
|
-
* `input.enabled`, which wins when defined; null when off (the default).
|
|
841
|
-
*/
|
|
842
|
-
function resolveProjectionWorkerOptions(input, env) {
|
|
843
|
-
if (!(input?.enabled ?? parseOnOff(env.REACTOR_PROJECTION_WORKER) ?? false)) return null;
|
|
844
|
-
const dbPoolSize = input?.dbPoolSize ?? parseNonNegativeInt(env.REACTOR_DB_POOL_SIZE_PROJECTION, "REACTOR_DB_POOL_SIZE_PROJECTION") ?? DEFAULT_DB_POOL_SIZE_PROJECTION;
|
|
845
|
-
if (dbPoolSize < 1) throw new Error("REACTOR_DB_POOL_SIZE_PROJECTION must be at least 1; the projection worker cannot run without a pool");
|
|
846
|
-
return { dbPoolSize };
|
|
847
|
-
}
|
|
848
|
-
/** Same preconditions as the executor worker pool; message shape mirrors server.mts. */
|
|
849
|
-
function assertProjectionWorkerSupported(args) {
|
|
850
|
-
if (args.dev) throw new Error("The projection worker (REACTOR_PROJECTION_WORKER) is not supported in dev mode: Vite-loaded document models cannot cross a worker-thread boundary");
|
|
851
|
-
if (!args.reactorDbUrl || !isPostgresUrl(args.reactorDbUrl)) throw new Error("The projection worker (REACTOR_PROJECTION_WORKER) requires a Postgres reactor database — set PH_REACTOR_DATABASE_URL or PH_SWITCHBOARD_DATABASE_URL. PGlite cannot be shared across worker threads.");
|
|
852
|
-
}
|
|
853
|
-
function parseOnOff(raw) {
|
|
854
|
-
if (raw === void 0 || raw.trim() === "") return;
|
|
855
|
-
const token = raw.trim().toLowerCase();
|
|
856
|
-
if (ON_TOKENS.includes(token)) return true;
|
|
857
|
-
if (OFF_TOKENS.includes(token)) return false;
|
|
858
|
-
throw new Error(`REACTOR_PROJECTION_WORKER must be one of 1, true, on, yes, 0, false, off, no, got "${raw}"`);
|
|
859
|
-
}
|
|
860
|
-
//#endregion
|
|
861
|
-
//#region src/feature-flags.ts
|
|
862
|
-
async function initFeatureFlags() {
|
|
863
|
-
const provider = new EnvVarProvider();
|
|
864
|
-
await OpenFeature.setProviderAndWait(provider);
|
|
865
|
-
return OpenFeature.getClient();
|
|
866
|
-
}
|
|
867
|
-
//#endregion
|
|
868
|
-
//#region src/pglite-dialect.ts
|
|
869
|
-
var ClosablePGliteDialect = class extends PGliteDialect {
|
|
870
|
-
#pglite;
|
|
871
|
-
constructor(pglite) {
|
|
872
|
-
super(pglite);
|
|
873
|
-
this.#pglite = pglite;
|
|
874
|
-
}
|
|
875
|
-
createDriver() {
|
|
876
|
-
const driver = super.createDriver();
|
|
877
|
-
const pglite = this.#pglite;
|
|
878
|
-
const innerDestroy = driver.destroy.bind(driver);
|
|
879
|
-
driver.destroy = async () => {
|
|
880
|
-
await innerDestroy();
|
|
881
|
-
if (!pglite.closed) await pglite.close();
|
|
882
|
-
};
|
|
883
|
-
return driver;
|
|
884
|
-
}
|
|
885
|
-
};
|
|
886
|
-
//#endregion
|
|
887
|
-
//#region src/pglite-migration.ts
|
|
888
|
-
function backupPath(dataDir, major) {
|
|
889
|
-
return `${dataDir}.backup-pg${major}-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
|
|
890
|
-
}
|
|
891
|
-
async function pathExists(p) {
|
|
892
|
-
try {
|
|
893
|
-
await promises.stat(p);
|
|
894
|
-
return true;
|
|
895
|
-
} catch {
|
|
896
|
-
return false;
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
function logRestoreFailure(dataDir, sql, err, logger) {
|
|
900
|
-
const errObj = err;
|
|
901
|
-
const position = typeof errObj.position === "string" ? parseInt(errObj.position, 10) : typeof errObj.position === "number" ? errObj.position : NaN;
|
|
902
|
-
logger.error(`[pglite-migration] Restore failed for ${dataDir}: code=${errObj.code ?? ""} severity=${errObj.severity ?? ""} message=${errObj.message ?? ""} sqlLength=${sql.length}`);
|
|
903
|
-
if (Number.isFinite(position) && position > 0) {
|
|
904
|
-
const zeroBased = position - 1;
|
|
905
|
-
const start = Math.max(0, zeroBased - 200);
|
|
906
|
-
const end = Math.min(sql.length, zeroBased + 200);
|
|
907
|
-
const before = sql.slice(start, zeroBased);
|
|
908
|
-
const at = sql.slice(zeroBased, zeroBased + 1);
|
|
909
|
-
const after = sql.slice(zeroBased + 1, end);
|
|
910
|
-
logger.error(`[pglite-migration] SQL context around position ${position}:\n${before}»${at}«${after}`);
|
|
911
|
-
} else logger.error(`[pglite-migration] No position info. First 2000 chars of dump:\n${sql.slice(0, 2e3)}`);
|
|
912
|
-
}
|
|
913
|
-
/**
|
|
914
|
-
* Migrate a filesystem PGLite data directory from a legacy PG major to the
|
|
915
|
-
* current one. Renames the existing dir to a timestamped backup, dumps via the
|
|
916
|
-
* matching legacy `pg_dump`, restores into a fresh current-version PGLite at
|
|
917
|
-
* the original path. On failure, the original dir is restored from the backup.
|
|
918
|
-
*
|
|
919
|
-
* No-op when the dir is missing or already at the current major.
|
|
920
|
-
*/
|
|
921
|
-
async function migratePgliteDir(dataDir, logger) {
|
|
922
|
-
const major = await readPgVersionFile(dataDir);
|
|
923
|
-
if (major === null) {
|
|
924
|
-
logger.info(`[pglite-migration] No PG_VERSION at ${dataDir}; skipping migration`);
|
|
925
|
-
return;
|
|
926
|
-
}
|
|
927
|
-
if (major === 17) return;
|
|
928
|
-
if (!isSupportedMajor(major)) throw new Error(`Unsupported legacy PGlite data dir: PG_VERSION=${major} for ${dataDir}`);
|
|
929
|
-
const backupDir = backupPath(dataDir, major);
|
|
930
|
-
logger.info(`[pglite-migration] Migrating ${dataDir} from PG${major} to PG17; backup: ${backupDir}`);
|
|
931
|
-
await promises.rename(dataDir, backupDir);
|
|
932
|
-
let sql;
|
|
933
|
-
try {
|
|
934
|
-
const [legacyMod, pgDump] = await Promise.all([loadPGliteModule(major), loadPgDump(major)]);
|
|
935
|
-
const LegacyPGlite = legacyMod.PGlite;
|
|
936
|
-
const pg = new LegacyPGlite(backupDir);
|
|
937
|
-
try {
|
|
938
|
-
await pg.waitReady;
|
|
939
|
-
sql = await (await pgDump({ pg })).text();
|
|
940
|
-
} finally {
|
|
941
|
-
await pg.close();
|
|
942
|
-
}
|
|
943
|
-
} catch (err) {
|
|
944
|
-
await rollback(dataDir, backupDir, err, logger);
|
|
945
|
-
throw err;
|
|
946
|
-
}
|
|
947
|
-
try {
|
|
948
|
-
const CurrentPGlite = (await loadPGliteModule(17)).PGlite;
|
|
949
|
-
const pg = new CurrentPGlite(dataDir, { relaxedDurability: false });
|
|
950
|
-
try {
|
|
951
|
-
await pg.waitReady;
|
|
952
|
-
try {
|
|
953
|
-
await pg.exec("SET standard_conforming_strings = off;");
|
|
954
|
-
} catch (gucErr) {
|
|
955
|
-
logger.warn(`[pglite-migration] Could not force standard_conforming_strings=off: ${String(gucErr)}`);
|
|
956
|
-
}
|
|
957
|
-
try {
|
|
958
|
-
await pg.exec(sql);
|
|
959
|
-
} catch (execErr) {
|
|
960
|
-
logRestoreFailure(dataDir, sql, execErr, logger);
|
|
961
|
-
throw execErr;
|
|
962
|
-
}
|
|
963
|
-
} finally {
|
|
964
|
-
await pg.close();
|
|
965
|
-
}
|
|
966
|
-
} catch (err) {
|
|
967
|
-
await rollback(dataDir, backupDir, err, logger);
|
|
968
|
-
throw err;
|
|
969
|
-
}
|
|
970
|
-
logger.info(`[pglite-migration] Migration of ${dataDir} complete. Backup retained at ${backupDir}; remove it manually once you have verified the upgrade.`);
|
|
971
|
-
}
|
|
972
|
-
async function rollback(dataDir, backupDir, originalError, logger) {
|
|
973
|
-
try {
|
|
974
|
-
if (await pathExists(dataDir)) await promises.rm(dataDir, {
|
|
975
|
-
recursive: true,
|
|
976
|
-
force: true
|
|
977
|
-
});
|
|
978
|
-
if (await pathExists(backupDir)) await promises.rename(backupDir, dataDir);
|
|
979
|
-
} catch (rollbackErr) {
|
|
980
|
-
logger.error(`[pglite-migration] Migration AND rollback failed for ${dataDir}. Original error: ${String(originalError)}; rollback error: ${String(rollbackErr)}; backup may still exist at ${backupDir}.`);
|
|
981
|
-
return;
|
|
982
|
-
}
|
|
983
|
-
logger.error(`[pglite-migration] Migration failed for ${dataDir}; rolled back from ${backupDir}. Original error: ${String(originalError)}`);
|
|
984
|
-
}
|
|
985
|
-
//#endregion
|
|
986
|
-
//#region src/reactor-feature-flags.mts
|
|
987
|
-
/**
|
|
988
|
-
* Enforcement flags from the REACTOR_* env vars. Each flag requires the ones
|
|
989
|
-
* before it; the reactor rejects an inconsistent set rather than enforcing less
|
|
990
|
-
* than the operator asked for, so this reports what was asked without
|
|
991
|
-
* correcting it.
|
|
992
|
-
*/
|
|
993
|
-
function resolveReactorFeatureFlags(env) {
|
|
994
|
-
const flags = {
|
|
995
|
-
documentDecisions: env.REACTOR_DOCUMENT_DECISIONS === "true",
|
|
996
|
-
authEnforcement: env.REACTOR_AUTH_ENFORCEMENT === "true",
|
|
997
|
-
authGroups: env.REACTOR_AUTH_GROUPS === "true",
|
|
998
|
-
authConditions: env.REACTOR_AUTH_CONDITIONS === "true"
|
|
999
|
-
};
|
|
1000
|
-
return {
|
|
1001
|
-
flags,
|
|
1002
|
-
enabled: Object.entries(flags).filter(([, isEnabled]) => isEnabled).map(([name]) => name)
|
|
1003
|
-
};
|
|
1004
|
-
}
|
|
1005
|
-
//#endregion
|
|
1006
|
-
//#region src/renown.ts
|
|
1007
|
-
const logger = childLogger(["switchboard", "renown"]);
|
|
1008
|
-
/**
|
|
1009
|
-
* Initialize Renown for the Switchboard instance.
|
|
1010
|
-
* This allows Switchboard to authenticate with remote services
|
|
1011
|
-
* using the same identity established during `ph login`.
|
|
1012
|
-
*/
|
|
1013
|
-
async function initRenown(options = {}) {
|
|
1014
|
-
const { keypairPath, requireExisting = false, baseUrl = DEFAULT_RENOWN_URL } = options;
|
|
1015
|
-
const keyStorage = new NodeKeyStorage(keypairPath, { logger });
|
|
1016
|
-
const existingKeyPair = await keyStorage.loadKeyPair();
|
|
1017
|
-
if (!existingKeyPair && requireExisting) throw new Error("No existing keypair found and requireExisting is true. Run \"ph login\" to create one.");
|
|
1018
|
-
if (!existingKeyPair) logger.info("No existing keypair found. A new one will be generated.");
|
|
1019
|
-
const renownCrypto = await new RenownCryptoBuilder().withKeyPairStorage(keyStorage).build();
|
|
1020
|
-
const renown = await new RenownBuilder("switchboard", {}).withCrypto(renownCrypto).withBaseUrl(baseUrl).build();
|
|
1021
|
-
logger.info("Switchboard identity initialized: @did", renownCrypto.did);
|
|
1022
|
-
return renown;
|
|
1023
|
-
}
|
|
1024
|
-
/**
|
|
1025
|
-
* Get the signer config for the given renown instance.
|
|
1026
|
-
*
|
|
1027
|
-
* @param renown - The renown instance
|
|
1028
|
-
* @param requireSignature - If true, unsigned actions are rejected
|
|
1029
|
-
*/
|
|
1030
|
-
function getRenownSignerConfig(renown, requireSignature) {
|
|
1031
|
-
return {
|
|
1032
|
-
signer: renown.signer,
|
|
1033
|
-
verifier: createSignatureVerifier(requireSignature)
|
|
1034
|
-
};
|
|
1035
|
-
}
|
|
1036
|
-
//#endregion
|
|
1037
|
-
//#region src/server.mts
|
|
1038
|
-
const defaultLogger = childLogger(["switchboard"]);
|
|
1039
|
-
const LogLevel = process.env.LOG_LEVEL || "info";
|
|
1040
|
-
setLogLevel(LogLevel);
|
|
1041
|
-
dotenv.config();
|
|
1042
|
-
const DOCUMENT_MODEL_SUBGRAPHS_ENABLED = "DOCUMENT_MODEL_SUBGRAPHS_ENABLED";
|
|
1043
|
-
const DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT = true;
|
|
1044
|
-
const REQUIRE_SIGNATURES = "REQUIRE_SIGNATURES";
|
|
1045
|
-
const REQUIRE_SIGNATURES_DEFAULT = false;
|
|
1046
|
-
const DEFAULT_PORT = process.env.PORT ? Number(process.env.PORT) : 4001;
|
|
1047
|
-
const PORT_FALLBACK_ATTEMPTS = 20;
|
|
1048
|
-
const PGLITE_FLUSH_INTERVAL_MS = (() => {
|
|
1049
|
-
const raw = process.env.PGLITE_FLUSH_INTERVAL_MS;
|
|
1050
|
-
if (raw === void 0) return 100;
|
|
1051
|
-
const parsed = Number(raw);
|
|
1052
|
-
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 100;
|
|
1053
|
-
})();
|
|
1054
|
-
const PGLITE_IN_MEMORY = process.env.PH_PGLITE_IN_MEMORY === "1";
|
|
1055
|
-
/**
|
|
1056
|
-
* Attempt to bind a throwaway TCP server to the given port. Resolves true if
|
|
1057
|
-
* the port is free, false if the OS reports it in use. Any other error is
|
|
1058
|
-
* surfaced so we don't silently mask real issues (permissions, bad host, …).
|
|
1059
|
-
*/
|
|
1060
|
-
function isPortAvailable(port) {
|
|
1061
|
-
return new Promise((resolve, reject) => {
|
|
1062
|
-
const tester = net.createServer();
|
|
1063
|
-
tester.once("error", (err) => {
|
|
1064
|
-
if (err.code === "EADDRINUSE" || err.code === "EACCES") resolve(false);
|
|
1065
|
-
else reject(err);
|
|
1066
|
-
});
|
|
1067
|
-
tester.once("listening", () => {
|
|
1068
|
-
tester.close(() => resolve(true));
|
|
1069
|
-
});
|
|
1070
|
-
tester.listen({
|
|
1071
|
-
port,
|
|
1072
|
-
host: "::"
|
|
1073
|
-
});
|
|
1074
|
-
});
|
|
1075
|
-
}
|
|
1076
|
-
/** The powerhouse.config.json this run reads, defaulting to the cwd copy. */
|
|
1077
|
-
function resolveConfigPath(configFile) {
|
|
1078
|
-
return configFile ?? path$1.join(process.cwd(), "powerhouse.config.json");
|
|
1079
|
-
}
|
|
1080
|
-
async function resolveServerPort(requested, strictPort, logger) {
|
|
1081
|
-
if (strictPort) return requested;
|
|
1082
|
-
for (let i = 0; i < PORT_FALLBACK_ATTEMPTS; i++) {
|
|
1083
|
-
const candidate = requested + i;
|
|
1084
|
-
if (await isPortAvailable(candidate)) {
|
|
1085
|
-
if (candidate !== requested) logger.info(`Port ${requested} is in use. Falling back to port ${candidate}.`);
|
|
1086
|
-
return candidate;
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
|
-
return requested;
|
|
1090
|
-
}
|
|
1091
|
-
async function createReactorKysely(opts) {
|
|
1092
|
-
const { reactorDbUrl, reactorPgliteDir, reactorPgliteMajor, inMemory, flushIntervalMs, hostPoolSize, logger } = opts;
|
|
1093
|
-
if (reactorDbUrl && isPostgresUrl(reactorDbUrl)) {
|
|
1094
|
-
const connectionString = reactorDbUrl.includes("?") ? reactorDbUrl : `${reactorDbUrl}?sslmode=disable`;
|
|
1095
|
-
const poolSize = hostPoolSize();
|
|
1096
|
-
const pool = new Pool({
|
|
1097
|
-
connectionString,
|
|
1098
|
-
max: poolSize
|
|
1099
|
-
});
|
|
1100
|
-
const poolInstrumentation = instrumentPgPool(pool, "reactor-host");
|
|
1101
|
-
logger.info(`Using PostgreSQL for reactor storage (host pool max ${poolSize})`);
|
|
1102
|
-
return {
|
|
1103
|
-
kysely: new Kysely({ dialect: new PostgresDialect({ pool }) }),
|
|
1104
|
-
poolInstrumentation
|
|
1105
|
-
};
|
|
1106
|
-
}
|
|
1107
|
-
if (!reactorPgliteDir || reactorPgliteMajor === null) throw new Error("Reactor PGLite directory not resolved");
|
|
1108
|
-
const { PGlite } = await loadPGliteModule(reactorPgliteMajor);
|
|
1109
|
-
const pglite = inMemory ? new PGlite() : new PGlite({ fs: new AtomicNodeFs(reactorPgliteDir, {
|
|
1110
|
-
logger,
|
|
1111
|
-
flushIntervalMs
|
|
1112
|
-
}) });
|
|
1113
|
-
logger.info(inMemory ? `Using in-memory PGlite (PG${reactorPgliteMajor}) for reactor storage [PH_PGLITE_IN_MEMORY=1]` : `Using PGlite (PG${reactorPgliteMajor}) for reactor storage at ${reactorPgliteDir}`);
|
|
1114
|
-
return {
|
|
1115
|
-
kysely: new Kysely({ dialect: new ClosablePGliteDialect(pglite) }),
|
|
1116
|
-
poolInstrumentation: void 0
|
|
1117
|
-
};
|
|
1118
|
-
}
|
|
1119
|
-
/** Derive the remote attachment service config for switchboard's own `/attachments/*` API. */
|
|
1120
|
-
function deriveAttachmentServiceConfig(options, serverPort, renown) {
|
|
1121
|
-
const protocol = options.https ? "https" : "http";
|
|
1122
|
-
return {
|
|
1123
|
-
remoteUrl: options.attachmentServiceUrl ?? process.env.PH_SWITCHBOARD_PUBLIC_URL ?? `${protocol}://localhost:${serverPort}`,
|
|
1124
|
-
jwtHandler: renown ? async (url) => renown.user ? renown.getBearerToken({
|
|
1125
|
-
expiresIn: 10,
|
|
1126
|
-
aud: url
|
|
1127
|
-
}) : void 0 : void 0
|
|
1128
|
-
};
|
|
1129
|
-
}
|
|
1130
|
-
async function initServer(serverPort, options, renown, renownConfig) {
|
|
1131
|
-
const { dev, packages = [], remoteDrives = [], logger = defaultLogger } = options;
|
|
1132
|
-
logger.level = LogLevel;
|
|
1133
|
-
const dbPath = options.dbPath ?? process.env.DATABASE_URL ?? process.env.PH_SWITCHBOARD_DATABASE_URL;
|
|
1134
|
-
const readModelPath = dbPath || ".ph/read-storage";
|
|
1135
|
-
const reactorDbUrl = options.dbPath ?? process.env.PH_REACTOR_DATABASE_URL ?? process.env.PH_SWITCHBOARD_DATABASE_URL;
|
|
1136
|
-
const reactorPath = reactorDbUrl || "./.ph/reactor-storage";
|
|
1137
|
-
const reactorPgliteDir = options.reactor ? null : !reactorDbUrl || !isPostgresUrl(reactorDbUrl) ? reactorPath : null;
|
|
1138
|
-
const readModelPgliteDir = !dbPath || !isPostgresUrl(dbPath) ? readModelPath : null;
|
|
1139
|
-
const pgliteDirs = [reactorPgliteDir, readModelPgliteDir].filter((d) => d !== null);
|
|
1140
|
-
const detectedMajors = /* @__PURE__ */ new Map();
|
|
1141
|
-
for (const dir of pgliteDirs) {
|
|
1142
|
-
const lockPath = path$1.join(dir, "postmaster.pid");
|
|
1143
|
-
try {
|
|
1144
|
-
await promises.unlink(lockPath);
|
|
1145
|
-
logger.warn(`Removed stale PGLite lockfile ${lockPath}`);
|
|
1146
|
-
} catch (err) {
|
|
1147
|
-
if (err.code !== "ENOENT") throw err;
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
if (options.forcePgVersion !== void 0 && pgliteDirs.length > 0) {
|
|
1151
|
-
if (options.migratePglite) logger.warn("PH_FORCE_PG_VERSION is set; ignoring --migrate-pglite/PH_MIGRATE_PGLITE because the data dirs will be wiped.");
|
|
1152
|
-
logger.warn(`PH_FORCE_PG_VERSION=${options.forcePgVersion} set; wiping PGLite data dirs and re-initializing at PG${options.forcePgVersion}.`);
|
|
1153
|
-
for (const dir of pgliteDirs) {
|
|
1154
|
-
await promises.rm(dir, {
|
|
1155
|
-
recursive: true,
|
|
1156
|
-
force: true
|
|
1157
|
-
});
|
|
1158
|
-
logger.info(`Wiped PGLite data dir ${dir}`);
|
|
1159
|
-
}
|
|
1160
|
-
} else if (options.forcePgVersion === void 0) {
|
|
1161
|
-
for (const dir of pgliteDirs) {
|
|
1162
|
-
const major = await readPgVersionFile(dir);
|
|
1163
|
-
if (major !== null) detectedMajors.set(dir, major);
|
|
1164
|
-
}
|
|
1165
|
-
if (options.migratePglite) for (const [dir, major] of detectedMajors) {
|
|
1166
|
-
if (major === 17) continue;
|
|
1167
|
-
await migratePgliteDir(dir, logger);
|
|
1168
|
-
const after = await readPgVersionFile(dir);
|
|
1169
|
-
if (after !== null) detectedMajors.set(dir, after);
|
|
1170
|
-
}
|
|
1171
|
-
else for (const [dir, major] of detectedMajors) {
|
|
1172
|
-
if (major === 17) continue;
|
|
1173
|
-
logger.warn(`PGLite data dir at ${dir} was created with PG${major} but Switchboard ships PG17. Running on legacy PGLite. Re-start with --migrate-pglite (or PH_MIGRATE_PGLITE=true) to upgrade.`);
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
function resolvePgliteMajorForDir(dir) {
|
|
1177
|
-
if (options.forcePgVersion !== void 0) return options.forcePgVersion;
|
|
1178
|
-
const detected = detectedMajors.get(dir);
|
|
1179
|
-
if (detected === void 0) return 17;
|
|
1180
|
-
if (!isSupportedMajor(detected)) throw new Error(`Unsupported PGLite data dir at ${dir}: PG_VERSION=${detected}`);
|
|
1181
|
-
return detected;
|
|
1182
|
-
}
|
|
1183
|
-
const reactorPgliteMajor = reactorPgliteDir ? resolvePgliteMajorForDir(reactorPgliteDir) : null;
|
|
1184
|
-
const readModelPgliteMajor = readModelPgliteDir ? resolvePgliteMajorForDir(readModelPgliteDir) : null;
|
|
1185
|
-
let workerPool = resolveWorkerPoolOptions(options.workerPool, process.env);
|
|
1186
|
-
if (workerPool && options.reactor) {
|
|
1187
|
-
logger.warn("Worker pool configuration ignored: the caller-provided reactor owns its own executor");
|
|
1188
|
-
workerPool = null;
|
|
1189
|
-
}
|
|
1190
|
-
if (workerPool) {
|
|
1191
|
-
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");
|
|
1192
|
-
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.");
|
|
1193
|
-
}
|
|
1194
|
-
let projectionWorker = resolveProjectionWorkerOptions(options.projectionWorker, process.env);
|
|
1195
|
-
if (projectionWorker && options.reactor) {
|
|
1196
|
-
logger.warn("Projection worker configuration ignored: the caller-provided reactor owns its own read-model coordinator");
|
|
1197
|
-
projectionWorker = null;
|
|
1198
|
-
}
|
|
1199
|
-
if (projectionWorker) assertProjectionWorkerSupported({
|
|
1200
|
-
dev: dev === true,
|
|
1201
|
-
reactorDbUrl
|
|
1202
|
-
});
|
|
1203
|
-
const apiRef = { current: void 0 };
|
|
1204
|
-
let driveNodeView;
|
|
1205
|
-
const configPath = resolveConfigPath(options.configFile);
|
|
1206
|
-
const config = getConfig(configPath);
|
|
1207
|
-
const registryUrl = options.registryUrl ?? process.env.PH_REGISTRY_URL ?? config.packageRegistryUrl;
|
|
1208
|
-
const registryPackages = process.env.PH_REGISTRY_PACKAGES;
|
|
1209
|
-
const dynamicModelLoading = options.dynamicModelLoading ?? process.env.DYNAMIC_MODEL_LOADING === "true";
|
|
1210
|
-
let httpLoader;
|
|
1211
|
-
if (registryUrl) {
|
|
1212
|
-
register(httpsHooksPath, import.meta.url);
|
|
1213
|
-
httpLoader = new HttpPackageLoader({ registryUrl });
|
|
1214
|
-
registryPackages?.split(",").forEach((p) => {
|
|
1215
|
-
const name = p.trim();
|
|
1216
|
-
if (!packages.includes(name)) packages.push(name);
|
|
1217
|
-
});
|
|
1218
|
-
}
|
|
1219
|
-
const reactorLogger = logger.child(["reactor"]);
|
|
1220
|
-
let projectionWorkerFatalFired = false;
|
|
1221
|
-
const onProjectionWorkerFatal = (shardId, reason) => {
|
|
1222
|
-
if (projectionWorkerFatalFired) return;
|
|
1223
|
-
projectionWorkerFatalFired = true;
|
|
1224
|
-
reactorLogger.error(`Projection worker ${shardId} died; shutting down so the supervisor restarts a healthy process`, reason);
|
|
1225
|
-
process.kill(process.pid, "SIGTERM");
|
|
1226
|
-
};
|
|
1227
|
-
let ownedReactorModule;
|
|
1228
|
-
const initializeClient = async (documentModels, { attachmentReferenceWriter, upgradeManifests }) => {
|
|
1229
|
-
if (options.reactor) {
|
|
1230
|
-
const attachmentReferenceProjection = await registerAttachmentReferenceReadModelOnModule(options.reactor, attachmentReferenceWriter);
|
|
1231
|
-
if (options.reactor.reactorModule) {
|
|
1232
|
-
new ReactorInstrumentation(options.reactor.reactorModule).start();
|
|
1233
|
-
reactorLogger.info("Reactor metrics instrumentation started (using caller-provided reactor)");
|
|
1234
|
-
}
|
|
1235
|
-
return {
|
|
1236
|
-
module: options.reactor,
|
|
1237
|
-
attachmentReferenceProjection
|
|
1238
|
-
};
|
|
1239
|
-
}
|
|
1240
|
-
const { kysely: baseKysely, poolInstrumentation } = await createReactorKysely({
|
|
1241
|
-
reactorDbUrl,
|
|
1242
|
-
reactorPgliteDir,
|
|
1243
|
-
reactorPgliteMajor,
|
|
1244
|
-
inMemory: PGLITE_IN_MEMORY,
|
|
1245
|
-
flushIntervalMs: PGLITE_FLUSH_INTERVAL_MS,
|
|
1246
|
-
hostPoolSize: () => resolveHostPoolSize(process.env),
|
|
1247
|
-
logger
|
|
1248
|
-
});
|
|
1249
|
-
const maxSkipThreshold = parseInt(process.env.MAX_SKIP_THRESHOLD ?? "", 10);
|
|
1250
|
-
const hasSkipThreshold = !isNaN(maxSkipThreshold) && maxSkipThreshold > 0;
|
|
1251
|
-
if (hasSkipThreshold) logger.info(`Reactor maxSkipThreshold set to ${maxSkipThreshold}`);
|
|
1252
|
-
const { flags: reactorFeatureFlags, enabled: enabledFeatureFlags } = resolveReactorFeatureFlags(process.env);
|
|
1253
|
-
if (enabledFeatureFlags.length > 0) logger.info(`Reactor feature flags enabled: ${enabledFeatureFlags.join(", ")}`);
|
|
1254
|
-
const reactorBuilder = new ReactorBuilder().withEventBus(new EventBus()).withKysely(baseKysely).withFeatures({ legacyProcessorIds: process.env.REACTOR_LEGACY_PROCESSOR_IDS !== "false" });
|
|
1255
|
-
if (poolInstrumentation) reactorBuilder.withInstrumentedPool(poolInstrumentation);
|
|
1256
|
-
const clientBuilder = new ReactorClientBuilder().withReactorBuilder(reactorBuilder);
|
|
1257
|
-
const vetraDocumentModels = dev ? Object.values(await import("@powerhousedao/vetra/document-models")).filter((m) => typeof m === "object" && m !== null && "documentModel" in m && "reducer" in m) : [];
|
|
1258
|
-
applySwitchboardReactorDefaults(reactorBuilder, clientBuilder, {
|
|
1259
|
-
documentModels: [...documentModels, ...vetraDocumentModels],
|
|
1260
|
-
upgradeManifests,
|
|
1261
|
-
executorConfig: hasSkipThreshold || enabledFeatureFlags.length > 0 ? {
|
|
1262
|
-
...hasSkipThreshold ? { maxSkipThreshold } : {},
|
|
1263
|
-
...enabledFeatureFlags.length > 0 ? { featureFlags: reactorFeatureFlags } : {}
|
|
1264
|
-
} : void 0,
|
|
1265
|
-
documentModelLoader: httpLoader && dynamicModelLoading ? httpLoader.documentModelLoader : void 0,
|
|
1266
|
-
logger: reactorLogger,
|
|
1267
|
-
signer: renown ? getRenownSignerConfig(renown, options.identity?.requireSignatures) : void 0
|
|
1268
|
-
});
|
|
1269
|
-
if (workerPool) {
|
|
1270
|
-
if (!reactorDbUrl) throw new Error("unreachable: worker pool enabled without a reactor database URL");
|
|
1271
|
-
const workerSources = await resolveWorkerModelSources(packages, reactorLogger);
|
|
1272
|
-
reactorBuilder.withDocumentModelSources(workerSources).withWorkerPool({
|
|
1273
|
-
numWorkers: workerPool.numWorkers,
|
|
1274
|
-
db: buildWorkerDbConfig(reactorDbUrl, workerPool)
|
|
1275
|
-
});
|
|
1276
|
-
reactorLogger.info(`Executor worker pool enabled: ${workerPool.numWorkers} worker threads${workerPool.mode === "auto" ? " (auto-sized from cores)" : ""}`);
|
|
1277
|
-
}
|
|
1278
|
-
reactorBuilder.withReadModelFactory(async ({ operationIndex, writeCache, processorManagerConsistencyTracker }) => {
|
|
1279
|
-
const nodeProcessor = new NodeProcessor(baseKysely, REACTOR_SCHEMA, operationIndex, writeCache, processorManagerConsistencyTracker);
|
|
1280
|
-
await nodeProcessor.init();
|
|
1281
|
-
return nodeProcessor;
|
|
1282
|
-
});
|
|
1283
|
-
registerAttachmentReferenceReadModel(reactorBuilder, {
|
|
1284
|
-
baseKysely,
|
|
1285
|
-
attachmentReferenceWriter
|
|
1286
|
-
});
|
|
1287
|
-
if (projectionWorker) {
|
|
1288
|
-
if (!reactorDbUrl) throw new Error("unreachable: projection worker enabled without a reactor database URL");
|
|
1289
|
-
if (!workerPool) {
|
|
1290
|
-
const workerSources = await resolveWorkerModelSources(packages, reactorLogger);
|
|
1291
|
-
reactorBuilder.withDocumentModelSources(workerSources);
|
|
1292
|
-
}
|
|
1293
|
-
const db = {
|
|
1294
|
-
...buildWorkerDbConfig(reactorDbUrl, {
|
|
1295
|
-
dbPoolSizePerWorker: projectionWorker.dbPoolSize,
|
|
1296
|
-
acquireTimeoutMs: workerPool?.acquireTimeoutMs ?? 5e3
|
|
1297
|
-
}),
|
|
1298
|
-
applicationName: "switchboard-projection"
|
|
1299
|
-
};
|
|
1300
|
-
reactorBuilder.withReadModelCoordinatorFactory(createHybridProjectionCoordinatorFactory({
|
|
1301
|
-
shardCount: 1,
|
|
1302
|
-
poolSize: projectionWorker.dbPoolSize,
|
|
1303
|
-
db,
|
|
1304
|
-
onFatal: onProjectionWorkerFatal
|
|
1305
|
-
}));
|
|
1306
|
-
reactorLogger.info(`Projection worker enabled: 1 worker thread, pool size ${projectionWorker.dbPoolSize}`);
|
|
1307
|
-
}
|
|
1308
|
-
reactorBuilder.withShutdownHook(async () => {
|
|
1309
|
-
if (apiRef.current) await apiRef.current.dispose();
|
|
1310
|
-
});
|
|
1311
|
-
const module = await clientBuilder.buildModule();
|
|
1312
|
-
if (module.reactorModule) {
|
|
1313
|
-
new ReactorInstrumentation(module.reactorModule).start();
|
|
1314
|
-
reactorLogger.info("Reactor metrics instrumentation started");
|
|
1315
|
-
}
|
|
1316
|
-
driveNodeView = new DriveNodeView(baseKysely.withSchema(REACTOR_SCHEMA));
|
|
1317
|
-
const reactorDriveClient = new ReactorDriveClient({
|
|
1318
|
-
reactor: module.client,
|
|
1319
|
-
readModel: driveNodeView
|
|
1320
|
-
});
|
|
1321
|
-
ownedReactorModule = module;
|
|
1322
|
-
return {
|
|
1323
|
-
module,
|
|
1324
|
-
reactorDriveClient,
|
|
1325
|
-
attachmentReferenceProjection: { status: "available" }
|
|
1326
|
-
};
|
|
1327
|
-
};
|
|
1328
|
-
const abortBoot = async (api) => {
|
|
1329
|
-
const owned = ownedReactorModule;
|
|
1330
|
-
if (owned) try {
|
|
1331
|
-
await owned.reactor.kill().completed;
|
|
1332
|
-
} catch (error) {
|
|
1333
|
-
logger.error("Aborting boot: reactor shutdown failed: @error", error);
|
|
1334
|
-
}
|
|
1335
|
-
try {
|
|
1336
|
-
await api.dispose();
|
|
1337
|
-
} catch (error) {
|
|
1338
|
-
logger.error("Aborting boot: api dispose failed: @error", error);
|
|
1339
|
-
}
|
|
1340
|
-
if (owned?.reactorModule) try {
|
|
1341
|
-
await owned.reactorModule.database.destroy();
|
|
1342
|
-
} catch (error) {
|
|
1343
|
-
logger.error("Aborting boot: database destroy failed: @error", error);
|
|
1344
|
-
}
|
|
1345
|
-
};
|
|
1346
|
-
let localCredentialCheck;
|
|
1347
|
-
const verifyCredential = renownConfig.source === "self" ? (params) => localCredentialCheck ? localCredentialCheck(params) : Promise.reject(/* @__PURE__ */ new Error("The local renown read model is not bound yet")) : void 0;
|
|
1348
|
-
let defaultDriveUrl = void 0;
|
|
1349
|
-
const basePath = process.cwd();
|
|
1350
|
-
let vite;
|
|
1351
|
-
let viteLoader;
|
|
1352
|
-
if (dev) {
|
|
1353
|
-
const { VitePackageLoader, createViteLogger, startViteServer } = await import("@powerhousedao/reactor-api/vite");
|
|
1354
|
-
vite = await startViteServer(process.cwd(), createViteLogger(logger));
|
|
1355
|
-
viteLoader = VitePackageLoader.build(vite);
|
|
1356
|
-
}
|
|
1357
|
-
const vetraProcessorFactory = dev ? (await import("@powerhousedao/vetra/processors")).processorFactory : void 0;
|
|
1358
|
-
if (!options.disableLocalPackages) packages.push(basePath);
|
|
1359
|
-
const packageLoaders = [];
|
|
1360
|
-
if (viteLoader) packageLoaders.push(viteLoader);
|
|
1361
|
-
else packageLoaders.push(new ImportPackageLoader());
|
|
1362
|
-
if (httpLoader) {
|
|
1363
|
-
packageLoaders.push(httpLoader);
|
|
1364
|
-
registryPackages?.split(",").forEach((p) => {
|
|
1365
|
-
const name = p.trim();
|
|
1366
|
-
if (!packages.includes(name)) packages.push(name);
|
|
1367
|
-
});
|
|
1368
|
-
}
|
|
1369
|
-
const apiLogger = logger.child(["reactor-api"]);
|
|
1370
|
-
let pgliteFactory;
|
|
1371
|
-
if (readModelPgliteDir && readModelPgliteMajor !== null) {
|
|
1372
|
-
const { PGlite: ReadModelPGlite } = await loadPGliteModule(readModelPgliteMajor);
|
|
1373
|
-
pgliteFactory = PGLITE_IN_MEMORY ? () => new ReadModelPGlite({ parsers: PGLITE_UTC_PARSERS }) : (connectionString) => new ReadModelPGlite({
|
|
1374
|
-
fs: new AtomicNodeFs(connectionString ?? readModelPgliteDir, {
|
|
1375
|
-
logger,
|
|
1376
|
-
flushIntervalMs: PGLITE_FLUSH_INTERVAL_MS
|
|
1377
|
-
}),
|
|
1378
|
-
parsers: PGLITE_UTC_PARSERS
|
|
1379
|
-
});
|
|
1380
|
-
}
|
|
1381
|
-
const api = await initializeAndStartAPI(initializeClient, {
|
|
1382
|
-
port: serverPort,
|
|
1383
|
-
dbPath: readModelPath,
|
|
1384
|
-
pgliteFactory,
|
|
1385
|
-
https: options.https,
|
|
1386
|
-
packageLoaders: packageLoaders.length > 0 ? packageLoaders : void 0,
|
|
1387
|
-
packages,
|
|
1388
|
-
processorConfig: options.processorConfig,
|
|
1389
|
-
processors: vetraProcessorFactory ? { "@powerhousedao/vetra": [vetraProcessorFactory] } : {},
|
|
1390
|
-
configFile: configPath,
|
|
1391
|
-
mcp: options.mcp ?? true,
|
|
1392
|
-
logger: apiLogger,
|
|
1393
|
-
enableDocumentModelSubgraphs: options.enableDocumentModelSubgraphs,
|
|
1394
|
-
renown: renownConfig,
|
|
1395
|
-
verifyCredential
|
|
1396
|
-
}, "switchboard");
|
|
1397
|
-
apiRef.current = api;
|
|
1398
|
-
if (renownConfig.source === "self") {
|
|
1399
|
-
const { graphqlManager: manager } = api;
|
|
1400
|
-
if (!manager.hasSubgraphHandler(RENOWN_READ_MODEL_SUBGRAPH)) {
|
|
1401
|
-
await abortBoot(api);
|
|
1402
|
-
throw new Error(`Renown credential verification is set to "self" (auth.renown.source or RENOWN_SOURCE) but no loaded package serves the "${RENOWN_READ_MODEL_SUBGRAPH}" subgraph. Install one (@powerhousedao/renown-package) or set RENOWN_SOURCE=remote.`);
|
|
1403
|
-
}
|
|
1404
|
-
localCredentialCheck = createLocalCredentialVerifier((query, variables) => manager.executeSubgraphQuery(RENOWN_READ_MODEL_SUBGRAPH, query, variables), { onError: (error) => logger.error("Renown read model query failed: @error", error) });
|
|
1405
|
-
logger.info("Renown credentials will be verified against this switchboard's own renown read model");
|
|
1406
|
-
}
|
|
1407
|
-
registerAttachmentRoutes(api);
|
|
1408
|
-
const attachmentService = createRemoteAttachmentService(deriveAttachmentServiceConfig(options, serverPort, renown));
|
|
1409
|
-
if (process.env.SENTRY_DSN) api.httpAdapter.setupSentryErrorHandler(Sentry);
|
|
1410
|
-
const { client, graphqlManager, documentModelRegistry } = api;
|
|
1411
|
-
const lateSubgraphs = [];
|
|
1412
|
-
if (httpLoader) {
|
|
1413
|
-
const packageManagementService = new PackageManagementService({
|
|
1414
|
-
defaultRegistryUrl: registryUrl,
|
|
1415
|
-
httpLoader,
|
|
1416
|
-
documentModelRegistry,
|
|
1417
|
-
packageManager: api.packageManager
|
|
1418
|
-
});
|
|
1419
|
-
packageManagementService.setOnModelsChanged(() => {
|
|
1420
|
-
graphqlManager.regenerateDocumentModelSubgraphs().catch((error) => {
|
|
1421
|
-
logger.error("Failed to regenerate document model subgraphs: @error", error);
|
|
1422
|
-
});
|
|
1423
|
-
});
|
|
1424
|
-
const packagesSubgraph = new PackagesSubgraph({
|
|
1425
|
-
relationalDb: void 0,
|
|
1426
|
-
analyticsStore: void 0,
|
|
1427
|
-
reactorClient: client,
|
|
1428
|
-
graphqlManager,
|
|
1429
|
-
syncManager: api.syncManager,
|
|
1430
|
-
path: graphqlManager.getBasePath(),
|
|
1431
|
-
authorizationService: graphqlManager.getAuthorizationService(),
|
|
1432
|
-
packageManagementService,
|
|
1433
|
-
http: graphqlManager.scopeForPackage("@powerhousedao/switchboard")
|
|
1434
|
-
});
|
|
1435
|
-
lateSubgraphs.push(graphqlManager.registerSubgraphInstance(packagesSubgraph, "graphql", false).catch((error) => {
|
|
1436
|
-
logger.error("Failed to register packages subgraph: @error", error);
|
|
1437
|
-
}));
|
|
1438
|
-
}
|
|
1439
|
-
if (driveNodeView) {
|
|
1440
|
-
graphqlManager.setAdditionalContextFields({ readModel: driveNodeView });
|
|
1441
|
-
const reactorDriveSubgraph = {
|
|
1442
|
-
name: "reactor-drive",
|
|
1443
|
-
path: graphqlManager.getBasePath(),
|
|
1444
|
-
resolvers: createReactorDriveResolvers(),
|
|
1445
|
-
typeDefs: reactorDriveSubgraphTypeDefs,
|
|
1446
|
-
reactorClient: client,
|
|
1447
|
-
relationalDb: void 0
|
|
1448
|
-
};
|
|
1449
|
-
lateSubgraphs.push(graphqlManager.registerSubgraphInstance(reactorDriveSubgraph, "graphql", false).catch((error) => {
|
|
1450
|
-
logger.error("Failed to register reactor-drive subgraph: @error", error);
|
|
1451
|
-
}));
|
|
1452
|
-
}
|
|
1453
|
-
(async () => {
|
|
1454
|
-
await Promise.all(lateSubgraphs);
|
|
1455
|
-
try {
|
|
1456
|
-
await graphqlManager.updateRouter(true);
|
|
1457
|
-
} catch (error) {
|
|
1458
|
-
logger.error("Final router update before readiness failed: @error", error);
|
|
1459
|
-
}
|
|
1460
|
-
api.readiness.markReady();
|
|
1461
|
-
})();
|
|
1462
|
-
if (options.drive) {
|
|
1463
|
-
if (!renown) throw new Error("Cannot create default drive without Renown identity");
|
|
1464
|
-
if ((options.drive.documentType ?? "powerhouse/document-drive") === "powerhouse/reactor-drive") defaultDriveUrl = await addDefaultReactorDrive(client, options.drive, serverPort);
|
|
1465
|
-
else defaultDriveUrl = await addDefaultDrive(client, options.drive, serverPort);
|
|
1466
|
-
}
|
|
1467
|
-
if (vite) api.httpAdapter.mountRawMiddleware(vite.middlewares);
|
|
1468
|
-
if (remoteDrives.length > 0) for (const remoteDriveUrl of remoteDrives) {
|
|
1469
|
-
let driveId;
|
|
1470
|
-
try {
|
|
1471
|
-
const { syncManager } = api;
|
|
1472
|
-
const parsed = parseDriveUrl(remoteDriveUrl);
|
|
1473
|
-
driveId = parsed.driveId;
|
|
1474
|
-
const remoteName = `remote-drive-${driveId}-${crypto.randomUUID()}`;
|
|
1475
|
-
await syncManager.add(remoteName, DriveCollectionId.forDrive(driveId), {
|
|
1476
|
-
type: "gql",
|
|
1477
|
-
parameters: { url: parsed.graphqlEndpoint }
|
|
1478
|
-
});
|
|
1479
|
-
logger.debug("Remote drive @remoteDriveUrl synced", remoteDriveUrl);
|
|
1480
|
-
} catch (error) {
|
|
1481
|
-
if (error instanceof Error && error.message.includes("already exists")) {
|
|
1482
|
-
logger.debug("Remote drive already added: @remoteDriveUrl", remoteDriveUrl);
|
|
1483
|
-
driveId = remoteDriveUrl.split("/").pop();
|
|
1484
|
-
} else logger.error("Failed to connect to remote drive @remoteDriveUrl: @error", remoteDriveUrl, error);
|
|
1485
|
-
} finally {
|
|
1486
|
-
if (!defaultDriveUrl && driveId) defaultDriveUrl = `${options.https ? "https" : "http"}://localhost:${serverPort}/d/${driveId}`;
|
|
1487
|
-
}
|
|
1488
|
-
}
|
|
1489
|
-
return {
|
|
1490
|
-
defaultDriveUrl,
|
|
1491
|
-
api,
|
|
1492
|
-
reactor: client,
|
|
1493
|
-
attachmentService,
|
|
1494
|
-
attachmentReferenceProjection: api.attachmentReferenceProjection,
|
|
1495
|
-
renown,
|
|
1496
|
-
port: serverPort,
|
|
1497
|
-
shutdown: () => api.dispose()
|
|
1498
|
-
};
|
|
1499
|
-
}
|
|
1500
|
-
/**
|
|
1501
|
-
* Boot the switchboard HTTP/GraphQL/MCP stack on top of a reactor.
|
|
1502
|
-
*
|
|
1503
|
-
* If `options.reactor` is provided, the switchboard reuses it instead of
|
|
1504
|
-
* building its own — the caller then owns the reactor's lifecycle and is
|
|
1505
|
-
* responsible for invoking `SwitchboardReactor.shutdown()` from their own
|
|
1506
|
-
* teardown / SIGINT path. The switchboard will not reach into the caller's
|
|
1507
|
-
* reactor; killing the reactor alone leaves the api/GraphQL/MCP resources
|
|
1508
|
-
* dangling until the process exits.
|
|
1509
|
-
*
|
|
1510
|
-
* When `options.reactor` is omitted, the switchboard builds and owns the
|
|
1511
|
-
* reactor. `shutdown()` on the returned handle only drains the api (HTTP
|
|
1512
|
-
* server, GraphQL, MCP, attachments); the reactor itself is torn down by
|
|
1513
|
-
* its own signal handlers (`withSignalHandlers`), which call `kill()` and
|
|
1514
|
-
* trigger the `withShutdownHook` chain that disposes the api. Programmatic
|
|
1515
|
-
* full teardown isn't currently exposed — wire it via SIGINT/SIGTERM.
|
|
1516
|
-
*/
|
|
1517
|
-
const startSwitchboard = async (options = {}) => {
|
|
1518
|
-
const requestedPort = options.port ?? DEFAULT_PORT;
|
|
1519
|
-
const logger = options.logger ?? defaultLogger;
|
|
1520
|
-
const serverPort = await resolveServerPort(requestedPort, options.strictPort ?? false, logger);
|
|
1521
|
-
const featureFlags = await initFeatureFlags();
|
|
1522
|
-
const enableDocumentModelSubgraphs = await featureFlags.getBooleanValue(DOCUMENT_MODEL_SUBGRAPHS_ENABLED, options.enableDocumentModelSubgraphs ?? DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT);
|
|
1523
|
-
options.enableDocumentModelSubgraphs = enableDocumentModelSubgraphs;
|
|
1524
|
-
const requireSignatures = options.identity?.requireSignatures ?? await featureFlags.getBooleanValue(REQUIRE_SIGNATURES, REQUIRE_SIGNATURES_DEFAULT);
|
|
1525
|
-
const renownConfig = resolveRenownConfig(getConfig(resolveConfigPath(options.configFile)).auth?.renown, process.env, logger);
|
|
1526
|
-
options.identity = {
|
|
1527
|
-
...options.identity,
|
|
1528
|
-
requireSignatures,
|
|
1529
|
-
baseUrl: options.identity?.baseUrl ?? renownConfig.url
|
|
1530
|
-
};
|
|
1531
|
-
logger.info("Feature flags: @flags", JSON.stringify({
|
|
1532
|
-
DOCUMENT_MODEL_SUBGRAPHS_ENABLED: enableDocumentModelSubgraphs,
|
|
1533
|
-
REQUIRE_SIGNATURES: requireSignatures
|
|
1534
|
-
}, null, 2));
|
|
1535
|
-
let renown = null;
|
|
1536
|
-
try {
|
|
1537
|
-
renown = await initRenown(options.identity);
|
|
1538
|
-
} catch (e) {
|
|
1539
|
-
logger.warn("Failed to initialize ConnectCrypto: @error", e);
|
|
1540
|
-
if (options.identity.requireExisting) throw new Error("Identity required but failed to initialize. Run \"ph login\" first.", { cause: e });
|
|
1541
|
-
}
|
|
1542
|
-
try {
|
|
1543
|
-
return await initServer(serverPort, options, renown, renownConfig);
|
|
1544
|
-
} catch (e) {
|
|
1545
|
-
Sentry.captureException(e);
|
|
1546
|
-
logger.error("App crashed: @error", e);
|
|
1547
|
-
throw e;
|
|
1548
|
-
}
|
|
1549
|
-
};
|
|
1550
|
-
if (import.meta.main) await startSwitchboard();
|
|
1551
|
-
//#endregion
|
|
1552
|
-
export { parseForcePgVersion as a, applySwitchboardReactorDefaults as i, isPortAvailable as n, startSwitchboard as r, deriveAttachmentServiceConfig as t };
|
|
1553
|
-
|
|
1554
|
-
//# sourceMappingURL=server-CiKkVE6g.mjs.map
|
|
1555
|
-
//# debugId=e2c62b7b-68fd-59c2-ab82-7d035e11698b
|