Summer File — Streaming Zip Exporter
Gate: Verify summer-core loaded and io.f8a.summer:summer-platform in build.gradle before proceeding.
Module: io.f8a.summer:summer-file (added 0.3.5)
Package: io.f8a.summer.file.export
Activation: Manual dependency — no Spring auto-config. Pure utility module.
This SKILL.md tracks LATEST stable (0.3.5). First shipped in 0.3.5; no overlays for older versions (module did not exist).
When to reach for it
Any service streaming rows as .zip of .xlsx/.csv/.json files. Before 0.3.5 every service had its own ZipOutputStream + chunk + size-cap boilerplate; this replaces it with one call.
Skip when export is single small file (write directly) or format is itself an archive (e.g. SQLite dump).
Public API
| Type | Role |
|---|---|
ExportRow |
Marker interface for any row type. |
DatedExportRow extends ExportRow |
Sub-interface with LocalDate date() — rows are grouped into one zip entry per date. |
ChunkWriter<R> |
Functional interface — caller owns the per-file format (xlsx, csv, …). The exporter never inspects rows. |
ExportSpec<R> |
Lombok @Builder config: baseName, fileExtension (default .xlsx), maxRowsPerFile (default 100,000), maxZipSizeBytes (0 disables), chunkWriter. |
ZipExporter |
Static writeZip / pipeZip — both Iterable<R> and Flux<R> overloads. |
SizeLimitedOutputStream |
FilterOutputStream that throws IOException past maxBytes. Used internally; exported for standalone use. |
Behavior
- Plain
ExportRow→ flat output:<baseName>_<seq><ext>(payments_1.xlsx,payments_2.xlsx, …). DatedExportRow→ grouped:<YYYY-MM-DD>_<seq><ext>(2026-05-08_1.xlsx, …). Rows must arrive contiguous by date — new group emitted whendate()changes.maxRowsPerFilecaps any single file. Past cap, new entry opens with next sequence suffix.maxZipSizeBytes > 0wraps output inSizeLimitedOutputStream, aborts withIOExceptionon overflow.- Empty input still emits exactly one entry (chunk writer invoked with empty list) — zip always has ≥1 file.
Threading
writeZipiterates on calling thread and may block (POI workbook serialization is synchronous).Flux<R>overload bridges viaFlux.toIterable()— must run on blocking-capable scheduler (Schedulers.boundedElastic()or dedicated executor). Never call on Reactor event-loop thread.pipeZipschedules writer for you — preferred when downstream is an uploader.
Stream-to-uploader pattern (pipeZip)
Standard "stream → upload" path without temp file or in-memory buffer. Returns PipedInputStream to hand to storage client.
PipedInputStream in = ZipExporter.pipeZip(
repo.streamAll(filter), // Flux<R> — the R2DBC case
spec,
r -> Schedulers.boundedElastic().schedule(r));
storage.upload(bucket, key, in);
On completion, writer closes PipedOutputStream, reader sees EOF, upload finishes. If writer throws, PipedInputStream closes too — blocked reader unblocks with "Pipe closed" IOException.
Minimal example — flat xlsx export
record TxnRow(String id, BigDecimal amount, Instant at) implements ExportRow {}
ChunkWriter<TxnRow> xlsxWriter = (rows, out) -> {
try (Workbook wb = new SXSSFWorkbook(100)) {
Sheet sheet = wb.createSheet("transactions");
int r = 0;
Row header = sheet.createRow(r++);
header.createCell(0).setCellValue("id");
header.createCell(1).setCellValue("amount");
header.createCell(2).setCellValue("at");
for (TxnRow row : rows) {
Row xr = sheet.createRow(r++);
xr.createCell(0).setCellValue(row.id());
xr.createCell(1).setCellValue(row.amount().doubleValue());
xr.createCell(2).setCellValue(row.at().toString());
}
wb.write(out); // exporter owns `out` — DO NOT close it
}
};
ExportSpec<TxnRow> spec = ExportSpec.<TxnRow>builder()
.baseName("transactions")
.maxRowsPerFile(50_000)
.maxZipSizeBytes(50L * 1024 * 1024)
.chunkWriter(xlsxWriter)
.build();
ZipExporter.writeZip(rows, out, spec);
Date-grouped variant
Implement DatedExportRow, leave baseName unset. Output: 2026-05-07_1.xlsx, 2026-05-08_1.xlsx, 2026-05-08_2.xlsx, …
record DailyRow(LocalDate date, ...) implements DatedExportRow {
@Override public LocalDate date() { return date; }
}
Rows must arrive in date order. For R2DBC, add ORDER BY <date_col>, <id> to query.
Gradle
implementation 'io.f8a.summer:summer-file'
No auto-config; small utility jar. Apache POI / OpenCSV (or whatever ChunkWriter uses) must be in service dependencies.
Rules
- Never close
OutputStreaminsideChunkWriter— exporter owns lifecycle. Early close truncates zip. - Pick
maxRowsPerFileper downstream tool limits. Excel chokes past ~1M rows; many email systems cap at 25 MB. - For
Fluxinputs always use blocking-capable scheduler. Event-loop threads must not block. - Date-grouped exports must receive rows pre-sorted by
date(). Exporter splits on changes, does not sort. maxZipSizeBytes = 0= no cap (explicit opt-out). Production exports of unknown size SHOULD set concrete cap — fail loud rather than fill disk/PVC.
References
- references/versions/0.3.5.md — Initial release notes (this is when the module shipped).
- references/export-examples.md — End-to-end recipes: R2DBC → uploader; date-grouped statements; CSV writer variant.
For the full feature × version table see summer-core/references/version-matrix.md.
Related Skills
- summer-core — Gate;
Txidfor transaction IDs printed inside the rows. - summer-data — Source
Flux<R>typically comes from R2DBC repositories.