@teamscala/service-boot 0.23.535 → 0.23.537
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 +306 -305
- package/src/agreement-gate/signing-session.ts +1 -1
- package/src/apply-logger-config.ts +2 -1
- package/src/build-client-assets/hydrate-bundle.ts +3 -2
- package/src/build-client-assets/manifest.ts +0 -38
- package/src/build-client-assets/sourcemaps-upload.test.ts +2 -4
- package/src/build-client-assets/sourcemaps-upload.ts +244 -138
- package/src/build-client-assets/styles-build.ts +2 -2
- package/src/build-client-assets.ts +9 -46
- package/src/load-service-install-row.ts +1 -1
- package/src/mount-server-packages.ts +6 -12
- package/src/orpc-contract-rows.json +34 -17
- package/src/orpc-contract-rows.test.ts +1450 -0
- package/src/pg-boss-mount.ts +124 -15
- package/src/read-html-template.ts +1 -25
- package/src/register-page-server-client.ts +2 -2
- package/src/register-registry-page-route/live-loader-config.test.ts +0 -12
- package/src/register-registry-page-route/live-loader-config.ts +1 -1
- package/src/register-registry-page-route/resolve-request-graph.ts +1 -1
- package/src/register-registry-page-route.ts +3 -3
- package/src/two-phase-full-app.ts +3 -3
- package/src/ui-service.ts +11 -36
- package/src/build-client-assets/source-candidates.test.ts +0 -60
- package/src/build-client-assets/source-candidates.ts +0 -76
- package/src/orpc-openapi-gate.test.ts +0 -751
package/src/pg-boss-mount.ts
CHANGED
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { ScheduledJobConfig } from "@teamscala/
|
|
7
|
-
import { validateRow } from "@teamscala/
|
|
6
|
+
import type { ScheduledJobConfig } from "@teamscala/registry-schemas/registry-schemas/scheduled-job";
|
|
7
|
+
import { validateRow } from "@teamscala/row-validation/validate-row";
|
|
8
8
|
import { pgSslCompatUrl } from "@teamscala/db/provider/pg-ssl-compat";
|
|
9
9
|
import { getAllEntries } from "@teamscala/db/registry/accessors";
|
|
10
10
|
import type { RegistryEntry } from "@teamscala/db/registry/types";
|
|
11
11
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
12
12
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
13
|
+
import { getPrisma } from "@teamscala/db/prisma-registry";
|
|
13
14
|
import type { JobHandler } from "@teamscala/os/runtime-contracts/mount-context";
|
|
14
15
|
import type { ScheduleOptions } from "pg-boss";
|
|
15
16
|
import { PgBoss } from "pg-boss";
|
|
@@ -77,6 +78,98 @@ async function resolveHandler(
|
|
|
77
78
|
return await handler({ config, jobData, sendJob: (queue, data, options) => boss.send(queue, data, options) });
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
async function recordFire(jobSlug: string, queueName: string): Promise<string | null> {
|
|
82
|
+
try {
|
|
83
|
+
const row = await getPrisma().scheduledJobRuns.create({
|
|
84
|
+
data: { job_slug: jobSlug, queue_name: queueName, status: "active", attempt: 1 },
|
|
85
|
+
});
|
|
86
|
+
return String(row.id);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
getAppLogger().warn(`[pg-boss-mount:${jobSlug}] scheduled_job_runs fire write failed`, {
|
|
89
|
+
error: err instanceof Error ? err.message : String(err),
|
|
90
|
+
});
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function recordOutcome(
|
|
96
|
+
runId: string | null,
|
|
97
|
+
jobSlug: string,
|
|
98
|
+
status: "completed" | "error",
|
|
99
|
+
error: string | null,
|
|
100
|
+
result: unknown,
|
|
101
|
+
): Promise<void> {
|
|
102
|
+
if (runId === null) return;
|
|
103
|
+
try {
|
|
104
|
+
await getPrisma().scheduledJobRuns.update({
|
|
105
|
+
where: { id: runId },
|
|
106
|
+
data: { status, completed_at: new Date(), error, result },
|
|
107
|
+
});
|
|
108
|
+
} catch (err) {
|
|
109
|
+
getAppLogger().warn(`[pg-boss-mount:${jobSlug}] scheduled_job_runs outcome write failed`, {
|
|
110
|
+
error: err instanceof Error ? err.message : String(err),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function expirePriorProcessRuns(slugs: string[]): Promise<void> {
|
|
116
|
+
for (const slug of slugs) {
|
|
117
|
+
try {
|
|
118
|
+
await getPrisma().scheduledJobRuns.updateMany({
|
|
119
|
+
where: { job_slug: slug, status: "active" },
|
|
120
|
+
data: {
|
|
121
|
+
status: "expired",
|
|
122
|
+
completed_at: new Date(),
|
|
123
|
+
error: "owning service process exited before writing an outcome",
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
} catch (err) {
|
|
127
|
+
getAppLogger().warn(`[pg-boss-mount:${slug}] scheduled_job_runs prior-process expiry failed`, {
|
|
128
|
+
error: err instanceof Error ? err.message : String(err),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const SINGLETON_POLICY = "singleton";
|
|
135
|
+
|
|
136
|
+
async function scheduleRow(
|
|
137
|
+
boss: PgBoss,
|
|
138
|
+
slug: string,
|
|
139
|
+
config: ScheduledJobConfig,
|
|
140
|
+
cron: string,
|
|
141
|
+
scheduleOpts: ScheduleOptions,
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
const queueName = config.queue_name;
|
|
144
|
+
if (!config.singleton) {
|
|
145
|
+
await boss.schedule(queueName, cron, null, scheduleOpts);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
scheduleOpts.singletonKey = slug;
|
|
150
|
+
|
|
151
|
+
const [before] = await boss.getQueueStats(queueName);
|
|
152
|
+
const staleQueued = before?.queuedCount ?? 0;
|
|
153
|
+
if (staleQueued > 0) {
|
|
154
|
+
await boss.deleteQueuedJobs(queueName);
|
|
155
|
+
const [after] = await boss.getQueueStats(queueName);
|
|
156
|
+
const drained = staleQueued - (after?.queuedCount ?? staleQueued);
|
|
157
|
+
recordEvent({
|
|
158
|
+
kind: after?.queuedCount === 0 ? "pgboss.queue.backlog-drained" : "pgboss.queue.backlog-drain-failed",
|
|
159
|
+
payload: {
|
|
160
|
+
queue: queueName,
|
|
161
|
+
slug,
|
|
162
|
+
stale_queued: staleQueued,
|
|
163
|
+
drained,
|
|
164
|
+
remaining: after?.queuedCount ?? null,
|
|
165
|
+
reason: "singleton-subscription-before-backlog-drain",
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await boss.schedule(queueName, cron, null, scheduleOpts);
|
|
171
|
+
}
|
|
172
|
+
|
|
80
173
|
export async function mountPgBoss(
|
|
81
174
|
ctx: PgBossMountContext,
|
|
82
175
|
): Promise<PgBossMountResult> {
|
|
@@ -93,6 +186,7 @@ export async function mountPgBoss(
|
|
|
93
186
|
|
|
94
187
|
const log = getAppLogger();
|
|
95
188
|
const jobs: MountedJob[] = [];
|
|
189
|
+
await expirePriorProcessRuns(scheduledEntries.map((e) => e.slug));
|
|
96
190
|
|
|
97
191
|
const boss = new PgBoss({
|
|
98
192
|
useListenNotify: false,
|
|
@@ -143,11 +237,19 @@ export async function mountPgBoss(
|
|
|
143
237
|
const queueName = config.queue_name;
|
|
144
238
|
|
|
145
239
|
try {
|
|
146
|
-
await boss.createQueue(queueName);
|
|
240
|
+
await boss.createQueue(queueName, { policy: config.singleton ? SINGLETON_POLICY : undefined });
|
|
147
241
|
await boss.work(queueName, async (jobs) => {
|
|
148
242
|
let result: unknown;
|
|
149
243
|
for (const job of jobs) {
|
|
150
|
-
|
|
244
|
+
const runId = await recordFire(entry.slug, queueName);
|
|
245
|
+
try {
|
|
246
|
+
result = await resolveHandler(boss, ctx.jobHandlers, config.handler_slug, config, job.data);
|
|
247
|
+
await recordOutcome(runId, entry.slug, "completed", null, result);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
const outcomeError = err instanceof Error ? err.message : String(err);
|
|
250
|
+
await recordOutcome(runId, entry.slug, "error", outcomeError, null);
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
151
253
|
}
|
|
152
254
|
return jobs.length === 1 ? result : undefined;
|
|
153
255
|
});
|
|
@@ -159,7 +261,7 @@ export async function mountPgBoss(
|
|
|
159
261
|
scheduleOpts.retryDelay = config.retry.backoff_seconds;
|
|
160
262
|
scheduleOpts.retryBackoff = true;
|
|
161
263
|
}
|
|
162
|
-
await boss.
|
|
264
|
+
await scheduleRow(boss, entry.slug, config, config.cron, scheduleOpts);
|
|
163
265
|
jobs.push({
|
|
164
266
|
queueName,
|
|
165
267
|
handlerSlug: config.handler_slug,
|
|
@@ -173,7 +275,7 @@ export async function mountPgBoss(
|
|
|
173
275
|
scheduleOpts.retryDelay = config.retry.backoff_seconds;
|
|
174
276
|
scheduleOpts.retryBackoff = true;
|
|
175
277
|
}
|
|
176
|
-
await boss.
|
|
278
|
+
await scheduleRow(boss, entry.slug, config, cron, scheduleOpts);
|
|
177
279
|
jobs.push({
|
|
178
280
|
queueName,
|
|
179
281
|
handlerSlug: config.handler_slug,
|
|
@@ -214,12 +316,14 @@ export async function mountPgBoss(
|
|
|
214
316
|
|
|
215
317
|
try {
|
|
216
318
|
const backedScheduleNames = new Set(
|
|
217
|
-
allEntries
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
319
|
+
allEntries
|
|
320
|
+
.filter((e) => (e.type as string) === "scheduled_job" && e.is_active)
|
|
321
|
+
.flatMap((e) => {
|
|
322
|
+
const cfg = e.config as { queue_name?: string } | null;
|
|
323
|
+
return [e.slug, cfg?.queue_name].filter(
|
|
324
|
+
(n): n is string => typeof n === "string" && n.length > 0,
|
|
325
|
+
);
|
|
326
|
+
}),
|
|
223
327
|
);
|
|
224
328
|
const liveSchedules = await boss.getSchedules();
|
|
225
329
|
for (const sched of liveSchedules) {
|
|
@@ -227,13 +331,18 @@ export async function mountPgBoss(
|
|
|
227
331
|
log.warn(
|
|
228
332
|
`[pg-boss:${ctx.serviceSlug}] unscheduling orphaned cron "${sched.name}" (no active registry row backs it — drift)`,
|
|
229
333
|
);
|
|
230
|
-
await boss.unschedule(sched.name);
|
|
334
|
+
await boss.unschedule(sched.name, sched.key);
|
|
335
|
+
const gone = await boss.getSchedule(sched.name, sched.key);
|
|
231
336
|
recordEvent({
|
|
232
|
-
kind: "pgboss.schedule.unscheduled",
|
|
337
|
+
kind: gone === null ? "pgboss.schedule.unscheduled" : "pgboss.schedule.unschedule-failed",
|
|
233
338
|
payload: {
|
|
234
339
|
service: ctx.serviceSlug,
|
|
235
340
|
name: sched.name,
|
|
236
|
-
|
|
341
|
+
key: sched.key,
|
|
342
|
+
reason:
|
|
343
|
+
gone === null
|
|
344
|
+
? "orphaned-no-active-backing-row"
|
|
345
|
+
: "unschedule-readback-row-still-present",
|
|
237
346
|
},
|
|
238
347
|
});
|
|
239
348
|
}
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { createCache } from "@teamscala/cache/create-cache";
|
|
7
|
-
import { readClientBuildStatus } from "./build-client-assets/manifest.ts";
|
|
8
7
|
import { themeFontsLink } from "./theme-fonts-link.ts";
|
|
9
8
|
import { join } from "node:path";
|
|
10
9
|
|
|
@@ -54,11 +53,6 @@ async function manifestMtimeMs(): Promise<number> {
|
|
|
54
53
|
}
|
|
55
54
|
|
|
56
55
|
async function loadManifest(): Promise<AssetManifest | null> {
|
|
57
|
-
// Pure read: this function must NOT throw on a missing or unreadable
|
|
58
|
-
// manifest, and must NOT depend on the app logger (other suites mock it
|
|
59
|
-
// with a partial shape). The gate's `boot.client-build-failed` event is
|
|
60
|
-
// the single home for the diagnostic — adding a warn here duplicates it
|
|
61
|
-
// and risks a missing method on a mocked logger.
|
|
62
56
|
const path = manifestPath();
|
|
63
57
|
if (!(await Bun.file(path).exists())) return null;
|
|
64
58
|
try {
|
|
@@ -67,15 +61,12 @@ async function loadManifest(): Promise<AssetManifest | null> {
|
|
|
67
61
|
return { stylesPath: parsed.stylesPath, hydratePath: parsed.hydratePath };
|
|
68
62
|
}
|
|
69
63
|
} catch {
|
|
70
|
-
|
|
64
|
+
return null;
|
|
71
65
|
}
|
|
72
66
|
return null;
|
|
73
67
|
}
|
|
74
68
|
|
|
75
69
|
function manifestCacheKey(): string {
|
|
76
|
-
// Keying by the manifest path means two testdirs (or two concurrent
|
|
77
|
-
// services in one process, e.g. a fleet supervisor probe) cannot poison
|
|
78
|
-
// each other's cache — absent and present manifests carry different paths.
|
|
79
70
|
return manifestPath();
|
|
80
71
|
}
|
|
81
72
|
|
|
@@ -94,22 +85,7 @@ export async function readHtmlTemplate(): Promise<string> {
|
|
|
94
85
|
let html = PAGE_DOCUMENT
|
|
95
86
|
.replace(/\{\{stylesPath\}\}/g, manifest.stylesPath)
|
|
96
87
|
.replace(/\{\{hydratePath\}\}/g, manifest.hydratePath)
|
|
97
|
-
// The head's font families derive from the css_theme row (one source);
|
|
98
|
-
// captured at cache build — boot-time semantics, a row edit reaches the
|
|
99
|
-
// envelope on the next service restart.
|
|
100
88
|
.replace("<!--theme-fonts-->", () => themeFontsLink());
|
|
101
|
-
// Tombstone stamp — surfaces a failed client build at the served layer.
|
|
102
|
-
// `<meta>` in <head> is the minimum-invasive vehicle (no layout shift, no
|
|
103
|
-
// JS dependency, visible via view-source / devtools / a future status
|
|
104
|
-
// probe). `x-` prefix keeps it out of any standard meta namespace.
|
|
105
|
-
const status = await readClientBuildStatus();
|
|
106
|
-
if (status.failed) {
|
|
107
|
-
const reason = (status.reason ?? "unknown").replace(/"/g, "'").slice(0, 200);
|
|
108
|
-
const meta = `<meta name="x-client-build-status" content="failed" data-reason="${reason}" data-at="${status.at ?? ""}">`;
|
|
109
|
-
html = html.includes("</head>")
|
|
110
|
-
? html.replace("</head>", `${meta}</head>`)
|
|
111
|
-
: `${meta}${html}`;
|
|
112
|
-
}
|
|
113
89
|
htmlCache.set(manifestCacheKey(), { html, manifestMtime });
|
|
114
90
|
return html;
|
|
115
91
|
}
|
|
@@ -45,7 +45,7 @@ export async function registerPageServerClient(
|
|
|
45
45
|
"@teamscala/db/registry/errors"
|
|
46
46
|
);
|
|
47
47
|
const { ServiceSchemaConfigSchema } = await import(
|
|
48
|
-
"@teamscala/
|
|
48
|
+
"@teamscala/registry-schemas/registry-schemas/service-schema"
|
|
49
49
|
);
|
|
50
50
|
try {
|
|
51
51
|
const row = await loadOptionalRegistryConfig(
|
|
@@ -86,7 +86,7 @@ export async function registerPageServerClient(
|
|
|
86
86
|
"@teamscala/db/registry/load-config"
|
|
87
87
|
);
|
|
88
88
|
const { PlatformIdentitySchema } = await import(
|
|
89
|
-
"@teamscala/
|
|
89
|
+
"@teamscala/registry-schemas/registry-schemas/platform-identity"
|
|
90
90
|
);
|
|
91
91
|
const platformIdentity = await loadRegistryConfig(
|
|
92
92
|
"config",
|
|
@@ -100,7 +100,6 @@ mock.module("@teamscala/logger/app-loggers", () => ({
|
|
|
100
100
|
const { registerRegistryPageRoute } = await import(
|
|
101
101
|
"../register-registry-page-route.ts"
|
|
102
102
|
);
|
|
103
|
-
const { fingerprintLoaderConfig } = await import("./live-loader-config.ts");
|
|
104
103
|
const { Hono } = await import("hono");
|
|
105
104
|
|
|
106
105
|
afterEach(() => {
|
|
@@ -189,15 +188,4 @@ describe("live loader config — per-request read (boot-baked-loader fix)", () =
|
|
|
189
188
|
const res = await requestRoute(app);
|
|
190
189
|
expect(res.status).toBe(500);
|
|
191
190
|
});
|
|
192
|
-
|
|
193
|
-
test("the graphKey fingerprint changes with the config and is key-order stable", () => {
|
|
194
|
-
const a = fingerprintLoaderConfig({ sources: [{ key: "page" }] });
|
|
195
|
-
const b = fingerprintLoaderConfig({
|
|
196
|
-
sources: [{ key: "page" }, { key: "agreement" }],
|
|
197
|
-
});
|
|
198
|
-
expect(a).not.toBe(b);
|
|
199
|
-
expect(fingerprintLoaderConfig({ a: 1, b: 2 })).toBe(
|
|
200
|
-
fingerprintLoaderConfig({ b: 2, a: 1 }),
|
|
201
|
-
);
|
|
202
|
-
});
|
|
203
191
|
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
7
|
-
import { ResourceLoaderConfigSchema } from "@teamscala/
|
|
7
|
+
import { ResourceLoaderConfigSchema } from "@teamscala/registry-schemas/registry-schemas/resource-loader";
|
|
8
8
|
import type { ResourceLoaderKey } from "@teamscala/db/registry/generated/resource-loader";
|
|
9
9
|
import * as v from "valibot";
|
|
10
10
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { PageRouteConfig } from "../register-registry-page-route.ts";
|
|
7
7
|
import * as v from "valibot";
|
|
8
|
-
import type { ResourceLoaderConfigSchema } from "@teamscala/
|
|
8
|
+
import type { ResourceLoaderConfigSchema } from "@teamscala/registry-schemas/registry-schemas/resource-loader";
|
|
9
9
|
import { resolveFromLoaderConfig } from "../page-server-loader-graph.ts";
|
|
10
10
|
import { decideContentPageFloor } from "../content-page-floor.ts";
|
|
11
11
|
import { PrimaryResourceMissingError } from "../resource-not-found.ts";
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import type { InferOutput } from "valibot";
|
|
7
7
|
import type { RenderModeLiteral } from "./page-route-shell-types.ts";
|
|
8
|
-
import { PageRouteConfigSchema } from "@teamscala/
|
|
9
|
-
import { ResourceLoaderConfigSchema } from "@teamscala/
|
|
10
|
-
import { FeatureFlagConfigSchema } from "@teamscala/
|
|
8
|
+
import { PageRouteConfigSchema } from "@teamscala/registry-schemas/registry-schemas/page-route";
|
|
9
|
+
import { ResourceLoaderConfigSchema } from "@teamscala/registry-schemas/registry-schemas/resource-loader";
|
|
10
|
+
import { FeatureFlagConfigSchema } from "@teamscala/registry-schemas/registry-schemas/feature-flag";
|
|
11
11
|
import {
|
|
12
12
|
loadRegistryConfig,
|
|
13
13
|
loadRegistryConfigsByType,
|
|
@@ -35,7 +35,7 @@ export async function buildFullApp(
|
|
|
35
35
|
log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets DONE`);
|
|
36
36
|
} catch (error) {
|
|
37
37
|
log.error(
|
|
38
|
-
`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets FAILED — continuing to scheduled jobs and pg-boss
|
|
38
|
+
`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets FAILED — continuing to scheduled jobs and pg-boss; the verification gate below then refuses readiness`,
|
|
39
39
|
{ error: error instanceof Error ? error.message : String(error) },
|
|
40
40
|
);
|
|
41
41
|
}
|
|
@@ -94,9 +94,9 @@ export async function buildFullApp(
|
|
|
94
94
|
}));
|
|
95
95
|
|
|
96
96
|
return {
|
|
97
|
-
app: minimalApp,
|
|
97
|
+
app: minimalApp,
|
|
98
98
|
blocks: resolvedBlocks,
|
|
99
|
-
routes: [],
|
|
99
|
+
routes: [],
|
|
100
100
|
pgBossJobs,
|
|
101
101
|
};
|
|
102
102
|
}
|
package/src/ui-service.ts
CHANGED
|
@@ -3,23 +3,7 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
* Boots a UI service using two-phase boot for fast availability.
|
|
8
|
-
*
|
|
9
|
-
* Phase 1 (fast): Build and serve minimal tool-only app (ORPC, data routes,
|
|
10
|
-
* auth gates). This becomes available within ~10s, enabling the MCP tool
|
|
11
|
-
* surface to accept requests immediately after restart.
|
|
12
|
-
*
|
|
13
|
-
* Phase 2 (background): Build full app (pages, hydration) and atomically swap
|
|
14
|
-
* it in. The readiness gate flips to ready after Phase 1, not Phase 2.
|
|
15
|
-
*
|
|
16
|
-
* The interim mitigation (503 + gateway retry + circuit breaker) remains in
|
|
17
|
-
* place and is NOT undone.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
6
|
+
import { recordEventDurable } from "@teamscala/event-log/record-event";
|
|
23
7
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
24
8
|
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
25
9
|
import { Hono } from "hono";
|
|
@@ -43,17 +27,9 @@ export interface UiServiceBootResult {
|
|
|
43
27
|
app: Hono;
|
|
44
28
|
}
|
|
45
29
|
|
|
46
|
-
/**
|
|
47
|
-
* Page-server symbols injected by the caller. service-boot is tier 4
|
|
48
|
-
* (server) and must not import from tier 2 (`@teamscala/service-runtime`)
|
|
49
|
-
* per `installer-and-runtime` Rule 1. The runtime's bin entrypoint
|
|
50
|
-
* boot pipeline imports these from `page-server/` (same package —
|
|
51
|
-
* legal) and passes them down here.
|
|
52
|
-
*/
|
|
53
30
|
export interface PageServerDeps {
|
|
54
31
|
createPageHandler: import("./types.ts").CreatePageHandler;
|
|
55
32
|
upgradeWebSocket?: UpgradeWebSocket;
|
|
56
|
-
/** Shared Prisma client threaded to mounts via ctx.getSharedDb (optional). */
|
|
57
33
|
getSharedDb?: () => unknown;
|
|
58
34
|
}
|
|
59
35
|
|
|
@@ -129,25 +105,24 @@ export async function bootUiService(
|
|
|
129
105
|
`[ui-service-boot:${serviceSlug}] Full app swapped in after ${fullDuration}ms (${fullDuration - minimalDuration}ms after minimal app)`,
|
|
130
106
|
);
|
|
131
107
|
} catch (err) {
|
|
132
|
-
const
|
|
108
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
133
109
|
log.error(
|
|
134
|
-
`[ui-service-boot:${serviceSlug}]
|
|
110
|
+
`[ui-service-boot:${serviceSlug}] Phase-2 boot failed — exiting so the supervisor restarts a service that serves a fresh client build: ${message}`,
|
|
135
111
|
);
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
at: new Date().toISOString(),
|
|
142
|
-
},
|
|
112
|
+
await recordEventDurable({
|
|
113
|
+
id: crypto.randomUUID(),
|
|
114
|
+
kind: "service.boot.failed",
|
|
115
|
+
payload: { service_slug: serviceSlug, phase: "phase-2", message: message.slice(0, 2000) },
|
|
116
|
+
occurredAt: new Date(),
|
|
143
117
|
});
|
|
118
|
+
process.exit(1);
|
|
144
119
|
}
|
|
145
120
|
})();
|
|
146
121
|
|
|
147
122
|
return {
|
|
148
123
|
kind: "ui",
|
|
149
|
-
blocks: [],
|
|
150
|
-
routes: [],
|
|
124
|
+
blocks: [],
|
|
125
|
+
routes: [],
|
|
151
126
|
port,
|
|
152
127
|
app: minimalAppResult.app,
|
|
153
128
|
};
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
// @system codegen
|
|
2
|
-
// @status generated
|
|
3
|
-
// @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
|
|
4
|
-
//
|
|
5
|
-
// This suite's assertions are OWNED by the codegen band: the band module
|
|
6
|
-
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
|
-
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
|
-
// with it into the band.
|
|
9
|
-
|
|
10
|
-
import { describe, expect, test } from "bun:test";
|
|
11
|
-
import { mkdir, rm } from "node:fs/promises";
|
|
12
|
-
import { join } from "node:path";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
14
|
-
import { scanSourceCandidates } from "./source-candidates.ts";
|
|
15
|
-
|
|
16
|
-
const noopLog = { warn: () => {}, info: () => {}, error: () => {} } as never;
|
|
17
|
-
|
|
18
|
-
async function makeSourceTree(tag: string, files: Record<string, string>) {
|
|
19
|
-
const root = join(tmpdir(), `source-candidates-${tag}-${process.pid}`);
|
|
20
|
-
await rm(root, { recursive: true, force: true });
|
|
21
|
-
for (const [rel, content] of Object.entries(files)) {
|
|
22
|
-
const p = join(root, rel);
|
|
23
|
-
await mkdir(p.slice(0, p.lastIndexOf("/")), { recursive: true });
|
|
24
|
-
await Bun.file(p).write(content);
|
|
25
|
-
}
|
|
26
|
-
return root;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
describe("scanSourceCandidates", () => {
|
|
30
|
-
test("scans a DIRECTORY source and extracts utility candidates", async () => {
|
|
31
|
-
const root = await makeSourceTree("dir-src", {
|
|
32
|
-
"src/Button.tsx": 'export const k = "h-12 px-6 text-base rounded-md";',
|
|
33
|
-
});
|
|
34
|
-
const candidates = await scanSourceCandidates(
|
|
35
|
-
[{ base: root, pattern: "src" }],
|
|
36
|
-
noopLog,
|
|
37
|
-
);
|
|
38
|
-
expect(candidates).toContain("h-12");
|
|
39
|
-
expect(candidates).toContain("px-6");
|
|
40
|
-
expect(candidates).toContain("text-base");
|
|
41
|
-
expect(candidates).toContain("rounded-md");
|
|
42
|
-
await rm(root, { recursive: true, force: true });
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("skips an absent source silently instead of zeroing the scan", async () => {
|
|
46
|
-
const root = await makeSourceTree("absent-src", {
|
|
47
|
-
"src/Keep.tsx": "flex gap-2",
|
|
48
|
-
});
|
|
49
|
-
const candidates = await scanSourceCandidates(
|
|
50
|
-
[
|
|
51
|
-
{ base: root, pattern: "src" },
|
|
52
|
-
{ base: root, pattern: "does-not-exist" },
|
|
53
|
-
],
|
|
54
|
-
noopLog,
|
|
55
|
-
);
|
|
56
|
-
expect(candidates).toContain("flex");
|
|
57
|
-
expect(candidates).toContain("gap-2");
|
|
58
|
-
await rm(root, { recursive: true, force: true });
|
|
59
|
-
});
|
|
60
|
-
});
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { resolve } from "node:path";
|
|
7
|
-
import { stat } from "node:fs/promises";
|
|
8
|
-
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
9
|
-
|
|
10
|
-
async function existsStat(path: string): Promise<boolean> {
|
|
11
|
-
try {
|
|
12
|
-
await stat(path);
|
|
13
|
-
return true;
|
|
14
|
-
} catch {
|
|
15
|
-
return false;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const SCAN_EXTS = new Set([
|
|
20
|
-
".ts",
|
|
21
|
-
".tsx",
|
|
22
|
-
".js",
|
|
23
|
-
".jsx",
|
|
24
|
-
".mjs",
|
|
25
|
-
".cjs",
|
|
26
|
-
".html",
|
|
27
|
-
".md",
|
|
28
|
-
".mdx",
|
|
29
|
-
]);
|
|
30
|
-
const CANDIDATE_RE = /[A-Za-z0-9_:/[\].%#!,&-]+/g;
|
|
31
|
-
|
|
32
|
-
export async function scanSourceCandidates(
|
|
33
|
-
sources: Array<{ base: string; pattern: string; negated?: boolean }>,
|
|
34
|
-
log: ReturnType<typeof getAppLogger>,
|
|
35
|
-
): Promise<string[]> {
|
|
36
|
-
const candidates = new Set<string>();
|
|
37
|
-
for (const src of sources) {
|
|
38
|
-
if (src.negated) continue;
|
|
39
|
-
let root: string;
|
|
40
|
-
try {
|
|
41
|
-
root = resolve(src.base, src.pattern);
|
|
42
|
-
} catch {
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
if (!await existsStat(root)) continue;
|
|
46
|
-
const rootInNodeModules = root.includes("/node_modules/");
|
|
47
|
-
try {
|
|
48
|
-
const glob = new Bun.Glob("**/*");
|
|
49
|
-
for await (const file of glob.scan({
|
|
50
|
-
cwd: root,
|
|
51
|
-
absolute: true,
|
|
52
|
-
onlyFiles: true,
|
|
53
|
-
})) {
|
|
54
|
-
if (!rootInNodeModules && file.includes("/node_modules/")) continue;
|
|
55
|
-
if (file.includes("/dist/") || file.endsWith(".d.ts")) continue;
|
|
56
|
-
const dot = file.lastIndexOf(".");
|
|
57
|
-
if (dot < 0 || !SCAN_EXTS.has(file.slice(dot))) continue;
|
|
58
|
-
let text: string;
|
|
59
|
-
try {
|
|
60
|
-
text = await Bun.file(file).text();
|
|
61
|
-
} catch {
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
const matched = text.match(CANDIDATE_RE);
|
|
65
|
-
if (matched) for (const t of matched) candidates.add(t);
|
|
66
|
-
}
|
|
67
|
-
} catch (err) {
|
|
68
|
-
log.warn(
|
|
69
|
-
`[build-client-assets] candidate scan skipped ${root}: ${
|
|
70
|
-
err instanceof Error ? err.message : String(err)
|
|
71
|
-
}`,
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
return [...candidates];
|
|
76
|
-
}
|