- Add per-collection row cap (default 10k, env EXPORT_ROW_CAP) via Prisma take on all findMany calls - Add total size cap (default 100MB, env EXPORT_SIZE_CAP_MB); throws PayloadTooLargeException (413) when exceeded - Convert response to Node.js Readable stream piped via NestJS StreamableFile to avoid large in-memory buffers - Export ExportUserDataResult interface (stream + truncated flag) from handler - Update controller to set Content-Type/Content-Disposition headers and return StreamableFile - Document EXPORT_ROW_CAP and EXPORT_SIZE_CAP_MB env vars in Swagger - Extend tests: row-cap assertion (take arg), size-cap 413 path, stream assertions Fixes GOO-223 (M-1 from GOO-200 audit). Co-Authored-By: Paperclip <noreply@paperclip.ing>
70 lines
2.3 KiB
TypeScript
70 lines
2.3 KiB
TypeScript
import { isUuidV7 } from './uuid-v7';
|
|
|
|
export const EVENT_ENVELOPE_SCHEMA_VERSION = 1;
|
|
|
|
export interface EventEnvelope<TPayload = unknown> {
|
|
schemaVersion: number;
|
|
eventId: string;
|
|
eventType: string;
|
|
occurredAt: string;
|
|
producer: string;
|
|
traceId: string;
|
|
payload: TPayload;
|
|
}
|
|
|
|
const TRACE_ID_RE = /^[0-9a-f]{32}$/i;
|
|
const ISO_8601_RE =
|
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
|
|
export interface EnvelopeValidationIssue {
|
|
path: string;
|
|
message: string;
|
|
}
|
|
|
|
export function validateEnvelope(envelope: unknown): EnvelopeValidationIssue[] {
|
|
const issues: EnvelopeValidationIssue[] = [];
|
|
if (envelope === null || typeof envelope !== 'object') {
|
|
return [{ path: '$', message: 'envelope must be an object' }];
|
|
}
|
|
const e = envelope as Record<string, unknown>;
|
|
if (e['schemaVersion'] !== EVENT_ENVELOPE_SCHEMA_VERSION) {
|
|
issues.push({
|
|
path: 'schemaVersion',
|
|
message: `expected ${EVENT_ENVELOPE_SCHEMA_VERSION}, got ${String(e['schemaVersion'])}`,
|
|
});
|
|
}
|
|
if (typeof e['eventId'] !== 'string' || !isUuidV7(e['eventId'])) {
|
|
issues.push({ path: 'eventId', message: 'must be a UUIDv7 string' });
|
|
}
|
|
if (
|
|
typeof e['eventType'] !== 'string' ||
|
|
!/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(e['eventType'])
|
|
) {
|
|
issues.push({
|
|
path: 'eventType',
|
|
message: 'must match /^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$/',
|
|
});
|
|
}
|
|
if (typeof e['occurredAt'] !== 'string' || !ISO_8601_RE.test(e['occurredAt'])) {
|
|
issues.push({ path: 'occurredAt', message: 'must be an ISO-8601 timestamp' });
|
|
}
|
|
if (typeof e['producer'] !== 'string' || e['producer'].length === 0) {
|
|
issues.push({ path: 'producer', message: 'must be a non-empty string' });
|
|
}
|
|
if (typeof e['traceId'] !== 'string' || !TRACE_ID_RE.test(e['traceId'])) {
|
|
issues.push({ path: 'traceId', message: 'must be 32 hex characters' });
|
|
}
|
|
if (!('payload' in e)) {
|
|
issues.push({ path: 'payload', message: 'is required (use {} for empty)' });
|
|
}
|
|
return issues;
|
|
}
|
|
|
|
export function assertValidEnvelope(envelope: unknown): asserts envelope is EventEnvelope {
|
|
const issues = validateEnvelope(envelope);
|
|
if (issues.length > 0) {
|
|
const flat = issues.map((i) => `${i.path}: ${i.message}`).join('; ');
|
|
throw new Error(`Invalid EventEnvelope — ${flat}`);
|
|
}
|
|
}
|