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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -29,6 +29,7 @@ function stubDailyResult(userId: string): DailyAlertSearchResult {
|
||||
partnerOutcome: null,
|
||||
completed: false,
|
||||
consumed: false,
|
||||
digest: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user