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:
@@ -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
@@ -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(
|
||||
|
||||
@@ -23,6 +23,7 @@ export {
|
||||
export {
|
||||
fetchEndorserAlertSearchPage,
|
||||
fetchPartnerAlertSearchPage,
|
||||
nextEndorserBeforeId,
|
||||
parseEndorserAlertSearchResponse,
|
||||
parsePartnerAlertSearchResponse,
|
||||
} from "./client.js";
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user