From 0a7b0448adc166b6941b278eb2472289cab1bea5 Mon Sep 17 00:00:00 2001 From: flfeders Date: Sun, 5 Jul 2026 12:25:39 +0200 Subject: [PATCH] Add prepared tenant export archives --- .../db/migrations/0054_tenant_export_jobs.sql | 19 ++ backend/db/migrations/meta/_journal.json | 7 + backend/db/schema/index.ts | 1 + backend/db/schema/tenant_export_jobs.ts | 41 +++ backend/src/routes/admin.ts | 257 ++++++++++++++- backend/src/utils/tenantFullExport.ts | 295 +++++++++++++++++- 6 files changed, 601 insertions(+), 19 deletions(-) create mode 100644 backend/db/migrations/0054_tenant_export_jobs.sql create mode 100644 backend/db/schema/tenant_export_jobs.ts diff --git a/backend/db/migrations/0054_tenant_export_jobs.sql b/backend/db/migrations/0054_tenant_export_jobs.sql new file mode 100644 index 0000000..3f383fd --- /dev/null +++ b/backend/db/migrations/0054_tenant_export_jobs.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS "tenant_export_jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone, + "completed_at" timestamp with time zone, + "tenant_id" bigint NOT NULL REFERENCES "tenants"("id") ON DELETE cascade, + "created_by" uuid REFERENCES "auth_users"("id"), + "status" text DEFAULT 'queued' NOT NULL, + "filename" text NOT NULL, + "storage_path" text, + "content_type" text DEFAULT 'application/zip' NOT NULL, + "file_size" bigint, + "error" text, + "files_total" integer DEFAULT 0 NOT NULL, + "files_done" integer DEFAULT 0 NOT NULL +); + +CREATE INDEX IF NOT EXISTS "tenant_export_jobs_tenant_status_idx" + ON "tenant_export_jobs" ("tenant_id", "status"); diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index c1bfbdf..e2be355 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -358,6 +358,13 @@ "when": 1780261200000, "tag": "0050_outgoing_document_costcentres", "breakpoints": true + }, + { + "idx": 51, + "version": "7", + "when": 1783247083934, + "tag": "0054_tenant_export_jobs", + "breakpoints": true } ] } diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index 8fabe61..90a53dd 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -82,6 +82,7 @@ export * from "./taxtypes" export * from "./telephony_calls" export * from "./telephony_extensions" export * from "./telephony_trunks" +export * from "./tenant_export_jobs" export * from "./tenants" export * from "./texttemplates" export * from "./units" diff --git a/backend/db/schema/tenant_export_jobs.ts b/backend/db/schema/tenant_export_jobs.ts new file mode 100644 index 0000000..84277ed --- /dev/null +++ b/backend/db/schema/tenant_export_jobs.ts @@ -0,0 +1,41 @@ +import { + pgTable, + uuid, + bigint, + timestamp, + text, + integer, +} from "drizzle-orm/pg-core" + +import { tenants } from "./tenants" +import { authUsers } from "./auth_users" + +export const tenantExportJobs = pgTable("tenant_export_jobs", { + id: uuid("id").primaryKey().defaultRandom(), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + + updatedAt: timestamp("updated_at", { withTimezone: true }), + completedAt: timestamp("completed_at", { withTimezone: true }), + + tenantId: bigint("tenant_id", { mode: "number" }) + .notNull() + .references(() => tenants.id, { onDelete: "cascade" }), + + createdBy: uuid("created_by").references(() => authUsers.id), + + status: text("status").notNull().default("queued"), + filename: text("filename").notNull(), + storagePath: text("storage_path"), + contentType: text("content_type").notNull().default("application/zip"), + fileSize: bigint("file_size", { mode: "number" }), + error: text("error"), + + filesTotal: integer("files_total").notNull().default(0), + filesDone: integer("files_done").notNull().default(0), +}) + +export type TenantExportJob = typeof tenantExportJobs.$inferSelect +export type NewTenantExportJob = typeof tenantExportJobs.$inferInsert diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index c8e981a..e5cb46d 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -1,5 +1,7 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { and, eq, inArray, isNull } from "drizzle-orm"; +import multipart from "@fastify/multipart"; +import { GetObjectCommand } from "@aws-sdk/client-s3"; import { authTenantUsers, @@ -10,17 +12,28 @@ import { authUsers, filetags, folders, + tenantExportJobs, tenants, } from "../../db/schema"; import { generateRandomPassword, hashPassword } from "../utils/password"; import { sendMail } from "../utils/mailer"; import { ensureTenantBaseData } from "../modules/bootstrap.service"; -import { buildTenantFullExport, importTenantFullExport } from "../utils/tenantFullExport"; +import { + createTenantFullExportArchive, + importTenantFullExport, + importTenantFullExportArchive, +} from "../utils/tenantFullExport"; import type { TenantFullExport } from "../utils/tenantFullExport"; import { buildSystemStatus } from "../modules/system-status.service"; import { matrixService } from "../modules/matrix.service"; +import { s3 } from "../utils/s3"; +import { secrets } from "../utils/secrets"; export default async function adminRoutes(server: FastifyInstance) { + await server.register(multipart, { + limits: { fileSize: 1024 * 1024 * 1024 } + }); + const deriveNameFromEmail = (email: string) => { const localPart = email.split("@")[0] || "Benutzer"; const normalized = localPart.replace(/[._-]+/g, " ").trim(); @@ -245,6 +258,98 @@ export default async function adminRoutes(server: FastifyInstance) { ]); }; + const tenantExportFilename = (tenantId: number, tenant?: { short?: string | null; name?: string | null }) => { + const safeTenantName = String(tenant?.short || tenant?.name || tenantId) + .replace(/[^a-z0-9_-]+/gi, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase(); + + return `fedeo-tenant-${safeTenantName || tenantId}-${new Date().toISOString().slice(0, 10)}.fedeo-export.zip`; + }; + + const startTenantExportJob = async (jobId: string, tenantId: number, filename: string) => { + try { + await server.db + .update(tenantExportJobs) + .set({ + status: "running", + updatedAt: new Date(), + }) + .where(eq(tenantExportJobs.id, jobId)); + + const result = await createTenantFullExportArchive(server, tenantId, { + filename, + storagePath: `tenant-exports/${tenantId}/${jobId}/${filename}`, + onProgress: async ({ filesDone, filesTotal }) => { + await server.db + .update(tenantExportJobs) + .set({ + filesDone, + filesTotal, + updatedAt: new Date(), + }) + .where(eq(tenantExportJobs.id, jobId)); + }, + }); + + await server.db + .update(tenantExportJobs) + .set({ + status: "ready", + storagePath: result.storagePath, + contentType: result.contentType, + fileSize: result.size, + filesTotal: result.filesTotal, + filesDone: result.filesDone, + completedAt: new Date(), + updatedAt: new Date(), + error: null, + }) + .where(eq(tenantExportJobs.id, jobId)); + } catch (err: any) { + console.error("ERROR tenant export job:", err); + await server.db + .update(tenantExportJobs) + .set({ + status: "failed", + error: err?.message || String(err), + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(tenantExportJobs.id, jobId)); + } + }; + + const createPreparedTenantExport = async (tenantId: number, currentUserId: string) => { + const [tenant] = await server.db + .select({ + id: tenants.id, + name: tenants.name, + short: tenants.short, + }) + .from(tenants) + .where(eq(tenants.id, tenantId)) + .limit(1); + + if (!tenant) return null; + + const filename = tenantExportFilename(tenantId, tenant); + const [job] = await server.db + .insert(tenantExportJobs) + .values({ + tenantId, + createdBy: currentUserId, + status: "queued", + filename, + updatedAt: new Date(), + }) + .returning(); + + void startTenantExportJob(job.id, tenantId, filename); + + return job; + }; + const requireAdmin = async (req: FastifyRequest, reply: FastifyReply) => { if (!req.user?.user_id) { reply.code(401).send({ error: "Unauthorized" }); @@ -969,22 +1074,128 @@ export default async function adminRoutes(server: FastifyInstance) { return reply.code(400).send({ error: "tenant_id required" }); } - const exportData = await buildTenantFullExport(server, tenantId); - const safeTenantName = String(exportData.tables.tenants?.[0]?.short || exportData.tables.tenants?.[0]?.name || tenantId) - .replace(/[^a-z0-9_-]+/gi, "-") - .replace(/^-+|-+$/g, "") - .toLowerCase(); - const filename = `fedeo-tenant-${safeTenantName || tenantId}-${new Date().toISOString().slice(0, 10)}.json`; + const job = await createPreparedTenantExport(tenantId, currentUser.id); + if (!job) { + return reply.code(404).send({ error: "Tenant not found" }); + } - reply.header("Content-Type", "application/json"); - reply.header("Content-Disposition", `attachment; filename="${filename}"`); - return reply.send(exportData); + return reply.code(202).send({ + exportId: job.id, + status: job.status, + filename: job.filename, + statusUrl: `/api/admin/tenant-exports/${job.id}`, + downloadUrl: `/api/admin/tenant-exports/${job.id}/download`, + }); } catch (err) { console.error("ERROR /admin/tenants/:tenant_id/export:", err); return reply.code(500).send({ error: "Internal Server Error" }); } }); + // ------------------------------------------------------------- + // POST /admin/tenants/:tenant_id/exports + // ------------------------------------------------------------- + server.post("/admin/tenants/:tenant_id/exports", async (req, reply) => { + try { + const currentUser = await requireAdmin(req, reply); + if (!currentUser) return; + + const { tenant_id } = req.params as { tenant_id: string }; + const tenantId = Number(tenant_id); + if (!tenantId) { + return reply.code(400).send({ error: "tenant_id required" }); + } + + const job = await createPreparedTenantExport(tenantId, currentUser.id); + if (!job) { + return reply.code(404).send({ error: "Tenant not found" }); + } + + return reply.code(202).send({ + exportId: job.id, + status: job.status, + filename: job.filename, + statusUrl: `/api/admin/tenant-exports/${job.id}`, + downloadUrl: `/api/admin/tenant-exports/${job.id}/download`, + }); + } catch (err) { + console.error("ERROR /admin/tenants/:tenant_id/exports:", err); + return reply.code(500).send({ error: "Internal Server Error" }); + } + }); + + // ------------------------------------------------------------- + // GET /admin/tenant-exports/:export_id + // ------------------------------------------------------------- + server.get("/admin/tenant-exports/:export_id", async (req, reply) => { + try { + const currentUser = await requireAdmin(req, reply); + if (!currentUser) return; + + const { export_id } = req.params as { export_id: string }; + const [job] = await server.db + .select() + .from(tenantExportJobs) + .where(eq(tenantExportJobs.id, export_id)) + .limit(1); + + if (!job) return reply.code(404).send({ error: "Export not found" }); + + return { + exportId: job.id, + tenantId: job.tenantId, + status: job.status, + filename: job.filename, + fileSize: job.fileSize, + filesDone: job.filesDone, + filesTotal: job.filesTotal, + error: job.error, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + completedAt: job.completedAt, + downloadUrl: job.status === "ready" ? `/api/admin/tenant-exports/${job.id}/download` : null, + }; + } catch (err) { + console.error("ERROR /admin/tenant-exports/:export_id:", err); + return reply.code(500).send({ error: "Internal Server Error" }); + } + }); + + // ------------------------------------------------------------- + // GET /admin/tenant-exports/:export_id/download + // ------------------------------------------------------------- + server.get("/admin/tenant-exports/:export_id/download", async (req, reply) => { + try { + const currentUser = await requireAdmin(req, reply); + if (!currentUser) return; + + const { export_id } = req.params as { export_id: string }; + const [job] = await server.db + .select() + .from(tenantExportJobs) + .where(eq(tenantExportJobs.id, export_id)) + .limit(1); + + if (!job) return reply.code(404).send({ error: "Export not found" }); + if (job.status !== "ready" || !job.storagePath) { + return reply.code(409).send({ error: "Export not ready", status: job.status }); + } + + const { Body, ContentLength } = await s3.send(new GetObjectCommand({ + Bucket: secrets.S3_BUCKET, + Key: job.storagePath, + })); + + reply.header("Content-Type", job.contentType || "application/zip"); + reply.header("Content-Disposition", `attachment; filename="${job.filename}"`); + if (ContentLength) reply.header("Content-Length", String(ContentLength)); + return reply.send(Body as any); + } catch (err) { + console.error("ERROR /admin/tenant-exports/:export_id/download:", err); + return reply.code(500).send({ error: "Internal Server Error" }); + } + }); + // ------------------------------------------------------------- // POST /admin/tenant-imports // ------------------------------------------------------------- @@ -993,15 +1204,27 @@ export default async function adminRoutes(server: FastifyInstance) { const currentUser = await requireAdmin(req, reply); if (!currentUser) return; - const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number }; - const exportData = "format" in body ? body : body.exportData; - const targetTenantId = "format" in body ? null : Number(body.targetTenantId || 0) || null; + const isMultipart = req.headers["content-type"]?.includes("multipart/form-data"); + let result; - if (!exportData) { - return reply.code(400).send({ error: "exportData required" }); + if (isMultipart) { + const data: any = await req.file(); + if (!data?.file) return reply.code(400).send({ error: "export file required" }); + + const archiveBuffer = await data.toBuffer(); + const targetTenantId = Number(data.fields?.targetTenantId?.value || 0) || null; + result = await importTenantFullExportArchive(server, archiveBuffer, { targetTenantId }); + } else { + const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number }; + const exportData = "format" in body ? body : body.exportData; + const targetTenantId = "format" in body ? null : Number(body.targetTenantId || 0) || null; + + if (!exportData) { + return reply.code(400).send({ error: "exportData required" }); + } + + result = await importTenantFullExport(server, exportData, { targetTenantId }); } - - const result = await importTenantFullExport(server, exportData, { targetTenantId }); const fallbackName = deriveNameFromEmail(currentUser.email); await server.db diff --git a/backend/src/utils/tenantFullExport.ts b/backend/src/utils/tenantFullExport.ts index f032169..5a0ec2b 100644 --- a/backend/src/utils/tenantFullExport.ts +++ b/backend/src/utils/tenantFullExport.ts @@ -1,5 +1,8 @@ import { FastifyInstance } from "fastify" import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3" +import archiver from "archiver" +import { PassThrough } from "stream" +import { BlobReader, TextWriter, Uint8ArrayWriter, ZipReader } from "@zip.js/zip.js" import { pool } from "../../db" import { s3 } from "./s3" @@ -41,6 +44,34 @@ type ImportOptions = { targetTenantId?: number | null } +type BuildTenantFullExportOptions = { + includeFileContents?: boolean +} + +type TenantArchiveManifest = { + format: "fedeo.tenant-archive-export" + version: 1 + exportedAt: string + tenantId: number + tables: { name: string; rows: number; path: string }[] + files: { + id: string + path: string + archivePath: string | null + name: string | null + mimeType: string | null + size: number | null + missing?: boolean + error?: string + }[] +} + +type ArchiveExportOptions = { + filename?: string + storagePath?: string + onProgress?: (progress: { filesDone: number; filesTotal: number }) => Promise | void +} + const ENTITY_BANKACCOUNT_PLAIN_FIELDS = { iban: "__plainIban", bic: "__plainBic", @@ -196,8 +227,49 @@ const loadObjectAsBase64 = async (path: string) => { } } -export const buildTenantFullExport = async (server: FastifyInstance, tenantId: number): Promise => { +const loadObjectStream = async (path: string) => { + try { + const { Body } = await s3.send(new GetObjectCommand({ + Bucket: secrets.S3_BUCKET, + Key: path, + })) + + return { + body: Body as any, + missing: false, + error: null, + } + } catch (err: any) { + if (!isMissingObjectError(err)) throw err + + return { + body: null, + missing: true, + error: err?.Code || err?.name || "NoSuchKey", + } + } +} + +const archiveEntryPathForFile = (path: string) => { + const normalizedPath = path + .replace(/\\/g, "/") + .replace(/^\/+/, "") + .split("/") + .filter((part) => part && part !== "." && part !== "..") + .join("/") + + return `files/${normalizedPath || "unnamed-file"}` +} + +const jsonBuffer = (value: unknown) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8") + +export const buildTenantFullExport = async ( + server: FastifyInstance, + tenantId: number, + options: BuildTenantFullExportOptions = {} +): Promise => { const client = await pool.connect() + const includeFileContents = options.includeFileContents !== false try { const columnsByTable = await tableColumns(client) @@ -259,7 +331,9 @@ export const buildTenantFullExport = async (server: FastifyInstance, tenantId: n for (const file of fileRows) { if (!file.path) continue - const object = await loadObjectAsBase64(file.path) + const object = includeFileContents + ? await loadObjectAsBase64(file.path) + : { contentBase64: null, missing: false, error: null } if (object.missing) { server.log.warn({ fileId: file.id, @@ -294,6 +368,106 @@ export const buildTenantFullExport = async (server: FastifyInstance, tenantId: n } } +export const createTenantFullExportArchive = async ( + server: FastifyInstance, + tenantId: number, + options: ArchiveExportOptions = {} +) => { + const exportData = await buildTenantFullExport(server, tenantId, { includeFileContents: false }) + const safeTenantName = String(exportData.tables.tenants?.[0]?.short || exportData.tables.tenants?.[0]?.name || tenantId) + .replace(/[^a-z0-9_-]+/gi, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase() + const filename = options.filename || `fedeo-tenant-${safeTenantName || tenantId}-${new Date().toISOString().slice(0, 10)}.fedeo-export.zip` + const storagePath = options.storagePath || `tenant-exports/${tenantId}/${Date.now()}-${filename}` + const fileRows = exportData.tables.files || [] + const archive = archiver("zip", { zlib: { level: 6 } }) + const output = new PassThrough() + let size = 0 + + output.on("data", (chunk: Buffer) => { + size += chunk.length + }) + + const uploadPromise = s3.send(new PutObjectCommand({ + Bucket: secrets.S3_BUCKET, + Key: storagePath, + Body: output, + ContentType: "application/zip", + ContentDisposition: `attachment; filename="${filename}"`, + })) + + archive.pipe(output) + + const tables = Object.entries(exportData.tables) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([table, rows]) => { + const path = `tables/${table}.json` + archive.append(jsonBuffer(rows), { name: path }) + return { name: table, rows: rows.length, path } + }) + + const manifestFiles: TenantArchiveManifest["files"] = [] + let filesDone = 0 + const filesTotal = fileRows.filter((file) => file.path).length + + for (const file of fileRows) { + if (!file.path) continue + + const archivePath = archiveEntryPathForFile(file.path) + const object = await loadObjectStream(file.path) + + if (object.missing) { + server.log.warn({ + fileId: file.id, + path: file.path, + bucket: secrets.S3_BUCKET, + error: object.error, + }, "Tenant archive export skipped missing S3 object") + } else { + archive.append(object.body, { name: archivePath }) + } + + filesDone += 1 + manifestFiles.push({ + id: file.id, + path: file.path, + archivePath: object.missing ? null : archivePath, + name: file.name || null, + mimeType: file.mimeType || null, + size: file.size || null, + missing: object.missing || undefined, + error: object.error || undefined, + }) + + if (filesDone % 10 === 0 || filesDone === filesTotal) { + await options.onProgress?.({ filesDone, filesTotal }) + } + } + + const manifest: TenantArchiveManifest = { + format: "fedeo.tenant-archive-export", + version: 1, + exportedAt: exportData.exportedAt, + tenantId, + tables, + files: manifestFiles, + } + + archive.append(jsonBuffer(manifest), { name: "manifest.json" }) + await archive.finalize() + await uploadPromise + + return { + filename, + storagePath, + contentType: "application/zip", + size, + filesTotal, + filesDone, + } +} + const restoreFiles = async (exportData: TenantFullExport) => { let restored = 0 let skipped = 0 @@ -318,6 +492,56 @@ const restoreFiles = async (exportData: TenantFullExport) => { return { restored, skipped } } +const readZipTextEntry = async (entriesByName: Map, name: string) => { + const entry = entriesByName.get(name) + if (!entry) throw new Error(`${name} fehlt im Mandantenexport`) + + return entry.getData(new TextWriter()) +} + +const restoreArchiveFiles = async ( + entriesByName: Map, + exportData: TenantFullExport, + manifest: TenantArchiveManifest +) => { + let restored = 0 + let skipped = 0 + const filesByPath = new Map((manifest.files || []).map((file) => [file.path, file])) + + for (const fileRow of exportData.tables.files || []) { + const originalPath = fileRow.path + if (!originalPath) { + skipped += 1 + continue + } + + const manifestFile = filesByPath.get(originalPath) + const archivePath = manifestFile?.archivePath + if (!archivePath || manifestFile?.missing) { + skipped += 1 + continue + } + + const entry = entriesByName.get(archivePath) + if (!entry) { + skipped += 1 + continue + } + + const content = await entry.getData(new Uint8ArrayWriter()) + await s3.send(new PutObjectCommand({ + Bucket: secrets.S3_BUCKET, + Key: fileRow.path, + Body: Buffer.from(content), + ContentType: fileRow.mimeType || manifestFile.mimeType || "application/octet-stream", + ContentLength: content.length, + })) + restored += 1 + } + + return { restored, skipped } +} + const remapTenantScopedExport = ( exportData: TenantFullExport, targetTenantId?: number | null @@ -644,3 +868,70 @@ export const importTenantFullExport = async ( client.release() } } + +export const importTenantFullExportArchive = async ( + server: FastifyInstance, + archiveBuffer: Buffer, + options: ImportOptions = {} +): Promise => { + const reader = new ZipReader(new BlobReader(new Blob([archiveBuffer]))) + + try { + const entries = await reader.getEntries() + const entriesByName = new Map(entries.map((entry: any) => [entry.filename, entry])) + const manifest = JSON.parse(await readZipTextEntry(entriesByName, "manifest.json")) as TenantArchiveManifest + + if (manifest?.format !== "fedeo.tenant-archive-export" || manifest.version !== 1) { + throw new Error("Ungültiges FEDEO Mandantenarchiv-Format") + } + + const tables: TableRows = {} + for (const table of manifest.tables || []) { + tables[table.name] = JSON.parse(await readZipTextEntry(entriesByName, table.path)) + } + + const rawExportData: TenantFullExport = { + format: "fedeo.tenant-full-export", + version: 1, + exportedAt: manifest.exportedAt, + tenantId: manifest.tenantId, + tables, + files: (manifest.files || []).map((file) => ({ + id: file.id, + path: file.path, + name: file.name, + mimeType: file.mimeType, + size: file.size, + contentBase64: null, + missing: file.missing, + error: file.error, + })), + } + + const exportData = remapTenantScopedExport(rawExportData, options.targetTenantId) + const sourceTenantId = rawExportData.tenantId + const targetTenantId = exportData.tenantId + const sourcePrefix = `${sourceTenantId}/` + const targetPrefix = `${targetTenantId}/` + const remappedManifest: TenantArchiveManifest = { + ...manifest, + tenantId: targetTenantId, + files: (manifest.files || []).map((file) => ({ + ...file, + path: file.path?.startsWith(sourcePrefix) + ? `${targetPrefix}${file.path.slice(sourcePrefix.length)}` + : file.path, + })), + } + + const result = await importTenantFullExport(server, exportData, { targetTenantId: null }) + const files = await restoreArchiveFiles(entriesByName, exportData, remappedManifest) + + return { + ...result, + files, + } + } finally { + await reader.close() + } +}