Neon / portal template support (#713)
TODOs: - [x] Do restart when checkout / restore if there is a DB - [x] List all branches (branch id, name, date) - [x] Allow checking out versions with no DB - [x] safeguard to never delete main branches - [x] create app hook for neon template - [x] weird UX with connector on configure panel - [x] tiny neon logo in connector - [x] deploy to vercel - [x] build forgot password page - [x] what about email setup - [x] lots of imgix errors - [x] edit file - db snapshot - [x] DYAD_DISABLE_DB_PUSH - [ ] update portal doc - [x] switch preview branch to be read-only endpoint - [x] disable supabase sys prompt if neon is enabled - [ ] https://payloadcms.com/docs/upload/storage-adapters - [x] need to use main branch... Phase 2? - [x] generate DB migrations
This commit is contained in:
@@ -3,7 +3,109 @@
|
||||
* Environment variables are sensitive and should not be logged.
|
||||
*/
|
||||
|
||||
import { getDyadAppPath } from "@/paths/paths";
|
||||
import { EnvVar } from "../ipc_types";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import log from "electron-log";
|
||||
|
||||
const logger = log.scope("app_env_var_utils");
|
||||
|
||||
export const ENV_FILE_NAME = ".env.local";
|
||||
|
||||
function getEnvFilePath({ appPath }: { appPath: string }): string {
|
||||
return path.join(getDyadAppPath(appPath), ENV_FILE_NAME);
|
||||
}
|
||||
|
||||
export async function updatePostgresUrlEnvVar({
|
||||
appPath,
|
||||
connectionUri,
|
||||
}: {
|
||||
appPath: string;
|
||||
connectionUri: string;
|
||||
}) {
|
||||
// Given the connection uri, update the env var for POSTGRES_URL
|
||||
const envVars = parseEnvFile(await readEnvFile({ appPath }));
|
||||
|
||||
// Find existing POSTGRES_URL or add it if it doesn't exist
|
||||
const existingVar = envVars.find((envVar) => envVar.key === "POSTGRES_URL");
|
||||
if (existingVar) {
|
||||
existingVar.value = connectionUri;
|
||||
} else {
|
||||
envVars.push({
|
||||
key: "POSTGRES_URL",
|
||||
value: connectionUri,
|
||||
});
|
||||
}
|
||||
|
||||
const envFileContents = serializeEnvFile(envVars);
|
||||
await fs.promises.writeFile(getEnvFilePath({ appPath }), envFileContents);
|
||||
}
|
||||
|
||||
export async function updateDbPushEnvVar({
|
||||
appPath,
|
||||
disabled,
|
||||
}: {
|
||||
appPath: string;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
try {
|
||||
// Try to read existing env file
|
||||
let envVars: EnvVar[];
|
||||
try {
|
||||
const content = await readEnvFile({ appPath });
|
||||
envVars = parseEnvFile(content);
|
||||
} catch {
|
||||
// If file doesn't exist, start with empty array
|
||||
envVars = [];
|
||||
}
|
||||
|
||||
// Update or add DYAD_DISABLE_DB_PUSH
|
||||
const existingVar = envVars.find(
|
||||
(envVar) => envVar.key === "DYAD_DISABLE_DB_PUSH",
|
||||
);
|
||||
if (existingVar) {
|
||||
existingVar.value = disabled ? "true" : "false";
|
||||
} else {
|
||||
envVars.push({
|
||||
key: "DYAD_DISABLE_DB_PUSH",
|
||||
value: disabled ? "true" : "false",
|
||||
});
|
||||
}
|
||||
|
||||
const envFileContents = serializeEnvFile(envVars);
|
||||
await fs.promises.writeFile(getEnvFilePath({ appPath }), envFileContents);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to update DB push environment variable for app ${appPath}: ${error}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPostgresUrlFromEnvFile({
|
||||
appPath,
|
||||
}: {
|
||||
appPath: string;
|
||||
}): Promise<string> {
|
||||
const contents = await readEnvFile({ appPath });
|
||||
const envVars = parseEnvFile(contents);
|
||||
const postgresUrl = envVars.find(
|
||||
(envVar) => envVar.key === "POSTGRES_URL",
|
||||
)?.value;
|
||||
if (!postgresUrl) {
|
||||
throw new Error("POSTGRES_URL not found in .env.local");
|
||||
}
|
||||
return postgresUrl;
|
||||
}
|
||||
|
||||
export async function readEnvFile({
|
||||
appPath,
|
||||
}: {
|
||||
appPath: string;
|
||||
}): Promise<string> {
|
||||
return fs.promises.readFile(getEnvFilePath({ appPath }), "utf8");
|
||||
}
|
||||
|
||||
// Helper function to parse .env.local file content
|
||||
export function parseEnvFile(content: string): EnvVar[] {
|
||||
|
||||
134
src/ipc/utils/neon_timestamp_utils.ts
Normal file
134
src/ipc/utils/neon_timestamp_utils.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { db } from "../../db";
|
||||
import { versions, apps } from "../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import fs from "node:fs";
|
||||
import git from "isomorphic-git";
|
||||
import { getDyadAppPath } from "../../paths/paths";
|
||||
import { neon } from "@neondatabase/serverless";
|
||||
|
||||
import log from "electron-log";
|
||||
import { getNeonClient } from "@/neon_admin/neon_management_client";
|
||||
|
||||
const logger = log.scope("neon_timestamp_utils");
|
||||
|
||||
/**
|
||||
* Retrieves the current timestamp from a Neon database
|
||||
*/
|
||||
async function getLastUpdatedTimestampFromNeon({
|
||||
neonConnectionUri,
|
||||
}: {
|
||||
neonConnectionUri: string;
|
||||
}): Promise<string> {
|
||||
try {
|
||||
const sql = neon(neonConnectionUri);
|
||||
|
||||
const [{ current_timestamp }] = await sql`
|
||||
SELECT TO_CHAR(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z') AS current_timestamp
|
||||
`;
|
||||
|
||||
return current_timestamp;
|
||||
} catch (error) {
|
||||
logger.error("Error retrieving timestamp from Neon:", error);
|
||||
throw new Error(`Failed to retrieve timestamp from Neon: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a Neon database timestamp for the current git commit hash
|
||||
* and stores it in the versions table
|
||||
* @param appId - The app ID
|
||||
* @param neonConnectionUri - The Neon connection URI to get the timestamp from
|
||||
*/
|
||||
export async function storeDbTimestampAtCurrentVersion({
|
||||
appId,
|
||||
}: {
|
||||
appId: number;
|
||||
}): Promise<{ timestamp: string }> {
|
||||
try {
|
||||
logger.info(`Storing DB timestamp for current version - app ${appId}`);
|
||||
|
||||
// 1. Get the app to find the path
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error(`App with ID ${appId} not found`);
|
||||
}
|
||||
|
||||
if (!app.neonProjectId || !app.neonDevelopmentBranchId) {
|
||||
throw new Error(`App with ID ${appId} has no Neon project or branch`);
|
||||
}
|
||||
|
||||
// 2. Get the current commit hash
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
const currentCommitHash = await git.resolveRef({
|
||||
fs,
|
||||
dir: appPath,
|
||||
ref: "HEAD",
|
||||
});
|
||||
|
||||
logger.info(`Current commit hash: ${currentCommitHash}`);
|
||||
|
||||
const neonClient = await getNeonClient();
|
||||
const connectionUri = await neonClient.getConnectionUri({
|
||||
projectId: app.neonProjectId,
|
||||
branch_id: app.neonDevelopmentBranchId,
|
||||
database_name: "neondb",
|
||||
role_name: "neondb_owner",
|
||||
});
|
||||
|
||||
// 3. Get the current timestamp from Neon
|
||||
const currentTimestamp = await getLastUpdatedTimestampFromNeon({
|
||||
neonConnectionUri: connectionUri.data.uri,
|
||||
});
|
||||
|
||||
logger.info(`Current timestamp from Neon: ${currentTimestamp}`);
|
||||
|
||||
// 4. Check if a version with this commit hash already exists
|
||||
const existingVersion = await db.query.versions.findFirst({
|
||||
where: and(
|
||||
eq(versions.appId, appId),
|
||||
eq(versions.commitHash, currentCommitHash),
|
||||
),
|
||||
});
|
||||
|
||||
if (existingVersion) {
|
||||
// Update existing version with the new timestamp
|
||||
await db
|
||||
.update(versions)
|
||||
.set({
|
||||
neonDbTimestamp: currentTimestamp,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(versions.appId, appId),
|
||||
eq(versions.commitHash, currentCommitHash),
|
||||
),
|
||||
);
|
||||
logger.info(
|
||||
`Updated existing version record with timestamp ${currentTimestamp}`,
|
||||
);
|
||||
} else {
|
||||
// Create new version record
|
||||
await db.insert(versions).values({
|
||||
appId,
|
||||
commitHash: currentCommitHash,
|
||||
neonDbTimestamp: currentTimestamp,
|
||||
});
|
||||
logger.info(
|
||||
`Created new version record for commit ${currentCommitHash} with timestamp ${currentTimestamp}`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Successfully stored timestamp for commit ${currentCommitHash} in app ${appId}`,
|
||||
);
|
||||
|
||||
return { timestamp: currentTimestamp };
|
||||
} catch (error) {
|
||||
logger.error("Error in storeDbTimestampAtCurrentVersion:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
71
src/ipc/utils/retryOnLocked.ts
Normal file
71
src/ipc/utils/retryOnLocked.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import log from "electron-log";
|
||||
|
||||
export const logger = log.scope("retryOnLocked");
|
||||
|
||||
export function isLockedError(error: any): boolean {
|
||||
return error.response?.status === 423;
|
||||
}
|
||||
|
||||
// Retry configuration
|
||||
const RETRY_CONFIG = {
|
||||
maxRetries: 6,
|
||||
baseDelay: 1000, // 1 second
|
||||
maxDelay: 90_000, // 90 seconds
|
||||
jitterFactor: 0.1, // 10% jitter
|
||||
};
|
||||
|
||||
/**
|
||||
* Retries an async operation with exponential backoff on locked errors (423)
|
||||
*/
|
||||
|
||||
export async function retryOnLocked<T>(
|
||||
operation: () => Promise<T>,
|
||||
context: string,
|
||||
{
|
||||
retryBranchWithChildError = false,
|
||||
}: { retryBranchWithChildError?: boolean } = {},
|
||||
): Promise<T> {
|
||||
let lastError: any;
|
||||
|
||||
for (let attempt = 0; attempt <= RETRY_CONFIG.maxRetries; attempt++) {
|
||||
try {
|
||||
const result = await operation();
|
||||
logger.info(`${context}: Success after ${attempt + 1} attempts`);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
|
||||
// Only retry on locked errors
|
||||
if (!isLockedError(error)) {
|
||||
if (retryBranchWithChildError && error.response?.status === 422) {
|
||||
logger.info(
|
||||
`${context}: Branch with child error (attempt ${attempt + 1}/${RETRY_CONFIG.maxRetries + 1})`,
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't retry if we've exhausted all attempts
|
||||
if (attempt === RETRY_CONFIG.maxRetries) {
|
||||
logger.error(
|
||||
`${context}: Failed after ${RETRY_CONFIG.maxRetries + 1} attempts due to locked error`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Calculate delay with exponential backoff and jitter
|
||||
const baseDelay = RETRY_CONFIG.baseDelay * Math.pow(2, attempt);
|
||||
const jitter = baseDelay * RETRY_CONFIG.jitterFactor * Math.random();
|
||||
const delay = Math.min(baseDelay + jitter, RETRY_CONFIG.maxDelay);
|
||||
|
||||
logger.warn(
|
||||
`${context}: Locked error (attempt ${attempt + 1}/${RETRY_CONFIG.maxRetries + 1}), retrying in ${Math.round(delay)}ms`,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
Reference in New Issue
Block a user