chore(release): v1.28.24 — a deleted Google reading no longer blocks its re-import

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-11 00:35:50 +02:00
1 parent 1c0f91adc1
commit 2869d0ab27
6 files changed
+166 -21

No files matched your search

+11
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+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.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/*`
@@ -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<string, unknown>;
};
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<string, unknown>;
};
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);
});
});
+32 -18
View File
@@ -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<string, { id: string }>();
let rowByKey = new Map<string, { id: string }>();
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 },
},