1import * as Planetscale from "@/Planetscale";
2import * as Provider from "@/Provider";
3import * as Test from "@/Test/Alchemy";
4import * as ps from "@distilled.cloud/planetscale";
5import { describe, expect } from "alchemy-test";
6import { Data, Schedule } from "effect";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9
10const { test } = Test.make({ providers: Planetscale.providers() });
11
12const logLevel = Effect.provideService(
13 MinimumLogLevel,
14 process.env.DEBUG ? "Debug" : "Info",
15);
16
17const branchOutput = (
18 overrides: Partial<Planetscale.PostgresBranchAttributes> = {},
19): Planetscale.PostgresBranchAttributes => ({
20 name: "branch",
21 organization: "org",
22 database: "database",
23 parentBranch: "main",
24 production: false,
25 createdAt: "2026-01-01T00:00:00.000Z",
26 updatedAt: "2026-01-01T00:00:00.000Z",
27 htmlUrl: "https://planetscale.com/org/database/branch/branch",
28 region: { slug: "us-east" },
29 migrationsDir: undefined,
30 migrationsTable: undefined,
31 migrationsHashes: {},
32 importHashes: {},
33 desiredReplicas: undefined,
34 hasReplicas: undefined,
35 hasReadOnlyReplicas: undefined,
36 ...overrides,
37});
38
39test.provider(
40 "diff tracks Postgres branch replica intent",
41 () =>
42 Effect.gen(function* () {
43 const provider = yield* Provider.findProvider(Planetscale.PostgresBranch);
44 const props = (replicas: number): Planetscale.PostgresBranchProps => ({
45 database: "database",
46 parentBranch: "main",
47 replicas,
48 });
49
50 const alreadyConvergedToNonHa = yield* provider.diff!({
51 id: "Branch",
52 fqn: "Branch",
53 instanceId: "instance",
54 olds: props(0),
55 news: props(0),
56 oldBindings: [],
57 newBindings: [],
58 output: branchOutput({
59 desiredReplicas: 0,
60 hasReplicas: false,
61 hasReadOnlyReplicas: false,
62 }),
63 });
64 expect(alreadyConvergedToNonHa).toBeUndefined();
65
66 const exactHaCountChanged = yield* provider.diff!({
67 id: "Branch",
68 fqn: "Branch",
69 instanceId: "instance",
70 olds: props(2),
71 news: props(3),
72 oldBindings: [],
73 newBindings: [],
74 output: branchOutput({
75 desiredReplicas: 2,
76 hasReplicas: true,
77 hasReadOnlyReplicas: false,
78 }),
79 });
80 // A non-renaming update advertises `name` as stable so downstream
81 // consumers keep resolving `branch.name` at plan time.
82 expect(exactHaCountChanged).toEqual({
83 action: "update",
84 stables: ["organization", "database", "name"],
85 });
86 }),
87 { tags: ["provider:planetscale", "provider:planetscale:postgres", "live"] },
88);
89
90describe.skipIf(!process.env.PLANETSCALE_TEST)(
91 "Branch",
92 { tags: ["provider:planetscale", "live"] },
93 () => {
94 test.provider.skipIf(
95 !process.env.PLANETSCALE_BRANCH_REPLICA_TEST ||
96 !process.env.PLANETSCALE_BRANCH_REPLICA_DATABASE,
97 )(
98 "Postgres branch persists replica intent and plans no-op once converged",
99 (stack) =>
100 Effect.gen(function* () {
101 const dbName = process.env.PLANETSCALE_BRANCH_REPLICA_DATABASE!;
102 const branchName = `replica-target-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
103 const { organization } = yield* yield* Planetscale.Credentials;
104
105 yield* Effect.gen(function* () {
106 yield* stack.destroy();
107 yield* deleteBranchIfExists(dbName, branchName, organization);
108
109 const program = Effect.gen(function* () {
110 const branch = yield* Planetscale.PostgresBranch(
111 "ReplicaBranch",
112 {
113 name: branchName,
114 database: dbName,
115 parentBranch: "main",
116 replicas: 0,
117 },
118 );
119
120 return { branch };
121 });
122
123 const { branch } = yield* stack.deploy(program);
124
125 expect(branch).toMatchObject({
126 name: branchName,
127 database: dbName,
128 desiredReplicas: 0,
129 hasReplicas: false,
130 hasReadOnlyReplicas: false,
131 });
132
133 const live = yield* ps.getBranch({
134 organization,
135 database: dbName,
136 branch: branchName,
137 });
138
139 expect(live.has_replicas).toBe(false);
140 expect(live.has_read_only_replicas).toBe(false);
141
142 const plan = yield* stack.plan(program);
143 expect(plan.resources.ReplicaBranch).toMatchObject({
144 action: "noop",
145 });
146
147 yield* stack.destroy();
148 yield* waitForBranchToBeDeleted(dbName, branchName, organization);
149 }).pipe(
150 Effect.ensuring(
151 deleteBranchIfExists(dbName, branchName, organization),
152 ),
153 );
154 }).pipe(logLevel),
155 { timeout: 5_000_000, tags: ["provider:planetscale:postgres"] },
156 );
157
158 // Canonical `list()` test (PARENT FAN-OUT): branches live under a database
159 // within the credentialed organization. `list()` enumerates every database
160 // in the org, lists each database's branches, and keeps only the engine's
161 // kind (here MySQL). Deploy one branch, then assert it appears in the
162 // exhaustively-paginated result.
163 test.provider(
164 "list enumerates the deployed branch across the org",
165 (stack) =>
166 Effect.gen(function* () {
167 const dbName = "alchemy-branch-list";
168 const branchName = "list-target";
169
170 yield* stack.destroy();
171
172 const { database, branch } = yield* stack.deploy(
173 Effect.gen(function* () {
174 const database = yield* Planetscale.MySQLDatabase("Database", {
175 name: dbName,
176 region: { slug: "us-east" },
177 clusterSize: "PS_10",
178 });
179 const branch = yield* Planetscale.MySQLBranch("ListBranch", {
180 name: branchName,
181 database,
182 parentBranch: "main",
183 isProduction: false,
184 });
185
186 return { database, branch };
187 }),
188 );
189
190 const provider = yield* Provider.findProvider(
191 Planetscale.MySQLBranch,
192 );
193 const all = yield* provider.list();
194
195 const found = all.find(
196 (b) =>
197 b.organization === database.organization &&
198 b.database === dbName &&
199 b.name === branch.name,
200 );
201
202 expect(found).toBeDefined();
203 expect(found).toMatchObject({
204 organization: database.organization,
205 database: dbName,
206 name: branch.name,
207 parentBranch: "main",
208 production: false,
209 createdAt: expect.any(String),
210 updatedAt: expect.any(String),
211 htmlUrl: expect.any(String),
212 region: { slug: expect.any(String) },
213 });
214
215 // Every item is hydrated into the exact `read` Attributes shape — the
216 // org's `main` branch is enumerated too, only as MySQL kind.
217 expect(
218 all.every((b) => b.organization === database.organization),
219 ).toBe(true);
220
221 yield* stack.destroy();
222 yield* waitForDatabaseToBeDeleted(dbName, database.organization);
223 }).pipe(logLevel),
224 { timeout: 5_000_000, tags: ["provider:planetscale:mysql"] },
225 );
226 },
227);
228
229const waitForDatabaseToBeDeleted = Effect.fn(function* (
230 database: string,
231 organization: string,
232) {
233 yield* ps
234 .getDatabase({
235 organization,
236 database,
237 })
238 .pipe(
239 Effect.flatMap(() => Effect.fail(new DatabaseStillExists())),
240 Effect.retry({
241 while: (e): e is DatabaseStillExists =>
242 e instanceof DatabaseStillExists,
243 schedule: Schedule.exponential(100),
244 }),
245 Effect.catchTag("NotFound", () => Effect.void),
246 );
247});
248
249const waitForBranchToBeDeleted = Effect.fn(function* (
250 database: string,
251 branch: string,
252 organization: string,
253) {
254 yield* ps
255 .getBranch({
256 organization,
257 database,
258 branch,
259 })
260 .pipe(
261 Effect.flatMap(() => Effect.fail(new BranchStillExists())),
262 Effect.retry({
263 while: (e): e is BranchStillExists => e instanceof BranchStillExists,
264 schedule: Schedule.exponential(100),
265 }),
266 Effect.catchTag("NotFound", () => Effect.void),
267 );
268});
269
270const deleteBranchIfExists = (
271 database: string,
272 branch: string,
273 organization: string,
274) =>
275 ps.deleteBranch({ organization, database, branch }).pipe(
276 Effect.catchTag("NotFound", () => Effect.void),
277 Effect.flatMap(() =>
278 waitForBranchToBeDeleted(database, branch, organization),
279 ),
280 Effect.ignore,
281 );
282
283class BranchStillExists extends Data.TaggedError("BranchStillExists") {}
284class DatabaseStillExists extends Data.TaggedError("DatabaseStillExists") {}