merge: s3-written into release/v1.34.0

This commit is contained in:
Marc-André Bombeck committed 2026-07-27 23:41:33 +02:00
commit 602ca36ee2
57 files changed
+1629 -545

No files matched your search

@@ -17,15 +17,18 @@ import { syncUserPolarLegs } from "../polar-sync";
beforeEach(() => {
vi.clearAllMocks();
syncUserPolar.mockResolvedValue(0);
syncUserPolar.mockResolvedValue({ imported: 0, failed: false });
syncUserPolarWorkouts.mockResolvedValue(0);
});
describe("syncUserPolarLegs — composite independence on the shared polar ledger", () => {
it("runs both legs and returns the summed count when both pass", async () => {
syncUserPolar.mockResolvedValue(5);
syncUserPolar.mockResolvedValue({ imported: 5, failed: false });
syncUserPolarWorkouts.mockResolvedValue(2);
await expect(syncUserPolarLegs("u1")).resolves.toBe(7);
await expect(syncUserPolarLegs("u1")).resolves.toEqual({
imported: 7,
failed: false,
});
expect(syncUserPolar).toHaveBeenCalledWith("u1");
expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1");
});
@@ -35,9 +38,15 @@ describe("syncUserPolarLegs — composite independence on the shared polar ledge
// its healthy-collections import count rather than throwing. The wrapper
// must sum it with the workout leg and complete cleanly — a partial vitals
// failure never starves the user's success accounting at the cohort level.
syncUserPolar.mockResolvedValue(4);
syncUserPolar.mockResolvedValue({ imported: 4, failed: true });
syncUserPolarWorkouts.mockResolvedValue(2);
await expect(syncUserPolarLegs("u1")).resolves.toBe(6);
// The partial is carried, not swallowed: the wrapper completes with the
// summed count AND says a leg did not settle, so the card cannot paint a
// run in which four of five collections failed as a clean one.
await expect(syncUserPolarLegs("u1")).resolves.toEqual({
imported: 6,
failed: true,
});
expect(syncUserPolar).toHaveBeenCalledWith("u1");
expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1");
});
@@ -52,7 +61,7 @@ describe("syncUserPolarLegs — composite independence on the shared polar ledge
});
it("leaves the vitals leg's result intact when the workout leg fails", async () => {
syncUserPolar.mockResolvedValue(4);
syncUserPolar.mockResolvedValue({ imported: 4, failed: false });
syncUserPolarWorkouts.mockRejectedValue(new Error("workouts down"));
// The vitals leg ran to completion (its own success already recorded on
// the shared ledger); the workout error is surfaced without undoing it.
+3 -1
View File
@@ -27,7 +27,9 @@ export const handleNightscoutSync = makePollCohortHandler({
});
return users.map((u) => u.id);
},
syncUser: syncUserNightscout,
// The cohort accounts in rows imported; the failure half of the result is
// recorded on the `nightscout` ledger by the sync itself.
syncUser: async (userId) => (await syncUserNightscout(userId)).imported,
// The fetch path already records + marks its own failure (token-redacted), so
// this fires only for a future UNMARKED escape — and redacts the token before
// the message reaches the ledger.
+3 -1
View File
@@ -21,7 +21,9 @@ export const handleOuraSync = makePollCohortHandler({
});
return users.map((u) => u.id);
},
syncUser: syncUserOura,
// The cohort accounts in rows imported; the failure half of the result is
// recorded on the `oura` ledger by the sync itself.
syncUser: async (userId) => (await syncUserOura(userId)).imported,
// Catches an UNMARKED escape — today the `upsertOuraMeasurements` write-path
// throw (the fetch/refresh path already records + marks its own failures).
recordFailure: recordOuraSyncFailure,
+15 -4
View File
@@ -15,6 +15,7 @@
*/
import { recordPolarSyncFailure, syncUserPolar } from "@/lib/polar/sync";
import { syncUserPolarWorkouts } from "@/lib/polar/sync-workouts";
import type { SyncWriteResult } from "@/lib/outcome/written-outcome";
import { makePollCohortHandler, type PollCohortPayload } from "./poll-cohort";
export type PolarSyncPayload = PollCohortPayload;
@@ -34,13 +35,21 @@ export type PolarSyncPayload = PollCohortPayload;
* healthy collections. So the vitals leg reaching this wrapper's catch means a
* write-path/unexpected error, not a collection fetch failure.
*/
export async function syncUserPolarLegs(userId: string): Promise<number> {
export async function syncUserPolarLegs(
userId: string,
): Promise<SyncWriteResult> {
let total = 0;
let firstError: unknown;
let hadError = false;
// A settled-but-partial vitals leg neither throws nor reduces the count, so
// it has to be carried explicitly — otherwise a run in which four of five
// collections failed reads exactly like a clean one.
let partial = false;
try {
total += await syncUserPolar(userId);
const vitals = await syncUserPolar(userId);
total += vitals.imported;
partial = vitals.failed;
} catch (err) {
firstError = err;
hadError = true;
@@ -56,7 +65,7 @@ export async function syncUserPolarLegs(userId: string): Promise<number> {
}
if (hadError) throw firstError;
return total;
return { imported: total, failed: partial };
}
export const handlePolarSync = makePollCohortHandler({
@@ -68,7 +77,9 @@ export const handlePolarSync = makePollCohortHandler({
});
return users.map((u) => u.id);
},
syncUser: syncUserPolarLegs,
// The cohort accounts in rows imported; a partial vitals leg is already on
// the `polar` ledger, recorded by the leg that saw it.
syncUser: async (userId) => (await syncUserPolarLegs(userId)).imported,
// Catches an UNMARKED escape — the `upsertPolarMeasurements` /
// `upsertPolarWorkouts` write-path throw. The vitals leg records its own
// per-collection partial failures inline and returns without rethrowing; the
+1 -1
View File
@@ -89,7 +89,7 @@ export async function handleWithingsFallbackSync(
for (const connection of connections) {
try {
const imported = await syncUserMeasurements(connection.userId);
const { imported } = await syncUserMeasurements(connection.userId);
usersSynced++;
measurementsImported += imported;
// v1.18.1 — a fresh reading landed; resolve this user's Vorsorge
+12 -10
View File
@@ -90,18 +90,18 @@ afterEach(() => {
});
describe("syncUserNightscout", () => {
it("returns 0 and records nothing when the user is not configured", async () => {
it("returns an empty result and records nothing when the user is not configured", async () => {
getCredsMock.mockResolvedValue(null);
const n = await syncUserNightscout("u1");
expect(n).toBe(0);
const result = await syncUserNightscout("u1");
expect(result).toEqual({ imported: 0, failed: false });
expect(fetchSgvEntriesMock).not.toHaveBeenCalled();
expect(recordSuccessMock).not.toHaveBeenCalled();
});
it("writes each SGV entry as a BLOOD_GLUCOSE mg/dL measurement", async () => {
fetchSgvEntriesMock.mockResolvedValue([ENTRY_A, ENTRY_B]);
const n = await syncUserNightscout("u1");
expect(n).toBe(2);
const result = await syncUserNightscout("u1");
expect(result).toEqual({ imported: 2, failed: false });
expect(upsertMock).toHaveBeenCalledTimes(2);
const first = upsertMock.mock.calls[0]![0];
expect(first.create.type).toBe("BLOOD_GLUCOSE");
@@ -146,11 +146,11 @@ describe("syncUserNightscout", () => {
// ENTRY_A already exists (a re-confirm); ENTRY_B is genuinely new.
findManyMock.mockResolvedValue([{ externalId: "ns:abc" }]);
const n = await syncUserNightscout("u1");
const result = await syncUserNightscout("u1");
// The inflated insert+reconfirm count used to fire the reminder-satisfy
// trigger every tick; the honest count is the single fresh insert.
expect(n).toBe(1);
expect(result).toEqual({ imported: 1, failed: false });
// Both rows are still written (idempotent), but only the fresh one emits.
expect(upsertMock).toHaveBeenCalledTimes(2);
expect(emitArrivalsMock).toHaveBeenCalledTimes(1);
@@ -172,10 +172,12 @@ describe("syncUserNightscout", () => {
.mockResolvedValueOnce({ id: "r1" })
.mockRejectedValueOnce(new Error("bad row"));
const n = await syncUserNightscout("u1");
const result = await syncUserNightscout("u1");
// The one good row still landed; the tick is honest about the failed row.
expect(n).toBe(1);
// The one good row still landed; the tick is honest about the failed row —
// the count alone used to narrow that away, so the card called a window in
// which nothing wrote a success.
expect(result).toEqual({ imported: 1, failed: true });
expect(recordSuccessMock).not.toHaveBeenCalled();
expect(recordFailureMock).toHaveBeenCalledWith(
expect.objectContaining({
+20 -10
View File
@@ -30,6 +30,7 @@ import {
type FailureKind,
} from "@/lib/integrations/status";
import type { IntegrationClassification } from "@/lib/integrations/http-status-classifier";
import type { SyncWriteResult } from "@/lib/outcome/written-outcome";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
@@ -122,19 +123,28 @@ export function classifyNightscoutFailure(err: unknown): FailureKind {
}
/**
* Sync one user's recent SGV entries. Returns the count of rows FRESHLY
* INSERTED this tick (re-confirmed rows the upsert merely touched are not
* counted — the old insert+reconfirm total inflated the cohort's
* `measurements_imported` and fired the reminder-satisfy trigger every tick
* regardless of whether new glucose actually arrived). A user with no
* configured instance is a clean no-op (returns 0, touches no status row).
* Sync one user's recent SGV entries.
*
* `imported` counts the rows FRESHLY INSERTED this tick (re-confirmed rows the
* upsert merely touched are not counted — the old insert+reconfirm total
* inflated the cohort's `measurements_imported` and fired the reminder-satisfy
* trigger every tick regardless of whether new glucose actually arrived).
* `failed` is true when any SGV row did not write.
*
* The count used to be the whole return value, which narrowed the failures
* away: a tick where every row was refused answered the same `0` as a tick
* that found nothing new, and the settings card rendered both as a green tick
* with a count. The caller now gets both halves and decides.
*
* A user with no configured instance is a clean no-op (nothing imported,
* nothing failed, no status row touched).
*/
export async function syncUserNightscout(
userId: string,
opts: { count?: number } = {},
): Promise<number> {
): Promise<SyncWriteResult> {
const creds = await getUserNightscoutCredentials(userId);
if (!creds) return 0;
if (!creds) return { imported: 0, failed: false };
let entries: ParsedSgvEntry[];
try {
@@ -168,11 +178,11 @@ export async function syncUserNightscout(
kind: "transient",
message: `${failedRows} of ${entries.length} SGV rows failed to write`,
});
return inserted;
return { imported: inserted, failed: true };
}
await recordSyncSuccess(userId, "nightscout");
return inserted;
return { imported: inserted, failed: false };
}
/**
+5 -1
View File
@@ -105,11 +105,15 @@ const commitResponse = z
reason: z.literal("duplicate"),
}),
),
outcome: z.enum(["empty", "failed", "partial", "success"]).meta({
description:
"How the commit reads, resolved from the counts rather than the status code. `success` only when every confirmed row was written; `partial` when some were skipped; `failed` when nothing was written and at least one row was skipped; `empty` when there was nothing to write.",
}),
})
.meta({
id: "OcrCommitResponse",
description:
'The rows written (`source: "OCR"`) and those skipped as commit-time duplicates.',
'The rows written (`source: "OCR"`), those skipped as commit-time duplicates, and the resolved outcome.',
});
export const ocrPaths: NonNullable<ZodOpenApiObject["paths"]> = {
+14 -12
View File
@@ -220,7 +220,7 @@ function createdRows() {
describe("syncUserOura", () => {
it("no-ops cleanly when unconnected", async () => {
getConnMock.mockResolvedValue(null);
expect(await syncUserOura("u1")).toBe(0);
expect(await syncUserOura("u1")).toEqual({ imported: 0, failed: false });
expect(recordSuccessMock).not.toHaveBeenCalled();
});
@@ -229,7 +229,7 @@ describe("syncUserOura", () => {
fetchReadinessMock.mockResolvedValue([
{ id: "x", day: "2026-06-10", score: 84 },
]);
const imported = await syncUserOura("u1");
const { imported } = await syncUserOura("u1");
expect(imported).toBe(1);
const row = createdRows()[0];
expect(row).toMatchObject({
@@ -253,7 +253,7 @@ describe("syncUserOura", () => {
refresh_token: "newRef",
expires_in: 86400,
});
const imported = await syncUserOura("u1");
const { imported } = await syncUserOura("u1");
expect(refreshMock).toHaveBeenCalledWith("ref", expect.anything());
expect(storeTokensMock).toHaveBeenCalledWith(
"u1",
@@ -278,7 +278,7 @@ describe("syncUserOura", () => {
// A concurrent sync rotated first → the persist returns the PEER's token.
storeTokensMock.mockResolvedValue("peerAcc");
const imported = await syncUserOura("u1");
const { imported } = await syncUserOura("u1");
// The retry runs with the peer's token rather than the invalidated one.
expect(fetchReadinessMock).toHaveBeenLastCalledWith(
@@ -461,7 +461,7 @@ describe("syncUserOura", () => {
fetchResilienceMock.mockResolvedValue([
{ id: "r", day: "2026-06-10", level: "strong" },
]);
const imported = await syncUserOura("u1");
const { imported } = await syncUserOura("u1");
expect(imported).toBe(1);
const row = createdRows()[0];
expect(row).toMatchObject({
@@ -478,7 +478,7 @@ describe("syncUserOura", () => {
fetchResilienceMock.mockResolvedValue([
{ id: "r", day: "2026-06-10", level: "godlike" },
]);
const imported = await syncUserOura("u1");
const { imported } = await syncUserOura("u1");
expect(imported).toBe(0);
expect(reconcileMock).not.toHaveBeenCalled();
});
@@ -561,10 +561,12 @@ describe("syncUserOura", () => {
{ id: "o", day: "2026-06-10", spo2_percentage: { average: 97 } },
]);
const imported = await syncUserOura("u1");
const result = await syncUserOura("u1");
// The healthy collections still wrote their rows — the source is not blanked.
expect(imported).toBe(2);
// The healthy collections still wrote their rows — the source is not
// blanked — and the partial rides back with the count, so the card can
// report a warning rather than a tick over a run that lost a collection.
expect(result).toEqual({ imported: 2, failed: true });
const written = createdRows().map((row) => row.type);
expect(written).toContain("SLEEP_SCORE");
expect(written).toContain("OXYGEN_SATURATION");
@@ -596,7 +598,7 @@ describe("syncUserOura", () => {
{ id: "o", day: "2026-06-10", spo2_percentage: { average: 96 } },
]);
const imported = await syncUserOura("u1");
const { imported } = await syncUserOura("u1");
expect(imported).toBe(1);
const written = createdRows().map((row) => row.type);
@@ -620,7 +622,7 @@ describe("syncUserOura", () => {
reason: "http_403",
}),
);
const imported = await syncUserOura("u1");
const { imported } = await syncUserOura("u1");
expect(imported).toBe(1);
expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura");
expect(recordFailureMock).not.toHaveBeenCalled();
@@ -647,7 +649,7 @@ describe("syncUserOura", () => {
{ id: "s", day: "2026-06-10", score: 80 },
]);
fetchCyclePhasesMock.mockRejectedValue(new Error("boom"));
const imported = await syncUserOura("u1");
const { imported } = await syncUserOura("u1");
expect(imported).toBe(1);
expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura");
});
+13 -7
View File
@@ -52,6 +52,7 @@ import {
type InsertedMeasurementArrivalRow,
} from "@/lib/arrivals/measurement-emit";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import type { SyncWriteResult } from "@/lib/outcome/written-outcome";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import {
sweepStaleSleepSegments,
@@ -411,16 +412,21 @@ async function fetchAll(
}
/**
* Sync one user's Oura data. Returns the count of measurement rows written.
* A user with no Oura connection is a clean no-op (returns 0, touches no
* status row).
* Sync one user's Oura data.
*
* `imported` is the count of measurement rows written; `failed` is true when a
* collection did not settle. The count used to be the whole return value,
* which hid the partial: a run where every collection failed answered `0`, the
* same as a run that found nothing new, and the settings card reported both as
* a success. A user with no Oura connection is a clean no-op (nothing
* imported, nothing failed, no status row touched).
*/
export async function syncUserOura(
userId: string,
opts: { lookbackDays?: number } = {},
): Promise<number> {
): Promise<SyncWriteResult> {
const conn = await getOuraConnection(userId);
if (!conn) return 0;
if (!conn) return { imported: 0, failed: false };
// An explicit window from the caller wins; otherwise derive it from what we
// already hold so a gap widens the window instead of being skipped over.
@@ -516,11 +522,11 @@ export async function syncUserOura(
? String(firstErr.httpStatus)
: undefined,
});
return imported;
return { imported, failed: true };
}
await recordSyncSuccess(userId, "oura");
return imported;
return { imported, failed: false };
}
export async function upsertOuraMeasurements(
@@ -0,0 +1,134 @@
/**
* #640 — a write that wrote nothing must never read as a success.
*
* The reported failure was not the parser refusing rows; it was the card
* answering that refusal with a green tick, which is what turned a
* recoverable problem into a silent one. These pin the classifier every
* import card and every provider-sync card now renders from; the structural
* guard that keeps the success affordance attached to the success outcome
* alone lives in `src/__tests__/success-affordance-guard.test.ts`.
*/
import { describe, it, expect } from "vitest";
import {
classifyWrittenOutcome,
groupSkipReasons,
resolveSyncOutcome,
} from "../written-outcome";
describe("classifyWrittenOutcome", () => {
it("calls the reported production result a failure", () => {
// The audit envelope from the report: 1 597 rows in, nothing written.
expect(classifyWrittenOutcome({ written: 0, skipped: 1597 })).toBe(
"failed",
);
});
it("calls a file with no data rows empty, not a success", () => {
expect(classifyWrittenOutcome({ written: 0, skipped: 0 })).toBe("empty");
});
it("calls a mixed result partial", () => {
expect(classifyWrittenOutcome({ written: 3, skipped: 2 })).toBe("partial");
});
it("calls a clean result a success", () => {
expect(classifyWrittenOutcome({ written: 5, skipped: 0 })).toBe("success");
});
it("never reaches success while nothing was written", () => {
for (let written = 0; written <= 4; written++) {
for (let skipped = 0; skipped <= 4; skipped++) {
const outcome = classifyWrittenOutcome({ written, skipped });
if (written === 0) {
expect(outcome).not.toBe("success");
expect(outcome).not.toBe("partial");
}
if (outcome === "success") {
expect(written).toBeGreaterThan(0);
expect(skipped).toBe(0);
}
}
}
});
});
describe("resolveSyncOutcome", () => {
it("calls a run that wrote nothing and failed a failure, not an empty run", () => {
// The defect: every row refused by a constraint, count 0, HTTP 200.
expect(resolveSyncOutcome({ imported: 0, failed: true })).toEqual({
imported: 0,
failed: true,
outcome: "failed",
});
});
it("calls a run that found nothing new empty", () => {
expect(resolveSyncOutcome({ imported: 0, failed: false }).outcome).toBe(
"empty",
);
});
it("calls a run with a failed leg partial even when rows landed", () => {
expect(resolveSyncOutcome({ imported: 12, failed: true }).outcome).toBe(
"partial",
);
});
it("calls a clean run a success", () => {
expect(resolveSyncOutcome({ imported: 12, failed: false }).outcome).toBe(
"success",
);
});
it("never reaches success while the run reported a failure", () => {
for (const imported of [0, 1, 500]) {
expect(resolveSyncOutcome({ imported, failed: true }).outcome).not.toBe(
"success",
);
}
});
});
describe("groupSkipReasons", () => {
it("collapses a repeated reason to one entry with a count", () => {
const rows = Array.from({ length: 1597 }, (_, i) => ({
line: i + 2,
status: "skipped",
reason: "invalid_glucose_context",
}));
expect(groupSkipReasons(rows)).toEqual([
{ reason: "invalid_glucose_context", count: 1597 },
]);
});
it("ignores rows that were written", () => {
expect(
groupSkipReasons([
{ line: 2, status: "inserted" },
{ line: 3, status: "updated" },
{ line: 4, status: "skipped", reason: "unknown_unit" },
]),
).toEqual([{ reason: "unknown_unit", count: 1 }]);
});
it("orders by count descending, then by reason", () => {
const rows = [
{ line: 2, status: "skipped", reason: "unknown_unit" },
{ line: 3, status: "skipped", reason: "duplicate" },
{ line: 4, status: "skipped", reason: "duplicate" },
{ line: 5, status: "skipped", reason: "notes_too_long" },
];
expect(groupSkipReasons(rows)).toEqual([
{ reason: "duplicate", count: 2 },
{ reason: "notes_too_long", count: 1 },
{ reason: "unknown_unit", count: 1 },
]);
});
it("buckets a skipped row that carries no reason", () => {
expect(groupSkipReasons([{ line: 2, status: "skipped" }])).toEqual([
{ reason: "unknown", count: 1 },
]);
});
});
+122
View File
@@ -0,0 +1,122 @@
/**
* Written-outcome classification — the one place that decides whether a write
* reads as a success.
*
* The rule started in the import panel (`import-result-state.ts`, #640/#650)
* and is unchanged; only its reach is wider. A per-row importer answers HTTP
* 200 for a file where every single row was refused, and a provider sync
* answers HTTP 200 for a run where every reading failed a constraint. The
* transport succeeded, the write did not. Rendering either as a green tick is
* how a self-hoster's sensor export could write nothing and still look
* finished. So the counts, not the status code, pick the state:
*
* - nothing written, nothing refused → `empty` (there was nothing to write)
* - nothing written, rows refused → `failed` (never a success tone)
* - some written, some refused → `partial` (a warning, not a tick)
* - everything written → `success`
*
* `empty` is deliberately its own state rather than a failure: an import of a
* file with no data rows and a sync that finds nothing new are both honest
* non-events, and both would be a lie under a tick.
*
* Every surface that reports a write routes through this, and
* `success-affordance-guard.test.ts` holds the line that the tick and the
* success colour live only in the presentation module this feeds.
*/
export type WrittenOutcome = "empty" | "failed" | "partial" | "success";
export interface WriteCounts {
/** Rows that reached the database — inserted + updated. */
written: number;
/** Rows the write refused, for any reason. */
skipped: number;
}
export function classifyWrittenOutcome({
written,
skipped,
}: WriteCounts): WrittenOutcome {
if (written <= 0) return skipped > 0 ? "failed" : "empty";
return skipped > 0 ? "partial" : "success";
}
/**
* What a provider sync reports back to its route.
*
* Three providers (Fitbit, Google Health, WHOOP) already answered in this
* shape; the rest returned a bare count that narrowed their failures away —
* a Nightscout run whose every SGV row failed to write returned the same `0`
* as a run that found nothing new. The boolean is the honest floor: a sync
* knows a leg / collection / row did not land, but not always how many.
*/
export interface SyncWriteResult {
/** Rows that reached the database this run. */
imported: number;
/** True when any part of the run did not land — a row, a collection, a leg. */
failed: boolean;
}
/** The sync envelope a route answers with: the counts plus the resolved tone. */
export interface ResolvedSyncOutcome extends SyncWriteResult {
outcome: WrittenOutcome;
}
/**
* Resolve a sync's write result to the outcome its card renders.
*
* Server-side on purpose: the verdict is the server's to compute, so the web
* card and the native client cannot drift on how the same run reads. A failed
* leg counts as one refusal — enough to move `success` to `partial` and
* `empty` to `failed`, which is the whole point.
*/
export function resolveSyncOutcome({
imported,
failed,
}: SyncWriteResult): ResolvedSyncOutcome {
return {
imported,
failed,
outcome: classifyWrittenOutcome({
written: imported,
skipped: failed ? 1 : 0,
}),
};
}
export interface SkipReasonGroup {
/** The machine-readable reason as the route reported it. */
reason: string;
count: number;
}
/** A row of the per-row envelope, narrowed to what the grouping needs. */
export interface WrittenRowResult {
line: number;
status: string;
reason?: string;
}
const UNKNOWN_REASON = "unknown";
/**
* Collapse the per-row envelope into one entry per skip reason.
*
* A sensor export that trips one validation trips it on every row, so the
* raw list is the same sentence a few thousand times. Grouping turns "scroll
* fifty identical lines" into one fact with a count. Ordered by count
* descending, then by reason, so the same result always renders the same way.
*/
export function groupSkipReasons(
rows: readonly WrittenRowResult[],
): SkipReasonGroup[] {
const counts = new Map<string, number>();
for (const row of rows) {
if (row.status !== "skipped") continue;
const reason = row.reason ?? UNKNOWN_REASON;
counts.set(reason, (counts.get(reason) ?? 0) + 1);
}
return [...counts.entries()]
.map(([reason, count]) => ({ reason, count }))
.sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason));
}
+10 -7
View File
@@ -164,7 +164,7 @@ function createdRows() {
describe("syncUserPolar", () => {
it("no-ops cleanly for an unconnected user", async () => {
getConnMock.mockResolvedValue(null);
expect(await syncUserPolar("u1")).toBe(0);
expect(await syncUserPolar("u1")).toEqual({ imported: 0, failed: false });
expect(createManyAndReturnMock).not.toHaveBeenCalled();
expect(recordSuccessMock).not.toHaveBeenCalled();
});
@@ -186,7 +186,7 @@ describe("syncUserPolar", () => {
fetchRechargesMock.mockResolvedValue([
{ date: "2026-06-10", nightly_recharge_status: 6 },
]);
const imported = await syncUserPolar("u1");
const { imported } = await syncUserPolar("u1");
expect(imported).toBe(1);
const row = createdRows()[0];
expect(row).toMatchObject({
@@ -206,7 +206,7 @@ describe("syncUserPolar", () => {
fetchCardioLoadsMock.mockResolvedValue([
{ date: "2026-06-10", cardio_load: 123.45 },
]);
const imported = await syncUserPolar("u1");
const { imported } = await syncUserPolar("u1");
expect(imported).toBe(1);
const row = createdRows()[0];
expect(row).toMatchObject({
@@ -348,7 +348,7 @@ describe("syncUserPolar", () => {
{ date: "2026-06-10", cardio_load: 42 },
]);
const imported = await syncUserPolar("u1");
const { imported } = await syncUserPolar("u1");
// No rethrow now — a fetch failure is isolated per-collection.
expect(imported).toBe(1);
@@ -397,8 +397,11 @@ describe("syncUserPolar", () => {
{ date: "2026-06-10", cardio_load: 55 },
]);
const imported = await syncUserPolar("u1");
const result = await syncUserPolar("u1");
const imported = result.imported;
// The partial rides back with the count rather than being narrowed away.
expect(result.failed).toBe(true);
// The healthy four still wrote their rows — the source is not blanked.
const written = createdRows().map((row) => row.type);
expect(written).toContain("RECOVERY_SCORE");
@@ -438,7 +441,7 @@ describe("syncUserPolar", () => {
{ date: "2026-06-10", cardio_load: 77 },
]);
const imported = await syncUserPolar("u1");
const { imported } = await syncUserPolar("u1");
expect(imported).toBe(1);
expect(createdRows().map((row) => row.type)).toContain("CARDIO_LOAD");
@@ -463,7 +466,7 @@ describe("syncUserPolar", () => {
{ start_time: "2026-06-10T00:00:00", steps: 8000 },
]);
const imported = await syncUserPolar("u1");
const { imported } = await syncUserPolar("u1");
// The sweep never ran — a failed sleep collection pushes no sweep entries.
expect(updateManyMock).not.toHaveBeenCalled();
+10 -7
View File
@@ -51,6 +51,7 @@ import {
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import type { SyncWriteResult } from "@/lib/outcome/written-outcome";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import {
emitInsertedMeasurementArrivals,
@@ -161,9 +162,11 @@ export interface PolarCollectionFailure {
}
/**
* Sync one user's Polar data. Returns the count of measurement rows written.
* A user with no Polar connection is a clean no-op (returns 0, touches no
* status row).
* Sync one user's Polar data. Returns the rows written AND whether a
* collection did not settle — the bare count used to narrow the partial away,
* so a run whose every collection failed answered the same `0` as a quiet
* night. A user with no Polar connection is a clean no-op (nothing imported,
* nothing failed, no status row touched).
*
* The five vitals collections are settled independently: the healthy ones
* import, a failing one is recorded as a single partial failure (no success
@@ -171,9 +174,9 @@ export interface PolarCollectionFailure {
* escapes now is a write-path/unexpected error out of `upsertPolarMeasurements`
* — the poll-cohort boundary records that unmarked escape once.
*/
export async function syncUserPolar(userId: string): Promise<number> {
export async function syncUserPolar(userId: string): Promise<SyncWriteResult> {
const conn = await getPolarConnection(userId);
if (!conn) return 0;
if (!conn) return { imported: 0, failed: false };
// Filled by the sleep collection's closure as it maps records; carried so the
// caller can run the record-scoped sweep before the upsert. A collection that
@@ -301,11 +304,11 @@ export async function syncUserPolar(userId: string): Promise<number> {
? String(firstErr.httpStatus)
: undefined,
});
return imported;
return { imported, failed: true };
}
await recordSyncSuccess(userId, "polar", { leg: POLAR_LEG_VITALS });
return imported;
return { imported, failed: false };
}
/**
+9
View File
@@ -18,6 +18,8 @@
*/
import { z } from "zod/v4";
import type { WrittenOutcome } from "@/lib/outcome/written-outcome";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
/** Max rows a single scan / commit may carry — a dense panel is ~30 analytes. */
@@ -200,6 +202,13 @@ export interface OcrSkippedRowDto {
export interface OcrCommitResponseDto {
inserted: unknown[];
skipped: OcrSkippedRowDto[];
/**
* How the commit reads, resolved server-side from the two lists. The dialog
* renders this rather than assuming a 200 means readings were saved — a
* re-scan in which every confirmed row turned out to be a duplicate writes
* nothing and must not report a green toast.
*/
outcome: WrittenOutcome;
}
/** The capability-probe response (drives whether the UI shows the scan entry). */
@@ -171,10 +171,10 @@ describe("syncUserMeasurements — watermark hold on hard row failure (F4)", ()
.mockRejectedValueOnce(new Error("connection reset by peer"))
.mockResolvedValue({} as never);
const imported = await syncUserMeasurements("user-1");
const result = await syncUserMeasurements("user-1");
// Only the second row persisted.
expect(imported).toBe(1);
// Only the second row persisted, and the caller is told the run lost one.
expect(result).toEqual({ imported: 1, failed: true });
// The watermark is HELD — no stamp — so the next tick refetches the window.
expect(prisma.withingsConnection.update).not.toHaveBeenCalled();
expect(recordSyncSuccess).not.toHaveBeenCalled();
@@ -195,7 +195,7 @@ describe("syncUserMeasurements — watermark hold on hard row failure (F4)", ()
] as never);
vi.mocked(prisma.measurement.create).mockRejectedValueOnce(P2002);
const imported = await syncUserMeasurements("user-1");
const { imported } = await syncUserMeasurements("user-1");
// P2002 means the row is already present — nothing lost — so the cycle is
// still healthy: stamp the watermark + success, record no failure.
@@ -216,7 +216,7 @@ describe("syncUserMeasurements — watermark hold on hard row failure (F4)", ()
},
] as never);
const imported = await syncUserMeasurements("user-1");
const { imported } = await syncUserMeasurements("user-1");
expect(imported).toBe(1);
expect(prisma.withingsConnection.update).toHaveBeenCalledTimes(1);
+15 -8
View File
@@ -32,6 +32,7 @@ import {
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
import type { SyncWriteResult } from "@/lib/outcome/written-outcome";
import {
acquireProviderTokenRefreshLock,
PROVIDER_REFRESH_TRANSACTION_OPTIONS,
@@ -180,6 +181,9 @@ export const WITHINGS_INCREMENTAL_OVERLAP_MS = 10 * 60 * 1000;
* Sync measurements from Withings for a given user.
* Fetches data since last sync (or last 30 days if first sync).
*
* Returns what was written AND whether any part of the run did not land, so
* the caller can tell a quiet week apart from a run that refused every row.
*
* Status-tracking contract:
* - On any failure (refresh failure, fetch failure, downstream
* measurement-upsert that fails ALL items) we call
@@ -193,26 +197,29 @@ export const WITHINGS_INCREMENTAL_OVERLAP_MS = 10 * 60 * 1000;
export async function syncUserMeasurements(
userId: string,
opts: { fullSync?: boolean } = {},
): Promise<number> {
): Promise<SyncWriteResult> {
// Park: if the last burst said reauth_required, do nothing until
// the OAuth callback flips state back to "connected". Returning 0
// matches the existing contract for "no-op sync".
// the OAuth callback flips state back to "connected". The run wrote
// nothing AND did not complete, so it reports as a failure — a bare 0
// read as "nothing new", which is how a parked connection could keep
// answering with a green tick.
if (await isReauthRequired(userId, "withings")) {
getEvent()?.addWarning(
`withings sync skipped for ${userId}: parked at error_reauth`,
);
return 0;
return { imported: 0, failed: true };
}
const tokenInfo = await getValidToken(userId);
// Note: getValidToken already calls recordSyncFailure on the refresh
// path, so we don't double-record here.
if (!tokenInfo) return 0;
if (!tokenInfo) return { imported: 0, failed: true };
const connection = await prisma.withingsConnection.findUnique({
where: { userId },
});
if (!connection) return 0;
// No connection row is not a failure — there is nothing to sync.
if (!connection) return { imported: 0, failed: false };
const startDate = opts.fullSync
? undefined
@@ -390,7 +397,7 @@ export async function syncUserMeasurements(
firstRowError ?? new Error("Withings measure row write failed"),
WITHINGS_LEG_MEASURES,
);
return imported;
return { imported, failed: true };
}
// Update last synced timestamp
@@ -407,7 +414,7 @@ export async function syncUserMeasurements(
leg: WITHINGS_LEG_MEASURES,
});
return imported;
return { imported, failed: false };
}
/**