OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room commits / 256f9bc

state-base: in-place re-adopt migration for the namespace rename
A room created under the pre-extraction namespace ('hyper-tps/state/v3') opens under the renamed one ('atproto-room/state/v3') as a fresh, empty base sharing the same on-disk Corestore as its old cores. On ready(), the writable creator now seeds the renamed base from the legacy one when the renamed base is empty and a legacy base is present: - deriveGsKeyPair takes a namespace arg; new opts.gsNamespace override (default = current). - _migrateFromLegacyNamespace opens the legacy base READ-ONLY on a Corestore SESSION (shared storage/cores, isolated ref-counting → no SESSION_CLOSED, no second fd-lock) behind a cheap bootstrap-length probe (skips fresh rooms without spawning empty cores), materializes it with a minimal read-router, and copies every direct set-entry entry verbatim — repo records + __commit__ firehose + __members__/__devices__ + prefs. Apply-DERIVED governance (__removed__ bans / __receipt__ min-epoch) is not carried; re-establishes from config. Idempotent. Verified: bare test/all.js 28/28 (new migration-readopt.test.js: migrate + idempotent + no-legacy-untouched); hyper-proto-peer swarm-device/multi-device/ban still green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
256f9bc4debd208ff179f75be730198878c5673e
by plankatron <plankatron@users.noreply.github.com> · 2026-07-14T22:57:37.000Z

Changed files (5) +164 -5

lib/state-base.js +105 -2
……@@ -39,6 +39,10 @@ const STATE_VERSION = 3
3939// hyper-proto-peer device-node.js bootSwarmDevice). Never change this string again without a
4040// migration story.
4141const GS_NAMESPACE = Buffer.from('atproto-room/state/v' + STATE_VERSION, 'utf-8')
42// The pre-extraction namespace. A room created before the 'hyper-tps/state/v3' -> 'atproto-room/
43// state/v3' rename derives its base key from this string. Kept solely so the one-time in-place
44// re-adopt migration (_migrateFromLegacyNamespace) can open such a room and seed the renamed one.
45const LEGACY_GS_NAMESPACE = Buffer.from('hyper-tps/state/v' + STATE_VERSION, 'utf-8')
4246const ENTRIES = '@state/entries'
4347// Reserved op key: a designated admin anchor claims the SOLE indexer slot
4448// (move-indexer). Carried as the `key` of an optimistic admit op; never inserted.
……@@ -77,7 +81,7 @@ function normKeyHex (k) {
7781 const s = String(k)
7882 return /^[0-9a-fA-F]{64}$/.test(s) ? s.toLowerCase() : s
7983}
80function deriveGsKeyPair (roomSeed) { return crypto.keyPair(crypto.hash([GS_NAMESPACE, toBuf(roomSeed)])) }
84function deriveGsKeyPair (roomSeed, ns = GS_NAMESPACE) { return crypto.keyPair(crypto.hash([ns, toBuf(roomSeed)])) }
8185function deriveBaseKey (gsPublicKey) { return Hypercore.key({ version: 1, signers: [{ publicKey: gsPublicKey }] }) }
8286
8387// Router → instance handlers (need instance state for admission). ctx carries
……@@ -146,7 +150,10 @@ export class StateBaseV3 {
146150
147151 // Deterministic base identity from the room's members-only secret (v2 design).
148152 this._roomSeed = opts.roomSeed ? toBuf(opts.roomSeed) : null
149 this._gsKeyPair = this._roomSeed ? deriveGsKeyPair(this._roomSeed) : null
153 // gsNamespace override: defaults to the current GS_NAMESPACE. The one-time re-adopt migration
154 // and tests pass LEGACY_GS_NAMESPACE to open a pre-rename room in place.
155 this._gsNamespace = opts.gsNamespace ? toBuf(opts.gsNamespace) : GS_NAMESPACE
156 this._gsKeyPair = this._roomSeed ? deriveGsKeyPair(this._roomSeed, this._gsNamespace) : null
150157 this._detBaseKey = this._gsKeyPair ? deriveBaseKey(this._gsKeyPair.publicKey) : null
151158 this._encryptionKey = opts.encryptionKey ? toBuf(opts.encryptionKey) : null
152159
……@@ -199,6 +206,10 @@ export class StateBaseV3 {
199206 await this._quarantineAndReset()
200207 }
201208 this._ready = true
209 // One-time in-place re-adopt migration for the namespace rename: if this is the writable creator
210 // and the (renamed) base is empty but a legacy-namespace base is present in the same store, copy
211 // its durable entries across. Best-effort + awaited so the room is seeded before consumers read.
212 await this._migrateFromLegacyNamespace()
202213 // Admins/mods AUTO-promote to indexers on open (keet model: admins ARE the
203214 // indexers — members/index.js addWriter(indexer:isIndexing)). Best-effort +
204215 // background so it never blocks boot; needs an online indexer to land and
……@@ -646,6 +657,98 @@ export class StateBaseV3 {
646657 } catch { return false }
647658 }
648659
660 // ── One-time in-place re-adopt migration (namespace rename) ─────────────────
661 // The 'hyper-tps/state/v3' -> 'atproto-room/state/v3' rename changes the derived base key, so a
662 // room created under the old namespace opens here as a FRESH, empty base that shares the same
663 // on-disk Corestore as its old cores. Seed the renamed base from the legacy one so the room is
664 // reconstituted — repo records + the signed __commit__ firehose + membership. Only the writable
665 // creator seeds (peers replicate); no-op if the renamed base already has entries, if this instance
666 // IS the legacy namespace, or if no legacy base is present. Idempotent across reboots.
667 //
668 // Carries every DIRECT set-entry entry (repo records, __members__, __devices__, __commit__/*,
669 // prefs) verbatim. It does NOT carry the apply-DERIVED governance entries __removed__ (bans) /
670 // __receipt__ (min-epoch) — those are produced inside apply from admission-proof ops, not stored as
671 // plain set-entry ops, so they re-establish from config (re-issue a ban after migration if needed).
672 async _migrateFromLegacyNamespace () {
673 try {
674 if (!this._roomSeed || !this.base || !this.base.writable) return
675 if (Buffer.compare(this._gsNamespace, LEGACY_GS_NAMESPACE) === 0) return // already the legacy view
676 const existing = await this.list('')
677 if (Object.keys(existing).length > 0) return // renamed base already populated — nothing to do
678 const legacyBaseKey = deriveBaseKey(deriveGsKeyPair(this._roomSeed, LEGACY_GS_NAMESPACE).publicKey)
679 if (Buffer.compare(legacyBaseKey, this._detBaseKey) === 0) return // no rename in effect
680 // Open the legacy base READ-ONLY on the SAME corestore (no fd-lock conflict — one store, two
681 // autobases), with a minimal apply that only materializes direct set/del entries.
682 const router = this._makeLegacyReadRouter()
683 const opts = {
684 open: (store) => HyperDB.bee(store.get('view'), hyperdbDefinition, { autoUpdate: true }),
685 apply: async (nodes, view) => {
686 const tx = view.transaction()
687 try {
688 for (const node of nodes) { const op = node.value; if (Buffer.isBuffer(op)) await router.dispatch(op, { tx }) }
689 await tx.flush()
690 } catch (e) { try { await tx.close() } catch {} ; throw e }
691 },
692 close: async (view) => { if (view) await view.close() },
693 valueEncoding: 'binary', optimistic: true, ackInterval: 1000, ackThreshold: 4
694 }
695 if (this._encryptionKey) { opts.encryptionKey = this._encryptionKey; opts.encrypted = true }
696 // Open on a Corestore SESSION, not this.store directly: a session shares the same storage +
697 // cores (so it sees the legacy base) but has its own ref-counting, so closing it releases only
698 // the migration's core sessions — it never tears down cores the live base is still using
699 // (SESSION_CLOSED). No second fd-lock (same underlying storage).
700 const legacyStore = this.store.session()
701 // Cheap presence probe FIRST: the legacy creator's ops live in the bootstrap core (it opened
702 // with that keyPair), so a real legacy room has length > 0 there. A genuinely fresh room has
703 // no such data — skip WITHOUT opening a full legacy Autobase (which would spawn ~10 empty
704 // cores on every first boot). One tiny empty probe core is the only footprint.
705 const probe = legacyStore.get({ key: legacyBaseKey })
706 await probe.ready()
707 const hasLegacy = probe.length > 0
708 if (!hasLegacy) { try { await legacyStore.close() } catch {} ; return }
709 const legacy = new Autobase(legacyStore, legacyBaseKey, opts)
710 try {
711 await legacy.ready()
712 await legacy.update()
713 const entries = []
714 const stream = legacy.view.find(ENTRIES, { gte: { key: '' }, lt: { key: '~' } })
715 for await (const rec of stream) entries.push(rec)
716 if (entries.length === 0) return
717 let n = 0
718 for (const rec of entries) {
719 // Copy verbatim (value is already the stored encoded string). Direct append — the creator
720 // is writable, so no doorbell needed (mirrors projectMembers).
721 await this.base.append(encode('@state/set-entry', { key: rec.key, value: rec.value, ts: rec.ts || 0 }))
722 n++
723 }
724 await this.base.update()
725 console.log('[state-base-v3] room=%s RE-ADOPT MIGRATED %d entries from legacy namespace (base %s)',
726 this._roomKeyHex.slice(0, 12), n, legacyBaseKey.toString('hex').slice(0, 12))
727 } finally { try { await legacy.close() } catch {} ; try { await legacyStore.close() } catch {} }
728 } catch (e) { console.log('[state-base-v3] legacy migration skipped: %s', e && e.message) }
729 }
730
731 // Minimal read-only router for materializing a legacy base's view: LWW-insert direct set-entry
732 // ops, LWW-delete del-entry ops. Skips the transient admission/indexer/ban/epoch REQUEST ops
733 // (empty key + the reserved __*__ command keys) — those carry no durable entry (their effects are
734 // applied by the full router). Enough to reproduce every stored entry we migrate.
735 _makeLegacyReadRouter () {
736 const TRANSIENT = new Set(['', MOVE_INDEXER, START_INDEXING, REMOVE_DEVICE, SET_EPOCH])
737 const r = new Router()
738 r.add('@state/set-entry', async (rec, ctx) => {
739 if (!rec.key || TRANSIENT.has(rec.key)) return
740 const existing = await ctx.tx.get(ENTRIES, { key: rec.key })
741 if (existing && typeof existing.ts === 'number' && existing.ts > (rec.ts || 0)) return
742 await ctx.tx.insert(ENTRIES, { key: rec.key, value: rec.value, ts: rec.ts || 0 })
743 })
744 r.add('@state/del-entry', async (rec, ctx) => {
745 const existing = await ctx.tx.get(ENTRIES, { key: rec.key })
746 if (existing && typeof existing.ts === 'number' && existing.ts > (rec.ts || 0)) return
747 await ctx.tx.delete(ENTRIES, { key: rec.key })
748 })
749 return r
750 }
751
649752 // ── Membership projection (cross-base sync) ────────────────
650753 // Daemon's out-of-apply watcher copies keet-room membership in so apply can
651754 // gate admission deterministically. Stored under the reserved key __members__
package-lock.json +2 -2
……@@ -1,12 +1,12 @@
11{
22 "name": "atproto-room",
3 "version": "0.1.0",
3 "version": "0.2.0",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "atproto-room",
9 "version": "0.1.0",
9 "version": "0.2.0",
1010 "hasInstallScript": true,
1111 "dependencies": {
1212 "@ipld/dag-cbor": "^9.2.1",
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-room",
3 "version": "0.1.0",
3 "version": "0.2.0",
44 "description": "Attestation-gated, encrypted multi-writer room engine — StateBase (keet-model admission) + Lease + signer + RepoBase + LabelBase/LabelRoom + serve-membrane. Extracted from hyper-proto-peer; consumed by the atproto peer (repo devices) and the co-authored labeler.",
55 "type": "module",
66 "main": "index.js",
test/all.js +1 -0
……@@ -12,3 +12,4 @@ import './membership.test.js'
1212import './doorbell-durable-write.test.js'
1313import './non-indexer-default.test.js'
1414import './co-authored-labeler.test.js'
15import './migration-readopt.test.js'
test/migration-readopt.test.js +55 -0
……@@ -0,0 +1,55 @@
1// Re-adopt migration for the 'hyper-tps/state/v3' -> 'atproto-room/state/v3' namespace rename.
2// A room created under the legacy namespace opens under the new one as an empty base sharing the
3// same on-disk Corestore; StateBaseV3._migrateFromLegacyNamespace seeds the new base from the old.
4// Run: bare test/migration-readopt.test.js (aggregated in test/all.js)
5import '../lib/bare-globals.js'
6import test from 'brittle'
7import crypto from 'hypercore-crypto'
8import fs from 'bare-fs'
9import { StateBaseV3 } from '../lib/state-base.js'
10
11const LEGACY = Buffer.from('hyper-tps/state/v3', 'utf-8')
12const tmp = () => { const d = '/tmp/atr-mig-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
13
14test('re-adopt: a renamed-namespace room seeds from the legacy base in the same dir', async (t) => {
15 const dir = tmp()
16 const roomKey = crypto.randomBytes(32)
17 const roomSeed = crypto.randomBytes(32)
18 const encryptionKey = crypto.randomBytes(32)
19 const cfg = (gsNamespace) => ({ roomSeed, encryptionKey, network: false, ...(gsNamespace ? { gsNamespace } : {}) })
20
21 // 1. Populate a LEGACY-namespace room: repo records + a firehose entry + a membership entry.
22 const legacy = new StateBaseV3(dir, roomKey, cfg(LEGACY))
23 await legacy.ready()
24 await legacy.set('app.bsky.feed.post/aaa', { text: 'hello' })
25 await legacy.set('app.bsky.feed.post/bbb', { text: 'world' })
26 await legacy.set('__commit__/3abc', { rev: '3abc', commitCid: 'bafyfake' })
27 await legacy.close() // release the fd-lock before reopening the same dir
28
29 // 2. Open the RENAMED room on the SAME dir → migration fires (empty new base + legacy base present).
30 const fresh = new StateBaseV3(dir, roomKey, cfg())
31 await fresh.ready()
32 t.alike(await fresh.get('app.bsky.feed.post/aaa'), { text: 'hello' }, 'repo record aaa migrated')
33 t.alike(await fresh.get('app.bsky.feed.post/bbb'), { text: 'world' }, 'repo record bbb migrated')
34 t.alike(await fresh.get('__commit__/3abc'), { rev: '3abc', commitCid: 'bafyfake' }, '__commit__ firehose entry migrated')
35 const all = await fresh.list('')
36 t.is(Object.keys(all).length, 3, 'exactly the 3 durable entries migrated (no transient/admission junk)')
37 await fresh.close()
38
39 // 3. Idempotent: reopening the renamed room again does not re-migrate or lose data.
40 const again = new StateBaseV3(dir, roomKey, cfg())
41 await again.ready()
42 t.alike(await again.get('app.bsky.feed.post/aaa'), { text: 'hello' }, 'still present on 2nd reopen (idempotent)')
43 t.is(Object.keys(await again.list('')).length, 3, 'still exactly 3 entries (no duplication)')
44 await again.close()
45})
46
47test('re-adopt: a fresh renamed room with NO legacy base is untouched', async (t) => {
48 const dir = tmp()
49 const sb = new StateBaseV3(dir, crypto.randomBytes(32), { roomSeed: crypto.randomBytes(32), encryptionKey: crypto.randomBytes(32), network: false })
50 await sb.ready()
51 t.is(Object.keys(await sb.list('')).length, 0, 'no legacy base → nothing migrated, room is empty')
52 await sb.set('app.bsky.feed.post/x', { text: 'new' })
53 t.alike(await sb.get('app.bsky.feed.post/x'), { text: 'new' }, 'normal writes still work')
54 await sb.close()
55})