Skip to content

Feat/hypercore11 migration - #1246

Open
RangerMauve wants to merge 90 commits into
mainfrom
feat/hypercore11-migration
Open

Feat/hypercore11 migration#1246
RangerMauve wants to merge 90 commits into
mainfrom
feat/hypercore11-migration

Conversation

@RangerMauve

Copy link
Copy Markdown
Contributor

Closes #1042

Mauve Signweaver and others added 30 commits February 16, 2026 16:27
* chore: auto-open resources

* chore: increase CI timeout
Co-authored-by: Andrew Chou <andrewchou@fastmail.com>
@RangerMauve
RangerMauve force-pushed the feat/hypercore11-migration branch from 17f675c to 4997b24 Compare July 2, 2026 14:23
@gmaclennan

Copy link
Copy Markdown
Member

I used a bunch of tokens on Claude Fable (available until July 7th on the monthly usage plan) to review this on "xhigh", which includes confirmation of issues found. The issues marked CONFIRMED should have been tested and confirmed. "Plausible" could be wrong.

Critical — sync correctness

1. forceBitfieldExchange is silently a no-op on hypercore 11src/utils.js:356 (also test/helpers/create-core.js:46) · CONFIRMED
peer.core is hypercore 11's internal Core object, which has no .length property (the real length is peer.core.state.length). So maxPage = Math.ceil(undefined / 131072) is NaN and the want-send loop never executes, and bitfield.want(0, undefined) yields nothing. The entire eager bitfield exchange degrades to sending only the contiguous range. With sparse data (blob cores with download filters, partially synced cores), neither side learns the other's real bitfield — sync progress appears stuck, and older clients never get the want that prompts them to send their bitfield. Verified at runtime by the finder. Notably, the unit test covering this ('eagerly updates remote bitfields') was skipped in this branch, so nothing catches it.

High — migration module crashes and misbehavior

2. checkShouldMigrate throws ENOENT on fresh installssrc/migration.js:25 · CONFIRMED
listProjectsFromStorage calls readdir(storagePath) unguarded. On a fresh install where the storage folder doesn't exist yet, the migration pre-check rejects with a raw filesystem error instead of returning { shouldUpgrade: false }.

3. Free-space check contradicts its own contractsrc/migration.js:254 · CONFIRMED
The JSDoc says available space must be at least 2.5× the largest core, but AVAILABLE_SPACE_MULTIPLIER is 1.05. A device with 1.1× space passes the check, then hits ENOSPC mid-migration and is left with a half-migrated corestore — the exact situation the check exists to route to the fallback path.

4. Broken published types for the new @comapeo/core/migration.js entry pointpackage.json:20 · CONFIRMED
dist/migration.d.ts ends with import CorestoreStorage from 'hypercore-storage', but the only declaration for that module lives in the unpublished types/ folder. Verified with tsc -p tsconfig.npm.json: consumers get TS2307 with skipLibCheck: false, or silent any for the migration API's key types with it on.

5. Migration progress counts can disagreesrc/migration.js:196 · CONFIRMED
The total is pre-counted from a filesystem heuristic (dirs containing an oplog file) while doneSoFar increments per entry of storage.createCoreStream(). When the sets differ, a progress UI shows >100% or never reaches 100%.

6. entry.path ?? entry.parentPath isn't portablesrc/migration.js:73 · PLAUSIBLE
dirent.path (deprecated, slated for removal) and dirent.parentPath (added v20.12) don't both exist on all runtimes. On a runtime with neither (e.g. Node 18-era nodejs-mobile), path.join(undefined, entry.name) throws and the whole migration pre-check crashes. Worth checking what nodejs-mobile ships before relying on either.

High — latent data-corruption and resource risks

7. PretendCorestore.get lets keyPair overwrite an explicit keysrc/blob-store/hyperdrive-index.js:111 · PLAUSIBLE
The keyPair branch runs after the key branch and unconditionally overwrites opts.publicKey. Hyperdrive 13's _openBlobsFromHeader passes both (key: blobsKey, keyPair: db.core.keyPair), which would resolve the blobIndex core instead of the blobs core — blob reads decode hyperbee blocks as blob bytes, and writes corrupt the replicated drive index. Currently latent because comapeo cores open in compat mode, but it fires for any non-compat blobIndex core. The fix is cheap: only apply the keyPair fallback when no explicit key is given.

8. File-descriptor cap removed with no replacementsrc/mapeo-manager.js:252 · CONFIRMED
The 768-fd RandomAccessFilePool (added for Android's 1024-fd process limit) is gone, and no equivalent limit is configured on the RocksDB-backed CorestoreStorage stack. The covering test was skipped rather than replaced. Multi-project users on Android risk EMFILE — the field failure the pool existed to prevent.

Medium

9. PeerState.want() off-by-one vs wantWord()src/sync/core-sync-state.js:445 · CONFIRMED
want() uses index > contiguousLength where wantWord() uses >=, so the first block after a peer's contiguous range is reported as not wanted by one path and wanted by the other.

10. kCreateOrUpdateWithDocId no-op detection misses field removalsrc/datatype/index.js:202 · PLAUSIBLE
hasChanges iterates only Object.keys(value), so an update whose purpose is dropping a field (omitting the key) is treated as no change and skipped — the stale field persists while the caller sees success.

Test-coverage regressions

11–12. The two tests covering exactly the rewritten mechanisms were skippedtest/core-manager.js:512 (deleteOthersData) and test/core-manager.js:53 (eager bitfield exchange) · both CONFIRMED
deleteOthersData() was the only test asserting other members' data is actually removed from disk; its replacement (purgeCore() poking private storage internals, pending upstream hypercore PR #788) is now covered only by an e2e check that disk usage decreased by some amount. The bitfield test skip is what let finding #1 land unnoticed. If either mechanism drifts in a hypercore patch release, no test catches it.

Cleanup

13. Stale JSDoc: coreStorage no longer accepts a factory functionsrc/mapeo-manager.js:163 (also src/core-manager/index.js:74) · CONFIRMED — a consumer following the docs gets an opaque path.join TypeError deep inside MapeoManager.

14. migrateStorage JSDoc claims a dry-runsrc/migration.js:143 · CONFIRMED — it actually runs with dryRun: false, gc: true, irreversibly migrating; the misleading doc ships in the published .d.ts.

15. Leftover console.log(error)src/migration.js:208 · CONFIRMED — dumps raw errors to stdout from library code; the caller already gets the same error via results[projectId].error.

Six candidates were refuted in verification — among them "purgeCore leaves dangling references" (guarded by hypercore 11's closed-session checks) and "Downloader swallows legitimate cancellations" (the consumer already ignored those errors on main).

The standouts are #1 (breaks the sync behavior this branch exists to preserve, with its test skipped) and the migration-module cluster (#2#6), since that code runs exactly once per device with no second chance. Happy to fix any of these — say the word (or re-run with --fix).

@RangerMauve

Copy link
Copy Markdown
Contributor Author
  1. Got the test passing, passed in proper reference to the core

@RangerMauve

Copy link
Copy Markdown
Contributor Author
  1. added check
  2. My docs were messed, in my tests we just needed like 3% extra storage + the size of the core. So 2.5 is overkill. 1.05 is enough, but maybe I should go just a bit higher, 1.5
  3. I think downstream should include the types folder in their tsconfig if that is an issue. The argument is optional and only used for testing anyway.
  4. I don't think it'll cause problems but just in case I added a thing that will ensure the cb is called with the total at the end.
  5. entry.path ?? entry.parentPath is portable. I added this specifically so the tests would pass in node 18/20/24
  6. Not an issue we will face since we won't have drives open without a content key in their header. Really not sure why they even have that fallback
  7. non issue since we won't be approaching that limit unless we open hundreds of projects.

@RangerMauve

Copy link
Copy Markdown
Contributor Author
  1. was an issue, added tests to catch and fix it
  2. I think this is working as intended?

RangerMauve and others added 5 commits July 8, 2026 18:35
…ypercore 11

peer.core is hypercore 11's internal Core object, which has no .length
property, so bitfield.want(contig, undefined) yielded nothing and
sendWants was a silent no-op. Same fix as cdf3eb6: use the session
core's length. Adds a regression test that shares a sparse pre-existing
bitfield without downloading (a download would fetch the bitfield
natively and mask a broken helper).
….js exports

hypercore-storage ships no types and our
declarations for it in types/ are not published,
so the emitted dist/migration.d.ts ended with a
bare 'import CorestoreStorage from
hypercore-storage' that gave consumers TS2307 (or
silent any with skipLibCheck). Type the exported
signatures with a subset of CorestoreStorage
instead, making dist/migration.d.ts
self-contained.
@gmaclennan
gmaclennan self-requested a review July 13, 2026 19:56

@gmaclennan gmaclennan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed two small commits to fix the exported types (which I think will be helpful for frontend implementation) and addresses an issue where the tests weren't actually testing the eager want-sending.

I think it would be good to explicitly add hypercore-storage as a dependency before merging - it's imported in the code, and only works because it's a transitive dependency

@cimigree

Copy link
Copy Markdown

For the designs in Figma, https://www.figma.com/design/iUeC0Qzhb4H0unuPfxQAsM/-Mobile--CoMapeo?node-id=11371-4308&t=nKpXn1KBC1KIdSK8-0, we need a value for the amount of space needed. This will most likely help the user know how much space to free up...

gmaclennan and others added 4 commits July 16, 2026 12:54
* test(core-manager): failing test for purged cores reopening in repair mode

purgeCore deletes all tree nodes and blocks but leaves the core's head
record (fork, length, root hash) in storage. When the same storage is
re-opened - as happens when a device re-joins a project after leaving,
which re-adds the same core keys - hypercore finds a header claiming
length > 0 with no merkle roots and puts the core in repair mode: the
replicator becomes push-only and never sends a Synchronize, so the core
silently never syncs for the rest of the session. Peers waiting on that
core's handshake wait forever.

The new test purges a downloaded core, re-opens the same storage, and
asserts a block can be re-downloaded. It times out on the current
implementation.

* fix(core-manager): delete the head record when purging a core

purgeCore deletes all tree nodes and blocks but left the core's head
record (fork, length, root hash) in storage. When the same storage was
re-opened - a device re-joining a project after leaving re-adds the same
core keys - hypercore found a header claiming length > 0 with no merkle
roots and put the core in repair mode: push-only, never sending a
Synchronize, so the core silently never synced for the rest of the
session, and peers gating on its handshake waited forever.

Delete the head record too, so a purged core re-opens as a normal empty
core and re-syncs from peers.
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.

Upgrade to hypercore 11

4 participants