mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Platform-wide hardening: the failure classes found live this week hunted down across every integration. Fitbit carried the same tombstone wedge fixed for Google — probe now matches soft-deleted rows and the update resurrects them; the comment describing a partial unique index that never existed is corrected. Sleep segment ids are stable across re-scoring for Withings (session+segment-start), WHOOP/Polar (one segment per stage, no positional index) and Oura (run-start keyed), each with a session-scoped sweep that soft-deletes rows a re-score orphaned — so legacy and affected nights heal as they are re-read. One platform rule for deleted rows: source-owned readings resurrect on re-import (Withings, WHOOP, Oura, Polar, Nightscout, mood webhook, CSV re-imports, Apple stats day-totals); Apple sample-grain deletions stick per the paired-client contract, and the mood bulk route's tombstone match is now a true no-op reported as duplicate. Import counts reconcile against actual createMany counts. Google lifecycle: a dead token registers on the cycle's hard-fail ledger, so an expired connection parks instead of stamping success hourly; backfill and sleep-repair markers stamp only after a clean pass (pg-boss retries are now real); failed measurement writes hold the watermark; no-op updates are skipped; mapping.md tells the current truth. Scale: doctor-report reads only consumed columns and enabled sections; exports and backups keyset-paginate; achievements tally via SQL buckets; series charts day-bucket dense kinds beyond 90 days; BP pairing is a linear two-pointer; distinct-type scans run server-side; the graded-series cold fallback is bounded; the fitbit defer tracker loops instead of spreading.
84 lines
3.6 KiB
TypeScript
84 lines
3.6 KiB
TypeScript
/**
|
|
* Session-scoped sweep of stale sleep-segment rows.
|
|
*
|
|
* Wearables re-score a night after the fact (WHOOP re-scores, Withings
|
|
* re-aggregates, Oura revises the hypnogram, Polar re-settles the stage
|
|
* totals). Before the stable-externalId fix, a re-scored night minted FRESH
|
|
* segment externalIds (positional indexes renumbered, boundaries shifted), so
|
|
* the upsert inserted a second set of rows next to the first and the
|
|
* night-total silently double-counted. The externalIds are stable now, but two
|
|
* classes of orphans remain: rows a re-score genuinely dropped (a segment that
|
|
* no longer exists in the fresh scoring) and every legacy row still keyed on
|
|
* the old volatile format. This sweep clears both, so the id change self-heals
|
|
* without a migration — the same posture as Google Health's
|
|
* `replaceStaleGoogleHealthSleep`, expressed as an externalId-prefix bound
|
|
* instead of a time-window bound because these integrations key every segment
|
|
* row under a per-session externalId prefix.
|
|
*
|
|
* Safety contract (pinned by `__tests__/sweep-stale-segments.test.ts`):
|
|
* - Bounded to the sessions of THIS fetch — each entry sweeps only rows whose
|
|
* `externalId` starts with that one session's prefix. Never a blanket
|
|
* delete.
|
|
* - LIVE rows only (`deletedAt: null`), `SLEEP_DURATION` only, one source.
|
|
* - The fresh id set (`keepIds`) is never touched; an entry with no fresh ids
|
|
* is skipped entirely (a mapper hiccup must not wipe a night).
|
|
* - Soft-delete (`deletedAt = now`), never a hard remove.
|
|
* - Best-effort: a failed sweep warns and moves on — it never fails the
|
|
* user's sync. The session stays inside the integration's re-fetch window,
|
|
* so the next tick retries the sweep.
|
|
*/
|
|
import type { MeasurementSource } from "@/generated/prisma/client";
|
|
|
|
import { prisma } from "@/lib/db";
|
|
import { getEvent } from "@/lib/logging/context";
|
|
|
|
/** One just-fetched sleep session: its externalId prefix + the fresh ids. */
|
|
export interface SleepSegmentSweep {
|
|
/**
|
|
* The session-scoped externalId prefix (e.g. `<sleep-uuid>:` for WHOOP,
|
|
* `withings:sleep:<user>:<sessionId>:` for Withings). Every live
|
|
* `SLEEP_DURATION` row under it that is not in `keepIds` is considered a
|
|
* re-score orphan or a legacy-format duplicate.
|
|
*/
|
|
prefix: string;
|
|
/** The fresh externalIds this fetch produced for the session — never swept. */
|
|
keepIds: string[];
|
|
}
|
|
|
|
/**
|
|
* Soft-delete every live `SLEEP_DURATION` row of `source` whose externalId
|
|
* falls under one of the given session prefixes but was not re-produced by
|
|
* this fetch. Returns the number of rows tombstoned.
|
|
*/
|
|
export async function sweepStaleSleepSegments(
|
|
userId: string,
|
|
source: MeasurementSource,
|
|
sessions: SleepSegmentSweep[],
|
|
): Promise<number> {
|
|
let removed = 0;
|
|
for (const s of sessions) {
|
|
// An empty prefix would unbound the sweep to the whole source; an empty
|
|
// keep-set means this fetch produced nothing for the session (unscored /
|
|
// mapper hiccup) — in both cases deleting would be data loss, so skip.
|
|
if (!s.prefix || s.keepIds.length === 0) continue;
|
|
try {
|
|
const res = await prisma.measurement.updateMany({
|
|
where: {
|
|
userId,
|
|
source,
|
|
type: "SLEEP_DURATION",
|
|
deletedAt: null,
|
|
externalId: { startsWith: s.prefix, notIn: s.keepIds },
|
|
},
|
|
data: { deletedAt: new Date() },
|
|
});
|
|
removed += res.count;
|
|
} catch (err) {
|
|
getEvent()?.addWarning(
|
|
`${source.toLowerCase()}: stale sleep-segment sweep failed for prefix ${s.prefix}: ${err}`,
|
|
);
|
|
}
|
|
}
|
|
return removed;
|
|
}
|