feat(api): nullable deviceType on Measurement for two-axis source priority

Add an optional deviceType field to the POST /api/measurements/batch
payload schema. The iOS client maps each sample's HKDevice.model to a
canonical device class (watch | band | ring | phone | scale | other |
unknown); the server stores it on a new Measurement.device_type
column. The cross-source canonical picker (W8c) reads this column to
break Apple-Watch-vs-iPhone ties when the same source contributed
multiple devices for the same day.

Backward-compatible end-to-end:
  - Schema field is optional; legacy iOS builds keep working unchanged.
  - DB column is nullable; the migration is an additive ALTER TABLE
    (Migration 0051, next free per v1.4.25 handoff section 4).
  - The picker treats null as 'unknown' and only uses it as a
    tiebreaker when a ranked device-type coexists in the bucket; with
    a single source + null deviceType the row passes through as today.

OpenAPI: deviceType is documented on AppleHealthBatchEntry with
guidance on the HKDevice.model mapping. Batch endpoint description
gains a W8c note. iOS-Claude can lock the field name + enum from the
spec without re-reading source.

Tests: 45/45 source-priority tests pass; the batch route's existing
integration tests are untouched by the schema addition.

Pre-existing lint failure unrelated to this change:
src/components/i18n/maintainership-banner.tsx triggers a
react-hooks/set-state-in-effect warning; the file is currently
untracked and lives outside this Wave's scope.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 15:34:18 +02:00
1 parent 27f06c1576
commit 024845e5bd
4 files changed
+52 -2

No files matched your search

@@ -0,0 +1,13 @@
-- v1.4.25 W8c — nullable device_type column on measurements.
--
-- Two-axis source-priority picker (src/lib/analytics/source-priority.ts)
-- reads this column to break ties when more than one device contributes
-- the same source's metric (e.g. Apple Watch + iPhone both stream
-- HealthKit steps via APPLE_HEALTH). NULL columns map to "unknown" in
-- the picker, so legacy rows continue to work without a backfill.
--
-- Canonical values documented in src/lib/validations/source-priority.ts
-- under `deviceTypeEnum`: watch | band | ring | phone | scale | other |
-- unknown. Stored as free text to avoid coordinated enum bumps across
-- the iOS client and server every time a new device class arrives.
ALTER TABLE "measurements" ADD COLUMN "device_type" TEXT;
+11
View File
@@ -327,6 +327,17 @@ model Measurement {
// only for `type = SLEEP_DURATION`. Mirrors the glucoseContext
// pattern. A single night produces ~5 rows (one per stage).
sleepStage SleepStage? @map("sleep_stage")
// v1.4.25 W8c — device-type tag for the cross-source canonical
// picker. Stored as a free-text string (kept simple — Prisma enum
// would require coordinated client + server bumps every time we
// gain a new device class). The canonical values are documented in
// `deviceTypeEnum` (src/lib/validations/source-priority.ts):
// `watch | band | ring | phone | scale | other | unknown`. Legacy
// rows + every pre-W8c Measurement stays NULL; the picker treats
// NULL as `unknown`. Unique constraints intentionally do not
// include `deviceType` so two ingest paths from the same source
// still dedup on (type, source, externalId).
deviceType String? @map("device_type")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
+14
View File
@@ -34,6 +34,7 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import { mapAppleHealthEntry } from "@/lib/measurements/apple-health-mapping";
import { validateMeasurementRange } from "@/lib/validations/measurement";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { Prisma } from "@/generated/prisma/client";
const MAX_BATCH_ENTRIES = 500;
@@ -47,6 +48,14 @@ const batchEntrySchema = z.object({
sleepStage: z.number().int().min(0).max(20).optional(),
externalId: z.string().min(1).max(120),
externalSourceVersion: z.string().min(1).max(120).optional(),
// v1.4.25 W8c — optional device-type tag. The iOS client maps the
// `HKDevice.model` of each sample to one of the canonical device
// classes (watch | band | ring | phone | scale | other | unknown).
// Backward-compatible: every pre-W8c iOS build skips the field and
// the row is stored with `deviceType = null`; the canonical picker
// treats null as `unknown` and only uses it as a tiebreaker when a
// ranked device-type coexists in the same daily bucket.
deviceType: deviceTypeEnum.nullable().optional(),
});
const batchPayloadSchema = z.object({
@@ -156,6 +165,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
externalId: entry.externalId,
externalSourceVersion: entry.externalSourceVersion ?? null,
sleepStage: mapped.sleepStage ?? null,
// v1.4.25 W8c — pass the iOS-supplied device-type through to
// the row. Stays null for pre-W8c iOS builds; the canonical
// picker treats null as `unknown` so legacy ingest keeps
// working without a server-side default.
deviceType: entry.deviceType ?? null,
},
});
}
+14 -2
View File
@@ -31,7 +31,10 @@ import {
} from "@/lib/validations/measurement";
import { loginPasswordSchema } from "@/lib/validations/auth";
import { coachPrefsSchema } from "@/lib/validations/coach-prefs";
import { sourcePrioritySchema } from "@/lib/validations/source-priority";
import {
deviceTypeEnum,
sourcePrioritySchema,
} from "@/lib/validations/source-priority";
/**
* Common envelopes — every HealthLog API response wraps payload in
@@ -238,6 +241,15 @@ const batchEntrySchema = z
.max(120)
.describe("HKSample.uuid string — the dedup key."),
externalSourceVersion: z.string().min(1).max(120).optional(),
// v1.4.25 W8c — optional device-type tag fed into the canonical
// source picker's second axis. NULL is treated as `unknown`;
// legacy iOS builds that don't ship the field continue to work.
deviceType: deviceTypeEnum
.nullable()
.optional()
.describe(
"Device class mapped from `HKDevice.model`. Used by the analytics aggregator to break ties when the same source contributed multiple devices for the same day. Omit (or send null) on legacy clients — the server treats it as `unknown` and the picker falls through.",
),
})
.meta({ id: "AppleHealthBatchEntry" });
@@ -466,7 +478,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Measurements"],
summary: "Apple Health batch ingest",
description:
"Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status lets the iOS client advance its sync cursor accurately.",
"Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status lets the iOS client advance its sync cursor accurately. v1.4.25 W8c adds an optional `deviceType` per entry — feed it from `HKDevice.model` so the cross-source canonical picker can break Apple-Watch-vs-iPhone ties; null/absent stays backward-compatible with v1.4.23 clients.",
requestBody: {
required: true,
content: { "application/json": { schema: batchPayloadSchema } },