{"id":"code-dev-sql-keyset-pagination","audience":"developers","category":"coding","track":"coding","technique":"constraints","title":"Convert OFFSET paging to keyset pagination","free":false,"task":"The activity feed's OFFSET paging gets slow and skips rows when new items arrive. Rewrite it as keyset pagination: the client sends the last row's created_at and id as $1 and $2, workspace_id moves to $3, and the page size is now 25. Use a row comparison so ties on created_at don't skip rows, and keep the same columns and the deleted filter. Exactly 1 code block of at most 8 lines, under 25 words of text.","materialLabel":"The current query and notes (attached to your prompt automatically)","material":"-- feed.sql  (PostgreSQL 16, Sparrowdesk activity feed)\n-- current version, page N:\nSELECT id, actor_id, verb, created_at\nFROM activity\nWHERE workspace_id = $1\n  AND deleted_at IS NULL\nORDER BY created_at DESC\nLIMIT 50 OFFSET $2;\n\n-- index: CREATE INDEX activity_ws_created ON activity (workspace_id, created_at DESC, id DESC);\n\n-- Problems reported:\n-- * page 400 takes 3.8 s (OFFSET scans and throws away 20,000 rows)\n-- * when someone posts while you scroll, the next page repeats an item\n-- * bulk imports write hundreds of rows with the same created_at, and\n--   some of them never show up\n\n-- Mobile team (Ade) on the API:\n-- \"We'll send the created_at and id of the last item we got.\n--  First page: we'll send '9999-12-31' and 0, if that's easier.\"\n--\n-- Product (Lin): \"Page size 25 from now on, matches the new design.\"","rules":[{"type":"codeBlocks","count":1},{"type":"maxCodeLines","max":8},{"type":"maxProseWords","max":25}],"mistakes":[{"title":"Comparing only the timestamp","why":"created_at < $1 alone skips rows that share a timestamp, like the bulk imports.","fix":"Add: \"Use the row comparison (created_at, id) < ($1, $2).\"","checks":["r0"]},{"title":"Old page size and order","why":"The AI keeps LIMIT 50 and may drop id from the ORDER BY, so ties come back in random order.","fix":"Add: \"ORDER BY created_at DESC, id DESC LIMIT 25.\"","checks":["r1"]},{"title":"Renumbered or lost filters","why":"The AI may keep workspace_id as $1 or drop the deleted filter.","fix":"Add: \"workspace_id = $3; keep deleted_at IS NULL and the same columns.\"","checks":["r2"]},{"title":"An essay on pagination","why":"The AI explains keyset vs offset with several variants.","fix":"Add: \"One query, one code block, no alternatives.\"","checks":["codeBlocks","maxCodeLines","maxProseWords"]}],"promptHints":[{"label":"Asks for keyset pagination","patterns":["keyset","cursor","no offset","seek"]},{"label":"Handles ties","patterns":["tie","\\(created_at, id\\)","row comparison"]},{"label":"Gives the placeholders","patterns":["\\$1","\\$3"]},{"label":"Gives the page size","patterns":["\\b25\\b","page size"]},{"label":"Keeps the filters","patterns":["deleted","same columns","keep"]}],"hint":"Keyset pagination is only correct with a tie-breaker. Tell the AI the cursor parameters, the tie rule, the new page size and what must stay.","rubricLabels":["Uses a row comparison (created_at, id) < ($1, $2) and has no OFFSET","Orders by created_at DESC, id DESC with LIMIT 25","Filters workspace_id = $3 and deleted_at IS NULL, and selects the same columns id, actor_id, verb, created_at"]}