diff --git a/apps/web/src/lib/contrail/cursor.test.ts b/apps/web/src/lib/contrail/cursor.test.ts new file mode 100644 index 0000000..c6eafac --- /dev/null +++ b/apps/web/src/lib/contrail/cursor.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { tagCursor, parseCursor } from './cursor'; + +// A pagination cursor handed to the client is tagged with the backend that +// issued it so load-more can route by the tag instead of re-deriving the +// backend from request shape (om-7dbs). These pin the tag round-trip and the +// legacy (untagged) fallback contract both cursor kinds share. +describe('tagCursor', () => { + it('prefixes a Meili offset with its backend tag', () => { + expect(tagCursor('meili', '20')).toBe('meili:20'); + }); + + it('prefixes an opaque D1 base64url keyset with its backend tag', () => { + // A real D1 cursor is base64url(JSON) — no ':' in the alphabet, so the + // first ':' is unambiguously the tag separator. + const d1 = 'eyJ0IjoxNzUsImsiOiJhdDovL3gifQ'; + expect(tagCursor('d1', d1)).toBe(`d1:${d1}`); + }); + + it('returns null for a null/empty raw cursor (no more pages)', () => { + expect(tagCursor('meili', null)).toBeNull(); + expect(tagCursor('d1', undefined)).toBeNull(); + expect(tagCursor('d1', '')).toBeNull(); + }); +}); + +describe('parseCursor', () => { + it('round-trips a Meili-tagged cursor back to {backend, raw}', () => { + expect(parseCursor(tagCursor('meili', '20'))).toEqual({ backend: 'meili', raw: '20' }); + }); + + it('round-trips a D1-tagged cursor back to {backend, raw}', () => { + const d1 = 'eyJ0IjoxNzUsImsiOiJhdDovL3gifQ'; + expect(parseCursor(tagCursor('d1', d1))).toEqual({ backend: 'd1', raw: d1 }); + }); + + it('treats a null/empty cursor as no cursor', () => { + expect(parseCursor(null)).toEqual({ backend: null, raw: null }); + expect(parseCursor(undefined)).toEqual({ backend: null, raw: null }); + expect(parseCursor('')).toEqual({ backend: null, raw: null }); + }); + + it('treats an untagged legacy Meili offset as backend:null with raw preserved', () => { + // In-flight cursor issued before this deploy: no recognized tag, so the + // caller falls back to the old inference and can still consume raw. + expect(parseCursor('20')).toEqual({ backend: null, raw: '20' }); + }); + + it('treats an untagged legacy D1 base64url keyset as backend:null with raw preserved', () => { + const d1 = 'eyJ0IjoxNzUsImsiOiJhdDovL3gifQ'; + expect(parseCursor(d1)).toEqual({ backend: null, raw: d1 }); + }); + + it('does not mistake an unknown prefix for a backend tag', () => { + // Only 'meili'/'d1' are backends; anything else is legacy/opaque and kept whole. + expect(parseCursor('foo:bar')).toEqual({ backend: null, raw: 'foo:bar' }); + }); +}); diff --git a/apps/web/src/lib/contrail/cursor.ts b/apps/web/src/lib/contrail/cursor.ts new file mode 100644 index 0000000..b2f6fa2 --- /dev/null +++ b/apps/web/src/lib/contrail/cursor.ts @@ -0,0 +1,56 @@ +// Self-describing pagination cursors (om-7dbs). +// +// A cursor handed to the client is tagged with the backend that issued it, so +// load-more routes by the tag instead of re-deriving the backend from the +// request shape ("is search set AND is Meili configured"). That inference broke +// whenever a page's FIRST load came from one backend but its load-more resolved +// to the other: +// - a D1 keyset fed to Meili: Number(base64url) -> NaN -> offset 0 -> a +// relevance-reordered duplicate of page 1; +// - a Meili offset fed to D1 listRecords: ignored, and the discoverable / +// time-bound filters the first page applied get dropped. +// +// The raw cursor is opaque: a Meili offset string, or a base64url(JSON) D1 +// keyset built inside @atmo-dev/contrail. We WRAP it, never rewrite it — the +// separator below can't collide because base64url's alphabet excludes ':' and a +// Meili offset is decimal digits. + +export type CursorBackend = 'meili' | 'd1'; + +const SEP = ':'; +const BACKENDS: readonly CursorBackend[] = ['meili', 'd1']; + +/** + * Tag a backend-native cursor for the client. A null/empty raw cursor stays + * null (the backend signalled "no more pages"); tagging must not manufacture a + * cursor where there wasn't one. + */ +export function tagCursor(backend: CursorBackend, raw: string | null | undefined): string | null { + if (raw == null || raw === '') return null; + return `${backend}${SEP}${raw}`; +} + +export type ParsedCursor = + | { backend: CursorBackend; raw: string } + | { backend: null; raw: string | null }; + +/** + * Split a client cursor back into { backend, raw }. + * + * - A recognized `meili:`/`d1:` tag routes by that backend. + * - `null`/empty -> { backend: null, raw: null } (no cursor). + * - Anything else is an untagged legacy cursor (in-flight from before this + * deploy, or an unknown prefix): { backend: null, raw: } so the caller + * can fall back to the old inference and still consume it. + */ +export function parseCursor(cursor: string | null | undefined): ParsedCursor { + if (cursor == null || cursor === '') return { backend: null, raw: null }; + const sep = cursor.indexOf(SEP); + if (sep > 0) { + const tag = cursor.slice(0, sep); + if ((BACKENDS as readonly string[]).includes(tag)) { + return { backend: tag as CursorBackend, raw: cursor.slice(sep + 1) }; + } + } + return { backend: null, raw: cursor }; +}