Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql

docs: add pagination placeholder fix plan

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+520
+520
dev-docs/plans/2026-01-19-fix-pagination-placeholders.md
··· 1 + # Fix Pagination Cursor Placeholder Bug 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Fix cursor-based pagination (`after`/`before`) which returns 0 results on PostgreSQL due to incorrect SQL placeholders. 6 + 7 + **Architecture:** The `build_cursor_where_clause` function in `pagination.gleam` uses literal `?` placeholders, but PostgreSQL requires numbered placeholders (`$1`, `$2`, etc.). We need to pass the executor and a starting index so proper placeholders can be generated. 8 + 9 + **Tech Stack:** Gleam, PostgreSQL, SQLite 10 + 11 + --- 12 + 13 + ## Root Cause 14 + 15 + In `server/src/database/queries/pagination.gleam`, the `build_cursor_where_clause` and `build_progressive_clauses` functions build SQL with literal `?`: 16 + 17 + ```gleam 18 + let new_part = field_ref <> " = ?" // Line 261 19 + let comparison_part = field_ref <> " " <> comparison_op <> " ?" // Line 273 20 + ``` 21 + 22 + But PostgreSQL needs `$1, $2, $3`. The executor has a `placeholder(index)` function that returns the correct format for each dialect, but it's not being used. 23 + 24 + --- 25 + 26 + ### Task 1: Update `build_cursor_where_clause` Signature 27 + 28 + **Files:** 29 + - Modify: `server/src/database/queries/pagination.gleam:210-237` 30 + 31 + **Step 1: Update function signature to accept start_index** 32 + 33 + Change the function signature from: 34 + 35 + ```gleam 36 + pub fn build_cursor_where_clause( 37 + exec: Executor, 38 + decoded_cursor: DecodedCursor, 39 + sort_by: Option(List(#(String, String))), 40 + is_before: Bool, 41 + ) -> #(String, List(String)) { 42 + ``` 43 + 44 + To: 45 + 46 + ```gleam 47 + pub fn build_cursor_where_clause( 48 + exec: Executor, 49 + decoded_cursor: DecodedCursor, 50 + sort_by: Option(List(#(String, String))), 51 + is_before: Bool, 52 + start_index: Int, 53 + ) -> #(String, List(String)) { 54 + ``` 55 + 56 + **Step 2: Update the call to build_progressive_clauses** 57 + 58 + Change line 225-231 from: 59 + 60 + ```gleam 61 + let clauses = 62 + build_progressive_clauses( 63 + exec, 64 + sort_fields, 65 + decoded_cursor.field_values, 66 + decoded_cursor.cid, 67 + is_before, 68 + ) 69 + ``` 70 + 71 + To: 72 + 73 + ```gleam 74 + let clauses = 75 + build_progressive_clauses( 76 + exec, 77 + sort_fields, 78 + decoded_cursor.field_values, 79 + decoded_cursor.cid, 80 + is_before, 81 + start_index, 82 + ) 83 + ``` 84 + 85 + **Step 3: Run build to check for compilation errors** 86 + 87 + Run: `cd ~/code/quickslice/server && gleam build` 88 + Expected: Compilation errors about missing argument (we'll fix callers in Task 3) 89 + 90 + --- 91 + 92 + ### Task 2: Update `build_progressive_clauses` to Use Numbered Placeholders 93 + 94 + **Files:** 95 + - Modify: `server/src/database/queries/pagination.gleam:239-307` 96 + 97 + **Step 1: Update function signature** 98 + 99 + Change line 240-246 from: 100 + 101 + ```gleam 102 + fn build_progressive_clauses( 103 + exec: Executor, 104 + sort_fields: List(#(String, String)), 105 + field_values: List(String), 106 + cid: String, 107 + is_before: Bool, 108 + ) -> #(List(String), List(String)) { 109 + ``` 110 + 111 + To: 112 + 113 + ```gleam 114 + fn build_progressive_clauses( 115 + exec: Executor, 116 + sort_fields: List(#(String, String)), 117 + field_values: List(String), 118 + cid: String, 119 + is_before: Bool, 120 + start_index: Int, 121 + ) -> #(List(String), List(String)) { 122 + ``` 123 + 124 + **Step 2: Rewrite the function body to track placeholder indices** 125 + 126 + Replace the entire function body (lines 247-307) with: 127 + 128 + ```gleam 129 + fn build_progressive_clauses( 130 + exec: Executor, 131 + sort_fields: List(#(String, String)), 132 + field_values: List(String), 133 + cid: String, 134 + is_before: Bool, 135 + start_index: Int, 136 + ) -> #(List(String), List(String)) { 137 + // Build clauses with tracked parameter index 138 + let #(clauses, params, next_index) = 139 + list.index_fold(sort_fields, #([], [], start_index), fn(acc, field, i) { 140 + let #(acc_clauses, acc_params, param_index) = acc 141 + 142 + // Build equality parts for prior fields 143 + let #(equality_parts, equality_params, idx_after_eq) = case i { 144 + 0 -> #([], [], param_index) 145 + _ -> { 146 + list.index_fold( 147 + list.take(sort_fields, i), 148 + #([], [], param_index), 149 + fn(eq_acc, prior_field, j) { 150 + let #(eq_parts, eq_params, eq_idx) = eq_acc 151 + let value = list_at(field_values, j) |> result.unwrap("") 152 + let field_ref = build_cursor_field_reference(exec, prior_field.0) 153 + let placeholder = executor.placeholder(exec, eq_idx) 154 + let new_part = field_ref <> " = " <> placeholder 155 + #( 156 + list.append(eq_parts, [new_part]), 157 + list.append(eq_params, [value]), 158 + eq_idx + 1, 159 + ) 160 + }, 161 + ) 162 + } 163 + } 164 + 165 + let value = list_at(field_values, i) |> result.unwrap("") 166 + let comparison_op = get_comparison_operator(field.1, is_before) 167 + let field_ref = build_cursor_field_reference(exec, field.0) 168 + let placeholder = executor.placeholder(exec, idx_after_eq) 169 + 170 + let comparison_part = field_ref <> " " <> comparison_op <> " " <> placeholder 171 + let all_parts = list.append(equality_parts, [comparison_part]) 172 + let all_params = list.append(equality_params, [value]) 173 + 174 + let clause = "(" <> string.join(all_parts, " AND ") <> ")" 175 + 176 + #( 177 + list.append(acc_clauses, [clause]), 178 + list.append(acc_params, all_params), 179 + idx_after_eq + 1, 180 + ) 181 + }) 182 + 183 + // Build final clause with all fields equal and CID comparison 184 + let #(final_equality_parts, final_equality_params, idx_after_final_eq) = 185 + list.index_fold(sort_fields, #([], [], next_index), fn(acc, field, j) { 186 + let #(parts, params, idx) = acc 187 + let value = list_at(field_values, j) |> result.unwrap("") 188 + let field_ref = build_cursor_field_reference(exec, field.0) 189 + let placeholder = executor.placeholder(exec, idx) 190 + #( 191 + list.append(parts, [field_ref <> " = " <> placeholder]), 192 + list.append(params, [value]), 193 + idx + 1, 194 + ) 195 + }) 196 + 197 + let last_field = list.last(sort_fields) |> result.unwrap(#("", "desc")) 198 + let cid_comparison_op = get_comparison_operator(last_field.1, is_before) 199 + let cid_placeholder = executor.placeholder(exec, idx_after_final_eq) 200 + 201 + let final_parts = 202 + list.append(final_equality_parts, ["cid " <> cid_comparison_op <> " " <> cid_placeholder]) 203 + let final_params = list.append(final_equality_params, [cid]) 204 + 205 + let final_clause = "(" <> string.join(final_parts, " AND ") <> ")" 206 + let all_clauses = list.append(clauses, [final_clause]) 207 + let all_params = list.append(params, final_params) 208 + 209 + #(all_clauses, all_params) 210 + } 211 + ``` 212 + 213 + **Step 3: Run build to verify syntax** 214 + 215 + Run: `cd ~/code/quickslice/server && gleam build` 216 + Expected: Compilation errors about callers (we'll fix in Task 3) 217 + 218 + --- 219 + 220 + ### Task 3: Update All Callers in records.gleam 221 + 222 + **Files:** 223 + - Modify: `server/src/database/repositories/records.gleam` 224 + 225 + There are 5 places that call `build_cursor_where_clause`. Each needs to pass the current parameter count + 1 as the start_index. 226 + 227 + **Step 1: Update get_by_collection_paginated (line ~548)** 228 + 229 + Find lines 548-555 and change from: 230 + 231 + ```gleam 232 + let #(cursor_where, cursor_params) = 233 + pagination.build_cursor_where_clause( 234 + exec, 235 + decoded_cursor, 236 + sort_by, 237 + !is_forward, 238 + ) 239 + ``` 240 + 241 + To: 242 + 243 + ```gleam 244 + let #(cursor_where, cursor_params) = 245 + pagination.build_cursor_where_clause( 246 + exec, 247 + decoded_cursor, 248 + sort_by, 249 + !is_forward, 250 + list.length(bind_values) + 1, 251 + ) 252 + ``` 253 + 254 + **Step 2: Update get_by_collection_paginated_with_where (line ~701)** 255 + 256 + Find lines 701-708 and change from: 257 + 258 + ```gleam 259 + let #(cursor_where, cursor_params) = 260 + pagination.build_cursor_where_clause( 261 + exec, 262 + decoded_cursor, 263 + sort_by, 264 + !is_forward, 265 + ) 266 + ``` 267 + 268 + To: 269 + 270 + ```gleam 271 + let #(cursor_where, cursor_params) = 272 + pagination.build_cursor_where_clause( 273 + exec, 274 + decoded_cursor, 275 + sort_by, 276 + !is_forward, 277 + list.length(bind_values) + 1, 278 + ) 279 + ``` 280 + 281 + **Step 3: Update get_by_reference_field_paginated (line ~941)** 282 + 283 + Find lines 941-948 and change from: 284 + 285 + ```gleam 286 + let #(cursor_where, cursor_params) = 287 + pagination.build_cursor_where_clause( 288 + exec, 289 + decoded_cursor, 290 + sort_by, 291 + !is_forward, 292 + ) 293 + ``` 294 + 295 + To: 296 + 297 + ```gleam 298 + let #(cursor_where, cursor_params) = 299 + pagination.build_cursor_where_clause( 300 + exec, 301 + decoded_cursor, 302 + sort_by, 303 + !is_forward, 304 + list.length(with_where_values) + 1, 305 + ) 306 + ``` 307 + 308 + **Step 4: Update get_by_dids_and_collection_paginated (line ~1297)** 309 + 310 + Find lines 1297-1304 and change from: 311 + 312 + ```gleam 313 + let #(cursor_where, cursor_params) = 314 + pagination.build_cursor_where_clause( 315 + exec, 316 + decoded_cursor, 317 + sort_by, 318 + !is_forward, 319 + ) 320 + ``` 321 + 322 + To: 323 + 324 + ```gleam 325 + let #(cursor_where, cursor_params) = 326 + pagination.build_cursor_where_clause( 327 + exec, 328 + decoded_cursor, 329 + sort_by, 330 + !is_forward, 331 + list.length(with_where_values) + 1, 332 + ) 333 + ``` 334 + 335 + **Step 5: Build to verify all callers are updated** 336 + 337 + Run: `cd ~/code/quickslice/server && gleam build` 338 + Expected: BUILD SUCCESS 339 + 340 + **Step 6: Commit the fix** 341 + 342 + ```bash 343 + cd ~/code/quickslice/server 344 + git add src/database/queries/pagination.gleam src/database/repositories/records.gleam 345 + git commit -m "fix: use numbered placeholders in cursor WHERE clause for PostgreSQL 346 + 347 + The build_cursor_where_clause function was using literal '?' placeholders, 348 + which works for SQLite but fails on PostgreSQL (which needs \$1, \$2, etc.). 349 + 350 + Now accepts a start_index parameter and uses executor.placeholder() to 351 + generate the correct format for each database dialect. 352 + 353 + Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>" 354 + ``` 355 + 356 + --- 357 + 358 + ### Task 4: Update Unit Tests 359 + 360 + **Files:** 361 + - Modify: `server/test/pagination_test.gleam` 362 + 363 + The existing tests check for `?` placeholders. They pass because tests use SQLite. We need to update tests to pass the new start_index parameter. 364 + 365 + **Step 1: Update build_where_single_field_desc_test** 366 + 367 + Find the test around line 272 and change: 368 + 369 + ```gleam 370 + let #(sql, params) = 371 + pagination.build_cursor_where_clause(exec, decoded, sort_by, False) 372 + ``` 373 + 374 + To: 375 + 376 + ```gleam 377 + let #(sql, params) = 378 + pagination.build_cursor_where_clause(exec, decoded, sort_by, False, 1) 379 + ``` 380 + 381 + **Step 2: Update build_where_single_field_asc_test** 382 + 383 + Find the test around line 298 and change: 384 + 385 + ```gleam 386 + let #(sql, params) = 387 + pagination.build_cursor_where_clause(exec, decoded, sort_by, False) 388 + ``` 389 + 390 + To: 391 + 392 + ```gleam 393 + let #(sql, params) = 394 + pagination.build_cursor_where_clause(exec, decoded, sort_by, False, 1) 395 + ``` 396 + 397 + **Step 3: Update build_where_json_field_test** 398 + 399 + Find the test around line 324 and change: 400 + 401 + ```gleam 402 + let #(sql, params) = 403 + pagination.build_cursor_where_clause(exec, decoded, sort_by, False) 404 + ``` 405 + 406 + To: 407 + 408 + ```gleam 409 + let #(sql, params) = 410 + pagination.build_cursor_where_clause(exec, decoded, sort_by, False, 1) 411 + ``` 412 + 413 + **Step 4: Update build_where_nested_json_field_test** 414 + 415 + Find the test around line 345 and change: 416 + 417 + ```gleam 418 + let #(sql, params) = 419 + pagination.build_cursor_where_clause(exec, decoded, sort_by, False) 420 + ``` 421 + 422 + To: 423 + 424 + ```gleam 425 + let #(sql, params) = 426 + pagination.build_cursor_where_clause(exec, decoded, sort_by, False, 1) 427 + ``` 428 + 429 + **Step 5: Update build_where_multi_field_test** 430 + 431 + Find the test around line 366 and change: 432 + 433 + ```gleam 434 + let #(sql, params) = 435 + pagination.build_cursor_where_clause(exec, decoded, sort_by, False) 436 + ``` 437 + 438 + To: 439 + 440 + ```gleam 441 + let #(sql, params) = 442 + pagination.build_cursor_where_clause(exec, decoded, sort_by, False, 1) 443 + ``` 444 + 445 + **Step 6: Update build_where_backward_test** 446 + 447 + Find the test around line 398 and change: 448 + 449 + ```gleam 450 + let #(sql, params) = 451 + pagination.build_cursor_where_clause(exec, decoded, sort_by, True) 452 + ``` 453 + 454 + To: 455 + 456 + ```gleam 457 + let #(sql, params) = 458 + pagination.build_cursor_where_clause(exec, decoded, sort_by, True, 1) 459 + ``` 460 + 461 + **Step 7: Run tests** 462 + 463 + Run: `cd ~/code/quickslice/server && gleam test` 464 + Expected: All tests pass (SQLite uses `?` regardless of index, so assertions still work) 465 + 466 + **Step 8: Commit test updates** 467 + 468 + ```bash 469 + cd ~/code/quickslice/server 470 + git add test/pagination_test.gleam 471 + git commit -m "test: update pagination tests with start_index parameter 472 + 473 + Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>" 474 + ``` 475 + 476 + --- 477 + 478 + ### Task 5: Manual Verification with MCP 479 + 480 + **Step 1: Test pagination via quickslice MCP** 481 + 482 + Run this GraphQL query: 483 + 484 + ```graphql 485 + query { 486 + gamesGamesgamesgamesgamesGame(first: 2) { 487 + pageInfo { 488 + hasNextPage 489 + endCursor 490 + } 491 + edges { 492 + node { name } 493 + } 494 + } 495 + } 496 + ``` 497 + 498 + **Step 2: Test pagination with cursor** 499 + 500 + Use the `endCursor` from step 1: 501 + 502 + ```graphql 503 + query { 504 + gamesGamesgamesgamesgamesGame(first: 2, after: "<endCursor>") { 505 + pageInfo { 506 + hasNextPage 507 + endCursor 508 + } 509 + edges { 510 + node { name } 511 + } 512 + } 513 + } 514 + ``` 515 + 516 + Expected: Returns the NEXT 2 records (not empty, not the same as page 1) 517 + 518 + **Step 3: Commit verification note (optional)** 519 + 520 + If all works, the fix is complete.