Compare commits
187 Commits
books-ui
...
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 |
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__/
|
||||||
|
|||||||
@@ -25,3 +25,6 @@ static/vendor/
|
|||||||
|
|
||||||
# Generated drizzle snapshots
|
# Generated drizzle snapshots
|
||||||
drizzle/*/snapshot.json
|
drizzle/*/snapshot.json
|
||||||
|
|
||||||
|
# Generated PUBG map intel (scripts/build-pubg-intel.py)
|
||||||
|
static/pubg-intel/
|
||||||
|
|||||||
17
Dockerfile
17
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,7 +6,7 @@ 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
|
||||||
@@ -17,9 +17,20 @@ 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"]
|
||||||
|
|||||||
40
README.md
40
README.md
@@ -255,18 +255,30 @@ Files are read from `BOOKS_ROOT` (default `/books` when it exists, otherwise `~/
|
|||||||
- `BOOKS_ROOT/library/<books.files.path>` for book files (written by `scripts/books-ingest.py`)
|
- `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
|
- `BOOKS_ROOT/covers/<books.works.cover_path>` for downloaded covers
|
||||||
|
|
||||||
In production, `scripts/deploy-remote.sh` writes `/opt/personal-website/compose.books.yml`, which
|
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
|
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
|
`BOOKS_ROOT=/books`. Without a `library` directory there the deploy still succeeds, but files can't
|
||||||
be opened.
|
be opened.
|
||||||
|
|
||||||
## Money tracker
|
## Money
|
||||||
|
|
||||||
`/projects/money-tracker` is restricted to administrator accounts. Its tables
|
`/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
|
||||||
@@ -330,6 +342,28 @@ 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).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
`/trackers/notes` (owner-only, always: every page, API, attachment and the `/ws/notes` socket 404s
|
||||||
|
for anyone else) is a web editor for Stan's Obsidian vault, two-way and near-real-time.
|
||||||
|
|
||||||
|
- **Source of truth:** the markdown files in `~/obsidian-vault` on the server (Syncthing folder
|
||||||
|
`obsidian-vault`, synced to his devices), bind-mounted read-write at `/notes` by
|
||||||
|
`scripts/deploy-remote.sh` (`NOTES_HOST_DIR`). The site reads and writes `.md` files directly:
|
||||||
|
atomic writes (hidden `.notes-tmp-*` file in the same folder, fsync, rename), chowned to the
|
||||||
|
vault owner, frontmatter and everything else kept byte-for-byte. Deleting moves the file to
|
||||||
|
`<vault>/.trash` like Obsidian does.
|
||||||
|
- **Index only:** `notes.files` / `notes.links` (full-text search, tags, wikilinks, backlinks) are
|
||||||
|
rebuilt from the files by `src/lib/server/notes/indexer.ts`: recursive `fs.watch` plus a full
|
||||||
|
rescan every minute. Dropping the tables loses nothing.
|
||||||
|
- **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`.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
There are two environments: local dev and production. Prod is the source of truth
|
There are two environments: local dev and production. Prod is the source of truth
|
||||||
|
|||||||
6
bun.lock
6
bun.lock
@@ -15,6 +15,7 @@
|
|||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
"highlight.js": "^11.12.0",
|
"highlight.js": "^11.12.0",
|
||||||
"jose": "^6.2.10",
|
"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",
|
||||||
@@ -39,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",
|
||||||
@@ -473,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=="],
|
||||||
@@ -771,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=="],
|
||||||
|
|||||||
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
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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { defineConfig } from 'drizzle-kit';
|
|||||||
// - lists: runtime tables created through meta.create_list() (owned by personal_ddl)
|
// - lists: runtime tables created through meta.create_list() (owned by personal_ddl)
|
||||||
// - legacy: pre-reorg tables kept read-only for rollback
|
// - legacy: pre-reorg tables kept read-only for rollback
|
||||||
// - energy: owned by ned-collector
|
// - energy: owned by ned-collector
|
||||||
// - hardware: not modelled yet
|
|
||||||
// Migrations run as the schema owner (`personal`), never as the app role.
|
// Migrations run as the schema owner (`personal`), never as the app role.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
dialect: 'postgresql',
|
dialect: 'postgresql',
|
||||||
@@ -21,7 +20,22 @@ export default defineConfig({
|
|||||||
'site',
|
'site',
|
||||||
'meta',
|
'meta',
|
||||||
'health',
|
'health',
|
||||||
'books'
|
'books',
|
||||||
|
'tasks',
|
||||||
|
'groceries',
|
||||||
|
'home',
|
||||||
|
'cars',
|
||||||
|
'music',
|
||||||
|
'hardware',
|
||||||
|
'planes',
|
||||||
|
'wildlife',
|
||||||
|
'trains',
|
||||||
|
'dataholders',
|
||||||
|
'stats',
|
||||||
|
'ai',
|
||||||
|
'movies',
|
||||||
|
'worldtodo',
|
||||||
|
'notes'
|
||||||
],
|
],
|
||||||
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
||||||
verbose: true,
|
verbose: true,
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
CREATE TYPE "books"."reading_status" AS ENUM('want_to_read', 'reading', 'finished', 'abandoned');--> 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
|
|
||||||
ALTER TABLE "books"."progress" ADD COLUMN "status" "books"."reading_status" DEFAULT 'want_to_read'::"books"."reading_status" NOT NULL;--> statement-breakpoint
|
|
||||||
ALTER TABLE "books"."progress" ADD COLUMN "rating" smallint;--> statement-breakpoint
|
|
||||||
ALTER TABLE "books"."progress" ADD COLUMN "last_read_at" timestamp with time zone;--> 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
|
|
||||||
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"."progress" ADD CONSTRAINT "progress_rating_check" CHECK ("rating" is null or "rating" between 1 and 5);
|
|
||||||
--> 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 $$;
|
|
||||||
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
@@ -1,6 +1,7 @@
|
|||||||
CREATE SCHEMA "books";
|
CREATE SCHEMA "books";
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TYPE "books"."metadata_status" AS ENUM('pending', 'matched', 'low_confidence', 'not_found', 'manual');--> 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" (
|
CREATE TABLE "books"."files" (
|
||||||
"id" serial PRIMARY KEY,
|
"id" serial PRIMARY KEY,
|
||||||
"work_id" integer NOT NULL,
|
"work_id" integer NOT NULL,
|
||||||
@@ -36,9 +37,32 @@ CREATE TABLE "books"."progress" (
|
|||||||
"percent" real DEFAULT 0 NOT NULL,
|
"percent" real DEFAULT 0 NOT NULL,
|
||||||
"started_at" timestamp with time zone,
|
"started_at" timestamp with time zone,
|
||||||
"finished_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,
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
"updated_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_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
|
--> statement-breakpoint
|
||||||
CREATE TABLE "books"."shelf_works" (
|
CREATE TABLE "books"."shelf_works" (
|
||||||
@@ -92,6 +116,9 @@ CREATE UNIQUE INDEX "files_sha256_key" ON "books"."files" ("sha256");--> stateme
|
|||||||
CREATE INDEX "files_work_id_idx" ON "books"."files" ("work_id");--> 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 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 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 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 "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 UNIQUE INDEX "works_isbn13_key" ON "books"."works" ("isbn13") WHERE "isbn13" is not null;--> statement-breakpoint
|
||||||
@@ -101,6 +128,8 @@ ALTER TABLE "books"."notes" ADD CONSTRAINT "notes_work_id_works_id_fkey" FOREIGN
|
|||||||
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"."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_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"."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_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;
|
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
|
--> statement-breakpoint
|
||||||
@@ -141,6 +170,25 @@ BEGIN
|
|||||||
('books','shelves','books-shelves','Book shelves','core',true,false,1000),
|
('books','shelves','books-shelves','Book shelves','core',true,false,1000),
|
||||||
('books','files','books-files','Book files','core',false,false,1000)
|
('books','files','books-files','Book files','core',false,false,1000)
|
||||||
ON CONFLICT DO NOTHING;
|
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();
|
PERFORM meta.sync();
|
||||||
END IF;
|
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 $$;
|
END $$;
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"version": "8",
|
"version": "8",
|
||||||
"dialect": "postgres",
|
"dialect": "postgres",
|
||||||
"id": "f239c655-63c8-4807-b44f-b7698330d30a",
|
"id": "b9c155ef-74b1-4481-acaa-7649155ee04f",
|
||||||
"prevIds": [
|
"prevIds": [
|
||||||
"ffad1d0e-51e0-4302-9ab2-022eba184390"
|
"46aa2cce-9f94-40c6-ae0d-1489f5164c74"
|
||||||
],
|
],
|
||||||
"ddl": [
|
"ddl": [
|
||||||
{
|
{
|
||||||
@@ -5265,6 +5265,45 @@
|
|||||||
"schema": "money",
|
"schema": "money",
|
||||||
"table": "accounts"
|
"table": "accounts"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "bank_balance_cents",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "money",
|
||||||
|
"table": "accounts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "bank_balance_type",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "money",
|
||||||
|
"table": "accounts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "bank_balance_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "money",
|
||||||
|
"table": "accounts"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "timestamp with time zone",
|
"type": "timestamp with time zone",
|
||||||
"typeSchema": null,
|
"typeSchema": null,
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "money"."accounts" ADD COLUMN "include_in_total" boolean DEFAULT true NOT NULL;
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"version": "8",
|
"version": "8",
|
||||||
"dialect": "postgres",
|
"dialect": "postgres",
|
||||||
"id": "ffad1d0e-51e0-4302-9ab2-022eba184390",
|
"id": "d7b58ea0-d24b-4872-b34b-d03d7e202744",
|
||||||
"prevIds": [
|
"prevIds": [
|
||||||
"2aa940a3-e75f-4f6f-b12f-6935c9bd4f9c"
|
"b9c155ef-74b1-4481-acaa-7649155ee04f"
|
||||||
],
|
],
|
||||||
"ddl": [
|
"ddl": [
|
||||||
{
|
{
|
||||||
@@ -85,6 +85,17 @@
|
|||||||
"entityType": "enums",
|
"entityType": "enums",
|
||||||
"schema": "books"
|
"schema": "books"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"values": [
|
||||||
|
"want_to_read",
|
||||||
|
"reading",
|
||||||
|
"finished",
|
||||||
|
"abandoned"
|
||||||
|
],
|
||||||
|
"name": "reading_status",
|
||||||
|
"entityType": "enums",
|
||||||
|
"schema": "books"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"isRlsEnabled": false,
|
"isRlsEnabled": false,
|
||||||
"name": "account",
|
"name": "account",
|
||||||
@@ -493,6 +504,12 @@
|
|||||||
"entityType": "tables",
|
"entityType": "tables",
|
||||||
"schema": "books"
|
"schema": "books"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "sessions",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "books"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"isRlsEnabled": false,
|
"isRlsEnabled": false,
|
||||||
"name": "shelf_works",
|
"name": "shelf_works",
|
||||||
@@ -5170,6 +5187,19 @@
|
|||||||
"schema": "money",
|
"schema": "money",
|
||||||
"table": "accounts"
|
"table": "accounts"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "true",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "include_in_total",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "money",
|
||||||
|
"table": "accounts"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"typeSchema": null,
|
"typeSchema": null,
|
||||||
@@ -5248,6 +5278,45 @@
|
|||||||
"schema": "money",
|
"schema": "money",
|
||||||
"table": "accounts"
|
"table": "accounts"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "bank_balance_cents",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "money",
|
||||||
|
"table": "accounts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "bank_balance_type",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "money",
|
||||||
|
"table": "accounts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "bank_balance_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "money",
|
||||||
|
"table": "accounts"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "timestamp with time zone",
|
"type": "timestamp with time zone",
|
||||||
"typeSchema": null,
|
"typeSchema": null,
|
||||||
@@ -8831,6 +8900,45 @@
|
|||||||
"schema": "books",
|
"schema": "books",
|
||||||
"table": "progress"
|
"table": "progress"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "reading_status",
|
||||||
|
"typeSchema": "books",
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "'want_to_read'",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "status",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "progress"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "smallint",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "rating",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "progress"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "last_read_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "progress"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "timestamp with time zone",
|
"type": "timestamp with time zone",
|
||||||
"typeSchema": null,
|
"typeSchema": null,
|
||||||
@@ -8857,6 +8965,188 @@
|
|||||||
"schema": "books",
|
"schema": "books",
|
||||||
"table": "progress"
|
"table": "progress"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "serial",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "work_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "file_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "started_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "ended_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "real",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "start_percent",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "real",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "end_percent",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "start_page",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "end_page",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "pages_read",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "'reader'",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "source",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "note",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "created_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "updated_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"typeSchema": null,
|
"typeSchema": null,
|
||||||
@@ -10502,6 +10792,69 @@
|
|||||||
"schema": "books",
|
"schema": "books",
|
||||||
"table": "progress"
|
"table": "progress"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"value": "status",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nullsFirst": false,
|
||||||
|
"opclass": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"where": null,
|
||||||
|
"with": "",
|
||||||
|
"method": "btree",
|
||||||
|
"concurrently": false,
|
||||||
|
"name": "progress_status_idx",
|
||||||
|
"entityType": "indexes",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "progress"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"value": "work_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nullsFirst": false,
|
||||||
|
"opclass": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"where": null,
|
||||||
|
"with": "",
|
||||||
|
"method": "btree",
|
||||||
|
"concurrently": false,
|
||||||
|
"name": "sessions_work_id_idx",
|
||||||
|
"entityType": "indexes",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"value": "started_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nullsFirst": false,
|
||||||
|
"opclass": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"where": null,
|
||||||
|
"with": "",
|
||||||
|
"method": "btree",
|
||||||
|
"concurrently": false,
|
||||||
|
"name": "sessions_started_at_idx",
|
||||||
|
"entityType": "indexes",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"nameExplicit": true,
|
"nameExplicit": true,
|
||||||
"columns": [
|
"columns": [
|
||||||
@@ -11742,6 +12095,40 @@
|
|||||||
"schema": "books",
|
"schema": "books",
|
||||||
"table": "progress"
|
"table": "progress"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"work_id"
|
||||||
|
],
|
||||||
|
"schemaTo": "books",
|
||||||
|
"tableTo": "works",
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"name": "sessions_work_id_works_id_fkey",
|
||||||
|
"entityType": "fks",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"file_id"
|
||||||
|
],
|
||||||
|
"schemaTo": "books",
|
||||||
|
"tableTo": "files",
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"onDelete": "SET NULL",
|
||||||
|
"name": "sessions_file_id_files_id_fkey",
|
||||||
|
"entityType": "fks",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"columns": [
|
"columns": [
|
||||||
@@ -12471,6 +12858,16 @@
|
|||||||
"table": "progress",
|
"table": "progress",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "sessions_pkey",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": [
|
||||||
"id"
|
"id"
|
||||||
@@ -13305,6 +13702,27 @@
|
|||||||
"schema": "books",
|
"schema": "books",
|
||||||
"table": "progress"
|
"table": "progress"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"value": "\"rating\" is null or \"rating\" between 1 and 5",
|
||||||
|
"name": "progress_rating_check",
|
||||||
|
"entityType": "checks",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "progress"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "(\"start_percent\" is null or \"start_percent\" between 0 and 100) and (\"end_percent\" is null or \"end_percent\" between 0 and 100)",
|
||||||
|
"name": "sessions_percent_check",
|
||||||
|
"entityType": "checks",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "\"ended_at\" is null or \"ended_at\" >= \"started_at\"",
|
||||||
|
"name": "sessions_end_check",
|
||||||
|
"entityType": "checks",
|
||||||
|
"schema": "books",
|
||||||
|
"table": "sessions"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"value": "\"year\" is null or \"year\" between 0 and 2100",
|
"value": "\"year\" is null or \"year\" between 0 and 2100",
|
||||||
"name": "works_year_check",
|
"name": "works_year_check",
|
||||||
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
11
drizzle/20261024154032_house_liveability/migration.sql
Normal file
11
drizzle/20261024154032_house_liveability/migration.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE "house"."listings" ADD COLUMN "lbm_score" double precision;--> statement-breakpoint
|
||||||
|
ALTER TABLE "house"."listings" ADD COLUMN "lbm_class" integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE "house"."listings" ADD COLUMN "lbm_onv_class" integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE "house"."listings" ADD COLUMN "lbm_level" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "house"."listings" ADD COLUMN "lbm_precision" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "house"."listings" ADD COLUMN "lbm_buurt_code" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "house"."listings" ADD COLUMN "lbm_buurt_name" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "house"."listings" ADD COLUMN "lbm_year" integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE "house"."listings" ADD COLUMN "lbm_detail" jsonb;--> statement-breakpoint
|
||||||
|
ALTER TABLE "house"."listings" ADD COLUMN "lbm_query" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "house"."listings" ADD COLUMN "lbm_scored_at" timestamp with time zone;
|
||||||
18493
drizzle/20261024154032_house_liveability/snapshot.json
Normal file
18493
drizzle/20261024154032_house_liveability/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
159
drizzle/20261024155715_music/migration.sql
Normal file
159
drizzle/20261024155715_music/migration.sql
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
CREATE SCHEMA "music";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "music"."albums" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"spotify_id" text,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"artist_id" integer,
|
||||||
|
"image_url" text,
|
||||||
|
"image_url_small" text,
|
||||||
|
"release_date" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "music"."artists" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"spotify_id" text,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"image_url" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "music"."import_state" (
|
||||||
|
"key" text PRIMARY KEY,
|
||||||
|
"data" jsonb NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "music"."now_playing" (
|
||||||
|
"id" smallint PRIMARY KEY DEFAULT 1,
|
||||||
|
"track_id" integer,
|
||||||
|
"is_playing" boolean DEFAULT false NOT NULL,
|
||||||
|
"progress_ms" integer,
|
||||||
|
"device_name" text,
|
||||||
|
"device_type" text,
|
||||||
|
"context_uri" text,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "now_playing_single_row" CHECK ("id" = 1)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "music"."plays" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"track_id" integer NOT NULL,
|
||||||
|
"played_at" timestamp with time zone NOT NULL,
|
||||||
|
"source" text DEFAULT 'spotify' NOT NULL,
|
||||||
|
"origin" text DEFAULT 'api' NOT NULL,
|
||||||
|
"ms_played" integer,
|
||||||
|
"context_type" text,
|
||||||
|
"context_uri" text,
|
||||||
|
"platform" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "plays_source_check" CHECK ("source" in ('spotify', 'lastfm', 'youtube', 'local')),
|
||||||
|
CONSTRAINT "plays_origin_check" CHECK ("origin" in ('api', 'export', 'import', 'manual'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "music"."spotify_auth" (
|
||||||
|
"id" smallint PRIMARY KEY DEFAULT 1,
|
||||||
|
"spotify_user_id" text NOT NULL,
|
||||||
|
"display_name" text,
|
||||||
|
"refresh_token" text NOT NULL,
|
||||||
|
"access_token" text,
|
||||||
|
"access_expires_at" timestamp with time zone,
|
||||||
|
"scope" text,
|
||||||
|
"recent_cursor" bigint,
|
||||||
|
"needs_reauth" boolean DEFAULT false NOT NULL,
|
||||||
|
"lease_until" timestamp with time zone,
|
||||||
|
"last_poll_at" timestamp with time zone,
|
||||||
|
"last_error" text,
|
||||||
|
"connected_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "spotify_auth_single_row" CHECK ("id" = 1)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "music"."track_artists" (
|
||||||
|
"track_id" integer,
|
||||||
|
"artist_id" integer,
|
||||||
|
"position" smallint DEFAULT 0 NOT NULL,
|
||||||
|
CONSTRAINT "track_artists_pkey" PRIMARY KEY("track_id","artist_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "music"."tracks" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"spotify_id" text,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"album_id" integer,
|
||||||
|
"artist_id" integer,
|
||||||
|
"duration_ms" integer,
|
||||||
|
"explicit" boolean,
|
||||||
|
"match_key" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "albums_spotify_id_key" ON "music"."albums" ("spotify_id") WHERE "spotify_id" is not null;--> statement-breakpoint
|
||||||
|
CREATE INDEX "albums_name_idx" ON "music"."albums" (lower("name"));--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "artists_spotify_id_key" ON "music"."artists" ("spotify_id") WHERE "spotify_id" is not null;--> statement-breakpoint
|
||||||
|
CREATE INDEX "artists_name_idx" ON "music"."artists" (lower("name"));--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "plays_dedupe_key" ON "music"."plays" ("source","track_id","played_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "plays_played_at_idx" ON "music"."plays" ("played_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "plays_source_played_idx" ON "music"."plays" ("source","played_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "plays_track_played_idx" ON "music"."plays" ("track_id","played_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "track_artists_artist_idx" ON "music"."track_artists" ("artist_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "tracks_spotify_id_key" ON "music"."tracks" ("spotify_id") WHERE "spotify_id" is not null;--> statement-breakpoint
|
||||||
|
CREATE INDEX "tracks_name_idx" ON "music"."tracks" (lower("name"));--> statement-breakpoint
|
||||||
|
CREATE INDEX "tracks_match_key_idx" ON "music"."tracks" ("match_key");--> statement-breakpoint
|
||||||
|
ALTER TABLE "music"."albums" ADD CONSTRAINT "albums_artist_id_artists_id_fkey" FOREIGN KEY ("artist_id") REFERENCES "music"."artists"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "music"."now_playing" ADD CONSTRAINT "now_playing_track_id_tracks_id_fkey" FOREIGN KEY ("track_id") REFERENCES "music"."tracks"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "music"."plays" ADD CONSTRAINT "plays_track_id_tracks_id_fkey" FOREIGN KEY ("track_id") REFERENCES "music"."tracks"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "music"."track_artists" ADD CONSTRAINT "track_artists_track_id_tracks_id_fkey" FOREIGN KEY ("track_id") REFERENCES "music"."tracks"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "music"."track_artists" ADD CONSTRAINT "track_artists_artist_id_artists_id_fkey" FOREIGN KEY ("artist_id") REFERENCES "music"."artists"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "music"."tracks" ADD CONSTRAINT "tracks_album_id_albums_id_fkey" FOREIGN KEY ("album_id") REFERENCES "music"."albums"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "music"."tracks" ADD CONSTRAINT "tracks_artist_id_artists_id_fkey" FOREIGN KEY ("artist_id") REFERENCES "music"."artists"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
-- Hand-written below. Cross-source dedupe: every stat reads plays_deduped, which hides a
|
||||||
|
-- Last.fm scrobble when Spotify (live API or the streaming-history export) has the same
|
||||||
|
-- normalised artist+title around the same time. Last.fm stores when a track started,
|
||||||
|
-- Spotify when it ended, so the Spotify end may fall from 3 min before the scrobble to
|
||||||
|
-- track length + 3 min after it. Spotify rows always win; Last.fm fills the gaps.
|
||||||
|
CREATE VIEW "music"."plays_deduped" AS
|
||||||
|
SELECT p.* FROM "music"."plays" p
|
||||||
|
WHERE p.source <> 'lastfm' OR NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "music"."tracks" lt, "music"."plays" s
|
||||||
|
JOIN "music"."tracks" st ON st.id = s.track_id
|
||||||
|
WHERE lt.id = p.track_id
|
||||||
|
AND s.source = 'spotify'
|
||||||
|
AND s.played_at >= p.played_at - interval '3 minutes'
|
||||||
|
AND s.played_at <= p.played_at + interval '25 minutes'
|
||||||
|
AND s.played_at <= p.played_at + make_interval(secs => coalesce(st.duration_ms, 600000) / 1000.0) + interval '3 minutes'
|
||||||
|
AND st.match_key = lt.match_key
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Grants only run on prod, where migrations run as `personal`. Listening history is public
|
||||||
|
-- (personal_ro may read it); the Spotify refresh token is for the app role only.
|
||||||
|
REVOKE ALL ON SCHEMA music 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 music TO personal_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA music TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA music TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA music
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA music
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||||
|
END IF;
|
||||||
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||||
|
GRANT USAGE ON SCHEMA music TO personal_ro;
|
||||||
|
GRANT SELECT ON music.artists, music.albums, music.tracks, music.track_artists, music.plays,
|
||||||
|
music.plays_deduped, music.now_playing TO personal_ro;
|
||||||
|
END IF;
|
||||||
|
-- Optional host-side poller (deploy/music-collector). The role is created out of band
|
||||||
|
-- (it has a password); see deploy/music-collector/README.md.
|
||||||
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'music_collector') THEN
|
||||||
|
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;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
19833
drizzle/20261024155715_music/snapshot.json
Normal file
19833
drizzle/20261024155715_music/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1
drizzle/20261024160855_tasks_auto_handle/migration.sql
Normal file
1
drizzle/20261024160855_tasks_auto_handle/migration.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "tasks"."tasks" ADD COLUMN "auto_handle" boolean DEFAULT false NOT NULL;
|
||||||
19846
drizzle/20261024160855_tasks_auto_handle/snapshot.json
Normal file
19846
drizzle/20261024160855_tasks_auto_handle/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7
drizzle/20261024160937_osu_match_cache/migration.sql
Normal file
7
drizzle/20261024160937_osu_match_cache/migration.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE "music"."osu_match_cache" (
|
||||||
|
"key" text PRIMARY KEY,
|
||||||
|
"result" jsonb NOT NULL,
|
||||||
|
"searched_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN IF current_user = 'personal' THEN GRANT SELECT, INSERT, UPDATE, DELETE ON music.osu_match_cache TO personal_app; END IF; END $$;
|
||||||
19901
drizzle/20261024160937_osu_match_cache/snapshot.json
Normal file
19901
drizzle/20261024160937_osu_match_cache/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
50
drizzle/20261024161853_cars_photos/migration.sql
Normal file
50
drizzle/20261024161853_cars_photos/migration.sql
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
CREATE TABLE "cars"."photos" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"model_id" integer NOT NULL,
|
||||||
|
"position" smallint NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"file" text NOT NULL,
|
||||||
|
"caption" text,
|
||||||
|
"thumb" text NOT NULL,
|
||||||
|
"width" integer NOT NULL,
|
||||||
|
"height" integer NOT NULL,
|
||||||
|
"author" text,
|
||||||
|
"license" text,
|
||||||
|
"license_url" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "cars"."brands" ADD COLUMN "wikidata" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "cars"."brands" ADD COLUMN "wikipedia" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "cars"."brands" ADD COLUMN "logo" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "cars"."brands" ADD COLUMN "logo_file" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "cars"."brands" ADD COLUMN "logo_author" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "cars"."brands" ADD COLUMN "logo_license" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "cars"."brands" ADD COLUMN "photo" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "cars"."models" ADD COLUMN "wikidata" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "cars"."models" ADD COLUMN "wikipedia" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "cars"."models" ADD COLUMN "photo" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "cars"."models" ADD COLUMN "photo_count" smallint DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "cars"."models" ADD COLUMN "photos_checked_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "photos_model_position_key" ON "cars"."photos" ("model_id","position");--> statement-breakpoint
|
||||||
|
ALTER TABLE "cars"."photos" ADD CONSTRAINT "photos_model_id_models_id_fkey" FOREIGN KEY ("model_id") REFERENCES "cars"."models"("id") ON DELETE CASCADE;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- hand-written: grants + registry for the photos table
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON cars.photos TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCE cars.photos_id_seq TO personal_app;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||||
|
GRANT SELECT ON cars.photos 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','photos','cars-photos','Car photos','core',false,false,1000)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
20274
drizzle/20261024161853_cars_photos/snapshot.json
Normal file
20274
drizzle/20261024161853_cars_photos/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1
drizzle/20261024172700_music_lease_holder/migration.sql
Normal file
1
drizzle/20261024172700_music_lease_holder/migration.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "music"."spotify_auth" ADD COLUMN "lease_holder" text;
|
||||||
20287
drizzle/20261024172700_music_lease_holder/snapshot.json
Normal file
20287
drizzle/20261024172700_music_lease_holder/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
290
drizzle/20261024174805_hardware/migration.sql
Normal file
290
drizzle/20261024174805_hardware/migration.sql
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
-- Hand-reconciled: the hardware schema already existed (hand-made 2026-10-22 from Stan's list +
|
||||||
|
-- order mails, see ~/dev/hardware/schema.sql; prices later moved to cents). This brings an existing
|
||||||
|
-- schema to the Drizzle model, and creates it from scratch on an empty database.
|
||||||
|
CREATE SCHEMA IF NOT EXISTS "hardware";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "hardware"."devices" (
|
||||||
|
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hardware"."devices_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||||
|
"name" text NOT NULL UNIQUE,
|
||||||
|
"label" text,
|
||||||
|
"hostnames" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"status" text DEFAULT 'active' NOT NULL,
|
||||||
|
"brand" text,
|
||||||
|
"model" text,
|
||||||
|
"specs" jsonb DEFAULT '{}' NOT NULL,
|
||||||
|
"price_cents" integer,
|
||||||
|
"monthly_cents" integer,
|
||||||
|
"budget_cents" integer,
|
||||||
|
"shop" text,
|
||||||
|
"purchase_date" date,
|
||||||
|
"url" text,
|
||||||
|
"image_url" text,
|
||||||
|
"sort_order" integer DEFAULT 100 NOT NULL,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "devices_kind_check" CHECK (kind in ('desktop', 'laptop', 'server', 'vm', 'phone', 'tablet', 'monitor', 'other')),
|
||||||
|
CONSTRAINT "devices_status_check" CHECK (status in ('active', 'spare', 'planned', 'retired', 'sold')),
|
||||||
|
CONSTRAINT "devices_cents_check" CHECK ((price_cents is null or price_cents >= 0) and (monthly_cents is null or monthly_cents >= 0) and (budget_cents is null or budget_cents >= 0))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "hardware"."components" (
|
||||||
|
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hardware"."components_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||||
|
"device_id" bigint NOT NULL,
|
||||||
|
"category" text NOT NULL,
|
||||||
|
"brand" text,
|
||||||
|
"model" text NOT NULL,
|
||||||
|
"quantity" integer DEFAULT 1 NOT NULL,
|
||||||
|
"specs" jsonb DEFAULT '{}' NOT NULL,
|
||||||
|
"unit_price_cents" integer,
|
||||||
|
"total_price_cents" integer,
|
||||||
|
"shop" text,
|
||||||
|
"order_number" text,
|
||||||
|
"order_date" date,
|
||||||
|
"warranty_until" date,
|
||||||
|
"invoice_number" text,
|
||||||
|
"status" text DEFAULT 'in_use' NOT NULL,
|
||||||
|
"verified" boolean DEFAULT true NOT NULL,
|
||||||
|
"dpi" integer,
|
||||||
|
"measured_dpi" integer,
|
||||||
|
"url" text,
|
||||||
|
"image_url" text,
|
||||||
|
"notes" text,
|
||||||
|
"source" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "components_dedupe_uq" UNIQUE NULLS NOT DISTINCT("device_id","model","order_number"),
|
||||||
|
CONSTRAINT "components_category_check" CHECK (category in ('case', 'cpu', 'motherboard', 'ram', 'cpu_cooler', 'gpu', 'psu', 'storage', 'fan', 'lighting', 'monitor', 'mouse', 'keyboard', 'headset', 'audio', 'controller', 'accessory', 'other')),
|
||||||
|
CONSTRAINT "components_status_check" CHECK (status in ('in_use', 'planned', 'spare', 'retired', 'sold')),
|
||||||
|
CONSTRAINT "components_quantity_check" CHECK (quantity > 0),
|
||||||
|
CONSTRAINT "components_unit_price_cents_check" CHECK (unit_price_cents >= 0),
|
||||||
|
CONSTRAINT "components_total_price_cents_check" CHECK (total_price_cents >= 0),
|
||||||
|
CONSTRAINT "components_dpi_check" CHECK ((dpi is null or dpi > 0) and (measured_dpi is null or measured_dpi > 0))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "hardware"."sensitivities" (
|
||||||
|
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hardware"."sensitivities_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||||
|
"game" text NOT NULL UNIQUE,
|
||||||
|
"cm_per_360" numeric(7,2),
|
||||||
|
"yaw" numeric(10,6),
|
||||||
|
"in_game_sens" numeric(10,4),
|
||||||
|
"mouse_id" bigint,
|
||||||
|
"fov" text,
|
||||||
|
"notes" text,
|
||||||
|
"sort_order" integer DEFAULT 100 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "sensitivities_cm_check" CHECK (cm_per_360 is null or cm_per_360 > 0),
|
||||||
|
CONSTRAINT "sensitivities_yaw_check" CHECK (yaw is null or yaw > 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Existing tables: new columns.
|
||||||
|
ALTER TABLE "hardware"."devices"
|
||||||
|
ADD COLUMN IF NOT EXISTS "label" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "hostnames" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS "status" text DEFAULT 'active' NOT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS "brand" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "model" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "specs" jsonb DEFAULT '{}' NOT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS "price_cents" integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS "monthly_cents" integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS "budget_cents" integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS "shop" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "purchase_date" date,
|
||||||
|
ADD COLUMN IF NOT EXISTS "url" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "image_url" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "sort_order" integer DEFAULT 100 NOT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS "updated_at" timestamp with time zone DEFAULT now() NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "hardware"."components"
|
||||||
|
ADD COLUMN IF NOT EXISTS "dpi" integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS "measured_dpi" integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS "url" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "image_url" text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Checks: widen kinds/categories/statuses (drop + re-add by name).
|
||||||
|
ALTER TABLE "hardware"."devices" DROP CONSTRAINT IF EXISTS "devices_kind_check";
|
||||||
|
ALTER TABLE "hardware"."devices" ADD CONSTRAINT "devices_kind_check" CHECK (kind in ('desktop', 'laptop', 'server', 'vm', 'phone', 'tablet', 'monitor', 'other'));
|
||||||
|
ALTER TABLE "hardware"."devices" DROP CONSTRAINT IF EXISTS "devices_status_check";
|
||||||
|
ALTER TABLE "hardware"."devices" ADD CONSTRAINT "devices_status_check" CHECK (status in ('active', 'spare', 'planned', 'retired', 'sold'));
|
||||||
|
ALTER TABLE "hardware"."devices" DROP CONSTRAINT IF EXISTS "devices_cents_check";
|
||||||
|
ALTER TABLE "hardware"."devices" ADD CONSTRAINT "devices_cents_check" CHECK ((price_cents is null or price_cents >= 0) and (monthly_cents is null or monthly_cents >= 0) and (budget_cents is null or budget_cents >= 0));
|
||||||
|
ALTER TABLE "hardware"."components" DROP CONSTRAINT IF EXISTS "components_category_check";
|
||||||
|
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_category_check" CHECK (category in ('case', 'cpu', 'motherboard', 'ram', 'cpu_cooler', 'gpu', 'psu', 'storage', 'fan', 'lighting', 'monitor', 'mouse', 'keyboard', 'headset', 'audio', 'controller', 'accessory', 'other'));
|
||||||
|
ALTER TABLE "hardware"."components" DROP CONSTRAINT IF EXISTS "components_status_check";
|
||||||
|
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_status_check" CHECK (status in ('in_use', 'planned', 'spare', 'retired', 'sold'));
|
||||||
|
ALTER TABLE "hardware"."components" DROP CONSTRAINT IF EXISTS "components_dpi_check";
|
||||||
|
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_dpi_check" CHECK ((dpi is null or dpi > 0) and (measured_dpi is null or measured_dpi > 0));
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Dedupe key: unique index -> constraint (same name, same NULLS NOT DISTINCT semantics);
|
||||||
|
-- device FK -> Drizzle's name.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'components_dedupe_uq' AND connamespace = 'hardware'::regnamespace) THEN
|
||||||
|
DROP INDEX IF EXISTS "hardware"."components_dedupe_uq";
|
||||||
|
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_dedupe_uq" UNIQUE NULLS NOT DISTINCT ("device_id","model","order_number");
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'components_device_id_fkey' AND connamespace = 'hardware'::regnamespace) THEN
|
||||||
|
ALTER TABLE "hardware"."components" RENAME CONSTRAINT "components_device_id_fkey" TO "components_device_id_devices_id_fkey";
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'components_device_id_devices_id_fkey' AND connamespace = 'hardware'::regnamespace) THEN
|
||||||
|
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_device_id_devices_id_fkey" FOREIGN KEY ("device_id") REFERENCES "hardware"."devices"("id") ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'sensitivities_mouse_id_components_id_fkey' AND connamespace = 'hardware'::regnamespace) THEN
|
||||||
|
ALTER TABLE "hardware"."sensitivities" ADD CONSTRAINT "sensitivities_mouse_id_components_id_fkey" FOREIGN KEY ("mouse_id") REFERENCES "hardware"."components"("id") ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "components_device_idx" ON "hardware"."components" ("device_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "components_category_idx" ON "hardware"."components" ("category");
|
||||||
|
CREATE INDEX IF NOT EXISTS "components_specs_gin" ON "hardware"."components" USING gin ("specs");
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Hand-written: updated_at triggers, overview view, grants, GUI registration, seed.
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH t IN ARRAY ARRAY['devices','components','sensitivities'] LOOP
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'touch_updated_at' AND tgrelid = format('hardware.%I', t)::regclass) THEN
|
||||||
|
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON hardware.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP VIEW IF EXISTS "hardware"."setup_overview";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE VIEW "hardware"."setup_overview" AS
|
||||||
|
SELECT d.name AS device,
|
||||||
|
d.label AS device_label,
|
||||||
|
d.kind AS device_kind,
|
||||||
|
d.status AS device_status,
|
||||||
|
c.category,
|
||||||
|
c.brand,
|
||||||
|
c.model,
|
||||||
|
c.quantity,
|
||||||
|
c.total_price_cents,
|
||||||
|
round(c.total_price_cents::numeric / 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.dpi,
|
||||||
|
c.measured_dpi,
|
||||||
|
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.sort_order, d.name,
|
||||||
|
array_position(ARRAY['case','cpu','motherboard','ram','cpu_cooler','gpu','psu','storage','fan','lighting','monitor','mouse','keyboard','headset','audio','controller','accessory','other'], c.category),
|
||||||
|
c.order_date NULLS LAST, c.model;
|
||||||
|
--> statement-breakpoint
|
||||||
|
COMMENT ON VIEW "hardware"."setup_overview" IS 'Flat view of every component per device (build order), with a live under_warranty flag. status = ''in_use'' is the current setup, ''planned'' the upgrade plan.';
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT USAGE ON SCHEMA hardware TO personal_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA hardware TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA hardware TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA hardware GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA hardware 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 hardware TO personal_ro;
|
||||||
|
GRANT SELECT ON ALL TABLES IN SCHEMA hardware TO personal_ro;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA hardware 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 ('hardware','devices','hardware-devices','Hardware devices','core',true,false,1000),
|
||||||
|
('hardware','components','hardware-components','Hardware components','core',true,false,1000),
|
||||||
|
('hardware','sensitivities','hardware-sensitivities','Game sensitivities (cm/360)','core',true,false,1000)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||||
|
PERFORM meta.sync();
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- ===== Seed (idempotent). Facts from Stan's own list, order mails and what he told Daisy;
|
||||||
|
-- unknowns stay null. No home address in public-facing notes.
|
||||||
|
INSERT INTO hardware.devices (name, kind) VALUES ('stan-pc','desktop'), ('old-pc-2021','desktop')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE hardware.devices SET
|
||||||
|
label = 'Main PC', hostnames = '{stan-arch,stan-win11}', status = 'active', model = 'Custom build',
|
||||||
|
shop = 'Megekko', sort_order = 10,
|
||||||
|
notes = 'Stan''s main PC: Lian Li O11 Dynamic XL ROG White, i9-12900KF, RX 7900 XT, 10 Lian Li fans around the case + 3 NZXT Aer on the radiator. Dual-boots Arch (stan-arch) and Windows 11 + WSL2 (stan-win11). Desk peripherals and the Odyssey G9 are tracked here. Mostly bought at Megekko 2021-2022.'
|
||||||
|
WHERE name = 'stan-pc' AND label IS NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE hardware.devices SET
|
||||||
|
label = 'Previous PC (2021)', status = 'retired', model = 'Budget build', shop = 'Megekko',
|
||||||
|
purchase_date = '2021-01-31', sort_order = 90
|
||||||
|
WHERE name = 'old-pc-2021' AND label IS NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO hardware.devices (name, label, hostnames, kind, status, brand, model, specs, price_cents, monthly_cents, budget_cents, shop, purchase_date, url, sort_order, notes) VALUES
|
||||||
|
('stan-pc-next','Next PC (planned)','{}','desktop','planned',NULL,'High-end AMD + RTX 5090 build','{}',NULL,NULL,1000000,NULL,NULL,NULL,15,
|
||||||
|
'Planned upgrade, budget up to about EUR 10k: top AMD X3D CPU, RTX 5090, fast DDR5, PCIe Gen5 SSD, high-end motherboard and a cooler that fits. Parts not picked yet.'),
|
||||||
|
('stan-ovh','Server','{}','server','active','OVHcloud','RISE-S',
|
||||||
|
'{"cpu":"AMD Ryzen 7 9700X","ram":"64 GB DDR5 ECC","storage":"2x 512 GB NVMe (software RAID1)","os":"Ubuntu 26.04"}',
|
||||||
|
NULL,6174,NULL,'OVHcloud','2026-10-24','https://www.ovhcloud.com/en/bare-metal/rise/',20,
|
||||||
|
'Dedicated server, prod since 2026-10-24 (replaced stan-vm). EUR 61.74/month excl. VAT. Runs Postgres, Gitea, the website and the bots.'),
|
||||||
|
('stan-vm','Old VM','{}','vm','retired','Hetzner Cloud',NULL,'{"vcpu":4,"ram":"8 GB"}',NULL,2000,NULL,'Hetzner',NULL,NULL,80,
|
||||||
|
'Old always-on Hetzner VM (about EUR 20/month, bought before the price rises). Replaced by stan-ovh on 2026-10-24; stopped, kept as a fallback until Stan cancels it.'),
|
||||||
|
('stan-macbookpro','MacBook Pro','{}','laptop','active','Apple','MacBook Pro','{}',NULL,NULL,NULL,NULL,NULL,NULL,30,
|
||||||
|
'Exact model/chip not recorded yet. An M5 Max MacBook Pro is planned.'),
|
||||||
|
('stan-pixel','Phone','{}','phone','active','Google','Pixel 11 Pro XL','{"storage":"256 GB"}',NULL,NULL,NULL,'Belsimpel','2026-10-18',NULL,40,
|
||||||
|
'Main phone since 2026-10-19, stock Android. About EUR 1,299 SIM-free (exact amount not recorded).'),
|
||||||
|
('stan-iphone','Old phone','{}','phone','spare','Apple','iPhone 14 Pro Max','{}',NULL,NULL,NULL,NULL,NULL,NULL,45,
|
||||||
|
'Previous phone, kept (weak battery and speed).'),
|
||||||
|
('stan-ipad','iPad','{}','tablet','active','Apple','iPad Air 13-inch (M2)','{"screen_in":13,"chip":"Apple M2"}',NULL,NULL,NULL,NULL,NULL,NULL,50,
|
||||||
|
'With a keyboard case; used for typing on the go and Termius SSH.'),
|
||||||
|
('cargill-laptop','Work laptop','{}','laptop','active',NULL,NULL,'{"ram":"16 GB"}',NULL,NULL,NULL,NULL,NULL,NULL,60,
|
||||||
|
'Cargill work laptop (not personal; Windows + WSL2). Model not recorded.')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Peripherals get real categories instead of "accessory".
|
||||||
|
UPDATE hardware.components SET category = 'mouse' WHERE category = 'accessory' AND model ILIKE 'MX Master%';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE hardware.components SET category = 'keyboard' WHERE category = 'accessory' AND (model ILIKE 'Alloy Origins%' OR model ILIKE 'Pudding Keycaps%' OR model ILIKE 'UwU%');
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE hardware.components SET category = 'controller' WHERE category = 'accessory' AND brand = 'Thrustmaster';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE hardware.components SET category = 'audio' WHERE category = 'accessory' AND model ILIKE '%mic boom arm%';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE hardware.components SET notes = replace(notes, ', delivered 2026-03-03 to Houtenstraat 15 Gouda.', ', delivered 2026-03-03.') WHERE notes LIKE '%Houtenstraat%';
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO hardware.components (device_id, category, brand, model, quantity, specs, status, verified, notes, source)
|
||||||
|
SELECT d.id, v.category, v.brand, v.model, v.quantity, v.specs::jsonb, v.status, true, v.notes, v.source
|
||||||
|
FROM (VALUES
|
||||||
|
('stan-ovh','cpu','AMD','Ryzen 7 9700X',1,'{"cores":8,"threads":16,"socket":"AM5"}','in_use',NULL,'manual'),
|
||||||
|
('stan-ovh','ram',NULL,'64 GB DDR5 ECC',1,'{"type":"DDR5 ECC","capacity_gb":64}','in_use',NULL,'manual'),
|
||||||
|
('stan-ovh','storage',NULL,'512 GB NVMe SSD',2,'{"interface":"NVMe","capacity_gb":512,"raid":"software RAID1"}','in_use','Mirrored (RAID1), about 440 GB usable for data.','manual'),
|
||||||
|
('stan-pc-next','cpu','AMD','Top Ryzen X3D (model TBD)',1,'{"socket":"AM5"}','planned','Pick the fastest gaming X3D chip available when buying.','plan'),
|
||||||
|
('stan-pc-next','gpu','NVIDIA','GeForce RTX 5090',1,'{"vram_gb":32}','planned','Board partner TBD.','plan'),
|
||||||
|
('stan-pc-next','motherboard',NULL,'High-end AM5 motherboard (TBD)',1,'{"socket":"AM5"}','planned',NULL,'plan'),
|
||||||
|
('stan-pc-next','ram',NULL,'Fast DDR5 kit (TBD)',1,'{"type":"DDR5"}','planned',NULL,'plan'),
|
||||||
|
('stan-pc-next','storage',NULL,'PCIe 5.0 NVMe SSD (TBD)',1,'{"interface":"PCIe 5.0 x4 NVMe"}','planned',NULL,'plan'),
|
||||||
|
('stan-pc-next','cpu_cooler',NULL,'Cooler for the new CPU (TBD)',1,'{}','planned','Must fit the new socket and the O11 Dynamic XL (or the new case).','plan')
|
||||||
|
) AS v(device, category, brand, model, quantity, specs, status, notes, source)
|
||||||
|
JOIN hardware.devices d ON d.name = v.device
|
||||||
|
ON CONFLICT (device_id, model, order_number) DO NOTHING;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Games Stan plays; cm/360 left empty for him to fill. CS2's yaw (0.022 deg/count) is the
|
||||||
|
-- Source engine constant, so its in-game value is computed from a mouse's measured DPI.
|
||||||
|
INSERT INTO hardware.sensitivities (game, yaw, sort_order) VALUES
|
||||||
|
('Counter-Strike 2', 0.022, 10),
|
||||||
|
('Call of Duty', NULL, 20),
|
||||||
|
('osu!', NULL, 30),
|
||||||
|
('Sea of Thieves', NULL, 40)
|
||||||
|
ON CONFLICT (game) DO NOTHING;
|
||||||
21290
drizzle/20261024174805_hardware/snapshot.json
Normal file
21290
drizzle/20261024174805_hardware/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
140
drizzle/20261024193304_planes/migration.sql
Normal file
140
drizzle/20261024193304_planes/migration.sql
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
CREATE SCHEMA "planes";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "planes"."makers" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"full_name" text,
|
||||||
|
"country" text,
|
||||||
|
"wikidata" text,
|
||||||
|
"wikipedia" text,
|
||||||
|
"type_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"first_year" smallint,
|
||||||
|
"last_year" smallint,
|
||||||
|
"built_total" integer DEFAULT 0 NOT NULL,
|
||||||
|
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"logo" text,
|
||||||
|
"logo_file" text,
|
||||||
|
"logo_author" text,
|
||||||
|
"logo_license" text,
|
||||||
|
"photo" 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 "planes"."photos" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"type_id" integer NOT NULL,
|
||||||
|
"position" smallint NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"file" text NOT NULL,
|
||||||
|
"caption" text,
|
||||||
|
"thumb" text NOT NULL,
|
||||||
|
"width" integer NOT NULL,
|
||||||
|
"height" integer NOT NULL,
|
||||||
|
"author" text,
|
||||||
|
"license" text,
|
||||||
|
"license_url" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "planes"."types" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"dex" integer NOT NULL,
|
||||||
|
"maker_id" integer NOT NULL,
|
||||||
|
"family_id" integer,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"full_name" text NOT NULL,
|
||||||
|
"search" text NOT NULL,
|
||||||
|
"wikidata" text NOT NULL,
|
||||||
|
"wikipedia" text,
|
||||||
|
"kind" text,
|
||||||
|
"description" text,
|
||||||
|
"summary" text,
|
||||||
|
"role" text,
|
||||||
|
"roles" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"role_label" text,
|
||||||
|
"country" text,
|
||||||
|
"first_flight" text,
|
||||||
|
"first_flight_year" smallint,
|
||||||
|
"introduced_year" smallint,
|
||||||
|
"retired_year" smallint,
|
||||||
|
"status" text,
|
||||||
|
"in_service" boolean,
|
||||||
|
"number_built" integer,
|
||||||
|
"makers" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"engine_type" text,
|
||||||
|
"engine_count" smallint,
|
||||||
|
"engines" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"crew" smallint,
|
||||||
|
"passengers" smallint,
|
||||||
|
"length_m" real,
|
||||||
|
"wingspan_m" real,
|
||||||
|
"height_m" real,
|
||||||
|
"rotor_m" real,
|
||||||
|
"mtow_kg" integer,
|
||||||
|
"max_speed_kmh" smallint,
|
||||||
|
"cruise_kmh" smallint,
|
||||||
|
"range_km" integer,
|
||||||
|
"ceiling_m" integer,
|
||||||
|
"wing" text,
|
||||||
|
"gear" text,
|
||||||
|
"icao" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"recognize" text,
|
||||||
|
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"photo" text,
|
||||||
|
"photo_count" smallint DEFAULT 0 NOT NULL,
|
||||||
|
"photos_checked_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 UNIQUE INDEX "makers_slug_key" ON "planes"."makers" ("slug");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "photos_type_position_key" ON "planes"."photos" ("type_id","position");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "types_dex_key" ON "planes"."types" ("dex");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "types_wikidata_key" ON "planes"."types" ("wikidata");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "types_maker_slug_key" ON "planes"."types" ("maker_id","slug");--> statement-breakpoint
|
||||||
|
CREATE INDEX "types_number_built_idx" ON "planes"."types" ("number_built");--> statement-breakpoint
|
||||||
|
CREATE INDEX "types_first_flight_year_idx" ON "planes"."types" ("first_flight_year");--> statement-breakpoint
|
||||||
|
CREATE INDEX "types_search_trgm_idx" ON "planes"."types" USING gin ("search" public.gin_trgm_ops);--> statement-breakpoint
|
||||||
|
ALTER TABLE "planes"."photos" ADD CONSTRAINT "photos_type_id_types_id_fkey" FOREIGN KEY ("type_id") REFERENCES "planes"."types"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "planes"."types" ADD CONSTRAINT "types_maker_id_makers_id_fkey" FOREIGN KEY ("maker_id") REFERENCES "planes"."makers"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "planes"."types" ADD CONSTRAINT "types_family_id_types_id_fkey" FOREIGN KEY ("family_id") REFERENCES "planes"."types"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
-- Hand-written: updated_at triggers, grants, GUI registration. Data comes from scripts/planes-import.ts and scripts/planes-photos.ts.
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH t IN ARRAY ARRAY['makers','types'] LOOP
|
||||||
|
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON planes.%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 planes TO personal_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA planes TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA planes TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA planes GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA planes 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 planes TO personal_ro;
|
||||||
|
GRANT SELECT ON ALL TABLES IN SCHEMA planes TO personal_ro;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA planes 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 ('planes','makers','planes-makers','Plane makers','core',false,false,1000),
|
||||||
|
('planes','types','planes-types','Plane types','core',false,false,1000),
|
||||||
|
('planes','photos','planes-photos','Plane photos','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 $$;
|
||||||
22615
drizzle/20261024193304_planes/snapshot.json
Normal file
22615
drizzle/20261024193304_planes/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
122
drizzle/20261024215408_wildlife/migration.sql
Normal file
122
drizzle/20261024215408_wildlife/migration.sql
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
CREATE SCHEMA "wildlife";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "wildlife"."groups" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"name_nl" text NOT NULL,
|
||||||
|
"emoji" text NOT NULL,
|
||||||
|
"sort_order" smallint DEFAULT 0 NOT NULL,
|
||||||
|
"species_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"nl_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"photo" 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 "wildlife"."photos" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"species_id" integer NOT NULL,
|
||||||
|
"position" smallint NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"photo_id" integer NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"width" integer,
|
||||||
|
"height" integer,
|
||||||
|
"author" text,
|
||||||
|
"license" text NOT NULL,
|
||||||
|
"page_url" text,
|
||||||
|
"place" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "wildlife"."species" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"dex" integer NOT NULL,
|
||||||
|
"group_id" integer NOT NULL,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"scientific_name" text NOT NULL,
|
||||||
|
"name_en" text,
|
||||||
|
"name_nl" text,
|
||||||
|
"search" text NOT NULL,
|
||||||
|
"inat_id" integer NOT NULL,
|
||||||
|
"gbif_key" integer,
|
||||||
|
"wikidata" text,
|
||||||
|
"wikipedia_en" text,
|
||||||
|
"wikipedia_nl" text,
|
||||||
|
"class_name" text,
|
||||||
|
"order_name" text,
|
||||||
|
"family_name" text,
|
||||||
|
"iucn" text,
|
||||||
|
"nl_status" text,
|
||||||
|
"nl_establishment" text,
|
||||||
|
"extinct" boolean DEFAULT false NOT NULL,
|
||||||
|
"nl_obs" integer DEFAULT 0 NOT NULL,
|
||||||
|
"europe_obs" integer DEFAULT 0 NOT NULL,
|
||||||
|
"world_obs" integer DEFAULT 0 NOT NULL,
|
||||||
|
"gbif_nl" integer,
|
||||||
|
"scope" text NOT NULL,
|
||||||
|
"summary" text,
|
||||||
|
"summary_nl" text,
|
||||||
|
"facts" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"mass_kg" real,
|
||||||
|
"length_m" real,
|
||||||
|
"lifespan_years" real,
|
||||||
|
"named_year" smallint,
|
||||||
|
"named_by" text,
|
||||||
|
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"photo" text,
|
||||||
|
"photo_count" smallint DEFAULT 0 NOT NULL,
|
||||||
|
"photos_checked_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 UNIQUE INDEX "groups_slug_key" ON "wildlife"."groups" ("slug");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "photos_species_position_key" ON "wildlife"."photos" ("species_id","position");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "photos_species_photo_key" ON "wildlife"."photos" ("species_id","photo_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "species_dex_key" ON "wildlife"."species" ("dex");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "species_inat_key" ON "wildlife"."species" ("inat_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "species_slug_key" ON "wildlife"."species" ("slug");--> statement-breakpoint
|
||||||
|
CREATE INDEX "species_group_idx" ON "wildlife"."species" ("group_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "species_nl_obs_idx" ON "wildlife"."species" ("nl_obs");--> statement-breakpoint
|
||||||
|
CREATE INDEX "species_search_trgm_idx" ON "wildlife"."species" USING gin ("search" public.gin_trgm_ops);--> statement-breakpoint
|
||||||
|
ALTER TABLE "wildlife"."photos" ADD CONSTRAINT "photos_species_id_species_id_fkey" FOREIGN KEY ("species_id") REFERENCES "wildlife"."species"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "wildlife"."species" ADD CONSTRAINT "species_group_id_groups_id_fkey" FOREIGN KEY ("group_id") REFERENCES "wildlife"."groups"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
-- Hand-written: updated_at triggers, grants, GUI registration. Data comes from scripts/wildlife-import.ts and scripts/wildlife-photos.ts.
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH t IN ARRAY ARRAY['groups','species'] LOOP
|
||||||
|
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON wildlife.%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 wildlife TO personal_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA wildlife TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA wildlife TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA wildlife GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA wildlife 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 wildlife TO personal_ro;
|
||||||
|
GRANT SELECT ON ALL TABLES IN SCHEMA wildlife TO personal_ro;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA wildlife 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 ('wildlife','groups','wildlife-groups','Wildlife groups','core',false,false,1000),
|
||||||
|
('wildlife','species','wildlife-species','Wildlife species','core',false,false,1000),
|
||||||
|
('wildlife','photos','wildlife-photos','Wildlife photos','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 $$;
|
||||||
23710
drizzle/20261024215408_wildlife/snapshot.json
Normal file
23710
drizzle/20261024215408_wildlife/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
167
drizzle/20261024232143_trains/migration.sql
Normal file
167
drizzle/20261024232143_trains/migration.sql
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
CREATE SCHEMA "trains";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "trains"."makers" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"full_name" text,
|
||||||
|
"country" text,
|
||||||
|
"wikidata" text,
|
||||||
|
"wikipedia" text,
|
||||||
|
"type_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"first_year" smallint,
|
||||||
|
"last_year" smallint,
|
||||||
|
"built_total" integer DEFAULT 0 NOT NULL,
|
||||||
|
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"logo" text,
|
||||||
|
"logo_file" text,
|
||||||
|
"logo_author" text,
|
||||||
|
"logo_license" text,
|
||||||
|
"photo" 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 "trains"."operators" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"full_name" text,
|
||||||
|
"country" text,
|
||||||
|
"wikidata" text,
|
||||||
|
"wikipedia" text,
|
||||||
|
"type_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"logo" text,
|
||||||
|
"logo_file" text,
|
||||||
|
"logo_author" text,
|
||||||
|
"logo_license" text,
|
||||||
|
"photo" 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 "trains"."photos" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"type_id" integer NOT NULL,
|
||||||
|
"position" smallint NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"file" text NOT NULL,
|
||||||
|
"caption" text,
|
||||||
|
"thumb" text NOT NULL,
|
||||||
|
"width" integer NOT NULL,
|
||||||
|
"height" integer NOT NULL,
|
||||||
|
"author" text,
|
||||||
|
"license" text,
|
||||||
|
"license_url" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "trains"."type_operators" (
|
||||||
|
"type_id" integer,
|
||||||
|
"operator_id" integer,
|
||||||
|
"position" smallint DEFAULT 0 NOT NULL,
|
||||||
|
CONSTRAINT "type_operators_pkey" PRIMARY KEY("type_id","operator_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "trains"."types" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"dex" integer NOT NULL,
|
||||||
|
"maker_id" integer NOT NULL,
|
||||||
|
"family_id" integer,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"full_name" text NOT NULL,
|
||||||
|
"nl_name" text,
|
||||||
|
"search" text NOT NULL,
|
||||||
|
"wikidata" text NOT NULL,
|
||||||
|
"wikipedia" text,
|
||||||
|
"description" text,
|
||||||
|
"summary" text,
|
||||||
|
"category" text NOT NULL,
|
||||||
|
"category_label" text,
|
||||||
|
"traction" text,
|
||||||
|
"country" text,
|
||||||
|
"countries" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"makers" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"operators" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"first_built" smallint,
|
||||||
|
"last_built" smallint,
|
||||||
|
"introduced_year" smallint,
|
||||||
|
"retired_year" smallint,
|
||||||
|
"status" text,
|
||||||
|
"in_service" boolean,
|
||||||
|
"number_built" integer,
|
||||||
|
"max_speed_kmh" smallint,
|
||||||
|
"power_kw" integer,
|
||||||
|
"gauge" text,
|
||||||
|
"gauge_mm" smallint,
|
||||||
|
"electric_system" text,
|
||||||
|
"wheels" text,
|
||||||
|
"length_m" real,
|
||||||
|
"weight_t" real,
|
||||||
|
"capacity" smallint,
|
||||||
|
"formation" text,
|
||||||
|
"designer" text,
|
||||||
|
"nicknames" text,
|
||||||
|
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"photo" text,
|
||||||
|
"photo_count" smallint DEFAULT 0 NOT NULL,
|
||||||
|
"photos_checked_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 UNIQUE INDEX "makers_slug_key" ON "trains"."makers" ("slug");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "operators_slug_key" ON "trains"."operators" ("slug");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "photos_type_position_key" ON "trains"."photos" ("type_id","position");--> statement-breakpoint
|
||||||
|
CREATE INDEX "type_operators_operator_idx" ON "trains"."type_operators" ("operator_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "types_dex_key" ON "trains"."types" ("dex");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "types_wikidata_key" ON "trains"."types" ("wikidata");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "types_maker_slug_key" ON "trains"."types" ("maker_id","slug");--> statement-breakpoint
|
||||||
|
CREATE INDEX "types_number_built_idx" ON "trains"."types" ("number_built");--> statement-breakpoint
|
||||||
|
CREATE INDEX "types_first_built_idx" ON "trains"."types" ("first_built");--> statement-breakpoint
|
||||||
|
CREATE INDEX "types_category_idx" ON "trains"."types" ("category");--> statement-breakpoint
|
||||||
|
CREATE INDEX "types_search_trgm_idx" ON "trains"."types" USING gin ("search" public.gin_trgm_ops);--> statement-breakpoint
|
||||||
|
ALTER TABLE "trains"."photos" ADD CONSTRAINT "photos_type_id_types_id_fkey" FOREIGN KEY ("type_id") REFERENCES "trains"."types"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "trains"."type_operators" ADD CONSTRAINT "type_operators_type_id_types_id_fkey" FOREIGN KEY ("type_id") REFERENCES "trains"."types"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "trains"."type_operators" ADD CONSTRAINT "type_operators_operator_id_operators_id_fkey" FOREIGN KEY ("operator_id") REFERENCES "trains"."operators"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "trains"."types" ADD CONSTRAINT "types_maker_id_makers_id_fkey" FOREIGN KEY ("maker_id") REFERENCES "trains"."makers"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "trains"."types" ADD CONSTRAINT "types_family_id_types_id_fkey" FOREIGN KEY ("family_id") REFERENCES "trains"."types"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
-- Hand-written: updated_at triggers, grants, GUI registration. Data comes from scripts/trains-import.ts and scripts/trains-photos.ts.
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH t IN ARRAY ARRAY['makers','operators','types'] LOOP
|
||||||
|
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON trains.%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 trains TO personal_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA trains TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA trains TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA trains GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA trains 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 trains TO personal_ro;
|
||||||
|
GRANT SELECT ON ALL TABLES IN SCHEMA trains TO personal_ro;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA trains 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 ('trains','makers','trains-makers','Train makers','core',false,false,1000),
|
||||||
|
('trains','operators','trains-operators','Train operators','core',false,false,1000),
|
||||||
|
('trains','types','trains-types','Train types','core',false,false,1000),
|
||||||
|
('trains','photos','trains-photos','Train photos','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 $$;
|
||||||
25347
drizzle/20261024232143_trains/snapshot.json
Normal file
25347
drizzle/20261024232143_trains/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
106
drizzle/20261025130829_spotters/migration.sql
Normal file
106
drizzle/20261025130829_spotters/migration.sql
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
CREATE TABLE "cars"."activity" (
|
||||||
|
"model_id" integer,
|
||||||
|
"area" text,
|
||||||
|
"score" real NOT NULL,
|
||||||
|
CONSTRAINT "activity_pkey" PRIMARY KEY("area","model_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "cars"."seen" (
|
||||||
|
"model_id" integer PRIMARY KEY,
|
||||||
|
"seen_on" date,
|
||||||
|
"note" 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 "planes"."activity" (
|
||||||
|
"type_id" integer,
|
||||||
|
"area" text,
|
||||||
|
"score" real NOT NULL,
|
||||||
|
CONSTRAINT "activity_pkey" PRIMARY KEY("area","type_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "planes"."seen" (
|
||||||
|
"type_id" integer PRIMARY KEY,
|
||||||
|
"seen_on" date,
|
||||||
|
"note" 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 "wildlife"."activity" (
|
||||||
|
"species_id" integer,
|
||||||
|
"area" text,
|
||||||
|
"score" real NOT NULL,
|
||||||
|
CONSTRAINT "activity_pkey" PRIMARY KEY("area","species_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "wildlife"."seen" (
|
||||||
|
"species_id" integer PRIMARY KEY,
|
||||||
|
"seen_on" date,
|
||||||
|
"note" 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 "trains"."activity" (
|
||||||
|
"type_id" integer,
|
||||||
|
"area" text,
|
||||||
|
"score" real NOT NULL,
|
||||||
|
CONSTRAINT "activity_pkey" PRIMARY KEY("area","type_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "trains"."seen" (
|
||||||
|
"type_id" integer PRIMARY KEY,
|
||||||
|
"seen_on" date,
|
||||||
|
"note" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "activity_area_score_idx" ON "cars"."activity" ("area","score");--> statement-breakpoint
|
||||||
|
CREATE INDEX "activity_area_score_idx" ON "planes"."activity" ("area","score");--> statement-breakpoint
|
||||||
|
CREATE INDEX "activity_area_score_idx" ON "wildlife"."activity" ("area","score");--> statement-breakpoint
|
||||||
|
CREATE INDEX "activity_area_score_idx" ON "trains"."activity" ("area","score");--> statement-breakpoint
|
||||||
|
ALTER TABLE "cars"."activity" ADD CONSTRAINT "activity_model_id_models_id_fkey" FOREIGN KEY ("model_id") REFERENCES "cars"."models"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "cars"."seen" ADD CONSTRAINT "seen_model_id_models_id_fkey" FOREIGN KEY ("model_id") REFERENCES "cars"."models"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "planes"."activity" ADD CONSTRAINT "activity_type_id_types_id_fkey" FOREIGN KEY ("type_id") REFERENCES "planes"."types"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "planes"."seen" ADD CONSTRAINT "seen_type_id_types_id_fkey" FOREIGN KEY ("type_id") REFERENCES "planes"."types"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "wildlife"."activity" ADD CONSTRAINT "activity_species_id_species_id_fkey" FOREIGN KEY ("species_id") REFERENCES "wildlife"."species"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "wildlife"."seen" ADD CONSTRAINT "seen_species_id_species_id_fkey" FOREIGN KEY ("species_id") REFERENCES "wildlife"."species"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "trains"."activity" ADD CONSTRAINT "activity_type_id_types_id_fkey" FOREIGN KEY ("type_id") REFERENCES "trains"."types"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "trains"."seen" ADD CONSTRAINT "seen_type_id_types_id_fkey" FOREIGN KEY ("type_id") REFERENCES "trains"."types"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
-- Hand-written: updated_at triggers, grants, GUI registration. activity is filled by
|
||||||
|
-- scripts/spotters-activity.ts; seen is written by the owner from the pokedex pages.
|
||||||
|
DO $$
|
||||||
|
DECLARE s text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH s IN ARRAY ARRAY['cars','planes','trains','wildlife'] LOOP
|
||||||
|
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON %I.seen FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', s);
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON %I.activity, %I.seen TO personal_app', s, s);
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||||
|
EXECUTE format('GRANT SELECT ON %I.activity, %I.seen TO personal_ro', s, s);
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
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','activity','cars-activity','Car activity by country','core',false,false,1000),
|
||||||
|
('cars','seen','cars-seen','Cars seen','core',false,false,1000),
|
||||||
|
('planes','activity','planes-activity','Plane activity by country','core',false,false,1000),
|
||||||
|
('planes','seen','planes-seen','Planes seen','core',false,false,1000),
|
||||||
|
('trains','activity','trains-activity','Train activity by country','core',false,false,1000),
|
||||||
|
('trains','seen','trains-seen','Trains seen','core',false,false,1000),
|
||||||
|
('wildlife','activity','wildlife-activity','Wildlife activity by country','core',false,false,1000),
|
||||||
|
('wildlife','seen','wildlife-seen','Wildlife seen','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 $$;
|
||||||
26143
drizzle/20261025130829_spotters/snapshot.json
Normal file
26143
drizzle/20261025130829_spotters/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