UNPKG

convex

Version:

Client for the Convex Cloud

391 lines (354 loc) 11.9 kB
/* ~/.cache/convex binaries 0.0.1 convex-local-backend[.exe] // convex-local-backend.exe on windows 0.0.2 convex-local-backend[.exe] dashboard config.json out // if present, output files from building the self-hosted dashboard which can // be served using `npx serve` index.html Deployment state storage locations: New default (project-local): .convex/local/default/ - Used for both "local" (linked to Convex project) and "anonymous" deployments - One deployment per project/worktree/clone - This allows worktrees/clones to have isolated storage without conflicts Legacy (home directory) - used for backward compatibility if data already exists: - For "local" deployments: ~/.convex/convex-backend-state/local-{team}-{project}/ - For "anonymous" deployments: ~/.convex/anonymous-convex-backend-state/{anonymous-deployment-name}/ ~/.convex convex-backend-state local-my_team-chess config.json // contains `LocalDeploymentConfig` convex_local_storage convex_local_backend.sqlite3 local-my_team-whisper config.json convex_local_storage convex_local_backend.sqlite3 anonymous-convex-backend-state config.json // contains { uuid: <uuid> }, used to identify the anonymous user anonymous-chess config.json convex_local_storage convex_local_backend.sqlite3 */ import path from "path"; import { cacheDir, rootDirectory } from "../utils/utils.js"; import { Context } from "../../../bundler/context.js"; import { logVerbose } from "../../../bundler/log.js"; import { recursivelyDelete } from "../fsUtils.js"; import crypto from "crypto"; /** * Ensure the `.convex/.gitignore` file exists with the right content. * This prevents local deployment state from being committed to git. */ export function ensureDotConvexGitignore( ctx: Context, projectDir?: string, ): void { const baseDir = projectDir ?? process.cwd(); const dotConvexDir = path.join(baseDir, ".convex"); const gitignorePath = path.join(dotConvexDir, ".gitignore"); // Only create if .convex directory exists but .gitignore doesn't if (ctx.fs.exists(dotConvexDir) && !ctx.fs.exists(gitignorePath)) { ctx.fs.writeUtf8File(gitignorePath, "/*\n"); logVerbose(`Created .convex/.gitignore to ignore local/`); } } // Naming is hard, but "local" refers to deployments linked to a Convex project // and "anonymous" refers to deployments that are not linked to a Convex project // (but in both cases they are running locally). export type LocalDeploymentKind = "local" | "anonymous"; export function rootDeploymentStateDir(kind: LocalDeploymentKind) { return path.join( rootDirectory(), kind === "local" ? "convex-backend-state" : "anonymous-convex-backend-state", ); } /** * Get the project-local state directory for a deployment. * Always returns `.convex/local/default/` - one deployment per project. */ export function projectLocalStateDir(projectDir?: string): string { const baseDir = projectDir ?? process.cwd(); return path.join(baseDir, ".convex", "local", "default"); } /** * Get the legacy home directory state path for a deployment. */ export function legacyDeploymentStateDir( deploymentKind: LocalDeploymentKind, deploymentName: string, ): string { return path.join(rootDeploymentStateDir(deploymentKind), deploymentName); } /** * Get the state directory for a deployment. * * Priority order: * 1. Project-local directory if it has data (config.json exists) * 2. Legacy home directory if it exists (backward compatibility) * 3. Project-local directory for new deployments * * This ensures that when project-local storage is in use, it takes precedence * over any legacy directories that might exist with the same deployment name. */ export function deploymentStateDir( ctx: Context, deploymentKind: LocalDeploymentKind, deploymentName: string, projectDir?: string, ): string { // Check if project-local has data - if so, use it // This takes precedence over legacy to support switching deployment types // within the same project directory const localDir = projectLocalStateDir(projectDir); const localConfigFile = path.join(localDir, "config.json"); if (ctx.fs.exists(localConfigFile)) { logVerbose( `Using project-local state for deployment ${deploymentName}: ${localDir}`, ); return localDir; } // Check if legacy data exists in home directory const legacyDir = legacyDeploymentStateDir(deploymentKind, deploymentName); if (ctx.fs.exists(legacyDir) && ctx.fs.stat(legacyDir).isDirectory()) { logVerbose( `Using legacy home directory state for deployment ${deploymentName}: ${legacyDir}`, ); return legacyDir; } // Default to project-local for new deployments logVerbose( `Using project-local state for new deployment ${deploymentName}: ${localDir}`, ); return localDir; } /** * Get the state directory for a deployment without checking for legacy data. * This always returns the project-local path. */ export function deploymentStateDirUnchecked(projectDir?: string): string { return projectLocalStateDir(projectDir); } export type LocalDeploymentConfig = { ports: { cloud: number; site: number; }; backendVersion: string; adminKey: string; // If not present, use the default instance secret for local backends instanceSecret?: string; // The deployment name (e.g., "local-my_team-my_project" or "anonymous-chess") // This is stored in the config for project-local storage where the directory // name is always "default" rather than the deployment name. deploymentName?: string; }; /** * Load deployment config from a specific directory. * This is used when we already know the directory path. */ export function loadDeploymentConfigFromDir( ctx: Context, dir: string, ): LocalDeploymentConfig | null { const configFile = path.join(dir, "config.json"); if (!ctx.fs.exists(configFile)) { return null; } const content = ctx.fs.readUtf8File(configFile); try { return JSON.parse(content); } catch (e) { logVerbose( `Failed to parse local deployment config at ${dir}: ${e as any}`, ); return null; } } /** * Load the project-local deployment config. * This returns the config from `.convex/local/default/` if it exists. * Returns both the config and the deployment name stored in it. */ export function loadProjectLocalConfig( ctx: Context, projectDir?: string, ): { deploymentName: string; config: LocalDeploymentConfig } | null { const localDir = projectLocalStateDir(projectDir); const config = loadDeploymentConfigFromDir(ctx, localDir); if (config !== null && config.deploymentName) { logVerbose( `Found project-local deployment config for ${config.deploymentName}`, ); return { deploymentName: config.deploymentName, config }; } return null; } /** * Load deployment config for a deployment. * * Priority order (matching deploymentStateDir): * 1. Project-local directory if it has a matching config * 2. Legacy home directory */ export function loadDeploymentConfig( ctx: Context, deploymentKind: LocalDeploymentKind, deploymentName: string, projectDir?: string, ): LocalDeploymentConfig | null { // Check project-local location first - matches deploymentStateDir priority const localDir = projectLocalStateDir(projectDir); const localConfig = loadDeploymentConfigFromDir(ctx, localDir); if (localConfig !== null) { // Only use if config matches the requested deployment name // (project-local can hold different deployments at different times) if ( !localConfig.deploymentName || localConfig.deploymentName === deploymentName ) { logVerbose( `Found deployment config in project-local location for ${deploymentName}`, ); return localConfig; } logVerbose( `Project-local config is for ${localConfig.deploymentName}, not ${deploymentName}`, ); } // Check legacy location const legacyDir = legacyDeploymentStateDir(deploymentKind, deploymentName); const legacyConfig = loadDeploymentConfigFromDir(ctx, legacyDir); if (legacyConfig !== null) { logVerbose( `Found deployment config in legacy location for ${deploymentName}`, ); return legacyConfig; } return null; } /** * Save deployment config. * * If data already exists in the legacy home directory, continue using that * location. Otherwise, use the project-local directory. The deployment name * is always stored in the config for project-local storage. */ export function saveDeploymentConfig( ctx: Context, deploymentKind: LocalDeploymentKind, deploymentName: string, config: LocalDeploymentConfig, projectDir?: string, ) { const dir = deploymentStateDir( ctx, deploymentKind, deploymentName, projectDir, ); const configFile = path.join(dir, "config.json"); if (!ctx.fs.exists(dir)) { ctx.fs.mkdir(dir, { recursive: true }); } // Ensure .gitignore exists to prevent local state from being committed ensureDotConvexGitignore(ctx, projectDir); // Always include the deployment name in the config for project-local storage const configWithName = { ...config, deploymentName }; ctx.fs.writeUtf8File(configFile, JSON.stringify(configWithName)); } export function binariesDir() { return path.join(cacheDir(), "binaries"); } export function dashboardZip() { return path.join(dashboardDir(), "dashboard.zip"); } export function versionedBinaryDir(version: string) { return path.join(binariesDir(), version); } export function executablePath(version: string) { return path.join(versionedBinaryDir(version), executableName()); } export function executableName() { const ext = process.platform === "win32" ? ".exe" : ""; return `convex-local-backend${ext}`; } export function dashboardDir() { return path.join(cacheDir(), "dashboard"); } export async function resetDashboardDir(ctx: Context) { const dir = dashboardDir(); if (ctx.fs.exists(dir)) { await recursivelyDelete(ctx, dir); } ctx.fs.mkdir(dir, { recursive: true }); } export function dashboardOutDir() { return path.join(dashboardDir(), "out"); } export type DashboardConfig = { port: number; apiPort: number; version: string; }; export function loadDashboardConfig(ctx: Context) { const configFile = path.join(dashboardDir(), "config.json"); if (!ctx.fs.exists(configFile)) { return null; } const content = ctx.fs.readUtf8File(configFile); try { return JSON.parse(content); } catch (e) { logVerbose(`Failed to parse dashboard config: ${e as any}`); return null; } } export function saveDashboardConfig(ctx: Context, config: DashboardConfig) { const configFile = path.join(dashboardDir(), "config.json"); if (!ctx.fs.exists(dashboardDir())) { ctx.fs.mkdir(dashboardDir(), { recursive: true }); } ctx.fs.writeUtf8File(configFile, JSON.stringify(config)); } export function loadUuidForAnonymousUser(ctx: Context) { const configFile = path.join( rootDeploymentStateDir("anonymous"), "config.json", ); if (!ctx.fs.exists(configFile)) { return null; } const content = ctx.fs.readUtf8File(configFile); try { const config = JSON.parse(content); return config.uuid ?? null; } catch (e) { logVerbose(`Failed to parse uuid for anonymous user: ${e as any}`); return null; } } export function ensureUuidForAnonymousUser(ctx: Context) { const uuid = loadUuidForAnonymousUser(ctx); if (uuid) { return uuid; } const newUuid = crypto.randomUUID(); const anonymousDir = rootDeploymentStateDir("anonymous"); if (!ctx.fs.exists(anonymousDir)) { ctx.fs.mkdir(anonymousDir, { recursive: true }); } ctx.fs.writeUtf8File( path.join(anonymousDir, "config.json"), JSON.stringify({ uuid: newUuid }), ); return newUuid; }