Compare commits
20 Commits
house-trac
...
books
| Author | SHA1 | Date | |
|---|---|---|---|
| d246552951 | |||
| 2dee93f7b9 | |||
| 88837af624 | |||
| 45f2bbb198 | |||
| 36af5675e9 | |||
| 7e69d1e888 | |||
| 2915135ed8 | |||
| 4cfe6bad6c | |||
| 64fb8df262 | |||
| b8b366b542 | |||
| 6ed508702e | |||
| 46ab4a0adb | |||
| 08ab1f2716 | |||
| e039f69afd | |||
| 1ff6bb859e | |||
| 997c54133f | |||
| bdc8ac24ac | |||
| c688b874c8 | |||
| 74f64c7457 | |||
| 245137c65d |
@@ -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/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/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/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
|
||||||
```
|
```
|
||||||
|
|||||||
17
db/reorg/01_roles.sql
Normal file
17
db/reorg/01_roles.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- 01_roles.sql: cluster-level roles. Run as a superuser (postgres). Idempotent.
|
||||||
|
-- In prod: needs `sudo docker exec -i <pg-container> psql -U postgres`; set passwords separately (\password), never in this file.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
CREATE ROLE personal_app LOGIN; -- app, ned-collector, house worker
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ddl') THEN
|
||||||
|
CREATE ROLE personal_ddl NOLOGIN; -- owns schema lists; only reachable via SECURITY DEFINER fns
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||||
|
CREATE ROLE personal_ro LOGIN; -- read-only for audits/agents/pgAdmin
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
ALTER ROLE personal_ro SET default_transaction_read_only = on;
|
||||||
|
ALTER ROLE personal_app SET search_path = '$user'; -- force schema-qualified names in app SQL
|
||||||
|
ALTER ROLE personal_ro SET search_path = '$user';
|
||||||
142
db/reorg/02_schemas_move.sql
Normal file
142
db/reorg/02_schemas_move.sql
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
-- 02_schemas_move.sql: create schemas, snapshot counts, move + rename all 58 public tables.
|
||||||
|
SET ROLE personal;
|
||||||
|
|
||||||
|
CREATE SCHEMA auth; CREATE SCHEMA money; CREATE SCHEMA house; CREATE SCHEMA ref;
|
||||||
|
CREATE SCHEMA games; CREATE SCHEMA cs; CREATE SCHEMA animaldex; CREATE SCHEMA site;
|
||||||
|
CREATE SCHEMA meta; CREATE SCHEMA hardware; CREATE SCHEMA energy; CREATE SCHEMA legacy;
|
||||||
|
COMMENT ON SCHEMA legacy IS 'Pre-reorg list tables + reorg bookkeeping. Drop 30 days after cutover.';
|
||||||
|
COMMENT ON SCHEMA ref IS 'Seeded third-party catalog data (Pokemon, CoD tracker definitions). Read-only in the GUI; seeders own it.';
|
||||||
|
COMMENT ON SCHEMA games IS 'Stan''s own game progress.';
|
||||||
|
|
||||||
|
-- exact row counts of every public table before anything moves
|
||||||
|
CREATE TABLE legacy.reorg_counts_before (table_name text PRIMARY KEY, n bigint NOT NULL);
|
||||||
|
DO $$
|
||||||
|
DECLARE t text; c bigint;
|
||||||
|
BEGIN
|
||||||
|
FOR t IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
|
||||||
|
EXECUTE format('SELECT count(*) FROM public.%I', t) INTO c;
|
||||||
|
INSERT INTO legacy.reorg_counts_before VALUES (t, c);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE TABLE legacy.reorg_table_map (
|
||||||
|
old_name text PRIMARY KEY,
|
||||||
|
old_prefix text NOT NULL, -- prefix used by its index/constraint/sequence names
|
||||||
|
new_schema text NOT NULL,
|
||||||
|
new_name text NOT NULL,
|
||||||
|
UNIQUE (new_schema, new_name)
|
||||||
|
);
|
||||||
|
INSERT INTO legacy.reorg_table_map (old_name, old_prefix, new_schema, new_name) VALUES
|
||||||
|
('user','user','auth','user'), ('session','session','auth','session'),
|
||||||
|
('account','account','auth','account'), ('verification','verification','auth','verification'),
|
||||||
|
('money_accounts','money_accounts','money','accounts'), ('money_transactions','money_transactions','money','transactions'),
|
||||||
|
('money_recurring','money_recurring','money','recurring'), ('money_forecasts','money_forecasts','money','forecasts'),
|
||||||
|
('money_insurances','money_insurances','money','insurances'), ('money_wishlist','money_wishlist','money','wishlist'),
|
||||||
|
('money_projection_settings','money_projection_settings','money','projection_settings'),
|
||||||
|
('money_utility_settings','money_utility_settings','money','utility_settings'),
|
||||||
|
('money_utility_readings','money_utility_readings','money','utility_readings'),
|
||||||
|
('house_listings','house_listings','house','listings'), ('house_price_history','house_price_history','house','price_history'),
|
||||||
|
('house_searches','house_searches','house','searches'), ('house_worker_runs','house_worker_runs','house','worker_runs'),
|
||||||
|
('house_alert_rules','house_alert_rules','house','alert_rules'),
|
||||||
|
('pokemon','pokemon','ref','pokemon'), ('games','game','ref','pokemon_games'),
|
||||||
|
('pokemon_game_entries','pokemon_game_entry','ref','pokemon_game_entries'),
|
||||||
|
('pokemon_locations','pokemon_locations','ref','pokemon_locations'), ('pokemon_encounters','pokemon_encounters','ref','pokemon_encounters'),
|
||||||
|
('pokemon_evolutions','pokemon_evolutions','ref','pokemon_evolutions'), ('pokemon_dex_numbers','pokemon_dex_numbers','ref','pokemon_dex_numbers'),
|
||||||
|
('pokemon_moves','pokemon_moves','ref','pokemon_moves'), ('pokemon_learnsets','pokemon_learnsets','ref','pokemon_learnsets'),
|
||||||
|
('pokemon_research','pokemon_research','ref','pokemon_research'), ('pokemon_game_research','pokemon_game_research','ref','pokemon_game_research'),
|
||||||
|
('tracker_games','tracker_games','ref','tracker_games'), ('tracker_items','tracker_items','ref','tracker_items'),
|
||||||
|
('tracker_challenges','tracker_challenges','ref','tracker_challenges'),
|
||||||
|
('user_games','user_game','games','catch_em_all_games'), ('user_entries','user_entry','games','catch_em_all_entries'),
|
||||||
|
('catch_em_all_settings','catch_em_all_settings','games','catch_em_all_settings'),
|
||||||
|
('pokemon_journeys','pokemon_journeys','games','pokemon_journeys'), ('pokemon_seen','pokemon_seen','games','pokemon_seen'),
|
||||||
|
('pokemon_catches','pokemon_catches','games','pokemon_catches'), ('pokemon_milestones','pokemon_milestones','games','pokemon_milestones'),
|
||||||
|
('tracker_progress','tracker_progress','games','tracker_progress'), ('tracker_ranks','tracker_ranks','games','tracker_ranks'),
|
||||||
|
('tracker_weapon_ranks','tracker_weapon_ranks','games','tracker_weapon_ranks'),
|
||||||
|
('tracker_challenge_progress','tracker_challenge_progress','games','tracker_challenge_progress'),
|
||||||
|
('osu_entries','osu_entries','games','osu_entries'),
|
||||||
|
('cs_market_items','cs_market_items','cs','items'), ('cs_price_observations','cs_price_observations','cs','price_observations'),
|
||||||
|
('cs_item_dimensions','cs_item_dimensions','cs','item_dimensions'), ('cs_price_streams','cs_price_streams','cs','price_streams'),
|
||||||
|
('animals','animals','animaldex','animals'), ('sightings','sightings','animaldex','sightings'),
|
||||||
|
('photos','photos','animaldex','sighting_photos'),
|
||||||
|
('blog_articles','blog_articles','site','blog_articles'), ('tasks','tasks','site','tasks'),
|
||||||
|
('topics','topics','site','topics'), ('tasks_to_topics','tasks_to_topics','site','task_topics'),
|
||||||
|
('lists','lists','legacy','lists'), ('list_items','list_items','legacy','list_items'),
|
||||||
|
('list_item_completions','list_item_completions','legacy','list_item_completions');
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
missing text;
|
||||||
|
BEGIN
|
||||||
|
SELECT string_agg(tablename, ', ') INTO missing FROM pg_tables
|
||||||
|
WHERE schemaname = 'public' AND tablename NOT IN (SELECT old_name FROM legacy.reorg_table_map);
|
||||||
|
IF missing IS NOT NULL THEN RAISE EXCEPTION 'unmapped public tables: %', missing; END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- types + shared trigger function
|
||||||
|
ALTER TYPE public.availability SET SCHEMA ref;
|
||||||
|
ALTER TYPE public.blog_article_status SET SCHEMA site;
|
||||||
|
ALTER FUNCTION public.site_touch_updated_at() SET SCHEMA meta;
|
||||||
|
ALTER FUNCTION meta.site_touch_updated_at() RENAME TO touch_updated_at;
|
||||||
|
|
||||||
|
-- Duplicate indexes (hand SQL + Drizzle both created one). Keep Drizzle's `_index` / constraint-backed `_key`.
|
||||||
|
DROP INDEX public.games_source_id_unique; -- dup of constraint games_source_id_key
|
||||||
|
DROP INDEX public.pokemon_number_unique; -- dup of constraint pokemon_number_key
|
||||||
|
DROP INDEX public.catch_em_all_settings_user_id_unique;-- dup of constraint ..._user_id_key
|
||||||
|
DROP INDEX public.money_accounts_user_id_idx;
|
||||||
|
DROP INDEX public.money_forecasts_user_date_idx;
|
||||||
|
DROP INDEX public.money_insurances_user_id_idx;
|
||||||
|
DROP INDEX public.money_recurring_user_id_idx;
|
||||||
|
DROP INDEX public.money_transactions_account_id_idx;
|
||||||
|
DROP INDEX public.money_transactions_user_date_idx;
|
||||||
|
DROP INDEX public.money_wishlist_user_id_idx;
|
||||||
|
DROP INDEX public.lists_slug_unique; -- dup of lists_slug_key (legacy table, tidy anyway)
|
||||||
|
|
||||||
|
-- Move + rename tables; rename their constraints, indexes and owned sequences to the new prefix.
|
||||||
|
CREATE FUNCTION pg_temp.reprefix(name text, old_name text, old_prefix text, new_name text) RETURNS text
|
||||||
|
LANGUAGE sql IMMUTABLE AS $$
|
||||||
|
SELECT CASE
|
||||||
|
WHEN name LIKE old_name || '\_%' THEN new_name || substr(name, length(old_name) + 1)
|
||||||
|
WHEN name LIKE old_prefix || '\_%' THEN new_name || substr(name, length(old_prefix) + 1)
|
||||||
|
ELSE NULL END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
m record; r record; tbl regclass; newn text;
|
||||||
|
BEGIN
|
||||||
|
FOR m IN SELECT * FROM legacy.reorg_table_map ORDER BY old_name LOOP
|
||||||
|
EXECUTE format('ALTER TABLE public.%I SET SCHEMA %I', m.old_name, m.new_schema);
|
||||||
|
IF m.old_name <> m.new_name THEN
|
||||||
|
EXECUTE format('ALTER TABLE %I.%I RENAME TO %I', m.new_schema, m.old_name, m.new_name);
|
||||||
|
END IF;
|
||||||
|
tbl := format('%I.%I', m.new_schema, m.new_name)::regclass;
|
||||||
|
-- constraints (renaming a pk/unique constraint renames its index too)
|
||||||
|
FOR r IN SELECT conname AS n FROM pg_constraint WHERE conrelid = tbl LOOP
|
||||||
|
newn := pg_temp.reprefix(r.n, m.old_name, m.old_prefix, m.new_name);
|
||||||
|
IF newn IS NOT NULL AND newn <> r.n THEN
|
||||||
|
EXECUTE format('ALTER TABLE %s RENAME CONSTRAINT %I TO %I', tbl, r.n, newn);
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
-- remaining plain indexes
|
||||||
|
FOR r IN SELECT i.relname AS n FROM pg_index x JOIN pg_class i ON i.oid = x.indexrelid
|
||||||
|
WHERE x.indrelid = tbl
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = x.indexrelid AND c.conrelid = tbl) LOOP
|
||||||
|
newn := pg_temp.reprefix(r.n, m.old_name, m.old_prefix, m.new_name);
|
||||||
|
IF newn IS NOT NULL AND newn <> r.n THEN
|
||||||
|
EXECUTE format('ALTER INDEX %I.%I RENAME TO %I', m.new_schema, r.n, newn);
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
-- owned sequences
|
||||||
|
FOR r IN SELECT s.relname AS n FROM pg_class s JOIN pg_depend d ON d.objid = s.oid AND d.deptype IN ('a','i')
|
||||||
|
WHERE s.relkind = 'S' AND d.refobjid = tbl LOOP
|
||||||
|
newn := pg_temp.reprefix(r.n, m.old_name, m.old_prefix, m.new_name);
|
||||||
|
IF newn IS NOT NULL AND newn <> r.n THEN
|
||||||
|
EXECUTE format('ALTER SEQUENCE %I.%I RENAME TO %I', m.new_schema, r.n, newn);
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END LOOP;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public') THEN
|
||||||
|
RAISE EXCEPTION 'public still has tables';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
RESET ROLE;
|
||||||
32
db/reorg/03_fixes.sql
Normal file
32
db/reorg/03_fixes.sql
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
-- 03_fixes.sql: missing PKs, type fixes, CHECKs, updated_at columns.
|
||||||
|
SET ROLE personal;
|
||||||
|
|
||||||
|
-- missing primary keys (promote the existing unique indexes)
|
||||||
|
ALTER TABLE cs.item_dimensions ADD CONSTRAINT item_dimensions_pkey PRIMARY KEY USING INDEX item_dimensions_unique_idx;
|
||||||
|
ALTER TABLE cs.price_streams ADD CONSTRAINT price_streams_pkey PRIMARY KEY USING INDEX price_streams_unique_idx;
|
||||||
|
ALTER TABLE house.price_history ADD CONSTRAINT price_history_pkey PRIMARY KEY (listing_id, observed_at);
|
||||||
|
DROP INDEX house.price_history_listing_idx; -- now covered by the PK
|
||||||
|
|
||||||
|
-- include_recurring was an int used as a boolean (app code: charts.ts `!== 0`)
|
||||||
|
ALTER TABLE money.projection_settings ALTER COLUMN include_recurring DROP DEFAULT;
|
||||||
|
ALTER TABLE money.projection_settings ALTER COLUMN include_recurring TYPE boolean USING include_recurring <> 0;
|
||||||
|
ALTER TABLE money.projection_settings ALTER COLUMN include_recurring SET DEFAULT true;
|
||||||
|
|
||||||
|
-- osu numbers stored as text (0 rows in prod)
|
||||||
|
ALTER TABLE games.osu_entries ALTER COLUMN pp TYPE numeric USING nullif(pp, '')::numeric;
|
||||||
|
ALTER TABLE games.osu_entries ALTER COLUMN accuracy TYPE numeric USING nullif(replace(accuracy, '%', ''), '')::numeric;
|
||||||
|
ALTER TABLE games.osu_entries ALTER COLUMN rate DROP DEFAULT;
|
||||||
|
ALTER TABLE games.osu_entries ALTER COLUMN rate TYPE numeric(4,2) USING nullif(rate, '')::numeric;
|
||||||
|
ALTER TABLE games.osu_entries ALTER COLUMN rate SET DEFAULT 1.00;
|
||||||
|
|
||||||
|
-- CHECKs matching the values the app already enforces (src/lib/server/money-tracker.ts, money page)
|
||||||
|
ALTER TABLE money.recurring ADD CONSTRAINT recurring_cadence_check CHECK (cadence IN ('weekly','monthly','yearly'));
|
||||||
|
ALTER TABLE money.insurances ADD CONSTRAINT insurances_cadence_check CHECK (cadence IN ('weekly','monthly','yearly'));
|
||||||
|
ALTER TABLE money.accounts ADD CONSTRAINT accounts_kind_check CHECK (kind IN ('spendable','investment'));
|
||||||
|
ALTER TABLE money.utility_settings ADD CONSTRAINT utility_settings_kind_check CHECK (kind IN ('electricity','gas','water'));
|
||||||
|
ALTER TABLE money.utility_readings ADD CONSTRAINT utility_readings_kind_check CHECK (kind IN ('electricity','gas','water'));
|
||||||
|
ALTER TABLE money.projection_settings ADD CONSTRAINT projection_settings_contribution_mode_check CHECK (contribution_mode IN ('excess','fixed'));
|
||||||
|
|
||||||
|
-- user-editable tables without updated_at
|
||||||
|
ALTER TABLE house.alert_rules ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
RESET ROLE;
|
||||||
224
db/reorg/04_meta.sql
Normal file
224
db/reorg/04_meta.sql
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
-- 04_meta.sql: GUI registry (meta.tables / meta.columns), runtime-DDL functions, schema `lists`.
|
||||||
|
SET ROLE personal;
|
||||||
|
|
||||||
|
CREATE TABLE meta.tables (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
schema_name text NOT NULL,
|
||||||
|
table_name text NOT NULL,
|
||||||
|
slug text NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
|
||||||
|
display_name text NOT NULL,
|
||||||
|
description text,
|
||||||
|
icon text,
|
||||||
|
picture_url text,
|
||||||
|
kind text NOT NULL CHECK (kind IN ('list','core','ref')),
|
||||||
|
editable boolean NOT NULL DEFAULT true,
|
||||||
|
has_done boolean NOT NULL DEFAULT false,
|
||||||
|
default_sort text,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0,
|
||||||
|
archived_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (schema_name, table_name)
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE meta.tables IS 'One row per GUI-visible table (runtime lists + core + ref). Structure truth stays in pg_catalog; this is presentation.';
|
||||||
|
|
||||||
|
CREATE TABLE meta.columns (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
table_id integer NOT NULL REFERENCES meta.tables(id) ON DELETE CASCADE,
|
||||||
|
column_name text NOT NULL,
|
||||||
|
label text,
|
||||||
|
ui_type text NOT NULL CHECK (ui_type IN ('text','longtext','url','image','number','cents','date','datetime','bool','select','multiselect','fk','json')),
|
||||||
|
options jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
position integer NOT NULL DEFAULT 0,
|
||||||
|
hidden boolean NOT NULL DEFAULT false,
|
||||||
|
help_text text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (table_id, column_name)
|
||||||
|
);
|
||||||
|
CREATE INDEX columns_table_id_index ON meta.columns (table_id);
|
||||||
|
|
||||||
|
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON meta.tables FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at();
|
||||||
|
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON meta.columns FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at();
|
||||||
|
|
||||||
|
RESET ROLE;
|
||||||
|
-- schema for runtime lists, owned by personal_ddl (not by the migration owner)
|
||||||
|
CREATE SCHEMA lists AUTHORIZATION personal_ddl;
|
||||||
|
COMMENT ON SCHEMA lists IS 'Runtime-created user lists. Never touched by Drizzle migrations (excluded via schemaFilter). Create/alter only via meta.* functions.';
|
||||||
|
GRANT USAGE, CREATE ON SCHEMA meta TO personal_ddl;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON meta.tables, meta.columns TO personal_ddl;
|
||||||
|
SET ROLE personal_ddl;
|
||||||
|
|
||||||
|
-- guess a ui_type from a column's catalog type (format_type output) and name
|
||||||
|
CREATE FUNCTION meta.guess_ui_type(p_type text, p_col text, p_is_enum boolean DEFAULT false) RETURNS text
|
||||||
|
LANGUAGE sql IMMUTABLE SET search_path = pg_catalog AS $$
|
||||||
|
SELECT CASE
|
||||||
|
WHEN p_is_enum THEN 'select'
|
||||||
|
WHEN p_type LIKE '%[]' THEN 'multiselect'
|
||||||
|
WHEN p_col IN ('picture_url','image_url','icon_url','image') THEN 'image'
|
||||||
|
WHEN p_col LIKE '%\_url' OR p_col = 'url' THEN 'url'
|
||||||
|
WHEN p_col LIKE '%\_cents' THEN 'cents'
|
||||||
|
WHEN p_col IN ('notes','description','content','note') THEN 'longtext'
|
||||||
|
WHEN p_type IN ('smallint','integer','bigint','real','double precision') OR p_type LIKE 'numeric%' THEN 'number'
|
||||||
|
WHEN p_type = 'boolean' THEN 'bool'
|
||||||
|
WHEN p_type = 'date' THEN 'date'
|
||||||
|
WHEN p_type LIKE 'timestamp%' THEN 'datetime'
|
||||||
|
WHEN p_type IN ('json','jsonb') THEN 'json'
|
||||||
|
ELSE 'text' END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- reconcile meta.columns with pg_catalog for one table (or all registered tables).
|
||||||
|
-- Uses pg_attribute (not information_schema, which hides tables the caller has no grants on).
|
||||||
|
CREATE FUNCTION meta.sync(p_table_id integer DEFAULT NULL) RETURNS integer
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
|
||||||
|
DECLARE k integer;
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('pg_temp._meta_cols') IS NULL THEN CREATE TEMP TABLE _meta_cols ON COMMIT DROP AS SELECT 1::integer AS table_id, ''::text AS col, ''::text AS typ, false AS is_enum, 0::integer AS pos, NULL::jsonb AS vals LIMIT 0; END IF;
|
||||||
|
TRUNCATE _meta_cols;
|
||||||
|
INSERT INTO _meta_cols
|
||||||
|
SELECT t.id, a.attname, format_type(a.atttypid, a.atttypmod), ty.typtype = 'e', a.attnum,
|
||||||
|
CASE WHEN ty.typtype = 'e' THEN (SELECT jsonb_agg(e.enumlabel ORDER BY e.enumsortorder) FROM pg_enum e WHERE e.enumtypid = ty.oid) END
|
||||||
|
FROM meta.tables t
|
||||||
|
JOIN pg_class c ON c.relname = t.table_name
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = t.schema_name
|
||||||
|
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
|
||||||
|
JOIN pg_type ty ON ty.oid = a.atttypid
|
||||||
|
WHERE p_table_id IS NULL OR t.id = p_table_id;
|
||||||
|
DELETE FROM meta.columns mc
|
||||||
|
WHERE (p_table_id IS NULL OR mc.table_id = p_table_id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM _meta_cols x WHERE x.table_id = mc.table_id AND x.col = mc.column_name);
|
||||||
|
INSERT INTO meta.columns (table_id, column_name, label, ui_type, options, position, hidden)
|
||||||
|
SELECT x.table_id, x.col, initcap(replace(x.col, '_', ' ')), meta.guess_ui_type(x.typ, x.col, x.is_enum),
|
||||||
|
CASE WHEN x.vals IS NOT NULL THEN jsonb_build_object('values', x.vals) ELSE '{}'::jsonb END,
|
||||||
|
x.pos, x.col IN ('id','created_at','updated_at','user_id','sort_order')
|
||||||
|
FROM _meta_cols x
|
||||||
|
ON CONFLICT (table_id, column_name) DO NOTHING;
|
||||||
|
GET DIAGNOSTICS k = ROW_COUNT;
|
||||||
|
RETURN k;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- allowed column types for runtime lists -> SQL type
|
||||||
|
CREATE FUNCTION meta.sql_type(p_ui_type text) RETURNS text
|
||||||
|
LANGUAGE sql IMMUTABLE SET search_path = pg_catalog AS $$
|
||||||
|
SELECT CASE p_ui_type
|
||||||
|
WHEN 'text' THEN 'text' WHEN 'longtext' THEN 'text' WHEN 'url' THEN 'text' WHEN 'image' THEN 'text'
|
||||||
|
WHEN 'select' THEN 'text' WHEN 'number' THEN 'numeric' WHEN 'cents' THEN 'integer'
|
||||||
|
WHEN 'date' THEN 'date' WHEN 'datetime' THEN 'timestamptz' WHEN 'bool' THEN 'boolean'
|
||||||
|
WHEN 'multiselect' THEN 'text[]' WHEN 'json' THEN 'jsonb' END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION meta.assert_ident(p text) RETURNS void
|
||||||
|
LANGUAGE plpgsql IMMUTABLE SET search_path = pg_catalog AS $$
|
||||||
|
BEGIN
|
||||||
|
IF p IS NULL OR p !~ '^[a-z][a-z0-9_]{0,62}$' THEN
|
||||||
|
RAISE EXCEPTION 'invalid identifier: %', p USING ERRCODE = '22023';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- add one column to a runtime list. p_col: {"name","ui_type","label"?,"options"?,"default"?}
|
||||||
|
CREATE FUNCTION meta.add_column(p_slug text, p_col jsonb) RETURNS void
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
|
||||||
|
DECLARE
|
||||||
|
t meta.tables; c text := p_col->>'name'; u text := coalesce(p_col->>'ui_type', 'text');
|
||||||
|
st text := meta.sql_type(u); vals jsonb := p_col->'options'->'values'; ddl text;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO t FROM meta.tables WHERE slug = p_slug AND kind = 'list';
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'no runtime list %', p_slug; END IF;
|
||||||
|
PERFORM meta.assert_ident(c);
|
||||||
|
IF c IN ('id','name','notes','picture_url','done_at','sort_order','created_at','updated_at') THEN
|
||||||
|
RAISE EXCEPTION 'column % is a standard column', c; END IF;
|
||||||
|
IF st IS NULL THEN RAISE EXCEPTION 'unsupported ui_type %', u; END IF;
|
||||||
|
ddl := format('ALTER TABLE lists.%I ADD COLUMN %I %s', t.table_name, c, st);
|
||||||
|
IF p_col ? 'default' THEN ddl := ddl || format(' DEFAULT %L', p_col->>'default'); END IF;
|
||||||
|
IF u = 'select' AND jsonb_typeof(vals) = 'array' THEN
|
||||||
|
ddl := ddl || format(' CONSTRAINT %I CHECK (%I IN (%s))', t.table_name || '_' || c || '_check', c,
|
||||||
|
(SELECT string_agg(quote_literal(v), ',') FROM jsonb_array_elements_text(vals) v));
|
||||||
|
END IF;
|
||||||
|
EXECUTE ddl;
|
||||||
|
INSERT INTO meta.columns (table_id, column_name, label, ui_type, options, position)
|
||||||
|
VALUES (t.id, c, coalesce(p_col->>'label', initcap(replace(c, '_', ' '))), u,
|
||||||
|
coalesce(p_col->'options', '{}'::jsonb),
|
||||||
|
(SELECT coalesce(max(position), 0) + 1 FROM meta.columns WHERE table_id = t.id));
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- create a runtime list table with the standard columns + extras
|
||||||
|
CREATE FUNCTION meta.create_list(
|
||||||
|
p_slug text, p_table text, p_display_name text,
|
||||||
|
p_has_done boolean DEFAULT true, p_columns jsonb DEFAULT '[]'::jsonb,
|
||||||
|
p_picture_url text DEFAULT NULL, p_description text DEFAULT NULL, p_icon text DEFAULT NULL,
|
||||||
|
p_sort_order integer DEFAULT 0
|
||||||
|
) RETURNS integer
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
|
||||||
|
DECLARE tid integer; col jsonb;
|
||||||
|
BEGIN
|
||||||
|
PERFORM meta.assert_ident(p_table);
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'lists' AND tablename = p_table) THEN
|
||||||
|
RAISE EXCEPTION 'lists.% already exists', p_table; END IF;
|
||||||
|
EXECUTE format($f$CREATE TABLE lists.%1$I (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL CHECK (btrim(name) <> ''),
|
||||||
|
notes text,
|
||||||
|
picture_url text,
|
||||||
|
%2$s
|
||||||
|
sort_order integer NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now())$f$,
|
||||||
|
p_table, CASE WHEN p_has_done THEN 'done_at timestamptz,' ELSE '' END);
|
||||||
|
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON lists.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', p_table);
|
||||||
|
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, description, icon, picture_url, kind, editable, has_done, default_sort, sort_order)
|
||||||
|
VALUES ('lists', p_table, p_slug, p_display_name, p_description, p_icon, p_picture_url, 'list', true, p_has_done,
|
||||||
|
CASE WHEN p_has_done THEN 'done_at nulls first, sort_order, name' ELSE 'sort_order, name' END, p_sort_order)
|
||||||
|
RETURNING id INTO tid;
|
||||||
|
PERFORM meta.sync(tid);
|
||||||
|
UPDATE meta.columns SET label = 'Done', ui_type = 'datetime' WHERE table_id = tid AND column_name = 'done_at';
|
||||||
|
FOR col IN SELECT * FROM jsonb_array_elements(p_columns) LOOP
|
||||||
|
PERFORM meta.add_column(p_slug, col);
|
||||||
|
END LOOP;
|
||||||
|
RETURN tid;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE FUNCTION meta.rename_column(p_slug text, p_old text, p_new text) RETURNS void
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
|
||||||
|
DECLARE t meta.tables;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO t FROM meta.tables WHERE slug = p_slug AND kind = 'list';
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'no runtime list %', p_slug; END IF;
|
||||||
|
PERFORM meta.assert_ident(p_new);
|
||||||
|
IF p_old IN ('id','name','done_at','created_at','updated_at') THEN RAISE EXCEPTION 'standard column'; END IF;
|
||||||
|
EXECUTE format('ALTER TABLE lists.%I RENAME COLUMN %I TO %I', t.table_name, p_old, p_new);
|
||||||
|
UPDATE meta.columns SET column_name = p_new WHERE table_id = t.id AND column_name = p_old;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE FUNCTION meta.drop_column(p_slug text, p_col text) RETURNS void
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
|
||||||
|
DECLARE t meta.tables;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO t FROM meta.tables WHERE slug = p_slug AND kind = 'list';
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'no runtime list %', p_slug; END IF;
|
||||||
|
IF p_col IN ('id','name','notes','picture_url','done_at','sort_order','created_at','updated_at') THEN
|
||||||
|
RAISE EXCEPTION 'standard column'; END IF;
|
||||||
|
EXECUTE format('ALTER TABLE lists.%I DROP COLUMN %I', t.table_name, p_col);
|
||||||
|
DELETE FROM meta.columns WHERE table_id = t.id AND column_name = p_col;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE FUNCTION meta.archive_list(p_slug text) RETURNS void
|
||||||
|
LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
|
||||||
|
UPDATE meta.tables SET archived_at = now() WHERE slug = p_slug AND kind = 'list' AND archived_at IS NULL
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- real DROP only after 7 days in the archive (backups cover the rest)
|
||||||
|
CREATE FUNCTION meta.drop_list(p_slug text) RETURNS void
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
|
||||||
|
DECLARE t meta.tables;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO t FROM meta.tables WHERE slug = p_slug AND kind = 'list';
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'no runtime list %', p_slug; END IF;
|
||||||
|
IF t.archived_at IS NULL OR t.archived_at > now() - interval '7 days' THEN
|
||||||
|
RAISE EXCEPTION 'archive % first and wait 7 days', p_slug; END IF;
|
||||||
|
EXECUTE format('DROP TABLE lists.%I', t.table_name);
|
||||||
|
DELETE FROM meta.tables WHERE id = t.id;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
RESET ROLE;
|
||||||
|
REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA meta FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION meta.touch_updated_at() TO PUBLIC; -- trigger fn, must stay callable
|
||||||
176
db/reorg/05_lists.sql
Normal file
176
db/reorg/05_lists.sql
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
-- 05_lists.sql: the 22 legacy lists become real tables in schema `lists`.
|
||||||
|
-- legacy.* stays untouched (rollback reference); typo fixes + dedupes are applied to a temp working copy.
|
||||||
|
-- Runs as superuser (inserts into personal_ddl-owned tables); tables are created via meta.create_list.
|
||||||
|
|
||||||
|
CREATE TEMP TABLE src ON COMMIT DROP AS
|
||||||
|
SELECT i.id, i.list_id, btrim(i.name) AS name, nullif(btrim(i.description), '') AS notes, i.picture_url,
|
||||||
|
c.created_at AS done_at, i.created_at, i.updated_at
|
||||||
|
FROM legacy.list_items i
|
||||||
|
LEFT JOIN legacy.list_item_completions c ON c.item_id = i.id;
|
||||||
|
|
||||||
|
CREATE TEMP TABLE fix_log (item_id integer, list_id integer, action text, before text, after text) ON COMMIT DROP;
|
||||||
|
|
||||||
|
-- ---- dedupes: keep the lower id, carry over done_at / picture / notes, delete the other ----
|
||||||
|
CREATE TEMP TABLE dedupe (keep_id integer, drop_id integer, new_name text) ON COMMIT DROP;
|
||||||
|
INSERT INTO dedupe VALUES
|
||||||
|
(165, 323, NULL), -- places: New York x2
|
||||||
|
(168, 331, NULL), -- places: France x2
|
||||||
|
(156, 271, 'Advanced Programming in the UNIX Environment'), -- books: "...UNIX® Environment" dup
|
||||||
|
(139, 287, 'Building Knowledge Graphs: A Practitioner''s Guide'); -- books: short + full title
|
||||||
|
UPDATE src k SET done_at = coalesce(k.done_at, d.done_at), picture_url = coalesce(k.picture_url, d.picture_url),
|
||||||
|
notes = coalesce(k.notes, d.notes), name = coalesce(x.new_name, k.name)
|
||||||
|
FROM dedupe x JOIN src d ON d.id = x.drop_id WHERE k.id = x.keep_id;
|
||||||
|
INSERT INTO fix_log SELECT s.id, s.list_id, 'dedupe-drop', s.name, 'merged into ' || x.keep_id FROM src s JOIN dedupe x ON x.drop_id = s.id;
|
||||||
|
DELETE FROM src WHERE id IN (SELECT drop_id FROM dedupe);
|
||||||
|
|
||||||
|
-- ---- typo / naming fixes ----
|
||||||
|
CREATE TEMP TABLE renames (id integer PRIMARY KEY, new_name text NOT NULL) ON COMMIT DROP;
|
||||||
|
INSERT INTO renames VALUES
|
||||||
|
-- vinyls (kept as "Artist - Album" here; split below)
|
||||||
|
(343, 'Pearl Jam - Ten'), (346, 'Amy Winehouse - Back to Black'),
|
||||||
|
(27, 'Slipknot - Vol. 3: (The Subliminal Verses)'), (6, 'Them Crooked Vultures - Them Crooked Vultures'),
|
||||||
|
(39, 'Various Artists - Katamari Damacy (OST)'),
|
||||||
|
(40, 'Gojira - The Way of All Flesh'), (370, 'Gojira - The Link'),
|
||||||
|
(80, 'Deftones - Private Music'), (308, 'Kendrick Lamar - DAMN.'),
|
||||||
|
-- movies/shows
|
||||||
|
(98, 'Kim''s Convenience'), (394, 'Azuki-chan'), (111, 'A Minecraft Movie'),
|
||||||
|
-- books
|
||||||
|
(150, 'Domain-Driven Design: Tackling Complexity in the Heart of Software'),
|
||||||
|
(285, 'Molecular Biology of the Cell'), (288, 'Building Microservices'),
|
||||||
|
(147, 'Scrum: The Art of Doing Twice the Work in Half the Time'),
|
||||||
|
-- places
|
||||||
|
(325, 'Curaçao'),
|
||||||
|
-- cars
|
||||||
|
(322, 'Mazda MX-5 Miata'),
|
||||||
|
-- leetcode
|
||||||
|
(217, 'Two Sum'),
|
||||||
|
-- foods
|
||||||
|
(221, 'Sotteok Sotteok'), (230, 'Bibim Naengmyeon'), (240, 'Doenjang Jjigae'), (241, 'Kimchi Jjigae'),
|
||||||
|
-- papers
|
||||||
|
(190, 'Order Matters: Sequence to Sequence for Sets'),
|
||||||
|
(200, 'GPipe: Easy Scaling with Micro-Batch Pipeline Parallelism'),
|
||||||
|
(203, 'Attention Is All You Need'),
|
||||||
|
(201, 'Deep Speech 2: End-to-End Speech Recognition in English and Mandarin'),
|
||||||
|
-- games
|
||||||
|
(385, 'Chrono Trigger');
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF (SELECT count(*) FROM renames r JOIN src s ON s.id = r.id) <> (SELECT count(*) FROM renames) THEN
|
||||||
|
RAISE EXCEPTION 'rename list references missing item ids'; END IF;
|
||||||
|
END $$;
|
||||||
|
INSERT INTO fix_log SELECT s.id, s.list_id, 'rename', s.name, r.new_name FROM src s JOIN renames r ON r.id = s.id WHERE s.name <> r.new_name;
|
||||||
|
UPDATE src s SET name = r.new_name FROM renames r WHERE r.id = s.id;
|
||||||
|
|
||||||
|
-- helper: copy standard columns for one list into lists.<t> and return the row count
|
||||||
|
CREATE FUNCTION pg_temp.copy_std(p_list integer, p_table text, p_has_done boolean) RETURNS bigint LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE n bigint;
|
||||||
|
BEGIN
|
||||||
|
EXECUTE format('INSERT INTO lists.%I (id, name, notes, picture_url, %s sort_order, created_at, updated_at)
|
||||||
|
SELECT id, name, notes, picture_url, %s row_number() OVER (ORDER BY id), created_at, updated_at
|
||||||
|
FROM src WHERE list_id = %s', p_table,
|
||||||
|
CASE WHEN p_has_done THEN 'done_at,' ELSE '' END,
|
||||||
|
CASE WHEN p_has_done THEN 'done_at,' ELSE '' END, p_list);
|
||||||
|
GET DIAGNOSTICS n = ROW_COUNT;
|
||||||
|
EXECUTE format('SELECT setval(pg_get_serial_sequence(%L, ''id''), (SELECT max(id) FROM lists.%I))', 'lists.' || p_table, p_table);
|
||||||
|
RETURN n;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE TEMP TABLE list_map (list_id integer PRIMARY KEY, tbl text NOT NULL, has_done boolean NOT NULL, cols jsonb NOT NULL) ON COMMIT DROP;
|
||||||
|
INSERT INTO list_map VALUES
|
||||||
|
(1, 'vinyls', true, '[{"name":"artist","ui_type":"text"}]'),
|
||||||
|
(2, 'watchlist', true, '[{"name":"kind","ui_type":"select","options":{"values":["movie","show","anime","documentary"]}}]'),
|
||||||
|
(3, 'books', true, '[{"name":"author","ui_type":"text"}]'),
|
||||||
|
(4, 'side_projects', false, '[{"name":"status","ui_type":"select","default":"idea","options":{"values":["idea","active","paused","done","dropped"]}}]'),
|
||||||
|
(5, 'artists_to_see', true, '[]'),
|
||||||
|
(6, 'osu_maps', true, '[{"name":"mods","ui_type":"text"},{"name":"goal","ui_type":"select","options":{"values":["pass","fc"]}}]'),
|
||||||
|
(7, 'animals_to_spot', true, '[]'),
|
||||||
|
(8, 'vehicles_to_spot', true, '[{"name":"kind","ui_type":"select","default":"car","options":{"values":["car","train","plane"]}}]'),
|
||||||
|
(9, 'leetcode', true, '[{"name":"kind","ui_type":"select","default":"problem","options":{"values":["problem","topic"]}},{"name":"url","ui_type":"url"}]'),
|
||||||
|
(10, 'restaurants', true, '[{"name":"city","ui_type":"text"}]'),
|
||||||
|
(11, 'recipes', true, '[{"name":"cuisine","ui_type":"text"},{"name":"recipe_url","ui_type":"url"}]'),
|
||||||
|
(12, 'places', true, '[{"name":"kind","ui_type":"select","options":{"values":["country","region","city","island"]}}]'),
|
||||||
|
(13, 'papers', false, '[{"name":"authors","ui_type":"text"},{"name":"year","ui_type":"number"},{"name":"url","ui_type":"url"}]'),
|
||||||
|
(14, 'piano_songs', true, '[{"name":"artist","ui_type":"text"},{"name":"reference_url","ui_type":"url"}]'),
|
||||||
|
(15, 'games_to_complete', true, '[{"name":"platform","ui_type":"text"}]'),
|
||||||
|
(22, 'things_to_learn', true, '[]'),
|
||||||
|
(23, 'build_from_scratch', true, '[]'),
|
||||||
|
(24, 'tools_to_try', true, '[{"name":"url","ui_type":"url"}]'),
|
||||||
|
(25, 'setup_upgrades', true, '[]'),
|
||||||
|
(26, 'bucket_list', true, '[]'),
|
||||||
|
(27, 'docs_to_read', true, '[{"name":"rfc_number","ui_type":"number"},{"name":"topic","ui_type":"text"},{"name":"starter_pack","ui_type":"bool","default":"false"},{"name":"url","ui_type":"url"}]'),
|
||||||
|
(28, 'banger_songs', false, '[{"name":"artist","ui_type":"text"},{"name":"kind","ui_type":"select","default":"song","options":{"values":["song","playlist"]}},{"name":"url","ui_type":"url"}]');
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM legacy.lists l WHERE NOT EXISTS (SELECT 1 FROM list_map m WHERE m.list_id = l.id))
|
||||||
|
OR EXISTS (SELECT 1 FROM list_map m JOIN legacy.lists l ON l.id = m.list_id WHERE l.is_progress <> m.has_done AND m.list_id NOT IN (4)) THEN
|
||||||
|
RAISE EXCEPTION 'list_map does not cover legacy.lists / has_done mismatch';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- create tables (as personal_ddl via SECURITY DEFINER) and copy standard columns
|
||||||
|
DO $$
|
||||||
|
DECLARE m record; l record; n bigint;
|
||||||
|
BEGIN
|
||||||
|
FOR m IN SELECT * FROM list_map ORDER BY list_id LOOP
|
||||||
|
SELECT * INTO l FROM legacy.lists WHERE id = m.list_id;
|
||||||
|
PERFORM meta.create_list(l.slug, m.tbl, l.name, m.has_done, m.cols, l.picture_url, NULL, NULL, m.list_id);
|
||||||
|
UPDATE meta.tables SET created_at = l.created_at WHERE slug = l.slug;
|
||||||
|
n := pg_temp.copy_std(m.list_id, m.tbl, m.has_done);
|
||||||
|
RAISE NOTICE 'lists.%: % rows', m.tbl, n;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- parsing must not bump updated_at: suppress user triggers for these UPDATEs (superuser only)
|
||||||
|
SET LOCAL session_replication_role = replica;
|
||||||
|
-- ---- per-list parsing into the extra columns (only where it parses cleanly) ----
|
||||||
|
-- vinyls: "Artist - Album"
|
||||||
|
UPDATE lists.vinyls SET artist = split_part(name, ' - ', 1), name = substr(name, strpos(name, ' - ') + 3)
|
||||||
|
WHERE strpos(name, ' - ') > 0;
|
||||||
|
-- books: "Author - Title" only for the 3 personal-name ones (others contain ':' subtitles, never ' - ')
|
||||||
|
UPDATE lists.books SET author = split_part(name, ' - ', 1), name = substr(name, strpos(name, ' - ') + 3)
|
||||||
|
WHERE id IN (28, 29, 306);
|
||||||
|
-- side projects: everything starts as an idea
|
||||||
|
UPDATE lists.side_projects SET status = 'idea';
|
||||||
|
-- osu: trailing mods
|
||||||
|
UPDATE lists.osu_maps SET name = 'Lonely Go', mods = 'HR' WHERE id = 310;
|
||||||
|
UPDATE lists.osu_maps SET name = 'Imagination', mods = 'HR' WHERE id = 317;
|
||||||
|
-- vehicles: all current entries are cars
|
||||||
|
UPDATE lists.vehicles_to_spot SET kind = 'car';
|
||||||
|
-- leetcode
|
||||||
|
UPDATE lists.leetcode SET kind = CASE WHEN name IN ('Two Sum','3Sum') THEN 'problem' ELSE 'topic' END;
|
||||||
|
UPDATE lists.leetcode SET url = 'https://leetcode.com/problems/two-sum/' WHERE name = 'Two Sum';
|
||||||
|
UPDATE lists.leetcode SET url = 'https://leetcode.com/problems/3sum/' WHERE name = '3Sum';
|
||||||
|
-- restaurants: "Name (City)"
|
||||||
|
UPDATE lists.restaurants SET city = substring(name from '\(([^)]+)\)\s*$'), name = btrim(regexp_replace(name, '\s*\([^)]+\)\s*$', ''))
|
||||||
|
WHERE name ~ '\([^)]+\)\s*$';
|
||||||
|
-- recipes
|
||||||
|
UPDATE lists.recipes SET cuisine = CASE WHEN name = 'Yogurt, honey & cruesli' THEN NULL ELSE 'korean' END;
|
||||||
|
-- places
|
||||||
|
UPDATE lists.places SET kind = CASE
|
||||||
|
WHEN name IN ('London','Seattle','Miami','Dubai','New York') THEN 'city'
|
||||||
|
WHEN name IN ('California','Texas') THEN 'region'
|
||||||
|
WHEN name IN ('Curaçao','Aruba','Bonaire') THEN 'island'
|
||||||
|
ELSE 'country' END;
|
||||||
|
-- piano: "artist - title" + URL in notes
|
||||||
|
UPDATE lists.piano_songs SET artist = 'ryo (supercell)', name = 'odds & ends', reference_url = notes, notes = NULL
|
||||||
|
WHERE name = 'ryo/supercell - odds & ends';
|
||||||
|
-- tools: notes that are a bare URL become url
|
||||||
|
UPDATE lists.tools_to_try SET url = notes, notes = NULL WHERE notes ~ '^https?://\S+$';
|
||||||
|
-- docs/RFCs: "RFC n – Title", notes "Topic · starter pack"
|
||||||
|
UPDATE lists.docs_to_read SET
|
||||||
|
rfc_number = substring(name from '^RFC (\d+)')::integer,
|
||||||
|
topic = btrim(split_part(notes, '·', 1)),
|
||||||
|
starter_pack = notes LIKE '%starter pack%',
|
||||||
|
url = 'https://www.rfc-editor.org/rfc/rfc' || substring(name from '^RFC (\d+)'),
|
||||||
|
notes = NULL
|
||||||
|
WHERE name ~ '^RFC \d+';
|
||||||
|
-- banger songs: "Artist - Title" + URL notes; the 2 YT mixes are playlists
|
||||||
|
UPDATE lists.banger_songs SET url = notes, notes = NULL WHERE notes ~ '^https?://\S+$';
|
||||||
|
UPDATE lists.banger_songs SET artist = split_part(name, ' - ', 1), name = substr(name, strpos(name, ' - ') + 3), kind = 'song'
|
||||||
|
WHERE strpos(name, ' - ') > 0;
|
||||||
|
UPDATE lists.banger_songs SET kind = 'playlist' WHERE artist IS NULL;
|
||||||
|
|
||||||
|
SET LOCAL session_replication_role = origin;
|
||||||
|
|
||||||
|
-- persist the fix log for review (legacy schema, dropped with it)
|
||||||
|
CREATE TABLE legacy.reorg_list_fixes AS SELECT * FROM fix_log;
|
||||||
|
ALTER TABLE legacy.reorg_list_fixes OWNER TO personal; -- so `personal` can pg_dump everything
|
||||||
148
db/reorg/06_hardware_energy_cs.sql
Normal file
148
db/reorg/06_hardware_energy_cs.sql
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
-- 06_hardware_energy_cs.sql: bring hardware + energy + CS prices over from dev.
|
||||||
|
-- Expects schemas import_hardware / import_energy / import_cs, staged by run.sh from dumps/dev-import-staged.dump.
|
||||||
|
SET ROLE personal;
|
||||||
|
|
||||||
|
-- ---------- energy (DDL identical to ned-collector/collect.py so its IF NOT EXISTS is a no-op) ----------
|
||||||
|
CREATE TABLE energy.gas_storage_daily (
|
||||||
|
gas_day date NOT NULL,
|
||||||
|
site text NOT NULL,
|
||||||
|
point_name text,
|
||||||
|
volume_twh numeric(12,4),
|
||||||
|
fill_pct numeric(7,3),
|
||||||
|
source text NOT NULL DEFAULT 'ned-json',
|
||||||
|
first_seen timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (gas_day, site)
|
||||||
|
);
|
||||||
|
CREATE TABLE energy.ned_utilization (
|
||||||
|
valid_from timestamptz NOT NULL, valid_to timestamptz NOT NULL,
|
||||||
|
point text NOT NULL, type text NOT NULL, activity text NOT NULL, classification text NOT NULL, granularity text NOT NULL,
|
||||||
|
capacity_kw numeric, volume_kwh numeric, percentage numeric, emission_kg numeric, emission_factor numeric,
|
||||||
|
source_file text,
|
||||||
|
first_seen timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (valid_from, point, type, activity, classification, granularity)
|
||||||
|
);
|
||||||
|
CREATE INDEX ned_utilization_type_time ON energy.ned_utilization (type, granularity, valid_from DESC);
|
||||||
|
CREATE TABLE energy.collector_runs (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
started_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz,
|
||||||
|
mode text NOT NULL, ok boolean, rows_upserted integer, errors text
|
||||||
|
);
|
||||||
|
CREATE VIEW energy.gas_storage_latest AS
|
||||||
|
SELECT DISTINCT ON (site) site, gas_day, volume_twh, fill_pct, updated_at
|
||||||
|
FROM energy.gas_storage_daily ORDER BY site, gas_day DESC;
|
||||||
|
CREATE VIEW energy.electricity_hourly AS
|
||||||
|
SELECT valid_from AT TIME ZONE 'Europe/Amsterdam' AS hour_local, type, classification,
|
||||||
|
round(volume_kwh / 1000.0, 1) AS mwh, percentage, emission_factor AS kg_co2_per_kwh
|
||||||
|
FROM energy.ned_utilization WHERE granularity = 'Hour';
|
||||||
|
|
||||||
|
INSERT INTO energy.gas_storage_daily SELECT * FROM import_energy.gas_storage_daily;
|
||||||
|
INSERT INTO energy.ned_utilization SELECT * FROM import_energy.ned_utilization;
|
||||||
|
INSERT INTO energy.collector_runs SELECT * FROM import_energy.collector_runs;
|
||||||
|
SELECT setval('energy.collector_runs_id_seq', coalesce((SELECT max(id) FROM energy.collector_runs), 1));
|
||||||
|
|
||||||
|
-- ---------- hardware (prices -> integer cents) ----------
|
||||||
|
CREATE TABLE hardware.devices (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE,
|
||||||
|
kind text NOT NULL CHECK (kind IN ('desktop','laptop','phone','vm','monitor')),
|
||||||
|
notes text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE hardware.components (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
device_id bigint NOT NULL REFERENCES hardware.devices(id) ON DELETE CASCADE,
|
||||||
|
category text NOT NULL CHECK (category IN ('case','cpu','motherboard','ram','cpu_cooler','gpu','psu','storage','fan','monitor','lighting','accessory')),
|
||||||
|
brand text,
|
||||||
|
model text NOT NULL,
|
||||||
|
quantity integer NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||||
|
specs jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
unit_price_cents integer CHECK (unit_price_cents >= 0),
|
||||||
|
total_price_cents integer CHECK (total_price_cents >= 0),
|
||||||
|
shop text,
|
||||||
|
order_number text,
|
||||||
|
order_date date,
|
||||||
|
warranty_until date,
|
||||||
|
invoice_number text,
|
||||||
|
status text NOT NULL DEFAULT 'in_use' CHECK (status IN ('in_use','retired','sold')),
|
||||||
|
verified boolean NOT NULL DEFAULT true,
|
||||||
|
notes text,
|
||||||
|
source text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX components_category_idx ON hardware.components (category);
|
||||||
|
CREATE INDEX components_device_idx ON hardware.components (device_id);
|
||||||
|
CREATE UNIQUE INDEX components_dedupe_uq ON hardware.components (device_id, model, order_number) NULLS NOT DISTINCT;
|
||||||
|
CREATE INDEX components_specs_gin ON hardware.components USING gin (specs);
|
||||||
|
COMMENT ON SCHEMA hardware IS 'Stan''s hardware inventory: devices (PCs, laptops, phones, VMs, monitors) and their components/peripherals with purchase + warranty details.';
|
||||||
|
COMMENT ON TABLE hardware.devices IS 'A physical or virtual machine Stan owns/uses (e.g. stan-pc). Components hang off a device.';
|
||||||
|
COMMENT ON TABLE hardware.components IS 'One line item (part or peripheral) belonging to a device, with purchase, price and warranty info.';
|
||||||
|
COMMENT ON COLUMN hardware.components.unit_price_cents IS 'Price per unit in EUR cents incl. VAT.';
|
||||||
|
COMMENT ON COLUMN hardware.components.total_price_cents IS 'unit price * quantity in EUR cents incl. VAT (excl. shipping).';
|
||||||
|
COMMENT ON COLUMN hardware.components.verified IS 'false = purchase is real but unconfirmed whether it is (still) part of this setup, or some details are guessed. See notes.';
|
||||||
|
COMMENT ON COLUMN hardware.components.source IS 'Where the data came from: user_list, gmail, manual.';
|
||||||
|
|
||||||
|
INSERT INTO hardware.devices (id, name, kind, notes, created_at, updated_at) OVERRIDING SYSTEM VALUE
|
||||||
|
SELECT id, name, kind, notes, created_at, created_at FROM import_hardware.devices;
|
||||||
|
INSERT INTO hardware.components (id, device_id, category, brand, model, quantity, specs, unit_price_cents, total_price_cents,
|
||||||
|
shop, order_number, order_date, warranty_until, invoice_number, status, verified, notes, source, created_at, updated_at)
|
||||||
|
OVERRIDING SYSTEM VALUE
|
||||||
|
SELECT id, device_id, category, brand, model, quantity, specs, round(unit_price_eur * 100)::integer, round(total_price_eur * 100)::integer,
|
||||||
|
shop, order_number, order_date, warranty_until, invoice_number, status, verified, notes, source, created_at, updated_at
|
||||||
|
FROM import_hardware.components;
|
||||||
|
SELECT setval(pg_get_serial_sequence('hardware.devices', 'id'), (SELECT max(id) FROM hardware.devices));
|
||||||
|
SELECT setval(pg_get_serial_sequence('hardware.components', 'id'), (SELECT max(id) FROM hardware.components));
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF (SELECT sum(total_price_cents) FROM hardware.components) <> (SELECT round(sum(total_price_eur) * 100) FROM import_hardware.components) THEN
|
||||||
|
RAISE EXCEPTION 'hardware cents conversion mismatch'; END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE VIEW hardware.setup_overview AS
|
||||||
|
SELECT d.name AS device, d.kind AS device_kind, c.category, c.brand, c.model, c.quantity,
|
||||||
|
c.total_price_cents, round(c.total_price_cents / 100.0, 2) AS total_price_eur,
|
||||||
|
c.shop, c.order_date, c.warranty_until, (c.warranty_until >= CURRENT_DATE) AS under_warranty,
|
||||||
|
c.status, c.verified, c.specs, c.notes, c.order_number, c.invoice_number, c.source, c.id AS component_id
|
||||||
|
FROM hardware.components c JOIN hardware.devices d ON d.id = c.device_id
|
||||||
|
ORDER BY d.name, array_position(ARRAY['case','cpu','motherboard','ram','cpu_cooler','gpu','psu','storage','fan','lighting','monitor','accessory'], c.category),
|
||||||
|
c.order_date, c.model;
|
||||||
|
COMMENT ON VIEW hardware.setup_overview IS 'Flat view of every component per device, build order, live under_warranty flag. Filter status = ''in_use'' for the current setup.';
|
||||||
|
|
||||||
|
-- ---------- CS prices (prod tables are empty; copy dev) ----------
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM cs.items) OR EXISTS (SELECT 1 FROM cs.price_observations)
|
||||||
|
OR EXISTS (SELECT 1 FROM cs.item_dimensions) OR EXISTS (SELECT 1 FROM cs.price_streams) THEN
|
||||||
|
RAISE EXCEPTION 'cs.* not empty in target; refusing to merge dev data'; END IF;
|
||||||
|
END $$;
|
||||||
|
INSERT INTO cs.items (id, market_hash_name, name, icon_url, item_type, first_seen_at, last_seen_at)
|
||||||
|
SELECT id, market_hash_name, name, icon_url, item_type, first_seen_at, last_seen_at FROM import_cs.cs_market_items;
|
||||||
|
INSERT INTO cs.item_dimensions (item_id, key, value) SELECT item_id, key, value FROM import_cs.cs_item_dimensions;
|
||||||
|
INSERT INTO cs.price_streams (source, currency, kind) SELECT source, currency, kind FROM import_cs.cs_price_streams;
|
||||||
|
INSERT INTO cs.price_observations (id, item_id, source, currency, observed_at, price_cents, volume, kind, imported_at) OVERRIDING SYSTEM VALUE
|
||||||
|
SELECT id, item_id, source, currency, observed_at, price_cents, volume, kind, imported_at FROM import_cs.cs_price_observations;
|
||||||
|
SELECT setval(pg_get_serial_sequence('cs.items', 'id'), coalesce((SELECT max(id) FROM cs.items), 1));
|
||||||
|
SELECT setval(pg_get_serial_sequence('cs.price_observations', 'id'), coalesce((SELECT max(id) FROM cs.price_observations), 1));
|
||||||
|
|
||||||
|
CREATE TABLE legacy.reorg_imports (dst text PRIMARY KEY, src text NOT NULL, n bigint NOT NULL);
|
||||||
|
-- ---------- verify imports against the staged dev copies, then drop staging ----------
|
||||||
|
RESET ROLE;
|
||||||
|
DO $$
|
||||||
|
DECLARE r record; a bigint; b bigint;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN SELECT * FROM (VALUES
|
||||||
|
('energy.gas_storage_daily','import_energy.gas_storage_daily'), ('energy.ned_utilization','import_energy.ned_utilization'),
|
||||||
|
('energy.collector_runs','import_energy.collector_runs'), ('hardware.devices','import_hardware.devices'),
|
||||||
|
('hardware.components','import_hardware.components'), ('cs.items','import_cs.cs_market_items'),
|
||||||
|
('cs.item_dimensions','import_cs.cs_item_dimensions'), ('cs.price_streams','import_cs.cs_price_streams'),
|
||||||
|
('cs.price_observations','import_cs.cs_price_observations')) v(dst, srct) LOOP
|
||||||
|
EXECUTE 'SELECT count(*) FROM ' || r.dst INTO a;
|
||||||
|
EXECUTE 'SELECT count(*) FROM ' || r.srct INTO b;
|
||||||
|
IF a <> b THEN RAISE EXCEPTION '% has % rows, dev % has %', r.dst, a, r.srct, b; END IF;
|
||||||
|
INSERT INTO legacy.reorg_imports VALUES (r.dst, r.srct, a);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
DROP SCHEMA import_energy CASCADE;
|
||||||
|
DROP SCHEMA import_hardware CASCADE;
|
||||||
|
DROP SCHEMA import_cs CASCADE;
|
||||||
79
db/reorg/07_triggers_meta_grants.sql
Normal file
79
db/reorg/07_triggers_meta_grants.sql
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
-- 07_triggers_meta_grants.sql: updated_at triggers everywhere, register core tables in meta, grants.
|
||||||
|
SET ROLE personal;
|
||||||
|
|
||||||
|
-- 1) updated_at trigger on every table that has the column but no trigger (27 pre-existing + new ones)
|
||||||
|
DO $$
|
||||||
|
DECLARE r record; n integer := 0;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
SELECT n.nspname AS s, c.relname AS t
|
||||||
|
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'updated_at' AND NOT a.attisdropped
|
||||||
|
WHERE c.relkind = 'r'
|
||||||
|
AND n.nspname IN ('auth','money','house','ref','games','cs','animaldex','site','hardware','energy')
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM pg_trigger tg WHERE tg.tgrelid = c.oid AND NOT tg.tgisinternal
|
||||||
|
AND tg.tgfoid = 'meta.touch_updated_at()'::regprocedure)
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON %I.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', r.s, r.t);
|
||||||
|
n := n + 1;
|
||||||
|
END LOOP;
|
||||||
|
RAISE NOTICE 'added updated_at triggers: %', n;
|
||||||
|
END $$;
|
||||||
|
-- the 16 old triggers were all named site_touch_updated_at; normalise the name
|
||||||
|
DO $$
|
||||||
|
DECLARE r record;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN SELECT tg.tgname, tg.tgrelid::regclass AS rel FROM pg_trigger tg WHERE tg.tgname = 'site_touch_updated_at' LOOP
|
||||||
|
EXECUTE format('ALTER TRIGGER site_touch_updated_at ON %s RENAME TO touch_updated_at', r.rel);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
RESET ROLE;
|
||||||
|
|
||||||
|
-- 2) register core + ref tables in meta (GUI visibility); auth + bookkeeping are not exposed
|
||||||
|
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||||
|
SELECT n.nspname, c.relname,
|
||||||
|
replace(n.nspname || '-' || c.relname, '_', '-'),
|
||||||
|
initcap(replace(c.relname, '_', ' ')),
|
||||||
|
CASE WHEN n.nspname = 'ref' THEN 'ref' ELSE 'core' END,
|
||||||
|
n.nspname <> 'ref', false, 1000
|
||||||
|
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE c.relkind IN ('r','v')
|
||||||
|
AND n.nspname IN ('money','house','ref','games','cs','animaldex','site','hardware','energy')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
UPDATE meta.tables SET editable = false WHERE kind = 'core' AND (schema_name, table_name) IN
|
||||||
|
(SELECT n.nspname, c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'v');
|
||||||
|
SELECT meta.sync();
|
||||||
|
|
||||||
|
-- 3) grants
|
||||||
|
GRANT USAGE ON SCHEMA auth, money, house, ref, games, cs, animaldex, site, meta, hardware, energy, lists TO personal_app, personal_ro;
|
||||||
|
-- app: full DML on its own data, read-only on ref
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA auth, money, house, games, cs, animaldex, site, hardware, energy, lists TO personal_app;
|
||||||
|
GRANT SELECT ON ALL TABLES IN SCHEMA ref, meta TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA auth, money, house, games, cs, animaldex, site, hardware, energy, lists TO personal_app;
|
||||||
|
-- app may edit presentation fields of the registry directly
|
||||||
|
GRANT UPDATE (slug, display_name, description, icon, picture_url, default_sort, sort_order) ON meta.tables TO personal_app; -- slug: URL only, table name stays
|
||||||
|
GRANT UPDATE (label, ui_type, options, position, hidden, help_text) ON meta.columns TO personal_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION meta.create_list(text, text, text, boolean, jsonb, text, text, text, integer),
|
||||||
|
meta.add_column(text, jsonb), meta.rename_column(text, text, text), meta.drop_column(text, text),
|
||||||
|
meta.archive_list(text), meta.drop_list(text), meta.sync(integer) TO personal_app;
|
||||||
|
-- ro: everything readable
|
||||||
|
GRANT SELECT ON ALL TABLES IN SCHEMA auth, money, house, ref, games, cs, animaldex, site, meta, hardware, energy, lists TO personal_ro;
|
||||||
|
-- future objects
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA auth, money, house, games, cs, animaldex, site, hardware, energy
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA auth, money, house, games, cs, animaldex, site, hardware, energy
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA ref, meta GRANT SELECT ON TABLES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA auth, money, house, ref, games, cs, animaldex, site, meta, hardware, energy
|
||||||
|
GRANT SELECT ON TABLES TO personal_ro;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal_ddl IN SCHEMA lists GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal_ddl IN SCHEMA lists GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal_ddl IN SCHEMA lists GRANT SELECT ON TABLES TO personal_ro;
|
||||||
|
-- migration owner can read the runtime lists, so deploy backups (pg_dump as `personal`) cover everything
|
||||||
|
GRANT USAGE ON SCHEMA lists TO personal;
|
||||||
|
GRANT SELECT ON ALL TABLES IN SCHEMA lists TO personal;
|
||||||
|
GRANT SELECT ON ALL SEQUENCES IN SCHEMA lists TO personal;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal_ddl IN SCHEMA lists GRANT SELECT ON TABLES TO personal;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal_ddl IN SCHEMA lists GRANT SELECT ON SEQUENCES TO personal;
|
||||||
|
-- nobody creates in public anymore
|
||||||
|
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
|
||||||
68
db/reorg/08_verify.sql
Normal file
68
db/reorg/08_verify.sql
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
-- 08_verify.sql: hard assertions; any failure aborts the whole transaction.
|
||||||
|
DO $$
|
||||||
|
DECLARE r record; a bigint; bad text := '';
|
||||||
|
BEGIN
|
||||||
|
-- every moved table keeps its exact row count
|
||||||
|
-- expected = prod count before + rows imported from dev (cs.* only; prod cs was empty)
|
||||||
|
FOR r IN SELECT m.*, b.n + coalesce(i.n, 0) AS n FROM legacy.reorg_table_map m
|
||||||
|
JOIN legacy.reorg_counts_before b ON b.table_name = m.old_name
|
||||||
|
LEFT JOIN legacy.reorg_imports i ON i.dst = m.new_schema || '.' || m.new_name LOOP
|
||||||
|
EXECUTE format('SELECT count(*) FROM %I.%I', r.new_schema, r.new_name) INTO a;
|
||||||
|
IF a <> r.n THEN bad := bad || format('%s.%s %s<>%s; ', r.new_schema, r.new_name, a, r.n); END IF;
|
||||||
|
END LOOP;
|
||||||
|
IF bad <> '' THEN RAISE EXCEPTION 'row count mismatch: %', bad; END IF;
|
||||||
|
IF (SELECT count(*) FROM legacy.reorg_counts_before) <> 58 THEN RAISE EXCEPTION 'expected 58 source tables'; END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public') THEN RAISE EXCEPTION 'public not empty'; END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- lists: every legacy item is in exactly one lists.* table, except the 4 merged duplicates; done count preserved
|
||||||
|
DO $$
|
||||||
|
DECLARE t record; n bigint; total bigint := 0; done bigint := 0; d bigint;
|
||||||
|
dropped bigint := (SELECT count(*) FROM legacy.reorg_list_fixes WHERE action = 'dedupe-drop');
|
||||||
|
BEGIN
|
||||||
|
FOR t IN SELECT * FROM meta.tables WHERE kind = 'list' LOOP
|
||||||
|
EXECUTE format('SELECT count(*) FROM lists.%I', t.table_name) INTO n;
|
||||||
|
total := total + n;
|
||||||
|
IF t.has_done THEN EXECUTE format('SELECT count(*) FROM lists.%I WHERE done_at IS NOT NULL', t.table_name) INTO d; done := done + d; END IF;
|
||||||
|
END LOOP;
|
||||||
|
IF total + dropped <> (SELECT count(*) FROM legacy.list_items) THEN
|
||||||
|
RAISE EXCEPTION 'lists total % + % dropped <> legacy %', total, dropped, (SELECT count(*) FROM legacy.list_items); END IF;
|
||||||
|
-- expected done = legacy completions minus dedupe pairs where BOTH copies were done (they collapse to one)
|
||||||
|
d := (SELECT count(*) FROM legacy.list_item_completions)
|
||||||
|
- (SELECT count(*) FROM dedupe x
|
||||||
|
WHERE EXISTS (SELECT 1 FROM legacy.list_item_completions c WHERE c.item_id = x.keep_id)
|
||||||
|
AND EXISTS (SELECT 1 FROM legacy.list_item_completions c WHERE c.item_id = x.drop_id));
|
||||||
|
IF done <> d THEN RAISE EXCEPTION 'done count % <> expected %', done, d; END IF;
|
||||||
|
IF (SELECT count(*) FROM meta.tables WHERE kind = 'list') <> (SELECT count(*) FROM legacy.lists) THEN
|
||||||
|
RAISE EXCEPTION 'list count mismatch'; END IF;
|
||||||
|
RAISE NOTICE 'lists OK: % rows (+% merged dupes), % done', total, dropped, done;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- every table with updated_at has the trigger
|
||||||
|
DO $$
|
||||||
|
DECLARE missing text;
|
||||||
|
BEGIN
|
||||||
|
SELECT string_agg(n.nspname || '.' || c.relname, ', ') INTO missing
|
||||||
|
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'updated_at' AND NOT a.attisdropped
|
||||||
|
WHERE c.relkind = 'r' AND n.nspname NOT IN ('legacy','pg_catalog','information_schema') AND n.nspname !~ '^pg_'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM pg_trigger tg WHERE tg.tgrelid = c.oid AND tg.tgfoid = 'meta.touch_updated_at()'::regprocedure);
|
||||||
|
IF missing IS NOT NULL THEN RAISE EXCEPTION 'missing updated_at trigger: %', missing; END IF;
|
||||||
|
-- every table has a PK
|
||||||
|
SELECT string_agg(n.nspname || '.' || c.relname, ', ') INTO missing
|
||||||
|
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE c.relkind = 'r' AND n.nspname NOT IN ('legacy','pg_catalog','information_schema') AND n.nspname !~ '^pg_'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM pg_constraint k WHERE k.conrelid = c.oid AND k.contype = 'p');
|
||||||
|
IF missing IS NOT NULL THEN RAISE EXCEPTION 'tables without PK: %', missing; END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- list rows keep their original timestamps
|
||||||
|
DO $$
|
||||||
|
DECLARE t record; n bigint; bad bigint := 0;
|
||||||
|
BEGIN
|
||||||
|
FOR t IN SELECT table_name FROM meta.tables WHERE kind = 'list' LOOP
|
||||||
|
EXECUTE format('SELECT count(*) FROM lists.%I x JOIN legacy.list_items i ON i.id = x.id WHERE x.updated_at <> i.updated_at OR x.created_at <> i.created_at', t.table_name) INTO n;
|
||||||
|
bad := bad + n;
|
||||||
|
END LOOP;
|
||||||
|
IF bad > 0 THEN RAISE EXCEPTION '% list rows lost their original timestamps', bad; END IF;
|
||||||
|
END $$;
|
||||||
58
db/reorg/09_drizzle_names.sql
Normal file
58
db/reorg/09_drizzle_names.sql
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
-- 09_drizzle_names.sql: rename constraints/indexes to the names Drizzle generates, so future
|
||||||
|
-- drizzle-kit migrations (which address objects by name) match the database. Pure renames, no data change.
|
||||||
|
-- Generated from a diff of the rehearsed DB against the Drizzle baseline (2026-10-23).
|
||||||
|
SET ROLE personal;
|
||||||
|
ALTER TABLE cs.item_dimensions RENAME CONSTRAINT item_dimensions_item_id_cs_market_items_id_fkey TO item_dimensions_item_id_items_id_fkey;
|
||||||
|
ALTER TABLE cs.price_observations RENAME CONSTRAINT price_observations_item_id_cs_market_items_id_fkey TO price_observations_item_id_items_id_fkey;
|
||||||
|
ALTER TABLE games.catch_em_all_entries RENAME CONSTRAINT catch_em_all_entries_pokemon_game_entry_id_pokemon_game_entry_i TO "catch_em_all_entries_HeABXvGNiB6D_fkey";
|
||||||
|
ALTER TABLE games.catch_em_all_games RENAME CONSTRAINT catch_em_all_games_game_id_game_id_fkey TO catch_em_all_games_game_id_pokemon_games_id_fkey;
|
||||||
|
ALTER TABLE games.catch_em_all_settings RENAME CONSTRAINT catch_em_all_settings_user_id_fkey TO catch_em_all_settings_user_id_user_id_fkey;
|
||||||
|
ALTER TABLE games.osu_entries RENAME CONSTRAINT osu_entries_user_id_fkey TO osu_entries_user_id_user_id_fkey;
|
||||||
|
ALTER TABLE games.pokemon_catches RENAME CONSTRAINT pokemon_catches_game_id_fkey TO pokemon_catches_game_id_pokemon_games_id_fkey;
|
||||||
|
ALTER TABLE games.pokemon_catches RENAME CONSTRAINT pokemon_catches_pokemon_id_fkey TO pokemon_catches_pokemon_id_pokemon_id_fkey;
|
||||||
|
ALTER TABLE games.pokemon_catches RENAME CONSTRAINT pokemon_catches_user_id_fkey TO pokemon_catches_user_id_user_id_fkey;
|
||||||
|
ALTER TABLE games.pokemon_catches RENAME CONSTRAINT pokemon_catches_user_id_game_id_party_slot_key TO pokemon_catches_user_id_game_id_party_slot_unique;
|
||||||
|
ALTER TABLE games.pokemon_journeys RENAME CONSTRAINT pokemon_journeys_game_id_fkey TO pokemon_journeys_game_id_pokemon_games_id_fkey;
|
||||||
|
ALTER TABLE games.pokemon_journeys RENAME CONSTRAINT pokemon_journeys_user_id_fkey TO pokemon_journeys_user_id_user_id_fkey;
|
||||||
|
ALTER TABLE games.pokemon_journeys RENAME CONSTRAINT pokemon_journeys_user_id_game_id_key TO pokemon_journeys_user_id_game_id_unique;
|
||||||
|
ALTER TABLE games.pokemon_milestones RENAME CONSTRAINT pokemon_milestones_game_id_fkey TO pokemon_milestones_game_id_pokemon_games_id_fkey;
|
||||||
|
ALTER TABLE games.pokemon_milestones RENAME CONSTRAINT pokemon_milestones_user_id_fkey TO pokemon_milestones_user_id_user_id_fkey;
|
||||||
|
ALTER TABLE games.pokemon_milestones RENAME CONSTRAINT pokemon_milestones_user_id_game_id_key_key TO pokemon_milestones_user_id_game_id_key_unique;
|
||||||
|
ALTER TABLE games.pokemon_seen RENAME CONSTRAINT pokemon_seen_game_id_fkey TO pokemon_seen_game_id_pokemon_games_id_fkey;
|
||||||
|
ALTER TABLE games.pokemon_seen RENAME CONSTRAINT pokemon_seen_pokemon_id_fkey TO pokemon_seen_pokemon_id_pokemon_id_fkey;
|
||||||
|
ALTER TABLE games.pokemon_seen RENAME CONSTRAINT pokemon_seen_user_id_fkey TO pokemon_seen_user_id_user_id_fkey;
|
||||||
|
ALTER TABLE games.pokemon_seen RENAME CONSTRAINT pokemon_seen_user_id_game_id_pokemon_id_key TO pokemon_seen_user_id_game_id_pokemon_id_unique;
|
||||||
|
ALTER TABLE games.tracker_challenge_progress RENAME CONSTRAINT tracker_challenge_progress_challenge_id_fkey TO "tracker_challenge_progress_HtzyFtOyUAhk_fkey";
|
||||||
|
ALTER TABLE games.tracker_challenge_progress RENAME CONSTRAINT tracker_challenge_progress_user_id_challenge_id_key TO tracker_challenge_progress_user_id_challenge_id_unique;
|
||||||
|
ALTER TABLE games.tracker_challenge_progress RENAME CONSTRAINT tracker_challenge_progress_user_id_fkey TO tracker_challenge_progress_user_id_user_id_fkey;
|
||||||
|
ALTER TABLE games.tracker_ranks RENAME CONSTRAINT tracker_ranks_game_id_fkey TO tracker_ranks_game_id_tracker_games_id_fkey;
|
||||||
|
ALTER TABLE games.tracker_ranks RENAME CONSTRAINT tracker_ranks_user_id_fkey TO tracker_ranks_user_id_user_id_fkey;
|
||||||
|
ALTER TABLE games.tracker_ranks RENAME CONSTRAINT tracker_ranks_user_id_game_id_key TO tracker_ranks_user_id_game_id_unique;
|
||||||
|
ALTER TABLE games.tracker_weapon_ranks RENAME CONSTRAINT tracker_weapon_ranks_item_id_fkey TO tracker_weapon_ranks_item_id_tracker_items_id_fkey;
|
||||||
|
ALTER TABLE games.tracker_weapon_ranks RENAME CONSTRAINT tracker_weapon_ranks_user_id_fkey TO tracker_weapon_ranks_user_id_user_id_fkey;
|
||||||
|
ALTER TABLE games.tracker_weapon_ranks RENAME CONSTRAINT tracker_weapon_ranks_user_id_item_id_key TO tracker_weapon_ranks_user_id_item_id_unique;
|
||||||
|
ALTER TABLE house.price_history RENAME CONSTRAINT price_history_listing_id_fkey TO price_history_listing_id_listings_id_fkey;
|
||||||
|
ALTER TABLE meta.columns RENAME CONSTRAINT columns_table_id_fkey TO columns_table_id_tables_id_fkey;
|
||||||
|
ALTER TABLE money.forecasts RENAME CONSTRAINT forecasts_account_id_money_accounts_id_fkey TO forecasts_account_id_accounts_id_fkey;
|
||||||
|
ALTER TABLE money.projection_settings RENAME CONSTRAINT "projection_settings_fqvzThdinM8E_fkey" TO projection_settings_investment_account_id_accounts_id_fkey;
|
||||||
|
ALTER TABLE money.recurring RENAME CONSTRAINT recurring_account_id_money_accounts_id_fkey TO recurring_account_id_accounts_id_fkey;
|
||||||
|
ALTER TABLE money.transactions RENAME CONSTRAINT transactions_account_id_money_accounts_id_fkey TO transactions_account_id_accounts_id_fkey;
|
||||||
|
ALTER TABLE ref.pokemon_dex_numbers RENAME CONSTRAINT pokemon_dex_numbers_game_id_fkey TO pokemon_dex_numbers_game_id_pokemon_games_id_fkey;
|
||||||
|
ALTER TABLE ref.pokemon_dex_numbers RENAME CONSTRAINT pokemon_dex_numbers_pokemon_id_fkey TO pokemon_dex_numbers_pokemon_id_pokemon_id_fkey;
|
||||||
|
ALTER TABLE ref.pokemon_dex_numbers RENAME CONSTRAINT pokemon_dex_numbers_pokemon_id_game_id_dex_key TO pokemon_dex_numbers_pokemon_id_game_id_dex_unique;
|
||||||
|
ALTER TABLE ref.pokemon_encounters RENAME CONSTRAINT pokemon_encounters_game_id_games_id_fkey TO pokemon_encounters_game_id_pokemon_games_id_fkey;
|
||||||
|
ALTER TABLE ref.pokemon_evolutions RENAME CONSTRAINT pokemon_evolutions_game_id_games_id_fkey TO pokemon_evolutions_game_id_pokemon_games_id_fkey;
|
||||||
|
ALTER TABLE ref.pokemon_game_entries RENAME CONSTRAINT pokemon_game_entries_game_id_game_id_fkey TO pokemon_game_entries_game_id_pokemon_games_id_fkey;
|
||||||
|
ALTER TABLE ref.pokemon_game_research RENAME CONSTRAINT pokemon_game_research_game_id_fkey TO pokemon_game_research_game_id_pokemon_games_id_fkey;
|
||||||
|
ALTER TABLE ref.pokemon_learnsets RENAME CONSTRAINT pokemon_learnsets_pokemon_id_fkey TO pokemon_learnsets_pokemon_id_pokemon_id_fkey;
|
||||||
|
ALTER TABLE ref.pokemon_learnsets RENAME CONSTRAINT pokemon_learnsets_pokemon_id_version_group_key TO pokemon_learnsets_pokemon_id_version_group_unique;
|
||||||
|
ALTER TABLE ref.pokemon_research RENAME CONSTRAINT pokemon_research_pokemon_id_fkey TO pokemon_research_pokemon_id_pokemon_id_fkey;
|
||||||
|
ALTER TABLE ref.tracker_challenges RENAME CONSTRAINT tracker_challenges_game_id_fkey TO tracker_challenges_game_id_tracker_games_id_fkey;
|
||||||
|
ALTER TABLE ref.tracker_challenges RENAME CONSTRAINT tracker_challenges_game_id_key_key TO tracker_challenges_game_id_key_unique;
|
||||||
|
ALTER TABLE site.blog_articles RENAME CONSTRAINT blog_articles_slug_unique TO blog_articles_slug_key;
|
||||||
|
ALTER INDEX games.osu_entries_owner_idx RENAME TO osu_entries_user_id_kind_index;
|
||||||
|
ALTER INDEX games.pokemon_catches_owner_idx RENAME TO pokemon_catches_user_id_game_id_pokemon_id_index;
|
||||||
|
ALTER INDEX games.pokemon_seen_user_idx RENAME TO pokemon_seen_user_id_index;
|
||||||
|
ALTER INDEX money.utility_readings_user_kind_date_idx RENAME TO utility_readings_user_id_kind_date_index;
|
||||||
|
ALTER INDEX ref.pokemon_dex_game_idx RENAME TO pokemon_dex_numbers_game_id_index;
|
||||||
|
RESET ROLE;
|
||||||
21
db/reorg/README.md
Normal file
21
db/reorg/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# One-time schema reorg (2026-10)
|
||||||
|
|
||||||
|
Hand-written, reviewed SQL that moved the old flat `public.*` tables into the
|
||||||
|
domain schemas (`auth`, `money`, `house`, `ref`, `games`, `cs`, `animaldex`,
|
||||||
|
`site`, `meta`, `lists`, `hardware`, `energy`, `legacy`), created the roles
|
||||||
|
(`personal`, `personal_app`, `personal_ddl`, `personal_ro`), the `meta.*`
|
||||||
|
registry/API and the 22 runtime lists. It is the state that the Drizzle baseline
|
||||||
|
in `drizzle/` describes.
|
||||||
|
|
||||||
|
These files are kept for the record and for the cutover only. They were
|
||||||
|
rehearsed on a copy of prod (PG17) and run in one transaction (`run.sh`,
|
||||||
|
which refuses the real database names unless `ALLOW_REAL=yes`). After the
|
||||||
|
cutover, record the baseline with `bun scripts/db-migrate.ts --init`; from then
|
||||||
|
on every schema change is a normal Drizzle migration (`bun run db:generate`).
|
||||||
|
|
||||||
|
Passwords are never in these files; set them with `\password` in psql.
|
||||||
|
|
||||||
|
`09_drizzle_names.sql` runs just before `08_verify.sql` and only renames 48
|
||||||
|
constraints and 5 indexes to Drizzle's generated names, so a catalog diff of the
|
||||||
|
reorged DB against a DB built from the baseline is empty (columns, defaults,
|
||||||
|
constraints, indexes, enums in the nine Drizzle schemas).
|
||||||
24
db/reorg/run.sh
Executable file
24
db/reorg/run.sh
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# run.sh <target-db> : stage the dev import, then apply 01..08 in ONE transaction as superuser.
|
||||||
|
# Rehearsal: runs against the private PG17 cluster in ~/dev/setup/db/rehearsal (port 5441, unix socket only).
|
||||||
|
# Prod: same files, but psql = `sudo docker exec -i <pg-container> psql -U postgres -d personal` (see PLAN.md §4c).
|
||||||
|
set -euo pipefail
|
||||||
|
DB=${1:?target db}
|
||||||
|
case "$DB" in personal|personal_dev|personal_staging) [ "${ALLOW_REAL:-}" = yes ] || { echo "refusing $DB without ALLOW_REAL=yes"; exit 3; };; esac
|
||||||
|
D=~/dev/setup/db
|
||||||
|
export LD_LIBRARY_PATH=$D/tools/root/usr/lib/x86_64-linux-gnu
|
||||||
|
BIN=$D/tools/root/usr/lib/postgresql/17/bin
|
||||||
|
CONN=(-h "${PGSOCK:-$D/rehearsal/sock}" -p "${PGPORT:-5441}" -U postgres)
|
||||||
|
STAGED=${STAGED:-$D/dumps/dev-import-staged.dump}
|
||||||
|
|
||||||
|
# 1) stage dev data as import_* schemas (owned by personal so 06 can read it)
|
||||||
|
$BIN/psql "${CONN[@]}" -d "$DB" -v ON_ERROR_STOP=1 -qc "DROP SCHEMA IF EXISTS import_energy, import_hardware, import_cs CASCADE"
|
||||||
|
$BIN/pg_restore "${CONN[@]}" -d "$DB" --no-owner --role=personal --single-transaction --exit-on-error "$STAGED"
|
||||||
|
|
||||||
|
# 2) the migration, all-or-nothing
|
||||||
|
cd "$D/migration"
|
||||||
|
$BIN/psql "${CONN[@]}" -d "$DB" -v ON_ERROR_STOP=1 --single-transaction \
|
||||||
|
-c "SET lock_timeout = '5s'" -c "SET statement_timeout = '10min'" \
|
||||||
|
-f 01_roles.sql -f 02_schemas_move.sql -f 03_fixes.sql -f 04_meta.sql -f 05_lists.sql \
|
||||||
|
-f 06_hardware_energy_cs.sql -f 07_triggers_meta_grants.sql -f 09_drizzle_names.sql -f 08_verify.sql
|
||||||
|
echo "migration committed on $DB"
|
||||||
@@ -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
|
||||||
@@ -91,7 +91,7 @@ newer; older versions cannot read this repository's lockfile. Use `bun upgrade`
|
|||||||
if needed.
|
if needed.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://git.stanrunge.dev/stan/personal-website ~/house-worker
|
git clone https://git.stanrunge.dev/stan/website ~/house-worker
|
||||||
cd ~/house-worker && bun install --frozen-lockfile
|
cd ~/house-worker && bun install --frozen-lockfile
|
||||||
cat > house.env <<'EOF'
|
cat > house.env <<'EOF'
|
||||||
HOUSE_API_URL=https://stanrunge.dev
|
HOUSE_API_URL=https://stanrunge.dev
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import { defineConfig } from 'drizzle-kit';
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
// Drizzle owns the core schemas only. Deliberately excluded:
|
||||||
|
// - lists: runtime tables created through meta.create_list() (owned by personal_ddl)
|
||||||
|
// - legacy: pre-reorg tables kept read-only for rollback
|
||||||
|
// - energy: owned by ned-collector
|
||||||
|
// - hardware: not modelled yet
|
||||||
|
// Migrations run as the schema owner (`personal`), never as the app role.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
dialect: 'postgresql',
|
dialect: 'postgresql',
|
||||||
schema: './src/lib/server/db/schema/index.ts',
|
schema: './src/lib/server/db/schema/index.ts',
|
||||||
out: './drizzle',
|
out: './drizzle',
|
||||||
|
schemaFilter: [
|
||||||
|
'books',
|
||||||
|
'auth',
|
||||||
|
'money',
|
||||||
|
'house',
|
||||||
|
'ref',
|
||||||
|
'games',
|
||||||
|
'cs',
|
||||||
|
'animaldex',
|
||||||
|
'site',
|
||||||
|
'meta'
|
||||||
|
],
|
||||||
|
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
||||||
verbose: true,
|
verbose: true,
|
||||||
strict: true,
|
strict: true,
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DB_URL!
|
url: (process.env.DB_MIGRATE_URL || process.env.DB_URL)!
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
857
drizzle/20261023215112_baseline/migration.sql
Normal file
857
drizzle/20261023215112_baseline/migration.sql
Normal file
@@ -0,0 +1,857 @@
|
|||||||
|
CREATE SCHEMA "animaldex";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE SCHEMA "auth";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE SCHEMA "cs";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE SCHEMA "games";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE SCHEMA "house";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE SCHEMA "meta";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE SCHEMA "money";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE SCHEMA "ref";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE SCHEMA "site";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TYPE "site"."blog_article_status" AS ENUM('draft', 'published');--> statement-breakpoint
|
||||||
|
CREATE TYPE "ref"."availability" AS ENUM('catchable', 'trade_only', 'transfer_only', 'event_only', 'unknown', 'evolve', 'breed');--> statement-breakpoint
|
||||||
|
CREATE TABLE "auth"."account" (
|
||||||
|
"id" text PRIMARY KEY,
|
||||||
|
"account_id" text NOT NULL,
|
||||||
|
"issuer" text NOT NULL,
|
||||||
|
"provider_id" text NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"access_token" text,
|
||||||
|
"refresh_token" text,
|
||||||
|
"id_token" text,
|
||||||
|
"access_token_expires_at" timestamp with time zone,
|
||||||
|
"refresh_token_expires_at" timestamp with time zone,
|
||||||
|
"scope" text,
|
||||||
|
"password" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "auth"."session" (
|
||||||
|
"id" text PRIMARY KEY,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"token" text NOT NULL UNIQUE,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone NOT NULL,
|
||||||
|
"ip_address" text,
|
||||||
|
"user_agent" text,
|
||||||
|
"user_id" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "auth"."user" (
|
||||||
|
"id" text PRIMARY KEY,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"role" text DEFAULT 'user' NOT NULL,
|
||||||
|
"email" text NOT NULL UNIQUE,
|
||||||
|
"email_verified" boolean DEFAULT false NOT NULL,
|
||||||
|
"image" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "auth"."verification" (
|
||||||
|
"id" text PRIMARY KEY,
|
||||||
|
"identifier" text NOT NULL,
|
||||||
|
"value" text NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "site"."blog_articles" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"slug" text NOT NULL UNIQUE,
|
||||||
|
"content" text DEFAULT '' NOT NULL,
|
||||||
|
"tags" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"status" "site"."blog_article_status" DEFAULT 'draft'::"site"."blog_article_status" NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "site"."tasks" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"current_points" integer DEFAULT 0 NOT NULL,
|
||||||
|
"total_points" integer DEFAULT 1 NOT NULL,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "site"."task_topics" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"task_id" integer NOT NULL,
|
||||||
|
"topic_id" integer NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "task_topics_task_id_topic_id_unique" UNIQUE("task_id","topic_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "site"."topics" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"emoji" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "games"."catch_em_all_settings" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL UNIQUE,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ref"."pokemon_games" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"source_id" integer UNIQUE,
|
||||||
|
"slug" text UNIQUE,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"generation" integer DEFAULT 1 NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"breeding" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ref"."pokemon" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"number" integer NOT NULL UNIQUE,
|
||||||
|
"generation" integer DEFAULT 1 NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"type1" text NOT NULL,
|
||||||
|
"type2" text,
|
||||||
|
"hp" integer DEFAULT 0 NOT NULL,
|
||||||
|
"attack" integer DEFAULT 0 NOT NULL,
|
||||||
|
"defense" integer DEFAULT 0 NOT NULL,
|
||||||
|
"special_attack" integer DEFAULT 0 NOT NULL,
|
||||||
|
"special_defense" integer DEFAULT 0 NOT NULL,
|
||||||
|
"speed" integer DEFAULT 0 NOT NULL,
|
||||||
|
"evolves_from" integer,
|
||||||
|
"evolution_chain" integer,
|
||||||
|
"is_baby" boolean DEFAULT false NOT NULL,
|
||||||
|
"is_legendary" boolean DEFAULT false NOT NULL,
|
||||||
|
"is_mythical" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ref"."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[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"note" text,
|
||||||
|
"source" text DEFAULT 'pokeapi' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ref"."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 DEFAULT 'pokeapi' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "pokemon_evolutions_from_pokemon_id_to_pokemon_id_game_id_method_unique" UNIQUE("from_pokemon_id","to_pokemon_id","game_id","method")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ref"."pokemon_game_entries" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"pokemon_id" integer NOT NULL,
|
||||||
|
"game_id" integer NOT NULL,
|
||||||
|
"availability" "ref"."availability" DEFAULT 'catchable'::"ref"."availability" NOT NULL,
|
||||||
|
"note" text,
|
||||||
|
"source" text DEFAULT 'pokeapi' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "pokemon_game_entries_pokemon_id_game_id_unique" UNIQUE("pokemon_id","game_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ref"."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 DEFAULT 'pokeapi' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "games"."catch_em_all_entries" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"pokemon_game_entry_id" integer NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "catch_em_all_entries_pokemon_game_entry_id_user_id_unique" UNIQUE("pokemon_game_entry_id","user_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "games"."catch_em_all_games" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"game_id" integer NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "catch_em_all_games_user_id_game_id_unique" UNIQUE("user_id","game_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ref"."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 DEFAULT 0 NOT NULL,
|
||||||
|
"rules" jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ref"."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 DEFAULT false NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "tracker_items_game_id_key_unique" UNIQUE("game_id","key")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "games"."tracker_progress" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"item_id" integer NOT NULL,
|
||||||
|
"milestone" text DEFAULT 'done' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "tracker_progress_user_id_item_id_milestone_unique" UNIQUE("user_id","item_id","milestone")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "games"."osu_entries" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"mode" text DEFAULT 'osu' NOT NULL,
|
||||||
|
"beatmap_url" text,
|
||||||
|
"score_url" text,
|
||||||
|
"goal" text DEFAULT 'pass' NOT NULL,
|
||||||
|
"skill" text DEFAULT 'stamina' NOT NULL,
|
||||||
|
"bpm" integer,
|
||||||
|
"duration" integer,
|
||||||
|
"accuracy" numeric,
|
||||||
|
"combo" integer,
|
||||||
|
"misses" integer,
|
||||||
|
"pp" numeric,
|
||||||
|
"mods" text DEFAULT 'NM' NOT NULL,
|
||||||
|
"rate" numeric(4,2) DEFAULT 1.00 NOT NULL,
|
||||||
|
"achieved" boolean DEFAULT false NOT NULL,
|
||||||
|
"played_on" date NOT NULL,
|
||||||
|
"notes" text DEFAULT '' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "osu_entries_kind_check" CHECK ("kind" IN ('score','goal','training'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "games"."pokemon_catches" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"game_id" integer NOT NULL,
|
||||||
|
"pokemon_id" integer NOT NULL,
|
||||||
|
"nickname" text DEFAULT '' NOT NULL,
|
||||||
|
"level" integer DEFAULT 1 NOT NULL,
|
||||||
|
"moves" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"party_slot" integer,
|
||||||
|
"shiny" boolean DEFAULT false NOT NULL,
|
||||||
|
"nature" text,
|
||||||
|
"ability" text,
|
||||||
|
"ball" text,
|
||||||
|
"location" text,
|
||||||
|
"obtained" text DEFAULT 'caught' NOT NULL,
|
||||||
|
"stage" text DEFAULT 'story' NOT NULL,
|
||||||
|
"notes" text DEFAULT '' NOT NULL,
|
||||||
|
"caught_on" date,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "pokemon_catches_user_id_game_id_party_slot_unique" UNIQUE("user_id","game_id","party_slot"),
|
||||||
|
CONSTRAINT "pokemon_catches_level_check" CHECK ("level" between 1 and 100),
|
||||||
|
CONSTRAINT "pokemon_catches_party_slot_check" CHECK ("party_slot" between 1 and 6),
|
||||||
|
CONSTRAINT "pokemon_catches_moves_check" CHECK (cardinality("moves") <= 4),
|
||||||
|
CONSTRAINT "pokemon_catches_stage_check" CHECK ("stage" IN ('story','postgame','collection'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ref"."pokemon_dex_numbers" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"pokemon_id" integer NOT NULL,
|
||||||
|
"game_id" integer NOT NULL,
|
||||||
|
"dex" text NOT NULL,
|
||||||
|
"dex_name" text NOT NULL,
|
||||||
|
"number" integer NOT NULL,
|
||||||
|
CONSTRAINT "pokemon_dex_numbers_pokemon_id_game_id_dex_unique" UNIQUE("pokemon_id","game_id","dex")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ref"."pokemon_game_research" (
|
||||||
|
"game_id" integer PRIMARY KEY,
|
||||||
|
"version_group" integer NOT NULL,
|
||||||
|
"regions" text[] NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "games"."pokemon_journeys" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"game_id" integer NOT NULL,
|
||||||
|
"stage" text DEFAULT 'story' NOT NULL,
|
||||||
|
"location" text,
|
||||||
|
"completed" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "pokemon_journeys_user_id_game_id_unique" UNIQUE("user_id","game_id"),
|
||||||
|
CONSTRAINT "pokemon_journeys_stage_check" CHECK ("stage" IN ('story','postgame','collection'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ref"."pokemon_learnsets" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"pokemon_id" integer NOT NULL,
|
||||||
|
"version_group" integer NOT NULL,
|
||||||
|
"moves" jsonb NOT NULL,
|
||||||
|
CONSTRAINT "pokemon_learnsets_pokemon_id_version_group_unique" UNIQUE("pokemon_id","version_group")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "games"."pokemon_milestones" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"game_id" integer NOT NULL,
|
||||||
|
"key" text NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"category" text NOT NULL,
|
||||||
|
"stage" text DEFAULT 'story' NOT NULL,
|
||||||
|
"done" boolean DEFAULT false NOT NULL,
|
||||||
|
"notes" text DEFAULT '' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "pokemon_milestones_user_id_game_id_key_unique" UNIQUE("user_id","game_id","key")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ref"."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
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ref"."pokemon_research" (
|
||||||
|
"pokemon_id" integer PRIMARY KEY,
|
||||||
|
"facts" jsonb NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "games"."pokemon_seen" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"game_id" integer NOT NULL,
|
||||||
|
"pokemon_id" integer NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "pokemon_seen_user_id_game_id_pokemon_id_unique" UNIQUE("user_id","game_id","pokemon_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "games"."tracker_challenge_progress" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"challenge_id" integer NOT NULL,
|
||||||
|
"current" integer DEFAULT 0 NOT NULL,
|
||||||
|
"done" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "tracker_challenge_progress_user_id_challenge_id_unique" UNIQUE("user_id","challenge_id"),
|
||||||
|
CONSTRAINT "tracker_challenge_progress_current_check" CHECK ("current" >= 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ref"."tracker_challenges" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"game_id" integer NOT NULL,
|
||||||
|
"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,
|
||||||
|
CONSTRAINT "tracker_challenges_game_id_key_unique" UNIQUE("game_id","key")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "games"."tracker_ranks" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"game_id" integer NOT NULL,
|
||||||
|
"level" integer DEFAULT 1 NOT NULL,
|
||||||
|
"prestige" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "tracker_ranks_user_id_game_id_unique" UNIQUE("user_id","game_id"),
|
||||||
|
CONSTRAINT "tracker_ranks_level_check" CHECK ("level" between 1 and 10000),
|
||||||
|
CONSTRAINT "tracker_ranks_prestige_check" CHECK ("prestige" between 0 and 1000)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "games"."tracker_weapon_ranks" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"item_id" integer NOT NULL,
|
||||||
|
"level" integer DEFAULT 1 NOT NULL,
|
||||||
|
"prestige" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "tracker_weapon_ranks_user_id_item_id_unique" UNIQUE("user_id","item_id"),
|
||||||
|
CONSTRAINT "tracker_weapon_ranks_level_check" CHECK ("level" between 1 and 10000),
|
||||||
|
CONSTRAINT "tracker_weapon_ranks_prestige_check" CHECK ("prestige" between 0 and 1000)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "animaldex"."animals" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"species" text NOT NULL,
|
||||||
|
"breed" text,
|
||||||
|
"animal_name" text,
|
||||||
|
"description" text,
|
||||||
|
"ai_breed_suggestion" text,
|
||||||
|
"ai_breed_confidence" double precision,
|
||||||
|
"submitted_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"accepted_at" timestamp with time zone,
|
||||||
|
"denied_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "animaldex"."sighting_photos" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"sighting_id" integer NOT NULL,
|
||||||
|
"r2_key" text NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"uploaded_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "animaldex"."sightings" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"animal_id" integer NOT NULL,
|
||||||
|
"reporter_name" text,
|
||||||
|
"seen_at" timestamp with time zone NOT NULL,
|
||||||
|
"lat" double precision NOT NULL,
|
||||||
|
"lng" double precision NOT NULL,
|
||||||
|
"submitted_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"accepted_at" timestamp with time zone,
|
||||||
|
"denied_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "meta"."columns" (
|
||||||
|
"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "meta"."columns_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||||
|
"table_id" integer NOT NULL,
|
||||||
|
"column_name" text NOT NULL,
|
||||||
|
"label" text,
|
||||||
|
"ui_type" text NOT NULL,
|
||||||
|
"options" jsonb DEFAULT '{}' NOT NULL,
|
||||||
|
"position" integer DEFAULT 0 NOT NULL,
|
||||||
|
"hidden" boolean DEFAULT false NOT NULL,
|
||||||
|
"help_text" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "columns_table_id_column_name_key" UNIQUE("table_id","column_name"),
|
||||||
|
CONSTRAINT "columns_ui_type_check" CHECK (ui_type IN ('text','longtext','url','image','number','cents','date','datetime','bool','select','multiselect','fk','json'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "meta"."tables" (
|
||||||
|
"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "meta"."tables_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||||
|
"schema_name" text NOT NULL,
|
||||||
|
"table_name" text NOT NULL,
|
||||||
|
"slug" text NOT NULL CONSTRAINT "tables_slug_key" UNIQUE,
|
||||||
|
"display_name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"icon" text,
|
||||||
|
"picture_url" text,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"editable" boolean DEFAULT true NOT NULL,
|
||||||
|
"has_done" boolean DEFAULT false NOT NULL,
|
||||||
|
"default_sort" text,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"archived_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "tables_schema_name_table_name_key" UNIQUE("schema_name","table_name"),
|
||||||
|
CONSTRAINT "tables_slug_check" CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
|
||||||
|
CONSTRAINT "tables_kind_check" CHECK (kind IN ('list','core','ref'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "money"."accounts" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"opening_balance_cents" integer DEFAULT 0 NOT NULL,
|
||||||
|
"kind" text DEFAULT 'spendable' NOT NULL,
|
||||||
|
"color" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "accounts_user_id_name_unique" UNIQUE("user_id","name"),
|
||||||
|
CONSTRAINT "accounts_kind_check" CHECK (kind IN ('spendable','investment'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "money"."forecasts" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"date" date NOT NULL,
|
||||||
|
"description" text NOT NULL,
|
||||||
|
"amount_cents" integer NOT NULL,
|
||||||
|
"account_id" integer,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "money"."insurances" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"provider" text,
|
||||||
|
"coverage" text,
|
||||||
|
"premium_cents" integer,
|
||||||
|
"cadence" text DEFAULT 'monthly' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "insurances_cadence_check" CHECK (cadence IN ('weekly','monthly','yearly'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "money"."projection_settings" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL UNIQUE,
|
||||||
|
"investment_account_id" integer,
|
||||||
|
"reserve_cents" integer DEFAULT 100000 NOT NULL,
|
||||||
|
"annual_return_percent" numeric(5,2) DEFAULT '5.00' NOT NULL,
|
||||||
|
"contribution_mode" text DEFAULT 'excess' NOT NULL,
|
||||||
|
"include_recurring" boolean DEFAULT true NOT NULL,
|
||||||
|
"fixed_monthly_cents" integer DEFAULT 0 NOT NULL,
|
||||||
|
"end_date" date,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "projection_settings_contribution_mode_check" CHECK (contribution_mode IN ('excess','fixed'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "money"."recurring" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"amount_cents" integer NOT NULL,
|
||||||
|
"cadence" text NOT NULL,
|
||||||
|
"starts_on" date,
|
||||||
|
"ends_on" date,
|
||||||
|
"account_id" integer,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "recurring_cadence_check" CHECK (cadence IN ('weekly','monthly','yearly'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "money"."transactions" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"account_id" integer NOT NULL,
|
||||||
|
"date" date NOT NULL,
|
||||||
|
"description" text NOT NULL,
|
||||||
|
"amount_cents" integer NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "money"."utility_readings" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"date" date NOT NULL,
|
||||||
|
"reading" numeric(12,3) NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "utility_readings_kind_check" CHECK (kind IN ('electricity','gas','water'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "money"."utility_settings" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"rate_per_unit" numeric(12,4) DEFAULT '0' NOT NULL,
|
||||||
|
"monthly_advance_cents" integer DEFAULT 0 NOT NULL,
|
||||||
|
"billing_start" date,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "utility_settings_user_id_kind_unique" UNIQUE("user_id","kind"),
|
||||||
|
CONSTRAINT "utility_settings_kind_check" CHECK (kind IN ('electricity','gas','water'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "money"."wishlist" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"estimated_cents" integer,
|
||||||
|
"priority" integer DEFAULT 3 NOT NULL,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "cs"."item_dimensions" (
|
||||||
|
"item_id" integer,
|
||||||
|
"key" text,
|
||||||
|
"value" text NOT NULL,
|
||||||
|
CONSTRAINT "item_dimensions_pkey" PRIMARY KEY("item_id","key")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "cs"."items" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"market_hash_name" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"icon_url" text,
|
||||||
|
"item_type" text,
|
||||||
|
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "cs"."price_observations" (
|
||||||
|
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "cs"."price_observations_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||||
|
"item_id" integer NOT NULL,
|
||||||
|
"source" text NOT NULL,
|
||||||
|
"currency" text NOT NULL,
|
||||||
|
"observed_at" timestamp with time zone NOT NULL,
|
||||||
|
"price_cents" integer NOT NULL,
|
||||||
|
"volume" integer,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"imported_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "cs"."price_streams" (
|
||||||
|
"source" text,
|
||||||
|
"currency" text,
|
||||||
|
"kind" text,
|
||||||
|
CONSTRAINT "price_streams_pkey" PRIMARY KEY("source","currency","kind")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "house"."alert_rules" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"offer" text DEFAULT 'rent' NOT NULL,
|
||||||
|
"min_price" integer,
|
||||||
|
"max_price" integer,
|
||||||
|
"min_area" integer,
|
||||||
|
"min_bedrooms" integer,
|
||||||
|
"cities" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"property_types" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "alert_rules_offer_check" CHECK (offer IN ('rent', 'buy'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "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,
|
||||||
|
"property_type" text NOT NULL,
|
||||||
|
"title" text,
|
||||||
|
"street" text,
|
||||||
|
"postal_code" text,
|
||||||
|
"city" text,
|
||||||
|
"city_slug" text,
|
||||||
|
"neighbourhood" text,
|
||||||
|
"price_eur" integer,
|
||||||
|
"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 DEFAULT '{}' NOT NULL,
|
||||||
|
"lat" double precision,
|
||||||
|
"lng" double precision,
|
||||||
|
"offered_at" timestamp with time zone,
|
||||||
|
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"first_seen_lane" text DEFAULT 'slow' NOT NULL,
|
||||||
|
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"detail_fetched_at" timestamp with time zone,
|
||||||
|
"removed_at" timestamp with time zone,
|
||||||
|
"alerted_at" timestamp with time zone,
|
||||||
|
"status" text,
|
||||||
|
"notes" text,
|
||||||
|
CONSTRAINT "listings_offer_check" CHECK (offer IN ('rent', 'buy')),
|
||||||
|
CONSTRAINT "listings_price_eur_check" CHECK (price_eur IS NULL OR price_eur >= 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "house"."price_history" (
|
||||||
|
"listing_id" integer,
|
||||||
|
"observed_at" timestamp with time zone DEFAULT now(),
|
||||||
|
"price_eur" integer NOT NULL,
|
||||||
|
CONSTRAINT "price_history_pkey" PRIMARY KEY("listing_id","observed_at")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "house"."searches" (
|
||||||
|
"search_url" text PRIMARY KEY,
|
||||||
|
"source" text NOT NULL,
|
||||||
|
"lane" text NOT NULL,
|
||||||
|
"first_synced_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"last_synced_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"last_found" integer DEFAULT 0 NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "house"."worker_runs" (
|
||||||
|
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "house"."worker_runs_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||||
|
"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" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "account_userId_idx" ON "auth"."account" ("user_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "account_issuer_account_id_unique" ON "auth"."account" ("issuer","account_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "session_userId_idx" ON "auth"."session" ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "verification_identifier_idx" ON "auth"."verification" ("identifier");--> statement-breakpoint
|
||||||
|
CREATE INDEX "pokemon_encounters_pokemon_id_game_id_index" ON "ref"."pokemon_encounters" ("pokemon_id","game_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "pokemon_encounters_location_id_index" ON "ref"."pokemon_encounters" ("location_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "pokemon_evolutions_game_id_index" ON "ref"."pokemon_evolutions" ("game_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "pokemon_game_entries_game_id_index" ON "ref"."pokemon_game_entries" ("game_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "catch_em_all_entries_user_id_index" ON "games"."catch_em_all_entries" ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "catch_em_all_games_user_id_index" ON "games"."catch_em_all_games" ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "tracker_items_game_id_index" ON "ref"."tracker_items" ("game_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "tracker_progress_user_id_index" ON "games"."tracker_progress" ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "osu_entries_user_id_kind_index" ON "games"."osu_entries" ("user_id","kind");--> statement-breakpoint
|
||||||
|
CREATE INDEX "pokemon_catches_user_id_game_id_pokemon_id_index" ON "games"."pokemon_catches" ("user_id","game_id","pokemon_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "pokemon_dex_numbers_game_id_index" ON "ref"."pokemon_dex_numbers" ("game_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "pokemon_seen_user_id_index" ON "games"."pokemon_seen" ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "columns_table_id_index" ON "meta"."columns" ("table_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "accounts_user_id_index" ON "money"."accounts" ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "forecasts_user_id_date_index" ON "money"."forecasts" ("user_id","date");--> statement-breakpoint
|
||||||
|
CREATE INDEX "insurances_user_id_index" ON "money"."insurances" ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "recurring_user_id_index" ON "money"."recurring" ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "transactions_user_id_date_id_index" ON "money"."transactions" ("user_id","date","id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "transactions_account_id_index" ON "money"."transactions" ("account_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "utility_readings_user_id_kind_date_index" ON "money"."utility_readings" ("user_id","kind","date");--> statement-breakpoint
|
||||||
|
CREATE INDEX "wishlist_user_id_index" ON "money"."wishlist" ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "item_dimensions_filter_idx" ON "cs"."item_dimensions" ("key","value","item_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "items_hash_idx" ON "cs"."items" ("market_hash_name");--> statement-breakpoint
|
||||||
|
CREATE INDEX "items_name_idx" ON "cs"."items" ("name");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "price_observations_unique_idx" ON "cs"."price_observations" ("item_id","source","currency","observed_at","kind");--> statement-breakpoint
|
||||||
|
CREATE INDEX "price_observations_lookup_idx" ON "cs"."price_observations" ("item_id","source","currency","observed_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "price_observations_time_idx" ON "cs"."price_observations" ("observed_at");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "listings_source_idx" ON "house"."listings" ("source","source_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "listings_active_idx" ON "house"."listings" ("offer","first_seen_at" DESC) WHERE removed_at IS NULL;--> statement-breakpoint
|
||||||
|
CREATE INDEX "listings_city_idx" ON "house"."listings" ("source","offer","city_slug");--> statement-breakpoint
|
||||||
|
CREATE INDEX "listings_details_idx" ON "house"."listings" ("first_seen_at" DESC) WHERE detail_fetched_at IS NULL;--> statement-breakpoint
|
||||||
|
CREATE INDEX "worker_runs_created_idx" ON "house"."worker_runs" ("created_at" DESC);--> statement-breakpoint
|
||||||
|
ALTER TABLE "auth"."account" ADD CONSTRAINT "account_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "auth"."session" ADD CONSTRAINT "session_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "site"."task_topics" ADD CONSTRAINT "task_topics_task_id_tasks_id_fkey" FOREIGN KEY ("task_id") REFERENCES "site"."tasks"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "site"."task_topics" ADD CONSTRAINT "task_topics_topic_id_topics_id_fkey" FOREIGN KEY ("topic_id") REFERENCES "site"."topics"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."catch_em_all_settings" ADD CONSTRAINT "catch_em_all_settings_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ref"."pokemon_encounters" ADD CONSTRAINT "pokemon_encounters_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ref"."pokemon_encounters" ADD CONSTRAINT "pokemon_encounters_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ref"."pokemon_encounters" ADD CONSTRAINT "pokemon_encounters_location_id_pokemon_locations_id_fkey" FOREIGN KEY ("location_id") REFERENCES "ref"."pokemon_locations"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ref"."pokemon_evolutions" ADD CONSTRAINT "pokemon_evolutions_from_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("from_pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ref"."pokemon_evolutions" ADD CONSTRAINT "pokemon_evolutions_to_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("to_pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ref"."pokemon_evolutions" ADD CONSTRAINT "pokemon_evolutions_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ref"."pokemon_game_entries" ADD CONSTRAINT "pokemon_game_entries_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ref"."pokemon_game_entries" ADD CONSTRAINT "pokemon_game_entries_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."catch_em_all_entries" ADD CONSTRAINT "catch_em_all_entries_HeABXvGNiB6D_fkey" FOREIGN KEY ("pokemon_game_entry_id") REFERENCES "ref"."pokemon_game_entries"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."catch_em_all_entries" ADD CONSTRAINT "catch_em_all_entries_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."catch_em_all_games" ADD CONSTRAINT "catch_em_all_games_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."catch_em_all_games" ADD CONSTRAINT "catch_em_all_games_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ref"."tracker_items" ADD CONSTRAINT "tracker_items_game_id_tracker_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."tracker_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."tracker_progress" ADD CONSTRAINT "tracker_progress_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."tracker_progress" ADD CONSTRAINT "tracker_progress_item_id_tracker_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "ref"."tracker_items"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."osu_entries" ADD CONSTRAINT "osu_entries_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."pokemon_catches" ADD CONSTRAINT "pokemon_catches_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."pokemon_catches" ADD CONSTRAINT "pokemon_catches_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."pokemon_catches" ADD CONSTRAINT "pokemon_catches_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ref"."pokemon_dex_numbers" ADD CONSTRAINT "pokemon_dex_numbers_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ref"."pokemon_dex_numbers" ADD CONSTRAINT "pokemon_dex_numbers_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ref"."pokemon_game_research" ADD CONSTRAINT "pokemon_game_research_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."pokemon_journeys" ADD CONSTRAINT "pokemon_journeys_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."pokemon_journeys" ADD CONSTRAINT "pokemon_journeys_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ref"."pokemon_learnsets" ADD CONSTRAINT "pokemon_learnsets_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."pokemon_milestones" ADD CONSTRAINT "pokemon_milestones_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."pokemon_milestones" ADD CONSTRAINT "pokemon_milestones_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ref"."pokemon_research" ADD CONSTRAINT "pokemon_research_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."pokemon_seen" ADD CONSTRAINT "pokemon_seen_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."pokemon_seen" ADD CONSTRAINT "pokemon_seen_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."pokemon_seen" ADD CONSTRAINT "pokemon_seen_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."tracker_challenge_progress" ADD CONSTRAINT "tracker_challenge_progress_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."tracker_challenge_progress" ADD CONSTRAINT "tracker_challenge_progress_HtzyFtOyUAhk_fkey" FOREIGN KEY ("challenge_id") REFERENCES "ref"."tracker_challenges"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ref"."tracker_challenges" ADD CONSTRAINT "tracker_challenges_game_id_tracker_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."tracker_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."tracker_ranks" ADD CONSTRAINT "tracker_ranks_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."tracker_ranks" ADD CONSTRAINT "tracker_ranks_game_id_tracker_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."tracker_games"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."tracker_weapon_ranks" ADD CONSTRAINT "tracker_weapon_ranks_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "games"."tracker_weapon_ranks" ADD CONSTRAINT "tracker_weapon_ranks_item_id_tracker_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "ref"."tracker_items"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "animaldex"."sighting_photos" ADD CONSTRAINT "sighting_photos_sighting_id_sightings_id_fkey" FOREIGN KEY ("sighting_id") REFERENCES "animaldex"."sightings"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "animaldex"."sightings" ADD CONSTRAINT "sightings_animal_id_animals_id_fkey" FOREIGN KEY ("animal_id") REFERENCES "animaldex"."animals"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "meta"."columns" ADD CONSTRAINT "columns_table_id_tables_id_fkey" FOREIGN KEY ("table_id") REFERENCES "meta"."tables"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."accounts" ADD CONSTRAINT "accounts_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."forecasts" ADD CONSTRAINT "forecasts_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."forecasts" ADD CONSTRAINT "forecasts_account_id_accounts_id_fkey" FOREIGN KEY ("account_id") REFERENCES "money"."accounts"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."insurances" ADD CONSTRAINT "insurances_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."projection_settings" ADD CONSTRAINT "projection_settings_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."projection_settings" ADD CONSTRAINT "projection_settings_investment_account_id_accounts_id_fkey" FOREIGN KEY ("investment_account_id") REFERENCES "money"."accounts"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."recurring" ADD CONSTRAINT "recurring_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."recurring" ADD CONSTRAINT "recurring_account_id_accounts_id_fkey" FOREIGN KEY ("account_id") REFERENCES "money"."accounts"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."transactions" ADD CONSTRAINT "transactions_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."transactions" ADD CONSTRAINT "transactions_account_id_accounts_id_fkey" FOREIGN KEY ("account_id") REFERENCES "money"."accounts"("id");--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."utility_readings" ADD CONSTRAINT "utility_readings_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."utility_settings" ADD CONSTRAINT "utility_settings_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."wishlist" ADD CONSTRAINT "wishlist_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "cs"."item_dimensions" ADD CONSTRAINT "item_dimensions_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "cs"."items"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "cs"."price_observations" ADD CONSTRAINT "price_observations_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "cs"."items"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "house"."price_history" ADD CONSTRAINT "price_history_listing_id_listings_id_fkey" FOREIGN KEY ("listing_id") REFERENCES "house"."listings"("id") ON DELETE CASCADE;
|
||||||
10307
drizzle/20261023215112_baseline/snapshot.json
Normal file
10307
drizzle/20261023215112_baseline/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
146
drizzle/20261023232911_books/migration.sql
Normal file
146
drizzle/20261023232911_books/migration.sql
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
CREATE SCHEMA "books";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TYPE "books"."metadata_status" AS ENUM('pending', 'matched', 'low_confidence', 'not_found', 'manual');--> statement-breakpoint
|
||||||
|
CREATE TABLE "books"."files" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"work_id" integer NOT NULL,
|
||||||
|
"path" text NOT NULL,
|
||||||
|
"format" text NOT NULL,
|
||||||
|
"size_bytes" bigint NOT NULL,
|
||||||
|
"sha256" text NOT NULL,
|
||||||
|
"md5" text,
|
||||||
|
"source" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "books"."notes" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"work_id" integer NOT NULL,
|
||||||
|
"file_id" integer,
|
||||||
|
"locator" text,
|
||||||
|
"page" integer,
|
||||||
|
"highlight" text,
|
||||||
|
"note" text,
|
||||||
|
"color" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "books"."progress" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"work_id" integer NOT NULL,
|
||||||
|
"file_id" integer,
|
||||||
|
"locator" text,
|
||||||
|
"page" integer,
|
||||||
|
"percent" real DEFAULT 0 NOT NULL,
|
||||||
|
"started_at" timestamp with time zone,
|
||||||
|
"finished_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "progress_percent_check" CHECK ("percent" between 0 and 100)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "books"."shelf_works" (
|
||||||
|
"shelf_id" integer,
|
||||||
|
"work_id" integer,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"added_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "shelf_works_pkey" PRIMARY KEY("shelf_id","work_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "books"."shelves" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "books"."works" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"subtitle" text,
|
||||||
|
"authors" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"isbn10" text,
|
||||||
|
"isbn13" text,
|
||||||
|
"publisher" text,
|
||||||
|
"year" integer,
|
||||||
|
"language" text,
|
||||||
|
"page_count" integer,
|
||||||
|
"description" text,
|
||||||
|
"tags" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"cover_url" text,
|
||||||
|
"cover_path" text,
|
||||||
|
"openlibrary_work_id" text,
|
||||||
|
"openlibrary_edition_id" text,
|
||||||
|
"google_books_id" text,
|
||||||
|
"annas_md5" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"source_refs" jsonb DEFAULT '{}' NOT NULL,
|
||||||
|
"metadata_status" "books"."metadata_status" DEFAULT 'pending'::"books"."metadata_status" NOT NULL,
|
||||||
|
"metadata_source" text,
|
||||||
|
"metadata_fetched_at" timestamp with time zone,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "works_year_check" CHECK ("year" is null or "year" between 0 and 2100)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "files_path_key" ON "books"."files" ("path");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "files_sha256_key" ON "books"."files" ("sha256");--> statement-breakpoint
|
||||||
|
CREATE INDEX "files_work_id_idx" ON "books"."files" ("work_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "notes_work_id_idx" ON "books"."notes" ("work_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "progress_work_id_key" ON "books"."progress" ("work_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "shelf_works_work_id_idx" ON "books"."shelf_works" ("work_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "shelves_slug_key" ON "books"."shelves" ("slug");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "works_isbn13_key" ON "books"."works" ("isbn13") WHERE "isbn13" is not null;--> statement-breakpoint
|
||||||
|
CREATE INDEX "works_title_idx" ON "books"."works" (lower("title"));--> statement-breakpoint
|
||||||
|
ALTER TABLE "books"."files" ADD CONSTRAINT "files_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "books"."notes" ADD CONSTRAINT "notes_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "books"."notes" ADD CONSTRAINT "notes_file_id_files_id_fkey" FOREIGN KEY ("file_id") REFERENCES "books"."files"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "books"."progress" ADD CONSTRAINT "progress_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "books"."progress" ADD CONSTRAINT "progress_file_id_files_id_fkey" FOREIGN KEY ("file_id") REFERENCES "books"."files"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "books"."shelf_works" ADD CONSTRAINT "shelf_works_shelf_id_shelves_id_fkey" FOREIGN KEY ("shelf_id") REFERENCES "books"."shelves"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "books"."shelf_works" ADD CONSTRAINT "shelf_works_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Hand-written: updated_at triggers, grants, default shelves, GUI registration.
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH t IN ARRAY ARRAY['works','files','shelves','progress','notes'] LOOP
|
||||||
|
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON books.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT USAGE ON SCHEMA books TO personal_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA books TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA books TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA books GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA books GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||||
|
GRANT USAGE ON SCHEMA books TO personal_ro;
|
||||||
|
GRANT SELECT ON ALL TABLES IN SCHEMA books TO personal_ro;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA books GRANT SELECT ON TABLES TO personal_ro;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO books.shelves (slug, name, sort_order) VALUES
|
||||||
|
('to-read', 'To read', 10), ('reading', 'Reading', 20), ('done', 'Done', 30), ('reference', 'Reference', 40)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||||
|
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||||
|
VALUES ('books','works','books-works','Books','core',true,false,1000),
|
||||||
|
('books','shelves','books-shelves','Book shelves','core',true,false,1000),
|
||||||
|
('books','files','books-files','Book files','core',false,false,1000)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
PERFORM meta.sync();
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
11587
drizzle/20261023232911_books/snapshot.json
Normal file
11587
drizzle/20261023232911_books/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
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;
|
|
||||||
@@ -3,9 +3,10 @@ import postgres from 'postgres';
|
|||||||
// Read-only fingerprints: lets a deployment verify saved progress without logging it.
|
// Read-only fingerprints: lets a deployment verify saved progress without logging it.
|
||||||
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
||||||
const client = postgres(process.env.DB_URL, { max: 1 });
|
const client = postgres(process.env.DB_URL, { max: 1 });
|
||||||
|
// All saved progress lives in schema `games` since the schema reorg.
|
||||||
const tables = [
|
const tables = [
|
||||||
'user_entries',
|
'catch_em_all_entries',
|
||||||
'user_games',
|
'catch_em_all_games',
|
||||||
'catch_em_all_settings',
|
'catch_em_all_settings',
|
||||||
'pokemon_journeys',
|
'pokemon_journeys',
|
||||||
'pokemon_catches',
|
'pokemon_catches',
|
||||||
@@ -20,11 +21,11 @@ const tables = [
|
|||||||
try {
|
try {
|
||||||
const result: Record<string, unknown> = {};
|
const result: Record<string, unknown> = {};
|
||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
const [exists] = await client`select to_regclass(${'public.' + table}) is not null as present`;
|
const [exists] = await client`select to_regclass(${'games.' + table}) is not null as present`;
|
||||||
if (!exists.present) continue;
|
if (!exists.present) continue;
|
||||||
const [row] = await client.unsafe(`select count(*)::int as rows,
|
const [row] = await client.unsafe(`select count(*)::int as rows,
|
||||||
md5(coalesce(string_agg(record, E'\\n' order by record), '')) as fingerprint
|
md5(coalesce(string_agg(record, E'\\n' order by record), '')) as fingerprint
|
||||||
from (select row_to_json(t)::text as record from "${table}" t) s`);
|
from (select row_to_json(t)::text as record from games."${table}" t) s`);
|
||||||
result[table] = row;
|
result[table] = row;
|
||||||
}
|
}
|
||||||
console.log(JSON.stringify(result, null, 2));
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
945
scripts/books-ingest.py
Normal file
945
scripts/books-ingest.py
Normal file
@@ -0,0 +1,945 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Books ingest + metadata enrichment.
|
||||||
|
|
||||||
|
Builds books.works / books.files from:
|
||||||
|
* lists.books rows (the old GUI list) -> placeholders
|
||||||
|
* Obsidian "Book links.md" md5s resolved via the libgen JSON API -> placeholders
|
||||||
|
* ebook files dropped in $BOOKS_ROOT/inbox (sha256-deduped) -> works + files
|
||||||
|
|
||||||
|
Metadata: embedded (EPUB OPF / PDF info + ISBN scan) -> Open Library -> Google Books
|
||||||
|
(-> arXiv for anything tagged paper). Covers go to $BOOKS_ROOT/covers/<work_id>.jpg.
|
||||||
|
Files are moved to $BOOKS_ROOT/library/<Author>/<Title>/.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
DB_URL=postgresql://... BOOKS_ROOT=~/books \
|
||||||
|
python scripts/books-ingest.py --obsidian-json resolved.json [--reset] [--dry-run]
|
||||||
|
|
||||||
|
Network calls are cached in $BOOKS_ROOT/.cache/http so reruns are cheap and polite.
|
||||||
|
Refuses to run against the prod database name ('personal') unless --allow-prod.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse, difflib, hashlib, html, json, os, re, shutil, sys, time, unicodedata, urllib.parse, urllib.request, zipfile
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
UA = "stan-books-ingest/0.1 (personal library catalogue; low volume)"
|
||||||
|
LANG = {"eng": "en", "dut": "nl", "nld": "nl", "ger": "de", "deu": "de", "fre": "fr", "fra": "fr", "spa": "es", "ita": "it",
|
||||||
|
"por": "pt", "rus": "ru", "jpn": "ja", "kor": "ko", "chi": "zh", "zho": "zh", "lat": "la", "gre": "el", "swe": "sv"}
|
||||||
|
BOOK_EXT = {".pdf", ".epub", ".mobi", ".azw3", ".azw", ".lit", ".djvu", ".docx", ".fb2", ".cbz"}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- helpers
|
||||||
|
|
||||||
|
def strip_accents(s: str) -> str:
|
||||||
|
s = (s or "").replace("'", "").replace("’", "")
|
||||||
|
return "".join(c for c in unicodedata.normalize("NFKD", s) if not unicodedata.combining(c))
|
||||||
|
|
||||||
|
def norm_title(t: str) -> str:
|
||||||
|
t = strip_accents(html.unescape(t or "")).lower().replace("&", " and ")
|
||||||
|
t = re.split(r"\s*[:(\[]\s*|\s+[-–—]\s+|_\s", t, maxsplit=1)[0] # main title only
|
||||||
|
t = re.sub(r"[^a-z0-9]+", " ", t).strip()
|
||||||
|
t = re.sub(r"^(the|a|an|de|het|een)\s+", "", t)
|
||||||
|
t = re.sub(r"\s+(\d+(st|nd|rd|th)?\s+edition|ed)$", "", t)
|
||||||
|
return t
|
||||||
|
|
||||||
|
def full_norm(t: str) -> str:
|
||||||
|
return re.sub(r"[^a-z0-9]+", " ", strip_accents(t or "").lower()).strip()
|
||||||
|
|
||||||
|
def sim(a: str, b: str) -> float:
|
||||||
|
na, nb = norm_title(a), norm_title(b)
|
||||||
|
a, b = full_norm(a), full_norm(b)
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
if a == b:
|
||||||
|
return 1.0
|
||||||
|
# volume / part / edition markers must agree ("Finance I" vs "Finance II", "Part 1" vs "Part 2")
|
||||||
|
marks = lambda x: {t for t in x.split() if re.fullmatch(r"\d{1,2}|[ivx]{1,4}", t)}
|
||||||
|
if marks(a) != marks(b) and marks(a) and marks(b):
|
||||||
|
return 0.5
|
||||||
|
r = difflib.SequenceMatcher(None, a, b).ratio()
|
||||||
|
if na and na == nb and len(na) >= 15:
|
||||||
|
r = max(r, 0.93)
|
||||||
|
return r
|
||||||
|
|
||||||
|
def surnames(authors: list[str]) -> set[str]:
|
||||||
|
out = set()
|
||||||
|
for a in authors or []:
|
||||||
|
a = strip_accents(a).lower()
|
||||||
|
if "," in a: # "King, Michael"
|
||||||
|
a = a.split(",")[0]
|
||||||
|
parts = re.findall(r"[a-z]{3,}", a)
|
||||||
|
if parts:
|
||||||
|
out.add(parts[-1] if "," not in a else parts[0])
|
||||||
|
out.update(parts)
|
||||||
|
return out
|
||||||
|
|
||||||
|
JUNK_AUTHORS = {"admin", "administrator", "user", "owner", "unknown", "oreily", "oreilly", "o'reilly", "author", "john", "mr2"}
|
||||||
|
|
||||||
|
def clean_authors(names) -> list[str]:
|
||||||
|
out = []
|
||||||
|
for n in names or []:
|
||||||
|
n = re.sub(r"\s+", " ", (n or "").strip(" ,;"))
|
||||||
|
low = n.lower()
|
||||||
|
if not n or low in JUNK_AUTHORS or re.search(r"\d|@|\.(com|org|net)\b", n) or " " not in n and "," not in n and "." not in n:
|
||||||
|
continue
|
||||||
|
if n.isupper():
|
||||||
|
n = n.title()
|
||||||
|
out.append(n)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def authors_compatible(a: list[str], b: list[str]) -> bool:
|
||||||
|
if not a or not b:
|
||||||
|
return True
|
||||||
|
return bool(surnames(a) & surnames(b))
|
||||||
|
|
||||||
|
def isbn_ok(s: str) -> str | None:
|
||||||
|
s = re.sub(r"[^0-9Xx]", "", s or "").upper()
|
||||||
|
if len(s) == 13 and s.isdigit() and s[:3] in ("978", "979"):
|
||||||
|
tot = sum(int(c) * (1 if i % 2 == 0 else 3) for i, c in enumerate(s[:12]))
|
||||||
|
return s if (10 - tot % 10) % 10 == int(s[12]) else None
|
||||||
|
if len(s) == 10 and s[:9].isdigit():
|
||||||
|
tot = sum((10 - i) * int(c) for i, c in enumerate(s[:9]))
|
||||||
|
chk = (11 - tot % 11) % 11
|
||||||
|
return s if (("X" if chk == 10 else str(chk)) == s[9]) else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def to13(s: str) -> str | None:
|
||||||
|
s = isbn_ok(s)
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
if len(s) == 13:
|
||||||
|
return s
|
||||||
|
core = "978" + s[:9]
|
||||||
|
tot = sum(int(c) * (1 if i % 2 == 0 else 3) for i, c in enumerate(core))
|
||||||
|
return core + str((10 - tot % 10) % 10)
|
||||||
|
|
||||||
|
def to10(s13: str) -> str | None:
|
||||||
|
if not s13 or not s13.startswith("978"):
|
||||||
|
return None
|
||||||
|
core = s13[3:12]
|
||||||
|
tot = sum((10 - i) * int(c) for i, c in enumerate(core))
|
||||||
|
chk = (11 - tot % 11) % 11
|
||||||
|
return core + ("X" if chk == 10 else str(chk))
|
||||||
|
|
||||||
|
ISBN_RE = re.compile(r"ISBN(?:-1[03])?[:\s]*((?:97[89][\s-]?)?(?:\d[\s-]?){9}[\dXx])", re.I)
|
||||||
|
|
||||||
|
def find_isbns(text: str) -> list[str]:
|
||||||
|
out = []
|
||||||
|
for m in ISBN_RE.finditer(text or ""):
|
||||||
|
i = to13(m.group(1))
|
||||||
|
if i and i not in out:
|
||||||
|
out.append(i)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def safe_name(s: str, n: int = 80) -> str:
|
||||||
|
s = strip_accents(s or "").replace("/", "-").replace("\\", "-")
|
||||||
|
s = re.sub(r'[<>:"|?*\x00-\x1f]', "", s)
|
||||||
|
s = re.sub(r"\s+", " ", s).strip(" .")
|
||||||
|
return (s[:n].rstrip(" .") or "Untitled")
|
||||||
|
|
||||||
|
def year_of(v) -> int | None:
|
||||||
|
m = re.search(r"(1[5-9]\d\d|20\d\d)", str(v or ""))
|
||||||
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- http with cache
|
||||||
|
|
||||||
|
class Http:
|
||||||
|
def __init__(self, cache: Path, delay: float = 1.0):
|
||||||
|
self.cache, self.delay, self.last = cache, delay, 0.0
|
||||||
|
cache.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.calls = 0
|
||||||
|
self.dead_hosts: set[str] = set()
|
||||||
|
|
||||||
|
def _get(self, url: str, binary=False):
|
||||||
|
host = urllib.parse.urlparse(url).netloc
|
||||||
|
if host in self.dead_hosts: # daily quota hit (Google Books): stop hammering it this run
|
||||||
|
return 429, b""
|
||||||
|
wait = self.delay - (time.time() - self.last)
|
||||||
|
if wait > 0:
|
||||||
|
time.sleep(wait)
|
||||||
|
self.last = time.time()
|
||||||
|
self.calls += 1
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "*/*"})
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return r.status, r.read()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 429 and "googleapis" in host:
|
||||||
|
self.dead_hosts.add(host)
|
||||||
|
log(f" {host}: quota exhausted (429), skipping it for the rest of this run")
|
||||||
|
return 429, b""
|
||||||
|
if e.code in (429, 503) and attempt < 2:
|
||||||
|
time.sleep(5 * (attempt + 1))
|
||||||
|
continue
|
||||||
|
return e.code, b""
|
||||||
|
except Exception:
|
||||||
|
if attempt < 2:
|
||||||
|
time.sleep(3)
|
||||||
|
continue
|
||||||
|
return 0, b""
|
||||||
|
return 0, b""
|
||||||
|
|
||||||
|
def json(self, url: str):
|
||||||
|
key = self.cache / (hashlib.sha1(url.encode()).hexdigest() + ".json")
|
||||||
|
if key.exists():
|
||||||
|
return json.loads(key.read_text())
|
||||||
|
status, body = self._get(url)
|
||||||
|
data = None
|
||||||
|
if status == 200:
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
except Exception:
|
||||||
|
data = None
|
||||||
|
if status in (200, 404): # don't cache transient errors
|
||||||
|
key.write_text(json.dumps(data))
|
||||||
|
return data
|
||||||
|
|
||||||
|
def text(self, url: str):
|
||||||
|
key = self.cache / (hashlib.sha1(url.encode()).hexdigest() + ".txt")
|
||||||
|
if key.exists():
|
||||||
|
return key.read_text()
|
||||||
|
status, body = self._get(url)
|
||||||
|
if status == 200:
|
||||||
|
key.write_text(body.decode("utf-8", "replace"))
|
||||||
|
return body.decode("utf-8", "replace")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def download(self, url: str, dest: Path) -> bool:
|
||||||
|
status, body = self._get(url, binary=True)
|
||||||
|
if status == 200 and len(body) > 1500: # skip 1x1 placeholders
|
||||||
|
dest.write_bytes(body)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- model
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Work:
|
||||||
|
title: str
|
||||||
|
authors: list[str] = field(default_factory=list)
|
||||||
|
subtitle: str | None = None
|
||||||
|
isbns: list[str] = field(default_factory=list) # isbn13s, best first
|
||||||
|
publisher: str | None = None
|
||||||
|
year: int | None = None
|
||||||
|
language: str | None = None
|
||||||
|
page_count: int | None = None
|
||||||
|
description: str | None = None
|
||||||
|
tags: list[str] = field(default_factory=list)
|
||||||
|
cover_url: str | None = None
|
||||||
|
cover_path: str | None = None
|
||||||
|
ol_work: str | None = None
|
||||||
|
ol_edition: str | None = None
|
||||||
|
gb_id: str | None = None
|
||||||
|
md5s: list[str] = field(default_factory=list)
|
||||||
|
refs: dict = field(default_factory=dict)
|
||||||
|
status: str = "pending"
|
||||||
|
source: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
files: list = field(default_factory=list)
|
||||||
|
shelves: set = field(default_factory=set)
|
||||||
|
origins: set = field(default_factory=set)
|
||||||
|
db_id: int | None = None
|
||||||
|
|
||||||
|
def add_ref(self, k, v):
|
||||||
|
self.refs.setdefault(k, [])
|
||||||
|
if v not in self.refs[k]:
|
||||||
|
self.refs[k].append(v)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileRec:
|
||||||
|
path: Path
|
||||||
|
rel_inbox: str
|
||||||
|
sha256: str
|
||||||
|
md5: str
|
||||||
|
size: int
|
||||||
|
fmt: str
|
||||||
|
title: str | None = None
|
||||||
|
authors: list[str] = field(default_factory=list)
|
||||||
|
isbns: list[str] = field(default_factory=list)
|
||||||
|
year: int | None = None
|
||||||
|
publisher: str | None = None
|
||||||
|
language: str | None = None
|
||||||
|
pages: int | None = None
|
||||||
|
is_paper: bool = False
|
||||||
|
broken: str | None = None
|
||||||
|
dest_rel: str | None = None
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- embedded metadata
|
||||||
|
|
||||||
|
def epub_meta(p: Path) -> dict:
|
||||||
|
out: dict = {}
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(p) as z:
|
||||||
|
cont = ET.fromstring(z.read("META-INF/container.xml"))
|
||||||
|
opf_path = next(e.get("full-path") for e in cont.iter() if e.tag.endswith("rootfile"))
|
||||||
|
opf = ET.fromstring(z.read(opf_path))
|
||||||
|
md = next((e for e in opf.iter() if e.tag.endswith("metadata")), None)
|
||||||
|
if md is not None:
|
||||||
|
for e in md:
|
||||||
|
tag = e.tag.split("}")[-1]
|
||||||
|
txt = (e.text or "").strip()
|
||||||
|
if not txt:
|
||||||
|
continue
|
||||||
|
if tag == "title" and "title" not in out:
|
||||||
|
out["title"] = txt
|
||||||
|
elif tag == "creator":
|
||||||
|
out.setdefault("authors", []).append(txt)
|
||||||
|
elif tag == "identifier":
|
||||||
|
i = to13(txt.replace("urn:isbn:", ""))
|
||||||
|
if i:
|
||||||
|
out.setdefault("isbns", []).append(i)
|
||||||
|
elif tag == "publisher":
|
||||||
|
out.setdefault("publisher", txt)
|
||||||
|
elif tag == "date":
|
||||||
|
out.setdefault("year", year_of(txt))
|
||||||
|
elif tag == "language":
|
||||||
|
out.setdefault("language", txt[:2].lower())
|
||||||
|
# scan the first few content docs (copyright page) for ISBNs
|
||||||
|
names = [n for n in z.namelist() if n.lower().endswith((".xhtml", ".html", ".htm"))][:12]
|
||||||
|
for n in names:
|
||||||
|
for i in find_isbns(re.sub(r"<[^>]+>", " ", z.read(n).decode("utf-8", "ignore"))):
|
||||||
|
out.setdefault("isbns", [])
|
||||||
|
if i not in out["isbns"]:
|
||||||
|
out["isbns"].append(i)
|
||||||
|
except Exception as e: # noqa
|
||||||
|
out["error"] = f"epub: {e.__class__.__name__}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
def pdf_meta(p: Path) -> dict:
|
||||||
|
out: dict = {}
|
||||||
|
try:
|
||||||
|
from pypdf import PdfReader
|
||||||
|
import logging
|
||||||
|
logging.getLogger("pypdf").setLevel(logging.ERROR)
|
||||||
|
r = PdfReader(str(p), strict=False)
|
||||||
|
out["pages"] = len(r.pages)
|
||||||
|
info = r.metadata or {}
|
||||||
|
t = (info.get("/Title") or "").strip() if info else ""
|
||||||
|
a = (info.get("/Author") or "").strip() if info else ""
|
||||||
|
if t:
|
||||||
|
out["title"] = t
|
||||||
|
if a:
|
||||||
|
out["authors"] = clean_authors(re.split(r";|\band\b|&", a))
|
||||||
|
text = ""
|
||||||
|
for i in range(min(len(r.pages), 10)):
|
||||||
|
try:
|
||||||
|
text += (r.pages[i].extract_text() or "") + "\n"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
out["isbns"] = find_isbns(text)
|
||||||
|
if re.search(r"arXiv:\d{4}\.\d{4,5}", text):
|
||||||
|
out["arxiv"] = re.search(r"arXiv:(\d{4}\.\d{4,5})", text).group(1)
|
||||||
|
except Exception as e: # noqa
|
||||||
|
out["error"] = f"pdf: {e.__class__.__name__}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
ANNAS_RE = re.compile(r"^(?P<title>.+?) -- (?P<author>.+?) -- (?P<rest>.+?) -- (?P<md5>[0-9a-f]{32}) -- Anna’s Archive$")
|
||||||
|
|
||||||
|
def filename_meta(stem: str) -> dict:
|
||||||
|
out: dict = {}
|
||||||
|
m = ANNAS_RE.match(stem)
|
||||||
|
if m:
|
||||||
|
out["title"] = m["title"].replace("_ ", ": ").replace("_", " ")
|
||||||
|
out["authors"] = [a.strip().replace("_", ".") for a in m["author"].split(",") if a.strip()]
|
||||||
|
out["md5"] = m["md5"]
|
||||||
|
for part in m["rest"].split(" -- "):
|
||||||
|
i = to13(part)
|
||||||
|
if i:
|
||||||
|
out.setdefault("isbns", []).append(i)
|
||||||
|
elif year_of(part) and "year" not in out:
|
||||||
|
out["year"] = year_of(part)
|
||||||
|
return out
|
||||||
|
s = re.sub(r"\s*-\s*libgen\.(li|rs|is)$", "", stem)
|
||||||
|
s = re.sub(r"\s*\((z-library|z-lib\.org|1lib\.\w+)\)", "", s, flags=re.I)
|
||||||
|
s = re.sub(r"\s+(penguin (modern )?classics)$", "", s, flags=re.I)
|
||||||
|
s = re.sub(r"\s+0$", "", s)
|
||||||
|
mm = re.match(r"^([^-]+?) : ([^-]+?) - (.+)$", s) # "A : B - Title" (libgen multi-author)
|
||||||
|
if mm:
|
||||||
|
out["authors"] = [mm[1].strip(), mm[2].strip()]
|
||||||
|
s = mm[3]
|
||||||
|
pm = re.search(r"\s*\((\d{4}),\s*([^)]+)\)\s*$", s)
|
||||||
|
if pm:
|
||||||
|
out["year"], out["publisher"] = int(pm[1]), pm[2].strip()
|
||||||
|
s = s[: pm.start()]
|
||||||
|
# "Author - Title" (only when the left part looks like a person name)
|
||||||
|
mm = re.match(r"^([A-Z][a-z]+(?: [A-Z][a-z]+){1,2}) - (.+)$", s)
|
||||||
|
if mm:
|
||||||
|
out["authors"] = [mm[1]]
|
||||||
|
s = mm[2]
|
||||||
|
mm = re.match(r"^(.+?) by ([A-Z][\w.]+(?: [A-Z][\w.]+){1,2})$", s)
|
||||||
|
if mm:
|
||||||
|
s, out["authors"] = mm[1], [mm[2]]
|
||||||
|
if s.count("-") >= 4 and " " not in s: # The-Lean-Startup_-How-...-Eric-Ries-2011-Crown-Business-030788791X-
|
||||||
|
for tok in s.split("-"):
|
||||||
|
i = to13(tok)
|
||||||
|
if i:
|
||||||
|
out.setdefault("isbns", []).append(i)
|
||||||
|
s = s.split("_")[0].replace("-", " ")
|
||||||
|
s = s.replace("_ ", ": ").replace("_", " ")
|
||||||
|
if s.count(".") >= 3 and s.count(" ") <= 1: # Sipser_Introduction.to.the.Theory.of.Computation.3E
|
||||||
|
s = s.replace(".", " ")
|
||||||
|
s = re.sub(r"\s+", " ", s).strip(" -")
|
||||||
|
out["title"] = s
|
||||||
|
return out
|
||||||
|
|
||||||
|
def good_embedded_title(t: str | None, fname_title: str) -> bool:
|
||||||
|
if not t or len(t) < 4:
|
||||||
|
return False
|
||||||
|
bad = ("untitled", "microsoft word", ".doc", ".pdf", ".indd", "layout 1", "unknown", "title", "cover")
|
||||||
|
tl = t.lower()
|
||||||
|
if any(b in tl for b in bad) or re.fullmatch(r"[\w-]+\.\w+", t) or re.fullmatch(r"[0-9a-f\-_ ]+", tl):
|
||||||
|
return False
|
||||||
|
return sim(t, fname_title) > 0.55
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- enrichment
|
||||||
|
|
||||||
|
class Enricher:
|
||||||
|
def __init__(self, http: Http):
|
||||||
|
self.h = http
|
||||||
|
|
||||||
|
def ol_isbn(self, isbn: str):
|
||||||
|
ed = self.h.json(f"https://openlibrary.org/isbn/{isbn}.json")
|
||||||
|
if not ed or "title" not in ed:
|
||||||
|
return None
|
||||||
|
rec = {"title": ed.get("title"), "subtitle": ed.get("subtitle"), "publisher": (ed.get("publishers") or [None])[0],
|
||||||
|
"year": year_of(ed.get("publish_date")), "pages": ed.get("number_of_pages"),
|
||||||
|
"ol_edition": ed.get("key", "").split("/")[-1] or None, "isbn": isbn,
|
||||||
|
"cover": f"https://covers.openlibrary.org/b/id/{ed['covers'][0]}-L.jpg" if ed.get("covers") and ed["covers"][0] > 0 else None,
|
||||||
|
"lang": LANG.get(((ed.get("languages") or [{}])[0].get("key") or "").split("/")[-1])}
|
||||||
|
wk = (ed.get("works") or [{}])[0].get("key")
|
||||||
|
if wk:
|
||||||
|
w = self.h.json(f"https://openlibrary.org{wk}.json") or {}
|
||||||
|
rec["ol_work"] = wk.split("/")[-1]
|
||||||
|
d = w.get("description")
|
||||||
|
rec["description"] = d.get("value") if isinstance(d, dict) else d
|
||||||
|
rec["subjects"] = (w.get("subjects") or [])[:8]
|
||||||
|
names = []
|
||||||
|
for a in (w.get("authors") or [])[:4]:
|
||||||
|
k = (a.get("author") or {}).get("key")
|
||||||
|
if k:
|
||||||
|
aj = self.h.json(f"https://openlibrary.org{k}.json") or {}
|
||||||
|
if aj.get("name"):
|
||||||
|
names.append(aj["name"])
|
||||||
|
rec["authors"] = names
|
||||||
|
if not rec["cover"] and w.get("covers") and w["covers"][0] > 0:
|
||||||
|
rec["cover"] = f"https://covers.openlibrary.org/b/id/{w['covers'][0]}-L.jpg"
|
||||||
|
return rec
|
||||||
|
|
||||||
|
def ol_search(self, title: str, authors: list[str]):
|
||||||
|
q = {"title": norm_title(title) if len(norm_title(title)) > 3 else title, "limit": 8,
|
||||||
|
"fields": "key,title,subtitle,author_name,first_publish_year,isbn,cover_i,cover_edition_key,language,number_of_pages_median,publisher,subject"}
|
||||||
|
if authors:
|
||||||
|
q["author"] = re.sub(r"[^\w\s.-]", " ", authors[0])
|
||||||
|
d = self.h.json("https://openlibrary.org/search.json?" + urllib.parse.urlencode(q))
|
||||||
|
docs = (d or {}).get("docs") or []
|
||||||
|
if not docs and authors:
|
||||||
|
q.pop("author")
|
||||||
|
d = self.h.json("https://openlibrary.org/search.json?" + urllib.parse.urlencode(q))
|
||||||
|
docs = (d or {}).get("docs") or []
|
||||||
|
best, bs = None, 0.0
|
||||||
|
for doc in docs:
|
||||||
|
t = doc.get("title", "")
|
||||||
|
s = max(sim(title, t), sim(title, f"{t}: {doc.get('subtitle', '')}"))
|
||||||
|
if norm_title(title) and norm_title(title) == norm_title(t) and (
|
||||||
|
authors and doc.get("author_name") and authors_compatible(authors, doc["author_name"]) or len(norm_title(t)) >= 15):
|
||||||
|
s = max(s, 0.92)
|
||||||
|
if authors and doc.get("author_name"):
|
||||||
|
s += 0.05 if authors_compatible(authors, doc["author_name"]) else -0.25
|
||||||
|
if s > bs:
|
||||||
|
best, bs = doc, s
|
||||||
|
if not best:
|
||||||
|
return None, 0.0
|
||||||
|
isbns = [to13(i) for i in (best.get("isbn") or [])]
|
||||||
|
isbns = [i for i in isbns if i]
|
||||||
|
rec = {"title": best.get("title"), "subtitle": best.get("subtitle"), "authors": best.get("author_name") or [],
|
||||||
|
"year": best.get("first_publish_year"), "ol_work": best.get("key", "").split("/")[-1] or None,
|
||||||
|
"ol_edition": best.get("cover_edition_key"), "pages": best.get("number_of_pages_median"),
|
||||||
|
"publisher": (best.get("publisher") or [None])[0],
|
||||||
|
"cover": f"https://covers.openlibrary.org/b/id/{best['cover_i']}-L.jpg" if best.get("cover_i") else None,
|
||||||
|
"lang": LANG.get((best.get("language") or [""])[0]), "subjects": (best.get("subject") or [])[:8],
|
||||||
|
"isbn": next((i for i in isbns if i.startswith("978")), isbns[0] if isbns else None)}
|
||||||
|
if rec["ol_work"]:
|
||||||
|
w = self.h.json(f"https://openlibrary.org/works/{rec['ol_work']}.json") or {}
|
||||||
|
dsc = w.get("description")
|
||||||
|
rec["description"] = dsc.get("value") if isinstance(dsc, dict) else dsc
|
||||||
|
return rec, min(bs, 1.0)
|
||||||
|
|
||||||
|
def gb(self, title: str | None, authors: list[str], isbn: str | None = None):
|
||||||
|
if isbn:
|
||||||
|
q = f"isbn:{isbn}"
|
||||||
|
else:
|
||||||
|
q = f'intitle:"{norm_title(title) or title}"' + (f" inauthor:{surnames(authors[:1]).pop()}" if authors and surnames(authors[:1]) else "")
|
||||||
|
d = self.h.json("https://www.googleapis.com/books/v1/volumes?" + urllib.parse.urlencode({"q": q, "maxResults": 8, "printType": "books"}))
|
||||||
|
items = (d or {}).get("items") or []
|
||||||
|
best, bs = None, 0.0
|
||||||
|
for it in items:
|
||||||
|
v = it.get("volumeInfo", {})
|
||||||
|
if isbn:
|
||||||
|
s = 1.0
|
||||||
|
else:
|
||||||
|
s = max(sim(title, v.get("title", "")), sim(title, f"{v.get('title', '')}: {v.get('subtitle', '')}"))
|
||||||
|
if norm_title(title) and norm_title(title) == norm_title(v.get("title", "")) and (
|
||||||
|
authors and v.get("authors") and authors_compatible(authors, v["authors"]) or len(norm_title(title)) >= 15):
|
||||||
|
s = max(s, 0.92)
|
||||||
|
if authors and v.get("authors"):
|
||||||
|
s += 0.05 if authors_compatible(authors, v["authors"]) else -0.25
|
||||||
|
if s > bs:
|
||||||
|
best, bs = it, s
|
||||||
|
if isbn:
|
||||||
|
break
|
||||||
|
if not best:
|
||||||
|
return None, 0.0
|
||||||
|
v = best["volumeInfo"]
|
||||||
|
ids = {x.get("type"): x.get("identifier") for x in v.get("industryIdentifiers", [])}
|
||||||
|
img = (v.get("imageLinks") or {})
|
||||||
|
cover = img.get("extraLarge") or img.get("large") or img.get("medium") or img.get("thumbnail")
|
||||||
|
if cover:
|
||||||
|
cover = cover.replace("http://", "https://").replace("&edge=curl", "")
|
||||||
|
return {"title": v.get("title"), "subtitle": v.get("subtitle"), "authors": v.get("authors") or [],
|
||||||
|
"publisher": v.get("publisher"), "year": year_of(v.get("publishedDate")), "pages": v.get("pageCount"),
|
||||||
|
"description": re.sub(r"<[^>]+>", "", v.get("description") or "") or None,
|
||||||
|
"lang": (v.get("language") or "")[:2] or None, "gb_id": best.get("id"), "cover": cover,
|
||||||
|
"isbn": to13(ids.get("ISBN_13") or ids.get("ISBN_10") or ""), "subjects": v.get("categories") or []}, min(bs, 1.0)
|
||||||
|
|
||||||
|
def arxiv(self, title: str, arxiv_id: str | None = None):
|
||||||
|
if arxiv_id:
|
||||||
|
url = f"https://export.arxiv.org/api/query?id_list={arxiv_id}"
|
||||||
|
else:
|
||||||
|
t = re.sub(r"[^\w\s]", " ", title)
|
||||||
|
url = "https://export.arxiv.org/api/query?" + urllib.parse.urlencode({"search_query": f'ti:"{t}"', "max_results": 5})
|
||||||
|
x = self.h.text(url)
|
||||||
|
if not x:
|
||||||
|
return None, 0.0
|
||||||
|
ns = {"a": "http://www.w3.org/2005/Atom"}
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(x)
|
||||||
|
except ET.ParseError:
|
||||||
|
return None, 0.0
|
||||||
|
best, bs = None, 0.0
|
||||||
|
for e in root.findall("a:entry", ns):
|
||||||
|
t = re.sub(r"\s+", " ", e.findtext("a:title", "", ns)).strip()
|
||||||
|
if not t or t == "Error":
|
||||||
|
continue
|
||||||
|
s = 1.0 if arxiv_id else sim(title, t)
|
||||||
|
if s > bs:
|
||||||
|
best, bs = (e, t), s
|
||||||
|
if not best:
|
||||||
|
return None, 0.0
|
||||||
|
e, t = best
|
||||||
|
return {"title": t, "authors": [a.findtext("a:name", "", ns) for a in e.findall("a:author", ns)],
|
||||||
|
"year": year_of(e.findtext("a:published", "", ns)),
|
||||||
|
"description": re.sub(r"\s+", " ", e.findtext("a:summary", "", ns)).strip(),
|
||||||
|
"arxiv": e.findtext("a:id", "", ns).split("/abs/")[-1]}, bs
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- main
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--obsidian-json", help="md5 -> libgen record map from the resolver")
|
||||||
|
ap.add_argument("--extra", action="append", default=[], help='extra placeholder "Title|Author|isbn13|origin"')
|
||||||
|
ap.add_argument("--reset", action="store_true", help="truncate books.works/files first (scratch DBs only)")
|
||||||
|
ap.add_argument("--dry-run", action="store_true", help="no DB writes, no file moves; write plan json")
|
||||||
|
ap.add_argument("--no-network", action="store_true")
|
||||||
|
ap.add_argument("--allow-prod", action="store_true")
|
||||||
|
ap.add_argument("--source", default="gdrive")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
root = Path(os.path.expanduser(os.environ.get("BOOKS_ROOT", "/srv/books")))
|
||||||
|
inbox, library, covers = root / "inbox", root / "library", root / "covers"
|
||||||
|
for d in (inbox, library, covers):
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
db_url = os.environ.get("DB_URL")
|
||||||
|
if not db_url:
|
||||||
|
sys.exit("DB_URL required")
|
||||||
|
dbname = urllib.parse.urlparse(db_url).path.lstrip("/")
|
||||||
|
if dbname == "personal" and not args.allow_prod:
|
||||||
|
sys.exit("refusing to run against prod DB 'personal' (use --allow-prod)")
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
conn = psycopg.connect(db_url)
|
||||||
|
http = Http(root / ".cache" / "http", delay=0.0 if args.no_network else 1.0)
|
||||||
|
en = Enricher(http)
|
||||||
|
works: list[Work] = []
|
||||||
|
log = lambda *a: print(*a, flush=True) # noqa
|
||||||
|
|
||||||
|
# ---- 1. placeholders: lists.books
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("select to_regclass('lists.books')")
|
||||||
|
has_lists = cur.fetchone()[0] is not None
|
||||||
|
rows = []
|
||||||
|
if has_lists:
|
||||||
|
cur.execute("select id, name, author, notes, done_at from lists.books order by sort_order nulls last, id")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
for bid, name, author, notes, done_at in rows:
|
||||||
|
w = Work(title=name.strip(), authors=[author.strip()] if author else [], notes=notes or None)
|
||||||
|
w.add_ref("lists_books_id", bid)
|
||||||
|
w.origins.add("lists")
|
||||||
|
w.shelves.add("done" if done_at else "to-read")
|
||||||
|
if done_at:
|
||||||
|
w.refs["done_at"] = done_at.isoformat()
|
||||||
|
works.append(w)
|
||||||
|
n_lists = len(rows)
|
||||||
|
|
||||||
|
def find_work(title, authors, isbns, md5=None):
|
||||||
|
for w in works:
|
||||||
|
if md5 and md5 in w.md5s:
|
||||||
|
return w
|
||||||
|
if isbns and set(isbns) & set(w.isbns) and (not title or not w.title or sim(title, w.title) >= 0.85):
|
||||||
|
return w
|
||||||
|
best, bs = None, 0.0
|
||||||
|
for w in works:
|
||||||
|
s = sim(title, w.title)
|
||||||
|
if s >= 0.9 and authors_compatible(authors, w.authors) and s > bs:
|
||||||
|
best, bs = w, s
|
||||||
|
return best
|
||||||
|
|
||||||
|
def merge(w: Work, title, authors, isbns, **kw):
|
||||||
|
if authors and not w.authors:
|
||||||
|
w.authors = authors
|
||||||
|
for i in isbns or []:
|
||||||
|
if i not in w.isbns:
|
||||||
|
w.isbns.append(i)
|
||||||
|
for k in ("year", "publisher", "language"):
|
||||||
|
if kw.get(k) and not getattr(w, k):
|
||||||
|
setattr(w, k, kw[k])
|
||||||
|
# prefer the longer, properly-cased title when the list name is a lowercase shorthand
|
||||||
|
if title and (w.title.islower() or len(norm_title(w.title)) < len(norm_title(title)) and sim(w.title, title) > 0.9 and w.title == w.title.lower()):
|
||||||
|
w.title = title
|
||||||
|
|
||||||
|
# ---- 2. placeholders: Obsidian md5s
|
||||||
|
obs_total = obs_resolved = obs_overlap = 0
|
||||||
|
if args.obsidian_json:
|
||||||
|
data = json.load(open(args.obsidian_json))
|
||||||
|
for md5, rec in data.items():
|
||||||
|
obs_total += 1
|
||||||
|
eds = (rec or {}).get("editions") or []
|
||||||
|
if not eds:
|
||||||
|
continue
|
||||||
|
obs_resolved += 1
|
||||||
|
ed = next((e for e in eds if e.get("isbns")), eds[0])
|
||||||
|
isbns = []
|
||||||
|
for e in eds:
|
||||||
|
for i in e.get("isbns") or []:
|
||||||
|
i13 = to13(i)
|
||||||
|
if i13 and i13 not in isbns:
|
||||||
|
isbns.append(i13)
|
||||||
|
title = (ed.get("title") or rec.get("locator") or md5).strip()
|
||||||
|
authors = [a.strip() for a in re.split(r";|, (?=[A-Z][a-z]+ [A-Z])", ed.get("author") or "") if a.strip()]
|
||||||
|
lang = LANG.get((ed.get("lang") or [""])[0], None) if isinstance(ed.get("lang"), list) else None
|
||||||
|
w = find_work(title, authors, isbns, md5)
|
||||||
|
if w:
|
||||||
|
if "obsidian" not in w.origins:
|
||||||
|
obs_overlap += 1
|
||||||
|
merge(w, title, authors, isbns, year=year_of(ed.get("year")), publisher=ed.get("publisher") or None, language=lang)
|
||||||
|
else:
|
||||||
|
w = Work(title=title, authors=authors, isbns=isbns, year=year_of(ed.get("year")), publisher=ed.get("publisher") or None, language=lang)
|
||||||
|
works.append(w)
|
||||||
|
w.shelves.add("to-read")
|
||||||
|
w.origins.add("obsidian")
|
||||||
|
if md5 not in w.md5s:
|
||||||
|
w.md5s.append(md5)
|
||||||
|
w.add_ref("obsidian", {"md5": md5, "ext": rec.get("ext"), "filesize": rec.get("filesize")})
|
||||||
|
for ex in args.extra:
|
||||||
|
t, a, i, origin = (ex.split("|") + ["", "", "", ""])[:4]
|
||||||
|
isbns = [to13(i)] if to13(i) else []
|
||||||
|
authors = [a] if a else []
|
||||||
|
w = find_work(t, authors, isbns)
|
||||||
|
if w:
|
||||||
|
merge(w, t, authors, isbns)
|
||||||
|
else:
|
||||||
|
w = Work(title=t, authors=authors, isbns=isbns)
|
||||||
|
works.append(w)
|
||||||
|
w.shelves.add("to-read")
|
||||||
|
w.origins.add("obsidian")
|
||||||
|
w.add_ref("obsidian_link", origin)
|
||||||
|
n_placeholders = len(works)
|
||||||
|
|
||||||
|
# ---- 3. files in inbox
|
||||||
|
files: list[FileRec] = []
|
||||||
|
for p in sorted(inbox.rglob("*")):
|
||||||
|
if not p.is_file() or p.name.startswith("."):
|
||||||
|
continue
|
||||||
|
h256, h5 = hashlib.sha256(), hashlib.md5()
|
||||||
|
with open(p, "rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||||
|
h256.update(chunk)
|
||||||
|
h5.update(chunk)
|
||||||
|
rel = str(p.relative_to(inbox))
|
||||||
|
ext = p.suffix.lower()
|
||||||
|
with open(p, "rb") as hf:
|
||||||
|
head = hf.read(8)
|
||||||
|
fmt = ext.lstrip(".") or "bin"
|
||||||
|
if head.startswith(b"%PDF"):
|
||||||
|
fmt = "pdf"
|
||||||
|
elif head.startswith(b"PK") and ext in (".epub", ""):
|
||||||
|
fmt = "epub"
|
||||||
|
fr = FileRec(path=p, rel_inbox=rel, sha256=h256.hexdigest(), md5=h5.hexdigest(), size=p.stat().st_size, fmt=fmt)
|
||||||
|
fr.is_paper = rel.split("/")[0].lower() == "papers"
|
||||||
|
if ext == ".crdownload":
|
||||||
|
fr.broken = "partial Chrome download (.crdownload)"
|
||||||
|
fr.fmt = "pdf" if head.startswith(b"%PDF") else "partial"
|
||||||
|
files.append(fr)
|
||||||
|
by_sha: dict[str, FileRec] = {}
|
||||||
|
dups: list[tuple[FileRec, FileRec]] = []
|
||||||
|
uniq: list[FileRec] = []
|
||||||
|
for fr in files:
|
||||||
|
if fr.sha256 in by_sha:
|
||||||
|
dups.append((fr, by_sha[fr.sha256]))
|
||||||
|
else:
|
||||||
|
by_sha[fr.sha256] = fr
|
||||||
|
uniq.append(fr)
|
||||||
|
log(f"files: {len(files)} total, {len(uniq)} unique, {len(dups)} sha256 duplicates")
|
||||||
|
|
||||||
|
# already-ingested shas (rerun without --reset)
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("select sha256 from books.files")
|
||||||
|
known = {r[0] for r in cur.fetchall()} if not args.reset else set()
|
||||||
|
|
||||||
|
matched_existing = 0
|
||||||
|
for fr in uniq:
|
||||||
|
if fr.sha256 in known:
|
||||||
|
continue
|
||||||
|
stem = Path(fr.rel_inbox).name
|
||||||
|
stem = stem[: -len(Path(stem).suffix)] if Path(stem).suffix.lower() in BOOK_EXT | {".crdownload"} else stem
|
||||||
|
fm = filename_meta(stem)
|
||||||
|
em = epub_meta(fr.path) if fr.fmt == "epub" else pdf_meta(fr.path) if fr.fmt == "pdf" and not fr.broken else {}
|
||||||
|
fr.title = fm.get("title") or stem
|
||||||
|
if fr.fmt == "epub" and good_embedded_title(em.get("title"), fr.title):
|
||||||
|
fr.title = em["title"]
|
||||||
|
fr.authors = fm.get("authors") or [a for a in em.get("authors", []) if len(a) > 2 and "unknown" not in a.lower()][:4]
|
||||||
|
fr.isbns = list(dict.fromkeys((fm.get("isbns") or []) + (em.get("isbns") or [])))[:6]
|
||||||
|
fr.year = fm.get("year") or em.get("year")
|
||||||
|
fr.publisher = fm.get("publisher") or em.get("publisher")
|
||||||
|
fr.language = em.get("language")
|
||||||
|
fr.pages = em.get("pages")
|
||||||
|
md5 = fm.get("md5") or fr.md5
|
||||||
|
w = find_work(fr.title, fr.authors, fr.isbns[:2], md5) or (find_work(fr.title, fr.authors, [], fr.md5))
|
||||||
|
if w and w.files == [] and w.origins & {"lists", "obsidian"} and "drive" not in w.origins:
|
||||||
|
matched_existing += 1
|
||||||
|
if w:
|
||||||
|
merge(w, fr.title, fr.authors, fr.isbns[:2], year=fr.year, publisher=fr.publisher, language=fr.language)
|
||||||
|
else:
|
||||||
|
w = Work(title=fr.title, authors=fr.authors, isbns=fr.isbns[:2], year=fr.year, publisher=fr.publisher, language=fr.language, page_count=fr.pages)
|
||||||
|
works.append(w)
|
||||||
|
if fr.is_paper and "paper" not in w.tags:
|
||||||
|
w.tags.append("paper")
|
||||||
|
if fr.md5 not in w.md5s and fm.get("md5"):
|
||||||
|
w.md5s.append(fm["md5"])
|
||||||
|
w.origins.add("drive")
|
||||||
|
w.add_ref("drive", fr.rel_inbox)
|
||||||
|
if em.get("arxiv"):
|
||||||
|
w.refs["arxiv"] = em["arxiv"]
|
||||||
|
if fr.broken:
|
||||||
|
w.notes = ((w.notes + "\n") if w.notes else "") + f"Drive copy is broken: {fr.broken}; needs a fresh copy."
|
||||||
|
w.add_ref("broken_files", fr.rel_inbox)
|
||||||
|
else:
|
||||||
|
w.files.append(fr)
|
||||||
|
for d, orig in dups:
|
||||||
|
for w in works:
|
||||||
|
if orig in w.files:
|
||||||
|
w.add_ref("drive_duplicates", d.rel_inbox)
|
||||||
|
log(f"works: {len(works)} ({n_placeholders} placeholders before files; {matched_existing} placeholders got files)")
|
||||||
|
|
||||||
|
# ---- 4. enrichment
|
||||||
|
stats = {"matched": 0, "low_confidence": 0, "not_found": 0}
|
||||||
|
for n, w in enumerate(works, 1):
|
||||||
|
if args.no_network:
|
||||||
|
break
|
||||||
|
rec, score, src = None, 0.0, None
|
||||||
|
if "paper" in w.tags:
|
||||||
|
rec, score = en.arxiv(w.title, w.refs.get("arxiv"))
|
||||||
|
src = "arxiv"
|
||||||
|
else:
|
||||||
|
for isbn in w.isbns[:3]:
|
||||||
|
rec = en.ol_isbn(isbn)
|
||||||
|
if rec and (sim(w.title, rec["title"]) > 0.6 or sim(w.title, f"{rec['title']}: {rec.get('subtitle') or ''}") > 0.6 or "drive" not in w.origins):
|
||||||
|
score, src = 1.0, "openlibrary"
|
||||||
|
break
|
||||||
|
rec = None
|
||||||
|
if not rec:
|
||||||
|
rec, score = en.ol_search(w.title, w.authors)
|
||||||
|
src = "openlibrary"
|
||||||
|
if score < 0.9:
|
||||||
|
g, gs = en.gb(None, [], w.isbns[0]) if w.isbns else (None, 0.0)
|
||||||
|
if g and sim(w.title, g["title"] or "") < 0.6 and sim(w.title, f"{g['title']}: {g.get('subtitle') or ''}") < 0.6:
|
||||||
|
g, gs = None, 0.0
|
||||||
|
if not g:
|
||||||
|
g, gs = en.gb(w.title, w.authors)
|
||||||
|
if g and gs > score:
|
||||||
|
rec, score, src = g, gs, "googlebooks"
|
||||||
|
if rec and score >= 0.9:
|
||||||
|
w.status = "matched"
|
||||||
|
elif rec and score >= 0.75:
|
||||||
|
w.status = "low_confidence"
|
||||||
|
else:
|
||||||
|
w.status = "not_found"
|
||||||
|
stats[w.status] += 1
|
||||||
|
if rec:
|
||||||
|
w.refs["match"] = {"source": src, "score": round(score, 3), "title": rec.get("title"), "authors": rec.get("authors")}
|
||||||
|
if w.status == "matched":
|
||||||
|
w.source = src
|
||||||
|
if src == "openlibrary" or src == "googlebooks":
|
||||||
|
# keep Stan's/the file's title wording unless it was a lowercase list shorthand or a filename
|
||||||
|
# keep Stan's own list wording; otherwise take the catalogue's properly-cased title
|
||||||
|
if rec.get("title") and ("lists" not in w.origins or w.title.islower()):
|
||||||
|
w.title = rec["title"]
|
||||||
|
w.subtitle = w.subtitle or rec.get("subtitle")
|
||||||
|
elif src == "arxiv":
|
||||||
|
w.title = rec["title"]
|
||||||
|
if rec.get("authors"):
|
||||||
|
w.authors = rec["authors"][:6]
|
||||||
|
w.year = w.year or rec.get("year")
|
||||||
|
if src == "openlibrary" and rec.get("year") and w.year and rec["year"] < w.year:
|
||||||
|
w.year = w.year # edition year stays; first-publish year recorded in refs
|
||||||
|
w.refs["first_publish_year"] = rec["year"]
|
||||||
|
w.publisher = w.publisher or rec.get("publisher")
|
||||||
|
w.page_count = w.page_count or rec.get("pages")
|
||||||
|
w.language = w.language or rec.get("lang")
|
||||||
|
w.description = w.description or rec.get("description")
|
||||||
|
if rec.get("isbn") and rec["isbn"] not in w.isbns:
|
||||||
|
w.isbns.insert(0 if not w.isbns else len(w.isbns), rec["isbn"])
|
||||||
|
w.ol_work = w.ol_work or rec.get("ol_work")
|
||||||
|
w.ol_edition = w.ol_edition or rec.get("ol_edition")
|
||||||
|
w.gb_id = w.gb_id or rec.get("gb_id")
|
||||||
|
w.cover_url = w.cover_url or rec.get("cover")
|
||||||
|
if rec.get("arxiv"):
|
||||||
|
w.refs["arxiv"] = rec["arxiv"]
|
||||||
|
for s in (rec.get("subjects") or [])[:5]:
|
||||||
|
s = s.lower().strip()
|
||||||
|
if 2 < len(s) < 40 and s not in w.tags:
|
||||||
|
w.tags.append(s)
|
||||||
|
if n % 25 == 0:
|
||||||
|
log(f" enriched {n}/{len(works)} ({http.calls} http calls)")
|
||||||
|
if args.no_network:
|
||||||
|
stats = {"skipped": len(works)}
|
||||||
|
log("metadata:", stats)
|
||||||
|
|
||||||
|
# cover fallback for matched works lacking one: Google Books by isbn
|
||||||
|
if not args.no_network:
|
||||||
|
for w in works:
|
||||||
|
if not w.cover_url and w.status == "matched" and "paper" not in w.tags and w.isbns:
|
||||||
|
g, _ = en.gb(None, [], w.isbns[0])
|
||||||
|
if g and g.get("cover"):
|
||||||
|
w.cover_url = g["cover"]
|
||||||
|
w.gb_id = w.gb_id or g.get("gb_id")
|
||||||
|
|
||||||
|
# ---- 5. plan destinations
|
||||||
|
used: set[str] = set()
|
||||||
|
for w in works:
|
||||||
|
author_dir = safe_name(w.authors[0] if w.authors else "Unknown", 60)
|
||||||
|
title_dir = safe_name(w.title, 90)
|
||||||
|
for fr in w.files:
|
||||||
|
base = f"{author_dir}/{title_dir}/{title_dir}"
|
||||||
|
rel = f"{base}.{fr.fmt}"
|
||||||
|
k = 2
|
||||||
|
while rel in used or (library / rel).exists() and not args.reset:
|
||||||
|
rel = f"{base} ({k}).{fr.fmt}"
|
||||||
|
k += 1
|
||||||
|
used.add(rel)
|
||||||
|
fr.dest_rel = rel
|
||||||
|
|
||||||
|
plan = [{"title": w.title, "authors": w.authors, "isbns": w.isbns, "status": w.status, "origins": sorted(w.origins),
|
||||||
|
"files": [{"from": f.rel_inbox, "to": f.dest_rel, "sha256": f.sha256} for f in w.files], "refs": w.refs,
|
||||||
|
"cover": w.cover_url} for w in works]
|
||||||
|
(root / ".cache").mkdir(exist_ok=True)
|
||||||
|
(root / ".cache" / "ingest-plan.json").write_text(json.dumps(plan, indent=1, default=str))
|
||||||
|
if args.dry_run:
|
||||||
|
log("dry run: plan written to", root / ".cache" / "ingest-plan.json")
|
||||||
|
return
|
||||||
|
|
||||||
|
# ---- 6. write DB
|
||||||
|
def isbn13(w):
|
||||||
|
return next((i for i in w.isbns if i.startswith("978") or i.startswith("979")), None)
|
||||||
|
seen13 = set()
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with conn.transaction():
|
||||||
|
cur = conn.cursor()
|
||||||
|
if args.reset:
|
||||||
|
cur.execute("truncate books.notes, books.progress, books.shelf_works, books.files, books.works restart identity cascade")
|
||||||
|
cur.execute("select slug, id from books.shelves")
|
||||||
|
shelves = dict(cur.fetchall())
|
||||||
|
for w in works:
|
||||||
|
i13 = isbn13(w)
|
||||||
|
if i13 in seen13:
|
||||||
|
w.refs["isbn13_shared_with_other_work"] = i13
|
||||||
|
i13 = None
|
||||||
|
if i13:
|
||||||
|
seen13.add(i13)
|
||||||
|
refs = dict(w.refs)
|
||||||
|
refs["origins"] = sorted(w.origins)
|
||||||
|
if len(w.isbns) > 1:
|
||||||
|
refs["all_isbns"] = w.isbns
|
||||||
|
cur.execute(
|
||||||
|
"""insert into books.works (title, subtitle, authors, isbn10, isbn13, publisher, year, language, page_count,
|
||||||
|
description, tags, cover_url, openlibrary_work_id, openlibrary_edition_id, google_books_id, annas_md5,
|
||||||
|
source_refs, metadata_status, metadata_source, metadata_fetched_at, notes)
|
||||||
|
values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) returning id""",
|
||||||
|
(w.title, w.subtitle, w.authors, to10(i13) if i13 else None, i13, w.publisher,
|
||||||
|
w.year if w.year and 1400 < w.year < 2100 else None, w.language, w.page_count, w.description, w.tags,
|
||||||
|
w.cover_url, w.ol_work, w.ol_edition, w.gb_id, w.md5s, json.dumps(refs, default=str), w.status,
|
||||||
|
w.source, now if w.status != "pending" else None, w.notes))
|
||||||
|
w.db_id = cur.fetchone()[0]
|
||||||
|
for fr in w.files:
|
||||||
|
cur.execute("insert into books.files (work_id, path, format, size_bytes, sha256, md5, source) values (%s,%s,%s,%s,%s,%s,%s)",
|
||||||
|
(w.db_id, fr.dest_rel, fr.fmt, fr.size, fr.sha256, fr.md5, args.source))
|
||||||
|
for s in sorted(w.shelves):
|
||||||
|
if s in shelves:
|
||||||
|
cur.execute("insert into books.shelf_works (shelf_id, work_id) values (%s,%s) on conflict do nothing", (shelves[s], w.db_id))
|
||||||
|
if "done" in w.shelves and w.refs.get("done_at"):
|
||||||
|
cur.execute("insert into books.progress (work_id, percent, finished_at) values (%s, 100, %s) on conflict do nothing", (w.db_id, w.refs["done_at"]))
|
||||||
|
|
||||||
|
# ---- 7. covers
|
||||||
|
got = 0
|
||||||
|
if not args.no_network:
|
||||||
|
for w in works:
|
||||||
|
if not w.cover_url:
|
||||||
|
continue
|
||||||
|
dest = covers / f"{w.db_id}.jpg"
|
||||||
|
if dest.exists() or http.download(w.cover_url, dest):
|
||||||
|
got += 1
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("update books.works set cover_path=%s where id=%s", (dest.name, w.db_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# ---- 8. move files (inbox -> library); write undo manifest
|
||||||
|
manifest = root / ".cache" / f"moves-{time.strftime('%Y%m%d-%H%M%S')}.tsv"
|
||||||
|
dupdir = root / "duplicates"
|
||||||
|
qdir = root / "quarantine"
|
||||||
|
with open(manifest, "w") as mf:
|
||||||
|
for w in works:
|
||||||
|
for fr in w.files:
|
||||||
|
dest = library / fr.dest_rel
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.move(str(fr.path), dest)
|
||||||
|
mf.write(f"{fr.path}\t{dest}\n")
|
||||||
|
for d, _ in dups:
|
||||||
|
dest = dupdir / d.rel_inbox
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.move(str(d.path), dest)
|
||||||
|
mf.write(f"{d.path}\t{dest}\n")
|
||||||
|
for fr in uniq:
|
||||||
|
if fr.broken and fr.path.exists():
|
||||||
|
dest = qdir / fr.rel_inbox
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.move(str(fr.path), dest)
|
||||||
|
mf.write(f"{fr.path}\t{dest}\n")
|
||||||
|
for d in sorted(inbox.rglob("*"), reverse=True):
|
||||||
|
if d.is_dir() and not any(d.iterdir()):
|
||||||
|
d.rmdir()
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"lists_rows": n_lists, "obsidian_md5s": obs_total, "obsidian_resolved": obs_resolved,
|
||||||
|
"obsidian_overlapping_lists": obs_overlap, "placeholders": n_placeholders,
|
||||||
|
"files_total": len(files), "files_unique": len(uniq), "sha256_duplicates": len(dups),
|
||||||
|
"placeholders_with_files": matched_existing, "works_total": len(works),
|
||||||
|
"works_with_files": sum(1 for w in works if w.files), "metadata": stats, "covers": got,
|
||||||
|
"papers": sum(1 for w in works if "paper" in w.tags), "http_calls": http.calls, "manifest": str(manifest),
|
||||||
|
}
|
||||||
|
(root / ".cache" / "ingest-summary.json").write_text(json.dumps(summary, indent=1))
|
||||||
|
print(json.dumps(summary, indent=1))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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);
|
|
||||||
55
scripts/db-migrate.ts
Normal file
55
scripts/db-migrate.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// Applies pending Drizzle migrations from ./drizzle. Runs as the schema owner
|
||||||
|
// (DB_MIGRATE_URL, role `personal`); falls back to DB_URL for local dev.
|
||||||
|
//
|
||||||
|
// bun scripts/db-migrate.ts apply pending migrations
|
||||||
|
// bun scripts/db-migrate.ts --init record the baseline as applied without running it
|
||||||
|
// (once, on a database already reorganised by the
|
||||||
|
// hand-written reorg scripts in db/reorg/)
|
||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
|
||||||
|
const url = process.env.DB_MIGRATE_URL || process.env.DB_URL;
|
||||||
|
if (!url) throw new Error('Set DB_MIGRATE_URL (schema owner) or DB_URL');
|
||||||
|
const init = process.argv.includes('--init');
|
||||||
|
|
||||||
|
const client = postgres(url, { max: 1, connect_timeout: 10, onnotice: () => {} });
|
||||||
|
try {
|
||||||
|
const db = drizzle({ client });
|
||||||
|
// Safety: never run the baseline on a database that already has data. Existing
|
||||||
|
// databases are brought to the baseline by the reviewed reorg scripts and then
|
||||||
|
// recorded with --init; only a truly empty database gets the baseline executed.
|
||||||
|
const [state] = await client`
|
||||||
|
select
|
||||||
|
to_regclass('drizzle.__drizzle_migrations') is not null as tracked,
|
||||||
|
(select count(*)::int from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
||||||
|
where c.relkind = 'r' and n.nspname not in ('pg_catalog', 'information_schema', 'drizzle')
|
||||||
|
and n.nspname not like 'pg_toast%') as tables`;
|
||||||
|
const recorded = state.tracked
|
||||||
|
? (await client`select count(*)::int as n from drizzle.__drizzle_migrations`)[0].n
|
||||||
|
: 0;
|
||||||
|
if (!init && recorded === 0 && state.tables > 0)
|
||||||
|
throw new Error(
|
||||||
|
`Refusing to run the Drizzle baseline on a non-empty database (${state.tables} tables). ` +
|
||||||
|
'Apply the reorg scripts (db/reorg/) and record the baseline with --init first.'
|
||||||
|
);
|
||||||
|
if (init && state.tables === 0)
|
||||||
|
throw new Error('--init on an empty database: run without --init to create the schema');
|
||||||
|
const result = await migrate(db, {
|
||||||
|
migrationsFolder: new URL('../drizzle', import.meta.url).pathname,
|
||||||
|
migrationsSchema: 'drizzle',
|
||||||
|
migrationsTable: '__drizzle_migrations',
|
||||||
|
...(init ? { init: true } : {})
|
||||||
|
});
|
||||||
|
if (result && 'exitCode' in result) {
|
||||||
|
throw new Error(
|
||||||
|
result.exitCode === 'databaseMigrations'
|
||||||
|
? '--init refused: migrations are already recorded in this database'
|
||||||
|
: '--init refused: more than one local migration exists'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [{ n }] = await client`select count(*)::int as n from drizzle.__drizzle_migrations`;
|
||||||
|
console.log(`${init ? 'Baseline recorded' : 'Migrations up to date'}; ${n} recorded`);
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
112
scripts/db-refresh-dev.sh
Executable file
112
scripts/db-refresh-dev.sh
Executable file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Refresh the development database (personal_dev) 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:5440/postgres
|
||||||
|
# (not needed with PG_CONTAINER, which uses the container's local superuser)
|
||||||
|
# Optional:
|
||||||
|
# DEV_DB_NAME target database (default personal_dev)
|
||||||
|
# DEV_OWNER role that owns every object in the dev DB (default personal_dev). It is the
|
||||||
|
# role the dev site logs in as, so it can also run migrations there. It must not
|
||||||
|
# be a member of any prod role; prod's CONNECT is not granted to it.
|
||||||
|
# PG_DOCKER=1 use the postgres:17-alpine image instead of local pg_dump/pg_restore
|
||||||
|
# PG_CONTAINER name of the Postgres container (e.g. postgres-db-1): run every tool inside it
|
||||||
|
# via `docker exec` as the local superuser. URLs are then as seen from inside
|
||||||
|
# the container, e.g. PROD_DB_URL=postgresql://personal:...@127.0.0.1:5432/personal
|
||||||
|
# DOCKER docker command (default "docker"; e.g. "sudo -n docker")
|
||||||
|
#
|
||||||
|
# What it does: dump prod (custom format) -> drop + recreate DEV_DB_NAME owned by DEV_OWNER ->
|
||||||
|
# restore without prod ownership/grants, every object owned by DEV_OWNER -> 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_DB_NAME="${DEV_DB_NAME:-personal_dev}"
|
||||||
|
DEV_OWNER="${DEV_OWNER:-personal_dev}"
|
||||||
|
DOCKER="${DOCKER:-docker}"
|
||||||
|
if [[ -n "${PG_CONTAINER:-}" ]]; then
|
||||||
|
DEV_ADMIN_URL="${DEV_ADMIN_URL:-postgresql://postgres@localhost/postgres}"
|
||||||
|
fi
|
||||||
|
: "${DEV_ADMIN_URL:?set DEV_ADMIN_URL (superuser URL to the dev cluster maintenance DB)}"
|
||||||
|
|
||||||
|
for ident in "$DEV_DB_NAME" "$DEV_OWNER"; do
|
||||||
|
if [[ ! "$ident" =~ ^[a-z_][a-z0-9_]*$ ]]; then
|
||||||
|
echo "DEV_DB_NAME / DEV_OWNER must be plain lowercase identifiers" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
case "$DEV_DB_NAME" in
|
||||||
|
personal | personal_prod | postgres | template0 | template1)
|
||||||
|
echo "Refusing to overwrite '$DEV_DB_NAME'" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
case "$DEV_OWNER" in
|
||||||
|
personal | personal_app | personal_ddl | personal_ro | postgres)
|
||||||
|
echo "Refusing: DEV_OWNER '$DEV_OWNER' is a prod/superuser role" >&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
|
||||||
|
|
||||||
|
# Dump goes to stdout and restore reads stdin, so the same commands work locally,
|
||||||
|
# in a throwaway postgres image, or inside the Postgres container.
|
||||||
|
if [[ -n "${PG_CONTAINER:-}" ]]; then
|
||||||
|
pg() { $DOCKER exec -i -u postgres "$PG_CONTAINER" "$@"; }
|
||||||
|
elif [[ "${PG_DOCKER:-0}" == 1 ]]; then
|
||||||
|
pg() { $DOCKER run --rm -i --network host 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 "$PROD_DB_URL" >"$dump"
|
||||||
|
[[ -s "$dump" ]] || { echo "Empty dump; aborting before touching $DEV_DB_NAME" >&2; exit 1; }
|
||||||
|
pg pg_restore --list <"$dump" >/dev/null
|
||||||
|
|
||||||
|
echo "Recreating $DEV_DB_NAME (owner $DEV_OWNER)..."
|
||||||
|
pg psql -v ON_ERROR_STOP=1 -q "$DEV_ADMIN_URL" <<SQL
|
||||||
|
SELECT count(pg_terminate_backend(pid)) AS terminated_sessions FROM pg_stat_activity WHERE datname = '$DEV_DB_NAME' AND pid <> pg_backend_pid();
|
||||||
|
DROP DATABASE IF EXISTS "$DEV_DB_NAME";
|
||||||
|
DO \$\$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$DEV_OWNER') THEN
|
||||||
|
EXECUTE format('CREATE ROLE %I NOLOGIN', '$DEV_OWNER');
|
||||||
|
RAISE NOTICE 'created role $DEV_OWNER (NOLOGIN; give it a password + LOGIN for dev use)';
|
||||||
|
END IF;
|
||||||
|
END \$\$;
|
||||||
|
CREATE DATABASE "$DEV_DB_NAME" OWNER "$DEV_OWNER";
|
||||||
|
REVOKE CONNECT, TEMPORARY ON DATABASE "$DEV_DB_NAME" FROM PUBLIC;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "Restoring into $DEV_DB_NAME..."
|
||||||
|
# --no-owner/--no-privileges + --role: everything ends up owned by DEV_OWNER and none of
|
||||||
|
# prod's grants (personal_app, personal_ro, ...) are copied into dev.
|
||||||
|
pg pg_restore --no-password --exit-on-error --no-owner --no-privileges --role="$DEV_OWNER" \
|
||||||
|
--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,32 @@
|
|||||||
#!/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 ;;
|
|
||||||
*) echo 'Unrecognized deployment target' >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
image="git.stanrunge.dev/stan/personal-website:$sha"
|
|
||||||
compose="$directory/compose.prod.yml"
|
|
||||||
if [ "$directory" = /opt/personal-website-staging ]; then
|
|
||||||
environment_name=staging
|
|
||||||
else
|
|
||||||
environment_name=production
|
environment_name=production
|
||||||
|
|
||||||
|
image="git.stanrunge.dev/stan/website:$sha"
|
||||||
|
compose="$directory/compose.prod.yml"
|
||||||
|
# Images built before the repo rename (stan/personal-website -> stan/website) keep
|
||||||
|
# their old local name; retag so rollbacks to those SHAs still work.
|
||||||
|
legacy_image="git.stanrunge.dev/stan/personal-website:$sha"
|
||||||
|
if ! sudo docker image inspect "$image" >/dev/null 2>&1 &&
|
||||||
|
sudo docker image inspect "$legacy_image" >/dev/null 2>&1; then
|
||||||
|
sudo docker tag "$legacy_image" "$image"
|
||||||
|
fi
|
||||||
|
if ! sudo docker image inspect "$image" >/dev/null 2>&1; then
|
||||||
|
echo "Image $image is not on this host" >&2; exit 1
|
||||||
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 +39,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 +63,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 +108,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);
|
|
||||||
@@ -85,7 +85,7 @@ async function itemId(
|
|||||||
extraDimensions: Dimensions = {}
|
extraDimensions: Dimensions = {}
|
||||||
) {
|
) {
|
||||||
const [row] = await sql<{ id: number }[]>`
|
const [row] = await sql<{ id: number }[]>`
|
||||||
INSERT INTO cs_market_items (market_hash_name, name, icon_url, item_type)
|
INSERT INTO cs.items (market_hash_name, name, icon_url, item_type)
|
||||||
VALUES (${name}, ${displayName}, ${iconUrl ?? null}, ${itemType ?? null})
|
VALUES (${name}, ${displayName}, ${iconUrl ?? null}, ${itemType ?? null})
|
||||||
ON CONFLICT (market_hash_name) DO UPDATE SET
|
ON CONFLICT (market_hash_name) DO UPDATE SET
|
||||||
name = EXCLUDED.name,
|
name = EXCLUDED.name,
|
||||||
@@ -98,13 +98,13 @@ async function itemId(
|
|||||||
for (const [key, value] of Object.entries(inferred)) {
|
for (const [key, value] of Object.entries(inferred)) {
|
||||||
if (!/^[a-z][a-z0-9_]*$/.test(key) || !value) continue;
|
if (!/^[a-z][a-z0-9_]*$/.test(key) || !value) continue;
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO cs_item_dimensions (item_id, key, value) VALUES (${row.id}, ${key}, ${value})
|
INSERT INTO cs.item_dimensions (item_id, key, value) VALUES (${row.id}, ${key}, ${value})
|
||||||
ON CONFLICT (item_id, key) DO NOTHING`;
|
ON CONFLICT (item_id, key) DO NOTHING`;
|
||||||
}
|
}
|
||||||
for (const [key, value] of Object.entries(extraDimensions)) {
|
for (const [key, value] of Object.entries(extraDimensions)) {
|
||||||
if (!/^[a-z][a-z0-9_]*$/.test(key) || !value) continue;
|
if (!/^[a-z][a-z0-9_]*$/.test(key) || !value) continue;
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO cs_item_dimensions (item_id, key, value) VALUES (${row.id}, ${key}, ${value})
|
INSERT INTO cs.item_dimensions (item_id, key, value) VALUES (${row.id}, ${key}, ${value})
|
||||||
ON CONFLICT (item_id, key) DO UPDATE SET value = EXCLUDED.value`;
|
ON CONFLICT (item_id, key) DO UPDATE SET value = EXCLUDED.value`;
|
||||||
}
|
}
|
||||||
return row.id;
|
return row.id;
|
||||||
@@ -116,7 +116,7 @@ async function saveObservation(row: Observation, knownItemId?: number) {
|
|||||||
(await itemId(row.marketHashName, row.name, undefined, undefined, row.dimensions));
|
(await itemId(row.marketHashName, row.name, undefined, undefined, row.dimensions));
|
||||||
await ensureStream(row.source, row.currency, row.kind);
|
await ensureStream(row.source, row.currency, row.kind);
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO cs_price_observations
|
INSERT INTO cs.price_observations
|
||||||
(item_id, source, currency, observed_at, price_cents, volume, kind)
|
(item_id, source, currency, observed_at, price_cents, volume, kind)
|
||||||
VALUES (${item}, ${row.source}, ${row.currency}, ${new Date(row.observedAt)},
|
VALUES (${item}, ${row.source}, ${row.currency}, ${new Date(row.observedAt)},
|
||||||
${row.priceCents}, ${row.volume ?? null}, ${row.kind})
|
${row.priceCents}, ${row.volume ?? null}, ${row.kind})
|
||||||
@@ -128,7 +128,7 @@ async function saveObservation(row: Observation, knownItemId?: number) {
|
|||||||
async function ensureStream(source: string, currency: string, kind: string) {
|
async function ensureStream(source: string, currency: string, kind: string) {
|
||||||
const key = `${source}\0${currency}\0${kind}`;
|
const key = `${source}\0${currency}\0${kind}`;
|
||||||
if (knownStreams.has(key)) return;
|
if (knownStreams.has(key)) return;
|
||||||
await sql`INSERT INTO cs_price_streams (source, currency, kind)
|
await sql`INSERT INTO cs.price_streams (source, currency, kind)
|
||||||
VALUES (${source}, ${currency}, ${kind}) ON CONFLICT DO NOTHING`;
|
VALUES (${source}, ${currency}, ${kind}) ON CONFLICT DO NOTHING`;
|
||||||
knownStreams.add(key);
|
knownStreams.add(key);
|
||||||
}
|
}
|
||||||
@@ -150,7 +150,7 @@ async function saveHistoryRows(
|
|||||||
kind: 'sale-median'
|
kind: 'sale-median'
|
||||||
}));
|
}));
|
||||||
for (let offset = 0; offset < rows.length; offset += 500) {
|
for (let offset = 0; offset < rows.length; offset += 500) {
|
||||||
await sql`INSERT INTO cs_price_observations ${sql(rows.slice(offset, offset + 500))}
|
await sql`INSERT INTO cs.price_observations ${sql(rows.slice(offset, offset + 500))}
|
||||||
ON CONFLICT (item_id, source, currency, observed_at, kind) DO UPDATE SET
|
ON CONFLICT (item_id, source, currency, observed_at, kind) DO UPDATE SET
|
||||||
price_cents = EXCLUDED.price_cents, volume = EXCLUDED.volume`;
|
price_cents = EXCLUDED.price_cents, volume = EXCLUDED.volume`;
|
||||||
}
|
}
|
||||||
@@ -242,7 +242,7 @@ async function history() {
|
|||||||
if (!process.env.STEAM_SESSIONID || !process.env.STEAM_LOGIN_SECURE)
|
if (!process.env.STEAM_SESSIONID || !process.env.STEAM_LOGIN_SECURE)
|
||||||
throw new Error('STEAM_SESSIONID and STEAM_LOGIN_SECURE are required for Steam history');
|
throw new Error('STEAM_SESSIONID and STEAM_LOGIN_SECURE are required for Steam history');
|
||||||
const items = await sql<{ id: number; market_hash_name: string }[]>`
|
const items = await sql<{ id: number; market_hash_name: string }[]>`
|
||||||
SELECT id, market_hash_name FROM cs_market_items ORDER BY id LIMIT ${limit} OFFSET ${start}`;
|
SELECT id, market_hash_name FROM cs.items ORDER BY id LIMIT ${limit} OFFSET ${start}`;
|
||||||
let done = 0;
|
let done = 0;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const url = new URL('https://steamcommunity.com/market/pricehistory/');
|
const url = new URL('https://steamcommunity.com/market/pricehistory/');
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -8,11 +8,11 @@ try {
|
|||||||
await sql.begin(async (tx) => {
|
await sql.begin(async (tx) => {
|
||||||
for (const image of artwork) {
|
for (const image of artwork) {
|
||||||
const rows =
|
const rows =
|
||||||
await tx`update tracker_items i set icon=${image.path} from tracker_games g where i.game_id=g.id and g.slug=${image.game} and i.key=${image.key} returning i.id`;
|
await tx`update ref.tracker_items i set icon=${image.path} from ref.tracker_games g where i.game_id=g.id and g.slug=${image.game} and i.key=${image.key} returning i.id`;
|
||||||
if (rows.length !== 1) throw new Error(`Unknown artwork target: ${image.game}/${image.key}`);
|
if (rows.length !== 1) throw new Error(`Unknown artwork target: ${image.game}/${image.key}`);
|
||||||
}
|
}
|
||||||
for (const [source, path] of Object.entries(achievementArt)) {
|
for (const [source, path] of Object.entries(achievementArt)) {
|
||||||
await tx`update tracker_items set icon=${path} where kind='achievement' and icon=${source}`;
|
await tx`update ref.tracker_items set icon=${path} where kind='achievement' and icon=${source}`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -22,17 +22,17 @@ try {
|
|||||||
}[]
|
}[]
|
||||||
][]) {
|
][]) {
|
||||||
const [game] =
|
const [game] =
|
||||||
await tx`select id from tracker_games where slug=${slug} and franchise='call-of-duty'`;
|
await tx`select id from ref.tracker_games where slug=${slug} and franchise='call-of-duty'`;
|
||||||
if (!game) throw new Error(`Seed Call of Duty catalog first: ${slug}`);
|
if (!game) throw new Error(`Seed Call of Duty catalog first: ${slug}`);
|
||||||
for (let offset = 0; offset < rows.length; offset += 500) {
|
for (let offset = 0; offset < rows.length; offset += 500) {
|
||||||
const batch = rows
|
const batch = rows
|
||||||
.slice(offset, offset + 500)
|
.slice(offset, offset + 500)
|
||||||
.map(({ sourceUrl, ...r }) => ({ ...r, game_id: game.id, source_url: sourceUrl }));
|
.map(({ sourceUrl, ...r }) => ({ ...r, game_id: game.id, source_url: sourceUrl }));
|
||||||
await tx`insert into tracker_challenges ${tx(batch)} on conflict (game_id,key) do update set name=excluded.name,category=excluded.category,requirement=excluded.requirement,reward=excluded.reward,icon=excluded.icon,target=excluded.target,source_url=excluded.source_url`;
|
await tx`insert into ref.tracker_challenges ${tx(batch)} on conflict (game_id,key) do update set name=excluded.name,category=excluded.category,requirement=excluded.requirement,reward=excluded.reward,icon=excluded.icon,target=excluded.target,source_url=excluded.source_url`;
|
||||||
}
|
}
|
||||||
// Retire removed source definitions only when nobody has saved progress.
|
// Retire removed source definitions only when nobody has saved progress.
|
||||||
if (rows.length)
|
if (rows.length)
|
||||||
await tx`delete from tracker_challenges c where c.game_id=${game.id} and c.key not in ${tx(rows.map((r) => r.key))} and not exists (select 1 from tracker_challenge_progress p where p.challenge_id=c.id)`;
|
await tx`delete from ref.tracker_challenges c where c.game_id=${game.id} and c.key not in ${tx(rows.map((r) => r.key))} and not exists (select 1 from games.tracker_challenge_progress p where p.challenge_id=c.id)`;
|
||||||
console.log(`${slug}: ${rows.length} challenge rows`);
|
console.log(`${slug}: ${rows.length} challenge rows`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ const client = postgres(process.env.DB_URL, { max: 1 });
|
|||||||
try {
|
try {
|
||||||
await client.begin(async (tx) => {
|
await client.begin(async (tx) => {
|
||||||
const species = new Map(
|
const species = new Map(
|
||||||
(await tx`select id, number from pokemon`).map((r) => [r.number, r.id])
|
(await tx`select id, number from ref.pokemon`).map((r) => [r.number, r.id])
|
||||||
);
|
);
|
||||||
const games = new Map(
|
const games = new Map(
|
||||||
(await tx`select id, source_id from games`).map((r) => [r.source_id, r.id])
|
(await tx`select id, source_id from ref.pokemon_games`).map((r) => [r.source_id, r.id])
|
||||||
);
|
);
|
||||||
const speciesId = (n: number) => {
|
const speciesId = (n: number) => {
|
||||||
const id = species.get(n);
|
const id = species.get(n);
|
||||||
@@ -25,10 +25,10 @@ try {
|
|||||||
return id;
|
return id;
|
||||||
};
|
};
|
||||||
for (const row of catalog.facts)
|
for (const row of catalog.facts)
|
||||||
await tx`insert into pokemon_research (pokemon_id,facts) values (${speciesId(row.number)},${tx.json(row.facts)}) on conflict (pokemon_id) do update set facts=excluded.facts`;
|
await tx`insert into ref.pokemon_research (pokemon_id,facts) values (${speciesId(row.number)},${tx.json(row.facts)}) on conflict (pokemon_id) do update set facts=excluded.facts`;
|
||||||
for (const row of catalog.games) {
|
for (const row of catalog.games) {
|
||||||
if (!games.has(row.sourceId)) continue;
|
if (!games.has(row.sourceId)) continue;
|
||||||
await tx`insert into pokemon_game_research (game_id,version_group,regions) values (${gameId(row.sourceId)},${row.versionGroup},${row.regions}) on conflict (game_id) do update set version_group=excluded.version_group,regions=excluded.regions`;
|
await tx`insert into ref.pokemon_game_research (game_id,version_group,regions) values (${gameId(row.sourceId)},${row.versionGroup},${row.regions}) on conflict (game_id) do update set version_group=excluded.version_group,regions=excluded.regions`;
|
||||||
}
|
}
|
||||||
for (let offset = 0; offset < catalog.dexes.length; offset += 1000) {
|
for (let offset = 0; offset < catalog.dexes.length; offset += 1000) {
|
||||||
const batch = catalog.dexes
|
const batch = catalog.dexes
|
||||||
@@ -42,10 +42,10 @@ try {
|
|||||||
number
|
number
|
||||||
}));
|
}));
|
||||||
if (batch.length)
|
if (batch.length)
|
||||||
await tx`insert into pokemon_dex_numbers ${tx(batch)} on conflict (pokemon_id,game_id,dex) do update set number=excluded.number,dex_name=excluded.dex_name`;
|
await tx`insert into ref.pokemon_dex_numbers ${tx(batch)} on conflict (pokemon_id,game_id,dex) do update set number=excluded.number,dex_name=excluded.dex_name`;
|
||||||
}
|
}
|
||||||
for (const row of catalog.moves)
|
for (const row of catalog.moves)
|
||||||
await tx`insert into pokemon_moves ${tx(row)} on conflict (id) do update set name=excluded.name,type=excluded.type,category=excluded.category,power=excluded.power,accuracy=excluded.accuracy,pp=excluded.pp,priority=excluded.priority,target=excluded.target`;
|
await tx`insert into ref.pokemon_moves ${tx(row)} on conflict (id) do update set name=excluded.name,type=excluded.type,category=excluded.category,power=excluded.power,accuracy=excluded.accuracy,pp=excluded.pp,priority=excluded.priority,target=excluded.target`;
|
||||||
for (let offset = 0; offset < catalog.learnsets.length; offset += 500) {
|
for (let offset = 0; offset < catalog.learnsets.length; offset += 500) {
|
||||||
const batch = catalog.learnsets
|
const batch = catalog.learnsets
|
||||||
.slice(offset, offset + 500)
|
.slice(offset, offset + 500)
|
||||||
@@ -54,7 +54,7 @@ try {
|
|||||||
version_group,
|
version_group,
|
||||||
moves: tx.json(moves)
|
moves: tx.json(moves)
|
||||||
}));
|
}));
|
||||||
await tx`insert into pokemon_learnsets ${tx(batch)} on conflict (pokemon_id,version_group) do update set moves=excluded.moves`;
|
await tx`insert into ref.pokemon_learnsets ${tx(batch)} on conflict (pokemon_id,version_group) do update set moves=excluded.moves`;
|
||||||
}
|
}
|
||||||
console.log(
|
console.log(
|
||||||
`Seeded ${catalog.facts.length} profiles, ${catalog.dexes.length} dex entries, ${catalog.moves.length} moves and ${catalog.learnsets.length} versioned learnsets`
|
`Seeded ${catalog.facts.length} profiles, ${catalog.dexes.length} dex entries, ${catalog.moves.length} moves and ${catalog.learnsets.length} versioned learnsets`
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
|||||||
const sql = postgres(process.env.DB_URL, { max: 1 });
|
const sql = postgres(process.env.DB_URL, { max: 1 });
|
||||||
try {
|
try {
|
||||||
const users =
|
const users =
|
||||||
await sql`update "user" set role = ${role}, updated_at = now() where id = ${userId} returning id, role`;
|
await sql`update auth."user" set role = ${role}, updated_at = now() where id = ${userId} returning id, role`;
|
||||||
if (!users.length)
|
if (!users.length)
|
||||||
throw new Error('User ID not found. Register first and copy the ID from your account page.');
|
throw new Error('User ID not found. Register first and copy the ID from your account page.');
|
||||||
console.log(users[0]);
|
console.log(users[0]);
|
||||||
|
|||||||
@@ -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"
|
|
||||||
63
src/lib/components/CatalogGrid.svelte
Normal file
63
src/lib/components/CatalogGrid.svelte
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Card grid shared by the home page, /projects and /trackers.
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||||
|
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||||
|
import * as Card from '#lib/components/ui/card/index.js';
|
||||||
|
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
|
||||||
|
|
||||||
|
type Item = { name: string; subtitle: string; href: string; external: boolean };
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
subtitleClass = 'mt-1 block text-sm text-muted-foreground'
|
||||||
|
}: { items: readonly Item[]; subtitleClass?: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ul class="grid gap-3 sm:grid-cols-2">
|
||||||
|
{#each items as project (project.name)}
|
||||||
|
<li>
|
||||||
|
<Card.Root class="h-full py-0 transition-colors hover:bg-muted/50"
|
||||||
|
><Card.Content class="p-0">
|
||||||
|
{#if project.external}
|
||||||
|
<a
|
||||||
|
href={project.href}
|
||||||
|
rel="external"
|
||||||
|
class="flex min-h-28 items-start justify-between gap-4 p-5"
|
||||||
|
>
|
||||||
|
<span class="flex min-w-0 items-start gap-3">
|
||||||
|
<span
|
||||||
|
class="mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-md border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"
|
||||||
|
>
|
||||||
|
<ProjectIcon name={project.name} class="size-5" />
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0"
|
||||||
|
><span class="block font-medium">{project.name}</span><span class={subtitleClass}
|
||||||
|
>{project.subtitle}</span
|
||||||
|
></span
|
||||||
|
>
|
||||||
|
</span><ExternalLink size={18} aria-hidden="true" class="text-[var(--site-muted)]" />
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href={resolve(project.href as '/')}
|
||||||
|
class="flex min-h-28 items-start justify-between gap-4 p-5"
|
||||||
|
>
|
||||||
|
<span class="flex min-w-0 items-start gap-3">
|
||||||
|
<span
|
||||||
|
class="mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-md border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"
|
||||||
|
>
|
||||||
|
<ProjectIcon name={project.name} class="size-5" />
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0"
|
||||||
|
><span class="block font-medium">{project.name}</span><span class={subtitleClass}
|
||||||
|
>{project.subtitle}</span
|
||||||
|
></span
|
||||||
|
>
|
||||||
|
</span><ArrowRight size={18} aria-hidden="true" class="text-[var(--site-muted)]" />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</Card.Content></Card.Root
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
59
src/lib/components/ListExtraFields.svelte
Normal file
59
src/lib/components/ListExtraFields.svelte
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Form inputs for a list's own typed columns (meta.columns), posted as `extra:<column>`.
|
||||||
|
import { Input } from '#lib/components/ui/input/index.js';
|
||||||
|
import { Textarea } from '#lib/components/ui/textarea/index.js';
|
||||||
|
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||||||
|
import { Checkbox } from '#lib/components/ui/checkbox/index.js';
|
||||||
|
import type { ExtraValue, ListColumn } from '#lib/server/lists.js';
|
||||||
|
|
||||||
|
let { columns, values = {} }: { columns: ListColumn[]; values?: Record<string, ExtraValue> } =
|
||||||
|
$props();
|
||||||
|
const str = (v: ExtraValue | undefined) => (v === null || v === undefined ? '' : String(v));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each columns as column (column.name)}
|
||||||
|
{@const name = `extra:${column.name}`}
|
||||||
|
{#if column.uiType === 'bool'}
|
||||||
|
<label class="text-sm"
|
||||||
|
><Checkbox {name} value="on" checked={values[column.name] === true} /> {column.label}</label
|
||||||
|
>
|
||||||
|
{:else if column.uiType === 'select' && column.options.length}
|
||||||
|
<label
|
||||||
|
>{column.label}<NativeSelect
|
||||||
|
{name}
|
||||||
|
class="block w-full rounded border p-2"
|
||||||
|
value={str(values[column.name])}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{#each column.options as option (option)}<option value={option}>{option}</option>{/each}
|
||||||
|
</NativeSelect></label
|
||||||
|
>
|
||||||
|
{:else if column.uiType === 'longtext'}
|
||||||
|
<label
|
||||||
|
>{column.label}<Textarea
|
||||||
|
{name}
|
||||||
|
rows={3}
|
||||||
|
maxlength={2000}
|
||||||
|
class="block w-full rounded border p-2"
|
||||||
|
value={str(values[column.name])}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
{:else if ['text', 'url', 'image', 'number', 'cents', 'date', 'select'].includes(column.uiType)}
|
||||||
|
<label
|
||||||
|
>{column.label}<Input
|
||||||
|
{name}
|
||||||
|
type={column.uiType === 'number' || column.uiType === 'cents'
|
||||||
|
? 'number'
|
||||||
|
: column.uiType === 'date'
|
||||||
|
? 'date'
|
||||||
|
: column.uiType === 'url' || column.uiType === 'image'
|
||||||
|
? 'url'
|
||||||
|
: 'text'}
|
||||||
|
step={column.uiType === 'number' ? 'any' : undefined}
|
||||||
|
maxlength={column.uiType === 'text' ? 2000 : undefined}
|
||||||
|
class="block w-full rounded border p-2"
|
||||||
|
value={str(values[column.name])}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
import House from 'lucide-svelte/icons/house';
|
import House from 'lucide-svelte/icons/house';
|
||||||
import Globe2 from 'lucide-svelte/icons/globe-2';
|
import Globe2 from 'lucide-svelte/icons/globe-2';
|
||||||
import Languages from 'lucide-svelte/icons/languages';
|
import Languages from 'lucide-svelte/icons/languages';
|
||||||
|
import ListChecks from 'lucide-svelte/icons/list-checks';
|
||||||
import Music2 from 'lucide-svelte/icons/music-2';
|
import Music2 from 'lucide-svelte/icons/music-2';
|
||||||
import PawPrint from 'lucide-svelte/icons/paw-print';
|
import PawPrint from 'lucide-svelte/icons/paw-print';
|
||||||
import Trophy from 'lucide-svelte/icons/trophy';
|
import Trophy from 'lucide-svelte/icons/trophy';
|
||||||
@@ -32,7 +33,8 @@
|
|||||||
'language learner': Languages,
|
'language learner': Languages,
|
||||||
'money tracker': Wallet,
|
'money tracker': Wallet,
|
||||||
'cs prices': CandlestickChart,
|
'cs prices': CandlestickChart,
|
||||||
'house tracker': House
|
'house tracker': House,
|
||||||
|
lists: ListChecks
|
||||||
};
|
};
|
||||||
|
|
||||||
const Icon = $derived(icons[name as keyof typeof icons] ?? FolderKanban);
|
const Icon = $derived(icons[name as keyof typeof icons] ?? FolderKanban);
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ describe('money tracker calculations', () => {
|
|||||||
annualReturnPercent: '5.00',
|
annualReturnPercent: '5.00',
|
||||||
contributionMode: 'excess',
|
contributionMode: 'excess',
|
||||||
fixedMonthlyCents: 0,
|
fixedMonthlyCents: 0,
|
||||||
includeRecurring: 1,
|
includeRecurring: true,
|
||||||
endDate: '2026-11-30'
|
endDate: '2026-11-30'
|
||||||
};
|
};
|
||||||
const accounts = [
|
const accounts = [
|
||||||
@@ -140,7 +140,7 @@ describe('money tracker calculations', () => {
|
|||||||
annualReturnPercent: '5.00',
|
annualReturnPercent: '5.00',
|
||||||
contributionMode: 'excess',
|
contributionMode: 'excess',
|
||||||
fixedMonthlyCents: 0,
|
fixedMonthlyCents: 0,
|
||||||
includeRecurring: 1,
|
includeRecurring: true,
|
||||||
endDate: '2026-11-30'
|
endDate: '2026-11-30'
|
||||||
},
|
},
|
||||||
'2026-09-23'
|
'2026-09-23'
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ type ProjectionSettings = {
|
|||||||
annualReturnPercent: string | number;
|
annualReturnPercent: string | number;
|
||||||
contributionMode: string;
|
contributionMode: string;
|
||||||
fixedMonthlyCents: number;
|
fixedMonthlyCents: number;
|
||||||
includeRecurring: number;
|
includeRecurring: boolean;
|
||||||
endDate?: string | null;
|
endDate?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ export function wealthProjection(
|
|||||||
if (entry.accountId === validInvestmentId) indexBalance += entry.amountCents;
|
if (entry.accountId === validInvestmentId) indexBalance += entry.amountCents;
|
||||||
} else spendable += entry.amountCents;
|
} else spendable += entry.amountCents;
|
||||||
}
|
}
|
||||||
if (settings?.includeRecurring !== 0 && index > 0) {
|
if (settings?.includeRecurring !== false && index > 0) {
|
||||||
for (const item of recurring) {
|
for (const item of recurring) {
|
||||||
if ((item.startsOn && item.startsOn > date) || (item.endsOn && item.endsOn < first))
|
if ((item.startsOn && item.startsOn > date) || (item.endsOn && item.endsOn < first))
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Real projects (tools and experiments). Personal data trackers live in `trackers`.
|
||||||
export const projects = [
|
export const projects = [
|
||||||
{
|
{
|
||||||
name: 'vash esports',
|
name: 'vash esports',
|
||||||
@@ -5,18 +6,6 @@ export const projects = [
|
|||||||
href: 'https://esports.vash.software',
|
href: 'https://esports.vash.software',
|
||||||
external: true
|
external: true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'game tracker',
|
|
||||||
subtitle: 'every pokemon in every game, and call of duty camo mastery',
|
|
||||||
href: '/projects/game-tracker',
|
|
||||||
external: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'animaldex',
|
|
||||||
subtitle: 'add neighborhood pets to the world map',
|
|
||||||
href: '/projects/animaldex',
|
|
||||||
external: false
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'osu! map finder',
|
name: 'osu! map finder',
|
||||||
subtitle: 'find osu! maps for Spotify songs/albums/playlists',
|
subtitle: 'find osu! maps for Spotify songs/albums/playlists',
|
||||||
@@ -40,6 +29,16 @@ export const projects = [
|
|||||||
subtitle: 'Dutch, Korean and English, from zero to fluent',
|
subtitle: 'Dutch, Korean and English, from zero to fluent',
|
||||||
href: '/projects/language-learner',
|
href: '/projects/language-learner',
|
||||||
external: false
|
external: false
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Trackers keep their existing /projects/... URLs (no moves, so no redirects needed).
|
||||||
|
export const trackers = [
|
||||||
|
{
|
||||||
|
name: 'game tracker',
|
||||||
|
subtitle: 'every pokemon in every game, and call of duty camo mastery',
|
||||||
|
href: '/projects/game-tracker',
|
||||||
|
external: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'money tracker',
|
name: 'money tracker',
|
||||||
@@ -58,5 +57,17 @@ export const projects = [
|
|||||||
subtitle: 'Counter-Strike market prices across time',
|
subtitle: 'Counter-Strike market prices across time',
|
||||||
href: '/projects/cs-prices',
|
href: '/projects/cs-prices',
|
||||||
external: false
|
external: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'animaldex',
|
||||||
|
subtitle: 'add neighborhood pets to the world map',
|
||||||
|
href: '/projects/animaldex',
|
||||||
|
external: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'lists',
|
||||||
|
subtitle: 'books, games, places and other things worth keeping',
|
||||||
|
href: '/lists',
|
||||||
|
external: false
|
||||||
}
|
}
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -32,9 +32,14 @@ const publicPages: Record<string, { title: string; description: string }> = {
|
|||||||
'/projects': {
|
'/projects': {
|
||||||
title: 'Projects',
|
title: 'Projects',
|
||||||
description:
|
description:
|
||||||
'Software projects by Stan Runge, including Game Tracker, Animaldex and Derby Lens.'
|
'Software projects by Stan Runge, including osu! map finder, Derby Lens and Language Learner.'
|
||||||
},
|
},
|
||||||
'/blog': { title: 'Blog', description: 'Articles and notes by Stan Runge.' },
|
'/blog': { title: 'Blog', description: 'Articles and notes by Stan Runge.' },
|
||||||
|
'/trackers': {
|
||||||
|
title: 'Trackers',
|
||||||
|
description:
|
||||||
|
'Stan Runge’s trackers: games, money, houses, Counter-Strike prices, Animaldex and lists.'
|
||||||
|
},
|
||||||
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
|
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
|
||||||
'/projects/game-tracker': {
|
'/projects/game-tracker': {
|
||||||
title: 'Game Tracker',
|
title: 'Game Tracker',
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ export const relations = defineRelations(schema, (r) => ({
|
|||||||
sessions: r.many.session(),
|
sessions: r.many.session(),
|
||||||
accounts: r.many.account(),
|
accounts: r.many.account(),
|
||||||
entries: r.many.userEntries(),
|
entries: r.many.userEntries(),
|
||||||
games: r.many.userGames(),
|
games: r.many.userGames()
|
||||||
listCompletions: r.many.listItemCompletions()
|
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
user: r.one.user({
|
user: r.one.user({
|
||||||
@@ -138,25 +137,14 @@ export const relations = defineRelations(schema, (r) => ({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Lists ─────────────────────────────────────
|
// ── Meta registry (lists live in schema `lists`, see src/lib/server/lists.ts)
|
||||||
lists: {
|
metaTables: {
|
||||||
items: r.many.listItems()
|
columns: r.many.metaColumns()
|
||||||
},
|
},
|
||||||
listItems: {
|
metaColumns: {
|
||||||
list: r.one.lists({
|
table: r.one.metaTables({
|
||||||
from: r.listItems.listId,
|
from: r.metaColumns.tableId,
|
||||||
to: r.lists.id
|
to: r.metaTables.id
|
||||||
}),
|
|
||||||
completions: r.many.listItemCompletions()
|
|
||||||
},
|
|
||||||
listItemCompletions: {
|
|
||||||
item: r.one.listItems({
|
|
||||||
from: r.listItemCompletions.itemId,
|
|
||||||
to: r.listItems.id
|
|
||||||
}),
|
|
||||||
user: r.one.user({
|
|
||||||
from: r.listItemCompletions.userId,
|
|
||||||
to: r.user.id
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { doublePrecision, integer, pgTable, text } from 'drizzle-orm/pg-core';
|
import { doublePrecision, integer, text } from 'drizzle-orm/pg-core';
|
||||||
|
import { animaldexSchema } from './schemas';
|
||||||
import { id, timestamps, timestamptz } from './helpers';
|
import { id, timestamps, timestamptz } from './helpers';
|
||||||
|
|
||||||
export const animals = pgTable('animals', {
|
export const animals = animaldexSchema.table('animals', {
|
||||||
id: id(),
|
id: id(),
|
||||||
|
|
||||||
species: text('species').notNull(),
|
species: text('species').notNull(),
|
||||||
@@ -22,7 +23,7 @@ export const animals = pgTable('animals', {
|
|||||||
...timestamps
|
...timestamps
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sightings = pgTable('sightings', {
|
export const sightings = animaldexSchema.table('sightings', {
|
||||||
id: id(),
|
id: id(),
|
||||||
animalId: integer('animal_id')
|
animalId: integer('animal_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -40,7 +41,7 @@ export const sightings = pgTable('sightings', {
|
|||||||
...timestamps
|
...timestamps
|
||||||
});
|
});
|
||||||
|
|
||||||
export const photos = pgTable('photos', {
|
export const photos = animaldexSchema.table('sighting_photos', {
|
||||||
id: id(),
|
id: id(),
|
||||||
sightingId: integer('sighting_id')
|
sightingId: integer('sighting_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { pgTable, text, timestamp, boolean, index, uniqueIndex } from 'drizzle-orm/pg-core';
|
import { text, timestamp, boolean, index, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
|
import { authSchema } from './schemas';
|
||||||
|
|
||||||
export const user = pgTable('user', {
|
export const user = authSchema.table('user', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
role: text('role').notNull().default('user'),
|
role: text('role').notNull().default('user'),
|
||||||
@@ -14,7 +15,7 @@ export const user = pgTable('user', {
|
|||||||
.notNull()
|
.notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const session = pgTable(
|
export const session = authSchema.table(
|
||||||
'session',
|
'session',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -33,7 +34,7 @@ export const session = pgTable(
|
|||||||
(table) => [index('session_userId_idx').on(table.userId)]
|
(table) => [index('session_userId_idx').on(table.userId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const account = pgTable(
|
export const account = authSchema.table(
|
||||||
'account',
|
'account',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -61,7 +62,7 @@ export const account = pgTable(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const verification = pgTable(
|
export const verification = authSchema.table(
|
||||||
'verification',
|
'verification',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { pgEnum, pgTable, text, unique } from 'drizzle-orm/pg-core';
|
import { text, unique } from 'drizzle-orm/pg-core';
|
||||||
|
import { siteSchema } from './schemas';
|
||||||
import { id, timestamps } from './helpers';
|
import { id, timestamps } from './helpers';
|
||||||
|
|
||||||
export const blogArticleStatus = pgEnum('blog_article_status', ['draft', 'published']);
|
export const blogArticleStatus = siteSchema.enum('blog_article_status', ['draft', 'published']);
|
||||||
|
|
||||||
export const blogArticles = pgTable(
|
export const blogArticles = siteSchema.table(
|
||||||
'blog_articles',
|
'blog_articles',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
|
|||||||
162
src/lib/server/db/schema/books.ts
Normal file
162
src/lib/server/db/schema/books.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
bigint,
|
||||||
|
check,
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
|
primaryKey,
|
||||||
|
real,
|
||||||
|
text,
|
||||||
|
uniqueIndex
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { booksSchema } from './schemas';
|
||||||
|
import { id, timestamps, timestamptz } from './helpers';
|
||||||
|
|
||||||
|
// Book tracker + reader. A work is one book (any edition we care about); a work can
|
||||||
|
// exist without files (placeholder for a book Stan wants but doesn't have yet).
|
||||||
|
// Files live on stan-vm under $BOOKS_ROOT/library (target /srv/books/library); covers under $BOOKS_ROOT/covers.
|
||||||
|
|
||||||
|
export const bookMetadataStatus = booksSchema.enum('metadata_status', [
|
||||||
|
'pending', // not looked up yet
|
||||||
|
'matched', // confident match (ISBN or exact title+author)
|
||||||
|
'low_confidence', // fuzzy match, needs a human look
|
||||||
|
'not_found',
|
||||||
|
'manual' // edited by hand; enrichment never overwrites it
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const bookWorks = booksSchema.table(
|
||||||
|
'works',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
subtitle: text('subtitle'),
|
||||||
|
authors: text('authors').array().notNull().default([]),
|
||||||
|
isbn10: text('isbn10'),
|
||||||
|
isbn13: text('isbn13'),
|
||||||
|
publisher: text('publisher'),
|
||||||
|
year: integer('year'),
|
||||||
|
language: text('language'), // ISO 639-1 where known, e.g. 'en', 'nl'
|
||||||
|
pageCount: integer('page_count'),
|
||||||
|
description: text('description'),
|
||||||
|
tags: text('tags').array().notNull().default([]),
|
||||||
|
coverUrl: text('cover_url'), // remote source of the cover
|
||||||
|
coverPath: text('cover_path'), // local file, relative to /srv/books/covers
|
||||||
|
// External ids
|
||||||
|
openlibraryWorkId: text('openlibrary_work_id'), // e.g. OL45883W
|
||||||
|
openlibraryEditionId: text('openlibrary_edition_id'), // e.g. OL7353617M
|
||||||
|
googleBooksId: text('google_books_id'),
|
||||||
|
annasMd5: text('annas_md5').array().notNull().default([]), // Anna's Archive / libgen file md5s
|
||||||
|
sourceRefs: jsonb('source_refs').$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
metadataStatus: bookMetadataStatus('metadata_status').notNull().default('pending'),
|
||||||
|
metadataSource: text('metadata_source'), // 'openlibrary' | 'googlebooks' | 'libgen' | 'manual'
|
||||||
|
metadataFetchedAt: timestamptz('metadata_fetched_at'),
|
||||||
|
notes: text('notes'),
|
||||||
|
...timestamps
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('works_isbn13_key')
|
||||||
|
.on(t.isbn13)
|
||||||
|
.where(sql`${t.isbn13} is not null`),
|
||||||
|
index('works_title_idx').on(sql`lower(${t.title})`),
|
||||||
|
check('works_year_check', sql`${t.year} is null or ${t.year} between 0 and 2100`)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const bookFiles = booksSchema.table(
|
||||||
|
'files',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
workId: integer('work_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => bookWorks.id, { onDelete: 'cascade' }),
|
||||||
|
path: text('path').notNull(), // relative to /srv/books/library
|
||||||
|
format: text('format').notNull(), // epub | pdf | mobi | azw3 | djvu
|
||||||
|
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
|
||||||
|
sha256: text('sha256').notNull(),
|
||||||
|
md5: text('md5'),
|
||||||
|
source: text('source'), // 'gdrive' | 'textbooks-repo' | 'annas' | 'upload'
|
||||||
|
...timestamps
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('files_path_key').on(t.path),
|
||||||
|
uniqueIndex('files_sha256_key').on(t.sha256),
|
||||||
|
index('files_work_id_idx').on(t.workId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const bookShelves = booksSchema.table(
|
||||||
|
'shelves',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
slug: text('slug').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
|
...timestamps
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex('shelves_slug_key').on(t.slug)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const bookShelfWorks = booksSchema.table(
|
||||||
|
'shelf_works',
|
||||||
|
{
|
||||||
|
shelfId: integer('shelf_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => bookShelves.id, { onDelete: 'cascade' }),
|
||||||
|
workId: integer('work_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => bookWorks.id, { onDelete: 'cascade' }),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
|
addedAt: timestamptz('added_at').notNull().defaultNow()
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
primaryKey({ columns: [t.shelfId, t.workId] }),
|
||||||
|
index('shelf_works_work_id_idx').on(t.workId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const bookProgress = booksSchema.table(
|
||||||
|
'progress',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
workId: integer('work_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => bookWorks.id, { onDelete: 'cascade' }),
|
||||||
|
fileId: integer('file_id').references(() => bookFiles.id, { onDelete: 'set null' }),
|
||||||
|
locator: text('locator'), // EPUB CFI or 'page:123' for PDFs
|
||||||
|
page: integer('page'),
|
||||||
|
percent: real('percent').notNull().default(0),
|
||||||
|
startedAt: timestamptz('started_at'),
|
||||||
|
finishedAt: timestamptz('finished_at'),
|
||||||
|
...timestamps
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('progress_work_id_key').on(t.workId),
|
||||||
|
check('progress_percent_check', sql`${t.percent} between 0 and 100`)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const bookNotes = booksSchema.table(
|
||||||
|
'notes',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
workId: integer('work_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => bookWorks.id, { onDelete: 'cascade' }),
|
||||||
|
fileId: integer('file_id').references(() => bookFiles.id, { onDelete: 'set null' }),
|
||||||
|
locator: text('locator'),
|
||||||
|
page: integer('page'),
|
||||||
|
highlight: text('highlight'), // the selected text, if any
|
||||||
|
note: text('note'),
|
||||||
|
color: text('color'),
|
||||||
|
...timestamps
|
||||||
|
},
|
||||||
|
(t) => [index('notes_work_id_idx').on(t.workId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export type BookWork = typeof bookWorks.$inferSelect;
|
||||||
|
export type NewBookWork = typeof bookWorks.$inferInsert;
|
||||||
|
export type BookFile = typeof bookFiles.$inferSelect;
|
||||||
|
export type BookShelf = typeof bookShelves.$inferSelect;
|
||||||
|
export type BookProgress = typeof bookProgress.$inferSelect;
|
||||||
|
export type BookNote = typeof bookNotes.$inferSelect;
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import { boolean, index, integer, pgEnum, pgTable, text, unique } from 'drizzle-orm/pg-core';
|
import { boolean, index, integer, text, unique } from 'drizzle-orm/pg-core';
|
||||||
|
import { gamesSchema, refSchema } from './schemas';
|
||||||
import { availabilityValues } from '../../../availability';
|
import { availabilityValues } from '../../../availability';
|
||||||
import { user } from './auth';
|
import { user } from './auth';
|
||||||
import { id, timestamps } from './helpers';
|
import { id, timestamps } from './helpers';
|
||||||
|
|
||||||
export const availability = pgEnum('availability', availabilityValues);
|
export const availability = refSchema.enum('availability', availabilityValues);
|
||||||
|
|
||||||
// Where a catalog row came from. Seeding replaces `pokeapi` and `derived` rows
|
// Where a catalog row came from. Seeding replaces `pokeapi` and `derived` rows
|
||||||
// but never touches `manual` ones, which are admin corrections.
|
// but never touches `manual` ones, which are admin corrections.
|
||||||
export const catalogSources = ['pokeapi', 'derived', 'manual'] as const;
|
export const catalogSources = ['pokeapi', 'derived', 'manual'] as const;
|
||||||
const source = () => text('source', { enum: catalogSources }).notNull().default('pokeapi');
|
const source = () => text('source', { enum: catalogSources }).notNull().default('pokeapi');
|
||||||
|
|
||||||
export const pokemon = pgTable('pokemon', {
|
export const pokemon = refSchema.table('pokemon', {
|
||||||
id: id(),
|
id: id(),
|
||||||
number: integer('number').notNull().unique(),
|
number: integer('number').notNull().unique(),
|
||||||
generation: integer('generation').notNull().default(1),
|
generation: integer('generation').notNull().default(1),
|
||||||
@@ -32,7 +33,7 @@ export const pokemon = pgTable('pokemon', {
|
|||||||
...timestamps
|
...timestamps
|
||||||
});
|
});
|
||||||
|
|
||||||
export const games = pgTable('games', {
|
export const games = refSchema.table('pokemon_games', {
|
||||||
id: id(),
|
id: id(),
|
||||||
sourceId: integer('source_id').unique(),
|
sourceId: integer('source_id').unique(),
|
||||||
slug: text('slug').unique(),
|
slug: text('slug').unique(),
|
||||||
@@ -43,7 +44,7 @@ export const games = pgTable('games', {
|
|||||||
...timestamps
|
...timestamps
|
||||||
});
|
});
|
||||||
|
|
||||||
export const pokemonGameEntries = pgTable(
|
export const pokemonGameEntries = refSchema.table(
|
||||||
'pokemon_game_entries',
|
'pokemon_game_entries',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
@@ -62,7 +63,7 @@ export const pokemonGameEntries = pgTable(
|
|||||||
(table) => [unique().on(table.pokemonId, table.gameId), index().on(table.gameId)]
|
(table) => [unique().on(table.pokemonId, table.gameId), index().on(table.gameId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const pokemonLocations = pgTable('pokemon_locations', {
|
export const pokemonLocations = refSchema.table('pokemon_locations', {
|
||||||
id: id(),
|
id: id(),
|
||||||
sourceId: integer('source_id').unique(),
|
sourceId: integer('source_id').unique(),
|
||||||
slug: text('slug').notNull(),
|
slug: text('slug').notNull(),
|
||||||
@@ -76,7 +77,7 @@ export const pokemonLocations = pgTable('pokemon_locations', {
|
|||||||
...timestamps
|
...timestamps
|
||||||
});
|
});
|
||||||
|
|
||||||
export const pokemonEncounters = pgTable(
|
export const pokemonEncounters = refSchema.table(
|
||||||
'pokemon_encounters',
|
'pokemon_encounters',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
@@ -106,7 +107,7 @@ export const pokemonEncounters = pgTable(
|
|||||||
|
|
||||||
// The method that applies in each game, e.g. Magneton → Magnezone needs Mt.
|
// The method that applies in each game, e.g. Magneton → Magnezone needs Mt.
|
||||||
// Coronet in Platinum but a Thunder Stone in Sword.
|
// Coronet in Platinum but a Thunder Stone in Sword.
|
||||||
export const pokemonEvolutions = pgTable(
|
export const pokemonEvolutions = refSchema.table(
|
||||||
'pokemon_evolutions',
|
'pokemon_evolutions',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
@@ -129,8 +130,8 @@ export const pokemonEvolutions = pgTable(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const userEntries = pgTable(
|
export const userEntries = gamesSchema.table(
|
||||||
'user_entries',
|
'catch_em_all_entries',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
pokemonGameEntryId: integer('pokemon_game_entry_id')
|
pokemonGameEntryId: integer('pokemon_game_entry_id')
|
||||||
@@ -145,8 +146,8 @@ export const userEntries = pgTable(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// which games a user has chosen to track ("rules")
|
// which games a user has chosen to track ("rules")
|
||||||
export const userGames = pgTable(
|
export const userGames = gamesSchema.table(
|
||||||
'user_games',
|
'catch_em_all_games',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
userId: text('user_id')
|
userId: text('user_id')
|
||||||
@@ -161,7 +162,7 @@ export const userGames = pgTable(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// A row distinguishes an explicit empty game selection from a new account.
|
// A row distinguishes an explicit empty game selection from a new account.
|
||||||
export const catchEmAllSettings = pgTable('catch_em_all_settings', {
|
export const catchEmAllSettings = gamesSchema.table('catch_em_all_settings', {
|
||||||
id: id(),
|
id: id(),
|
||||||
userId: text('user_id')
|
userId: text('user_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { index, integer, pgTable, text, timestamp, uniqueIndex, bigint } from 'drizzle-orm/pg-core';
|
import {
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
primaryKey,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
|
bigint
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { csSchema } from './schemas';
|
||||||
import { id } from './helpers';
|
import { id } from './helpers';
|
||||||
|
|
||||||
// A Steam market hash name identifies a listing, including wear and special variants.
|
// A Steam market hash name identifies a listing, including wear and special variants.
|
||||||
export const csMarketItems = pgTable(
|
export const csMarketItems = csSchema.table(
|
||||||
'cs_market_items',
|
'items',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
marketHashName: text('market_hash_name').notNull(),
|
marketHashName: text('market_hash_name').notNull(),
|
||||||
@@ -14,14 +23,14 @@ export const csMarketItems = pgTable(
|
|||||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow()
|
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow()
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
uniqueIndex('cs_market_items_hash_idx').on(table.marketHashName),
|
uniqueIndex('items_hash_idx').on(table.marketHashName),
|
||||||
index('cs_market_items_name_idx').on(table.name)
|
index('items_name_idx').on(table.name)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep source observations intact. Aggregation happens only when the chart is requested.
|
// Keep source observations intact. Aggregation happens only when the chart is requested.
|
||||||
export const csPriceObservations = pgTable(
|
export const csPriceObservations = csSchema.table(
|
||||||
'cs_price_observations',
|
'price_observations',
|
||||||
{
|
{
|
||||||
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
|
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
|
||||||
itemId: integer('item_id')
|
itemId: integer('item_id')
|
||||||
@@ -36,25 +45,25 @@ export const csPriceObservations = pgTable(
|
|||||||
importedAt: timestamp('imported_at', { withTimezone: true }).notNull().defaultNow()
|
importedAt: timestamp('imported_at', { withTimezone: true }).notNull().defaultNow()
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
uniqueIndex('cs_price_observations_unique_idx').on(
|
uniqueIndex('price_observations_unique_idx').on(
|
||||||
table.itemId,
|
table.itemId,
|
||||||
table.source,
|
table.source,
|
||||||
table.currency,
|
table.currency,
|
||||||
table.observedAt,
|
table.observedAt,
|
||||||
table.kind
|
table.kind
|
||||||
),
|
),
|
||||||
index('cs_price_observations_lookup_idx').on(
|
index('price_observations_lookup_idx').on(
|
||||||
table.itemId,
|
table.itemId,
|
||||||
table.source,
|
table.source,
|
||||||
table.currency,
|
table.currency,
|
||||||
table.observedAt
|
table.observedAt
|
||||||
),
|
),
|
||||||
index('cs_price_observations_time_idx').on(table.observedAt)
|
index('price_observations_time_idx').on(table.observedAt)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const csItemDimensions = pgTable(
|
export const csItemDimensions = csSchema.table(
|
||||||
'cs_item_dimensions',
|
'item_dimensions',
|
||||||
{
|
{
|
||||||
itemId: integer('item_id')
|
itemId: integer('item_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -63,19 +72,19 @@ export const csItemDimensions = pgTable(
|
|||||||
value: text('value').notNull()
|
value: text('value').notNull()
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
uniqueIndex('cs_item_dimensions_unique_idx').on(table.itemId, table.key),
|
primaryKey({ name: 'item_dimensions_pkey', columns: [table.itemId, table.key] }),
|
||||||
index('cs_item_dimensions_filter_idx').on(table.key, table.value, table.itemId)
|
index('item_dimensions_filter_idx').on(table.key, table.value, table.itemId)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const csPriceStreams = pgTable(
|
export const csPriceStreams = csSchema.table(
|
||||||
'cs_price_streams',
|
'price_streams',
|
||||||
{
|
{
|
||||||
source: text('source').notNull(),
|
source: text('source').notNull(),
|
||||||
currency: text('currency').notNull(),
|
currency: text('currency').notNull(),
|
||||||
kind: text('kind').notNull()
|
kind: text('kind').notNull()
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
uniqueIndex('cs_price_streams_unique_idx').on(table.source, table.currency, table.kind)
|
primaryKey({ name: 'price_streams_pkey', columns: [table.source, table.currency, table.kind] })
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { boolean, index, integer, jsonb, pgTable, text, unique } from 'drizzle-orm/pg-core';
|
import { boolean, index, integer, jsonb, text, unique } from 'drizzle-orm/pg-core';
|
||||||
|
import { gamesSchema, refSchema } from './schemas';
|
||||||
import type { MasteryRules } from '../../../game-tracker/mastery';
|
import type { MasteryRules } from '../../../game-tracker/mastery';
|
||||||
import { user } from './auth';
|
import { user } from './auth';
|
||||||
import { id, timestamps } from './helpers';
|
import { id, timestamps } from './helpers';
|
||||||
|
|
||||||
// Checklist-style games (Call of Duty and future franchises). Pokémon keeps its
|
// Checklist-style games (Call of Duty and future franchises). Pokémon keeps its
|
||||||
// own species × game model in catch-em-all.ts.
|
// own species × game model in catch-em-all.ts.
|
||||||
export const trackerGames = pgTable('tracker_games', {
|
export const trackerGames = refSchema.table('tracker_games', {
|
||||||
id: id(),
|
id: id(),
|
||||||
slug: text('slug').notNull().unique(),
|
slug: text('slug').notNull().unique(),
|
||||||
franchise: text('franchise').notNull(),
|
franchise: text('franchise').notNull(),
|
||||||
@@ -17,7 +18,7 @@ export const trackerGames = pgTable('tracker_games', {
|
|||||||
...timestamps
|
...timestamps
|
||||||
});
|
});
|
||||||
|
|
||||||
export const trackerItems = pgTable(
|
export const trackerItems = refSchema.table(
|
||||||
'tracker_items',
|
'tracker_items',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
@@ -41,7 +42,7 @@ export const trackerItems = pgTable(
|
|||||||
|
|
||||||
// One row per completed milestone: `done` for achievements, or a weapon
|
// One row per completed milestone: `done` for achievements, or a weapon
|
||||||
// milestone key such as `gold` or `maxLevel`. Derived tiers are never stored.
|
// milestone key such as `gold` or `maxLevel`. Derived tiers are never stored.
|
||||||
export const trackerProgress = pgTable(
|
export const trackerProgress = gamesSchema.table(
|
||||||
'tracker_progress',
|
'tracker_progress',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
|
|||||||
@@ -6,16 +6,16 @@ import {
|
|||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
jsonb,
|
jsonb,
|
||||||
pgTable,
|
primaryKey,
|
||||||
text,
|
text,
|
||||||
uniqueIndex
|
uniqueIndex
|
||||||
} from 'drizzle-orm/pg-core';
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { houseSchema } from './schemas';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { id, timestamptz } from './helpers';
|
import { id, timestamptz } from './helpers';
|
||||||
|
|
||||||
// Created by scripts/house-tracker-migration.sql; keep both in sync.
|
export const houseListings = houseSchema.table(
|
||||||
export const houseListings = pgTable(
|
'listings',
|
||||||
'house_listings',
|
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
source: text('source').notNull(),
|
source: text('source').notNull(),
|
||||||
@@ -58,21 +58,21 @@ export const houseListings = pgTable(
|
|||||||
notes: text('notes')
|
notes: text('notes')
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
check('house_listings_offer_check', sql`offer IN ('rent', 'buy')`),
|
check('listings_offer_check', sql`offer IN ('rent', 'buy')`),
|
||||||
check('house_listings_price_eur_check', sql`price_eur IS NULL OR price_eur >= 0`),
|
check('listings_price_eur_check', sql`price_eur IS NULL OR price_eur >= 0`),
|
||||||
uniqueIndex('house_listings_source_idx').on(table.source, table.sourceId),
|
uniqueIndex('listings_source_idx').on(table.source, table.sourceId),
|
||||||
index('house_listings_active_idx')
|
index('listings_active_idx')
|
||||||
.on(table.offer, table.firstSeenAt.desc())
|
.on(table.offer, table.firstSeenAt.desc().nullsFirst())
|
||||||
.where(sql`removed_at IS NULL`),
|
.where(sql`removed_at IS NULL`),
|
||||||
index('house_listings_city_idx').on(table.source, table.offer, table.citySlug),
|
index('listings_city_idx').on(table.source, table.offer, table.citySlug),
|
||||||
index('house_listings_details_idx')
|
index('listings_details_idx')
|
||||||
.on(table.firstSeenAt.desc())
|
.on(table.firstSeenAt.desc().nullsFirst())
|
||||||
.where(sql`detail_fetched_at IS NULL`)
|
.where(sql`detail_fetched_at IS NULL`)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const housePriceHistory = pgTable(
|
export const housePriceHistory = houseSchema.table(
|
||||||
'house_price_history',
|
'price_history',
|
||||||
{
|
{
|
||||||
listingId: integer('listing_id')
|
listingId: integer('listing_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -80,10 +80,12 @@ export const housePriceHistory = pgTable(
|
|||||||
observedAt: timestamptz('observed_at').notNull().defaultNow(),
|
observedAt: timestamptz('observed_at').notNull().defaultNow(),
|
||||||
priceEur: integer('price_eur').notNull()
|
priceEur: integer('price_eur').notNull()
|
||||||
},
|
},
|
||||||
(table) => [index('house_price_history_listing_idx').on(table.listingId, table.observedAt)]
|
(table) => [
|
||||||
|
primaryKey({ name: 'price_history_pkey', columns: [table.listingId, table.observedAt] })
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const houseSearches = pgTable('house_searches', {
|
export const houseSearches = houseSchema.table('searches', {
|
||||||
searchUrl: text('search_url').primaryKey(),
|
searchUrl: text('search_url').primaryKey(),
|
||||||
source: text('source').notNull(),
|
source: text('source').notNull(),
|
||||||
lane: text('lane').notNull(),
|
lane: text('lane').notNull(),
|
||||||
@@ -92,8 +94,8 @@ export const houseSearches = pgTable('house_searches', {
|
|||||||
lastFound: integer('last_found').notNull().default(0)
|
lastFound: integer('last_found').notNull().default(0)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const houseWorkerRuns = pgTable(
|
export const houseWorkerRuns = houseSchema.table(
|
||||||
'house_worker_runs',
|
'worker_runs',
|
||||||
{
|
{
|
||||||
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
|
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
|
||||||
source: text('source').notNull(),
|
source: text('source').notNull(),
|
||||||
@@ -106,11 +108,11 @@ export const houseWorkerRuns = pgTable(
|
|||||||
message: text('message'),
|
message: text('message'),
|
||||||
createdAt: timestamptz('created_at').notNull().defaultNow()
|
createdAt: timestamptz('created_at').notNull().defaultNow()
|
||||||
},
|
},
|
||||||
(table) => [index('house_worker_runs_created_idx').on(table.createdAt.desc())]
|
(table) => [index('worker_runs_created_idx').on(table.createdAt.desc().nullsFirst())]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const houseAlertRules = pgTable(
|
export const houseAlertRules = houseSchema.table(
|
||||||
'house_alert_rules',
|
'alert_rules',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
@@ -122,7 +124,11 @@ export const houseAlertRules = pgTable(
|
|||||||
minBedrooms: integer('min_bedrooms'),
|
minBedrooms: integer('min_bedrooms'),
|
||||||
cities: text('cities').array().notNull().default([]),
|
cities: text('cities').array().notNull().default([]),
|
||||||
propertyTypes: text('property_types').array().notNull().default([]),
|
propertyTypes: text('property_types').array().notNull().default([]),
|
||||||
createdAt: timestamptz('created_at').notNull().defaultNow()
|
createdAt: timestamptz('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamptz('updated_at')
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
},
|
},
|
||||||
() => [check('house_alert_rules_offer_check', sql`offer IN ('rent', 'buy')`)]
|
() => [check('alert_rules_offer_check', sql`offer IN ('rent', 'buy')`)]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ export * from './catch-em-all';
|
|||||||
export * from './game-tracker';
|
export * from './game-tracker';
|
||||||
export * from './tracker-journal';
|
export * from './tracker-journal';
|
||||||
export * from './animaldex';
|
export * from './animaldex';
|
||||||
export * from './lists';
|
export * from './lists'; // meta registry; the lists themselves are runtime tables
|
||||||
|
export * from './schemas';
|
||||||
export * from './money-tracker';
|
export * from './money-tracker';
|
||||||
export * from './cs-prices';
|
export * from './cs-prices';
|
||||||
export * from './house-tracker';
|
export * from './house-tracker';
|
||||||
|
export * from './books';
|
||||||
|
|||||||
@@ -1,47 +1,92 @@
|
|||||||
import { boolean, index, integer, pgTable, text, unique } from 'drizzle-orm/pg-core';
|
import {
|
||||||
import { id, timestamps } from './helpers';
|
boolean,
|
||||||
import { user } from './auth';
|
check,
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
unique
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { metaSchema } from './schemas';
|
||||||
|
|
||||||
export const lists = pgTable('lists', {
|
// Registry of GUI-visible tables. Runtime lists live in schema `lists` (one real
|
||||||
id: id(),
|
// table per list, created through meta.create_list()); that schema is excluded
|
||||||
name: text('name').notNull(),
|
// from drizzle-kit via schemaFilter. Structure truth is pg_catalog; these rows are
|
||||||
slug: text('slug').notNull().unique(),
|
// presentation. See src/lib/server/lists.ts for the list engine.
|
||||||
pictureUrl: text('picture_url'),
|
const ts = (name: string) => timestamp(name, { withTimezone: true });
|
||||||
isProgress: boolean('is_progress').notNull().default(false),
|
|
||||||
...timestamps
|
|
||||||
});
|
|
||||||
|
|
||||||
export const listItems = pgTable(
|
export const metaTables = metaSchema.table(
|
||||||
'list_items',
|
'tables',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: integer('id').primaryKey().generatedByDefaultAsIdentity(),
|
||||||
listId: integer('list_id')
|
schemaName: text('schema_name').notNull(),
|
||||||
.notNull()
|
tableName: text('table_name').notNull(),
|
||||||
.references(() => lists.id, { onDelete: 'cascade' }),
|
slug: text('slug').notNull().unique('tables_slug_key'),
|
||||||
name: text('name').notNull(),
|
displayName: text('display_name').notNull(),
|
||||||
pictureUrl: text('picture_url'),
|
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
...timestamps
|
icon: text('icon'),
|
||||||
|
pictureUrl: text('picture_url'),
|
||||||
|
kind: text('kind', { enum: ['list', 'core', 'ref'] }).notNull(),
|
||||||
|
editable: boolean('editable').notNull().default(true),
|
||||||
|
hasDone: boolean('has_done').notNull().default(false),
|
||||||
|
defaultSort: text('default_sort'),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
|
archivedAt: ts('archived_at'),
|
||||||
|
createdAt: ts('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: ts('updated_at').notNull().defaultNow()
|
||||||
},
|
},
|
||||||
(table) => [index().on(table.listId)]
|
(t) => [
|
||||||
|
unique('tables_schema_name_table_name_key').on(t.schemaName, t.tableName),
|
||||||
|
check('tables_slug_check', sql`slug ~ '^[a-z0-9][a-z0-9-]{0,62}$'`),
|
||||||
|
check('tables_kind_check', sql`kind IN ('list','core','ref')`)
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const listItemCompletions = pgTable(
|
export const uiTypes = [
|
||||||
'list_item_completions',
|
'text',
|
||||||
|
'longtext',
|
||||||
|
'url',
|
||||||
|
'image',
|
||||||
|
'number',
|
||||||
|
'cents',
|
||||||
|
'date',
|
||||||
|
'datetime',
|
||||||
|
'bool',
|
||||||
|
'select',
|
||||||
|
'multiselect',
|
||||||
|
'fk',
|
||||||
|
'json'
|
||||||
|
] as const;
|
||||||
|
export type UiType = (typeof uiTypes)[number];
|
||||||
|
|
||||||
|
export const metaColumns = metaSchema.table(
|
||||||
|
'columns',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: integer('id').primaryKey().generatedByDefaultAsIdentity(),
|
||||||
itemId: integer('item_id')
|
tableId: integer('table_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => listItems.id, { onDelete: 'cascade' }),
|
.references(() => metaTables.id, { onDelete: 'cascade' }),
|
||||||
userId: text('user_id')
|
columnName: text('column_name').notNull(),
|
||||||
.notNull()
|
label: text('label'),
|
||||||
.references(() => user.id, { onDelete: 'cascade' }),
|
uiType: text('ui_type', { enum: uiTypes }).notNull(),
|
||||||
...timestamps
|
options: jsonb('options').$type<{ values?: string[] }>().notNull().default({}),
|
||||||
|
position: integer('position').notNull().default(0),
|
||||||
|
hidden: boolean('hidden').notNull().default(false),
|
||||||
|
helpText: text('help_text'),
|
||||||
|
createdAt: ts('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: ts('updated_at').notNull().defaultNow()
|
||||||
},
|
},
|
||||||
(table) => [unique().on(table.itemId, table.userId), index().on(table.userId)]
|
(t) => [
|
||||||
|
unique('columns_table_id_column_name_key').on(t.tableId, t.columnName),
|
||||||
|
index('columns_table_id_index').on(t.tableId),
|
||||||
|
check(
|
||||||
|
'columns_ui_type_check',
|
||||||
|
sql`ui_type IN ('text','longtext','url','image','number','cents','date','datetime','bool','select','multiselect','fk','json')`
|
||||||
|
)
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export type List = typeof lists.$inferSelect;
|
export type MetaTable = typeof metaTables.$inferSelect;
|
||||||
export type NewList = typeof lists.$inferInsert;
|
export type MetaColumn = typeof metaColumns.$inferSelect;
|
||||||
export type ListItem = typeof listItems.$inferSelect;
|
|
||||||
export type NewListItem = typeof listItems.$inferInsert;
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { date, index, integer, numeric, pgTable, text, unique } from 'drizzle-orm/pg-core';
|
import { boolean, check, date, index, integer, numeric, text, unique } from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { moneySchema } from './schemas';
|
||||||
import { id, timestamps } from './helpers';
|
import { id, timestamps } from './helpers';
|
||||||
import { user } from './auth';
|
import { user } from './auth';
|
||||||
|
|
||||||
export const moneyAccounts = pgTable(
|
export const moneyAccounts = moneySchema.table(
|
||||||
'money_accounts',
|
'accounts',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
userId: text('user_id')
|
userId: text('user_id')
|
||||||
@@ -15,11 +17,15 @@ export const moneyAccounts = pgTable(
|
|||||||
color: text('color'),
|
color: text('color'),
|
||||||
...timestamps
|
...timestamps
|
||||||
},
|
},
|
||||||
(table) => [unique().on(table.userId, table.name), index().on(table.userId)]
|
(table) => [
|
||||||
|
unique().on(table.userId, table.name),
|
||||||
|
index().on(table.userId),
|
||||||
|
check('accounts_kind_check', sql`kind IN ('spendable','investment')`)
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const moneyTransactions = pgTable(
|
export const moneyTransactions = moneySchema.table(
|
||||||
'money_transactions',
|
'transactions',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
userId: text('user_id')
|
userId: text('user_id')
|
||||||
@@ -36,8 +42,8 @@ export const moneyTransactions = pgTable(
|
|||||||
(table) => [index().on(table.userId, table.date, table.id), index().on(table.accountId)]
|
(table) => [index().on(table.userId, table.date, table.id), index().on(table.accountId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const moneyRecurring = pgTable(
|
export const moneyRecurring = moneySchema.table(
|
||||||
'money_recurring',
|
'recurring',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
userId: text('user_id')
|
userId: text('user_id')
|
||||||
@@ -51,11 +57,14 @@ export const moneyRecurring = pgTable(
|
|||||||
accountId: integer('account_id').references(() => moneyAccounts.id, { onDelete: 'set null' }),
|
accountId: integer('account_id').references(() => moneyAccounts.id, { onDelete: 'set null' }),
|
||||||
...timestamps
|
...timestamps
|
||||||
},
|
},
|
||||||
(table) => [index().on(table.userId)]
|
(table) => [
|
||||||
|
index().on(table.userId),
|
||||||
|
check('recurring_cadence_check', sql`cadence IN ('weekly','monthly','yearly')`)
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const moneyForecasts = pgTable(
|
export const moneyForecasts = moneySchema.table(
|
||||||
'money_forecasts',
|
'forecasts',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
userId: text('user_id')
|
userId: text('user_id')
|
||||||
@@ -70,8 +79,8 @@ export const moneyForecasts = pgTable(
|
|||||||
(table) => [index().on(table.userId, table.date)]
|
(table) => [index().on(table.userId, table.date)]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const moneyUtilitySettings = pgTable(
|
export const moneyUtilitySettings = moneySchema.table(
|
||||||
'money_utility_settings',
|
'utility_settings',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
userId: text('user_id')
|
userId: text('user_id')
|
||||||
@@ -83,11 +92,14 @@ export const moneyUtilitySettings = pgTable(
|
|||||||
billingStart: date('billing_start', { mode: 'string' }),
|
billingStart: date('billing_start', { mode: 'string' }),
|
||||||
...timestamps
|
...timestamps
|
||||||
},
|
},
|
||||||
(table) => [unique().on(table.userId, table.kind)]
|
(table) => [
|
||||||
|
unique().on(table.userId, table.kind),
|
||||||
|
check('utility_settings_kind_check', sql`kind IN ('electricity','gas','water')`)
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const moneyUtilityReadings = pgTable(
|
export const moneyUtilityReadings = moneySchema.table(
|
||||||
'money_utility_readings',
|
'utility_readings',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
userId: text('user_id')
|
userId: text('user_id')
|
||||||
@@ -98,11 +110,14 @@ export const moneyUtilityReadings = pgTable(
|
|||||||
reading: numeric('reading', { precision: 12, scale: 3 }).notNull(),
|
reading: numeric('reading', { precision: 12, scale: 3 }).notNull(),
|
||||||
...timestamps
|
...timestamps
|
||||||
},
|
},
|
||||||
(table) => [index().on(table.userId, table.kind, table.date)]
|
(table) => [
|
||||||
|
index().on(table.userId, table.kind, table.date),
|
||||||
|
check('utility_readings_kind_check', sql`kind IN ('electricity','gas','water')`)
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const moneyWishlist = pgTable(
|
export const moneyWishlist = moneySchema.table(
|
||||||
'money_wishlist',
|
'wishlist',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
userId: text('user_id')
|
userId: text('user_id')
|
||||||
@@ -117,8 +132,8 @@ export const moneyWishlist = pgTable(
|
|||||||
(table) => [index().on(table.userId)]
|
(table) => [index().on(table.userId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const moneyProjectionSettings = pgTable(
|
export const moneyProjectionSettings = moneySchema.table(
|
||||||
'money_projection_settings',
|
'projection_settings',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
userId: text('user_id')
|
userId: text('user_id')
|
||||||
@@ -132,16 +147,22 @@ export const moneyProjectionSettings = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default('5.00'),
|
.default('5.00'),
|
||||||
contributionMode: text('contribution_mode').notNull().default('excess'),
|
contributionMode: text('contribution_mode').notNull().default('excess'),
|
||||||
includeRecurring: integer('include_recurring').notNull().default(1),
|
includeRecurring: boolean('include_recurring').notNull().default(true),
|
||||||
fixedMonthlyCents: integer('fixed_monthly_cents').notNull().default(0),
|
fixedMonthlyCents: integer('fixed_monthly_cents').notNull().default(0),
|
||||||
endDate: date('end_date', { mode: 'string' }),
|
endDate: date('end_date', { mode: 'string' }),
|
||||||
...timestamps
|
...timestamps
|
||||||
},
|
},
|
||||||
(table) => [unique().on(table.userId)]
|
(table) => [
|
||||||
|
unique().on(table.userId),
|
||||||
|
check(
|
||||||
|
'projection_settings_contribution_mode_check',
|
||||||
|
sql`contribution_mode IN ('excess','fixed')`
|
||||||
|
)
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const moneyInsurances = pgTable(
|
export const moneyInsurances = moneySchema.table(
|
||||||
'money_insurances',
|
'insurances',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
userId: text('user_id')
|
userId: text('user_id')
|
||||||
@@ -154,5 +175,8 @@ export const moneyInsurances = pgTable(
|
|||||||
cadence: text('cadence').notNull().default('monthly'),
|
cadence: text('cadence').notNull().default('monthly'),
|
||||||
...timestamps
|
...timestamps
|
||||||
},
|
},
|
||||||
(table) => [index().on(table.userId)]
|
(table) => [
|
||||||
|
index().on(table.userId),
|
||||||
|
check('insurances_cadence_check', sql`cadence IN ('weekly','monthly','yearly')`)
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { integer, pgTable, text, unique } from 'drizzle-orm/pg-core';
|
import { integer, text, unique } from 'drizzle-orm/pg-core';
|
||||||
|
import { siteSchema } from './schemas';
|
||||||
import { id, timestamps } from './helpers';
|
import { id, timestamps } from './helpers';
|
||||||
|
|
||||||
export const tasks = pgTable('tasks', {
|
export const tasks = siteSchema.table('tasks', {
|
||||||
id: id(),
|
id: id(),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
currentPoints: integer('current_points').notNull().default(0),
|
currentPoints: integer('current_points').notNull().default(0),
|
||||||
@@ -10,15 +11,15 @@ export const tasks = pgTable('tasks', {
|
|||||||
...timestamps
|
...timestamps
|
||||||
});
|
});
|
||||||
|
|
||||||
export const topics = pgTable('topics', {
|
export const topics = siteSchema.table('topics', {
|
||||||
id: id(),
|
id: id(),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
emoji: text('emoji'),
|
emoji: text('emoji'),
|
||||||
...timestamps
|
...timestamps
|
||||||
});
|
});
|
||||||
|
|
||||||
export const tasksToTopics = pgTable(
|
export const tasksToTopics = siteSchema.table(
|
||||||
'tasks_to_topics',
|
'task_topics',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
taskId: integer('task_id')
|
taskId: integer('task_id')
|
||||||
|
|||||||
15
src/lib/server/db/schema/schemas.ts
Normal file
15
src/lib/server/db/schema/schemas.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { pgSchema } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
// One Postgres schema per domain (see ~/dev/setup/db/PLAN.md). `lists`, `legacy`,
|
||||||
|
// `energy` and `hardware` are deliberately not modelled here: lists are created at
|
||||||
|
// runtime through meta.create_list(), and energy/hardware belong to other tools.
|
||||||
|
export const authSchema = pgSchema('auth');
|
||||||
|
export const moneySchema = pgSchema('money');
|
||||||
|
export const houseSchema = pgSchema('house');
|
||||||
|
export const refSchema = pgSchema('ref');
|
||||||
|
export const gamesSchema = pgSchema('games');
|
||||||
|
export const csSchema = pgSchema('cs');
|
||||||
|
export const animaldexSchema = pgSchema('animaldex');
|
||||||
|
export const siteSchema = pgSchema('site');
|
||||||
|
export const metaSchema = pgSchema('meta');
|
||||||
|
export const booksSchema = pgSchema('books');
|
||||||
@@ -2,20 +2,21 @@ import {
|
|||||||
boolean,
|
boolean,
|
||||||
check,
|
check,
|
||||||
date,
|
date,
|
||||||
|
numeric,
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
jsonb,
|
jsonb,
|
||||||
pgTable,
|
|
||||||
text,
|
text,
|
||||||
unique
|
unique
|
||||||
} from 'drizzle-orm/pg-core';
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { gamesSchema, refSchema } from './schemas';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { user } from './auth';
|
import { user } from './auth';
|
||||||
import { games, pokemon } from './catch-em-all';
|
import { games, pokemon } from './catch-em-all';
|
||||||
import { trackerGames, trackerItems } from './game-tracker';
|
import { trackerGames, trackerItems } from './game-tracker';
|
||||||
import { id, timestamps } from './helpers';
|
import { id, timestamps } from './helpers';
|
||||||
|
|
||||||
export const pokemonResearch = pgTable('pokemon_research', {
|
export const pokemonResearch = refSchema.table('pokemon_research', {
|
||||||
pokemonId: integer('pokemon_id')
|
pokemonId: integer('pokemon_id')
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
||||||
@@ -36,7 +37,7 @@ export const pokemonResearch = pgTable('pokemon_research', {
|
|||||||
.notNull()
|
.notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const pokemonDexNumbers = pgTable(
|
export const pokemonDexNumbers = refSchema.table(
|
||||||
'pokemon_dex_numbers',
|
'pokemon_dex_numbers',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
@@ -53,7 +54,7 @@ export const pokemonDexNumbers = pgTable(
|
|||||||
(t) => [unique().on(t.pokemonId, t.gameId, t.dex), index().on(t.gameId)]
|
(t) => [unique().on(t.pokemonId, t.gameId, t.dex), index().on(t.gameId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const pokemonMoves = pgTable('pokemon_moves', {
|
export const pokemonMoves = refSchema.table('pokemon_moves', {
|
||||||
id: integer('id').primaryKey(),
|
id: integer('id').primaryKey(),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
type: text('type').notNull(),
|
type: text('type').notNull(),
|
||||||
@@ -65,7 +66,7 @@ export const pokemonMoves = pgTable('pokemon_moves', {
|
|||||||
target: text('target').notNull()
|
target: text('target').notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const pokemonLearnsets = pgTable(
|
export const pokemonLearnsets = refSchema.table(
|
||||||
'pokemon_learnsets',
|
'pokemon_learnsets',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
@@ -78,7 +79,7 @@ export const pokemonLearnsets = pgTable(
|
|||||||
(t) => [unique().on(t.pokemonId, t.versionGroup)]
|
(t) => [unique().on(t.pokemonId, t.versionGroup)]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const pokemonGameResearch = pgTable('pokemon_game_research', {
|
export const pokemonGameResearch = refSchema.table('pokemon_game_research', {
|
||||||
gameId: integer('game_id')
|
gameId: integer('game_id')
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.references(() => games.id, { onDelete: 'cascade' }),
|
.references(() => games.id, { onDelete: 'cascade' }),
|
||||||
@@ -86,7 +87,7 @@ export const pokemonGameResearch = pgTable('pokemon_game_research', {
|
|||||||
regions: text('regions').array().notNull()
|
regions: text('regions').array().notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const pokemonJourneys = pgTable(
|
export const pokemonJourneys = gamesSchema.table(
|
||||||
'pokemon_journeys',
|
'pokemon_journeys',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
@@ -103,10 +104,13 @@ export const pokemonJourneys = pgTable(
|
|||||||
completed: boolean('completed').notNull().default(false),
|
completed: boolean('completed').notNull().default(false),
|
||||||
...timestamps
|
...timestamps
|
||||||
},
|
},
|
||||||
(t) => [unique().on(t.userId, t.gameId)]
|
(t) => [
|
||||||
|
unique().on(t.userId, t.gameId),
|
||||||
|
check('pokemon_journeys_stage_check', sql`${t.stage} IN ('story','postgame','collection')`)
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const pokemonSeen = pgTable(
|
export const pokemonSeen = gamesSchema.table(
|
||||||
'pokemon_seen',
|
'pokemon_seen',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
@@ -124,7 +128,7 @@ export const pokemonSeen = pgTable(
|
|||||||
(t) => [unique().on(t.userId, t.gameId, t.pokemonId), index().on(t.userId)]
|
(t) => [unique().on(t.userId, t.gameId, t.pokemonId), index().on(t.userId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const pokemonCatches = pgTable(
|
export const pokemonCatches = gamesSchema.table(
|
||||||
'pokemon_catches',
|
'pokemon_catches',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
@@ -157,13 +161,14 @@ export const pokemonCatches = pgTable(
|
|||||||
(t) => [
|
(t) => [
|
||||||
index().on(t.userId, t.gameId, t.pokemonId),
|
index().on(t.userId, t.gameId, t.pokemonId),
|
||||||
unique().on(t.userId, t.gameId, t.partySlot),
|
unique().on(t.userId, t.gameId, t.partySlot),
|
||||||
check('pokemon_catch_level', sql`${t.level} between 1 and 100`),
|
check('pokemon_catches_level_check', sql`${t.level} between 1 and 100`),
|
||||||
check('pokemon_party_slot', sql`${t.partySlot} between 1 and 6`),
|
check('pokemon_catches_party_slot_check', sql`${t.partySlot} between 1 and 6`),
|
||||||
check('pokemon_four_moves', sql`cardinality(${t.moves}) <= 4`)
|
check('pokemon_catches_moves_check', sql`cardinality(${t.moves}) <= 4`),
|
||||||
|
check('pokemon_catches_stage_check', sql`${t.stage} IN ('story','postgame','collection')`)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const pokemonMilestones = pgTable(
|
export const pokemonMilestones = gamesSchema.table(
|
||||||
'pokemon_milestones',
|
'pokemon_milestones',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
@@ -184,7 +189,7 @@ export const pokemonMilestones = pgTable(
|
|||||||
(t) => [unique().on(t.userId, t.gameId, t.key)]
|
(t) => [unique().on(t.userId, t.gameId, t.key)]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const trackerRanks = pgTable(
|
export const trackerRanks = gamesSchema.table(
|
||||||
'tracker_ranks',
|
'tracker_ranks',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
@@ -200,14 +205,12 @@ export const trackerRanks = pgTable(
|
|||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
unique().on(t.userId, t.gameId),
|
unique().on(t.userId, t.gameId),
|
||||||
check(
|
check('tracker_ranks_level_check', sql`${t.level} between 1 and 10000`),
|
||||||
'tracker_rank_range',
|
check('tracker_ranks_prestige_check', sql`${t.prestige} between 0 and 1000`)
|
||||||
sql`${t.level} between 1 and 10000 and ${t.prestige} between 0 and 1000`
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const trackerWeaponRanks = pgTable(
|
export const trackerWeaponRanks = gamesSchema.table(
|
||||||
'tracker_weapon_ranks',
|
'tracker_weapon_ranks',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
@@ -223,14 +226,12 @@ export const trackerWeaponRanks = pgTable(
|
|||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
unique().on(t.userId, t.itemId),
|
unique().on(t.userId, t.itemId),
|
||||||
check(
|
check('tracker_weapon_ranks_level_check', sql`${t.level} between 1 and 10000`),
|
||||||
'tracker_weapon_rank_range',
|
check('tracker_weapon_ranks_prestige_check', sql`${t.prestige} between 0 and 1000`)
|
||||||
sql`${t.level} between 1 and 10000 and ${t.prestige} between 0 and 1000`
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const trackerChallenges = pgTable(
|
export const trackerChallenges = refSchema.table(
|
||||||
'tracker_challenges',
|
'tracker_challenges',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
@@ -249,7 +250,7 @@ export const trackerChallenges = pgTable(
|
|||||||
(t) => [unique().on(t.gameId, t.key)]
|
(t) => [unique().on(t.gameId, t.key)]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const trackerChallengeProgress = pgTable(
|
export const trackerChallengeProgress = gamesSchema.table(
|
||||||
'tracker_challenge_progress',
|
'tracker_challenge_progress',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
@@ -263,10 +264,13 @@ export const trackerChallengeProgress = pgTable(
|
|||||||
done: boolean('done').notNull().default(false),
|
done: boolean('done').notNull().default(false),
|
||||||
...timestamps
|
...timestamps
|
||||||
},
|
},
|
||||||
(t) => [unique().on(t.userId, t.challengeId)]
|
(t) => [
|
||||||
|
unique().on(t.userId, t.challengeId),
|
||||||
|
check('tracker_challenge_progress_current_check', sql`${t.current} >= 0`)
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const osuEntries = pgTable(
|
export const osuEntries = gamesSchema.table(
|
||||||
'osu_entries',
|
'osu_entries',
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
@@ -282,18 +286,23 @@ export const osuEntries = pgTable(
|
|||||||
skill: text('skill').notNull().default('stamina'),
|
skill: text('skill').notNull().default('stamina'),
|
||||||
bpm: integer('bpm'),
|
bpm: integer('bpm'),
|
||||||
duration: integer('duration'),
|
duration: integer('duration'),
|
||||||
accuracy: text('accuracy'),
|
accuracy: numeric('accuracy', { mode: 'number' }),
|
||||||
combo: integer('combo'),
|
combo: integer('combo'),
|
||||||
misses: integer('misses'),
|
misses: integer('misses'),
|
||||||
pp: text('pp'),
|
pp: numeric('pp', { mode: 'number' }),
|
||||||
mods: text('mods').notNull().default('NM'),
|
mods: text('mods').notNull().default('NM'),
|
||||||
rate: text('rate').notNull().default('1.00'),
|
rate: numeric('rate', { precision: 4, scale: 2, mode: 'number' })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`1.00`),
|
||||||
achieved: boolean('achieved').notNull().default(false),
|
achieved: boolean('achieved').notNull().default(false),
|
||||||
playedOn: date('played_on').notNull(),
|
playedOn: date('played_on').notNull(),
|
||||||
notes: text('notes').notNull().default(''),
|
notes: text('notes').notNull().default(''),
|
||||||
...timestamps
|
...timestamps
|
||||||
},
|
},
|
||||||
(t) => [index().on(t.userId, t.kind)]
|
(t) => [
|
||||||
|
index().on(t.userId, t.kind),
|
||||||
|
check('osu_entries_kind_check', sql`${t.kind} IN ('score','goal','training')`)
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export type PokemonCatch = typeof pokemonCatches.$inferSelect;
|
export type PokemonCatch = typeof pokemonCatches.$inferSelect;
|
||||||
|
|||||||
296
src/lib/server/lists.ts
Normal file
296
src/lib/server/lists.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
// Generic list engine. Every list is a real table in schema `lists`, registered
|
||||||
|
// in meta.tables / meta.columns. Table and column names only ever come from the
|
||||||
|
// registry (written by the SECURITY DEFINER meta.* functions, which validate
|
||||||
|
// identifiers) and are re-checked here before being used as identifiers.
|
||||||
|
import { and, asc, desc, eq, isNull, max, notInArray, sql } from 'drizzle-orm';
|
||||||
|
import { db } from '#lib/server/db/index.js';
|
||||||
|
import {
|
||||||
|
metaColumns,
|
||||||
|
metaTables,
|
||||||
|
type MetaTable,
|
||||||
|
type UiType
|
||||||
|
} from '#lib/server/db/schema/index.js';
|
||||||
|
|
||||||
|
const IDENT = /^[a-z][a-z0-9_]{0,62}$/;
|
||||||
|
/** Columns every list has; everything else is a list-specific extra column. */
|
||||||
|
const STANDARD = [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'notes',
|
||||||
|
'picture_url',
|
||||||
|
'done_at',
|
||||||
|
'sort_order',
|
||||||
|
'created_at',
|
||||||
|
'updated_at'
|
||||||
|
];
|
||||||
|
|
||||||
|
export type ExtraValue = string | number | boolean | null;
|
||||||
|
export type ListColumn = { name: string; label: string; uiType: UiType; options: string[] };
|
||||||
|
export type ListItem = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
pictureUrl: string | null;
|
||||||
|
doneAt: Date | null;
|
||||||
|
extras: Record<string, ExtraValue>;
|
||||||
|
};
|
||||||
|
export type ListSummary = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
pictureUrl: string | null;
|
||||||
|
isProgress: boolean;
|
||||||
|
total: number;
|
||||||
|
checked: number;
|
||||||
|
};
|
||||||
|
export type ListView = Omit<ListSummary, 'total' | 'checked'> & {
|
||||||
|
columns: ListColumn[];
|
||||||
|
items: ListItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function table(t: Pick<MetaTable, 'schemaName' | 'tableName'>) {
|
||||||
|
if (t.schemaName !== 'lists' || !IDENT.test(t.tableName))
|
||||||
|
throw new Error(`refusing unexpected list table ${t.schemaName}.${t.tableName}`);
|
||||||
|
return sql`${sql.identifier('lists')}.${sql.identifier(t.tableName)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeList = and(eq(metaTables.kind, 'list'), isNull(metaTables.archivedAt));
|
||||||
|
|
||||||
|
export async function findList(slug: string) {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(metaTables)
|
||||||
|
.where(and(activeList, eq(metaTables.slug, slug)));
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSummaries(): Promise<ListSummary[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(metaTables)
|
||||||
|
.where(activeList)
|
||||||
|
.orderBy(desc(metaTables.sortOrder), desc(metaTables.id));
|
||||||
|
if (!rows.length) return [];
|
||||||
|
const counts = await db.execute<{ id: number; total: number; checked: number }>(
|
||||||
|
sql.join(
|
||||||
|
rows.map(
|
||||||
|
(t) =>
|
||||||
|
sql`SELECT ${t.id}::int AS id, count(*)::int AS total, ${
|
||||||
|
t.hasDone ? sql`count(done_at)::int` : sql`0`
|
||||||
|
} AS checked FROM ${table(t)}`
|
||||||
|
),
|
||||||
|
sql` UNION ALL `
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const byId = new Map([...counts].map((c) => [Number(c.id), c]));
|
||||||
|
return rows.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.displayName,
|
||||||
|
slug: t.slug,
|
||||||
|
pictureUrl: t.pictureUrl,
|
||||||
|
isProgress: t.hasDone,
|
||||||
|
total: byId.get(t.id)?.total ?? 0,
|
||||||
|
checked: byId.get(t.id)?.checked ?? 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listColumns(t: MetaTable): Promise<ListColumn[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(metaColumns)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(metaColumns.tableId, t.id),
|
||||||
|
eq(metaColumns.hidden, false),
|
||||||
|
notInArray(metaColumns.columnName, STANDARD)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(metaColumns.position));
|
||||||
|
return rows
|
||||||
|
.filter((c) => IDENT.test(c.columnName))
|
||||||
|
.map((c) => ({
|
||||||
|
name: c.columnName,
|
||||||
|
label: c.label ?? c.columnName.replace(/_/g, ' '),
|
||||||
|
uiType: c.uiType,
|
||||||
|
options: c.options.values ?? []
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
type Row = Record<string, unknown> & { id: number; name: string };
|
||||||
|
function toItem(row: Row, columns: ListColumn[]): ListItem {
|
||||||
|
const extras: Record<string, ExtraValue> = {};
|
||||||
|
for (const c of columns) {
|
||||||
|
const v = row[c.name];
|
||||||
|
extras[c.name] = v instanceof Date ? v.toISOString() : ((v ?? null) as ExtraValue);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
name: row.name,
|
||||||
|
description: (row.notes as string | null) ?? null,
|
||||||
|
pictureUrl: (row.picture_url as string | null) ?? null,
|
||||||
|
doneAt: row.done_at ? new Date(row.done_at as string) : null,
|
||||||
|
extras
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getList(slug: string): Promise<ListView | null> {
|
||||||
|
const t = await findList(slug);
|
||||||
|
if (!t) return null;
|
||||||
|
const columns = await listColumns(t);
|
||||||
|
const rows = await db.execute<Row>(sql`SELECT * FROM ${table(t)} ORDER BY sort_order, id`);
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
name: t.displayName,
|
||||||
|
slug: t.slug,
|
||||||
|
pictureUrl: t.pictureUrl,
|
||||||
|
isProgress: t.hasDone,
|
||||||
|
columns,
|
||||||
|
items: [...rows].map((r) => toItem(r, columns))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the `extra:<column>` form fields for this list's columns. */
|
||||||
|
export function extraFields(
|
||||||
|
form: FormData,
|
||||||
|
columns: ListColumn[]
|
||||||
|
): { values: Record<string, ExtraValue> } | { error: string } {
|
||||||
|
const values: Record<string, ExtraValue> = {};
|
||||||
|
for (const c of columns) {
|
||||||
|
const key = `extra:${c.name}`;
|
||||||
|
if (c.uiType === 'bool') {
|
||||||
|
values[c.name] = form.get(key) === 'on';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!form.has(key)) continue;
|
||||||
|
const raw = String(form.get(key) ?? '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
values[c.name] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (raw.length > 2000) return { error: `${c.label} is too long.` };
|
||||||
|
switch (c.uiType) {
|
||||||
|
case 'number':
|
||||||
|
case 'cents': {
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n) || (c.uiType === 'cents' && !Number.isInteger(n)))
|
||||||
|
return { error: `${c.label} must be a number.` };
|
||||||
|
values[c.name] = n;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'select':
|
||||||
|
if (c.options.length && !c.options.includes(raw))
|
||||||
|
return { error: `${c.label} must be one of: ${c.options.join(', ')}.` };
|
||||||
|
values[c.name] = raw;
|
||||||
|
break;
|
||||||
|
case 'url':
|
||||||
|
case 'image':
|
||||||
|
if (!/^https?:\/\/\S+$/.test(raw)) return { error: `${c.label} must be an http(s) URL.` };
|
||||||
|
values[c.name] = raw;
|
||||||
|
break;
|
||||||
|
case 'date':
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return { error: `${c.label} must be a date.` };
|
||||||
|
values[c.name] = raw;
|
||||||
|
break;
|
||||||
|
case 'text':
|
||||||
|
case 'longtext':
|
||||||
|
values[c.name] = raw;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// json / multiselect / fk are not editable from this form yet
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { values };
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnsAndValues(values: Record<string, ExtraValue>) {
|
||||||
|
const entries = Object.entries(values).filter(([k]) => IDENT.test(k));
|
||||||
|
return {
|
||||||
|
cols: entries.map(([k]) => sql.identifier(k)),
|
||||||
|
vals: entries.map(([, v]) => sql`${v}`),
|
||||||
|
sets: entries.map(([k, v]) => sql`${sql.identifier(k)} = ${v}`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ItemValues = {
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
pictureUrl?: string | null;
|
||||||
|
extras: Record<string, ExtraValue>;
|
||||||
|
};
|
||||||
|
function rowValues(v: ItemValues): Record<string, ExtraValue> {
|
||||||
|
return {
|
||||||
|
...v.extras,
|
||||||
|
name: v.name,
|
||||||
|
notes: v.description,
|
||||||
|
...('pictureUrl' in v ? { picture_url: v.pictureUrl ?? null } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertItem(t: MetaTable, v: ItemValues) {
|
||||||
|
const { cols, vals } = columnsAndValues(rowValues(v));
|
||||||
|
const tbl = table(t);
|
||||||
|
await db.execute(
|
||||||
|
sql`INSERT INTO ${tbl} (${sql.join(cols, sql`, `)}, sort_order)
|
||||||
|
VALUES (${sql.join(vals, sql`, `)}, (SELECT coalesce(max(sort_order), 0) + 1 FROM ${tbl}))`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getItem(t: MetaTable, id: number) {
|
||||||
|
const [row] = await db.execute<Row>(sql`SELECT * FROM ${table(t)} WHERE id = ${id}`);
|
||||||
|
return row ? toItem(row, []) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateItem(t: MetaTable, id: number, v: ItemValues) {
|
||||||
|
const { sets } = columnsAndValues(rowValues(v));
|
||||||
|
const rows = await db.execute<{ id: number }>(
|
||||||
|
sql`UPDATE ${table(t)} SET ${sql.join(sets, sql`, `)} WHERE id = ${id} RETURNING id`
|
||||||
|
);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteItem(t: MetaTable, id: number) {
|
||||||
|
const [row] = await db.execute<{ picture_url: string | null }>(
|
||||||
|
sql`DELETE FROM ${table(t)} WHERE id = ${id} RETURNING picture_url`
|
||||||
|
);
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDone(t: MetaTable, id: number, done: boolean) {
|
||||||
|
if (!t.hasDone) throw new Error('not a progress list');
|
||||||
|
const rows = await db.execute<{ id: number }>(
|
||||||
|
sql`UPDATE ${table(t)} SET done_at = ${done ? sql`coalesce(done_at, now())` : sql`NULL`}
|
||||||
|
WHERE id = ${id} RETURNING id`
|
||||||
|
);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new runtime list (a real table) through the meta API. */
|
||||||
|
export async function createList(input: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
isProgress: boolean;
|
||||||
|
pictureUrl: string | null;
|
||||||
|
}) {
|
||||||
|
let base = input.slug.replace(/-/g, '_').replace(/^[^a-z]+/, '');
|
||||||
|
base = (base || 'list').slice(0, 55);
|
||||||
|
let tableName = base;
|
||||||
|
for (let n = 2; ; n++) {
|
||||||
|
const [taken] = await db
|
||||||
|
.select({ id: metaTables.id })
|
||||||
|
.from(metaTables)
|
||||||
|
.where(and(eq(metaTables.schemaName, 'lists'), eq(metaTables.tableName, tableName)));
|
||||||
|
if (!taken) break;
|
||||||
|
tableName = `${base}_${n}`;
|
||||||
|
}
|
||||||
|
const [{ top }] = await db.select({ top: max(metaTables.sortOrder) }).from(metaTables);
|
||||||
|
await db.execute(
|
||||||
|
sql`SELECT meta.create_list(${input.slug}, ${tableName}, ${input.name}, ${input.isProgress},
|
||||||
|
'[]'::jsonb, ${input.pictureUrl}, NULL, NULL, ${(top ?? 0) + 1})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft delete: the table stays for 7 days (meta.drop_list refuses earlier). */
|
||||||
|
export async function archiveList(slug: string) {
|
||||||
|
await db.execute(sql`SELECT meta.archive_list(${slug})`);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from '#lib/server/db/index.js';
|
import { db } from '#lib/server/db/index.js';
|
||||||
import { blogArticles, lists } from '#lib/server/db/schema/index.js';
|
import { blogArticles, metaTables } from '#lib/server/db/schema/index.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
export function slugify(title: string, fallback = 'article'): string {
|
export function slugify(title: string, fallback = 'article'): string {
|
||||||
@@ -32,7 +32,10 @@ export async function uniqueListSlug(name: string): Promise<string> {
|
|||||||
const base = slugify(name, 'list');
|
const base = slugify(name, 'list');
|
||||||
let slug = base;
|
let slug = base;
|
||||||
let suffix = 2;
|
let suffix = 2;
|
||||||
while ((await db.select({ id: lists.id }).from(lists).where(eq(lists.slug, slug))).length > 0) {
|
while (
|
||||||
|
(await db.select({ id: metaTables.id }).from(metaTables).where(eq(metaTables.slug, slug)))
|
||||||
|
.length > 0
|
||||||
|
) {
|
||||||
slug = `${base}-${suffix++}`;
|
slug = `${base}-${suffix++}`;
|
||||||
}
|
}
|
||||||
return slug;
|
return slug;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
|
|||||||
const titles: Record<string, string> = {
|
const titles: Record<string, string> = {
|
||||||
'/': 'Home',
|
'/': 'Home',
|
||||||
'/projects': 'Projects',
|
'/projects': 'Projects',
|
||||||
|
'/trackers': 'Trackers',
|
||||||
'/blog': 'Blog',
|
'/blog': 'Blog',
|
||||||
'/lists': 'Lists',
|
'/lists': 'Lists',
|
||||||
'/login': 'Sign in',
|
'/login': 'Sign in',
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { getSeo } from '#lib/seo.js';
|
import { getSeo } from '#lib/seo.js';
|
||||||
import CopyButton from '#lib/components/CopyButton.svelte';
|
import CopyButton from '#lib/components/CopyButton.svelte';
|
||||||
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
|
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
|
||||||
import { projects } from '#lib/projects.js';
|
import { projects, trackers } from '#lib/projects.js';
|
||||||
import { Button, buttonVariants } from '#lib/components/ui/button/index.js';
|
import { Button, buttonVariants } from '#lib/components/ui/button/index.js';
|
||||||
import * as Sheet from '#lib/components/ui/sheet/index.js';
|
import * as Sheet from '#lib/components/ui/sheet/index.js';
|
||||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||||
@@ -31,15 +31,24 @@
|
|||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
const seo = $derived(getSeo(page.url.pathname, page.data, data.environment));
|
const seo = $derived(getSeo(page.url.pathname, page.data, data.environment));
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
let projectsMenuOpen = $state(false);
|
// Which header dropdown is open (desktop): '/projects', '/trackers' or none.
|
||||||
|
let openMenu = $state<string | null>(null);
|
||||||
type ThemePreference = 'dark' | 'system' | 'light';
|
type ThemePreference = 'dark' | 'system' | 'light';
|
||||||
let theme = $state<ThemePreference>('dark');
|
let theme = $state<ThemePreference>('dark');
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ label: 'home', href: '/' },
|
{ label: 'home', href: '/' },
|
||||||
{ label: 'projects', href: '/projects' },
|
{ label: 'projects', href: '/projects' },
|
||||||
|
{ label: 'trackers', href: '/trackers' },
|
||||||
{ label: 'blog', href: '/blog' },
|
{ label: 'blog', href: '/blog' },
|
||||||
{ label: 'lists', href: '/lists' }
|
{ label: 'lists', href: '/lists' }
|
||||||
] as const;
|
] as const;
|
||||||
|
const menus: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; items: readonly (typeof projects | typeof trackers)[number][] }
|
||||||
|
> = {
|
||||||
|
'/projects': { label: 'projects', items: projects },
|
||||||
|
'/trackers': { label: 'trackers', items: trackers }
|
||||||
|
};
|
||||||
const socialLinks = [
|
const socialLinks = [
|
||||||
{ icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' },
|
{ icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' },
|
||||||
{ icon: SiGithub, label: 'github', href: 'https://github.com/stanrunge' },
|
{ icon: SiGithub, label: 'github', href: 'https://github.com/stanrunge' },
|
||||||
@@ -102,14 +111,14 @@
|
|||||||
return page.url.pathname === href || (href !== '/' && page.url.pathname.startsWith(href + '/'));
|
return page.url.pathname === href || (href !== '/' && page.url.pathname.startsWith(href + '/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleProjectsPointerEnter(event: PointerEvent) {
|
function handleMenuPointerEnter(event: PointerEvent, href: string) {
|
||||||
if (event.pointerType === 'touch') return;
|
if (event.pointerType === 'touch') return;
|
||||||
projectsMenuOpen = true;
|
openMenu = href;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleProjectsPointerLeave(event: PointerEvent) {
|
function handleMenuPointerLeave(event: PointerEvent) {
|
||||||
if (event.pointerType === 'touch') return;
|
if (event.pointerType === 'touch') return;
|
||||||
projectsMenuOpen = false;
|
openMenu = null;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -170,7 +179,14 @@
|
|||||||
class="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:bg-[var(--site-bg)] focus:px-4 focus:py-2"
|
class="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:bg-[var(--site-bg)] focus:px-4 focus:py-2"
|
||||||
>skip to content</a
|
>skip to content</a
|
||||||
>
|
>
|
||||||
<header class="border-b border-border bg-background/95 backdrop-blur">
|
<!--
|
||||||
|
Stacking: backdrop-blur makes the header its own stacking context, so the dropdown's z-50 only
|
||||||
|
counts inside it. Without a z-index of its own the header then sits at z-auto/0 and anything
|
||||||
|
positioned later in <main> (maps, chart tooltips, sticky headers) paints over the dropdown.
|
||||||
|
Fix: lift the header (relative z-50) and isolate <main> so page z-indexes stay inside it.
|
||||||
|
Dialogs/sheets/popovers are portalled to <body> and still render above both.
|
||||||
|
-->
|
||||||
|
<header class="relative z-50 border-b border-border bg-background/95 backdrop-blur">
|
||||||
<div class="mx-auto flex max-w-6xl items-center gap-3 px-4 py-3 sm:px-6">
|
<div class="mx-auto flex max-w-6xl items-center gap-3 px-4 py-3 sm:px-6">
|
||||||
<a
|
<a
|
||||||
href={resolve('/')}
|
href={resolve('/')}
|
||||||
@@ -190,39 +206,40 @@
|
|||||||
</a>
|
</a>
|
||||||
<nav class="ml-auto hidden items-center gap-1 md:flex" aria-label="Main navigation">
|
<nav class="ml-auto hidden items-center gap-1 md:flex" aria-label="Main navigation">
|
||||||
{#each navigation as item (item.href)}
|
{#each navigation as item (item.href)}
|
||||||
{#if item.href === '/projects'}
|
{#if menus[item.href]}
|
||||||
|
{@const menu = menus[item.href]}
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="Projects menu"
|
aria-label={`${menu.label} menu`}
|
||||||
onpointerenter={handleProjectsPointerEnter}
|
onpointerenter={(event) => handleMenuPointerEnter(event, item.href)}
|
||||||
onpointerleave={handleProjectsPointerLeave}
|
onpointerleave={handleMenuPointerLeave}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
href={resolve('projects')}
|
href={resolve(item.href)}
|
||||||
variant={current(item.href) ? 'secondary' : 'ghost'}
|
variant={current(item.href) ? 'secondary' : 'ghost'}
|
||||||
size="sm"
|
size="sm"
|
||||||
class="cursor-pointer gap-1"
|
class="cursor-pointer gap-1"
|
||||||
aria-label="Browse projects"
|
aria-label={`Browse ${menu.label}`}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-expanded={projectsMenuOpen}
|
aria-expanded={openMenu === item.href}
|
||||||
onclick={() => (projectsMenuOpen = false)}
|
onclick={() => (openMenu = null)}
|
||||||
onkeydown={(event) => {
|
onkeydown={(event) => {
|
||||||
if (event.key === 'Escape') projectsMenuOpen = false;
|
if (event.key === 'Escape') openMenu = null;
|
||||||
}}>{item.label}<ChevronDown size={15} /></Button
|
}}>{item.label}<ChevronDown size={15} /></Button
|
||||||
>
|
>
|
||||||
{#if projectsMenuOpen}
|
{#if openMenu === item.href}
|
||||||
<div
|
<div
|
||||||
class="absolute top-full left-0 z-50 max-h-[70vh] w-72 overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
|
class="absolute top-full left-0 z-50 max-h-[70vh] w-72 overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
|
||||||
aria-label="Projects"
|
aria-label={menu.label}
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href={resolve('projects')}
|
href={resolve(item.href)}
|
||||||
class="flex items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent"
|
class="flex items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent"
|
||||||
>all projects <ArrowRight size={14} class="ml-auto" /></a
|
>all {menu.label} <ArrowRight size={14} class="ml-auto" /></a
|
||||||
>
|
>
|
||||||
<div class="my-1 h-px bg-border"></div>
|
<div class="my-1 h-px bg-border"></div>
|
||||||
{#each projects as project (project.name)}
|
{#each menu.items as project (project.name)}
|
||||||
<!-- Internal project links use resolve; external URLs are from the project catalog. -->
|
<!-- Internal project links use resolve; external URLs are from the project catalog. -->
|
||||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||||
<a
|
<a
|
||||||
@@ -297,9 +314,9 @@
|
|||||||
class="justify-start"
|
class="justify-start"
|
||||||
onclick={() => (menuOpen = false)}>{item.label}</Button
|
onclick={() => (menuOpen = false)}>{item.label}</Button
|
||||||
>
|
>
|
||||||
{#if item.href === '/projects'}
|
{#if menus[item.href]}
|
||||||
<div class="ml-3 flex flex-col border-l border-border pl-3">
|
<div class="ml-3 flex flex-col border-l border-border pl-3">
|
||||||
{#each projects as project (project.name)}
|
{#each menus[item.href].items as project (project.name)}
|
||||||
<Button
|
<Button
|
||||||
href={project.external ? project.href : resolve(project.href)}
|
href={project.external ? project.href : resolve(project.href)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -343,7 +360,7 @@
|
|||||||
</Sheet.Root>
|
</Sheet.Root>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main id="main" class="min-w-0 flex-1">{@render children()}</main>
|
<main id="main" class="isolate min-w-0 flex-1">{@render children()}</main>
|
||||||
<footer
|
<footer
|
||||||
class="mt-16 border-t border-[var(--site-border)]"
|
class="mt-16 border-t border-[var(--site-border)]"
|
||||||
class:tracker-footer={page.route.id === '/projects/game-tracker'}
|
class:tracker-footer={page.route.id === '/projects/game-tracker'}
|
||||||
@@ -357,7 +374,7 @@
|
|||||||
></span
|
></span
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="https://git.stanrunge.dev/stan/personal-website"
|
href="https://git.stanrunge.dev/stan/website"
|
||||||
rel="external"
|
rel="external"
|
||||||
class="inline-flex items-center gap-1 hover:text-[var(--site-text)]"
|
class="inline-flex items-center gap-1 hover:text-[var(--site-text)]"
|
||||||
>source <ExternalLink size={14} aria-hidden="true" /></a
|
>source <ExternalLink size={14} aria-hidden="true" /></a
|
||||||
|
|||||||
@@ -1,33 +1,10 @@
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { db } from '#lib/server/db/index.js';
|
import { listSummaries } from '#lib/server/lists.js';
|
||||||
import { listItemCompletions } from '#lib/server/db/schema/index.js';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async () => {
|
||||||
const lists = await db.query.lists.findMany({
|
// `done` is a property of the item now (one owner), so progress is the same for every visitor.
|
||||||
columns: { id: true, name: true, slug: true, isProgress: true, pictureUrl: true },
|
return { lists: await listSummaries() };
|
||||||
with: { items: { columns: { id: true } } },
|
|
||||||
orderBy: { id: 'desc' }
|
|
||||||
});
|
|
||||||
|
|
||||||
const completed = new Set(
|
|
||||||
locals.user
|
|
||||||
? (
|
|
||||||
await db
|
|
||||||
.select({ itemId: listItemCompletions.itemId })
|
|
||||||
.from(listItemCompletions)
|
|
||||||
.where(eq(listItemCompletions.userId, locals.user.id))
|
|
||||||
).map((row) => row.itemId)
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
lists: lists.map(({ items, ...list }) => ({
|
|
||||||
...list,
|
|
||||||
total: items.length,
|
|
||||||
checked: list.isProgress ? items.filter((item) => completed.has(item.id)).length : 0
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Retire the old shared-token login; legacy cookies grant no permissions.
|
// Retire the old shared-token login; legacy cookies grant no permissions.
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import portrait from '#lib/assets/stan.jpg?url';
|
import portrait from '#lib/assets/stan.jpg?url';
|
||||||
import ProgressBar from '#lib/components/ProgressBar.svelte';
|
import ProgressBar from '#lib/components/ProgressBar.svelte';
|
||||||
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
|
import CatalogGrid from '#lib/components/CatalogGrid.svelte';
|
||||||
import * as Card from '#lib/components/ui/card/index.js';
|
import { projects, trackers } from '#lib/projects.js';
|
||||||
import { projects } from '#lib/projects.js';
|
|
||||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||||
import Mail from 'lucide-svelte/icons/mail';
|
import Mail from 'lucide-svelte/icons/mail';
|
||||||
@@ -104,64 +103,18 @@
|
|||||||
>projects <ArrowRight size={20} aria-hidden="true" /></a
|
>projects <ArrowRight size={20} aria-hidden="true" /></a
|
||||||
>
|
>
|
||||||
</h2>
|
</h2>
|
||||||
<ul class="grid gap-3 sm:grid-cols-2">
|
<CatalogGrid items={projects} subtitleClass="mt-1 block text-base text-[var(--site-muted)]" />
|
||||||
{#each projects as project (project.name)}
|
</section>
|
||||||
<li>
|
|
||||||
<Card.Root class="h-full py-0 transition-colors hover:bg-muted/50"
|
<section aria-labelledby="trackers-heading" class="border-t border-[var(--site-border)] pt-8">
|
||||||
><Card.Content class="p-0">
|
<h2 id="trackers-heading" class="mb-4 text-2xl font-semibold tracking-tight">
|
||||||
{#if project.external}
|
|
||||||
<a
|
<a
|
||||||
href={project.href}
|
href={resolve('trackers')}
|
||||||
rel="external"
|
class="inline-flex items-center gap-1 hover:text-[var(--site-muted)]"
|
||||||
class="flex min-h-28 items-start justify-between gap-4 p-5"
|
>trackers <ArrowRight size={20} aria-hidden="true" /></a
|
||||||
>
|
>
|
||||||
<span class="flex min-w-0 items-start gap-3">
|
</h2>
|
||||||
<span
|
<CatalogGrid items={trackers} subtitleClass="mt-1 block text-base text-[var(--site-muted)]" />
|
||||||
class="mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-md border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"
|
|
||||||
>
|
|
||||||
<ProjectIcon name={project.name} class="size-5" />
|
|
||||||
</span>
|
|
||||||
<span class="min-w-0">
|
|
||||||
<span class="block font-medium">{project.name}</span>
|
|
||||||
<span class="mt-1 block text-base text-[var(--site-muted)]"
|
|
||||||
>{project.subtitle}</span
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
</span><ExternalLink
|
|
||||||
size={18}
|
|
||||||
aria-hidden="true"
|
|
||||||
class="shrink-0 text-[var(--site-muted)]"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<a
|
|
||||||
href={resolve(project.href)}
|
|
||||||
class="flex min-h-28 items-start justify-between gap-4 p-5"
|
|
||||||
>
|
|
||||||
<span class="flex min-w-0 items-start gap-3">
|
|
||||||
<span
|
|
||||||
class="mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-md border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"
|
|
||||||
>
|
|
||||||
<ProjectIcon name={project.name} class="size-5" />
|
|
||||||
</span>
|
|
||||||
<span class="min-w-0">
|
|
||||||
<span class="block font-medium">{project.name}</span>
|
|
||||||
<span class="mt-1 block text-base text-[var(--site-muted)]"
|
|
||||||
>{project.subtitle}</span
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
</span><ArrowRight
|
|
||||||
size={18}
|
|
||||||
aria-hidden="true"
|
|
||||||
class="shrink-0 text-[var(--site-muted)]"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</Card.Content></Card.Root
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-labelledby="lists-heading" class="pt-14 sm:pt-20">
|
<section aria-labelledby="lists-heading" class="pt-14 sm:pt-20">
|
||||||
@@ -205,7 +158,7 @@
|
|||||||
<span
|
<span
|
||||||
class="inline-flex min-w-16 items-center justify-end gap-1 whitespace-nowrap text-base text-[var(--site-muted)]"
|
class="inline-flex min-w-16 items-center justify-end gap-1 whitespace-nowrap text-base text-[var(--site-muted)]"
|
||||||
>
|
>
|
||||||
{#if list.isProgress && data.user}
|
{#if list.isProgress}
|
||||||
{list.total ? Math.round((list.checked / list.total) * 100) : 0}%
|
{list.total ? Math.round((list.checked / list.total) * 100) : 0}%
|
||||||
{:else}
|
{:else}
|
||||||
{list.total}
|
{list.total}
|
||||||
|
|||||||
@@ -1,35 +1,11 @@
|
|||||||
import { db } from '#lib/server/db/index.js';
|
|
||||||
import { listItemCompletions, lists } from '#lib/server/db/schema/index.js';
|
|
||||||
import { requireAdmin } from '#lib/server/authorization.js';
|
import { requireAdmin } from '#lib/server/authorization.js';
|
||||||
import { uniqueListSlug } from '#lib/server/slugify.js';
|
import { uniqueListSlug } from '#lib/server/slugify.js';
|
||||||
import { listImageUrl } from '#lib/server/r2.js';
|
import { listImageUrl } from '#lib/server/r2.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { createList, listSummaries } from '#lib/server/lists.js';
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async () => ({ lists: await listSummaries() });
|
||||||
const rows = await db.query.lists.findMany({
|
|
||||||
with: { items: { columns: { id: true } } },
|
|
||||||
orderBy: { id: 'desc' }
|
|
||||||
});
|
|
||||||
const completed = new Set(
|
|
||||||
locals.user
|
|
||||||
? (
|
|
||||||
await db
|
|
||||||
.select({ itemId: listItemCompletions.itemId })
|
|
||||||
.from(listItemCompletions)
|
|
||||||
.where(eq(listItemCompletions.userId, locals.user.id))
|
|
||||||
).map((row) => row.itemId)
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
lists: rows.map(({ items, ...list }) => ({
|
|
||||||
...list,
|
|
||||||
total: items.length,
|
|
||||||
checked: list.isProgress ? items.filter((item) => completed.has(item.id)).length : 0
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
newList: async (event) => {
|
newList: async (event) => {
|
||||||
@@ -47,8 +23,9 @@ export const actions: Actions = {
|
|||||||
return fail(400, { message: 'Enable JavaScript to upload an image.' });
|
return fail(400, { message: 'Enable JavaScript to upload an image.' });
|
||||||
const pictureUrl = pictureKey ? listImageUrl(pictureKey) : null;
|
const pictureUrl = pictureKey ? listImageUrl(pictureKey) : null;
|
||||||
if (pictureKey && !pictureUrl) return fail(400, { message: 'Invalid uploaded picture.' });
|
if (pictureKey && !pictureUrl) return fail(400, { message: 'Invalid uploaded picture.' });
|
||||||
const slug = await uniqueListSlug(name);
|
// meta.tables.slug allows at most 63 characters
|
||||||
await db.insert(lists).values({ name, slug, isProgress: type === 'progress', pictureUrl });
|
const slug = (await uniqueListSlug(name.slice(0, 60))).slice(0, 63).replace(/-+$/, '');
|
||||||
|
await createList({ slug, name, isProgress: type === 'progress', pictureUrl });
|
||||||
redirect(303, `/lists/${slug}`);
|
redirect(303, `/lists/${slug}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="min-w-16 whitespace-nowrap text-right text-base text-[var(--site-muted)]">
|
<span class="min-w-16 whitespace-nowrap text-right text-base text-[var(--site-muted)]">
|
||||||
{#if list.isProgress && data.user}
|
{#if list.isProgress}
|
||||||
{list.total ? Math.round((list.checked / list.total) * 100) : 0}%
|
{list.total ? Math.round((list.checked / list.total) * 100) : 0}%
|
||||||
{:else}
|
{:else}
|
||||||
{list.total} {list.total === 1 ? 'item' : 'items'}
|
{list.total} {list.total === 1 ? 'item' : 'items'}
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
import { db } from '#lib/server/db/index.js';
|
import { db } from '#lib/server/db/index.js';
|
||||||
import { ORIGIN } from '$app/env/private';
|
import { ORIGIN } from '$app/env/private';
|
||||||
import { lists, listItems, listItemCompletions } from '#lib/server/db/schema/index.js';
|
import { metaTables } from '#lib/server/db/schema/index.js';
|
||||||
import { requireAdmin, requireUser } from '#lib/server/authorization.js';
|
import { requireAdmin } from '#lib/server/authorization.js';
|
||||||
import { deleteObject, listImageKey, listImageUrl } from '#lib/server/r2.js';
|
import { deleteObject, listImageKey, listImageUrl } from '#lib/server/r2.js';
|
||||||
import { validSlug } from '#lib/server/slugify.js';
|
import { validSlug } from '#lib/server/slugify.js';
|
||||||
|
import {
|
||||||
|
archiveList,
|
||||||
|
deleteItem,
|
||||||
|
extraFields,
|
||||||
|
findList,
|
||||||
|
getItem,
|
||||||
|
getList,
|
||||||
|
insertItem,
|
||||||
|
listColumns,
|
||||||
|
setDone,
|
||||||
|
updateItem,
|
||||||
|
type ListColumn
|
||||||
|
} from '#lib/server/lists.js';
|
||||||
import { and, eq, ne } from 'drizzle-orm';
|
import { and, eq, ne } from 'drizzle-orm';
|
||||||
import { error, fail, redirect } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
async function currentList(slug: string) {
|
async function currentList(slug: string) {
|
||||||
const list = await db.query.lists.findFirst({ where: { slug } });
|
const list = await findList(slug);
|
||||||
if (!list) error(404, 'List not found');
|
if (!list) error(404, 'List not found');
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
@@ -24,7 +37,7 @@ async function removeStoredPicture(url: string | null) {
|
|||||||
console.error('Could not remove list picture from R2', cause);
|
console.error('Could not remove list picture from R2', cause);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function itemFields(form: FormData) {
|
function itemFields(form: FormData, columns: ListColumn[]) {
|
||||||
const name = String(form.get('name') ?? '').trim();
|
const name = String(form.get('name') ?? '').trim();
|
||||||
const description = String(form.get('description') ?? '').trim();
|
const description = String(form.get('description') ?? '').trim();
|
||||||
const pictureKey = String(form.get('pictureKey') ?? '').trim();
|
const pictureKey = String(form.get('pictureKey') ?? '').trim();
|
||||||
@@ -37,54 +50,51 @@ function itemFields(form: FormData) {
|
|||||||
if (pictureKey && removePicture) return { error: 'Choose a new picture or remove the old one.' };
|
if (pictureKey && removePicture) return { error: 'Choose a new picture or remove the old one.' };
|
||||||
const pictureUrl = pictureKey ? listImageUrl(pictureKey) : null;
|
const pictureUrl = pictureKey ? listImageUrl(pictureKey) : null;
|
||||||
if (pictureKey && !pictureUrl) return { error: 'Invalid uploaded picture.' };
|
if (pictureKey && !pictureUrl) return { error: 'Invalid uploaded picture.' };
|
||||||
|
const extras = extraFields(form, columns);
|
||||||
|
if ('error' in extras) return { error: extras.error };
|
||||||
return {
|
return {
|
||||||
values: {
|
values: {
|
||||||
name,
|
name,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
|
extras: extras.values,
|
||||||
...(pictureKey ? { pictureUrl } : removePicture ? { pictureUrl: null } : {})
|
...(pictureKey ? { pictureUrl } : removePicture ? { pictureUrl: null } : {})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
function itemId(form: FormData) {
|
||||||
|
const id = Number(form.get('itemId'));
|
||||||
|
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
if (/^[1-9]\d*$/.test(params.slug)) {
|
if (/^[1-9]\d*$/.test(params.slug)) {
|
||||||
|
// Old numeric list URLs: list ids were preserved as meta.tables.sort_order.
|
||||||
const oldId = Number(params.slug);
|
const oldId = Number(params.slug);
|
||||||
if (Number.isSafeInteger(oldId)) {
|
if (Number.isSafeInteger(oldId)) {
|
||||||
const oldList = await db.query.lists.findFirst({ where: { id: oldId } });
|
const [old] = await db
|
||||||
if (oldList) redirect(308, `/lists/${oldList.slug}`);
|
.select({ slug: metaTables.slug })
|
||||||
|
.from(metaTables)
|
||||||
|
.where(and(eq(metaTables.kind, 'list'), eq(metaTables.sortOrder, oldId)));
|
||||||
|
if (old) redirect(308, `/lists/${old.slug}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const list = await db.query.lists.findFirst({
|
const list = await getList(params.slug);
|
||||||
where: { slug: params.slug },
|
|
||||||
with: { items: { orderBy: { id: 'asc' } } }
|
|
||||||
});
|
|
||||||
if (!list) error(404, 'List not found');
|
if (!list) error(404, 'List not found');
|
||||||
const completedIds =
|
const completedIds = list.isProgress
|
||||||
locals.user && list.isProgress
|
? list.items.filter((item) => item.doneAt).map((item) => item.id)
|
||||||
? (
|
|
||||||
await db
|
|
||||||
.select({ itemId: listItemCompletions.itemId })
|
|
||||||
.from(listItemCompletions)
|
|
||||||
.where(eq(listItemCompletions.userId, locals.user.id))
|
|
||||||
).map((row) => row.itemId)
|
|
||||||
: [];
|
: [];
|
||||||
const inList = completedIds.filter((id) => list.items.some((item) => item.id === id));
|
return { list, completedIds, checked: completedIds.length, total: list.items.length };
|
||||||
return { list, completedIds: inList, checked: inList.length, total: list.items.length };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
deleteList: async (event) => {
|
deleteList: async (event) => {
|
||||||
requireAdmin(event);
|
requireAdmin(event);
|
||||||
const list = await currentList(event.params.slug);
|
const list = await currentList(event.params.slug);
|
||||||
const confirmation = String((await event.request.formData()).get('confirmName') ?? '');
|
const confirmation = String((await event.request.formData()).get('confirmName') ?? '');
|
||||||
if (confirmation !== list.name)
|
if (confirmation !== list.displayName)
|
||||||
return fail(400, { message: 'Type the exact list name to confirm deletion.' });
|
return fail(400, { message: 'Type the exact list name to confirm deletion.' });
|
||||||
const pictures = await db
|
// Archived, not dropped: the table and its pictures stay restorable for 7 days.
|
||||||
.select({ pictureUrl: listItems.pictureUrl })
|
await archiveList(list.slug);
|
||||||
.from(listItems)
|
|
||||||
.where(eq(listItems.listId, list.id));
|
|
||||||
await db.delete(lists).where(eq(lists.id, list.id));
|
|
||||||
await Promise.all(
|
|
||||||
[...pictures.map((item) => item.pictureUrl), list.pictureUrl].map(removeStoredPicture)
|
|
||||||
);
|
|
||||||
redirect(303, '/lists');
|
redirect(303, '/lists');
|
||||||
},
|
},
|
||||||
updateList: async (event) => {
|
updateList: async (event) => {
|
||||||
@@ -94,15 +104,12 @@ export const actions: Actions = {
|
|||||||
const slug = String(form.get('slug') ?? '')
|
const slug = String(form.get('slug') ?? '')
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
const type = String(form.get('type') ?? '');
|
|
||||||
const pictureKey = String(form.get('pictureKey') ?? '').trim();
|
const pictureKey = String(form.get('pictureKey') ?? '').trim();
|
||||||
const removePicture = form.get('removePicture') === 'on';
|
const removePicture = form.get('removePicture') === 'on';
|
||||||
const picture = form.get('picture');
|
const picture = form.get('picture');
|
||||||
if (!name || name.length > 200)
|
if (!name || name.length > 200)
|
||||||
return fail(400, { message: 'Enter a name of 1–200 characters' });
|
return fail(400, { message: 'Enter a name of 1–200 characters' });
|
||||||
if (type !== 'normal' && type !== 'progress')
|
if (!validSlug(slug) || slug.length > 63)
|
||||||
return fail(400, { message: 'Choose a list type.' });
|
|
||||||
if (!validSlug(slug))
|
|
||||||
return fail(400, { message: 'Use a lowercase slug with letters, numbers, and hyphens.' });
|
return fail(400, { message: 'Use a lowercase slug with letters, numbers, and hyphens.' });
|
||||||
if (picture instanceof File && picture.size > 0)
|
if (picture instanceof File && picture.size > 0)
|
||||||
return fail(400, { message: 'Enable JavaScript to upload an image.' });
|
return fail(400, { message: 'Enable JavaScript to upload an image.' });
|
||||||
@@ -112,20 +119,19 @@ export const actions: Actions = {
|
|||||||
if (pictureKey && !pictureUrl) return fail(400, { message: 'Invalid uploaded picture.' });
|
if (pictureKey && !pictureUrl) return fail(400, { message: 'Invalid uploaded picture.' });
|
||||||
const current = await currentList(event.params.slug);
|
const current = await currentList(event.params.slug);
|
||||||
const duplicate = await db
|
const duplicate = await db
|
||||||
.select({ id: lists.id })
|
.select({ id: metaTables.id })
|
||||||
.from(lists)
|
.from(metaTables)
|
||||||
.where(and(eq(lists.slug, slug), ne(lists.id, current.id)));
|
.where(and(eq(metaTables.slug, slug), ne(metaTables.id, current.id)));
|
||||||
if (duplicate.length) return fail(409, { message: 'That list slug is already in use.' });
|
if (duplicate.length) return fail(409, { message: 'That list slug is already in use.' });
|
||||||
const [list] = await db
|
const [list] = await db
|
||||||
.update(lists)
|
.update(metaTables)
|
||||||
.set({
|
.set({
|
||||||
name,
|
displayName: name,
|
||||||
slug,
|
slug,
|
||||||
isProgress: type === 'progress',
|
|
||||||
...(pictureKey ? { pictureUrl } : removePicture ? { pictureUrl: null } : {})
|
...(pictureKey ? { pictureUrl } : removePicture ? { pictureUrl: null } : {})
|
||||||
})
|
})
|
||||||
.where(eq(lists.id, current.id))
|
.where(eq(metaTables.id, current.id))
|
||||||
.returning();
|
.returning({ pictureUrl: metaTables.pictureUrl });
|
||||||
if (!list) error(404, 'List not found');
|
if (!list) error(404, 'List not found');
|
||||||
if (list.pictureUrl !== current.pictureUrl) await removeStoredPicture(current.pictureUrl);
|
if (list.pictureUrl !== current.pictureUrl) await removeStoredPicture(current.pictureUrl);
|
||||||
if (slug !== event.params.slug) redirect(303, `/lists/${slug}`);
|
if (slug !== event.params.slug) redirect(303, `/lists/${slug}`);
|
||||||
@@ -133,66 +139,48 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
addItem: async (event) => {
|
addItem: async (event) => {
|
||||||
requireAdmin(event);
|
requireAdmin(event);
|
||||||
const fields = itemFields(await event.request.formData());
|
|
||||||
if (fields.error) return fail(400, { message: fields.error });
|
|
||||||
const list = await currentList(event.params.slug);
|
const list = await currentList(event.params.slug);
|
||||||
await db.insert(listItems).values({ ...fields.values!, listId: list.id });
|
const fields = itemFields(await event.request.formData(), await listColumns(list));
|
||||||
|
if (fields.error) return fail(400, { message: fields.error });
|
||||||
|
await insertItem(list, fields.values!);
|
||||||
return { saved: true };
|
return { saved: true };
|
||||||
},
|
},
|
||||||
updateItem: async (event) => {
|
updateItem: async (event) => {
|
||||||
requireAdmin(event);
|
requireAdmin(event);
|
||||||
const form = await event.request.formData();
|
const form = await event.request.formData();
|
||||||
const id = Number(form.get('itemId'));
|
const id = itemId(form);
|
||||||
if (!Number.isSafeInteger(id) || id <= 0) return fail(400, { message: 'Invalid item.' });
|
if (!id) return fail(400, { message: 'Invalid item.' });
|
||||||
const fields = itemFields(form);
|
|
||||||
if (fields.error) return fail(400, { message: fields.error });
|
|
||||||
const list = await currentList(event.params.slug);
|
const list = await currentList(event.params.slug);
|
||||||
const existing = await db.query.listItems.findFirst({ where: { id, listId: list.id } });
|
const fields = itemFields(form, await listColumns(list));
|
||||||
|
if (fields.error) return fail(400, { message: fields.error });
|
||||||
|
const existing = await getItem(list, id);
|
||||||
if (!existing) error(404, 'Item not found');
|
if (!existing) error(404, 'Item not found');
|
||||||
const [item] = await db
|
if (!(await updateItem(list, id, fields.values!))) error(404, 'Item not found');
|
||||||
.update(listItems)
|
|
||||||
.set(fields.values!)
|
|
||||||
.where(and(eq(listItems.id, id), eq(listItems.listId, list.id)))
|
|
||||||
.returning({ id: listItems.id });
|
|
||||||
if (!item) error(404, 'Item not found');
|
|
||||||
if ('pictureUrl' in fields.values! && fields.values!.pictureUrl !== existing.pictureUrl)
|
if ('pictureUrl' in fields.values! && fields.values!.pictureUrl !== existing.pictureUrl)
|
||||||
await removeStoredPicture(existing.pictureUrl);
|
await removeStoredPicture(existing.pictureUrl);
|
||||||
return { saved: true };
|
return { saved: true };
|
||||||
},
|
},
|
||||||
removeItem: async (event) => {
|
removeItem: async (event) => {
|
||||||
requireAdmin(event);
|
requireAdmin(event);
|
||||||
const id = Number((await event.request.formData()).get('itemId'));
|
const id = itemId(await event.request.formData());
|
||||||
if (!Number.isSafeInteger(id) || id <= 0) return fail(400, { message: 'Invalid item' });
|
if (!id) return fail(400, { message: 'Invalid item' });
|
||||||
const list = await currentList(event.params.slug);
|
const list = await currentList(event.params.slug);
|
||||||
const [item] = await db
|
const item = await deleteItem(list, id);
|
||||||
.delete(listItems)
|
|
||||||
.where(and(eq(listItems.id, id), eq(listItems.listId, list.id)))
|
|
||||||
.returning({ pictureUrl: listItems.pictureUrl });
|
|
||||||
if (!item) error(404, 'Item not found');
|
if (!item) error(404, 'Item not found');
|
||||||
await removeStoredPicture(item.pictureUrl);
|
await removeStoredPicture(item.picture_url);
|
||||||
return { saved: true };
|
return { saved: true };
|
||||||
},
|
},
|
||||||
setCompletion: async (event) => {
|
setCompletion: async (event) => {
|
||||||
const user = requireUser(event);
|
// One owner: `done` lives on the item, so only the admin can tick items off.
|
||||||
|
requireAdmin(event);
|
||||||
const list = await currentList(event.params.slug);
|
const list = await currentList(event.params.slug);
|
||||||
if (!list.isProgress) return fail(400, { message: 'This is not a progress list.' });
|
if (!list.hasDone) return fail(400, { message: 'This is not a progress list.' });
|
||||||
const form = await event.request.formData();
|
const form = await event.request.formData();
|
||||||
const id = Number(form.get('itemId'));
|
const id = itemId(form);
|
||||||
const completed = form.get('completed');
|
const completed = form.get('completed');
|
||||||
if (!Number.isSafeInteger(id) || id <= 0 || (completed !== 'yes' && completed !== 'no'))
|
if (!id || (completed !== 'yes' && completed !== 'no'))
|
||||||
return fail(400, { message: 'Invalid progress change.' });
|
return fail(400, { message: 'Invalid progress change.' });
|
||||||
const item = await db.query.listItems.findFirst({ where: { id, listId: list.id } });
|
if (!(await setDone(list, id, completed === 'yes'))) error(404, 'Item not found');
|
||||||
if (!item) error(404, 'Item not found');
|
|
||||||
if (completed === 'yes') {
|
|
||||||
await db
|
|
||||||
.insert(listItemCompletions)
|
|
||||||
.values({ itemId: id, userId: user.id })
|
|
||||||
.onConflictDoNothing();
|
|
||||||
} else {
|
|
||||||
await db
|
|
||||||
.delete(listItemCompletions)
|
|
||||||
.where(and(eq(listItemCompletions.itemId, id), eq(listItemCompletions.userId, user.id)));
|
|
||||||
}
|
|
||||||
return { saved: true };
|
return { saved: true };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
|
||||||
import { Input } from '#lib/components/ui/input/index.js';
|
import { Input } from '#lib/components/ui/input/index.js';
|
||||||
import { Textarea } from '#lib/components/ui/textarea/index.js';
|
import { Textarea } from '#lib/components/ui/textarea/index.js';
|
||||||
import { Checkbox } from '#lib/components/ui/checkbox/index.js';
|
import { Checkbox } from '#lib/components/ui/checkbox/index.js';
|
||||||
@@ -14,6 +13,7 @@
|
|||||||
import { enhance, type SubmitFunction } from '$app/forms';
|
import { enhance, type SubmitFunction } from '$app/forms';
|
||||||
import ListPictureDropzone from '#lib/components/ListPictureDropzone.svelte';
|
import ListPictureDropzone from '#lib/components/ListPictureDropzone.svelte';
|
||||||
import ProgressBar from '#lib/components/ProgressBar.svelte';
|
import ProgressBar from '#lib/components/ProgressBar.svelte';
|
||||||
|
import ListExtraFields from '#lib/components/ListExtraFields.svelte';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
let completed = $derived(new Set(data.completedIds));
|
let completed = $derived(new Set(data.completedIds));
|
||||||
@@ -109,7 +109,6 @@
|
|||||||
{#if form?.message}<p role="alert" class="text-red-700">{form.message}</p>{/if}
|
{#if form?.message}<p role="alert" class="text-red-700">{form.message}</p>{/if}
|
||||||
{#if uploadError}<p role="alert" class="text-red-700">{uploadError}</p>{/if}
|
{#if uploadError}<p role="alert" class="text-red-700">{uploadError}</p>{/if}
|
||||||
{#if data.list.isProgress}
|
{#if data.list.isProgress}
|
||||||
{#if data.user}
|
|
||||||
<div class="border-y border-[var(--site-border)] py-4">
|
<div class="border-y border-[var(--site-border)] py-4">
|
||||||
<p class="font-medium">
|
<p class="font-medium">
|
||||||
{data.checked} of {data.total} checked · {data.total
|
{data.checked} of {data.total} checked · {data.total
|
||||||
@@ -120,12 +119,6 @@
|
|||||||
<ProgressBar value={data.checked} max={data.total} label="List progress" small />
|
<ProgressBar value={data.checked} max={data.total} label="List progress" small />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
|
||||||
<p>
|
|
||||||
<a class="underline" href={resolve(`login?returnTo=/lists/${data.list.slug}`)}>Log in</a> to save
|
|
||||||
your progress.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if data.isAdmin && managing}
|
{#if data.isAdmin && managing}
|
||||||
<Collapsible.Root class="border-b border-[var(--site-border)] pb-5">
|
<Collapsible.Root class="border-b border-[var(--site-border)] pb-5">
|
||||||
@@ -159,12 +152,9 @@
|
|||||||
class="block w-full rounded border p-2"
|
class="block w-full rounded border p-2"
|
||||||
/></label
|
/></label
|
||||||
>
|
>
|
||||||
<label
|
<p class="self-end text-sm text-[var(--site-muted)]">
|
||||||
>Type<NativeSelect name="type" class="block w-full rounded border p-2">
|
{data.list.isProgress ? 'Progress' : 'Normal'} list (type is fixed once created)
|
||||||
<option value="normal" selected={!data.list.isProgress}>Normal</option>
|
</p>
|
||||||
<option value="progress" selected={data.list.isProgress}>Progress</option>
|
|
||||||
</NativeSelect></label
|
|
||||||
>
|
|
||||||
<div class="sm:col-span-2">
|
<div class="sm:col-span-2">
|
||||||
<ListPictureDropzone label="List picture (optional)" />
|
<ListPictureDropzone label="List picture (optional)" />
|
||||||
{#if data.list.pictureUrl}<label class="mt-2 block text-sm"
|
{#if data.list.pictureUrl}<label class="mt-2 block text-sm"
|
||||||
@@ -238,8 +228,21 @@
|
|||||||
>
|
>
|
||||||
{item.description}
|
{item.description}
|
||||||
</p>{/if}
|
</p>{/if}
|
||||||
|
{#if data.list.columns.length}<p class="mt-1 text-sm text-[var(--site-muted)]">
|
||||||
|
{#each data.list.columns.filter((c) => item.extras[c.name] !== null && item.extras[c.name] !== '') as column, i (column.name)}{#if i}
|
||||||
|
·
|
||||||
|
{/if}{column.label}: {#if column.uiType === 'url'}<a
|
||||||
|
class="underline"
|
||||||
|
href={String(item.extras[column.name])}
|
||||||
|
rel="noopener noreferrer">link</a
|
||||||
|
>{:else if column.uiType === 'bool'}{item.extras[column.name]
|
||||||
|
? 'yes'
|
||||||
|
: 'no'}{:else if column.uiType === 'cents'}{(
|
||||||
|
Number(item.extras[column.name]) / 100
|
||||||
|
).toFixed(2)}{:else}{item.extras[column.name]}{/if}{/each}
|
||||||
|
</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if data.list.isProgress && data.user}
|
{#if data.list.isProgress && data.isAdmin}
|
||||||
<form method="POST" action="?/setCompletion" use:enhance class="shrink-0">
|
<form method="POST" action="?/setCompletion" use:enhance class="shrink-0">
|
||||||
<input type="hidden" name="itemId" value={item.id} />
|
<input type="hidden" name="itemId" value={item.id} />
|
||||||
<input type="hidden" name="completed" value={completed.has(item.id) ? 'no' : 'yes'} />
|
<input type="hidden" name="completed" value={completed.has(item.id) ? 'no' : 'yes'} />
|
||||||
@@ -251,6 +254,8 @@
|
|||||||
{#if completed.has(item.id)}<Check size={16} aria-hidden="true" />{/if}
|
{#if completed.has(item.id)}<Check size={16} aria-hidden="true" />{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
{:else if data.list.isProgress && completed.has(item.id)}
|
||||||
|
<Check size={16} aria-label="Done" class="shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
{#if data.isAdmin && managing}<Button
|
{#if data.isAdmin && managing}<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -309,6 +314,7 @@
|
|||||||
value={editingItem.description ?? ''}
|
value={editingItem.description ?? ''}
|
||||||
/></label
|
/></label
|
||||||
>
|
>
|
||||||
|
<ListExtraFields columns={data.list.columns} values={editingItem.extras} />
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
@@ -373,6 +379,7 @@
|
|||||||
class="block w-full rounded border p-2"
|
class="block w-full rounded border p-2"
|
||||||
/></label
|
/></label
|
||||||
>
|
>
|
||||||
|
<ListExtraFields columns={data.list.columns} />
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
|
||||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
|
||||||
import * as Card from '#lib/components/ui/card/index.js';
|
|
||||||
import { Badge } from '#lib/components/ui/badge/index.js';
|
import { Badge } from '#lib/components/ui/badge/index.js';
|
||||||
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
|
import CatalogGrid from '#lib/components/CatalogGrid.svelte';
|
||||||
import { projects } from '#lib/projects.js';
|
import { projects } from '#lib/projects.js';
|
||||||
|
|
||||||
const earlier = [
|
const earlier = [
|
||||||
@@ -15,59 +12,12 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||||
<h1 class="mb-10 text-4xl font-semibold tracking-tight sm:text-5xl">projects</h1>
|
<h1 class="mb-3 text-4xl font-semibold tracking-tight sm:text-5xl">projects</h1>
|
||||||
<ul class="grid gap-3 sm:grid-cols-2">
|
<p class="mb-10 text-[var(--site-muted)]">
|
||||||
{#each projects as project (project.name)}
|
Things I built. Looking for the money, house, game or price trackers? They live under
|
||||||
<li>
|
<a class="underline" href={resolve('trackers')}>trackers</a>.
|
||||||
<Card.Root class="h-full py-0 transition-colors hover:bg-muted/50"
|
</p>
|
||||||
><Card.Content class="p-0">
|
<CatalogGrid items={projects} />
|
||||||
{#if project.external}
|
|
||||||
<a
|
|
||||||
href={project.href}
|
|
||||||
rel="external"
|
|
||||||
class="flex min-h-28 items-start justify-between gap-4 p-5"
|
|
||||||
>
|
|
||||||
<span class="flex min-w-0 items-start gap-3">
|
|
||||||
<span
|
|
||||||
class="mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-md border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"
|
|
||||||
>
|
|
||||||
<ProjectIcon name={project.name} class="size-5" />
|
|
||||||
</span>
|
|
||||||
<span class="min-w-0"
|
|
||||||
><span class="block font-medium">{project.name}</span><span
|
|
||||||
class="mt-1 block text-sm text-muted-foreground">{project.subtitle}</span
|
|
||||||
></span
|
|
||||||
>
|
|
||||||
</span><ExternalLink
|
|
||||||
size={18}
|
|
||||||
aria-hidden="true"
|
|
||||||
class="text-[var(--site-muted)]"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<a
|
|
||||||
href={resolve(project.href)}
|
|
||||||
class="flex min-h-28 items-start justify-between gap-4 p-5"
|
|
||||||
>
|
|
||||||
<span class="flex min-w-0 items-start gap-3">
|
|
||||||
<span
|
|
||||||
class="mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-md border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"
|
|
||||||
>
|
|
||||||
<ProjectIcon name={project.name} class="size-5" />
|
|
||||||
</span>
|
|
||||||
<span class="min-w-0"
|
|
||||||
><span class="block font-medium">{project.name}</span><span
|
|
||||||
class="mt-1 block text-sm text-muted-foreground">{project.subtitle}</span
|
|
||||||
></span
|
|
||||||
>
|
|
||||||
</span><ArrowRight size={18} aria-hidden="true" class="text-[var(--site-muted)]" />
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</Card.Content></Card.Root
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
<h2 class="mt-14 mb-3 text-2xl font-semibold">other</h2>
|
<h2 class="mt-14 mb-3 text-2xl font-semibold">other</h2>
|
||||||
<ul>
|
<ul>
|
||||||
{#each earlier as name (name)}
|
{#each earlier as name (name)}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
sightingAcceptedAt: sightings.acceptedAt,
|
sightingAcceptedAt: sightings.acceptedAt,
|
||||||
sightingDeniedAt: sightings.deniedAt,
|
sightingDeniedAt: sightings.deniedAt,
|
||||||
photoUrl: sql<string>`(
|
photoUrl: sql<string>`(
|
||||||
SELECT url FROM photos
|
SELECT url FROM animaldex.sighting_photos
|
||||||
WHERE sighting_id = ${sightings.id}
|
WHERE sighting_id = ${sightings.id}
|
||||||
ORDER BY sort_order ASC
|
ORDER BY sort_order ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -40,7 +40,7 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
and(
|
and(
|
||||||
eq(sightings.animalId, animals.id),
|
eq(sightings.animalId, animals.id),
|
||||||
sql`${sightings.id} = (
|
sql`${sightings.id} = (
|
||||||
SELECT id FROM sightings
|
SELECT id FROM animaldex.sightings
|
||||||
WHERE animal_id = ${animals.id}
|
WHERE animal_id = ${animals.id}
|
||||||
ORDER BY submitted_at ASC
|
ORDER BY submitted_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
lat: sightings.lat,
|
lat: sightings.lat,
|
||||||
lng: sightings.lng,
|
lng: sightings.lng,
|
||||||
photoUrl: sql<string>`(
|
photoUrl: sql<string>`(
|
||||||
SELECT url FROM photos
|
SELECT url FROM animaldex.sighting_photos
|
||||||
WHERE sighting_id = ${sightings.id}
|
WHERE sighting_id = ${sightings.id}
|
||||||
ORDER BY sort_order ASC
|
ORDER BY sort_order ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import type { PageServerLoad } from './$types';
|
|||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
const [stats, dimensions, markets] = await Promise.all([
|
const [stats, dimensions, markets] = await Promise.all([
|
||||||
db.execute(sql`SELECT
|
db.execute(sql`SELECT
|
||||||
(SELECT count(*)::int FROM cs_market_items) AS items,
|
(SELECT count(*)::int FROM cs.items) AS items,
|
||||||
(SELECT greatest(reltuples::bigint, 0) FROM pg_class WHERE oid = 'cs_price_observations'::regclass) AS observations,
|
(SELECT greatest(reltuples::bigint, 0) FROM pg_class WHERE oid = 'cs.price_observations'::regclass) AS observations,
|
||||||
(SELECT min(observed_at) FROM cs_price_observations) AS earliest,
|
(SELECT min(observed_at) FROM cs.price_observations) AS earliest,
|
||||||
(SELECT max(observed_at) FROM cs_price_observations) AS latest`),
|
(SELECT max(observed_at) FROM cs.price_observations) AS latest`),
|
||||||
db.execute(sql`SELECT key, count(DISTINCT value)::int AS values
|
db.execute(sql`SELECT key, count(DISTINCT value)::int AS values
|
||||||
FROM cs_item_dimensions GROUP BY key ORDER BY key`),
|
FROM cs.item_dimensions GROUP BY key ORDER BY key`),
|
||||||
db.execute(sql`SELECT source, currency, kind FROM cs_price_streams ORDER BY 1, 2, 3`)
|
db.execute(sql`SELECT source, currency, kind FROM cs.price_streams ORDER BY 1, 2, 3`)
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
stats: stats[0] as {
|
stats: stats[0] as {
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
const pattern = `%${q.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
const pattern = `%${q.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||||
if (dimension === 'item') {
|
if (dimension === 'item') {
|
||||||
const rows = await db.execute(sql`SELECT id, name, market_hash_name, icon_url
|
const rows = await db.execute(sql`SELECT id, name, market_hash_name, icon_url
|
||||||
FROM cs_market_items WHERE name ILIKE ${pattern} ESCAPE '\'
|
FROM cs.items WHERE name ILIKE ${pattern} ESCAPE '\'
|
||||||
ORDER BY name, id LIMIT ${limit} OFFSET ${offset}`);
|
ORDER BY name, id LIMIT ${limit} OFFSET ${offset}`);
|
||||||
return Response.json({ results: rows });
|
return Response.json({ results: rows });
|
||||||
}
|
}
|
||||||
if (!/^[a-z][a-z0-9_]*$/.test(dimension))
|
if (!/^[a-z][a-z0-9_]*$/.test(dimension))
|
||||||
return Response.json({ error: 'Invalid dimension' }, { status: 400 });
|
return Response.json({ error: 'Invalid dimension' }, { status: 400 });
|
||||||
const rows = await db.execute(sql`SELECT value, count(*)::int AS items
|
const rows = await db.execute(sql`SELECT value, count(*)::int AS items
|
||||||
FROM cs_item_dimensions WHERE key = ${dimension} AND value ILIKE ${pattern} ESCAPE '\'
|
FROM cs.item_dimensions WHERE key = ${dimension} AND value ILIKE ${pattern} ESCAPE '\'
|
||||||
GROUP BY value ORDER BY value LIMIT ${limit} OFFSET ${offset}`);
|
GROUP BY value ORDER BY value LIMIT ${limit} OFFSET ${offset}`);
|
||||||
return Response.json({ results: rows });
|
return Response.json({ results: rows });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,11 +34,11 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
const seconds = Math.max(60, Math.ceil((+to - +from) / 1000 / 240 / 60) * 60);
|
const seconds = Math.max(60, Math.ceil((+to - +from) / 1000 / 240 / 60) * 60);
|
||||||
const selection =
|
const selection =
|
||||||
dimension === 'item'
|
dimension === 'item'
|
||||||
? sql`JOIN cs_market_items i ON i.id = p.item_id AND i.id IN (${sql.join(
|
? sql`JOIN cs.items i ON i.id = p.item_id AND i.id IN (${sql.join(
|
||||||
ids.map((id) => sql`${id}`),
|
ids.map((id) => sql`${id}`),
|
||||||
sql`,`
|
sql`,`
|
||||||
)})`
|
)})`
|
||||||
: sql`JOIN cs_item_dimensions d ON d.item_id = p.item_id AND d.key = ${dimension}
|
: sql`JOIN cs.item_dimensions d ON d.item_id = p.item_id AND d.key = ${dimension}
|
||||||
AND d.value IN (${sql.join(
|
AND d.value IN (${sql.join(
|
||||||
values.map((value) => sql`${value}`),
|
values.map((value) => sql`${value}`),
|
||||||
sql`,`
|
sql`,`
|
||||||
@@ -53,7 +53,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
sum(p.volume) AS volume,
|
sum(p.volume) AS volume,
|
||||||
min(p.price_cents) AS min_cents,
|
min(p.price_cents) AS min_cents,
|
||||||
max(p.price_cents) AS max_cents
|
max(p.price_cents) AS max_cents
|
||||||
FROM cs_price_observations p ${selection}
|
FROM cs.price_observations p ${selection}
|
||||||
WHERE p.source = ${source} AND p.currency = ${currency} AND p.kind = ${kind}
|
WHERE p.source = ${source} AND p.currency = ${currency} AND p.kind = ${kind}
|
||||||
AND p.observed_at >= ${from.toISOString()}::timestamptz
|
AND p.observed_at >= ${from.toISOString()}::timestamptz
|
||||||
AND p.observed_at <= ${to.toISOString()}::timestamptz
|
AND p.observed_at <= ${to.toISOString()}::timestamptz
|
||||||
|
|||||||
@@ -82,9 +82,9 @@ export const actions = {
|
|||||||
duration,
|
duration,
|
||||||
combo,
|
combo,
|
||||||
misses,
|
misses,
|
||||||
accuracy: t('accuracy') || null,
|
accuracy: t('accuracy') ? Number(t('accuracy')) : null,
|
||||||
pp: t('pp') || null,
|
pp: t('pp') ? Number(t('pp')) : null,
|
||||||
rate: t('rate'),
|
rate: Number(t('rate')),
|
||||||
mods: t('mods') || 'NM',
|
mods: t('mods') || 'NM',
|
||||||
beatmapUrl: t('beatmapUrl') || null,
|
beatmapUrl: t('beatmapUrl') || null,
|
||||||
scoreUrl: t('scoreUrl') || null,
|
scoreUrl: t('scoreUrl') || null,
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
.limit(400),
|
.limit(400),
|
||||||
db.execute(sql`SELECT city_slug AS slug, min(city) AS name,
|
db.execute(sql`SELECT city_slug AS slug, min(city) AS name,
|
||||||
count(*) FILTER (WHERE removed_at IS NULL)::int AS active
|
count(*) FILTER (WHERE removed_at IS NULL)::int AS active
|
||||||
FROM house_listings WHERE offer = ${offer} AND city_slug IS NOT NULL
|
FROM house.listings WHERE offer = ${offer} AND city_slug IS NOT NULL
|
||||||
GROUP BY city_slug ORDER BY active DESC, slug LIMIT 200`),
|
GROUP BY city_slug ORDER BY active DESC, slug LIMIT 200`),
|
||||||
db.execute(sql`SELECT coalesce(min(city), city_slug) AS city,
|
db.execute(sql`SELECT coalesce(min(city), city_slug) AS city,
|
||||||
count(*) FILTER (WHERE removed_at IS NULL)::int AS active,
|
count(*) FILTER (WHERE removed_at IS NULL)::int AS active,
|
||||||
@@ -103,18 +103,18 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
percentile_cont(0.5) WITHIN GROUP (
|
percentile_cont(0.5) WITHIN GROUP (
|
||||||
ORDER BY extract(epoch FROM removed_at - coalesce(offered_at, first_seen_at)) / 86400)
|
ORDER BY extract(epoch FROM removed_at - coalesce(offered_at, first_seen_at)) / 86400)
|
||||||
FILTER (WHERE removed_at > now() - interval '90 days') AS median_days_listed
|
FILTER (WHERE removed_at > now() - interval '90 days') AS median_days_listed
|
||||||
FROM house_listings WHERE offer = ${offer} AND city_slug IS NOT NULL
|
FROM house.listings WHERE offer = ${offer} AND city_slug IS NOT NULL
|
||||||
GROUP BY city_slug ORDER BY active DESC LIMIT 30`),
|
GROUP BY city_slug ORDER BY active DESC LIMIT 30`),
|
||||||
db.select().from(houseAlertRules).orderBy(asc(houseAlertRules.id)),
|
db.select().from(houseAlertRules).orderBy(asc(houseAlertRules.id)),
|
||||||
db.select().from(houseWorkerRuns).orderBy(desc(houseWorkerRuns.createdAt)).limit(25),
|
db.select().from(houseWorkerRuns).orderBy(desc(houseWorkerRuns.createdAt)).limit(25),
|
||||||
db.execute(sql`SELECT
|
db.execute(sql`SELECT
|
||||||
(SELECT max(created_at) FROM house_worker_runs WHERE status = 'ok') AS last_ok,
|
(SELECT max(created_at) FROM house.worker_runs WHERE status = 'ok') AS last_ok,
|
||||||
(SELECT max(created_at) FROM house_worker_runs WHERE status = 'challenged') AS last_challenge,
|
(SELECT max(created_at) FROM house.worker_runs WHERE status = 'challenged') AS last_challenge,
|
||||||
(SELECT count(*)::int FROM house_listings) AS total,
|
(SELECT count(*)::int FROM house.listings) AS total,
|
||||||
(SELECT count(*)::int FROM house_listings WHERE alerted_at > now() - interval '7 days') AS alerts_week,
|
(SELECT count(*)::int FROM house.listings WHERE alerted_at > now() - interval '7 days') AS alerts_week,
|
||||||
(SELECT percentile_cont(0.5) WITHIN GROUP (
|
(SELECT percentile_cont(0.5) WITHIN GROUP (
|
||||||
ORDER BY extract(epoch FROM first_seen_at - offered_at) / 60)
|
ORDER BY extract(epoch FROM first_seen_at - offered_at) / 60)
|
||||||
FROM house_listings WHERE first_seen_lane = 'fast' AND offered_at IS NOT NULL
|
FROM house.listings WHERE first_seen_lane = 'fast' AND offered_at IS NOT NULL
|
||||||
AND first_seen_at >= offered_at AND first_seen_at - offered_at < interval '3 days'
|
AND first_seen_at >= offered_at AND first_seen_at - offered_at < interval '3 days'
|
||||||
AND first_seen_at > now() - interval '30 days')
|
AND first_seen_at > now() - interval '30 days')
|
||||||
AS median_detect_minutes`)
|
AS median_detect_minutes`)
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ export const actions: Actions = {
|
|||||||
fixedMonthlyCents,
|
fixedMonthlyCents,
|
||||||
annualReturnPercent,
|
annualReturnPercent,
|
||||||
contributionMode,
|
contributionMode,
|
||||||
includeRecurring: form.get('includeRecurring') === 'on' ? 1 : 0,
|
includeRecurring: form.get('includeRecurring') === 'on',
|
||||||
endDate
|
endDate
|
||||||
};
|
};
|
||||||
await db
|
await db
|
||||||
|
|||||||
@@ -324,7 +324,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="muted projection-note">
|
<p class="muted projection-note">
|
||||||
Includes future dated transactions and estimates{data.projectionSettings
|
Includes future dated transactions and estimates{data.projectionSettings
|
||||||
?.includeRecurring !== 0
|
?.includeRecurring !== false
|
||||||
? ', plus recurring monthly equivalents from next month'
|
? ', plus recurring monthly equivalents from next month'
|
||||||
: ''}. {data.projectionSettings?.investmentAccountId
|
: ''}. {data.projectionSettings?.investmentAccountId
|
||||||
? `The selected investment account and new contributions use a hypothetical ${data.projectionSettings.annualReturnPercent}% annual return.`
|
? `The selected investment account and new contributions use a hypothetical ${data.projectionSettings.annualReturnPercent}% annual return.`
|
||||||
@@ -991,8 +991,11 @@
|
|||||||
></label
|
></label
|
||||||
>
|
>
|
||||||
<label class="field check-field"
|
<label class="field check-field"
|
||||||
><Checkbox name="includeRecurring" value="on" checked={active.includeRecurring !== 0} /> Include
|
><Checkbox
|
||||||
recurring items from next month</label
|
name="includeRecurring"
|
||||||
|
value="on"
|
||||||
|
checked={active.includeRecurring !== false}
|
||||||
|
/> Include recurring items from next month</label
|
||||||
>
|
>
|
||||||
{:else if mode === 'settings'}
|
{:else if mode === 'settings'}
|
||||||
<input type="hidden" name="kind" value={active.kind ?? selectedUtility} />
|
<input type="hidden" name="kind" value={active.kind ?? selectedUtility} />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { listSummaries } from '#lib/server/lists.js';
|
||||||
import { db } from '#lib/server/db/index.js';
|
import { db } from '#lib/server/db/index.js';
|
||||||
import { blogArticles } from '#lib/server/db/schema/index.js';
|
import { blogArticles } from '#lib/server/db/schema/index.js';
|
||||||
import { SITE_ORIGIN } from '#lib/seo.js';
|
import { SITE_ORIGIN } from '#lib/seo.js';
|
||||||
@@ -9,6 +10,7 @@ import type { RequestHandler } from './$types';
|
|||||||
const staticPaths = [
|
const staticPaths = [
|
||||||
'/',
|
'/',
|
||||||
'/projects',
|
'/projects',
|
||||||
|
'/trackers',
|
||||||
'/blog',
|
'/blog',
|
||||||
'/lists',
|
'/lists',
|
||||||
'/projects/game-tracker',
|
'/projects/game-tracker',
|
||||||
@@ -42,7 +44,7 @@ export const GET: RequestHandler = async () => {
|
|||||||
.select({ slug: blogArticles.slug, updatedAt: blogArticles.updatedAt })
|
.select({ slug: blogArticles.slug, updatedAt: blogArticles.updatedAt })
|
||||||
.from(blogArticles)
|
.from(blogArticles)
|
||||||
.where(eq(blogArticles.status, 'published')),
|
.where(eq(blogArticles.status, 'published')),
|
||||||
db.query.lists.findMany({ columns: { slug: true } })
|
listSummaries()
|
||||||
]);
|
]);
|
||||||
const entries: { path: string; lastmod?: string }[] = [
|
const entries: { path: string; lastmod?: string }[] = [
|
||||||
...staticPaths.map((path) => ({ path })),
|
...staticPaths.map((path) => ({ path })),
|
||||||
|
|||||||
12
src/routes/trackers/+page.svelte
Normal file
12
src/routes/trackers/+page.svelte
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CatalogGrid from '#lib/components/CatalogGrid.svelte';
|
||||||
|
import { trackers } from '#lib/projects.js';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||||
|
<h1 class="mb-3 text-4xl font-semibold tracking-tight sm:text-5xl">trackers</h1>
|
||||||
|
<p class="mb-10 text-[var(--site-muted)]">
|
||||||
|
Personal data I keep track of: games, money, houses, prices, animals and lists.
|
||||||
|
</p>
|
||||||
|
<CatalogGrid items={trackers} />
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user