From 131d2fc9d93ffcdfb150aca71b78dd0af6806061 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Thu, 27 Aug 2026 20:55:13 +0800 Subject: [PATCH] Add an explicit alertSearch cycle that loads cursors, retrieves, and advances only complete sources so scheduler integration can reuse a proven path. Treat Partner LIMIT-50 pages with a tied oldest updatedAt as incomplete so exclusive beforeDate paging cannot skip remaining rows. --- CHANGELOG.md | 7 + README.md | 2 +- package.json | 2 +- src/alertSearch/client.ts | 18 ++ src/alertSearch/cycle.test.ts | 530 +++++++++++++++++++++++++++++++ src/alertSearch/cycle.ts | 39 +++ src/alertSearch/index.ts | 3 + src/alertSearch/retrieve.test.ts | 38 +-- src/alertSearch/retrieve.ts | 16 + 9 files changed, 632 insertions(+), 23 deletions(-) create mode 100644 src/alertSearch/cycle.test.ts create mode 100644 src/alertSearch/cycle.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2145b21..80caffe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.8] - 2026.08.27 +### Added +- `runAlertSearchCycle` integration of cursor load, alertSearch retrieve, and independent cursor advance +### Changed +- Partner pages of 50 profiles that share the oldest `updatedAt` are classified incomplete so the cursor cannot skip tied rows + + ## [0.1.7] - 2026.08.27 ### Added - Persistent per-user Endorser and Partner alertSearch cursors (advanced only after complete retrievals) diff --git a/README.md b/README.md index f32853c..6d8651a 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Authorization: Bearer The **delegated** JWT is sent as `Authorization: Bearer`. Pass independent `endorserAfterId` / `partnerAfterDate` (or omit them on first run). Nearby search uses the alertSearch bbox (`minLocLat`, `maxLocLat`, `minLocLon`, `maxLocLon`). -`loadAlertSearchCursors` / `advanceAlertSearchCursors` persist those bounds per user DID in SQLite. Cursors advance only after a complete `success` retrieval (not `empty`, `pagination`, or errors). They are not wired to the scheduler yet. +`loadAlertSearchCursors` / `retrieveAlertSearch` / `advanceAlertSearchCursors` (or `runAlertSearchCycle`) persist those bounds per user DID in SQLite. Cursors advance only after a complete `success` retrieval (not `empty`, `pagination`, or errors). A Partner page of 50 rows that share the oldest `updatedAt` is `pagination` because exclusive `beforeDate` cannot drain timestamp ties. They are not wired to the scheduler yet. ## Storage diff --git a/package.json b/package.json index d03f2cf..89f18c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "notification-wakeup-service", - "version": "0.1.7", + "version": "0.1.8", "private": true, "type": "module", "packageManager": "pnpm@11.4.0", diff --git a/src/alertSearch/client.ts b/src/alertSearch/client.ts index 8dcded4..296a54a 100644 --- a/src/alertSearch/client.ts +++ b/src/alertSearch/client.ts @@ -345,3 +345,21 @@ export function minPartnerBeforeDate( dates.sort(); return dates[0]; } + +/** + * A LIMIT-50 page whose oldest updatedAt appears more than once cannot be + * drained with exclusive beforeDate: remaining rows at that timestamp are + * skipped. Detectable from the page without a rowid API. + */ +export function partnerPageHasTiedBeforeDate( + data: PartnerAlertSearchData +): boolean { + if (data.profilesNearby.length < ALERT_SEARCH_PAGE_SIZE) return false; + const min = minPartnerBeforeDate(data); + if (min === undefined) return false; + let count = 0; + for (const row of data.profilesNearby) { + if (row.updatedAt === min) count += 1; + } + return count > 1; +} diff --git a/src/alertSearch/cycle.test.ts b/src/alertSearch/cycle.test.ts new file mode 100644 index 0000000..46a75f7 --- /dev/null +++ b/src/alertSearch/cycle.test.ts @@ -0,0 +1,530 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { alertSearchCursorsDb } from "../db/alertSearchCursorsSqlite.js"; +import { closeDatabase } from "../db/sqlite.js"; +import { + partnerPageHasTiedBeforeDate, + type FetchLike, +} from "./client.js"; +import { runAlertSearchCycle } from "./cycle.js"; +import { ALERT_SEARCH_PAGE_SIZE } from "./types.js"; + +const USER = "did:ethr:0xcycleuser"; +const JWT = "delegated.jwt.token"; +const ENDORSER_BASE = "https://api.endorser.ch"; +const PARTNER_BASE = "https://partner-api.endorser.ch"; + +function ulid(n: number): string { + return `01H${String(n).padStart(23, "0")}`; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function emptyEndorserBody(overrides?: Record) { + return { + data: { + claims: [], + personalPlanContributions: [], + trackedPlanUpdates: [], + trackedPlanClaims: [], + plansNearby: [], + ...overrides, + }, + }; +} + +function emptyPartnerBody(overrides?: Record) { + return { data: { profilesNearby: [], ...overrides } }; +} + +function claimRows(from: number, count: number) { + return Array.from({ length: count }, (_, i) => ({ + id: ulid(from + i), + issuedAt: "2026-01-01T00:00:00Z", + issuer: "did:ethr:0x1", + })); +} + +function planRows(from: number, count: number) { + return Array.from({ length: count }, (_, i) => ({ + handleId: `plan-${from + i}`, + jwtId: ulid(from + i), + })); +} + +function profileRows( + count: number, + updatedAt: (index: number) => string +) { + return Array.from({ length: count }, (_, i) => ({ + issuerDid: `did:ethr:0x${i}`, + description: "p", + updatedAt: updatedAt(i), + rowId: i + 1, + })); +} + +function captureFetch( + handler: (url: string) => Response | Promise +): { fetch: FetchLike; urls: string[] } { + const urls: string[] = []; + return { + urls, + fetch: async (url) => { + urls.push(url); + return handler(url); + }, + }; +} + +const cycleOpts = { + jwt: JWT, + endorserBaseUrl: ENDORSER_BASE, + partnerBaseUrl: PARTNER_BASE, +}; + +describe("partnerPageHasTiedBeforeDate", () => { + it("is false when the page is under the server limit", () => { + assert.equal( + partnerPageHasTiedBeforeDate({ + profilesNearby: profileRows(10, () => "2026-01-01T00:00:00.000Z"), + }), + false + ); + }); + + it("is true when a full page shares the oldest updatedAt", () => { + const tied = "2026-01-01T12:00:00.000Z"; + assert.equal( + partnerPageHasTiedBeforeDate({ + profilesNearby: profileRows(ALERT_SEARCH_PAGE_SIZE, () => tied), + }), + true + ); + }); + + it("is true when two or more rows share the page minimum", () => { + const rows = profileRows(ALERT_SEARCH_PAGE_SIZE, (i) => + i < 2 ? "2026-01-01T00:00:00.000Z" : `2026-02-01T00:${String(i).padStart(2, "0")}:00.000Z` + ); + assert.equal(partnerPageHasTiedBeforeDate({ profilesNearby: rows }), true); + }); + + it("is false when the full page has a unique oldest timestamp", () => { + const rows = profileRows( + ALERT_SEARCH_PAGE_SIZE, + (i) => `2026-01-01T00:${String(i).padStart(2, "0")}:00.000Z` + ); + assert.equal(partnerPageHasTiedBeforeDate({ profilesNearby: rows }), false); + }); +}); + +describe("alertSearch cycle integration", () => { + let dir: string; + let previousDataDir: string | undefined; + + beforeEach(async () => { + previousDataDir = process.env.NOTIFY_DATA_DIR; + dir = await mkdtemp(path.join(tmpdir(), "alert-search-cycle-")); + process.env.NOTIFY_DATA_DIR = dir; + closeDatabase(); + }); + + afterEach(async () => { + closeDatabase(); + if (previousDataDir === undefined) { + delete process.env.NOTIFY_DATA_DIR; + } else { + process.env.NOTIFY_DATA_DIR = previousDataDir; + } + await rm(dir, { recursive: true, force: true }); + }); + + it("first run omits cursors and persists after a complete non-empty retrieve", async () => { + const cap = captureFetch((url) => { + if (url.includes("/api/partner/")) { + return jsonResponse( + emptyPartnerBody({ + profilesNearby: [ + { + issuerDid: "did:ethr:0x1", + description: "p", + updatedAt: "2026-03-01T00:00:00.000Z", + rowId: 1, + }, + ], + }) + ); + } + return jsonResponse( + emptyEndorserBody({ + claims: claimRows(10, 1), + trackedPlanUpdates: planRows(15, 1), + }) + ); + }); + const cycle = await runAlertSearchCycle(USER, { + ...cycleOpts, + config: { fetch: cap.fetch }, + }); + assert.equal(cycle.loaded.endorserAfterId, undefined); + assert.equal(cycle.loaded.partnerAfterDate, undefined); + for (const url of cap.urls) { + assert.equal(url.includes("afterId="), false); + assert.equal(url.includes("afterDate="), false); + } + assert.equal(cycle.retrieved.endorser.outcome, "success"); + assert.equal(cycle.retrieved.partner.outcome, "success"); + assert.equal(cycle.advanced.endorserAdvanced, true); + assert.equal(cycle.advanced.partnerAdvanced, true); + assert.equal(cycle.advanced.endorserAfterId, ulid(15)); + assert.equal(cycle.advanced.partnerAfterAt, "2026-03-01T00:00:00.000Z"); + }); + + it("loads Endorser afterId, paginates with MAX per-bucket minima, then advances to max ULID", async () => { + await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(1)); + let endorserPages = 0; + const cap = captureFetch((url) => { + if (url.includes("/api/partner/")) { + return jsonResponse(emptyPartnerBody()); + } + endorserPages += 1; + if (endorserPages === 1) { + assert.equal(url.includes(ulid(1)), true); + assert.equal(url.includes("beforeId="), false); + return jsonResponse( + emptyEndorserBody({ + claims: claimRows(200, ALERT_SEARCH_PAGE_SIZE), + personalPlanContributions: claimRows(50, ALERT_SEARCH_PAGE_SIZE), + }) + ); + } + assert.equal(url.includes(`beforeId=${ulid(200)}`), true); + assert.equal(url.includes(`beforeId=${ulid(50)}`), false); + return jsonResponse( + emptyEndorserBody({ + claims: claimRows(180, 3), + }) + ); + }); + const cycle = await runAlertSearchCycle(USER, { + ...cycleOpts, + config: { fetch: cap.fetch }, + }); + assert.equal(cycle.retrieved.endorser.outcome, "success"); + assert.equal(cycle.retrieved.endorser.pageCount, 2); + assert.equal(cycle.advanced.endorserAdvanced, true); + assert.equal(cycle.advanced.endorserAfterId, ulid(249)); + assert.equal(cycle.advanced.partnerAdvanced, false); + }); + + it("does not advance Endorser on empty retrieve", async () => { + await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(9)); + const cap = captureFetch((url) => { + if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody()); + return jsonResponse(emptyEndorserBody()); + }); + const cycle = await runAlertSearchCycle(USER, { + ...cycleOpts, + config: { fetch: cap.fetch }, + }); + assert.equal(cycle.retrieved.endorser.outcome, "empty"); + assert.equal(cycle.advanced.endorserAdvanced, false); + assert.equal(cycle.advanced.endorserAfterId, ulid(9)); + }); + + it("does not advance Endorser when plansNearby hits 50", async () => { + await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(4)); + let endorserPages = 0; + const cap = captureFetch((url) => { + if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody()); + endorserPages += 1; + if (endorserPages === 1) { + return jsonResponse( + emptyEndorserBody({ + plansNearby: planRows(300, ALERT_SEARCH_PAGE_SIZE), + }) + ); + } + return jsonResponse( + emptyEndorserBody({ plansNearby: planRows(250, 2) }) + ); + }); + const cycle = await runAlertSearchCycle(USER, { + ...cycleOpts, + config: { fetch: cap.fetch }, + }); + assert.equal(cycle.retrieved.endorser.outcome, "pagination"); + assert.equal(cycle.retrieved.data.plansNearby.length, ALERT_SEARCH_PAGE_SIZE + 2); + assert.equal(cycle.advanced.endorserAdvanced, false); + assert.equal(cycle.advanced.endorserAfterId, ulid(4)); + }); + + it("does not advance Endorser on HTTP auth failure", async () => { + await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(4)); + const cap = captureFetch((url) => { + if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody()); + return jsonResponse({ error: "Unauthorized" }, 401); + }); + const cycle = await runAlertSearchCycle(USER, { + ...cycleOpts, + config: { fetch: cap.fetch }, + }); + assert.equal(cycle.retrieved.endorser.outcome, "auth"); + assert.equal(cycle.advanced.endorserAdvanced, false); + assert.equal(cycle.advanced.endorserAfterId, ulid(4)); + }); + + it("loads Partner afterDate, paginates with beforeDate, advances max updatedAt", async () => { + await alertSearchCursorsDb.setPartnerAfterAt( + USER, + "2026-01-01T00:00:00.000Z" + ); + const page1 = profileRows( + ALERT_SEARCH_PAGE_SIZE, + (i) => `2026-02-01T00:${String(i).padStart(2, "0")}:00.000Z` + ); + const minTime = [...page1.map((p) => p.updatedAt)].sort()[0]; + let partnerPages = 0; + const cap = captureFetch((url) => { + if (url.includes("/api/v2/report/")) { + return jsonResponse(emptyEndorserBody()); + } + partnerPages += 1; + if (partnerPages === 1) { + assert.equal( + decodeURIComponent(url).includes("afterDate=2026-01-01T00:00:00.000Z"), + true + ); + return jsonResponse(emptyPartnerBody({ profilesNearby: page1 })); + } + assert.equal(decodeURIComponent(url).includes(`beforeDate=${minTime}`), true); + return jsonResponse( + emptyPartnerBody({ + profilesNearby: [ + { + issuerDid: "did:ethr:0x99", + description: "p", + updatedAt: "2026-01-15T00:00:00.000Z", + rowId: 99, + }, + ], + }) + ); + }); + const cycle = await runAlertSearchCycle(USER, { + ...cycleOpts, + config: { fetch: cap.fetch }, + }); + assert.equal(cycle.retrieved.partner.outcome, "success"); + assert.equal(cycle.retrieved.partner.pageCount, 2); + assert.equal(cycle.advanced.partnerAdvanced, true); + assert.equal(cycle.advanced.partnerAfterAt, page1.map((p) => p.updatedAt).sort()[page1.length - 1]); + }); + + it("does not advance Partner on empty retrieve", async () => { + await alertSearchCursorsDb.setPartnerAfterAt(USER, "2026-01-01T00:00:00.000Z"); + const cap = captureFetch((url) => { + if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody()); + return jsonResponse(emptyEndorserBody()); + }); + const cycle = await runAlertSearchCycle(USER, { + ...cycleOpts, + config: { fetch: cap.fetch }, + }); + assert.equal(cycle.retrieved.partner.outcome, "empty"); + assert.equal(cycle.advanced.partnerAdvanced, false); + assert.equal(cycle.advanced.partnerAfterAt, "2026-01-01T00:00:00.000Z"); + }); + + it("does not advance Partner when a full page has tied oldest updatedAt", async () => { + await alertSearchCursorsDb.setPartnerAfterAt(USER, "2026-01-01T00:00:00.000Z"); + const tied = "2026-04-01T00:00:00.000Z"; + const cap = captureFetch((url) => { + if (url.includes("/api/v2/report/")) { + return jsonResponse(emptyEndorserBody()); + } + return jsonResponse( + emptyPartnerBody({ + profilesNearby: profileRows(ALERT_SEARCH_PAGE_SIZE, () => tied), + }) + ); + }); + const cycle = await runAlertSearchCycle(USER, { + ...cycleOpts, + config: { fetch: cap.fetch }, + }); + assert.equal(cycle.retrieved.partner.outcome, "pagination"); + assert.match(cycle.retrieved.partner.message ?? "", /updatedAt/); + assert.equal( + cycle.retrieved.data.profilesNearby.length, + ALERT_SEARCH_PAGE_SIZE + ); + assert.equal(cycle.advanced.partnerAdvanced, false); + assert.equal(cycle.advanced.partnerAfterAt, "2026-01-01T00:00:00.000Z"); + }); + + it("A: both success → both advance", async () => { + const cap = captureFetch((url) => { + if (url.includes("/api/partner/")) { + return jsonResponse( + emptyPartnerBody({ + profilesNearby: [ + { + issuerDid: "did:ethr:0x1", + description: "p", + updatedAt: "2026-05-01T00:00:00.000Z", + }, + ], + }) + ); + } + return jsonResponse(emptyEndorserBody({ claims: claimRows(20, 1) })); + }); + const cycle = await runAlertSearchCycle(USER, { + ...cycleOpts, + config: { fetch: cap.fetch }, + }); + assert.equal(cycle.advanced.endorserAdvanced, true); + assert.equal(cycle.advanced.partnerAdvanced, true); + assert.equal(cycle.advanced.endorserAfterId, ulid(20)); + assert.equal(cycle.advanced.partnerAfterAt, "2026-05-01T00:00:00.000Z"); + }); + + it("B: Endorser success + Partner failure → Endorser only", async () => { + const cap = captureFetch((url) => { + if (url.includes("/api/partner/")) { + return jsonResponse({ error: "Unauthorized" }, 401); + } + return jsonResponse(emptyEndorserBody({ claims: claimRows(21, 1) })); + }); + const cycle = await runAlertSearchCycle(USER, { + ...cycleOpts, + config: { fetch: cap.fetch }, + }); + assert.equal(cycle.retrieved.endorser.outcome, "success"); + assert.equal(cycle.retrieved.partner.outcome, "auth"); + assert.equal(cycle.advanced.endorserAdvanced, true); + assert.equal(cycle.advanced.partnerAdvanced, false); + assert.equal(cycle.advanced.endorserAfterId, ulid(21)); + assert.equal(cycle.advanced.partnerAfterAt, null); + }); + + it("C: Endorser failure + Partner success → Partner only", async () => { + const cap = captureFetch((url) => { + if (url.includes("/api/partner/")) { + return jsonResponse( + emptyPartnerBody({ + profilesNearby: [ + { + issuerDid: "did:ethr:0x1", + description: "p", + updatedAt: "2026-06-01T00:00:00.000Z", + }, + ], + }) + ); + } + return jsonResponse({ error: "fail" }, 500); + }); + const cycle = await runAlertSearchCycle(USER, { + ...cycleOpts, + config: { fetch: cap.fetch }, + }); + assert.equal(cycle.retrieved.endorser.outcome, "http"); + assert.equal(cycle.retrieved.partner.outcome, "success"); + assert.equal(cycle.advanced.endorserAdvanced, false); + assert.equal(cycle.advanced.partnerAdvanced, true); + assert.equal(cycle.advanced.endorserAfterId, null); + assert.equal(cycle.advanced.partnerAfterAt, "2026-06-01T00:00:00.000Z"); + }); + + it("D: both fail → neither advances", async () => { + await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(3)); + await alertSearchCursorsDb.setPartnerAfterAt(USER, "2026-01-01T00:00:00.000Z"); + const cap = captureFetch(() => jsonResponse({ error: "down" }, 503)); + const cycle = await runAlertSearchCycle(USER, { + ...cycleOpts, + config: { fetch: cap.fetch }, + }); + assert.equal(cycle.advanced.endorserAdvanced, false); + assert.equal(cycle.advanced.partnerAdvanced, false); + assert.equal(cycle.advanced.endorserAfterId, ulid(3)); + assert.equal(cycle.advanced.partnerAfterAt, "2026-01-01T00:00:00.000Z"); + }); + + it("E: Partner incomplete + Endorser success → Endorser only", async () => { + const tied = "2026-07-01T00:00:00.000Z"; + const cap = captureFetch((url) => { + if (url.includes("/api/partner/")) { + return jsonResponse( + emptyPartnerBody({ + profilesNearby: profileRows(ALERT_SEARCH_PAGE_SIZE, () => tied), + }) + ); + } + return jsonResponse(emptyEndorserBody({ claims: claimRows(22, 1) })); + }); + const cycle = await runAlertSearchCycle(USER, { + ...cycleOpts, + config: { fetch: cap.fetch }, + }); + assert.equal(cycle.retrieved.endorser.outcome, "success"); + assert.equal(cycle.retrieved.partner.outcome, "pagination"); + assert.equal(cycle.advanced.endorserAdvanced, true); + assert.equal(cycle.advanced.partnerAdvanced, false); + assert.equal(cycle.advanced.endorserAfterId, ulid(22)); + }); + + it("persists cursors across close/reopen and sends them on the next retrieve", async () => { + const first = captureFetch((url) => { + if (url.includes("/api/partner/")) { + return jsonResponse( + emptyPartnerBody({ + profilesNearby: [ + { + issuerDid: "did:ethr:0x1", + description: "p", + updatedAt: "2026-08-01T00:00:00.000Z", + }, + ], + }) + ); + } + return jsonResponse(emptyEndorserBody({ claims: claimRows(30, 1) })); + }); + await runAlertSearchCycle(USER, { + ...cycleOpts, + config: { fetch: first.fetch }, + }); + closeDatabase(); + + const second = captureFetch((url) => { + if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody()); + return jsonResponse(emptyEndorserBody()); + }); + const cycle = await runAlertSearchCycle(USER, { + ...cycleOpts, + config: { fetch: second.fetch }, + }); + assert.equal(cycle.loaded.endorserAfterId, ulid(30)); + assert.equal(cycle.loaded.partnerAfterDate, "2026-08-01T00:00:00.000Z"); + const endorserUrl = + second.urls.find((u) => u.includes("/api/v2/report/")) ?? ""; + const partnerUrl = second.urls.find((u) => u.includes("/api/partner/")) ?? ""; + assert.equal(endorserUrl.includes(ulid(30)), true); + assert.equal( + decodeURIComponent(partnerUrl).includes("afterDate=2026-08-01T00:00:00.000Z"), + true + ); + assert.equal(partnerUrl.includes("afterId=0"), false); + }); +}); diff --git a/src/alertSearch/cycle.ts b/src/alertSearch/cycle.ts new file mode 100644 index 0000000..216f235 --- /dev/null +++ b/src/alertSearch/cycle.ts @@ -0,0 +1,39 @@ +import { + advanceAlertSearchCursors, + loadAlertSearchCursors, + type CursorAdvanceResult, +} from "./cursors.js"; +import { + retrieveAlertSearch, + type RetrieveAlertSearchInput, + type RetrieveAlertSearchResult, +} from "./retrieve.js"; + +export type AlertSearchCycleInput = Omit< + RetrieveAlertSearchInput, + "endorserAfterId" | "partnerAfterId" | "partnerAfterDate" +>; + +export type AlertSearchCycleResult = { + loaded: Awaited>; + retrieved: RetrieveAlertSearchResult; + advanced: CursorAdvanceResult; +}; + +/** + * Load stored cursors, retrieve Endorser+Partner alertSearch, then advance + * only complete source cursors. Not invoked by FCM, register, or startup. + */ +export async function runAlertSearchCycle( + userId: string, + input: AlertSearchCycleInput +): Promise { + const loaded = await loadAlertSearchCursors(userId); + const retrieved = await retrieveAlertSearch({ + ...input, + endorserAfterId: loaded.endorserAfterId, + partnerAfterDate: loaded.partnerAfterDate, + }); + const advanced = await advanceAlertSearchCursors(userId, retrieved); + return { loaded, retrieved, advanced }; +} diff --git a/src/alertSearch/index.ts b/src/alertSearch/index.ts index f573778..1ac80db 100644 --- a/src/alertSearch/index.ts +++ b/src/alertSearch/index.ts @@ -26,6 +26,7 @@ export { nextEndorserBeforeId, parseEndorserAlertSearchResponse, parsePartnerAlertSearchResponse, + partnerPageHasTiedBeforeDate, } from "./client.js"; export { retrieveAlertSearch } from "./retrieve.js"; export type { RetrieveAlertSearchInput, RetrieveAlertSearchResult } from "./retrieve.js"; @@ -36,3 +37,5 @@ export { maxPartnerUpdatedAt, } from "./cursors.js"; export type { CursorAdvanceResult, StoredAlertSearchCursors } from "./cursors.js"; +export { runAlertSearchCycle } from "./cycle.js"; +export type { AlertSearchCycleInput, AlertSearchCycleResult } from "./cycle.js"; diff --git a/src/alertSearch/retrieve.test.ts b/src/alertSearch/retrieve.test.ts index adec533..7d2f48e 100644 --- a/src/alertSearch/retrieve.test.ts +++ b/src/alertSearch/retrieve.test.ts @@ -388,7 +388,7 @@ describe("retrieveAlertSearch", () => { assert.equal(result.partner.outcome, "success"); }); - it("Partner next page uses exclusive beforeDate with no rowid tie-breaker", async () => { + it("Partner full page with tied oldest updatedAt is incomplete, not a second exclusive page", async () => { const tied = "2026-01-01T12:00:00.000Z"; let partnerPages = 0; const cap = captureFetch((url) => { @@ -396,26 +396,21 @@ describe("retrieveAlertSearch", () => { return jsonResponse(emptyEndorserBody()); } partnerPages += 1; - if (partnerPages === 1) { - return jsonResponse( - emptyPartnerBody({ - profilesNearby: Array.from( - { length: ALERT_SEARCH_PAGE_SIZE }, - (_, i) => ({ - issuerDid: `did:ethr:0x${i}`, - description: "p", - updatedAt: tied, - rowId: i, - }) - ), - }) - ); - } - const decoded = decodeURIComponent(url); - assert.equal(decoded.includes(`beforeDate=${tied}`), true); assert.equal(url.includes("rowId"), false); assert.equal(url.includes("beforeRowId"), false); - return jsonResponse(emptyPartnerBody()); + return jsonResponse( + emptyPartnerBody({ + profilesNearby: Array.from( + { length: ALERT_SEARCH_PAGE_SIZE }, + (_, i) => ({ + issuerDid: `did:ethr:0x${i}`, + description: "p", + updatedAt: tied, + rowId: i, + }) + ), + }) + ); }); const result = await retrieveAlertSearch({ jwt: JWT, @@ -423,8 +418,9 @@ describe("retrieveAlertSearch", () => { partnerBaseUrl: PARTNER_BASE, config: { fetch: cap.fetch }, }); - assert.equal(result.partner.pageCount, 2); - assert.equal(result.partner.outcome, "success"); + assert.equal(partnerPages, 1); + assert.equal(result.partner.pageCount, 1); + assert.equal(result.partner.outcome, "pagination"); assert.equal(result.data.profilesNearby.length, ALERT_SEARCH_PAGE_SIZE); }); diff --git a/src/alertSearch/retrieve.ts b/src/alertSearch/retrieve.ts index ec82a9e..60cc7f7 100644 --- a/src/alertSearch/retrieve.ts +++ b/src/alertSearch/retrieve.ts @@ -13,6 +13,7 @@ import { minPartnerBeforeDate, nextEndorserBeforeId, partnerBucketHitPageLimit, + partnerPageHasTiedBeforeDate, plansNearbyHitPageLimit, type AlertSearchHttpConfig, } from "./client.js"; @@ -33,6 +34,9 @@ const TRUNCATION_HINT = "Some data was not available in this search"; const PLANS_NEARBY_INCOMPLETE_MESSAGE = "plansNearby reached the server row limit. The alertSearch API orders that bucket by rowid while filtering jwtId, so remaining nearby plans cannot be proven complete."; +const PARTNER_TIMESTAMP_TIE_MESSAGE = + "Partner alertSearch page hit the row limit with multiple profiles sharing the oldest updatedAt. Exclusive beforeDate pagination cannot retrieve remaining rows at that timestamp."; + export type AlertSearchSourceResult = { outcome: AlertSearchQueryOutcome; reason?: AlertSearchFailureReason; @@ -291,6 +295,18 @@ async function retrievePartnerPages(input: { : result.body.userMessage; } data = mergePartner(data, result.body.data); + if (partnerBucketHitPageLimit(result.body.data)) { + if (partnerPageHasTiedBeforeDate(result.body.data)) { + return { + outcome: "pagination", + reason: "pagination", + message: PARTNER_TIMESTAMP_TIE_MESSAGE, + userMessage, + pageCount, + data, + }; + } + } if (!partnerBucketHitPageLimit(result.body.data)) { return { outcome: partnerEmpty(data) ? "empty" : "success",