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