From b4a63106ca3cf03076a3bb7bc117c0a4afe558ad Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Wed, 8 Jul 2026 11:05:45 -0400 Subject: [PATCH] feat(routes): envelope page loads + deep search/topics pagination Home events, profile hosting/past-events, topic, and search each mint a self-describing continuation envelope from their own server-side filters; EventList echoes the opaque token on "load more", carrying no pipeline or filters of its own. This closes the gap that forced /search (D1 fallback) and /topics to return cursor:null, so both now paginate beyond page 1 with the discoverable + upcoming filters intact. Each route resumes a deep-linked ?cursor= only for its own query and scope; search page 1 does not resume from ?cursor= at all, since the term rides ?q= and can't be validated against the cursor. Adds the README "Load-more pagination" section describing the model. --- README.md | 14 ++ apps/web/src/lib/components/EventList.svelte | 29 ++--- .../src/routes/(app)/events/+page.server.ts | 14 +- apps/web/src/routes/(app)/events/+page.svelte | 14 +- .../(app)/p/[actor]/hosting/+page.server.ts | 11 +- .../(app)/p/[actor]/hosting/+page.svelte | 13 -- .../p/[actor]/past-events/+page.server.ts | 11 +- .../(app)/p/[actor]/past-events/+page.svelte | 13 -- .../src/routes/(app)/search/+page.server.ts | 31 +++-- apps/web/src/routes/(app)/search/+page.svelte | 8 +- .../routes/(app)/search/page.server.test.ts | 93 ++++++++++---- .../(app)/topics/[slug]/+page.server.ts | 29 ++--- .../routes/(app)/topics/[slug]/+page.svelte | 19 +-- .../(app)/topics/[slug]/page.server.test.ts | 121 ++++++++++++++++++ 14 files changed, 272 insertions(+), 148 deletions(-) create mode 100644 apps/web/src/routes/(app)/topics/[slug]/page.server.test.ts diff --git a/README.md b/README.md index cf7a81e..cd8b790 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,20 @@ Many events carry only a street address, no coordinates — so they never surfac **Backfilling an existing corpus.** The drip only trickles, so to resolve a backlog run the off-Cloudflare CLI against the deployed D1: `pnpm -C apps/web geocode:backfill --limit 50`. It reaches D1 over the REST API, so it needs `CLOUDFLARE_ACCOUNT_ID` / `CLOUDFLARE_API_TOKEN` / `D1_DATABASE_ID` and `MEILI_URL` / `MEILI_KEY` (plus `SEARCH_INDEX` if not `events`), and a LocationIQ `GEOCODER_URL` / `GEOCODER_KEY`. It refuses a bulk or uncapped run against public Nominatim (keyless is capped to `--limit 1..25`). Useful flags: `--limit N` (`0` = no cap), `--dry-run`, `--retry-negative` (re-attempt negatively-cached addresses), and `--allow-public-nominatim` (override the public-host guard). Like search itself, geocoding only helps once the sink is feeding the index, so run this after the rollout steps above. +## Load-more pagination + +Every paginated list — the home events feed, a profile's hosting and past events, a topic, and search — shares one continuation mechanism, so "load more" always resumes the same query on the same backend that produced page 1. + +**Why a self-describing token.** Load-more used to have the client echo back the page-1 query parameters and let the server re-derive which backend to use from the request shape ("is a search term set, and is Meilisearch configured?"). That inference broke whenever a page's first load and its load-more resolved to different backends. A D1 keyset fed to Meilisearch became `Number(base64url)`, which is `NaN`, which collapses to offset 0 — a relevance-reordered duplicate of page 1. A Meilisearch offset fed to D1 was ignored, silently dropping the upcoming and discoverable filters page 1 had applied. + +**The envelope.** The continuation cursor is now an opaque, self-contained token: `base64url(JSON { v, q, args?, raw })`. `q` names the server-side query (`events`, `hosting`, `past-events`, `topic`, `search-d1`, or `search-meili`). `args` carries only public-safe scope choices (a profile actor, a topic slug, the "popular" toggle). `raw` is the opaque backend-native cursor to resume from. The client treats the whole token as a blob and echoes it back unchanged. Load-more looks `q` up in a registry of resumers (`events-load-more.ts`) and re-runs that query with server-authoritative filter values; the client supplies neither the pipeline nor any filter. + +**Why that's safe.** Because every filter value lives server-side in the registry entry, a tampered token cannot widen what it sees — it can only name another already-public query or fail to decode. The unlisted-inclusive plain `listRecords` pipeline deliberately has no registry entry, so no cursor can reach it. `decodeCursor` never throws and returns null on anything malformed, so load-more simply ends pagination cleanly. A deep-linked `?cursor=` only resumes when the envelope was minted for the *same* query — same `q` **and** the same public-safe scope (topic slug, profile actor, or popular/all toggle); a keyset is specific to its result set, so a `technology` topic cursor, another actor's keyset, or a `popular` cursor under `?filter=all` all start a fresh page 1 rather than resuming a foreign position. Search (`search-d1`/`search-meili`) deep-links never resume: their defining term rides `?q=`, not the envelope, so an inbound cursor can't be proven to match the route's term. + +**Backend-native cursors.** `raw` stays opaque and backend-specific: a D1 keyset built inside `@atmo-dev/contrail`, or a Meilisearch offset that `tagCursor` prefixes as `meili:`. `tagCursor` and `parseCursor` in `cursor.ts` are that Meilisearch offset codec, also used by near-me; the envelope simply wraps whatever they produce. Cursors minted before the envelope existed (top-level `meili:` or `d1:` tags, or bare offsets) are tolerated as a deploy-window courtesy — they fail to decode and end pagination cleanly rather than resuming incorrectly — and that tolerance can be dropped once such cursors have drained. + +**The pieces.** `cursor.ts` handles envelope encode/decode and backend tagging. `events-load-more.ts` holds the resumer registry and the shared load-more handler. Each route's `+page.server.ts` mints the page-1 envelope from its own filters, and `EventList.svelte` echoes the token on "load more". Adding a query or backend means registering a resumer, not editing a branch. + ## contributing open for contributions by all :) diff --git a/apps/web/src/lib/components/EventList.svelte b/apps/web/src/lib/components/EventList.svelte index 8c6853b..abf88f8 100644 --- a/apps/web/src/lib/components/EventList.svelte +++ b/apps/web/src/lib/components/EventList.svelte @@ -9,14 +9,19 @@ cursor, handles = {}, actor = undefined, - fetchParams, + q = undefined, gridClass = 'grid gap-6 sm:grid-cols-2' }: { events: FlatEventRecord[]; cursor: string | null; handles?: Record; actor?: string | undefined; - fetchParams: Record; + // The cursor is now a fully opaque, self-describing continuation envelope: + // load-more POSTs only { cursor }, no client-echoed pipeline/ + // filters. The single exception is the free-text search TERM, which stays + // OUT of the envelope and rides here so the search page's load-more can + // re-run its query; other pages leave it undefined. + q?: string | undefined; gridClass?: string; } = $props(); @@ -43,19 +48,13 @@ loading = true; try { - const params: Record = {}; - for (const [key, value] of Object.entries(fetchParams)) { - if (key === 'limit' || key === 'rsvpsGoingCountMin' || key === 'rsvpsCountMin') { - params[key] = Number(value); - } else if (key === 'profiles') { - params[key] = value === 'true'; - } else { - params[key] = value; - } - } - params.cursor = currentCursor; - - const result = await loadMoreEvents(params as Parameters[0]); + // Opaque token in, opaque token out: the envelope names the server-side + // query, so there is no client-side query reconstruction to echo. Only + // the search term (when present) rides alongside the cursor. + const result = await loadMoreEvents({ + cursor: currentCursor, + ...(q !== undefined ? { q } : {}) + }); extraEvents = [...extraEvents, ...result.events]; currentCursor = result.cursor; diff --git a/apps/web/src/routes/(app)/events/+page.server.ts b/apps/web/src/routes/(app)/events/+page.server.ts index 787b2bb..15b64ee 100644 --- a/apps/web/src/routes/(app)/events/+page.server.ts +++ b/apps/web/src/routes/(app)/events/+page.server.ts @@ -3,7 +3,7 @@ import { getServerClient, listDiscoverableEventsFromContrail } from '$lib/contrail'; -import { parseCursor, tagCursor } from '$lib/contrail/cursor'; +import { nextCursor, rawForQuery } from '$lib/contrail/cursor'; import type { PageServerLoad } from './$types'; const PAGE_SIZE = 20; @@ -11,10 +11,10 @@ const PAGE_SIZE = 20; export const load: PageServerLoad = async ({ url, platform }) => { const client = getServerClient(platform!.env.DB); const now = new Date().toISOString(); - // Untag any inbound cursor (deep link) before handing the opaque keyset to D1; - // legacy untagged cursors pass through unchanged (om-7dbs). - const cursor = parseCursor(url.searchParams.get('cursor')).raw ?? undefined; const isPopular = url.searchParams.get('filter') !== 'all'; + // Deep-link ?cursor= resumes only an 'events' cursor minted for the same + // popular/all filter; anything else -> fresh page 1 (see rawForQuery). + const cursor = rawForQuery(url.searchParams.get('cursor'), 'events', { popular: isPopular }); const response = await listDiscoverableEventsFromContrail(client, { startsAtMin: now, @@ -36,8 +36,8 @@ export const load: PageServerLoad = async ({ url, platform }) => { return { events: flattenEventRecords(response.records), handles, - // Tag the first-page cursor so load-more routes back to this same D1 - // discoverable pipeline instead of re-inferring a backend (om-7dbs). - cursor: tagCursor('d1', response.cursor ?? null) + // A self-describing envelope: load-more re-runs THIS server-side query + // (discoverable + startsAtMin=now + the popular toggle), no client filters. + cursor: nextCursor('events', response.cursor ?? null, { popular: isPopular }) }; }; diff --git a/apps/web/src/routes/(app)/events/+page.svelte b/apps/web/src/routes/(app)/events/+page.svelte index 35b20f6..8a3142e 100644 --- a/apps/web/src/routes/(app)/events/+page.svelte +++ b/apps/web/src/routes/(app)/events/+page.svelte @@ -8,18 +8,6 @@ let filter = $derived(page.url.searchParams.get('filter') === 'all' ? 'all' : 'popular'); - let fetchParams = $derived({ - // load-more must re-run the discoverable pipeline + popular filter page 1 - // used, or unlisted / non-popular events leak onto later pages. - pipeline: 'discoverable', - startsAtMin: new Date().toISOString(), - profiles: 'true', - sort: 'startsAt', - order: 'asc', - limit: '20', - ...(filter === 'popular' ? { rsvpsCountMin: '2' } : {}) - }); - function setFilter(val: string) { const url = new URL(page.url); if (val === 'all') url.searchParams.set('filter', 'all'); @@ -67,6 +55,6 @@ {/if}

{:else} - + {/if} diff --git a/apps/web/src/routes/(app)/p/[actor]/hosting/+page.server.ts b/apps/web/src/routes/(app)/p/[actor]/hosting/+page.server.ts index 6486d82..35e75f4 100644 --- a/apps/web/src/routes/(app)/p/[actor]/hosting/+page.server.ts +++ b/apps/web/src/routes/(app)/p/[actor]/hosting/+page.server.ts @@ -5,7 +5,7 @@ import { getServerClient, listAuthoredEventsFromContrail } from '$lib/contrail'; -import { parseCursor, tagCursor } from '$lib/contrail/cursor'; +import { nextCursor, rawForQuery } from '$lib/contrail/cursor'; import { isActorIdentifier } from '@atcute/lexicons/syntax'; import { error } from '@sveltejs/kit'; @@ -20,8 +20,8 @@ export async function load({ params, url, platform }) { if (!did) throw error(404, 'Actor not found'); - // Untag any inbound cursor before the D1 read; legacy untagged passes through. - const cursor = parseCursor(url.searchParams.get('cursor')).raw ?? undefined; + // Deep-link ?cursor= resumes only a 'hosting' cursor for this actor; else fresh page 1. + const cursor = rawForQuery(url.searchParams.get('cursor'), 'hosting', { actor }); const now = new Date().toISOString(); const [profile, response] = await Promise.all([ @@ -39,8 +39,9 @@ export async function load({ params, url, platform }) { return { events: response ? flattenEventRecords(response.records) : [], - // Tag so load-more stays on this D1 authored pipeline (om-7dbs). - cursor: tagCursor('d1', response?.cursor ?? null), + // Self-describing envelope: load-more re-runs the authored + upcoming query + // scoped to this actor, server-side. + cursor: nextCursor('hosting', response?.cursor ?? null, { actor }), actorProfile: profile, actor, actorDid: did diff --git a/apps/web/src/routes/(app)/p/[actor]/hosting/+page.svelte b/apps/web/src/routes/(app)/p/[actor]/hosting/+page.svelte index dbd98b3..c55eee9 100644 --- a/apps/web/src/routes/(app)/p/[actor]/hosting/+page.svelte +++ b/apps/web/src/routes/(app)/p/[actor]/hosting/+page.svelte @@ -10,18 +10,6 @@ let hostAvatar = $derived( hostProfile?.value?.avatar ? getProfileBlobUrl(hostDid, hostProfile.value.avatar) : undefined ); - - let fetchParams: Record = $derived({ - // load-more must re-run the authored pipeline page 1 used, or conference - // talks (excluded by listAuthored) leak onto later pages. - pipeline: 'authored', - profiles: 'true', - sort: 'startsAt', - order: 'asc', - startsAtMin: new Date().toISOString(), - ...(data.actor ? { actor: data.actor } : {}), - limit: '20' - }); @@ -55,7 +43,6 @@ events={data.events ?? []} cursor={data.cursor ?? null} actor={data.actor} - {fetchParams} gridClass="space-y-3" /> {:else} diff --git a/apps/web/src/routes/(app)/p/[actor]/past-events/+page.server.ts b/apps/web/src/routes/(app)/p/[actor]/past-events/+page.server.ts index 56549b0..44fe029 100644 --- a/apps/web/src/routes/(app)/p/[actor]/past-events/+page.server.ts +++ b/apps/web/src/routes/(app)/p/[actor]/past-events/+page.server.ts @@ -5,7 +5,7 @@ import { getServerClient, listAuthoredEventsFromContrail } from '$lib/contrail'; -import { parseCursor, tagCursor } from '$lib/contrail/cursor'; +import { nextCursor, rawForQuery } from '$lib/contrail/cursor'; import { isActorIdentifier } from '@atcute/lexicons/syntax'; import { error } from '@sveltejs/kit'; @@ -20,8 +20,8 @@ export async function load({ params, url, platform }) { if (!did) throw error(404, 'Actor not found'); - // Untag any inbound cursor before the D1 read; legacy untagged passes through. - const cursor = parseCursor(url.searchParams.get('cursor')).raw ?? undefined; + // Deep-link ?cursor= resumes only a 'past-events' cursor for this actor; else fresh page 1. + const cursor = rawForQuery(url.searchParams.get('cursor'), 'past-events', { actor }); const now = new Date().toISOString(); const [profile, response] = await Promise.all([ @@ -44,8 +44,9 @@ export async function load({ params, url, platform }) { return { events, - // Tag so load-more stays on this D1 authored pipeline (om-7dbs). - cursor: tagCursor('d1', response?.cursor ?? null), + // Self-describing envelope: load-more re-runs the authored + past query + // (desc, startsAtMax=now) scoped to this actor, server-side. + cursor: nextCursor('past-events', response?.cursor ?? null, { actor }), actorProfile: profile, actor, actorDid: did diff --git a/apps/web/src/routes/(app)/p/[actor]/past-events/+page.svelte b/apps/web/src/routes/(app)/p/[actor]/past-events/+page.svelte index 3b73860..a2b76e1 100644 --- a/apps/web/src/routes/(app)/p/[actor]/past-events/+page.svelte +++ b/apps/web/src/routes/(app)/p/[actor]/past-events/+page.svelte @@ -10,18 +10,6 @@ let hostAvatar = $derived( hostProfile?.value?.avatar ? getProfileBlobUrl(hostDid, hostProfile.value.avatar) : undefined ); - - let fetchParams: Record = $derived({ - // load-more must re-run the authored pipeline page 1 used, or conference - // talks (excluded by listAuthored) leak onto later pages. - pipeline: 'authored', - profiles: 'true', - sort: 'startsAt', - order: 'desc', - startsAtMax: new Date().toISOString(), - ...(data.actor ? { actor: data.actor } : {}), - limit: '20' - }); @@ -55,7 +43,6 @@ events={data.events ?? []} cursor={data.cursor ?? null} actor={data.actor} - {fetchParams} gridClass="space-y-3" /> {:else} diff --git a/apps/web/src/routes/(app)/search/+page.server.ts b/apps/web/src/routes/(app)/search/+page.server.ts index 95eeb5e..9914d94 100644 --- a/apps/web/src/routes/(app)/search/+page.server.ts +++ b/apps/web/src/routes/(app)/search/+page.server.ts @@ -5,23 +5,32 @@ import { } from '$lib/contrail'; import { runEventSearchPage, searchBackendFromEnv } from '$lib/search/server/query'; import { SEARCH_PAGE_SIZE } from '$lib/search/constants'; +import { nextCursor } from '$lib/contrail/cursor'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ url, platform }) => { const client = getServerClient(platform!.env.DB); const q = url.searchParams.get('q')?.trim() || ''; - const cursor = url.searchParams.get('cursor') ?? undefined; if (!q) return { events: [], handles: {}, cursor: null, query: '' }; + // Search page 1 does NOT resume from ?cursor=: the term rides ?q=, not the + // envelope, so an inbound cursor can't be proven to match this route's term. + // (Load-more still resumes via the remote command, which carries the term.) + // Meilisearch ranks (typo tolerance, prefix, relevance); D1 supplies the // records. Falls back to the LIKE-based D1 path when the search backend is // unconfigured (local dev) or down. const backend = searchBackendFromEnv(platform?.env); if (backend) { try { - const page = await runEventSearchPage(backend, client, { q, cursor }); - return { events: page.events, handles: page.handles, cursor: page.cursor, query: q }; + const page = await runEventSearchPage(backend, client, { q }); + return { + events: page.events, + handles: page.handles, + cursor: nextCursor('search-meili', page.cursor), + query: q + }; } catch (err) { console.error('search backend failed, falling back to D1 search:', err); } @@ -36,8 +45,7 @@ export const load: PageServerLoad = async ({ url, platform }) => { startsAtMin: new Date().toISOString(), sort: 'startsAt', order: 'desc', - limit: SEARCH_PAGE_SIZE, - cursor + limit: SEARCH_PAGE_SIZE }); if (!response) return { events: [], handles: {}, cursor: null, query: q }; @@ -50,14 +58,11 @@ export const load: PageServerLoad = async ({ url, platform }) => { return { events: flattenEventRecords(response.records), handles, - // The D1 fallback is first-batch-only; don't hand its cursor back. Even - // with self-describing cursors (om-7dbs), the search fetchParams carry no - // `pipeline`, so a d1-tagged cursor would route load-more through plain - // listRecords — dropping the discoverable filter and startsAtMin this page - // applies — and later pages would drift into past and non-discoverable - // events. Re-enabling consistent D1 pagination here would mean threading the - // discoverable pipeline + filters through; deferred. Drop the cursor. - cursor: null, + // Self-describing 'search-d1' envelope: load-more re-runs the SAME + // discoverable + startsAtMin + desc query, with the search term from ?q=/ + // input — later pages stay upcoming-only and discoverable, no drift into + // past/non-discoverable events. + cursor: nextCursor('search-d1', response.cursor ?? null), query: q }; }; diff --git a/apps/web/src/routes/(app)/search/+page.svelte b/apps/web/src/routes/(app)/search/+page.svelte index ee540d7..87378b7 100644 --- a/apps/web/src/routes/(app)/search/+page.svelte +++ b/apps/web/src/routes/(app)/search/+page.svelte @@ -57,13 +57,7 @@ events={data.events} cursor={data.cursor} handles={data.handles} - fetchParams={{ - search: data.query, - profiles: 'true', - sort: 'startsAt', - order: 'desc', - limit: '20' - }} + q={data.query} /> {/if} {/if} diff --git a/apps/web/src/routes/(app)/search/page.server.test.ts b/apps/web/src/routes/(app)/search/page.server.test.ts index 2e2f491..4062a03 100644 --- a/apps/web/src/routes/(app)/search/page.server.test.ts +++ b/apps/web/src/routes/(app)/search/page.server.test.ts @@ -2,12 +2,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; // The search load decides between two backends whose cursors are NOT // interchangeable: Meilisearch (offset cursor) and the D1 LIKE fallback (opaque -// cursor). loadMoreEvents re-routes to Meili whenever a backend is configured, -// so a D1 cursor handed back after a backend failure would be misread. And even -// with no backend at all, loadMoreEvents paginates via listRecords — without the -// discoverable filter or startsAtMin this load applies — so its cursor isn't -// safe to continue either. The D1 fallback is therefore first-batch-only. These -// tests pin that contract. +// keyset). Both now hand back a self-describing continuation ENVELOPE: the Meili +// path a 'search-meili' envelope, the D1 fallback a 'search-d1' envelope. The D1 +// fallback used to return cursor:null because load-more had no safe way to +// re-run the discoverable+startsAtMin query — the envelope closes that gap (and +// with it the earlier "search results stop after the first batch" limitation), +// so these tests now pin a REAL cursor on the D1 path, keyed to a query the +// load-more registry re-runs identically. vi.mock('$lib/contrail', () => ({ getServerClient: vi.fn(() => ({})), flattenEventRecords: vi.fn((records: unknown[]) => records), @@ -19,8 +20,9 @@ vi.mock('$lib/search/server/query', () => ({ })); import { load } from './+page.server'; -import { flattenEventRecords, listDiscoverableEventsFromContrail } from '$lib/contrail'; +import { listDiscoverableEventsFromContrail } from '$lib/contrail'; import { runEventSearchPage, searchBackendFromEnv } from '$lib/search/server/query'; +import { decodeCursor, encodeCursor } from '$lib/contrail/cursor'; const mockSearchBackendFromEnv = vi.mocked(searchBackendFromEnv); const mockRunEventSearchPage = vi.mocked(runEventSearchPage); @@ -54,23 +56,24 @@ describe('search page load', () => { expect(mockListDiscoverable).not.toHaveBeenCalled(); }); - it('serves the Meili page (offset cursor) when the backend succeeds', async () => { + it('serves the Meili page wrapped in a search-meili envelope when the backend succeeds', async () => { mockSearchBackendFromEnv.mockReturnValue({ url: 'https://meili.test', apiKey: 'k' }); mockRunEventSearchPage.mockResolvedValue({ events: [{ uri: 'at://did:plc:a/community.lexicon.calendar.event/1' }], handles: { 'did:plc:a': 'alice' }, - cursor: '20', + cursor: 'meili:20', distances: {} } as unknown as Awaited>); const result = await runLoad('kite'); - expect(result.cursor).toBe('20'); - expect(result.events).toHaveLength(1); + // The offset rides inside a self-describing envelope, so load-more re-runs + // the Meili path (not D1 listRecords) with the term from ?q=/input. + expect(decodeCursor(result.cursor)).toEqual({ v: 1, q: 'search-meili', raw: 'meili:20' }); expect(mockListDiscoverable).not.toHaveBeenCalled(); }); - it('drops the D1 cursor when a configured backend fails, so load-more cannot misroute it', async () => { + it('paginates the D1 fallback with a search-d1 envelope when a configured backend fails', async () => { mockSearchBackendFromEnv.mockReturnValue({ url: 'https://meili.test', apiKey: 'k' }); mockRunEventSearchPage.mockRejectedValue(new Error('meili down')); mockListDiscoverable.mockResolvedValue({ @@ -81,15 +84,13 @@ describe('search page load', () => { const result = await runLoad('kite'); - // Events still served from the D1 fallback... - expect(result.events).toHaveLength(1); expect(result.handles).toEqual({ 'did:plc:b': 'bob' }); - // ...but the incompatible D1 cursor is suppressed. - expect(result.cursor).toBeNull(); - expect(flattenEventRecords).toHaveBeenCalled(); + // The D1 cursor is now RESUMABLE: a search-d1 envelope whose load-more re-runs + // the same discoverable + startsAtMin + desc query. No more cursor:null. + expect(decodeCursor(result.cursor)).toEqual({ v: 1, q: 'search-d1', raw: 'd1-opaque-cursor' }); }); - it('drops the D1 cursor when no backend is configured, so load-more cannot drift past the first batch', async () => { + it('paginates the D1 fallback with a search-d1 envelope when no backend is configured', async () => { mockSearchBackendFromEnv.mockReturnValue(null); mockListDiscoverable.mockResolvedValue({ records: [{ uri: 'at://did:plc:c/community.lexicon.calendar.event/3' }], @@ -99,12 +100,56 @@ describe('search page load', () => { const result = await runLoad('kite'); - // First batch is served from the discoverable, upcoming-only D1 query... - expect(result.events).toHaveLength(1); - // ...but the cursor is suppressed: loadMoreEvents would paginate via - // listRecords without the discoverable filter or startsAtMin, drifting - // into past and non-discoverable events on later pages. - expect(result.cursor).toBeNull(); + // First batch is the discoverable, upcoming-only, desc D1 query... + const params = mockListDiscoverable.mock.calls[0][1]; + expect(params).toMatchObject({ search: 'kite', order: 'desc' }); + expect(typeof params.startsAtMin).toBe('string'); + // ...and later pages resume it via a real envelope, not cursor:null. + expect(decodeCursor(result.cursor)).toEqual({ v: 1, q: 'search-d1', raw: 'd1-opaque-cursor' }); expect(mockRunEventSearchPage).not.toHaveBeenCalled(); }); + + it('ends cleanly (cursor:null) only on a genuinely last D1 page', async () => { + mockSearchBackendFromEnv.mockReturnValue(null); + mockListDiscoverable.mockResolvedValue({ + records: [{ uri: 'at://did:plc:d/community.lexicon.calendar.event/4' }], + profiles: [], + cursor: null + } as unknown as Awaited>); + + const result = await runLoad('kite'); + expect(result.cursor).toBeNull(); + }); + + it('deep-link: does NOT resume even its OWN search-d1 envelope (term not in envelope)', async () => { + mockSearchBackendFromEnv.mockReturnValue(null); + mockListDiscoverable.mockResolvedValue({ + records: [], + profiles: [], + cursor: null + } as unknown as Awaited>); + + // The keyset was minted for SOME term, but the term rides ?q= and is absent + // from the envelope — a `dogs` keyset under ?q=kite would corrupt pagination, + // and the two are indistinguishable. So page 1 always starts fresh. + const inbound = encodeCursor({ v: 1, q: 'search-d1', raw: 'page2keyset' }); + await runLoad('kite', inbound); + + expect(mockListDiscoverable.mock.calls[0][1].cursor).toBeUndefined(); + }); + + it('deep-link: ignores a foreign-query envelope (fresh page 1, no resume)', async () => { + mockSearchBackendFromEnv.mockReturnValue(null); + mockListDiscoverable.mockResolvedValue({ + records: [], + profiles: [], + cursor: null + } as unknown as Awaited>); + + // An 'events' envelope deep-linked into /search must not resume its keyset. + const foreign = encodeCursor({ v: 1, q: 'events', args: { popular: true }, raw: 'nope' }); + await runLoad('kite', foreign); + + expect(mockListDiscoverable.mock.calls[0][1].cursor).toBeUndefined(); + }); }); diff --git a/apps/web/src/routes/(app)/topics/[slug]/+page.server.ts b/apps/web/src/routes/(app)/topics/[slug]/+page.server.ts index e7f9628..ba7b4c4 100644 --- a/apps/web/src/routes/(app)/topics/[slug]/+page.server.ts +++ b/apps/web/src/routes/(app)/topics/[slug]/+page.server.ts @@ -1,15 +1,16 @@ import { error } from '@sveltejs/kit'; -import { getTopicBySlug } from '$lib/topics'; +import { getTopicBySlug, orQueryFromSlug } from '$lib/topics'; import { flattenEventRecords, getServerClient, listDiscoverableEventsFromContrail } from '$lib/contrail'; +import { nextCursor, rawForQuery } from '$lib/contrail/cursor'; import type { PageServerLoad } from './$types'; const PAGE_SIZE = 20; -export const load: PageServerLoad = async ({ params, platform }) => { +export const load: PageServerLoad = async ({ params, url, platform }) => { const topic = getTopicBySlug(params.slug); if (!topic) error(404, 'Topic not found'); @@ -18,9 +19,9 @@ export const load: PageServerLoad = async ({ params, platform }) => { // Match events whose name/description mention ANY of the topic's hashtag // terms. The discoverable list runs `search` through D1's SQLite FTS5 MATCH, // where an uppercase OR is a real disjunction operator — so this is a true - // "any term" query. (Meili treats OR as a literal token, but this page never - // routes through Meili: see the cursor note below.) - const query = topic.hashtags.map((h) => h.replace(/^#/, '')).join(' OR '); + // "any term" query. Derived SERVER-side from the slug via the shared helper the + // load-more registry also uses, so page 1 and load-more can't drift. + const query = orQueryFromSlug(params.slug) ?? ''; const response = await listDiscoverableEventsFromContrail(client, { search: query, @@ -29,7 +30,9 @@ export const load: PageServerLoad = async ({ params, platform }) => { startsAtMin: new Date().toISOString(), sort: 'startsAt', order: 'asc', - limit: PAGE_SIZE + limit: PAGE_SIZE, + // Deep-link ?cursor= resumes only a 'topic' cursor for this slug; else fresh page 1. + cursor: rawForQuery(url.searchParams.get('cursor'), 'topic', { slug: params.slug }) }); const handles: Record = {}; @@ -41,15 +44,11 @@ export const load: PageServerLoad = async ({ params, platform }) => { topic, events: flattenEventRecords(response?.records ?? []), handles, - // First-batch-only, like the search page's D1 fallback. Self-describing - // cursors (om-7dbs) now stop this page's D1 cursor from being mis-consumed - // as a Meili offset, but they don't make it resumable here: this fetchParams - // contract carries no `pipeline`, so a d1-tagged cursor would still route - // load-more through plain listRecords, dropping the discoverable + - // startsAtMin filters this page relies on. So don't hand back a cursor. Deep - // topic pagination (threading the discoverable pipeline through) is tracked - // separately (om-47ak). - cursor: null, + // Self-describing 'topic' envelope carrying the slug: load-more re-derives + // the same OR-search from the slug SERVER-side and re-runs the identical + // discoverable + startsAtMin query — later pages stay upcoming-only and + // discoverable. + cursor: nextCursor('topic', response?.cursor ?? null, { slug: params.slug }), query }; }; diff --git a/apps/web/src/routes/(app)/topics/[slug]/+page.svelte b/apps/web/src/routes/(app)/topics/[slug]/+page.svelte index 119020c..b8ef977 100644 --- a/apps/web/src/routes/(app)/topics/[slug]/+page.svelte +++ b/apps/web/src/routes/(app)/topics/[slug]/+page.svelte @@ -2,18 +2,6 @@ import EventList from '$lib/components/EventList.svelte'; let { data } = $props(); - - // Query is built server-side and passed through `data` so the two stay in - // sync. Mirrors the search page. The topic page is first-batch-only - // (data.cursor is null), so these params are only a contract for EventList, - // not an active pagination path. - let fetchParams = $derived({ - search: data.query, - profiles: 'true', - sort: 'startsAt', - order: 'asc', - limit: '20' - }); @@ -62,11 +50,6 @@

{:else} - + {/if} diff --git a/apps/web/src/routes/(app)/topics/[slug]/page.server.test.ts b/apps/web/src/routes/(app)/topics/[slug]/page.server.test.ts new file mode 100644 index 0000000..f3c8b9e --- /dev/null +++ b/apps/web/src/routes/(app)/topics/[slug]/page.server.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// The topic page used to return cursor:null (first-batch-only) because load-more +// had no safe way to re-run its discoverable + OR-search + startsAtMin query. +// The envelope closes that gap: the load now emits a self-describing 'topic' +// envelope carrying the slug, and the load-more registry re-derives the SAME +// OR-search from that slug server-side. These pin the page-1 side of that +// continuity plus the deep-link query-match rule. +vi.mock('$lib/contrail', () => ({ + getServerClient: vi.fn(() => ({})), + flattenEventRecords: vi.fn((records: unknown[]) => records), + listDiscoverableEventsFromContrail: vi.fn() +})); + +import { load } from './+page.server'; +import { listDiscoverableEventsFromContrail } from '$lib/contrail'; +import { decodeCursor, encodeCursor } from '$lib/contrail/cursor'; + +const mockListDiscoverable = vi.mocked(listDiscoverableEventsFromContrail); + +type LoadResult = { + topic: { slug: string }; + events: unknown[]; + handles: Record; + cursor: string | null; + query: string; +}; + +function event(slug: string, cursor?: string) { + const url = new URL(`https://atmo.test/topics/${slug}`); + if (cursor) url.searchParams.set('cursor', cursor); + return { params: { slug }, url, platform: { env: {} } } as unknown as Parameters[0]; +} + +const run = async (slug: string, cursor?: string) => + (await load(event(slug, cursor))) as unknown as LoadResult; + +afterEach(() => vi.clearAllMocks()); + +describe('topic page load', () => { + it("builds a 'topic' envelope carrying the slug, deriving the OR-search server-side", async () => { + mockListDiscoverable.mockResolvedValue({ + records: [{ uri: 'at://did:plc:a/community.lexicon.calendar.event/1' }], + profiles: [{ did: 'did:plc:a', handle: 'alice' }], + cursor: 'd1-topic-cursor' + } as unknown as Awaited>); + + const result = await run('technology'); + + const params = mockListDiscoverable.mock.calls[0][1]; + // orQueryFromSlug('technology') — the SAME helper the load-more registry uses. + expect(params.search).toBe('tech OR technology'); + expect(params).toMatchObject({ order: 'asc', limit: 20, profiles: true }); + expect(typeof params.startsAtMin).toBe('string'); + expect(result.query).toBe('tech OR technology'); + // A resumable envelope, not cursor:null. + expect(decodeCursor(result.cursor)).toEqual({ + v: 1, + q: 'topic', + args: { slug: 'technology' }, + raw: 'd1-topic-cursor' + }); + }); + + it('ends cleanly (cursor:null) only on a genuinely last page', async () => { + mockListDiscoverable.mockResolvedValue({ + records: [{ uri: 'at://did:plc:a/community.lexicon.calendar.event/1' }], + profiles: [], + cursor: null + } as unknown as Awaited>); + + const result = await run('technology'); + expect(result.cursor).toBeNull(); + }); + + it('deep-link: resumes a topic envelope by feeding its raw keyset to D1', async () => { + mockListDiscoverable.mockResolvedValue({ + records: [], + profiles: [], + cursor: null + } as unknown as Awaited>); + + const inbound = encodeCursor({ v: 1, q: 'topic', args: { slug: 'technology' }, raw: 'p2keyset' }); + await run('technology', inbound); + + expect(mockListDiscoverable.mock.calls[0][1]).toMatchObject({ cursor: 'p2keyset' }); + }); + + it('deep-link: ignores a foreign-query envelope (fresh page 1)', async () => { + mockListDiscoverable.mockResolvedValue({ + records: [], + profiles: [], + cursor: null + } as unknown as Awaited>); + + const foreign = encodeCursor({ v: 1, q: 'events', args: { popular: true }, raw: 'nope' }); + await run('technology', foreign); + + expect(mockListDiscoverable.mock.calls[0][1].cursor).toBeUndefined(); + }); + + it("deep-link: ignores a topic envelope minted for a DIFFERENT slug (fresh page 1)", async () => { + mockListDiscoverable.mockResolvedValue({ + records: [], + profiles: [], + cursor: null + } as unknown as Awaited>); + + // A 'technology' keyset deep-linked into /topics/ai names the same query but + // indexes a different OR-search result set — must not resume. + const otherSlug = encodeCursor({ v: 1, q: 'topic', args: { slug: 'technology' }, raw: 'nope' }); + await run('ai', otherSlug); + + expect(mockListDiscoverable.mock.calls[0][1].cursor).toBeUndefined(); + }); + + it('404s for an unknown slug before touching D1', async () => { + await expect(run('no-such-topic')).rejects.toThrow(); + expect(mockListDiscoverable).not.toHaveBeenCalled(); + }); +}); -- 2.51.2