From 4fa2d0b2b5e4b43c9991b80e45d9020c5cd5595b Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Sat, 25 Jul 2026 08:21:24 -0400 Subject: [PATCH] refactor(pagination): define each list query once, call it from both pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Page 1 and load-more are separate server entry points by necessity: a route load() that has url/params, and a remote command that has only an opaque cursor. Each hand-wrote the same filter bag, and a keyset cursor is specific to the query that produced it — so page 2 is adjacent to page 1 only while every filter, sort, bound and limit agrees between two call sites nothing forces to agree. Define each list once in contrail/queries.ts and have both entry points call it. Sort, order, limit, time bounds, any post-filter and the next-page envelope now live in one place. The resumer registry keeps its real job — validating decoded args at the trust boundary and naming which query to continue — and no longer restates the query itself. Two consequences worth naming. The past-events narrowing (its upper time bound admits an event that is still running) moves inside the query, so every page inherits it rather than applying it twice. And because that narrowing runs after pagination, a page can come back short or even empty while a cursor remains — so the "load more" affordance keys on the cursor, not on the page having events. That rule was previously implicit in one Svelte guard; it is now stated where callers will find it. Search keeps its page-1-only Meili->D1 fallback: a continuation must not switch backends, or it restarts page 1 with a keyset the other backend cannot read. Behaviour-preserving — the page-1/load-more continuity tests pass through it unchanged. README's pagination section is updated to match and trimmed to the model a reader needs. --- README.md | 12 +- apps/web/src/lib/contrail/events-load-more.ts | 198 +++++------------ apps/web/src/lib/contrail/queries.ts | 210 ++++++++++++++++++ .../src/routes/(app)/events/+page.server.ts | 39 +--- .../(app)/p/[actor]/hosting/+page.server.ts | 31 +-- .../p/[actor]/past-events/+page.server.ts | 41 +--- .../src/routes/(app)/search/+page.server.ts | 59 +---- .../(app)/topics/[slug]/+page.server.ts | 50 +---- 8 files changed, 313 insertions(+), 327 deletions(-) create mode 100644 apps/web/src/lib/contrail/queries.ts diff --git a/README.md b/README.md index cd8b790..6c20998 100644 --- a/README.md +++ b/README.md @@ -75,17 +75,15 @@ Many events carry only a street address, no coordinates — so they never surfac ## 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. +Every paginated list — the home feed, a profile's hosting and past events, a topic, and search — is defined once in `queries.ts` and continued by one shared mechanism. -**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. +**One definition, two entry points.** Page 1 runs in a route's `load()`, which has `url`/`params`; load-more runs in a remote command, which has only a cursor. A keyset is specific to the query that produced it, so page 2 is adjacent to page 1 only while every filter, sort, bound and limit agrees. Both entry points therefore call the same definition, which also owns any post-filter and mints the envelope that continues it. -**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. +**The envelope.** The continuation cursor is an opaque `base64url(JSON { v, q, args?, raw })`. `q` names the server-side query (`events`, `hosting`, `past-events`, `topic`, `search-d1`, `search-meili`); `args` carries only public-safe scope (profile actor, topic slug, popular toggle); `raw` is the backend-native cursor — a D1 keyset, or a Meilisearch offset that `tagCursor` prefixes as `meili:`. The client echoes the whole token back unchanged and supplies no filters of its own. Naming the query is what keeps a cursor with its backend: read a D1 keyset as a Meili offset and `Number(base64url)` is `NaN`, which collapses to offset 0 — page 1 again, silently. -**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. +**Why that's safe.** Every filter value lives in the query definition, so a tampered token can only name another already-public query or fail to decode; the unlisted-inclusive plain `listRecords` pipeline has no registry entry, so no cursor reaches it. `decodeCursor` never throws — anything malformed, including a pre-envelope `meili:`/`d1:` cursor, just ends pagination. A deep-linked `?cursor=` resumes only when the envelope was minted for the same `q` **and** the same scope, since a `/topics/ai` keyset indexes a different result set than a `/topics/technology` one. Search never resumes a deep link at all: its 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. +**The pieces.** `queries.ts` defines each list and mints its envelope. `cursor.ts` encodes/decodes envelopes and tags backend cursors. `events-load-more.ts` holds the resumer registry: it validates a decoded envelope's args and names which query to continue. Each route's `+page.server.ts` calls its query for page 1 and adds whatever else that page renders; `EventList.svelte` echoes the token on "load more" — keyed on the cursor, not on the page having events, since a query with a post-filter (`past-events`) can return a short or empty page while more pages remain. Adding a list means defining its query and registering it. ## contributing diff --git a/apps/web/src/lib/contrail/events-load-more.ts b/apps/web/src/lib/contrail/events-load-more.ts index 3cd24a6..864c64a 100644 --- a/apps/web/src/lib/contrail/events-load-more.ts +++ b/apps/web/src/lib/contrail/events-load-more.ts @@ -1,28 +1,24 @@ import * as v from 'valibot'; import type { Client } from '@atcute/client'; -import type { ActorIdentifier } from '@atcute/lexicons'; import { isActorIdentifier } from '@atcute/lexicons/syntax'; import { getServerClient } from './index'; import { - flattenEventRecords, - listAuthoredEventsFromContrail, - listDiscoverableEventsFromContrail -} from '$lib/contrail'; -import { runEventSearchPage, searchBackendFromEnv } from '$lib/search/server/query'; -import { SEARCH_PAGE_SIZE } from '$lib/search/constants'; -import { orQueryFromSlug } from '$lib/topics'; -import { hasEnded } from '$lib/past-events'; -import { decodeCursor, nextCursor, type CursorArgs, type CursorEnvelope, type CursorQuery } from './cursor'; - -const PAGE_SIZE = 20; - -// The load-more remote input. The continuation cursor is now a self-describing -// ENVELOPE carrying the server-side query name + public-safe args, so the client -// no longer echoes a query-reconstruction bag of pipeline/filters. Only two -// fields are read: `cursor` (the envelope) and `q` (the search TERM, which stays -// OUT of the envelope and rides ?q=/input — see the search resumers). A legacy -// client may still POST extra query params; `v.object` drops them, so they are -// accepted WITHOUT being trusted. + EMPTY_PAGE, + eventsQuery, + hostingQuery, + pastEventsQuery, + searchD1Query, + searchMeiliQuery, + topicQuery, + type EventsPage +} from './queries'; +import { decodeCursor, type CursorEnvelope, type CursorQuery } from './cursor'; + +// The load-more remote input. Exactly two fields are read: `cursor`, the opaque +// continuation envelope naming the server-side query and its public-safe args, +// and `q`, the free-text search TERM — which stays OUT of the envelope and rides +// ?q=/input (see the search resumers). A client may POST extra query params; +// `v.object` drops them, so they are accepted WITHOUT being trusted. export const listEventsInput = v.object({ cursor: v.optional(v.string()), /** Free-text search term for the search page; ignored for every other query. */ @@ -31,147 +27,66 @@ export const listEventsInput = v.object({ export type LoadMoreEventsInput = v.InferOutput; -export type LoadMoreEventsResult = { - events: ReturnType; - handles: Record; - cursor: string | null; -}; - -const EMPTY: LoadMoreEventsResult = { events: [], handles: {}, cursor: null }; - -function now(): string { - return new Date().toISOString(); -} +/** Load-more returns the same page shape a route's page-1 `load()` returns. */ +export type LoadMoreEventsResult = EventsPage; -/** - * Shape a contrail list response into a load-more result, re-encoding the next - * page's cursor as a same-query envelope (identical `q`/`args`, the fresh raw - * keyset) so continuations stay on the same server-authoritative query. - */ -function toResult( - q: CursorQuery, - args: CursorArgs | undefined, - response: Awaited> -): LoadMoreEventsResult { - if (!response) return EMPTY; - const events = flattenEventRecords(response.records ?? []); - const handles: Record = {}; - for (const p of response.profiles ?? []) { - if (p.handle) handles[p.did] = p.handle; - } - return { events, handles, cursor: nextCursor(q, response.cursor ?? null, args) }; -} +const EMPTY = EMPTY_PAGE; /** - * A resumer re-runs the page-1 query named by the envelope, from the envelope's - * opaque `raw` keyset, with SERVER-AUTHORITATIVE filter values. It receives the - * decoded envelope (never the raw client bag) plus the free-text search term - * (search queries only). Missing/malformed required args => end cleanly (EMPTY); - * never throw, never fall through to another query. + * A resumer continues the query named by the envelope, from the envelope's + * opaque `raw` keyset. It does not define the query: it validates the decoded + * args at the trust boundary and hands them to the shared query in `queries.ts` + * — the same function the route's page-1 `load()` calls. + * + * Malformed or missing required args end pagination cleanly (EMPTY); a resumer + * never throws and never falls through to another query. */ type Resumer = ( env: App.Platform['env'], client: Client, envelope: CursorEnvelope, searchTerm: string | undefined -) => Promise; +) => Promise; -// The backend->resumer REGISTRY, keyed by the envelope's query name. Adding a -// paginated query is REGISTERING an entry here, not editing a conditional; every -// filter VALUE is server-authoritative and lives in the entry. The plain, -// unlisted-inclusive listRecords pipeline deliberately has NO entry, so no -// decoded envelope can reach it. (See README → "Load-more pagination".) +// The query-name -> resumer REGISTRY. Adding a paginated list means defining its +// query in `queries.ts` and registering it here. The plain, unlisted-inclusive +// listRecords pipeline deliberately has NO entry, so no decoded envelope can +// reach it. (See README → "Load-more pagination".) const REGISTRY: Record = { - events: async (_env, client, { args, raw }) => { - const response = await listDiscoverableEventsFromContrail(client, { - startsAtMin: now(), - profiles: true, - sort: 'startsAt', - order: 'asc', - limit: PAGE_SIZE, - ...(args?.popular ? { rsvpsCountMin: 2 } : {}), - cursor: raw - }); - return toResult('events', args, response); - }, + events: async (_env, client, { args, raw }) => + eventsQuery(client, { popular: args?.popular === true }, raw), hosting: async (_env, client, { args, raw }) => { - if (!args?.actor || !isActorIdentifier(args.actor)) return EMPTY; - const response = await listAuthoredEventsFromContrail(client, { - actor: args.actor as ActorIdentifier, - startsAtMin: now(), - sort: 'startsAt', - order: 'asc', - profiles: true, - limit: PAGE_SIZE, - cursor: raw - }); - return toResult('hosting', args, response); + const actor = args?.actor; + if (!actor || !isActorIdentifier(actor)) return EMPTY; + return hostingQuery(client, { actor }, raw); }, 'past-events': async (_env, client, { args, raw }) => { - if (!args?.actor || !isActorIdentifier(args.actor)) return EMPTY; - const asOf = now(); - const response = await listAuthoredEventsFromContrail(client, { - actor: args.actor as ActorIdentifier, - startsAtMax: asOf, - sort: 'startsAt', - order: 'desc', - profiles: true, - limit: PAGE_SIZE, - cursor: raw - }); - // Page 1 narrows the same way, with the same shared predicate: startsAtMax - // still admits an event that began earlier and is still running, and an - // ongoing event must not be hidden on page 1 only to resurface on page 2. - const result = toResult('past-events', args, response); - return { ...result, events: result.events.filter((e) => hasEnded(e, asOf)) }; + const actor = args?.actor; + if (!actor || !isActorIdentifier(actor)) return EMPTY; + return pastEventsQuery(client, { actor }, raw); }, + // The search text is re-derived SERVER-side from the slug inside topicQuery, + // never taken from the client; an unknown slug ends cleanly. topic: async (_env, client, { args, raw }) => { - // Re-derive the search from the slug SERVER-side (shared helper), never from - // a client-supplied query. Unknown slug => end cleanly. - const search = args?.slug ? orQueryFromSlug(args.slug) : null; - if (!search) return EMPTY; - const response = await listDiscoverableEventsFromContrail(client, { - search, - startsAtMin: now(), - sort: 'startsAt', - order: 'asc', - profiles: true, - limit: PAGE_SIZE, - cursor: raw - }); - return toResult('topic', args, response); + const slug = args?.slug; + if (!slug) return EMPTY; + return topicQuery(client, { slug }, raw); }, - 'search-d1': async (_env, client, { args, raw }, searchTerm) => { - const term = searchTerm?.trim(); - if (!term) return EMPTY; // search term lost from the continuation => end cleanly - const response = await listDiscoverableEventsFromContrail(client, { - search: term, - startsAtMin: now(), - sort: 'startsAt', - order: 'desc', - profiles: true, - limit: SEARCH_PAGE_SIZE, - cursor: raw - }); - return toResult('search-d1', args, response); + // Search is the one query whose defining input is not in the envelope: the + // term rides ?q=/input. Lost term => end cleanly rather than continue an + // unfiltered list. + 'search-d1': async (_env, client, { raw }, searchTerm) => { + if (!searchTerm?.trim()) return EMPTY; + return searchD1Query(client, { term: searchTerm }, raw); }, - 'search-meili': async (env, client, { args, raw }, searchTerm) => { - const term = searchTerm?.trim(); - const backend = term ? searchBackendFromEnv(env) : null; - // Missing search term OR unconfigured backend => end cleanly rather than - // restart page 1 on the wrong backend. - if (!term || !backend) return EMPTY; - const page = await runEventSearchPage(backend, client, { q: term, cursor: raw }); - return { - events: page.events, - handles: page.handles, - cursor: nextCursor('search-meili', page.cursor, args) - }; + 'search-meili': async (env, client, { raw }, searchTerm) => { + if (!searchTerm?.trim()) return EMPTY; + return searchMeiliQuery(env, client, { term: searchTerm }, raw); } }; @@ -181,11 +96,8 @@ const REGISTRY: Record = { * be unit-tested directly (the plugin rejects non-remote exports from * `*.remote.ts`, so a test there can't mock `$app/server`). * - * Decode the envelope, look up its resumer, resume with server-authoritative - * filters, re-encode the next envelope — no per-backend if/else. An undecodable - * or legacy cursor decodes to null and ends pagination cleanly, without - * reconstructing the query from client fields. See README → - * "Load-more pagination". + * Decode the envelope, look up its resumer, continue the shared query. A cursor + * that fails to decode yields null and ends pagination cleanly. */ export async function runLoadMoreEvents( env: App.Platform['env'], diff --git a/apps/web/src/lib/contrail/queries.ts b/apps/web/src/lib/contrail/queries.ts new file mode 100644 index 0000000..e63333c --- /dev/null +++ b/apps/web/src/lib/contrail/queries.ts @@ -0,0 +1,210 @@ +// The paginated list queries — ONE definition per list, called by BOTH the +// route's page-1 `load()` and the load-more resumer. +// +// Page 1 and load-more are separate server entry points by necessity: an SSR +// `load()` that has `url`/`params`, and a remote command that has only an opaque +// cursor. A keyset cursor is specific to the query that produced it, so page 2 +// is adjacent to page 1 only while every filter, sort, bound and limit agrees. +// One definition called from both entry points makes that agreement structural, +// rather than a property two call sites have to keep in step. +// +// Each query owns its filter values, any post-filter, and the envelope that +// continues it. Args arrive already validated — the trust boundary is +// `decodeCursor` for load-more and route `params` for page 1 — so a query +// receives values, never client input. See README → "Load-more pagination". + +import type { Client } from '@atcute/client'; +import type { ActorIdentifier } from '@atcute/lexicons'; +import { + flattenEventRecords, + listAuthoredEventsFromContrail, + listDiscoverableEventsFromContrail +} from '$lib/contrail'; +import { runEventSearchPage, searchBackendFromEnv } from '$lib/search/server/query'; +import { SEARCH_PAGE_SIZE } from '$lib/search/constants'; +import { orQueryFromSlug } from '$lib/topics'; +import { hasEnded } from '$lib/past-events'; +import { nextCursor, type CursorArgs, type CursorQuery } from './cursor'; + +export const PAGE_SIZE = 20; + +/** + * One page of events plus the token that continues it. Page 1 and load-more + * return the SAME shape — routes spread it and add their own page data. + */ +export type EventsPage = { + events: ReturnType; + handles: Record; + cursor: string | null; +}; + +export const EMPTY_PAGE: EventsPage = { events: [], handles: {}, cursor: null }; + +function now(): string { + return new Date().toISOString(); +} + +/** + * Shape a contrail list response into a page, minting the next-page envelope for + * the SAME query name + scope args, so a continuation can only resume this query. + */ +function toPage( + q: CursorQuery, + args: CursorArgs | undefined, + response: Awaited> +): EventsPage { + if (!response) return EMPTY_PAGE; + const handles: Record = {}; + for (const p of response.profiles ?? []) { + if (p.handle) handles[p.did] = p.handle; + } + return { + events: flattenEventRecords(response.records ?? []), + handles, + cursor: nextCursor(q, response.cursor ?? null, args) + }; +} + +/** The home discovery feed: upcoming, discoverable, soonest first. */ +export async function eventsQuery( + client: Client, + args: { popular: boolean }, + cursor: string | null | undefined +): Promise { + const response = await listDiscoverableEventsFromContrail(client, { + startsAtMin: now(), + profiles: true, + sort: 'startsAt', + order: 'asc', + limit: PAGE_SIZE, + ...(args.popular ? { rsvpsCountMin: 2 } : {}), + cursor: cursor ?? undefined + }); + return toPage('events', { popular: args.popular }, response); +} + +/** A profile's upcoming events: authored by this actor, soonest first. */ +export async function hostingQuery( + client: Client, + args: { actor: ActorIdentifier }, + cursor: string | null | undefined +): Promise { + const response = await listAuthoredEventsFromContrail(client, { + actor: args.actor, + startsAtMin: now(), + sort: 'startsAt', + order: 'asc', + profiles: true, + limit: PAGE_SIZE, + cursor: cursor ?? undefined + }); + return toPage('hosting', { actor: args.actor }, response); +} + +/** + * A profile's past events: authored by this actor, most recent first. + * + * The D1 bound is `startsAtMax`, which still admits an event that began earlier + * and is STILL RUNNING, so the result is narrowed to events that have actually + * ended. The narrowing lives in the query, so every page inherits it. + * + * It runs AFTER pagination: the cursor reflects the unfiltered keyset position, + * which is what keeps page 2 adjacent to page 1. A page can therefore come back + * short — or empty while more pages remain — so a caller must keep its "load + * more" affordance alive for as long as there is a cursor, not for as long as + * the page has events. + */ +export async function pastEventsQuery( + client: Client, + args: { actor: ActorIdentifier }, + cursor: string | null | undefined +): Promise { + const asOf = now(); + const response = await listAuthoredEventsFromContrail(client, { + actor: args.actor, + startsAtMax: asOf, + sort: 'startsAt', + order: 'desc', + profiles: true, + limit: PAGE_SIZE, + cursor: cursor ?? undefined + }); + const page = toPage('past-events', { actor: args.actor }, response); + return { ...page, events: page.events.filter((e) => hasEnded(e, asOf)) }; +} + +/** + * A topic list: upcoming discoverable events matching ANY of the topic's + * hashtag terms. The search string is derived from the slug SERVER-side, so a + * caller can never supply the query text. An unknown slug yields an empty page. + */ +export async function topicQuery( + client: Client, + args: { slug: string }, + cursor: string | null | undefined +): Promise { + const search = orQueryFromSlug(args.slug); + if (!search) return EMPTY_PAGE; + const response = await listDiscoverableEventsFromContrail(client, { + search, + startsAtMin: now(), + sort: 'startsAt', + order: 'asc', + profiles: true, + limit: PAGE_SIZE, + cursor: cursor ?? undefined + }); + return toPage('topic', { slug: args.slug }, response); +} + +/** + * Free-text search, D1 LIKE path — the degraded backend used when Meilisearch + * is unconfigured or down. Upcoming-only, matching the Meili path; an empty term + * yields an empty page rather than an unfiltered list. + */ +export async function searchD1Query( + client: Client, + args: { term: string }, + cursor: string | null | undefined +): Promise { + const term = args.term.trim(); + if (!term) return EMPTY_PAGE; + const response = await listDiscoverableEventsFromContrail(client, { + search: term, + startsAtMin: now(), + sort: 'startsAt', + order: 'desc', + profiles: true, + limit: SEARCH_PAGE_SIZE, + cursor: cursor ?? undefined + }); + return toPage('search-d1', undefined, response); +} + +/** + * Free-text search, Meilisearch path: Meili ranks, D1 supplies the records. An + * empty term or unconfigured backend yields an empty page — page 1 falls back to + * D1 on failure, but a CONTINUATION must not, or it would restart page 1 on the + * other backend with a keyset that backend can't read. + */ +export async function searchMeiliQuery( + env: App.Platform['env'] | undefined, + client: Client, + args: { term: string }, + cursor: string | null | undefined +): Promise { + const term = args.term.trim(); + const backend = term ? searchBackendFromEnv(env) : null; + if (!term || !backend) return EMPTY_PAGE; + // Omit `cursor` entirely on a fresh page rather than passing null: page 1 and + // the resumer then issue byte-identical calls apart from the keyset itself. + const page = await runEventSearchPage(backend, client, { + q: term, + ...(cursor ? { cursor } : {}) + }); + return { + events: page.events, + handles: page.handles, + cursor: nextCursor('search-meili', page.cursor) + }; +} diff --git a/apps/web/src/routes/(app)/events/+page.server.ts b/apps/web/src/routes/(app)/events/+page.server.ts index 15b64ee..5c6ecb6 100644 --- a/apps/web/src/routes/(app)/events/+page.server.ts +++ b/apps/web/src/routes/(app)/events/+page.server.ts @@ -1,43 +1,16 @@ -import { - flattenEventRecords, - getServerClient, - listDiscoverableEventsFromContrail -} from '$lib/contrail'; -import { nextCursor, rawForQuery } from '$lib/contrail/cursor'; +import { getServerClient } from '$lib/contrail'; +import { eventsQuery } from '$lib/contrail/queries'; +import { rawForQuery } from '$lib/contrail/cursor'; import type { PageServerLoad } from './$types'; -const PAGE_SIZE = 20; - export const load: PageServerLoad = async ({ url, platform }) => { const client = getServerClient(platform!.env.DB); - const now = new Date().toISOString(); 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, - profiles: true, - sort: 'startsAt', - order: 'asc', - limit: PAGE_SIZE, - cursor, - ...(isPopular ? { rsvpsCountMin: 2 } : {}) - }); - - if (!response) return { events: [], handles: {}, cursor: null }; - - const handles: Record = {}; - for (const p of response.profiles ?? []) { - if (p.handle) handles[p.did] = p.handle; - } - - return { - events: flattenEventRecords(response.records), - handles, - // 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 }) - }; + // The same query load-more continues — see queries.ts. + return eventsQuery(client, { popular: isPopular }, cursor); }; 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 35e75f4..6b5c667 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 @@ -1,16 +1,10 @@ import { getActor } from '$lib/actor'; -import { - flattenEventRecords, - getProfileFromContrail, - getServerClient, - listAuthoredEventsFromContrail -} from '$lib/contrail'; -import { nextCursor, rawForQuery } from '$lib/contrail/cursor'; +import { getProfileFromContrail, getServerClient } from '$lib/contrail'; +import { hostingQuery } from '$lib/contrail/queries'; +import { rawForQuery } from '$lib/contrail/cursor'; import { isActorIdentifier } from '@atcute/lexicons/syntax'; import { error } from '@sveltejs/kit'; -const PAGE_SIZE = 20; - export async function load({ params, url, platform }) { const client = getServerClient(platform!.env.DB); if (!isActorIdentifier(params.actor)) return; @@ -22,26 +16,15 @@ export async function load({ params, url, platform }) { // 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([ + // The same query load-more continues — see queries.ts. + const [profile, page] = await Promise.all([ getProfileFromContrail(client, actor), - listAuthoredEventsFromContrail(client, { - profiles: true, - sort: 'startsAt', - order: 'asc', - startsAtMin: now, - actor, - limit: PAGE_SIZE, - cursor - }) + hostingQuery(client, { actor }, cursor) ]); return { - events: response ? flattenEventRecords(response.records) : [], - // Self-describing envelope: load-more re-runs the authored + upcoming query - // scoped to this actor, server-side. - cursor: nextCursor('hosting', response?.cursor ?? null, { actor }), + ...page, actorProfile: profile, actor, actorDid: did 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 301aa66..809f82a 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 @@ -1,17 +1,10 @@ import { getActor } from '$lib/actor'; -import { - flattenEventRecords, - getProfileFromContrail, - getServerClient, - listAuthoredEventsFromContrail -} from '$lib/contrail'; -import { nextCursor, rawForQuery } from '$lib/contrail/cursor'; -import { hasEnded } from '$lib/past-events'; +import { getProfileFromContrail, getServerClient } from '$lib/contrail'; +import { pastEventsQuery } from '$lib/contrail/queries'; +import { rawForQuery } from '$lib/contrail/cursor'; import { isActorIdentifier } from '@atcute/lexicons/syntax'; import { error } from '@sveltejs/kit'; -const PAGE_SIZE = 20; - export async function load({ params, url, platform }) { const client = getServerClient(platform!.env.DB); if (!isActorIdentifier(params.actor)) return; @@ -23,34 +16,16 @@ export async function load({ params, url, platform }) { // 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([ + // The same query load-more continues, including its ended-event narrowing, so + // this page can come back short while a cursor remains — see queries.ts. + const [profile, page] = await Promise.all([ getProfileFromContrail(client, actor), - listAuthoredEventsFromContrail(client, { - profiles: true, - sort: 'startsAt', - order: 'desc', - startsAtMax: now, - actor, - limit: PAGE_SIZE, - cursor - }) + pastEventsQuery(client, { actor }, cursor) ]); - // Narrow to events that have actually ENDED — startsAtMax still admits one - // that began earlier and is still running. The load-more resumer applies the - // same shared predicate, so an ongoing event can't be dropped here only to - // reappear on page 2. - const events = (response ? flattenEventRecords(response.records) : []).filter((e) => - hasEnded(e, now) - ); - return { - events, - // 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 }), + ...page, actorProfile: profile, actor, actorDid: did diff --git a/apps/web/src/routes/(app)/search/+page.server.ts b/apps/web/src/routes/(app)/search/+page.server.ts index 9914d94..28af2e9 100644 --- a/apps/web/src/routes/(app)/search/+page.server.ts +++ b/apps/web/src/routes/(app)/search/+page.server.ts @@ -1,18 +1,13 @@ -import { - flattenEventRecords, - getServerClient, - listDiscoverableEventsFromContrail -} 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 { getServerClient } from '$lib/contrail'; +import { EMPTY_PAGE, searchD1Query, searchMeiliQuery } from '$lib/contrail/queries'; +import { searchBackendFromEnv } from '$lib/search/server/query'; 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 term = url.searchParams.get('q')?.trim() || ''; - if (!q) return { events: [], handles: {}, cursor: null, query: '' }; + if (!term) return { ...EMPTY_PAGE, 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. @@ -20,49 +15,15 @@ export const load: PageServerLoad = async ({ url, platform }) => { // 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) { + // unconfigured (local dev) or down. Only page 1 falls back — a continuation + // must not switch backends, so the resumers don't (see queries.ts). + if (searchBackendFromEnv(platform?.env)) { try { - const page = await runEventSearchPage(backend, client, { q }); - return { - events: page.events, - handles: page.handles, - cursor: nextCursor('search-meili', page.cursor), - query: q - }; + return { ...(await searchMeiliQuery(platform?.env, client, { term }, null)), query: term }; } catch (err) { console.error('search backend failed, falling back to D1 search:', err); } } - // Keep the degraded path consistent with the Meilisearch path: upcoming only. - // D1 range params AND together (an endsAt bound would drop events with no - // endsAt), so this uses the start-based approximation the home list also uses. - const response = await listDiscoverableEventsFromContrail(client, { - search: q, - profiles: true, - startsAtMin: new Date().toISOString(), - sort: 'startsAt', - order: 'desc', - limit: SEARCH_PAGE_SIZE - }); - - if (!response) return { events: [], handles: {}, cursor: null, query: q }; - - const handles: Record = {}; - for (const p of response.profiles ?? []) { - if (p.handle) handles[p.did] = p.handle; - } - - return { - events: flattenEventRecords(response.records), - handles, - // 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 - }; + return { ...(await searchD1Query(client, { term }, null)), query: term }; }; 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 ba7b4c4..807257d 100644 --- a/apps/web/src/routes/(app)/topics/[slug]/+page.server.ts +++ b/apps/web/src/routes/(app)/topics/[slug]/+page.server.ts @@ -1,54 +1,28 @@ import { error } from '@sveltejs/kit'; import { getTopicBySlug, orQueryFromSlug } from '$lib/topics'; -import { - flattenEventRecords, - getServerClient, - listDiscoverableEventsFromContrail -} from '$lib/contrail'; -import { nextCursor, rawForQuery } from '$lib/contrail/cursor'; +import { getServerClient } from '$lib/contrail'; +import { topicQuery } from '$lib/contrail/queries'; +import { rawForQuery } from '$lib/contrail/cursor'; import type { PageServerLoad } from './$types'; -const PAGE_SIZE = 20; - export const load: PageServerLoad = async ({ params, url, platform }) => { const topic = getTopicBySlug(params.slug); if (!topic) error(404, 'Topic not found'); const client = getServerClient(platform!.env.DB); - // 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. 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, - profiles: true, - // Upcoming-only, soonest first — same shape as the home discovery list. - startsAtMin: new Date().toISOString(), - sort: 'startsAt', - order: 'asc', - 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 }) - }); + // Deep-link ?cursor= resumes only a 'topic' cursor for this slug; else fresh page 1. + const cursor = rawForQuery(url.searchParams.get('cursor'), 'topic', { slug: params.slug }); - const handles: Record = {}; - for (const p of response?.profiles ?? []) { - if (p.handle) handles[p.did] = p.handle; - } + // The same query load-more continues; it derives the OR-search from the slug + // server-side, so the query text is never supplied by a caller. + const page = await topicQuery(client, { slug: params.slug }, cursor); return { topic, - events: flattenEventRecords(response?.records ?? []), - handles, - // 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 + ...page, + // Shown in the UI; topicQuery derives the query it runs from the slug with + // this same helper. + query: orQueryFromSlug(params.slug) ?? '' }; }; -- 2.51.2