@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.
@@ -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.0) over the exports map of @teamscala/service-boot.
4
- * ./website-by-host-map declares 1 runtime export(s) and 7 declared behaviour vector(s);
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-ts/api_smoke.json (scala-test-suites) — hand edits are
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("a mapped host resolves ITS website with the entry body_path", async () => {
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((got as { slug: string }).slug).toBe("site-launcha");
37
- expect((got as { body_path: string }).body_path).toBe("/p");
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
- expect((got as { body_path: string }).body_path).toBe("/join");
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.0) over the exports map of @teamscala/service-boot.
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-ts/api_smoke.json (scala-test-suites) — hand edits are
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
- expect(call).toThrow(/ghost/);
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
- expect(call).toThrow(/boom/);
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.3.0) over the exports map of @teamscala/service-boot.
4
- * ./run-boot-hooks declares 1 runtime export(s) + 2 type(s) and 5 declared behaviour vector(s);
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-ts/api_smoke.json (scala-test-suites) — hand edits are
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
- import { mock } from "bun:test";
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
- expect(got.ran).toEqual([]);
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
- const got = await runBootHooks("svc-bh", { boot_hooks: [{ slug: "the-hook", handler_slug: "stub-boot-hook" }] } as never);
36
- expect(got.ran).toEqual(["the-hook"]);
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.3.0) over the exports map of @teamscala/service-boot.
4
- * ./scala-tools-currency-watchdog declares 1 runtime export(s) and 2 declared behaviour vector(s);
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-ts/api_smoke.json (scala-test-suites) — hand edits are
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
- import { mock } from "bun:test";
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
- startScalaToolsCurrencyWatchdog();
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(120_000);
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.0) over the exports map of @teamscala/service-boot.
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-ts/api_smoke.json (scala-test-suites) — hand edits are
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.3.0) over the exports map of @teamscala/service-boot.
4
- * ./two-phase-readiness declares 6 runtime export(s) and 8 declared behaviour vector(s);
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-ts/api_smoke.json (scala-test-suites) — hand edits are
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
- import { mock } from "bun:test";
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({ ready: false, hasHandler: false });
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("markFullAppReady alone flips ready WITHOUT a handler — the client-render contract", async () => {
74
+ it("setPageHandler implies ready AND a handler (the SSR contract) [toBe]", async () => {
59
75
  __resetPhase2StateForTest();
60
- markFullAppReady(); const got = getPageHandlerState();
61
- expect(got.ready).toBe(true);
62
- expect(got.hasHandler).toBe(false);
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
- expect(got.ready).toBe(true);
68
- expect(got.hasHandler).toBe(true);
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
- expect(got.status).toBe(503);
74
- expect((await got.json() as { status: string }).status).toBe("starting");
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
- expect(got.status).toBe(503);
80
- expect((await got.json() as { status: string }).status).toBe("degraded");
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
- expect(got.status).toBe(200);
86
- expect((await got.json() as { status: string }).status).toBe("ok");
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
- expect(got.status).toBe(503);
93
- expect(got.headers.get("content-type")).toContain("text/html");
94
- expect(got.headers.get("retry-after")).not.toBeNull();
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
- expect(await got.text()).toBe("real-render");
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 — 200 ONLY once the full app can actually render
44
- * a page, 503 before that.
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". A UI service that cannot render
76
- * is not ready, per `liveness-by-capability-not-process-existence` — liveness is
77
- * a probe of the capability being REQUESTED, never of process existence or of
78
- * some other surface that happens to answer.
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 "can you serve me a page", is moved to phase 2.
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.0) over the exports map of @teamscala/service-boot.
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-ts/api_smoke.json (scala-test-suites) — hand edits are
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
  });