1import * as Plan from "@/Plan";
2import * as Planetscale from "@/Planetscale";
3import * as Stack from "@/Stack";
4import { Stage } from "@/Stage";
5import {
6 InMemoryService,
7 inMemoryState,
8 State,
9 type ResourceState,
10} from "@/State";
11import * as Test from "@/Test/Alchemy";
12import { describe, expect } from "alchemy-test";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as Redacted from "effect/Redacted";
16
17const TEST_STACK = "planetscale-plan-stables";
18const TEST_STAGE = "test";
19
20// Fresh in-memory state per test run so seeded resources from one test
21// don't leak into another in the same file.
22const freshState = Layer.effect(
23 State,
24 Effect.sync(() => InMemoryService({})),
25);
26
27const { test } = Test.make({
28 providers: Planetscale.providers(),
29 state: freshState,
30});
31
32const seed = (resources: Record<string, ResourceState>) =>
33 Effect.gen(function* () {
34 const state = yield* yield* State;
35 for (const [fqn, value] of Object.entries(resources)) {
36 yield* state.set({ stack: TEST_STACK, stage: TEST_STAGE, fqn, value });
37 }
38 });
39
40const instanceId = "852f6ec2e19b66589825efe14dca2971";
41
42const makePlan = <A, Err = never, Req = never>(
43 effect: Effect.Effect<A, Err, Req>,
44): Effect.Effect<Plan.Plan<A>, Err, State> =>
45 // @ts-expect-error - Stack.make's typing erases R unsoundly here
46 Effect.gen(function* () {
47 // @ts-expect-error
48 return yield* effect.pipe(
49 // @ts-expect-error
50 Stack.make({
51 name: TEST_STACK,
52 providers: Layer.empty,
53 state: inMemoryState(),
54 }),
55 Effect.provideService(Stage, TEST_STAGE),
56 Effect.flatMap((stackSpec: any) => Plan.make(stackSpec)),
57 Effect.provide(Planetscale.providers()),
58 );
59 });
60
61const ORG = "test-org";
62const DB_NAME = "relay-db";
63const BRANCH_NAME = "relay-branch";
64const ROLE_NAME = "relay-role";
65
66/** Attributes of the database as persisted after a previous deploy. */
67const databaseAttrs = (migrationsHashes: Record<string, string>) => ({
68 id: "db_123",
69 name: DB_NAME,
70 organization: ORG,
71 state: "ready",
72 defaultBranch: "main",
73 plan: "scaler",
74 createdAt: "2026-01-01T00:00:00Z",
75 updatedAt: "2026-01-01T00:00:00Z",
76 htmlUrl: `https://app.planetscale.com/${ORG}/${DB_NAME}`,
77 region: { slug: "us-east" },
78 migrationsDir: "./migrations",
79 migrationsTable: "relay_migrations",
80 migrationsHashes,
81 importHashes: {},
82 clusterSize: "PS_10",
83 arch: "x86" as const,
84 requireApprovalForDeploy: false,
85 restrictBranchRegion: false,
86 productionBranchWebConsole: false,
87});
88
89const branchAttrs = () => ({
90 name: BRANCH_NAME,
91 organization: ORG,
92 database: DB_NAME,
93 parentBranch: "main",
94 production: false,
95 createdAt: "2026-01-01T00:00:00Z",
96 updatedAt: "2026-01-01T00:00:00Z",
97 htmlUrl: `https://app.planetscale.com/${ORG}/${DB_NAME}/${BRANCH_NAME}`,
98 region: { slug: "us-east" },
99 migrationsDir: undefined,
100 migrationsTable: undefined,
101 migrationsHashes: {},
102 importHashes: {},
103 desiredReplicas: undefined,
104 hasReplicas: false,
105 hasReadOnlyReplicas: false,
106});
107
108const roleAttrs = () => ({
109 id: "role_123",
110 name: ROLE_NAME,
111 expiresAt: null,
112 host: "gateway.psdb.cloud",
113 username: ROLE_NAME,
114 password: Redacted.make("secret"),
115 database: DB_NAME,
116 databaseName: "postgres",
117 origin: {
118 scheme: "postgres",
119 host: "gateway.psdb.cloud",
120 port: 5432,
121 database: "postgres",
122 user: ROLE_NAME,
123 password: Redacted.make("secret"),
124 },
125 pooledOrigin: {
126 scheme: "postgres",
127 host: "gateway.psdb.cloud",
128 port: 6432,
129 database: "postgres",
130 user: ROLE_NAME,
131 password: Redacted.make("secret"),
132 },
133 connectionUrl: Redacted.make("postgresql://..."),
134 connectionUrlPooled: Redacted.make("postgresql://..."),
135 inheritedRoles: ["pg_read_all_data"],
136 successor: "postgres",
137 organization: ORG,
138 branch: BRANCH_NAME,
139 ttl: null,
140});
141
142const stateRow = (
143 fqn: string,
144 resourceType: string,
145 props: Record<string, unknown>,
146 attr: Record<string, unknown>,
147): ResourceState => ({
148 instanceId,
149 providerVersion: 0,
150 logicalId: fqn,
151 fqn,
152 namespace: undefined,
153 resourceType,
154 status: "created",
155 props,
156 attr,
157 bindings: [],
158 downstream: [],
159});
160
161describe(
162 "Planetscale plan-time name stables",
163 { tags: ["provider:planetscale", "provider:planetscale:postgres", "live"] },
164 () => {
165 // Regression: a metadata-only update on a PostgresBranch (e.g. a
166 // referenced database's `migrationsHashes` moved) must not strip
167 // `branch.name` from downstream plan resolution — PostgresRole used to
168 // compare the old branch name against `undefined` and falsely plan a
169 // replacement (rotating credentials and crashing on the replacement's
170 // reconcile with `branch: undefined`).
171 test(
172 "role updates (not replaces) when its branch gets a metadata-only update",
173 Effect.gen(function* () {
174 // The database is an external ref (another stack/stage); its attrs
175 // arrive in props as a plain object. The previous deploy persisted
176 // the old migration hash; the new plan resolves the new one.
177 const oldDatabase = databaseAttrs({ "0001_init.sql": "aaa" });
178 const newDatabase = databaseAttrs({
179 "0001_init.sql": "aaa",
180 "0002_add_table.sql": "bbb",
181 });
182
183 yield* seed({
184 Branch: stateRow(
185 "Branch",
186 "Planetscale.PostgresBranch",
187 { name: BRANCH_NAME, database: oldDatabase },
188 branchAttrs(),
189 ),
190 Role: stateRow(
191 "Role",
192 "Planetscale.PostgresRole",
193 {
194 name: ROLE_NAME,
195 database: oldDatabase,
196 branch: branchAttrs(),
197 inheritedRoles: ["pg_read_all_data"],
198 },
199 roleAttrs(),
200 ),
201 });
202
203 const plan = yield* Effect.gen(function* () {
204 const branch = yield* Planetscale.PostgresBranch("Branch", {
205 name: BRANCH_NAME,
206 database: newDatabase as unknown as Planetscale.PostgresDatabase,
207 });
208 yield* Planetscale.PostgresRole("Role", {
209 name: ROLE_NAME,
210 database: newDatabase as unknown as Planetscale.PostgresDatabase,
211 branch,
212 inheritedRoles: ["pg_read_all_data"],
213 });
214 }).pipe(makePlan);
215
216 expect(plan.resources.Branch!.action).toBe("update");
217 // The branch update is not a rename, so `branch.name` stays
218 // resolvable and the role must not be replaced.
219 expect(plan.resources.Role!.action).toBe("update");
220 }),
221 );
222
223 // Regression: an in-place update on a PostgresDatabase (settings toggle,
224 // new migration hash, …) must keep `database.name` resolvable — the
225 // branch used to compare its persisted database name against `undefined`
226 // and plan a replacement, cascading into the role.
227 test(
228 "branch and role update (not replace) when the database gets an in-place update",
229 Effect.gen(function* () {
230 const dbAttrs = databaseAttrs({});
231
232 yield* seed({
233 Db: stateRow(
234 "Db",
235 "Planetscale.PostgresDatabase",
236 {
237 name: DB_NAME,
238 clusterSize: "PS_10",
239 requireApprovalForDeploy: false,
240 },
241 dbAttrs,
242 ),
243 Branch: stateRow(
244 "Branch",
245 "Planetscale.PostgresBranch",
246 { name: BRANCH_NAME, database: dbAttrs },
247 branchAttrs(),
248 ),
249 Role: stateRow(
250 "Role",
251 "Planetscale.PostgresRole",
252 {
253 name: ROLE_NAME,
254 database: dbAttrs,
255 branch: branchAttrs(),
256 inheritedRoles: ["pg_read_all_data"],
257 },
258 roleAttrs(),
259 ),
260 });
261
262 const plan = yield* Effect.gen(function* () {
263 const db = yield* Planetscale.PostgresDatabase("Db", {
264 name: DB_NAME,
265 clusterSize: "PS_10",
266 // In-place settings change — triggers an update, not a rename.
267 requireApprovalForDeploy: true,
268 });
269 const branch = yield* Planetscale.PostgresBranch("Branch", {
270 name: BRANCH_NAME,
271 database: db,
272 });
273 yield* Planetscale.PostgresRole("Role", {
274 name: ROLE_NAME,
275 database: db,
276 branch,
277 inheritedRoles: ["pg_read_all_data"],
278 });
279 }).pipe(makePlan);
280
281 expect(plan.resources.Db!.action).toBe("update");
282 expect(plan.resources.Branch!.action).toBe("update");
283 expect(plan.resources.Role!.action).toBe("update");
284 }),
285 );
286
287 // A rename IS an identity change for downstream consumers: `name` must
288 // not be advertised as stable, and the role must plan a replacement.
289 test(
290 "role still replaces when the branch is renamed",
291 Effect.gen(function* () {
292 const database = databaseAttrs({});
293
294 yield* seed({
295 Branch: stateRow(
296 "Branch",
297 "Planetscale.PostgresBranch",
298 { name: BRANCH_NAME, database },
299 branchAttrs(),
300 ),
301 Role: stateRow(
302 "Role",
303 "Planetscale.PostgresRole",
304 {
305 name: ROLE_NAME,
306 database,
307 branch: branchAttrs(),
308 inheritedRoles: ["pg_read_all_data"],
309 },
310 roleAttrs(),
311 ),
312 });
313
314 const plan = yield* Effect.gen(function* () {
315 const branch = yield* Planetscale.PostgresBranch("Branch", {
316 name: "renamed-branch",
317 database: database as unknown as Planetscale.PostgresDatabase,
318 });
319 yield* Planetscale.PostgresRole("Role", {
320 name: ROLE_NAME,
321 database: database as unknown as Planetscale.PostgresDatabase,
322 branch,
323 inheritedRoles: ["pg_read_all_data"],
324 });
325 }).pipe(makePlan);
326
327 expect(plan.resources.Branch!.action).toBe("update");
328 expect(plan.resources.Role!.action).toBe("replace");
329 }),
330 );
331 },
332);