OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-base commits / b6b7fd9

atproto-base: Phase 0 #5 — migration reader (importRepo + adopt-head) — Phase 0 COMPLETE
importRepo(carBytes): imports an existing atproto repo (the live DID's getRepo, or a backup CAR) into a fresh atproto-base and ADOPTS its head via adoptHead() — set the last-emitted head WITHOUT emitting, so getRepo serves it and the NEXT commit CONTINUES the chain (prev = adopted commit, rev > adopted rev) instead of restarting from genesis. Load-bearing: the relay's forward-only cursor rejects a rev regression (federation-wall #3). CIDs preserved (canonical DAG-CBOR continue-migrate). Proven: import a 2-record repo, adopt its head (CID preserved), getRepo serves it, a continuation post prev-links to the adopted head + rev strictly increases. *** ALL OF PHASE 0 COMPLETE *** — the five extraction-blocking gaps are closed: #1 deterministic base key, #2 members-only encryption, #3 keet-attestation admission, #4 repo-for-bridge projection, #5 migration reader. atproto-base can now stand in for a real co-owned StateBaseV3 DID. Next = Phase 1 (unify the 3x consumer primitive). test/migration.test.mjs (1 test / 7 asserts). Suite now 28 tests / 121 asserts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
b6b7fd9aab2741a7b416615f93045e6d80652625
by plankatron <fleeky@gmail.com> · 2026-07-23T20:00:10.000Z

Changed files (2) +74 -0

lib/base.js +28 -0
……@@ -25,6 +25,7 @@ import crypto from 'hypercore-crypto'
2525import { FirehoseSequencer } from 'atproto-store/lib/sequencer.js'
2626import { getRepoFromCommit } from 'atproto-store/lib/read-view.js'
2727import { cidForValue } from 'atproto-repo/lib/cid.js'
28import { readCar, loadCommit, listRecords } from 'atproto-repo'
2829import { projectCommit, nextRev } from './project.js'
2930import { buildCommitWrite } from './firehose.js'
3031import { revTime } from './rev.js'
……@@ -472,6 +473,33 @@ export class AtprotoBase {
472473 return getRepoFromCommit(this._repo, this._prevHead.signedCommit)
473474 }
474475
476 // ── Migration / adopt (EXTRACTION-PLAN #5) ──────────────────────────────────────────────────────
477 // Adopt an existing head WITHOUT emitting: set the last-emitted head to a given commit + record set,
478 // so getRepo serves it and the NEXT emitFirehose CONTINUES the chain (prev = this commit, rev > this
479 // rev) instead of re-deriving from genesis. Load-bearing for migration: the relay's forward-only
480 // cursor rejects a rev regression (federation-wall). records = [{ key, value: record }].
481 async adoptHead ({ records, rev, commitCid, signedCommit }) {
482 await this._projectRepo(records)
483 this._prevHead = { records, rev, commitCid, signedCommit }
484 }
485
486 // Migration reader: import an existing atproto repo (a CAR — the live DID's getRepo, or a backup)
487 // into this base, then ADOPT its head so the chain continues unbroken. The base must be writable.
488 // Records continue-migrate byte-identically (DAG-CBOR is canonical), so CIDs are preserved.
489 async importRepo (carBytes) {
490 const car = await readCar(carBytes)
491 const commit = loadCommit(car) // { cid, value: signedCommit }
492 const records = []
493 for (const { key, value } of listRecords(car)) {
494 const i = key.lastIndexOf('/')
495 await this.putRecord(key.slice(0, i), key.slice(i + 1), value)
496 records.push({ key, value })
497 }
498 await this.settled()
499 await this.adoptHead({ records, rev: commit.value.rev, commitCid: commit.cid, signedCommit: commit.value })
500 return { rev: commit.value.rev, commitCid: commit.cid, records: records.length }
501 }
502
475503 subscribeRepos (cursor = 0, opts) { return this._firehose.subscribe(cursor, opts) }
476504 get firehoseKey () { return this._firehose ? this._firehose.core.key : null }
477505 get firehoseLength () { return this._firehose ? this._firehose.length : 0 }
test/migration.test.mjs +46 -0
……@@ -0,0 +1,46 @@
1// PHASE 0 #5 — MIGRATION READER. importRepo() imports an existing atproto repo (a CAR — the live DID's
2// getRepo, or a backup) into a fresh atproto-base and ADOPTS its head, so the atproto chain CONTINUES
3// unbroken: the next commit prev-links to the adopted head and its rev strictly increases past it (the
4// relay's forward-only cursor rejects a regression — federation-wall). CIDs are preserved (canonical DAG-CBOR).
5
6import test from 'brittle'
7import { mkdtempSync, rmSync } from 'node:fs'
8import { tmpdir } from 'node:os'
9import { join } from 'node:path'
10import { deriveAtprotoKey, createAtprotoIdentity } from 'atproto-identity'
11import { readCar } from 'atproto-repo/lib/car.js'
12import { verifyRepo } from 'atproto-repo'
13import { AtprotoBase } from '../lib/base.js'
14import { projectCommit, nextRev } from '../lib/project.js'
15
16const MNEMONIC = 'test test test test test test test test test test test junk'
17const ID = createAtprotoIdentity(MNEMONIC, { handle: 'x.7rnx.net', pdsUrl: 'https://pds.7rnx.net' })
18const KEY = deriveAtprotoKey(MNEMONIC)
19const post = (text) => ({ $type: 'app.bsky.feed.post', text, createdAt: '2026-07-23T00:00:00.000Z', langs: ['en'] })
20const R = (k, v) => ({ key: 'app.bsky.feed.post/' + k, value: post(v) })
21function mkBase () { const dir = mkdtempSync(join(tmpdir(), 'mig-')); const base = new AtprotoBase({ path: dir, did: ID.did, privateKey: KEY.privateKey, ackInterval: 100 }); return { base, dir, close: async () => { await base.close(); rmSync(dir, { recursive: true, force: true }) } } }
22const recCount = async (car) => (await verifyRepo(await readCar(car))).records
23
24test('migration: importRepo adopts an existing repo CAR and continues the chain unbroken', async (t) => {
25 // A "legacy" repo, as if fetched from the live DID's getRepo: 2 records at legacyRev.
26 const legacyRev = nextRev(null)
27 const legacy = await projectCommit({ records: [R('3a', 'one'), R('3b', 'two')], did: ID.did, rev: legacyRev, prev: null, privateKey: KEY.privateKey })
28
29 const A = mkBase(); await A.base.ready()
30 t.teardown(() => A.close())
31
32 const adopted = await A.base.importRepo(legacy.carBytes)
33 t.is(adopted.records, 2, 'imported 2 records')
34 t.is(adopted.commitCid.toString(), legacy.commitCid.toString(), 'adopted the legacy head commit (CID preserved)')
35
36 const served = await A.base.getRepo()
37 t.is(await recCount(served.carBytes), 2, 'getRepo serves the 2 imported records')
38 t.is(served.commitCid.toString(), legacy.commitCid.toString(), 'getRepo commit == the adopted head')
39
40 // A continuation post: the chain must extend from the adopted head, not restart.
41 await A.base.putRecord('app.bsky.feed.post', '3c', post('after migration'))
42 const w = await A.base.emitFirehose()
43 t.is(w.signedCommit.prev.toString(), legacy.commitCid.toString(), 'next commit prev-links to the adopted head (unbroken chain)')
44 t.ok(w.rev > legacyRev, 'rev strictly increases past the adopted rev (relay forward-only cursor accepts)')
45 t.is(await recCount((await A.base.getRepo()).carBytes), 3, '3 records after the continuation post')
46})