Skip to content

Commit ca29de3

Browse files
authored
feat(planetscale): check unchangeable props with adopt: true (#1357)
1 parent e7249f0 commit ca29de3

File tree

4 files changed

+255
-3
lines changed

4 files changed

+255
-3
lines changed

alchemy/src/planetscale/branch.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,22 @@ export interface BranchProps extends PlanetScaleProps {
8383
* Enable or disable safe migrations on this branch
8484
*/
8585
safeMigrations?: boolean;
86+
87+
/**
88+
* The region to create the branch in.
89+
* If not provided, the branch will be created in the default region for its database.
90+
* On adopt/update, if specified, the actual branch region is validated against this value.
91+
*
92+
* @see https://planetscale.com/docs/concepts/regions
93+
*/
94+
region?: {
95+
/**
96+
* The slug identifier of the region (e.g. "us-east", "eu-west", "gcp-us-central1")
97+
*
98+
* @see https://planetscale.com/docs/concepts/regions#available-regions
99+
*/
100+
slug: string;
101+
};
86102
}
87103

88104
/**
@@ -113,6 +129,20 @@ export interface Branch extends BranchProps {
113129
* HTML URL to access the branch
114130
*/
115131
htmlUrl: string;
132+
133+
/**
134+
* The region of the branch as reported by PlanetScale.
135+
*
136+
* @see https://planetscale.com/docs/concepts/regions
137+
*/
138+
region: {
139+
/**
140+
* The slug identifier of the region (e.g. "us-east", "eu-west", "gcp-us-central1")
141+
*
142+
* @see https://planetscale.com/docs/concepts/regions#available-regions
143+
*/
144+
slug: string;
145+
};
116146
}
117147

118148
/**
@@ -259,6 +289,18 @@ export const Branch = Resource(
259289
const data = getResponse.data;
260290
const currentParentBranch = data.parent_branch || "main";
261291

292+
// Validate region matches if specified
293+
if (props.region) {
294+
const actualSlug = data.region.slug;
295+
if (actualSlug !== props.region.slug) {
296+
throw new Error(
297+
`Branch "${branchName}" is in region "${actualSlug}" but expected "${props.region.slug}". ` +
298+
`PlanetScale branch regions cannot be changed after creation. ` +
299+
`Either update the region in your configuration to match, or create a new branch in the correct region.`,
300+
);
301+
}
302+
}
303+
262304
// Check immutable properties
263305
if (props.parentBranch && parentBranchName !== currentParentBranch) {
264306
throw new Error(
@@ -323,6 +365,7 @@ export const Branch = Resource(
323365
createdAt: data.created_at,
324366
updatedAt: data.updated_at,
325367
htmlUrl: data.html_url,
368+
region: { slug: data.region.slug },
326369
};
327370
}
328371
let clusterSize: string | undefined;
@@ -352,6 +395,7 @@ export const Branch = Resource(
352395
parent_branch: parentBranchName,
353396
backup_id: props.backupId,
354397
seed_data: props.seedData,
398+
region: props.region?.slug,
355399
// This is ignored unless props.backupId is provided
356400
cluster_size: clusterSize,
357401
},
@@ -391,6 +435,7 @@ export const Branch = Resource(
391435
createdAt: data.created_at,
392436
updatedAt: data.updated_at,
393437
htmlUrl: data.html_url,
438+
region: { slug: data.region.slug },
394439
};
395440
},
396441
);

alchemy/src/planetscale/database.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,15 @@ interface BaseDatabaseProps extends PlanetScaleProps {
4242
delete?: boolean;
4343

4444
/**
45-
* The region where the database will be created (create only)
45+
* The region where the database will be created (create only).
46+
*
47+
* @see https://planetscale.com/docs/concepts/regions
4648
*/
4749
region?: {
4850
/**
49-
* The slug identifier of the region
51+
* The slug identifier of the region (e.g. "us-east", "eu-west", "gcp-us-central1")
52+
*
53+
* @see https://planetscale.com/docs/concepts/regions#available-regions
5054
*/
5155
slug: string;
5256
};
@@ -217,6 +221,20 @@ export type Database = DatabaseProps & {
217221
* The organization of the database
218222
*/
219223
organization: string;
224+
225+
/**
226+
* The region of the database as reported by PlanetScale.
227+
*
228+
* @see https://planetscale.com/docs/concepts/regions
229+
*/
230+
region: {
231+
/**
232+
* The slug identifier of the region (e.g. "us-east", "eu-west", "gcp-us-central1")
233+
*
234+
* @see https://planetscale.com/docs/concepts/regions#available-regions
235+
*/
236+
slug: string;
237+
};
220238
};
221239

222240
/**
@@ -331,6 +349,49 @@ export const Database = Resource(
331349
cause: getResponse.error,
332350
});
333351
}
352+
353+
// Validate immutable properties match if specified
354+
const actualKind = getResponse.data.kind;
355+
if (props.kind && actualKind !== props.kind) {
356+
throw new Error(
357+
`Database "${databaseName}" has kind "${actualKind}" but expected "${props.kind}". ` +
358+
`Database kind cannot be changed after creation.`,
359+
);
360+
}
361+
362+
if (props.region) {
363+
const actualSlug = getResponse.data.region.slug;
364+
if (actualSlug !== props.region.slug) {
365+
throw new Error(
366+
`Database "${databaseName}" is in region "${actualSlug}" but expected "${props.region.slug}". ` +
367+
`PlanetScale database regions cannot be changed after creation. ` +
368+
`Either update the region in your configuration to match, or create a new database in the correct region.`,
369+
);
370+
}
371+
}
372+
373+
if (props.kind === "postgresql" && props.arch) {
374+
const defaultBranch = props.defaultBranch || "main";
375+
const branchInfo = await api.getBranch({
376+
path: {
377+
organization,
378+
database: databaseName,
379+
branch: defaultBranch,
380+
},
381+
throwOnError: false,
382+
});
383+
if (branchInfo.data?.cluster_architecture) {
384+
const actualArch =
385+
branchInfo.data.cluster_architecture === "aarch64" ? "arm" : "x86";
386+
if (actualArch !== props.arch) {
387+
throw new Error(
388+
`Database "${databaseName}" has architecture "${actualArch}" but expected "${props.arch}". ` +
389+
`Database architecture cannot be changed after creation.`,
390+
);
391+
}
392+
}
393+
}
394+
334395
// Update database settings
335396
// If updating to a non-'main' default branch, create it first
336397
if (props.defaultBranch && props.defaultBranch !== "main") {
@@ -423,6 +484,7 @@ export const Database = Resource(
423484
updatedAt: data.updated_at,
424485
htmlUrl: data.html_url,
425486
organization,
487+
region: { slug: data.region.slug },
426488
};
427489
}
428490

@@ -546,6 +608,7 @@ export const Database = Resource(
546608
updatedAt: updatedData.updated_at,
547609
htmlUrl: updatedData.html_url,
548610
organization,
611+
region: { slug: updatedData.region.slug },
549612
};
550613
}
551614
}
@@ -573,6 +636,7 @@ export const Database = Resource(
573636
updatedAt: data.updated_at,
574637
htmlUrl: data.html_url,
575638
organization,
639+
region: { slug: data.region.slug },
576640
};
577641
},
578642
);

alchemy/test/planetscale/database.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ describe.skipIf(!process.env.PLANETSCALE_TEST).concurrent.each(kinds)(
5151
updatedAt: expect.any(String),
5252
htmlUrl: expect.any(String),
5353
kind,
54+
region: {
55+
slug: expect.any(String),
56+
},
5457
});
5558

5659
// Branch won't exist until database is ready
@@ -297,6 +300,142 @@ describe.skipIf(!process.env.PLANETSCALE_TEST).concurrent.each(kinds)(
297300
5_000_000,
298301
);
299302

303+
test(`adopt with wrong region should throw (${kind})`, async (scope) => {
304+
const name = `${BRANCH_PREFIX}-${kind}-region`;
305+
306+
try {
307+
// Create a database (will be in default region, typically us-east)
308+
const database = await Database("region-check", {
309+
name,
310+
region: { slug: "us-east" },
311+
clusterSize: "PS_10",
312+
kind,
313+
delete: true,
314+
});
315+
316+
expect(database.region).toMatchObject({
317+
slug: "us-east",
318+
});
319+
320+
// Now try to adopt it with a different region — should throw
321+
await expect(
322+
Database("region-check", {
323+
name,
324+
adopt: true,
325+
region: { slug: "eu-west" },
326+
clusterSize: "PS_10",
327+
kind,
328+
delete: true,
329+
}),
330+
).rejects.toThrow(/is in region "us-east" but expected "eu-west"/);
331+
332+
// Adopting with the correct region should succeed
333+
const adopted = await Database("region-check", {
334+
name,
335+
adopt: true,
336+
region: { slug: "us-east" },
337+
clusterSize: "PS_10",
338+
kind,
339+
delete: true,
340+
});
341+
342+
expect(adopted.region.slug).toBe("us-east");
343+
} finally {
344+
await destroy(scope);
345+
await assertDatabaseDeleted(api, organization, name);
346+
}
347+
}, 5_000_000);
348+
349+
test(`adopt with wrong kind should throw (${kind})`, async (scope) => {
350+
const name = `${BRANCH_PREFIX}-${kind}-kind`;
351+
const wrongKind = kind === "mysql" ? "postgresql" : "mysql";
352+
353+
try {
354+
await Database("kind-check", {
355+
name,
356+
clusterSize: "PS_10",
357+
kind,
358+
delete: true,
359+
});
360+
361+
await waitForDatabaseReady(api, organization, name);
362+
363+
// Try to adopt with the wrong kind — should throw
364+
await expect(
365+
Database("kind-check", {
366+
name,
367+
adopt: true,
368+
clusterSize: "PS_10",
369+
kind: wrongKind,
370+
delete: true,
371+
}),
372+
).rejects.toThrow(
373+
new RegExp(`has kind "${kind}" but expected "${wrongKind}"`),
374+
);
375+
376+
// Adopting with the correct kind should succeed
377+
const adopted = await Database("kind-check", {
378+
name,
379+
adopt: true,
380+
clusterSize: "PS_10",
381+
kind,
382+
delete: true,
383+
});
384+
385+
expect(adopted.name).toBe(name);
386+
} finally {
387+
await destroy(scope);
388+
await assertDatabaseDeleted(api, organization, name);
389+
}
390+
}, 5_000_000);
391+
392+
test.skipIf(kind !== "postgresql")(
393+
`adopt with wrong arch should throw (${kind})`,
394+
async (scope) => {
395+
const name = `${BRANCH_PREFIX}-${kind}-arch-check`;
396+
397+
try {
398+
await Database("arch-check", {
399+
name,
400+
clusterSize: "PS_10",
401+
kind: "postgresql",
402+
arch: "x86",
403+
delete: true,
404+
});
405+
406+
await waitForDatabaseReady(api, organization, name);
407+
408+
// Try to adopt with the wrong arch — should throw
409+
await expect(
410+
Database("arch-check", {
411+
name,
412+
adopt: true,
413+
clusterSize: "PS_10",
414+
kind: "postgresql",
415+
arch: "arm",
416+
delete: true,
417+
}),
418+
).rejects.toThrow(/has architecture "x86" but expected "arm"/);
419+
420+
// Adopting with the correct arch should succeed
421+
const adopted = await Database("arch-check", {
422+
name,
423+
adopt: true,
424+
clusterSize: "PS_10",
425+
kind: "postgresql",
426+
arch: "x86",
427+
delete: true,
428+
});
429+
430+
expect(adopted.name).toBe(name);
431+
} finally {
432+
await destroy(scope);
433+
await assertDatabaseDeleted(api, organization, name);
434+
}
435+
},
436+
5_000_000,
437+
);
438+
300439
test(`database with delete=false should not be deleted via API (${kind})`, async (scope) => {
301440
const name = `${BRANCH_PREFIX}-${kind}-nodelete`;
302441

bun.lock

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)