@lunora/cli
Version:
The Lunora CLI: init, dev, deploy, codegen, run, reset, and migrate commands
250 lines (247 loc) • 8.66 kB
JavaScript
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 };