convex
Version:
Client for the Convex Cloud
521 lines (496 loc) • 15.7 kB
text/typescript
import chalk from "chalk";
import {
ensureHasConvexDependency,
formatSize,
waitUntilCalled,
deploymentFetch,
logAndHandleFetchError,
} from "./lib/utils/utils.js";
import {
logFailure,
oneoffContext,
Context,
showSpinner,
logFinishedStep,
logWarning,
logMessage,
stopSpinner,
changeSpinner,
} from "../bundler/context.js";
import {
fetchDeploymentCredentialsProvisionProd,
deploymentSelectionFromOptions,
} from "./lib/api.js";
import path from "path";
import { subscribe } from "./lib/run.js";
import { Command, Option } from "@commander-js/extra-typings";
import { actionDescription } from "./lib/command.js";
import { ConvexHttpClient } from "../browser/http_client.js";
import { makeFunctionReference } from "../server/index.js";
import { deploymentDashboardUrlPage } from "./dashboard.js";
import { promptYesNo } from "./lib/utils/prompts.js";
// Backend has minimum chunk size of 5MiB except for the last chunk,
// so we use 5MiB as highWaterMark which makes fs.ReadStream[asyncIterator]
// output 5MiB chunks before the last one.
const CHUNK_SIZE = 5 * 1024 * 1024;
export const convexImport = new Command("import")
.summary("Import data from a file to your deployment")
.description(
"Import data from a file to your Convex deployment.\n\n" +
" From a snapshot: `npx convex import snapshot.zip`\n" +
" For a single table: `npx convex import --table tableName file.json`\n\n" +
"By default, this imports into your dev deployment.",
)
.addOption(
new Option(
"--table <table>",
"Destination table name. Required if format is csv, jsonLines, or jsonArray. Not supported if format is zip.",
),
)
.addOption(
new Option(
"--replace",
"Replace all existing data in any of the imported tables",
).conflicts("--append"),
)
.addOption(
new Option(
"--append",
"Append imported data to any existing tables",
).conflicts("--replace"),
)
.option(
"-y, --yes",
"Skip confirmation prompt when import leads to deleting existing documents",
)
.addOption(
new Option(
"--format <format>",
"Input file format. This flag is only required if the filename is missing an extension.\n" +
"- CSV files must have a header, and each row's entries are interpreted either as a (floating point) number or a string.\n" +
"- JSON files must be an array of JSON objects.\n" +
"- JSONLines files must have a JSON object per line.\n" +
"- ZIP files must have one directory per table, containing <table>/documents.jsonl. Snapshot exports from the Convex dashboard have this format.",
).choices(["csv", "jsonLines", "jsonArray", "zip"]),
)
.addDeploymentSelectionOptions(actionDescription("Import data into"))
.argument("<path>", "Path to the input file")
.showHelpAfterError()
.action(async (filePath, options, command) => {
const ctx = oneoffContext;
if (command.args.length > 1) {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: `Error: Too many positional arguments. If you're specifying a table name, use the \`--table\` option.`,
});
}
const deploymentSelection = deploymentSelectionFromOptions(options);
const {
adminKey,
url: deploymentUrl,
deploymentName,
} = await fetchDeploymentCredentialsProvisionProd(ctx, deploymentSelection);
if (!ctx.fs.exists(filePath)) {
return await ctx.crash({
exitCode: 1,
errorType: "invalid filesystem data",
printedMessage: `Error: Path ${chalk.bold(filePath)} does not exist.`,
});
}
const format = await determineFormat(ctx, filePath, options.format ?? null);
const tableName = options.table ?? null;
if (tableName === null) {
if (format !== "zip") {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: `Error: The \`--table\` option is required for format ${format}`,
});
}
} else {
if (format === "zip") {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: `Error: The \`--table\` option is not allowed for format ${format}`,
});
}
}
await ensureHasConvexDependency(ctx, "import");
const convexClient = new ConvexHttpClient(deploymentUrl);
convexClient.setAdminAuth(adminKey);
const existingImports = await convexClient.query(
makeFunctionReference<"query", Record<string, never>, Array<unknown>>(
"_system/cli/queryImport:list",
),
{},
);
const ongoingImports = existingImports.filter(
(i) => (i as any).state.state === "in_progress",
);
if (ongoingImports.length > 0) {
await askToConfirmImportWithExistingImports(
ctx,
deploymentName,
options.yes,
);
}
const fileStats = ctx.fs.stat(filePath);
showSpinner(ctx, `Importing ${filePath} (${formatSize(fileStats.size)})`);
let mode = "requireEmpty";
if (options.append) {
mode = "append";
} else if (options.replace) {
mode = "replace";
}
const importArgs = {
tableName: tableName === null ? undefined : tableName,
mode,
format,
};
const deploymentNotice = options.prod
? ` in your ${chalk.bold("prod")} deployment`
: "";
const tableNotice = tableName ? ` to table "${chalk.bold(tableName)}"` : "";
const onFailure = async () => {
logFailure(
ctx,
`Importing data from "${chalk.bold(
filePath,
)}"${tableNotice}${deploymentNotice} failed`,
);
};
const importId = await uploadForImport(ctx, {
deploymentUrl,
adminKey,
filePath,
importArgs,
onImportFailed: onFailure,
});
changeSpinner(ctx, "Parsing uploaded data");
const onProgress = (
ctx: Context,
state: InProgressImportState,
checkpointCount: number,
) => {
stopSpinner(ctx);
while ((state.checkpoint_messages?.length ?? 0) > checkpointCount) {
logFinishedStep(ctx, state.checkpoint_messages![checkpointCount]);
checkpointCount += 1;
}
showSpinner(ctx, state.progress_message ?? "Importing");
return checkpointCount;
};
// eslint-disable-next-line no-constant-condition
while (true) {
const snapshotImportState = await waitForStableImportState(ctx, {
importId,
deploymentUrl,
adminKey,
onProgress,
});
switch (snapshotImportState.state) {
case "completed":
logFinishedStep(
ctx,
`Added ${snapshotImportState.num_rows_written} documents${tableNotice}${deploymentNotice}.`,
);
return;
case "failed":
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: `Importing data from "${chalk.bold(
filePath,
)}"${tableNotice}${deploymentNotice} failed\n\n${chalk.red(snapshotImportState.error_message)}`,
});
case "waiting_for_confirmation": {
// Clear spinner state so we can log and prompt without clobbering lines.
stopSpinner(ctx);
await askToConfirmImport(
ctx,
snapshotImportState.message_to_confirm,
snapshotImportState.require_manual_confirmation,
options.yes,
);
showSpinner(ctx, `Importing`);
await confirmImport(ctx, {
importId,
adminKey,
deploymentUrl,
onError: async () => {
logFailure(
ctx,
`Importing data from "${chalk.bold(
filePath,
)}"${tableNotice}${deploymentNotice} failed`,
);
},
});
// Now we have kicked off the rest of the import, go around the loop again.
break;
}
case "uploaded": {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: `Import canceled while parsing uploaded file`,
});
}
case "in_progress": {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: `WARNING: Import is continuing to run on the server. Visit ${snapshotImportDashboardLink(deploymentName)} to monitor its progress.`,
});
}
default: {
const _: never = snapshotImportState;
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: `unknown error: unexpected state ${snapshotImportState as any}`,
errForSentry: `unexpected snapshot import state ${(snapshotImportState as any).state}`,
});
}
}
}
});
async function askToConfirmImport(
ctx: Context,
messageToConfirm: string | undefined,
requireManualConfirmation: boolean | undefined,
yes: boolean | undefined,
) {
if (!messageToConfirm?.length) {
return;
}
logMessage(ctx, messageToConfirm);
if (requireManualConfirmation !== false && !yes) {
const confirmed = await promptYesNo(ctx, {
message: "Perform import?",
default: true,
});
if (!confirmed) {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: "Import canceled",
});
}
}
}
function snapshotImportDashboardLink(deploymentName: string | undefined) {
return deploymentName === undefined
? "https://dashboard.convex.dev/d/settings/snapshot-export"
: deploymentDashboardUrlPage(deploymentName, "/settings/snapshot-export");
}
async function askToConfirmImportWithExistingImports(
ctx: Context,
deploymentName: string | undefined,
yes: boolean | undefined,
) {
logMessage(
ctx,
`There is already a snapshot import in progress. You can view its progress at ${snapshotImportDashboardLink(deploymentName)}.`,
);
if (yes) {
return;
}
const confirmed = await promptYesNo(ctx, {
message: "Start another import?",
default: true,
});
if (!confirmed) {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: "Import canceled",
});
}
}
type InProgressImportState = {
state: "in_progress";
progress_message?: string | undefined;
checkpoint_messages?: string[] | undefined;
};
type SnapshotImportState =
| { state: "uploaded" }
| {
state: "waiting_for_confirmation";
message_to_confirm?: string;
require_manual_confirmation?: boolean;
}
| InProgressImportState
| { state: "completed"; num_rows_written: bigint }
| { state: "failed"; error_message: string };
export async function waitForStableImportState(
ctx: Context,
args: {
importId: string;
deploymentUrl: string;
adminKey: string;
onProgress: (
ctx: Context,
state: InProgressImportState,
checkpointCount: number,
) => number;
},
): Promise<SnapshotImportState> {
const { importId, deploymentUrl, adminKey, onProgress } = args;
const [donePromise, onDone] = waitUntilCalled();
let snapshotImportState: SnapshotImportState;
let checkpointCount = 0;
await subscribe(
ctx,
deploymentUrl,
adminKey,
"_system/cli/queryImport",
{ importId },
donePromise,
{
onChange: (value: any) => {
snapshotImportState = value.state;
switch (snapshotImportState.state) {
case "waiting_for_confirmation":
case "completed":
case "failed":
onDone();
break;
case "uploaded":
// Not a stable state. Ignore while the server continues working.
return;
case "in_progress":
// Not a stable state. Ignore while the server continues working.
checkpointCount = onProgress(
ctx,
snapshotImportState,
checkpointCount,
);
return;
}
},
},
);
return snapshotImportState!;
}
async function determineFormat(
ctx: Context,
filePath: string,
format: string | null,
) {
const fileExtension = path.extname(filePath);
if (fileExtension !== "") {
const formatToExtension: Record<string, string> = {
csv: ".csv",
jsonLines: ".jsonl",
jsonArray: ".json",
zip: ".zip",
};
const extensionToFormat = Object.fromEntries(
Object.entries(formatToExtension).map((a) => a.reverse()),
);
if (format !== null && fileExtension !== formatToExtension[format]) {
logWarning(
ctx,
chalk.yellow(
`Warning: Extension of file ${filePath} (${fileExtension}) does not match specified format: ${format} (${formatToExtension[format]}).`,
),
);
}
format ??= extensionToFormat[fileExtension] ?? null;
}
if (format === null) {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage:
"No input file format inferred by the filename extension or specified. Specify your input file's format using the `--format` flag.",
});
}
return format;
}
export async function confirmImport(
ctx: Context,
args: {
importId: string;
adminKey: string;
deploymentUrl: string;
onError: (e: any) => Promise<void>;
},
) {
const { importId, adminKey, deploymentUrl } = args;
const fetch = deploymentFetch(deploymentUrl, adminKey);
const performUrl = `/api/perform_import`;
try {
await fetch(performUrl, {
method: "POST",
body: JSON.stringify({ importId }),
});
} catch (e) {
await args.onError(e);
return await logAndHandleFetchError(ctx, e);
}
}
export async function uploadForImport(
ctx: Context,
args: {
deploymentUrl: string;
adminKey: string;
filePath: string;
importArgs: { tableName?: string; mode: string; format: string };
onImportFailed: (e: any) => Promise<void>;
},
) {
const { deploymentUrl, adminKey, filePath } = args;
const fetch = deploymentFetch(deploymentUrl, adminKey);
const data = ctx.fs.createReadStream(filePath, {
highWaterMark: CHUNK_SIZE,
});
const fileStats = ctx.fs.stat(filePath);
showSpinner(ctx, `Importing ${filePath} (${formatSize(fileStats.size)})`);
let importId: string;
try {
const startResp = await fetch("/api/import/start_upload", {
method: "POST",
});
const { uploadToken } = await startResp.json();
const partTokens = [];
let partNumber = 1;
for await (const chunk of data) {
const partUrl = `/api/import/upload_part?uploadToken=${encodeURIComponent(
uploadToken,
)}&partNumber=${partNumber}`;
const partResp = await fetch(partUrl, {
headers: {
"Content-Type": "application/octet-stream",
},
body: chunk,
method: "POST",
});
partTokens.push(await partResp.json());
partNumber += 1;
changeSpinner(
ctx,
`Uploading ${filePath} (${formatSize(data.bytesRead)}/${formatSize(
fileStats.size,
)})`,
);
}
const finishResp = await fetch("/api/import/finish_upload", {
body: JSON.stringify({
import: args.importArgs,
uploadToken,
partTokens,
}),
method: "POST",
});
const body = await finishResp.json();
importId = body.importId;
} catch (e) {
await args.onImportFailed(e);
return await logAndHandleFetchError(ctx, e);
}
return importId;
}