@teamscala/service-boot 0.23.514 → 0.23.516

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
@@ -6,32 +6,32 @@
6
6
  },
7
7
  "dependencies": {
8
8
  "@orpc/server": "1.15.1",
9
- "@teamscala/agreement-generation": "1.0.17",
10
- "@teamscala/assessment": "0.0.22",
11
- "@teamscala/auth": "1.102.11",
12
- "@teamscala/cache": "1.0.79",
13
- "@teamscala/chat-completion": "0.0.469",
14
- "@teamscala/db": "1.21.43752",
15
- "@teamscala/db-validation": "0.1.344",
16
- "@teamscala/document-signing": "1.0.5",
17
- "@teamscala/email-client": "0.1.4",
18
- "@teamscala/encryption": "0.3.98",
19
- "@teamscala/event-log": "0.1.27",
20
- "@teamscala/file-lock": "0.0.2",
21
- "@teamscala/formatters": "0.2.95",
22
- "@teamscala/generic-registry": "1.0.12",
23
- "@teamscala/hono-boundary": "0.0.1",
24
- "@teamscala/http": "2.1.105",
25
- "@teamscala/logger": "1.1.203",
26
- "@teamscala/mcp-multi-session": "1.3.147",
27
- "@teamscala/object-storage": "1.2.201",
28
- "@teamscala/orpc": "2.6.118",
29
- "@teamscala/os": "1.15.264",
30
- "@teamscala/publish-registry": "0.1.3",
31
- "@teamscala/resolver-engine": "1.1.208",
32
- "@teamscala/timing": "1.0.51",
33
- "@teamscala/tool-executor": "4.1.166",
34
- "@teamscala/watchdog": "1.0.53",
9
+ "@teamscala/agreement-generation": "*",
10
+ "@teamscala/assessment": "*",
11
+ "@teamscala/auth": "*",
12
+ "@teamscala/cache": "*",
13
+ "@teamscala/chat-completion": "*",
14
+ "@teamscala/db": "*",
15
+ "@teamscala/db-validation": "*",
16
+ "@teamscala/document-signing": "*",
17
+ "@teamscala/email-client": "*",
18
+ "@teamscala/encryption": "*",
19
+ "@teamscala/event-log": "*",
20
+ "@teamscala/file-lock": "*",
21
+ "@teamscala/formatters": "*",
22
+ "@teamscala/generic-registry": "*",
23
+ "@teamscala/hono-boundary": "*",
24
+ "@teamscala/http": "*",
25
+ "@teamscala/logger": "*",
26
+ "@teamscala/mcp-multi-session": "*",
27
+ "@teamscala/object-storage": "*",
28
+ "@teamscala/orpc": "*",
29
+ "@teamscala/os": "*",
30
+ "@teamscala/publish-registry": "*",
31
+ "@teamscala/resolver-engine": "*",
32
+ "@teamscala/timing": "*",
33
+ "@teamscala/tool-executor": "*",
34
+ "@teamscala/watchdog": "*",
35
35
  "hono": "4.13.8",
36
36
  "pg-boss": "12.33.1",
37
37
  "tailwindcss": "4.3.3",
@@ -1187,5 +1187,5 @@
1187
1187
  "repository": "https://github.com/teamgbg/service-boot",
1188
1188
  "sideEffects": false,
1189
1189
  "type": "module",
1190
- "version": "0.23.514"
1190
+ "version": "0.23.516"
1191
1191
  }
@@ -1,3 +1,11 @@
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
1
9
  import { describe, expect, test } from "bun:test";
2
10
  import { toCapabilitySlug } from "./capability-slug.ts";
3
11
 
@@ -0,0 +1,185 @@
1
+ // @system ui-service-boot
2
+ // @status handwritten
3
+ // @edit edit directly
4
+ //
5
+ // register-page-server-client.test.ts — the declaredModels lookup must never
6
+ // quietly report "declares nothing".
7
+ //
8
+ // The regression this exists for (measured scala-sign 2026-09-24/25): the boot
9
+ // looked up service_schema with `undefined as never`, so loadRegistryConfig's
10
+ // `v.parse(undefined, raw)` threw a TypeError on EVERY service, the bare
11
+ // `catch {}` swallowed it into `declaredModels = []`, and the
12
+ // "declares > 0 models + no router → refuse the boot" check below never fired.
13
+ // A service declaring 18 models logged "0 declared model(s)", registered a
14
+ // router-less server client as healthy, and served 500 on every page while
15
+ // /health answered 200.
16
+ //
17
+ // Three assertions, each the operator's demand ("the catch must tell 'no
18
+ // service_schema row' apart from 'the read failed'. A read error must never
19
+ // quietly become 'no models'. Test both"):
20
+ // 1. a service that DECLARES models with no generated router → REFUSES the
21
+ // boot (proves the refusal actually fires now — it did not before);
22
+ // 2. a service with NO service_schema row → passes through clean (legitimate
23
+ // daemon/portal shell owns no DB models);
24
+ // 3. a service_schema READ that FAILS → REFUSES the boot naming the error
25
+ // (a read error is never treated as "declares nothing").
26
+
27
+ import { afterEach, describe, expect, mock, test } from "bun:test";
28
+ import { mkdtemp, rm } from "node:fs/promises";
29
+ import { tmpdir } from "node:os";
30
+ import { join } from "node:path";
31
+
32
+ const originalCwd = process.cwd();
33
+ const tempDirs: string[] = [];
34
+
35
+ afterEach(async () => {
36
+ process.chdir(originalCwd);
37
+ for (const directory of tempDirs.splice(0)) {
38
+ await rm(directory, { recursive: true, force: true });
39
+ }
40
+ });
41
+
42
+ // The registry read is scenario-driven: ONE load-config mock serves all three
43
+ // tests, and each test flips the mode before calling the function. The mock
44
+ // function is re-invoked on every call (the target imports it dynamically
45
+ // inside registerPageServerClient), so switching the mode takes effect.
46
+ let registryMode:
47
+ | { kind: "row"; models: string[] }
48
+ | { kind: "missing" }
49
+ | { kind: "missing-thrown" }
50
+ | { kind: "read-failed"; message: string } = { kind: "missing" };
51
+
52
+ // The class identity the catch's `instanceof` compares against. The target
53
+ // imports RegistryConfigMissingError from @teamscala/db/registry/errors, so the
54
+ // SAME mocked class must back both the throw path and the instanceof check.
55
+ class MockRegistryConfigMissingError extends Error {
56
+ readonly type: string;
57
+ readonly slug: string;
58
+ constructor(type: string, slug: string) {
59
+ super(`[registry] Config entry "${slug}" of type "${type}" not found`);
60
+ this.name = "RegistryConfigMissingError";
61
+ this.type = type;
62
+ this.slug = slug;
63
+ }
64
+ }
65
+
66
+ mock.module("@teamscala/db/registry/load-config", () => ({
67
+ loadOptionalRegistryConfig: async () => {
68
+ if (registryMode.kind === "missing") return null;
69
+ if (registryMode.kind === "missing-thrown") {
70
+ throw new MockRegistryConfigMissingError("service_schema", "scala-sign");
71
+ }
72
+ if (registryMode.kind === "row") return { models: registryMode.models };
73
+ throw new Error(registryMode.message);
74
+ },
75
+ loadRegistryConfig: async () => ({ platformOrganisationId: "org-platform" }),
76
+ }));
77
+
78
+ mock.module("@teamscala/db/registry/errors", () => ({
79
+ RegistryConfigMissingError: MockRegistryConfigMissingError,
80
+ }));
81
+
82
+ mock.module("@teamscala/db-validation/registry-schemas/service-schema", () => ({
83
+ ServiceSchemaConfigSchema: {},
84
+ }));
85
+
86
+ mock.module("@teamscala/db-validation/registry-schemas/platform-identity", () => ({
87
+ PlatformIdentitySchema: {},
88
+ }));
89
+
90
+ mock.module("@teamscala/logger/app-loggers", () => ({
91
+ getAppLogger: () => ({
92
+ info: (..._args: unknown[]) => {},
93
+ warn: (..._args: unknown[]) => {},
94
+ error: (..._args: unknown[]) => {},
95
+ }),
96
+ }));
97
+
98
+ mock.module("@orpc/server", () => ({
99
+ createRouterClient: () => ({}),
100
+ }));
101
+
102
+ mock.module("@teamscala/db/prisma-registry", () => ({
103
+ getPrisma: () => ({}),
104
+ }));
105
+
106
+ // The page-server server-client is imported dynamically via a string-cast
107
+ // specifier inside the function; mock the exact specifier so setServerClient
108
+ // is a recording no-op and the real runtime never loads.
109
+ const setServerClientCalls: number[] = [];
110
+ mock.module("@teamscala/service-boot/page-server-orpc/server-client", () => ({
111
+ setServerClient: () => {
112
+ setServerClientCalls.push(1);
113
+ },
114
+ }));
115
+
116
+ const { registerPageServerClient } = await import(
117
+ "./register-page-server-client.ts"
118
+ );
119
+
120
+ /** Make an empty service dir with NO generated router anywhere, and reset the
121
+ * setServerClient recorder so each test's count is its own. */
122
+ async function cwdWithoutRouter(): Promise<string> {
123
+ setServerClientCalls.length = 0;
124
+ const dir = await mkdtemp(join(tmpdir(), "register-psc-test-"));
125
+ tempDirs.push(dir);
126
+ process.chdir(dir);
127
+ return dir;
128
+ }
129
+
130
+ describe("registerPageServerClient — declaredModels lookup never lies", () => {
131
+ test("refuses the boot when the service DECLARES models but no router exists", async () => {
132
+ await cwdWithoutRouter();
133
+ registryMode = {
134
+ kind: "row",
135
+ // 18 models — the exact scala-sign row size whose absence from the
136
+ // boot log was the outage.
137
+ models: Array.from({ length: 18 }, (_unused, index) => `model_${index}`),
138
+ };
139
+
140
+ await expect(registerPageServerClient("scala-sign")).rejects.toThrow(
141
+ /service_schema declares 18 model\(s\).*no generated ORPC router is present/s,
142
+ );
143
+ expect(setServerClientCalls.length).toBe(0);
144
+ });
145
+
146
+ test("passes through clean when there is NO service_schema row", async () => {
147
+ await cwdWithoutRouter();
148
+ // loadOptionalRegistryConfig returns null — the legitimately-declares-
149
+ // nothing case (daemon/portal shell). With no models declared there is no
150
+ // router required, so the boot must NOT refuse.
151
+ registryMode = { kind: "missing" };
152
+
153
+ await expect(registerPageServerClient("some-daemon-shell")).resolves.toBeUndefined();
154
+ expect(setServerClientCalls.length).toBeGreaterThan(0);
155
+ });
156
+
157
+ test("refuses the boot when the service_schema READ fails, naming the error", async () => {
158
+ await cwdWithoutRouter();
159
+ // A read error (NOT a missing row) must fail the boot, never degrade to
160
+ // "declares nothing" — otherwise a data-backed service boots "0 models"
161
+ // and 500s on every page.
162
+ registryMode = { kind: "read-failed", message: "connection refused: db down" };
163
+
164
+ await expect(registerPageServerClient("scala-sign")).rejects.toThrow(
165
+ /service_schema read FAILED for "scala-sign"/,
166
+ );
167
+ await expect(registerPageServerClient("scala-sign")).rejects.toThrow(
168
+ /connection refused: db down/,
169
+ );
170
+ expect(setServerClientCalls.length).toBe(0);
171
+ });
172
+
173
+ test("treats a thrown RegistryConfigMissingError as declares-nothing, not failure", async () => {
174
+ await cwdWithoutRouter();
175
+ // The read path throws RegistryConfigMissingError (a genuinely-absent row,
176
+ // the same signal as a null return, just a different code path): the catch
177
+ // must classify it as "declares nothing" via the instanceof check and boot
178
+ // CLEAN — this is exactly the branch that must NOT fail the boot. Contrast
179
+ // the read-failed test above, where an unrelated error DOES fail the boot.
180
+ registryMode = { kind: "missing-thrown" };
181
+
182
+ await expect(registerPageServerClient("scala-sign")).resolves.toBeUndefined();
183
+ expect(setServerClientCalls.length).toBeGreaterThan(0);
184
+ });
185
+ });
@@ -65,37 +65,58 @@ export async function registerPageServerClient(
65
65
  );
66
66
  }
67
67
  }
68
- const declaredModels = await (async () => {
68
+ const declaredModels = await (async (): Promise<string[]> => {
69
+ const capSlug = toCapabilitySlug(serviceSlug);
70
+ // LOOK IT UP BY CAPABILITY, NOT BY DEPLOYABLE NAME. A service_schema
71
+ // row describes the APPLICATION's data surface, so there is ONE row
72
+ // for both lanes; the dev lane arrives here as
73
+ // `scala-projects-bun-dev`. Both sides normalize through
74
+ // toCapabilitySlug, so the join is tier-agnostic by construction.
75
+ // See ./capability-slug.ts.
76
+ //
77
+ // READ WITH THE REAL SCHEMA, NOT `undefined`. The previous call passed
78
+ // `undefined as never`, so v.parse(undefined, raw) threw a TypeError
79
+ // (schema is not a schema) on EVERY service, the bare catch below turned
80
+ // that into "declares nothing", and the refusal at the bottom NEVER
81
+ // fired — a service declaring 18 models booted "0 declared model(s)"
82
+ // and served 500s. (measured scala-sign 2026-09-24/25: row declares 18,
83
+ // boot logged 0.) loadOptionalRegistryConfig parses against
84
+ // ServiceSchemaConfigSchema and returns null ONLY for a missing row.
85
+ //
86
+ // A MISSING ROW is legitimately "declares nothing" (daemon/portal
87
+ // shells own no DB models) and passes through clean. A READ FAILURE is
88
+ // NOT "declares nothing" — it fails the boot naming the error, so the
89
+ // database can never quietly become "no models" and let a router-less,
90
+ // data-backed service boot and serve 500s
91
+ // (`a-component-may-not-report-a-state-it-has-not-verified`).
92
+ const { loadOptionalRegistryConfig } = await import(
93
+ "@teamscala/db/registry/load-config"
94
+ );
95
+ const { RegistryConfigMissingError } = await import(
96
+ "@teamscala/db/registry/errors"
97
+ );
98
+ const { ServiceSchemaConfigSchema } = await import(
99
+ "@teamscala/db-validation/registry-schemas/service-schema"
100
+ );
69
101
  try {
70
- const { loadRegistryConfig: loadCfg } = await import(
71
- "@teamscala/db/registry/load-config"
72
- );
73
- // serviceSlug is runtime-dynamic (the service being booted), so it
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.
86
- const row = (await loadCfg(
102
+ const row = await loadOptionalRegistryConfig(
87
103
  "service_schema",
88
- toCapabilitySlug(serviceSlug) as never,
89
- undefined as never,
90
- )) as
91
- | { models?: string[] }
92
- | null;
93
- return Array.isArray(row?.models) ? row.models : [];
94
- } catch {
95
- // No service_schema row, or it could not be read: fall back to
96
- // "declares nothing", so a read failure cannot manufacture a boot
97
- // refusal on a service that never needed a router.
98
- return [] as string[];
104
+ capSlug as never,
105
+ ServiceSchemaConfigSchema,
106
+ );
107
+ if (row === null) return [];
108
+ return Array.isArray(row.models) ? row.models : [];
109
+ } catch (err) {
110
+ if (err instanceof RegistryConfigMissingError) return [];
111
+ throw new Error(
112
+ `[scala-install:${serviceSlug}] service_schema read FAILED for ` +
113
+ `"${capSlug}" — this is NOT treated as "declares nothing". A ` +
114
+ `registry read error must never quietly become an empty model ` +
115
+ `set, because a data-backed service with no generated ORPC ` +
116
+ `router would otherwise boot healthy and 500 on every page. ` +
117
+ `Underlying error: ${err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err)}`,
118
+ { cause: err },
119
+ );
99
120
  }
100
121
  })();
101
122
  if (declaredModels.length > 0 && !appRouter) {
@@ -104,10 +125,10 @@ export async function registerPageServerClient(
104
125
  `model(s) (${declaredModels.slice(0, 5).join(", ")}${declaredModels.length > 5 ? ", …" : ""}) ` +
105
126
  `but no generated ORPC router is present at ${routerPath}. Every data-backed page ` +
106
127
  `would 500 with MissingLoaderModelCapabilityError while /health reported 200. ` +
107
- `This is a BUILD fault: deploy-build did not emit prisma/generated/orpc. Check that ` +
108
- `the service's railpack.json runs deploy-build with DATABASE_URL_FROM_OUTSIDE ` +
109
- `(the builder cannot resolve *.railway.internal) and that its codegen_output row ` +
110
- `for railpack-json carries the composite input the transform declares.`,
128
+ `This is a BUILD fault: the codegen_output emission for the ORPC router ` +
129
+ `(prisma/generated/orpc/routers/index.ts) is missing from the bundle — check that ` +
130
+ `the service's codegen_output rows are active and their outputs land in the image. ` +
131
+ `Rebuild via scala-pack; never patch the image by hand.`,
111
132
  );
112
133
  }
113
134
 
@@ -278,6 +278,13 @@ export async function registerRegistryPageRoute(
278
278
  themeOverride,
279
279
  orgTitlePrefix,
280
280
  );
281
+ // The matched route params (extractUrlParams + host/path) ride the
282
+ // per-request pageData so the SSR resolve ctx can scope PUBLIC
283
+ // block data per request — data_source loaders read ctx.pathParams
284
+ // (query-data-source); without them a public table would return
285
+ // every person's rows. Attached AFTER the graph cache (per-request,
286
+ // never cached with the graph).
287
+ (pageData as Record<string, unknown>).pathParams = params;
281
288
  const panelSlug = new URL(request.url).searchParams.get("panel");
282
289
  await attachPanelPage(pageData as Record<string, unknown>, panelSlug);
283
290
  if (