Files
HealthLog/src/lib/sleep/sweep-stale-segments.ts
T
Marc-André Bombeck 9c63d3ce20 chore(release): v1.28.25 — every integration held to the same standard
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.
2026-07-11 01:54:12 +02:00

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;
}