UNPKG

convex

Version:

Client for the Convex Cloud

516 lines (497 loc) 15.9 kB
import chalk from "chalk"; import { Command, Option } from "@commander-js/extra-typings"; import path from "path"; import { performance } from "perf_hooks"; import { OneoffCtx, logFinishedStep, logMessage, logVerbose, logWarning, oneoffContext, showSpinner, showSpinnerIfSlow, stopSpinner, } from "../bundler/context.js"; import { deploymentCredentialsOrConfigure } from "./configure.js"; import { checkAuthorization, performLogin } from "./lib/login.js"; import { PushOptions } from "./lib/push.js"; import { formatDuration, getCurrentTimeString, waitForever, waitUntilCalled, } from "./lib/utils/utils.js"; import { Crash, WatchContext, Watcher } from "./lib/watch.js"; import { watchLogs } from "./lib/logs.js"; import { subscribe } from "./lib/run.js"; import { Value } from "../values/index.js"; import { usageStateWarning } from "./lib/usage.js"; import { runPush } from "./lib/components.js"; export const dev = new Command("dev") .summary("Develop against a dev deployment, watching for changes") .description( "Develop against a dev deployment, watching for changes\n\n" + " 1. Configures a new or existing project (if needed)\n" + " 2. Updates generated types and pushes code to the configured dev deployment\n" + " 3. Runs the provided function (if `--run` is used)\n" + " 4. Watches for file changes, and repeats step 2\n", ) .option("-v, --verbose", "Show full listing of changes") .addOption( new Option( "--typecheck <mode>", `Check TypeScript files with \`tsc --noEmit\`.`, ) .choices(["enable", "try", "disable"] as const) .default("try" as const), ) .addOption( new Option("--codegen <mode>", "Regenerate code in `convex/_generated/`") .choices(["enable", "disable"] as const) .default("enable" as const), ) .addOption( new Option( "--configure [choice]", "Ignore existing configuration and configure new or existing project", ).choices(["new", "existing"] as const), ) .option("--team <team_slug>", "The team you'd like to use for this project") .option( "--project <project_slug>", "The name of the project you'd like to configure", ) .option( "--once", "Execute only the first 3 steps, stop on any failure", false, ) .option( "--until-success", "Execute only the first 3 steps, on failure watch for local and remote changes and retry steps 2 and 3", false, ) .option( "--run <functionName>", "The identifier of the function to run in step 3, " + "like `init` or `dir/file:myFunction`", ) .addOption( new Option( "--prod", "Develop live against this project's production deployment.", ) .default(false) .hideHelp(), ) .addOption( new Option( "--tail-logs", "Tail this project's Convex logs in this terminal.", ), ) .addOption(new Option("--trace-events").default(false).hideHelp()) .addOption(new Option("--verbose").default(false).hideHelp()) .addOption(new Option("--admin-key <adminKey>").hideHelp()) .addOption(new Option("--url <url>").hideHelp()) .addOption(new Option("--debug-bundle-path <path>").hideHelp()) // Options for testing .addOption(new Option("--override-auth-url <url>").hideHelp()) .addOption(new Option("--override-auth-client <id>").hideHelp()) .addOption(new Option("--override-auth-username <username>").hideHelp()) .addOption(new Option("--override-auth-password <password>").hideHelp()) .addOption( new Option("--local", "Develop live against a locally running backend.") .default(false) .conflicts(["--prod", "--url", "--admin-key"]) .hideHelp(), ) .addOption(new Option("--local-cloud-port <port>").hideHelp()) .addOption(new Option("--local-site-port <port>").hideHelp()) .addOption(new Option("--local-backend-version <version>").hideHelp()) .addOption(new Option("--local-force-upgrade").default(false).hideHelp()) .showHelpAfterError() .action(async (cmdOptions) => { const ctx = oneoffContext; if (cmdOptions.debugBundlePath !== undefined && !cmdOptions.once) { return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "`--debug-bundle-path` can only be used with `--once`.", }); } const localOptions: { ports?: { cloud: number; site: number }; backendVersion?: string | undefined; forceUpgrade: boolean; } = { forceUpgrade: false }; if (!cmdOptions.local) { if ( cmdOptions.localCloudPort !== undefined || cmdOptions.localSitePort !== undefined || cmdOptions.localBackendVersion !== undefined || cmdOptions.localForceUpgrade === true ) { return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "`--local-*` options can only be used with `--local`.", }); } } else { if (cmdOptions.localCloudPort !== undefined) { if (cmdOptions.localSitePort === undefined) { return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "`--local-cloud-port` requires `--local-site-port` to be set.", }); } localOptions["ports"] = { cloud: parseInt(cmdOptions.localCloudPort), site: parseInt(cmdOptions.localSitePort), }; } localOptions["backendVersion"] = cmdOptions.localBackendVersion; localOptions["forceUpgrade"] = cmdOptions.localForceUpgrade; } if (!cmdOptions.url || !cmdOptions.adminKey) { if (!(await checkAuthorization(ctx, false))) { await performLogin(ctx, cmdOptions); } } const configure = cmdOptions.configure === true ? "ask" : cmdOptions.configure ?? null; const credentials = await deploymentCredentialsOrConfigure(ctx, configure, { ...cmdOptions, localOptions, }); let cleanupHandle = credentials.cleanupHandle; process.on("SIGINT", async () => { logVerbose(ctx, "Received SIGINT, cleaning up..."); if (cleanupHandle !== null) { logVerbose(ctx, "About to run cleanup handle."); // Sometimes `SIGINT` gets sent twice, so set `cleanupHandle` to null to prevent double-cleanup const f = cleanupHandle; cleanupHandle = null; await f(); } logVerbose(ctx, "Cleaned up. Exiting."); process.exit(-2); }); await usageStateWarning(ctx); const promises = []; if (cmdOptions.tailLogs) { promises.push( watchLogs(ctx, credentials.url, credentials.adminKey, "stderr"), ); } promises.push( watchAndPush( ctx, { ...credentials, verbose: !!cmdOptions.verbose, dryRun: false, typecheck: cmdOptions.typecheck, debug: false, debugBundlePath: cmdOptions.debugBundlePath, codegen: cmdOptions.codegen === "enable", }, cmdOptions, ), ); await Promise.race(promises); }); export async function watchAndPush( outerCtx: OneoffCtx, options: PushOptions, cmdOptions: { once: boolean; untilSuccess: boolean; traceEvents: boolean; }, ) { const watch: { watcher: Watcher | undefined } = { watcher: undefined }; let numFailures = 0; let pushed = false; let tableNameTriggeringRetry; let shouldRetryOnDeploymentEnvVarChange; // eslint-disable-next-line no-constant-condition while (true) { const start = performance.now(); tableNameTriggeringRetry = null; shouldRetryOnDeploymentEnvVarChange = false; const ctx = new WatchContext(cmdOptions.traceEvents); showSpinner(ctx, "Preparing Convex functions..."); try { await runPush(ctx, options); const end = performance.now(); numFailures = 0; logFinishedStep( ctx, `${getCurrentTimeString()} Convex functions ready! (${formatDuration( end - start, )})`, ); pushed = true; } catch (e: any) { // Crash the app on unexpected errors. if (!(e instanceof Crash) || !e.errorType) { // eslint-disable-next-line no-restricted-syntax throw e; } if (e.errorType === "fatal") { break; } // Retry after an exponential backoff if we hit a transient error. if (e.errorType === "transient") { const delay = nextBackoff(numFailures); numFailures += 1; logWarning( ctx, chalk.yellow( `Failed due to network error, retrying in ${formatDuration( delay, )}...`, ), ); await new Promise((resolve) => setTimeout(resolve, delay)); continue; } // Fall through if we had a filesystem-based error. console.assert( e.errorType === "invalid filesystem data" || e.errorType === "invalid filesystem or env vars" || e.errorType["invalid filesystem or db data"] !== undefined, ); if (e.errorType === "invalid filesystem or env vars") { shouldRetryOnDeploymentEnvVarChange = true; } else if ( e.errorType !== "invalid filesystem data" && e.errorType["invalid filesystem or db data"] !== undefined ) { tableNameTriggeringRetry = e.errorType["invalid filesystem or db data"]; } if (cmdOptions.once) { await outerCtx.flushAndExit(1, e.errorType); } // Make sure that we don't spin if this push failed // in any edge cases that didn't call `logFailure` // before throwing. stopSpinner(ctx); } if (cmdOptions.once) { if (options.cleanupHandle !== null) { await options.cleanupHandle(); } return; } if (pushed && cmdOptions.untilSuccess) { if (options.cleanupHandle !== null) { await options.cleanupHandle(); } return; } const fileSystemWatch = getFileSystemWatch(ctx, watch, cmdOptions); const tableWatch = getTableWatch(ctx, options, tableNameTriggeringRetry); const envVarWatch = getDeplymentEnvVarWatch( ctx, options, shouldRetryOnDeploymentEnvVarChange, ); await Promise.race([ fileSystemWatch.watch(), tableWatch.watch(), envVarWatch.watch(), ]); fileSystemWatch.stop(); void tableWatch.stop(); void envVarWatch.stop(); } } function getTableWatch( ctx: WatchContext, credentials: { url: string; adminKey: string; }, tableName: string | null, ) { return getFunctionWatch(ctx, credentials, "_system/cli/queryTable", () => tableName !== null ? { tableName } : null, ); } function getDeplymentEnvVarWatch( ctx: WatchContext, credentials: { url: string; adminKey: string; }, shouldRetryOnDeploymentEnvVarChange: boolean, ) { return getFunctionWatch( ctx, credentials, "_system/cli/queryEnvironmentVariables", () => (shouldRetryOnDeploymentEnvVarChange ? {} : null), ); } function getFunctionWatch( ctx: WatchContext, credentials: { url: string; adminKey: string; }, functionName: string, getArgs: () => Record<string, Value> | null, ) { const [stopPromise, stop] = waitUntilCalled(); return { watch: async () => { const args = getArgs(); if (args === null) { return waitForever(); } let changes = 0; return subscribe( ctx, credentials.url, credentials.adminKey, functionName, args, stopPromise, { onChange: () => { changes++; // First bump is just the initial results reporting if (changes > 1) { stop(); } }, }, ); }, stop: () => { stop(); }, }; } function getFileSystemWatch( ctx: WatchContext, watch: { watcher: Watcher | undefined }, cmdOptions: { traceEvents: boolean }, ) { let hasStopped = false; return { watch: async () => { const observations = ctx.fs.finalize(); if (observations === "invalidated") { logMessage(ctx, "Filesystem changed during push, retrying..."); return; } // Initialize the watcher if we haven't done it already. Chokidar expects to have a // nonempty watch set at initialization, so we can't do it before running our first // push. if (!watch.watcher) { watch.watcher = new Watcher(observations); await showSpinnerIfSlow( ctx, "Preparing to watch files...", 500, async () => { await watch.watcher!.ready(); }, ); stopSpinner(ctx); } // Watch new directories if needed. watch.watcher.update(observations); // Process events until we find one that overlaps with our previous observations. let anyChanges = false; do { await watch.watcher.waitForEvent(); if (hasStopped) { return; } for (const event of watch.watcher.drainEvents()) { if (cmdOptions.traceEvents) { logMessage( ctx, "Processing", event.name, path.relative("", event.absPath), ); } const result = observations.overlaps(event); if (result.overlaps) { const relPath = path.relative("", event.absPath); if (cmdOptions.traceEvents) { logMessage(ctx, `${relPath} ${result.reason}, rebuilding...`); } anyChanges = true; break; } } } while (!anyChanges); // Wait for the filesystem to quiesce before starting a new push. It's okay to // drop filesystem events at this stage since we're already committed to doing // a push and resubscribing based on that push's observations. let deadline = performance.now() + quiescenceDelay; // eslint-disable-next-line no-constant-condition while (true) { const now = performance.now(); if (now >= deadline) { break; } const remaining = deadline - now; if (cmdOptions.traceEvents) { logMessage( ctx, `Waiting for ${formatDuration(remaining)} to quiesce...`, ); } const remainingWait = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), deadline - now), ); const result = await Promise.race([ remainingWait, watch.watcher.waitForEvent().then<"newEvents">(() => "newEvents"), ]); if (result === "newEvents") { for (const event of watch.watcher.drainEvents()) { const result = observations.overlaps(event); // Delay another `quiescenceDelay` since we had an overlapping event. if (result.overlaps) { if (cmdOptions.traceEvents) { logMessage( ctx, `Received an overlapping event at ${event.absPath}, delaying push.`, ); } deadline = performance.now() + quiescenceDelay; } } } else { console.assert(result === "timeout"); // Let the check above `break` from the loop if we're past our deadlne. } } }, stop: () => { hasStopped = true; }, }; } const initialBackoff = 500; const maxBackoff = 16000; const quiescenceDelay = 500; export function nextBackoff(prevFailures: number): number { const baseBackoff = initialBackoff * Math.pow(2, prevFailures); const actualBackoff = Math.min(baseBackoff, maxBackoff); const jitter = actualBackoff * (Math.random() - 0.5); return actualBackoff + jitter; }