@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 +1 -9
- package/src/mount-server-packages.ts +1 -1
- package/src/page-server-orpc/server-client.test.ts +68 -0
- package/src/page-server-orpc/server-client.ts +31 -5
- package/src/register-page-server-client.ts +64 -3
- package/src/resolve-from-loader-config.test.ts +5 -4
- package/src/two-phase-boot.test.ts +5 -7
- package/src/two-phase-boot.ts +6 -11
- package/src/start-scheduled-jobs.ts +0 -84
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
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),
|
|
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
|
|
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)
|
|
23
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
59
|
-
*
|
|
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,
|
|
71
|
+
const between = body.slice(build, boss);
|
|
74
72
|
expect(between).toContain("catch");
|
|
75
73
|
});
|
|
76
74
|
|
package/src/two-phase-boot.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
535
|
-
//
|
|
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:
|
|
545
|
-
//
|
|
546
|
-
//
|
|
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
|
-
}
|