Artikel Testning · Databas

Sida två tappar poster: stabil cursorpaginering i AI-genererad kod

Listendpointen ser rätt ut med tio testposter. I drift har flera poster samma tidsstämpel, en ny rad landar mellan två anrop och plötsligt syns en post två gånger medan en annan aldrig syns. Felet sitter inte i JSON-svaret utan i ordningen, sidgränsen och testdatan som aldrig pressade dem.

Senast granskad: 26 aug 2026

En ljus punktprofil av en apa i vänsterkant betraktar en andra nodprofil ovanför två mörka brickor med rader av små plattor, där några lyser syragrönt.
Två på varandra följande sidor hänger bara ihop när sorteringen är unik hela vägen ner.

Kortfattat

En cursor är bara så stabil som den ordning den pekar in i. Sorterar du enbart på created_at kan tio poster med samma tidsstämpel byta inbördes plats. Sorterar du på created_at DESC, id DESC blir ordningen unik, och samma två värden måste finnas både i cursorn och i nästa sidas WHERE-villkor. Testet ska hämta sida ett, lägga till och ta bort poster och först därefter hämta sida två. Då mäter du den egenskap som användaren märker: ingen post från den ursprungliga fortsättningen tappas eller dubbleras.

Offset räknar positioner som hinner flytta sig

Den första AI-genererade versionen brukar använda LIMIT och OFFSET. PostgreSQL beskriver OFFSET bokstavligt: databasen hoppar över ett antal rader innan den börjar returnera resultat [1]. Det låter som en sida, men offseten identifierar ingen post. Den säger bara ”hoppa över tre rader i resultatet som det ser ut just nu”.

async function listaPoster(db, sida) {
  const sidstorlek = 3;
  return db.query(`
    SELECT id, created_at, rubrik
    FROM posts
    ORDER BY created_at DESC
    LIMIT $1 OFFSET $2
  `, [sidstorlek, sida * sidstorlek]);
}

Anta att sida ett returnerar id 106, 105, 104. Innan klienten hämtar sida två tas 105 bort. Resultatet har nu bara två rader framför 103, men fråga två hoppar fortfarande över tre. 103 tappas. Om en ny post i stället läggs överst flyttas den gamla tredje raden till position fyra och visas igen på sida två.

Det finns ett andra fel i samma kod: created_at är inte nödvändigtvis unikt. PostgreSQL rekommenderar en ordning som gör resultatet unikt när LIMIT används; annars kan delmängden bli oförutsägbar, och olika kombinationer av LIMIT och OFFSET kan ge inkonsekventa resultat [1]. En tidsstämpel räcker därför inte som sidgräns när flera rader kan dela värdet.


Gör ordningen unik innan du bygger cursorn

Rättningen börjar i SQL, inte i base64-kodningen. Lägg en unik och oföränderlig skiljekolumn sist i sorteringen. Ett primärnyckels-id fungerar när högre id betyder senare placering inom samma tidsstämpel:

async function listaPoster(db, cursor) {
  const sidstorlek = 3;
  const grans = cursor ? avkodaCursor(cursor) : null;
  const resultat = await db.query(`
    SELECT id, created_at, rubrik
    FROM posts
    WHERE ($1::timestamptz IS NULL)
       OR (created_at, id) < ($1, $2)
    ORDER BY created_at DESC, id DESC
    LIMIT $3
  `, [grans?.createdAt ?? null, grans?.id ?? null, sidstorlek + 1]);

  const harFler = resultat.rows.length > sidstorlek;
  const poster = resultat.rows.slice(0, sidstorlek);
  const sista = poster.at(-1);

  return {
    poster,
    nextCursor: harFler ? kodaCursor({
      createdAt: sista.created_at,
      id: sista.id,
    }) : null,
  };
}

Villkoret och sorteringen är ett par. Med fallande ordning betyder ”efter cursorn” att tuple-värdet ska vara mindre än cursorns tuple. PostgreSQLs radjämförelse går från vänster till höger och stannar vid det första värdeparet som skiljer sig [2]. Därför betyder uttrycket i praktiken: tidigare tidsstämpel, eller samma tidsstämpel och lägre id.

Att hämta sidstorlek + 1 rader skiljer två frågor åt: vilka tre poster ska visas, och finns det minst en fortsättning? Den extra raden skickas inte till klienten. Cursorn byggs från den sista raden som faktiskt skickas, inte från tjuvtitten på rad fyra.

Samma kolumner överallt

En modell kan mycket väl skriva ORDER BY created_at DESC, id DESC men bara lägga created_at i cursorn. Då är SQL-resultatet sorterat men sidgränsen fortfarande tvetydig. Granska tre ställen som ett kontrakt: ORDER BY, cursorinnehållet och tuple-villkoret i WHERE. Kolumnerna, ordningen och riktningen ska motsvara varandra.


Gör API-cursorn ogenomskinlig, men inte magisk

Klienten behöver inte veta att sidgränsen består av en tidsstämpel och ett id. Den ska bara lagra strängen från nextCursor och skicka tillbaka den oförändrad. En enkel implementation kan serialisera ett versionsmärkt objekt som base64url:

function kodaCursor({ createdAt, id }) {
  return Buffer.from(JSON.stringify({
    v: 1,
    createdAt: new Date(createdAt).toISOString(),
    id: String(id),
  })).toString("base64url");
}

function avkodaCursor(cursor) {
  const data = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
  if (data.v !== 1 || !data.createdAt || !/^\d+$/.test(data.id)) {
    throw new Error("Ogiltig cursor");
  }
  return data;
}

”Ogenomskinlig” betyder här att formatet är ett serverkontrakt, inte att värdet är hemligt eller manipulationssäkert. Avkodningen måste fortfarande validera version, datum och id och svara med ett kontrollerat klientfel när cursorn är trasig. Versionsfältet gör det möjligt att senare byta sorteringsnyckel utan att tolka gamla strängar som om de hade det nya formatet.

Undvik också att sortera på ett fält som normalt ändras. Om updated_at flyttas efter att sida ett hämtats kan posten legitimt byta plats i ordningen och hamna på andra sidan cursorn. Cursorpaginering ger en stabil gräns i en bestämd ordning; den skapar inte en frusen ögonblicksbild över flera HTTP-anrop. PostgreSQLs standardnivå Read Committed tar en ny ögonblicksbild för varje SELECT, så två efterföljande frågor kan se olika data när andra transaktioner hinner committa mellan dem [3]. Behöver produkten en historiskt frusen export krävs ett annat kontrakt, till exempel en explicit övre tidsgräns eller en materialiserad resultatuppsättning.


Testa sidgränsen medan datan ändras

Ett test som hämtar sida ett och två ur oförändrad fixture bevisar för lite. Lägg först minst fyra poster på samma tidsstämpel så att id:t verkligen avgör en sidgräns. Mutera sedan datan mellan anropen på precis det sätt som får offset att gå sönder:

test("tappar eller dubblerar inte poster mellan sida ett och två", async () => {
  const tid = "2026-08-26T10:00:00.000Z";
  await skapaPoster([
    { id: 106, createdAt: tid },
    { id: 105, createdAt: tid },
    { id: 104, createdAt: tid },
    { id: 103, createdAt: tid },
    { id: 102, createdAt: tid },
    { id: 101, createdAt: "2026-08-26T09:00:00.000Z" },
  ]);

  const sidaEtt = await hamtaPoster();
  expect(sidaEtt.poster.map(p => p.id)).toEqual([106, 105, 104]);

  await skapaPost({ id: 107, createdAt: tid });
  await taBortPost(105);

  const sidaTva = await hamtaPoster(sidaEtt.nextCursor);
  expect(sidaTva.poster.map(p => p.id)).toEqual([103, 102, 101]);
  expect(gemensammaId(sidaEtt.poster, sidaTva.poster)).toEqual([]);
});

Insättningen med 107 ligger före cursorn och ska inte skjuta in någon redan visad rad på sida två. Borttagningen av 105 ska inte få nästa fråga att hoppa över 103. Fyra rader med samma tidsstämpel gör dessutom att testet fäller en implementation som glömmer id:t i cursorvillkoret.

Lägg till tre mindre kontraktsprov runt huvudfallet: en trasig cursor ger klientfel i stället för serverfel, sista sidan ger nextCursor: null, och en tom tabell ger en tom lista. Om endpointen stöder filter ska cursorn antingen bindas till samma filter eller dokumenteras som ogiltig när filtret ändras. Annars testar du en ordning och fortsätter i en annan.


Fem frågor till den AI-genererade diffen

Är hela sorteringen unik? Ett datum, ett statusfält eller ett avrundat poängtal behöver normalt en unik skiljekolumn.
Matchar cursor, ORDER BY och WHERE varandra? Samma kolumner ska komma i samma ordning, med jämförelseoperator som passar riktningen.
Byggs nästa cursor från sista returnerade rad? Inte från den extra raden som bara visar att fler poster finns.
Ändras data mellan sidhämtningarna i testet? Annars provas inte det produktionsfel som motiverar cursorpagineringen.
Kan sorteringsfältet flytta en befintlig post? Om ja behöver API-kontraktet beskriva den avvägningen eller välja en stabilare nyckel.

Det här testet kompletterar den generella teststrategin för AI-genererad kod: här är gränsen mellan två svar själva testobjektet. Och om listan samtidigt gör en relationsfråga per rad är det en annan felklass; den fångas av frågeräknaren för N+1, inte av paginationstestet. Håll kontrollerna separata så att en misslyckad assertion säger om raden saknas, ligger dubbelt eller bara var dyr att hämta.


Källor

[1] PostgreSQL 18, SELECT — hur LIMIT och OFFSET väljer delmängder samt kravet på en förutsägbar, unik ordning. [2] PostgreSQL 18, Row and Array Comparisons — hur radkonstruktorer jämförs fält för fält från vänster. [3] PostgreSQL 18, Transaction Isolation — Read Committed som standard och att efterföljande SELECT-frågor kan se olika committad data. Samtliga källor lästa 26 augusti 2026. SQL- och JavaScript-exemplen är avgränsade implementationsexempel; de är inte mätvärden eller fullständiga ramverksintegrationer.