1import { Branch } from "@/Neon/Branch";
2import type { PostgresOrigin } from "@/Neon/PostgresOrigin";
3import { Project, type ProjectProps } from "@/Neon/Project";
4import { providers } from "@/Neon/Providers";
5import { runSql, withPgClient } from "@/Neon/Migrations.ts";
6import { makePgMigrationExecutor } from "@/SQL/Migrations/index.ts";
7import * as Provider from "@/Provider";
8import { hashMigrations } from "@/SQL/SqlFile.ts";
9import * as Test from "@/Test/Alchemy";
10import {
11 createProject,
12 deleteProject,
13 getConnectionURI,
14 getProject,
15 updateProject,
16} from "@distilled.cloud/neon";
17import { adopt, OwnedBySomeoneElse, Unowned } from "@/AdoptPolicy";
18import * as Result from "effect/Result";
19import { waitForOperations } from "@/Neon/Project";
20import { expect } from "alchemy-test";
21import * as Effect from "effect/Effect";
22import * as FileSystem from "effect/FileSystem";
23import * as Path from "effect/Path";
24import * as Redacted from "effect/Redacted";
25import { MinimumLogLevel } from "effect/References";
26
27const { test } = Test.make({ providers: providers() });
28
29const logLevel = Effect.provideService(
30 MinimumLogLevel,
31 process.env.DEBUG ? "Debug" : "Info",
32);
33
34const 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
49test.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
76test.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
121test.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
157test.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
239test.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
326test.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
406test.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
460test.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 */
492test.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
581test.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);