UNPKG

@lunora/cli

Version:

The Lunora CLI: init, dev, deploy, codegen, run, reset, and migrate commands

250 lines (247 loc) 8.66 kB
import { createWriteStream, createReadStream } from 'node:fs'; import { unlink, stat } from 'node:fs/promises'; import { LunoraError } from '@lunora/errors'; import { r as resolveAdminBaseUrl } from './admin-url-4UzT-CI4.mjs'; const EXPORT_ENDPOINT_PATH = "/_lunora/admin/export"; const IMPORT_ENDPOINT_PATH = "/_lunora/admin/import"; const DEFAULT_IMPORT_BATCH_SIZE = 500; const resolveTables = (raw) => { if (raw === void 0) { return void 0; } const tables = raw.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0); return tables.length > 0 ? tables : void 0; }; const writeWithBackpressure = async (sink, line) => { if (!sink.write(line)) { await new Promise((resolve) => { sink.once("drain", resolve); }); } }; const streamNdjsonToSink = async (body, sink) => { const reader = body.getReader(); const decoder = new TextDecoder(); let bytes = 0; let rows = 0; let leftover = ""; let done = false; try { while (!done) { const read = await reader.read(); done = read.done; if (read.value === void 0) { continue; } bytes += read.value.length; leftover += decoder.decode(read.value, { stream: true }); let newlineIndex = leftover.indexOf("\n"); while (newlineIndex !== -1) { rows += 1; const line = `${leftover.slice(0, newlineIndex)} `; await writeWithBackpressure(sink, line); leftover = leftover.slice(newlineIndex + 1); newlineIndex = leftover.indexOf("\n"); } } if (leftover.length > 0) { rows += 1; await writeWithBackpressure(sink, `${leftover} `); } return { bytes, rows }; } finally { reader.releaseLock(); } }; const discardPartialExport = async (sink, outPath) => { if (outPath === void 0) { return; } sink.destroy(); try { await unlink(outPath); } catch { } }; const runExportCommand = async (options) => { if (options.prod && options.url === void 0) { options.logger.error("--prod requires an explicit --url (refusing to export from the implicit localhost worker)"); return { bytes: 0, code: 1, rows: 0 }; } const token = options.token ?? process.env["LUNORA_ADMIN_TOKEN"]; if (!token) { options.logger.error("admin token required — pass --token or set LUNORA_ADMIN_TOKEN"); return { bytes: 0, code: 1, rows: 0 }; } const baseUrl = resolveAdminBaseUrl(options.url, options.logger); if (baseUrl === void 0) { return { bytes: 0, code: 1, rows: 0 }; } const requestUrl = `${baseUrl}${EXPORT_ENDPOINT_PATH}`; const tables = resolveTables(options.tables); const fetchImpl = options.fetchImpl ?? globalThis.fetch; if (typeof fetchImpl !== "function") { throw new TypeError("no fetch implementation available — pass fetchImpl or run on Node >= 18"); } options.logger.info(`POST ${requestUrl} -> export${tables ? ` (tables: ${tables.join(",")})` : ""}`); const response = await fetchImpl(requestUrl, { body: JSON.stringify(tables ? { tables } : {}), headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, method: "POST" }); if (!response.ok) { const errorText = await response.text(); options.logger.error(`export failed: HTTP ${String(response.status)}: ${errorText}`); return { bytes: 0, code: 1, rows: 0 }; } if (!response.body) { options.logger.error("export response carried no body"); return { bytes: 0, code: 1, rows: 0 }; } const outPath = options.out === void 0 || options.out === "-" ? void 0 : options.out; const sink = outPath === void 0 ? process.stdout : createWriteStream(outPath, { encoding: "utf8" }); let bytes; let rows; try { ({ bytes, rows } = await streamNdjsonToSink(response.body, sink)); } catch (error) { await discardPartialExport(sink, outPath); throw error; } if (outPath !== void 0) { await new Promise((resolve, reject) => { sink.end((error) => { if (error) { reject(error); } else { resolve(); } }); }); options.logger.success(`wrote ${String(rows)} rows to ${outPath} (${String(bytes)} bytes)`); } return { bytes, code: 0, rows }; }; const resolveImportRequest = async (options) => { if (options.prod && options.url === void 0) { options.logger.error("--prod requires an explicit --url (refusing to import to the implicit localhost worker)"); return void 0; } if (options.prod && options.yes !== true) { options.logger.error("import --prod bulk-writes production. Re-run with --yes to confirm."); return void 0; } const token = options.token ?? process.env["LUNORA_ADMIN_TOKEN"]; if (!token) { options.logger.error("admin token required — pass --token or set LUNORA_ADMIN_TOKEN"); return void 0; } try { const stats = await stat(options.file); if (!stats.isFile()) { options.logger.error(`not a file: ${options.file}`); return void 0; } } catch (error) { const message = error instanceof Error ? error.message : String(error); options.logger.error(`failed to stat ${options.file}: ${message}`); return void 0; } const baseUrl = resolveAdminBaseUrl(options.url, options.logger); if (baseUrl === void 0) { return void 0; } const fetchImpl = options.fetchImpl ?? globalThis.fetch; if (typeof fetchImpl !== "function") { throw new TypeError("no fetch implementation available — pass fetchImpl or run on Node >= 18"); } return { fetchImpl, requestUrl: `${baseUrl}${IMPORT_ENDPOINT_PATH}`, token }; }; const runImportCommand = async (options) => { const request = await resolveImportRequest(options); if (request === void 0) { return { body: void 0, code: 1, inserted: 0 }; } const { fetchImpl, requestUrl, token } = request; const batchSize = options.batchSize ?? DEFAULT_IMPORT_BATCH_SIZE; options.logger.info(`POST ${requestUrl} -> import ${options.file}`); const stream = createReadStream(options.file, { encoding: "utf8" }); const inserted = {}; const errors = []; let conflicts = 0; let buffer = ""; let batch = []; let lineNumber = 0; const flush = async () => { if (batch.length === 0) { return; } const body2 = batch.join("\n"); batch = []; const response = await fetchImpl(requestUrl, { body: body2, headers: { authorization: `Bearer ${token}`, "content-type": "application/x-ndjson" }, method: "POST" }); if (!response.ok) { const text = await response.text().catch(() => "<no body>"); throw new LunoraError("INTERNAL", `import batch failed (HTTP ${String(response.status)}): ${text}`); } const json = await response.json(); if (json.inserted) { for (const [table, count] of Object.entries(json.inserted)) { inserted[table] = (inserted[table] ?? 0) + count; } } if (Array.isArray(json.errors)) { errors.push(...json.errors); } if (typeof json.conflicts === "number") { conflicts += json.conflicts; } }; const processLine = (line) => { const trimmed = line.trim(); if (trimmed.length === 0) { return; } lineNumber += 1; if (options.table === void 0) { batch.push(trimmed); return; } let parsedDocument; try { parsedDocument = JSON.parse(trimmed); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new LunoraError("INTERNAL", `invalid JSON on line ${String(lineNumber)}: ${message}`, { cause: error }); } batch.push(JSON.stringify({ doc: parsedDocument, table: options.table })); }; for await (const chunk of stream) { const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); buffer += text; let newlineIndex = buffer.indexOf("\n"); while (newlineIndex !== -1) { processLine(buffer.slice(0, newlineIndex)); buffer = buffer.slice(newlineIndex + 1); newlineIndex = buffer.indexOf("\n"); if (batch.length >= batchSize) { await flush(); } } } if (buffer.length > 0) { processLine(buffer); } await flush(); const insertedTotal = Object.values(inserted).reduce((a, b) => a + b, 0); const body = { conflicts, errors, inserted }; options.logger.info(JSON.stringify(body, void 0, 2)); options.logger.success(`imported ${String(insertedTotal)} rows (${String(conflicts)} conflicts, ${String(errors.length)} errors)`); return { body, code: errors.length > 0 ? 1 : 0, inserted: insertedTotal }; }; export { DEFAULT_IMPORT_BATCH_SIZE, runExportCommand, runImportCommand };