@teamscala/service-boot 0.23.501 → 0.23.503

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 CHANGED
@@ -1130,5 +1130,5 @@
1130
1130
  "repository": "https://github.com/teamgbg/service-boot",
1131
1131
  "sideEffects": false,
1132
1132
  "type": "module",
1133
- "version": "0.23.501"
1133
+ "version": "0.23.503"
1134
1134
  }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { toCapabilitySlug } from "./capability-slug.ts";
3
+
4
+ describe("toCapabilitySlug", () => {
5
+ test("strips the language+tier suffix from a dev deployable", () => {
6
+ expect(toCapabilitySlug("scala-projects-bun-dev")).toBe("scala-projects");
7
+ expect(toCapabilitySlug("scala-ai-bun-dev")).toBe("scala-ai");
8
+ expect(toCapabilitySlug("scala-image-optimizer-rust-dev")).toBe(
9
+ "scala-image-optimizer",
10
+ );
11
+ });
12
+
13
+ test("strips the language+tier suffix from a prod deployable", () => {
14
+ expect(toCapabilitySlug("scala-projects-bun-prod")).toBe("scala-projects");
15
+ expect(toCapabilitySlug("scala-messaging-service-rust-prod")).toBe(
16
+ "scala-messaging-service",
17
+ );
18
+ });
19
+
20
+ test("returns an already-capability slug unchanged", () => {
21
+ // The prod lane is still bare while the tier migration is in flight, so
22
+ // the SAME function must be correct before and after the rename lands.
23
+ expect(toCapabilitySlug("scala-projects")).toBe("scala-projects");
24
+ expect(toCapabilitySlug("scala-otp")).toBe("scala-otp");
25
+ });
26
+
27
+ test("both lanes of one application normalize to the same identity", () => {
28
+ // This is the property the page_route join depends on.
29
+ expect(toCapabilitySlug("scala-projects-bun-dev")).toBe(
30
+ toCapabilitySlug("scala-projects"),
31
+ );
32
+ expect(toCapabilitySlug("scala-quiz-bun-dev")).toBe(
33
+ toCapabilitySlug("scala-quiz-bun-prod"),
34
+ );
35
+ });
36
+
37
+ test("leaves a name whose tail is not the closed grammar alone", () => {
38
+ // Not a language segment.
39
+ expect(toCapabilitySlug("scala-node-dev")).toBe("scala-node-dev");
40
+ // Not a tier segment.
41
+ expect(toCapabilitySlug("scala-bun-staging")).toBe("scala-bun-staging");
42
+ // Too short to carry both segments.
43
+ expect(toCapabilitySlug("bun-dev")).toBe("bun-dev");
44
+ expect(toCapabilitySlug("supavisor")).toBe("supavisor");
45
+ });
46
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Deployable slug -> capability slug.
7
+ *
8
+ * A DEPLOYABLE is named `<capability>-<language>-<tier>`
9
+ * (`deployables-carry-language-and-tier`): `scala-projects-bun-dev` and
10
+ * `scala-projects-bun-prod` are two DEPLOYMENTS of one APPLICATION. The
11
+ * ecosystem generator emits that name into `SERVICE_SLUG` per lane, so one
12
+ * service arrives here under a DIFFERENT identity in each lane.
13
+ *
14
+ * A `page_route` row describes the APPLICATION url space, which is the same
15
+ * in both lanes, so it carries the CAPABILITY name and there is exactly one
16
+ * row per route rather than one per lane. Joining the two by raw string
17
+ * equality therefore matches at most one lane, and does so SILENTLY:
18
+ * measured 2026-09-11, 8 of 10 dev UI services registered ZERO page routes
19
+ * and served 404 on every path -- including `/sign-in` -- while booting
20
+ * clean and reporting healthy, because the dev lane ran as
21
+ * `scala-projects-bun-dev` while every page_route row said `scala-projects`.
22
+ *
23
+ * Both sides normalize through this one function, so the join is
24
+ * tier-agnostic by construction and a row may name either form.
25
+ *
26
+ * The suffix grammar is a CLOSED SET defined by doctrine and emitted by the
27
+ * generator, so this is a parse of a documented contract rather than a
28
+ * pattern heuristic. A slug that does not end in one is already a capability
29
+ * name and is returned unchanged -- which is why this is correct both BEFORE
30
+ * the tier migration completes (prod is still bare `scala-projects`) and
31
+ * after it (prod becomes `scala-projects-bun-prod`).
32
+ */
33
+
34
+ /** Language segment of a deployable name. */
35
+ const LANGUAGES = ["bun", "rust"] as const;
36
+
37
+ /** Tier segment of a deployable name. */
38
+ const TIERS = ["dev", "prod"] as const;
39
+
40
+ type Language = (typeof LANGUAGES)[number];
41
+ type Tier = (typeof TIERS)[number];
42
+
43
+ /**
44
+ * The capability half of a deployable slug.
45
+ *
46
+ * `scala-projects-bun-dev` -> `scala-projects`
47
+ * `scala-projects-bun-prod` -> `scala-projects`
48
+ * `scala-projects` -> `scala-projects` (already a capability name)
49
+ */
50
+ export function toCapabilitySlug(slug: string): string {
51
+ const parts = slug.split("-");
52
+ if (parts.length < 3) return slug;
53
+
54
+ const tier = parts[parts.length - 1] as Tier;
55
+ const language = parts[parts.length - 2] as Language;
56
+ if (!TIERS.includes(tier) || !LANGUAGES.includes(language)) return slug;
57
+
58
+ return parts.slice(0, -2).join("-");
59
+ }
@@ -14,6 +14,7 @@
14
14
  * `page_route` rows (all 9 services carry empty arrays).
15
15
  */
16
16
 
17
+ import { BootFatalError } from "@teamscala/os/errors/boot-error";
17
18
  import type { Hono } from "hono";
18
19
  import type { ServiceInstallConfig } from "./load-service-install-row.ts";
19
20
  import type { RenderModeLiteral } from "./page-route-shell-types.ts";
@@ -58,5 +59,19 @@ export async function registerPageRoutes(
58
59
  portalScoped,
59
60
  });
60
61
 
62
+ // A kind:ui SERVICE THAT RESOLVES NO PAGES IS AIMED AT NOTHING, and until
63
+ // now that state booted clean: zero matched rows was treated as a valid
64
+ // shape, so the service reported healthy and answered 404 on every path,
65
+ // /sign-in included. Measured 2026-09-11: 8 of 10 dev UI services sat in
66
+ // exactly this state and nothing anywhere said so
67
+ // (`a-component-may-not-report-a-state-it-has-not-verified`).
68
+ if ((config as { kind?: string }).kind === "ui" && registryPatterns.length === 0) {
69
+ throw new BootFatalError(
70
+ "ui-service.no-page-routes",
71
+ serviceSlug,
72
+ `No active page_route row resolves to this service. A kind:ui service must serve at least one page. Check that a page_route row names this capability (rows are matched on the capability half of the deployable slug -- see capability-slug.ts).`,
73
+ );
74
+ }
75
+
61
76
  return registryPatterns;
62
77
  }
@@ -23,6 +23,7 @@
23
23
 
24
24
  import { join } from "node:path";
25
25
  import { getAppLogger } from "@teamscala/logger/app-loggers";
26
+ import { toCapabilitySlug } from "./capability-slug.ts";
26
27
 
27
28
  export async function registerPageServerClient(
28
29
  serviceSlug: string,
@@ -71,9 +72,20 @@ export async function registerPageServerClient(
71
72
  );
72
73
  // serviceSlug is runtime-dynamic (the service being booted), so it
73
74
  // cannot satisfy the generated ServiceSchemaKey union statically.
75
+ //
76
+ // LOOK IT UP BY CAPABILITY, NOT BY DEPLOYABLE NAME. A service_schema
77
+ // row describes the APPLICATION's data surface, so there is ONE row
78
+ // for both lanes; the dev lane arrives here as
79
+ // `scala-projects-bun-dev` and matched nothing. The catch below then
80
+ // read "declares nothing" and the refusal fifteen lines down never
81
+ // fired — so a router-less client registered as healthy and every
82
+ // data-backed page 500ed with MissingLoaderModelCapabilityError,
83
+ // which is precisely the outage this refusal exists to prevent
84
+ // (measured 2026-09-11 on scala-projects-bun-dev: "0 declared
85
+ // model(s)" against a row declaring 31). See ./capability-slug.ts.
74
86
  const row = (await loadCfg(
75
87
  "service_schema",
76
- serviceSlug as never,
88
+ toCapabilitySlug(serviceSlug) as never,
77
89
  undefined as never,
78
90
  )) as
79
91
  | { models?: string[] }
@@ -31,6 +31,7 @@ import {
31
31
  import { getAppLogger } from "@teamscala/logger/app-loggers";
32
32
  import type { Hono } from "hono";
33
33
  import { bindPageHandler } from "./bind-page-handler.ts";
34
+ import { selectServiceRoutes } from "./select-service-routes.ts";
34
35
  import { resolveRequestGraph } from "./register-registry-page-route/resolve-request-graph.ts";
35
36
  import { stampSessionContext } from "./register-registry-page-route/stamp-session-context.ts";
36
37
  import { runContentGatesForPage } from "./register-registry-page-route/run-content-gates.ts";
@@ -134,29 +135,9 @@ export async function registerRegistryPageRoute(
134
135
  "page_route",
135
136
  PageRouteConfigSchema,
136
137
  );
137
- const serviceRoutes = allRoutes.filter(
138
- (r) => r.config.service === serviceSlug,
139
- );
138
+ const serviceRoutes = selectServiceRoutes(allRoutes, serviceSlug);
140
139
  if (serviceRoutes.length === 0) return [];
141
140
 
142
- // Sort routes so more specific patterns register before wildcard/param routes.
143
- // Hono matches by registration order, so `/*` (the portal catchall) registered
144
- // before `/sign-in` would swallow the sign-in page and render it inside the
145
- // PortalTemplate instead of the standalone PublicTemplate shell.
146
- serviceRoutes.sort((a, b) => {
147
- const pa = a.config.pattern;
148
- const pb = b.config.pattern;
149
- // Wildcard `/*` goes last
150
- if (pa === "/*") return 1;
151
- if (pb === "/*") return -1;
152
- // Param routes `/:param` sort after literal routes
153
- const aIsLiteral = !pa.includes(":") && !pa.includes("*");
154
- const bIsLiteral = !pb.includes(":") && !pb.includes("*");
155
- if (aIsLiteral && !bIsLiteral) return -1;
156
- if (!aIsLiteral && bIsLiteral) return 1;
157
- return 0;
158
- });
159
-
160
141
  const registeredPatterns: string[] = [];
161
142
 
162
143
  for (const routeRow of serviceRoutes) {
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { selectServiceRoutes } from "./select-service-routes.ts";
3
+
4
+ const row = (service: string, pattern: string) => ({ config: { service, pattern } });
5
+
6
+ const PROJECTS = [
7
+ row("scala-projects", "/:orgId"),
8
+ row("scala-projects", "/:orgId/kanban"),
9
+ row("scala-projects", "/sign-in"),
10
+ row("scala-portal", "/*"),
11
+ row("scala-quiz", "/"),
12
+ ];
13
+
14
+ describe("selectServiceRoutes", () => {
15
+ test("a dev lane resolves the rows that name the capability", () => {
16
+ // The regression this module exists for: before the capability join, a
17
+ // dev lane matched ZERO rows and served 404 on every path.
18
+ const got = selectServiceRoutes(PROJECTS, "scala-projects-bun-dev");
19
+ expect(got.map((r) => r.config.pattern).sort()).toEqual([
20
+ "/:orgId",
21
+ "/:orgId/kanban",
22
+ "/sign-in",
23
+ ]);
24
+ });
25
+
26
+ test("both lanes of one application resolve the same set", () => {
27
+ const dev = selectServiceRoutes(PROJECTS, "scala-projects-bun-dev");
28
+ const prodBare = selectServiceRoutes(PROJECTS, "scala-projects");
29
+ const prodTiered = selectServiceRoutes(PROJECTS, "scala-projects-bun-prod");
30
+ expect(dev).toEqual(prodBare);
31
+ expect(dev).toEqual(prodTiered);
32
+ });
33
+
34
+ test("a row naming a deployable form still matches its lane", () => {
35
+ const rows = [row("scala-roadmap-bun-dev", "/")];
36
+ expect(selectServiceRoutes(rows, "scala-roadmap")).toHaveLength(1);
37
+ });
38
+
39
+ test("never returns another service's rows", () => {
40
+ const got = selectServiceRoutes(PROJECTS, "scala-projects-bun-dev");
41
+ expect(got.every((r) => r.config.service === "scala-projects")).toBe(true);
42
+ });
43
+
44
+ test("orders literal before param before wildcard", () => {
45
+ const rows = [
46
+ row("scala-portal", "/*"),
47
+ row("scala-portal", "/:orgId"),
48
+ row("scala-portal", "/sign-in"),
49
+ ];
50
+ expect(
51
+ selectServiceRoutes(rows, "scala-portal").map((r) => r.config.pattern),
52
+ ).toEqual(["/sign-in", "/:orgId", "/*"]);
53
+ });
54
+
55
+ test("an unknown service resolves nothing", () => {
56
+ expect(selectServiceRoutes(PROJECTS, "scala-nonexistent-bun-dev")).toEqual([]);
57
+ });
58
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Selecting and ordering the `page_route` rows one service serves.
7
+ *
8
+ * Two decisions live here, both about WHICH rows and IN WHAT ORDER — never
9
+ * about what a route does once matched:
10
+ *
11
+ * 1. SELECTION joins on the CAPABILITY, never on the deployable name. A
12
+ * service arrives under its LANE identity (`scala-projects-bun-dev` in
13
+ * dev, `scala-projects` in prod), while a page_route row names the
14
+ * APPLICATION and there is ONE row for both lanes. Raw string equality
15
+ * therefore matched at most one lane, silently — see `./capability-slug.ts`
16
+ * for the measured incident.
17
+ *
18
+ * 2. ORDERING puts specific patterns before parameterised and wildcard ones,
19
+ * because Hono matches in REGISTRATION order.
20
+ */
21
+
22
+ import { toCapabilitySlug } from "./capability-slug.ts";
23
+
24
+ /** The shape this module needs from a loaded page_route row. */
25
+ export interface SelectableRoute {
26
+ config: { pattern: string; service: string };
27
+ }
28
+
29
+ /**
30
+ * Specificity rank for registration order. Hono matches by registration
31
+ * order, so `/*` (the portal catchall) registered before `/sign-in` would
32
+ * swallow the sign-in page and render it inside the PortalTemplate instead
33
+ * of the standalone PublicTemplate shell.
34
+ */
35
+ function comparePatternSpecificity(a: string, b: string): number {
36
+ // Wildcard `/*` goes last.
37
+ if (a === "/*") return 1;
38
+ if (b === "/*") return -1;
39
+ // Param routes `/:param` sort after literal routes.
40
+ const aIsLiteral = !a.includes(":") && !a.includes("*");
41
+ const bIsLiteral = !b.includes(":") && !b.includes("*");
42
+ if (aIsLiteral && !bIsLiteral) return -1;
43
+ if (!aIsLiteral && bIsLiteral) return 1;
44
+ return 0;
45
+ }
46
+
47
+ /**
48
+ * The page_route rows this service serves, in registration order.
49
+ *
50
+ * `serviceSlug` is the deployable identity of the calling lane; both sides
51
+ * of the join normalize through `toCapabilitySlug`, so a row may name either
52
+ * the capability or a deployable form and both lanes resolve the same set.
53
+ */
54
+ export function selectServiceRoutes<T extends SelectableRoute>(
55
+ allRoutes: readonly T[],
56
+ serviceSlug: string,
57
+ ): T[] {
58
+ const capability = toCapabilitySlug(serviceSlug);
59
+ const serviceRoutes = allRoutes.filter(
60
+ (r) => toCapabilitySlug(r.config.service) === capability,
61
+ );
62
+ serviceRoutes.sort((a, b) =>
63
+ comparePatternSpecificity(a.config.pattern, b.config.pattern),
64
+ );
65
+ return serviceRoutes;
66
+ }