Skip to content

Export a Cyd Bluesky archive people can actually be handed - #117

Merged
micahflee merged 5 commits into
mainfrom
99-resumable-archive-export
Sep 14, 2026
Merged

micahflee merged 5 commits into
mainfrom
99-resumable-archive-export

Conversation

@micahflee

@micahflee micahflee commented Sep 14, 2026 •

Copy link
Copy Markdown
Member

Closes #99.

#98 built the Cyd Bluesky archive v2 writer. This is the flow around it: resumable, cleaned up after itself, honest about what it produces, and clear about what the file is.

What changed

It survives being killed. Export staging holds a checkpoint beside the files it describes — the moment the archive is of, the media inventory taken at that moment, and the digest of every file already read. Reusing an export id picks that up, so an export the operating system reclaims comes back as the same export rather than a new one with a later point in time (ADR 0006, ADR 0010). Translating and packaging are rebuilt from the staged snapshot each attempt; an export killed after packaging keeps the archive it finished and offers that again rather than building a second identical one.

It fails an export whose file moved, rather than rebuilding around it. An earlier revision of this branch demoted such a file and rebuilt the archive around it, on the grounds that a resumed export would otherwise reread the same stale digest and fail identically forever. That turned out not to be true of this code: asset-changed is a named error, every named error clears its own staging, and the next attempt therefore re-inventories at the file's new size and succeeds. The retry loop and its ADR were removed. A file that moves while it is being packaged now fails the export and takes its staging with it; a file that moves before hashing reaches it is caught by the point-in-time inventory and is honestly unavailable in a complete-enough archive.

It says what the file is, and lets you keep it. A Cyd Bluesky archive is plaintext (ADR 0013), so the flow says so before anything is written. Delivery is then two things, not one: an Android share sheet is ACTION_SEND and lists only applications that receive content, so "Save to device" is its own affordance on both platforms. Without it the one instruction this flow gives — put it somewhere you trust — is the one thing an Android user could not do.

It starts one export however many times the button is pressed. Two taps landing before the screen caught up used to start two runs over a single staging directory, each snapshotting and packaging into the other's files. Separately, cancel returns before the run it cancelled has finished clearing up, and asking for another export inside that window handed the new one the dying run's directory — which was then deleted out from under it, surfacing a raw unable to open database file. An export now owns its staging directory for as long as it is running, and running outlasts the screen.

The screen keeps up with it. An export runs on the thread that draws the screen, and its long passes await work that has already finished — so React was told about progress it never got to commit, and a tap on Cancel sat unread until the export was over. On a real account the card showed one message for seventy-five seconds and Cancel did nothing at all. Hashing and packaging now give way on a timer. Separately, data.db was written a row at a time with no transaction around it, which outside one means SQLite committing and waiting on storage per statement; one transaction took the same export from about seventy-five seconds to about thirty.

It clears up after itself. Staging goes on delivery, on cancellation, and on a failure Cyd can name. Anything else keeps the checkpoint, because the next attempt carries on from it — including declining the warning, which is not cancelling. An account keeps at most one staged export, and sweeps neither another account's staging nor anything it cannot read a checkpoint from.

Nothing here reaches for a Cyd account. Portability is not a premium feature (ADR 0015), and the card exporting successfully with no CydAccountProvider above it is what proves it.

Acceptance criteria

  • Export checkpoints resume safely when Cyd next launches and clean staging after success, cancellation, or unrecoverable failure.
  • The exported Cyd Bluesky archive excludes connections, credentials, schedules, jobs, logs, analytics, reports, temporary files, caches, and UI state — the export tests now assert the archive's whole entry set, so neither the database snapshot nor the new checkpoint can drift into it.
  • The export flow clearly warns that posts, chats, and media are plaintext and not encrypted.
  • Export remains available without a premium subscription.
  • Incomplete but recoverable saved data exports successfully with honest completeness status — and missing (never finished saving) stays distinct from unavailable (could not be read).

Notes for review

  • expo-sharing is a new native dependency, so this needs a fresh dev build. Its config plugin is deliberately not in app.config.ts: that plugin builds an iOS share extension for receiving shares, which is not what this uses.
  • The entry point stays __DEV__. Per ADR 0004 the affordance is gated until Pass bidirectional Cyd Bluesky archive v2 conformance and enable the writer #100 proves conformance both ways; what Pass bidirectional Cyd Bluesky archive v2 conformance and enable the writer #100 changes is where this lives and what it is called, not what it does.
  • Resume is user-initiated, not launch-driven, following ADR 0006 — an export ends at a share sheet, so it needs somebody present. The consequence: an interrupted export for another account survives until that account next exports. Bounded at one staging directory per account.
  • share() clears staging on a sheet that cannot report success. expo-sharing's Android OnActivityResult discards the result code, and Intent.createChooser returns RESULT_CANCELED even on success, so dismissing the sheet deletes the archive. Left as-is deliberately; the alternative is keeping a copy of every account that ever opened one.
  • CHECKPOINT_VERSION was not bumped when the checkpoint shape changed, because the removed field is only ever dropped and never reinterpreted. Verified on device: a checkpoint carrying the old field resumes in place and completes.
  • The seeded account fixture moved to testUtils/blueskyExportAccount.ts so the resume tests could share it.

Testing

  • 902 passing (6 pre-existing skips), typecheck and lint clean.
  • npm run test:archive-contract and npm run test:archive-conformance green.
  • Tests: 6 resume/cleanup tests over the real writer, an archive entry-set exclusion test, flow tests including the two concurrency races above, 5 card tests, and unit tests for the interchange write's transaction and for giving way to the screen.
  • On device (Android, physical, ~2,615 posts / 117 media / 175 MiB): happy path; plaintext warning; resume after a kill mid-hash, which kept the original point in time and excluded a bookmark saved while the app was dead; a full round trip whose archive passes the conformance checker with clean ZIP CRCs, PRAGMA integrity_check ok and no foreign-key violations; staging cleared on delivery, on cancel mid-hash, and on a named failure; two accounts not sweeping each other; three abandoned exports collapsing to the newest; junk staging swept; and the dev card confirmed absent from a real release build.

(This was written by an LLM.)

🤖 Generated with Claude Code

micahflee and others added 5 commits September 13, 2026 19:13
#98 built the writer; this is the flow around it. Four things it adds.

**It survives being killed.** Export staging now holds a checkpoint beside the
files it describes: the moment the archive is of, the media inventory taken at
that moment, and the digest of every file already read. Reusing an export id
picks that up, so an export the operating system reclaims does not pay for the
snapshot or the hashing pass twice — and, more to the point, comes back as the
same export. Taking the snapshot again would have been easier and would have
quietly moved the archive's point in time past whatever saving did in between
(ADR 0006, ADR 0010). Translating and packaging are rebuilt from the staged
snapshot each attempt, which is cheap and leaves no half-written archive; an
export killed after packaging keeps the archive it finished and offers that
again rather than building a second identical one.

**It finishes around a file that moved.** Packaging is the last pass that can
catch a file the inventory and the hashing both accepted, and it used to fail
the whole export. A resumed export would then have reread the same stale digest
and failed identically forever. It is now recorded as changed, demoted to
unavailable, and the archive is rebuilt around it — an honest incomplete
archive rather than a permanent failure (ADR 0019).

**It says what the file is.** A Cyd Bluesky archive is plaintext, and the posts,
chats and media in it are as sensitive as they ever were (ADR 0013). The flow
asks before anything is written, not once the file is already in somebody's
downloads, and hands the archive to the share sheet — hence expo-sharing, whose
config plugin is deliberately *not* installed: that one builds an iOS share
extension for receiving shares, which is not what this is.

**It clears up after itself.** Staging is destroyed on delivery, on
cancellation, and on a failure Cyd can name; anything else keeps the checkpoint,
because the next launch can carry on from it. Declining the warning is not
cancelling and keeps it too. An account keeps at most one staged export, so an
abandoned run cannot leave a copy of the account database per attempt.

Clearing staging is the export's own job on the way out of a cancellation, not
the screen's: deleting the directory from under a pass that is still writing to
it only means it gets written again. Hashing checkpoints in batches, because the
checkpoint carries every digest so far and one write per file would rewrite the
whole pass once per file.

The export tests now assert the archive's whole entry set, so neither the
snapshot nor the new checkpoint can drift into it; the account fixture moved to
testUtils so the resume tests could share it. The entry point stays `__DEV__`,
where #100 will find it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Export ended at a share sheet, and on Android that is `ACTION_SEND`: an intent
that enumerates applications willing to *receive* content — cloud drives, mail,
messengers — and never the device's own storage, because saving into a folder
is `ACTION_OPEN_DOCUMENT_TREE` and a different intent entirely. So the one
instruction the flow gives, to put the archive somewhere you trust like an
encrypted drive, was the one thing an Android user could not do. Getting your
own data out of Cyd required first giving it to a third party (ADR 0015), in
plaintext (ADR 0013). Found on a device; the share sheet on a real export
offered Drive and messaging apps and no local destination at all.

Saving to the device is now its own delivery, on both platforms. It picks a
folder with `Directory.pickDirectoryAsync` and copies into it with the native
streaming `File.copy`, so an archive of preserved video costs what a thumbnail
does — reading it into JavaScript to write it back out would not survive a real
account.

That needed the flow to stop delivering by itself. Packaging used to hand the
archive straight to the share sheet and clear staging behind it; there is now a
step where the finished archive exists and where it goes has not been decided.
Staging is cleared when it has actually gone somewhere, which is what lets
backing out of the folder picker return to the archive rather than destroy it:
that cancellation is a real signal, and the staged copy is the only copy.

A share sheet gives no such signal — expo-sharing discards the result code and
resolves either way, and `Intent.createChooser` reports cancelled for most
successful shares, so there is nothing to read. Sharing therefore still clears
staging on the strength of the sheet having closed, unchanged from before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR 0019 said an export that found a preserved file changed between hashing
and packaging had to demote that asset and rebuild the archive around it,
because otherwise "a resumed export would reread the same stale digest and
fail identically on every launch", leaving an account that could never export.

That was never true. `asset-changed` is a BlueskyArchiveExportError, and every
named error clears its own staging, so the next attempt already took a fresh
snapshot, re-inventoried at the file's new size, and succeeded. The permanent
failure the decision existed to prevent was not reachable in this code or in
the code before it.

What the retry loop really bought was the hashing pass, which is the most
expensive thing an export does. That is worth something, but not its price: a
rebuild of the interchange database and the whole archive for every digest
that moved, where re-taking the inventory catches all of them at once. It was
a loop, a checkpoint field, an error carrying an entry path, and a special case
in the hashing pass, all for a case rarer than the one it made slower.

So a file that moves while it is being packaged now fails the export and takes
its staging with it, and the next attempt starts over. A file that moves before
hashing reaches it is unchanged and still produces an archive: the point-in-time
inventory catches it, and the asset is honestly unavailable. Both are now
tested, which they were not as one path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things could put two export runs over a single staging directory, and
nothing in the way of either.

The first is a second tap. `confirm` read the generation without moving it on,
so two taps landing before the screen caught up both passed the same check:
two runs, one `exportId`, each taking its own snapshot and packaging into the
other's files. The same shape was reachable from "Try again", and — since
delivery became a state of its own — from "Save to device" and "Share".

The second is slower to see. `cancel` moves the generation on and returns, but
the run in flight only notices at its next boundary, and clearing its own
staging is the last thing it does on the way out. Asking for another export
inside that window found the dying run's checkpoint still on disk, so
`claimBlueskyArchiveExport` handed back the very directory that was about to be
deleted. What reached the person was a raw `unable to open database file` from
part-way through translating, for having pressed a button twice. Nothing was
lost — the account is never touched, and trying again worked — but nothing
explained itself either.

Both are one rule now: an export owns its staging directory for as long as it
is running, and running outlasts the screen. `exclusively` will not start a
second thing in there while the first is still going, and `start` waits for a
departing run to finish clearing before it claims anything.

Neither race is easy to hit by hand — the screen usually re-renders before a
second tap lands — so both are held down at the hook's seam instead, where the
interleaving can be pinned: a run parked mid-hash, cancelled, and asked to
start again before it has let go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An export of a real account showed one message for its whole run. "Pausing
this account's work and copying its data…" stayed up for about seventy-five
seconds and was then replaced by the finished archive. "Checking the preserved
media…", "Writing the archive's database…" and "Packaging the archive…" were
never drawn, no progress fraction ever appeared, and Cancel — which the card
offers for the whole of hashing — did nothing whatever. A tap during
translating was read fifty-six seconds later, by which point the export had
finished and there was nothing left to cancel.

Neither symptom is slowness in the ordinary sense. An export runs on the
thread that draws the screen and delivers touches, and its long passes await
work that has already finished: an already-resolved promise is drained inside
the same tick, so React was told about progress it never got to commit, and
the tap sat in the queue unread. `shouldCancel` was not being ignored — it was
never asked a question anyone had had the chance to answer.

Most of the wait was not the export thinking, either. `data.db` was written a
row at a time with nothing around it, and outside a transaction SQLite commits
after every statement and waits for the storage to say it is done. On this
account that was most of a minute spent waiting on fsync. One transaction
takes that write down to a few seconds and the export as a whole from about
seventy-five seconds to about thirty. Foreign keys are still checked as each
row lands, so the reference order the writer has always used is doing exactly
what it always did; what is new is that a refused row takes the rest of the
write with it, rather than leaving half an account in a file that is about to
be packaged.

The rest is giving way on a timer, which is the only thing that really hands
the thread back, once a pass has held it for fifty milliseconds. Hashing and
packaging both do it, and that is what makes their progress visible and their
Cancel button real: cancelling mid-hash now clears its staging within a
second, which is the first time that has been true on hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@micahflee
micahflee merged commit f00aa94 into main Sep 14, 2026
1 check passed
@micahflee
micahflee deleted the 99-resumable-archive-export branch September 14, 2026 20:02
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.

Make Cyd Bluesky archive export resumable and safe to release

1 participant