@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.
Files changed (40) hide show
  1. package/package.json +5 -1
  2. package/src/apply-logger-config.test.ts +13 -8
  3. package/src/attach-portal-members.test.ts +4 -0
  4. package/src/boot-shell.test.ts +57 -0
  5. package/src/boot-shell.ts +20 -0
  6. package/src/build-client-assets/sourcemaps-upload.test.ts +51 -6
  7. package/src/error-page-logo.test.ts +35 -15
  8. package/src/mount-orpc-router.test.ts +8 -0
  9. package/src/orpc-contract-rows.test.ts +10 -9
  10. package/src/page-server-loader-graph.test.ts +10 -6
  11. package/src/read-html-template.test.ts +9 -1
  12. package/src/readiness-gate.ts +4 -0
  13. package/src/register-registry-page-route/apply-page-branding.test.ts +13 -1
  14. package/src/register-registry-page-route/apply-page-branding.ts +1 -1
  15. package/src/register-registry-page-route/live-loader-config.test.ts +13 -4
  16. package/src/register-registry-page-route/meta-title.test.ts +8 -8
  17. package/src/register-registry-page-route/page-data-shared.test.ts +12 -5
  18. package/src/register-registry-page-route/resolve-entity-title.test.ts +9 -1
  19. package/src/register-registry-page-route/resolve-page-data.test.ts +16 -1
  20. package/src/register-registry-page-route/resolve-request-graph.test.ts +12 -26
  21. package/src/resolve-brand-favicon.test.ts +13 -11
  22. package/src/resolve-theme-mode.test.ts +52 -27
  23. package/src/resolvers/fetch-org-brand.test.ts +23 -12
  24. package/src/resolvers/website-by-host-map.test.ts +32 -16
  25. package/src/resource-not-found.test.ts +19 -1
  26. package/src/run-boot-hook-list.test.ts +10 -8
  27. package/src/run-boot-hooks.test.ts +11 -25
  28. package/src/scala-tools-currency-watchdog.test.ts +14 -6
  29. package/src/scala-tools-currency-watchdog.ts +5 -1
  30. package/src/theme-fonts-link.test.ts +2 -0
  31. package/src/two-phase-full-app.test.ts +69 -2
  32. package/src/two-phase-full-app.ts +29 -6
  33. package/src/two-phase-health-gate.test.ts +24 -0
  34. package/src/two-phase-minimal-app.test.ts +2 -2
  35. package/src/two-phase-readiness.test.ts +84 -34
  36. package/src/two-phase-readiness.ts +33 -7
  37. package/src/types.test.ts +3 -4
  38. package/src/ui-service.test.ts +2 -2
  39. package/src/ui-service.ts +55 -23
  40. 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.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
  });
@@ -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/os/spawn";
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
- test("a failed verification throws — the boot never continues past an unverified manifest", () => {
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
- throw new Error(
67
- `[two-phase-boot:${serviceSlug}] refusing readiness — ${clientBuild.reason}. ` +
68
- `Serving pages here would emit asset URLs that 404 while every health surface reports ok. ` +
69
- `Background work is mounted and unaffected; pages and /health stay unavailable until a real client build exists. ` +
70
- `Fix the client build (see build-client-assets logs above) and restart.`,
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.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
  });
@@ -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
  * ./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-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