Attach the alertSearch digest to each daily run so a completed retrieve is notification-ready without changing JWT consumption or cursor rules.

This commit is contained in:
Jose Olarte III
2026-08-31 20:44:04 +08:00
parent 49f50e36b7
commit 10e80c6f41
7 changed files with 65 additions and 10 deletions
+5
View File
@@ -6,6 +6,11 @@ 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.12] - 2026.08.31
### Added
- `runDailyAlertSearch` includes a Phase 6A `digest` on retrieval attempts (`null` when no batch or today's JWT is missing); JWT consumption is still based on both sources completing, not `hasUpdates`
## [0.1.11] - 2026.08.28
### Added
- `buildAlertSearchDigest` turns a completed Endorser+Partner alertSearch retrieve into structured per-bucket records and counts for a future app/plugin payload (no notification message or FCM send)
+1 -1
View File
@@ -75,7 +75,7 @@ The **delegated** JWT is sent as `Authorization: Bearer`. Pass independent `endo
`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.
`runDailyAlertSearch(userId, now?)` uses the latest batch's stored IANA timezone to pick today's unused delegated JWT, runs `runAlertSearchCycle` with that JWT, and marks that specific JWT consumed only when both Endorser and Partner complete (`success` or `empty`, including both empty). Pagination or source failures leave the JWT unused so the same day can be retried. An invalid stored timezone is an error; there is no fallback to the server timezone.
`runDailyAlertSearch(userId, now?)` uses the latest batch's stored IANA timezone to pick today's unused delegated JWT, runs `runAlertSearchCycle` with that JWT, and marks that specific JWT consumed only when both Endorser and Partner complete (`success` or `empty`, including both empty). Pagination or source failures leave the JWT unused so the same day can be retried. An invalid stored timezone is an error; there is no fallback to the server timezone. After a retrieve, the result includes `digest` from `buildAlertSearchDigest` (six bucket records and counts). `digest` is `null` when there is no batch or no unused JWT for today. Consumption does not depend on `digest.hasUpdates`.
`startAlertSearchScheduler()` (started from `src/index.ts` next to the FCM scheduler) is a **separate** user-level job. It lists distinct `userId`s from `alert_authorization_batches` and calls `runDailyAlertSearch` once per user. It does not use `fcm_registrations`, does not call `sendPushToDevice`, and does not change `WAKEUP_PING`. A process-local in-flight flag skips a tick if a pass is still running.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "notification-wakeup-service",
"version": "0.1.11",
"version": "0.1.12",
"private": true,
"type": "module",
"packageManager": "pnpm@11.4.0",
+39 -1
View File
@@ -244,6 +244,7 @@ describe("runDailyAlertSearch", () => {
assert.equal(result.partnerOutcome, null);
assert.equal(result.completed, false);
assert.equal(result.consumed, false);
assert.equal(result.digest, null);
});
it("returns a structured no-JWT result when the user has no batch", async () => {
@@ -253,6 +254,7 @@ describe("runDailyAlertSearch", () => {
assert.equal(result.jwtSequence, null);
assert.equal(result.completed, false);
assert.equal(result.consumed, false);
assert.equal(result.digest, null);
});
it("passes today's JWT to runAlertSearchCycle", async () => {
@@ -261,13 +263,16 @@ describe("runDailyAlertSearch", () => {
jwtInput(2, DAY_AUCKLAND, JWT_AUCKLAND),
]);
const cap = bothSuccessFetch();
await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
assert.ok(cap.urls.some((u) => u.includes("/api/v2/report/alertSearch")));
assert.ok(cap.urls.some((u) => u.includes("/api/partner/alertSearch")));
assert.ok(cap.auths.length >= 2);
for (const auth of cap.auths) {
assert.equal(auth, `Bearer ${JWT_LA}`);
}
assert.ok(result.digest);
assert.equal(result.digest.hasUpdates, true);
assert.equal(result.jwtSequence, 1);
});
it("consumes today's JWT when both sources succeed", async () => {
@@ -281,6 +286,15 @@ describe("runDailyAlertSearch", () => {
assert.equal(result.partnerOutcome, "success");
assert.equal(result.completed, true);
assert.equal(result.consumed, true);
assert.ok(result.digest);
assert.equal(result.digest.completed, true);
assert.equal(result.digest.hasUpdates, true);
assert.equal(result.digest.totalCount, 2);
assert.equal(result.digest.counts.claims, 1);
assert.equal(result.digest.counts.profilesNearby, 1);
assert.equal(result.digest.records.claims.length, 1);
assert.equal(result.digest.records.claims[0].id, ulid(10));
assert.equal(result.digest.records.profilesNearby[0].updatedAt, "2026-03-01T00:00:00.000Z");
const leftover = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA);
assert.equal(leftover, undefined);
});
@@ -296,6 +310,10 @@ describe("runDailyAlertSearch", () => {
assert.equal(result.partnerOutcome, "empty");
assert.equal(result.completed, true);
assert.equal(result.consumed, true);
assert.ok(result.digest);
assert.equal(result.digest.completed, true);
assert.equal(result.digest.hasUpdates, false);
assert.equal(result.digest.totalCount, 0);
});
it("does not consume when Endorser succeeds and Partner fails", async () => {
@@ -311,6 +329,11 @@ describe("runDailyAlertSearch", () => {
assert.equal(result.partnerOutcome, "auth");
assert.equal(result.completed, false);
assert.equal(result.consumed, false);
assert.ok(result.digest);
assert.equal(result.digest.completed, false);
assert.equal(result.digest.hasUpdates, false);
assert.equal(result.digest.records.claims.length, 1);
assert.equal(result.digest.records.claims[0].id, ulid(10));
const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA);
assert.equal(unused?.jwt, JWT_LA);
const stored = await alertSearchCursorsDb.get(USER);
@@ -342,6 +365,10 @@ describe("runDailyAlertSearch", () => {
assert.equal(result.partnerOutcome, "success");
assert.equal(result.completed, false);
assert.equal(result.consumed, false);
assert.ok(result.digest);
assert.equal(result.digest.completed, false);
assert.equal(result.digest.hasUpdates, false);
assert.equal(result.digest.records.profilesNearby.length, 1);
const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA);
assert.equal(unused?.status, ALERT_JWT_STATUS_UNUSED);
const stored = await alertSearchCursorsDb.get(USER);
@@ -364,6 +391,10 @@ describe("runDailyAlertSearch", () => {
assert.equal(result.partnerOutcome, "empty");
assert.equal(result.completed, false);
assert.equal(result.consumed, false);
assert.ok(result.digest);
assert.equal(result.digest.completed, false);
assert.equal(result.digest.hasUpdates, false);
assert.equal(result.digest.endorser.outcome, "pagination");
});
it("does not consume on Partner pagination", async () => {
@@ -384,6 +415,9 @@ describe("runDailyAlertSearch", () => {
assert.equal(result.partnerOutcome, "pagination");
assert.equal(result.completed, false);
assert.equal(result.consumed, false);
assert.ok(result.digest);
assert.equal(result.digest.completed, false);
assert.equal(result.digest.partner.outcome, "pagination");
});
it("does not consume on network, auth, timeout, malformed, or http failure", async () => {
@@ -433,6 +467,9 @@ describe("runDailyAlertSearch", () => {
assert.equal(result.partnerOutcome, c.expected, c.name);
assert.equal(result.completed, false, c.name);
assert.equal(result.consumed, false, c.name);
assert.ok(result.digest, c.name);
assert.equal(result.digest.completed, false, c.name);
assert.equal(result.digest.hasUpdates, false, c.name);
const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_LA);
assert.equal(unused?.jwt, JWT_LA, c.name);
}
@@ -479,6 +516,7 @@ describe("runDailyAlertSearch", () => {
assert.equal(second.endorserOutcome, null);
assert.equal(second.completed, false);
assert.equal(second.consumed, false);
assert.equal(second.digest, null);
assert.equal(await alertAuthorizationDb.countUnused(USER), 0);
});
+11 -6
View File
@@ -7,6 +7,11 @@ import {
runAlertSearchCycle,
type AlertSearchCycleInput,
} from "./cycle.js";
import {
buildAlertSearchDigest,
sourceCompletedDailyRun,
type AlertSearchDigest,
} from "./digest.js";
import type { AlertSearchQueryOutcome } from "./types.js";
export class InvalidAlertAuthorizationTimezoneError extends Error {
@@ -21,12 +26,7 @@ export class InvalidAlertAuthorizationTimezoneError extends Error {
}
}
/** Retrieval finished for the daily run (cursor advance still follows Phase 4B). */
export function sourceCompletedDailyRun(
outcome: AlertSearchQueryOutcome
): boolean {
return outcome === "success" || outcome === "empty";
}
export { sourceCompletedDailyRun };
export type DailyAlertSearchCycleInput = Omit<AlertSearchCycleInput, "jwt">;
@@ -39,6 +39,8 @@ export type DailyAlertSearchResult = {
partnerOutcome: AlertSearchQueryOutcome | null;
completed: boolean;
consumed: boolean;
/** Phase 6A digest after a retrieve; null when no batch/JWT was used. */
digest: AlertSearchDigest | null;
};
function noJwtResult(
@@ -55,6 +57,7 @@ function noJwtResult(
partnerOutcome: null,
completed: false,
consumed: false,
digest: null,
};
}
@@ -90,6 +93,7 @@ export async function runDailyAlertSearch(
jwt: selected.jwt,
});
const digest = buildAlertSearchDigest(cycle.retrieved);
const endorserOutcome = cycle.retrieved.endorser.outcome;
const partnerOutcome = cycle.retrieved.partner.outcome;
const completed =
@@ -113,5 +117,6 @@ export async function runDailyAlertSearch(
partnerOutcome,
completed,
consumed,
digest,
};
}
+7 -1
View File
@@ -1,4 +1,3 @@
import { sourceCompletedDailyRun } from "./daily.js";
import type {
AlertSearchSourceResult,
RetrieveAlertSearchResult,
@@ -11,6 +10,13 @@ import type {
PartnerAlertSearchData,
} from "./types.js";
/** Retrieval finished for the daily run (cursor advance still follows Phase 4B). */
export function sourceCompletedDailyRun(
outcome: AlertSearchQueryOutcome
): boolean {
return outcome === "success" || outcome === "empty";
}
export const ALERT_SEARCH_DIGEST_BUCKETS = [
"claims",
"personalPlanContributions",
+1
View File
@@ -29,6 +29,7 @@ function stubDailyResult(userId: string): DailyAlertSearchResult {
partnerOutcome: null,
completed: false,
consumed: false,
digest: null,
};
}