@teamscala/service-boot 0.23.540 → 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 +3 -1
- package/src/apply-logger-config.test.ts +13 -8
- package/src/boot-shell.test.ts +57 -0
- package/src/boot-shell.ts +20 -0
- package/src/error-page-logo.test.ts +35 -15
- package/src/orpc-contract-rows.test.ts +10 -9
- package/src/page-server-loader-graph.test.ts +10 -6
- package/src/readiness-gate.ts +4 -0
- package/src/register-registry-page-route/meta-title.test.ts +8 -8
- 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/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 +4 -0
- package/src/two-phase-full-app.test.ts +21 -0
- package/src/two-phase-full-app.ts +5 -1
- 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 +49 -24
- package/src/ws-boot-self-test.test.ts +15 -8
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @status generated — ts-api-smoke template (scala-test-suites
|
|
3
|
-
* templates/ts_api_smoke.pkl @1.
|
|
4
|
-
* ./website-by-host-map declares 1 runtime export(s) and
|
|
3
|
+
* templates/ts_api_smoke.pkl @1.5.0) over the exports map of @teamscala/service-boot.
|
|
4
|
+
* ./website-by-host-map declares 1 runtime export(s) and 10 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
|
|
|
@@ -23,41 +23,57 @@ describe("website-by-host-map exports-map smoke", () => {
|
|
|
23
23
|
it("resolveWebsiteByHostMap is a function", () => {
|
|
24
24
|
expect(typeof mod["resolveWebsiteByHostMap"]).toBe("function");
|
|
25
25
|
});
|
|
26
|
-
it("no host or no hosts_map resolves null without a query", async () => {
|
|
26
|
+
it("no host or no hosts_map resolves null without a query [toBeNull]", async () => {
|
|
27
27
|
let queried = 0; const client = { builda_websites: { findFirst: async () => { queried++; return null; } } };
|
|
28
|
-
const got = await resolveWebsiteByHostMap({ input: {}, client, params: {} } as never)
|
|
28
|
+
const got = await resolveWebsiteByHostMap({ input: {}, client, params: {} } as never)
|
|
29
29
|
expect(got).toBeNull();
|
|
30
|
-
expect(queried).toBe(0);
|
|
31
30
|
});
|
|
32
|
-
it("
|
|
31
|
+
it("no host or no hosts_map resolves null without a query [toBe]", async () => {
|
|
32
|
+
let queried = 0; const client = { builda_websites: { findFirst: async () => { queried++; return null; } } };
|
|
33
|
+
const got = await resolveWebsiteByHostMap({ input: {}, client, params: {} } as never)
|
|
34
|
+
expect(queried).toEqual(0);
|
|
35
|
+
});
|
|
36
|
+
it("a mapped host resolves ITS website with the entry body_path [toBeNull]", async () => {
|
|
33
37
|
const client = { builda_websites: { findFirst: async ({ where }) => (where.slug === "site-launcha" ? { id: "w1", slug: "site-launcha", name: "Launcha" } : null) } };
|
|
34
|
-
const got = await resolveWebsiteByHostMap({ input: { host: "launcha.example", hosts_map: { "launcha.example": { website: "site-launcha", body_path: "/p" } } }, client, params: {} } as never)
|
|
38
|
+
const got = await resolveWebsiteByHostMap({ input: { host: "launcha.example", hosts_map: { "launcha.example": { website: "site-launcha", body_path: "/p" } } }, client, params: {} } as never)
|
|
35
39
|
expect(got).not.toBeNull();
|
|
36
|
-
expect(
|
|
37
|
-
|
|
40
|
+
expect(got).not.toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
it("a mapped host resolves ITS website with the entry body_path [toBe]", async () => {
|
|
43
|
+
const client = { builda_websites: { findFirst: async ({ where }) => (where.slug === "site-launcha" ? { id: "w1", slug: "site-launcha", name: "Launcha" } : null) } };
|
|
44
|
+
const got = await resolveWebsiteByHostMap({ input: { host: "launcha.example", hosts_map: { "launcha.example": { website: "site-launcha", body_path: "/p" } } }, client, params: {} } as never)
|
|
45
|
+
const got2 = ((got as { slug: string }).slug);
|
|
46
|
+
expect(got2).toEqual("site-launcha");
|
|
47
|
+
});
|
|
48
|
+
it("a mapped host resolves ITS website with the entry body_path [toBe]", async () => {
|
|
49
|
+
const client = { builda_websites: { findFirst: async ({ where }) => (where.slug === "site-launcha" ? { id: "w1", slug: "site-launcha", name: "Launcha" } : null) } };
|
|
50
|
+
const got = await resolveWebsiteByHostMap({ input: { host: "launcha.example", hosts_map: { "launcha.example": { website: "site-launcha", body_path: "/p" } } }, client, params: {} } as never)
|
|
51
|
+
const got2 = ((got as { body_path: string }).body_path);
|
|
52
|
+
expect(got2).toEqual("/p");
|
|
38
53
|
});
|
|
39
54
|
it("an unmapped host falls to default_website and :path follows the request path", async () => {
|
|
40
55
|
const client = { builda_websites: { findFirst: async ({ where }) => (where.slug === "site-default" ? { id: "w2", slug: "site-default" } : null) } };
|
|
41
|
-
const got = await resolveWebsiteByHostMap({ input: { host: "other.example", hosts_map: {}, default_website: "site-default", default_body_path: ":path" }, client, params: { path: "/join" } } as never)
|
|
42
|
-
|
|
56
|
+
const got = await resolveWebsiteByHostMap({ input: { host: "other.example", hosts_map: {}, default_website: "site-default", default_body_path: ":path" }, client, params: { path: "/join" } } as never)
|
|
57
|
+
const got2 = ((got as { body_path: string }).body_path);
|
|
58
|
+
expect(got2).toEqual("/join");
|
|
43
59
|
});
|
|
44
60
|
it("an unknown slug in the map resolves null (the lookup is the validation)", async () => {
|
|
45
61
|
const client = { builda_websites: { findFirst: async () => null } };
|
|
46
|
-
const got = await resolveWebsiteByHostMap({ input: { host: "x.example", hosts_map: { "x.example": { website: "nope" } } }, client, params: {} } as never)
|
|
62
|
+
const got = await resolveWebsiteByHostMap({ input: { host: "x.example", hosts_map: { "x.example": { website: "nope" } } }, client, params: {} } as never)
|
|
47
63
|
expect(got).toBeNull();
|
|
48
64
|
});
|
|
49
65
|
it("a mapped host whose client carries no builda_websites is a MISS, not a TypeError (the sign.scala.business 500)", async () => {
|
|
50
66
|
const client = { other_model: {} };
|
|
51
|
-
const got = await resolveWebsiteByHostMap({ input: { host: "sign.scala.business", hosts_map: { "sign.scala.business": { website: "site-sign" } } }, client, params: {} } as never)
|
|
67
|
+
const got = await resolveWebsiteByHostMap({ input: { host: "sign.scala.business", hosts_map: { "sign.scala.business": { website: "site-sign" } } }, client, params: {} } as never)
|
|
52
68
|
expect(got).toBeNull();
|
|
53
69
|
});
|
|
54
70
|
it("an undefined client on a mapped host is a MISS, never a throw", async () => {
|
|
55
|
-
const got = await resolveWebsiteByHostMap({ input: { host: "sign.scala.business", hosts_map: { "sign.scala.business": { website: "site-sign" } } }, client: undefined, params: {} } as never)
|
|
71
|
+
const got = await resolveWebsiteByHostMap({ input: { host: "sign.scala.business", hosts_map: { "sign.scala.business": { website: "site-sign" } } }, client: undefined, params: {} } as never)
|
|
56
72
|
expect(got).toBeNull();
|
|
57
73
|
});
|
|
58
74
|
it("a bound accessor with no findFirst is a MISS, never a throw", async () => {
|
|
59
75
|
const client = { builda_websites: {} };
|
|
60
|
-
const got = await resolveWebsiteByHostMap({ input: { host: "sign.scala.business", hosts_map: { "sign.scala.business": { website: "site-sign" } } }, client, params: {} } as never)
|
|
76
|
+
const got = await resolveWebsiteByHostMap({ input: { host: "sign.scala.business", hosts_map: { "sign.scala.business": { website: "site-sign" } } }, client, params: {} } as never)
|
|
61
77
|
expect(got).toBeNull();
|
|
62
78
|
});
|
|
63
79
|
});
|
|
@@ -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
|
* ./run-boot-hook-list declares 1 runtime export(s) and 4 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
|
|
|
@@ -26,22 +26,24 @@ describe("run-boot-hook-list exports-map smoke", () => {
|
|
|
26
26
|
it("declared hooks run in declared order, registered-but-undeclared run after", async () => {
|
|
27
27
|
const ran: string[] = []; const hooks = new Map([["late", async () => { ran.push("late"); }], ["second", async () => { ran.push("second"); }], ["first", async () => { ran.push("first"); }]]);
|
|
28
28
|
const config = { boot_hooks: [{ slug: "first" }, { slug: "second" }] };
|
|
29
|
-
await runBootHookList(config as never, { bootHooks: hooks } as never, "svc-hl")
|
|
29
|
+
await runBootHookList(config as never, { bootHooks: hooks } as never, "svc-hl")
|
|
30
30
|
expect(ran).toEqual(["first", "second", "late"]);
|
|
31
31
|
});
|
|
32
32
|
it("a declared hook with no registered handler is a boot-fatal, not a silent skip", async () => {
|
|
33
33
|
const config = { boot_hooks: [{ slug: "ghost" }] };
|
|
34
|
-
const call = () => runBootHookList(config as never, { bootHooks: new Map() } as never, "svc-hl")
|
|
35
|
-
|
|
34
|
+
const call = () => runBootHookList(config as never, { bootHooks: new Map() } as never, "svc-hl")
|
|
35
|
+
const got = (call);
|
|
36
|
+
expect(() => got()).toThrow("ghost");
|
|
36
37
|
});
|
|
37
38
|
it("a throwing hook is fatal and NAMES the hook", async () => {
|
|
38
39
|
const hooks = new Map([["boom", async () => { throw new Error("disk gone"); }]]);
|
|
39
40
|
const config = { boot_hooks: [{ slug: "boom" }] };
|
|
40
|
-
const call = () => runBootHookList(config as never, { bootHooks: hooks } as never, "svc-hl")
|
|
41
|
-
|
|
41
|
+
const call = () => runBootHookList(config as never, { bootHooks: hooks } as never, "svc-hl")
|
|
42
|
+
const got = (call);
|
|
43
|
+
expect(() => got()).toThrow("boom");
|
|
42
44
|
});
|
|
43
45
|
it("no hooks and no registrations resolves without work", async () => {
|
|
44
|
-
const got = await runBootHookList({} as never, { bootHooks: new Map() } as never, "svc-hl")
|
|
46
|
+
const got = await runBootHookList({} as never, { bootHooks: new Map() } as never, "svc-hl")
|
|
45
47
|
expect(got).toBeUndefined();
|
|
46
48
|
});
|
|
47
49
|
});
|
|
@@ -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
|
});
|
|
@@ -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;
|
|
@@ -152,4 +152,25 @@ describe("phase-2 step isolation", () => {
|
|
|
152
152
|
expect(setPageHandlerCall).toBeGreaterThan(degraded);
|
|
153
153
|
expect(markFullAppReadyCall).toBeGreaterThan(degraded);
|
|
154
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);
|
|
175
|
+
});
|
|
155
176
|
});
|
|
@@ -14,7 +14,7 @@ import { verifyClientBuildAssets } from "./build-client-assets/manifest.ts";
|
|
|
14
14
|
import { mountPgBoss } from "./pg-boss-mount.ts";
|
|
15
15
|
import { swapApp } from "./mutable-app-ref.ts";
|
|
16
16
|
import type { MinimalAppResult } from "./two-phase-minimal-app.ts";
|
|
17
|
-
import { markFullAppReady, setPageHandler } from "./two-phase-readiness.ts";
|
|
17
|
+
import { markFullAppDegraded, markFullAppReady, setPageHandler } from "./two-phase-readiness.ts";
|
|
18
18
|
import type { PageServerDeps } from "./ui-service.ts";
|
|
19
19
|
|
|
20
20
|
export interface FullAppResult {
|
|
@@ -81,6 +81,10 @@ export async function buildFullApp(
|
|
|
81
81
|
},
|
|
82
82
|
occurredAt: new Date(),
|
|
83
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`,
|
|
87
|
+
);
|
|
84
88
|
return {
|
|
85
89
|
app: minimalApp,
|
|
86
90
|
blocks: [],
|
|
@@ -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
|
});
|