@clubedge/create-clubedge-app 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +12 -11
  3. package/package.json +3 -3
  4. package/template/.github/workflows/ci.yml +18 -0
  5. package/template/.github/workflows/deploy.yml +109 -0
  6. package/template/Dockerfile +5 -0
  7. package/template/README.md +7 -0
  8. package/template/SETUP.md +84 -1
  9. package/template/_gitignore +3 -0
  10. package/template/apps/start/Dockerfile +5 -0
  11. package/template/apps/start/netlify.toml +6 -0
  12. package/template/apps/start/package.json +1 -1
  13. package/template/apps/start/src/routes/api/ready.ts +23 -0
  14. package/template/apps/start/vercel.json +5 -0
  15. package/template/apps/web/netlify.toml +6 -0
  16. package/template/apps/web/package.json +1 -1
  17. package/template/apps/web/src/app/api/ready/route.ts +18 -0
  18. package/template/apps/web/vercel.json +5 -0
  19. package/template/clubedge.template.json +31 -0
  20. package/template/e2e/ready.spec.ts +11 -0
  21. package/template/fly.toml +25 -0
  22. package/template/package.json +2 -1
  23. package/template/packages/auth/package.json +1 -1
  24. package/template/packages/auth-better-auth/package.json +7 -1
  25. package/template/packages/auth-supabase/package.json +1 -1
  26. package/template/packages/cache/package.json +1 -1
  27. package/template/packages/cache-redis/package.json +1 -1
  28. package/template/packages/core/package.json +1 -1
  29. package/template/packages/db/package.json +7 -1
  30. package/template/packages/db/src/client.test.ts +28 -0
  31. package/template/packages/db/src/client.ts +19 -0
  32. package/template/packages/db/src/index.ts +1 -1
  33. package/template/packages/email/package.json +1 -1
  34. package/template/packages/email-resend/package.json +1 -1
  35. package/template/packages/email-smtp/package.json +1 -1
  36. package/template/packages/local-db/package.json +1 -1
  37. package/template/packages/storage/package.json +1 -1
  38. package/template/packages/storage-s3/package.json +1 -1
  39. package/template/packages/storage-supabase/package.json +1 -1
  40. package/template/packages/ui/package.json +1 -1
  41. package/template/pnpm-lock.yaml +3 -0
  42. package/template/railway.json +13 -0
  43. package/template/render.yaml +66 -0
  44. package/template/scripts/build-migrator.mjs +51 -0
  45. package/template/scripts/migrate.mjs +30 -0
  46. package/template.lock.json +16 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.11.0
6
+
7
+ - Default to Starter `v0.10.0`, which adds the deploy module (`--deploy docker|fly|railway|render|vercel|netlify`), a migrator in the production image, a manual deploy workflow, and the `/api/ready` readiness endpoint. `--deploy` needs no CLI code: it comes from the Starter's manifest.
8
+ - Test Fly.io and Vercel projects in CI and a Netlify project in the release smoke test.
9
+
5
10
  ## 0.10.0
6
11
 
7
12
  - Default to Starter `v0.9.0`, which adds the email module (`--email smtp|resend|none`), password reset for both auth providers, and email verification for Better Auth. `--email` needs no CLI code: it comes from the Starter's manifest.
package/README.md CHANGED
@@ -12,27 +12,27 @@ The CLI downloads a tested Starter release, names the generated project, creates
12
12
  Each CLI release is tested against one specific Starter release:
13
13
 
14
14
  ```text
15
- create-clubedge-app v0.10.0
15
+ create-clubedge-app v0.11.0
16
16
  |
17
17
  v
18
- Clubedge Starter v0.9.0
19
- commit e858d0b5b60bbb05f1b6aed3f4d9e841ea2df2a5
18
+ Clubedge Starter v0.10.0
19
+ commit 9d8fec71b3851c2d615735fb37edb6bb0e568761
20
20
  ```
21
21
 
22
22
  The current relationship is:
23
23
 
24
24
  | CLI release | Default Starter release | Meaning |
25
25
  | --- | --- | --- |
26
- | `create-clubedge-app@0.10.0` | `clubedge-starter@v0.9.0` | The CLI's required, tested default |
26
+ | `create-clubedge-app@0.11.0` | `clubedge-starter@v0.10.0` | The CLI's required, tested default |
27
27
 
28
28
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
29
29
 
30
- The `v0.9.0` tag is expected to resolve to commit
31
- `e858d0b5b60bbb05f1b6aed3f4d9e841ea2df2a5`. The CLI verifies this before
30
+ The `v0.10.0` tag is expected to resolve to commit
31
+ `9d8fec71b3851c2d615735fb37edb6bb0e568761`. The CLI verifies this before
32
32
  scaffolding with the default reference and fails rather than silently using a
33
33
  moved tag.
34
34
 
35
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.9.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
35
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.10.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
36
36
 
37
37
  ## Requirements
38
38
 
@@ -85,7 +85,7 @@ Either way, the app lives in `apps/web` and uses the same shared packages, so th
85
85
 
86
86
  ### Choosing services
87
87
 
88
- Authentication, file storage, caching, email, and local infrastructure are modules. Each has a default, and anything you leave out is removed completely: its package, its provider SDK, its environment variables, its documentation, and its lockfile entries. The Starter's `clubedge.template.json` defines the modules, so a module a newer Starter adds gets its `--<module>` flag without a CLI change; `--help` lists the modules of the Starter this CLI release creates.
88
+ Authentication, file storage, caching, email, local infrastructure, and deployment are modules. Each has a default, and anything you leave out is removed completely: its package, its provider SDK, its environment variables, its documentation, and its lockfile entries. The Starter's `clubedge.template.json` defines the modules, so a module a newer Starter adds gets its `--<module>` flag without a CLI change; `--help` lists the modules of the Starter this CLI release creates.
89
89
 
90
90
  | Flag | Options | Default |
91
91
  | ----------- | ------------------------------------------ | ---------- |
@@ -93,6 +93,7 @@ Authentication, file storage, caching, email, and local infrastructure are modul
93
93
  | `--storage` | `s3`, `supabase` (needs Supabase Auth), `none` | `s3` |
94
94
  | `--cache` | `redis` (falls back to memory without `REDIS_URL`), `memory` | `redis` |
95
95
  | `--email` | `smtp` (any provider, or a local inbox), `resend`, `none` | `smtp` |
96
+ | `--deploy` | `docker` (any container host), `fly`, `railway`, `render`, `vercel`, `netlify` | `docker` |
96
97
  | `--infra` | `docker` (Docker Compose), `supabase` (Supabase CLI), `local` (no Docker: PGlite and a local folder), `none` | `docker` |
97
98
 
98
99
  ```sh
@@ -137,7 +138,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
137
138
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
138
139
 
139
140
  # Reproduce a specific Starter release or commit
140
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.9.0
141
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.10.0
141
142
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
142
143
 
143
144
  # Test local Starter changes before releasing them
@@ -154,7 +155,7 @@ pnpm dlx @clubedge/create-clubedge-app add cache redis # or switch: add aut
154
155
  pnpm dlx @clubedge/create-clubedge-app remove storage
155
156
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
156
157
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
157
- pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.9.0 # or a specific tag, branch, or commit
158
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.10.0 # or a specific tag, branch, or commit
158
159
  ```
159
160
 
160
161
  The CLI renders the project twice, from the Starter commit and modules recorded in your `package.json` and from the new state, and merges the difference into your project. The new state has other modules for `add` and `remove`, and the newer Starter with the same modules for `upgrade`. Modules a newer Starter introduces start as `none` where possible, so an upgrade never adds a service on its own.
@@ -205,7 +206,7 @@ CI packs the CLI with `npm pack`, scaffolds a clean project from the packed tarb
205
206
  1. Make and validate changes in `clubedge-starter`.
206
207
  2. Run lint, typecheck, unit tests, browser tests, and production build.
207
208
  3. Update the Starter package versions.
208
- 4. Commit `main`, create a release tag such as `v0.9.0`, and publish the Starter release.
209
+ 4. Commit `main`, create a release tag such as `v0.10.0`, and publish the Starter release.
209
210
 
210
211
  ### CLI maintainers
211
212
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Create a Next.js or TanStack Start application from the Clubedge Starter reference implementation.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.9.0",
@@ -22,8 +22,8 @@
22
22
  },
23
23
  "clubedge": {
24
24
  "starterRepository": "Clubedge/clubedge-starter",
25
- "starterRef": "v0.9.0",
26
- "starterCommit": "e858d0b5b60bbb05f1b6aed3f4d9e841ea2df2a5"
25
+ "starterRef": "v0.10.0",
26
+ "starterCommit": "9d8fec71b3851c2d615735fb37edb6bb0e568761"
27
27
  },
28
28
  "license": "Apache-2.0",
29
29
  "repository": {
@@ -58,10 +58,28 @@ jobs:
58
58
 
59
59
  docker:
60
60
  runs-on: ubuntu-latest
61
+ services:
62
+ postgres:
63
+ image: postgres:17
64
+ env:
65
+ POSTGRES_USER: postgres
66
+ POSTGRES_PASSWORD: postgres
67
+ POSTGRES_DB: app
68
+ ports:
69
+ - 5432:5432
70
+ options: >-
71
+ --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
61
72
  steps:
62
73
  - uses: actions/checkout@v4
63
74
  - name: Build the production image
64
75
  run: docker build --tag app:ci .
76
+ - name: Run the image's migrations twice; the second run changes nothing
77
+ run: |
78
+ set -euo pipefail
79
+ for run in 1 2; do
80
+ docker run --rm --network host --env DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/app app:ci node /app/migrate.mjs
81
+ done
82
+ PGPASSWORD=postgres psql --host 127.0.0.1 --username postgres --dbname app --command "select count(*) from public.users"
65
83
  - name: Start the image and check health
66
84
  run: |
67
85
  set -euo pipefail
@@ -0,0 +1,109 @@
1
+ # Deploys on demand: Actions > Deploy > Run workflow. SETUP.md lists the secrets it needs.
2
+ name: Deploy
3
+ on:
4
+ workflow_dispatch:
5
+ permissions:
6
+ contents: read
7
+ concurrency:
8
+ group: deploy
9
+ cancel-in-progress: false
10
+ jobs:
11
+ # clubedge:if deploy=docker
12
+ # Builds the production image and publishes it to this repository's GitHub Container Registry
13
+ # as ghcr.io/<owner>/<repository>, tagged with the commit and latest. No secret is needed.
14
+ image:
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ contents: read
18
+ packages: write
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: docker/setup-buildx-action@v3
22
+ - uses: docker/login-action@v3
23
+ with:
24
+ registry: ghcr.io
25
+ username: ${{ github.actor }}
26
+ password: ${{ secrets.GITHUB_TOKEN }}
27
+ - id: meta
28
+ uses: docker/metadata-action@v5
29
+ with:
30
+ images: ghcr.io/${{ github.repository }}
31
+ tags: |
32
+ type=sha
33
+ type=raw,value=latest
34
+ - uses: docker/build-push-action@v6
35
+ with:
36
+ context: .
37
+ push: true
38
+ tags: ${{ steps.meta.outputs.tags }}
39
+ labels: ${{ steps.meta.outputs.labels }}
40
+ # clubedge:end
41
+ # clubedge:if deploy=fly
42
+ fly:
43
+ runs-on: ubuntu-latest
44
+ steps:
45
+ - uses: actions/checkout@v4
46
+ - uses: superfly/flyctl-actions/setup-flyctl@master
47
+ # Builds on Fly's builders and runs the release command (migrations) before switching.
48
+ - run: flyctl deploy --remote-only
49
+ env:
50
+ FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
51
+ # clubedge:end
52
+ # clubedge:if deploy=railway
53
+ railway:
54
+ runs-on: ubuntu-latest
55
+ steps:
56
+ - uses: actions/checkout@v4
57
+ # Uploads this checkout; Railway builds the image and runs the pre-deploy migrations.
58
+ - run: npx --yes @railway/cli@4 up --ci --service "$RAILWAY_SERVICE"
59
+ env:
60
+ RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
61
+ RAILWAY_SERVICE: ${{ vars.RAILWAY_SERVICE }}
62
+ # clubedge:end
63
+ # clubedge:if deploy=render
64
+ render:
65
+ runs-on: ubuntu-latest
66
+ steps:
67
+ # The service's deploy hook starts a deploy of its branch, with its pre-deploy migrations.
68
+ - run: curl --fail --silent --show-error --request POST "$RENDER_DEPLOY_HOOK_URL"
69
+ env:
70
+ RENDER_DEPLOY_HOOK_URL: ${{ secrets.RENDER_DEPLOY_HOOK_URL }}
71
+ # clubedge:end
72
+ # clubedge:if deploy=vercel
73
+ vercel:
74
+ runs-on: ubuntu-latest
75
+ env:
76
+ VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
77
+ VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
78
+ VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
79
+ steps:
80
+ - uses: actions/checkout@v4
81
+ - uses: pnpm/action-setup@v4
82
+ - uses: actions/setup-node@v4
83
+ with:
84
+ node-version: 22
85
+ cache: pnpm
86
+ # Builds here with the project's production settings and variables (including the
87
+ # migrations in its build command), then uploads the result.
88
+ - run: npx --yes vercel@latest pull --yes --environment=production --token="$VERCEL_TOKEN"
89
+ - run: npx --yes vercel@latest build --prod --token="$VERCEL_TOKEN"
90
+ - run: npx --yes vercel@latest deploy --prebuilt --prod --token="$VERCEL_TOKEN"
91
+ # clubedge:end
92
+ # clubedge:if deploy=netlify
93
+ netlify:
94
+ runs-on: ubuntu-latest
95
+ env:
96
+ NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
97
+ NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
98
+ steps:
99
+ - uses: actions/checkout@v4
100
+ - uses: pnpm/action-setup@v4
101
+ - uses: actions/setup-node@v4
102
+ with:
103
+ node-version: 22
104
+ cache: pnpm
105
+ - run: pnpm install --frozen-lockfile
106
+ # Builds with netlify.toml (including its migrations) and the site's variables, then deploys.
107
+ - run: npx --yes netlify-cli@latest deploy --build --prod
108
+ working-directory: apps/web
109
+ # clubedge:end
@@ -18,6 +18,10 @@ COPY --from=deps /app/ .
18
18
  COPY --from=pruner /app/out/full/ .
19
19
  COPY tsconfig.base.json ./
20
20
  RUN pnpm build
21
+ # The production migrator (node /app/migrate.mjs): one bundled file plus every package's
22
+ # migrations, so release commands can migrate before a new version starts.
23
+ COPY scripts/migrate.mjs scripts/build-migrator.mjs ./scripts/
24
+ RUN node scripts/build-migrator.mjs /app/.migrator
21
25
 
22
26
  FROM node:22-alpine AS runner
23
27
  ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=3000
@@ -26,6 +30,7 @@ RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
26
30
  COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
27
31
  COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
28
32
  COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
33
+ COPY --from=builder --chown=nextjs:nodejs /app/.migrator ./
29
34
  WORKDIR /app/apps/web
30
35
  USER nextjs
31
36
  EXPOSE 3000
@@ -29,6 +29,13 @@ This repository contains the same application for two frameworks, Next.js (`apps
29
29
  - Email through Resend; development prints emails to the server log until the API key is set. <!-- clubedge:only email=resend -->
30
30
  - Zod environment validation, security headers, structured errors, and `/api/health`.
31
31
  - Docker support, GitHub Actions CI, Vitest, Playwright, ESLint, and Prettier.
32
+ - A production image that includes its own migrator (`node /app/migrate.mjs`), for running migrations before each release.
33
+ - A manual deploy workflow for the chosen platform, and a readiness endpoint (`/api/ready`) that checks the database.
34
+ - Deployment to Fly.io with `fly.toml`: migrations run as the release command. <!-- clubedge:only deploy=fly -->
35
+ - Deployment to Railway with `railway.json`: migrations run before each deploy. <!-- clubedge:only deploy=railway -->
36
+ - Deployment to Render with a `render.yaml` Blueprint: migrations run before each deploy. <!-- clubedge:only deploy=render -->
37
+ - Deployment to Vercel with `apps/web/vercel.json`: migrations run during the build. <!-- clubedge:only deploy=vercel -->
38
+ - Deployment to Netlify with `apps/web/netlify.toml`: migrations run during the build. <!-- clubedge:only deploy=netlify -->
32
39
 
33
40
  ## Architecture
34
41
 
package/template/SETUP.md CHANGED
@@ -56,7 +56,7 @@ Start the development server:
56
56
  pnpm dev
57
57
  ```
58
58
 
59
- The app is available at <http://localhost:3000> and the liveness endpoint at <http://localhost:3000/api/health>.
59
+ The app is available at <http://localhost:3000>, the liveness endpoint at <http://localhost:3000/api/health>, and the readiness endpoint, which also checks the database, at <http://localhost:3000/api/ready>.
60
60
 
61
61
  <!-- clubedge:if auth=supabase -->
62
62
 
@@ -262,6 +262,89 @@ pnpm docker:start
262
262
  The image runs the Next.js standalone output as an unprivileged user. Ensure the configured database and provider hosts are reachable from the container. <!-- clubedge:only framework=next -->
263
263
  The image runs the self-contained Nitro server bundle as an unprivileged user, with no `node_modules`. Ensure the configured database and provider hosts are reachable from the container. <!-- clubedge:only framework=tanstack-start -->
264
264
 
265
+ The image also contains the production migrator, which applies every package's migrations with only `DATABASE_URL`:
266
+
267
+ ```sh
268
+ docker run --rm --env DATABASE_URL="postgresql://..." <image> node /app/migrate.mjs
269
+ ```
270
+
271
+ Run it before a new version starts serving; it skips migrations that are already applied.
272
+
273
+ ## Deploy
274
+
275
+ <!-- clubedge:if deploy=docker -->
276
+
277
+ Run the production image on any host or orchestrator that runs containers: build it with `pnpm docker:build` or in CI, push it to a registry, and start it with the production environment. Before each release, run the migrator above as a one-off task (a Kubernetes Job, an ECS task, or `docker run`), then roll out the new containers. Point the platform's health check at `/api/health`.
278
+
279
+ <!-- clubedge:end -->
280
+ <!-- clubedge:if deploy=fly -->
281
+
282
+ `fly.toml` deploys the production image to [Fly.io](https://fly.io):
283
+
284
+ ```sh
285
+ fly launch --no-deploy # creates the app; keep the existing fly.toml
286
+ fly secrets set DATABASE_URL="postgresql://..."
287
+ fly deploy
288
+ ```
289
+
290
+ Set the other production variables listed below with `fly secrets set`. Each deploy runs `node /app/migrate.mjs` as its release command, in a temporary machine, before the new version replaces the old one; a failed migration stops the deploy. Health checks call `/api/health`.
291
+
292
+ <!-- clubedge:end -->
293
+ <!-- clubedge:if deploy=railway -->
294
+
295
+ `railway.json` deploys the production image to [Railway](https://railway.com): create a project from this repository, add the production variables listed below to the service, and deploy. Railway runs `node /app/migrate.mjs` as the pre-deploy command, so a failed migration stops the release, and checks `/api/health` before switching traffic.
296
+
297
+ <!-- clubedge:end -->
298
+ <!-- clubedge:if deploy=render -->
299
+
300
+ `render.yaml` is a [Render](https://render.com) Blueprint: in the dashboard, choose New, then Blueprint, and select this repository. Render asks for each variable marked `sync: false` and generates `BETTER_AUTH_SECRET`. <!-- clubedge:only auth=better-auth -->
301
+ `render.yaml` is a [Render](https://render.com) Blueprint: in the dashboard, choose New, then Blueprint, and select this repository. Render asks for each variable marked `sync: false`. <!-- clubedge:only auth!=better-auth -->
302
+
303
+ Render runs `node /app/migrate.mjs` as the pre-deploy command before each deploy goes live, and a failed migration stops the deploy. Pre-deploy commands need a paid instance type. On the free type, remove `preDeployCommand` from `render.yaml` and apply migrations yourself before deploying, from your machine or CI, with `DATABASE_URL` set to the production database: `pnpm db:migrate`, or the migrator in the image as shown above. Health checks call `/api/health`.
304
+
305
+ <!-- clubedge:end -->
306
+ <!-- clubedge:if deploy=vercel -->
307
+
308
+ `apps/web/vercel.json` deploys the app to [Vercel](https://vercel.com). Import the repository, set the project's Root Directory to `apps/web`, add the production variables listed below, and deploy. Vercel installs the whole pnpm workspace and builds the app. <!-- clubedge:only framework=next -->
309
+ `apps/web/vercel.json` deploys the app to [Vercel](https://vercel.com). Import the repository, set the project's Root Directory to `apps/web`, add the production variables listed below, and deploy. The build detects Vercel and writes Vercel's Build Output, with server routes as functions. <!-- clubedge:only framework=tanstack-start -->
310
+
311
+ Vercel has no step that runs before a release, so the build command applies migrations (`pnpm db:migrate`) against the `DATABASE_URL` of the deployment's environment. Give Preview deployments their own database, or remove the migration step from `buildCommand` and run migrations yourself. Serverless functions open a connection per instance: use your provider's pooled connection URL (for example Supabase's transaction pooler or Neon's pooled endpoint).
312
+
313
+ <!-- clubedge:end -->
314
+ <!-- clubedge:if deploy=netlify -->
315
+
316
+ `apps/web/netlify.toml` deploys the app to [Netlify](https://www.netlify.com). Import the repository, set the site's Base directory to `apps/web`, add the production variables listed below, and deploy. Netlify installs its Next.js runtime automatically. <!-- clubedge:only framework=next -->
317
+ `apps/web/netlify.toml` deploys the app to [Netlify](https://www.netlify.com). Import the repository, set the site's Base directory to `apps/web`, add the production variables listed below, and deploy. The build detects Netlify and writes its functions and static assets. <!-- clubedge:only framework=tanstack-start -->
318
+
319
+ Netlify has no step that runs before a release, so the build command applies migrations (`pnpm db:migrate`) against the `DATABASE_URL` of the deploy context. Give deploy previews their own database, or remove the migration step from the build command and run migrations yourself. Serverless functions open a connection per instance: use your provider's pooled connection URL (for example Supabase's transaction pooler or Neon's pooled endpoint).
320
+
321
+ <!-- clubedge:end -->
322
+
323
+ `.github/workflows/deploy.yml` deploys on demand: in GitHub, open Actions, then Deploy, then Run workflow.
324
+
325
+ - It builds the production image and publishes it to this repository's GitHub Container Registry as `ghcr.io/<owner>/<repository>`, tagged with the commit and `latest`, using the workflow's own token. Run the migrator from that image before rolling it out. <!-- clubedge:only deploy=docker -->
326
+ - It needs the secret `FLY_API_TOKEN`, from `fly tokens create deploy`. <!-- clubedge:only deploy=fly -->
327
+ - It needs the secret `RAILWAY_TOKEN`, a project token from the project's settings, and the variable `RAILWAY_SERVICE`, the service's name. <!-- clubedge:only deploy=railway -->
328
+ - It needs the secret `RENDER_DEPLOY_HOOK_URL`, the deploy hook from the service's settings. <!-- clubedge:only deploy=render -->
329
+ - It needs the secrets `VERCEL_TOKEN`, from your account settings, and `VERCEL_ORG_ID` and `VERCEL_PROJECT_ID`, which `vercel link` writes to `.vercel/project.json`. <!-- clubedge:only deploy=vercel -->
330
+ - It needs the secrets `NETLIFY_AUTH_TOKEN`, a personal access token, and `NETLIFY_SITE_ID`, from the site's settings. <!-- clubedge:only deploy=netlify -->
331
+
332
+ `/api/health` answers as long as the app runs, so platforms use it to decide whether to restart the app. `/api/ready` also checks that the database answers, returning 200 or 503 without details: point load balancers and readiness probes at it, so traffic waits while the database is unreachable instead of the app restarting.
333
+
334
+ Production needs these variables:
335
+
336
+ - `DATABASE_URL`, pointing at a managed PostgreSQL database.
337
+ - `NEXT_PUBLIC_APP_URL`, the deployed origin. <!-- clubedge:only framework=next -->
338
+ - `APP_URL`, the deployed origin. <!-- clubedge:only framework=tanstack-start -->
339
+ - `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`, from the Supabase project. <!-- clubedge:only framework=next && auth=supabase -->
340
+ - `SUPABASE_URL` and `SUPABASE_PUBLISHABLE_KEY`, from the Supabase project. <!-- clubedge:only framework=tanstack-start && auth=supabase -->
341
+ - `BETTER_AUTH_SECRET`, at least 32 random characters, different from development. <!-- clubedge:only auth=better-auth -->
342
+ - `SMTP_URL` and `EMAIL_FROM`; without them, production refuses to send email. <!-- clubedge:only email=smtp -->
343
+ - `RESEND_API_KEY` and `EMAIL_FROM`; without them, production refuses to send email. <!-- clubedge:only email=resend -->
344
+ - `REDIS_URL`, when you run more than one instance. <!-- clubedge:only cache=redis -->
345
+ - The `STORAGE_*` variables for your S3-compatible bucket. <!-- clubedge:only storage=s3 -->
346
+ - `SUPABASE_STORAGE_BUCKET`. <!-- clubedge:only storage=supabase -->
347
+
265
348
  ## Checks
266
349
 
267
350
  Run the checks relevant to your change before opening a pull request:
@@ -22,3 +22,6 @@ supabase/.temp/
22
22
  supabase/.branches/
23
23
  # Local database and files for --infra local
24
24
  .data/
25
+ # Build output of the Vercel and Netlify presets
26
+ .vercel/
27
+ .netlify/
@@ -25,6 +25,10 @@ COPY --from=deps /app/ .
25
25
  COPY --from=pruner /app/out/full/ .
26
26
  COPY tsconfig.base.json ./
27
27
  RUN pnpm build
28
+ # The production migrator (node /app/migrate.mjs): one bundled file plus every package's
29
+ # migrations, so release commands can migrate before a new version starts.
30
+ COPY scripts/migrate.mjs scripts/build-migrator.mjs ./scripts/
31
+ RUN node scripts/build-migrator.mjs /app/.migrator
28
32
 
29
33
  # Nitro bundles the server and its dependencies into .output, so no node_modules are needed.
30
34
  FROM node:22-alpine AS runner
@@ -33,6 +37,7 @@ ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000
33
37
  WORKDIR /app
34
38
  RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 app
35
39
  COPY --from=builder --chown=app:nodejs /app/${APP_DIR}/.output ./.output
40
+ COPY --from=builder --chown=app:nodejs /app/.migrator ./
36
41
  USER app
37
42
  EXPOSE 3000
38
43
  CMD ["node", ".output/server/index.mjs"]
@@ -0,0 +1,6 @@
1
+ # Netlify (https://docs.netlify.com/configure-builds/file-based-configuration/). Set the site's
2
+ # base directory to apps/web; the build detects Netlify and writes its functions and assets.
3
+ [build]
4
+ # Migrations run during the build, against the DATABASE_URL set for the deploy context.
5
+ command = "pnpm --dir ../.. db:migrate && pnpm run build"
6
+ publish = "dist"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/start",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,23 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { isDatabaseReady } from "@clubedge/db";
3
+ import { getDb } from "@/server/db";
4
+
5
+ /**
6
+ * Readiness: 200 when the database answers, 503 otherwise, without details. /api/health stays a
7
+ * liveness check, so a brief database outage does not make the platform restart the app.
8
+ */
9
+ export const Route = createFileRoute("/api/ready")({
10
+ server: {
11
+ handlers: {
12
+ GET: async () => {
13
+ const ready = await Promise.resolve()
14
+ .then(() => isDatabaseReady(getDb()))
15
+ .catch(() => false);
16
+ return Response.json(
17
+ { status: ready ? "ok" : "unavailable" },
18
+ { status: ready ? 200 : 503, headers: { "Cache-Control": "no-store" } },
19
+ );
20
+ },
21
+ },
22
+ },
23
+ });
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "https://openapi.vercel.sh/vercel.json",
3
+ "framework": null,
4
+ "buildCommand": "pnpm --dir ../.. db:migrate && pnpm run build"
5
+ }
@@ -0,0 +1,6 @@
1
+ # Netlify (https://docs.netlify.com/configure-builds/file-based-configuration/). Set the site's
2
+ # base directory to apps/web; Netlify installs its Next.js runtime automatically.
3
+ [build]
4
+ # Migrations run during the build, against the DATABASE_URL set for the deploy context.
5
+ command = "pnpm --dir ../.. db:migrate && pnpm run build"
6
+ publish = ".next"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/web",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "scripts": {
@@ -0,0 +1,18 @@
1
+ import { isDatabaseReady } from "@clubedge/db";
2
+ import { getDb } from "@/server/db";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ /**
7
+ * Readiness: 200 when the database answers, 503 otherwise, without details. /api/health stays a
8
+ * liveness check, so a brief database outage does not make the platform restart the app.
9
+ */
10
+ export async function GET() {
11
+ const ready = await Promise.resolve()
12
+ .then(() => isDatabaseReady(getDb()))
13
+ .catch(() => false);
14
+ return Response.json(
15
+ { status: ready ? "ok" : "unavailable" },
16
+ { status: ready ? 200 : 503, headers: { "Cache-Control": "no-store" } },
17
+ );
18
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "https://openapi.vercel.sh/vercel.json",
3
+ "framework": "nextjs",
4
+ "buildCommand": "pnpm --dir ../.. db:migrate && pnpm run build"
5
+ }
@@ -249,11 +249,41 @@
249
249
  }
250
250
  }
251
251
  }
252
+ },
253
+ "deploy": {
254
+ "name": "Deployment",
255
+ "default": "docker",
256
+ "options": {
257
+ "docker": {
258
+ "name": "Docker image, on any container host"
259
+ },
260
+ "fly": {
261
+ "name": "Fly.io",
262
+ "files": ["fly.toml"]
263
+ },
264
+ "railway": {
265
+ "name": "Railway",
266
+ "files": ["railway.json"]
267
+ },
268
+ "render": {
269
+ "name": "Render",
270
+ "files": ["render.yaml"]
271
+ },
272
+ "vercel": {
273
+ "name": "Vercel",
274
+ "files": ["apps/start/vercel.json", "apps/web/vercel.json"]
275
+ },
276
+ "netlify": {
277
+ "name": "Netlify",
278
+ "files": ["apps/start/netlify.toml", "apps/web/netlify.toml"]
279
+ }
280
+ }
252
281
  }
253
282
  },
254
283
  "conditional": [
255
284
  ".env.example",
256
285
  ".github/workflows/ci.yml",
286
+ ".github/workflows/deploy.yml",
257
287
  "AGENTS.md",
258
288
  "README.md",
259
289
  "SETUP.md",
@@ -271,6 +301,7 @@
271
301
  "apps/web/src/server/auth-email.ts",
272
302
  "apps/web/src/server/storage.ts",
273
303
  "docker-compose.dev.yml",
304
+ "render.yaml",
274
305
  "supabase/config.toml"
275
306
  ],
276
307
  "exclude": [
@@ -0,0 +1,11 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ // /api/ready reports whether the database answers. Without one (the default browser run uses a
4
+ // placeholder URL) it is 503; with E2E_DATABASE=1 (the Starter's CI with a real database), 200.
5
+ test("reports readiness from the database", async ({ request }) => {
6
+ const ready = Boolean(process.env.E2E_DATABASE);
7
+ const response = await request.get("/api/ready");
8
+ expect(response.status()).toBe(ready ? 200 : 503);
9
+ expect(response.headers()["cache-control"]).toContain("no-store");
10
+ expect(await response.json()).toEqual({ status: ready ? "ok" : "unavailable" });
11
+ });
@@ -0,0 +1,25 @@
1
+ # Fly.io (https://fly.io/docs/reference/configuration/). `fly launch --no-deploy` creates the app
2
+ # and adds its name and region here; set secrets with `fly secrets set`, then run `fly deploy`.
3
+ [build]
4
+ dockerfile = "Dockerfile"
5
+
6
+ # Runs in a temporary machine with the new image before it replaces the running one.
7
+ [deploy]
8
+ release_command = "node /app/migrate.mjs"
9
+
10
+ [http_service]
11
+ internal_port = 3000
12
+ force_https = true
13
+ auto_stop_machines = "stop"
14
+ auto_start_machines = true
15
+ min_machines_running = 0
16
+
17
+ [[http_service.checks]]
18
+ grace_period = "10s"
19
+ interval = "30s"
20
+ method = "GET"
21
+ path = "/api/health"
22
+ timeout = "5s"
23
+
24
+ [[vm]]
25
+ memory = "512mb"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clubedge-starter-v1",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "packageManager": "pnpm@10.9.0",
@@ -38,6 +38,7 @@
38
38
  "devDependencies": {
39
39
  "@playwright/test": "^1.55.0",
40
40
  "@types/node": "^22.18.6",
41
+ "esbuild": "^0.25.0",
41
42
  "eslint": "^10.12.0",
42
43
  "prettier": "^3.6.2",
43
44
  "turbo": "^2.5.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth-better-auth",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Better Auth adapter for @clubedge/auth, storing users and sessions in your own PostgreSQL.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -27,5 +27,11 @@
27
27
  "drizzle-kit": "^0.31.4",
28
28
  "tsx": "^4.20.5",
29
29
  "typescript": "^5.9.2"
30
+ },
31
+ "clubedge": {
32
+ "migrations": {
33
+ "folder": "drizzle",
34
+ "table": "__drizzle_migrations_better_auth"
35
+ }
30
36
  }
31
37
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth-supabase",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Supabase Auth adapter for @clubedge/auth.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/cache",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/cache-redis",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Redis rate limiter and cache for @clubedge/cache.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/core",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/db",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -27,5 +27,11 @@
27
27
  "drizzle-kit": "^0.31.4",
28
28
  "tsx": "^4.20.5",
29
29
  "typescript": "^5.9.2"
30
+ },
31
+ "clubedge": {
32
+ "migrations": {
33
+ "folder": "drizzle",
34
+ "table": "__drizzle_migrations"
35
+ }
30
36
  }
31
37
  }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isDatabaseReady, type Database } from "./client";
3
+
4
+ const databaseThat = (execute: () => Promise<unknown>) => ({ execute }) as unknown as Database;
5
+
6
+ describe("isDatabaseReady", () => {
7
+ it("is ready when the database answers", async () => {
8
+ expect(await isDatabaseReady(databaseThat(async () => [{ "?column?": 1 }]))).toBe(true);
9
+ });
10
+
11
+ it("is not ready when the query fails", async () => {
12
+ const failing = databaseThat(async () => {
13
+ throw new Error("connection refused");
14
+ });
15
+ expect(await isDatabaseReady(failing)).toBe(false);
16
+ });
17
+
18
+ it("is not ready when the database does not answer in time", async () => {
19
+ const started = Date.now();
20
+ expect(
21
+ await isDatabaseReady(
22
+ databaseThat(() => new Promise(() => {})),
23
+ 50,
24
+ ),
25
+ ).toBe(false);
26
+ expect(Date.now() - started).toBeLessThan(1_000);
27
+ });
28
+ });
@@ -1,4 +1,5 @@
1
1
  import postgres from "postgres";
2
+ import { sql } from "drizzle-orm";
2
3
  import { drizzle } from "drizzle-orm/postgres-js";
3
4
  import * as schema from "./schema";
4
5
 
@@ -20,3 +21,21 @@ export function createDb({ url, maxConnections = 1 }: DatabaseConfig) {
20
21
  }
21
22
 
22
23
  export type Database = ReturnType<typeof createDb>;
24
+
25
+ /**
26
+ * Resolves true when the database answers a trivial query within `timeoutMs`, for readiness
27
+ * checks: a database that hangs counts as not ready rather than holding the request open.
28
+ */
29
+ export async function isDatabaseReady(db: Database, timeoutMs = 2_000): Promise<boolean> {
30
+ let timer: ReturnType<typeof setTimeout> | undefined;
31
+ const timeout = new Promise<boolean>((resolve) => {
32
+ timer = setTimeout(() => resolve(false), timeoutMs);
33
+ });
34
+ try {
35
+ return await Promise.race([db.execute(sql`select 1`).then(() => true), timeout]);
36
+ } catch {
37
+ return false;
38
+ } finally {
39
+ clearTimeout(timer);
40
+ }
41
+ }
@@ -1,4 +1,4 @@
1
- export { createDb, type Database, type DatabaseConfig } from "./client";
1
+ export { createDb, isDatabaseReady, type Database, type DatabaseConfig } from "./client";
2
2
  export * as schema from "./schema";
3
3
  export * from "./schema";
4
4
  export { upsertUserProfile } from "./users";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/email",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Email interface, console and in-memory providers, and message templates.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/email-resend",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Resend provider for @clubedge/email, through Resend's HTTP API.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/email-smtp",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "SMTP provider for @clubedge/email, for any SMTP server.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/local-db",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "PostgreSQL for development without Docker: PGlite served over the PostgreSQL protocol.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/storage",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/storage-s3",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "S3-compatible adapter for @clubedge/storage.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/storage-supabase",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Supabase Storage adapter for @clubedge/storage.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/ui",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -17,6 +17,9 @@ importers:
17
17
  '@types/node':
18
18
  specifier: ^22.18.6
19
19
  version: 22.20.5
20
+ esbuild:
21
+ specifier: ^0.25.0
22
+ version: 0.25.12
20
23
  eslint:
21
24
  specifier: ^10.12.0
22
25
  version: 10.12.0(jiti@2.7.0)
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://railway.com/railway.schema.json",
3
+ "build": {
4
+ "builder": "DOCKERFILE",
5
+ "dockerfilePath": "Dockerfile"
6
+ },
7
+ "deploy": {
8
+ "preDeployCommand": ["node /app/migrate.mjs"],
9
+ "healthcheckPath": "/api/health",
10
+ "healthcheckTimeout": 60,
11
+ "restartPolicyType": "ON_FAILURE"
12
+ }
13
+ }
@@ -0,0 +1,66 @@
1
+ # Render Blueprint (https://render.com/docs/blueprint-spec). Create it from the dashboard with
2
+ # New > Blueprint; Render asks for every variable marked sync: false.
3
+ services:
4
+ - type: web
5
+ name: app
6
+ runtime: docker
7
+ dockerfilePath: ./Dockerfile
8
+ healthCheckPath: /api/health
9
+ # Applies migrations before each deploy goes live. Pre-deploy commands need a paid instance
10
+ # type; on the free type, remove this line and migrate before deploying (see SETUP.md).
11
+ preDeployCommand: node /app/migrate.mjs
12
+ envVars:
13
+ - key: DATABASE_URL
14
+ sync: false
15
+ - key: NEXT_PUBLIC_APP_URL # clubedge:only framework=next
16
+ sync: false # clubedge:only framework=next
17
+ - key: APP_URL # clubedge:only framework=tanstack-start
18
+ sync: false # clubedge:only framework=tanstack-start
19
+ # clubedge:if auth=supabase && framework=next
20
+ - key: NEXT_PUBLIC_SUPABASE_URL
21
+ sync: false
22
+ - key: NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
23
+ sync: false
24
+ # clubedge:end
25
+ # clubedge:if auth=supabase && framework=tanstack-start
26
+ - key: SUPABASE_URL
27
+ sync: false
28
+ - key: SUPABASE_PUBLISHABLE_KEY
29
+ sync: false
30
+ # clubedge:end
31
+ # clubedge:if auth=better-auth
32
+ - key: BETTER_AUTH_SECRET
33
+ generateValue: true
34
+ # clubedge:end
35
+ # clubedge:if email=smtp
36
+ - key: SMTP_URL
37
+ sync: false
38
+ - key: EMAIL_FROM
39
+ sync: false
40
+ # clubedge:end
41
+ # clubedge:if email=resend
42
+ - key: RESEND_API_KEY
43
+ sync: false
44
+ - key: EMAIL_FROM
45
+ sync: false
46
+ # clubedge:end
47
+ # clubedge:if cache=redis
48
+ - key: REDIS_URL
49
+ sync: false
50
+ # clubedge:end
51
+ # clubedge:if storage=s3
52
+ - key: STORAGE_BUCKET
53
+ sync: false
54
+ - key: STORAGE_REGION
55
+ sync: false
56
+ - key: STORAGE_ENDPOINT
57
+ sync: false
58
+ - key: STORAGE_ACCESS_KEY_ID
59
+ sync: false
60
+ - key: STORAGE_SECRET_ACCESS_KEY
61
+ sync: false
62
+ # clubedge:end
63
+ # clubedge:if storage=supabase
64
+ - key: SUPABASE_STORAGE_BUCKET
65
+ sync: false
66
+ # clubedge:end
@@ -0,0 +1,51 @@
1
+ // Builds the production migrator: `node scripts/build-migrator.mjs <out-dir>` writes
2
+ // <out-dir>/migrate.mjs, a single file with its dependencies, and <out-dir>/migrations with the
3
+ // migrations of every workspace package that declares them in package.json:
4
+ // "clubedge": { "migrations": { "folder": "drizzle", "table": "__drizzle_migrations" } }
5
+ // Packages a project leaves out are not in the workspace, so their migrations are not either.
6
+ import {
7
+ cpSync,
8
+ existsSync,
9
+ mkdirSync,
10
+ readdirSync,
11
+ readFileSync,
12
+ rmSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { join, resolve } from "node:path";
16
+ import { build } from "esbuild";
17
+
18
+ const root = resolve(import.meta.dirname, "..");
19
+ const out = resolve(process.argv[2] ?? ".migrator");
20
+ rmSync(out, { recursive: true, force: true });
21
+ mkdirSync(join(out, "migrations"), { recursive: true });
22
+
23
+ const sets = [];
24
+ for (const name of readdirSync(join(root, "packages")).sort()) {
25
+ const manifestPath = join(root, "packages", name, "package.json");
26
+ if (!existsSync(manifestPath)) continue;
27
+ const migrations = JSON.parse(readFileSync(manifestPath, "utf8")).clubedge?.migrations;
28
+ if (!migrations) continue;
29
+ cpSync(join(root, "packages", name, migrations.folder), join(out, "migrations", name), {
30
+ recursive: true,
31
+ });
32
+ sets.push({ name, table: migrations.table });
33
+ }
34
+ // The app's own tables first: other packages' tables may reference them.
35
+ sets.sort((a, b) => (a.name === "db" ? -1 : b.name === "db" ? 1 : a.name.localeCompare(b.name)));
36
+ writeFileSync(join(out, "migrations", "manifest.json"), `${JSON.stringify(sets, null, 2)}\n`);
37
+
38
+ await build({
39
+ entryPoints: [join(root, "scripts", "migrate.mjs")],
40
+ outfile: join(out, "migrate.mjs"),
41
+ bundle: true,
42
+ platform: "node",
43
+ format: "esm",
44
+ target: "node22",
45
+ // Resolve postgres and drizzle-orm from the database package, which depends on them.
46
+ nodePaths: [join(root, "packages", "db", "node_modules")],
47
+ logLevel: "warning",
48
+ });
49
+ console.info(
50
+ `Migrator written to ${out} (${sets.map((set) => set.name).join(", ") || "no migrations"}).`,
51
+ );
@@ -0,0 +1,30 @@
1
+ // Applies every package's database migrations in production: `node /app/migrate.mjs` in the image.
2
+ // scripts/build-migrator.mjs bundles this file with its dependencies and copies each package's
3
+ // migrations next to it, so it runs without the workspace. Development uses `pnpm db:migrate`.
4
+ import { readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import postgres from "postgres";
7
+ import { drizzle } from "drizzle-orm/postgres-js";
8
+ import { migrate } from "drizzle-orm/postgres-js/migrator";
9
+
10
+ const url = process.env.DATABASE_URL;
11
+ if (!url) {
12
+ console.error("DATABASE_URL is required to run migrations.");
13
+ process.exit(1);
14
+ }
15
+
16
+ const directory = join(import.meta.dirname, "migrations");
17
+ /** @type {Array<{ name: string; table: string }>} */
18
+ const sets = JSON.parse(readFileSync(join(directory, "manifest.json"), "utf8"));
19
+
20
+ // Prepared statements off, as packages/db configures postgres.js, for transaction poolers.
21
+ const client = postgres(url, { max: 1, prepare: false });
22
+ try {
23
+ const db = drizzle({ client });
24
+ for (const { name, table } of sets) {
25
+ await migrate(db, { migrationsFolder: join(directory, name), migrationsTable: table });
26
+ console.info(`Applied ${name} migrations.`);
27
+ }
28
+ } finally {
29
+ await client.end();
30
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "repository": "Clubedge/clubedge-starter",
3
- "ref": "v0.9.0",
4
- "commit": "e858d0b5b60bbb05f1b6aed3f4d9e841ea2df2a5",
3
+ "ref": "v0.10.0",
4
+ "commit": "9d8fec71b3851c2d615735fb37edb6bb0e568761",
5
5
  "files": [
6
6
  ".dockerignore",
7
7
  ".env.example",
@@ -11,6 +11,7 @@
11
11
  ".github/ISSUE_TEMPLATE/feature_request.yml",
12
12
  ".github/pull_request_template.md",
13
13
  ".github/workflows/ci.yml",
14
+ ".github/workflows/deploy.yml",
14
15
  "_gitignore",
15
16
  ".prettierignore",
16
17
  ".prettierrc.json",
@@ -29,6 +30,7 @@
29
30
  "apps/start/AGENTS.md",
30
31
  "apps/start/Dockerfile",
31
32
  "apps/start/components.json",
33
+ "apps/start/netlify.toml",
32
34
  "apps/start/package.json",
33
35
  "apps/start/public/.well-known/logo.svg",
34
36
  "apps/start/public/favicon.ico",
@@ -54,6 +56,7 @@
54
56
  "apps/start/src/router.tsx",
55
57
  "apps/start/src/routes/__root.tsx",
56
58
  "apps/start/src/routes/api/health.ts",
59
+ "apps/start/src/routes/api/ready.ts",
57
60
  "apps/start/src/routes/auth/callback.ts",
58
61
  "apps/start/src/routes/auth/forgot-password.ts",
59
62
  "apps/start/src/routes/auth/reset-password.ts",
@@ -88,9 +91,11 @@
88
91
  "apps/start/src/styles.css",
89
92
  "apps/start/tsconfig.json",
90
93
  "apps/start/tsr.config.json",
94
+ "apps/start/vercel.json",
91
95
  "apps/start/vite.config.ts",
92
96
  "apps/web/AGENTS.md",
93
97
  "apps/web/components.json",
98
+ "apps/web/netlify.toml",
94
99
  "apps/web/next-env.d.ts",
95
100
  "apps/web/next.config.ts",
96
101
  "apps/web/package.json",
@@ -101,6 +106,7 @@
101
106
  "apps/web/scripts/start-standalone.mjs",
102
107
  "apps/web/src/app/actions/auth.ts",
103
108
  "apps/web/src/app/api/health/route.ts",
109
+ "apps/web/src/app/api/ready/route.ts",
104
110
  "apps/web/src/app/auth/callback/route.ts",
105
111
  "apps/web/src/app/dashboard/_components/account-button.tsx",
106
112
  "apps/web/src/app/dashboard/_components/app-sidebar.tsx",
@@ -144,6 +150,7 @@
144
150
  "apps/web/src/server/variants/storage.supabase.ts",
145
151
  "apps/web/src/variants/proxy.better-auth.ts",
146
152
  "apps/web/tsconfig.json",
153
+ "apps/web/vercel.json",
147
154
  "clubedge.template.json",
148
155
  "docker-compose.dev.yml",
149
156
  "docker-compose.yml",
@@ -154,7 +161,9 @@
154
161
  "e2e/home.spec.ts",
155
162
  "e2e/inbox.ts",
156
163
  "e2e/password-reset.spec.ts",
164
+ "e2e/ready.spec.ts",
157
165
  "eslint.config.mjs",
166
+ "fly.toml",
158
167
  "package.json",
159
168
  "packages/auth-better-auth/drizzle.config.ts",
160
169
  "packages/auth-better-auth/drizzle/0000_initial.sql",
@@ -203,6 +212,7 @@
203
212
  "packages/db/scripts/load-env.ts",
204
213
  "packages/db/scripts/migrate.ts",
205
214
  "packages/db/scripts/seed.ts",
215
+ "packages/db/src/client.test.ts",
206
216
  "packages/db/src/client.ts",
207
217
  "packages/db/src/index.ts",
208
218
  "packages/db/src/schema/index.ts",
@@ -261,6 +271,10 @@
261
271
  "playwright.config.ts",
262
272
  "pnpm-lock.yaml",
263
273
  "pnpm-workspace.yaml",
274
+ "railway.json",
275
+ "render.yaml",
276
+ "scripts/build-migrator.mjs",
277
+ "scripts/migrate.mjs",
264
278
  "supabase/config.toml",
265
279
  "tsconfig.base.json",
266
280
  "turbo.json",