source: Klonkt/src/services/ap-reactions.js@ db81e56

main
Last change on this file since db81e56 was 911d5cd, checked in by Robin <roboburr@…>, 2 weeks ago

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.

  • Property mode set to 100644
File size: 16.2 KB
Line 
1/**
2 * ap-reactions.js — het reactiecluster (stap 6 van shaer-drc).
3 *
4 * De waarheid over "heb ik hierop gereageerd" (ap_my_reactions), de afgeleide
5 * vlaggen op ap_timeline die setReaction daaruit bijhoudt, de eenmalige
6 * migratie, en de boost-upsert die een vreemde post de tijdlijn in trekt.
7 *
8 * Twee koppelingen, allebei bewust zo:
9 * - tlStmts komt STATISCH uit ap-timeline: reacties schrijven de
10 * tijdlijnvlaggen, dus die pijl wijst een kant op en mag gewoon een import
11 * zijn.
12 * - de omgekeerde pijl (de tijdlijn-leeskant heeft getReactionsFor nodig)
13 * blijft de injectie via wireTimeline in ActivityPubService -- twee
14 * zustermodules die elkaar importeren zou precies de kring zijn die
15 * shaer-drc vermijdt.
16 * movedLock komt uit de dienstlaag (FEP-7628) en dus via wireReactions binnen.
17 */
18import db from '../config/database.js';
19import { tlStmts } from './ap-timeline.js';
20
21// Het ene werktuig uit de dienstlaag; ActivityPubService vult het onderaan.
22let movedLock;
23export function wireReactions(deps) {
24 ({ movedLock } = deps);
25}
26
27// Your like/boost state on a REMOTE post (interact page toggles).
28export function setMyReaction(slug, uri, kind, on) {
29 if (on) db.prepare('INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind) VALUES (?,?,?)').run(slug, uri, kind);
30 else db.prepare('DELETE FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ? AND kind = ?').run(slug, uri, kind);
31}
32export function getMyReactions(slug, uri) {
33 const rows = (slug && uri) ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, uri) : [];
34 return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
35}
36
37// AFGELEIDE, GEEN BRON (shaer-9e9). De waarheid over "heb ik hierop gereageerd"
38// staat in ap_my_reactions; deze vlaggen worden daaruit bijgehouden door
39// setReaction en door niets anders. Roep ze niet los aan -- dan schrijf je de
40// helft, en dat is precies hoe shaer:liked maandenlang false bleef (04aca12).
41//
42// ap_timeline.boosted verdient zijn bestaan wel: hij staat in de WHERE van de
43// Cirkel-feed (getCirkelPosts) en in boostedCount, dus hij is een index en geen
44// kopie. ap_timeline.liked wordt nergens als verzameling bevraagd en kan weg
45// zodra fase 2 lang genoeg goed staat; hij is nu nog het vangnet waarmee
46// terugdraaien een code-revert blijft in plaats van dataherstel.
47let _markBoost, _unmarkBoost, _boostedCount;
48export function markBoosted(slug, noteId) {
49 try { if (!_markBoost) _markBoost = db.prepare('UPDATE ap_timeline SET boosted = 1 WHERE slug = ? AND id = ?'); _markBoost.run(slug, noteId); } catch { /* ignore */ }
50}
51export function unmarkBoosted(slug, noteId) {
52 try { if (!_unmarkBoost) _unmarkBoost = db.prepare('UPDATE ap_timeline SET boosted = 0 WHERE slug = ? AND id = ?'); _unmarkBoost.run(slug, noteId); } catch { /* ignore */ }
53}
54let _markLike, _unmarkLike;
55export function markLiked(slug, noteId) {
56 try { if (!_markLike) _markLike = db.prepare('UPDATE ap_timeline SET liked = 1 WHERE slug = ? AND id = ?'); _markLike.run(slug, noteId); } catch { /* ignore */ }
57}
58export function unmarkLiked(slug, noteId) {
59 try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
60}
61/**
62 * Zet een reactie van JOU op een object. Dit hoort het enige schrijfpad te zijn
63 * (shaer-9e9): de tussentabel ap_my_reactions is de waarheid, de vlaggen op
64 * ap_timeline zijn de afgeleide. Zolang markLiked en broers los aanroepbaar
65 * blijven kan een aanroeper ze vergeten, en dat is niet hypothetisch -- precies
66 * dat leverde de shaer:liked-bug op (04aca12).
67 *
68 * `opts.note` is de opgeloste remote note bij een boost. Die is niet optioneel
69 * uit netheid: een boost moet de post je tijdlijn IN trekken als je de auteur
70 * niet volgt, anders heeft de vlag geen rij om op te landen en verschijnt de
71 * boost nergens -- ook niet in de Cirkel.
72 *
73 * `opts.flagUri` bestaat omdat de twee bronnen vandaag verschillend gesleuteld
74 * worden: de tussentabel op de URI die de client stuurde, de vlag op de
75 * opgeloste object-URI. Meestal zijn die gelijk, maar niet gegarandeerd. Deze
76 * naad houdt fase 1 gedragsbehoudend; het samentrekken van die twee sleutels is
77 * werk voor fase 2, mét datamigratie.
78 */
79// Reactie-migratie (shaer-9e9). Draait bij boot, EEN keer per bump, net als
80// selfHealTimeline. Bewust automatisch: klonkt-update tilt een hele vloot in een
81// stap naar nieuwe code, en een handmatig script per instance wordt vergeten --
82// terwijl het falen stil is (een reactie die niemand meer ziet geeft geen fout).
83// v2 haalt de derde bron erbij: ap_interactions.acted_* (shaer-ipb). Een bump
84// laat alle stappen opnieuw lopen, en dat mag -- ze zijn alle drie idempotent.
85const REACTIONS_MIGRATION_VERSION = 2;
86
87/**
88 * Brengt alle reacties naar de tussentabel, onder de canonieke object-URI.
89 *
90 * Twee stappen, en ze zijn allebei nodig:
91 *
92 * 1. HERSLEUTELEN. De oude interact-route bewaarde de URI waarmee je binnenkwam
93 * en de bookmarklet geeft window.location.href door, dus de permalink. Sinds
94 * canonicalReactionUri wordt er op de object-URI gezocht, waardoor die rijen
95 * wees zouden zijn. De created_at reist mee: bij hersleutelen weten we
96 * wanneer je reageerde, bij aanvullen niet.
97 * 2. AANVULLEN vanuit de afgeleide kolommen. Alles wat op oude code via de
98 * Krant is gegeven staat alleen daar; zonder deze stap toont het als
99 * niet-gereageerd en klikt een gebruiker opnieuw -- met een tweede Like de
100 * fediverse in als gevolg.
101 *
102 * Idempotent. Geeft terug wat er gebeurd is, zodat het script het kan tonen.
103 */
104export function migrateReactions(opts = {}) {
105 const uit = { hersleuteld: 0, aangevuld: 0, reacties: 0, overgeslagen: false };
106 try {
107 if (!opts.force) {
108 const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('reactions_migration_version');
109 const cur = r ? (parseInt(r.value, 10) || 0) : 0;
110 if (cur >= REACTIONS_MIGRATION_VERSION) { uit.overgeslagen = true; return uit; }
111 }
112 } catch { return uit; } // geen app_settings → deze database is te oud om aan te raken
113
114 // Een rij die NIET op een tijdlijn-id staat maar wel op een tijdlijn-url.
115 const wees = `
116 FROM ap_my_reactions r JOIN ap_timeline t ON t.slug = r.site_slug AND t.url = r.target_uri
117 WHERE NOT EXISTS (SELECT 1 FROM ap_timeline t2 WHERE t2.slug = r.site_slug AND t2.id = r.target_uri)`;
118 const scheef = (kind, kolom) => `
119 FROM ap_timeline t
120 WHERE t.${kolom} = 1
121 AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
122 WHERE r.site_slug = t.slug AND r.target_uri = t.id AND r.kind = '${kind}')`;
123 // 3. De derde bron: wat JIJ deed met een reactie onder je eigen post. De slug
124 // hangt hier niet aan de rij maar aan de post; vandaar de twee joins. Een
125 // rij zonder object_uri kan nooit een reactie dragen (fedi-react eist hem),
126 // dus die uitsluiting verliest per constructie niets.
127 const acted = (kind, kolom) => `
128 FROM ap_interactions i
129 JOIN posts p ON p.id = i.post_id
130 JOIN sites s ON s.id = p.site_id
131 WHERE i.${kolom} = 1 AND IFNULL(i.object_uri, '') <> ''
132 AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
133 WHERE r.site_slug = s.slug AND r.target_uri = i.object_uri AND r.kind = '${kind}')`;
134
135 if (opts.dryRun) {
136 const tel = (sql) => { try { return db.prepare(`SELECT COUNT(*) AS n ${sql}`).get().n; } catch { return 0; } };
137 uit.hersleuteld = tel(wees);
138 uit.aangevuld = tel(scheef('like', 'liked')) + tel(scheef('boost', 'boosted'));
139 uit.reacties = tel(acted('like', 'acted_like')) + tel(acted('boost', 'acted_boost'));
140 return uit;
141 }
142
143 try {
144 db.transaction(() => {
145 // 1. Hersleutelen: eerst de canonieke variant erbij, dan de permalink weg.
146 // In die volgorde, zodat een onderbreking hooguit een dubbele rij
147 // oplevert en nooit een verdwenen reactie.
148 uit.hersleuteld = db.prepare(`
149 INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind, created_at)
150 SELECT r.site_slug, t.id, r.kind, r.created_at ${wees}`).run().changes;
151 db.prepare(`DELETE FROM ap_my_reactions WHERE rowid IN (SELECT r.rowid ${wees})`).run();
152
153 // 2. Aanvullen vanuit de kolommen.
154 for (const [kind, kolom] of [['like', 'liked'], ['boost', 'boosted']]) {
155 uit.aangevuld += db.prepare(`
156 INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
157 SELECT t.slug, t.id, '${kind}' ${scheef(kind, kolom)}`).run().changes;
158 }
159
160 // 3. En vanuit acted_* op de reacties onder je eigen posts.
161 for (const [kind, kolom] of [['like', 'acted_like'], ['boost', 'acted_boost']]) {
162 uit.reacties += db.prepare(`
163 INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
164 SELECT s.slug, i.object_uri, '${kind}' ${acted(kind, kolom)}`).run().changes;
165 }
166 })();
167 if (uit.hersleuteld || uit.aangevuld || uit.reacties) {
168 console.log(`[AP] reaction migration v${REACTIONS_MIGRATION_VERSION}: ${uit.hersleuteld} re-keyed, ${uit.aangevuld} backfilled, ${uit.reacties} from comments`);
169 }
170 if (!opts.force) {
171 db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
172 .run('reactions_migration_version', String(REACTIONS_MIGRATION_VERSION));
173 }
174 } catch (e) {
175 // Niet fataal: de kolommen staan er nog, dus de oude waarheid is niet weg.
176 // Een volgende boot probeert het opnieuw, want de versie is niet gezet.
177 console.warn('[AP] reaction migration failed:', e.message);
178 }
179 return uit;
180}
181
182/**
183 * Van wat de client stuurde naar de canonieke sleutel voor een reactie.
184 *
185 * Een post heeft twee URI's: zijn AP-object-id (.../ap/notes/<uuid>) en zijn
186 * leesbare permalink (.../effortlesseffect). De Krant en het C2S-pad spreken de
187 * eerste, de interact-pagina de tweede. Werden reacties onder allebei opgeslagen,
188 * dan bestond dezelfde like twee keer -- en erger: een like uit de Krant was op
189 * de interact-pagina onzichtbaar, want daar werd op de permalink gezocht.
190 *
191 * Dit was de naad die fase 1 bewust open liet ("samentrekken is werk voor fase
192 * 2"). Robin liep er meteen tegenaan: een geboost en geliket bericht toonde geen
193 * highlight. Vandaar hier, en niet later.
194 *
195 * De object-URI wint, want dat is waar ap_timeline op sleutelt en waar de
196 * backfill op is gebaseerd. Kennen we de post niet, dan blijft de invoer staan:
197 * een reactie op iets buiten je tijdlijn moet gewoon werken.
198 */
199export function canonicalReactionUri(slug, uri) {
200 if (!slug || !uri) return uri;
201 try {
202 if (db.prepare('SELECT 1 FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, uri)) return uri;
203 const row = db.prepare('SELECT id FROM ap_timeline WHERE slug = ? AND url = ? LIMIT 1').get(slug, uri);
204 return (row && row.id) || uri;
205 } catch { return uri; }
206}
207
208/**
209 * Wat heb IK met dit object gedaan? Leest de tussentabel, de bron van waarheid
210 * sinds shaer-9e9 fase 2. Vervangt getMyReactions en getTimelineReaction, die
211 * dezelfde vraag beantwoordden uit twee verschillende bronnen.
212 */
213export function getReaction(slug, uri) {
214 try {
215 const key = canonicalReactionUri(slug, uri);
216 const rows = (slug && key)
217 ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, key)
218 : [];
219 return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
220 } catch { return { liked: false, boosted: false }; }
221}
222
223/**
224 * Dezelfde vraag voor een hele pagina in EEN query. De C2S-tijdlijn zet
225 * shaer:liked op elke post; per rij vragen zou dat een N+1 maken, en dan had je
226 * een consistentiebug geruild voor een traagheidsbug.
227 */
228export function getReactionsFor(slug, uris) {
229 const out = new Map();
230 const list = [...new Set((uris || []).filter(Boolean))].slice(0, 500);
231 if (!slug || !list.length) return out;
232 try {
233 const rows = db.prepare(
234 `SELECT target_uri, kind FROM ap_my_reactions
235 WHERE site_slug = ? AND target_uri IN (${list.map(() => '?').join(',')})`,
236 ).all(slug, ...list);
237 for (const r of rows) {
238 const cur = out.get(r.target_uri) || { liked: false, boosted: false };
239 if (r.kind === 'like') cur.liked = true;
240 if (r.kind === 'boost') cur.boosted = true;
241 out.set(r.target_uri, cur);
242 }
243 } catch { /* leeg = niets gereageerd, en dat is een veilige uitkomst */ }
244 return out;
245}
246
247export function setReaction(slug, uri, kind, on, opts = {}) {
248 if (!slug || !uri || (kind !== 'like' && kind !== 'boost')) return;
249 // Ook hier, en niet alleen bij sendInteraction. Deze functie schrijft ALLEEN de
250 // lokale vlag; het versturen gebeurt elders. Zonder deze poort zou je op een
251 // verhuisd account een like zien staan die nooit de deur uit is gegaan, en dat
252 // is de halve toestand die erger is dan een duidelijke weigering.
253 try {
254 const s = db.prepare('SELECT moved_to FROM sites WHERE slug = ?').get(slug);
255 if (movedLock(s).locked) { console.warn('[AP] reactie geweigerd, account verhuisd:', slug, kind); return; }
256 } catch { /* geen sites-tabel = geen verhuizing */ }
257 // EEN sleutel voor beide bronnen. opts.flagUri is de opgeloste object-URI van
258 // de aanroeper (het C2S-pad kent die uit resolveRemoteNote en dat is
259 // betrouwbaarder dan onze cache); anders leiden we hem af. Vroeger kreeg de
260 // tussentabel de URI die de client stuurde en de vlag de opgeloste -- dat
261 // maakte dezelfde like onvindbaar vanaf de andere pagina.
262 const flagUri = opts.flagUri || canonicalReactionUri(slug, uri);
263 setMyReaction(slug, flagUri, kind, !!on);
264 if (kind === 'boost') {
265 if (!on) unmarkBoosted(slug, flagUri);
266 else if (opts.note) upsertBoostedNote(slug, opts.note);
267 else markBoosted(slug, flagUri);
268 } else if (on) markLiked(slug, flagUri);
269 else unmarkLiked(slug, flagUri);
270}
271
272export function getTimelineReaction(slug, noteId) {
273 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 }; }
274}
275// Boost a REMOTE post that may not be in your timeline (you don't follow the author):
276// store it in ap_timeline (INSERT OR IGNORE → no dup for followed posts) so it shows in
277// the Cirkel with a Boost badge, then flag it boosted.
278export function upsertBoostedNote(slug, note) {
279 if (!slug || !note || !note.object_uri) return;
280 const id = note.object_uri;
281 // Prefer the full typed media (incl. video/mp4 — a Loops boost is video-only and
282 // rendered a bare text tile); fall back to the image-only list for older callers.
283 const media = (note.media && note.media !== '[]')
284 ? note.media
285 : JSON.stringify((note.images || []).map((u) => ({ url: u, type: 'image/jpeg' })));
286 try {
287 const r = tlStmts().ins.run(id, slug, note.actor_uri || '', note.actor_name || '', note.actor_handle || '',
288 note.actor_icon || '', note.actor_url || '', note.content || '', note.url || null,
289 new Date().toISOString(), media, note.sensitive ? 1 : 0, note.cw || null);
290 if (!r.changes) {
291 // Row already cached (INSERT OR IGNORE) → refresh it with the freshly
292 // resolved note. Without this a row cached without its cover (or with
293 // stale content) stayed stale forever — even boosting again didn't heal it.
294 // Keep the CACHED media when the resolve yielded none: an empty re-resolve
295 // used to clobber a good media_json (the followed copy had the video, the
296 // boost wiped it to []).
297 db.prepare(`UPDATE ap_timeline SET content = ?, media_json = CASE WHEN ? = '[]' THEN media_json ELSE ? END,
298 nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE slug = ? AND id = ?`)
299 .run(note.content || '', media, media, note.sensitive ? 1 : 0, note.cw || null, note.url || null, slug, id);
300 }
301 } catch { /* ignore */ }
302 markBoosted(slug, id);
303}
304export function boostedCount(slug) {
305 // Geboost EN in je tijdlijn, zoals voorheen: de tussentabel kan ook een boost
306 // bevatten van iets dat er (nog) niet in staat.
307 try {
308 if (!_boostedCount) _boostedCount = db.prepare(`SELECT COUNT(*) AS n FROM ap_my_reactions r
309 JOIN ap_timeline t ON t.slug = r.site_slug AND t.id = r.target_uri
310 WHERE r.site_slug = ? AND r.kind = 'boost'`);
311 return _boostedCount.get(slug).n;
312 } catch { return 0; }
313}
Note: See TracBrowser for help on using the repository browser.