@teamscala/service-boot 0.23.382 → 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 +17 -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/register-registry-page-route/captured-loader-config.test.ts +37 -0
- package/src/register-registry-page-route/captured-loader-config.ts +29 -0
- package/src/register-registry-page-route/resolve-page-data.test.ts +72 -0
- package/src/register-registry-page-route/resolve-page-data.ts +22 -1
- package/src/register-registry-page-route.ts +6 -1
- 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/*",
|
|
@@ -190,6 +190,14 @@
|
|
|
190
190
|
"types": "./src/content-gate/access.d.ts",
|
|
191
191
|
"default": "./src/content-gate/access.ts"
|
|
192
192
|
},
|
|
193
|
+
"./content-gate/catalog": {
|
|
194
|
+
"types": "./src/content-gate/catalog.d.ts",
|
|
195
|
+
"default": "./src/content-gate/catalog.ts"
|
|
196
|
+
},
|
|
197
|
+
"./content-gate/catalog.ts": {
|
|
198
|
+
"types": "./src/content-gate/catalog.d.ts",
|
|
199
|
+
"default": "./src/content-gate/catalog.ts"
|
|
200
|
+
},
|
|
193
201
|
"./content-gate/config-types": {
|
|
194
202
|
"types": "./src/content-gate/config-types.d.ts",
|
|
195
203
|
"default": "./src/content-gate/config-types.ts"
|
|
@@ -722,6 +730,14 @@
|
|
|
722
730
|
"types": "./src/register-registry-page-route/ai-chat-prefetch.d.ts",
|
|
723
731
|
"default": "./src/register-registry-page-route/ai-chat-prefetch.ts"
|
|
724
732
|
},
|
|
733
|
+
"./register-registry-page-route/captured-loader-config": {
|
|
734
|
+
"types": "./src/register-registry-page-route/captured-loader-config.d.ts",
|
|
735
|
+
"default": "./src/register-registry-page-route/captured-loader-config.ts"
|
|
736
|
+
},
|
|
737
|
+
"./register-registry-page-route/captured-loader-config.ts": {
|
|
738
|
+
"types": "./src/register-registry-page-route/captured-loader-config.d.ts",
|
|
739
|
+
"default": "./src/register-registry-page-route/captured-loader-config.ts"
|
|
740
|
+
},
|
|
725
741
|
"./register-registry-page-route/graph-cache": {
|
|
726
742
|
"types": "./src/register-registry-page-route/graph-cache.d.ts",
|
|
727
743
|
"default": "./src/register-registry-page-route/graph-cache.ts"
|
|
@@ -906,14 +922,6 @@
|
|
|
906
922
|
"types": "./src/server-error-page.d.ts",
|
|
907
923
|
"default": "./src/server-error-page.ts"
|
|
908
924
|
},
|
|
909
|
-
"./start-scheduled-jobs": {
|
|
910
|
-
"types": "./src/start-scheduled-jobs.d.ts",
|
|
911
|
-
"default": "./src/start-scheduled-jobs.ts"
|
|
912
|
-
},
|
|
913
|
-
"./start-scheduled-jobs.ts": {
|
|
914
|
-
"types": "./src/start-scheduled-jobs.d.ts",
|
|
915
|
-
"default": "./src/start-scheduled-jobs.ts"
|
|
916
|
-
},
|
|
917
925
|
"./two-phase-boot": {
|
|
918
926
|
"types": "./src/two-phase-boot.d.ts",
|
|
919
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
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The boot log is the ONLY observable statement of what a running service
|
|
7
|
+
* captured from its resource_loader row (the 2026-08-18 evening-long bisect:
|
|
8
|
+
* a footer region added to the row never reached the booted process, and
|
|
9
|
+
* nothing anywhere said so). These tests pin the contract the line states —
|
|
10
|
+
* the captured source keys, the captured region slots, and the restart
|
|
11
|
+
* requirement — so the observability cannot silently regress to the old bare
|
|
12
|
+
* "registered route" line.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, test } from "bun:test";
|
|
15
|
+
import { describeCapturedLoaderConfig } from "./captured-loader-config.ts";
|
|
16
|
+
|
|
17
|
+
describe("describeCapturedLoaderConfig", () => {
|
|
18
|
+
test("names every captured source key and region slot, and states the restart contract", () => {
|
|
19
|
+
const line = describeCapturedLoaderConfig({
|
|
20
|
+
sources: [{ key: "brand" }, { key: "website" }, { key: "footer" }],
|
|
21
|
+
region_slots: ["header", "center", "footer"],
|
|
22
|
+
});
|
|
23
|
+
expect(line).toContain("brand");
|
|
24
|
+
expect(line).toContain("website");
|
|
25
|
+
expect(line).toContain("footer");
|
|
26
|
+
expect(line).toContain("header");
|
|
27
|
+
expect(line).toContain("center");
|
|
28
|
+
expect(line).toContain("captured at boot");
|
|
29
|
+
expect(line).toContain("restart");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("an absent list renders as none, never an empty bracket pair", () => {
|
|
33
|
+
const line = describeCapturedLoaderConfig({ sources: [{ key: "page" }] });
|
|
34
|
+
expect(line).toContain("region_slots [none]");
|
|
35
|
+
expect(line).not.toContain("region_slots []");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Summarises the resource_loader config a route captured at boot, for the
|
|
7
|
+
* route-registration log line. Registration reads the loader row ONCE into
|
|
8
|
+
* the route closure, so an edit to the row silently does not reach the
|
|
9
|
+
* running service (measured 2026-08-18: a footer region added to a report
|
|
10
|
+
* produced no footer and no diagnostic until a cold wake). Naming the
|
|
11
|
+
* captured shape in the boot log makes the row-vs-running-service gap a
|
|
12
|
+
* one-glance check in the service logs, and the restart contract is stated
|
|
13
|
+
* where the operator will be looking when the edit "doesn't work".
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Structural slice of the ResourceLoaderConfigSchema output this unit reads. */
|
|
17
|
+
interface LoaderConfigSlice {
|
|
18
|
+
sources?: { key: string }[];
|
|
19
|
+
region_slots?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function describeCapturedLoaderConfig(
|
|
23
|
+
loaderConfig: LoaderConfigSlice,
|
|
24
|
+
): string {
|
|
25
|
+
const sourceKeys =
|
|
26
|
+
loaderConfig.sources?.map((source) => source.key).join(", ") ?? "";
|
|
27
|
+
const regionSlots = loaderConfig.region_slots?.join(", ") ?? "";
|
|
28
|
+
return `captured at boot: sources [${sourceKeys || "none"}], region_slots [${regionSlots || "none"}] — resource_loader edits reach this service only after a restart`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Regression test for the silent region-slot vanish (measured 2026-08-18): a
|
|
7
|
+
* region the operator declared in resource_loader.region_slots whose source
|
|
8
|
+
* did not resolve was skipped with no log, no error and no signal anywhere —
|
|
9
|
+
* the page rendered minus a region while every other suspect checked out.
|
|
10
|
+
* These tests pin the observable contract: the omission is LOGGED naming the
|
|
11
|
+
* slot, once per slot per process (hot path never spams), and the resolved
|
|
12
|
+
* regions still render every slot that did resolve.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
15
|
+
|
|
16
|
+
const warn = mock((_message: string) => {});
|
|
17
|
+
mock.module("@teamscala/logger/app-loggers", () => ({
|
|
18
|
+
getAppLogger: () => ({ warn, info: () => {} }),
|
|
19
|
+
}));
|
|
20
|
+
mock.module("@teamscala/db/registry/load-config", () => ({
|
|
21
|
+
loadRegistryConfig: async () => ({}),
|
|
22
|
+
}));
|
|
23
|
+
mock.module("@teamscala/service-boot/page-server-orpc/server-client", () => ({
|
|
24
|
+
getServerClient: () => ({}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const { resolvedToPageData } = await import("./resolve-page-data.ts");
|
|
28
|
+
|
|
29
|
+
const request = (): Request =>
|
|
30
|
+
new Request("https://portal.example.com/site/org/dashboard");
|
|
31
|
+
|
|
32
|
+
function makeResolved(): Record<string, unknown> {
|
|
33
|
+
return {
|
|
34
|
+
website: { slug: "site", name: "Site" },
|
|
35
|
+
center: {
|
|
36
|
+
id: "p1",
|
|
37
|
+
name: "Dashboard",
|
|
38
|
+
puck_data: { root: { props: {} }, content: [] },
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("resolvedToPageData — unresolved region slots are observable", () => {
|
|
44
|
+
test("a declared slot whose source did not resolve is omitted AND warned by name", () => {
|
|
45
|
+
const pageData = resolvedToPageData(
|
|
46
|
+
makeResolved(),
|
|
47
|
+
{},
|
|
48
|
+
request(),
|
|
49
|
+
undefined,
|
|
50
|
+
["center", "footer"],
|
|
51
|
+
) as { regions?: { slot: string }[] };
|
|
52
|
+
const slots = (pageData.regions ?? []).map((region) => region.slot);
|
|
53
|
+
expect(slots).toContain("center");
|
|
54
|
+
expect(slots).not.toContain("footer");
|
|
55
|
+
expect(warn.mock.calls.length).toBe(1);
|
|
56
|
+
const message = String(warn.mock.calls[0][0]);
|
|
57
|
+
expect(message).toContain("footer");
|
|
58
|
+
expect(message).toContain("no source resolved");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("the warn throttles to once per slot per process", () => {
|
|
62
|
+
const before = warn.mock.calls.length;
|
|
63
|
+
resolvedToPageData(
|
|
64
|
+
makeResolved(),
|
|
65
|
+
{},
|
|
66
|
+
request(),
|
|
67
|
+
undefined,
|
|
68
|
+
["center", "footer"],
|
|
69
|
+
);
|
|
70
|
+
expect(warn.mock.calls.length).toBe(before);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -81,6 +81,24 @@ interface RegionEntry {
|
|
|
81
81
|
metaDescription?: string | null;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// A region slot the operator DECLARED (resource_loader.region_slots) whose
|
|
85
|
+
// source did not resolve used to vanish with no signal anywhere — the page
|
|
86
|
+
// rendered minus a region while the page, source, attributes, template and
|
|
87
|
+
// tree builder all looked innocent (measured 2026-08-18: a footer region
|
|
88
|
+
// added to a report produced no footer and no diagnostic; the cause was the
|
|
89
|
+
// boot-time config capture named in the message). One warn per slot per
|
|
90
|
+
// process, throttled like the block-coverage guard in the caller, so the hot
|
|
91
|
+
// path never spams it.
|
|
92
|
+
const unresolvedRegionSlotsLogged = new Set<string>();
|
|
93
|
+
|
|
94
|
+
function warnUnresolvedRegionSlot(slot: string): void {
|
|
95
|
+
if (unresolvedRegionSlotsLogged.has(slot)) return;
|
|
96
|
+
unresolvedRegionSlotsLogged.add(slot);
|
|
97
|
+
getAppLogger().warn(
|
|
98
|
+
`[resolve-page-data] region slot "${slot}" is declared in the resource_loader config but no source resolved for it — the region is omitted from this render. A resource_loader edit reaches a booted service only after a restart.`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
84
102
|
export function resolvedToPageData(
|
|
85
103
|
resolved: Record<string, unknown>,
|
|
86
104
|
meta: Record<string, unknown>,
|
|
@@ -133,7 +151,10 @@ export function resolvedToPageData(
|
|
|
133
151
|
const regions: RegionEntry[] = [];
|
|
134
152
|
for (const slot of regionSlots) {
|
|
135
153
|
const source = resolved[slot] as Record<string, unknown> | undefined;
|
|
136
|
-
if (!source)
|
|
154
|
+
if (!source) {
|
|
155
|
+
warnUnresolvedRegionSlot(slot);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
137
158
|
const puckData = extractPuckData(source) ?? EMPTY_PUCK_DATA;
|
|
138
159
|
applyLayoutDefaults(puckData, layoutDefaults);
|
|
139
160
|
regions.push({
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
readCachedGraph,
|
|
43
43
|
} from "./register-registry-page-route/graph-cache.ts";
|
|
44
44
|
import { resolvedToPageData } from "./register-registry-page-route/resolve-page-data.ts";
|
|
45
|
+
import { describeCapturedLoaderConfig } from "./register-registry-page-route/captured-loader-config.ts";
|
|
45
46
|
import {
|
|
46
47
|
buildMetaFromContext,
|
|
47
48
|
} from "./register-registry-page-route/meta-title.ts";
|
|
@@ -850,8 +851,12 @@ export async function registerRegistryPageRoute(
|
|
|
850
851
|
});
|
|
851
852
|
|
|
852
853
|
registeredPatterns.push(pattern);
|
|
854
|
+
// The loader config was captured at boot into the loadData closure — name
|
|
855
|
+
// the captured shape + the restart contract so a row edit that "doesn't
|
|
856
|
+
// work" on a running service is answered by this line, not an evening of
|
|
857
|
+
// bisecting (measured 2026-08-18).
|
|
853
858
|
log.info(
|
|
854
|
-
`[scala-install:${serviceSlug}] registered registry page route "${pattern}" → resource_loader "${loaderSlug}"`,
|
|
859
|
+
`[scala-install:${serviceSlug}] registered registry page route "${pattern}" → resource_loader "${loaderSlug}" (${describeCapturedLoaderConfig(loaderConfig)})`,
|
|
855
860
|
);
|
|
856
861
|
}
|
|
857
862
|
|
|
@@ -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
|
-
}
|