diff --git a/packages/alchemy/src/Prisma/App.ts b/packages/alchemy/src/Prisma/App.ts index 337c3d7b51..d4503cb12b 100644 --- a/packages/alchemy/src/Prisma/App.ts +++ b/packages/alchemy/src/Prisma/App.ts @@ -21,6 +21,7 @@ import { destroyApp } from "./ComputeLifecycle.ts"; import { ensureAppImmutableIdentity } from "./Internal/AppIdentity.ts"; import type { Project } from "./Project.ts"; import type { Providers } from "./Providers.ts"; +import { desiredBranchId } from "./Branches.ts"; import { concreteIdsChanged, isInputObject, @@ -125,45 +126,6 @@ export interface App extends Resource< */ export const App = Resource("Prisma.App"); -const desiredBranchId = Effect.fn(function* ( - client: PrismaManagementClient, - projectId: string, - props: Pick, -) { - if (props.branchId !== undefined && !isPrismaDevId(props.branchId)) { - return { resolved: true as const, id: props.branchId }; - } - if (props.branchGitName !== undefined) { - const branches = yield* client.listBranches(projectId, { - gitName: props.branchGitName, - limit: 100, - }); - if (branches.length > 1) { - return yield* Effect.fail( - new Error( - `Prisma returned multiple branches named '${props.branchGitName}' in project '${projectId}'; refusing an ambiguous App match.`, - ), - ); - } - return branches[0] - ? { resolved: true as const, id: branches[0].id } - : { resolved: false as const }; - } - const branches = yield* client.listBranches(projectId, { limit: 100 }); - const defaults = branches.filter((branch) => branch.isDefault); - if (defaults.length > 1) { - return yield* Effect.fail( - new Error( - `Prisma returned multiple default branches for project '${projectId}'; refusing an ambiguous App match.`, - ), - ); - } - const defaultBranch = defaults[0]; - return defaultBranch - ? { resolved: true as const, id: defaultBranch.id } - : { resolved: false as const }; -}); - const createDisplayName = (id: string, displayName: string | undefined) => displayName === undefined ? createPhysicalName({ id }) @@ -180,13 +142,13 @@ const findApp = Effect.fn(function* ( limit: 100, })).filter((app) => app.name === displayName); if (candidates.length === 0) return undefined; - const branch = yield* desiredBranchId(client, projectId, props); - if (!branch.resolved) return undefined; - const matches = candidates.filter((app) => app.branchId === branch.id); + const branchId = yield* desiredBranchId(client, projectId, props); + if (branchId === undefined) return undefined; + const matches = candidates.filter((app) => app.branchId === branchId); if (matches.length > 1) { return yield* Effect.fail( new Error( - `Prisma returned multiple Apps named '${displayName}' on branch '${branch.id}' in project '${projectId}'; refusing an ambiguous ownership match.`, + `Prisma returned multiple Apps named '${displayName}' on branch '${branchId}' in project '${projectId}'; refusing an ambiguous ownership match.`, ), ); } @@ -214,11 +176,11 @@ const branchNeedsSync = Effect.fn(function* ( return app.branchId !== props.branchId; } if (props.branchGitName === undefined) { - const branch = yield* desiredBranchId(client, projectId, props); - return !branch.resolved || app.branchId !== branch.id; + const branchId = yield* desiredBranchId(client, projectId, props); + return branchId === undefined || app.branchId !== branchId; } - const branch = yield* desiredBranchId(client, projectId, props); - return !branch.resolved || branch.id !== app.branchId; + const branchId = yield* desiredBranchId(client, projectId, props); + return branchId === undefined || branchId !== app.branchId; }); const validateAppProps = (props: AppProps) => @@ -300,12 +262,12 @@ const ProviderLive = () => if (output.name !== resolvedUpdateProps.displayName) { return { action: "update" } as const; } - const branch = yield* desiredBranchId( + const branchId = yield* desiredBranchId( client, newProjectId ?? output.projectId, resolvedUpdateProps, ); - return !branch.resolved || output.branchId !== branch.id + return branchId === undefined || output.branchId !== branchId ? ({ action: "update" } as const) : undefined; }), @@ -338,8 +300,8 @@ const ProviderLive = () => yield* validateAppProps(news); const projectId = yield* resolveProjectId(news.project); const displayName = yield* createDisplayName(id, news.displayName); - const branch = yield* desiredBranchId(client, projectId, news); - if (!branch.resolved) { + const branchId = yield* desiredBranchId(client, projectId, news); + if (branchId === undefined) { return yield* Effect.fail( new Error( news.branchGitName === undefined @@ -364,7 +326,7 @@ const ProviderLive = () => projectId, displayName, regionId: news.regionId, - branchId: branch.id, + branchId: branchId, branchGitName: undefined, }) .pipe( @@ -405,14 +367,14 @@ const ProviderLive = () => if (app.name !== displayName || needsBranchSync) { app = yield* client.updateApp(app.id, { displayName, - branchId: branch.id, + branchId: branchId, branchGitName: undefined, }); } - if (app.name !== displayName || app.branchId !== branch.id) { + if (app.name !== displayName || app.branchId !== branchId) { return yield* Effect.fail( new Error( - `Prisma App '${app.id}' did not converge to display name '${displayName}' and branch '${branch.id ?? "null"}'. Refusing to persist mismatched App state.`, + `Prisma App '${app.id}' did not converge to display name '${displayName}' and branch '${branchId ?? "null"}'. Refusing to persist mismatched App state.`, ), ); } diff --git a/packages/alchemy/src/Prisma/Branches.ts b/packages/alchemy/src/Prisma/Branches.ts new file mode 100644 index 0000000000..3779becda9 --- /dev/null +++ b/packages/alchemy/src/Prisma/Branches.ts @@ -0,0 +1,38 @@ +import * as Effect from "effect/Effect"; +import { isPrismaDevId } from "./Refs.ts"; +import type { PrismaManagementClient } from "./Client.ts"; + +/** Resolves the desired branch id from explicit props or the project's default branch. */ +export const desiredBranchId = Effect.fn(function* ( + client: PrismaManagementClient, + projectId: string, + props: { branchId?: string; branchGitName?: string }, +) { + if (props.branchId !== undefined && !isPrismaDevId(props.branchId)) { + return props.branchId; + } + if (props.branchGitName !== undefined) { + const branches = yield* client.listBranches(projectId, { + gitName: props.branchGitName, + limit: 2, + }); + if (branches.length > 1) { + return yield* Effect.fail( + new Error( + `Prisma returned multiple branches named '${props.branchGitName}' in project '${projectId}'; refusing to select one arbitrarily.`, + ), + ); + } + return branches[0]?.id; + } + const branches = yield* client.listBranches(projectId, { limit: 100 }); + const defaults = branches.filter((branch) => branch.isDefault); + if (defaults.length > 1) { + return yield* Effect.fail( + new Error( + `Prisma returned multiple default branches for project '${projectId}'; refusing to select one arbitrarily.`, + ), + ); + } + return defaults[0]?.id; +}); diff --git a/packages/alchemy/src/Prisma/Database.ts b/packages/alchemy/src/Prisma/Database.ts index ffd7eeac3f..cb520dff6c 100644 --- a/packages/alchemy/src/Prisma/Database.ts +++ b/packages/alchemy/src/Prisma/Database.ts @@ -22,6 +22,7 @@ import { isNotFound, type PrismaManagementClient, } from "./Client.ts"; +import { desiredBranchId } from "./Branches.ts"; import type { Project } from "./Project.ts"; import { hasCanonicalConnectionSecrets, @@ -145,13 +146,17 @@ export interface DatabaseProps { */ source?: DatabaseSourceInput; /** - * Branch ID to attach the database to. Mutually exclusive with branchGitName. + * Branch ID to attach the database to. Mutually exclusive with + * branchGitName. Omit both fields to attach the database to the project's + * default branch. */ - branchId?: string | null; + branchId?: string; /** - * Branch git name to attach the database to. Mutually exclusive with branchId. + * Branch git name to attach the database to. Mutually exclusive with + * branchId. Omit both fields to attach the database to the project's + * default branch. */ - branchGitName?: string | null; + branchGitName?: string; /** * Local database settings for `alchemy dev`. Set to `false` to keep only * placeholder IDs. @@ -242,7 +247,10 @@ export interface Database extends Resource< * * Standalone `Prisma.Database` resources cannot be the project's default * database. Use `Prisma.Project` when the project should own a default - * database. Project, region, and source changes require replacement; display + * database. Omit `branchId` and `branchGitName` to attach the database to the + * project's current default branch. A database is always attached to a + * branch; an unassigned database is not representable as desired state. + * Project, region, and source changes require replacement; display * name and branch attachment can converge in place. Destroying this resource * deletes its database and data. * @@ -393,24 +401,8 @@ const desiredSourcesMatch = ( right: DatabaseSourceInput | undefined, ) => deepEqual(normalizeDatabaseSource(left), normalizeDatabaseSource(right)); -const branchIdForGitName = ( - client: PrismaManagementClient, - projectId: string, - gitName: string, -) => - client - .listBranches(projectId, { gitName, limit: 2 }) - .pipe( - Effect.flatMap((branches) => - branches.length > 1 - ? Effect.fail( - new Error( - `Prisma project '${projectId}' has multiple branches named '${gitName}'; refusing to select one arbitrarily.`, - ), - ) - : Effect.succeed(branches[0]?.id), - ), - ); +const UNATTACHED_BRANCH_ERROR = + "Prisma.Database requires an attached branch because an unassigned database is not representable as desired state. Omit both fields to use the project default branch, or provide branchId/branchGitName."; const attrsFrom = ( database: ApiDatabase, @@ -433,45 +425,6 @@ const attrsFrom = ( password: secrets.password, }); -const branchNeedsSync = Effect.fn(function* ( - client: PrismaManagementClient, - projectId: string, - database: ApiDatabase, - props: DatabaseProps, -) { - if (props.branchId !== undefined && !isPrismaDevId(props.branchId)) { - return database.branchId !== props.branchId; - } - if (props.branchGitName === undefined) { - return database.branchId !== null; - } - if (props.branchGitName === null) { - return database.branchId !== null; - } - const branchId = yield* branchIdForGitName( - client, - projectId, - props.branchGitName, - ); - return branchId === undefined || branchId !== database.branchId; -}); - -const branchAttachment = (props: DatabaseProps) => - props.branchId !== undefined && !isPrismaDevId(props.branchId) - ? { - branchId: props.branchId, - branchGitName: undefined, - } - : props.branchGitName !== undefined - ? { - branchId: undefined, - branchGitName: props.branchGitName, - } - : { - branchId: undefined, - branchGitName: undefined, - }; - const validateDatabaseProps = (props: DatabaseProps) => Effect.gen(function* () { if ((props as { isDefault?: boolean }).isDefault === true) { @@ -486,6 +439,9 @@ const validateDatabaseProps = (props: DatabaseProps) => new Error("branchId and branchGitName are mutually exclusive."), ); } + if (props.branchId === null || props.branchGitName === null) { + return yield* Effect.fail(new Error(UNATTACHED_BRANCH_ERROR)); + } }); const ProviderLive = () => @@ -522,6 +478,12 @@ const ProviderLive = () => ), ); } + if ( + (isResolved(news.branchId) && news.branchId === null) || + (isResolved(news.branchGitName) && news.branchGitName === null) + ) { + return yield* Effect.fail(new Error(UNATTACHED_BRANCH_ERROR)); + } if (isPrismaDevId(output?.databaseId)) { return { action: "update" } as const; } @@ -592,28 +554,12 @@ const ProviderLive = () => } else if ( isResolved(news.branchGitName) && news.branchGitName !== undefined - ) { - if (news.branchGitName === null) { - branchMismatch = - (output?.branchId ?? olds.branchId ?? null) !== null; - } else if (output && newProjectId !== undefined) { - const desiredBranchId = yield* branchIdForGitName( - client, - newProjectId, - news.branchGitName, - ); - branchMismatch = - desiredBranchId === undefined || - desiredBranchId !== output.branchId; - } else { - branchMismatch = news.branchGitName !== olds.branchGitName; - } - } else if ( - isResolved(news.branchId) && - isResolved(news.branchGitName) ) { branchMismatch = - (output?.branchId ?? olds.branchId ?? null) !== null; + news.branchGitName !== (olds.branchGitName ?? null) || + output?.branchId === null; + } else { + branchMismatch = output?.branchId === null; } if (desiredName !== observedName || branchMismatch) { return { action: "update" } as const; @@ -697,9 +643,27 @@ const ProviderLive = () => database = yield* findDatabaseByName(client, projectId, name); } + // Resolve the default branch once for generated-name creates (both + // branch props omitted) — the same id is used in the create body and + // the patch arm below. + let createBranchId: string | undefined; + if ( + news.branchId === undefined && + news.branchGitName === undefined && + news.name === undefined + ) { + createBranchId = yield* desiredBranchId(client, projectId, news); + if (createBranchId === undefined) { + return yield* Effect.fail( + new Error( + `Prisma project '${projectId}' has no default branch to attach database '${name}'. Create or promote a default branch, or specify branchId/branchGitName.`, + ), + ); + } + } + let secrets: PrismaSecretConnection = {}; let recoverCreateSecrets = false; - const attach = branchAttachment(news); if (!database) { if ( news.name !== undefined && @@ -711,6 +675,7 @@ const ProviderLive = () => ), ); } + // The Management API cannot create a named database with a branch attachment, so named creates attach via PATCH below. const result = yield* client .createDatabase({ projectId, @@ -718,8 +683,8 @@ const ProviderLive = () => region, isDefault: news.isDefault ?? false, source: news.source, - branchId: attach.branchId, - branchGitName: attach.branchGitName, + branchId: createBranchId, + branchGitName: undefined, }) .pipe( Effect.map((database) => ({ @@ -756,6 +721,44 @@ const ProviderLive = () => recoverCreateSecrets = result.recoverSecrets; } + let patchBranchId: string | undefined; + let needsPatch: boolean; + if (news.branchId !== undefined && !isPrismaDevId(news.branchId)) { + patchBranchId = news.branchId; + needsPatch = + database.name !== name || database.branchId !== news.branchId; + } else if (news.branchGitName !== undefined) { + const gitNameChanged = + olds !== undefined && olds.branchGitName !== news.branchGitName; + const unattached = database.branchId === null; + needsPatch = database.name !== name || unattached || gitNameChanged; + if (needsPatch) { + const branchId = yield* desiredBranchId(client, projectId, news); + if (branchId === undefined) { + return yield* Effect.fail( + new Error( + `Prisma project '${projectId}' has no branch named '${news.branchGitName}' to attach database '${name}'.`, + ), + ); + } + patchBranchId = branchId; + } + } else { + const branchId = + createBranchId ?? + (yield* desiredBranchId(client, projectId, news)); + if (branchId === undefined) { + return yield* Effect.fail( + new Error( + `Prisma project '${projectId}' has no default branch to attach database '${name}'. Create or promote a default branch, or specify branchId/branchGitName.`, + ), + ); + } + patchBranchId = branchId; + needsPatch = + database.name !== name || database.branchId !== branchId; + } + if (database.project.id !== projectId) { return yield* Effect.fail( new Error( @@ -793,20 +796,11 @@ const ProviderLive = () => const ownedGeneratedIdentity = news.name === undefined && database.name === name; - const desired = { ...news, name }; - const needsPatch = - database.name !== name || - (yield* branchNeedsSync(client, projectId, database, desired)); if (needsPatch) { - const updateAttachment = - attach.branchId === undefined && - attach.branchGitName === undefined - ? { branchId: null, branchGitName: undefined } - : attach; database = yield* client.updateDatabase(database.id, { name, - branchId: updateAttachment.branchId, - branchGitName: updateAttachment.branchGitName, + branchId: patchBranchId, + branchGitName: undefined, }); } diff --git a/packages/alchemy/src/Prisma/SourceRepository.ts b/packages/alchemy/src/Prisma/SourceRepository.ts index d24003b0ae..58ad2edfd0 100644 --- a/packages/alchemy/src/Prisma/SourceRepository.ts +++ b/packages/alchemy/src/Prisma/SourceRepository.ts @@ -233,7 +233,7 @@ const verifyRepositoryLink = Effect.fn(function* ( } const branches = yield* client.listBranches(repo.projectId, { gitName: observed.defaultBranch, - limit: 100, + limit: 2, }); const defaults = branches.filter( (branch) => diff --git a/packages/alchemy/test/Prisma/Database.test.ts b/packages/alchemy/test/Prisma/Database.test.ts new file mode 100644 index 0000000000..9c7cb85d81 --- /dev/null +++ b/packages/alchemy/test/Prisma/Database.test.ts @@ -0,0 +1,383 @@ +import { AlchemyContext } from "@/AlchemyContext"; +import { InstanceId } from "@/InstanceId"; +import { PrismaClient, type PrismaManagementClient } from "@/Prisma/Client"; +import { + Database as PrismaDatabase, + DatabaseProvider, +} from "@/Prisma/Database"; +import type { Database as ApiDatabase } from "@/Prisma/Types"; +import { Stack } from "@/Stack"; +import { Stage } from "@/Stage"; +import { describe, expect, it } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +const createdAt = "2026-01-01T00:00:00.000Z"; +const instanceId = "00000000000000000000000000000000"; + +const branch = (id: string, isDefault = true) => ({ + id, + type: "branch" as const, + url: `https://api.prisma.test/v1/branches/${id}`, + gitName: "main", + isDefault, + role: "production" as const, + createdAt, + updatedAt: createdAt, + project: { + id: "project-1", + url: "https://api.prisma.test/v1/projects/project-1", + name: "app", + }, +}); + +const connection = (databaseId: string) => ({ + id: `connection-${databaseId}`, + type: "connection" as const, + url: `https://api.prisma.test/v1/connections/connection-${databaseId}`, + name: "default", + createdAt, + kind: "postgres" as const, + endpoints: { + direct: { + host: "db.prisma.test", + port: 5432, + connectionString: `postgres://direct-${databaseId}`, + }, + pooled: { + host: "pool.prisma.test", + port: 5432, + connectionString: `postgres://pooled-${databaseId}`, + }, + }, + database: { + id: databaseId, + url: `https://api.prisma.test/v1/databases/${databaseId}`, + name: "db", + }, +}); + +const database = ( + id: string, + branchId: string | null, + overrides: Partial = {}, +): ApiDatabase => ({ + id, + type: "database", + url: `https://api.prisma.test/v1/databases/${id}`, + name: "db", + status: "ready", + createdAt, + isDefault: false, + defaultConnectionId: `connection-${id}`, + connections: [connection(id)], + project: { + id: "project-1", + url: "https://api.prisma.test/v1/projects/project-1", + name: "app", + }, + region: { id: "us-east-1", name: "US East" }, + source: { type: "empty" }, + branchId, + ...overrides, +}); + +const attrs = ( + databaseId: string, + branchId: string | null, +): PrismaDatabase["Attributes"] => ({ + databaseId, + databaseName: "db", + projectId: "project-1", + status: "ready", + region: "us-east-1", + isDefault: false, + branchId, + defaultConnectionId: `connection-${databaseId}`, + createdAt, + directConnectionString: undefined, + pooledConnectionString: undefined, + accelerateConnectionString: undefined, + host: undefined, + user: undefined, + password: undefined, +}); + +const liveProviderContext = Layer.succeed(AlchemyContext, { + dotAlchemy: ".alchemy-test", + dev: false, + adopt: false, +}); + +const provide = + (client: PrismaManagementClient) => + (effect: Effect.Effect) => + effect.pipe( + Effect.provide(DatabaseProvider()), + Effect.provide(Layer.succeed(PrismaClient, client)), + Effect.provide(liveProviderContext), + Effect.provideService(Stack, { + name: "prisma-database-test", + stage: "test", + resources: {}, + bindings: {}, + actions: {}, + }), + Effect.provideService(Stage, "test"), + Effect.provideService(InstanceId, instanceId), + ); + +const reconcileInput = (news: unknown, output?: unknown, olds?: unknown) => + ({ + id: "Database", + fqn: "Database", + instanceId, + news, + olds, + output, + session: undefined as never, + bindings: [], + }) as never; + +const diffInput = (olds: unknown, news: unknown, output?: unknown) => + ({ + id: "Database", + fqn: "Database", + instanceId, + olds, + news, + output, + oldBindings: [], + newBindings: [], + }) as never; + +describe("Prisma Database", () => { + it.effect( + "attaches a generated-name create to the project's default branch", + () => { + const calls: Array<[string, unknown?]> = []; + const client = { + listBranches: () => Effect.succeed([branch("branch-main")]), + listProjectDatabases: () => Effect.succeed([]), + createDatabase: (input: { name?: string; branchId?: string }) => + Effect.sync(() => { + calls.push(["createDatabase", input]); + return database("database-1", input.branchId ?? null, { + name: input.name, + }); + }), + updateDatabase: () => + Effect.die("a create born attached must not be patched"), + } as unknown as PrismaManagementClient; + + return Effect.gen(function* () { + const provider = yield* PrismaDatabase.Provider; + const output = yield* provider.reconcile( + reconcileInput({ project: "project-1" }), + ); + + expect(output.branchId).toBe("branch-main"); + expect(calls.map(([name]) => name)).toEqual(["createDatabase"]); + expect(calls[0]?.[1]).toMatchObject({ branchId: "branch-main" }); + }).pipe(provide(client)); + }, + ); + + it.effect( + "never detaches a database already attached to the default branch", + () => { + const client = { + getDatabase: () => + Effect.succeed(database("database-1", "branch-main")), + listBranches: () => Effect.succeed([branch("branch-main")]), + updateDatabase: () => + Effect.die("omitted branch props must not detach the database"), + } as unknown as PrismaManagementClient; + const props = { project: "project-1", name: "db" }; + + return Effect.gen(function* () { + const provider = yield* PrismaDatabase.Provider; + const output = yield* provider.reconcile( + reconcileInput(props, attrs("database-1", "branch-main"), props), + ); + expect(output.databaseId).toBe("database-1"); + expect(output.branchId).toBe("branch-main"); + + const clean = yield* provider.diff!( + diffInput(props, props, attrs("database-1", "branch-main")), + ); + expect(clean).toBeUndefined(); + }).pipe(provide(client)); + }, + ); + + it.effect( + "converges a pre-existing unassigned database onto the default branch in place", + () => { + const calls: Array<[string, unknown?]> = []; + let observed = database("database-1", null); + const client = { + getDatabase: () => Effect.sync(() => observed), + listBranches: () => Effect.succeed([branch("branch-main")]), + updateDatabase: (id: string, input: { branchId?: string | null }) => + Effect.sync(() => { + calls.push(["updateDatabase", { id, input }]); + observed = database(id, input.branchId ?? null); + return observed; + }), + } as unknown as PrismaManagementClient; + const props = { project: "project-1", name: "db" }; + + return Effect.gen(function* () { + const provider = yield* PrismaDatabase.Provider; + const plan = yield* provider.diff!( + diffInput(props, props, attrs("database-1", null)), + ); + expect(plan).toEqual({ action: "update" }); + + const output = yield* provider.reconcile( + reconcileInput(props, attrs("database-1", null), props), + ); + expect(output.databaseId).toBe("database-1"); + expect(output.branchId).toBe("branch-main"); + expect(calls).toEqual([ + [ + "updateDatabase", + { + id: "database-1", + input: { + name: "db", + branchId: "branch-main", + branchGitName: undefined, + }, + }, + ], + ]); + }).pipe(provide(client)); + }, + ); + + it.effect("keeps an explicit branchId attachment authoritative", () => { + const calls: Array<[string, unknown?]> = []; + const client = { + getDatabase: () => Effect.succeed(database("database-1", "branch-main")), + updateDatabase: (id: string, input: { branchId?: string | null }) => + Effect.sync(() => { + calls.push(["updateDatabase", { id, input }]); + return database(id, input.branchId ?? null); + }), + } as unknown as PrismaManagementClient; + const props = { + project: "project-1", + name: "db", + branchId: "branch-feature", + }; + + return Effect.gen(function* () { + const provider = yield* PrismaDatabase.Provider; + const output = yield* provider.reconcile( + reconcileInput(props, attrs("database-1", "branch-main"), props), + ); + expect(output.branchId).toBe("branch-feature"); + expect(calls).toEqual([ + [ + "updateDatabase", + { + id: "database-1", + input: { + name: "db", + branchId: "branch-feature", + branchGitName: undefined, + }, + }, + ], + ]); + }).pipe(provide(client)); + }); + + it.effect("rejects explicit null branch props as unrepresentable", () => { + const client = {} as unknown as PrismaManagementClient; + + return Effect.gen(function* () { + const provider = yield* PrismaDatabase.Provider; + const reconcileError = yield* provider + .reconcile( + reconcileInput({ + project: "project-1", + name: "db", + branchGitName: null, + }), + ) + .pipe(Effect.flip); + expect(String(reconcileError)).toContain("requires an attached branch"); + + const diffError = yield* provider.diff!( + diffInput( + { project: "project-1", name: "db" }, + { project: "project-1", name: "db", branchId: null }, + attrs("database-1", "branch-main"), + ), + ).pipe(Effect.flip); + expect(String(diffError)).toContain("requires an attached branch"); + }).pipe(provide(client)); + }); + + it.effect("fails loudly when the project has no default branch", () => { + const client = { + listBranches: () => Effect.succeed([branch("branch-preview", false)]), + listProjectDatabases: () => Effect.succeed([]), + createDatabase: () => + Effect.die("must not create a database it cannot attach"), + } as unknown as PrismaManagementClient; + + return Effect.gen(function* () { + const provider = yield* PrismaDatabase.Provider; + const error = yield* provider + .reconcile(reconcileInput({ project: "project-1" })) + .pipe(Effect.flip); + expect(String(error)).toContain( + "has no default branch to attach database", + ); + expect(String(error)).toContain("Create or promote a default branch"); + }).pipe(provide(client)); + }); + + it.effect( + "converges an explicitly named create onto the default branch in the same reconcile", + () => { + const calls: Array<[string, unknown?]> = []; + const client = { + listBranches: () => Effect.succeed([branch("branch-main")]), + createDatabase: (input: { name?: string; branchId?: string }) => + Effect.sync(() => { + calls.push(["createDatabase", input]); + return database("database-1", input.branchId ?? null, { + name: input.name, + }); + }), + updateDatabase: (id: string, input: { branchId?: string | null }) => + Effect.sync(() => { + calls.push(["updateDatabase", { id, input }]); + return database(id, input.branchId ?? null); + }), + } as unknown as PrismaManagementClient; + + return Effect.gen(function* () { + const provider = yield* PrismaDatabase.Provider; + const output = yield* provider.reconcile( + reconcileInput({ project: "project-1", name: "db" }), + ); + + expect(output.branchId).toBe("branch-main"); + expect(calls.map(([name]) => name)).toEqual([ + "createDatabase", + "updateDatabase", + ]); + expect(calls[0]?.[1]).toMatchObject({ branchId: undefined }); + expect(calls[1]?.[1]).toMatchObject({ + input: { branchId: "branch-main" }, + }); + }).pipe(provide(client)); + }, + ); +}); diff --git a/packages/alchemy/test/Prisma/ManagementLifecycle.test.ts b/packages/alchemy/test/Prisma/ManagementLifecycle.test.ts index 19394d7165..0f9b5d62a2 100644 --- a/packages/alchemy/test/Prisma/ManagementLifecycle.test.ts +++ b/packages/alchemy/test/Prisma/ManagementLifecycle.test.ts @@ -872,6 +872,7 @@ const apiDatabase = ( region?: string; isDefault?: boolean; source?: ApiDatabase["source"]; + branchId?: string | null; }, ): ApiDatabase => ({ id, @@ -890,7 +891,7 @@ const apiDatabase = ( }, region: { id: input.region ?? "us-east-1", name: "Region" }, source: input.source ?? { type: "empty" }, - branchId: null, + branchId: input.branchId ?? null, }); const makeDatabaseCloud = () => { @@ -905,6 +906,27 @@ const makeDatabaseCloud = () => { (database) => database.project.id === projectId, ), ), + listBranches: (projectId: string) => + Effect.sync(() => { + calls.push(["listBranches", projectId]); + return [ + { + id: "branch-default", + type: "branch" as const, + url: "https://api.prisma.test/v1/branches/branch-default", + gitName: "main", + isDefault: true, + role: "production" as const, + createdAt, + updatedAt: createdAt, + project: { + id: projectId, + url: `https://api.prisma.test/v1/projects/${projectId}`, + name: "app", + }, + }, + ]; + }), getDatabase: (id: string) => Effect.suspend(() => { calls.push(["getDatabase", id]); @@ -926,6 +948,7 @@ const makeDatabaseCloud = () => { region?: string; isDefault?: boolean; source?: ApiDatabase["source"]; + branchId?: string; }) => Effect.sync(() => { calls.push(["createDatabase", input]); @@ -941,10 +964,15 @@ const makeDatabaseCloud = () => { databases.set(id, database); return database; }), - updateDatabase: (id: string, input: { name?: string }) => + updateDatabase: (id: string, input: { name?: string; branchId?: string }) => Effect.sync(() => { + calls.push(["updateDatabase", { id, input }]); const database = databases.get(id)!; - const updated = { ...database, name: input.name ?? database.name }; + const updated = { + ...database, + name: input.name ?? database.name, + branchId: input.branchId ?? database.branchId, + }; databases.set(id, updated); return updated; }), diff --git a/packages/alchemy/test/Prisma/Resources.test.ts b/packages/alchemy/test/Prisma/Resources.test.ts index 1ff0ae99d6..9ba35c5438 100644 --- a/packages/alchemy/test/Prisma/Resources.test.ts +++ b/packages/alchemy/test/Prisma/Resources.test.ts @@ -165,7 +165,50 @@ const makeClient = () => { project: resourceRef("projects", "project-1", "app"), region: { id: "us-east-1", name: "US East" }, source: { type: "empty" }, - branchId: null, + branchId: (input as { branchId?: string }).branchId ?? null, + }); + }, + updateDatabase: ( + id: string, + input: { name?: string; branchId?: string }, + ) => { + calls.push(["updateDatabase", { id, input }]); + return Effect.succeed({ + id, + type: "database", + url: `https://api.prisma.test/v1/databases/${id}`, + name: input.name ?? "main", + status: "ready", + createdAt, + isDefault: false, + defaultConnectionId: "connection-1", + connections: [ + { + id: "connection-1", + type: "connection", + url: "https://api.prisma.test/v1/connections/connection-1", + name: "default", + createdAt, + kind: "postgres", + endpoints: { + direct: { + host: "db.prisma.test", + port: 5432, + connectionString: "postgres://direct", + }, + pooled: { + host: "pool.prisma.test", + port: 5432, + connectionString: "postgres://pooled", + }, + }, + database: resourceRef("databases", id, "main"), + }, + ], + project: resourceRef("projects", "project-1", "app"), + region: { id: "us-east-1", name: "US East" }, + source: { type: "empty" }, + branchId: input.branchId ?? null, }); }, listDatabaseConnections: (databaseId: string, query: unknown) => { @@ -2024,6 +2067,18 @@ describe("Prisma resource providers", () => { branchGitName: undefined, }, ], + ["listBranches", { projectId: "project-1", query: { limit: 100 } }], + [ + "updateDatabase", + { + id: "database-1", + input: { + name: "main", + branchId: "branch-1", + branchGitName: undefined, + }, + }, + ], [ "listDatabaseConnections", { databaseId: "database-1", query: { limit: 100 } }, @@ -2098,7 +2153,7 @@ describe("Prisma resource providers", () => { "listBranches", { projectId: "project-1", - query: { gitName: "main", limit: 100 }, + query: { gitName: "main", limit: 2 }, }, ], ]); @@ -3412,15 +3467,11 @@ describe("Prisma resource providers", () => { expect(service.branchId).toBe("branch-main"); expect(calls).toEqual([ ["getDatabase", "database-1"], - [ - "listBranches", - { projectId: "project-1", query: { gitName: "main", limit: 2 } }, - ], [ "listBranches", { projectId: "project-1", - query: { gitName: "main", limit: 100 }, + query: { gitName: "main", limit: 2 }, }, ], ["getApp", "service-1"], @@ -3428,7 +3479,7 @@ describe("Prisma resource providers", () => { "listBranches", { projectId: "project-1", - query: { gitName: "main", limit: 100 }, + query: { gitName: "main", limit: 2 }, }, ], ]); @@ -3464,7 +3515,7 @@ describe("Prisma resource providers", () => { project: resourceRef("projects", "project-1", "app"), region: { id: "us-east-1", name: "US East" }, source: { type: "database" as const, databaseId: "source" }, - branchId: null, + branchId: "branch-1", }; const client = { getDatabase: (id: string) => @@ -3472,6 +3523,19 @@ describe("Prisma resource providers", () => { calls.push(["getDatabase", id]); return database; }), + listBranches: (projectId: string) => + Effect.succeed([ + { + id: "branch-1", + type: "branch" as const, + url: "https://api.prisma.test/v1/branches/branch-1", + gitName: "main", + isDefault: true, + createdAt, + updatedAt, + project: resourceRef("projects", projectId, "app"), + }, + ]), updateDatabase: () => Effect.die("normalized clone source must not trigger an update"), } as unknown as PrismaManagementClient; @@ -3494,7 +3558,7 @@ describe("Prisma resource providers", () => { status: "ready" as const, region: "us-east-1", isDefault: false, - branchId: null, + branchId: "branch-1", defaultConnectionId: "connection-clone", createdAt, directConnectionString: undefined, @@ -3513,91 +3577,99 @@ describe("Prisma resource providers", () => { }, ); - it.effect("detaches an observed branch when branch props are omitted", () => { - const calls: Call[] = []; - const database = { - id: "database-1", - type: "database" as const, - url: "https://api.prisma.test/v1/databases/database-1", - name: "main", - status: "ready" as const, - createdAt, - isDefault: false, - defaultConnectionId: "connection-1", - connections: [], - project: resourceRef("projects", "project-1", "app"), - region: { id: "us-east-1", name: "US East" }, - source: { type: "empty" as const }, - branchId: "branch-1", - }; - const client = { - getDatabase: (id: string) => - Effect.sync(() => { - calls.push(["getDatabase", id]); - return database; - }), - updateDatabase: (id: string, input: unknown) => - Effect.sync(() => { - calls.push(["updateDatabase", { id, input }]); - return { ...database, branchId: null }; - }), - rotateConnection: () => - Effect.die("persisted credentials must prevent an unrelated rotation"), - } as unknown as PrismaManagementClient; - - return Effect.gen(function* () { - const provider = yield* PrismaDatabase.Provider; - const result = yield* provider.reconcile( - reconcileInput( - "Database", - { - project: "project-1", - name: "main", - region: "us-east-1", - }, - { - databaseId: "database-1", - databaseName: "main", - projectId: "project-1", - status: "ready" as const, - region: "us-east-1", - isDefault: false, - branchId: "branch-1", - defaultConnectionId: "connection-1", - createdAt, - directConnectionString: Redacted.make("postgres://persisted"), - pooledConnectionString: undefined, - accelerateConnectionString: undefined, - host: "db.prisma.test", - user: "user", - password: undefined, - }, - { - project: "project-1", - name: "main", - region: "us-east-1", - branchId: "branch-1", - }, - ), - ); + it.effect( + "keeps the observed branch attachment when branch props are omitted", + () => { + const calls: Call[] = []; + const database = { + id: "database-1", + type: "database" as const, + url: "https://api.prisma.test/v1/databases/database-1", + name: "main", + status: "ready" as const, + createdAt, + isDefault: false, + defaultConnectionId: "connection-1", + connections: [], + project: resourceRef("projects", "project-1", "app"), + region: { id: "us-east-1", name: "US East" }, + source: { type: "empty" as const }, + branchId: "branch-1", + }; + const client = { + getDatabase: (id: string) => + Effect.sync(() => { + calls.push(["getDatabase", id]); + return database; + }), + listBranches: (projectId: string) => + Effect.sync(() => { + calls.push(["listBranches", projectId]); + return [ + { + id: "branch-1", + type: "branch" as const, + url: "https://api.prisma.test/v1/branches/branch-1", + gitName: "main", + isDefault: true, + createdAt, + updatedAt, + project: resourceRef("projects", projectId, "app"), + }, + ]; + }), + updateDatabase: () => + Effect.die("omitted branch props must never detach the database"), + rotateConnection: () => + Effect.die( + "persisted credentials must prevent an unrelated rotation", + ), + } as unknown as PrismaManagementClient; - expect(result.branchId).toBeNull(); - expect(calls).toEqual([ - ["getDatabase", "database-1"], - [ - "updateDatabase", - { - id: "database-1", - input: { + return Effect.gen(function* () { + const provider = yield* PrismaDatabase.Provider; + const result = yield* provider.reconcile( + reconcileInput( + "Database", + { + project: "project-1", name: "main", - branchId: null, - branchGitName: undefined, + region: "us-east-1", }, - }, - ], - ]); - }).pipe(Effect.provide(providerLayer(client))); - }); + { + databaseId: "database-1", + databaseName: "main", + projectId: "project-1", + status: "ready" as const, + region: "us-east-1", + isDefault: false, + branchId: "branch-1", + defaultConnectionId: "connection-1", + createdAt, + directConnectionString: Redacted.make("postgres://persisted"), + pooledConnectionString: undefined, + accelerateConnectionString: undefined, + host: "db.prisma.test", + user: "user", + password: undefined, + }, + { + project: "project-1", + name: "main", + region: "us-east-1", + branchId: "branch-1", + }, + ), + ); + + expect(result.branchId).toBe("branch-1"); + expect(calls).toEqual([ + ["getDatabase", "database-1"], + ["listBranches", "project-1"], + ]); + }).pipe(Effect.provide(providerLayer(client))); + }, + ); it.effect( "forces Project and Database reconcile when adoption rotation is enabled",