Fix Endorser alertSearch pagination so the next beforeId is the max of per-bucket minima, not the global min.

Treat a full plansNearby page as incomplete because the server orders that bucket by rowid while filtering jwtId.
This commit is contained in:
Jose Olarte III
2026-08-27 16:34:36 +08:00
parent 74e9c02669
commit caeca10910
5 changed files with 225 additions and 36 deletions
+2
View File
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.1.6] - 2026.08.27
### Added
- AlertSearch retrieval layer for Endorser and Partner GET endpoints (pagination, independent cursors; not scheduled yet)
### Changed
- Endorser in-run beforeId is MAX of per-bucket minima; plansNearby hitting the row limit is classified incomplete
## [0.1.5] - 2026.08.26
+44 -32
View File
@@ -285,41 +285,53 @@ export function partnerBucketHitPageLimit(
return data.profilesNearby.length >= ALERT_SEARCH_PAGE_SIZE;
}
export function minEndorserBeforeId(
function minUlid(ids: unknown[]): string | undefined {
const values = ids.filter(
(id): id is string => typeof id === "string" && id.length > 0
);
if (values.length === 0) return undefined;
values.sort();
return values[0];
}
/**
* Next shared Endorser beforeId: MAX of the per-bucket minima for buckets
* that hit LIMIT 50. A global min would skip remaining rows in higher-ID
* truncated buckets.
*/
export function nextEndorserBeforeId(
data: EndorserAlertSearchData
): string | undefined {
const ids: string[] = [];
const takeJwtIdBuckets: Array<{ length: number; jwtIds: Array<unknown> }> = [
{
length: data.claims.length,
jwtIds: data.claims.map((r) => r.id),
},
{
length: data.personalPlanContributions.length,
jwtIds: data.personalPlanContributions.map((r) => r.id),
},
{
length: data.trackedPlanClaims.length,
jwtIds: data.trackedPlanClaims.map((r) => r.id),
},
{
length: data.trackedPlanUpdates.length,
jwtIds: data.trackedPlanUpdates.map((r) => r.jwtId),
},
{
length: data.plansNearby.length,
jwtIds: data.plansNearby.map((r) => r.jwtId),
},
];
for (const bucket of takeJwtIdBuckets) {
if (bucket.length < ALERT_SEARCH_PAGE_SIZE) continue;
for (const id of bucket.jwtIds) {
if (typeof id === "string" && id.length > 0) ids.push(id);
}
const truncatedMins: string[] = [];
const truncatedBuckets: unknown[][] = [];
if (data.claims.length >= ALERT_SEARCH_PAGE_SIZE) {
truncatedBuckets.push(data.claims.map((r) => r.id));
}
if (ids.length === 0) return undefined;
ids.sort();
return ids[0];
if (data.personalPlanContributions.length >= ALERT_SEARCH_PAGE_SIZE) {
truncatedBuckets.push(data.personalPlanContributions.map((r) => r.id));
}
if (data.trackedPlanClaims.length >= ALERT_SEARCH_PAGE_SIZE) {
truncatedBuckets.push(data.trackedPlanClaims.map((r) => r.id));
}
if (data.trackedPlanUpdates.length >= ALERT_SEARCH_PAGE_SIZE) {
truncatedBuckets.push(data.trackedPlanUpdates.map((r) => r.jwtId));
}
if (data.plansNearby.length >= ALERT_SEARCH_PAGE_SIZE) {
truncatedBuckets.push(data.plansNearby.map((r) => r.jwtId));
}
for (const ids of truncatedBuckets) {
const min = minUlid(ids);
if (min !== undefined) truncatedMins.push(min);
}
if (truncatedMins.length === 0) return undefined;
truncatedMins.sort();
return truncatedMins[truncatedMins.length - 1];
}
export function plansNearbyHitPageLimit(
data: EndorserAlertSearchData
): boolean {
return data.plansNearby.length >= ALERT_SEARCH_PAGE_SIZE;
}
export function minPartnerBeforeDate(
+1
View File
@@ -23,6 +23,7 @@ export {
export {
fetchEndorserAlertSearchPage,
fetchPartnerAlertSearchPage,
nextEndorserBeforeId,
parseEndorserAlertSearchResponse,
parsePartnerAlertSearchResponse,
} from "./client.js";
+155 -1
View File
@@ -3,10 +3,11 @@ import { describe, it } from "node:test";
import {
fetchEndorserAlertSearchPage,
fetchPartnerAlertSearchPage,
nextEndorserBeforeId,
type FetchLike,
} from "./client.js";
import { retrieveAlertSearch } from "./retrieve.js";
import { ALERT_SEARCH_PAGE_SIZE } from "./types.js";
import { ALERT_SEARCH_PAGE_SIZE, type EndorserAlertSearchData } from "./types.js";
const JWT = "delegated.jwt.token";
const ENDORSER_BASE = "https://api.endorser.ch";
@@ -61,6 +62,21 @@ function ulid(n: number): string {
return `01H${String(n).padStart(23, "0")}`;
}
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),
}));
}
describe("alertSearch HTTP pages", () => {
it("sends the delegated JWT as Bearer on Endorser GET", async () => {
const cap = captureFetch(() => jsonResponse(emptyEndorserBody()));
@@ -143,6 +159,33 @@ describe("alertSearch HTTP pages", () => {
});
});
describe("nextEndorserBeforeId", () => {
it("uses MAX of per-bucket minima when multiple buckets hit 50", () => {
const data: EndorserAlertSearchData = {
claims: claimRows(200, ALERT_SEARCH_PAGE_SIZE),
personalPlanContributions: claimRows(50, ALERT_SEARCH_PAGE_SIZE),
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
};
const claimsMin = ulid(200);
const contributionsMin = ulid(50);
assert.equal(nextEndorserBeforeId(data), claimsMin);
assert.notEqual(nextEndorserBeforeId(data), contributionsMin);
});
it("uses the single truncated bucket min", () => {
const data: EndorserAlertSearchData = {
claims: claimRows(100, ALERT_SEARCH_PAGE_SIZE),
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
};
assert.equal(nextEndorserBeforeId(data), ulid(100));
});
});
describe("retrieveAlertSearch", () => {
it("first run omits afterId on both hosts", async () => {
const cap = captureFetch((url) => {
@@ -236,6 +279,77 @@ describe("retrieveAlertSearch", () => {
assert.equal(result.data.claims.length, ALERT_SEARCH_PAGE_SIZE + 1);
});
it("uses MAX of per-bucket minima as shared beforeId when two buckets hit 50", async () => {
const claimsMin = ulid(200);
const contributionsMin = ulid(50);
let endorserPages = 0;
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) {
return jsonResponse(emptyPartnerBody());
}
endorserPages += 1;
if (endorserPages === 1) {
return jsonResponse(
emptyEndorserBody({
claims: claimRows(200, ALERT_SEARCH_PAGE_SIZE),
personalPlanContributions: claimRows(50, ALERT_SEARCH_PAGE_SIZE),
})
);
}
assert.equal(url.includes(`beforeId=${claimsMin}`), true);
assert.equal(url.includes(`beforeId=${contributionsMin}`), false);
return jsonResponse(
emptyEndorserBody({
claims: claimRows(180, 5),
})
);
});
const result = await retrieveAlertSearch({
jwt: JWT,
endorserBaseUrl: ENDORSER_BASE,
partnerBaseUrl: PARTNER_BASE,
config: { fetch: cap.fetch },
});
assert.equal(result.endorser.outcome, "success");
assert.equal(result.endorser.pageCount, 2);
assert.equal(result.data.claims.length, ALERT_SEARCH_PAGE_SIZE + 5);
assert.equal(result.data.personalPlanContributions.length, ALERT_SEARCH_PAGE_SIZE);
});
it("classifies plansNearby hitting 50 as incomplete pagination and keeps records", async () => {
let endorserPages = 0;
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) {
return jsonResponse(emptyPartnerBody());
}
endorserPages += 1;
if (endorserPages === 1) {
return jsonResponse(
emptyEndorserBody({
claims: claimRows(1, 3),
plansNearby: planRows(300, ALERT_SEARCH_PAGE_SIZE),
})
);
}
return jsonResponse(
emptyEndorserBody({
plansNearby: planRows(250, 4),
})
);
});
const result = await retrieveAlertSearch({
jwt: JWT,
endorserBaseUrl: ENDORSER_BASE,
partnerBaseUrl: PARTNER_BASE,
config: { fetch: cap.fetch },
});
assert.equal(result.endorser.outcome, "pagination");
assert.equal(result.endorser.reason, "pagination");
assert.match(result.endorser.message ?? "", /plansNearby/);
assert.equal(result.data.plansNearby.length, ALERT_SEARCH_PAGE_SIZE + 4);
assert.equal(result.data.claims.length, 3);
});
it("paginates Partner with beforeDate independently", async () => {
const times = Array.from(
{ length: ALERT_SEARCH_PAGE_SIZE },
@@ -274,6 +388,46 @@ describe("retrieveAlertSearch", () => {
assert.equal(result.partner.outcome, "success");
});
it("Partner next page uses exclusive beforeDate with no rowid tie-breaker", async () => {
const tied = "2026-01-01T12:00:00.000Z";
let partnerPages = 0;
const cap = captureFetch((url) => {
if (url.includes("/api/v2/report/")) {
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());
});
const result = await retrieveAlertSearch({
jwt: JWT,
endorserBaseUrl: ENDORSER_BASE,
partnerBaseUrl: PARTNER_BASE,
config: { fetch: cap.fetch },
});
assert.equal(result.partner.pageCount, 2);
assert.equal(result.partner.outcome, "success");
assert.equal(result.data.profilesNearby.length, ALERT_SEARCH_PAGE_SIZE);
});
it("returns success with empty buckets", async () => {
const cap = captureFetch((url) => {
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
+23 -3
View File
@@ -10,9 +10,10 @@ import {
endorserBucketHitPageLimit,
fetchEndorserAlertSearchPage,
fetchPartnerAlertSearchPage,
minEndorserBeforeId,
minPartnerBeforeDate,
nextEndorserBeforeId,
partnerBucketHitPageLimit,
plansNearbyHitPageLimit,
type AlertSearchHttpConfig,
} from "./client.js";
import type { AlertSearchQueryInput } from "./params.js";
@@ -29,6 +30,9 @@ const DEFAULT_MAX_PAGES = 20;
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.";
export type AlertSearchSourceResult<TData> = {
outcome: AlertSearchQueryOutcome;
reason?: AlertSearchFailureReason;
@@ -150,6 +154,7 @@ async function retrieveEndorserPages(input: {
let beforeId: string | undefined;
let userMessage: string | undefined;
let pageCount = 0;
let plansNearbyTruncated = false;
for (let page = 0; page < input.maxPages; page++) {
const query: AlertSearchQueryInput = {
@@ -192,7 +197,20 @@ async function retrieveEndorserPages(input: {
};
}
data = mergeEndorser(data, result.body.data);
if (plansNearbyHitPageLimit(result.body.data)) {
plansNearbyTruncated = true;
}
if (!endorserBucketHitPageLimit(result.body.data)) {
if (plansNearbyTruncated) {
return {
outcome: "pagination",
reason: "pagination",
message: PLANS_NEARBY_INCOMPLETE_MESSAGE,
userMessage,
pageCount,
data,
};
}
return {
outcome: endorserEmpty(data) ? "empty" : "success",
userMessage,
@@ -200,7 +218,7 @@ async function retrieveEndorserPages(input: {
data,
};
}
const nextBefore = minEndorserBeforeId(result.body.data);
const nextBefore = nextEndorserBeforeId(result.body.data);
if (nextBefore === undefined) {
return {
outcome: "pagination",
@@ -218,7 +236,9 @@ async function retrieveEndorserPages(input: {
return {
outcome: "pagination",
reason: "pagination",
message: `Endorser alertSearch stopped after ${input.maxPages} pages.`,
message: plansNearbyTruncated
? PLANS_NEARBY_INCOMPLETE_MESSAGE
: `Endorser alertSearch stopped after ${input.maxPages} pages.`,
userMessage,
pageCount,
data,