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
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 10:34:45 +02:00
1 parent 703c92c776
commit 6a2247e422
25 files changed
+547 -84

No files matched your search

+30 -14
View File
@@ -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,
+1
View File
@@ -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}"
+1
View File
@@ -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}"
+1
View File
@@ -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}"
+1
View File
@@ -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}"
+1
View File
@@ -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}"
+1
View File
@@ -7430,6 +7430,7 @@
"lastReceived": "마지막 수신 {relative}",
"lastNewValue": "마지막 새 값 {relative}",
"lastBackground": "마지막 백그라운드 수신 {relative}",
"lastDuplicate": "새 값 없이 마지막 재전송 {relative}",
"never": "아직 수신되지 않음",
"lastBackfill": "기록 보충 {relative}",
"lastEnteredHere": "여기에서 마지막 입력 {relative}"
+1
View File
@@ -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}"
+1
View File
@@ -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}"
@@ -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);
+14 -7
View File
@@ -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)
@@ -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/,
},
],
},
];
/**
@@ -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 });
});
+26 -5
View File
@@ -657,6 +657,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
* 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<Prepared>();
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<Response> {
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<Response> {
let hourInsertedCount = 0;
let hourUpdatedCount = 0;
const hourLanded: HourBucketWrite[] = [];
/** The landed hours that wrote a new or different value (#1259). */
const hourChanged = new Set<HourBucketWrite>();
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<Response> {
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<Response> {
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<MeasurementType, HealthKitTypeArrival>();
for (const p of prepared) {
if (p.row.source !== "APPLE_HEALTH") continue;
@@ -1226,14 +1244,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
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) {
+3 -2
View File
@@ -1008,8 +1008,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
// 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;
@@ -449,6 +449,32 @@ describe("<AppleHealthCard> — 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", () => {
@@ -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 (
+82 -39
View File
@@ -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<void> {
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<string, HealthKitTypeArrivalRecord>();
@@ -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,
};
});
}
+18 -6
View File
@@ -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<Map<number, "inserted" | "updated">> {
const statuses = new Map<number, "inserted" | "updated">();
): Promise<Map<number, HourBucketWriteStatus>> {
const statuses = new Map<number, HourBucketWriteStatus>();
const groups = new Map<string, HourBucketWrite[]>();
for (const w of writes) {
const key = `${w.type}::${w.dateKey}`;
@@ -282,7 +287,14 @@ export async function writeHourBuckets(
? [...existing.hourlyIncrements]
: new Array<number>(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);
+8 -2
View File
@@ -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()
+1 -1
View File
@@ -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({
+1 -1
View File
@@ -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({
@@ -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",
@@ -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 () => {
@@ -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();
});
});