@teamscala/service-boot 0.23.477 → 0.23.479

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.477",
9
+ "version": "0.23.479",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -14,6 +14,7 @@ import {
14
14
  type ReportAssessmentInput,
15
15
  type StageBand,
16
16
  } from "./assessment-report-data.ts";
17
+ import { assessmentResearchFindingsSchema } from "@teamscala/db-validation/generated/assessment-research-findings";
17
18
 
18
19
  const ANNA_LINKEDIN_RAW = "https://www.linkedin.com/in/annaosherov/";
19
20
  const ANNA_KEY = "linkedin.com/in/annaosherov";
@@ -173,7 +174,17 @@ describe("research shaping", () => {
173
174
  ).toThrow(/superseded/);
174
175
  });
175
176
 
176
- test("malformed authored findings throw instead of silently rendering garbage", () => {
177
+ // The db-validation generated schema is the contract this parse enforces.
178
+ // While the generated artifact is still the permissive v.record fallback
179
+ // (the codegen engine that emits the faithful schema embeds
180
+ // STRUCTURED_JSONB_KEYS at its build and needs an engine rebuild with
181
+ // @teamscala/codegen >= 2.1.585 — then `scala-tools codegen run --output
182
+ // db-column-validation --force`, publish db-validation, bump here), this
183
+ // test activates itself: the skip is a property of the shipped artifact,
184
+ // not of the expectation.
185
+ const schemaIsFaithful = !("key" in (assessmentResearchFindingsSchema as object));
186
+ const enforceTest = schemaIsFaithful ? test : test.skip;
187
+ enforceTest("malformed authored findings throw instead of silently rendering garbage", () => {
177
188
  expect(() => readResearchFindings({ groups: "not-an-array" })).toThrow();
178
189
  });
179
190
  });
@@ -13,7 +13,10 @@ import { afterEach, describe, expect, mock, test } from "bun:test";
13
13
  import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import { mountOrpcRouter } from "./mount-orpc-router.ts";
16
-
16
+ // Captured at module-load time — BEFORE any mock.module() in the test bodies
17
+ // replaces the specifier — so the error-hook test can restore the REAL
18
+ // configured-primitive surface the mount wires the recorder through.
19
+ import * as orpcConfigureReal from "@teamscala/orpc/configure";
17
20
  const originalCwd = process.cwd();
18
21
  const tempDirs: string[] = [];
19
22
 
@@ -85,3 +88,96 @@ describe("mountOrpcRouter generated-artifact seam", () => {
85
88
  expect(seenKeys).not.toContain("getMcpGatewayUrl");
86
89
  });
87
90
  });
91
+
92
+ describe("mountOrpcRouter procedure error hook", () => {
93
+ test("a throwing procedure through the mounted route records a durable event with the stack", async () => {
94
+ const service = await mkdtemp(join(tmpdir(), "mount-orpc-router-test-"));
95
+ tempDirs.push(service);
96
+ await mkdir(join(service, "prisma/generated/orpc/routers"), {
97
+ recursive: true,
98
+ });
99
+ await Bun.file(join(service, "prisma/schema.prisma")).write(
100
+ 'datasource db { provider = "postgresql" }',
101
+ );
102
+ await Bun.file(
103
+ join(service, "prisma/generated/orpc/routers/index.ts"),
104
+ ).write("export const router = {};\n");
105
+ process.chdir(service);
106
+
107
+ const { os } = await import("@orpc/server");
108
+ // The REAL installed orpc surface stays live (configure +
109
+ // procedure-error-report + the RPCHandler the mount builds) so the
110
+ // asserted record proves the wiring, not a mock agreeing with itself.
111
+ // Only the app router source and the event-log write boundary are
112
+ // replaced. The earlier test in this file partially mocked the
113
+ // configure specifier; restore the real exports first. getPrisma is
114
+ // configured because the route reads it per request.
115
+ mock.module("@teamscala/orpc/configure", () => orpcConfigureReal);
116
+ const { configure: orpcConfigure } = await import(
117
+ "@teamscala/orpc/configure"
118
+ );
119
+ orpcConfigure({ getPrisma: () => ({}) as never });
120
+
121
+ const recordedEvents: Array<{ kind: string; payload: unknown }> = [];
122
+ mock.module("@teamscala/orpc/app-router", () => ({
123
+ getAppRouter: async () => ({
124
+ fn: {
125
+ test: {
126
+ boom: os.handler(async () => {
127
+ throw new ReferenceError("mount-boundary boom");
128
+ }),
129
+ },
130
+ },
131
+ }),
132
+ }));
133
+ mock.module("@teamscala/event-log/record-event", () => ({
134
+ recordEvent: (input: { kind: string; payload: unknown }) => {
135
+ recordedEvents.push(input);
136
+ },
137
+ }));
138
+
139
+ let routeHandler: ((c: unknown) => Promise<Response>) | undefined;
140
+ const app = {
141
+ post: (
142
+ _path: string,
143
+ ...handlers: Array<(c: unknown) => Promise<Response>>
144
+ ) => {
145
+ routeHandler = handlers.at(-1);
146
+ },
147
+ };
148
+ await mountOrpcRouter(app as never, "scala-mcp");
149
+ expect(routeHandler).toBeTypeOf("function");
150
+
151
+ const response = await routeHandler?.({
152
+ req: {
153
+ raw: new Request("http://localhost/api/rpc/fn/test/boom", {
154
+ method: "POST",
155
+ headers: { "content-type": "application/json" },
156
+ body: JSON.stringify({}),
157
+ }),
158
+ path: "/api/rpc/fn/test/boom",
159
+ method: "POST",
160
+ },
161
+ notFound: () => new Response(null, { status: 404 }),
162
+ });
163
+
164
+ expect(response?.status).toBe(500);
165
+ expect(recordedEvents).toHaveLength(1);
166
+ const { kind, payload } = recordedEvents[0];
167
+ expect(kind).toBe("orpc.procedure.failed");
168
+ const record = payload as {
169
+ procedure: string;
170
+ errorName: string;
171
+ message: string;
172
+ errorId: string;
173
+ stackHead: string;
174
+ };
175
+ expect(record.procedure).toBe("fn.test.boom");
176
+ expect(record.errorName).toBe("ReferenceError");
177
+ expect(record.message).toContain("mount-boundary boom");
178
+ expect(record.errorId).toBeTruthy();
179
+ const stackLines = record.stackHead.split("\n");
180
+ expect(stackLines.length).toBeGreaterThanOrEqual(2);
181
+ expect(stackLines[0]).toContain("ReferenceError");
182
+ });
183
+ });
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { join } from "node:path";
13
13
  import { getAppLogger } from "@teamscala/logger/app-loggers";
14
- import type { InjectedDb8 } from "@teamscala/db/db8-registry";
14
+ import type { Db8Client } from "@teamscala/db/db8-registry";
15
15
  import type { Hono } from "hono";
16
16
  import { bodyLimit } from "hono/body-limit";
17
17
 
@@ -94,8 +94,28 @@ export async function mountOrpcRouter(
94
94
  const router = await getAppRouter();
95
95
  if (!router || Object.keys(router).length === 0) return null;
96
96
 
97
+ // The mount error hook: every procedure error crossing this boundary is
98
+ // shaped by buildErrorReport (errorId, class, stack head) and recorded
99
+ // durably via event-log before ORPC swallows it into a stackless 500.
100
+ // The recorder is wired HERE because orpc is primitives tier and cannot
101
+ // import @teamscala/event-log across a same-tier edge — the boot layer
102
+ // owns that dependency.
103
+ const { procedureErrorReportInterceptor } = await import(
104
+ "@teamscala/orpc/procedure-error-report"
105
+ );
106
+ const { recordEvent } = await import("@teamscala/event-log/record-event");
107
+ const { configure: orpcConfigure } = await import(
108
+ "@teamscala/orpc/configure"
109
+ );
110
+ orpcConfigure({
111
+ procedureErrorRecorder: (record) =>
112
+ recordEvent({ kind: "orpc.procedure.failed", payload: record }),
113
+ });
114
+
97
115
  const { RPCHandler } = await import("@orpc/server/fetch");
98
- const handler = new RPCHandler(router as never, {});
116
+ const handler = new RPCHandler(router as never, {
117
+ interceptors: [procedureErrorReportInterceptor()] as never,
118
+ });
99
119
 
100
120
  // The bootloader injects the service Prisma client into ORPC's configured
101
121
  // primitive. Reading it through that surface keeps this mount on the same
@@ -109,7 +129,7 @@ export async function mountOrpcRouter(
109
129
  // the EXPECTED state for services that have not migrated — db8 stays
110
130
  // undefined and v7 procedures are untouched. One boot-time creation,
111
131
  // closed over by the request handler (connects lazily on first query).
112
- let db8: InjectedDb8 | undefined;
132
+ let db8: Db8Client | undefined;
113
133
  try {
114
134
  if (process.env.DATABASE_URL) {
115
135
  const { createDbClientV8 } = await import(
@@ -175,7 +195,12 @@ export async function mountOrpcRouter(
175
195
  import("@teamscala/db/work-items"),
176
196
  import("@teamscala/email-client/client"),
177
197
  ]);
178
- configureWorkItems({ emailSender: createEmailClient(), logger: log });
198
+ configureWorkItems({
199
+ // The injected contract is the template-send subset; the real
200
+ // client's send agrees at every call site ({to, template, vars}).
201
+ emailSender: createEmailClient() as never,
202
+ logger: log,
203
+ });
179
204
  } catch (err) {
180
205
  log.warn(
181
206
  `[scala-install:${serviceSlug}] work-items email sender not wired: ${err instanceof Error ? err.message : err}`,
@@ -234,11 +259,10 @@ export async function mountOrpcRouter(
234
259
  context: { prisma, db8, headers: c.req.raw.headers, resolveUser },
235
260
  });
236
261
  if (!result?.matched) return c.notFound();
237
- // ORPC swallows procedure errors into a 500 response with a
238
- // generic INTERNAL_SERVER_ERROR body. Without logging here the
239
- // only signal is a fleet-wide spike in 500s with no traceback —
240
- // measured 2026-09-04 when the regenerated prisma client shipped
241
- // an empty orm and every model action returned a silent 500.
262
+ // Response-side signal only (a fleet-wide 500 spike with no
263
+ // traceback — measured 2026-09-04). The real error, its class and
264
+ // its stack are captured by the mount error hook above; this line
265
+ // stays for failures that reach the boundary without a throw.
242
266
  if (result.response.status >= 500) {
243
267
  log.error(
244
268
  `[ORPC] ${c.req.method} ${c.req.path} → ${result.response.status}`,