@teamscala/service-boot 0.23.539 → 0.23.541
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 +5 -1
- package/src/apply-logger-config.test.ts +13 -8
- package/src/attach-portal-members.test.ts +4 -0
- package/src/boot-shell.test.ts +57 -0
- package/src/boot-shell.ts +20 -0
- package/src/build-client-assets/sourcemaps-upload.test.ts +51 -6
- package/src/error-page-logo.test.ts +35 -15
- package/src/mount-orpc-router.test.ts +8 -0
- package/src/orpc-contract-rows.test.ts +10 -9
- package/src/page-server-loader-graph.test.ts +10 -6
- package/src/read-html-template.test.ts +9 -1
- package/src/readiness-gate.ts +4 -0
- package/src/register-registry-page-route/apply-page-branding.test.ts +13 -1
- package/src/register-registry-page-route/apply-page-branding.ts +1 -1
- package/src/register-registry-page-route/live-loader-config.test.ts +13 -4
- package/src/register-registry-page-route/meta-title.test.ts +8 -8
- package/src/register-registry-page-route/page-data-shared.test.ts +12 -5
- package/src/register-registry-page-route/resolve-entity-title.test.ts +9 -1
- package/src/register-registry-page-route/resolve-page-data.test.ts +16 -1
- package/src/register-registry-page-route/resolve-request-graph.test.ts +12 -26
- package/src/resolve-brand-favicon.test.ts +13 -11
- package/src/resolve-theme-mode.test.ts +52 -27
- package/src/resolvers/fetch-org-brand.test.ts +23 -12
- package/src/resolvers/website-by-host-map.test.ts +32 -16
- package/src/resource-not-found.test.ts +19 -1
- package/src/run-boot-hook-list.test.ts +10 -8
- package/src/run-boot-hooks.test.ts +11 -25
- package/src/scala-tools-currency-watchdog.test.ts +14 -6
- package/src/scala-tools-currency-watchdog.ts +5 -1
- package/src/theme-fonts-link.test.ts +2 -0
- package/src/two-phase-full-app.test.ts +69 -2
- package/src/two-phase-full-app.ts +29 -6
- package/src/two-phase-health-gate.test.ts +24 -0
- package/src/two-phase-minimal-app.test.ts +2 -2
- package/src/two-phase-readiness.test.ts +84 -34
- package/src/two-phase-readiness.ts +33 -7
- package/src/types.test.ts +3 -4
- package/src/ui-service.test.ts +2 -2
- package/src/ui-service.ts +55 -23
- package/src/ws-boot-self-test.test.ts +15 -8
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @status generated — ts-api-smoke template (scala-test-suites
|
|
3
|
-
* templates/ts_api_smoke.pkl @1.
|
|
4
|
-
* ./run-boot-hooks declares 1 runtime export(s) + 2 type(s) and
|
|
3
|
+
* templates/ts_api_smoke.pkl @1.5.0) over the exports map of @teamscala/service-boot.
|
|
4
|
+
* ./run-boot-hooks declares 1 runtime export(s) + 2 type(s) and 2 declared behaviour vector(s);
|
|
5
5
|
* this suite asserts the import resolves, the runtime surface matches
|
|
6
6
|
* exactly, and every export carries its declared kind. Regenerate from
|
|
7
|
-
* inputs/server-service-boot
|
|
7
|
+
* inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
|
|
8
8
|
* overwritten.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { describe, expect, it } from "bun:test";
|
|
12
|
-
|
|
12
|
+
const { mock } = await import("bun:test");
|
|
13
13
|
const __hookCalls: string[] = [];
|
|
14
14
|
mock.module("stub-boot-hook", () => ({ default: async (ctx: { serviceSlug: string }) => { __hookCalls.push(ctx.serviceSlug); } }));
|
|
15
15
|
mock.module("stub-boot-hook-no-default", () => ({ named: async () => {} }));
|
|
@@ -28,29 +28,15 @@ describe("run-boot-hooks exports-map smoke", () => {
|
|
|
28
28
|
expect(typeof mod["runBootHooks"]).toBe("function");
|
|
29
29
|
});
|
|
30
30
|
it("no declared hooks resolves an empty ran", async () => {
|
|
31
|
-
const got = await runBootHooks("svc-bh", {} as never)
|
|
32
|
-
|
|
31
|
+
const got = await runBootHooks("svc-bh", {} as never)
|
|
32
|
+
const got2 = (got.ran);
|
|
33
|
+
expect(got2).toEqual([]);
|
|
33
34
|
});
|
|
34
35
|
it("each hook dynamic-imports its handler_slug and receives the boot context", async () => {
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
__hookCalls.length = 0;
|
|
37
|
+
const got = await runBootHooks("svc-bh", { boot_hooks: [{ slug: "the-hook", handler_slug: "stub-boot-hook" }] } as never)
|
|
38
|
+
const got2 = (got.ran);
|
|
39
|
+
expect(got2).toEqual(["the-hook"]);
|
|
37
40
|
expect(__hookCalls).toEqual(["svc-bh"]);
|
|
38
41
|
});
|
|
39
|
-
it("hooks run in ascending order, defaulting to 100", async () => {
|
|
40
|
-
const order: string[] = [];
|
|
41
|
-
mock.module("stub-hook-a", () => ({ default: async () => { order.push("a-200"); } }));
|
|
42
|
-
mock.module("stub-hook-b", () => ({ default: async () => { order.push("b-50"); } }));
|
|
43
|
-
mock.module("stub-hook-c", () => ({ default: async () => { order.push("c-default"); } }));
|
|
44
|
-
const got = await runBootHooks("svc-bh", { boot_hooks: [{ slug: "a", handler_slug: "stub-hook-a", order: 200 }, { slug: "b", handler_slug: "stub-hook-b", order: 50 }, { slug: "c", handler_slug: "stub-hook-c" }] } as never);
|
|
45
|
-
expect(got.ran).toEqual(["b", "c", "a"]);
|
|
46
|
-
expect(order).toEqual(["b-50", "c-default", "a-200"]);
|
|
47
|
-
});
|
|
48
|
-
it("a module with no default export is boot-fatal", async () => {
|
|
49
|
-
const call = () => runBootHooks("svc-bh", { boot_hooks: [{ slug: "nope", handler_slug: "stub-boot-hook-no-default" }] } as never);
|
|
50
|
-
expect(call).toThrow(/no default export/);
|
|
51
|
-
});
|
|
52
|
-
it("an unimportable handler is boot-fatal and names the specifier", async () => {
|
|
53
|
-
const call = () => runBootHooks("svc-bh", { boot_hooks: [{ slug: "gone", handler_slug: "stub-hook-does-not-exist" }] } as never);
|
|
54
|
-
expect(call).toThrow(/stub-hook-does-not-exist/);
|
|
55
|
-
});
|
|
56
42
|
});
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @status generated — ts-api-smoke template (scala-test-suites
|
|
3
|
-
* templates/ts_api_smoke.pkl @1.
|
|
4
|
-
* ./scala-tools-currency-watchdog declares
|
|
3
|
+
* templates/ts_api_smoke.pkl @1.5.0) over the exports map of @teamscala/service-boot.
|
|
4
|
+
* ./scala-tools-currency-watchdog declares 2 runtime export(s) and 2 declared behaviour vector(s);
|
|
5
5
|
* this suite asserts the import resolves, the runtime surface matches
|
|
6
6
|
* exactly, and every export carries its declared kind. Regenerate from
|
|
7
|
-
* inputs/server-service-boot
|
|
7
|
+
* inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
|
|
8
8
|
* overwritten.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { describe, expect, it } from "bun:test";
|
|
12
|
-
|
|
12
|
+
const { mock } = await import("bun:test");
|
|
13
13
|
const __watchdogs: { name: string; options: Record<string, unknown>; started: boolean }[] = [];
|
|
14
14
|
const __captureCreateWatchdog = (name: string, options: Record<string, unknown>) => { const handle = { name, options, started: false, start() { this.started = true; }, stop() {} }; __watchdogs.push(handle); return handle; };
|
|
15
15
|
mock.module("@teamscala/watchdog/create-watchdog", () => ({ createWatchdog: __captureCreateWatchdog }));
|
|
@@ -21,23 +21,31 @@ const { startScalaToolsCurrencyWatchdog } = mod;
|
|
|
21
21
|
describe("scala-tools-currency-watchdog exports-map smoke", () => {
|
|
22
22
|
it("the runtime export set is exactly the declared surface", () => {
|
|
23
23
|
expect(Object.keys(mod).sort()).toEqual([
|
|
24
|
+
"__resetCurrencyWatchdogForTest",
|
|
24
25
|
"startScalaToolsCurrencyWatchdog",
|
|
25
26
|
]);
|
|
26
27
|
});
|
|
27
28
|
it("startScalaToolsCurrencyWatchdog is a function", () => {
|
|
28
29
|
expect(typeof mod["startScalaToolsCurrencyWatchdog"]).toBe("function");
|
|
29
30
|
});
|
|
31
|
+
it("__resetCurrencyWatchdogForTest is a function", () => {
|
|
32
|
+
expect(typeof mod["__resetCurrencyWatchdogForTest"]).toBe("function");
|
|
33
|
+
});
|
|
30
34
|
it("starts the currency watchdog with the poll contract and no run-on-start", async () => {
|
|
31
35
|
__watchdogs.length = 0;
|
|
32
|
-
|
|
36
|
+
mod.__resetCurrencyWatchdogForTest();
|
|
37
|
+
startScalaToolsCurrencyWatchdog()
|
|
33
38
|
expect(__watchdogs.length).toBe(1);
|
|
34
39
|
expect(__watchdogs[0].name).toBe("scala-sentinel:scala-tools-currency");
|
|
35
|
-
expect(__watchdogs[0].options.intervalMs).toBe(
|
|
40
|
+
expect(__watchdogs[0].options.intervalMs).toBe(120000);
|
|
36
41
|
expect(__watchdogs[0].options.runOnStart).toBe(false);
|
|
37
42
|
expect(__watchdogs[0].started).toBe(true);
|
|
38
43
|
});
|
|
39
44
|
it("a second start does not create a second watchdog", async () => {
|
|
45
|
+
__watchdogs.length = 0;
|
|
46
|
+
mod.__resetCurrencyWatchdogForTest();
|
|
40
47
|
startScalaToolsCurrencyWatchdog();
|
|
48
|
+
startScalaToolsCurrencyWatchdog()
|
|
41
49
|
expect(__watchdogs.length).toBe(1);
|
|
42
50
|
});
|
|
43
51
|
});
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
7
7
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
8
|
-
import { spawn } from "@teamscala/
|
|
8
|
+
import { spawn } from "@teamscala/process-spawn/spawn";
|
|
9
9
|
import { createWatchdog } from "@teamscala/watchdog/create-watchdog";
|
|
10
10
|
import type { WatchdogHandle } from "@teamscala/watchdog/types";
|
|
11
11
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
@@ -69,6 +69,10 @@ interface PackageManifest {
|
|
|
69
69
|
|
|
70
70
|
let watchdog: WatchdogHandle | null = null;
|
|
71
71
|
|
|
72
|
+
export function __resetCurrencyWatchdogForTest(): void {
|
|
73
|
+
watchdog = null;
|
|
74
|
+
}
|
|
75
|
+
|
|
72
76
|
async function readInstalledVersion(cwd: string): Promise<string | null> {
|
|
73
77
|
try {
|
|
74
78
|
const manifest = (await Bun.file(`${cwd}/${PACKAGE_PATH}`).json()) as PackageManifest;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
7
|
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
8
|
// with it into the band.
|
|
9
|
+
import * as __scalaReal_teamscala_db_registry_accessors from "@teamscala/db/registry/accessors";
|
|
9
10
|
|
|
10
11
|
import { describe, expect, mock, test } from "bun:test";
|
|
11
12
|
|
|
@@ -13,6 +14,7 @@ type ConfigRow = { fonts?: Record<string, string> } | undefined;
|
|
|
13
14
|
let injectedConfig: ConfigRow;
|
|
14
15
|
|
|
15
16
|
mock.module("@teamscala/db/registry/accessors", () => ({
|
|
17
|
+
...__scalaReal_teamscala_db_registry_accessors,
|
|
16
18
|
getEntryConfig: (_type: string, _slug: string) => injectedConfig,
|
|
17
19
|
}));
|
|
18
20
|
|
|
@@ -99,11 +99,78 @@ describe("phase-2 step isolation", () => {
|
|
|
99
99
|
expect(markFullAppReadyCall).toBeGreaterThan(gate);
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
/*
|
|
103
|
+
* THE 2026-10-13 RULING (client-build failure must not kill the process).
|
|
104
|
+
*
|
|
105
|
+
* The gate used to THROW on a failed verification, and the caller in ui-service
|
|
106
|
+
* turns any phase-2 throw into process.exit(1). Measured on portal-dev: a
|
|
107
|
+
* hydrate build that failed on unresolved @teamscala/ui-foundation block
|
|
108
|
+
* imports exited the process, supervise-daemon respawned until it gave up, and
|
|
109
|
+
* the service was parked stopped — so a broken CLIENT BUNDLE took down the
|
|
110
|
+
* API, MCP and job surfaces too, and nothing rendered for an error boundary to
|
|
111
|
+
* catch. A build failure is a fault to REPORT, not a reason to exit.
|
|
112
|
+
*
|
|
113
|
+
* The gate now returns with clientBuildFailed set and never throws, so the
|
|
114
|
+
* process stays up serving its non-page surfaces while pages stay unready.
|
|
115
|
+
*/
|
|
116
|
+
test("a failed verification returns degraded instead of throwing out of phase 2", () => {
|
|
103
117
|
const body = buildFullAppBody();
|
|
104
118
|
const gate = body.indexOf("verifyClientBuildAssets()");
|
|
105
119
|
const slice = body.slice(gate, gate + 1200);
|
|
106
120
|
expect(slice).toContain("!clientBuild.ok");
|
|
107
|
-
expect(slice).toContain("throw new Error");
|
|
121
|
+
expect(slice).not.toContain("throw new Error");
|
|
122
|
+
expect(slice).toContain("clientBuildFailed");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/*
|
|
126
|
+
* The failure must REACH US. The reason is logged and recorded as a durable
|
|
127
|
+
* boot.client-build-failed event, so the degraded state is observable on our
|
|
128
|
+
* end rather than a silent page outage.
|
|
129
|
+
*/
|
|
130
|
+
test("a failed verification records the fault durably, so the degraded state is reported", () => {
|
|
131
|
+
const body = buildFullAppBody();
|
|
132
|
+
const gate = body.indexOf("verifyClientBuildAssets()");
|
|
133
|
+
const slice = body.slice(gate, gate + 1200);
|
|
134
|
+
expect(slice).toContain("log.error");
|
|
135
|
+
expect(slice).toContain("recordEventDurable");
|
|
136
|
+
expect(slice).toContain("boot.client-build-failed");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/*
|
|
140
|
+
* PAGES STAY UNAVAILABLE. The early return must sit BEFORE both readiness flip
|
|
141
|
+
* call sites, or the 2026-09-01 unstyled-proposal incident returns: a service
|
|
142
|
+
* whose client build never ran flips ready, serves pages, and answers /health
|
|
143
|
+
* 200 while every asset URL 404s. Degrading the PROCESS is not degrading READINESS.
|
|
144
|
+
*/
|
|
145
|
+
test("the degraded return precedes both readiness flips, so pages stay unready", () => {
|
|
146
|
+
const body = buildFullAppBody();
|
|
147
|
+
const gate = body.indexOf("verifyClientBuildAssets()");
|
|
148
|
+
const degraded = body.indexOf("clientBuildFailed: reason");
|
|
149
|
+
const setPageHandlerCall = body.indexOf("setPageHandler(deps.createPageHandler)");
|
|
150
|
+
const markFullAppReadyCall = body.indexOf("markFullAppReady()");
|
|
151
|
+
expect(degraded).toBeGreaterThan(gate);
|
|
152
|
+
expect(setPageHandlerCall).toBeGreaterThan(degraded);
|
|
153
|
+
expect(markFullAppReadyCall).toBeGreaterThan(degraded);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/*
|
|
157
|
+
* THE BOOT ITSELF FINISHED, AND /health MUST SAY SO. Measured 2026-10-14 on
|
|
158
|
+
* portal-dev: the degraded path returned before every phase-2 flip, so
|
|
159
|
+
* /health answered 503 "starting" forever — a state the boot KNEW was false.
|
|
160
|
+
* Every health-gated consumer reads 503 as still-booting
|
|
161
|
+
* (build_executor::deploy_probe::still_booting resets its rounds counter on a
|
|
162
|
+
* 503), so the dev-update rehearsal probed the finished boot for hours and
|
|
163
|
+
* never completed; the activator wake gate would have held a customer the
|
|
164
|
+
* same way. markFullAppDegraded flips the flag WITH the reason so /health
|
|
165
|
+
* reports 200 degraded — the boot's real verdict — while the return above
|
|
166
|
+
* still keeps the real page handler unregistered.
|
|
167
|
+
*/
|
|
168
|
+
test("the degraded branch flips readiness with its reason before returning", () => {
|
|
169
|
+
const body = buildFullAppBody();
|
|
170
|
+
const gate = body.indexOf("verifyClientBuildAssets()");
|
|
171
|
+
const flip = body.indexOf("markFullAppDegraded(reason)");
|
|
172
|
+
const degraded = body.indexOf("clientBuildFailed: reason");
|
|
173
|
+
expect(flip).toBeGreaterThan(gate);
|
|
174
|
+
expect(degraded).toBeGreaterThan(flip);
|
|
108
175
|
});
|
|
109
176
|
});
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
import { recordEventDurable } from "@teamscala/event-log/record-event";
|
|
9
10
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
10
11
|
import type { Hono } from "hono";
|
|
11
12
|
import { buildClientAssets } from "./build-client-assets.ts";
|
|
@@ -13,7 +14,7 @@ import { verifyClientBuildAssets } from "./build-client-assets/manifest.ts";
|
|
|
13
14
|
import { mountPgBoss } from "./pg-boss-mount.ts";
|
|
14
15
|
import { swapApp } from "./mutable-app-ref.ts";
|
|
15
16
|
import type { MinimalAppResult } from "./two-phase-minimal-app.ts";
|
|
16
|
-
import { markFullAppReady, setPageHandler } from "./two-phase-readiness.ts";
|
|
17
|
+
import { markFullAppDegraded, markFullAppReady, setPageHandler } from "./two-phase-readiness.ts";
|
|
17
18
|
import type { PageServerDeps } from "./ui-service.ts";
|
|
18
19
|
|
|
19
20
|
export interface FullAppResult {
|
|
@@ -21,6 +22,7 @@ export interface FullAppResult {
|
|
|
21
22
|
blocks: { slug: string; packagePath: string }[];
|
|
22
23
|
routes: string[];
|
|
23
24
|
pgBossJobs: Array<{ handlerSlug: string }>;
|
|
25
|
+
clientBuildFailed?: string;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
export async function buildFullApp(
|
|
@@ -63,12 +65,33 @@ export async function buildFullApp(
|
|
|
63
65
|
|
|
64
66
|
const clientBuild = await verifyClientBuildAssets();
|
|
65
67
|
if (!clientBuild.ok) {
|
|
66
|
-
|
|
67
|
-
`[two-phase-boot:${serviceSlug}]
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
const reason =
|
|
69
|
+
`[two-phase-boot:${serviceSlug}] client build is not servable — ${clientBuild.reason}. ` +
|
|
70
|
+
`Pages stay unavailable: serving here would emit asset URLs that 404 while every health surface reports ok. ` +
|
|
71
|
+
`Background work is mounted and unaffected, and the server keeps serving its API, MCP and job surfaces. ` +
|
|
72
|
+
`Fix the client build (see build-client-assets logs above); pages recover on the next restart.`;
|
|
73
|
+
log.error(reason);
|
|
74
|
+
await recordEventDurable({
|
|
75
|
+
id: crypto.randomUUID(),
|
|
76
|
+
kind: "boot.client-build-failed",
|
|
77
|
+
payload: {
|
|
78
|
+
at: new Date().toISOString(),
|
|
79
|
+
service: serviceSlug,
|
|
80
|
+
reason: reason.slice(0, 2000),
|
|
81
|
+
},
|
|
82
|
+
occurredAt: new Date(),
|
|
83
|
+
});
|
|
84
|
+
markFullAppDegraded(reason);
|
|
85
|
+
log.error(
|
|
86
|
+
`[two-phase-boot:${serviceSlug}] /health now reports degraded with this reason — a 503 "starting" here would hold every health-gated consumer (deploy probe, activator wake, poller health gate) open on a boot that has finished`,
|
|
71
87
|
);
|
|
88
|
+
return {
|
|
89
|
+
app: minimalApp,
|
|
90
|
+
blocks: [],
|
|
91
|
+
routes: [],
|
|
92
|
+
pgBossJobs,
|
|
93
|
+
clientBuildFailed: reason,
|
|
94
|
+
};
|
|
72
95
|
}
|
|
73
96
|
|
|
74
97
|
log.info(
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
__resetPhase2StateForTest,
|
|
14
14
|
getPageHandlerState,
|
|
15
15
|
healthHandler,
|
|
16
|
+
markFullAppDegraded,
|
|
16
17
|
setPageHandler,
|
|
17
18
|
} from "./two-phase-readiness.ts";
|
|
18
19
|
|
|
@@ -59,4 +60,27 @@ describe("/health is gated on page-serving readiness", () => {
|
|
|
59
60
|
expect(res.ok).toBe(true);
|
|
60
61
|
expect(await res.json()).toEqual({ status: "ok" });
|
|
61
62
|
});
|
|
63
|
+
|
|
64
|
+
test("after a degraded phase-2 completion it reports 200 degraded with the reason", async () => {
|
|
65
|
+
// The degraded path (client build not servable) is a COMPLETED boot, not a
|
|
66
|
+
// longer one: markFullAppDegraded flips fullAppReady with the reason, so
|
|
67
|
+
// /health cannot answer 503 "starting" — a state the boot knows is false.
|
|
68
|
+
// Every health-gated consumer reads 503 as still-booting
|
|
69
|
+
// (build_executor::deploy_probe::still_booting resets its rounds counter on
|
|
70
|
+
// a 503), so the dev-update rehearsal probed a finished boot for hours and
|
|
71
|
+
// never completed (measured 2026-10-14, portal-dev). The 200 carries the
|
|
72
|
+
// boot's real verdict; traffic flows to the branded holding page the app
|
|
73
|
+
// itself serves.
|
|
74
|
+
markFullAppDegraded("client build is not servable: test reason");
|
|
75
|
+
expect(getPageHandlerState().ready).toBe(true);
|
|
76
|
+
|
|
77
|
+
const res = await app.request("/health");
|
|
78
|
+
|
|
79
|
+
expect(res.status).toBe(200);
|
|
80
|
+
expect(res.ok).toBe(true);
|
|
81
|
+
expect(await res.json()).toEqual({
|
|
82
|
+
status: "degraded",
|
|
83
|
+
reason: "client build is not servable: test reason",
|
|
84
|
+
});
|
|
85
|
+
});
|
|
62
86
|
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @status generated — ts-api-smoke template (scala-test-suites
|
|
3
|
-
* templates/ts_api_smoke.pkl @1.
|
|
3
|
+
* templates/ts_api_smoke.pkl @1.5.0) over the exports map of @teamscala/service-boot.
|
|
4
4
|
* ./two-phase-minimal-app declares 1 runtime export(s) + 1 type(s);
|
|
5
5
|
* this suite asserts the import resolves, the runtime surface matches
|
|
6
6
|
* exactly, and every export carries its declared kind. Regenerate from
|
|
7
|
-
* inputs/server-service-boot
|
|
7
|
+
* inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
|
|
8
8
|
* overwritten.
|
|
9
9
|
*/
|
|
10
10
|
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @status generated — ts-api-smoke template (scala-test-suites
|
|
3
|
-
* templates/ts_api_smoke.pkl @1.
|
|
4
|
-
* ./two-phase-readiness declares
|
|
3
|
+
* templates/ts_api_smoke.pkl @1.5.0) over the exports map of @teamscala/service-boot.
|
|
4
|
+
* ./two-phase-readiness declares 7 runtime export(s) and 15 declared behaviour vector(s);
|
|
5
5
|
* this suite asserts the import resolves, the runtime surface matches
|
|
6
6
|
* exactly, and every export carries its declared kind. Regenerate from
|
|
7
|
-
* inputs/server-service-boot
|
|
7
|
+
* inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
|
|
8
8
|
* overwritten.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { describe, expect, it } from "bun:test";
|
|
12
|
-
|
|
12
|
+
const { mock } = await import("bun:test");
|
|
13
13
|
const __realServerClient = await import("./page-server-orpc/server-client.ts");
|
|
14
14
|
const __capabilityFault: { value: string | null } = { value: null };
|
|
15
15
|
mock.module("./page-server-orpc/server-client.ts", () => ({ ...__realServerClient, getServerClientCapabilityFault: () => __capabilityFault.value }));
|
|
@@ -19,7 +19,7 @@ const __ctx = { json: (body: unknown, status?: number, headers?: Record<string,
|
|
|
19
19
|
|
|
20
20
|
const mod = await import("./two-phase-readiness");
|
|
21
21
|
|
|
22
|
-
const { setPageHandler, markFullAppReady, __resetPhase2StateForTest, getPageHandlerState, healthHandler, createPhase1PageHandler } = mod;
|
|
22
|
+
const { setPageHandler, markFullAppReady, markFullAppDegraded, __resetPhase2StateForTest, getPageHandlerState, healthHandler, createPhase1PageHandler } = mod;
|
|
23
23
|
|
|
24
24
|
describe("two-phase-readiness exports-map smoke", () => {
|
|
25
25
|
it("the runtime export set is exactly the declared surface", () => {
|
|
@@ -28,6 +28,7 @@ describe("two-phase-readiness exports-map smoke", () => {
|
|
|
28
28
|
"createPhase1PageHandler",
|
|
29
29
|
"getPageHandlerState",
|
|
30
30
|
"healthHandler",
|
|
31
|
+
"markFullAppDegraded",
|
|
31
32
|
"markFullAppReady",
|
|
32
33
|
"setPageHandler",
|
|
33
34
|
]);
|
|
@@ -38,6 +39,9 @@ describe("two-phase-readiness exports-map smoke", () => {
|
|
|
38
39
|
it("markFullAppReady is a function", () => {
|
|
39
40
|
expect(typeof mod["markFullAppReady"]).toBe("function");
|
|
40
41
|
});
|
|
42
|
+
it("markFullAppDegraded is a function", () => {
|
|
43
|
+
expect(typeof mod["markFullAppDegraded"]).toBe("function");
|
|
44
|
+
});
|
|
41
45
|
it("__resetPhase2StateForTest is a function", () => {
|
|
42
46
|
expect(typeof mod["__resetPhase2StateForTest"]).toBe("function");
|
|
43
47
|
});
|
|
@@ -52,51 +56,97 @@ describe("two-phase-readiness exports-map smoke", () => {
|
|
|
52
56
|
});
|
|
53
57
|
it("a fresh boot starts unready with no handler", async () => {
|
|
54
58
|
__resetPhase2StateForTest();
|
|
55
|
-
const got = getPageHandlerState()
|
|
56
|
-
expect(got).toEqual({
|
|
59
|
+
const got = getPageHandlerState()
|
|
60
|
+
expect(got).toEqual({"ready": false, "hasHandler": false});
|
|
61
|
+
});
|
|
62
|
+
it("markFullAppReady alone flips ready WITHOUT a handler — the client-render contract [toBe]", async () => {
|
|
63
|
+
__resetPhase2StateForTest();
|
|
64
|
+
markFullAppReady(); const got = getPageHandlerState()
|
|
65
|
+
const got2 = (got.ready);
|
|
66
|
+
expect(got2).toEqual(true);
|
|
67
|
+
});
|
|
68
|
+
it("markFullAppReady alone flips ready WITHOUT a handler — the client-render contract [toBe]", async () => {
|
|
69
|
+
__resetPhase2StateForTest();
|
|
70
|
+
markFullAppReady(); const got = getPageHandlerState()
|
|
71
|
+
const got2 = (got.hasHandler);
|
|
72
|
+
expect(got2).toEqual(false);
|
|
57
73
|
});
|
|
58
|
-
it("
|
|
74
|
+
it("setPageHandler implies ready AND a handler (the SSR contract) [toBe]", async () => {
|
|
59
75
|
__resetPhase2StateForTest();
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
expect(
|
|
76
|
+
setPageHandler((() => async () => new Response("ok")) as never); const got = getPageHandlerState()
|
|
77
|
+
const got2 = (got.ready);
|
|
78
|
+
expect(got2).toEqual(true);
|
|
63
79
|
});
|
|
64
|
-
it("setPageHandler implies ready AND a handler (the SSR contract)", async () => {
|
|
80
|
+
it("setPageHandler implies ready AND a handler (the SSR contract) [toBe]", async () => {
|
|
65
81
|
__resetPhase2StateForTest();
|
|
66
|
-
setPageHandler((() => async () => new Response("ok")) as never); const got = getPageHandlerState()
|
|
67
|
-
|
|
68
|
-
expect(
|
|
82
|
+
setPageHandler((() => async () => new Response("ok")) as never); const got = getPageHandlerState()
|
|
83
|
+
const got2 = (got.hasHandler);
|
|
84
|
+
expect(got2).toEqual(true);
|
|
69
85
|
});
|
|
70
|
-
it("/health reports 503 starting before phase 2", async () => {
|
|
86
|
+
it("/health reports 503 starting before phase 2 [toBe]", async () => {
|
|
71
87
|
__resetPhase2StateForTest(); __capabilityFault.value = null;
|
|
72
|
-
const got = healthHandler(__ctx as never) as Response
|
|
73
|
-
|
|
74
|
-
expect(
|
|
88
|
+
const got = healthHandler(__ctx as never) as Response
|
|
89
|
+
const got2 = (got.status);
|
|
90
|
+
expect(got2).toEqual(503);
|
|
91
|
+
});
|
|
92
|
+
it("/health reports 503 starting before phase 2 [toBe]", async () => {
|
|
93
|
+
__resetPhase2StateForTest(); __capabilityFault.value = null;
|
|
94
|
+
const got = healthHandler(__ctx as never) as Response
|
|
95
|
+
const got2 = ((await got.json() as { status: string }).status);
|
|
96
|
+
expect(got2).toEqual("starting");
|
|
97
|
+
});
|
|
98
|
+
it("/health asserts the capability, not just the phase flag [toBe]", async () => {
|
|
99
|
+
__resetPhase2StateForTest(); markFullAppReady(); __capabilityFault.value = "registered client carries no CRUD models";
|
|
100
|
+
const got = healthHandler(__ctx as never) as Response
|
|
101
|
+
const got2 = (got.status);
|
|
102
|
+
expect(got2).toEqual(503);
|
|
75
103
|
});
|
|
76
|
-
it("/health asserts the capability, not just the phase flag", async () => {
|
|
104
|
+
it("/health asserts the capability, not just the phase flag [toBe]", async () => {
|
|
77
105
|
__resetPhase2StateForTest(); markFullAppReady(); __capabilityFault.value = "registered client carries no CRUD models";
|
|
78
|
-
const got = healthHandler(__ctx as never) as Response
|
|
79
|
-
|
|
80
|
-
expect(
|
|
106
|
+
const got = healthHandler(__ctx as never) as Response
|
|
107
|
+
const got2 = ((await got.json() as { status: string }).status);
|
|
108
|
+
expect(got2).toEqual("degraded");
|
|
81
109
|
});
|
|
82
|
-
it("/health answers ok once ready and the capability is intact", async () => {
|
|
110
|
+
it("/health answers ok once ready and the capability is intact [toBe]", async () => {
|
|
83
111
|
__resetPhase2StateForTest(); markFullAppReady(); __capabilityFault.value = null;
|
|
84
|
-
const got = healthHandler(__ctx as never) as Response
|
|
85
|
-
|
|
86
|
-
expect(
|
|
112
|
+
const got = healthHandler(__ctx as never) as Response
|
|
113
|
+
const got2 = (got.status);
|
|
114
|
+
expect(got2).toEqual(200);
|
|
115
|
+
});
|
|
116
|
+
it("/health answers ok once ready and the capability is intact [toBe]", async () => {
|
|
117
|
+
__resetPhase2StateForTest(); markFullAppReady(); __capabilityFault.value = null;
|
|
118
|
+
const got = healthHandler(__ctx as never) as Response
|
|
119
|
+
const got2 = ((await got.json() as { status: string }).status);
|
|
120
|
+
expect(got2).toEqual("ok");
|
|
121
|
+
});
|
|
122
|
+
it("the phase-1 page handler serves the branded waking 503 before phase 2 [toBe]", async () => {
|
|
123
|
+
__resetPhase2StateForTest();
|
|
124
|
+
const handler = createPhase1PageHandler("svc-tr")( {} as never );
|
|
125
|
+
const got = await handler(new Request("http://x.local/"), {} as never)
|
|
126
|
+
const got2 = (got.status);
|
|
127
|
+
expect(got2).toEqual(503);
|
|
128
|
+
});
|
|
129
|
+
it("the phase-1 page handler serves the branded waking 503 before phase 2 [toContain]", async () => {
|
|
130
|
+
__resetPhase2StateForTest();
|
|
131
|
+
const handler = createPhase1PageHandler("svc-tr")( {} as never );
|
|
132
|
+
const got = await handler(new Request("http://x.local/"), {} as never)
|
|
133
|
+
const got2 = (got.headers.get("content-type"));
|
|
134
|
+
const __t0_0 = got2;
|
|
135
|
+
expect(typeof __t0_0 === "string" ? __t0_0 : JSON.stringify(__t0_0)).toContain("text/html");
|
|
87
136
|
});
|
|
88
|
-
it("the phase-1 page handler serves the branded waking 503 before phase 2", async () => {
|
|
137
|
+
it("the phase-1 page handler serves the branded waking 503 before phase 2 [toBeNull]", async () => {
|
|
89
138
|
__resetPhase2StateForTest();
|
|
90
139
|
const handler = createPhase1PageHandler("svc-tr")( {} as never );
|
|
91
|
-
const got = await handler(new Request("http://x.local/"), {} as never)
|
|
92
|
-
|
|
93
|
-
expect(
|
|
94
|
-
expect(
|
|
140
|
+
const got = await handler(new Request("http://x.local/"), {} as never)
|
|
141
|
+
const got2 = (got.headers.get("retry-after"));
|
|
142
|
+
expect(got2).not.toBeNull();
|
|
143
|
+
expect(got2).not.toBeUndefined();
|
|
95
144
|
});
|
|
96
145
|
it("the phase-1 handler delegates to the real handler once registered", async () => {
|
|
97
146
|
__resetPhase2StateForTest(); setPageHandler((() => async () => new Response("real-render")) as never);
|
|
98
147
|
const handler = createPhase1PageHandler("svc-tr")( {} as never );
|
|
99
|
-
const got = await handler(new Request("http://x.local/"), {} as never)
|
|
100
|
-
|
|
148
|
+
const got = await handler(new Request("http://x.local/"), {} as never)
|
|
149
|
+
const got2 = (await got.text());
|
|
150
|
+
expect(got2).toEqual("real-render");
|
|
101
151
|
});
|
|
102
152
|
});
|
|
@@ -40,8 +40,9 @@
|
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* `/health` for a UI service —
|
|
44
|
-
*
|
|
43
|
+
* `/health` for a UI service — 503 `{"status":"starting"}` until phase 2
|
|
44
|
+
* completes, then 200: `{"status":"degraded","reason":...}` when the client
|
|
45
|
+
* build failed, `{"status":"ok"}` otherwise.
|
|
45
46
|
*
|
|
46
47
|
* This endpoint is not decoration: it is the wake gate for every scale-to-zero
|
|
47
48
|
* app. `scala-activator` (`@teamscala/scale-to-zero`) holds the client's TCP
|
|
@@ -72,14 +73,22 @@
|
|
|
72
73
|
* health-gated router) rather than teaching each one its own probe, and it is
|
|
73
74
|
* what `readiness-gated-boot` already requires: "/health returns 503 until
|
|
74
75
|
* ready, 200 after — so it is the real readiness signal", a service being
|
|
75
|
-
* "binary: fully-ready or cleanly-unavailable".
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
76
|
+
* "binary: fully-ready or cleanly-unavailable".
|
|
77
|
+
*
|
|
78
|
+
* DEGRADED COMPLETION IS A 200, NOT A THIRD KIND OF STARTING (measured
|
|
79
|
+
* 2026-10-14, portal-dev). The degraded path (client build not servable) first
|
|
80
|
+
* returned before the phase-2 flips, so `/health` answered 503 "starting"
|
|
81
|
+
* forever — a state the boot KNEW was false. Every health-gated consumer reads
|
|
82
|
+
* 503 as still-booting: `build_executor::deploy_probe::still_booting` resets
|
|
83
|
+
* its answered-rounds counter on "503 Service Unavailable", so the dev-update
|
|
84
|
+
* rehearsal probed a finished boot for 2.5h and never completed; the activator
|
|
85
|
+
* wake gate would hold a customer forever. The boot's own verdict — degraded,
|
|
86
|
+
* with the recorded reason in the body — is the honest answer, and it lets
|
|
87
|
+
* traffic flow to the branded holding page the app itself serves.
|
|
79
88
|
*
|
|
80
89
|
* The early readiness-gate flip is deliberately left alone: it gates the ORPC
|
|
81
90
|
* tool surface, which genuinely IS usable at phase 1. Only this endpoint, whose
|
|
82
|
-
* one consumer contract is "
|
|
91
|
+
* one consumer contract is "has boot completed", is moved to phase 2.
|
|
83
92
|
*/
|
|
84
93
|
|
|
85
94
|
import { buildWakingPageHtml } from "@teamscala/brand/brand/waking-page-html";
|
|
@@ -93,6 +102,19 @@ import type { CreatePageHandler } from "./types.ts";
|
|
|
93
102
|
// This allows the temporary Phase 1 handler to delegate to the real handler once ready.
|
|
94
103
|
let realPageHandler: CreatePageHandler | null = null;
|
|
95
104
|
let fullAppReady = false;
|
|
105
|
+
let degradedReason: string | null = null;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Phase 2 completed DEGRADED: the client build is not servable, so the full
|
|
109
|
+
* route set never registered — but the boot itself finished, and the process
|
|
110
|
+
* serves its API, MCP and job surfaces plus the branded holding page for
|
|
111
|
+
* pages. `fullAppReady` flips true because "starting" is no longer true; the
|
|
112
|
+
* reason travels beside it so `/health` can report the real verdict.
|
|
113
|
+
*/
|
|
114
|
+
export function markFullAppDegraded(reason: string): void {
|
|
115
|
+
degradedReason = reason;
|
|
116
|
+
fullAppReady = true;
|
|
117
|
+
}
|
|
96
118
|
|
|
97
119
|
/**
|
|
98
120
|
* Set the real page handler (called when Phase 2 completes on an SSR service).
|
|
@@ -113,6 +135,7 @@ export function markFullAppReady(): void {
|
|
|
113
135
|
export function __resetPhase2StateForTest(): void {
|
|
114
136
|
realPageHandler = null;
|
|
115
137
|
fullAppReady = false;
|
|
138
|
+
degradedReason = null;
|
|
116
139
|
}
|
|
117
140
|
|
|
118
141
|
/**
|
|
@@ -124,6 +147,9 @@ export function getPageHandlerState(): { ready: boolean; hasHandler: boolean } {
|
|
|
124
147
|
|
|
125
148
|
export function healthHandler(c: Context): Response {
|
|
126
149
|
if (!fullAppReady) return c.json({ status: "starting" }, 503, { "Retry-After": "1" });
|
|
150
|
+
if (degradedReason !== null) {
|
|
151
|
+
return c.json({ status: "degraded", reason: degradedReason });
|
|
152
|
+
}
|
|
127
153
|
// Assert the CAPABILITY, not just the phase flag. On 2026-08-20 this returned
|
|
128
154
|
// 200 through a total outage: the process was up and phase 2 had completed
|
|
129
155
|
// while the registered client carried no CRUD models, so every data-backed
|
package/src/types.test.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @status generated — ts-api-smoke template (scala-test-suites
|
|
3
|
-
* templates/ts_api_smoke.pkl @1.
|
|
3
|
+
* templates/ts_api_smoke.pkl @1.5.0) over the exports map of @teamscala/service-boot.
|
|
4
4
|
* ./types declares 0 runtime export(s) + 5 type(s);
|
|
5
5
|
* this suite asserts the import resolves, the runtime surface matches
|
|
6
6
|
* exactly, and every export carries its declared kind. Regenerate from
|
|
7
|
-
* inputs/server-service-boot
|
|
7
|
+
* inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
|
|
8
8
|
* overwritten.
|
|
9
9
|
*/
|
|
10
10
|
|
|
@@ -13,8 +13,7 @@ import { describe, expect, it } from "bun:test";
|
|
|
13
13
|
const mod = await import("./types");
|
|
14
14
|
|
|
15
15
|
describe("types exports-map smoke", () => {
|
|
16
|
-
it("is type-only — the runtime module carries no values", () => {
|
|
17
|
-
// Declared types: RendererComponentEntry, PageHandlerExtras, CreatePageHandler, PageHandlerConfig, ServiceInstallConfig
|
|
16
|
+
it("is type-only (declared: RendererComponentEntry, PageHandlerExtras, CreatePageHandler, PageHandlerConfig, ServiceInstallConfig) — the runtime module carries no values", () => {
|
|
18
17
|
expect(Object.keys(mod).length).toBe(0);
|
|
19
18
|
});
|
|
20
19
|
});
|
package/src/ui-service.test.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @status generated — ts-api-smoke template (scala-test-suites
|
|
3
|
-
* templates/ts_api_smoke.pkl @1.
|
|
3
|
+
* templates/ts_api_smoke.pkl @1.5.0) over the exports map of @teamscala/service-boot.
|
|
4
4
|
* ./ui-service declares 1 runtime export(s) + 3 type(s);
|
|
5
5
|
* this suite asserts the import resolves, the runtime surface matches
|
|
6
6
|
* exactly, and every export carries its declared kind. Regenerate from
|
|
7
|
-
* inputs/server-service-boot
|
|
7
|
+
* inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
|
|
8
8
|
* overwritten.
|
|
9
9
|
*/
|
|
10
10
|
|