| 1 | import { |
| 2 | deleteProject, |
| 3 | getConnectionURI, |
| 4 | getProject, |
| 5 | getProjectOperation, |
| 6 | listProjectBranchDatabases, |
| 7 | listProjectBranches, |
| 8 | listProjects, |
| 9 | type ListProjectsResponse, |
| 10 | type Project as NeonProject, |
| 11 | createProject as sdkCreateProject, |
| 12 | updateProject, |
| 13 | } from "@distilled.cloud/neon"; |
| 14 | import * as Data from "effect/Data"; |
| 15 | import * as Effect from "effect/Effect"; |
| 16 | import * as Redacted from "effect/Redacted"; |
| 17 | import * as Schedule from "effect/Schedule"; |
| 18 | import { OwnedBySomeoneElse, Unowned } from "../AdoptPolicy.ts"; |
| 19 | import { isResolved } from "../Diff.ts"; |
| 20 | import { createPhysicalName } from "../PhysicalName.ts"; |
| 21 | import * as Provider from "../Provider.ts"; |
| 22 | import { Resource } from "../Resource.ts"; |
| 23 | import { |
| 24 | diffMigrations, |
| 25 | migrationsAttrs, |
| 26 | migrationsInputOf, |
| 27 | stampedOf, |
| 28 | type MigrationsInput, |
| 29 | } from "../SQL/Migrations/index.ts"; |
| 30 | import { hashImports, readSqlFile } from "../SQL/SqlFile.ts"; |
| 31 | import { recordsEqual } from "../Util/equal.ts"; |
| 32 | import { runPgMigrations, runSql } from "./Migrations.ts"; |
| 33 | import { parsePostgresOrigin, type PostgresOrigin } from "./PostgresOrigin.ts"; |
| 34 | import type { Providers } from "./Providers.ts"; |
| 35 | |
| 36 | const DEFAULT_REGION: NeonRegion = "aws-us-east-1"; |
| 37 | const DEFAULT_PG_VERSION: NeonPgVersion = 17; |
| 38 | |
| 39 | export type NeonRegion = |
| 40 | | "aws-us-east-1" |
| 41 | | "aws-us-east-2" |
| 42 | | "aws-us-west-2" |
| 43 | | "aws-eu-central-1" |
| 44 | | "aws-eu-west-2" |
| 45 | | "aws-ap-southeast-1" |
| 46 | | "aws-ap-southeast-2" |
| 47 | | "aws-sa-east-1" |
| 48 | | "azure-eastus2" |
| 49 | | "azure-westus3" |
| 50 | | "azure-gwc"; |
| 51 | |
| 52 | export type NeonPgVersion = 14 | 15 | 16 | 17 | 18; |
| 53 | |
| 54 | export type ProjectProps = { |
| 55 | /** |
| 56 | * Mutable project name. If omitted at creation, a unique name is generated |
| 57 | * from `${app}-${stage}-${id}`. Omitting it later preserves the deployed name. |
| 58 | */ |
| 59 | name?: string; |
| 60 | /** |
| 61 | * Region where the project is provisioned. Cannot be changed after |
| 62 | * creation. |
| 63 | * |
| 64 | * @default "aws-us-east-1" |
| 65 | */ |
| 66 | region?: NeonRegion; |
| 67 | /** |
| 68 | * Postgres version. Cannot be changed after creation. |
| 69 | * |
| 70 | * @default 17 |
| 71 | */ |
| 72 | pgVersion?: NeonPgVersion; |
| 73 | /** |
| 74 | * Name of the default branch. Defaults to Neon's default ("main"). Cannot |
| 75 | * be changed after creation. |
| 76 | */ |
| 77 | defaultBranchName?: string; |
| 78 | /** |
| 79 | * Name of the default role created with the project. Defaults to |
| 80 | * `neondb_owner` on creation; omission preserves the observed role on adoption |
| 81 | * and updates. An explicit change replaces the project. |
| 82 | */ |
| 83 | roleName?: string; |
| 84 | /** |
| 85 | * Name of the default database created with the project. Defaults to |
| 86 | * `neondb` on creation; omission preserves the observed database on adoption |
| 87 | * and updates. An explicit change replaces the project. |
| 88 | */ |
| 89 | databaseName?: string; |
| 90 | /** |
| 91 | * Number of seconds of WAL history retained on the project for |
| 92 | * point-in-time branching/restore. |
| 93 | * |
| 94 | * @default 86400 |
| 95 | */ |
| 96 | historyRetentionSeconds?: number; |
| 97 | /** |
| 98 | * Optional Neon organization ID. Cannot be changed after creation. |
| 99 | */ |
| 100 | orgId?: string; |
| 101 | /** |
| 102 | * Enable Postgres logical replication on the project. Once enabled, |
| 103 | * Neon does not support disabling it again. |
| 104 | * |
| 105 | * @default false |
| 106 | * @see https://neon.tech/docs/guides/logical-replication-neon |
| 107 | */ |
| 108 | enableLogicalReplication?: boolean; |
| 109 | /** |
| 110 | * SQL migrations to apply against the default branch's primary database. |
| 111 | * Accepts a directory path, a `Drizzle.Schema` resource, or |
| 112 | * `{ dir, table? }`. |
| 113 | * |
| 114 | * Bookkeeping always lives in Alchemy's `__alchemy_migrations` table. A |
| 115 | * database previously migrated by drizzle-kit or Prisma is adopted by a |
| 116 | * one-way conversion on first deploy: the old tool's applied history is |
| 117 | * copied into Alchemy's table and the old table is left frozen. No |
| 118 | * baselining required. |
| 119 | */ |
| 120 | migrations?: MigrationsInput; |
| 121 | /** |
| 122 | * Paths to additional `.sql` files to apply after migrations. Each file |
| 123 | * is hashed; only files whose contents change are re-applied on |
| 124 | * subsequent deploys. |
| 125 | */ |
| 126 | importFiles?: string[]; |
| 127 | }; |
| 128 | |
| 129 | export type Project = Resource< |
| 130 | "Neon.Project", |
| 131 | ProjectProps, |
| 132 | { |
| 133 | /** Neon project identifier. */ |
| 134 | projectId: string; |
| 135 | /** Observed project name. */ |
| 136 | projectName: string; |
| 137 | /** Immutable Postgres region. */ |
| 138 | region: NeonRegion; |
| 139 | /** Immutable Postgres major version. */ |
| 140 | pgVersion: NeonPgVersion; |
| 141 | /** Current default branch identifier. */ |
| 142 | defaultBranchId: string; |
| 143 | /** Current default branch name. */ |
| 144 | defaultBranchName: string; |
| 145 | /** Database selected for connection outputs and migrations. */ |
| 146 | databaseName: string; |
| 147 | /** Observed owner of the selected database. */ |
| 148 | roleName: string; |
| 149 | /** Postgres connection URI for the default branch + database. */ |
| 150 | connectionUri: string; |
| 151 | /** Pooled connection URI (uses pgbouncer). */ |
| 152 | pooledConnectionUri: string; |
| 153 | /** |
| 154 | * Parsed connection components ready to feed into a Postgres origin |
| 155 | * — e.g. `Cloudflare.Hyperdrive`'s `origin` prop. Points at the |
| 156 | * direct (non-pooled) endpoint, which is the recommended target |
| 157 | * when fronting Neon with another pooler like Hyperdrive. |
| 158 | */ |
| 159 | origin: PostgresOrigin; |
| 160 | /** |
| 161 | * Parsed pooled connection components. Useful as a Hyperdrive `dev` |
| 162 | * origin when local workers bypass Hyperdrive and connect directly. |
| 163 | */ |
| 164 | pooledOrigin: PostgresOrigin; |
| 165 | /** Observed history retention window in seconds. */ |
| 166 | historyRetentionSeconds: number; |
| 167 | /** Whether logical replication is enabled. Once enabled it cannot be disabled. */ |
| 168 | enableLogicalReplication: boolean; |
| 169 | /** Directory of the applied SQL migrations. */ |
| 170 | migrationsDir: string | undefined; |
| 171 | /** Migration bookkeeping table. */ |
| 172 | migrationsTable: string | undefined; |
| 173 | /** Applied migration content hashes. */ |
| 174 | migrationsHashes: Record<string, string>; |
| 175 | /** Applied SQL import content hashes. */ |
| 176 | importHashes: Record<string, string>; |
| 177 | }, |
| 178 | never, |
| 179 | Providers |
| 180 | >; |
| 181 | |
| 182 | type ProjectAttributes = Project["Attributes"]; |
| 183 | |
| 184 | /** |
| 185 | * A Neon serverless Postgres project. |
| 186 | * |
| 187 | * Creating a project also provisions the project's default branch (named |
| 188 | * "main" by default), an initial role, an initial database, and a |
| 189 | * read-write compute endpoint, exposed as `connectionUri`. |
| 190 | * ### Creating a Project |
| 191 | * **Example:** Basic project |
| 192 | * ```typescript |
| 193 | * const project = yield* Neon.Project("my-project"); |
| 194 | * ``` |
| 195 | * |
| 196 | * **Example:** Project with explicit region and PG version |
| 197 | * ```typescript |
| 198 | * const project = yield* Neon.Project("my-project", { |
| 199 | * region: "aws-eu-central-1", |
| 200 | * pgVersion: 17, |
| 201 | * }); |
| 202 | * ``` |
| 203 | * |
| 204 | * **Example:** Project with logical replication enabled |
| 205 | * ```typescript |
| 206 | * const project = yield* Neon.Project("my-project", { |
| 207 | * enableLogicalReplication: true, |
| 208 | * }); |
| 209 | * ``` |
| 210 | * |
| 211 | * ### Migrations and seed data |
| 212 | * **Example:** Apply migrations and seed files |
| 213 | * ```typescript |
| 214 | * const project = yield* Neon.Project("my-project", { |
| 215 | * migrations: "./migrations", |
| 216 | * importFiles: ["./seed/users.sql"], |
| 217 | * }); |
| 218 | * ``` |
| 219 | * |
| 220 | * ### Branching |
| 221 | * **Example:** Create a branch off the project's default branch |
| 222 | * ```typescript |
| 223 | * const project = yield* Neon.Project("my-project"); |
| 224 | * const dev = yield* Neon.Branch("dev-branch", { project }); |
| 225 | * ``` |
| 226 | * |
| 227 | * @see https://neon.tech/docs/manage/projects/ |
| 228 | * |
| 229 | * @resource |
| 230 | * @product Project |
| 231 | */ |
| 232 | export const Project = Resource<Project>("Neon.Project"); |
| 233 | |
| 234 | export const ProjectProvider = () => |
| 235 | Provider.succeed(Project, { |
| 236 | stables: ["projectId", "defaultBranchId"], |
| 237 | list: Effect.fn(function* () { |
| 238 | // Account-scoped collection: enumerate every project via the Neon |
| 239 | // projects list API, then hydrate each into the exact `read` |
| 240 | // Attributes shape with bounded concurrency. |
| 241 | const projects = yield* listAllProjects; |
| 242 | const rows = yield* Effect.forEach( |
| 243 | projects, |
| 244 | (project) => |
| 245 | hydrateProjectAttributes(project).pipe( |
| 246 | // A project can be deleted between the list call and |
| 247 | // hydration — skip it rather than fail the whole enumeration. |
| 248 | Effect.catchTag("NotFound", () => Effect.succeed(undefined)), |
| 249 | ), |
| 250 | { concurrency: 10 }, |
| 251 | ); |
| 252 | return rows.filter((row): row is ProjectAttributes => row !== undefined); |
| 253 | }), |
| 254 | diff: Effect.fn(function* ({ id, olds = {}, news = {}, output }) { |
| 255 | if (!isResolved(news)) return undefined; |
| 256 | const oldName = |
| 257 | output?.projectName ?? (yield* createProjectName(id, olds.name)); |
| 258 | // Preserve generated names; only an explicit name requests a rename. |
| 259 | const name = news.name ?? oldName; |
| 260 | if ( |
| 261 | (news.region ?? output?.region ?? DEFAULT_REGION) !== |
| 262 | (output?.region ?? olds.region ?? DEFAULT_REGION) || |
| 263 | (news.pgVersion ?? output?.pgVersion ?? DEFAULT_PG_VERSION) !== |
| 264 | (output?.pgVersion ?? olds.pgVersion ?? DEFAULT_PG_VERSION) || |
| 265 | (news.defaultBranchName ?? output?.defaultBranchName) !== |
| 266 | output?.defaultBranchName || |
| 267 | (news.databaseName ?? output?.databaseName ?? "neondb") !== |
| 268 | (output?.databaseName ?? olds.databaseName ?? "neondb") || |
| 269 | (news.roleName ?? output?.roleName ?? "neondb_owner") !== |
| 270 | (output?.roleName ?? olds.roleName ?? "neondb_owner") || |
| 271 | news.orgId !== olds.orgId |
| 272 | ) { |
| 273 | return { |
| 274 | action: "replace", |
| 275 | deleteFirst: news.name === oldName && news.orgId === olds.orgId, |
| 276 | } as const; |
| 277 | } |
| 278 | if ( |
| 279 | oldName !== name || |
| 280 | (news.historyRetentionSeconds ?? 86400) !== |
| 281 | (output?.historyRetentionSeconds ?? 86400) |
| 282 | ) { |
| 283 | return { action: "update" } as const; |
| 284 | } |
| 285 | if ( |
| 286 | news.enableLogicalReplication !== undefined && |
| 287 | news.enableLogicalReplication !== |
| 288 | (output?.enableLogicalReplication ?? false) |
| 289 | ) { |
| 290 | return { action: "update" } as const; |
| 291 | } |
| 292 | if (yield* diffMigrations({ news, output })) { |
| 293 | return { action: "update" } as const; |
| 294 | } |
| 295 | if (news.importFiles?.length) { |
| 296 | const newHashes = yield* hashImports(news.importFiles, yield* rootDir); |
| 297 | if (!recordsEqual(newHashes, output?.importHashes ?? {})) { |
| 298 | return { action: "update" } as const; |
| 299 | } |
| 300 | } |
| 301 | return undefined; |
| 302 | }), |
| 303 | read: Effect.fn(function* ({ id, output, olds }) { |
| 304 | if (output?.projectId) { |
| 305 | return yield* getProject({ project_id: output.projectId }).pipe( |
| 306 | Effect.flatMap(({ project }) => |
| 307 | hydrateProjectAttributes(project, output), |
| 308 | ), |
| 309 | Effect.catchTag("NotFound", () => Effect.succeed(undefined)), |
| 310 | ); |
| 311 | } |
| 312 | const name = yield* createProjectName(id, olds?.name); |
| 313 | const matches = yield* findProjectByName(name, olds?.orgId); |
| 314 | if (matches.length > 1) { |
| 315 | return yield* new ProjectStateError({ |
| 316 | reason: "Ambiguous project name", |
| 317 | }); |
| 318 | } |
| 319 | const match = matches[0]; |
| 320 | if (!match) return undefined; |
| 321 | const attrs = yield* hydrateProjectAttributes(match, { |
| 322 | defaultBranchName: olds?.defaultBranchName, |
| 323 | migrationsDir: (olds && migrationsInputOf(olds))?.dir, |
| 324 | migrationsTable: (olds && migrationsInputOf(olds))?.table, |
| 325 | }); |
| 326 | return attrs && Unowned(attrs); |
| 327 | }), |
| 328 | reconcile: Effect.fn(function* ({ id, news = {}, output }) { |
| 329 | const name = |
| 330 | news.name ?? |
| 331 | output?.projectName ?? |
| 332 | (yield* createProjectName(id, undefined)); |
| 333 | // Names are not ownership evidence; approval applies only to the observed ID. |
| 334 | const authorize = ( |
| 335 | project: ObservedProject, |
| 336 | ): Effect.Effect<ObservedProject, OwnedBySomeoneElse> => |
| 337 | project.id === output?.projectId |
| 338 | ? Effect.succeed(project) |
| 339 | : Effect.fail( |
| 340 | new OwnedBySomeoneElse({ |
| 341 | message: `Neon project "${name}" requires explicit adoption`, |
| 342 | resourceType: "Neon.Project", |
| 343 | logicalId: id, |
| 344 | physicalName: project.id, |
| 345 | }), |
| 346 | ); |
| 347 | let observed: ObservedProject | undefined = output?.projectId |
| 348 | ? yield* getProject({ project_id: output.projectId }).pipe( |
| 349 | Effect.map(({ project }) => project), |
| 350 | Effect.catchTag("NotFound", () => Effect.succeed(undefined)), |
| 351 | ) |
| 352 | : undefined; |
| 353 | if (!observed) { |
| 354 | const matches = yield* findProjectByName(name, news.orgId); |
| 355 | if (matches.length > 1) { |
| 356 | return yield* new ProjectStateError({ |
| 357 | reason: "Ambiguous project name", |
| 358 | }); |
| 359 | } |
| 360 | observed = matches[0] ? yield* authorize(matches[0]) : undefined; |
| 361 | } |
| 362 | if (!observed) { |
| 363 | const created = yield* sdkCreateProject({ |
| 364 | project: { |
| 365 | name, |
| 366 | region_id: news.region ?? DEFAULT_REGION, |
| 367 | pg_version: news.pgVersion ?? DEFAULT_PG_VERSION, |
| 368 | branch: { |
| 369 | name: news.defaultBranchName, |
| 370 | role_name: news.roleName, |
| 371 | database_name: news.databaseName, |
| 372 | }, |
| 373 | history_retention_seconds: news.historyRetentionSeconds, |
| 374 | org_id: news.orgId, |
| 375 | settings: news.enableLogicalReplication |
| 376 | ? { enable_logical_replication: true } |
| 377 | : undefined, |
| 378 | }, |
| 379 | }).pipe( |
| 380 | Effect.catchTag("Conflict", (error) => |
| 381 | findProjectByName(name, news.orgId).pipe( |
| 382 | Effect.flatMap( |
| 383 | Effect.fn(function* (matches) { |
| 384 | if (matches.length !== 1) return yield* Effect.fail(error); |
| 385 | const project = yield* authorize(matches[0]!); |
| 386 | return { project, operations: [] }; |
| 387 | }), |
| 388 | ), |
| 389 | ), |
| 390 | ), |
| 391 | ); |
| 392 | yield* waitForOperations(created.operations); |
| 393 | observed = created.project; |
| 394 | } |
| 395 | const replication = |
| 396 | observed.settings?.enable_logical_replication === true; |
| 397 | if (replication && news.enableLogicalReplication === false) { |
| 398 | return yield* new ProjectStateError({ |
| 399 | reason: "Neon logical replication cannot be disabled once enabled", |
| 400 | }); |
| 401 | } |
| 402 | const retention = news.historyRetentionSeconds ?? 86400; |
| 403 | if ( |
| 404 | observed.name !== name || |
| 405 | observed.history_retention_seconds !== retention || |
| 406 | (news.enableLogicalReplication === true && !replication) |
| 407 | ) { |
| 408 | const updated = yield* updateProject({ |
| 409 | project_id: observed.id, |
| 410 | project: { |
| 411 | name: observed.name !== name ? name : undefined, |
| 412 | history_retention_seconds: |
| 413 | observed.history_retention_seconds !== retention |
| 414 | ? retention |
| 415 | : undefined, |
| 416 | settings: |
| 417 | news.enableLogicalReplication === true && !replication |
| 418 | ? { enable_logical_replication: true } |
| 419 | : undefined, |
| 420 | }, |
| 421 | }); |
| 422 | yield* waitForOperations(updated.operations); |
| 423 | } |
| 424 | const current = yield* getProject({ project_id: observed.id }); |
| 425 | const projectInfo = yield* hydrateProjectAttributes(current.project, { |
| 426 | defaultBranchName: news.defaultBranchName, |
| 427 | databaseName: |
| 428 | news.databaseName ?? |
| 429 | (output?.projectId === observed.id ? output.databaseName : undefined), |
| 430 | }); |
| 431 | if (!projectInfo) { |
| 432 | return yield* new ProjectStateError({ |
| 433 | reason: "Project has no default branch or database", |
| 434 | }); |
| 435 | } |
| 436 | const previous = |
| 437 | projectInfo.projectId === output?.projectId ? output : undefined; |
| 438 | |
| 439 | const connectionUri = Redacted.make(projectInfo.connectionUri); |
| 440 | const migrationsInput = migrationsInputOf(news); |
| 441 | const migrations = migrationsInput |
| 442 | ? yield* runPgMigrations({ |
| 443 | connectionUri, |
| 444 | input: migrationsInput, |
| 445 | stamped: stampedOf(previous), |
| 446 | }) |
| 447 | : undefined; |
| 448 | const importHashes = news.importFiles?.length |
| 449 | ? yield* runImports( |
| 450 | connectionUri, |
| 451 | news.importFiles, |
| 452 | yield* rootDir, |
| 453 | previous?.importHashes ?? {}, |
| 454 | ) |
| 455 | : {}; |
| 456 | |
| 457 | return { |
| 458 | ...projectInfo, |
| 459 | ...migrationsAttrs({ |
| 460 | input: migrationsInput, |
| 461 | run: migrations, |
| 462 | output: previous, |
| 463 | }), |
| 464 | importHashes, |
| 465 | }; |
| 466 | }), |
| 467 | delete: Effect.fn(function* ({ output }) { |
| 468 | yield* deleteProject({ project_id: output.projectId }).pipe( |
| 469 | Effect.catchTag("NotFound", () => Effect.void), |
| 470 | ); |
| 471 | yield* getProject({ project_id: output.projectId }).pipe( |
| 472 | Effect.flatMap(() => |
| 473 | Effect.fail(new DeletionPending({ resourceId: output.projectId })), |
| 474 | ), |
| 475 | Effect.catchTag("NotFound", () => Effect.void), |
| 476 | Effect.retry({ |
| 477 | while: (error) => error._tag === "NeonDeletionPending", |
| 478 | schedule: Schedule.spaced("2 seconds"), |
| 479 | times: 8, |
| 480 | }), |
| 481 | Effect.timeout("20 seconds"), |
| 482 | ); |
| 483 | }), |
| 484 | }); |
| 485 | |
| 486 | const rootDir = Effect.sync(process.cwd); |
| 487 | |
| 488 | const createProjectName = (id: string, name: string | undefined) => |
| 489 | Effect.gen(function* () { |
| 490 | return name ?? (yield* createPhysicalName({ id })); |
| 491 | }); |
| 492 | |
| 493 | const resolveConnection = ( |
| 494 | projectId: string, |
| 495 | branchId: string, |
| 496 | databaseName: string, |
| 497 | roleName: string, |
| 498 | ) => |
| 499 | Effect.gen(function* () { |
| 500 | const direct = yield* getConnectionURI({ |
| 501 | project_id: projectId, |
| 502 | branch_id: branchId, |
| 503 | database_name: databaseName, |
| 504 | role_name: roleName, |
| 505 | pooled: false, |
| 506 | }); |
| 507 | const pooled = yield* getConnectionURI({ |
| 508 | project_id: projectId, |
| 509 | branch_id: branchId, |
| 510 | database_name: databaseName, |
| 511 | role_name: roleName, |
| 512 | pooled: true, |
| 513 | }); |
| 514 | return { uri: direct.uri, pooled: pooled.uri }; |
| 515 | }); |
| 516 | |
| 517 | export class ProjectStateError extends Data.TaggedError( |
| 518 | "NeonProjectStateError", |
| 519 | )<{ |
| 520 | reason: string; |
| 521 | }> {} |
| 522 | |
| 523 | export class DeletionPending extends Data.TaggedError("NeonDeletionPending")<{ |
| 524 | resourceId: string; |
| 525 | }> {} |
| 526 | |
| 527 | export class OperationFailed extends Data.TaggedError("NeonOperationFailed")<{ |
| 528 | operationId: string; |
| 529 | action: string; |
| 530 | status: string; |
| 531 | }> {} |
| 532 | |
| 533 | export class OperationPending extends Data.TaggedError("NeonOperationPending")<{ |
| 534 | operationId: string; |
| 535 | status: string; |
| 536 | }> {} |
| 537 | |
| 538 | type PendingOperation = { |
| 539 | readonly id: string; |
| 540 | readonly project_id: string; |
| 541 | readonly action: string; |
| 542 | readonly status: string; |
| 543 | }; |
| 544 | |
| 545 | const checkOperation = ( |
| 546 | op: PendingOperation, |
| 547 | ): Effect.Effect<void, OperationFailed | OperationPending> => { |
| 548 | if (op.status === "finished" || op.status === "skipped") return Effect.void; |
| 549 | if ( |
| 550 | op.status === "failed" || |
| 551 | op.status === "error" || |
| 552 | op.status === "cancelled" |
| 553 | ) { |
| 554 | return Effect.fail( |
| 555 | new OperationFailed({ |
| 556 | operationId: op.id, |
| 557 | action: op.action, |
| 558 | status: op.status, |
| 559 | }), |
| 560 | ); |
| 561 | } |
| 562 | return Effect.fail( |
| 563 | new OperationPending({ operationId: op.id, status: op.status }), |
| 564 | ); |
| 565 | }; |
| 566 | |
| 567 | /** Wait at most 55 seconds; pending, cancelled and failed operations never succeed. */ |
| 568 | export const waitForOperations = ( |
| 569 | operations: ReadonlyArray<PendingOperation>, |
| 570 | ) => |
| 571 | Effect.forEach( |
| 572 | operations, |
| 573 | (op) => |
| 574 | checkOperation(op).pipe( |
| 575 | Effect.catchTag("NeonOperationPending", () => |
| 576 | getProjectOperation({ |
| 577 | project_id: op.project_id, |
| 578 | operation_id: op.id, |
| 579 | }).pipe( |
| 580 | Effect.flatMap(({ operation }) => checkOperation(operation)), |
| 581 | Effect.retry({ |
| 582 | while: (error) => error._tag === "NeonOperationPending", |
| 583 | schedule: Schedule.spaced("5 seconds"), |
| 584 | times: 8, |
| 585 | }), |
| 586 | ), |
| 587 | ), |
| 588 | ), |
| 589 | { concurrency: 10, discard: true }, |
| 590 | ).pipe(Effect.timeout("55 seconds")); |
| 591 | |
| 592 | const findProjectByName = (name: string, orgId?: string) => |
| 593 | Effect.gen(function* () { |
| 594 | const matches: ListProjectsResponse["projects"][number][] = []; |
| 595 | let cursor: string | undefined; |
| 596 | while (true) { |
| 597 | const page = yield* listProjects({ |
| 598 | search: name, |
| 599 | ...(orgId !== undefined ? { org_id: orgId } : {}), |
| 600 | ...(cursor !== undefined ? { cursor } : {}), |
| 601 | }); |
| 602 | for (const p of page.projects) { |
| 603 | if (p.name === name) matches.push(p); |
| 604 | } |
| 605 | const nextCursor = page.pagination?.cursor; |
| 606 | // Neon returns a `pagination.cursor` on every response — it's the |
| 607 | // `created_at` of the last row, not a "has next page" flag — so we |
| 608 | // can't loop on cursor presence alone or we spin forever re-fetching |
| 609 | // empty/identical pages. Stop once a page comes back empty or the |
| 610 | // cursor stops advancing. |
| 611 | if ( |
| 612 | page.projects.length === 0 || |
| 613 | nextCursor === undefined || |
| 614 | nextCursor === cursor |
| 615 | ) { |
| 616 | break; |
| 617 | } |
| 618 | cursor = nextCursor; |
| 619 | } |
| 620 | return matches; |
| 621 | }); |
| 622 | |
| 623 | /** |
| 624 | * Exhaustively enumerate every project in the account. Uses the same |
| 625 | * cursor-stop heuristic as {@link findProjectByName} because Neon returns a |
| 626 | * `pagination.cursor` on every page (it's the last row's `created_at`, not a |
| 627 | * "has next page" flag), so we'd otherwise loop forever re-fetching. |
| 628 | */ |
| 629 | const listAllProjects = Effect.gen(function* () { |
| 630 | const projects: ListProjectsResponse["projects"][number][] = []; |
| 631 | let cursor: string | undefined; |
| 632 | while (true) { |
| 633 | const page = yield* listProjects(cursor !== undefined ? { cursor } : {}); |
| 634 | projects.push(...page.projects); |
| 635 | const nextCursor = page.pagination?.cursor; |
| 636 | if ( |
| 637 | page.projects.length === 0 || |
| 638 | nextCursor === undefined || |
| 639 | nextCursor === cursor |
| 640 | ) { |
| 641 | break; |
| 642 | } |
| 643 | cursor = nextCursor; |
| 644 | } |
| 645 | return projects; |
| 646 | }); |
| 647 | |
| 648 | type ObservedProject = NeonProject | ListProjectsResponse["projects"][number]; |
| 649 | |
| 650 | /** |
| 651 | * Hydrate a project summary (from the list API) into the exact `read` |
| 652 | * Attributes shape — resolving the default branch, its primary database, and |
| 653 | * the direct + pooled connection URIs. Returns `undefined` when the project |
| 654 | * has no branch or database yet (mirrors `read`). |
| 655 | */ |
| 656 | const hydrateProjectAttributes = ( |
| 657 | project: ObservedProject, |
| 658 | opts: { |
| 659 | defaultBranchName?: string; |
| 660 | databaseName?: string; |
| 661 | migrationsDir?: string; |
| 662 | migrationsTable?: string; |
| 663 | migrationsHashes?: Record<string, string>; |
| 664 | importHashes?: Record<string, string>; |
| 665 | } = {}, |
| 666 | ) => |
| 667 | Effect.gen(function* () { |
| 668 | const branches: import("@distilled.cloud/neon").Branch[] = []; |
| 669 | let cursor: string | undefined; |
| 670 | do { |
| 671 | const page = yield* listProjectBranches({ |
| 672 | project_id: project.id, |
| 673 | cursor, |
| 674 | }); |
| 675 | branches.push(...page.branches); |
| 676 | const next = page.pagination?.next; |
| 677 | if (!next || next === cursor || page.branches.length === 0) break; |
| 678 | cursor = next; |
| 679 | } while (cursor); |
| 680 | const defaultBranch = branches.find((b) => b.default); |
| 681 | if (!defaultBranch) return undefined; |
| 682 | const databases = yield* listProjectBranchDatabases({ |
| 683 | project_id: project.id, |
| 684 | branch_id: defaultBranch.id, |
| 685 | }); |
| 686 | const db = opts.databaseName |
| 687 | ? databases.databases.find((db) => db.name === opts.databaseName) |
| 688 | : databases.databases[0]; |
| 689 | if (!db) return undefined; |
| 690 | const conn = yield* resolveConnection( |
| 691 | project.id, |
| 692 | defaultBranch.id, |
| 693 | db.name, |
| 694 | db.owner_name, |
| 695 | ); |
| 696 | return { |
| 697 | projectId: project.id, |
| 698 | projectName: project.name, |
| 699 | region: project.region_id as NeonRegion, |
| 700 | pgVersion: project.pg_version as NeonPgVersion, |
| 701 | defaultBranchId: defaultBranch.id, |
| 702 | defaultBranchName: defaultBranch.name, |
| 703 | databaseName: db.name, |
| 704 | roleName: db.owner_name, |
| 705 | connectionUri: conn.uri, |
| 706 | pooledConnectionUri: conn.pooled, |
| 707 | origin: parsePostgresOrigin(conn.uri), |
| 708 | pooledOrigin: parsePostgresOrigin(conn.pooled), |
| 709 | historyRetentionSeconds: project.history_retention_seconds ?? 86400, |
| 710 | enableLogicalReplication: |
| 711 | project.settings?.enable_logical_replication === true, |
| 712 | migrationsDir: opts.migrationsDir, |
| 713 | migrationsTable: opts.migrationsTable, |
| 714 | migrationsHashes: opts.migrationsHashes ?? {}, |
| 715 | importHashes: opts.importHashes ?? {}, |
| 716 | } satisfies ProjectAttributes; |
| 717 | }); |
| 718 | |
| 719 | const runImports = ( |
| 720 | connectionUri: Redacted.Redacted<string>, |
| 721 | importFiles: ReadonlyArray<string>, |
| 722 | rootDir: string, |
| 723 | previous: Record<string, string>, |
| 724 | ) => |
| 725 | Effect.gen(function* () { |
| 726 | const hashes: Record<string, string> = { ...previous }; |
| 727 | for (const filePath of importFiles) { |
| 728 | const file = yield* readSqlFile(rootDir, filePath); |
| 729 | if (previous[filePath] === file.hash) { |
| 730 | hashes[filePath] = file.hash; |
| 731 | continue; |
| 732 | } |
| 733 | yield* runSql(connectionUri, file.sql); |
| 734 | hashes[filePath] = file.hash; |
| 735 | } |
| 736 | const tracked = new Set(importFiles); |
| 737 | for (const key of Object.keys(hashes)) { |
| 738 | if (!tracked.has(key)) delete hashes[key]; |
| 739 | } |
| 740 | return hashes; |
| 741 | }); |