/*
~/.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;
}