Merge w11b/a4hrwire into release/v1.19.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-21 10:22:36 +02:00
commit 837f215f66
6 files changed
+540 -19

No files matched your search

+48 -6
View File
@@ -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");
});
});
+41 -8
View File
@@ -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 -5
View File
@@ -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" });