@powerhousedao/switchboard 6.2.3-dev.43 → 6.2.3-dev.45
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/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/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/snapshot-writer.d.ts +18 -0
- package/.tsbuild/test/snapshot-writer.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/tsconfig.tsbuildinfo +1 -1
- package/CHANGELOG.md +43 -0
- package/dist/index.mjs +42 -9
- package/dist/index.mjs.map +1 -1
- package/dist/{server-CMAxYG6D.mjs → server-Dvw8_NeM.mjs} +271 -79
- package/dist/server-Dvw8_NeM.mjs.map +1 -0
- package/dist/server.d.mts +3 -1
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +3 -3
- package/package.json +15 -16
- package/test/boot-unsupported-stored-documents.test.ts +5 -5
- package/test/durable-node-fs-pg16.test.ts +80 -0
- package/test/fatal-shutdown.test.ts +33 -0
- package/test/observability-signals.test.ts +131 -0
- package/test/pglite-dialect.test.ts +28 -0
- package/test/pglite-preflight.test.ts +313 -0
- package/test/pglite-version.test.ts +9 -1
- package/test/snapshot-writer.ts +94 -0
- package/test/worker-pool.test.ts +27 -1
- package/test/workflow/resolvers.test.ts +26 -0
- package/test/workflow-reactor-access.test.ts +384 -0
- package/test/workflow-reactor-enforcement.test.ts +805 -0
- package/test/workflow-runtime.test.ts +53 -0
- package/tsconfig.json +0 -3
- package/dist/server-CMAxYG6D.mjs.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="b9c02424-1406-5d63-abbd-6c32caa53205")}catch(e){}}();
|
|
3
3
|
import { n as addDefaultReactorDrive, r as isPostgresUrl, t as addDefaultDrive } from "./utils-uaBlzyjS.mjs";
|
|
4
4
|
import { createRequire, register } from "node:module";
|
|
5
5
|
import { ReactorInstrumentation } from "@powerhousedao/opentelemetry-instrumentation-reactor";
|
|
@@ -9,7 +9,7 @@ import dotenv from "dotenv";
|
|
|
9
9
|
import { getConfig } from "@powerhousedao/config/node";
|
|
10
10
|
import { existsSync, promises, realpathSync } from "node:fs";
|
|
11
11
|
import path, { resolve } from "node:path";
|
|
12
|
-
import {
|
|
12
|
+
import { createDurableNodeFs, preparePgliteDataDir, removeStalePgliteFiles, resolvePgliteFsync, syncTree } from "@powerhousedao/reactor-api/pglite-node";
|
|
13
13
|
import { ChannelScheme, DriveCollectionId, EventBus, REACTOR_SCHEMA, ReactorBuilder, ReactorClientBuilder, UnsupportedStoredProtocolError, createHybridProjectionCoordinatorFactory, instrumentPgPool, parseDriveUrl, supportsLiveReadModelRegistration } from "@powerhousedao/reactor";
|
|
14
14
|
import { AuthorizationPolicy, BaseSubgraph, ForbiddenError, HttpPackageLoader, ImportPackageLoader, PGLITE_UTC_PARSERS, PackageManagementService, PackagesSubgraph, PrivacyPermissionAdapter, callerSubject, createCanonicalDocumentIdResolver, getUniqueDocumentModels, initializeAndStartAPI, resolveRenownConfig } from "@powerhousedao/reactor-api";
|
|
15
15
|
import { httpsHooksPath } from "@powerhousedao/reactor-api/https-hooks";
|
|
@@ -29,7 +29,7 @@ import { driveDocumentModelModule } from "@powerhousedao/shared/document-drive";
|
|
|
29
29
|
import { DEFAULT_SIGNATURE_POLICY, isSignaturePolicy } from "@powerhousedao/shared/document-model";
|
|
30
30
|
import { readFile } from "node:fs/promises";
|
|
31
31
|
import os from "node:os";
|
|
32
|
-
import { fileURLToPath } from "node:url";
|
|
32
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
33
33
|
import { EnvVarProvider } from "@openfeature/env-var-provider";
|
|
34
34
|
import { OpenFeature } from "@openfeature/server-sdk";
|
|
35
35
|
import { GraphQLError, Kind } from "graphql";
|
|
@@ -65,6 +65,19 @@ async function loadPGliteModule(major) {
|
|
|
65
65
|
if (major === 16) return await import("pglite-legacy-02");
|
|
66
66
|
return import("@electric-sql/pglite");
|
|
67
67
|
}
|
|
68
|
+
/** The stock NodeFS class of the PGlite module that opens `major` dirs. */
|
|
69
|
+
async function loadNodeFsClass(major) {
|
|
70
|
+
if (major === 16) return (await import("pglite-legacy-02/nodefs")).NodeFS;
|
|
71
|
+
return (await import("@electric-sql/pglite/nodefs")).NodeFS;
|
|
72
|
+
}
|
|
73
|
+
/** Opens a converted dir over stock NodeFS so the conversion can check it. */
|
|
74
|
+
async function openForVerify(major, dataDir) {
|
|
75
|
+
if (!isSupportedMajor(major)) throw new Error(`Cannot verify PGlite data dir ${dataDir}: PG_VERSION=${major} is not supported (expected one of ${SUPPORTED_PG_MAJORS.join(", ")})`);
|
|
76
|
+
const [{ PGlite }, NodeFS] = await Promise.all([loadPGliteModule(major), loadNodeFsClass(major)]);
|
|
77
|
+
const pg = new PGlite({ fs: new NodeFS(dataDir) });
|
|
78
|
+
await pg.waitReady;
|
|
79
|
+
return pg;
|
|
80
|
+
}
|
|
68
81
|
async function loadPgDump(major) {
|
|
69
82
|
if (major === 16) return (await import("pglite-tools-legacy-02/pg_dump")).pgDump;
|
|
70
83
|
return (await import("@electric-sql/pglite-tools/pg_dump")).pgDump;
|
|
@@ -798,9 +811,11 @@ const fatalHandlers = /* @__PURE__ */ new WeakMap();
|
|
|
798
811
|
/**
|
|
799
812
|
* Sends an uncaught exception or unhandled rejection through the SIGTERM
|
|
800
813
|
* shutdown that `withSignalHandlers()` installs, so the reactor and read-model
|
|
801
|
-
* PGlite stores
|
|
802
|
-
* Node's default is to exit at once, which
|
|
803
|
-
*
|
|
814
|
+
* PGlite stores close with a shutdown checkpoint before the process exits
|
|
815
|
+
* with code 1. Node's default is to exit at once, which leaves both stores
|
|
816
|
+
* to WAL recovery on the next open. A store whose wasm runtime aborted
|
|
817
|
+
* (ENOSPC) rejects its close; the builder logs that and still exits. A
|
|
818
|
+
* shutdown that hangs is cut off after FORCED_EXIT_MS.
|
|
804
819
|
*
|
|
805
820
|
* A rejection is fatal only when this is its sole listener: Node exits on an
|
|
806
821
|
* unhandled rejection only when nothing listens for it, and Sentry's
|
|
@@ -810,13 +825,26 @@ function installFatalErrorShutdown(logger, proc = process) {
|
|
|
810
825
|
if (fatalHandlers.has(proc)) return;
|
|
811
826
|
const realExit = proc.exit.bind(proc);
|
|
812
827
|
let shuttingDown = false;
|
|
828
|
+
/**
|
|
829
|
+
* Logs without ever throwing. onFatal runs inside the uncaughtException
|
|
830
|
+
* handler, so a logger whose transport is gone (EPIPE on a closed stdout)
|
|
831
|
+
* would otherwise throw from the handler that reports throws, killing the
|
|
832
|
+
* process before the shutdown it is supposed to run. A logging failure is
|
|
833
|
+
* swallowed: there is nowhere left to report it.
|
|
834
|
+
*/
|
|
835
|
+
const logFatal = (message, err) => {
|
|
836
|
+
try {
|
|
837
|
+
if (err === void 0) logger.error(message);
|
|
838
|
+
else logger.error(message, err);
|
|
839
|
+
} catch {}
|
|
840
|
+
};
|
|
813
841
|
const onFatal = (kind, err) => {
|
|
814
|
-
|
|
842
|
+
logFatal(`${kind}: @error`, err);
|
|
815
843
|
if (shuttingDown) return;
|
|
816
844
|
shuttingDown = true;
|
|
817
845
|
proc.exitCode = 1;
|
|
818
846
|
setTimeout(() => {
|
|
819
|
-
|
|
847
|
+
logFatal("Shutdown after fatal error timed out; exiting");
|
|
820
848
|
realExit(1);
|
|
821
849
|
}, FORCED_EXIT_MS).unref();
|
|
822
850
|
proc.kill(proc.pid, "SIGTERM");
|
|
@@ -1035,6 +1063,20 @@ const BASE_MODEL_SPECIFIERS = [
|
|
|
1035
1063
|
"@powerhousedao/reactor-drive",
|
|
1036
1064
|
"@powerhousedao/reactor-group/document-models"
|
|
1037
1065
|
];
|
|
1066
|
+
function isModelModule(value) {
|
|
1067
|
+
const candidate = value;
|
|
1068
|
+
return typeof candidate?.reducer === "function" && typeof candidate.documentModel?.global?.id === "string";
|
|
1069
|
+
}
|
|
1070
|
+
async function keepImportableSources(sources, logger) {
|
|
1071
|
+
const kept = [];
|
|
1072
|
+
for (const source of sources) try {
|
|
1073
|
+
const exports = await import(pathToFileURL(source.filePath).href);
|
|
1074
|
+
if (Object.values(exports).some(isModelModule)) kept.push(source);
|
|
1075
|
+
} catch (error) {
|
|
1076
|
+
logger.warn(`Worker model sources: "${source.filePath}" does not import, so workflow pieces will not see its models: ${String(error)}`);
|
|
1077
|
+
}
|
|
1078
|
+
return kept;
|
|
1079
|
+
}
|
|
1038
1080
|
/**
|
|
1039
1081
|
* Resolves base models and configured package identifiers to `{ filePath }`
|
|
1040
1082
|
* sources for the reactor builder. Resolution to absolute paths happens here
|
|
@@ -1349,6 +1391,7 @@ function toStepRecord(row) {
|
|
|
1349
1391
|
output: parseJson(row.output),
|
|
1350
1392
|
port: row.port,
|
|
1351
1393
|
error: row.error,
|
|
1394
|
+
errorName: row.error_name,
|
|
1352
1395
|
startedAt: row.started_at,
|
|
1353
1396
|
endedAt: row.ended_at,
|
|
1354
1397
|
pieceVersion: row.piece_version,
|
|
@@ -1367,6 +1410,7 @@ function toRunRecord(row, steps) {
|
|
|
1367
1410
|
triggerPayload: parseJson(row.trigger_payload),
|
|
1368
1411
|
status: row.status,
|
|
1369
1412
|
error: row.error,
|
|
1413
|
+
errorName: row.error_name,
|
|
1370
1414
|
startedAt: row.started_at,
|
|
1371
1415
|
endedAt: row.ended_at,
|
|
1372
1416
|
rerunOf: row.rerun_of,
|
|
@@ -1375,6 +1419,14 @@ function toRunRecord(row, steps) {
|
|
|
1375
1419
|
steps: steps.map(toStepRecord)
|
|
1376
1420
|
};
|
|
1377
1421
|
}
|
|
1422
|
+
const UNKNOWN_ACCESS = {
|
|
1423
|
+
authEnforcement: true,
|
|
1424
|
+
authConditions: false,
|
|
1425
|
+
identity: null
|
|
1426
|
+
};
|
|
1427
|
+
function signedIn(ctx) {
|
|
1428
|
+
return Boolean(ctx.user?.address);
|
|
1429
|
+
}
|
|
1378
1430
|
function isAbsolute(url) {
|
|
1379
1431
|
return /^https?:\/\//i.test(url);
|
|
1380
1432
|
}
|
|
@@ -1403,13 +1455,21 @@ function returnUrlFor(requested, ctx) {
|
|
|
1403
1455
|
if (!url || !origin || url.origin !== origin) throw new GraphQLError("returnUrl must be on the requesting page's origin");
|
|
1404
1456
|
return url.href;
|
|
1405
1457
|
}
|
|
1406
|
-
const getResolvers = (runtime, authorizationService, oauth) => {
|
|
1458
|
+
const getResolvers = (runtime, authorizationService, oauth, access = UNKNOWN_ACCESS, assertCanRead) => {
|
|
1407
1459
|
return {
|
|
1408
1460
|
Query: { workflowRuntime: () => ({}) },
|
|
1409
1461
|
WorkflowRuntimeQueries: {
|
|
1410
1462
|
health: () => "ok",
|
|
1463
|
+
authEnforcement: () => access.authEnforcement,
|
|
1464
|
+
reactorIdentity: (_parent, _args, ctx) => signedIn(ctx) ? access.identity : null,
|
|
1465
|
+
authConditions: (_parent, _args, ctx) => signedIn(ctx) ? access.authConditions : null,
|
|
1466
|
+
reactorAccessDenial: async (_parent, args, ctx) => {
|
|
1467
|
+
if (!assertCanRead) throw new GraphQLError("No read check configured");
|
|
1468
|
+
await assertCanRead(args.workflowId, ctx);
|
|
1469
|
+
return runtime.reactorAccessDenial(args.workflowId) ?? null;
|
|
1470
|
+
},
|
|
1411
1471
|
blockDescriptor: (_parent, args) => runtime.blockDescriptor(blockRef(args.block)),
|
|
1412
|
-
blockOptions: (_parent, args, ctx) => runtime.blockOptions(blockRef(args.block), args.propName, args.input, args.connectionId ?? void 0, ctx, args.searchValue ?? void 0),
|
|
1472
|
+
blockOptions: (_parent, args, ctx) => runtime.blockOptions(blockRef(args.block), args.propName, args.input, args.connectionId ?? void 0, ctx, args.searchValue ?? void 0, args.reactorConnectionId ?? void 0),
|
|
1413
1473
|
pieceCatalog: () => runtime.pieceCatalog(),
|
|
1414
1474
|
pieceActions: (_parent, args) => runtime.pieceActions(args.packageName, args.version ?? void 0),
|
|
1415
1475
|
pieceTriggers: (_parent, args) => runtime.pieceTriggers(args.packageName, args.version ?? void 0),
|
|
@@ -1520,6 +1580,28 @@ const schema = gql`
|
|
|
1520
1580
|
type WorkflowRuntimeQueries {
|
|
1521
1581
|
health: String!
|
|
1522
1582
|
"""
|
|
1583
|
+
Whether this reactor enforces document auth (REACTOR_AUTH_ENFORCEMENT).
|
|
1584
|
+
On, a workflow needs a signed publish, and the host grants, to reach
|
|
1585
|
+
documents. Answered to any caller.
|
|
1586
|
+
"""
|
|
1587
|
+
authEnforcement: Boolean!
|
|
1588
|
+
"""
|
|
1589
|
+
Who this host signs as: grant it on each document a step writes. Null for
|
|
1590
|
+
a caller who is not signed in, or a host with no signer.
|
|
1591
|
+
"""
|
|
1592
|
+
reactorIdentity: ReactorIdentity
|
|
1593
|
+
"""
|
|
1594
|
+
REACTOR_AUTH_CONDITIONS. True: grant the host by key, with
|
|
1595
|
+
{ match: subject.key == key }; false: by address. Null for a caller who
|
|
1596
|
+
is not signed in.
|
|
1597
|
+
"""
|
|
1598
|
+
authConditions: Boolean
|
|
1599
|
+
"""
|
|
1600
|
+
Why the workflow's last publish or enable got no reactor access, naming
|
|
1601
|
+
the publisher and the connection; null when the check passed.
|
|
1602
|
+
"""
|
|
1603
|
+
reactorAccessDenial(workflowId: String!): String
|
|
1604
|
+
"""
|
|
1523
1605
|
Persisted runs, newest first. Scope them to one workflow, or to every
|
|
1524
1606
|
workflow a drive holds; workflowId wins when both are given.
|
|
1525
1607
|
"""
|
|
@@ -1560,6 +1642,8 @@ const schema = gql`
|
|
|
1560
1642
|
connectionId: String
|
|
1561
1643
|
"What the author typed, for a DROPDOWN declared with refreshOnSearch."
|
|
1562
1644
|
searchValue: String
|
|
1645
|
+
"The step's reactor connection; its filter narrows what the resolver reads."
|
|
1646
|
+
reactorConnectionId: String
|
|
1563
1647
|
): Unknown
|
|
1564
1648
|
"""
|
|
1565
1649
|
Every piece this reactor offers: the core piece, its package pieces, and
|
|
@@ -1737,6 +1821,13 @@ const schema = gql`
|
|
|
1737
1821
|
accountLabel: String
|
|
1738
1822
|
}
|
|
1739
1823
|
|
|
1824
|
+
type ReactorIdentity {
|
|
1825
|
+
"The address the host signs as; null without a signed-in identity."
|
|
1826
|
+
address: String
|
|
1827
|
+
"The did:key that signs the host's operations."
|
|
1828
|
+
key: String!
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1740
1831
|
type ConnectionCheckResult {
|
|
1741
1832
|
ok: Boolean!
|
|
1742
1833
|
detail: String
|
|
@@ -1798,6 +1889,8 @@ const schema = gql`
|
|
|
1798
1889
|
output: Unknown
|
|
1799
1890
|
port: String
|
|
1800
1891
|
error: String
|
|
1892
|
+
"The thrown error's name, e.g. ReactorAccessDeniedError."
|
|
1893
|
+
errorName: String
|
|
1801
1894
|
startedAt: String
|
|
1802
1895
|
endedAt: String
|
|
1803
1896
|
"The piece version that ran; null for an unresolved block."
|
|
@@ -1819,6 +1912,7 @@ const schema = gql`
|
|
|
1819
1912
|
triggerPayload: Unknown
|
|
1820
1913
|
status: String!
|
|
1821
1914
|
error: String
|
|
1915
|
+
errorName: String
|
|
1822
1916
|
"When the run began executing; a PENDING run's is when it was journaled."
|
|
1823
1917
|
startedAt: String!
|
|
1824
1918
|
endedAt: String
|
|
@@ -1856,12 +1950,14 @@ const schema = gql`
|
|
|
1856
1950
|
output: Unknown
|
|
1857
1951
|
port: String
|
|
1858
1952
|
error: String
|
|
1953
|
+
errorName: String
|
|
1859
1954
|
}
|
|
1860
1955
|
|
|
1861
1956
|
type WorkflowRunPayload {
|
|
1862
1957
|
runId: String
|
|
1863
1958
|
status: String!
|
|
1864
1959
|
error: String
|
|
1960
|
+
errorName: String
|
|
1865
1961
|
steps: [WorkflowStepRun!]!
|
|
1866
1962
|
}
|
|
1867
1963
|
|
|
@@ -1872,6 +1968,7 @@ const schema = gql`
|
|
|
1872
1968
|
status: String!
|
|
1873
1969
|
output: Unknown
|
|
1874
1970
|
error: String
|
|
1971
|
+
errorName: String
|
|
1875
1972
|
durationMs: Int!
|
|
1876
1973
|
}
|
|
1877
1974
|
|
|
@@ -1956,11 +2053,11 @@ const schema = gql`
|
|
|
1956
2053
|
//#region src/workflow/subgraph.ts
|
|
1957
2054
|
/** The runtime's read/write surface. The runtime itself is composed by the
|
|
1958
2055
|
* host, so the subgraph only serves what it is handed. */
|
|
1959
|
-
function createWorkflowRuntimeSubgraph(runtime, oauth) {
|
|
2056
|
+
function createWorkflowRuntimeSubgraph(runtime, oauth, access) {
|
|
1960
2057
|
return class WorkflowRuntimeSubgraph extends BaseSubgraph {
|
|
1961
2058
|
name = "workflow-runtime";
|
|
1962
2059
|
typeDefs = schema;
|
|
1963
|
-
resolvers = getResolvers(runtime, this.authorizationService, oauth);
|
|
2060
|
+
resolvers = getResolvers(runtime, this.authorizationService, oauth, access, (documentId, ctx) => this.assertCanRead(documentId, ctx));
|
|
1964
2061
|
additionalContextFields = {};
|
|
1965
2062
|
};
|
|
1966
2063
|
}
|
|
@@ -1986,6 +2083,12 @@ async function assertWorkflowPackageLoadable(load = () => import("@powerhousedao
|
|
|
1986
2083
|
throw new Error(`Workflows are enabled but ${WORKFLOW_PACKAGE_NAME} could not be loaded`, { cause: error });
|
|
1987
2084
|
}
|
|
1988
2085
|
}
|
|
2086
|
+
function modelManifestSource(builder) {
|
|
2087
|
+
return {
|
|
2088
|
+
modelManifest: () => builder.getResolvedModelManifest() ?? [],
|
|
2089
|
+
modelEntries: (documentType) => builder.getImportableEntries(documentType)
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
1989
2092
|
function readAssertion(authorizationService, reactorClient) {
|
|
1990
2093
|
const resolveCanonical = createCanonicalDocumentIdResolver(reactorClient);
|
|
1991
2094
|
const hostCanRead = async (identifier, ctx) => {
|
|
@@ -2027,6 +2130,37 @@ function writeAssertion(authorizationService, reactorClient) {
|
|
|
2027
2130
|
if (!await authorizationService.canWrite(documentId, ctx.user?.address)) throw new ForbiddenError("to write this document");
|
|
2028
2131
|
};
|
|
2029
2132
|
}
|
|
2133
|
+
/** What the reactor enforces and who it signs as; unknown counts as enforced. */
|
|
2134
|
+
function reactorAccessOf(clientModule) {
|
|
2135
|
+
const flags = clientModule?.reactorModule?.featureFlags;
|
|
2136
|
+
const signer = clientModule?.signer;
|
|
2137
|
+
const key = signer?.app?.key;
|
|
2138
|
+
const identity = key ? {
|
|
2139
|
+
address: signer.user?.address ?? null,
|
|
2140
|
+
key
|
|
2141
|
+
} : null;
|
|
2142
|
+
return {
|
|
2143
|
+
authEnforcement: flags ? flags.authEnforcement : true,
|
|
2144
|
+
authConditions: flags?.authConditions === true,
|
|
2145
|
+
identity
|
|
2146
|
+
};
|
|
2147
|
+
}
|
|
2148
|
+
/** Whom a run's created documents also grant: the host, as §8 grants it. */
|
|
2149
|
+
function hostPrincipalOf(access) {
|
|
2150
|
+
const identity = access.identity;
|
|
2151
|
+
if (!identity) return void 0;
|
|
2152
|
+
if (access.authConditions) return { match: { eq: [{ attr: "subject.key" }, { lit: identity.key }] } };
|
|
2153
|
+
return identity.address ? { address: identity.address } : void 0;
|
|
2154
|
+
}
|
|
2155
|
+
/** Who the host signs as, so a publish it signs makes no run user. */
|
|
2156
|
+
function hostIdentityOf(access) {
|
|
2157
|
+
const identity = access.identity;
|
|
2158
|
+
if (!identity) return void 0;
|
|
2159
|
+
return identity.address ? {
|
|
2160
|
+
address: identity.address,
|
|
2161
|
+
key: identity.key
|
|
2162
|
+
} : { key: identity.key };
|
|
2163
|
+
}
|
|
2030
2164
|
/** A step reads any well-formed attachment, as it reads any document: a run
|
|
2031
2165
|
* carries no caller to check against. */
|
|
2032
2166
|
function canReadAttachmentRef(_documentId, ref) {
|
|
@@ -2069,6 +2203,8 @@ async function composeWorkflowRuntime(deps) {
|
|
|
2069
2203
|
}
|
|
2070
2204
|
engine.setPieceRegistryUrl(deps.pieceRegistryUrl);
|
|
2071
2205
|
if (deps.pieces) bindPackagePieces(engine.packagePieces, deps.pieces);
|
|
2206
|
+
const access = reactorAccessOf(deps.clientModule);
|
|
2207
|
+
const models = deps.models;
|
|
2072
2208
|
const runtime = engine.createWorkflowRuntime({
|
|
2073
2209
|
relationalDb: deps.relationalDb,
|
|
2074
2210
|
secretsKeyFile: deps.secretsKeyFile,
|
|
@@ -2076,6 +2212,13 @@ async function composeWorkflowRuntime(deps) {
|
|
|
2076
2212
|
assertCanRead: readAssertion(deps.authorizationService, deps.reactorClient),
|
|
2077
2213
|
subjectOf: (caller) => callerSubject(caller.user),
|
|
2078
2214
|
assertCanWrite: writeAssertion(deps.authorizationService, deps.reactorClient),
|
|
2215
|
+
...deps.clientModule?.reactorModule ? { authEnforcement: access.authEnforcement } : {},
|
|
2216
|
+
hostPrincipal: hostPrincipalOf(access),
|
|
2217
|
+
hostIdentity: hostIdentityOf(access),
|
|
2218
|
+
...models ? {
|
|
2219
|
+
modelManifest: () => models.modelManifest(),
|
|
2220
|
+
modelEntries: (documentType) => models.modelEntries(documentType)
|
|
2221
|
+
} : {},
|
|
2079
2222
|
webhooks: deps.webhooks,
|
|
2080
2223
|
attachments: deps.attachments,
|
|
2081
2224
|
canReadAttachmentRef,
|
|
@@ -2087,7 +2230,7 @@ async function composeWorkflowRuntime(deps) {
|
|
|
2087
2230
|
let stopped = false;
|
|
2088
2231
|
const oauthCallback = deps.http ? registerOAuthCallback(deps.http, runtime) : void 0;
|
|
2089
2232
|
return {
|
|
2090
|
-
subgraph: createWorkflowRuntimeSubgraph(runtime, deps.http ? { callbackUrl: callbackUrlOf(deps.http) } : void 0),
|
|
2233
|
+
subgraph: createWorkflowRuntimeSubgraph(runtime, deps.http ? { callbackUrl: callbackUrlOf(deps.http) } : void 0, access),
|
|
2091
2234
|
triggers,
|
|
2092
2235
|
async start() {
|
|
2093
2236
|
await runtime.registerWebhookEndpoint();
|
|
@@ -2116,7 +2259,12 @@ var ClosablePGliteDialect = class extends PGliteDialect {
|
|
|
2116
2259
|
const innerDestroy = driver.destroy.bind(driver);
|
|
2117
2260
|
driver.destroy = async () => {
|
|
2118
2261
|
await innerDestroy();
|
|
2119
|
-
if (
|
|
2262
|
+
if (pglite.closed) return;
|
|
2263
|
+
try {
|
|
2264
|
+
await pglite.close();
|
|
2265
|
+
} catch (err) {
|
|
2266
|
+
if (!/PGlite aborted/.test(String(err))) throw err;
|
|
2267
|
+
}
|
|
2120
2268
|
};
|
|
2121
2269
|
return driver;
|
|
2122
2270
|
}
|
|
@@ -2171,7 +2319,15 @@ async function migratePgliteDir(dataDir, logger) {
|
|
|
2171
2319
|
try {
|
|
2172
2320
|
const [legacyMod, pgDump] = await Promise.all([loadPGliteModule(major), loadPgDump(major)]);
|
|
2173
2321
|
const LegacyPGlite = legacyMod.PGlite;
|
|
2174
|
-
const
|
|
2322
|
+
const onDisk = new LegacyPGlite(backupDir);
|
|
2323
|
+
let tar;
|
|
2324
|
+
try {
|
|
2325
|
+
await onDisk.waitReady;
|
|
2326
|
+
tar = await onDisk.dumpDataDir("none");
|
|
2327
|
+
} finally {
|
|
2328
|
+
await onDisk.close();
|
|
2329
|
+
}
|
|
2330
|
+
const pg = new LegacyPGlite({ loadDataDir: tar });
|
|
2175
2331
|
try {
|
|
2176
2332
|
await pg.waitReady;
|
|
2177
2333
|
sql = await (await pgDump({ pg })).text();
|
|
@@ -2201,6 +2357,7 @@ async function migratePgliteDir(dataDir, logger) {
|
|
|
2201
2357
|
} finally {
|
|
2202
2358
|
await pg.close();
|
|
2203
2359
|
}
|
|
2360
|
+
syncTree(dataDir);
|
|
2204
2361
|
} catch (err) {
|
|
2205
2362
|
await rollback(dataDir, backupDir, err, logger);
|
|
2206
2363
|
throw err;
|
|
@@ -2221,6 +2378,59 @@ async function rollback(dataDir, backupDir, originalError, logger) {
|
|
|
2221
2378
|
logger.error(`[pglite-migration] Migration failed for ${dataDir}; rolled back from ${backupDir}. Original error: ${String(originalError)}`);
|
|
2222
2379
|
}
|
|
2223
2380
|
//#endregion
|
|
2381
|
+
//#region src/pglite-preflight.ts
|
|
2382
|
+
const RM_OPTIONS = {
|
|
2383
|
+
recursive: true,
|
|
2384
|
+
force: true,
|
|
2385
|
+
maxRetries: 10,
|
|
2386
|
+
retryDelay: 100
|
|
2387
|
+
};
|
|
2388
|
+
/**
|
|
2389
|
+
* Prepares every local PGlite dir before the first open and returns the
|
|
2390
|
+
* PG major detected on disk per dir. Conversion of an AtomicNodeFs snapshot
|
|
2391
|
+
* runs for every dir, before the major is read, so a pure-snapshot PG16 dir
|
|
2392
|
+
* is converted and then migrated.
|
|
2393
|
+
*/
|
|
2394
|
+
async function runPglitePreflight(options) {
|
|
2395
|
+
const { dirs, logger } = options;
|
|
2396
|
+
const detectedMajors = /* @__PURE__ */ new Map();
|
|
2397
|
+
if (options.inMemory || dirs.length === 0) return detectedMajors;
|
|
2398
|
+
if (options.forcePgVersion !== void 0) {
|
|
2399
|
+
if (options.migratePglite) logger.warn("PH_FORCE_PG_VERSION is set; ignoring --migrate-pglite/PH_MIGRATE_PGLITE because the data dirs will be wiped.");
|
|
2400
|
+
logger.warn(`PH_FORCE_PG_VERSION=${options.forcePgVersion} set; wiping PGLite data dirs and re-initializing at PG${options.forcePgVersion}.`);
|
|
2401
|
+
for (const dir of dirs) {
|
|
2402
|
+
for (const p of [
|
|
2403
|
+
dir,
|
|
2404
|
+
`${dir}.converting`,
|
|
2405
|
+
`${dir}.old`
|
|
2406
|
+
]) await promises.rm(p, RM_OPTIONS);
|
|
2407
|
+
logger.info(`Wiped PGLite data dir ${dir}`);
|
|
2408
|
+
}
|
|
2409
|
+
return detectedMajors;
|
|
2410
|
+
}
|
|
2411
|
+
const deps = {
|
|
2412
|
+
openForVerify: options.openForVerify ?? openForVerify,
|
|
2413
|
+
logger
|
|
2414
|
+
};
|
|
2415
|
+
for (const dir of dirs) {
|
|
2416
|
+
await preparePgliteDataDir(dir, deps);
|
|
2417
|
+
const major = await readPgVersionFile(dir);
|
|
2418
|
+
if (major !== null) detectedMajors.set(dir, major);
|
|
2419
|
+
}
|
|
2420
|
+
if (options.migratePglite) for (const [dir, major] of detectedMajors) {
|
|
2421
|
+
if (major === 17) continue;
|
|
2422
|
+
await migratePgliteDir(dir, logger);
|
|
2423
|
+
await removeStalePgliteFiles(dir, logger);
|
|
2424
|
+
const after = await readPgVersionFile(dir);
|
|
2425
|
+
if (after !== null) detectedMajors.set(dir, after);
|
|
2426
|
+
}
|
|
2427
|
+
else for (const [dir, major] of detectedMajors) {
|
|
2428
|
+
if (major === 17) continue;
|
|
2429
|
+
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.`);
|
|
2430
|
+
}
|
|
2431
|
+
return detectedMajors;
|
|
2432
|
+
}
|
|
2433
|
+
//#endregion
|
|
2224
2434
|
//#region src/reactor-feature-flags.mts
|
|
2225
2435
|
/**
|
|
2226
2436
|
* Enforcement flags from the REACTOR_* env vars. Each flag requires the ones
|
|
@@ -2409,14 +2619,29 @@ const DOCUMENT_MODEL_SUBGRAPHS_ENABLED = "DOCUMENT_MODEL_SUBGRAPHS_ENABLED";
|
|
|
2409
2619
|
const DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT = true;
|
|
2410
2620
|
const DEFAULT_PORT = process.env.PORT ? Number(process.env.PORT) : 4001;
|
|
2411
2621
|
const PORT_FALLBACK_ATTEMPTS = 20;
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
if (
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2622
|
+
let flushIntervalWarned = false;
|
|
2623
|
+
function warnIgnoredFlushInterval(logger) {
|
|
2624
|
+
if (flushIntervalWarned) return;
|
|
2625
|
+
if (process.env.PGLITE_FLUSH_INTERVAL_MS === void 0) return;
|
|
2626
|
+
flushIntervalWarned = true;
|
|
2627
|
+
logger.warn("PGLITE_FLUSH_INTERVAL_MS is set but ignored: PGlite stores no longer snapshot on a timer.");
|
|
2628
|
+
}
|
|
2629
|
+
function onPgliteAbort(logger, what) {
|
|
2630
|
+
if (!triggerFatalShutdown("PGlite aborted", what)) logger.error("PGlite aborted: @error", what);
|
|
2631
|
+
}
|
|
2632
|
+
function pgliteFsyncFor(major, dir, logger) {
|
|
2633
|
+
const wanted = resolvePgliteFsync(process.env);
|
|
2634
|
+
if (wanted && major === 16) logger.warn(`PGlite data dir ${dir} is PG16 on pglite-legacy-02, which does not sync commits to disk; only checkpoints are synced until --migrate-pglite upgrades it.`);
|
|
2635
|
+
return wanted;
|
|
2636
|
+
}
|
|
2637
|
+
/** Preloads the module's NodeFS so the returned factory can be synchronous. */
|
|
2638
|
+
async function loadDurableFsFactory(major, logger) {
|
|
2639
|
+
const NodeFS = await loadNodeFsClass(major);
|
|
2640
|
+
return (dir) => createDurableNodeFs(NodeFS, dir, {
|
|
2641
|
+
fsync: pgliteFsyncFor(major, dir, logger),
|
|
2642
|
+
logger,
|
|
2643
|
+
onAbort: (what) => onPgliteAbort(logger, what)
|
|
2644
|
+
});
|
|
2420
2645
|
}
|
|
2421
2646
|
const PGLITE_IN_MEMORY = process.env.PH_PGLITE_IN_MEMORY === "1";
|
|
2422
2647
|
/**
|
|
@@ -2463,7 +2688,7 @@ async function resolveServerPort(requested, strictPort, logger) {
|
|
|
2463
2688
|
return requested;
|
|
2464
2689
|
}
|
|
2465
2690
|
async function createReactorKysely(opts) {
|
|
2466
|
-
const { reactorDbUrl, reactorPgliteDir, reactorPgliteMajor, inMemory,
|
|
2691
|
+
const { reactorDbUrl, reactorPgliteDir, reactorPgliteMajor, inMemory, hostPoolSize, logger } = opts;
|
|
2467
2692
|
if (reactorDbUrl && isPostgresUrl(reactorDbUrl)) {
|
|
2468
2693
|
const connectionString = reactorDbUrl.includes("?") ? reactorDbUrl : `${reactorDbUrl}?sslmode=disable`;
|
|
2469
2694
|
const poolSize = hostPoolSize();
|
|
@@ -2480,11 +2705,7 @@ async function createReactorKysely(opts) {
|
|
|
2480
2705
|
}
|
|
2481
2706
|
if (!reactorPgliteDir || reactorPgliteMajor === null) throw new Error("Reactor PGLite directory not resolved");
|
|
2482
2707
|
const { PGlite } = await loadPGliteModule(reactorPgliteMajor);
|
|
2483
|
-
const pglite = inMemory ? new PGlite() : new PGlite({ fs:
|
|
2484
|
-
logger,
|
|
2485
|
-
flushIntervalMs,
|
|
2486
|
-
onFlushError: onPgliteFlushError
|
|
2487
|
-
}) });
|
|
2708
|
+
const pglite = inMemory ? new PGlite() : new PGlite({ fs: (await loadDurableFsFactory(reactorPgliteMajor, logger))(reactorPgliteDir) });
|
|
2488
2709
|
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}`);
|
|
2489
2710
|
return {
|
|
2490
2711
|
kysely: new Kysely({ dialect: new ClosablePGliteDialect(pglite) }),
|
|
@@ -2512,42 +2733,14 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
2512
2733
|
const reactorPgliteDir = options.reactor ? null : !reactorDbUrl || !isPostgresUrl(reactorDbUrl) ? reactorPath : null;
|
|
2513
2734
|
const readModelPgliteDir = !dbPath || !isPostgresUrl(dbPath) ? readModelPath : null;
|
|
2514
2735
|
const pgliteDirs = [reactorPgliteDir, readModelPgliteDir].filter((d) => d !== null);
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
}
|
|
2524
|
-
}
|
|
2525
|
-
if (options.forcePgVersion !== void 0 && pgliteDirs.length > 0) {
|
|
2526
|
-
if (options.migratePglite) logger.warn("PH_FORCE_PG_VERSION is set; ignoring --migrate-pglite/PH_MIGRATE_PGLITE because the data dirs will be wiped.");
|
|
2527
|
-
logger.warn(`PH_FORCE_PG_VERSION=${options.forcePgVersion} set; wiping PGLite data dirs and re-initializing at PG${options.forcePgVersion}.`);
|
|
2528
|
-
for (const dir of pgliteDirs) {
|
|
2529
|
-
await promises.rm(dir, {
|
|
2530
|
-
recursive: true,
|
|
2531
|
-
force: true
|
|
2532
|
-
});
|
|
2533
|
-
logger.info(`Wiped PGLite data dir ${dir}`);
|
|
2534
|
-
}
|
|
2535
|
-
} else if (options.forcePgVersion === void 0) {
|
|
2536
|
-
for (const dir of pgliteDirs) {
|
|
2537
|
-
const major = await readPgVersionFile(dir);
|
|
2538
|
-
if (major !== null) detectedMajors.set(dir, major);
|
|
2539
|
-
}
|
|
2540
|
-
if (options.migratePglite) for (const [dir, major] of detectedMajors) {
|
|
2541
|
-
if (major === 17) continue;
|
|
2542
|
-
await migratePgliteDir(dir, logger);
|
|
2543
|
-
const after = await readPgVersionFile(dir);
|
|
2544
|
-
if (after !== null) detectedMajors.set(dir, after);
|
|
2545
|
-
}
|
|
2546
|
-
else for (const [dir, major] of detectedMajors) {
|
|
2547
|
-
if (major === 17) continue;
|
|
2548
|
-
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.`);
|
|
2549
|
-
}
|
|
2550
|
-
}
|
|
2736
|
+
warnIgnoredFlushInterval(logger);
|
|
2737
|
+
const detectedMajors = await runPglitePreflight({
|
|
2738
|
+
dirs: pgliteDirs,
|
|
2739
|
+
forcePgVersion: options.forcePgVersion,
|
|
2740
|
+
migratePglite: options.migratePglite,
|
|
2741
|
+
inMemory: PGLITE_IN_MEMORY,
|
|
2742
|
+
logger
|
|
2743
|
+
});
|
|
2551
2744
|
function resolvePgliteMajorForDir(dir) {
|
|
2552
2745
|
if (options.forcePgVersion !== void 0) return options.forcePgVersion;
|
|
2553
2746
|
const detected = detectedMajors.get(dir);
|
|
@@ -2614,6 +2807,7 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
2614
2807
|
if (!packages.includes("@powerhousedao/workflow")) packages.push(WORKFLOW_PACKAGE_NAME);
|
|
2615
2808
|
}
|
|
2616
2809
|
let ownedReactorModule;
|
|
2810
|
+
let workerModels;
|
|
2617
2811
|
let localRenownRequest;
|
|
2618
2812
|
const initializeClient = async (documentModels, { attachmentReferenceWriter, upgradeManifests }) => {
|
|
2619
2813
|
if (options.reactor) {
|
|
@@ -2632,7 +2826,6 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
2632
2826
|
reactorPgliteDir,
|
|
2633
2827
|
reactorPgliteMajor,
|
|
2634
2828
|
inMemory: PGLITE_IN_MEMORY,
|
|
2635
|
-
flushIntervalMs: PGLITE_FLUSH_INTERVAL_MS,
|
|
2636
2829
|
hostPoolSize: () => resolveHostPoolSize(process.env),
|
|
2637
2830
|
logger
|
|
2638
2831
|
});
|
|
@@ -2668,10 +2861,14 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
2668
2861
|
switchboardUrl: renownConfig.switchboardUrl
|
|
2669
2862
|
}, renown) : void 0
|
|
2670
2863
|
});
|
|
2864
|
+
if (workerPool || projectionWorker || workflowsEnabled) {
|
|
2865
|
+
const sources = await resolveWorkerModelSources(packages, reactorLogger, { resolveRemote: resolveRegistryModels });
|
|
2866
|
+
reactorBuilder.withDocumentModelSources(workerPool || projectionWorker ? sources : await keepImportableSources(sources, reactorLogger));
|
|
2867
|
+
}
|
|
2868
|
+
workerModels = modelManifestSource(reactorBuilder);
|
|
2671
2869
|
if (workerPool) {
|
|
2672
2870
|
if (!reactorDbUrl) throw new Error("unreachable: worker pool enabled without a reactor database URL");
|
|
2673
|
-
|
|
2674
|
-
reactorBuilder.withDocumentModelSources(workerSources).withWorkerPool({
|
|
2871
|
+
reactorBuilder.withWorkerPool({
|
|
2675
2872
|
numWorkers: workerPool.numWorkers,
|
|
2676
2873
|
db: buildWorkerDbConfig(reactorDbUrl, workerPool)
|
|
2677
2874
|
});
|
|
@@ -2688,10 +2885,6 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
2688
2885
|
});
|
|
2689
2886
|
if (projectionWorker) {
|
|
2690
2887
|
if (!reactorDbUrl) throw new Error("unreachable: projection worker enabled without a reactor database URL");
|
|
2691
|
-
if (!workerPool) {
|
|
2692
|
-
const workerSources = await resolveWorkerModelSources(packages, reactorLogger, { resolveRemote: resolveRegistryModels });
|
|
2693
|
-
reactorBuilder.withDocumentModelSources(workerSources);
|
|
2694
|
-
}
|
|
2695
2888
|
const db = {
|
|
2696
2889
|
...buildWorkerDbConfig(reactorDbUrl, {
|
|
2697
2890
|
dbPoolSizePerWorker: projectionWorker.dbPoolSize,
|
|
@@ -2785,12 +2978,9 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
2785
2978
|
let pgliteFactory;
|
|
2786
2979
|
if (readModelPgliteDir && readModelPgliteMajor !== null) {
|
|
2787
2980
|
const { PGlite: ReadModelPGlite } = await loadPGliteModule(readModelPgliteMajor);
|
|
2981
|
+
const durableFs = await loadDurableFsFactory(readModelPgliteMajor, logger);
|
|
2788
2982
|
pgliteFactory = PGLITE_IN_MEMORY ? () => new ReadModelPGlite({ parsers: PGLITE_UTC_PARSERS }) : (connectionString) => new ReadModelPGlite({
|
|
2789
|
-
fs:
|
|
2790
|
-
logger,
|
|
2791
|
-
flushIntervalMs: PGLITE_FLUSH_INTERVAL_MS,
|
|
2792
|
-
onFlushError: onPgliteFlushError
|
|
2793
|
-
}),
|
|
2983
|
+
fs: durableFs(connectionString ?? readModelPgliteDir),
|
|
2794
2984
|
parsers: PGLITE_UTC_PARSERS
|
|
2795
2985
|
});
|
|
2796
2986
|
}
|
|
@@ -2839,6 +3029,7 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
2839
3029
|
authorizationService: api.authorizationService,
|
|
2840
3030
|
pieces: api.packageManager,
|
|
2841
3031
|
pieceRegistryUrl: registryUrl,
|
|
3032
|
+
models: workerModels,
|
|
2842
3033
|
logger: logger.child(["workflow-runtime"])
|
|
2843
3034
|
});
|
|
2844
3035
|
const WorkflowRuntimeSubgraph = workflows.subgraph;
|
|
@@ -2976,6 +3167,7 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
2976
3167
|
attachmentReferenceProjection: api.attachmentReferenceProjection,
|
|
2977
3168
|
workflowTriggers: workflows?.triggers,
|
|
2978
3169
|
workflowsEnabled,
|
|
3170
|
+
modelManifest: () => workerModels?.modelManifest() ?? [],
|
|
2979
3171
|
privacy: privacy ? { erasure: privacy.erasure } : void 0,
|
|
2980
3172
|
mcpEnabled: options.mcp !== false,
|
|
2981
3173
|
renown,
|
|
@@ -3048,5 +3240,5 @@ if (import.meta.main) await startSwitchboard({ fatalErrorShutdown: true });
|
|
|
3048
3240
|
//#endregion
|
|
3049
3241
|
export { resolveUnsupportedStoredDocuments as a, StoredDocumentsRefusedError as i, isPortAvailable as n, applySwitchboardReactorDefaults as o, startSwitchboard as r, parseForcePgVersion as s, deriveAttachmentServiceConfig as t };
|
|
3050
3242
|
|
|
3051
|
-
//# sourceMappingURL=server-
|
|
3052
|
-
//# debugId=
|
|
3243
|
+
//# sourceMappingURL=server-Dvw8_NeM.mjs.map
|
|
3244
|
+
//# debugId=b9c02424-1406-5d63-abbd-6c32caa53205
|