1import { CallHandler, Injectable, NestInterceptor } from "@nestjs/common"; 2import type { ExecutionContext } from "@nestjs/common"; 3import { Observable } from "rxjs"; 4import { map } from "rxjs/operators"; 5 6/** 7 * Global BigInt serialization interceptor (TASK-0101 regression fix). 8 * 9 * Prisma models BigInt columns as JS `bigint`; `JSON.stringify` throws 10 * `TypeError: Do not know how to serialize a BigInt` on them, so any 11 * endpoint that returns a row containing one 500s through the exceptions 12 * filter. The first BigInt column shipped in Phase 9 13 * (`workspace.usageStorageBytes`, migration 14 * 20260929000000_workspace_usage_storage_bytes) — `POST /api/v1/auth/ 15 * register` returns the personal workspace row and broke the whole 16 * integration suite with this exact error. 17 * 18 * The canonical API representation of a byte/counter column is a decimal 19 * STRING (arbitrary precision — the client never arithmetic on it): every 20 * `bigint` value in a success response body is deep-converted to 21 * `string`. Conversion is structural (plain objects/arrays only) and 22 * memoized per response, so a 10 MB payload is walked once and only 23 * `bigint` leaves are rewritten; shared references survive the walk. A 24 * read-only `containsBigInt` scan runs first, so the clone is allocated 25 * only when the response actually contains a BigInt column. 26 * 27 * Registration order matters. Nest runs interceptors in provider order with 28 * the FIRST one outermost; on the way back the INNER interceptor's `map` 29 * applies first, the outer one's last. The idempotency interceptor (see 30 * AppModule) caches the response body for 24 h replay, so this conversion 31 * must apply BEFORE that cache. That requires this interceptor to be INNER 32 * to idempotency — i.e. listed AFTER it in the `APP_INTERCEPTOR` array. 33 * Otherwise the raw `bigint` body is what gets cached (Redis would store a 34 * `bigint` object and the 24 h replay would still 500 on serialize). 35 */ 36@Injectable() 37export class BigIntSerializationInterceptor implements NestInterceptor { 38 intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> { 39 return next.handle().pipe( 40 map((data: unknown) => { 41 // HEAD / raw-body routes return void — nothing to convert. 42 if (data === null || data === undefined) return data; 43 // Fast path: the overwhelming majority of responses carry no BigInt 44 // column → a read-only scan and no clone at all. 45 if (!containsBigInt(data)) return data; 46 return walk(data); 47 }), 48 ); 49 } 50} 51 52/** Read-only structural scan for any bigint leaf (no allocation). */ 53function containsBigInt(node: unknown, seen: WeakSet<object> = new WeakSet()): boolean { 54 if (typeof node === "bigint") return true; 55 if (Array.isArray(node)) { 56 if (seen.has(node)) return false; 57 seen.add(node); 58 for (const item of node) if (containsBigInt(item, seen)) return true; 59 return false; 60 } 61 if (typeof node === "object" && node !== null) { 62 if (seen.has(node)) return false; 63 seen.add(node); 64 for (const v of Object.values(node)) if (containsBigInt(v, seen)) return true; 65 return false; 66 } 67 return false; 68} 69 70function walk(node: unknown, memo: WeakMap<object, unknown> = new WeakMap()): unknown { 71 if (typeof node === "bigint") return node.toString(); 72 if (Array.isArray(node)) { 73 if (memo.has(node)) return memo.get(node); 74 const out: unknown[] = new Array(node.length); 75 memo.set(node, out); 76 for (let i = 0; i < node.length; i++) out[i] = walk(node[i], memo); 77 return out; 78 } 79 if (typeof node === "object" && node !== null) { 80 if (memo.has(node)) return memo.get(node); 81 // Only PLAIN objects are rebuilt (Prisma rows are plain). Dates, Buffers 82 // and class instances pass through untouched — `JSON.stringify` already 83 // serializes them correctly and a walk would lose their identity. 84 const proto = Object.getPrototypeOf(node); 85 if (proto !== Object.prototype && proto !== null) return node; 86 const out: Record<string, unknown> = {}; 87 memo.set(node, out); 88 for (const [k, v] of Object.entries(node)) out[k] = walk(v, memo); 89 return out; 90 } 91 return node; 92} 93