Skip to content

Commit c2764c8

Browse files
committed
refactor: rewrite DSQL cluster management in TypeScript and add Name tags
The bash script (scripts/dsql.sh) required jq and AWS CLI, making it platform-dependent. Rewrite as packages/db/src/cluster-cli.ts using @aws-sdk/client-dsql with idempotent create, SDK waiter, resource tagging, and a status subcommand. Rename cli.ts to migrate-cli.ts for clarity. Add Name tag to CDK-managed DSQL cluster for console identification. Document Drizzle findMany() alias pitfall in AGENTS.md and migration-prompt.ja.md — db.query.*.findMany() with exists() subqueries causes alias errors on DSQL (drizzle-orm#3068).
1 parent e9f4a4c commit c2764c8

File tree

11 files changed

+518
-124
lines changed

11 files changed

+518
-124
lines changed

.serverless-full-stack-webapp-starter-kit/docs/v3.0.0/migration-prompt.ja.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
| `packages/db/src/client.ts` | Proxy 遅延初期化 + globalThis シングルトン |
4242
| `packages/db/src/migrate.ts` | マイグレーションランナーコアロジック |
4343
| `packages/db/src/dsql-compat.ts` | SQL 変換 + バリデーション |
44-
| `packages/db/src/cli.ts` | CLI エントリポイント |
44+
| `packages/db/src/migrate-cli.ts` | マイグレーション CLI エントリポイント |
4545
| `packages/db/src/check-dsql-compat.ts` | drizzle-kit generate 後処理 |
4646
| `packages/db/drizzle.config.ts` ||
4747
| `packages/db/package.json` ||
@@ -50,7 +50,7 @@
5050
| `packages/shared-types/tsconfig.json` ||
5151
| `apps/cdk/lib/constructs/database.ts` | DSQL CfnCluster + IAM 認証 |
5252
| `apps/cdk/lib/constructs/dsql-migrator/` | Dockerfile, handler.ts, index.ts 一式 |
53-
| `scripts/dsql.sh` | 開発用 DSQL クラスタの作成・削除 |
53+
| `packages/db/src/cluster-cli.ts` | 開発用 DSQL クラスタの作成・削除 |
5454

5555
以下はユーザー固有の変換が必要なため、コピーではなく手書き・変換する:
5656

@@ -229,6 +229,7 @@ pnpm --filter @repo/db run generate
229229
- `next.config.ts``transpilePackages: ['@repo/db', '@repo/shared-types']` を追加(ユーザーの既存設定を保持しつつマージ)
230230
- **Json カラムの全使用箇所を洗い出す**`rg 'Json|\.json\b' --type ts` でスキーマ定義と読み書き箇所を特定)。Prisma は Json 型を自動で parse/stringify するが、Drizzle の `text()` は手動変換が必要。読み出し時に `JSON.parse()`、書き込み時に `JSON.stringify()` を追加すること
231231
- **Prisma の nested create(暗黙トランザクション)を `db.transaction()` に変換する。** 特に `onDelete: Cascade` に依存していた削除ロジックは、`db.transaction()` 内で子テーブルを先に削除してから親テーブルを削除するように書き換えること
232+
- **`db.query.*.findMany()``exists()`/`notExists()` サブクエリと組み合わせないこと。** `findMany()` は内部でテーブルにエイリアスを付与するが、`where`/`extras` 内のカラム参照は元テーブル名で展開されるため `invalid reference to FROM-clause entry for table` エラーになる。`db.select().from().leftJoin()` に書き換えること。`findFirst()` はサブクエリなしなら安全。詳細は drizzle-team/drizzle-orm#3068
232233

233234
### 3-5. クリーンアップ
234235

@@ -238,10 +239,10 @@ pnpm --filter @repo/db run generate
238239

239240
### 3-6. 開発用 DSQL クラスタでの検証
240241

241-
本番データベースに触れる前に、開発用 DSQL クラスタでスキーマとアプリコードの動作を検証する。v3 キットの `scripts/dsql.sh` を使う:
242+
本番データベースに触れる前に、開発用 DSQL クラスタでスキーマとアプリコードの動作を検証する。v3 キットの `packages/db` の cluster コマンドを使う:
242243

243244
```bash
244-
bash scripts/dsql.sh create --region <region>
245+
pnpm --filter @repo/db run cluster create --region <region>
245246
```
246247

247248
このスクリプトは開発用 DSQL クラスタを作成し、`packages/db/.env` に接続情報を自動で書き込む。
@@ -262,7 +263,7 @@ bash scripts/dsql.sh create --region <region>
262263

263264
```bash
264265
# Phase 5 完了後に実行
265-
bash scripts/dsql.sh delete --region <region>
266+
pnpm --filter @repo/db run cluster delete --region <region>
266267
```
267268

268269
### チェックポイント

AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ pnpm --filter @repo/db run migrate
2424
pnpm run lint
2525

2626
# local development (requires DSQL cluster)
27-
bash scripts/dsql.sh create # create dev DSQL cluster
27+
pnpm --filter @repo/db run cluster create # create dev DSQL cluster
2828
cd apps/webapp && pnpm run dev
2929
```
3030

@@ -60,6 +60,8 @@ DSQL constraints:
6060
- 1 DDL per transaction
6161
- ALTER TABLE only supports: ADD COLUMN, RENAME COLUMN/TABLE/CONSTRAINT, SET SCHEMA, OWNER TO, and IDENTITY operations. Everything else (DROP COLUMN, ALTER COLUMN TYPE, SET/DROP NOT NULL, SET/DROP DEFAULT, DROP CONSTRAINT) requires table recreation.
6262

63+
- `db.query.*.findMany()` with `exists()`/`sql` subqueries causes alias errors on DSQL (drizzle-team/drizzle-orm#3068). Use `db.select().from()` instead. `findFirst()` is safe.
64+
6365
### Database migration
6466

6567
See `packages/db/README.md` for full usage. Key rules:

apps/cdk/lib/constructs/database.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export class Database extends Construct {
3030

3131
const cluster = new CfnCluster(this, 'Cluster', {
3232
deletionProtectionEnabled,
33+
tags: [{ key: 'Name', value: `${Stack.of(this).stackName}-dsql` }],
3334
});
3435
cluster.applyRemovalPolicy(removalPolicy);
3536

apps/cdk/test/__snapshots__/serverless-fullstack-webapp-starter-kit-without-domain.test.ts.snap

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

33
exports[`Snapshot test 1`] = `
44
{
@@ -1597,6 +1597,12 @@ exports[`Snapshot test 2`] = `
15971597
"DeletionPolicy": "RetainExceptOnCreate",
15981598
"Properties": {
15991599
"DeletionProtectionEnabled": false,
1600+
"Tags": [
1601+
{
1602+
"Key": "Name",
1603+
"Value": "ServerlessWebappStarterKitStack-dsql",
1604+
},
1605+
],
16001606
},
16011607
"Type": "AWS::DSQL::Cluster",
16021608
"UpdateReplacePolicy": "Retain",

apps/cdk/test/__snapshots__/serverless-fullstack-webapp-starter-kit.test.ts.snap

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

33
exports[`Snapshot test 1`] = `
44
{
@@ -1513,6 +1513,12 @@ exports[`Snapshot test 2`] = `
15131513
"DeletionPolicy": "RetainExceptOnCreate",
15141514
"Properties": {
15151515
"DeletionProtectionEnabled": false,
1516+
"Tags": [
1517+
{
1518+
"Key": "Name",
1519+
"Value": "ServerlessWebappStarterKitStack-dsql",
1520+
},
1521+
],
15161522
},
15171523
"Type": "AWS::DSQL::Cluster",
15181524
"UpdateReplacePolicy": "Retain",

packages/db/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ DSQL_ENDPOINT=<cluster>.dsql.<region>.on.aws
9494
AWS_REGION=<region>
9595
```
9696

97-
Or use `scripts/dsql.sh create --region <region>` from the repo root to provision a dev cluster and write `.env` automatically.
97+
Or use `pnpm --filter @repo/db run cluster create --region <region>` to provision a dev cluster and write `.env` automatically.
9898

9999
## Testing
100100

packages/db/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
},
1111
"scripts": {
1212
"generate": "drizzle-kit generate && tsx src/check-dsql-compat.ts",
13-
"migrate": "tsx --env-file=.env src/cli.ts",
13+
"migrate": "tsx --env-file=.env src/migrate-cli.ts",
14+
"cluster": "tsx src/cluster-cli.ts",
1415
"test:unit": "vitest run --exclude '**/*.integ.test.ts'",
1516
"test:integ": "vitest run --config vitest.integ.config.ts",
1617
"test:watch": "vitest --exclude '**/*.integ.test.ts'",
@@ -27,6 +28,7 @@
2728
"pg": "^8"
2829
},
2930
"devDependencies": {
31+
"@aws-sdk/client-dsql": "^3",
3032
"@types/pg": "^8",
3133
"drizzle-kit": "^0.31.10",
3234
"tsx": "^4",

packages/db/src/cluster-cli.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Dev DSQL cluster management CLI
2+
//
3+
// Usage:
4+
// pnpm --filter @repo/db run cluster create [--region REGION]
5+
// pnpm --filter @repo/db run cluster delete [--region REGION]
6+
// pnpm --filter @repo/db run cluster status [--region REGION]
7+
//
8+
// After creation, DSQL_ENDPOINT and AWS_REGION are written to packages/db/.env.
9+
10+
import { writeFileSync, readFileSync, unlinkSync, existsSync } from 'node:fs';
11+
import { join } from 'node:path';
12+
import { execSync } from 'node:child_process';
13+
import {
14+
DSQLClient,
15+
CreateClusterCommand,
16+
DeleteClusterCommand,
17+
GetClusterCommand,
18+
waitUntilClusterActive,
19+
} from '@aws-sdk/client-dsql';
20+
21+
const ENV_PATH = join(import.meta.dirname, '..', '.env');
22+
23+
function parseArgs(args: string[]): { command: string; region: string } {
24+
const command = args[0] ?? 'help';
25+
let region = process.env.AWS_REGION ?? 'us-east-1';
26+
for (let i = 1; i < args.length; i++) {
27+
if (args[i] === '--region' && args[i + 1]) {
28+
region = args[++i];
29+
}
30+
}
31+
return { command, region };
32+
}
33+
34+
function readClusterId(): string | undefined {
35+
if (!existsSync(ENV_PATH)) return undefined;
36+
const match = readFileSync(ENV_PATH, 'utf-8').match(/^DSQL_ENDPOINT=([^.]+)/m);
37+
return match?.[1];
38+
}
39+
40+
function writeEnv(endpoint: string, region: string) {
41+
writeFileSync(ENV_PATH, `DSQL_ENDPOINT=${endpoint}\nAWS_REGION=${region}\n`);
42+
}
43+
44+
function getOwner(): string {
45+
return execSync('whoami', { encoding: 'utf-8' }).trim();
46+
}
47+
48+
async function create(client: DSQLClient, region: string) {
49+
// Idempotent: skip if an ACTIVE/CREATING cluster already exists
50+
const existing = readClusterId();
51+
if (existing) {
52+
try {
53+
const { status: clusterStatus } = await client.send(new GetClusterCommand({ identifier: existing }));
54+
if (clusterStatus === 'ACTIVE' || clusterStatus === 'CREATING') {
55+
console.log(`Cluster already exists: ${existing} (${clusterStatus})`);
56+
return;
57+
}
58+
console.log(`Previous cluster ${existing} is ${clusterStatus}, creating new one...`);
59+
} catch {
60+
// Cluster not found — proceed to create
61+
}
62+
}
63+
64+
console.log(`Creating DSQL cluster in ${region}...`);
65+
const res = await client.send(
66+
new CreateClusterCommand({
67+
deletionProtectionEnabled: false,
68+
tags: {
69+
Application: 'ServerlessWebappStarterKit',
70+
ManagedBy: 'cluster-cli',
71+
Owner: getOwner(),
72+
Name: 'ServerlessWebappStarterKit-dsql-dev',
73+
},
74+
}),
75+
);
76+
77+
const endpoint = res.endpoint!;
78+
const identifier = res.identifier!;
79+
writeEnv(endpoint, region);
80+
console.log(`Cluster created: ${identifier}`);
81+
console.log(`Endpoint: ${endpoint}`);
82+
console.log(`Written to: .env`);
83+
84+
console.log('\nWaiting for ACTIVE status...');
85+
await waitUntilClusterActive({ client, maxWaitTime: 300 }, { identifier });
86+
console.log('Cluster is ACTIVE');
87+
88+
console.log('\nRun schema migration:');
89+
console.log(' pnpm --filter @repo/db run migrate');
90+
}
91+
92+
async function del(client: DSQLClient) {
93+
const clusterId = readClusterId();
94+
if (!clusterId) {
95+
console.error('No cluster found in .env');
96+
process.exit(1);
97+
}
98+
console.log(`Deleting cluster: ${clusterId}`);
99+
await client.send(new DeleteClusterCommand({ identifier: clusterId }));
100+
unlinkSync(ENV_PATH);
101+
console.log('Cluster deleted and .env removed');
102+
}
103+
104+
async function status(client: DSQLClient) {
105+
const clusterId = readClusterId();
106+
if (!clusterId) {
107+
console.error('No cluster found in .env');
108+
process.exit(1);
109+
}
110+
const cluster = await client.send(new GetClusterCommand({ identifier: clusterId }));
111+
console.log(`Identifier: ${cluster.identifier}`);
112+
console.log(`Status: ${cluster.status}`);
113+
console.log(`Endpoint: ${cluster.endpoint}`);
114+
}
115+
116+
async function main() {
117+
const { command, region } = parseArgs(process.argv.slice(2));
118+
119+
if (command === 'help') {
120+
console.log('Usage: cluster {create|delete|status} [--region REGION]');
121+
return;
122+
}
123+
124+
const client = new DSQLClient({ region });
125+
126+
switch (command) {
127+
case 'create':
128+
return create(client, region);
129+
case 'delete':
130+
return del(client);
131+
case 'status':
132+
return status(client);
133+
default:
134+
console.error(`Unknown command: ${command}`);
135+
process.exit(1);
136+
}
137+
}
138+
139+
main().catch((err) => {
140+
console.error(err);
141+
process.exit(1);
142+
});

0 commit comments

Comments
 (0)