Repository navigation
Restore a Cyd Bluesky archive into a browseable local account - #114
Merged
Merged
Conversation
#96 asks for the commit half of a Bluesky archive import: intake (#95) ends with a verified archive in staging, and nothing yet turns that into an account somebody can read. This adds `services/archive-restore`, which creates one new, disconnected Bluesky local account from a prepared archive for an identity this installation does not already have (ADR 0017). An identity Cyd does have is refused here — that is the recovery merge #97 owns. The translation is the mirror of the writer's and is pure in the same way. It matters where the two models disagree. The interchange model records a like or a repost as a record of its own; Mobile records it as viewer state on the post, and browse filters on exactly those flags, so a selection that did not land there would restore data nobody could see. A repost of a post the archive does not carry has nothing to attach to, so it keeps Mobile's shape for a repost saved on its own. Where Mobile requires what the contract leaves optional, the fallback is a fact the archive already states: a profile with no handle keeps its DID rather than an empty string. Media is copied into the account under its own digest, so an image two records share is one file (ADR 0007), and it renders with no network because the rows point at the file rather than at Bluesky's CDN. An asset the archive called unavailable — or that will not copy, or arrives the wrong size — becomes a failed download that keeps its source URL, which is the state Mobile already uses for media it has not preserved, so it stays visible and retryable. The archive's own completeness is recorded rather than recomputed: an incomplete archive still restores every record it does carry. The archive's local-account UUID is adopted when free and remapped, with a report, when another Bluesky identity holds it (ADR 0011). Handles get the same treatment for a different reason: they move between identities, and Mobile keeps them unique. Credentials and schedules never cross, and the account is written signed out. restore.test.ts drives the committed real-data fixtures through intake and then restore, and asserts the result through Mobile's own browse queries and the files on disk rather than through the rows in between. That is what makes "browseable offline" a property of the code rather than a claim. It also pins the failure path: a restore that dies part-way leaves no account, no media, and a staging directory still worth retrying. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two findings were real bugs against #96's acceptance criteria. A restored asset recorded this account's DID as the repository to fetch it from. Media on a liked or bookmarked post lives in its *author's* repository, and a retry calls `downloadMedia(blobCid, sourceDid)`, so "missing assets remain explicit and retryable" held for the label and not for the retry. The owning record's author DID goes in instead. The counts a restore reported were counts of what the archive *selected*. A selection naming a record the archive does not carry restores nothing — there is no post to hang the viewer state on, and browse filters on exactly that — so the result was promising likes nobody could open. Counts now come from the restored rows, the same way browse counts them, and a selection that could not land is reported under `unrestorable.selections` rather than disappearing. Restored avatars were also being counted unrestorable while visibly working. Two other review findings did not survive checking, and are worth recording so nobody re-opens them: archive embeds do carry `$type` (it is simply not the first key, so `embedType` restores exactly as Mobile stores it), and a repost Mobile saved as a row of its own has always had empty text — `postRecord` is null for a repost record — so restoring "" is faithful rather than lossy. The rest is house style. Tests import the unit under test relatively, as every other archive test does, which let the barrel shrink to the entry point, the failures a caller reports, and the ports an adapter implements. The DID guard is its own function rather than a second job inside `chooseRestoredAccountUuid`. Settings columns are a closed union rather than free strings interpolated into SQL, and the portable-settings map is typed against the contract's own keys, so the export twin and this one cannot drift apart silently. `restoreFixtures.ts` holds port adapters rather than fixtures, so it is `archiveEnvironments.ts` now. A size mismatch sets a placement directly instead of throwing an error its own `catch` swallows two lines down. Config keys nobody reads are gone. The device adapter had no tests — the Node one carried the restore tests — so it gets the treatment intake's does: an in-memory `expo-file-system`, proving a restored asset lands inside its own account, a committed archive's staging goes away, and discarding a half-restored account takes its media with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #96.
Intake (#95) ends with a verified Cyd Bluesky archive sitting in staging, and
nothing turned that into an account anybody could read. This adds
services/archive-restore, the commit half of a Bluesky archive import: onenew, disconnected Bluesky local account, browseable offline, without premium
(ADR 0017). An identity this installation already holds is refused here — that
is the recovery merge #97 owns.
How a restore works
Four steps, and the order is the design:
a version 2 Bluesky archive. Nothing has been created yet.
under its digest, so an image two records share is one file (ADR 0007). An
asset that will not copy becomes a failed download, not a failed restore.
in a pure function over rows and asset placements.
transaction, then clear the staging the archive came from.
A failure at any step removes the half-built account and its media, so an
installation is either unchanged or holds a whole account. The prepared archive
survives, so retrying costs nothing to unpack again.
Decisions worth a look
a like or a repost as a record of its own; Mobile records it as viewer state
on the post, and browse filters on exactly
viewerLiked,viewerReposted,and
viewerBookmarked. A translation that missed this would restore datanobody could see, which is why the tests assert through Mobile's own query
builders rather than through the rows in between.
contract leaves optional, the fallback is a fact the archive already states:
a profile with no handle keeps its DID rather than an empty string. Where the
contract holds what Mobile has no column for — blocks, mutes, a chat
message's media, a profile banner — it is counted and reported under
unrestorablerather than bent into a column that means something else.or that will not copy, becomes a failed download that keeps its source URL —
the state Mobile already renders as "Media unavailable — save again to retry".
The archive's completeness is recorded rather than recomputed, so an
incomplete archive still restores every record it does carry.
with a report when another identity holds it (ADR 0011). Handles get the same
treatment for a different reason: they move between identities, and Mobile
keeps them unique, so a stale one would otherwise fail the insert.
out.
Tests
restore.test.tsdrives both committed real-data fixtures through intake andthen restore, and asserts the result through Mobile's browse queries and the
files on disk: category counts, the quote context, chat members, five files for
six references each named after its own digest, and the incomplete fixture
keeping every record while its one missing asset stays explicit and retryable.
It also pins the failure path and the UUID remapping. The device adapter gets
the treatment intake's has — an in-memory
expo-file-system.Worth knowing
The picker-and-progress flow is a later slice; say the word and I will add a
__DEV__affordance like the export card.complete.cydcarries no bookmarks, replies, external embeds, or video — the 5 MiB budget
cost the videos (Create a point-in-time Cyd Bluesky archive v2 export #98) — so those paths rest on synthetic cases in
mobile-rows.test.ts.has no bookmark record URI. That is a contract gap already listed in
docs/bluesky-archive-fixtures.md, surfaced here rather than introduced.🤖 Generated with Claude Code