···55The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7788+## v0.20.2
99+1010+### Fixed
1111+- Fix filtered queries (`where` clause) returning incorrect results on PostgreSQL due to parameter index collision (WHERE clause placeholders always started at `$1` instead of accounting for prior bind values)
1212+1313+## v0.20.1
1414+1515+### Fixed
1616+- Fix cursor-based pagination (`after`/`before`) returning 0 results on PostgreSQL due to incorrect SQL placeholders (literal `?` instead of numbered `$1`, `$2`, etc.)
1717+818## v0.20.0
9191020### Added
···11+# Fix Pagination Cursor Placeholder Bug
22+33+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
44+55+**Goal:** Fix cursor-based pagination (`after`/`before`) which returns 0 results on PostgreSQL due to incorrect SQL placeholders.
66+77+**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.
88+99+**Tech Stack:** Gleam, PostgreSQL, SQLite
1010+1111+---
1212+1313+## Root Cause
1414+1515+In `server/src/database/queries/pagination.gleam`, the `build_cursor_where_clause` and `build_progressive_clauses` functions build SQL with literal `?`:
1616+1717+```gleam
1818+let new_part = field_ref <> " = ?" // Line 261
1919+let comparison_part = field_ref <> " " <> comparison_op <> " ?" // Line 273
2020+```
2121+2222+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.
2323+2424+---
2525+2626+### Task 1: Update `build_cursor_where_clause` Signature
2727+2828+**Files:**
2929+- Modify: `server/src/database/queries/pagination.gleam:210-237`
3030+3131+**Step 1: Update function signature to accept start_index**
3232+3333+Change the function signature from:
3434+3535+```gleam
3636+pub fn build_cursor_where_clause(
3737+ exec: Executor,
3838+ decoded_cursor: DecodedCursor,
3939+ sort_by: Option(List(#(String, String))),
4040+ is_before: Bool,
4141+) -> #(String, List(String)) {
4242+```
4343+4444+To:
4545+4646+```gleam
4747+pub fn build_cursor_where_clause(
4848+ exec: Executor,
4949+ decoded_cursor: DecodedCursor,
5050+ sort_by: Option(List(#(String, String))),
5151+ is_before: Bool,
5252+ start_index: Int,
5353+) -> #(String, List(String)) {
5454+```
5555+5656+**Step 2: Update the call to build_progressive_clauses**
5757+5858+Change line 225-231 from:
5959+6060+```gleam
6161+ let clauses =
6262+ build_progressive_clauses(
6363+ exec,
6464+ sort_fields,
6565+ decoded_cursor.field_values,
6666+ decoded_cursor.cid,
6767+ is_before,
6868+ )
6969+```
7070+7171+To:
7272+7373+```gleam
7474+ let clauses =
7575+ build_progressive_clauses(
7676+ exec,
7777+ sort_fields,
7878+ decoded_cursor.field_values,
7979+ decoded_cursor.cid,
8080+ is_before,
8181+ start_index,
8282+ )
8383+```
8484+8585+**Step 3: Run build to check for compilation errors**
8686+8787+Run: `cd ~/code/quickslice/server && gleam build`
8888+Expected: Compilation errors about missing argument (we'll fix callers in Task 3)
8989+9090+---
9191+9292+### Task 2: Update `build_progressive_clauses` to Use Numbered Placeholders
9393+9494+**Files:**
9595+- Modify: `server/src/database/queries/pagination.gleam:239-307`
9696+9797+**Step 1: Update function signature**
9898+9999+Change line 240-246 from:
100100+101101+```gleam
102102+fn build_progressive_clauses(
103103+ exec: Executor,
104104+ sort_fields: List(#(String, String)),
105105+ field_values: List(String),
106106+ cid: String,
107107+ is_before: Bool,
108108+) -> #(List(String), List(String)) {
109109+```
110110+111111+To:
112112+113113+```gleam
114114+fn build_progressive_clauses(
115115+ exec: Executor,
116116+ sort_fields: List(#(String, String)),
117117+ field_values: List(String),
118118+ cid: String,
119119+ is_before: Bool,
120120+ start_index: Int,
121121+) -> #(List(String), List(String)) {
122122+```
123123+124124+**Step 2: Rewrite the function body to track placeholder indices**
125125+126126+Replace the entire function body (lines 247-307) with:
127127+128128+```gleam
129129+fn build_progressive_clauses(
130130+ exec: Executor,
131131+ sort_fields: List(#(String, String)),
132132+ field_values: List(String),
133133+ cid: String,
134134+ is_before: Bool,
135135+ start_index: Int,
136136+) -> #(List(String), List(String)) {
137137+ // Build clauses with tracked parameter index
138138+ let #(clauses, params, next_index) =
139139+ list.index_fold(sort_fields, #([], [], start_index), fn(acc, field, i) {
140140+ let #(acc_clauses, acc_params, param_index) = acc
141141+142142+ // Build equality parts for prior fields
143143+ let #(equality_parts, equality_params, idx_after_eq) = case i {
144144+ 0 -> #([], [], param_index)
145145+ _ -> {
146146+ list.index_fold(
147147+ list.take(sort_fields, i),
148148+ #([], [], param_index),
149149+ fn(eq_acc, prior_field, j) {
150150+ let #(eq_parts, eq_params, eq_idx) = eq_acc
151151+ let value = list_at(field_values, j) |> result.unwrap("")
152152+ let field_ref = build_cursor_field_reference(exec, prior_field.0)
153153+ let placeholder = executor.placeholder(exec, eq_idx)
154154+ let new_part = field_ref <> " = " <> placeholder
155155+ #(
156156+ list.append(eq_parts, [new_part]),
157157+ list.append(eq_params, [value]),
158158+ eq_idx + 1,
159159+ )
160160+ },
161161+ )
162162+ }
163163+ }
164164+165165+ let value = list_at(field_values, i) |> result.unwrap("")
166166+ let comparison_op = get_comparison_operator(field.1, is_before)
167167+ let field_ref = build_cursor_field_reference(exec, field.0)
168168+ let placeholder = executor.placeholder(exec, idx_after_eq)
169169+170170+ let comparison_part = field_ref <> " " <> comparison_op <> " " <> placeholder
171171+ let all_parts = list.append(equality_parts, [comparison_part])
172172+ let all_params = list.append(equality_params, [value])
173173+174174+ let clause = "(" <> string.join(all_parts, " AND ") <> ")"
175175+176176+ #(
177177+ list.append(acc_clauses, [clause]),
178178+ list.append(acc_params, all_params),
179179+ idx_after_eq + 1,
180180+ )
181181+ })
182182+183183+ // Build final clause with all fields equal and CID comparison
184184+ let #(final_equality_parts, final_equality_params, idx_after_final_eq) =
185185+ list.index_fold(sort_fields, #([], [], next_index), fn(acc, field, j) {
186186+ let #(parts, params, idx) = acc
187187+ let value = list_at(field_values, j) |> result.unwrap("")
188188+ let field_ref = build_cursor_field_reference(exec, field.0)
189189+ let placeholder = executor.placeholder(exec, idx)
190190+ #(
191191+ list.append(parts, [field_ref <> " = " <> placeholder]),
192192+ list.append(params, [value]),
193193+ idx + 1,
194194+ )
195195+ })
196196+197197+ let last_field = list.last(sort_fields) |> result.unwrap(#("", "desc"))
198198+ let cid_comparison_op = get_comparison_operator(last_field.1, is_before)
199199+ let cid_placeholder = executor.placeholder(exec, idx_after_final_eq)
200200+201201+ let final_parts =
202202+ list.append(final_equality_parts, ["cid " <> cid_comparison_op <> " " <> cid_placeholder])
203203+ let final_params = list.append(final_equality_params, [cid])
204204+205205+ let final_clause = "(" <> string.join(final_parts, " AND ") <> ")"
206206+ let all_clauses = list.append(clauses, [final_clause])
207207+ let all_params = list.append(params, final_params)
208208+209209+ #(all_clauses, all_params)
210210+}
211211+```
212212+213213+**Step 3: Run build to verify syntax**
214214+215215+Run: `cd ~/code/quickslice/server && gleam build`
216216+Expected: Compilation errors about callers (we'll fix in Task 3)
217217+218218+---
219219+220220+### Task 3: Update All Callers in records.gleam
221221+222222+**Files:**
223223+- Modify: `server/src/database/repositories/records.gleam`
224224+225225+There are 5 places that call `build_cursor_where_clause`. Each needs to pass the current parameter count + 1 as the start_index.
226226+227227+**Step 1: Update get_by_collection_paginated (line ~548)**
228228+229229+Find lines 548-555 and change from:
230230+231231+```gleam
232232+ let #(cursor_where, cursor_params) =
233233+ pagination.build_cursor_where_clause(
234234+ exec,
235235+ decoded_cursor,
236236+ sort_by,
237237+ !is_forward,
238238+ )
239239+```
240240+241241+To:
242242+243243+```gleam
244244+ let #(cursor_where, cursor_params) =
245245+ pagination.build_cursor_where_clause(
246246+ exec,
247247+ decoded_cursor,
248248+ sort_by,
249249+ !is_forward,
250250+ list.length(bind_values) + 1,
251251+ )
252252+```
253253+254254+**Step 2: Update get_by_collection_paginated_with_where (line ~701)**
255255+256256+Find lines 701-708 and change from:
257257+258258+```gleam
259259+ let #(cursor_where, cursor_params) =
260260+ pagination.build_cursor_where_clause(
261261+ exec,
262262+ decoded_cursor,
263263+ sort_by,
264264+ !is_forward,
265265+ )
266266+```
267267+268268+To:
269269+270270+```gleam
271271+ let #(cursor_where, cursor_params) =
272272+ pagination.build_cursor_where_clause(
273273+ exec,
274274+ decoded_cursor,
275275+ sort_by,
276276+ !is_forward,
277277+ list.length(bind_values) + 1,
278278+ )
279279+```
280280+281281+**Step 3: Update get_by_reference_field_paginated (line ~941)**
282282+283283+Find lines 941-948 and change from:
284284+285285+```gleam
286286+ let #(cursor_where, cursor_params) =
287287+ pagination.build_cursor_where_clause(
288288+ exec,
289289+ decoded_cursor,
290290+ sort_by,
291291+ !is_forward,
292292+ )
293293+```
294294+295295+To:
296296+297297+```gleam
298298+ let #(cursor_where, cursor_params) =
299299+ pagination.build_cursor_where_clause(
300300+ exec,
301301+ decoded_cursor,
302302+ sort_by,
303303+ !is_forward,
304304+ list.length(with_where_values) + 1,
305305+ )
306306+```
307307+308308+**Step 4: Update get_by_dids_and_collection_paginated (line ~1297)**
309309+310310+Find lines 1297-1304 and change from:
311311+312312+```gleam
313313+ let #(cursor_where, cursor_params) =
314314+ pagination.build_cursor_where_clause(
315315+ exec,
316316+ decoded_cursor,
317317+ sort_by,
318318+ !is_forward,
319319+ )
320320+```
321321+322322+To:
323323+324324+```gleam
325325+ let #(cursor_where, cursor_params) =
326326+ pagination.build_cursor_where_clause(
327327+ exec,
328328+ decoded_cursor,
329329+ sort_by,
330330+ !is_forward,
331331+ list.length(with_where_values) + 1,
332332+ )
333333+```
334334+335335+**Step 5: Build to verify all callers are updated**
336336+337337+Run: `cd ~/code/quickslice/server && gleam build`
338338+Expected: BUILD SUCCESS
339339+340340+**Step 6: Commit the fix**
341341+342342+```bash
343343+cd ~/code/quickslice/server
344344+git add src/database/queries/pagination.gleam src/database/repositories/records.gleam
345345+git commit -m "fix: use numbered placeholders in cursor WHERE clause for PostgreSQL
346346+347347+The build_cursor_where_clause function was using literal '?' placeholders,
348348+which works for SQLite but fails on PostgreSQL (which needs \$1, \$2, etc.).
349349+350350+Now accepts a start_index parameter and uses executor.placeholder() to
351351+generate the correct format for each database dialect.
352352+353353+Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
354354+```
355355+356356+---
357357+358358+### Task 4: Update Unit Tests
359359+360360+**Files:**
361361+- Modify: `server/test/pagination_test.gleam`
362362+363363+The existing tests check for `?` placeholders. They pass because tests use SQLite. We need to update tests to pass the new start_index parameter.
364364+365365+**Step 1: Update build_where_single_field_desc_test**
366366+367367+Find the test around line 272 and change:
368368+369369+```gleam
370370+ let #(sql, params) =
371371+ pagination.build_cursor_where_clause(exec, decoded, sort_by, False)
372372+```
373373+374374+To:
375375+376376+```gleam
377377+ let #(sql, params) =
378378+ pagination.build_cursor_where_clause(exec, decoded, sort_by, False, 1)
379379+```
380380+381381+**Step 2: Update build_where_single_field_asc_test**
382382+383383+Find the test around line 298 and change:
384384+385385+```gleam
386386+ let #(sql, params) =
387387+ pagination.build_cursor_where_clause(exec, decoded, sort_by, False)
388388+```
389389+390390+To:
391391+392392+```gleam
393393+ let #(sql, params) =
394394+ pagination.build_cursor_where_clause(exec, decoded, sort_by, False, 1)
395395+```
396396+397397+**Step 3: Update build_where_json_field_test**
398398+399399+Find the test around line 324 and change:
400400+401401+```gleam
402402+ let #(sql, params) =
403403+ pagination.build_cursor_where_clause(exec, decoded, sort_by, False)
404404+```
405405+406406+To:
407407+408408+```gleam
409409+ let #(sql, params) =
410410+ pagination.build_cursor_where_clause(exec, decoded, sort_by, False, 1)
411411+```
412412+413413+**Step 4: Update build_where_nested_json_field_test**
414414+415415+Find the test around line 345 and change:
416416+417417+```gleam
418418+ let #(sql, params) =
419419+ pagination.build_cursor_where_clause(exec, decoded, sort_by, False)
420420+```
421421+422422+To:
423423+424424+```gleam
425425+ let #(sql, params) =
426426+ pagination.build_cursor_where_clause(exec, decoded, sort_by, False, 1)
427427+```
428428+429429+**Step 5: Update build_where_multi_field_test**
430430+431431+Find the test around line 366 and change:
432432+433433+```gleam
434434+ let #(sql, params) =
435435+ pagination.build_cursor_where_clause(exec, decoded, sort_by, False)
436436+```
437437+438438+To:
439439+440440+```gleam
441441+ let #(sql, params) =
442442+ pagination.build_cursor_where_clause(exec, decoded, sort_by, False, 1)
443443+```
444444+445445+**Step 6: Update build_where_backward_test**
446446+447447+Find the test around line 398 and change:
448448+449449+```gleam
450450+ let #(sql, params) =
451451+ pagination.build_cursor_where_clause(exec, decoded, sort_by, True)
452452+```
453453+454454+To:
455455+456456+```gleam
457457+ let #(sql, params) =
458458+ pagination.build_cursor_where_clause(exec, decoded, sort_by, True, 1)
459459+```
460460+461461+**Step 7: Run tests**
462462+463463+Run: `cd ~/code/quickslice/server && gleam test`
464464+Expected: All tests pass (SQLite uses `?` regardless of index, so assertions still work)
465465+466466+**Step 8: Commit test updates**
467467+468468+```bash
469469+cd ~/code/quickslice/server
470470+git add test/pagination_test.gleam
471471+git commit -m "test: update pagination tests with start_index parameter
472472+473473+Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
474474+```
475475+476476+---
477477+478478+### Task 5: Manual Verification with MCP
479479+480480+**Step 1: Test pagination via quickslice MCP**
481481+482482+Run this GraphQL query:
483483+484484+```graphql
485485+query {
486486+ gamesGamesgamesgamesgamesGame(first: 2) {
487487+ pageInfo {
488488+ hasNextPage
489489+ endCursor
490490+ }
491491+ edges {
492492+ node { name }
493493+ }
494494+ }
495495+}
496496+```
497497+498498+**Step 2: Test pagination with cursor**
499499+500500+Use the `endCursor` from step 1:
501501+502502+```graphql
503503+query {
504504+ gamesGamesgamesgamesgamesGame(first: 2, after: "<endCursor>") {
505505+ pageInfo {
506506+ hasNextPage
507507+ endCursor
508508+ }
509509+ edges {
510510+ node { name }
511511+ }
512512+ }
513513+}
514514+```
515515+516516+Expected: Returns the NEXT 2 records (not empty, not the same as page 1)
517517+518518+**Step 3: Commit verification note (optional)**
519519+520520+If all works, the fix is complete.
+46
docs/guides/moderation.md
···301301- `!warn` and `!hide` always apply their effects
302302303303Attempting to set a preference for a system label returns an error.
304304+305305+### Client-Side Filtering
306306+307307+The server filters takedown labels automatically, but clients must apply user preferences for other labels. Here's the pattern:
308308+309309+```typescript
310310+// 1. Fetch preferences once and cache
311311+const prefs = await client.query(`{
312312+ viewerLabelPreferences { val visibility }
313313+}`)
314314+const prefMap = new Map(prefs.map(p => [p.val, p.visibility]))
315315+316316+// 2. Check visibility for each record
317317+function getVisibility(record) {
318318+ for (const label of record.labels ?? []) {
319319+ const vis = prefMap.get(label.val) ?? 'WARN'
320320+ if (vis === 'HIDE') return { show: false }
321321+ if (vis === 'WARN') return { show: true, blur: true }
322322+ }
323323+ return { show: true, blur: false }
324324+}
325325+326326+// 3. Apply in your UI
327327+function RecordCard({ record }) {
328328+ const { show, blur } = getVisibility(record)
329329+330330+ if (!show) return null
331331+332332+ if (blur) {
333333+ return (
334334+ <BlurOverlay onReveal={() => setRevealed(true)}>
335335+ <Content record={record} />
336336+ </BlurOverlay>
337337+ )
338338+ }
339339+340340+ return <Content record={record} />
341341+}
342342+```
343343+344344+Key points:
345345+346346+- Cache preferences at session start or when user updates them
347347+- Default unknown labels to `WARN` for safety
348348+- Multiple labels on one record: apply the most restrictive
349349+- `IGNORE` and `SHOW` both display normally; `SHOW` is for explicit opt-in to adult content
+179
docs/guides/notifications.md
···11+# Notifications
22+33+Notifications show records that mention the authenticated user. When someone likes your post, follows you, or references your DID in any record, it appears in your notifications.
44+55+## How It Works
66+77+The `notifications` query searches all records for your DID. It returns matches where:
88+- The record's JSON contains your DID (as a URI or raw DID)
99+- The record was authored by someone else (self-mentions excluded)
1010+1111+The server identifies you from your access token. Authentication is required.
1212+1313+## Basic Query
1414+1515+```graphql
1616+query {
1717+ notifications(first: 20) {
1818+ edges {
1919+ node {
2020+ __typename
2121+ ... on AppBskyFeedLike {
2222+ uri
2323+ did
2424+ createdAt
2525+ }
2626+ ... on AppBskyGraphFollow {
2727+ uri
2828+ did
2929+ createdAt
3030+ }
3131+ }
3232+ cursor
3333+ }
3434+ pageInfo {
3535+ hasNextPage
3636+ endCursor
3737+ }
3838+ }
3939+}
4040+```
4141+4242+The `node` is a union type containing all record types in your schema. Use inline fragments (`... on TypeName`) to access type-specific fields.
4343+4444+## Response Example
4545+4646+When Alice likes your post and Bob follows you:
4747+4848+```json
4949+{
5050+ "data": {
5151+ "notifications": {
5252+ "edges": [
5353+ {
5454+ "node": {
5555+ "__typename": "AppBskyGraphFollow",
5656+ "uri": "at://did:plc:bob/app.bsky.graph.follow/3k2yab7",
5757+ "did": "did:plc:bob",
5858+ "createdAt": "2024-01-03T12:00:00Z"
5959+ },
6060+ "cursor": "eyJ..."
6161+ },
6262+ {
6363+ "node": {
6464+ "__typename": "AppBskyFeedLike",
6565+ "uri": "at://did:plc:alice/app.bsky.feed.like/3k2xz9m",
6666+ "did": "did:plc:alice",
6767+ "createdAt": "2024-01-02T10:30:00Z"
6868+ },
6969+ "cursor": "eyJ..."
7070+ }
7171+ ],
7272+ "pageInfo": {
7373+ "hasNextPage": false,
7474+ "endCursor": "eyJ..."
7575+ }
7676+ }
7777+ }
7878+}
7979+```
8080+8181+Results are sorted newest-first by rkey (TID).
8282+8383+## Filtering by Collection
8484+8585+Filter to specific record types using the `collections` argument:
8686+8787+```graphql
8888+query {
8989+ notifications(collections: [APP_BSKY_FEED_LIKE], first: 20) {
9090+ edges {
9191+ node {
9292+ ... on AppBskyFeedLike {
9393+ uri
9494+ did
9595+ }
9696+ }
9797+ }
9898+ }
9999+}
100100+```
101101+102102+Collection names use the enum format: `app.bsky.feed.like` becomes `APP_BSKY_FEED_LIKE`.
103103+104104+Filter to multiple types:
105105+106106+```graphql
107107+query {
108108+ notifications(
109109+ collections: [APP_BSKY_FEED_LIKE, APP_BSKY_GRAPH_FOLLOW]
110110+ first: 20
111111+ ) {
112112+ # ...
113113+ }
114114+}
115115+```
116116+117117+## Pagination
118118+119119+Use cursor-based pagination to fetch more results:
120120+121121+```graphql
122122+query {
123123+ notifications(first: 20, after: "eyJ...") {
124124+ edges {
125125+ node { __typename }
126126+ cursor
127127+ }
128128+ pageInfo {
129129+ hasNextPage
130130+ endCursor
131131+ }
132132+ }
133133+}
134134+```
135135+136136+Pass `pageInfo.endCursor` as the `after` argument to fetch the next page.
137137+138138+## Real-time Updates
139139+140140+Subscribe to new notifications as they happen:
141141+142142+```graphql
143143+subscription {
144144+ notificationCreated {
145145+ __typename
146146+ ... on AppBskyFeedLike {
147147+ uri
148148+ did
149149+ createdAt
150150+ }
151151+ ... on AppBskyGraphFollow {
152152+ uri
153153+ did
154154+ createdAt
155155+ }
156156+ }
157157+}
158158+```
159159+160160+Filter to specific collections:
161161+162162+```graphql
163163+subscription {
164164+ notificationCreated(collections: [APP_BSKY_FEED_LIKE]) {
165165+ ... on AppBskyFeedLike {
166166+ uri
167167+ did
168168+ }
169169+ }
170170+}
171171+```
172172+173173+See [Subscriptions](./subscriptions.md) for WebSocket connection details.
174174+175175+## Authentication Required
176176+177177+Notifications require authentication. Without a valid access token, the query returns an error.
178178+179179+Use the [Quickslice client SDK](./authentication.md#using-the-client-sdk) to handle authentication automatically.