@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.
Files changed (86) hide show
  1. package/package.json +232 -1127
  2. package/src/agreement-gate/settings.ts +8 -8
  3. package/src/apply-logger-config.test.ts +46 -0
  4. package/src/apply-logger-config.ts +31 -0
  5. package/src/asset-proxy.test.ts +9 -5
  6. package/src/auth/sign-out.ts +122 -0
  7. package/src/build-client-assets/hydrate-bundle.ts +14 -4
  8. package/src/build-client-assets/manifest.ts +5 -18
  9. package/src/build-client-assets/styles-build.test.ts +11 -8
  10. package/src/build-client-assets/styles-build.ts +14 -63
  11. package/src/build-client-assets.test.ts +0 -161
  12. package/src/build-client-assets.ts +20 -67
  13. package/src/content-gate/token.ts +9 -2
  14. package/src/install-logging.ts +10 -26
  15. package/src/mount-orpc-router.ts +8 -2
  16. package/src/mutable-app-ref.ts +0 -7
  17. package/src/page-server-loader-graph.test.ts +31 -0
  18. package/src/page-server-loader-graph.ts +21 -0
  19. package/src/pg-boss-mount.ts +3 -16
  20. package/src/readiness-gate.ts +0 -4
  21. package/src/register-api-routes.ts +8 -17
  22. package/src/register-page-server-client.ts +6 -0
  23. package/src/register-registry-page-route/meta-title.test.ts +50 -0
  24. package/src/register-registry-page-route/meta-title.ts +1 -1
  25. package/src/register-registry-page-route/page-data-shared.ts +7 -11
  26. package/src/register-registry-page-route/resolve-request-graph.test.ts +1 -1
  27. package/src/register-registry-page-route/resolve-request-graph.ts +1 -2
  28. package/src/register-registry-page-route.ts +14 -16
  29. package/src/resolve-brand-favicon.test.ts +63 -0
  30. package/src/resolve-theme-mode.test.ts +51 -0
  31. package/src/resolvers/fetch-org-brand.test.ts +48 -0
  32. package/src/resolvers/website-by-host-map.test.ts +63 -0
  33. package/src/resolvers/website-by-host-map.ts +15 -3
  34. package/src/run-boot-hook-list.test.ts +47 -0
  35. package/src/run-boot-hooks.test.ts +56 -0
  36. package/src/scala-tools-currency-watchdog.test.ts +43 -0
  37. package/src/scala-tools-currency-watchdog.ts +0 -5
  38. package/src/transfers/presigned-upload.ts +165 -0
  39. package/src/two-phase-minimal-app.test.ts +24 -0
  40. package/src/two-phase-readiness.test.ts +102 -0
  41. package/src/types.test.ts +20 -0
  42. package/src/ui-service.test.ts +24 -0
  43. package/src/ws-boot-self-test.test.ts +43 -0
  44. package/src/build-client-assets/input-fingerprint.ts +0 -92
  45. package/src/build-client-assets/input-hashing.ts +0 -206
  46. package/src/handlers/bun-stable-watch.ts +0 -86
  47. package/src/handlers/messaging-send-reconcile-logic.test.ts +0 -79
  48. package/src/handlers/messaging-send-reconcile-logic.ts +0 -83
  49. package/src/handlers/messaging-send-recording-logic.test.ts +0 -86
  50. package/src/handlers/messaging-send-recording-logic.ts +0 -68
  51. package/src/handlers/messaging-send-recording-watch.test.ts +0 -91
  52. package/src/handlers/messaging-send-recording-watch.ts +0 -244
  53. package/src/handlers/messaging-send-sweep-selection.test.ts +0 -47
  54. package/src/handlers/messaging-send-sweep-selection.ts +0 -35
  55. package/src/handlers/platform-fault-escalation/config.ts +0 -95
  56. package/src/handlers/platform-fault-escalation/delivery.ts +0 -327
  57. package/src/handlers/platform-fault-escalation/event-log-queries.ts +0 -182
  58. package/src/handlers/platform-fault-escalation/orpc-client.ts +0 -114
  59. package/src/handlers/platform-fault-escalation/probe.test.ts +0 -63
  60. package/src/handlers/platform-fault-escalation/probe.ts +0 -19
  61. package/src/handlers/platform-fault-escalation-logic.ts +0 -382
  62. package/src/handlers/platform-fault-escalation-refresh.test.ts +0 -72
  63. package/src/handlers/platform-fault-escalation.test.ts +0 -497
  64. package/src/handlers/platform-fault-escalation.ts +0 -246
  65. package/src/handlers/projects-digest.ts +0 -198
  66. package/src/handlers/quiz-research.ts +0 -178
  67. package/src/handlers/registry-stale-inactive-sweep.ts +0 -82
  68. package/src/handlers/scala-tools-currency-watch.test.ts +0 -65
  69. package/src/handlers/scala-tools-currency-watch.ts +0 -162
  70. package/src/handlers/singleton-tools-currency-watch.test.ts +0 -51
  71. package/src/handlers/singleton-tools-currency-watch.ts +0 -380
  72. package/src/handlers/sweep-event-log.ts +0 -98
  73. package/src/handlers/upstream-issue-sync.ts +0 -220
  74. package/src/handlers/whatsapp-liveness-handler.test.ts +0 -56
  75. package/src/handlers/whatsapp-liveness-logic.ts +0 -53
  76. package/src/handlers/whatsapp-liveness.test.ts +0 -78
  77. package/src/handlers/whatsapp-liveness.ts +0 -138
  78. package/src/interpolate-meta.ts +0 -123
  79. package/src/load-service-prisma-client.ts +0 -53
  80. package/src/loader-errors.ts +0 -41
  81. package/src/read-viewport-from-request.ts +0 -36
  82. package/src/register-page-server-client.test.ts +0 -185
  83. package/src/resolve-app-base-url.ts +0 -35
  84. package/src/resolve-from-loader-config.test.ts +0 -429
  85. package/src/resolve-from-loader-config.ts +0 -316
  86. 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
- }