mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge w11b/a4hrwire into release/v1.19.0
This commit is contained in:
commit
837f215f66
6 files changed
+540
-19
No files matched your search
+48
-6
@@ -7209,6 +7209,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
|
||||
@@ -7238,7 +7241,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:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>` — 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:<bucket-start-hour>` — v1.19.0 (iOS #34) hourly heart-rate bucket
|
||||
carrying the hour's AVERAGE bpm as one PULSE row. `<bucket-start-hour>` 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
|
||||
@@ -11286,25 +11303,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
|
||||
@@ -11319,8 +11357,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:
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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:<HK>:<YYYY-MM-DD>` (Steps,
|
||||
* Active Energy, Sleep Duration, Walking/Running Distance,
|
||||
* Flights Climbed);
|
||||
* · v1.19.0 (iOS #34) hourly heart-rate buckets —
|
||||
* `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start-hour>`
|
||||
* 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<Response> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire
|
||||
// contract. A `stats:HKQuantityTypeIdentifierHeartRate:<hour>` 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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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:<HK>:<day>`
|
||||
* cumulative rows, but at hourly granularity for the spot HR metric.
|
||||
*
|
||||
* Format: `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start>` where
|
||||
* `<bucket-start>` 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
|
||||
|
||||
@@ -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:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>` — 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:<bucket-start-hour>` — v1.19.0 (iOS #34) hourly heart-rate bucket carrying the hour's AVERAGE bpm as one PULSE row. `<bucket-start-hour>` 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" });
|
||||
|
||||
Reference in new issue
Block a user