@teamscala/service-boot 0.23.287 → 0.23.289

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.287",
9
+ "version": "0.23.289",
10
10
  "sideEffects": false,
11
11
  "files": [
12
12
  "src"
@@ -225,6 +225,28 @@ export async function mountPgBoss(
225
225
  handlerSlug: config.handler_slug,
226
226
  });
227
227
  }
228
+
229
+ // A SUCCESSFUL mount emitted nothing until now, while every failure
230
+ // path above recorded an event — so the one state you most need to
231
+ // confirm ("is this job actually scheduled on this service?") was the
232
+ // only one with no evidence. Combined with resolveHandler THROWING
233
+ // into boss.work() rather than warning, that made "never scheduled"
234
+ // and "fired and threw" externally identical, and cost a
235
+ // scheduled_job relocation that had to be reverted undiagnosed on
236
+ // 2026-07-30. Emitting on success makes the mount set queryable
237
+ // (event-log-is-the-only-durable-event-log).
238
+ const mounted = jobs[jobs.length - 1];
239
+ recordEvent({
240
+ kind: "pgboss.job.mounted",
241
+ payload: {
242
+ service: ctx.serviceSlug,
243
+ slug: entry.slug,
244
+ queue: queueName,
245
+ handler: config.handler_slug,
246
+ cron: mounted?.cron ?? null,
247
+ interval_seconds: mounted?.intervalSeconds ?? null,
248
+ },
249
+ });
228
250
  } catch (err) {
229
251
  // crash-isolation for the mount path: a work()/schedule() failure
230
252
  // (e.g. DB not open, queue conflict) degrades to "this job unavailable"
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Phase-2 step isolation: a client-asset build failure must not prevent
7
+ * scheduled jobs and pg-boss from mounting.
8
+ *
9
+ * Asserted against the SOURCE rather than by booting a service, because the
10
+ * behaviour is an ordering/error-handling property of buildFullApp and booting
11
+ * it here would need a database, a registry, a Vite build and a live pg-boss.
12
+ * The property is structural: buildClientAssets must be inside a try/catch that
13
+ * appears BEFORE the startScheduledJobs and mountPgBoss calls. That is exactly
14
+ * what regressed, and a source assertion fails the moment someone re-awaits it
15
+ * unguarded — which is the regression this locks.
16
+ */
17
+
18
+ import { describe, expect, test } from "bun:test";
19
+
20
+ const src = await Bun.file(new URL("./two-phase-boot.ts", import.meta.url)).text();
21
+
22
+ /** The body of buildFullApp, where phase-2 ordering lives. */
23
+ function buildFullAppBody(): string {
24
+ const start = src.indexOf("export async function buildFullApp");
25
+ expect(start).toBeGreaterThan(-1);
26
+ return src.slice(start);
27
+ }
28
+
29
+ describe("phase-2 step isolation", () => {
30
+ /*
31
+ * THE MEASURED REGRESSION. buildClientAssets was awaited unguarded, so a
32
+ * throw aborted buildFullApp before startScheduledJobs and mountPgBoss ran.
33
+ * The caller's catch logged "Failed to build full app, continuing with
34
+ * minimal app", which reads as UI-only degradation while every scheduled job
35
+ * and pg-boss handler on the service had silently died.
36
+ *
37
+ * 2026-07-30: a stale portal-ui artifact importing the retired @teamgbg scope
38
+ * threw ENOENT on a missing theme CSS file in scala-agents. No
39
+ * "scheduledJobs DONE" line appeared for ~20 minutes across repeated boots,
40
+ * the pane-outbox drain never mounted, and the operator's WhatsApp assistant
41
+ * went silent — because of a stylesheet. The service reported `online` and
42
+ * answered /health throughout.
43
+ */
44
+ test("buildClientAssets is wrapped so its failure cannot abort phase-2", () => {
45
+ const body = buildFullAppBody();
46
+ const call = body.indexOf("buildClientAssets(serviceSlug)");
47
+ expect(call).toBeGreaterThan(-1);
48
+
49
+ // A try must open before the call and a catch must follow it, so a throw
50
+ // is contained rather than propagating out of buildFullApp.
51
+ const tryBefore = body.lastIndexOf("try {", call);
52
+ expect(tryBefore).toBeGreaterThan(-1);
53
+ expect(body.slice(call).indexOf("catch")).toBeGreaterThan(-1);
54
+ });
55
+
56
+ /*
57
+ * THE LOAD-BEARING ASSERTION. A `catch` must appear BETWEEN the build call and
58
+ * the startScheduledJobs call. That is what makes this test fail if the guard
59
+ * is removed: unwrap the await and there is no catch in that span, so the
60
+ * expectation cannot be satisfied by some unrelated try/catch elsewhere in the
61
+ * function. Ordering alone would NOT catch the regression — the original buggy
62
+ * code also had jobs after the build; it just had no catch in between.
63
+ */
64
+ test("a catch sits between the build and the jobs, so a throw cannot skip them", () => {
65
+ const body = buildFullAppBody();
66
+ const build = body.indexOf("buildClientAssets(serviceSlug)");
67
+ const jobs = body.indexOf("startScheduledJobs(");
68
+ const boss = body.indexOf("mountPgBoss(");
69
+ expect(build).toBeGreaterThan(-1);
70
+ expect(jobs).toBeGreaterThan(build);
71
+ expect(boss).toBeGreaterThan(build);
72
+
73
+ const between = body.slice(build, jobs);
74
+ expect(between).toContain("catch");
75
+ });
76
+
77
+ test("the build failure is logged, so a degraded bundle is never silent", () => {
78
+ const body = buildFullAppBody();
79
+ const guardCatch = body.indexOf("catch", body.indexOf("buildClientAssets(serviceSlug)"));
80
+ // The catch must record something; swallowing here would reproduce the
81
+ // original invisibility from the other direction.
82
+ expect(body.slice(guardCatch, guardCatch + 400)).toContain("log.error");
83
+ });
84
+ });
@@ -396,9 +396,37 @@ export async function buildFullApp(
396
396
  const log = getAppLogger();
397
397
  const { config, serviceSlug, mountRegistry, components, loaded, app: minimalApp } = minimalResult;
398
398
 
399
- // Client asset building (expensive: Vite build)
400
- await buildClientAssets(serviceSlug);
401
- log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets DONE`);
399
+ // Client asset building (expensive: Vite build).
400
+ //
401
+ // ISOLATED, because a PRESENTATION failure must not take out BACKGROUND WORK.
402
+ // This was awaited unguarded, so a throw here aborted buildFullApp before
403
+ // startScheduledJobs and mountPgBoss ran, and the caller's catch logged
404
+ // "Failed to build full app, continuing with minimal app" — which reads as a
405
+ // UI-only degradation while having silently killed every scheduled job and
406
+ // every pg-boss handler on the service.
407
+ //
408
+ // Measured 2026-07-30: a stale @teamscala/portal-ui artifact still importing
409
+ // the retired @teamgbg scope threw ENOENT on a missing theme CSS file in
410
+ // scala-agents. Zero "scheduledJobs DONE" lines appeared for ~20 minutes
411
+ // across repeated boots, the pane-outbox drain never mounted, and the
412
+ // operator's WhatsApp assistant went silent — because of a stylesheet. The
413
+ // service stayed `online` and healthy throughout, so nothing surfaced it.
414
+ //
415
+ // The steps after this one do not depend on client assets: jobs and pg-boss
416
+ // need the registry and the DB, not a bundle. Ordering them behind a Vite
417
+ // build was incidental, so the coupling was never intended.
418
+ try {
419
+ await buildClientAssets(serviceSlug);
420
+ log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets DONE`);
421
+ } catch (error) {
422
+ // Loud but non-fatal: the tombstone manifest written by buildClientAssets
423
+ // already makes SSR serve the prior bundle, so degrading here costs the
424
+ // new bundle, never the background work.
425
+ log.error(
426
+ `[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets FAILED — continuing to scheduled jobs and pg-boss (a client-asset failure must not stop background work)`,
427
+ { error: error instanceof Error ? error.message : String(error) },
428
+ );
429
+ }
402
430
 
403
431
  // Scheduled jobs
404
432
  startScheduledJobs(config, mountRegistry, serviceSlug);