mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
fix(release): harden v1.31 arrival contracts
This commit is contained in:
1 parent
44add3bc1e
commit
4591899657
98 files changed
+5560
-1600
No files matched your search
@@ -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(() => ({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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" });
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user