diff --git a/apps/web/src/lib/contrail/events-load-more.test.ts b/apps/web/src/lib/contrail/events-load-more.test.ts index dbffdef..025278c 100644 --- a/apps/web/src/lib/contrail/events-load-more.test.ts +++ b/apps/web/src/lib/contrail/events-load-more.test.ts @@ -1,16 +1,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -// runLoadMoreEvents must re-run the SAME read pipeline page 1 used. The bug -// (om-5iiw) was that it always called listRecords, so the discoverable filter -// (home) and the authored filter (profile hosting/past) were dropped on page -// 2+, leaking unlisted events and conference talks. These tests pin the -// routing: the `pipeline` selector picks the matching contrail fn and is -// stripped from the params handed to it (it is our selector, not an xrpc param). +// runLoadMoreEvents now decodes a self-describing continuation ENVELOPE and +// dispatches through a backend->resumer REGISTRY keyed by the envelope's query +// name — no routeMeili/if-else, no pipeline switch, no client-echoed +// query-reconstruction bag. These tests pin: (1) each query routes to the right +// server-side pipeline with SERVER-AUTHORITATIVE filters, (2) security — a +// tampered/forged envelope can never reach the unlisted-inclusive plain +// listRecords pipeline (it has no registry entry), (3) continuity — the next +// cursor re-encodes the SAME query so page N+1 stays adjacent and +// non-overlapping, (4) legacy/undecodable cursors end pagination cleanly +// without reconstruction. vi.mock('./index', () => ({ getServerClient: vi.fn(() => ({})) })); vi.mock('$lib/contrail', () => ({ flattenEventRecords: vi.fn((records: unknown[]) => records), + // Exported so the no-leak assertion can prove it is NEVER called — it has no + // registry entry, so no decoded envelope can reach it. listEventRecordsFromContrail: vi.fn(), listDiscoverableEventsFromContrail: vi.fn(), listAuthoredEventsFromContrail: vi.fn() @@ -20,182 +26,329 @@ vi.mock('$lib/search/server/query', () => ({ runEventSearchPage: vi.fn() })); -import { runLoadMoreEvents, type LoadMoreEventsInput } from './events-load-more'; +import { runLoadMoreEvents } from './events-load-more'; +import { encodeCursor, decodeCursor, type CursorEnvelope } from './cursor'; import { listAuthoredEventsFromContrail, listDiscoverableEventsFromContrail, listEventRecordsFromContrail } from '$lib/contrail'; import { runEventSearchPage, searchBackendFromEnv } from '$lib/search/server/query'; +import { SEARCH_PAGE_SIZE } from '$lib/search/constants'; const mockRecords = vi.mocked(listEventRecordsFromContrail); const mockDiscoverable = vi.mocked(listDiscoverableEventsFromContrail); const mockAuthored = vi.mocked(listAuthoredEventsFromContrail); const mockSearchBackend = vi.mocked(searchBackendFromEnv); +const mockRunSearch = vi.mocked(runEventSearchPage); -const emptyPage = { records: [], profiles: [], cursor: 'next' } as unknown as Awaited< - ReturnType ->; - -// env is opaque here — getServerClient is mocked, and the search backend is -// resolved via the (mocked) searchBackendFromEnv. const env = { DB: {} } as unknown as App.Platform['env']; -const call = (input: Partial) => - runLoadMoreEvents(env, input as LoadMoreEventsInput); + +const call = (cursor: string | undefined, q?: string) => runLoadMoreEvents(env, { cursor, q }); + +// A valid envelope token (as the server would emit it). +const token = (envelope: CursorEnvelope) => encodeCursor(envelope); + +// A forged token with ARBITRARY content, bypassing encodeCursor's type gate. +const forge = (obj: unknown) => Buffer.from(JSON.stringify(obj), 'utf-8').toString('base64url'); + +const page = (records: unknown[], cursor: string | null = 'next', profiles: unknown[] = []) => + ({ records, profiles, cursor }) as unknown as Awaited< + ReturnType + >; + +const noReads = () => { + expect(mockDiscoverable).not.toHaveBeenCalled(); + expect(mockAuthored).not.toHaveBeenCalled(); + expect(mockRecords).not.toHaveBeenCalled(); + expect(mockRunSearch).not.toHaveBeenCalled(); +}; afterEach(() => vi.clearAllMocks()); -describe('runLoadMoreEvents pipeline routing', () => { - it("routes pipeline:'discoverable' to listDiscoverable, never listRecords", async () => { - mockDiscoverable.mockResolvedValue(emptyPage); +describe('runLoadMoreEvents registry dispatch', () => { + it("routes 'events' to listDiscoverable with server-authoritative upcoming filters", async () => { + mockDiscoverable.mockResolvedValue(page([{ uri: 'at://x' }], 'raw2')); - await call({ pipeline: 'discoverable', startsAtMin: '2026-01-01T00:00:00Z', cursor: 'c' }); + const result = await call(token({ v: 1, q: 'events', args: { popular: true }, raw: 'raw1' })); expect(mockDiscoverable).toHaveBeenCalledTimes(1); - expect(mockRecords).not.toHaveBeenCalled(); expect(mockAuthored).not.toHaveBeenCalled(); + expect(mockRecords).not.toHaveBeenCalled(); + const params = mockDiscoverable.mock.calls[0][1]; + // Same literals the events/+page.server.ts page-1 load uses (continuity). + expect(params).toMatchObject({ + sort: 'startsAt', + order: 'asc', + limit: 20, + profiles: true, + rsvpsCountMin: 2, + cursor: 'raw1' + }); + expect(typeof params.startsAtMin).toBe('string'); + // Next cursor re-encodes the SAME query + args with the fresh keyset. + expect(decodeCursor(result.cursor)).toEqual({ + v: 1, + q: 'events', + args: { popular: true }, + raw: 'raw2' + }); }); - it("routes pipeline:'authored' to listAuthored, never listRecords", async () => { - mockAuthored.mockResolvedValue(emptyPage); + it("drops rsvpsCountMin for a non-popular 'events' envelope", async () => { + mockDiscoverable.mockResolvedValue(page([], null)); + await call(token({ v: 1, q: 'events', args: { popular: false }, raw: 'r' })); + expect(mockDiscoverable.mock.calls[0][1]).not.toHaveProperty('rsvpsCountMin'); + }); - await call({ pipeline: 'authored', actor: 'did:plc:alice', cursor: 'c' }); + it("routes 'hosting' to listAuthored scoped to the actor, upcoming asc", async () => { + mockAuthored.mockResolvedValue(page([{ uri: 'at://h' }], 'raw2')); + + const result = await call( + token({ v: 1, q: 'hosting', args: { actor: 'did:plc:alice' }, raw: 'raw1' }) + ); expect(mockAuthored).toHaveBeenCalledTimes(1); - expect(mockRecords).not.toHaveBeenCalled(); expect(mockDiscoverable).not.toHaveBeenCalled(); + const params = mockAuthored.mock.calls[0][1]; + expect(params).toMatchObject({ + actor: 'did:plc:alice', + sort: 'startsAt', + order: 'asc', + profiles: true, + limit: 20, + cursor: 'raw1' + }); + expect(typeof params.startsAtMin).toBe('string'); + expect(decodeCursor(result.cursor)).toEqual({ + v: 1, + q: 'hosting', + args: { actor: 'did:plc:alice' }, + raw: 'raw2' + }); }); - it('falls back to plain listRecords when no pipeline is given', async () => { - mockRecords.mockResolvedValue(emptyPage); + it("routes 'past-events' to listAuthored scoped to the actor, past desc", async () => { + mockAuthored.mockResolvedValue(page([{ uri: 'at://p' }], 'raw2')); - await call({ cursor: 'c' }); + const result = await call( + token({ v: 1, q: 'past-events', args: { actor: 'did:plc:alice' }, raw: 'raw1' }) + ); - expect(mockRecords).toHaveBeenCalledTimes(1); - expect(mockDiscoverable).not.toHaveBeenCalled(); - expect(mockAuthored).not.toHaveBeenCalled(); + const params = mockAuthored.mock.calls[0][1]; + expect(params).toMatchObject({ + actor: 'did:plc:alice', + sort: 'startsAt', + order: 'desc', + limit: 20, + cursor: 'raw1' + }); + expect(typeof params.startsAtMax).toBe('string'); + expect(decodeCursor(result.cursor)).toMatchObject({ q: 'past-events' }); }); - it('strips the pipeline selector but forwards the real filters', async () => { - mockDiscoverable.mockResolvedValue(emptyPage); + it("routes 'topic' to listDiscoverable, deriving the search from the slug server-side", async () => { + mockDiscoverable.mockResolvedValue(page([{ uri: 'at://t' }], 'raw2')); - await call({ pipeline: 'discoverable', rsvpsCountMin: 2, cursor: 'c' }); + const result = await call( + token({ v: 1, q: 'topic', args: { slug: 'technology' }, raw: 'raw1' }) + ); const params = mockDiscoverable.mock.calls[0][1]; - expect(params).not.toHaveProperty('pipeline'); - expect(params).toMatchObject({ rsvpsCountMin: 2, cursor: 'c' }); + // orQueryFromSlug('technology') — the SAME helper the topic page load uses. + expect(params.search).toBe('tech OR technology'); + expect(params).toMatchObject({ order: 'asc', limit: 20, cursor: 'raw1' }); + expect(decodeCursor(result.cursor)).toEqual({ + v: 1, + q: 'topic', + args: { slug: 'technology' }, + raw: 'raw2' + }); }); - it('does not consult the search backend for a non-search load', async () => { - mockDiscoverable.mockResolvedValue(emptyPage); + it("routes 'search-d1' to listDiscoverable with the term from input, upcoming desc", async () => { + mockDiscoverable.mockResolvedValue(page([{ uri: 'at://s' }], 'raw2')); - await call({ pipeline: 'discoverable', cursor: 'c' }); + const result = await call(token({ v: 1, q: 'search-d1', raw: 'raw1' }), 'jazz'); - expect(mockSearchBackend).not.toHaveBeenCalled(); + const params = mockDiscoverable.mock.calls[0][1]; + expect(params).toMatchObject({ + search: 'jazz', + order: 'desc', + limit: SEARCH_PAGE_SIZE, + cursor: 'raw1' + }); + expect(typeof params.startsAtMin).toBe('string'); + expect(decodeCursor(result.cursor)).toMatchObject({ q: 'search-d1', raw: 'raw2' }); }); - it('tags the D1 cursor it returns to the client with the d1 backend', async () => { - mockDiscoverable.mockResolvedValue(emptyPage); // cursor: 'next' + it("routes 'search-meili' to runEventSearchPage with the term + raw offset, re-wraps next", async () => { + mockSearchBackend.mockReturnValue({ url: 'https://m', apiKey: 'k' }); + mockRunSearch.mockResolvedValue({ + events: [{ uri: 'at://s' }], + handles: { 'did:plc:a': 'alice' }, + cursor: 'meili:40', + distances: {} + } as unknown as Awaited>); - const result = await call({ pipeline: 'discoverable', cursor: 'd1:opaque' }); + const result = await call(token({ v: 1, q: 'search-meili', raw: 'meili:20' }), 'jazz'); - expect(result.cursor).toBe('d1:next'); + expect(mockRunSearch).toHaveBeenCalledTimes(1); + expect(mockRunSearch.mock.calls[0][2]).toMatchObject({ q: 'jazz', cursor: 'meili:20' }); + expect(decodeCursor(result.cursor)).toEqual({ v: 1, q: 'search-meili', raw: 'meili:40' }); + }); + + it('ends pagination when the contrail read returns null', async () => { + mockDiscoverable.mockResolvedValue(null); + const result = await call(token({ v: 1, q: 'events', args: { popular: true }, raw: 'k' })); + expect(result).toEqual({ events: [], handles: {}, cursor: null }); + }); + + it('ends pagination (null cursor) when the backend has no next page', async () => { + mockDiscoverable.mockResolvedValue(page([{ uri: 'at://x' }], null)); + const result = await call(token({ v: 1, q: 'events', args: { popular: true }, raw: 'k' })); + expect(result.cursor).toBeNull(); }); }); -// The om-7dbs bug class: a page whose FIRST load came from one backend but whose -// load-more re-derived the OTHER, handing over an incompatible cursor. Routing -// by the cursor's own tag pins each continuation to the backend that issued it. -describe('runLoadMoreEvents backend routing by cursor tag', () => { - const mockRunSearch = vi.mocked(runEventSearchPage); - const searchPage = { - events: [], - handles: {}, - cursor: 'meili:40' - } as unknown as Awaited>; - - it('keeps a d1-tagged cursor on D1 even with a search term AND Meili configured', async () => { - // PR #49's trip case: D1 first page + search term + configured Meili. The - // old inference re-routed to Meili and NaN-parsed the keyset into a page-1 - // refetch. The tag must win: stay on D1, filters intact. - mockSearchBackend.mockReturnValue({ url: 'https://meili.test', apiKey: 'k' }); - mockDiscoverable.mockResolvedValue(emptyPage); - - const result = await call({ - pipeline: 'discoverable', - search: 'jazz', - startsAtMin: '2026-01-01T00:00:00Z', - cursor: 'd1:keyset' - }); +// Security invariant: a client-held, mutable cursor must not let a tampered +// continuation widen visibility. It holds BY CONSTRUCTION — the unlisted- +// inclusive listRecords pipeline has NO registry entry, and the envelope carries +// no filter values to tamper. +describe('security: a tampered/forged envelope cannot surface non-discoverable events', () => { + const unlisted = { uri: 'at://did:plc:secret/community.lexicon.calendar.event/hidden' }; - expect(mockRunSearch).not.toHaveBeenCalled(); - expect(mockDiscoverable).toHaveBeenCalledTimes(1); - const params = mockDiscoverable.mock.calls[0][1]; - // The untagged keyset is forwarded to D1; filters survive. - expect(params).toMatchObject({ - cursor: 'keyset', - search: 'jazz', - startsAtMin: '2026-01-01T00:00:00Z' - }); - expect(result.cursor).toBe('d1:next'); + it("q tampered to 'plain' resumes nothing and never reaches plain listRecords", async () => { + // listRecords WOULD return the hidden event if it were reachable; prove it + // is never called and the hidden event never surfaces. + mockRecords.mockResolvedValue(page([unlisted])); + mockDiscoverable.mockResolvedValue(page([{ uri: 'at://ok' }])); + + const result = await call(forge({ v: 1, q: 'plain', raw: 'anything' })); + + expect(result).toEqual({ events: [], handles: {}, cursor: null }); + expect(result.events).not.toContainEqual(unlisted); + noReads(); }); - it('keeps a meili-tagged cursor on Meili and hands it the untagged offset', async () => { - mockSearchBackend.mockReturnValue({ url: 'https://meili.test', apiKey: 'k' }); - mockRunSearch.mockResolvedValue(searchPage); + it('q tampered to an unknown string / missing / empty => empty, and listRecords never runs', async () => { + mockRecords.mockResolvedValue(page([unlisted])); + for (const forged of [ + forge({ v: 1, q: 'listRecords', raw: 'x' }), + forge({ v: 1, raw: 'x' }), + forge({ v: 1, q: '', raw: 'x' }) + ]) { + expect(await call(forged)).toEqual({ events: [], handles: {}, cursor: null }); + } + expect(mockRecords).not.toHaveBeenCalled(); + }); - const result = await call({ search: 'jazz', cursor: 'meili:20' }); + it('a valid discoverable envelope routes ONLY to listDiscoverable, never to listRecords', async () => { + mockRecords.mockResolvedValue(page([unlisted])); + mockDiscoverable.mockResolvedValue(page([{ uri: 'at://discoverable' }], null)); - expect(mockRunSearch).toHaveBeenCalledTimes(1); - expect(mockRunSearch.mock.calls[0][2]).toMatchObject({ q: 'jazz', cursor: '20' }); + const result = await call(token({ v: 1, q: 'events', args: { popular: true }, raw: 'k' })); + + expect(mockDiscoverable).toHaveBeenCalledTimes(1); expect(mockRecords).not.toHaveBeenCalled(); - expect(mockDiscoverable).not.toHaveBeenCalled(); - expect(result.cursor).toBe('meili:40'); + expect(result.events).not.toContainEqual(unlisted); }); - it('fails safe (ends pagination) for a meili-tagged cursor when no backend is configured', async () => { - // The Meili offset is meaningless to D1 listRecords; rather than restart - // page 1 on D1 or NaN-parse, end pagination. - mockSearchBackend.mockReturnValue(null); + it('dropping all args cannot surface a non-discoverable event (startsAtMin is server-applied)', async () => { + mockRecords.mockResolvedValue(page([unlisted])); + mockDiscoverable.mockResolvedValue(page([], null)); - const result = await call({ search: 'jazz', cursor: 'meili:20' }); + await call(token({ v: 1, q: 'events', raw: 'k' })); // args stripped entirely - expect(mockRunSearch).not.toHaveBeenCalled(); expect(mockRecords).not.toHaveBeenCalled(); - expect(mockDiscoverable).not.toHaveBeenCalled(); - expect(result).toEqual({ events: [], handles: {}, cursor: null }); + // The discoverability filter + startsAtMin live in the D1 pipeline, not the + // client cursor: listDiscoverable still ran with a server-supplied bound. + expect(typeof mockDiscoverable.mock.calls[0][1].startsAtMin).toBe('string'); }); - it('fails safe for a meili-tagged cursor when the search term was lost from the continuation', async () => { - mockSearchBackend.mockReturnValue({ url: 'https://meili.test', apiKey: 'k' }); + it('switching q among public-safe queries re-scopes but never leaks or throws', async () => { + mockAuthored.mockResolvedValue(page([{ uri: 'at://authored' }], null)); + // A different actor on 'hosting' is exactly the same as browsing that public + // profile — permitted, and it still scopes to that actor. + const result = await call(token({ v: 1, q: 'hosting', args: { actor: 'did:plc:bob' }, raw: 'k' })); + expect(mockAuthored).toHaveBeenCalledTimes(1); + expect(mockAuthored.mock.calls[0][1]).toMatchObject({ actor: 'did:plc:bob' }); + expect(result.cursor).toBeNull(); + }); +}); - const result = await call({ cursor: 'meili:20' }); +describe('registry required-arg guards end cleanly (never throw, never fall through)', () => { + it('hosting with a missing actor => empty', async () => { + const result = await call(token({ v: 1, q: 'hosting', raw: 'k' })); + expect(mockAuthored).not.toHaveBeenCalled(); + expect(result).toEqual({ events: [], handles: {}, cursor: null }); + }); - expect(mockRunSearch).not.toHaveBeenCalled(); + it('hosting with a malformed actor => empty', async () => { + const result = await call( + token({ v: 1, q: 'hosting', args: { actor: 'not an actor!!' }, raw: 'k' }) + ); + expect(mockAuthored).not.toHaveBeenCalled(); + expect(result.cursor).toBeNull(); + }); + + it('topic with an unknown slug => empty', async () => { + const result = await call(token({ v: 1, q: 'topic', args: { slug: 'no-such-topic' }, raw: 'k' })); expect(mockDiscoverable).not.toHaveBeenCalled(); expect(result).toEqual({ events: [], handles: {}, cursor: null }); }); - it('legacy untagged cursor + search + Meili configured falls back to the old inference (Meili)', async () => { - mockSearchBackend.mockReturnValue({ url: 'https://meili.test', apiKey: 'k' }); - mockRunSearch.mockResolvedValue(searchPage); + it('search-d1 with the search term lost => empty', async () => { + const result = await call(token({ v: 1, q: 'search-d1', raw: 'k' })); + expect(mockDiscoverable).not.toHaveBeenCalled(); + expect(result).toEqual({ events: [], handles: {}, cursor: null }); + }); - const result = await call({ search: 'jazz', cursor: '20' }); + it('search-meili with no configured backend => empty', async () => { + mockSearchBackend.mockReturnValue(null); + const result = await call(token({ v: 1, q: 'search-meili', raw: 'meili:20' }), 'jazz'); + expect(mockRunSearch).not.toHaveBeenCalled(); + expect(result).toEqual({ events: [], handles: {}, cursor: null }); + }); - expect(mockRunSearch).toHaveBeenCalledTimes(1); - // The legacy offset is passed through for the Meili path to parse. - expect(mockRunSearch.mock.calls[0][2]).toMatchObject({ q: 'jazz', cursor: '20' }); - expect(result.cursor).toBe('meili:40'); + it('search-meili with the search term lost => empty', async () => { + mockSearchBackend.mockReturnValue({ url: 'https://m', apiKey: 'k' }); + const result = await call(token({ v: 1, q: 'search-meili', raw: 'meili:20' })); + expect(mockRunSearch).not.toHaveBeenCalled(); + expect(result).toEqual({ events: [], handles: {}, cursor: null }); }); +}); - it('legacy untagged cursor with no search context falls back to D1', async () => { - mockRecords.mockResolvedValue(emptyPage); +// Legacy meili:/d1: tags and bare offsets are in-flight during the deploy +// window. They (and any tampered token) must end pagination cleanly, WITHOUT +// reconstructing a query from client fields (that would re-open the insecure +// path). +describe('legacy / undecodable cursors end pagination cleanly with no read', () => { + it.each([ + ['a meili tag', 'meili:20'], + ['a d1 tag', 'd1:eyJ0IjoxNzUsImsiOiJhdDovL3gifQ'], + ['a bare legacy offset', '20'], + ['a bare legacy keyset', 'eyJ0IjoxNzUsImsiOiJhdDovL3gifQ'], + ['a garbage token', 'not a real token'], + ['an empty string', ''] + ])('%s => empty, no query run', async (_label, cursor) => { + mockSearchBackend.mockReturnValue({ url: 'https://m', apiKey: 'k' }); + const result = await call(cursor, 'jazz'); + expect(result).toEqual({ events: [], handles: {}, cursor: null }); + noReads(); + }); - const result = await call({ cursor: 'legacyOpaqueKeyset' }); + it('an absent cursor => empty', async () => { + expect(await call(undefined)).toEqual({ events: [], handles: {}, cursor: null }); + noReads(); + }); - expect(mockRecords).toHaveBeenCalledTimes(1); - expect(mockRecords.mock.calls[0][1]).toMatchObject({ cursor: 'legacyOpaqueKeyset' }); + it('does NOT reconstruct a query from a legacy tag even with a search term present', async () => { + // Deploy-straddle: old client POSTs a legacy tag + (dropped) stale query + // params + a search term. The tag fails to decode => empty, no re-inference. + mockSearchBackend.mockReturnValue({ url: 'https://m', apiKey: 'k' }); + const result = await call('meili:20', 'jazz'); + expect(result.cursor).toBeNull(); expect(mockRunSearch).not.toHaveBeenCalled(); - expect(result.cursor).toBe('d1:next'); }); }); diff --git a/apps/web/src/lib/contrail/events-load-more.ts b/apps/web/src/lib/contrail/events-load-more.ts index a7b663c..402e4db 100644 --- a/apps/web/src/lib/contrail/events-load-more.ts +++ b/apps/web/src/lib/contrail/events-load-more.ts @@ -1,34 +1,31 @@ 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, - listEventRecordsFromContrail + listDiscoverableEventsFromContrail } from '$lib/contrail'; import { runEventSearchPage, searchBackendFromEnv } from '$lib/search/server/query'; -import { parseCursor, tagCursor } from './cursor'; -import type { ActorIdentifier } from '@atcute/lexicons'; +import { SEARCH_PAGE_SIZE } from '$lib/search/constants'; +import { orQueryFromSlug } from '$lib/topics'; +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. export const listEventsInput = v.object({ - actor: v.optional(v.string()), - search: v.optional(v.string()), - startsAtMin: v.optional(v.string()), - startsAtMax: v.optional(v.string()), - endsAtMin: v.optional(v.string()), - endsAtMax: v.optional(v.string()), - rsvpsCountMin: v.optional(v.number()), - rsvpsGoingCountMin: v.optional(v.number()), - profiles: v.optional(v.boolean()), - sort: v.optional(v.string()), - order: v.optional(v.picklist(['asc', 'desc'])), - limit: v.optional(v.number()), cursor: v.optional(v.string()), - // Which page-1 read pipeline this list came from. load-more MUST re-run the - // same pipeline or it drifts: 'discoverable' (home) drops the unlisted-event - // filter, 'authored' (profile hosting/past) drops the conference-talk filter, - // and either leaks records page 1 excluded. Absent => plain listRecords. - pipeline: v.optional(v.picklist(['discoverable', 'authored'])) + /** Free-text search term for the search page; ignored for every other query. */ + q: v.optional(v.string()) }); export type LoadMoreEventsInput = v.InferOutput; @@ -39,89 +36,163 @@ export type LoadMoreEventsResult = { cursor: string | null; }; +const EMPTY: LoadMoreEventsResult = { events: [], handles: {}, cursor: null }; + +function now(): string { + return new Date().toISOString(); +} + +/** + * 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) }; +} + +/** + * 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. + */ +type Resumer = ( + env: App.Platform['env'], + client: Client, + envelope: CursorEnvelope, + searchTerm: string | undefined +) => 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".) +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); + }, + + 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); + }, + + 'past-events': async (_env, client, { args, raw }) => { + if (!args?.actor || !isActorIdentifier(args.actor)) return EMPTY; + const response = await listAuthoredEventsFromContrail(client, { + actor: args.actor as ActorIdentifier, + startsAtMax: now(), + sort: 'startsAt', + order: 'desc', + profiles: true, + limit: PAGE_SIZE, + cursor: raw + }); + return toResult('past-events', args, response); + }, + + 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); + }, + + 'search-d1': async (_env, client, { args, raw }, searchTerm) => { + const q = searchTerm?.trim(); + if (!q) return EMPTY; // search term lost from the continuation => end cleanly + const response = await listDiscoverableEventsFromContrail(client, { + search: q, + startsAtMin: now(), + sort: 'startsAt', + order: 'desc', + profiles: true, + limit: SEARCH_PAGE_SIZE, + cursor: raw + }); + return toResult('search-d1', args, response); + }, + + 'search-meili': async (env, client, { args, raw }, searchTerm) => { + const q = searchTerm?.trim(); + const backend = q ? searchBackendFromEnv(env) : null; + // Missing search term OR unconfigured backend => end cleanly rather than + // restart page 1 on the wrong backend. + if (!q || !backend) return EMPTY; + const page = await runEventSearchPage(backend, client, { q, cursor: raw }); + return { + events: page.events, + handles: page.handles, + cursor: nextCursor('search-meili', page.cursor, args) + }; + } +}; + /** * Shared load-more handler. Kept out of the `.remote.ts` adapter so it is a * plain function the SvelteKit remote-functions plugin won't wrap — that lets it * 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". */ export async function runLoadMoreEvents( env: App.Platform['env'], input: LoadMoreEventsInput ): Promise { - const client = getServerClient(env.DB); - - // Route by the cursor's own tag, not by re-deriving the backend from request - // shape. The page that issued this cursor already committed to a backend; - // load-more MUST continue on that same one or first-load and load-more diverge - // and hand over an incompatible cursor (om-7dbs). - const { backend: cursorBackend, raw: cursorRaw } = parseCursor(input.cursor); - - // Only resolve the Meili backend when a search term is present (matches the - // search page's first-page path); avoids touching it for plain D1 loads. - const searchTerm = input.search?.trim(); - const searchBackend = searchTerm ? searchBackendFromEnv(env) : null; - - // Meili path when: the cursor is explicitly meili-tagged, OR it's an untagged - // legacy cursor (in-flight from before this deploy) and the old inference - // ("search set AND Meili configured") would have chosen Meili. A d1-tagged - // cursor is NEVER routed here, even with a search term + configured backend — - // that is exactly the divergence the tag exists to prevent. - const routeMeili = - cursorBackend === 'meili' || (cursorBackend === null && !!searchBackend && !!searchTerm); - if (routeMeili) { - if (!searchBackend || !searchTerm) { - // A meili-tagged cursor arrived but this context can't serve Meili (the - // backend is now unconfigured, or the search term was lost from the - // continuation). Feeding the offset to D1 listRecords would ignore it and - // drop filters, and re-inferring would restart page 1 on the wrong - // backend. Fail safe: end pagination cleanly. Errors otherwise propagate - // to EventList's catch so the user can retry with the cursor intact. - return { events: [], handles: {}, cursor: null }; - } - const page = await runEventSearchPage(searchBackend, client, { - q: searchTerm, - // Pass the untagged offset; runEventSearchPage also strips a meili tag - // itself, so a legacy bare offset works here too. - cursor: cursorRaw - }); - return { events: page.events, handles: page.handles, cursor: page.cursor }; - } - - // D1 path: an explicit d1 tag, or an untagged cursor with no Meili search - // context. Re-run the SAME page-1 pipeline so load-more inherits its filters. - // `pipeline` is our selector, not an xrpc param, so strip it. `cursor` is - // overwritten below with the untagged keyset (the inbound one carries the tag). - const { pipeline, ...rest } = input; - const params = { - ...rest, - actor: rest.actor as ActorIdentifier | undefined, - cursor: cursorRaw ?? undefined - }; - - const response = - pipeline === 'discoverable' - ? await listDiscoverableEventsFromContrail(client, params) - : pipeline === 'authored' - ? await listAuthoredEventsFromContrail(client, params) - : await listEventRecordsFromContrail(client, params); - - if (!response) { - return { events: [], handles: {}, cursor: null }; - } + const envelope = decodeCursor(input.cursor); + if (!envelope) return EMPTY; - const events = flattenEventRecords(response.records ?? []); + const resumer = REGISTRY[envelope.q]; + // decodeCursor already rejects an unknown `q`; this is belt-and-suspenders so + // the dispatch can never fall through to a default/plain pipeline. + if (!resumer) return EMPTY; - const handles: Record = {}; - for (const p of response.profiles ?? []) { - if (p.handle) handles[p.did] = p.handle; - } - - return { - events, - handles, - // Tag the keyset with the D1 backend so the next load-more stays on D1 and - // can't be re-inferred onto Meili (om-7dbs). - cursor: tagCursor('d1', response.cursor ?? null) - }; + const client = getServerClient(env.DB); + return resumer(env, client, envelope, input.q); } diff --git a/apps/web/src/lib/topics.ts b/apps/web/src/lib/topics.ts index 89b7b7e..c565549 100644 --- a/apps/web/src/lib/topics.ts +++ b/apps/web/src/lib/topics.ts @@ -214,3 +214,18 @@ export const TOPICS: Topic[] = [ export function getTopicBySlug(slug: string): Topic | undefined { return TOPICS.find((t) => t.slug === slug); } + +/** + * The FTS5 disjunction query for a topic slug: match events whose + * name/description mention ANY of the topic's hashtag terms. D1's SQLite FTS5 + * MATCH treats an uppercase `OR` as a real disjunction operator, so this is a + * true "any term" query. Returns null for an unknown slug so callers (the topic + * page load AND the load-more registry) can end pagination cleanly rather than + * run an empty/garbage search — the two MUST derive the query identically or + * page 1 and load-more drift apart. + */ +export function orQueryFromSlug(slug: string): string | null { + const topic = getTopicBySlug(slug); + if (!topic) return null; + return topic.hashtags.map((h) => h.replace(/^#/, '')).join(' OR '); +}