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:
@@ -1,7 +1,12 @@
|
||||
import { db } from "../../db";
|
||||
import { apps, messages } from "../../db/schema";
|
||||
import { apps, messages, versions } from "../../db/schema";
|
||||
import { desc, eq, and, gt } from "drizzle-orm";
|
||||
import type { Version, BranchResult } from "../ipc_types";
|
||||
import type {
|
||||
Version,
|
||||
BranchResult,
|
||||
RevertVersionParams,
|
||||
RevertVersionResponse,
|
||||
} from "../ipc_types";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getDyadAppPath } from "../../paths/paths";
|
||||
@@ -11,10 +16,51 @@ import log from "electron-log";
|
||||
import { createLoggedHandler } from "./safe_handle";
|
||||
import { gitCheckout, gitCommit, gitStageToRevert } from "../utils/git_utils";
|
||||
|
||||
import {
|
||||
getNeonClient,
|
||||
getNeonErrorMessage,
|
||||
} from "../../neon_admin/neon_management_client";
|
||||
import {
|
||||
updatePostgresUrlEnvVar,
|
||||
updateDbPushEnvVar,
|
||||
} from "../utils/app_env_var_utils";
|
||||
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
|
||||
import { retryOnLocked } from "../utils/retryOnLocked";
|
||||
|
||||
const logger = log.scope("version_handlers");
|
||||
|
||||
const handle = createLoggedHandler(logger);
|
||||
|
||||
async function restoreBranchForPreview({
|
||||
appId,
|
||||
dbTimestamp,
|
||||
neonProjectId,
|
||||
previewBranchId,
|
||||
developmentBranchId,
|
||||
}: {
|
||||
appId: number;
|
||||
dbTimestamp: string;
|
||||
neonProjectId: string;
|
||||
previewBranchId: string;
|
||||
developmentBranchId: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const neonClient = await getNeonClient();
|
||||
await retryOnLocked(
|
||||
() =>
|
||||
neonClient.restoreProjectBranch(neonProjectId, previewBranchId, {
|
||||
source_branch_id: developmentBranchId,
|
||||
source_timestamp: dbTimestamp,
|
||||
}),
|
||||
`Restore preview branch ${previewBranchId} for app ${appId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = getNeonErrorMessage(error);
|
||||
logger.error("Error in restoreBranchForPreview:", errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerVersionHandlers() {
|
||||
handle("list-versions", async (_, { appId }: { appId: number }) => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
@@ -40,11 +86,32 @@ export function registerVersionHandlers() {
|
||||
depth: 100_000, // Limit to last 100_000 commits for performance
|
||||
});
|
||||
|
||||
return commits.map((commit: ReadCommitResult) => ({
|
||||
oid: commit.oid,
|
||||
message: commit.commit.message,
|
||||
timestamp: commit.commit.author.timestamp,
|
||||
})) satisfies Version[];
|
||||
// Get all snapshots for this app to match with commits
|
||||
const appSnapshots = await db.query.versions.findMany({
|
||||
where: eq(versions.appId, appId),
|
||||
});
|
||||
|
||||
// Create a map of commitHash -> snapshot info for quick lookup
|
||||
const snapshotMap = new Map<
|
||||
string,
|
||||
{ neonDbTimestamp: string | null; createdAt: Date }
|
||||
>();
|
||||
for (const snapshot of appSnapshots) {
|
||||
snapshotMap.set(snapshot.commitHash, {
|
||||
neonDbTimestamp: snapshot.neonDbTimestamp,
|
||||
createdAt: snapshot.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
return commits.map((commit: ReadCommitResult) => {
|
||||
const snapshotInfo = snapshotMap.get(commit.oid);
|
||||
return {
|
||||
oid: commit.oid,
|
||||
message: commit.commit.message,
|
||||
timestamp: commit.commit.author.timestamp,
|
||||
dbTimestamp: snapshotInfo?.neonDbTimestamp,
|
||||
};
|
||||
}) satisfies Version[];
|
||||
});
|
||||
|
||||
handle(
|
||||
@@ -86,12 +153,11 @@ export function registerVersionHandlers() {
|
||||
"revert-version",
|
||||
async (
|
||||
_,
|
||||
{
|
||||
appId,
|
||||
previousVersionId,
|
||||
}: { appId: number; previousVersionId: string },
|
||||
): Promise<void> => {
|
||||
{ appId, previousVersionId }: RevertVersionParams,
|
||||
): Promise<RevertVersionResponse> => {
|
||||
return withLock(appId, async () => {
|
||||
let successMessage = "Restored version";
|
||||
let warningMessage: string | undefined = undefined;
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
@@ -101,12 +167,26 @@ export function registerVersionHandlers() {
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
// Get the current commit hash before reverting
|
||||
const currentCommitHash = await git.resolveRef({
|
||||
fs,
|
||||
dir: appPath,
|
||||
ref: "main",
|
||||
});
|
||||
|
||||
await gitCheckout({
|
||||
path: appPath,
|
||||
ref: "main",
|
||||
});
|
||||
|
||||
if (app.neonProjectId && app.neonDevelopmentBranchId) {
|
||||
// We are going to add a new commit on top, so let's store
|
||||
// the current timestamp at the current version.
|
||||
await storeDbTimestampAtCurrentVersion({
|
||||
appId,
|
||||
});
|
||||
}
|
||||
|
||||
await gitStageToRevert({
|
||||
path: appPath,
|
||||
targetOid: previousVersionId,
|
||||
@@ -154,6 +234,87 @@ export function registerVersionHandlers() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (app.neonProjectId && app.neonDevelopmentBranchId) {
|
||||
const version = await db.query.versions.findFirst({
|
||||
where: and(
|
||||
eq(versions.appId, appId),
|
||||
eq(versions.commitHash, previousVersionId),
|
||||
),
|
||||
});
|
||||
if (version && version.neonDbTimestamp) {
|
||||
try {
|
||||
const preserveBranchName = `preserve_${currentCommitHash}-${Date.now()}`;
|
||||
const neonClient = await getNeonClient();
|
||||
const response = await retryOnLocked(
|
||||
() =>
|
||||
neonClient.restoreProjectBranch(
|
||||
app.neonProjectId!,
|
||||
app.neonDevelopmentBranchId!,
|
||||
{
|
||||
source_branch_id: app.neonDevelopmentBranchId!,
|
||||
source_timestamp: version.neonDbTimestamp!,
|
||||
preserve_under_name: preserveBranchName,
|
||||
},
|
||||
),
|
||||
`Restore development branch ${app.neonDevelopmentBranchId} for app ${appId}`,
|
||||
);
|
||||
// Update all versions which have a newer DB timestamp than the version we're restoring to
|
||||
// and remove their DB timestamp.
|
||||
await db
|
||||
.update(versions)
|
||||
.set({ neonDbTimestamp: null })
|
||||
.where(
|
||||
and(
|
||||
eq(versions.appId, appId),
|
||||
gt(versions.neonDbTimestamp, version.neonDbTimestamp),
|
||||
),
|
||||
);
|
||||
|
||||
const preserveBranchId = response.data.branch.parent_id;
|
||||
if (!preserveBranchId) {
|
||||
throw new Error("Preserve branch ID not found");
|
||||
}
|
||||
logger.info(
|
||||
`Deleting preserve branch ${preserveBranchId} for app ${appId}`,
|
||||
);
|
||||
try {
|
||||
// Intentionally do not await this because it's not
|
||||
// critical for the restore operation, it's to clean up branches
|
||||
// so the user doesn't hit the branch limit later.
|
||||
retryOnLocked(
|
||||
() =>
|
||||
neonClient.deleteProjectBranch(
|
||||
app.neonProjectId!,
|
||||
preserveBranchId,
|
||||
),
|
||||
`Delete preserve branch ${preserveBranchId} for app ${appId}`,
|
||||
{ retryBranchWithChildError: true },
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = getNeonErrorMessage(error);
|
||||
logger.error("Error in deleteProjectBranch:", errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getNeonErrorMessage(error);
|
||||
logger.error("Error in restoreBranchForCheckout:", errorMessage);
|
||||
warningMessage = `Could not restore database because of error: ${errorMessage}`;
|
||||
// Do not throw, so we can finish switching the postgres branch
|
||||
// It might throw because they picked a timestamp that's too old.
|
||||
}
|
||||
successMessage =
|
||||
"Successfully restored to version (including database)";
|
||||
}
|
||||
await switchPostgresToDevelopmentBranch({
|
||||
neonProjectId: app.neonProjectId,
|
||||
neonDevelopmentBranchId: app.neonDevelopmentBranchId,
|
||||
appPath: app.path,
|
||||
});
|
||||
}
|
||||
if (warningMessage) {
|
||||
return { warningMessage };
|
||||
}
|
||||
return { successMessage };
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -162,7 +323,7 @@ export function registerVersionHandlers() {
|
||||
"checkout-version",
|
||||
async (
|
||||
_,
|
||||
{ appId, versionId }: { appId: number; versionId: string },
|
||||
{ appId, versionId: gitRef }: { appId: number; versionId: string },
|
||||
): Promise<void> => {
|
||||
return withLock(appId, async () => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
@@ -173,13 +334,106 @@ export function registerVersionHandlers() {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
if (
|
||||
app.neonProjectId &&
|
||||
app.neonDevelopmentBranchId &&
|
||||
app.neonPreviewBranchId
|
||||
) {
|
||||
if (gitRef === "main") {
|
||||
logger.info(
|
||||
`Switching Postgres to development branch for app ${appId}`,
|
||||
);
|
||||
await switchPostgresToDevelopmentBranch({
|
||||
neonProjectId: app.neonProjectId,
|
||||
neonDevelopmentBranchId: app.neonDevelopmentBranchId,
|
||||
appPath: app.path,
|
||||
});
|
||||
} else {
|
||||
logger.info(
|
||||
`Switching Postgres to preview branch for app ${appId}`,
|
||||
);
|
||||
|
||||
// Regardless of whether we have a timestamp or not, we want to disable DB push
|
||||
// while we're checking out an earlier version
|
||||
await updateDbPushEnvVar({
|
||||
appPath: app.path,
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
const version = await db.query.versions.findFirst({
|
||||
where: and(
|
||||
eq(versions.appId, appId),
|
||||
eq(versions.commitHash, gitRef),
|
||||
),
|
||||
});
|
||||
|
||||
if (version && version.neonDbTimestamp) {
|
||||
// SWITCH the env var for POSTGRES_URL to the preview branch
|
||||
const neonClient = await getNeonClient();
|
||||
const connectionUri = await neonClient.getConnectionUri({
|
||||
projectId: app.neonProjectId,
|
||||
branch_id: app.neonPreviewBranchId,
|
||||
// This is the default database name for Neon
|
||||
database_name: "neondb",
|
||||
// This is the default role name for Neon
|
||||
role_name: "neondb_owner",
|
||||
});
|
||||
|
||||
await restoreBranchForPreview({
|
||||
appId,
|
||||
dbTimestamp: version.neonDbTimestamp,
|
||||
neonProjectId: app.neonProjectId,
|
||||
previewBranchId: app.neonPreviewBranchId,
|
||||
developmentBranchId: app.neonDevelopmentBranchId,
|
||||
});
|
||||
|
||||
await updatePostgresUrlEnvVar({
|
||||
appPath: app.path,
|
||||
connectionUri: connectionUri.data.uri,
|
||||
});
|
||||
logger.info(
|
||||
`Switched Postgres to preview branch for app ${appId} commit ${version.commitHash} dbTimestamp=${version.neonDbTimestamp}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const fullAppPath = getDyadAppPath(app.path);
|
||||
await gitCheckout({
|
||||
path: appPath,
|
||||
ref: versionId,
|
||||
path: fullAppPath,
|
||||
ref: gitRef,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function switchPostgresToDevelopmentBranch({
|
||||
neonProjectId,
|
||||
neonDevelopmentBranchId,
|
||||
appPath,
|
||||
}: {
|
||||
neonProjectId: string;
|
||||
neonDevelopmentBranchId: string;
|
||||
appPath: string;
|
||||
}) {
|
||||
// SWITCH the env var for POSTGRES_URL to the development branch
|
||||
const neonClient = await getNeonClient();
|
||||
const connectionUri = await neonClient.getConnectionUri({
|
||||
projectId: neonProjectId,
|
||||
branch_id: neonDevelopmentBranchId,
|
||||
// This is the default database name for Neon
|
||||
database_name: "neondb",
|
||||
// This is the default role name for Neon
|
||||
role_name: "neondb_owner",
|
||||
});
|
||||
|
||||
await updatePostgresUrlEnvVar({
|
||||
appPath,
|
||||
connectionUri: connectionUri.data.uri,
|
||||
});
|
||||
|
||||
await updateDbPushEnvVar({
|
||||
appPath,
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user