| … | … | @@ -4,3 +4,5 @@ |
| 4 | 4 | export { communitySchema, isDid } from './lib/schema.js' |
| 5 | 5 | export { CommunityRoom, CommunityRooms } from './lib/community-room.js' |
| 6 | 6 | export { CommunityDiscovery } from './lib/discovery.js' |
| 7 | export { FeedRegistry, FeedRegistries } from './lib/feed-registry.js' | |
| 8 | export { feedRegistrySchema, feedKey, FEED_PREFIX } from './lib/feed-schema.js' |
| … | … | @@ -0,0 +1,179 @@ |
| 1 | // FeedRegistry — the watched, co-ownable list of feeds (favela-web/spec/community-ux.md). One class, three | |
| 2 | // roles (same shape as CommunityRoom): | |
| 3 | // - OWNER (create): genesis writer + sole indexer; publishes/removes feed entries. | |
| 4 | // - MOD (open + admitted): a co-writer, admitted by the owner (addMod) — so a registry can be co-owned. | |
| 5 | // - READER (openReadonly): KEYLESS — opens by the PUBLIC bootstrapKey, holds no key, never a writer; | |
| 6 | // replicates + lists + WATCHES. This is the reactor path (favela-web / membrane / hpp / blind-relay). | |
| 7 | // | |
| 8 | // Entries linearize into a plaintext Hyperbee view (feed-schema.js). It is the source of truth: the mint | |
| 9 | // writes an entry; the reactors watch it and serve/front/index/seed the feed with no per-feed config. | |
| 10 | // | |
| 11 | // hyper-rooms `Room` is imported by SUBPATH (skips the room-pairing/blind-pairing dep the read/write paths | |
| 12 | // here don't use — mod admission is key-exchange, below), matching CommunityRoom. | |
| 13 | import { Room } from 'hyper-rooms/lib/room.js' | |
| 14 | import { feedRegistrySchema, isDid, isRkey, FEED_PREFIX } from './feed-schema.js' | |
| 15 | ||
| 16 | const normKey = (k) => (typeof k === 'string' ? k.toLowerCase() : k) | |
| 17 | ||
| 18 | export class FeedRegistry { | |
| 19 | constructor (room) { this._room = room } | |
| 20 | ||
| 21 | // OWNER — create a fresh registry. This peer is the genesis writer AND the sole indexer (N=1, the safe | |
| 22 | // default per the autobase quorum rule — never auto-grow to N=2). Grow indexers only via addIndexer. | |
| 23 | static async create (corestore, { namespace = 'feed-registry', name = null } = {}) { | |
| 24 | const room = new Room(corestore, { schema: feedRegistrySchema, bootstrapKey: null, namespace }) | |
| 25 | await room.ready() | |
| 26 | const fr = new FeedRegistry(room) | |
| 27 | if (name) await fr._room.append({ type: 'set-meta', key: 'name', value: name }).catch(() => {}) | |
| 28 | return fr | |
| 29 | } | |
| 30 | ||
| 31 | // MOD — open an EXISTING registry to co-write. Writable only AFTER the owner addMods this peer's localKey | |
| 32 | // (waitWritable blocks until then). Until admitted it can still READ. | |
| 33 | static async open (corestore, roomKey, { namespace } = {}) { | |
| 34 | const key = normKey(roomKey) | |
| 35 | if (!/^[0-9a-f]{64}$/.test(String(key))) throw new Error('roomKey must be a 32-byte hex bootstrap key') | |
| 36 | const room = new Room(corestore, { schema: feedRegistrySchema, bootstrapKey: key, namespace: namespace || `feed-registry:${key}` }) | |
| 37 | await room.ready() | |
| 38 | return new FeedRegistry(room) | |
| 39 | } | |
| 40 | ||
| 41 | // READER — keyless read-only open (semantically identical to open(); named for intent). The reactors use | |
| 42 | // this: they never call a write method, so are never admitted and hold no key. | |
| 43 | static openReadonly (corestore, roomKey, opts) { return FeedRegistry.open(corestore, roomKey, opts) } | |
| 44 | ||
| 45 | // ── identity / replication surface ── | |
| 46 | get key () { return this._room.keyHex } // PUBLIC bootstrap key — share to reactors/co-mods | |
| 47 | get localKey () { return this._room.localKeyHex } // THIS peer's writer key — give to the owner for addMod | |
| 48 | get discoveryKey () { return this._room.discoveryKey } | |
| 49 | get writable () { return this._room.writable } | |
| 50 | get corestore () { return this._room.corestore } | |
| 51 | get base () { return this._room.base } | |
| 52 | get view () { return this._room.view } | |
| 53 | ||
| 54 | async update () { await this._room.base.update() } | |
| 55 | async close () { await this._room.close() } | |
| 56 | ||
| 57 | // Block until this peer has been admitted as a writer (autopass whenWritable pattern). Returns false on | |
| 58 | // timeout. A mod calls this after handing its localKey to the owner, before writing. | |
| 59 | waitWritable ({ timeout = 30000 } = {}) { | |
| 60 | const base = this._room.base | |
| 61 | if (base.writable) return Promise.resolve(true) | |
| 62 | return new Promise((resolve) => { | |
| 63 | let done = false | |
| 64 | const finish = (v) => { if (done) return; done = true; base.off('update', check); clearTimeout(timer); resolve(v) } | |
| 65 | const check = () => { if (base.writable) finish(true) } | |
| 66 | const timer = setTimeout(() => finish(false), timeout) | |
| 67 | base.on('update', check) | |
| 68 | }) | |
| 69 | } | |
| 70 | ||
| 71 | // ── reads (keyless-safe) ── | |
| 72 | // Every registered feed, as the stored entry objects { did, rkey, name, curator, params, roomKey, handle, | |
| 73 | // createdBy, at }. This is what a reactor iterates: index / front / serve / seed each. | |
| 74 | async feeds () { | |
| 75 | await this._room.base.update() | |
| 76 | const out = [] | |
| 77 | for await (const { value } of this._room.view.createReadStream({ gte: FEED_PREFIX, lt: FEED_PREFIX + '\xff' })) out.push(value) | |
| 78 | return out | |
| 79 | } | |
| 80 | ||
| 81 | async feed (did, rkey) { | |
| 82 | await this._room.base.update() | |
| 83 | const node = await this._room.view.get(`${FEED_PREFIX}${did}/${rkey}`) | |
| 84 | return (node && node.value) || null | |
| 85 | } | |
| 86 | ||
| 87 | // ── writes (owner/mods; throw if not admitted) ── | |
| 88 | // Publish (or update — last-writer-wins on the key) a feed. did = the repo that HOLDS the feed record | |
| 89 | // (your own hpp for a light feed, or a co-owned community DID). curator/params = the contract favela-web | |
| 90 | // runs; roomKey = the roster CommunityRoom (only for community/roster feeds). Idempotent per (did, rkey). | |
| 91 | async addFeed ({ did, rkey, name = null, curator, params = {}, roomKey = null, handle = null, by = null } = {}) { | |
| 92 | this._assertWritable('addFeed') | |
| 93 | if (!isDid(did)) throw new Error('addFeed requires a valid did (the feed record home repo)') | |
| 94 | if (!isRkey(rkey)) throw new Error('addFeed requires a valid rkey') | |
| 95 | if (typeof curator !== 'string' || !curator) throw new Error('addFeed requires a curator name') | |
| 96 | if (params != null && (typeof params !== 'object' || Array.isArray(params))) throw new Error('addFeed params must be an object') | |
| 97 | await this._room.append({ type: 'add-feed', did, rkey, name, curator, params: params || {}, roomKey, handle, by, at: Date.now() }) | |
| 98 | } | |
| 99 | ||
| 100 | async removeFeed (did, rkey) { | |
| 101 | this._assertWritable('removeFeed') | |
| 102 | if (!isDid(did) || !isRkey(rkey)) throw new Error('removeFeed requires a valid did + rkey') | |
| 103 | await this._room.append({ type: 'remove-feed', did, rkey }) | |
| 104 | } | |
| 105 | ||
| 106 | // ── admission (owner adds a co-writer) — same model + warnings as CommunityRoom.addMod ── | |
| 107 | // Default role 'writer' = a plain writer (non-indexer, the SAFE topology: owner stays sole indexer N=1, so | |
| 108 | // the mod's ops still linearize via ackInterval without the N=2 quorum trap). role 'owner' promotes to | |
| 109 | // INDEXER — only to reach an ODD indexer count ≥3 on independent always-on boxes. | |
| 110 | async addMod (writerKeyHex, { role = 'writer', userId = null, addedBy = null } = {}) { | |
| 111 | this._assertWritable('addMod') | |
| 112 | if (!/^[0-9a-f]{64}$/i.test(String(writerKeyHex))) throw new Error('addMod requires the mod\'s 64-hex localKey') | |
| 113 | if (!['owner', 'writer', 'mirror'].includes(role)) throw new Error('role must be owner, writer, or mirror') | |
| 114 | await this._room.addWriter(userId || String(writerKeyHex).toLowerCase(), { writerKey: String(writerKeyHex).toLowerCase(), role, addedBy }) | |
| 115 | } | |
| 116 | ||
| 117 | async removeMod (userId) { this._assertWritable('removeMod'); await this._room.removeWriter(userId) } | |
| 118 | ||
| 119 | _assertWritable (op) { if (!this._room.writable) throw new Error(`${op}: not a writer — this peer holds no key / has not been admitted (reactors are keyless by design)`) } | |
| 120 | } | |
| 121 | ||
| 122 | // ── read-manager (reactors) ───────────────────────────────────── | |
| 123 | // Opens + maintains a KEYLESS read replica of a registry on a shared corestore + swarm, answers feeds(), and | |
| 124 | // WATCHES for changes. This is the reactor primitive: favela-web / membrane / hpp / blind-relay each wrap a | |
| 125 | // FeedRegistries and react to onChange(feeds[]) — index / front / serve / seed the new feed. No per-feed config. | |
| 126 | export class FeedRegistries { | |
| 127 | constructor (corestore, swarm) { | |
| 128 | this._store = corestore | |
| 129 | this._swarm = swarm | |
| 130 | this._registries = new Map() // roomKeyHex -> Promise<FeedRegistry> | |
| 131 | // Replicate our corestore on every swarm connection (joining a discoveryKey only DISCOVERS; without | |
| 132 | // this the connection forms but no cores flow and the list never syncs). Mirrors CommunityRooms. | |
| 133 | if (swarm) swarm.on('connection', (conn) => { conn.on('error', () => {}); this._store.replicate(conn) }) | |
| 134 | } | |
| 135 | ||
| 136 | registry (roomKeyHex) { | |
| 137 | const key = normKey(roomKeyHex) | |
| 138 | if (this._registries.has(key)) return this._registries.get(key) | |
| 139 | const p = (async () => { | |
| 140 | const fr = await FeedRegistry.openReadonly(this._store, key) | |
| 141 | if (this._swarm) this._swarm.join(fr.discoveryKey, { client: true, server: true }) | |
| 142 | return fr | |
| 143 | })().catch((e) => { this._registries.delete(key); throw e }) | |
| 144 | this._registries.set(key, p) | |
| 145 | return p | |
| 146 | } | |
| 147 | ||
| 148 | async feeds (roomKeyHex) { return (await this.registry(roomKeyHex)).feeds() } | |
| 149 | ||
| 150 | // Watch a registry for changes. onChange(feeds[]) fires (debounced) on every linearized-view advance — i.e. | |
| 151 | // whenever a feed add/remove replicates in — PLUS once immediately as a baseline. This is what a reactor | |
| 152 | // uses to pick up feeds published AFTER it started, with NO restart. Serialized + coalesced; onChange | |
| 153 | // errors are swallowed (a flaky reactor must never break replication). Returns an async unwatch fn. | |
| 154 | async watch (roomKeyHex, onChange, { debounceMs = 300 } = {}) { | |
| 155 | const fr = await this.registry(roomKeyHex) | |
| 156 | const base = fr.base | |
| 157 | let timer = null | |
| 158 | let running = false | |
| 159 | let pending = false | |
| 160 | const run = async () => { | |
| 161 | if (running) { pending = true; return } | |
| 162 | running = true | |
| 163 | try { await onChange(await fr.feeds()) } catch {} | |
| 164 | running = false | |
| 165 | if (pending) { pending = false; schedule() } | |
| 166 | } | |
| 167 | const schedule = () => { clearTimeout(timer); timer = setTimeout(run, debounceMs) } | |
| 168 | base.on('update', schedule) | |
| 169 | schedule() // baseline: emit the current feed set now | |
| 170 | return async () => { clearTimeout(timer); base.off('update', schedule) } | |
| 171 | } | |
| 172 | ||
| 173 | async close () { | |
| 174 | for (const p of this._registries.values()) { try { await (await p).close() } catch {} } | |
| 175 | this._registries.clear() | |
| 176 | } | |
| 177 | } | |
| 178 | ||
| 179 | export default { FeedRegistry, FeedRegistries } |
| … | … | @@ -0,0 +1,64 @@ |
| 1 | // The FEED REGISTRY protocol — the op contract for a watched list of feeds, as a hyper-rooms schema. This | |
| 2 | // is the single source of truth that makes "a feed exists" one durable P2P fact everyone reacts to | |
| 3 | // (favela-web/spec/community-ux.md): the mint writes an entry; favela-web (index), the membrane (front + | |
| 4 | // requestCrawl), the multi-DID hpp (serve co-owned ones), and the blind-relay (seed) all keyless-READ + | |
| 5 | // WATCH it — no per-feed config, no restart. Plaintext by design (feed identities are PUBLIC — a did + rkey | |
| 6 | // + curation contract; never a secret). | |
| 7 | // | |
| 8 | // A feed is the PRIMITIVE (a community is just the roster-curated, co-owned kind): { curator, params } + a | |
| 9 | // HOME repo (`did`) that holds the app.bsky.feed.generator record. For a light/arbitrary feed `did` is the | |
| 10 | // creator's OWN hpp repo (no new DID); for a community it's the co-owned DID. `roomKey` is present only for | |
| 11 | // roster-type feeds (the audience CommunityRoom favela-web reads). | |
| 12 | // | |
| 13 | // Domain ops (writer ops add-writer/remove-writer are handled by hyper-rooms Room itself): | |
| 14 | // add-feed {did,rkey,name,curator,params,roomKey?,handle?} → feed:<did>/<rkey> | |
| 15 | // remove-feed {did,rkey} → del feed:<did>/<rkey> | |
| 16 | // | |
| 17 | // apply is DETERMINISTIC — reads only fields carried in the op (op.at/op.by set at append time), never | |
| 18 | // Date.now()/randomness (Autobase replays apply on every boot + reorg). | |
| 19 | import { jsonSchema } from 'hyper-rooms/lib/json-schema.js' | |
| 20 | ||
| 21 | export const isDid = (d) => typeof d === 'string' && /^did:[a-z]+:/.test(d) | |
| 22 | // rkey syntax = [A-Za-z0-9._~-], 1–512, not "."/".."; keep it simple + safe for the `feed:<did>/<rkey>` key. | |
| 23 | export const isRkey = (r) => typeof r === 'string' && r.length > 0 && r.length <= 512 && /^[A-Za-z0-9._~-]+$/.test(r) && r !== '.' && r !== '..' | |
| 24 | const isPlainObject = (o) => o != null && typeof o === 'object' && !Array.isArray(o) | |
| 25 | ||
| 26 | export const FEED_PREFIX = 'feed:' | |
| 27 | export const feedKey = (did, rkey) => `${FEED_PREFIX}${did}/${rkey}` | |
| 28 | ||
| 29 | export const feedRegistrySchema = jsonSchema({ | |
| 30 | validate (op) { | |
| 31 | if (op.type === 'add-feed') { | |
| 32 | if (!isDid(op.did)) throw new Error('add-feed requires a valid did (the feed record home repo)') | |
| 33 | if (!isRkey(op.rkey)) throw new Error('add-feed requires a valid rkey') | |
| 34 | if (typeof op.curator !== 'string' || !op.curator) throw new Error('add-feed requires a curator name') | |
| 35 | if (op.params != null && !isPlainObject(op.params)) throw new Error('add-feed params must be an object') | |
| 36 | if (op.roomKey != null && !/^[0-9a-f]{64}$/i.test(String(op.roomKey))) throw new Error('add-feed roomKey must be a 32-byte hex key') | |
| 37 | } | |
| 38 | if (op.type === 'remove-feed') { | |
| 39 | if (!isDid(op.did) || !isRkey(op.rkey)) throw new Error('remove-feed requires a valid did + rkey') | |
| 40 | } | |
| 41 | }, | |
| 42 | async apply (batch, op, host) { | |
| 43 | switch (op.type) { | |
| 44 | case 'add-feed': | |
| 45 | await batch.put(feedKey(op.did, op.rkey), { | |
| 46 | did: op.did, | |
| 47 | rkey: op.rkey, | |
| 48 | name: op.name || op.rkey, | |
| 49 | curator: op.curator, | |
| 50 | params: op.params || {}, | |
| 51 | roomKey: op.roomKey ? String(op.roomKey).toLowerCase() : null, | |
| 52 | handle: op.handle || null, | |
| 53 | createdBy: op.by || null, | |
| 54 | at: op.at || null | |
| 55 | }) | |
| 56 | break | |
| 57 | case 'remove-feed': | |
| 58 | await batch.del(feedKey(op.did, op.rkey)) | |
| 59 | break | |
| 60 | } | |
| 61 | } | |
| 62 | }) | |
| 63 | ||
| 64 | export default feedRegistrySchema |
| … | … | @@ -8,7 +8,7 @@ |
| 8 | 8 | "favela-community": "bin/favela-community.js" |
| 9 | 9 | }, |
| 10 | 10 | "scripts": { |
| 11 | "test": "bare test/community-room.test.mjs && bare test/cli.test.mjs && bare test/discovery.test.mjs", | |
| 11 | "test": "bare test/community-room.test.mjs && bare test/feed-registry.test.mjs && bare test/cli.test.mjs && bare test/discovery.test.mjs", | |
| 12 | 12 | "e2e": "bare test/live-swarm-e2e.mjs" |
| 13 | 13 | }, |
| 14 | 14 | "dependencies": { |
| … | … | @@ -0,0 +1,94 @@ |
| 1 | // favela-community — the FEED REGISTRY: the durable, co-ownable, keyless-readable list of feeds that every | |
| 2 | // reactor (favela-web index / membrane front+requestCrawl / hpp serve / blind-relay seed) watches, so "a | |
| 3 | // feed exists" is ONE P2P fact with no per-feed config (favela-web/spec/community-ux.md). Same three-role | |
| 4 | // shape as CommunityRoom: OWNER (create) / MOD (open + admitted) / READER (keyless openReadonly). | |
| 5 | // Bare-native (Autobase/Hyperbee need a disk path). Run: bare test/feed-registry.test.mjs | |
| 6 | import Corestore from 'corestore' | |
| 7 | import { FeedRegistry, FeedRegistries } from '../index.js' | |
| 8 | ||
| 9 | const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) } | |
| 10 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) | |
| 11 | const stamp = Date.now() | |
| 12 | ||
| 13 | // a light feed (creator's OWN hpp repo, no roomKey) + a community feed (co-owned DID + roster roomKey) | |
| 14 | const OWN = 'did:plc:owner222222222222222222222' | |
| 15 | const COMM = 'did:plc:community222222222222222' | |
| 16 | const ROSTER = 'a'.repeat(64) // a 32-byte hex CommunityRoom key | |
| 17 | ||
| 18 | // pipe two bases' replication streams together (in-process stand-in for the swarm) | |
| 19 | function wire (a, b) { | |
| 20 | const s1 = a.base.replicate(true); const s2 = b.base.replicate(false) | |
| 21 | s1.on('error', () => {}); s2.on('error', () => {}) | |
| 22 | s1.pipe(s2).pipe(s1) | |
| 23 | return () => { try { s1.destroy() } catch {} try { s2.destroy() } catch {} } | |
| 24 | } | |
| 25 | async function until (fn, ms = 15000) { const end = Date.now() + ms; while (Date.now() < end) { if (await fn()) return true; await sleep(100) } return false } | |
| 26 | ||
| 27 | const ownerStore = new Corestore('data/fr-owner-' + stamp) | |
| 28 | const readerStore = new Corestore('data/fr-reader-' + stamp) | |
| 29 | const modStore = new Corestore('data/fr-mod-' + stamp) | |
| 30 | ||
| 31 | // ── OWNER: create + publish feeds ── | |
| 32 | const owner = await FeedRegistry.create(ownerStore, { name: 'Favela Feeds' }) | |
| 33 | const REG = owner.key | |
| 34 | assert(/^[0-9a-f]{64}$/.test(REG), 'fresh registry has a 64-hex public bootstrap key') | |
| 35 | assert(owner.writable, 'creator is a writer (genesis indexer)') | |
| 36 | ||
| 37 | // a light feed (arbitrary curator, HOME = creator's own hpp, no roster) + a community feed (roster curator) | |
| 38 | await owner.addFeed({ did: OWN, rkey: 'my-p2p', name: 'p2p tag', curator: 'subject', params: { tag: 'p2p' } }) | |
| 39 | await owner.addFeed({ did: COMM, rkey: 'loser-club', name: 'loser club', curator: 'roster', params: {}, roomKey: ROSTER }) | |
| 40 | await owner.update() | |
| 41 | let of = await owner.feeds() | |
| 42 | assert(of.length === 2, 'owner lists both published feeds') | |
| 43 | const community = of.find((f) => f.rkey === 'loser-club') | |
| 44 | assert(community && community.did === COMM && community.curator === 'roster' && community.roomKey === ROSTER, 'community feed entry carries did + roster curator + roomKey') | |
| 45 | const light = of.find((f) => f.rkey === 'my-p2p') | |
| 46 | assert(light && light.curator === 'subject' && light.params.tag === 'p2p' && light.roomKey === null, 'light feed entry carries subject curator + params, roomKey null') | |
| 47 | const one = await owner.feed(COMM, 'loser-club') | |
| 48 | assert(one && one.name === 'loser club', 'feed(did,rkey) reads a single entry') | |
| 49 | ||
| 50 | // ── validation ── | |
| 51 | const rejects = async (fn, m) => { let t = false; try { await fn() } catch { t = true } assert(t, m) } | |
| 52 | await rejects(() => owner.addFeed({ did: 'not-a-did', rkey: 'x', curator: 'roster' }), 'addFeed rejects a bad did') | |
| 53 | await rejects(() => owner.addFeed({ did: OWN, rkey: '..', curator: 'roster' }), 'addFeed rejects a bad rkey') | |
| 54 | await rejects(() => owner.addFeed({ did: OWN, rkey: 'x', curator: '' }), 'addFeed rejects an empty curator') | |
| 55 | await rejects(() => owner.addFeed({ did: OWN, rkey: 'x', curator: 'roster', params: [1, 2] }), 'addFeed rejects non-object params') | |
| 56 | ||
| 57 | // ── remove-feed ── | |
| 58 | await owner.removeFeed(OWN, 'my-p2p') | |
| 59 | assert(await until(async () => (await owner.feeds()).length === 1), 'removeFeed drops the entry (last-writer-wins del)') | |
| 60 | ||
| 61 | // ── KEYLESS READER (the reactor path): openReadonly, replicates, never writes ── | |
| 62 | const reader = await FeedRegistry.openReadonly(readerStore, REG) | |
| 63 | assert(!reader.writable, 'keyless reader is NOT writable (holds no key)') | |
| 64 | const unwireR = wire(owner, reader) | |
| 65 | assert(await until(async () => (await reader.feeds()).some((f) => f.rkey === 'loser-club')), 'KEYLESS READ: reader replicates the feed list (no key)') | |
| 66 | await rejects(() => reader.addFeed({ did: OWN, rkey: 'evil', curator: 'roster' }), 'keyless reader REFUSES to write (addFeed throws — reactors are keyless)') | |
| 67 | ||
| 68 | // ── MOD: admitted by the owner, co-publishes (a registry can be co-owned) ── | |
| 69 | const mod = await FeedRegistry.open(modStore, REG) | |
| 70 | assert(!mod.writable, 'mod is not writable before admission') | |
| 71 | const unwireM = wire(owner, mod) | |
| 72 | await owner.addMod(mod.localKey, { userId: 'mod-b' }) | |
| 73 | assert(await mod.waitWritable({ timeout: 15000 }), 'mod becomes writable after owner.addMod (admission)') | |
| 74 | await mod.addFeed({ did: COMM, rkey: 'mod-feed', name: 'mod feed', curator: 'roster', params: {}, roomKey: ROSTER }) | |
| 75 | assert(await until(async () => (await owner.feeds()).some((f) => f.rkey === 'mod-feed')), 'WRITE LAYER: owner sees the mod\'s addFeed — co-writer ops linearize through the owner') | |
| 76 | assert(await until(async () => (await reader.feeds()).some((f) => f.rkey === 'mod-feed')), 'the keyless reader also converges on the mod\'s addFeed') | |
| 77 | ||
| 78 | // ── REACTOR WATCHER: a feed published AFTER watch starts reaches the reactor with NO restart (the design goal) ── | |
| 79 | const watchStore = new Corestore('data/fr-watch-' + stamp) | |
| 80 | const regs = new FeedRegistries(watchStore, null) // no swarm — wire replication manually below | |
| 81 | const seen = [] | |
| 82 | const unwatch = await regs.watch(REG, (feeds) => { seen.push(feeds.map((f) => f.rkey).sort()) }, { debounceMs: 50 }) | |
| 83 | const watchReader = await regs.registry(REG) // same replica watch() opened | |
| 84 | const unwireW = wire(owner, watchReader) | |
| 85 | assert(await until(() => seen.length > 0 && seen[seen.length - 1].includes('loser-club')), 'watch BASELINE: reactor sees the current feed set after replication') | |
| 86 | await owner.addFeed({ did: COMM, rkey: 'fresh', name: 'fresh feed', curator: 'roster', params: {}, roomKey: ROSTER }) | |
| 87 | assert(await until(() => seen[seen.length - 1].includes('fresh')), 'watch DELTA: a feed published AFTER watch started reaches the reactor with NO re-open') | |
| 88 | await unwatch(); unwireW() | |
| 89 | ||
| 90 | console.log('\nFAVELA-COMMUNITY FEED-REGISTRY OK (Bare-native): owner publish + validation + remove + keyless read + mod admission (co-owned) + reactor watcher (baseline + delta)') | |
| 91 | unwireR(); unwireM() | |
| 92 | await owner.close(); await reader.close(); await mod.close() | |
| 93 | try { await regs.close() } catch {} | |
| 94 | try { await ownerStore.close(); await readerStore.close(); await modStore.close(); await watchStore.close() } catch {} |