Build: The JSONL Sink — Load
Maps to: Task 1.3. Kind: Build — spec and test names only. No implementation.
Objective
Implement JsonlSink — the L stage, and the file contract the rest of the world reads. It appends normalized Row values to a file as JSON Lines: one JSON object per line, appended (never truncated) so a run only ever adds to what came before. This is the exact format panoptes-gen consumes downstream, which is why "append-only, one row per line" is a contract and not an implementation detail.
Scaffold
Create: crates/etl-core/src/sink_jsonl.rs; declare the module and re-export JsonlSink from lib.rs.
Dependencies: none new. serde/serde_json and tokio were declared when you built etl-core. The async I/O uses tokio::fs and tokio::io::AsyncWriteExt, both under tokio's full features.
Expected result: cargo test -p etl-core sink → the sink tests pass.
The spec (givens)
JsonlSink — one field, path: PathBuf; constructor new(path: impl Into<PathBuf>). It implements the Sink trait from pipeline.rs:
#[async_trait]
impl Sink for JsonlSink {
async fn load(&self, rows: &[Row]) -> Result<usize, EtlError> { ... }
}
Behavior load must have:
- Open
pathwith create + append (tokio::fs::OpenOptions::new().create(true).append(true)). Append is the whole point: a secondloadmust not erase the first. - Serialize each
Rowwithserde_json::to_string. A serialization failure maps toEtlError::Parse(...). (In practiceRowalways serializes, but the boundary stays honest.) - Write one line per row — each JSON object followed by a
'\n'. Build the whole batch into one buffer and do a singlewrite_all, so a batch is one write, not N. - Return the count of rows written (
rows.len()). - The
?on file operations relies onEtlError's#[from] std::io::Error(Part I) — an I/O failure surfaces asEtlError::Iofor free.
Test names to hit
writes_one_row_per_line_and_roundtrips— write a temp path (usestd::env::temp_dir()plus the process id so parallel test runs do not collide, andremove_filefirst for a clean slate).loadtwo rows; assert the returned count is2; read the file back and assert it has exactly 2 lines;serde_json::from_strthe first line and assert it equals the first row you wrote. This proves both halves of the contract at once: one row per line, and a clean round-trip through serde.append_accumulates_across_loads— callloadtwice on the same path with the same 2 rows; read the file and assert it now has 4 lines. This is the append guarantee: the second call added to the file rather than replacing it.
load truncated, a re-run — or a second source writing to the same file — would silently destroy earlier rows, and the "idempotent, content-addressed writes" seam in Part IV would have nothing to build on. Append is the primitive; de-duplication comes later, as a wrapper around this sink, never as a change to it.
The build loop (you drive)
- Write
writes_one_row_per_line_and_roundtripsfirst. You need sample rows — reuse a couple ofRow::Launchvalues. Predict the returned count and the line count before you run. - Implement
loadwith create+append and the single bufferedwrite_all. - Run, check the round-trip assertion — if the deserialized row differs, your serialize/deserialize derives on the domain types disagree (revisit the
roundtripstest from the domain build). - Write
append_accumulates_across_loads. Predict: if you accidentally used.truncate(true)or.write(true)without.append(true), how many lines would this test see? Then confirm append gives 4. - Run green, commit.
Done when
cargo test -p etl-core sink is green. You now have all three stages — E (CelestrakSource), T (CelestrakTransform), L (JsonlSink) — each satisfying its trait from etl-core. A complete pipeline is now just "call extract, feed each RawRecord through transform, hand the rows to load." The next arcs make that wiring incremental, authenticated, retried, and eventually scheduled — but the seams are all in place as of this chapter.