Skip to content

Commit ffcb754

Browse files
committed
fix(stats): run production migration safely
1 parent d4d841b commit ffcb754

3 files changed

Lines changed: 115 additions & 8 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ jobs:
4747
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
4848
VITE_SENTRY_RELEASE: web@${{ github.sha }}
4949

50-
- run: bun sst shell --stage=${{ github.ref_name }} -- bun run --cwd packages/stats/core db:ensure-unique-users
50+
- if: github.ref_name != 'production'
51+
run: bun sst shell --stage=${{ github.ref_name }} -- bun run --cwd packages/stats/core db:ensure-unique-users
5152
env:
5253
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
5354
PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
@@ -60,3 +61,67 @@ jobs:
6061
SENTRY_RELEASE: web@${{ github.sha }}
6162
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
6263
VITE_SENTRY_RELEASE: web@${{ github.sha }}
64+
65+
- if: github.ref_name == 'production'
66+
uses: planetscale/setup-pscale-action@v1
67+
68+
- if: github.ref_name == 'production'
69+
run: |
70+
set -euo pipefail
71+
72+
database="opencode-stats"
73+
organization="anomalyco"
74+
branch="unique-users-${GITHUB_SHA::12}"
75+
password_id=""
76+
77+
cleanup() {
78+
if [ -n "$password_id" ]; then
79+
pscale password delete "$database" "$branch" "$password_id" --org "$organization" --force >/dev/null 2>&1 || true
80+
fi
81+
pscale branch delete "$database" "$branch" --org "$organization" --force >/dev/null 2>&1 || true
82+
}
83+
84+
trap cleanup EXIT
85+
86+
if bun sst shell --stage=production -- bun run --cwd packages/stats/core db:check-unique-users; then
87+
echo "unique_users columns already exist in production"
88+
exit 0
89+
fi
90+
91+
pscale branch delete "$database" "$branch" --org "$organization" --force >/dev/null 2>&1 || true
92+
pscale branch create "$database" "$branch" --org "$organization" --from production --wait
93+
94+
response="$(pscale password create "$database" "$branch" "unique-users-${GITHUB_RUN_ID}" --org "$organization" --format json)"
95+
password_id="$(echo "$response" | jq -r '.id')"
96+
97+
export PLANETSCALE_HOST="$(echo "$response" | jq -r '.access_host_url')"
98+
export PLANETSCALE_USERNAME="$(echo "$response" | jq -r '.username')"
99+
export PLANETSCALE_PASSWORD="$(echo "$response" | jq -r '.plain_text')"
100+
export PLANETSCALE_DATABASE="$database"
101+
102+
echo "::add-mask::$PLANETSCALE_PASSWORD"
103+
bun run --cwd packages/stats/core db:ensure-unique-users
104+
105+
deploy_response="$(pscale deploy-request create "$database" "$branch" --org "$organization" --deploy-to production --format json)"
106+
deploy_number="$(echo "$deploy_response" | jq -r '.number')"
107+
108+
if [ -z "$deploy_number" ] || [ "$deploy_number" = "null" ]; then
109+
echo "Could not read deploy request number"
110+
exit 1
111+
fi
112+
113+
pscale deploy-request review "$database" "$deploy_number" --org "$organization" --approve || true
114+
pscale deploy-request deploy "$database" "$deploy_number" --org "$organization"
115+
env:
116+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
117+
PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
118+
PLANETSCALE_SERVICE_TOKEN_ID: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
119+
PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}
120+
STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }}
121+
HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }}
122+
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
123+
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
124+
SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}
125+
SENTRY_RELEASE: web@${{ github.sha }}
126+
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
127+
VITE_SENTRY_RELEASE: web@${{ github.sha }}

packages/stats/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
},
1818
"scripts": {
1919
"db:generate": "drizzle-kit generate --config=drizzle.config.ts",
20+
"db:check-unique-users": "bun src/ensure-unique-users.ts --check",
2021
"db:ensure-unique-users": "bun src/ensure-unique-users.ts",
2122
"db:migrate": "bun src/migrate.ts",
2223
"db:push": "drizzle-kit push --config=drizzle.config.ts",

packages/stats/core/src/ensure-unique-users.ts

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,63 @@ import { Client } from "@planetscale/database"
22
import { Resource } from "sst/resource"
33

44
const tables = ["geo_stat", "model_stat", "provider_stat"] as const
5+
const checkOnly = process.argv.includes("--check")
56

6-
const client = new Client({ url: Resource.StatsDatabase.url })
7+
const client = new Client({ url: databaseUrl() })
78

8-
await tables.reduce((promise, table) => promise.then(() => ensureUniqueUsersColumn(table)), Promise.resolve())
9+
const missing = await tables.reduce<Promise<(typeof tables)[number][]>>(
10+
async (promise, table) => {
11+
const result = await promise
12+
if (await hasUniqueUsersColumn(table)) {
13+
console.log(`unique_users column already exists on ${table}`)
14+
return result
15+
}
16+
return [...result, table]
17+
},
18+
Promise.resolve([]),
19+
)
920

10-
async function ensureUniqueUsersColumn(table: (typeof tables)[number]) {
21+
if (missing.length === 0) {
22+
console.log("unique_users columns complete")
23+
process.exit(0)
24+
}
25+
26+
if (checkOnly) {
27+
console.log(`unique_users columns missing on ${missing.join(", ")}`)
28+
process.exit(1)
29+
}
30+
31+
await missing.reduce(
32+
(promise, table) => promise.then(() => addUniqueUsersColumn(table)),
33+
Promise.resolve(),
34+
)
35+
36+
function databaseUrl() {
37+
if (
38+
process.env.PLANETSCALE_HOST &&
39+
process.env.PLANETSCALE_USERNAME &&
40+
process.env.PLANETSCALE_PASSWORD &&
41+
process.env.PLANETSCALE_DATABASE
42+
)
43+
return `mysql://${encodeURIComponent(process.env.PLANETSCALE_USERNAME)}:${encodeURIComponent(
44+
process.env.PLANETSCALE_PASSWORD,
45+
)}@${process.env.PLANETSCALE_HOST}/${process.env.PLANETSCALE_DATABASE}?ssl=${encodeURIComponent(
46+
JSON.stringify({ rejectUnauthorized: true }),
47+
)}`
48+
49+
return process.env.DATABASE_URL ?? Resource.StatsDatabase.url
50+
}
51+
52+
async function hasUniqueUsersColumn(table: (typeof tables)[number]) {
1153
const result = await client.execute<{ column_name: string }>(
1254
"SELECT column_name FROM information_schema.columns WHERE table_schema = database() AND table_name = ? AND column_name = 'unique_users'",
1355
[table],
1456
)
1557

16-
if (result.rows.length > 0) {
17-
console.log(`unique_users column already exists on ${table}`)
18-
return
19-
}
58+
return result.rows.length > 0
59+
}
2060

61+
async function addUniqueUsersColumn(table: (typeof tables)[number]) {
2162
await client.execute(`ALTER TABLE \`${table}\` ADD \`unique_users\` bigint NOT NULL DEFAULT 0`)
2263
console.log(`added unique_users column to ${table}`)
2364
}

0 commit comments

Comments
 (0)