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:
Will Chen
2025-08-04 16:36:09 -07:00
committed by GitHub
parent 0f1a5c5c77
commit b0f08eaf15
50 changed files with 3525 additions and 205 deletions

View File

@@ -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,
});
}