feat(sleep): backfill existing WHOOP + Withings sleep rows after the stamp fix

The WHOOP timeline reconstruction and the Withings END-stamp change the
stored measuredAt (and, for WHOOP, the externalId), so existing rows
keep the old broken shape until re-synced. An in-place update collides
with the (userId, type, measuredAt, source, sleepStage) unique index, so
the backfill deletes the affected SLEEP_DURATION rows for the source and
re-syncs from scratch; the re-sync re-folds the rollup tier in its tail.

Add a boot-time pg-boss one-shot per connection, modelled on the WHOOP /
Fitbit backfills: discovery enqueues one job per (user, provider) where
sleep_timeline_backfill_at is null, the per-user pass deletes + re-syncs
and stamps the marker, and the pass is idempotent across reboots. The
queue is registered in allQueues, wired to a handler, and enqueued at
boot.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:16:33 +02:00
1 parent 7ad0b9c4f4
commit ddfed17fa3
6 files changed
+453

No files matched your search

@@ -0,0 +1,20 @@
-- v1.17.1 — one-shot sleep-timeline backfill markers.
--
-- WHOOP's v2 API returns only per-stage sleep DURATION totals (no onset
-- timestamps), and the pre-fix mapper stamped all five stage totals on the one
-- sleep-END instant. The hypnogram then reconstructed every stage as a span
-- touching the night's right edge — the stacked-bar artefact with no clock
-- times. The fix reconstructs an ordered, contiguous per-segment timeline.
--
-- Withings stamped each sleep segment with its START while every reader treats
-- `measured_at` as the segment END, shifting each night one segment-length
-- earlier. The fix stamps the END.
--
-- Both fixes change the stored row shape / instant, so existing rows must be
-- re-synced. The boot-time backfill (per connection) deletes the affected
-- SLEEP_DURATION rows, re-syncs with the corrected mapper, re-folds the rollup
-- tier, and stamps `sleep_timeline_backfill_at` so the discovery query drops
-- the connection. Null = backfill not yet run; the marker makes the one-shot
-- idempotent across reboots.
ALTER TABLE "whoop_connections" ADD COLUMN "sleep_timeline_backfill_at" TIMESTAMP(3);
ALTER TABLE "withings_connections" ADD COLUMN "sleep_timeline_backfill_at" TIMESTAMP(3);
+14
View File
@@ -2150,6 +2150,13 @@ model WithingsConnection {
/// has never re-authed since v1.4.25 — the UI surfaces a banner
/// inviting them to reconnect.
scope String? @map("scope")
/// v1.17.1 — one-shot marker for the sleep-stamp backfill. Pre-fix Withings
/// sleep rows were stamped with the segment START; the reader treats
/// `measuredAt` as the END, shifting every night one segment-length earlier.
/// The boot-time backfill deletes the affected WITHINGS / SLEEP_DURATION rows
/// and re-syncs with the corrected END stamp, then sets this so the discovery
/// query drops the connection. Null = backfill not yet run.
sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -2226,6 +2233,13 @@ model WhoopConnection {
/// Set once the self-converging history backfill walks every collection to
/// completion. Null = backfill still in flight (or never started).
backfillCompletedAt DateTime? @map("backfill_completed_at")
/// v1.17.1 — one-shot marker for the sleep-timeline backfill. Pre-fix WHOOP
/// sleep rows stamped all five stage totals on the one sleep-END instant, so
/// the hypnogram stacked them on the night's right edge. The boot-time
/// backfill deletes the legacy summary rows and re-syncs the reconstructed
/// per-segment timeline, then sets this so the discovery query drops the
/// connection. Null = backfill not yet run.
sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at")
/// Max heart rate from the WHOOP body-measurement profile — a profile
/// constant, not a time series, so it lives here rather than as a
/// Measurement row.