Index: src/services/MigrationService.js
===================================================================
--- src/services/MigrationService.js	(revision 47f118301f52a4a219d9668d6650ac9d77a0e128)
+++ src/services/MigrationService.js	(revision 7e474f7d0992ca0db57522140a4481a44568fe75)
@@ -36,5 +36,5 @@
 import crypto from 'crypto';
 import db from '../config/database.js';
-import { AP_CONTEXT, actorId, pagedCollection } from './ap-core.js';
+import { AP_CONTEXT, actorId, pagedCollection, PAGINA_GROOTTE } from './ap-core.js';
 
 // ── Vertaaltabel ──────────────────────────────────────────────────
@@ -66,9 +66,15 @@
  * de URIs van je fan-only posts opsomt is een lek, ook zonder de inhoud.
  */
-export function migrationItems(slug, { alles = false } = {}) {
+export function migrationItems(slug, { alles = false, limit = null, offset = 0 } = {}) {
   try {
+    // IN SQL pagineren, niet in geheugen. Dit is een PUBLIEK endpoint dat
+    // derden volgens FEP-1580 juist herhaaldelijk ophalen tot migrationComplete
+    // waar is. Alles laden om er twintig te tonen is dan geen inefficientie
+    // maar een hefboom: bij honderdduizend berichten bouwt elke poll
+    // honderdduizend objecten die meteen de prullenbak in gaan.
     const sql = `SELECT origin, target, source_actor FROM ap_migration
-                 WHERE slug = ?${alles ? '' : ' AND is_public = 1'} ORDER BY id DESC`;
-    return db.prepare(sql).all(slug);
+                 WHERE slug = ?${alles ? '' : ' AND is_public = 1'}
+                 ORDER BY id DESC${limit ? ' LIMIT ? OFFSET ?' : ''}`;
+    return limit ? db.prepare(sql).all(slug, limit, offset) : db.prepare(sql).all(slug);
   } catch { return []; }
 }
@@ -151,5 +157,10 @@
   const me = actorId(base, site.slug);
   const id = `${me}/migration`;
-  const rows = migrationItems(site.slug, { alles });
+  const totaal = migrationCount(site.slug, { alles });
+  // Zonder pagina: alleen de omslag met eerste/laatste en de telling. Zo hoeft
+  // de kale collectie geen enkele rij aan te raken, en dat is precies wat een
+  // consument als eerste opvraagt.
+  const nr = page ? Math.max(1, Math.floor(Number(page)) || 1) : false;
+  const rows = nr ? migrationItems(site.slug, { alles, limit: PAGINA_GROOTTE, offset: (nr - 1) * PAGINA_GROOTTE }) : [];
   const items = rows.map((r) => ({
     type: 'Move',
@@ -159,5 +170,7 @@
   }));
   return pagedCollection(id, items, {
-    page,
+    page: nr,
+    totalItems: totaal,
+    alGesneden: true,
     extra: {
       attributedTo: me,
Index: test/fep1580-migration.test.js
===================================================================
--- test/fep1580-migration.test.js	(revision 47f118301f52a4a219d9668d6650ac9d77a0e128)
+++ test/fep1580-migration.test.js	(revision 7e474f7d0992ca0db57522140a4481a44568fe75)
@@ -149,10 +149,42 @@
   Mig.recordMigrated('ik', { origin: `${BRON}/notes/1`, target: `${IK}/notes/a`, sourceActor: BRON });
   Mig.recordMigrated('ik', { origin: `${BRON}/notes/2`, target: `${IK}/notes/b`, sourceActor: BRON });
-  const coll = Mig.buildMigration('https://nieuw.example', s);
+  // De KALE collectie draagt alleen de telling en de wegwijzers. Bewust: dit is
+  // een publiek endpoint dat derden herhaaldelijk pollen, en alle rijen laden om
+  // er twintig te tonen is bij honderdduizend berichten een hefboom. Bovendien
+  // was een kale collectie MET items precies de valstrik waar onze eigen ingest
+  // in liep: hij zag items, sloeg `first` over en miste de helft.
+  const kaal = Mig.buildMigration('https://nieuw.example', s);
+  assert.equal(kaal.totalItems, 2, 'de telling staat er wel');
+  assert.equal((kaal.orderedItems || []).length, 0, 'maar de rijen niet');
+  assert.ok(kaal.first, 'wie ze wil volgt first');
+
+  const coll = Mig.buildMigration('https://nieuw.example', s, { page: 1 });
   assert.equal(coll.totalItems, 2);
   const items = coll.orderedItems || coll.items;
+  assert.equal(items.length, 2);
   assert.equal(items[0].origin, `${BRON}/notes/2`, 'omgekeerd chronologisch op aanmaakmoment HIER');
   assert.equal(items[0].type, 'Move');
   assert.equal(items[0].target, `${IK}/notes/b`);
+});
+
+test('de publieke vertaaltabel laadt niet alles om twintig te tonen', () => {
+  // Robins herinnering: honderd miljoen Klonkt-gebruikers. Dit endpoint is
+  // publiek en derden pollen het tot migrationComplete waar is, dus een query
+  // zonder LIMIT is hier geen inefficientie maar een hefboom.
+  const s = site();
+  for (let i = 0; i < 45; i++) {
+    Mig.recordMigrated('ik', { origin: `${BRON}/notes/n${i}`, target: `${IK}/notes/n${i}` });
+  }
+  const p1 = Mig.buildMigration('https://nieuw.example', s, { page: 1 });
+  assert.equal(p1.totalItems, 45, 'de telling klopt over het geheel');
+  assert.equal(p1.orderedItems.length, 20, 'maar er komt een PAGINA uit, geen bak van 45');
+  assert.ok(p1.next, 'en er is een volgende');
+  const p3 = Mig.buildMigration('https://nieuw.example', s, { page: 3 });
+  assert.equal(p3.orderedItems.length, 5, 'de laatste pagina is de rest');
+  assert.equal(p3.next, undefined, 'en die biedt geen volgende meer aan');
+  // De pagina's overlappen niet en samen zijn ze het geheel.
+  const p2 = Mig.buildMigration('https://nieuw.example', s, { page: 2 });
+  const alle = [...p1.orderedItems, ...p2.orderedItems, ...p3.orderedItems].map((x) => x.origin);
+  assert.equal(new Set(alle).size, 45, 'geen dubbele en niets kwijt');
 });
 
