@teamscala/service-boot 0.23.516 → 0.23.518
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/package.json +232 -1127
- package/src/agreement-gate/settings.ts +8 -8
- package/src/apply-logger-config.test.ts +46 -0
- package/src/apply-logger-config.ts +31 -0
- package/src/asset-proxy.test.ts +9 -5
- package/src/auth/sign-out.ts +122 -0
- package/src/build-client-assets/hydrate-bundle.ts +14 -4
- package/src/build-client-assets/manifest.ts +5 -18
- package/src/build-client-assets/styles-build.test.ts +11 -8
- package/src/build-client-assets/styles-build.ts +14 -63
- package/src/build-client-assets.test.ts +0 -161
- package/src/build-client-assets.ts +20 -67
- package/src/content-gate/token.ts +9 -2
- package/src/install-logging.ts +10 -26
- package/src/mount-orpc-router.ts +8 -2
- package/src/mutable-app-ref.ts +0 -7
- package/src/page-server-loader-graph.test.ts +31 -0
- package/src/page-server-loader-graph.ts +21 -0
- package/src/pg-boss-mount.ts +3 -16
- package/src/readiness-gate.ts +0 -4
- package/src/register-api-routes.ts +8 -17
- package/src/register-page-server-client.ts +6 -0
- package/src/register-registry-page-route/meta-title.test.ts +50 -0
- package/src/register-registry-page-route/meta-title.ts +1 -1
- package/src/register-registry-page-route/page-data-shared.ts +7 -11
- package/src/register-registry-page-route/resolve-request-graph.test.ts +1 -1
- package/src/register-registry-page-route/resolve-request-graph.ts +1 -2
- package/src/register-registry-page-route.ts +14 -16
- package/src/resolve-brand-favicon.test.ts +63 -0
- package/src/resolve-theme-mode.test.ts +51 -0
- package/src/resolvers/fetch-org-brand.test.ts +48 -0
- package/src/resolvers/website-by-host-map.test.ts +63 -0
- package/src/resolvers/website-by-host-map.ts +15 -3
- package/src/run-boot-hook-list.test.ts +47 -0
- package/src/run-boot-hooks.test.ts +56 -0
- package/src/scala-tools-currency-watchdog.test.ts +43 -0
- package/src/scala-tools-currency-watchdog.ts +0 -5
- package/src/transfers/presigned-upload.ts +165 -0
- package/src/two-phase-minimal-app.test.ts +24 -0
- package/src/two-phase-readiness.test.ts +102 -0
- package/src/types.test.ts +20 -0
- package/src/ui-service.test.ts +24 -0
- package/src/ws-boot-self-test.test.ts +43 -0
- package/src/build-client-assets/input-fingerprint.ts +0 -92
- package/src/build-client-assets/input-hashing.ts +0 -206
- package/src/handlers/bun-stable-watch.ts +0 -86
- package/src/handlers/messaging-send-reconcile-logic.test.ts +0 -79
- package/src/handlers/messaging-send-reconcile-logic.ts +0 -83
- package/src/handlers/messaging-send-recording-logic.test.ts +0 -86
- package/src/handlers/messaging-send-recording-logic.ts +0 -68
- package/src/handlers/messaging-send-recording-watch.test.ts +0 -91
- package/src/handlers/messaging-send-recording-watch.ts +0 -244
- package/src/handlers/messaging-send-sweep-selection.test.ts +0 -47
- package/src/handlers/messaging-send-sweep-selection.ts +0 -35
- package/src/handlers/platform-fault-escalation/config.ts +0 -95
- package/src/handlers/platform-fault-escalation/delivery.ts +0 -327
- package/src/handlers/platform-fault-escalation/event-log-queries.ts +0 -182
- package/src/handlers/platform-fault-escalation/orpc-client.ts +0 -114
- package/src/handlers/platform-fault-escalation/probe.test.ts +0 -63
- package/src/handlers/platform-fault-escalation/probe.ts +0 -19
- package/src/handlers/platform-fault-escalation-logic.ts +0 -382
- package/src/handlers/platform-fault-escalation-refresh.test.ts +0 -72
- package/src/handlers/platform-fault-escalation.test.ts +0 -497
- package/src/handlers/platform-fault-escalation.ts +0 -246
- package/src/handlers/projects-digest.ts +0 -198
- package/src/handlers/quiz-research.ts +0 -178
- package/src/handlers/registry-stale-inactive-sweep.ts +0 -82
- package/src/handlers/scala-tools-currency-watch.test.ts +0 -65
- package/src/handlers/scala-tools-currency-watch.ts +0 -162
- package/src/handlers/singleton-tools-currency-watch.test.ts +0 -51
- package/src/handlers/singleton-tools-currency-watch.ts +0 -380
- package/src/handlers/sweep-event-log.ts +0 -98
- package/src/handlers/upstream-issue-sync.ts +0 -220
- package/src/handlers/whatsapp-liveness-handler.test.ts +0 -56
- package/src/handlers/whatsapp-liveness-logic.ts +0 -53
- package/src/handlers/whatsapp-liveness.test.ts +0 -78
- package/src/handlers/whatsapp-liveness.ts +0 -138
- package/src/interpolate-meta.ts +0 -123
- package/src/load-service-prisma-client.ts +0 -53
- package/src/loader-errors.ts +0 -41
- package/src/read-viewport-from-request.ts +0 -36
- package/src/register-page-server-client.test.ts +0 -185
- package/src/resolve-app-base-url.ts +0 -35
- package/src/resolve-from-loader-config.test.ts +0 -429
- package/src/resolve-from-loader-config.ts +0 -316
- package/src/resolve-service-context.ts +0 -88
|
@@ -1,316 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system ui-service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Walks a resource_loader.sources[] graph and resolves each source in
|
|
7
|
-
* dependency order. Returns an assembled data object keyed by source key.
|
|
8
|
-
*
|
|
9
|
-
* Source types: "orpc", "prisma", "resolver_fn".
|
|
10
|
-
* Each source may declare depends_on for ordering.
|
|
11
|
-
* Params are interpolated from URL params and prior resolved data.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
15
|
-
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
16
|
-
import type { ResolverFnKey } from "@teamscala/db/registry/generated/resolver-fn";
|
|
17
|
-
import { ResolverFnConfigSchema } from "@teamscala/db-validation/registry-schemas/resolver-fn";
|
|
18
|
-
import { getServerClient } from "./page-server-orpc/server-client";
|
|
19
|
-
import { interpolateTemplate, resolveParamRef } from "./interpolate-meta";
|
|
20
|
-
import {
|
|
21
|
-
MissingLoaderModelCapabilityError,
|
|
22
|
-
isUrlRefNotUuidError,
|
|
23
|
-
} from "./loader-errors";
|
|
24
|
-
|
|
25
|
-
interface SourceConfig {
|
|
26
|
-
key: string;
|
|
27
|
-
source: "orpc" | "prisma" | "resolver_fn";
|
|
28
|
-
model?: string;
|
|
29
|
-
where?: Record<string, unknown>;
|
|
30
|
-
select?: string[];
|
|
31
|
-
depends_on?: string[];
|
|
32
|
-
optional?: boolean;
|
|
33
|
-
resolver_fn?: string;
|
|
34
|
-
resolver_input?: Record<string, unknown>;
|
|
35
|
-
registry_filter?: Record<string, unknown>;
|
|
36
|
-
transforms?: string[];
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
interface LoaderConfig {
|
|
40
|
-
sources: SourceConfig[];
|
|
41
|
-
merge_strategy?: Record<string, unknown>;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function toSelectMap(
|
|
45
|
-
fields: string[] | undefined,
|
|
46
|
-
): Record<string, boolean> | undefined {
|
|
47
|
-
if (!fields || fields.length === 0) return undefined;
|
|
48
|
-
const map: Record<string, boolean> = {};
|
|
49
|
-
for (const f of fields) map[f] = true;
|
|
50
|
-
return map;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* resolver_fn executor — registry-driven dispatch (business-logic-is-rows-plus-executors).
|
|
55
|
-
* Reads the resolver_fn row (module+export) + dynamic-imports + calls it. Replaces the
|
|
56
|
-
* hardcoded if(resolver_fn==='X') chain — adding a resolver is now a registry_edit row.
|
|
57
|
-
*/
|
|
58
|
-
async function resolveByResolverFn(
|
|
59
|
-
slug: ResolverFnKey,
|
|
60
|
-
input: Record<string, unknown>,
|
|
61
|
-
params: Record<string, unknown>,
|
|
62
|
-
): Promise<unknown> {
|
|
63
|
-
if (!slug) throw new Error("[resource_loader] resolver_fn source missing resolver_fn slug");
|
|
64
|
-
const row = await loadRegistryConfig("resolver_fn", slug, ResolverFnConfigSchema);
|
|
65
|
-
if (!row) {
|
|
66
|
-
throw new Error(
|
|
67
|
-
`[resource_loader] resolver_fn "${slug}" has no resolver_fn registry row — create one (module+export) or fix the resource_loader row`,
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
const mod = await import(row.module);
|
|
71
|
-
const fn = (mod as Record<string, unknown>)[row.export] as (args: {
|
|
72
|
-
input: Record<string, unknown>;
|
|
73
|
-
client: unknown;
|
|
74
|
-
params: Record<string, unknown>;
|
|
75
|
-
}) => Promise<unknown> | unknown;
|
|
76
|
-
if (typeof fn !== "function") {
|
|
77
|
-
throw new Error(
|
|
78
|
-
`[resource_loader] resolver_fn "${slug}": export "${row.export}" not a function in ${row.module}`,
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
return fn({ input, client: await getServerClient(), params });
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Exact `:ref` strings inside a raw where/resolver_input whose value resolves
|
|
86
|
-
* to undefined — the keys interpolateTemplate DROPS. A dropped where key on a
|
|
87
|
-
* required source would query with a partial (or empty) filter, which matches
|
|
88
|
-
* an ARBITRARY row — worse than an error, so required sources refuse here
|
|
89
|
-
* instead (`a-component-may-not-report-a-state-it-has-not-verified`). Null is
|
|
90
|
-
* a real value (an unset column filtered as null) and never counts as broken.
|
|
91
|
-
*/
|
|
92
|
-
function unresolvedTemplateRefs(
|
|
93
|
-
template: unknown,
|
|
94
|
-
params: Record<string, unknown>,
|
|
95
|
-
resolved: Record<string, unknown>,
|
|
96
|
-
): string[] {
|
|
97
|
-
const broken: string[] = [];
|
|
98
|
-
const walk = (node: unknown): void => {
|
|
99
|
-
if (typeof node === "string") {
|
|
100
|
-
const exact = node.match(/^:([a-zA-Z0-9_.]+)$/);
|
|
101
|
-
if (exact && resolveParamRef(exact[1], params, resolved) === undefined) {
|
|
102
|
-
broken.push(exact[1]);
|
|
103
|
-
}
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
if (Array.isArray(node)) {
|
|
107
|
-
for (const v of node) walk(v);
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
if (node != null && typeof node === "object") {
|
|
111
|
-
for (const v of Object.values(node)) walk(v);
|
|
112
|
-
}
|
|
113
|
-
};
|
|
114
|
-
walk(template);
|
|
115
|
-
return broken;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
async function resolveSource(
|
|
119
|
-
source: SourceConfig,
|
|
120
|
-
params: Record<string, unknown>,
|
|
121
|
-
resolved: Record<string, unknown>,
|
|
122
|
-
): Promise<unknown> {
|
|
123
|
-
const client = await getServerClient();
|
|
124
|
-
|
|
125
|
-
switch (source.source) {
|
|
126
|
-
case "orpc":
|
|
127
|
-
case "prisma": {
|
|
128
|
-
if (!source.model) return null;
|
|
129
|
-
// OPTIONAL MEANS THE SOURCE MAY BE ABSENT. It has never meant the
|
|
130
|
-
// source may be A STRANGER'S ROW, and that is what an unfiltered
|
|
131
|
-
// query returns. This check previously guarded only REQUIRED
|
|
132
|
-
// sources, so every optional one whose ref failed to resolve issued
|
|
133
|
-
// `where: {}` and findFirst handed back an arbitrary first row.
|
|
134
|
-
//
|
|
135
|
-
// Measured 2026-09-03 on a live client proposal: page, website,
|
|
136
|
-
// contact, partnerOrg and clientOrg ALL queried with an empty where
|
|
137
|
-
// and loaded unrelated records, which then rendered as the page. The
|
|
138
|
-
// only correct source was `proposal`, whose ref is the URL param
|
|
139
|
-
// rather than a sibling. Nothing errored and the page answered 200.
|
|
140
|
-
//
|
|
141
|
-
// A dropped filter is now refused for every source that declared
|
|
142
|
-
// one. Required sources still throw; an optional source resolves to
|
|
143
|
-
// null — absent, which is exactly what optional promises.
|
|
144
|
-
if (source.where) {
|
|
145
|
-
const broken = unresolvedTemplateRefs(source.where, params, resolved);
|
|
146
|
-
if (broken.length > 0) {
|
|
147
|
-
const refs = broken.map((ref) => `:${ref}`).join(", ");
|
|
148
|
-
if (source.optional) {
|
|
149
|
-
getAppLogger().warn(
|
|
150
|
-
`[resource_loader] optional source "${source.key}": where ref(s) ${refs} resolved to undefined — treating the source as ABSENT rather than querying without them (an unfiltered read returns an arbitrary row)`,
|
|
151
|
-
);
|
|
152
|
-
return null;
|
|
153
|
-
}
|
|
154
|
-
throw new Error(
|
|
155
|
-
`[resource_loader] required source "${source.key}": where ref(s) ${refs} resolved to undefined — refusing to query without them (a dropped filter silently matches an arbitrary row)`,
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
const where = source.where
|
|
160
|
-
? (interpolateTemplate(source.where, params, resolved) as Record<
|
|
161
|
-
string,
|
|
162
|
-
unknown
|
|
163
|
-
>)
|
|
164
|
-
: ({} as Record<string, unknown>);
|
|
165
|
-
const model = client[source.model as keyof typeof client] as
|
|
166
|
-
| {
|
|
167
|
-
findFirst?: (args: {
|
|
168
|
-
where: unknown;
|
|
169
|
-
select: unknown;
|
|
170
|
-
}) => Promise<unknown>;
|
|
171
|
-
}
|
|
172
|
-
| undefined;
|
|
173
|
-
if (!model?.findFirst) {
|
|
174
|
-
throw new MissingLoaderModelCapabilityError(source.model, source.key);
|
|
175
|
-
}
|
|
176
|
-
return model.findFirst({
|
|
177
|
-
where,
|
|
178
|
-
select: toSelectMap(source.select),
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
case "resolver_fn": {
|
|
182
|
-
const input = source.resolver_input
|
|
183
|
-
? (interpolateTemplate(
|
|
184
|
-
source.resolver_input,
|
|
185
|
-
params,
|
|
186
|
-
resolved,
|
|
187
|
-
) as Record<string, unknown>)
|
|
188
|
-
: {};
|
|
189
|
-
|
|
190
|
-
return resolveByResolverFn(
|
|
191
|
-
source.resolver_fn as ResolverFnKey,
|
|
192
|
-
input,
|
|
193
|
-
params,
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
default:
|
|
197
|
-
throw new Error(
|
|
198
|
-
`[resource_loader] Unknown source type "${source.source}" on source "${source.key}".`,
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Dependencies a source has on sibling sources, beyond its declared
|
|
205
|
-
* `depends_on`: any `:key.field` template ref whose head names another source
|
|
206
|
-
* resolves from `resolved` (see resolveParamRef), so it is a real ordering
|
|
207
|
-
* edge even when the row forgot to declare it. Inferring these keeps the
|
|
208
|
-
* parallel waves safe against under-declared rows — under the old serial
|
|
209
|
-
* loop, row order silently masked missing declarations.
|
|
210
|
-
*/
|
|
211
|
-
function sourceDeps(source: SourceConfig, sourceKeys: Set<string>): string[] {
|
|
212
|
-
const deps = new Set<string>(source.depends_on ?? []);
|
|
213
|
-
const scan = (template: unknown): void => {
|
|
214
|
-
if (typeof template === "string") {
|
|
215
|
-
for (const m of template.matchAll(
|
|
216
|
-
/:([a-zA-Z0-9_]+)\.[a-zA-Z0-9_.]+/g,
|
|
217
|
-
)) {
|
|
218
|
-
const head = m[1];
|
|
219
|
-
if (head && sourceKeys.has(head)) deps.add(head);
|
|
220
|
-
}
|
|
221
|
-
} else if (Array.isArray(template)) {
|
|
222
|
-
for (const v of template) scan(v);
|
|
223
|
-
} else if (template != null && typeof template === "object") {
|
|
224
|
-
for (const v of Object.values(template)) scan(v);
|
|
225
|
-
}
|
|
226
|
-
};
|
|
227
|
-
scan(source.where);
|
|
228
|
-
scan(source.resolver_input);
|
|
229
|
-
scan(source.registry_filter);
|
|
230
|
-
deps.delete(source.key);
|
|
231
|
-
return [...deps];
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export async function resolveFromLoaderConfig(
|
|
235
|
-
config: LoaderConfig,
|
|
236
|
-
params: Record<string, unknown>,
|
|
237
|
-
): Promise<Record<string, unknown>> {
|
|
238
|
-
const sources = config.sources ?? [];
|
|
239
|
-
const resolved: Record<string, unknown> = {};
|
|
240
|
-
const resolvedKeys = new Set<string>();
|
|
241
|
-
|
|
242
|
-
// Wave-parallel resolution per `loading-shape` (unrelated roots resolve
|
|
243
|
-
// concurrently): each wave gathers every source whose deps are satisfied
|
|
244
|
-
// and resolves them via Promise.allSettled — wall-clock per wave is the
|
|
245
|
-
// slowest source, not the sum. The previous strictly-serial loop cost
|
|
246
|
-
// N × DB-round-trip per page (~2.3s for a 5-source loader from the dev
|
|
247
|
-
// host); waves cut it to the dependency depth (typically 2).
|
|
248
|
-
const sourceKeys = new Set(sources.map((s) => s.key));
|
|
249
|
-
const depsByKey = new Map(
|
|
250
|
-
sources.map((s) => [s.key, sourceDeps(s, sourceKeys)]),
|
|
251
|
-
);
|
|
252
|
-
|
|
253
|
-
const maxIterations = sources.length + 1;
|
|
254
|
-
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
255
|
-
const wave = sources.filter(
|
|
256
|
-
(s) =>
|
|
257
|
-
!resolvedKeys.has(s.key) &&
|
|
258
|
-
(depsByKey.get(s.key) ?? []).every((d) => resolvedKeys.has(d)),
|
|
259
|
-
);
|
|
260
|
-
if (wave.length === 0) {
|
|
261
|
-
if (resolvedKeys.size === sources.length) break;
|
|
262
|
-
const unresolved = sources
|
|
263
|
-
.filter((s) => !resolvedKeys.has(s.key))
|
|
264
|
-
.map((s) => s.key);
|
|
265
|
-
throw new Error(
|
|
266
|
-
`Circular or unresolvable dependency in resource_loader: ${unresolved.join(", ")}`,
|
|
267
|
-
);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
const results = await Promise.allSettled(
|
|
271
|
-
wave.map((source) => resolveSource(source, params, resolved)),
|
|
272
|
-
);
|
|
273
|
-
for (let i = 0; i < wave.length; i++) {
|
|
274
|
-
const source = wave[i];
|
|
275
|
-
const result = results[i];
|
|
276
|
-
if (!source || !result) continue;
|
|
277
|
-
if (result.status === "fulfilled") {
|
|
278
|
-
resolved[source.key] = result.value;
|
|
279
|
-
} else if (isUrlRefNotUuidError(result.reason)) {
|
|
280
|
-
// A URL ref that cannot be a uuid names no resource: resolve the
|
|
281
|
-
// source ABSENT (null) and let the existing semantics decide —
|
|
282
|
-
// the primary-missing check upstream turns it into the branded
|
|
283
|
-
// 404 (PrimaryResourceMissingError, which the render-budget
|
|
284
|
-
// breaker already excludes), an optional source treats it like
|
|
285
|
-
// any absent row. Throwing here instead is what let dead links
|
|
286
|
-
// poison the breaker into a site-wide 503 (see the predicate).
|
|
287
|
-
getAppLogger().warn(
|
|
288
|
-
`[resource_loader] source "${source.key}" got a non-UUID URL ref — treating as MISSING for this request: ${
|
|
289
|
-
result.reason instanceof Error ? result.reason.message : String(result.reason)
|
|
290
|
-
}`,
|
|
291
|
-
);
|
|
292
|
-
resolved[source.key] = null;
|
|
293
|
-
} else if (
|
|
294
|
-
source.optional &&
|
|
295
|
-
!(result.reason instanceof MissingLoaderModelCapabilityError)
|
|
296
|
-
) {
|
|
297
|
-
// Optional means MAY BE ABSENT, never FAIL IN SILENCE: the source
|
|
298
|
-
// still resolves to null, but the reason becomes evidence.
|
|
299
|
-
getAppLogger().warn(
|
|
300
|
-
`[resource_loader] optional source "${source.key}" FAILED and is absent from this render: ${
|
|
301
|
-
result.reason instanceof Error
|
|
302
|
-
? `${result.reason.message}\n${result.reason.stack ?? ""}`
|
|
303
|
-
: String(result.reason)
|
|
304
|
-
}`,
|
|
305
|
-
);
|
|
306
|
-
resolved[source.key] = null;
|
|
307
|
-
} else {
|
|
308
|
-
throw result.reason;
|
|
309
|
-
}
|
|
310
|
-
resolvedKeys.add(source.key);
|
|
311
|
-
}
|
|
312
|
-
if (resolvedKeys.size === sources.length) break;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
return resolved;
|
|
316
|
-
}
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system server-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Resolves the minimum service context the bin needs to choose its launch
|
|
7
|
-
* path: connect DB, load registry, read service_install row, return kind +
|
|
8
|
-
* port. Caller is responsible for disconnecting.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { createDbClient } from "@teamscala/db/create-db-client";
|
|
12
|
-
import type { PrismaClient } from "@teamscala/db/client";
|
|
13
|
-
import { loadServicePrismaClient } from "@teamscala/db/load-service-prisma-client";
|
|
14
|
-
import { registerPrismaClient } from "@teamscala/db/prisma-registry";
|
|
15
|
-
import { loadRegistryEntries } from "@teamscala/db/registry/loader/load-entries";
|
|
16
|
-
import type { ServiceInstallConfig } from "./load-service-install-row";
|
|
17
|
-
import { loadServiceInstallRow } from "./load-service-install-row";
|
|
18
|
-
|
|
19
|
-
export interface ServiceContext {
|
|
20
|
-
kind: "ui" | "backend" | "runtime";
|
|
21
|
-
port: number;
|
|
22
|
-
config: ServiceInstallConfig;
|
|
23
|
-
disconnect: () => Promise<void>;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export async function resolveServiceContext(
|
|
27
|
-
serviceSlug: string,
|
|
28
|
-
databaseUrl: string,
|
|
29
|
-
): Promise<ServiceContext> {
|
|
30
|
-
// THE V8 CLIENT REGISTERS FIRST — before any registry read. getPrisma()
|
|
31
|
-
// serves the v8 model-indexed adapter (a81b9f0a), so an early registry
|
|
32
|
-
// read (getCookieDomain in buildMinimalApp, loadRegistryEntries) that ran
|
|
33
|
-
// against a v7-only registration found no `.orm` surface: 43695 threw
|
|
34
|
-
// "model not in the v8 client" (fleet-wide boot crash) and 43696+ degraded
|
|
35
|
-
// to EMPTY rows ("Config entry cookie-domain not found" on a row that has
|
|
36
|
-
// existed since July). Registering the real v8 client HERE — the earliest
|
|
37
|
-
// boot step — makes every later read resolve against the client the
|
|
38
|
-
// adapter expects. Measured 2026-09-03: this ordering gap took down every
|
|
39
|
-
// UI service for the second half of the Prisma 8 migration night.
|
|
40
|
-
const { createDbClientV8 } = await import("@teamscala/db/create-db-client-v8");
|
|
41
|
-
const db8 = await createDbClientV8({
|
|
42
|
-
connectionString: databaseUrl,
|
|
43
|
-
serviceName: serviceSlug,
|
|
44
|
-
});
|
|
45
|
-
const { registerDb8 } = await import("@teamscala/db/db8-registry");
|
|
46
|
-
registerDb8(db8 as never);
|
|
47
|
-
registerPrismaClient(db8 as never);
|
|
48
|
-
|
|
49
|
-
const PrismaClientConstructor = await loadServicePrismaClient(serviceSlug);
|
|
50
|
-
const dbClient = await createDbClient({
|
|
51
|
-
connectionString: databaseUrl,
|
|
52
|
-
serviceName: serviceSlug,
|
|
53
|
-
PrismaClientConstructor: PrismaClientConstructor as never,
|
|
54
|
-
});
|
|
55
|
-
// The v7 client stays ONLY as the explicit per-call client (loadRegistry-
|
|
56
|
-
// Entries below, callers' disconnect) — it must NOT re-register, or the
|
|
57
|
-
// v7 registration overwrites the v8 one and the reads break again.
|
|
58
|
-
await loadRegistryEntries(dbClient.prisma as never);
|
|
59
|
-
|
|
60
|
-
const rowResult = await loadServiceInstallRow(serviceSlug);
|
|
61
|
-
if (!rowResult.success) {
|
|
62
|
-
throw new Error(`[scala-install:${serviceSlug}] ${rowResult.message}`);
|
|
63
|
-
}
|
|
64
|
-
const cfg = rowResult.config;
|
|
65
|
-
const port = Number(cfg.port);
|
|
66
|
-
if (!Number.isFinite(port) || port <= 0) {
|
|
67
|
-
throw new Error(
|
|
68
|
-
`[scala-install:${serviceSlug}] invalid port "${cfg.port}" in service_install row`,
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
// Export the resolved port to process.env so loopback code reads the REAL
|
|
72
|
-
// port without a separate PORT injection. The central-tool-forwarder (the
|
|
73
|
-
// chat tool dispatch) falls back to http://localhost:${PORT} for the
|
|
74
|
-
// co-located /mcp; without this export every chat tool call fails with
|
|
75
|
-
// "cannot resolve the /mcp base URL" (the highlight-ring end-to-end blocker,
|
|
76
|
-
// 2026-07-02). Railway's $PORT (when present) is respected — only set when
|
|
77
|
-
// unset so prod's Railway-assigned port wins.
|
|
78
|
-
if (!process.env.PORT) process.env.PORT = String(port);
|
|
79
|
-
|
|
80
|
-
return {
|
|
81
|
-
kind: cfg.kind,
|
|
82
|
-
port,
|
|
83
|
-
config: cfg,
|
|
84
|
-
disconnect: async () => {
|
|
85
|
-
await dbClient.disconnect?.();
|
|
86
|
-
},
|
|
87
|
-
};
|
|
88
|
-
}
|