Incremental Sync¶
pplx-export batch is built to run often: each run exports only what is new or
changed, heals gaps left by interrupted runs, and never retouches threads the
platform has already retired. The single source of truth for "what has been
exported" is index/batch_state.json (BatchState,
pplx_export/core/state.py:63), updated after every thread — there is no
separate shadow copy.
Prerequisites and basic usage¶
pplx-export index --account alice # refresh index/library_alice.json first
pplx-export batch --account alice # incremental export (early stop, resumable)
batch refuses to run without the index
(pplx_export/commands/batch_cmd.py:79-81). --limit N and --mode <mode>
filter the index rows before planning; rows missing entryUUID are skipped
with a warning instead of crashing the run (batch_cmd.py:95-100).
How the incremental plan works¶
- Sort. Index rows are sorted by
lastUpdated, newest first (batch_cmd.py:89). Brand-new conversations and resumed old ones (whoselastUpdatedjust moved them upward) both sit at the top — this ordering is what makes early stop safe. - Classify.
plan_incremental(pplx_export/hooks/incremental.py:36-87) — a pure function shared bybatchandschedule— assigns each row exactly one action:
| action | condition | what batch does |
|---|---|---|
new |
uuid never seen in batch_state |
export |
updated |
lastUpdated differs from the recorded value, or --force |
re-export |
done |
status ok and lastUpdated unchanged |
skip |
expired |
the platform returned ENTRY_EXPIRED on an earlier attempt |
skip — terminal, never retried |
deleted |
sync-deleted confirmed a remote deletion |
skip — terminal, never retried |
- Early stop. By default (neither
--fullnor--force) the longest trailing run of terminal entries (done/expired/deleted) is trimmed wholesale and counted asn_stopped(incremental.py:83-87). Because the list is newest-first, everything below an unchanged entry is necessarily older and unchanged too — scanning further would only burn time.
flowchart TD
IDX["library index rows<br/>sorted by lastUpdated, newest first"] --> PLAN["plan_incremental"]
PLAN --> NEW["new → export"]
PLAN --> UPD["updated → re-export"]
PLAN --> DONE["done → skip"]
PLAN --> TERM["expired / deleted → skip (terminal)"]
DONE --> STOP["early stop:<br/>trailing terminal run trimmed"]
TERM --> STOP
- Execute. Every exported thread is marked immediately (
mark_ok/mark_error/mark_expired/mark_deleted) and the state file is saved after each item (batch_cmd.py:154-201); aKeyboardInterruptalso saves before propagating (batch_cmd.py:158-161). Writes are atomic — temp file plusos.replace(state.py:145-152) — so an interrupted run never leaves truncated JSON.
Gap healing after interrupted runs¶
Early stop never buries a gap. Threads that failed (status error) or were
never reached sit above the terminal suffix, so the next run re-plans them
as updated / new and exports them before the early-stop point is reached
(incremental.py:12-14, batch_cmd.py:206-208). Combined with per-item state
saves, a batch run can be interrupted at any point and simply re-run.
If batch_state.json itself is corrupted, it is not silently blanked: the
original is renamed to batch_state.json.corrupt-<timestamp> so recorded
terminal states are not lost and needlessly retried (state.py:68-81).
--full and --force¶
| flag | effect | terminal states | when to use |
|---|---|---|---|
| (default) | early stop over the trailing terminal run | skipped | every regular / scheduled run |
--full |
full scan, no early stop; unchanged threads are still skipped as done |
skipped | periodic backstop, or when archive gaps are suspected |
--force |
re-export everything, even unchanged threads | still excluded — never retried | after pipeline fixes that must re-fetch raw data |
Terminal states are excluded from --force by design: retrying an expired or
remotely deleted thread only wastes requests and backoff budget
(batch_cmd.py:120-127).
The lastUpdated comparison normalizes trailing zeros in the
fractional-seconds part (.18033Z equals .180330Z; state.py:23-55),
because the platform occasionally drops them — an exact string compare would
misjudge "changed" and cause duplicate exports.
Terminal states: expired and deleted¶
expired |
deleted |
|
|---|---|---|
| meaning | the platform purged the thread (~3-month retention window); the export attempt returned ENTRY_EXPIRED |
user/remote deletion, confirmed by sync-deleted |
| recorded by | batch itself (mark_expired, state.py:131-134) |
pplx-export sync-deleted --online (mark_deleted, state.py:136-143) |
| retried? | never — not even with --force |
never — not even with --force |
| evidence | the ENTRY_EXPIRED response |
note field: index absence + GET /rest/thread/<uuid> → ENTRY_DELETED / ENTRY_EXPIRED / HTTP 404 |
sync-deleted: confirming remote deletions¶
pplx-export sync-deleted --account alice # offline dry-run: list candidates only
pplx-export sync-deleted --account alice --online # confirm each candidate online
- Candidates (offline, zero network). Any thread with status
okinbatch_statethat is missing from theentryUUIDunion of allindex/library_*.jsonaccount indexes is a suspected remote-deletion candidate (pplx_export/commands/sync_deleted_cmd.py:148-212). The union across accounts is required: a thread owned bybobbut exported byalicethrough a shared space never appears inalice's own index — a single-account diff would false-positive that whole set. When no usable index exists at all, every candidate is safely skipped with the reason recorded. - Dry-run by default. Without
--onlinethe command only lists candidates — no network, no file changes. --onlineconfirmation. Each candidate is verified withGET /rest/thread/<uuid>, using the candidate'sexport_viaaccount fromthread.json(the cookie switches automatically):
| result | outcome |
|---|---|
ENTRY_DELETED / ENTRY_EXPIRED / HTTP 404 |
confirmed: batch_state marks terminal deleted (the note records the reason), and every thread.json of that thread gets a remote_deleted timestamp in place |
| thread still exists | false positive: reported as-is (the index may not be fully refreshed — re-run index and check again), nothing changed |
| 5xx / network error | no state change; the candidate is left for the next round |
| 3 consecutive 401/403 | fail-fast abort — an expired cookie cannot heal itself, and spinning on would mis-mark live threads (sync_deleted_cmd.py:333-337) |
Confirmed marks are persisted per item, so an interrupted --online run
loses nothing and reruns are idempotent (sync_deleted_cmd.py:254-256).
The tombstone principle¶
Local archives are never deleted
This archive is the backup of record for the exported conversations.
sync-deleted only identifies and marks (tombstone): it never deletes
or moves any archive file. Confirmation changes exactly two things — the
batch_state status and one marker key in thread.json:
"remote_deleted": "2026-07-23T10:20:30Z"
The stamp is idempotent: an existing remote_deleted key is neither
rewritten nor overwritten (sync_deleted_cmd.py:215-244).
Idempotence and offline re-render¶
- Re-running
batchagainst an unchanged index exports nothing: every row classifies asdoneand the run stops at the early-stop point. State writes are atomic, marks are per-thread, and re-confirmed deletions never duplicate theremote_deletedstamp. - The archive keeps the raw API payloads (
raw_entries.json/raw_blocks.json), so the rendered files can be regenerated at any time with zero network access:
pplx-export re-render # rebuild conversation.md + turns/ everywhere
pplx-export re-render --dry-run # only list the thread directories
pplx-export re-render --thread-json # also sync interruptions / answer_variants keys
re-render re-parses the raw JSON with the current renderer
(pplx_export/commands/rerender_cmd.py:105-190): conversation.md and
turns/turn_*.md are rewritten, stale turn files numbered above the current
turn count are removed, and sources, assets, report.md and thread.json
are left untouched. That is how renderer fixes roll out over the whole
archive without a single request.
See also¶
- pplx-export.md — full
batchcommand reference (--mode,--limit, delays) - maintenance-commands.md —
sync-deleted,re-renderand the backfill commands - archive-layout.md — where
batch_state.jsonandthread.jsonlive - rate-limiting.md — pacing between threads, backoff, auth fail-fast
- ../design/export-pipeline.md — the full export pipeline
- ../design/offline-operations.md — the offline rebuild pipeline in depth
- ../design/rate-limiting-errors.md — error taxonomy and terminal-state handling