/** * JSON encode/decode helpers for event payloads. Centralised so we * have one place to evolve (e.g. add structured-clone or BigInt * support) without touching emit/subscribe. */ export function encodePayload(payload: unknown): string | null { if (payload === undefined) return null; return JSON.stringify(payload); } export function decodePayload(raw: string | null): T { if (raw === null) return undefined as T; return JSON.parse(raw) as T; } export function serializeError(err: unknown): string { if (err instanceof Error) { return JSON.stringify({ name: err.name, message: err.message, stack: err.stack, }); } // An error-SHAPED plain object must not go down the String() path: that // stores `{"message":"[object Object]","value":{"message":"the real text"}}` // and the readable text disappears from every operator surface. The // dispatcher builds exactly such an object to append handler output. if (isErrorLike(err)) { return JSON.stringify({ message: err.message, stack: err.stack }); } try { return JSON.stringify({ message: String(err), value: err }); } catch { return JSON.stringify({ message: String(err) }); } } function isErrorLike(v: unknown): v is { message: string; stack?: string } { return ( typeof v === 'object' && v !== null && typeof (v as { message?: unknown }).message === 'string' ); } /** * Human-readable text for a stored `last_error` (whatever `serializeError` * wrote). Anything unparseable is returned as-is. * * Unwraps the double-wrapped `{"message":"[object Object]","value":{...}}` rows * written before the fix above. Those live for `failedRetentionDays` (90), so * unwrapping is the only way an operator can read the backlog they have today. */ export function describeError(stored: string | null): string | null { if (!stored) return null; let parsed: unknown; try { parsed = JSON.parse(stored); } catch { return stored; } return readMessage(parsed) ?? stored; } function readMessage(v: unknown): string | null { if (typeof v === 'string') return v; if (typeof v !== 'object' || v === null) return null; const obj = v as { message?: unknown; value?: unknown }; if (typeof obj.message === 'string' && obj.message !== '[object Object]') return obj.message; return readMessage(obj.value); }