release: v1.32.0 — hardening and scalability (#580)

* release: prepare v1.32.0

* test: stabilize water capture re-entry
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-22 04:14:15 +02:00
1 parent b01822be48
commit 62eb431d66
372 files changed
+25178 -8561

No files matched your search

@@ -0,0 +1,259 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { formatMeasurementsForExport, toCSV } from "@/lib/export";
import { MEASUREMENT_EXPORT_PAGE_SIZE } from "@/lib/export/paged-measurements";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn().mockResolvedValue("UTC"),
}));
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn().mockResolvedValue(null),
}));
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { GET } from "../measurements/route";
import { GET as GET_LEGACY } from "../route";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", role: "USER" as const },
};
function request(): NextRequest {
return new NextRequest(
"http://localhost/api/export/measurements?granularity=raw",
{ method: "GET" },
);
}
function legacyRequest(): NextRequest {
return new NextRequest(
"http://localhost/api/export?format=json&type=measurements",
{ method: "GET" },
);
}
function measurement(index: number) {
return {
id: `m-${String(index).padStart(5, "0")}`,
type: index % 2 === 0 ? "WEIGHT" : "HEART_RATE",
value: index + 0.25,
unit: index % 2 === 0 ? "kg" : "bpm",
measuredAt: new Date(Date.UTC(2026, 6, 21, 12, 0, 0) - index * 1_000),
source: "MANUAL",
notes: index === 1 ? 'comma, quote " and\nnewline' : null,
notesEncrypted: null,
glucoseContext: null,
sleepStage: null,
deviceType: null,
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(auditLog).mockReset().mockResolvedValue(undefined);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3_600_000,
});
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mg/dL",
} as never);
});
describe("GET /api/export/measurements streaming", () => {
it("does not audit a failed authorization", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const response = await GET(request());
expect(response.status).toBe(401);
expect(auditLog).not.toHaveBeenCalled();
});
it("persists the audit event before returning the streaming response", async () => {
let persistAudit!: () => void;
const auditPersistence = new Promise<void>((resolve) => {
persistAudit = resolve;
});
let markAuditStarted: (() => void) | undefined;
const auditStarted = new Promise<void>((resolve) => {
markAuditStarted = resolve;
});
vi.mocked(auditLog).mockImplementationOnce(() => {
markAuditStarted?.();
return auditPersistence;
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
const responsePromise = GET(request());
const firstSettled = await Promise.race([
responsePromise.then(() => "response" as const),
auditStarted.then(() => "audit" as const),
]);
persistAudit();
const response = await responsePromise;
expect(response.status).toBe(200);
expect(firstSettled).toBe("audit");
expect(auditLog).toHaveBeenCalledTimes(1);
await response.body?.cancel();
});
it("streams exact CSV across a keyset page boundary", async () => {
const firstPage = Array.from(
{ length: MEASUREMENT_EXPORT_PAGE_SIZE },
(_, index) => measurement(index),
);
const finalPage = [measurement(MEASUREMENT_EXPORT_PAGE_SIZE)];
vi.mocked(prisma.measurement.findMany)
.mockResolvedValueOnce(firstPage as never)
.mockResolvedValueOnce(finalPage as never);
const response = await GET(request());
expect(response.status).toBe(200);
expect(response.body).not.toBeNull();
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(1);
expect(auditLog).toHaveBeenCalledTimes(1);
const body = await response.text();
const allRows = [...firstPage, ...finalPage];
const expected = toCSV(
formatMeasurementsForExport(allRows, "UTC", {
granularity: "raw",
sleepTz: "UTC",
sourcePriorityJson: null,
glucoseUnit: "mg/dL",
}),
);
expect(body).toBe(expected);
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(2);
expect(auditLog).toHaveBeenCalledWith("user.export.measurements", {
userId: "user-1",
ipAddress: null,
details: {
outcome: "attempted",
since: null,
until: null,
},
});
expect(auditLog).toHaveBeenCalledTimes(1);
});
it("propagates a later page failure through the response body", async () => {
const failure = new Error("measurement page failed");
const firstPage = Array.from(
{ length: MEASUREMENT_EXPORT_PAGE_SIZE },
(_, index) => measurement(index),
);
vi.mocked(prisma.measurement.findMany)
.mockResolvedValueOnce(firstPage as never)
.mockRejectedValueOnce(failure);
const response = await GET(request());
expect(response.status).toBe(200);
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(1);
await expect(response.text()).rejects.toThrow("measurement page failed");
expect(auditLog).toHaveBeenCalledTimes(1);
});
});
describe("GET /api/export legacy measurement streaming", () => {
it("does not audit a failed authorization", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const response = await GET_LEGACY(legacyRequest());
expect(response.status).toBe(401);
expect(auditLog).not.toHaveBeenCalled();
});
it("persists the audit event before returning the streaming response", async () => {
let persistAudit!: () => void;
const auditPersistence = new Promise<void>((resolve) => {
persistAudit = resolve;
});
let markAuditStarted: (() => void) | undefined;
const auditStarted = new Promise<void>((resolve) => {
markAuditStarted = resolve;
});
vi.mocked(auditLog).mockImplementationOnce(() => {
markAuditStarted?.();
return auditPersistence;
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
const responsePromise = GET_LEGACY(legacyRequest());
const firstSettled = await Promise.race([
responsePromise.then(() => "response" as const),
auditStarted.then(() => "audit" as const),
]);
persistAudit();
const response = await responsePromise;
expect(response.status).toBe(200);
expect(firstSettled).toBe("audit");
expect(auditLog).toHaveBeenCalledTimes(1);
await response.body?.cancel();
});
it("frames exact JSON while pulling later measurement pages from the body", async () => {
const firstPage = Array.from(
{ length: MEASUREMENT_EXPORT_PAGE_SIZE },
(_, index) => measurement(index),
);
const finalPage = [measurement(MEASUREMENT_EXPORT_PAGE_SIZE)];
vi.mocked(prisma.measurement.findMany)
.mockResolvedValueOnce(firstPage as never)
.mockResolvedValueOnce(finalPage as never);
const response = await GET_LEGACY(legacyRequest());
expect(response.status).toBe(200);
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(1);
expect(auditLog).toHaveBeenCalledTimes(1);
const body = await response.text();
const records = formatMeasurementsForExport(
[...firstPage, ...finalPage],
"UTC",
{
sleepTz: "UTC",
sourcePriorityJson: null,
glucoseUnit: "mg/dL",
},
);
expect(body).toBe(JSON.stringify({ data: { measurements: records } }));
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(2);
expect(auditLog).toHaveBeenCalledWith("export.download", {
userId: "user-1",
ipAddress: null,
details: {
format: "json",
type: "measurements",
outcome: "attempted",
},
});
expect(auditLog).toHaveBeenCalledTimes(1);
});
});
@@ -96,6 +96,7 @@ describe("GET /api/export/measurements", () => {
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "measurement-weight-1",
type: "WEIGHT",
value: 80,
unit: "kg",
@@ -166,6 +167,7 @@ describe("GET /api/export/measurements", () => {
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "measurement-glucose-mmol",
type: "BLOOD_GLUCOSE",
value: 100,
unit: "mg/dL",
@@ -196,6 +198,7 @@ describe("GET /api/export/measurements", () => {
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "measurement-glucose-mgdl",
type: "BLOOD_GLUCOSE",
value: 100,
unit: "mg/dL",
+105 -57
View File
@@ -22,9 +22,11 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { toCSV, formatMeasurementsForExport } from "@/lib/export";
import { findMeasurementsPaged } from "@/lib/export/paged-measurements";
import { shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
import { toCSV, type ExportableRecord } from "@/lib/export";
import {
formatMeasurementPageChunks,
iterateMeasurementPages,
} from "@/lib/export/paged-measurements";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveGlucoseUnit } from "@/lib/glucose";
@@ -41,80 +43,123 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { since, until } = parseRange(request.url);
const where = buildWhere(user.id, { since, until });
// v1.11.5 — sleep export defaults to one row per night; `granularity=raw`
// keeps the per-stage rows for power users.
const granularity =
new URL(request.url).searchParams.get("granularity") === "raw"
? "raw"
: "night";
const [measurements, userTz, sourcePriorityJson, profile] = await Promise.all(
[
// v1.28.25 — keyset-paginated read with a narrow select. The route
// keeps its contract (an absent `since`/`until` means the full
// history), but the read is now chunked so an unbounded window on a
// CGM / per-sample-HR account never materialises the whole table in
// one query. The select is exactly the formatter's `ExportMeasurement`
// shape + note decryption + the `id` cursor key.
findMeasurementsPaged(prisma, where, {
id: true,
type: true,
value: true,
unit: true,
measuredAt: true,
source: true,
notes: true,
notesEncrypted: true,
glucoseContext: true,
sleepStage: true,
deviceType: true,
}),
resolveUserTimezone(user.id),
loadUserSourcePriority(user.id),
// v1.16.16 — resolve the glucose display unit so BLOOD_GLUCOSE rows
// export in the user's unit (matching FHIR), not raw stored mg/dL.
prisma.user.findUnique({
where: { id: user.id },
select: { glucoseUnit: true },
}),
],
);
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
const csv = toCSV(
formatMeasurementsForExport(
measurements.map(shapeMeasurementNotes),
userTz,
{
granularity,
sleepTz: userTz,
sourcePriorityJson,
glucoseUnit,
},
),
);
await auditLog("user.export.measurements", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
count: measurements.length,
outcome: "attempted",
since: since?.toISOString() ?? null,
until: until?.toISOString() ?? null,
},
});
annotate({
meta: {
export_count: measurements.length,
export_outcome: "attempted",
export_since: since?.toISOString() ?? null,
export_until: until?.toISOString() ?? null,
},
});
const pageIterator = iterateMeasurementPages(prisma, where, {
id: true,
type: true,
value: true,
unit: true,
measuredAt: true,
source: true,
notes: true,
notesEncrypted: true,
glucoseContext: true,
sleepStage: true,
deviceType: true,
})[Symbol.asyncIterator]();
return csvResponse(csv, `healthlog-measurements-${user.id}`);
// Prime one bounded page before sending headers. Authentication, metadata,
// and first-query failures therefore keep the historical API error
// behaviour; later pages are pulled only as the client consumes the body.
const [firstPage, userTz, sourcePriorityJson, profile] = await Promise.all([
pageIterator.next(),
resolveUserTimezone(user.id),
loadUserSourcePriority(user.id),
prisma.user.findUnique({
where: { id: user.id },
select: { glucoseUnit: true },
}),
]).catch(async (error: unknown) => {
await pageIterator.return?.();
throw error;
});
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
const pages = prefetchedPages(pageIterator, firstPage);
const recordChunks = formatMeasurementPageChunks(pages, userTz, {
granularity,
sourcePriorityJson,
glucoseUnit,
});
const body = streamText(csvChunks(recordChunks));
return csvResponse(body, `healthlog-measurements-${user.id}`);
});
async function* prefetchedPages<T>(
iterator: AsyncIterator<T[]>,
first: IteratorResult<T[]>,
): AsyncGenerator<readonly T[], void, void> {
try {
let next = first;
while (!next.done) {
yield next.value;
next = await iterator.next();
}
} finally {
await iterator.return?.();
}
}
async function* csvChunks(
chunks: AsyncIterable<ExportableRecord[]>,
): AsyncGenerator<string, void, void> {
let wroteHeader = false;
for await (const records of chunks) {
const csv = toCSV(records);
if (csv.length === 0) continue;
if (!wroteHeader) {
wroteHeader = true;
yield csv;
continue;
}
const headerEnd = csv.indexOf("\n");
if (headerEnd >= 0 && headerEnd + 1 < csv.length) {
yield `\n${csv.slice(headerEnd + 1)}`;
}
}
}
function streamText(chunks: AsyncIterable<string>): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
const iterator = chunks[Symbol.asyncIterator]();
return new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const next = await iterator.next();
if (next.done) {
controller.close();
return;
}
controller.enqueue(encoder.encode(next.value));
} catch (error) {
controller.error(error);
}
},
async cancel() {
await iterator.return?.();
},
});
}
function parseRange(url: string): {
since: Date | undefined;
until: Date | undefined;
@@ -159,7 +204,10 @@ function buildWhere(
return where;
}
function csvResponse(body: string, prefix: string): NextResponse {
function csvResponse(
body: ReadableStream<Uint8Array>,
prefix: string,
): NextResponse {
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(body, {
status: 200,
+154 -47
View File
@@ -6,13 +6,16 @@ import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import {
toCSV,
formatMeasurementsForExport,
formatMedicationsForExport,
formatIntakeEventsForExport,
formatMoodEntriesForExport,
type ExportableRecord,
} from "@/lib/export";
import { shapeMeasurementNotes, shapeMoodNote } from "@/lib/crypto/note-cipher";
import { findMeasurementsPaged } from "@/lib/export/paged-measurements";
import { shapeMoodNote } from "@/lib/crypto/note-cipher";
import {
formatMeasurementPageChunks,
iterateMeasurementPages,
} from "@/lib/export/paged-measurements";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { NextRequest, NextResponse } from "next/server";
@@ -52,26 +55,28 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const userId = user.id;
await auditLog("export.download", {
userId,
ipAddress: getClientIp(request),
details: { format, type, outcome: "attempted" },
});
annotate({
meta: {
export_format: format,
export_type: type,
export_outcome: "attempted",
},
});
const [userTz, sourcePriorityJson] = await Promise.all([
resolveUserTimezone(userId),
loadUserSourcePriority(userId),
]);
const data: Record<string, unknown> = {};
let measurementChunks: AsyncIterable<ExportableRecord[]> | null = null;
if (type === "measurements" || type === "all") {
// v1.28.25 — keyset-paginated read with a narrow select. The legacy
// export has no date window, so a CGM / per-sample-HR account holds a
// six-figure row set; one unbounded full-width `findMany` here was the
// scale hazard. The select carries exactly the fields the formatter +
// note decryption consume (`ExportMeasurement` + `shapeMeasurementNotes`
// + the `id` cursor key) — no Bytes column beyond the note ciphertext
// the export must decrypt.
const measurements = await findMeasurementsPaged(
const pageIterator = iterateMeasurementPages(
prisma,
// v1.4.41 W-DELETED-2 — exclude soft-deleted measurements from
// the legacy /api/export endpoint so CSV + JSON downloads stay
// consistent with the live measurement reads.
{ userId, deletedAt: null },
{
id: true,
@@ -86,19 +91,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
sleepStage: true,
deviceType: true,
},
);
// v1.11.5 — sleep collapses to one row per night by default (the
// formatter's `night` granularity), matching the per-type CSV route.
// Thread the user's source-priority so a multi-source night dedups to
// the same canonical source the UI shows.
const measurementRecords = formatMeasurementsForExport(
// v1.23 — decrypt notes before formatting; strip the ciphertext column.
measurements.map(shapeMeasurementNotes),
)[Symbol.asyncIterator]();
const firstPage = await pageIterator.next();
measurementChunks = formatMeasurementPageChunks(
prefetchedPages(pageIterator, firstPage),
userTz,
{ sleepTz: userTz, sourcePriorityJson },
{
granularity: "night",
sourcePriorityJson,
// Preserve the legacy endpoint's canonical storage-unit contract.
glucoseUnit: "mg/dL",
},
);
data.measurements =
format === "csv" ? toCSV(measurementRecords) : measurementRecords;
}
if (type === "medications" || type === "all") {
@@ -115,8 +119,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (type === "intake" || type === "all") {
const events = await prisma.medicationIntakeEvent.findMany({
// v1.7.0 sync — exclude tombstoned rows from the export (mirrors
// the measurement export's `deletedAt: null` filter above).
where: { userId, deletedAt: null },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
@@ -129,7 +131,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (type === "mood" || type === "all") {
const moodEntries = await prisma.moodEntry.findMany({
// v1.7.0 sync — exclude tombstoned rows from the export.
where: { userId, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
});
@@ -140,36 +141,50 @@ export const GET = apiHandler(async (request: NextRequest) => {
: formatMoodEntriesForExport(decryptedMood, userTz);
}
await auditLog("export.download", {
userId,
ipAddress: getClientIp(request),
details: { format, type },
});
const filename = `healthlog-export-${new Date().toISOString().slice(0, 10)}`;
annotate({ meta: { export_format: format, export_type: type } });
if (measurementChunks && format === "csv") {
return new NextResponse(
streamText(legacyCsvChunks(measurementChunks, data)),
{
status: 200,
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${filename}.csv"`,
},
},
);
}
if (measurementChunks) {
return new NextResponse(
streamText(legacyJsonChunks(measurementChunks, data)),
{
status: 200,
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="${filename}.json"`,
},
},
);
}
if (format === "csv") {
// For CSV with all types, combine into separate sections
const csvParts: string[] = [];
if (data.measurements) {
csvParts.push("# Measurements\n" + data.measurements);
}
if (data.medications) {
if (typeof data.medications === "string" && data.medications.length > 0) {
csvParts.push("# Medications\n" + data.medications);
}
if (data.intakeEvents) {
if (typeof data.intakeEvents === "string" && data.intakeEvents.length > 0) {
csvParts.push("# Intake Events\n" + data.intakeEvents);
}
if (data.moodEntries) {
if (typeof data.moodEntries === "string" && data.moodEntries.length > 0) {
csvParts.push("# Mood Entries\n" + data.moodEntries);
}
const csvContent = csvParts.join("\n\n");
return new NextResponse(csvContent, {
return new NextResponse(csvParts.join("\n\n"), {
status: 200,
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="healthlog-export-${new Date().toISOString().slice(0, 10)}.csv"`,
"Content-Disposition": `attachment; filename="${filename}.csv"`,
},
});
}
@@ -179,8 +194,100 @@ export const GET = apiHandler(async (request: NextRequest) => {
{
status: 200,
headers: {
"Content-Disposition": `attachment; filename="healthlog-export-${new Date().toISOString().slice(0, 10)}.json"`,
"Content-Disposition": `attachment; filename="${filename}.json"`,
},
},
);
});
async function* prefetchedPages<T>(
iterator: AsyncIterator<T[]>,
first: IteratorResult<T[]>,
): AsyncGenerator<readonly T[], void, void> {
try {
let next = first;
while (!next.done) {
yield next.value;
next = await iterator.next();
}
} finally {
await iterator.return?.();
}
}
async function* legacyCsvChunks(
measurementChunks: AsyncIterable<ExportableRecord[]>,
data: Record<string, unknown>,
): AsyncGenerator<string, void, void> {
let wroteSection = false;
let wroteMeasurementHeader = false;
for await (const records of measurementChunks) {
const csv = toCSV(records);
if (csv.length === 0) continue;
if (!wroteMeasurementHeader) {
wroteMeasurementHeader = true;
wroteSection = true;
yield `# Measurements\n${csv}`;
continue;
}
const headerEnd = csv.indexOf("\n");
if (headerEnd >= 0 && headerEnd + 1 < csv.length) {
yield `\n${csv.slice(headerEnd + 1)}`;
}
}
const staticSections: Array<[string, unknown]> = [
["Medications", data.medications],
["Intake Events", data.intakeEvents],
["Mood Entries", data.moodEntries],
];
for (const [heading, value] of staticSections) {
if (typeof value !== "string" || value.length === 0) continue;
yield `${wroteSection ? "\n\n" : ""}# ${heading}\n${value}`;
wroteSection = true;
}
}
async function* legacyJsonChunks(
measurementChunks: AsyncIterable<ExportableRecord[]>,
data: Record<string, unknown>,
): AsyncGenerator<string, void, void> {
yield '{"data":{"measurements":[';
let wroteMeasurement = false;
for await (const records of measurementChunks) {
if (records.length === 0) continue;
const json = records.map((record) => JSON.stringify(record)).join(",");
yield `${wroteMeasurement ? "," : ""}${json}`;
wroteMeasurement = true;
}
yield "]";
const staticKeys = ["medications", "intakeEvents", "moodEntries"] as const;
for (const key of staticKeys) {
if (!(key in data)) continue;
yield `,${JSON.stringify(key)}:${JSON.stringify(data[key])}`;
}
yield "}}";
}
function streamText(chunks: AsyncIterable<string>): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
const iterator = chunks[Symbol.asyncIterator]();
return new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const next = await iterator.next();
if (next.done) {
controller.close();
return;
}
controller.enqueue(encoder.encode(next.value));
} catch (error) {
controller.error(error);
}
},
async cancel() {
await iterator.return?.();
},
});
}