Changeset b79466e in Klonkt


Ignore:
Timestamp:
06/26/2026 10:04:14 AM (2 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
a404018
Parents:
14e2ce1
Message:

feat(fediverse): self-heal the timeline cache once per SELFHEAL_VERSION bump

selfHealTimeline() re-fetches cached ap_timeline notes and refreshes content +
media (recovers covers/edits delivered during a flux window, e.g. a fleet-wide
update) and drops gone (404/410) notes. Gated by a selfheal_version setting so it
runs ONCE after a SELFHEAL_VERSION bump, not on every boot. Async + bounded
(latest 200) like autoMigrateCircles. Closes prutfolio-src-5gl.

Co-Authored-By: Claude <noreply@…>

Location:
src
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • src/server.js

    r14e2ce1 rb79466e  
    5858import ogRoutes from './routes/og.js';
    5959import apRoutes from './routes/activitypub.js';
    60 import { apWants, startDeliveryWorker, autoMigrateCircles } from './services/ActivityPubService.js';
     60import { apWants, startDeliveryWorker, autoMigrateCircles, selfHealTimeline } from './services/ActivityPubService.js';
    6161
    6262// SESSION_SECRET: use the env var if set. Otherwise auto-generate a strong one
     
    162162startDeliveryWorker(); // retry failed fediverse deliveries with backoff
    163163autoMigrateCircles(); // one-time: convert legacy circle_links -> AP auto-boost follows
     164selfHealTimeline(); // once per SELFHEAL_VERSION bump: re-sync the fediverse cache (covers/edits) after a drastic update
    164165
    165166// Safety net: guarantee that there is always a primary site (solo/hub/circle).
  • src/services/ActivityPubService.js

    r14e2ce1 rb79466e  
    11051105}
    11061106
     1107// ── Self-heal: re-sync the fediverse cache (ap_timeline) after a DRASTIC update ──
     1108// Runs ONCE per SELFHEAL_VERSION bump — NOT on every boot. Re-fetches each cached
     1109// note and refreshes content + media (recovers covers/edits that were delivered
     1110// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
     1111// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
     1112const SELFHEAL_VERSION = 1;
     1113async function fetchNoteAP(url) {
     1114  try {
     1115    const r = await fetch(url, { headers: { Accept: 'application/activity+json' } });
     1116    if (r.status === 404 || r.status === 410) return 404;
     1117    if (r.ok) return await r.json();
     1118  } catch { /* unreachable */ }
     1119  return null;
     1120}
     1121function mediaFromNote(note) {
     1122  const atts = (Array.isArray(note.attachment) ? note.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
     1123  if (!atts.some((m) => !m.type || /image/i.test(m.type)) && note.image) {
     1124    const im = Array.isArray(note.image) ? note.image[0] : note.image;
     1125    const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
     1126    if (iu) atts.push({ url: iu, type: (im && im.mediaType) || 'image/jpeg' });
     1127  }
     1128  return JSON.stringify(atts);
     1129}
     1130let _selfHealing = false;
     1131export async function selfHealTimeline() {
     1132  if (_selfHealing) return; _selfHealing = true;
     1133  try {
     1134    let cur = 0;
     1135    try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('selfheal_version'); cur = r ? (parseInt(r.value, 10) || 0) : 0; } catch { return; }
     1136    if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
     1137    let rows = [];
     1138    try { rows = db.prepare('SELECT id, content, media_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
     1139    let healed = 0;
     1140    for (const r of rows) {
     1141      try {
     1142        const note = await fetchNoteAP(r.id);
     1143        if (note === 404) { db.prepare('DELETE FROM ap_timeline WHERE id = ?').run(r.id); healed++; continue; }
     1144        if (!note || typeof note !== 'object') continue;
     1145        const html = HtmlSanitizerService.sanitize(note.content || '');
     1146        const media = mediaFromNote(note);
     1147        if ((html && html !== r.content) || media !== (r.media_json || '[]')) {
     1148          db.prepare('UPDATE ap_timeline SET content = ?, media_json = ? WHERE id = ?').run(html || r.content, media, r.id);
     1149          healed++;
     1150        }
     1151      } catch { /* per-note best-effort */ }
     1152    }
     1153    try { db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('selfheal_version', String(SELFHEAL_VERSION)); } catch { /* ignore */ }
     1154    if (rows.length) console.log(`[AP] self-heal v${SELFHEAL_VERSION}: ${healed}/${rows.length} timeline notes`);
     1155  } catch { /* never block boot */ } finally { _selfHealing = false; }
     1156}
     1157
    11071158// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
    11081159export async function followActor(site, handle, autoBoost = false) {
     
    12631314  listOutbox, deliverOutboxDelete,
    12641315  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, getTimeline, sendInteraction,
    1265   autoBoostCount, getCirkelPosts, getCirkelMembers, autoMigrateCircles,
     1316  autoBoostCount, getCirkelPosts, getCirkelMembers, autoMigrateCircles, selfHealTimeline,
    12661317  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
    12671318  deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
Note: See TracChangeset for help on using the changeset viewer.