OverviewFilesCommitsIssuesDocsReleasesPeers

favela-bridge commits / a647eb3

0.0.14: retract a deleted favela post or reply from bsky
Each pass reads the peer's tombstones (peer.ownDeleted) and deletes app.bsky.feed.post/<tidFor(name)> for each one the repo still holds, through hpp's apply-write delete (BskySink.deleteRecord). Positive evidence only — absence never deletes; needs a readable repo and send; not gated on consent, since deleting publishes nothing. A peer without ownDeleted retracts nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
a647eb322fa242174b7d68753efdd6d940e96fd1
by plankatron <fleeky@gmail.com> · 2026-10-15T00:50:55.000Z

Changed files (4) +108 -3

lib/bsky.js +17 -0
……@@ -143,6 +143,23 @@ export class BskySink {
143143 }
144144 }
145145
146 /** Delete one record — the retraction of a favela post that was deleted. `{ sent, error? }`, never rejects. */
147 async deleteRecord (collection, rkey) {
148 if (!this.configured) return { sent: false, error: 'no bsky sink configured' }
149 let client = null
150 try {
151 client = await this._open()
152 await client.call('apply-write', { action: 'delete', collection, rkey })
153 return { sent: true }
154 } catch (err) {
155 const msg = (err && err.message) || String(err)
156 this._log('record-delete-failed', { collection, rkey, err: msg })
157 return { sent: false, error: msg }
158 } finally {
159 if (client && client.close) { try { await client.close() } catch {} }
160 }
161 }
162
146163 /** The `app.bsky.actor.profile` currently in the repo, or null. Never throws. */
147164 async getProfile () {
148165 if (!this.configured) return null
lib/project.js +26 -1
……@@ -263,6 +263,31 @@ export async function projectCycle ({
263263 else log(' fediverse: withheld — ' + plan.fediverse.losses.join('; '))
264264 }
265265
266 // ── deletes ──────────────────────────────────────────────────────────────────────────────────
267 //
268 // ⛔ ON POSITIVE EVIDENCE ONLY. A name absent from ownPosts() may be deleted or may simply not have
269 // replicated, so absence never deletes anything; a TOMBSTONE the peer reports can only mean deleted.
270 // Posts and replies both live at app.bsky.feed.post/<tidFor(name)>.
271 //
272 // ⚠ NOT gated on consent. Deleting publishes nothing, and a person who switched bsky off and then
273 // deleted a post still wants it gone from there. It does need a readable repo (`found` — a send with
274 // an unreadable one was refused above) and SEND. A peer adapter without `ownDeleted` (the desktop
275 // CLI's socket client) retracts nothing, as before.
276 let deleted = 0
277 if (typeof peer.ownDeleted === 'function' && found !== null) {
278 let gone = null
279 try { gone = await peer.ownDeleted() } catch (err) { log(' deletes: could not read — ' + ((err && err.message) || err)) }
280 const names = gone ? [...(gone.post || []), ...(gone.reply || [])] : []
281 for (const name of names) {
282 const rkey = tidFor(name)
283 if (!have.has(rkey)) continue
284 if (!send) { log(' ' + name + ' bsky: would DELETE ' + rkey); continue }
285 const d = await bsky.deleteRecord(POST_COLLECTION, rkey)
286 if (d.sent) { deleted++; log(' ' + name + ' bsky: DELETED ' + rkey) }
287 else log(' ' + name + ' bsky: delete FAILED — ' + d.error)
288 }
289 }
290
266291 // ── edges ────────────────────────────────────────────────────────────────────────────────────
267292 //
268293 // ⛔ AFTER THE POSTS, ALWAYS. An edge names a subject, and the subject resolves only once the post
……@@ -319,7 +344,7 @@ export async function projectCycle ({
319344 ' · already there ' + skipped + ' · withheld ' + withheld +
320345 (held ? ' · held by limit ' + held : ''))
321346
322 return { refused: null, feed, rows: rows.length, sent, skipped, withheld, held, profile, reach, edges: edgeStats }
347 return { refused: null, feed, rows: rows.length, sent, skipped, withheld, held, deleted, profile, reach, edges: edgeStats }
323348}
324349
325350export default { projectCycle, reconcileProfile, buildEmbed }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-bridge",
3 "version": "0.0.13",
3 "version": "0.0.14",
44 "description": "Projects favela records onto the networks a room may reach. One reader, one reach decision, one projection per network.",
55 "type": "module",
66 "main": "index.js",
test/project.test.js +64 -1
……@@ -15,8 +15,10 @@ import { tidFor } from '../lib/tid.js'
1515const at = '2026-09-07T23:12:04.600Z'
1616const row = (name, body) => ({ name, body, createdAt: at })
1717
18function fakePeer ({ networks = ['bsky'], rows = [], profile = null, unknown = false } = {}) {
18function fakePeer ({ networks = ['bsky'], rows = [], profile = null, unknown = false, deleted = null } = {}) {
1919 return {
20 // Only when given: a peer adapter without it (the desktop CLI's socket client) must still work.
21 ...(deleted ? { ownDeleted: async () => deleted } : {}),
2022 reach: async () => (unknown ? { networks: [], credential: null, unknown: true } : { networks, credential: null }),
2123 ownPosts: async () => ({ feed: 'f'.repeat(64), rows }),
2224 call: async (cmd) => {
……@@ -214,3 +216,64 @@ test('⛔ no claim without bsky consent, on a dry run, or when the repo cannot b
214216 await projectCycle({ peer: fakePeer(), bsky: blind.sink, send: true })
215217 t.absent(blind.written.some((w) => w.collection === FEED_CLAIM), 'could not read is not absent')
216218})
219
220// ── deletes ──────────────────────────────────────────────────────────────────────────────────────
221//
222// ⛔ A deleted favela post is retracted from bsky ONLY on positive evidence — a tombstone the peer
223// reports — and only where the repo still holds it. A name merely missing from ownPosts() is never
224// grounds to delete anything: "not replicated yet" looks identical.
225
226const deletes = (written) => written.filter((w) => w.action === 'delete')
227
228test('★★★ a deleted post that bsky holds is deleted there, at its TID rkey', async (t) => {
229 const name = '1788822724600'
230 const { sink, written } = fakeSink({ has: [tidFor(name)] })
231 const r = await projectCycle({ peer: fakePeer({ deleted: { post: [name], reply: [] } }), bsky: sink, send: true })
232 t.alike(deletes(written), [{ action: 'delete', collection: 'app.bsky.feed.post', rkey: tidFor(name) }], 'exactly that record')
233 t.is(r.deleted, 1)
234})
235
236test('★★ a deleted reply is retracted the same way — replies are app.bsky.feed.post too', async (t) => {
237 const name = '1788822724601'
238 const { sink, written } = fakeSink({ has: [tidFor(name)] })
239 await projectCycle({ peer: fakePeer({ deleted: { post: [], reply: [name] } }), bsky: sink, send: true })
240 t.is(deletes(written).length, 1)
241 t.is(deletes(written)[0].rkey, tidFor(name))
242})
243
244test('★★ a deleted post bsky never had is not touched', async (t) => {
245 const { sink, written } = fakeSink({ has: [] })
246 const r = await projectCycle({ peer: fakePeer({ deleted: { post: ['1788822724600'], reply: [] } }), bsky: sink, send: true })
247 t.is(deletes(written).length, 0, 'nothing to retract')
248 t.is(r.deleted, 0)
249})
250
251test('★★ a dry run deletes nothing', async (t) => {
252 const name = '1788822724600'
253 const { sink, written } = fakeSink({ has: [tidFor(name)] })
254 const said = []
255 await projectCycle({ peer: fakePeer({ deleted: { post: [name], reply: [] } }), bsky: sink, send: false, log: (l) => said.push(l) })
256 t.is(deletes(written).length, 0)
257 t.ok(said.some((l) => /would DELETE/.test(l)), 'but says it would')
258})
259
260test('★★ retraction is not gated on consent — switching bsky off must not strand a deleted post there', async (t) => {
261 const name = '1788822724600'
262 const { sink, written } = fakeSink({ has: [tidFor(name)] })
263 await projectCycle({ peer: fakePeer({ networks: [], deleted: { post: [name], reply: [] } }), bsky: sink, send: true })
264 t.is(deletes(written).length, 1, 'deleting publishes nothing, so it does not need consent to publish')
265})
266
267test('★★★ an unreadable repo deletes nothing either', async (t) => {
268 const { sink, written } = fakeSink({ haveNull: true })
269 await projectCycle({ peer: fakePeer({ deleted: { post: ['1788822724600'], reply: [] } }), bsky: sink, send: true })
270 t.is(deletes(written).length, 0)
271})
272
273test('a peer with no ownDeleted (the desktop socket client) projects as before', async (t) => {
274 const { sink, written } = fakeSink({ has: [] })
275 const r = await projectCycle({ peer: fakePeer({ rows: [row('1788822724600', 'hello')] }), bsky: sink, send: true })
276 t.is(r.sent, 1)
277 t.is(deletes(written).length, 0)
278 t.is(r.deleted, 0)
279})