@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.
- package/CHANGELOG.md +5 -0
- package/README.md +12 -11
- package/package.json +3 -3
- package/template/.github/workflows/ci.yml +18 -0
- package/template/.github/workflows/deploy.yml +109 -0
- package/template/Dockerfile +5 -0
- package/template/README.md +7 -0
- package/template/SETUP.md +84 -1
- package/template/_gitignore +3 -0
- package/template/apps/start/Dockerfile +5 -0
- package/template/apps/start/netlify.toml +6 -0
- package/template/apps/start/package.json +1 -1
- package/template/apps/start/src/routes/api/ready.ts +23 -0
- package/template/apps/start/vercel.json +5 -0
- package/template/apps/web/netlify.toml +6 -0
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/src/app/api/ready/route.ts +18 -0
- package/template/apps/web/vercel.json +5 -0
- package/template/clubedge.template.json +31 -0
- package/template/e2e/ready.spec.ts +11 -0
- package/template/fly.toml +25 -0
- package/template/package.json +2 -1
- package/template/packages/auth/package.json +1 -1
- package/template/packages/auth-better-auth/package.json +7 -1
- package/template/packages/auth-supabase/package.json +1 -1
- package/template/packages/cache/package.json +1 -1
- package/template/packages/cache-redis/package.json +1 -1
- package/template/packages/core/package.json +1 -1
- package/template/packages/db/package.json +7 -1
- package/template/packages/db/src/client.test.ts +28 -0
- package/template/packages/db/src/client.ts +19 -0
- package/template/packages/db/src/index.ts +1 -1
- package/template/packages/email/package.json +1 -1
- package/template/packages/email-resend/package.json +1 -1
- package/template/packages/email-smtp/package.json +1 -1
- package/template/packages/local-db/package.json +1 -1
- package/template/packages/storage/package.json +1 -1
- package/template/packages/storage-s3/package.json +1 -1
- package/template/packages/storage-supabase/package.json +1 -1
- package/template/packages/ui/package.json +1 -1
- package/template/pnpm-lock.yaml +3 -0
- package/template/railway.json +13 -0
- package/template/render.yaml +66 -0
- package/template/scripts/build-migrator.mjs +51 -0
- package/template/scripts/migrate.mjs +30 -0
- 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.
|
|
15
|
+
create-clubedge-app v0.11.0
|
|
16
16
|
|
|
|
17
17
|
v
|
|
18
|
-
Clubedge Starter v0.
|
|
19
|
-
commit
|
|
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.
|
|
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.
|
|
31
|
-
`
|
|
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.
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
26
|
-
"starterCommit": "
|
|
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
|
package/template/Dockerfile
CHANGED
|
@@ -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
|
package/template/README.md
CHANGED
|
@@ -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
|
|
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:
|
package/template/_gitignore
CHANGED
|
@@ -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"
|
|
@@ -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,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"
|
|
@@ -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
|
+
}
|
|
@@ -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"
|
package/template/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clubedge-starter-v1",
|
|
3
|
-
"version": "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-better-auth",
|
|
3
|
-
"version": "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/db",
|
|
3
|
-
"version": "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";
|
package/template/pnpm-lock.yaml
CHANGED
|
@@ -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
|
+
}
|
package/template.lock.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"repository": "Clubedge/clubedge-starter",
|
|
3
|
-
"ref": "v0.
|
|
4
|
-
"commit": "
|
|
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",
|