From 2fabb43223e4471485cd89089519245e6bb184a2 Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Thu, 2 Jul 2026 15:57:17 -0400 Subject: [PATCH 1/4] feat(search): add self-describing pagination cursor tags (om-7dbs) Introduce tagCursor/parseCursor: a cursor handed to the client is prefixed with the backend that issued it (meili: / d1:). The base64url D1 keyset and a decimal Meili offset both exclude ':', so the first ':' is an unambiguous tag separator. Untagged values parse to backend:null so callers can fall back to the old inference for in-flight pre-deploy cursors. --- apps/web/src/lib/contrail/cursor.test.ts | 58 ++++++++++++++++++++++++ apps/web/src/lib/contrail/cursor.ts | 56 +++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 apps/web/src/lib/contrail/cursor.test.ts create mode 100644 apps/web/src/lib/contrail/cursor.ts diff --git a/apps/web/src/lib/contrail/cursor.test.ts b/apps/web/src/lib/contrail/cursor.test.ts new file mode 100644 index 0000000..c6eafac --- /dev/null +++ b/apps/web/src/lib/contrail/cursor.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { tagCursor, parseCursor } from './cursor'; + +// A pagination cursor handed to the client is tagged with the backend that +// issued it so load-more can route by the tag instead of re-deriving the +// backend from request shape (om-7dbs). These pin the tag round-trip and the +// legacy (untagged) fallback contract both cursor kinds share. +describe('tagCursor', () => { + it('prefixes a Meili offset with its backend tag', () => { + expect(tagCursor('meili', '20')).toBe('meili:20'); + }); + + it('prefixes an opaque D1 base64url keyset with its backend tag', () => { + // A real D1 cursor is base64url(JSON) — no ':' in the alphabet, so the + // first ':' is unambiguously the tag separator. + const d1 = 'eyJ0IjoxNzUsImsiOiJhdDovL3gifQ'; + expect(tagCursor('d1', d1)).toBe(`d1:${d1}`); + }); + + it('returns null for a null/empty raw cursor (no more pages)', () => { + expect(tagCursor('meili', null)).toBeNull(); + expect(tagCursor('d1', undefined)).toBeNull(); + expect(tagCursor('d1', '')).toBeNull(); + }); +}); + +describe('parseCursor', () => { + it('round-trips a Meili-tagged cursor back to {backend, raw}', () => { + expect(parseCursor(tagCursor('meili', '20'))).toEqual({ backend: 'meili', raw: '20' }); + }); + + it('round-trips a D1-tagged cursor back to {backend, raw}', () => { + const d1 = 'eyJ0IjoxNzUsImsiOiJhdDovL3gifQ'; + expect(parseCursor(tagCursor('d1', d1))).toEqual({ backend: 'd1', raw: d1 }); + }); + + it('treats a null/empty cursor as no cursor', () => { + expect(parseCursor(null)).toEqual({ backend: null, raw: null }); + expect(parseCursor(undefined)).toEqual({ backend: null, raw: null }); + expect(parseCursor('')).toEqual({ backend: null, raw: null }); + }); + + it('treats an untagged legacy Meili offset as backend:null with raw preserved', () => { + // In-flight cursor issued before this deploy: no recognized tag, so the + // caller falls back to the old inference and can still consume raw. + expect(parseCursor('20')).toEqual({ backend: null, raw: '20' }); + }); + + it('treats an untagged legacy D1 base64url keyset as backend:null with raw preserved', () => { + const d1 = 'eyJ0IjoxNzUsImsiOiJhdDovL3gifQ'; + expect(parseCursor(d1)).toEqual({ backend: null, raw: d1 }); + }); + + it('does not mistake an unknown prefix for a backend tag', () => { + // Only 'meili'/'d1' are backends; anything else is legacy/opaque and kept whole. + expect(parseCursor('foo:bar')).toEqual({ backend: null, raw: 'foo:bar' }); + }); +}); diff --git a/apps/web/src/lib/contrail/cursor.ts b/apps/web/src/lib/contrail/cursor.ts new file mode 100644 index 0000000..b2f6fa2 --- /dev/null +++ b/apps/web/src/lib/contrail/cursor.ts @@ -0,0 +1,56 @@ +// Self-describing pagination cursors (om-7dbs). +// +// A cursor handed to the client is tagged with the backend that issued it, so +// load-more routes by the tag instead of re-deriving the backend from the +// request shape ("is search set AND is Meili configured"). That inference broke +// whenever a page's FIRST load came from one backend but its load-more resolved +// to the other: +// - a D1 keyset fed to Meili: Number(base64url) -> NaN -> offset 0 -> a +// relevance-reordered duplicate of page 1; +// - a Meili offset fed to D1 listRecords: ignored, and the discoverable / +// time-bound filters the first page applied get dropped. +// +// The raw cursor is opaque: a Meili offset string, or a base64url(JSON) D1 +// keyset built inside @atmo-dev/contrail. We WRAP it, never rewrite it — the +// separator below can't collide because base64url's alphabet excludes ':' and a +// Meili offset is decimal digits. + +export type CursorBackend = 'meili' | 'd1'; + +const SEP = ':'; +const BACKENDS: readonly CursorBackend[] = ['meili', 'd1']; + +/** + * Tag a backend-native cursor for the client. A null/empty raw cursor stays + * null (the backend signalled "no more pages"); tagging must not manufacture a + * cursor where there wasn't one. + */ +export function tagCursor(backend: CursorBackend, raw: string | null | undefined): string | null { + if (raw == null || raw === '') return null; + return `${backend}${SEP}${raw}`; +} + +export type ParsedCursor = + | { backend: CursorBackend; raw: string } + | { backend: null; raw: string | null }; + +/** + * Split a client cursor back into { backend, raw }. + * + * - A recognized `meili:`/`d1:` tag routes by that backend. + * - `null`/empty -> { backend: null, raw: null } (no cursor). + * - Anything else is an untagged legacy cursor (in-flight from before this + * deploy, or an unknown prefix): { backend: null, raw: } so the caller + * can fall back to the old inference and still consume it. + */ +export function parseCursor(cursor: string | null | undefined): ParsedCursor { + if (cursor == null || cursor === '') return { backend: null, raw: null }; + const sep = cursor.indexOf(SEP); + if (sep > 0) { + const tag = cursor.slice(0, sep); + if ((BACKENDS as readonly string[]).includes(tag)) { + return { backend: tag as CursorBackend, raw: cursor.slice(sep + 1) }; + } + } + return { backend: null, raw: cursor }; +} -- 2.51.2 From d9fca44540e440a66ad144762a956ff0d8cceb45 Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Thu, 2 Jul 2026 15:57:24 -0400 Subject: [PATCH 2/4] fix(search): tag Meili offset cursors and strip tag on parse (om-7dbs) runEventSearchPage/runNearMePage now emit a meili:-tagged offset, and parseOffsetCursor accepts both the tagged cursor and a bare legacy offset. A d1-tagged keyset reaching here resolves to offset 0 (clean restart) instead of Number(base64url)->NaN. First-page search results therefore emit a tagged cursor for free. --- apps/web/src/lib/search/server/query.test.ts | 27 +++++++++++++++++--- apps/web/src/lib/search/server/query.ts | 14 ++++++++-- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/web/src/lib/search/server/query.test.ts b/apps/web/src/lib/search/server/query.test.ts index 67ea9ee..ad7a801 100644 --- a/apps/web/src/lib/search/server/query.test.ts +++ b/apps/web/src/lib/search/server/query.test.ts @@ -55,7 +55,11 @@ describe('searchBackendFromEnv', () => { it('carries SEARCH_INDEX through so the read path can match the sink index', () => { expect( - searchBackendFromEnv({ SEARCH_URL: 'https://s', SEARCH_API_KEY: 'k', SEARCH_INDEX: 'events-test' }) + searchBackendFromEnv({ + SEARCH_URL: 'https://s', + SEARCH_API_KEY: 'k', + SEARCH_INDEX: 'events-test' + }) ).toMatchObject({ url: 'https://s', apiKey: 'k', indexUid: 'events-test' }); }); }); @@ -75,7 +79,12 @@ describe('runEventSearchPage', () => { [{ did: 'did:plc:a', handle: 'alice.test' }] ); - const page = await runEventSearchPage(backend(fetchFn), client, { q: 'fest', cursor: '10' }); + // A tagged `meili:10` cursor (from a load-more round-trip) parses back to + // offset 10 — the tag is stripped before it reaches Meilisearch. + const page = await runEventSearchPage(backend(fetchFn), client, { + q: 'fest', + cursor: 'meili:10' + }); expect(bodies[0].offset).toBe(10); expect(bodies[0].q).toBe('fest'); @@ -97,9 +106,19 @@ describe('runEventSearchPage', () => { const page = await runEventSearchPage(backend(fetchFn), client, { q: 'fest', cursor: null }); - // Page fills at SEARCH_PAGE_SIZE hits; the next offset is that count. + // Page fills at SEARCH_PAGE_SIZE hits; the next offset is that count, tagged + // with the Meili backend so load-more can't misroute it to D1 (om-7dbs). expect(page.events).toHaveLength(SEARCH_PAGE_SIZE); - expect(page.cursor).toBe(String(SEARCH_PAGE_SIZE)); + expect(page.cursor).toBe(`meili:${SEARCH_PAGE_SIZE}`); + }); + + it('accepts a bare legacy offset cursor (untagged, pre-deploy in-flight)', async () => { + const { fetchFn, bodies } = meiliFetch([{ uri: 'at://did:plc:a/c/1' }], 5); + const client = fakeClient([record('at://did:plc:a/c/1', 'alpha')]); + + await runEventSearchPage(backend(fetchFn), client, { q: 'fest', cursor: '10' }); + + expect(bodies[0].offset).toBe(10); }); it('throws when D1 hydration fails so the caller can fall back instead of skipping hits', async () => { diff --git a/apps/web/src/lib/search/server/query.ts b/apps/web/src/lib/search/server/query.ts index 14b0c09..795ea9b 100644 --- a/apps/web/src/lib/search/server/query.ts +++ b/apps/web/src/lib/search/server/query.ts @@ -16,6 +16,7 @@ import { listDiscoverableEventsByUrisFromContrail, type FlatEventRecord } from '$lib/contrail'; +import { parseCursor, tagCursor } from '$lib/contrail/cursor'; export interface SearchPageResult { events: FlatEventRecord[]; @@ -38,7 +39,14 @@ export function searchBackendFromEnv(env?: { } function parseOffsetCursor(cursor: string | null | undefined): number { - const n = Number(cursor); + // Accept both a tagged `meili:` cursor (a load-more round-trip) and a bare + // `` (first page, or a legacy pre-deploy cursor). A D1-tagged keyset should + // never reach here now that load-more routes by tag — but if one does, treat + // it as offset 0 (a clean restart) rather than Number(base64url) -> NaN, which + // this guard already collapses to 0 anyway. + const { backend, raw } = parseCursor(cursor); + if (backend === 'd1') return 0; + const n = Number(raw); return Number.isInteger(n) && n > 0 ? n : 0; } @@ -82,7 +90,9 @@ async function hydrateToPage( return { events: flattenEventRecords(items.map((i) => i.record)), handles, - cursor: consumed > 0 && moreToScan ? String(next) : null, + // Tag the offset with the Meili backend so load-more continues on Meili and + // never feeds this number to D1 listRecords (om-7dbs). + cursor: consumed > 0 && moreToScan ? tagCursor('meili', String(next)) : null, distances }; } -- 2.51.2 From 850579ccfd95f38da291a3bf1d55fc8f0fda42c2 Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Thu, 2 Jul 2026 15:57:36 -0400 Subject: [PATCH 3/4] fix(search): route loadMoreEvents by cursor tag, not backend inference (om-7dbs) runLoadMoreEvents now honors the cursor's own backend tag: a meili: cursor stays on Meili, a d1: cursor stays on D1 even when a search term + configured Meili backend are present (PR #49's topics trip case). A meili: cursor with no usable Meili context fails safe by ending pagination rather than restarting page 1 on D1 or NaN-parsing. Untagged legacy cursors fall back to the old search-set-AND-Meili-configured inference. The D1 keyset returned to the client is now d1:-tagged. --- .../src/lib/contrail/events-load-more.test.ts | 107 +++++++++++++++++- apps/web/src/lib/contrail/events-load-more.ts | 57 ++++++++-- 2 files changed, 151 insertions(+), 13 deletions(-) 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) }; } -- 2.51.2 From e0e6aa0e250026fab064b11ebf7c76a5196a4012 Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Thu, 2 Jul 2026 15:57:43 -0400 Subject: [PATCH 4/4] fix(search): tag D1 first-page cursors and refresh stale routing comments (om-7dbs) Home events, profile hosting/past-events first pages now emit d1:-tagged cursors and untag any inbound (deep-link) cursor before the D1 read, so first-load and load-more can't diverge. Search D1 fallback and topics pages keep their null cursor (no pipeline in fetchParams to resume through); their comments are updated to describe the new tag-based routing and defer re-enabling pagination to om-47ak. --- apps/web/src/routes/(app)/events/+page.server.ts | 9 +++++++-- .../(app)/p/[actor]/hosting/+page.server.ts | 7 +++++-- .../(app)/p/[actor]/past-events/+page.server.ts | 7 +++++-- apps/web/src/routes/(app)/search/+page.server.ts | 14 +++++++------- .../routes/(app)/topics/[slug]/+page.server.ts | 15 ++++++++------- 5 files changed, 32 insertions(+), 20 deletions(-) diff --git a/apps/web/src/routes/(app)/events/+page.server.ts b/apps/web/src/routes/(app)/events/+page.server.ts index cb8d7c6..787b2bb 100644 --- a/apps/web/src/routes/(app)/events/+page.server.ts +++ b/apps/web/src/routes/(app)/events/+page.server.ts @@ -3,6 +3,7 @@ import { getServerClient, listDiscoverableEventsFromContrail } from '$lib/contrail'; +import { parseCursor, tagCursor } from '$lib/contrail/cursor'; import type { PageServerLoad } from './$types'; const PAGE_SIZE = 20; @@ -10,7 +11,9 @@ const PAGE_SIZE = 20; export const load: PageServerLoad = async ({ url, platform }) => { const client = getServerClient(platform!.env.DB); const now = new Date().toISOString(); - const cursor = url.searchParams.get('cursor') ?? undefined; + // Untag any inbound cursor (deep link) before handing the opaque keyset to D1; + // legacy untagged cursors pass through unchanged (om-7dbs). + const cursor = parseCursor(url.searchParams.get('cursor')).raw ?? undefined; const isPopular = url.searchParams.get('filter') !== 'all'; const response = await listDiscoverableEventsFromContrail(client, { @@ -33,6 +36,8 @@ export const load: PageServerLoad = async ({ url, platform }) => { return { events: flattenEventRecords(response.records), handles, - cursor: response.cursor ?? null + // Tag the first-page cursor so load-more routes back to this same D1 + // discoverable pipeline instead of re-inferring a backend (om-7dbs). + cursor: tagCursor('d1', response.cursor ?? null) }; }; 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 22c63ed..6486d82 100644 --- a/apps/web/src/routes/(app)/p/[actor]/hosting/+page.server.ts +++ b/apps/web/src/routes/(app)/p/[actor]/hosting/+page.server.ts @@ -5,6 +5,7 @@ import { getServerClient, listAuthoredEventsFromContrail } from '$lib/contrail'; +import { parseCursor, tagCursor } from '$lib/contrail/cursor'; import { isActorIdentifier } from '@atcute/lexicons/syntax'; import { error } from '@sveltejs/kit'; @@ -19,7 +20,8 @@ export async function load({ params, url, platform }) { if (!did) throw error(404, 'Actor not found'); - const cursor = url.searchParams.get('cursor') ?? undefined; + // Untag any inbound cursor before the D1 read; legacy untagged passes through. + const cursor = parseCursor(url.searchParams.get('cursor')).raw ?? undefined; const now = new Date().toISOString(); const [profile, response] = await Promise.all([ @@ -37,7 +39,8 @@ export async function load({ params, url, platform }) { return { events: response ? flattenEventRecords(response.records) : [], - cursor: response?.cursor ?? null, + // Tag so load-more stays on this D1 authored pipeline (om-7dbs). + cursor: tagCursor('d1', response?.cursor ?? null), 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 9e08c64..56549b0 100644 --- a/apps/web/src/routes/(app)/p/[actor]/past-events/+page.server.ts +++ b/apps/web/src/routes/(app)/p/[actor]/past-events/+page.server.ts @@ -5,6 +5,7 @@ import { getServerClient, listAuthoredEventsFromContrail } from '$lib/contrail'; +import { parseCursor, tagCursor } from '$lib/contrail/cursor'; import { isActorIdentifier } from '@atcute/lexicons/syntax'; import { error } from '@sveltejs/kit'; @@ -19,7 +20,8 @@ export async function load({ params, url, platform }) { if (!did) throw error(404, 'Actor not found'); - const cursor = url.searchParams.get('cursor') ?? undefined; + // Untag any inbound cursor before the D1 read; legacy untagged passes through. + const cursor = parseCursor(url.searchParams.get('cursor')).raw ?? undefined; const now = new Date().toISOString(); const [profile, response] = await Promise.all([ @@ -42,7 +44,8 @@ export async function load({ params, url, platform }) { return { events, - cursor: response?.cursor ?? null, + // Tag so load-more stays on this D1 authored pipeline (om-7dbs). + cursor: tagCursor('d1', response?.cursor ?? null), 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 a93e668..95eeb5e 100644 --- a/apps/web/src/routes/(app)/search/+page.server.ts +++ b/apps/web/src/routes/(app)/search/+page.server.ts @@ -50,13 +50,13 @@ export const load: PageServerLoad = async ({ url, platform }) => { return { events: flattenEventRecords(response.records), handles, - // The D1 fallback is first-batch-only; don't hand its cursor back. - // loadMoreEvents has no search backend here, so it paginates via - // listRecords, which applies neither the discoverable filter nor the - // startsAtMin this page uses — later pages would drift into past and - // non-discoverable events. (When a configured backend errored the cursor - // is also unusable: it's a D1 cursor but loadMoreEvents re-routes to Meili - // whenever a backend is configured.) Drop it in both cases. + // The D1 fallback is first-batch-only; don't hand its cursor back. Even + // with self-describing cursors (om-7dbs), the search fetchParams carry no + // `pipeline`, so a d1-tagged cursor would route load-more through plain + // listRecords — dropping the discoverable filter and startsAtMin this page + // applies — and later pages would drift into past and non-discoverable + // events. Re-enabling consistent D1 pagination here would mean threading the + // discoverable pipeline + filters through; deferred. Drop the cursor. cursor: null, query: q }; 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 2b4ae58..e7f9628 100644 --- a/apps/web/src/routes/(app)/topics/[slug]/+page.server.ts +++ b/apps/web/src/routes/(app)/topics/[slug]/+page.server.ts @@ -41,13 +41,14 @@ export const load: PageServerLoad = async ({ params, platform }) => { topic, events: flattenEventRecords(response?.records ?? []), handles, - // First-batch-only, like the search page's D1 fallback. This D1 FTS read has - // no cursor loadMoreEvents can resume: with a search backend configured it - // re-routes to Meili (whose offset cursor is incompatible with this D1 - // cursor), and without one it paginates via listRecords (dropping the - // discoverable + startsAtMin filters this page relies on). Either way later - // pages would drift, so don't hand back a cursor. Deep topic pagination is - // tracked separately (om-47ak). + // First-batch-only, like the search page's D1 fallback. Self-describing + // cursors (om-7dbs) now stop this page's D1 cursor from being mis-consumed + // as a Meili offset, but they don't make it resumable here: this fetchParams + // contract carries no `pipeline`, so a d1-tagged cursor would still route + // load-more through plain listRecords, dropping the discoverable + + // startsAtMin filters this page relies on. So don't hand back a cursor. Deep + // topic pagination (threading the discoverable pipeline through) is tracked + // separately (om-47ak). cursor: null, query }; -- 2.51.2