From f38c4bed05323c03f63f391388b9797a11754c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 10:22:11 +0200 Subject: [PATCH] feat(measurements): hourly heart-rate batch upload contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define the go-forward aggregated heart-rate upload shape so clients can stop posting one Measurement per raw HealthKit sample. A heart-rate row keyed `stats:HKQuantityTypeIdentifierHeartRate:` carries the hour's average bpm as a single PULSE row and rides the existing `stats:` overwrite path: a re-post within the hour overwrites the value instead of duplicating, identical to the per-day cumulative rows. The hour boundary is UTC so the key is timezone-stable. The batch route rejects a row that targets the heart-rate bucket prefix with a malformed hour suffix as a per-entry skip (`malformed_hr_bucket_id`) rather than persisting an un-overwriteable row; well-formed buckets, per-sample uuid ingest, and the per-day cumulative overwrite path are all unchanged. Average-only for now — the measurement row carries a single value column, so hourly min/max would need a schema change and is left as a follow-up. Documents all three externalId shapes plus the `updated` status in the OpenAPI contract. --- docs/api/openapi.yaml | 54 +++- .../batch/__tests__/hourly-hr-wire.test.ts | 242 ++++++++++++++++++ src/app/api/measurements/batch/route.ts | 49 +++- .../__tests__/hourly-hr-stats.test.ts | 99 +++++++ src/lib/measurements/apple-health-mapping.ts | 84 ++++++ src/lib/openapi/routes/measurements.ts | 31 ++- 6 files changed, 540 insertions(+), 19 deletions(-) create mode 100644 src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts create mode 100644 src/lib/measurements/__tests__/hourly-hr-stats.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index abb539733..a1b40484c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -7206,6 +7206,9 @@ components: description: HealthKit identifier (e.g. `HKQuantityTypeIdentifierBodyMass`). value: type: number + description: Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For an + hourly heart-rate bucket (see `externalId`) this is the hour's AVERAGE bpm — v1.19.0 ships avg-only; min/max + are a documented fast-follow. unit: type: string minLength: 1 @@ -7235,7 +7238,21 @@ components: type: string minLength: 1 maxLength: 120 - description: HKSample.uuid string — the dedup key. + description: >- + Dedup key for the `(userId, type, source, externalId)` composite index. Three shapes: + + • `HKSample.uuid` — a per-sample reading. First-write-wins: a re-post returns `duplicate` and the stored row + is immutable. + + • `stats::` — a per-day cumulative total (Steps, Active Energy, Sleep + Duration, Walking/Running Distance, Flights Climbed). A re-post OVERWRITES the row and returns `updated`. + + • `stats:HKQuantityTypeIdentifierHeartRate:` — v1.19.0 (iOS #34) hourly heart-rate bucket + carrying the hour's AVERAGE bpm as one PULSE row. `` is the ISO-8601 UTC hour boundary + with zeroed minutes/seconds/millis and a trailing `Z` (e.g. `2026-06-21T14:00:00.000Z`). A re-post within + the hour OVERWRITES the row and returns `updated`, so iOS uploads ~24 rows/day instead of one per raw HR + sample. A row that targets this prefix with a malformed hour suffix is `skipped` with reason + `malformed_hr_bucket_id`. externalSourceVersion: type: string minLength: 1 @@ -11283,25 +11300,46 @@ components: AppleHealthBatchResponse: type: object properties: + processed: + type: integer + minimum: 0 + maximum: 9007199254740991 inserted: type: integer minimum: 0 maximum: 9007199254740991 - duplicate: + updated: + type: integer + minimum: 0 + maximum: 9007199254740991 + duplicates: type: integer minimum: 0 maximum: 9007199254740991 skipped: - type: integer - minimum: 0 - maximum: 9007199254740991 + type: array + items: + type: object + properties: + index: + type: integer + minimum: 0 + maximum: 9007199254740991 + reason: + type: string + required: + - index + - reason + additionalProperties: false entries: type: array items: $ref: "#/components/schemas/AppleHealthBatchEntryResult" required: + - processed - inserted - - duplicate + - updated + - duplicates - skipped - entries additionalProperties: false @@ -11316,8 +11354,12 @@ components: type: string enum: - inserted + - 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`." reason: type: string required: diff --git a/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts b/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts new file mode 100644 index 000000000..09f6ceaad --- /dev/null +++ b/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts @@ -0,0 +1,242 @@ +/** + * v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire contract on + * `POST /api/measurements/batch`. + * + * Asserts: + * - a fresh hourly HR bucket inserts; + * - a re-post of the same hour OVERWRITES (status `updated`, not a + * duplicate row); + * - a malformed hourly-HR bucket externalId is `skipped`; + * - the per-sample uuid HR path is unaffected (immutable duplicate); + * - the existing per-day step `stats:` overwrite path is unaffected. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: vi.fn(), + createMany: vi.fn(), + updateMany: vi.fn(), + }, + $transaction: vi.fn(async (fn: unknown) => { + if (typeof fn === "function") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (fn as any)(prisma as unknown as { measurement: unknown }); + } + }), + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/jobs/pr-detection", () => ({ + enqueuePrDetection: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/jobs/reminder-satisfy", () => ({ + enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMeasurements: vi.fn(), +})); +vi.mock("@/lib/insights/comprehensive-generate", () => ({ + invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), + collapseToTypeDayKeys: vi.fn(() => []), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +const HR_BUCKET_ID = + "stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:00:00.000Z"; + +function makeRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/measurements/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function hrBucketEntry(externalId: string, value: number) { + return { + hkIdentifier: "HKQuantityTypeIdentifierHeartRate", + value, + unit: "count/min", + startDate: "2026-06-21T14:00:00.000Z", + endDate: "2026-06-21T14:59:59.000Z", + externalId, + }; +} + +async function readJson(res: Response) { + return (await res.json()) as { + data: { + processed: number; + inserted: number; + updated: number; + duplicates: number; + skipped: { index: number; reason: string }[]; + entries: { index: number; status: string; reason?: string }[]; + }; + }; +} + +beforeEach(() => { + // `clearAllMocks` (not `resetAllMocks`) so the factory `$transaction` + // pass-through implementation survives — the overwrite path sets its + // per-entry status INSIDE the transaction, and a wiped impl would leave + // the result slot undefined and 500 the route. + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 60, + resetAt: Date.now() + 60_000, + }); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([]); + vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 0 }); + vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 1 }); +}); + +describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", () => { + it("inserts a fresh hourly HR bucket as a PULSE row", async () => { + vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 }); + const res = await POST( + makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72)] }), + ); + expect(res.status).toBe(200); + const { data } = await readJson(res); + expect(data.inserted).toBe(1); + expect(data.updated).toBe(0); + expect(data.entries[0].status).toBe("inserted"); + + // Stored as a PULSE row carrying the hourly average value. + const createArg = vi.mocked(prisma.measurement.createMany).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); + }); + + it("overwrites the same hour bucket on a re-post (updated, not duplicate)", async () => { + // The bucket already exists from an earlier within-hour post. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { + type: "PULSE", + source: "APPLE_HEALTH", + externalId: HR_BUCKET_ID, + }, + ] as never); + + const res = await POST( + makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 78)] }), + ); + expect(res.status).toBe(200); + const { data } = await readJson(res); + expect(data.updated).toBe(1); + expect(data.inserted).toBe(0); + expect(data.duplicates).toBe(0); + expect(data.entries[0].status).toBe("updated"); + + // The overwrite went through updateMany with the new hourly average. + expect(prisma.measurement.updateMany).toHaveBeenCalledTimes(1); + const updateArg = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0]; + expect((updateArg as { data: { value: number } }).data.value).toBe(78); + expect(prisma.measurement.createMany).not.toHaveBeenCalled(); + }); + + it("skips a malformed hourly HR bucket externalId", async () => { + const res = await POST( + makeRequest({ + entries: [ + hrBucketEntry( + "stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:30:00.000Z", + 70, + ), + ], + }), + ); + expect(res.status).toBe(200); + const { data } = await readJson(res); + expect(data.inserted).toBe(0); + expect(data.skipped).toEqual([ + { index: 0, reason: "malformed_hr_bucket_id" }, + ]); + expect(prisma.measurement.createMany).not.toHaveBeenCalled(); + }); + + it("keeps the per-sample uuid HR path immutable (duplicate, not overwrite)", async () => { + const SAMPLE_ID = "B3A1C0DE-0000-4000-8000-000000000000"; + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "PULSE", source: "APPLE_HEALTH", externalId: SAMPLE_ID }, + ] as never); + + const res = await POST( + makeRequest({ entries: [hrBucketEntry(SAMPLE_ID, 99)] }), + ); + expect(res.status).toBe(200); + const { data } = await readJson(res); + expect(data.duplicates).toBe(1); + expect(data.updated).toBe(0); + expect(data.entries[0].status).toBe("duplicate"); + expect(prisma.measurement.updateMany).not.toHaveBeenCalled(); + }); + + it("leaves the per-day step stats overwrite path unaffected", async () => { + const STEP_STATS_ID = "stats:HKQuantityTypeIdentifierStepCount:2026-06-21"; + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { + type: "ACTIVITY_STEPS", + source: "APPLE_HEALTH", + externalId: STEP_STATS_ID, + }, + ] as never); + + const res = await POST( + makeRequest({ + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierStepCount", + value: 8421, + unit: "count", + startDate: "2026-06-21T00:00:00.000Z", + endDate: "2026-06-21T23:59:59.000Z", + externalId: STEP_STATS_ID, + }, + ], + }), + ); + expect(res.status).toBe(200); + const { data } = await readJson(res); + expect(data.updated).toBe(1); + expect(data.entries[0].status).toBe("updated"); + }); +}); diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 4cfb40d7d..d56a1d236 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -18,13 +18,20 @@ * HealthKit sample, surface as `duplicate` per entry instead of * hard-failing the batch. * - v1.5.0 issue #213 — entries whose `externalId` starts with - * `stats:` are per-day cumulative totals (Steps, Active Energy, - * Sleep Duration, Walking/Running Distance, Flights Climbed). The - * iOS HealthKit observer re-posts those throughout the day as the - * running total changes. They surface as `updated` (the row's - * value is overwritten) rather than `duplicate` (the new value - * dropped). Sample-class externalIds keep the strict immutable - * `duplicate` contract. + * `stats:` are aggregate rows the iOS HealthKit observer re-posts as + * the underlying window fills. Two granularities ride the same + * overwrite mechanism: + * · per-day cumulative totals — `stats::` (Steps, + * Active Energy, Sleep Duration, Walking/Running Distance, + * Flights Climbed); + * · v1.19.0 (iOS #34) hourly heart-rate buckets — + * `stats:HKQuantityTypeIdentifierHeartRate:` + * carrying the hour's AVERAGE bpm as one PULSE row, so iOS stops + * uploading one row per raw HR sample. + * Both surface as `updated` (the row's value is overwritten) rather + * than `duplicate` (the new value dropped). Sample-class externalIds + * (every other prefix) keep the strict immutable `duplicate` + * contract. */ import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -40,7 +47,11 @@ import { safeJson, } from "@/lib/api-response"; import { withIdempotency } from "@/lib/idempotency"; -import { mapAppleHealthEntry } from "@/lib/measurements/apple-health-mapping"; +import { + mapAppleHealthEntry, + isHourlyHeartRateStatsExternalId, + targetsHourlyHeartRateBucket, +} from "@/lib/measurements/apple-health-mapping"; import { MEASURED_AT_TOLERANCE_MS, isMergeableSource, @@ -268,6 +279,28 @@ async function postBatch(request: NextRequest): Promise { continue; } + // v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire + // contract. A `stats:HKQuantityTypeIdentifierHeartRate:` row + // is the hourly-average PULSE bucket; it rides the generic `stats:` + // overwrite path below so a within-hour re-post (the running mean + // shifts) replaces the value instead of duplicating. Reject a row + // that targets the HR-bucket prefix but carries a malformed hour + // suffix — a garbage suffix would mint an un-overwriteable row that + // the next hour's re-post can't collapse onto. A well-formed bucket + // (and every non-HR `stats:` / per-sample `uuid` externalId) passes + // through unchanged. + if ( + targetsHourlyHeartRateBucket(entry.externalId) && + !isHourlyHeartRateStatsExternalId(entry.externalId) + ) { + results[index] = { + index, + status: "skipped", + reason: "malformed_hr_bucket_id", + }; + continue; + } + prepared.push({ index, entry, diff --git a/src/lib/measurements/__tests__/hourly-hr-stats.test.ts b/src/lib/measurements/__tests__/hourly-hr-stats.test.ts new file mode 100644 index 000000000..bf00e18d3 --- /dev/null +++ b/src/lib/measurements/__tests__/hourly-hr-stats.test.ts @@ -0,0 +1,99 @@ +/** + * v1.19.0 (iOS #34) — hourly heart-rate wire-contract helpers. + * + * Covers the externalId minting + well-formedness guard that the batch + * ingest route uses to route the go-forward hourly-average PULSE bucket + * through the existing `stats:` overwrite path. + */ +import { describe, expect, it } from "vitest"; + +import { + hourlyHeartRateStatsExternalId, + isHourlyHeartRateStatsExternalId, + targetsHourlyHeartRateBucket, +} from "@/lib/measurements/apple-health-mapping"; + +describe("hourlyHeartRateStatsExternalId", () => { + it("floors to the UTC hour and mints the canonical stats externalId", () => { + const id = hourlyHeartRateStatsExternalId( + new Date("2026-06-21T14:37:42.913Z"), + ); + expect(id).toBe( + "stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:00:00.000Z", + ); + }); + + it("is stable: two instants in the same UTC hour map to one id", () => { + const a = hourlyHeartRateStatsExternalId(new Date("2026-06-21T14:00:00Z")); + const b = hourlyHeartRateStatsExternalId(new Date("2026-06-21T14:59:59Z")); + expect(a).toBe(b); + }); + + it("round-trips through the well-formedness guard", () => { + const id = hourlyHeartRateStatsExternalId(new Date("2026-01-02T03:04:05Z")); + expect(isHourlyHeartRateStatsExternalId(id)).toBe(true); + }); +}); + +describe("isHourlyHeartRateStatsExternalId", () => { + it("accepts a zeroed ISO-hour HR bucket", () => { + expect( + isHourlyHeartRateStatsExternalId( + "stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:00:00.000Z", + ), + ).toBe(true); + }); + + it.each([ + // non-zero minutes + "stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:30:00.000Z", + // missing millis + "stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:00:00Z", + // date-only (the per-day cumulative shape, not hourly) + "stats:HKQuantityTypeIdentifierHeartRate:2026-06-21", + // no trailing Z + "stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:00:00.000", + // garbage suffix + "stats:HKQuantityTypeIdentifierHeartRate:not-a-date", + // impossible month still matches the regex shape but fails Date parse + "stats:HKQuantityTypeIdentifierHeartRate:2026-13-01T14:00:00.000Z", + ])("rejects malformed suffix %s", (id) => { + expect(isHourlyHeartRateStatsExternalId(id)).toBe(false); + }); + + it.each([ + // a different HK identifier's per-day stats row + "stats:HKQuantityTypeIdentifierStepCount:2026-06-21", + // a per-sample uuid externalId + "B3A1C0DE-0000-4000-8000-000000000000", + null, + undefined, + ])("returns false for non-HR-bucket externalId %s", (id) => { + expect(isHourlyHeartRateStatsExternalId(id as string | null)).toBe(false); + }); +}); + +describe("targetsHourlyHeartRateBucket", () => { + it("is true for any HR-bucket prefix, valid or malformed", () => { + expect( + targetsHourlyHeartRateBucket( + "stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:00:00.000Z", + ), + ).toBe(true); + expect( + targetsHourlyHeartRateBucket( + "stats:HKQuantityTypeIdentifierHeartRate:not-a-date", + ), + ).toBe(true); + }); + + it("is false for per-day cumulative stats and per-sample uuids", () => { + expect( + targetsHourlyHeartRateBucket( + "stats:HKQuantityTypeIdentifierStepCount:2026-06-21", + ), + ).toBe(false); + expect(targetsHourlyHeartRateBucket("some-uuid")).toBe(false); + expect(targetsHourlyHeartRateBucket(null)).toBe(false); + }); +}); diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index 497845ee7..866ecc5c6 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -795,6 +795,90 @@ export function dailyStatsExternalId( return `stats:${hkIdentifier}:${dateYYYYMMDD}`; } +/** + * v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire contract. + * + * iOS uploads ONE PULSE row per hourly heart-rate bucket instead of one + * row per raw HealthKit sample (~16k rows/user/day → ~24). The bucket's + * `value` is the hourly AVERAGE bpm; the row reuses the existing + * `stats:` overwrite path so a re-post within the same hour (the running + * average shifts as more samples land) OVERWRITES rather than + * duplicating — identical mechanics to the per-day `stats::` + * cumulative rows, but at hourly granularity for the spot HR metric. + * + * Format: `stats:HKQuantityTypeIdentifierHeartRate:` where + * `` is the ISO-8601 instant of the hour boundary in UTC + * with a literal `Z` and ZEROED minutes/seconds/millis — e.g. + * `stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:00:00.000Z`. + * + * The hour boundary is in UTC (not the user's local hour) so the + * externalId is timezone-stable: a user travelling across zones never + * re-keys the same wall-clock hour to a new bucket. The `measuredAt` + * the client sends (`endDate`) still carries the offset for display. + * + * AVG-ONLY in v1.19.0. The `Measurement` model carries a single `value` + * column; hourly min/max would need a schema migration (companion + * columns or companion `stats:` rows), which is a fast-follow — see the + * iOS-coord note. Until then iOS uploads the hourly mean as PULSE; the + * raw min/max is recoverable from the device's HealthKit store and the + * server-side nightly fold of any residual raw backlog. + */ +const HOURLY_HR_HK_IDENTIFIER = "HKQuantityTypeIdentifierHeartRate"; + +export function hourlyHeartRateStatsExternalId(bucketStart: Date): string { + const hourFloor = new Date(bucketStart); + hourFloor.setUTCMinutes(0, 0, 0); + return `stats:${HOURLY_HR_HK_IDENTIFIER}:${hourFloor.toISOString()}`; +} + +/** + * The exact ISO-hour suffix shape the hourly HR bucket externalId must + * carry: full ISO-8601 UTC instant, minutes/seconds/millis all zero, + * trailing `Z`. Anchored so a partial / malformed suffix is rejected. + */ +const HOURLY_HR_SUFFIX_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:00:00\.000Z$/; + +/** + * v1.19.0 (iOS #34) — well-formedness guard for the hourly HR bucket + * externalId. Returns `true` ONLY for a `stats:` externalId that targets + * the heart-rate identifier AND carries a zeroed ISO-hour suffix that + * parses to a real instant. Used by the batch route to reject a + * malformed hourly-HR bucket (`skipped:"malformed_hr_bucket_id"`) rather + * than persisting an un-overwriteable, un-foldable garbage row. + * + * Non-HR `stats:` externalIds (the per-day cumulative rows) are NOT this + * function's concern — it returns `false` for them so the caller leaves + * them on the untouched path. + */ +export function isHourlyHeartRateStatsExternalId( + externalId: string | null | undefined, +): boolean { + if (typeof externalId !== "string") return false; + const prefix = `stats:${HOURLY_HR_HK_IDENTIFIER}:`; + if (!externalId.startsWith(prefix)) return false; + const suffix = externalId.slice(prefix.length); + if (!HOURLY_HR_SUFFIX_RE.test(suffix)) return false; + const parsed = new Date(suffix); + return !Number.isNaN(parsed.getTime()); +} + +/** + * v1.19.0 (iOS #34) — does this externalId CLAIM to be an hourly HR + * bucket (right prefix + HR identifier) regardless of suffix validity? + * The route uses this to decide a malformed-vs-valid split: a row that + * targets the HR bucket prefix but fails `isHourlyHeartRateStatsExternalId` + * is a malformed bucket (reject), not an unrelated `stats:` row (pass + * through untouched). + */ +export function targetsHourlyHeartRateBucket( + externalId: string | null | undefined, +): boolean { + return ( + typeof externalId === "string" && + externalId.startsWith(`stats:${HOURLY_HR_HK_IDENTIFIER}:`) + ); +} + /** * v1.4.30 — reverse lookup from a HealthLog `MeasurementType` to the * canonical HealthKit identifier for that type. Used by the drain diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index b9e7e76f8..d1b1f1ef0 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -26,7 +26,12 @@ const batchEntrySchema = z .describe( "HealthKit identifier (e.g. `HKQuantityTypeIdentifierBodyMass`).", ), - value: z.number().finite(), + value: z + .number() + .finite() + .describe( + "Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For an hourly heart-rate bucket (see `externalId`) this is the hour's AVERAGE bpm — v1.19.0 ships avg-only; min/max are a documented fast-follow.", + ), unit: z.string().min(1).max(60), startDate: z.iso.datetime({ offset: true }), endDate: z.iso.datetime({ offset: true }), @@ -52,7 +57,12 @@ const batchEntrySchema = z .string() .min(1) .max(120) - .describe("HKSample.uuid string — the dedup key."), + .describe( + "Dedup key for the `(userId, type, source, externalId)` composite index. Three shapes:\n" + + "• `HKSample.uuid` — a per-sample reading. First-write-wins: a re-post returns `duplicate` and the stored row is immutable.\n" + + "• `stats::` — a per-day cumulative total (Steps, Active Energy, Sleep Duration, Walking/Running Distance, Flights Climbed). A re-post OVERWRITES the row and returns `updated`.\n" + + "• `stats:HKQuantityTypeIdentifierHeartRate:` — v1.19.0 (iOS #34) hourly heart-rate bucket carrying the hour's AVERAGE bpm as one PULSE row. `` is the ISO-8601 UTC hour boundary with zeroed minutes/seconds/millis and a trailing `Z` (e.g. `2026-06-21T14:00:00.000Z`). A re-post within the hour OVERWRITES the row and returns `updated`, so iOS uploads ~24 rows/day instead of one per raw HR sample. A row that targets this prefix with a malformed hour suffix is `skipped` with reason `malformed_hr_bucket_id`.", + ), externalSourceVersion: z.string().min(1).max(120).optional(), // v1.8.6 W6 — optional per-entry source tag. Defaults to // `APPLE_HEALTH` server-side when omitted, so legacy clients are @@ -90,16 +100,27 @@ const batchPayloadSchema = z const batchEntryResult = z .object({ index: z.number().int().nonnegative(), - status: z.enum(["inserted", "duplicate", "skipped"]), + status: z + .enum(["inserted", "updated", "duplicate", "skipped"]) + .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`.", + ), reason: z.string().optional(), }) .meta({ id: "AppleHealthBatchEntryResult" }); const batchResponse = z .object({ + processed: z.number().int().nonnegative(), inserted: z.number().int().nonnegative(), - duplicate: z.number().int().nonnegative(), - skipped: z.number().int().nonnegative(), + updated: z.number().int().nonnegative(), + duplicates: z.number().int().nonnegative(), + skipped: z.array( + z.object({ + index: z.number().int().nonnegative(), + reason: z.string(), + }), + ), entries: z.array(batchEntryResult), }) .meta({ id: "AppleHealthBatchResponse" });