Skip to content

Export every page and window into one file - #42

Open
fedealconada wants to merge 1 commit into
prooflinedev:mainfrom
fedealconada:feat/export-all-v2
Open

fedealconada wants to merge 1 commit into
prooflinedev:mainfrom
fedealconada:feat/export-all-v2

Conversation

@fedealconada

@fedealconada fedealconada commented Nov 6, 2026 •

Copy link
Copy Markdown

Who this helps

People who run the CLI from a shell or a cron job and want one complete file for a range, such as a quarter of review data for a spreadsheet or a warehouse load. Today proofline export returns one page of one window of at most 31 days. A full export means scripting cursor loops and window math, then joining pages, and the README warns that partial output from several invocations must never be treated as complete. --all --output FILE does this safely in one command.

What changed

  • proofline export has two new flags, --all and --output FILE. --all requires --output, --output requires --all, and --all conflicts with --cursor. These are clap attributes, so clap reports the errors.
  • With --all, --from/--to can span any length. The range [from, to) is split into consecutive windows of at most 31 days. Each window's exclusive end is exactly the next window's inclusive start. A span that already fits in one window is sent exactly as typed. Generated boundaries are UTC RFC3339.
  • The CLI refuses malformed timestamps and --to <= --from before it sends any request.
  • For each window, the CLI follows X-Proofline-Export-Next-Cursor until the header is absent. The per-page checks stay the same: format and version headers, a settled horizon, and UTF-8. The page fetch and its checks now live in one shared function, export_page, which both paths use.
  • CSV: the header is written once, and every later page must have an identical header. Records keep their CRLF line endings. NDJSON pages are concatenated. In both formats, each non-empty page ends with a newline, so a record from one page cannot join a record from the next.
  • Atomic output: pages are written to a temporary file in FILE's directory. The temporary file is fsynced and then renamed over FILE only after every page of every window succeeds. Any failure leaves an existing FILE untouched and leaves no temporary file behind.
  • On success, settled-until: <earliest horizon across windows> goes to stderr through terminal_safe.
  • README: three lines in the export section describe --all --output.

Decisions

  • No date library. The crate deliberately has few dependencies. So src/tools.rs has a small RFC3339 parser and a UTC formatter built on Howard Hinnant's days_from_civil and civil_from_days. It refuses leap seconds (:60) and fractions finer than a nanosecond instead of rounding them.
  • Earliest horizon, not latest. Each window has its own settled horizon. The earliest one is the only guarantee that holds for the whole file.
  • Fail closed instead of "best effort" merging. The export fails in each of these cases:
    • the settled horizon changes between pages of one window, because those pages describe different snapshots
    • a cursor is empty or repeats one already seen in the window (loop guard)
    • the export goes past 1000 pages in total, with a message that says to use a shorter range
    • a later CSV page has a different header
    • a CSV page has no header
    • a window's settled horizon cannot be parsed
  • How this differs from the SDK pagination PRs (sdk-python Expose matching local findings in the public contract #27, Expose cited files in check results #31, Sync optional assessment coverage metadata #35, sdk-typescript Verify hosted installation of CLI 0.1.7 #29): those add page iterators for library callers. This change is in the CLI. It writes a file, splits windows, and the write is atomic.

Two small side effects:

  • The single-page path now checks that the next-cursor header is readable before it writes anything. Before, it checked after printing the page. Valid responses behave exactly as before.
  • On Unix, tempfile creates the file with mode 0600, so the exported FILE ends up 0600, including when it replaces an existing file.

How it was verified

Rebased on main at 14c8f4f. cargo fmt --check and cargo clippy --locked --all-targets -- -D warnings pass. cargo test --locked --no-fail-fast passes except tests/agent_install.rs unattended_registration_preserves_settings_and_uses_the_installed_binary, which fails the same way on main at 14c8f4f on my machine, so it looks environment specific and unrelated to this change.

New tests:

  • tests/http.rs
    • export_all_follows_every_cursor_into_one_ndjson_file: three pages, one without a final newline. Checks the exact file content, the cursors sent (including one that needs URL encoding), empty stdout and the stderr horizon.
    • export_all_splits_a_long_range_into_adjacent_windows_with_one_csv_header: 2026-08-01 to 2026-10-10. Checks the exact (from, to, cursor) queries sent, a single header, and that the earliest window horizon is reported.
    • export_all_failure_leaves_no_file_and_an_existing_file_untouched
    • export_all_refuses_inconsistent_pages: horizon change within a window, repeated cursor, empty cursor, different CSV header
    • export_all_stops_at_the_page_cap: exactly 1000 requests, then a refusal
    • export_all_needs_output_conflicts_with_cursor_and_checks_the_range_first: the clap refusals, plus malformed and empty ranges refused with zero requests
  • Unit tests in src/tools.rs
    • windows share each exclusive end as the next start (including an offset and a fractional start)
    • a fitting span is sent as typed
    • malformed or empty ranges are refused (leap second, 10 fraction digits, Feb 29 in a non-leap year, missing offset, trailing space, non-ASCII digit, …)
    • civil dates round-trip

Deliberate breaks. Each one made at least one test fail, and the code was then restored byte-for-byte:

  1. The next window starts one second after the previous end: windows_share_each_exclusive_end_as_the_next_start and …splits_a_long_range… fail.
  2. The CSV header is written for every page: …splits_a_long_range… fails.
  3. The export is written directly to FILE instead of the temporary file: …failure_leaves_no_file_and_an_existing_file_untouched fails, along with four other export tests.
  4. The horizon equality check is removed: export_all_refuses_inconsistent_pages fails.

Manual run against the mock server (manual-test/export_mock.py) at http://127.0.0.1:8788, using export reviews --from 2026-08-01T00:00:00Z --to 2026-10-10T00:00:00Z --format csv --all --output reviews.csv:

  • Normal run: exit 0. It sent 6 requests over 3 windows (08-01→09-01, 09-01→10-02, 10-02→10-10), 2 pages each. The file has 12 rows and one header, and stderr shows settled-until: 2026-11-05T09:55:00Z.
  • FAIL=1 (HTTP 500 on the second page of window 2): exit 1 with returned HTTP 500. The existing reviews.csv has the same checksum as before, and no temporary file is left in the directory.

I prototyped this earlier and rebuilt it in a fresh session so the prompts below are complete.

🤖 Generated with Claude Code

`proofline export --all --output FILE` follows every cursor, splits a longer
range into adjacent 31-day windows, writes one CSV header, and replaces FILE
only after every page succeeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant