diff --git a/apps/web/src/lib/contrail/events-load-more.ts b/apps/web/src/lib/contrail/events-load-more.ts index 402e4db..2410c8a 100644 --- a/apps/web/src/lib/contrail/events-load-more.ts +++ b/apps/web/src/lib/contrail/events-load-more.ts @@ -140,10 +140,10 @@ const REGISTRY: Record = { }, '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 term = searchTerm?.trim(); + if (!term) return EMPTY; // search term lost from the continuation => end cleanly const response = await listDiscoverableEventsFromContrail(client, { - search: q, + search: term, startsAtMin: now(), sort: 'startsAt', order: 'desc', @@ -155,12 +155,12 @@ const REGISTRY: Record = { }, 'search-meili': async (env, client, { args, raw }, searchTerm) => { - const q = searchTerm?.trim(); - const backend = q ? searchBackendFromEnv(env) : null; + 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 (!q || !backend) return EMPTY; - const page = await runEventSearchPage(backend, client, { q, cursor: raw }); + if (!term || !backend) return EMPTY; + const page = await runEventSearchPage(backend, client, { q: term, cursor: raw }); return { events: page.events, handles: page.handles, @@ -185,14 +185,24 @@ export async function runLoadMoreEvents( env: App.Platform['env'], input: LoadMoreEventsInput ): Promise { + // Two different `q`s meet in this file: `input.q` is the free-text search TERM + // the client re-supplies, while `envelope.q` below is the server-side query + // NAME. Bind the term to a distinct local so the rest of the file can't read + // one as the other. The WIRE field stays `q` — renaming it would silently + // strand already-loaded search tabs across a deploy. + const searchTerm = input.q; + const envelope = decodeCursor(input.cursor); if (!envelope) return EMPTY; + // Own-property dispatch guard: index REGISTRY only when it OWNS the key, so a + // prototype-chain name (e.g. 'constructor', 'toString') can never resolve to an + // inherited value and dispatch. decodeCursor already allow-lists `q` against + // CURSOR_QUERIES upstream, so this is defense in depth against the dispatch + // ever falling through to a default/plain pipeline. + if (!Object.hasOwn(REGISTRY, envelope.q)) return EMPTY; 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 client = getServerClient(env.DB); - return resumer(env, client, envelope, input.q); + return resumer(env, client, envelope, searchTerm); } -- 2.51.2 From 723ed7610fa7ad2dc2059c4cb7e6112ed7810187 Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Fri, 24 Jul 2026 13:35:22 -0400 Subject: [PATCH 2/5] fix(past-events): stop an ongoing event resurfacing on page 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The past-events list bounds its D1 query on startsAt, which still admits an event that began earlier and has NOT finished. Page 1 narrowed that away with an inline predicate; the load-more resumer did not. So an event that started last week and runs until next week was filtered off page 1 and then appeared on page 2 of "Past Events". Extract the predicate to `hasEnded(event, asOf)` and apply it on both sides, the way `orQueryFromSlug` is already shared between the topic load and its resumer. Page 1 can now filter a full backend batch down to nothing while still holding a cursor. Render the list whenever a cursor remains, so that case shows a Load more button instead of "No past events found." — previously the genuinely past events behind it were unreachable. Note the residual: because the predicate runs after pagination, a Load more click can still return zero new events when a whole batch is ongoing. Making the resumer scan ahead for a non-empty page is a bigger change and is left out of here deliberately. --- apps/web/src/lib/contrail/events-load-more.ts | 10 ++++++++-- apps/web/src/lib/past-events.ts | 19 +++++++++++++++++++ .../p/[actor]/past-events/+page.server.ts | 10 +++++++--- .../(app)/p/[actor]/past-events/+page.svelte | 6 +++++- 4 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/lib/past-events.ts diff --git a/apps/web/src/lib/contrail/events-load-more.ts b/apps/web/src/lib/contrail/events-load-more.ts index 2410c8a..3cd24a6 100644 --- a/apps/web/src/lib/contrail/events-load-more.ts +++ b/apps/web/src/lib/contrail/events-load-more.ts @@ -11,6 +11,7 @@ import { 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; @@ -110,16 +111,21 @@ const REGISTRY: Record = { '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: now(), + startsAtMax: asOf, sort: 'startsAt', order: 'desc', profiles: true, limit: PAGE_SIZE, cursor: raw }); - return toResult('past-events', args, response); + // 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)) }; }, topic: async (_env, client, { args, raw }) => { diff --git a/apps/web/src/lib/past-events.ts b/apps/web/src/lib/past-events.ts new file mode 100644 index 0000000..8b22a30 --- /dev/null +++ b/apps/web/src/lib/past-events.ts @@ -0,0 +1,19 @@ +import type { FlatEventRecord } from '@atmo-dev/events-ui'; + +/** + * Has this event finished as of `asOf` (an ISO instant)? + * + * The "past events" list bounds its D1 query on startsAt, which still admits an + * event that began earlier and is STILL RUNNING. Page 1 and the load-more + * resumer both narrow their results with this predicate, so an ongoing event + * cannot be filtered off page 1 and then reappear on page 2. + * + * An event with no endsAt is treated as over once it has started, matching how + * the rest of the app approximates duration it was never given. + */ +export function hasEnded( + event: Pick, + asOf: string +): boolean { + return new Date(event.endsAt || event.startsAt) < new Date(asOf); +} 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 44fe029..301aa66 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 @@ -6,6 +6,7 @@ import { listAuthoredEventsFromContrail } from '$lib/contrail'; import { nextCursor, rawForQuery } from '$lib/contrail/cursor'; +import { hasEnded } from '$lib/past-events'; import { isActorIdentifier } from '@atcute/lexicons/syntax'; import { error } from '@sveltejs/kit'; @@ -37,9 +38,12 @@ export async function load({ params, url, platform }) { }) ]); - const nowDate = new Date(now); - const events = (response ? flattenEventRecords(response.records) : []).filter( - (e) => new Date(e.endsAt || e.startsAt) < nowDate + // 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 { diff --git a/apps/web/src/routes/(app)/p/[actor]/past-events/+page.svelte b/apps/web/src/routes/(app)/p/[actor]/past-events/+page.svelte index a2b76e1..9e9ec25 100644 --- a/apps/web/src/routes/(app)/p/[actor]/past-events/+page.svelte +++ b/apps/web/src/routes/(app)/p/[actor]/past-events/+page.svelte @@ -38,7 +38,11 @@ {hostName} - {#if (data.events?.length ?? 0) > 0} + + {#if (data.events?.length ?? 0) > 0 || data.cursor} Date: Fri, 24 Jul 2026 13:35:22 -0400 Subject: [PATCH 3/5] test(pagination): pin page-1 load and load-more to the same query on every route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Page 1 comes from each route's own load(); page 2 comes from the resumer registry in events-load-more.ts. Nothing tied the two together — events-load-more.test.ts asserted the literals it BELIEVED each route used and never imported a route load. Routes with no page.server test could drift their limit, order or time bound freely; the routes that had one still left most of the query bag unasserted. A keyset is query-shape-specific, so that drift silently skips or repeats rows. Each paginated route now drives its real load() AND the real runLoadMoreEvents resumer, then compares the two emitted query bags in FULL, minus the cursor, which is checked separately: page 1 runs fresh, page 2 threads page 1's raw keyset through unchanged. Comparing the whole bag rather than a named subset means a param added to one side only is caught even though no assertion mentions it by name. The clock is frozen per file so both sides compute the same startsAtMin / startsAtMax and the bound is compared BY VALUE rather than by type — a bound that drifts to a different instant is precisely the drift worth catching. Query identity that lives outside the options bag is pinned too: the search tests compare the Meilisearch backend both calls were handed, since it carries the index an offset is only meaningful against. The one route-specific result filter is pinned as well — past-events asserts both sides exclude a still-running event — and hasEnded has its own unit test for the missing-endsAt and exact-instant edges. - new: events, p/[actor]/hosting, p/[actor]/past-events - extended: search (both the D1 fallback and the Meili offset path), topics - new unit: past-events hasEnded predicate (missing endsAt, strict <) - per route: deep-link guard cases for a foreign query name and for the same name with different args - listEventsInput: pin that unknown POST keys are stripped, so a legacy or hostile client bag cannot influence a resumer Verified by mutation: 22 drifts injected across the five page-1 loads and the six registry resumers — order, limit, sort, time bound, a dropped filter, a page-1-only search option, a page-1-only search index, and the ended-event predicate removed from either side — each one fails at least one of these tests. --- .../src/lib/contrail/continuity.test-utils.ts | 41 +++++ .../src/lib/contrail/events-load-more.test.ts | 50 +++++- apps/web/src/lib/past-events.test.ts | 34 +++++ .../routes/(app)/events/page.server.test.ts | 121 +++++++++++++++ .../p/[actor]/hosting/page.server.test.ts | 106 +++++++++++++ .../p/[actor]/past-events/page.server.test.ts | 144 ++++++++++++++++++ .../routes/(app)/search/page.server.test.ts | 82 ++++++++-- .../(app)/topics/[slug]/page.server.test.ts | 73 +++++++-- 8 files changed, 625 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/lib/contrail/continuity.test-utils.ts create mode 100644 apps/web/src/lib/past-events.test.ts create mode 100644 apps/web/src/routes/(app)/events/page.server.test.ts create mode 100644 apps/web/src/routes/(app)/p/[actor]/hosting/page.server.test.ts create mode 100644 apps/web/src/routes/(app)/p/[actor]/past-events/page.server.test.ts diff --git a/apps/web/src/lib/contrail/continuity.test-utils.ts b/apps/web/src/lib/contrail/continuity.test-utils.ts new file mode 100644 index 0000000..7e7608a --- /dev/null +++ b/apps/web/src/lib/contrail/continuity.test-utils.ts @@ -0,0 +1,41 @@ +// Shared assertions for the page-1 ↔ load-more continuity tests that sit beside +// each paginated route's +page.server.ts. +import { expect } from 'vitest'; + +/** + * A fixed instant for those tests to run at, so page 1 and the resumer compute + * the same `now()` time bound and it can be compared by value. Install per file: + * + * beforeEach(() => vi.useFakeTimers({ toFake: ['Date'], now: FROZEN_NOW })); + * afterEach(() => vi.useRealTimers()); + * + * Only `Date` is faked, so async control flow is untouched. + */ +export const FROZEN_NOW = new Date('2026-06-01T12:00:00.000Z'); + +/** + * Assert that a route's page-1 `load()` and the load-more resumer continuing it + * issued the SAME query. + * + * A keyset cursor is query-shape-specific, so page 2 is only adjacent to page 1 + * when every other param agrees — sort, order, limit, filters, scope and the + * time bounds. Hence the full param bags are compared rather than a chosen + * subset: a param added to one side only is drift, whether or not this helper + * knows its name. The cursor is the one param that legitimately differs, so it + * is checked separately — page 1 runs fresh, page 2 threads page 1's raw keyset + * through unchanged. + * + * Requires a frozen clock (see FROZEN_NOW). + */ +export function expectSameQuery( + page1: Record, + page2: Record, + rawKeyset: string +): void { + const { cursor: page1Cursor, ...page1Query } = page1; + const { cursor: page2Cursor, ...page2Query } = page2; + + expect(page2Query).toEqual(page1Query); + expect(page1Cursor).toBeUndefined(); + expect(page2Cursor).toBe(rawKeyset); +} 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 025278c..d1534ca 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,8 @@ vi.mock('$lib/search/server/query', () => ({ runEventSearchPage: vi.fn() })); -import { runLoadMoreEvents } from './events-load-more'; +import * as v from 'valibot'; +import { listEventsInput, runLoadMoreEvents } from './events-load-more'; import { encodeCursor, decodeCursor, type CursorEnvelope } from './cursor'; import { listAuthoredEventsFromContrail, @@ -231,10 +232,14 @@ describe('security: a tampered/forged envelope cannot surface non-discoverable e noReads(); }); - it('q tampered to an unknown string / missing / empty => empty, and listRecords never runs', async () => { + it('q tampered to an unknown string / prototype key / missing / empty => empty, and listRecords never runs', async () => { mockRecords.mockResolvedValue(page([unlisted])); for (const forged of [ forge({ v: 1, q: 'listRecords', raw: 'x' }), + // A prototype-chain name must never dispatch (decodeCursor rejects it first, + // and the Object.hasOwn dispatch guard is belt-and-suspenders behind that). + forge({ v: 1, q: 'constructor', raw: 'x' }), + forge({ v: 1, q: '__proto__', raw: 'x' }), forge({ v: 1, raw: 'x' }), forge({ v: 1, q: '', raw: 'x' }) ]) { @@ -270,7 +275,9 @@ describe('security: a tampered/forged envelope cannot surface non-discoverable e 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' })); + 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(); @@ -293,7 +300,9 @@ describe('registry required-arg guards end cleanly (never throw, never fall thro }); it('topic with an unknown slug => empty', async () => { - const result = await call(token({ v: 1, q: 'topic', args: { slug: 'no-such-topic' }, raw: 'k' })); + 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 }); }); @@ -352,3 +361,36 @@ describe('legacy / undecodable cursors end pagination cleanly with no read', () expect(mockRunSearch).not.toHaveBeenCalled(); }); }); + +// The schema is the trust boundary for the load-more POST body — a legacy client +// mid-deploy, or a hostile one, can put anything in it. `v.object` STRIPS unknown +// keys, so only `cursor` and `q` ever reach runLoadMoreEvents and every filter +// value stays server-authoritative instead of client-echoed. +describe('listEventsInput accepts a client bag without trusting it', () => { + it('strips a query-reconstruction bag down to the two fields that are read', () => { + const parsed = v.parse(listEventsInput, { + cursor: 'envelope-token', + q: 'jazz', + // What a pre-envelope client echoed, and what a hostile one would widen. + pipeline: 'listRecords', + sort: 'indexedAt', + order: 'desc', + limit: 500, + startsAtMin: '1970-01-01T00:00:00.000Z', + actor: 'did:plc:someone-else' + }); + expect(parsed).toEqual({ cursor: 'envelope-token', q: 'jazz' }); + }); + + it('accepts a cursor-only body and an empty one', () => { + expect(v.parse(listEventsInput, { cursor: 'envelope-token' })).toEqual({ + cursor: 'envelope-token' + }); + expect(v.parse(listEventsInput, {})).toEqual({}); + }); + + it('rejects a mistyped cursor/q rather than coercing it', () => { + expect(() => v.parse(listEventsInput, { cursor: 123 })).toThrow(); + expect(() => v.parse(listEventsInput, { q: ['jazz'] })).toThrow(); + }); +}); diff --git a/apps/web/src/lib/past-events.test.ts b/apps/web/src/lib/past-events.test.ts new file mode 100644 index 0000000..438e523 --- /dev/null +++ b/apps/web/src/lib/past-events.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { hasEnded } from './past-events'; + +// hasEnded is the shared "is this event over?" predicate the past-events route +// load and its load-more resumer both apply. These pin the two edges that the +// startsAt/endsAt fallback and the strict comparison decide, so neither side can +// quietly re-interpret "past". +const ASOF = '2026-06-01T12:00:00.000Z'; + +describe('hasEnded', () => { + it('is over once an event with no endsAt has started', () => { + expect(hasEnded({ startsAt: '2026-01-01T00:00:00Z', endsAt: undefined }, ASOF)).toBe(true); + }); + + it('is not over when an event with no endsAt has not started yet', () => { + expect(hasEnded({ startsAt: '2026-12-01T00:00:00Z', endsAt: undefined }, ASOF)).toBe(false); + }); + + it('uses endsAt, not startsAt: a started-but-still-running event is not over', () => { + expect( + hasEnded({ startsAt: '2026-01-01T00:00:00Z', endsAt: '2026-12-01T00:00:00Z' }, ASOF) + ).toBe(false); + }); + + it('is over when endsAt is in the past', () => { + expect( + hasEnded({ startsAt: '2026-01-01T00:00:00Z', endsAt: '2026-03-01T00:00:00Z' }, ASOF) + ).toBe(true); + }); + + it('is NOT over at the exact instant it ends — strict <, so the past/upcoming partition never overlaps', () => { + expect(hasEnded({ startsAt: '2026-01-01T00:00:00Z', endsAt: ASOF }, ASOF)).toBe(false); + }); +}); diff --git a/apps/web/src/routes/(app)/events/page.server.test.ts b/apps/web/src/routes/(app)/events/page.server.test.ts new file mode 100644 index 0000000..3ac8aa8 --- /dev/null +++ b/apps/web/src/routes/(app)/events/page.server.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Page 1 of /events comes from this route's load(); page 2 comes from the +// load-more registry's 'events' resumer. They are separate code paths that must +// issue the same discoverable query, so these drive both for real and compare +// what each one emitted. +vi.mock('$lib/contrail', () => ({ + getServerClient: vi.fn(() => ({})), + flattenEventRecords: vi.fn((records: unknown[]) => records), + listDiscoverableEventsFromContrail: vi.fn(), + listAuthoredEventsFromContrail: vi.fn() +})); +// events-load-more.ts imports the search module at load time; the events route +// never hits it, so a null-backend stub keeps the import deterministic. +vi.mock('$lib/search/server/query', () => ({ + searchBackendFromEnv: vi.fn(() => null), + runEventSearchPage: vi.fn() +})); + +import { load } from './+page.server'; +import { runLoadMoreEvents } from '$lib/contrail/events-load-more'; +import { listDiscoverableEventsFromContrail } from '$lib/contrail'; +import { decodeCursor, encodeCursor } from '$lib/contrail/cursor'; +import { FROZEN_NOW, expectSameQuery } from '$lib/contrail/continuity.test-utils'; + +const mockDiscoverable = vi.mocked(listDiscoverableEventsFromContrail); + +const env = { DB: {} } as unknown as App.Platform['env']; + +function event(opts?: { filter?: string; cursor?: string }) { + const url = new URL('https://atmo.test/events'); + if (opts?.filter) url.searchParams.set('filter', opts.filter); + if (opts?.cursor) url.searchParams.set('cursor', opts.cursor); + return { url, platform: { env: { DB: {} } } } as unknown as Parameters[0]; +} + +const page = (cursor: string | null) => + ({ + records: [{ uri: 'at://did:plc:a/community.lexicon.calendar.event/1' }], + profiles: [{ did: 'did:plc:a', handle: 'alice' }], + cursor + }) as unknown as Awaited>; + +type LoadResult = { events: unknown[]; handles: Record; cursor: string | null }; +const runLoad = async (opts?: { filter?: string; cursor?: string }) => + (await load(event(opts))) as LoadResult; + +beforeEach(() => vi.useFakeTimers({ toFake: ['Date'], now: FROZEN_NOW })); +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe('events page load ↔ resumer continuity', () => { + it('page-1 load and the events resumer issue the same query (popular)', async () => { + mockDiscoverable.mockResolvedValue(page('keyset-p1')); + const result = await runLoad(); // no filter => popular + const p1 = mockDiscoverable.mock.calls[0][1]; + + // The emitted envelope names the same server-side query + scope, carrying the + // fresh keyset for the resumer to thread back in. + expect(decodeCursor(result.cursor)).toEqual({ + v: 1, + q: 'events', + args: { popular: true }, + raw: 'keyset-p1' + }); + + await runLoadMoreEvents(env, { cursor: result.cursor! }); + const p2 = mockDiscoverable.mock.calls[1][1]; + + expectSameQuery(p1, p2, 'keyset-p1'); + }); + + it('page-1 load and the resumer both drop rsvpsCountMin for the all/non-popular filter', async () => { + mockDiscoverable.mockResolvedValue(page('keyset-all')); + const result = await runLoad({ filter: 'all' }); + const p1 = mockDiscoverable.mock.calls[0][1]; + expect(p1).not.toHaveProperty('rsvpsCountMin'); + expect(decodeCursor(result.cursor)).toEqual({ + v: 1, + q: 'events', + args: { popular: false }, + raw: 'keyset-all' + }); + + await runLoadMoreEvents(env, { cursor: result.cursor! }); + const p2 = mockDiscoverable.mock.calls[1][1]; + expect(p2).not.toHaveProperty('rsvpsCountMin'); + + expectSameQuery(p1, p2, 'keyset-all'); + }); +}); + +describe('events deep-link ?cursor= guard', () => { + it('resumes an events envelope minted for the SAME popular filter', async () => { + mockDiscoverable.mockResolvedValue(page(null)); + const inbound = encodeCursor({ v: 1, q: 'events', args: { popular: true }, raw: 'p2keyset' }); + await runLoad({ cursor: inbound }); // default load is popular + expect(mockDiscoverable.mock.calls[0][1].cursor).toBe('p2keyset'); + }); + + it('ignores an events envelope minted for a DIFFERENT filter (same q, diff args => fresh page 1)', async () => { + mockDiscoverable.mockResolvedValue(page(null)); + const otherArgs = encodeCursor({ v: 1, q: 'events', args: { popular: false }, raw: 'nope' }); + await runLoad({ cursor: otherArgs }); // default popular:true vs envelope popular:false + expect(mockDiscoverable.mock.calls[0][1].cursor).toBeUndefined(); + }); + + it('ignores a foreign-query envelope (fresh page 1)', async () => { + mockDiscoverable.mockResolvedValue(page(null)); + const foreign = encodeCursor({ + v: 1, + q: 'hosting', + args: { actor: 'did:plc:alice' }, + raw: 'nope' + }); + await runLoad({ cursor: foreign }); + expect(mockDiscoverable.mock.calls[0][1].cursor).toBeUndefined(); + }); +}); diff --git a/apps/web/src/routes/(app)/p/[actor]/hosting/page.server.test.ts b/apps/web/src/routes/(app)/p/[actor]/hosting/page.server.test.ts new file mode 100644 index 0000000..eec5d2e --- /dev/null +++ b/apps/web/src/routes/(app)/p/[actor]/hosting/page.server.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Page 1 of /p/[actor]/hosting comes from this route's load(); page 2 comes from +// the load-more registry's 'hosting' resumer. They are separate code paths that +// must issue the same authored + upcoming query, scoped to the same actor, so +// these drive both for real and compare what each one emitted. +vi.mock('$lib/actor', () => ({ + getActor: vi.fn(async () => 'did:plc:alice') +})); +vi.mock('$lib/contrail', () => ({ + getServerClient: vi.fn(() => ({})), + flattenEventRecords: vi.fn((records: unknown[]) => records), + getProfileFromContrail: vi.fn(async () => ({})), + listAuthoredEventsFromContrail: vi.fn(), + listDiscoverableEventsFromContrail: vi.fn() +})); +vi.mock('$lib/search/server/query', () => ({ + searchBackendFromEnv: vi.fn(() => null), + runEventSearchPage: vi.fn() +})); + +import { load } from './+page.server'; +import { runLoadMoreEvents } from '$lib/contrail/events-load-more'; +import { listAuthoredEventsFromContrail } from '$lib/contrail'; +import { decodeCursor, encodeCursor } from '$lib/contrail/cursor'; +import { FROZEN_NOW, expectSameQuery } from '$lib/contrail/continuity.test-utils'; + +const mockAuthored = vi.mocked(listAuthoredEventsFromContrail); + +const ACTOR = 'did:plc:alice'; +const env = { DB: {} } as unknown as App.Platform['env']; + +function event(actor: string, cursor?: string) { + const url = new URL(`https://atmo.test/p/${actor}/hosting`); + if (cursor) url.searchParams.set('cursor', cursor); + return { + params: { actor }, + url, + platform: { env: { DB: {} } } + } as unknown as Parameters[0]; +} + +const page = (cursor: string | null) => + ({ + records: [{ uri: 'at://did:plc:alice/community.lexicon.calendar.event/1' }], + profiles: [{ did: ACTOR, handle: 'alice' }], + cursor + }) as unknown as Awaited>; + +type LoadResult = { events: unknown[]; cursor: string | null }; +const runLoad = async (actor: string, cursor?: string) => + (await load(event(actor, cursor))) as LoadResult; + +beforeEach(() => vi.useFakeTimers({ toFake: ['Date'], now: FROZEN_NOW })); +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe('hosting page load ↔ resumer continuity', () => { + it('page-1 load and the hosting resumer issue the same query', async () => { + mockAuthored.mockResolvedValue(page('keyset-p1')); + const result = await runLoad(ACTOR); + const p1 = mockAuthored.mock.calls[0][1]; + + expect(decodeCursor(result.cursor)).toEqual({ + v: 1, + q: 'hosting', + args: { actor: ACTOR }, + raw: 'keyset-p1' + }); + + await runLoadMoreEvents(env, { cursor: result.cursor! }); + const p2 = mockAuthored.mock.calls[1][1]; + + expectSameQuery(p1, p2, 'keyset-p1'); + }); +}); + +describe('hosting deep-link ?cursor= guard', () => { + it('resumes a hosting envelope minted for THIS actor', async () => { + mockAuthored.mockResolvedValue(page(null)); + const inbound = encodeCursor({ v: 1, q: 'hosting', args: { actor: ACTOR }, raw: 'p2keyset' }); + await runLoad(ACTOR, inbound); + expect(mockAuthored.mock.calls[0][1].cursor).toBe('p2keyset'); + }); + + it('ignores a hosting envelope minted for a DIFFERENT actor (same q, diff args => fresh page 1)', async () => { + mockAuthored.mockResolvedValue(page(null)); + const otherActor = encodeCursor({ + v: 1, + q: 'hosting', + args: { actor: 'did:plc:bob' }, + raw: 'nope' + }); + await runLoad(ACTOR, otherActor); + expect(mockAuthored.mock.calls[0][1].cursor).toBeUndefined(); + }); + + it('ignores a foreign-query (past-events) envelope even for the same actor (fresh page 1)', async () => { + mockAuthored.mockResolvedValue(page(null)); + const foreign = encodeCursor({ v: 1, q: 'past-events', args: { actor: ACTOR }, raw: 'nope' }); + await runLoad(ACTOR, foreign); + expect(mockAuthored.mock.calls[0][1].cursor).toBeUndefined(); + }); +}); diff --git a/apps/web/src/routes/(app)/p/[actor]/past-events/page.server.test.ts b/apps/web/src/routes/(app)/p/[actor]/past-events/page.server.test.ts new file mode 100644 index 0000000..5d32006 --- /dev/null +++ b/apps/web/src/routes/(app)/p/[actor]/past-events/page.server.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Page 1 of /p/[actor]/past-events comes from this route's load(); page 2 comes +// from the load-more registry's 'past-events' resumer. They are separate code +// paths that must issue the same authored + past query — desc order under a +// startsAtMax upper bound, not startsAtMin — so these drive both for real and +// compare what each one emitted. +vi.mock('$lib/actor', () => ({ + getActor: vi.fn(async () => 'did:plc:alice') +})); +vi.mock('$lib/contrail', () => ({ + getServerClient: vi.fn(() => ({})), + flattenEventRecords: vi.fn((records: unknown[]) => records), + getProfileFromContrail: vi.fn(async () => ({})), + listAuthoredEventsFromContrail: vi.fn(), + listDiscoverableEventsFromContrail: vi.fn() +})); +vi.mock('$lib/search/server/query', () => ({ + searchBackendFromEnv: vi.fn(() => null), + runEventSearchPage: vi.fn() +})); + +import { load } from './+page.server'; +import { runLoadMoreEvents } from '$lib/contrail/events-load-more'; +import { listAuthoredEventsFromContrail } from '$lib/contrail'; +import { decodeCursor, encodeCursor } from '$lib/contrail/cursor'; +import { FROZEN_NOW, expectSameQuery } from '$lib/contrail/continuity.test-utils'; + +const mockAuthored = vi.mocked(listAuthoredEventsFromContrail); + +const ACTOR = 'did:plc:alice'; +const env = { DB: {} } as unknown as App.Platform['env']; + +function event(actor: string, cursor?: string) { + const url = new URL(`https://atmo.test/p/${actor}/past-events`); + if (cursor) url.searchParams.set('cursor', cursor); + return { + params: { actor }, + url, + platform: { env: { DB: {} } } + } as unknown as Parameters[0]; +} + +// Genuinely over before the frozen clock. +const ENDED = { + uri: 'at://did:plc:alice/community.lexicon.calendar.event/1', + startsAt: '2000-01-01T00:00:00Z' +}; +// Began before the frozen clock but has NOT finished. The startsAtMax bound +// admits it, so both sides have to exclude it themselves. +const ONGOING = { + uri: 'at://did:plc:alice/community.lexicon.calendar.event/ongoing', + startsAt: '2000-01-01T00:00:00Z', + endsAt: '2030-01-01T00:00:00Z' +}; + +const page = (cursor: string | null, records: unknown[] = [ENDED]) => + ({ + records, + profiles: [{ did: ACTOR, handle: 'alice' }], + cursor + }) as unknown as Awaited>; + +type LoadResult = { events: unknown[]; cursor: string | null }; +const runLoad = async (actor: string, cursor?: string) => + (await load(event(actor, cursor))) as LoadResult; + +beforeEach(() => vi.useFakeTimers({ toFake: ['Date'], now: FROZEN_NOW })); +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe('past-events page load ↔ resumer continuity', () => { + it('page-1 load and the past-events resumer issue the same query (desc, startsAtMax)', async () => { + mockAuthored.mockResolvedValue(page('keyset-p1')); + const result = await runLoad(ACTOR); + const p1 = mockAuthored.mock.calls[0][1]; + // The past query is bounded ABOVE by now, not below — the one route where + // swapping the bound would still look plausible. + expect(typeof p1.startsAtMax).toBe('string'); + expect(p1.startsAtMin).toBeUndefined(); + expect(p1.order).toBe('desc'); + + expect(decodeCursor(result.cursor)).toEqual({ + v: 1, + q: 'past-events', + args: { actor: ACTOR }, + raw: 'keyset-p1' + }); + + await runLoadMoreEvents(env, { cursor: result.cursor! }); + const p2 = mockAuthored.mock.calls[1][1]; + + expectSameQuery(p1, p2, 'keyset-p1'); + }); + + // Matching query bags are not enough here: this route also narrows the + // RESULT after the query returns. If only page 1 did that, an event that is + // still running would be filtered off page 1 and then resurface on page 2 of + // "past events". + it('page-1 load and the resumer both exclude an event that is still running', async () => { + mockAuthored.mockResolvedValue(page('keyset-p1', [ONGOING, ENDED])); + + const result = await runLoad(ACTOR); + expect(result.events).toEqual([ENDED]); + + const page2 = await runLoadMoreEvents(env, { cursor: result.cursor! }); + expect(page2.events).toEqual([ENDED]); + }); +}); + +describe('past-events deep-link ?cursor= guard', () => { + it('resumes a past-events envelope minted for THIS actor', async () => { + mockAuthored.mockResolvedValue(page(null)); + const inbound = encodeCursor({ + v: 1, + q: 'past-events', + args: { actor: ACTOR }, + raw: 'p2keyset' + }); + await runLoad(ACTOR, inbound); + expect(mockAuthored.mock.calls[0][1].cursor).toBe('p2keyset'); + }); + + it('ignores a past-events envelope minted for a DIFFERENT actor (same q, diff args => fresh page 1)', async () => { + mockAuthored.mockResolvedValue(page(null)); + const otherActor = encodeCursor({ + v: 1, + q: 'past-events', + args: { actor: 'did:plc:bob' }, + raw: 'nope' + }); + await runLoad(ACTOR, otherActor); + expect(mockAuthored.mock.calls[0][1].cursor).toBeUndefined(); + }); + + it('ignores a foreign-query (hosting) envelope even for the same actor (fresh page 1)', async () => { + mockAuthored.mockResolvedValue(page(null)); + const foreign = encodeCursor({ v: 1, q: 'hosting', args: { actor: ACTOR }, raw: 'nope' }); + await runLoad(ACTOR, foreign); + expect(mockAuthored.mock.calls[0][1].cursor).toBeUndefined(); + }); +}); diff --git a/apps/web/src/routes/(app)/search/page.server.test.ts b/apps/web/src/routes/(app)/search/page.server.test.ts index 4062a03..2a10ecc 100644 --- a/apps/web/src/routes/(app)/search/page.server.test.ts +++ b/apps/web/src/routes/(app)/search/page.server.test.ts @@ -1,14 +1,11 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -// The search load decides between two backends whose cursors are NOT +// The search load picks between two backends whose cursors are NOT // interchangeable: Meilisearch (offset cursor) and the D1 LIKE fallback (opaque -// keyset). Both now hand back a self-describing continuation ENVELOPE: the Meili -// path a 'search-meili' envelope, the D1 fallback a 'search-d1' envelope. The D1 -// fallback used to return cursor:null because load-more had no safe way to -// re-run the discoverable+startsAtMin query — the envelope closes that gap (and -// with it the earlier "search results stop after the first batch" limitation), -// so these tests now pin a REAL cursor on the D1 path, keyed to a query the -// load-more registry re-runs identically. +// keyset). Each hands back a self-describing continuation envelope naming its +// own query, so load-more resumes on the backend that produced the page. These +// pin the page-1 behaviour, the deep-link guard, and — for both backends — that +// page 1 and its resumer issue the same query. vi.mock('$lib/contrail', () => ({ getServerClient: vi.fn(() => ({})), flattenEventRecords: vi.fn((records: unknown[]) => records), @@ -20,14 +17,18 @@ vi.mock('$lib/search/server/query', () => ({ })); import { load } from './+page.server'; +import { runLoadMoreEvents } from '$lib/contrail/events-load-more'; import { listDiscoverableEventsFromContrail } from '$lib/contrail'; import { runEventSearchPage, searchBackendFromEnv } from '$lib/search/server/query'; import { decodeCursor, encodeCursor } from '$lib/contrail/cursor'; +import { FROZEN_NOW, expectSameQuery } from '$lib/contrail/continuity.test-utils'; const mockSearchBackendFromEnv = vi.mocked(searchBackendFromEnv); const mockRunEventSearchPage = vi.mocked(runEventSearchPage); const mockListDiscoverable = vi.mocked(listDiscoverableEventsFromContrail); +const env = { DB: {} } as unknown as App.Platform['env']; + type LoadResult = { events: unknown[]; handles: Record; @@ -46,7 +47,11 @@ function event(q: string, cursor?: string) { return { url, platform: { env: {} } } as unknown as Parameters[0]; } -afterEach(() => vi.clearAllMocks()); +beforeEach(() => vi.useFakeTimers({ toFake: ['Date'], now: FROZEN_NOW })); +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); describe('search page load', () => { it('returns early for an empty query without touching any backend', async () => { @@ -153,3 +158,60 @@ describe('search page load', () => { expect(mockListDiscoverable.mock.calls[0][1].cursor).toBeUndefined(); }); }); + +// Unlike the other routes the search term rides ?q=/input, NOT the envelope, so +// the resumer must be HANDED the term to reproduce page 1. These pin that once +// it is, the page-1 load and the resumer issue the same query — on whichever of +// the two backends served page 1. +describe('search page load ↔ resumer continuity', () => { + it('page-1 D1 fallback and the search-d1 resumer issue the same query', async () => { + mockSearchBackendFromEnv.mockReturnValue(null); + mockListDiscoverable.mockResolvedValue({ + records: [{ uri: 'at://did:plc:a/community.lexicon.calendar.event/1' }], + profiles: [{ did: 'did:plc:a', handle: 'alice' }], + cursor: 'keyset-p1' + } as unknown as Awaited>); + + const result = await runLoad('kite'); + const p1 = mockListDiscoverable.mock.calls[0][1]; + expect(decodeCursor(result.cursor)).toEqual({ v: 1, q: 'search-d1', raw: 'keyset-p1' }); + + // The term is absent from the envelope; the client re-supplies it as `q`. + await runLoadMoreEvents(env, { cursor: result.cursor!, q: 'kite' }); + const p2 = mockListDiscoverable.mock.calls[1][1]; + + expectSameQuery(p1, p2, 'keyset-p1'); + }); + + it('page-1 Meili and the search-meili resumer issue the same query, threading the raw offset', async () => { + mockSearchBackendFromEnv.mockReturnValue({ url: 'https://meili.test', apiKey: 'k' }); + mockRunEventSearchPage.mockResolvedValue({ + events: [{ uri: 'at://did:plc:a/community.lexicon.calendar.event/1' }], + handles: { 'did:plc:a': 'alice' }, + cursor: 'meili:20', + distances: {} + } as unknown as Awaited>); + + const result = await runLoad('kite'); + expect(decodeCursor(result.cursor)).toEqual({ v: 1, q: 'search-meili', raw: 'meili:20' }); + + await runLoadMoreEvents(env, { cursor: result.cursor!, q: 'kite' }); + + // Compare the whole options object both sides passed, not just `q` — an + // option added to the page-1 call alone (a filter, a page size, a locale) + // would search a different result set on page 2. + const p1 = mockRunEventSearchPage.mock.calls[0][2]; + const p2 = mockRunEventSearchPage.mock.calls[1][2]; + expectSameQuery(p1, p2, 'meili:20'); + + // The backend is part of the query's identity too — it carries the index + // being searched, so an offset from one index is meaningless against + // another. This is outside the options bag, so it needs its own pin. + expect(mockRunEventSearchPage.mock.calls[1][0]).toEqual( + mockRunEventSearchPage.mock.calls[0][0] + ); + + expect(p1.q).toBe('kite'); + expect(mockListDiscoverable).not.toHaveBeenCalled(); // never the D1 path + }); +}); diff --git a/apps/web/src/routes/(app)/topics/[slug]/page.server.test.ts b/apps/web/src/routes/(app)/topics/[slug]/page.server.test.ts index f3c8b9e..1ccc20a 100644 --- a/apps/web/src/routes/(app)/topics/[slug]/page.server.test.ts +++ b/apps/web/src/routes/(app)/topics/[slug]/page.server.test.ts @@ -1,23 +1,34 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -// The topic page used to return cursor:null (first-batch-only) because load-more -// had no safe way to re-run its discoverable + OR-search + startsAtMin query. -// The envelope closes that gap: the load now emits a self-describing 'topic' -// envelope carrying the slug, and the load-more registry re-derives the SAME -// OR-search from that slug server-side. These pin the page-1 side of that -// continuity plus the deep-link query-match rule. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Page 1 of /topics/[slug] comes from this route's load(); page 2 comes from the +// load-more registry's 'topic' resumer. Both derive the OR-search from the slug +// server-side, and both must issue the same discoverable query, so these drive +// both for real and compare what each one emitted — plus the deep-link guard. vi.mock('$lib/contrail', () => ({ getServerClient: vi.fn(() => ({})), flattenEventRecords: vi.fn((records: unknown[]) => records), - listDiscoverableEventsFromContrail: vi.fn() + listDiscoverableEventsFromContrail: vi.fn(), + listAuthoredEventsFromContrail: vi.fn() +})); +// events-load-more.ts imports the search module at load time; the topic query +// never hits it, so a null-backend stub keeps the import deterministic. $lib/topics +// is intentionally left REAL so orQueryFromSlug derives the same OR-search on both +// the page-1 and resumer sides. +vi.mock('$lib/search/server/query', () => ({ + searchBackendFromEnv: vi.fn(() => null), + runEventSearchPage: vi.fn() })); import { load } from './+page.server'; +import { runLoadMoreEvents } from '$lib/contrail/events-load-more'; import { listDiscoverableEventsFromContrail } from '$lib/contrail'; import { decodeCursor, encodeCursor } from '$lib/contrail/cursor'; +import { FROZEN_NOW, expectSameQuery } from '$lib/contrail/continuity.test-utils'; const mockListDiscoverable = vi.mocked(listDiscoverableEventsFromContrail); +const env = { DB: {} } as unknown as App.Platform['env']; + type LoadResult = { topic: { slug: string }; events: unknown[]; @@ -35,7 +46,11 @@ function event(slug: string, cursor?: string) { const run = async (slug: string, cursor?: string) => (await load(event(slug, cursor))) as unknown as LoadResult; -afterEach(() => vi.clearAllMocks()); +beforeEach(() => vi.useFakeTimers({ toFake: ['Date'], now: FROZEN_NOW })); +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); describe('topic page load', () => { it("builds a 'topic' envelope carrying the slug, deriving the OR-search server-side", async () => { @@ -80,7 +95,12 @@ describe('topic page load', () => { cursor: null } as unknown as Awaited>); - const inbound = encodeCursor({ v: 1, q: 'topic', args: { slug: 'technology' }, raw: 'p2keyset' }); + const inbound = encodeCursor({ + v: 1, + q: 'topic', + args: { slug: 'technology' }, + raw: 'p2keyset' + }); await run('technology', inbound); expect(mockListDiscoverable.mock.calls[0][1]).toMatchObject({ cursor: 'p2keyset' }); @@ -99,7 +119,7 @@ describe('topic page load', () => { expect(mockListDiscoverable.mock.calls[0][1].cursor).toBeUndefined(); }); - it("deep-link: ignores a topic envelope minted for a DIFFERENT slug (fresh page 1)", async () => { + it('deep-link: ignores a topic envelope minted for a DIFFERENT slug (fresh page 1)', async () => { mockListDiscoverable.mockResolvedValue({ records: [], profiles: [], @@ -119,3 +139,32 @@ describe('topic page load', () => { expect(mockListDiscoverable).not.toHaveBeenCalled(); }); }); + +describe('topic page load ↔ resumer continuity', () => { + it('page-1 load and the topic resumer issue the same query (same OR-search)', async () => { + mockListDiscoverable.mockResolvedValue({ + records: [{ uri: 'at://did:plc:a/community.lexicon.calendar.event/1' }], + profiles: [{ did: 'did:plc:a', handle: 'alice' }], + cursor: 'keyset-p1' + } as unknown as Awaited>); + + const result = await run('technology'); + const p1 = mockListDiscoverable.mock.calls[0][1]; + // Page 1 derives the OR-search server-side from the slug. + expect(p1.search).toBe('tech OR technology'); + expect(decodeCursor(result.cursor)).toEqual({ + v: 1, + q: 'topic', + args: { slug: 'technology' }, + raw: 'keyset-p1' + }); + + await runLoadMoreEvents(env, { cursor: result.cursor! }); + const p2 = mockListDiscoverable.mock.calls[1][1]; + + // The comparison covers `search`, so it also pins that the resumer re-derived + // the SAME 'tech OR technology' from the slug rather than trusting a client + // value or drifting to another spelling of the query. + expectSameQuery(p1, p2, 'keyset-p1'); + }); +}); -- 2.51.2 From 4fa2d0b2b5e4b43c9991b80e45d9020c5cd5595b Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Sat, 25 Jul 2026 08:21:24 -0400 Subject: [PATCH 4/5] refactor(pagination): define each list query once, call it from both pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Page 1 and load-more are separate server entry points by necessity: a route load() that has url/params, and a remote command that has only an opaque cursor. Each hand-wrote the same filter bag, and a keyset cursor is specific to the query that produced it — so page 2 is adjacent to page 1 only while every filter, sort, bound and limit agrees between two call sites nothing forces to agree. Define each list once in contrail/queries.ts and have both entry points call it. Sort, order, limit, time bounds, any post-filter and the next-page envelope now live in one place. The resumer registry keeps its real job — validating decoded args at the trust boundary and naming which query to continue — and no longer restates the query itself. Two consequences worth naming. The past-events narrowing (its upper time bound admits an event that is still running) moves inside the query, so every page inherits it rather than applying it twice. And because that narrowing runs after pagination, a page can come back short or even empty while a cursor remains — so the "load more" affordance keys on the cursor, not on the page having events. That rule was previously implicit in one Svelte guard; it is now stated where callers will find it. Search keeps its page-1-only Meili->D1 fallback: a continuation must not switch backends, or it restarts page 1 with a keyset the other backend cannot read. Behaviour-preserving — the page-1/load-more continuity tests pass through it unchanged. README's pagination section is updated to match and trimmed to the model a reader needs. --- README.md | 12 +- apps/web/src/lib/contrail/events-load-more.ts | 198 +++++------------ apps/web/src/lib/contrail/queries.ts | 210 ++++++++++++++++++ .../src/routes/(app)/events/+page.server.ts | 39 +--- .../(app)/p/[actor]/hosting/+page.server.ts | 31 +-- .../p/[actor]/past-events/+page.server.ts | 41 +--- .../src/routes/(app)/search/+page.server.ts | 59 +---- .../(app)/topics/[slug]/+page.server.ts | 50 +---- 8 files changed, 313 insertions(+), 327 deletions(-) create mode 100644 apps/web/src/lib/contrail/queries.ts diff --git a/README.md b/README.md index cd8b790..6c20998 100644 --- a/README.md +++ b/README.md @@ -75,17 +75,15 @@ Many events carry only a street address, no coordinates — so they never surfac ## Load-more pagination -Every paginated list — the home events feed, a profile's hosting and past events, a topic, and search — shares one continuation mechanism, so "load more" always resumes the same query on the same backend that produced page 1. +Every paginated list — the home feed, a profile's hosting and past events, a topic, and search — is defined once in `queries.ts` and continued by one shared mechanism. -**Why a self-describing token.** Load-more used to have the client echo back the page-1 query parameters and let the server re-derive which backend to use from the request shape ("is a search term set, and is Meilisearch configured?"). That inference broke whenever a page's first load and its load-more resolved to different backends. A D1 keyset fed to Meilisearch became `Number(base64url)`, which is `NaN`, which collapses to offset 0 — a relevance-reordered duplicate of page 1. A Meilisearch offset fed to D1 was ignored, silently dropping the upcoming and discoverable filters page 1 had applied. +**One definition, two entry points.** Page 1 runs in a route's `load()`, which has `url`/`params`; load-more runs in a remote command, which has only a cursor. A keyset is specific to the query that produced it, so page 2 is adjacent to page 1 only while every filter, sort, bound and limit agrees. Both entry points therefore call the same definition, which also owns any post-filter and mints the envelope that continues it. -**The envelope.** The continuation cursor is now an opaque, self-contained token: `base64url(JSON { v, q, args?, raw })`. `q` names the server-side query (`events`, `hosting`, `past-events`, `topic`, `search-d1`, or `search-meili`). `args` carries only public-safe scope choices (a profile actor, a topic slug, the "popular" toggle). `raw` is the opaque backend-native cursor to resume from. The client treats the whole token as a blob and echoes it back unchanged. Load-more looks `q` up in a registry of resumers (`events-load-more.ts`) and re-runs that query with server-authoritative filter values; the client supplies neither the pipeline nor any filter. +**The envelope.** The continuation cursor is an opaque `base64url(JSON { v, q, args?, raw })`. `q` names the server-side query (`events`, `hosting`, `past-events`, `topic`, `search-d1`, `search-meili`); `args` carries only public-safe scope (profile actor, topic slug, popular toggle); `raw` is the backend-native cursor — a D1 keyset, or a Meilisearch offset that `tagCursor` prefixes as `meili:`. The client echoes the whole token back unchanged and supplies no filters of its own. Naming the query is what keeps a cursor with its backend: read a D1 keyset as a Meili offset and `Number(base64url)` is `NaN`, which collapses to offset 0 — page 1 again, silently. -**Why that's safe.** Because every filter value lives server-side in the registry entry, a tampered token cannot widen what it sees — it can only name another already-public query or fail to decode. The unlisted-inclusive plain `listRecords` pipeline deliberately has no registry entry, so no cursor can reach it. `decodeCursor` never throws and returns null on anything malformed, so load-more simply ends pagination cleanly. A deep-linked `?cursor=` only resumes when the envelope was minted for the *same* query — same `q` **and** the same public-safe scope (topic slug, profile actor, or popular/all toggle); a keyset is specific to its result set, so a `technology` topic cursor, another actor's keyset, or a `popular` cursor under `?filter=all` all start a fresh page 1 rather than resuming a foreign position. Search (`search-d1`/`search-meili`) deep-links never resume: their defining term rides `?q=`, not the envelope, so an inbound cursor can't be proven to match the route's term. +**Why that's safe.** Every filter value lives in the query definition, so a tampered token can only name another already-public query or fail to decode; the unlisted-inclusive plain `listRecords` pipeline has no registry entry, so no cursor reaches it. `decodeCursor` never throws — anything malformed, including a pre-envelope `meili:`/`d1:` cursor, just ends pagination. A deep-linked `?cursor=` resumes only when the envelope was minted for the same `q` **and** the same scope, since a `/topics/ai` keyset indexes a different result set than a `/topics/technology` one. Search never resumes a deep link at all: its term rides `?q=`, not the envelope, so an inbound cursor can't be proven to match the route's term. -**Backend-native cursors.** `raw` stays opaque and backend-specific: a D1 keyset built inside `@atmo-dev/contrail`, or a Meilisearch offset that `tagCursor` prefixes as `meili:`. `tagCursor` and `parseCursor` in `cursor.ts` are that Meilisearch offset codec, also used by near-me; the envelope simply wraps whatever they produce. Cursors minted before the envelope existed (top-level `meili:` or `d1:` tags, or bare offsets) are tolerated as a deploy-window courtesy — they fail to decode and end pagination cleanly rather than resuming incorrectly — and that tolerance can be dropped once such cursors have drained. - -**The pieces.** `cursor.ts` handles envelope encode/decode and backend tagging. `events-load-more.ts` holds the resumer registry and the shared load-more handler. Each route's `+page.server.ts` mints the page-1 envelope from its own filters, and `EventList.svelte` echoes the token on "load more". Adding a query or backend means registering a resumer, not editing a branch. +**The pieces.** `queries.ts` defines each list and mints its envelope. `cursor.ts` encodes/decodes envelopes and tags backend cursors. `events-load-more.ts` holds the resumer registry: it validates a decoded envelope's args and names which query to continue. Each route's `+page.server.ts` calls its query for page 1 and adds whatever else that page renders; `EventList.svelte` echoes the token on "load more" — keyed on the cursor, not on the page having events, since a query with a post-filter (`past-events`) can return a short or empty page while more pages remain. Adding a list means defining its query and registering it. ## contributing diff --git a/apps/web/src/lib/contrail/events-load-more.ts b/apps/web/src/lib/contrail/events-load-more.ts index 3cd24a6..864c64a 100644 --- a/apps/web/src/lib/contrail/events-load-more.ts +++ b/apps/web/src/lib/contrail/events-load-more.ts @@ -1,28 +1,24 @@ import * as v from 'valibot'; import type { Client } from '@atcute/client'; -import type { ActorIdentifier } from '@atcute/lexicons'; import { isActorIdentifier } from '@atcute/lexicons/syntax'; import { getServerClient } from './index'; import { - flattenEventRecords, - listAuthoredEventsFromContrail, - listDiscoverableEventsFromContrail -} from '$lib/contrail'; -import { runEventSearchPage, searchBackendFromEnv } from '$lib/search/server/query'; -import { SEARCH_PAGE_SIZE } from '$lib/search/constants'; -import { orQueryFromSlug } from '$lib/topics'; -import { hasEnded } from '$lib/past-events'; -import { decodeCursor, nextCursor, type CursorArgs, type CursorEnvelope, type CursorQuery } from './cursor'; - -const PAGE_SIZE = 20; - -// The load-more remote input. The continuation cursor is now a self-describing -// ENVELOPE carrying the server-side query name + public-safe args, so the client -// no longer echoes a query-reconstruction bag of pipeline/filters. Only two -// fields are read: `cursor` (the envelope) and `q` (the search TERM, which stays -// OUT of the envelope and rides ?q=/input — see the search resumers). A legacy -// client may still POST extra query params; `v.object` drops them, so they are -// accepted WITHOUT being trusted. + EMPTY_PAGE, + eventsQuery, + hostingQuery, + pastEventsQuery, + searchD1Query, + searchMeiliQuery, + topicQuery, + type EventsPage +} from './queries'; +import { decodeCursor, type CursorEnvelope, type CursorQuery } from './cursor'; + +// The load-more remote input. Exactly two fields are read: `cursor`, the opaque +// continuation envelope naming the server-side query and its public-safe args, +// and `q`, the free-text search TERM — which stays OUT of the envelope and rides +// ?q=/input (see the search resumers). A client may POST extra query params; +// `v.object` drops them, so they are accepted WITHOUT being trusted. export const listEventsInput = v.object({ cursor: v.optional(v.string()), /** Free-text search term for the search page; ignored for every other query. */ @@ -31,147 +27,66 @@ export const listEventsInput = v.object({ export type LoadMoreEventsInput = v.InferOutput; -export type LoadMoreEventsResult = { - events: ReturnType; - handles: Record; - cursor: string | null; -}; - -const EMPTY: LoadMoreEventsResult = { events: [], handles: {}, cursor: null }; - -function now(): string { - return new Date().toISOString(); -} +/** Load-more returns the same page shape a route's page-1 `load()` returns. */ +export type LoadMoreEventsResult = EventsPage; -/** - * Shape a contrail list response into a load-more result, re-encoding the next - * page's cursor as a same-query envelope (identical `q`/`args`, the fresh raw - * keyset) so continuations stay on the same server-authoritative query. - */ -function toResult( - q: CursorQuery, - args: CursorArgs | undefined, - response: Awaited> -): LoadMoreEventsResult { - if (!response) return EMPTY; - const events = flattenEventRecords(response.records ?? []); - const handles: Record = {}; - for (const p of response.profiles ?? []) { - if (p.handle) handles[p.did] = p.handle; - } - return { events, handles, cursor: nextCursor(q, response.cursor ?? null, args) }; -} +const EMPTY = EMPTY_PAGE; /** - * A resumer re-runs the page-1 query named by the envelope, from the envelope's - * opaque `raw` keyset, with SERVER-AUTHORITATIVE filter values. It receives the - * decoded envelope (never the raw client bag) plus the free-text search term - * (search queries only). Missing/malformed required args => end cleanly (EMPTY); - * never throw, never fall through to another query. + * A resumer continues the query named by the envelope, from the envelope's + * opaque `raw` keyset. It does not define the query: it validates the decoded + * args at the trust boundary and hands them to the shared query in `queries.ts` + * — the same function the route's page-1 `load()` calls. + * + * Malformed or missing required args end pagination cleanly (EMPTY); a resumer + * never throws and never falls through to another query. */ type Resumer = ( env: App.Platform['env'], client: Client, envelope: CursorEnvelope, searchTerm: string | undefined -) => Promise; +) => Promise; -// The backend->resumer REGISTRY, keyed by the envelope's query name. Adding a -// paginated query is REGISTERING an entry here, not editing a conditional; every -// filter VALUE is server-authoritative and lives in the entry. The plain, -// unlisted-inclusive listRecords pipeline deliberately has NO entry, so no -// decoded envelope can reach it. (See README → "Load-more pagination".) +// The query-name -> resumer REGISTRY. Adding a paginated list means defining its +// query in `queries.ts` and registering it here. The plain, unlisted-inclusive +// listRecords pipeline deliberately has NO entry, so no decoded envelope can +// reach it. (See README → "Load-more pagination".) const REGISTRY: Record = { - events: async (_env, client, { args, raw }) => { - const response = await listDiscoverableEventsFromContrail(client, { - startsAtMin: now(), - profiles: true, - sort: 'startsAt', - order: 'asc', - limit: PAGE_SIZE, - ...(args?.popular ? { rsvpsCountMin: 2 } : {}), - cursor: raw - }); - return toResult('events', args, response); - }, + events: async (_env, client, { args, raw }) => + eventsQuery(client, { popular: args?.popular === true }, raw), hosting: async (_env, client, { args, raw }) => { - if (!args?.actor || !isActorIdentifier(args.actor)) return EMPTY; - const response = await listAuthoredEventsFromContrail(client, { - actor: args.actor as ActorIdentifier, - startsAtMin: now(), - sort: 'startsAt', - order: 'asc', - profiles: true, - limit: PAGE_SIZE, - cursor: raw - }); - return toResult('hosting', args, response); + const actor = args?.actor; + if (!actor || !isActorIdentifier(actor)) return EMPTY; + return hostingQuery(client, { actor }, raw); }, 'past-events': async (_env, client, { args, raw }) => { - if (!args?.actor || !isActorIdentifier(args.actor)) return EMPTY; - const asOf = now(); - const response = await listAuthoredEventsFromContrail(client, { - actor: args.actor as ActorIdentifier, - startsAtMax: asOf, - sort: 'startsAt', - order: 'desc', - profiles: true, - limit: PAGE_SIZE, - cursor: raw - }); - // Page 1 narrows the same way, with the same shared predicate: startsAtMax - // still admits an event that began earlier and is still running, and an - // ongoing event must not be hidden on page 1 only to resurface on page 2. - const result = toResult('past-events', args, response); - return { ...result, events: result.events.filter((e) => hasEnded(e, asOf)) }; + const actor = args?.actor; + if (!actor || !isActorIdentifier(actor)) return EMPTY; + return pastEventsQuery(client, { actor }, raw); }, + // The search text is re-derived SERVER-side from the slug inside topicQuery, + // never taken from the client; an unknown slug ends cleanly. topic: async (_env, client, { args, raw }) => { - // Re-derive the search from the slug SERVER-side (shared helper), never from - // a client-supplied query. Unknown slug => end cleanly. - const search = args?.slug ? orQueryFromSlug(args.slug) : null; - if (!search) return EMPTY; - const response = await listDiscoverableEventsFromContrail(client, { - search, - startsAtMin: now(), - sort: 'startsAt', - order: 'asc', - profiles: true, - limit: PAGE_SIZE, - cursor: raw - }); - return toResult('topic', args, response); + const slug = args?.slug; + if (!slug) return EMPTY; + return topicQuery(client, { slug }, raw); }, - 'search-d1': async (_env, client, { args, raw }, searchTerm) => { - const term = searchTerm?.trim(); - if (!term) return EMPTY; // search term lost from the continuation => end cleanly - const response = await listDiscoverableEventsFromContrail(client, { - search: term, - startsAtMin: now(), - sort: 'startsAt', - order: 'desc', - profiles: true, - limit: SEARCH_PAGE_SIZE, - cursor: raw - }); - return toResult('search-d1', args, response); + // Search is the one query whose defining input is not in the envelope: the + // term rides ?q=/input. Lost term => end cleanly rather than continue an + // unfiltered list. + 'search-d1': async (_env, client, { raw }, searchTerm) => { + if (!searchTerm?.trim()) return EMPTY; + return searchD1Query(client, { term: searchTerm }, raw); }, - 'search-meili': async (env, client, { args, raw }, searchTerm) => { - const term = searchTerm?.trim(); - const backend = term ? searchBackendFromEnv(env) : null; - // Missing search term OR unconfigured backend => end cleanly rather than - // restart page 1 on the wrong backend. - if (!term || !backend) return EMPTY; - const page = await runEventSearchPage(backend, client, { q: term, cursor: raw }); - return { - events: page.events, - handles: page.handles, - cursor: nextCursor('search-meili', page.cursor, args) - }; + 'search-meili': async (env, client, { raw }, searchTerm) => { + if (!searchTerm?.trim()) return EMPTY; + return searchMeiliQuery(env, client, { term: searchTerm }, raw); } }; @@ -181,11 +96,8 @@ const REGISTRY: Record = { * be unit-tested directly (the plugin rejects non-remote exports from * `*.remote.ts`, so a test there can't mock `$app/server`). * - * Decode the envelope, look up its resumer, resume with server-authoritative - * filters, re-encode the next envelope — no per-backend if/else. An undecodable - * or legacy cursor decodes to null and ends pagination cleanly, without - * reconstructing the query from client fields. See README → - * "Load-more pagination". + * Decode the envelope, look up its resumer, continue the shared query. A cursor + * that fails to decode yields null and ends pagination cleanly. */ export async function runLoadMoreEvents( env: App.Platform['env'], diff --git a/apps/web/src/lib/contrail/queries.ts b/apps/web/src/lib/contrail/queries.ts new file mode 100644 index 0000000..e63333c --- /dev/null +++ b/apps/web/src/lib/contrail/queries.ts @@ -0,0 +1,210 @@ +// The paginated list queries — ONE definition per list, called by BOTH the +// route's page-1 `load()` and the load-more resumer. +// +// Page 1 and load-more are separate server entry points by necessity: an SSR +// `load()` that has `url`/`params`, and a remote command that has only an opaque +// cursor. A keyset cursor is specific to the query that produced it, so page 2 +// is adjacent to page 1 only while every filter, sort, bound and limit agrees. +// One definition called from both entry points makes that agreement structural, +// rather than a property two call sites have to keep in step. +// +// Each query owns its filter values, any post-filter, and the envelope that +// continues it. Args arrive already validated — the trust boundary is +// `decodeCursor` for load-more and route `params` for page 1 — so a query +// receives values, never client input. See README → "Load-more pagination". + +import type { Client } from '@atcute/client'; +import type { ActorIdentifier } from '@atcute/lexicons'; +import { + flattenEventRecords, + listAuthoredEventsFromContrail, + listDiscoverableEventsFromContrail +} from '$lib/contrail'; +import { runEventSearchPage, searchBackendFromEnv } from '$lib/search/server/query'; +import { SEARCH_PAGE_SIZE } from '$lib/search/constants'; +import { orQueryFromSlug } from '$lib/topics'; +import { hasEnded } from '$lib/past-events'; +import { nextCursor, type CursorArgs, type CursorQuery } from './cursor'; + +export const PAGE_SIZE = 20; + +/** + * One page of events plus the token that continues it. Page 1 and load-more + * return the SAME shape — routes spread it and add their own page data. + */ +export type EventsPage = { + events: ReturnType; + handles: Record; + cursor: string | null; +}; + +export const EMPTY_PAGE: EventsPage = { events: [], handles: {}, cursor: null }; + +function now(): string { + return new Date().toISOString(); +} + +/** + * Shape a contrail list response into a page, minting the next-page envelope for + * the SAME query name + scope args, so a continuation can only resume this query. + */ +function toPage( + q: CursorQuery, + args: CursorArgs | undefined, + response: Awaited> +): EventsPage { + if (!response) return EMPTY_PAGE; + const handles: Record = {}; + for (const p of response.profiles ?? []) { + if (p.handle) handles[p.did] = p.handle; + } + return { + events: flattenEventRecords(response.records ?? []), + handles, + cursor: nextCursor(q, response.cursor ?? null, args) + }; +} + +/** The home discovery feed: upcoming, discoverable, soonest first. */ +export async function eventsQuery( + client: Client, + args: { popular: boolean }, + cursor: string | null | undefined +): Promise { + const response = await listDiscoverableEventsFromContrail(client, { + startsAtMin: now(), + profiles: true, + sort: 'startsAt', + order: 'asc', + limit: PAGE_SIZE, + ...(args.popular ? { rsvpsCountMin: 2 } : {}), + cursor: cursor ?? undefined + }); + return toPage('events', { popular: args.popular }, response); +} + +/** A profile's upcoming events: authored by this actor, soonest first. */ +export async function hostingQuery( + client: Client, + args: { actor: ActorIdentifier }, + cursor: string | null | undefined +): Promise { + const response = await listAuthoredEventsFromContrail(client, { + actor: args.actor, + startsAtMin: now(), + sort: 'startsAt', + order: 'asc', + profiles: true, + limit: PAGE_SIZE, + cursor: cursor ?? undefined + }); + return toPage('hosting', { actor: args.actor }, response); +} + +/** + * A profile's past events: authored by this actor, most recent first. + * + * The D1 bound is `startsAtMax`, which still admits an event that began earlier + * and is STILL RUNNING, so the result is narrowed to events that have actually + * ended. The narrowing lives in the query, so every page inherits it. + * + * It runs AFTER pagination: the cursor reflects the unfiltered keyset position, + * which is what keeps page 2 adjacent to page 1. A page can therefore come back + * short — or empty while more pages remain — so a caller must keep its "load + * more" affordance alive for as long as there is a cursor, not for as long as + * the page has events. + */ +export async function pastEventsQuery( + client: Client, + args: { actor: ActorIdentifier }, + cursor: string | null | undefined +): Promise { + const asOf = now(); + const response = await listAuthoredEventsFromContrail(client, { + actor: args.actor, + startsAtMax: asOf, + sort: 'startsAt', + order: 'desc', + profiles: true, + limit: PAGE_SIZE, + cursor: cursor ?? undefined + }); + const page = toPage('past-events', { actor: args.actor }, response); + return { ...page, events: page.events.filter((e) => hasEnded(e, asOf)) }; +} + +/** + * A topic list: upcoming discoverable events matching ANY of the topic's + * hashtag terms. The search string is derived from the slug SERVER-side, so a + * caller can never supply the query text. An unknown slug yields an empty page. + */ +export async function topicQuery( + client: Client, + args: { slug: string }, + cursor: string | null | undefined +): Promise { + const search = orQueryFromSlug(args.slug); + if (!search) return EMPTY_PAGE; + const response = await listDiscoverableEventsFromContrail(client, { + search, + startsAtMin: now(), + sort: 'startsAt', + order: 'asc', + profiles: true, + limit: PAGE_SIZE, + cursor: cursor ?? undefined + }); + return toPage('topic', { slug: args.slug }, response); +} + +/** + * Free-text search, D1 LIKE path — the degraded backend used when Meilisearch + * is unconfigured or down. Upcoming-only, matching the Meili path; an empty term + * yields an empty page rather than an unfiltered list. + */ +export async function searchD1Query( + client: Client, + args: { term: string }, + cursor: string | null | undefined +): Promise { + const term = args.term.trim(); + if (!term) return EMPTY_PAGE; + const response = await listDiscoverableEventsFromContrail(client, { + search: term, + startsAtMin: now(), + sort: 'startsAt', + order: 'desc', + profiles: true, + limit: SEARCH_PAGE_SIZE, + cursor: cursor ?? undefined + }); + return toPage('search-d1', undefined, response); +} + +/** + * Free-text search, Meilisearch path: Meili ranks, D1 supplies the records. An + * empty term or unconfigured backend yields an empty page — page 1 falls back to + * D1 on failure, but a CONTINUATION must not, or it would restart page 1 on the + * other backend with a keyset that backend can't read. + */ +export async function searchMeiliQuery( + env: App.Platform['env'] | undefined, + client: Client, + args: { term: string }, + cursor: string | null | undefined +): Promise { + const term = args.term.trim(); + const backend = term ? searchBackendFromEnv(env) : null; + if (!term || !backend) return EMPTY_PAGE; + // Omit `cursor` entirely on a fresh page rather than passing null: page 1 and + // the resumer then issue byte-identical calls apart from the keyset itself. + const page = await runEventSearchPage(backend, client, { + q: term, + ...(cursor ? { cursor } : {}) + }); + return { + events: page.events, + handles: page.handles, + cursor: nextCursor('search-meili', page.cursor) + }; +} diff --git a/apps/web/src/routes/(app)/events/+page.server.ts b/apps/web/src/routes/(app)/events/+page.server.ts index 15b64ee..5c6ecb6 100644 --- a/apps/web/src/routes/(app)/events/+page.server.ts +++ b/apps/web/src/routes/(app)/events/+page.server.ts @@ -1,43 +1,16 @@ -import { - flattenEventRecords, - getServerClient, - listDiscoverableEventsFromContrail -} from '$lib/contrail'; -import { nextCursor, rawForQuery } from '$lib/contrail/cursor'; +import { getServerClient } from '$lib/contrail'; +import { eventsQuery } from '$lib/contrail/queries'; +import { rawForQuery } from '$lib/contrail/cursor'; import type { PageServerLoad } from './$types'; -const PAGE_SIZE = 20; - export const load: PageServerLoad = async ({ url, platform }) => { const client = getServerClient(platform!.env.DB); - const now = new Date().toISOString(); const isPopular = url.searchParams.get('filter') !== 'all'; + // Deep-link ?cursor= resumes only an 'events' cursor minted for the same // popular/all filter; anything else -> fresh page 1 (see rawForQuery). const cursor = rawForQuery(url.searchParams.get('cursor'), 'events', { popular: isPopular }); - const response = await listDiscoverableEventsFromContrail(client, { - startsAtMin: now, - profiles: true, - sort: 'startsAt', - order: 'asc', - limit: PAGE_SIZE, - cursor, - ...(isPopular ? { rsvpsCountMin: 2 } : {}) - }); - - if (!response) return { events: [], handles: {}, cursor: null }; - - const handles: Record = {}; - for (const p of response.profiles ?? []) { - if (p.handle) handles[p.did] = p.handle; - } - - return { - events: flattenEventRecords(response.records), - handles, - // A self-describing envelope: load-more re-runs THIS server-side query - // (discoverable + startsAtMin=now + the popular toggle), no client filters. - cursor: nextCursor('events', response.cursor ?? null, { popular: isPopular }) - }; + // The same query load-more continues — see queries.ts. + return eventsQuery(client, { popular: isPopular }, cursor); }; diff --git a/apps/web/src/routes/(app)/p/[actor]/hosting/+page.server.ts b/apps/web/src/routes/(app)/p/[actor]/hosting/+page.server.ts index 35e75f4..6b5c667 100644 --- a/apps/web/src/routes/(app)/p/[actor]/hosting/+page.server.ts +++ b/apps/web/src/routes/(app)/p/[actor]/hosting/+page.server.ts @@ -1,16 +1,10 @@ import { getActor } from '$lib/actor'; -import { - flattenEventRecords, - getProfileFromContrail, - getServerClient, - listAuthoredEventsFromContrail -} from '$lib/contrail'; -import { nextCursor, rawForQuery } from '$lib/contrail/cursor'; +import { getProfileFromContrail, getServerClient } from '$lib/contrail'; +import { hostingQuery } from '$lib/contrail/queries'; +import { rawForQuery } from '$lib/contrail/cursor'; import { isActorIdentifier } from '@atcute/lexicons/syntax'; import { error } from '@sveltejs/kit'; -const PAGE_SIZE = 20; - export async function load({ params, url, platform }) { const client = getServerClient(platform!.env.DB); if (!isActorIdentifier(params.actor)) return; @@ -22,26 +16,15 @@ export async function load({ params, url, platform }) { // Deep-link ?cursor= resumes only a 'hosting' cursor for this actor; else fresh page 1. const cursor = rawForQuery(url.searchParams.get('cursor'), 'hosting', { actor }); - const now = new Date().toISOString(); - const [profile, response] = await Promise.all([ + // The same query load-more continues — see queries.ts. + const [profile, page] = await Promise.all([ getProfileFromContrail(client, actor), - listAuthoredEventsFromContrail(client, { - profiles: true, - sort: 'startsAt', - order: 'asc', - startsAtMin: now, - actor, - limit: PAGE_SIZE, - cursor - }) + hostingQuery(client, { actor }, cursor) ]); return { - events: response ? flattenEventRecords(response.records) : [], - // Self-describing envelope: load-more re-runs the authored + upcoming query - // scoped to this actor, server-side. - cursor: nextCursor('hosting', response?.cursor ?? null, { actor }), + ...page, actorProfile: profile, actor, actorDid: did diff --git a/apps/web/src/routes/(app)/p/[actor]/past-events/+page.server.ts b/apps/web/src/routes/(app)/p/[actor]/past-events/+page.server.ts index 301aa66..809f82a 100644 --- a/apps/web/src/routes/(app)/p/[actor]/past-events/+page.server.ts +++ b/apps/web/src/routes/(app)/p/[actor]/past-events/+page.server.ts @@ -1,17 +1,10 @@ import { getActor } from '$lib/actor'; -import { - flattenEventRecords, - getProfileFromContrail, - getServerClient, - listAuthoredEventsFromContrail -} from '$lib/contrail'; -import { nextCursor, rawForQuery } from '$lib/contrail/cursor'; -import { hasEnded } from '$lib/past-events'; +import { getProfileFromContrail, getServerClient } from '$lib/contrail'; +import { pastEventsQuery } from '$lib/contrail/queries'; +import { rawForQuery } from '$lib/contrail/cursor'; import { isActorIdentifier } from '@atcute/lexicons/syntax'; import { error } from '@sveltejs/kit'; -const PAGE_SIZE = 20; - export async function load({ params, url, platform }) { const client = getServerClient(platform!.env.DB); if (!isActorIdentifier(params.actor)) return; @@ -23,34 +16,16 @@ export async function load({ params, url, platform }) { // Deep-link ?cursor= resumes only a 'past-events' cursor for this actor; else fresh page 1. const cursor = rawForQuery(url.searchParams.get('cursor'), 'past-events', { actor }); - const now = new Date().toISOString(); - const [profile, response] = await Promise.all([ + // The same query load-more continues, including its ended-event narrowing, so + // this page can come back short while a cursor remains — see queries.ts. + const [profile, page] = await Promise.all([ getProfileFromContrail(client, actor), - listAuthoredEventsFromContrail(client, { - profiles: true, - sort: 'startsAt', - order: 'desc', - startsAtMax: now, - actor, - limit: PAGE_SIZE, - cursor - }) + pastEventsQuery(client, { actor }, cursor) ]); - // Narrow to events that have actually ENDED — startsAtMax still admits one - // that began earlier and is still running. The load-more resumer applies the - // same shared predicate, so an ongoing event can't be dropped here only to - // reappear on page 2. - const events = (response ? flattenEventRecords(response.records) : []).filter((e) => - hasEnded(e, now) - ); - return { - events, - // Self-describing envelope: load-more re-runs the authored + past query - // (desc, startsAtMax=now) scoped to this actor, server-side. - cursor: nextCursor('past-events', response?.cursor ?? null, { actor }), + ...page, actorProfile: profile, actor, actorDid: did diff --git a/apps/web/src/routes/(app)/search/+page.server.ts b/apps/web/src/routes/(app)/search/+page.server.ts index 9914d94..28af2e9 100644 --- a/apps/web/src/routes/(app)/search/+page.server.ts +++ b/apps/web/src/routes/(app)/search/+page.server.ts @@ -1,18 +1,13 @@ -import { - flattenEventRecords, - getServerClient, - listDiscoverableEventsFromContrail -} from '$lib/contrail'; -import { runEventSearchPage, searchBackendFromEnv } from '$lib/search/server/query'; -import { SEARCH_PAGE_SIZE } from '$lib/search/constants'; -import { nextCursor } from '$lib/contrail/cursor'; +import { getServerClient } from '$lib/contrail'; +import { EMPTY_PAGE, searchD1Query, searchMeiliQuery } from '$lib/contrail/queries'; +import { searchBackendFromEnv } from '$lib/search/server/query'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ url, platform }) => { const client = getServerClient(platform!.env.DB); - const q = url.searchParams.get('q')?.trim() || ''; + const term = url.searchParams.get('q')?.trim() || ''; - if (!q) return { events: [], handles: {}, cursor: null, query: '' }; + if (!term) return { ...EMPTY_PAGE, query: '' }; // Search page 1 does NOT resume from ?cursor=: the term rides ?q=, not the // envelope, so an inbound cursor can't be proven to match this route's term. @@ -20,49 +15,15 @@ export const load: PageServerLoad = async ({ url, platform }) => { // Meilisearch ranks (typo tolerance, prefix, relevance); D1 supplies the // records. Falls back to the LIKE-based D1 path when the search backend is - // unconfigured (local dev) or down. - const backend = searchBackendFromEnv(platform?.env); - if (backend) { + // unconfigured (local dev) or down. Only page 1 falls back — a continuation + // must not switch backends, so the resumers don't (see queries.ts). + if (searchBackendFromEnv(platform?.env)) { try { - const page = await runEventSearchPage(backend, client, { q }); - return { - events: page.events, - handles: page.handles, - cursor: nextCursor('search-meili', page.cursor), - query: q - }; + return { ...(await searchMeiliQuery(platform?.env, client, { term }, null)), query: term }; } catch (err) { console.error('search backend failed, falling back to D1 search:', err); } } - // Keep the degraded path consistent with the Meilisearch path: upcoming only. - // D1 range params AND together (an endsAt bound would drop events with no - // endsAt), so this uses the start-based approximation the home list also uses. - const response = await listDiscoverableEventsFromContrail(client, { - search: q, - profiles: true, - startsAtMin: new Date().toISOString(), - sort: 'startsAt', - order: 'desc', - limit: SEARCH_PAGE_SIZE - }); - - if (!response) return { events: [], handles: {}, cursor: null, query: q }; - - const handles: Record = {}; - for (const p of response.profiles ?? []) { - if (p.handle) handles[p.did] = p.handle; - } - - return { - events: flattenEventRecords(response.records), - handles, - // Self-describing 'search-d1' envelope: load-more re-runs the SAME - // discoverable + startsAtMin + desc query, with the search term from ?q=/ - // input — later pages stay upcoming-only and discoverable, no drift into - // past/non-discoverable events. - cursor: nextCursor('search-d1', response.cursor ?? null), - query: q - }; + return { ...(await searchD1Query(client, { term }, null)), query: term }; }; diff --git a/apps/web/src/routes/(app)/topics/[slug]/+page.server.ts b/apps/web/src/routes/(app)/topics/[slug]/+page.server.ts index ba7b4c4..807257d 100644 --- a/apps/web/src/routes/(app)/topics/[slug]/+page.server.ts +++ b/apps/web/src/routes/(app)/topics/[slug]/+page.server.ts @@ -1,54 +1,28 @@ import { error } from '@sveltejs/kit'; import { getTopicBySlug, orQueryFromSlug } from '$lib/topics'; -import { - flattenEventRecords, - getServerClient, - listDiscoverableEventsFromContrail -} from '$lib/contrail'; -import { nextCursor, rawForQuery } from '$lib/contrail/cursor'; +import { getServerClient } from '$lib/contrail'; +import { topicQuery } from '$lib/contrail/queries'; +import { rawForQuery } from '$lib/contrail/cursor'; import type { PageServerLoad } from './$types'; -const PAGE_SIZE = 20; - export const load: PageServerLoad = async ({ params, url, platform }) => { const topic = getTopicBySlug(params.slug); if (!topic) error(404, 'Topic not found'); const client = getServerClient(platform!.env.DB); - // Match events whose name/description mention ANY of the topic's hashtag - // terms. The discoverable list runs `search` through D1's SQLite FTS5 MATCH, - // where an uppercase OR is a real disjunction operator — so this is a true - // "any term" query. Derived SERVER-side from the slug via the shared helper the - // load-more registry also uses, so page 1 and load-more can't drift. - const query = orQueryFromSlug(params.slug) ?? ''; - - const response = await listDiscoverableEventsFromContrail(client, { - search: query, - profiles: true, - // Upcoming-only, soonest first — same shape as the home discovery list. - startsAtMin: new Date().toISOString(), - sort: 'startsAt', - order: 'asc', - limit: PAGE_SIZE, - // Deep-link ?cursor= resumes only a 'topic' cursor for this slug; else fresh page 1. - cursor: rawForQuery(url.searchParams.get('cursor'), 'topic', { slug: params.slug }) - }); + // Deep-link ?cursor= resumes only a 'topic' cursor for this slug; else fresh page 1. + const cursor = rawForQuery(url.searchParams.get('cursor'), 'topic', { slug: params.slug }); - const handles: Record = {}; - for (const p of response?.profiles ?? []) { - if (p.handle) handles[p.did] = p.handle; - } + // The same query load-more continues; it derives the OR-search from the slug + // server-side, so the query text is never supplied by a caller. + const page = await topicQuery(client, { slug: params.slug }, cursor); return { topic, - events: flattenEventRecords(response?.records ?? []), - handles, - // Self-describing 'topic' envelope carrying the slug: load-more re-derives - // the same OR-search from the slug SERVER-side and re-runs the identical - // discoverable + startsAtMin query — later pages stay upcoming-only and - // discoverable. - cursor: nextCursor('topic', response?.cursor ?? null, { slug: params.slug }), - query + ...page, + // Shown in the UI; topicQuery derives the query it runs from the slug with + // this same helper. + query: orQueryFromSlug(params.slug) ?? '' }; }; -- 2.51.2 From e4e3c9190bf8fbbacb8f8ccee47d7047a9f98064 Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Sat, 25 Jul 2026 08:21:24 -0400 Subject: [PATCH 5/5] test(pagination): keep the tests that can still fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page-1/load-more continuity tests compared the filter bags two call sites emitted. Both now call one function, so those comparisons assert that a function equals itself: they cannot fail, and a test that cannot fail is upkeep without signal. Remove them and their shared helper. Nothing they covered goes uncovered. Each query's filters, order, bounds and limit stay pinned in events-load-more.test.ts, which drives every registry entry and asserts the exact params it issues. A route calling the wrong query is still caught by the envelope it emits, since a query mints an envelope naming itself — those assertions are kept where the pairings had them. What remains per route is what a route decides: which query it runs, the scope it derives from url/params, whether an inbound ?cursor= may resume, and its own errors. past-events also keeps the still-running exclusion, which shapes results rather than the query. Frozen clocks stay only there, where fixtures depend on one. --- .../src/lib/contrail/continuity.test-utils.ts | 41 --------- .../routes/(app)/events/page.server.test.ts | 52 ++++-------- .../p/[actor]/hosting/page.server.test.ts | 31 +++---- .../p/[actor]/past-events/page.server.test.ts | 47 +++++------ .../routes/(app)/search/page.server.test.ts | 83 ++----------------- .../(app)/topics/[slug]/page.server.test.ts | 55 ++---------- 6 files changed, 64 insertions(+), 245 deletions(-) delete mode 100644 apps/web/src/lib/contrail/continuity.test-utils.ts diff --git a/apps/web/src/lib/contrail/continuity.test-utils.ts b/apps/web/src/lib/contrail/continuity.test-utils.ts deleted file mode 100644 index 7e7608a..0000000 --- a/apps/web/src/lib/contrail/continuity.test-utils.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Shared assertions for the page-1 ↔ load-more continuity tests that sit beside -// each paginated route's +page.server.ts. -import { expect } from 'vitest'; - -/** - * A fixed instant for those tests to run at, so page 1 and the resumer compute - * the same `now()` time bound and it can be compared by value. Install per file: - * - * beforeEach(() => vi.useFakeTimers({ toFake: ['Date'], now: FROZEN_NOW })); - * afterEach(() => vi.useRealTimers()); - * - * Only `Date` is faked, so async control flow is untouched. - */ -export const FROZEN_NOW = new Date('2026-06-01T12:00:00.000Z'); - -/** - * Assert that a route's page-1 `load()` and the load-more resumer continuing it - * issued the SAME query. - * - * A keyset cursor is query-shape-specific, so page 2 is only adjacent to page 1 - * when every other param agrees — sort, order, limit, filters, scope and the - * time bounds. Hence the full param bags are compared rather than a chosen - * subset: a param added to one side only is drift, whether or not this helper - * knows its name. The cursor is the one param that legitimately differs, so it - * is checked separately — page 1 runs fresh, page 2 threads page 1's raw keyset - * through unchanged. - * - * Requires a frozen clock (see FROZEN_NOW). - */ -export function expectSameQuery( - page1: Record, - page2: Record, - rawKeyset: string -): void { - const { cursor: page1Cursor, ...page1Query } = page1; - const { cursor: page2Cursor, ...page2Query } = page2; - - expect(page2Query).toEqual(page1Query); - expect(page1Cursor).toBeUndefined(); - expect(page2Cursor).toBe(rawKeyset); -} diff --git a/apps/web/src/routes/(app)/events/page.server.test.ts b/apps/web/src/routes/(app)/events/page.server.test.ts index 3ac8aa8..c123693 100644 --- a/apps/web/src/routes/(app)/events/page.server.test.ts +++ b/apps/web/src/routes/(app)/events/page.server.test.ts @@ -1,32 +1,28 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -// Page 1 of /events comes from this route's load(); page 2 comes from the -// load-more registry's 'events' resumer. They are separate code paths that must -// issue the same discoverable query, so these drive both for real and compare -// what each one emitted. +// What this route decides for /events: which query it runs, the scope it derives +// from ?filter= and records in the emitted envelope, and whether an inbound +// ?cursor= may be resumed. The query body it calls — discoverable, upcoming, asc +// — is pinned in lib/contrail/events-load-more.test.ts. vi.mock('$lib/contrail', () => ({ getServerClient: vi.fn(() => ({})), flattenEventRecords: vi.fn((records: unknown[]) => records), listDiscoverableEventsFromContrail: vi.fn(), listAuthoredEventsFromContrail: vi.fn() })); -// events-load-more.ts imports the search module at load time; the events route -// never hits it, so a null-backend stub keeps the import deterministic. +// queries.ts imports the search module at load time; the events query never hits +// it, so a null-backend stub keeps the import deterministic. vi.mock('$lib/search/server/query', () => ({ searchBackendFromEnv: vi.fn(() => null), runEventSearchPage: vi.fn() })); import { load } from './+page.server'; -import { runLoadMoreEvents } from '$lib/contrail/events-load-more'; import { listDiscoverableEventsFromContrail } from '$lib/contrail'; import { decodeCursor, encodeCursor } from '$lib/contrail/cursor'; -import { FROZEN_NOW, expectSameQuery } from '$lib/contrail/continuity.test-utils'; const mockDiscoverable = vi.mocked(listDiscoverableEventsFromContrail); -const env = { DB: {} } as unknown as App.Platform['env']; - function event(opts?: { filter?: string; cursor?: string }) { const url = new URL('https://atmo.test/events'); if (opts?.filter) url.searchParams.set('filter', opts.filter); @@ -45,50 +41,36 @@ type LoadResult = { events: unknown[]; handles: Record; cursor: const runLoad = async (opts?: { filter?: string; cursor?: string }) => (await load(event(opts))) as LoadResult; -beforeEach(() => vi.useFakeTimers({ toFake: ['Date'], now: FROZEN_NOW })); -afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); -}); +afterEach(() => vi.clearAllMocks()); -describe('events page load ↔ resumer continuity', () => { - it('page-1 load and the events resumer issue the same query (popular)', async () => { +describe('events page load', () => { + // The envelope names the query that minted it, so this also pins that the + // route called the events query and not another one — a mis-wired route would + // emit some other `q` here. + it("mints an 'events' envelope carrying the popular scope", async () => { mockDiscoverable.mockResolvedValue(page('keyset-p1')); const result = await runLoad(); // no filter => popular - const p1 = mockDiscoverable.mock.calls[0][1]; - // The emitted envelope names the same server-side query + scope, carrying the - // fresh keyset for the resumer to thread back in. + expect(mockDiscoverable.mock.calls[0][1]).toMatchObject({ rsvpsCountMin: 2 }); expect(decodeCursor(result.cursor)).toEqual({ v: 1, q: 'events', args: { popular: true }, raw: 'keyset-p1' }); - - await runLoadMoreEvents(env, { cursor: result.cursor! }); - const p2 = mockDiscoverable.mock.calls[1][1]; - - expectSameQuery(p1, p2, 'keyset-p1'); }); - it('page-1 load and the resumer both drop rsvpsCountMin for the all/non-popular filter', async () => { + it('drops rsvpsCountMin for the all/non-popular filter and says so in the envelope', async () => { mockDiscoverable.mockResolvedValue(page('keyset-all')); const result = await runLoad({ filter: 'all' }); - const p1 = mockDiscoverable.mock.calls[0][1]; - expect(p1).not.toHaveProperty('rsvpsCountMin'); + + expect(mockDiscoverable.mock.calls[0][1]).not.toHaveProperty('rsvpsCountMin'); expect(decodeCursor(result.cursor)).toEqual({ v: 1, q: 'events', args: { popular: false }, raw: 'keyset-all' }); - - await runLoadMoreEvents(env, { cursor: result.cursor! }); - const p2 = mockDiscoverable.mock.calls[1][1]; - expect(p2).not.toHaveProperty('rsvpsCountMin'); - - expectSameQuery(p1, p2, 'keyset-all'); }); }); diff --git a/apps/web/src/routes/(app)/p/[actor]/hosting/page.server.test.ts b/apps/web/src/routes/(app)/p/[actor]/hosting/page.server.test.ts index eec5d2e..6a6f304 100644 --- a/apps/web/src/routes/(app)/p/[actor]/hosting/page.server.test.ts +++ b/apps/web/src/routes/(app)/p/[actor]/hosting/page.server.test.ts @@ -1,9 +1,9 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -// Page 1 of /p/[actor]/hosting comes from this route's load(); page 2 comes from -// the load-more registry's 'hosting' resumer. They are separate code paths that -// must issue the same authored + upcoming query, scoped to the same actor, so -// these drive both for real and compare what each one emitted. +// What this route decides for /p/[actor]/hosting: which query it runs, the actor +// scope it records in the emitted envelope, and whether an inbound ?cursor= may +// be resumed. The query body — authored, upcoming, asc — is pinned in +// lib/contrail/events-load-more.test.ts. vi.mock('$lib/actor', () => ({ getActor: vi.fn(async () => 'did:plc:alice') })); @@ -20,15 +20,12 @@ vi.mock('$lib/search/server/query', () => ({ })); import { load } from './+page.server'; -import { runLoadMoreEvents } from '$lib/contrail/events-load-more'; import { listAuthoredEventsFromContrail } from '$lib/contrail'; import { decodeCursor, encodeCursor } from '$lib/contrail/cursor'; -import { FROZEN_NOW, expectSameQuery } from '$lib/contrail/continuity.test-utils'; const mockAuthored = vi.mocked(listAuthoredEventsFromContrail); const ACTOR = 'did:plc:alice'; -const env = { DB: {} } as unknown as App.Platform['env']; function event(actor: string, cursor?: string) { const url = new URL(`https://atmo.test/p/${actor}/hosting`); @@ -51,17 +48,14 @@ type LoadResult = { events: unknown[]; cursor: string | null }; const runLoad = async (actor: string, cursor?: string) => (await load(event(actor, cursor))) as LoadResult; -beforeEach(() => vi.useFakeTimers({ toFake: ['Date'], now: FROZEN_NOW })); -afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); -}); +afterEach(() => vi.clearAllMocks()); -describe('hosting page load ↔ resumer continuity', () => { - it('page-1 load and the hosting resumer issue the same query', async () => { +describe('hosting page load', () => { + // The envelope names the query that minted it, so this also pins that the + // route called the hosting query — the past-events one would say so here. + it("mints a 'hosting' envelope scoped to this actor", async () => { mockAuthored.mockResolvedValue(page('keyset-p1')); const result = await runLoad(ACTOR); - const p1 = mockAuthored.mock.calls[0][1]; expect(decodeCursor(result.cursor)).toEqual({ v: 1, @@ -69,11 +63,6 @@ describe('hosting page load ↔ resumer continuity', () => { args: { actor: ACTOR }, raw: 'keyset-p1' }); - - await runLoadMoreEvents(env, { cursor: result.cursor! }); - const p2 = mockAuthored.mock.calls[1][1]; - - expectSameQuery(p1, p2, 'keyset-p1'); }); }); diff --git a/apps/web/src/routes/(app)/p/[actor]/past-events/page.server.test.ts b/apps/web/src/routes/(app)/p/[actor]/past-events/page.server.test.ts index 5d32006..b88b30b 100644 --- a/apps/web/src/routes/(app)/p/[actor]/past-events/page.server.test.ts +++ b/apps/web/src/routes/(app)/p/[actor]/past-events/page.server.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -// Page 1 of /p/[actor]/past-events comes from this route's load(); page 2 comes -// from the load-more registry's 'past-events' resumer. They are separate code -// paths that must issue the same authored + past query — desc order under a -// startsAtMax upper bound, not startsAtMin — so these drive both for real and -// compare what each one emitted. +// What this route decides for /p/[actor]/past-events: which query it runs, the +// actor scope it records in the emitted envelope, and whether an inbound ?cursor= +// may be resumed. Plus the one result-shaping rule that is easy to get wrong — +// the upper time bound admits an event that is still running, so the query +// excludes it. vi.mock('$lib/actor', () => ({ getActor: vi.fn(async () => 'did:plc:alice') })); @@ -21,15 +21,14 @@ vi.mock('$lib/search/server/query', () => ({ })); import { load } from './+page.server'; -import { runLoadMoreEvents } from '$lib/contrail/events-load-more'; import { listAuthoredEventsFromContrail } from '$lib/contrail'; import { decodeCursor, encodeCursor } from '$lib/contrail/cursor'; -import { FROZEN_NOW, expectSameQuery } from '$lib/contrail/continuity.test-utils'; const mockAuthored = vi.mocked(listAuthoredEventsFromContrail); +// Frozen so ENDED/ONGOING sit deterministically either side of "now". +const FROZEN_NOW = new Date('2026-06-01T12:00:00.000Z'); const ACTOR = 'did:plc:alice'; -const env = { DB: {} } as unknown as App.Platform['env']; function event(actor: string, cursor?: string) { const url = new URL(`https://atmo.test/p/${actor}/past-events`); @@ -46,8 +45,8 @@ const ENDED = { uri: 'at://did:plc:alice/community.lexicon.calendar.event/1', startsAt: '2000-01-01T00:00:00Z' }; -// Began before the frozen clock but has NOT finished. The startsAtMax bound -// admits it, so both sides have to exclude it themselves. +// Began before the frozen clock but has NOT finished; the startsAtMax bound +// admits it, so the query has to exclude it. const ONGOING = { uri: 'at://did:plc:alice/community.lexicon.calendar.event/ongoing', startsAt: '2000-01-01T00:00:00Z', @@ -71,13 +70,14 @@ afterEach(() => { vi.clearAllMocks(); }); -describe('past-events page load ↔ resumer continuity', () => { - it('page-1 load and the past-events resumer issue the same query (desc, startsAtMax)', async () => { +describe('past-events page load', () => { + it("mints a 'past-events' envelope, bounded ABOVE by now", async () => { mockAuthored.mockResolvedValue(page('keyset-p1')); const result = await runLoad(ACTOR); - const p1 = mockAuthored.mock.calls[0][1]; - // The past query is bounded ABOVE by now, not below — the one route where + + // The past query is bounded above by now, not below — the one route where // swapping the bound would still look plausible. + const p1 = mockAuthored.mock.calls[0][1]; expect(typeof p1.startsAtMax).toBe('string'); expect(p1.startsAtMin).toBeUndefined(); expect(p1.order).toBe('desc'); @@ -88,25 +88,18 @@ describe('past-events page load ↔ resumer continuity', () => { args: { actor: ACTOR }, raw: 'keyset-p1' }); - - await runLoadMoreEvents(env, { cursor: result.cursor! }); - const p2 = mockAuthored.mock.calls[1][1]; - - expectSameQuery(p1, p2, 'keyset-p1'); }); - // Matching query bags are not enough here: this route also narrows the - // RESULT after the query returns. If only page 1 did that, an event that is - // still running would be filtered off page 1 and then resurface on page 2 of - // "past events". - it('page-1 load and the resumer both exclude an event that is still running', async () => { + // startsAtMax admits an event that began earlier and is still running, so the + // query narrows the result after the read. Every page inherits that narrowing + // because there is one query — an ongoing event cannot be dropped from one + // page and resurface on the next. + it('excludes an event that is still running', async () => { mockAuthored.mockResolvedValue(page('keyset-p1', [ONGOING, ENDED])); const result = await runLoad(ACTOR); - expect(result.events).toEqual([ENDED]); - const page2 = await runLoadMoreEvents(env, { cursor: result.cursor! }); - expect(page2.events).toEqual([ENDED]); + expect(result.events).toEqual([ENDED]); }); }); diff --git a/apps/web/src/routes/(app)/search/page.server.test.ts b/apps/web/src/routes/(app)/search/page.server.test.ts index 2a10ecc..9f6fbe4 100644 --- a/apps/web/src/routes/(app)/search/page.server.test.ts +++ b/apps/web/src/routes/(app)/search/page.server.test.ts @@ -1,11 +1,11 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -// The search load picks between two backends whose cursors are NOT -// interchangeable: Meilisearch (offset cursor) and the D1 LIKE fallback (opaque -// keyset). Each hands back a self-describing continuation envelope naming its -// own query, so load-more resumes on the backend that produced the page. These -// pin the page-1 behaviour, the deep-link guard, and — for both backends — that -// page 1 and its resumer issue the same query. +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// The one route that picks between two backends whose cursors are NOT +// interchangeable: Meilisearch (offset) and the D1 LIKE fallback (opaque keyset). +// Only page 1 may fall back — a continuation must not switch backends — so these +// pin which backend serves the page, that the emitted envelope names it, and that +// no inbound ?cursor= is ever resumed here (the term rides ?q=, not the +// envelope). vi.mock('$lib/contrail', () => ({ getServerClient: vi.fn(() => ({})), flattenEventRecords: vi.fn((records: unknown[]) => records), @@ -17,18 +17,14 @@ vi.mock('$lib/search/server/query', () => ({ })); import { load } from './+page.server'; -import { runLoadMoreEvents } from '$lib/contrail/events-load-more'; import { listDiscoverableEventsFromContrail } from '$lib/contrail'; import { runEventSearchPage, searchBackendFromEnv } from '$lib/search/server/query'; import { decodeCursor, encodeCursor } from '$lib/contrail/cursor'; -import { FROZEN_NOW, expectSameQuery } from '$lib/contrail/continuity.test-utils'; const mockSearchBackendFromEnv = vi.mocked(searchBackendFromEnv); const mockRunEventSearchPage = vi.mocked(runEventSearchPage); const mockListDiscoverable = vi.mocked(listDiscoverableEventsFromContrail); -const env = { DB: {} } as unknown as App.Platform['env']; - type LoadResult = { events: unknown[]; handles: Record; @@ -47,11 +43,7 @@ function event(q: string, cursor?: string) { return { url, platform: { env: {} } } as unknown as Parameters[0]; } -beforeEach(() => vi.useFakeTimers({ toFake: ['Date'], now: FROZEN_NOW })); -afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); -}); +afterEach(() => vi.clearAllMocks()); describe('search page load', () => { it('returns early for an empty query without touching any backend', async () => { @@ -158,60 +150,3 @@ describe('search page load', () => { expect(mockListDiscoverable.mock.calls[0][1].cursor).toBeUndefined(); }); }); - -// Unlike the other routes the search term rides ?q=/input, NOT the envelope, so -// the resumer must be HANDED the term to reproduce page 1. These pin that once -// it is, the page-1 load and the resumer issue the same query — on whichever of -// the two backends served page 1. -describe('search page load ↔ resumer continuity', () => { - it('page-1 D1 fallback and the search-d1 resumer issue the same query', async () => { - mockSearchBackendFromEnv.mockReturnValue(null); - mockListDiscoverable.mockResolvedValue({ - records: [{ uri: 'at://did:plc:a/community.lexicon.calendar.event/1' }], - profiles: [{ did: 'did:plc:a', handle: 'alice' }], - cursor: 'keyset-p1' - } as unknown as Awaited>); - - const result = await runLoad('kite'); - const p1 = mockListDiscoverable.mock.calls[0][1]; - expect(decodeCursor(result.cursor)).toEqual({ v: 1, q: 'search-d1', raw: 'keyset-p1' }); - - // The term is absent from the envelope; the client re-supplies it as `q`. - await runLoadMoreEvents(env, { cursor: result.cursor!, q: 'kite' }); - const p2 = mockListDiscoverable.mock.calls[1][1]; - - expectSameQuery(p1, p2, 'keyset-p1'); - }); - - it('page-1 Meili and the search-meili resumer issue the same query, threading the raw offset', async () => { - mockSearchBackendFromEnv.mockReturnValue({ url: 'https://meili.test', apiKey: 'k' }); - mockRunEventSearchPage.mockResolvedValue({ - events: [{ uri: 'at://did:plc:a/community.lexicon.calendar.event/1' }], - handles: { 'did:plc:a': 'alice' }, - cursor: 'meili:20', - distances: {} - } as unknown as Awaited>); - - const result = await runLoad('kite'); - expect(decodeCursor(result.cursor)).toEqual({ v: 1, q: 'search-meili', raw: 'meili:20' }); - - await runLoadMoreEvents(env, { cursor: result.cursor!, q: 'kite' }); - - // Compare the whole options object both sides passed, not just `q` — an - // option added to the page-1 call alone (a filter, a page size, a locale) - // would search a different result set on page 2. - const p1 = mockRunEventSearchPage.mock.calls[0][2]; - const p2 = mockRunEventSearchPage.mock.calls[1][2]; - expectSameQuery(p1, p2, 'meili:20'); - - // The backend is part of the query's identity too — it carries the index - // being searched, so an offset from one index is meaningless against - // another. This is outside the options bag, so it needs its own pin. - expect(mockRunEventSearchPage.mock.calls[1][0]).toEqual( - mockRunEventSearchPage.mock.calls[0][0] - ); - - expect(p1.q).toBe('kite'); - expect(mockListDiscoverable).not.toHaveBeenCalled(); // never the D1 path - }); -}); diff --git a/apps/web/src/routes/(app)/topics/[slug]/page.server.test.ts b/apps/web/src/routes/(app)/topics/[slug]/page.server.test.ts index 1ccc20a..8e9ed9c 100644 --- a/apps/web/src/routes/(app)/topics/[slug]/page.server.test.ts +++ b/apps/web/src/routes/(app)/topics/[slug]/page.server.test.ts @@ -1,34 +1,28 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -// Page 1 of /topics/[slug] comes from this route's load(); page 2 comes from the -// load-more registry's 'topic' resumer. Both derive the OR-search from the slug -// server-side, and both must issue the same discoverable query, so these drive -// both for real and compare what each one emitted — plus the deep-link guard. +// What this route decides for /topics/[slug]: the 404 for an unknown slug, which +// query it runs, the slug it records in the emitted envelope, and whether an +// inbound ?cursor= may be resumed. $lib/topics is left REAL so the OR-search is +// derived from the slug for real rather than stubbed. vi.mock('$lib/contrail', () => ({ getServerClient: vi.fn(() => ({})), flattenEventRecords: vi.fn((records: unknown[]) => records), listDiscoverableEventsFromContrail: vi.fn(), listAuthoredEventsFromContrail: vi.fn() })); -// events-load-more.ts imports the search module at load time; the topic query -// never hits it, so a null-backend stub keeps the import deterministic. $lib/topics -// is intentionally left REAL so orQueryFromSlug derives the same OR-search on both -// the page-1 and resumer sides. +// queries.ts imports the search module at load time; the topic query never hits +// it, so a null-backend stub keeps the import deterministic. vi.mock('$lib/search/server/query', () => ({ searchBackendFromEnv: vi.fn(() => null), runEventSearchPage: vi.fn() })); import { load } from './+page.server'; -import { runLoadMoreEvents } from '$lib/contrail/events-load-more'; import { listDiscoverableEventsFromContrail } from '$lib/contrail'; import { decodeCursor, encodeCursor } from '$lib/contrail/cursor'; -import { FROZEN_NOW, expectSameQuery } from '$lib/contrail/continuity.test-utils'; const mockListDiscoverable = vi.mocked(listDiscoverableEventsFromContrail); -const env = { DB: {} } as unknown as App.Platform['env']; - type LoadResult = { topic: { slug: string }; events: unknown[]; @@ -46,11 +40,7 @@ function event(slug: string, cursor?: string) { const run = async (slug: string, cursor?: string) => (await load(event(slug, cursor))) as unknown as LoadResult; -beforeEach(() => vi.useFakeTimers({ toFake: ['Date'], now: FROZEN_NOW })); -afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); -}); +afterEach(() => vi.clearAllMocks()); describe('topic page load', () => { it("builds a 'topic' envelope carrying the slug, deriving the OR-search server-side", async () => { @@ -139,32 +129,3 @@ describe('topic page load', () => { expect(mockListDiscoverable).not.toHaveBeenCalled(); }); }); - -describe('topic page load ↔ resumer continuity', () => { - it('page-1 load and the topic resumer issue the same query (same OR-search)', async () => { - mockListDiscoverable.mockResolvedValue({ - records: [{ uri: 'at://did:plc:a/community.lexicon.calendar.event/1' }], - profiles: [{ did: 'did:plc:a', handle: 'alice' }], - cursor: 'keyset-p1' - } as unknown as Awaited>); - - const result = await run('technology'); - const p1 = mockListDiscoverable.mock.calls[0][1]; - // Page 1 derives the OR-search server-side from the slug. - expect(p1.search).toBe('tech OR technology'); - expect(decodeCursor(result.cursor)).toEqual({ - v: 1, - q: 'topic', - args: { slug: 'technology' }, - raw: 'keyset-p1' - }); - - await runLoadMoreEvents(env, { cursor: result.cursor! }); - const p2 = mockListDiscoverable.mock.calls[1][1]; - - // The comparison covers `search`, so it also pins that the resumer re-derived - // the SAME 'tech OR technology' from the slug rather than trusting a client - // value or drifting to another spelling of the query. - expectSameQuery(p1, p2, 'keyset-p1'); - }); -});