Changeset c010b42 in Klonkt


Ignore:
Timestamp:
08/06/2026 08:46:01 AM (5 weeks ago)
Author:
Claude (agent) <aiclaude@…>
Branches:
main
Children:
0e27e54
Parents:
02fb7ce
git-author:
Robin <roboburr@…> (08/06/2026 08:46:00 AM)
git-committer:
Claude (agent) <aiclaude@…> (08/06/2026 08:46:01 AM)
Message:

Eén schrijfpad voor reacties: setReaction (shaer-9e9 fase 1, stap 2)

Alle plekken die "ik heb hierop gereageerd" opschreven gaan nu door
setReaction(slug, uri, kind, on, opts): de tussentabel ap_my_reactions is de
waarheid, de vlaggen op ap_timeline zijn de afgeleide. Dat zijn de twee takken
van de C2S-outbox (Like/Announce en Undo) en de vier webhandlers (interact-like,
interact-boost, tijdlijn-like, tijdlijn-boost).

Twee dingen zitten er bewust in.

opts.note, omdat een boost geen vlag zet maar de post je tijdlijn IN trekt als je
de auteur niet volgt -- anders heeft de vlag geen rij om op te landen en
verschijnt de boost nergens, ook niet in de Cirkel. Dat is de valkuil die in het
plan met naam staat.

opts.flagUri, omdat de twee bronnen vandaag verschillend gesleuteld worden: de
tussentabel op de URI die de client stuurde, de vlag op de opgeloste object-URI.
Meestal gelijk, niet gegarandeerd. Die naad houdt deze stap gedragsbehoudend;
het samentrekken van de sleutels is fase 2, met datamigratie.

CORRECTIE OP HET PLAN: ik schreef dat deze stap "per definitie" geen
gedragswijziging geeft omdat lezen niet verandert. Dat klopt niet. Nu de
interact-route ook de kolom vult, verschijnt een like die eerder alleen in de
tussentabel stond voortaan ook in de Krant. Dat is de bedoelde convergentie,
maar het is wel zichtbaar -- en het hoort in de notitie te staan in plaats van
weggeredeneerd.

CORRECTIE OP DE TESTS: de karakteriseringstests zouden volgens hun eigen kop
gaan falen na deze stap. Dat gebeurde niet, en terecht: ze roepen de primitieven
aan en die zijn niet veranderd. De kop is bijgewerkt en er zijn vier tests bij
die wel bewaken wat deze stap oplevert -- dat setReaction beide bronnen vult,
dat een boost met note de rij aanmaakt en in de Cirkel belandt, dat de
gescheiden sleutels blijven werken, en dat rommelige invoer niets doet in plaats
van iets halfs.

Suite 426/426.

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

Files:
3 edited

Legend:

Unmodified
Added
Removed
  • src/routes/posts.js

    r02fb7ce rc010b42  
    843843      .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri))
    844844      .catch((e) => console.warn('[AP] remote like failed:', e.message));
    845     ActivityPubService.setMyReaction(site.slug, uri, 'like', on);
     845    // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag.
     846    ActivityPubService.setReaction(site.slug, uri, 'like', on);
    846847  }
    847848  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
     
    862863        const id = note.object_uri || uri;
    863864        return Promise.resolve(ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', id, note.actor_uri))
    864           // Boost → store the post in the timeline (even if you don't follow the author) so it
    865           // surfaces in the Cirkel; unboost → just clear the flag.
    866           .then(() => on ? ActivityPubService.upsertBoostedNote(site.slug, note) : ActivityPubService.unmarkBoosted(site.slug, id));
     865          // De note gaat mee: een boost zet niet alleen een vlag maar trekt de
     866          // post je tijdlijn in, ook als je de auteur niet volgt, zodat hij in
     867          // de Cirkel verschijnt.
     868          .then(() => ActivityPubService.setReaction(site.slug, uri, 'boost', on, { flagUri: id, note: on ? note : null }));
    867869      })
    868870      .catch((e) => console.warn('[AP] remote boost failed:', e.message));
     871    // De tussentabel meteen, zodat de knop klopt voordat de resolve terug is.
    869872    ActivityPubService.setMyReaction(site.slug, uri, 'boost', on);
    870873  }
     
    12791282    on = !ActivityPubService.getTimelineReaction(site.slug, note).liked;
    12801283    try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
    1281     if (on) ActivityPubService.markLiked(site.slug, note); else ActivityPubService.unmarkLiked(site.slug, note);
     1284    ActivityPubService.setReaction(site.slug, note, 'like', on);
    12821285  }
    12831286  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
     
    12931296    on = !ActivityPubService.getTimelineReaction(site.slug, note).boosted;
    12941297    try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
     1298    ActivityPubService.setReaction(site.slug, note, 'boost', on); // instant UI state
    12951299    if (on) {
    1296       ActivityPubService.markBoosted(site.slug, note); // instant UI state
    12971300      // Fire-and-forget: re-resolve the note so the cached row is refreshed
    12981301      // (cover/content) — boosting again heals a stale copy from EVERY boost
    12991302      // path, not just the interact page.
    13001303      ActivityPubService.resolveRemoteNote(note)
    1301         .then((n) => { if (n) ActivityPubService.upsertBoostedNote(site.slug, n); })
     1304        .then((n) => { if (n) ActivityPubService.setReaction(site.slug, note, 'boost', true, { note: n }); })
    13021305        .catch(() => { /* best-effort */ });
    1303     } else {
    1304       ActivityPubService.unmarkBoosted(site.slug, note);
    13051306    }
    13061307  }
  • src/services/ActivityPubService.js

    r02fb7ce rc010b42  
    26232623        const kind = type === 'Announce' ? 'boost' : 'like';
    26242624        await sendInteraction(site, kind, objUri, authorUri);
    2625         setMyReaction(site.slug, targetUri, kind, true);
    2626         if (type === 'Announce' && note) { try { upsertBoostedNote(site.slug, note); } catch { /* non-fatal */ } }
     2625        // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag in één keer.
     2626        // De note gaat mee zodat een boost de post je tijdlijn in trekt.
     2627        try { setReaction(site.slug, targetUri, kind, true, { flagUri: objUri, note: type === 'Announce' ? note : null }); }
     2628        catch { /* non-fatal: een reactie mag nooit de bezorging blokkeren */ }
    26272629        // Een Like uit een app moet ook in ap_timeline.liked landen, want dat
    26282630        // is wat de C2S-tijdlijn als shaer:liked teruggeeft. Zonder dit werd
     
    26342636        // in je tijdlijn te trekken, dus staat de post er niet in, dan is dit
    26352637        // terecht een no-op.
    2636         if (type === 'Like') { try { markLiked(site.slug, objUri); } catch { /* non-fatal */ } }
    26372638        return { status: 202, url: objUri };
    26382639      }
     
    26832684          const objUri = (note && note.object_uri) || innerTarget;
    26842685          await sendInteraction(site, kind, objUri, note && note.actor_uri);
    2685           setMyReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false);
    2686           if (innerType === 'Announce') { try { unmarkBoosted(site.slug, objUri); } catch { /* non-fatal */ } }
    2687           if (innerType === 'Like') { try { unmarkLiked(site.slug, objUri); } catch { /* non-fatal */ } }
     2686          try { setReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false, { flagUri: objUri }); }
     2687          catch { /* non-fatal */ }
    26882688          return { status: 202, url: objUri };
    26892689        }
     
    34113411  try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
    34123412}
     3413/**
     3414 * Zet een reactie van JOU op een object. Dit hoort het enige schrijfpad te zijn
     3415 * (shaer-9e9): de tussentabel ap_my_reactions is de waarheid, de vlaggen op
     3416 * ap_timeline zijn de afgeleide. Zolang markLiked en broers los aanroepbaar
     3417 * blijven kan een aanroeper ze vergeten, en dat is niet hypothetisch -- precies
     3418 * dat leverde de shaer:liked-bug op (04aca12).
     3419 *
     3420 * `opts.note` is de opgeloste remote note bij een boost. Die is niet optioneel
     3421 * uit netheid: een boost moet de post je tijdlijn IN trekken als je de auteur
     3422 * niet volgt, anders heeft de vlag geen rij om op te landen en verschijnt de
     3423 * boost nergens -- ook niet in de Cirkel.
     3424 *
     3425 * `opts.flagUri` bestaat omdat de twee bronnen vandaag verschillend gesleuteld
     3426 * worden: de tussentabel op de URI die de client stuurde, de vlag op de
     3427 * opgeloste object-URI. Meestal zijn die gelijk, maar niet gegarandeerd. Deze
     3428 * naad houdt fase 1 gedragsbehoudend; het samentrekken van die twee sleutels is
     3429 * werk voor fase 2, mét datamigratie.
     3430 */
     3431export function setReaction(slug, uri, kind, on, opts = {}) {
     3432  if (!slug || !uri || (kind !== 'like' && kind !== 'boost')) return;
     3433  setMyReaction(slug, uri, kind, !!on);
     3434  const flagUri = opts.flagUri || uri;
     3435  if (kind === 'boost') {
     3436    if (!on) unmarkBoosted(slug, flagUri);
     3437    else if (opts.note) upsertBoostedNote(slug, opts.note);
     3438    else markBoosted(slug, flagUri);
     3439  } else if (on) markLiked(slug, flagUri);
     3440  else unmarkLiked(slug, flagUri);
     3441}
     3442
    34133443export function getTimelineReaction(slug, noteId) {
    34143444  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 }; }
     
    47794809  gateOutgoingFollow, performApprovedFollow,
    47804810  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
    4781   autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
     4811  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, setReaction, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
    47824812  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
    47834813  deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
  • test/reactions-characterization.test.js

    r02fb7ce rc010b42  
    99// eerst is vastgeschreven.
    1010//
    11 // LET OP bij het opruimen: de tests onder "de twee bronnen lopen uiteen" horen
    12 // te GAAN FALEN zodra fase 1 (één setReaction) klaar is. Dat is geen regressie
    13 // maar het doel; werk ze dan bewust bij in plaats van ze te laten verdwijnen.
     11// LET OP: de tests onder "de twee bronnen lopen uiteen" roepen de PRIMITIEVEN
     12// aan (markLiked, setMyReaction). Die blijven bewust gescheiden -- het
     13// samenvoegen zit in setReaction, dus in de aanroepers, niet in de primitieven.
     14// Deze tests blijven daarom groen na fase 1 en beschrijven dan nog steeds iets
     15// waars: wie markLiked los aanroept, raakt de tussentabel niet. Dat is precies
     16// waarom setReaction bestaat en waarom die primitieven op termijn intern moeten
     17// worden.
     18//
     19// De invariant die fase 1 wél bewaakt staat onderaan, bij setReaction.
    1420//
    1521// Wat hier NIET in kan: de routes zelf (die vragen HTTP + sessie) en het
     
    115121
    116122// ── De twee bronnen lopen uiteen ─────────────────────────────────────────
    117 // DEZE TESTS HOREN TE FALEN NA FASE 1. Ze leggen de huidige splitsing vast,
    118 // zodat het samenvoegen een zichtbare gebeurtenis is en geen stille.
     123// De primitieven raken elkaar niet, en dat blijft na fase 1 zo: het
     124// samenvoegen zit in setReaction. Deze drie leggen vast waarom die functie
     125// moet bestaan, en waarom markLiked en broers uiteindelijk intern horen te
     126// worden -- zolang ze los aanroepbaar zijn, kan een aanroeper de helft doen.
    119127
    120128test('HUIDIG GEDRAG: de tijdlijn-route vult de tussentabel niet', () => {
     
    123131  assert.equal(AP.getTimelineReaction('me', u).liked, true, 'de kolom staat aan');
    124132  assert.equal(AP.getMyReactions('me', u).liked, false,
    125     'NA FASE 1 hoort dit true te zijn: pas deze test dan bewust aan');
     133    'de primitief raakt de tussentabel niet -- daarvoor is setReaction');
    126134});
    127135
     
    131139  assert.equal(AP.getMyReactions('me', u).liked, true, 'de tussentabel staat aan');
    132140  assert.equal(AP.getTimelineReaction('me', u).liked, false,
    133     'NA FASE 1 hoort dit true te zijn: pas deze test dan bewust aan');
     141    'de primitief raakt de kolom niet -- daarvoor is setReaction');
    134142});
    135143
     
    145153});
    146154
    147 // ── Wat de invariant-test van fase 1 straks moet bewaken ─────────────────
     155// ── De scheefheid, meetbaar (fase 0 uit shaer-9e9 als test) ─────────────
    148156
    149157test('meetbaar: hoeveel rijen hebben een vlag zonder tegenhanger', () => {
     
    157165  assert.ok(scheef > 0, 'vandaag lopen ze uiteen; na fase 1 hoort deze assert omgedraaid te worden naar === 0');
    158166});
     167
     168// ── De invariant van fase 1: setReaction houdt de bronnen gelijk ─────────
     169
     170test('setReaction: een like landt in BEIDE bronnen', () => {
     171  const u = uri('sr1'); seedTimeline(u);
     172  AP.setReaction('me', u, 'like', true);
     173  assert.equal(AP.getMyReactions('me', u).liked, true, 'tussentabel');
     174  assert.equal(AP.getTimelineReaction('me', u).liked, true, 'afgeleide vlag');
     175  AP.setReaction('me', u, 'like', false);
     176  assert.equal(AP.getMyReactions('me', u).liked, false);
     177  assert.equal(AP.getTimelineReaction('me', u).liked, false);
     178});
     179
     180test('setReaction: een boost met note trekt de post je tijdlijn in EN vult beide', () => {
     181  // De valkuil uit het plan: zonder de note bestaat de rij niet en landt de
     182  // vlag nergens, dus zou de boost onvindbaar zijn.
     183  const u = uri('sr2');
     184  AP.setReaction('me', u, 'boost', true, {
     185    note: { object_uri: u, actor_uri: 'https://r.test/users/bo', actor_name: 'Bo', content: '<p>x</p>', media: '[]' },
     186  });
     187  assert.equal(AP.getMyReactions('me', u).boosted, true, 'tussentabel');
     188  assert.equal(AP.getTimelineReaction('me', u).boosted, true, 'afgeleide vlag');
     189  assert.ok(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), 'en zichtbaar in de Cirkel');
     190  AP.setReaction('me', u, 'boost', false);
     191  assert.equal(AP.getMyReactions('me', u).boosted, false);
     192  assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false);
     193});
     194
     195test('setReaction: gescheiden sleutels blijven werken zolang ze bestaan', () => {
     196  // De tussentabel wordt gesleuteld op wat de client stuurde, de vlag op de
     197  // opgeloste object-URI. Meestal gelijk, niet gegarandeerd. flagUri houdt dat
     198  // uit elkaar tot fase 2 ze samentrekt.
     199  const gestuurd = 'https://r.test/@anna/123';
     200  const opgelost = uri('sr3');
     201  seedTimeline(opgelost);
     202  AP.setReaction('me', gestuurd, 'like', true, { flagUri: opgelost });
     203  assert.equal(AP.getMyReactions('me', gestuurd).liked, true, 'op de gestuurde uri');
     204  assert.equal(AP.getTimelineReaction('me', opgelost).liked, true, 'op de opgeloste uri');
     205});
     206
     207test('setReaction: rommelige invoer doet niets in plaats van iets halfs', () => {
     208  AP.setReaction('me', uri('sr4'), 'sterretje', true);
     209  assert.equal(AP.getMyReactions('me', uri('sr4')).liked, false);
     210  AP.setReaction('', uri('sr5'), 'like', true);
     211  AP.setReaction('me', '', 'like', true);
     212  assert.equal(AP.getMyReactions('me', uri('sr5')).liked, false);
     213});
Note: See TracChangeset for help on using the changeset viewer.