Skip to content
Source
Sign inSign up
Open History to see the last commit

Branch.test.ts

924 lines37.7 KB

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