mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
1c0f91adc1
commit
2869d0ab27
6 files changed
+166
-21
No files matched your search
@@ -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,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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user