Files
HealthLog/src/__tests__/measurement-write-tail-guard.test.ts
T
Marc-André Bombeck 83c06051fe fix(garmin): read weigh-ins in both shapes, keep refused resources out of the way
Weigh-ins: Garmin's document has no samplePk and gives kilograms, the live
service sends a samplePk and grams. A weigh-in now takes its id from
samplePk or else its own GMT instant, reads each mass by magnitude (above
1000 is grams), and is written whole or not at all: a missing weight, a
missing instant or one implausible value drops every row of it, counted
on the wide event by reason.

Refused resources: a lasting 4xx on one resource (a cycle summary for an
account that tracks no cycle) is noted once on the progress column,
counts as done for the backfill, is skipped by the hourly sync and is
listed in the card's details. It no longer holds the backfill open or
turns the connection red. The cycle leg never parks the connection,
whatever it is refused with.

Backfill: weigh-ins walk back to the start of Garmin Connect on their
own, so history before the first watch day and a scale-only account
arrive. Progress and the completion stamp are written only while the
connection the slice started on is still the stored one.

Key: connect sends the key to the one service the person picks (Garmin
Connect or Garmin Connect China), never to both. A parked key is asked
about again once a day with a single token exchange and reconnected when
Garmin accepts it. The key's end is read from the token's own iat and
exp, an estimate the key outlived is cleared after an accepted exchange,
and the validity can be corrected from the card's menu (PATCH
/api/garmin/key-expiry, with the day the key was created) without
pasting it again.

Sync: an incremental run reaches back to the last successful sync, up to
a year, so an outage longer than the 30-day window leaves no gap; a night
Garmin no longer reports (deleted, or turned into a nap) is retired.

Card: connect errors are translated from meta.errorCode in every locale.

Types: BODY_BATTERY, STRESS_LEVEL, TRAINING_READINESS and
ACUTE_TRAINING_LOAD get source ladders of their own (the device or the
bridge first, then the hand-entered sources in their usual order), join
the ranked types and the provenance picker, and the recovery-empty
description names them.
2026-11-04 10:56:52 +01:00

234 lines
10 KiB
TypeScript

/**
* Every place that writes `Measurement` rows either runs the shared
* post-write tail or says here why it does not.
*
* Two readers hang off every measurement write: the rollup buckets the write
* dirtied, and the cached status assessments of the types it touched. The
* shared tail (`afterMeasurementMutation`) runs both. Several write paths
* refreshed the rollups by hand and never re-warmed the assessments (the MCP
* writes, the Telegram reply, both importers), so a reading logged there kept
* the day's old assessment until the nightly pass. This guard freezes the set
* of write sites: a new one fails until it is listed with its tail.
*
* A tripwire, not a proof: it cannot tell whether a listed reason is still
* true, only that the set did not change without someone editing this file.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/**
* `prisma.measurement.create(`, `tx.measurement.deleteMany(`, … Whitespace
* and line breaks between the parts are tolerated so a formatter reflow does
* not hide a site.
*/
const WRITE_RE =
/\.\s*measurement\s*\.\s*(?:create|createMany|createManyAndReturn|upsert|update|updateMany|delete|deleteMany)\s*\(/g;
/** Runs `afterMeasurementMutation` on the rows it wrote. */
const TAIL = "runs afterMeasurementMutation";
/**
* Writes here; the module named runs `afterMeasurementMutation` on the same
* rows (a service split into stages, whose write and tail are siblings).
*/
const TAIL_VIA_PREFIX = "runs afterMeasurementMutation via ";
const TAIL_VIA = (file: string) => `${TAIL_VIA_PREFIX}${file}`;
/**
* Runs the provider write step (`integrations/sync-kit/after-write.ts`),
* which is `afterMeasurementMutation` plus the arrivals and, where asked, the
* dashboard snapshot.
*/
const PROVIDER_TAIL = "runs afterProviderWrite";
/**
* Every file with a measurement write, and how it keeps the two readers
* current. A file listed here must still contain a write (a stale entry
* fails too).
*/
const WRITE_SITES: Record<string, string> = {
"lib/import/csv-write.ts": TAIL,
"lib/import/json/measurements.ts": TAIL_VIA("lib/import/json/run.ts"),
"app/api/measurements/[id]/route.ts": TAIL,
"app/api/measurements/bulk-delete/route.ts": TAIL,
"app/api/measurements/by-external-ids/route.ts": TAIL,
"app/api/measurements/restore/route.ts": TAIL,
"lib/measurements/batch-ingest/write-measurements.ts": TAIL_VIA(
"lib/measurements/batch-ingest/tail.ts",
),
"lib/measurements/create/create-readings.ts": TAIL,
"app/api/mental-health/assessments/route.ts": TAIL,
"lib/insights/score-row.ts": TAIL,
"lib/mcp/writes.ts": TAIL,
"lib/measurements/create-from-telegram.ts": TAIL,
"lib/whoop/sync-body.ts": TAIL,
"lib/whoop/webhook-handler.ts": TAIL,
// Retiring the rows of Oura sleep periods the person rejected or deleted.
"lib/oura/sync.ts": TAIL,
// Retiring the rows of nights Garmin no longer reports.
"lib/garmin/sync.ts": TAIL,
// Retiring the equivalent-distance rows earlier Oura syncs wrote.
"lib/oura/retire-distance.ts": TAIL,
// Integration syncs: each collects the (type, day) pairs it touched and
// runs the provider write step over them.
"lib/fitbit/sync-core.ts": PROVIDER_TAIL,
"lib/google-health/sync-core.ts": PROVIDER_TAIL,
"lib/nightscout/sync.ts": PROVIDER_TAIL,
"lib/withings/sync.ts": PROVIDER_TAIL,
"lib/oura/sync-heart-rate.ts":
"collects the days each heart-rate page touched; refolds them and re-warms the type once per window",
"lib/withings/sync-activity.ts":
"refolds the touched days (and the day a moved row left) and re-warms the touched types",
"lib/withings/sync-ecg.ts":
"refolds the touched days and re-warms the touched types at the end of the sync",
// The reconcile primitive writes one row inside the caller's transaction;
// its callers (Polar, Oura, WHOOP, the Apple export import) collect the
// verdicts' dirty identities and run the tail once per batch.
"lib/measurements/reconcile-external-measurement.ts":
"a primitive; every caller runs the tail over the verdicts' dirty identities",
"lib/measurements/import-apple-health-export.ts":
"the import worker refolds the import's span and re-warms every imported type",
"lib/measurements/import-unchanged-rows.ts":
"the Apple export import's natural-key rescue, inside the import; its worker runs both legs once over the whole import",
"lib/import/health-connect/import.ts":
"the Health Connect import worker refolds the import's span and re-warms every imported type",
"lib/export/measurements-restore.ts":
"a restore replaces the whole record: it refolds the whole window and drops every per-user cache",
// Value-preserving reshapes: the same readings folded into daily or hourly
// rows. The rollups are refolded; the figures an assessment reads do not
// change, so re-warming would only re-bill the same text.
"lib/measurements/consolidate-daily-mean.ts":
"value-preserving consolidation; refolds the touched days",
"lib/measurements/consolidate-legacy-steps.ts":
"value-preserving consolidation; refolds the touched days",
"lib/measurements/drain-per-sample-cumulative.ts":
"value-preserving consolidation; refolds the touched days",
"lib/measurements/dense-intraday-hourly-rebuild.ts":
"value-preserving hourly fold; refolds the touched spans",
"lib/measurements/dense-intraday-retention.ts":
"value-preserving retention fold; refolds the touched days",
"lib/measurements/dense-fold-removal.ts":
"removes the samples the retention fold has just taken into its hourly means, inside the fold's transaction; the fold refolds the touched days",
"lib/jobs/sleep-timeline-backfill.ts":
"one-shot re-stamp of stored sleep rows onto their timeline; refolds the touched days",
"lib/jobs/step-consolidation-repair.ts":
"one-shot restore of step rows an old consolidation removed; refolds the touched days",
"lib/sleep/sweep-stale-segments.ts":
"tombstones re-scored sleep segments inside a sync; the calling sync refolds the night",
// Writes that do not change a reading.
"lib/jobs/note-encryption-backfill.ts":
"encrypts the note column in place; no value, instant or type changes",
"lib/jobs/measurement-tombstone-cleanup.ts":
"hard-deletes rows that were already soft-deleted and read nowhere",
"lib/jobs/compaction-tombstone-purge.ts":
"hard-deletes compaction tombstones, rows already soft-deleted and read nowhere",
"lib/jobs/measurement-fold-repair.ts":
"corrects the value of a folded stats: mean in place, takes the window's live samples into it, and refolds the DAY rollup of a touched daily mean itself; no instant or type changes",
"lib/measurements/fold-constituents.ts":
"soft-deletes the samples a fold or the fold repair has just taken into a recomputed stats: mean, which accounts for them from then on",
};
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.sort();
}
function writeCounts(): Map<string, number> {
const out = new Map<string, number>();
for (const rel of sourceFiles()) {
const matches = readFileSync(join(SRC, rel), "utf8").match(WRITE_RE);
if (matches && matches.length > 0) out.set(rel, matches.length);
}
return out;
}
describe("measurement write tail", () => {
const counts = writeCounts();
it("finds the write sites at all", () => {
// An empty match set would agree with any allowlist.
expect(counts.size).toBeGreaterThanOrEqual(30);
});
it("lists every file that writes measurements", () => {
const unlisted = [...counts.keys()].filter((f) => !(f in WRITE_SITES));
expect(
unlisted,
"a new measurement write site: run afterMeasurementMutation on the rows it writes, or list it here with the reason it does not",
).toEqual([]);
});
it("has no stale entries", () => {
const stale = Object.keys(WRITE_SITES).filter((f) => !counts.has(f));
expect(stale).toEqual([]);
});
it("the files that claim the shared tail call it", () => {
const claims = Object.entries(WRITE_SITES).filter(
([, reason]) => reason === TAIL || reason.startsWith(TAIL_VIA_PREFIX),
);
// Both shapes are in use; a set that lost either would check nothing.
expect(claims.filter(([, r]) => r === TAIL).length).toBeGreaterThan(5);
expect(
claims.filter(([, r]) => r.startsWith(TAIL_VIA_PREFIX)).length,
).toBeGreaterThan(0);
const missing = claims
.map(([file, reason]) =>
reason.startsWith(TAIL_VIA_PREFIX)
? reason.slice(TAIL_VIA_PREFIX.length)
: file,
)
.filter(
(file) =>
!/afterMeasurementMutation\s*\(/.test(
readFileSync(join(SRC, file), "utf8"),
),
);
expect(missing).toEqual([]);
});
it("the files that claim the provider write step call it", () => {
const claimed = Object.entries(WRITE_SITES)
.filter(([, reason]) => reason === PROVIDER_TAIL)
.map(([file]) => file);
expect(claimed.length).toBeGreaterThan(0);
const missing = claimed.filter(
(file) =>
!/afterProviderWrite\s*\(/.test(readFileSync(join(SRC, file), "utf8")),
);
expect(missing).toEqual([]);
});
it("the syncs that run both legs by hand call both", () => {
const handRolled = Object.entries(WRITE_SITES)
.filter(([, reason]) => reason.includes("re-warms"))
.map(([file]) => file)
// The two export importers leave both legs to their workers, which
// run them once over the whole import.
.filter(
(file) =>
file !== "lib/measurements/import-apple-health-export.ts" &&
file !== "lib/import/health-connect/import.ts",
);
expect(handRolled.length).toBeGreaterThan(0);
const missing = handRolled.filter((file) => {
const src = readFileSync(join(SRC, file), "utf8");
return !(
/recomputeBucketsFor(Measurement|Keys)|afterMeasurementMutation/.test(
src,
) &&
/invalidateStatusInsightsForTypes|afterMeasurementMutation/.test(src)
);
});
expect(missing).toEqual([]);
});
});