Changeset 8455dac in Klonkt


Ignore:
Timestamp:
08/13/2026 01:05:47 PM (4 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
a117862
Parents:
783b9ff
Message:

De outbox bladert nu echt door (shaer-sk4)

Wat er stond was geen paginering maar een KAP: de route haalde twintig posts uit
SQL en hield daar twintig items van over. Alles daarvoor lag niet op een volgende
pagina maar was helemaal onbereikbaar -- ook voor een lezer die netjes next
volgt.

DE MOEILIJKHEID, en waarom dit niet met een tweede slice kon: de outbox mengt
twee bronnen, posts en open tracks, gevlochten op datum. Met twee losse queries
en een offset per tabel weet je niet hoeveel van elk er in pagina drie horen.
outboxSlice doet daarom een UNION met de datum als sleutel, zet de LIMIT/OFFSET
daaroverheen, en haalt pas dan de rijen zelf op -- twee kleine queries voor de
ids van die ene pagina.

De fans-only voorwaarde zit in de UNION, dus de telling klopt per lezer: een
vreemde ziet 50 en een vriend 51, en niet allebei hetzelfde getal met een andere
lijst eronder.

pagedCollection kent nu alGesneden. Zonder dat zou een volle pagina zichzelf
als de enige zien -- hij telt het aantal pagina's uit de LIJST, en die is bij
SQL-paginering precies een pagina lang. Nu telt hij uit totalItems en biedt hij
een next zolang er meer is.

TWEE DINGEN DIE IK BIJNA VERKEERD DEED. outboxSlice en PAGINA_GROOTTE stonden
niet in de DEFAULT export, en de routes importeren AP zo -- dan is het undefined
bij het VERZOEK, niet bij het importeren. Nagelopen op de server: function,
number. En mijn eigen testdata maakte "2026-01-45" voor de 45e post; dat is geen
datum, en toISOString viel er terecht over om.

Zes tests, waaronder de kern: opeenvolgende pagina's overlappen niet en laten
niets vallen -- drie pagina's dekken alle 50, en niets twee keer.

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

Files:
1 added
4 edited

Legend:

Unmodified
Added
Removed
  • src/routes/activitypub.js

    r783b9ff r8455dac  
    197197    return AP.sendAP(res, AP.buildOutbox(baseUrl(req), site, [], [], { page: paginaNr(req) }), 'private, no-store');
    198198  }
    199   const fanClause = audience === 'friend' ? '' : "AND (fan_only IS NULL OR fan_only = 0)";
    200   const posts = db.prepare(
    201     `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, c2s_attachments, quote_json, embed_json, published_at, created_at
    202      FROM posts WHERE site_id = ? AND status = 'published' ${fanClause}
    203      ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
    204   ).all(site.id);
     199  // ECHT DOORBLADEREN (shaer-sk4). Hier stonden twintig posts uit SQL met een
     200  // tweede kap van twintig eroverheen: alles daarvoor was niet op een volgende
     201  // pagina maar helemaal onbereikbaar. outboxSlice pagineert over de UNION van
     202  // posts en tracks, want die vlechten op datum en zijn met twee losse queries
     203  // niet te offsetten.
     204  //
    205205  // De tracks gaan mee voor iedereen die de deur door mag; de blocked-tak
    206206  // hierboven levert bewust een outbox ZONDER posts en zonder tracks.
    207   const ob = AP.buildOutbox(baseUrl(req), site, posts, AP.siteOpenTracks(site.id), { page: paginaNr(req) });
     207  const nr = paginaNr(req);
     208  const { posts, tracks, totaal } = AP.outboxSlice(site.id, {
     209    fanOnly: audience === 'friend',
     210    offset: (Math.max(1, nr || 1) - 1) * AP.PAGINA_GROOTTE,
     211    limit: AP.PAGINA_GROOTTE,
     212  });
     213  const ob = AP.buildOutbox(baseUrl(req), site, posts, tracks, { page: nr, totalItems: totaal, alGesneden: true });
    208214  if (audience === 'friend') {
    209215    // The owner's app builds its feed from this leg, and every note here is
  • src/services/ActivityPubService.js

    r783b9ff r8455dac  
    2929import Blocklist from './BlocklistService.js';
    3030import * as Guardianship from './guardianship/index.js';
    31 import { PUBLIC, AP_CONTEXT, safeUrl, actorId, noteId, guessMediaType, normalizeTags, tagParts, hashtagTags, buildHashtagList, pagedCollection } from './ap-core.js';
     31import { PUBLIC, AP_CONTEXT, safeUrl, actorId, noteId, guessMediaType, normalizeTags, tagParts, hashtagTags, buildHashtagList, pagedCollection, PAGINA_GROOTTE } from './ap-core.js';
    3232// Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken.
    3333export { AP_CONTEXT, actorId, noteId, guessMediaType };
    3434// De muziekkant woont in music/ (shaer-drc). Doorgeven wat hier altijd
    3535// vandaan kwam, zodat elke bestaande aanroep blijft werken.
    36 import {
     36import { TRACK_KOLOMMEN,
    3737  playlistOpenTracks, siteOpenTracks, openTrack, trackHostPosts,
    3838  buildTrackAudio, buildTrackCollection, buildTrackCreate,
     
    10191019 * zijn eigen database bevraagt zou dwars door die deur heen leveren.
    10201020 */
    1021 export function buildOutbox(base, site, posts, tracks = [], { page = false } = {}) {
     1021/**
     1022 * Een PAGINA van de outbox, in SQL (shaer-sk4).
     1023 *
     1024 * De outbox mengt twee bronnen: posts en open tracks, gevlochten op datum. Een
     1025 * offset over die twee kan niet met twee losse queries -- je weet niet hoeveel
     1026 * van elk er in pagina drie horen. Vandaar een UNION met de datum als sleutel,
     1027 * daar de LIMIT/OFFSET overheen, en pas dan de rijen zelf ophalen.
     1028 *
     1029 * Wat er stond was geen paginering maar een KAP: de route haalde twintig posts
     1030 * en hield daarvan twintig items over. Alles daarvoor was niet op een volgende
     1031 * pagina maar helemaal onbereikbaar.
     1032 *
     1033 * @param {boolean} fanOnly  mag de lezer ook de fans-only posts zien?
     1034 */
     1035export function outboxSlice(siteId, { fanOnly = false, offset = 0, limit = MAX_OUTBOX } = {}) {
     1036  const fanClause = fanOnly ? '' : 'AND (p.fan_only IS NULL OR p.fan_only = 0)';
     1037  const unie = `
     1038    SELECT 'post' AS soort, p.id AS id, COALESCE(p.published_at, p.created_at) AS wanneer
     1039      FROM posts p WHERE p.site_id = ? AND p.status = 'published' ${fanClause}
     1040    UNION ALL
     1041    SELECT 'track', t.id, t.created_at
     1042      FROM audio_tracks t WHERE t.site_id = ? AND t.fedi_open = 1`;
     1043  let rijen = [], totaal = 0;
     1044  try {
     1045    totaal = db.prepare(`SELECT COUNT(*) n FROM (${unie})`).get(siteId, siteId).n;
     1046    rijen = db.prepare(`SELECT soort, id FROM (${unie}) ORDER BY wanneer DESC LIMIT ? OFFSET ?`)
     1047      .all(siteId, siteId, limit, Math.max(0, offset));
     1048  } catch { return { posts: [], tracks: [], totaal: 0 }; }
     1049
     1050  const postIds = rijen.filter((r) => r.soort === 'post').map((r) => r.id);
     1051  const trackIds = rijen.filter((r) => r.soort === 'track').map((r) => r.id);
     1052  const gaten = (n) => Array.from({ length: n }, () => '?').join(',');
     1053  const posts = postIds.length ? db.prepare(
     1054    `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning,
     1055            c2s_attachments, quote_json, embed_json, published_at, created_at
     1056       FROM posts WHERE id IN (${gaten(postIds.length)})`).all(...postIds) : [];
     1057  const tracks = trackIds.length ? db.prepare(
     1058    `SELECT ${TRACK_KOLOMMEN}
     1059       FROM audio_tracks t JOIN media m ON m.id = t.media_id
     1060      WHERE t.id IN (${gaten(trackIds.length)})`).all(...trackIds) : [];
     1061  return { posts, tracks, totaal };
     1062}
     1063
     1064export function buildOutbox(base, site, posts, tracks = [], { page = false, totalItems, alGesneden = false } = {}) {
    10221065  const id = `${actorId(base, site.slug)}/outbox`;
    10231066  const wanneer = (x) => Date.parse(x && x.published ? x.published : 0) || 0;
     
    10311074  ]
    10321075    .sort((a, b) => wanneer(b) - wanneer(a))
    1033     .slice(0, MAX_OUTBOX);
     1076    .slice(0, alGesneden ? Infinity : MAX_OUTBOX);
    10341077  // WAT HIER NOG NIET GEPAGINEERD IS, en dat hoort genoemd (shaer-sk4): deze
    10351078  // lijst is al door de route op twintig rijen afgekapt, dus pagina 2 is leeg.
     
    10501093  // wie hem vandaag leest hoort er morgen niet voor te hoeven pagineren. Er is
    10511094  // precies een pagina, dus first en last wijzen naar dezelfde.
    1052   return pagedCollection(id, items, { page });
     1095  return pagedCollection(id, items, { page, totalItems, alGesneden });
    10531096}
    10541097
     
    65286571export default {
    65296572  AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions, pagedCollection,
    6530   deriveHandle, localSlugOf,
     6573  deriveHandle, localSlugOf, outboxSlice, PAGINA_GROOTTE,
    65316574  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
    65326575  channelUrls, channelCategory, timelineFields, guessMediaType,
  • src/services/ap-core.js

    r783b9ff r8455dac  
    232232 * antwoord zijn dat over zichzelf liegt.
    233233 */
    234 export function pagedCollection(id, items, { totalItems, page = false, perPage = PAGINA_GROOTTE, extra = {} } = {}) {
     234export function pagedCollection(id, items, { totalItems, page = false, perPage = PAGINA_GROOTTE, alGesneden = false, extra = {} } = {}) {
    235235  const lijst = items || [];
    236236  const telling = totalItems === undefined ? lijst.length : totalItems;
    237237  const grootte = Math.max(1, Number(perPage) || PAGINA_GROOTTE);
    238   const paginas = Math.max(1, Math.ceil(lijst.length / grootte));
     238  // `alGesneden` voor wie in SQL al gepagineerd heeft (de outbox): dan is `lijst`
     239  // een PAGINA en zegt hij niets over het geheel, dus telt het aantal pagina's
     240  // uit `totalItems`. Zonder dat zou een volle pagina zichzelf als de enige zien
     241  // en nooit een `next` aanbieden.
     242  const paginas = Math.max(1, Math.ceil((alGesneden ? telling : lijst.length) / grootte));
    239243  const url = (n) => `${id}?page=${n}`;
    240244
    241245  if (page) {
    242246    const n = Math.max(1, Math.floor(Number(page)) || 1);
    243     const deel = lijst.slice((n - 1) * grootte, n * grootte);
     247    const deel = alGesneden ? lijst : lijst.slice((n - 1) * grootte, n * grootte);
    244248    return {
    245249      '@context': AP_CONTEXT,
  • src/services/music/index.js

    r783b9ff r8455dac  
    1818
    1919// m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes.
    20 const TRACK_KOLOMMEN = `t.id, t.title, t.artist, t.duration, t.cover_url, t.created_at,
     20export const TRACK_KOLOMMEN = `t.id, t.title, t.artist, t.duration, t.cover_url, t.created_at,
    2121     t.position, t.license,
    2222     m.filename, m.storage_path, m.mime_type, m.size`;
Note: See TracChangeset for help on using the changeset viewer.