fix(oura): skip rejected and deleted sleep periods

Oura returns detected periods the person dismissed (type rest) and ones
they deleted in the sleep collection. They were imported as sleep, with
their lowest heart rate, HRV and breathing rate, which added an extra
session to the day. They now map to nothing, and rows an earlier sync
stored for such a period are soft-deleted on the next sync through the
shared write tail.
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 15:10:18 +02:00
1 parent d7588fe9bf
commit 35a33ca173
6 files changed
+296 -2

No files matched your search

@@ -51,6 +51,8 @@ const WRITE_SITES: Record<string, string> = {
"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,
// Integration syncs: each collects the (type, day) pairs it touched and
// runs both legs itself at the end of the sync.
"lib/fitbit/sync-core.ts":
+18
View File
@@ -330,6 +330,24 @@ describe("mapReadiness", () => {
});
describe("mapSleep", () => {
it("maps a rejected (`rest`) or deleted period to nothing", () => {
const base: OuraSleep = {
id: "rec-r",
day: "2026-06-10",
bedtime_start: "2026-06-10T14:00:00.000Z",
bedtime_end: "2026-06-10T15:00:00.000Z",
sleep_phase_5_min: "222222222222",
lowest_heart_rate: 58,
average_hrv: 40,
};
expect(mapSleep({ ...base, type: "rest" })).toEqual([]);
expect(mapSleep({ ...base, type: "deleted" })).toEqual([]);
// Periods Oura counts still map, a nap included.
for (const type of ["long_sleep", "sleep", "late_nap", undefined]) {
expect(mapSleep({ ...base, type }).length).toBeGreaterThan(0);
}
});
it("falls back to stage totals (no hypnogram) and record-scopes the externalId", () => {
const s: OuraSleep = {
id: "rec-9",
+69
View File
@@ -415,6 +415,75 @@ describe("syncUserOura", () => {
);
});
it("imports nothing for a rejected or deleted sleep period and retires what an earlier sync stored for it", async () => {
getConnMock.mockResolvedValue(CONN);
fetchSleepMock.mockResolvedValue([
{
id: "rec-rest",
type: "rest",
day: "2026-06-10",
bedtime_start: "2026-06-10T14:00:00.000Z",
bedtime_end: "2026-06-10T15:00:00.000Z",
sleep_phase_5_min: "222222222222",
lowest_heart_rate: 58,
},
{
id: "rec-del",
type: "deleted",
day: "2026-06-09",
bedtime_end: "2026-06-09T16:00:00.000Z",
light_sleep_duration: 1800,
},
]);
const stored = [
{
id: "m1",
type: "SLEEP_DURATION",
measuredAt: new Date("2026-06-10T15:00:00.000Z"),
},
{
id: "m2",
type: "RESTING_HEART_RATE",
measuredAt: new Date("2026-06-10T15:00:00.000Z"),
},
];
findManyMock.mockResolvedValue(stored);
updateManyMock.mockResolvedValue({ count: 2 });
await syncUserOura("u1");
// Nothing of either period reaches the write path.
expect(
createdRows().filter((r) => String(r.externalId).startsWith("sleep:")),
).toEqual([]);
// The rows of both periods are looked up by their record prefix, any type.
const lookup = findManyMock.mock.calls
.map((c) => c[0] as { where: Record<string, unknown> })
.find((a) => Array.isArray(a.where.OR));
expect(lookup).toBeDefined();
expect(lookup!.where).toMatchObject({
userId: "u1",
source: "OURA",
deletedAt: null,
});
expect(lookup!.where.type).toBeUndefined();
expect(lookup!.where.OR).toEqual([
{ externalId: { startsWith: "sleep:rec-rest:" } },
{ externalId: { startsWith: "sleep:rec-del:" } },
]);
// ...and soft-deleted by id, then their rollup days recomputed.
const retire = updateManyMock.mock.calls
.map((c) => c[0] as { where: { id?: { in: string[] } }; data: unknown })
.find((a) => a.where.id);
expect(retire?.where.id?.in).toEqual(["m1", "m2"]);
expect(retire?.data).toEqual({ deletedAt: expect.any(Date) });
expect(recomputeMock).toHaveBeenCalledWith(
"u1",
"RESTING_HEART_RATE",
stored[1]!.measuredAt,
);
});
it("never sweeps when the sleep collection returns no records", async () => {
getConnMock.mockResolvedValue(CONN);
fetchReadinessMock.mockResolvedValue([
+28 -2
View File
@@ -309,7 +309,11 @@ export interface OuraCollection<T> {
export interface OuraSleep {
id: string;
day: string;
/** `long` | `short` | `nap` | `rest` — disambiguates a nap from the main sleep. */
/**
* `long_sleep` | `sleep` | `late_nap` | `rest` | `deleted`. `rest` is a
* detected period the person rejected and `deleted` one they removed; see
* `isRetiredOuraSleepPeriod`.
*/
type?: string | null;
bedtime_start?: string;
bedtime_end?: string;
@@ -796,13 +800,35 @@ function mapSleepTimeline(s: OuraSleep): MappedMeasurement[] | null {
}
/**
* Map one Oura sleep record. When the real `sleep_phase_5_min` hypnogram is
* Oura sleep periods that are not sleep. Oura's `type` marks a detected period
* the person turned down in the app's confirm prompt as `rest` ("falsely
* detected sleep / nap, rejected by the user") and one they deleted as
* `deleted`; the sleep collection still returns both. Importing them made an
* afternoon on the sofa a second sleep session on that day, which added its
* minutes to the night and pulled the night's midpoint into the afternoon.
* `sleep`, `long_sleep` and `late_nap` are periods Oura counts, and an unknown
* or missing type stays in, as before.
*/
const RETIRED_OURA_SLEEP_TYPES: ReadonlySet<string> = new Set([
"rest",
"deleted",
]);
/** True for an Oura sleep period the person rejected or deleted. */
export function isRetiredOuraSleepPeriod(s: Pick<OuraSleep, "type">): boolean {
return typeof s.type === "string" && RETIRED_OURA_SLEEP_TYPES.has(s.type);
}
/**
* Map one Oura sleep record. A rejected or deleted period
* (`isRetiredOuraSleepPeriod`) maps to nothing. When the real `sleep_phase_5_min` hypnogram is
* present we emit timed per-segment `SLEEP_DURATION` rows (Oura's measured
* timeline); otherwise we fall back to the four per-stage totals stamped on the
* sleep END. Either way we add efficiency, HRV, RHR (`lowest_heart_rate`), and
* respiratory rate.
*/
export function mapSleep(s: OuraSleep, tz?: string): MappedMeasurement[] {
if (isRetiredOuraSleepPeriod(s)) return [];
const measuredAt = dayAnchor(s.day, tz, s.bedtime_end);
if (!measuredAt) return [];
const out: MappedMeasurement[] = [];
+63
View File
@@ -52,6 +52,7 @@ import {
type InsertedMeasurementArrivalRow,
} from "@/lib/arrivals/measurement-emit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import type { SyncWriteResult } from "@/lib/outcome/written-outcome";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
@@ -72,6 +73,7 @@ import {
fetchResilience,
fetchSleep,
fetchVo2Max,
isRetiredOuraSleepPeriod,
mapCardiovascularAge,
mapDailyActivity,
mapDailySleep,
@@ -245,6 +247,12 @@ export interface OuraFetchResult {
* run-indexed `seg:<i>` row — before the fresh set upserts.
*/
sleepSweeps: SleepSegmentSweep[];
/**
* The `sleep:<record-id>:` prefix of every fetched sleep period Oura marks as
* rejected or deleted (`isRetiredOuraSleepPeriod`). Such a period maps to no
* rows, and whatever an earlier sync imported for it is retired.
*/
sleepRetirements: string[];
/**
* Collections refused with 401 while a sibling succeeded with the same
* token: the token is fine, the grant does not cover them. Not failures; the
@@ -335,6 +343,7 @@ async function fetchAll(
// upsert. A collection that throws pushes nothing (the fetch is its first
// await), so a 401-retry cannot double-enter a record.
const sleepSweeps: SleepSegmentSweep[] = [];
const sleepRetirements: string[] = [];
const collections: OuraCollectionSpec[] = [
{
@@ -354,6 +363,10 @@ async function fetchAll(
const records = await fetchSleep(t, query);
const ups: OuraMeasurementUpsert[] = [];
for (const s of records) {
if (isRetiredOuraSleepPeriod(s)) {
if (s.id) sleepRetirements.push(`sleep:${s.id}:`);
continue;
}
const rows = toUpsert(
mapSleep(s, tz),
"sleep",
@@ -480,6 +493,7 @@ async function fetchAll(
readings: first.readings,
...splitScopeDenied(first.failures, first.succeeded > 0),
sleepSweeps,
sleepRetirements,
};
}
@@ -492,6 +506,7 @@ async function fetchAll(
readings: first.readings,
failures: first.failures,
sleepSweeps,
sleepRetirements,
scopeDenied: null,
};
@@ -510,6 +525,7 @@ async function fetchAll(
readings: first.readings,
failures: first.failures,
sleepSweeps,
sleepRetirements,
scopeDenied: null,
};
@@ -520,9 +536,55 @@ async function fetchAll(
// proves the grant as much as one that succeeded after it.
...splitScopeDenied(retry.failures, first.succeeded + retry.succeeded > 0),
sleepSweeps,
sleepRetirements,
};
}
/**
* Retire what earlier syncs imported for sleep periods Oura now reports as
* rejected or deleted: every live OURA row keyed under the period's
* `sleep:<record-id>:` prefix, whatever its type, because the period's stage
* rows and its nightly scalars (lowest heart rate, HRV, breathing rate,
* efficiency) all came from a period that was not sleep. Soft delete, then the
* shared write tail refolds the touched days and re-warms the touched types.
* Best-effort like the segment sweep: a failure never fails the sync, and the
* record stays inside the lookback window, so the next run retries it.
*/
export async function retireOuraSleepRecords(
userId: string,
prefixes: readonly string[],
): Promise<number> {
// A bare `sleep::` (no record id) would match nothing useful and must never
// widen to the whole source.
const bounded = prefixes.filter((p) => /^sleep:[^:]+:$/.test(p));
if (bounded.length === 0) return 0;
try {
const rows = await prisma.measurement.findMany({
where: {
userId,
source: "OURA",
deletedAt: null,
OR: bounded.map((prefix) => ({ externalId: { startsWith: prefix } })),
},
select: { id: true, type: true, measuredAt: true },
});
if (rows.length === 0) return 0;
const res = await prisma.measurement.updateMany({
where: { userId, id: { in: rows.map((r) => r.id) }, deletedAt: null },
data: { deletedAt: new Date() },
});
await afterMeasurementMutation(userId, rows, "oura");
invalidateUserMeasurements(userId);
annotate({ meta: { oura_sleep_retired_rows: res.count } });
return res.count;
} catch (err) {
getEvent()?.addWarning(
`oura: retiring rejected sleep periods failed for ${userId}: ${err}`,
);
return 0;
}
}
/**
* Sync one user's Oura data.
*
@@ -589,6 +651,7 @@ export async function syncUserOura(
// sync; the record stays inside the lookback window, so the next tick
// retries it.
await sweepStaleSleepSegments(userId, "OURA", result.sleepSweeps);
await retireOuraSleepRecords(userId, result.sleepRetirements);
// Import everything the healthy collections returned regardless of whether a
// sibling collection failed — one bad collection must not blank the source.
@@ -0,0 +1,116 @@
/**
* Oura sleep periods the person rejected (`rest`) or deleted: against the
* real schema, retiring one soft-deletes every live OURA row under its record
* prefix, whatever the type, leaves a counted sleep period and the other
* sources alone, and recomputes the rollup day so the day stops counting the
* retired reading.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER = "user-oura-retired-sleep";
const END = new Date(Date.UTC(2026, 5, 10, 15, 0, 0));
const NIGHT_END = new Date(Date.UTC(2026, 5, 10, 7, 0, 0));
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.user.create({
data: {
id: USER,
username: "oura-retired-sleep",
email: "oura-retired-sleep@example.test",
},
});
const row = (
type: "SLEEP_DURATION" | "RESTING_HEART_RATE",
externalId: string,
value: number,
measuredAt: Date,
source: "OURA" | "APPLE_HEALTH" = "OURA",
) => ({
userId: USER,
type,
value,
unit: type === "SLEEP_DURATION" ? "minutes" : "bpm",
measuredAt,
source,
externalId,
sleepStage: type === "SLEEP_DURATION" ? ("CORE" as const) : null,
});
await prisma.measurement.createMany({
data: [
// The rejected afternoon period: a stage row and its nightly scalar.
row(
"SLEEP_DURATION",
"sleep:rec-rest:seg:2026-06-10T14:00:00.000Z",
60,
END,
),
row("RESTING_HEART_RATE", "sleep:rec-rest:rhr", 58, END),
// The real night before it, same day.
row(
"SLEEP_DURATION",
"sleep:rec-night:seg:2026-06-09T23:00:00.000Z",
480,
NIGHT_END,
),
row("RESTING_HEART_RATE", "sleep:rec-night:rhr", 48, NIGHT_END),
// Another source's row whose id happens to share the prefix shape.
row("RESTING_HEART_RATE", "sleep:rec-rest:rhr", 52, END, "APPLE_HEALTH"),
],
});
await recomputeBucketsForMeasurement(USER, "RESTING_HEART_RATE", END);
});
describe("retireOuraSleepRecords", () => {
it("retires only the rejected period's OURA rows and recomputes the day", async () => {
const prisma = getPrismaClient();
const { retireOuraSleepRecords } = await import("@/lib/oura/sync");
const before = await prisma.measurementRollup.findFirst({
where: {
userId: USER,
type: "RESTING_HEART_RATE",
granularity: "DAY",
source: "OURA",
},
});
expect(before?.count).toBe(2);
const removed = await retireOuraSleepRecords(USER, ["sleep:rec-rest:"]);
expect(removed).toBe(2);
const live = await prisma.measurement.findMany({
where: { userId: USER, deletedAt: null },
select: { externalId: true, source: true },
});
const key = (r: { externalId: string | null; source: string }) =>
`${r.source}|${r.externalId}`;
expect(live.sort((a, b) => key(a).localeCompare(key(b)))).toEqual([
{ externalId: "sleep:rec-rest:rhr", source: "APPLE_HEALTH" },
{ externalId: "sleep:rec-night:rhr", source: "OURA" },
{
externalId: "sleep:rec-night:seg:2026-06-09T23:00:00.000Z",
source: "OURA",
},
]);
const after = await prisma.measurementRollup.findFirst({
where: {
userId: USER,
type: "RESTING_HEART_RATE",
granularity: "DAY",
source: "OURA",
},
});
expect(after?.count).toBe(1);
expect(after?.mean).toBe(48);
// A malformed prefix never widens to the whole source.
expect(await retireOuraSleepRecords(USER, ["sleep::", "sleep:"])).toBe(0);
});
});