@teamscala/service-boot 0.23.449 → 0.23.451
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 +19 -1
- package/src/auth-gate.ts +1 -1
- package/src/fn-routers/agreement-completion-email.ts +1 -1
- package/src/fn-routers/agreement-send-router.ts +1 -1
- package/src/fn-routers/assessment-report-email.ts +1 -1
- package/src/fn-routers/proposal-send-router.ts +1 -1
- package/src/fn-routers/public-assessment-router.test.ts +2 -2
- package/src/handlers/upstream-issue-sync.ts +2 -1
- package/src/mount-server-packages.ts +1 -1
- package/src/register-api-routes.ts +1 -1
- package/src/register-registry-page-route/live-loader-config.test.ts +219 -0
- package/src/register-registry-page-route/live-loader-config.ts +58 -0
- package/src/register-registry-page-route/resolve-request-graph.ts +7 -2
- package/src/register-registry-page-route.ts +32 -7
- package/src/register-ws-routes.ts +1 -1
- package/src/resolve-service-context.ts +2 -2
- package/src/resolvers/app-header.test.ts +65 -0
- package/src/resolvers/app-header.ts +77 -0
- package/src/run-boot-hook-list.ts +1 -1
- package/src/run-boot-hooks.ts +1 -1
- package/src/types.ts +1 -1
- package/src/ws-boot-self-test.ts +1 -1
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.451",
|
|
10
10
|
"sideEffects": false,
|
|
11
11
|
"imports": {
|
|
12
12
|
"#service-boot/*": "./src/*",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"@teamscala/document-signing": "*",
|
|
28
28
|
"@teamscala/email": "*",
|
|
29
29
|
"@teamscala/email-client": "*",
|
|
30
|
+
"@teamscala/email-template": "*",
|
|
30
31
|
"@teamscala/encryption": "*",
|
|
31
32
|
"@teamscala/event-log": "*",
|
|
32
33
|
"@teamscala/file-lock": "*",
|
|
@@ -42,6 +43,7 @@
|
|
|
42
43
|
"@teamscala/orpc": "*",
|
|
43
44
|
"@teamscala/os": "*",
|
|
44
45
|
"@teamscala/publish-registry": "*",
|
|
46
|
+
"@teamscala/puck-boundary": "*",
|
|
45
47
|
"@teamscala/realtime-utils": "*",
|
|
46
48
|
"@teamscala/resolver-engine": "*",
|
|
47
49
|
"@teamscala/safe-fallback": "*",
|
|
@@ -1207,6 +1209,14 @@
|
|
|
1207
1209
|
"types": "./src/resolve-theme-mode.d.ts",
|
|
1208
1210
|
"default": "./src/resolve-theme-mode.ts"
|
|
1209
1211
|
},
|
|
1212
|
+
"./resolvers/app-header": {
|
|
1213
|
+
"types": "./src/resolvers/app-header.d.ts",
|
|
1214
|
+
"default": "./src/resolvers/app-header.ts"
|
|
1215
|
+
},
|
|
1216
|
+
"./resolvers/app-header.ts": {
|
|
1217
|
+
"types": "./src/resolvers/app-header.d.ts",
|
|
1218
|
+
"default": "./src/resolvers/app-header.ts"
|
|
1219
|
+
},
|
|
1210
1220
|
"./resolvers/fetch-org-brand": {
|
|
1211
1221
|
"types": "./src/resolvers/fetch-org-brand.d.ts",
|
|
1212
1222
|
"default": "./src/resolvers/fetch-org-brand.ts"
|
|
@@ -1287,6 +1297,14 @@
|
|
|
1287
1297
|
"types": "./src/server-error-page.d.ts",
|
|
1288
1298
|
"default": "./src/server-error-page.ts"
|
|
1289
1299
|
},
|
|
1300
|
+
"./theme-fonts-link": {
|
|
1301
|
+
"types": "./src/theme-fonts-link.d.ts",
|
|
1302
|
+
"default": "./src/theme-fonts-link.ts"
|
|
1303
|
+
},
|
|
1304
|
+
"./theme-fonts-link.ts": {
|
|
1305
|
+
"types": "./src/theme-fonts-link.d.ts",
|
|
1306
|
+
"default": "./src/theme-fonts-link.ts"
|
|
1307
|
+
},
|
|
1290
1308
|
"./two-phase-full-app": {
|
|
1291
1309
|
"types": "./src/two-phase-full-app.d.ts",
|
|
1292
1310
|
"default": "./src/two-phase-full-app.ts"
|
package/src/auth-gate.ts
CHANGED
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
// service-to-service auth still flows through the cookie/secret on the
|
|
71
71
|
// request, never the source IP.
|
|
72
72
|
|
|
73
|
-
import type { ServiceInstallConfig } from "
|
|
73
|
+
import type { ServiceInstallConfig } from "./load-service-install-row";
|
|
74
74
|
import { getPrisma } from "@teamscala/db/prisma-registry";
|
|
75
75
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
76
76
|
import { resolveAccess } from "@teamscala/auth/resolve-access";
|
|
@@ -18,7 +18,7 @@ import type { PrismaClient } from "@teamscala/db/client";
|
|
|
18
18
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
19
19
|
import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
|
|
20
20
|
import { sendResendEmail } from "./resend-client.ts";
|
|
21
|
-
import { applyTemplateVars, lookupTemplate } from "@teamscala/email/
|
|
21
|
+
import { applyTemplateVars, lookupTemplate } from "@teamscala/email-template/lookup";
|
|
22
22
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
23
23
|
import { signAgreementAccessToken } from "@teamscala/document-signing/agreement-access-token";
|
|
24
24
|
import { parseAgreementParties } from "@teamscala/document-signing/agreement-parties";
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
type SecretConfigLike,
|
|
31
31
|
} from "@teamscala/encryption/crypto/secret-config";
|
|
32
32
|
import { sendResendEmail } from "./resend-client.ts";
|
|
33
|
-
import { applyTemplateVars, lookupTemplate } from "@teamscala/email/
|
|
33
|
+
import { applyTemplateVars, lookupTemplate } from "@teamscala/email-template/lookup";
|
|
34
34
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
35
35
|
import type { PrismaClient } from "@teamscala/db/client";
|
|
36
36
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import * as v from "valibot";
|
|
16
16
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
17
17
|
import { sendResendEmail } from "./resend-client.ts";
|
|
18
|
-
import { applyTemplateVars, lookupTemplate } from "@teamscala/email/
|
|
18
|
+
import { applyTemplateVars, lookupTemplate } from "@teamscala/email-template/lookup";
|
|
19
19
|
import { resolveEmailSender } from "@teamscala/email/identity/resolve-email-sender";
|
|
20
20
|
import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
|
|
21
21
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
@@ -13,7 +13,7 @@ import { os } from "@orpc/server";
|
|
|
13
13
|
import * as v from "valibot";
|
|
14
14
|
import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
|
|
15
15
|
import { sendResendEmail } from "./resend-client.ts";
|
|
16
|
-
import { applyTemplateVars, lookupTemplate } from "@teamscala/email/
|
|
16
|
+
import { applyTemplateVars, lookupTemplate } from "@teamscala/email-template/lookup";
|
|
17
17
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
18
18
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
19
19
|
import { signAccessToken } from "../content-gate/token.ts";
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
16
|
-
import { applyTemplateVars } from "@teamscala/email/
|
|
16
|
+
import { applyTemplateVars } from "@teamscala/email-template/lookup";
|
|
17
17
|
|
|
18
18
|
interface CapturedEmail {
|
|
19
19
|
to: string;
|
|
@@ -58,7 +58,7 @@ mock.module("@teamscala/api-resend", () => ({
|
|
|
58
58
|
// The row owns copy + layout; the test mirrors the authority-index-report
|
|
59
59
|
// row's merge tokens so the substitution surface exercised is the real one.
|
|
60
60
|
// applyTemplateVars stays the REAL implementation — only lookup is stubbed.
|
|
61
|
-
mock.module("@teamscala/email/
|
|
61
|
+
mock.module("@teamscala/email-template/lookup", () => ({
|
|
62
62
|
lookupTemplate: async () => ({
|
|
63
63
|
// Mirrors the live authority-index-report row, which leads with {{orgName}}
|
|
64
64
|
// — a stub without it cannot catch a wrong org name in the subject.
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { getAllEntries } from "@teamscala/db/registry/accessors";
|
|
27
27
|
import { persistRegistryEntryConfig } from "@teamscala/db/registry/persist-entry-config";
|
|
28
|
+
import { validateRow } from "@teamscala/db-validation/validate-row";
|
|
28
29
|
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
29
30
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
30
31
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
@@ -181,7 +182,7 @@ async function upstreamIssueSync(_ctx: { config: ScheduledJobConfig }): Promise<
|
|
|
181
182
|
};
|
|
182
183
|
|
|
183
184
|
try {
|
|
184
|
-
await persistRegistryEntryConfig("upstream_issue", row.slug, nextConfig);
|
|
185
|
+
await persistRegistryEntryConfig("upstream_issue", row.slug, nextConfig, validateRow);
|
|
185
186
|
} catch (err) {
|
|
186
187
|
log.warn(
|
|
187
188
|
`[upstream-issue-sync] row ${row.slug} write failed (validation or db): ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* only warms the cache, never gates boot.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import type { ServiceInstallConfig } from "
|
|
27
|
+
import type { ServiceInstallConfig } from "./load-service-install-row";
|
|
28
28
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
29
29
|
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
30
30
|
import {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* walking when explicit caller headers are absent.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import type { ServiceInstallConfig } from "
|
|
16
|
+
import type { ServiceInstallConfig } from "./load-service-install-row";
|
|
17
17
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
18
18
|
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
19
19
|
import type { MountRegistry } from "@teamscala/os/runtime-contracts/mount-context";
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit extend the regression set for the live loader-config contract
|
|
5
|
+
*
|
|
6
|
+
* Behavioural tests for the per-request resource_loader read (the fix for the
|
|
7
|
+
* boot-baked-loader defect): resource_loader is NOT on the deploy-time bake
|
|
8
|
+
* list, so a registry row edit must reach the running service WITHOUT a
|
|
9
|
+
* restart. The registration loop's boot read is warm/validate only.
|
|
10
|
+
*
|
|
11
|
+
* 1. Two requests to the same route with the row edited between them — the
|
|
12
|
+
* SECOND request's graph resolution receives the NEW source set, and the
|
|
13
|
+
* live read ran once per request (not once per boot).
|
|
14
|
+
* 2. A row that vanishes (or stops validating) between requests — the second
|
|
15
|
+
* request FAILS CLOSED (the read throws into the branded error path;
|
|
16
|
+
* the boot snapshot never silently outlives its row).
|
|
17
|
+
* 3. The graphKey fingerprint changes when the config changes — a row edit
|
|
18
|
+
* invalidates the per-route SWR graph cache immediately instead of
|
|
19
|
+
* serving the old graph until the fresh window lapses.
|
|
20
|
+
*
|
|
21
|
+
* Only the registry reader and the heavy graph collaborators are mocked; the
|
|
22
|
+
* real registrar, real Hono app, real loadData pipeline and the real
|
|
23
|
+
* live-loader-config unit are under test.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
|
27
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
28
|
+
|
|
29
|
+
if (!globalThis.document) GlobalRegistrator.register();
|
|
30
|
+
|
|
31
|
+
// ── The mutable registry ────────────────────────────────────────────────
|
|
32
|
+
const calls: { slug: string; type: string }[] = [];
|
|
33
|
+
const store: {
|
|
34
|
+
routeRows: { slug: string; config: Record<string, unknown> }[];
|
|
35
|
+
loaderConfig: Record<string, unknown> | null;
|
|
36
|
+
} = {
|
|
37
|
+
routeRows: [],
|
|
38
|
+
loaderConfig: null,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
mock.module("@teamscala/db/registry/load-config", () => {
|
|
42
|
+
class RegistryConfigMissingError extends Error {}
|
|
43
|
+
return {
|
|
44
|
+
RegistryConfigMissingError,
|
|
45
|
+
loadRegistryConfig: async (
|
|
46
|
+
type: string,
|
|
47
|
+
slug: string,
|
|
48
|
+
_schema: unknown,
|
|
49
|
+
) => {
|
|
50
|
+
calls.push({ type, slug });
|
|
51
|
+
if (type === "resource_loader") {
|
|
52
|
+
if (!store.loaderConfig) throw new RegistryConfigMissingError(slug);
|
|
53
|
+
return store.loaderConfig;
|
|
54
|
+
}
|
|
55
|
+
// feature_flag rows absent — the registrar's legacy catch.
|
|
56
|
+
throw new RegistryConfigMissingError(slug);
|
|
57
|
+
},
|
|
58
|
+
loadRegistryConfigsByType: async () => store.routeRows,
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ── The heavy collaborators (recorded, not executed) ───────────────────
|
|
63
|
+
const graphCalls: { loaderConfig: Record<string, unknown>; configFingerprint?: string }[] = [];
|
|
64
|
+
|
|
65
|
+
mock.module("./resolve-request-graph.ts", () => ({
|
|
66
|
+
resolveRequestGraph: async (args: {
|
|
67
|
+
loaderConfig: Record<string, unknown>;
|
|
68
|
+
configFingerprint?: string;
|
|
69
|
+
}) => {
|
|
70
|
+
graphCalls.push({
|
|
71
|
+
loaderConfig: args.loaderConfig,
|
|
72
|
+
configFingerprint: args.configFingerprint,
|
|
73
|
+
});
|
|
74
|
+
return {};
|
|
75
|
+
},
|
|
76
|
+
}));
|
|
77
|
+
mock.module("./stamp-session-context.ts", () => ({
|
|
78
|
+
stampSessionContext: async () => ({ sessionUserId: null, sessionOrgId: null }),
|
|
79
|
+
}));
|
|
80
|
+
mock.module("./run-content-gates.ts", () => ({
|
|
81
|
+
runContentGatesForPage: async () => {},
|
|
82
|
+
}));
|
|
83
|
+
mock.module("./apply-page-branding.ts", () => ({
|
|
84
|
+
applyBrandOrgPath: async () => {},
|
|
85
|
+
applySessionOrgBranding: async () => {},
|
|
86
|
+
applySignInBrandOverride: async () => {},
|
|
87
|
+
}));
|
|
88
|
+
mock.module("./resolve-panel-page.ts", () => ({
|
|
89
|
+
attachPanelPage: async () => {},
|
|
90
|
+
}));
|
|
91
|
+
mock.module("./warn-uncovered-blocks.ts", () => ({
|
|
92
|
+
warnUncoveredBlocks: () => {},
|
|
93
|
+
}));
|
|
94
|
+
mock.module("./resolve-page-data.ts", () => ({
|
|
95
|
+
resolvedToPageData: () => ({ puckData: { content: [] } }),
|
|
96
|
+
}));
|
|
97
|
+
mock.module("./ai-chat-prefetch.ts", () => ({
|
|
98
|
+
prefetchAiChatSession: async () => {},
|
|
99
|
+
}));
|
|
100
|
+
mock.module("./proposal-approval-email-token.ts", () => ({
|
|
101
|
+
applyApprovalEmailToken: async () => {},
|
|
102
|
+
}));
|
|
103
|
+
// bindPageHandler → run the captured loadData directly so the test drives
|
|
104
|
+
// the per-request path end to end.
|
|
105
|
+
mock.module("../bind-page-handler.ts", () => ({
|
|
106
|
+
bindPageHandler: async (
|
|
107
|
+
c: { req: { raw: Request }; json: (body: unknown) => Response },
|
|
108
|
+
handler: { loadData: (req: Request) => Promise<unknown> },
|
|
109
|
+
_request: Request,
|
|
110
|
+
) => c.json(await handler.loadData(c.req.raw)),
|
|
111
|
+
}));
|
|
112
|
+
mock.module("@teamscala/logger/app-loggers", () => ({
|
|
113
|
+
getAppLogger: () => ({ info: () => {}, warn: () => {} }),
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
const { registerRegistryPageRoute } = await import(
|
|
117
|
+
"../register-registry-page-route.ts"
|
|
118
|
+
);
|
|
119
|
+
const { fingerprintLoaderConfig } = await import("./live-loader-config.ts");
|
|
120
|
+
const { Hono } = await import("hono");
|
|
121
|
+
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
calls.length = 0;
|
|
124
|
+
graphCalls.length = 0;
|
|
125
|
+
store.routeRows = [];
|
|
126
|
+
store.loaderConfig = null;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
async function registerRoute(): Promise<InstanceType<typeof Hono>> {
|
|
130
|
+
const app = new Hono();
|
|
131
|
+
await registerRegistryPageRoute({
|
|
132
|
+
app: app as never,
|
|
133
|
+
serviceSlug: "test-svc",
|
|
134
|
+
components: {},
|
|
135
|
+
createPageHandler: (args: { loadData: unknown }) =>
|
|
136
|
+
({ loadData: args.loadData }) as never,
|
|
137
|
+
authMode: "none",
|
|
138
|
+
renderMode: "public" as never,
|
|
139
|
+
providers: [],
|
|
140
|
+
portalScoped: false,
|
|
141
|
+
} as never);
|
|
142
|
+
return app;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function seedRouteRow() {
|
|
146
|
+
store.routeRows = [
|
|
147
|
+
{
|
|
148
|
+
slug: "test-route",
|
|
149
|
+
config: {
|
|
150
|
+
pattern: "/:id",
|
|
151
|
+
service: "test-svc",
|
|
152
|
+
loader_slug: "test-loader",
|
|
153
|
+
render_mode: "public",
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const requestRoute = (app: InstanceType<typeof Hono>) =>
|
|
160
|
+
app.request("/abc", { headers: { host: "test.local" } });
|
|
161
|
+
|
|
162
|
+
describe("live loader config — per-request read (boot-baked-loader fix)", () => {
|
|
163
|
+
test("a row edit between two requests reaches the second request without a restart", async () => {
|
|
164
|
+
seedRouteRow();
|
|
165
|
+
store.loaderConfig = { sources: [{ key: "page" }] };
|
|
166
|
+
const app = await registerRoute();
|
|
167
|
+
|
|
168
|
+
await requestRoute(app);
|
|
169
|
+
expect(graphCalls[0]?.loaderConfig).toEqual({
|
|
170
|
+
sources: [{ key: "page" }],
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// THE EDIT — no restart, no redeploy.
|
|
174
|
+
store.loaderConfig = {
|
|
175
|
+
sources: [{ key: "page" }, { key: "agreement" }],
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
await requestRoute(app);
|
|
179
|
+
const loaderReads = calls.filter(
|
|
180
|
+
(c) => c.type === "resource_loader" && c.slug === "test-loader",
|
|
181
|
+
);
|
|
182
|
+
// Boot warm/validate counts once; per-request reads add one per request.
|
|
183
|
+
// The fix's contract is that the count GROWS past the boot read — a
|
|
184
|
+
// single boot-time capture would read once total, never three.
|
|
185
|
+
expect(loaderReads.length).toBeGreaterThanOrEqual(3);
|
|
186
|
+
// The second request's graph resolution received the new source set.
|
|
187
|
+
const second = graphCalls.at(-1)?.loaderConfig as {
|
|
188
|
+
sources: { key: string }[];
|
|
189
|
+
};
|
|
190
|
+
expect(
|
|
191
|
+
second.sources.some((s) => s.key === "agreement"),
|
|
192
|
+
).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("a row that vanishes between requests fails closed", async () => {
|
|
196
|
+
seedRouteRow();
|
|
197
|
+
store.loaderConfig = { sources: [{ key: "page" }] };
|
|
198
|
+
const app = await registerRoute();
|
|
199
|
+
await requestRoute(app);
|
|
200
|
+
|
|
201
|
+
store.loaderConfig = null;
|
|
202
|
+
|
|
203
|
+
// Fail closed = the request ERORS (Hono maps the thrown read to a 500),
|
|
204
|
+
// never a 200 served from the boot-time snapshot of a dead row.
|
|
205
|
+
const res = await requestRoute(app);
|
|
206
|
+
expect(res.status).toBe(500);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("the graphKey fingerprint changes with the config and is key-order stable", () => {
|
|
210
|
+
const a = fingerprintLoaderConfig({ sources: [{ key: "page" }] });
|
|
211
|
+
const b = fingerprintLoaderConfig({
|
|
212
|
+
sources: [{ key: "page" }, { key: "agreement" }],
|
|
213
|
+
});
|
|
214
|
+
expect(a).not.toBe(b);
|
|
215
|
+
expect(fingerprintLoaderConfig({ a: 1, b: 2 })).toBe(
|
|
216
|
+
fingerprintLoaderConfig({ b: 2, a: 1 }),
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* live-loader-config.ts — the per-request resource_loader read.
|
|
7
|
+
*
|
|
8
|
+
* The constitution's deploy-time bake list (installer-and-runtime) names
|
|
9
|
+
* service_install, service_manifest, block_schema, page_route, api_route and
|
|
10
|
+
* scheduled_job — resource_loader is NOT among it, so a loader row is runtime
|
|
11
|
+
* data: a registry edit must reach the running service on a subsequent request
|
|
12
|
+
* without a restart. This module is that contract: `readLiveLoaderConfig`
|
|
13
|
+
* re-reads the row through `loadRegistryConfig` on EVERY request (its SWR
|
|
14
|
+
* cache makes the fresh-window hit a memory read, not a DB round trip) and
|
|
15
|
+
* FAILS CLOSED — a row that vanished or stopped validating mid-flight throws
|
|
16
|
+
* into the branded error path rather than silently serving the boot-time
|
|
17
|
+
* snapshot of a row that no longer exists.
|
|
18
|
+
*
|
|
19
|
+
* `fingerprintLoaderConfig` gives the per-request graph cache a key component
|
|
20
|
+
* derived from the LIVE config, so a row edit invalidates cached graphs for
|
|
21
|
+
* that route immediately (the graph cache keys on route+host+path alone would
|
|
22
|
+
* otherwise serve a stale graph until its SWR window lapsed — right cache,
|
|
23
|
+
* wrong key: the graph is a function of the config, so the config belongs in
|
|
24
|
+
* the key).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
28
|
+
import { ResourceLoaderConfigSchema } from "@teamscala/db-validation/registry-schemas/resource-loader";
|
|
29
|
+
import type { ResourceLoaderKey } from "@teamscala/db/registry/generated/resource-loader";
|
|
30
|
+
import * as v from "valibot";
|
|
31
|
+
|
|
32
|
+
export type LoaderConfig = v.InferOutput<typeof ResourceLoaderConfigSchema>;
|
|
33
|
+
|
|
34
|
+
export async function readLiveLoaderConfig(
|
|
35
|
+
loaderSlug: ResourceLoaderKey,
|
|
36
|
+
): Promise<LoaderConfig> {
|
|
37
|
+
return loadRegistryConfig(
|
|
38
|
+
"resource_loader",
|
|
39
|
+
loaderSlug,
|
|
40
|
+
ResourceLoaderConfigSchema,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** djb2 over the config's stable JSON — short, deterministic, SSR-safe. */
|
|
45
|
+
export function fingerprintLoaderConfig(config: unknown): string {
|
|
46
|
+
const json = JSON.stringify(config, sortedTopLevelKeys(config));
|
|
47
|
+
let hash = 5381;
|
|
48
|
+
for (let i = 0; i < json.length; i++) {
|
|
49
|
+
hash = ((hash << 5) + hash + json.charCodeAt(i)) | 0;
|
|
50
|
+
}
|
|
51
|
+
return (hash >>> 0).toString(36);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sortedTopLevelKeys(config: unknown): string[] {
|
|
55
|
+
return config != null && typeof config === "object"
|
|
56
|
+
? Object.keys(config).sort()
|
|
57
|
+
: [];
|
|
58
|
+
}
|
|
@@ -34,12 +34,17 @@ export interface ResolveRequestGraphArgs {
|
|
|
34
34
|
params: Record<string, string>;
|
|
35
35
|
isStaticShell: boolean;
|
|
36
36
|
serviceSlug: string;
|
|
37
|
+
/** Stable fingerprint of the LIVE loader config (fingerprintLoaderConfig).
|
|
38
|
+
* Appended to the graphKey so a registry row edit invalidates cached
|
|
39
|
+
* graphs for the route on the very next request — the graph is a
|
|
40
|
+
* function of the config, so the config belongs in the cache key. */
|
|
41
|
+
configFingerprint?: string;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
export async function resolveRequestGraph(
|
|
40
45
|
args: ResolveRequestGraphArgs,
|
|
41
46
|
): Promise<Record<string, unknown>> {
|
|
42
|
-
const { pattern, routeSlug, routeConfig, loaderConfig, params, isStaticShell, serviceSlug } = args;
|
|
47
|
+
const { pattern, routeSlug, routeConfig, loaderConfig, params, isStaticShell, serviceSlug, configFingerprint } = args;
|
|
43
48
|
const log = getAppLogger();
|
|
44
49
|
|
|
45
50
|
// A bare root ("/") that declares a resource_type is a UUID-driven
|
|
@@ -57,7 +62,7 @@ export async function resolveRequestGraph(
|
|
|
57
62
|
// the default 404 (genuinely not found).
|
|
58
63
|
throw new PrimaryResourceMissingError(routeConfig.resource_type, 200);
|
|
59
64
|
}
|
|
60
|
-
const graphKey = `graph:${routeSlug}:${params.host}:${params.path}`;
|
|
65
|
+
const graphKey = `graph:${routeSlug}:${params.host}:${params.path}${configFingerprint ? `:${configFingerprint}` : ""}`;
|
|
61
66
|
const primaryType = routeConfig.resource_type;
|
|
62
67
|
const hasPrimarySource =
|
|
63
68
|
!!primaryType &&
|
|
@@ -57,6 +57,10 @@ import {
|
|
|
57
57
|
applyClientLogo,
|
|
58
58
|
} from "./register-registry-page-route/proposal-brand-stamping.ts";
|
|
59
59
|
import type { CreatePageHandler, RendererComponentEntry } from "./types.ts";
|
|
60
|
+
import {
|
|
61
|
+
fingerprintLoaderConfig,
|
|
62
|
+
readLiveLoaderConfig,
|
|
63
|
+
} from "./register-registry-page-route/live-loader-config.ts";
|
|
60
64
|
|
|
61
65
|
export interface PageRouteConfig {
|
|
62
66
|
pattern: string;
|
|
@@ -163,6 +167,12 @@ export async function registerRegistryPageRoute(
|
|
|
163
167
|
ReturnType<typeof loadRegistryConfig<typeof ResourceLoaderConfigSchema>>
|
|
164
168
|
> | null = null;
|
|
165
169
|
try {
|
|
170
|
+
// Boot-time warm + validate: a missing row skips the route (warn),
|
|
171
|
+
// an invalid row fails closed. resource_loader is NOT on the
|
|
172
|
+
// deploy-time bake list (constitution installer-and-runtime) — it
|
|
173
|
+
// is re-read per request via readLiveLoaderConfig below — so this
|
|
174
|
+
// boot read exists ONLY to catch a misconfigured row early, never
|
|
175
|
+
// as the source of truth for live rendering.
|
|
166
176
|
loaderConfig = await loadRegistryConfig(
|
|
167
177
|
"resource_loader",
|
|
168
178
|
loaderSlug,
|
|
@@ -188,6 +198,16 @@ export async function registerRegistryPageRoute(
|
|
|
188
198
|
!pattern.includes("*");
|
|
189
199
|
|
|
190
200
|
const loadData = async (request: Request) => {
|
|
201
|
+
// resource_loader is runtime data, not deploy-time baked. Re-read it
|
|
202
|
+
// per request — loadRegistryConfig's SWR cache makes the fresh-window
|
|
203
|
+
// hit a memory read, not a DB round trip. Throws into the branded
|
|
204
|
+
// error path on a vanished or invalid row: the boot-time snapshot
|
|
205
|
+
// must NEVER silently outlive the row it captured. This is the fix
|
|
206
|
+
// for the loader-row-edits-look-like-they-don't-work defect (every
|
|
207
|
+
// edit used to need a service restart; the sign-page-with-brand
|
|
208
|
+
// `agreement` source added at 22:16 still hadn't reached
|
|
209
|
+
// __PAGE_DATA__.loaderData at 22:29 — 13 minutes, and never).
|
|
210
|
+
const liveLoaderConfig = await readLiveLoaderConfig(loaderSlug);
|
|
191
211
|
const url = new URL(request.url);
|
|
192
212
|
const params = extractUrlParams(pattern, url.pathname);
|
|
193
213
|
params.host = request.headers.get("host")?.split(":")[0] || url.hostname;
|
|
@@ -196,10 +216,15 @@ export async function registerRegistryPageRoute(
|
|
|
196
216
|
pattern,
|
|
197
217
|
routeSlug: routeRow.slug,
|
|
198
218
|
routeConfig,
|
|
199
|
-
loaderConfig:
|
|
219
|
+
loaderConfig: liveLoaderConfig as never,
|
|
200
220
|
params,
|
|
201
221
|
isStaticShell,
|
|
202
222
|
serviceSlug,
|
|
223
|
+
// Folds the live config into the per-route graphKey so a row edit
|
|
224
|
+
// invalidates the cached graph immediately (the graph is a
|
|
225
|
+
// function of the config; without this the SWR graph cache
|
|
226
|
+
// would still hold the old shape until its fresh window lapsed).
|
|
227
|
+
configFingerprint: fingerprintLoaderConfig(liveLoaderConfig),
|
|
203
228
|
});
|
|
204
229
|
const { sessionUserId, sessionOrgId } = await stampSessionContext(
|
|
205
230
|
resolved,
|
|
@@ -207,7 +232,7 @@ export async function registerRegistryPageRoute(
|
|
|
207
232
|
);
|
|
208
233
|
const gateCookie = request.headers.get("cookie");
|
|
209
234
|
const regionSlots = (
|
|
210
|
-
|
|
235
|
+
liveLoaderConfig as { region_slots?: string[] }
|
|
211
236
|
).region_slots;
|
|
212
237
|
await runContentGatesForPage(
|
|
213
238
|
resolved,
|
|
@@ -375,12 +400,12 @@ export async function registerRegistryPageRoute(
|
|
|
375
400
|
});
|
|
376
401
|
|
|
377
402
|
registeredPatterns.push(pattern);
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
403
|
+
// Boot-validated snapshot (the LIVE read is per request — see
|
|
404
|
+
// readLiveLoaderConfig); name the shape so a misconfigured row is
|
|
405
|
+
// visible at boot, and note the live contract so "my loader edit
|
|
406
|
+
// didn't take effect" routes to the graph SWR window, not a restart.
|
|
382
407
|
log.info(
|
|
383
|
-
`[scala-install:${serviceSlug}] registered registry page route "${pattern}" → resource_loader "${loaderSlug}" (${describeCapturedLoaderConfig(loaderConfig)})`,
|
|
408
|
+
`[scala-install:${serviceSlug}] registered registry page route "${pattern}" → resource_loader "${loaderSlug}" (boot-validated: ${describeCapturedLoaderConfig(loaderConfig)}; re-read live per request)`,
|
|
384
409
|
);
|
|
385
410
|
}
|
|
386
411
|
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { getAppRouter } from "@teamscala/orpc/app-router";
|
|
27
27
|
import { mountOrpcWs } from "@teamscala/orpc/ws/server";
|
|
28
|
-
import type { ServiceInstallConfig } from "
|
|
28
|
+
import type { ServiceInstallConfig } from "./load-service-install-row";
|
|
29
29
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
30
30
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
31
31
|
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
@@ -13,8 +13,8 @@ import type { PrismaClient } from "@teamscala/db/client";
|
|
|
13
13
|
import { loadServicePrismaClient } from "@teamscala/db/load-service-prisma-client";
|
|
14
14
|
import { registerPrismaClient } from "@teamscala/db/prisma-registry";
|
|
15
15
|
import { loadRegistryEntries } from "@teamscala/db/registry/loader/load-entries";
|
|
16
|
-
import type { ServiceInstallConfig } from "
|
|
17
|
-
import { loadServiceInstallRow } from "
|
|
16
|
+
import type { ServiceInstallConfig } from "./load-service-install-row";
|
|
17
|
+
import { loadServiceInstallRow } from "./load-service-install-row";
|
|
18
18
|
|
|
19
19
|
export interface ServiceContext {
|
|
20
20
|
kind: "ui" | "backend" | "runtime";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { resolveAppHeader } from "./app-header.ts";
|
|
8
|
+
|
|
9
|
+
const ARGS = { client: null, params: {} } as const;
|
|
10
|
+
|
|
11
|
+
describe("resolveAppHeader", () => {
|
|
12
|
+
test("passes interpolated values through as strings", () => {
|
|
13
|
+
const out = resolveAppHeader({
|
|
14
|
+
...ARGS,
|
|
15
|
+
input: {
|
|
16
|
+
title: "Anna's Proposal",
|
|
17
|
+
row1Label: "Prepared for",
|
|
18
|
+
row1Value: "Joe Bellissimo",
|
|
19
|
+
row2Label: "Organisation",
|
|
20
|
+
row2Value: "GMG APAC",
|
|
21
|
+
row3Label: "Date",
|
|
22
|
+
row3Value: "2026-09-03T00:00:00.000Z",
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
expect(out.title).toBe("Anna's Proposal");
|
|
26
|
+
expect(out.row1Label).toBe("Prepared for");
|
|
27
|
+
expect(out.row1Value).toBe("Joe Bellissimo");
|
|
28
|
+
expect(out.row3Value).toBe("2026-09-03T00:00:00.000Z");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("coerces Prisma Date values to ISO strings", () => {
|
|
32
|
+
const out = resolveAppHeader({
|
|
33
|
+
...ARGS,
|
|
34
|
+
input: {
|
|
35
|
+
title: "Report",
|
|
36
|
+
row3Value: new Date("2026-08-25T15:46:58.698Z"),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
expect(out.row3Value).toBe("2026-08-25T15:46:58.698Z");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("expands the today directive to a noon-UTC instant of the AEST date", () => {
|
|
43
|
+
const out = resolveAppHeader({ ...ARGS, input: { row3Value: "today" } });
|
|
44
|
+
const value = out.row3Value as string;
|
|
45
|
+
// Noon UTC keeps the calendar date stable in every timezone.
|
|
46
|
+
expect(value.endsWith("T12:00:00.000Z")).toBe(true);
|
|
47
|
+
const [isoDate] = value.split("T");
|
|
48
|
+
const aestDate = new Intl.DateTimeFormat("en-CA", {
|
|
49
|
+
timeZone: "Australia/Sydney",
|
|
50
|
+
year: "numeric",
|
|
51
|
+
month: "2-digit",
|
|
52
|
+
day: "2-digit",
|
|
53
|
+
}).format(new Date());
|
|
54
|
+
expect(isoDate).toBe(aestDate);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("maps absent fields to null (the resource-not-blank contract: null, never a stale value)", () => {
|
|
58
|
+
const out = resolveAppHeader({ ...ARGS, input: { title: "Only a title" } });
|
|
59
|
+
expect(out.title).toBe("Only a title");
|
|
60
|
+
expect(out.row1Label).toBeNull();
|
|
61
|
+
expect(out.row1Value).toBeNull();
|
|
62
|
+
expect(out.row2Value).toBeNull();
|
|
63
|
+
expect(out.row3Value).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* app-header.ts — shared application header resolver_fn.
|
|
7
|
+
*
|
|
8
|
+
* Shapes an app's per-row resolver_input into the canonical header-data object
|
|
9
|
+
* the ONE shared header Puck page (`/shared-app-header`) binds through the
|
|
10
|
+
* unified page-data surface (`dataSource:"page-data"` + `fieldPath:"headerData.<key>"`).
|
|
11
|
+
* The loader row owns the mapping (title field path, label/value pairs — the
|
|
12
|
+
* per-app variation); this executor owns the shape and the two coercions the
|
|
13
|
+
* rows cannot express:
|
|
14
|
+
*
|
|
15
|
+
* - `Date` values (Prisma returns Date objects) → ISO strings, so
|
|
16
|
+
* __PAGE_DATA__ serialises them the same way the client reads them
|
|
17
|
+
* (server-serializes-client-reads-verbatim).
|
|
18
|
+
* - the literal `"today"` → a noon-UTC ISO instant of the AEST calendar date.
|
|
19
|
+
* Noon keeps the calendar date stable under every timezone offset, so the
|
|
20
|
+
* header shows the same day on the server and on any visitor's client —
|
|
21
|
+
* the deterministic replacement for the old `format:"today"` clock block.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** AEST calendar date (the operator timezone) as `YYYY-MM-DD`. */
|
|
25
|
+
function aestCalendarDate(now: Date): string {
|
|
26
|
+
return new Intl.DateTimeFormat("en-CA", {
|
|
27
|
+
timeZone: "Australia/Sydney",
|
|
28
|
+
year: "numeric",
|
|
29
|
+
month: "2-digit",
|
|
30
|
+
day: "2-digit",
|
|
31
|
+
}).format(now);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function coerceValue(value: unknown): string | null {
|
|
35
|
+
if (value == null) return null;
|
|
36
|
+
if (value instanceof Date) return value.toISOString();
|
|
37
|
+
if (value === "today") return `${aestCalendarDate(new Date())}T12:00:00.000Z`;
|
|
38
|
+
return String(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AppHeaderInput {
|
|
42
|
+
title?: unknown;
|
|
43
|
+
row1Label?: unknown;
|
|
44
|
+
row1Value?: unknown;
|
|
45
|
+
row2Label?: unknown;
|
|
46
|
+
row2Value?: unknown;
|
|
47
|
+
row3Label?: unknown;
|
|
48
|
+
row3Value?: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AppHeaderData {
|
|
52
|
+
title: string | null;
|
|
53
|
+
row1Label: string | null;
|
|
54
|
+
row1Value: string | null;
|
|
55
|
+
row2Label: string | null;
|
|
56
|
+
row2Value: string | null;
|
|
57
|
+
row3Label: string | null;
|
|
58
|
+
row3Value: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveAppHeader({
|
|
62
|
+
input,
|
|
63
|
+
}: {
|
|
64
|
+
input: AppHeaderInput;
|
|
65
|
+
client: unknown;
|
|
66
|
+
params: Record<string, unknown>;
|
|
67
|
+
}): AppHeaderData {
|
|
68
|
+
return {
|
|
69
|
+
title: coerceValue(input.title),
|
|
70
|
+
row1Label: coerceValue(input.row1Label),
|
|
71
|
+
row1Value: coerceValue(input.row1Value),
|
|
72
|
+
row2Label: coerceValue(input.row2Label),
|
|
73
|
+
row2Value: coerceValue(input.row2Value),
|
|
74
|
+
row3Label: coerceValue(input.row3Label),
|
|
75
|
+
row3Value: coerceValue(input.row3Value),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* relay never started (2026-06-29).
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import type { ServiceInstallConfig } from "
|
|
18
|
+
import type { ServiceInstallConfig } from "./load-service-install-row";
|
|
19
19
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
20
20
|
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
21
21
|
import type { BootHookHandler, MountRegistry } from "@teamscala/os/runtime-contracts/mount-context";
|
package/src/run-boot-hooks.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* top-level boot-error handler.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import type { ServiceInstallConfig } from "
|
|
24
|
+
import type { ServiceInstallConfig } from "./load-service-install-row";
|
|
25
25
|
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
26
26
|
|
|
27
27
|
export interface BootHookContext {
|
package/src/types.ts
CHANGED
package/src/ws-boot-self-test.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* reports healthy.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import type { ServiceInstallConfig } from "
|
|
19
|
+
import type { ServiceInstallConfig } from "./load-service-install-row";
|
|
20
20
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
21
21
|
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
22
22
|
|