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 0689679..dbffdef 100644 --- a/apps/web/src/lib/contrail/events-load-more.test.ts +++ b/apps/web/src/lib/contrail/events-load-more.test.ts @@ -26,7 +26,7 @@ import { listDiscoverableEventsFromContrail, listEventRecordsFromContrail } from '$lib/contrail'; -import { searchBackendFromEnv } from '$lib/search/server/query'; +import { runEventSearchPage, searchBackendFromEnv } from '$lib/search/server/query'; const mockRecords = vi.mocked(listEventRecordsFromContrail); const mockDiscoverable = vi.mocked(listDiscoverableEventsFromContrail); @@ -93,4 +93,109 @@ describe('runLoadMoreEvents pipeline routing', () => { expect(mockSearchBackend).not.toHaveBeenCalled(); }); + + it('tags the D1 cursor it returns to the client with the d1 backend', async () => { + mockDiscoverable.mockResolvedValue(emptyPage); // cursor: 'next' + + const result = await call({ pipeline: 'discoverable', cursor: 'd1:opaque' }); + + expect(result.cursor).toBe('d1:next'); + }); +}); + +// 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' + }); + + 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('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); + + const result = await call({ search: 'jazz', cursor: 'meili:20' }); + + expect(mockRunSearch).toHaveBeenCalledTimes(1); + expect(mockRunSearch.mock.calls[0][2]).toMatchObject({ q: 'jazz', cursor: '20' }); + expect(mockRecords).not.toHaveBeenCalled(); + expect(mockDiscoverable).not.toHaveBeenCalled(); + expect(result.cursor).toBe('meili:40'); + }); + + 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); + + const result = await call({ search: 'jazz', cursor: 'meili:20' }); + + expect(mockRunSearch).not.toHaveBeenCalled(); + expect(mockRecords).not.toHaveBeenCalled(); + expect(mockDiscoverable).not.toHaveBeenCalled(); + expect(result).toEqual({ events: [], handles: {}, cursor: null }); + }); + + 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' }); + + const result = await call({ cursor: 'meili:20' }); + + expect(mockRunSearch).not.toHaveBeenCalled(); + 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); + + const result = await call({ search: 'jazz', cursor: '20' }); + + 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('legacy untagged cursor with no search context falls back to D1', async () => { + mockRecords.mockResolvedValue(emptyPage); + + const result = await call({ cursor: 'legacyOpaqueKeyset' }); + + expect(mockRecords).toHaveBeenCalledTimes(1); + expect(mockRecords.mock.calls[0][1]).toMatchObject({ cursor: 'legacyOpaqueKeyset' }); + 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 bd8abc5..a7b663c 100644 --- a/apps/web/src/lib/contrail/events-load-more.ts +++ b/apps/web/src/lib/contrail/events-load-more.ts @@ -7,6 +7,7 @@ import { listEventRecordsFromContrail } from '$lib/contrail'; import { runEventSearchPage, searchBackendFromEnv } from '$lib/search/server/query'; +import { parseCursor, tagCursor } from './cursor'; import type { ActorIdentifier } from '@atcute/lexicons'; export const listEventsInput = v.object({ @@ -50,23 +51,53 @@ export async function runLoadMoreEvents( ): Promise { const client = getServerClient(env.DB); - // Text-search pagination goes through Meilisearch when configured, matching - // the search page's first-page path — its cursor is a Meili offset, which - // the D1 path below cannot consume (and vice versa). Errors propagate to - // EventList's catch so the user can retry with the cursor intact. - const searchBackend = input.search?.trim() ? searchBackendFromEnv(env) : null; - if (searchBackend && input.search) { + // 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: input.search.trim(), - cursor: input.cursor ?? null + 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 }; } - // Re-run the SAME page-1 pipeline so load-more inherits its filters. `pipeline` - // is our selector, not an xrpc param, so strip it before the call. + // 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 }; + const params = { + ...rest, + actor: rest.actor as ActorIdentifier | undefined, + cursor: cursorRaw ?? undefined + }; const response = pipeline === 'discoverable' @@ -89,6 +120,8 @@ export async function runLoadMoreEvents( return { events, handles, - cursor: response.cursor ?? null + // 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) }; }