···5The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
700000000008## v0.20.0
910### Added
···5The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
78+## v0.20.2
9+10+### Fixed
11+- 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)
12+13+## v0.20.1
14+15+### Fixed
16+- Fix cursor-based pagination (`after`/`before`) returning 0 results on PostgreSQL due to incorrect SQL placeholders (literal `?` instead of numbered `$1`, `$2`, etc.)
17+18## v0.20.0
1920### Added
···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.
+46
docs/guides/moderation.md
···301- `!warn` and `!hide` always apply their effects
302303Attempting to set a preference for a system label returns an error.
0000000000000000000000000000000000000000000000
···301- `!warn` and `!hide` always apply their effects
302303Attempting to set a preference for a system label returns an error.
304+305+### Client-Side Filtering
306+307+The server filters takedown labels automatically, but clients must apply user preferences for other labels. Here's the pattern:
308+309+```typescript
310+// 1. Fetch preferences once and cache
311+const prefs = await client.query(`{
312+ viewerLabelPreferences { val visibility }
313+}`)
314+const prefMap = new Map(prefs.map(p => [p.val, p.visibility]))
315+316+// 2. Check visibility for each record
317+function getVisibility(record) {
318+ for (const label of record.labels ?? []) {
319+ const vis = prefMap.get(label.val) ?? 'WARN'
320+ if (vis === 'HIDE') return { show: false }
321+ if (vis === 'WARN') return { show: true, blur: true }
322+ }
323+ return { show: true, blur: false }
324+}
325+326+// 3. Apply in your UI
327+function RecordCard({ record }) {
328+ const { show, blur } = getVisibility(record)
329+330+ if (!show) return null
331+332+ if (blur) {
333+ return (
334+ <BlurOverlay onReveal={() => setRevealed(true)}>
335+ <Content record={record} />
336+ </BlurOverlay>
337+ )
338+ }
339+340+ return <Content record={record} />
341+}
342+```
343+344+Key points:
345+346+- Cache preferences at session start or when user updates them
347+- Default unknown labels to `WARN` for safety
348+- Multiple labels on one record: apply the most restrictive
349+- `IGNORE` and `SHOW` both display normally; `SHOW` is for explicit opt-in to adult content
···1+# Notifications
2+3+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.
4+5+## How It Works
6+7+The `notifications` query searches all records for your DID. It returns matches where:
8+- The record's JSON contains your DID (as a URI or raw DID)
9+- The record was authored by someone else (self-mentions excluded)
10+11+The server identifies you from your access token. Authentication is required.
12+13+## Basic Query
14+15+```graphql
16+query {
17+ notifications(first: 20) {
18+ edges {
19+ node {
20+ __typename
21+ ... on AppBskyFeedLike {
22+ uri
23+ did
24+ createdAt
25+ }
26+ ... on AppBskyGraphFollow {
27+ uri
28+ did
29+ createdAt
30+ }
31+ }
32+ cursor
33+ }
34+ pageInfo {
35+ hasNextPage
36+ endCursor
37+ }
38+ }
39+}
40+```
41+42+The `node` is a union type containing all record types in your schema. Use inline fragments (`... on TypeName`) to access type-specific fields.
43+44+## Response Example
45+46+When Alice likes your post and Bob follows you:
47+48+```json
49+{
50+ "data": {
51+ "notifications": {
52+ "edges": [
53+ {
54+ "node": {
55+ "__typename": "AppBskyGraphFollow",
56+ "uri": "at://did:plc:bob/app.bsky.graph.follow/3k2yab7",
57+ "did": "did:plc:bob",
58+ "createdAt": "2024-01-03T12:00:00Z"
59+ },
60+ "cursor": "eyJ..."
61+ },
62+ {
63+ "node": {
64+ "__typename": "AppBskyFeedLike",
65+ "uri": "at://did:plc:alice/app.bsky.feed.like/3k2xz9m",
66+ "did": "did:plc:alice",
67+ "createdAt": "2024-01-02T10:30:00Z"
68+ },
69+ "cursor": "eyJ..."
70+ }
71+ ],
72+ "pageInfo": {
73+ "hasNextPage": false,
74+ "endCursor": "eyJ..."
75+ }
76+ }
77+ }
78+}
79+```
80+81+Results are sorted newest-first by rkey (TID).
82+83+## Filtering by Collection
84+85+Filter to specific record types using the `collections` argument:
86+87+```graphql
88+query {
89+ notifications(collections: [APP_BSKY_FEED_LIKE], first: 20) {
90+ edges {
91+ node {
92+ ... on AppBskyFeedLike {
93+ uri
94+ did
95+ }
96+ }
97+ }
98+ }
99+}
100+```
101+102+Collection names use the enum format: `app.bsky.feed.like` becomes `APP_BSKY_FEED_LIKE`.
103+104+Filter to multiple types:
105+106+```graphql
107+query {
108+ notifications(
109+ collections: [APP_BSKY_FEED_LIKE, APP_BSKY_GRAPH_FOLLOW]
110+ first: 20
111+ ) {
112+ # ...
113+ }
114+}
115+```
116+117+## Pagination
118+119+Use cursor-based pagination to fetch more results:
120+121+```graphql
122+query {
123+ notifications(first: 20, after: "eyJ...") {
124+ edges {
125+ node { __typename }
126+ cursor
127+ }
128+ pageInfo {
129+ hasNextPage
130+ endCursor
131+ }
132+ }
133+}
134+```
135+136+Pass `pageInfo.endCursor` as the `after` argument to fetch the next page.
137+138+## Real-time Updates
139+140+Subscribe to new notifications as they happen:
141+142+```graphql
143+subscription {
144+ notificationCreated {
145+ __typename
146+ ... on AppBskyFeedLike {
147+ uri
148+ did
149+ createdAt
150+ }
151+ ... on AppBskyGraphFollow {
152+ uri
153+ did
154+ createdAt
155+ }
156+ }
157+}
158+```
159+160+Filter to specific collections:
161+162+```graphql
163+subscription {
164+ notificationCreated(collections: [APP_BSKY_FEED_LIKE]) {
165+ ... on AppBskyFeedLike {
166+ uri
167+ did
168+ }
169+ }
170+}
171+```
172+173+See [Subscriptions](./subscriptions.md) for WebSocket connection details.
174+175+## Authentication Required
176+177+Notifications require authentication. Without a valid access token, the query returns an error.
178+179+Use the [Quickslice client SDK](./authentication.md#using-the-client-sdk) to handle authentication automatically.