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.
This commit is contained in:
@@ -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).
|
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
|
## [0.1.7] - 2026.08.27
|
||||||
### Added
|
### Added
|
||||||
- Persistent per-user Endorser and Partner alertSearch cursors (advanced only after complete retrievals)
|
- Persistent per-user Endorser and Partner alertSearch cursors (advanced only after complete retrievals)
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ Authorization: Bearer <current-user-JWT>
|
|||||||
|
|
||||||
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`).
|
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
|
## Storage
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "notification-wakeup-service",
|
"name": "notification-wakeup-service",
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@11.4.0",
|
"packageManager": "pnpm@11.4.0",
|
||||||
|
|||||||
@@ -345,3 +345,21 @@ export function minPartnerBeforeDate(
|
|||||||
dates.sort();
|
dates.sort();
|
||||||
return dates[0];
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<string, unknown>) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
claims: [],
|
||||||
|
personalPlanContributions: [],
|
||||||
|
trackedPlanUpdates: [],
|
||||||
|
trackedPlanClaims: [],
|
||||||
|
plansNearby: [],
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyPartnerBody(overrides?: Record<string, unknown>) {
|
||||||
|
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<Response>
|
||||||
|
): { 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<ReturnType<typeof loadAlertSearchCursors>>;
|
||||||
|
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<AlertSearchCycleResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ export {
|
|||||||
nextEndorserBeforeId,
|
nextEndorserBeforeId,
|
||||||
parseEndorserAlertSearchResponse,
|
parseEndorserAlertSearchResponse,
|
||||||
parsePartnerAlertSearchResponse,
|
parsePartnerAlertSearchResponse,
|
||||||
|
partnerPageHasTiedBeforeDate,
|
||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
export { retrieveAlertSearch } from "./retrieve.js";
|
export { retrieveAlertSearch } from "./retrieve.js";
|
||||||
export type { RetrieveAlertSearchInput, RetrieveAlertSearchResult } from "./retrieve.js";
|
export type { RetrieveAlertSearchInput, RetrieveAlertSearchResult } from "./retrieve.js";
|
||||||
@@ -36,3 +37,5 @@ export {
|
|||||||
maxPartnerUpdatedAt,
|
maxPartnerUpdatedAt,
|
||||||
} from "./cursors.js";
|
} from "./cursors.js";
|
||||||
export type { CursorAdvanceResult, StoredAlertSearchCursors } from "./cursors.js";
|
export type { CursorAdvanceResult, StoredAlertSearchCursors } from "./cursors.js";
|
||||||
|
export { runAlertSearchCycle } from "./cycle.js";
|
||||||
|
export type { AlertSearchCycleInput, AlertSearchCycleResult } from "./cycle.js";
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ describe("retrieveAlertSearch", () => {
|
|||||||
assert.equal(result.partner.outcome, "success");
|
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";
|
const tied = "2026-01-01T12:00:00.000Z";
|
||||||
let partnerPages = 0;
|
let partnerPages = 0;
|
||||||
const cap = captureFetch((url) => {
|
const cap = captureFetch((url) => {
|
||||||
@@ -396,26 +396,21 @@ describe("retrieveAlertSearch", () => {
|
|||||||
return jsonResponse(emptyEndorserBody());
|
return jsonResponse(emptyEndorserBody());
|
||||||
}
|
}
|
||||||
partnerPages += 1;
|
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("rowId"), false);
|
||||||
assert.equal(url.includes("beforeRowId"), 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({
|
const result = await retrieveAlertSearch({
|
||||||
jwt: JWT,
|
jwt: JWT,
|
||||||
@@ -423,8 +418,9 @@ describe("retrieveAlertSearch", () => {
|
|||||||
partnerBaseUrl: PARTNER_BASE,
|
partnerBaseUrl: PARTNER_BASE,
|
||||||
config: { fetch: cap.fetch },
|
config: { fetch: cap.fetch },
|
||||||
});
|
});
|
||||||
assert.equal(result.partner.pageCount, 2);
|
assert.equal(partnerPages, 1);
|
||||||
assert.equal(result.partner.outcome, "success");
|
assert.equal(result.partner.pageCount, 1);
|
||||||
|
assert.equal(result.partner.outcome, "pagination");
|
||||||
assert.equal(result.data.profilesNearby.length, ALERT_SEARCH_PAGE_SIZE);
|
assert.equal(result.data.profilesNearby.length, ALERT_SEARCH_PAGE_SIZE);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
minPartnerBeforeDate,
|
minPartnerBeforeDate,
|
||||||
nextEndorserBeforeId,
|
nextEndorserBeforeId,
|
||||||
partnerBucketHitPageLimit,
|
partnerBucketHitPageLimit,
|
||||||
|
partnerPageHasTiedBeforeDate,
|
||||||
plansNearbyHitPageLimit,
|
plansNearbyHitPageLimit,
|
||||||
type AlertSearchHttpConfig,
|
type AlertSearchHttpConfig,
|
||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
@@ -33,6 +34,9 @@ const TRUNCATION_HINT = "Some data was not available in this search";
|
|||||||
const PLANS_NEARBY_INCOMPLETE_MESSAGE =
|
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.";
|
"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<TData> = {
|
export type AlertSearchSourceResult<TData> = {
|
||||||
outcome: AlertSearchQueryOutcome;
|
outcome: AlertSearchQueryOutcome;
|
||||||
reason?: AlertSearchFailureReason;
|
reason?: AlertSearchFailureReason;
|
||||||
@@ -291,6 +295,18 @@ async function retrievePartnerPages(input: {
|
|||||||
: result.body.userMessage;
|
: result.body.userMessage;
|
||||||
}
|
}
|
||||||
data = mergePartner(data, result.body.data);
|
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)) {
|
if (!partnerBucketHitPageLimit(result.body.data)) {
|
||||||
return {
|
return {
|
||||||
outcome: partnerEmpty(data) ? "empty" : "success",
|
outcome: partnerEmpty(data) ? "empty" : "success",
|
||||||
|
|||||||
Reference in New Issue
Block a user