@teamscala/service-boot 0.23.516 → 0.23.518

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.
Files changed (86) hide show
  1. package/package.json +232 -1127
  2. package/src/agreement-gate/settings.ts +8 -8
  3. package/src/apply-logger-config.test.ts +46 -0
  4. package/src/apply-logger-config.ts +31 -0
  5. package/src/asset-proxy.test.ts +9 -5
  6. package/src/auth/sign-out.ts +122 -0
  7. package/src/build-client-assets/hydrate-bundle.ts +14 -4
  8. package/src/build-client-assets/manifest.ts +5 -18
  9. package/src/build-client-assets/styles-build.test.ts +11 -8
  10. package/src/build-client-assets/styles-build.ts +14 -63
  11. package/src/build-client-assets.test.ts +0 -161
  12. package/src/build-client-assets.ts +20 -67
  13. package/src/content-gate/token.ts +9 -2
  14. package/src/install-logging.ts +10 -26
  15. package/src/mount-orpc-router.ts +8 -2
  16. package/src/mutable-app-ref.ts +0 -7
  17. package/src/page-server-loader-graph.test.ts +31 -0
  18. package/src/page-server-loader-graph.ts +21 -0
  19. package/src/pg-boss-mount.ts +3 -16
  20. package/src/readiness-gate.ts +0 -4
  21. package/src/register-api-routes.ts +8 -17
  22. package/src/register-page-server-client.ts +6 -0
  23. package/src/register-registry-page-route/meta-title.test.ts +50 -0
  24. package/src/register-registry-page-route/meta-title.ts +1 -1
  25. package/src/register-registry-page-route/page-data-shared.ts +7 -11
  26. package/src/register-registry-page-route/resolve-request-graph.test.ts +1 -1
  27. package/src/register-registry-page-route/resolve-request-graph.ts +1 -2
  28. package/src/register-registry-page-route.ts +14 -16
  29. package/src/resolve-brand-favicon.test.ts +63 -0
  30. package/src/resolve-theme-mode.test.ts +51 -0
  31. package/src/resolvers/fetch-org-brand.test.ts +48 -0
  32. package/src/resolvers/website-by-host-map.test.ts +63 -0
  33. package/src/resolvers/website-by-host-map.ts +15 -3
  34. package/src/run-boot-hook-list.test.ts +47 -0
  35. package/src/run-boot-hooks.test.ts +56 -0
  36. package/src/scala-tools-currency-watchdog.test.ts +43 -0
  37. package/src/scala-tools-currency-watchdog.ts +0 -5
  38. package/src/transfers/presigned-upload.ts +165 -0
  39. package/src/two-phase-minimal-app.test.ts +24 -0
  40. package/src/two-phase-readiness.test.ts +102 -0
  41. package/src/types.test.ts +20 -0
  42. package/src/ui-service.test.ts +24 -0
  43. package/src/ws-boot-self-test.test.ts +43 -0
  44. package/src/build-client-assets/input-fingerprint.ts +0 -92
  45. package/src/build-client-assets/input-hashing.ts +0 -206
  46. package/src/handlers/bun-stable-watch.ts +0 -86
  47. package/src/handlers/messaging-send-reconcile-logic.test.ts +0 -79
  48. package/src/handlers/messaging-send-reconcile-logic.ts +0 -83
  49. package/src/handlers/messaging-send-recording-logic.test.ts +0 -86
  50. package/src/handlers/messaging-send-recording-logic.ts +0 -68
  51. package/src/handlers/messaging-send-recording-watch.test.ts +0 -91
  52. package/src/handlers/messaging-send-recording-watch.ts +0 -244
  53. package/src/handlers/messaging-send-sweep-selection.test.ts +0 -47
  54. package/src/handlers/messaging-send-sweep-selection.ts +0 -35
  55. package/src/handlers/platform-fault-escalation/config.ts +0 -95
  56. package/src/handlers/platform-fault-escalation/delivery.ts +0 -327
  57. package/src/handlers/platform-fault-escalation/event-log-queries.ts +0 -182
  58. package/src/handlers/platform-fault-escalation/orpc-client.ts +0 -114
  59. package/src/handlers/platform-fault-escalation/probe.test.ts +0 -63
  60. package/src/handlers/platform-fault-escalation/probe.ts +0 -19
  61. package/src/handlers/platform-fault-escalation-logic.ts +0 -382
  62. package/src/handlers/platform-fault-escalation-refresh.test.ts +0 -72
  63. package/src/handlers/platform-fault-escalation.test.ts +0 -497
  64. package/src/handlers/platform-fault-escalation.ts +0 -246
  65. package/src/handlers/projects-digest.ts +0 -198
  66. package/src/handlers/quiz-research.ts +0 -178
  67. package/src/handlers/registry-stale-inactive-sweep.ts +0 -82
  68. package/src/handlers/scala-tools-currency-watch.test.ts +0 -65
  69. package/src/handlers/scala-tools-currency-watch.ts +0 -162
  70. package/src/handlers/singleton-tools-currency-watch.test.ts +0 -51
  71. package/src/handlers/singleton-tools-currency-watch.ts +0 -380
  72. package/src/handlers/sweep-event-log.ts +0 -98
  73. package/src/handlers/upstream-issue-sync.ts +0 -220
  74. package/src/handlers/whatsapp-liveness-handler.test.ts +0 -56
  75. package/src/handlers/whatsapp-liveness-logic.ts +0 -53
  76. package/src/handlers/whatsapp-liveness.test.ts +0 -78
  77. package/src/handlers/whatsapp-liveness.ts +0 -138
  78. package/src/interpolate-meta.ts +0 -123
  79. package/src/load-service-prisma-client.ts +0 -53
  80. package/src/loader-errors.ts +0 -41
  81. package/src/read-viewport-from-request.ts +0 -36
  82. package/src/register-page-server-client.test.ts +0 -185
  83. package/src/resolve-app-base-url.ts +0 -35
  84. package/src/resolve-from-loader-config.test.ts +0 -429
  85. package/src/resolve-from-loader-config.ts +0 -316
  86. package/src/resolve-service-context.ts +0 -88
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @status generated — ts-api-smoke template (scala-test-suites
3
+ * templates/ts_api_smoke.pkl @1.3.0) over the exports map of @teamscala/service-boot.
4
+ * ./two-phase-readiness declares 6 runtime export(s) and 8 declared behaviour vector(s);
5
+ * this suite asserts the import resolves, the runtime surface matches
6
+ * exactly, and every export carries its declared kind. Regenerate from
7
+ * inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
8
+ * overwritten.
9
+ */
10
+
11
+ import { describe, expect, it } from "bun:test";
12
+ import { mock } from "bun:test";
13
+ const __realServerClient = await import("./page-server-orpc/server-client.ts");
14
+ const __capabilityFault: { value: string | null } = { value: null };
15
+ mock.module("./page-server-orpc/server-client.ts", () => ({ ...__realServerClient, getServerClientCapabilityFault: () => __capabilityFault.value }));
16
+ const __realNotFound = await import("./resource-not-found.ts");
17
+ mock.module("./resource-not-found.ts", () => ({ ...__realNotFound, resolveErrorPageLogo: async () => "https://logo.local/light.png" }));
18
+ const __ctx = { json: (body: unknown, status?: number, headers?: Record<string, string>) => new Response(JSON.stringify(body), { status: status ?? 200, headers }) };
19
+
20
+ const mod = await import("./two-phase-readiness");
21
+
22
+ const { setPageHandler, markFullAppReady, __resetPhase2StateForTest, getPageHandlerState, healthHandler, createPhase1PageHandler } = mod;
23
+
24
+ describe("two-phase-readiness exports-map smoke", () => {
25
+ it("the runtime export set is exactly the declared surface", () => {
26
+ expect(Object.keys(mod).sort()).toEqual([
27
+ "__resetPhase2StateForTest",
28
+ "createPhase1PageHandler",
29
+ "getPageHandlerState",
30
+ "healthHandler",
31
+ "markFullAppReady",
32
+ "setPageHandler",
33
+ ]);
34
+ });
35
+ it("setPageHandler is a function", () => {
36
+ expect(typeof mod["setPageHandler"]).toBe("function");
37
+ });
38
+ it("markFullAppReady is a function", () => {
39
+ expect(typeof mod["markFullAppReady"]).toBe("function");
40
+ });
41
+ it("__resetPhase2StateForTest is a function", () => {
42
+ expect(typeof mod["__resetPhase2StateForTest"]).toBe("function");
43
+ });
44
+ it("getPageHandlerState is a function", () => {
45
+ expect(typeof mod["getPageHandlerState"]).toBe("function");
46
+ });
47
+ it("healthHandler is a function", () => {
48
+ expect(typeof mod["healthHandler"]).toBe("function");
49
+ });
50
+ it("createPhase1PageHandler is a function", () => {
51
+ expect(typeof mod["createPhase1PageHandler"]).toBe("function");
52
+ });
53
+ it("a fresh boot starts unready with no handler", async () => {
54
+ __resetPhase2StateForTest();
55
+ const got = getPageHandlerState();
56
+ expect(got).toEqual({ ready: false, hasHandler: false });
57
+ });
58
+ it("markFullAppReady alone flips ready WITHOUT a handler — the client-render contract", async () => {
59
+ __resetPhase2StateForTest();
60
+ markFullAppReady(); const got = getPageHandlerState();
61
+ expect(got.ready).toBe(true);
62
+ expect(got.hasHandler).toBe(false);
63
+ });
64
+ it("setPageHandler implies ready AND a handler (the SSR contract)", async () => {
65
+ __resetPhase2StateForTest();
66
+ setPageHandler((() => async () => new Response("ok")) as never); const got = getPageHandlerState();
67
+ expect(got.ready).toBe(true);
68
+ expect(got.hasHandler).toBe(true);
69
+ });
70
+ it("/health reports 503 starting before phase 2", async () => {
71
+ __resetPhase2StateForTest(); __capabilityFault.value = null;
72
+ const got = healthHandler(__ctx as never) as Response;
73
+ expect(got.status).toBe(503);
74
+ expect((await got.json() as { status: string }).status).toBe("starting");
75
+ });
76
+ it("/health asserts the capability, not just the phase flag", async () => {
77
+ __resetPhase2StateForTest(); markFullAppReady(); __capabilityFault.value = "registered client carries no CRUD models";
78
+ const got = healthHandler(__ctx as never) as Response;
79
+ expect(got.status).toBe(503);
80
+ expect((await got.json() as { status: string }).status).toBe("degraded");
81
+ });
82
+ it("/health answers ok once ready and the capability is intact", async () => {
83
+ __resetPhase2StateForTest(); markFullAppReady(); __capabilityFault.value = null;
84
+ const got = healthHandler(__ctx as never) as Response;
85
+ expect(got.status).toBe(200);
86
+ expect((await got.json() as { status: string }).status).toBe("ok");
87
+ });
88
+ it("the phase-1 page handler serves the branded waking 503 before phase 2", async () => {
89
+ __resetPhase2StateForTest();
90
+ const handler = createPhase1PageHandler("svc-tr")( {} as never );
91
+ const got = await handler(new Request("http://x.local/"), {} as never);
92
+ expect(got.status).toBe(503);
93
+ expect(got.headers.get("content-type")).toContain("text/html");
94
+ expect(got.headers.get("retry-after")).not.toBeNull();
95
+ });
96
+ it("the phase-1 handler delegates to the real handler once registered", async () => {
97
+ __resetPhase2StateForTest(); setPageHandler((() => async () => new Response("real-render")) as never);
98
+ const handler = createPhase1PageHandler("svc-tr")( {} as never );
99
+ const got = await handler(new Request("http://x.local/"), {} as never);
100
+ expect(await got.text()).toBe("real-render");
101
+ });
102
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @status generated — ts-api-smoke template (scala-test-suites
3
+ * templates/ts_api_smoke.pkl @1.3.0) over the exports map of @teamscala/service-boot.
4
+ * ./types declares 0 runtime export(s) + 5 type(s);
5
+ * this suite asserts the import resolves, the runtime surface matches
6
+ * exactly, and every export carries its declared kind. Regenerate from
7
+ * inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
8
+ * overwritten.
9
+ */
10
+
11
+ import { describe, expect, it } from "bun:test";
12
+
13
+ const mod = await import("./types");
14
+
15
+ describe("types exports-map smoke", () => {
16
+ it("is type-only — the runtime module carries no values", () => {
17
+ // Declared types: RendererComponentEntry, PageHandlerExtras, CreatePageHandler, PageHandlerConfig, ServiceInstallConfig
18
+ expect(Object.keys(mod).length).toBe(0);
19
+ });
20
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @status generated — ts-api-smoke template (scala-test-suites
3
+ * templates/ts_api_smoke.pkl @1.3.0) over the exports map of @teamscala/service-boot.
4
+ * ./ui-service declares 1 runtime export(s) + 3 type(s);
5
+ * this suite asserts the import resolves, the runtime surface matches
6
+ * exactly, and every export carries its declared kind. Regenerate from
7
+ * inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
8
+ * overwritten.
9
+ */
10
+
11
+ import { describe, expect, it } from "bun:test";
12
+
13
+ const mod = await import("./ui-service");
14
+
15
+ describe("ui-service exports-map smoke", () => {
16
+ it("the runtime export set is exactly the declared surface", () => {
17
+ expect(Object.keys(mod).sort()).toEqual([
18
+ "bootUiService",
19
+ ]);
20
+ });
21
+ it("bootUiService is a function", () => {
22
+ expect(typeof mod["bootUiService"]).toBe("function");
23
+ });
24
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @status generated — ts-api-smoke template (scala-test-suites
3
+ * templates/ts_api_smoke.pkl @1.3.0) over the exports map of @teamscala/service-boot.
4
+ * ./ws-boot-self-test declares 1 runtime export(s) and 3 declared behaviour vector(s);
5
+ * this suite asserts the import resolves, the runtime surface matches
6
+ * exactly, and every export carries its declared kind. Regenerate from
7
+ * inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
8
+ * overwritten.
9
+ */
10
+
11
+ import { describe, expect, it } from "bun:test";
12
+
13
+ const mod = await import("./ws-boot-self-test");
14
+
15
+ const { runWsBootSelfTest } = mod;
16
+
17
+ describe("ws-boot-self-test exports-map smoke", () => {
18
+ it("the runtime export set is exactly the declared surface", () => {
19
+ expect(Object.keys(mod).sort()).toEqual([
20
+ "runWsBootSelfTest",
21
+ ]);
22
+ });
23
+ it("runWsBootSelfTest is a function", () => {
24
+ expect(typeof mod["runWsBootSelfTest"]).toBe("function");
25
+ });
26
+ it("no declared websocket routes resolves without connecting", async () => {
27
+ const got = await runWsBootSelfTest({ websocket_routes: [] } as never, "svc-ws", 59999);
28
+ expect(got).toBeUndefined();
29
+ });
30
+ it("a route whose server upgrades resolves 101", async () => {
31
+ const server = Bun.serve({ port: 0, fetch(req, srv) { if (!srv.upgrade(req)) return new Response("no upgrade", { status: 401 }); }, websocket: { open() {}, message() {} } });
32
+ const port = server.port as number;
33
+ const got = await runWsBootSelfTest({ websocket_routes: [{ path: "/ws-boot-self-test" }] } as never, "svc-ws", port);
34
+ expect(got).toBeUndefined();
35
+ server.stop(true);
36
+ });
37
+ it("a route nobody listens on fails boot loudly, naming the path", async () => {
38
+ let thrown: unknown = null;
39
+ try { await runWsBootSelfTest({ websocket_routes: [{ path: "/ws-boot-self-test-dead" }] } as never, "svc-ws", 59987); } catch (err) { thrown = err; }
40
+ expect(thrown).not.toBeNull();
41
+ expect(String(thrown)).toContain("/ws-boot-self-test-dead");
42
+ });
43
+ });
@@ -1,92 +0,0 @@
1
- /**
2
- * @system server-handlers
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The content fingerprint of the client-bundle dependency closure — the
7
- * cache key that decides whether the boot-time client-asset build can be
8
- * skipped. Composes the input-hashing module's closure walkers.
9
- *
10
- * Split out of build-client-assets.ts (962 lines) alongside its build
11
- * siblings. Byte-for-byte move from the parent except createHash →
12
- * Bun.CryptoHasher (bun-native-crypto guard; identical digest output).
13
- */
14
-
15
- // Content fingerprint of the client-bundle dependency CLOSURE — hashed by CONTENT, never mtime (cache-key-hashes-source-inputs-only). Rebuild iff this
16
- // differs from the fingerprint stored in the existing manifest. Scoped to the CLOSURE (not all of node_modules) so a @teamscala/* publish that is not in this
17
- // service's client bundle cannot trigger a rebuild — the publish→rebuild storm is architecturally impossible (`no-uncontrolled-repetition-or-cascade`).
18
- //
19
- // Inputs (everything that can change the emitted bytes): src/entry-client.ts — the bundle entrypoint. src/page-manifest.ts — the generated block→component map the bundle is built
20
- // from; a codegen regen (block added/removed) changes it, and the hydrate bundle MUST rebuild or new blocks render
21
- // empty → hydration mismatch. src/app.css — the Tailwind entry. @teamscala/* JS closure (hashTeamgbjJsGraph) — the CONTENT of every @teamscala/*
22
- // module reachable from the client entrypoints. Content (not version) so a mid-install stale bake (bun.lock
23
- // ahead of node_modules — the lock can name a new version while the prior bytes are still on disk) is detected on
24
- // the next boot and self-heals (scala-agents 2026-07-12). @teamscala/* CSS closure (hashResolvedPackageStylesheets) — the CONTENT of
25
- // every package stylesheet app.css @imports (transitively). third-party closure-boundary versions (hashTeamgbgJsGraph) — the resolved
26
- // VERSION of each third-party package the client closure imports (react, etc.). Third-party modules are not
27
- // content-walked (large; pinned by bun.lock + change only on a version bump), but a version bump of a closure dep
28
- // MUST invalidate, so the installed package.json#version is folded. This is the closure-scoped replacement for
29
- // hashing all of bun.lock: react bumping in the closure still rebuilds, while a backend @teamscala/* publish
30
- // outside the closure does not. NB: bun.lock is INTENTIONALLY NOT an input. It encodes every resolved
31
- // @teamscala/* version (backend packages included), so it invalidates on every publish — the false-invalidation source that made every UI service rebuild
32
- // on a backend publish. The closure inputs above are a strict improvement: they catch every change that affects THIS bundle AND nothing that doesn't.
33
-
34
- // Keyed on each input's RELATIVE label + CONTENT only — never the absolute
35
- // path (that would make the fingerprint cwd-dependent and defeat the skip
36
- // across two checkouts of the same service). bun.lock is DELIBERATELY
37
- // EXCLUDED — it encodes EVERY resolved @teamscala/* version, so folding it in
38
- // invalidates on every publish including packages NOT in this service's
39
- // client bundle (the publish→rebuild storm — `no-uncontrolled-repetition-
40
- // or-cascade`). The closure content hashes below (hashTeamgbgJsGraph +
41
- // hashResolvedPackageStylesheets) + the third-party closure-boundary
42
- // versions cover every dependency change that ACTUALLY affects this bundle,
43
- // so a non-closure publish changes nothing here → zero rebuild. Rebuild
44
- // iff this fingerprint differs from the one stored in the manifest.
45
-
46
- // Fold in the resolved CONTENT of every @teamscala/* JS/TS module reachable
47
- // from the client entrypoints (hashTeamgbgJsGraph). bun.lock pins versions;
48
- // the build reads BYTES — and a boot can build MID-INSTALL (the package-
49
- // poller restarts the service after writing the new bun.lock but before
50
- // node_modules extraction finishes), so Bun.build faithfully bakes the OLD
51
- // @teamgbg JS while the lock-derived fingerprint is stored as "current".
52
- // Every later restart then recomputes the same lock fingerprint → skip →
53
- // STALE bundle served forever (scala-agents 2026-07-12: hydrate-4dad7478bd
54
- // carried zero of portal-ui@2.4.41's markers across restarts). Bun.build
55
- // itself is NOT caching — an isolated repro proves it reads fresh content
56
- // across processes — so the fix is to make the fingerprint cover the actual
57
- // @teamgbg JS bytes (the JS analogue of hashResolvedPackageStylesheets,
58
- // which already closes this exact race for CSS). A stale bake then hashes
59
- // differently from the freshly-installed bytes → rebuild → self-heals.
60
-
61
- import { join } from "node:path";
62
- import {
63
- hashResolvedPackageStylesheets,
64
- hashTeamgbgJsGraph,
65
- } from "./input-hashing.ts";
66
-
67
- export async function computeInputFingerprint(cwd: string): Promise<string> {
68
- const sources: Array<[label: string, path: string]> = [
69
- ["src/entry-client.ts", join(cwd, "src", "entry-client.ts")],
70
- ["src/page-manifest.ts", join(cwd, "src", "page-manifest.ts")],
71
- ["src/app.css", join(cwd, "src", "app.css")],
72
- ];
73
- const h = new Bun.CryptoHasher("sha256");
74
- for (const [label, path] of sources) {
75
- // Missing input contributes a stable sentinel rather than throwing — a
76
- // backend service has no app.css, and that absence is itself part of the
77
- // fingerprint. `(await Bun.file().text())` is parenthesised so a missing
78
- // file's sync TypeError is caught (bun-async-file-operations).
79
- try {
80
- h.update(label).update("\0").update(await Bun.file(path).text()).update("\0");
81
- } catch {
82
- h.update(label).update("\0<absent>\0");
83
- }
84
- }
85
- // Fold the resolved CONTENT of every @teamscala/* stylesheet app.css @imports
86
- // (client-asset-cache-key-embeds-package-content). bun.lock pins versions; the
87
- // build reads bytes. When they diverge (boot built mid-install), the lock-only
88
- // fingerprint false-matches and skips forever — hashing the bytes closes it.
89
- await hashResolvedPackageStylesheets(cwd, h);
90
- await hashTeamgbgJsGraph(cwd, h);
91
- return h.digest("hex").slice(0, 16);
92
- }
@@ -1,206 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Input hashing for the client-asset build fingerprint.
7
- *
8
- * Split out of build-client-assets.ts, which had grown to 1221 lines holding
9
- * six distinct concerns (lock + status, manifest, hydrate bundle, source maps,
10
- * stylesheet/tailwind, and this). `single-purpose-file` did not catch it: that
11
- * rule flags a file whose top-level exports share no common name root, which
12
- * measures export-NAME cohesion rather than how many concerns a file holds — so
13
- * a large file with few tidily-named exports stays invisible to it.
14
- *
15
- * This module owns ONE concern: turning the build's real inputs into a stable
16
- * hash. `computeInputFingerprint` (still in the parent, and still the only
17
- * exported entry point) composes the two functions exported here; everything
18
- * else is module-private because nothing outside needs it.
19
- *
20
- * Byte-for-byte move — imports adjusted, nothing else. No renames, no type
21
- * changes (`migrations-copy-not-rewrite`).
22
- */
23
-
24
- import { dirname, join, relative, resolve } from "node:path";
25
- import { firstPartyScopeAlternation } from "@teamscala/os/workspace-topology/first-party-scopes";
26
-
27
- // Resolve + hash the content of every `@import` in app.css's @teamscala/*
28
- // stylesheet chain — INCLUDING transitive @imports (generic-ui/styles.css
29
- // @imports blk-utilities/styles.css; hashing only the direct import left
30
- // blk-utilities changes undetected → stale bundle). Cycle-safe via `seen`.
31
- export async function hashResolvedPackageStylesheets(
32
- cwd: string,
33
- h: Bun.CryptoHasher,
34
- ): Promise<void> {
35
- let appCss: string;
36
- try {
37
- appCss = await Bun.file(join(cwd, "src", "app.css")).text();
38
- } catch {
39
- return; // no app.css → nothing to fold (the main loop already recorded <absent>)
40
- }
41
- const seen = new Set<string>();
42
- const hashSpec = async (spec: string, base: string): Promise<void> => {
43
- if (seen.has(spec)) return;
44
- seen.add(spec);
45
- try {
46
- const resolved = await Bun.resolve(spec, base);
47
- const content = await Bun.file(resolved).text();
48
- h.update("pkgcss:").update(spec).update("\0").update(content).update("\0");
49
- // Follow transitive @imports within this stylesheet (relative + @teamscala/*).
50
- for (const child of [
51
- ...content.matchAll(/@import\s+["']([^"']+)["']/g),
52
- ].map((m) => m[1].trim())) {
53
- if (child.startsWith("tailwindcss")) continue;
54
- await hashSpec(child, dirname(resolved));
55
- }
56
- } catch {
57
- h.update("pkgcss:").update(spec).update("\0<unresolved>\0");
58
- }
59
- };
60
- for (const spec of [
61
- ...appCss.matchAll(new RegExp(`@import\\s+["']((?:${firstPartyScopeAlternation()})\\/[^"']+)['"]`, "g")),
62
- ].map((m) => m[1].trim())) {
63
- await hashSpec(spec, join(cwd, "src"));
64
- }
65
- }
66
-
67
- // The JS twin of hashResolvedPackageStylesheets: resolve + hash the CONTENT of every @teamscala/* JS/TS module reachable from the client entrypoints, so a
68
- // mid-install stale bake (bun.lock ahead of node_modules — see computeInputFingerprint) is detected on the next boot and self-heals. Third-party modules are NOT walked
69
- // (their versions are pinned by bun.lock and they don't change on a @teamgbg
70
- // publish); only the @teamgbg surface — the publish-driven change graph — is folded.
71
- //
72
- // Determinism: per-file content hashes are collected into a map keyed on the
73
- // package-relative path, then folded into the aggregate hasher in SORTED path
74
- // order. The walk itself runs bounded-concurrent (no-serial-per-unit-work-on-hot-
75
- // paths — the @teamgbg graph is ~1k+ files for a large UI; a serial await-per-file
76
- // walk starves the boot path), but scheduling order never affects the result.
77
- const JS_IMPORT_SPEC_RE = /\b(?:from|import|export|require)\s*\(?["']([^"']+)["']/g;
78
- const JS_GRAPH_CONCURRENCY = 32;
79
-
80
- // Parse the package name (scoped `@scope/pkg` or unscoped `pkg`) from a
81
- // resolved node_modules path. lastIndexOf picks the INNERMOST package when
82
- // node_modules is nested (a -> node_modules/b), which is the package actually
83
- // imported. Returns null for a path not under node_modules.
84
- function nodeModulesPkgName(resolvedPath: string): string | null {
85
- const idx = resolvedPath.lastIndexOf("/node_modules/");
86
- if (idx < 0) return null;
87
- const rest = resolvedPath.slice(idx + "/node_modules/".length);
88
- const slash = rest.indexOf("/");
89
- if (rest.startsWith("@")) {
90
- if (slash < 0) return rest; // malformed scoped name with no slash
91
- const secondSlash = rest.indexOf("/", slash + 1);
92
- return secondSlash < 0 ? rest : rest.slice(0, secondSlash);
93
- }
94
- return slash < 0 ? rest : rest.slice(0, slash);
95
- }
96
-
97
- // Best-effort read of an installed package's declared version. Used at the
98
- // third-party closure boundary so a version bump of a closure dep (react, etc.)
99
- // invalidates the fingerprint without content-walking the (large, pinned)
100
- // package. Returns null on any failure — an ABSENT signal, not a masking
101
- // fallback (protective-layers-fail-explicitly): the caller omits the package,
102
- // so a later successful read folds in a real version and invalidates. Never
103
- // returns a non-null placeholder that would hide the failure.
104
- async function readInstalledPkgVersion(
105
- resolvedPath: string,
106
- pkgName: string,
107
- ): Promise<string | null> {
108
- const marker = "/node_modules/";
109
- const idx = resolvedPath.lastIndexOf(marker);
110
- const pkgRoot = join(resolvedPath.slice(0, idx + marker.length), pkgName);
111
- try {
112
- const pkg = await Bun.file(join(pkgRoot, "package.json")).json();
113
- return typeof pkg.version === "string" ? pkg.version : null;
114
- } catch {
115
- return null;
116
- }
117
- }
118
-
119
- export async function hashTeamgbgJsGraph(
120
- cwd: string,
121
- h: Bun.CryptoHasher,
122
- ): Promise<void> {
123
- const roots = [];
124
- for (const p of [
125
- join(cwd, "src", "entry-client.ts"),
126
- join(cwd, "src", "page-manifest.ts"),
127
- ]) {
128
- if (await Bun.file(p).exists()) roots.push(p);
129
- }
130
- if (roots.length === 0) return; // backend service — no client bundle to guard
131
-
132
- const seen = new Set<string>();
133
- for (const r of roots) seen.add(r);
134
- const fileHashes = new Map<string, string>(); // pkg-relative path → content hash
135
- const thirdPartyVersions = new Map<string, string>(); // pkg name → installed version
136
-
137
- let frontier: string[] = [...roots];
138
- while (frontier.length > 0) {
139
- const batch = frontier.splice(0, JS_GRAPH_CONCURRENCY);
140
- const expanded = await Promise.all(
141
- batch.map(async (file) => {
142
- let text: string;
143
- try {
144
- text = await Bun.file(file).text();
145
- } catch {
146
- return [] as string[];
147
- }
148
- const nmIdx = file.indexOf("/node_modules/@teamscala/");
149
- if (nmIdx >= 0) {
150
- const rel = file.slice(nmIdx + "/node_modules/".length); // @teamscala/pkg/...
151
- fileHashes.set(rel, new Bun.CryptoHasher("sha256").update(text).digest("hex"));
152
- }
153
- const next: string[] = [];
154
- for (const m of text.matchAll(JS_IMPORT_SPEC_RE)) {
155
- let resolved: string;
156
- try {
157
- resolved = await Bun.resolve(m[1].trim(), dirname(file));
158
- } catch {
159
- continue;
160
- }
161
- const inNodeModules = resolved.includes("/node_modules/");
162
- const isTeamgbg = resolved.includes("/node_modules/@teamscala/");
163
- if (inNodeModules && !isTeamgbg) {
164
- // Third-party closure boundary: stop the content walk (large,
165
- // version-pinned packages) but record this dep's resolved VERSION
166
- // so a closure version bump (react, …) still invalidates the
167
- // fingerprint. The closure-scoped replacement for hashing all of
168
- // bun.lock (`no-uncontrolled-repetition-or-cascade`). has()+set()
169
- // straddle an await, so two concurrent imports of the same package
170
- // can both read its version — idempotent (same value), so the
171
- // folded result is deterministic regardless of scheduling.
172
- const pkgName = nodeModulesPkgName(resolved);
173
- if (pkgName && !thirdPartyVersions.has(pkgName)) {
174
- const version = await readInstalledPkgVersion(resolved, pkgName);
175
- // null = absent (unreadable package.json) — OMIT so a later
176
- // successful read invalidates; never mask behind a placeholder
177
- // (protective-layers-fail-explicitly). has() stays false → the
178
- // read is re-attempted on the next import (cheap, bounded by
179
- // closure size); a successful read sets + dedups.
180
- if (version !== null) thirdPartyVersions.set(pkgName, version);
181
- }
182
- continue;
183
- }
184
- // Atomic dedup: check+add are synchronous (no await between), so on
185
- // the single-threaded event loop only one task ever enqueues a path.
186
- if (seen.has(resolved)) continue;
187
- seen.add(resolved);
188
- next.push(resolved);
189
- }
190
- return next;
191
- }),
192
- );
193
- for (const arr of expanded) frontier.push(...arr);
194
- }
195
-
196
- for (const rel of [...fileHashes.keys()].sort()) {
197
- h.update("tgjs:").update(rel).update("\0").update(fileHashes.get(rel) as string).update("\0");
198
- }
199
- // Fold the resolved versions of every third-party package at the closure
200
- // boundary, sorted by name for determinism. This is the ONLY signal for
201
- // third-party changes (their bodies are not content-walked); without it a
202
- // react version bump in the closure would bake a stale bundle.
203
- for (const name of [...thirdPartyVersions.keys()].sort()) {
204
- h.update("tpv:").update(name).update("\0").update(thirdPartyVersions.get(name) as string).update("\0");
205
- }
206
- }
@@ -1,86 +0,0 @@
1
- /**
2
- * @system server-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The `bun-stable-watch` pg-boss handler — the durable trigger for the
7
- * bun-1.4 lock-resolution escape hatch's atomic flip-back (open-migration
8
- * `bun-14-lock-resolution-pin`). Polls the public npm `bun` `latest`
9
- * dist-tag once daily; when a STABLE 1.4.x (≥1.4.0, no prerelease) ships,
10
- * records a detection event so the escape hatch can be retired via the atomic
11
- * flip-back: `config/scala-tools-deps.lockResolutionBun` → "bun" + the ~18
12
- * `railpack_config` rows → 1.4.0 stable + delete the pinned
13
- * `/usr/local/bin/bun-1.3.14` binary + a green deploy (all one change). The
14
- * flip-back itself is a deliberate action, not auto-fired — it mutates config
15
- * + railpack + a binary + a deploy.
16
- *
17
- * `bun/latest` returns the stable release (canaries live under the `canary`
18
- * dist-tag, not `latest`), so a non-prerelease version ≥1.4.0 means stable
19
- * shipped. A daily `bun.watch.tick` liveness event lets a watchdog detect a
20
- * silently-stopped watch (the same shape as `sweep.completed`).
21
- *
22
- * Registered under slug `@teamscala/service-boot:bun-stable-watch`.
23
- */
24
- import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
25
- import { recordEvent } from "@teamscala/event-log/record-event";
26
- import { getAppLogger } from "@teamscala/logger/app-loggers";
27
- import { getDistTagVersion } from "@teamscala/publish-registry";
28
- import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
29
-
30
- const NPM_REGISTRY = "https://registry.npmjs.org";
31
- const TARGET_STABLE = "1.4.0"; // flip back when a stable at or above this ships
32
-
33
- interface BunLatestManifest {
34
- version: string;
35
- }
36
-
37
- function parseSemver(
38
- v: string,
39
- ): { major: number; minor: number; patch: number; prerelease: string } | null {
40
- const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(v.trim());
41
- if (!m) return null;
42
- return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), prerelease: m[4] ?? "" };
43
- }
44
-
45
- /** A stable (non-prerelease) release at or above the target version. */
46
- function isStableAtOrAbove(version: string, target: string): boolean {
47
- const v = parseSemver(version);
48
- const t = parseSemver(target);
49
- if (!v || !t) return false;
50
- if (v.prerelease) return false; // canary/beta/rc are not stable
51
- if (v.major !== t.major) return v.major > t.major;
52
- if (v.minor !== t.minor) return v.minor > t.minor;
53
- return v.patch >= t.patch;
54
- }
55
-
56
- async function bunStableWatch(_ctx: { config: ScheduledJobConfig }): Promise<void> {
57
- const log = getAppLogger();
58
- const version = await getDistTagVersion("bun", "latest", NPM_REGISTRY);
59
- if (!version) {
60
- log.warn("[bun-stable-watch] failed to fetch npm bun/latest");
61
- return;
62
- }
63
- if (isStableAtOrAbove(version, TARGET_STABLE)) {
64
- log.warn(
65
- `[bun-stable-watch] Bun stable ${version} (>=${TARGET_STABLE}) shipped — escape hatch 'bun-14-lock-resolution-pin' is ready to retire. Run the atomic flip-back: config/scala-tools-deps.lockResolutionBun -> "bun" + railpack_config rows -> 1.4.0 stable + delete /usr/local/bin/bun-1.3.14 + a green deploy.`,
66
- );
67
- recordEvent({
68
- kind: "bun.stable.shipped",
69
- payload: {
70
- latest: version,
71
- targetStable: TARGET_STABLE,
72
- note: "escape hatch bun-14-lock-resolution-pin ready to retire; verify railpack 1.4.0 support then run the atomic flip-back",
73
- },
74
- });
75
- return;
76
- }
77
- log.info(
78
- `[bun-stable-watch] npm bun/latest = ${version} — not yet stable ${TARGET_STABLE}; escape hatch remains`,
79
- );
80
- recordEvent({
81
- kind: "bun.watch.tick",
82
- payload: { latest: version, targetStable: TARGET_STABLE },
83
- });
84
- }
85
-
86
- pgBossHandlerRegistry.register("@teamscala/service-boot:bun-stable-watch", bunStableWatch);
@@ -1,79 +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 {
12
- buildBackfillRow,
13
- diffOutboundSends,
14
- type GowaOutboundMessage,
15
- } from "./messaging-send-reconcile-logic.ts";
16
-
17
- const NOW = new Date("2026-08-26T02:30:00Z");
18
- const SETTLE_MS = 120_000;
19
-
20
- describe("diffOutboundSends", () => {
21
- const rawSend: GowaOutboundMessage = {
22
- id: "3EB0F05AC9FAAF24BDA045",
23
- chat_jid: "61428193498@s.whatsapp.net",
24
- content: "Launcha Authority Index — report page header",
25
- timestamp: "2026-08-25T16:13:57Z",
26
- is_from_me: true,
27
- };
28
-
29
- test("CLEAN: every outbound id already recorded — nothing to backfill", () => {
30
- const missing = diffOutboundSends(
31
- [rawSend],
32
- new Set(["3EB0F05AC9FAAF24BDA045"]),
33
- NOW,
34
- SETTLE_MS,
35
- );
36
- expect(missing).toEqual([]);
37
- });
38
-
39
- test("VIOLATING: a GOWA-outbound send with no recorded row is a backfill candidate", () => {
40
- const missing = diffOutboundSends([rawSend], new Set(), NOW, SETTLE_MS);
41
- expect(missing).toHaveLength(1);
42
- expect(missing[0]).toEqual({
43
- id: "3EB0F05AC9FAAF24BDA045",
44
- chatJid: "61428193498@s.whatsapp.net",
45
- content: "Launcha Authority Index — report page header",
46
- timestamp: "2026-08-25T16:13:57.000Z",
47
- });
48
- });
49
-
50
- test("inbound messages are never candidates (they record themselves via webhook)", () => {
51
- const inbound: GowaOutboundMessage = { ...rawSend, is_from_me: false };
52
- expect(diffOutboundSends([inbound], new Set(), NOW, SETTLE_MS)).toEqual([]);
53
- });
54
-
55
- test("a send younger than the settle window waits one tick for the recorder", () => {
56
- const fresh: GowaOutboundMessage = {
57
- ...rawSend,
58
- timestamp: "2026-08-26T02:29:30Z",
59
- };
60
- expect(diffOutboundSends([fresh], new Set(), NOW, SETTLE_MS)).toEqual([]);
61
- });
62
- });
63
-
64
- describe("buildBackfillRow", () => {
65
- test("carries the provider id, the recipient, and the reconciled send_kind", () => {
66
- const row = buildBackfillRow({
67
- id: "3EB0AF4413C8A5A0694B38",
68
- chatJid: "61428193498@s.whatsapp.net",
69
- content: "The quiz report email as delivered",
70
- timestamp: "2026-08-25T16:14:13Z",
71
- });
72
- expect(row.direction).toBe("outbound");
73
- expect(row.event_type).toBe("sent");
74
- expect(row.external_msg_id).toBe("3EB0AF4413C8A5A0694B38");
75
- expect(row.external_chat_id).toBe("61428193498@s.whatsapp.net");
76
- expect(row.user_phone).toBe("61428193498@s.whatsapp.net");
77
- expect(row.payload.send_kind).toBe("gowa-reconcile");
78
- });
79
- });