mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(ingest): keep the batch sample's unit captured, not authoritative
The record-unit conversion is now opt-in per caller and only the `export.xml` archive path opts in. There the attribute is authoritative: Apple stamps every quantity `<Record>` with the account's own display unit, so `km` means kilometres and the reading is meaningless without it. `POST /api/measurements/batch` keeps its documented contract — `unit` is captured for audit and never read. Nothing in the tree pins the native client's unit strings for the seventeen convertible identifiers, and a client that reads a sample in metres while stamping the person's display unit into the field would have had every walking distance multiplied by a thousand on ingest. Reading the field there is a wire change and needs the client's strings pinned first. The mapping-table audit and the shared conversion module stay. The batch path's identity behaviour is written down where it is decided and pinned twice: every convertible identifier round-trips a sibling unit unchanged by default, and a walking-distance entry sent as `km` is stored as it arrived. The OpenAPI description says what the server actually does. Review finding H2.
This commit is contained in:
1 parent
e031e2211c
commit
65e9ecbde6
6 files changed
+161
-30
No files matched your search
@@ -390,6 +390,38 @@ describe("mapAppleHealthEntry", () => {
|
||||
expect(out!.takenAt.toISOString()).toBe(ts);
|
||||
});
|
||||
|
||||
it("stores a batch reading in the unit the table assumes, whatever `unit` says", () => {
|
||||
// issue #944 — the batch route's `unit` is captured for audit and never
|
||||
// read. A client that stamps the person's DISPLAY unit into the field
|
||||
// must not have its metres multiplied by a thousand: the conversion is
|
||||
// opt-in and only the `export.xml` path opts in.
|
||||
const out = mapAppleHealthEntry({
|
||||
hkIdentifier: "HKQuantityTypeIdentifierDistanceWalkingRunning",
|
||||
value: 2484,
|
||||
unit: "km",
|
||||
startDate: ts,
|
||||
endDate: ts,
|
||||
});
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!.value).toBe(2484);
|
||||
expect(out!.unit).toBe("m");
|
||||
});
|
||||
|
||||
it("converts out of the record's own unit when the caller opts in", () => {
|
||||
const out = mapAppleHealthEntry(
|
||||
{
|
||||
hkIdentifier: "HKQuantityTypeIdentifierDistanceWalkingRunning",
|
||||
value: 2.484,
|
||||
unit: "km",
|
||||
startDate: ts,
|
||||
endDate: ts,
|
||||
},
|
||||
{ convertRecordUnit: true },
|
||||
);
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!.value).toBeCloseTo(2484, 9);
|
||||
});
|
||||
|
||||
it("applies the unit conversion for fraction-shaped quantities", () => {
|
||||
const out = mapAppleHealthEntry({
|
||||
hkIdentifier: "HKQuantityTypeIdentifierOxygenSaturation",
|
||||
|
||||
@@ -54,8 +54,9 @@ export interface AppleHealthMapping {
|
||||
* Apple's `export.xml` writes the ACCOUNT's display unit on every
|
||||
* quantity `<Record>`, not the unit this table assumes, so
|
||||
* `mapAppleHealthEntry()` converts the reading from the record's own
|
||||
* unit into `hkUnit` before `convertToDbUnit` runs. That only works for
|
||||
* a unit `convertHkValue()` knows a factor for. An entry whose `hkUnit`
|
||||
* unit into `hkUnit` before `convertToDbUnit` runs — on the archive
|
||||
* path, which is the caller that opts in. That only works for a unit
|
||||
* `convertHkValue()` knows a factor for. An entry whose `hkUnit`
|
||||
* is NOT convertible (a dimensionless count, a pinned event, a
|
||||
* logarithmic dB level, a compound rate) must say here why no record
|
||||
* unit can change its reading — the structural guard
|
||||
@@ -1205,10 +1206,12 @@ export interface AppleHealthEntryInput {
|
||||
/** Numeric value as Apple delivers it (pre-conversion). */
|
||||
value: number;
|
||||
/**
|
||||
* Apple's unit string for this reading. issue #944 — the mapper converts
|
||||
* the value from this unit into the table's `hkUnit` when the two differ
|
||||
* and a factor is known (`km` -> `m`, `lb` -> `kg`, `degF` -> `degC`, …);
|
||||
* an unknown or unplaceable unit leaves the reading untouched.
|
||||
* Apple's unit string for this reading, captured for audit.
|
||||
*
|
||||
* Read ONLY when the caller opts in with `convertRecordUnit` — see
|
||||
* `AppleHealthEntryOptions`. Every other caller gets the historical
|
||||
* contract: the field is recorded and the reading is taken to already
|
||||
* be in the table's `hkUnit`.
|
||||
*/
|
||||
unit: string;
|
||||
/** ISO timestamp string (e.g. HealthKit `startDate`). */
|
||||
@@ -1244,6 +1247,33 @@ export interface AppleHealthEntryInput {
|
||||
valueMax?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-call options for `mapAppleHealthEntry()`.
|
||||
*/
|
||||
export interface AppleHealthEntryOptions {
|
||||
/**
|
||||
* Convert the reading out of `input.unit` and into the table's `hkUnit`
|
||||
* before `convertToDbUnit` runs. Default FALSE.
|
||||
*
|
||||
* issue #944 — only the `export.xml` archive path passes `true`, because
|
||||
* only there is the unit attribute known to be authoritative: Apple
|
||||
* stamps every quantity `<Record>` with the ACCOUNT's display unit, so a
|
||||
* metric archive reports walking distance in `km` and the number is
|
||||
* meaningless without it.
|
||||
*
|
||||
* `POST /api/measurements/batch` deliberately does NOT opt in. Its `unit`
|
||||
* is documented as captured-for-audit, not validated, and the native
|
||||
* client's unit strings for the seventeen convertible identifiers are not
|
||||
* pinned by anything in this repository — no fixture, no contract test, no
|
||||
* coordination note. A client that reads a sample in metres and stamps the
|
||||
* person's DISPLAY unit (`km`) into the field would have every walking
|
||||
* distance multiplied by a thousand on ingest. Reading the field there is
|
||||
* a wire change, and a wire change needs the client's strings pinned
|
||||
* first.
|
||||
*/
|
||||
convertRecordUnit?: boolean;
|
||||
}
|
||||
|
||||
/** Output of `mapAppleHealthEntry()`. */
|
||||
export interface AppleHealthEntryOutput {
|
||||
type: MeasurementType;
|
||||
@@ -1283,6 +1313,7 @@ export interface AppleHealthEntryOutput {
|
||||
*/
|
||||
export function mapAppleHealthEntry(
|
||||
input: AppleHealthEntryInput,
|
||||
options: AppleHealthEntryOptions = {},
|
||||
): AppleHealthEntryOutput | null {
|
||||
const mapping = APPLE_HEALTH_TYPE_MAP[input.hkIdentifier];
|
||||
if (!mapping) return null;
|
||||
@@ -1294,10 +1325,14 @@ export function mapAppleHealthEntry(
|
||||
// `hkUnit`, but Apple's `export.xml` stamps each `<Record>` with the
|
||||
// ACCOUNT's own display unit: a metric archive reports walking distance
|
||||
// in `km`, an imperial one in `mi`, body mass in `lb`, energy in `kJ`.
|
||||
// Normalise the reading into `hkUnit` first; a unit we cannot place
|
||||
// leaves the value untouched (see `convertHkValue`).
|
||||
// The archive path opts in and the reading is normalised into `hkUnit`
|
||||
// first; a unit we cannot place leaves the value untouched (see
|
||||
// `convertHkValue`). Every other caller keeps the identity behaviour —
|
||||
// see `AppleHealthEntryOptions.convertRecordUnit` for why.
|
||||
const toHkUnit = (raw: number): number =>
|
||||
convertHkValue(raw, input.unit, mapping.hkUnit) ?? raw;
|
||||
options.convertRecordUnit
|
||||
? (convertHkValue(raw, input.unit, mapping.hkUnit) ?? raw)
|
||||
: raw;
|
||||
|
||||
const value = mapping.convertToDbUnit(toHkUnit(input.value));
|
||||
|
||||
|
||||
@@ -855,14 +855,21 @@ export async function streamParseExportXml(
|
||||
return;
|
||||
}
|
||||
|
||||
const mapped = mapAppleHealthEntry({
|
||||
hkIdentifier: hkType,
|
||||
value: parsedValue.value,
|
||||
unit: attrs.unit ?? mapping.hkUnit,
|
||||
startDate: attrs.startDate,
|
||||
endDate: attrs.endDate,
|
||||
sleepStage: parsedValue.sleepStage,
|
||||
});
|
||||
const mapped = mapAppleHealthEntry(
|
||||
{
|
||||
hkIdentifier: hkType,
|
||||
value: parsedValue.value,
|
||||
unit: attrs.unit ?? mapping.hkUnit,
|
||||
startDate: attrs.startDate,
|
||||
endDate: attrs.endDate,
|
||||
sleepStage: parsedValue.sleepStage,
|
||||
},
|
||||
// issue #944 — the archive is the one caller whose unit attribute
|
||||
// is authoritative: Apple writes the account's own display unit on
|
||||
// every quantity `<Record>`, so `km` here means kilometres and the
|
||||
// reading has to be converted before it is stored.
|
||||
{ convertRecordUnit: true },
|
||||
);
|
||||
if (!mapped) {
|
||||
unknown[`${hkType}::map_failed`] =
|
||||
(unknown[`${hkType}::map_failed`] ?? 0) + 1;
|
||||
|
||||
@@ -58,7 +58,7 @@ const batchEntrySchema = z
|
||||
.min(1)
|
||||
.max(60)
|
||||
.describe(
|
||||
"Apple's `HKUnit` string for `value`. The server converts the reading into the unit it stores the identifier in when the two differ and the factor is known (`km` to `m`, `lb` to `kg`, `degF` to `degC`, and their siblings); a unit the server cannot place leaves the reading untouched.",
|
||||
"Apple's `HKUnit` string for `value`, captured for audit. The server does NOT convert from it: `value` is read in the unit HealthLog stores the identifier in (`m` for walking distance, `kg` for body mass, `kcal` for energy, `degC` for temperature). Send the sample in that unit.",
|
||||
),
|
||||
startDate: z.iso.datetime({ offset: true }),
|
||||
endDate: z.iso.datetime({ offset: true }),
|
||||
|
||||
Reference in new issue
Block a user