// reproject — the operator verb that re-delivers improved projections.
//
// WHY IT EXISTS. _drain only runs on onHead(), so a projection improvement reaches already-delivered
// posts only when some UNRELATED future post advances the head. #26 recorded that trap twice:
// "event-driven quietly meant never happens". Measured 2026-08-05: 5 posts hold servable image bytes
// while showing zero media on prsm.space, because they were delivered before attachments projected.
import test from 'brittle'
import { Publisher } from '../lib/publisher.js'
import { MemoryState } from '../lib/state.js'
const note = (rkey, over = {}) => ({
id: `https://ap.example/ap/objects/${rkey}`,
type: 'Note', content: `<p>${rkey}</p>`, published: '2026-08-01T00:00:00Z',
to: ['https://www.w3.org/ns/activitystreams#Public'], cc: [], ...over
})
function mk ({ notes, delivered = [], blobResolves = null }) {
const sent = []
const state = new MemoryState()
for (const [k, fp] of delivered) state._delivered?.set?.(k, fp)
const p = new Publisher({
records: { listNotes: async () => ({ notes }) },
followers: { list: async () => ['https://remote.example/inbox'] },
deliver: async ({ activity }) => { sent.push({ verb: activity.type, id: activity.object.id, updated: activity.object.updated, object: activity.object }) },
actorId: 'https://ap.example/ap/actor',
newId: () => 'act1',
state,
blobResolves
})
return { p, sent, state }
}
test('reproject sends Update for a post whose projection CHANGED', async (t) => {
const n = note('r1', { attachment: [{ type: 'Document', url: 'https://ap.example/ap/blobs/c1' }] })
const { p, sent, state } = mk({ notes: [n] })
await state.markDelivered([['r1', 'an-old-fingerprint']])
const res = await p.reproject()
t.is(res.sent, 1, 'one post re-sent')
t.is(sent[0].verb, 'Update', 'as an Update — Mastodon\'s edit mechanism, the only refetch trigger')
})
test('reproject SKIPS a post whose projection is unchanged', async (t) => {
const n = note('r1')
const { p, sent, state } = mk({ notes: [n] })
await p.reproject() // first pass delivers + records the fingerprint
sent.length = 0
const res = await p.reproject()
t.is(res.sent, 0, 'nothing re-sent')
t.is(sent.length, 0, 'and no follower was bothered')
})
test('force re-sends even when unchanged', async (t) => {
const { p, sent } = mk({ notes: [note('r1')] })
await p.reproject()
sent.length = 0
const res = await p.reproject({ force: true })
t.is(res.sent, 1, 'force ignores the fingerprint')
})
test('rkey restricts to one post', async (t) => {
const { p, sent } = mk({ notes: [note('r1'), note('r2'), note('r3')] })
const res = await p.reproject({ rkey: 'r2' })
t.is(res.sent, 1)
t.ok(sent[0].id.endsWith('/r2'), 'only the named post went out')
})
// ⛔ THE GUARD THAT MATTERS. One post's blob bytes were lost before the overlay migration, so its
// projection points at a URL that cannot resolve. Re-sending teaches the receiver the same absence
// again AND burns the one edit that would have fixed it if the bytes were ever recovered.
test('a post whose media does NOT resolve is skipped, not re-sent', async (t) => {
const good = note('ok', { attachment: [{ type: 'Document', url: 'https://ap.example/ap/blobs/good' }] })
const bad = note('gone', { attachment: [{ type: 'Document', url: 'https://ap.example/ap/blobs/gone' }] })
const { p, sent } = mk({
notes: [good, bad],
blobResolves: async (u) => !u.endsWith('/gone')
})
const res = await p.reproject({ force: true })
t.is(res.sent, 1, 'only the servable one was sent')
t.is(res.skipped.length, 1)
t.is(res.skipped[0].rkey, 'gone')
t.ok(sent.every((s) => !s.id.endsWith('/gone')), 'the broken post never left')
})
test('verify:false sends it anyway — an explicit operator override', async (t) => {
const bad = note('gone', { attachment: [{ type: 'Document', url: 'https://ap.example/ap/blobs/gone' }] })
const { p } = mk({ notes: [bad], blobResolves: async () => false })
const res = await p.reproject({ force: true, verify: false })
t.is(res.sent, 1, 'the guard is on by default but not mandatory')
})
test('a post with NO attachments is never blocked by the media guard', async (t) => {
const { p } = mk({ notes: [note('plain')], blobResolves: async () => false })
const res = await p.reproject({ force: true })
t.is(res.sent, 1, 'text-only posts do not consult the probe at all')
})
test('an undelivered post reprojects as Create, not Update', async (t) => {
const { p, sent } = mk({ notes: [note('new')] })
await p.reproject()
t.is(sent[0].verb, 'Create', 'never claim an edit for something never sent')
})
// ⛔ THE SILENT-FAILURE GUARD. Mastodon accepts an Update without `updated`, returns 2xx, and does
// NOTHING — no edit, no attachment re-fetch, edited_at stays null. Verified live 2026-08-05: a forced
// Update was delivered and accepted, and the status still showed 0 media. Required since Mastodon
// 3.5.0. Without this the whole reproject feature appears to work and changes nothing.
test('an Update carries an `updated` timestamp, or Mastodon silently ignores it', async (t) => {
const { p, sent, state } = mk({ notes: [note('r1')] })
await state.markDelivered([['r1', 'stale-fp']])
await p.reproject()
t.is(sent[0].verb, 'Update')
t.ok(sent[0].updated, 'the object carries `updated`')
t.ok(!Number.isNaN(Date.parse(sent[0].updated)), 'and it parses as a timestamp')
})
test('a Create does NOT carry `updated` — it is not an edit', async (t) => {
const { p, sent } = mk({ notes: [note('new')] })
await p.reproject()
t.is(sent[0].verb, 'Create')
t.absent(sent[0].updated, 'a first delivery must not claim to be an edit')
})
test('`updated` is NOT part of the projection — else every post looks permanently edited', async (t) => {
const n = note('r1')
const { p, sent, state } = mk({ notes: [n] })
await state.markDelivered([['r1', 'stale-fp']])
await p.reproject()
t.absent(n.updated, 'the source note is not mutated')
// Second reproject with no change: the fingerprint must have settled, so nothing re-sends.
sent.length = 0
await p.reproject()
t.is(sent.length, 0, 'a stamped Update does not make the post permanently dirty')
})