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) ?? '' }; };