import { CallHandler, Injectable, NestInterceptor } from "@nestjs/common"; import type { ExecutionContext } from "@nestjs/common"; import { Observable } from "rxjs"; import { map } from "rxjs/operators"; /** * Global BigInt serialization interceptor (TASK-0101 regression fix). * * Prisma models BigInt columns as JS `bigint`; `JSON.stringify` throws * `TypeError: Do not know how to serialize a BigInt` on them, so any * endpoint that returns a row containing one 500s through the exceptions * filter. The first BigInt column shipped in Phase 9 * (`workspace.usageStorageBytes`, migration * 20260929000000_workspace_usage_storage_bytes) — `POST /api/v1/auth/ * register` returns the personal workspace row and broke the whole * integration suite with this exact error. * * The canonical API representation of a byte/counter column is a decimal * STRING (arbitrary precision — the client never arithmetic on it): every * `bigint` value in a success response body is deep-converted to * `string`. Conversion is structural (plain objects/arrays only) and * memoized per response, so a 10 MB payload is walked once and only * `bigint` leaves are rewritten; shared references survive the walk. A * read-only `containsBigInt` scan runs first, so the clone is allocated * only when the response actually contains a BigInt column. * * Registration order matters. Nest runs interceptors in provider order with * the FIRST one outermost; on the way back the INNER interceptor's `map` * applies first, the outer one's last. The idempotency interceptor (see * AppModule) caches the response body for 24 h replay, so this conversion * must apply BEFORE that cache. That requires this interceptor to be INNER * to idempotency — i.e. listed AFTER it in the `APP_INTERCEPTOR` array. * Otherwise the raw `bigint` body is what gets cached (Redis would store a * `bigint` object and the 24 h replay would still 500 on serialize). */ @Injectable() export class BigIntSerializationInterceptor implements NestInterceptor { intercept(_context: ExecutionContext, next: CallHandler): Observable { return next.handle().pipe( map((data: unknown) => { // HEAD / raw-body routes return void — nothing to convert. if (data === null || data === undefined) return data; // Fast path: the overwhelming majority of responses carry no BigInt // column → a read-only scan and no clone at all. if (!containsBigInt(data)) return data; return walk(data); }), ); } } /** Read-only structural scan for any bigint leaf (no allocation). */ function containsBigInt(node: unknown, seen: WeakSet = new WeakSet()): boolean { if (typeof node === "bigint") return true; if (Array.isArray(node)) { if (seen.has(node)) return false; seen.add(node); for (const item of node) if (containsBigInt(item, seen)) return true; return false; } if (typeof node === "object" && node !== null) { if (seen.has(node)) return false; seen.add(node); for (const v of Object.values(node)) if (containsBigInt(v, seen)) return true; return false; } return false; } function walk(node: unknown, memo: WeakMap = new WeakMap()): unknown { if (typeof node === "bigint") return node.toString(); if (Array.isArray(node)) { if (memo.has(node)) return memo.get(node); const out: unknown[] = new Array(node.length); memo.set(node, out); for (let i = 0; i < node.length; i++) out[i] = walk(node[i], memo); return out; } if (typeof node === "object" && node !== null) { if (memo.has(node)) return memo.get(node); // Only PLAIN objects are rebuilt (Prisma rows are plain). Dates, Buffers // and class instances pass through untouched — `JSON.stringify` already // serializes them correctly and a walk would lose their identity. const proto = Object.getPrototypeOf(node); if (proto !== Object.prototype && proto !== null) return node; const out: Record = {}; memo.set(node, out); for (const [k, v] of Object.entries(node)) out[k] = walk(v, memo); return out; } return node; }