Merge pull request #569 from MBombeck/release/v1.31.2-data-integrity

release: HealthLog v1.31.2 data integrity hardening
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-20 15:26:16 +02:00
commit f1a1887a01
92 files changed
+10028 -2748

No files matched your search

+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: "pnpm"
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: "pnpm"
@@ -70,7 +70,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: "pnpm"
+4
View File
@@ -85,3 +85,7 @@ next-env.d.ts
# Local screenshot/audit harnesses (repo-root for module resolution; never committed)
.audit-*.mjs
# Isolated development worktrees
/.worktrees/
+34
View File
@@ -2,6 +2,40 @@
## [Unreleased]
## [1.31.2] — 2026-07-20
- **Provider imports now commit identity and progress atomically.** Withings,
WHOOP, Oura, Polar, Strava, and Apple Health reconcile external and natural
measurement identities under database locks, preserve tombstones, emit
arrivals only for genuine inserts, and advance cursors only after durable
writes. OAuth refresh-token rotation is serialized per user and provider.
- **Webhook and import success now means the source data is durable.** moodLog
writes before acknowledging delivery, while Withings ECG callbacks enter a
durable queue with bounded retry and replay-safe processing. WHOOP backfills
commit bounded chunks without advancing failed resource cursors, and legacy
mood imports use deterministic, collision-free retry identities. Operational
JSON import failures are reported as failures instead of misleading
duplicate skips.
- **Recovery is complete, exact, and owner-safe.** Canonical backups round-trip
every supported record class, including encrypted document content,
measurement reconciliation identities, and tombstones. Restore rejects
account mismatches before writing, validates backup enums at the boundary,
processes stable measurement IDs in bounded batches, and preserves recovery
integrity across both uploaded and off-host payloads.
- **Device and mood edits no longer lose related state.** Same-user APNs
registrations merge transactionally without crossing account boundaries,
refresh-token bindings follow the surviving device, and mood edits preserve
rated factors unless the caller explicitly replaces them.
- **Polar sync follows the current AccessLink collection API.** Collection
requests no longer include the obsolete user ID path segment, and current
activity, Nightly Recharge, cardio-load, sleep, and SpO2 response shapes are
normalized without losing stable measurement identity.
- **The July dependency bundle is included.** PDF.js 6 now releases its loading
task correctly; Canvas 1.0, Radix UI 1.6.2, Tailwind CSS 4.3.3, the Tailwind
Prettier plugin 0.8.1, and setup-node 7 are integrated.
No migrations. No breaking changes.
## [1.31.1] — 2026-07-19
- **Arrival events now follow committed inserts across every writer.** Provider
+10 -4
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.31.1
version: 1.31.2
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -17434,6 +17434,10 @@ components:
type: integer
minimum: 0
maximum: 9007199254740991
failed:
type: integer
minimum: 0
maximum: 9007199254740991
skipped:
type: array
items:
@@ -17458,6 +17462,7 @@ components:
- inserted
- updated
- duplicates
- failed
- skipped
- entries
additionalProperties: false
@@ -17475,9 +17480,10 @@ components:
- updated
- duplicate
- skipped
description: "`inserted`/`duplicate` — the row landed (advance the cursor). `updated` — a `stats:` aggregate (per-day
cumulative total or hourly heart-rate bucket) overwrote an existing row. `skipped` — not stored (e.g.
unmappable identifier, out-of-range value, or `malformed_hr_bucket_id`); see `reason`."
- failed
description: "`inserted`/`duplicate` — the row landed (advance the cursor). `updated` — a `stats:` aggregate overwrote
an existing row. `skipped` — validation no-op; see `reason`. `failed` — retryable database failure that must
not advance the entry cursor; the response is marked `Cache-Control: no-store`."
reason:
type: string
required:
+1 -1
View File
@@ -6243,7 +6243,7 @@
"restore": "Wiederherstellen",
"restoreAria": "Backup für {username} wiederherstellen",
"restoreTitle": "Aus Backup wiederherstellen",
"restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt.",
"restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt. Falls im Snapshot enthalten, überschreiben instanzweite Einstellungen die Konfiguration der gesamten Installation.",
"restorePromptLabel": "Tippe RESTORE zum Fortfahren",
"restoreConfirm": "Jetzt wiederherstellen",
"restoreInProgress": "Wiederherstellung läuft…",
+1 -1
View File
@@ -6243,7 +6243,7 @@
"restore": "Restore",
"restoreAria": "Restore from backup for {username}",
"restoreTitle": "Restore from backup",
"restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted.",
"restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted. If included in the snapshot, instance-wide settings overwrite the configuration for the entire installation.",
"restorePromptLabel": "Type RESTORE to continue",
"restoreConfirm": "Restore now",
"restoreInProgress": "Restoring…",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.31.1",
"version": "1.31.2",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -59,7 +59,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@napi-rs/canvas": "0.1.80",
"@napi-rs/canvas": "1.0.2",
"@node-rs/argon2": "^2.0.2",
"@parse/node-apn": "^8.1.0",
"@prisma/adapter-pg": "^7.8.0",
@@ -87,7 +87,7 @@
"pg-boss": "^12.26.0",
"prisma": "^7.8.0",
"qrcode": "^1.5.4",
"radix-ui": "^1.6.0",
"radix-ui": "^1.6.2",
"react": "19.2.7",
"react-day-picker": "^10.0.1",
"react-dom": "19.2.7",
@@ -125,10 +125,10 @@
"knip": "^6.16.1",
"lefthook": "^2.1.10",
"pdf-parse": "^2.4.5",
"pdfjs-dist": "5.4.296",
"pdfjs-dist": "6.1.200",
"pg": "^8.22.0",
"prettier": "^3.9.1",
"prettier-plugin-tailwindcss": "^0.8.0",
"prettier-plugin-tailwindcss": "^0.8.1",
"shadcn": "^4.13.0",
"tailwindcss": "^4",
"testcontainers": "^12.0.3",
+532 -390
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.31.1";
/* @sw-version-fallback */ "v1.31.2";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -39,6 +39,7 @@ vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "{}") }));
vi.mock("@/lib/crypto/note-cipher", () => ({ encryptNote: vi.fn() }));
vi.mock("@/lib/validations/backup", () => ({
parseBackupPayload: vi.fn(),
isCompatibleSchemaVersion: vi.fn(() => true),
summarizeBackup: vi.fn(() => ({})),
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
File diff suppressed because it is too large. Load diff
+69 -8
View File
@@ -8,7 +8,13 @@ vi.mock("@/lib/db", () => ({
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
deleteMany: vi.fn(),
},
refreshToken: {
updateMany: vi.fn(),
},
$queryRaw: vi.fn(),
$transaction: vi.fn(),
notificationChannel: {
upsert: vi.fn(),
},
@@ -76,6 +82,12 @@ beforeEach(() => {
vi.mocked(prisma.device.findFirst).mockResolvedValue(null);
vi.mocked(prisma.device.create).mockResolvedValue({ id: "dev-1" } as never);
vi.mocked(prisma.device.update).mockResolvedValue({ id: "dev-1" } as never);
vi.mocked(prisma.device.deleteMany).mockResolvedValue({ count: 0 });
vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ count: 0 });
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
vi.mocked(prisma.$transaction).mockImplementation(async (callback) =>
callback(prisma as never),
);
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({
id: "ch-1",
} as never);
@@ -228,27 +240,76 @@ describe("POST /api/devices", () => {
expect(prisma.device.update).not.toHaveBeenCalled();
});
it("falls through idempotently when the apnsToken already belongs to the same user", async () => {
// v1.4.23 W6 reconcile (HIGH 3): the cross-user-hijack guard used
// to filter `NOT: { userId }` so it skipped same-user collisions
// entirely, letting `findMany({ apnsToken })` fan a single push
// out to two Device rows. The guard now reads `userId` and only
// rejects cross-user collisions; same-user collisions fall
// through to the legacy-token upsert path.
it("reconciles a changed legacy token onto the same user's APNs device", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.device.findFirst).mockResolvedValue({
id: "self-dev",
userId: "user-1",
token: "original-legacy-token",
} as never);
const res = await POST(
req({
token: "abcd1234efgh5678",
token: "changed-legacy-token",
bundleId: "io.healthlog.app",
apnsToken: "deadbeef".repeat(8),
apnsEnvironment: "sandbox",
}),
);
expect(res.status).toBe(201);
expect(prisma.device.create).not.toHaveBeenCalled();
expect(prisma.device.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "self-dev", userId: "user-1" },
data: expect.objectContaining({ token: "changed-legacy-token" }),
}),
);
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", deviceId: "original-legacy-token" },
data: { deviceId: "changed-legacy-token" },
});
});
it("merges two same-user identities and migrates owned refresh tokens", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.device.findUnique).mockResolvedValue({
id: "legacy-dev",
userId: "user-1",
token: "changed-legacy-token",
medicationDelivery: "client",
liveActivityPushToken: "cafebabe".repeat(8),
} as never);
vi.mocked(prisma.device.findFirst).mockResolvedValue({
id: "apns-dev",
userId: "user-1",
token: "original-legacy-token",
} as never);
const res = await POST(
req({
token: "changed-legacy-token",
bundleId: "io.healthlog.app",
apnsToken: "deadbeef".repeat(8),
apnsEnvironment: "production",
}),
);
expect(res.status).toBe(201);
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", deviceId: "original-legacy-token" },
data: { deviceId: "changed-legacy-token" },
});
expect(prisma.device.deleteMany).toHaveBeenCalledWith({
where: { id: "legacy-dev", userId: "user-1" },
});
expect(prisma.device.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "apns-dev", userId: "user-1" },
data: expect.objectContaining({
medicationDelivery: "client",
liveActivityPushToken: "cafebabe".repeat(8),
}),
}),
);
});
// v1.17.1 (#22) — Live Activity push-token registration.
+130 -100
View File
@@ -3,8 +3,8 @@
*
* Native-client device registration. The iOS app calls this on login,
* on APNs token rotation, and whenever a fresh `apnsToken` arrives from
* `application:didRegisterForRemoteNotificationsWithDeviceToken:`. We
* upsert by `token` (the legacy generic identifier).
* `application:didRegisterForRemoteNotificationsWithDeviceToken:`. Both the
* legacy `token` and `apnsToken` resolve to one canonical device.
*
* Cross-user-hijack guard:
* * A device `token` belongs to exactly one user. Re-registering the
@@ -162,109 +162,139 @@ export const POST = apiHandler(async (request: NextRequest) => {
liveActivityPushToken,
} = parsed.data;
// APNs-token collision lookup. Two cases:
// * A row for ANOTHER user owns this token → 409 hijack guard.
// APNs tokens aren't secrets, so accepting wire input that
// references another user's token would let anyone who learns
// one redirect that user's pushes.
// * A row for THIS user already owns it → idempotent fall-through.
// The upsert below either updates the matching legacy `token`
// row in place, or creates a fresh legacy `token` row that the
// migration-0041 partial unique index now blocks (the upsert
// branch will hit the unique constraint and the request will
// fail with a 500 — but that's a programmer-error path the
// iOS client should never trigger; same-user re-registration
// keeps the same legacy `token`).
// The `NOT: { userId }` clause was removed in v1.4.23 W6 reconcile —
// it skipped the same-user collision and let `findMany({ apnsToken })`
// fan a single push out to two Device rows, double-charging the APNs
// quota.
if (apnsToken) {
const existingApns = await prisma.device.findFirst({
where: { apnsToken },
select: { id: true, userId: true },
});
if (existingApns && existingApns.userId !== user.id) {
await auditLog("device.register.denied", {
userId: user.id,
details: {
reason: "apns_token_owned_by_other_user",
deviceId: existingApns.id,
const registration = await prisma.$transaction(async (tx) => {
// Token ownership is decided from one database snapshot. Advisory
// transaction locks serialize registrations that share either identity,
// including cross-user attempts and the first-create race. Sorting avoids
// deadlocks when concurrent requests present the same pair in reverse.
const lockKeys = [
`device-token:${token}`,
...(apnsToken ? [`device-apns:${apnsToken}`] : []),
].sort();
for (const lockKey of lockKeys) {
// `pg_advisory_xact_lock` returns void, which Prisma cannot deserialize
// directly. Selecting through FROM yields a plain integer row.
await tx.$queryRaw`
SELECT 1 AS locked
FROM pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))
`;
}
const [legacyDevice, apnsDevice] = await Promise.all([
tx.device.findUnique({
where: { token },
select: {
id: true,
userId: true,
token: true,
medicationDelivery: true,
liveActivityPushToken: true,
},
});
return apiError("APNs token already registered to another account", 409);
}),
apnsToken
? tx.device.findFirst({
where: { apnsToken },
select: {
id: true,
userId: true,
token: true,
medicationDelivery: true,
liveActivityPushToken: true,
},
})
: null,
]);
if (apnsDevice && apnsDevice.userId !== user.id) {
return {
conflict: "apns_token_owned_by_other_user" as const,
deviceId: apnsDevice.id,
};
}
if (legacyDevice && legacyDevice.userId !== user.id) {
return {
conflict: "token_owned_by_other_user" as const,
deviceId: legacyDevice.id,
};
}
// RefreshToken.deviceId stores the client-supplied Device.token value,
// not the database row id. Keep this user's token family aligned when
// APNs identifies an existing device under a changed legacy token.
if (apnsDevice && apnsDevice.token !== token) {
await tx.refreshToken.updateMany({
where: { userId: user.id, deviceId: apnsDevice.token },
data: { deviceId: token },
});
}
// An APNs match is the canonical physical-device identity. If the newly
// presented legacy token already has a second same-user row, carry its
// explicit per-device state (including null clears), remove it, and update
// the APNs row with the newly presented metadata.
let canonicalDevice = apnsDevice ?? legacyDevice;
let duplicateDevice: typeof legacyDevice = null;
if (apnsDevice && legacyDevice && apnsDevice.id !== legacyDevice.id) {
duplicateDevice = legacyDevice;
await tx.device.deleteMany({
where: { id: legacyDevice.id, userId: user.id },
});
canonicalDevice = apnsDevice;
}
const deviceData = {
platform: "ios",
token,
bundleId,
locale: locale ?? null,
appVersion: appVersion ?? null,
model: model ?? null,
apnsToken: apnsToken ?? null,
apnsEnvironment: apnsEnvironment ?? null,
...(medicationDelivery !== undefined
? { medicationDelivery }
: duplicateDevice
? { medicationDelivery: duplicateDevice.medicationDelivery }
: {}),
...(liveActivityPushToken !== undefined
? { liveActivityPushToken }
: duplicateDevice
? { liveActivityPushToken: duplicateDevice.liveActivityPushToken }
: {}),
};
const device = canonicalDevice
? await tx.device.update({
where: { id: canonicalDevice.id, userId: user.id },
data: { ...deviceData, lastSeen: new Date() },
select: { id: true },
})
: await tx.device.create({
data: { userId: user.id, ...deviceData },
select: { id: true },
});
return { id: device.id };
});
if ("conflict" in registration) {
await auditLog("device.register.denied", {
userId: user.id,
details: {
reason: registration.conflict,
deviceId: registration.deviceId,
},
});
return registration.conflict === "apns_token_owned_by_other_user"
? apiError("APNs token already registered to another account", 409)
: apiError("Device token already registered to another account", 409);
}
const existing = await prisma.device.findUnique({ where: { token } });
let id: string;
if (existing) {
// Cross-user-hijack guard: a device-token belongs to exactly one user.
// Re-registering the same token under a different account is rejected
// — APNs tokens aren't a secret, so trusting the wire input would let
// anyone who learns/guesses a token redirect another user's pushes.
if (existing.userId !== user.id) {
await auditLog("device.register.denied", {
userId: user.id,
details: { reason: "token_owned_by_other_user", deviceId: existing.id },
});
return apiError(
"Device token already registered to another account",
409,
);
}
const updated = await prisma.device.update({
where: { token },
data: {
platform: "ios",
bundleId,
locale: locale ?? null,
appVersion: appVersion ?? null,
model: model ?? null,
apnsToken: apnsToken ?? null,
apnsEnvironment: apnsEnvironment ?? null,
// v1.7.0 — only touch the override when the client sends the
// field, so a re-register that omits it keeps the prior value.
...(medicationDelivery !== undefined && { medicationDelivery }),
// v1.17.1 (#22) — same omit-keeps-prior rule; `null` explicitly
// clears the token when the Activity ends.
...(liveActivityPushToken !== undefined && { liveActivityPushToken }),
lastSeen: new Date(),
},
select: { id: true },
});
id = updated.id;
} else {
const created = await prisma.device.create({
data: {
userId: user.id,
platform: "ios",
token,
bundleId,
locale: locale ?? null,
appVersion: appVersion ?? null,
model: model ?? null,
apnsToken: apnsToken ?? null,
apnsEnvironment: apnsEnvironment ?? null,
// v1.7.0 — per-device delivery override (null = inherit default).
...(medicationDelivery !== undefined && { medicationDelivery }),
// v1.17.1 (#22) — Live Activity push token (null = no Activity).
...(liveActivityPushToken !== undefined && { liveActivityPushToken }),
},
select: { id: true },
});
id = created.id;
}
const { id } = registration;
// Auto-create the APNS NotificationChannel row when the device
// registers with an apnsToken. Without this row the dispatcher's APNS
// branch (`dispatcher.ts:41-49`) never fires for the user — every
// production iOS install would otherwise need a manual settings
// toggle that doesn't exist. Mirrors the legacy Telegram on-first-
// dispatch auto-migration, but eager so the very first reminder
// after device registration already routes through APNs. Config is
// an encrypted empty record by design — the per-device token +
// environment live on the Device row.
// Reconcile APNs channel state only after the canonical device transaction
// commits. Config is an encrypted empty record by design — the per-device
// token and environment live on the Device row.
if (apnsToken) {
await prisma.notificationChannel.upsert({
where: { userId_type: { userId: user.id, type: "APNS" } },
@@ -421,7 +421,7 @@ describe("GET /api/export/full-backup", () => {
expect(res.headers.get("content-disposition")).toMatch(/\.json"/);
const json = await res.json();
expect(json).toMatchObject({
schemaVersion: "1",
schemaVersion: "2",
userId: "user-1",
measurements: [],
medications: [],
+596 -6
View File
@@ -17,7 +17,7 @@ vi.mock("@/lib/db", () => ({
findUnique: vi.fn(),
upsert: vi.fn(),
},
moodEntry: { create: vi.fn(), upsert: vi.fn() },
moodEntry: { create: vi.fn(), findUnique: vi.fn(), upsert: vi.fn() },
},
}));
@@ -33,6 +33,12 @@ vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
const { encryptNoteMock } = vi.hoisted(() => ({ encryptNoteMock: vi.fn() }));
vi.mock("@/lib/crypto/note-cipher", () => ({
encryptNote: encryptNoteMock,
}));
vi.mock("@/lib/arrivals/measurement-emit", () => ({
emitInsertedMeasurementArrivals: vi.fn(),
}));
@@ -65,12 +71,18 @@ import { prisma } from "@/lib/db";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import { Prisma } from "@/generated/prisma/client";
import { auditLog } from "@/lib/auth/audit";
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findUnique).mockResolvedValue(null);
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null);
vi.mocked(emitInsertedMeasurementArrivals).mockResolvedValue(undefined);
vi.mocked(maybeEnqueueMorningRefresh).mockResolvedValue(undefined);
encryptNoteMock.mockImplementation((note: string | null) =>
note === null ? null : `encrypted:${note}`,
);
});
interface ApiErrorEnvelope {
@@ -78,6 +90,10 @@ interface ApiErrorEnvelope {
error: string;
}
function asDate(value: string | Date): Date {
return value instanceof Date ? value : new Date(value);
}
// V3 audit: /api/import POST had no rate-limit. Bulk-injection vector
// (max:10000 records per call). Now capped at 5/hour/user.
describe("POST /api/import — rate-limit guard", () => {
@@ -234,7 +250,10 @@ describe("POST /api/import — measurement rollup hook (v1.4.39.1)", () => {
resetAt: new Date(Date.now() + 1000),
} as never);
vi.mocked(prisma.measurement.create).mockRejectedValue(
new Error("P2002 duplicate"),
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
}),
);
const response = await POST(
@@ -390,10 +409,10 @@ describe("POST /api/import — arrival wiring", () => {
});
it("does not emit or refresh an existing external-id update", async () => {
vi.mocked(prisma.measurement.findUnique).mockResolvedValue({
id: "existing",
} as never);
vi.mocked(prisma.measurement.upsert).mockResolvedValue({
vi.mocked(prisma.measurement.createManyAndReturn).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.measurement.update).mockResolvedValue({
id: "existing",
type: "SLEEP_DURATION",
measuredAt: new Date("2026-05-14T06:00:00.000Z"),
@@ -573,3 +592,574 @@ describe("POST /api/import — measurement externalId dedup (v1.17.1)", () => {
expect(vi.mocked(prisma.measurement.upsert)).not.toHaveBeenCalled();
});
});
describe("POST /api/import — write failure classification", () => {
beforeEach(() => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 4,
resetAt: new Date(Date.now() + 1000),
} as never);
});
function request(body: Record<string, unknown>): NextRequest {
return new NextRequest("http://localhost/api/import", {
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
});
}
function measurement(value = 80): Record<string, unknown> {
return {
type: "WEIGHT",
value,
unit: "kg",
measuredAt: "2026-05-01T08:00:00.000Z",
};
}
function moodEntry(): Record<string, unknown> {
return {
date: "2026-05-01",
mood: "GUT",
score: 4,
loggedAt: "2026-05-01T12:00:00.000Z",
};
}
function expectRetryableFailure(
body: unknown,
stats: { measurements: number; moodEntries: number; skipped: number },
): void {
expect(body).toEqual({
data: null,
error: "Import write failed",
meta: {
errorCode: "import.write_failed",
retryable: true,
stats,
},
});
}
it("counts an authentic P2002 measurement error as a duplicate", async () => {
vi.mocked(prisma.measurement.create).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: {
target: ["userId", "type", "measuredAt", "source", "sleepStage"],
},
}),
);
const response = await POST(request({ measurements: [measurement()] }));
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
data: { measurements: 0, moodEntries: 0, skipped: 1 },
error: null,
});
});
it("counts an authentic P2002 mood error as a duplicate", async () => {
vi.mocked(prisma.moodEntry.create).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: {
target: ["userId", "date", "moodLoggedAt"],
},
}),
);
const response = await POST(request({ moodEntries: [moodEntry()] }));
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
data: { measurements: 0, moodEntries: 0, skipped: 1 },
error: null,
});
});
it("skips an external-id insert race lost on timestamp", async () => {
vi.mocked(prisma.measurement.createManyAndReturn).mockResolvedValueOnce(
[] as never,
);
vi.mocked(prisma.measurement.update).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("Record to update not found", {
code: "P2025",
clientVersion: "test",
}),
);
const response = await POST(
request({
measurements: [{ ...measurement(), externalId: "new-source-id" }],
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
data: { measurements: 0, moodEntries: 0, skipped: 1 },
error: null,
});
});
it("returns retryable counts on database timeout", async () => {
vi.mocked(prisma.measurement.create)
.mockResolvedValueOnce({
id: "measurement-1",
type: "WEIGHT",
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
} as never)
.mockRejectedValueOnce(
new Prisma.PrismaClientInitializationError(
"Operations timed out",
"test",
"P1008",
),
);
const response = await POST(
request({ measurements: [measurement(80), measurement(81)] }),
);
expect(response.status).toBe(503);
expectRetryableFailure(await response.json(), {
measurements: 1,
moodEntries: 0,
skipped: 0,
});
});
it("returns retryable counts on connection loss", async () => {
vi.mocked(prisma.measurement.create).mockResolvedValueOnce({
id: "measurement-1",
type: "WEIGHT",
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
} as never);
vi.mocked(prisma.moodEntry.create).mockRejectedValueOnce(
new Prisma.PrismaClientInitializationError(
"Can't reach database server",
"test",
"P1001",
),
);
const response = await POST(
request({
measurements: [measurement()],
moodEntries: [moodEntry()],
}),
);
expect(response.status).toBe(503);
expectRetryableFailure(await response.json(), {
measurements: 1,
moodEntries: 0,
skipped: 0,
});
});
it("returns a retryable failure when note encryption fails", async () => {
encryptNoteMock.mockImplementationOnce(() => {
throw new Error("Encryption key unavailable");
});
const response = await POST(
request({
measurements: [{ ...measurement(), notes: "private note" }],
}),
);
expect(response.status).toBe(503);
expectRetryableFailure(await response.json(), {
measurements: 0,
moodEntries: 0,
skipped: 0,
});
});
it("resumes a partially committed legacy mood import without duplicates", async () => {
type LegacyMoodWrite = { date: string; moodLoggedAt: Date };
const committedKeys = new Set<string>();
let writeAttempt = 0;
const writeMood = async (
data: LegacyMoodWrite,
target: Set<string>,
): Promise<never> => {
writeAttempt++;
if (writeAttempt === 2) {
throw new Error("transient write failure");
}
const key = `${data.date}:${data.moodLoggedAt.toISOString()}`;
if (target.has(key)) {
throw new Prisma.PrismaClientKnownRequestError(
"Unique constraint failed",
{
code: "P2002",
clientVersion: "test",
meta: { target: ["userId", "date", "moodLoggedAt"] },
},
);
}
target.add(key);
return {} as never;
};
vi.mocked(prisma.moodEntry.create).mockImplementation(
({ data }: Prisma.MoodEntryCreateArgs) =>
writeMood(data as LegacyMoodWrite, committedKeys) as never,
);
vi.mocked(prisma.moodEntry.findUnique).mockImplementation(
({ where }: Prisma.MoodEntryFindUniqueArgs) => {
const composite = where.userId_date_moodLoggedAt!;
const key = `${composite.date}:${asDate(composite.moodLoggedAt).toISOString()}`;
if (!committedKeys.has(key)) return Promise.resolve(null) as never;
const isFirstDay = composite.date === "2026-05-01";
return Promise.resolve({
source: "IMPORT",
mood: isFirstDay ? "GUT" : "OKAY",
score: isFirstDay ? 4 : 3,
tags: null,
externalId: null,
} as never) as never;
},
);
const payload = {
moodEntries: [
{ date: "2026-05-01", mood: "GUT", score: 4 },
{ date: "2026-05-02", mood: "OKAY", score: 3 },
],
};
const failedResponse = await POST(request(payload));
expect(failedResponse.status).toBe(503);
expectRetryableFailure(await failedResponse.json(), {
measurements: 0,
moodEntries: 1,
skipped: 0,
});
expect(committedKeys.size).toBe(1);
const retryResponse = await POST(request(payload));
expect(retryResponse.status).toBe(200);
expect(await retryResponse.json()).toEqual({
data: { measurements: 0, moodEntries: 1, skipped: 1 },
error: null,
});
expect(committedKeys.size).toBe(2);
});
it("does not alias unrelated date-only mood imports", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-05-10T08:00:00.000Z"));
const committedKeys = new Set<string>();
vi.mocked(prisma.moodEntry.create).mockImplementation(
({ data }: Prisma.MoodEntryCreateArgs) => {
const key = `${data.date}:${asDate(data.moodLoggedAt).toISOString()}`;
if (committedKeys.has(key)) {
return Promise.reject(
new Prisma.PrismaClientKnownRequestError(
"Unique constraint failed",
{
code: "P2002",
clientVersion: "test",
meta: { target: ["userId", "date", "moodLoggedAt"] },
},
),
) as never;
}
committedKeys.add(key);
return Promise.resolve({} as never) as never;
},
);
const firstResponse = await POST(
request({
moodEntries: [{ date: "2026-05-10", mood: "GUT", score: 4 }],
}),
);
vi.advanceTimersByTime(1000);
const secondResponse = await POST(
request({
moodEntries: [{ date: "2026-05-10", mood: "SCHLECHT", score: 2 }],
}),
);
expect(firstResponse.status).toBe(200);
expect(secondResponse.status).toBe(200);
expect(await secondResponse.json()).toEqual({
data: { measurements: 0, moodEntries: 1, skipped: 0 },
error: null,
});
expect(committedKeys.size).toBe(2);
} finally {
vi.useRealTimers();
}
});
it("anchors a date-only mood row to its recorded day", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-05-10T08:00:00.000Z"));
vi.mocked(prisma.moodEntry.create).mockResolvedValue({} as never);
const response = await POST(
request({
moodEntries: [{ date: "2026-05-01", mood: "GUT", score: 4 }],
}),
);
expect(response.status).toBe(200);
const moodLoggedAt = asDate(
vi.mocked(prisma.moodEntry.create).mock.calls[0][0].data.moodLoggedAt,
);
expect(moodLoggedAt.getTime()).toBeGreaterThanOrEqual(
Date.parse("2026-04-30T22:00:00.000Z"),
);
expect(moodLoggedAt.getTime()).toBeLessThan(
Date.parse("2026-04-30T22:05:00.000Z"),
);
} finally {
vi.useRealTimers();
}
});
it("accepts the current Berlin day shortly after local midnight", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-05-10T22:30:00.000Z"));
vi.mocked(prisma.moodEntry.create).mockResolvedValue({} as never);
const response = await POST(
request({
moodEntries: [{ date: "2026-05-11", mood: "GUT", score: 4 }],
}),
);
expect(response.status).toBe(200);
const moodLoggedAt = vi.mocked(prisma.moodEntry.create).mock.calls[0][0]
.data.moodLoggedAt;
expect(new Date(moodLoggedAt).getTime()).toBeGreaterThanOrEqual(
Date.parse("2026-05-10T22:00:00.000Z"),
);
expect(new Date(moodLoggedAt).getTime()).toBeLessThan(
Date.parse("2026-05-10T22:05:00.000Z"),
);
} finally {
vi.useRealTimers();
}
});
it("resolves persisted and repeated-row fallback timestamp collisions", async () => {
type StoredMood = {
date: string;
mood: string;
score: number;
tags: string | null;
externalId?: string | null;
moodLoggedAt: Date;
};
const persisted = new Map<string, StoredMood>();
vi.mocked(prisma.moodEntry.create).mockImplementation(
({ data }: Prisma.MoodEntryCreateArgs) => {
const moodLoggedAt = asDate(data.moodLoggedAt);
const key = `${data.date}:${moodLoggedAt.toISOString()}`;
if (persisted.has(key)) {
return Promise.reject(
new Prisma.PrismaClientKnownRequestError(
"Unique constraint failed",
{
code: "P2002",
clientVersion: "test",
meta: { target: ["userId", "date", "moodLoggedAt"] },
},
),
) as never;
}
persisted.set(key, { ...data, moodLoggedAt } as StoredMood);
return Promise.resolve({} as never) as never;
},
);
vi.mocked(prisma.moodEntry.findUnique).mockImplementation(
({ where }: Prisma.MoodEntryFindUniqueArgs) => {
const composite = where.userId_date_moodLoggedAt!;
const key = `${composite.date}:${asDate(composite.moodLoggedAt).toISOString()}`;
return Promise.resolve((persisted.get(key) ?? null) as never) as never;
},
);
const firstPayload = {
moodEntries: [
{ date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-250" },
],
};
const collidingPayload = {
moodEntries: [
{ date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-421" },
],
};
await POST(request(firstPayload));
const collisionResponse = await POST(request(collidingPayload));
const retryResponse = await POST(request(collidingPayload));
expect(await collisionResponse.json()).toEqual({
data: { measurements: 0, moodEntries: 1, skipped: 0 },
error: null,
});
expect(await retryResponse.json()).toEqual({
data: { measurements: 0, moodEntries: 0, skipped: 1 },
error: null,
});
expect(persisted.size).toBe(2);
const reverseOrderResponse = await POST(
request({
moodEntries: [
{ date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-421" },
{ date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-250" },
],
}),
);
expect(await reverseOrderResponse.json()).toEqual({
data: { measurements: 0, moodEntries: 0, skipped: 2 },
error: null,
});
expect(persisted.size).toBe(2);
const repeatedRowResponse = await POST(
request({
moodEntries: [
{ date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-53200" },
{ date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-53200" },
],
}),
);
expect(await repeatedRowResponse.json()).toEqual({
data: { measurements: 0, moodEntries: 2, skipped: 0 },
error: null,
});
expect(persisted.size).toBe(4);
});
it("keeps a generated timestamp stable when upserting an external ID", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-05-10T08:00:00.000Z"));
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({} as never);
const payload = {
moodEntries: [
{
date: "2026-05-01",
mood: "GUT",
score: 4,
externalId: "legacy-row-1",
},
],
};
await POST(request(payload));
vi.advanceTimersByTime(1000);
await POST(request(payload));
const first = vi.mocked(prisma.moodEntry.upsert).mock.calls[0][0];
const second = vi.mocked(prisma.moodEntry.upsert).mock.calls[1][0];
expect(first.create.moodLoggedAt).toEqual(second.create.moodLoggedAt);
expect(first.update.moodLoggedAt).toEqual(second.update.moodLoggedAt);
} finally {
vi.useRealTimers();
}
});
it.each(["1899-12-31", "2026-05-11"])(
"rejects an implausible date-only mood row dated %s",
async (date) => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-05-10T08:00:00.000Z"));
const response = await POST(
request({ moodEntries: [{ date, mood: "GUT", score: 4 }] }),
);
expect(response.status).toBe(422);
expect(prisma.moodEntry.create).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
},
);
it("returns a retryable failure for a generic mood write error", async () => {
vi.mocked(prisma.moodEntry.create).mockRejectedValueOnce(
new Error("write failed"),
);
const response = await POST(request({ moodEntries: [moodEntry()] }));
expect(response.status).toBe(503);
expectRetryableFailure(await response.json(), {
measurements: 0,
moodEntries: 0,
skipped: 0,
});
});
it("audits rows committed before a later write failure", async () => {
vi.mocked(prisma.measurement.create)
.mockResolvedValueOnce({
id: "measurement-1",
type: "WEIGHT",
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
} as never)
.mockRejectedValueOnce(new Error("write failed"));
const response = await POST(
request({ measurements: [measurement(80), measurement(81)] }),
);
expect(response.status).toBe(503);
expect(auditLog).toHaveBeenCalledWith("import.upload", {
userId: "u-1",
ipAddress: null,
details: { measurements: 1, moodEntries: 0, skipped: 0 },
});
});
it("keeps the classified failure when its audit also fails", async () => {
vi.mocked(prisma.measurement.create).mockRejectedValueOnce(
new Prisma.PrismaClientInitializationError(
"Can't reach database server",
"test",
"P1001",
),
);
vi.mocked(auditLog).mockRejectedValueOnce(
new Prisma.PrismaClientInitializationError(
"Can't reach database server",
"test",
"P1001",
),
);
const response = await POST(request({ measurements: [measurement()] }));
expect(response.status).toBe(503);
expectRetryableFailure(await response.json(), {
measurements: 0,
moodEntries: 0,
skipped: 0,
});
expect(auditLog).toHaveBeenCalledTimes(1);
});
});
+235 -89
View File
@@ -11,13 +11,18 @@ import {
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { createHash } from "node:crypto";
import {
validateMeasurementRange,
measurementTypeEnum,
glucoseContextEnum,
} from "@/lib/validations/measurement";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
import {
ENTRY_INSTANT_CLOCK_SKEW_MS,
isPlausibleEntryInstant,
validateEntryInstant,
} from "@/lib/validations/entry-instant";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
import {
@@ -27,6 +32,9 @@ import {
import type { MeasurementType } from "@/generated/prisma/client";
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import { isP2002, isP2025 } from "@/lib/prisma-errors";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
// Derived from canonical enum so round-trip export → import covers every
// type. Previous hardcoded subset silently dropped 4 of 11 types
@@ -59,22 +67,71 @@ const measurementSchema = z
"Wert ausserhalb des plausiblen Bereichs",
);
const moodEntrySchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
mood: z.enum(["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]),
score: z.number().int().min(1).max(5),
tags: z.string().optional(),
// v1.17.1 — same entry-instant bound as measurements; an imported mood
// log cannot be forward-dated. Optional + transforms to a `Date` when set.
loggedAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
).optional(),
// v1.12.1 — optional source-stable id (e.g. a Daylio row id). When
// present, the import upserts on `(userId, source, externalId)` so a
// re-import of the same export is idempotent rather than minting a
// duplicate; absent → the legacy first-write-wins create path.
externalId: z.string().min(1).max(120).optional(),
});
function fallbackMoodDateStart(date: string): Date | null {
const [year, month, day] = date.split("-").map(Number);
if (!year || !month || !day) return null;
const instant = zonedWallClockToUtc(
{ year, month, day, hour: 0, minute: 0 },
DEFAULT_TIMEZONE,
);
return moodDateKey(instant, DEFAULT_TIMEZONE) === date ? instant : null;
}
const moodEntrySchema = z
.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
mood: z.enum(["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]),
score: z.number().int().min(1).max(5),
tags: z.string().optional(),
// v1.17.1 — same entry-instant bound as measurements; an imported mood
// log cannot be forward-dated. Optional + transforms to a `Date` when set.
loggedAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
).optional(),
// v1.12.1 — optional source-stable id (e.g. a Daylio row id). When
// present, the import upserts on `(userId, source, externalId)` so a
// re-import of the same export is idempotent rather than minting a
// duplicate; absent → the legacy first-write-wins create path.
externalId: z.string().min(1).max(120).optional(),
})
.superRefine((entry, ctx) => {
if (entry.loggedAt) return;
const dateStart = fallbackMoodDateStart(entry.date);
if (!dateStart || !isPlausibleEntryInstant(dateStart)) {
ctx.addIssue({
code: "custom",
path: ["date"],
message: "Mood date must be between 1900 and today",
});
}
});
type MoodEntryInput = z.infer<typeof moodEntrySchema>;
function fallbackMoodIdentity(entry: MoodEntryInput): string {
return JSON.stringify([
entry.date,
entry.mood,
entry.score,
entry.tags ?? null,
entry.externalId ?? null,
]);
}
function stableFallbackMoodInstant(
entry: MoodEntryInput,
occurrence: number,
probe: number,
): Date {
const digest = createHash("sha256")
.update(`${fallbackMoodIdentity(entry)}:${occurrence}`)
.digest();
const initialOffset = digest.readUInt32BE(0) % ENTRY_INSTANT_CLOCK_SKEW_MS;
const offset = (initialOffset + probe) % ENTRY_INSTANT_CLOCK_SKEW_MS;
const dateStart = fallbackMoodDateStart(entry.date);
if (!dateStart) throw new Error("Invalid mood date");
return new Date(dateStart.getTime() + offset);
}
const importSchema = z.object({
measurements: z.array(measurementSchema).max(10000).optional(),
@@ -113,6 +170,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const userId = user.id;
const data = parsed.data;
const stats = { measurements: 0, moodEntries: 0, skipped: 0 };
let writeFailed = false;
// Import measurements
// v1.4.39.1 — track each (type, measuredAt) we wrote so we can re-
@@ -164,24 +222,32 @@ export const POST = apiHandler(async (request: NextRequest) => {
// A pre-existing row or a raced insert owns the key. Preserve
// idempotent re-import semantics without classifying this update
// as an arrival.
await prisma.measurement.update({
where: {
userId_type_source_externalId: {
userId,
type: m.type,
source: "IMPORT",
externalId: m.externalId,
try {
await prisma.measurement.update({
where: {
userId_type_source_externalId: {
userId,
type: m.type,
source: "IMPORT",
externalId: m.externalId,
},
},
},
data: {
value: m.value,
unit: m.unit,
measuredAt,
notes: null,
notesEncrypted,
glucoseContext: m.glucoseContext ?? null,
},
});
data: {
value: m.value,
unit: m.unit,
measuredAt,
notes: null,
notesEncrypted,
glucoseContext: m.glucoseContext ?? null,
},
});
} catch (err) {
if (isP2025(err)) {
stats.skipped++;
continue;
}
throw err;
}
}
} else {
const inserted = await prisma.measurement.create({
@@ -205,9 +271,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
measuredAt,
});
stats.measurements++;
} catch {
// Unique constraint violation — skip duplicate
stats.skipped++;
} catch (err) {
if (isP2002(err)) {
stats.skipped++;
continue;
}
writeFailed = true;
break;
}
}
}
@@ -225,59 +295,122 @@ export const POST = apiHandler(async (request: NextRequest) => {
).catch(() => {});
// Import mood entries
if (data.moodEntries?.length) {
if (!writeFailed && data.moodEntries?.length) {
const fallbackOccurrences = new Map<string, number>();
const reservedFallbackInstants = new Set<number>();
for (const e of data.moodEntries) {
try {
const loggedAt = e.loggedAt ?? new Date();
if (e.externalId) {
// v1.12.1 — idempotent re-import keyed on the source-stable id.
// A second import of the same export updates the row in place
// rather than skipping it as a duplicate, so an upstream edit
// (re-scored mood, added tag) is reflected.
await prisma.moodEntry.upsert({
where: {
userId_source_externalId: {
const identity = e.loggedAt ? null : fallbackMoodIdentity(e);
const occurrence =
identity === null ? 0 : (fallbackOccurrences.get(identity) ?? 0);
if (identity !== null) {
fallbackOccurrences.set(identity, occurrence + 1);
}
for (let probe = 0; probe < ENTRY_INSTANT_CLOCK_SKEW_MS; probe++) {
const loggedAt =
e.loggedAt ?? stableFallbackMoodInstant(e, occurrence, probe);
if (!e.loggedAt && reservedFallbackInstants.has(loggedAt.getTime())) {
continue;
}
try {
if (e.externalId) {
// v1.12.1 — idempotent re-import keyed on the source-stable id.
// A second import of the same export updates the row in place
// rather than skipping it as a duplicate, so an upstream edit
// (re-scored mood, added tag) is reflected.
await prisma.moodEntry.upsert({
where: {
userId_source_externalId: {
userId,
source: "IMPORT",
externalId: e.externalId,
},
},
update: {
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags || null,
moodLoggedAt: loggedAt,
},
create: {
userId,
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags || null,
source: "IMPORT",
externalId: e.externalId,
moodLoggedAt: loggedAt,
},
},
update: {
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags || null,
moodLoggedAt: loggedAt,
},
create: {
userId,
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags || null,
source: "IMPORT",
externalId: e.externalId,
moodLoggedAt: loggedAt,
},
});
} else {
await prisma.moodEntry.create({
data: {
userId,
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags || null,
source: "IMPORT",
moodLoggedAt: loggedAt,
},
});
});
} else {
await prisma.moodEntry.create({
data: {
userId,
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags || null,
source: "IMPORT",
moodLoggedAt: loggedAt,
},
});
}
if (!e.loggedAt) {
reservedFallbackInstants.add(loggedAt.getTime());
}
stats.moodEntries++;
break;
} catch (err) {
if (!isP2002(err)) {
writeFailed = true;
break;
}
if (e.loggedAt) {
stats.skipped++;
break;
}
if (!e.externalId) {
try {
const existing = await prisma.moodEntry.findUnique({
where: {
userId_date_moodLoggedAt: {
userId,
date: e.date,
moodLoggedAt: loggedAt,
},
},
select: {
source: true,
mood: true,
score: true,
tags: true,
externalId: true,
},
});
if (
existing?.source === "IMPORT" &&
existing.mood === e.mood &&
existing.score === e.score &&
existing.tags === (e.tags || null) &&
(existing.externalId ?? null) === null
) {
reservedFallbackInstants.add(loggedAt.getTime());
stats.skipped++;
break;
}
} catch {
writeFailed = true;
break;
}
}
if (probe === ENTRY_INSTANT_CLOCK_SKEW_MS - 1) {
writeFailed = true;
}
}
stats.moodEntries++;
} catch {
// Unique constraint violation — skip duplicate
stats.skipped++;
}
if (writeFailed) break;
}
}
@@ -322,13 +455,26 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
await auditLog("import.upload", {
userId,
ipAddress: getClientIp(request),
details: stats,
});
annotate({ meta: { import_stats: stats } });
try {
await auditLog("import.upload", {
userId,
ipAddress: getClientIp(request),
details: stats,
});
} catch (err) {
if (!writeFailed) throw err;
annotate({ meta: { import_audit_failed: true } });
}
if (writeFailed) {
return apiError("Import write failed", 503, {
errorCode: "import.write_failed",
retryable: true,
stats,
});
}
return apiSuccess(stats);
});
@@ -192,6 +192,23 @@ describe("POST /api/integrations/moodlog/webhook", () => {
expect(wheres[0]).toEqual(wheres[1]);
});
it("returns retryable failure when the source record cannot be persisted", async () => {
vi.mocked(prisma.moodEntry.upsert).mockRejectedValueOnce(
new Error("database unavailable"),
);
const res = await POST(
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
);
expect(res.status).toBe(503);
expect(await res.json()).toMatchObject({
data: null,
error: expect.stringMatching(/persist/i),
});
expect(prisma.moodEntry.upsert).toHaveBeenCalledOnce();
});
it("mood.deleted removes any matching entry rather than upserting", async () => {
const deletedPayload = {
...VALID_PAYLOAD,
@@ -6,6 +6,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { getClientIp } from "@/lib/api-response";
import { moodLogWebhookPayloadSchema } from "@/lib/validations/moodlog";
import { readMoodLogSecret } from "@/lib/moodlog-secret";
import { persistMoodLogSourceEntry } from "@/lib/moodlog/persistence";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
@@ -97,7 +98,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { event, entry } = parsed.data;
annotate({ meta: { webhook_event: event } });
// 5. Process event (non-blocking — fire and forget with error logging)
// 5. Process event and acknowledge only after the source write commits.
const moodLoggedAt = new Date(entry.time);
// v1.12.1 — when the upstream supplies a stable entry id, dedup on
// `(userId, source, externalId)` so a re-emit with a re-rounded /
@@ -116,67 +117,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
: { date: entry.date, moodLoggedAt }),
},
});
} else if (externalId) {
// mood.created or mood.updated, keyed on the stable external id.
await prisma.moodEntry.upsert({
where: {
userId_source_externalId: {
userId: user.id,
source: "MOODLOG",
externalId,
},
},
update: {
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
date: entry.date,
moodLoggedAt,
// No-op on a live row, a deliberate RESURRECTION on a tombstoned
// one — moodLog is the source of truth for the rows it minted; a
// created/updated event means the entry exists upstream (deletion
// arrives as its own `mood.deleted` event).
deletedAt: null,
},
create: {
userId: user.id,
date: entry.date,
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
externalId,
moodLoggedAt,
},
});
} else {
// mood.created or mood.updated, legacy wall-clock key.
await prisma.moodEntry.upsert({
where: {
userId_date_moodLoggedAt: {
userId: user.id,
date: entry.date,
moodLoggedAt,
},
},
update: {
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
// Same resurrection rule as the externalId-keyed upsert above.
deletedAt: null,
},
create: {
userId: user.id,
date: entry.date,
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
moodLoggedAt,
},
await persistMoodLogSourceEntry(user.id, {
externalId,
date: entry.date,
moodLoggedAt,
mood: entry.mood,
score: entry.score,
tags: entry.tags,
});
}
@@ -193,9 +141,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
} catch (err) {
getEvent()?.addWarning("DB operation failed: " + err);
const message = err instanceof Error ? err.message : String(err);
getEvent()?.addWarning(`MoodLog source persistence failed: ${message}`);
return apiError("Failed to persist moodLog event", 503);
}
// 6. Return 200 immediately
// 6. The source record is durable; the provider can stop retrying.
return new Response(null, { status: 200 });
});
@@ -31,6 +31,64 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
reconcileExternalMeasurement: async (
tx: {
measurement: {
findMany: (args: unknown) => Promise<
Array<{
id?: string;
type?: string;
source?: string;
externalId?: string | null;
}>
>;
createManyAndReturn: (args: {
data: Record<string, unknown>;
}) => Promise<Array<Record<string, unknown>>>;
updateMany: (args: {
where: Record<string, unknown>;
data: Record<string, unknown>;
}) => Promise<unknown>;
};
},
input: Record<string, unknown> & {
userId: string;
type: string;
source: string;
externalId: string;
},
options: { exactExternalMatch?: "update" | "duplicate" } = {},
) => {
const existing = await tx.measurement.findMany({});
const exact = existing.find(
(row: { type?: string; source?: string; externalId?: string | null }) =>
row.type === input.type &&
row.source === input.source &&
row.externalId === input.externalId,
);
if (exact && options.exactExternalMatch !== "update") {
return { status: "duplicate", row: exact };
}
if (exact) {
await tx.measurement.updateMany({
where: { id: exact.id },
data: { ...input, deletedAt: null },
});
return { status: "updated", row: { ...exact, ...input } };
}
const [inserted] = await tx.measurement.createManyAndReturn({
data: input,
});
if (inserted) return { status: "inserted", row: inserted };
if (options.exactExternalMatch === "update") {
await tx.measurement.updateMany({ where: {}, data: input });
return { status: "updated", row: input };
}
return { status: "duplicate" };
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -159,12 +217,10 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
expect(data.entries[0].status).toBe("inserted");
// Stored as a PULSE row carrying the 10-min average value.
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0][0];
const rows = (createArg as { data: { type: string; value: number }[] })
.data;
expect(rows[0].type).toBe("PULSE");
expect(rows[0].value).toBe(72);
const row = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0]![0]!.data as { type: string; value: number };
expect(row.type).toBe("PULSE");
expect(row.value).toBe(72);
});
it("persists per-bucket min/max on a fresh 10-min HR bucket (iOS #34 ext)", async () => {
@@ -176,16 +232,15 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
}),
);
expect(res.status).toBe(200);
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0][0];
const rows = (
createArg as {
data: { value: number; valueMin: number; valueMax: number }[];
}
).data;
expect(rows[0].value).toBe(72);
expect(rows[0].valueMin).toBe(58);
expect(rows[0].valueMax).toBe(96);
const row = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0]![0]!.data as {
value: number;
valueMin: number;
valueMax: number;
};
expect(row.value).toBe(72);
expect(row.valueMin).toBe(58);
expect(row.valueMax).toBe(96);
});
it.each([
@@ -202,21 +257,15 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72, spread)] }),
);
expect(res.status).toBe(200);
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0][0];
const rows = (
createArg as {
data: {
value: number;
valueMin: number | null;
valueMax: number | null;
}[];
}
).data;
// The trustworthy average survives; the invalid spread is dropped.
expect(rows[0].value).toBe(72);
expect(rows[0].valueMin).toBeNull();
expect(rows[0].valueMax).toBeNull();
const row = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0]![0]!.data as {
value: number;
valueMin: number | null;
valueMax: number | null;
};
expect(row.value).toBe(72);
expect(row.valueMin).toBeNull();
expect(row.valueMax).toBeNull();
},
);
@@ -251,15 +300,13 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
}),
);
expect(res.status).toBe(200);
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0][0];
const rows = (
createArg as {
data: { valueMin: number | null; valueMax: number | null }[];
}
).data;
expect(rows[0].valueMin).toBeNull();
expect(rows[0].valueMax).toBeNull();
const row = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0]![0]!.data as {
valueMin: number | null;
valueMax: number | null;
};
expect(row.valueMin).toBeNull();
expect(row.valueMax).toBeNull();
});
it("overwrites the same bucket on a re-post (updated, not duplicate)", async () => {
@@ -445,12 +492,10 @@ describe("POST /api/measurements/batch — intra-batch `stats:` supersession", (
expect(res.status).toBe(200);
const { data } = await readJson(res);
// Exactly ONE row reaches the insert, carrying the NEWER value.
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0][0];
const rows = (createArg as { data: { value: number }[] }).data;
expect(rows).toHaveLength(1);
expect(rows[0].value).toBe(78);
expect(prisma.measurement.createManyAndReturn).toHaveBeenCalledTimes(1);
const row = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0]![0]!.data as { value: number };
expect(row.value).toBe(78);
// The superseded entry is reported honestly, not as a phantom insert.
expect(data.entries[0].status).toBe("duplicate");
@@ -500,9 +545,7 @@ describe("POST /api/measurements/batch — intra-batch `stats:` supersession", (
expect(res.status).toBe(200);
const { data } = await readJson(res);
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0][0];
expect((createArg as { data: unknown[] }).data).toHaveLength(2);
expect(prisma.measurement.createManyAndReturn).toHaveBeenCalledTimes(2);
expect(data.inserted).toBe(2);
expect(data.duplicates).toBe(0);
});
@@ -15,6 +15,60 @@ vi.mock("@/lib/db", () => ({
}),
},
}));
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
reconcileExternalMeasurement: async (
tx: {
measurement: {
findMany: (args: unknown) => Promise<
Array<{
id?: string;
type?: string;
source?: string;
externalId?: string | null;
}>
>;
createManyAndReturn: (args: {
data: Record<string, unknown>;
}) => Promise<Array<Record<string, unknown>>>;
updateMany: (args: {
where: Record<string, unknown>;
data: Record<string, unknown>;
}) => Promise<unknown>;
};
},
input: Record<string, unknown> & {
userId: string;
type: string;
source: string;
externalId: string;
},
options: { exactExternalMatch?: "update" | "duplicate" } = {},
) => {
const existing = await tx.measurement.findMany({});
const exact = existing.find(
(row: { type?: string; source?: string; externalId?: string | null }) =>
row.type === input.type &&
row.source === input.source &&
row.externalId === input.externalId,
);
if (exact && options.exactExternalMatch !== "update") {
return { status: "duplicate", row: exact };
}
if (exact) {
await tx.measurement.updateMany({ where: { id: exact.id }, data: input });
return { status: "updated", row: { ...exact, ...input } };
}
const [inserted] = await tx.measurement.createManyAndReturn({
data: input,
});
if (inserted) return { status: "inserted", row: inserted };
if (options.exactExternalMatch === "update") {
await tx.measurement.updateMany({ where: {}, data: input });
return { status: "updated", row: input };
}
return { status: "duplicate" };
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -190,12 +244,7 @@ describe("POST /api/measurements/batch — exact INSERT RETURNING identity", ()
"duplicate",
"duplicate",
]);
expect(prisma.measurement.createManyAndReturn).toHaveBeenCalledWith(
expect.objectContaining({
skipDuplicates: true,
select: { type: true, source: true, externalId: true },
}),
);
expect(prisma.measurement.createManyAndReturn).toHaveBeenCalledTimes(4);
expect(maybeEnqueueMorningRefresh).toHaveBeenCalledWith("user-1", [
new Date(insertedSleepAt),
]);
@@ -8,6 +8,8 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const reconcileOverrideMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
@@ -23,6 +25,28 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
reconcileExternalMeasurement: async (
tx: {
measurement: {
createManyAndReturn: (args: {
data: Record<string, unknown>;
}) => Promise<Array<Record<string, unknown>>>;
};
},
input: Record<string, unknown>,
) => {
const override = await reconcileOverrideMock(tx, input);
if (override !== undefined) return override;
const [inserted] = await tx.measurement.createManyAndReturn({
data: input,
});
return inserted
? { status: "inserted", row: inserted }
: { status: "duplicate" };
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -118,6 +142,29 @@ describe("POST /api/measurements/batch — PR detection enqueue (v1.4.25 W16c)",
});
});
it("surfaces a hard reconciliation failure without checkpointing it", async () => {
reconcileOverrideMock.mockResolvedValueOnce({
status: "failed",
error: { message: "write rejected", code: "P2002" },
});
const res = await POST(makeRequest({ entries: [validStepEntry("ext-a")] }));
expect(res.status).toBe(200);
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.json()).toMatchObject({
data: {
processed: 1,
inserted: 0,
updated: 0,
duplicates: 0,
failed: 1,
entries: [{ index: 0, status: "failed", reason: "P2002" }],
},
error: null,
});
expect(enqueuePrDetection).not.toHaveBeenCalled();
});
it("sets silent=true once the batch crosses the 50-entry threshold", async () => {
const big = Array.from({ length: 51 }, (_, i) =>
validStepEntry(`ext-${i}`),
+97 -189
View File
@@ -60,6 +60,7 @@ import {
oppositeMergeSource,
type MergeCandidate,
} from "@/lib/measurements/cross-source-merge";
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
import { validateMeasurementRange } from "@/lib/validations/measurement";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -173,11 +174,10 @@ type BatchEntry = z.infer<typeof batchEntrySchema>;
* we already have, the server overwrites the row's `value` rather than
* dropping the new payload as a duplicate. Sample-class rows (every
* other HK metric) keep the strict `duplicate` semantics because each
* sample is a canonical, immutable reading. `skipped` is the only case
* where the client may want to surface a diagnostic — typically because
* Apple introduced a new identifier the server doesn't know about yet.
* sample is a canonical, immutable reading. `skipped` is a validation no-op;
* `failed` is a hard database verdict and must not be checkpointed as landed.
*/
type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped";
type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped" | "failed";
// v1.5.0 issue #213 — `stats:*` externalIds carry per-day cumulative
// totals that the iOS HealthKit observer re-posts as the day progresses.
@@ -372,47 +372,20 @@ async function postBatch(request: NextRequest): Promise<Response> {
let insertedCount = 0;
let updatedCount = 0;
let duplicateCount = 0;
let failedCount = 0;
// v1.11.4 (iOS #2) — subset of `duplicateCount` collapsed by the
// MANUAL↔APPLE_HEALTH same-reading merge (as opposed to a plain
// composite-key duplicate). Surfaced as a dedicated wide-event count so
// an operator can see how often the standalone-pair mirror collapses.
let crossSourceMergedCount = 0;
// Exact prepared rows returned by INSERT ... RETURNING. This is the only
// source for post-write arrival and sleep side effects: preflight checks can
// lose a unique-key race after they run.
// Only rows the reconciler actually inserted drive arrival side effects.
const insertedPrepared: Prepared[] = [];
const writtenIdentities: Array<{
type: MeasurementType;
measuredAt: Date;
}> = [];
if (prepared.length > 0) {
// Pre-flight duplicate detection classifies rows already present before
// this request. The later INSERT ... RETURNING remains authoritative when
// a competing request wins the unique-key race after this probe.
// v1.8.6 W6 — `source` is now part of the dedup key, so the lookup
// matches on (type, source, externalId) per incoming row rather than
// a single hardcoded `APPLE_HEALTH`. A `MANUAL` row and an
// `APPLE_HEALTH` row sharing an externalId are distinct, so each must
// carry its source through the existence probe and the composite key.
const incomingKeys = prepared.map((p) => ({
type: p.row.type as Prisma.MeasurementCreateManyInput["type"],
source: p.row.source as Prisma.MeasurementCreateManyInput["source"],
externalId: p.row.externalId as string,
}));
const existing = await prisma.measurement.findMany({
where: {
userId: user.id,
OR: incomingKeys.map((k) => ({
type: k.type,
source: k.source,
externalId: k.externalId,
})),
},
select: { type: true, source: true, externalId: true },
});
const existingSet = new Set(
existing.map((row) => `${row.type}::${row.source}::${row.externalId}`),
);
// v1.11.4 (iOS #2) — same-reading merge for the MANUAL ↔ APPLE_HEALTH
// mirror duplicate. When the iOS app logs a manual reading offline it
// mirrors that reading into HealthKit; on pairing, adopt-on-pair
@@ -485,20 +458,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
return false;
}
// Intra-batch `stats:` supersession. Two entries carrying the same
// (type, source, externalId) `stats:` key inside ONE batch both miss
// `existingSet` — neither is in the DB yet — so the overwrite branch
// never runs, both land in `toInsert`, and
// `createMany({ skipDuplicates: true })` keeps whichever row the unique
// index saw first. The OLDER snapshot of a per-day cumulative total (or
// of a still-filling ten-minute heart-rate bucket) therefore won and the
// newer, larger figure was dropped silently: both entries reported
// `inserted`, the counters still summed, and the client checkpointed
// past both. `stats:` rows carry overwrite semantics by contract — a
// re-post replaces value / unit / measuredAt — so within one batch the
// LAST entry for a key is the authoritative one. Resolve it here and
// mark the superseded earlier entries `duplicate`, the same status the
// client already checkpoints past for a composite-key duplicate.
// `stats:` rows carry overwrite semantics and the last entry in one batch
// is authoritative. Earlier snapshots are safe duplicates.
const lastStatsIndexByKey = new Map<string, number>();
for (const p of prepared) {
if (!isStatsExternalId(p.row.externalId as string)) continue;
@@ -508,153 +469,101 @@ async function postBatch(request: NextRequest): Promise<Response> {
);
}
const toInsert: Prepared[] = [];
const insertKeys = new Set<string>();
const toOverwrite: Prepared[] = [];
for (const p of prepared) {
const key = `${p.row.type}::${p.row.source}::${p.row.externalId}`;
if (
isStatsExternalId(p.row.externalId as string) &&
lastStatsIndexByKey.get(key) !== p.index
) {
const externalId = p.row.externalId as string;
const statsRow = isStatsExternalId(externalId);
const key = `${p.row.type}::${p.row.source}::${externalId}`;
if (statsRow && lastStatsIndexByKey.get(key) !== p.index) {
results[p.index] = {
index: p.index,
status: "duplicate",
reason: "superseded_in_batch",
};
duplicateCount += 1;
duplicateCount++;
continue;
}
if (existingSet.has(key) || insertKeys.has(key)) {
// v1.5.0 issue #213 — per-day cumulative `stats:*` rows are
// intentionally overwritten on a re-post so today's tile reflects
// the latest HealthKit total instead of freezing at the
// first-sync value. Sample-class rows keep the immutable
// duplicate contract.
if (isStatsExternalId(p.row.externalId as string)) {
toOverwrite.push(p);
} else {
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
}
} else if (
!isStatsExternalId(p.row.externalId as string) &&
hasSameReadingSibling(p)
) {
// v1.11.4 (iOS #2) — cross-source same-reading collapse. The
// physical reading already exists under the opposite client
// source; drop this mirror copy. Status stays `duplicate` so the
// iOS sync cursor checkpoints past it exactly as it does for a
// composite-key duplicate; the `reason` distinguishes it for ops.
if (!statsRow && hasSameReadingSibling(p)) {
results[p.index] = {
index: p.index,
status: "duplicate",
reason: "cross_source_merge",
};
duplicateCount += 1;
crossSourceMergedCount += 1;
} else {
results[p.index] = { index: p.index, status: "inserted" };
toInsert.push(p);
insertKeys.add(key);
// Track this row so a same-reading sibling later in the SAME
// batch collapses against it.
if (isMergeableSource(p.row.source as string)) {
inBatchCandidates.push({
duplicateCount++;
crossSourceMergedCount++;
continue;
}
const verdict = await prisma.$transaction((tx) =>
reconcileExternalMeasurement(
tx,
{
userId: user.id,
type: p.row.type as MeasurementType,
source: p.row.source as string,
value: p.row.value as number,
valueMin: p.row.valueMin as number | null,
valueMax: p.row.valueMax as number | null,
unit: p.row.unit as string,
source: p.row
.source as Prisma.MeasurementUncheckedCreateInput["source"],
measuredAt: p.row.measuredAt as Date,
externalId,
externalSourceVersion: p.row.externalSourceVersion as string | null,
glucoseContext: p.row
.glucoseContext as Prisma.MeasurementUncheckedCreateInput["glucoseContext"],
sleepStage: p.row
.sleepStage as Prisma.MeasurementUncheckedCreateInput["sleepStage"],
rhythmClassification: p.row
.rhythmClassification as Prisma.MeasurementUncheckedCreateInput["rhythmClassification"],
deviceType: p.row.deviceType as string | null,
},
{ exactExternalMatch: statsRow ? "update" : "duplicate" },
),
);
switch (verdict.status) {
case "inserted":
results[p.index] = { index: p.index, status: "inserted" };
insertedCount++;
insertedPrepared.push(p);
writtenIdentities.push({
type: p.row.type as MeasurementType,
measuredAt: p.row.measuredAt as Date,
});
}
}
}
if (toOverwrite.length > 0) {
await prisma.$transaction(async (tx) => {
for (const p of toOverwrite) {
await tx.measurement.updateMany({
where: {
userId: user.id,
// v1.8.6 W6 — scope the overwrite to the row's own source
// so a `stats:*` re-post only touches the matching
// (type, source, externalId) row.
source: p.row
.source as Prisma.MeasurementCreateManyInput["source"],
type: p.row.type as MeasurementType,
externalId: p.row.externalId as string,
},
data: {
value: p.row.value as number,
// v1.19.2 (iOS #34 extension) — overwrite the per-bucket
// spread alongside `value` so a within-hour re-post (the
// running mean + range shift as more samples land) replaces
// both. Null for every non-HR-bucket `stats:` overwrite.
valueMin: p.row.valueMin as number | null,
valueMax: p.row.valueMax as number | null,
unit: p.row.unit as string,
measuredAt: p.row.measuredAt as Date,
externalSourceVersion: p.row.externalSourceVersion as
string | null,
deviceType: p.row.deviceType as Prepared["row"]["deviceType"],
sleepStage: p.row.sleepStage as Prepared["row"]["sleepStage"],
// No-op on a live row, a deliberate RESURRECTION on a
// tombstoned one — the observer re-posts the day's canonical
// total, so a `stats:` day-total row follows the
// source-owned resurrect rule (mirrors Google / Fitbit).
// SAMPLE-grain rows never reach this branch: their
// tombstones stick (the iOS LWW reconciler propagates
// HealthKit deletions and reports them as `duplicate`).
deletedAt: null,
},
});
break;
case "updated":
case "resurrected":
results[p.index] = { index: p.index, status: "updated" };
updatedCount += 1;
}
});
}
updatedCount++;
writtenIdentities.push(
{
type: p.row.type as MeasurementType,
measuredAt: p.row.measuredAt as Date,
},
...(verdict.dirtyIdentities ?? []),
);
break;
case "duplicate":
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount++;
break;
case "failed":
results[p.index] = {
index: p.index,
status: "failed",
reason: verdict.error.code ?? "write_failed",
};
failedCount++;
continue;
}
if (toInsert.length > 0) {
// PostgreSQL RETURNING identifies the exact winner when another request
// races the same composite unique key. An aggregate insert count can say
// how many rows landed but not which prepared rows won, which would make
// arrival and morning-refresh side effects attach to arbitrary rows.
const CHUNK = 200;
const insertedIndexes = new Set<number>();
await prisma.$transaction(async (tx) => {
for (let i = 0; i < toInsert.length; i += CHUNK) {
const chunk = toInsert.slice(i, i + CHUNK);
const pendingByKey = new Map<string, Prepared>();
for (const p of chunk) {
pendingByKey.set(
`${p.row.type}::${p.row.source}::${p.row.externalId}`,
p,
);
}
const inserted = await tx.measurement.createManyAndReturn({
data: chunk.map((p) => p.row),
skipDuplicates: true,
select: { type: true, source: true, externalId: true },
});
// Count only returned identities that map to an attempted row, so
// aggregate counts and per-entry statuses share one source of truth.
for (const row of inserted) {
const preparedRow = pendingByKey.get(
`${row.type}::${row.source}::${row.externalId}`,
);
if (!preparedRow) continue;
insertedIndexes.add(preparedRow.index);
insertedCount += 1;
insertedPrepared.push(preparedRow);
}
}
});
for (const p of toInsert) {
if (insertedIndexes.has(p.index)) continue;
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
if (isMergeableSource(p.row.source as string)) {
inBatchCandidates.push({
type: p.row.type as MeasurementType,
source: p.row.source as string,
value: p.row.value as number,
measuredAt: p.row.measuredAt as Date,
});
}
}
}
@@ -680,6 +589,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
updated: updatedCount,
duplicates: duplicateCount,
skipped: skipped.length,
failed: failedCount,
},
});
@@ -760,6 +670,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
updated: updatedCount,
duplicates: duplicateCount,
skipped: skipped.length,
failed: failedCount,
},
});
@@ -784,15 +695,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
// by day so a 500-row Apple Health batch fires only one recompute per
// type/day pair. Best-effort — a populator hiccup never fails ingest.
try {
const insertedKeys = collapseToTypeDayKeys(
prepared.map((p) => ({
type: p.row.type as MeasurementType,
measuredAt:
p.row.measuredAt instanceof Date
? p.row.measuredAt
: new Date(p.row.measuredAt as string),
})),
);
const insertedKeys = collapseToTypeDayKeys(writtenIdentities);
for (const k of insertedKeys) {
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
}
@@ -851,12 +754,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
}
return apiSuccess({
const response = apiSuccess({
processed: entries.length,
inserted: insertedCount,
updated: updatedCount,
duplicates: duplicateCount,
failed: failedCount,
skipped,
entries: results,
});
if (failedCount > 0) {
response.headers.set("Cache-Control", "private, no-store");
}
return response;
}
@@ -3,6 +3,15 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import type * as TagLinksModule from "@/lib/mood/tag-links";
const txClient = vi.hoisted(() => ({
moodEntry: {
update: vi.fn(),
},
moodEntryTagLink: {
findMany: vi.fn(),
},
}));
vi.mock("@/lib/db", () => ({
prisma: {
@@ -12,10 +21,20 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(),
delete: vi.fn(),
},
moodEntryTagLink: {
findMany: vi.fn(),
},
auditLog: { create: vi.fn() },
$transaction: vi.fn(),
},
}));
vi.mock("@/lib/mood/tag-links", async (importOriginal) => ({
...(await importOriginal<typeof TagLinksModule>()),
replaceTagLinks: vi.fn(),
replaceRatedFactorLinks: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -42,12 +61,37 @@ vi.mock("next/headers", () => ({
import { PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import {
RatedFactorOutOfRangeError,
replaceRatedFactorLinks,
replaceTagLinks,
} from "@/lib/mood/tag-links";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
const EXISTING_ENTRY = {
id: "me1",
userId: "user-1",
date: "2026-06-01",
mood: "OKAY",
score: 3,
tags: null,
note: null,
noteEncrypted: null,
source: "MANUAL",
moodLoggedAt: new Date("2026-06-01T08:00:00.000Z"),
syncVersion: 7,
};
const UPDATED_ENTRY = {
...EXISTING_ENTRY,
moodLoggedAt: new Date("2026-06-02T08:00:00.000Z"),
syncVersion: 8,
};
function putReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/mood-entries/me1", {
method: "PUT",
@@ -62,16 +106,21 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.$transaction).mockImplementation(async (callback) =>
callback(txClient as never),
);
txClient.moodEntry.update.mockResolvedValue(UPDATED_ENTRY as never);
txClient.moodEntryTagLink.findMany.mockResolvedValue([]);
vi.mocked(replaceTagLinks).mockResolvedValue(undefined);
vi.mocked(replaceRatedFactorLinks).mockResolvedValue(undefined);
// v1.7.0 sync — PUT now looks the row up via `findFirst` with a
// `deletedAt: null` guard (refuses to resurrect-edit a tombstone).
vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue({
id: "me1",
userId: "user-1",
} as never);
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({
id: "me1",
userId: "user-1",
} as never);
vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue(
EXISTING_ENTRY as never,
);
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(
EXISTING_ENTRY as never,
);
});
describe("PUT /api/mood-entries/[id] — 422 multi-issue (v1.4.43 W6)", () => {
@@ -139,3 +188,158 @@ describe("PUT /api/mood-entries/[id] — 422 multi-issue (v1.4.43 W6)", () => {
expect(res.status).toBe(422);
});
});
describe("PUT /api/mood-entries/[id] — split tag replacement", () => {
it("preserves RATED links for a note/time/tagKeys-only edit", async () => {
txClient.moodEntryTagLink.findMany.mockResolvedValue([
{ rating: null, moodTag: { key: "happy", kind: "BINARY" } },
{ rating: 4, moodTag: { key: "factor_work", kind: "RATED" } },
]);
const res = await PUT(
putReq({
note: null,
moodLoggedAt: "2026-06-02T08:00:00.000Z",
tagKeys: ["happy"],
}),
ROUTE_CTX,
);
expect(res.status).toBe(200);
expect(replaceTagLinks).toHaveBeenCalledWith(
"me1",
"user-1",
["happy"],
txClient,
);
expect(replaceRatedFactorLinks).not.toHaveBeenCalled();
const body = (await res.json()) as {
data: {
tagKeys: string[];
ratedFactors: Array<{ key: string; rating: number }>;
};
error: null;
};
expect(body.data.tagKeys).toEqual(["happy"]);
expect(body.data.ratedFactors).toEqual([{ key: "factor_work", rating: 4 }]);
expect(body.error).toBeNull();
});
it("preserves BINARY links and updates scores for a ratedFactors-only edit", async () => {
txClient.moodEntryTagLink.findMany.mockResolvedValue([
{ rating: null, moodTag: { key: "happy", kind: "BINARY" } },
{ rating: 5, moodTag: { key: "factor_work", kind: "RATED" } },
]);
const res = await PUT(
putReq({ ratedFactors: [{ key: "factor_work", rating: 5 }] }),
ROUTE_CTX,
);
expect(res.status).toBe(200);
expect(replaceTagLinks).not.toHaveBeenCalled();
expect(replaceRatedFactorLinks).toHaveBeenCalledWith(
"me1",
"user-1",
[{ key: "factor_work", rating: 5 }],
txClient,
);
const body = (await res.json()) as {
data: {
tagKeys: string[];
ratedFactors: Array<{ key: string; rating: number }>;
};
};
expect(body.data.tagKeys).toEqual(["happy"]);
expect(body.data.ratedFactors).toEqual([{ key: "factor_work", rating: 5 }]);
});
it("returns 422 when a factor score is outside its catalog scale", async () => {
vi.mocked(replaceRatedFactorLinks).mockRejectedValueOnce(
new RatedFactorOutOfRangeError("factor_conflict", 5, 1, 2),
);
const res = await PUT(
putReq({ ratedFactors: [{ key: "factor_conflict", rating: 5 }] }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
meta: { errorCode: string };
};
expect(body.data).toBeNull();
expect(body.meta.errorCode).toBe("mood.ratedFactor.out_of_range");
});
it("rolls back the mood row, both link sets, and syncVersion when the second replacement fails", async () => {
let persisted = {
mood: "OKAY",
syncVersion: 7,
binary: ["happy"],
rated: [{ key: "factor_work", rating: 2 }],
};
let draft = structuredClone(persisted);
const transactionClient = {
moodEntry: {
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
if (typeof data.mood === "string") draft.mood = data.mood;
if (data.syncVersion) draft.syncVersion += 1;
return UPDATED_ENTRY;
}),
},
moodEntryTagLink: {
findMany: vi.fn(),
},
};
vi.mocked(replaceTagLinks).mockImplementationOnce(async () => {
draft.binary = ["calm"];
});
vi.mocked(replaceRatedFactorLinks).mockImplementationOnce(async () => {
draft.rated = [{ key: "factor_work", rating: 5 }];
throw new Error("injected rated-link replacement failure");
});
vi.mocked(prisma.$transaction).mockImplementationOnce(async (callback) => {
draft = structuredClone(persisted);
const result = await callback(transactionClient as never);
persisted = draft;
return result;
});
const res = await PUT(
putReq({
mood: "GUT",
tagKeys: ["calm"],
ratedFactors: [{ key: "factor_work", rating: 5 }],
}),
ROUTE_CTX,
);
expect(replaceTagLinks).toHaveBeenCalledTimes(1);
expect(replaceRatedFactorLinks).toHaveBeenCalledTimes(1);
expect(res.status).toBe(500);
expect(persisted).toEqual({
mood: "OKAY",
syncVersion: 7,
binary: ["happy"],
rated: [{ key: "factor_work", rating: 2 }],
});
});
it.each([{ ratedFactors: null }, { ratedFactors: [] }])(
"clears RATED links for an explicit $ratedFactors",
async (payload) => {
const res = await PUT(putReq(payload), ROUTE_CTX);
expect(res.status).toBe(200);
expect(replaceRatedFactorLinks).toHaveBeenCalledWith(
"me1",
"user-1",
[],
txClient,
);
},
);
});
+58 -16
View File
@@ -19,7 +19,11 @@ import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { replaceTagLinks } from "@/lib/mood/tag-links";
import {
RatedFactorOutOfRangeError,
replaceRatedFactorLinks,
replaceTagLinks,
} from "@/lib/mood/tag-links";
type RouteParams = { params: Promise<{ id: string }> };
@@ -140,38 +144,75 @@ export const PUT = apiHandler(
// monotonic value and paired clients reconcile higher-wins.
updateData.syncVersion = { increment: 1 };
// v1.8.5 — update the entry and replace its structured-tag links in
// one transaction. A tag-link failure must roll the entry update back
// too — otherwise a client retry on the 5xx double-applies the edit
// (and the `syncVersion` increment). The tx client is threaded through
// the helper so both writes commit (or abort) together.
const { entry, persistedTagKeys } = await prisma.$transaction(
async (tx) => {
// Update the row and replace each submitted half of the structured-link
// contract in one transaction. A failure in either replacement rolls
// back the mood edit and its syncVersion increment as one unit.
const persistUpdate = () =>
prisma.$transaction(async (tx) => {
const updated = await tx.moodEntry.update({
where: { id },
data: updateData,
});
// Full replacement of the structured-tag link set when `tagKeys`
// is present in the body. `null` clears every link; an omitted
// field leaves the links untouched.
// Omission preserves the corresponding link set. Explicit null/empty
// clears it, matching the validated update contract.
if (data.tagKeys !== undefined) {
await replaceTagLinks(id, user.id, data.tagKeys ?? [], tx);
}
if (data.ratedFactors !== undefined) {
await replaceRatedFactorLinks(
id,
user.id,
data.ratedFactors ?? [],
tx,
);
}
// v1.8.5 — read the persisted link keys back so the update
// response mirrors the list GET shape exactly.
// Return the same split shape as list/create: binary keys are
// independent from rated-factor keys and their per-entry scores.
const links = await tx.moodEntryTagLink.findMany({
where: { moodEntryId: id },
select: { moodTag: { select: { key: true } } },
select: {
rating: true,
moodTag: { select: { key: true, kind: true } },
},
});
return {
entry: updated,
persistedTagKeys: links.map((link) => link.moodTag.key),
persistedTagKeys: links
.filter((link) => link.moodTag.kind !== "RATED")
.map((link) => link.moodTag.key),
persistedRatedFactors: links
.filter(
(link) => link.moodTag.kind === "RATED" && link.rating !== null,
)
.map((link) => ({
key: link.moodTag.key,
rating: link.rating as number,
})),
};
},
});
const transactionOutcome = await persistUpdate().then(
(result) => ({ ok: true as const, result }),
(error: unknown) => ({ ok: false as const, error }),
);
if (!transactionOutcome.ok) {
const { error } = transactionOutcome;
if (error instanceof RatedFactorOutOfRangeError) {
annotate({
action: { name: "mood-entries.update.rated-factor-out-of-range" },
meta: { scaleMin: error.scaleMin, scaleMax: error.scaleMax },
});
return apiError(error.message, 422, {
errorCode: "mood.ratedFactor.out_of_range",
});
}
throw error;
}
const { entry, persistedTagKeys, persistedRatedFactors } =
transactionOutcome.result;
await auditLog("moodEntry.update", {
userId: user.id,
@@ -219,6 +260,7 @@ export const PUT = apiHandler(
// hydrating from the update response renders the tag set without a
// refetch (shape-matches the list GET).
tagKeys: persistedTagKeys,
ratedFactors: persistedRatedFactors,
});
},
);
@@ -48,6 +48,7 @@ import { prisma } from "@/lib/db";
import { syncUserMeasurements } from "@/lib/withings/sync";
import { checkRateLimit } from "@/lib/rate-limit";
import { getEvent } from "@/lib/logging/context";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
const ORIGINAL_SECRET = process.env.WITHINGS_WEBHOOK_SECRET;
@@ -64,6 +65,9 @@ beforeEach(() => {
addWarning: vi.fn(),
} as never);
vi.mocked(syncUserMeasurements).mockResolvedValue(undefined as never);
vi.mocked(getGlobalBoss).mockReturnValue({
send: vi.fn().mockResolvedValue("ecg-job"),
} as never);
});
afterEach(() => {
@@ -69,9 +69,9 @@ beforeEach(() => {
addWarning: vi.fn(),
} as never);
vi.mocked(syncUserMeasurements).mockResolvedValue(undefined as never);
// Default: pg-boss not available → activity / sleep paths fall back
// to inline sync. Individual tests override with a fake boss.
vi.mocked(getGlobalBoss).mockReturnValue(null);
vi.mocked(getGlobalBoss).mockReturnValue({
send: vi.fn().mockResolvedValue("ecg-job"),
} as never);
vi.mocked(syncUserActivity).mockResolvedValue(0 as never);
vi.mocked(syncUserSleep).mockResolvedValue(0 as never);
});
@@ -301,7 +301,7 @@ describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => {
userId: "user-act-inline",
withingsUserId: "wu-act-inline",
} as never);
// Default beforeEach already sets `getGlobalBoss → null`.
vi.mocked(getGlobalBoss).mockReturnValue(null);
const res = await POST(
formRequest(
@@ -319,6 +319,7 @@ describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => {
userId: "user-sleep-inline",
withingsUserId: "wu-sleep-inline",
} as never);
vi.mocked(getGlobalBoss).mockReturnValue(null);
const res = await POST(
formRequest(
@@ -364,6 +365,78 @@ describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => {
expect(res.status).toBe(200);
expect(syncUserMeasurements).toHaveBeenCalledWith("user-legacy");
});
it("returns retryable non-2xx when the ECG event cannot be durably enqueued", async () => {
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValue({
userId: "user-ecg",
withingsUserId: "wu-ecg",
} as never);
const send = vi.fn().mockRejectedValue(new Error("queue unavailable"));
vi.mocked(getGlobalBoss).mockReturnValue({ send } as never);
const res = await POST(
formRequest(
{
userid: "wu-ecg",
appli: "1",
startdate: "1715000000",
enddate: "1715000060",
},
{ "x-withings-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(503);
expect(syncUserMeasurements).not.toHaveBeenCalled();
});
it("uses one logical ECG job identity for a provider replay", async () => {
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValue({
userId: "user-ecg",
withingsUserId: "wu-ecg",
} as never);
const send = vi
.fn()
.mockResolvedValueOnce("job-1")
.mockResolvedValueOnce(null);
vi.mocked(getGlobalBoss).mockReturnValue({ send } as never);
const delivery = {
userid: "wu-ecg",
appli: "1",
startdate: "1715000000",
enddate: "1715000060",
};
const first = await POST(
formRequest(delivery, {
"x-withings-webhook-secret": "test-secret",
}),
);
const replay = await POST(
formRequest(delivery, {
"x-withings-webhook-secret": "test-secret",
}),
);
expect(first.status).toBe(200);
expect(replay.status).toBe(200);
expect(send).toHaveBeenCalledTimes(2);
expect(send.mock.calls[0][0]).toBe("withings-ecg-sync");
expect(send.mock.calls[1][0]).toBe("withings-ecg-sync");
expect(send.mock.calls[0][1]).toMatchObject({
userId: "user-ecg",
startdate: 1715000000,
enddate: 1715000060,
});
expect(send.mock.calls[0][2]).toMatchObject({
singletonKey: expect.any(String),
retryLimit: 3,
retryDelay: 60,
retryBackoff: true,
});
expect(send.mock.calls[0][2].singletonKey).toBe(
send.mock.calls[1][2].singletonKey,
);
});
});
describe("HEAD /api/withings/webhook (Withings URL verification)", () => {
@@ -1,5 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import de from "../../../../messages/de.json";
import en from "../../../../messages/en.json";
/**
* v1.4.15 phase-C5 — `/admin/backups` empty state.
@@ -76,4 +78,13 @@ describe("BackupsSection — empty state", () => {
const matches = (html.match(/Backup now/g) ?? []).length;
expect(matches).toBeGreaterThanOrEqual(2);
});
it("warns in both catalogs that restore overwrites instance-wide settings", () => {
expect(de.admin.section.backups.restoreDescription).toMatch(
/instanzweite Einstellungen/i,
);
expect(en.admin.section.backups.restoreDescription).toMatch(
/instance-wide settings/i,
);
});
});
@@ -139,6 +139,20 @@ describe("MoodForm — rated factors (v1.12.0)", () => {
});
});
describe("MoodList — rated factor editing", () => {
it("hydrates, renders, and submits the entry's current ratedFactors", () => {
expect(listSrc).toContain("ratedFactors: RatedFactor[];");
expect(listSrc).toContain(
"const [editRatedFactors, setEditRatedFactors] = useState<",
);
expect(listSrc).toContain("setEditRatedFactors(entry.ratedFactors ?? [])");
expect(listSrc).toMatch(
/<MoodTagPicker[\s\S]*ratedFactors=\{editRatedFactors\}[\s\S]*onRateFactor=\{rateEditFactor\}/,
);
expect(listSrc).toContain("ratedFactors: editRatedFactors,");
});
});
describe("MoodList — delete failure toast (v1.11.5)", () => {
it("surfaces a toast on a rejected delete instead of failing silently", () => {
expect(listSrc).toContain('import { toast } from "sonner"');
+17 -1
View File
@@ -81,7 +81,7 @@ import {
selectedCountOnPage,
} from "@/components/data-list";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { MoodTagPicker } from "./mood-tag-picker";
import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
// Re-export the score map under the legacy local name to keep the
@@ -97,6 +97,7 @@ interface MoodEntry {
tags: string[];
// v1.8.5 — structured-tag keys + free-text note.
tagKeys: string[];
ratedFactors: RatedFactor[];
note: string | null;
source: string;
moodLoggedAt: string;
@@ -174,6 +175,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
});
const [editTagsInput, setEditTagsInput] = useState("");
const [editTagKeys, setEditTagKeys] = useState<string[]>([]);
const [editRatedFactors, setEditRatedFactors] = useState<RatedFactor[]>([]);
const [editNote, setEditNote] = useState("");
const [editMoodLoggedAt, setEditMoodLoggedAt] = useState("");
const [editError, setEditError] = useState<string | null>(null);
@@ -183,6 +185,13 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);
}
function rateEditFactor(key: string, rating: number | null) {
setEditRatedFactors((prev) => {
const rest = prev.filter((factor) => factor.key !== key);
return rating === null ? rest : [...rest, { key, rating }];
});
}
const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false);
// v1.4.27 R4 RC2 — Sheet-branch sticky-pinned footer slot.
const editFormId = useId();
@@ -357,6 +366,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
mood,
tags,
tagKeys,
ratedFactors,
note,
moodLoggedAt,
}: {
@@ -364,6 +374,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
mood: string;
tags: string[] | null;
tagKeys: string[];
ratedFactors: RatedFactor[];
note: string | null;
moodLoggedAt: string;
}) => {
@@ -371,6 +382,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
mood,
tags,
tagKeys,
ratedFactors,
note,
moodLoggedAt,
});
@@ -393,6 +405,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
setEditMood(entry.mood);
setEditTagsInput(entry.tags.join(", "));
setEditTagKeys(entry.tagKeys ?? []);
setEditRatedFactors(entry.ratedFactors ?? []);
setEditNote(entry.note ?? "");
setEditMoodLoggedAt(toDateTimeLocalValue(entry.moodLoggedAt));
setEditError(null);
@@ -440,6 +453,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
mood: editMood,
tags: tags.length > 0 ? tags : null,
tagKeys: editTagKeys,
ratedFactors: editRatedFactors,
note: trimmedNote.length > 0 ? trimmedNote : null,
moodLoggedAt: measuredDate.toISOString(),
});
@@ -909,6 +923,8 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
<MoodTagPicker
selected={editTagKeys}
onToggle={toggleEditTagKey}
ratedFactors={editRatedFactors}
onRateFactor={rateEditFactor}
/>
</div>
+18
View File
@@ -145,6 +145,24 @@ describe("withIdempotency", () => {
expect(handler).toHaveBeenCalledTimes(1); // not called again
});
it("does not cache a successful response marked no-store", async () => {
const handler = vi.fn(async () =>
NextResponse.json(
{ data: { failed: 1 }, error: null },
{ headers: { "Cache-Control": "private, no-store" } },
),
);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
const response = await wrapped(
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
);
expect(response.status).toBe(200);
expect(prisma.idempotencyKey.updateMany).not.toHaveBeenCalled();
expect(prisma.idempotencyKey.deleteMany).toHaveBeenCalled();
});
it("ignores expired cache rows and re-runs the handler", async () => {
const handler = vi.fn(async () =>
NextResponse.json({ data: "fresh", error: null }, { status: 200 }),
+153
View File
@@ -0,0 +1,153 @@
import { describe, expect, it, vi } from "vitest";
import { buildCycleBackupSection } from "../backup";
const deletedAt = new Date("2026-07-19T12:00:00.000Z");
const createdAt = new Date("2026-07-01T08:00:00.000Z");
const updatedAt = new Date("2026-07-19T11:00:00.000Z");
describe("buildCycleBackupSection disaster-recovery mode", () => {
it("preserves stable ids, reconciliation fields, and tombstones", async () => {
const prisma = {
cycleProfile: {
findUnique: vi.fn().mockResolvedValue({
id: "profile-dr",
goal: "GENERAL_HEALTH",
cycleTrackingEnabled: true,
typicalCycleLength: 28,
typicalPeriodLength: 5,
lutealPhaseLength: 14,
secondarySymptom: "MUCUS",
predictionEnabled: true,
rawChartMode: false,
discreetNotifications: true,
sensitiveCategoryEncryption: true,
createdAt,
updatedAt,
}),
},
menstrualCycle: {
findMany: vi.fn().mockResolvedValue([
{
id: "cycle-dr",
startDate: "2026-07-01",
endDate: "2026-07-28",
periodEndDate: "2026-07-05",
lengthDays: 28,
ovulationDate: "2026-07-14",
ovulationConfirmed: true,
isPredicted: false,
tz: "Europe/London",
syncVersion: 5,
deletedAt,
createdAt,
updatedAt,
},
]),
},
cycleDayLog: {
findMany: vi.fn().mockResolvedValue([
{
id: "cycle-day-dr",
date: "2026-07-02",
cycleId: "cycle-dr",
flow: "HEAVY",
intermenstrualBleeding: false,
basalBodyTempC: 36.7,
temperatureExcluded: true,
ovulationTest: "NEGATIVE",
cervicalMucus: "CREAMY",
cervixPosition: "LOW",
cervixFirmness: "FIRM",
cervixOpening: "CLOSED",
sexualActivity: false,
protectedSex: null,
pregnancyTest: null,
progesteroneTest: null,
contraceptive: null,
sensitiveEncrypted: "sensitive-ciphertext",
notesEncrypted: "notes-ciphertext",
source: "APPLE_HEALTH",
externalId: "cycle-day-external",
tz: "Europe/London",
syncVersion: 9,
deletedAt,
createdAt,
updatedAt,
symptomLinks: [{ symptom: { key: "cramps" } }],
},
]),
},
};
const section = await buildCycleBackupSection(prisma as never, "user-1", {
purpose: "disaster-recovery",
});
expect(prisma.menstrualCycle.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: "user-1" } }),
);
expect(prisma.cycleDayLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: "user-1" } }),
);
expect(section.cycleProfile).toEqual({
id: "profile-dr",
goal: "GENERAL_HEALTH",
cycleTrackingEnabled: true,
typicalCycleLength: 28,
typicalPeriodLength: 5,
lutealPhaseLength: 14,
secondarySymptom: "MUCUS",
predictionEnabled: true,
rawChartMode: false,
discreetNotifications: true,
sensitiveCategoryEncryption: true,
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
});
expect(section.cycles[0]).toEqual({
id: "cycle-dr",
startDate: "2026-07-01",
endDate: "2026-07-28",
periodEndDate: "2026-07-05",
lengthDays: 28,
ovulationDate: "2026-07-14",
ovulationConfirmed: true,
isPredicted: false,
tz: "Europe/London",
syncVersion: 5,
deletedAt: deletedAt.toISOString(),
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
});
expect(section.cycleDayLogs[0]).toEqual({
id: "cycle-day-dr",
date: "2026-07-02",
cycleId: "cycle-dr",
flow: "HEAVY",
intermenstrualBleeding: false,
basalBodyTempC: 36.7,
temperatureExcluded: true,
ovulationTest: "NEGATIVE",
cervicalMucus: "CREAMY",
cervixPosition: "LOW",
cervixFirmness: "FIRM",
cervixOpening: "CLOSED",
sexualActivity: false,
protectedSex: null,
pregnancyTest: null,
progesteroneTest: null,
contraceptive: null,
sensitiveEncrypted: "sensitive-ciphertext",
notesEncrypted: "notes-ciphertext",
source: "APPLE_HEALTH",
externalId: "cycle-day-external",
tz: "Europe/London",
syncVersion: 9,
deletedAt: deletedAt.toISOString(),
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
symptomKeys: ["cramps"],
});
});
});
+79 -5
View File
@@ -28,8 +28,13 @@ import type {
import type { BackupPayload } from "@/lib/validations/backup";
/** The cycle slice of a backup payload. */
export interface CycleBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
}
export interface CycleBackupSection {
cycleProfile: {
id?: string;
goal: string;
cycleTrackingEnabled: boolean | null;
typicalCycleLength: number | null;
@@ -40,8 +45,11 @@ export interface CycleBackupSection {
rawChartMode: boolean;
discreetNotifications: boolean;
sensitiveCategoryEncryption: boolean;
createdAt?: string;
updatedAt?: string;
} | null;
cycles: Array<{
id?: string;
startDate: string;
endDate: string | null;
periodEndDate: string | null;
@@ -49,8 +57,15 @@ export interface CycleBackupSection {
ovulationDate: string | null;
ovulationConfirmed: boolean;
tz: string | null;
isPredicted?: boolean;
syncVersion?: number;
deletedAt?: string | null;
createdAt?: string;
updatedAt?: string;
}>;
cycleDayLogs: Array<{
id?: string;
cycleId?: string | null;
date: string;
flow: string | null;
intermenstrualBleeding: boolean;
@@ -71,6 +86,10 @@ export interface CycleBackupSection {
source: string;
externalId: string | null;
tz: string | null;
syncVersion?: number;
deletedAt?: string | null;
createdAt?: string;
updatedAt?: string;
symptomKeys: string[];
}>;
}
@@ -83,15 +102,19 @@ export interface CycleBackupSection {
export async function buildCycleBackupSection(
prisma: Pick<PrismaClient, "cycleProfile" | "menstrualCycle" | "cycleDayLog">,
userId: string,
options: CycleBackupOptions = {},
): Promise<CycleBackupSection> {
const disasterRecovery = options.purpose === "disaster-recovery";
const [profile, cycles, dayLogs] = await Promise.all([
prisma.cycleProfile.findUnique({ where: { userId } }),
prisma.menstrualCycle.findMany({
where: { userId, deletedAt: null, isPredicted: false },
where: disasterRecovery
? { userId }
: { userId, deletedAt: null, isPredicted: false },
orderBy: { startDate: "asc" },
}),
prisma.cycleDayLog.findMany({
where: { userId, deletedAt: null },
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { date: "asc" },
include: {
symptomLinks: { include: { symptom: { select: { key: true } } } },
@@ -102,6 +125,13 @@ export async function buildCycleBackupSection(
return {
cycleProfile: profile
? {
...(disasterRecovery
? {
id: profile.id,
createdAt: profile.createdAt.toISOString(),
updatedAt: profile.updatedAt.toISOString(),
}
: {}),
goal: profile.goal,
cycleTrackingEnabled: profile.cycleTrackingEnabled,
typicalCycleLength: profile.typicalCycleLength,
@@ -115,6 +145,16 @@ export async function buildCycleBackupSection(
}
: null,
cycles: cycles.map((c) => ({
...(disasterRecovery
? {
id: c.id,
isPredicted: c.isPredicted,
syncVersion: c.syncVersion,
deletedAt: c.deletedAt?.toISOString() ?? null,
createdAt: c.createdAt.toISOString(),
updatedAt: c.updatedAt.toISOString(),
}
: {}),
startDate: c.startDate,
endDate: c.endDate,
periodEndDate: c.periodEndDate,
@@ -124,6 +164,16 @@ export async function buildCycleBackupSection(
tz: c.tz,
})),
cycleDayLogs: dayLogs.map((d) => ({
...(disasterRecovery
? {
id: d.id,
cycleId: d.cycleId,
syncVersion: d.syncVersion,
deletedAt: d.deletedAt?.toISOString() ?? null,
createdAt: d.createdAt.toISOString(),
updatedAt: d.updatedAt.toISOString(),
}
: {}),
date: d.date,
flow: d.flow,
intermenstrualBleeding: d.intermenstrualBleeding,
@@ -139,7 +189,6 @@ export async function buildCycleBackupSection(
pregnancyTest: d.pregnancyTest,
progesteroneTest: d.progesteroneTest,
contraceptive: d.contraceptive,
// Both ciphertext envelopes carried verbatim — never decrypted here.
sensitiveEncrypted: d.sensitiveEncrypted,
notesEncrypted: d.notesEncrypted,
source: d.source,
@@ -258,6 +307,7 @@ export async function restoreCycleData(
const p = payload.cycleProfile;
await tx.cycleProfile.create({
data: {
...(p.id ? { id: p.id } : {}),
userId: ownerId,
goal: enumOrNull(p.goal, CYCLE_GOALS) ?? "GENERAL_HEALTH",
cycleTrackingEnabled: p.cycleTrackingEnabled ?? null,
@@ -270,6 +320,8 @@ export async function restoreCycleData(
rawChartMode: p.rawChartMode ?? false,
discreetNotifications: p.discreetNotifications ?? false,
sensitiveCategoryEncryption: p.sensitiveCategoryEncryption ?? true,
...(p.createdAt ? { createdAt: new Date(p.createdAt) } : {}),
...(p.updatedAt ? { updatedAt: new Date(p.updatedAt) } : {}),
},
});
}
@@ -277,9 +329,11 @@ export async function restoreCycleData(
// Recreate observed cycle spans; map startDate → id so day-logs can
// re-attach to their owning span.
const cycleIdByStart = new Map<string, string>();
const restoredCycleIds = new Set<string>();
for (const c of payload.cycles) {
const created = await tx.menstrualCycle.create({
data: {
...(c.id ? { id: c.id } : {}),
userId: ownerId,
startDate: c.startDate,
endDate: c.endDate ?? null,
@@ -288,10 +342,15 @@ export async function restoreCycleData(
ovulationDate: c.ovulationDate ?? null,
ovulationConfirmed: c.ovulationConfirmed ?? false,
tz: c.tz ?? null,
isPredicted: false,
isPredicted: c.isPredicted ?? false,
syncVersion: c.syncVersion ?? 0,
deletedAt: c.deletedAt ? new Date(c.deletedAt) : null,
...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}),
...(c.updatedAt ? { updatedAt: new Date(c.updatedAt) } : {}),
},
});
cycleIdByStart.set(c.startDate, created.id);
restoredCycleIds.add(created.id);
}
// Resolve the seeded symptom catalogue once for the link re-creation.
@@ -328,11 +387,22 @@ export async function restoreCycleData(
const symptomIds = (d.symptomKeys ?? [])
.map((k) => symptomIdByKey.get(k))
.filter((v): v is string => v !== undefined);
const cycleId =
d.cycleId !== undefined
? d.cycleId === null
? null
: restoredCycleIds.has(d.cycleId)
? d.cycleId
: (() => {
throw new Error(`Unknown cycle reference: ${d.cycleId}`);
})()
: owningCycleId(d.date);
await tx.cycleDayLog.create({
data: {
...(d.id ? { id: d.id } : {}),
userId: ownerId,
date: d.date,
cycleId: owningCycleId(d.date),
cycleId,
flow: enumOrNull(d.flow, FLOW_LEVELS),
intermenstrualBleeding: d.intermenstrualBleeding ?? false,
basalBodyTempC: d.basalBodyTempC ?? null,
@@ -353,6 +423,10 @@ export async function restoreCycleData(
source: enumOrNull(d.source, CYCLE_SOURCES) ?? "MANUAL",
externalId: d.externalId ?? null,
tz: d.tz ?? null,
syncVersion: d.syncVersion ?? 0,
deletedAt: d.deletedAt ? new Date(d.deletedAt) : null,
...(d.createdAt ? { createdAt: new Date(d.createdAt) } : {}),
...(d.updatedAt ? { updatedAt: new Date(d.updatedAt) } : {}),
...(symptomIds.length > 0
? {
symptomLinks: {
@@ -0,0 +1,53 @@
import { Buffer } from "node:buffer";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { destroyLoadingTask } = vi.hoisted(() => ({
destroyLoadingTask: vi.fn(async () => undefined),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("../native-canvas-support", () => ({
nativeCanvasSupported: () => true,
}));
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({
getDocument: () => ({
destroy: destroyLoadingTask,
promise: Promise.resolve({
numPages: 1,
canvasFactory: {
create: () => ({
canvas: {
toBuffer: () => Buffer.from([0xff, 0xd8]),
},
context: {},
}),
},
getPage: async () => ({
getViewport: ({ scale }: { scale: number }) => ({
width: 100 * scale,
height: 100 * scale,
}),
render: () => ({ promise: Promise.resolve() }),
cleanup: vi.fn(),
}),
}),
}),
}));
import { rasterizePdf } from "../rasterize-pdf";
describe("rasterizePdf PDF.js lifecycle", () => {
beforeEach(() => {
destroyLoadingTask.mockClear();
});
it("destroys the loading task after rendering", async () => {
await expect(rasterizePdf(Buffer.from("synthetic"))).resolves.toMatchObject(
{
ok: true,
},
);
expect(destroyLoadingTask).toHaveBeenCalledOnce();
});
});
+8 -4
View File
@@ -102,6 +102,9 @@ interface PdfDocument {
numPages: number;
canvasFactory: PdfCanvasFactory;
getPage(pageNumber: number): Promise<PdfPage>;
}
interface PdfLoadingTask {
promise: Promise<PdfDocument>;
destroy(): Promise<void>;
}
interface PdfjsModule {
@@ -110,7 +113,7 @@ interface PdfjsModule {
verbosity?: number;
isEvalSupported?: boolean;
useSystemFonts?: boolean;
}): { promise: Promise<PdfDocument> };
}): PdfLoadingTask;
}
/**
@@ -125,6 +128,7 @@ export async function rasterizePdf(
maxPages: number = RASTER_MAX_PAGES,
): Promise<RasterResult> {
let doc: PdfDocument | null = null;
let task: PdfLoadingTask | null = null;
// CPU-feature gate: pdfjs renders through @napi-rs/canvas (Skia), whose x64
// build uses AVX2 — an unsupported CPU dies with an uncatchable SIGILL on
// the first render. Degrade to the existing no-raster path instead (scanned
@@ -142,7 +146,7 @@ export async function rasterizePdf(
const pdfjs =
(await import("pdfjs-dist/legacy/build/pdf.mjs")) as unknown as PdfjsModule;
const task = pdfjs.getDocument({
task = pdfjs.getDocument({
data: new Uint8Array(buffer),
verbosity: 0, // VerbosityLevel.ERRORS — no console spam on odd PDFs.
isEvalSupported: false, // never eval font programs (defence in depth).
@@ -205,9 +209,9 @@ export async function rasterizePdf(
});
return { ok: false };
} finally {
if (doc) {
if (task) {
try {
await doc.destroy();
await task.destroy();
} catch {
// Best-effort teardown; a destroy failure must not mask the result.
}
@@ -0,0 +1,326 @@
import { describe, expect, it, vi } from "vitest";
import { Buffer } from "node:buffer";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
process.env.ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const mocks = vi.hoisted(() => ({
findMeasurementsPaged: vi.fn(),
buildCycleBackupSection: vi.fn(),
buildRecordsBackupSection: vi.fn(),
}));
vi.mock("@/lib/export/paged-measurements", () => ({
findMeasurementsPaged: mocks.findMeasurementsPaged,
}));
vi.mock("@/lib/cycle/backup", () => ({
buildCycleBackupSection: mocks.buildCycleBackupSection,
}));
vi.mock("@/lib/export/records-backup", () => ({
buildRecordsBackupSection: mocks.buildRecordsBackupSection,
countRecordsBackupSection: vi.fn(() => ({
labResults: 0,
biomarkers: 0,
illnessEpisodes: 0,
illnessDayLogs: 0,
allergies: 0,
familyHistory: 0,
workouts: 0,
documents: 0,
})),
}));
import { buildFullBackupPayload } from "../full-backup-payload";
const deletedAt = new Date("2026-07-19T12:00:00.000Z");
const measurementNote = encryptToBytes("canonical measurement note");
const appSettings = {
id: "singleton",
registrationEnabled: false,
mfaRequired: true,
defaultLocale: "en",
telegramGlobal: false,
ntfyGlobal: false,
webPushGlobal: false,
webPushVapidPublicKey: "public-key",
webPushVapidPrivateKeyEncrypted: "encrypted-private-key",
webPushVapidSubject: "mailto:ops@example.test",
apiGlobal: false,
moodLogGlobal: false,
umamiEnabled: true,
umamiScriptUrl: "https://analytics.example.test/script.js",
umamiWebsiteId: "website-id",
glitchtipEnabled: true,
glitchtipDsn: "https://dsn.example.test/1",
glitchtipEnvironment: "recovery",
reminderLateMinutes: 45,
reminderMissedMinutes: 90,
adminAiKeyEncrypted: "encrypted-ai-key",
adminAiModel: "gpt-test",
adminAiBaseUrl: "https://ai.example.test/v1",
adminCodexAccessTokenEncrypted: "encrypted-access",
adminCodexRefreshTokenEncrypted: "encrypted-refresh",
adminCodexAccountIdEncrypted: "encrypted-account",
adminCodexTokenExpiresAt: new Date("2026-07-21T08:00:00.000Z"),
adminCodexConnectedAt: new Date("2026-07-20T08:00:00.000Z"),
adminCodexConnectionStatus: "connected",
adminAiInsightsFeedbackSummary: { helpful: 7, total: 9 },
defaultUserTimezone: "Europe/London",
assistantEnabled: false,
assistantCoachEnabled: false,
assistantBriefingEnabled: false,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: false,
assistantHealthScoreExplainerEnabled: false,
moduleAvailabilityJson: { nutrients: false, insights: true },
documentMaxFileBytes: 12_345_678,
documentQuotaBytes: BigInt("9876543210"),
};
function makePrisma() {
return {
appSettings: { findUnique: vi.fn().mockResolvedValue(appSettings) },
medication: {
findMany: vi.fn().mockResolvedValue([
{
id: "medication-canonical",
name: "Example",
dose: "2 mg",
treatmentClass: "GENERIC",
dosesPerUnit: 4,
unitsPerDose: { toString: () => "0.5000" },
active: false,
notificationsEnabled: false,
pausedAt: new Date("2026-07-18T10:00:00.000Z"),
snoozedUntil: new Date("2026-07-22T10:00:00.000Z"),
startsOn: new Date("2026-07-01T00:00:00.000Z"),
endsOn: new Date("2026-08-01T00:00:00.000Z"),
oneShot: false,
asNeeded: false,
deliveryForm: "INJECTION",
trackInjectionSites: true,
allowedInjectionSites: ["THIGH_LEFT", "THIGH_RIGHT"],
liveActivityEnabled: true,
criticalAlarmEnabled: true,
atcCode: "A10BX10",
rxNormCode: "12345",
lowStockNotifiedAt: new Date("2026-07-17T10:00:00.000Z"),
lowStockNotifiedThresholdDays: 7,
reorderLeadDays: 3,
externalSource: "APPLE_HEALTH",
externalId: "med-external",
createdAt: new Date("2026-06-01T10:00:00.000Z"),
updatedAt: new Date("2026-07-19T10:00:00.000Z"),
schedules: [
{
id: "schedule-canonical",
windowStart: "08:00",
windowEnd: "10:00",
label: "Morning",
dose: "1 mg",
daysOfWeek: "i2;1,3,5",
timesOfDay: ["08:00", "20:00"],
reminderGraceMinutes: 90,
rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR",
rollingIntervalDays: null,
scheduleType: "CYCLIC",
cyclicOnWeeks: 3,
cyclicOffWeeks: 1,
doseWindows: [
{ timeOfDay: "08:00", start: "07:30", end: "09:00" },
],
},
],
},
]),
},
medicationIntakeEvent: {
findMany: vi.fn().mockResolvedValue([
{
id: "intake-canonical",
medicationId: "medication-canonical",
medication: { name: "Example" },
scheduledFor: new Date("2026-07-19T08:00:00.000Z"),
takenAt: new Date("2026-07-19T08:05:00.000Z"),
skipped: false,
autoMissed: false,
attributionSource: "USER_PIN",
source: "APPLE_HEALTH",
idempotencyKey: "intake-idempotency",
createdAt: new Date("2026-07-19T08:05:00.000Z"),
injectionSite: "THIGH_LEFT",
doseTaken: "1 mg",
inventoryConsumption: [{ itemId: "pen-1", units: 0.5 }],
externalId: "intake-external",
updatedAt: new Date("2026-07-19T08:06:00.000Z"),
syncVersion: 4,
deletedAt,
},
]),
},
moodEntry: {
findMany: vi.fn().mockResolvedValue([
{
id: "mood-tombstone",
date: "2026-07-19",
mood: "OKAY",
score: 3,
tags: null,
source: "MOODLOG",
externalId: "moodlog-42",
moodLoggedAt: new Date("2026-07-19T08:00:00.000Z"),
deletedAt,
tagLinks: [{ rating: 4, moodTag: { key: "sleep_quality" } }],
},
]),
},
};
}
function installSectionMocks() {
void measurementNote;
mocks.findMeasurementsPaged.mockResolvedValue([
{
id: "measurement-tombstone",
type: "SLEEP_DURATION",
value: 75,
valueMin: 60,
valueMax: 90,
unit: "minutes",
measuredAt: new Date("2026-07-19T07:00:00.000Z"),
source: "IMPORT",
notes: null,
notesEncrypted: measurementNote,
externalId: "oura-stage-deep",
externalSourceVersion: "oura-v3",
glucoseContext: null,
sleepStage: "DEEP",
rhythmClassification: null,
deviceType: "ring",
syncVersion: 7,
deletedAt,
createdAt: new Date("2026-07-19T07:01:00.000Z"),
updatedAt: new Date("2026-07-19T07:02:00.000Z"),
},
]);
mocks.buildCycleBackupSection.mockResolvedValue({
cycleProfile: null,
cycles: [],
cycleDayLogs: [],
});
mocks.buildRecordsBackupSection.mockResolvedValue({
labResults: [],
biomarkers: [],
illnessEpisodes: [],
allergies: [],
familyHistory: [],
workouts: [],
documents: [],
manifest: {
documents: { included: "encrypted-content", note: "included" },
workouts: { included: "summary-only", note: "included" },
},
});
}
describe("buildFullBackupPayload disaster-recovery mode", () => {
it("preserves measurement and mood tombstones with stable ids", async () => {
installSectionMocks();
const prisma = makePrisma();
const { payload } = await buildFullBackupPayload(
prisma as never,
"user-1",
{
purpose: "disaster-recovery",
exportedAt: new Date("2026-07-20T00:00:00.000Z"),
},
);
expect(mocks.findMeasurementsPaged).toHaveBeenCalledWith(
prisma,
{ userId: "user-1" },
expect.objectContaining({ id: true, deletedAt: true }),
);
expect(prisma.moodEntry.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1" },
include: expect.any(Object),
}),
);
expect(mocks.buildRecordsBackupSection).toHaveBeenCalledWith(
prisma,
"user-1",
{ purpose: "disaster-recovery" },
);
expect(payload).toMatchObject({
schemaVersion: "2",
exportedAt: "2026-07-20T00:00:00.000Z",
appSettings: {
...appSettings,
adminCodexTokenExpiresAt:
appSettings.adminCodexTokenExpiresAt.toISOString(),
adminCodexConnectedAt: appSettings.adminCodexConnectedAt.toISOString(),
documentQuotaBytes: appSettings.documentQuotaBytes.toString(),
},
measurements: [
{
id: "measurement-tombstone",
type: "SLEEP_DURATION",
value: 75,
valueMin: 60,
valueMax: 90,
unit: "minutes",
measuredAt: "2026-07-19T07:00:00.000Z",
source: "IMPORT",
notesEncrypted: Buffer.from(measurementNote).toString("base64"),
externalId: "oura-stage-deep",
externalSourceVersion: "oura-v3",
glucoseContext: null,
sleepStage: "DEEP",
rhythmClassification: null,
deviceType: "ring",
syncVersion: 7,
deletedAt: deletedAt.toISOString(),
createdAt: "2026-07-19T07:01:00.000Z",
updatedAt: "2026-07-19T07:02:00.000Z",
},
],
medications: [
expect.objectContaining({
id: "medication-canonical",
asNeeded: false,
deliveryForm: "INJECTION",
unitsPerDose: "0.5000",
schedules: [
expect.objectContaining({
id: "schedule-canonical",
rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR",
scheduleType: "CYCLIC",
cyclicOnWeeks: 3,
cyclicOffWeeks: 1,
}),
],
}),
],
intakeEvents: [
expect.objectContaining({
id: "intake-canonical",
medicationId: "medication-canonical",
syncVersion: 4,
deletedAt: deletedAt.toISOString(),
}),
],
moodEntries: [
{
id: "mood-tombstone",
externalId: "moodlog-42",
deletedAt: deletedAt.toISOString(),
factors: [{ key: "sleep_quality", rating: 4 }],
},
],
});
});
});
@@ -234,6 +234,166 @@ describe("buildRecordsBackupSection", () => {
expect(section.documents[0].summary).toBe("Routine panel.");
});
it("preserves every canonical lab, illness, day-log, and allergy field verbatim", async () => {
const prisma = buildMockPrisma();
const deletedAt = new Date("2026-04-13T12:00:00.000Z");
const createdAt = new Date("2026-04-10T01:00:00.000Z");
const updatedAt = new Date("2026-04-12T23:00:00.000Z");
const labNote = encryptNoteToBytes("Canonical lab note");
const illnessNote = encryptToBytes("Canonical illness note");
const dayNote = encryptToBytes("Canonical day note");
const reaction = encryptToBytes("Canonical reaction");
const allergyNote = encryptToBytes("Canonical allergy note");
prisma.labResult.findMany.mockResolvedValueOnce([
{
id: "lab-canonical",
panel: "Lipid panel",
analyte: "LDL",
value: 118,
valueText: null,
unit: "mg/dL",
referenceLow: null,
referenceHigh: 130,
takenAt: new Date("2026-04-01T09:00:00.000Z"),
biomarkerId: "biomarker-canonical",
biomarker: { name: "LDL Cholesterol" },
source: "IMPORT",
noteEncrypted: labNote,
createdAt,
updatedAt,
deletedAt,
},
]);
prisma.illnessEpisode.findMany.mockResolvedValueOnce([
{
id: "episode-canonical",
label: "Migraine",
type: "CHRONIC",
lifecycle: "CHRONIC_ONGOING",
onsetAt: new Date("2026-04-10T00:00:00.000Z"),
resolvedAt: null,
parentConditionId: null,
noteEncrypted: illnessNote,
createdAt,
updatedAt,
deletedAt,
dayLogs: [
{
id: "day-canonical",
episodeId: "episode-canonical",
date: "2026-04-11",
functionalImpact: 2,
feverC: 38.2,
noteEncrypted: dayNote,
tz: "Europe/London",
createdAt,
updatedAt,
deletedAt,
symptomLinks: [{ severity: 3, symptom: { key: "headache" } }],
},
],
},
]);
prisma.allergy.findMany.mockResolvedValueOnce([
{
id: "allergy-canonical",
substance: "Penicillin",
category: "MEDICATION",
type: "ALLERGY",
severity: "SEVERE",
status: "INACTIVE",
onsetAt: new Date("2020-01-01T00:00:00.000Z"),
reactionEncrypted: reaction,
notesEncrypted: allergyNote,
createdAt,
updatedAt,
deletedAt,
},
]);
prisma.biomarker.findMany.mockResolvedValueOnce([]);
prisma.familyHistoryEntry.findMany.mockResolvedValueOnce([]);
prisma.workout.findMany.mockResolvedValueOnce([]);
prisma.inboundDocument.findMany.mockResolvedValueOnce([]);
const section = await buildRecordsBackupSection(prisma as never, USER_ID, {
purpose: "disaster-recovery",
});
const episodeQuery = prisma.illnessEpisode.findMany.mock.calls.at(-1)?.[0];
expect(episodeQuery).toEqual(
expect.objectContaining({ where: { userId: USER_ID } }),
);
expect(episodeQuery.include.dayLogs).not.toHaveProperty("where");
expect(prisma.allergy.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: USER_ID } }),
);
expect(section.labResults[0]).toEqual({
id: "lab-canonical",
panel: "Lipid panel",
analyte: "LDL",
value: 118,
valueText: null,
unit: "mg/dL",
referenceLow: null,
referenceHigh: 130,
takenAt: "2026-04-01T09:00:00.000Z",
biomarkerId: "biomarker-canonical",
biomarkerName: "LDL Cholesterol",
source: "IMPORT",
note: null,
noteEncrypted: Buffer.from(labNote).toString("base64"),
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
deletedAt: deletedAt.toISOString(),
});
expect(section.illnessEpisodes[0]).toEqual({
id: "episode-canonical",
label: "Migraine",
type: "CHRONIC",
lifecycle: "CHRONIC_ONGOING",
onsetAt: "2026-04-10T00:00:00.000Z",
resolvedAt: null,
parentConditionId: null,
note: null,
noteEncrypted: Buffer.from(illnessNote).toString("base64"),
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
deletedAt: deletedAt.toISOString(),
dayLogs: [
{
id: "day-canonical",
episodeId: "episode-canonical",
date: "2026-04-11",
functionalImpact: 2,
feverC: 38.2,
note: null,
noteEncrypted: Buffer.from(dayNote).toString("base64"),
tz: "Europe/London",
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
deletedAt: deletedAt.toISOString(),
symptoms: [{ key: "headache", severity: 3 }],
},
],
});
expect(section.allergies[0]).toEqual({
id: "allergy-canonical",
substance: "Penicillin",
category: "MEDICATION",
type: "ALLERGY",
severity: "SEVERE",
status: "INACTIVE",
onsetAt: "2020-01-01T00:00:00.000Z",
reaction: null,
note: null,
reactionEncrypted: Buffer.from(reaction).toString("base64"),
notesEncrypted: Buffer.from(allergyNote).toString("base64"),
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
deletedAt: deletedAt.toISOString(),
});
});
it("threads an illness flare's parentConditionId and nests its day-log + symptom", async () => {
const prisma = buildMockPrisma();
const section = await buildRecordsBackupSection(prisma as never, USER_ID);
+191 -61
View File
@@ -10,6 +10,7 @@
* The decrypted notes are surfaced here (the backup is the human-readable
* artefact); an admin restore re-encrypts them on re-insert.
*/
import { Buffer } from "node:buffer";
import type { PrismaClient } from "@/generated/prisma/client";
import { readNote } from "@/lib/crypto/note-cipher";
import { findMeasurementsPaged } from "@/lib/export/paged-measurements";
@@ -35,84 +36,189 @@ export interface FullBackupResult {
counts: FullBackupCounts;
}
export interface FullBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
exportedAt?: Date;
}
/**
* Build the canonical full-backup payload for `userId`. Soft-deleted rows are
* excluded so a round-trip via admin restore never resurrects deleted records.
* Build the canonical full-backup payload for `userId`. Portable exports omit
* tombstones and document ciphertext; disaster-recovery payloads preserve
* both so weekly and off-host snapshots share one restorable wire format.
*/
export async function buildFullBackupPayload(
prisma: PrismaClient,
userId: string,
options: FullBackupOptions = {},
): Promise<FullBackupResult> {
const [measurements, medications, intakeEvents, moodEntries, cycle, records] =
await Promise.all([
// v1.28.25 — keyset-paginated read with a narrow select. The backup
// is inherently whole-history, so on a CGM / per-sample-HR account the
// previous single full-width `findMany` materialised a six-figure row
// set in one shot. The chunked read accumulates the same rows in the
// same `measuredAt desc` order; the payload FORMAT below is untouched
// (restore compatibility). The select is exactly the fields the
// serializer reads: type / value / unit / measuredAt / source + the
// note columns `readNote` decrypts + the `id` cursor key.
findMeasurementsPaged(
prisma,
{ userId, deletedAt: null },
{
id: true,
type: true,
value: true,
unit: true,
measuredAt: true,
source: true,
notes: true,
notesEncrypted: true,
},
),
prisma.medication.findMany({
where: { userId },
select: {
name: true,
dose: true,
active: true,
schedules: {
select: {
windowStart: true,
windowEnd: true,
label: true,
dose: true,
},
const disasterRecovery = options.purpose === "disaster-recovery";
const [
appSettings,
measurements,
medications,
intakeEvents,
moodEntries,
cycle,
records,
] = await Promise.all([
disasterRecovery
? prisma.appSettings.findUnique({ where: { id: "singleton" } })
: Promise.resolve(null),
findMeasurementsPaged(
prisma,
disasterRecovery ? { userId } : { userId, deletedAt: null },
{
id: true,
type: true,
value: true,
valueMin: true,
valueMax: true,
unit: true,
measuredAt: true,
source: true,
notes: true,
notesEncrypted: true,
externalId: true,
externalSourceVersion: true,
glucoseContext: true,
sleepStage: true,
rhythmClassification: true,
deviceType: true,
syncVersion: true,
deletedAt: true,
createdAt: true,
updatedAt: true,
},
),
prisma.medication.findMany({
where: { userId },
include: { schedules: true },
}),
prisma.medicationIntakeEvent.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
}),
prisma.moodEntry.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
include: {
tagLinks: {
where: { rating: { not: null } },
select: {
rating: true,
moodTag: { select: { key: true } },
},
},
}),
prisma.medicationIntakeEvent.findMany({
where: { userId, deletedAt: null },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
}),
prisma.moodEntry.findMany({
where: { userId, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
}),
buildCycleBackupSection(prisma, userId),
buildRecordsBackupSection(prisma, userId),
]);
},
}),
buildCycleBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
buildRecordsBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
]);
const payload = {
schemaVersion: BACKUP_SCHEMA_VERSION,
exportedAt: new Date().toISOString(),
exportedAt: (options.exportedAt ?? new Date()).toISOString(),
userId,
measurements: measurements.map((m) => ({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt.toISOString(),
source: m.source,
notes: readNote(m.notesEncrypted, m.notes),
})),
appSettings:
disasterRecovery && appSettings
? {
...appSettings,
adminCodexTokenExpiresAt:
appSettings.adminCodexTokenExpiresAt?.toISOString() ?? null,
adminCodexConnectedAt:
appSettings.adminCodexConnectedAt?.toISOString() ?? null,
documentQuotaBytes: appSettings.documentQuotaBytes.toString(),
}
: null,
measurements: measurements.map((m) =>
disasterRecovery
? {
id: m.id,
type: m.type,
value: m.value,
valueMin: m.valueMin,
valueMax: m.valueMax,
unit: m.unit,
measuredAt: m.measuredAt.toISOString(),
source: m.source,
notes: m.notes,
notesEncrypted: m.notesEncrypted
? Buffer.from(m.notesEncrypted).toString("base64")
: null,
externalId: m.externalId,
externalSourceVersion: m.externalSourceVersion,
glucoseContext: m.glucoseContext,
sleepStage: m.sleepStage,
rhythmClassification: m.rhythmClassification,
deviceType: m.deviceType,
syncVersion: m.syncVersion,
deletedAt: m.deletedAt?.toISOString() ?? null,
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
}
: {
id: m.id,
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt.toISOString(),
source: m.source,
notes: readNote(m.notesEncrypted, m.notes),
deletedAt: m.deletedAt?.toISOString() ?? null,
},
),
medications: medications.map((m) => ({
...(disasterRecovery
? {
id: m.id,
treatmentClass: m.treatmentClass,
dosesPerUnit: m.dosesPerUnit,
unitsPerDose: m.unitsPerDose.toString(),
notificationsEnabled: m.notificationsEnabled,
pausedAt: m.pausedAt?.toISOString() ?? null,
snoozedUntil: m.snoozedUntil?.toISOString() ?? null,
startsOn: m.startsOn?.toISOString() ?? null,
endsOn: m.endsOn?.toISOString() ?? null,
oneShot: m.oneShot,
asNeeded: m.asNeeded,
deliveryForm: m.deliveryForm,
trackInjectionSites: m.trackInjectionSites,
allowedInjectionSites: m.allowedInjectionSites,
liveActivityEnabled: m.liveActivityEnabled,
criticalAlarmEnabled: m.criticalAlarmEnabled,
atcCode: m.atcCode,
rxNormCode: m.rxNormCode,
lowStockNotifiedAt: m.lowStockNotifiedAt?.toISOString() ?? null,
lowStockNotifiedThresholdDays: m.lowStockNotifiedThresholdDays,
reorderLeadDays: m.reorderLeadDays,
externalSource: m.externalSource,
externalId: m.externalId,
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
}
: {}),
name: m.name,
dose: m.dose,
active: m.active,
schedules: m.schedules.map((s) => ({
...(disasterRecovery
? {
id: s.id,
daysOfWeek: s.daysOfWeek,
timesOfDay: s.timesOfDay,
reminderGraceMinutes: s.reminderGraceMinutes,
rrule: s.rrule,
rollingIntervalDays: s.rollingIntervalDays,
scheduleType: s.scheduleType,
cyclicOnWeeks: s.cyclicOnWeeks,
cyclicOffWeeks: s.cyclicOffWeeks,
doseWindows: s.doseWindows,
}
: {}),
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label,
@@ -120,6 +226,23 @@ export async function buildFullBackupPayload(
})),
})),
intakeEvents: intakeEvents.map((e) => ({
...(disasterRecovery
? {
id: e.id,
medicationId: e.medicationId,
autoMissed: e.autoMissed,
attributionSource: e.attributionSource,
idempotencyKey: e.idempotencyKey,
createdAt: e.createdAt.toISOString(),
injectionSite: e.injectionSite,
doseTaken: e.doseTaken,
inventoryConsumption: e.inventoryConsumption,
externalId: e.externalId,
updatedAt: e.updatedAt.toISOString(),
syncVersion: e.syncVersion,
deletedAt: e.deletedAt?.toISOString() ?? null,
}
: {}),
medication: e.medication.name,
scheduledFor: e.scheduledFor.toISOString(),
takenAt: e.takenAt?.toISOString() ?? null,
@@ -127,12 +250,19 @@ export async function buildFullBackupPayload(
source: e.source,
})),
moodEntries: moodEntries.map((e) => ({
id: e.id,
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags,
source: e.source,
loggedAt: e.moodLoggedAt.toISOString(),
externalId: e.externalId,
deletedAt: e.deletedAt?.toISOString() ?? null,
factors: e.tagLinks.map((link) => ({
key: link.moodTag.key,
rating: link.rating!,
})),
})),
cycleProfile: cycle.cycleProfile,
cycles: cycle.cycles,
+269 -76
View File
@@ -1,32 +1,17 @@
/**
* Structured-records / workouts / documents-manifest backup section.
* Structured records, workouts, and document backup section.
*
* Shared by the on-demand full-backup builder (`full-backup-payload.ts`,
* consumed by `GET /api/export/full-backup` + `POST /api/export/encrypted`)
* AND the weekly `data-backup` pg-boss worker
* (`src/lib/jobs/reminder/backup-handlers.ts`) so both writers emit the same
* shape — the doc-comment convention `buildCycleBackupSection` already
* established for the cycle tables.
* Portable exports keep the historical v1.28 metadata-only document shape.
* The weekly disaster-recovery writer opts into the canonical mode, which
* carries the encrypted document bytes and their persistence metadata
* verbatim. The DR path never decrypts document content or summaries.
*
* Closes the backup-completeness gap: the pre-existing payload covered
* measurements / medications / intake events / mood / cycle only. This adds
* lab results + the biomarker catalog, illness episodes (including
* flares/exacerbations via `parentConditionId`) with their day-logs,
* allergies, family history, and workout summaries.
*
* Every read is scoped to `userId` + `deletedAt: null` (soft-deleted rows
* never resurrect into a backup). Encrypted free-text columns are decrypted
* FAIL-SOFT (null on a bad key / corrupt row, with a wide-event warning) so
* one damaged row never aborts an entire backup — the same posture the
* illness/allergy/family-history DTO layer already uses for list-style
* reads. Never the ciphertext itself leaves this module.
*
* Document BINARIES are deliberately excluded — see `DOCUMENTS_MANIFEST`.
* Workout GPS routes + per-sample time series (`WorkoutRoute` /
* `WorkoutSamples`) are deliberately excluded — see `WORKOUTS_MANIFEST`.
* Both exclusions are disclosed in the payload's `manifest` section (and in
* the export UI copy) rather than silently implied as "everything".
* Every read is scoped to the owner. Soft-deleted rows are excluded so a
* restore cannot resurrect tombstones. Other encrypted free-text fields keep
* the established fail-soft portable representation and are re-encrypted by
* the restore importer.
*/
import { Buffer } from "node:buffer";
import type { PrismaClient } from "@/generated/prisma/client";
import { decryptNoteSoft } from "@/lib/labs/store";
import { decryptContextSoft } from "@/lib/labs/biomarker-store";
@@ -60,6 +45,8 @@ export const WORKOUTS_MANIFEST_NOTE =
"series are not included in this export.";
export interface LabResultBackupEntry {
/** Present in canonical DR payloads so rows keep a stable identity. */
id?: string;
panel: string | null;
analyte: string;
value: number | null;
@@ -70,11 +57,20 @@ export interface LabResultBackupEntry {
takenAt: string;
source: string;
/** Human-readable cross-reference into `biomarkers` below, not an id. */
/** Stable canonical FK; legacy portable payloads use biomarkerName. */
biomarkerId?: string | null;
biomarkerName: string | null;
note: string | null;
/** Base64 ciphertext in canonical DR payloads. */
noteEncrypted?: string | null;
createdAt?: string;
updatedAt?: string;
deletedAt?: string | null;
}
export interface BiomarkerBackupEntry {
/** Present in canonical DR payloads; name remains the natural key. */
id?: string;
name: string;
unit: string;
lowerBound: number | null;
@@ -82,9 +78,15 @@ export interface BiomarkerBackupEntry {
panel: string | null;
hidden: boolean;
context: string | null;
createdAt?: string;
updatedAt?: string;
}
export interface WorkoutBackupEntry {
/** Present in canonical DR payloads for manual workouts without externalId. */
id?: string;
createdAt?: string;
updatedAt?: string;
sportType: string;
startedAt: string;
endedAt: string;
@@ -111,20 +113,70 @@ export interface DocumentBackupEntry {
status: string;
reportDate: string | null;
documentDate: string | null;
summary: string | null;
/** Portable export only; canonical DR carries summaryEncrypted instead. */
summary?: string | null;
createdAt: string;
updatedAt?: string;
/** Base64 of the already-encrypted BYTEA value; never plaintext. */
contentEncrypted?: string;
contentSha256?: string | null;
contentCodec?: string;
providerType?: string | null;
errorReason?: string | null;
/** Base64 of the already-encrypted summary BYTEA value. */
summaryEncrypted?: string | null;
summaryGeneratedAt?: string | null;
summaryState?: string;
}
export interface RecordsBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
}
interface DisasterRecoveryDocumentRow {
contentEncrypted: Uint8Array;
contentSha256: string | null;
contentCodec: string;
providerType: string | null;
errorReason: string | null;
summaryEncrypted: Uint8Array | null;
summaryGeneratedAt: Date | null;
summaryState: string;
updatedAt: Date;
}
type CanonicalIllnessDayLog = IllnessDayLogDTO & {
noteEncrypted?: string | null;
tz?: string | null;
createdAt?: string;
deletedAt?: string | null;
};
type CanonicalIllnessEpisode = IllnessEpisodeDTO & {
noteEncrypted?: string | null;
deletedAt?: string | null;
dayLogs: CanonicalIllnessDayLog[];
};
type CanonicalAllergy = AllergyDTO & {
reactionEncrypted?: string | null;
notesEncrypted?: string | null;
deletedAt?: string | null;
};
export interface RecordsBackupSection {
labResults: LabResultBackupEntry[];
biomarkers: BiomarkerBackupEntry[];
illnessEpisodes: Array<IllnessEpisodeDTO & { dayLogs: IllnessDayLogDTO[] }>;
allergies: AllergyDTO[];
illnessEpisodes: CanonicalIllnessEpisode[];
allergies: CanonicalAllergy[];
familyHistory: FamilyHistoryEntryDTO[];
workouts: WorkoutBackupEntry[];
documents: DocumentBackupEntry[];
manifest: {
documents: { included: "metadata-only"; note: string };
documents: {
included: "metadata-only" | "encrypted-content";
note: string;
};
workouts: { included: "summary-only"; note: string };
};
}
@@ -156,10 +208,8 @@ function decryptSummarySoft(buf: Uint8Array | null): string | null {
}
/**
* Build the structured-records / workouts / documents-manifest slice of a
* user's full backup. Accepts any client exposing the needed delegates (the
* app's global `prisma` OR the pg-boss worker's `getWorkerPrisma()` client)
* so both writers share one read.
* Build the structured-records slice. The default is the established portable
* export. Only the weekly DR writer passes `purpose: "disaster-recovery"`.
*/
export async function buildRecordsBackupSection(
prisma: Pick<
@@ -173,7 +223,52 @@ export async function buildRecordsBackupSection(
| "inboundDocument"
>,
userId: string,
options: RecordsBackupOptions = {},
): Promise<RecordsBackupSection> {
const disasterRecovery = options.purpose === "disaster-recovery";
const documentQuery = disasterRecovery
? prisma.inboundDocument.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "desc" },
select: {
id: true,
kind: true,
title: true,
filename: true,
mimeType: true,
byteSize: true,
contentEncrypted: true,
contentSha256: true,
contentCodec: true,
status: true,
providerType: true,
reportDate: true,
documentDate: true,
errorReason: true,
summaryEncrypted: true,
summaryGeneratedAt: true,
summaryState: true,
createdAt: true,
updatedAt: true,
},
})
: prisma.inboundDocument.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "desc" },
select: {
id: true,
kind: true,
title: true,
filename: true,
mimeType: true,
byteSize: true,
status: true,
reportDate: true,
documentDate: true,
summaryEncrypted: true,
createdAt: true,
},
});
const [
labResultRows,
biomarkerRows,
@@ -184,7 +279,7 @@ export async function buildRecordsBackupSection(
documentRows,
] = await Promise.all([
prisma.labResult.findMany({
where: { userId, deletedAt: null },
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { takenAt: "desc" },
include: { biomarker: { select: { name: true } } },
}),
@@ -193,18 +288,18 @@ export async function buildRecordsBackupSection(
orderBy: { name: "asc" },
}),
prisma.illnessEpisode.findMany({
where: { userId, deletedAt: null },
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { onsetAt: "desc" },
include: {
dayLogs: {
where: { deletedAt: null },
...(disasterRecovery ? {} : { where: { deletedAt: null } }),
orderBy: { date: "asc" },
include: dayLogSymptomInclude,
},
},
}),
prisma.allergy.findMany({
where: { userId, deletedAt: null },
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { createdAt: "desc" },
}),
prisma.familyHistoryEntry.findMany({
@@ -215,26 +310,23 @@ export async function buildRecordsBackupSection(
where: { userId },
orderBy: { startedAt: "desc" },
}),
prisma.inboundDocument.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "desc" },
select: {
id: true,
kind: true,
title: true,
filename: true,
mimeType: true,
byteSize: true,
status: true,
reportDate: true,
documentDate: true,
summaryEncrypted: true,
createdAt: true,
},
}),
documentQuery,
]);
const labResults: LabResultBackupEntry[] = labResultRows.map((r) => ({
...(disasterRecovery
? {
id: r.id,
biomarkerId: r.biomarkerId,
noteEncrypted: r.noteEncrypted
? Buffer.from(r.noteEncrypted).toString("base64")
: null,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
deletedAt: r.deletedAt?.toISOString() ?? null,
note: null,
}
: { note: decryptNoteSoft(r.noteEncrypted) }),
panel: r.panel,
analyte: r.analyte,
value: r.value,
@@ -245,10 +337,16 @@ export async function buildRecordsBackupSection(
takenAt: r.takenAt.toISOString(),
source: r.source,
biomarkerName: r.biomarker?.name ?? null,
note: decryptNoteSoft(r.noteEncrypted),
}));
const biomarkers: BiomarkerBackupEntry[] = biomarkerRows.map((b) => ({
...(disasterRecovery
? {
id: b.id,
createdAt: b.createdAt.toISOString(),
updatedAt: b.updatedAt.toISOString(),
}
: {}),
name: b.name,
unit: b.unit,
lowerBound: b.lowerBound,
@@ -258,15 +356,79 @@ export async function buildRecordsBackupSection(
context: decryptContextSoft(b.contextEncrypted),
}));
const illnessEpisodes = episodeRows.map((row) => ({
...toIllnessEpisodeDTO(row),
dayLogs: row.dayLogs.map(toIllnessDayLogDTO),
}));
const illnessEpisodes: CanonicalIllnessEpisode[] = disasterRecovery
? episodeRows.map((row) => ({
id: row.id,
label: row.label,
type: row.type,
lifecycle: row.lifecycle,
onsetAt: row.onsetAt.toISOString(),
resolvedAt: row.resolvedAt?.toISOString() ?? null,
parentConditionId: row.parentConditionId,
note: null,
noteEncrypted: row.noteEncrypted
? Buffer.from(row.noteEncrypted).toString("base64")
: null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null,
dayLogs: row.dayLogs.map((dayLog) => ({
id: dayLog.id,
episodeId: dayLog.episodeId,
date: dayLog.date,
functionalImpact: dayLog.functionalImpact,
feverC: dayLog.feverC,
note: null,
noteEncrypted: dayLog.noteEncrypted
? Buffer.from(dayLog.noteEncrypted).toString("base64")
: null,
tz: dayLog.tz,
createdAt: dayLog.createdAt.toISOString(),
updatedAt: dayLog.updatedAt.toISOString(),
deletedAt: dayLog.deletedAt?.toISOString() ?? null,
symptoms: dayLog.symptomLinks.map((link) => ({
key: link.symptom.key,
severity: link.severity,
})),
})),
}))
: episodeRows.map((row) => ({
...toIllnessEpisodeDTO(row),
dayLogs: row.dayLogs.map(toIllnessDayLogDTO),
}));
const allergies = allergyRows.map(toAllergyDTO);
const allergies: CanonicalAllergy[] = disasterRecovery
? allergyRows.map((row) => ({
id: row.id,
substance: row.substance,
category: row.category,
type: row.type,
severity: row.severity,
status: row.status,
onsetAt: row.onsetAt?.toISOString() ?? null,
reaction: null,
note: null,
reactionEncrypted: row.reactionEncrypted
? Buffer.from(row.reactionEncrypted).toString("base64")
: null,
notesEncrypted: row.notesEncrypted
? Buffer.from(row.notesEncrypted).toString("base64")
: null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null,
}))
: allergyRows.map(toAllergyDTO);
const familyHistory = familyRows.map(toFamilyHistoryEntryDTO);
const workouts: WorkoutBackupEntry[] = workoutRows.map((w) => ({
...(disasterRecovery
? {
id: w.id,
createdAt: w.createdAt.toISOString(),
updatedAt: w.updatedAt.toISOString(),
}
: {}),
sportType: w.sportType,
startedAt: w.startedAt.toISOString(),
endedAt: w.endedAt.toISOString(),
@@ -283,21 +445,47 @@ export async function buildRecordsBackupSection(
externalId: w.externalId,
}));
const documents: DocumentBackupEntry[] = documentRows.map((d) => ({
id: d.id,
kind: d.kind,
title: d.title,
filename: d.filename,
mimeType: d.mimeType,
byteSize: d.byteSize,
status: d.status,
reportDate: d.reportDate ? d.reportDate.toISOString().slice(0, 10) : null,
documentDate: d.documentDate
? d.documentDate.toISOString().slice(0, 10)
: null,
summary: decryptSummarySoft(d.summaryEncrypted),
createdAt: d.createdAt.toISOString(),
}));
const documents: DocumentBackupEntry[] = documentRows.map((d) => {
const metadata = {
id: d.id,
kind: d.kind,
title: d.title,
filename: d.filename,
mimeType: d.mimeType,
byteSize: d.byteSize,
status: d.status,
reportDate: d.reportDate ? d.reportDate.toISOString().slice(0, 10) : null,
documentDate: d.documentDate
? d.documentDate.toISOString().slice(0, 10)
: null,
createdAt: d.createdAt.toISOString(),
};
if (disasterRecovery && "contentEncrypted" in d) {
const dr = d as typeof d & DisasterRecoveryDocumentRow;
return {
...metadata,
reportDate: dr.reportDate?.toISOString() ?? null,
documentDate: dr.documentDate?.toISOString() ?? null,
updatedAt: dr.updatedAt.toISOString(),
contentEncrypted: Buffer.from(dr.contentEncrypted).toString("base64"),
contentSha256: dr.contentSha256,
contentCodec: dr.contentCodec,
providerType: dr.providerType,
errorReason: dr.errorReason,
summaryEncrypted: dr.summaryEncrypted
? Buffer.from(dr.summaryEncrypted).toString("base64")
: null,
summaryGeneratedAt: dr.summaryGeneratedAt?.toISOString() ?? null,
summaryState: dr.summaryState,
};
}
return {
...metadata,
summary: decryptSummarySoft(d.summaryEncrypted),
};
});
return {
labResults,
@@ -308,7 +496,12 @@ export async function buildRecordsBackupSection(
workouts,
documents,
manifest: {
documents: { included: "metadata-only", note: DOCUMENTS_MANIFEST_NOTE },
documents: disasterRecovery
? {
included: "encrypted-content",
note: "Document metadata and encrypted stored bytes are included for disaster recovery.",
}
: { included: "metadata-only", note: DOCUMENTS_MANIFEST_NOTE },
workouts: { included: "summary-only", note: WORKOUTS_MANIFEST_NOTE },
},
};
+7 -3
View File
@@ -411,7 +411,11 @@ export function withIdempotency<
throw err;
}
if (isCachableStatus(response.status)) {
const noStore = response.headers
.get("Cache-Control")
?.split(",")
.some((directive) => directive.trim().toLowerCase() === "no-store");
if (isCachableStatus(response.status) && !noStore) {
// Defence-in-depth: never persist a body that carries a freshly-issued
// bearer / refresh token or a third-party AI provider key. Auth and
// settings routes shouldn't be wrapped in withIdempotency to begin
@@ -437,8 +441,8 @@ export function withIdempotency<
await releaseClaim(ctx);
}
} else {
// Non-cachable status (401/403/408/429/5xx) — release the claim so a
// retry gets a fresh attempt rather than a stuck pending row.
// Non-cachable status or an explicit `no-store` response releases the
// claim so a retry gets a fresh attempt.
await releaseClaim(ctx);
}
+31
View File
@@ -1,3 +1,5 @@
import type { Prisma } from "@/generated/prisma/client";
/**
* Shared OAuth rotating-refresh-token persistence helper.
*
@@ -58,3 +60,32 @@ export async function persistRotatedToken(
// rather than parking the connection at reauth.
return opts.readPeerAccessToken();
}
export type SerializedRefreshProvider = "whoop" | "withings";
/**
* Provider refreshes hold a database transaction open around one bounded
* external request. `maxWait` bounds pool acquisition and `timeout` bounds the
* advisory-lock wait, provider call, and durable token replacement together.
*/
export const PROVIDER_REFRESH_TRANSACTION_OPTIONS = {
maxWait: 10_000,
timeout: 60_000,
} as const;
/**
* Serialize one provider/user refresh attempt for the transaction lifetime.
* Callers must re-read token state after this returns: a prior contender may
* have committed a fresh token while this transaction waited for the lock.
*/
export async function acquireProviderTokenRefreshLock(
tx: Pick<Prisma.TransactionClient, "$queryRaw">,
provider: SerializedRefreshProvider,
userId: string,
): Promise<void> {
const key = `provider-token-refresh:${provider}:${userId}`;
await tx.$queryRaw`
SELECT 1 AS locked
FROM pg_advisory_xact_lock(hashtextextended(${key}, 0))
`;
}
+97 -53
View File
@@ -1,4 +1,16 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import {
BACKUP_SCHEMA_VERSION,
backupPayloadSchema,
} from "@/lib/validations/backup";
const mocks = vi.hoisted(() => ({
buildFullBackupPayload: vi.fn(),
}));
vi.mock("@/lib/export/full-backup-payload", () => ({
buildFullBackupPayload: mocks.buildFullBackupPayload,
}));
import {
encryptBackup,
decryptBackup,
@@ -6,7 +18,6 @@ import {
runOffhostBackup,
runOffhostRoundtripTest,
} from "../offhost-backup";
import { MEASUREMENT_BACKUP_PAGE_SIZE } from "../backup/measurement-page";
const ENC_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
@@ -89,12 +100,36 @@ function makeS3Mock() {
describe("runOffhostBackup", () => {
beforeEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
vi.stubEnv("BACKUP_S3_ENDPOINT", "https://r2.example");
vi.stubEnv("BACKUP_S3_BUCKET", "hl-backups");
vi.stubEnv("BACKUP_S3_ACCESS_KEY", "AKIA");
vi.stubEnv("BACKUP_S3_SECRET_KEY", "secret");
vi.stubEnv("BACKUP_S3_REGION", "auto");
vi.stubEnv("BACKUP_ENCRYPTION_KEY", ENC_KEY);
mocks.buildFullBackupPayload.mockImplementation(
async (_prisma: unknown, userId: string) => ({
payload: {
schemaVersion: BACKUP_SCHEMA_VERSION,
exportedAt: "2026-05-08T03:00:00.000Z",
userId,
measurements: [],
medications: [],
intakeEvents: [],
moodEntries: [
{
id: `mood-${userId}`,
date: "2026-05-08",
mood: "GUT",
score: 4,
loggedAt: "2026-05-08T20:00:00.000Z",
factors: [],
},
],
},
counts: {},
}),
);
});
it("uploads one object per user and never deletes existing ones", async () => {
@@ -137,56 +172,66 @@ describe("runOffhostBackup", () => {
const decoded = decryptBackup(ct, Buffer.from(ENC_KEY, "hex"));
const parsed = JSON.parse(decoded);
expect(parsed.userId).toBe("u1");
expect(() => backupPayloadSchema.parse(parsed)).not.toThrow();
expect(parsed.schemaVersion).toBe(BACKUP_SCHEMA_VERSION);
expect(mocks.buildFullBackupPayload).toHaveBeenCalledWith(prisma, "u1", {
purpose: "disaster-recovery",
exportedAt: now,
});
});
it("keyset-pages the intake/mood/cycle tables across multiple pages", async () => {
it("uploads the canonical builder output without reshaping it", async () => {
const s3 = makeS3Mock();
// A full first page (=== page size) followed by a short page proves the
// reader walks the id cursor instead of one unbounded findMany.
const fullPage = Array.from(
{ length: MEASUREMENT_BACKUP_PAGE_SIZE },
(_, i) => ({
id: `e${String(i).padStart(5, "0")}`,
userId: "u1",
takenAt: new Date("2026-05-01T00:00:00Z"),
}),
);
const tailPage = [
{ id: "z9999", userId: "u1", takenAt: new Date("2026-05-02T00:00:00Z") },
];
const intakeFindMany = vi.fn(
async (args: { where?: { id?: { gt?: string } } }) =>
args?.where?.id?.gt ? tailPage : fullPage,
);
const prisma = {
user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) },
measurement: { findMany: vi.fn().mockResolvedValue([]) },
medication: { findMany: vi.fn().mockResolvedValue([]) },
medicationIntakeEvent: { findMany: intakeFindMany },
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
};
const canonicalPayload = {
schemaVersion: BACKUP_SCHEMA_VERSION,
exportedAt: "2026-05-08T00:00:00.000Z",
userId: "u1",
measurements: [],
medications: [],
intakeEvents: [],
moodEntries: [],
documents: [
{
id: "document-1",
kind: "LAB_RESULT",
mimeType: "application/pdf",
byteSize: 4,
status: "STORED",
contentEncrypted: Buffer.from([1, 2, 3, 4]).toString("base64"),
contentSha256: null,
contentCodec: "binary2",
providerType: null,
reportDate: null,
documentDate: null,
errorReason: null,
summaryEncrypted: null,
summaryGeneratedAt: null,
summaryState: "NONE",
createdAt: "2026-05-08T00:00:00.000Z",
updatedAt: "2026-05-08T00:00:00.000Z",
},
],
};
mocks.buildFullBackupPayload.mockResolvedValueOnce({
payload: canonicalPayload,
counts: {},
});
const report = await runOffhostBackup(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma as any,
prisma as never,
s3,
new Date("2026-05-08T00:00:00Z"),
);
expect(report.uploaded).toBe(1);
// Two round-trips: the full page, then the short tail that ends the walk.
expect(intakeFindMany).toHaveBeenCalledTimes(2);
expect(intakeFindMany.mock.calls[0][0]).toMatchObject({
orderBy: { id: "asc" },
take: MEASUREMENT_BACKUP_PAGE_SIZE,
});
// Every paged row lands in the decrypted payload — no rows dropped.
const ct = s3.store.get("2026-05-08/user-u1.json.enc")!;
const parsed = JSON.parse(decryptBackup(ct, Buffer.from(ENC_KEY, "hex")));
expect(parsed.intakeEvents).toHaveLength(MEASUREMENT_BACKUP_PAGE_SIZE + 1);
const ciphertext = s3.store.get("2026-05-08/user-u1.json.enc")!;
expect(
JSON.parse(decryptBackup(ciphertext, Buffer.from(ENC_KEY, "hex"))),
).toEqual(canonicalPayload);
expect(() => backupPayloadSchema.parse(canonicalPayload)).not.toThrow();
});
it("counts per-user failures without aborting the whole run", async () => {
@@ -195,25 +240,24 @@ describe("runOffhostBackup", () => {
user: {
findMany: vi.fn().mockResolvedValue([{ id: "u1" }, { id: "u2" }]),
},
measurement: {
findMany: vi
.fn()
.mockResolvedValueOnce([])
.mockRejectedValueOnce(new Error("db gone")),
},
medication: { findMany: vi.fn().mockResolvedValue([]) },
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
};
mocks.buildFullBackupPayload
.mockResolvedValueOnce({
payload: {
schemaVersion: BACKUP_SCHEMA_VERSION,
exportedAt: "2026-05-08T00:00:00.000Z",
userId: "u1",
},
counts: {},
})
.mockRejectedValueOnce(new Error("db gone"));
const report = await runOffhostBackup(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma as any,
prisma as never,
s3,
new Date("2026-05-08T00:00:00Z"),
);
expect(report.uploaded).toBe(1);
expect(report.failed).toBe(1);
});
@@ -0,0 +1,122 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Job } from "pg-boss";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const { syncUserEcg } = vi.hoisted(() => ({
syncUserEcg:
vi.fn<
(
userId: string,
options?: { startdate?: number; enddate?: number },
) => Promise<number>
>(),
}));
vi.mock("@/lib/withings/sync-ecg", () => ({
syncUserEcg: (
userId: string,
options?: { startdate?: number; enddate?: number },
) => syncUserEcg(userId, options),
}));
vi.mock("@/lib/withings/sync", () => ({
syncUserMeasurements: vi.fn(async () => 0),
}));
vi.mock("@/lib/withings/sync-activity", () => ({
syncUserActivity: vi.fn(async () => 0),
}));
vi.mock("@/lib/withings/sync-sleep", () => ({
syncUserSleep: vi.fn(async () => 0),
}));
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
enqueueReminderSatisfy: vi.fn(async () => undefined),
}));
vi.mock("@/lib/jobs/worker-status", () => ({
recordError: vi.fn(),
recordWithingsSync: vi.fn(),
}));
vi.mock("@/lib/logging/background", () => ({
withBackgroundEvent: async (
_name: string,
fn: (event: {
setBackground: (value: unknown) => void;
setError: (error: unknown) => void;
addWarning: (warning: string) => void;
}) => Promise<void>,
) =>
fn({
setBackground: vi.fn(),
setError: vi.fn(),
addWarning: vi.fn(),
}),
}));
vi.mock("@/lib/logging/fire-and-forget", () => ({
fireAndForget: vi.fn(),
}));
vi.mock("../reminder/shared", () => ({
getWorkerPrisma: vi.fn(() => ({
withingsConnection: { findMany: vi.fn(async () => []) },
})),
}));
import {
handleWithingsEcgSync,
type WithingsEcgSyncPayload,
} from "../reminder/withings-sync";
function job(data: WithingsEcgSyncPayload): Job<WithingsEcgSyncPayload> {
return { data } as Job<WithingsEcgSyncPayload>;
}
beforeEach(() => {
vi.clearAllMocks();
});
const registrarSource = readFileSync(
join(process.cwd(), "src/lib/jobs/reminder/register-integration-sync.ts"),
"utf8",
);
describe("handleWithingsEcgSync", () => {
it("rejects a failed source write so pg-boss can retry the same job", async () => {
syncUserEcg
.mockRejectedValueOnce(new Error("source write failed"))
.mockResolvedValueOnce(1);
const queued = job({
userId: "user-1",
eventId: "wu-1:1:1715000000:1715000060",
triggeredAt: "2026-07-20T12:00:00.000Z",
startdate: 1715000000,
enddate: 1715000060,
});
await expect(handleWithingsEcgSync([queued])).rejects.toThrow(
"source write failed",
);
await expect(handleWithingsEcgSync([queued])).resolves.toBeUndefined();
expect(syncUserEcg).toHaveBeenCalledTimes(2);
expect(syncUserEcg).toHaveBeenNthCalledWith(1, "user-1", {
startdate: 1715000000,
enddate: 1715000060,
});
expect(syncUserEcg).toHaveBeenNthCalledWith(2, "user-1", {
startdate: 1715000000,
enddate: 1715000060,
});
});
});
describe("withings ECG queue registration", () => {
it("deduplicates only queued replays and admits a rescue after work starts", () => {
const allQueues = registrarSource.match(
/const allQueues\s*=\s*\[([\s\S]*?)\];/,
);
expect(allQueues).not.toBeNull();
expect(allQueues![1]).toMatch(/\bWITHINGS_ECG_SYNC_QUEUE\b/);
expect(registrarSource).toMatch(
/\[WITHINGS_ECG_SYNC_QUEUE\]:\s*\{[\s\S]*?policy:\s*"short"/,
);
expect(registrarSource).toMatch(
/boss\.work<WithingsEcgSyncPayload>\([\s\S]*?WITHINGS_ECG_SYNC_QUEUE[\s\S]*?handleWithingsEcgSync/,
);
});
});
+6 -144
View File
@@ -19,11 +19,8 @@
*/
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import type { PrismaClient } from "@/generated/prisma/client";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { getEvent } from "@/lib/logging/context";
import {
collectPagedMeasurements,
MEASUREMENT_BACKUP_PAGE_SIZE,
} from "@/lib/jobs/backup/measurement-page";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12;
@@ -218,147 +215,12 @@ export async function runOffhostBackup(
const evt = getEvent();
for (const user of users) {
try {
const [
measurements,
medications,
intakeEvents,
moodEntries,
cycleProfile,
cycles,
cycleDayLogs,
] = await Promise.all([
// includes soft-deleted rows because this is the DR snapshot,
// not a user-facing export — see
// `/api/export/full-backup/route.ts` for the symmetric exclusion.
//
// Keyset-paged narrow read instead of one full-set findMany: a
// heavy multi-year tenant otherwise loads every measurement ORM
// row into a single array alongside the giant JSON.stringify
// below, the worker's dominant heap spike. The DR dump keeps the
// FULL row shape, so the select lists every scalar column (no
// relations) — identical bytes to the prior default findMany —
// while the page reader bounds peak heap to the page size. No
// `deletedAt` filter: the tombstoned rows stay in the snapshot.
collectPagedMeasurements({
fetchPage: (afterId, take) =>
prisma.measurement.findMany({
where: {
userId: user.id,
...(afterId ? { id: { gt: afterId } } : {}),
},
select: {
id: true,
userId: true,
type: true,
value: true,
valueMin: true,
valueMax: true,
unit: true,
source: true,
measuredAt: true,
notes: true,
notesEncrypted: true,
externalId: true,
externalSourceVersion: true,
glucoseContext: true,
sleepStage: true,
rhythmClassification: true,
deviceType: true,
syncVersion: true,
deletedAt: true,
createdAt: true,
updatedAt: true,
},
orderBy: { id: "asc" },
take,
}),
project: (row) => row,
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
}),
prisma.medication.findMany({
where: { userId: user.id },
include: { schedules: true },
}),
// The intake / mood / cycle tables scale with history depth the same
// way measurements do (multiple intakes + a mood + a cycle-day-log per
// day over years), so they get the same keyset-paged read: at most one
// page is resident per round-trip instead of the entire table landing
// in one array alongside the giant JSON.stringify below. The projection
// keeps the full row (no select), so payload bytes are identical to the
// prior default findMany — only peak heap during the read is bounded.
collectPagedMeasurements({
fetchPage: (afterId, take) =>
prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
...(afterId ? { id: { gt: afterId } } : {}),
},
orderBy: { id: "asc" },
take,
}),
project: (row) => row,
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
}),
collectPagedMeasurements({
fetchPage: (afterId, take) =>
prisma.moodEntry.findMany({
where: {
userId: user.id,
...(afterId ? { id: { gt: afterId } } : {}),
},
orderBy: { id: "asc" },
take,
}),
project: (row) => row,
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
}),
// v1.15.0 — cycle tables in the DR snapshot. Raw rows (this is a
// disaster-recovery dump, not the canonical user-facing backup);
// `notesEncrypted` stays ciphertext. The profile is a single row, so it
// stays a direct read; the cycle + day-log tables are paged.
prisma.cycleProfile.findUnique({ where: { userId: user.id } }),
collectPagedMeasurements({
fetchPage: (afterId, take) =>
prisma.menstrualCycle.findMany({
where: {
userId: user.id,
...(afterId ? { id: { gt: afterId } } : {}),
},
orderBy: { id: "asc" },
take,
}),
project: (row) => row,
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
}),
collectPagedMeasurements({
fetchPage: (afterId, take) =>
prisma.cycleDayLog.findMany({
where: {
userId: user.id,
...(afterId ? { id: { gt: afterId } } : {}),
},
include: { symptomLinks: true },
orderBy: { id: "asc" },
take,
}),
project: (row) => row,
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
}),
]);
const payload = JSON.stringify({
exportedAt: now.toISOString(),
userId: user.id,
measurements,
medications,
intakeEvents,
moodEntries,
cycleProfile,
cycles,
cycleDayLogs,
const { payload } = await buildFullBackupPayload(prisma, user.id, {
purpose: "disaster-recovery",
exportedAt: now,
});
const ciphertext = encryptBackup(payload, cfg.encryptionKey);
const plaintext = JSON.stringify(payload);
const ciphertext = encryptBackup(plaintext, cfg.encryptionKey);
const key = `${dateKey}/user-${user.id}.json.enc`;
await s3.putObject(key, ciphertext);
uploaded++;
@@ -0,0 +1,111 @@
import { Buffer } from "node:buffer";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
buildFullBackupPayload: vi.fn(),
encrypt: vi.fn((value: string) => value),
getWorkerPrisma: vi.fn(),
upsert: vi.fn(),
}));
vi.mock("@/lib/export/full-backup-payload", () => ({
buildFullBackupPayload: mocks.buildFullBackupPayload,
}));
vi.mock("@/lib/crypto", () => ({ encrypt: mocks.encrypt }));
vi.mock("@/lib/logging/background", () => ({
withBackgroundEvent: vi.fn(
async (_name: string, run: (event: object) => Promise<void>) =>
run({
addMeta: vi.fn(),
addWarning: vi.fn(),
setBackground: vi.fn(),
setError: vi.fn(),
}),
),
}));
vi.mock("../shared", () => ({
getWorkerPrisma: mocks.getWorkerPrisma,
}));
import { handleDataBackup } from "../backup-handlers";
const documentCiphertext = Buffer.from([1, 2, 3, 4]).toString("base64");
function buildPrismaMock() {
return {
user: {
findMany: vi
.fn()
.mockResolvedValue([{ id: "user-dr", username: "backup-owner" }]),
},
dataBackup: { upsert: mocks.upsert },
};
}
describe("handleDataBackup canonical DR payload", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getWorkerPrisma.mockReturnValue(buildPrismaMock());
mocks.upsert.mockResolvedValue({});
mocks.buildFullBackupPayload.mockResolvedValue({
payload: {
schemaVersion: "1",
exportedAt: "2026-07-20T00:00:00.000Z",
userId: "user-dr",
moodEntries: [
{
id: "mood-dr",
externalId: "mood-external-dr",
factors: [{ key: "sleep_quality", rating: 5 }],
},
],
documents: [
{
id: "document-dr",
contentEncrypted: documentCiphertext,
contentCodec: "binary2",
},
],
},
counts: {},
});
});
it("serializes the shared canonical disaster-recovery payload", async () => {
await handleDataBackup([]);
const prisma = mocks.getWorkerPrisma.mock.results[0]!.value;
expect(mocks.buildFullBackupPayload).toHaveBeenCalledWith(
prisma,
"user-dr",
{ purpose: "disaster-recovery" },
);
expect(mocks.upsert).toHaveBeenCalledOnce();
const encrypted = mocks.upsert.mock.calls[0]![0].create.data as string;
const payload = JSON.parse(encrypted) as {
moodEntries: Array<{
id: string;
externalId: string;
factors: Array<{ key: string; rating: number }>;
}>;
documents: Array<{ contentEncrypted: string; contentCodec: string }>;
};
expect(payload.moodEntries).toEqual([
expect.objectContaining({
id: "mood-dr",
externalId: "mood-external-dr",
factors: [{ key: "sleep_quality", rating: 5 }],
}),
]);
expect(payload.documents).toEqual([
expect.objectContaining({
contentEncrypted: documentCiphertext,
contentCodec: "binary2",
}),
]);
});
});
+4 -124
View File
@@ -6,17 +6,10 @@
*/
import { type Job } from "pg-boss";
import { recordError } from "@/lib/jobs/worker-status";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { encrypt } from "@/lib/crypto";
import { withBackgroundEvent } from "@/lib/logging/background";
import { buildCycleBackupSection } from "@/lib/cycle/backup";
import { buildRecordsBackupSection } from "@/lib/export/records-backup";
import { runOffhostBackup } from "@/lib/jobs/offhost-backup";
import {
collectPagedMeasurements,
MEASUREMENT_BACKUP_PAGE_SIZE,
sortWeeklyMeasurementsDesc,
toWeeklyBackupMeasurement,
} from "@/lib/jobs/backup/measurement-page";
import { getWorkerPrisma } from "./shared";
export interface DataBackupPayload {
@@ -65,123 +58,10 @@ export async function handleDataBackup(jobs: Job<DataBackupPayload>[]) {
let backed = 0;
for (const user of users) {
try {
const [
measurements,
medications,
intakeEvents,
moodEntries,
cycle,
records,
] = await Promise.all([
// Keyset-paged narrow read instead of one full-set
// findMany + map: a heavy multi-year tenant otherwise
// loads every measurement ORM row into a single array
// alongside the giant JSON.stringify below, the worker's
// dominant heap spike. The page reader projects each page
// to the compact backup shape before the next page loads,
// bounding peak heap to the page size. Row scope is
// unchanged — no `deletedAt` filter, so tombstoned rows
// still round-trip exactly as before. The order is
// restored to measuredAt-desc to match the prior output.
collectPagedMeasurements({
fetchPage: (afterId, take) =>
prisma.measurement.findMany({
where: {
userId: user.id,
...(afterId ? { id: { gt: afterId } } : {}),
},
// Exactly the columns the admin restore recreates a
// row from, plus `id` for the keyset cursor.
select: {
id: true,
type: true,
value: true,
unit: true,
source: true,
measuredAt: true,
notes: true,
notesEncrypted: true,
},
orderBy: { id: "asc" },
take,
}),
project: toWeeklyBackupMeasurement,
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
}).then(sortWeeklyMeasurementsDesc),
prisma.medication.findMany({
where: { userId: user.id },
include: { schedules: true },
}),
prisma.medicationIntakeEvent.findMany({
where: { userId: user.id },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
}),
prisma.moodEntry.findMany({
where: { userId: user.id },
orderBy: { moodLoggedAt: "desc" },
}),
// v1.15.0 — cycle slice (shared helper, notesEncrypted verbatim).
buildCycleBackupSection(prisma, user.id),
// v1.28 backup-completeness — labs/biomarkers, illness episodes
// + day-logs, allergies, family history, workouts, and the
// documents manifest (shared helper — see records-backup.ts).
buildRecordsBackupSection(prisma, user.id),
]);
const backupJson = JSON.stringify({
// Bumped only when the on-disk shape changes incompatibly.
// Mirrors `BACKUP_SCHEMA_VERSION` in
// `src/lib/validations/backup.ts` — keep them in sync.
schemaVersion: "1",
exportedAt: new Date().toISOString(),
userId: user.id,
// Already projected + ordered by the paged reader above; the
// decrypted note rode into each row so an admin restore
// re-encrypts on re-insert (v1.23 contract).
measurements,
medications: medications.map((m) => ({
name: m.name,
dose: m.dose,
active: m.active,
schedules: m.schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label,
dose: s.dose,
})),
})),
intakeEvents: intakeEvents.map((e) => ({
medication: e.medication.name,
scheduledFor: e.scheduledFor.toISOString(),
takenAt: e.takenAt?.toISOString() ?? null,
skipped: e.skipped,
source: e.source,
})),
moodEntries: moodEntries.map((e) => ({
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags,
source: e.source,
loggedAt: e.moodLoggedAt.toISOString(),
})),
// v1.15.0 — cycle slice (profile + observed spans + day-logs).
cycleProfile: cycle.cycleProfile,
cycles: cycle.cycles,
cycleDayLogs: cycle.cycleDayLogs,
// v1.28 backup-completeness (see records-backup.ts) — export-only
// domains; the manifest field discloses the two deliberate
// exclusions (document binaries, workout GPS/sample series).
labResults: records.labResults,
biomarkers: records.biomarkers,
illnessEpisodes: records.illnessEpisodes,
allergies: records.allergies,
familyHistory: records.familyHistory,
workouts: records.workouts,
documents: records.documents,
manifest: records.manifest,
const { payload } = await buildFullBackupPayload(prisma, user.id, {
purpose: "disaster-recovery",
});
const backupJson = JSON.stringify(payload);
// Encrypt the backup data (contains sensitive health information)
const encryptedBackup = encrypt(backupJson);
@@ -69,13 +69,16 @@ import {
type QueuePolicyTable,
type ScheduleEntry,
} from "./registrar-shared";
import { WITHINGS_ECG_SYNC_QUEUE } from "@/lib/jobs/withings-ecg-queue";
import {
WithingsSyncPayload,
WithingsActivitySyncPayload,
WithingsSleepSyncPayload,
WithingsEcgSyncPayload,
handleWithingsFallbackSync,
handleWithingsActivitySync,
handleWithingsSleepSync,
handleWithingsEcgSync,
} from "./withings-sync";
import {
WhoopSyncPayload,
@@ -242,6 +245,7 @@ const allQueues = [
WITHINGS_SYNC_QUEUE,
WITHINGS_ACTIVITY_QUEUE,
WITHINGS_SLEEP_QUEUE,
WITHINGS_ECG_SYNC_QUEUE,
MOODLOG_SYNC_QUEUE,
WITHINGS_OAUTH_STATE_CLEANUP_QUEUE,
// v1.11.0 — WHOOP sync queues. Webhook-primary + cron-safety-net for
@@ -349,6 +353,11 @@ const schedules: ScheduleEntry[] = [
* the work is still outstanding, a suppressed send can never lose work.
*/
const queuePolicies: QueuePolicyTable = {
[WITHINGS_ECG_SYNC_QUEUE]: {
policy: "short",
reason:
"Webhook retries share one identity while queued; once work starts, a later replay remains admissible as a rescue if the original exhausts retries.",
},
[WHOOP_BACKFILL_QUEUE]: {
policy: "exclusive",
reason:
@@ -410,6 +419,11 @@ export async function registerIntegrationSyncQueues(
{ localConcurrency: 1 },
handleWithingsSleepSync,
);
await boss.work<WithingsEcgSyncPayload>(
WITHINGS_ECG_SYNC_QUEUE,
{ localConcurrency: 1 },
handleWithingsEcgSync,
);
await boss.work<WithingsOAuthStateCleanupPayload>(
WITHINGS_OAUTH_STATE_CLEANUP_QUEUE,
{ localConcurrency: 1 },
+34
View File
@@ -11,8 +11,11 @@ import { withBackgroundEvent } from "@/lib/logging/background";
import { syncUserMeasurements } from "@/lib/withings/sync";
import { syncUserActivity } from "@/lib/withings/sync-activity";
import { syncUserSleep } from "@/lib/withings/sync-sleep";
import { syncUserEcg } from "@/lib/withings/sync-ecg";
import type { WithingsEcgSyncPayload } from "@/lib/jobs/withings-ecg-queue";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { getWorkerPrisma } from "./shared";
export type { WithingsEcgSyncPayload } from "@/lib/jobs/withings-ecg-queue";
export interface WithingsSyncPayload {
triggeredAt: string;
@@ -96,6 +99,37 @@ export async function handleWithingsFallbackSync(
});
}
/**
* Drain webhook-owned ECG events. A failed source read or write is allowed to
* reject the handler so pg-boss retains the job and applies its retry policy.
*/
export async function handleWithingsEcgSync(
jobs: Job<WithingsEcgSyncPayload>[],
) {
await withBackgroundEvent("job.withings_ecg_sync", async (evt) => {
try {
let recordingsImported = 0;
for (const job of jobs) {
recordingsImported += await syncUserEcg(job.data.userId, {
startdate: job.data.startdate,
enddate: job.data.enddate,
});
}
evt.setBackground({
task_name: "job.withings_ecg_sync",
result: {
events_processed: jobs.length,
recordings_imported: recordingsImported,
},
});
} catch (err) {
evt.setError(err);
recordError();
throw err;
}
});
}
/**
* v1.4.25 W17b — activity-sync handler.
*
+64
View File
@@ -0,0 +1,64 @@
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { z } from "zod/v4";
const unixSecondsSchema = z.coerce.number().int().nonnegative().safe();
export const WITHINGS_ECG_SYNC_QUEUE = "withings-ecg-sync";
export interface WithingsEcgSyncPayload {
userId: string;
eventId: string;
triggeredAt: string;
startdate?: number;
enddate?: number;
}
export interface WithingsEcgEventIdentity {
userId: string;
withingsUserId: string;
appli: number | null;
startdate: string | null;
enddate: string | null;
}
export async function enqueueWithingsEcgSync(
identity: WithingsEcgEventIdentity,
): Promise<"enqueued" | "known"> {
const boss = getGlobalBoss();
if (!boss) {
throw new Error("pg-boss producer is unavailable");
}
const eventId = [
identity.withingsUserId,
identity.appli ?? "unknown",
identity.startdate ?? "unknown",
identity.enddate ?? "unknown",
].join(":");
const parsedStartdate =
identity.startdate === null
? null
: unixSecondsSchema.safeParse(identity.startdate);
const parsedEnddate =
identity.enddate === null
? null
: unixSecondsSchema.safeParse(identity.enddate);
const jobId = await boss.send(
WITHINGS_ECG_SYNC_QUEUE,
{
userId: identity.userId,
eventId,
triggeredAt: new Date().toISOString(),
...(parsedStartdate?.success ? { startdate: parsedStartdate.data } : {}),
...(parsedEnddate?.success ? { enddate: parsedEnddate.data } : {}),
} satisfies WithingsEcgSyncPayload,
{
singletonKey: `withings-ecg:${identity.userId}:${eventId}`,
retryLimit: 3,
retryDelay: 60,
retryBackoff: true,
},
);
return jobId ? "enqueued" : "known";
}
@@ -0,0 +1,231 @@
import { describe, expect, it, vi } from "vitest";
import type { Prisma } from "@/generated/prisma/client";
import {
reconcileExternalMeasurement,
type ExternalMeasurementWrite,
} from "../reconcile-external-measurement";
function desired(
overrides: Partial<ExternalMeasurementWrite> = {},
): ExternalMeasurementWrite {
return {
userId: "user-1",
type: "SLEEP_DURATION",
value: 42,
unit: "minutes",
source: "OURA",
measuredAt: new Date("2026-07-20T06:30:00.000Z"),
sleepStage: "DEEP",
externalId: "sleep:new",
externalSourceVersion: "v2",
deviceType: "ring",
...overrides,
};
}
function row(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: "row-1",
userId: "user-1",
type: "SLEEP_DURATION",
value: 30,
valueMin: null,
valueMax: null,
unit: "minutes",
source: "OURA",
measuredAt: new Date("2026-07-20T06:00:00.000Z"),
notes: null,
notesEncrypted: null,
externalId: "sleep:new",
externalSourceVersion: null,
glucoseContext: null,
sleepStage: "DEEP",
rhythmClassification: null,
deviceType: "ring",
syncVersion: 1,
deletedAt: null,
createdAt: new Date("2026-07-20T07:00:00.000Z"),
updatedAt: new Date("2026-07-20T07:00:00.000Z"),
...overrides,
};
}
function transaction(rows: Record<string, unknown>[]) {
const measurement = {
findMany: vi.fn().mockResolvedValue(rows),
create: vi.fn(),
update: vi.fn(),
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
findFirst: vi.fn().mockResolvedValue(null),
};
const tx = {
measurement,
$queryRaw: vi.fn().mockResolvedValue([]),
$executeRawUnsafe: vi.fn().mockResolvedValue(0),
} as unknown as Prisma.TransactionClient;
return { tx, measurement };
}
describe("reconcileExternalMeasurement", () => {
it("keeps the external-id row canonical when its desired natural key is occupied", async () => {
const external = row({ id: "external-row" });
const natural = row({
id: "natural-row",
externalId: "sleep:legacy",
measuredAt: new Date("2026-07-20T06:30:00.000Z"),
});
const { tx, measurement } = transaction([external, natural]);
measurement.update.mockResolvedValue(
row({
id: "external-row",
value: 42,
measuredAt: new Date("2026-07-20T06:30:00.000Z"),
externalSourceVersion: "v2",
}),
);
const verdict = await reconcileExternalMeasurement(tx, desired());
expect(verdict).toMatchObject({
status: "updated",
row: { id: "external-row", externalId: "sleep:new" },
retiredCollisionId: "natural-row",
});
expect(measurement.deleteMany).not.toHaveBeenCalled();
expect(measurement.update).toHaveBeenLastCalledWith(
expect.objectContaining({
where: { id: "external-row" },
data: expect.objectContaining({
value: 42,
measuredAt: new Date("2026-07-20T06:30:00.000Z"),
externalId: "sleep:new",
externalSourceVersion: "v2",
deviceType: "ring",
deletedAt: null,
}),
}),
);
});
it("retires the redundant collision as a durable tombstone", async () => {
const external = row({ id: "external-row" });
const natural = row({
id: "natural-row",
externalId: "sleep:legacy",
measuredAt: new Date("2026-07-20T06:30:00.000Z"),
});
const { tx, measurement } = transaction([external, natural]);
measurement.update
.mockResolvedValueOnce(
row({
id: "natural-row",
externalId: "sleep:legacy",
measuredAt: new Date(0),
deletedAt: expect.any(Date),
}),
)
.mockResolvedValueOnce(
row({
id: "external-row",
value: 42,
measuredAt: new Date("2026-07-20T06:30:00.000Z"),
}),
);
const verdict = await reconcileExternalMeasurement(tx, desired());
expect(verdict).toMatchObject({
status: "updated",
retiredCollisionId: "natural-row",
});
expect(measurement.deleteMany).not.toHaveBeenCalled();
expect(measurement.update).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
where: { id: "natural-row" },
data: expect.objectContaining({
measuredAt: new Date(0),
}),
}),
);
});
it("resurrects a tombstoned natural-key row and adopts the desired external identity", async () => {
const tombstone = row({
id: "tombstone-row",
externalId: "sleep:legacy",
measuredAt: new Date("2026-07-20T06:30:00.000Z"),
deletedAt: new Date("2026-07-20T08:00:00.000Z"),
});
const { tx, measurement } = transaction([tombstone]);
measurement.update.mockResolvedValue(
row({
id: "tombstone-row",
value: 42,
measuredAt: new Date("2026-07-20T06:30:00.000Z"),
deletedAt: null,
}),
);
const verdict = await reconcileExternalMeasurement(tx, desired());
expect(verdict).toMatchObject({
status: "resurrected",
row: { id: "tombstone-row", externalId: "sleep:new" },
});
});
it("returns duplicate for an immutable exact external/natural match", async () => {
const existing = row({
id: "exact-row",
measuredAt: new Date("2026-07-20T06:30:00.000Z"),
});
const { tx, measurement } = transaction([existing]);
const verdict = await reconcileExternalMeasurement(tx, desired(), {
exactExternalMatch: "duplicate",
});
expect(verdict).toMatchObject({
status: "duplicate",
row: { id: "exact-row" },
});
expect(measurement.update).not.toHaveBeenCalled();
expect(measurement.deleteMany).not.toHaveBeenCalled();
});
it("keeps an immutable exact-match tombstone deleted", async () => {
const tombstone = row({
id: "deleted-sample",
measuredAt: new Date("2026-07-20T06:30:00.000Z"),
deletedAt: new Date("2026-07-20T08:00:00.000Z"),
});
const { tx, measurement } = transaction([tombstone]);
const verdict = await reconcileExternalMeasurement(tx, desired(), {
exactExternalMatch: "duplicate",
});
expect(verdict.status).toBe("duplicate");
expect(measurement.update).not.toHaveBeenCalled();
});
it("rolls back its savepoint and returns failed for a non-benign write error", async () => {
const existing = row({
id: "exact-row",
measuredAt: new Date("2026-07-20T06:30:00.000Z"),
});
const { tx, measurement } = transaction([existing]);
measurement.update.mockRejectedValue(new Error("connection reset"));
const verdict = await reconcileExternalMeasurement(tx, desired());
expect(verdict).toEqual({
status: "failed",
error: { message: "connection reset" },
});
expect(tx.$executeRawUnsafe).toHaveBeenCalledWith(
"ROLLBACK TO SAVEPOINT measurement_identity_reconcile",
);
});
});
@@ -0,0 +1,296 @@
import {
Prisma,
type Measurement,
type Prisma as PrismaTypes,
} from "@/generated/prisma/client";
const SAVEPOINT = "measurement_identity_reconcile";
const rowSelect = {
id: true,
userId: true,
type: true,
source: true,
measuredAt: true,
sleepStage: true,
externalId: true,
deletedAt: true,
} satisfies PrismaTypes.MeasurementSelect;
export type ExternalMeasurementWrite = Omit<
PrismaTypes.MeasurementUncheckedCreateInput,
| "id"
| "createdAt"
| "updatedAt"
| "syncVersion"
| "deletedAt"
| "externalId"
| "measuredAt"
> & {
externalId: string;
measuredAt: Date;
};
export type ReconciledMeasurementRow = Pick<
Measurement,
"id" | "type" | "measuredAt" | "externalId"
>;
export type DirtyMeasurementIdentity = Pick<Measurement, "type" | "measuredAt">;
export type MeasurementReconciliationVerdict =
| {
status: "inserted" | "updated" | "resurrected" | "duplicate";
row: ReconciledMeasurementRow;
retiredCollisionId?: string;
dirtyIdentities?: DirtyMeasurementIdentity[];
}
| {
status: "failed";
error: { message: string; code?: string };
};
export type FailedMeasurementReconciliation = Extract<
MeasurementReconciliationVerdict,
{ status: "failed" }
>;
export class MeasurementReconciliationError extends Error {
readonly verdict: FailedMeasurementReconciliation;
constructor(verdict: FailedMeasurementReconciliation) {
super(verdict.error.message);
this.name = "MeasurementReconciliationError";
this.verdict = verdict;
}
}
export interface MeasurementReconciliationOptions {
/**
* Provider re-scores overwrite an exact identity match. Immutable sample
* uploads instead report an exact live match as a duplicate. Identity moves,
* collision merges, and tombstone resurrection always reconcile.
*/
exactExternalMatch?: "update" | "duplicate";
}
function errorVerdict(err: unknown): MeasurementReconciliationVerdict {
const message = err instanceof Error ? err.message : String(err);
if (err instanceof Prisma.PrismaClientKnownRequestError) {
return { status: "failed", error: { message, code: err.code } };
}
return { status: "failed", error: { message } };
}
function sameNaturalIdentity(
row: Pick<Measurement, "measuredAt" | "sleepStage">,
desired: ExternalMeasurementWrite,
): boolean {
return (
row.measuredAt.getTime() === desired.measuredAt.getTime() &&
row.sleepStage === (desired.sleepStage ?? null)
);
}
function lockKeys(desired: ExternalMeasurementWrite): string[] {
const prefix = [desired.userId, desired.type, desired.source];
return [
JSON.stringify([...prefix, "external", desired.externalId]),
JSON.stringify([
...prefix,
"natural",
desired.measuredAt.toISOString(),
desired.sleepStage ?? null,
]),
].sort();
}
async function lockUser(
tx: PrismaTypes.TransactionClient,
userId: string,
): Promise<void> {
const key = JSON.stringify(["measurement-reconcile-user", userId]);
await tx.$queryRaw(
Prisma.sql`
SELECT 1::int AS "locked"
FROM (
SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0))
) AS user_lock
`,
);
}
async function lockAndReloadCandidates(
tx: PrismaTypes.TransactionClient,
desired: ExternalMeasurementWrite,
): Promise<
Array<PrismaTypes.MeasurementGetPayload<{ select: typeof rowSelect }>>
> {
const where = {
userId: desired.userId,
OR: [
{
type: desired.type,
source: desired.source,
externalId: desired.externalId,
},
{
type: desired.type,
source: desired.source,
measuredAt: desired.measuredAt,
sleepStage: desired.sleepStage ?? null,
},
],
} satisfies PrismaTypes.MeasurementWhereInput;
const candidates = await tx.measurement.findMany({
where,
select: rowSelect,
});
if (candidates.length > 0) {
await tx.$queryRaw(
Prisma.sql`
SELECT "id"
FROM "measurements"
WHERE "id" IN (${Prisma.join(candidates.map((row) => row.id))})
FOR UPDATE
`,
);
}
return tx.measurement.findMany({ where, select: rowSelect });
}
async function retireCollision(
tx: PrismaTypes.TransactionClient,
row: PrismaTypes.MeasurementGetPayload<{ select: typeof rowSelect }>,
): Promise<void> {
let measuredAt = new Date(0);
while (
await tx.measurement.findFirst({
where: {
id: { not: row.id },
userId: row.userId,
type: row.type,
source: row.source,
measuredAt,
sleepStage: row.sleepStage,
},
select: { id: true },
})
) {
measuredAt = new Date(measuredAt.getTime() + 1);
}
await tx.measurement.update({
where: { id: row.id },
data: {
measuredAt,
deletedAt: new Date(),
syncVersion: { increment: 1 },
},
select: rowSelect,
});
}
/**
* Atomically reconcile the two full unique identities held by Measurement.
*
* The caller supplies an interactive Prisma transaction. A transaction-local
* advisory lock serializes cooperative writers sharing either identity, and a
* savepoint turns a non-benign database error into a structured `failed`
* verdict without poisoning the caller's surrounding transaction.
*/
export async function reconcileExternalMeasurement(
tx: PrismaTypes.TransactionClient,
desired: ExternalMeasurementWrite,
options: MeasurementReconciliationOptions = {},
): Promise<MeasurementReconciliationVerdict> {
await tx.$executeRawUnsafe(`SAVEPOINT ${SAVEPOINT}`);
try {
await lockUser(tx, desired.userId);
for (const key of lockKeys(desired)) {
await tx.$queryRaw(
Prisma.sql`
SELECT 1::int AS "locked"
FROM (
SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0))
) AS identity_lock
`,
);
}
const candidates = await lockAndReloadCandidates(tx, desired);
const externalHit = candidates.find(
(row) => row.externalId === desired.externalId,
);
const naturalHit = candidates.find((row) =>
sameNaturalIdentity(row, desired),
);
if (!externalHit && !naturalHit) {
const created = await tx.measurement.create({
data: {
...desired,
sleepStage: desired.sleepStage ?? null,
deletedAt: null,
},
select: rowSelect,
});
await tx.$executeRawUnsafe(`RELEASE SAVEPOINT ${SAVEPOINT}`);
return { status: "inserted", row: created };
}
const canonical = externalHit ?? naturalHit!;
const redundant =
externalHit && naturalHit && externalHit.id !== naturalHit.id
? naturalHit
: undefined;
const exactExternalMatch = externalHit?.id === naturalHit?.id;
if (
options.exactExternalMatch === "duplicate" &&
exactExternalMatch &&
!redundant
) {
await tx.$executeRawUnsafe(`RELEASE SAVEPOINT ${SAVEPOINT}`);
return { status: "duplicate", row: canonical };
}
if (redundant) {
await retireCollision(tx, redundant);
}
const { userId: _ownerId, ...mutable } = desired;
void _ownerId;
const updated = await tx.measurement.update({
where: { id: canonical.id },
data: {
...mutable,
sleepStage: desired.sleepStage ?? null,
deletedAt: null,
syncVersion: { increment: 1 },
},
select: rowSelect,
});
await tx.$executeRawUnsafe(`RELEASE SAVEPOINT ${SAVEPOINT}`);
return {
status: canonical.deletedAt === null ? "updated" : "resurrected",
row: updated,
dirtyIdentities: [
{ type: canonical.type, measuredAt: canonical.measuredAt },
...(redundant
? [{ type: redundant.type, measuredAt: redundant.measuredAt }]
: []),
],
...(redundant ? { retiredCollisionId: redundant.id } : {}),
};
} catch (err) {
try {
await tx.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${SAVEPOINT}`);
await tx.$executeRawUnsafe(`RELEASE SAVEPOINT ${SAVEPOINT}`);
} catch {
// The original database error is the actionable failure verdict.
}
return errorVerdict(err);
}
}
+51 -5
View File
@@ -45,8 +45,12 @@ beforeEach(() => {
});
/** Existing-link row as `replaceTagLinks` selects it. */
function link(moodTagId: string, isActive: boolean) {
return { moodTagId, moodTag: { isActive } };
function link(
moodTagId: string,
isActive: boolean,
kind: "BINARY" | "RATED" = "BINARY",
) {
return { moodTagId, moodTag: { isActive, kind } };
}
describe("resolveRatedFactors", () => {
@@ -196,6 +200,27 @@ describe("replaceTagLinks — archived-tag links survive an entry edit", () => {
expect(linkDeleteMany).not.toHaveBeenCalled();
});
it("preserves active RATED links while replacing binary links", async () => {
linkFindMany.mockResolvedValue([
link("mt_happy", true),
link("mt_factor_work", true, "RATED"),
]);
moodTagFindMany.mockResolvedValue([]);
await replaceTagLinks("entry-1", "user-1", []);
expect(linkDeleteMany).toHaveBeenCalledWith({
where: { moodEntryId: "entry-1", moodTagId: { in: ["mt_happy"] } },
});
expect(linkDeleteMany).not.toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
moodTagId: { in: expect.arrayContaining(["mt_factor_work"]) },
}),
}),
);
});
it("createTagLinks (the bulk-ingest path) is additive — it never deletes a link", async () => {
// The bulk route re-posts an entry via `createTagLinks`; an archived
// key cannot resolve (isActive pinned) but no existing link is ever
@@ -213,7 +238,7 @@ describe("replaceTagLinks — archived-tag links survive an entry edit", () => {
);
});
it("replaceRatedFactorLinks scopes its delete to active tags", async () => {
it("replaceRatedFactorLinks deletes every active RATED row, including a null-rated legacy link", async () => {
moodTagFindMany.mockResolvedValue([]);
await replaceRatedFactorLinks("entry-1", "user-1", []);
@@ -221,11 +246,32 @@ describe("replaceTagLinks — archived-tag links survive an entry edit", () => {
expect(linkDeleteMany).toHaveBeenCalledWith({
where: {
moodEntryId: "entry-1",
rating: { not: null },
moodTag: { isActive: true },
moodTag: { isActive: true, kind: "RATED" },
},
});
});
it("replaces RATED scores without deleting binary links", async () => {
moodTagFindMany.mockResolvedValue([
{ id: "mt_factor_work", key: "factor_work", scaleMin: 1, scaleMax: 5 },
]);
await replaceRatedFactorLinks("entry-1", "user-1", [
{ key: "factor_work", rating: 4 },
]);
expect(linkDeleteMany).toHaveBeenCalledWith({
where: {
moodEntryId: "entry-1",
moodTag: { isActive: true, kind: "RATED" },
},
});
expect(linkCreateMany).toHaveBeenCalledWith({
data: [
{ moodEntryId: "entry-1", moodTagId: "mt_factor_work", rating: 4 },
],
skipDuplicates: true,
});
});
});
describe("entry-ownership guard", () => {
+45 -26
View File
@@ -150,11 +150,16 @@ export async function resolveRatedFactors(
* the resolution is catalogue-only (`user_id IS NULL`), never matching any
* custom row — the safe default, since `{ userId: undefined }` in a Prisma
* filter would otherwise match every row.
*
* `kind` narrows resolution when a caller owns only one half of the split
* link contract. Edit-time `tagKeys` are binary-only; rated factors use
* `resolveRatedFactors` and carry their score separately.
*/
export async function resolveTagKeysToIds(
keys: string[],
db: TagLinkDb = prisma,
ownerUserId?: string,
kind?: "BINARY" | "RATED",
): Promise<string[]> {
const unique = Array.from(new Set(keys));
if (unique.length === 0) return [];
@@ -162,7 +167,12 @@ export async function resolveTagKeysToIds(
? { OR: [{ userId: null }, { userId: ownerUserId }] }
: { userId: null };
const rows = await db.moodTag.findMany({
where: { key: { in: unique }, isActive: true, ...ownerClause },
where: {
key: { in: unique },
isActive: true,
...(kind ? { kind } : {}),
...ownerClause,
},
select: { id: true },
});
return rows.map((row) => row.id);
@@ -221,12 +231,12 @@ export async function createTagLinks(
}
/**
* Replace the structured-tag link set for an entry. `keys` is the desired
* set; the helper deletes links no longer present and inserts the new
* ones, leaving unchanged links in place. Passing an empty array clears
* every ACTIVE link. Asserts the entry belongs to `userId` before
* touching links — a defensive guard against editing links on an entry
* the acting session does not own.
* Replace the active BINARY structured-tag link set for an entry. `keys` is
* the desired binary set; RATED links are owned by
* `replaceRatedFactorLinks` and remain untouched. Passing an empty array
* clears every active binary link. Asserts the entry belongs to `userId`
* before touching links — a defensive guard against editing links on an
* entry the acting session does not own.
*
* The submitted keys govern ACTIVE tag links only. `resolveTagKeysToIds`
* pins `isActive: true`, so an archived tag's key can never resolve back
@@ -246,15 +256,25 @@ export async function replaceTagLinks(
db: TagLinkDb = prisma,
): Promise<void> {
await assertEntryOwnership(moodEntryId, userId, db);
const desiredIds = new Set(await resolveTagKeysToIds(keys, db, userId));
const desiredIds = new Set(
await resolveTagKeysToIds(keys, db, userId, "BINARY"),
);
const existing = await db.moodEntryTagLink.findMany({
where: { moodEntryId },
select: { moodTagId: true, moodTag: { select: { isActive: true } } },
select: {
moodTagId: true,
moodTag: { select: { isActive: true, kind: true } },
},
});
const existingIds = new Set(existing.map((row) => row.moodTagId));
const toDelete = existing
.filter((row) => row.moodTag.isActive && !desiredIds.has(row.moodTagId))
.filter(
(row) =>
row.moodTag.isActive &&
row.moodTag.kind === "BINARY" &&
!desiredIds.has(row.moodTagId),
)
.map((row) => row.moodTagId);
const toCreate = [...desiredIds].filter((id) => !existingIds.has(id));
@@ -273,16 +293,13 @@ export async function replaceTagLinks(
/**
* v1.12.0 — replace the full rated-factor link set for an entry. A
* changed rating on the same factor is a real change (not a no-op), so
* the simplest correct shape is "delete every RATED link for the entry,
* re-insert the desired set". Binary links are left untouched. Passing an
* empty array clears every rated link. Throws `RatedFactorOutOfRangeError`
* (→ route 422) before any write when a rating is out of scale.
*
* Not wired into a route yet (the POST + bulk ingestion contract is the
* v1.12.0 scope); exported for the PATCH edit path a later wave adds.
* Asserts the entry belongs to `userId` before any write — a defensive
* guard for that future edit route against touching another user's entry.
* changed rating on the same factor is a real change, so the helper deletes
* every active RATED link and re-inserts the desired set. This also upgrades
* legacy/null-rated RATED rows without colliding with their join key. Binary
* links are left untouched. Passing an empty array clears every rated link.
* Throws `RatedFactorOutOfRangeError` (→ route 422) before any write when a
* rating is out of scale. Asserts the entry belongs to `userId` before any
* write.
*/
export async function replaceRatedFactorLinks(
moodEntryId: string,
@@ -295,13 +312,15 @@ export async function replaceRatedFactorLinks(
// aborts the replace cleanly.
const resolved = await resolveRatedFactors(factors, db);
// Drop every existing RATED link for the entry (a `rating IS NOT NULL`
// row is, by construction, a rated-factor link). Same archive contract
// as `replaceTagLinks`: a link whose tag has been archived since the
// entry was logged is history, not editable state — it survives the
// replace untouched.
// Drop every active RATED link, including a legacy row whose rating is
// null. Same archive contract as `replaceTagLinks`: a link whose tag has
// been archived since the entry was logged is history, not editable state
// and survives the replacement.
await db.moodEntryTagLink.deleteMany({
where: { moodEntryId, rating: { not: null }, moodTag: { isActive: true } },
where: {
moodEntryId,
moodTag: { isActive: true, kind: "RATED" },
},
});
if (resolved.length > 0) {
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
moodEntry: {
upsert: vi.fn(),
updateMany: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
import { prisma } from "@/lib/db";
import { persistMoodLogSourceEntry } from "../persistence";
describe("persistMoodLogSourceEntry concurrent legacy adoption", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("applies the losing replay payload to the row adopted by the winner", async () => {
const moodLoggedAt = new Date("2026-07-19T08:30:00.000Z");
vi.mocked(prisma.moodEntry.upsert).mockRejectedValueOnce(
Object.assign(new Error("natural-key collision"), { code: "P2002" }),
);
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValueOnce({
count: 0,
});
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValueOnce({
date: "2026-07-19",
moodLoggedAt,
} as never);
vi.mocked(prisma.moodEntry.update).mockResolvedValueOnce({} as never);
await persistMoodLogSourceEntry("user-1", {
externalId: "provider-event-1",
date: "2026-07-19",
moodLoggedAt,
mood: "SUPER_GUT",
score: 5,
tags: ["updated"],
});
expect(prisma.moodEntry.update).toHaveBeenCalledWith({
where: {
userId_source_externalId: {
userId: "user-1",
source: "MOODLOG",
externalId: "provider-event-1",
},
},
data: {
mood: "SUPER_GUT",
score: 5,
tags: JSON.stringify(["updated"]),
source: "MOODLOG",
deletedAt: null,
date: "2026-07-19",
moodLoggedAt,
},
});
});
});
+98
View File
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
moodEntry: {
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((value: string) => value),
}));
vi.mock("@/lib/validations/notifications", () => ({
isPublicUrl: vi.fn(() => true),
}));
vi.mock("@/lib/safe-fetch", () => ({
safeFetch: vi.fn(),
}));
vi.mock("@/lib/integrations/status", () => ({
isReauthRequired: vi.fn(),
recordSyncFailure: vi.fn(),
recordSyncSuccess: vi.fn(),
}));
vi.mock("@/lib/rollups/mood-rollups", () => ({
recomputeUserMoodRollups: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: vi.fn(() => ({
addExternalCall: vi.fn(),
addWarning: vi.fn(),
})),
}));
import { prisma } from "@/lib/db";
import {
isReauthRequired,
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
import { safeFetch } from "@/lib/safe-fetch";
import { syncMoodLogEntries } from "../sync";
const SOURCE_ENTRY = {
id: "provider-event-1",
date: "2026-07-19",
time: "2026-07-19T08:30:00.000Z",
mood: "GUT",
score: 4,
tags: ["walk"],
};
describe("syncMoodLogEntries persistence completion", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findUnique).mockResolvedValue({
moodLogEnabled: true,
moodLogUrlEncrypted: "https://moodlog.example.test",
moodLogApiKeyEncrypted: "api-key",
moodLogLastSyncedAt: new Date("2026-07-18T08:30:00.000Z"),
} as never);
vi.mocked(isReauthRequired).mockResolvedValue(false);
vi.mocked(safeFetch).mockResolvedValue(
new Response(JSON.stringify({ version: "1", entries: [SOURCE_ENTRY] }), {
status: 200,
}),
);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
});
it("holds the watermark and success marker when a source upsert fails", async () => {
const persistenceError = new Error("database unavailable");
vi.mocked(prisma.moodEntry.upsert).mockRejectedValueOnce(persistenceError);
await expect(syncMoodLogEntries("user-1")).rejects.toThrow(
"database unavailable",
);
expect(prisma.user.update).not.toHaveBeenCalled();
expect(recordSyncSuccess).not.toHaveBeenCalled();
expect(recordSyncFailure).toHaveBeenCalledWith({
userId: "user-1",
integration: "moodlog",
kind: "transient",
message: "database unavailable",
errorCode: "persistence_failed",
});
});
});
+132
View File
@@ -0,0 +1,132 @@
import { prisma } from "@/lib/db";
import { isP2002 } from "@/lib/prisma-errors";
export interface MoodLogSourceEntry {
externalId: string | null;
date: string;
moodLoggedAt: Date;
mood: string;
score: number;
tags?: readonly string[];
}
/**
* Persist one moodLog-owned source row using the provider id when available
* and the legacy wall-clock key otherwise.
*
* A provider may add an id after an older delivery already created the row by
* wall-clock key. In that case the external-id upsert collides with the legacy
* unique constraint; adopt that durable row and attach the provider id rather
* than creating a duplicate or retrying forever.
*/
export async function persistMoodLogSourceEntry(
userId: string,
entry: MoodLogSourceEntry,
): Promise<void> {
const tags = entry.tags ? JSON.stringify(entry.tags) : null;
const update = {
mood: entry.mood,
score: entry.score,
tags,
source: "MOODLOG",
deletedAt: null,
} as const;
if (!entry.externalId) {
await prisma.moodEntry.upsert({
where: {
userId_date_moodLoggedAt: {
userId,
date: entry.date,
moodLoggedAt: entry.moodLoggedAt,
},
},
update,
create: {
userId,
date: entry.date,
moodLoggedAt: entry.moodLoggedAt,
...update,
},
});
return;
}
let collisionError: unknown;
try {
await prisma.moodEntry.upsert({
where: {
userId_source_externalId: {
userId,
source: "MOODLOG",
externalId: entry.externalId,
},
},
update: {
...update,
date: entry.date,
moodLoggedAt: entry.moodLoggedAt,
},
create: {
userId,
date: entry.date,
moodLoggedAt: entry.moodLoggedAt,
externalId: entry.externalId,
...update,
},
});
return;
} catch (err) {
if (!isP2002(err)) throw err;
collisionError = err;
}
const adopted = await prisma.moodEntry.updateMany({
where: {
userId,
date: entry.date,
moodLoggedAt: entry.moodLoggedAt,
source: "MOODLOG",
externalId: null,
},
data: {
...update,
externalId: entry.externalId,
},
});
if (adopted.count === 1) return;
// A concurrent replay may have adopted the same legacy row first.
const winner = await prisma.moodEntry.findUnique({
where: {
userId_source_externalId: {
userId,
source: "MOODLOG",
externalId: entry.externalId,
},
},
select: { date: true, moodLoggedAt: true },
});
if (
winner?.date === entry.date &&
winner.moodLoggedAt.getTime() === entry.moodLoggedAt.getTime()
) {
await prisma.moodEntry.update({
where: {
userId_source_externalId: {
userId,
source: "MOODLOG",
externalId: entry.externalId,
},
},
data: {
...update,
date: entry.date,
moodLoggedAt: entry.moodLoggedAt,
},
});
return;
}
throw collisionError;
}
+19 -55
View File
@@ -8,6 +8,7 @@ import {
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
import { persistMoodLogSourceEntry } from "@/lib/moodlog/persistence";
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
/**
@@ -235,63 +236,26 @@ export async function syncMoodLogEntries(
? entry.id.slice(0, 120)
: null;
if (externalId) {
await prisma.moodEntry.upsert({
where: {
userId_source_externalId: {
userId,
source: "MOODLOG",
externalId,
},
},
update: {
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
date,
moodLoggedAt,
},
create: {
userId,
date,
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
externalId,
moodLoggedAt,
},
});
} else {
await prisma.moodEntry.upsert({
where: {
userId_date_moodLoggedAt: {
userId,
date,
moodLoggedAt,
},
},
update: {
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
},
create: {
userId,
date,
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
moodLoggedAt,
},
});
}
await persistMoodLogSourceEntry(userId, {
externalId,
date,
moodLoggedAt,
mood: entry.mood,
score: entry.score,
tags: entry.tags,
});
imported++;
} catch (err) {
getEvent()?.addWarning(`Failed to upsert entry: ${err}`);
const message = err instanceof Error ? err.message : String(err);
getEvent()?.addWarning(`Failed to upsert moodLog entry: ${message}`);
await recordSyncFailure({
userId,
integration: "moodlog",
kind: "transient",
message,
errorCode: "persistence_failed",
});
throw err;
}
}
+3 -2
View File
@@ -121,9 +121,9 @@ const batchEntryResult = z
.object({
index: z.number().int().nonnegative(),
status: z
.enum(["inserted", "updated", "duplicate", "skipped"])
.enum(["inserted", "updated", "duplicate", "skipped", "failed"])
.describe(
"`inserted`/`duplicate` — the row landed (advance the cursor). `updated` — a `stats:` aggregate (per-day cumulative total or hourly heart-rate bucket) overwrote an existing row. `skipped` — not stored (e.g. unmappable identifier, out-of-range value, or `malformed_hr_bucket_id`); see `reason`.",
"`inserted`/`duplicate` — the row landed (advance the cursor). `updated` — a `stats:` aggregate overwrote an existing row. `skipped` — validation no-op; see `reason`. `failed` — retryable database failure that must not advance the entry cursor; the response is marked `Cache-Control: no-store`.",
),
reason: z.string().optional(),
})
@@ -135,6 +135,7 @@ const batchResponse = z
inserted: z.number().int().nonnegative(),
updated: z.number().int().nonnegative(),
duplicates: z.number().int().nonnegative(),
failed: z.number().int().nonnegative(),
skipped: z.array(
z.object({
index: z.number().int().nonnegative(),
+63 -19
View File
@@ -23,6 +23,8 @@ const {
recordFailureMock,
recomputeMock,
invalidateMock,
transactionMock,
reconcileMock,
} = vi.hoisted(() => ({
getConnMock: vi.fn(),
storeTokensMock: vi.fn(),
@@ -46,6 +48,8 @@ const {
recordFailureMock: vi.fn(),
recomputeMock: vi.fn(),
invalidateMock: vi.fn(),
transactionMock: vi.fn(),
reconcileMock: vi.fn(),
}));
vi.mock("../credentials", () => ({
@@ -62,9 +66,15 @@ vi.mock("@/lib/db", () => ({
findMany: findManyMock,
updateMany: updateManyMock,
},
$transaction: transactionMock,
},
}));
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
reconcileExternalMeasurement: reconcileMock,
MeasurementReconciliationError: class extends Error {},
}));
vi.mock("@/lib/integrations/status", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/integrations/status")>()),
recordSyncSuccess: recordSuccessMock,
@@ -129,6 +139,25 @@ beforeEach(() => {
fetchResilienceMock.mockReset().mockResolvedValue([]);
fetchCyclePhasesMock.mockReset().mockResolvedValue([]);
refreshMock.mockReset();
transactionMock
.mockReset()
.mockImplementation(async (run: (tx: unknown) => unknown) => run({}));
reconcileMock
.mockReset()
.mockImplementation(
async (
_tx: unknown,
input: { type: string; measuredAt: Date; externalId: string },
) => ({
status: "inserted",
row: {
id: `inserted:${input.externalId}`,
type: input.type,
measuredAt: input.measuredAt,
externalId: input.externalId,
},
}),
);
createManyAndReturnMock.mockReset().mockImplementation(
async ({
data,
@@ -168,7 +197,7 @@ function err401() {
}
function createdRows() {
return createManyAndReturnMock.mock.calls.flatMap((call) => call[0].data);
return reconcileMock.mock.calls.map((call) => call[1]);
}
describe("syncUserOura", () => {
@@ -327,7 +356,7 @@ describe("syncUserOura", () => {
// Replace-then-write: the sweep runs before the insert statement.
expect(updateManyMock.mock.invocationCallOrder[0]!).toBeLessThan(
createManyAndReturnMock.mock.invocationCallOrder[0]!,
reconcileMock.mock.invocationCallOrder[0]!,
);
});
@@ -408,7 +437,7 @@ describe("syncUserOura", () => {
]);
const imported = await syncUserOura("u1");
expect(imported).toBe(0);
expect(createManyAndReturnMock).not.toHaveBeenCalled();
expect(reconcileMock).not.toHaveBeenCalled();
});
it("re-sync of the same day upserts RESILIENCE in place (overwrite, not duplicate)", async () => {
@@ -421,26 +450,34 @@ describe("syncUserOura", () => {
{ id: "r", day: "2026-06-10", level: "adequate" },
])
.mockResolvedValueOnce([{ id: "r", day: "2026-06-10", level: "solid" }]);
createManyAndReturnMock
.mockResolvedValueOnce([
{
reconcileMock
.mockResolvedValueOnce({
status: "inserted",
row: {
id: "inserted-resilience",
type: "RESILIENCE",
measuredAt: new Date("2026-06-10T00:00:00.000Z"),
externalId: "resilience:2026-06-10:resilience",
},
])
.mockResolvedValueOnce([]);
})
.mockResolvedValueOnce({
status: "updated",
row: {
id: "inserted-resilience",
type: "RESILIENCE",
measuredAt: new Date("2026-06-10T00:00:00.000Z"),
externalId: "resilience:2026-06-10:resilience",
},
});
await syncUserOura("u1");
await syncUserOura("u1");
const createCalls = createManyAndReturnMock.mock.calls;
expect(createCalls.map((call) => call[0].data[0].externalId)).toEqual([
const writes = reconcileMock.mock.calls.map((call) => call[1]);
expect(writes.map((row) => row.externalId)).toEqual([
"resilience:2026-06-10:resilience",
"resilience:2026-06-10:resilience",
]);
// The second sync carries the re-scored value through the update branch.
expect(createCalls[0]![0].data[0].value).toBe(2);
expect(updateMock.mock.calls[0]![0].data.value).toBe(3);
expect(writes[0].value).toBe(2);
expect(writes[1].value).toBe(3);
});
it("does NOT refresh on a 403 (not an expiry case)", async () => {
@@ -599,7 +636,17 @@ describe("upsertOuraMeasurements — exact insertion results", () => {
measuredAt,
externalId: readings[0].externalId,
};
createManyAndReturnMock.mockResolvedValue([inserted]);
reconcileMock
.mockResolvedValueOnce({ status: "inserted", row: inserted })
.mockResolvedValueOnce({
status: "updated",
row: {
id: "existing-2",
type: "HRV_RMSSD",
measuredAt,
externalId: readings[1].externalId,
},
});
const onInserted = vi.fn();
expect(await upsertOuraMeasurements("u1", readings, { onInserted })).toBe(
@@ -608,10 +655,7 @@ describe("upsertOuraMeasurements — exact insertion results", () => {
expect(onInserted).toHaveBeenCalledWith([inserted]);
expect(emitArrivalMock).toHaveBeenCalledWith("u1", [inserted], "oura");
expect(updateMock).toHaveBeenCalledTimes(1);
expect(
updateMock.mock.calls[0]![0].where.userId_type_source_externalId,
).toEqual({
expect(reconcileMock.mock.calls[1]![1]).toMatchObject({
userId: "u1",
type: "HRV_RMSSD",
source: "OURA",
@@ -627,7 +671,7 @@ describe("upsertOuraMeasurements — exact insertion results", () => {
measuredAt,
externalId: readings[0].externalId,
};
createManyAndReturnMock.mockResolvedValue([inserted]);
reconcileMock.mockResolvedValueOnce({ status: "inserted", row: inserted });
await upsertOuraMeasurements("u1", [readings[0]]);
+43 -66
View File
@@ -27,9 +27,9 @@
* Idempotency: `externalId = <resource>:<day>:<fieldTag>` for the day-keyed
* collections; sleep rows carry a record-scoped `sleep:<record-id>:<fieldTag>`
* key (per-segment timeline + nightly scalars) so a nap and the main sleep on
* one day stay distinct instead of overwriting each other (B2). Upsert keyed on
* `(userId, type, source = OURA, externalId)`. Oura finalises a day's scores
* after the night, so the `update` branch overwrites in place (re-score).
* one day stay distinct instead of overwriting each other (B2). The shared
* reconciler protects both external and natural identity. Oura finalises a
* day's scores after the night, so an exact re-post overwrites in place.
*
* The measurement-write tail mirrors the shared WHOOP / Nightscout sync tail.
*/
@@ -56,6 +56,10 @@ import {
sweepStaleSleepSegments,
type SleepSegmentSweep,
} from "@/lib/sleep/sweep-stale-segments";
import {
MeasurementReconciliationError,
reconcileExternalMeasurement,
} from "@/lib/measurements/reconcile-external-measurement";
import {
fetchCardiovascularAge,
fetchDailyActivity,
@@ -453,76 +457,49 @@ export async function upsertOuraMeasurements(
let imported = 0;
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
let insertedRows: Array<
const insertedRows: Array<
InsertedMeasurementArrivalRow & { externalId: string | null }
> = [];
try {
insertedRows = await prisma.measurement.createManyAndReturn({
data: readings.map((r) => ({
userId,
type: r.type as MeasurementType,
source: "OURA" as const,
value: r.value,
unit: r.unit,
measuredAt: r.measuredAt,
externalId: r.externalId,
sleepStage: r.sleepStage ?? null,
})),
skipDuplicates: true,
select: {
id: true,
type: true,
measuredAt: true,
externalId: true,
},
});
imported += insertedRows.length;
for (const row of insertedRows) {
touched.push({ type: row.type, measuredAt: row.measuredAt });
}
} catch (err) {
getEvent()?.addWarning(`oura: failed to create measurements: ${err}`);
}
const insertedIdentityCounts = new Map<string, number>();
for (const row of insertedRows) {
const key = `${row.type}:${row.externalId ?? ""}`;
insertedIdentityCounts.set(key, (insertedIdentityCounts.get(key) ?? 0) + 1);
}
for (const r of readings) {
const type = r.type as MeasurementType;
const key = `${type}:${r.externalId}`;
const insertedCount = insertedIdentityCounts.get(key) ?? 0;
if (insertedCount > 0) {
insertedIdentityCounts.set(key, insertedCount - 1);
continue;
}
try {
await prisma.measurement.update({
where: {
userId_type_source_externalId: {
const verdicts = await prisma.$transaction(
async (tx) => {
const outcomes = [];
for (const r of readings) {
const verdict = await reconcileExternalMeasurement(
tx,
{
userId,
type,
type: r.type as MeasurementType,
source: "OURA",
value: r.value,
unit: r.unit,
measuredAt: r.measuredAt,
externalId: r.externalId,
sleepStage: r.sleepStage ?? null,
},
},
data: {
value: r.value,
unit: r.unit,
measuredAt: r.measuredAt,
sleepStage: r.sleepStage ?? null,
deletedAt: null,
syncVersion: { increment: 1 },
},
});
touched.push({ type, measuredAt: r.measuredAt });
imported++;
} catch (err) {
getEvent()?.addWarning(`oura: failed to update measurement: ${err}`);
{ exactExternalMatch: "update" },
);
if (verdict.status === "failed") {
throw new MeasurementReconciliationError(verdict);
}
outcomes.push(verdict);
}
return outcomes;
},
{ timeout: 60_000 },
);
for (let index = 0; index < readings.length; index++) {
const reading = readings[index]!;
const verdict = verdicts[index]!;
for (const dirty of verdict.dirtyIdentities ?? []) {
touched.push(dirty);
}
const type = reading.type as MeasurementType;
imported++;
touched.push({ type, measuredAt: reading.measuredAt });
if (verdict.status === "inserted") {
insertedRows.push(verdict.row);
}
}
+107 -23
View File
@@ -3,8 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
POLAR_OAUTH_SCOPE,
exchangeCode,
fetchActivities,
fetchCardioLoads,
fetchNightlyRecharges,
fetchSleeps,
fetchSpo2,
getAuthorizationUrl,
getPolarCredentials,
@@ -99,6 +101,12 @@ describe("exchangeCode", () => {
const tok = await exchangeCode("code123", CREDS);
expect(tok.access_token).toBe("tok");
expect(tok.x_user_id).toBe(42);
expect(fetchMock.mock.calls[0]![0]).toBe(
"https://polarremote.com/v2/oauth2/token",
);
expect(fetchMock.mock.calls[0]![1]).toEqual(
expect.objectContaining({ method: "POST" }),
);
const [, init] = fetchMock.mock.calls[0]!;
const headers = init!.headers as Record<string, string>;
expect(headers.Authorization).toBe(
@@ -120,9 +128,13 @@ describe("registerUser", () => {
installFetchMock([{ status: 409, body: {} }]);
await expect(registerUser("tok", "42")).resolves.toBeUndefined();
});
it("succeeds on a 200", async () => {
installFetchMock([{ status: 200, body: {} }]);
it("posts to the documented registration path", async () => {
const fetchMock = installFetchMock([{ status: 200, body: {} }]);
await expect(registerUser("tok", "42")).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith(
"https://www.polaraccesslink.com/v3/users",
expect.objectContaining({ method: "POST" }),
);
});
it("throws on a 500", async () => {
installFetchMock([{ status: 500 }]);
@@ -132,16 +144,55 @@ describe("registerUser", () => {
});
});
describe("Polar AccessLink request paths", () => {
it.each([
{
resource: "nightly recharge",
request: () => fetchNightlyRecharges("tok"),
url: "https://www.polaraccesslink.com/v3/users/nightly-recharge",
},
{
resource: "sleep",
request: () => fetchSleeps("tok"),
url: "https://www.polaraccesslink.com/v3/users/sleep",
},
{
resource: "daily activity",
request: () => fetchActivities("tok"),
url: "https://www.polaraccesslink.com/v3/users/activities",
},
{
resource: "cardio load",
request: () => fetchCardioLoads("tok"),
url: "https://www.polaraccesslink.com/v3/users/cardio-load",
},
{
resource: "SpO2",
request: () => fetchSpo2("tok"),
url: "https://www.polaraccesslink.com/v3/users/biosensing/spo2",
},
])("uses the documented GET path for $resource", async ({ request, url }) => {
const fetchMock = installFetchMock([{ status: 204 }]);
await request();
expect(fetchMock).toHaveBeenCalledWith(
url,
expect.objectContaining({ method: "GET" }),
);
});
});
describe("fetchNightlyRecharges", () => {
it("returns [] on a 204 No Content", async () => {
installFetchMock([{ status: 204 }]);
expect(await fetchNightlyRecharges("tok", "42")).toEqual([]);
expect(await fetchNightlyRecharges("tok")).toEqual([]);
});
it("unwraps the recharges array", async () => {
installFetchMock([
{ status: 200, body: { recharges: [{ date: "2026-06-10" }] } },
]);
const r = await fetchNightlyRecharges("tok", "42");
const r = await fetchNightlyRecharges("tok");
expect(r).toHaveLength(1);
expect(r[0]!.date).toBe("2026-06-10");
});
@@ -152,7 +203,7 @@ describe("mapNightlyRecharge", () => {
const rec: PolarNightlyRecharge = {
date: "2026-06-10",
nightly_recharge_status: 6,
hrv_avg: 55,
heart_rate_variability_avg: 55,
heart_rate_avg: 52.4,
breathing_rate_avg: 14.2,
};
@@ -364,8 +415,8 @@ describe("mapSleep", () => {
describe("mapActivity — distance", () => {
it("maps distance_from_steps to WALKING_RUNNING_DISTANCE in metres", () => {
const a: PolarActivity = {
date: "2026-06-10",
"active-steps": 8000,
start_time: "2026-06-10T00:00:00",
steps: 8000,
distance_from_steps: 4590.53,
};
const dist = mapActivity(a).find(
@@ -393,39 +444,69 @@ describe("mapCardioLoad", () => {
});
describe("mapSpo2", () => {
it("maps blood_oxygen_percentage to OXYGEN_SATURATION (percent)", () => {
const r: PolarSpo2 = { date: "2026-06-10", blood_oxygen_percentage: 96.4 };
it("maps the documented SpO2 fields to an exact-timestamp reading", () => {
const r: PolarSpo2 = {
test_time: 1_781_059_600,
blood_oxygen_percent: 96,
source_device_id: "device-1",
};
const mapped = mapSpo2(r);
expect(mapped[0]?.type).toBe("OXYGEN_SATURATION");
expect(mapped[0]?.value).toBe(96.4);
expect(mapped[0]?.value).toBe(96);
expect(mapped[0]?.unit).toBe("%");
expect(mapped[0]?.measuredAt.toISOString()).toBe(
"2026-06-10T02:46:40.000Z",
);
expect(mapped[0]?.externalId).toBe("spo2:1781059600:device-1");
});
it("rejects out-of-range readings", () => {
expect(mapSpo2({ date: "2026-06-10", blood_oxygen_percentage: 0 })).toEqual(
[],
);
expect(
mapSpo2({ date: "2026-06-10", blood_oxygen_percentage: 120 }),
mapSpo2({ test_time: 1_781_059_600, blood_oxygen_percent: 0 }),
).toEqual([]);
expect(
mapSpo2({ test_time: 1_781_059_600, blood_oxygen_percent: 120 }),
).toEqual([]);
});
});
describe("fetchCardioLoads", () => {
it("unwraps the cardio-loads array", async () => {
it("returns the documented top-level cardio-load array", async () => {
installFetchMock([
{ status: 200, body: { "cardio-loads": [{ date: "2026-06-10" }] } },
{ status: 200, body: [{ date: "2026-06-10", cardio_load: 123.45 }] },
]);
const r = await fetchCardioLoads("tok", "42");
const r = await fetchCardioLoads("tok");
expect(r).toHaveLength(1);
});
});
describe("fetchSpo2", () => {
it("unwraps the tests array", async () => {
it("returns the documented top-level SpO2 array", async () => {
installFetchMock([
{ status: 200, body: { tests: [{ date: "2026-06-10" }] } },
{
status: 200,
body: [{ test_time: 1_781_059_600, blood_oxygen_percent: 96 }],
},
]);
const r = await fetchSpo2("tok", "42");
const r = await fetchSpo2("tok");
expect(r).toHaveLength(1);
});
});
describe("fetchActivities", () => {
it("returns the documented top-level activity array", async () => {
installFetchMock([
{
status: 200,
body: [
{
start_time: "2026-06-10T00:00:00",
active_calories: 540.6,
steps: 8123,
},
],
},
]);
const r = await fetchActivities("tok");
expect(r).toHaveLength(1);
});
});
@@ -433,9 +514,9 @@ describe("fetchSpo2", () => {
describe("mapActivity", () => {
it("maps steps and active energy to canonical MeasurementType enum values", () => {
const a: PolarActivity = {
date: "2026-06-10",
"active-steps": 8123,
"active-calories": 540.6,
start_time: "2026-06-10T00:00:00",
steps: 8123,
active_calories: 540.6,
calories: 2400,
};
const mapped = mapActivity(a);
@@ -445,6 +526,9 @@ describe("mapActivity", () => {
540.6,
);
});
it("ignores an activity when Polar omits its optional start time", () => {
expect(mapActivity({})).toEqual([]);
});
});
describe("POLAR_OAUTH_SCOPE", () => {
+72 -16
View File
@@ -15,6 +15,8 @@ const {
recordSuccessMock,
recordFailureMock,
recomputeMock,
transactionMock,
reconcileMock,
invalidateMock,
} = vi.hoisted(() => ({
getConnMock: vi.fn(),
@@ -31,6 +33,8 @@ const {
recordSuccessMock: vi.fn(),
recordFailureMock: vi.fn(),
recomputeMock: vi.fn(),
transactionMock: vi.fn(),
reconcileMock: vi.fn(),
invalidateMock: vi.fn(),
}));
@@ -46,9 +50,15 @@ vi.mock("@/lib/db", () => ({
findMany: findManyMock,
updateMany: updateManyMock,
},
$transaction: transactionMock,
},
}));
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
reconcileExternalMeasurement: reconcileMock,
MeasurementReconciliationError: class extends Error {},
}));
vi.mock("@/lib/integrations/status", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/integrations/status")>()),
recordSyncSuccess: recordSuccessMock,
@@ -93,6 +103,25 @@ beforeEach(() => {
fetchActivitiesMock.mockReset();
fetchCardioLoadsMock.mockReset();
fetchSpo2Mock.mockReset();
transactionMock
.mockReset()
.mockImplementation(async (run: (tx: unknown) => unknown) => run({}));
reconcileMock
.mockReset()
.mockImplementation(
async (
_tx: unknown,
input: { type: string; measuredAt: Date; externalId: string },
) => ({
status: "inserted",
row: {
id: `inserted:${input.externalId}`,
type: input.type,
measuredAt: input.measuredAt,
externalId: input.externalId,
},
}),
);
createManyAndReturnMock.mockReset().mockImplementation(
async ({
data,
@@ -128,7 +157,7 @@ beforeEach(() => {
afterEach(() => vi.clearAllMocks());
function createdRows() {
return createManyAndReturnMock.mock.calls.flatMap((call) => call[0].data);
return reconcileMock.mock.calls.map((call) => call[1]);
}
describe("syncUserPolar", () => {
@@ -139,6 +168,18 @@ describe("syncUserPolar", () => {
expect(recordSuccessMock).not.toHaveBeenCalled();
});
it("authenticates all collection reads with the access token only", async () => {
getConnMock.mockResolvedValue(CONN);
await syncUserPolar("u1");
expect(fetchRechargesMock).toHaveBeenCalledWith("tok");
expect(fetchSleepsMock).toHaveBeenCalledWith("tok");
expect(fetchActivitiesMock).toHaveBeenCalledWith("tok");
expect(fetchCardioLoadsMock).toHaveBeenCalledWith("tok");
expect(fetchSpo2Mock).toHaveBeenCalledWith("tok");
});
it("maps a recharge RECOVERY_SCORE row with source POLAR + stable externalId", async () => {
getConnMock.mockResolvedValue(CONN);
fetchRechargesMock.mockResolvedValue([
@@ -245,7 +286,7 @@ describe("syncUserPolar", () => {
// Replace-then-write: the sweep runs before the insert statement.
expect(updateManyMock.mock.invocationCallOrder[0]!).toBeLessThan(
createManyAndReturnMock.mock.invocationCallOrder[0]!,
reconcileMock.mock.invocationCallOrder[0]!,
);
});
@@ -263,21 +304,29 @@ describe("syncUserPolar", () => {
fetchRechargesMock.mockResolvedValue([
{ date: "2026-06-10", nightly_recharge_status: 3 },
]);
createManyAndReturnMock
.mockResolvedValueOnce([
{
reconcileMock
.mockResolvedValueOnce({
status: "inserted",
row: {
id: "inserted-recharge",
type: "RECOVERY_SCORE",
measuredAt: new Date("2026-06-10T00:00:00.000Z"),
externalId: "recharge:2026-06-10:recovery",
},
])
.mockResolvedValueOnce([]);
})
.mockResolvedValueOnce({
status: "updated",
row: {
id: "inserted-recharge",
type: "RECOVERY_SCORE",
measuredAt: new Date("2026-06-10T00:00:00.000Z"),
externalId: "recharge:2026-06-10:recovery",
},
});
await syncUserPolar("u1");
await syncUserPolar("u1");
expect(createManyAndReturnMock.mock.calls[0]![0].data[0].externalId).toBe(
updateMock.mock.calls[0]![0].where.userId_type_source_externalId
.externalId,
expect(reconcileMock.mock.calls[0]![1].externalId).toBe(
reconcileMock.mock.calls[1]![1].externalId,
);
});
@@ -330,7 +379,17 @@ describe("upsertPolarMeasurements — exact insertion results", () => {
measuredAt,
externalId: readings[0].externalId,
};
createManyAndReturnMock.mockResolvedValue([inserted]);
reconcileMock
.mockResolvedValueOnce({ status: "inserted", row: inserted })
.mockResolvedValueOnce({
status: "updated",
row: {
id: "existing-2",
type: "CARDIO_LOAD",
measuredAt,
externalId: readings[1].externalId,
},
});
const onInserted = vi.fn();
expect(await upsertPolarMeasurements("u1", readings, { onInserted })).toBe(
@@ -339,10 +398,7 @@ describe("upsertPolarMeasurements — exact insertion results", () => {
expect(onInserted).toHaveBeenCalledWith([inserted]);
expect(emitArrivalMock).toHaveBeenCalledWith("u1", [inserted], "polar");
expect(updateMock).toHaveBeenCalledTimes(1);
expect(
updateMock.mock.calls[0]![0].where.userId_type_source_externalId,
).toEqual({
expect(reconcileMock.mock.calls[1]![1]).toMatchObject({
userId: "u1",
type: "CARDIO_LOAD",
source: "POLAR",
@@ -358,7 +414,7 @@ describe("upsertPolarMeasurements — exact insertion results", () => {
measuredAt,
externalId: readings[0].externalId,
};
createManyAndReturnMock.mockResolvedValue([inserted]);
reconcileMock.mockResolvedValueOnce({ status: "inserted", row: inserted });
await upsertPolarMeasurements("u1", [readings[0]]);
+45 -53
View File
@@ -13,9 +13,9 @@
* - Polar access tokens DO NOT expire and there is NO refresh token. The
* `User.polarRefreshTokenEncrypted` column stays null; `getValidToken`
* never refreshes. A revoked grant surfaces as a 401 on the next data read.
* - The token response carries `x_user_id` — Polar's numeric member id — which
* every data path needs (`/v3/users/{userId}/...`). It is persisted as
* `User.polarUserIdEncrypted` at connect time.
* - The token response carries `x_user_id` — Polar's numeric member id. It
* remains necessary for user registration and direct user-information
* lookups, while collection reads identify the user from the access token.
* - The client must register the user once (`POST /v3/users`) before data
* reads work; a 409 from registration means "already registered" and is
* treated as success.
@@ -180,7 +180,7 @@ export interface PolarNightlyRecharge {
ans_charge?: number | null;
ans_charge_status?: number | null;
heart_rate_avg?: number | null;
hrv_avg?: number | null;
heart_rate_variability_avg?: number | null;
breathing_rate_avg?: number | null;
}
@@ -198,11 +198,11 @@ export interface PolarSleep {
heart_rate_samples?: Record<string, number>;
}
/** One Polar daily-activity summary. */
/** One current AccessLink daily-activity summary. */
export interface PolarActivity {
date: string;
"active-calories"?: number | null;
"active-steps"?: number | null;
start_time?: string;
active_calories?: number | null;
steps?: number | null;
calories?: number | null;
/** Estimated walking/running distance derived from steps, in METRES. */
distance_from_steps?: number | null;
@@ -223,11 +223,12 @@ export interface PolarCardioLoad {
cardio_load_ratio?: number | null;
}
/** One Polar SpO2 (Elixir pulse-ox) test result. `blood_oxygen_percentage` is
* the recorded SpO2 reading in percent (0..100). */
/** One Polar SpO2 (Elixir pulse-ox) test result. `test_time` is a Unix
* timestamp in seconds; `blood_oxygen_percent` is the recorded percentage. */
export interface PolarSpo2 {
date: string;
blood_oxygen_percentage?: number | null;
source_device_id?: string;
test_time?: number;
blood_oxygen_percent?: number | null;
}
interface CollectionResult<T> {
@@ -238,7 +239,7 @@ async function fetchCollection<T>(
path: string,
accessToken: string,
verb: string,
recordsKey: string,
recordsKey?: string,
): Promise<T[]> {
const start = performance.now();
const res = await safeFetch(`${POLAR_API_BASE}${path}`, {
@@ -267,73 +268,60 @@ async function fetchCollection<T>(
// 204 No Content — a window with no records. Polar returns an empty body.
if (res.status === 204) return [];
const json = (await res.json().catch(() => null)) as
CollectionResult<T> | Record<string, T[]> | null;
CollectionResult<T> | Record<string, T[]> | T[] | null;
if (!json) return [];
if (Array.isArray(json)) return json;
if (!recordsKey) return [];
const list = (json as Record<string, unknown>)[recordsKey];
return Array.isArray(list) ? (list as T[]) : [];
}
export function fetchNightlyRecharges(
accessToken: string,
userId: string,
): Promise<PolarNightlyRecharge[]> {
return fetchCollection<PolarNightlyRecharge>(
`/v3/users/${encodeURIComponent(userId)}/nightly-recharge`,
"/v3/users/nightly-recharge",
accessToken,
"fetchNightlyRecharges",
"recharges",
);
}
export function fetchSleeps(
accessToken: string,
userId: string,
): Promise<PolarSleep[]> {
export function fetchSleeps(accessToken: string): Promise<PolarSleep[]> {
return fetchCollection<PolarSleep>(
`/v3/users/${encodeURIComponent(userId)}/sleep`,
"/v3/users/sleep",
accessToken,
"fetchSleeps",
"nights",
);
}
export function fetchActivities(
accessToken: string,
userId: string,
): Promise<PolarActivity[]> {
export function fetchActivities(accessToken: string): Promise<PolarActivity[]> {
return fetchCollection<PolarActivity>(
`/v3/users/${encodeURIComponent(userId)}/activity-transactions`,
"/v3/users/activities",
accessToken,
"fetchActivities",
"activity-log",
);
}
/** Training Load Pro collection — `cardio_load` strain figures for the recent
* window. Records live under the `cardio-loads` key. */
* window. */
export function fetchCardioLoads(
accessToken: string,
userId: string,
): Promise<PolarCardioLoad[]> {
return fetchCollection<PolarCardioLoad>(
`/v3/users/${encodeURIComponent(userId)}/cardio-load`,
"/v3/users/cardio-load",
accessToken,
"fetchCardioLoads",
"cardio-loads",
);
}
/** SpO2 (Elixir pulse-ox) collection — nightly blood-oxygen readings. Records
* live under the `tests` key. */
export function fetchSpo2(
accessToken: string,
userId: string,
): Promise<PolarSpo2[]> {
/** SpO2 (Elixir pulse-ox) collection — nightly blood-oxygen readings. */
export function fetchSpo2(accessToken: string): Promise<PolarSpo2[]> {
return fetchCollection<PolarSpo2>(
`/v3/users/${encodeURIComponent(userId)}/spo2-tests`,
"/v3/users/biosensing/spo2",
accessToken,
"fetchSpo2",
"tests",
);
}
@@ -405,10 +393,13 @@ export function mapNightlyRecharge(
fieldTag: "recovery",
});
}
if (typeof r.hrv_avg === "number" && r.hrv_avg > 0) {
if (
typeof r.heart_rate_variability_avg === "number" &&
r.heart_rate_variability_avg > 0
) {
out.push({
type: "HRV_RMSSD",
value: round2(r.hrv_avg),
value: round2(r.heart_rate_variability_avg),
unit: "ms",
measuredAt,
fieldTag: "hrv_rmssd",
@@ -567,16 +558,15 @@ export function mapSleep(s: PolarSleep): MappedMeasurement[] {
return out;
}
/** Map one Polar daily-activity record: `ACTIVITY_STEPS` +
* `ACTIVE_ENERGY_BURNED` where Polar reports them. Steps live under
* `active-steps`; the ACTIVE energy portion under `active-calories` (NOT the
* total `calories`, which includes BMR — matching the Fitbit active-only
* convention). */
/** Map one current AccessLink daily-activity record: `steps` +
* `active_calories` where Polar reports them. The active energy portion is
* used instead of total `calories`, which includes BMR. */
export function mapActivity(a: PolarActivity): MappedMeasurement[] {
const measuredAt = canonicalDailyTimestamp(a.date);
if (typeof a.start_time !== "string") return [];
const measuredAt = canonicalDailyTimestamp(a.start_time.slice(0, 10));
if (Number.isNaN(measuredAt.getTime())) return [];
const out: MappedMeasurement[] = [];
const steps = a["active-steps"];
const steps = a.steps;
if (typeof steps === "number" && steps >= 0) {
out.push({
type: "ACTIVITY_STEPS",
@@ -586,7 +576,7 @@ export function mapActivity(a: PolarActivity): MappedMeasurement[] {
fieldTag: "steps",
});
}
const activeCalories = a["active-calories"];
const activeCalories = a.active_calories;
if (typeof activeCalories === "number" && activeCalories >= 0) {
out.push({
type: "ACTIVE_ENERGY_BURNED",
@@ -630,12 +620,13 @@ export function mapCardioLoad(c: PolarCardioLoad): MappedMeasurement[] {
];
}
/** Map one Polar SpO2 test result: `blood_oxygen_percentage` →
* `OXYGEN_SATURATION` (percent, 0..100). */
/** Map one Polar SpO2 test result: `blood_oxygen_percent` →
* `OXYGEN_SATURATION` at the test's exact Unix timestamp. */
export function mapSpo2(r: PolarSpo2): MappedMeasurement[] {
const measuredAt = canonicalDailyTimestamp(r.date);
if (typeof r.test_time !== "number") return [];
const measuredAt = new Date(r.test_time * 1000);
if (Number.isNaN(measuredAt.getTime())) return [];
const pct = r.blood_oxygen_percentage;
const pct = r.blood_oxygen_percent;
if (typeof pct !== "number" || pct <= 0 || pct > 100) return [];
return [
{
@@ -644,6 +635,7 @@ export function mapSpo2(r: PolarSpo2): MappedMeasurement[] {
unit: "%",
measuredAt,
fieldTag: "spo2",
externalId: `spo2:${r.test_time}:${r.source_device_id ?? "unknown"}`,
},
];
}
+52 -74
View File
@@ -12,10 +12,10 @@
* as a 401 → `reauth_required` on the shared integration ledger (`polar` key),
* exactly like Nightscout's token-rejected path.
*
* Idempotency: each row's `externalId` is `<date>:<fieldTag>` and the write is
* an upsert keyed on `(userId, type, source = POLAR, externalId)`. Polar
* re-scores a night for a short window after the fact, so the `update` branch
* overwrites in place (WHOOP-style re-score), not first-write-wins.
* Idempotency: each row's `externalId` is `<date>:<fieldTag>`. The shared
* reconciler protects both external and natural identity. Polar re-scores a
* night for a short window after the fact, so an exact re-post overwrites in
* place (WHOOP-style re-score), not first-write-wins.
*
* The measurement-write tail (per-row upsert → rollup fold → status-insight
* invalidate) mirrors the shared WHOOP / Nightscout sync tail; it is NOT a new
@@ -40,6 +40,10 @@ import {
emitInsertedMeasurementArrivals,
type InsertedMeasurementArrivalRow,
} from "@/lib/arrivals/measurement-emit";
import {
MeasurementReconciliationError,
reconcileExternalMeasurement,
} from "@/lib/measurements/reconcile-external-measurement";
import {
fetchActivities,
fetchCardioLoads,
@@ -111,11 +115,11 @@ export async function syncUserPolar(userId: string): Promise<number> {
try {
const [recharges, sleeps, activities, cardioLoads, spo2Tests] =
await Promise.all([
fetchNightlyRecharges(conn.accessToken, conn.polarUserId),
fetchSleeps(conn.accessToken, conn.polarUserId),
fetchActivities(conn.accessToken, conn.polarUserId),
fetchCardioLoads(conn.accessToken, conn.polarUserId),
fetchSpo2(conn.accessToken, conn.polarUserId),
fetchNightlyRecharges(conn.accessToken),
fetchSleeps(conn.accessToken),
fetchActivities(conn.accessToken),
fetchCardioLoads(conn.accessToken),
fetchSpo2(conn.accessToken),
]);
for (const r of recharges) {
readings.push(...toUpsert(mapNightlyRecharge(r), "recharge"));
@@ -187,8 +191,9 @@ export async function syncUserPolar(userId: string): Promise<number> {
/**
* Upsert a batch of mapped Polar readings, then fold the rollup tier +
* invalidate status-insight caches once at the end (mirrors the WHOOP /
* Nightscout sync tail). Idempotent: the `(userId, type, source, externalId)`
* unique key makes a re-post overwrite in place. Best-effort on the rollup fold.
* Nightscout sync tail). The shared reconciler protects both external and
* natural identity; an exact re-post overwrites in place. Best-effort on the
* rollup fold.
*/
export async function upsertPolarMeasurements(
userId: string,
@@ -201,76 +206,49 @@ export async function upsertPolarMeasurements(
let imported = 0;
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
let insertedRows: Array<
const insertedRows: Array<
InsertedMeasurementArrivalRow & { externalId: string | null }
> = [];
try {
insertedRows = await prisma.measurement.createManyAndReturn({
data: readings.map((r) => ({
userId,
type: r.type as MeasurementType,
source: "POLAR" as const,
value: r.value,
unit: r.unit,
measuredAt: r.measuredAt,
externalId: r.externalId,
sleepStage: r.sleepStage ?? null,
})),
skipDuplicates: true,
select: {
id: true,
type: true,
measuredAt: true,
externalId: true,
},
});
imported += insertedRows.length;
for (const row of insertedRows) {
touched.push({ type: row.type, measuredAt: row.measuredAt });
}
} catch (err) {
getEvent()?.addWarning(`polar: failed to create measurements: ${err}`);
}
const insertedIdentityCounts = new Map<string, number>();
for (const row of insertedRows) {
const key = `${row.type}:${row.externalId ?? ""}`;
insertedIdentityCounts.set(key, (insertedIdentityCounts.get(key) ?? 0) + 1);
}
for (const r of readings) {
const type = r.type as MeasurementType;
const key = `${type}:${r.externalId}`;
const insertedCount = insertedIdentityCounts.get(key) ?? 0;
if (insertedCount > 0) {
insertedIdentityCounts.set(key, insertedCount - 1);
continue;
}
try {
await prisma.measurement.update({
where: {
userId_type_source_externalId: {
const verdicts = await prisma.$transaction(
async (tx) => {
const outcomes = [];
for (const r of readings) {
const verdict = await reconcileExternalMeasurement(
tx,
{
userId,
type,
type: r.type as MeasurementType,
source: "POLAR",
value: r.value,
unit: r.unit,
measuredAt: r.measuredAt,
externalId: r.externalId,
sleepStage: r.sleepStage ?? null,
},
},
data: {
value: r.value,
unit: r.unit,
measuredAt: r.measuredAt,
sleepStage: r.sleepStage ?? null,
deletedAt: null,
syncVersion: { increment: 1 },
},
});
touched.push({ type, measuredAt: r.measuredAt });
imported++;
} catch (err) {
getEvent()?.addWarning(`polar: failed to update measurement: ${err}`);
{ exactExternalMatch: "update" },
);
if (verdict.status === "failed") {
throw new MeasurementReconciliationError(verdict);
}
outcomes.push(verdict);
}
return outcomes;
},
{ timeout: 60_000 },
);
for (let index = 0; index < readings.length; index++) {
const reading = readings[index]!;
const verdict = verdicts[index]!;
for (const dirty of verdict.dirtyIdentities ?? []) {
touched.push(dirty);
}
const type = reading.type as MeasurementType;
imported++;
touched.push({ type, measuredAt: reading.measuredAt });
if (verdict.status === "inserted") {
insertedRows.push(verdict.row);
}
}
@@ -0,0 +1,208 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { StravaWorkoutRow } from "../client";
const mocks = vi.hoisted(() => ({
fetchActivities: vi.fn(),
fetchActivityById: vi.fn(),
mapActivity: vi.fn(),
findUnique: vi.fn(),
updateMany: vi.fn(),
updateUser: vi.fn(),
createManyAndReturn: vi.fn(),
updateWorkout: vi.fn(),
getStravaConnection: vi.fn(),
recordSyncFailure: vi.fn(),
recordSyncSuccess: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: mocks.findUnique,
updateMany: mocks.updateMany,
update: mocks.updateUser,
},
workout: {
createManyAndReturn: mocks.createManyAndReturn,
update: mocks.updateWorkout,
},
},
}));
vi.mock("@/lib/arrivals/workout-emit", () => ({
emitInsertedWorkoutArrival: vi.fn(async () => {}),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => ({ addWarning: vi.fn() }),
}));
vi.mock("@/lib/integrations/status", () => ({
recordSyncFailure: mocks.recordSyncFailure,
recordSyncSuccess: mocks.recordSyncSuccess,
toFailureKind: vi.fn(() => "transient"),
}));
vi.mock("../credentials", () => ({
getStravaConnection: mocks.getStravaConnection,
getStravaClientCredentials: vi.fn(),
storeStravaTokens: vi.fn(),
}));
vi.mock("../client", () => ({
fetchActivities: mocks.fetchActivities,
fetchActivityById: mocks.fetchActivityById,
mapActivity: mocks.mapActivity,
refreshAccessToken: vi.fn(),
summaryHasHeartRate: vi.fn(() => true),
}));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: vi.fn(() => null),
}));
import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill";
import { syncUserStrava } from "../sync";
const previousCursor = new Date("2026-07-01T08:00:00.000Z");
const firstStart = new Date("2026-07-18T08:00:00.000Z");
const secondStart = new Date("2026-07-19T08:00:00.000Z");
const thirdStart = new Date("2026-07-20T08:00:00.000Z");
function workout(externalId: string, startedAt: Date): StravaWorkoutRow {
return {
externalId,
sportType: "running",
startedAt,
endedAt: new Date(startedAt.getTime() + 3_600_000),
durationSec: 3_600,
totalEnergyKcal: 500,
totalDistanceM: 10_000,
avgHeartRate: 145,
maxHeartRate: 170,
elevationM: 80,
metadata: {},
};
}
function activity(id: number, row: StravaWorkoutRow) {
return {
id,
start_date: row.startedAt.toISOString(),
row,
};
}
describe("syncUserStrava cursor durability", () => {
let cursor: Date | null;
let failExternalId: string | null;
let workouts: Map<string, StravaWorkoutRow>;
let backfillCompletedAt: Date | null;
beforeEach(() => {
vi.clearAllMocks();
cursor = previousCursor;
failExternalId = null;
workouts = new Map();
backfillCompletedAt = null;
const rows = [
workout("strava-1", firstStart),
workout("strava-2", secondStart),
workout("strava-3", thirdStart),
];
mocks.getStravaConnection.mockResolvedValue({
accessToken: "access-token",
refreshToken: "refresh-token",
refreshTokenCiphertext: Buffer.from("ciphertext"),
});
mocks.findUnique.mockImplementation(async () => ({
stravaLastActivityAt: cursor,
}));
mocks.fetchActivities.mockResolvedValue(
rows.map((row, index) => activity(index + 1, row)),
);
mocks.fetchActivityById.mockResolvedValue({});
mocks.mapActivity.mockImplementation((source) => source.row);
mocks.createManyAndReturn.mockImplementation(async ({ data }) => {
if (data.externalId === failExternalId) {
throw new Error("database unavailable");
}
if (workouts.has(data.externalId)) return [];
workouts.set(data.externalId, data);
return [{ id: `workout-${data.externalId}`, startedAt: data.startedAt }];
});
mocks.updateWorkout.mockImplementation(async ({ where, data }) => {
const externalId = where.userId_source_externalId.externalId;
const existing = workouts.get(externalId);
if (!existing) throw new Error("missing workout");
workouts.set(externalId, { ...existing, ...data });
return { id: `workout-${externalId}` };
});
mocks.updateMany.mockImplementation(async ({ data }) => {
cursor = data.stravaLastActivityAt;
return { count: 1 };
});
mocks.updateUser.mockImplementation(async ({ data }) => {
backfillCompletedAt = data.stravaBackfillCompletedAt;
return { id: "user-1" };
});
mocks.recordSyncFailure.mockResolvedValue(undefined);
mocks.recordSyncSuccess.mockResolvedValue(undefined);
});
it("leaves the previous cursor unchanged and rejects when a workout write fails", async () => {
failExternalId = "strava-2";
await expect(syncUserStrava("user-1")).rejects.toThrow(
"database unavailable",
);
expect(cursor).toEqual(previousCursor);
expect(mocks.updateMany).not.toHaveBeenCalled();
expect(workouts.has("strava-3")).toBe(true);
expect(mocks.recordSyncFailure).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
integration: "strava",
message: "database unavailable",
}),
);
expect(mocks.recordSyncSuccess).not.toHaveBeenCalled();
});
it("leaves the backfill watermark unchanged when a workout write fails", async () => {
failExternalId = "strava-2";
await expect(runStravaBackfillForUser("user-1")).rejects.toThrow(
"database unavailable",
);
expect(backfillCompletedAt).toBeNull();
expect(mocks.updateUser).not.toHaveBeenCalled();
});
it("advances the backfill watermark after every workout is persisted", async () => {
await expect(runStravaBackfillForUser("user-1")).resolves.toEqual({
imported: 3,
});
expect(workouts.size).toBe(3);
expect(backfillCompletedAt).toBeInstanceOf(Date);
expect(mocks.updateUser).toHaveBeenCalledTimes(1);
});
it("advances the cursor only after every fetched workout is persisted", async () => {
await expect(syncUserStrava("user-1")).resolves.toBe(3);
expect(workouts.size).toBe(3);
expect(cursor).toEqual(thirdStart);
expect(mocks.updateMany).toHaveBeenCalledTimes(1);
expect(mocks.recordSyncSuccess).toHaveBeenCalledWith("user-1", "strava");
});
it("replays by external identity without creating duplicate workouts", async () => {
await expect(syncUserStrava("user-1")).resolves.toBe(3);
await expect(syncUserStrava("user-1")).resolves.toBe(3);
expect(workouts.size).toBe(3);
expect(mocks.createManyAndReturn).toHaveBeenCalledTimes(6);
expect(mocks.updateWorkout).toHaveBeenCalledTimes(3);
expect(cursor).toEqual(thirdStart);
});
});
+49 -27
View File
@@ -68,6 +68,22 @@ export function classifyStravaFailure(err: unknown): FailureKind {
return toFailureKind(classifyStravaError(err));
}
async function recordStravaFailure(
userId: string,
err: unknown,
): Promise<void> {
await recordSyncFailure({
userId,
integration: "strava",
kind: classifyStravaFailure(err),
message: err instanceof Error ? err.message : String(err),
errorCode:
err instanceof StravaApiError && err.httpStatus != null
? String(err.httpStatus)
: undefined,
});
}
export interface SyncUserStravaOptions {
fullSync?: boolean;
lookbackDays?: number;
@@ -198,35 +214,32 @@ export async function syncUserStrava(
if (activities.length < PER_PAGE) break;
}
} catch (err) {
await recordSyncFailure({
userId,
integration: "strava",
kind: classifyStravaFailure(err),
message: err instanceof Error ? err.message : String(err),
errorCode:
err instanceof StravaApiError && err.httpStatus != null
? String(err.httpStatus)
: undefined,
});
await recordStravaFailure(userId, err);
throw err;
}
const imported = await upsertStravaWorkouts(userId, rows);
let imported: number;
try {
imported = await upsertStravaWorkouts(userId, rows);
// Advance the incremental cursor to the newest activity start we saw, but
// never move it backwards (a bounded backfill page-walk may not reach the
// very newest activity in one pass).
if (newestStart) {
await prisma.user.updateMany({
where: {
id: userId,
OR: [
{ stravaLastActivityAt: null },
{ stravaLastActivityAt: { lt: newestStart } },
],
},
data: { stravaLastActivityAt: newestStart },
});
// Advance the incremental cursor to the newest activity start we saw, but
// never move it backwards (a bounded backfill page-walk may not reach the
// very newest activity in one pass).
if (newestStart) {
await prisma.user.updateMany({
where: {
id: userId,
OR: [
{ stravaLastActivityAt: null },
{ stravaLastActivityAt: { lt: newestStart } },
],
},
data: { stravaLastActivityAt: newestStart },
});
}
} catch (err) {
await recordStravaFailure(userId, err);
throw err;
}
await recordSyncSuccess(userId, "strava");
@@ -239,8 +252,10 @@ export async function syncUserStrava(
/**
* Upsert a batch of mapped Strava activities into `Workout`, keyed on
* `(userId, source = STRAVA, externalId)` so a re-fetch overwrites in place.
* Best-effort per row: a single bad row is logged, never thrown, so it can't
* fail the surrounding sync.
* `(userId, source = STRAVA, externalId)` so replays overwrite in place.
* Failed rows are logged while the rest of the batch is attempted, then the
* first error is rethrown so the caller cannot advance its cursor or backfill
* watermark past a partially persisted batch.
*/
export async function upsertStravaWorkouts(
userId: string,
@@ -249,6 +264,8 @@ export async function upsertStravaWorkouts(
if (rows.length === 0) return 0;
let imported = 0;
let firstError: unknown;
let hadError = false;
for (const r of rows) {
const data = {
sportType: r.sportType,
@@ -292,8 +309,13 @@ export async function upsertStravaWorkouts(
imported += 1;
} catch (err) {
getEvent()?.addWarning(`strava: failed to upsert workout: ${err}`);
if (!hadError) {
firstError = err;
hadError = true;
}
}
}
if (hadError) throw firstError;
annotate({
action: { name: "strava.workout.ingest", details: { imported } },
+184 -1
View File
@@ -18,7 +18,12 @@
* of bubbling out of `prisma.createMany` with a useless message.
*/
import { describe, expect, it } from "vitest";
import { backupPayloadSchema, summarizeBackup } from "../backup";
import {
backupPayloadSchema,
isCompatibleSchemaVersion,
parseBackupPayload,
summarizeBackup,
} from "../backup";
const baseEntry = {
date: "2026-05-08",
@@ -383,3 +388,181 @@ describe("backupPayloadSchema — v1.28 backup-completeness domains", () => {
expect(summary.documents).toBe(0);
});
});
describe("backupPayloadSchema — canonical v2 identity compatibility", () => {
const base = {
exportedAt: "2026-07-20T00:00:00.000Z",
userId: "u1",
};
it("treats an unversioned payload as legacy v1 and accepts measurements without ids", () => {
const payload = parseBackupPayload({
...base,
measurements: [
{
type: "WEIGHT",
value: 75,
unit: "kg",
measuredAt: "2026-07-19T07:00:00.000Z",
},
],
});
expect(payload.schemaVersion).toBe("1");
expect(isCompatibleSchemaVersion(payload.schemaVersion)).toBe(true);
});
it("requires stable measurement ids in canonical v2 payloads", () => {
const result = backupPayloadSchema.safeParse({
...base,
schemaVersion: "2",
measurements: [
{
type: "SLEEP_DURATION",
value: 120,
unit: "minutes",
measuredAt: "2026-07-19T22:00:00.000Z",
source: "IMPORT",
sleepStage: "DEEP",
},
],
});
expect(result.success).toBe(false);
expect(isCompatibleSchemaVersion("1")).toBe(true);
expect(isCompatibleSchemaVersion("2")).toBe(true);
expect(isCompatibleSchemaVersion("3")).toBe(false);
});
});
describe("backupPayloadSchema — database enum boundaries", () => {
const enumPayload = {
schemaVersion: "1",
exportedAt: "2026-07-20T00:00:00.000Z",
userId: "u1",
measurements: [
{
id: "measurement-1",
type: "WEIGHT",
value: 75,
unit: "kg",
measuredAt: "2026-07-19T07:00:00.000Z",
source: "MANUAL",
},
],
intakeEvents: [
{
medication: "Example",
scheduledFor: "2026-07-19T08:00:00.000Z",
source: "WEB",
},
],
cycleProfile: {
goal: "GENERAL_HEALTH",
secondarySymptom: "MUCUS",
},
cycleDayLogs: [
{
date: "2026-07-19",
flow: "MEDIUM",
ovulationTest: "NEGATIVE",
cervicalMucus: "CREAMY",
cervixPosition: "LOW",
cervixFirmness: "FIRM",
cervixOpening: "CLOSED",
pregnancyTest: "NEGATIVE",
progesteroneTest: "INDETERMINATE",
contraceptive: "NONE",
source: "MANUAL",
},
],
illnessEpisodes: [
{
id: "illness-1",
label: "Cold",
type: "INFECTION",
lifecycle: "ACUTE",
onsetAt: "2026-07-01T00:00:00.000Z",
},
],
allergies: [
{
id: "allergy-1",
substance: "Penicillin",
category: "MEDICATION",
type: "ALLERGY",
severity: "SEVERE",
status: "ACTIVE",
},
],
familyHistory: [
{
id: "family-1",
relationship: "MOTHER",
condition: "Diabetes",
},
],
workouts: [
{
id: "workout-1",
sportType: "RUNNING",
startedAt: "2026-07-19T06:00:00.000Z",
endedAt: "2026-07-19T06:30:00.000Z",
durationSec: 1800,
source: "APPLE_HEALTH",
},
],
documents: [
{
id: "document-1",
kind: "LAB_RESULT",
mimeType: "application/pdf",
byteSize: 42,
status: "STORED",
summaryState: "NONE",
},
],
};
const enumPaths: Array<[string, Array<string | number>]> = [
["measurement type", ["measurements", 0, "type"]],
["measurement source", ["measurements", 0, "source"]],
["intake source", ["intakeEvents", 0, "source"]],
["cycle goal", ["cycleProfile", "goal"]],
["cycle secondary symptom", ["cycleProfile", "secondarySymptom"]],
["cycle flow", ["cycleDayLogs", 0, "flow"]],
["cycle ovulation test", ["cycleDayLogs", 0, "ovulationTest"]],
["cycle cervical mucus", ["cycleDayLogs", 0, "cervicalMucus"]],
["cycle cervix position", ["cycleDayLogs", 0, "cervixPosition"]],
["cycle cervix firmness", ["cycleDayLogs", 0, "cervixFirmness"]],
["cycle cervix opening", ["cycleDayLogs", 0, "cervixOpening"]],
["cycle pregnancy test", ["cycleDayLogs", 0, "pregnancyTest"]],
["cycle progesterone test", ["cycleDayLogs", 0, "progesteroneTest"]],
["cycle contraceptive", ["cycleDayLogs", 0, "contraceptive"]],
["cycle source", ["cycleDayLogs", 0, "source"]],
["illness type", ["illnessEpisodes", 0, "type"]],
["illness lifecycle", ["illnessEpisodes", 0, "lifecycle"]],
["allergy category", ["allergies", 0, "category"]],
["allergy type", ["allergies", 0, "type"]],
["allergy severity", ["allergies", 0, "severity"]],
["allergy status", ["allergies", 0, "status"]],
["family relationship", ["familyHistory", 0, "relationship"]],
["workout source", ["workouts", 0, "source"]],
["document kind", ["documents", 0, "kind"]],
["document status", ["documents", 0, "status"]],
["document summary state", ["documents", 0, "summaryState"]],
];
it.each(enumPaths)("rejects an unsupported %s", (_label, path) => {
const candidate = structuredClone(enumPayload);
let cursor: Record<PropertyKey, unknown> = candidate;
for (const segment of path.slice(0, -1)) {
cursor = cursor[segment] as Record<PropertyKey, unknown>;
}
cursor[path.at(-1)!] = "UNSUPPORTED_BACKUP_ENUM";
const parsed = backupPayloadSchema.safeParse(candidate);
expect(parsed.success).toBe(false);
});
});
+271 -51
View File
@@ -20,8 +20,42 @@
* a useful summary for them.
*/
import { z } from "zod/v4";
import {
AllergyCategory,
AllergySeverity,
AllergyStatus,
AllergyType,
CervicalMucus,
CervixFirmness,
CervixOpening,
CervixPosition,
ContraceptiveKind,
CycleTrackingGoal,
DocumentSummaryState,
FamilyRelationship,
FlowLevel,
GlucoseContext,
HomeTestResult,
IllnessLifecycle,
IllnessType,
InboundDocumentKind,
InboundDocumentStatus,
InjectionSite,
IntakeAttributionSource,
IntakeSource,
MeasurementSource,
MeasurementType,
MedicationCategory,
MedicationDeliveryForm,
MedicationScheduleType,
OvulationTest,
RhythmClassification,
SecondarySymptom,
SleepStage,
} from "@/generated/prisma/enums";
export const BACKUP_SCHEMA_VERSION = "1" as const;
export const BACKUP_SCHEMA_VERSION = "2" as const;
const LEGACY_BACKUP_SCHEMA_VERSION = "1" as const;
const isoDateTime = z
.string()
@@ -30,42 +64,112 @@ const isoDateTime = z
message: "Expected ISO-8601 date-time string",
});
const base64BytesSchema = z
.string()
.min(1)
.refine(
(value) => value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value),
{ message: "Expected base64-encoded encrypted bytes" },
);
const measurementSchema = z
.object({
type: z.string().min(1),
id: z.string().min(1).optional(),
type: z.enum(MeasurementType),
value: z.number(),
valueMin: z.number().nullable().optional(),
valueMax: z.number().nullable().optional(),
unit: z.string().min(1),
measuredAt: isoDateTime,
source: z.string().min(1).optional(),
source: z.enum(MeasurementSource).optional(),
notes: z.string().nullable().optional(),
notesEncrypted: base64BytesSchema.nullable().optional(),
externalId: z.string().nullable().optional(),
externalSourceVersion: z.string().nullable().optional(),
glucoseContext: z.enum(GlucoseContext).nullable().optional(),
sleepStage: z.enum(SleepStage).nullable().optional(),
rhythmClassification: z.enum(RhythmClassification).nullable().optional(),
deviceType: z.string().nullable().optional(),
syncVersion: z.number().int().optional(),
deletedAt: isoDateTime.nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
const medicationScheduleSchema = z
.object({
id: z.string().min(1).optional(),
windowStart: z.string().min(1),
windowEnd: z.string().min(1),
label: z.string().nullable().optional(),
dose: z.string().nullable().optional(),
daysOfWeek: z.string().nullable().optional(),
timesOfDay: z.array(z.string()).optional(),
reminderGraceMinutes: z.number().int().nullable().optional(),
rrule: z.string().nullable().optional(),
rollingIntervalDays: z.number().int().nullable().optional(),
scheduleType: z.enum(MedicationScheduleType).optional(),
cyclicOnWeeks: z.number().int().nullable().optional(),
cyclicOffWeeks: z.number().int().nullable().optional(),
doseWindows: z.unknown().nullable().optional(),
})
.passthrough();
const medicationSchema = z
.object({
id: z.string().min(1).optional(),
name: z.string().min(1),
dose: z.string(),
treatmentClass: z.enum(MedicationCategory).optional(),
dosesPerUnit: z.number().int().nullable().optional(),
unitsPerDose: z.string().min(1).optional(),
active: z.boolean().optional(),
notificationsEnabled: z.boolean().optional(),
pausedAt: isoDateTime.nullable().optional(),
snoozedUntil: isoDateTime.nullable().optional(),
startsOn: isoDateTime.nullable().optional(),
endsOn: isoDateTime.nullable().optional(),
oneShot: z.boolean().optional(),
asNeeded: z.boolean().optional(),
deliveryForm: z.enum(MedicationDeliveryForm).optional(),
trackInjectionSites: z.boolean().optional(),
allowedInjectionSites: z.array(z.enum(InjectionSite)).optional(),
liveActivityEnabled: z.boolean().optional(),
criticalAlarmEnabled: z.boolean().optional(),
atcCode: z.string().nullable().optional(),
rxNormCode: z.string().nullable().optional(),
lowStockNotifiedAt: isoDateTime.nullable().optional(),
lowStockNotifiedThresholdDays: z.number().int().nullable().optional(),
reorderLeadDays: z.number().int().nullable().optional(),
externalSource: z.enum(IntakeSource).nullable().optional(),
externalId: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
schedules: z.array(medicationScheduleSchema).default([]),
})
.passthrough();
const intakeEventSchema = z
.object({
id: z.string().min(1).optional(),
medicationId: z.string().min(1).optional(),
medication: z.string().min(1),
scheduledFor: isoDateTime,
takenAt: isoDateTime.nullable().optional(),
skipped: z.boolean().optional(),
source: z.string().min(1).optional(),
autoMissed: z.boolean().optional(),
attributionSource: z.enum(IntakeAttributionSource).optional(),
source: z.enum(IntakeSource).optional(),
idempotencyKey: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
injectionSite: z.enum(InjectionSite).nullable().optional(),
doseTaken: z.string().nullable().optional(),
inventoryConsumption: z.unknown().nullable().optional(),
externalId: z.string().nullable().optional(),
updatedAt: isoDateTime.optional(),
syncVersion: z.number().int().optional(),
deletedAt: isoDateTime.nullable().optional(),
})
.passthrough();
@@ -97,14 +201,25 @@ const moodEntryTagsSchema = z
{ message: "tags must be null, empty, or a JSON array of strings" },
);
const moodFactorSchema = z
.object({
key: z.string().min(1),
rating: z.number().int(),
})
.passthrough();
const moodEntrySchema = z
.object({
id: z.string().min(1).optional(),
date: z.string().min(1),
mood: z.string().min(1),
score: z.number().int().min(0).max(10),
tags: moodEntryTagsSchema,
source: z.string().min(1).optional(),
externalId: z.string().nullable().optional(),
loggedAt: isoDateTime,
deletedAt: isoDateTime.nullable().optional(),
factors: z.array(moodFactorSchema).default([]),
})
.passthrough();
@@ -118,13 +233,19 @@ const moodEntrySchema = z
*/
const cycleSpanSchema = z
.object({
id: z.string().min(1).optional(),
startDate: z.string().min(1),
endDate: z.string().nullable().optional(),
periodEndDate: z.string().nullable().optional(),
lengthDays: z.number().int().nullable().optional(),
ovulationDate: z.string().nullable().optional(),
ovulationConfirmed: z.boolean().optional(),
isPredicted: z.boolean().optional(),
tz: z.string().nullable().optional(),
syncVersion: z.number().int().optional(),
deletedAt: isoDateTime.nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
@@ -137,26 +258,32 @@ const cycleSpanSchema = z
*/
const cycleDayLogSchema = z
.object({
id: z.string().min(1).optional(),
date: z.string().min(1),
flow: z.string().nullable().optional(),
cycleId: z.string().nullable().optional(),
flow: z.enum(FlowLevel).nullable().optional(),
intermenstrualBleeding: z.boolean().optional(),
basalBodyTempC: z.number().nullable().optional(),
temperatureExcluded: z.boolean().optional(),
ovulationTest: z.string().nullable().optional(),
cervicalMucus: z.string().nullable().optional(),
cervixPosition: z.string().nullable().optional(),
cervixFirmness: z.string().nullable().optional(),
cervixOpening: z.string().nullable().optional(),
ovulationTest: z.enum(OvulationTest).nullable().optional(),
cervicalMucus: z.enum(CervicalMucus).nullable().optional(),
cervixPosition: z.enum(CervixPosition).nullable().optional(),
cervixFirmness: z.enum(CervixFirmness).nullable().optional(),
cervixOpening: z.enum(CervixOpening).nullable().optional(),
sexualActivity: z.boolean().optional(),
protectedSex: z.boolean().nullable().optional(),
pregnancyTest: z.string().nullable().optional(),
progesteroneTest: z.string().nullable().optional(),
contraceptive: z.string().nullable().optional(),
pregnancyTest: z.enum(HomeTestResult).nullable().optional(),
progesteroneTest: z.enum(HomeTestResult).nullable().optional(),
contraceptive: z.enum(ContraceptiveKind).nullable().optional(),
sensitiveEncrypted: z.string().nullable().optional(),
notesEncrypted: z.string().nullable().optional(),
source: z.string().min(1).optional(),
source: z.enum(MeasurementSource).optional(),
externalId: z.string().nullable().optional(),
tz: z.string().nullable().optional(),
syncVersion: z.number().int().optional(),
deletedAt: isoDateTime.nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
symptomKeys: z.array(z.string()).default([]),
})
.passthrough();
@@ -164,35 +291,79 @@ const cycleDayLogSchema = z
/** Cycle-tracking preferences (one row per user). */
const cycleProfileSchema = z
.object({
goal: z.string().min(1).optional(),
id: z.string().min(1).optional(),
goal: z.enum(CycleTrackingGoal).optional(),
cycleTrackingEnabled: z.boolean().nullable().optional(),
typicalCycleLength: z.number().int().nullable().optional(),
typicalPeriodLength: z.number().int().nullable().optional(),
lutealPhaseLength: z.number().int().nullable().optional(),
secondarySymptom: z.string().nullable().optional(),
secondarySymptom: z.enum(SecondarySymptom).nullable().optional(),
predictionEnabled: z.boolean().optional(),
rawChartMode: z.boolean().optional(),
discreetNotifications: z.boolean().optional(),
sensitiveCategoryEncryption: z.boolean().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
/* ── v1.28 backup-completeness shapes ──────────────────────────────
const appSettingsBackupSchema = z
.object({
id: z.string().min(1),
registrationEnabled: z.boolean(),
mfaRequired: z.boolean(),
defaultLocale: z.string(),
telegramGlobal: z.boolean(),
ntfyGlobal: z.boolean(),
webPushGlobal: z.boolean(),
webPushVapidPublicKey: z.string().nullable(),
webPushVapidPrivateKeyEncrypted: z.string().nullable(),
webPushVapidSubject: z.string().nullable(),
apiGlobal: z.boolean(),
moodLogGlobal: z.boolean(),
umamiEnabled: z.boolean(),
umamiScriptUrl: z.string().nullable(),
umamiWebsiteId: z.string().nullable(),
glitchtipEnabled: z.boolean(),
glitchtipDsn: z.string().nullable(),
glitchtipEnvironment: z.string().nullable(),
reminderLateMinutes: z.number().int(),
reminderMissedMinutes: z.number().int(),
adminAiKeyEncrypted: z.string().nullable(),
adminAiModel: z.string(),
adminAiBaseUrl: z.string(),
adminCodexAccessTokenEncrypted: z.string().nullable(),
adminCodexRefreshTokenEncrypted: z.string().nullable(),
adminCodexAccountIdEncrypted: z.string().nullable(),
adminCodexTokenExpiresAt: isoDateTime.nullable(),
adminCodexConnectedAt: isoDateTime.nullable(),
adminCodexConnectionStatus: z.string(),
adminAiInsightsFeedbackSummary: z.unknown().nullable(),
defaultUserTimezone: z.string().nullable(),
assistantEnabled: z.boolean(),
assistantCoachEnabled: z.boolean(),
assistantBriefingEnabled: z.boolean(),
assistantInsightStatusEnabled: z.boolean(),
assistantCorrelationsEnabled: z.boolean(),
assistantHealthScoreExplainerEnabled: z.boolean(),
moduleAvailabilityJson: z.unknown().nullable(),
documentMaxFileBytes: z.number().int(),
documentQuotaBytes: z.string().regex(/^\d+$/),
})
.passthrough();
/* ── Structured-record disaster-recovery shapes ─────────────────────
*
* Labs, illness episodes (+ flares/exacerbations), allergies, family
* history, workouts, and the documents manifest. Written by
* `buildRecordsBackupSection` (`src/lib/export/records-backup.ts`) — shared
* by the on-demand export routes AND the weekly `data-backup` worker.
*
* EXPORT-ONLY today: `POST /api/admin/backups/[id]/restore` does not yet
* recreate these domains on restore (only measurements / medications /
* mood / cycle round-trip). The schemas below exist so the payload
* validates + parses with real types (an admin reviewing an uploaded
* backup sees accurate counts) even though restore ignores them for now.
* These shapes serve both the historical portable export and the canonical
* weekly/off-host disaster-recovery payload. Portable document entries remain
* metadata-only. Canonical entries additionally carry encrypted BYTEA values
* as base64 plus the codec/hash fields required to recreate InboundDocument
* without decrypting or fabricating content.
*/
const labResultBackupSchema = z
.object({
id: z.string().min(1).optional(),
panel: z.string().nullable().optional(),
analyte: z.string().min(1),
value: z.number().nullable().optional(),
@@ -203,12 +374,18 @@ const labResultBackupSchema = z
takenAt: isoDateTime,
source: z.string().min(1),
biomarkerName: z.string().nullable().optional(),
biomarkerId: z.string().nullable().optional(),
note: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
noteEncrypted: base64BytesSchema.nullable().optional(),
deletedAt: isoDateTime.nullable().optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
const biomarkerBackupSchema = z
.object({
id: z.string().min(1).optional(),
name: z.string().min(1),
unit: z.string().min(1),
lowerBound: z.number().nullable().optional(),
@@ -216,6 +393,8 @@ const biomarkerBackupSchema = z
panel: z.string().nullable().optional(),
hidden: z.boolean().optional(),
context: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
@@ -236,6 +415,10 @@ const illnessDayLogBackupSchema = z
symptoms: z.array(illnessSymptomBackupSchema).default([]),
note: z.string().nullable().optional(),
updatedAt: isoDateTime.optional(),
noteEncrypted: base64BytesSchema.nullable().optional(),
tz: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
deletedAt: isoDateTime.nullable().optional(),
})
.passthrough();
@@ -243,8 +426,8 @@ const illnessEpisodeBackupSchema = z
.object({
id: z.string().min(1),
label: z.string().min(1),
type: z.string().min(1),
lifecycle: z.string().min(1),
type: z.enum(IllnessType),
lifecycle: z.enum(IllnessLifecycle),
onsetAt: isoDateTime,
resolvedAt: isoDateTime.nullable().optional(),
// Self-referencing flare/exacerbation link, carried as the exported
@@ -252,6 +435,8 @@ const illnessEpisodeBackupSchema = z
parentConditionId: z.string().nullable().optional(),
note: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
noteEncrypted: base64BytesSchema.nullable().optional(),
deletedAt: isoDateTime.nullable().optional(),
updatedAt: isoDateTime.optional(),
dayLogs: z.array(illnessDayLogBackupSchema).default([]),
})
@@ -261,14 +446,17 @@ const allergyBackupSchema = z
.object({
id: z.string().min(1),
substance: z.string().min(1),
category: z.string().min(1),
type: z.string().min(1),
severity: z.string().nullable().optional(),
status: z.string().min(1),
category: z.enum(AllergyCategory),
type: z.enum(AllergyType),
severity: z.enum(AllergySeverity).nullable().optional(),
status: z.enum(AllergyStatus),
onsetAt: isoDateTime.nullable().optional(),
reaction: z.string().nullable().optional(),
note: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
reactionEncrypted: base64BytesSchema.nullable().optional(),
notesEncrypted: base64BytesSchema.nullable().optional(),
deletedAt: isoDateTime.nullable().optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
@@ -276,7 +464,7 @@ const allergyBackupSchema = z
const familyHistoryBackupSchema = z
.object({
id: z.string().min(1),
relationship: z.string().min(1),
relationship: z.enum(FamilyRelationship),
condition: z.string().min(1),
ageAtOnset: z.number().int().nullable().optional(),
note: z.string().nullable().optional(),
@@ -287,6 +475,7 @@ const familyHistoryBackupSchema = z
const workoutBackupSchema = z
.object({
id: z.string().min(1).optional(),
sportType: z.string().min(1),
startedAt: isoDateTime,
endedAt: isoDateTime,
@@ -299,24 +488,39 @@ const workoutBackupSchema = z
stepCount: z.number().int().nullable().optional(),
elevationM: z.number().nullable().optional(),
pauseDurationSec: z.number().int().nullable().optional(),
source: z.string().min(1),
source: z.enum(MeasurementSource),
externalId: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
const documentBackupSchema = z
.object({
id: z.string().min(1),
kind: z.string().min(1),
kind: z.enum(InboundDocumentKind),
title: z.string().nullable().optional(),
filename: z.string().nullable().optional(),
mimeType: z.string().min(1),
byteSize: z.number().int(),
status: z.string().min(1),
status: z.enum(InboundDocumentStatus),
reportDate: z.string().nullable().optional(),
documentDate: z.string().nullable().optional(),
contentEncrypted: base64BytesSchema.optional(),
contentSha256: z
.string()
.regex(/^[a-f0-9]{64}$/)
.nullable()
.optional(),
contentCodec: z.string().min(1).optional(),
providerType: z.string().nullable().optional(),
errorReason: z.string().nullable().optional(),
summaryEncrypted: base64BytesSchema.nullable().optional(),
summaryGeneratedAt: isoDateTime.nullable().optional(),
summaryState: z.enum(DocumentSummaryState).optional(),
summary: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
@@ -338,9 +542,10 @@ const backupManifestSchema = z
*/
export const backupPayloadSchema = z
.object({
schemaVersion: z.string().min(1).default(BACKUP_SCHEMA_VERSION),
schemaVersion: z.string().min(1).default(LEGACY_BACKUP_SCHEMA_VERSION),
exportedAt: isoDateTime,
userId: z.string().min(1),
appSettings: appSettingsBackupSchema.nullable().default(null),
measurements: z.array(measurementSchema).default([]),
medications: z.array(medicationSchema).default([]),
intakeEvents: z.array(intakeEventSchema).default([]),
@@ -350,9 +555,9 @@ export const backupPayloadSchema = z
cycleProfile: cycleProfileSchema.nullable().default(null),
cycles: z.array(cycleSpanSchema).default([]),
cycleDayLogs: z.array(cycleDayLogSchema).default([]),
// v1.28 backup-completeness — export-only for now (see the block
// comment above). Default to empty arrays / null so a pre-v1.28 backup
// (no records keys) still round-trips unchanged.
// Structured records default to empty arrays so older backups remain
// parseable. Canonical DR writers add stable ids and encrypted document
// fields; portable exports retain the metadata-only subset.
labResults: z.array(labResultBackupSchema).default([]),
biomarkers: z.array(biomarkerBackupSchema).default([]),
illnessEpisodes: z.array(illnessEpisodeBackupSchema).default([]),
@@ -362,7 +567,19 @@ export const backupPayloadSchema = z
documents: z.array(documentBackupSchema).default([]),
manifest: backupManifestSchema.nullable().default(null),
})
.passthrough();
.passthrough()
.superRefine((payload, ctx) => {
if (payload.schemaVersion !== BACKUP_SCHEMA_VERSION) return;
payload.measurements.forEach((measurement, index) => {
if (!measurement.id) {
ctx.addIssue({
code: "custom",
path: ["measurements", index, "id"],
message: "Canonical v2 measurements require a stable id",
});
}
});
});
export type BackupPayload = z.infer<typeof backupPayloadSchema>;
@@ -383,21 +600,21 @@ export interface BackupSummary {
cycles: number;
/** v1.15.0 — cycle day-logs in the backup. */
cycleDayLogs: number;
/** v1.28 — lab results in the backup (export-only, see above). */
/** Lab results in the backup. */
labResults: number;
/** v1.28 — biomarker catalog entries in the backup (export-only). */
/** User-scoped biomarker catalog entries in the backup. */
biomarkers: number;
/** v1.28 — illness episodes, incl. flares/exacerbations (export-only). */
/** Illness episodes, including flares/exacerbations. */
illnessEpisodes: number;
/** v1.28 — illness day-logs across every episode (export-only). */
/** Illness day-logs across every episode. */
illnessDayLogs: number;
/** v1.28 — allergy/intolerance records in the backup (export-only). */
/** Allergy/intolerance records in the backup. */
allergies: number;
/** v1.28 — family-history entries in the backup (export-only). */
/** Family-history entries in the backup. */
familyHistory: number;
/** v1.28 — workout summary records in the backup (export-only). */
/** Workout summary records in the backup. */
workouts: number;
/** v1.28 — document manifest entries (metadata only, export-only). */
/** Document records (ciphertext included in canonical DR payloads). */
documents: number;
}
@@ -449,5 +666,8 @@ export function parseBackupPayload(input: string | unknown): BackupPayload {
* new shape carried.
*/
export function isCompatibleSchemaVersion(version: string): boolean {
return version === BACKUP_SCHEMA_VERSION;
return (
version === LEGACY_BACKUP_SCHEMA_VERSION ||
version === BACKUP_SCHEMA_VERSION
);
}
@@ -9,24 +9,33 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const { updateManyMock, upsertMeasurementsMock } = vi.hoisted(() => ({
updateManyMock: vi.fn(async () => ({ count: 0 })),
upsertMeasurementsMock: vi.fn(
async (
_userId: string,
readings: Array<{ type: string; measuredAt: Date }>,
opts?: {
onInserted?: (
rows: Array<{ id: string; type: string; measuredAt: Date }>,
) => void;
const { updateManyMock, upsertMeasurementsMock, morningRefreshMock } =
vi.hoisted(() => ({
updateManyMock: vi.fn(async () => ({ count: 0 })),
upsertMeasurementsMock: vi.fn(
async (
_userId: string,
readings: Array<{ type: string; measuredAt: Date }>,
opts?: {
onInserted?: (
rows: Array<{ id: string; type: string; measuredAt: Date }>,
) => void;
},
) => {
opts?.onInserted?.(
readings.map((row, index) => ({ ...row, id: `inserted-${index}` })),
);
return 1;
},
) => {
opts?.onInserted?.(
readings.map((row, index) => ({ ...row, id: `inserted-${index}` })),
);
return 1;
},
),
),
morningRefreshMock: vi.fn<(...args: unknown[]) => Promise<void>>(
async () => {},
),
}));
vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
maybeEnqueueMorningRefresh: (...args: unknown[]) =>
morningRefreshMock(...args),
}));
vi.mock("@/lib/db", () => ({
@@ -92,6 +101,7 @@ beforeEach(() => {
updateManyMock.mockClear().mockResolvedValue({ count: 0 });
upsertMeasurementsMock.mockClear();
fetchSleepsMock.mockReset();
morningRefreshMock.mockClear();
});
describe("syncUserSleep — record-scoped stale-segment sweep", () => {
@@ -155,4 +165,72 @@ describe("syncUserSleep — record-scoped stale-segment sweep", () => {
await expect(syncUserSleep("user-1")).resolves.toBe(1);
expect(upsertMeasurementsMock).toHaveBeenCalledTimes(1);
});
it("enqueues one refresh after every committed chunk settles", async () => {
fetchSleepsMock.mockResolvedValue([NIGHT]);
const firstMeasuredAt = new Date("2026-06-01T06:30:00.000Z");
const secondMeasuredAt = new Date("2026-06-01T07:00:00.000Z");
upsertMeasurementsMock.mockImplementationOnce(
async (
_userId: string,
_readings: Array<{ type: string; measuredAt: Date }>,
opts?: {
onInserted?: (
rows: Array<{ id: string; type: string; measuredAt: Date }>,
) => void;
},
) => {
opts?.onInserted?.([
{
id: "first-committed-segment",
type: "SLEEP_DURATION",
measuredAt: firstMeasuredAt,
},
]);
opts?.onInserted?.([
{
id: "second-committed-segment",
type: "SLEEP_DURATION",
measuredAt: secondMeasuredAt,
},
]);
return 2;
},
);
await expect(syncUserSleep("user-1")).resolves.toBe(2);
expect(morningRefreshMock).toHaveBeenCalledTimes(1);
expect(morningRefreshMock).toHaveBeenCalledWith("user-1", [
firstMeasuredAt,
secondMeasuredAt,
]);
});
it("enqueues refreshes for a committed chunk before a later chunk fails", async () => {
fetchSleepsMock.mockResolvedValue([NIGHT]);
const measuredAt = new Date("2026-06-01T07:00:00.000Z");
upsertMeasurementsMock.mockImplementationOnce(
async (
_userId: string,
_readings: Array<{ type: string; measuredAt: Date }>,
opts?: {
onInserted?: (
rows: Array<{ id: string; type: string; measuredAt: Date }>,
) => void;
},
) => {
opts?.onInserted?.([
{ id: "committed-segment", type: "SLEEP_DURATION", measuredAt },
]);
throw new Error("injected later-chunk failure");
},
);
await expect(syncUserSleep("user-1")).rejects.toThrow(
"injected later-chunk failure",
);
expect(morningRefreshMock).toHaveBeenCalledWith("user-1", [measuredAt]);
});
});
+275 -34
View File
@@ -16,6 +16,9 @@ const {
recordSyncSuccess,
isReauthRequired,
emitArrivalMock,
reconcileMock,
recomputeBucketsMock,
invalidateStatusMock,
} = vi.hoisted(() => ({
prismaMock: {
whoopConnection: {
@@ -30,6 +33,8 @@ const {
findMany: vi.fn(),
},
$executeRaw: vi.fn(),
$queryRaw: vi.fn(),
$transaction: vi.fn(),
},
refreshAccessTokenMock: vi.fn(),
fetchRecoveriesMock: vi.fn(),
@@ -38,7 +43,14 @@ const {
isReauthRequired: vi.fn<(...a: unknown[]) => Promise<boolean>>(
async () => false,
),
reconcileMock: vi.fn(),
emitArrivalMock: vi.fn(),
recomputeBucketsMock: vi.fn<(...a: unknown[]) => Promise<void>>(
async () => {},
),
invalidateStatusMock: vi.fn<(...a: unknown[]) => Promise<void>>(
async () => {},
),
}));
vi.mock("@/lib/db", () => ({ prisma: prismaMock }));
@@ -57,6 +69,11 @@ vi.mock("../client", async (orig) => {
};
});
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
reconcileExternalMeasurement: reconcileMock,
MeasurementReconciliationError: class extends Error {},
}));
vi.mock("../credentials", () => ({
getUserWhoopCredentials: vi.fn(async () => ({
clientId: "cid",
@@ -73,11 +90,13 @@ vi.mock("@/lib/integrations/status", () => ({
vi.mock("@/lib/rollups/measurement-rollups", () => ({
collapseToTypeDayKeys: (rows: Array<{ type: string; measuredAt: Date }>) =>
rows.map((r) => ({ type: r.type, measuredAt: r.measuredAt })),
recomputeBucketsForMeasurement: vi.fn(async () => {}),
recomputeBucketsForMeasurement: (...a: unknown[]) =>
recomputeBucketsMock(...a),
}));
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
invalidateStatusInsightsForTypes: (...a: unknown[]) =>
invalidateStatusMock(...a),
}));
vi.mock("@/lib/arrivals/measurement-emit", () => ({
@@ -105,6 +124,23 @@ import { syncUserRecovery } from "../sync-recovery";
beforeEach(() => {
vi.clearAllMocks();
prismaMock.$transaction.mockImplementation(
async (run: (tx: unknown) => unknown) => run(prismaMock),
);
reconcileMock.mockImplementation(
async (
_tx: unknown,
input: { type: string; measuredAt: Date; externalId: string },
) => ({
status: "inserted",
row: {
id: `inserted:${input.externalId}`,
type: input.type,
measuredAt: input.measuredAt,
externalId: input.externalId,
},
}),
);
isReauthRequired.mockResolvedValue(false);
emitArrivalMock.mockResolvedValue(undefined);
});
@@ -128,7 +164,7 @@ describe("getValidToken — rotating refresh", () => {
refresh_token: "new-refresh",
expires_in: 3600,
});
prismaMock.whoopConnection.updateMany.mockResolvedValue({ count: 1 });
prismaMock.whoopConnection.update.mockResolvedValue({});
const result = await getValidToken("user1");
@@ -137,15 +173,14 @@ describe("getValidToken — rotating refresh", () => {
clientId: "cid",
clientSecret: "csecret",
});
const updateArg = prismaMock.whoopConnection.updateMany.mock.calls[0]![0];
// CAS guard: scoped to the connection AND the exact stored ciphertext spent.
const updateArg = prismaMock.whoopConnection.update.mock.calls[0]![0];
expect(updateArg.where.id).toBe("conn1");
expect(updateArg.where.refreshToken).toBe("enc(old-refresh)");
expect(updateArg.data.accessToken).toBe("enc(new-access)");
expect(updateArg.data.refreshToken).toBe("enc(new-refresh)");
expect(prismaMock.$queryRaw).toHaveBeenCalledTimes(1);
});
it("reuses the peer's rotated token on a lost CAS race (no spurious reauth)", async () => {
it("re-reads token state under an advisory lock and reuses a peer winner", async () => {
prismaMock.whoopConnection.findUnique
.mockResolvedValueOnce({
id: "conn1",
@@ -154,20 +189,26 @@ describe("getValidToken — rotating refresh", () => {
refreshToken: "enc(old-refresh)",
tokenExpiresAt: new Date(Date.now() - 1000),
})
// Re-read after the lost race surfaces the peer's freshly rotated access.
.mockResolvedValueOnce({ accessToken: "enc(peer-access)" });
refreshAccessTokenMock.mockResolvedValue({
access_token: "new-access",
refresh_token: "new-refresh",
expires_in: 3600,
});
// A concurrent sync already rotated the token → zero rows match the guard.
prismaMock.whoopConnection.updateMany.mockResolvedValue({ count: 0 });
.mockResolvedValueOnce({
id: "conn1",
whoopUserId: "42",
accessToken: "enc(winner-access)",
refreshToken: "enc(winner-refresh)",
tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
});
const result = await getValidToken("user1");
expect(result?.accessToken).toBe("peer-access");
expect(recordSyncFailure).not.toHaveBeenCalled();
expect(result?.accessToken).toBe("winner-access");
expect(prismaMock.$queryRaw).toHaveBeenCalledTimes(1);
expect(refreshAccessTokenMock).not.toHaveBeenCalled();
expect(prismaMock.$transaction).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
maxWait: expect.any(Number),
timeout: expect.any(Number),
}),
);
});
it("returns the stored token without refresh when not near expiry", async () => {
@@ -371,8 +412,17 @@ describe("upsertWhoopMeasurements — exact insertion results", () => {
measuredAt,
externalId: readings[0].externalId,
};
prismaMock.measurement.createManyAndReturn.mockResolvedValue([inserted]);
prismaMock.measurement.update.mockResolvedValue({});
reconcileMock
.mockResolvedValueOnce({ status: "inserted", row: inserted })
.mockResolvedValueOnce({
status: "updated",
row: {
id: "existing-2",
type: "HRV_RMSSD",
measuredAt,
externalId: readings[1].externalId,
},
});
const onInserted = vi.fn();
expect(
@@ -381,16 +431,19 @@ describe("upsertWhoopMeasurements — exact insertion results", () => {
expect(onInserted).toHaveBeenCalledWith([inserted]);
expect(emitArrivalMock).toHaveBeenCalledWith("user1", [inserted], "whoop");
expect(prismaMock.measurement.update).toHaveBeenCalledTimes(1);
expect(
prismaMock.measurement.update.mock.calls[0]![0].where
.userId_type_source_externalId,
).toEqual({
expect(reconcileMock.mock.calls[1]![1]).toMatchObject({
userId: "user1",
type: "HRV_RMSSD",
source: "WHOOP",
externalId: readings[1].externalId,
});
expect(prismaMock.$transaction).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
maxWait: expect.any(Number),
timeout: expect.any(Number),
}),
);
});
it("does not pre-probe and still emits a genuine insert", async () => {
@@ -403,7 +456,7 @@ describe("upsertWhoopMeasurements — exact insertion results", () => {
measuredAt,
externalId: readings[0].externalId,
};
prismaMock.measurement.createManyAndReturn.mockResolvedValue([inserted]);
reconcileMock.mockResolvedValueOnce({ status: "inserted", row: inserted });
await upsertWhoopMeasurements("user1", [readings[0]]);
@@ -412,20 +465,208 @@ describe("upsertWhoopMeasurements — exact insertion results", () => {
});
it("updates a re-post in place without reporting another insert", async () => {
prismaMock.measurement.createManyAndReturn.mockResolvedValue([]);
prismaMock.measurement.update.mockResolvedValue({});
reconcileMock.mockResolvedValueOnce({
status: "updated",
row: {
id: "existing-1",
type: "RECOVERY_SCORE",
measuredAt,
externalId: readings[0].externalId,
},
});
expect(await upsertWhoopMeasurements("user1", [readings[0]])).toBe(1);
expect(emitArrivalMock).toHaveBeenCalledWith("user1", [], "whoop");
expect(prismaMock.measurement.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
value: 71,
syncVersion: { increment: 1 },
}),
expect(reconcileMock.mock.calls[0]![1]).toMatchObject({
value: 71,
externalId: readings[0].externalId,
});
});
});
describe("syncUserRecovery — durable cursor ordering", () => {
const recovery = {
cycle_id: 1,
sleep_id: "sleep-uuid",
user_id: 42,
created_at: "2026-06-01T06:00:00.000Z",
updated_at: "2026-06-01T07:00:00.000Z",
score_state: "SCORED",
score: {
user_calibrating: false,
recovery_score: 66,
resting_heart_rate: 52,
hrv_rmssd_milli: 48.7,
spo2_percentage: 97,
skin_temp_celsius: 33.4,
},
};
beforeEach(() => {
prismaMock.whoopConnection.findUnique.mockResolvedValue({
id: "conn1",
whoopUserId: "42",
accessToken: "enc(live-access)",
refreshToken: "enc(live-refresh)",
tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
lastSyncedAt: new Date("2026-06-01T00:00:00Z"),
resourceCursors: {
recovery: "2026-06-01T00:00:00.000Z",
},
});
fetchRecoveriesMock.mockResolvedValue([recovery]);
});
it("keeps the cursor unchanged when any represented measurement write fails", async () => {
reconcileMock
.mockResolvedValueOnce({
status: "inserted",
row: {
id: "inserted:first",
type: "RECOVERY_SCORE",
measuredAt: new Date(recovery.updated_at),
externalId: "sleep-uuid:recovery_score",
},
})
.mockResolvedValueOnce({
status: "failed",
reason: "db_error",
error: new Error("injected write failure"),
});
await expect(syncUserRecovery("user1")).rejects.toThrow();
expect(prismaMock.$executeRaw).not.toHaveBeenCalled();
});
it("keeps earlier chunks durable and resumes without duplicate rows after a later chunk fails", async () => {
const records = Array.from({ length: 21 }, (_, index) => ({
...recovery,
cycle_id: index + 1,
sleep_id: `sleep-${index.toString().padStart(2, "0")}`,
}));
fetchRecoveriesMock.mockResolvedValue(records);
type StoredRow = {
id: string;
type: string;
measuredAt: Date;
externalId: string;
};
type TransactionState = { rows: Map<string, StoredRow> };
let committedRows = new Map<string, StoredRow>();
let committedInsertCount = 0;
let failLaterChunk = true;
prismaMock.$transaction.mockImplementation(
async (run: (tx: TransactionState) => unknown) => {
const stagedRows = new Map(committedRows);
const result = await run({ rows: stagedRows });
committedInsertCount += [...stagedRows.keys()].filter(
(key) => !committedRows.has(key),
).length;
committedRows = stagedRows;
return result;
},
);
reconcileMock.mockImplementation(
async (
tx: TransactionState,
input: { type: string; measuredAt: Date; externalId: string },
) => {
if (failLaterChunk && input.externalId === "sleep-20:recovery") {
return {
status: "failed",
reason: "db_error",
error: new Error("injected later-chunk failure"),
};
}
const existing = tx.rows.get(input.externalId);
const row = {
id: existing?.id ?? `row:${input.externalId}`,
type: input.type,
measuredAt: input.measuredAt,
externalId: input.externalId,
};
tx.rows.set(input.externalId, row);
return { status: existing ? "updated" : "inserted", row };
},
);
await expect(syncUserRecovery("user1")).rejects.toThrow();
expect(committedRows.size).toBeGreaterThan(0);
expect(committedRows.size).toBeLessThan(105);
expect(prismaMock.$transaction.mock.calls.length).toBeGreaterThan(1);
expect(prismaMock.$executeRaw).not.toHaveBeenCalled();
expect(recomputeBucketsMock).toHaveBeenCalled();
expect(invalidateStatusMock).toHaveBeenCalledTimes(1);
failLaterChunk = false;
await expect(syncUserRecovery("user1")).resolves.toBe(105);
const expectedExternalIds = records.flatMap(({ sleep_id }) =>
["recovery", "hrv_rmssd", "rhr", "spo2", "skin_temp"].map(
(fieldTag) => `${sleep_id}:${fieldTag}`,
),
);
const emittedExternalIds = emitArrivalMock.mock.calls.flatMap((call) =>
(
call[1] as Array<{
externalId: string;
}>
).map((row) => row.externalId),
);
expect(committedRows).toHaveLength(105);
expect(committedInsertCount).toBe(105);
expect([...committedRows.keys()]).toEqual(expectedExternalIds);
expect(emittedExternalIds).toEqual(expectedExternalIds);
expect(invalidateStatusMock).toHaveBeenCalledTimes(2);
expect(prismaMock.$executeRaw).toHaveBeenCalledTimes(1);
});
it("advances after success and replay updates identities without new arrivals", async () => {
expect(await syncUserRecovery("user1")).toBe(5);
expect(prismaMock.$executeRaw).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
prismaMock.$transaction.mockImplementation(
async (run: (tx: unknown) => unknown) => run(prismaMock),
);
prismaMock.whoopConnection.findUnique.mockResolvedValue({
id: "conn1",
whoopUserId: "42",
accessToken: "enc(live-access)",
refreshToken: "enc(live-refresh)",
tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
lastSyncedAt: new Date("2026-06-01T00:00:00Z"),
resourceCursors: {
recovery: "2026-06-01T00:00:00.000Z",
},
});
fetchRecoveriesMock.mockResolvedValue([recovery]);
reconcileMock.mockImplementation(
async (
_tx: unknown,
input: { type: string; measuredAt: Date; externalId: string },
) => ({
status: "updated",
row: {
id: `existing:${input.externalId}`,
type: input.type,
measuredAt: input.measuredAt,
externalId: input.externalId,
},
}),
);
expect(await syncUserRecovery("user1")).toBe(5);
expect(prismaMock.$executeRaw).toHaveBeenCalledTimes(1);
expect(emitArrivalMock).toHaveBeenCalledWith("user1", [], "whoop");
});
});
+33 -24
View File
@@ -40,6 +40,14 @@ import {
} from "./sync";
import { prisma } from "@/lib/db";
function enqueueMorningRefreshForMeasuredAts(
userId: string,
measuredAts: Date[],
): void {
if (measuredAts.length === 0) return;
void maybeEnqueueMorningRefresh(userId, measuredAts).catch(() => {});
}
export async function syncUserSleep(
userId: string,
opts: { fullSync?: boolean } = {},
@@ -94,21 +102,26 @@ export async function syncUserSleep(
// the fresh set upserts (mirrors Google Health's replace-by-window order).
await sweepStaleSleepSegments(userId, "WHOOP", sweeps);
let insertedSleepMeasuredAts: Date[] = [];
const imported = await upsertWhoopMeasurements(userId, readings, {
onInserted: (rows) => {
insertedSleepMeasuredAts = rows
.filter((row) => row.type === "SLEEP_DURATION")
.map((row) => row.measuredAt);
},
});
// Accumulate committed chunks, then trigger once after the write pass settles.
// The finally path also covers a later chunk failure without generating from
// an intermediate prefix while more chunks are still being written.
const insertedSleepMeasuredAts: Date[] = [];
let imported: number;
try {
imported = await upsertWhoopMeasurements(userId, readings, {
onInserted: (rows) => {
insertedSleepMeasuredAts.push(
...rows
.filter((row) => row.type === "SLEEP_DURATION")
.map((row) => row.measuredAt),
);
},
});
} finally {
enqueueMorningRefreshForMeasuredAts(userId, insertedSleepMeasuredAts);
}
await markResourceSynced(userId, "sleep");
// S4 — trigger the debounced morning refresh on a last-night segment landing.
void maybeEnqueueMorningRefresh(userId, insertedSleepMeasuredAts).catch(
() => {},
);
return imported;
}
@@ -156,20 +169,16 @@ export async function syncWhoopSleepById(
{ prefix: `${record.id}:`, keepIds },
]);
let insertedSleepMeasuredAts: Date[] = [];
const insertedSleepMeasuredAts: Date[] = [];
const imported = await upsertWhoopMeasurements(userId, readings, {
onInserted: (rows) => {
insertedSleepMeasuredAts = rows
.filter((row) => row.type === "SLEEP_DURATION")
.map((row) => row.measuredAt);
insertedSleepMeasuredAts.push(
...rows
.filter((row) => row.type === "SLEEP_DURATION")
.map((row) => row.measuredAt),
);
},
});
// S4 — a webhook-driven single-record refresh is the freshest possible
// last-night signal; kick the debounced morning refresh on its segments.
void maybeEnqueueMorningRefresh(userId, insertedSleepMeasuredAts).catch(
() => {},
);
enqueueMorningRefreshForMeasuredAts(userId, insertedSleepMeasuredAts);
return imported;
}
+181 -180
View File
@@ -6,9 +6,9 @@
* - `getValidToken` decrypts the stored pair, refreshes at
* `tokenExpiresAt - 5 min`, and persists BOTH rotated tokens (WHOOP
* invalidates the prior access AND refresh token on every refresh — the
* same discipline Withings uses for its rotating refresh token). The persist
* is a compare-and-swap on the stored ciphertext so two overlapping syncs
* cannot park the connection at reauth (see `persistRotatedToken`).
* same discipline Withings uses for its rotating refresh token). A
* provider/user advisory lock serializes refresh, and token state is
* re-read inside the transaction before the one-time token is spent.
* - Each per-resource sync (`sync-recovery` / `sync-sleep` / `sync-cycle` /
* `sync-workout`) upserts into `Measurement` / `Workout` keyed on
* `(userId, type, source = WHOOP, externalId)` so a re-post (WHOOP
@@ -44,7 +44,14 @@ import {
emitInsertedMeasurementArrivals,
type InsertedMeasurementArrivalRow,
} from "@/lib/arrivals/measurement-emit";
import { persistRotatedToken } from "@/lib/integrations/oauth-refresh";
import {
acquireProviderTokenRefreshLock,
PROVIDER_REFRESH_TRANSACTION_OPTIONS,
} from "@/lib/integrations/oauth-refresh";
import {
MeasurementReconciliationError,
reconcileExternalMeasurement,
} from "@/lib/measurements/reconcile-external-measurement";
import { refreshAccessToken } from "./client";
import { getUserWhoopCredentials } from "./credentials";
import {
@@ -198,91 +205,86 @@ export async function getValidToken(
});
if (!connection) return null;
const accessToken = decrypt(connection.accessToken);
const refreshToken = decrypt(connection.refreshToken);
if (
connection.tokenExpiresAt.getTime() - TOKEN_REFRESH_BUFFER_MS <
connection.tokenExpiresAt.getTime() - TOKEN_REFRESH_BUFFER_MS >=
Date.now()
) {
try {
const creds = await getUserWhoopCredentials(userId);
if (!creds) {
getEvent()?.addWarning(
`No WHOOP credentials found for user ${userId} during token refresh`,
);
await recordSyncFailure({
userId,
integration: "whoop",
kind: "reauth_required",
message: "WHOOP credentials missing — token refresh skipped",
errorCode: "credentials_missing",
});
return null;
}
const newTokens = await refreshAccessToken(refreshToken, creds);
const expiresAt = new Date(Date.now() + newTokens.expires_in * 1000);
// WHOOP rotates the refresh token on every refresh. Persist BOTH the new
// access token AND the new refresh token with a compare-and-swap on the
// stored ciphertext: a concurrent sync that already rotated the token is
// not clobbered, and THIS caller — if it lost the race — reuses the peer's
// rotated access token instead of dropping the connection to reauth.
const persistedAccessToken = await persistRotatedToken(
newTokens.access_token,
{
conditionalUpdate: async () => {
const { count } = await prisma.whoopConnection.updateMany({
// `connection.refreshToken` is the exact ciphertext we read +
// spent; matching it is the CAS guard (a re-encrypt would differ).
where: {
id: connection.id,
refreshToken: connection.refreshToken,
},
data: {
accessToken: encrypt(newTokens.access_token),
refreshToken: encrypt(newTokens.refresh_token),
tokenExpiresAt: expiresAt,
},
});
return count;
},
readPeerAccessToken: async () => {
const fresh = await prisma.whoopConnection.findUnique({
where: { id: connection.id },
select: { accessToken: true },
});
return fresh ? decrypt(fresh.accessToken) : null;
},
},
);
if (!persistedAccessToken) return null;
return {
accessToken: persistedAccessToken,
connection: {
id: connection.id,
whoopUserId: connection.whoopUserId,
},
};
} catch (err) {
getEvent()?.addWarning(
`WHOOP token refresh failed for user ${userId}: ${err}`,
);
await recordWhoopSyncFailure(userId, err);
return null;
}
return {
accessToken: decrypt(connection.accessToken),
connection: {
id: connection.id,
whoopUserId: connection.whoopUserId,
},
};
}
return {
accessToken,
connection: {
id: connection.id,
whoopUserId: connection.whoopUserId,
},
};
const creds = await getUserWhoopCredentials(userId);
if (!creds) {
getEvent()?.addWarning(
`No WHOOP credentials found for user ${userId} during token refresh`,
);
await recordSyncFailure({
userId,
integration: "whoop",
kind: "reauth_required",
message: "WHOOP credentials missing — token refresh skipped",
errorCode: "credentials_missing",
});
return null;
}
try {
return await prisma.$transaction(async (tx) => {
await acquireProviderTokenRefreshLock(tx, "whoop", userId);
const current = await tx.whoopConnection.findUnique({
where: { userId },
});
if (!current) return null;
if (
current.tokenExpiresAt.getTime() - TOKEN_REFRESH_BUFFER_MS >=
Date.now()
) {
return {
accessToken: decrypt(current.accessToken),
connection: {
id: current.id,
whoopUserId: current.whoopUserId,
},
};
}
const newTokens = await refreshAccessToken(
decrypt(current.refreshToken),
creds,
);
const expiresAt = new Date(Date.now() + newTokens.expires_in * 1000);
await tx.whoopConnection.update({
where: { id: current.id },
data: {
accessToken: encrypt(newTokens.access_token),
refreshToken: encrypt(newTokens.refresh_token),
tokenExpiresAt: expiresAt,
},
});
return {
accessToken: newTokens.access_token,
connection: {
id: current.id,
whoopUserId: current.whoopUserId,
},
};
}, PROVIDER_REFRESH_TRANSACTION_OPTIONS);
} catch (err) {
getEvent()?.addWarning(
`WHOOP token refresh failed for user ${userId}: ${err}`,
);
await recordWhoopSyncFailure(userId, err);
return null;
}
}
/**
@@ -321,11 +323,13 @@ export interface WhoopMeasurementUpsert {
sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED" | null;
}
const WHOOP_MEASUREMENT_TRANSACTION_CHUNK_SIZE = 100;
/**
* Upsert a batch of mapped WHOOP readings for one user and fold the rollup
* tier + invalidate status-insight caches once at the end (mirrors the
* Withings sync tail). Idempotent: the `(userId, type, source, externalId)`
* unique key makes a re-post (WHOOP re-score) overwrite in place. Returns the
* Upsert mapped WHOOP readings for one user in bounded transactions, then fold
* the rollup tier + invalidate status-insight caches once at the end (mirrors
* the Withings sync tail). The shared reconciler protects both external and
* natural identity; an exact WHOOP re-score overwrites in place. Returns the
* count of rows written.
*
* Best-effort on the rollup fold + insight invalidate — a populator hiccup
@@ -341,103 +345,100 @@ export async function upsertWhoopMeasurements(
if (readings.length === 0) return 0;
let imported = 0;
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
let insertedRows: Array<
InsertedMeasurementArrivalRow & { externalId: string | null }
> = [];
const touchedTypes = new Set<MeasurementType>();
const invalidateTouchedTypes = (): void => {
if (touchedTypes.size === 0) return;
try {
insertedRows = await prisma.measurement.createManyAndReturn({
data: readings.map((r) => ({
userId,
type: r.type as MeasurementType,
source: "WHOOP" as const,
value: r.value,
unit: r.unit,
measuredAt: r.measuredAt,
externalId: r.externalId,
sleepStage: r.sleepStage ?? null,
})),
skipDuplicates: true,
select: {
id: true,
type: true,
measuredAt: true,
externalId: true,
void invalidateStatusInsightsForTypes(userId, [...touchedTypes]).catch(
(err) => {
getEvent()?.addWarning(
`whoop: status-insight invalidate failed for ${userId}: ${err}`,
);
},
});
imported += insertedRows.length;
for (const row of insertedRows) {
touched.push({ type: row.type, measuredAt: row.measuredAt });
}
} catch (err) {
getEvent()?.addWarning(`WHOOP: failed to create measurements: ${err}`);
}
const insertedIdentityCounts = new Map<string, number>();
for (const row of insertedRows) {
const key = `${row.type}:${row.externalId ?? ""}`;
insertedIdentityCounts.set(key, (insertedIdentityCounts.get(key) ?? 0) + 1);
}
for (const r of readings) {
const type = r.type as MeasurementType;
const key = `${type}:${r.externalId}`;
const insertedCount = insertedIdentityCounts.get(key) ?? 0;
if (insertedCount > 0) {
insertedIdentityCounts.set(key, insertedCount - 1);
continue;
}
try {
await prisma.measurement.update({
where: {
userId_type_source_externalId: {
userId,
type,
source: "WHOOP",
externalId: r.externalId,
},
},
data: {
value: r.value,
unit: r.unit,
measuredAt: r.measuredAt,
sleepStage: r.sleepStage ?? null,
deletedAt: null,
syncVersion: { increment: 1 },
},
});
touched.push({ type, measuredAt: r.measuredAt });
imported++;
} catch (err) {
getEvent()?.addWarning(`WHOOP: failed to update measurement: ${err}`);
}
}
opts.onInserted?.(insertedRows);
void emitInsertedMeasurementArrivals(userId, insertedRows, "whoop").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(
`whoop: status-insight invalidate failed for ${userId}: ${err}`,
);
});
} catch (err) {
getEvent()?.addWarning(
`whoop: rollup recompute failed for ${userId}: ${err}`,
);
};
for (
let chunkStart = 0;
chunkStart < readings.length;
chunkStart += WHOOP_MEASUREMENT_TRANSACTION_CHUNK_SIZE
) {
const chunkEnd = Math.min(
chunkStart + WHOOP_MEASUREMENT_TRANSACTION_CHUNK_SIZE,
readings.length,
);
const verdicts = await prisma
.$transaction(
async (tx) => {
const outcomes = [];
for (let index = chunkStart; index < chunkEnd; index++) {
const reading = readings[index]!;
const verdict = await reconcileExternalMeasurement(
tx,
{
userId,
type: reading.type as MeasurementType,
source: "WHOOP",
value: reading.value,
unit: reading.unit,
measuredAt: reading.measuredAt,
externalId: reading.externalId,
sleepStage: reading.sleepStage ?? null,
},
{ exactExternalMatch: "update" },
);
if (verdict.status === "failed") {
throw new MeasurementReconciliationError(verdict);
}
outcomes.push(verdict);
}
return outcomes;
},
{ maxWait: 10_000, timeout: 60_000 },
)
.catch((err) => {
invalidateTouchedTypes();
throw err;
});
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
const insertedRows: Array<
InsertedMeasurementArrivalRow & { externalId: string | null }
> = [];
for (let index = chunkStart; index < chunkEnd; index++) {
const reading = readings[index]!;
const verdict = verdicts[index - chunkStart]!;
for (const dirty of verdict.dirtyIdentities ?? []) {
touched.push(dirty);
touchedTypes.add(dirty.type);
}
const type = reading.type as MeasurementType;
touchedTypes.add(type);
imported++;
touched.push({ type, measuredAt: reading.measuredAt });
if (verdict.status === "inserted") {
insertedRows.push(verdict.row);
}
}
opts.onInserted?.(insertedRows);
void emitInsertedMeasurementArrivals(userId, insertedRows, "whoop").catch(
() => {},
);
try {
const keys = collapseToTypeDayKeys(touched);
for (const k of keys) {
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
}
} catch (err) {
getEvent()?.addWarning(
`whoop: rollup recompute failed for ${userId}: ${err}`,
);
}
}
invalidateTouchedTypes();
return imported;
}
@@ -16,6 +16,9 @@ vi.mock("@/lib/db", () => ({
ecgRecording: {
upsert: vi.fn(),
},
withingsConnection: {
findUnique: vi.fn(),
},
},
}));
@@ -68,6 +71,8 @@ import {
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
import { getValidToken, recordWithingsSyncFailure } from "../sync";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import {
fetchWithingsHeartList,
@@ -112,6 +117,12 @@ function installFetchMock(
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(isReauthRequired).mockResolvedValue(false);
vi.mocked(getValidToken).mockResolvedValue({
accessToken: "token",
} as never);
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
id: "connection-1",
} as never);
vi.mocked(prisma.measurement.upsert).mockResolvedValue({
id: "m-1",
} as never);
@@ -120,6 +131,7 @@ beforeEach(() => {
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("mapWithingsAfib", () => {
@@ -207,6 +219,30 @@ describe("syncUserEcg", () => {
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings");
});
it("never narrows the broad catch-up to an ordinary webhook window", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-20T12:00:00.000Z"));
const fetchMock = installFetchMock([]);
const recentStart = Math.floor(
new Date("2026-07-19T12:00:00.000Z").getTime() / 1000,
);
const recentEnd = Math.floor(
new Date("2026-07-20T12:00:00.000Z").getTime() / 1000,
);
await syncUserEcg("user-1", {
startdate: recentStart,
enddate: recentEnd,
});
const init = fetchMock.mock.calls[0][1] as { body: string };
const broadStart = Math.floor(
new Date("2026-06-20T12:00:00.000Z").getTime() / 1000,
);
expect(init.body).toContain(`startdate=${broadStart}`);
expect(init.body).toContain(`enddate=${recentEnd}`);
});
it("skips a heart entry that carries no ECG / afib verdict", async () => {
installFetchMock([
{ timestamp: 1715000000, ecg: null },
@@ -321,6 +357,58 @@ describe("syncUserEcg", () => {
expect(keys[0]).toEqual(keys[1]);
});
it("retries token acquisition failures while the owned connection still exists", async () => {
installFetchMock([]);
vi.mocked(getValidToken).mockResolvedValue(null);
await expect(syncUserEcg("user-1")).rejects.toThrow(
"Withings ECG token unavailable",
);
expect(prisma.withingsConnection.findUnique).toHaveBeenCalledWith({
where: { userId: "user-1" },
select: { id: true },
});
expect(recordSyncSuccess).not.toHaveBeenCalled();
});
it("does not mark the source complete when an ECG event write fails", async () => {
installFetchMock([
{ timestamp: 1715000000, ecg: { signalid: 111, afib: 1 } },
]);
vi.mocked(prisma.measurement.upsert).mockRejectedValue(
new Error("source write failed"),
);
await expect(syncUserEcg("user-1")).rejects.toThrow("source write failed");
expect(recordSyncSuccess).not.toHaveBeenCalled();
expect(recordWithingsSyncFailure).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({ message: "source write failed" }),
);
});
it("continues later ECG rows before retrying a failed source write", async () => {
installFetchMock([
{ timestamp: 1715000000, ecg: { signalid: 111, afib: 1 } },
{ timestamp: 1715003600, ecg: { signalid: 222, afib: 0 } },
]);
const poison = new Error("poison row");
vi.mocked(prisma.measurement.upsert)
.mockRejectedValueOnce(poison)
.mockResolvedValueOnce({ id: "m-2" } as never);
await expect(syncUserEcg("user-1")).rejects.toThrow("poison row");
expect(prisma.measurement.upsert).toHaveBeenCalledTimes(2);
expect(recomputeBucketsForMeasurement).toHaveBeenCalledWith(
"user-1",
"IRREGULAR_RHYTHM_NOTIFICATION",
new Date("2024-05-06T00:00:00.000Z"),
);
expect(recordWithingsSyncFailure).toHaveBeenCalledWith("user-1", poison);
expect(recordSyncSuccess).not.toHaveBeenCalled();
});
it("still captures the AFib verdict when the waveform fetch fails", async () => {
// List returns an entry, but the signal GET returns a hard-fail body.
const fetchMock = vi.fn(async (_url: string, init: { body: string }) => {
@@ -24,6 +24,8 @@ vi.mock("@/lib/db", () => ({
findUnique: vi.fn(),
update: vi.fn(),
},
$queryRaw: vi.fn(),
$transaction: vi.fn(),
},
}));
@@ -38,6 +40,13 @@ vi.mock("../client", () => ({
subscribeWebhook: vi.fn(),
}));
vi.mock("../credentials", () => ({
getUserWithingsCredentials: vi.fn(async () => ({
clientId: "cid",
clientSecret: "secret",
})),
}));
vi.mock("@/lib/integrations/status", async () => {
const actual = await vi.importActual<
typeof import("@/lib/integrations/status")
@@ -77,13 +86,13 @@ vi.mock("@/lib/logging/context", () => ({
}));
import { prisma } from "@/lib/db";
import { fetchMeasurements } from "../client";
import { fetchMeasurements, refreshAccessToken } from "../client";
import {
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
import { syncUserMeasurements } from "../sync";
import { getValidToken, syncUserMeasurements } from "../sync";
const P2002 = Object.assign(new Error("Unique constraint failed"), {
code: "P2002",
@@ -91,6 +100,9 @@ const P2002 = Object.assign(new Error("Unique constraint failed"), {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.$transaction).mockImplementation(async (run) =>
run(prisma as never),
);
// A live, non-expired connection so `getValidToken` returns the token
// without touching the refresh path.
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
@@ -107,6 +119,39 @@ beforeEach(() => {
vi.mocked(prisma.measurement.update).mockResolvedValue({} as never);
});
describe("getValidToken — serialized rotating refresh", () => {
it("re-reads token state under an advisory lock and reuses a peer winner", async () => {
vi.mocked(prisma.withingsConnection.findUnique)
.mockResolvedValueOnce({
id: "conn-1",
withingsUserId: "wu-1",
accessToken: "old-access",
refreshToken: "old-refresh",
tokenExpiresAt: new Date(Date.now() - 1000),
} as never)
.mockResolvedValueOnce({
id: "conn-1",
withingsUserId: "wu-1",
accessToken: "winner-access",
refreshToken: "winner-refresh",
tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
} as never);
const result = await getValidToken("user-1");
expect(result?.accessToken).toBe("winner-access");
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
expect(refreshAccessToken).not.toHaveBeenCalled();
expect(prisma.$transaction).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
maxWait: expect.any(Number),
timeout: expect.any(Number),
}),
);
});
});
describe("syncUserMeasurements — watermark hold on hard row failure (F4)", () => {
it("holds lastSyncedAt and records a failure when a row hard-fails", async () => {
vi.mocked(fetchMeasurements).mockResolvedValue([
+76 -90
View File
@@ -8,6 +8,11 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { reconcileMock, transactionMock } = vi.hoisted(() => ({
reconcileMock: vi.fn(),
transactionMock: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
@@ -18,9 +23,15 @@ vi.mock("@/lib/db", () => ({
updateMany: vi.fn(),
},
withingsConnection: { findUnique: vi.fn() },
$transaction: transactionMock,
},
}));
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
reconcileExternalMeasurement: reconcileMock,
MeasurementReconciliationError: class extends Error {},
}));
vi.mock("@/lib/integrations/status", () => ({
isReauthRequired: vi.fn().mockResolvedValue(false),
parkIntegrationAtReauth: vi.fn(),
@@ -96,6 +107,23 @@ function installFetchMock(segments: FakeSegment[]) {
beforeEach(() => {
vi.resetAllMocks();
transactionMock.mockImplementation(async (run: (tx: unknown) => unknown) =>
run({}),
);
reconcileMock.mockImplementation(
async (
_tx: unknown,
input: { type: string; measuredAt: Date; externalId: string },
) => ({
status: "inserted",
row: {
id: `inserted:${input.externalId}`,
type: input.type,
measuredAt: input.measuredAt,
externalId: input.externalId,
},
}),
);
// v1.4.26 — every syncUserSleep call now reads the connection's
// scope to short-circuit legacy `user.metrics`-only connections.
// Default-mock to "scope is fine" so the existing segment-mapping
@@ -249,16 +277,14 @@ describe("syncUserSleep — segment writes + idempotency", () => {
const imported = await syncUserSleep("user-1");
expect(imported).toBe(4);
const stages = vi
.mocked(prisma.measurement.create)
.mock.calls.map((c) => (c[0].data as { sleepStage: string }).sleepStage);
expect(stages).toEqual(["CORE", "DEEP", "REM", "AWAKE"]);
// First segment: 60 minutes (3600s / 60).
const firstDuration = vi.mocked(prisma.measurement.create).mock
.calls[0][0] as { data: { value: number; unit: string } };
expect(firstDuration.data.value).toBe(60);
expect(firstDuration.data.unit).toBe("minutes");
const writes = reconcileMock.mock.calls.map((call) => call[1]);
expect(writes.map((row) => row.sleepStage)).toEqual([
"CORE",
"DEEP",
"REM",
"AWAKE",
]);
expect(writes[0]).toMatchObject({ value: 60, unit: "minutes" });
});
it("stamps measuredAt at the segment END (enddate, unix seconds → Date)", async () => {
@@ -269,13 +295,11 @@ describe("syncUserSleep — segment writes + idempotency", () => {
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
await syncUserSleep("user-1");
const arg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as {
data: { measuredAt: Date };
};
const row = reconcileMock.mock.calls[0]![1] as { measuredAt: Date };
// measuredAt is the segment END — every reader treats it as the END and
// resolves the span as start = end − duration. Stamping the START shifted
// the night one segment-length earlier.
expect(arg.data.measuredAt.getTime()).toBe(1715003600 * 1000);
expect(row.measuredAt.getTime()).toBe(1715003600 * 1000);
});
it("skips state 4 (synthetic marker) without throwing", async () => {
@@ -288,9 +312,7 @@ describe("syncUserSleep — segment writes + idempotency", () => {
const imported = await syncUserSleep("user-1");
expect(imported).toBe(1);
const stages = vi
.mocked(prisma.measurement.create)
.mock.calls.map((c) => (c[0].data as { sleepStage: string }).sleepStage);
const stages = reconcileMock.mock.calls.map((call) => call[1].sleepStage);
expect(stages).toEqual(["DEEP"]);
});
@@ -298,27 +320,23 @@ describe("syncUserSleep — segment writes + idempotency", () => {
installFetchMock([
{ startdate: 1715000000, enddate: 1715003600, state: 2, id: 1 },
]);
// findFirst returns an existing row → writer takes the update path.
vi.mocked(prisma.measurement.findFirst).mockResolvedValue({
id: "row-1",
} as never);
vi.mocked(prisma.measurement.update).mockResolvedValue({} as never);
reconcileMock.mockResolvedValueOnce({
status: "updated",
row: {
id: "row-1",
type: "SLEEP_DURATION",
measuredAt: new Date(1715003600 * 1000),
externalId: "withings:sleep:user-1:1:1715000000",
},
});
const imported = await syncUserSleep("user-1");
expect(imported).toBe(1);
expect(prisma.measurement.create).not.toHaveBeenCalled();
// Re-sync keys on the stable externalId and refreshes value, the
// END-stamped measuredAt (enddate may shift) and the stage.
expect(prisma.measurement.update).toHaveBeenCalledWith({
where: { id: "row-1" },
data: {
value: 60,
measuredAt: new Date(1715003600 * 1000),
sleepStage: "DEEP",
// No-op on a live row, resurrection on a tombstoned one — the
// source-owned resurrect rule.
deletedAt: null,
},
expect(reconcileMock.mock.calls[0]![1]).toMatchObject({
value: 60,
measuredAt: new Date(1715003600 * 1000),
sleepStage: "DEEP",
externalId: "withings:sleep:user-1:1:1715000000",
});
});
@@ -333,32 +351,13 @@ describe("syncUserSleep — segment writes + idempotency", () => {
installFetchMock([
{ startdate: 1715000500, enddate: 1715003600, state: 2, id: 42 },
]);
vi.mocked(prisma.measurement.findFirst).mockImplementation((async (args: {
where: Record<string, unknown>;
}) => {
const where = args.where;
// externalId probe (START shifted ⇒ new id) → miss.
if ("externalId" in where) return null;
// natural-key probe (END + stage unchanged) → the surviving prior row.
if ("measuredAt" in where) return { id: "old-row" };
return null;
}) as never);
vi.mocked(prisma.measurement.update).mockResolvedValue({} as never);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
const imported = await syncUserSleep("user-1");
expect(imported).toBe(1);
// No collision: the survivor is re-keyed in place, never re-created.
expect(prisma.measurement.create).not.toHaveBeenCalled();
expect(prisma.measurement.update).toHaveBeenCalledWith({
where: { id: "old-row" },
data: {
value: expect.any(Number),
// Re-keyed onto the shifted START-based externalId and resurrected.
externalId: "withings:sleep:user-1:42:1715000500",
deletedAt: null,
},
expect(reconcileMock.mock.calls[0]![1]).toMatchObject({
value: expect.any(Number),
measuredAt: new Date(1715003600 * 1000),
sleepStage: "DEEP",
externalId: "withings:sleep:user-1:42:1715000500",
});
});
@@ -370,14 +369,12 @@ describe("syncUserSleep — segment writes + idempotency", () => {
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
await syncUserSleep("user-1");
const arg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as {
data: { externalId: string };
};
const row = reconcileMock.mock.calls[0]![1] as { externalId: string };
// Session id + the segment's own startdate — both stable within a session.
// The retired running index counted across the whole rolling 30-day fetch
// window, so the window slide renumbered every segment on each sync and a
// re-aggregated night inserted a duplicate set.
expect(arg.data.externalId).toBe("withings:sleep:user-1:42:1715000000");
expect(row.externalId).toBe("withings:sleep:user-1:42:1715000000");
});
it("mints IDENTICAL externalIds when the fetch window slides (re-fetch of the same night)", async () => {
@@ -395,19 +392,18 @@ describe("syncUserSleep — segment writes + idempotency", () => {
installFetchMock(night);
await syncUserSleep("user-1");
const firstIds = vi
.mocked(prisma.measurement.create)
.mock.calls.map((c) => (c[0].data as { externalId: string }).externalId);
const firstIds = reconcileMock.mock.calls.map(
(call) => call[1].externalId as string,
);
vi.mocked(prisma.measurement.create).mockClear();
reconcileMock.mockClear();
installFetchMock([
{ startdate: 1714990000, enddate: 1714993600, state: 1, id: 7 },
...night,
]);
await syncUserSleep("user-1");
const secondIds = vi
.mocked(prisma.measurement.create)
.mock.calls.map((c) => (c[0].data as { externalId: string }).externalId)
const secondIds = reconcileMock.mock.calls
.map((call) => call[1].externalId as string)
.filter((id) => id.includes(":42:"));
expect(secondIds).toEqual(firstIds);
@@ -496,28 +492,20 @@ describe("syncUserSleep — segment writes + idempotency", () => {
},
],
);
vi.mocked(prisma.measurement.upsert).mockResolvedValue({} as never);
reconcileMock.mockResolvedValue({ status: "updated", row: {} });
const imported = await syncUserSleep("user-1");
// Five vitals upserted (no stage segments in this fixture).
expect(imported).toBe(5);
const calls = vi.mocked(prisma.measurement.upsert).mock.calls;
const byType = new Map(
calls.map((c) => {
const w = c[0] as {
where: {
userId_type_source_externalId: { type: string; externalId: string };
};
create: { value: number };
reconcileMock.mock.calls.map((call) => {
const row = call[1] as {
type: string;
externalId: string;
value: number;
};
return [
w.where.userId_type_source_externalId.type,
{
externalId: w.where.userId_type_source_externalId.externalId,
value: w.create.value,
},
];
return [row.type, { externalId: row.externalId, value: row.value }];
}),
);
expect(byType.get("RESTING_HEART_RATE")).toEqual({
@@ -565,7 +553,7 @@ describe("syncUserSleep — segment writes + idempotency", () => {
const imported = await syncUserSleep("user-1");
// The one stage segment is still written; the summary failure is swallowed.
expect(imported).toBe(1);
expect(prisma.measurement.create).toHaveBeenCalledTimes(1);
expect(reconcileMock).toHaveBeenCalledTimes(1);
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings");
});
@@ -582,9 +570,7 @@ describe("syncUserSleep — segment writes + idempotency", () => {
const imported = await syncUserSleep("user-1");
expect(imported).toBe(2);
const stages = vi
.mocked(prisma.measurement.create)
.mock.calls.map((c) => (c[0].data as { sleepStage: string }).sleepStage);
const stages = reconcileMock.mock.calls.map((call) => call[1].sleepStage);
expect(stages).not.toContain("REM");
});
});
@@ -601,7 +587,7 @@ describe("syncUserSleep — scope-skip guard (v1.4.26)", () => {
expect(imported).toBe(0);
expect(fetchMock).not.toHaveBeenCalled();
expect(prisma.measurement.create).not.toHaveBeenCalled();
expect(reconcileMock).not.toHaveBeenCalled();
expect(recordSyncSuccess).not.toHaveBeenCalled();
});
+39 -9
View File
@@ -340,14 +340,19 @@ async function captureEcgWaveform(params: {
}
/**
* Sync ECG / AFib recordings for a single user. Walks the trailing 30-day
* window, writing one `IRREGULAR_RHYTHM_NOTIFICATION` row per ECG recording
* tagged with the device's AFib verdict. Returns the number of upserted rows.
* Sync ECG / AFib recordings for a single user. Always walks at least the
* trailing 30 days and widens that catch-up to include an older webhook source
* window when supplied. Writes
* one `IRREGULAR_RHYTHM_NOTIFICATION` row per ECG recording tagged with the
* device's AFib verdict. Returns the number of upserted rows.
*
* Park behaviour mirrors the other Withings syncs — a connection at
* `error_reauth` short-circuits before the upstream call.
*/
export async function syncUserEcg(userId: string): Promise<number> {
export async function syncUserEcg(
userId: string,
options: { startdate?: number; enddate?: number } = {},
): Promise<number> {
if (await isReauthRequired(userId, "withings")) {
getEvent()?.addWarning(
`withings ecg sync skipped for ${userId}: parked at error_reauth`,
@@ -356,14 +361,32 @@ export async function syncUserEcg(userId: string): Promise<number> {
}
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
if (!tokenInfo) {
if (await isReauthRequired(userId, "withings")) return 0;
const connection = await prisma.withingsConnection.findUnique({
where: { userId },
select: { id: true },
});
if (!connection) return 0;
throw new Error("Withings ECG token unavailable");
}
const now = new Date();
const start = new Date(
now.getTime() - ECG_BACKFILL_DAYS * 24 * 60 * 60 * 1000,
const defaultStartUnix = Math.floor(
(now.getTime() - ECG_BACKFILL_DAYS * 24 * 60 * 60 * 1000) / 1000,
);
const startUnix = Math.floor(start.getTime() / 1000);
const endUnix = Math.floor(now.getTime() / 1000);
const defaultEndUnix = Math.floor(now.getTime() / 1000);
const hasProviderWindow =
Number.isSafeInteger(options.startdate) &&
Number.isSafeInteger(options.enddate) &&
options.startdate! >= 0 &&
options.startdate! <= options.enddate!;
const startUnix = hasProviderWindow
? Math.min(defaultStartUnix, options.startdate!)
: defaultStartUnix;
const endUnix = hasProviderWindow
? Math.max(defaultEndUnix, options.enddate!)
: defaultEndUnix;
let entries: WithingsHeartEntry[];
try {
@@ -395,6 +418,7 @@ export async function syncUserEcg(userId: string): Promise<number> {
let imported = 0;
let waveformsCaptured = 0;
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
let sourceWriteError: { cause: unknown } | null = null;
for (const entry of entries) {
const classification = mapWithingsAfib(entry.ecg?.afib);
@@ -444,6 +468,7 @@ export async function syncUserEcg(userId: string): Promise<number> {
getEvent()?.addWarning(
`withings ecg: failed to upsert recording (${classification}, ${measuredAt.toISOString()}): ${err}`,
);
sourceWriteError ??= { cause: err };
continue;
}
@@ -500,6 +525,11 @@ export async function syncUserEcg(userId: string): Promise<number> {
);
}
if (sourceWriteError) {
await recordWithingsSyncFailure(userId, sourceWriteError.cause);
throw sourceWriteError.cause;
}
await recordSyncSuccess(userId, "withings");
return imported;
}
+72 -163
View File
@@ -54,6 +54,10 @@ import {
sweepStaleSleepSegments,
type SleepSegmentSweep,
} from "@/lib/sleep/sweep-stale-segments";
import {
MeasurementReconciliationError,
reconcileExternalMeasurement,
} from "@/lib/measurements/reconcile-external-measurement";
import { hasActivityScope } from "./client";
import {
@@ -408,131 +412,51 @@ export async function syncUserSleep(
// and are excluded (their `no-id` prefix would span every id-less night in
// history, not just this fetch).
const freshBySession = new Map<number, string[]>();
for (const segment of segments) {
const stage = mapWithingsSleepState(segment.state);
if (!stage) {
// State 4 (manual / synthetic marker) is ignored — see file
// header.
continue;
}
// `measuredAt` is the segment END (`enddate`). Every reader treats
// `measuredAt` as the END instant — `segmentOf` resolves the span as
// `start = measuredAt − value·60_000` (sleep-night.ts). Stamping the START
// here shifted the whole night one segment-length earlier; END is correct.
const measuredAt = new Date(segment.enddate * 1000);
const durationSec = Math.max(0, segment.enddate - segment.startdate);
// Canonical SLEEP_DURATION unit is minutes (Apple Health alignment,
// v1.4.23 schema note). Round so re-sync doesn't flip values on
// floating-point noise.
const minutes = Math.round(durationSec / 60);
await prisma.$transaction(
async (tx) => {
for (const segment of segments) {
const stage = mapWithingsSleepState(segment.state);
if (!stage) continue;
// Keyed by the segment's own START instant — a coordinate that is stable
// within a session — NOT a positional index. The old running index counted
// across the whole 30-day fetch window, so the sliding window renumbered
// every segment on each sync: the probe below never matched, the update
// branch was dead code, and a re-aggregated night inserted a second set of
// rows next to the first (the same double-count bug class Google Health
// fixed with its stable session anchor). Legacy indexed rows are swept by
// the session-scoped cleanup below, so the format change self-heals.
const externalId = `withings:sleep:${userId}:${segment.id ?? "no-id"}:${segment.startdate}`;
if (typeof segment.id === "number") {
const fresh = freshBySession.get(segment.id) ?? [];
fresh.push(externalId);
freshBySession.set(segment.id, fresh);
}
const measuredAt = new Date(segment.enddate * 1000);
const durationSec = Math.max(0, segment.enddate - segment.startdate);
const minutes = Math.round(durationSec / 60);
const externalId = `withings:sleep:${userId}:${segment.id ?? "no-id"}:${segment.startdate}`;
if (typeof segment.id === "number") {
const fresh = freshBySession.get(segment.id) ?? [];
fresh.push(externalId);
freshBySession.set(segment.id, fresh);
}
try {
// Key the re-sync lookup on the STABLE `externalId` (session id +
// segment start), not `measuredAt`. Withings re-aggregates a night
// between syncs, so a segment's `enddate` — and therefore its
// END-stamped `measuredAt` — shifts; keying on `measuredAt` would miss
// the prior row and then collide with the unique `externalId`, leaving
// the night stuck at the stale value. Update both `value` and
// `measuredAt`.
const existing = await prisma.measurement.findFirst({
where: {
userId,
type: SLEEP_TYPE,
source: "WITHINGS",
externalId,
},
select: { id: true },
});
if (existing) {
await prisma.measurement.update({
where: { id: existing.id },
// `deletedAt: null` is a no-op on a live row, a deliberate
// RESURRECTION on a tombstoned one — Withings is the source of
// truth for its own rows, so a re-fetched segment brings the row
// back (mirrors Google / Fitbit).
data: {
value: minutes,
measuredAt,
sleepStage: stage,
deletedAt: null,
},
});
} else {
// Natural-key rescue — mirrors the measure path's findFirst in
// `sync.ts`. When Withings re-scores a night it can shift a segment's
// START (its externalId) while the END (`measuredAt`) and the stage
// stay put: the externalId probe above then MISSES, but the 0055
// natural key `(userId, type, measuredAt, source, sleepStage)`
// (NULLS NOT DISTINCT) is already occupied by the prior row. A blind
// `create` P2002s on that key; the collision is swallowed by the catch
// below and the sweep then tombstones the old row — so the re-keyed
// segment is lost and, because a tombstoned row still holds the natural
// key, every later sync re-collides: the night wedges permanently (the
// "0055 erased re-keyed sleep" class). Probe the natural key
// (tombstone-inclusive — no `deletedAt` filter) and re-key that row in
// place, so a re-scored segment updates rather than colliding.
const naturalKeyRow = await prisma.measurement.findFirst({
where: {
const verdict = await reconcileExternalMeasurement(
tx,
{
userId,
type: SLEEP_TYPE,
source: "WITHINGS",
value: minutes,
unit: getUnitForType(SLEEP_TYPE),
measuredAt,
source: "WITHINGS",
sleepStage: stage,
externalId,
},
select: { id: true },
});
if (naturalKeyRow) {
await prisma.measurement.update({
where: { id: naturalKeyRow.id },
// Re-key the surviving row onto the new START-based externalId and
// resurrect it if the sweep had tombstoned it. `measuredAt` and
// `sleepStage` already match by construction (they ARE the natural
// key we matched on), so only value + externalId + deletedAt move.
data: {
value: minutes,
externalId,
deletedAt: null,
},
});
} else {
await prisma.measurement.create({
data: {
userId,
type: SLEEP_TYPE,
value: minutes,
unit: getUnitForType(SLEEP_TYPE),
measuredAt,
source: "WITHINGS",
sleepStage: stage,
externalId,
},
});
{ exactExternalMatch: "update" },
);
if (verdict.status === "failed") {
throw new MeasurementReconciliationError(verdict);
}
for (const dirty of verdict.dirtyIdentities ?? []) {
touched.push(dirty);
}
if (verdict.status === "inserted") {
insertedSleepMeasuredAts.push(measuredAt);
}
touched.push({ type: SLEEP_TYPE, measuredAt });
imported++;
}
touched.push({ type: SLEEP_TYPE, measuredAt });
imported++;
} catch (err) {
getEvent()?.addWarning(
`Failed to upsert sleep segment (${stage}, ${measuredAt.toISOString()}): ${err}`,
);
}
}
},
{ timeout: 60_000 },
);
// Session-scoped sweep (mirrors Google Health's replace-by-window): for each
// session this fetch re-produced, soft-delete any live WITHINGS
@@ -550,68 +474,53 @@ export async function syncUserSleep(
await sweepStaleSleepSegments(userId, "WITHINGS", sweeps);
// v1.18.10 P0 — nightly sleep vitals (avg HR / respiratory rate / SDNN HRV
// / avg SpO2 / sleep score) from the per-night summary. A summary-fetch
// failure must not lose the stage segments already written, so it runs in
// its own try/catch and only annotates on error. Each vital upserts on the
// stable `(userId, type, source, externalId)` composite so a re-sync
// overwrites the night's value rather than inserting a duplicate.
// / avg SpO2 / sleep score) from the per-night summary. Fetch failure stays
// isolated from the stage writes; database failures are hard sync failures.
let summaries: WithingsSleepSummary[] = [];
try {
const startYmd = ymdUtc(start);
const endYmd = ymdUtc(now);
const summaries = await fetchWithingsSleepSummary(
summaries = await fetchWithingsSleepSummary(
tokenInfo.accessToken,
startYmd,
endYmd,
ymdUtc(start),
ymdUtc(now),
);
for (const summary of summaries) {
const measuredAt = new Date(summary.enddate * 1000);
const sessionId = summary.id ?? "no-id";
const vitals = mapWithingsSleepSummary(summary.data);
for (const vital of vitals) {
const externalId = `withings:sleep:${userId}:${sessionId}:${vital.fieldTag}`;
try {
await prisma.measurement.upsert({
where: {
userId_type_source_externalId: {
userId,
type: vital.type,
source: "WITHINGS",
externalId,
},
},
create: {
} catch (err) {
getEvent()?.addWarning(
`withings sleep: summary fetch failed for ${userId}: ${err}`,
);
}
await prisma.$transaction(
async (tx) => {
for (const summary of summaries) {
const measuredAt = new Date(summary.enddate * 1000);
const sessionId = summary.id ?? "no-id";
for (const vital of mapWithingsSleepSummary(summary.data)) {
const verdict = await reconcileExternalMeasurement(
tx,
{
userId,
type: vital.type,
value: vital.value,
unit: getUnitForType(vital.type),
measuredAt,
source: "WITHINGS",
externalId,
externalId: `withings:sleep:${userId}:${sessionId}:${vital.fieldTag}`,
},
update: {
value: vital.value,
measuredAt,
// No-op on a live row, a deliberate RESURRECTION on a
// tombstoned one — Withings is the source of truth for its
// own rows (mirrors Google / Fitbit).
deletedAt: null,
syncVersion: { increment: 1 },
},
});
{ exactExternalMatch: "update" },
);
if (verdict.status === "failed") {
throw new MeasurementReconciliationError(verdict);
}
for (const dirty of verdict.dirtyIdentities ?? []) {
touched.push(dirty);
}
touched.push({ type: vital.type, measuredAt });
imported++;
} catch (err) {
getEvent()?.addWarning(
`withings sleep: failed to upsert vital (${vital.type}, ${measuredAt.toISOString()}): ${err}`,
);
}
}
}
} catch (err) {
getEvent()?.addWarning(
`withings sleep: summary fetch failed for ${userId}: ${err}`,
);
}
},
{ timeout: 60_000 },
);
// v1.4.39.1 — refresh the persistent rollup table for every distinct
// (type, day) the sync touched. Sleep segments collapse heavily —
+59 -40
View File
@@ -32,6 +32,10 @@ import {
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
import {
acquireProviderTokenRefreshLock,
PROVIDER_REFRESH_TRANSACTION_OPTIONS,
} from "@/lib/integrations/oauth-refresh";
/**
* Build the callback URL handed to Withings at `Notify.subscribe` time.
@@ -74,35 +78,58 @@ export async function getValidToken(userId: string): Promise<{
if (!connection) return null;
const accessToken = decrypt(connection.accessToken);
const refreshToken = decrypt(connection.refreshToken);
if (connection.tokenExpiresAt.getTime() - 5 * 60 * 1000 >= Date.now()) {
return {
accessToken: decrypt(connection.accessToken),
connection: {
id: connection.id,
withingsUserId: connection.withingsUserId,
},
};
}
// Check if token is expired (with 5 min buffer)
if (connection.tokenExpiresAt.getTime() - 5 * 60 * 1000 < Date.now()) {
try {
const creds = await getUserWithingsCredentials(userId);
if (!creds) {
getEvent()?.addWarning(
`No credentials found for user ${userId} during token refresh`,
);
// Without credentials we cannot refresh OR re-authenticate — the
// user has to re-enter the client_id/secret. Mark as reauth so
// scheduled syncs back off.
await recordSyncFailure({
userId,
integration: "withings",
kind: "reauth_required",
message: "Withings credentials missing — token refresh skipped",
errorCode: "credentials_missing",
});
return null;
const creds = await getUserWithingsCredentials(userId);
if (!creds) {
getEvent()?.addWarning(
`No credentials found for user ${userId} during token refresh`,
);
await recordSyncFailure({
userId,
integration: "withings",
kind: "reauth_required",
message: "Withings credentials missing — token refresh skipped",
errorCode: "credentials_missing",
});
return null;
}
try {
return await prisma.$transaction(async (tx) => {
await acquireProviderTokenRefreshLock(tx, "withings", userId);
const current = await tx.withingsConnection.findUnique({
where: { userId },
});
if (!current) return null;
if (current.tokenExpiresAt.getTime() - 5 * 60 * 1000 >= Date.now()) {
return {
accessToken: decrypt(current.accessToken),
connection: {
id: current.id,
withingsUserId: current.withingsUserId,
},
};
}
const newTokens = await refreshAccessToken(refreshToken, creds);
const newTokens = await refreshAccessToken(
decrypt(current.refreshToken),
creds,
);
const expiresAt = new Date(Date.now() + newTokens.expires_in * 1000);
await prisma.withingsConnection.update({
where: { id: connection.id },
await tx.withingsConnection.update({
where: { id: current.id },
data: {
accessToken: encrypt(newTokens.access_token),
refreshToken: encrypt(newTokens.refresh_token),
@@ -113,24 +140,16 @@ export async function getValidToken(userId: string): Promise<{
return {
accessToken: newTokens.access_token,
connection: {
id: connection.id,
withingsUserId: connection.withingsUserId,
id: current.id,
withingsUserId: current.withingsUserId,
},
};
} catch (err) {
getEvent()?.addWarning(`Token refresh failed for user ${userId}: ${err}`);
await recordWithingsSyncFailure(userId, err);
return null;
}
}, PROVIDER_REFRESH_TRANSACTION_OPTIONS);
} catch (err) {
getEvent()?.addWarning(`Token refresh failed for user ${userId}: ${err}`);
await recordWithingsSyncFailure(userId, err);
return null;
}
return {
accessToken,
connection: {
id: connection.id,
withingsUserId: connection.withingsUserId,
},
};
}
/**
+29 -16
View File
@@ -30,6 +30,7 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { getClientIp, safeJson } from "@/lib/api-response";
import { annotate, getEvent } from "@/lib/logging/context";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { enqueueWithingsEcgSync } from "@/lib/jobs/withings-ecg-queue";
/**
* v1.4.25 W17b/c — appli categories that have their own sync routine
@@ -94,6 +95,8 @@ export async function processWithingsNotification(
const contentType = request.headers.get("content-type") ?? "";
let withingsUserId: string | null = null;
let appli: number | null = null;
let startdate: string | null = null;
let enddate: string | null = null;
if (contentType.includes("application/x-www-form-urlencoded")) {
const formData = await request.formData();
@@ -103,10 +106,16 @@ export async function processWithingsNotification(
const parsed = Number.parseInt(String(rawAppli), 10);
if (Number.isFinite(parsed)) appli = parsed;
}
const rawStartdate = formData.get("startdate");
const rawEnddate = formData.get("enddate");
startdate = rawStartdate == null ? null : String(rawStartdate);
enddate = rawEnddate == null ? null : String(rawEnddate);
} else {
const { data: body, error: jsonError } = await safeJson<{
userid?: string | number;
appli?: string | number;
startdate?: string | number;
enddate?: string | number;
}>(request);
if (jsonError) return jsonError;
withingsUserId = body.userid?.toString() ?? null;
@@ -114,6 +123,8 @@ export async function processWithingsNotification(
const parsed = Number.parseInt(String(body.appli), 10);
if (Number.isFinite(parsed)) appli = parsed;
}
startdate = body.startdate?.toString() ?? null;
enddate = body.enddate?.toString() ?? null;
}
if (!withingsUserId) {
@@ -150,27 +161,29 @@ export async function processWithingsNotification(
} else if (appli === WITHINGS_SLEEP_APPLI) {
await enqueueWithingsSync(WITHINGS_SLEEP_QUEUE, connection.userId);
} else {
try {
await enqueueWithingsEcgSync({
userId: connection.userId,
withingsUserId,
appli,
startdate,
enddate,
});
} catch (err) {
getEvent()?.addWarning(
`Failed to durably enqueue Withings ECG event for ${connection.userId}: ${err}`,
);
return NextResponse.json(
{ status: "unavailable" },
{ status: 503, headers: { "retry-after": "60" } },
);
}
syncUserMeasurements(connection.userId).catch((err) => {
getEvent()?.addWarning(
"Sync failed for user " + connection.userId + ": " + err,
);
});
// v1.18.11 — ECG / AFib capture rides the measure path. The Heart List
// endpoint shares the `user.metrics` scope with the measure family, so a
// measure-family notification (which is what a ScanWatch ECG recording
// delivers) is the right trigger. Fire it non-blocking and lazy-loaded so
// the webhook response stays fast and the cold path doesn't pull the ECG
// module in until a measure notification actually lands.
void (async () => {
try {
const { syncUserEcg } = await import("./sync-ecg");
await syncUserEcg(connection.userId);
} catch (err) {
getEvent()?.addWarning(
"ECG sync failed for user " + connection.userId + ": " + err,
);
}
})();
}
return NextResponse.json({ status: "ok" }, { status: 200 });
File diff suppressed because it is too large. Load diff
@@ -100,7 +100,8 @@ describe("POST /api/admin/backups/[id]/restore", () => {
unit: "kg",
measuredAt: "2026-05-08T07:00:00.000Z",
source: "MANUAL",
notes: null,
notes: "private legacy note",
notesEncrypted: null,
},
{
type: "PULSE",
@@ -221,6 +222,8 @@ describe("POST /api/admin/backups/[id]/restore", () => {
expect(measurements[0]!.type).toBe("WEIGHT");
expect(measurements[0]!.value).toBe(80.5);
expect(measurements[1]!.type).toBe("PULSE");
expect(measurements[0]!.notes).toBeNull();
expect(measurements[0]!.notesEncrypted).not.toBeNull();
const medications = await prisma.medication.findMany({
where: { userId: admin.id },
@@ -303,6 +306,8 @@ describe("POST /api/admin/backups/[id]/restore", () => {
});
expect(res.status).toBe(422);
const error = (await res.json()) as { error: string };
expect(error.error).toMatch(/instance-wide settings/i);
const denied = await prisma.auditLog.count({
where: {
action: "admin.backups.restore.denied",
+72 -1
View File
@@ -70,7 +70,34 @@ async function seedAdminSession() {
return admin;
}
function buildPayload(userId: string) {
interface UploadPayloadFixture {
schemaVersion: string;
exportedAt: string;
userId: string;
measurements: Array<{
type: string;
value: number;
unit: string;
measuredAt: string;
source: string;
notes: null;
}>;
medications: Array<{
name: string;
dose: string;
active: boolean;
schedules: Array<{
windowStart: string;
windowEnd: string;
label: string;
dose: null;
}>;
}>;
intakeEvents: [];
moodEntries: [];
}
function buildPayload(userId: string): UploadPayloadFixture {
return {
schemaVersion: "1",
exportedAt: "2026-05-09T10:00:00.000Z",
@@ -211,6 +238,45 @@ describe("POST /api/admin/backups/upload", () => {
expect(denied[0]?.details ?? "").toContain("owner_not_found");
});
it.each([
[
"measurement type",
(payload: UploadPayloadFixture) => {
payload.measurements[0]!.type = "NOT_A_MEASUREMENT";
},
],
[
"measurement source",
(payload: UploadPayloadFixture) => {
payload.measurements[0]!.source = "NOT_A_SOURCE";
},
],
])(
"rejects an invalid %s with 422 and zero backup writes",
async (_label, mutate) => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const payload = buildPayload(admin.id);
mutate(payload);
const file = new File([JSON.stringify(payload)], "invalid-enum.json", {
type: "application/json",
});
const { POST } = await import("@/app/api/admin/backups/upload/route");
const res = await POST(
buildMultipart(file) as unknown as Parameters<typeof POST>[0],
);
expect(res.status).toBe(422);
expect(await res.json()).toMatchObject({
data: null,
error: "Backup payload failed schema validation",
meta: { issues: expect.any(Array) },
});
expect(await prisma.dataBackup.count()).toBe(0);
},
);
it("rejects an incompatible schemaVersion with 422", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
@@ -224,6 +290,11 @@ describe("POST /api/admin/backups/upload", () => {
buildMultipart(file) as unknown as Parameters<typeof POST>[0],
);
expect(await res.json()).toMatchObject({
data: null,
error: expect.stringContaining("not supported"),
});
expect(await prisma.dataBackup.count()).toBe(0);
expect(res.status).toBe(422);
const denied = await prisma.auditLog.findMany({
where: { action: "admin.backups.upload.denied", userId: admin.id },
+238 -39
View File
@@ -10,12 +10,12 @@
* 2. Registering without an `apnsToken` does NOT create the channel —
* the iOS client may upsert device metadata before APNs grants a
* token, and we must not enable APNs delivery prematurely.
* 3. Registering a SECOND device with the same `apnsToken` for the
* same user is idempotent — the channel row stays at one. The
* same-user collision path returns 200 (upsert) rather than 409
* (the cross-user hijack guard).
* 3. Re-registering the same `apnsToken` is idempotent, including when the
* legacy token changes: the route returns 201 and keeps one canonical
* device and one channel. Foreign ownership still returns 409.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { NextRequest } from "next/server";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
@@ -75,23 +75,26 @@ function buildRequest(body: Record<string, unknown>) {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}) as unknown as import("next/server").NextRequest;
}) as unknown as NextRequest;
}
async function registerDevice(body: Record<string, unknown>) {
// Load after the test env and next/headers mock are installed.
const { POST } = await import("@/app/api/devices/route");
return (POST as (request: NextRequest) => Promise<Response>)(
buildRequest(body),
);
}
describe("POST /api/devices", () => {
it("auto-upserts a NotificationChannel { type: APNS } when apnsToken is supplied", async () => {
const user = await seedSession("device-apns-channel");
const { POST } = await import("@/app/api/devices/route");
const req = buildRequest({
const res = await registerDevice({
token: "legacy-token-1",
bundleId: "io.healthlog.ios",
apnsToken: "abcdef0123456789",
apnsEnvironment: "sandbox",
});
const res = await (
POST as (r: import("next/server").NextRequest) => Promise<Response>
)(req);
expect(res.status).toBe(201);
const channel = await getPrismaClient().notificationChannel.findFirst({
@@ -103,15 +106,10 @@ describe("POST /api/devices", () => {
it("does NOT create an APNS channel when apnsToken is omitted", async () => {
const user = await seedSession("device-no-apns");
const { POST } = await import("@/app/api/devices/route");
const req = buildRequest({
const res = await registerDevice({
token: "legacy-token-2",
bundleId: "io.healthlog.ios",
});
const res = await (
POST as (r: import("next/server").NextRequest) => Promise<Response>
)(req);
expect(res.status).toBe(201);
const channel = await getPrismaClient().notificationChannel.findFirst({
@@ -122,30 +120,20 @@ describe("POST /api/devices", () => {
it("re-registering the same apnsToken for the same user keeps the channel row at one", async () => {
const user = await seedSession("device-apns-idempotent");
const { POST } = await import("@/app/api/devices/route");
const first = await (
POST as (r: import("next/server").NextRequest) => Promise<Response>
)(
buildRequest({
token: "legacy-token-3",
bundleId: "io.healthlog.ios",
apnsToken: "deadbeef",
apnsEnvironment: "sandbox",
}),
);
const first = await registerDevice({
token: "legacy-token-3",
bundleId: "io.healthlog.ios",
apnsToken: "deadbeef",
apnsEnvironment: "sandbox",
});
expect(first.status).toBe(201);
const second = await (
POST as (r: import("next/server").NextRequest) => Promise<Response>
)(
buildRequest({
token: "legacy-token-3",
bundleId: "io.healthlog.ios",
apnsToken: "deadbeef",
apnsEnvironment: "sandbox",
}),
);
const second = await registerDevice({
token: "legacy-token-3",
bundleId: "io.healthlog.ios",
apnsToken: "deadbeef",
apnsEnvironment: "sandbox",
});
expect(second.status).toBe(201);
const channels = await getPrismaClient().notificationChannel.findMany({
@@ -153,4 +141,215 @@ describe("POST /api/devices", () => {
});
expect(channels).toHaveLength(1);
});
it("reconciles a changed legacy token onto the same user's APNs device", async () => {
const user = await seedSession("device-changed-token");
const prisma = getPrismaClient();
const first = await registerDevice({
token: "legacy-token-old",
bundleId: "io.healthlog.ios",
apnsToken: "aaaabbbbccccdddd",
apnsEnvironment: "sandbox",
appVersion: "1.0.0",
});
expect(first.status).toBe(201);
const firstBody = (await first.json()) as { data: { id: string } };
const refreshToken = await prisma.refreshToken.create({
data: {
userId: user.id,
tokenHash: "changed-token-refresh-hash",
deviceId: "legacy-token-old",
expiresAt: new Date(Date.now() + 60_000),
},
});
const second = await registerDevice({
token: "legacy-token-new",
bundleId: "io.healthlog.ios.beta",
apnsToken: "aaaabbbbccccdddd",
apnsEnvironment: "production",
appVersion: "2.0.0",
});
expect(second.status).toBe(201);
const secondBody = (await second.json()) as { data: { id: string } };
expect(secondBody.data.id).toBe(firstBody.data.id);
const devices = await prisma.device.findMany({
where: { userId: user.id },
});
expect(devices).toHaveLength(1);
expect(devices[0]).toMatchObject({
id: firstBody.data.id,
token: "legacy-token-new",
bundleId: "io.healthlog.ios.beta",
apnsToken: "aaaabbbbccccdddd",
apnsEnvironment: "production",
appVersion: "2.0.0",
});
await expect(
prisma.refreshToken.findUniqueOrThrow({
where: { id: refreshToken.id },
select: { deviceId: true },
}),
).resolves.toEqual({ deviceId: "legacy-token-new" });
});
it("merges same-user device identities and migrates only owned refresh-token bindings", async () => {
const user = await seedSession("device-identity-merge");
const prisma = getPrismaClient();
const otherUser = await prisma.user.create({
data: {
username: "device-identity-merge-other",
email: "device-identity-merge-other@example.test",
},
});
const apnsDevice = await prisma.device.create({
data: {
userId: user.id,
platform: "ios",
token: "legacy-token-original",
bundleId: "io.healthlog.ios",
apnsToken: "1111222233334444",
apnsEnvironment: "sandbox",
medicationDelivery: "server",
liveActivityPushToken: "feedface".repeat(8),
},
});
await prisma.device.create({
data: {
userId: user.id,
platform: "ios",
token: "legacy-token-current",
bundleId: "io.healthlog.ios",
medicationDelivery: null,
liveActivityPushToken: null,
},
});
const ownedRefreshToken = await prisma.refreshToken.create({
data: {
userId: user.id,
tokenHash: "owned-refresh-token-hash",
deviceId: "legacy-token-original",
expiresAt: new Date(Date.now() + 60_000),
},
});
const foreignRefreshToken = await prisma.refreshToken.create({
data: {
userId: otherUser.id,
tokenHash: "foreign-refresh-token-hash",
deviceId: "legacy-token-original",
expiresAt: new Date(Date.now() + 60_000),
},
});
const response = await registerDevice({
token: "legacy-token-current",
bundleId: "io.healthlog.ios.updated",
apnsToken: "1111222233334444",
apnsEnvironment: "production",
model: "iPhone17,1",
});
expect(response.status).toBe(201);
const body = (await response.json()) as { data: { id: string } };
expect(body.data.id).toBe(apnsDevice.id);
const devices = await prisma.device.findMany({
where: { userId: user.id },
});
expect(devices).toHaveLength(1);
expect(devices[0]).toMatchObject({
id: apnsDevice.id,
token: "legacy-token-current",
bundleId: "io.healthlog.ios.updated",
apnsToken: "1111222233334444",
apnsEnvironment: "production",
model: "iPhone17,1",
medicationDelivery: null,
liveActivityPushToken: null,
});
await expect(
prisma.refreshToken.findUniqueOrThrow({
where: { id: ownedRefreshToken.id },
select: { deviceId: true },
}),
).resolves.toEqual({ deviceId: "legacy-token-current" });
await expect(
prisma.refreshToken.findUniqueOrThrow({
where: { id: foreignRefreshToken.id },
select: { deviceId: true },
}),
).resolves.toEqual({ deviceId: "legacy-token-original" });
});
it("rejects an APNs token owned by another user without mutating either account", async () => {
const user = await seedSession("device-apns-conflict");
const prisma = getPrismaClient();
const owner = await prisma.user.create({
data: {
username: "device-apns-owner",
email: "device-apns-owner@example.test",
},
});
const ownedDevice = await prisma.device.create({
data: {
userId: owner.id,
platform: "ios",
token: "foreign-legacy-token",
bundleId: "io.healthlog.ios",
apnsToken: "9999aaaabbbbcccc",
apnsEnvironment: "sandbox",
},
});
const response = await registerDevice({
token: "requesting-user-token",
bundleId: "io.healthlog.ios.changed",
apnsToken: "9999aaaabbbbcccc",
apnsEnvironment: "production",
});
expect(response.status).toBe(409);
await expect(
prisma.device.findUniqueOrThrow({ where: { id: ownedDevice.id } }),
).resolves.toMatchObject({
userId: owner.id,
token: "foreign-legacy-token",
bundleId: "io.healthlog.ios",
apnsEnvironment: "sandbox",
});
await expect(
prisma.device.count({ where: { userId: user.id } }),
).resolves.toBe(0);
});
it("serializes concurrent registration into one canonical device and channel", async () => {
const user = await seedSession("device-concurrent-register");
const payload = {
token: "concurrent-legacy-token",
bundleId: "io.healthlog.ios",
apnsToken: "abcdabcdabcdabcd",
apnsEnvironment: "sandbox",
};
const responses = await Promise.all([
registerDevice(payload),
registerDevice(payload),
]);
expect(responses.map((response) => response.status)).toEqual([201, 201]);
const bodies = (await Promise.all(
responses.map((response) => response.json()),
)) as Array<{ data: { id: string } }>;
expect(bodies[0].data.id).toBe(bodies[1].data.id);
const prisma = getPrismaClient();
await expect(
prisma.device.count({ where: { userId: user.id } }),
).resolves.toBe(1);
await expect(
prisma.notificationChannel.count({
where: { userId: user.id, type: "APNS" },
}),
).resolves.toBe(1);
});
});
@@ -0,0 +1,224 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
reconcileExternalMeasurement,
type ExternalMeasurementWrite,
} from "@/lib/measurements/reconcile-external-measurement";
import { getPrismaClient, truncateAllTables } from "./setup";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
async function createUser(id: string) {
return getPrismaClient().user.create({
data: {
id,
username: id,
email: `${id}@example.test`,
role: "USER",
},
});
}
const measuredAt = new Date("2026-07-20T06:30:00.000Z");
function desired(
userId: string,
externalId: string,
overrides: Partial<ExternalMeasurementWrite> = {},
): ExternalMeasurementWrite {
return {
userId,
type: "SLEEP_DURATION" as const,
value: 42,
unit: "minutes",
source: "OURA" as const,
measuredAt,
sleepStage: "DEEP" as const,
externalId,
externalSourceVersion: "v2",
deviceType: "ring",
...overrides,
};
}
async function reconcile(
input: ExternalMeasurementWrite,
options?: { exactExternalMatch?: "update" | "duplicate" },
) {
const prisma = getPrismaClient();
return prisma.$transaction((tx) =>
reconcileExternalMeasurement(tx, input, options),
);
}
describe("external measurement identity reconciliation (real Postgres)", () => {
it("moves an external-id hit onto an occupied natural key and retires the redundant row", async () => {
const prisma = getPrismaClient();
const user = await createUser("identity-external-move");
const external = await prisma.measurement.create({
data: {
...desired(user.id, "sleep:new"),
value: 30,
measuredAt: new Date("2026-07-20T06:00:00.000Z"),
externalSourceVersion: null,
},
});
const collision = await prisma.measurement.create({
data: {
...desired(user.id, "sleep:legacy"),
value: 35,
},
});
const verdict = await reconcile(desired(user.id, "sleep:new"));
expect(verdict).toMatchObject({
status: "updated",
row: { id: external.id, externalId: "sleep:new" },
retiredCollisionId: collision.id,
});
const rows = await prisma.measurement.findMany({
where: { userId: user.id },
});
expect(rows).toHaveLength(2);
expect(rows.find((row) => row.id === external.id)).toMatchObject({
value: 42,
measuredAt,
externalId: "sleep:new",
externalSourceVersion: "v2",
deviceType: "ring",
deletedAt: null,
});
expect(rows.find((row) => row.id === collision.id)).toMatchObject({
externalId: "sleep:legacy",
deletedAt: expect.any(Date),
});
});
it("adopts a live natural-key row when a new external id collides", async () => {
const prisma = getPrismaClient();
const user = await createUser("identity-natural-live");
const occupied = await prisma.measurement.create({
data: { ...desired(user.id, "sleep:legacy"), value: 30 },
});
const verdict = await reconcile(desired(user.id, "sleep:new"));
expect(verdict).toMatchObject({
status: "updated",
row: { id: occupied.id, externalId: "sleep:new" },
});
expect(await prisma.measurement.count({ where: { userId: user.id } })).toBe(
1,
);
});
it("resurrects and re-keys a tombstone occupying the natural key", async () => {
const prisma = getPrismaClient();
const user = await createUser("identity-natural-tombstone");
const occupied = await prisma.measurement.create({
data: {
...desired(user.id, "sleep:legacy"),
value: 30,
deletedAt: new Date("2026-07-20T08:00:00.000Z"),
},
});
const verdict = await reconcile(desired(user.id, "sleep:new"));
expect(verdict).toMatchObject({
status: "resurrected",
row: { id: occupied.id, externalId: "sleep:new" },
});
const stored = await prisma.measurement.findUniqueOrThrow({
where: { id: occupied.id },
});
expect(stored.deletedAt).toBeNull();
expect(stored.value).toBe(42);
});
it("never merges a row owned by another user", async () => {
const prisma = getPrismaClient();
const first = await createUser("identity-owner-first");
const second = await createUser("identity-owner-second");
const other = await prisma.measurement.create({
data: desired(first.id, "sleep:shared"),
});
const verdict = await reconcile(desired(second.id, "sleep:shared"));
expect(verdict.status).toBe("inserted");
expect(await prisma.measurement.count()).toBe(2);
expect(
await prisma.measurement.findUniqueOrThrow({ where: { id: other.id } }),
).toMatchObject({ userId: first.id, value: 42 });
});
it("serializes concurrent attempts that share the same natural identity", async () => {
const prisma = getPrismaClient();
const user = await createUser("identity-concurrent");
const verdicts = await Promise.all([
reconcile(desired(user.id, "sleep:race-a", { value: 41 })),
reconcile(desired(user.id, "sleep:race-b", { value: 43 })),
]);
expect(verdicts.every((verdict) => verdict.status !== "failed")).toBe(true);
expect(verdicts.map((verdict) => verdict.status).sort()).toEqual([
"inserted",
"updated",
]);
const rows = await prisma.measurement.findMany({
where: { userId: user.id },
});
expect(rows).toHaveLength(1);
expect(["sleep:race-a", "sleep:race-b"]).toContain(rows[0]?.externalId);
expect([41, 43]).toContain(rows[0]?.value);
});
it("serializes crossing external and natural identity moves", async () => {
const prisma = getPrismaClient();
const user = await createUser("identity-crossing-race");
const firstTime = new Date("2026-07-20T06:00:00.000Z");
const secondTime = new Date("2026-07-20T07:00:00.000Z");
await prisma.measurement.create({
data: desired(user.id, "sleep:race-a", { measuredAt: firstTime }),
});
const verdicts = await Promise.all([
reconcile(
desired(user.id, "sleep:race-a", {
measuredAt: secondTime,
value: 41,
}),
),
reconcile(
desired(user.id, "sleep:race-b", {
measuredAt: firstTime,
value: 43,
}),
),
]);
expect(verdicts.every((verdict) => verdict.status !== "failed")).toBe(true);
const rows = await prisma.measurement.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { measuredAt: "asc" },
});
expect(rows).toHaveLength(2);
expect(rows).toEqual([
expect.objectContaining({
externalId: "sleep:race-b",
measuredAt: firstTime,
value: 43,
}),
expect.objectContaining({
externalId: "sleep:race-a",
measuredAt: secondTime,
value: 41,
}),
]);
});
});
+31 -29
View File
@@ -326,27 +326,17 @@ describe("POST /api/measurements/batch (real Postgres)", () => {
expect(stored.every((r) => r.unit === "minutes")).toBe(true);
});
// v1.4.25 W10 reconcile (senior-dev H-1): under contention the
// per-entry envelope MUST stay in sync with the aggregate
// `inserted` / `duplicate` counts. The previous reconciliation
// block was an effective no-op (it looked for rows that were
// "marked inserted but missing from the DB" — an impossibility
// because the raced row IS in the DB, written by the other
// batch). The corrected block downgrades enough "inserted"
// statuses to "duplicate" so the per-entry envelope and the
// aggregate counts agree, which the iOS sync cursor depends on
// to checkpoint correctly.
// Under contention every per-entry verdict must stay in sync with its
// aggregate counter. Inserted, updated, duplicate, skipped, and failed are
// all explicit because only the hard failure verdict must block checkpoint
// advancement.
it("keeps per-entry status in sync with aggregate counts under a concurrent-write race", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
// Two batches with overlapping externalIds posted in parallel.
// The composite unique index ensures only one row per
// (user, type, source, externalId) lands; `skipDuplicates`
// absorbs duplicate-key conflicts. The exact split between
// "won the race" and "got absorbed" depends on Postgres's
// commit order, so we assert only the invariant the iOS sync
// cursor relies on: per-entry envelope sums equal the aggregate
// counts for each response.
// Two batches with overlapping external ids and the same natural identity
// are posted in parallel. The reconciler serializes both identity indexes;
// the exact inserted/updated split depends on commit order, but every
// response must remain internally consistent.
const sharedEntries = () =>
Array.from({ length: 6 }, (_, i) => ({
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
@@ -368,6 +358,8 @@ describe("POST /api/measurements/batch (real Postgres)", () => {
data: {
processed: number;
inserted: number;
updated: number;
failed: number;
duplicates: number;
entries: Array<{ status: string }>;
};
@@ -379,19 +371,31 @@ describe("POST /api/measurements/batch (real Postgres)", () => {
// no-op reconciliation left the envelope out of sync.
for (const json of [firstJson, secondJson]) {
const insertedEntries = json.data.entries.filter(
(e) => e.status === "inserted",
(entry) => entry.status === "inserted",
).length;
const updatedEntries = json.data.entries.filter(
(entry) => entry.status === "updated",
).length;
const duplicateEntries = json.data.entries.filter(
(e) => e.status === "duplicate",
(entry) => entry.status === "duplicate",
).length;
const skippedEntries = json.data.entries.filter(
(e) => e.status === "skipped",
(entry) => entry.status === "skipped",
).length;
const failedEntries = json.data.entries.filter(
(entry) => entry.status === "failed",
).length;
expect(insertedEntries).toBe(json.data.inserted);
expect(updatedEntries).toBe(json.data.updated);
expect(duplicateEntries).toBe(json.data.duplicates);
expect(insertedEntries + duplicateEntries + skippedEntries).toBe(
json.data.processed,
);
expect(failedEntries).toBe(json.data.failed);
expect(
insertedEntries +
updatedEntries +
duplicateEntries +
skippedEntries +
failedEntries,
).toBe(json.data.processed);
}
// Invariant 2 — aggregate counts are non-negative. The previous
@@ -400,14 +404,12 @@ describe("POST /api/measurements/batch (real Postgres)", () => {
expect(firstJson.data.inserted).toBeGreaterThanOrEqual(0);
expect(secondJson.data.inserted).toBeGreaterThanOrEqual(0);
// Invariant 3 — the DB ends up with at most 6 rows because the
// composite unique index enforces single-copy, and the
// `inserted` counts across both requests cannot exceed the
// number of rows actually present.
// Invariant 3 — all samples share one natural identity, so exactly one
// canonical row survives and only one request can report its insertion.
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID },
});
expect(stored.length).toBeLessThanOrEqual(6);
expect(stored).toHaveLength(1);
expect(firstJson.data.inserted + secondJson.data.inserted).toBe(
stored.length,
);
@@ -199,6 +199,159 @@ describe("MoodEntry — external-id re-import idempotency (v1.12.1 / 0122)", ()
expect(rows[0].score).toBe(5);
});
it("acknowledges an exact provider replay with one stable durable row", async () => {
const prisma = getPrismaClient();
const secret = "mood-secret-replay";
const user = await prisma.user.create({
data: {
username: "mood-provider-replay",
email: "mood-provider-replay@example.test",
role: "USER",
moodLogEnabled: true,
moodLogWebhookSecret: secret,
},
});
const { POST } =
await import("@/app/api/integrations/moodlog/webhook/route");
const payload = {
event: "mood.created",
timestamp: "2026-06-04T11:00:00.000Z",
entry: {
id: "provider-event-replay-1",
date: "2026-06-04",
time: "2026-06-04T11:00:00.000Z",
mood: "GUT",
score: 4,
},
};
const first = await POST(webhookRequest(secret, payload));
expect(first.status).toBe(200);
const firstRow = await prisma.moodEntry.findUniqueOrThrow({
where: {
userId_source_externalId: {
userId: user.id,
source: "MOODLOG",
externalId: "provider-event-replay-1",
},
},
});
const replay = await POST(webhookRequest(secret, payload));
expect(replay.status).toBe(200);
const rows = await prisma.moodEntry.findMany({
where: { userId: user.id, source: "MOODLOG" },
});
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe(firstRow.id);
});
it("adopts a legacy natural-key row when a replay gains a provider id", async () => {
const prisma = getPrismaClient();
const secret = "mood-secret-key-upgrade";
const user = await prisma.user.create({
data: {
username: "mood-key-upgrade",
email: "mood-key-upgrade@example.test",
role: "USER",
moodLogEnabled: true,
moodLogWebhookSecret: secret,
},
});
const { POST } =
await import("@/app/api/integrations/moodlog/webhook/route");
const entry = {
date: "2026-06-04",
time: "2026-06-04T12:00:00.000Z",
mood: "GUT",
score: 4,
};
const legacy = await POST(
webhookRequest(secret, {
event: "mood.created",
timestamp: "2026-06-04T12:00:00.000Z",
entry,
}),
);
expect(legacy.status).toBe(200);
const legacyRow = await prisma.moodEntry.findFirstOrThrow({
where: { userId: user.id, source: "MOODLOG" },
});
expect(legacyRow.externalId).toBeNull();
const upgraded = await POST(
webhookRequest(secret, {
event: "mood.updated",
timestamp: "2026-06-04T12:05:00.000Z",
entry: { ...entry, id: "provider-key-upgrade-1" },
}),
);
expect(upgraded.status).toBe(200);
const rows = await prisma.moodEntry.findMany({
where: { userId: user.id, source: "MOODLOG" },
});
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe(legacyRow.id);
expect(rows[0].externalId).toBe("provider-key-upgrade-1");
});
it("does not adopt an unrelated row that occupies the natural key", async () => {
const prisma = getPrismaClient();
const secret = "mood-secret-unrelated-collision";
const user = await prisma.user.create({
data: {
username: "mood-unrelated-collision",
email: "mood-unrelated-collision@example.test",
role: "USER",
moodLogEnabled: true,
moodLogWebhookSecret: secret,
},
});
const moodLoggedAt = new Date("2026-06-04T13:00:00.000Z");
const manual = await prisma.moodEntry.create({
data: {
userId: user.id,
date: "2026-06-04",
moodLoggedAt,
mood: "OKAY",
score: 3,
source: "MANUAL",
},
});
const { POST } =
await import("@/app/api/integrations/moodlog/webhook/route");
const response = await POST(
webhookRequest(secret, {
event: "mood.created",
timestamp: "2026-06-04T13:00:00.000Z",
entry: {
id: "provider-unrelated-collision-1",
date: "2026-06-04",
time: moodLoggedAt.toISOString(),
mood: "GUT",
score: 4,
},
}),
);
expect(response.status).toBe(503);
const row = await prisma.moodEntry.findUniqueOrThrow({
where: { id: manual.id },
});
expect(row).toMatchObject({
source: "MANUAL",
externalId: null,
mood: "OKAY",
score: 3,
});
expect(await prisma.moodEntry.count({ where: { userId: user.id } })).toBe(
1,
);
});
it("entries without an id still dedup on the legacy wall-clock key", async () => {
const prisma = getPrismaClient();
const secret = "mood-secret-legacy";
@@ -0,0 +1,228 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type * as WhoopClientModule from "@/lib/whoop/client";
import type * as WithingsClientModule from "@/lib/withings/client";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
delete process.env.ENCRYPTION_KEYS;
delete process.env.ENCRYPTION_ACTIVE_KEY_ID;
const { whoopRefresh, withingsRefresh, whoopRecoveries } = vi.hoisted(() => ({
whoopRefresh: vi.fn(),
withingsRefresh: vi.fn(),
whoopRecoveries: vi.fn(),
}));
vi.mock("@/lib/whoop/client", async (importOriginal) => ({
...(await importOriginal<typeof WhoopClientModule>()),
refreshAccessToken: whoopRefresh,
fetchRecoveries: whoopRecoveries,
}));
vi.mock("@/lib/withings/client", async (importOriginal) => ({
...(await importOriginal<typeof WithingsClientModule>()),
refreshAccessToken: withingsRefresh,
}));
import { encrypt, decrypt } from "@/lib/crypto";
import { getValidToken as getValidWhoopToken } from "@/lib/whoop/sync";
import { syncUserRecovery } from "@/lib/whoop/sync-recovery";
import { getValidToken as getValidWithingsToken } from "@/lib/withings/sync";
const CONTENDERS = 75;
const EXPIRED_AT = new Date("2026-01-01T00:00:00.000Z");
async function seedProviderUser(provider: "whoop" | "withings") {
const prisma = getPrismaClient();
const userId = `refresh-${provider}`;
await prisma.user.create({
data: {
id: userId,
username: `refresh-${provider}`,
email: `refresh-${provider}@example.test`,
...(provider === "whoop"
? {
whoopClientIdEncrypted: encrypt("whoop-client"),
whoopClientSecretEncrypted: encrypt("whoop-secret"),
}
: {
withingsClientIdEncrypted: encrypt("withings-client"),
withingsClientSecretEncrypted: encrypt("withings-secret"),
}),
},
});
if (provider === "whoop") {
await prisma.whoopConnection.create({
data: {
userId,
whoopUserId: "whoop-user",
accessToken: encrypt("stale-access"),
refreshToken: encrypt("stale-refresh"),
tokenExpiresAt: EXPIRED_AT,
},
});
} else {
await prisma.withingsConnection.create({
data: {
userId,
withingsUserId: "withings-user",
accessToken: encrypt("stale-access"),
refreshToken: encrypt("stale-refresh"),
tokenExpiresAt: EXPIRED_AT,
},
});
}
return userId;
}
async function runContenders(provider: "whoop" | "withings", userId: string) {
const getToken =
provider === "whoop" ? getValidWhoopToken : getValidWithingsToken;
return Promise.all(
Array.from({ length: CONTENDERS }, () => getToken(userId)),
);
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
vi.clearAllMocks();
});
describe.each([
["whoop", whoopRefresh],
["withings", withingsRefresh],
] as const)(
"%s rotating token refresh serialization (real Postgres)",
(provider, refresh) => {
it("lets exactly one contender refresh a stale token and shares the winner", async () => {
const userId = await seedProviderUser(provider);
refresh.mockImplementation(async (refreshToken: string) => {
expect(refreshToken).toBe("stale-refresh");
return {
access_token: "winner-access",
refresh_token: "winner-refresh",
expires_in: 3600,
};
});
const results = await runContenders(provider, userId);
expect(refresh).toHaveBeenCalledTimes(1);
expect(results).toHaveLength(CONTENDERS);
expect(
results.every((result) => result?.accessToken === "winner-access"),
).toBe(true);
const prisma = getPrismaClient();
const row =
provider === "whoop"
? await prisma.whoopConnection.findUniqueOrThrow({
where: { userId },
})
: await prisma.withingsConnection.findUniqueOrThrow({
where: { userId },
});
expect(decrypt(row.accessToken)).toBe("winner-access");
expect(decrypt(row.refreshToken)).toBe("winner-refresh");
});
it("rolls back a failed winner so a later contender can retry", async () => {
const userId = await seedProviderUser(provider);
refresh
.mockRejectedValueOnce(new Error("injected refresh failure"))
.mockResolvedValue({
access_token: "retry-access",
refresh_token: "retry-refresh",
expires_in: 3600,
});
const results = await runContenders(provider, userId);
expect(refresh).toHaveBeenCalledTimes(2);
expect(results.filter((result) => result === null)).toHaveLength(1);
expect(
results.filter((result) => result?.accessToken === "retry-access"),
).toHaveLength(CONTENDERS - 1);
const prisma = getPrismaClient();
const row =
provider === "whoop"
? await prisma.whoopConnection.findUniqueOrThrow({
where: { userId },
})
: await prisma.withingsConnection.findUniqueOrThrow({
where: { userId },
});
expect(decrypt(row.accessToken)).toBe("retry-access");
expect(decrypt(row.refreshToken)).toBe("retry-refresh");
});
},
);
describe("WHOOP recovery cursor durability (real Postgres)", () => {
const oldCursor = "2026-06-01T00:00:00.000Z";
const recovery = {
cycle_id: 1,
sleep_id: "sleep-real",
user_id: 42,
created_at: "2026-06-01T06:00:00.000Z",
updated_at: "2026-06-01T07:00:00.000Z",
score_state: "SCORED",
score: {
user_calibrating: false,
recovery_score: 66,
resting_heart_rate: 52,
hrv_rmssd_milli: 48.7,
spo2_percentage: 97,
skin_temp_celsius: 33.4,
},
};
it("holds the cursor on a durable write failure, then advances idempotently", async () => {
const userId = await seedProviderUser("whoop");
const prisma = getPrismaClient();
await prisma.whoopConnection.update({
where: { userId },
data: {
tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
lastSyncedAt: new Date(oldCursor),
resourceCursors: { recovery: oldCursor },
},
});
whoopRecoveries.mockResolvedValue([recovery]);
await prisma.$executeRaw`
ALTER TABLE "measurements"
ADD CONSTRAINT "test_whoop_recovery_write_failure"
CHECK ("external_id" <> 'sleep-real:hrv_rmssd')
`;
try {
await expect(syncUserRecovery(userId)).rejects.toThrow();
const failed = await prisma.whoopConnection.findUniqueOrThrow({
where: { userId },
});
expect(failed.resourceCursors).toEqual({ recovery: oldCursor });
expect(await prisma.measurement.count({ where: { userId } })).toBe(0);
} finally {
await prisma.$executeRaw`
ALTER TABLE "measurements"
DROP CONSTRAINT IF EXISTS "test_whoop_recovery_write_failure"
`;
}
expect(await syncUserRecovery(userId)).toBe(5);
const succeeded = await prisma.whoopConnection.findUniqueOrThrow({
where: { userId },
});
expect(succeeded.resourceCursors).not.toEqual({ recovery: oldCursor });
expect(await prisma.measurement.count({ where: { userId } })).toBe(5);
expect(await syncUserRecovery(userId)).toBe(5);
expect(await prisma.measurement.count({ where: { userId } })).toBe(5);
});
});