| 1 | import { Branch } from "@/Neon/Branch"; |
| 2 | import type { PostgresOrigin } from "@/Neon/PostgresOrigin"; |
| 3 | import { Project, type ProjectProps } from "@/Neon/Project"; |
| 4 | import { providers } from "@/Neon/Providers"; |
| 5 | import { runSql, withPgClient } from "@/Neon/Migrations.ts"; |
| 6 | import { makePgMigrationExecutor } from "@/SQL/Migrations/index.ts"; |
| 7 | import * as Provider from "@/Provider"; |
| 8 | import { hashMigrations } from "@/SQL/SqlFile.ts"; |
| 9 | import * as Test from "@/Test/Alchemy"; |
| 10 | import { |
| 11 | createProject, |
| 12 | deleteProject, |
| 13 | getConnectionURI, |
| 14 | getProject, |
| 15 | updateProject, |
| 16 | } from "@distilled.cloud/neon"; |
| 17 | import { adopt, OwnedBySomeoneElse, Unowned } from "@/AdoptPolicy"; |
| 18 | import * as Result from "effect/Result"; |
| 19 | import { waitForOperations } from "@/Neon/Project"; |
| 20 | import { expect } from "alchemy-test"; |
| 21 | import * as Effect from "effect/Effect"; |
| 22 | import * as FileSystem from "effect/FileSystem"; |
| 23 | import * as Path from "effect/Path"; |
| 24 | import * as Redacted from "effect/Redacted"; |
| 25 | import { MinimumLogLevel } from "effect/References"; |
| 26 | |
| 27 | const { test } = Test.make({ providers: providers() }); |
| 28 | |
| 29 | const logLevel = Effect.provideService( |
| 30 | MinimumLogLevel, |
| 31 | process.env.DEBUG ? "Debug" : "Info", |
| 32 | ); |
| 33 | |
| 34 | const expectPooledOrigin = (project: { |
| 35 | pooledConnectionUri: string; |
| 36 | pooledOrigin: PostgresOrigin; |
| 37 | }) => { |
| 38 | const uri = new URL(project.pooledConnectionUri); |
| 39 | expect(project.pooledOrigin).toMatchObject({ |
| 40 | scheme: uri.protocol === "postgresql:" ? "postgresql" : "postgres", |
| 41 | host: uri.hostname, |
| 42 | port: uri.port ? Number(uri.port) : 5432, |
| 43 | database: uri.pathname.replace(/^\//, ""), |
| 44 | user: decodeURIComponent(uri.username), |
| 45 | }); |
| 46 | expect(project.pooledOrigin.password).toBeDefined(); |
| 47 | }; |
| 48 | |
| 49 | test.provider( |
| 50 | "create and delete project with default props", |
| 51 | (stack) => |
| 52 | Effect.gen(function* () { |
| 53 | yield* stack.destroy(); |
| 54 | |
| 55 | const project = yield* stack.deploy( |
| 56 | Effect.gen(function* () { |
| 57 | return yield* Project("DefaultProject"); |
| 58 | }), |
| 59 | ); |
| 60 | |
| 61 | expect(project.projectId).toBeDefined(); |
| 62 | expect(project.projectName).toBeDefined(); |
| 63 | expect(project.defaultBranchId).toBeDefined(); |
| 64 | expect(project.connectionUri).toContain("postgres"); |
| 65 | expect(project.pooledConnectionUri).toContain("postgres"); |
| 66 | expectPooledOrigin(project); |
| 67 | |
| 68 | const fetched = yield* getProject({ project_id: project.projectId }); |
| 69 | expect(fetched.project.id).toEqual(project.projectId); |
| 70 | |
| 71 | yield* stack.destroy(); |
| 72 | }).pipe(logLevel), |
| 73 | { tags: ["provider:neon", "provider:neon:project", "live"] }, |
| 74 | ); |
| 75 | |
| 76 | test.provider( |
| 77 | "project with default props does not change on update", |
| 78 | (stack) => |
| 79 | Effect.gen(function* () { |
| 80 | yield* stack.destroy(); |
| 81 | |
| 82 | const deploy = stack.deploy(Project("DefaultProjectUpdate")); |
| 83 | |
| 84 | const created = yield* deploy; |
| 85 | |
| 86 | expect(created.projectId).toBeDefined(); |
| 87 | expect(created.projectName).toBeDefined(); |
| 88 | expect(created.defaultBranchId).toBeDefined(); |
| 89 | expect(created.connectionUri).toContain("postgres"); |
| 90 | expectPooledOrigin(created); |
| 91 | |
| 92 | const fetched = yield* getProject({ project_id: created.projectId }); |
| 93 | expect(fetched.project.id).toEqual(created.projectId); |
| 94 | |
| 95 | const updated = yield* deploy; |
| 96 | |
| 97 | expect(updated.projectId).toEqual(created.projectId); |
| 98 | expect(updated.projectName).toEqual(created.projectName); |
| 99 | expect(updated.defaultBranchId).toEqual(created.defaultBranchId); |
| 100 | expect(updated.connectionUri).toEqual(created.connectionUri); |
| 101 | expectPooledOrigin(updated); |
| 102 | |
| 103 | const renamed = yield* stack.deploy( |
| 104 | Project("DefaultProjectUpdate", { |
| 105 | name: `${created.projectName}-renamed`, |
| 106 | }), |
| 107 | ); |
| 108 | expect(renamed.projectId).toBe(created.projectId); |
| 109 | expect( |
| 110 | (yield* getProject({ project_id: renamed.projectId })).project.name, |
| 111 | ).toBe(`${created.projectName}-renamed`); |
| 112 | const preserved = yield* deploy; |
| 113 | expect(preserved.projectId).toBe(created.projectId); |
| 114 | expect(preserved.projectName).toBe(renamed.projectName); |
| 115 | |
| 116 | yield* stack.destroy(); |
| 117 | }).pipe(logLevel), |
| 118 | { tags: ["provider:neon", "provider:neon:project", "live"] }, |
| 119 | ); |
| 120 | |
| 121 | test.provider( |
| 122 | "enable logical replication on update", |
| 123 | (stack) => |
| 124 | Effect.gen(function* () { |
| 125 | yield* stack.destroy(); |
| 126 | |
| 127 | const initial = yield* stack.deploy( |
| 128 | Effect.gen(function* () { |
| 129 | return yield* Project("LogicalReplicationProject", { |
| 130 | region: "aws-us-east-1", |
| 131 | }); |
| 132 | }), |
| 133 | ); |
| 134 | expect(initial.enableLogicalReplication).toEqual(false); |
| 135 | |
| 136 | const enabled = yield* stack.deploy( |
| 137 | Effect.gen(function* () { |
| 138 | return yield* Project("LogicalReplicationProject", { |
| 139 | region: "aws-us-east-1", |
| 140 | enableLogicalReplication: true, |
| 141 | }); |
| 142 | }), |
| 143 | ); |
| 144 | expect(enabled.projectId).toEqual(initial.projectId); |
| 145 | expect(enabled.enableLogicalReplication).toEqual(true); |
| 146 | |
| 147 | const fetched = yield* getProject({ project_id: enabled.projectId }); |
| 148 | expect(fetched.project.settings).toMatchObject({ |
| 149 | enable_logical_replication: true, |
| 150 | }); |
| 151 | |
| 152 | yield* stack.destroy(); |
| 153 | }).pipe(logLevel), |
| 154 | { tags: ["provider:neon", "provider:neon:project", "live"] }, |
| 155 | ); |
| 156 | |
| 157 | test.provider( |
| 158 | "observes project drift, removal, adoption, and missing cached identity", |
| 159 | (stack) => |
| 160 | Effect.gen(function* () { |
| 161 | yield* stack.destroy(); |
| 162 | const deploy = (historyRetentionSeconds?: number) => |
| 163 | stack.deploy( |
| 164 | Project("ObservedProject", { |
| 165 | defaultBranchName: "production", |
| 166 | historyRetentionSeconds, |
| 167 | }), |
| 168 | ); |
| 169 | const initial = yield* deploy(21600); |
| 170 | expect(initial.defaultBranchName).toBe("production"); |
| 171 | const provider = yield* Provider.findProvider(Project); |
| 172 | const context = { |
| 173 | id: "ObservedProject", |
| 174 | fqn: "ObservedProject", |
| 175 | instanceId: "observed-project", |
| 176 | bindings: [], |
| 177 | session: { |
| 178 | emit: () => Effect.void, |
| 179 | done: () => Effect.void, |
| 180 | note: () => Effect.void, |
| 181 | }, |
| 182 | }; |
| 183 | const news = { |
| 184 | name: initial.projectName, |
| 185 | defaultBranchName: "production", |
| 186 | historyRetentionSeconds: 21600, |
| 187 | }; |
| 188 | const unowned = yield* provider.read!({ |
| 189 | ...context, |
| 190 | olds: news, |
| 191 | output: undefined, |
| 192 | }); |
| 193 | expect(Unowned.is(unowned)).toBe(true); |
| 194 | const drift = yield* updateProject({ |
| 195 | project_id: initial.projectId, |
| 196 | project: { history_retention_seconds: 0 }, |
| 197 | }); |
| 198 | yield* waitForOperations(drift.operations); |
| 199 | const adopted = yield* provider.reconcile({ |
| 200 | ...context, |
| 201 | news, |
| 202 | olds: undefined, |
| 203 | output: initial, |
| 204 | }); |
| 205 | expect(adopted.historyRetentionSeconds).toBe(21600); |
| 206 | const reset = yield* deploy(); |
| 207 | expect( |
| 208 | (yield* getProject({ project_id: reset.projectId })).project |
| 209 | .history_retention_seconds, |
| 210 | ).toBe(86400); |
| 211 | const uri = yield* getConnectionURI({ |
| 212 | project_id: reset.projectId, |
| 213 | branch_id: reset.defaultBranchId, |
| 214 | database_name: reset.databaseName, |
| 215 | role_name: reset.roleName, |
| 216 | pooled: true, |
| 217 | }); |
| 218 | expect(reset.pooledConnectionUri === uri.uri).toBe(true); |
| 219 | yield* deleteProject({ project_id: reset.projectId }); |
| 220 | const recovered = yield* deploy(21600); |
| 221 | expect(recovered.projectId).not.toBe(reset.projectId); |
| 222 | const persisted = yield* deploy(21600); |
| 223 | expect(persisted.projectId).toBe(recovered.projectId); |
| 224 | yield* stack.destroy(); |
| 225 | yield* stack.destroy(); |
| 226 | expect( |
| 227 | yield* getProject({ project_id: recovered.projectId }).pipe( |
| 228 | Effect.as(false), |
| 229 | Effect.catchTag("NotFound", () => Effect.succeed(true)), |
| 230 | ), |
| 231 | ).toBe(true); |
| 232 | }), |
| 233 | { |
| 234 | tags: ["provider:neon", "provider:neon:project", "live"], |
| 235 | timeout: 120_000, |
| 236 | }, |
| 237 | ); |
| 238 | |
| 239 | test.provider( |
| 240 | "explicit project names use delete-first immutable replacements", |
| 241 | (stack) => |
| 242 | Effect.gen(function* () { |
| 243 | yield* stack.destroy(); |
| 244 | const program = (props: ProjectProps = {}) => |
| 245 | Project("NamedProject", { |
| 246 | name: "alchemy-neon-named-replacement", |
| 247 | ...props, |
| 248 | }); |
| 249 | const initial = yield* stack.deploy(program()); |
| 250 | for (const props of [ |
| 251 | { region: "aws-us-west-2" }, |
| 252 | { pgVersion: 16 }, |
| 253 | { defaultBranchName: "production" }, |
| 254 | { databaseName: "application" }, |
| 255 | { roleName: "application_owner" }, |
| 256 | ] satisfies ProjectProps[]) { |
| 257 | const plan = yield* stack.plan(program(props)); |
| 258 | expect(plan.resources.NamedProject).toMatchObject({ |
| 259 | action: "replace", |
| 260 | deleteFirst: true, |
| 261 | }); |
| 262 | } |
| 263 | const renamed = yield* stack.plan( |
| 264 | program({ |
| 265 | name: "alchemy-neon-renamed-replacement", |
| 266 | databaseName: "application", |
| 267 | }), |
| 268 | ); |
| 269 | expect(renamed.resources.NamedProject).toMatchObject({ |
| 270 | action: "replace", |
| 271 | deleteFirst: false, |
| 272 | }); |
| 273 | const provider = yield* Provider.findProvider(Project); |
| 274 | const recovery = yield* provider |
| 275 | .reconcile({ |
| 276 | id: "NamedProject", |
| 277 | fqn: "NamedProject", |
| 278 | instanceId: "replacement-project", |
| 279 | news: { name: initial.projectName, databaseName: "application" }, |
| 280 | olds: undefined, |
| 281 | output: undefined, |
| 282 | bindings: [], |
| 283 | session: { |
| 284 | emit: () => Effect.void, |
| 285 | done: () => Effect.void, |
| 286 | note: () => Effect.void, |
| 287 | }, |
| 288 | }) |
| 289 | .pipe(Effect.result); |
| 290 | expect(Result.isFailure(recovery)).toBe(true); |
| 291 | if (Result.isFailure(recovery)) |
| 292 | expect(recovery.failure).toBeInstanceOf(OwnedBySomeoneElse); |
| 293 | const replaced = yield* stack.deploy( |
| 294 | program({ databaseName: "application" }), |
| 295 | ); |
| 296 | expect(replaced.projectId).not.toBe(initial.projectId); |
| 297 | expect(replaced.projectName).toBe(initial.projectName); |
| 298 | expect(replaced.databaseName).toBe("application"); |
| 299 | expect( |
| 300 | (yield* getProject({ project_id: replaced.projectId })).project.id, |
| 301 | ).toBe(replaced.projectId); |
| 302 | expect( |
| 303 | yield* getProject({ project_id: initial.projectId }).pipe( |
| 304 | Effect.as(false), |
| 305 | Effect.catchTag("NotFound", () => Effect.succeed(true)), |
| 306 | ), |
| 307 | ).toBe(true); |
| 308 | const stable = yield* stack.deploy( |
| 309 | program({ databaseName: "application" }), |
| 310 | ); |
| 311 | expect(stable.projectId).toBe(replaced.projectId); |
| 312 | yield* stack.destroy(); |
| 313 | expect( |
| 314 | yield* getProject({ project_id: replaced.projectId }).pipe( |
| 315 | Effect.as(false), |
| 316 | Effect.catchTag("NotFound", () => Effect.succeed(true)), |
| 317 | ), |
| 318 | ).toBe(true); |
| 319 | }), |
| 320 | { |
| 321 | tags: ["provider:neon", "provider:neon:project", "live"], |
| 322 | timeout: 120_000, |
| 323 | }, |
| 324 | ); |
| 325 | |
| 326 | test.provider( |
| 327 | "refuses late same-name projects and foreign replacements of cached identities", |
| 328 | (stack) => |
| 329 | Effect.gen(function* () { |
| 330 | yield* stack.destroy(); |
| 331 | const owners = Effect.gen(function* () { |
| 332 | const cached = yield* Project("CachedProject"); |
| 333 | const foreign = yield* Project("ForeignProject"); |
| 334 | return { cached, foreign }; |
| 335 | }); |
| 336 | const initial = yield* stack.deploy(owners); |
| 337 | const name = "alchemy-neon-late-project"; |
| 338 | const plan = yield* stack.plan( |
| 339 | Effect.gen(function* () { |
| 340 | yield* owners; |
| 341 | return yield* Project("LateProject", { name }); |
| 342 | }), |
| 343 | ); |
| 344 | expect(plan.resources.LateProject.action).toBe("create"); |
| 345 | const renamed = yield* updateProject({ |
| 346 | project_id: initial.foreign.projectId, |
| 347 | project: { name, history_retention_seconds: 21600 }, |
| 348 | }); |
| 349 | yield* waitForOperations(renamed.operations); |
| 350 | const provider = yield* Provider.findProvider(Project); |
| 351 | const context = { |
| 352 | id: "LateProject", |
| 353 | fqn: "LateProject", |
| 354 | instanceId: "late-project", |
| 355 | bindings: [], |
| 356 | session: { |
| 357 | emit: () => Effect.void, |
| 358 | done: () => Effect.void, |
| 359 | note: () => Effect.void, |
| 360 | }, |
| 361 | }; |
| 362 | const late = yield* provider |
| 363 | .reconcile({ |
| 364 | ...context, |
| 365 | news: { name }, |
| 366 | olds: undefined, |
| 367 | output: undefined, |
| 368 | }) |
| 369 | .pipe(Effect.result); |
| 370 | expect(Result.isFailure(late)).toBe(true); |
| 371 | if (Result.isFailure(late)) |
| 372 | expect(late.failure).toBeInstanceOf(OwnedBySomeoneElse); |
| 373 | yield* deleteProject({ project_id: initial.cached.projectId }); |
| 374 | const cached = yield* provider |
| 375 | .reconcile({ |
| 376 | ...context, |
| 377 | news: { name }, |
| 378 | olds: {}, |
| 379 | output: initial.cached, |
| 380 | }) |
| 381 | .pipe(Effect.result); |
| 382 | expect(Result.isFailure(cached)).toBe(true); |
| 383 | if (Result.isFailure(cached)) |
| 384 | expect(cached.failure).toBeInstanceOf(OwnedBySomeoneElse); |
| 385 | const observed = yield* getProject({ |
| 386 | project_id: initial.foreign.projectId, |
| 387 | }); |
| 388 | expect(observed.project.history_retention_seconds).toBe(21600); |
| 389 | expect(observed.project.name).toBe(name); |
| 390 | yield* stack.destroy(); |
| 391 | for (const project of [initial.cached, initial.foreign]) { |
| 392 | expect( |
| 393 | yield* getProject({ project_id: project.projectId }).pipe( |
| 394 | Effect.as(false), |
| 395 | Effect.catchTag("NotFound", () => Effect.succeed(true)), |
| 396 | ), |
| 397 | ).toBe(true); |
| 398 | } |
| 399 | }), |
| 400 | { |
| 401 | tags: ["provider:neon", "provider:neon:project", "live"], |
| 402 | timeout: 120_000, |
| 403 | }, |
| 404 | ); |
| 405 | |
| 406 | test.provider( |
| 407 | "explicit adoption preserves customized project database and role defaults", |
| 408 | (stack) => |
| 409 | Effect.gen(function* () { |
| 410 | yield* stack.destroy(); |
| 411 | const name = "alchemy-neon-custom-adoption"; |
| 412 | const foreign = yield* createProject({ |
| 413 | project: { |
| 414 | name, |
| 415 | region_id: "aws-us-east-1", |
| 416 | pg_version: 17, |
| 417 | branch: { |
| 418 | name: "production", |
| 419 | database_name: "application", |
| 420 | role_name: "application_owner", |
| 421 | }, |
| 422 | }, |
| 423 | }); |
| 424 | yield* waitForOperations(foreign.operations); |
| 425 | const program = (allow: boolean) => |
| 426 | Project("CustomizedProject", { name }).pipe(adopt(allow)); |
| 427 | const refused = yield* stack.plan(program(false)).pipe(Effect.result); |
| 428 | expect(Result.isFailure(refused)).toBe(true); |
| 429 | if (Result.isFailure(refused)) |
| 430 | expect(refused.failure).toBeInstanceOf(OwnedBySomeoneElse); |
| 431 | const plan = yield* stack.plan(program(true)); |
| 432 | expect(plan.resources.CustomizedProject.action).not.toBe("replace"); |
| 433 | const adopted = yield* stack.deploy(program(true)); |
| 434 | expect(adopted.projectId).toBe(foreign.project.id); |
| 435 | expect(adopted.defaultBranchName).toBe("production"); |
| 436 | expect(adopted.databaseName).toBe("application"); |
| 437 | expect(adopted.roleName).toBe("application_owner"); |
| 438 | const stable = yield* stack.plan(program(false)); |
| 439 | expect(stable.resources.CustomizedProject.action).toBe("noop"); |
| 440 | expect((yield* stack.deploy(program(false))).projectId).toBe( |
| 441 | foreign.project.id, |
| 442 | ); |
| 443 | expect( |
| 444 | (yield* getProject({ project_id: foreign.project.id })).project.id, |
| 445 | ).toBe(foreign.project.id); |
| 446 | yield* stack.destroy(); |
| 447 | expect( |
| 448 | yield* getProject({ project_id: foreign.project.id }).pipe( |
| 449 | Effect.as(false), |
| 450 | Effect.catchTag("NotFound", () => Effect.succeed(true)), |
| 451 | ), |
| 452 | ).toBe(true); |
| 453 | }), |
| 454 | { |
| 455 | tags: ["provider:neon", "provider:neon:project", "live"], |
| 456 | timeout: 120_000, |
| 457 | }, |
| 458 | ); |
| 459 | |
| 460 | test.provider( |
| 461 | "list enumerates the deployed project", |
| 462 | (stack) => |
| 463 | Effect.gen(function* () { |
| 464 | yield* stack.destroy(); |
| 465 | |
| 466 | const deployed = yield* stack.deploy( |
| 467 | Effect.gen(function* () { |
| 468 | return yield* Project("ListProject"); |
| 469 | }), |
| 470 | ); |
| 471 | |
| 472 | const provider = yield* Provider.findProvider(Project); |
| 473 | const all = yield* provider.list(); |
| 474 | |
| 475 | const found = all.find((p) => p.projectId === deployed.projectId); |
| 476 | expect(found).toBeDefined(); |
| 477 | expectPooledOrigin(found!); |
| 478 | |
| 479 | yield* stack.destroy(); |
| 480 | }).pipe(logLevel), |
| 481 | { tags: ["provider:neon", "provider:neon:project", "live"] }, |
| 482 | ); |
| 483 | |
| 484 | /** |
| 485 | * Adopting a drizzle-kit-migrated Postgres database: drizzle's table lives |
| 486 | * schema-qualified at `drizzle.__drizzle_migrations` on pg. The first |
| 487 | * deploy with migrations converts that history into the public |
| 488 | * `__alchemy_migrations` (hashes carried verbatim) and freezes drizzle's |
| 489 | * table. This is the only place the schema-qualified source read and the |
| 490 | * pg-dialect conversion DDL execute against a real Postgres. |
| 491 | */ |
| 492 | test.provider( |
| 493 | "adopts a drizzle-kit-migrated Postgres database via one-way conversion", |
| 494 | (stack) => |
| 495 | Effect.gen(function* () { |
| 496 | const fs = yield* FileSystem.FileSystem; |
| 497 | const path = yield* Path.Path; |
| 498 | const migrationsDir = yield* fs.makeTempDirectory({ |
| 499 | prefix: "alchemy-neon-drizzle-", |
| 500 | }); |
| 501 | const initSql = |
| 502 | "CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT NOT NULL);"; |
| 503 | yield* fs.makeDirectory(path.join(migrationsDir, "20240101000000_init")); |
| 504 | yield* fs.writeFileString( |
| 505 | path.join(migrationsDir, "20240101000000_init", "migration.sql"), |
| 506 | initSql, |
| 507 | ); |
| 508 | const initHash = yield* hashMigrations(migrationsDir).pipe( |
| 509 | Effect.map((hashes) => Object.values(hashes)[0]), |
| 510 | ); |
| 511 | |
| 512 | yield* stack.destroy(); |
| 513 | |
| 514 | // Phase 1: what `drizzle-kit migrate` left behind on Postgres. |
| 515 | const seeded = yield* stack.deploy( |
| 516 | Effect.gen(function* () { |
| 517 | return yield* Project("DrizzleAdoptionProject"); |
| 518 | }), |
| 519 | ); |
| 520 | const connectionUri = Redacted.make(seeded.connectionUri); |
| 521 | yield* runSql(connectionUri, initSql); |
| 522 | yield* runSql(connectionUri, "CREATE SCHEMA IF NOT EXISTS drizzle;"); |
| 523 | yield* runSql( |
| 524 | connectionUri, |
| 525 | `CREATE TABLE drizzle.__drizzle_migrations ( |
| 526 | id SERIAL PRIMARY KEY, |
| 527 | hash text NOT NULL, |
| 528 | created_at bigint, |
| 529 | name text, |
| 530 | applied_at timestamp with time zone DEFAULT now() |
| 531 | );`, |
| 532 | ); |
| 533 | yield* runSql( |
| 534 | connectionUri, |
| 535 | `INSERT INTO drizzle.__drizzle_migrations (hash, created_at, name) |
| 536 | VALUES ('${initHash}', 1704067200000, '20240101000000_init');`, |
| 537 | ); |
| 538 | |
| 539 | // Phase 2: first deploy with migrations + a pending one. |
| 540 | yield* fs.makeDirectory(path.join(migrationsDir, "20240102000000_posts")); |
| 541 | yield* fs.writeFileString( |
| 542 | path.join(migrationsDir, "20240102000000_posts", "migration.sql"), |
| 543 | "CREATE TABLE posts (id SERIAL PRIMARY KEY, title TEXT NOT NULL);", |
| 544 | ); |
| 545 | const project = yield* stack.deploy( |
| 546 | Effect.gen(function* () { |
| 547 | return yield* Project("DrizzleAdoptionProject", { |
| 548 | migrations: migrationsDir, |
| 549 | }); |
| 550 | }), |
| 551 | ); |
| 552 | expect(project.projectId).toEqual(seeded.projectId); |
| 553 | expect(project.migrationsTable).toEqual("__alchemy_migrations"); |
| 554 | |
| 555 | // History converted (hash verbatim), only the pending migration ran |
| 556 | // (a replay of init's bare CREATE TABLE would fail). |
| 557 | const applied = yield* withPgClient(connectionUri, (client) => |
| 558 | makePgMigrationExecutor(client).query( |
| 559 | "SELECT name, hash FROM __alchemy_migrations ORDER BY id;", |
| 560 | ), |
| 561 | ); |
| 562 | expect(applied.map((r) => r.name)).toEqual([ |
| 563 | "20240101000000_init", |
| 564 | "20240102000000_posts", |
| 565 | ]); |
| 566 | expect(applied[0].hash).toBe(initHash); |
| 567 | |
| 568 | // drizzle's schema-qualified table is frozen. |
| 569 | const frozen = yield* withPgClient(connectionUri, (client) => |
| 570 | makePgMigrationExecutor(client).query( |
| 571 | "SELECT name FROM drizzle.__drizzle_migrations ORDER BY id;", |
| 572 | ), |
| 573 | ); |
| 574 | expect(frozen.map((r) => r.name)).toEqual(["20240101000000_init"]); |
| 575 | |
| 576 | yield* stack.destroy(); |
| 577 | }).pipe(logLevel), |
| 578 | { tags: ["provider:neon", "provider:neon:project", "live"] }, |
| 579 | ); |
| 580 | |
| 581 | test.provider( |
| 582 | "create project, apply migrations and seed data, then create a branch", |
| 583 | (stack) => |
| 584 | Effect.gen(function* () { |
| 585 | const fs = yield* FileSystem.FileSystem; |
| 586 | const path = yield* Path.Path; |
| 587 | const migrationsDir = yield* fs.makeTempDirectory({ |
| 588 | prefix: "alchemy-neon-migrations-", |
| 589 | }); |
| 590 | yield* fs.writeFileString( |
| 591 | path.join(migrationsDir, "0001_users.sql"), |
| 592 | "CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT NOT NULL);", |
| 593 | ); |
| 594 | const seedDir = yield* fs.makeTempDirectory({ |
| 595 | prefix: "alchemy-neon-seed-", |
| 596 | }); |
| 597 | const seedPath = path.join(seedDir, "seed.sql"); |
| 598 | yield* fs.writeFileString( |
| 599 | seedPath, |
| 600 | "INSERT INTO users (name) VALUES ('alice'), ('bob');", |
| 601 | ); |
| 602 | |
| 603 | yield* stack.destroy(); |
| 604 | |
| 605 | const { project, branch } = yield* stack.deploy( |
| 606 | Effect.gen(function* () { |
| 607 | const project = yield* Project("MigrationProject", { |
| 608 | migrations: migrationsDir, |
| 609 | importFiles: [seedPath], |
| 610 | }); |
| 611 | const branch = yield* Branch("FeatureBranch", { |
| 612 | project, |
| 613 | }); |
| 614 | return { project, branch }; |
| 615 | }), |
| 616 | ); |
| 617 | |
| 618 | // Fresh deploys use Alchemy's one table; legacy rows that persisted |
| 619 | // neon_migrations keep converging against it via state. |
| 620 | expect(project.migrationsTable).toEqual("__alchemy_migrations"); |
| 621 | expect(Object.keys(project.migrationsHashes).sort()).toEqual([ |
| 622 | "0001_users.sql", |
| 623 | ]); |
| 624 | expect(project.importHashes[seedPath]).toBeDefined(); |
| 625 | |
| 626 | expect(branch.projectId).toEqual(project.projectId); |
| 627 | expect(branch.parentBranchId).toEqual(project.defaultBranchId); |
| 628 | |
| 629 | yield* stack.destroy(); |
| 630 | }).pipe(logLevel), |
| 631 | { |
| 632 | tags: [ |
| 633 | "provider:neon", |
| 634 | "provider:neon:branch", |
| 635 | "provider:neon:project", |
| 636 | "live", |
| 637 | ], |
| 638 | }, |
| 639 | ); |