mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(measurements): merge MANUAL and Apple Health rows for the same reading
A measurement logged by hand on the iOS app while offline is mirrored into HealthKit. On pairing a server, adopt-on-pair uploads it as a MANUAL row while HealthKit background sync independently re-ingests the same sample as an APPLE_HEALTH row. The two carry different external ids and sources, so the composite unique index keeps both — doubling the whole hand-logged history on first pair. Collapse the pair at ingest: an incoming MANUAL or APPLE_HEALTH row is dropped as a duplicate when a same-reading row of the opposite source already exists (same type and value, measuredAt within two seconds). The match is scoped to that one cross-source pair, so same-source rows and rows from server-owned sources keep their existing contracts, and two genuinely distinct readings at the same minute never collapse. stats:* cumulative rows stay on their per-day overwrite lane. The collapsed copy returns status "duplicate" with reason "cross_source_merge" so the client cursor advances unchanged; no schema, migration, or OpenAPI change.
This commit is contained in:
1 parent
b54b95dbc7
commit
accd7c8514
4 files changed
+765
-3
No files matched your search
@@ -41,6 +41,13 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { mapAppleHealthEntry } from "@/lib/measurements/apple-health-mapping";
|
||||
import {
|
||||
MEASURED_AT_TOLERANCE_MS,
|
||||
isMergeableSource,
|
||||
isSameReadingAcrossSource,
|
||||
oppositeMergeSource,
|
||||
type MergeCandidate,
|
||||
} from "@/lib/measurements/cross-source-merge";
|
||||
import { validateMeasurementRange } from "@/lib/validations/measurement";
|
||||
import { deviceTypeEnum } from "@/lib/validations/source-priority";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -287,6 +294,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
let insertedCount = 0;
|
||||
let updatedCount = 0;
|
||||
let duplicateCount = 0;
|
||||
// v1.11.4 (iOS #2) — subset of `duplicateCount` collapsed by the
|
||||
// MANUAL↔APPLE_HEALTH same-reading merge (as opposed to a plain
|
||||
// composite-key duplicate). Surfaced as a dedicated wide-event count so
|
||||
// an operator can see how often the standalone-pair mirror collapses.
|
||||
let crossSourceMergedCount = 0;
|
||||
|
||||
if (prepared.length > 0) {
|
||||
// Pre-flight duplicate detection so we can return per-entry status
|
||||
@@ -320,6 +332,78 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
existing.map((row) => `${row.type}::${row.source}::${row.externalId}`),
|
||||
);
|
||||
|
||||
// v1.11.4 (iOS #2) — same-reading merge for the MANUAL ↔ APPLE_HEALTH
|
||||
// mirror duplicate. When the iOS app logs a manual reading offline it
|
||||
// mirrors that reading into HealthKit; on pairing, adopt-on-pair
|
||||
// uploads it as MANUAL and HealthKit background sync independently
|
||||
// re-ingests the mirrored sample as APPLE_HEALTH. The two carry
|
||||
// different externalIds + sources, so the composite unique index lets
|
||||
// both land — duplicating the whole hand-logged history on first pair.
|
||||
// We collapse them at ingest: an incoming MANUAL / APPLE_HEALTH row is
|
||||
// dropped as a `duplicate` when a same-reading row of the OPPOSITE
|
||||
// source already exists (same type + value + measuredAt within a tight
|
||||
// ±2 s window). `stats:*` cumulative rows are excluded — those are
|
||||
// per-day aggregates the observer overwrites in place, never
|
||||
// hand-entered point readings. See `cross-source-merge.ts` for the
|
||||
// full rule + why first-physical-reading-wins (the value is identical
|
||||
// between the two rows, so only the source label differs).
|
||||
//
|
||||
// Pull the opposite-source candidate rows in one widened query: for
|
||||
// every mergeable, non-`stats:*` incoming row, look for an opposite-
|
||||
// source row of the same type whose measuredAt falls inside the
|
||||
// tolerance window. Compared in JS because the value match needs a
|
||||
// float epsilon the SQL `IN` can't express.
|
||||
const mergeProbes = prepared.filter(
|
||||
(p) =>
|
||||
isMergeableSource(p.row.source as string) &&
|
||||
!isStatsExternalId(p.row.externalId as string),
|
||||
);
|
||||
const crossSourceCandidates: MergeCandidate[] = [];
|
||||
if (mergeProbes.length > 0) {
|
||||
const candidateRows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
OR: mergeProbes.map((p) => {
|
||||
const measuredAt = p.row.measuredAt as Date;
|
||||
return {
|
||||
type: p.row.type as MeasurementType,
|
||||
source: oppositeMergeSource(
|
||||
p.row.source as "MANUAL" | "APPLE_HEALTH",
|
||||
),
|
||||
measuredAt: {
|
||||
gte: new Date(measuredAt.getTime() - MEASURED_AT_TOLERANCE_MS),
|
||||
lte: new Date(measuredAt.getTime() + MEASURED_AT_TOLERANCE_MS),
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
select: { type: true, source: true, value: true, measuredAt: true },
|
||||
});
|
||||
crossSourceCandidates.push(...candidateRows);
|
||||
}
|
||||
|
||||
// Rows already chosen for insert earlier in THIS batch — so a MANUAL
|
||||
// and an APPLE_HEALTH same-reading arriving in the SAME batch also
|
||||
// collapse (the DB probe above only sees rows from prior batches).
|
||||
const inBatchCandidates: MergeCandidate[] = [];
|
||||
function hasSameReadingSibling(p: Prepared): boolean {
|
||||
const incoming = {
|
||||
type: p.row.type as MeasurementType,
|
||||
source: p.row.source as string,
|
||||
value: p.row.value as number,
|
||||
measuredAt: p.row.measuredAt as Date,
|
||||
};
|
||||
if (!isMergeableSource(incoming.source)) return false;
|
||||
for (const candidate of crossSourceCandidates) {
|
||||
if (isSameReadingAcrossSource(incoming, candidate)) return true;
|
||||
}
|
||||
for (const candidate of inBatchCandidates) {
|
||||
if (isSameReadingAcrossSource(incoming, candidate)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const toInsert: Prisma.MeasurementCreateManyInput[] = [];
|
||||
const toOverwrite: Prepared[] = [];
|
||||
for (const p of prepared) {
|
||||
@@ -336,9 +420,35 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
results[p.index] = { index: p.index, status: "duplicate" };
|
||||
duplicateCount += 1;
|
||||
}
|
||||
} else if (
|
||||
!isStatsExternalId(p.row.externalId as string) &&
|
||||
hasSameReadingSibling(p)
|
||||
) {
|
||||
// v1.11.4 (iOS #2) — cross-source same-reading collapse. The
|
||||
// physical reading already exists under the opposite client
|
||||
// source; drop this mirror copy. Status stays `duplicate` so the
|
||||
// iOS sync cursor checkpoints past it exactly as it does for a
|
||||
// composite-key duplicate; the `reason` distinguishes it for ops.
|
||||
results[p.index] = {
|
||||
index: p.index,
|
||||
status: "duplicate",
|
||||
reason: "cross_source_merge",
|
||||
};
|
||||
duplicateCount += 1;
|
||||
crossSourceMergedCount += 1;
|
||||
} else {
|
||||
results[p.index] = { index: p.index, status: "inserted" };
|
||||
toInsert.push(p.row);
|
||||
// Track this row so a same-reading sibling later in the SAME
|
||||
// batch collapses against it.
|
||||
if (isMergeableSource(p.row.source as string)) {
|
||||
inBatchCandidates.push({
|
||||
type: p.row.type as MeasurementType,
|
||||
source: p.row.source as string,
|
||||
value: p.row.value as number,
|
||||
measuredAt: p.row.measuredAt as Date,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,6 +573,22 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
// v1.11.4 (iOS #2) — dedicated annotation for the MANUAL↔APPLE_HEALTH
|
||||
// same-reading collapse so an operator can grep
|
||||
// `measurement.batch.cross-source-merge` to confirm the standalone-pair
|
||||
// mirror duplicate is being absorbed (a healthy first-pair flow). Only
|
||||
// fires when at least one row was merged so the baseline ingest trace
|
||||
// is unchanged for the common case.
|
||||
if (crossSourceMergedCount > 0) {
|
||||
annotate({
|
||||
action: { name: "measurement.batch.cross-source-merge" },
|
||||
meta: {
|
||||
merged: crossSourceMergedCount,
|
||||
processed: entries.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.25 W16c — kick off PR detection for this user. We always
|
||||
// enqueue when at least one row was written (or the batch had any
|
||||
// measurements to consider) so a single off-day reading still gets
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* v1.11.4 (iOS #2) — unit coverage for the MANUAL↔APPLE_HEALTH
|
||||
* same-reading merge predicate. The route integration test exercises the
|
||||
* end-to-end ingest path; this suite pins the pure matching rule:
|
||||
* - cross-source same reading → match
|
||||
* - same-source / server-source pair → no match (no over-merge)
|
||||
* - different value or out-of-tolerance timestamp → no match
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
MEASURED_AT_TOLERANCE_MS,
|
||||
isMergeableSource,
|
||||
isSameReadingAcrossSource,
|
||||
measuredAtMatch,
|
||||
oppositeMergeSource,
|
||||
valuesMatch,
|
||||
type MergeCandidate,
|
||||
} from "@/lib/measurements/cross-source-merge";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
const T0 = new Date("2026-06-04T07:30:00.000Z");
|
||||
|
||||
function candidate(over: Partial<MergeCandidate> = {}): MergeCandidate {
|
||||
return {
|
||||
type: "WEIGHT" as MeasurementType,
|
||||
source: "MANUAL",
|
||||
value: 81.4,
|
||||
measuredAt: T0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function incoming(
|
||||
over: Partial<{
|
||||
type: MeasurementType;
|
||||
source: string;
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
type: "WEIGHT" as MeasurementType,
|
||||
source: "APPLE_HEALTH",
|
||||
value: 81.4,
|
||||
measuredAt: T0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isMergeableSource", () => {
|
||||
it("accepts only the two client-facing mirror sources", () => {
|
||||
expect(isMergeableSource("MANUAL")).toBe(true);
|
||||
expect(isMergeableSource("APPLE_HEALTH")).toBe(true);
|
||||
expect(isMergeableSource("WITHINGS")).toBe(false);
|
||||
expect(isMergeableSource("IMPORT")).toBe(false);
|
||||
expect(isMergeableSource("COMPUTED")).toBe(false);
|
||||
expect(isMergeableSource("WHOOP")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("oppositeMergeSource", () => {
|
||||
it("flips MANUAL ↔ APPLE_HEALTH", () => {
|
||||
expect(oppositeMergeSource("MANUAL")).toBe("APPLE_HEALTH");
|
||||
expect(oppositeMergeSource("APPLE_HEALTH")).toBe("MANUAL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("valuesMatch", () => {
|
||||
it("matches identical and sub-epsilon values", () => {
|
||||
expect(valuesMatch(81.4, 81.4)).toBe(true);
|
||||
expect(valuesMatch(81.4, 81.4 + 1e-12)).toBe(true);
|
||||
expect(valuesMatch(0.97, 0.97)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects genuinely different values", () => {
|
||||
expect(valuesMatch(81.4, 81.5)).toBe(false);
|
||||
expect(valuesMatch(120, 121)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("measuredAtMatch", () => {
|
||||
it("matches inside the ±tolerance window", () => {
|
||||
expect(measuredAtMatch(T0, T0)).toBe(true);
|
||||
expect(
|
||||
measuredAtMatch(T0, new Date(T0.getTime() + MEASURED_AT_TOLERANCE_MS)),
|
||||
).toBe(true);
|
||||
expect(
|
||||
measuredAtMatch(T0, new Date(T0.getTime() - MEASURED_AT_TOLERANCE_MS)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects timestamps beyond the tolerance window", () => {
|
||||
expect(
|
||||
measuredAtMatch(
|
||||
T0,
|
||||
new Date(T0.getTime() + MEASURED_AT_TOLERANCE_MS + 1),
|
||||
),
|
||||
).toBe(false);
|
||||
// A whole minute apart — two distinct readings, never the mirror pair.
|
||||
expect(measuredAtMatch(T0, new Date(T0.getTime() + 60_000))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSameReadingAcrossSource", () => {
|
||||
it("matches a MANUAL candidate against an incoming APPLE_HEALTH mirror", () => {
|
||||
expect(isSameReadingAcrossSource(incoming(), candidate())).toBe(true);
|
||||
});
|
||||
|
||||
it("matches an APPLE_HEALTH candidate against an incoming MANUAL mirror", () => {
|
||||
expect(
|
||||
isSameReadingAcrossSource(
|
||||
incoming({ source: "MANUAL" }),
|
||||
candidate({ source: "APPLE_HEALTH" }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches within the timestamp tolerance", () => {
|
||||
expect(
|
||||
isSameReadingAcrossSource(
|
||||
incoming({ measuredAt: new Date(T0.getTime() + 1_500) }),
|
||||
candidate(),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT match a same-source pair (no over-merge)", () => {
|
||||
// Two APPLE_HEALTH readings — keep the existing per-source contract.
|
||||
expect(
|
||||
isSameReadingAcrossSource(
|
||||
incoming({ source: "APPLE_HEALTH" }),
|
||||
candidate({ source: "APPLE_HEALTH" }),
|
||||
),
|
||||
).toBe(false);
|
||||
// Two MANUAL readings.
|
||||
expect(
|
||||
isSameReadingAcrossSource(
|
||||
incoming({ source: "MANUAL" }),
|
||||
candidate({ source: "MANUAL" }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT match when a server-owned source is involved", () => {
|
||||
expect(
|
||||
isSameReadingAcrossSource(
|
||||
incoming({ source: "APPLE_HEALTH" }),
|
||||
candidate({ source: "WITHINGS" }),
|
||||
),
|
||||
).toBe(false);
|
||||
// Incoming WITHINGS could never reach this route, but the predicate
|
||||
// must still refuse it.
|
||||
expect(
|
||||
isSameReadingAcrossSource(
|
||||
incoming({ source: "WITHINGS" }),
|
||||
candidate({ source: "APPLE_HEALTH" }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT match a different measurement type", () => {
|
||||
expect(
|
||||
isSameReadingAcrossSource(
|
||||
incoming({ type: "PULSE" as MeasurementType }),
|
||||
candidate({ type: "WEIGHT" as MeasurementType }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT match a different value (two real readings, same minute)", () => {
|
||||
expect(
|
||||
isSameReadingAcrossSource(incoming({ value: 81.4 }), candidate({ value: 82.0 })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT match a timestamp beyond the tolerance window", () => {
|
||||
expect(
|
||||
isSameReadingAcrossSource(
|
||||
incoming({ measuredAt: new Date(T0.getTime() + 60_000) }),
|
||||
candidate(),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* v1.11.4 (iOS #2) — same-reading merge for the MANUAL ↔ APPLE_HEALTH
|
||||
* mirror duplicate.
|
||||
*
|
||||
* ## The problem
|
||||
* When the iOS app runs standalone/offline and the user logs a MANUAL
|
||||
* measurement, iOS mirrors that reading into HealthKit too. On pairing a
|
||||
* server, two independent upload paths then reach
|
||||
* `POST /api/measurements/batch` for the SAME physical reading:
|
||||
* 1. adopt-on-pair backfill uploads it as `source = MANUAL`
|
||||
* (externalId = the app's local id), and
|
||||
* 2. HealthKit background sync re-ingests the mirrored sample and
|
||||
* uploads it as `source = APPLE_HEALTH` (externalId = HKSample.uuid).
|
||||
*
|
||||
* The two carry different `externalId`s and different `source`s, so the
|
||||
* `(userId, type, source, externalId)` unique index treats them as two
|
||||
* distinct rows — duplicating the whole manually-logged history on first
|
||||
* pair.
|
||||
*
|
||||
* ## The rule
|
||||
* Treat a MANUAL row and an APPLE_HEALTH row as the SAME physical
|
||||
* reading when they share:
|
||||
* - the same `userId` (multi-tenant scoping — always narrowed), AND
|
||||
* - the same `type`, AND
|
||||
* - the same `value` (within a tiny float epsilon — both rows are the
|
||||
* same hand-entered number run through the identical
|
||||
* `mapAppleHealthEntry` conversion, so equality is exact in practice;
|
||||
* the epsilon only guards against sub-ulp drift), AND
|
||||
* - a `measuredAt` within `MEASURED_AT_TOLERANCE_MS` of each other.
|
||||
*
|
||||
* The merge is deliberately ONE-SIDED in scope: it only fires for the
|
||||
* MANUAL ↔ APPLE_HEALTH cross-source pair. MANUAL ↔ MANUAL,
|
||||
* APPLE_HEALTH ↔ APPLE_HEALTH, and any pair involving a server-owned
|
||||
* source (WITHINGS / IMPORT / COMPUTED / WHOOP) keep the existing
|
||||
* first-write-wins / per-source contracts untouched. Two genuinely
|
||||
* distinct readings at the same minute from two devices therefore never
|
||||
* collapse — they are same-source or involve a server source, both of
|
||||
* which fall outside this rule.
|
||||
*
|
||||
* ## Ingest-time, first-physical-reading-wins
|
||||
* The collapse runs at INGEST time, not read time: read-time dedup would
|
||||
* have to live in the rollup tier and every analytics path forever.
|
||||
* Ingest-time keeps the storage clean and the rule contained.
|
||||
*
|
||||
* Because the two uploads can arrive in either order and minutes apart
|
||||
* in SEPARATE batches, the collapse compares each incoming row against
|
||||
* the rows already PERSISTED for the user (cross-batch) as well as the
|
||||
* rows already chosen for insert earlier in the SAME batch.
|
||||
*
|
||||
* The winner is whichever physical reading landed first. The `value` is
|
||||
* identical between the two rows by construction, so the only difference
|
||||
* the surviving row carries is its `source` label — the numeric series
|
||||
* the user sees is the same either way. A source-priority-aware REPLACE
|
||||
* (overwrite the loser's source/row with the higher-ranked source) was
|
||||
* considered and rejected for a patch: it would require mutating or
|
||||
* re-sourcing an existing row (rollup re-source + sync-version churn) for
|
||||
* zero change to the displayed value. First-physical-reading-wins is the
|
||||
* natural extension of the endpoint's existing first-write-wins
|
||||
* philosophy across the MANUAL↔APPLE_HEALTH boundary.
|
||||
*
|
||||
* `stats:*` cumulative externalIds are explicitly OUT of scope — those
|
||||
* are per-day aggregate rows the iOS observer overwrites in place
|
||||
* (issue #213), never hand-entered point readings, so the cross-source
|
||||
* mirror duplicate cannot arise for them.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Timestamp tolerance for the same-reading match. A reading the user
|
||||
* hand-enters and the app mirrors to HealthKit shares the same instant;
|
||||
* the HealthKit re-ingest may round to the second or carry a sub-second
|
||||
* drift, so a tight ±2 s window absorbs that without any chance of
|
||||
* catching a separate genuine reading — a user does not hand-enter two
|
||||
* distinct readings of the same type within two seconds.
|
||||
*/
|
||||
export const MEASURED_AT_TOLERANCE_MS = 2_000;
|
||||
|
||||
/**
|
||||
* Relative epsilon for the value match. Both rows run the same
|
||||
* `convertToDbUnit` over the same source number, so the stored floats
|
||||
* are bit-identical in practice; the epsilon is a defensive guard
|
||||
* against any future conversion that introduces sub-ulp rounding. Scaled
|
||||
* to the magnitude of the value so it stays meaningful for both a
|
||||
* 0.97-fraction SpO2 and a 12 000-step count.
|
||||
*/
|
||||
const VALUE_RELATIVE_EPSILON = 1e-9;
|
||||
|
||||
/** The two client-facing sources that can mirror the same reading. */
|
||||
type MergeableSource = "MANUAL" | "APPLE_HEALTH";
|
||||
|
||||
/** The opposite member of the MANUAL ↔ APPLE_HEALTH pair. */
|
||||
export function oppositeMergeSource(source: MergeableSource): MergeableSource {
|
||||
return source === "MANUAL" ? "APPLE_HEALTH" : "MANUAL";
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `source` participates in the cross-source merge. Only the
|
||||
* two client-facing mirror sources qualify; server-owned sources are
|
||||
* untouched.
|
||||
*/
|
||||
export function isMergeableSource(source: string): source is MergeableSource {
|
||||
return source === "MANUAL" || source === "APPLE_HEALTH";
|
||||
}
|
||||
|
||||
/** Are two values the same reading within the relative epsilon? */
|
||||
export function valuesMatch(a: number, b: number): boolean {
|
||||
if (a === b) return true;
|
||||
const scale = Math.max(Math.abs(a), Math.abs(b), 1);
|
||||
return Math.abs(a - b) <= VALUE_RELATIVE_EPSILON * scale;
|
||||
}
|
||||
|
||||
/** Are two instants within the same-reading tolerance window? */
|
||||
export function measuredAtMatch(a: Date, b: Date): boolean {
|
||||
return Math.abs(a.getTime() - b.getTime()) <= MEASURED_AT_TOLERANCE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* A candidate same-reading row — either already persisted for the user
|
||||
* or already chosen for insert earlier in the same batch. Carries only
|
||||
* the fields the match needs.
|
||||
*/
|
||||
export interface MergeCandidate {
|
||||
type: MeasurementType;
|
||||
source: string;
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `candidate` represent the same physical reading as an incoming
|
||||
* row of `(type, incomingSource, value, measuredAt)`, under the
|
||||
* cross-source MANUAL↔APPLE_HEALTH rule? Requires:
|
||||
* - the incoming row's source is mergeable,
|
||||
* - the candidate's source is the OPPOSITE mergeable source,
|
||||
* - same type, value (±epsilon), measuredAt (±tolerance).
|
||||
*/
|
||||
export function isSameReadingAcrossSource(
|
||||
incoming: {
|
||||
type: MeasurementType;
|
||||
source: string;
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
},
|
||||
candidate: MergeCandidate,
|
||||
): boolean {
|
||||
if (!isMergeableSource(incoming.source)) return false;
|
||||
if (candidate.source !== oppositeMergeSource(incoming.source)) return false;
|
||||
if (candidate.type !== incoming.type) return false;
|
||||
if (!valuesMatch(candidate.value, incoming.value)) return false;
|
||||
return measuredAtMatch(candidate.measuredAt, incoming.measuredAt);
|
||||
}
|
||||
@@ -764,6 +764,11 @@ describe("POST /api/measurements/batch (real Postgres)", () => {
|
||||
it("treats the same externalId under different sources as two distinct rows", async () => {
|
||||
const { POST } = await import("@/app/api/measurements/batch/route");
|
||||
|
||||
// The two rows share an externalId but carry DIFFERENT values +
|
||||
// timestamps so they are genuinely distinct readings — this asserts
|
||||
// that `source` is part of the composite dedup key, without tripping
|
||||
// the v1.11.4 cross-source same-reading merge (which only collapses a
|
||||
// MANUAL + APPLE_HEALTH pair that share value + measuredAt).
|
||||
const sharedExternalId = "uuid-source-collision-001";
|
||||
const response = await POST(
|
||||
makeRequest({
|
||||
@@ -779,10 +784,10 @@ describe("POST /api/measurements/batch (real Postgres)", () => {
|
||||
},
|
||||
{
|
||||
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
||||
value: 81.0,
|
||||
value: 79.5,
|
||||
unit: "kg",
|
||||
startDate: "2026-05-09T07:30:00.000Z",
|
||||
endDate: "2026-05-09T07:30:00.000Z",
|
||||
startDate: "2026-05-09T19:30:00.000Z",
|
||||
endDate: "2026-05-09T19:30:00.000Z",
|
||||
externalId: sharedExternalId,
|
||||
source: "MANUAL",
|
||||
},
|
||||
@@ -806,6 +811,301 @@ describe("POST /api/measurements/batch (real Postgres)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// v1.11.4 (iOS #2) — same-reading merge for the MANUAL↔APPLE_HEALTH
|
||||
// mirror duplicate. A standalone manual reading mirrors into HealthKit;
|
||||
// on pair, adopt-on-pair uploads it as MANUAL and HealthKit background
|
||||
// sync re-ingests the mirror as APPLE_HEALTH with a different
|
||||
// externalId. The composite unique index would let both land; the
|
||||
// cross-source collapse drops the second copy as a `duplicate`.
|
||||
describe("cross-source same-reading merge (iOS #2)", () => {
|
||||
const weightEntry = (over: Partial<BatchEntryFixture>): BatchEntryFixture => ({
|
||||
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
||||
value: 81.4,
|
||||
unit: "kg",
|
||||
startDate: "2026-06-04T07:30:00.000Z",
|
||||
endDate: "2026-06-04T07:30:00.000Z",
|
||||
externalId: "uuid-merge-default",
|
||||
...over,
|
||||
});
|
||||
|
||||
it("collapses MANUAL then APPLE_HEALTH same reading into one row (separate batches)", async () => {
|
||||
const { POST } = await import("@/app/api/measurements/batch/route");
|
||||
|
||||
// Adopt-on-pair MANUAL upload first.
|
||||
const first = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
weightEntry({ externalId: "local-manual-001", source: "MANUAL" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(first.status).toBe(200);
|
||||
expect(((await first.json()) as { data: { inserted: number } }).data.inserted).toBe(1);
|
||||
|
||||
// HealthKit background sync re-ingests the same physical reading
|
||||
// as APPLE_HEALTH with a different (HKSample.uuid) externalId.
|
||||
const second = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
weightEntry({ externalId: "hk-uuid-001", source: "APPLE_HEALTH" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(second.status).toBe(200);
|
||||
const secondJson = (await second.json()) as {
|
||||
data: {
|
||||
inserted: number;
|
||||
duplicates: number;
|
||||
entries: Array<{ status: string; reason?: string }>;
|
||||
};
|
||||
};
|
||||
expect(secondJson.data.inserted).toBe(0);
|
||||
expect(secondJson.data.duplicates).toBe(1);
|
||||
expect(secondJson.data.entries[0]?.status).toBe("duplicate");
|
||||
expect(secondJson.data.entries[0]?.reason).toBe("cross_source_merge");
|
||||
|
||||
const stored = await getPrismaClient().measurement.findMany({
|
||||
where: { userId: TEST_USER_ID, type: "WEIGHT" },
|
||||
});
|
||||
expect(stored).toHaveLength(1);
|
||||
expect(stored[0]!.source).toBe("MANUAL");
|
||||
});
|
||||
|
||||
it("collapses regardless of arrival order (APPLE_HEALTH then MANUAL)", async () => {
|
||||
const { POST } = await import("@/app/api/measurements/batch/route");
|
||||
|
||||
const first = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
weightEntry({ externalId: "hk-uuid-002", source: "APPLE_HEALTH" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(first.status).toBe(200);
|
||||
|
||||
const second = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
weightEntry({ externalId: "local-manual-002", source: "MANUAL" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(second.status).toBe(200);
|
||||
const secondJson = (await second.json()) as {
|
||||
data: { inserted: number; duplicates: number };
|
||||
};
|
||||
expect(secondJson.data.inserted).toBe(0);
|
||||
expect(secondJson.data.duplicates).toBe(1);
|
||||
|
||||
const stored = await getPrismaClient().measurement.findMany({
|
||||
where: { userId: TEST_USER_ID, type: "WEIGHT" },
|
||||
});
|
||||
expect(stored).toHaveLength(1);
|
||||
expect(stored[0]!.source).toBe("APPLE_HEALTH");
|
||||
});
|
||||
|
||||
it("collapses a MANUAL + APPLE_HEALTH mirror arriving in the SAME batch", async () => {
|
||||
const { POST } = await import("@/app/api/measurements/batch/route");
|
||||
|
||||
const response = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
weightEntry({ externalId: "local-manual-003", source: "MANUAL" }),
|
||||
weightEntry({ externalId: "hk-uuid-003", source: "APPLE_HEALTH" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const json = (await response.json()) as {
|
||||
data: { inserted: number; duplicates: number };
|
||||
};
|
||||
expect(json.data.inserted).toBe(1);
|
||||
expect(json.data.duplicates).toBe(1);
|
||||
|
||||
const stored = await getPrismaClient().measurement.findMany({
|
||||
where: { userId: TEST_USER_ID, type: "WEIGHT" },
|
||||
});
|
||||
expect(stored).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("matches within the ±2s tolerance window", async () => {
|
||||
const { POST } = await import("@/app/api/measurements/batch/route");
|
||||
|
||||
await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
weightEntry({
|
||||
externalId: "local-manual-tol",
|
||||
source: "MANUAL",
|
||||
startDate: "2026-06-04T07:30:00.000Z",
|
||||
endDate: "2026-06-04T07:30:00.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// HK mirror ingested with a 1s sub-second drift on the same reading.
|
||||
const second = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
weightEntry({
|
||||
externalId: "hk-uuid-tol",
|
||||
source: "APPLE_HEALTH",
|
||||
startDate: "2026-06-04T07:30:01.000Z",
|
||||
endDate: "2026-06-04T07:30:01.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(second.status).toBe(200);
|
||||
const secondJson = (await second.json()) as {
|
||||
data: { inserted: number; duplicates: number };
|
||||
};
|
||||
expect(secondJson.data.inserted).toBe(0);
|
||||
expect(secondJson.data.duplicates).toBe(1);
|
||||
|
||||
const stored = await getPrismaClient().measurement.findMany({
|
||||
where: { userId: TEST_USER_ID, type: "WEIGHT" },
|
||||
});
|
||||
expect(stored).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does NOT merge different values (two real readings same minute)", async () => {
|
||||
const { POST } = await import("@/app/api/measurements/batch/route");
|
||||
|
||||
await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
weightEntry({
|
||||
externalId: "local-manual-distinct",
|
||||
source: "MANUAL",
|
||||
value: 81.4,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const second = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
weightEntry({
|
||||
externalId: "hk-uuid-distinct",
|
||||
source: "APPLE_HEALTH",
|
||||
value: 82.0,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(second.status).toBe(200);
|
||||
const secondJson = (await second.json()) as {
|
||||
data: { inserted: number; duplicates: number };
|
||||
};
|
||||
// Different value → genuinely distinct reading, both rows survive.
|
||||
expect(secondJson.data.inserted).toBe(1);
|
||||
expect(secondJson.data.duplicates).toBe(0);
|
||||
|
||||
const stored = await getPrismaClient().measurement.findMany({
|
||||
where: { userId: TEST_USER_ID, type: "WEIGHT" },
|
||||
});
|
||||
expect(stored).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does NOT merge timestamps beyond the tolerance window", async () => {
|
||||
const { POST } = await import("@/app/api/measurements/batch/route");
|
||||
|
||||
await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
weightEntry({
|
||||
externalId: "local-manual-far",
|
||||
source: "MANUAL",
|
||||
startDate: "2026-06-04T07:30:00.000Z",
|
||||
endDate: "2026-06-04T07:30:00.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// A full minute later — distinct reading, not the mirror pair.
|
||||
const second = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
weightEntry({
|
||||
externalId: "hk-uuid-far",
|
||||
source: "APPLE_HEALTH",
|
||||
startDate: "2026-06-04T07:31:00.000Z",
|
||||
endDate: "2026-06-04T07:31:00.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(second.status).toBe(200);
|
||||
const secondJson = (await second.json()) as {
|
||||
data: { inserted: number; duplicates: number };
|
||||
};
|
||||
expect(secondJson.data.inserted).toBe(1);
|
||||
expect(secondJson.data.duplicates).toBe(0);
|
||||
|
||||
const stored = await getPrismaClient().measurement.findMany({
|
||||
where: { userId: TEST_USER_ID, type: "WEIGHT" },
|
||||
});
|
||||
expect(stored).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("leaves the stats:* overwrite contract untouched (no cross-source collapse)", async () => {
|
||||
const { POST } = await import("@/app/api/measurements/batch/route");
|
||||
|
||||
const externalId = "stats:HKQuantityTypeIdentifierStepCount:2026-06-04";
|
||||
// A MANUAL same-day-total then an APPLE_HEALTH re-post must NOT
|
||||
// collapse — stats:* rows are per-day aggregates, excluded from the
|
||||
// merge. They are distinct (type, source) rows and each keeps its
|
||||
// own overwrite lane.
|
||||
await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
{
|
||||
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
|
||||
value: 4000,
|
||||
unit: "count",
|
||||
startDate: "2026-06-04T00:00:00.000Z",
|
||||
endDate: "2026-06-04T12:00:00.000Z",
|
||||
externalId,
|
||||
source: "MANUAL",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const second = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
{
|
||||
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
|
||||
value: 4000,
|
||||
unit: "count",
|
||||
startDate: "2026-06-04T00:00:00.000Z",
|
||||
endDate: "2026-06-04T12:00:00.000Z",
|
||||
externalId,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(second.status).toBe(200);
|
||||
const secondJson = (await second.json()) as {
|
||||
data: { inserted: number; duplicates: number };
|
||||
};
|
||||
// Different source on a stats:* externalId → distinct row, inserted.
|
||||
expect(secondJson.data.inserted).toBe(1);
|
||||
expect(secondJson.data.duplicates).toBe(0);
|
||||
|
||||
const stored = await getPrismaClient().measurement.findMany({
|
||||
where: { userId: TEST_USER_ID, externalId },
|
||||
});
|
||||
expect(stored).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an out-of-range source value with 422", async () => {
|
||||
const { POST } = await import("@/app/api/measurements/batch/route");
|
||||
|
||||
|
||||
Reference in new issue
Block a user