OverviewFilesCommitsIssuesDocsReleasesPeers

favela-community commits / 57886d8

favela-community 0.1.1: CommunityRooms.watch — roster-change watcher
Add watch(roomKey, onChange) to the keyless read-manager: onChange fires (debounced) on every linearized-view advance — a member add/remove/ban replicating in — plus once immediately as a baseline. Same base.on('update') signal waitWritable relies on, which fires on replication. Runs are serialized (never re-entrant) and coalesced (a change during a run re-fires once after); onChange errors are swallowed so a flaky consumer never breaks replication. Returns an async unwatch fn. This is the primitive favela-web uses to index community members added AFTER it started, with no restart (DESIGN §7 roster watcher). Test proves baseline + delta over hand-wired replication. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
57886d8634e1d1d3bfda8bff867e79ee17a050e9
by plankatron <fleeky@gmail.com> · 2026-07-15T12:56:13.000Z

Changed files (3) +47 -4

lib/community-room.js +25 -0
……@@ -160,6 +160,31 @@ export class CommunityRooms {
160160 async members (roomKeyHex) { return (await this.room(roomKeyHex)).members() }
161161 async meta (roomKeyHex) { return (await this.room(roomKeyHex)).meta() }
162162
163 // Watch one room's roster for changes. onChange(memberDids[]) fires (debounced) on every linearized-view
164 // advance — i.e. whenever a member add/remove/ban replicates in — PLUS once immediately as a baseline.
165 // This is the roster-watcher a keyless consumer (favela-web) uses to index members added AFTER it started,
166 // with no restart: the same `base.on('update')` signal waitWritable relies on, which fires on replication.
167 // Runs are serialized (never re-entrant) and coalesced (a change during a run re-fires once after). Errors
168 // from onChange are swallowed — a flaky consumer must never break replication. Returns an async unwatch fn.
169 async watch (roomKeyHex, onChange, { debounceMs = 300 } = {}) {
170 const cr = await this.room(roomKeyHex)
171 const base = cr.base
172 let timer = null
173 let running = false
174 let pending = false
175 const run = async () => {
176 if (running) { pending = true; return }
177 running = true
178 try { await onChange(await cr.members()) } catch {}
179 running = false
180 if (pending) { pending = false; schedule() }
181 }
182 const schedule = () => { clearTimeout(timer); timer = setTimeout(run, debounceMs) }
183 base.on('update', schedule)
184 schedule() // baseline: index the current roster now
185 return async () => { clearTimeout(timer); base.off('update', schedule) }
186 }
187
163188 async close () {
164189 for (const p of this._rooms.values()) { try { await (await p).close() } catch {} }
165190 this._rooms.clear()
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-community",
3 "version": "0.1.0",
3 "version": "0.1.1",
44 "description": "Co-owned, plaintext community rooms for Favela — membership + moderation on a hyper-rooms Autobase. Read KEYLESSLY by the gateway (favela-web); WRITTEN by mods (hpp / full-node / CLI). One community protocol, shared across surfaces.",
55 "type": "module",
66 "main": "index.js",
test/community-room.test.mjs +21 -3
……@@ -4,7 +4,7 @@
44// ops linearize through the owner (sole indexer) — the write layer.
55// Bare-native (Autobase/Hyperbee need a disk path). Run: bare test/community-room.test.mjs
66import Corestore from 'corestore'
7import { CommunityRoom } from '../index.js'
7import { CommunityRoom, CommunityRooms } from '../index.js'
88
99const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) }
1010const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
……@@ -67,7 +67,25 @@ assert(await until(async () => { const m = await reader.members(); return m.incl
6767const final = (await owner.members()).sort()
6868assert(final.length === 2 && final.includes(C) && final.includes(D), 'final roster = {C,D} (A banned by mod, B banned by owner)')
6969
70console.log('\nFAVELA-COMMUNITY OK (Bare-native): keyless read + mod admission + co-writer membership/moderation linearize through the owner')
70// ── ROSTER WATCHER: a member added AFTER watch starts reaches the consumer with no re-open ──
71// This is favela-web's path: index members added to a room LATER without a restart (DESIGN §7). We drive
72// replication by hand (no swarm) and assert the watcher fires a baseline AND a delta.
73const watchStore = new Corestore('data/cc-watch-' + stamp)
74const crooms = new CommunityRooms(watchStore, null) // no swarm — wire replication manually below
75const seen = []
76const unwatch = await crooms.watch(ROOM, (dids) => { seen.push(dids.slice().sort()) }, { debounceMs: 50 })
77const watchReader = await crooms.room(ROOM) // same replica watch() opened
78const unwireW = wire(owner, watchReader)
79// current roster at this point = {C, D} (A + B banned above)
80assert(await until(() => seen.length > 0 && seen[seen.length - 1].includes(D) && seen[seen.length - 1].includes(C)), 'watch BASELINE: consumer sees the current roster {C,D} after replication')
81const E = 'did:plc:erin2222222222222222222222'
82await owner.addMember(E)
83assert(await until(() => seen[seen.length - 1].includes(E)), 'watch DELTA: a member (E) added AFTER watch started reaches the consumer with NO re-open')
84await unwatch()
85unwireW()
86
87console.log('\nFAVELA-COMMUNITY OK (Bare-native): keyless read + mod admission + co-writer membership/moderation linearize through the owner + roster watcher (baseline + delta)')
7188unwireR(); unwireM()
7289await owner.close(); await reader.close(); await mod.close()
73try { await ownerStore.close(); await readerStore.close(); await modStore.close() } catch {}
90try { await crooms.close() } catch {}
91try { await ownerStore.close(); await readerStore.close(); await modStore.close(); await watchStore.close() } catch {}