feat(api): lock the daily-stats externalId shape for cumulative HealthKit types

R-A Option A handoff prep. Adds dailyStatsExternalId() helper alongside
the v1.4.29 CUMULATIVE_HK_TYPES set; the helper mints
"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>" so iOS can UPSERT one
row per day per cumulative type via the existing batch endpoint, the
unique index collapses re-syncs idempotently, and a future PATCH-on-
divergence call updates a late-watch-sync revision without inserting a
second row. Handoff docs (06 + 08) lock the shape for the iOS client.

Cumulative-type set unchanged from v1.4.29; spot metrics keep the
per-sample uuid externalId. Server tolerates both shapes during the
cutover window — the drain script (next commit) collapses pre-existing
per-sample rows post-cutover.

Tests cover the helper round-trip, the cumulative-type identifier wall,
and the iOS-trusted date-string passthrough.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 11:51:25 +02:00
1 parent 1a2447295d
commit 6e6bc61800
4 files changed
+182 -1

No files matched your search

@@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest";
import {
APPLE_HEALTH_SLEEP_STAGE_MAP,
APPLE_HEALTH_TYPE_MAP,
CUMULATIVE_HK_TYPES,
HK_QUANTITY_TYPE_DEFERRED,
dailyStatsExternalId,
mapAppleHealthEntry,
} from "../apple-health-mapping";
import { measurementTypeEnum } from "@/lib/validations/measurement";
@@ -343,3 +345,55 @@ describe("mapAppleHealthEntry", () => {
expect(out).toBeNull();
});
});
describe("dailyStatsExternalId (v1.4.30 — R-A Option A handoff lock)", () => {
it("produces the canonical stats:<type>:<date> shape for stepCount", () => {
expect(
dailyStatsExternalId("HKQuantityTypeIdentifierStepCount", "2026-05-16"),
).toBe("stats:HKQuantityTypeIdentifierStepCount:2026-05-16");
});
it("produces the same shape for every cumulative-type identifier", () => {
const cases: Array<[string, string]> = [
["HKQuantityTypeIdentifierStepCount", "2026-01-01"],
["HKQuantityTypeIdentifierActiveEnergyBurned", "2026-02-29"],
["HKQuantityTypeIdentifierFlightsClimbed", "2025-12-31"],
["HKQuantityTypeIdentifierDistanceWalkingRunning", "2024-02-29"],
["HKQuantityTypeIdentifierTimeInDaylight", "2026-05-16"],
];
for (const [id, day] of cases) {
expect(dailyStatsExternalId(id, day)).toBe(`stats:${id}:${day}`);
}
});
it("accepts the date string as-is — iOS owns the format", () => {
// Per R-A §5, iOS generates the date string from the user's IANA
// timezone via DateFormatter with the `yyyy-MM-dd` pattern. The
// server trusts the inbound shape rather than re-validating; the
// receiving Zod schema already caps `externalId` at 120 chars.
expect(
dailyStatsExternalId("HKQuantityTypeIdentifierStepCount", " 2026-05-16 "),
).toBe("stats:HKQuantityTypeIdentifierStepCount: 2026-05-16 ");
expect(
dailyStatsExternalId("HKQuantityTypeIdentifierStepCount", ""),
).toBe("stats:HKQuantityTypeIdentifierStepCount:");
});
it("covers every CUMULATIVE_HK_TYPES MeasurementType via a known HK identifier", () => {
// Sanity wall: every cumulative MeasurementType has a forward-map
// entry in APPLE_HEALTH_TYPE_MAP so the iOS-side daily-stats
// service can mint the externalId without a server round-trip.
const cumulativeIdentifiers = Object.values(APPLE_HEALTH_TYPE_MAP)
.filter((m) => CUMULATIVE_HK_TYPES.has(m.measurementType))
.map((m) => m.hkIdentifier);
expect(cumulativeIdentifiers.sort()).toEqual(
[
"HKQuantityTypeIdentifierStepCount",
"HKQuantityTypeIdentifierActiveEnergyBurned",
"HKQuantityTypeIdentifierFlightsClimbed",
"HKQuantityTypeIdentifierDistanceWalkingRunning",
"HKQuantityTypeIdentifierTimeInDaylight",
].sort(),
);
});
});
@@ -310,6 +310,41 @@ export const CUMULATIVE_HK_TYPES: ReadonlySet<MeasurementType> = new Set<Measure
"TIME_IN_DAYLIGHT",
]);
/**
* v1.4.30 — externalId shape for daily-aggregated cumulative
* HealthKit rows. iOS emits one row per day per cumulative type via
* `HKStatisticsCollectionQuery` per R-A Option A; the externalId
* UPSERTs the matching server row idempotently across re-syncs.
*
* Format: `stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>`.
*
* Cumulative HK types only — the spot-sample path keeps using
* `HKSample.uuid` as `externalId`. The shape is intentionally
* stable across the cutover: iOS clients still posting per-sample
* rows round-trip through the existing `(userId, type, source,
* externalId)` unique index; iOS clients on the daily-stats path
* collide on the deterministic `"stats:..."` key for idempotent
* UPSERTs.
*
* Locked contract — see
* `.planning/v15-ios-handoff/08-locked-contracts.md` §13 and
* `.planning/v15-ios-handoff/06-ios-responsibilities.md` Domain 1
* "Cumulative metrics: daily aggregation on iOS".
*
* The helper accepts the date string as-is: iOS generates it from
* the user's IANA timezone via `DateFormatter` with the
* `yyyy-MM-dd` pattern; the server trusts that format rather than
* re-validating per ingest because the iOS handoff doc locks the
* shape and the receiving Zod schema already caps `externalId` at
* 120 characters.
*/
export function dailyStatsExternalId(
hkIdentifier: string,
dateYYYYMMDD: string,
): string {
return `stats:${hkIdentifier}:${dateYYYYMMDD}`;
}
/**
* HK identifiers the iOS app may emit that HealthLog deliberately does
* NOT map yet. Listing them here means the batch route can log a