deploy: prod-only pipeline with Drizzle migrations; dev refresh + preview
- push to main builds, backs up prod (pg_dump 17), runs db-migrate + seeds, preflights and deploys; rollback = workflow_dispatch with rollback_tag - staging workflow, promote step and migrate-*.ts / old SQL removed - DB_URL = app role, DB_MIGRATE_URL = owner role - scripts/db-refresh-dev.sh (prod dump -> local dev DB), scripts/preview.sh <branch>
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
# PostgreSQL connection (server only)
|
# PostgreSQL connections (server only). Never commit real passwords.
|
||||||
DB_URL=postgresql://postgres:postgres@localhost:5432/personal_dev
|
# DB_URL: the app role (personal_app: DML only, no DDL).
|
||||||
|
DB_URL=postgresql://personal_app:CHANGE_ME@localhost:5432/personal_dev
|
||||||
|
# DB_MIGRATE_URL: the schema owner (personal), used only by drizzle-kit,
|
||||||
|
# scripts/db-migrate.ts, the seed scripts and deploy backups.
|
||||||
|
DB_MIGRATE_URL=postgresql://personal:CHANGE_ME@localhost:5432/personal_dev
|
||||||
|
|
||||||
# Use the exact public origin, including scheme and port. Set the same value for both.
|
# Use the exact public origin, including scheme and port. Set the same value for both.
|
||||||
ORIGIN=http://localhost:5173
|
ORIGIN=http://localhost:5173
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
name: Test and deploy staging
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [staging]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
staging:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# Build once. The same SHA-tagged image stays on the deployment host for
|
|
||||||
# one-click promotion to production; the production workflow never rebuilds it.
|
|
||||||
- uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: false
|
|
||||||
load: true
|
|
||||||
tags: git.stanrunge.dev/stan/personal-website:${{ github.sha }}
|
|
||||||
build-args: |
|
|
||||||
GIT_SHA=${{ github.sha }}
|
|
||||||
|
|
||||||
- name: Configure deployment SSH key
|
|
||||||
env:
|
|
||||||
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
install -d -m 700 ~/.ssh
|
|
||||||
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
ssh-keyscan -H 172.17.0.1 >> ~/.ssh/known_hosts
|
|
||||||
|
|
||||||
- name: Transfer tested image
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
image="git.stanrunge.dev/stan/personal-website:${{ github.sha }}"
|
|
||||||
docker save "$image" | ssh -o BatchMode=yes deploy@172.17.0.1 'sudo docker load'
|
|
||||||
|
|
||||||
- name: Deploy staging
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
ssh -o BatchMode=yes deploy@172.17.0.1 \
|
|
||||||
"bash -s -- '${{ github.sha }}' '/opt/personal-website-staging' 'https://staging.stanrunge.dev'" \
|
|
||||||
< scripts/deploy-remote.sh
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
name: Deploy production
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
promote:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
# Always test the staging branch, regardless of which branch the button
|
|
||||||
# appears on in Gitea's Actions UI.
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: staging
|
|
||||||
|
|
||||||
- name: Configure deployment SSH key
|
|
||||||
env:
|
|
||||||
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
install -d -m 700 ~/.ssh
|
|
||||||
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
ssh-keyscan -H 172.17.0.1 >> ~/.ssh/known_hosts
|
|
||||||
|
|
||||||
- name: Promote the exact staging image
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
commit=$(git rev-parse HEAD)
|
|
||||||
ssh -o BatchMode=yes deploy@172.17.0.1 "bash -s -- '$commit'" \
|
|
||||||
< scripts/verify-staging-image.sh
|
|
||||||
ssh -o BatchMode=yes deploy@172.17.0.1 \
|
|
||||||
"bash -s -- '$commit' '/opt/personal-website' 'https://stanrunge.dev'" \
|
|
||||||
< scripts/deploy-remote.sh
|
|
||||||
70
.gitea/workflows/deploy.yml
Normal file
70
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
name: Deploy production
|
||||||
|
# Two environments only: local dev (see README "Development") and prod.
|
||||||
|
# A push/merge to main builds and tests the image, backs up prod (pg_dump via
|
||||||
|
# postgres:17-alpine), runs Drizzle migrations, then switches the container.
|
||||||
|
# Pushes to any other branch (e.g. db-schemas) do not match and run nothing.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
rollback_tag:
|
||||||
|
description: 'Roll back: redeploy this earlier image SHA (skips migrations/seeds)'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-production
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
# Belt and braces: a manual dispatch from another branch does nothing either.
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Configure deployment SSH key
|
||||||
|
env:
|
||||||
|
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
install -d -m 700 ~/.ssh
|
||||||
|
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -H 172.17.0.1 >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
# The Dockerfile runs bun install --frozen-lockfile, unit tests and the build,
|
||||||
|
# so a failing test never produces a deployable image.
|
||||||
|
- if: github.event_name == 'push'
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: git.stanrunge.dev/stan/personal-website:${{ github.sha }}
|
||||||
|
build-args: |
|
||||||
|
GIT_SHA=${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Transfer image
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
image="git.stanrunge.dev/stan/personal-website:${{ github.sha }}"
|
||||||
|
docker save "$image" | ssh -o BatchMode=yes deploy@172.17.0.1 'sudo docker load'
|
||||||
|
|
||||||
|
- name: Back up, migrate and deploy
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ssh -o BatchMode=yes deploy@172.17.0.1 \
|
||||||
|
"bash -s -- '${{ github.sha }}' deploy" < scripts/deploy-remote.sh
|
||||||
|
|
||||||
|
- name: Roll back to an earlier image
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
|
env:
|
||||||
|
TAG: ${{ inputs.rollback_tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
case "$TAG" in *[!0-9a-f]*|'') echo 'Invalid image tag' >&2; exit 2 ;; esac
|
||||||
|
ssh -o BatchMode=yes deploy@172.17.0.1 "bash -s -- '$TAG' rollback" < scripts/deploy-remote.sh
|
||||||
@@ -13,6 +13,7 @@ ENV APP_VERSION=$GIT_SHA
|
|||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
COPY --from=builder /app/build ./build
|
COPY --from=builder /app/build ./build
|
||||||
COPY --from=builder /app/scripts ./scripts
|
COPY --from=builder /app/scripts ./scripts
|
||||||
|
COPY --from=builder /app/drizzle ./drizzle
|
||||||
COPY --from=builder /app/data ./data
|
COPY --from=builder /app/data ./data
|
||||||
COPY --from=builder /app/src/lib/server/db/schema ./src/lib/server/db/schema
|
COPY --from=builder /app/src/lib/server/db/schema ./src/lib/server/db/schema
|
||||||
COPY --from=builder /app/src/lib/cs-prices ./src/lib/cs-prices
|
COPY --from=builder /app/src/lib/cs-prices ./src/lib/cs-prices
|
||||||
|
|||||||
183
README.md
183
README.md
@@ -227,25 +227,14 @@ progress and warns about them.
|
|||||||
|
|
||||||
### Database
|
### Database
|
||||||
|
|
||||||
For an existing database using the current domain schemas:
|
The schema is managed by Drizzle (`src/lib/server/db/schema/`, migrations in
|
||||||
|
`drizzle/`). Postgres schemas: `auth`, `money`, `house`, `ref`, `games`, `cs`,
|
||||||
```sh
|
`animaldex`, `site`, `meta` (Drizzle-managed) and `lists` (one real table per list,
|
||||||
npm run db:migrate:auth
|
created at runtime through the `meta.create_list` family of SQL functions, so it is
|
||||||
npm run db:migrate:foundation
|
excluded from Drizzle via `schemaFilter`). See [Development](#development) for the
|
||||||
npm run db:migrate:money-tracker
|
local database and migrations, and `db/reorg/README.md` for the one-time move of the
|
||||||
npm run db:migrate:game-tracker
|
old flat schema to this layout. Catalog seeds (`npm run db:seed`, `db:seed:cod`, …)
|
||||||
npm run db:seed
|
upsert reference data and run on every production deploy.
|
||||||
npm run db:seed:cod
|
|
||||||
```
|
|
||||||
|
|
||||||
For a fresh database, run `npm run db:push` followed by both seeds.
|
|
||||||
The migrations are additive and repeatable; they do not reset tables. The
|
|
||||||
game-tracker migration adds the `evolve`/`breed` availability values, the entry
|
|
||||||
`source` column (marking pre-existing hand-edited notes as `manual`), and the
|
|
||||||
location, encounter, evolution and `tracker_*` tables. Unknown legacy OAuth
|
|
||||||
providers cause the auth migration to fail for explicit review instead of
|
|
||||||
guessing an issuer. Back up a production database before any schema migration.
|
|
||||||
The scripts use `DB_URL` from `.env` via Bun.
|
|
||||||
|
|
||||||
## Money tracker
|
## Money tracker
|
||||||
|
|
||||||
@@ -317,112 +306,87 @@ registered callback URL. The integration follows the installed Better Auth APIs;
|
|||||||
see [SvelteKit integration](https://better-auth.com/docs/integrations/svelte-kit)
|
see [SvelteKit integration](https://better-auth.com/docs/integrations/svelte-kit)
|
||||||
and [Generic OAuth](https://better-auth.com/docs/plugins/generic-oauth).
|
and [Generic OAuth](https://better-auth.com/docs/plugins/generic-oauth).
|
||||||
|
|
||||||
## Staging and production rollout
|
## Development
|
||||||
|
|
||||||
Use `staging` as the working branch and set it as the repository's default branch
|
There are two environments: local dev and production. Prod is the source of truth
|
||||||
in Gitea after the first push. A direct push to `staging` runs unit tests, builds
|
for data; the dev database is a disposable copy of it.
|
||||||
the site and a SHA-tagged Docker image, and deploys that image to
|
|
||||||
`https://staging.stanrunge.dev`. After checking staging, use Gitea Actions →
|
|
||||||
**Deploy production** → **Run workflow**. That action retests the current staging
|
|
||||||
commit, verifies that staging runs its exact image, and starts the **same image**
|
|
||||||
at `https://stanrunge.dev`. It does not rebuild the image or need a PR. Pushes to
|
|
||||||
`main` no longer deploy production; once `staging` is the default branch, its
|
|
||||||
current manual workflow is also the one shown in Gitea's Actions UI.
|
|
||||||
|
|
||||||
The staging Docker build runs `bun install --frozen-lockfile`, unit tests, and
|
```sh
|
||||||
the production build, so a failed test never produces a deployable image. The
|
cp .env.example .env # DB_URL (app role) + DB_MIGRATE_URL (owner role) -> local personal_dev
|
||||||
production button verifies the exact tested staging image before switching
|
scripts/db-refresh-dev.sh # fresh prod dump -> personal_dev (sessions cleared)
|
||||||
production. Browser/database tests need a
|
bun run db:migrate # apply this branch's pending Drizzle migrations
|
||||||
dedicated test database and are not part of this gate yet. `npm run check` and
|
bun run dev
|
||||||
full lint are also not gates while the repository has existing unrelated
|
```
|
||||||
diagnostics; add them after those are fixed. A failed test, build, or preflight
|
|
||||||
prevents the switch; a failed post-deploy health check attempts to restore the
|
|
||||||
previous image tag. Deployment never migrates or seeds either database. The
|
|
||||||
Gitea runner needs Docker CLI/socket access and the existing `DEPLOY_KEY` secret.
|
|
||||||
|
|
||||||
Before either push, configure the existing private environment file on that
|
`db-refresh-dev.sh` reads `PROD_DB_URL` (a role that can read everything, e.g.
|
||||||
host. Staging uses `/etc/personal-website-staging/staging.env`; production uses
|
`personal`) and `DEV_ADMIN_URL` (superuser on the dev cluster) from the environment
|
||||||
`/etc/personal-website/prod.env`. Each file needs `DB_URL`, a unique random
|
or `.env.dev-refresh` (git-ignored, never commit it). It refuses to overwrite the
|
||||||
`BETTER_AUTH_SECRET` of at least 32 characters, and both `ORIGIN` and
|
source database or any database named `personal`. `PG_DOCKER=1` uses the
|
||||||
`BETTER_AUTH_URL` set exactly to its public HTTPS origin. Add the Google and
|
`postgres:17-alpine` image when local `pg_dump` 17 isn't installed.
|
||||||
osu! client ID/secret pairs for whichever sign-in buttons should be enabled.
|
|
||||||
List and Animaldex picture uploads reuse the existing R2 bucket; set the five
|
Schema changes: edit `src/lib/server/db/schema/`, run `bun run db:generate --name
|
||||||
`R2_*` values from `.env.example`. The app server uploads images to R2, so the
|
<change>`, review the SQL in `drizzle/`, then `bun run db:migrate`. Never edit an
|
||||||
bucket does not need browser CORS rules for these forms. Each image is limited
|
applied migration; never use `db:push` against prod.
|
||||||
to 5 MB; the container sets `BODY_SIZE_LIMIT=6M` for multipart requests.
|
|
||||||
Their provider console callback URLs must use that environment's origin:
|
`scripts/preview.sh <branch> [port]` runs a branch against a throwaway copy of the
|
||||||
|
dev database (migrated with that branch's migrations) and cleans everything up on
|
||||||
|
Ctrl-C.
|
||||||
|
|
||||||
|
### Database roles
|
||||||
|
|
||||||
|
| env var | role | used for |
|
||||||
|
| ---------------- | -------------- | --------------------------------------------------------------- |
|
||||||
|
| `DB_URL` | `personal_app` | the app at runtime: DML only, calls the `meta.*` list functions |
|
||||||
|
| `DB_MIGRATE_URL` | `personal` | Drizzle migrations, seeds, backups (`pg_dump`) |
|
||||||
|
|
||||||
|
`personal_ddl` owns the `lists` schema (only reachable through the SECURITY DEFINER
|
||||||
|
`meta.*` functions); `personal_ro` is read-only. The deploy preflight fails if
|
||||||
|
`DB_URL` can create objects, i.e. if it isn't the app role.
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
Push or merge to `main` → Gitea Actions (`.gitea/workflows/deploy.yml`) builds a
|
||||||
|
SHA-tagged image (unit tests + production build run inside the Docker build),
|
||||||
|
transfers it, and runs `scripts/deploy-remote.sh <sha> deploy` on the host:
|
||||||
|
|
||||||
|
1. checks the image and that `DB_MIGRATE_URL` is set in `/etc/personal-website/prod.env`;
|
||||||
|
2. `pg_dump` of prod (postgres:17-alpine) into the backup directory;
|
||||||
|
3. `bun scripts/db-migrate.ts` (Drizzle migrations) and the catalog seeds;
|
||||||
|
4. preflight (tables, column types, app-role privileges), switch the container, health checks.
|
||||||
|
|
||||||
|
A failed health check restores the previous image tag automatically. Manual
|
||||||
|
rollback: Gitea Actions → **Deploy production** → **Run workflow** with
|
||||||
|
`rollback_tag=<previous sha>`; this redeploys that image without migrating or
|
||||||
|
seeding. Database changes are not rolled back with the image; restore the
|
||||||
|
pre-deploy dump if a migration has to be undone. Only `main` deploys; other
|
||||||
|
branches never trigger the workflow. The runner needs Docker CLI/socket access
|
||||||
|
and the `DEPLOY_KEY` secret.
|
||||||
|
|
||||||
|
`/etc/personal-website/prod.env` needs `DB_URL`, `DB_MIGRATE_URL`, a unique random
|
||||||
|
`BETTER_AUTH_SECRET` (32+ characters), and `ORIGIN`/`BETTER_AUTH_URL` set to
|
||||||
|
`https://stanrunge.dev`, plus the Google and osu! client ID/secret pairs and the five
|
||||||
|
`R2_*` values from `.env.example`. Picture uploads (lists, Animaldex) go through the
|
||||||
|
app server to R2 (5 MB per image; the container sets `BODY_SIZE_LIMIT=6M`). OAuth
|
||||||
|
callback URLs:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://staging.stanrunge.dev/api/auth/callback/google
|
|
||||||
https://staging.stanrunge.dev/api/auth/callback/osu
|
|
||||||
https://stanrunge.dev/api/auth/callback/google
|
https://stanrunge.dev/api/auth/callback/google
|
||||||
https://stanrunge.dev/api/auth/callback/osu
|
https://stanrunge.dev/api/auth/callback/osu
|
||||||
```
|
```
|
||||||
|
|
||||||
For this first rollout, review the SQL and back up each database. The first push
|
After registering, promote your account (ID shown on `/account`):
|
||||||
to `staging` may pass **Transfer tested image** but fail **Deploy staging** at
|
|
||||||
the database preflight. That is expected if the database has not been migrated:
|
|
||||||
the image is then available on the deployment host for you to run the migrations
|
|
||||||
yourself. If `animals`, `sightings`, and `photos` still have UUID IDs, first apply
|
|
||||||
`scripts/animaldex-uuid-to-serial.sql` with `psql` once. If the database has the
|
|
||||||
legacy singular tables `game`, `pokemon_game_entry`, `user_entry`, and `user_game`,
|
|
||||||
then apply `scripts/legacy-schema-migration.sql` with `psql` once. That migration
|
|
||||||
renames those tables without discarding their rows and creates the empty base
|
|
||||||
`lists` and `list_items` tables. Run both reviewed SQL files before the container
|
|
||||||
commands below. On the deployment host, use the SHA from the staging Actions run:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
release_image=git.stanrunge.dev/stan/personal-website:<staging-sha>
|
sudo docker run --rm --network backend --env-file /etc/personal-website/prod.env \
|
||||||
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/migrate-auth-catalog.ts
|
git.stanrunge.dev/stan/personal-website:<deployed-sha> bun scripts/set-admin.ts <account-id> admin
|
||||||
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/migrate-foundation.ts
|
|
||||||
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/migrate-money-tracker.ts
|
|
||||||
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/migrate-game-tracker.ts
|
|
||||||
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/migrate-tracker-journal.ts
|
|
||||||
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/seed-catalog.ts
|
|
||||||
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/seed-cod-catalog.ts
|
|
||||||
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/seed-pokemon-research.ts
|
|
||||||
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/seed-cod-challenges.ts
|
|
||||||
sudo docker run --rm --network backend --env-file /etc/personal-website-staging/staging.env "$release_image" bun scripts/seed-cod-art.ts
|
|
||||||
```
|
```
|
||||||
|
|
||||||
These catalog seeds use the reviewed files in `data/` and upsert catalog rows;
|
No public signup grants admin privileges.
|
||||||
they preserve user accounts, catches, ranks, and saved challenge progress. Do not
|
|
||||||
copy or restore the entire development database over staging or production.
|
|
||||||
Repeat the same migration and seed commands for staging first, replacing the
|
|
||||||
environment file with `/etc/personal-website/prod.env` only after staging has
|
|
||||||
passed its preflight and been reviewed. Back up production before applying its
|
|
||||||
migrations. Rerun the same staging Actions run after the staging migration and
|
|
||||||
seed complete. Click the production button only after production migration and
|
|
||||||
seed complete. Future releases without database changes need only a staging push
|
|
||||||
and the production button.
|
|
||||||
|
|
||||||
The foundation migration gives lists slugs, progress, pictures, and blog tags;
|
|
||||||
gives site-owned join/settings rows numeric IDs; and adds `created_at`/
|
|
||||||
`updated_at` with a database update trigger. Blog posts already had numeric IDs
|
|
||||||
and timestamps. Better Auth's account/session IDs stay as text because they are
|
|
||||||
provider-managed identities. The deploy script verifies the migrations and
|
|
||||||
catalog before switching the image, then checks `/login` and Catchemall. A
|
|
||||||
failed app health check attempts to restore the previous image tag. Database
|
|
||||||
changes are not rolled back with the app image.
|
|
||||||
|
|
||||||
The release image contains `scripts/`, `data/`, and the schema files needed by
|
|
||||||
the migration tools. After registration in each environment, promote your own
|
|
||||||
account using the host's matching environment file, for example:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo docker run --rm --network backend \
|
|
||||||
--env-file /etc/personal-website-staging/staging.env \
|
|
||||||
git.stanrunge.dev/stan/personal-website:<deployed-sha> \
|
|
||||||
bun scripts/set-admin.ts <account-id> admin
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `/etc/personal-website/prod.env` and its deployed image SHA for production.
|
|
||||||
The account ID is shown on `/account`. No public signup grants admin privileges.
|
|
||||||
|
|
||||||
# CS prices
|
# CS prices
|
||||||
|
|
||||||
`/projects/cs-prices` reads only from Postgres. Each Steam market hash name is an
|
`/projects/cs-prices` reads only from Postgres. Each Steam market hash name is an
|
||||||
individual listing. The separate `cs_item_dimensions` table makes it possible to
|
individual listing. The separate `cs.item_dimensions` table makes it possible to
|
||||||
compare or group by weapon type, weapon name, weapon plus skin, skin, wear, StatTrak, Souvenir, and
|
compare or group by weapon type, weapon name, weapon plus skin, skin, wear, StatTrak, Souvenir, and
|
||||||
additional imported dimensions such as collection or rarity. Group chart points
|
additional imported dimensions such as collection or rarity. Group chart points
|
||||||
average the available per-listing prices equally within each time bucket. Raw
|
average the available per-listing prices equally within each time bucket. Raw
|
||||||
@@ -431,10 +395,9 @@ volume. Chart responses use up to roughly 240 time buckets per series and allow
|
|||||||
up to 1,000 selected series; actual response time still depends on database size
|
up to 1,000 selected series; actual response time still depends on database size
|
||||||
and hardware.
|
and hardware.
|
||||||
|
|
||||||
With `DB_URL` pointed at a running Postgres database:
|
With `DB_URL` pointed at a migrated database:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run db:migrate:cs-prices
|
|
||||||
npm run cs-prices:ingest -- catalog
|
npm run cs-prices:ingest -- catalog
|
||||||
STEAM_SESSIONID=... STEAM_LOGIN_SECURE=... npm run cs-prices:ingest -- history
|
STEAM_SESSIONID=... STEAM_LOGIN_SECURE=... npm run cs-prices:ingest -- history
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ HOUSE_NTFY_TOKEN= # only for a protected ntfy topic
|
|||||||
PUBLIC_MAPTILER_API_KEY= # already set for Animaldex
|
PUBLIC_MAPTILER_API_KEY= # already set for Animaldex
|
||||||
```
|
```
|
||||||
|
|
||||||
`scripts/migrate-house-tracker.ts` runs on every deploy (idempotent). Use
|
The `house` schema is part of the Drizzle migrations that run on every deploy. Use
|
||||||
"send test alert" on the page to check the alert channel.
|
"send test alert" on the page to check the alert channel.
|
||||||
|
|
||||||
If Cloudflare Bot Fight Mode / WAF challenges are on for stanrunge.dev, add a
|
If Cloudflare Bot Fight Mode / WAF challenges are on for stanrunge.dev, add a
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -15,23 +15,18 @@
|
|||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "bun scripts/db-migrate.ts",
|
||||||
|
"db:refresh-dev": "bash scripts/db-refresh-dev.sh",
|
||||||
|
"preview:branch": "bash scripts/preview.sh",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:seed": "bun scripts/seed-catalog.ts",
|
"db:seed": "bun scripts/seed-catalog.ts",
|
||||||
"db:seed:cod": "bun scripts/seed-cod-catalog.ts",
|
"db:seed:cod": "bun scripts/seed-cod-catalog.ts",
|
||||||
"db:migrate:auth": "bun scripts/migrate-auth-catalog.ts",
|
|
||||||
"db:migrate:foundation": "bun scripts/migrate-foundation.ts",
|
|
||||||
"db:migrate:money-tracker": "bun scripts/migrate-money-tracker.ts",
|
|
||||||
"db:migrate:game-tracker": "bun scripts/migrate-game-tracker.ts",
|
|
||||||
"db:migrate:cs-prices": "bun scripts/migrate-cs-prices.ts",
|
|
||||||
"cs-prices:ingest": "bun scripts/ingest-cs-prices.ts",
|
"cs-prices:ingest": "bun scripts/ingest-cs-prices.ts",
|
||||||
"db:migrate:house-tracker": "bun scripts/migrate-house-tracker.ts",
|
|
||||||
"house:worker": "bun scripts/house-worker.ts",
|
"house:worker": "bun scripts/house-worker.ts",
|
||||||
"auth:role": "bun scripts/set-admin.ts",
|
"auth:role": "bun scripts/set-admin.ts",
|
||||||
"catalog:build": "python3 scripts/build-pokemon-catalog.py",
|
"catalog:build": "python3 scripts/build-pokemon-catalog.py",
|
||||||
"catalog:cod": "python3 scripts/build-cod-catalog.py && prettier --write data/call-of-duty.json",
|
"catalog:cod": "python3 scripts/build-cod-catalog.py && prettier --write data/call-of-duty.json",
|
||||||
"test:auth": "node --env-file=.env node_modules/@playwright/test/cli.js test --config playwright.auth.config.ts",
|
"test:auth": "node --env-file=.env node_modules/@playwright/test/cli.js test --config playwright.auth.config.ts",
|
||||||
"db:migrate:tracker-journal": "bun scripts/migrate-tracker-journal.ts",
|
|
||||||
"catalog:research": "python3 scripts/build-pokemon-research.py",
|
"catalog:research": "python3 scripts/build-pokemon-research.py",
|
||||||
"db:seed:research": "bun scripts/seed-pokemon-research.ts",
|
"db:seed:research": "bun scripts/seed-pokemon-research.ts",
|
||||||
"db:seed:challenges": "bun scripts/seed-cod-challenges.ts",
|
"db:seed:challenges": "bun scripts/seed-cod-challenges.ts",
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
-- One-off: convert animaldex ids from uuid to serial, keeping existing rows.
|
|
||||||
-- Postgres can't cast uuid -> integer, so db:push can't do this itself.
|
|
||||||
--
|
|
||||||
-- Run BEFORE re-running db:push, once per database:
|
|
||||||
-- psql "$DB_URL" -v ON_ERROR_STOP=1 -f scripts/animaldex-uuid-to-serial.sql
|
|
||||||
--
|
|
||||||
-- Safe to run on an empty database too. Everything is in one transaction, so a
|
|
||||||
-- failure leaves the database untouched.
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
-- the failed push created this before erroring out
|
|
||||||
DROP SEQUENCE IF EXISTS animals_id_seq;
|
|
||||||
|
|
||||||
-- 1. new integer ids alongside the uuid ones
|
|
||||||
ALTER TABLE animals ADD COLUMN new_id serial;
|
|
||||||
ALTER TABLE sightings ADD COLUMN new_id serial;
|
|
||||||
ALTER TABLE photos ADD COLUMN new_id serial;
|
|
||||||
|
|
||||||
-- 2. translate the foreign keys through the uuid -> integer mapping
|
|
||||||
ALTER TABLE sightings ADD COLUMN new_animal_id integer;
|
|
||||||
UPDATE sightings s SET new_animal_id = a.new_id FROM animals a WHERE a.id = s.animal_id;
|
|
||||||
|
|
||||||
ALTER TABLE photos ADD COLUMN new_sighting_id integer;
|
|
||||||
UPDATE photos p SET new_sighting_id = s.new_id FROM sightings s WHERE s.id = p.sighting_id;
|
|
||||||
|
|
||||||
-- 3. drop the old foreign keys (names vary by how they were created)
|
|
||||||
DO $$
|
|
||||||
DECLARE r record;
|
|
||||||
BEGIN
|
|
||||||
FOR r IN
|
|
||||||
SELECT conrelid::regclass AS tbl, conname
|
|
||||||
FROM pg_constraint
|
|
||||||
WHERE contype = 'f' AND conrelid IN ('sightings'::regclass, 'photos'::regclass)
|
|
||||||
LOOP
|
|
||||||
EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', r.tbl, r.conname);
|
|
||||||
END LOOP;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- 4. swap the foreign key columns
|
|
||||||
ALTER TABLE photos DROP COLUMN sighting_id;
|
|
||||||
ALTER TABLE photos RENAME COLUMN new_sighting_id TO sighting_id;
|
|
||||||
ALTER TABLE photos ALTER COLUMN sighting_id SET NOT NULL;
|
|
||||||
|
|
||||||
ALTER TABLE sightings DROP COLUMN animal_id;
|
|
||||||
ALTER TABLE sightings RENAME COLUMN new_animal_id TO animal_id;
|
|
||||||
ALTER TABLE sightings ALTER COLUMN animal_id SET NOT NULL;
|
|
||||||
|
|
||||||
-- 5. swap the primary keys
|
|
||||||
DO $$
|
|
||||||
DECLARE r record;
|
|
||||||
BEGIN
|
|
||||||
FOR r IN
|
|
||||||
SELECT conrelid::regclass AS tbl, conname
|
|
||||||
FROM pg_constraint
|
|
||||||
WHERE contype = 'p'
|
|
||||||
AND conrelid IN ('animals'::regclass, 'sightings'::regclass, 'photos'::regclass)
|
|
||||||
LOOP
|
|
||||||
EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', r.tbl, r.conname);
|
|
||||||
END LOOP;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
ALTER TABLE animals DROP COLUMN id;
|
|
||||||
ALTER TABLE animals RENAME COLUMN new_id TO id;
|
|
||||||
ALTER TABLE animals ADD PRIMARY KEY (id);
|
|
||||||
ALTER SEQUENCE animals_new_id_seq RENAME TO animals_id_seq;
|
|
||||||
|
|
||||||
ALTER TABLE sightings DROP COLUMN id;
|
|
||||||
ALTER TABLE sightings RENAME COLUMN new_id TO id;
|
|
||||||
ALTER TABLE sightings ADD PRIMARY KEY (id);
|
|
||||||
ALTER SEQUENCE sightings_new_id_seq RENAME TO sightings_id_seq;
|
|
||||||
|
|
||||||
ALTER TABLE photos DROP COLUMN id;
|
|
||||||
ALTER TABLE photos RENAME COLUMN new_id TO id;
|
|
||||||
ALTER TABLE photos ADD PRIMARY KEY (id);
|
|
||||||
ALTER SEQUENCE photos_new_id_seq RENAME TO photos_id_seq;
|
|
||||||
|
|
||||||
-- 6. restore the foreign keys under the names drizzle expects
|
|
||||||
ALTER TABLE sightings ADD CONSTRAINT sightings_animal_id_animals_id_fkey
|
|
||||||
FOREIGN KEY (animal_id) REFERENCES animals(id) ON DELETE CASCADE;
|
|
||||||
ALTER TABLE photos ADD CONSTRAINT photos_sighting_id_sightings_id_fkey
|
|
||||||
FOREIGN KEY (sighting_id) REFERENCES sightings(id) ON DELETE CASCADE;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
-- Additive migration for the existing domain schema. No users or progress are removed.
|
|
||||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS role text NOT NULL DEFAULT 'user';
|
|
||||||
ALTER TABLE account ADD COLUMN IF NOT EXISTS issuer text;
|
|
||||||
-- Existing credentials use Better Auth's local namespace. Fail on unknown legacy
|
|
||||||
-- OAuth providers rather than silently changing which remote identity they trust.
|
|
||||||
UPDATE account SET issuer = 'local:credential' WHERE issuer IS NULL AND provider_id = 'credential';
|
|
||||||
UPDATE account SET issuer = 'https://accounts.google.com' WHERE issuer IS NULL AND provider_id = 'google';
|
|
||||||
UPDATE account SET issuer = 'local:oauth:github' WHERE issuer IS NULL AND provider_id = 'github';
|
|
||||||
UPDATE account SET issuer = 'local:oauth:osu' WHERE issuer IS NULL AND provider_id = 'osu';
|
|
||||||
ALTER TABLE account ALTER COLUMN issuer SET NOT NULL;
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS account_issuer_account_id_unique ON account (issuer, account_id);
|
|
||||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS source_id integer;
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS games_source_id_unique ON games (source_id);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS pokemon_number_unique ON pokemon (number);
|
|
||||||
ALTER TYPE availability ADD VALUE IF NOT EXISTS 'unknown';
|
|
||||||
CREATE TABLE IF NOT EXISTS catch_em_all_settings (
|
|
||||||
user_id text PRIMARY KEY REFERENCES "user"(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS cs_market_items (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
market_hash_name text NOT NULL,
|
|
||||||
name text NOT NULL,
|
|
||||||
icon_url text,
|
|
||||||
item_type text,
|
|
||||||
first_seen_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
last_seen_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS cs_market_items_hash_idx ON cs_market_items (market_hash_name);
|
|
||||||
CREATE INDEX IF NOT EXISTS cs_market_items_name_idx ON cs_market_items (name);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS cs_item_dimensions (
|
|
||||||
item_id integer NOT NULL REFERENCES cs_market_items(id) ON DELETE CASCADE,
|
|
||||||
key text NOT NULL,
|
|
||||||
value text NOT NULL
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS cs_item_dimensions_unique_idx ON cs_item_dimensions (item_id, key);
|
|
||||||
CREATE INDEX IF NOT EXISTS cs_item_dimensions_filter_idx ON cs_item_dimensions (key, value, item_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS cs_price_streams (
|
|
||||||
source text NOT NULL,
|
|
||||||
currency text NOT NULL,
|
|
||||||
kind text NOT NULL
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS cs_price_streams_unique_idx
|
|
||||||
ON cs_price_streams (source, currency, kind);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS cs_price_observations (
|
|
||||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
item_id integer NOT NULL REFERENCES cs_market_items(id) ON DELETE CASCADE,
|
|
||||||
source text NOT NULL,
|
|
||||||
currency text NOT NULL,
|
|
||||||
observed_at timestamptz NOT NULL,
|
|
||||||
price_cents integer NOT NULL CHECK (price_cents >= 0),
|
|
||||||
volume integer CHECK (volume IS NULL OR volume >= 0),
|
|
||||||
kind text NOT NULL,
|
|
||||||
imported_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS cs_price_observations_unique_idx
|
|
||||||
ON cs_price_observations (item_id, source, currency, observed_at, kind);
|
|
||||||
CREATE INDEX IF NOT EXISTS cs_price_observations_lookup_idx
|
|
||||||
ON cs_price_observations (item_id, source, currency, observed_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS cs_price_observations_time_idx
|
|
||||||
ON cs_price_observations (observed_at);
|
|
||||||
84
scripts/db-refresh-dev.sh
Executable file
84
scripts/db-refresh-dev.sh
Executable file
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Refresh the local development database from a fresh production dump.
|
||||||
|
#
|
||||||
|
# Prod is the source of truth for data; dev is a disposable copy of it.
|
||||||
|
#
|
||||||
|
# Required env (put them in .env.dev-refresh or export them; never commit real values):
|
||||||
|
# PROD_DB_URL read access to prod, e.g. postgresql://personal:...@127.0.0.1:5440/personal
|
||||||
|
# DEV_ADMIN_URL superuser on the dev cluster, maintenance DB, e.g. postgresql://postgres:...@127.0.0.1:5432/postgres
|
||||||
|
# Optional:
|
||||||
|
# DEV_DB_NAME target database (default personal_dev)
|
||||||
|
# PG_DOCKER=1 use the postgres:17-alpine image instead of local pg_dump/pg_restore
|
||||||
|
#
|
||||||
|
# What it does: dump prod (custom format) -> drop + recreate DEV_DB_NAME -> create
|
||||||
|
# missing roles (NOLOGIN) -> restore with ownership/grants intact -> clear sessions
|
||||||
|
# and verification tokens so prod logins can't be reused in dev. It never writes to prod.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
if [[ -f .env.dev-refresh ]]; then set -a; . ./.env.dev-refresh; set +a; fi
|
||||||
|
|
||||||
|
: "${PROD_DB_URL:?set PROD_DB_URL (source, read-only use)}"
|
||||||
|
: "${DEV_ADMIN_URL:?set DEV_ADMIN_URL (superuser URL to the dev cluster maintenance DB)}"
|
||||||
|
DEV_DB_NAME="${DEV_DB_NAME:-personal_dev}"
|
||||||
|
|
||||||
|
if [[ ! "$DEV_DB_NAME" =~ ^[a-z_][a-z0-9_]*$ ]]; then
|
||||||
|
echo "DEV_DB_NAME must be a plain lowercase identifier" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
case "$DEV_DB_NAME" in
|
||||||
|
personal | personal_prod | postgres | template0 | template1)
|
||||||
|
echo "Refusing to overwrite '$DEV_DB_NAME'" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# host:port/db of a URL (no credentials), for the "same database" guard.
|
||||||
|
endpoint() { bun -e 'const u=new URL(process.argv[1]); console.log(`${u.hostname}:${u.port||5432}${u.pathname}`)' "$1"; }
|
||||||
|
with_db() { bun -e 'const u=new URL(process.argv[1]); u.pathname="/"+process.argv[2]; console.log(u.toString())' "$1" "$2"; }
|
||||||
|
|
||||||
|
prod_endpoint="$(endpoint "$PROD_DB_URL")"
|
||||||
|
dev_url="$(with_db "$DEV_ADMIN_URL" "$DEV_DB_NAME")"
|
||||||
|
if [[ "$(endpoint "$dev_url")" == "$prod_endpoint" ]]; then
|
||||||
|
echo "Refusing: target $DEV_DB_NAME is the prod source database ($prod_endpoint)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${PG_DOCKER:-0}" == 1 ]]; then
|
||||||
|
pg() { docker run --rm -i --network host -v "$work:$work" postgres:17-alpine "$@"; }
|
||||||
|
else
|
||||||
|
pg() { "$@"; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
dump="$work/prod.dump"
|
||||||
|
|
||||||
|
echo "Dumping prod ($prod_endpoint)..."
|
||||||
|
pg pg_dump --format=custom --no-password --file="$dump" "$PROD_DB_URL"
|
||||||
|
|
||||||
|
echo "Recreating $DEV_DB_NAME..."
|
||||||
|
pg psql -v ON_ERROR_STOP=1 -q "$DEV_ADMIN_URL" <<SQL
|
||||||
|
SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DEV_DB_NAME' AND pid <> pg_backend_pid();
|
||||||
|
DROP DATABASE IF EXISTS "$DEV_DB_NAME";
|
||||||
|
DO \$\$
|
||||||
|
DECLARE r text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH r IN ARRAY ARRAY['personal','personal_app','personal_ddl','personal_ro'] LOOP
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = r) THEN
|
||||||
|
EXECUTE format('CREATE ROLE %I NOLOGIN', r);
|
||||||
|
RAISE NOTICE 'created role % (NOLOGIN; give it a password + LOGIN for dev use)', r;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END \$\$;
|
||||||
|
CREATE DATABASE "$DEV_DB_NAME" OWNER personal;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "Restoring into $DEV_DB_NAME..."
|
||||||
|
pg pg_restore --no-password --exit-on-error --dbname="$dev_url" "$dump"
|
||||||
|
|
||||||
|
echo "Clearing sessions and verification tokens..."
|
||||||
|
pg psql -v ON_ERROR_STOP=1 -q "$dev_url" -c 'TRUNCATE auth.session, auth.verification'
|
||||||
|
|
||||||
|
pg psql -qAt "$dev_url" -c "SELECT 'drizzle migrations recorded: ' || count(*) FROM drizzle.__drizzle_migrations"
|
||||||
|
echo "Done. Apply pending migrations from your branch with: bun run db:migrate"
|
||||||
@@ -17,81 +17,108 @@ if (process.env.ORIGIN !== expectedOrigin || process.env.BETTER_AUTH_URL !== exp
|
|||||||
const client = postgres(process.env.DB_URL, { max: 1, connect_timeout: 10 });
|
const client = postgres(process.env.DB_URL, { max: 1, connect_timeout: 10 });
|
||||||
try {
|
try {
|
||||||
await client`select 1`;
|
await client`select 1`;
|
||||||
|
// Schema layout after the reorg (auth/money/house/ref/games/cs/animaldex/site/meta/lists).
|
||||||
|
const required = [
|
||||||
|
'auth.user',
|
||||||
|
'auth.session',
|
||||||
|
'auth.account',
|
||||||
|
'auth.verification',
|
||||||
|
'money.accounts',
|
||||||
|
'money.transactions',
|
||||||
|
'money.recurring',
|
||||||
|
'money.forecasts',
|
||||||
|
'money.utility_settings',
|
||||||
|
'money.utility_readings',
|
||||||
|
'money.wishlist',
|
||||||
|
'money.insurances',
|
||||||
|
'money.projection_settings',
|
||||||
|
'house.listings',
|
||||||
|
'house.price_history',
|
||||||
|
'house.alert_rules',
|
||||||
|
'house.worker_runs',
|
||||||
|
'ref.pokemon',
|
||||||
|
'ref.pokemon_games',
|
||||||
|
'ref.pokemon_game_entries',
|
||||||
|
'ref.pokemon_locations',
|
||||||
|
'ref.pokemon_encounters',
|
||||||
|
'ref.pokemon_evolutions',
|
||||||
|
'ref.pokemon_research',
|
||||||
|
'ref.pokemon_dex_numbers',
|
||||||
|
'ref.pokemon_moves',
|
||||||
|
'ref.pokemon_learnsets',
|
||||||
|
'ref.pokemon_game_research',
|
||||||
|
'ref.tracker_games',
|
||||||
|
'ref.tracker_items',
|
||||||
|
'ref.tracker_challenges',
|
||||||
|
'games.catch_em_all_entries',
|
||||||
|
'games.catch_em_all_games',
|
||||||
|
'games.catch_em_all_settings',
|
||||||
|
'games.pokemon_journeys',
|
||||||
|
'games.pokemon_catches',
|
||||||
|
'games.pokemon_milestones',
|
||||||
|
'games.pokemon_seen',
|
||||||
|
'games.tracker_progress',
|
||||||
|
'games.tracker_ranks',
|
||||||
|
'games.tracker_weapon_ranks',
|
||||||
|
'games.tracker_challenge_progress',
|
||||||
|
'games.osu_entries',
|
||||||
|
'cs.items',
|
||||||
|
'cs.price_observations',
|
||||||
|
'cs.item_dimensions',
|
||||||
|
'cs.price_streams',
|
||||||
|
'animaldex.animals',
|
||||||
|
'animaldex.sightings',
|
||||||
|
'animaldex.sighting_photos',
|
||||||
|
'site.blog_articles',
|
||||||
|
'site.tasks',
|
||||||
|
'site.topics',
|
||||||
|
'site.task_topics',
|
||||||
|
'meta.tables',
|
||||||
|
'meta.columns'
|
||||||
|
];
|
||||||
|
const missing = await client<{ name: string }[]>`
|
||||||
|
select name from unnest(${required}::text[]) as name where to_regclass(name) is null`;
|
||||||
|
if (missing.length)
|
||||||
|
throw new Error(`Schema reorg/migrations missing: ${missing.map((m) => m.name).join(', ')}`);
|
||||||
const [schema] = await client`
|
const [schema] = await client`
|
||||||
select
|
select
|
||||||
(select count(*) from information_schema.columns
|
(select count(*) from information_schema.columns
|
||||||
where table_name = 'user' and column_name = 'role') as user_role,
|
where table_schema = 'auth' and table_name = 'user' and column_name = 'role') as user_role,
|
||||||
(select count(*) from information_schema.columns
|
(select count(*) from information_schema.columns
|
||||||
where table_name = 'account' and column_name = 'issuer') as account_issuer,
|
where table_schema = 'money' and table_name = 'projection_settings'
|
||||||
|
and column_name = 'include_recurring' and data_type = 'boolean') as include_recurring_bool,
|
||||||
(select count(*) from information_schema.columns
|
(select count(*) from information_schema.columns
|
||||||
where table_name = 'blog_articles' and column_name = 'tags') as blog_tags,
|
where table_schema = 'games' and table_name = 'osu_entries'
|
||||||
to_regclass('public.catch_em_all_settings') is not null as settings_ready,
|
and column_name in ('accuracy', 'pp', 'rate') and data_type = 'numeric') as osu_numeric,
|
||||||
(select count(*) from information_schema.columns where table_name = 'lists' and column_name = 'slug') as list_slug,
|
(select count(*) from meta.tables where kind = 'list') as lists,
|
||||||
to_regclass('public.list_item_completions') is not null as list_completions,
|
has_function_privilege('meta.create_list(text, text, text, boolean, jsonb, text, text, text, integer)', 'execute') as can_create_list,
|
||||||
(select count(*) from information_schema.columns where table_name = 'tasks_to_topics' and column_name = 'id') as topic_link_id,
|
has_schema_privilege('money', 'create') as app_has_ddl
|
||||||
(select count(*) from information_schema.columns
|
|
||||||
where table_name in ('animals', 'sightings', 'photos')
|
|
||||||
and column_name = 'id' and data_type = 'integer') as animal_serial_ids,
|
|
||||||
(select count(*) from information_schema.tables
|
|
||||||
where table_schema = 'public' and table_name in (
|
|
||||||
'money_accounts', 'money_transactions', 'money_recurring', 'money_forecasts',
|
|
||||||
'money_utility_settings', 'money_utility_readings', 'money_wishlist', 'money_insurances',
|
|
||||||
'money_projection_settings'
|
|
||||||
)) as money_tables,
|
|
||||||
(select count(*) from information_schema.columns
|
|
||||||
where table_name = 'money_utility_settings' and column_name = 'billing_start') as money_billing_start,
|
|
||||||
(select count(*) from information_schema.tables
|
|
||||||
where table_schema = 'public' and table_name in (
|
|
||||||
'pokemon_locations', 'pokemon_encounters', 'pokemon_evolutions',
|
|
||||||
'tracker_games', 'tracker_items', 'tracker_progress'
|
|
||||||
)) as game_tracker_tables,
|
|
||||||
(select count(*) from information_schema.tables where table_schema='public' and table_name in (
|
|
||||||
'pokemon_research','pokemon_dex_numbers','pokemon_moves','pokemon_learnsets','pokemon_game_research',
|
|
||||||
'pokemon_journeys','pokemon_catches','pokemon_milestones','tracker_ranks','tracker_weapon_ranks',
|
|
||||||
'tracker_challenges','tracker_challenge_progress','osu_entries','pokemon_seen'
|
|
||||||
)) as tracker_journal_tables,
|
|
||||||
(select count(*) from information_schema.columns where table_name='pokemon_journeys' and column_name='completed') as tracker_completion_column,
|
|
||||||
(select count(*) from information_schema.columns
|
|
||||||
where table_name = 'pokemon_game_entries' and column_name = 'source') as entry_source,
|
|
||||||
(select count(*) from information_schema.columns
|
|
||||||
where (table_name = 'money_accounts' and column_name = 'kind')
|
|
||||||
or (table_name = 'money_recurring' and column_name in ('starts_on', 'ends_on'))
|
|
||||||
or (table_name = 'money_wishlist' and column_name = 'priority')
|
|
||||||
or (table_name = 'money_projection_settings' and column_name = 'include_recurring')) as money_projection_columns
|
|
||||||
`;
|
`;
|
||||||
if (
|
if (
|
||||||
!Number(schema.user_role) ||
|
!Number(schema.user_role) ||
|
||||||
!Number(schema.account_issuer) ||
|
!Number(schema.include_recurring_bool) ||
|
||||||
!Number(schema.blog_tags) ||
|
Number(schema.osu_numeric) !== 3 ||
|
||||||
!schema.settings_ready ||
|
!Number(schema.lists) ||
|
||||||
!Number(schema.list_slug) ||
|
!schema.can_create_list
|
||||||
!schema.list_completions ||
|
|
||||||
!Number(schema.topic_link_id) ||
|
|
||||||
Number(schema.animal_serial_ids) !== 3 ||
|
|
||||||
Number(schema.money_tables) !== 9 ||
|
|
||||||
!Number(schema.money_billing_start) ||
|
|
||||||
Number(schema.money_projection_columns) !== 5 ||
|
|
||||||
Number(schema.game_tracker_tables) !== 6 ||
|
|
||||||
Number(schema.tracker_journal_tables) !== 14 ||
|
|
||||||
!Number(schema.tracker_completion_column) ||
|
|
||||||
!Number(schema.entry_source)
|
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Required migrations or Animaldex ID conversion have not been applied to this database'
|
'The schema reorg has not been applied to this database (or grants are missing)'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (schema.app_has_ddl)
|
||||||
|
throw new Error('DB_URL must use the app role (personal_app), not the schema owner');
|
||||||
const [catalog] = await client`
|
const [catalog] = await client`
|
||||||
select
|
select
|
||||||
(select count(*) from pokemon) as pokemon,
|
(select count(*) from ref.pokemon) as pokemon,
|
||||||
(select count(*) from games where source_id is not null) as games,
|
(select count(*) from ref.pokemon_games where source_id is not null) as games,
|
||||||
(select count(*) from pokemon_game_entries) as entries,
|
(select count(*) from ref.pokemon_game_entries) as entries,
|
||||||
(select count(*) from pokemon_encounters) as encounters,
|
(select count(*) from ref.pokemon_encounters) as encounters,
|
||||||
(select count(*) from tracker_games where franchise = 'call-of-duty') as cod_games,
|
(select count(*) from ref.tracker_games where franchise = 'call-of-duty') as cod_games,
|
||||||
(select count(*) from pokemon_research) as pokemon_research,
|
(select count(*) from ref.pokemon_research) as pokemon_research,
|
||||||
(select count(*) from pokemon_moves) as pokemon_moves,
|
(select count(*) from ref.pokemon_moves) as pokemon_moves,
|
||||||
(select count(*) from pokemon_learnsets) as pokemon_learnsets,
|
(select count(*) from ref.pokemon_learnsets) as pokemon_learnsets,
|
||||||
(select count(*) from pokemon_dex_numbers) as pokemon_dex_numbers,
|
(select count(*) from ref.pokemon_dex_numbers) as pokemon_dex_numbers,
|
||||||
(select count(*) from tracker_challenges) as tracker_challenges
|
(select count(*) from ref.tracker_challenges) as tracker_challenges
|
||||||
`;
|
`;
|
||||||
if (
|
if (
|
||||||
Number(catalog.pokemon) < 1025 ||
|
Number(catalog.pokemon) < 1025 ||
|
||||||
@@ -109,15 +136,15 @@ try {
|
|||||||
Number(catalog.tracker_challenges) < 3600
|
Number(catalog.tracker_challenges) < 3600
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Run db:migrate:tracker-journal, db:seed:research and db:seed:challenges before deploying the tracker expansion'
|
'Run db:seed:research and db:seed:challenges before deploying the tracker expansion'
|
||||||
);
|
);
|
||||||
if (Number(catalog.cod_games) < 7) {
|
if (Number(catalog.cod_games) < 7) {
|
||||||
throw new Error('Call of Duty catalog has not been seeded in this database');
|
throw new Error('Call of Duty catalog has not been seeded in this database');
|
||||||
}
|
}
|
||||||
const [bo2] = await client`select rules,
|
const [bo2] = await client`select rules,
|
||||||
(select count(*)::int from tracker_items i where i.game_id=g.id and i.kind='weapon') as weapons,
|
(select count(*)::int from ref.tracker_items i where i.game_id=g.id and i.kind='weapon') as weapons,
|
||||||
(select count(*)::int from tracker_items i where i.game_id=g.id and i.kind='weapon' and i.icon is not null) as pictures
|
(select count(*)::int from ref.tracker_items i where i.game_id=g.id and i.kind='weapon' and i.icon is not null) as pictures
|
||||||
from tracker_games g where slug='black-ops-2'`;
|
from ref.tracker_games g where slug='black-ops-2'`;
|
||||||
if (
|
if (
|
||||||
!bo2 ||
|
!bo2 ||
|
||||||
bo2.rules.rankCaps?.weaponPrestige !== 2 ||
|
bo2.rules.rankCaps?.weaponPrestige !== 2 ||
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Production is the only deployed environment (local dev uses scripts/db-refresh-dev.sh).
|
||||||
|
# deploy-remote.sh <sha> deploy back up, migrate, seed, preflight, switch, health-check
|
||||||
|
# deploy-remote.sh <sha> rollback back up, preflight, switch to an earlier image (no migrations;
|
||||||
|
# the database is not rolled back, restore a backup for that)
|
||||||
sha=${1:?commit SHA required}
|
sha=${1:?commit SHA required}
|
||||||
directory=${2:?deployment directory required}
|
mode=${2:-deploy}
|
||||||
origin=${3:?public origin required}
|
|
||||||
case "$sha" in
|
case "$sha" in
|
||||||
*[!0-9a-f]*|'') echo 'Invalid image tag' >&2; exit 2 ;;
|
*[!0-9a-f]*|'') echo 'Invalid image tag' >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
case "$directory:$origin" in
|
case "$mode" in deploy|rollback) ;; *) echo 'Mode must be deploy or rollback' >&2; exit 2 ;; esac
|
||||||
/opt/personal-website:https://stanrunge.dev)
|
directory=/opt/personal-website
|
||||||
environment=/etc/personal-website/prod.env ;;
|
origin=https://stanrunge.dev
|
||||||
/opt/personal-website-staging:https://staging.stanrunge.dev)
|
environment=/etc/personal-website/prod.env
|
||||||
environment=/etc/personal-website-staging/staging.env ;;
|
environment_name=production
|
||||||
*) echo 'Unrecognized deployment target' >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
image="git.stanrunge.dev/stan/personal-website:$sha"
|
image="git.stanrunge.dev/stan/personal-website:$sha"
|
||||||
compose="$directory/compose.prod.yml"
|
compose="$directory/compose.prod.yml"
|
||||||
if [ "$directory" = /opt/personal-website-staging ]; then
|
if ! sudo docker image inspect "$image" >/dev/null 2>&1; then
|
||||||
environment_name=staging
|
echo "Image $image is not on this host" >&2; exit 1
|
||||||
else
|
|
||||||
environment_name=production
|
|
||||||
fi
|
fi
|
||||||
previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1)
|
previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1)
|
||||||
switched=0
|
switched=0
|
||||||
@@ -33,17 +32,21 @@ rollback() {
|
|||||||
}
|
}
|
||||||
trap rollback ERR
|
trap rollback ERR
|
||||||
|
|
||||||
|
# The deploy account cannot read the env file directly; check inside a container.
|
||||||
|
if ! sudo docker run --rm --env-file "$environment" --entrypoint sh "$image" \
|
||||||
|
-c 'test -n "${DB_MIGRATE_URL:-}"'; then
|
||||||
|
echo "DB_MIGRATE_URL (schema owner) is missing from $environment" >&2; exit 1
|
||||||
|
fi
|
||||||
db_version=$(sudo docker run --rm --network backend --env-file "$environment" "$image" \
|
db_version=$(sudo docker run --rm --network backend --env-file "$environment" "$image" \
|
||||||
bun scripts/print-db-server-version.ts)
|
bun scripts/print-db-server-version.ts)
|
||||||
case "$db_version" in
|
case "$db_version" in
|
||||||
*[!0-9]*|'') echo 'Could not determine the PostgreSQL server version' >&2; exit 1 ;;
|
*[!0-9]*|'') echo 'Could not determine the PostgreSQL server version' >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
if [ "$db_version" -ge 100000 ]; then
|
|
||||||
db_major=$((db_version / 10000))
|
db_major=$((db_version / 10000))
|
||||||
else
|
if [ "$db_major" -gt 17 ]; then
|
||||||
db_major="$((db_version / 10000)).$(((db_version / 100) % 100))"
|
echo "Server is PostgreSQL $db_major; update the pinned postgres:17-alpine backup image" >&2; exit 1
|
||||||
fi
|
fi
|
||||||
backup_image="postgres:${db_major}-alpine"
|
backup_image=postgres:17-alpine
|
||||||
backup_dir=/var/backups/personal-website
|
backup_dir=/var/backups/personal-website
|
||||||
backup_stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
backup_stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||||
backup_name="${environment_name}-${backup_stamp}-${sha}.dump"
|
backup_name="${environment_name}-${backup_stamp}-${sha}.dump"
|
||||||
@@ -53,27 +56,29 @@ sudo docker pull "$backup_image"
|
|||||||
sudo docker run --rm --network backend --env-file "$environment" \
|
sudo docker run --rm --network backend --env-file "$environment" \
|
||||||
--volume "$backup_dir:/backups" \
|
--volume "$backup_dir:/backups" \
|
||||||
--env "BACKUP_FILE=$backup_name" "$backup_image" \
|
--env "BACKUP_FILE=$backup_name" "$backup_image" \
|
||||||
sh -eu -c 'chmod 0700 /backups; umask 077; pg_dump --format=custom --no-owner --no-acl --file="/backups/$BACKUP_FILE" "$DB_URL"'
|
sh -eu -c 'chmod 0700 /backups; umask 077; pg_dump --format=custom --no-owner --no-acl --file="/backups/$BACKUP_FILE" "$DB_MIGRATE_URL"'
|
||||||
sudo docker run --rm --mount "type=bind,source=$backup_dir,target=/backups" \
|
sudo docker run --rm --mount "type=bind,source=$backup_dir,target=/backups" \
|
||||||
"$backup_image" pg_restore --list "/backups/$backup_name" >/dev/null
|
"$backup_image" pg_restore --list "/backups/$backup_name" >/dev/null
|
||||||
echo "Verified $environment_name database backup at $backup_dir/$backup_name"
|
echo "Verified $environment_name database backup at $backup_dir/$backup_name"
|
||||||
|
|
||||||
|
# Migrations and seeds (ref.* is read-only for the app) need the owner role.
|
||||||
|
# DB_MIGRATE_URL (role personal) must be set in the env file; DB_URL is the app
|
||||||
|
# role (personal_app), which has no DDL rights.
|
||||||
run_database_script() {
|
run_database_script() {
|
||||||
echo "Running $1"
|
echo "Running $1"
|
||||||
sudo docker run --rm --network backend --env-file "$environment" "$image" bun "$1"
|
sudo docker run --rm --network backend --env-file "$environment" \
|
||||||
|
--entrypoint sh "$image" -eu -c 'DB_URL="$DB_MIGRATE_URL" exec bun "$0"' "$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
run_database_script scripts/migrate-auth-catalog.ts
|
if [ "$mode" = deploy ]; then
|
||||||
run_database_script scripts/migrate-foundation.ts
|
# Schema changes: Drizzle migrations from ./drizzle, as the schema owner.
|
||||||
run_database_script scripts/migrate-money-tracker.ts
|
run_database_script scripts/db-migrate.ts
|
||||||
run_database_script scripts/migrate-game-tracker.ts
|
|
||||||
run_database_script scripts/migrate-tracker-journal.ts
|
|
||||||
run_database_script scripts/migrate-house-tracker.ts
|
|
||||||
run_database_script scripts/seed-catalog.ts
|
run_database_script scripts/seed-catalog.ts
|
||||||
run_database_script scripts/seed-cod-catalog.ts
|
run_database_script scripts/seed-cod-catalog.ts
|
||||||
run_database_script scripts/seed-pokemon-research.ts
|
run_database_script scripts/seed-pokemon-research.ts
|
||||||
run_database_script scripts/seed-cod-challenges.ts
|
run_database_script scripts/seed-cod-challenges.ts
|
||||||
run_database_script scripts/seed-cod-art.ts
|
run_database_script scripts/seed-cod-art.ts
|
||||||
|
fi
|
||||||
|
|
||||||
sudo docker run --rm --network backend --env-file "$environment" "$image" \
|
sudo docker run --rm --network backend --env-file "$environment" "$image" \
|
||||||
bun scripts/deploy-preflight.ts "$origin"
|
bun scripts/deploy-preflight.ts "$origin"
|
||||||
@@ -96,4 +101,4 @@ curl --retry 6 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
|
|||||||
curl --retry 6 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
|
curl --retry 6 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
|
||||||
"$origin/projects/game-tracker/call-of-duty/black-ops-2" >/dev/null
|
"$origin/projects/game-tracker/call-of-duty/black-ops-2" >/dev/null
|
||||||
trap - ERR
|
trap - ERR
|
||||||
echo "Deployed $sha to $origin"
|
echo "Deployed $sha to $origin ($mode)"
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
-- Apply after auth-catalog-migration.sql and before deploying the foundation release.
|
|
||||||
-- Additive domain-schema changes; existing records and numeric IDs are retained.
|
|
||||||
|
|
||||||
ALTER TABLE blog_articles ADD COLUMN IF NOT EXISTS tags text[] NOT NULL DEFAULT '{}'::text[];
|
|
||||||
|
|
||||||
ALTER TABLE lists ADD COLUMN IF NOT EXISTS slug text;
|
|
||||||
UPDATE lists SET slug = COALESCE(NULLIF(trim(both '-' from regexp_replace(lower(name), '[^a-z0-9]+', '-', 'g')), ''), 'list') || '-' || id
|
|
||||||
WHERE slug IS NULL;
|
|
||||||
ALTER TABLE lists ALTER COLUMN slug SET NOT NULL;
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS lists_slug_unique ON lists (slug);
|
|
||||||
ALTER TABLE lists ADD COLUMN IF NOT EXISTS is_progress boolean NOT NULL DEFAULT false;
|
|
||||||
ALTER TABLE lists ADD COLUMN IF NOT EXISTS picture_url text;
|
|
||||||
ALTER TABLE list_items ADD COLUMN IF NOT EXISTS picture_url text;
|
|
||||||
ALTER TABLE list_items ADD COLUMN IF NOT EXISTS description text;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS list_item_completions (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
item_id integer NOT NULL REFERENCES list_items(id) ON DELETE CASCADE,
|
|
||||||
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT list_item_completions_item_id_user_id_unique UNIQUE (item_id, user_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS list_item_completions_user_id_idx ON list_item_completions(user_id);
|
|
||||||
|
|
||||||
-- Join/setting records gain the same numeric identity as other site-owned rows.
|
|
||||||
ALTER TABLE tasks_to_topics ADD COLUMN IF NOT EXISTS id serial;
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'tasks_to_topics'::regclass AND conname = 'tasks_to_topics_pkey'
|
|
||||||
AND pg_get_constraintdef(oid) NOT LIKE 'PRIMARY KEY (id)') THEN
|
|
||||||
ALTER TABLE tasks_to_topics DROP CONSTRAINT tasks_to_topics_pkey;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'tasks_to_topics'::regclass AND contype = 'p') THEN
|
|
||||||
ALTER TABLE tasks_to_topics ADD CONSTRAINT tasks_to_topics_pkey PRIMARY KEY (id);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS tasks_to_topics_task_id_topic_id_unique ON tasks_to_topics(task_id, topic_id);
|
|
||||||
|
|
||||||
ALTER TABLE catch_em_all_settings ADD COLUMN IF NOT EXISTS id serial;
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'catch_em_all_settings'::regclass AND conname = 'catch_em_all_settings_pkey'
|
|
||||||
AND pg_get_constraintdef(oid) NOT LIKE 'PRIMARY KEY (id)') THEN
|
|
||||||
ALTER TABLE catch_em_all_settings DROP CONSTRAINT catch_em_all_settings_pkey;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'catch_em_all_settings'::regclass AND contype = 'p') THEN
|
|
||||||
ALTER TABLE catch_em_all_settings ADD CONSTRAINT catch_em_all_settings_pkey PRIMARY KEY (id);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS catch_em_all_settings_user_id_unique ON catch_em_all_settings(user_id);
|
|
||||||
|
|
||||||
-- Every site-owned table records creation and latest update. Domain-specific
|
|
||||||
-- event times (submitted_at, seen_at, uploaded_at) remain untouched.
|
|
||||||
DO $$
|
|
||||||
DECLARE table_name text;
|
|
||||||
BEGIN
|
|
||||||
FOREACH table_name IN ARRAY ARRAY[
|
|
||||||
'blog_articles', 'lists', 'list_items', 'list_item_completions',
|
|
||||||
'animals', 'sightings', 'photos', 'tasks', 'topics', 'tasks_to_topics',
|
|
||||||
'pokemon', 'games', 'pokemon_game_entries', 'user_entries', 'user_games',
|
|
||||||
'catch_em_all_settings'
|
|
||||||
] LOOP
|
|
||||||
EXECUTE format('ALTER TABLE %I ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now()', table_name);
|
|
||||||
EXECUTE format('ALTER TABLE %I ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now()', table_name);
|
|
||||||
END LOOP;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- Preserve the best available creation time for older Animaldex rows.
|
|
||||||
UPDATE animals SET created_at = submitted_at WHERE created_at > submitted_at;
|
|
||||||
UPDATE sightings SET created_at = submitted_at WHERE created_at > submitted_at;
|
|
||||||
UPDATE photos SET created_at = uploaded_at WHERE created_at > uploaded_at;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION site_touch_updated_at() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = now();
|
|
||||||
RETURN NEW;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
DECLARE table_name text;
|
|
||||||
BEGIN
|
|
||||||
FOREACH table_name IN ARRAY ARRAY[
|
|
||||||
'blog_articles', 'lists', 'list_items', 'list_item_completions',
|
|
||||||
'animals', 'sightings', 'photos', 'tasks', 'topics', 'tasks_to_topics',
|
|
||||||
'pokemon', 'games', 'pokemon_game_entries', 'user_entries', 'user_games',
|
|
||||||
'catch_em_all_settings'
|
|
||||||
] LOOP
|
|
||||||
EXECUTE format('DROP TRIGGER IF EXISTS site_touch_updated_at ON %I', table_name);
|
|
||||||
EXECUTE format('CREATE TRIGGER site_touch_updated_at BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION site_touch_updated_at()', table_name);
|
|
||||||
END LOOP;
|
|
||||||
END $$;
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
-- Game tracker: owned Pokémon locations/encounters/evolutions and checklist games.
|
|
||||||
-- Additive and repeatable. Apply before seeding (`npm run db:seed`, `npm run db:seed:cod`).
|
|
||||||
|
|
||||||
ALTER TYPE availability ADD VALUE IF NOT EXISTS 'evolve';
|
|
||||||
ALTER TYPE availability ADD VALUE IF NOT EXISTS 'breed';
|
|
||||||
|
|
||||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS evolves_from integer;
|
|
||||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS evolution_chain integer;
|
|
||||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS is_baby boolean NOT NULL DEFAULT false;
|
|
||||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS is_legendary boolean NOT NULL DEFAULT false;
|
|
||||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS is_mythical boolean NOT NULL DEFAULT false;
|
|
||||||
|
|
||||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS slug text;
|
|
||||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0;
|
|
||||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS breeding boolean NOT NULL DEFAULT true;
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'games_slug_key') THEN
|
|
||||||
ALTER TABLE games ADD CONSTRAINT games_slug_key UNIQUE (slug);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- Seeds used to mark their own rows with a "PokéAPI:" note prefix; anything else
|
|
||||||
-- was hand-maintained and must survive reseeding.
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name = 'pokemon_game_entries' AND column_name = 'source') THEN
|
|
||||||
ALTER TABLE pokemon_game_entries ADD COLUMN source text NOT NULL DEFAULT 'pokeapi';
|
|
||||||
UPDATE pokemon_game_entries SET source = 'manual' WHERE note IS NULL OR note NOT LIKE 'PokéAPI:%';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pokemon_locations (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
source_id integer UNIQUE,
|
|
||||||
slug text NOT NULL,
|
|
||||||
name text NOT NULL,
|
|
||||||
region text,
|
|
||||||
description text,
|
|
||||||
image_key text,
|
|
||||||
image_credit text,
|
|
||||||
source text NOT NULL DEFAULT 'pokeapi',
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pokemon_encounters (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
pokemon_id integer NOT NULL,
|
|
||||||
game_id integer NOT NULL,
|
|
||||||
location_id integer NOT NULL,
|
|
||||||
area text,
|
|
||||||
method text NOT NULL,
|
|
||||||
min_level integer,
|
|
||||||
max_level integer,
|
|
||||||
rarity integer,
|
|
||||||
form text,
|
|
||||||
conditions text[] NOT NULL DEFAULT '{}'::text[],
|
|
||||||
note text,
|
|
||||||
source text NOT NULL DEFAULT 'pokeapi',
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT pokemon_encounters_pokemon_id_pokemon_id_fkey FOREIGN KEY (pokemon_id) REFERENCES pokemon(id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT pokemon_encounters_game_id_games_id_fkey FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT pokemon_encounters_location_id_pokemon_locations_id_fkey FOREIGN KEY (location_id) REFERENCES pokemon_locations(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS pokemon_encounters_pokemon_id_game_id_index ON pokemon_encounters (pokemon_id, game_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS pokemon_encounters_location_id_index ON pokemon_encounters (location_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pokemon_evolutions (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
from_pokemon_id integer NOT NULL,
|
|
||||||
to_pokemon_id integer NOT NULL,
|
|
||||||
game_id integer NOT NULL,
|
|
||||||
method text NOT NULL,
|
|
||||||
source text NOT NULL DEFAULT 'pokeapi',
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT pokemon_evolutions_from_pokemon_id_to_pokemon_id_game_id_method_unique UNIQUE (from_pokemon_id, to_pokemon_id, game_id, method),
|
|
||||||
CONSTRAINT pokemon_evolutions_from_pokemon_id_pokemon_id_fkey FOREIGN KEY (from_pokemon_id) REFERENCES pokemon(id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT pokemon_evolutions_to_pokemon_id_pokemon_id_fkey FOREIGN KEY (to_pokemon_id) REFERENCES pokemon(id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT pokemon_evolutions_game_id_games_id_fkey FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS pokemon_evolutions_game_id_index ON pokemon_evolutions (game_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tracker_games (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
slug text NOT NULL UNIQUE,
|
|
||||||
franchise text NOT NULL,
|
|
||||||
name text NOT NULL,
|
|
||||||
short_name text NOT NULL,
|
|
||||||
release_year integer,
|
|
||||||
sort_order integer NOT NULL DEFAULT 0,
|
|
||||||
rules jsonb NOT NULL,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tracker_items (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
game_id integer NOT NULL,
|
|
||||||
key text NOT NULL,
|
|
||||||
kind text NOT NULL,
|
|
||||||
category text NOT NULL,
|
|
||||||
name text NOT NULL,
|
|
||||||
description text,
|
|
||||||
icon text,
|
|
||||||
post_launch boolean NOT NULL DEFAULT false,
|
|
||||||
sort_order integer NOT NULL DEFAULT 0,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT tracker_items_game_id_key_unique UNIQUE (game_id, key),
|
|
||||||
CONSTRAINT tracker_items_game_id_tracker_games_id_fkey FOREIGN KEY (game_id) REFERENCES tracker_games(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS tracker_items_game_id_index ON tracker_items (game_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tracker_progress (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
user_id text NOT NULL,
|
|
||||||
item_id integer NOT NULL,
|
|
||||||
milestone text NOT NULL DEFAULT 'done',
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT tracker_progress_user_id_item_id_milestone_unique UNIQUE (user_id, item_id, milestone),
|
|
||||||
CONSTRAINT tracker_progress_user_id_user_id_fkey FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT tracker_progress_item_id_tracker_items_id_fkey FOREIGN KEY (item_id) REFERENCES tracker_items(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS tracker_progress_user_id_index ON tracker_progress (user_id);
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
-- House tracker: listings from Dutch housing sites, collected by a home worker.
|
|
||||||
-- Idempotent; safe to run on every deploy.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS house_listings (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
source text NOT NULL,
|
|
||||||
source_id text NOT NULL,
|
|
||||||
external_id text,
|
|
||||||
url text NOT NULL,
|
|
||||||
offer text NOT NULL CHECK (offer IN ('rent', 'buy')),
|
|
||||||
property_type text NOT NULL,
|
|
||||||
title text,
|
|
||||||
street text,
|
|
||||||
postal_code text,
|
|
||||||
city text,
|
|
||||||
city_slug text,
|
|
||||||
neighbourhood text,
|
|
||||||
price_eur integer CHECK (price_eur IS NULL OR price_eur >= 0),
|
|
||||||
area_m2 integer,
|
|
||||||
rooms integer,
|
|
||||||
bedrooms integer,
|
|
||||||
bathrooms integer,
|
|
||||||
construction_year integer,
|
|
||||||
energy_label text,
|
|
||||||
interior text,
|
|
||||||
deposit_eur integer,
|
|
||||||
available_from text,
|
|
||||||
contract_type text,
|
|
||||||
agent text,
|
|
||||||
image_url text,
|
|
||||||
description text,
|
|
||||||
features jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
lat double precision,
|
|
||||||
lng double precision,
|
|
||||||
-- When the site says it was published. first_seen_at is when we noticed.
|
|
||||||
offered_at timestamptz,
|
|
||||||
first_seen_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
-- fast, slow, or seed (already listed when its search was first polled).
|
|
||||||
first_seen_lane text NOT NULL DEFAULT 'slow',
|
|
||||||
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
detail_fetched_at timestamptz,
|
|
||||||
removed_at timestamptz,
|
|
||||||
alerted_at timestamptz,
|
|
||||||
-- Personal tracking.
|
|
||||||
status text,
|
|
||||||
notes text
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS house_listings_source_idx ON house_listings (source, source_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS house_listings_active_idx
|
|
||||||
ON house_listings (offer, first_seen_at DESC) WHERE removed_at IS NULL;
|
|
||||||
CREATE INDEX IF NOT EXISTS house_listings_city_idx ON house_listings (source, offer, city_slug);
|
|
||||||
CREATE INDEX IF NOT EXISTS house_listings_details_idx
|
|
||||||
ON house_listings (first_seen_at DESC) WHERE detail_fetched_at IS NULL;
|
|
||||||
|
|
||||||
-- Every price we have seen, so price drops and market trends survive edits.
|
|
||||||
CREATE TABLE IF NOT EXISTS house_price_history (
|
|
||||||
listing_id integer NOT NULL REFERENCES house_listings(id) ON DELETE CASCADE,
|
|
||||||
observed_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
price_eur integer NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS house_price_history_listing_idx
|
|
||||||
ON house_price_history (listing_id, observed_at);
|
|
||||||
|
|
||||||
-- One row per polled search URL. The first sync of a search only seeds the
|
|
||||||
-- database: alerting on everything already listed would be noise.
|
|
||||||
CREATE TABLE IF NOT EXISTS house_searches (
|
|
||||||
search_url text PRIMARY KEY,
|
|
||||||
source text NOT NULL,
|
|
||||||
lane text NOT NULL,
|
|
||||||
first_synced_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
last_synced_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
last_found integer NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Worker health: challenges, HTTP errors, parse failures.
|
|
||||||
CREATE TABLE IF NOT EXISTS house_worker_runs (
|
|
||||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
source text NOT NULL,
|
|
||||||
lane text NOT NULL,
|
|
||||||
url text NOT NULL,
|
|
||||||
status text NOT NULL,
|
|
||||||
http_status integer,
|
|
||||||
found integer,
|
|
||||||
duration_ms integer,
|
|
||||||
message text,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS house_worker_runs_created_idx ON house_worker_runs (created_at DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS house_alert_rules (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
name text NOT NULL,
|
|
||||||
enabled boolean NOT NULL DEFAULT true,
|
|
||||||
offer text NOT NULL DEFAULT 'rent' CHECK (offer IN ('rent', 'buy')),
|
|
||||||
min_price integer,
|
|
||||||
max_price integer,
|
|
||||||
min_area integer,
|
|
||||||
min_bedrooms integer,
|
|
||||||
-- Lowercase city names; empty means anywhere.
|
|
||||||
cities text[] NOT NULL DEFAULT '{}',
|
|
||||||
-- Empty means every property type.
|
|
||||||
property_types text[] NOT NULL DEFAULT '{}',
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO house_alert_rules (name, offer, min_price, max_price, cities)
|
|
||||||
SELECT 'Gouda area rentals', 'rent', 1300, 1700, ARRAY[
|
|
||||||
'gouda', 'waddinxveen', 'reeuwijk', 'moordrecht', 'nieuwerkerk aan den ijssel',
|
|
||||||
'zevenhuizen', 'bodegraven', 'boskoop', 'oudewater', 'schoonhoven', 'haastrecht'
|
|
||||||
]
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM house_alert_rules);
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
-- One-off bridge from the pre-foundation site schema to the current table names.
|
|
||||||
-- Run after the Animaldex UUID conversion, if needed, and before the auth,
|
|
||||||
-- foundation, and catalog migrations. Review and back up each database first.
|
|
||||||
-- Renaming keeps existing rows and foreign keys; lists did not exist in this schema.
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
ALTER TABLE public.game RENAME TO games;
|
|
||||||
ALTER TABLE public.pokemon_game_entry RENAME TO pokemon_game_entries;
|
|
||||||
ALTER TABLE public.user_entry RENAME TO user_entries;
|
|
||||||
ALTER TABLE public.user_game RENAME TO user_games;
|
|
||||||
|
|
||||||
CREATE TABLE public.lists (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
name text NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE public.list_items (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
list_id integer NOT NULL REFERENCES public.lists(id) ON DELETE CASCADE,
|
|
||||||
name text NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX list_items_list_id_idx ON public.list_items(list_id);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE lists ADD COLUMN IF NOT EXISTS picture_url text;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import postgres from 'postgres';
|
|
||||||
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
|
||||||
const sql = postgres(process.env.DB_URL, { max: 1 });
|
|
||||||
try {
|
|
||||||
await sql.begin(async (tx) => {
|
|
||||||
await tx.unsafe(
|
|
||||||
await readFile(new URL('./auth-catalog-migration.sql', import.meta.url), 'utf8')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
console.log('Authentication and catalog migration applied');
|
|
||||||
} finally {
|
|
||||||
await sql.end();
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import postgres from 'postgres';
|
|
||||||
|
|
||||||
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
|
||||||
const client = postgres(process.env.DB_URL, { max: 1 });
|
|
||||||
try {
|
|
||||||
await client.begin(async (tx) => {
|
|
||||||
await tx.unsafe(await readFile(new URL('./cs-prices-migration.sql', import.meta.url), 'utf8'));
|
|
||||||
});
|
|
||||||
console.log('CS prices migration applied');
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import postgres from 'postgres';
|
|
||||||
|
|
||||||
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
|
||||||
const client = postgres(process.env.DB_URL, { max: 1 });
|
|
||||||
try {
|
|
||||||
await client.begin(async (tx) => {
|
|
||||||
await tx.unsafe(await readFile(new URL('./foundation-migration.sql', import.meta.url), 'utf8'));
|
|
||||||
});
|
|
||||||
console.log('Foundation migration applied');
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import postgres from 'postgres';
|
|
||||||
|
|
||||||
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
|
||||||
const client = postgres(process.env.DB_URL, { max: 1 });
|
|
||||||
try {
|
|
||||||
await client.begin(async (tx) => {
|
|
||||||
await tx.unsafe(
|
|
||||||
await readFile(new URL('./game-tracker-migration.sql', import.meta.url), 'utf8')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
console.log('Game tracker migration applied');
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import postgres from 'postgres';
|
|
||||||
|
|
||||||
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
|
||||||
const client = postgres(process.env.DB_URL, { max: 1 });
|
|
||||||
try {
|
|
||||||
await client.begin(async (tx) => {
|
|
||||||
await tx.unsafe(
|
|
||||||
await readFile(new URL('./house-tracker-migration.sql', import.meta.url), 'utf8')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
console.log('House tracker migration applied');
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import postgres from 'postgres';
|
|
||||||
|
|
||||||
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
|
||||||
const client = postgres(process.env.DB_URL, { max: 1 });
|
|
||||||
try {
|
|
||||||
await client.begin(async (tx) => {
|
|
||||||
await tx.unsafe(
|
|
||||||
await readFile(new URL('./money-tracker-migration.sql', import.meta.url), 'utf8')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
console.log('Money tracker migration applied');
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import postgres from 'postgres';
|
|
||||||
|
|
||||||
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
|
||||||
const client = postgres(process.env.DB_URL, { max: 1 });
|
|
||||||
try {
|
|
||||||
await client.begin(async (tx) => {
|
|
||||||
await tx.unsafe(
|
|
||||||
await readFile(new URL('./tracker-journal-migration.sql', import.meta.url), 'utf8')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
console.log('Tracker journal migration applied');
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS money_accounts (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
|
||||||
name text NOT NULL,
|
|
||||||
opening_balance_cents integer NOT NULL DEFAULT 0,
|
|
||||||
kind text NOT NULL DEFAULT 'spendable',
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (user_id, name)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS money_accounts_user_id_idx ON money_accounts (user_id);
|
|
||||||
ALTER TABLE money_accounts ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'spendable';
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS money_transactions (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
|
||||||
account_id integer NOT NULL REFERENCES money_accounts(id),
|
|
||||||
date date NOT NULL,
|
|
||||||
description text NOT NULL,
|
|
||||||
amount_cents integer NOT NULL,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS money_transactions_user_date_idx ON money_transactions (user_id, date, id);
|
|
||||||
CREATE INDEX IF NOT EXISTS money_transactions_account_id_idx ON money_transactions (account_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS money_recurring (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
|
||||||
name text NOT NULL,
|
|
||||||
amount_cents integer NOT NULL,
|
|
||||||
cadence text NOT NULL,
|
|
||||||
starts_on date,
|
|
||||||
ends_on date,
|
|
||||||
account_id integer REFERENCES money_accounts(id) ON DELETE SET NULL,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS money_recurring_user_id_idx ON money_recurring (user_id);
|
|
||||||
ALTER TABLE money_recurring ADD COLUMN IF NOT EXISTS starts_on date;
|
|
||||||
ALTER TABLE money_recurring ADD COLUMN IF NOT EXISTS ends_on date;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS money_forecasts (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
|
||||||
date date NOT NULL,
|
|
||||||
description text NOT NULL,
|
|
||||||
amount_cents integer NOT NULL,
|
|
||||||
account_id integer REFERENCES money_accounts(id) ON DELETE SET NULL,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS money_forecasts_user_date_idx ON money_forecasts (user_id, date);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS money_utility_settings (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
|
||||||
kind text NOT NULL,
|
|
||||||
rate_per_unit numeric(12, 4) NOT NULL DEFAULT 0,
|
|
||||||
monthly_advance_cents integer NOT NULL DEFAULT 0,
|
|
||||||
billing_start date,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (user_id, kind)
|
|
||||||
);
|
|
||||||
ALTER TABLE money_utility_settings ADD COLUMN IF NOT EXISTS billing_start date;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS money_utility_readings (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
|
||||||
kind text NOT NULL,
|
|
||||||
date date NOT NULL,
|
|
||||||
reading numeric(12, 3) NOT NULL,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS money_utility_readings_user_kind_date_idx ON money_utility_readings (user_id, kind, date);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS money_wishlist (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
|
||||||
name text NOT NULL,
|
|
||||||
estimated_cents integer,
|
|
||||||
priority integer NOT NULL DEFAULT 3,
|
|
||||||
notes text,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS money_wishlist_user_id_idx ON money_wishlist (user_id);
|
|
||||||
ALTER TABLE money_wishlist ADD COLUMN IF NOT EXISTS priority integer NOT NULL DEFAULT 3;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS money_projection_settings (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
user_id text NOT NULL UNIQUE REFERENCES "user"(id) ON DELETE CASCADE,
|
|
||||||
investment_account_id integer REFERENCES money_accounts(id) ON DELETE SET NULL,
|
|
||||||
reserve_cents integer NOT NULL DEFAULT 100000,
|
|
||||||
annual_return_percent numeric(5, 2) NOT NULL DEFAULT 5.00,
|
|
||||||
contribution_mode text NOT NULL DEFAULT 'excess',
|
|
||||||
include_recurring integer NOT NULL DEFAULT 1,
|
|
||||||
fixed_monthly_cents integer NOT NULL DEFAULT 0,
|
|
||||||
end_date date,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
ALTER TABLE money_projection_settings ADD COLUMN IF NOT EXISTS include_recurring integer NOT NULL DEFAULT 1;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS money_insurances (
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
|
||||||
name text NOT NULL,
|
|
||||||
provider text,
|
|
||||||
coverage text,
|
|
||||||
premium_cents integer,
|
|
||||||
cadence text NOT NULL DEFAULT 'monthly',
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS money_insurances_user_id_idx ON money_insurances (user_id);
|
|
||||||
|
|
||||||
ALTER TABLE money_accounts ADD COLUMN IF NOT EXISTS color text;
|
|
||||||
58
scripts/preview.sh
Executable file
58
scripts/preview.sh
Executable file
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run a branch against a throwaway copy of the dev database (itself a prod copy).
|
||||||
|
#
|
||||||
|
# scripts/preview.sh <branch> [port]
|
||||||
|
#
|
||||||
|
# Creates a git worktree in /tmp, clones DEV_DB_NAME into a temporary database
|
||||||
|
# (CREATE DATABASE ... TEMPLATE), runs that branch's Drizzle migrations on the copy,
|
||||||
|
# builds and serves it on <port> (default 5180). Ctrl-C removes the server,
|
||||||
|
# database and worktree. Prod and the dev DB itself are never modified.
|
||||||
|
#
|
||||||
|
# Uses .env from the main checkout: DB_URL (app role) and DB_MIGRATE_URL (owner role)
|
||||||
|
# pointing at the dev DB, plus DEV_ADMIN_URL (superuser, maintenance DB) for create/drop.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
branch="${1:?usage: scripts/preview.sh <branch> [port]}"
|
||||||
|
port="${2:-5180}"
|
||||||
|
repo="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$repo"
|
||||||
|
set -a
|
||||||
|
. ./.env
|
||||||
|
[[ -f .env.dev-refresh ]] && . ./.env.dev-refresh
|
||||||
|
set +a
|
||||||
|
: "${DB_URL:?}" "${DB_MIGRATE_URL:?}" "${DEV_ADMIN_URL:?set DEV_ADMIN_URL}"
|
||||||
|
DEV_DB_NAME="${DEV_DB_NAME:-personal_dev}"
|
||||||
|
|
||||||
|
safe="$(printf '%s' "$branch" | tr -c 'a-zA-Z0-9' '_' | tr 'A-Z' 'a-z' | cut -c1-40)"
|
||||||
|
db="preview_${safe}"
|
||||||
|
dir="/tmp/personal-website-preview-${safe}"
|
||||||
|
with_db() { bun -e 'const u=new URL(process.argv[1]); u.pathname="/"+process.argv[2]; console.log(u.toString())' "$1" "$2"; }
|
||||||
|
|
||||||
|
server_pid=""
|
||||||
|
cleanup() {
|
||||||
|
[[ -n "$server_pid" ]] && kill "$server_pid" 2>/dev/null || true
|
||||||
|
psql -q "$DEV_ADMIN_URL" -c "DROP DATABASE IF EXISTS \"$db\" WITH (FORCE)" || true
|
||||||
|
git -C "$repo" worktree remove --force "$dir" 2>/dev/null || true
|
||||||
|
echo "Preview cleaned up."
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
git fetch origin "$branch"
|
||||||
|
git worktree add --detach "$dir" "origin/$branch"
|
||||||
|
|
||||||
|
# TEMPLATE needs no other connections to the source; stop `bun run dev` first if it fails.
|
||||||
|
psql -v ON_ERROR_STOP=1 -q "$DEV_ADMIN_URL" -c "CREATE DATABASE \"$db\" TEMPLATE \"$DEV_DB_NAME\" OWNER personal"
|
||||||
|
|
||||||
|
export DB_URL="$(with_db "$DB_URL" "$db")"
|
||||||
|
export DB_MIGRATE_URL="$(with_db "$DB_MIGRATE_URL" "$db")"
|
||||||
|
export ORIGIN="http://localhost:$port" BETTER_AUTH_URL="http://localhost:$port" PORT="$port"
|
||||||
|
|
||||||
|
cd "$dir"
|
||||||
|
cp "$repo/.env" .env
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
bun scripts/db-migrate.ts
|
||||||
|
bun run build
|
||||||
|
echo "Preview of $branch on http://localhost:$port (Ctrl-C to stop and clean up)"
|
||||||
|
bun build/index.js &
|
||||||
|
server_pid=$!
|
||||||
|
wait "$server_pid"
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
-- Additive, repeatable journal and factual catalog expansion. Existing progress is preserved.
|
|
||||||
CREATE TABLE IF NOT EXISTS pokemon_seen (
|
|
||||||
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
|
||||||
game_id integer NOT NULL REFERENCES games(id) ON DELETE CASCADE,
|
|
||||||
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE(user_id, game_id, pokemon_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS pokemon_seen_user_idx ON pokemon_seen(user_id);
|
|
||||||
CREATE TABLE IF NOT EXISTS pokemon_research (
|
|
||||||
pokemon_id integer PRIMARY KEY REFERENCES pokemon(id) ON DELETE CASCADE, facts jsonb NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pokemon_dex_numbers (
|
|
||||||
id serial PRIMARY KEY, pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE, game_id integer NOT NULL REFERENCES games(id) ON DELETE CASCADE, dex text NOT NULL, dex_name text NOT NULL, number integer NOT NULL, UNIQUE(pokemon_id,game_id,dex)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pokemon_moves (
|
|
||||||
id integer PRIMARY KEY, name text NOT NULL, type text NOT NULL, category text NOT NULL, power integer, accuracy integer, pp integer, priority integer NOT NULL, target text NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pokemon_learnsets (
|
|
||||||
id serial PRIMARY KEY, pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE, version_group integer NOT NULL, moves jsonb NOT NULL, UNIQUE(pokemon_id,version_group)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pokemon_game_research (
|
|
||||||
game_id integer PRIMARY KEY REFERENCES games(id) ON DELETE CASCADE, version_group integer NOT NULL, regions text[] NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pokemon_journeys (
|
|
||||||
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, game_id integer NOT NULL REFERENCES games(id) ON DELETE CASCADE, stage text NOT NULL DEFAULT 'story' CHECK(stage IN ('story','postgame','collection')), location text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(user_id,game_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pokemon_catches (
|
|
||||||
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, game_id integer NOT NULL REFERENCES games(id) ON DELETE CASCADE, pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE, nickname text NOT NULL DEFAULT '', level integer NOT NULL DEFAULT 1 CHECK(level BETWEEN 1 AND 100), moves text[] NOT NULL DEFAULT '{}' CHECK(cardinality(moves)<=4), party_slot integer CHECK(party_slot BETWEEN 1 AND 6), shiny boolean NOT NULL DEFAULT false, nature text, ability text, ball text, location text, obtained text NOT NULL DEFAULT 'caught', stage text NOT NULL DEFAULT 'story' CHECK(stage IN ('story','postgame','collection')), notes text NOT NULL DEFAULT '', caught_on date, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(user_id,game_id,party_slot)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pokemon_milestones (
|
|
||||||
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, game_id integer NOT NULL REFERENCES games(id) ON DELETE CASCADE, key text NOT NULL, title text NOT NULL, category text NOT NULL, stage text NOT NULL DEFAULT 'story', done boolean NOT NULL DEFAULT false, notes text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(user_id,game_id,key)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tracker_ranks (
|
|
||||||
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, game_id integer NOT NULL REFERENCES tracker_games(id) ON DELETE CASCADE, level integer NOT NULL DEFAULT 1 CHECK(level BETWEEN 1 AND 10000), prestige integer NOT NULL DEFAULT 0 CHECK(prestige BETWEEN 0 AND 1000), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(user_id,game_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tracker_weapon_ranks (
|
|
||||||
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, item_id integer NOT NULL REFERENCES tracker_items(id) ON DELETE CASCADE, level integer NOT NULL DEFAULT 1 CHECK(level BETWEEN 1 AND 10000), prestige integer NOT NULL DEFAULT 0 CHECK(prestige BETWEEN 0 AND 1000), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(user_id,item_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tracker_challenges (
|
|
||||||
id serial PRIMARY KEY, game_id integer NOT NULL REFERENCES tracker_games(id) ON DELETE CASCADE, key text NOT NULL, name text NOT NULL, category text NOT NULL, requirement text NOT NULL, reward text, icon text, target integer, source_url text NOT NULL, UNIQUE(game_id,key)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tracker_challenge_progress (
|
|
||||||
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, challenge_id integer NOT NULL REFERENCES tracker_challenges(id) ON DELETE CASCADE, current integer NOT NULL DEFAULT 0 CHECK(current >= 0), done boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(user_id,challenge_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS osu_entries (
|
|
||||||
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, kind text NOT NULL CHECK(kind IN ('score','goal','training')), title text NOT NULL, mode text NOT NULL DEFAULT 'osu', beatmap_url text, score_url text, goal text NOT NULL DEFAULT 'pass', skill text NOT NULL DEFAULT 'stamina', bpm integer, duration integer, accuracy text, combo integer, misses integer, pp text, mods text NOT NULL DEFAULT 'NM', rate text NOT NULL DEFAULT '1.00', achieved boolean NOT NULL DEFAULT false, played_on date NOT NULL, notes text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS pokemon_dex_game_idx ON pokemon_dex_numbers(game_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS pokemon_catches_owner_idx ON pokemon_catches(user_id,game_id,pokemon_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS osu_entries_owner_idx ON osu_entries(user_id,kind);
|
|
||||||
|
|
||||||
ALTER TABLE pokemon_journeys ADD COLUMN IF NOT EXISTS completed boolean NOT NULL DEFAULT false;
|
|
||||||
-- Historical catches were necessarily seen; repeatable and non-destructive.
|
|
||||||
INSERT INTO pokemon_seen (user_id, game_id, pokemon_id)
|
|
||||||
SELECT u.user_id, e.game_id, e.pokemon_id FROM user_entries u
|
|
||||||
JOIN pokemon_game_entries e ON e.id = u.pokemon_game_entry_id
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
sha=${1:?staging commit required}
|
|
||||||
case "$sha" in
|
|
||||||
*[!0-9a-f]*|'') echo 'Invalid image tag' >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
directory=/opt/personal-website-staging
|
|
||||||
tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1)
|
|
||||||
if [ "$tag" != "$sha" ]; then
|
|
||||||
echo "Staging runs $tag, not $sha" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
image="git.stanrunge.dev/stan/personal-website:$sha"
|
|
||||||
container=$(sudo docker compose --env-file "$directory/.env" -f "$directory/compose.prod.yml" ps -q app)
|
|
||||||
expected=$(sudo docker image inspect -f '{{.Id}}' "$image")
|
|
||||||
actual=$(sudo docker inspect -f '{{.Image}}' "$container")
|
|
||||||
if [ "$actual" != "$expected" ]; then
|
|
||||||
echo 'Staging container differs from the tested image' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
curl --fail --silent --show-error https://staging.stanrunge.dev/login >/dev/null
|
|
||||||
echo "Verified staging image $sha"
|
|
||||||
Reference in New Issue
Block a user