@teamscala/service-boot 0.23.383 → 0.23.384

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,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.383",
9
+ "version": "0.23.384",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -922,14 +922,6 @@
922
922
  "types": "./src/server-error-page.d.ts",
923
923
  "default": "./src/server-error-page.ts"
924
924
  },
925
- "./start-scheduled-jobs": {
926
- "types": "./src/start-scheduled-jobs.d.ts",
927
- "default": "./src/start-scheduled-jobs.ts"
928
- },
929
- "./start-scheduled-jobs.ts": {
930
- "types": "./src/start-scheduled-jobs.d.ts",
931
- "default": "./src/start-scheduled-jobs.ts"
932
- },
933
925
  "./two-phase-boot": {
934
926
  "types": "./src/two-phase-boot.d.ts",
935
927
  "default": "./src/two-phase-boot.ts"
@@ -147,7 +147,7 @@ export async function mountServerPackages(
147
147
 
148
148
  log.info(
149
149
  `[scala-install:${serviceSlug}] Registered ${mountRegistry.handlers.size} handler(s), ` +
150
- `${mountRegistry.wsRoutes.size} ws route(s), ${mountRegistry.scheduledJobs.size} scheduled job(s), ` +
150
+ `${mountRegistry.wsRoutes.size} ws route(s), ` +
151
151
  `${mountRegistry.bootHooks.size} boot hook(s)`,
152
152
  );
153
153
 
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @system page-server
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The empty-router substitute that hid a total outage.
7
+ *
8
+ * MEASURED 2026-08-20: scala-quiz and scala-forms served 500s on every
9
+ * data-backed page for hours. /health returned 200 and boot logged "ORPC
10
+ * serverClient registered", because an unregistered read was answered with
11
+ * `createRouterClient({}, …)` — a client that looks registered and carries no
12
+ * models. The fault only surfaced later and elsewhere, as
13
+ * MissingLoaderModelCapabilityError during page render.
14
+ *
15
+ * These pin the contract: a missing client is an error at the point of the
16
+ * fault, never a fabricated stand-in.
17
+ */
18
+
19
+ import { afterEach, describe, expect, test } from "bun:test";
20
+ import {
21
+ getServerClient,
22
+ isServerClientRegistered,
23
+ setServerClient,
24
+ } from "./server-client.ts";
25
+
26
+ afterEach(() => {
27
+ setServerClient(null as never);
28
+ });
29
+
30
+ describe("page-server server client", () => {
31
+ test("throws rather than substituting an empty router when unregistered", () => {
32
+ setServerClient(null as never);
33
+ expect(() => getServerClient()).toThrow(
34
+ /no ORPC server client registered/,
35
+ );
36
+ });
37
+
38
+ test("the failure names the boot step responsible", () => {
39
+ setServerClient(null as never);
40
+ expect(() => getServerClient()).toThrow(/registerPageServerClient/);
41
+ });
42
+
43
+ test("never returns a model-less object that reads as healthy", () => {
44
+ // The whole defect in one assertion: the old fallback returned a truthy
45
+ // client, so callers could not tell registration had never happened.
46
+ setServerClient(null as never);
47
+ let returned: unknown = "sentinel";
48
+ try {
49
+ returned = getServerClient();
50
+ } catch {
51
+ returned = "threw";
52
+ }
53
+ expect(returned).toBe("threw");
54
+ });
55
+
56
+ test("returns the registered client once boot sets one", () => {
57
+ const client = { assessments: { findFirst: async () => null } };
58
+ setServerClient(client as never);
59
+ expect(getServerClient()).toBe(client as never);
60
+ });
61
+
62
+ test("isServerClientRegistered tracks registration state", () => {
63
+ setServerClient(null as never);
64
+ expect(isServerClientRegistered()).toBe(false);
65
+ setServerClient({ x: 1 } as never);
66
+ expect(isServerClientRegistered()).toBe(true);
67
+ });
68
+ });
@@ -10,20 +10,46 @@
10
10
  * at boot, after createRouterClient(appRouter, { context: { prisma } }).
11
11
  * Then page-server's createPageHandler reads `getServerClient()` per request
12
12
  * — no HTTP loopback, just a typed function call.
13
+ *
14
+ * NO EMPTY-ROUTER SUBSTITUTE. This used to answer an unregistered read by
15
+ * fabricating `createRouterClient({}, …)` — a client with no models on it. That
16
+ * turned "boot never registered a client" into "every model lookup returns
17
+ * undefined", which surfaces far away as MissingLoaderModelCapabilityError on
18
+ * page render rather than as a boot failure. Measured 2026-08-20: quiz and forms
19
+ * served 500s on every data-backed page for hours while /health returned 200 and
20
+ * boot logged "ORPC serverClient registered", because the empty router is
21
+ * indistinguishable from a real one until something asks it for a model
22
+ * (`a-component-may-not-report-a-state-it-has-not-verified`).
23
+ *
24
+ * A missing client is now a thrown error naming the boot step that should have
25
+ * set it, so the failure is reported by the component that has the fault.
13
26
  */
14
27
 
15
- import { createRouterClient, os } from "@orpc/server";
16
-
17
- const emptyRouter = {};
28
+ import { createRouterClient } from "@orpc/server";
18
29
 
19
30
  let serverClient: ReturnType<typeof createRouterClient> | null = null;
20
31
 
32
+ /**
33
+ * The registered server client. Throws if boot never registered one — never
34
+ * substitutes an empty router, see the file header.
35
+ */
21
36
  export function getServerClient(): ReturnType<typeof createRouterClient> {
22
- if (serverClient) return serverClient;
23
- serverClient = createRouterClient(emptyRouter, { context: {} });
37
+ if (!serverClient) {
38
+ throw new Error(
39
+ "[page-server] no ORPC server client registered — registerPageServerClient() " +
40
+ "did not run or failed during boot. A page-server read cannot be served " +
41
+ "without it; the previous empty-router fallback hid this as a per-model " +
42
+ "'does not expose findFirst' error at render time.",
43
+ );
44
+ }
24
45
  return serverClient;
25
46
  }
26
47
 
48
+ /** True once boot has registered a client. For callers that must degrade rather than throw. */
49
+ export function isServerClientRegistered(): boolean {
50
+ return serverClient !== null;
51
+ }
52
+
27
53
  export function setServerClient(
28
54
  client: ReturnType<typeof createRouterClient>,
29
55
  ): void {
@@ -52,6 +52,60 @@ export async function registerPageServerClient(
52
52
  );
53
53
  }
54
54
  }
55
+ // REFUSE, DO NOT SKIP. `existsSync(routerPath)` being false used to fall
56
+ // straight through with appRouter=null and still register a client + log
57
+ // success. That client carries only the three platform routers below and
58
+ // NO CRUD models, which is indistinguishable from a healthy one until a
59
+ // page asks it for a model — at which point resolve-from-loader-config
60
+ // throws MissingLoaderModelCapabilityError, far from the actual fault.
61
+ //
62
+ // Measured 2026-08-20: scala-quiz and scala-forms served 500s on every
63
+ // data-backed page while /health returned 200 and this line logged "ORPC
64
+ // serverClient registered". The build had produced no generated router
65
+ // (its railpack.json ran registry codegen against an unreachable
66
+ // *.railway.internal host), and nothing anywhere said so.
67
+ //
68
+ // A service whose service_schema DECLARES models and has no generated
69
+ // router is not degraded, it is non-functional — so this fails the boot
70
+ // rather than reporting a state it has not verified
71
+ // (`a-component-may-not-report-a-state-it-has-not-verified`). A service
72
+ // that declares no models (daemon/portal shells owning no DB schema)
73
+ // still passes through cleanly.
74
+ const declaredModels = await (async () => {
75
+ try {
76
+ const { loadRegistryConfig: loadCfg } = await import(
77
+ "@teamscala/db/registry/load-config"
78
+ );
79
+ // serviceSlug is runtime-dynamic (the service being booted), so it
80
+ // cannot satisfy the generated ServiceSchemaKey union statically.
81
+ const row = (await loadCfg(
82
+ "service_schema",
83
+ serviceSlug as never,
84
+ undefined as never,
85
+ )) as
86
+ | { models?: string[] }
87
+ | null;
88
+ return Array.isArray(row?.models) ? row.models : [];
89
+ } catch {
90
+ // No service_schema row, or it could not be read: fall back to
91
+ // "declares nothing", so a read failure cannot manufacture a boot
92
+ // refusal on a service that never needed a router.
93
+ return [] as string[];
94
+ }
95
+ })();
96
+ if (declaredModels.length > 0 && !appRouter) {
97
+ throw new Error(
98
+ `[scala-install:${serviceSlug}] service_schema declares ${declaredModels.length} ` +
99
+ `model(s) (${declaredModels.slice(0, 5).join(", ")}${declaredModels.length > 5 ? ", …" : ""}) ` +
100
+ `but no generated ORPC router is present at ${routerPath}. Every data-backed page ` +
101
+ `would 500 with MissingLoaderModelCapabilityError while /health reported 200. ` +
102
+ `This is a BUILD fault: deploy-build did not emit prisma/generated/orpc. Check that ` +
103
+ `the service's railpack.json runs deploy-build with DATABASE_URL_FROM_OUTSIDE ` +
104
+ `(the builder cannot resolve *.railway.internal) and that its codegen_output row ` +
105
+ `for railpack-json carries the composite input the transform declares.`,
106
+ );
107
+ }
108
+
55
109
  const { puckPagesRouter } = await import(
56
110
  "@teamscala/service-boot/page-server-orpc/puck-pages-router"
57
111
  );
@@ -94,12 +148,19 @@ export async function registerPageServerClient(
94
148
  } as never,
95
149
  );
96
150
  mod.setServerClient(client);
151
+ // State the model count. "registered" alone was true in the outage and told
152
+ // nobody anything; a zero here is now visible in the boot log.
97
153
  log.info(
98
- `[scala-install:${serviceSlug}] ORPC serverClient registered (system:super-admin)`,
154
+ `[scala-install:${serviceSlug}] ORPC serverClient registered (system:super-admin) — ` +
155
+ `${Object.keys(composed).length} router(s), ${declaredModels.length} declared model(s)`,
99
156
  );
100
157
  } catch (err) {
101
- log.warn(
102
- `[scala-install:${serviceSlug}] Failed to load appRouter for serverClient: ${err instanceof Error ? err.message : err}`,
158
+ // RETHROW. Swallowing this into a warn is what let a service boot, pass its
159
+ // healthcheck and serve 500s on every page. The boot must fail so the
160
+ // supervisor and the deploy healthcheck can see it.
161
+ log.error(
162
+ `[scala-install:${serviceSlug}] serverClient registration FAILED — data-backed pages cannot render: ${err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err)}`,
103
163
  );
164
+ throw err;
104
165
  }
105
166
  }
@@ -16,6 +16,7 @@ import {
16
16
  } from "./resolve-from-loader-config";
17
17
  import {
18
18
  getServerClient,
19
+ isServerClientRegistered,
19
20
  setServerClient,
20
21
  } from "./page-server-orpc/server-client";
21
22
 
@@ -52,7 +53,7 @@ describe("interpolateMeta (resolver_input ref contract)", () => {
52
53
 
53
54
  describe("resource-loader model capability", () => {
54
55
  test("an optional source still refuses a model absent from the generated client", async () => {
55
- const original = getServerClient();
56
+ const original = isServerClientRegistered() ? getServerClient() : null;
56
57
  setServerClient({} as never);
57
58
  try {
58
59
  await expect(
@@ -73,12 +74,12 @@ describe("resource-loader model capability", () => {
73
74
  'requires model "builda_pages", but the generated server client does not expose findFirst',
74
75
  );
75
76
  } finally {
76
- setServerClient(original);
77
+ setServerClient(original as never);
77
78
  }
78
79
  });
79
80
 
80
81
  test("an optional source may resolve an absent row when the model capability exists", async () => {
81
- const original = getServerClient();
82
+ const original = isServerClientRegistered() ? getServerClient() : null;
82
83
  setServerClient({
83
84
  builda_pages: { findFirst: async () => null },
84
85
  } as never);
@@ -99,7 +100,7 @@ describe("resource-loader model capability", () => {
99
100
  ),
100
101
  ).resolves.toEqual({ body: null });
101
102
  } finally {
102
- setServerClient(original);
103
+ setServerClient(original as never);
103
104
  }
104
105
  });
105
106
  });
@@ -4,13 +4,13 @@
4
4
  * @edit edit directly
5
5
  *
6
6
  * Phase-2 step isolation: a client-asset build failure must not prevent
7
- * scheduled jobs and pg-boss from mounting.
7
+ * pg-boss from mounting.
8
8
  *
9
9
  * Asserted against the SOURCE rather than by booting a service, because the
10
10
  * behaviour is an ordering/error-handling property of buildFullApp and booting
11
11
  * it here would need a database, a registry, a Vite build and a live pg-boss.
12
12
  * The property is structural: buildClientAssets must be inside a try/catch that
13
- * appears BEFORE the startScheduledJobs and mountPgBoss calls. That is exactly
13
+ * appears BEFORE the mountPgBoss call. That is exactly
14
14
  * what regressed, and a source assertion fails the moment someone re-awaits it
15
15
  * unguarded — which is the regression this locks.
16
16
  */
@@ -55,8 +55,8 @@ describe("phase-2 step isolation", () => {
55
55
 
56
56
  /*
57
57
  * THE LOAD-BEARING ASSERTION. A `catch` must appear BETWEEN the build call and
58
- * the startScheduledJobs call. That is what makes this test fail if the guard
59
- * is removed: unwrap the await and there is no catch in that span, so the
58
+ * the mountPgBoss call. That is what makes this test fail if the guard is
59
+ * removed: unwrap the await and there is no catch in that span, so the
60
60
  * expectation cannot be satisfied by some unrelated try/catch elsewhere in the
61
61
  * function. Ordering alone would NOT catch the regression — the original buggy
62
62
  * code also had jobs after the build; it just had no catch in between.
@@ -64,13 +64,11 @@ describe("phase-2 step isolation", () => {
64
64
  test("a catch sits between the build and the jobs, so a throw cannot skip them", () => {
65
65
  const body = buildFullAppBody();
66
66
  const build = body.indexOf("buildClientAssets(serviceSlug)");
67
- const jobs = body.indexOf("startScheduledJobs(");
68
67
  const boss = body.indexOf("mountPgBoss(");
69
68
  expect(build).toBeGreaterThan(-1);
70
- expect(jobs).toBeGreaterThan(build);
71
69
  expect(boss).toBeGreaterThan(build);
72
70
 
73
- const between = body.slice(build, jobs);
71
+ const between = body.slice(build, boss);
74
72
  expect(between).toContain("catch");
75
73
  });
76
74
 
@@ -48,7 +48,6 @@ import {
48
48
  resolveErrorPageLogo,
49
49
  } from "./resource-not-found.ts";
50
50
  import { injectSecurityHeaders } from "./security-headers.ts";
51
- import { startScheduledJobs } from "./start-scheduled-jobs.ts";
52
51
  import type { PageServerDeps } from "./ui-service.ts";
53
52
  import { registerPageRoutes } from "./register-page-routes.ts";
54
53
  import { runBootHookList } from "./run-boot-hook-list.ts";
@@ -529,10 +528,10 @@ export async function buildFullApp(
529
528
  //
530
529
  // ISOLATED, because a PRESENTATION failure must not take out BACKGROUND WORK.
531
530
  // This was awaited unguarded, so a throw here aborted buildFullApp before
532
- // startScheduledJobs and mountPgBoss ran, and the caller's catch logged
531
+ // mountPgBoss ran, and the caller's catch logged
533
532
  // "Failed to build full app, continuing with minimal app" — which reads as a
534
- // UI-only degradation while having silently killed every scheduled job and
535
- // every pg-boss handler on the service.
533
+ // UI-only degradation while having silently killed every pg-boss handler on
534
+ // the service.
536
535
  //
537
536
  // Measured 2026-07-30: a stale @teamscala/portal-ui artifact still importing
538
537
  // the retired @teamgbg scope threw ENOENT on a missing theme CSS file in
@@ -541,9 +540,9 @@ export async function buildFullApp(
541
540
  // operator's WhatsApp assistant went silent — because of a stylesheet. The
542
541
  // service stayed `online` and healthy throughout, so nothing surfaced it.
543
542
  //
544
- // The steps after this one do not depend on client assets: jobs and pg-boss
545
- // need the registry and the DB, not a bundle. Ordering them behind a Vite
546
- // build was incidental, so the coupling was never intended.
543
+ // The steps after this one do not depend on client assets: pg-boss needs the
544
+ // registry and the DB, not a bundle. Ordering it behind a Vite build was
545
+ // incidental, so the coupling was never intended.
547
546
  try {
548
547
  await buildClientAssets(serviceSlug);
549
548
  log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets DONE`);
@@ -557,10 +556,6 @@ export async function buildFullApp(
557
556
  );
558
557
  }
559
558
 
560
- // Scheduled jobs
561
- startScheduledJobs(config, mountRegistry, serviceSlug);
562
- log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: scheduledJobs DONE`);
563
-
564
559
  // pg-boss (mount for UI services - wasn't mounted in minimal app)
565
560
  const { jobs: pgBossJobs } = await mountPgBoss({
566
561
  databaseUrl: minimalResult.databaseUrl,
@@ -1,84 +0,0 @@
1
- /**
2
- * @system server-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Starts `service_install.scheduled_jobs[]`. Two kinds:
7
- *
8
- * - `long_running`: handler invoked once at boot, runs for the lifetime of
9
- * the process (e.g. SSE subscribers, WAL watchers). Failures are logged.
10
- * - `recurring`: declared here but cron scheduling lands via pg-boss in a
11
- * follow-up — currently a no-op log.
12
- *
13
- * Implementations are registered by server_packages via
14
- * ctx.registerScheduledJob during the mount phase.
15
- */
16
-
17
- import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
18
- import { getAppLogger } from "@teamscala/logger/app-loggers";
19
- import type { MountRegistry } from "@teamscala/os/runtime-contracts/mount-context";
20
-
21
- export function startScheduledJobs(
22
- config: ServiceInstallConfig,
23
- mountRegistry: MountRegistry,
24
- serviceSlug: string,
25
- ): void {
26
- const log = getAppLogger();
27
- for (const rawJob of config.scheduled_jobs ?? []) {
28
- const job = rawJob as {
29
- name: string;
30
- kind: string;
31
- [key: string]: unknown;
32
- };
33
- const handler = mountRegistry.scheduledJobs.get(job.name);
34
- if (!handler) {
35
- log.warn(
36
- `[scala-install:${serviceSlug}] scheduled_job "${job.name}" has no registered handler — skipping`,
37
- );
38
- continue;
39
- }
40
- if (job.kind === "long_running") {
41
- handler({ name: job.name, attempt: 1 })?.catch?.((err: unknown) => {
42
- log.error(
43
- `[scala-install:${serviceSlug}] long_running job "${job.name}" failed: ${err}`,
44
- );
45
- });
46
- log.info(
47
- `[scala-install:${serviceSlug}] long_running job "${job.name}" started`,
48
- );
49
- } else {
50
- // Recurring handlers manage their own setInterval — invoke once at
51
- // boot so the timer arms. pg-boss-driven scheduling lands separately
52
- // for cluster-wide coordination, but fire-and-forget is sufficient
53
- // for single-node dev and matches how long_running handlers behave.
54
- handler({ name: job.name, attempt: 1 })?.catch?.((err: unknown) => {
55
- log.error(
56
- `[scala-install:${serviceSlug}] recurring job "${job.name}" boot invocation failed: ${err}`,
57
- );
58
- });
59
- log.info(
60
- `[scala-install:${serviceSlug}] recurring job "${job.name}" started`,
61
- );
62
- }
63
- }
64
-
65
- // Hollow-shell detection — the symmetric half of the row-without-handler
66
- // warn above. A handler a server_package registered at mount but the
67
- // service_install row never declared is silently dead: the package believes
68
- // its watchdog/subscriber runs, the runtime never invokes it, and nothing
69
- // says so. A registry edit that rewrites scheduled_jobs drops an entry with
70
- // no diff and no error, so boot is the only place both sets are known.
71
- // Twice now a watchdog sat dead this way — the fleet stalled-run watchdog
72
- // (2026-06-05) and task-list-watchdog, dropped by a row edit on 2026-07-23
73
- // and unnoticed for two days. Loud at boot so the gap is visible same day.
74
- const declared = new Set(
75
- (config.scheduled_jobs ?? []).map((j) => (j as { name: string }).name),
76
- );
77
- for (const name of mountRegistry.scheduledJobs.keys()) {
78
- if (!declared.has(name)) {
79
- log.error(
80
- `[scala-install:${serviceSlug}] scheduled job handler "${name}" is registered but NOT declared in service_install.scheduled_jobs — it will NEVER run. Add it to the row or remove the registration.`,
81
- );
82
- }
83
- }
84
- }