| … | … | @@ -39,6 +39,10 @@ const STATE_VERSION = 3 |
| 39 | 39 | // hyper-proto-peer device-node.js bootSwarmDevice). Never change this string again without a |
| 40 | 40 | // migration story. |
| 41 | 41 | const 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. |
| 45 | const LEGACY_GS_NAMESPACE = Buffer.from('hyper-tps/state/v' + STATE_VERSION, 'utf-8') |
| 42 | 46 | const ENTRIES = '@state/entries' |
| 43 | 47 | // Reserved op key: a designated admin anchor claims the SOLE indexer slot |
| 44 | 48 | // (move-indexer). Carried as the `key` of an optimistic admit op; never inserted. |
| … | … | @@ -77,7 +81,7 @@ function normKeyHex (k) { |
| 77 | 81 | const s = String(k) |
| 78 | 82 | return /^[0-9a-fA-F]{64}$/.test(s) ? s.toLowerCase() : s |
| 79 | 83 | } |
| 80 | | function deriveGsKeyPair (roomSeed) { return crypto.keyPair(crypto.hash([GS_NAMESPACE, toBuf(roomSeed)])) } |
| 84 | function deriveGsKeyPair (roomSeed, ns = GS_NAMESPACE) { return crypto.keyPair(crypto.hash([ns, toBuf(roomSeed)])) } |
| 81 | 85 | function deriveBaseKey (gsPublicKey) { return Hypercore.key({ version: 1, signers: [{ publicKey: gsPublicKey }] }) } |
| 82 | 86 | |
| 83 | 87 | // Router → instance handlers (need instance state for admission). ctx carries |
| … | … | @@ -146,7 +150,10 @@ export class StateBaseV3 { |
| 146 | 150 | |
| 147 | 151 | // Deterministic base identity from the room's members-only secret (v2 design). |
| 148 | 152 | 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 |
| 150 | 157 | this._detBaseKey = this._gsKeyPair ? deriveBaseKey(this._gsKeyPair.publicKey) : null |
| 151 | 158 | this._encryptionKey = opts.encryptionKey ? toBuf(opts.encryptionKey) : null |
| 152 | 159 | |
| … | … | @@ -199,6 +206,10 @@ export class StateBaseV3 { |
| 199 | 206 | await this._quarantineAndReset() |
| 200 | 207 | } |
| 201 | 208 | 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() |
| 202 | 213 | // Admins/mods AUTO-promote to indexers on open (keet model: admins ARE the |
| 203 | 214 | // indexers — members/index.js addWriter(indexer:isIndexing)). Best-effort + |
| 204 | 215 | // background so it never blocks boot; needs an online indexer to land and |
| … | … | @@ -646,6 +657,98 @@ export class StateBaseV3 { |
| 646 | 657 | } catch { return false } |
| 647 | 658 | } |
| 648 | 659 | |
| 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 | |
| 649 | 752 | // ── Membership projection (cross-base sync) ──────────────── |
| 650 | 753 | // Daemon's out-of-apply watcher copies keet-room membership in so apply can |
| 651 | 754 | // gate admission deterministically. Stored under the reserved key __members__ |