OverviewFilesCommitsIssuesDocsReleasesPeers

favela-bridge commits / 4323df5

0.0.11: the post ceiling was the FORMER one, and --reproject revisits what improved
⛔ I WROTE MAX_IMAGE_BYTES = 1000000 FROM MEMORY IN 0.0.10. The lexicon says: "blob": { "accept": ["image/*"], "maxSize": 2000000 } "The raw image file. May be up to 2 MB, formerly limited to 1 MB." So I had shipped the superseded limit, and it would have withheld every picture between 1 and 2 MB — including the 1,044,866-byte screenshot that prompted this work, which fits with room to spare. favela-web's resize-plan.js carried the correct number in a comment the entire time. Read the lexicon; do not remember it. And profile.js had NO size gate at all. app.bsky.actor.profile caps avatar and banner at 1,000,000 — HALF the post ceiling, and different in kind: a post image accepts image/*, a profile picture accepts png and jpeg only. Without the gate an oversized avatar was sent and refused by the PDS, which reads from here as a picture that never appears. --reproject is the reconciliation counterpart to the event path, the verb ap-bridge already has. A post is skipped once its rkey is in the repo — right for delivery, wrong the moment the PROJECTION improves, because every post made before images worked keeps its text-only version for ever and no event will revisit it. It re-derives each post and updates only those that actually CHANGED, compared against the repo through hpp's get-record rather than a local note of what we last sent. Not a --force. 29/29. Dry run on tabertron names exactly one post to reproject, the only one carrying an attachment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4323df5db3e90f7e22255a1ff69664939d6c54c8
by plankatron <fleeky@gmail.com> · 2026-09-09T23:21:19.000Z

Changed files (6) +113 -7

index.js +30 -4
……@@ -21,10 +21,10 @@
2121import process from 'bare-process'
2222import os from 'bare-os'
2323import { join } from 'bare-path'
24import { planRecord, selectImages } from './lib/plan.js'
24import { planRecord, selectImages, samePost } from './lib/plan.js'
2525import { planProfile, sameProfile } from './lib/profile.js'
2626import { FavelaPeer } from './lib/peer.js'
27import { BskySink } from './lib/bsky.js'
27import { BskySink, POST_COLLECTION } from './lib/bsky.js'
2828import { tidFor } from './lib/tid.js'
2929
3030const argv = process.argv.slice(2)
……@@ -42,6 +42,12 @@ const LIMIT = (() => {
4242// ⛔ --watch implies --send. A watch that only planned would be a process that costs power and
4343// changes nothing, and the flag would read as if it were working.
4444const WATCH = argv.includes('--watch')
45// ⛔ RECONCILIATION, the counterpart to the event path — the same verb ap-bridge has. A post is
46// skipped once its rkey is in the repo, which is right for delivery and wrong the moment the
47// PROJECTION improves: every post made before images worked keeps its text-only version for ever,
48// and no event will ever revisit it. This re-derives each one and updates the ones that CHANGED.
49// Not a --force: it compares against the repo and writes only a real difference.
50const REPROJECT = argv.includes('--reproject')
4551const EVERY = Math.max(5, Number(process.env.FAVELA_BRIDGE_INTERVAL || 15)) * 1000
4652const PEER_SOCK = process.env.FAVELA_PEER_SOCKET || join(os.homedir(), '.config', 'favela-peer', 'favela.sock')
4753const BSKY_SOCK = process.env.FAVELA_BSKY_SOCKET ||
……@@ -122,8 +128,28 @@ async function cycle (send) {
122128 withheld++
123129 console.log(line + ' bsky: withheld — ' + plan.bsky.losses.join('; '))
124130 } else if (have.has(tidFor(row.name))) {
125 skipped++
126 console.log(line + ' bsky: already there')
131 if (!REPROJECT) {
132 skipped++
133 console.log(line + ' bsky: already there')
134 } else {
135 const { embed, losses: imgLosses } = await buildEmbed(peer, bsky, plan.bsky.images)
136 for (const l of imgLosses) console.log(line + ' bsky: ' + l)
137 const desired = embed ? { ...plan.bsky.record, embed } : plan.bsky.record
138 const rkey = tidFor(row.name)
139 const actual = await bsky.getRecord(POST_COLLECTION, rkey)
140 if (actual && actual.unknown) {
141 console.log(line + ' bsky: could not read what is there — not touching it')
142 } else if (samePost(desired, actual)) {
143 skipped++
144 console.log(line + ' bsky: already matches')
145 } else if (!send) {
146 console.log(line + ' bsky: would REPROJECT ' + rkey + (embed ? ' (+' + embed.images.length + ' image)' : ''))
147 } else {
148 const r = await bsky.putRecord(POST_COLLECTION, rkey, desired)
149 if (r.sent) { sent++; console.log(line + ' bsky: REPROJECTED ' + rkey + (embed ? ' (+' + embed.images.length + ' image)' : '')) }
150 else console.log(line + ' bsky: reproject FAILED — ' + r.error)
151 }
152 }
127153 } else if (send && sent >= LIMIT) {
128154 // Reported per record rather than silently truncated: a run that stopped early and one that
129155 // had nothing left to do print the same summary otherwise.
lib/bsky.js +38 -0
……@@ -14,6 +14,7 @@ import { tidFor } from './tid.js'
1414
1515export const PROFILE_COLLECTION = 'app.bsky.actor.profile'
1616export const PROFILE_RKEY = 'self'
17export const POST_COLLECTION = 'app.bsky.feed.post'
1718
1819export class BskySink {
1920 constructor (sock, opts = {}) {
……@@ -88,6 +89,43 @@ export class BskySink {
8889 // a profile nobody changed. Reconciling against the record actually in the repo — rather than a
8990 // local note of what we last sent — is also the only version that cannot drift from it.
9091
92 /**
93 * One record currently in the repo, or null. `{ unknown: true }` when we could not ask.
94 *
95 * ⛔ "COULD NOT ASK" IS NOT "ABSENT" and the caller must not treat them alike — writing because a
96 * read failed republishes on every transient error, and each write is a repo version.
97 */
98 async getRecord (collection, rkey) {
99 if (!this.configured) return null
100 let client = null
101 try {
102 client = await this._open()
103 return await client.call('get-record', { collection, rkey })
104 } catch (err) {
105 this._log('record-read-failed', { collection, rkey, err: (err && err.message) || String(err) })
106 return { unknown: true }
107 } finally {
108 if (client && client.close) { try { await client.close() } catch {} }
109 }
110 }
111
112 /** Replace an existing record — reprojection, not a new post. */
113 async putRecord (collection, rkey, record) {
114 if (!this.configured) return { sent: false, error: 'no bsky sink configured' }
115 let client = null
116 try {
117 client = await this._open()
118 await client.call('apply-write', { action: 'put', collection, rkey, record })
119 return { sent: true }
120 } catch (err) {
121 const msg = (err && err.message) || String(err)
122 this._log('record-write-failed', { collection, rkey, err: msg })
123 return { sent: false, error: msg }
124 } finally {
125 if (client && client.close) { try { await client.close() } catch {} }
126 }
127 }
128
91129 /** The `app.bsky.actor.profile` currently in the repo, or null. Never throws. */
92130 async getProfile () {
93131 if (!this.configured) return null
lib/plan.js +25 -2
……@@ -32,9 +32,16 @@ const MAX_BYTES = 3000
3232
3333const BLOB_RE = /!\[[^\]]*\]\(favela:\/\/blob\//
3434
35// app.bsky.embed.images: at most 4, and each blob at most 1,000,000 bytes.
35// app.bsky.embed.images, quoted from the lexicon rather than recalled:
36// "blob": { "accept": ["image/*"], "maxSize": 2000000 }
37// "The raw image file. May be up to 2 MB, formerly limited to 1 MB."
38//
39// ⛔ I FIRST WROTE 1,000,000 HERE FROM MEMORY, which is the FORMER limit. It would have withheld
40// every picture between 1 and 2 MB — including the one that prompted this work, a 1,044,866-byte
41// screenshot that fits with room to spare. favela-web's resize-plan.js had the right number in a
42// comment the whole time. Read the lexicon, do not remember it.
3643export const MAX_IMAGES = 4
37export const MAX_IMAGE_BYTES = 1000000
44export const MAX_IMAGE_BYTES = 2000000
3845
3946/**
4047 * Which described images may actually be sent. Pure.
……@@ -45,6 +52,22 @@ export const MAX_IMAGE_BYTES = 1000000
4552 * nothing to say it had been altered — the same argument that makes plan.js withhold a long post
4653 * rather than cut it. The record stays whole on favela and the loss is named.
4754 */
55/**
56 * Is the post we would write already the one that is there?
57 *
58 * ⛔ Compared against the REPO, like the profile — never a local note of what we last sent. Blob refs
59 * compare by CID: the bytes are content-addressed, so an unchanged picture yields an identical ref.
60 */
61export function samePost (desired, actual) {
62 if (!desired || !actual) return false
63 const cids = (r) => ((r.embed && r.embed.images) || []).map((i) => {
64 const b = i && i.image
65 return (b && b.ref && b.ref.$link) || (b && b.cid) || null
66 })
67 const norm = (r) => JSON.stringify([r.text || '', r.createdAt || '', cids(r), ((r.embed && r.embed.images) || []).map((i) => i.alt || '')])
68 return norm(desired) === norm(actual)
69}
70
4871export function selectImages (described) {
4972 const images = []
5073 const losses = []
lib/profile.js +10 -0
……@@ -27,6 +27,12 @@ export const MAX_DESCRIPTION_GRAPHEMES = 256
2727// destination is a decision about their content, not a projection of it.
2828export const BSKY_IMAGE_TYPES = ['image/png', 'image/jpeg']
2929
30// ⛔ AND A SIZE GATE, which was missing entirely. app.bsky.actor.profile caps avatar and banner at
31// 1,000,000 bytes — HALF the post-embed ceiling, and different in kind: a post image accepts
32// image/*, a profile picture accepts png and jpeg only. Without this an oversized avatar was sent
33// and refused by the PDS, which reads from here as a picture that simply never appears.
34export const MAX_PROFILE_IMAGE_BYTES = 1000000
35
3036const graphemesOf = (s) => [...String(s || '')].length
3137
3238// TOTAL, like everything else here: a bio that will not parse degrades to its own text rather than
……@@ -81,6 +87,10 @@ export function planProfile ({ name = null, bio = null, avatar = null, banner =
8187 losses.push(field + ' omitted — bsky takes ' + BSKY_IMAGE_TYPES.join(' or ') + ', this is ' + (img.mimeType || 'untyped'))
8288 continue
8389 }
90 if (typeof img.size === 'number' && img.size > MAX_PROFILE_IMAGE_BYTES) {
91 losses.push(field + ' omitted — ' + img.size + ' bytes against a profile limit of ' + MAX_PROFILE_IMAGE_BYTES)
92 continue
93 }
8494 images[field] = img
8595 }
8696
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-bridge",
3 "version": "0.0.10",
3 "version": "0.0.11",
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/profile.test.js +9 -0
……@@ -72,3 +72,12 @@ test('the description is stripped, not handed over raw', (t) => {
7272 t.absent(p.record.description.includes('**'), '⛔ or the asterisks show on the live profile')
7373 t.ok(p.record.description.includes('dev'), 'the words survive')
7474})
75
76// ⛔ A profile picture caps at 1,000,000 — HALF the post-embed ceiling, and there was no gate at all.
77test('an oversized profile picture is omitted rather than sent to be refused', (t) => {
78 const p = planProfile({ name: 'ash', avatar: { oid: 'a', mimeType: 'image/png', size: 1000001 } })
79 t.absent(p.images.avatar, 'not offered for upload')
80 t.ok(p.losses.some((l) => l.includes('profile limit')), 'and named')
81 const ok = planProfile({ name: 'ash', avatar: { oid: 'a', mimeType: 'image/png', size: 999999 } })
82 t.ok(ok.images.avatar, 'and one that fits still goes')
83})