| 1 | import * as Plan from "@/Plan"; |
| 2 | import * as Planetscale from "@/Planetscale"; |
| 3 | import * as Stack from "@/Stack"; |
| 4 | import { Stage } from "@/Stage"; |
| 5 | import { |
| 6 | InMemoryService, |
| 7 | inMemoryState, |
| 8 | State, |
| 9 | type ResourceState, |
| 10 | } from "@/State"; |
| 11 | import * as Test from "@/Test/Alchemy"; |
| 12 | import { describe, expect } from "alchemy-test"; |
| 13 | import * as Effect from "effect/Effect"; |
| 14 | import * as Layer from "effect/Layer"; |
| 15 | import * as Redacted from "effect/Redacted"; |
| 16 | |
| 17 | const TEST_STACK = "planetscale-plan-stables"; |
| 18 | const 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. |
| 22 | const freshState = Layer.effect( |
| 23 | State, |
| 24 | Effect.sync(() => InMemoryService({})), |
| 25 | ); |
| 26 | |
| 27 | const { test } = Test.make({ |
| 28 | providers: Planetscale.providers(), |
| 29 | state: freshState, |
| 30 | }); |
| 31 | |
| 32 | const 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 | |
| 40 | const instanceId = "852f6ec2e19b66589825efe14dca2971"; |
| 41 | |
| 42 | const 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 | |
| 61 | const ORG = "test-org"; |
| 62 | const DB_NAME = "relay-db"; |
| 63 | const BRANCH_NAME = "relay-branch"; |
| 64 | const ROLE_NAME = "relay-role"; |
| 65 | |
| 66 | /** Attributes of the database as persisted after a previous deploy. */ |
| 67 | const 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 | |
| 89 | const 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 | |
| 108 | const 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 | |
| 142 | const 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 | |
| 161 | describe( |
| 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 | ); |