From 2869d0ab272ad96be45f1714b756bbac3a445ebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 11 Jul 2026 00:35:50 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.28.24=20=E2=80=94=20a=20de?= =?UTF-8?q?leted=20Google=20reading=20no=20longer=20blocks=20its=20re-impo?= =?UTF-8?q?rt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The measurements unique index on (userId, type, source, externalId) is FULL — it covers soft-deleted rows — so a tombstoned Google row permanently owned its key: the live-only probe treated it as absent, planned a fresh insert, and createMany's skipDuplicates dropped that insert silently against the tombstone's key. Every sync, forever. Live incident: a self-hoster's step days were stuck exactly this way (five tombstoned dates, confirmed in their data). The probe now matches tombstoned rows too and the update branch carries deletedAt: null unconditionally — a no-op on a live row, a deliberate resurrection on a tombstoned one. Google remains the source of truth for its own rows; removing Google data permanently means disconnecting the integration. The stale 'partial unique index' comments are corrected. --- CHANGELOG.md | 11 ++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- .../__tests__/sync-upsert-resurrect.test.ts | 120 ++++++++++++++++++ src/lib/google-health/sync.ts | 50 +++++--- 6 files changed, 166 insertions(+), 21 deletions(-) create mode 100644 src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 17dfaa1f1..382c81b1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +## [1.28.24] — 2026-07-11 — A deleted Google reading no longer blocks its re-import + +A Google Health reading that had been soft-deleted could never be imported +again: the sync treated the deleted row as absent and planned a fresh insert, +but the database's uniqueness rule still saw the deleted row and silently +dropped the insert — every sync, forever. A self-hoster's step days were stuck +exactly this way. The sync now recognises the deleted row and revives it in +place with the freshly fetched value: Google remains the source of truth for +its own readings, so a re-import deliberately brings a deleted one back. To +remove Google data permanently, disconnect the integration. + ## [1.28.23] — 2026-07-11 — Sleep disagreement visible on the dashboard; Apple Health medication groundwork When two sources disagree about last night's sleep — a sleep mat's time in bed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c8f4b3d37..81d53e3de 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.23 + version: 1.28.24 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 5bbfc343e..883c22641 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.23", + "version": "1.28.24", "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", diff --git a/public/sw.js b/public/sw.js index a26ea98f1..f0010277f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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.28.23"; + /* @sw-version-fallback */ "v1.28.24"; 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/*` diff --git a/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts b/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts new file mode 100644 index 000000000..d028ac07b --- /dev/null +++ b/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts @@ -0,0 +1,120 @@ +/** + * Pins the tombstone-resurrect contract of `upsertGoogleHealthMeasurements`. + * + * The measurements unique index on `(userId, type, source, externalId)` is + * FULL — it covers soft-deleted rows — so a tombstoned row permanently owns + * its key. The probe must therefore match tombstoned rows too and route them + * into the UPDATE branch with `deletedAt: null`: treating them as absent plans + * an insert that `skipDuplicates` drops SILENTLY against the tombstone's key, + * wedging the key forever (live incident: a self-hoster's step days could + * never re-import after their rows were soft-deleted). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { findManyMock, createManyMock, updateMock } = vi.hoisted(() => ({ + findManyMock: vi.fn(async () => [] as unknown[]), + createManyMock: vi.fn(async () => ({ count: 0 })), + updateMock: vi.fn(async () => ({})), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: findManyMock, + createMany: createManyMock, + update: updateMock, + }, + }, +})); +vi.mock("@/lib/crypto", () => ({ encrypt: vi.fn(), decrypt: vi.fn() })); +vi.mock("@/lib/integrations/status", () => ({ + isReauthRequired: vi.fn(async () => false), + recordSyncFailure: vi.fn(async () => {}), + recordSyncSuccess: vi.fn(async () => {}), +})); +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + collapseToTypeDayKeys: vi.fn(() => []), + recomputeBucketsForMeasurement: vi.fn(async () => {}), + recomputeUserRollups: vi.fn(async () => {}), +})); +vi.mock("@/lib/insights/comprehensive-generate", () => ({ + invalidateStatusInsightsForTypes: vi.fn(async () => {}), +})); +vi.mock("../credentials", () => ({ + getUserGoogleHealthCredentials: vi.fn(async () => null), +})); +vi.mock("../client", () => ({ refreshAccessToken: vi.fn() })); + +import { upsertGoogleHealthMeasurements } from "../sync"; + +const STEPS_READING = { + type: "ACTIVITY_STEPS", + value: 8123, + unit: "steps", + measuredAt: new Date("2026-07-08T00:00:00.000Z"), + externalId: "stats:steps:2026-07-08", +}; + +beforeEach(() => { + findManyMock.mockReset().mockResolvedValue([]); + createManyMock.mockReset().mockResolvedValue({ count: 0 }); + updateMock.mockReset().mockResolvedValue({}); +}); + +describe("upsertGoogleHealthMeasurements — tombstones resurrect", () => { + it("probes WITHOUT a deletedAt filter (a tombstoned row must be matched)", async () => { + await upsertGoogleHealthMeasurements("user-1", [STEPS_READING], { + deferRollup: true, + }); + const arg = (findManyMock.mock.calls[0] as unknown[])[0] as { + where: Record; + }; + expect(arg.where).toEqual({ + userId: "user-1", + source: "GOOGLE_HEALTH", + externalId: { in: ["stats:steps:2026-07-08"] }, + }); + expect(arg.where).not.toHaveProperty("deletedAt"); + }); + + it("routes a matched (tombstoned) row into an update that clears deletedAt", async () => { + findManyMock.mockResolvedValue([ + { + id: "row-1", + type: "ACTIVITY_STEPS", + externalId: "stats:steps:2026-07-08", + }, + ]); + + const { imported } = await upsertGoogleHealthMeasurements( + "user-1", + [STEPS_READING], + { deferRollup: true }, + ); + + // No insert is planned for an owned key — the update resurrects in place. + expect(createManyMock).not.toHaveBeenCalled(); + expect(updateMock).toHaveBeenCalledTimes(1); + const upd = (updateMock.mock.calls[0] as unknown[])[0] as { + where: { id: string }; + data: Record; + }; + expect(upd.where).toEqual({ id: "row-1" }); + expect(upd.data.deletedAt).toBeNull(); + expect(upd.data.value).toBe(8123); + expect(upd.data.syncVersion).toEqual({ increment: 1 }); + expect(imported).toBe(1); + }); + + it("still creates a genuinely fresh key", async () => { + createManyMock.mockResolvedValue({ count: 1 }); + const { imported } = await upsertGoogleHealthMeasurements( + "user-1", + [STEPS_READING], + { deferRollup: true }, + ); + expect(updateMock).not.toHaveBeenCalled(); + expect(createManyMock).toHaveBeenCalledTimes(1); + expect(imported).toBe(1); + }); +}); diff --git a/src/lib/google-health/sync.ts b/src/lib/google-health/sync.ts index e3e2629d4..0b77a52d6 100644 --- a/src/lib/google-health/sync.ts +++ b/src/lib/google-health/sync.ts @@ -377,18 +377,26 @@ export async function replaceStaleGoogleHealthSleep( * end (mirrors the WHOOP / Withings sync tail). Returns the count of rows * written plus the distinct `(type, day)` keys the write touched. * - * TOMBSTONE-SAFE: the live-row probe filters `deletedAt: null`, so a row the - * user soft-deleted (iOS LWW reconciler) is NOT matched — a fresh insert is - * created instead of resurrecting the tombstone on the next hourly sync. The - * partial unique index keeps the fresh insert from colliding with the live set. + * TOMBSTONES RESURRECT: the measurements unique index on + * `(userId, type, source, externalId)` is FULL — it covers soft-deleted rows + * too, so exactly ONE row can ever exist per key, live or tombstoned. An + * earlier revision assumed a partial (live-only) index and skipped tombstoned + * rows in the probe, expecting a fresh insert; in reality `createMany` with + * `skipDuplicates` then hit the tombstoned row's key and dropped the insert + * SILENTLY — once a Google row was soft-deleted, that key could never import + * again (live: a self-hoster's step days were stuck this way). The probe now + * matches tombstoned rows as well and the update branch clears `deletedAt`: + * Google remains the source of truth for its own rows, so a re-import + * deliberately revives a deleted one. An operator who wants Google data gone + * permanently disconnects the integration rather than deleting rows. * * OVERWRITE CONTRACT preserved: a re-fetched daily summary (`stats:`-keyed and * every other stable per-point anchor) maps to the SAME live `externalId`, so - * the probe finds the existing live row and takes the in-place update branch. + * the probe finds the existing row and takes the in-place update branch. * * BATCHING: the existence probe is a single `findMany` over the batch's * externalIds; fresh rows go through chunked `createMany`, and only the - * already-live rows take a per-row `update` for their differing values. + * already-existing rows take a per-row `update` for their differing values. * * Best-effort on the rollup fold + insight invalidate — a populator hiccup never * fails the user's sync. @@ -403,31 +411,33 @@ export async function upsertGoogleHealthMeasurements( }> { if (readings.length === 0) return { imported: 0, touched: [] }; - // Probe the LIVE rows (deletedAt: null) for every externalId in the batch in - // a single query. A tombstoned row deliberately does NOT appear here, so it is - // treated as absent → a fresh insert, not a resurrecting update. + // Probe EVERY existing row (live AND tombstoned) for the batch's externalIds + // in a single query. The full unique index guarantees at most one row per + // `(type, externalId)` key, and a tombstoned match must take the UPDATE + // branch (which clears `deletedAt`) — treating it as absent would plan an + // insert that `skipDuplicates` silently drops against the tombstone's key, + // permanently wedging the key (see TOMBSTONES RESURRECT above). const externalIds = readings.map((r) => r.externalId); - let liveByKey = new Map(); + let rowByKey = new Map(); try { const existing = await prisma.measurement.findMany({ where: { userId, source: "GOOGLE_HEALTH", - deletedAt: null, externalId: { in: externalIds }, }, select: { id: true, type: true, externalId: true }, }); - liveByKey = new Map( + rowByKey = new Map( existing .filter((e) => e.externalId !== null) .map((e) => [`${e.type} ${e.externalId}`, { id: e.id }]), ); } catch (err) { // A probe failure must not strand the whole batch; fall back to treating - // every row as fresh (the partial unique index still rejects a genuine - // live-row collision, which the per-create catch swallows). - getEvent()?.addWarning(`google-health: live-row probe failed: ${err}`); + // every row as fresh (`skipDuplicates` absorbs the collision on the full + // unique index — those rows simply retry on the next sync tick). + getEvent()?.addWarning(`google-health: row probe failed: ${err}`); } const toCreate: Array<{ @@ -447,7 +457,7 @@ export async function upsertGoogleHealthMeasurements( for (const r of readings) { const type = r.type as MeasurementType; const key = `${type} ${r.externalId}`; - const live = liveByKey.get(key); + const live = rowByKey.get(key); if (live) { toUpdate.push({ id: live.id, r }); } else if (!plannedCreateKeys.has(key)) { @@ -511,8 +521,11 @@ export async function upsertGoogleHealthMeasurements( } } - // Live-row overwrites: per-row update (differing values) on the live id, so - // the re-fetched daily summary overwrites in place and bumps `syncVersion`. + // Existing-row overwrites: per-row update (differing values) on the matched + // id, so the re-fetched daily summary overwrites in place and bumps + // `syncVersion`. `deletedAt: null` rides along unconditionally — a no-op on + // a live row, a deliberate RESURRECTION on a tombstoned one (Google is the + // source of truth for its own rows; see TOMBSTONES RESURRECT above). for (const { id, r } of toUpdate) { try { await prisma.measurement.update({ @@ -522,6 +535,7 @@ export async function upsertGoogleHealthMeasurements( unit: r.unit, measuredAt: r.measuredAt, sleepStage: r.sleepStage ?? null, + deletedAt: null, // Surface the server-side mutation to the iOS LWW reconciler. syncVersion: { increment: 1 }, },