diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 078695c5d..25b9708e0 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,12 +1,12 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 124, - "total": 147278, + "total": 147270, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, "app/api/insights/generate/route.ts": 1209, - "app/api/measurements/batch/route.ts": 1569, + "app/api/measurements/batch/route.ts": 1562, "app/api/measurements/route.ts": 938, "app/api/medications/[id]/route.ts": 1281, "app/api/medications/intake/bulk/route.ts": 1044, @@ -46,7 +46,7 @@ "lib/ai/coach/results/metric-table-tool.ts": 1102, "lib/ai/coach/snapshot.ts": 1362, "lib/ai/coach/stream-events.ts": 817, - "lib/ai/coach/tools/executor.ts": 1523, + "lib/ai/coach/tools/executor.ts": 1522, "lib/ai/coach/turn/model.ts": 820, "lib/ai/coach/types.ts": 1011, "lib/ai/codex-client.ts": 1094, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index e2ad0f8c9..ef058a2e5 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 410, - "total": 148056, + "total": 148049, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -38,7 +38,7 @@ "app/api/mcp/oauth/token/route.ts :: POST > withBackgroundEvent(arg)": 248, "app/api/measurement-reminders/[id]/route.ts :: apiHandler(arg) #2": 240, "app/api/measurements/[id]/route.ts :: apiHandler(arg) #2": 203, - "app/api/measurements/batch/route.ts :: postBatch": 1262, + "app/api/measurements/batch/route.ts :: postBatch": 1255, "app/api/measurements/route.ts :: apiHandler(arg)": 286, "app/api/measurements/route.ts :: postMeasurement": 377, "app/api/measurements/series/route.ts :: apiHandler(arg)": 549, diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 7a0cfbb4d..d634884bc 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -88,7 +88,7 @@ import { BACKFILL_SYNC_TRIGGER, HEALTHKIT_REQUEST_SYNC_TRIGGERS, recordHealthKitTypeArrivals, - type HealthKitTypeArrival, + tallyHealthKitArrivals, } from "@/lib/integrations/healthkit-type-sync"; import { classifyHourBucket, @@ -657,11 +657,7 @@ async function postBatch(request: NextRequest): Promise { * and in nothing that starts work. */ let unchangedStatsCount = 0; - /** - * The `stats:` re-posts answered `updated` without a write because nothing - * changed. Not an arrival of their type (#1259). - */ - const unchangedStatsRows = new Set(); + const unchangedStatsRows = new Set(); // #1259: not an arrival let duplicateCount = 0; let failedCount = 0; // v1.44 — subset of `insertedCount` written by the set-based insert. @@ -1109,8 +1105,7 @@ async function postBatch(request: NextRequest): Promise { let hourInsertedCount = 0; let hourUpdatedCount = 0; const hourLanded: HourBucketWrite[] = []; - /** The landed hours that wrote a new or different value (#1259). */ - const hourChanged = new Set(); + const hourChanged = new Set(); // #1259: a new value if (hourWrites.length > 0 && accountTz) { const { writes, superseded } = combineHourWrites(hourWrites); for (const index of superseded) { @@ -1131,13 +1126,10 @@ async function postBatch(request: NextRequest): Promise { ); for (const { write, contributors } of writes) { const status = statuses.get(write.index) ?? "inserted"; + // An unchanged hour still reads `updated` on the wire (#1259). + const wire = status === "inserted" ? "inserted" : "updated"; for (const index of contributors) { - // An hour posted again with the same value reads `updated` on the - // wire, as before; only the arrival ledger tells the two apart. - results[index] = { - index, - status: status === "inserted" ? "inserted" : "updated", - }; + results[index] = { index, status: wire }; if (status === "inserted") hourInsertedCount++; else hourUpdatedCount++; } @@ -1223,40 +1215,20 @@ async function postBatch(request: NextRequest): Promise { const syncedAt = new Date(); const deliveredWithoutTheApp = syncTrigger === "background" || syncTrigger === "push"; - // v1.42 (#1173) — what this sync brought per type, for the per-type - // freshness list on the Apple Health card. Apple Health rows only, and - // under the same conditions as the checkpoint beside it. Since #1259 only - // a new or changed row makes a type arrive: a duplicate, an unchanged - // `stats:` re-post and an hour posted again with its stored value are - // carried (`accepted`) but bring nothing (`newSamples`), so a replay or a - // retry cannot make a stalled type look live. - const arrivals = new Map(); - for (const p of prepared) { - if (p.row.source !== "APPLE_HEALTH") continue; - const status = results[p.index]?.status; - if ( - status !== "inserted" && - status !== "updated" && - status !== "duplicate" - ) { - continue; - } - const type = p.row.type as MeasurementType; - const entry = arrivals.get(type) ?? { accepted: 0, newSamples: 0 }; - entry.accepted += 1; - if (status !== "duplicate" && !unchangedStatsRows.has(p)) { - entry.newSamples += 1; - } - arrivals.set(type, entry); - } - // The hours the phone posted are carried by their type as well, and - // bring something only when the slot's value changed. - for (const w of hourLanded) { - const entry = arrivals.get(w.type) ?? { accepted: 0, newSamples: 0 }; - entry.accepted += 1; - if (hourChanged.has(w)) entry.newSamples += 1; - arrivals.set(w.type, entry); - } + // v1.42 (#1173) — per-type arrivals for the Apple Health card, Apple + // Health rows and hours only, under the checkpoint's conditions. Since + // #1259 only a new or changed one brings something (`brought`). + const arrivals = tallyHealthKitArrivals([ + ...prepared.flatMap((p) => { + const status = results[p.index]?.status; + if (p.row.source !== "APPLE_HEALTH" || !status) return []; + if (status === "skipped" || status === "failed") return []; + const unchanged = unchangedStatsRows.has(p); + const brought = status !== "duplicate" && !unchanged; + return [{ type: p.row.type as MeasurementType, brought }]; + }), + ...hourLanded.map((w) => ({ type: w.type, brought: hourChanged.has(w) })), + ]); if (arrivals.size > 0) { await recordHealthKitTypeArrivals(prisma, { userId: user.id, diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index e2b0b2029..7e6f9a453 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -1008,9 +1008,8 @@ async function postBulk(request: NextRequest): Promise { // trigger, when a sync last brought a new or changed dose, and when a // background sync last carried them. Entries sent with `source: // "APPLE_HEALTH"` only; a dose taken in the app is not an Apple Health - // arrival. A batch of replays answered `duplicate` alone is not an arrival - // (#1259): it stamps only `lastSeenDuplicateAt`, the same rule as the batch - // route. + // arrival. Replays answered `duplicate` alone are not one (#1259), the + // same rule as the batch route. const doseArrival = { accepted: 0, newSamples: 0 }; for (const result of results) { if (entries[result.index]?.source !== "APPLE_HEALTH") continue; diff --git a/src/lib/integrations/healthkit-type-sync.ts b/src/lib/integrations/healthkit-type-sync.ts index 6120819b2..e2cfbd3b2 100644 --- a/src/lib/integrations/healthkit-type-sync.ts +++ b/src/lib/integrations/healthkit-type-sync.ts @@ -124,6 +124,25 @@ export interface HealthKitTypeArrival { newSamples: number; } +/** + * Fold a batch's landed Apple Health entries and hours into what it did per + * type. `brought` is true for an entry or hour that wrote a new or different + * value; a duplicate, an unchanged `stats:` re-post and an hour posted again + * with its stored value are carried but bring nothing (#1259). + */ +export function tallyHealthKitArrivals( + items: Iterable<{ type: MeasurementType; brought: boolean }>, +): Map { + const arrivals = new Map(); + for (const { type, brought } of items) { + const entry = arrivals.get(type) ?? { accepted: 0, newSamples: 0 }; + entry.accepted += 1; + if (brought) entry.newSamples += 1; + arrivals.set(type, entry); + } + return arrivals; +} + /** * One statement for the whole batch (`unnest` over at most a few dozen * types). A type counts as arrived only when the batch brought at least one