@teamscala/service-boot 0.23.437 → 0.23.439

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.437",
9
+ "version": "0.23.439",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -59,14 +59,24 @@ import { mkdir, readdir, rename, rm } from "node:fs/promises";
59
59
  import { join } from "node:path";
60
60
  import { getAppLogger } from "@teamscala/logger/app-loggers";
61
61
  import { fileHash, removeStaleHashedFiles } from "./hashed-output.ts";
62
- import { uploadSourceMaps } from "./sourcemaps-upload.ts";
62
+ import {
63
+ uploadSourceMaps,
64
+ type SourceMapUploadResult,
65
+ } from "./sourcemaps-upload.ts";
63
66
 
64
67
  const HYDRATE_BASE = "hydrate";
65
68
 
69
+ export interface HydrateBundleResult {
70
+ /** Served entry path (`/assets/hydrate-<hash>.js`), or null when no entry exists. */
71
+ hydratePath: string | null;
72
+ /** The VERIFIED source-map upload outcome for this build's release. */
73
+ sourceMaps: SourceMapUploadResult;
74
+ }
75
+
66
76
  export async function buildHydrateBundle(
67
77
  cwd: string,
68
78
  log: ReturnType<typeof getAppLogger>,
69
- ): Promise<string | null> {
79
+ ): Promise<HydrateBundleResult | null> {
70
80
  const entry = join(cwd, "src", "entry-client.ts");
71
81
  const outDir = join(cwd, "dist", "client");
72
82
  const tmpFile = join(outDir, `${HYDRATE_BASE}.js`);
@@ -264,10 +274,11 @@ export async function buildHydrateBundle(
264
274
  { externalizedBlocks },
265
275
  );
266
276
  }
267
- // Upload source maps to GlitchTip (best-effort, dev-host-only) + delete the
268
- // .map files so source never ships publicly. See uploadSourceMaps.
269
- await uploadSourceMaps(outDir, hash, log);
270
- return `/assets/${hashedName}`;
277
+ // Upload source maps to GlitchTip (best-effort) + RETAIN the .map files
278
+ // outside the served root so a failed upload retries next boot — source
279
+ // never ships publicly. See uploadSourceMaps.
280
+ const sourceMaps = await uploadSourceMaps(cwd, outDir, hash, log);
281
+ return { hydratePath: `/assets/${hashedName}`, sourceMaps };
271
282
  }
272
283
 
273
284
  // Remove hashed code-split chunks no longer referenced by the current entry.
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { join } from "node:path";
18
+ import type { SourceMapUploadResult } from "./sourcemaps-upload.ts";
18
19
 
19
20
  export const MANIFEST_FILE = "asset-manifest.json";
20
21
 
@@ -42,6 +43,13 @@ export interface AssetManifest {
42
43
  buildFailed?: boolean;
43
44
  buildFailedReason?: string;
44
45
  buildFailedAt?: string;
46
+ // VERIFIED outcome of the last source-map upload attempt for THIS build's
47
+ // release (`uploaded` = every response checked 2xx; `failed` names each
48
+ // rejection in `failures`; `skipped` = no SENTRY_AUTH_TOKEN/SENTRY_URL).
49
+ // The fingerprint-skip path reads it: any status other than `uploaded`
50
+ // re-runs the upload from the retained map cache on every boot until it
51
+ // verifies — an unuploaded release must not silently persist forever.
52
+ sourceMaps?: SourceMapUploadResult;
45
53
  }
46
54
 
47
55
  // Last-resort paths for a tombstone when no prior successful manifest exists
@@ -93,6 +101,7 @@ export async function readManifest(outDir: string): Promise<AssetManifest | null
93
101
  buildFailed: parsed.buildFailed,
94
102
  buildFailedReason: parsed.buildFailedReason,
95
103
  buildFailedAt: parsed.buildFailedAt,
104
+ sourceMaps: parsed.sourceMaps,
96
105
  };
97
106
  }
98
107
  return null;
@@ -0,0 +1,190 @@
1
+ /**
2
+ * @system server-handlers
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Behaviour tests for the source-map upload unit. The pre-2026-09 flow
7
+ * deleted the maps after an upload whose responses were never checked, so a
8
+ * missing token or a 4xx permanently lost a release's maps while the boot
9
+ * log kept saying "uploaded" — client stacks stayed unattributable in
10
+ * GlitchTip. These tests assert the OUTCOME, not the call: the served root
11
+ * never keeps a .map, failures are named and retain the maps for retry, and
12
+ * only verified acceptance reports "uploaded".
13
+ */
14
+
15
+ import { mkdtemp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { afterAll, afterEach, describe, expect, test } from "bun:test";
19
+ import {
20
+ releaseFromHydratePath,
21
+ sourceMapsCacheDir,
22
+ uploadCachedSourceMaps,
23
+ uploadSourceMaps,
24
+ type SourceMapUploadResult,
25
+ } from "./sourcemaps-upload.ts";
26
+
27
+ const dirs: string[] = [];
28
+ const realFetch = globalThis.fetch;
29
+ const createdFetchCalls: Array<{ url: string; name: string | null }> = [];
30
+
31
+ async function makeDir(): Promise<string> {
32
+ const dir = await mkdtemp(join(tmpdir(), "sourcemaps-up-"));
33
+ dirs.push(dir);
34
+ return dir;
35
+ }
36
+
37
+ async function seedMaps(outDir: string, names: string[]): Promise<void> {
38
+ await mkdir(outDir, { recursive: true });
39
+ for (const name of names) {
40
+ await writeFile(
41
+ join(outDir, name),
42
+ JSON.stringify({ version: 3, sources: ["a.ts"], mappings: "AAAA" }),
43
+ );
44
+ }
45
+ }
46
+
47
+ const noopLog = {
48
+ info: () => {},
49
+ warn: () => {},
50
+ error: () => {},
51
+ } as unknown as ReturnType<typeof import("@teamscala/logger/app-loggers").getAppLogger>;
52
+
53
+ function mockFetch(
54
+ releaseStatus: number,
55
+ fileStatus: number,
56
+ ): void {
57
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
58
+ const url = String(input);
59
+ if (url.endsWith("/releases/")) {
60
+ return new Response("{}", { status: releaseStatus });
61
+ }
62
+ let name: string | null = null;
63
+ if (init?.body instanceof FormData) {
64
+ name = (init.body.get("name") as string | null) ?? null;
65
+ }
66
+ createdFetchCalls.push({ url, name });
67
+ return new Response("{}", { status: fileStatus });
68
+ }) as typeof fetch;
69
+ }
70
+
71
+ afterEach(() => {
72
+ globalThis.fetch = realFetch;
73
+ delete process.env.SENTRY_AUTH_TOKEN;
74
+ delete process.env.SENTRY_URL;
75
+ createdFetchCalls.length = 0;
76
+ });
77
+
78
+ afterAll(async () => {
79
+ globalThis.fetch = realFetch;
80
+ for (const d of dirs) await rm(d, { recursive: true, force: true });
81
+ });
82
+
83
+ describe("uploadSourceMaps", () => {
84
+ test("absent env → skipped, but maps STILL leave the served root and are retained", async () => {
85
+ const cwd = await makeDir();
86
+ const outDir = join(cwd, "dist", "client");
87
+ await seedMaps(outDir, ["hydrate-abc123.js.map", "chunk-xyz789.js.map"]);
88
+ const result = await uploadSourceMaps(cwd, outDir, "abc123", noopLog);
89
+ expect(result.status).toBe("skipped");
90
+ expect(result.total).toBe(2);
91
+ expect((await readdir(outDir)).filter((n) => n.endsWith(".map"))).toEqual([]);
92
+ const cache = sourceMapsCacheDir(cwd, "abc123");
93
+ const retained = (await readdir(cache)).filter((n) => n.endsWith(".map"));
94
+ expect(retained.sort()).toEqual(["chunk-xyz789.js.map", "hydrate-abc123.js.map"].sort());
95
+ });
96
+
97
+ test("token present + all 2xx → uploaded, verified count, artifact names map 1:1 to served assets", async () => {
98
+ process.env.SENTRY_AUTH_TOKEN = "test-token";
99
+ process.env.SENTRY_URL = "https://glitchtip.test";
100
+ const cwd = await makeDir();
101
+ const outDir = join(cwd, "dist", "client");
102
+ await seedMaps(outDir, ["hydrate-abc123.js.map"]);
103
+ mockFetch(201, 201);
104
+ const result = await uploadSourceMaps(cwd, outDir, "abc123", noopLog);
105
+ expect(result.status).toBe("uploaded");
106
+ expect(result.uploaded).toBe(1);
107
+ expect(result.total).toBe(1);
108
+ expect(result.failures).toEqual([]);
109
+ expect(createdFetchCalls[0]?.name).toBe("~/assets/hydrate-abc123.js");
110
+ expect((await readdir(outDir)).filter((n) => n.endsWith(".map"))).toEqual([]);
111
+ });
112
+
113
+ test("file upload rejected → failed, EACH failure names map + status, maps retained for retry", async () => {
114
+ process.env.SENTRY_AUTH_TOKEN = "test-token";
115
+ process.env.SENTRY_URL = "https://glitchtip.test";
116
+ const cwd = await makeDir();
117
+ const outDir = join(cwd, "dist", "client");
118
+ await seedMaps(outDir, ["hydrate-abc123.js.map", "chunk-xyz789.js.map"]);
119
+ mockFetch(201, 403);
120
+ const result = await uploadSourceMaps(cwd, outDir, "abc123", noopLog);
121
+ expect(result.status).toBe("failed");
122
+ expect(result.uploaded).toBe(0);
123
+ expect(result.total).toBe(2);
124
+ expect(result.failures.length).toBe(2);
125
+ expect(result.failures[0]).toContain("hydrate-abc123.js.map");
126
+ expect(result.failures[0]).toContain("403");
127
+ expect((await readdir(outDir)).filter((n) => n.endsWith(".map"))).toEqual([]);
128
+ const retained = await readdir(sourceMapsCacheDir(cwd, "abc123"));
129
+ expect(retained.filter((n) => n.endsWith(".map")).length).toBe(2);
130
+ });
131
+
132
+ test("release create 208 (already exists) still uploads", async () => {
133
+ process.env.SENTRY_AUTH_TOKEN = "test-token";
134
+ process.env.SENTRY_URL = "https://glitchtip.test";
135
+ const cwd = await makeDir();
136
+ const outDir = join(cwd, "dist", "client");
137
+ await seedMaps(outDir, ["hydrate-abc123.js.map"]);
138
+ mockFetch(208, 201);
139
+ const result = await uploadSourceMaps(cwd, outDir, "abc123", noopLog);
140
+ expect(result.status).toBe("uploaded");
141
+ expect(result.uploaded).toBe(1);
142
+ });
143
+ });
144
+
145
+ describe("uploadCachedSourceMaps (the fingerprint-skip retry path)", () => {
146
+ test("no cached maps → failed, naming the release and the reason", async () => {
147
+ process.env.SENTRY_AUTH_TOKEN = "test-token";
148
+ process.env.SENTRY_URL = "https://glitchtip.test";
149
+ const cwd = await makeDir();
150
+ const result = await uploadCachedSourceMaps(cwd, "deadbeef", noopLog);
151
+ expect(result.status).toBe("failed");
152
+ expect(result.failures[0]).toContain("deadbeef");
153
+ });
154
+
155
+ test("cached maps retained by a failed pass upload successfully on retry", async () => {
156
+ process.env.SENTRY_AUTH_TOKEN = "test-token";
157
+ process.env.SENTRY_URL = "https://glitchtip.test";
158
+ const cwd = await makeDir();
159
+ const outDir = join(cwd, "dist", "client");
160
+ await seedMaps(outDir, ["hydrate-abc123.js.map"]);
161
+ // First pass fails (500) and retains the maps in the cache.
162
+ mockFetch(201, 500);
163
+ const failed: SourceMapUploadResult = await uploadSourceMaps(cwd, outDir, "abc123", noopLog);
164
+ expect(failed.status).toBe("failed");
165
+ // Retry pass (what the skip path runs on next boot) succeeds from cache.
166
+ mockFetch(201, 201);
167
+ const retried = await uploadCachedSourceMaps(cwd, "abc123", noopLog);
168
+ expect(retried.status).toBe("uploaded");
169
+ expect(retried.uploaded).toBe(1);
170
+ expect(createdFetchCalls[0]?.name).toBe("~/assets/hydrate-abc123.js");
171
+ });
172
+
173
+ test("absent env → skipped without touching the network", async () => {
174
+ const cwd = await makeDir();
175
+ const result = await uploadCachedSourceMaps(cwd, "abc123", noopLog);
176
+ expect(result.status).toBe("skipped");
177
+ expect(createdFetchCalls).toEqual([]);
178
+ });
179
+ });
180
+
181
+ describe("releaseFromHydratePath", () => {
182
+ test("extracts the bare content hash from a manifest hydratePath", () => {
183
+ expect(releaseFromHydratePath("/assets/hydrate-9df3aab732.js")).toBe("9df3aab732");
184
+ });
185
+
186
+ test("non-hydrate paths yield null (no proximity guess)", () => {
187
+ expect(releaseFromHydratePath("/assets/hydrate.js")).toBeNull();
188
+ expect(releaseFromHydratePath("/assets/styles-abc.css")).toBeNull();
189
+ });
190
+ });
@@ -4,7 +4,8 @@
4
4
  * @edit edit directly
5
5
  *
6
6
  * Upload source maps to GlitchTip via the official `glitchtip-cli` debug-ID
7
- * flow, then delete the .map files so source never ships publicly.
7
+ * flow, then RETAIN the maps outside the served root so a failed upload is
8
+ * retryable on a later boot.
8
9
  *
9
10
  * Split out of build-client-assets.ts (962 lines) alongside its build
10
11
  * siblings. Byte-for-byte move from the parent — imports adjusted, nothing
@@ -17,89 +18,266 @@
17
18
  * `sourcemaps upload --release`; see telemetry.md `client-errors-report-to-glitchtip`
18
19
  * + https://glitchtip.com/documentation/cli). Debug IDs injected into the served
19
20
  * bundles + uploaded maps are the matching mechanism — robust vs URL-prefix
20
- * guessing. Best-effort + dev-host-only: skips silently when the CLI or
21
- * SENTRY_AUTH_TOKEN is absent (Railway builds don't install the CLI; prod source
22
- * maps are a follow-up). The .map files are deleted after upload — source never
23
- * ships publicly; the debug IDs in the served bundles are all the SDK needs.
21
+ * guessing.
22
+ *
23
+ * THE MAPS OUTLIVE THE UPLOAD ATTEMPT: `.map` files are MOVED out of the
24
+ * served root (`dist/client/` — /assets/* would serve source publicly) into
25
+ * the retained cache `dist/sourcemaps/<release>/` BEFORE anything else, and
26
+ * the upload runs from the cache. A failed or skipped upload therefore
27
+ * leaves the maps on disk for the next boot's retry (the fingerprint-skip
28
+ * path in build-client-assets.ts re-runs the upload whenever the manifest
29
+ * does not record `uploaded`) instead of destroying the only copy of a
30
+ * release's maps on an unverified claim (`a-component-may-not-report-a-
31
+ * state-it-has-not-verified`; measured: the pre-2026-09 flow deleted the
32
+ * maps after an upload whose responses were never checked, so a missing
33
+ * token or a 4xx permanently lost a release's maps while the boot log kept
34
+ * saying "uploaded").
35
+ *
36
+ * Every upstream response is verified: the release-create call must return
37
+ * 2xx/208 and each file upload must return 2xx, else the failure is named
38
+ * (status + body head) in the result. Best-effort at the SERVICE level — a
39
+ * failed upload never blocks boot — but never at the CLAIM level: the
40
+ * result says what actually happened and the manifest records it.
24
41
  */
25
42
 
26
- import { readdir, rm } from "node:fs/promises";
43
+ import { mkdir, readdir, rename, rm } from "node:fs/promises";
27
44
  import { join } from "node:path";
28
45
  import { getAppLogger } from "@teamscala/logger/app-loggers";
29
46
  import { userHome } from "@teamscala/os/host-paths";
30
47
  import { spawnSync } from "@teamscala/os/spawn";
31
48
 
32
- export async function uploadSourceMaps(
49
+ export type SourceMapUploadStatus = "uploaded" | "failed" | "skipped";
50
+
51
+ export interface SourceMapUploadResult {
52
+ status: SourceMapUploadStatus;
53
+ /** Maps verified accepted by GlitchTip (2xx), never assumed. */
54
+ uploaded: number;
55
+ /** Maps present for this upload pass (moved to the retained cache). */
56
+ total: number;
57
+ /** One named failure per non-accepted upload (status + body head). */
58
+ failures: string[];
59
+ at: string;
60
+ }
61
+
62
+ const UPLOAD_TIMEOUT_MS = 30_000;
63
+
64
+ /** The retained, never-served home of a release's source maps. */
65
+ export function sourceMapsCacheDir(cwd: string, release: string): string {
66
+ return join(cwd, "dist", "sourcemaps", release);
67
+ }
68
+
69
+ /** The release (bare content hash) a manifest's hydratePath names. */
70
+ export function releaseFromHydratePath(hydratePath: string): string | null {
71
+ return /hydrate-([a-z0-9]+)\.js/.exec(hydratePath)?.[1] ?? null;
72
+ }
73
+
74
+ /**
75
+ * Move every `.map` in `outDir` into the retained cache for `release` and
76
+ * return their names. The served root ends the pass with zero `.map` files:
77
+ * any not moved (stale maps from an older build whose release cache already
78
+ * holds a copy) are deleted — source never ships via /assets/*.
79
+ */
80
+ async function moveMapsToCache(
81
+ cwd: string,
33
82
  outDir: string,
34
83
  release: string,
35
84
  log: ReturnType<typeof getAppLogger>,
36
- ): Promise<void> {
85
+ ): Promise<string[]> {
86
+ const cacheDir = sourceMapsCacheDir(cwd, release);
87
+ const moved: string[] = [];
88
+ for (const name of await readdir(outDir, "utf-8")) {
89
+ if (!name.endsWith(".map")) continue;
90
+ try {
91
+ await mkdir(cacheDir, { recursive: true });
92
+ await rename(join(outDir, name), join(cacheDir, name));
93
+ moved.push(name);
94
+ } catch (error) {
95
+ // The move IS the serve-safety guarantee — if it failed, delete the
96
+ // map rather than leave source in the served root.
97
+ log.warn("sourcemap cache move failed — map deleted (never serve source)", {
98
+ name,
99
+ error: error instanceof Error ? error.message : String(error),
100
+ });
101
+ await rm(join(outDir, name), { force: true });
102
+ }
103
+ }
104
+ for (const name of await readdir(outDir, "utf-8")) {
105
+ if (name.endsWith(".map")) {
106
+ log.warn("stale sourcemap removed from served root (older release)", { name });
107
+ await rm(join(outDir, name), { force: true });
108
+ }
109
+ }
110
+ return moved;
111
+ }
112
+
113
+ function authEnv(): { token: string; url: string } | null {
37
114
  const token = process.env.SENTRY_AUTH_TOKEN;
38
- const sentryUrl = process.env.SENTRY_URL;
39
- if (!token || !sentryUrl) return; // best-effort: no token/URL → skip
115
+ const url = process.env.SENTRY_URL;
116
+ if (!token || !url) return null;
117
+ return { token, url };
118
+ }
119
+
120
+ /** Upload the maps ALREADY RETAINED in the cache for `release`. */
121
+ export async function uploadCachedSourceMaps(
122
+ cwd: string,
123
+ release: string,
124
+ log: ReturnType<typeof getAppLogger>,
125
+ ): Promise<SourceMapUploadResult> {
126
+ const at = new Date().toISOString();
127
+ const auth = authEnv();
128
+ const cacheDir = sourceMapsCacheDir(cwd, release);
129
+ if (!auth) {
130
+ return { status: "skipped", uploaded: 0, total: 0, failures: [], at };
131
+ }
132
+ let names: string[] = [];
133
+ try {
134
+ names = (await readdir(cacheDir, "utf-8")).filter((n) => n.endsWith(".map"));
135
+ } catch {
136
+ names = [];
137
+ }
138
+ if (names.length === 0) {
139
+ return {
140
+ status: "failed",
141
+ uploaded: 0,
142
+ total: 0,
143
+ failures: [
144
+ `no cached source maps for release ${release} at ${cacheDir} — the build that produced them predates retention or they were removed; next bundle rebuild regenerates them`,
145
+ ],
146
+ at,
147
+ };
148
+ }
149
+ return uploadMapsFromCache(cacheDir, names, release, auth, log, at);
150
+ }
151
+
152
+ /** Upload source maps for the bundle just built into `outDir`. */
153
+ export async function uploadSourceMaps(
154
+ cwd: string,
155
+ outDir: string,
156
+ release: string,
157
+ log: ReturnType<typeof getAppLogger>,
158
+ ): Promise<SourceMapUploadResult> {
159
+ const at = new Date().toISOString();
160
+ const auth = authEnv();
161
+ // Inject debug IDs into the served bundles + maps (if glitchtip-cli is
162
+ // present). Runs on outDir BEFORE the cache move so both the served
163
+ // bundle and its retained map carry the same debug id. The CLI is
164
+ // optional — only used for the inject step.
165
+ const cli = join(userHome(), ".local", "bin", "glitchtip-cli");
166
+ if (await Bun.file(cli).exists()) {
167
+ spawnSync({
168
+ name: "glitchtip-cli-sourcemaps-inject",
169
+ command: [cli, "sourcemaps", "inject", outDir],
170
+ env: { ...process.env, SENTRY_AUTH_TOKEN: auth?.token ?? "" },
171
+ timeoutMs: 60_000,
172
+ captureStderr: true,
173
+ });
174
+ }
175
+ // The move is unconditional — the served root must never hold `.map`
176
+ // files, whether or not an upload will follow.
177
+ const names = await moveMapsToCache(cwd, outDir, release, log);
178
+ if (!auth) {
179
+ // Best-effort by design, but VISIBLE: one warn per boot names the
180
+ // missing env instead of a silent skip that reads as success.
181
+ log.warn(
182
+ `[build-client-assets] source maps NOT uploaded — SENTRY_AUTH_TOKEN/SENTRY_URL absent (maps retained at ${sourceMapsCacheDir(cwd, release)} for retry once configured)`,
183
+ { release, mapsRetained: names.length },
184
+ );
185
+ return { status: "skipped", uploaded: 0, total: names.length, failures: [], at };
186
+ }
187
+ return uploadMapsFromCache(
188
+ sourceMapsCacheDir(cwd, release),
189
+ names,
190
+ release,
191
+ auth,
192
+ log,
193
+ at,
194
+ );
195
+ }
196
+
197
+ async function uploadMapsFromCache(
198
+ cacheDir: string,
199
+ names: string[],
200
+ release: string,
201
+ auth: { token: string; url: string },
202
+ log: ReturnType<typeof getAppLogger>,
203
+ at: string,
204
+ ): Promise<SourceMapUploadResult> {
40
205
  const org = process.env.SENTRY_ORG ?? "scala";
41
206
  const project = process.env.SENTRY_PROJECT ?? "scala-hub";
42
- const cli = join(userHome(), ".local", "bin", "glitchtip-cli");
43
- const headers = { Authorization: `Bearer ${token}` };
207
+ const headers = { Authorization: `Bearer ${auth.token}` };
208
+ const failures: string[] = [];
209
+ let uploaded = 0;
44
210
  try {
45
- // Inject debug IDs into the bundles + maps (if glitchtip-cli is present).
46
- // The CLI is optional — only used for the inject step.
47
- if (await Bun.file(cli).exists()) {
48
- spawnSync({
49
- name: "glitchtip-cli-sourcemaps-inject",
50
- command: [cli, "sourcemaps", "inject", outDir],
51
- env: { ...process.env, SENTRY_AUTH_TOKEN: token },
52
- timeoutMs: 60_000,
53
- captureStderr: true,
54
- });
55
- }
56
- // Upload source maps via the direct release-files API (NOT the CLI's
57
- // chunk-upload — that hits a host-mismatch behind reverse proxies: the
58
- // server returns a stale internal URL the CLI refuses to send the token to).
59
- // The direct API posts to SENTRY_URL (which we control) — no server-returned
60
- // URL, no host mismatch.
61
- await fetch(`${sentryUrl}/api/0/organizations/${org}/releases/`, {
211
+ // Create the release (2xx or 208 already-exists are both "exists now").
212
+ // The direct API posts to SENTRY_URL (which we control) — not the CLI's
213
+ // chunk-upload, which hits a host-mismatch behind reverse proxies: the
214
+ // server returns a stale internal URL the CLI refuses to send the token
215
+ // to.
216
+ const releaseRes = await fetch(`${auth.url}/api/0/organizations/${org}/releases/`, {
62
217
  method: "POST",
218
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
63
219
  headers: { ...headers, "Content-Type": "application/json" },
64
220
  body: JSON.stringify({ version: release, projects: [project] }),
65
221
  });
66
- let uploaded = 0;
67
- for (const name of await readdir(outDir, "utf-8")) {
68
- if (!name.endsWith(".map")) continue;
222
+ if (!(releaseRes.ok || releaseRes.status === 208)) {
223
+ const body = (await releaseRes.text()).slice(0, 300);
224
+ failures.push(
225
+ `release create ${release} → HTTP ${releaseRes.status}: ${body}`,
226
+ );
227
+ }
228
+ for (const name of names) {
69
229
  try {
70
- const mapBytes = await Bun.file(join(outDir, name)).bytes();
230
+ const mapBytes = await Bun.file(join(cacheDir, name)).bytes();
71
231
  const form = new FormData();
72
- form.append("name", `~/assets/${name.replace(".map", "")}`);
232
+ form.append("name", `~/assets/${name.replace(/\.map$/, "")}`);
73
233
  form.append("file", new Blob([mapBytes]), name);
74
234
  const res = await fetch(
75
- `${sentryUrl}/api/0/projects/${org}/${project}/releases/${release}/files/`,
76
- { method: "POST", headers, body: form },
235
+ `${auth.url}/api/0/projects/${org}/${project}/releases/${release}/files/`,
236
+ {
237
+ method: "POST",
238
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
239
+ headers,
240
+ body: form,
241
+ },
77
242
  );
78
- if (res.ok) uploaded++;
243
+ if (res.ok) {
244
+ uploaded++;
245
+ } else {
246
+ const body = (await res.text()).slice(0, 300);
247
+ failures.push(`${name} → HTTP ${res.status}: ${body}`);
248
+ }
79
249
  } catch (error) {
80
- log.warn("sourcemap upload failed — remaining maps continue", error);
250
+ failures.push(
251
+ `${name} → ${error instanceof Error ? error.message : String(error)}`,
252
+ );
81
253
  }
82
254
  }
83
- log.info("[build-client-assets] source maps uploaded to GlitchTip", {
255
+ } catch (err) {
256
+ failures.push(
257
+ err instanceof Error ? err.message : String(err),
258
+ );
259
+ }
260
+ const status: SourceMapUploadStatus = failures.length === 0 && uploaded === names.length
261
+ ? "uploaded"
262
+ : "failed";
263
+ if (status === "uploaded") {
264
+ log.info("[build-client-assets] source maps uploaded to GlitchTip (verified)", {
84
265
  release,
85
266
  project,
86
267
  uploaded,
268
+ total: names.length,
87
269
  });
88
- } catch (err) {
89
- log.warn("[build-client-assets] source-map upload error (best-effort, skipped)", {
270
+ } else {
271
+ // Name every failure — a report that cannot answer "what do I do" is
272
+ // unfinished. Each failure carries its map name and HTTP status/body.
273
+ log.error("[build-client-assets] source map upload INCOMPLETE — maps retained for retry", {
90
274
  release,
91
- error: err instanceof Error ? err.message : String(err),
275
+ project,
276
+ uploaded,
277
+ total: names.length,
278
+ failures,
279
+ cacheDir,
92
280
  });
93
281
  }
94
- // Delete the .map files — never serve source. The debug IDs injected into the
95
- // served bundles are all the SDK needs; the maps live in GlitchTip keyed by them.
96
- for (const name of await readdir(outDir, "utf-8")) {
97
- if (name.endsWith(".map")) {
98
- try {
99
- await rm(join(outDir, name));
100
- } catch (error) {
101
- getAppLogger().warn("sourcemaps-upload.ts: bare catch around upstream call — logged, continuing (protective-layers-fail-explicitly)", error);
102
- }
103
- }
104
- }
282
+ return { status, uploaded, total: names.length, failures, at };
105
283
  }
@@ -64,6 +64,10 @@ import {
64
64
  readManifest,
65
65
  writeManifest,
66
66
  } from "./build-client-assets/manifest.ts";
67
+ import {
68
+ releaseFromHydratePath,
69
+ uploadCachedSourceMaps,
70
+ } from "./build-client-assets/sourcemaps-upload.ts";
67
71
  import { buildTailwindCss } from "./build-client-assets/styles-build.ts";
68
72
 
69
73
  export async function buildClientAssets(serviceSlug: string): Promise<void> {
@@ -74,7 +78,23 @@ export async function buildClientAssets(serviceSlug: string): Promise<void> {
74
78
  const existingManifest = await readManifest(outDir);
75
79
  const fingerprint = await computeInputFingerprint(cwd);
76
80
  if (existingManifest && existingManifest.inputFingerprint === fingerprint) {
77
- log.info(`[build-client-assets:${serviceSlug}] manifest up-to-date (skip)`);
81
+ // The bundle is fresh, but a fresh bundle with an UNVERIFIED source-map
82
+ // upload must not stay that way forever: retry the upload from the
83
+ // retained map cache on every boot until it verifies. Without this, one
84
+ // failed boot-time upload (GlitchTip down, token missing at that boot)
85
+ // permanently lost the release's maps — client stacks stayed minified
86
+ // until the next real input change forced a rebuild.
87
+ const recorded = existingManifest.sourceMaps?.status;
88
+ const release = releaseFromHydratePath(existingManifest.hydratePath);
89
+ if (recorded !== "uploaded" && release) {
90
+ log.info(
91
+ `[build-client-assets:${serviceSlug}] manifest up-to-date (skip); source maps previously ${recorded ?? "unrecorded"} — retrying upload for release ${release}`,
92
+ );
93
+ const sourceMaps = await uploadCachedSourceMaps(cwd, release, log);
94
+ await writeManifest(outDir, { ...existingManifest, sourceMaps });
95
+ } else {
96
+ log.info(`[build-client-assets:${serviceSlug}] manifest up-to-date (skip)`);
97
+ }
78
98
  return;
79
99
  }
80
100
  if (existingManifest && existingManifest.inputFingerprint !== fingerprint) {
@@ -177,10 +197,10 @@ async function performBuild(
177
197
  );
178
198
  }
179
199
 
180
- const hydratePath = hydrateResult.value;
200
+ const hydrate = hydrateResult.value;
181
201
  const stylesPath = stylesResult.value;
182
- if (!hydratePath || !stylesPath) {
183
- const reason = `bundle build returned empty path (hydrate=${hydratePath}, styles=${stylesPath})`;
202
+ if (!hydrate?.hydratePath || !stylesPath) {
203
+ const reason = `bundle build returned empty path (hydrate=${hydrate?.hydratePath ?? "<null>"}, styles=${stylesPath})`;
184
204
  await writeTombstone(reason);
185
205
  throw new Error(
186
206
  `[build-client-assets:${serviceSlug}] ${reason} — tombstone manifest written.`,
@@ -188,7 +208,12 @@ async function performBuild(
188
208
  }
189
209
 
190
210
  await mkdir(outDir, { recursive: true });
191
- await writeManifest(outDir, { stylesPath, hydratePath, inputFingerprint });
211
+ await writeManifest(outDir, {
212
+ stylesPath,
213
+ hydratePath: hydrate.hydratePath,
214
+ inputFingerprint,
215
+ sourceMaps: hydrate.sourceMaps,
216
+ });
192
217
 
193
218
  log.info(
194
219
  `[build-client-assets:${serviceSlug}] boot-time build complete in ${Date.now() - t0}ms`,
@@ -12,6 +12,7 @@
12
12
  import { join } from "node:path";
13
13
  import { getAppLogger } from "@teamscala/logger/app-loggers";
14
14
  import { registerFnRouters } from "./fn-routers/index.ts";
15
+ import type { InjectedDb8 } from "@teamscala/orpc/configure";
15
16
  import type { Hono } from "hono";
16
17
  import { bodyLimit } from "hono/body-limit";
17
18
 
@@ -70,6 +71,29 @@ export async function mountOrpcRouter(
70
71
  "@teamscala/orpc/configure"
71
72
  );
72
73
 
74
+ // Coexistence (Prisma 8 migration): create the v8 chained client when
75
+ // the shared platform contract ships inside @teamscala/db. Absence is
76
+ // the EXPECTED state for services that have not migrated — db8 stays
77
+ // undefined and v7 procedures are untouched. One boot-time creation,
78
+ // closed over by the request handler (connects lazily on first query).
79
+ let db8: InjectedDb8 | undefined;
80
+ try {
81
+ if (process.env.DATABASE_URL) {
82
+ const { createDbClientV8 } = await import(
83
+ "@teamscala/db/create-db-client-v8"
84
+ );
85
+ db8 = (await createDbClientV8({
86
+ connectionString: process.env.DATABASE_URL,
87
+ serviceName: serviceSlug,
88
+ })) as never;
89
+ }
90
+ } catch (err) {
91
+ log.info(
92
+ `[scala-install:${serviceSlug}] db8 (Prisma 8 chained client) unavailable: ${err instanceof Error ? err.message : err} — serving v7 procedures only`,
93
+ );
94
+ db8 = undefined;
95
+ }
96
+
73
97
  // Bridge the composed router into tool-executor so in-process tool
74
98
  // dispatch (chat agents' work_items/meetings/agreements/proposals
75
99
  // tools, etc.) resolves against this service's ORPC router + Prisma
@@ -160,7 +184,7 @@ export async function mountOrpcRouter(
160
184
  const prisma = getConfiguredPrisma();
161
185
  const result = await handler.handle(c.req.raw, {
162
186
  prefix: "/api/rpc",
163
- context: { prisma, headers: c.req.raw.headers, resolveUser },
187
+ context: { prisma, db8, headers: c.req.raw.headers, resolveUser },
164
188
  });
165
189
  if (!result?.matched) return c.notFound();
166
190
  return result.response;