Files
HealthLog/src/lib/workouts/batch-ingest-parse.ts
T
Marc-André Bombeck 3d55289ba3 feat(workouts): attach a heart-rate series to a workout already stored
A batch entry matching a stored workout stays first-write-wins for the
workout row. What it can do now is hand over a heart-rate series the
stored workout does not have yet: the series is attached, the row itself
is not written at all, and the entry reports back as `enriched`. A
workout that already carries a series is a no-op, so a client can walk
its whole history as often as it likes.

- The pre-flight probe now answers both questions at once, whether the
  row exists and whether it already has a series, and carries the id so
  the attach addresses the stored workout without a second round trip.
- The attach rides the same child write as a fresh workout's series.
  RETURNING names the rows that landed, so a workout that gained a
  series between probe and write reports as the duplicate it is instead
  of claiming an attach that never happened.
- A `samples` array that fails validation is refused per entry with
  `reason: "invalid_samples"` rather than failing the batch. One
  unusable session out of a hundred must not stall a history sweep the
  client cannot repair by retrying; every other validation failure keeps
  failing the batch.
- The wide event and the audit row tally enrichments. Counts only, the
  series never reaches either.
- `enriched` is the one contract movement: additive on the per-entry
  status, request shape unchanged. OpenAPI regenerated.

Integration coverage against a real Postgres pins the attach, the
repeat as a no-op, the refusal of a bad series alongside entries that
land, a mixed batch reporting each status, and the stored workout row
compared whole before and after, `updated_at` included.
2026-08-08 09:15:44 +02:00

110 lines
4.0 KiB
TypeScript

/**
* Batch-ingest body parsing for `POST /api/workouts/batch`.
*
* The wire contract is `createBatchWorkoutSchema` and stays exactly that:
* this module never widens what the endpoint accepts. What it changes is
* WHERE one specific failure lands.
*
* A malformed field anywhere in the batch fails the whole call with a 400,
* which is right for a client that got the shape wrong. The `samples`
* array is the exception, because of how it arrives: the native client
* re-posts workouts the server already stores purely to hand over their
* heart-rate curve, sweeping years of history in batches of up to a
* hundred. One session whose series came out of HealthKit malformed would
* otherwise stall the entire sweep at a 400 the client cannot repair by
* retrying — every other workout in that batch is fine and has nothing to
* do with the bad one.
*
* So an entry whose `samples` array fails validation is refused on its
* own, exactly the way an entry with an unusable `externalId` already is
* (see `classifyExternalId` in the route), and the rest of the batch
* lands. The refused entry is NOT stripped down and stored without its
* series: the caller asked for the curve to be saved, so a half-write
* would be a silent loss. It is reported back with its index so the
* client knows which session to fix.
*
* Every other validation failure keeps failing the batch.
*/
import {
createBatchWorkoutSchema,
type CreateBatchWorkoutInput,
} from "@/lib/validations/workout";
/** Per-entry `reason` code for a `samples` array that failed validation. */
export const INVALID_SAMPLES_REASON = "invalid_samples";
export type WorkoutBatchParseResult =
| {
ok: true;
data: CreateBatchWorkoutInput;
/** Batch indices whose `samples` array was refused. */
refusedSampleIndices: number[];
}
| { ok: false; message: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Indices of entries whose ONLY problem is the `samples` array. An issue
* path of `["workouts", 3, "samples", ...]` names entry 3; anything else
* means the batch is broken in a way this path does not forgive.
*/
function collectSampleFailureIndices(
issues: ReadonlyArray<{ path: PropertyKey[] }>,
): number[] | null {
const indices = new Set<number>();
for (const issue of issues) {
const [root, index, field] = issue.path;
if (root !== "workouts" || typeof index !== "number" || field !== "samples")
return null;
indices.add(index);
}
return indices.size > 0 ? [...indices].sort((a, b) => a - b) : null;
}
/**
* Parses a batch body, refusing per entry when the only failures are
* `samples` arrays. On that path the offending entries are re-parsed
* without their series so the surviving entries keep their fully typed
* shape; the caller drops the refused indices before any write.
*/
export function parseWorkoutBatch(rawBody: unknown): WorkoutBatchParseResult {
const strict = createBatchWorkoutSchema.safeParse(rawBody);
if (strict.success) {
return { ok: true, data: strict.data, refusedSampleIndices: [] };
}
const refusedSampleIndices = collectSampleFailureIndices(strict.error.issues);
if (
refusedSampleIndices === null ||
!isRecord(rawBody) ||
!Array.isArray(rawBody.workouts)
) {
return {
ok: false,
message: strict.error.issues[0]?.message ?? "Invalid batch",
};
}
const refused = new Set(refusedSampleIndices);
const stripped = {
...rawBody,
workouts: rawBody.workouts.map((entry, index) => {
if (!refused.has(index) || !isRecord(entry)) return entry;
const { samples: _samples, ...rest } = entry;
return rest;
}),
};
const relaxed = createBatchWorkoutSchema.safeParse(stripped);
if (!relaxed.success) {
return {
ok: false,
message: relaxed.error.issues[0]?.message ?? "Invalid batch",
};
}
return { ok: true, data: relaxed.data, refusedSampleIndices };
}