OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-orgs commits / b858680

Add HyperOrgs persistence — re-open org Rooms on restart
Local append-only index core (NOT replicated) records which orgs this instance tracks + how to re-open each Room (name, bootstrapKey, role, created). ready() replays it; create/joinOrg append 'track', delete appends 'untrack'. Created orgs re-open under namespace 'org/<name>' (stable creator writer); joined under 'org-join/<key16>'. Required for the daemon (survives restart). persistence test: orgs + state survive a Corestore restart and stay writable. Suite 23/23, 62 asserts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
b8586801d2650e2baa79df7fa793db5c4970af49
by plankatron <fleeky@gmail.com> · 2026-05-24T23:57:58.000Z

Changed files (2) +89 -6

lib/hyper-orgs.js +44 -6
……@@ -35,16 +35,51 @@ export class HyperOrgs {
3535 */
3636 constructor (corestore, options = {}) {
3737 this._corestore = corestore.namespace(options.namespace || 'orgs')
38 // name -> { room: Room, role: string, joined: boolean }
38 // name -> { room: Room, role: string, created: boolean }
3939 this._orgs = new Map()
40 // Local append-only index (NOT replicated): which orgs this instance
41 // tracks + how to re-open each Room on restart. {op,name,bootstrapKey,
42 // role,created}. Last-write-wins per name; op:'untrack' drops.
43 this._index = null
4044 this._opened = false
4145 }
4246
4347 async ready () {
48 if (this._opened) return
49 this._index = this._corestore.namespace('orgs-index').get({ name: 'log', valueEncoding: 'json' })
50 await this._index.ready()
51 // Replay the index → re-open tracked org Rooms.
52 const tracked = new Map()
53 for (let i = 0; i < this._index.length; i++) {
54 const e = await this._index.get(i)
55 if (!e || !e.name) continue
56 if (e.op === 'untrack') tracked.delete(e.name)
57 else tracked.set(e.name, e)
58 }
59 for (const e of tracked.values()) {
60 await this._openOrgRoom(e.name, e.bootstrapKey, { created: !!e.created, role: e.role || 'reader' })
61 }
4462 this._opened = true
45 // TODO(daemon): re-open known org Rooms from a local persistence
46 // index here so orgs survive a restart. In-process callers create
47 // or join within one instance lifetime, so the Map suffices.
63 }
64
65 // Open (or re-open) an org's Room. Created orgs use namespace
66 // 'org/<name>' (creator's local writer); joined orgs use
67 // 'org-join/<key16>'. The namespace must match the original so the
68 // local writer identity is stable across restarts.
69 async _openOrgRoom (name, bootstrapKey, { created, role }) {
70 const namespace = created ? 'org/' + name : 'org-join/' + bootstrapKey.slice(0, 16)
71 const room = new Room(this._corestore, { schema: orgsSchema, namespace, bootstrapKey })
72 await room.ready()
73 this._orgs.set(name, { room, role, created })
74 return room
75 }
76
77 async _track (name, bootstrapKey, role, created) {
78 if (this._index) await this._index.append({ op: 'track', name, bootstrapKey, role, created })
79 }
80
81 async _untrack (name) {
82 if (this._index) await this._index.append({ op: 'untrack', name })
4883 }
4984
5085 async sync () {
……@@ -104,7 +139,7 @@ export class HyperOrgs {
104139 bootstrapKey: null
105140 })
106141 await room.ready()
107 this._orgs.set(name, { room, role: 'admin', joined: false })
142 this._orgs.set(name, { room, role: 'admin', created: true })
108143
109144 await room.append({
110145 type: 'create-org',
……@@ -119,6 +154,7 @@ export class HyperOrgs {
119154 timestamp: Date.now()
120155 })
121156 await room.base.update()
157 await this._track(name, room.keyHex, 'admin', true)
122158 return this.get(name)
123159 }
124160
……@@ -159,6 +195,7 @@ export class HyperOrgs {
159195 const e = this._orgs.get(name)
160196 if (e) { try { await e.room.close() } catch {} }
161197 this._orgs.delete(name)
198 await this._untrack(name)
162199 }
163200
164201 /** Orgs this instance tracks (created or joined). */
……@@ -230,7 +267,8 @@ export class HyperOrgs {
230267 })
231268 await room.ready()
232269 await room.base.update()
233 this._orgs.set(name, { room, role, joined: true })
270 this._orgs.set(name, { room, role, created: false })
271 await this._track(name, decoded.bootstrapKey, role, false)
234272 return { name, writerKey: room.localKeyHex, role }
235273 }
236274
test/persistence.test.js +45 -0
……@@ -0,0 +1,45 @@
1// Persistence (issue #35): a daemon restarts. Orgs created/joined in a
2// prior run must be re-opened from the local index on ready(), not lost.
3
4import test from 'brittle'
5import Corestore from 'corestore'
6import { mkdtempSync } from 'fs'
7import { join } from 'path'
8import { tmpdir } from 'os'
9import { HyperOrgs } from '../index.js'
10
11test('orgs survive a restart (re-opened from local index)', async (t) => {
12 const dir = mkdtempSync(join(tmpdir(), 'hyper-orgs-persist-'))
13
14 // ── first run: create two orgs ──
15 let store = new Corestore(dir)
16 let orgs = new HyperOrgs(store, { namespace: 'persist' })
17 await orgs.ready()
18 await orgs.create('alpha', 'group', { displayName: 'Alpha' })
19 await orgs.create('beta', 'group')
20 await orgs.addResource('alpha', 'repo-1')
21 await orgs.close()
22 await store.close()
23
24 // ── restart: fresh instances on the SAME storage dir ──
25 store = new Corestore(dir)
26 orgs = new HyperOrgs(store, { namespace: 'persist' })
27 await orgs.ready()
28
29 const names = (await orgs.list()).map(o => o.name).sort()
30 t.alike(names, ['alpha', 'beta'], 'both orgs re-opened after restart')
31
32 const alpha = await orgs.get('alpha')
33 t.ok(alpha, 'alpha readable after restart')
34 t.alike(alpha.resources, ['repo-1'], 'alpha state intact')
35
36 // re-opened created org is still writable (same namespace → same writer)
37 t.ok(orgs.getOrgRoom('alpha').writable, 'creator still writable after restart')
38 await orgs.addResource('beta', 'repo-2')
39 t.alike((await orgs.get('beta')).resources, ['repo-2'], 'can still write after restart')
40
41 t.teardown(async () => {
42 try { await orgs.close() } catch {}
43 try { await store.close() } catch {}
44 })
45})