Compare commits
214 Commits
house-trac
...
money-dail
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e8658bf33 | |||
| e33afe5387 | |||
| 6654c0990a | |||
| 5e812271e1 | |||
| 0ee981a96b | |||
| d5e2ec346b | |||
| 807a6b3104 | |||
| 5d732fc750 | |||
| 5c6ce75409 | |||
| 5e10ecf525 | |||
| 9f8d54d9e3 | |||
| 45841d6f6c | |||
| 28140bd425 | |||
| 71e015d1d0 | |||
| 75571bbfe0 | |||
| d71e39f40a | |||
| be3473caa0 | |||
| b1edf7761c | |||
| 3d3f008037 | |||
| cf0fec973c | |||
| 9b21220280 | |||
| e0bcd63ce3 | |||
| 0088e5202e | |||
| fa3aa25f0b | |||
| 241b90d2f9 | |||
| 0fe0d36f87 | |||
| d93374bca2 | |||
| e1fcd4f321 | |||
| 42c9351ebd | |||
| 030f3c08ee | |||
| 031ee4baf2 | |||
| a6b4f9931d | |||
| ff1520ae8b | |||
| 27a15f0428 | |||
| 3c5ef605df | |||
| fa58e5c661 | |||
| 2a4466cb82 | |||
| d19313035f | |||
| 02734607cc | |||
| 8958d3ff8f | |||
| 7e905b1651 | |||
| 76bfb3dcec | |||
| 6dcbcfee25 | |||
| 8e35a58095 | |||
| a6ef263c7e | |||
| c4397e9d59 | |||
| 8ef291f586 | |||
| 902b1cadd5 | |||
| 3ad13e5828 | |||
| 60370d4572 | |||
| 5028524537 | |||
| 27abff569c | |||
| 1cfbe6486a | |||
| 0e98acd0a8 | |||
| e2af941b88 | |||
| 707f07efd4 | |||
| 67d74f39b8 | |||
| cce6ec3f2b | |||
| 1bab96c20d | |||
| e4162d3a78 | |||
| 942c436d03 | |||
| c897ae3d14 | |||
| ce8b545f60 | |||
| 5d3e8d0261 | |||
| eba5f9eda3 | |||
| 48ec29beac | |||
| 3f30bb883f | |||
| b23f7b3475 | |||
| 5a87a079eb | |||
| ec4d3fd79e | |||
| 676ac2ad64 | |||
| eff5a24985 | |||
| d414e7190e | |||
| 0b7509bbbc | |||
| 772e699655 | |||
| fd4a34b495 | |||
| 80803ef5ac | |||
| 9114b5433b | |||
| 968ec34ac5 | |||
| d79b73ad83 | |||
| 037a525f91 | |||
| 8045362852 | |||
| 152880f5ab | |||
| 7aeb31c46a | |||
| 3668c9e383 | |||
| 4af041719c | |||
| 27987fb8e7 | |||
| 8a8ed1642e | |||
| 11fa8ec006 | |||
| 0f8db8e432 | |||
| 516bc15545 | |||
| 5e4cd83748 | |||
| 7d6cee26a8 | |||
| df081b8058 | |||
| d6c5dde2c1 | |||
| e31e1be73f | |||
| 7b5faf6e74 | |||
| 628e5cb9b3 | |||
| 253cd69424 | |||
| 3f294df337 | |||
| 20ad42b247 | |||
| 92d6da959d | |||
| a89a187346 | |||
| f86c8ef294 | |||
| 85c6f89e36 | |||
| cc34f5751b | |||
| 45943543c2 | |||
| d8490ce94f | |||
| 5226ed7fec | |||
| 704880dc98 | |||
| 5f940e8196 | |||
| 8c0fc74283 | |||
| ef9524aa0f | |||
| 8515cf0e9c | |||
| 602ea9c8e1 | |||
| 42b6b6568a | |||
| ba44365c71 | |||
| 3f251145be | |||
| 59b35c0886 | |||
| eccae362ec | |||
| 6696bc7162 | |||
| 2259965a28 | |||
| cc6c7c8ede | |||
| 6249e6daeb | |||
| 085a30bec8 | |||
| 8442d59b68 | |||
| fac49b57ba | |||
| 7636f0b2bc | |||
| bf0a1e1d2a | |||
|
|
0612270545 | ||
| 901d12f71d | |||
| 771f64c168 | |||
| 46703f1a6c | |||
| 0aa14cebf3 | |||
| 3903317a59 | |||
| 4738f10ad1 | |||
| bc58e97776 | |||
| b757269151 | |||
| 45c124a365 | |||
| 9e30e4246f | |||
| a04e5faebe | |||
| 113eb54cd9 | |||
| e95b7da1e3 | |||
| cc4b10f239 | |||
|
|
112c31d194 | ||
| 2bd668bf5e | |||
| 61ae72e50b | |||
| c4689adda4 | |||
| 807f8af167 | |||
| 8c9a08afa1 | |||
| b5748e5ae5 | |||
| c827deb395 | |||
| 8a29d5ad59 | |||
| 35f78ec829 | |||
| 8818dde99d | |||
| 07d2c70973 | |||
| b51fef14fd | |||
| ea7d64a556 | |||
| 6e528da403 | |||
| 85da5afdfe | |||
| 427f4ed095 | |||
| 8dde5aa302 | |||
| 5f67ad36e7 | |||
| b77c3f8cf5 | |||
| 1754ea9316 | |||
| 6a127de950 | |||
| 9d08e6af23 | |||
| 941a9f16e1 | |||
| c8205e474e | |||
| 81ee02f11a | |||
| eb80a830f5 | |||
| 988c6c31a9 | |||
| 2af279e8a9 | |||
| faeb76a7e7 | |||
| a022c45499 | |||
| 6f82d1350e | |||
| 7a039b23f4 | |||
| 8445eef3cf | |||
| 1d4cfd58c5 | |||
| 763d7881fb | |||
| 2cada618bf | |||
| 7914b0140b | |||
| 178fa8ddce | |||
| 9c8f20a618 | |||
| 9c46e281c2 | |||
| 8b2dfed239 | |||
| 2da375451a | |||
| e4de2dcec3 | |||
| 00c1e8b1e7 | |||
| 9a1b21ec39 | |||
| b1ce715ec7 | |||
| 064d41595f | |||
| fa2456b99e | |||
| 8aa689af64 | |||
| 8275f1fb22 | |||
| 0cf5d44062 | |||
| 88837af624 | |||
| 45f2bbb198 | |||
| 36af5675e9 | |||
| 7e69d1e888 | |||
| 2915135ed8 | |||
| 4cfe6bad6c | |||
| 64fb8df262 | |||
| b8b366b542 | |||
| 6ed508702e | |||
| 46ab4a0adb | |||
| 08ab1f2716 | |||
| e039f69afd | |||
| 1ff6bb859e | |||
| 997c54133f | |||
| bdc8ac24ac | |||
| c688b874c8 | |||
| 74f64c7457 | |||
| 245137c65d |
14
.env.example
14
.env.example
@@ -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
|
||||||
@@ -35,3 +39,9 @@ HOUSE_WORKER_TOKEN=
|
|||||||
HOUSE_DISCORD_WEBHOOK_URL=
|
HOUSE_DISCORD_WEBHOOK_URL=
|
||||||
HOUSE_NTFY_URL=
|
HOUSE_NTFY_URL=
|
||||||
HOUSE_NTFY_TOKEN=
|
HOUSE_NTFY_TOKEN=
|
||||||
|
|
||||||
|
# Money tracker bank sync (scripts/bank-link.ts, scripts/bank-sync.ts; not read by the web app).
|
||||||
|
# Keep these in ~/.config/money-sync.env (mode 600), never in git.
|
||||||
|
ENABLEBANKING_APP_ID=
|
||||||
|
ENABLEBANKING_KEY_PATH=
|
||||||
|
ENABLEBANKING_REDIRECT_URL=https://stanrunge.dev/projects/money-tracker/bank-callback
|
||||||
|
|||||||
@@ -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
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,3 +29,4 @@ vite.config.ts.timestamp-*
|
|||||||
# Browser test output
|
# Browser test output
|
||||||
/test-results/
|
/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
|
__pycache__/
|
||||||
|
|||||||
@@ -19,3 +19,12 @@ data/cod-challenges.json
|
|||||||
data/game-images.json
|
data/game-images.json
|
||||||
data/cod-achievement-art.json
|
data/cod-achievement-art.json
|
||||||
src/lib/house-tracker/fixtures/
|
src/lib/house-tracker/fixtures/
|
||||||
|
|
||||||
|
# Vendored third-party code
|
||||||
|
static/vendor/
|
||||||
|
|
||||||
|
# Generated drizzle snapshots
|
||||||
|
drizzle/*/snapshot.json
|
||||||
|
|
||||||
|
# Generated PUBG map intel (scripts/build-pubg-intel.py)
|
||||||
|
static/pubg-intel/
|
||||||
|
|||||||
18
Dockerfile
18
Dockerfile
@@ -1,4 +1,4 @@
|
|||||||
FROM oven/bun:1.4.0 AS builder
|
FROM oven/bun:1.4.4 AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json bun.lock ./
|
COPY package.json bun.lock ./
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
@@ -6,19 +6,31 @@ COPY . .
|
|||||||
RUN bun run test:unit -- --run
|
RUN bun run test:unit -- --run
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
FROM oven/bun:1.4.0
|
FROM oven/bun:1.4.4
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG GIT_SHA=dev
|
ARG GIT_SHA=dev
|
||||||
ENV APP_VERSION=$GIT_SHA
|
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
|
||||||
|
# scripts/house-listing.ts (manual listings CLI) imports these pure helpers.
|
||||||
|
COPY --from=builder /app/src/lib/house-tracker ./src/lib/house-tracker
|
||||||
COPY --from=builder /app/src/lib/availability.ts ./src/lib/availability.ts
|
COPY --from=builder /app/src/lib/availability.ts ./src/lib/availability.ts
|
||||||
|
# scripts/music-*.ts (Spotify poll, Last.fm and streaming-history imports) import these.
|
||||||
|
COPY --from=builder /app/src/lib/music ./src/lib/music
|
||||||
|
# scripts/games-ingest.ts (match history backfills) imports the games ingest.
|
||||||
|
COPY --from=builder /app/src/lib/games-ingest ./src/lib/games-ingest
|
||||||
|
# scripts/ai-*-import.ts (AI tracker news and bot status imports) import this.
|
||||||
|
COPY --from=builder /app/src/lib/server/ai-import.ts ./src/lib/server/ai-import.ts
|
||||||
|
COPY --from=builder /app/server.js ./server.js
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV BODY_SIZE_LIMIT=6M
|
ENV BODY_SIZE_LIMIT=6M
|
||||||
CMD ["bun", "./build"]
|
# server.js wraps the adapter-bun build: same server, plus /ws/<channel> WebSockets and the
|
||||||
|
# PUBG map tiles (/pubg-tiles from the PUBG_TILES_ROOT bind mount, see scripts/deploy-remote.sh).
|
||||||
|
CMD ["bun", "./server.js"]
|
||||||
|
|||||||
241
README.md
241
README.md
@@ -227,33 +227,58 @@ 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`,
|
||||||
|
`animaldex`, `site`, `meta` (Drizzle-managed) and `lists` (one real table per list,
|
||||||
|
created at runtime through the `meta.create_list` family of SQL functions, so it is
|
||||||
|
excluded from Drizzle via `schemaFilter`). See [Development](#development) for the
|
||||||
|
local database and migrations, and `db/reorg/README.md` for the one-time move of the
|
||||||
|
old flat schema to this layout. Catalog seeds (`npm run db:seed`, `db:seed:cod`, …)
|
||||||
|
upsert reference data and run on every production deploy.
|
||||||
|
|
||||||
```sh
|
## Book tracker
|
||||||
npm run db:migrate:auth
|
|
||||||
npm run db:migrate:foundation
|
|
||||||
npm run db:migrate:money-tracker
|
|
||||||
npm run db:migrate:game-tracker
|
|
||||||
npm run db:seed
|
|
||||||
npm run db:seed:cod
|
|
||||||
```
|
|
||||||
|
|
||||||
For a fresh database, run `npm run db:push` followed by both seeds.
|
`/trackers/books` is the public catalogue (covers, status, progress, stats). `/trackers/books/<id>`
|
||||||
The migrations are additive and repeatable; they do not reset tables. The
|
shows a book; the owner also sees files, notes, highlights and reading sessions there and can
|
||||||
game-tracker migration adds the `evolve`/`breed` availability values, the entry
|
edit status, rating and progress. `/trackers/books/<id>/read` is the owner-only reader:
|
||||||
`source` column (marking pre-existing hand-edited notes as `manual`), and the
|
EPUB, MOBI/AZW3 and FB2 open in [foliate-js](https://github.com/johnfactotum/foliate-js)
|
||||||
location, encounter, evolution and `tracker_*` tables. Unknown legacy OAuth
|
(vendored in `static/vendor/foliate-js`, no conversion), PDFs in pdf.js. The reader remembers the
|
||||||
providers cause the auth migration to fail for explicit review instead of
|
position per file, autosaves progress and sessions, and stores highlights with notes.
|
||||||
guessing an issuer. Back up a production database before any schema migration.
|
|
||||||
The scripts use `DB_URL` from `.env` via Bun.
|
|
||||||
|
|
||||||
## Money tracker
|
Files are never public. They are streamed (with Range support) by
|
||||||
|
`/trackers/books/files/<id>` to the owner only; everyone else gets a 404. The reader, progress
|
||||||
|
and notes endpoints are owner-only too (`src/lib/server/owner.ts`, backstopped in
|
||||||
|
`hooks.server.ts`).
|
||||||
|
|
||||||
`/projects/money-tracker` is restricted to administrator accounts. Its tables
|
Files are read from `BOOKS_ROOT` (default `/books` when it exists, otherwise `~/books`):
|
||||||
|
|
||||||
|
- `BOOKS_ROOT/library/<books.files.path>` for book files (written by `scripts/books-ingest.py`)
|
||||||
|
- `BOOKS_ROOT/covers/<books.works.cover_path>` for downloaded covers
|
||||||
|
|
||||||
|
In production, `scripts/deploy-remote.sh` writes `~deploy/compose.books.yml` (the deploy account may only sudo-tee `/opt/personal-website/.env`), which
|
||||||
|
bind-mounts the host's `BOOKS_HOST_DIR` (default `/home/stan/books`) read-only at `/books` and sets
|
||||||
|
`BOOKS_ROOT=/books`. Without a `library` directory there the deploy still succeeds, but files can't
|
||||||
|
be opened.
|
||||||
|
|
||||||
|
## Money
|
||||||
|
|
||||||
|
`/trackers/money` (formerly `/projects/money-tracker`, which now redirects) is restricted to administrator accounts. Its tables
|
||||||
are created by `npm run db:migrate:money-tracker`; apply that migration to each
|
are created by `npm run db:migrate:money-tracker`; apply that migration to each
|
||||||
environment before deploying the new route. The deployment preflight checks for
|
environment before deploying the new route. The deployment preflight checks for
|
||||||
all nine tracker tables and the projection columns.
|
all nine tracker tables and the projection columns.
|
||||||
|
|
||||||
|
`/trackers/money/documents` lists private documents (contracts, pension rules) with a
|
||||||
|
written summary, an inline viewer and a download. Nothing about them is in git: the
|
||||||
|
files and the summaries live on the host in `MONEY_DOCUMENTS_ROOT` (default
|
||||||
|
`/money-documents` when it exists, otherwise `~/money-documents`) as
|
||||||
|
`documents.json` (`[{ id, title, category, date, pages, file, summary }]`, summary in
|
||||||
|
Markdown) plus `files/<file>`. `scripts/deploy-remote.sh` bind-mounts
|
||||||
|
`/home/stan/money-documents` read-only at `/money-documents` when it has a
|
||||||
|
`documents.json`. Files are only streamed to admins via
|
||||||
|
`/trackers/money/documents/<id>/file` (401 logged out, 403 for non-admins,
|
||||||
|
`private, no-store`). To add one, copy the file into `files/` and add a manifest entry;
|
||||||
|
no redeploy is needed.
|
||||||
|
|
||||||
Transactions can be entered manually or imported from a CSV or tab-separated
|
Transactions can be entered manually or imported from a CSV or tab-separated
|
||||||
export with `date,account,description,amount` headers. Dates accept
|
export with `date,account,description,amount` headers. Dates accept
|
||||||
`YYYY-MM-DD`, `DD/MM/YYYY`, or `Fri 1 Aug 2025`; amounts accept signed euros
|
`YYYY-MM-DD`, `DD/MM/YYYY`, or `Fri 1 Aug 2025`; amounts accept signed euros
|
||||||
@@ -317,112 +342,109 @@ 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
|
## Notes
|
||||||
|
|
||||||
Use `staging` as the working branch and set it as the repository's default branch
|
`/trackers/notes` (owner-only, always: every page, API, attachment and the `/ws/notes` socket 404s
|
||||||
in Gitea after the first push. A direct push to `staging` runs unit tests, builds
|
for anyone else) is a web editor for Stan's Obsidian vault, two-way and near-real-time.
|
||||||
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
|
- **Source of truth:** the markdown files in `~/obsidian-vault` on the server (Syncthing folder
|
||||||
the production build, so a failed test never produces a deployable image. The
|
`obsidian-vault`, synced to his devices), bind-mounted read-write at `/notes` by
|
||||||
production button verifies the exact tested staging image before switching
|
`scripts/deploy-remote.sh` (`NOTES_HOST_DIR`). The site reads and writes `.md` files directly:
|
||||||
production. Browser/database tests need a
|
atomic writes (hidden `.notes-tmp-*` file in the same folder, fsync, rename), chowned to the
|
||||||
dedicated test database and are not part of this gate yet. `npm run check` and
|
vault owner, frontmatter and everything else kept byte-for-byte. Deleting moves the file to
|
||||||
full lint are also not gates while the repository has existing unrelated
|
`<vault>/.trash` like Obsidian does.
|
||||||
diagnostics; add them after those are fixed. A failed test, build, or preflight
|
- **Index only:** `notes.files` / `notes.links` (full-text search, tags, wikilinks, backlinks) are
|
||||||
prevents the switch; a failed post-deploy health check attempts to restore the
|
rebuilt from the files by `src/lib/server/notes/indexer.ts`: recursive `fs.watch` plus a full
|
||||||
previous image tag. Deployment never migrates or seeds either database. The
|
rescan every minute. Dropping the tables loses nothing.
|
||||||
Gitea runner needs Docker CLI/socket access and the existing `DEPLOY_KEY` secret.
|
- **Live:** file changes (from Obsidian via Syncthing, or the site) are pushed to open pages over
|
||||||
|
`wss://stanrunge.dev/ws/notes` (paths and hashes only, never content). Saves carry the hash the
|
||||||
|
editor loaded; if the file changed in between the save is refused (409) and the page offers
|
||||||
|
load theirs / overwrite / save a copy.
|
||||||
|
- **Ignored:** anything under a dot entry (`.obsidian`, `.trash`, `.stfolder`, `.stversions`,
|
||||||
|
`.DS_Store`, Syncthing temps). `*.sync-conflict-*` files are listed as warnings, not indexed.
|
||||||
|
- Syncthing should ignore the site's write temps: `(?d).notes-tmp-*` in the vault's `.stignore`.
|
||||||
|
|
||||||
Before either push, configure the existing private environment file on that
|
## Development
|
||||||
host. Staging uses `/etc/personal-website-staging/staging.env`; production uses
|
|
||||||
`/etc/personal-website/prod.env`. Each file needs `DB_URL`, a unique random
|
There are two environments: local dev and production. Prod is the source of truth
|
||||||
`BETTER_AUTH_SECRET` of at least 32 characters, and both `ORIGIN` and
|
for data; the dev database is a disposable copy of it.
|
||||||
`BETTER_AUTH_URL` set exactly to its public HTTPS origin. Add the Google and
|
|
||||||
osu! client ID/secret pairs for whichever sign-in buttons should be enabled.
|
```sh
|
||||||
List and Animaldex picture uploads reuse the existing R2 bucket; set the five
|
cp .env.example .env # DB_URL (app role) + DB_MIGRATE_URL (owner role) -> local personal_dev
|
||||||
`R2_*` values from `.env.example`. The app server uploads images to R2, so the
|
scripts/db-refresh-dev.sh # fresh prod dump -> personal_dev (sessions cleared)
|
||||||
bucket does not need browser CORS rules for these forms. Each image is limited
|
bun run db:migrate # apply this branch's pending Drizzle migrations
|
||||||
to 5 MB; the container sets `BODY_SIZE_LIMIT=6M` for multipart requests.
|
bun run dev
|
||||||
Their provider console callback URLs must use that environment's origin:
|
```
|
||||||
|
|
||||||
|
`db-refresh-dev.sh` reads `PROD_DB_URL` (a role that can read everything, e.g.
|
||||||
|
`personal`) and `DEV_ADMIN_URL` (superuser on the dev cluster) from the environment
|
||||||
|
or `.env.dev-refresh` (git-ignored, never commit it). It refuses to overwrite the
|
||||||
|
source database or any database named `personal`. `PG_DOCKER=1` uses the
|
||||||
|
`postgres:17-alpine` image when local `pg_dump` 17 isn't installed.
|
||||||
|
|
||||||
|
Schema changes: edit `src/lib/server/db/schema/`, run `bun run db:generate --name
|
||||||
|
<change>`, review the SQL in `drizzle/`, then `bun run db:migrate`. Never edit an
|
||||||
|
applied migration; never use `db:push` against prod.
|
||||||
|
|
||||||
|
`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 +453,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
|
||||||
```
|
```
|
||||||
|
|||||||
34
bun.lock
34
bun.lock
@@ -14,9 +14,12 @@
|
|||||||
"drizzle-orm": "^1.0.0-rc.4",
|
"drizzle-orm": "^1.0.0-rc.4",
|
||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
"highlight.js": "^11.12.0",
|
"highlight.js": "^11.12.0",
|
||||||
|
"jose": "^6.2.10",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"marked": "^18.0.11",
|
"marked": "^18.0.11",
|
||||||
"marked-highlight": "^2.2.4",
|
"marked-highlight": "^2.2.4",
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
|
"pdfjs-dist": "^6.4.299",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"topojson-client": "^3.1.0",
|
"topojson-client": "^3.1.0",
|
||||||
"world-atlas": "^2.0.2",
|
"world-atlas": "^2.0.2",
|
||||||
@@ -37,6 +40,7 @@
|
|||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@types/d3-geo": "^3.1.1",
|
"@types/d3-geo": "^3.1.1",
|
||||||
"@types/eslint": "9.6.1",
|
"@types/eslint": "9.6.1",
|
||||||
|
"@types/leaflet": "^1.9.22",
|
||||||
"@types/pg": "^8.23.1",
|
"@types/pg": "^8.23.1",
|
||||||
"@types/topojson-client": "^3.1.5",
|
"@types/topojson-client": "^3.1.5",
|
||||||
"@typescript/native": "npm:typescript@7",
|
"@typescript/native": "npm:typescript@7",
|
||||||
@@ -325,6 +329,30 @@
|
|||||||
|
|
||||||
"@melt-ui/svelte": ["@melt-ui/svelte@0.76.2", "", { "dependencies": { "@floating-ui/core": "^1.3.1", "@floating-ui/dom": "^1.4.5", "@internationalized/date": "^3.5.0", "dequal": "^2.0.3", "focus-trap": "^7.5.2", "nanoid": "^5.0.4" }, "peerDependencies": { "svelte": ">=3 <5" } }, "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA=="],
|
"@melt-ui/svelte": ["@melt-ui/svelte@0.76.2", "", { "dependencies": { "@floating-ui/core": "^1.3.1", "@floating-ui/dom": "^1.4.5", "@internationalized/date": "^3.5.0", "dequal": "^2.0.3", "focus-trap": "^7.5.2", "nanoid": "^5.0.4" }, "peerDependencies": { "svelte": ">=3 <5" } }, "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas": ["@napi-rs/canvas@1.0.13", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.13", "@napi-rs/canvas-darwin-arm64": "1.0.13", "@napi-rs/canvas-darwin-x64": "1.0.13", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.13", "@napi-rs/canvas-linux-arm64-gnu": "1.0.13", "@napi-rs/canvas-linux-arm64-musl": "1.0.13", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.13", "@napi-rs/canvas-linux-x64-gnu": "1.0.13", "@napi-rs/canvas-linux-x64-musl": "1.0.13", "@napi-rs/canvas-win32-arm64-msvc": "1.0.13", "@napi-rs/canvas-win32-x64-msvc": "1.0.13" } }, "sha512-b87ugZpQ5ViwqVBkjervxpwa0GmJPsKHLBNoZN6GpjWeHzTbDUGryriq+2DYW2XbB+xCqSIINQysC0/I87nPbw=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.13", "", { "os": "android", "cpu": "arm64" }, "sha512-trn7AaDzQ+yeeA0ezhkC7cB1ZDPtp4UYk0EvB5F8qtYL9jEVWtVrU30WOpI52DszoTxaTsCgf5PP4ZVaOlOIHQ=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6Y2djEetL8copxfNK6hg7oig/xqTwOchv19hBOm+zBNdKc/02Z3kjZN5wrLzv/l5O04/bAQS6I/AlQpwBlXgPg=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-OZoPwwu4TMMg8GTCFBrg8UFExwQTU/55i3uTC/izDat6PHE+ro9dimyQoOboFf91+NY7Pnn70ScR8f8I8+eZXw=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.13", "", { "os": "linux", "cpu": "arm" }, "sha512-dZSYaKROuswU2eUWG5aBKndNVe68elo1YX93xtRax7q658tj8rYE+HwduovWJgs6qwv1r1/CLR60nDeBbyjvyg=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-W6FzYE8ADHI93In1vi80KBL6btDtAMuj50Ch5ptkWS8xuntyzbR4h5JDUmXoakxZn2yMInksudTEqVSWoFqFGg=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-+6fQ+rR49hkAa4voh2stLIwQ+4t+gmoseM8Bc53/DlMXb73IKymDPWK9A/NtWeqTW4ijWppYuyuxS2hPI8tzNA=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.13", "", { "os": "linux", "cpu": "none" }, "sha512-17XEMO3t9RW/TtHtSFf2AkaSWuCWdzbPeidz7zrOFawHxoLSJCnpTI1hN5aUvrZNnkNNmt/vZVJCh0ryUaik3A=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.13", "", { "os": "linux", "cpu": "x64" }, "sha512-lc48IwknVHBsNzXn9Xs5OkUsRX38Ml3Z1aOybQ51mMEzZckm8+4A0qROlyYHEoBPzr1kL3IU8ZUAqDb7wtdX8w=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.13", "", { "os": "linux", "cpu": "x64" }, "sha512-bGY1r26S2bs8UNFJcPWcCSNdCFbKep5PATsvtgtC9A5u6SRgr2/jJE/RlhHL5m9ZpixNDhVkLY/5h16Kx5V9YA=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-2vRPBZoaIvJrUkDhZTE2Hn0YrnpJRCWVM3EJACoBKjuWI6WgPxGtUoRMsxRviCsyks8bKTSFdyw14CellnG2qA=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.13", "", { "os": "win32", "cpu": "x64" }, "sha512-DUlW3lZPz63kYsf8mxgyyZuevhlH0T3/BGP9l+NazhB0F1+gL6D4hqZdnhZpw24DlWmTpATDcyYSZlJOi+DdfQ=="],
|
||||||
|
|
||||||
"@noble/ciphers": ["@noble/ciphers@2.4.0", "", {}, "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw=="],
|
"@noble/ciphers": ["@noble/ciphers@2.4.0", "", {}, "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw=="],
|
||||||
|
|
||||||
"@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="],
|
"@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="],
|
||||||
@@ -447,6 +475,8 @@
|
|||||||
|
|
||||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
|
|
||||||
|
"@types/leaflet": ["@types/leaflet@1.9.22", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
|
"@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
|
||||||
|
|
||||||
"@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="],
|
"@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="],
|
||||||
@@ -745,6 +775,8 @@
|
|||||||
|
|
||||||
"kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="],
|
"kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="],
|
||||||
|
|
||||||
|
"leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
|
||||||
|
|
||||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||||
|
|
||||||
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
||||||
@@ -837,6 +869,8 @@
|
|||||||
|
|
||||||
"pbf": ["pbf@4.0.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": "bin/pbf" }, "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg=="],
|
"pbf": ["pbf@4.0.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": "bin/pbf" }, "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg=="],
|
||||||
|
|
||||||
|
"pdfjs-dist": ["pdfjs-dist@6.4.299", "", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.10" } }, "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g=="],
|
||||||
|
|
||||||
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
|
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
|
||||||
|
|
||||||
"pg-protocol": ["pg-protocol@1.16.0", "", {}, "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg=="],
|
"pg-protocol": ["pg-protocol@1.16.0", "", {}, "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg=="],
|
||||||
|
|||||||
643
data/tasks/daisy-import.json
Normal file
643
data/tasks/daisy-import.json
Normal file
@@ -0,0 +1,643 @@
|
|||||||
|
{
|
||||||
|
"source": "daisy-import",
|
||||||
|
"areas": [
|
||||||
|
{
|
||||||
|
"slug": "deadlines",
|
||||||
|
"name": "Deadlines and dates",
|
||||||
|
"sortOrder": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "house",
|
||||||
|
"name": "House and moving",
|
||||||
|
"description": "Jan–Feb 2027 with Juhee",
|
||||||
|
"sortOrder": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "kibi",
|
||||||
|
"name": "Kibi",
|
||||||
|
"sortOrder": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "admin",
|
||||||
|
"name": "Phone, subscriptions and admin",
|
||||||
|
"sortOrder": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "clients",
|
||||||
|
"name": "Clients",
|
||||||
|
"sortOrder": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "website",
|
||||||
|
"name": "Website (stanrunge.dev)",
|
||||||
|
"sortOrder": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "setup",
|
||||||
|
"name": "Setup and security",
|
||||||
|
"description": "Perma evolvable setup, before 2027",
|
||||||
|
"sortOrder": 70
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "home",
|
||||||
|
"name": "Home",
|
||||||
|
"sortOrder": 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "health",
|
||||||
|
"name": "Health and self-care",
|
||||||
|
"sortOrder": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "reading",
|
||||||
|
"name": "Reading",
|
||||||
|
"sortOrder": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "planning",
|
||||||
|
"name": "Planning and goals",
|
||||||
|
"sortOrder": 110
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"goals": [
|
||||||
|
{
|
||||||
|
"title": "€10k net/month into own Revolut by 2030",
|
||||||
|
"area": "planning",
|
||||||
|
"targetDate": "2030-01-01",
|
||||||
|
"metric": "Net income per month",
|
||||||
|
"metricUnit": "EUR",
|
||||||
|
"metricTarget": 10000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Own a really nice Ford Mustang GT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "PC overhaul",
|
||||||
|
"notes": "Top AMD X3D CPU, RTX 5090.",
|
||||||
|
"metric": "Budget",
|
||||||
|
"metricUnit": "EUR",
|
||||||
|
"metricTarget": 10000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Organise goals into a plan",
|
||||||
|
"area": "planning",
|
||||||
|
"notes": "Social media, Grok Bot, IT company."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"area": "setup",
|
||||||
|
"targetDate": "2026-12-31"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Get in shape long term",
|
||||||
|
"area": "health"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"area": "deadlines",
|
||||||
|
"title": "Jan Loman printer visit",
|
||||||
|
"status": "done",
|
||||||
|
"notes": "Settled.",
|
||||||
|
"due": "2026-10-24",
|
||||||
|
"dueTime": "14:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "deadlines",
|
||||||
|
"title": "Call BackHome about Kibi's chip",
|
||||||
|
"status": "todo",
|
||||||
|
"due": "2026-10-26",
|
||||||
|
"dueTime": "10:00",
|
||||||
|
"tags": ["kibi", "call"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "deadlines",
|
||||||
|
"title": "Cargill collective health insurance (OHRA, code 114)",
|
||||||
|
"status": "todo",
|
||||||
|
"notes": "Needs current insurer + premium to compare.",
|
||||||
|
"due": "2026-12-31",
|
||||||
|
"tags": ["insurance"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "deadlines",
|
||||||
|
"title": "Reply to mom about insurance info",
|
||||||
|
"status": "todo",
|
||||||
|
"notes": "Waiting since Oct 9.",
|
||||||
|
"tags": ["insurance"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "deadlines",
|
||||||
|
"title": "Stop zorgtoeslag from Jan 2027",
|
||||||
|
"status": "todo",
|
||||||
|
"due": "2027-01-01",
|
||||||
|
"reminderAt": "2026-12-07T09:00:00+01:00",
|
||||||
|
"tags": ["admin"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "deadlines",
|
||||||
|
"title": "Supermarket product list",
|
||||||
|
"status": "todo",
|
||||||
|
"reminderAt": "2026-10-24T11:50:00+02:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "deadlines",
|
||||||
|
"title": "Book haircut",
|
||||||
|
"status": "todo",
|
||||||
|
"notes": "Around Nov 14.",
|
||||||
|
"due": "2026-11-14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "house",
|
||||||
|
"title": "House overview",
|
||||||
|
"status": "in_progress",
|
||||||
|
"flagged": true,
|
||||||
|
"notes": "Applied only to Graaf Hendrikstraat 16 so far."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "house",
|
||||||
|
"title": "React to house options",
|
||||||
|
"status": "todo",
|
||||||
|
"notes": "Raam €1,475; Burg. van Dijkesingel 214-B; Groenhovenweg 161; Spoorstraat €795; Noothoven van Goorstraat 1-206."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "house",
|
||||||
|
"title": "Tell Daisy budget, move date, must-haves, Funda/Pararius applications",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "house",
|
||||||
|
"title": "Mijndak Midden-Holland sign-up",
|
||||||
|
"status": "todo",
|
||||||
|
"flagged": true,
|
||||||
|
"notes": "€23 option."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "house",
|
||||||
|
"title": "Sign up: socialehuurwoning.nl, Vesteda Goudshof, VBT, Viramah, agency Jesse's mom uses",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "house",
|
||||||
|
"title": "House tracker/scraper on the site",
|
||||||
|
"status": "todo",
|
||||||
|
"tags": ["website"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "house",
|
||||||
|
"title": "Update address everywhere",
|
||||||
|
"status": "todo",
|
||||||
|
"notes": "Checklist ready."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "house",
|
||||||
|
"title": "UniFi network for the new place",
|
||||||
|
"status": "todo",
|
||||||
|
"tags": ["network"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "kibi",
|
||||||
|
"title": "Food ordered",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "kibi",
|
||||||
|
"title": "Chip scanner ordered",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "kibi",
|
||||||
|
"title": "Chip number found",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "kibi",
|
||||||
|
"title": "Chip owner change at BackHome",
|
||||||
|
"status": "todo",
|
||||||
|
"notes": "Still 'Kwiebus' under E Zwart."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "kibi",
|
||||||
|
"title": "Call vet about Kibi's patient card",
|
||||||
|
"status": "todo",
|
||||||
|
"tags": ["call"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "kibi",
|
||||||
|
"title": "GPS tracker",
|
||||||
|
"status": "todo",
|
||||||
|
"notes": "Dad may have done it already."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "kibi",
|
||||||
|
"title": "Dash KIBI",
|
||||||
|
"status": "todo",
|
||||||
|
"flagged": true,
|
||||||
|
"notes": "Meaning unclear, ask Stan."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "admin",
|
||||||
|
"title": "Basic-Fit cancelled",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "admin",
|
||||||
|
"title": "Ohra inboedelverzekering cancelled",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "admin",
|
||||||
|
"title": "Zorgtoeslag income updated for 2026",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "admin",
|
||||||
|
"title": "Move number to Revolut eSIM, then iPad eSIM, cancel Odido",
|
||||||
|
"status": "in_progress"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "admin",
|
||||||
|
"title": "Move SIM + WhatsApp from iPhone to Pixel 11 Pro XL",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "admin",
|
||||||
|
"title": "Check Oasen and Delta Energie fully cancelled",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "admin",
|
||||||
|
"title": "Cancel other unused subscriptions",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "admin",
|
||||||
|
"title": "Merge banks into Revolut",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "clients",
|
||||||
|
"title": "Quote dack €450 for vet clinic site fix",
|
||||||
|
"status": "done",
|
||||||
|
"tags": ["dack"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "clients",
|
||||||
|
"title": "Reply to dack",
|
||||||
|
"status": "done",
|
||||||
|
"tags": ["dack"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "clients",
|
||||||
|
"title": "Cloudflare DNS/SSL for dack",
|
||||||
|
"status": "in_progress",
|
||||||
|
"tags": ["dack"],
|
||||||
|
"notes": "Grind-week goal."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "clients",
|
||||||
|
"title": "Send dack's invoice via Moneybird",
|
||||||
|
"status": "todo",
|
||||||
|
"tags": ["dack"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "clients",
|
||||||
|
"title": "Cheap shared WordPress hosting for dack's clients on own VMs",
|
||||||
|
"status": "todo",
|
||||||
|
"tags": ["dack"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "clients",
|
||||||
|
"title": "Karolina's Wix site, Master Purpose (NL/PL)",
|
||||||
|
"status": "todo",
|
||||||
|
"tags": ["karolina"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "clients",
|
||||||
|
"title": "Wix Studio + Velo/CLI so an AI can edit code",
|
||||||
|
"status": "todo",
|
||||||
|
"tags": ["karolina"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "DB reorganised into schemas, staging retired",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "Energy tracker (NED data) live",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "Health tracker (private) + 15 RIVM vaccinations",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "Book tracker, reader, 362 books live",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "Reader zoom, 2-page view, scroll/paging",
|
||||||
|
"status": "in_progress"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "Money tracker with MoneyMan",
|
||||||
|
"status": "in_progress"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "NED backfill",
|
||||||
|
"status": "in_progress"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "Music listening tracker + now-playing",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "Task/goal tracker to replace TickTick",
|
||||||
|
"status": "in_progress"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "Hardware DB (current PC parts first)",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "List of every organisation holding my data and what they know",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "Move to SvelteKit 3",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "Juhee website data",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "AWS cleanup ($0/month)",
|
||||||
|
"status": "done",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Google Cloud cleanup",
|
||||||
|
"status": "done",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "stan-vm hardened (ufw, Tailscale-only SSH)",
|
||||||
|
"status": "done",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Nightly restic backups to B2 + Storage Box",
|
||||||
|
"status": "done",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "WhatsApp bridge on stan-vm",
|
||||||
|
"status": "done",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "gcloud + moneybird-cli on stan-vm",
|
||||||
|
"status": "done",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Domains Squarespace to Cloudflare",
|
||||||
|
"status": "in_progress",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"notes": "Both transfers pending.",
|
||||||
|
"due": "2026-10-29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Syncthing: Introducer + Auto Accept on Arch and Windows",
|
||||||
|
"status": "in_progress",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Backup follow-up: restic passwords into 1Password",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"due": "2026-10-24",
|
||||||
|
"dueTime": "11:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Delete empty B2 bucket",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"due": "2026-10-25"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Rotate weak admin secret + DB password",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"tags": ["security"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "1Password overhaul",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"notes": "Imports, 2FA, passkeys, dedupe, delete unused accounts.",
|
||||||
|
"tags": ["security"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Moneybird API token, then clean up Moneybird",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Authentik SSO on stan-vm",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "vash-prod memory warnings + finish security audit",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"tags": ["security"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Remove old grok-bot devices from Tailscale",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Close empty GCP billing account; decide on AWS 'Vash Software'",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"notes": "AWS part optional."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Raise WSL RAM",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"notes": "Sees 8 of 16 GB."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Home server rebuild with ZFS",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Analytics, monitoring, alerts across devices",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Mouse sensitivity cm/360 across devices",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Buy Logitech G840 XL mousepad",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"tags": ["buy"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Router and networking setup",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"tags": ["network"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "J5 Hexagon research, then move wachtboden",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Agent-deck + AI/Linux dev setup",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "MITM/certificate lab",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"notes": "Idea.",
|
||||||
|
"tags": ["idea"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "home",
|
||||||
|
"title": "Unpack boxes; organise batteries and tools; buy thermometer",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "home",
|
||||||
|
"title": "Clean desk, laundry, clothes",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "home",
|
||||||
|
"title": "Connect vinyl player, printer, monitor, switch, osu! pad, tablet, fridge, microwave",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "home",
|
||||||
|
"title": "Hue lights + Home Assistant + Apple Home",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "home",
|
||||||
|
"title": "Fan controller",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "home",
|
||||||
|
"title": "Opa's stuff (WhatsApp, sensor install)",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "health",
|
||||||
|
"title": "Getting in shape with Doc",
|
||||||
|
"status": "in_progress",
|
||||||
|
"notes": "Lesson 1 done; ~2,300–2,400 kcal and 160–190 g protein a day.",
|
||||||
|
"goal": "Get in shape long term"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "health",
|
||||||
|
"title": "Join the gym my parents use",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Get in shape long term"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "health",
|
||||||
|
"title": "Brush teeth twice a day, shower, hair care",
|
||||||
|
"status": "todo",
|
||||||
|
"tags": ["habit"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "health",
|
||||||
|
"title": "Dentist checkup",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "health",
|
||||||
|
"title": "Pick hardware for Withings/Garmin syncs",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "health",
|
||||||
|
"title": "Curaçao vaccines",
|
||||||
|
"status": "todo",
|
||||||
|
"notes": "Via Doc."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "reading",
|
||||||
|
"title": "Read 'De meeste mensen deugen' (Bregman)",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "reading",
|
||||||
|
"title": "Re-download 'Haves and Have-Nots'",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "reading",
|
||||||
|
"title": "Book questions: duplicates, 6 unknown files, Nick's repo 404",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "planning",
|
||||||
|
"title": "Schedule sync (paper agenda, TickTick, Daisy)",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Organise goals into a plan"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
7
data/tasks/ticktick-merges.json
Normal file
7
data/tasks/ticktick-merges.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[
|
||||||
|
{ "ticktickId": "6ad52191ebcd7400000000ce", "title": "Jan Loman printer visit" },
|
||||||
|
{ "ticktickId": "690d2fd98c824268f297a676", "title": "Router and networking setup" },
|
||||||
|
{ "ticktickId": "6844769df471d103461fa9f1", "title": "Home server rebuild with ZFS" },
|
||||||
|
{ "ticktickId": "6ab6f32759b6090811e91a00", "title": "Dentist checkup" },
|
||||||
|
{ "ticktickId": "68225a8dbfe81104eac49a7a", "title": "Merge banks into Revolut" }
|
||||||
|
]
|
||||||
1274
data/worldtodo-seed.json
Normal file
1274
data/worldtodo-seed.json
Normal file
File diff suppressed because it is too large
Load Diff
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"
|
||||||
43
deploy/home-collector/README.md
Normal file
43
deploy/home-collector/README.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# home-collector
|
||||||
|
|
||||||
|
Feeds the private dashboard at `/start` (also the browser new-tab page). The app container has
|
||||||
|
no Docker socket and must not get one, so a small stdlib-only Python script runs on the host
|
||||||
|
every minute (systemd **user** timer `home-collector.timer`) and upserts two rows into
|
||||||
|
`home.snapshots`:
|
||||||
|
|
||||||
|
| key | contents |
|
||||||
|
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `host` | CPU %, load, uptime, RAM, swap, disk; every Docker container (state, health, restart count, CPU/mem via `sudo docker`); systemd timers (`stan-backup*`, `postgres-backup*`, `disk-guard-*`, `house-scrape`, `b2-cost`, `groceries-import`, `money-sync`, `ned-collector-*`) with last/next run and result; HTTP up/down + latency for each self-hosted service; latest `~/backups-cost/daily/*.json` |
|
||||||
|
| `ntfy` | the last 40 messages (72 h) from the ntfy topics in `~/.config/disk-guard.env` (`NTFY_URL`) and `~/.config/house-scrape.env` (`HOUSE_NTFY_URL`), plus `NTFY_EXTRA`. Only a label is stored, never the topic name. |
|
||||||
|
|
||||||
|
The page reads both rows in the same parallel query batch as tasks and links, so a slow
|
||||||
|
upstream never delays it; a snapshot older than 3 minutes is shown as stale.
|
||||||
|
|
||||||
|
## Live stats (1 s)
|
||||||
|
|
||||||
|
On top of the minute snapshot, `/start` opens a Server-Sent Events stream (`/start/live`,
|
||||||
|
owner only) while the tab is visible. The app reads netdata on the host
|
||||||
|
(`NETDATA_URL`, default `http://100.116.93.104:19999`, the Tailscale-only bind) once a second
|
||||||
|
through one shared poller (`src/lib/server/live-stats.ts`): host CPU, RAM, network, disk IO
|
||||||
|
and per-container CPU/mem/state/health (netdata's docker and cgroups collectors), so the app
|
||||||
|
still never touches the Docker socket. The poller stops 5 s after the last viewer leaves.
|
||||||
|
The app container reaches netdata through one ufw rule:
|
||||||
|
`ufw allow in on br-c1585f0009ac from 172.26.0.0/16 to 100.116.93.104 port 19999 proto tcp`
|
||||||
|
(the `backend` compose network's bridge).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. A login role that may only upsert snapshots (created once, cluster superuser):
|
||||||
|
`CREATE ROLE home_collector LOGIN CONNECTION LIMIT 3 PASSWORD '…'; GRANT CONNECT ON DATABASE personal TO home_collector;`
|
||||||
|
The `home` migration grants `USAGE` on the schema and `SELECT, INSERT, UPDATE` on
|
||||||
|
`home.snapshots` when the role exists (re-run those two GRANTs by hand if the role is created later).
|
||||||
|
2. `~/.config/home-collector.env` (mode 600):
|
||||||
|
```
|
||||||
|
HOME_DB_URL=postgresql://home_collector:…@127.0.0.1:5440/personal
|
||||||
|
# optional extra topics, e.g. the backup alerts from /etc/stan-backup:
|
||||||
|
# NTFY_EXTRA=backups=https://ntfy.sh/<topic>
|
||||||
|
```
|
||||||
|
3. `bash deploy/home-collector/install.sh` (copies the script to `~/.local/bin/home-collector`
|
||||||
|
and enables the timer). Re-run after editing the script, e.g. to add a service to `SERVICES`.
|
||||||
|
|
||||||
|
Debug: `python3 ~/.local/bin/home-collector --print`, `journalctl --user -u home-collector -n 20`.
|
||||||
347
deploy/home-collector/home-collector.py
Normal file
347
deploy/home-collector/home-collector.py
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Host-side collector for the /start dashboard on stanrunge.dev.
|
||||||
|
|
||||||
|
Runs every minute as a systemd user timer on the server (home-collector.timer) and upserts
|
||||||
|
two rows into home.snapshots with the limited `home_collector` role:
|
||||||
|
host CPU, RAM, swap, disk, load, uptime, Docker containers (state, health, restarts,
|
||||||
|
CPU/mem, compose project/service), systemd timer results (backups, disk-guard, scrapers) and service checks
|
||||||
|
ntfy the most recent messages from the owner's ntfy topics
|
||||||
|
|
||||||
|
The app container has no Docker socket and never gets one; this script uses the host's
|
||||||
|
passwordless `sudo docker`. ntfy topic names stay on the host: only a label is stored.
|
||||||
|
|
||||||
|
Config: ~/.config/home-collector.env (mode 600)
|
||||||
|
HOME_DB_URL=postgres://home_collector:...@127.0.0.1:5440/<db>
|
||||||
|
NTFY_EXTRA=label=https://ntfy.sh/topic,... (optional extra topics, e.g. backups)
|
||||||
|
Topics are also read from ~/.config/disk-guard.env (NTFY_URL) and
|
||||||
|
~/.config/house-scrape.env (HOUSE_NTFY_URL).
|
||||||
|
Stdlib only. `--print` prints the snapshot instead of writing it; HOME_DB_URL in the
|
||||||
|
environment overrides the env file (e.g. to fill the dev database).
|
||||||
|
"""
|
||||||
|
import concurrent.futures as cf
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
HOME = os.path.expanduser('~')
|
||||||
|
CFG = os.path.join(HOME, '.config')
|
||||||
|
TS = 'stan-ovh.tail665403.ts.net'
|
||||||
|
|
||||||
|
# (name, link shown on the dashboard, url checked from the host or None = link only)
|
||||||
|
SERVICES = [
|
||||||
|
('Website', 'https://stanrunge.dev', 'http://127.0.0.1:3000/login'),
|
||||||
|
('Gitea', 'https://git.stanrunge.dev', 'http://127.0.0.1:3010/'),
|
||||||
|
('Immich', 'https://photos.stanrunge.dev', 'http://127.0.0.1:2283/'),
|
||||||
|
('Umami', 'https://analytics.stanrunge.dev', 'http://127.0.0.1:3110/'),
|
||||||
|
('pgAdmin', 'https://pgadmin.stanrunge.dev', 'http://127.0.0.1:5050/misc/ping'),
|
||||||
|
('Vash esports', 'https://esports.vash.software', 'http://127.0.0.1:3100/'),
|
||||||
|
('Vash staging', 'https://esports-staging.vash.software', 'http://127.0.0.1:3101/'),
|
||||||
|
('Dozzle logs', 'https://logs.vash.software', 'http://127.0.0.1:3111/'),
|
||||||
|
('Beeriokart', 'https://beeriokart.stanrunge.dev', 'http://127.0.0.1:3003/'),
|
||||||
|
('Stuhee', 'https://stuhee.stanrunge.dev', 'http://127.0.0.1:3004/'),
|
||||||
|
('Dev site', 'https://dev.stanrunge.dev', 'http://127.0.0.1:3002/'),
|
||||||
|
('Netdata', f'http://{TS}:19999', 'http://127.0.0.1:19999/api/v1/info'),
|
||||||
|
('Portainer', f'https://{TS}:9443', 'https://127.0.0.1:9443/'),
|
||||||
|
('ntfy', 'https://ntfy.sh/app', 'https://ntfy.sh/v1/health'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# (label, unit, scope)
|
||||||
|
TIMERS = [
|
||||||
|
('Backup (restic → B2)', 'stan-backup', 'system'),
|
||||||
|
('Backup maintenance', 'stan-backup-maintain', 'system'),
|
||||||
|
('Postgres dump', 'postgres-backup', 'system'),
|
||||||
|
('Postgres dump prune', 'postgres-backup-prune', 'system'),
|
||||||
|
('Disk guard check', 'disk-guard-check', 'user'),
|
||||||
|
('Disk guard cleanup', 'disk-guard-cleanup', 'user'),
|
||||||
|
('House scrape', 'house-scrape', 'user'),
|
||||||
|
('B2 cost', 'b2-cost', 'user'),
|
||||||
|
('Groceries import', 'groceries-import', 'user'),
|
||||||
|
('Money sync', 'money-sync', 'user'),
|
||||||
|
('Energy (NED) hourly', 'ned-collector-hourly', 'user'),
|
||||||
|
('Energy (NED) daily', 'ned-collector-daily', 'user'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def read_env(path):
|
||||||
|
out = {}
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith('#') or '=' not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split('=', 1)
|
||||||
|
out[k.strip()] = v.strip().strip('"').strip("'")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd, timeout=15):
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||||
|
return r.stdout if r.returncode == 0 else ''
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def cpu_times():
|
||||||
|
with open('/proc/stat') as f:
|
||||||
|
parts = [int(x) for x in f.readline().split()[1:]]
|
||||||
|
idle = parts[3] + (parts[4] if len(parts) > 4 else 0)
|
||||||
|
return idle, sum(parts[:8])
|
||||||
|
|
||||||
|
|
||||||
|
def meminfo():
|
||||||
|
m = {}
|
||||||
|
with open('/proc/meminfo') as f:
|
||||||
|
for line in f:
|
||||||
|
k, v = line.split(':', 1)
|
||||||
|
m[k] = int(v.split()[0]) * 1024
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def system_stats():
|
||||||
|
i1, t1 = cpu_times()
|
||||||
|
time.sleep(1)
|
||||||
|
i2, t2 = cpu_times()
|
||||||
|
cpu = 100.0 * (1 - (i2 - i1) / max(1, t2 - t1))
|
||||||
|
m = meminfo()
|
||||||
|
with open('/proc/uptime') as f:
|
||||||
|
uptime = float(f.read().split()[0])
|
||||||
|
load = list(os.getloadavg())
|
||||||
|
disks = []
|
||||||
|
for mount in ('/',):
|
||||||
|
u = shutil.disk_usage(mount)
|
||||||
|
disks.append({'mount': mount, 'total': u.total, 'used': u.used, 'free': u.free,
|
||||||
|
'pct': round(100 * u.used / u.total, 1)})
|
||||||
|
return {
|
||||||
|
'hostname': socket.gethostname(),
|
||||||
|
'cpus': os.cpu_count(),
|
||||||
|
'cpuPct': round(cpu, 1),
|
||||||
|
'load': [round(x, 2) for x in load],
|
||||||
|
'uptimeS': int(uptime),
|
||||||
|
'mem': {'total': m['MemTotal'], 'available': m['MemAvailable'],
|
||||||
|
'used': m['MemTotal'] - m['MemAvailable']},
|
||||||
|
'swap': {'total': m.get('SwapTotal', 0), 'used': m.get('SwapTotal', 0) - m.get('SwapFree', 0)},
|
||||||
|
'disks': disks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def containers():
|
||||||
|
ids = run(['sudo', '-n', 'docker', 'ps', '-aq']).split()
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
info = json.loads(run(['sudo', '-n', 'docker', 'inspect', *ids]) or '[]')
|
||||||
|
except ValueError:
|
||||||
|
info = []
|
||||||
|
stats = {}
|
||||||
|
for line in run(['sudo', '-n', 'docker', 'stats', '--no-stream', '--format', '{{json .}}'], 20).splitlines():
|
||||||
|
try:
|
||||||
|
s = json.loads(line)
|
||||||
|
stats[s['Name']] = {'cpu': s.get('CPUPerc'), 'mem': s.get('MemUsage', '').split(' / ')[0]}
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
pass
|
||||||
|
out = []
|
||||||
|
for c in info:
|
||||||
|
st = c.get('State', {})
|
||||||
|
name = c.get('Name', '').lstrip('/')
|
||||||
|
labels = c.get('Config', {}).get('Labels') or {}
|
||||||
|
out.append({
|
||||||
|
'name': name,
|
||||||
|
'image': c.get('Config', {}).get('Image', '').split('@')[0][:80],
|
||||||
|
'state': st.get('Status'),
|
||||||
|
'health': (st.get('Health') or {}).get('Status'),
|
||||||
|
'restarts': c.get('RestartCount', 0),
|
||||||
|
'exitCode': st.get('ExitCode'),
|
||||||
|
'startedAt': st.get('StartedAt'),
|
||||||
|
'cpu': stats.get(name, {}).get('cpu'),
|
||||||
|
'mem': stats.get(name, {}).get('mem'),
|
||||||
|
# Compose project/service, so /start can group containers by stack.
|
||||||
|
'project': labels.get('com.docker.compose.project'),
|
||||||
|
'service': labels.get('com.docker.compose.service'),
|
||||||
|
})
|
||||||
|
out.sort(key=lambda c: c['name'])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def unit_props(unit, scope, props):
|
||||||
|
cmd = ['systemctl'] + (['--user'] if scope == 'user' else []) + [
|
||||||
|
'show', unit, '-p', ','.join(props)]
|
||||||
|
d = {}
|
||||||
|
for line in run(cmd).splitlines():
|
||||||
|
if '=' in line:
|
||||||
|
k, v = line.split('=', 1)
|
||||||
|
d[k] = v
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def list_timers(scope):
|
||||||
|
cmd = ['systemctl'] + (['--user'] if scope == 'user' else []) + ['list-timers', '--all', '-o', 'json']
|
||||||
|
try:
|
||||||
|
return {t['unit']: t for t in json.loads(run(cmd) or '[]')}
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def sec(usec):
|
||||||
|
try:
|
||||||
|
n = int(usec or 0)
|
||||||
|
return n // 1_000_000 if n > 0 else None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def timers():
|
||||||
|
lists = {'user': list_timers('user'), 'system': list_timers('system')}
|
||||||
|
out = []
|
||||||
|
for label, unit, scope in TIMERS + [('Home collector', 'home-collector', 'user')]:
|
||||||
|
t = lists[scope].get(unit + '.timer')
|
||||||
|
if not t:
|
||||||
|
continue
|
||||||
|
s = unit_props(unit + '.service', scope, ['ActiveState', 'Result', 'ExecMainStatus'])
|
||||||
|
out.append({
|
||||||
|
'label': label, 'unit': unit, 'scope': scope,
|
||||||
|
'last': sec(t.get('last')),
|
||||||
|
'next': sec(t.get('next')),
|
||||||
|
'running': s.get('ActiveState') in ('activating', 'active', 'deactivating'),
|
||||||
|
'result': s.get('Result'),
|
||||||
|
'exitStatus': int(s.get('ExecMainStatus') or 0),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_insecure = ssl.create_default_context()
|
||||||
|
_insecure.check_hostname = False
|
||||||
|
_insecure.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
|
|
||||||
|
def check(service):
|
||||||
|
name, link, url = service
|
||||||
|
res = {'name': name, 'url': link, 'ok': None, 'code': None, 'ms': None}
|
||||||
|
if not url:
|
||||||
|
return res
|
||||||
|
start = time.monotonic()
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={'User-Agent': 'home-collector'})
|
||||||
|
with urllib.request.urlopen(req, timeout=5, context=_insecure) as r:
|
||||||
|
r.read(256)
|
||||||
|
res['code'] = r.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
res['code'] = e.code
|
||||||
|
except Exception:
|
||||||
|
res['code'] = 0
|
||||||
|
res['ms'] = round((time.monotonic() - start) * 1000)
|
||||||
|
res['ok'] = bool(res['code']) and res['code'] < 500
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def services():
|
||||||
|
with cf.ThreadPoolExecutor(8) as ex:
|
||||||
|
return list(ex.map(check, SERVICES))
|
||||||
|
|
||||||
|
|
||||||
|
def b2_cost():
|
||||||
|
d = os.path.join(HOME, 'backups-cost', 'daily')
|
||||||
|
try:
|
||||||
|
files = sorted(f for f in os.listdir(d) if f.endswith('.json'))
|
||||||
|
if files:
|
||||||
|
with open(os.path.join(d, files[-1])) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ntfy_topics(cfg):
|
||||||
|
topics = []
|
||||||
|
for label, path, key in (('disk-guard', 'disk-guard.env', 'NTFY_URL'),
|
||||||
|
('houses', 'house-scrape.env', 'HOUSE_NTFY_URL')):
|
||||||
|
url = read_env(os.path.join(CFG, path)).get(key)
|
||||||
|
if url:
|
||||||
|
topics.append((label, url))
|
||||||
|
for item in (cfg.get('NTFY_EXTRA') or '').split(','):
|
||||||
|
if '=' in item:
|
||||||
|
label, url = item.split('=', 1)
|
||||||
|
topics.append((label.strip(), url.strip()))
|
||||||
|
return topics
|
||||||
|
|
||||||
|
|
||||||
|
def ntfy(cfg):
|
||||||
|
msgs, seen, errors = [], set(), []
|
||||||
|
for label, url in ntfy_topics(cfg):
|
||||||
|
if url in seen:
|
||||||
|
continue
|
||||||
|
seen.add(url)
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url.rstrip('/') + '/json?poll=1&since=72h',
|
||||||
|
headers={'User-Agent': 'home-collector'})
|
||||||
|
with urllib.request.urlopen(req, timeout=8) as r:
|
||||||
|
for line in r.read().decode('utf-8', 'replace').splitlines():
|
||||||
|
try:
|
||||||
|
m = json.loads(line)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if m.get('event') != 'message':
|
||||||
|
continue
|
||||||
|
msgs.append({'id': m.get('id'), 'topic': label, 'time': m.get('time'),
|
||||||
|
'title': (m.get('title') or '')[:200],
|
||||||
|
'message': (m.get('message') or '')[:600],
|
||||||
|
'priority': m.get('priority', 3), 'tags': m.get('tags') or [],
|
||||||
|
'click': m.get('click')})
|
||||||
|
except Exception:
|
||||||
|
errors.append(label)
|
||||||
|
msgs.sort(key=lambda m: m['time'] or 0, reverse=True)
|
||||||
|
return {'messages': msgs[:40], 'topics': [t for t, _ in ntfy_topics(cfg)], 'errors': errors}
|
||||||
|
|
||||||
|
|
||||||
|
def write(db_url, rows):
|
||||||
|
tag = 'j' + secrets.token_hex(8)
|
||||||
|
sql = ['BEGIN;']
|
||||||
|
for key, data in rows.items():
|
||||||
|
payload = json.dumps(data, separators=(',', ':'))
|
||||||
|
sql.append(
|
||||||
|
f"INSERT INTO home.snapshots (key, data, collected_at) VALUES ('{key}', ${tag}${payload}${tag}$::jsonb, now()) "
|
||||||
|
"ON CONFLICT (key) DO UPDATE SET data = excluded.data, collected_at = excluded.collected_at;")
|
||||||
|
sql.append('COMMIT;')
|
||||||
|
r = subprocess.run(['psql', '-X', '-q', '-v', 'ON_ERROR_STOP=1', db_url], input='\n'.join(sql),
|
||||||
|
capture_output=True, text=True, timeout=20)
|
||||||
|
if r.returncode != 0:
|
||||||
|
# psql errors never contain the password; the URL is not echoed.
|
||||||
|
sys.stderr.write(r.stderr[-500:])
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cfg = read_env(os.path.join(CFG, 'home-collector.env'))
|
||||||
|
with cf.ThreadPoolExecutor(4) as ex:
|
||||||
|
f_sys = ex.submit(system_stats)
|
||||||
|
f_ct = ex.submit(containers)
|
||||||
|
f_svc = ex.submit(services)
|
||||||
|
f_ntfy = ex.submit(ntfy, cfg)
|
||||||
|
host = f_sys.result()
|
||||||
|
host['containers'] = f_ct.result()
|
||||||
|
host['services'] = f_svc.result()
|
||||||
|
host['timers'] = timers()
|
||||||
|
host['b2'] = b2_cost()
|
||||||
|
notes = f_ntfy.result()
|
||||||
|
rows = {'host': host, 'ntfy': notes}
|
||||||
|
if '--print' in sys.argv:
|
||||||
|
print(json.dumps(rows, indent=1))
|
||||||
|
return
|
||||||
|
url = os.environ.get('HOME_DB_URL') or cfg.get('HOME_DB_URL')
|
||||||
|
if not url:
|
||||||
|
sys.exit('HOME_DB_URL missing in ~/.config/home-collector.env')
|
||||||
|
write(url, rows)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
11
deploy/home-collector/home-collector.service
Normal file
11
deploy/home-collector/home-collector.service
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Snapshot server stats, containers, timers and ntfy for stanrunge.dev/start
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
ConditionPathExists=%h/.config/home-collector.env
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/python3 %h/.local/bin/home-collector
|
||||||
|
TimeoutStartSec=50
|
||||||
|
Nice=10
|
||||||
10
deploy/home-collector/home-collector.timer
Normal file
10
deploy/home-collector/home-collector.timer
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run home-collector every minute (stanrunge.dev/start)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=30s
|
||||||
|
OnUnitActiveSec=60s
|
||||||
|
AccuracySec=5s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
12
deploy/home-collector/install.sh
Executable file
12
deploy/home-collector/install.sh
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install/refresh the /start collector as a systemd user timer (run as stan on the server).
|
||||||
|
# Needs ~/.config/home-collector.env with HOME_DB_URL for the home_collector role (see README).
|
||||||
|
set -euo pipefail
|
||||||
|
here="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
install -D -m 755 "$here/home-collector.py" "$HOME/.local/bin/home-collector"
|
||||||
|
install -D -m 644 "$here/home-collector.service" "$HOME/.config/systemd/user/home-collector.service"
|
||||||
|
install -D -m 644 "$here/home-collector.timer" "$HOME/.config/systemd/user/home-collector.timer"
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now home-collector.timer
|
||||||
|
systemctl --user start home-collector.service || true
|
||||||
|
systemctl --user --no-pager status home-collector.service | head -5
|
||||||
66
deploy/music-collector/README.md
Normal file
66
deploy/music-collector/README.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# music (self-hosted Last.fm)
|
||||||
|
|
||||||
|
`/trackers/music` (stats) and the "listening now" card on the homepage read the `music`
|
||||||
|
schema in the `personal` database:
|
||||||
|
|
||||||
|
| table / view | contents |
|
||||||
|
| ------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||||
|
| `artists`, `albums` | Spotify ids when known (imports are matched by name), album art URLs (640 px + small) |
|
||||||
|
| `tracks`, `track_artists` | `match_key` = normalised `artist\|title` (`src/lib/music/normalize.ts`) for cross-source dedupe |
|
||||||
|
| `plays` | one row per play: `source` spotify / lastfm / youtube / local, `origin` api / export / import |
|
||||||
|
| `plays_deduped` (view) | `plays` minus Last.fm scrobbles that Spotify also has (what every stat reads) |
|
||||||
|
| `now_playing` | row 1, refreshed every minute |
|
||||||
|
| `spotify_auth` | row 1, refresh token + poller state. **Not** granted to `personal_ro` |
|
||||||
|
| `import_state` | resumable backfill progress (`lastfm:<user>`) |
|
||||||
|
|
||||||
|
## Spotify (live)
|
||||||
|
|
||||||
|
1. In the Spotify developer dashboard (the existing app, `SPOTIFY_CLIENT_ID`) add the redirect
|
||||||
|
URIs `https://stanrunge.dev/api/spotify/callback` and
|
||||||
|
`https://dev.stanrunge.dev/api/spotify/callback`.
|
||||||
|
2. Logged in as the owner, open <https://stanrunge.dev/api/spotify/connect> once. Scopes:
|
||||||
|
`user-read-currently-playing user-read-recently-played user-read-playback-state user-top-read user-library-read playlist-read-private`.
|
||||||
|
3. That's it: the **app itself** polls every minute (`src/lib/server/music/poller.ts`, started
|
||||||
|
from `hooks.server.ts` `init`) with its own role and Spotify credentials. Status (last poll,
|
||||||
|
last error) is shown to the owner on `/trackers/music`.
|
||||||
|
|
||||||
|
### Optional: poll from the host instead
|
||||||
|
|
||||||
|
Only if the poller should keep running independent of the app container. Needs its own role:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo docker exec -i postgres-db-1 psql -U postgres -d personal <<'SQL'
|
||||||
|
CREATE ROLE music_collector LOGIN CONNECTION LIMIT 3 PASSWORD '<pick one>';
|
||||||
|
GRANT CONNECT ON DATABASE personal TO music_collector;
|
||||||
|
GRANT USAGE ON SCHEMA music TO music_collector;
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON music.artists, music.albums, music.tracks, music.track_artists,
|
||||||
|
music.plays, music.now_playing, music.import_state TO music_collector;
|
||||||
|
GRANT SELECT, UPDATE ON music.spotify_auth TO music_collector;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA music TO music_collector;
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
`~/.config/music-collector.env` (mode 600): `MUSIC_DB_URL=postgresql://music_collector:…@127.0.0.1:5440/personal`,
|
||||||
|
`SPOTIFY_CLIENT_ID=…`, `SPOTIFY_CLIENT_SECRET=…`. Then `bash deploy/music-collector/install.sh --enable`
|
||||||
|
and set `MUSIC_POLLER=off` in prod.env. (The music migration grants the same when the role
|
||||||
|
already exists.) Debug: `journalctl --user -u music-collector -n 20`.
|
||||||
|
|
||||||
|
## Backfills
|
||||||
|
|
||||||
|
Both run from the deployed image with the app role, so no extra credentials:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tag=$(sudo sed -n 's/^IMAGE_TAG=//p' /opt/personal-website/.env)
|
||||||
|
# Last.fm (needs an API key from https://www.last.fm/api/account/create); resumable, re-run any time
|
||||||
|
sudo docker run --rm --network backend --env-file /etc/personal-website/prod.env \
|
||||||
|
-e LASTFM_API_KEY=<key> git.stanrunge.dev/stan/website:$tag bun scripts/music-import-lastfm.ts --user stanrunge
|
||||||
|
# Spotify Extended Streaming History (unzipped export in ~/spotify-export)
|
||||||
|
sudo docker run --rm --network backend --env-file /etc/personal-website/prod.env \
|
||||||
|
-v ~/spotify-export:/data:ro git.stanrunge.dev/stan/website:$tag bun scripts/music-import-history.ts /data
|
||||||
|
```
|
||||||
|
|
||||||
|
Dedupe: within one source a play within 60 s of the same `match_key` is skipped on insert;
|
||||||
|
across sources `plays_deduped` hides a Last.fm scrobble when a Spotify play of the same
|
||||||
|
`match_key` ended between 3 min before and track length + 3 min after the scrobble time
|
||||||
|
(Last.fm records when a track started, Spotify when it ended). Listening time uses
|
||||||
|
`ms_played` when known, else the track length.
|
||||||
17
deploy/music-collector/install.sh
Executable file
17
deploy/music-collector/install.sh
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install the optional host-side Spotify poller as a systemd user timer (run as stan on the
|
||||||
|
# server). Copies the units; enables the timer only with --enable (needs
|
||||||
|
# ~/.config/music-collector.env, see README.md). Normally not needed: the app polls itself.
|
||||||
|
set -euo pipefail
|
||||||
|
here="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
install -D -m 644 "$here/music-collector.service" "$HOME/.config/systemd/user/music-collector.service"
|
||||||
|
install -D -m 644 "$here/music-collector.timer" "$HOME/.config/systemd/user/music-collector.timer"
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
if [ "${1:-}" = --enable ]; then
|
||||||
|
test -f "$HOME/.config/music-collector.env" || { echo 'Missing ~/.config/music-collector.env' >&2; exit 1; }
|
||||||
|
systemctl --user enable --now music-collector.timer
|
||||||
|
systemctl --user start music-collector.service || true
|
||||||
|
systemctl --user --no-pager status music-collector.service | head -5
|
||||||
|
else
|
||||||
|
echo 'Installed (not enabled). Enable with: bash deploy/music-collector/install.sh --enable'
|
||||||
|
fi
|
||||||
15
deploy/music-collector/music-collector.service
Normal file
15
deploy/music-collector/music-collector.service
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Spotify scrobbler poll (now playing + recently played -> music.*) for stanrunge.dev/trackers/music
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
ConditionPathExists=%h/.config/music-collector.env
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# A checkout of stan/website on main. Not the live dev tree.
|
||||||
|
WorkingDirectory=%h/dev/website-music
|
||||||
|
# MUSIC_DB_URL (music_collector role), SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET; mode 600.
|
||||||
|
EnvironmentFile=%h/.config/music-collector.env
|
||||||
|
ExecStart=/usr/bin/bun scripts/music-poll.ts
|
||||||
|
TimeoutStartSec=50
|
||||||
|
Nice=10
|
||||||
10
deploy/music-collector/music-collector.timer
Normal file
10
deploy/music-collector/music-collector.timer
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Spotify scrobbler poll every minute (optional; the app polls by itself)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=45s
|
||||||
|
OnUnitActiveSec=60s
|
||||||
|
AccuracySec=5s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
49
deploy/stats-collector/README.md
Normal file
49
deploy/stats-collector/README.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# stats collector
|
||||||
|
|
||||||
|
Host-side collector for the public [/stats](https://stanrunge.dev/stats) section. It pulls open
|
||||||
|
statistics into the `stats` schema with `scripts/stats-fetch.ts`:
|
||||||
|
|
||||||
|
| source | timer | schedule (Europe/Amsterdam) | what |
|
||||||
|
| ---------------- | ---------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `cbs` | `stats-cbs.timer` | nightly 05:40 | CBS StatLine OData v4: checks every table's `Modified`, re-pulls changed ones, flags discontinued tables (base-year swaps) |
|
||||||
|
| `eurostat` | `stats-eurostat.timer` | Mondays 06:20 | Eurostat JSON-stat datasets (energy prices, yields, HICP index 2015 = 100 + annual rates by ECOICOP 2 category (`prc_hicp_minr`), government finance…) |
|
||||||
|
| `fred` | `stats-fred.timer` | daily 07:20 | FRED graph CSV (Brent, TTF, Fed, Treasuries, US energy prices, indices, US CPI by category) |
|
||||||
|
| `bis` | `stats-bis.timer` | daily 07:50 | BIS central bank policy rates (monthly + daily) and long CPI series `WS_LONG_CPI` (index 2010 = 100) for the core-7 and comparison countries |
|
||||||
|
| `worldbank` | `stats-worldbank.timer` | Mondays 04:15 | World Bank WDI indicators for the compared countries |
|
||||||
|
| `oecd` | `stats-oecd.timer` | Thursdays 06:35 | OECD Data Explorer SDMX CSV (no key): CPI index by category for Korea, Japan (ends 2021-06) and China (total + food), 10-year government bond yields |
|
||||||
|
| `statcan` | `stats-statcan.timer` | daily 16:10 | Statistics Canada WDS table 18-10-0004-01: CPI all items, food, shelter, energy, core (2002 = 100) |
|
||||||
|
| `imf` | `stats-imf.timer` | 1st of the month 04:05 | IMF WEO (DataMapper), stored per WEO vintage with forecast flags |
|
||||||
|
| `owid` | `stats-owid.timer` | 2nd of the month 04:35 | Our World in Data: Maddison GDP per capita, energy dataset |
|
||||||
|
| `energycharts` | `stats-energycharts.timer` | hourly at :19 | Energy-Charts NL day-ahead prices + cross-border flows (last 3 days; `--backfill` from 2015) |
|
||||||
|
| `rijksfinancien` | `stats-rijksfinancien.timer` | 3rd of the month 05:05 | Rijksfinanciën budget tables, last year to next year (`--backfill` from 2015) |
|
||||||
|
| `ustreasury` | `stats-ustreasury.timer` | daily 23:25 | US Treasury MTS receipts/outlays + Debt to the Penny (last 400 days; `--backfill` all) |
|
||||||
|
| `usaspending` | `stats-usaspending.timer` | Sundays 06:10 | USAspending budgetary resources per agency and fiscal year |
|
||||||
|
| `eponline` | `stats-eponline.timer` | 3rd of the month 05:20 | EP-Online monthly total file (RVO energy labels): homes per label for NL, Gouda and Gouda PC4s; aggregates only. Needs `EP_ONLINE_API_KEY` in `~/.config/stats/ep-online.env` (mode 600), which the runner exports for this source only |
|
||||||
|
| `elections` | `stats-elections.timer` | 15th of the month 04:50 | A14: Kiesraad Tweede Kamer results 2010→ (data.overheid.nl CSV + EML zips read with HTTP ranges; national, municipality, Gouda polling stations), CBS 37278 (1918–2006), MIT US presidential by state. Finished elections are skipped unless `--force` |
|
||||||
|
| `parties` | `stats-parties.timer` | quarterly, 16th 04:40 | A15: CHES trend file + V-Party (RData via `~/.local/share/stats-py`, pyreadr) linked with Party Facts into `wo_party`/`wo_party_position` |
|
||||||
|
| `wikipolls` | `stats-wikipolls.timer` | daily 08:10 | A16: Wikipedia poll tables (NL seats, Trump approval, Lee Jae-myung approval, KR party polls); wikitext snapshot per revision |
|
||||||
|
| `gtrends` | `stats-gtrends.timer` | daily 21:45 | A16: Google Trends trending-now RSS for NL, US, KR |
|
||||||
|
| `gdelt` | `stats-gdelt.timer` | nightly 02:30 | A16: GDELT DOC timelinevol, 10 countries × 12 themes, last 30 days, ≥5.5 s between requests |
|
||||||
|
| `eurobarometer` | `stats-eurobarometer.timer` | 20th of the month 05:35 | A16: Standard Eurobarometer volume A xlsx (QA3/QA4/QA5 most important issues, trust in institutions); new waves only |
|
||||||
|
| `whr` | `stats-whr.timer` | 21st of the month 05:10 | A16: World Happiness Report Figure 2.1 (life evaluation and components, 2011→) |
|
||||||
|
| `cbscw` | `stats-cbscw.timer` | Tuesdays 05:50 | CBS Curaçao: scrapes the topic pages (cbs.cw), matches each table's file by regex, re-imports only when its sha256 changes (`stats.cw_cbs_file`); CPI from PDF (pdfjs), GDP, government finance, population since 1900, LFS, tourism from xlsx |
|
||||||
|
| `cwintl` | `stats-cwintl.timer` | Wednesdays 05:50 | Curaçao from international APIs: ILOSTAT SDMX (labour, with retries), OWID grapher CSVs (population, life expectancy, median age, CO₂), UN SDG area 531, UN Comtrade public preview reporter 531. EU trade with Curaçao (Eurostat `ext_lt_maineu`) runs with the `eurostat` timer |
|
||||||
|
| – | – | manual | CBCS (centralbank.cw) blocks scripts with a Cloudflare challenge: download the BoP xlsx by hand and load it into `stats.cw_cbcs_bop` (period, item, mln_ang) |
|
||||||
|
| – | – | manual | China CPI by category beyond food (NBS, data.stats.gov.cn) has no stable no-key API: not collected. Japan e-Stat needs an app id: skipped |
|
||||||
|
|
||||||
|
Each timer starts `stats-collector@<source>.service`, which runs
|
||||||
|
`~/.local/bin/stats-collector-run.sh <source>`: it updates a detached checkout of `origin/main`
|
||||||
|
in `~/dev/stats-runner`, reads `DB_URL` (app role) from the running app container, and runs the
|
||||||
|
fetch. Every job is logged in `stats.fetch_run` (shown as "data freshness" on /stats).
|
||||||
|
|
||||||
|
Politeness: one shared User-Agent, a per-host gap between requests (CBS 250 ms ≈ 4 req/s,
|
||||||
|
OECD 3.1 s ≈ 19/min, FRED/BIS 1 s), retries with backoff on 429/5xx.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bash deploy/stats-collector/install.sh # install units + enable timers
|
||||||
|
systemctl --user start stats-collector@fred.service # run one source now
|
||||||
|
# long backfills: detached, survive SSH drops; logs in journalctl --user -u stats-backfill-cbs
|
||||||
|
systemd-run --user --unit=stats-backfill-cbs ~/.local/bin/stats-collector-run.sh cbs --backfill
|
||||||
|
```
|
||||||
|
|
||||||
|
Never touches the `energy` schema or ned-collector.
|
||||||
20
deploy/stats-collector/install.sh
Normal file
20
deploy/stats-collector/install.sh
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install the /stats collector on the host as systemd user units (run as stan on stan-ovh):
|
||||||
|
# the runner script, the stats-collector@.service template and one timer per source.
|
||||||
|
# bash deploy/stats-collector/install.sh install + enable every timer
|
||||||
|
# bash deploy/stats-collector/install.sh --no-enable install only
|
||||||
|
set -euo pipefail
|
||||||
|
here="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
units="$HOME/.config/systemd/user"
|
||||||
|
install -D -m 755 "$here/stats-collector-run.sh" "$HOME/.local/bin/stats-collector-run.sh"
|
||||||
|
install -D -m 644 "$here/stats-collector@.service" "$units/stats-collector@.service"
|
||||||
|
timers=()
|
||||||
|
for timer in "$here"/stats-*.timer; do
|
||||||
|
install -m 644 "$timer" "$units/$(basename "$timer")"
|
||||||
|
timers+=("$(basename "$timer")")
|
||||||
|
done
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
if [ "${1:-}" != --no-enable ]; then
|
||||||
|
systemctl --user enable --now "${timers[@]}"
|
||||||
|
fi
|
||||||
|
systemctl --user list-timers --no-pager 'stats-*'
|
||||||
11
deploy/stats-collector/stats-bis.timer
Normal file
11
deploy/stats-collector/stats-bis.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: BIS policy rates daily
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 07:50:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@bis.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-cbs.timer
Normal file
11
deploy/stats-collector/stats-cbs.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: CBS StatLine nightly (re-pulls tables whose Modified changed)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 05:40:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@cbs.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-cbscw.timer
Normal file
11
deploy/stats-collector/stats-cbscw.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: CBS Curaçao files (re-imports a table only when its file hash changes)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=Tue *-*-* 05:50:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=20min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@cbscw.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
46
deploy/stats-collector/stats-collector-run.sh
Normal file
46
deploy/stats-collector/stats-collector-run.sh
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# /stats collector runner (systemd: stats-collector@<source>.service, one timer per source).
|
||||||
|
# stats-collector-run.sh <source> [--backfill|--force|--only=…]
|
||||||
|
# Runs scripts/stats-fetch.ts from a detached checkout of origin/main (~/dev/stats-runner), with
|
||||||
|
# DB_URL read fresh from the app container each run (never stored on disk). One lock per source,
|
||||||
|
# so a slow backfill and its timer never overlap; a shared lock guards the checkout update.
|
||||||
|
set -euo pipefail
|
||||||
|
source_name=${1:?source required (cbs, eurostat, fred, bis, …)}
|
||||||
|
case "$source_name" in *[!a-z0-9-]*) echo "bad source name" >&2; exit 2 ;; esac
|
||||||
|
runner="${STATS_RUNNER_DIR:-$HOME/dev/stats-runner}"
|
||||||
|
locks="${XDG_RUNTIME_DIR:-/tmp}/stats-collector"
|
||||||
|
mkdir -p "$locks"
|
||||||
|
exec 9>"$locks/$source_name.lock"
|
||||||
|
if ! flock -w "${STATS_LOCK_WAIT:-3600}" 9; then
|
||||||
|
echo "another $source_name run still holds the lock; giving up" >&2
|
||||||
|
exit 75
|
||||||
|
fi
|
||||||
|
if [ ! -e "$runner/.git" ]; then
|
||||||
|
git -C "$HOME/dev/personal-website" fetch -q origin
|
||||||
|
git -C "$HOME/dev/personal-website" worktree add -q --detach "$runner" origin/main
|
||||||
|
fi
|
||||||
|
(
|
||||||
|
flock -w 600 8
|
||||||
|
cd "$runner"
|
||||||
|
git fetch -q origin
|
||||||
|
git checkout -q --detach origin/main
|
||||||
|
bun install --frozen-lockfile --silent
|
||||||
|
) 8>"$locks/checkout.lock"
|
||||||
|
cd "$runner"
|
||||||
|
DB_URL=$(sudo -n docker inspect personal-website-app-1 --format '{{range .Config.Env}}{{println .}}{{end}}' \
|
||||||
|
| sed -n 's/^DB_URL=//p' | sed 's/@db:5432/@127.0.0.1:5440/')
|
||||||
|
[ -n "$DB_URL" ] || { echo "no DB_URL from app container" >&2; exit 1; }
|
||||||
|
export DB_URL
|
||||||
|
# Per-source API keys live in ~/.config/stats/<name>.env (mode 600); exported, never echoed.
|
||||||
|
case "$source_name" in
|
||||||
|
eponline) env_file="$HOME/.config/stats/ep-online.env" ;;
|
||||||
|
*) env_file="" ;;
|
||||||
|
esac
|
||||||
|
if [ -n "$env_file" ]; then
|
||||||
|
[ -r "$env_file" ] || { echo "missing $env_file" >&2; exit 1; }
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$env_file"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
exec nice -n 10 bun scripts/stats-fetch.ts "$@"
|
||||||
10
deploy/stats-collector/stats-collector@.service
Normal file
10
deploy/stats-collector/stats-collector@.service
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=stanrunge.dev /stats collector: %i (open data → stats.*)
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=%h/.local/bin/stats-collector-run.sh %i
|
||||||
|
TimeoutStartSec=3h
|
||||||
|
Nice=10
|
||||||
11
deploy/stats-collector/stats-cwintl.timer
Normal file
11
deploy/stats-collector/stats-cwintl.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: Curaçao from ILOSTAT, OWID, UN SDG and UN Comtrade
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=Wed *-*-* 05:50:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=20min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@cwintl.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-elections.timer
Normal file
11
deploy/stats-collector/stats-elections.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: Kiesraad TK results, CBS 37278 history, MIT US presidential (new elections only)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-15 04:50:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=20min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@elections.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-energycharts.timer
Normal file
11
deploy/stats-collector/stats-energycharts.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: Energy-Charts hourly at :19 (off ned-collector's :07/:37)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* *:19:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=2min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@energycharts.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-eponline.timer
Normal file
11
deploy/stats-collector/stats-eponline.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: EP-Online energy labels, monthly total file (published on the 1st)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-03 05:20:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=20min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@eponline.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-eurobarometer.timer
Normal file
11
deploy/stats-collector/stats-eurobarometer.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: Standard Eurobarometer volume A, new waves (monthly check)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-20 05:35:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=20min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@eurobarometer.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-eurostat.timer
Normal file
11
deploy/stats-collector/stats-eurostat.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: Eurostat weekly
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=Mon *-*-* 06:20:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@eurostat.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-fred.timer
Normal file
11
deploy/stats-collector/stats-fred.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: FRED graph CSV daily
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 07:20:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@fred.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-gdelt.timer
Normal file
11
deploy/stats-collector/stats-gdelt.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: GDELT theme coverage, slow loop at 1 request per 5.5 s (nightly)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 02:30:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=20min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@gdelt.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-gtrends.timer
Normal file
11
deploy/stats-collector/stats-gtrends.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: Google Trends trending-now RSS for NL, US, KR (daily)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 21:45:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=20min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@gtrends.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-history.timer
Normal file
11
deploy/stats-collector/stats-history.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: history and culture (Wikidata, UNESCO, Nager.Date, OWID, CBS religion; monthly)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-03 05:40:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=30min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@history.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-imf.timer
Normal file
11
deploy/stats-collector/stats-imf.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: IMF WEO monthly (new rounds in April and October)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-01 04:05:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@imf.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-oecd.timer
Normal file
11
deploy/stats-collector/stats-oecd.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: OECD SDMX CPI categories and 10-year yields, Thursdays
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=Thu *-*-* 06:35:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@oecd.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-owid.timer
Normal file
11
deploy/stats-collector/stats-owid.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: Our World in Data monthly (Maddison, energy dataset)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-02 04:35:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@owid.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-parties.timer
Normal file
11
deploy/stats-collector/stats-parties.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: CHES + V-Party party positions via Party Facts (quarterly)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-01,04,07,10-16 04:40:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=20min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@parties.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-rijksfinancien.timer
Normal file
11
deploy/stats-collector/stats-rijksfinancien.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: Rijksfinanciën monthly (budget tables)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-03 05:05:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@rijksfinancien.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-statcan.timer
Normal file
11
deploy/stats-collector/stats-statcan.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: Statistics Canada CPI daily
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 16:10:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@statcan.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-usaspending.timer
Normal file
11
deploy/stats-collector/stats-usaspending.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: USAspending weekly (Sunday)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=Sun *-*-* 06:10:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@usaspending.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-ustreasury.timer
Normal file
11
deploy/stats-collector/stats-ustreasury.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: US Treasury Fiscal Data daily (after the US afternoon release)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 23:25:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@ustreasury.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-whr.timer
Normal file
11
deploy/stats-collector/stats-whr.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: World Happiness Report Figure 2.1 (monthly check)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-21 05:10:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=20min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@whr.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-wikipolls.timer
Normal file
11
deploy/stats-collector/stats-wikipolls.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: Wikipedia opinion-poll tables (daily)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 08:10:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=20min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@wikipolls.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-worldbank.timer
Normal file
11
deploy/stats-collector/stats-worldbank.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: World Bank WDI weekly (Monday)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=Mon *-*-* 04:15:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@worldbank.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
13
deploy/systemd/goal-metrics.service
Normal file
13
deploy/systemd/goal-metrics.service
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Goal metrics refresh (Moneybird side income -> tasks.metric_sources, auto goals -> tasks.goals)
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# A checkout of stan/website on main (needs node_modules for postgres.js).
|
||||||
|
WorkingDirectory=%h/dev/website-goalbar
|
||||||
|
# DB_URL (app role), MONEYBIRD_API_TOKEN, MONEYBIRD_ADMINISTRATION_ID; mode 600, never committed.
|
||||||
|
EnvironmentFile=%h/.config/goal-metrics.env
|
||||||
|
ExecStart=/usr/bin/bun scripts/goal-metrics.ts
|
||||||
|
TimeoutStartSec=300
|
||||||
10
deploy/systemd/goal-metrics.timer
Normal file
10
deploy/systemd/goal-metrics.timer
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Goal metrics refresh, daily (salary part is live on page load anyway)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 06:40:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=300
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
13
deploy/systemd/house-scrape.service
Normal file
13
deploy/systemd/house-scrape.service
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=House tracker nightly scrape (Kamernet, Vesteda, re-check of imported rows -> house.listings)
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# A checkout of stan/website on main. Not the live dev tree.
|
||||||
|
WorkingDirectory=%h/dev/website-houses
|
||||||
|
# DB_URL (house_scraper role: house schema only), optional HOUSE_NTFY_URL; mode 600, never committed.
|
||||||
|
EnvironmentFile=%h/.config/house-scrape.env
|
||||||
|
ExecStart=/usr/bin/bun scripts/house-scrape.ts
|
||||||
|
TimeoutStartSec=900
|
||||||
10
deploy/systemd/house-scrape.timer
Normal file
10
deploy/systemd/house-scrape.timer
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=House tracker scrape, once a night
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 03:40:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=1200
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
13
deploy/systemd/money-sync.service
Normal file
13
deploy/systemd/money-sync.service
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Money tracker bank sync (Enable Banking -> money.transactions)
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# A checkout of stan/website (main once merged). Not the live dev tree.
|
||||||
|
WorkingDirectory=%h/dev/website-money
|
||||||
|
# DB_URL (app role), ENABLEBANKING_APP_ID, ENABLEBANKING_KEY_PATH; mode 600, never committed.
|
||||||
|
EnvironmentFile=%h/.config/money-sync.env
|
||||||
|
ExecStart=/usr/bin/bun scripts/bank-sync.ts
|
||||||
|
TimeoutStartSec=600
|
||||||
10
deploy/systemd/money-sync.timer
Normal file
10
deploy/systemd/money-sync.timer
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Money tracker bank sync, 4x per day (PSD2 allows ~4 unattended fetches/day)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 06,11,16,21:20:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=300
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -1,7 +1,72 @@
|
|||||||
# House tracker
|
# House tracker
|
||||||
|
|
||||||
|
## Nightly scrape on stan-ovh (prod; was stan-vm until 2026-10-24)
|
||||||
|
|
||||||
|
`scripts/house-scrape.ts` runs once a night from a systemd user timer
|
||||||
|
(`deploy/systemd/house-scrape.{service,timer}`, 03:40 Amsterdam + up to 20 min jitter) and
|
||||||
|
writes `house.listings` directly with the limited `house_scraper` role
|
||||||
|
(`~/.config/house-scrape.env`, mode 600). One request at a time, 6–10 s apart, honest
|
||||||
|
User-Agent, robots.txt-allowed paths only. A 403/429/challenge stops that source for the
|
||||||
|
night and is logged as `challenged`; nothing is retried or worked around.
|
||||||
|
|
||||||
|
| source | how | status |
|
||||||
|
| ---------------------- | ------------------------------------------------------------- | ------------------------------------ |
|
||||||
|
| Kamernet | `/huren/huurwoningen-<city>` pages, `__NEXT_DATA__` JSON | scraped (10 cities around Gouda) |
|
||||||
|
| Vesteda | `POST /api/units/search/facet` (what their site calls), 20 km | scraped; rented units count as gone |
|
||||||
|
| Pararius | Cloudflare 403 from the Hetzner IP | not scraped here; home worker below |
|
||||||
|
| huurwoningen.nl | same Cloudflare 403 (Pararius group) | skipped |
|
||||||
|
| Funda | bot-check page ("Je bent bijna op de pagina…") | skipped |
|
||||||
|
| Huurstunt | Cloudflare challenge | skipped |
|
||||||
|
| Mijndak Midden-Holland | OutSystems app behind a login, no public listing feed | skipped; use their own e-mail alerts |
|
||||||
|
|
||||||
|
**Re-check of imported rows** (`src/lib/house-tracker/recheck.ts`). Rows added by the CLI or
|
||||||
|
one-off backfills from huispedia, huislijn, huurportaal and rentola are not in any search
|
||||||
|
feed, so after the sources the scrape fetches each row's own page (all four allow detail
|
||||||
|
pages in robots.txt), up to 400 per site, oldest `last_seen_at` first, within
|
||||||
|
`HOUSE_RECHECK_MINUTES` (default 8). Each site has its own one-at-a-time 6–10 s queue.
|
||||||
|
Alive sets `last_seen_at`; gone sets `removed_at` and needs a clear signal:
|
||||||
|
|
||||||
|
| site | gone when |
|
||||||
|
| -------------------- | ------------------------------------------------------------------------------- |
|
||||||
|
| huurportaal, rentola | redirect to a search page with `?inactive_property_id=`, or 404/410 |
|
||||||
|
| huislijn | redirect to the province overview (id gone from the URL), 404/410, woningruil |
|
||||||
|
| huispedia | 404/410 ("niet meer beschikbaar"), or Status verhuurd / verkocht / niet te koop |
|
||||||
|
|
||||||
|
Errors, timeouts, off-site redirects and unfamiliar pages are `unknown` and change nothing; a
|
||||||
|
403/429/503 or challenge stops that site for the night. If more than half of a site's checks
|
||||||
|
in one night (min. 5) say gone, none are applied and the run is logged as `suspicious`.
|
||||||
|
Try it with `--dry-run --source recheck --recheck-limit 5`.
|
||||||
|
|
||||||
|
Same home on two sources: `address_key` (street + number + city, normalised, see
|
||||||
|
`src/lib/house-tracker/address.ts`) links the copy to the original via `duplicate_of`; a
|
||||||
|
manual entry wins. The page shows one row with "also on …" links. Kamernet never shows house
|
||||||
|
numbers, so its listings are never merged. Alerts: new non-copy listings matching an alert
|
||||||
|
rule go to `HOUSE_NTFY_URL` (its own topic, max 5 per night plus one summary); the first run
|
||||||
|
of a source only seeds.
|
||||||
|
|
||||||
|
No caps at scrape time: every listing in the region is stored (Kamernet city pages, Vesteda
|
||||||
|
with an open rent range, Pararius without a price band). The €2,000 / 2-bedroom / 50 m²
|
||||||
|
limits are only the page's default filters (clear a field to drop it) and the alert rule.
|
||||||
|
|
||||||
|
Liveability: every listing gets a Leefbaarometer 3.0 score (BZK, measurement 2024, published
|
||||||
|
July 2026; `src/lib/house-tracker/liveability.ts`). The address is geocoded with PDOK
|
||||||
|
Locatieserver (exact address, else 6-char postcode, else street centroid → marked approx.),
|
||||||
|
then the 100 m grid cell and the buurt containing the point are read from the official WFS
|
||||||
|
(`geo.leefbaarometer.nl/lbm3`, layers `clippedgridscore24` / `buurtscore24`). The cell score
|
||||||
|
is used when there is one (and the location is not street-level), else the buurt. Stored on
|
||||||
|
the row: `lbm_class` (1-9, zeer onvoldoende … uitstekend), `lbm_onv_class` (overlast &
|
||||||
|
onveiligheid, 1-9 where 5 = Dutch average), buurt, level, precision, year, and all
|
||||||
|
sub-scores in `lbm_detail`. Scored by the nightly scrape (new and changed addresses), the
|
||||||
|
CLI and the page's add form; backfill or re-score with `scripts/house-liveability.ts
|
||||||
|
[--force]`. For a new measurement, bump `LBM_YEAR` and run it with `--force`.
|
||||||
|
|
||||||
|
Manual listings: `scripts/house-listing.ts` (CLI) or the form on the page. Against prod use
|
||||||
|
`scripts/house-db.sh <command> …`, which runs the CLI as `house_cli` (SELECT/INSERT/UPDATE/
|
||||||
|
DELETE on house.listings, SELECT/INSERT on house.price_history, nothing else) from
|
||||||
|
`~/.config/house-cli.env`.
|
||||||
|
|
||||||
Personal tool: collects Dutch housing listings into Postgres, shows them on
|
Personal tool: collects Dutch housing listings into Postgres, shows them on
|
||||||
`/projects/house-tracker` (admin only) and sends an alert when a new listing
|
`/trackers/houses` (owner only, 404 for everyone else; the old URL redirects) and sends an alert when a new listing
|
||||||
matches a rule. Only Pararius for now; other sources plug in the same way.
|
matches a rule. Only Pararius for now; other sources plug in the same way.
|
||||||
|
|
||||||
## How it fits together
|
## How it fits together
|
||||||
@@ -51,7 +116,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 +156,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
|
||||||
@@ -176,3 +241,29 @@ fixture still covers pagination and price on request. Rental `priceEur` is the
|
|||||||
base/listed rent used by the search filter; a separately shown total can be
|
base/listed rent used by the search filter; a separately shown total can be
|
||||||
higher (Raam: €1,475 base, €1,750 total). If Pararius changes markup, the worker
|
higher (Raam: €1,475 base, €1,750 total). If Pararius changes markup, the worker
|
||||||
reports `parse-error` in the worker log on the page.
|
reports `parse-error` in the worker log on the page.
|
||||||
|
|
||||||
|
## Adding listings by hand
|
||||||
|
|
||||||
|
`scripts/house-listing.ts` writes `house.listings` directly (links from chats,
|
||||||
|
screenshots). No restart or deploy is needed for new rows.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# DATABASE_URL or DB_URL must be set; locally: bun --env-file=.env run house …
|
||||||
|
bun run house add --url https://vbtverhuurmakelaars.nl/woning/gouda-vest-7-d \
|
||||||
|
--city Gouda --price "€ 1.422,- p.m." --area 84 --rooms 3 --bedrooms 2 --energy-label A
|
||||||
|
echo '{"url":"…","city":"Gouda","price_eur":1350,"area_m2":81}' | bun run house add --json -
|
||||||
|
bun run house add --street "Vest 7-D" --city Gouda --price 1422 # no link (screenshot)
|
||||||
|
bun run house update <id|url> --status viewing --notes "za 14:00" # "" clears a field
|
||||||
|
bun run house list [--all] [--city gouda] [--offer buy] [--json]
|
||||||
|
bun run house remove <id|url> [--hard] # soft remove marks it gone; restore undoes it
|
||||||
|
```
|
||||||
|
|
||||||
|
- `source` is the site (`funda`, `pararius`, `vbt`, `vesteda`, … or `manual`
|
||||||
|
without a link); `source_id` comes from the URL (Funda's number, Pararius'
|
||||||
|
worker key, otherwise the path), or a hash of the address. Adding the same
|
||||||
|
link again updates that row and clears `removed_at`.
|
||||||
|
- Fields follow `listingDetailSchema` (camelCase, snake_case or the flag names)
|
||||||
|
and are lenient: "€ 1.422,- p.m." and "84 m²" are parsed. Only given fields are
|
||||||
|
written. `city_slug` is derived from `city` in Pararius' slug style.
|
||||||
|
- `--first-seen` sets when it was shared; price changes go to `price_history`.
|
||||||
|
- Manual rows use `first_seen_lane = 'manual'` and never trigger alerts.
|
||||||
|
|||||||
@@ -1,12 +1,46 @@
|
|||||||
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
|
||||||
|
// 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: [
|
||||||
|
'auth',
|
||||||
|
'money',
|
||||||
|
'house',
|
||||||
|
'ref',
|
||||||
|
'games',
|
||||||
|
'cs',
|
||||||
|
'animaldex',
|
||||||
|
'site',
|
||||||
|
'meta',
|
||||||
|
'health',
|
||||||
|
'books',
|
||||||
|
'tasks',
|
||||||
|
'groceries',
|
||||||
|
'home',
|
||||||
|
'cars',
|
||||||
|
'music',
|
||||||
|
'hardware',
|
||||||
|
'planes',
|
||||||
|
'wildlife',
|
||||||
|
'trains',
|
||||||
|
'dataholders',
|
||||||
|
'stats',
|
||||||
|
'ai',
|
||||||
|
'movies',
|
||||||
|
'worldtodo',
|
||||||
|
'notes'
|
||||||
|
],
|
||||||
|
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
148
drizzle/20261024002120_health/migration.sql
Normal file
148
drizzle/20261024002120_health/migration.sql
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
CREATE SCHEMA "health";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "health"."appointments" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"date" date NOT NULL,
|
||||||
|
"type" text DEFAULT 'appointment' NOT NULL,
|
||||||
|
"provider" text,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "appointments_type_check" CHECK ("type" IN ('appointment','checkup','condition','test','procedure','other')),
|
||||||
|
CONSTRAINT "appointments_date_check" CHECK ("date" >= DATE '1900-01-01')
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "health"."body_measurements" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"date" date NOT NULL,
|
||||||
|
"weight_kg" numeric(5,2),
|
||||||
|
"waist_cm" numeric(5,1),
|
||||||
|
"body_fat_pct" numeric(4,1),
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "body_measurements_any_check" CHECK ("weight_kg" IS NOT NULL OR "waist_cm" IS NOT NULL OR "body_fat_pct" IS NOT NULL),
|
||||||
|
CONSTRAINT "body_measurements_weight_check" CHECK ("weight_kg" IS NULL OR "weight_kg" BETWEEN 20 AND 400),
|
||||||
|
CONSTRAINT "body_measurements_waist_check" CHECK ("waist_cm" IS NULL OR "waist_cm" BETWEEN 30 AND 250),
|
||||||
|
CONSTRAINT "body_measurements_fat_check" CHECK ("body_fat_pct" IS NULL OR "body_fat_pct" BETWEEN 1 AND 75),
|
||||||
|
CONSTRAINT "body_measurements_date_check" CHECK ("date" >= DATE '1900-01-01')
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "health"."medications" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"kind" text DEFAULT 'medication' NOT NULL,
|
||||||
|
"dose" text,
|
||||||
|
"frequency" text,
|
||||||
|
"start_date" date,
|
||||||
|
"stop_date" date,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "medications_name_check" CHECK (length(btrim("name")) > 0),
|
||||||
|
CONSTRAINT "medications_kind_check" CHECK ("kind" IN ('medication','supplement')),
|
||||||
|
CONSTRAINT "medications_dates_check" CHECK ("stop_date" IS NULL OR "start_date" IS NULL OR "stop_date" >= "start_date")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "health"."nutrition_daily" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"date" date NOT NULL CONSTRAINT "nutrition_daily_date_key" UNIQUE,
|
||||||
|
"kcal" integer,
|
||||||
|
"protein_g" numeric(5,1),
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "nutrition_daily_kcal_check" CHECK ("kcal" IS NULL OR "kcal" BETWEEN 0 AND 20000),
|
||||||
|
CONSTRAINT "nutrition_daily_protein_check" CHECK ("protein_g" IS NULL OR "protein_g" BETWEEN 0 AND 1000),
|
||||||
|
CONSTRAINT "nutrition_daily_date_check" CHECK ("date" >= DATE '1900-01-01')
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "health"."sleep" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"date" date NOT NULL CONSTRAINT "sleep_date_key" UNIQUE,
|
||||||
|
"bedtime" timestamp with time zone NOT NULL,
|
||||||
|
"wake_time" timestamp with time zone NOT NULL,
|
||||||
|
"hours" numeric(4,2) GENERATED ALWAYS AS (round((extract(epoch from (wake_time - bedtime)) / 3600)::numeric, 2)) STORED,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "sleep_order_check" CHECK ("wake_time" > "bedtime"),
|
||||||
|
CONSTRAINT "sleep_length_check" CHECK ("wake_time" - "bedtime" <= interval '24 hours'),
|
||||||
|
CONSTRAINT "sleep_date_check" CHECK ("date" >= DATE '1900-01-01')
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "health"."vaccinations" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"vaccine" text NOT NULL,
|
||||||
|
"target" text,
|
||||||
|
"dose_no" integer,
|
||||||
|
"date" date NOT NULL,
|
||||||
|
"batch_lot" text,
|
||||||
|
"provider" text,
|
||||||
|
"country" text,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "vaccinations_vaccine_check" CHECK (length(btrim("vaccine")) > 0),
|
||||||
|
CONSTRAINT "vaccinations_dose_no_check" CHECK ("dose_no" IS NULL OR "dose_no" BETWEEN 1 AND 20),
|
||||||
|
CONSTRAINT "vaccinations_country_check" CHECK ("country" IS NULL OR "country" ~ '^[A-Z]{2}$'),
|
||||||
|
CONSTRAINT "vaccinations_date_check" CHECK ("date" >= DATE '1900-01-01')
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "health"."workouts" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"date" date NOT NULL,
|
||||||
|
"session_id" uuid,
|
||||||
|
"exercise" text NOT NULL,
|
||||||
|
"sets" integer,
|
||||||
|
"reps" integer,
|
||||||
|
"load_kg" numeric(6,2),
|
||||||
|
"rpe" numeric(3,1),
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "workouts_exercise_check" CHECK (length(btrim("exercise")) > 0),
|
||||||
|
CONSTRAINT "workouts_sets_check" CHECK ("sets" IS NULL OR "sets" BETWEEN 1 AND 100),
|
||||||
|
CONSTRAINT "workouts_reps_check" CHECK ("reps" IS NULL OR "reps" BETWEEN 0 AND 1000),
|
||||||
|
CONSTRAINT "workouts_load_check" CHECK ("load_kg" IS NULL OR "load_kg" BETWEEN 0 AND 1000),
|
||||||
|
CONSTRAINT "workouts_rpe_check" CHECK ("rpe" IS NULL OR "rpe" BETWEEN 1 AND 10),
|
||||||
|
CONSTRAINT "workouts_date_check" CHECK ("date" >= DATE '1900-01-01')
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "appointments_date_idx" ON "health"."appointments" ("date");--> statement-breakpoint
|
||||||
|
CREATE INDEX "body_measurements_date_idx" ON "health"."body_measurements" ("date");--> statement-breakpoint
|
||||||
|
CREATE INDEX "medications_start_date_idx" ON "health"."medications" ("start_date");--> statement-breakpoint
|
||||||
|
CREATE INDEX "medications_stop_date_idx" ON "health"."medications" ("stop_date");--> statement-breakpoint
|
||||||
|
CREATE INDEX "vaccinations_date_idx" ON "health"."vaccinations" ("date");--> statement-breakpoint
|
||||||
|
CREATE INDEX "workouts_date_idx" ON "health"."workouts" ("date");--> statement-breakpoint
|
||||||
|
CREATE INDEX "workouts_session_idx" ON "health"."workouts" ("session_id");
|
||||||
|
-- Hand-written below this line (not generated by drizzle-kit).
|
||||||
|
-- updated_at triggers, same as every other core table (07_triggers_meta_grants.sql).
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
|
||||||
|
FOREACH t IN ARRAY ARRAY['vaccinations','body_measurements','workouts','nutrition_daily','sleep','medications','appointments'] LOOP
|
||||||
|
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON health.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||||
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Privacy: health data is readable/writable by the app role only. Unlike the other core
|
||||||
|
-- schemas it is deliberately NOT granted to personal_ro, and it is not registered in
|
||||||
|
-- meta.tables (so the generic list/table GUI never shows it).
|
||||||
|
-- Grants only run on prod (migrations run as the schema owner `personal`); the dev DB
|
||||||
|
-- (owned by personal_dev) carries no prod grants, matching db-refresh-dev.sh.
|
||||||
|
REVOKE ALL ON SCHEMA health FROM PUBLIC;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT USAGE ON SCHEMA health TO personal_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA health TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA health
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA health
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
11583
drizzle/20261024002120_health/snapshot.json
Normal file
11583
drizzle/20261024002120_health/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
65
drizzle/20261024003809_money_bank_sync/migration.sql
Normal file
65
drizzle/20261024003809_money_bank_sync/migration.sql
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
CREATE TABLE "money"."bank_sessions" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"provider" text DEFAULT 'enablebanking' NOT NULL,
|
||||||
|
"session_id" text NOT NULL CONSTRAINT "bank_sessions_session_id_key" UNIQUE,
|
||||||
|
"aspsp_name" text NOT NULL,
|
||||||
|
"aspsp_country" text NOT NULL,
|
||||||
|
"psu_type" text,
|
||||||
|
"status" text DEFAULT 'authorized' NOT NULL,
|
||||||
|
"valid_until" timestamp with time zone,
|
||||||
|
"accounts" jsonb DEFAULT '[]' NOT NULL,
|
||||||
|
"last_synced_at" timestamp with time zone,
|
||||||
|
"last_error" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "bank_sessions_status_check" CHECK (status IN ('authorized','expired','revoked','error'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."accounts" ADD COLUMN "scope" text DEFAULT 'personal' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."accounts" ADD COLUMN "owner" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."accounts" ADD COLUMN "iban" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."accounts" ADD COLUMN "external_uid" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."accounts" ADD COLUMN "provider" text DEFAULT 'manual' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."transactions" ADD COLUMN "external_id" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."transactions" ADD COLUMN "source" text DEFAULT 'manual' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."transactions" ADD COLUMN "booking_status" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."transactions" ADD COLUMN "counterparty" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."transactions" ADD COLUMN "category" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."transactions" ADD COLUMN "raw" jsonb;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."transactions" ADD COLUMN "value_date" date;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."accounts" ADD CONSTRAINT "accounts_external_uid_key" UNIQUE("external_uid");--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."transactions" ADD CONSTRAINT "transactions_account_external_id_key" UNIQUE("account_id","external_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "bank_sessions_user_id_index" ON "money"."bank_sessions" ("user_id");--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."bank_sessions" ADD CONSTRAINT "bank_sessions_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."accounts" ADD CONSTRAINT "accounts_scope_check" CHECK (scope IN ('personal','joint'));--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."accounts" ADD CONSTRAINT "accounts_provider_check" CHECK (provider IN ('manual','enablebanking'));--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."transactions" ADD CONSTRAINT "transactions_source_check" CHECK (source IN ('manual','csv','enablebanking'));--> statement-breakpoint
|
||||||
|
-- Hand-written below this line (not generated by drizzle-kit).
|
||||||
|
-- Backfill: the shared account with Juhee is the only joint one; everything else stays personal.
|
||||||
|
UPDATE "money"."accounts" SET "scope" = 'joint', "owner" = 'Stan & Juhee' WHERE lower("name") = 'joint';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "money"."accounts" SET "owner" = 'Stan' WHERE "scope" = 'personal' AND "owner" IS NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- updated_at trigger, same as every other core table (07_triggers_meta_grants.sql).
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
|
||||||
|
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON money.bank_sessions
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at();
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Grants only run on prod (migrations run as the schema owner `personal`); the dev DB
|
||||||
|
-- (owned by personal_dev) carries no prod grants, matching db-refresh-dev.sh. Default
|
||||||
|
-- privileges on `money` already cover new tables; this is explicit for clarity.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON money.bank_sessions TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCE money.bank_sessions_id_seq TO personal_app;
|
||||||
|
END IF;
|
||||||
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||||
|
GRANT SELECT ON money.bank_sessions TO personal_ro;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
11543
drizzle/20261024003809_money_bank_sync/snapshot.json
Normal file
11543
drizzle/20261024003809_money_bank_sync/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3
drizzle/20261024011919_money_bank_balance/migration.sql
Normal file
3
drizzle/20261024011919_money_bank_balance/migration.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "money"."accounts" ADD COLUMN "bank_balance_cents" integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."accounts" ADD COLUMN "bank_balance_type" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "money"."accounts" ADD COLUMN "bank_balance_at" timestamp with time zone;
|
||||||
12076
drizzle/20261024011919_money_bank_balance/snapshot.json
Normal file
12076
drizzle/20261024011919_money_bank_balance/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
194
drizzle/20261024013208_books/migration.sql
Normal file
194
drizzle/20261024013208_books/migration.sql
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
CREATE SCHEMA "books";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TYPE "books"."metadata_status" AS ENUM('pending', 'matched', 'low_confidence', 'not_found', 'manual');--> statement-breakpoint
|
||||||
|
CREATE TYPE "books"."reading_status" AS ENUM('want_to_read', 'reading', 'finished', 'abandoned');--> 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,
|
||||||
|
"status" "books"."reading_status" DEFAULT 'want_to_read'::"books"."reading_status" NOT NULL,
|
||||||
|
"rating" smallint,
|
||||||
|
"last_read_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),
|
||||||
|
CONSTRAINT "progress_rating_check" CHECK ("rating" is null or "rating" between 1 and 5)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "books"."sessions" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"work_id" integer NOT NULL,
|
||||||
|
"file_id" integer,
|
||||||
|
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"ended_at" timestamp with time zone,
|
||||||
|
"start_percent" real,
|
||||||
|
"end_percent" real,
|
||||||
|
"start_page" integer,
|
||||||
|
"end_page" integer,
|
||||||
|
"pages_read" integer,
|
||||||
|
"source" text DEFAULT 'reader' NOT NULL,
|
||||||
|
"note" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "sessions_percent_check" CHECK (("start_percent" is null or "start_percent" between 0 and 100) and ("end_percent" is null or "end_percent" between 0 and 100)),
|
||||||
|
CONSTRAINT "sessions_end_check" CHECK ("ended_at" is null or "ended_at" >= "started_at")
|
||||||
|
);
|
||||||
|
--> 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 "progress_status_idx" ON "books"."progress" ("status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sessions_work_id_idx" ON "books"."sessions" ("work_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sessions_started_at_idx" ON "books"."sessions" ("started_at");--> 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"."sessions" ADD CONSTRAINT "sessions_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "books"."sessions" ADD CONSTRAINT "sessions_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;
|
||||||
|
-- meta.sync() is executable by personal_app/personal_ddl, not by the migration role
|
||||||
|
-- (personal) in prod; deploy-remote.sh runs it as the app role after migrating.
|
||||||
|
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||||
|
PERFORM meta.sync();
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Hand-written: updated_at trigger and explicit grants (default privileges from the books migration cover new tables too).
|
||||||
|
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON books.sessions FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at();
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON books.sessions TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCE books.sessions_id_seq TO personal_app;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||||
|
GRANT SELECT ON books.sessions TO personal_ro;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
13722
drizzle/20261024013208_books/snapshot.json
Normal file
13722
drizzle/20261024013208_books/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "money"."accounts" ADD COLUMN "include_in_total" boolean DEFAULT true NOT NULL;
|
||||||
13735
drizzle/20261024021157_money_include_in_total/snapshot.json
Normal file
13735
drizzle/20261024021157_money_include_in_total/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
142
drizzle/20261024022836_tasks/migration.sql
Normal file
142
drizzle/20261024022836_tasks/migration.sql
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
CREATE SCHEMA "tasks";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "tasks"."activity" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"task_id" integer,
|
||||||
|
"goal_id" integer,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"from_status" text,
|
||||||
|
"to_status" text,
|
||||||
|
"note" text,
|
||||||
|
"value" numeric(14,2),
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "activity_target_check" CHECK ("task_id" IS NOT NULL OR "goal_id" IS NOT NULL),
|
||||||
|
CONSTRAINT "activity_kind_check" CHECK ("kind" IN ('created','status','edit','note','progress'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "tasks"."areas" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"color" 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 "areas_slug_check" CHECK ("slug" ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
|
||||||
|
CONSTRAINT "areas_name_check" CHECK (length(btrim("name")) > 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "tasks"."goals" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"notes" text,
|
||||||
|
"status" text DEFAULT 'active' NOT NULL,
|
||||||
|
"area_id" integer,
|
||||||
|
"target_date" date,
|
||||||
|
"metric" text,
|
||||||
|
"metric_unit" text,
|
||||||
|
"metric_target" numeric(14,2),
|
||||||
|
"metric_current" numeric(14,2),
|
||||||
|
"flagged" boolean DEFAULT false NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"source" text,
|
||||||
|
"achieved_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 "goals_title_check" CHECK (length(btrim("title")) > 0),
|
||||||
|
CONSTRAINT "goals_status_check" CHECK ("status" IN ('active','achieved','dropped'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "tasks"."tasks" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"notes" text,
|
||||||
|
"status" text DEFAULT 'todo' NOT NULL,
|
||||||
|
"priority" smallint DEFAULT 0 NOT NULL,
|
||||||
|
"flagged" boolean DEFAULT false NOT NULL,
|
||||||
|
"due_date" date,
|
||||||
|
"due_time" time,
|
||||||
|
"reminder_at" timestamp with time zone,
|
||||||
|
"recurrence" text,
|
||||||
|
"area_id" integer,
|
||||||
|
"goal_id" integer,
|
||||||
|
"parent_id" integer,
|
||||||
|
"tags" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"source" text,
|
||||||
|
"external_id" text,
|
||||||
|
"started_at" timestamp with time zone,
|
||||||
|
"completed_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 "tasks_title_check" CHECK (length(btrim("title")) > 0),
|
||||||
|
CONSTRAINT "tasks_status_check" CHECK ("status" IN ('todo','in_progress','done','dropped')),
|
||||||
|
CONSTRAINT "tasks_priority_check" CHECK ("priority" BETWEEN 0 AND 3),
|
||||||
|
CONSTRAINT "tasks_parent_check" CHECK ("parent_id" IS NULL OR "parent_id" <> "id"),
|
||||||
|
CONSTRAINT "tasks_time_check" CHECK ("due_time" IS NULL OR "due_date" IS NOT NULL)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "activity_task_idx" ON "tasks"."activity" ("task_id","created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "activity_goal_idx" ON "tasks"."activity" ("goal_id","created_at");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "areas_slug_key" ON "tasks"."areas" ("slug");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "goals_title_key" ON "tasks"."goals" (lower("title"));--> statement-breakpoint
|
||||||
|
CREATE INDEX "tasks_area_idx" ON "tasks"."tasks" ("area_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "tasks_goal_idx" ON "tasks"."tasks" ("goal_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "tasks_parent_idx" ON "tasks"."tasks" ("parent_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "tasks_status_due_idx" ON "tasks"."tasks" ("status","due_date");--> statement-breakpoint
|
||||||
|
CREATE INDEX "tasks_tags_idx" ON "tasks"."tasks" USING gin ("tags");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "tasks_source_external_key" ON "tasks"."tasks" ("source","external_id") WHERE "external_id" IS NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tasks"."activity" ADD CONSTRAINT "activity_task_id_tasks_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"."tasks"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tasks"."activity" ADD CONSTRAINT "activity_goal_id_goals_id_fkey" FOREIGN KEY ("goal_id") REFERENCES "tasks"."goals"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tasks"."goals" ADD CONSTRAINT "goals_area_id_areas_id_fkey" FOREIGN KEY ("area_id") REFERENCES "tasks"."areas"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tasks"."tasks" ADD CONSTRAINT "tasks_area_id_areas_id_fkey" FOREIGN KEY ("area_id") REFERENCES "tasks"."areas"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tasks"."tasks" ADD CONSTRAINT "tasks_goal_id_goals_id_fkey" FOREIGN KEY ("goal_id") REFERENCES "tasks"."goals"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tasks"."tasks" ADD CONSTRAINT "tasks_parent_id_tasks_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "tasks"."tasks"("id") ON DELETE CASCADE;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Hand-written below this line (not generated by drizzle-kit).
|
||||||
|
-- updated_at triggers, same as every other core table.
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
|
||||||
|
FOREACH t IN ARRAY ARRAY['areas','goals','tasks'] LOOP
|
||||||
|
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON tasks.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||||
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Privacy: like health, the task tracker is readable/writable by the app role only
|
||||||
|
-- (no personal_ro grant). Grants only run on prod, where migrations run as `personal`.
|
||||||
|
REVOKE ALL ON SCHEMA tasks FROM PUBLIC;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT USAGE ON SCHEMA tasks TO personal_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tasks TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA tasks TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA tasks
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA tasks
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Registered in meta.tables (structure only; the lists GUI only shows kind = 'list').
|
||||||
|
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 ('tasks','tasks','tasks-tasks','Tasks','core',true,true,1000),
|
||||||
|
('tasks','goals','tasks-goals','Goals','core',true,false,1000),
|
||||||
|
('tasks','areas','tasks-areas','Task areas','core',true,false,1000),
|
||||||
|
('tasks','activity','tasks-activity','Task activity','core',false,false,1000)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
-- deploy-remote.sh runs meta.sync() as the app role after migrating.
|
||||||
|
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||||
|
PERFORM meta.sync();
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
14935
drizzle/20261024022836_tasks/snapshot.json
Normal file
14935
drizzle/20261024022836_tasks/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
29
drizzle/20261024024249_meta_edit_log/migration.sql
Normal file
29
drizzle/20261024024249_meta_edit_log/migration.sql
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
CREATE TABLE "meta"."edit_log" (
|
||||||
|
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "meta"."edit_log_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||||
|
"at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"user_id" text,
|
||||||
|
"user_email" text,
|
||||||
|
"schema_name" text NOT NULL,
|
||||||
|
"table_name" text NOT NULL,
|
||||||
|
"action" text NOT NULL,
|
||||||
|
"pk" jsonb,
|
||||||
|
"before" jsonb,
|
||||||
|
"after" jsonb,
|
||||||
|
CONSTRAINT "edit_log_action_check" CHECK (action IN ('insert','update','delete','import','add_column'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "edit_log_table_at_index" ON "meta"."edit_log" ("schema_name","table_name","at");--> statement-breakpoint
|
||||||
|
-- Hand-written below this line (not generated by drizzle-kit).
|
||||||
|
-- The app role appends to and reads the log; nobody updates or deletes through the app.
|
||||||
|
-- personal_ro gets no access: before/after values can hold private (health) data.
|
||||||
|
-- Grants only run on prod (as `personal`); the dev DB is owned by personal_dev.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT SELECT, INSERT ON meta.edit_log TO personal_app;
|
||||||
|
GRANT USAGE ON SEQUENCE meta.edit_log_id_seq TO personal_app;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||||
|
REVOKE ALL ON meta.edit_log FROM personal_ro;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
15132
drizzle/20261024024249_meta_edit_log/snapshot.json
Normal file
15132
drizzle/20261024024249_meta_edit_log/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2
drizzle/20261024030132_tasks_ticktick/migration.sql
Normal file
2
drizzle/20261024030132_tasks_ticktick/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "tasks"."tasks" ADD COLUMN "start_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tasks"."tasks" ADD COLUMN "time_zone" text;
|
||||||
15158
drizzle/20261024030132_tasks_ticktick/snapshot.json
Normal file
15158
drizzle/20261024030132_tasks_ticktick/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
112
drizzle/20261024132343_groceries/migration.sql
Normal file
112
drizzle/20261024132343_groceries/migration.sql
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
CREATE SCHEMA "groceries";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "groceries"."prices" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"product_id" integer NOT NULL,
|
||||||
|
"store_id" integer NOT NULL,
|
||||||
|
"price_cents" integer NOT NULL,
|
||||||
|
"unit_price_cents" integer,
|
||||||
|
"promo" boolean DEFAULT false NOT NULL,
|
||||||
|
"observed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"source" text DEFAULT 'manual' NOT NULL,
|
||||||
|
"store_product_id" text,
|
||||||
|
"url" text,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "prices_price_check" CHECK ("price_cents" >= 0),
|
||||||
|
CONSTRAINT "prices_unit_price_check" CHECK ("unit_price_cents" IS NULL OR "unit_price_cents" >= 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "groceries"."products" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"brand" text,
|
||||||
|
"category" text,
|
||||||
|
"size" text,
|
||||||
|
"unit_amount" numeric(10,3),
|
||||||
|
"unit" text,
|
||||||
|
"favorite" boolean DEFAULT false NOT NULL,
|
||||||
|
"notes" text,
|
||||||
|
"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 "products_name_check" CHECK (length(btrim("name")) > 0),
|
||||||
|
CONSTRAINT "products_unit_check" CHECK ("unit" IS NULL OR "unit" IN ('piece','kg','l')),
|
||||||
|
CONSTRAINT "products_unit_amount_check" CHECK ("unit_amount" IS NULL OR "unit_amount" > 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "groceries"."stores" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"website" text,
|
||||||
|
"color" 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 "stores_slug_check" CHECK ("slug" ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
|
||||||
|
CONSTRAINT "stores_name_check" CHECK (length(btrim("name")) > 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "prices_product_store_idx" ON "groceries"."prices" ("product_id","store_id","observed_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "prices_store_idx" ON "groceries"."prices" ("store_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "products_name_brand_key" ON "groceries"."products" (lower("name"),lower(coalesce("brand", '')));--> statement-breakpoint
|
||||||
|
CREATE INDEX "products_category_idx" ON "groceries"."products" ("category");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "stores_slug_key" ON "groceries"."stores" ("slug");--> statement-breakpoint
|
||||||
|
ALTER TABLE "groceries"."prices" ADD CONSTRAINT "prices_product_id_products_id_fkey" FOREIGN KEY ("product_id") REFERENCES "groceries"."products"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "groceries"."prices" ADD CONSTRAINT "prices_store_id_stores_id_fkey" FOREIGN KEY ("store_id") REFERENCES "groceries"."stores"("id") ON DELETE CASCADE;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Hand-written below this line (not generated by drizzle-kit).
|
||||||
|
-- updated_at triggers, same as every other core table.
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
|
||||||
|
FOREACH t IN ARRAY ARRAY['stores','products','prices'] LOOP
|
||||||
|
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON groceries.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||||
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Private like tasks/health: app role only, no personal_ro. Grants only run on prod,
|
||||||
|
-- where migrations run as `personal`.
|
||||||
|
REVOKE ALL ON SCHEMA groceries FROM PUBLIC;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT USAGE ON SCHEMA groceries TO personal_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA groceries TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA groceries TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA groceries
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA groceries
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO groceries.stores (slug, name, website, color, sort_order) VALUES
|
||||||
|
('ah', 'Albert Heijn', 'https://www.ah.nl', '#00a0e2', 10),
|
||||||
|
('jumbo', 'Jumbo', 'https://www.jumbo.com', '#eeb717', 20),
|
||||||
|
('lidl', 'Lidl', 'https://www.lidl.nl', '#0050aa', 30),
|
||||||
|
('plus', 'Plus', 'https://www.plus.nl', '#e2001a', 40)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Registered in meta.tables so the /lists database browser shows them (kind 'core').
|
||||||
|
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 ('groceries','products','groceries-products','Grocery products','core',true,false,1100),
|
||||||
|
('groceries','prices','groceries-prices','Grocery prices','core',true,false,1100),
|
||||||
|
('groceries','stores','groceries-stores','Grocery stores','core',true,false,1100)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
-- deploy-remote.sh runs meta.sync() as the app role after migrating.
|
||||||
|
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||||
|
PERFORM meta.sync();
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
15861
drizzle/20261024132343_groceries/snapshot.json
Normal file
15861
drizzle/20261024132343_groceries/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
11
drizzle/20261024134401_groceries_drop_manual/migration.sql
Normal file
11
drizzle/20261024134401_groceries_drop_manual/migration.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE "groceries"."prices" DROP CONSTRAINT "prices_product_id_products_id_fkey";--> statement-breakpoint
|
||||||
|
DROP TABLE "groceries"."prices";--> statement-breakpoint
|
||||||
|
DROP TABLE "groceries"."products";--> statement-breakpoint
|
||||||
|
-- The hand-entered products/prices tables were never used (0 rows); replaced by store items
|
||||||
|
-- and groups in the next migration. Unregister them from the /lists database browser.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||||
|
DELETE FROM meta.tables WHERE schema_name = 'groceries' AND table_name IN ('products','prices');
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
15330
drizzle/20261024134401_groceries_drop_manual/snapshot.json
Normal file
15330
drizzle/20261024134401_groceries_drop_manual/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
138
drizzle/20261024134411_groceries_compare/migration.sql
Normal file
138
drizzle/20261024134411_groceries_compare/migration.sql
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
-- Trigram search for group/item names (trusted extension; the database owner may create it).
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "groceries"."groups" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"key" text,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"brand" text,
|
||||||
|
"category" text,
|
||||||
|
"unit" text,
|
||||||
|
"family_key" text,
|
||||||
|
"favorite" boolean DEFAULT false NOT NULL,
|
||||||
|
"manual" boolean DEFAULT false NOT NULL,
|
||||||
|
"merged_into_id" integer,
|
||||||
|
"notes" text,
|
||||||
|
"item_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"store_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"store_ids" integer[] DEFAULT '{}'::integer[] NOT NULL,
|
||||||
|
"min_unit_price_cents" integer,
|
||||||
|
"min_price_cents" integer,
|
||||||
|
"promo_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"image_url" text,
|
||||||
|
"search_text" 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 "groups_name_check" CHECK (length(btrim("name")) > 0),
|
||||||
|
CONSTRAINT "groups_unit_check" CHECK ("unit" IS NULL OR "unit" IN ('piece','kg','l')),
|
||||||
|
CONSTRAINT "groups_not_self_merged" CHECK ("merged_into_id" IS NULL OR "merged_into_id" <> "id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "groceries"."import_runs" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"store_id" integer NOT NULL,
|
||||||
|
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"finished_at" timestamp with time zone,
|
||||||
|
"status" text DEFAULT 'running' NOT NULL,
|
||||||
|
"requests" integer DEFAULT 0 NOT NULL,
|
||||||
|
"fetched" integer DEFAULT 0 NOT NULL,
|
||||||
|
"inserted" integer DEFAULT 0 NOT NULL,
|
||||||
|
"price_changes" integer DEFAULT 0 NOT NULL,
|
||||||
|
"error" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "groceries"."item_prices" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"item_id" integer NOT NULL,
|
||||||
|
"observed_on" date NOT NULL,
|
||||||
|
"price_cents" integer,
|
||||||
|
"promo_price_cents" integer,
|
||||||
|
"promo_label" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "groceries"."items" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"store_id" integer NOT NULL,
|
||||||
|
"store_product_id" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"brand" text,
|
||||||
|
"size_text" text,
|
||||||
|
"unit" text,
|
||||||
|
"unit_amount" numeric(12,4),
|
||||||
|
"unit_source" text,
|
||||||
|
"price_cents" integer,
|
||||||
|
"promo_price_cents" integer,
|
||||||
|
"promo_label" text,
|
||||||
|
"unit_price_cents" integer,
|
||||||
|
"promo_unit_price_cents" integer,
|
||||||
|
"category" text,
|
||||||
|
"category_path" text,
|
||||||
|
"gtin" text,
|
||||||
|
"gtin_checked_at" timestamp with time zone,
|
||||||
|
"image_url" text,
|
||||||
|
"url" text,
|
||||||
|
"available" boolean DEFAULT true NOT NULL,
|
||||||
|
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"group_id" integer,
|
||||||
|
"match_key" text,
|
||||||
|
"match_method" text,
|
||||||
|
"match_confidence" real,
|
||||||
|
"match_locked" 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 "items_unit_check" CHECK ("unit" IS NULL OR "unit" IN ('piece','kg','l')),
|
||||||
|
CONSTRAINT "items_price_check" CHECK ("price_cents" IS NULL OR "price_cents" >= 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "groups_key_key" ON "groceries"."groups" ("key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "groups_family_idx" ON "groceries"."groups" ("family_key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "groups_category_idx" ON "groceries"."groups" ("category");--> statement-breakpoint
|
||||||
|
CREATE INDEX "groups_store_ids_idx" ON "groceries"."groups" USING gin ("store_ids");--> statement-breakpoint
|
||||||
|
CREATE INDEX "groups_search_trgm_idx" ON "groceries"."groups" USING gin ("search_text" gin_trgm_ops);--> statement-breakpoint
|
||||||
|
CREATE INDEX "groups_list_idx" ON "groceries"."groups" ("store_count","name");--> statement-breakpoint
|
||||||
|
CREATE INDEX "import_runs_store_idx" ON "groceries"."import_runs" ("store_id","started_at");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "item_prices_item_day_key" ON "groceries"."item_prices" ("item_id","observed_on");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "items_store_product_key" ON "groceries"."items" ("store_id","store_product_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "items_group_idx" ON "groceries"."items" ("group_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "items_gtin_idx" ON "groceries"."items" ("gtin");--> statement-breakpoint
|
||||||
|
CREATE INDEX "items_name_trgm_idx" ON "groceries"."items" USING gin ("name" gin_trgm_ops);--> statement-breakpoint
|
||||||
|
ALTER TABLE "groceries"."groups" ADD CONSTRAINT "groups_merged_into_id_groups_id_fkey" FOREIGN KEY ("merged_into_id") REFERENCES "groceries"."groups"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "groceries"."import_runs" ADD CONSTRAINT "import_runs_store_id_stores_id_fkey" FOREIGN KEY ("store_id") REFERENCES "groceries"."stores"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "groceries"."item_prices" ADD CONSTRAINT "item_prices_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "groceries"."items"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "groceries"."items" ADD CONSTRAINT "items_store_id_stores_id_fkey" FOREIGN KEY ("store_id") REFERENCES "groceries"."stores"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "groceries"."items" ADD CONSTRAINT "items_group_id_groups_id_fkey" FOREIGN KEY ("group_id") REFERENCES "groceries"."groups"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
|
||||||
|
FOREACH t IN ARRAY ARRAY['groups','items'] LOOP
|
||||||
|
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON groceries.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||||
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Private: app role only (default privileges from the first groceries migration also apply).
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA groceries TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA groceries TO personal_app;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> 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 ('groceries','groups','groceries-groups','Grocery groups','core',true,false,1100),
|
||||||
|
('groceries','items','groceries-items','Grocery store items','core',true,false,1100),
|
||||||
|
('groceries','item_prices','groceries-item-prices','Grocery price history','core',false,false,1100),
|
||||||
|
('groceries','import_runs','groceries-import-runs','Grocery import runs','core',false,false,1100)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||||
|
PERFORM meta.sync();
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
16678
drizzle/20261024134411_groceries_compare/snapshot.json
Normal file
16678
drizzle/20261024134411_groceries_compare/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5
drizzle/20261024140745_house_scrapers/migration.sql
Normal file
5
drizzle/20261024140745_house_scrapers/migration.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE "house"."listings" ADD COLUMN "address_key" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "house"."listings" ADD COLUMN "duplicate_of" integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE "house"."listings" ADD COLUMN "total_price_eur" integer;--> statement-breakpoint
|
||||||
|
CREATE INDEX "listings_address_idx" ON "house"."listings" ("address_key") WHERE address_key IS NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "house"."listings" ADD CONSTRAINT "listings_duplicate_of_listings_id_fkey" FOREIGN KEY ("duplicate_of") REFERENCES "house"."listings"("id") ON DELETE SET NULL;
|
||||||
16755
drizzle/20261024140745_house_scrapers/snapshot.json
Normal file
16755
drizzle/20261024140745_house_scrapers/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
20
drizzle/20261024141609_health_scale_metrics/migration.sql
Normal file
20
drizzle/20261024141609_health_scale_metrics/migration.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
ALTER TABLE "health"."body_measurements" ADD COLUMN "muscle_mass_kg" numeric(5,2);--> statement-breakpoint
|
||||||
|
ALTER TABLE "health"."body_measurements" ADD COLUMN "skeletal_muscle_kg" numeric(5,2);--> statement-breakpoint
|
||||||
|
ALTER TABLE "health"."body_measurements" ADD COLUMN "bone_mass_kg" numeric(4,2);--> statement-breakpoint
|
||||||
|
ALTER TABLE "health"."body_measurements" ADD COLUMN "visceral_fat" numeric(4,1);--> statement-breakpoint
|
||||||
|
ALTER TABLE "health"."body_measurements" ADD COLUMN "body_water_pct" numeric(4,1);--> statement-breakpoint
|
||||||
|
ALTER TABLE "health"."body_measurements" ADD COLUMN "protein_pct" numeric(4,1);--> statement-breakpoint
|
||||||
|
ALTER TABLE "health"."body_measurements" ADD COLUMN "bmr_kcal" integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE "health"."body_measurements" ADD COLUMN "bmi" numeric(4,1);--> statement-breakpoint
|
||||||
|
ALTER TABLE "health"."body_measurements" ADD COLUMN "measured_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "health"."body_measurements" ADD COLUMN "source" text;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "body_measurements_date_weight_key" ON "health"."body_measurements" ("date","weight_kg");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "body_measurements_measured_at_key" ON "health"."body_measurements" ("measured_at");--> statement-breakpoint
|
||||||
|
ALTER TABLE "health"."body_measurements" ADD CONSTRAINT "body_measurements_scale_check" CHECK (("muscle_mass_kg" IS NULL OR "muscle_mass_kg" BETWEEN 5 AND 250)
|
||||||
|
AND ("skeletal_muscle_kg" IS NULL OR "skeletal_muscle_kg" BETWEEN 5 AND 200)
|
||||||
|
AND ("bone_mass_kg" IS NULL OR "bone_mass_kg" BETWEEN 0.5 AND 20)
|
||||||
|
AND ("visceral_fat" IS NULL OR "visceral_fat" BETWEEN 1 AND 60)
|
||||||
|
AND ("body_water_pct" IS NULL OR "body_water_pct" BETWEEN 20 AND 85)
|
||||||
|
AND ("protein_pct" IS NULL OR "protein_pct" BETWEEN 5 AND 40)
|
||||||
|
AND ("bmr_kcal" IS NULL OR "bmr_kcal" BETWEEN 500 AND 6000)
|
||||||
|
AND ("bmi" IS NULL OR "bmi" BETWEEN 10 AND 80));
|
||||||
16941
drizzle/20261024141609_health_scale_metrics/snapshot.json
Normal file
16941
drizzle/20261024141609_health_scale_metrics/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
66
drizzle/20261024150925_home/migration.sql
Normal file
66
drizzle/20261024150925_home/migration.sql
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
CREATE SCHEMA "home";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "home"."links" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"section" text DEFAULT 'links' 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 "links_title_check" CHECK (length(btrim("title")) between 1 and 80),
|
||||||
|
CONSTRAINT "links_url_check" CHECK ("url" ~ '^https?://' and length("url") <= 2000)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "home"."snapshots" (
|
||||||
|
"key" text PRIMARY KEY,
|
||||||
|
"data" jsonb NOT NULL,
|
||||||
|
"collected_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "links_sort_idx" ON "home"."links" ("section","sort_order");--> statement-breakpoint
|
||||||
|
-- Privacy: like tasks and health, the start page is the app role only (no personal_ro).
|
||||||
|
-- Grants only run on prod, where migrations run as `personal`.
|
||||||
|
REVOKE ALL ON SCHEMA home FROM PUBLIC;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT USAGE ON SCHEMA home TO personal_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA home TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA home TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA home
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA home
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||||
|
END IF;
|
||||||
|
-- The host-side collector (deploy/home-collector) may only upsert snapshots. The role is
|
||||||
|
-- created out of band (it has a password); see deploy/home-collector/README.md.
|
||||||
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'home_collector') THEN
|
||||||
|
GRANT USAGE ON SCHEMA home TO home_collector;
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON home.snapshots TO home_collector;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Starter links; edited inline on /start.
|
||||||
|
INSERT INTO "home"."links" ("title", "url", "section", "sort_order") VALUES
|
||||||
|
('Gmail', 'https://mail.google.com/mail/u/0/', 'daily', 10),
|
||||||
|
('Calendar', 'https://calendar.google.com/', 'daily', 20),
|
||||||
|
('WhatsApp', 'https://web.whatsapp.com/', 'daily', 30),
|
||||||
|
('Discord', 'https://discord.com/app', 'daily', 40),
|
||||||
|
('X', 'https://x.com/home', 'daily', 50),
|
||||||
|
('YouTube', 'https://www.youtube.com/', 'daily', 60),
|
||||||
|
('Spotify', 'https://open.spotify.com/', 'daily', 70),
|
||||||
|
('Drive', 'https://drive.google.com/', 'daily', 80),
|
||||||
|
('Moneybird', 'https://moneybird.com/390525278507501515', 'money', 110),
|
||||||
|
('Revolut', 'https://app.revolut.com/', 'money', 120),
|
||||||
|
('Knab', 'https://www.knab.nl/', 'money', 130),
|
||||||
|
('Tikkie', 'https://www.tikkie.me/', 'money', 140),
|
||||||
|
('Gitea', 'https://git.stanrunge.dev/', 'dev', 210),
|
||||||
|
('GitHub', 'https://github.com/', 'dev', 220),
|
||||||
|
('Cloudflare', 'https://dash.cloudflare.com/', 'dev', 230),
|
||||||
|
('Tailscale', 'https://login.tailscale.com/admin/machines', 'dev', 240),
|
||||||
|
('Backblaze B2', 'https://secure.backblaze.com/b2_buckets.htm', 'dev', 250),
|
||||||
|
('1Password', 'https://my.1password.com/', 'dev', 260),
|
||||||
|
('osu!', 'https://osu.ppy.sh/', 'fun', 310),
|
||||||
|
('Twitch', 'https://www.twitch.tv/', 'fun', 320);
|
||||||
17149
drizzle/20261024150925_home/snapshot.json
Normal file
17149
drizzle/20261024150925_home/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
134
drizzle/20261024153902_cars/migration.sql
Normal file
134
drizzle/20261024153902_cars/migration.sql
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE SCHEMA "cars";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "cars"."brands" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"country" text,
|
||||||
|
"model_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"nl_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"first_year" smallint,
|
||||||
|
"last_year" smallint,
|
||||||
|
"sources" 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
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "cars"."models" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"dex" integer NOT NULL,
|
||||||
|
"brand_id" integer NOT NULL,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"search" text NOT NULL,
|
||||||
|
"first_year" smallint,
|
||||||
|
"last_year" smallint,
|
||||||
|
"bodies" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"size_classes" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"fuels" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"drives" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"transmissions" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"engine_l_min" real,
|
||||||
|
"engine_l_max" real,
|
||||||
|
"cylinders_min" smallint,
|
||||||
|
"cylinders_max" smallint,
|
||||||
|
"turbo" boolean,
|
||||||
|
"power_kw_min" real,
|
||||||
|
"power_kw_max" real,
|
||||||
|
"l100_min" real,
|
||||||
|
"l100_max" real,
|
||||||
|
"co2_min" smallint,
|
||||||
|
"co2_max" smallint,
|
||||||
|
"mpg_min" real,
|
||||||
|
"mpg_max" real,
|
||||||
|
"range_km_max" smallint,
|
||||||
|
"seats" smallint,
|
||||||
|
"doors" smallint,
|
||||||
|
"mass_kg" smallint,
|
||||||
|
"nl_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"nl_rank" integer,
|
||||||
|
"nl_first_year" smallint,
|
||||||
|
"nl_last_year" smallint,
|
||||||
|
"nl_price" integer,
|
||||||
|
"nl_price_latest" integer,
|
||||||
|
"nl_price_latest_year" smallint,
|
||||||
|
"nl_years" jsonb DEFAULT '[]' NOT NULL,
|
||||||
|
"nl_names" jsonb DEFAULT '[]' NOT NULL,
|
||||||
|
"us_years" smallint[] DEFAULT '{}'::smallint[] NOT NULL,
|
||||||
|
"variant_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"sources" 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
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "cars"."variants" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"model_id" integer NOT NULL,
|
||||||
|
"source" text DEFAULT 'fueleconomy.gov' NOT NULL,
|
||||||
|
"source_id" integer,
|
||||||
|
"year" smallint NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"engine_l" real,
|
||||||
|
"cylinders" smallint,
|
||||||
|
"transmission" text,
|
||||||
|
"drive" text,
|
||||||
|
"fuel" text,
|
||||||
|
"size_class" text,
|
||||||
|
"mpg_city" real,
|
||||||
|
"mpg_hwy" real,
|
||||||
|
"mpg_comb" real,
|
||||||
|
"co2_gpm" smallint,
|
||||||
|
"range_mi" smallint,
|
||||||
|
"ev_motor" text,
|
||||||
|
"turbo" boolean DEFAULT false NOT NULL,
|
||||||
|
"supercharged" boolean DEFAULT false NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "brands_slug_key" ON "cars"."brands" ("slug");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "models_dex_key" ON "cars"."models" ("dex");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "models_brand_slug_key" ON "cars"."models" ("brand_id","slug");--> statement-breakpoint
|
||||||
|
CREATE INDEX "models_nl_count_idx" ON "cars"."models" ("nl_count");--> statement-breakpoint
|
||||||
|
CREATE INDEX "models_search_trgm_idx" ON "cars"."models" USING gin ("search" public.gin_trgm_ops);--> statement-breakpoint
|
||||||
|
CREATE INDEX "variants_model_year_idx" ON "cars"."variants" ("model_id","year");--> statement-breakpoint
|
||||||
|
ALTER TABLE "cars"."models" ADD CONSTRAINT "models_brand_id_brands_id_fkey" FOREIGN KEY ("brand_id") REFERENCES "cars"."brands"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "cars"."variants" ADD CONSTRAINT "variants_model_id_models_id_fkey" FOREIGN KEY ("model_id") REFERENCES "cars"."models"("id") ON DELETE CASCADE;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Hand-written: updated_at triggers, grants, GUI registration. Data comes from scripts/cars-import.ts.
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH t IN ARRAY ARRAY['brands','models'] LOOP
|
||||||
|
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON cars.%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 cars TO personal_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA cars TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA cars TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA cars GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA cars 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 cars TO personal_ro;
|
||||||
|
GRANT SELECT ON ALL TABLES IN SCHEMA cars TO personal_ro;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA cars GRANT SELECT ON TABLES TO personal_ro;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> 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 ('cars','brands','cars-brands','Car brands','core',false,false,1000),
|
||||||
|
('cars','models','cars-models','Car models','core',false,false,1000)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||||
|
PERFORM meta.sync();
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
18350
drizzle/20261024153902_cars/snapshot.json
Normal file
18350
drizzle/20261024153902_cars/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user