fix(measurements): reconcile dual identities atomically

This commit is contained in:
Marc-André Bombeck committed 2026-07-20 11:55:09 +02:00
1 parent f8b0a64d69
commit a8ac376cf7
20 files changed
+1549 -797

No files matched your search

@@ -31,6 +31,62 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
reconcileExternalMeasurement: async (
tx: {
measurement: {
findMany: (args: unknown) => Promise<
Array<{
id?: string;
type?: string;
source?: string;
externalId?: string | null;
}>
>;
createManyAndReturn: (args: {
data: Record<string, unknown>;
}) => Promise<Array<Record<string, unknown>>>;
updateMany: (args: {
where: Record<string, unknown>;
data: Record<string, unknown>;
}) => Promise<unknown>;
};
},
input: Record<string, unknown> & {
userId: string;
type: string;
source: string;
externalId: string;
},
options: { exactExternalMatch?: "update" | "duplicate" } = {},
) => {
const existing = await tx.measurement.findMany({});
const exact = existing.find(
(row: { type?: string; source?: string; externalId?: string | null }) =>
row.type === input.type &&
row.source === input.source &&
row.externalId === input.externalId,
);
if (exact && options.exactExternalMatch !== "update") {
return { status: "duplicate", row: exact };
}
if (exact) {
await tx.measurement.updateMany({
where: { id: exact.id },
data: { ...input, deletedAt: null },
});
return { status: "updated", row: { ...exact, ...input } };
}
const [inserted] = await tx.measurement.createManyAndReturn({ data: input });
if (inserted) return { status: "inserted", row: inserted };
if (options.exactExternalMatch === "update") {
await tx.measurement.updateMany({ where: {}, data: input });
return { status: "updated", row: input };
}
return { status: "duplicate" };
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -159,12 +215,10 @@ 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.createManyAndReturn).mock
.calls[0][0];
const rows = (createArg as { data: { type: string; value: number }[] })
.data;
expect(rows[0].type).toBe("PULSE");
expect(rows[0].value).toBe(72);
const row = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0]![0]!.data as { type: string; value: number };
expect(row.type).toBe("PULSE");
expect(row.value).toBe(72);
});
it("persists per-bucket min/max on a fresh 10-min HR bucket (iOS #34 ext)", async () => {
@@ -176,16 +230,15 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
}),
);
expect(res.status).toBe(200);
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0][0];
const rows = (
createArg as {
data: { value: number; valueMin: number; valueMax: number }[];
}
).data;
expect(rows[0].value).toBe(72);
expect(rows[0].valueMin).toBe(58);
expect(rows[0].valueMax).toBe(96);
const row = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0]![0]!.data as {
value: number;
valueMin: number;
valueMax: number;
};
expect(row.value).toBe(72);
expect(row.valueMin).toBe(58);
expect(row.valueMax).toBe(96);
});
it.each([
@@ -202,21 +255,15 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72, spread)] }),
);
expect(res.status).toBe(200);
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0][0];
const rows = (
createArg as {
data: {
value: number;
valueMin: number | null;
valueMax: number | null;
}[];
}
).data;
// The trustworthy average survives; the invalid spread is dropped.
expect(rows[0].value).toBe(72);
expect(rows[0].valueMin).toBeNull();
expect(rows[0].valueMax).toBeNull();
const row = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0]![0]!.data as {
value: number;
valueMin: number | null;
valueMax: number | null;
};
expect(row.value).toBe(72);
expect(row.valueMin).toBeNull();
expect(row.valueMax).toBeNull();
},
);
@@ -251,15 +298,13 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
}),
);
expect(res.status).toBe(200);
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0][0];
const rows = (
createArg as {
data: { valueMin: number | null; valueMax: number | null }[];
}
).data;
expect(rows[0].valueMin).toBeNull();
expect(rows[0].valueMax).toBeNull();
const row = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0]![0]!.data as {
valueMin: number | null;
valueMax: number | null;
};
expect(row.valueMin).toBeNull();
expect(row.valueMax).toBeNull();
});
it("overwrites the same bucket on a re-post (updated, not duplicate)", async () => {
@@ -445,12 +490,10 @@ describe("POST /api/measurements/batch — intra-batch `stats:` supersession", (
expect(res.status).toBe(200);
const { data } = await readJson(res);
// Exactly ONE row reaches the insert, carrying the NEWER value.
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);
expect(prisma.measurement.createManyAndReturn).toHaveBeenCalledTimes(1);
const row = vi.mocked(prisma.measurement.createManyAndReturn).mock
.calls[0]![0]!.data as { value: number };
expect(row.value).toBe(78);
// The superseded entry is reported honestly, not as a phantom insert.
expect(data.entries[0].status).toBe("duplicate");
@@ -500,9 +543,7 @@ 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.createManyAndReturn).mock
.calls[0][0];
expect((createArg as { data: unknown[] }).data).toHaveLength(2);
expect(prisma.measurement.createManyAndReturn).toHaveBeenCalledTimes(2);
expect(data.inserted).toBe(2);
expect(data.duplicates).toBe(0);
});
@@ -15,6 +15,58 @@ vi.mock("@/lib/db", () => ({
}),
},
}));
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
reconcileExternalMeasurement: async (
tx: {
measurement: {
findMany: (args: unknown) => Promise<
Array<{
id?: string;
type?: string;
source?: string;
externalId?: string | null;
}>
>;
createManyAndReturn: (args: {
data: Record<string, unknown>;
}) => Promise<Array<Record<string, unknown>>>;
updateMany: (args: {
where: Record<string, unknown>;
data: Record<string, unknown>;
}) => Promise<unknown>;
};
},
input: Record<string, unknown> & {
userId: string;
type: string;
source: string;
externalId: string;
},
options: { exactExternalMatch?: "update" | "duplicate" } = {},
) => {
const existing = await tx.measurement.findMany({});
const exact = existing.find(
(row: { type?: string; source?: string; externalId?: string | null }) =>
row.type === input.type &&
row.source === input.source &&
row.externalId === input.externalId,
);
if (exact && options.exactExternalMatch !== "update") {
return { status: "duplicate", row: exact };
}
if (exact) {
await tx.measurement.updateMany({ where: { id: exact.id }, data: input });
return { status: "updated", row: { ...exact, ...input } };
}
const [inserted] = await tx.measurement.createManyAndReturn({ data: input });
if (inserted) return { status: "inserted", row: inserted };
if (options.exactExternalMatch === "update") {
await tx.measurement.updateMany({ where: {}, data: input });
return { status: "updated", row: input };
}
return { status: "duplicate" };
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -190,12 +242,7 @@ describe("POST /api/measurements/batch — exact INSERT RETURNING identity", ()
"duplicate",
"duplicate",
]);
expect(prisma.measurement.createManyAndReturn).toHaveBeenCalledWith(
expect.objectContaining({
skipDuplicates: true,
select: { type: true, source: true, externalId: true },
}),
);
expect(prisma.measurement.createManyAndReturn).toHaveBeenCalledTimes(4);
expect(maybeEnqueueMorningRefresh).toHaveBeenCalledWith("user-1", [
new Date(insertedSleepAt),
]);
@@ -8,6 +8,8 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const reconcileOverrideMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
@@ -23,6 +25,26 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
reconcileExternalMeasurement: async (
tx: {
measurement: {
createManyAndReturn: (args: {
data: Record<string, unknown>;
}) => Promise<Array<Record<string, unknown>>>;
};
},
input: Record<string, unknown>,
) => {
const override = await reconcileOverrideMock(tx, input);
if (override !== undefined) return override;
const [inserted] = await tx.measurement.createManyAndReturn({ data: input });
return inserted
? { status: "inserted", row: inserted }
: { status: "duplicate" };
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -118,6 +140,29 @@ describe("POST /api/measurements/batch — PR detection enqueue (v1.4.25 W16c)",
});
});
it("surfaces a hard reconciliation failure without checkpointing it", async () => {
reconcileOverrideMock.mockResolvedValueOnce({
status: "failed",
error: { message: "write rejected", code: "P2002" },
});
const res = await POST(makeRequest({ entries: [validStepEntry("ext-a")] }));
expect(res.status).toBe(200);
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.json()).toMatchObject({
data: {
processed: 1,
inserted: 0,
updated: 0,
duplicates: 0,
failed: 1,
entries: [{ index: 0, status: "failed", reason: "P2002" }],
},
error: null,
});
expect(enqueuePrDetection).not.toHaveBeenCalled();
});
it("sets silent=true once the batch crosses the 50-entry threshold", async () => {
const big = Array.from({ length: 51 }, (_, i) =>
validStepEntry(`ext-${i}`),
+114 -199
View File
@@ -60,6 +60,7 @@ import {
oppositeMergeSource,
type MergeCandidate,
} from "@/lib/measurements/cross-source-merge";
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
import { validateMeasurementRange } from "@/lib/validations/measurement";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -173,11 +174,15 @@ type BatchEntry = z.infer<typeof batchEntrySchema>;
* we already have, the server overwrites the row's `value` rather than
* dropping the new payload as a duplicate. Sample-class rows (every
* other HK metric) keep the strict `duplicate` semantics because each
* sample is a canonical, immutable reading. `skipped` is the only case
* where the client may want to surface a diagnostic — typically because
* Apple introduced a new identifier the server doesn't know about yet.
* sample is a canonical, immutable reading. `skipped` is a validation no-op;
* `failed` is a hard database verdict and must not be checkpointed as landed.
*/
type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped";
type EntryStatus =
| "inserted"
| "updated"
| "duplicate"
| "skipped"
| "failed";
// v1.5.0 issue #213 — `stats:*` externalIds carry per-day cumulative
// totals that the iOS HealthKit observer re-posts as the day progresses.
@@ -372,47 +377,20 @@ async function postBatch(request: NextRequest): Promise<Response> {
let insertedCount = 0;
let updatedCount = 0;
let duplicateCount = 0;
let failedCount = 0;
// v1.11.4 (iOS #2) — subset of `duplicateCount` collapsed by the
// MANUAL↔APPLE_HEALTH same-reading merge (as opposed to a plain
// 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.
// Only rows the reconciler actually inserted drive arrival side effects.
const insertedPrepared: Prepared[] = [];
const writtenIdentities: Array<{
type: MeasurementType;
measuredAt: Date;
}> = [];
if (prepared.length > 0) {
// 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
// `APPLE_HEALTH` row sharing an externalId are distinct, so each must
// carry its source through the existence probe and the composite key.
const incomingKeys = prepared.map((p) => ({
type: p.row.type as Prisma.MeasurementCreateManyInput["type"],
source: p.row.source as Prisma.MeasurementCreateManyInput["source"],
externalId: p.row.externalId as string,
}));
const existing = await prisma.measurement.findMany({
where: {
userId: user.id,
OR: incomingKeys.map((k) => ({
type: k.type,
source: k.source,
externalId: k.externalId,
})),
},
select: { type: true, source: true, externalId: true },
});
const existingSet = new Set(
existing.map((row) => `${row.type}::${row.source}::${row.externalId}`),
);
// v1.11.4 (iOS #2) — same-reading merge for the MANUAL ↔ APPLE_HEALTH
// mirror duplicate. When the iOS app logs a manual reading offline it
// mirrors that reading into HealthKit; on pairing, adopt-on-pair
@@ -485,20 +463,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
return false;
}
// Intra-batch `stats:` supersession. Two entries carrying the same
// (type, source, externalId) `stats:` key inside ONE batch both miss
// `existingSet` — neither is in the DB yet — so the overwrite branch
// never runs, both land in `toInsert`, and
// `createMany({ skipDuplicates: true })` keeps whichever row the unique
// index saw first. The OLDER snapshot of a per-day cumulative total (or
// of a still-filling ten-minute heart-rate bucket) therefore won and the
// newer, larger figure was dropped silently: both entries reported
// `inserted`, the counters still summed, and the client checkpointed
// past both. `stats:` rows carry overwrite semantics by contract — a
// re-post replaces value / unit / measuredAt — so within one batch the
// LAST entry for a key is the authoritative one. Resolve it here and
// mark the superseded earlier entries `duplicate`, the same status the
// client already checkpoints past for a composite-key duplicate.
// `stats:` rows carry overwrite semantics and the last entry in one batch
// is authoritative. Earlier snapshots are safe duplicates.
const lastStatsIndexByKey = new Map<string, number>();
for (const p of prepared) {
if (!isStatsExternalId(p.row.externalId as string)) continue;
@@ -508,57 +474,96 @@ async function postBatch(request: NextRequest): Promise<Response> {
);
}
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}`;
if (
isStatsExternalId(p.row.externalId as string) &&
lastStatsIndexByKey.get(key) !== p.index
) {
results[p.index] = {
index: p.index,
status: "duplicate",
reason: "superseded_in_batch",
};
duplicateCount += 1;
continue;
}
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
// first-sync value. Sample-class rows keep the immutable
// duplicate contract.
if (isStatsExternalId(p.row.externalId as string)) {
toOverwrite.push(p);
} else {
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
const externalId = p.row.externalId as string;
const statsRow = isStatsExternalId(externalId);
const key = `${p.row.type}::${p.row.source}::${externalId}`;
if (statsRow && lastStatsIndexByKey.get(key) !== p.index) {
results[p.index] = {
index: p.index,
status: "duplicate",
reason: "superseded_in_batch",
};
duplicateCount++;
continue;
}
} else if (
!isStatsExternalId(p.row.externalId as string) &&
hasSameReadingSibling(p)
) {
// v1.11.4 (iOS #2) — cross-source same-reading collapse. The
// physical reading already exists under the opposite client
// source; drop this mirror copy. Status stays `duplicate` so the
// iOS sync cursor checkpoints past it exactly as it does for a
// composite-key duplicate; the `reason` distinguishes it for ops.
results[p.index] = {
index: p.index,
status: "duplicate",
reason: "cross_source_merge",
};
duplicateCount += 1;
crossSourceMergedCount += 1;
} else {
results[p.index] = { index: p.index, status: "inserted" };
toInsert.push(p);
insertKeys.add(key);
// Track this row so a same-reading sibling later in the SAME
// batch collapses against it.
if (!statsRow && hasSameReadingSibling(p)) {
results[p.index] = {
index: p.index,
status: "duplicate",
reason: "cross_source_merge",
};
duplicateCount++;
crossSourceMergedCount++;
continue;
}
const verdict = await prisma.$transaction((tx) =>
reconcileExternalMeasurement(
tx,
{
userId: user.id,
type: p.row.type as MeasurementType,
value: p.row.value as number,
valueMin: p.row.valueMin as number | null,
valueMax: p.row.valueMax as number | null,
unit: p.row.unit as string,
source:
p.row.source as Prisma.MeasurementUncheckedCreateInput["source"],
measuredAt: p.row.measuredAt as Date,
externalId,
externalSourceVersion: p.row
.externalSourceVersion as string | null,
glucoseContext:
p.row.glucoseContext as Prisma.MeasurementUncheckedCreateInput["glucoseContext"],
sleepStage:
p.row.sleepStage as Prisma.MeasurementUncheckedCreateInput["sleepStage"],
rhythmClassification:
p.row
.rhythmClassification as Prisma.MeasurementUncheckedCreateInput["rhythmClassification"],
deviceType: p.row.deviceType as string | null,
},
{ exactExternalMatch: statsRow ? "update" : "duplicate" },
),
);
switch (verdict.status) {
case "inserted":
results[p.index] = { index: p.index, status: "inserted" };
insertedCount++;
insertedPrepared.push(p);
writtenIdentities.push({
type: p.row.type as MeasurementType,
measuredAt: p.row.measuredAt as Date,
});
break;
case "updated":
case "resurrected":
results[p.index] = { index: p.index, status: "updated" };
updatedCount++;
writtenIdentities.push(
{
type: p.row.type as MeasurementType,
measuredAt: p.row.measuredAt as Date,
},
...(verdict.dirtyIdentities ?? []),
);
break;
case "duplicate":
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount++;
break;
case "failed":
results[p.index] = {
index: p.index,
status: "failed",
reason: verdict.error.code ?? "write_failed",
};
failedCount++;
continue;
}
if (isMergeableSource(p.row.source as string)) {
inBatchCandidates.push({
type: p.row.type as MeasurementType,
@@ -567,95 +572,6 @@ async function postBatch(request: NextRequest): Promise<Response> {
measuredAt: p.row.measuredAt as Date,
});
}
}
}
if (toOverwrite.length > 0) {
await prisma.$transaction(async (tx) => {
for (const p of toOverwrite) {
await tx.measurement.updateMany({
where: {
userId: user.id,
// v1.8.6 W6 — scope the overwrite to the row's own source
// so a `stats:*` re-post only touches the matching
// (type, source, externalId) row.
source: p.row
.source as Prisma.MeasurementCreateManyInput["source"],
type: p.row.type as MeasurementType,
externalId: p.row.externalId as string,
},
data: {
value: p.row.value as number,
// v1.19.2 (iOS #34 extension) — overwrite the per-bucket
// spread alongside `value` so a within-hour re-post (the
// running mean + range shift as more samples land) replaces
// both. Null for every non-HR-bucket `stats:` overwrite.
valueMin: p.row.valueMin as number | null,
valueMax: p.row.valueMax as number | null,
unit: p.row.unit as string,
measuredAt: p.row.measuredAt as Date,
externalSourceVersion: p.row.externalSourceVersion as
string | null,
deviceType: p.row.deviceType as Prepared["row"]["deviceType"],
sleepStage: p.row.sleepStage as Prepared["row"]["sleepStage"],
// No-op on a live row, a deliberate RESURRECTION on a
// tombstoned one — the observer re-posts the day's canonical
// total, so a `stats:` day-total row follows the
// source-owned resurrect rule (mirrors Google / Fitbit).
// SAMPLE-grain rows never reach this branch: their
// tombstones stick (the iOS LWW reconciler propagates
// HealthKit deletions and reports them as `duplicate`).
deletedAt: null,
},
});
results[p.index] = { index: p.index, status: "updated" };
updatedCount += 1;
}
});
}
if (toInsert.length > 0) {
// 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 insertedIndexes = new Set<number>();
await prisma.$transaction(async (tx) => {
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 },
});
// 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);
}
}
});
for (const p of toInsert) {
if (insertedIndexes.has(p.index)) continue;
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
}
}
}
@@ -680,6 +596,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
updated: updatedCount,
duplicates: duplicateCount,
skipped: skipped.length,
failed: failedCount,
},
});
@@ -760,6 +677,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
updated: updatedCount,
duplicates: duplicateCount,
skipped: skipped.length,
failed: failedCount,
},
});
@@ -784,15 +702,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
// 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) => ({
type: p.row.type as MeasurementType,
measuredAt:
p.row.measuredAt instanceof Date
? p.row.measuredAt
: new Date(p.row.measuredAt as string),
})),
);
const insertedKeys = collapseToTypeDayKeys(writtenIdentities);
for (const k of insertedKeys) {
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
}
@@ -851,12 +761,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
}
return apiSuccess({
const response = apiSuccess({
processed: entries.length,
inserted: insertedCount,
updated: updatedCount,
duplicates: duplicateCount,
failed: failedCount,
skipped,
entries: results,
});
if (failedCount > 0) {
response.headers.set("Cache-Control", "private, no-store");
}
return response;
}