mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
fix(import): leave out a marked Apple Health sample only when this account holds it
A sample carrying the HealthLog origin marker was always dropped from an export import. After a move to a new instance without a backup, the export is how the mirrored values come back, and every one of them was silently left out. A marked sample now counts as written by HealthLog only when its HKExternalUUID names a row of this account of the same kind, deleted rows included; a marked cycle sample only when the account holds a day-log on its day. Every other marked sample is imported, and still meets the manual-value match. A correlation's own HKExternalUUID now covers records that carry none.
This commit is contained in:
1 parent
090e3d9a7e
commit
48ed149ef0
4 files changed
+367
-84
No files matched your search
@@ -4,9 +4,13 @@
|
||||
* from the app, and any Withings, import or manual row the server-to-Health
|
||||
* mirror writes. Builds from before the marker existed wrote the id alone.
|
||||
*
|
||||
* Such a sample is already in the database under its own source. When the same
|
||||
* sample comes back through an Apple Health export it is a second copy of that
|
||||
* reading, so the export import leaves it out.
|
||||
* Such a sample is usually already in the database under its own source. When
|
||||
* it comes back through an Apple Health export it is a second copy of that
|
||||
* reading, so the export import leaves it out, but only once its
|
||||
* `HKExternalUUID` names a row of the importing account (a cycle sample: once
|
||||
* that account holds a day-log on its day). The marker alone says HealthLog
|
||||
* wrote the sample, not that this instance still holds it: after a move to a
|
||||
* new instance without a backup, the export is how those values come back.
|
||||
*
|
||||
* This is not the predicate the app's own sync uses. The app decides "our own
|
||||
* echo" from `HKExternalUUID` plus the sample's authoring source being the app
|
||||
|
||||
@@ -122,11 +122,14 @@ export interface ImportJobResult {
|
||||
clinical: { skipped: number };
|
||||
/**
|
||||
* Records left out because HealthLog wrote them into Apple Health itself and
|
||||
* already holds them under their own source, each counted once however often
|
||||
* the export repeats it: `byMarker` carry the app's origin marker,
|
||||
* `byExternalId` carry a HealthLog row id as `HKExternalUUID` (samples from
|
||||
* before the marker existed), `matchedManual` match a manual entry (same type
|
||||
* and value, within 2 s) without either.
|
||||
* this account already holds them under their own source, each counted once
|
||||
* however often the export repeats it: `byMarker` carry the app's origin
|
||||
* marker and a HealthLog row id of this account (a cycle sample: a day-log of
|
||||
* this account on its day), `byExternalId` carry such a row id without the
|
||||
* marker (samples from before the marker existed), `matchedManual` match a
|
||||
* manual entry (same type and value, within 2 s). A marked sample this
|
||||
* account does not hold (a move to a new instance without a backup) is
|
||||
* imported like any other.
|
||||
*/
|
||||
writtenByHealthLog: {
|
||||
byMarker: number;
|
||||
@@ -186,8 +189,11 @@ interface PreparedMeasurement {
|
||||
* at the close tag, once its `<MetadataEntry>` children have been read.
|
||||
*/
|
||||
interface PendingRecord {
|
||||
/** Queue the record; `healthLogId` is the `HKExternalUUID` it carried. */
|
||||
commit: (healthLogId: string | null) => void;
|
||||
/**
|
||||
* Queue the record; `healthLogId` is the `HKExternalUUID` it carried and
|
||||
* `marked` whether it (or its correlation) carried the origin marker.
|
||||
*/
|
||||
commit: (healthLogId: string | null, marked: boolean) => void;
|
||||
ownOrigin: boolean;
|
||||
healthLogId: string | null;
|
||||
/** The record's sample identity, computed only when it is left out. */
|
||||
@@ -369,7 +375,11 @@ export interface StreamParseInput {
|
||||
/** Prisma client to flush rows through. */
|
||||
prisma: Pick<
|
||||
PrismaClient,
|
||||
"measurement" | "workout" | "$transaction" | "$queryRawUnsafe"
|
||||
| "measurement"
|
||||
| "workout"
|
||||
| "cycleDayLog"
|
||||
| "$transaction"
|
||||
| "$queryRawUnsafe"
|
||||
>;
|
||||
/**
|
||||
* Live progress hook. Called every `PROGRESS_TICK_RECORDS` records
|
||||
@@ -449,6 +459,15 @@ export async function streamParseExportXml(
|
||||
sampleKey: string;
|
||||
ownOrigin: boolean;
|
||||
} | null = null;
|
||||
// Marked cycle samples, held to the end of the parse: one counts as written
|
||||
// by HealthLog only when this account holds a day-log on its day.
|
||||
const markedCycle: Array<{
|
||||
hkType: string;
|
||||
dayKey: string;
|
||||
rawValue: string | undefined;
|
||||
protectionUsed?: boolean;
|
||||
sampleKey: string;
|
||||
}> = [];
|
||||
|
||||
// Cumulative fold: type -> local day -> hashed source identity -> subtotal.
|
||||
const cumulativeBucket = new Map<
|
||||
@@ -471,13 +490,25 @@ export async function streamParseExportXml(
|
||||
countedOwn.add(sampleKey);
|
||||
writtenByHealthLog[reason] += 1;
|
||||
};
|
||||
// The `HKExternalUUID` a queued spot row carried, checked at flush.
|
||||
// The `HKExternalUUID` a queued spot row carried, checked at flush, and the
|
||||
// queued rows that carried the origin marker.
|
||||
const healthLogIdOf = new Map<PreparedMeasurement, string>();
|
||||
const markedRows = new Set<PreparedMeasurement>();
|
||||
// Marked cumulative samples that named a HealthLog row id, held to the end
|
||||
// of the parse and added to their day only when the id is not this
|
||||
// account's. Small: one per mirrored total.
|
||||
const markedCumulative: Array<{
|
||||
type: MeasurementType;
|
||||
healthLogId: string;
|
||||
sampleKey: string;
|
||||
add: () => void;
|
||||
}> = [];
|
||||
// A `<Correlation>` (a blood pressure reading) wraps its records, and the
|
||||
// marker may sit on the correlation itself, before or after them. Its records
|
||||
// are held until it closes, then all kept or all left out together.
|
||||
let currentCorrelation: {
|
||||
ownOrigin: boolean;
|
||||
healthLogId: string | null;
|
||||
held: PendingRecord[];
|
||||
} | null = null;
|
||||
// Spot-row batch awaiting flush.
|
||||
@@ -536,12 +567,29 @@ export async function streamParseExportXml(
|
||||
};
|
||||
|
||||
// ── Flush helpers ──────────────────────────────────────────
|
||||
// A sample the app wrote before it stamped its origin marker still carries
|
||||
// the HealthLog row id as `HKExternalUUID`. A hit on one of this account's
|
||||
// rows (deleted ones included: the sample is ours either way) of the same
|
||||
// kind is an exact match. Blood pressure is one row per half, while the app
|
||||
// stamps both samples of a reading with one id (the systolic row's, the
|
||||
// diastolic row's in older builds), so either half matches either id.
|
||||
// A sample the app wrote carries the HealthLog row id as `HKExternalUUID`,
|
||||
// with the origin marker or (before the marker existed) without it. A hit on
|
||||
// one of this account's rows (deleted ones included: the sample is ours
|
||||
// either way) of the same kind is an exact match. The marker alone proves
|
||||
// nothing about THIS account: after a move to a new instance without a
|
||||
// backup, re-importing the export must bring the mirrored values back.
|
||||
// Blood pressure is one row per half, while the app stamps both samples of a
|
||||
// reading with one id (the systolic row's, the diastolic row's in older
|
||||
// builds), so either half matches either id.
|
||||
const ownRowKinds = async (
|
||||
ids: ReadonlySet<string>,
|
||||
): Promise<Map<string, string>> => {
|
||||
const out = new Map<string, string>();
|
||||
const list = [...ids];
|
||||
for (let i = 0; i < list.length; i += 1000) {
|
||||
const known = await prisma.measurement.findMany({
|
||||
where: { userId, id: { in: list.slice(i, i + 1000) } },
|
||||
select: { id: true, type: true },
|
||||
});
|
||||
for (const row of known) out.set(row.id, kindOf(row.type));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const withoutHealthLogIds = async (
|
||||
rows: PreparedMeasurement[],
|
||||
): Promise<PreparedMeasurement[]> => {
|
||||
@@ -551,17 +599,16 @@ export async function streamParseExportXml(
|
||||
if (id) ids.add(id);
|
||||
}
|
||||
if (ids.size === 0) return rows;
|
||||
const known = await prisma.measurement.findMany({
|
||||
where: { userId, id: { in: [...ids] } },
|
||||
select: { id: true, type: true },
|
||||
});
|
||||
if (known.length === 0) return rows;
|
||||
const typeOf = new Map(known.map((row) => [row.id, row.type]));
|
||||
const kindOfId = await ownRowKinds(ids);
|
||||
if (kindOfId.size === 0) return rows;
|
||||
return rows.filter((row) => {
|
||||
const id = healthLogIdOf.get(row);
|
||||
const rowType = id ? typeOf.get(id) : undefined;
|
||||
if (!rowType || kindOf(rowType) !== kindOf(row.type)) return true;
|
||||
countOwn("byExternalId", row.externalId);
|
||||
const rowKind = id ? kindOfId.get(id) : undefined;
|
||||
if (!rowKind || rowKind !== kindOf(row.type)) return true;
|
||||
countOwn(
|
||||
markedRows.has(row) ? "byMarker" : "byExternalId",
|
||||
row.externalId,
|
||||
);
|
||||
return false;
|
||||
});
|
||||
};
|
||||
@@ -663,7 +710,10 @@ export async function streamParseExportXml(
|
||||
const chunk = await withoutManualMirrors(
|
||||
await withoutHealthLogIds(incoming),
|
||||
);
|
||||
for (const row of incoming) healthLogIdOf.delete(row);
|
||||
for (const row of incoming) {
|
||||
healthLogIdOf.delete(row);
|
||||
markedRows.delete(row);
|
||||
}
|
||||
for (const row of chunk) widenSpan(row.measuredAt);
|
||||
const insertedArrivals: Array<{
|
||||
id: string;
|
||||
@@ -993,6 +1043,61 @@ export async function streamParseExportXml(
|
||||
}
|
||||
};
|
||||
|
||||
const consumeCycle = (rec: {
|
||||
hkType: string;
|
||||
dayKey: string;
|
||||
rawValue: string | undefined;
|
||||
protectionUsed?: boolean;
|
||||
}): void => {
|
||||
const consumed = cycleAccumulator.consume(
|
||||
rec.hkType,
|
||||
rec.dayKey,
|
||||
rec.rawValue,
|
||||
rec.protectionUsed,
|
||||
);
|
||||
if (!consumed) {
|
||||
// Recognised identifier but unrecognised value — count it under
|
||||
// `unknown` with the reason tag so operators can spot it.
|
||||
unknown[`${rec.hkType}::cycle_unmapped`] =
|
||||
(unknown[`${rec.hkType}::cycle_unmapped`] ?? 0) + 1;
|
||||
}
|
||||
};
|
||||
|
||||
// Marked cumulative samples: left out when their id is a row of this
|
||||
// account of the same kind, added to their day otherwise.
|
||||
const settleMarkedCumulative = async (): Promise<void> => {
|
||||
if (markedCumulative.length === 0) return;
|
||||
const kindOfId = await ownRowKinds(
|
||||
new Set(markedCumulative.map((m) => m.healthLogId)),
|
||||
);
|
||||
for (const m of markedCumulative.splice(0, markedCumulative.length)) {
|
||||
if (kindOfId.get(m.healthLogId) === kindOf(m.type)) {
|
||||
countOwn("byMarker", m.sampleKey);
|
||||
} else {
|
||||
m.add();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Marked cycle samples: left out when this account holds a day-log on
|
||||
// their day (deleted ones included), folded in otherwise.
|
||||
const settleMarkedCycle = async (): Promise<void> => {
|
||||
if (markedCycle.length === 0) return;
|
||||
const days = [...new Set(markedCycle.map((rec) => rec.dayKey))];
|
||||
const held = new Set<string>();
|
||||
for (let i = 0; i < days.length; i += 1000) {
|
||||
const logs = await prisma.cycleDayLog.findMany({
|
||||
where: { userId, date: { in: days.slice(i, i + 1000) } },
|
||||
select: { date: true },
|
||||
});
|
||||
for (const log of logs) held.add(log.date);
|
||||
}
|
||||
for (const rec of markedCycle.splice(0, markedCycle.length)) {
|
||||
if (held.has(rec.dayKey)) countOwn("byMarker", rec.sampleKey);
|
||||
else consumeCycle(rec);
|
||||
}
|
||||
};
|
||||
|
||||
// ── SAX parser configuration ────────────────────────────────
|
||||
const parser = sax.parser(true, { trim: true });
|
||||
let pendingError: Error | null = null;
|
||||
@@ -1121,27 +1226,44 @@ export async function streamParseExportXml(
|
||||
attrs.startDate,
|
||||
attrs.endDate,
|
||||
),
|
||||
commit: (healthLogId) => {
|
||||
commit: (healthLogId, marked) => {
|
||||
if (CUMULATIVE_HK_TYPES.has(mapped.type)) {
|
||||
const dayKey = dayKeyForUserTz(mapped.takenAt, userTimezone);
|
||||
const sourceHash = hashCumulativeSourceIdentity(
|
||||
attrs.sourceName,
|
||||
attrs.device,
|
||||
);
|
||||
let byDay = cumulativeBucket.get(mapped.type);
|
||||
if (!byDay) {
|
||||
byDay = new Map();
|
||||
cumulativeBucket.set(mapped.type, byDay);
|
||||
const add = () => {
|
||||
let byDay = cumulativeBucket.get(mapped.type);
|
||||
if (!byDay) {
|
||||
byDay = new Map();
|
||||
cumulativeBucket.set(mapped.type, byDay);
|
||||
}
|
||||
let bySource = byDay.get(dayKey);
|
||||
if (!bySource) {
|
||||
bySource = new Map();
|
||||
byDay.set(dayKey, bySource);
|
||||
}
|
||||
bySource.set(
|
||||
sourceHash,
|
||||
(bySource.get(sourceHash) ?? 0) + mapped.value,
|
||||
);
|
||||
};
|
||||
if (marked && healthLogId) {
|
||||
markedCumulative.push({
|
||||
type: mapped.type,
|
||||
healthLogId,
|
||||
sampleKey: hashSampleKey(
|
||||
hkType,
|
||||
attrs.value ?? "",
|
||||
attrs.startDate,
|
||||
attrs.endDate,
|
||||
),
|
||||
add,
|
||||
});
|
||||
} else {
|
||||
add();
|
||||
}
|
||||
let bySource = byDay.get(dayKey);
|
||||
if (!bySource) {
|
||||
bySource = new Map();
|
||||
byDay.set(dayKey, bySource);
|
||||
}
|
||||
bySource.set(
|
||||
sourceHash,
|
||||
(bySource.get(sourceHash) ?? 0) + mapped.value,
|
||||
);
|
||||
} else {
|
||||
// Spot row: derive a stable externalId, queue for flush.
|
||||
const externalId = hashSampleKey(
|
||||
@@ -1163,6 +1285,7 @@ export async function streamParseExportXml(
|
||||
};
|
||||
spotBatch.push(row);
|
||||
if (healthLogId) healthLogIdOf.set(row, healthLogId);
|
||||
if (marked) markedRows.add(row);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1244,7 +1367,7 @@ export async function streamParseExportXml(
|
||||
}
|
||||
|
||||
if (name === "Correlation") {
|
||||
currentCorrelation = { ownOrigin: false, held: [] };
|
||||
currentCorrelation = { ownOrigin: false, healthLogId: null, held: [] };
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1255,8 +1378,12 @@ export async function streamParseExportXml(
|
||||
else if (currentCycleRecord) currentCycleRecord.ownOrigin = true;
|
||||
else if (currentCorrelation) currentCorrelation.ownOrigin = true;
|
||||
}
|
||||
if (pendingRecord && attrs.key === HK_EXTERNAL_UUID_METADATA_KEY) {
|
||||
pendingRecord.healthLogId = attrs.value || null;
|
||||
if (attrs.key === HK_EXTERNAL_UUID_METADATA_KEY) {
|
||||
// On a record, or on the correlation that wraps it.
|
||||
if (pendingRecord) pendingRecord.healthLogId = attrs.value || null;
|
||||
else if (currentCorrelation && !currentCycleRecord) {
|
||||
currentCorrelation.healthLogId = attrs.value || null;
|
||||
}
|
||||
}
|
||||
// Attach the SexualActivity protection flag to the open cycle record.
|
||||
// Apple writes `HKMetadataKeySexualActivityProtectionUsed` with a
|
||||
@@ -1306,18 +1433,16 @@ export async function streamParseExportXml(
|
||||
const rec = pendingRecord;
|
||||
pendingRecord = null;
|
||||
if (currentCorrelation) currentCorrelation.held.push(rec);
|
||||
else if (rec.ownOrigin) countOwn("byMarker", rec.sampleKey());
|
||||
else rec.commit(rec.healthLogId);
|
||||
else rec.commit(rec.healthLogId, rec.ownOrigin);
|
||||
}
|
||||
if (tagName === "Correlation" && currentCorrelation) {
|
||||
const correlation = currentCorrelation;
|
||||
currentCorrelation = null;
|
||||
for (const rec of correlation.held) {
|
||||
if (correlation.ownOrigin || rec.ownOrigin) {
|
||||
countOwn("byMarker", rec.sampleKey());
|
||||
} else {
|
||||
rec.commit(rec.healthLogId);
|
||||
}
|
||||
rec.commit(
|
||||
rec.healthLogId ?? correlation.healthLogId,
|
||||
correlation.ownOrigin || rec.ownOrigin,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (tagName === "Workout" && currentWorkout) {
|
||||
@@ -1328,22 +1453,12 @@ export async function streamParseExportXml(
|
||||
const rec = currentCycleRecord;
|
||||
currentCycleRecord = null;
|
||||
if (rec.ownOrigin) {
|
||||
// A day HealthLog mirrored into Apple Health from its own day-log.
|
||||
countOwn("byMarker", rec.sampleKey);
|
||||
// A day HealthLog mirrored into Apple Health from a day-log; decided
|
||||
// at the end of the parse against this account's day-logs.
|
||||
markedCycle.push(rec);
|
||||
return;
|
||||
}
|
||||
const consumed = cycleAccumulator.consume(
|
||||
rec.hkType,
|
||||
rec.dayKey,
|
||||
rec.rawValue,
|
||||
rec.protectionUsed,
|
||||
);
|
||||
if (!consumed) {
|
||||
// Recognised identifier but unrecognised value — count it under
|
||||
// `unknown` with the reason tag so operators can spot it.
|
||||
unknown[`${rec.hkType}::cycle_unmapped`] =
|
||||
(unknown[`${rec.hkType}::cycle_unmapped`] ?? 0) + 1;
|
||||
}
|
||||
consumeCycle(rec);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1421,7 +1536,9 @@ export async function streamParseExportXml(
|
||||
// threshold during the parse.
|
||||
await flushSpotBatch();
|
||||
await flushWorkoutBatch();
|
||||
await settleMarkedCumulative();
|
||||
await flushCumulativeBuckets();
|
||||
await settleMarkedCycle();
|
||||
|
||||
// v1.15.0 — fold the accumulated reproductive samples into CYCLE
|
||||
// day-logs. Gated on cycle-tracking being enabled for the account so a
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
verbatim; that has to be checked against a real export (see #1109).
|
||||
|
||||
Records, in order:
|
||||
1. weight 80.1 carries the HealthLog origin marker -> left out
|
||||
1. weight 80.1 origin marker and a HealthLog row id -> left out
|
||||
when the importing account holds that row
|
||||
2. weight 80.2 user-entered, no marker -> imported
|
||||
3. weight 80.3 marker among other metadata entries -> left out
|
||||
3. weight 80.3 marker among other metadata entries, no id -> imported
|
||||
4. weight 80.4 no metadata at all -> imported
|
||||
5. weight 80.5 origin key with another value -> imported
|
||||
-->
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
* twice and the rollups count it twice.
|
||||
*
|
||||
* Asserted against a real Postgres:
|
||||
* - a record carrying the marker is left out, and counted;
|
||||
* - a record carrying the marker and a row id of this account is left out,
|
||||
* and counted; the marker alone proves nothing about this account (a move
|
||||
* to a new instance without a backup), so a marked record whose id is
|
||||
* missing, unknown or another account's is imported, a deleted row of this
|
||||
* account still counts, and a marked cycle day counts only when the
|
||||
* account holds a day-log on it;
|
||||
* - a record without it lands as before;
|
||||
* - the marker is read from a child `<MetadataEntry>` that follows the
|
||||
* record's open tag, so the record is committed at its close tag;
|
||||
@@ -56,13 +61,46 @@ ${records}
|
||||
|
||||
const weight = (value: number, end: string, children = "") =>
|
||||
` <Record type="HKQuantityTypeIdentifierBodyMass" sourceName="Health" unit="kg" startDate="${end}" endDate="${end}" value="${value}">${children}</Record>`;
|
||||
const OWN = `<MetadataEntry key="dev.healthlog.app.origin" value="healthlog"/>`;
|
||||
const MARKER = `<MetadataEntry key="dev.healthlog.app.origin" value="healthlog"/>`;
|
||||
const ext = (id: string) =>
|
||||
`<MetadataEntry key="HKExternalUUID" value="${id}"/>`;
|
||||
/** The row the app mirrored: a weight of this account, its id on the sample. */
|
||||
const OWN_WEIGHT_ID = "own-weight-row";
|
||||
const OWN_SYS_ID = "own-sys-row";
|
||||
const OWN = `${MARKER}${ext(OWN_WEIGHT_ID)}`;
|
||||
const OWN_BP = `${MARKER}${ext(OWN_SYS_ID)}`;
|
||||
const OTHER = `<MetadataEntry key="HKWasUserEntered" value="1"/>`;
|
||||
|
||||
/**
|
||||
* A row of this account the samples point at. Withings and ten minutes off the
|
||||
* samples, so neither the series under test nor the manual-value match sees it.
|
||||
*/
|
||||
async function seedOwnRow(
|
||||
userId: string,
|
||||
id: string,
|
||||
type: "WEIGHT" | "BLOOD_PRESSURE_SYS" | "ACTIVITY_STEPS",
|
||||
deletedAt: Date | null = null,
|
||||
) {
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
id,
|
||||
userId,
|
||||
type,
|
||||
value: type === "ACTIVITY_STEPS" ? 1000 : 70,
|
||||
unit:
|
||||
type === "WEIGHT" ? "kg" : type === "ACTIVITY_STEPS" ? "steps" : "mmHg",
|
||||
source: "WITHINGS",
|
||||
measuredAt: new Date("2026-05-14T06:10:00.000Z"),
|
||||
deletedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function run(records: string) {
|
||||
const user = await prisma.user.create({
|
||||
data: { username: "own-samples", email: "own@example.test", role: "USER" },
|
||||
});
|
||||
await seedOwnRow(user.id, OWN_WEIGHT_ID, "WEIGHT");
|
||||
const result = await streamParseExportXml({
|
||||
xmlPath: writeXml(records),
|
||||
userId: user.id,
|
||||
@@ -139,6 +177,7 @@ describe("blood pressure correlations and self-closing records", () => {
|
||||
const user = await prisma.user.create({
|
||||
data: { username: "own-bp", email: "bp@example.test", role: "USER" },
|
||||
});
|
||||
await seedOwnRow(user.id, OWN_SYS_ID, "BLOOD_PRESSURE_SYS");
|
||||
const result = await streamParseExportXml({
|
||||
xmlPath: writeXml(records),
|
||||
userId: user.id,
|
||||
@@ -148,6 +187,7 @@ describe("blood pressure correlations and self-closing records", () => {
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
source: "APPLE_HEALTH",
|
||||
type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] },
|
||||
},
|
||||
orderBy: [{ type: "asc" }],
|
||||
@@ -173,7 +213,7 @@ describe("blood pressure correlations and self-closing records", () => {
|
||||
[bpRecord("Systolic", 121, AT), bpRecord("Diastolic", 79, AT)].join(
|
||||
"\n",
|
||||
),
|
||||
`\n ${OWN}`,
|
||||
`\n ${OWN_BP}`,
|
||||
),
|
||||
);
|
||||
expect(values).toEqual([]);
|
||||
@@ -186,7 +226,7 @@ describe("blood pressure correlations and self-closing records", () => {
|
||||
bpRecord("Diastolic", 79, AT),
|
||||
].join("\n");
|
||||
const { values } = await runBp(
|
||||
` <Correlation type="HKCorrelationTypeIdentifierBloodPressure" sourceName="Health" startDate="${AT}" endDate="${AT}">\n${records}\n ${OWN}\n </Correlation>`,
|
||||
` <Correlation type="HKCorrelationTypeIdentifierBloodPressure" sourceName="Health" startDate="${AT}" endDate="${AT}">\n${records}\n ${OWN_BP}\n </Correlation>`,
|
||||
);
|
||||
expect(values).toEqual([]);
|
||||
});
|
||||
@@ -195,7 +235,7 @@ describe("blood pressure correlations and self-closing records", () => {
|
||||
const { values } = await runBp(
|
||||
correlation(
|
||||
[
|
||||
bpRecord("Systolic", 121, AT, `\n ${OWN}\n `),
|
||||
bpRecord("Systolic", 121, AT, `\n ${OWN_BP}\n `),
|
||||
bpRecord("Diastolic", 79, AT),
|
||||
].join("\n"),
|
||||
),
|
||||
@@ -210,7 +250,7 @@ describe("blood pressure correlations and self-closing records", () => {
|
||||
[bpRecord("Systolic", 121, AT), bpRecord("Diastolic", 79, AT)].join(
|
||||
"\n",
|
||||
),
|
||||
`\n ${OWN}`,
|
||||
`\n ${OWN_BP}`,
|
||||
),
|
||||
bpRecord("Systolic", 130, "2026-05-14 09:00:00 +0200"),
|
||||
].join("\n"),
|
||||
@@ -230,10 +270,11 @@ describe("the synthetic export fixture", () => {
|
||||
// `export-own-origin.synthetic.xml` is invented, in the shape Apple's DTD
|
||||
// documents. It shows the parser reads a file laid out like an export; it
|
||||
// cannot show that Apple writes a custom metadata key verbatim.
|
||||
it("leaves out the two marked records and imports the other three", async () => {
|
||||
it("leaves out the marked record this account holds and imports the other four", async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: { username: "own-fixture", email: "fx@example.test", role: "USER" },
|
||||
});
|
||||
await seedOwnRow(user.id, "00000000-0000-0000-0000-000000000001", "WEIGHT");
|
||||
const result = await streamParseExportXml({
|
||||
xmlPath: join(
|
||||
process.cwd(),
|
||||
@@ -247,9 +288,9 @@ describe("the synthetic export fixture", () => {
|
||||
where: { userId: user.id, type: "WEIGHT", source: "APPLE_HEALTH" },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
});
|
||||
expect(rows.map((r) => r.value)).toEqual([80.2, 80.4, 80.5]);
|
||||
expect(rows.map((r) => r.value)).toEqual([80.2, 80.3, 80.4, 80.5]);
|
||||
expect(result.writtenByHealthLog).toEqual({
|
||||
byMarker: 2,
|
||||
byMarker: 1,
|
||||
byExternalId: 0,
|
||||
matchedManual: 0,
|
||||
});
|
||||
@@ -421,8 +462,6 @@ describe("the manual-mirror lookup stays off the hot path", () => {
|
||||
|
||||
describe("a sample whose HKExternalUUID is a HealthLog row id", () => {
|
||||
const AT = "2026-05-14 08:00:00 +0200";
|
||||
const ext = (id: string) =>
|
||||
`<MetadataEntry key="HKExternalUUID" value="${id}"/>`;
|
||||
|
||||
async function runWith(
|
||||
seed: (userId: string) => Promise<Record<string, string>>,
|
||||
@@ -524,11 +563,12 @@ describe("a sample whose HKExternalUUID is a HealthLog row id", () => {
|
||||
describe("each sample left out is counted once", () => {
|
||||
it("does not count a marked record twice when it appears top level and inside its correlation", async () => {
|
||||
const AT = "2026-05-14 08:00:00 +0200";
|
||||
const sys = bpRecord("Systolic", 121, AT, `\n ${OWN}\n `);
|
||||
const dia = bpRecord("Diastolic", 79, AT, `\n ${OWN}\n `);
|
||||
const sys = bpRecord("Systolic", 121, AT, `\n ${OWN_BP}\n `);
|
||||
const dia = bpRecord("Diastolic", 79, AT, `\n ${OWN_BP}\n `);
|
||||
const user = await prisma.user.create({
|
||||
data: { username: "own-once", email: "once@example.test", role: "USER" },
|
||||
});
|
||||
await seedOwnRow(user.id, OWN_SYS_ID, "BLOOD_PRESSURE_SYS");
|
||||
const result = await streamParseExportXml({
|
||||
xmlPath: writeXml(
|
||||
[sys, dia, correlation([sys, dia].join("\n"))].join("\n"),
|
||||
@@ -542,7 +582,7 @@ describe("each sample left out is counted once", () => {
|
||||
});
|
||||
|
||||
describe("a cycle sample HealthLog wrote itself", () => {
|
||||
it("is left out of the day-log import", async () => {
|
||||
it("is left out on a day this account holds, imported on a day it does not", async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "own-cycle",
|
||||
@@ -552,11 +592,20 @@ describe("a cycle sample HealthLog wrote itself", () => {
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
// The day the app mirrored from: a day-log of this account.
|
||||
await prisma.cycleDayLog.create({
|
||||
data: { userId: user.id, date: "2026-03-02", flow: "MEDIUM" },
|
||||
});
|
||||
const flow = (day: string, children = "") =>
|
||||
` <Record type="HKCategoryTypeIdentifierMenstrualFlow" sourceName="Health" value="HKCategoryValueMenstrualFlowMedium" startDate="${day} 08:00:00 +0000" endDate="${day} 08:00:00 +0000">${children}</Record>`;
|
||||
const result = await streamParseExportXml({
|
||||
xmlPath: writeXml(
|
||||
[flow("2026-03-02", OWN), flow("2026-03-03")].join("\n"),
|
||||
[
|
||||
flow("2026-03-02", MARKER),
|
||||
flow("2026-03-03"),
|
||||
// Marked, but no day-log here: a move to a new instance.
|
||||
flow("2026-03-04", MARKER),
|
||||
].join("\n"),
|
||||
),
|
||||
userId: user.id,
|
||||
userTimezone: "Europe/Berlin",
|
||||
@@ -566,7 +615,119 @@ describe("a cycle sample HealthLog wrote itself", () => {
|
||||
where: { userId: user.id },
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
expect(days.map((d) => d.date)).toEqual(["2026-03-03"]);
|
||||
expect(days.map((d) => d.date)).toEqual([
|
||||
"2026-03-02",
|
||||
"2026-03-03",
|
||||
"2026-03-04",
|
||||
]);
|
||||
// The held day stays the account's own: the marked sample did not
|
||||
// re-import it as an Apple Health day.
|
||||
expect(days[0].source).toBe("MANUAL");
|
||||
expect(result.writtenByHealthLog.byMarker).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a marked record this account does not hold", () => {
|
||||
// A move to a new instance without a backup: the export still carries the
|
||||
// marker on every value the app mirrored, but none of the rows exist here.
|
||||
it("is imported when the id is missing, unknown or another account's", async () => {
|
||||
const other = await prisma.user.create({
|
||||
data: { username: "own-move-2", email: "mv2@example.test", role: "USER" },
|
||||
});
|
||||
await seedOwnRow(other.id, "foreign-weight-row", "WEIGHT");
|
||||
const user = await prisma.user.create({
|
||||
data: { username: "own-move", email: "mv@example.test", role: "USER" },
|
||||
});
|
||||
const result = await streamParseExportXml({
|
||||
xmlPath: writeXml(
|
||||
[
|
||||
weight(80.1, "2026-05-14 08:00:00 +0200", MARKER),
|
||||
weight(
|
||||
80.2,
|
||||
"2026-05-14 09:00:00 +0200",
|
||||
`${MARKER}${ext("00000000-0000-4000-8000-000000000009")}`,
|
||||
),
|
||||
weight(
|
||||
80.3,
|
||||
"2026-05-14 10:00:00 +0200",
|
||||
`${MARKER}${ext("foreign-weight-row")}`,
|
||||
),
|
||||
].join("\n"),
|
||||
),
|
||||
userId: user.id,
|
||||
userTimezone: "Europe/Berlin",
|
||||
prisma,
|
||||
});
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: { userId: user.id, type: "WEIGHT", source: "APPLE_HEALTH" },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
});
|
||||
expect(rows.map((r) => r.value)).toEqual([80.1, 80.2, 80.3]);
|
||||
expect(result.writtenByHealthLog).toEqual({
|
||||
byMarker: 0,
|
||||
byExternalId: 0,
|
||||
matchedManual: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("is still left out when its id names a deleted row of this account", async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "own-deleted",
|
||||
email: "del@example.test",
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
await seedOwnRow(user.id, OWN_WEIGHT_ID, "WEIGHT", new Date());
|
||||
const result = await streamParseExportXml({
|
||||
xmlPath: writeXml(weight(80.1, "2026-05-14 08:00:00 +0200", OWN)),
|
||||
userId: user.id,
|
||||
userTimezone: "Europe/Berlin",
|
||||
prisma,
|
||||
});
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: { userId: user.id, type: "WEIGHT", source: "APPLE_HEALTH" },
|
||||
});
|
||||
expect(rows).toHaveLength(0);
|
||||
expect(result.writtenByHealthLog.byMarker).toBe(1);
|
||||
});
|
||||
|
||||
it("adds a marked cumulative sample to its day unless its id is this account's", async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: { username: "own-steps", email: "st@example.test", role: "USER" },
|
||||
});
|
||||
await seedOwnRow(user.id, "own-steps-row", "ACTIVITY_STEPS");
|
||||
const steps = (value: number, at: string, children: string) =>
|
||||
` <Record type="HKQuantityTypeIdentifierStepCount" sourceName="Health" unit="count" startDate="${at}" endDate="${at}" value="${value}">${children}</Record>`;
|
||||
const result = await streamParseExportXml({
|
||||
xmlPath: writeXml(
|
||||
[
|
||||
steps(
|
||||
700,
|
||||
"2026-05-14 08:00:00 +0200",
|
||||
`${MARKER}${ext("own-steps-row")}`,
|
||||
),
|
||||
steps(
|
||||
300,
|
||||
"2026-05-14 09:00:00 +0200",
|
||||
`${MARKER}${ext("not-a-row")}`,
|
||||
),
|
||||
steps(200, "2026-05-14 10:00:00 +0200", ""),
|
||||
].join("\n"),
|
||||
),
|
||||
userId: user.id,
|
||||
userTimezone: "Europe/Berlin",
|
||||
prisma,
|
||||
});
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: "ACTIVITY_STEPS",
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
});
|
||||
// 300 + 200: the 700 the app mirrored from this account's row stays out.
|
||||
expect(rows.map((r) => r.value)).toEqual([500]);
|
||||
expect(result.writtenByHealthLog.byMarker).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user