Skip to content

Restore a Cyd Bluesky archive into a browseable local account - #114

Merged
micahflee merged 2 commits into
mainfrom
96-restore-bluesky-local-account
Sep 11, 2026
Merged

micahflee merged 2 commits into
mainfrom
96-restore-bluesky-local-account

Conversation

@micahflee

Copy link
Copy Markdown
Member

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: one
new, 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:

  1. Read — pull the interchange database into memory and check it really is
    a version 2 Bluesky archive. Nothing has been created yet.
  2. Place media — copy each packaged asset into the account's own storage
    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.
  3. Translate — the interchange rows into Mobile's private ones (ADR 0002),
    in a pure function over rows and asset placements.
  4. Commit — create the Bluesky local account and write its database in one
    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 selection has to land where browse looks. 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 viewerLiked, viewerReposted,
    and viewerBookmarked. A translation that missed this would restore data
    nobody could see, which is why the tests assert through Mobile's own query
    builders rather than through the rows in between.
  • Nothing is invented, in either direction. 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. 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
    unrestorable rather than bent into a column that means something else.
  • Missing stays missing, honestly. An asset the archive called unavailable,
    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.
  • Identity is the DID. The archive's UUID is adopted when free and remapped
    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.
  • Credentials and schedules never cross, and the account is written signed
    out.

Tests

restore.test.ts drives both committed real-data fixtures through intake and
then 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

  • Nothing calls this outside tests yet, the same way intake has no caller.
    The picker-and-progress flow is a later slice; say the word and I will add a
    __DEV__ affordance like the export card.
  • Fixture coverage is narrower than the test count suggests. complete.cyd
    carries 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.
  • A bookmark removed at source cannot carry its deletion state, because Mobile
    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

micahflee and others added 2 commits September 11, 2026 10:37
#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>
@micahflee
micahflee merged commit f3ee5ee into main Sep 11, 2026
1 check passed
@micahflee
micahflee deleted the 96-restore-bluesky-local-account branch September 11, 2026 18:29
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.

Restore a complete Bluesky local account for offline browsing

1 participant