From 6a2247e422695b0142ceb7069ccd7752f49c4968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 10:34:45 +0200 Subject: [PATCH] fix(healthkit): count a type as arrived only when the batch brought something new A batch that carried a type with duplicates only moved its lastReceivedAt, lastTrigger and lastBackgroundAt, so a type whose live delivery had stalled kept reading as freshly arrived for as long as a replay, a retry or an older client re-sent its old samples. The arrival ledger now moves those stamps only for a type the batch brought at least one new or changed row for. An unchanged stats: re-post and an hour posted again with its stored value count as duplicates here; the wire keeps answering updated for them. A duplicates-only type stamps the new last_seen_duplicate_at column on its existing row and nothing else. The intake bulk route's dose entry follows the same rule through the shared recorder. lastSyncedAt and healthKitLastSyncedAt are unchanged. GET /api/integrations/healthkit serves the stamp additively as metricFreshness[].lastSeenDuplicateAt, and the Apple Health card names a re-send that came after the last arrival. Refs #1259 --- docs/api/openapi.yaml | 44 ++- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/nl.json | 1 + messages/pl.json | 1 + .../migration.sql | 12 + prisma/schema.prisma | 21 +- .../resolved-field-consumer-guard.test.ts | 11 + .../healthkit/__tests__/route.test.ts | 1 + src/app/api/measurements/batch/route.ts | 31 +- src/app/api/medications/intake/bulk/route.ts | 5 +- .../__tests__/apple-health-card.test.tsx | 26 ++ .../integrations/apple-health-card.tsx | 15 +- src/lib/integrations/healthkit-type-sync.ts | 121 +++++--- src/lib/measurements/intraday-hour-buckets.ts | 24 +- src/lib/openapi/routes/integrations.ts | 10 +- src/lib/openapi/routes/measurements.ts | 2 +- src/lib/openapi/routes/medications/paths.ts | 2 +- .../healthkit-type-sync-migration.test.ts | 2 +- tests/integration/healthkit-type-sync.test.ts | 12 +- .../healthkit-arrival-new-samples.test.ts | 284 ++++++++++++++++++ 25 files changed, 547 insertions(+), 84 deletions(-) create mode 100644 prisma/migrations/0471_healthkit_type_sync_duplicate_stamp/migration.sql create mode 100644 tests/integration/routes/healthkit-arrival-new-samples.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a3bddb974..cf0c4a73e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -28534,11 +28534,12 @@ components: /api/integrations/healthkit`, never `lastReceivedAt`, `lastTrigger`, `lastBackgroundAt`, `lastSyncTrigger`, `lastBackgroundSyncAt` or the sync checkpoint. Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from - `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried - (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each - `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for a `background` trigger. It - does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 - clients omit it.' + `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch brought + something new or changed for (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / + `lastNewSampleAt` on each `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for + a `background` trigger. Since v1.45 a type the batch carried with duplicates only (an unchanged `stats:` + re-post included) moves only its `lastSeenDuplicateAt`. It does not affect dedup, attribution, or how any + sample is stored. Optional and backward-compatible: pre-#66 clients omit it.' type: string enum: - foreground @@ -29178,10 +29179,11 @@ components: description: "Diagnostic-only, the same field `POST /api/measurements/batch` takes (since v1.42.4): what woke the client for this sync. `backfill` (v1.43, only when `features.syncTriggerBackfill` is `true`) marks a history catch-up, which moves only the `MEDICATION_DOSE` entry's `lastBackfillAt` and leaves its live stamps and the - sync checkpoint alone. When the batch carries entries with `source: \"APPLE_HEALTH\"` that land (`inserted`, - `updated` or `duplicate`), it is recorded on the `MEDICATION_DOSE` entry of `metricFreshness` in `GET - /api/integrations/healthkit` as `lastTrigger`, and a `background` value also dates its `lastBackgroundAt`. - It changes no dose row. Optional; omitted records a null trigger." + sync checkpoint alone. When the batch brings at least one new or changed entry with `source: + \"APPLE_HEALTH\"` (`inserted` or `updated`), it is recorded on the `MEDICATION_DOSE` entry of + `metricFreshness` in `GET /api/integrations/healthkit` as `lastTrigger`, and a `background` value also dates + its `lastBackgroundAt`. Since v1.45 a batch whose Apple Health entries are all `duplicate` moves only that + entry's `lastSeenDuplicateAt`. It changes no dose row. Optional; omitted records a null trigger." type: string enum: - foreground @@ -81003,11 +81005,14 @@ components: description: This type has gone quiet while the source around it reads healthy — the dead-pipe signature (e.g. a single revoked HealthKit permission). Only ever true when the verdict is `fresh`. lastReceivedAt: - description: "When a live sync batch last carried this type (inserted, updated or duplicate entries all count). Null - when no live sync has carried it yet: a type that so far came only from the export import, or from before - v1.42. `lastSeenAt` is when the newest sample was TAKEN; this is when the server last RECEIVED the type. - Omitted on the `WORKOUTS` entry, which the ledger does not cover, and on every entry when the ledger read - failed." + description: "When a live sync batch last brought a new or changed row of this type. Since v1.45 a batch that carried + the type with duplicates only (an unchanged `stats:` re-post or an hour posted again with its stored value + included) does not move it, nor `lastTrigger` or `lastBackgroundAt`; it moves `lastSeenDuplicateAt`. Before + v1.45 duplicates counted too, so a stored value can still date from a duplicates-only batch. Null when no + live sync has brought anything for it yet: a type that so far came only from the export import, or from + before v1.42. `lastSeenAt` is when the newest sample was TAKEN; this is when the server last RECEIVED the + type. Omitted on the `WORKOUTS` entry, which the ledger does not cover, and on every entry when the ledger + read failed." anyOf: - type: string format: date-time @@ -81027,6 +81032,7 @@ components: - type: "null" lastNewSampleAt: description: When a live sync batch last brought a new or updated sample of this type, as opposed to duplicates only. + Since v1.45 this moves together with `lastReceivedAt`; the two differ only on a row last written before. Null when none has. Same omission rules as `lastReceivedAt`. anyOf: - type: string @@ -81045,6 +81051,16 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + lastSeenDuplicateAt: + description: v1.45 — when a live sync batch last carried this type with duplicates only, nothing new or changed. Later + than `lastReceivedAt` means the phone keeps sending the type but nothing new has arrived since + `lastReceivedAt`. Null when no duplicates-only batch was recorded for an existing ledger row since v1.45. + Same omission rules as `lastReceivedAt`; absent on older servers. + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" lastBackfillAt: description: 'v1.43 — when a batch with `syncTrigger: "backfill"` last carried this type. A backfill moves only this and `syncProgress`, never `lastReceivedAt`, `lastTrigger`, `lastBackgroundAt` or the account-level sync stamps, diff --git a/messages/de.json b/messages/de.json index 27d928e45..e984dd762 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7430,6 +7430,7 @@ "lastReceived": "Zuletzt empfangen {relative}", "lastNewValue": "Letzter neuer Wert {relative}", "lastBackground": "Zuletzt im Hintergrund {relative}", + "lastDuplicate": "Zuletzt ohne neue Werte erneut gesendet {relative}", "never": "Noch nicht empfangen", "lastBackfill": "Verlauf nachgeladen {relative}", "lastEnteredHere": "Zuletzt hier eingetragen {relative}" diff --git a/messages/en.json b/messages/en.json index 00bd35af2..78069ee50 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7430,6 +7430,7 @@ "lastReceived": "Last received {relative}", "lastNewValue": "Last new value {relative}", "lastBackground": "Last in background {relative}", + "lastDuplicate": "Last re-sent with nothing new {relative}", "never": "Not received yet", "lastBackfill": "History caught up {relative}", "lastEnteredHere": "Last entered here {relative}" diff --git a/messages/es.json b/messages/es.json index 60d87894d..85db098d5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7430,6 +7430,7 @@ "lastReceived": "Última recepción {relative}", "lastNewValue": "Último valor nuevo {relative}", "lastBackground": "Última vez en segundo plano {relative}", + "lastDuplicate": "Último reenvío sin datos nuevos {relative}", "never": "Aún no recibido", "lastBackfill": "Historial recuperado {relative}", "lastEnteredHere": "Último registro aquí {relative}" diff --git a/messages/fr.json b/messages/fr.json index 5d94ed83e..78d873360 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7430,6 +7430,7 @@ "lastReceived": "Dernière réception {relative}", "lastNewValue": "Dernière nouvelle valeur {relative}", "lastBackground": "Dernière fois en arrière-plan {relative}", + "lastDuplicate": "Dernier renvoi sans nouvelle donnée {relative}", "never": "Pas encore reçu", "lastBackfill": "Historique rattrapé {relative}", "lastEnteredHere": "Dernière saisie ici {relative}" diff --git a/messages/it.json b/messages/it.json index 99c97f0ad..e288b3d89 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7430,6 +7430,7 @@ "lastReceived": "Ultima ricezione {relative}", "lastNewValue": "Ultimo nuovo valore {relative}", "lastBackground": "Ultima volta in background {relative}", + "lastDuplicate": "Ultimo reinvio senza dati nuovi {relative}", "never": "Non ancora ricevuto", "lastBackfill": "Storico recuperato {relative}", "lastEnteredHere": "Ultimo inserimento qui {relative}" diff --git a/messages/ko.json b/messages/ko.json index db2d6a0b6..d825c089e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7430,6 +7430,7 @@ "lastReceived": "마지막 수신 {relative}", "lastNewValue": "마지막 새 값 {relative}", "lastBackground": "마지막 백그라운드 수신 {relative}", + "lastDuplicate": "새 값 없이 마지막 재전송 {relative}", "never": "아직 수신되지 않음", "lastBackfill": "기록 보충 {relative}", "lastEnteredHere": "여기에서 마지막 입력 {relative}" diff --git a/messages/nl.json b/messages/nl.json index 5c1fe7411..41eb4eb58 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -7430,6 +7430,7 @@ "lastReceived": "Laatst ontvangen {relative}", "lastNewValue": "Laatste nieuwe waarde {relative}", "lastBackground": "Laatst op de achtergrond {relative}", + "lastDuplicate": "Laatst opnieuw verzonden zonder nieuwe waarden {relative}", "never": "Nog niet ontvangen", "lastBackfill": "Geschiedenis bijgewerkt {relative}", "lastEnteredHere": "Laatst hier ingevoerd {relative}" diff --git a/messages/pl.json b/messages/pl.json index 0acd8f32d..da6829de7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7430,6 +7430,7 @@ "lastReceived": "Ostatnio odebrano {relative}", "lastNewValue": "Ostatnia nowa wartość {relative}", "lastBackground": "Ostatnio w tle {relative}", + "lastDuplicate": "Ostatnio wysłano ponownie bez nowych wartości {relative}", "never": "Jeszcze nie odebrano", "lastBackfill": "Historia uzupełniona {relative}", "lastEnteredHere": "Ostatni wpis tutaj {relative}" diff --git a/prisma/migrations/0471_healthkit_type_sync_duplicate_stamp/migration.sql b/prisma/migrations/0471_healthkit_type_sync_duplicate_stamp/migration.sql new file mode 100644 index 000000000..ced3d6cc0 --- /dev/null +++ b/prisma/migrations/0471_healthkit_type_sync_duplicate_stamp/migration.sql @@ -0,0 +1,12 @@ +-- #1259: a batch that carries a type with duplicates only no longer counts as +-- an arrival of that type. It stamps this column instead, so the card can +-- still say the phone re-sent the type while `last_received_at` keeps the +-- last batch that brought something new. +-- +-- Nullable, no default, no backfill: null reads as "no duplicates-only batch +-- recorded", which is true for every row until the next one. +-- +-- Reversibility: dropping the column loses only that stamp; nothing else +-- reads it. + +ALTER TABLE "healthkit_type_syncs" ADD COLUMN IF NOT EXISTS "last_seen_duplicate_at" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 860778e7a..970aeaa03 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4136,7 +4136,7 @@ model ManagedProfileHandover { /// batch route; read by the Apple Health card's per-type freshness list. /// Diagnostic state: rebuilt by the next sync, so it is not backed up. model HealthKitTypeSync { - userId String @map("user_id") + userId String @map("user_id") /// A `MeasurementType` name for rows the measurement batch writes, or /// `MEDICATION_DOSE` for Apple Health doses the intake bulk route /// receives. Text, not the enum, since v1.42.4 (migration 0401) because a @@ -4145,20 +4145,27 @@ model HealthKitTypeSync { /// last carried the type (its `lastReceivedAt`, every other column NULL), /// so a catch-up leaves the live row of the type untouched; live readers /// skip the prefix (`healthkit-type-sync.ts`). - type String - /// Last batch that carried this type. - lastReceivedAt DateTime @map("last_received_at") + type String + /// Last batch that brought a new or changed row of this type. Since #1259 + /// a batch of duplicates only does not move it (nor the trigger or the + /// background stamp); it moves `lastSeenDuplicateAt` instead. + lastReceivedAt DateTime @map("last_received_at") /// `syncTrigger` of that batch /// (`foreground` / `background` / `push` / `manual`), NULL when absent. - lastTrigger String? @map("last_trigger") + lastTrigger String? @map("last_trigger") /// Last batch that inserted or updated a sample of /// this type (as opposed to sending only duplicates). - lastNewSampleAt DateTime? @map("last_new_sample_at") + lastNewSampleAt DateTime? @map("last_new_sample_at") /// Last batch with `syncTrigger = background` that carried this type. A /// later foreground, push or manual arrival leaves it standing; NULL when /// none has (or only before v1.42.4, except a row whose latest arrival was /// a background one, which migration 0401 seeded). - lastBackgroundAt DateTime? @map("last_background_at") + lastBackgroundAt DateTime? @map("last_background_at") + /// #1259 — last live batch that carried this type with duplicates only: + /// nothing new or changed, so not an arrival. NULL when none has since + /// migration 0471. Read by the Apple Health card, served on + /// `GET /api/integrations/healthkit` as `lastSeenDuplicateAt`. + lastSeenDuplicateAt DateTime? @map("last_seen_duplicate_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/__tests__/resolved-field-consumer-guard.test.ts b/src/__tests__/resolved-field-consumer-guard.test.ts index bd75538a8..8fda69874 100644 --- a/src/__tests__/resolved-field-consumer-guard.test.ts +++ b/src/__tests__/resolved-field-consumer-guard.test.ts @@ -471,6 +471,17 @@ const V143_FIELDS: ResolvedFieldEntry[] = [ }, ], }, + { + field: + "GET /api/integrations/healthkit metricFreshness[].lastSeenDuplicateAt", + props: [], + readers: [ + { + file: "src/components/settings/integrations/apple-health-card.tsx", + pattern: /entry\.lastSeenDuplicateAt/, + }, + ], + }, ]; /** diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index 52b77eb46..7804fad49 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -617,6 +617,7 @@ describe("GET /api/integrations/healthkit — sync health", () => { lastNewSampleAt: null, lastBackgroundAt: backgroundAt.toISOString(), lastBackfillAt: null, + lastSeenDuplicateAt: null, }); expect(byType.PULSE).toMatchObject({ lastBackgroundAt: null }); }); diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 8f8c06549..7a0cfbb4d 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -657,6 +657,11 @@ 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(); let duplicateCount = 0; let failedCount = 0; // v1.44 — subset of `insertedCount` written by the set-based insert. @@ -1050,6 +1055,7 @@ async function postBatch(request: NextRequest): Promise { if (statsRow && isUnchangedStatsRow(p)) { results[p.index] = { index: p.index, status: "updated" }; unchangedStatsCount++; + unchangedStatsRows.add(p); pushMergeCandidate(p); continue; } @@ -1103,6 +1109,8 @@ 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(); if (hourWrites.length > 0 && accountTz) { const { writes, superseded } = combineHourWrites(hourWrites); for (const index of superseded) { @@ -1124,11 +1132,17 @@ async function postBatch(request: NextRequest): Promise { for (const { write, contributors } of writes) { const status = statuses.get(write.index) ?? "inserted"; for (const index of contributors) { - results[index] = { index, status }; + // 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", + }; if (status === "inserted") hourInsertedCount++; else hourUpdatedCount++; } hourLanded.push(write); + if (status !== "unchanged") hourChanged.add(write); } } catch (err) { for (const { contributors } of writes) { @@ -1211,7 +1225,11 @@ async function postBatch(request: NextRequest): Promise { 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. + // 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; @@ -1226,14 +1244,17 @@ async function postBatch(request: NextRequest): Promise { const type = p.row.type as MeasurementType; const entry = arrivals.get(type) ?? { accepted: 0, newSamples: 0 }; entry.accepted += 1; - if (status !== "duplicate") entry.newSamples += 1; + if (status !== "duplicate" && !unchangedStatsRows.has(p)) { + entry.newSamples += 1; + } arrivals.set(type, entry); } - // The hours the phone posted are an arrival of their type as well. + // 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; - entry.newSamples += 1; + if (hourChanged.has(w)) entry.newSamples += 1; arrivals.set(w.type, entry); } if (arrivals.size > 0) { diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index c3d3e65f7..e2b0b2029 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -1008,8 +1008,9 @@ 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 replay answered `duplicate` still counts as an arrival, the - // same rule as the batch route. + // arrival. A batch of replays answered `duplicate` alone is not an arrival + // (#1259): it stamps only `lastSeenDuplicateAt`, 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/components/settings/integrations/__tests__/apple-health-card.test.tsx b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx index ace7fc3c9..f80b62791 100644 --- a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx +++ b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx @@ -449,6 +449,32 @@ describe(" — per-type arrivals (#1173)", () => { expect(out).toContain("Last new value 3 d ago"); }); + // #1259 — a duplicates-only batch no longer moves the arrival; the card + // says the phone kept sending the type without hiding when it last brought + // something. + it("names a later re-send with nothing new beside the last arrival", () => { + const out = html({ + ...base, + lastReceivedAt: hoursAgo(20), + lastTrigger: "foreground", + lastNewSampleAt: hoursAgo(20), + lastSeenDuplicateAt: hoursAgo(1), + }); + expect(out).toContain("Last received 20 h ago"); + expect(out).toContain("Last re-sent with nothing new 1 h ago"); + }); + + it("does not name a re-send older than the last arrival", () => { + const out = html({ + ...base, + lastReceivedAt: hoursAgo(1), + lastTrigger: "foreground", + lastNewSampleAt: hoursAgo(1), + lastSeenDuplicateAt: hoursAgo(5), + }); + expect(out).not.toContain("Last re-sent"); + }); + // v1.42.4 — opening the app to look starts a foreground sync; the earlier // background arrival must stay visible beside it. it("keeps the last background arrival visible after a later foreground one", () => { diff --git a/src/components/settings/integrations/apple-health-card.tsx b/src/components/settings/integrations/apple-health-card.tsx index 4b6eb0bcd..5f8e25776 100644 --- a/src/components/settings/integrations/apple-health-card.tsx +++ b/src/components/settings/integrations/apple-health-card.tsx @@ -192,7 +192,8 @@ export function ArrivalDetail({ : "settings.appleHealth.delivery.trigger.unknown", ), ]; - // Only worth a mention when the last arrival brought duplicates alone. + // Only worth a mention on a row last written before duplicates stopped + // counting as an arrival (#1259); since then the two move together. if (entry.lastNewSampleAt && entry.lastNewSampleAt !== entry.lastReceivedAt) { parts.push( t("settings.appleHealth.freshness.lastNewValue", { @@ -200,6 +201,18 @@ export function ArrivalDetail({ }), ); } + // The phone kept re-sending the type after its last new value: the + // arrival above stays put, and this says the type is still being sent. + if ( + entry.lastSeenDuplicateAt && + Date.parse(entry.lastSeenDuplicateAt) > Date.parse(entry.lastReceivedAt) + ) { + parts.push( + t("settings.appleHealth.freshness.lastDuplicate", { + relative: relative(entry.lastSeenDuplicateAt), + }), + ); + } // The last background arrival, when a later one came another way: opening // the app to look would otherwise hide that the phone delivers by itself. if ( diff --git a/src/lib/integrations/healthkit-type-sync.ts b/src/lib/integrations/healthkit-type-sync.ts index 77d4c93d1..6120819b2 100644 --- a/src/lib/integrations/healthkit-type-sync.ts +++ b/src/lib/integrations/healthkit-type-sync.ts @@ -3,7 +3,9 @@ * * The batch route records, per measurement type it received, when the type * last arrived, under which `syncTrigger`, and when it last brought a new or - * updated sample (as opposed to duplicates only). Since v1.42.4 the intake + * updated sample. Since #1259 only a batch that brought something new or + * changed is an arrival; one of duplicates only stamps + * `last_seen_duplicate_at` and nothing else. Since v1.42.4 the intake * bulk route records Apple Health doses the same way under the key * `MEDICATION_DOSE`, and every row also keeps when a `background` batch last * carried the type. The Apple Health card reads it to show which types are @@ -114,18 +116,28 @@ export type HealthKitArrivalType = export interface HealthKitTypeArrival { /** Entries of this type the batch accepted (inserted, updated or duplicate). */ accepted: number; - /** Entries of this type that were inserted or updated. */ + /** + * Entries of this type that wrote something new or changed. An `updated` + * answer that rewrote nothing (an unchanged `stats:` re-post, an hour + * posted again with the same value) is not one. + */ newSamples: number; } /** * One statement for the whole batch (`unnest` over at most a few dozen - * types). `last_received_at` and `last_trigger` describe the latest batch - * that carried the type, so they are overwritten; `last_new_sample_at` only - * moves when this batch brought something new, so a duplicates-only re-send - * keeps the earlier date standing. `last_background_at` moves only on a - * `background` batch, so opening the app afterwards does not hide the - * earlier background arrival. + * types). A type counts as arrived only when the batch brought at least one + * new or changed row of it (#1259): then `last_received_at` and + * `last_trigger` describe this batch and are overwritten, `last_new_sample_at` + * moves with them, and `last_background_at` moves on a `background` batch so + * opening the app afterwards does not hide the earlier background arrival. + * + * A type the batch carried only as duplicates (a replay, a retry after an + * abort, a history walk an older client sends live, an unchanged `stats:` + * re-post) moves none of those: it stamps `last_seen_duplicate_at` alone, on + * a row that already exists. Before #1259 it moved the arrival and the + * trigger, so a type whose live delivery had stalled kept reading "arrived a + * minute ago" for as long as anything re-sent its old samples. * * Best effort: the ledger is a diagnostic beside rows that are already * written, so a failure here leaves a `warn` line and never fails the ingest. @@ -140,13 +152,13 @@ export async function recordHealthKitTypeArrivals( }, ): Promise { const types: string[] = []; - const brought: boolean[] = []; + const duplicateOnly: string[] = []; for (const [type, arrival] of input.counts) { if (arrival.accepted <= 0) continue; - types.push(type); - brought.push(arrival.newSamples > 0); + if (arrival.newSamples > 0) types.push(type); + else duplicateOnly.push(type); } - if (types.length === 0) return; + if (types.length === 0 && duplicateOnly.length === 0) return; // An ISO string cast in SQL, not a JS `Date`: a Date parameter is // serialised in the process's local zone and these are zone-less UTC // columns (same reasoning as the access-token sunset in refresh-token.ts). @@ -154,7 +166,9 @@ export async function recordHealthKitTypeArrivals( if (input.trigger === BACKFILL_SYNC_TRIGGER) { // v1.43 — a catch-up moves only its own row; the live row of each type // keeps its arrival, trigger and sample stamps untouched. - const keys = types.map((type) => `${BACKFILL_LEDGER_PREFIX}${type}`); + const keys = [...types, ...duplicateOnly].map( + (type) => `${BACKFILL_LEDGER_PREFIX}${type}`, + ); try { await tx.$executeRaw` INSERT INTO "healthkit_type_syncs" @@ -173,32 +187,50 @@ export async function recordHealthKitTypeArrivals( return; } const background = input.trigger === "background"; - try { - await tx.$executeRaw` - INSERT INTO "healthkit_type_syncs" - ("user_id", "type", "last_received_at", "last_trigger", - "last_new_sample_at", "last_background_at") - SELECT ${input.userId}, u.t, - ${at}::timestamptz AT TIME ZONE 'UTC', ${input.trigger}, - CASE WHEN u.n THEN ${at}::timestamptz AT TIME ZONE 'UTC' END, - CASE WHEN ${background}::boolean THEN ${at}::timestamptz AT TIME ZONE 'UTC' END - FROM unnest(${types}::text[], ${brought}::boolean[]) AS u(t, n) - ON CONFLICT ("user_id", "type") DO UPDATE SET - "last_received_at" = EXCLUDED."last_received_at", - "last_trigger" = EXCLUDED."last_trigger", - "last_new_sample_at" = COALESCE( - EXCLUDED."last_new_sample_at", - "healthkit_type_syncs"."last_new_sample_at" - ), - "last_background_at" = COALESCE( - EXCLUDED."last_background_at", - "healthkit_type_syncs"."last_background_at" - ) - `; - } catch (error) { - logCaught("healthkit.type_sync.record_failed", error, { - types: types.length, - }); + if (types.length > 0) { + try { + await tx.$executeRaw` + INSERT INTO "healthkit_type_syncs" + ("user_id", "type", "last_received_at", "last_trigger", + "last_new_sample_at", "last_background_at") + SELECT ${input.userId}, u.t, + ${at}::timestamptz AT TIME ZONE 'UTC', ${input.trigger}, + ${at}::timestamptz AT TIME ZONE 'UTC', + CASE WHEN ${background}::boolean THEN ${at}::timestamptz AT TIME ZONE 'UTC' END + FROM unnest(${types}::text[]) AS u(t) + ON CONFLICT ("user_id", "type") DO UPDATE SET + "last_received_at" = EXCLUDED."last_received_at", + "last_trigger" = EXCLUDED."last_trigger", + "last_new_sample_at" = EXCLUDED."last_new_sample_at", + "last_background_at" = COALESCE( + EXCLUDED."last_background_at", + "healthkit_type_syncs"."last_background_at" + ) + `; + } catch (error) { + logCaught("healthkit.type_sync.record_failed", error, { + types: types.length, + }); + } + } + if (duplicateOnly.length > 0) { + // An update, never an insert: `last_received_at` is the arrival and a + // type that has only ever been re-sent has not arrived. A type with no + // row yet (its samples predate the ledger, or came from the export + // import) keeps reading "not received", which is what the server knows. + try { + await tx.$executeRaw` + UPDATE "healthkit_type_syncs" + SET "last_seen_duplicate_at" = ${at}::timestamptz AT TIME ZONE 'UTC' + WHERE "user_id" = ${input.userId} + AND "type" = ANY(${duplicateOnly}::text[]) + `; + } catch (error) { + logCaught("healthkit.type_sync.record_failed", error, { + types: duplicateOnly.length, + duplicateOnly: true, + }); + } } } @@ -211,6 +243,11 @@ export interface HealthKitTypeArrivalRecord { lastBackgroundAt: string | null; /** v1.43 — when a `backfill` batch last carried the type; null when none has. */ lastBackfillAt: string | null; + /** + * #1259 — when a live batch last carried the type with duplicates only, + * nothing new or changed. Null when none has since the column exists. + */ + lastSeenDuplicateAt: string | null; } /** @@ -229,6 +266,7 @@ export async function getHealthKitTypeArrivals( lastTrigger: true, lastNewSampleAt: true, lastBackgroundAt: true, + lastSeenDuplicateAt: true, }, }); const out = new Map(); @@ -238,6 +276,7 @@ export async function getHealthKitTypeArrivals( lastNewSampleAt: null, lastBackgroundAt: null, lastBackfillAt: null, + lastSeenDuplicateAt: null, }); for (const row of rows) { if (isBackfillLedgerKey(row.type)) { @@ -255,6 +294,7 @@ export async function getHealthKitTypeArrivals( : null; record.lastNewSampleAt = row.lastNewSampleAt?.toISOString() ?? null; record.lastBackgroundAt = row.lastBackgroundAt?.toISOString() ?? null; + record.lastSeenDuplicateAt = row.lastSeenDuplicateAt?.toISOString() ?? null; out.set(row.type, record); } return out; @@ -298,6 +338,8 @@ export interface HealthKitFreshnessEntry extends MetricFreshnessEntry { lastBackgroundAt?: string | null; /** v1.43 — when a `backfill` batch last carried it. */ lastBackfillAt?: string | null; + /** #1259 — when a live sync last re-sent it with nothing new or changed. */ + lastSeenDuplicateAt?: string | null; } /** @@ -324,6 +366,7 @@ export function withHealthKitArrivals( lastNewSampleAt: arrival?.lastNewSampleAt ?? null, lastBackgroundAt: arrival?.lastBackgroundAt ?? null, lastBackfillAt: arrival?.lastBackfillAt ?? null, + lastSeenDuplicateAt: arrival?.lastSeenDuplicateAt ?? null, }; }); } diff --git a/src/lib/measurements/intraday-hour-buckets.ts b/src/lib/measurements/intraday-hour-buckets.ts index 2bd5a0830..29ab6f751 100644 --- a/src/lib/measurements/intraday-hour-buckets.ts +++ b/src/lib/measurements/intraday-hour-buckets.ts @@ -230,12 +230,17 @@ export function combineHourWrites(entries: readonly HourBucketEntry[]): { return { writes, superseded }; } +/** What one hour write did to its slot. */ +export type HourBucketWriteStatus = "inserted" | "updated" | "unchanged"; + /** * Store a batch's hours, one transaction per (type, day). Returns the * per-entry status: `inserted` for the first value of a slot, `updated` when - * it replaced one. A later entry for the same slot in one batch wins, and the - * earlier ones read `updated` too, since the slot already held a value when - * they were applied in order. + * it replaced one, `unchanged` when the slot already held exactly this value + * (a re-post that changed nothing; the wire still reports it `updated`, but + * it is not an arrival of the type, #1259). A later entry for the same slot + * in one batch wins, and the earlier ones read `updated` too, since the slot + * already held a value when they were applied in order. * * Serialised against the nightly drain's merge for the same day by the * drain's own advisory lock (keyed on the day's `stats:` id), so a bucket @@ -246,8 +251,8 @@ export async function writeHourBuckets( userId: string, timezone: string, writes: readonly HourBucketWrite[], -): Promise> { - const statuses = new Map(); +): Promise> { + const statuses = new Map(); const groups = new Map(); for (const w of writes) { const key = `${w.type}::${w.dateKey}`; @@ -282,7 +287,14 @@ export async function writeHourBuckets( ? [...existing.hourlyIncrements] : new Array(PROFILE_HOURS).fill(HOUR_NOT_POSTED); for (const w of group) { - statuses.set(w.index, increments[w.hour] >= 0 ? "updated" : "inserted"); + statuses.set( + w.index, + increments[w.hour] < 0 + ? "inserted" + : increments[w.hour] === w.value + ? "unchanged" + : "updated", + ); increments[w.hour] = w.value; } const bucketCount = postedHours(increments); diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index 95d1b43a5..c2acc5677 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -263,7 +263,7 @@ const appleHealthMetricFreshnessEntry = metricFreshnessEntry .nullable() .optional() .describe( - "When a live sync batch last carried this type (inserted, updated or duplicate entries all count). Null when no live sync has carried it yet: a type that so far came only from the export import, or from before v1.42. `lastSeenAt` is when the newest sample was TAKEN; this is when the server last RECEIVED the type. Omitted on the `WORKOUTS` entry, which the ledger does not cover, and on every entry when the ledger read failed.", + "When a live sync batch last brought a new or changed row of this type. Since v1.45 a batch that carried the type with duplicates only (an unchanged `stats:` re-post or an hour posted again with its stored value included) does not move it, nor `lastTrigger` or `lastBackgroundAt`; it moves `lastSeenDuplicateAt`. Before v1.45 duplicates counted too, so a stored value can still date from a duplicates-only batch. Null when no live sync has brought anything for it yet: a type that so far came only from the export import, or from before v1.42. `lastSeenAt` is when the newest sample was TAKEN; this is when the server last RECEIVED the type. Omitted on the `WORKOUTS` entry, which the ledger does not cover, and on every entry when the ledger read failed.", ), lastTrigger: healthKitSyncTriggerEnum .nullable() @@ -275,7 +275,7 @@ const appleHealthMetricFreshnessEntry = metricFreshnessEntry .nullable() .optional() .describe( - "When a live sync batch last brought a new or updated sample of this type, as opposed to duplicates only. Null when none has. Same omission rules as `lastReceivedAt`.", + "When a live sync batch last brought a new or updated sample of this type, as opposed to duplicates only. Since v1.45 this moves together with `lastReceivedAt`; the two differ only on a row last written before. Null when none has. Same omission rules as `lastReceivedAt`.", ), lastBackgroundAt: isoDateTime() .nullable() @@ -283,6 +283,12 @@ const appleHealthMetricFreshnessEntry = metricFreshnessEntry .describe( 'When a batch with `syncTrigger: "background"` last carried this type (since v1.42.4). A later `foreground`, `push` or `manual` batch moves `lastReceivedAt` and `lastTrigger` but leaves this standing, so opening the app does not hide an earlier background arrival. Null when the type has never arrived in the background as far as the server recorded: before v1.42.4 only the latest arrival was kept, so a type whose latest arrival then was a background one starts with that date and every other type starts null. Same omission rules as `lastReceivedAt`.', ), + lastSeenDuplicateAt: isoDateTime() + .nullable() + .optional() + .describe( + "v1.45 — when a live sync batch last carried this type with duplicates only, nothing new or changed. Later than `lastReceivedAt` means the phone keeps sending the type but nothing new has arrived since `lastReceivedAt`. Null when no duplicates-only batch was recorded for an existing ledger row since v1.45. Same omission rules as `lastReceivedAt`; absent on older servers.", + ), lastBackfillAt: isoDateTime() .nullable() .optional() diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index aff6fe3be..88706b03a 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -137,7 +137,7 @@ const batchPayloadSchema = z .enum(["foreground", "background", "push", "manual", "backfill"]) .optional() .describe( - 'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). `backfill` (v1.43, ios #77; send it only when `GET /api/auth/me` reports `features.syncTriggerBackfill: true`, an older server refuses the whole batch with 422) names a history catch-up: it moves only `syncProgress` and the per-type `lastBackfillAt` in `GET /api/integrations/healthkit`, never `lastReceivedAt`, `lastTrigger`, `lastBackgroundAt`, `lastSyncTrigger`, `lastBackgroundSyncAt` or the sync checkpoint. Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for a `background` trigger. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.', + 'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). `backfill` (v1.43, ios #77; send it only when `GET /api/auth/me` reports `features.syncTriggerBackfill: true`, an older server refuses the whole batch with 422) names a history catch-up: it moves only `syncProgress` and the per-type `lastBackfillAt` in `GET /api/integrations/healthkit`, never `lastReceivedAt`, `lastTrigger`, `lastBackgroundAt`, `lastSyncTrigger`, `lastBackgroundSyncAt` or the sync checkpoint. Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch brought something new or changed for (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for a `background` trigger. Since v1.45 a type the batch carried with duplicates only (an unchanged `stats:` re-post included) moves only its `lastSeenDuplicateAt`. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.', ), }) .meta({ diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index c0c968ed6..8ebdf52e0 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -201,7 +201,7 @@ const bulkIntakePayload = z .enum(["foreground", "background", "push", "manual", "backfill"]) .optional() .describe( - 'Diagnostic-only, the same field `POST /api/measurements/batch` takes (since v1.42.4): what woke the client for this sync. `backfill` (v1.43, only when `features.syncTriggerBackfill` is `true`) marks a history catch-up, which moves only the `MEDICATION_DOSE` entry\'s `lastBackfillAt` and leaves its live stamps and the sync checkpoint alone. When the batch carries entries with `source: "APPLE_HEALTH"` that land (`inserted`, `updated` or `duplicate`), it is recorded on the `MEDICATION_DOSE` entry of `metricFreshness` in `GET /api/integrations/healthkit` as `lastTrigger`, and a `background` value also dates its `lastBackgroundAt`. It changes no dose row. Optional; omitted records a null trigger.', + "Diagnostic-only, the same field `POST /api/measurements/batch` takes (since v1.42.4): what woke the client for this sync. `backfill` (v1.43, only when `features.syncTriggerBackfill` is `true`) marks a history catch-up, which moves only the `MEDICATION_DOSE` entry's `lastBackfillAt` and leaves its live stamps and the sync checkpoint alone. When the batch brings at least one new or changed entry with `source: \"APPLE_HEALTH\"` (`inserted` or `updated`), it is recorded on the `MEDICATION_DOSE` entry of `metricFreshness` in `GET /api/integrations/healthkit` as `lastTrigger`, and a `background` value also dates its `lastBackgroundAt`. Since v1.45 a batch whose Apple Health entries are all `duplicate` moves only that entry's `lastSeenDuplicateAt`. It changes no dose row. Optional; omitted records a null trigger.", ), }) .meta({ diff --git a/tests/integration/healthkit-type-sync-migration.test.ts b/tests/integration/healthkit-type-sync-migration.test.ts index 66e7638d3..5876d2370 100644 --- a/tests/integration/healthkit-type-sync-migration.test.ts +++ b/tests/integration/healthkit-type-sync-migration.test.ts @@ -159,7 +159,7 @@ describe("migration 0401 over enum-typed ledger rows", () => { await recordHealthKitTypeArrivals(prisma, { userId: USER, counts: new Map([ - ["WEIGHT", { accepted: 1, newSamples: 0 }], + ["WEIGHT", { accepted: 1, newSamples: 1 }], ["MEDICATION_DOSE", { accepted: 2, newSamples: 2 }], ]), trigger: "foreground", diff --git a/tests/integration/healthkit-type-sync.test.ts b/tests/integration/healthkit-type-sync.test.ts index b495e402d..fd8dd5455 100644 --- a/tests/integration/healthkit-type-sync.test.ts +++ b/tests/integration/healthkit-type-sync.test.ts @@ -138,7 +138,7 @@ describe("per-type arrival ledger (#1173)", () => { } }); - it("moves the arrival but keeps the last new value on a duplicates-only re-send", async () => { + it("keeps the arrival and dates the re-send on a duplicates-only batch (#1259)", async () => { await armSession(); const weight = entry( "HKQuantityTypeIdentifierBodyMass", @@ -158,13 +158,15 @@ describe("per-type arrival ledger (#1173)", () => { where: { userId_type: { userId: USER_ID, type: "WEIGHT" } }, }); - expect(second.lastTrigger).toBe("background"); - expect(second.lastReceivedAt.getTime()).toBeGreaterThan( - first.lastReceivedAt.getTime(), - ); + expect(second.lastTrigger).toBe("foreground"); + expect(second.lastReceivedAt).toEqual(first.lastReceivedAt); + expect(second.lastBackgroundAt).toBeNull(); expect(second.lastNewSampleAt?.toISOString()).toBe( first.lastNewSampleAt?.toISOString(), ); + expect(second.lastSeenDuplicateAt?.getTime()).toBeGreaterThan( + first.lastReceivedAt.getTime(), + ); }); it("stores a missing trigger as null rather than keeping the previous one", async () => { diff --git a/tests/integration/routes/healthkit-arrival-new-samples.test.ts b/tests/integration/routes/healthkit-arrival-new-samples.test.ts new file mode 100644 index 000000000..4f883e8c4 --- /dev/null +++ b/tests/integration/routes/healthkit-arrival-new-samples.test.ts @@ -0,0 +1,284 @@ +/** + * Apple Health freshness counts only new samples as an arrival (#1259), + * through the real API kit against the real database. + * + * Since the phone walks its history and re-sends old samples, most batches + * carry duplicates. Each one used to move the type's `lastReceivedAt`, + * `lastTrigger` and `lastBackgroundAt`, so a type whose live delivery had + * stalled read "arrived in the background a minute ago" for as long as a + * replay, a retry or an older client kept sending it. Now: + * - a batch that brings a new or changed row of a type is its arrival, as + * before; + * - a batch that carries the type with duplicates only moves none of the + * arrival stamps and stamps `lastSeenDuplicateAt` instead, which the + * status read serves; + * - an unchanged `stats:` re-post and an hour posted again with its stored + * value count as duplicates here, while the wire keeps answering + * `updated` for them; + * - the account-level sync stamps are untouched by this rule; + * - the dose ledger of the intake bulk route follows the same rule. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { GET as getHealthKit } from "@/app/api/integrations/healthkit/route"; +import { POST as postMeasurements } from "@/app/api/measurements/batch/route"; +import { POST as postDoses } from "@/app/api/medications/intake/bulk/route"; + +import { callRoute } from "../../kit/call-route"; +import { + makeMedication, + makeSession, + makeUser, + type SessionFixture, +} from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; + +const prisma = getPrismaClient(); +const STEPS = "HKQuantityTypeIdentifierStepCount"; + +let auth: SessionFixture; +let userId: string; + +beforeEach(async () => { + await truncateAllTables(prisma); + const user = await makeUser({ timezone: "UTC" }); + userId = user.id; + auth = await makeSession(user); +}); + +function pulse(at: string, externalId: string, value = 61) { + return { + hkIdentifier: "HKQuantityTypeIdentifierHeartRate", + value, + unit: "count/min", + startDate: at, + endDate: at, + externalId, + }; +} + +function stepsDay(day: string, value: number) { + return { + hkIdentifier: STEPS, + value, + unit: "count", + startDate: `${day}T00:00:00.000Z`, + endDate: `${day}T23:59:59.000Z`, + externalId: `stats:${STEPS}:${day}`, + }; +} + +function stepsHour(day: string, hour: number, value: number) { + const hh = String(hour).padStart(2, "0"); + const start = `${day}T${hh}:00:00.000Z`; + return { + hkIdentifier: STEPS, + value, + unit: "count", + startDate: start, + endDate: new Date(Date.parse(start) + 3_600_000).toISOString(), + externalId: `stats:${STEPS}:${day}T${hh}`, + }; +} + +async function post(entries: unknown[], syncTrigger: string) { + const res = await callRoute<{ entries: Array<{ status: string }> }>( + postMeasurements, + { + method: "POST", + route: "/api/measurements/batch", + auth, + body: { syncTrigger, entries }, + }, + ); + expect(res.status).toBe(200); + return res.data.entries.map((entry) => entry.status); +} + +async function ledger(type: string) { + return prisma.healthKitTypeSync.findUniqueOrThrow({ + where: { userId_type: { userId, type } }, + }); +} + +/** Let the clock move, so a stamp that moved is distinguishable. */ +const tick = () => new Promise((resolve) => setTimeout(resolve, 20)); + +const T1 = "2026-10-20T14:55:00.000Z"; +const T2 = "2026-10-21T08:30:00.000Z"; + +describe("a duplicates-only batch is not an arrival", () => { + it("keeps the arrival, trigger and background stamps and dates the re-send", async () => { + await post([pulse(T1, "uuid-p1")], "foreground"); + const first = await ledger("PULSE"); + expect(first.lastSeenDuplicateAt).toBeNull(); + + await tick(); + expect(await post([pulse(T1, "uuid-p1")], "background")).toEqual([ + "duplicate", + ]); + const second = await ledger("PULSE"); + + expect(second.lastReceivedAt).toEqual(first.lastReceivedAt); + expect(second.lastTrigger).toBe("foreground"); + expect(second.lastNewSampleAt).toEqual(first.lastNewSampleAt); + expect(second.lastBackgroundAt).toBeNull(); + expect(second.lastSeenDuplicateAt?.getTime()).toBeGreaterThan( + first.lastReceivedAt.getTime(), + ); + + // The account-level stamps still say the phone delivers (#1259 leaves + // them as they are: `lastSyncedAt` sizes the client's window). + const account = await prisma.user.findUniqueOrThrow({ + where: { id: userId }, + select: { lastSyncedAt: true, healthKitLastSyncTrigger: true }, + }); + expect(account.lastSyncedAt?.getTime()).toBeGreaterThan( + first.lastReceivedAt.getTime(), + ); + expect(account.healthKitLastSyncTrigger).toBe("background"); + + const status = await callRoute<{ + metricFreshness: Array<{ + type: string; + lastReceivedAt?: string | null; + lastTrigger?: string | null; + lastSeenDuplicateAt?: string | null; + }>; + }>(getHealthKit, { + method: "GET", + route: "/api/integrations/healthkit", + auth, + }); + expect(status.status).toBe(200); + const row = status.data.metricFreshness.find((e) => e.type === "PULSE"); + expect(row).toMatchObject({ + lastReceivedAt: first.lastReceivedAt.toISOString(), + lastTrigger: "foreground", + lastSeenDuplicateAt: second.lastSeenDuplicateAt?.toISOString(), + }); + }); + + it("counts a batch with one new row among duplicates as an arrival", async () => { + await post([pulse(T1, "uuid-p1")], "foreground"); + const first = await ledger("PULSE"); + await tick(); + expect( + await post( + [pulse(T1, "uuid-p1"), pulse(T2, "uuid-p2", 64)], + "background", + ), + ).toEqual(["duplicate", "inserted"]); + const second = await ledger("PULSE"); + expect(second.lastReceivedAt.getTime()).toBeGreaterThan( + first.lastReceivedAt.getTime(), + ); + expect(second.lastTrigger).toBe("background"); + expect(second.lastBackgroundAt).toEqual(second.lastReceivedAt); + expect(second.lastNewSampleAt).toEqual(second.lastReceivedAt); + }); + + it("creates no ledger row for a type that has only ever been re-sent", async () => { + // A sample stored before the ledger existed, re-sent by the phone. + await prisma.measurement.create({ + data: { + userId, + type: "PULSE", + value: 61, + unit: "bpm", + source: "APPLE_HEALTH", + externalId: "uuid-p1", + measuredAt: new Date(T1), + }, + }); + expect(await post([pulse(T1, "uuid-p1")], "background")).toEqual([ + "duplicate", + ]); + expect(await prisma.healthKitTypeSync.count({ where: { userId } })).toBe(0); + }); +}); + +describe("re-posts that change nothing", () => { + it("treats an unchanged stats: day as a duplicate and a changed one as an arrival", async () => { + await post([stepsDay("2026-10-20", 8421)], "foreground"); + const first = await ledger("ACTIVITY_STEPS"); + + await tick(); + expect(await post([stepsDay("2026-10-20", 8421)], "background")).toEqual([ + "updated", + ]); + const unchanged = await ledger("ACTIVITY_STEPS"); + expect(unchanged.lastReceivedAt).toEqual(first.lastReceivedAt); + expect(unchanged.lastTrigger).toBe("foreground"); + expect(unchanged.lastSeenDuplicateAt).not.toBeNull(); + + await tick(); + expect(await post([stepsDay("2026-10-20", 9100)], "background")).toEqual([ + "updated", + ]); + const changed = await ledger("ACTIVITY_STEPS"); + expect(changed.lastReceivedAt.getTime()).toBeGreaterThan( + first.lastReceivedAt.getTime(), + ); + expect(changed.lastTrigger).toBe("background"); + }); + + it("treats an hour posted again with its stored value as a duplicate", async () => { + const day = "2026-10-21"; + expect(await post([stepsHour(day, 8, 400)], "foreground")).toEqual([ + "inserted", + ]); + const first = await ledger("ACTIVITY_STEPS"); + + await tick(); + expect(await post([stepsHour(day, 8, 400)], "background")).toEqual([ + "updated", + ]); + const unchanged = await ledger("ACTIVITY_STEPS"); + expect(unchanged.lastReceivedAt).toEqual(first.lastReceivedAt); + expect(unchanged.lastSeenDuplicateAt).not.toBeNull(); + + await tick(); + expect(await post([stepsHour(day, 8, 520)], "background")).toEqual([ + "updated", + ]); + const changed = await ledger("ACTIVITY_STEPS"); + expect(changed.lastReceivedAt.getTime()).toBeGreaterThan( + first.lastReceivedAt.getTime(), + ); + }); +}); + +describe("Apple Health doses follow the same rule", () => { + it("does not move the dose arrival for a replay of a dose already stored", async () => { + const medication = await makeMedication( + { id: userId }, + { externalSource: "APPLE_HEALTH", externalId: "hk-med-arrival" }, + ); + const dose = { + medicationId: medication.id, + scheduledFor: T1, + takenAt: T1, + source: "APPLE_HEALTH", + externalId: "hk-dose-1", + }; + const send = (syncTrigger: string) => + callRoute<{ entries: Array<{ status: string }> }>(postDoses, { + method: "POST", + route: "/api/medications/intake/bulk", + auth, + body: { syncTrigger, entries: [dose] }, + }); + expect((await send("foreground")).status).toBe(200); + const first = await ledger("MEDICATION_DOSE"); + + await tick(); + const replay = await send("background"); + expect(replay.data.entries.map((e) => e.status)).toEqual(["duplicate"]); + const second = await ledger("MEDICATION_DOSE"); + expect(second.lastReceivedAt).toEqual(first.lastReceivedAt); + expect(second.lastTrigger).toBe("foreground"); + expect(second.lastBackgroundAt).toBeNull(); + expect(second.lastSeenDuplicateAt).not.toBeNull(); + }); +});