fix(release): harden v1.31 arrival contracts

This commit is contained in:
Marc-André Bombeck committed 2026-07-20 00:40:20 +02:00
1 parent 44add3bc1e
commit 4591899657
98 files changed
+5560 -1600

No files matched your search

+45 -9
View File
@@ -105,13 +105,11 @@ const layoutSchema = z.object({
// Partial-record matches the original intent — overlay prefs are
// per-chart opt-in, the resolver fills in defaults for missing keys.
//
// The inner object also documents `comparisonBaseline` so the
// per-chart `<ChartOverlayControls>` popover (which calls
// The inner object also documents `comparisonBaseline` and `rangePoints`
// so the per-chart `<ChartOverlayControls>` popover (which calls
// `/api/dashboard/chart-overlay-prefs`) and a full-layout PUT from
// Settings → Dashboard can both round-trip the field. Without it Zod
// would silently strip the per-chart `comparisonBaseline` on every
// Save click in Settings, wiping any per-chart toggle the user had
// set via the chart-card popover.
// Settings → Dashboard can round-trip both fields. Without them Zod would
// silently strip either per-chart choice on every Save click.
chartOverlayPrefs: z
.partialRecord(
z.enum(CHART_OVERLAY_KEYS),
@@ -122,6 +120,9 @@ const layoutSchema = z.object({
comparisonBaseline: z
.enum(["none", "lastMonth", "lastYear"])
.optional(),
rangePoints: z
.union([z.literal(0), z.literal(7), z.literal(30), z.literal(90)])
.optional(),
}),
)
.optional(),
@@ -291,14 +292,23 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// every field of `DashboardLayout`. Adding a layout field without a
// disposition fails typecheck, so the next field cannot silently miss this
// merge the way `comparisonBaseline` did. One stored-layout read covers
// every fallback, and the read is skipped entirely when the client sent
// the full set.
// every fallback, and the read is skipped only when the client sent every
// preserved top-level field and every supplied chart pref has rangePoints.
// The Zod shape leaves the per-chart `comparisonBaseline` optional while
// `ChartOverlayPrefs` requires it; `coerceChartOverlayPrefsMap` inside the
// serializer fills the gap, so the assertion is safe here exactly as it was
// on the previous per-field cast.
let toSerialize = parsed.data as Partial<DashboardLayout>;
if (layoutNeedsPreserveRead(toSerialize)) {
const incomingChartOverlayPrefs = toSerialize.chartOverlayPrefs;
const needsRangePointsPreserve =
incomingChartOverlayPrefs !== undefined &&
CHART_OVERLAY_KEYS.some((chartKey) => {
const prefs = incomingChartOverlayPrefs[chartKey];
return prefs !== undefined && prefs.rangePoints === undefined;
});
const needsPreserveRead =
layoutNeedsPreserveRead(toSerialize) || needsRangePointsPreserve;
if (needsPreserveRead) {
const existing = await prisma.user.findUnique({
where: { id: user.id },
select: { dashboardWidgetsJson: true },
@@ -307,6 +317,32 @@ export const PUT = apiHandler(async (request: NextRequest) => {
existing?.dashboardWidgetsJson,
);
toSerialize = mergePreservedLayoutFields(toSerialize, existingLayout);
if (needsRangePointsPreserve && toSerialize.chartOverlayPrefs) {
let mergedChartOverlayPrefs = toSerialize.chartOverlayPrefs;
for (const chartKey of CHART_OVERLAY_KEYS) {
const incomingPrefs = toSerialize.chartOverlayPrefs[chartKey];
const storedRangePoints =
existingLayout.chartOverlayPrefs?.[chartKey]?.rangePoints;
if (
incomingPrefs !== undefined &&
incomingPrefs.rangePoints === undefined &&
storedRangePoints !== undefined
) {
if (mergedChartOverlayPrefs === toSerialize.chartOverlayPrefs) {
mergedChartOverlayPrefs = { ...toSerialize.chartOverlayPrefs };
}
mergedChartOverlayPrefs[chartKey] = {
...incomingPrefs,
rangePoints: storedRangePoints,
};
}
}
toSerialize = {
...toSerialize,
chartOverlayPrefs: mergedChartOverlayPrefs,
};
}
}
const normalized = serializeDashboardLayout(toSerialize as DashboardLayout);
@@ -11,7 +11,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
* query NEVER selects the encrypted blob column.
*/
process.env.ENCRYPTION_KEY ??=
process.env.ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const { txQueryRaw, txCreate, txCreateMany } = vi.hoisted(() => ({
+195 -5
View File
@@ -10,7 +10,13 @@ vi.mock("@/lib/api-handler", () => ({
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { create: vi.fn(), upsert: vi.fn() },
measurement: {
create: vi.fn(),
createManyAndReturn: vi.fn(),
update: vi.fn(),
findUnique: vi.fn(),
upsert: vi.fn(),
},
moodEntry: { create: vi.fn(), upsert: vi.fn() },
},
}));
@@ -27,6 +33,13 @@ vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/arrivals/measurement-emit", () => ({
emitInsertedMeasurementArrivals: vi.fn(),
}));
vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
maybeEnqueueMorningRefresh: vi.fn(),
}));
// v1.4.39.1 — surface the rollup hook so the new regression test can
// assert that the import path now folds the persistent rollup tier
// for each touched (type, day). Mood rollup mock stays no-op because
@@ -50,9 +63,14 @@ import { POST } from "../route";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findUnique).mockResolvedValue(null);
vi.mocked(emitInsertedMeasurementArrivals).mockResolvedValue(undefined);
vi.mocked(maybeEnqueueMorningRefresh).mockResolvedValue(undefined);
});
interface ApiErrorEnvelope {
@@ -242,6 +260,173 @@ describe("POST /api/import — measurement rollup hook (v1.4.39.1)", () => {
});
});
describe("POST /api/import — arrival wiring", () => {
beforeEach(() => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 4,
resetAt: new Date(Date.now() + 1000),
} as never);
});
it("emits and refreshes only a newly inserted sleep row", async () => {
const measuredAt = new Date("2026-05-14T06:00:00.000Z");
const created = {
id: "sleep-new",
type: "SLEEP_DURATION" as const,
measuredAt,
};
vi.mocked(prisma.measurement.create).mockResolvedValue(created as never);
const response = await POST(
new NextRequest("http://localhost/api/import", {
method: "POST",
body: JSON.stringify({
measurements: [
{
type: "SLEEP_DURATION",
value: 480,
unit: "min",
measuredAt: measuredAt.toISOString(),
},
],
}),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBeLessThan(400);
expect(emitInsertedMeasurementArrivals).toHaveBeenCalledWith(
"u-1",
[created],
"json_import",
);
expect(maybeEnqueueMorningRefresh).toHaveBeenCalledWith("u-1", [
measuredAt,
]);
});
it("emits and refreshes an external-id row only when INSERT returns it", async () => {
const measuredAt = new Date("2026-05-14T06:00:00.000Z");
const inserted = {
id: "sleep-inserted",
type: "SLEEP_DURATION" as const,
measuredAt,
};
vi.mocked(prisma.measurement.createManyAndReturn).mockResolvedValue([
inserted,
] as never);
const response = await POST(
new NextRequest("http://localhost/api/import", {
method: "POST",
body: JSON.stringify({
measurements: [
{
type: "SLEEP_DURATION",
value: 480,
unit: "min",
measuredAt: measuredAt.toISOString(),
externalId: "sleep-new",
},
],
}),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBeLessThan(400);
expect(emitInsertedMeasurementArrivals).toHaveBeenCalledWith(
"u-1",
[inserted],
"json_import",
);
expect(maybeEnqueueMorningRefresh).toHaveBeenCalledWith("u-1", [
measuredAt,
]);
});
it("does not emit or refresh when INSERT loses a race and returns no row", async () => {
vi.mocked(prisma.measurement.findUnique).mockResolvedValue(null);
vi.mocked(prisma.measurement.createManyAndReturn).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.measurement.update).mockResolvedValue({
id: "raced-winner",
type: "SLEEP_DURATION",
measuredAt: new Date("2026-05-14T06:00:00.000Z"),
} as never);
vi.mocked(prisma.measurement.upsert).mockResolvedValue({
id: "raced-winner",
type: "SLEEP_DURATION",
measuredAt: new Date("2026-05-14T06:00:00.000Z"),
} as never);
const response = await POST(
new NextRequest("http://localhost/api/import", {
method: "POST",
body: JSON.stringify({
measurements: [
{
type: "SLEEP_DURATION",
value: 480,
unit: "min",
measuredAt: "2026-05-14T06:00:00.000Z",
externalId: "sleep-raced",
},
],
}),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBeLessThan(400);
expect(emitInsertedMeasurementArrivals).toHaveBeenCalledWith(
"u-1",
[],
"json_import",
);
expect(maybeEnqueueMorningRefresh).toHaveBeenCalledWith("u-1", []);
});
it("does not emit or refresh an existing external-id update", async () => {
vi.mocked(prisma.measurement.findUnique).mockResolvedValue({
id: "existing",
} as never);
vi.mocked(prisma.measurement.upsert).mockResolvedValue({
id: "existing",
type: "SLEEP_DURATION",
measuredAt: new Date("2026-05-14T06:00:00.000Z"),
} as never);
const response = await POST(
new NextRequest("http://localhost/api/import", {
method: "POST",
body: JSON.stringify({
measurements: [
{
type: "SLEEP_DURATION",
value: 480,
unit: "min",
measuredAt: "2026-05-14T06:00:00.000Z",
externalId: "sleep-existing",
},
],
}),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBeLessThan(400);
expect(emitInsertedMeasurementArrivals).toHaveBeenCalledWith(
"u-1",
[],
"json_import",
);
expect(maybeEnqueueMorningRefresh).toHaveBeenCalledWith("u-1", []);
});
});
// v1.17.1 — two real bugs the data-portability audit found in the JSON
// import. Regression guards so a future refactor can't silently re-open
// them.
@@ -323,8 +508,11 @@ describe("POST /api/import — measurement externalId dedup (v1.17.1)", () => {
} as never);
});
it("upserts (not creates) when a measurement carries an externalId", async () => {
vi.mocked(prisma.measurement.upsert).mockResolvedValue({} as never);
it("updates in place when an external-id INSERT returns no row", async () => {
vi.mocked(prisma.measurement.createManyAndReturn).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.measurement.update).mockResolvedValue({} as never);
const response = await POST(
new NextRequest("http://localhost/api/import", {
@@ -345,9 +533,11 @@ describe("POST /api/import — measurement externalId dedup (v1.17.1)", () => {
);
expect(response.status).toBeLessThan(400);
expect(vi.mocked(prisma.measurement.upsert)).toHaveBeenCalledTimes(1);
expect(
vi.mocked(prisma.measurement.createManyAndReturn),
).toHaveBeenCalledTimes(1);
expect(vi.mocked(prisma.measurement.create)).not.toHaveBeenCalled();
const arg = vi.mocked(prisma.measurement.upsert).mock.calls[0][0];
const arg = vi.mocked(prisma.measurement.update).mock.calls[0][0];
expect(arg.where).toEqual({
userId_type_source_externalId: {
userId: "u-1",
+32 -24
View File
@@ -11,14 +11,14 @@ vi.mock("@/lib/api-handler", () => ({
vi.mock("@/lib/db", () => {
const measurement = {
findMany: vi.fn(),
createMany: vi.fn(),
createManyAndReturn: vi.fn(),
updateMany: vi.fn(),
};
return {
prisma: {
measurement,
// Run the batched write callback against the same measurement mock so
// createMany / updateMany calls are observable.
// createManyAndReturn / updateMany calls are observable.
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) =>
fn({ measurement }),
),
@@ -99,14 +99,15 @@ beforeEach(() => {
// db mock factory (resetAllMocks clears its implementation), so restore it.
const measurement = prisma.measurement as unknown as {
findMany: ReturnType<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
createManyAndReturn: ReturnType<typeof vi.fn>;
updateMany: ReturnType<typeof vi.fn>;
};
measurement.findMany.mockResolvedValue([]);
// Echo the attempted chunk size, matching Postgres semantics when no row
// collides — the route now reconciles `inserted` against this count.
measurement.createMany.mockImplementation(
async (arg: { data: unknown[] }) => ({ count: arg.data.length }),
// Echo the attempted rows, matching Postgres semantics when no row
// collides — the route reconciles `inserted` against this result.
measurement.createManyAndReturn.mockImplementation(
async (arg: { data: Array<Record<string, unknown>> }) =>
arg.data.map((row, index) => ({ ...row, id: `inserted-${index}` })),
);
measurement.updateMany.mockResolvedValue({ count: 0 });
(
@@ -139,7 +140,7 @@ describe("POST /api/import/csv — rate limit", () => {
const mMeasurement = () =>
prisma.measurement as unknown as {
findMany: ReturnType<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
createManyAndReturn: ReturnType<typeof vi.fn>;
updateMany: ReturnType<typeof vi.fn>;
};
@@ -147,7 +148,7 @@ describe("POST /api/import/csv — fatal header error", () => {
it("returns 422 when a required column is missing", async () => {
const res = await POST(csvRequest("type,value,unit\nWEIGHT,80,kg"));
expect(res.status).toBe(422);
expect(mMeasurement().createMany).not.toHaveBeenCalled();
expect(mMeasurement().createManyAndReturn).not.toHaveBeenCalled();
});
});
@@ -173,15 +174,15 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
const reasons = body.data?.rows.map((r) => r.reason);
expect(reasons).toContain("unknown_type");
expect(reasons).toContain("missing_timezone_offset");
// One bulk createMany carrying the single valid survivor.
expect(mMeasurement().createMany).toHaveBeenCalledTimes(1);
const arg = mMeasurement().createMany.mock.calls[0][0];
// One bulk createManyAndReturn carrying the single valid survivor.
expect(mMeasurement().createManyAndReturn).toHaveBeenCalledTimes(1);
const arg = mMeasurement().createManyAndReturn.mock.calls[0][0];
expect(arg.data).toHaveLength(1);
// Rollup re-fold fired for the touched (type, day).
expect(vi.mocked(recomputeBucketsForMeasurement)).toHaveBeenCalled();
});
it("batches many rows into a single createMany (no per-row round-trip)", async () => {
it("batches many rows into a single createManyAndReturn", async () => {
const rows = [HEADER];
for (let i = 0; i < 50; i++) {
const mm = String(i).padStart(2, "0"); // unique minute → unique measuredAt
@@ -191,9 +192,11 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
expect(res.status).toBe(200);
const body = (await res.json()) as CsvEnvelope;
expect(body.data?.inserted).toBe(50);
// 50 rows, one createMany call — the whole point of the batch.
expect(mMeasurement().createMany).toHaveBeenCalledTimes(1);
expect(mMeasurement().createMany.mock.calls[0][0].data).toHaveLength(50);
// 50 rows, one bulk call — the whole point of the batch.
expect(mMeasurement().createManyAndReturn).toHaveBeenCalledTimes(1);
expect(
mMeasurement().createManyAndReturn.mock.calls[0][0].data,
).toHaveLength(50);
});
it("dryRun previews without writing and reports projected inserts", async () => {
@@ -208,7 +211,7 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
expect(body.data?.dryRun).toBe(true);
expect(body.data?.inserted).toBe(1);
expect(body.data?.rows[0].status).toBe("inserted");
expect(mMeasurement().createMany).not.toHaveBeenCalled();
expect(mMeasurement().createManyAndReturn).not.toHaveBeenCalled();
expect(vi.mocked(recomputeBucketsForMeasurement)).not.toHaveBeenCalled();
});
@@ -227,7 +230,7 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
expect(body.data?.inserted).toBe(0);
expect(body.data?.rows[0].status).toBe("updated");
expect(mMeasurement().updateMany).toHaveBeenCalledTimes(1);
expect(mMeasurement().createMany).not.toHaveBeenCalled();
expect(mMeasurement().createManyAndReturn).not.toHaveBeenCalled();
});
it("resurrects a tombstoned externalId row on re-import (deletedAt: null in update)", async () => {
@@ -264,7 +267,7 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
expect(body.data?.inserted).toBe(1);
expect(body.data?.updated).toBe(0);
expect(body.data?.rows[0].status).toBe("inserted");
expect(mMeasurement().createMany).toHaveBeenCalledTimes(1);
expect(mMeasurement().createManyAndReturn).toHaveBeenCalledTimes(1);
});
it("counts a pre-existing natural-key row as skipped/duplicate", async () => {
@@ -282,7 +285,7 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
status: "skipped",
reason: "duplicate",
});
expect(mMeasurement().createMany).not.toHaveBeenCalled();
expect(mMeasurement().createManyAndReturn).not.toHaveBeenCalled();
});
it("collapses an in-file duplicate (same type+measuredAt) to one insert + one duplicate", async () => {
@@ -299,16 +302,21 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
expect(body.data?.inserted).toBe(1);
expect(body.data?.skipped).toBe(1);
// Only the first survivor reaches the bulk insert.
expect(mMeasurement().createMany.mock.calls[0][0].data).toHaveLength(1);
expect(
mMeasurement().createManyAndReturn.mock.calls[0][0].data,
).toHaveLength(1);
});
it("reconciles `inserted` against the createMany count when skipDuplicates absorbs a race", async () => {
it("reconciles `inserted` against returned rows after a race", async () => {
// Two fresh rows attempted, but a concurrent double-submit already
// landed one of them — `skipDuplicates` absorbs the conflict and the
// count comes back short. The envelope must sum to the DB truth: one
// inserted, one downgraded to skipped/duplicate.
mMeasurement().createMany.mockImplementation(
async (arg: { data: unknown[] }) => ({ count: arg.data.length - 1 }),
mMeasurement().createManyAndReturn.mockImplementation(
async (arg: { data: Array<Record<string, unknown>> }) =>
arg.data
.slice(0, -1)
.map((row, index) => ({ ...row, id: `inserted-${index}` })),
);
const res = await POST(
+26 -5
View File
@@ -21,6 +21,8 @@ import type {
} from "@/generated/prisma/client";
import { Prisma } from "@/generated/prisma/client";
import type { NormalisedMeasurementRow } from "@/lib/import/csv-measurements";
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
/**
* CSV measurement import (v1.17.1).
@@ -231,22 +233,29 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
// Bulk write: createMany the survivors (chunked under the PG parameter
// cap, skipDuplicates to absorb a concurrent double-submit race), then
// grouped updateMany for the pre-existing externalId rows.
// Bulk write: createManyAndReturn the survivors (chunked under the PG
// parameter cap, skipDuplicates to absorb a concurrent double-submit
// race), then grouped updateMany for pre-existing externalId rows.
const toCreate: Prisma.MeasurementCreateManyInput[] = [
...[...extInserts.values()].map(buildCreateData),
...plainInserts.map(buildCreateData),
];
const CREATE_CHUNK = 200;
let createdCount = 0;
const createdRows: Array<{
id: string;
type: MeasurementType;
measuredAt: Date;
}> = [];
await prisma.$transaction(async (tx) => {
for (let i = 0; i < toCreate.length; i += CREATE_CHUNK) {
const res = await tx.measurement.createMany({
const rows = await tx.measurement.createManyAndReturn({
data: toCreate.slice(i, i + CREATE_CHUNK),
skipDuplicates: true,
select: { id: true, type: true, measuredAt: true },
});
createdCount += res.count;
createdRows.push(...rows);
createdCount += rows.length;
}
for (const m of extUpdates.values()) {
await tx.measurement.updateMany({
@@ -274,6 +283,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
});
void emitInsertedMeasurementArrivals(
userId,
createdRows,
"csv_import",
).catch(() => {});
void maybeEnqueueMorningRefresh(
userId,
createdRows
.filter((row) => row.type === "SLEEP_DURATION")
.map((row) => row.measuredAt),
).catch(() => {});
// Reconcile `inserted` against what `createMany` actually wrote
// (mirrors the batch route's raced-duplicate downgrade). Under
// `skipDuplicates` a conflicting row is silently absorbed — the key is
+64 -29
View File
@@ -25,6 +25,8 @@ import {
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import type { MeasurementType } from "@/generated/prisma/client";
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
// Derived from canonical enum so round-trip export → import covers every
// type. Previous hardcoded subset silently dropped 4 of 11 types
@@ -122,48 +124,67 @@ export const POST = apiHandler(async (request: NextRequest) => {
type: MeasurementType;
measuredAt: Date;
}> = [];
const insertedMeasurements: Array<{
id: string;
type: MeasurementType;
measuredAt: Date;
}> = [];
if (data.measurements?.length) {
for (const m of data.measurements) {
try {
// `measuredAt` is already a `Date` (validateEntryInstant transform).
const measuredAt = m.measuredAt;
if (m.externalId) {
// v1.17.1 — idempotent re-import keyed on the source-stable id, so
// re-uploading the same export updates in place rather than minting
// a duplicate. Mirrors the mood path. The unique key already exists
// (`userId_type_source_externalId`, schema.prisma:942).
await prisma.measurement.upsert({
where: {
userId_type_source_externalId: {
const notesEncrypted = encryptNote(m.notes || null);
// INSERT ... RETURNING is the only insertion proof. A concurrent
// importer can win this key after any existence probe, so attempt
// the insert first and update only when the statement returns no row.
const created = await prisma.measurement.createManyAndReturn({
data: [
{
userId,
type: m.type,
value: m.value,
unit: m.unit,
source: "IMPORT",
externalId: m.externalId,
measuredAt,
notes: null,
notesEncrypted,
glucoseContext: m.glucoseContext ?? null,
},
},
update: {
value: m.value,
unit: m.unit,
measuredAt,
notes: null,
notesEncrypted: encryptNote(m.notes || null),
glucoseContext: m.glucoseContext ?? null,
},
create: {
userId,
type: m.type,
value: m.value,
unit: m.unit,
source: "IMPORT",
externalId: m.externalId,
measuredAt,
notes: null,
notesEncrypted: encryptNote(m.notes || null),
glucoseContext: m.glucoseContext ?? null,
},
],
skipDuplicates: true,
select: { id: true, type: true, measuredAt: true },
});
const inserted = created[0];
if (inserted) {
insertedMeasurements.push(inserted);
} else {
// A pre-existing row or a raced insert owns the key. Preserve
// idempotent re-import semantics without classifying this update
// as an arrival.
await prisma.measurement.update({
where: {
userId_type_source_externalId: {
userId,
type: m.type,
source: "IMPORT",
externalId: m.externalId,
},
},
data: {
value: m.value,
unit: m.unit,
measuredAt,
notes: null,
notesEncrypted,
glucoseContext: m.glucoseContext ?? null,
},
});
}
} else {
await prisma.measurement.create({
const inserted = await prisma.measurement.create({
data: {
userId,
type: m.type,
@@ -175,7 +196,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
notesEncrypted: encryptNote(m.notes || null),
glucoseContext: m.glucoseContext ?? null,
},
select: { id: true, type: true, measuredAt: true },
});
insertedMeasurements.push(inserted);
}
touchedMeasurements.push({
type: m.type as MeasurementType,
@@ -189,6 +212,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
void emitInsertedMeasurementArrivals(
userId,
insertedMeasurements,
"json_import",
).catch(() => {});
void maybeEnqueueMorningRefresh(
userId,
insertedMeasurements
.filter((row) => row.type === "SLEEP_DURATION")
.map((row) => row.measuredAt),
).catch(() => {});
// Import mood entries
if (data.moodEntries?.length) {
for (const e of data.moodEntries) {
@@ -27,6 +27,7 @@ vi.mock("@/lib/api-handler", () => ({
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
@@ -41,6 +42,25 @@ vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(async () => ({ coachPrefsJson: null })) },
coachConversation: { findFirst: vi.fn(async () => ({ id: "c1" })) },
workout: {
findFirst: vi.fn(async () => ({
sportType: "running",
source: "APPLE_HEALTH",
startedAt: new Date("2026-07-01T06:00:00Z"),
endedAt: new Date("2026-07-01T06:40:00Z"),
durationSec: 2400,
totalEnergyKcal: 410,
totalDistanceM: 7200,
avgHeartRate: 148,
maxHeartRate: 171,
minHeartRate: 96,
stepCount: 6800,
elevationM: 62,
pauseDurationSec: 0,
metadata: null,
samples: null,
})),
},
},
}));
@@ -136,6 +156,17 @@ vi.mock("@/lib/ai/coach/snapshot", () => ({
referenceGrounding: "REFERENCE RANGES",
})),
}));
vi.mock("@/lib/workouts/hr-series", () => ({
buildWorkoutHrSeries: vi.fn(async () => null),
}));
vi.mock("@/lib/workouts/zones", () => ({
computeZones: vi.fn(() => null),
hrMaxFromAge: vi.fn(() => 185),
parseWhoopZoneDurations: vi.fn(() => null),
}));
vi.mock("@/lib/workouts/sport-context", () => ({
buildSportContext: vi.fn(async () => null),
}));
// Tool-module surface — stub the inventory + loop so we assert routing only.
const {
@@ -258,6 +289,23 @@ describe("coach chat — tool-mode routing (F1)", () => {
expect(userContent).not.toContain(SNAPSHOT_JSON);
});
it("pins selected-workout evidence into the tool-mode prompt", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
]);
await postAndDrain({
message: "Why was that hard?",
workoutId: "workout-1",
});
const args = (runCoachToolLoop.mock.calls[0] as unknown[])[0] as {
messages: Array<{ content: string }>;
};
expect(args.messages[0].content).toContain("SELECTED WORKOUT DATA");
expect(args.messages[0].content).toContain("thisWorkout");
expect(args.messages[0].content).toContain("2400");
});
it("reserves the multi-round budget in tool mode", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
@@ -37,8 +37,12 @@ vi.mock("@/lib/api-handler", () => ({
},
}));
const { isModuleEnabled } = vi.hoisted(() => ({
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
isModuleEnabled,
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
@@ -281,9 +285,19 @@ beforeEach(() => {
},
workingProvider: { providerType: "admin-openai" },
});
isModuleEnabled.mockResolvedValue(true);
});
describe("coach chat — workout scope, first turn", () => {
it("does not read workout evidence when the workouts module is disabled", async () => {
isModuleEnabled.mockResolvedValueOnce(false);
withPriorTurns(0);
await postAndDrain({ message: "Why was that hard?", workoutId: "w1" });
expect(workoutFindFirst).not.toHaveBeenCalled();
expect(lastUserPrompt()).not.toContain("thisWorkout");
});
it("pins the workout's own numbers as a snapshot section", async () => {
withPriorTurns(0);
await postAndDrain({ message: "Why was that hard?", workoutId: "w1" });
+31 -17
View File
@@ -37,7 +37,7 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { isModuleEnabled, requireModuleEnabled } from "@/lib/modules/gate";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { getServerTranslator } from "@/lib/i18n/server-translator";
@@ -547,18 +547,13 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
priorTurns.length >= TURN_CAP && priorTurns.length <= TURN_CAP + 1;
const includeFullSnapshot = isFirstTurn || historyElisionCrossing;
// v1.31.0 — a conversation launched from one workout pins ONE additional,
// bounded evidence section onto its snapshot.
//
// SNAPSHOT-ONCE. The read is gated on `isFirstTurn`, so it happens exactly
// once per conversation no matter how long the thread grows. The block then
// rides the same `includeFullSnapshot` prefix every other section does — the
// model composes its first reply against it, and that reply stays in the
// transcript on every later turn, so the figures remain in context without
// being re-paid for. Per-turn work is therefore CONSTANT in conversation
// length, which is the whole point of the ~99 % token cut.
// A workout launch is an optional narrowing of Coach, not permission to
// bypass the workouts module. Disabled modules contribute no read and no
// provider payload; the generic conversation still proceeds.
const workoutsEnabled =
!workoutId || (await isModuleEnabled(userId, "workouts"));
const workoutEvidence =
isFirstTurn && workoutId
isFirstTurn && workoutId && workoutsEnabled
? await buildWorkoutEvidenceSection(userId, workoutId)
: null;
if (workoutId) {
@@ -769,15 +764,22 @@ Reply now as the assistant, in ${LANGUAGE_NAMES[locale]}.`;
// equivalent of that narrowing). Empty string on a generic open.
const focusHint = renderFocusHint(effectiveScope?.sources);
const focusBlock = focusHint ? `${focusHint}\n\n` : "";
const workoutDataBlock =
workoutEvidence === null
? ""
: `SELECTED WORKOUT DATA
${fenceHealthData(JSON.stringify({ thisWorkout: workoutEvidence }))}
`;
const messages: AiMessage[] = [
{
role: "user",
content: `${focusBlock}${renderDataInventory(inventory)}${guidedBlock}
content: `${focusBlock}${workoutDataBlock}${renderDataInventory(inventory)}${guidedBlock}
CONVERSATION
${transcript}
Reply now as the assistant, in ${LANGUAGE_NAMES[locale]}. Fetch any figures you cite with the tools first.`,
Reply now as the assistant, in ${LANGUAGE_NAMES[locale]}. The selected-workout block is already authoritative; fetch any other figures you cite with the tools first.`,
},
];
const loop = await runCoachToolLoop({
@@ -803,7 +805,10 @@ Reply now as the assistant, in ${LANGUAGE_NAMES[locale]}. Fetch any figures you
result = loop.result;
workingProviderType = loop.workingProviderType;
toolTrace = loop.toolTrace;
toolResultPayloads = (loop.toolResults ?? []).map((r) => r.data);
toolResultPayloads = [
...(workoutEvidence === null ? [] : [workoutEvidence]),
...(loop.toolResults ?? []).map((r) => r.data),
];
totalTokensSpent = loop.totalTokens;
cachedTokensSpent = loop.cachedTokens;
} else {
@@ -852,7 +857,10 @@ Reply now as the assistant, in ${LANGUAGE_NAMES[locale]}. Fetch any figures you
// grade. When figures WERE delivered, the authoritative set is the
// structured snapshot record (incl. the correlations block).
if (includeFullSnapshot) {
noToolsSnapshotPayloads = [snapshot.sections];
noToolsSnapshotPayloads = [
snapshot.sections,
...(workoutEvidence === null ? [] : [workoutEvidence]),
];
}
}
} catch (err) {
@@ -1656,7 +1664,11 @@ async function buildWorkoutEvidenceSection(
const profile = await prisma.user.findUnique({
where: { id: userId },
select: { sourcePriorityJson: true, dateOfBirth: true },
select: {
sourcePriorityJson: true,
dateOfBirth: true,
timezone: true,
},
});
// The SAME series the detail route serves as `hrSeries` — one builder,
@@ -1680,12 +1692,14 @@ async function buildWorkoutEvidenceSection(
userId,
row.sportType,
profile?.sourcePriorityJson ?? null,
workoutId,
);
return buildWorkoutEvidence({
sportType: row.sportType,
source: row.source,
startedAt: row.startedAt,
timezone: profile?.timezone ?? "Europe/Berlin",
durationSec: row.durationSec,
totalEnergyKcal: row.totalEnergyKcal,
totalDistanceM: row.totalDistanceM,
@@ -44,13 +44,6 @@ interface ApiEnvelope<T> {
error: string | null;
}
function emptyRequest(): Request {
return new Request("http://localhost/api/integrations/withings/resume", {
method: "POST",
headers: { "content-length": "0" },
});
}
beforeEach(() => {
vi.mocked(resumeIntegrationFromPark).mockReset();
vi.mocked(checkRateLimit).mockResolvedValue({
@@ -0,0 +1,244 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
requireAuth: vi.fn(async () => ({
user: {
id: "user-1",
username: "tester",
role: "USER",
timezone: "Europe/Berlin",
},
})),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency: (fn: unknown) => fn,
}));
vi.mock("@/lib/db", () => {
const measurement = { create: vi.fn() };
return {
prisma: {
measurement,
auditLog: { create: vi.fn() },
$transaction: vi.fn(async (operations: Array<Promise<unknown>>) =>
Promise.all(operations),
),
},
};
});
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/logging/fire-and-forget", () => ({
fireAndForget: (promise: Promise<unknown>) => void promise.catch(() => {}),
}));
vi.mock("@/lib/crypto/note-cipher", () => ({
encryptNote: vi.fn(() => null),
shapeMeasurementNotes: vi.fn((row: unknown) => row),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/arrivals/emit-shared", () => ({
emitDataArrival: vi.fn(async () => {}),
}));
vi.mock("@/lib/arrivals/measurement-emit", () => ({
emitInsertedMeasurementArrivals: vi.fn(async () => {}),
}));
vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
maybeEnqueueMorningRefresh: vi.fn(async () => {}),
}));
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
}));
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
enqueueReminderSatisfy: vi.fn(async () => {}),
}));
vi.mock("@/lib/illness/safety-floor-check", () => ({
runSafetyFloorCheck: vi.fn(async () => {}),
}));
vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeBucketsForMeasurement: vi.fn(async () => {}),
collapseToTypeDayKeys: vi.fn(
(rows: Array<{ type: string; measuredAt: Date }>) => rows,
),
}));
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import { POST } from "../route";
const measuredAt = new Date("2026-07-18T06:30:00.000Z");
function row(id: string, type: string, value: number) {
return {
id,
userId: "user-1",
type,
value,
unit:
type === "WEIGHT" ? "kg" : type === "SLEEP_DURATION" ? "minutes" : "mmHg",
source: "MANUAL",
measuredAt,
notes: null,
notesEncrypted: null,
glucoseContext: null,
deviceType: null,
createdAt: measuredAt,
updatedAt: measuredAt,
};
}
function request(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/measurements", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("POST /api/measurements — exact arrival wiring", () => {
it("passes a fresh single sleep insert to the morning refresh seam", async () => {
const created = row("sleep-1", "SLEEP_DURATION", 450);
vi.mocked(prisma.measurement.create).mockResolvedValue(created as never);
const response = await (
POST as unknown as (request: NextRequest) => Promise<Response>
)(
request({
type: "SLEEP_DURATION",
value: 450,
measuredAt: measuredAt.toISOString(),
}),
);
expect(response.status).toBe(201);
expect(maybeEnqueueMorningRefresh).toHaveBeenCalledWith("user-1", [
measuredAt,
]);
expect(emitInsertedMeasurementArrivals).toHaveBeenCalledWith(
"user-1",
[created],
"manual",
);
});
it("passes only fresh sleep timestamps from an array create to the morning seam", async () => {
const created = [
row("sleep-batch-1", "SLEEP_DURATION", 435),
row("pulse-batch-1", "PULSE", 64),
];
vi.mocked(prisma.measurement.create)
.mockResolvedValueOnce(created[0] as never)
.mockResolvedValueOnce(created[1] as never);
const response = await (
POST as unknown as (request: NextRequest) => Promise<Response>
)(
request(
created.map(({ type, value }) => ({
type,
value,
measuredAt: measuredAt.toISOString(),
})),
),
);
expect(response.status).toBe(201);
expect(emitInsertedMeasurementArrivals).toHaveBeenCalledWith(
"user-1",
created,
"manual",
);
expect(maybeEnqueueMorningRefresh).toHaveBeenCalledWith("user-1", [
measuredAt,
]);
});
it("passes the exact returned weight and BP batch inserts to the arrival helper", async () => {
const created = [
row("weight-1", "WEIGHT", 82),
row("sys-1", "BLOOD_PRESSURE_SYS", 120),
row("dia-1", "BLOOD_PRESSURE_DIA", 78),
];
vi.mocked(prisma.measurement.create)
.mockResolvedValueOnce(created[0] as never)
.mockResolvedValueOnce(created[1] as never)
.mockResolvedValueOnce(created[2] as never);
const response = await (
POST as unknown as (request: NextRequest) => Promise<Response>
)(
request(
created.map(({ type, value }) => ({
type,
value,
measuredAt: measuredAt.toISOString(),
})),
),
);
expect(response.status).toBe(201);
expect(emitInsertedMeasurementArrivals).toHaveBeenCalledWith(
"user-1",
created,
"manual",
);
expect(maybeEnqueueMorningRefresh).not.toHaveBeenCalled();
});
it("does not invoke arrival callbacks for a duplicate single create", async () => {
vi.mocked(prisma.measurement.create).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("duplicate", {
code: "P2002",
clientVersion: "test",
}),
);
const response = await (
POST as unknown as (request: NextRequest) => Promise<Response>
)(
request({
type: "WEIGHT",
value: 82,
measuredAt: measuredAt.toISOString(),
}),
);
expect(response.status).toBe(409);
expect(emitInsertedMeasurementArrivals).not.toHaveBeenCalled();
expect(maybeEnqueueMorningRefresh).not.toHaveBeenCalled();
});
it("keeps the successful write response when both best-effort callbacks reject", async () => {
const created = row("sleep-2", "SLEEP_DURATION", 420);
vi.mocked(prisma.measurement.create).mockResolvedValue(created as never);
vi.mocked(emitInsertedMeasurementArrivals).mockRejectedValueOnce(
new Error("arrival unavailable"),
);
vi.mocked(maybeEnqueueMorningRefresh).mockRejectedValueOnce(
new Error("morning unavailable"),
);
const response = await (
POST as unknown as (request: NextRequest) => Promise<Response>
)(
request({
type: "SLEEP_DURATION",
value: 420,
measuredAt: measuredAt.toISOString(),
}),
);
expect(response.status).toBe(201);
});
});
@@ -19,7 +19,7 @@ vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findMany: vi.fn(),
createMany: vi.fn(),
createManyAndReturn: vi.fn(),
updateMany: vi.fn(),
},
$transaction: vi.fn(async (fn: unknown) => {
@@ -129,13 +129,26 @@ beforeEach(() => {
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 0 });
vi.mocked(prisma.measurement.createManyAndReturn).mockImplementation((async (
args: unknown,
) => {
const { data } = args as {
data:
| { type: unknown; source: string; externalId: string | null }
| Array<{ type: unknown; source: string; externalId: string | null }>;
};
const rows = Array.isArray(data) ? data : [data];
return rows.map(({ type, source, externalId }) => ({
type,
source,
externalId,
}));
}) as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 1 });
});
describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)", () => {
it("inserts a fresh 10-min 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)] }),
);
@@ -146,7 +159,8 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
expect(data.entries[0].status).toBe("inserted");
// Stored as a PULSE row carrying the 10-min average value.
const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0];
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0][0];
const rows = (createArg as { data: { type: string; value: number }[] })
.data;
expect(rows[0].type).toBe("PULSE");
@@ -154,7 +168,6 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
});
it("persists per-bucket min/max on a fresh 10-min HR bucket (iOS #34 ext)", async () => {
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({
entries: [
@@ -163,7 +176,8 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
}),
);
expect(res.status).toBe(200);
const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0];
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0][0];
const rows = (
createArg as {
data: { value: number; valueMin: number; valueMax: number }[];
@@ -184,12 +198,11 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
])(
"v1.30.8 — drops an out-of-range or mis-ordered spread to null but keeps the average %o",
async (spread) => {
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72, spread)] }),
);
expect(res.status).toBe(200);
const createArg = vi.mocked(prisma.measurement.createMany).mock
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0][0];
const rows = (
createArg as {
@@ -231,7 +244,6 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
});
it("leaves min/max null on a per-sample PULSE row even if sent", async () => {
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
const SAMPLE_ID = "C0FFEE00-0000-4000-8000-000000000000";
const res = await POST(
makeRequest({
@@ -239,7 +251,8 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
}),
);
expect(res.status).toBe(200);
const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0];
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0][0];
const rows = (
createArg as {
data: { valueMin: number | null; valueMax: number | null }[];
@@ -273,7 +286,7 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
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();
expect(prisma.measurement.createManyAndReturn).not.toHaveBeenCalled();
});
it("skips a malformed aggregated HR bucket externalId", async () => {
@@ -293,7 +306,7 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
expect(data.skipped).toEqual([
{ index: 0, reason: "malformed_hr_bucket_id" },
]);
expect(prisma.measurement.createMany).not.toHaveBeenCalled();
expect(prisma.measurement.createManyAndReturn).not.toHaveBeenCalled();
});
it("keeps the per-sample uuid HR path immutable (duplicate, not overwrite)", async () => {
@@ -406,7 +419,7 @@ describe("POST /api/measurements/batch — stats tombstone resurrection", () =>
expect(data.updated).toBe(0);
expect(data.entries[0].status).toBe("duplicate");
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
expect(prisma.measurement.createMany).not.toHaveBeenCalled();
expect(prisma.measurement.createManyAndReturn).not.toHaveBeenCalled();
});
});
@@ -421,8 +434,6 @@ describe("POST /api/measurements/batch — stats tombstone resurrection", () =>
*/
describe("POST /api/measurements/batch — intra-batch `stats:` supersession", () => {
it("keeps only the LAST entry when one batch carries the same stats: key twice", async () => {
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({
entries: [
@@ -435,7 +446,8 @@ describe("POST /api/measurements/batch — intra-batch `stats:` supersession", (
const { data } = await readJson(res);
// Exactly ONE row reaches the insert, carrying the NEWER value.
const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0];
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0][0];
const rows = (createArg as { data: { value: number }[] }).data;
expect(rows).toHaveLength(1);
expect(rows[0].value).toBe(78);
@@ -479,7 +491,6 @@ describe("POST /api/measurements/batch — intra-batch `stats:` supersession", (
it("leaves DISTINCT stats: keys in one batch untouched", async () => {
const OTHER =
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:10:00.000Z";
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 2 });
const res = await POST(
makeRequest({
@@ -489,7 +500,8 @@ describe("POST /api/measurements/batch — intra-batch `stats:` supersession", (
expect(res.status).toBe(200);
const { data } = await readJson(res);
const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0];
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0][0];
expect((createArg as { data: unknown[] }).data).toHaveLength(2);
expect(data.inserted).toBe(2);
expect(data.duplicates).toBe(0);
@@ -0,0 +1,211 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findMany: vi.fn(),
createManyAndReturn: vi.fn(),
updateMany: vi.fn(),
},
$transaction: vi.fn(async (fn: unknown) => {
if (typeof fn === "function") {
return (fn as (tx: unknown) => unknown)(prisma);
}
}),
},
}));
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("@/lib/daily/morning-refresh-trigger", () => ({
maybeEnqueueMorningRefresh: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/arrivals/emit-shared", () => ({
emitDataArrival: vi.fn().mockResolvedValue(undefined),
}));
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";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
const session = {
session: { id: "session-1", expiresAt: new Date("2026-08-01T00:00:00Z") },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function entry(
hkIdentifier: string,
value: number,
unit: string,
endDate: string,
externalId: string,
sleepStage?: number,
) {
return {
hkIdentifier,
value,
unit,
startDate: new Date(Date.parse(endDate) - 60_000).toISOString(),
endDate,
externalId,
...(sleepStage === undefined ? {} : { sleepStage }),
};
}
function request(entries: unknown[]): NextRequest {
return new NextRequest("http://localhost/api/measurements/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ entries }),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(session as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 0 });
});
describe("POST /api/measurements/batch — exact INSERT RETURNING identity", () => {
it("emits and refreshes only exact inserted rows when mixed kinds lose insert races", async () => {
const weightAt = "2026-07-19T07:00:00.000Z";
const insertedSleepAt = "2026-07-19T06:30:00.000Z";
const racedSleepAt = "2026-07-19T06:45:00.000Z";
const racedBpAt = "2026-07-19T07:15:00.000Z";
const entries = [
entry(
"HKQuantityTypeIdentifierBodyMass",
80,
"kg",
weightAt,
"weight-inserted",
),
entry(
"HKCategoryTypeIdentifierSleepAnalysis",
90,
"min",
insertedSleepAt,
"sleep-inserted",
3,
),
entry(
"HKCategoryTypeIdentifierSleepAnalysis",
45,
"min",
racedSleepAt,
"sleep-raced",
3,
),
entry(
"HKQuantityTypeIdentifierBloodPressureSystolic",
120,
"mmHg",
racedBpAt,
"bp-raced",
),
];
vi.mocked(prisma.measurement.createManyAndReturn).mockImplementation(
(async (args: unknown) => {
const { data } = args as {
data:
| { type: unknown; source: string; externalId: string | null }
| Array<{
type: unknown;
source: string;
externalId: string | null;
}>;
};
const rows = Array.isArray(data) ? data : [data];
return rows
.filter(
(row) =>
typeof row.externalId === "string" &&
["weight-inserted", "sleep-inserted"].includes(row.externalId),
)
.reverse()
.map(({ type, source, externalId }) => ({
type,
source,
externalId,
}));
}) as never,
);
const response = await POST(request(entries));
expect(response.status).toBe(200);
const body = (await response.json()) as {
data: {
inserted: number;
duplicates: number;
entries: Array<{ index: number; status: string }>;
};
};
expect(body.data.inserted).toBe(2);
expect(body.data.duplicates).toBe(2);
expect(body.data.entries.map(({ status }) => status)).toEqual([
"inserted",
"inserted",
"duplicate",
"duplicate",
]);
expect(prisma.measurement.createManyAndReturn).toHaveBeenCalledWith(
expect.objectContaining({
skipDuplicates: true,
select: { type: true, source: true, externalId: true },
}),
);
expect(maybeEnqueueMorningRefresh).toHaveBeenCalledWith("user-1", [
new Date(insertedSleepAt),
]);
expect(emitDataArrival).toHaveBeenCalledTimes(1);
expect(emitDataArrival).toHaveBeenCalledWith({
userId: "user-1",
kind: "weight",
newestSampleAt: new Date(weightAt),
insertedCount: 1,
source: "batch",
});
});
});
@@ -12,7 +12,7 @@ vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findMany: vi.fn(),
createMany: vi.fn(),
createManyAndReturn: vi.fn(),
},
$transaction: vi.fn(async (fn: unknown) => {
if (typeof fn === "function") {
@@ -83,7 +83,7 @@ function validStepEntry(externalId: string) {
}
beforeEach(() => {
vi.resetAllMocks();
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
@@ -91,12 +91,25 @@ beforeEach(() => {
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 0 });
vi.mocked(prisma.measurement.createManyAndReturn).mockImplementation((async (
args: unknown,
) => {
const { data } = args as {
data:
| { type: unknown; source: string; externalId: string | null }
| Array<{ type: unknown; source: string; externalId: string | null }>;
};
const rows = Array.isArray(data) ? data : [data];
return rows.map(({ type, source, externalId }) => ({
type,
source,
externalId,
}));
}) as never);
});
describe("POST /api/measurements/batch — PR detection enqueue (v1.4.25 W16c)", () => {
it("enqueues with silent=false for a small healthy batch", async () => {
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
const res = await POST(makeRequest({ entries: [validStepEntry("ext-a")] }));
expect(res.status).toBe(200);
expect(enqueuePrDetection).toHaveBeenCalledTimes(1);
@@ -109,7 +122,6 @@ describe("POST /api/measurements/batch — PR detection enqueue (v1.4.25 W16c)",
const big = Array.from({ length: 51 }, (_, i) =>
validStepEntry(`ext-${i}`),
);
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 51 });
const res = await POST(makeRequest({ entries: big }));
expect(res.status).toBe(200);
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
+61 -104
View File
@@ -179,39 +179,6 @@ type BatchEntry = z.infer<typeof batchEntrySchema>;
*/
type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped";
/**
* v1.31.0 — the rows this write actually INSERTED, in the shape the arrival
* kind grouper takes.
*
* Restricting to `status === "inserted"` is what keeps a re-sync silent: an
* `updated` row is the `stats:` overwrite contract re-stating a value the
* record already had, and a `duplicate` row landed nothing. The per-entry
* statuses are reconciled against the insert race upstream, so the count is
* accurate; in a rare concurrent-write tie it can be off on WHICH row, which
* cannot change the outcome because the day-scoped singleton key collapses the
* result either way.
*/
function insertedArrivalRows(
prepared: ReadonlyArray<{
index: number;
row: Prisma.MeasurementCreateManyInput;
}>,
results: ReadonlyArray<{ status: EntryStatus } | undefined>,
): Array<{ type: MeasurementType; measuredAt: Date }> {
const rows: Array<{ type: MeasurementType; measuredAt: Date }> = [];
for (const p of prepared) {
if (results[p.index]?.status !== "inserted") continue;
rows.push({
type: p.row.type as MeasurementType,
measuredAt:
p.row.measuredAt instanceof Date
? p.row.measuredAt
: new Date(p.row.measuredAt as string),
});
}
return rows;
}
// v1.5.0 issue #213 — `stats:*` externalIds carry per-day cumulative
// totals that the iOS HealthKit observer re-posts as the day progresses.
// Treating those as immutable duplicates freezes the day's tile at the
@@ -410,12 +377,15 @@ async function postBatch(request: NextRequest): Promise<Response> {
// composite-key duplicate). Surfaced as a dedicated wide-event count so
// an operator can see how often the standalone-pair mirror collapses.
let crossSourceMergedCount = 0;
// Exact prepared rows returned by INSERT ... RETURNING. This is the only
// source for post-write arrival and sleep side effects: preflight checks can
// lose a unique-key race after they run.
const insertedPrepared: Prepared[] = [];
if (prepared.length > 0) {
// Pre-flight duplicate detection so we can return per-entry status
// (createMany with skipDuplicates does the dedup but doesn't tell
// us *which* rows it skipped). We look up existing rows under the
// composite unique key first, then createMany the survivors.
// Pre-flight duplicate detection classifies rows already present before
// this request. The later INSERT ... RETURNING remains authoritative when
// a competing request wins the unique-key race after this probe.
// v1.8.6 W6 — `source` is now part of the dedup key, so the lookup
// matches on (type, source, externalId) per incoming row rather than
// a single hardcoded `APPLE_HEALTH`. A `MANUAL` row and an
@@ -538,7 +508,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
);
}
const toInsert: Prisma.MeasurementCreateManyInput[] = [];
const toInsert: Prepared[] = [];
const insertKeys = new Set<string>();
const toOverwrite: Prepared[] = [];
for (const p of prepared) {
const key = `${p.row.type}::${p.row.source}::${p.row.externalId}`;
@@ -554,7 +525,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
duplicateCount += 1;
continue;
}
if (existingSet.has(key)) {
if (existingSet.has(key) || insertKeys.has(key)) {
// v1.5.0 issue #213 — per-day cumulative `stats:*` rows are
// intentionally overwritten on a re-post so today's tile reflects
// the latest HealthKit total instead of freezing at the
@@ -584,7 +555,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
crossSourceMergedCount += 1;
} else {
results[p.index] = { index: p.index, status: "inserted" };
toInsert.push(p.row);
toInsert.push(p);
insertKeys.add(key);
// Track this row so a same-reading sibling later in the SAME
// batch collapses against it.
if (isMergeableSource(p.row.source as string)) {
@@ -643,64 +615,58 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
if (toInsert.length > 0) {
// Chunk to keep individual SQL statements bounded — 200 rows per
// chunk leaves headroom under Postgres' 65k-parameter cap even
// with 9-column inserts. `skipDuplicates: true` guards against
// races where two batch retries land in the same tick — the
// composite unique index does the actual dedup; skipDuplicates
// turns the duplicate-PG error into a no-op insert.
// PostgreSQL RETURNING identifies the exact winner when another request
// races the same composite unique key. An aggregate insert count can say
// how many rows landed but not which prepared rows won, which would make
// arrival and morning-refresh side effects attach to arbitrary rows.
const CHUNK = 200;
const chunks: Prisma.MeasurementCreateManyInput[][] = [];
for (let i = 0; i < toInsert.length; i += CHUNK) {
chunks.push(toInsert.slice(i, i + CHUNK));
}
const insertedIndexes = new Set<number>();
await prisma.$transaction(async (tx) => {
for (const chunk of chunks) {
const result = await tx.measurement.createMany({
data: chunk,
for (let i = 0; i < toInsert.length; i += CHUNK) {
const chunk = toInsert.slice(i, i + CHUNK);
const pendingByKey = new Map<string, Prepared>();
for (const p of chunk) {
pendingByKey.set(
`${p.row.type}::${p.row.source}::${p.row.externalId}`,
p,
);
}
const inserted = await tx.measurement.createManyAndReturn({
data: chunk.map((p) => p.row),
skipDuplicates: true,
select: { type: true, source: true, externalId: true },
});
insertedCount += result.count;
// Count only returned identities that map to an attempted row, so
// aggregate counts and per-entry statuses share one source of truth.
for (const row of inserted) {
const preparedRow = pendingByKey.get(
`${row.type}::${row.source}::${row.externalId}`,
);
if (!preparedRow) continue;
insertedIndexes.add(preparedRow.index);
insertedCount += 1;
insertedPrepared.push(preparedRow);
}
}
});
// v1.4.25 W10 reconcile (senior-dev H-1): the previous "stored
// vs not-stored" check was an effective no-op. Under standard
// Postgres semantics, `skipDuplicates` absorbs duplicate-key
// conflicts but the row is STILL present in the table (written
// by the other batch that won the race). So every row we
// attempted is in the DB after the call, and the
// `!stored.has(...)` branch never fired — leaving the aggregate
// `inserted` / `duplicate` counts inconsistent with the per-
// entry statuses under contention.
//
// Pragmatic fix: trust the `createMany` return count. The DB
// round-trip cannot distinguish a row this request wrote from
// a row the racing request wrote (the unique index sees both
// as the same key), so we cannot identify the SPECIFIC raced
// rows. What we CAN do is preserve count integrity for the
// iOS sync cursor: `insertedCount` is already the truth (it
// came from `createMany.count`); we only need to downgrade
// enough per-entry "inserted" statuses to "duplicate" so the
// per-entry envelope sums match. Order doesn't matter — the
// client checkpoints past both statuses, and the DB state for
// either outcome is identical (the row is now stored,
// single-copy).
const racedDuplicates = toInsert.length - insertedCount;
if (racedDuplicates > 0) {
let downgraded = 0;
for (const p of prepared) {
if (downgraded >= racedDuplicates) break;
if (results[p.index]?.status === "inserted") {
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
downgraded += 1;
}
}
for (const p of toInsert) {
if (insertedIndexes.has(p.index)) continue;
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
}
}
}
const freshlyInsertedRows = insertedPrepared.map((p) => ({
type: p.row.type as MeasurementType,
measuredAt:
p.row.measuredAt instanceof Date
? p.row.measuredAt
: new Date(p.row.measuredAt as string),
}));
const skipped = results
.filter((r) => r.status === "skipped")
.map((r) => ({ index: r.index, reason: r.reason ?? "unknown" }));
@@ -813,13 +779,10 @@ async function postBatch(request: NextRequest): Promise<Response> {
invalidateUserMeasurements(user.id, { evict: true });
// v1.5.0 — refresh the persistent rollup table for every distinct
// (type, day) the batch touched. We use the `prepared` rows
// because `createMany` does not return the inserted rows; the
// (type, measuredAt) tuples on `prepared` are exactly the row
// shape we need. Collapsed by day so a 500-row Apple Health
// batch fires N recomputes (typically <30, one per type-day
// pair) instead of one per row. Best-effort — a populator hiccup
// never fails the user's ingest.
// (type, day) the batch touched. Updates must participate as well as
// inserts, so this deliberately retains the prepared-row scope. Collapsed
// by day so a 500-row Apple Health batch fires only one recompute per
// type/day pair. Best-effort — a populator hiccup never fails ingest.
try {
const insertedKeys = collapseToTypeDayKeys(
prepared.map((p) => ({
@@ -855,13 +818,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
// months of nights never re-triggers for an old night. Fire-and-forget.
void maybeEnqueueMorningRefresh(
user.id,
prepared
.filter((p) => p.row.type === "SLEEP_DURATION")
.map((p) =>
p.row.measuredAt instanceof Date
? p.row.measuredAt
: new Date(p.row.measuredAt as string),
),
freshlyInsertedRows
.filter((row) => row.type === "SLEEP_DURATION")
.map((row) => row.measuredAt),
).catch(() => {});
// v1.31.0 — the measurement arm of the data-arrival spine.
@@ -881,9 +840,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
// `stats:` overwrite contract re-stating a value the record already had,
// which is not news. If nothing was inserted the classifier returns `noop`
// and nothing is enqueued.
for (const [kind, group] of groupRowsByArrivalKind(
insertedArrivalRows(prepared, results),
)) {
for (const [kind, group] of groupRowsByArrivalKind(freshlyInsertedRows)) {
void emitDataArrival({
userId: user.id,
kind,
+25 -32
View File
@@ -32,11 +32,8 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import {
ARRIVAL_MEASUREMENT_KIND,
groupRowsByArrivalKind,
} from "@/lib/arrivals/measurement-kind";
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { runSafetyFloorCheck } from "@/lib/illness/safety-floor-check";
@@ -851,19 +848,20 @@ async function postMeasurement(request: NextRequest) {
action: "reminder.satisfy.enqueue",
});
// v1.31.0 — the interactive arm of the data-arrival spine. This route is
// the combined BP + pulse form, so it lands both BP arms in one
// transaction; they are one reading and produce one arrival, which the
// kind map already collapses. Every row here is a real insert (the
// transaction throws rather than de-duplicating), so the count is exact.
for (const [kind, group] of groupRowsByArrivalKind(results)) {
void emitDataArrival({
userId: user.id,
kind,
newestSampleAt: group.newestAt,
insertedCount: group.count,
source: "manual",
}).catch(() => {});
// v1.31.0 — feed the shared arrival seams only the rows returned by the
// create-only transaction. Both callbacks are best-effort: arrival or
// morning-refresh infrastructure can never turn a committed write into an
// error response.
void emitInsertedMeasurementArrivals(user.id, results, "manual").catch(
() => {},
);
const sleepMeasuredAts = results
.filter((row) => row.type === "SLEEP_DURATION")
.map((row) => row.measuredAt);
if (sleepMeasuredAts.length > 0) {
void maybeEnqueueMorningRefresh(user.id, sleepMeasuredAts).catch(
() => {},
);
}
// v1.18.6 — absolute clinical safety-floor check on the just-written
@@ -1018,20 +1016,15 @@ async function postMeasurement(request: NextRequest) {
action: "reminder.satisfy.enqueue",
});
// v1.31.0 — the single-entry arm of the data-arrival spine. One row, so at
// most one kind; a type the spine does not track maps to null and emits
// nothing.
{
const kind = ARRIVAL_MEASUREMENT_KIND[measurement.type];
if (kind) {
void emitDataArrival({
userId: user.id,
kind,
newestSampleAt: measurement.measuredAt,
insertedCount: 1,
source: "manual",
}).catch(() => {});
}
// v1.31.0 — `measurement` is the exact successful create result. A P2002
// duplicate returns above and never reaches either best-effort callback.
void emitInsertedMeasurementArrivals(user.id, [measurement], "manual").catch(
() => {},
);
if (measurement.type === "SLEEP_DURATION") {
void maybeEnqueueMorningRefresh(user.id, [measurement.measuredAt]).catch(
() => {},
);
}
// v1.18.6 — absolute clinical safety-floor check (confirm-gated,
+9 -5
View File
@@ -29,7 +29,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { isModuleEnabled, requireModuleEnabled } from "@/lib/modules/gate";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series";
@@ -58,6 +58,7 @@ export const GET = apiHandler(
// v1.18.0 B1 — gate the detail surface behind the workouts module.
const gate = await requireModuleEnabled(user.id, "workouts");
if (!gate.enabled) return gate.response;
const insightsEnabled = await isModuleEnabled(user.id, "insights");
const row = await prisma.workout.findUnique({
where: { id },
@@ -76,9 +77,11 @@ export const GET = apiHandler(
sampleCount: true,
},
},
insight: {
select: { paragraphEncrypted: true, generatedAt: true },
},
insight: insightsEnabled
? {
select: { paragraphEncrypted: true, generatedAt: true },
}
: false,
},
});
@@ -183,6 +186,7 @@ export const GET = apiHandler(
user.id,
row.sportType,
userRow?.sourcePriorityJson ?? null,
row.id,
);
const route = row.route
@@ -215,7 +219,7 @@ export const GET = apiHandler(
// undecryptable paragraph degrades to no card, exactly like a workout that
// never had one.
let aiInsight: { paragraph: string; generatedAt: string } | null = null;
if (row.insight) {
if (insightsEnabled && row.insight) {
try {
aiInsight = {
paragraph: decryptFromBytes(row.insight.paragraphEncrypted),
@@ -17,6 +17,7 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn(),
createMany: vi.fn(),
findFirst: vi.fn(),
createManyAndReturn: vi.fn(),
},
workoutRoute: {
createMany: vi.fn(),
@@ -59,6 +60,10 @@ vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/arrivals/emit-shared", () => ({
emitDataArrival: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -73,6 +78,7 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -127,6 +133,32 @@ beforeEach(() => {
});
vi.mocked(prisma.workout.findMany).mockResolvedValue([]);
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 0 });
vi.mocked(prisma.workout.createManyAndReturn).mockImplementation(
(async (args: {
data: Record<string, unknown> | Array<Record<string, unknown>>;
skipDuplicates?: boolean;
}) => {
const data = Array.isArray(args.data) ? args.data : [args.data];
const result = await prisma.workout.createMany({
data: args.data,
skipDuplicates: args.skipDuplicates,
} as never);
const lookedUp = await prisma.workout.findMany({
select: { id: true, source: true, externalId: true },
} as never);
const returned = lookedUp.filter(
(row): row is typeof row & { id: string } =>
typeof (row as { id?: unknown }).id === "string",
);
if (returned.length >= result.count) {
return returned.slice(0, result.count);
}
return data.slice(0, result.count).map((row, index) => ({
...row,
id: `inserted-${index}`,
}));
}) as never,
);
vi.mocked(prisma.workout.findFirst).mockResolvedValue(null);
vi.mocked(prisma.workoutRoute.createMany).mockResolvedValue({ count: 0 });
vi.mocked(prisma.workoutSamples.createMany).mockResolvedValue({ count: 0 });
@@ -346,6 +378,101 @@ describe("POST /api/workouts/batch — per-entry status envelope", () => {
expect(body.data.inserted).toBe(1);
expect(body.data.duplicates).toBe(1);
});
it("marks the exact row returned by the insert as the race winner", async () => {
vi.mocked(prisma.workout.findMany).mockResolvedValue([]);
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
vi.mocked(prisma.workout.createManyAndReturn).mockResolvedValue([
{
id: "winner-1",
source: "APPLE_HEALTH",
externalId: "uuid-race-1",
startedAt: new Date("2026-05-14T06:30:00.000Z"),
sportType: "running",
createdAt: new Date("2026-05-14T10:00:00.000Z"),
},
] as never);
const res = await POST(
makeRequest({
workouts: [
validWorkout("uuid-race-1"),
validWorkout("uuid-race-2", {
startedAt: "2026-05-14T08:30:00.000Z",
endedAt: "2026-05-14T09:15:00.000Z",
}),
],
}),
);
const body = (await res.json()) as {
data: { entries: Array<{ index: number; status: string }> };
};
expect(body.data.entries).toEqual([
{ index: 0, status: "inserted" },
{ index: 1, status: "duplicate" },
]);
});
it("keeps same external ids from different sources mapped to their own workout", async () => {
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 2 });
vi.mocked(prisma.workout.createManyAndReturn).mockResolvedValue([
{
id: "apple-id",
source: "APPLE_HEALTH",
externalId: "shared-external-id",
startedAt: new Date("2026-05-14T06:30:00.000Z"),
sportType: "running",
createdAt: new Date("2026-05-14T10:00:00.000Z"),
},
{
id: "manual-id",
source: "MANUAL",
externalId: "shared-external-id",
startedAt: new Date("2026-05-14T08:30:00.000Z"),
sportType: "strength",
createdAt: new Date("2026-05-14T10:00:01.000Z"),
},
] as never);
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
select?: Record<string, unknown>;
}) => {
if (args.select?.id) {
return [
{
id: "apple-id",
source: "APPLE_HEALTH",
externalId: "shared-external-id",
},
{
id: "manual-id",
source: "MANUAL",
externalId: "shared-external-id",
},
];
}
return [];
}) as never);
const res = await POST(
makeRequest({
workouts: [
validWorkout("shared-external-id"),
validWorkout("shared-external-id", {
source: "MANUAL",
sportType: "strength",
startedAt: "2026-05-14T08:30:00.000Z",
endedAt: "2026-05-14T09:15:00.000Z",
}),
],
}),
);
expect(res.status).toBe(200);
await vi.waitFor(() => expect(emitDataArrival).toHaveBeenCalledTimes(2));
expect(
vi.mocked(emitDataArrival).mock.calls.map(([call]) => call.refId),
).toEqual(["apple-id", "manual-id"]);
});
});
describe("POST /api/workouts/batch — nested route attachment", () => {
+22 -1
View File
@@ -57,13 +57,14 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => {
...actual,
resolveModuleMap: vi.fn(async () => ({})),
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
isModuleEnabled: vi.fn(async () => true),
};
});
import { GET } from "../[id]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { isModuleEnabled, requireModuleEnabled } from "@/lib/modules/gate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -123,6 +124,7 @@ describe("GET /api/workouts/{id}", () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: true,
} as never);
vi.mocked(isModuleEnabled).mockResolvedValue(true);
});
it("returns the workout with iOS-contract field names", async () => {
@@ -399,6 +401,25 @@ describe("GET /api/workouts/{id} — aiInsight", () => {
});
});
it("does not read or expose the stored paragraph when Insights is disabled", async () => {
vi.mocked(isModuleEnabled).mockResolvedValueOnce(false);
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...BASE_ROW,
insight: {
paragraphEncrypted: new Uint8Array([1, 2, 3]),
generatedAt: new Date("2026-05-15T07:35:00Z"),
},
} as never);
const body = await (await get()).json();
expect(body.data.aiInsight).toBeNull();
expect(prisma.workout.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
include: expect.objectContaining({ insight: false }),
}),
);
});
it("degrades to no card rather than failing the page on an undecryptable row", async () => {
// `decrypt` is fail-closed by design. A key rotated away must not turn the
// whole workout-detail page into a 500 over a garnish field.
+93 -267
View File
@@ -39,13 +39,10 @@
* composite index. Re-posting the same batch surfaces duplicates
* rather than failing the call.
*
* Race reconciliation (mirrors the measurements batch fix from
* v1.4.25 W10): under contention, `createMany` with `skipDuplicates`
* absorbs duplicate-key conflicts but cannot tell us WHICH rows it
* absorbed. We trust the `createMany.count` return value and downgrade
* enough per-entry "inserted" statuses to "duplicate" so the envelope
* sums stay consistent with the aggregate counts. The iOS sync cursor
* advances past both statuses identically.
* Race reconciliation uses PostgreSQL's `INSERT ... RETURNING` through
* Prisma's `createManyAndReturn`. The returned composite keys identify the
* exact rows this request inserted, so per-entry statuses, child rows, and
* arrival references cannot drift to a racing or same-external-id workout.
*
* Prompt-injection surface: `Workout.metadata` is opaque — the Coach
* pipeline reads typed columns only (`sportType`, `durationSec`,
@@ -120,13 +117,11 @@ interface EntryResult {
}
/**
* v1.31.0 — emit one data arrival per workout this batch actually inserted.
* v1.31.1 — emit one data arrival per workout this batch actually inserted.
*
* `createMany` does not return ids, so they are recovered with a single indexed
* lookup on the `(userId, source, externalId)` dedup key. Entries without an
* external id cannot be looked up and are emitted without a `refId`; the spine
* then falls back to a day-scoped key for them, which is an honest degradation
* (no external id means no stable per-workout referent to react to anyway).
* `createManyAndReturn` gives the transaction the exact inserted ids. The
* index-keyed map avoids a second lookup and keeps equal external ids from
* different sources attached to their own workout.
*
* Fully best-effort — every failure path returns quietly. A reaction is never
* worth failing an ingest that already succeeded.
@@ -136,43 +131,23 @@ async function emitWorkoutArrivals(
prepared: ReadonlyArray<{
index: number;
row: Prisma.WorkoutCreateManyInput;
dedupKey: { source: string; externalId: string } | null;
}>,
results: ReadonlyArray<EntryResult | undefined>,
insertedIdByIndex: ReadonlyMap<number, string>,
): Promise<void> {
const inserted = prepared.filter(
(p) => results[p.index]?.status === "inserted",
);
if (inserted.length === 0) return;
const externalIds = inserted
.map((p) => p.dedupKey?.externalId)
.filter((id): id is string => typeof id === "string");
const idByExternalId = new Map<string, string>();
if (externalIds.length > 0) {
const rows = await prisma.workout.findMany({
where: { userId, externalId: { in: externalIds } },
select: { id: true, externalId: true },
});
for (const row of rows) {
if (row.externalId) idByExternalId.set(row.externalId, row.id);
}
}
for (const p of inserted) {
for (const p of prepared) {
const refId = insertedIdByIndex.get(p.index);
if (!refId) continue;
const startedAt =
p.row.startedAt instanceof Date
? p.row.startedAt
: new Date(p.row.startedAt as string);
if (Number.isNaN(startedAt.getTime())) continue;
const externalId = p.dedupKey?.externalId;
await emitDataArrival({
userId,
kind: "workout",
newestSampleAt: startedAt,
insertedCount: 1,
refId: externalId ? idByExternalId.get(externalId) : undefined,
refId,
source: "batch",
});
}
@@ -400,6 +375,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
const survivors = prepared.filter((p) => survivingIndices.has(p.index));
let duplicateCount = droppedByWriteDedup.length;
let insertedCount = 0;
const insertedIdByIndex = new Map<number, string>();
if (survivors.length > 0) {
// v1.4.42 W5 — only the write-time dedup survivors are probed
@@ -441,236 +417,94 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
if (toInsert.length > 0) {
// Two-step write: createMany the workout rows, then findMany the
// freshly-inserted rows to recover their ids so the route table
// can be populated by FK. We chunk both steps for parity with the
// measurements path and to keep individual SQL statements bounded
// under Postgres's 65k-parameter cap.
const CHUNK = 100;
await prisma.$transaction(async (tx) => {
for (let i = 0; i < toInsert.length; i += CHUNK) {
const slice = toInsert.slice(i, i + CHUNK);
const result = await tx.workout.createMany({
const withExternalId = toInsert.filter((p) => p.dedupKey !== null);
const withoutExternalId = toInsert.filter((p) => p.dedupKey === null);
// PostgreSQL RETURNING is the only reliable way to identify the exact
// winner when another request races the same unique key.
for (let i = 0; i < withExternalId.length; i += CHUNK) {
const slice = withExternalId.slice(i, i + CHUNK);
const pendingByKey = new Map<string, Prepared[]>();
for (const p of slice) {
const key = `${p.dedupKey!.source}::${p.dedupKey!.externalId}`;
const pending = pendingByKey.get(key);
if (pending) pending.push(p);
else pendingByKey.set(key, [p]);
}
const inserted = await tx.workout.createManyAndReturn({
data: slice.map((p) => p.row),
skipDuplicates: true,
select: { id: true, source: true, externalId: true },
});
insertedCount += result.count;
for (const row of inserted) {
if (row.externalId === null) continue;
const key = `${row.source}::${row.externalId}`;
const preparedRow = pendingByKey.get(key)?.shift();
if (preparedRow) insertedIdByIndex.set(preparedRow.index, row.id);
}
}
// Look up the workouts we just wrote (or that the racing batch
// wrote) so we can attach their child rows (route geometry +
// the route-independent HR series) by FK. We probe by the
// composite unique key for entries that have one, and by the
// `(userId, source, startedAt, sportType)` tuple as a fallback
// for manual entries (where externalId is null). The fallback
// is best-effort — manual entries from the iOS client always
// carry an HK uuid, so the null-externalId case is exercised
// only by future surface (manual workout entry).
//
// v1.10.0 — `samples` (the per-workout HR series) rides the
// SAME id lookup as `route`: an indoor workout carries samples
// with no route, so the lookup runs whenever EITHER child is
// present on a to-insert entry.
const childAttachable = toInsert.filter(
(p) => p.route !== null || p.samples !== null,
);
if (childAttachable.length > 0) {
const withExternal = childAttachable.filter(
(p) => p.dedupKey !== null,
);
const withoutExternal = childAttachable.filter(
(p) => p.dedupKey === null,
);
const lookedUp = withExternal.length
? await tx.workout.findMany({
where: {
userId: user.id,
OR: withExternal.map((p) => ({
source: p.dedupKey!
.source as Prisma.WorkoutCreateManyInput["source"],
externalId: p.dedupKey!.externalId,
})),
},
select: {
id: true,
source: true,
externalId: true,
},
})
: [];
const idBySourceExt = new Map<string, string>();
for (const row of lookedUp) {
if (row.externalId !== null) {
idBySourceExt.set(`${row.source}::${row.externalId}`, row.id);
}
}
// Manual entries — `externalId` is null and the unique index
// is NULL-distinct, so two manual entries on the same instant
// would both insert. We look these up by the deterministic
// (userId, source, startedAt, sportType) tuple for each row.
//
// v1.4.27 B7 / BL-code-M3 — single batched findMany with one
// OR-row per entry instead of N serial findFirst probes. For
// a 100-row batch this cuts the per-batch round-trip count
// from 1 + N to 2; the per-entry "most-recent createdAt"
// tie-break is preserved by sorting the returned rows in
// memory and keying on the deterministic tuple.
type ManualMatch = { id: string; createdAt: Date };
const manualMatchesById = new Map<string, ManualMatch>();
if (withoutExternal.length > 0) {
const manualRows = await tx.workout.findMany({
where: {
userId: user.id,
route: null,
OR: withoutExternal.map((p) => ({
source: p.row.source,
startedAt: p.row.startedAt as Date,
sportType: p.row.sportType,
})),
},
select: {
id: true,
source: true,
startedAt: true,
sportType: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
});
for (const row of manualRows) {
const key = `${row.source}::${row.startedAt.getTime()}::${row.sportType}`;
const existing = manualMatchesById.get(key);
if (!existing || existing.createdAt < row.createdAt) {
manualMatchesById.set(key, {
id: row.id,
createdAt: row.createdAt,
});
}
}
}
const manualMatches = withoutExternal.map((p) => {
const key = `${p.row.source}::${(p.row.startedAt as Date).getTime()}::${p.row.sportType}`;
const match = manualMatchesById.get(key);
return match ? { id: match.id } : null;
// NULL external ids are intentionally distinct in PostgreSQL. Insert
// them one at a time so a route/HR series can never attach to a
// different otherwise-identical manual workout.
for (const p of withoutExternalId) {
const inserted = await tx.workout.createManyAndReturn({
data: p.row,
select: { id: true },
});
const row = inserted[0];
if (row) insertedIdByIndex.set(p.index, row.id);
}
// Resolve the freshly-written workout id for a prepared
// entry — by composite key for externalId-bearing rows, by
// the deterministic (source, startedAt, sportType) tuple for
// manual rows. Shared by both child tables (route + samples)
// so the HR series and the route attach to the same row.
const resolveId = (
p: Prepared,
manualIndex: number,
): string | undefined => {
if (p.dedupKey !== null) {
return idBySourceExt.get(
`${p.dedupKey.source}::${p.dedupKey.externalId}`,
);
}
return manualMatches[manualIndex]?.id ?? undefined;
};
const routesToInsert: Prisma.WorkoutRouteCreateManyInput[] = [];
const samplesToInsert: Prisma.WorkoutSamplesCreateManyInput[] = [];
for (const p of toInsert) {
const id = insertedIdByIndex.get(p.index);
if (!id) continue;
if (p.route) {
routesToInsert.push({
workoutId: id,
geometry: p.route.geometry as Prisma.InputJsonValue,
sampleTimestamps:
p.route.sampleTimestamps === null
? Prisma.JsonNull
: (p.route.sampleTimestamps as Prisma.InputJsonValue),
});
}
if (p.samples) {
samplesToInsert.push({
workoutId: id,
samples: p.samples.samples as Prisma.InputJsonValue,
sampleCount: p.samples.sampleCount,
});
}
}
const routesToInsert: Prisma.WorkoutRouteCreateManyInput[] = [];
const samplesToInsert: Prisma.WorkoutSamplesCreateManyInput[] = [];
for (let i = 0; i < withExternal.length; i++) {
const p = withExternal[i];
const id = resolveId(p, -1);
if (!id) continue;
if (p.route) {
routesToInsert.push({
workoutId: id,
geometry: p.route.geometry as Prisma.InputJsonValue,
sampleTimestamps:
p.route.sampleTimestamps === null
? Prisma.JsonNull
: (p.route.sampleTimestamps as Prisma.InputJsonValue),
});
}
if (p.samples) {
samplesToInsert.push({
workoutId: id,
samples: p.samples.samples as Prisma.InputJsonValue,
sampleCount: p.samples.sampleCount,
});
}
}
for (let i = 0; i < withoutExternal.length; i++) {
const p = withoutExternal[i];
const id = resolveId(p, i);
if (!id) continue;
if (p.route) {
routesToInsert.push({
workoutId: id,
geometry: p.route.geometry as Prisma.InputJsonValue,
sampleTimestamps:
p.route.sampleTimestamps === null
? Prisma.JsonNull
: (p.route.sampleTimestamps as Prisma.InputJsonValue),
});
}
if (p.samples) {
samplesToInsert.push({
workoutId: id,
samples: p.samples.samples as Prisma.InputJsonValue,
sampleCount: p.samples.sampleCount,
});
}
}
if (routesToInsert.length > 0) {
for (let i = 0; i < routesToInsert.length; i += CHUNK) {
await tx.workoutRoute.createMany({
data: routesToInsert.slice(i, i + CHUNK),
// 1:1 FK — `workoutId` has a UNIQUE index, so the
// composite unique guards routes too. `skipDuplicates`
// means a re-submitted batch that wins the race on the
// workout row but loses on the route is a no-op rather
// than a hard error.
skipDuplicates: true,
});
}
}
if (samplesToInsert.length > 0) {
for (let i = 0; i < samplesToInsert.length; i += CHUNK) {
await tx.workoutSamples.createMany({
data: samplesToInsert.slice(i, i + CHUNK),
// 1:1 FK on `workoutId` (UNIQUE), same idempotency as
// the route child: a re-submitted batch that wins the
// workout-row race but loses the sample-row race is a
// no-op, not a hard error.
skipDuplicates: true,
});
}
}
for (let i = 0; i < routesToInsert.length; i += CHUNK) {
await tx.workoutRoute.createMany({
data: routesToInsert.slice(i, i + CHUNK),
skipDuplicates: true,
});
}
for (let i = 0; i < samplesToInsert.length; i += CHUNK) {
await tx.workoutSamples.createMany({
data: samplesToInsert.slice(i, i + CHUNK),
skipDuplicates: true,
});
}
});
// v1.4.25 W10 reconcile (senior-dev H-1 parity): when two batches
// race on overlapping externalIds, only one row lands per key.
// `createMany.count` is the source of truth for how many rows
// THIS request wrote; downgrade enough per-entry "inserted"
// statuses to "duplicate" so the per-entry envelope sums match
// the aggregate counts. Order doesn't matter — the iOS sync
// cursor advances past both statuses identically.
const racedDuplicates = toInsert.length - insertedCount;
if (racedDuplicates > 0) {
let downgraded = 0;
// v1.4.42 W5 — walk `survivors` (not `prepared`) so the
// already-downgraded-by-write-dedup losers don't get
// double-counted toward the race-reconcile budget.
for (const p of survivors) {
if (downgraded >= racedDuplicates) break;
if (results[p.index]?.status === "inserted") {
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
downgraded += 1;
}
insertedCount = insertedIdByIndex.size;
for (const p of toInsert) {
if (insertedIdByIndex.has(p.index)) {
results[p.index] = { index: p.index, status: "inserted" };
} else {
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
}
}
}
@@ -734,19 +568,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
if (insertedCount > 0) {
invalidateUserMeasurements(user.id);
// v1.31.0 — the workout arm of the data-arrival spine.
//
// Unlike every other kind, workouts emit ONE arrival PER workout, keyed by
// the workout's own id. Two sessions in a day are two events and each
// deserves its own reaction; a day-scoped key would silently collapse the
// second one.
//
// That needs the ids, and `createMany` does not return them, so this costs
// one extra indexed lookup — but only on a batch that actually inserted
// something, and only for entries carrying an external id. The classifier
// still runs per emit, so a historical import walks straight into
// `backfill` and enqueues nothing regardless of how many rows it wrote.
void emitWorkoutArrivals(user.id, prepared, results).catch(() => {});
// One arrival per exact INSERT ... RETURNING winner. Historical rows still
// stop at the shared salience classifier before any queue work.
void emitWorkoutArrivals(user.id, prepared, insertedIdByIndex).catch(
() => {},
);
}
return apiSuccess({