Changeset 7842ca1 in Klonkt


Ignore:
Timestamp:
08/06/2026 09:33:58 AM (5 weeks ago)
Author:
Claude (agent) <aiclaude@…>
Branches:
main
Children:
25f2e64
Parents:
6bc5ebd
git-author:
Robin <roboburr@…> (08/06/2026 09:33:57 AM)
git-committer:
Claude (agent) <aiclaude@…> (08/06/2026 09:33:58 AM)
Message:

Reactie-migratie draait bij boot, inclusief hersleutelen (shaer-9e9)

Een upgrade naar de fase-2-code was tot nu toe onveilig. Het schema migreert
vanzelf (ensureColumn bij boot), maar voor data was er niets: de backfill was een
handmatig script. Bij een vloot die met klonkt-update in een stap naar nieuwe code
gaat wordt dat vergeten, en het falen is stil -- een reactie die niemand meer
ziet geeft geen fout, en de gebruiker klikt gewoon opnieuw, met een tweede Like
de fediverse in als gevolg.

migrateReactions() draait nu bij boot, één keer per REACTIONS_MIGRATION_VERSION,
op dezelfde manier als selfHealTimeline. Twee stappen:

HERSLEUTELEN De oude interact-route bewaarde de URI waarmee je binnenkwam, en

de bookmarklet geeft window.location.href door -- dus de
permalink. Sinds canonicalReactionUri op de object-URI gezocht
wordt, zouden die rijen wees zijn. Dit gat zat NIET in mijn
eerste backfill-script; dat vulde alleen aan vanuit de kolommen
en zag deze rijen niet. Op dev viel het niet op omdat daar alles
al canoniek stond.

AANVULLEN Alles wat op oude code via de Krant is gegeven staat alleen in

ap_timeline.liked/boosted.

Volgorde binnen het hersleutelen: eerst de canonieke variant erbij, dan de
permalink weg. Een onderbreking levert dan hooguit een dubbele rij op en nooit
een verdwenen reactie. Bij hersleutelen reist created_at mee; bij aanvullen
bestaat die informatie niet en wordt het de migratiedatum.

Mislukt de migratie, dan wordt de versievlag niet gezet en probeert de volgende
boot het opnieuw. De kolommen blijven staan, dus de oude waarheid is intussen
niet weg.

Het losse script is nu een schil om diezelfde functie, met --dry-run en --force.
Twee implementaties van een migratie lopen uiteen, en dan repareert de ene wat de
andere niet ziet.

Droogloop tegen de dev-database: 0 te hersleutelen, 0 aan te vullen -- die is al
schoon, wat meteen de idempotentie bevestigt. Suite 438/438.

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

Files:
4 edited

Legend:

Unmodified
Added
Removed
  • scripts/backfill-reactions.mjs

    r6bc5ebd r7842ca1  
    11#!/usr/bin/env node
    22//
    3 // Backfill: reacties uit de afgeleide kolommen naar de tussentabel (shaer-9e9).
     3// Reactie-migratie handmatig draaien (shaer-9e9).
    44//
    5 // "Heb ik hierop gereageerd" stond in Klonkt op twee plekken die door
    6 // verschillende routes werden gevuld: ap_timeline.liked/boosted (web-tijdlijn)
    7 // en ap_my_reactions (interact-pagina). Sinds fase 1 schrijft setReaction ze
    8 // allebei, maar alles van VOOR die wijziging staat nog maar in een van de twee.
    9 //
    10 // Dit script vult de tussentabel aan met wat alleen in de kolommen staat. Het
    11 // moet draaien VOORDAT de lezers naar de tussentabel wijzen (fase 2), anders
    12 // verdwijnen die reacties uit beeld -- stil, want een ontbrekende rij is geen
    13 // fout.
     5// Normaal hoef je dit NIET: migrateReactions() draait bij boot, één keer per
     6// REACTIONS_MIGRATION_VERSION-bump, net als de self-heal. Dit script is er om
     7// vooraf te kijken wat er zou gebeuren, of om het gericht op één instance te
     8// forceren.
    149//
    1510//     node scripts/backfill-reactions.mjs --dry-run
    16 //     node scripts/backfill-reactions.mjs
     11//     node scripts/backfill-reactions.mjs            # respecteert de versievlag
     12//     node scripts/backfill-reactions.mjs --force    # ook als de vlag al staat
    1713//
    18 // Veilig om opnieuw te draaien: INSERT OR IGNORE plus de UNIQUE op
    19 // (site_slug, target_uri, kind) maakt het idempotent.
     14// Bewust een schil om dezelfde functie die bij boot draait: twee implementaties
     15// van een migratie lopen uiteen, en dan repareert de ene wat de andere niet ziet.
    2016//
    21 // Wat het NIET kan: de oorspronkelijke reactiedatum herstellen. Wanneer je
    22 // reageerde is nergens vastgelegd, dus de backfill-rijen krijgen de datum van
    23 // nu. Dat is geen verlies dat te repareren valt, wel iets om te weten als je
    24 // ooit op created_at gaat sorteren.
     17// Wat het doet, en waarom allebei nodig is:
     18//
     19//   HERSLEUTELEN  De oude interact-route bewaarde de URI waarmee je binnenkwam,
     20//                 en de bookmarklet geeft de permalink door. Sinds de reacties
     21//                 op de canonieke object-URI gezocht worden, zouden die rijen
     22//                 wees zijn. De created_at reist mee.
     23//   AANVULLEN     Alles wat op oude code via de Krant is gegeven staat alleen in
     24//                 ap_timeline.liked/boosted. Zonder deze stap toont het als
     25//                 niet-gereageerd -- en klikt iemand opnieuw, met een tweede
     26//                 Like de fediverse in als gevolg.
     27//
     28// Wat het NIET kan: bij AANVULLEN de oorspronkelijke reactiedatum herstellen.
     29// Wanneer je reageerde is nergens vastgelegd, dus die rijen krijgen de datum van
     30// nu. Bij hersleutelen blijft de datum wel behouden.
    2531
    2632import db from '../src/config/database.js';
     33import { migrateReactions } from '../src/services/ActivityPubService.js';
    2734
    2835const dryRun = process.argv.includes('--dry-run');
     36const force = process.argv.includes('--force');
    2937
    30 const scheef = (kind, kolom) => db.prepare(`
    31   SELECT t.slug, t.id FROM ap_timeline t
    32    WHERE t.${kolom} = 1
    33      AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
    34                       WHERE r.site_slug = t.slug AND r.target_uri = t.id AND r.kind = ?)
    35 `).all(kind);
    36 
    37 const voor = {
     38const meet = () => ({
    3839  tussentabel: db.prepare('SELECT COUNT(*) AS n FROM ap_my_reactions').get().n,
    3940  liked: db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE liked = 1').get().n,
    4041  boosted: db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE boosted = 1').get().n,
    41 };
    42 const teDoen = { like: scheef('like', 'liked'), boost: scheef('boost', 'boosted') };
    43 
    44 console.log('vooraf :', JSON.stringify(voor));
    45 console.log('aan te vullen: like =', teDoen.like.length, ', boost =', teDoen.boost.length);
    46 
    47 if (dryRun) {
    48   for (const [kind, rijen] of Object.entries(teDoen)) {
    49     for (const r of rijen.slice(0, 10)) console.log(`  [dry-run] ${kind}  ${r.slug}  ${r.id}`);
    50     if (rijen.length > 10) console.log(`  ... en nog ${rijen.length - 10}`);
    51   }
    52   console.log('\n--dry-run: niets geschreven.');
    53   process.exit(0);
    54 }
    55 
    56 const ins = db.prepare('INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind) VALUES (?,?,?)');
    57 let toegevoegd = 0;
    58 db.transaction(() => {
    59   for (const [kind, rijen] of Object.entries(teDoen)) {
    60     for (const r of rijen) toegevoegd += ins.run(r.slug, r.id, kind).changes;
    61   }
    62 })();
    63 
    64 const na = {
    65   tussentabel: db.prepare('SELECT COUNT(*) AS n FROM ap_my_reactions').get().n,
    6642  scheef: db.prepare(`
    6743    SELECT COUNT(*) AS n FROM ap_timeline t
     
    6945       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
    7046                        WHERE r.site_slug = t.slug AND r.target_uri = t.id)`).get().n,
    71 };
     47  wees: db.prepare(`
     48    SELECT COUNT(*) AS n FROM ap_my_reactions r
     49     WHERE NOT EXISTS (SELECT 1 FROM ap_timeline t
     50                        WHERE t.slug = r.site_slug AND t.id = r.target_uri)`).get().n,
     51});
    7252
    73 console.log('toegevoegd:', toegevoegd);
    74 console.log('achteraf  :', JSON.stringify(na));
     53const voor = meet();
     54console.log('vooraf :', JSON.stringify(voor));
    7555
    76 // De controle die telt: is er nog een vlag zonder tegenhanger? Zo ja, dan is de
    77 // tussentabel nog niet veilig als bron en mag fase 2 niet.
     56const uit = migrateReactions({ dryRun, force: force || dryRun });
     57if (uit.overgeslagen) {
     58  console.log('\novergeslagen: de versievlag staat al. Gebruik --force om toch te draaien.');
     59  process.exit(0);
     60}
     61if (dryRun) {
     62  console.log(`\n--dry-run: zou ${uit.hersleuteld} rij(en) hersleutelen en ${uit.aangevuld} aanvullen. Niets geschreven.`);
     63  process.exit(0);
     64}
     65
     66const na = meet();
     67console.log('hersleuteld:', uit.hersleuteld, ' aangevuld:', uit.aangevuld);
     68console.log('achteraf   :', JSON.stringify(na));
     69
     70// De twee controles die tellen. Blijft er een vlag zonder tegenhanger, dan is de
     71// tussentabel niet compleet en tonen reacties als niet-gegeven. Blijft er een
     72// tussentabel-rij zonder tijdlijnrij, dan is die op iets buiten je tijdlijn
     73// gericht (legitiem) OF nog op een permalink (niet legitiem) -- vandaar de
     74// waarschuwing in plaats van een fout.
    7875if (na.scheef !== 0) {
    79   console.error(`\nLET OP: nog ${na.scheef} rij(en) met een vlag zonder tegenhanger. Fase 2 mag NIET.`);
     76  console.error(`\nFOUT: nog ${na.scheef} rij(en) met een vlag zonder tegenhanger.`);
    8077  process.exit(1);
    8178}
    82 if (toegevoegd !== teDoen.like.length + teDoen.boost.length) {
    83   console.error('\nLET OP: het aantal toegevoegde rijen wijkt af van de meting vooraf.');
     79if (na.wees > voor.wees) {
     80  console.error('\nFOUT: er zijn tussentabel-rijen bijgekomen die nergens op slaan.');
    8481  process.exit(1);
    8582}
    86 console.log('\nOK: elke vlag heeft nu een tegenhanger in de tussentabel.');
     83if (na.wees) {
     84  console.warn(`\nLET OP: ${na.wees} tussentabel-rij(en) zonder tijdlijnrij. Dat mag (een reactie op iets\n`
     85    + 'buiten je tijdlijn), maar controleer of er geen permalinks tussen zitten van een post die je\n'
     86    + 'wel kent -- die zouden hersleuteld moeten zijn.');
     87}
     88console.log('\nOK: elke vlag heeft een tegenhanger in de tussentabel.');
  • src/server.js

    r6bc5ebd r7842ca1  
    6565import apRoutes from './routes/activitypub.js';
    6666import oauthRoutes from './routes/oauth.js';
    67 import { apWants, startDeliveryWorker, selfHealTimeline } from './services/ActivityPubService.js';
     67import { apWants, startDeliveryWorker, selfHealTimeline, migrateReactions } from './services/ActivityPubService.js';
    6868
    6969// SESSION_SECRET: use the env var if set. Otherwise auto-generate a strong one
     
    197197startScheduler(); // release planning: publish scheduled posts when publish_at is reached
    198198startDeliveryWorker(); // retry failed fediverse deliveries with backoff
     199// Once per REACTIONS_MIGRATION_VERSION bump: reacties naar de tussentabel, onder
     200// de canonieke object-URI. Moet VOOR het serveren, want vanaf nu leest de code
     201// die tabel -- draait hij niet, dan tonen oude likes als niet-gegeven.
     202migrateReactions();
    199203selfHealTimeline(); // once per SELFHEAL_VERSION bump: re-sync the fediverse cache (covers/edits) after a drastic update
    200204
  • src/services/ActivityPubService.js

    r6bc5ebd r7842ca1  
    34383438 * werk voor fase 2, mét datamigratie.
    34393439 */
     3440// Reactie-migratie (shaer-9e9). Draait bij boot, EEN keer per bump, net als
     3441// selfHealTimeline. Bewust automatisch: klonkt-update tilt een hele vloot in een
     3442// stap naar nieuwe code, en een handmatig script per instance wordt vergeten --
     3443// terwijl het falen stil is (een reactie die niemand meer ziet geeft geen fout).
     3444const REACTIONS_MIGRATION_VERSION = 1;
     3445
     3446/**
     3447 * Brengt alle reacties naar de tussentabel, onder de canonieke object-URI.
     3448 *
     3449 * Twee stappen, en ze zijn allebei nodig:
     3450 *
     3451 *  1. HERSLEUTELEN. De oude interact-route bewaarde de URI waarmee je binnenkwam
     3452 *     en de bookmarklet geeft window.location.href door, dus de permalink. Sinds
     3453 *     canonicalReactionUri wordt er op de object-URI gezocht, waardoor die rijen
     3454 *     wees zouden zijn. De created_at reist mee: bij hersleutelen weten we
     3455 *     wanneer je reageerde, bij aanvullen niet.
     3456 *  2. AANVULLEN vanuit de afgeleide kolommen. Alles wat op oude code via de
     3457 *     Krant is gegeven staat alleen daar; zonder deze stap toont het als
     3458 *     niet-gereageerd en klikt een gebruiker opnieuw -- met een tweede Like de
     3459 *     fediverse in als gevolg.
     3460 *
     3461 * Idempotent. Geeft terug wat er gebeurd is, zodat het script het kan tonen.
     3462 */
     3463export function migrateReactions(opts = {}) {
     3464  const uit = { hersleuteld: 0, aangevuld: 0, overgeslagen: false };
     3465  try {
     3466    if (!opts.force) {
     3467      const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('reactions_migration_version');
     3468      const cur = r ? (parseInt(r.value, 10) || 0) : 0;
     3469      if (cur >= REACTIONS_MIGRATION_VERSION) { uit.overgeslagen = true; return uit; }
     3470    }
     3471  } catch { return uit; }   // geen app_settings → deze database is te oud om aan te raken
     3472
     3473  // Een rij die NIET op een tijdlijn-id staat maar wel op een tijdlijn-url.
     3474  const wees = `
     3475    FROM ap_my_reactions r JOIN ap_timeline t ON t.slug = r.site_slug AND t.url = r.target_uri
     3476     WHERE NOT EXISTS (SELECT 1 FROM ap_timeline t2 WHERE t2.slug = r.site_slug AND t2.id = r.target_uri)`;
     3477  const scheef = (kind, kolom) => `
     3478    FROM ap_timeline t
     3479     WHERE t.${kolom} = 1
     3480       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
     3481                        WHERE r.site_slug = t.slug AND r.target_uri = t.id AND r.kind = '${kind}')`;
     3482
     3483  if (opts.dryRun) {
     3484    try {
     3485      uit.hersleuteld = db.prepare(`SELECT COUNT(*) AS n ${wees}`).get().n;
     3486      uit.aangevuld = db.prepare(`SELECT COUNT(*) AS n ${scheef('like', 'liked')}`).get().n
     3487                    + db.prepare(`SELECT COUNT(*) AS n ${scheef('boost', 'boosted')}`).get().n;
     3488    } catch { /* laat de nullen staan */ }
     3489    return uit;
     3490  }
     3491
     3492  try {
     3493    db.transaction(() => {
     3494      // 1. Hersleutelen: eerst de canonieke variant erbij, dan de permalink weg.
     3495      //    In die volgorde, zodat een onderbreking hooguit een dubbele rij
     3496      //    oplevert en nooit een verdwenen reactie.
     3497      uit.hersleuteld = db.prepare(`
     3498        INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind, created_at)
     3499        SELECT r.site_slug, t.id, r.kind, r.created_at ${wees}`).run().changes;
     3500      db.prepare(`DELETE FROM ap_my_reactions WHERE rowid IN (SELECT r.rowid ${wees})`).run();
     3501
     3502      // 2. Aanvullen vanuit de kolommen.
     3503      for (const [kind, kolom] of [['like', 'liked'], ['boost', 'boosted']]) {
     3504        uit.aangevuld += db.prepare(`
     3505          INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
     3506          SELECT t.slug, t.id, '${kind}' ${scheef(kind, kolom)}`).run().changes;
     3507      }
     3508    })();
     3509    if (uit.hersleuteld || uit.aangevuld) {
     3510      console.log(`[AP] reactie-migratie v${REACTIONS_MIGRATION_VERSION}: ${uit.hersleuteld} hersleuteld, ${uit.aangevuld} aangevuld`);
     3511    }
     3512    if (!opts.force) {
     3513      db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
     3514        .run('reactions_migration_version', String(REACTIONS_MIGRATION_VERSION));
     3515    }
     3516  } catch (e) {
     3517    // Niet fataal: de kolommen staan er nog, dus de oude waarheid is niet weg.
     3518    // Een volgende boot probeert het opnieuw, want de versie is niet gezet.
     3519    console.warn('[AP] reactie-migratie mislukt:', e.message);
     3520  }
     3521  return uit;
     3522}
     3523
    34403524/**
    34413525 * Van wat de client stuurde naar de canonieke sleutel voor een reactie.
     
    48884972  gateOutgoingFollow, performApprovedFollow,
    48894973  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
    4890   autoBoostCount, boostedCount, setReaction, getReaction, getReactionsFor, canonicalReactionUri, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
     4974  autoBoostCount, boostedCount, setReaction, getReaction, getReactionsFor, canonicalReactionUri, migrateReactions, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
    48914975  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
    48924976  deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
  • test/reactions-characterization.test.js

    r6bc5ebd r7842ca1  
    313313  assert.equal(AP.getReaction('me', onbekend).liked, true);
    314314});
     315
     316// ── De migratie bij boot ─────────────────────────────────────────────────
     317
     318test('migrateReactions hersleutelt een permalink-rij naar de object-URI', () => {
     319  // De oude interact-route bewaarde de URI waarmee je binnenkwam, en de
     320  // bookmarklet geeft de permalink door. Zonder hersleutelen zijn die rijen
     321  // wees zodra er op de object-URI gezocht wordt.
     322  const obj = uri('mig1');
     323  const permalink = 'https://sound-fabrics.com/oud-artikel';
     324  db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, content, url, created_at)
     325              VALUES (?,?,?,?,?,?,?)`)
     326    .run(obj, 'me', 'https://r.test/users/anna', 'Anna', '<p>x</p>', permalink, '2026-08-06 09:00:00');
     327  db.prepare("INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind, created_at) VALUES (?,?,?,?)")
     328    .run('me', permalink, 'like', '2026-07-01 12:00:00');
     329
     330  const uitkomst = AP.migrateReactions({ force: true });
     331  assert.ok(uitkomst.hersleuteld >= 1);
     332  assert.equal(AP.getReaction('me', obj).liked, true, 'nu vindbaar op de object-uri');
     333  const rij = db.prepare('SELECT created_at FROM ap_my_reactions WHERE site_slug=? AND target_uri=? AND kind=?').get('me', obj, 'like');
     334  assert.equal(rij.created_at, '2026-07-01 12:00:00', 'bij hersleutelen blijft de oorspronkelijke datum staan');
     335  const oud = db.prepare('SELECT COUNT(*) AS n FROM ap_my_reactions WHERE target_uri=?').get(permalink).n;
     336  assert.equal(oud, 0, 'de permalink-rij is opgeruimd');
     337});
     338
     339test('migrateReactions vult een kale kolomvlag aan', () => {
     340  const u = uri('mig2'); seedTimeline(u);
     341  AP.markLiked('me', u);                                  // zoals de oude Krant-route
     342  assert.equal(AP.getReaction('me', u).liked, false, 'vooraf onzichtbaar');
     343  AP.migrateReactions({ force: true });
     344  assert.equal(AP.getReaction('me', u).liked, true, 'daarna zichtbaar');
     345});
     346
     347test('migrateReactions is idempotent en respecteert de versievlag', () => {
     348  const eerste = AP.migrateReactions({ force: true });
     349  const tweede = AP.migrateReactions({ force: true });
     350  assert.equal(tweede.hersleuteld, 0, 'niets meer te hersleutelen');
     351  assert.equal(tweede.aangevuld, 0, 'niets meer aan te vullen');
     352  assert.ok(eerste.hersleuteld >= 0);
     353  // Zonder force draait hij niet nog eens zodra de vlag staat.
     354  db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?,?)').run('reactions_migration_version', '1');
     355  assert.equal(AP.migrateReactions().overgeslagen, true);
     356});
     357
     358test('migrateReactions --dry-run schrijft niets', () => {
     359  const u = uri('mig3'); seedTimeline(u);
     360  AP.markLiked('me', u);
     361  const telling = AP.migrateReactions({ force: true, dryRun: true });
     362  assert.ok(telling.aangevuld >= 1, 'hij ziet wel wat er te doen is');
     363  assert.equal(AP.getReaction('me', u).liked, false, 'maar heeft niets geschreven');
     364});
Note: See TracChangeset for help on using the changeset viewer.