1import {
2 Branch,
3 type BranchEndpointConfig,
4 type BranchProps,
5} from "@/Neon/Branch";
6import type { PostgresOrigin } from "@/Neon/PostgresOrigin";
7import { Project } from "@/Neon/Project";
8import { providers } from "@/Neon/Providers";
9import * as Provider from "@/Provider";
10import { isResourceState, State, type ResourceState } from "@/State";
11import * as Test from "@/Test/Alchemy";
12import {
13 createProjectBranch,
14 deleteProjectBranch,
15 deleteProjectEndpoint,
16 getConnectionURI,
17 getProject,
18 getProjectBranch,
19 listProjectBranchEndpoints,
20 updateProjectBranch,
21 updateProjectEndpoint,
22} from "@distilled.cloud/neon";
23import { adopt, OwnedBySomeoneElse, Unowned } from "@/AdoptPolicy";
24import * as Result from "effect/Result";
25import { waitForOperations } from "@/Neon/Project";
26import { describe, expect } from "alchemy-test";
27import * as Effect from "effect/Effect";
28import { MinimumLogLevel } from "effect/References";
29import * as Schedule from "effect/Schedule";
30
31const { test } = Test.make({ providers: providers() });
32
33const logLevel = Effect.provideService(
34 MinimumLogLevel,
35 process.env.DEBUG ? "Debug" : "Info",
36);
37
38const expectPooledOrigin = (branch: {
39 pooledConnectionUri: string;
40 pooledOrigin: PostgresOrigin;
41}) => {
42 const uri = new URL(branch.pooledConnectionUri);
43 expect(branch.pooledOrigin).toMatchObject({
44 scheme: uri.protocol === "postgresql:" ? "postgresql" : "postgres",
45 host: uri.hostname,
46 port: uri.port ? Number(uri.port) : 5432,
47 database: uri.pathname.replace(/^\//, ""),
48 user: decodeURIComponent(uri.username),
49 });
50 expect(branch.pooledOrigin.password).toBeDefined();
51};
52
53// Canonical `list()` test (parent fan-out): branches are scoped to a project
54// and there is no account-wide branch enumeration API, so `list()` enumerates
55// every project and lists+hydrates the branches of each. Deploy a project +
56// branch, then assert the deployed branch appears in the exhaustive result.
57describe.concurrent(
58 "branch lifecycle",
59 {
60 tags: [
61 "provider:neon",
62 "provider:neon:branch",
63 "provider:neon:project",
64 "live",
65 ],
66 },
67 () => {
68 test.provider("list enumerates the deployed branch", (stack) =>
69 Effect.gen(function* () {
70 yield* stack.destroy();
71
72 const { project, branch } = yield* stack.deploy(
73 Effect.gen(function* () {
74 const project = yield* Project("ListBranchProject");
75 const branch = yield* Branch("ListBranch", { project });
76 return { project, branch };
77 }),
78 );
79
80 const provider = yield* Provider.findProvider(Branch);
81 const all = yield* provider.list();
82
83 const found = all.find((b) => b.branchId === branch.branchId);
84 expect(found).toBeDefined();
85 expect(found?.projectId).toEqual(project.projectId);
86 expect(found?.branchName).toEqual(branch.branchName);
87 expect(found?.connectionUri).toContain("postgres");
88 expectPooledOrigin(branch);
89 expectPooledOrigin(found!);
90
91 yield* stack.destroy();
92 }).pipe(logLevel),
93 );
94
95 test.provider(
96 "updating project in-place does not replace the branch",
97 (stack) =>
98 Effect.gen(function* () {
99 yield* stack.destroy();
100
101 const initial = yield* stack.deploy(
102 Effect.gen(function* () {
103 const project = yield* Project("UpdateBranchProject", {
104 enableLogicalReplication: false,
105 });
106 const branch = yield* Branch("UpdateBranch", {
107 project,
108 });
109 return { project, branch };
110 }),
111 );
112
113 const updated = yield* stack.deploy(
114 Effect.gen(function* () {
115 const project = yield* Project("UpdateBranchProject", {
116 enableLogicalReplication: true,
117 });
118 const branch = yield* Branch("UpdateBranch", {
119 project,
120 });
121 return { project, branch };
122 }),
123 );
124
125 expect(updated.branch.projectId).toEqual(updated.project.projectId);
126 expect(updated.branch.branchId).toEqual(initial.branch.branchId);
127 expectPooledOrigin(updated.branch);
128
129 yield* stack.destroy();
130 }).pipe(logLevel),
131 );
132
133 test.provider(
134 "replaces branch when project changes to another pre-existing project",
135 (stack) =>
136 Effect.gen(function* () {
137 yield* stack.destroy();
138
139 const initial = yield* stack.deploy(
140 Effect.gen(function* () {
141 const projectA = yield* Project("ReplaceBranchProjectA");
142 const projectB = yield* Project("ReplaceBranchProjectB");
143 const branch = yield* Branch("ReplaceBranchExistingProject", {
144 project: projectA,
145 name: "cross-project-replacement",
146 });
147 return { projectA, projectB, branch };
148 }),
149 );
150
151 const replaced = yield* stack.deploy(
152 Effect.gen(function* () {
153 const projectA = yield* Project("ReplaceBranchProjectA");
154 const projectB = yield* Project("ReplaceBranchProjectB");
155 const branch = yield* Branch("ReplaceBranchExistingProject", {
156 project: projectB,
157 name: "cross-project-replacement",
158 });
159 return { projectA, projectB, branch };
160 }),
161 );
162
163 expect(replaced.branch.projectId).toEqual(
164 replaced.projectB.projectId,
165 );
166 expect(replaced.branch.branchId).not.toEqual(initial.branch.branchId);
167
168 const fetched = yield* getProjectBranch({
169 project_id: replaced.projectB.projectId,
170 branch_id: replaced.branch.branchId,
171 });
172 expect(fetched.branch.id).toEqual(replaced.branch.branchId);
173
174 const oldBranch = yield* getProjectBranch({
175 project_id: initial.projectA.projectId,
176 branch_id: initial.branch.branchId,
177 }).pipe(
178 Effect.as("found" as const),
179 Effect.catchTag("NotFound", () =>
180 Effect.succeed("not-found" as const),
181 ),
182 );
183 expect(oldBranch).toEqual("not-found");
184
185 yield* stack.destroy();
186 }).pipe(logLevel),
187 );
188
189 test.provider("replaces branch when project is replaced", (stack) =>
190 Effect.gen(function* () {
191 yield* stack.destroy();
192
193 const initial = yield* stack.deploy(
194 Effect.gen(function* () {
195 const project = yield* Project("ReplaceProject", {
196 region: "aws-us-east-1",
197 });
198 const branch = yield* Branch("ReplaceBranchReplaceProject", {
199 project,
200 });
201 return { project, branch };
202 }),
203 );
204
205 expect(initial.project.region).toEqual("aws-us-east-1");
206
207 // Trigger a replace on the project by changing the region.
208 // This should cause the branch to be replaced.
209 const replaced = yield* stack.deploy(
210 Effect.gen(function* () {
211 const project = yield* Project("ReplaceProject", {
212 region: "aws-us-west-2",
213 });
214 const branch = yield* Branch("ReplaceBranchReplaceProject", {
215 project,
216 });
217 return { project, branch };
218 }),
219 );
220
221 expect(replaced.project.region).toEqual("aws-us-west-2");
222 expect(replaced.branch.projectId).toEqual(replaced.project.projectId);
223 expect(replaced.branch.projectId).not.toEqual(
224 initial.project.projectId,
225 );
226 expect(replaced.branch.branchId).not.toEqual(initial.branch.branchId);
227
228 const fetched = yield* getProjectBranch({
229 project_id: replaced.project.projectId,
230 branch_id: replaced.branch.branchId,
231 });
232 expect(fetched.branch.id).toEqual(replaced.branch.branchId);
233
234 const oldBranch = yield* getProjectBranch({
235 project_id: initial.project.projectId,
236 branch_id: initial.branch.branchId,
237 }).pipe(
238 Effect.as("found" as const),
239 Effect.catchTag("NotFound", () =>
240 Effect.succeed("not-found" as const),
241 ),
242 );
243 expect(oldBranch).toEqual("not-found");
244
245 yield* stack.destroy();
246 }).pipe(logLevel),
247 );
248
249 // #736 regression tests: a `creating`-state row persisted before upstream
250 // Outputs resolve cannot round-trip Output-valued props — they deserialize as
251 // `undefined`. The engine's recovery paths hand those junk props back to the
252 // provider as `olds` (Plan.ts calls `read` and then `diff` with
253 // `olds: oldState.props` for a creating row with no attributes). The provider
254 // must fall through to the create path instead of crashing in
255 // `resolveProjectId`.
256 //
257 // Shared shape (Variant B): deploy a project + branch, rewrite the branch's
258 // persisted row into the wedged creating-state shape, delete the branch
259 // out-of-band so recovery must recreate it, redeploy, and assert convergence.
260
261 test.provider(
262 "converges endpoint settings, removals, drift, and a missing endpoint",
263 (stack) =>
264 Effect.gen(function* () {
265 yield* stack.destroy();
266 const deploy = (endpoints?: BranchEndpointConfig[]) =>
267 stack.deploy(
268 Effect.gen(function* () {
269 const project = yield* Project("EndpointProject");
270 const branch = yield* Branch("EndpointBranch", {
271 project,
272 endpoints,
273 });
274 return { project, branch };
275 }),
276 );
277 const initial = yield* deploy([
278 {
279 type: "read_write",
280 autoscalingLimitMinCu: 0.25,
281 autoscalingLimitMaxCu: 0.25,
282 },
283 {
284 type: "read_only",
285 autoscalingLimitMinCu: 0.25,
286 autoscalingLimitMaxCu: 0.25,
287 },
288 ]);
289 const scope = {
290 project_id: initial.project.projectId,
291 branch_id: initial.branch.branchId,
292 };
293 const initialEndpoints = (yield* listProjectBranchEndpoints(scope))
294 .endpoints;
295 expect(initialEndpoints).toHaveLength(2);
296 const first = initialEndpoints.find(
297 (endpoint) => endpoint.type === "read_write",
298 )!;
299 const drift = yield* updateProjectEndpoint({
300 project_id: scope.project_id,
301 endpoint_id: first.id,
302 endpoint: { autoscaling_limit_max_cu: 0.5 },
303 });
304 yield* waitForOperations(drift.operations);
305 const updated = yield* deploy([
306 {
307 type: "read_write",
308 autoscalingLimitMinCu: 0.25,
309 autoscalingLimitMaxCu: 1,
310 },
311 ]);
312 expect(updated.branch.branchId).toBe(initial.branch.branchId);
313 const updatedEndpoints = (yield* listProjectBranchEndpoints(scope))
314 .endpoints;
315 expect(updatedEndpoints).toHaveLength(1);
316 const current = updatedEndpoints[0]!;
317 expect(current.id).toBe(first.id);
318 expect(current.autoscaling_limit_max_cu).toBe(1);
319 const removed = yield* deploy();
320 const defaults = (yield* getProject({ project_id: scope.project_id }))
321 .project.default_endpoint_settings;
322 const reset = (yield* listProjectBranchEndpoints(scope))
323 .endpoints[0]!;
324 expect(reset.autoscaling_limit_min_cu).toBe(
325 defaults?.autoscaling_limit_min_cu ?? 0.25,
326 );
327 expect(reset.autoscaling_limit_max_cu).toBe(
328 defaults?.autoscaling_limit_max_cu ?? 2,
329 );
330 const deletion = yield* deleteProjectEndpoint({
331 project_id: scope.project_id,
332 endpoint_id: reset.id,
333 });
334 yield* waitForOperations(deletion.operations);
335 const recovered = yield* deploy([
336 {
337 type: "read_write",
338 autoscalingLimitMinCu: 0.25,
339 autoscalingLimitMaxCu: 0.5,
340 },
341 ]);
342 expect(recovered.branch.branchId).toBe(removed.branch.branchId);
343 const replacement = (yield* listProjectBranchEndpoints(scope))
344 .endpoints[0]!;
345 expect(replacement.id).not.toBe(reset.id);
346 const uri = yield* getConnectionURI({
347 ...scope,
348 database_name: recovered.branch.databaseName,
349 role_name: recovered.branch.roleName,
350 pooled: false,
351 });
352 expect(recovered.branch.connectionUri === uri.uri).toBe(true);
353 yield* stack.destroy();
354 yield* stack.destroy();
355 expect(
356 yield* getProjectBranch(scope).pipe(
357 Effect.as(false),
358 Effect.catchTag("NotFound", () => Effect.succeed(true)),
359 ),
360 ).toBe(true);
361 }),
362 { timeout: 120_000 },
363 );
364
365 test.provider(
366 "observes a missing branch despite cached output and converges adoption",
367 (stack) =>
368 Effect.gen(function* () {
369 yield* stack.destroy();
370 const deploy = (protectedBranch?: boolean) =>
371 stack.deploy(
372 Effect.gen(function* () {
373 const project = yield* Project("ObservedProject");
374 const branch = yield* Branch("ObservedBranch", {
375 project,
376 protected: protectedBranch,
377 });
378 return { project, branch };
379 }),
380 );
381 const initial = yield* deploy();
382 const provider = yield* Provider.findProvider(Branch);
383 const context = {
384 id: "ObservedBranch",
385 fqn: "ObservedBranch",
386 instanceId: "observed-branch",
387 bindings: [],
388 session: {
389 emit: () => Effect.void,
390 done: () => Effect.void,
391 note: () => Effect.void,
392 },
393 };
394 const news = {
395 project: { projectId: initial.project.projectId },
396 name: initial.branch.branchName,
397 };
398 const read = yield* provider.read!({
399 ...context,
400 olds: news,
401 output: undefined,
402 });
403 expect(Unowned.is(read)).toBe(true);
404 const drift = yield* updateProjectBranch({
405 project_id: initial.project.projectId,
406 branch_id: initial.branch.branchId,
407 branch: { name: `${initial.branch.branchName}-drift` },
408 });
409 yield* waitForOperations(drift.operations);
410 const adopted = yield* provider.reconcile({
411 ...context,
412 news,
413 olds: undefined,
414 output: initial.branch,
415 });
416 expect(adopted.branchName).toBe(initial.branch.branchName);
417 const unchanged = yield* provider.reconcile({
418 ...context,
419 news,
420 olds: news,
421 output: adopted,
422 });
423 expect(unchanged.branchId).toBe(adopted.branchId);
424 yield* deleteBranchOutOfBand(
425 initial.project.projectId,
426 initial.branch.branchId,
427 );
428 const recovered = yield* deploy(true);
429 expect(recovered.branch.branchId).not.toBe(initial.branch.branchId);
430 const persisted = yield* deploy(true);
431 expect(persisted.branch.branchId).toBe(recovered.branch.branchId);
432 expect(persisted.branch.protected).toBe(true);
433 const unprotected = yield* deploy();
434 expect(unprotected.branch.protected).toBe(false);
435 expect(
436 (yield* getProjectBranch({
437 project_id: initial.project.projectId,
438 branch_id: recovered.branch.branchId,
439 })).branch.protected,
440 ).toBe(false);
441 yield* stack.destroy();
442 yield* stack.destroy();
443 }),
444 { timeout: 120_000 },
445 );
446
447 test.provider(
448 "refuses foreign branch ownership until adoption is explicit",
449 (stack) =>
450 Effect.gen(function* () {
451 yield* stack.destroy();
452 const base = Project("AdoptionProject");
453 const project = yield* stack.deploy(base);
454 const foreign = yield* createProjectBranch({
455 project_id: project.projectId,
456 branch: { name: "foreign-branch" },
457 endpoints: [{ type: "read_write" }],
458 });
459 yield* waitForOperations(foreign.operations);
460 const program = (allow: boolean) =>
461 Effect.gen(function* () {
462 const project = yield* base;
463 return yield* Branch("AdoptedBranch", {
464 project,
465 name: "foreign-branch",
466 }).pipe(adopt(allow));
467 });
468 const refused = yield* stack
469 .deploy(program(false))
470 .pipe(Effect.result);
471 expect(Result.isFailure(refused)).toBe(true);
472 if (Result.isFailure(refused))
473 expect(refused.failure).toBeInstanceOf(OwnedBySomeoneElse);
474 const adopted = yield* stack.deploy(program(true));
475 expect(adopted.branchId).toBe(foreign.branch.id);
476 expect(
477 (yield* getProjectBranch({
478 project_id: project.projectId,
479 branch_id: adopted.branchId,
480 })).branch.name,
481 ).toBe("foreign-branch");
482 yield* stack.destroy();
483 expect(
484 yield* getProjectBranch({
485 project_id: project.projectId,
486 branch_id: adopted.branchId,
487 }).pipe(
488 Effect.as(false),
489 Effect.catchTag("NotFound", () => Effect.succeed(true)),
490 ),
491 ).toBe(true);
492 }),
493 { timeout: 120_000 },
494 );
495
496 test.provider(
497 "explicit branch names use delete-first immutable replacements in one project",
498 (stack) =>
499 Effect.gen(function* () {
500 yield* stack.destroy();
501 const program = (props: Partial<BranchProps> = {}) =>
502 Effect.gen(function* () {
503 const project = yield* Project("NamedBranchProject");
504 const parent = yield* Branch("AlternateParent", { project });
505 const branch = yield* Branch("NamedBranch", {
506 project,
507 name: "named-replacement",
508 ...props,
509 });
510 return { project, parent, branch };
511 });
512 const initial = yield* stack.deploy(program());
513 for (const props of [
514 { initSource: "schema-only" },
515 { parentBranch: { branchId: initial.parent.branchId } },
516 { parentLsn: "0/3FA01B0" },
517 { parentTimestamp: "2026-01-01T00:00:00Z" },
518 ] satisfies Partial<BranchProps>[]) {
519 const plan = yield* stack.plan(program(props));
520 expect(plan.resources.NamedBranch).toMatchObject({
521 action: "replace",
522 deleteFirst: true,
523 });
524 }
525 const renamed = yield* stack.plan(
526 program({ name: "renamed-replacement", initSource: "schema-only" }),
527 );
528 expect(renamed.resources.NamedBranch).toMatchObject({
529 action: "replace",
530 deleteFirst: false,
531 });
532 const provider = yield* Provider.findProvider(Branch);
533 const recovery = yield* provider
534 .reconcile({
535 id: "NamedBranch",
536 fqn: "NamedBranch",
537 instanceId: "replacement-branch",
538 news: {
539 project: { projectId: initial.project.projectId },
540 name: initial.branch.branchName,
541 initSource: "schema-only",
542 },
543 olds: undefined,
544 output: undefined,
545 bindings: [],
546 session: {
547 emit: () => Effect.void,
548 done: () => Effect.void,
549 note: () => Effect.void,
550 },
551 })
552 .pipe(Effect.result);
553 expect(Result.isFailure(recovery)).toBe(true);
554 if (Result.isFailure(recovery))
555 expect(recovery.failure).toBeInstanceOf(OwnedBySomeoneElse);
556 const props = {
557 initSource: "parent-data" as const,
558 parentBranch: { branchId: initial.parent.branchId },
559 };
560 const reparented = yield* stack.deploy(program(props));
561 expect(reparented.branch.branchId).not.toBe(initial.branch.branchId);
562 expect(reparented.branch.branchName).toBe(initial.branch.branchName);
563 expect(reparented.branch.initSource).toBe("parent-data");
564 expect(reparented.branch.parentBranchId).toBe(
565 initial.parent.branchId,
566 );
567 const removal = yield* stack.plan(program());
568 expect(removal.resources.NamedBranch).toMatchObject({
569 action: "replace",
570 deleteFirst: true,
571 });
572 const current = yield* getProjectBranch({
573 project_id: initial.project.projectId,
574 branch_id: reparented.branch.branchId,
575 });
576 expect(current.branch.parent_id).toBe(initial.parent.branchId);
577 expect(
578 (yield* stack.plan(program(props))).resources.NamedBranch.action,
579 ).toBe("noop");
580 expect((yield* stack.deploy(program(props))).branch.branchId).toBe(
581 reparented.branch.branchId,
582 );
583 const schemaProps = { initSource: "schema-only" as const };
584 const schema = yield* stack.deploy(program(schemaProps));
585 expect(schema.branch.branchId).not.toBe(reparented.branch.branchId);
586 expect(schema.branch.branchName).toBe(initial.branch.branchName);
587 expect(schema.branch.initSource).toBe("schema-only");
588 expect(schema.branch.parentBranchId).toBeUndefined();
589 expect(
590 (yield* getProjectBranch({
591 project_id: schema.project.projectId,
592 branch_id: schema.branch.branchId,
593 })).branch.parent_id,
594 ).toBeUndefined();
595 for (const branch of [initial.branch, reparented.branch]) {
596 expect(
597 yield* getProjectBranch({
598 project_id: branch.projectId,
599 branch_id: branch.branchId,
600 }).pipe(
601 Effect.as(false),
602 Effect.catchTag("NotFound", () => Effect.succeed(true)),
603 ),
604 ).toBe(true);
605 }
606 expect(
607 (yield* stack.plan(program(schemaProps))).resources.NamedBranch
608 .action,
609 ).toBe("noop");
610 expect(
611 (yield* stack.deploy(program(schemaProps))).branch.branchId,
612 ).toBe(schema.branch.branchId);
613 yield* stack.destroy();
614 expect(
615 yield* getProjectBranch({
616 project_id: initial.project.projectId,
617 branch_id: schema.branch.branchId,
618 }).pipe(
619 Effect.as(false),
620 Effect.catchTag("NotFound", () => Effect.succeed(true)),
621 ),
622 ).toBe(true);
623 expect(
624 yield* getProject({ project_id: initial.project.projectId }).pipe(
625 Effect.as(false),
626 Effect.catchTag("NotFound", () => Effect.succeed(true)),
627 ),
628 ).toBe(true);
629 }),
630 { timeout: 120_000 },
631 );
632
633 test.provider(
634 "explicit branch names preserve schema-only roots and replace changed schema sources",
635 (stack) =>
636 Effect.gen(function* () {
637 yield* stack.destroy();
638 const program = (props: Partial<BranchProps> = {}) =>
639 Effect.gen(function* () {
640 const project = yield* Project("SchemaBranchProject");
641 const parent = yield* Branch("SchemaSource", { project });
642 const branch = yield* Branch("SchemaBranch", {
643 project,
644 name: "schema-source-replacement",
645 initSource: "schema-only",
646 parentBranch: { branchId: project.defaultBranchId },
647 ...props,
648 });
649 return { project, parent, branch };
650 });
651 const initial = yield* stack.deploy(program());
652 expect(initial.branch.initSource).toBe("schema-only");
653 expect(initial.branch.parentBranchId).toBeUndefined();
654 expect(
655 (yield* stack.plan(program())).resources.SchemaBranch.action,
656 ).toBe("noop");
657 expect((yield* stack.deploy(program())).branch.branchId).toBe(
658 initial.branch.branchId,
659 );
660 const props = {
661 parentBranch: { branchId: initial.parent.branchId },
662 };
663 expect(
664 (yield* stack.plan(program(props))).resources.SchemaBranch,
665 ).toMatchObject({ action: "replace", deleteFirst: true });
666 const replaced = yield* stack.deploy(program(props));
667 expect(replaced.branch.branchId).not.toBe(initial.branch.branchId);
668 expect(replaced.branch.branchName).toBe(initial.branch.branchName);
669 expect(replaced.branch.initSource).toBe("schema-only");
670 expect(replaced.branch.parentBranchId).toBeUndefined();
671 const current = yield* getProjectBranch({
672 project_id: replaced.project.projectId,
673 branch_id: replaced.branch.branchId,
674 });
675 // Neon reports schema-only requests as parent-schema in responses.
676 expect(current.branch.init_source).toBe("parent-schema");
677 expect(current.branch.parent_id).toBeUndefined();
678 for (const next of [{}, { parentBranch: undefined }]) {
679 expect(
680 (yield* stack.plan(program(next))).resources.SchemaBranch,
681 ).toMatchObject({ action: "replace", deleteFirst: true });
682 }
683 expect(
684 (yield* stack.plan(program(props))).resources.SchemaBranch.action,
685 ).toBe("noop");
686 expect((yield* stack.deploy(program(props))).branch.branchId).toBe(
687 replaced.branch.branchId,
688 );
689 expect(
690 yield* getProjectBranch({
691 project_id: initial.project.projectId,
692 branch_id: initial.branch.branchId,
693 }).pipe(
694 Effect.as(false),
695 Effect.catchTag("NotFound", () => Effect.succeed(true)),
696 ),
697 ).toBe(true);
698 yield* stack.destroy();
699 for (const branch of [replaced.branch, initial.parent]) {
700 expect(
701 yield* getProjectBranch({
702 project_id: branch.projectId,
703 branch_id: branch.branchId,
704 }).pipe(
705 Effect.as(false),
706 Effect.catchTag("NotFound", () => Effect.succeed(true)),
707 ),
708 ).toBe(true);
709 }
710 expect(
711 yield* getProject({ project_id: initial.project.projectId }).pipe(
712 Effect.as(false),
713 Effect.catchTag("NotFound", () => Effect.succeed(true)),
714 ),
715 ).toBe(true);
716 }),
717 { timeout: 120_000 },
718 );
719
720 test.provider(
721 "refuses late same-name branches and foreign replacements of cached identities",
722 (stack) =>
723 Effect.gen(function* () {
724 yield* stack.destroy();
725 const owners = Effect.gen(function* () {
726 const project = yield* Project("LateBranchProject");
727 const cached = yield* Branch("CachedBranch", { project });
728 const foreign = yield* Branch("ForeignBranch", { project });
729 return { project, cached, foreign };
730 });
731 const initial = yield* stack.deploy(owners);
732 const name = "late-branch";
733 const plan = yield* stack.plan(
734 Effect.gen(function* () {
735 const { project } = yield* owners;
736 return yield* Branch("LateBranch", { project, name });
737 }),
738 );
739 expect(plan.resources.LateBranch.action).toBe("create");
740 const renamed = yield* updateProjectBranch({
741 project_id: initial.project.projectId,
742 branch_id: initial.foreign.branchId,
743 branch: { name },
744 });
745 yield* waitForOperations(renamed.operations);
746 const provider = yield* Provider.findProvider(Branch);
747 const context = {
748 id: "LateBranch",
749 fqn: "LateBranch",
750 instanceId: "late-branch",
751 bindings: [],
752 session: {
753 emit: () => Effect.void,
754 done: () => Effect.void,
755 note: () => Effect.void,
756 },
757 };
758 const news = {
759 project: { projectId: initial.project.projectId },
760 name,
761 protected: true,
762 };
763 const late = yield* provider
764 .reconcile({
765 ...context,
766 news,
767 olds: undefined,
768 output: undefined,
769 })
770 .pipe(Effect.result);
771 expect(Result.isFailure(late)).toBe(true);
772 if (Result.isFailure(late))
773 expect(late.failure).toBeInstanceOf(OwnedBySomeoneElse);
774 yield* deleteBranchOutOfBand(
775 initial.project.projectId,
776 initial.cached.branchId,
777 );
778 const cached = yield* provider
779 .reconcile({
780 ...context,
781 news,
782 olds: news,
783 output: initial.cached,
784 })
785 .pipe(Effect.result);
786 expect(Result.isFailure(cached)).toBe(true);
787 if (Result.isFailure(cached))
788 expect(cached.failure).toBeInstanceOf(OwnedBySomeoneElse);
789 const observed = yield* getProjectBranch({
790 project_id: initial.project.projectId,
791 branch_id: initial.foreign.branchId,
792 });
793 expect(observed.branch.name).toBe(name);
794 expect(observed.branch.protected).toBe(false);
795 yield* stack.destroy();
796 for (const branch of [initial.cached, initial.foreign]) {
797 expect(
798 yield* getProjectBranch({
799 project_id: initial.project.projectId,
800 branch_id: branch.branchId,
801 }).pipe(
802 Effect.as(false),
803 Effect.catchTag("NotFound", () => Effect.succeed(true)),
804 ),
805 ).toBe(true);
806 }
807 }),
808 { timeout: 120_000 },
809 );
810 },
811);
812
813/** Rewrite the deployed branch's state row into a wedged `creating` row. */
814const wedgeBranchRow = (
815 stack: { name: string; stage: string },
816 project: unknown,
817) =>
818 Effect.gen(function* () {
819 const state = yield* yield* State;
820 const stage = stack.stage;
821 const fqns = yield* state.list({ stack: stack.name, stage });
822 const rows = yield* Effect.forEach(fqns, (fqn) =>
823 state
824 .get({ stack: stack.name, stage, fqn })
825 .pipe(Effect.map((row) => ({ fqn, row }))),
826 );
827 const wedged = rows.find(
828 (r): r is { fqn: string; row: ResourceState } =>
829 isResourceState(r.row) && r.row.resourceType === "Neon.Branch",
830 );
831 if (!wedged) {
832 return yield* Effect.die(
833 new Error("no Branch state row found after deploy"),
834 );
835 }
836 yield* state.set({
837 stack: stack.name,
838 stage,
839 fqn: wedged.fqn,
840 value: {
841 ...wedged.row,
842 status: "creating",
843 attr: undefined,
844 props: {
845 ...wedged.row.props,
846 project,
847 },
848 },
849 });
850 });
851
852/** Delete the branch out-of-band and wait (bounded) until it is gone. */
853const deleteBranchOutOfBand = (projectId: string, branchId: string) =>
854 Effect.gen(function* () {
855 yield* deleteProjectBranch({
856 project_id: projectId,
857 branch_id: branchId,
858 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
859 const gone = yield* getProjectBranch({
860 project_id: projectId,
861 branch_id: branchId,
862 }).pipe(
863 Effect.as("found" as const),
864 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
865 Effect.repeat({
866 schedule: Schedule.spaced("2 seconds"),
867 until: (s) => s === "gone",
868 times: 10,
869 }),
870 );
871 expect(gone).toEqual("gone");
872 });
873
874// `read` guard: the project reference survived the creating-state round-trip
875// as an object, but its Output-valued `projectId` did not. Pre-fix, `read`
876// crashed with `Error: Invalid Neon project source: must be a Project or
877// { projectId }` (thrown by `resolveProjectId`); post-fix it returns
878// `undefined` and recovery recreates the branch.
879test.provider(
880 "recovers a creating-state branch whose project lost its Output-valued projectId (#736)",
881 (stack) =>
882 Effect.gen(function* () {
883 yield* stack.destroy();
884
885 const deployBranch = () =>
886 stack.deploy(
887 Effect.gen(function* () {
888 const project = yield* Project("WedgedReadProject");
889 const branch = yield* Branch("WedgedReadBranch", { project });
890 return { project, branch };
891 }),
892 );
893
894 const initial = yield* deployBranch();
895
896 // The #736 shape for the `read` guard: the object survived but the
897 // Output-valued `projectId` inside it deserialized as `undefined`.
898 yield* wedgeBranchRow(stack, { projectId: undefined });
899
900 // Delete the branch out-of-band so recovery must recreate it (a
901 // recovery `read` returning attributes would skip the create path).
902 yield* deleteBranchOutOfBand(
903 initial.project.projectId,
904 initial.branch.branchId,
905 );
906
907 const recovered = yield* deployBranch();
908 expect(recovered.branch.branchId).toBeDefined();
909 expect(recovered.branch.branchId).not.toEqual(initial.branch.branchId);
910 expect(recovered.branch.projectId).toEqual(recovered.project.projectId);
911 expect(recovered.branch.branchName).toEqual(initial.branch.branchName);
912
913 const fetched = yield* getProjectBranch({
914 project_id: recovered.project.projectId,
915 branch_id: recovered.branch.branchId,
916 });
917 expect(fetched.branch.id).toEqual(recovered.branch.branchId);
918
919 yield* stack.destroy();
920 }).pipe(logLevel),
921 {
922 tags: [
923 "provider:neon",
924 "provider:neon:branch",
925 "provider:neon:project",
926 "live",
927 ],
928 timeout: 120_000,
929 },
930);
931
932// `diff` guard: the whole `project` prop deserialized as `undefined`. `read`
933// falls through on `!olds?.project` (both pre- and post-fix), so the engine
934// then calls `diff` with the junk olds. Pre-fix, `diff` crashed in
935// `resolveProjectId(undefined)`; post-fix the unknown old project id falls
936// through to the create/update recovery path (no forced replacement).
937test.provider(
938 "recovers a creating-state branch whose project prop was lost entirely (#736)",
939 (stack) =>
940 Effect.gen(function* () {
941 yield* stack.destroy();
942
943 const deployBranch = () =>
944 stack.deploy(
945 Effect.gen(function* () {
946 const project = yield* Project("WedgedDiffProject");
947 const branch = yield* Branch("WedgedDiffBranch", { project });
948 return { project, branch };
949 }),
950 );
951
952 const initial = yield* deployBranch();
953
954 // The #736 shape for the `diff` guard: the entire Output-valued
955 // `project` prop deserialized as `undefined`.
956 yield* wedgeBranchRow(stack, undefined);
957
958 yield* deleteBranchOutOfBand(
959 initial.project.projectId,
960 initial.branch.branchId,
961 );
962
963 const recovered = yield* deployBranch();
964 expect(recovered.branch.branchId).toBeDefined();
965 expect(recovered.branch.branchId).not.toEqual(initial.branch.branchId);
966 expect(recovered.branch.projectId).toEqual(recovered.project.projectId);
967 expect(recovered.branch.branchName).toEqual(initial.branch.branchName);
968
969 const fetched = yield* getProjectBranch({
970 project_id: recovered.project.projectId,
971 branch_id: recovered.branch.branchId,
972 });
973 expect(fetched.branch.id).toEqual(recovered.branch.branchId);
974
975 yield* stack.destroy();
976 }).pipe(logLevel),
977 {
978 tags: [
979 "provider:neon",
980 "provider:neon:branch",
981 "provider:neon:project",
982 "live",
983 ],
984 timeout: 120_000,
985 },
986);