Changeset 911d5cd in Klonkt


Ignore:
Timestamp:
08/24/2026 04:20:30 PM (2 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
4d64a4b
Parents:
b05eb97
Message:

Opsplitsing stap 6 (shaer-drc): het reactiecluster naar ap-reactions.js

De waarheid over 'heb ik hierop gereageerd' (ap_my_reactions), de
afgeleide vlaggen die setReaction bijhoudt, de eenmalige migratie en de
boost-upsert verhuizen als twee blokken -- 286 regels, byte-voor-byte.
setMyReaction en getMyReactions gaan mee: dat paar is de tabel zelf.

De koppelingen, allebei bewust zo: tlStmts komt statisch uit ap-timeline
(reacties schrijven de tijdlijnvlaggen, die pijl wijst een kant op), en
de omgekeerde pijl blijft de wireTimeline-injectie in de dienst -- twee
zustermodules die elkaar importeren zou precies de kring zijn die
shaer-drc vermijdt. Alleen movedLock (FEP-7628) gaat via wireReactions.

Uitvoeroppervlak voor en na identiek gemeten (199 named exports, 180
sleutels op het default-object). Volle suite 1226 groen.
ActivityPubService staat nu op 5540 regels.

Location:
src/services
Files:
1 added
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    rb05eb97 r911d5cd  
    7070  extractQuoteUrl, extractLinkJson, quoteHrefOf, timelineQuote,
    7171};
     72// Stap 6 (shaer-drc): het reactiecluster woont in ap-reactions.js. Dat
     73// importeert tlStmts zelf statisch uit ap-timeline; alleen movedLock gaat er
     74// onderaan via wireReactions in.
     75import {
     76  wireReactions,
     77  setMyReaction, getMyReactions,
     78  markBoosted, unmarkBoosted, markLiked, unmarkLiked,
     79  migrateReactions, canonicalReactionUri, getReaction, getReactionsFor,
     80  setReaction, getTimelineReaction, upsertBoostedNote, boostedCount,
     81} from './ap-reactions.js';
     82export {
     83  setMyReaction, getMyReactions,
     84  markBoosted, unmarkBoosted, markLiked, unmarkLiked,
     85  migrateReactions, canonicalReactionUri, getReaction, getReactionsFor,
     86  setReaction, getTimelineReaction, upsertBoostedNote, boostedCount,
     87};
    7288// Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken.
    7389export { AP_CONTEXT, actorId, noteId, guessMediaType };
     
    14611477export function setInteractionLiked(id, on) {
    14621478  db.prepare('UPDATE ap_interactions SET acted_like = ? WHERE id = ?').run(on ? 1 : 0, id);
    1463 }
    1464 // Your like/boost state on a REMOTE post (interact page toggles).
    1465 export function setMyReaction(slug, uri, kind, on) {
    1466   if (on) db.prepare('INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind) VALUES (?,?,?)').run(slug, uri, kind);
    1467   else db.prepare('DELETE FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ? AND kind = ?').run(slug, uri, kind);
    1468 }
    1469 export function getMyReactions(slug, uri) {
    1470   const rows = (slug && uri) ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, uri) : [];
    1471   return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
    14721479}
    14731480
     
    38283835  try { if (!_cirkelMembers) _cirkelMembers = db.prepare('SELECT name, url, icon FROM ap_following WHERE slug = ? AND auto_boost = 1 ORDER BY name'); return _cirkelMembers.all(slug); } catch { return []; }
    38293836}
    3830 // AFGELEIDE, GEEN BRON (shaer-9e9). De waarheid over "heb ik hierop gereageerd"
    3831 // staat in ap_my_reactions; deze vlaggen worden daaruit bijgehouden door
    3832 // setReaction en door niets anders. Roep ze niet los aan -- dan schrijf je de
    3833 // helft, en dat is precies hoe shaer:liked maandenlang false bleef (04aca12).
    3834 //
    3835 // ap_timeline.boosted verdient zijn bestaan wel: hij staat in de WHERE van de
    3836 // Cirkel-feed (getCirkelPosts) en in boostedCount, dus hij is een index en geen
    3837 // kopie. ap_timeline.liked wordt nergens als verzameling bevraagd en kan weg
    3838 // zodra fase 2 lang genoeg goed staat; hij is nu nog het vangnet waarmee
    3839 // terugdraaien een code-revert blijft in plaats van dataherstel.
    3840 let _markBoost, _unmarkBoost, _boostedCount;
    3841 export function markBoosted(slug, noteId) {
    3842   try { if (!_markBoost) _markBoost = db.prepare('UPDATE ap_timeline SET boosted = 1 WHERE slug = ? AND id = ?'); _markBoost.run(slug, noteId); } catch { /* ignore */ }
    3843 }
    3844 export function unmarkBoosted(slug, noteId) {
    3845   try { if (!_unmarkBoost) _unmarkBoost = db.prepare('UPDATE ap_timeline SET boosted = 0 WHERE slug = ? AND id = ?'); _unmarkBoost.run(slug, noteId); } catch { /* ignore */ }
    3846 }
    3847 let _markLike, _unmarkLike;
    3848 export function markLiked(slug, noteId) {
    3849   try { if (!_markLike) _markLike = db.prepare('UPDATE ap_timeline SET liked = 1 WHERE slug = ? AND id = ?'); _markLike.run(slug, noteId); } catch { /* ignore */ }
    3850 }
    3851 export function unmarkLiked(slug, noteId) {
    3852   try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
    3853 }
    3854 /**
    3855  * Zet een reactie van JOU op een object. Dit hoort het enige schrijfpad te zijn
    3856  * (shaer-9e9): de tussentabel ap_my_reactions is de waarheid, de vlaggen op
    3857  * ap_timeline zijn de afgeleide. Zolang markLiked en broers los aanroepbaar
    3858  * blijven kan een aanroeper ze vergeten, en dat is niet hypothetisch -- precies
    3859  * dat leverde de shaer:liked-bug op (04aca12).
    3860  *
    3861  * `opts.note` is de opgeloste remote note bij een boost. Die is niet optioneel
    3862  * uit netheid: een boost moet de post je tijdlijn IN trekken als je de auteur
    3863  * niet volgt, anders heeft de vlag geen rij om op te landen en verschijnt de
    3864  * boost nergens -- ook niet in de Cirkel.
    3865  *
    3866  * `opts.flagUri` bestaat omdat de twee bronnen vandaag verschillend gesleuteld
    3867  * worden: de tussentabel op de URI die de client stuurde, de vlag op de
    3868  * opgeloste object-URI. Meestal zijn die gelijk, maar niet gegarandeerd. Deze
    3869  * naad houdt fase 1 gedragsbehoudend; het samentrekken van die twee sleutels is
    3870  * werk voor fase 2, mét datamigratie.
    3871  */
    3872 // Reactie-migratie (shaer-9e9). Draait bij boot, EEN keer per bump, net als
    3873 // selfHealTimeline. Bewust automatisch: klonkt-update tilt een hele vloot in een
    3874 // stap naar nieuwe code, en een handmatig script per instance wordt vergeten --
    3875 // terwijl het falen stil is (een reactie die niemand meer ziet geeft geen fout).
    3876 // v2 haalt de derde bron erbij: ap_interactions.acted_* (shaer-ipb). Een bump
    3877 // laat alle stappen opnieuw lopen, en dat mag -- ze zijn alle drie idempotent.
    3878 const REACTIONS_MIGRATION_VERSION = 2;
    3879 
    3880 /**
    3881  * Brengt alle reacties naar de tussentabel, onder de canonieke object-URI.
    3882  *
    3883  * Twee stappen, en ze zijn allebei nodig:
    3884  *
    3885  *  1. HERSLEUTELEN. De oude interact-route bewaarde de URI waarmee je binnenkwam
    3886  *     en de bookmarklet geeft window.location.href door, dus de permalink. Sinds
    3887  *     canonicalReactionUri wordt er op de object-URI gezocht, waardoor die rijen
    3888  *     wees zouden zijn. De created_at reist mee: bij hersleutelen weten we
    3889  *     wanneer je reageerde, bij aanvullen niet.
    3890  *  2. AANVULLEN vanuit de afgeleide kolommen. Alles wat op oude code via de
    3891  *     Krant is gegeven staat alleen daar; zonder deze stap toont het als
    3892  *     niet-gereageerd en klikt een gebruiker opnieuw -- met een tweede Like de
    3893  *     fediverse in als gevolg.
    3894  *
    3895  * Idempotent. Geeft terug wat er gebeurd is, zodat het script het kan tonen.
    3896  */
    3897 export function migrateReactions(opts = {}) {
    3898   const uit = { hersleuteld: 0, aangevuld: 0, reacties: 0, overgeslagen: false };
    3899   try {
    3900     if (!opts.force) {
    3901       const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('reactions_migration_version');
    3902       const cur = r ? (parseInt(r.value, 10) || 0) : 0;
    3903       if (cur >= REACTIONS_MIGRATION_VERSION) { uit.overgeslagen = true; return uit; }
    3904     }
    3905   } catch { return uit; }   // geen app_settings → deze database is te oud om aan te raken
    3906 
    3907   // Een rij die NIET op een tijdlijn-id staat maar wel op een tijdlijn-url.
    3908   const wees = `
    3909     FROM ap_my_reactions r JOIN ap_timeline t ON t.slug = r.site_slug AND t.url = r.target_uri
    3910      WHERE NOT EXISTS (SELECT 1 FROM ap_timeline t2 WHERE t2.slug = r.site_slug AND t2.id = r.target_uri)`;
    3911   const scheef = (kind, kolom) => `
    3912     FROM ap_timeline t
    3913      WHERE t.${kolom} = 1
    3914        AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
    3915                         WHERE r.site_slug = t.slug AND r.target_uri = t.id AND r.kind = '${kind}')`;
    3916   // 3. De derde bron: wat JIJ deed met een reactie onder je eigen post. De slug
    3917   //    hangt hier niet aan de rij maar aan de post; vandaar de twee joins. Een
    3918   //    rij zonder object_uri kan nooit een reactie dragen (fedi-react eist hem),
    3919   //    dus die uitsluiting verliest per constructie niets.
    3920   const acted = (kind, kolom) => `
    3921     FROM ap_interactions i
    3922      JOIN posts p ON p.id = i.post_id
    3923      JOIN sites s ON s.id = p.site_id
    3924      WHERE i.${kolom} = 1 AND IFNULL(i.object_uri, '') <> ''
    3925        AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
    3926                         WHERE r.site_slug = s.slug AND r.target_uri = i.object_uri AND r.kind = '${kind}')`;
    3927 
    3928   if (opts.dryRun) {
    3929     const tel = (sql) => { try { return db.prepare(`SELECT COUNT(*) AS n ${sql}`).get().n; } catch { return 0; } };
    3930     uit.hersleuteld = tel(wees);
    3931     uit.aangevuld = tel(scheef('like', 'liked')) + tel(scheef('boost', 'boosted'));
    3932     uit.reacties = tel(acted('like', 'acted_like')) + tel(acted('boost', 'acted_boost'));
    3933     return uit;
    3934   }
    3935 
    3936   try {
    3937     db.transaction(() => {
    3938       // 1. Hersleutelen: eerst de canonieke variant erbij, dan de permalink weg.
    3939       //    In die volgorde, zodat een onderbreking hooguit een dubbele rij
    3940       //    oplevert en nooit een verdwenen reactie.
    3941       uit.hersleuteld = db.prepare(`
    3942         INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind, created_at)
    3943         SELECT r.site_slug, t.id, r.kind, r.created_at ${wees}`).run().changes;
    3944       db.prepare(`DELETE FROM ap_my_reactions WHERE rowid IN (SELECT r.rowid ${wees})`).run();
    3945 
    3946       // 2. Aanvullen vanuit de kolommen.
    3947       for (const [kind, kolom] of [['like', 'liked'], ['boost', 'boosted']]) {
    3948         uit.aangevuld += db.prepare(`
    3949           INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
    3950           SELECT t.slug, t.id, '${kind}' ${scheef(kind, kolom)}`).run().changes;
    3951       }
    3952 
    3953       // 3. En vanuit acted_* op de reacties onder je eigen posts.
    3954       for (const [kind, kolom] of [['like', 'acted_like'], ['boost', 'acted_boost']]) {
    3955         uit.reacties += db.prepare(`
    3956           INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
    3957           SELECT s.slug, i.object_uri, '${kind}' ${acted(kind, kolom)}`).run().changes;
    3958       }
    3959     })();
    3960     if (uit.hersleuteld || uit.aangevuld || uit.reacties) {
    3961       console.log(`[AP] reaction migration v${REACTIONS_MIGRATION_VERSION}: ${uit.hersleuteld} re-keyed, ${uit.aangevuld} backfilled, ${uit.reacties} from comments`);
    3962     }
    3963     if (!opts.force) {
    3964       db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
    3965         .run('reactions_migration_version', String(REACTIONS_MIGRATION_VERSION));
    3966     }
    3967   } catch (e) {
    3968     // Niet fataal: de kolommen staan er nog, dus de oude waarheid is niet weg.
    3969     // Een volgende boot probeert het opnieuw, want de versie is niet gezet.
    3970     console.warn('[AP] reaction migration failed:', e.message);
    3971   }
    3972   return uit;
    3973 }
    3974 
    3975 /**
    3976  * Van wat de client stuurde naar de canonieke sleutel voor een reactie.
    3977  *
    3978  * Een post heeft twee URI's: zijn AP-object-id (.../ap/notes/<uuid>) en zijn
    3979  * leesbare permalink (.../effortlesseffect). De Krant en het C2S-pad spreken de
    3980  * eerste, de interact-pagina de tweede. Werden reacties onder allebei opgeslagen,
    3981  * dan bestond dezelfde like twee keer -- en erger: een like uit de Krant was op
    3982  * de interact-pagina onzichtbaar, want daar werd op de permalink gezocht.
    3983  *
    3984  * Dit was de naad die fase 1 bewust open liet ("samentrekken is werk voor fase
    3985  * 2"). Robin liep er meteen tegenaan: een geboost en geliket bericht toonde geen
    3986  * highlight. Vandaar hier, en niet later.
    3987  *
    3988  * De object-URI wint, want dat is waar ap_timeline op sleutelt en waar de
    3989  * backfill op is gebaseerd. Kennen we de post niet, dan blijft de invoer staan:
    3990  * een reactie op iets buiten je tijdlijn moet gewoon werken.
    3991  */
    3992 export function canonicalReactionUri(slug, uri) {
    3993   if (!slug || !uri) return uri;
    3994   try {
    3995     if (db.prepare('SELECT 1 FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, uri)) return uri;
    3996     const row = db.prepare('SELECT id FROM ap_timeline WHERE slug = ? AND url = ? LIMIT 1').get(slug, uri);
    3997     return (row && row.id) || uri;
    3998   } catch { return uri; }
    3999 }
    4000 
    4001 /**
    4002  * Wat heb IK met dit object gedaan? Leest de tussentabel, de bron van waarheid
    4003  * sinds shaer-9e9 fase 2. Vervangt getMyReactions en getTimelineReaction, die
    4004  * dezelfde vraag beantwoordden uit twee verschillende bronnen.
    4005  */
    4006 export function getReaction(slug, uri) {
    4007   try {
    4008     const key = canonicalReactionUri(slug, uri);
    4009     const rows = (slug && key)
    4010       ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, key)
    4011       : [];
    4012     return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
    4013   } catch { return { liked: false, boosted: false }; }
    4014 }
    4015 
    4016 /**
    4017  * Dezelfde vraag voor een hele pagina in EEN query. De C2S-tijdlijn zet
    4018  * shaer:liked op elke post; per rij vragen zou dat een N+1 maken, en dan had je
    4019  * een consistentiebug geruild voor een traagheidsbug.
    4020  */
    4021 export function getReactionsFor(slug, uris) {
    4022   const out = new Map();
    4023   const list = [...new Set((uris || []).filter(Boolean))].slice(0, 500);
    4024   if (!slug || !list.length) return out;
    4025   try {
    4026     const rows = db.prepare(
    4027       `SELECT target_uri, kind FROM ap_my_reactions
    4028         WHERE site_slug = ? AND target_uri IN (${list.map(() => '?').join(',')})`,
    4029     ).all(slug, ...list);
    4030     for (const r of rows) {
    4031       const cur = out.get(r.target_uri) || { liked: false, boosted: false };
    4032       if (r.kind === 'like') cur.liked = true;
    4033       if (r.kind === 'boost') cur.boosted = true;
    4034       out.set(r.target_uri, cur);
    4035     }
    4036   } catch { /* leeg = niets gereageerd, en dat is een veilige uitkomst */ }
    4037   return out;
    4038 }
    4039 
    4040 export function setReaction(slug, uri, kind, on, opts = {}) {
    4041   if (!slug || !uri || (kind !== 'like' && kind !== 'boost')) return;
    4042   // Ook hier, en niet alleen bij sendInteraction. Deze functie schrijft ALLEEN de
    4043   // lokale vlag; het versturen gebeurt elders. Zonder deze poort zou je op een
    4044   // verhuisd account een like zien staan die nooit de deur uit is gegaan, en dat
    4045   // is de halve toestand die erger is dan een duidelijke weigering.
    4046   try {
    4047     const s = db.prepare('SELECT moved_to FROM sites WHERE slug = ?').get(slug);
    4048     if (movedLock(s).locked) { console.warn('[AP] reactie geweigerd, account verhuisd:', slug, kind); return; }
    4049   } catch { /* geen sites-tabel = geen verhuizing */ }
    4050   // EEN sleutel voor beide bronnen. opts.flagUri is de opgeloste object-URI van
    4051   // de aanroeper (het C2S-pad kent die uit resolveRemoteNote en dat is
    4052   // betrouwbaarder dan onze cache); anders leiden we hem af. Vroeger kreeg de
    4053   // tussentabel de URI die de client stuurde en de vlag de opgeloste -- dat
    4054   // maakte dezelfde like onvindbaar vanaf de andere pagina.
    4055   const flagUri = opts.flagUri || canonicalReactionUri(slug, uri);
    4056   setMyReaction(slug, flagUri, kind, !!on);
    4057   if (kind === 'boost') {
    4058     if (!on) unmarkBoosted(slug, flagUri);
    4059     else if (opts.note) upsertBoostedNote(slug, opts.note);
    4060     else markBoosted(slug, flagUri);
    4061   } else if (on) markLiked(slug, flagUri);
    4062   else unmarkLiked(slug, flagUri);
    4063 }
    4064 
    4065 export function getTimelineReaction(slug, noteId) {
    4066   try { const r = db.prepare('SELECT liked, boosted FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, noteId); return { liked: !!(r && r.liked), boosted: !!(r && r.boosted) }; } catch { return { liked: false, boosted: false }; }
    4067 }
    4068 // Boost a REMOTE post that may not be in your timeline (you don't follow the author):
    4069 // store it in ap_timeline (INSERT OR IGNORE → no dup for followed posts) so it shows in
    4070 // the Cirkel with a Boost badge, then flag it boosted.
    4071 export function upsertBoostedNote(slug, note) {
    4072   if (!slug || !note || !note.object_uri) return;
    4073   const id = note.object_uri;
    4074   // Prefer the full typed media (incl. video/mp4 — a Loops boost is video-only and
    4075   // rendered a bare text tile); fall back to the image-only list for older callers.
    4076   const media = (note.media && note.media !== '[]')
    4077     ? note.media
    4078     : JSON.stringify((note.images || []).map((u) => ({ url: u, type: 'image/jpeg' })));
    4079   try {
    4080     const r = tlStmts().ins.run(id, slug, note.actor_uri || '', note.actor_name || '', note.actor_handle || '',
    4081       note.actor_icon || '', note.actor_url || '', note.content || '', note.url || null,
    4082       new Date().toISOString(), media, note.sensitive ? 1 : 0, note.cw || null);
    4083     if (!r.changes) {
    4084       // Row already cached (INSERT OR IGNORE) → refresh it with the freshly
    4085       // resolved note. Without this a row cached without its cover (or with
    4086       // stale content) stayed stale forever — even boosting again didn't heal it.
    4087       // Keep the CACHED media when the resolve yielded none: an empty re-resolve
    4088       // used to clobber a good media_json (the followed copy had the video, the
    4089       // boost wiped it to []).
    4090       db.prepare(`UPDATE ap_timeline SET content = ?, media_json = CASE WHEN ? = '[]' THEN media_json ELSE ? END,
    4091                   nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE slug = ? AND id = ?`)
    4092         .run(note.content || '', media, media, note.sensitive ? 1 : 0, note.cw || null, note.url || null, slug, id);
    4093     }
    4094   } catch { /* ignore */ }
    4095   markBoosted(slug, id);
    4096 }
    4097 export function boostedCount(slug) {
    4098   // Geboost EN in je tijdlijn, zoals voorheen: de tussentabel kan ook een boost
    4099   // bevatten van iets dat er (nog) niet in staat.
    4100   try {
    4101     if (!_boostedCount) _boostedCount = db.prepare(`SELECT COUNT(*) AS n FROM ap_my_reactions r
    4102       JOIN ap_timeline t ON t.slug = r.site_slug AND t.id = r.target_uri
    4103       WHERE r.site_slug = ? AND r.kind = 'boost'`);
    4104     return _boostedCount.get(slug).n;
    4105   } catch { return 0; }
    4106 }
    41073837
    41083838// Resolve a Klonkt/AP actor URL from a site root: a Klonkt site's root 302s to
     
    57725502  deliverCreate,
    57735503});
    5774 // En de tijdlijn-leeskant zijn ene werktuig (stap 5): liked/boosted komen uit
    5775 // het reactiecluster, dat hier woont.
     5504// En de tijdlijn-leeskant zijn ene werktuig (stap 5): liked/boosted komen
     5505// sinds stap 6 uit ap-reactions, maar de koppeling blijft HIER lopen -- twee
     5506// zustermodules die elkaar importeren zou een kring zijn.
    57765507wireTimeline({ getReactionsFor });
     5508// Het reactiecluster zijn ene werktuig (stap 6): de verhuisgrendel (FEP-7628).
     5509wireReactions({ movedLock });
    57775510
    57785511export default {
Note: See TracChangeset for help on using the changeset viewer.