Files
HealthLog/src/lib/nightscout/sync.ts
T
Marc-André Bombeck 002d120b69 Reach every cached reader from the integration and context write paths
Integration syncs (Fitbit, Oura, Polar, WHOOP, Strava, Nightscout) now
mark the per-user analytics cells stale and sweep the workouts cache at
their sync tails — imported rows used to stay invisible to the cached
dashboard, analytics and workout readers until the TTL lapsed. The WHOOP
per-resource webhook/cron driver and the Polar workouts leg carry the
same call, matching the posture Google Health and the workout batch
route already had.

Illness, cycle and encounter write routes hard-evict the analytics
bucket through the new invalidateUserHealthContext helper: Rest Mode,
the cycle context and the Today-rail appointment feed all read from
cached cells under that bucket, and an interactive write must be true on
the very next read.
2026-08-13 18:33:46 +02:00

349 lines
13 KiB
TypeScript

/**
* Nightscout CGM sync (v1.17.0).
*
* Pulls the most recent SGV entries from the user's self-hosted Nightscout
* instance and writes each as a `BLOOD_GLUCOSE` mg/dL Measurement tagged
* `source = NIGHTSCOUT`. This is the CGM density that makes the clinical
* glucose panel (TIR / GMI) real — a continuous stream rather than the manual
* spot readings.
*
* Idempotency: each row's `externalId` is derived from Nightscout's `_id` (or
* the reading's epoch ms), and the write is FIRST-WRITE-WINS — a CGM sample is
* an immutable canonical reading, so the `(userId, type, source, externalId)`
* unique collapses a re-sync onto the existing row and the `update` branch is a
* no-op (no value overwrite). This mirrors the Apple Health immutable-sample
* contract, not the WHOOP re-score overwrite.
*
* Status bookkeeping rides the shared integration ledger (`nightscout` key): a
* clean pass stamps `recordSyncSuccess`; an unreachable instance / wrong token
* records a classified `recordSyncFailure` and rethrows so the cohort runner
* can warn-and-continue.
*/
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { getEvent } from "@/lib/logging/context";
import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate";
import {
markSyncFailureRecorded,
recordSyncFailure,
recordSyncSuccess,
toFailureKind,
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,
} from "@/lib/rollups/measurement-rollups";
import {
emitInsertedMeasurementArrivals,
type InsertedMeasurementArrivalRow,
} from "@/lib/arrivals/measurement-emit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
fetchSgvEntries,
mapSgvEntryToMeasurement,
NightscoutApiError,
NightscoutPolicyError,
type ParsedSgvEntry,
} from "./client";
import { getUserNightscoutCredentials } from "./credentials";
/**
* How many recent SGV entries to pull per sync tick. A CGM emits one reading
* every ~5 min (≈288/day); 576 covers ~48 h of catch-up so a missed hourly
* tick (or a brief outage) still backfills on the next run without paging the
* full instance.
*
* This ~48 h window is the ONLY history Nightscout imports: there is no
* far-history backfill queue, so connecting Nightscout brings in about two
* days of readings and every tick thereafter walks the same recent set. A
* deeper walk would need its own windowed-pagination queue; it is not built.
*/
export const NIGHTSCOUT_SYNC_COUNT = 576;
/**
* Redact the Nightscout auth secret from an error message before it reaches the
* integration ledger / the `/api/nightscout/status` response.
*
* The entries URL carries the access token as a `?token=<secret>` query param
* (and a classic instance may carry `api-secret`). A `SafeFetchError` embeds the
* full target URL verbatim in its message — a private-host refusal, a timeout,
* or a network failure would otherwise persist the token into `lastError` and
* echo it back through the status endpoint. Rewrite any URL in the message so
* the secret-bearing params read `=REDACTED`; everything else is preserved so
* the operator still sees a useful diagnostic.
*/
export function redactNightscoutSecret(message: string): string {
// Match `token=...` / `api-secret=...` up to the next param / whitespace /
// quote boundary. Case-insensitive on the key; the value is whatever a URL
// query value can hold short of a delimiter.
return message.replace(/\b(token|api-secret)=[^\s&"']+/gi, "$1=REDACTED");
}
/**
* Record a Nightscout sync failure on the shared `nightscout` ledger with the
* correct classification + HTTP code + the token STRIPPED from the message.
* Extracted so the poll-cohort boundary can record a future escape with the
* same provider-owned classification AND redaction the inline fetch catch
* applies — a boundary-blind recorder would persist the token-bearing URL a
* `SafeFetchError` embeds.
*/
export async function recordNightscoutSyncFailure(
userId: string,
err: unknown,
): Promise<void> {
const policyDenied =
err instanceof NightscoutPolicyError ||
(typeof err === "object" &&
err !== null &&
"reasonCode" in err &&
err.reasonCode === "private_origin_not_approved");
await recordSyncFailure({
userId,
integration: "nightscout",
kind: classifyNightscoutFailure(err),
message: policyDenied
? "private_origin_not_approved"
: redactNightscoutSecret(
err instanceof Error ? err.message : String(err),
),
errorCode: policyDenied
? "private_origin_not_approved"
: err instanceof NightscoutApiError && err.status != null
? String(err.status)
: undefined,
});
}
/** Map a Nightscout HTTP status / network error onto the shared ledger kind. */
export function classifyNightscoutFailure(err: unknown): FailureKind {
let classification: IntegrationClassification = "transient";
if (
err instanceof NightscoutPolicyError ||
(typeof err === "object" &&
err !== null &&
"reasonCode" in err &&
err.reasonCode === "private_origin_not_approved")
) {
classification = "persistent";
} else if (err instanceof NightscoutApiError && err.status != null) {
// Wrong / missing token, or a token whose role can't read SGV.
if (err.status === 401 || err.status === 403) {
classification = "reauth_required";
} else if (err.status >= 400 && err.status < 500) {
// 4xx other than auth = a contract / config problem worth surfacing.
classification = "persistent";
}
}
// 5xx, timeout, DNS, connection refused stay `transient` — retry next tick.
return toFailureKind(classification);
}
/**
* 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<SyncWriteResult> {
const creds = await getUserNightscoutCredentials(userId);
if (!creds) return { imported: 0, failed: false };
let entries: ParsedSgvEntry[];
try {
entries = await fetchSgvEntries({
baseUrl: creds.baseUrl,
token: creds.token,
count: opts.count ?? NIGHTSCOUT_SYNC_COUNT,
});
} catch (err) {
// Recorded here (token-redacted), then marked so the poll-cohort boundary
// does not double-record it — and never re-persists the token-bearing URL
// a boundary-blind recorder would.
await recordNightscoutSyncFailure(userId, err);
throw markSyncFailureRecorded(err);
}
const { inserted, failedRows } = await upsertNightscoutEntries(
userId,
entries,
);
// Background-sync posture: mark the analytics cells stale (never hard-evict
// from a poll) so the inserted rows reach the cached readers. Fires on a
// partial failure too — rows that DID land must not stay invisible for the
// rest of the TTL window.
if (inserted > 0) {
invalidateUserMeasurements(userId);
}
if (failedRows > 0) {
// Partial write failure: keep the tick honest. `transient` because the
// ~48 h re-fetch window self-heals a transient DB hiccup, while a
// persistent malformed-row failure now accrues visible strikes instead of
// being invisibly reset by a success stamp. Do NOT stamp success.
await recordSyncFailure({
userId,
integration: "nightscout",
kind: "transient",
message: `${failedRows} of ${entries.length} SGV rows failed to write`,
});
return { imported: inserted, failed: true };
}
await recordSyncSuccess(userId, "nightscout");
return { imported: inserted, failed: false };
}
/**
* Insert each SGV entry as an immutable BLOOD_GLUCOSE mg/dL row, then fold the
* rollup tier + invalidate status-insight caches once at the end (mirrors the
* Withings / WHOOP sync tail). First-write-wins: the `update` branch is empty
* so a re-sync of the same reading is a no-op rather than a rewrite. Best-effort
* on the per-row write + the rollup fold — one bad row never aborts the pass,
* and the count of failed rows is returned so the caller can surface a partial
* write failure instead of stamping success over it.
*
* Returns `inserted` (rows whose key was absent before this pass — the honest
* "new readings" count that also drives the arrival emit) and `failedRows`.
*/
export async function upsertNightscoutEntries(
userId: string,
entries: ParsedSgvEntry[],
): Promise<{ inserted: number; failedRows: number }> {
if (entries.length === 0) return { inserted: 0, failedRows: 0 };
const type = "BLOOD_GLUCOSE" as MeasurementType;
// A CGM that reports a glucose the app's own band calls impossible is
// reporting a sensor fault or a unit slip, not a reading. Drop it: the row
// would otherwise be immutable (first-write-wins), so an impossible sample
// that lands here never corrects itself on a later pass.
const mapped = dropImplausibleMeasurements(
"nightscout",
entries.map((entry) => mapSgvEntryToMeasurement(entry)),
(m) => ({ type, value: m.value }),
);
if (mapped.length === 0) return { inserted: 0, failedRows: 0 };
// Probe which keys already exist so a re-confirmed reading (the upsert's
// no-op update branch) is not miscounted as a fresh import and the arrival
// spine emits ONLY the genuinely-new rows. Cohort passes are sequential per
// user, so no concurrent insert races this probe. A probe failure degrades
// to "treat every row as fresh" — the rollup fold + count self-correct on
// the next tick.
const existingKeys = new Set<string>();
try {
const existing = await prisma.measurement.findMany({
where: {
userId,
type,
source: "NIGHTSCOUT",
externalId: { in: mapped.map((m) => m.externalId) },
},
select: { externalId: true },
});
for (const row of existing) {
if (row.externalId) existingKeys.add(row.externalId);
}
} catch (err) {
getEvent()?.addWarning(
`nightscout: insert-probe failed for ${userId}: ${err}`,
);
}
let failedRows = 0;
const insertedRows: InsertedMeasurementArrivalRow[] = [];
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
for (const m of mapped) {
try {
const row = await prisma.measurement.upsert({
where: {
userId_type_source_externalId: {
userId,
type,
source: "NIGHTSCOUT",
externalId: m.externalId,
},
},
create: {
userId,
type,
source: "NIGHTSCOUT",
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: m.externalId,
},
// First-write-wins: a CGM sample is immutable, so a re-sync of the
// same reading must not rewrite the stored value or measuredAt.
// `deletedAt: null` is a no-op on a live row, a deliberate
// RESURRECTION on a tombstoned one — Nightscout is the source of
// truth for its own rows, so a re-synced reading brings the row
// back (mirrors Google / Fitbit).
update: { deletedAt: null },
select: { id: true },
});
touched.push({ type, measuredAt: m.measuredAt });
if (!existingKeys.has(m.externalId)) {
insertedRows.push({ type, measuredAt: m.measuredAt, id: row.id });
}
} catch (err) {
getEvent()?.addWarning(
`nightscout: failed to upsert measurement: ${err}`,
);
failedRows++;
}
}
// Spine parity with every other measurement writer: emit at most one arrival
// per supported kind for the rows freshly inserted. BLOOD_GLUCOSE is not yet
// an arrival kind (`ARRIVAL_MEASUREMENT_KIND`), so this is behaviourally
// inert for glucose today — it removes Nightscout as the spine's odd one out
// and lights up automatically once a `glucose` arrival kind + its consuming
// surface land in a later release.
void emitInsertedMeasurementArrivals(
userId,
insertedRows,
"nightscout",
).catch(() => {});
try {
const keys = collapseToTypeDayKeys(touched);
for (const k of keys) {
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
}
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
).catch((err) => {
getEvent()?.addWarning(
`nightscout: status-insight invalidate failed for ${userId}: ${err}`,
);
});
} catch (err) {
getEvent()?.addWarning(
`nightscout: rollup fold failed for ${userId}: ${err}`,
);
}
return { inserted: insertedRows.length, failedRows };
}