Compare commits
186 Commits
money-bala
...
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 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,3 +29,4 @@ vite.config.ts.timestamp-*
|
||||
# Browser test output
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
__pycache__/
|
||||
|
||||
@@ -19,3 +19,12 @@ data/cod-challenges.json
|
||||
data/game-images.json
|
||||
data/cod-achievement-art.json
|
||||
src/lib/house-tracker/fixtures/
|
||||
|
||||
# Vendored third-party code
|
||||
static/vendor/
|
||||
|
||||
# Generated drizzle snapshots
|
||||
drizzle/*/snapshot.json
|
||||
|
||||
# Generated PUBG map intel (scripts/build-pubg-intel.py)
|
||||
static/pubg-intel/
|
||||
|
||||
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
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
@@ -6,7 +6,7 @@ COPY . .
|
||||
RUN bun run test:unit -- --run
|
||||
RUN bun run build
|
||||
|
||||
FROM oven/bun:1.4.0
|
||||
FROM oven/bun:1.4.4
|
||||
WORKDIR /app
|
||||
ARG GIT_SHA=dev
|
||||
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/src/lib/server/db/schema ./src/lib/server/db/schema
|
||||
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
|
||||
# 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 ./
|
||||
EXPOSE 3000
|
||||
ENV NODE_ENV=production
|
||||
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"]
|
||||
|
||||
62
README.md
62
README.md
@@ -236,13 +236,49 @@ local database and migrations, and `db/reorg/README.md` for the one-time move of
|
||||
old flat schema to this layout. Catalog seeds (`npm run db:seed`, `db:seed:cod`, …)
|
||||
upsert reference data and run on every production deploy.
|
||||
|
||||
## Money tracker
|
||||
## Book tracker
|
||||
|
||||
`/projects/money-tracker` is restricted to administrator accounts. Its tables
|
||||
`/trackers/books` is the public catalogue (covers, status, progress, stats). `/trackers/books/<id>`
|
||||
shows a book; the owner also sees files, notes, highlights and reading sessions there and can
|
||||
edit status, rating and progress. `/trackers/books/<id>/read` is the owner-only reader:
|
||||
EPUB, MOBI/AZW3 and FB2 open in [foliate-js](https://github.com/johnfactotum/foliate-js)
|
||||
(vendored in `static/vendor/foliate-js`, no conversion), PDFs in pdf.js. The reader remembers the
|
||||
position per file, autosaves progress and sessions, and stores highlights with notes.
|
||||
|
||||
Files are never public. They are streamed (with Range support) by
|
||||
`/trackers/books/files/<id>` to the owner only; everyone else gets a 404. The reader, progress
|
||||
and notes endpoints are owner-only too (`src/lib/server/owner.ts`, backstopped in
|
||||
`hooks.server.ts`).
|
||||
|
||||
Files are read from `BOOKS_ROOT` (default `/books` when it exists, otherwise `~/books`):
|
||||
|
||||
- `BOOKS_ROOT/library/<books.files.path>` for book files (written by `scripts/books-ingest.py`)
|
||||
- `BOOKS_ROOT/covers/<books.works.cover_path>` for downloaded covers
|
||||
|
||||
In production, `scripts/deploy-remote.sh` writes `~deploy/compose.books.yml` (the deploy account may only sudo-tee `/opt/personal-website/.env`), which
|
||||
bind-mounts the host's `BOOKS_HOST_DIR` (default `/home/stan/books`) read-only at `/books` and sets
|
||||
`BOOKS_ROOT=/books`. Without a `library` directory there the deploy still succeeds, but files can't
|
||||
be opened.
|
||||
|
||||
## Money
|
||||
|
||||
`/trackers/money` (formerly `/projects/money-tracker`, which now redirects) is restricted to administrator accounts. Its tables
|
||||
are created by `npm run db:migrate:money-tracker`; apply that migration to each
|
||||
environment before deploying the new route. The deployment preflight checks for
|
||||
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
|
||||
export with `date,account,description,amount` headers. Dates accept
|
||||
`YYYY-MM-DD`, `DD/MM/YYYY`, or `Fri 1 Aug 2025`; amounts accept signed euros
|
||||
@@ -306,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)
|
||||
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
|
||||
|
||||
There are two environments: local dev and production. Prod is the source of truth
|
||||
|
||||
33
bun.lock
33
bun.lock
@@ -15,9 +15,11 @@
|
||||
"echarts": "^6.1.0",
|
||||
"highlight.js": "^11.12.0",
|
||||
"jose": "^6.2.10",
|
||||
"leaflet": "^1.9.4",
|
||||
"marked": "^18.0.11",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"pdfjs-dist": "^6.4.299",
|
||||
"postgres": "^3.4.9",
|
||||
"topojson-client": "^3.1.0",
|
||||
"world-atlas": "^2.0.2",
|
||||
@@ -38,6 +40,7 @@
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/d3-geo": "^3.1.1",
|
||||
"@types/eslint": "9.6.1",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"@typescript/native": "npm:typescript@7",
|
||||
@@ -326,6 +329,30 @@
|
||||
|
||||
"@melt-ui/svelte": ["@melt-ui/svelte@0.76.2", "", { "dependencies": { "@floating-ui/core": "^1.3.1", "@floating-ui/dom": "^1.4.5", "@internationalized/date": "^3.5.0", "dequal": "^2.0.3", "focus-trap": "^7.5.2", "nanoid": "^5.0.4" }, "peerDependencies": { "svelte": ">=3 <5" } }, "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA=="],
|
||||
|
||||
"@napi-rs/canvas": ["@napi-rs/canvas@1.0.13", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.13", "@napi-rs/canvas-darwin-arm64": "1.0.13", "@napi-rs/canvas-darwin-x64": "1.0.13", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.13", "@napi-rs/canvas-linux-arm64-gnu": "1.0.13", "@napi-rs/canvas-linux-arm64-musl": "1.0.13", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.13", "@napi-rs/canvas-linux-x64-gnu": "1.0.13", "@napi-rs/canvas-linux-x64-musl": "1.0.13", "@napi-rs/canvas-win32-arm64-msvc": "1.0.13", "@napi-rs/canvas-win32-x64-msvc": "1.0.13" } }, "sha512-b87ugZpQ5ViwqVBkjervxpwa0GmJPsKHLBNoZN6GpjWeHzTbDUGryriq+2DYW2XbB+xCqSIINQysC0/I87nPbw=="],
|
||||
|
||||
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.13", "", { "os": "android", "cpu": "arm64" }, "sha512-trn7AaDzQ+yeeA0ezhkC7cB1ZDPtp4UYk0EvB5F8qtYL9jEVWtVrU30WOpI52DszoTxaTsCgf5PP4ZVaOlOIHQ=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6Y2djEetL8copxfNK6hg7oig/xqTwOchv19hBOm+zBNdKc/02Z3kjZN5wrLzv/l5O04/bAQS6I/AlQpwBlXgPg=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-OZoPwwu4TMMg8GTCFBrg8UFExwQTU/55i3uTC/izDat6PHE+ro9dimyQoOboFf91+NY7Pnn70ScR8f8I8+eZXw=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.13", "", { "os": "linux", "cpu": "arm" }, "sha512-dZSYaKROuswU2eUWG5aBKndNVe68elo1YX93xtRax7q658tj8rYE+HwduovWJgs6qwv1r1/CLR60nDeBbyjvyg=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-W6FzYE8ADHI93In1vi80KBL6btDtAMuj50Ch5ptkWS8xuntyzbR4h5JDUmXoakxZn2yMInksudTEqVSWoFqFGg=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-+6fQ+rR49hkAa4voh2stLIwQ+4t+gmoseM8Bc53/DlMXb73IKymDPWK9A/NtWeqTW4ijWppYuyuxS2hPI8tzNA=="],
|
||||
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.13", "", { "os": "linux", "cpu": "none" }, "sha512-17XEMO3t9RW/TtHtSFf2AkaSWuCWdzbPeidz7zrOFawHxoLSJCnpTI1hN5aUvrZNnkNNmt/vZVJCh0ryUaik3A=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.13", "", { "os": "linux", "cpu": "x64" }, "sha512-lc48IwknVHBsNzXn9Xs5OkUsRX38Ml3Z1aOybQ51mMEzZckm8+4A0qROlyYHEoBPzr1kL3IU8ZUAqDb7wtdX8w=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.13", "", { "os": "linux", "cpu": "x64" }, "sha512-bGY1r26S2bs8UNFJcPWcCSNdCFbKep5PATsvtgtC9A5u6SRgr2/jJE/RlhHL5m9ZpixNDhVkLY/5h16Kx5V9YA=="],
|
||||
|
||||
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-2vRPBZoaIvJrUkDhZTE2Hn0YrnpJRCWVM3EJACoBKjuWI6WgPxGtUoRMsxRviCsyks8bKTSFdyw14CellnG2qA=="],
|
||||
|
||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.13", "", { "os": "win32", "cpu": "x64" }, "sha512-DUlW3lZPz63kYsf8mxgyyZuevhlH0T3/BGP9l+NazhB0F1+gL6D4hqZdnhZpw24DlWmTpATDcyYSZlJOi+DdfQ=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@2.4.0", "", {}, "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="],
|
||||
@@ -448,6 +475,8 @@
|
||||
|
||||
"@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/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="],
|
||||
@@ -746,6 +775,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
@@ -838,6 +869,8 @@
|
||||
|
||||
"pbf": ["pbf@4.0.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": "bin/pbf" }, "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg=="],
|
||||
|
||||
"pdfjs-dist": ["pdfjs-dist@6.4.299", "", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.10" } }, "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g=="],
|
||||
|
||||
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
|
||||
|
||||
"pg-protocol": ["pg-protocol@1.16.0", "", {}, "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg=="],
|
||||
|
||||
643
data/tasks/daisy-import.json
Normal file
643
data/tasks/daisy-import.json
Normal file
@@ -0,0 +1,643 @@
|
||||
{
|
||||
"source": "daisy-import",
|
||||
"areas": [
|
||||
{
|
||||
"slug": "deadlines",
|
||||
"name": "Deadlines and dates",
|
||||
"sortOrder": 10
|
||||
},
|
||||
{
|
||||
"slug": "house",
|
||||
"name": "House and moving",
|
||||
"description": "Jan–Feb 2027 with Juhee",
|
||||
"sortOrder": 20
|
||||
},
|
||||
{
|
||||
"slug": "kibi",
|
||||
"name": "Kibi",
|
||||
"sortOrder": 30
|
||||
},
|
||||
{
|
||||
"slug": "admin",
|
||||
"name": "Phone, subscriptions and admin",
|
||||
"sortOrder": 40
|
||||
},
|
||||
{
|
||||
"slug": "clients",
|
||||
"name": "Clients",
|
||||
"sortOrder": 50
|
||||
},
|
||||
{
|
||||
"slug": "website",
|
||||
"name": "Website (stanrunge.dev)",
|
||||
"sortOrder": 60
|
||||
},
|
||||
{
|
||||
"slug": "setup",
|
||||
"name": "Setup and security",
|
||||
"description": "Perma evolvable setup, before 2027",
|
||||
"sortOrder": 70
|
||||
},
|
||||
{
|
||||
"slug": "home",
|
||||
"name": "Home",
|
||||
"sortOrder": 80
|
||||
},
|
||||
{
|
||||
"slug": "health",
|
||||
"name": "Health and self-care",
|
||||
"sortOrder": 90
|
||||
},
|
||||
{
|
||||
"slug": "reading",
|
||||
"name": "Reading",
|
||||
"sortOrder": 100
|
||||
},
|
||||
{
|
||||
"slug": "planning",
|
||||
"name": "Planning and goals",
|
||||
"sortOrder": 110
|
||||
}
|
||||
],
|
||||
"goals": [
|
||||
{
|
||||
"title": "€10k net/month into own Revolut by 2030",
|
||||
"area": "planning",
|
||||
"targetDate": "2030-01-01",
|
||||
"metric": "Net income per month",
|
||||
"metricUnit": "EUR",
|
||||
"metricTarget": 10000
|
||||
},
|
||||
{
|
||||
"title": "Own a really nice Ford Mustang GT"
|
||||
},
|
||||
{
|
||||
"title": "PC overhaul",
|
||||
"notes": "Top AMD X3D CPU, RTX 5090.",
|
||||
"metric": "Budget",
|
||||
"metricUnit": "EUR",
|
||||
"metricTarget": 10000
|
||||
},
|
||||
{
|
||||
"title": "Organise goals into a plan",
|
||||
"area": "planning",
|
||||
"notes": "Social media, Grok Bot, IT company."
|
||||
},
|
||||
{
|
||||
"title": "Perma evolvable setup foundation ready before 2027",
|
||||
"area": "setup",
|
||||
"targetDate": "2026-12-31"
|
||||
},
|
||||
{
|
||||
"title": "Get in shape long term",
|
||||
"area": "health"
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"area": "deadlines",
|
||||
"title": "Jan Loman printer visit",
|
||||
"status": "done",
|
||||
"notes": "Settled.",
|
||||
"due": "2026-10-24",
|
||||
"dueTime": "14:00"
|
||||
},
|
||||
{
|
||||
"area": "deadlines",
|
||||
"title": "Call BackHome about Kibi's chip",
|
||||
"status": "todo",
|
||||
"due": "2026-10-26",
|
||||
"dueTime": "10:00",
|
||||
"tags": ["kibi", "call"]
|
||||
},
|
||||
{
|
||||
"area": "deadlines",
|
||||
"title": "Cargill collective health insurance (OHRA, code 114)",
|
||||
"status": "todo",
|
||||
"notes": "Needs current insurer + premium to compare.",
|
||||
"due": "2026-12-31",
|
||||
"tags": ["insurance"]
|
||||
},
|
||||
{
|
||||
"area": "deadlines",
|
||||
"title": "Reply to mom about insurance info",
|
||||
"status": "todo",
|
||||
"notes": "Waiting since Oct 9.",
|
||||
"tags": ["insurance"]
|
||||
},
|
||||
{
|
||||
"area": "deadlines",
|
||||
"title": "Stop zorgtoeslag from Jan 2027",
|
||||
"status": "todo",
|
||||
"due": "2027-01-01",
|
||||
"reminderAt": "2026-12-07T09:00:00+01:00",
|
||||
"tags": ["admin"]
|
||||
},
|
||||
{
|
||||
"area": "deadlines",
|
||||
"title": "Supermarket product list",
|
||||
"status": "todo",
|
||||
"reminderAt": "2026-10-24T11:50:00+02:00"
|
||||
},
|
||||
{
|
||||
"area": "deadlines",
|
||||
"title": "Book haircut",
|
||||
"status": "todo",
|
||||
"notes": "Around Nov 14.",
|
||||
"due": "2026-11-14"
|
||||
},
|
||||
{
|
||||
"area": "house",
|
||||
"title": "House overview",
|
||||
"status": "in_progress",
|
||||
"flagged": true,
|
||||
"notes": "Applied only to Graaf Hendrikstraat 16 so far."
|
||||
},
|
||||
{
|
||||
"area": "house",
|
||||
"title": "React to house options",
|
||||
"status": "todo",
|
||||
"notes": "Raam €1,475; Burg. van Dijkesingel 214-B; Groenhovenweg 161; Spoorstraat €795; Noothoven van Goorstraat 1-206."
|
||||
},
|
||||
{
|
||||
"area": "house",
|
||||
"title": "Tell Daisy budget, move date, must-haves, Funda/Pararius applications",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "house",
|
||||
"title": "Mijndak Midden-Holland sign-up",
|
||||
"status": "todo",
|
||||
"flagged": true,
|
||||
"notes": "€23 option."
|
||||
},
|
||||
{
|
||||
"area": "house",
|
||||
"title": "Sign up: socialehuurwoning.nl, Vesteda Goudshof, VBT, Viramah, agency Jesse's mom uses",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "house",
|
||||
"title": "House tracker/scraper on the site",
|
||||
"status": "todo",
|
||||
"tags": ["website"]
|
||||
},
|
||||
{
|
||||
"area": "house",
|
||||
"title": "Update address everywhere",
|
||||
"status": "todo",
|
||||
"notes": "Checklist ready."
|
||||
},
|
||||
{
|
||||
"area": "house",
|
||||
"title": "UniFi network for the new place",
|
||||
"status": "todo",
|
||||
"tags": ["network"]
|
||||
},
|
||||
{
|
||||
"area": "kibi",
|
||||
"title": "Food ordered",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"area": "kibi",
|
||||
"title": "Chip scanner ordered",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"area": "kibi",
|
||||
"title": "Chip number found",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"area": "kibi",
|
||||
"title": "Chip owner change at BackHome",
|
||||
"status": "todo",
|
||||
"notes": "Still 'Kwiebus' under E Zwart."
|
||||
},
|
||||
{
|
||||
"area": "kibi",
|
||||
"title": "Call vet about Kibi's patient card",
|
||||
"status": "todo",
|
||||
"tags": ["call"]
|
||||
},
|
||||
{
|
||||
"area": "kibi",
|
||||
"title": "GPS tracker",
|
||||
"status": "todo",
|
||||
"notes": "Dad may have done it already."
|
||||
},
|
||||
{
|
||||
"area": "kibi",
|
||||
"title": "Dash KIBI",
|
||||
"status": "todo",
|
||||
"flagged": true,
|
||||
"notes": "Meaning unclear, ask Stan."
|
||||
},
|
||||
{
|
||||
"area": "admin",
|
||||
"title": "Basic-Fit cancelled",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"area": "admin",
|
||||
"title": "Ohra inboedelverzekering cancelled",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"area": "admin",
|
||||
"title": "Zorgtoeslag income updated for 2026",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"area": "admin",
|
||||
"title": "Move number to Revolut eSIM, then iPad eSIM, cancel Odido",
|
||||
"status": "in_progress"
|
||||
},
|
||||
{
|
||||
"area": "admin",
|
||||
"title": "Move SIM + WhatsApp from iPhone to Pixel 11 Pro XL",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "admin",
|
||||
"title": "Check Oasen and Delta Energie fully cancelled",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "admin",
|
||||
"title": "Cancel other unused subscriptions",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "admin",
|
||||
"title": "Merge banks into Revolut",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "clients",
|
||||
"title": "Quote dack €450 for vet clinic site fix",
|
||||
"status": "done",
|
||||
"tags": ["dack"]
|
||||
},
|
||||
{
|
||||
"area": "clients",
|
||||
"title": "Reply to dack",
|
||||
"status": "done",
|
||||
"tags": ["dack"]
|
||||
},
|
||||
{
|
||||
"area": "clients",
|
||||
"title": "Cloudflare DNS/SSL for dack",
|
||||
"status": "in_progress",
|
||||
"tags": ["dack"],
|
||||
"notes": "Grind-week goal."
|
||||
},
|
||||
{
|
||||
"area": "clients",
|
||||
"title": "Send dack's invoice via Moneybird",
|
||||
"status": "todo",
|
||||
"tags": ["dack"]
|
||||
},
|
||||
{
|
||||
"area": "clients",
|
||||
"title": "Cheap shared WordPress hosting for dack's clients on own VMs",
|
||||
"status": "todo",
|
||||
"tags": ["dack"]
|
||||
},
|
||||
{
|
||||
"area": "clients",
|
||||
"title": "Karolina's Wix site, Master Purpose (NL/PL)",
|
||||
"status": "todo",
|
||||
"tags": ["karolina"]
|
||||
},
|
||||
{
|
||||
"area": "clients",
|
||||
"title": "Wix Studio + Velo/CLI so an AI can edit code",
|
||||
"status": "todo",
|
||||
"tags": ["karolina"]
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "DB reorganised into schemas, staging retired",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "Energy tracker (NED data) live",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "Health tracker (private) + 15 RIVM vaccinations",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "Book tracker, reader, 362 books live",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "Reader zoom, 2-page view, scroll/paging",
|
||||
"status": "in_progress"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "Money tracker with MoneyMan",
|
||||
"status": "in_progress"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "NED backfill",
|
||||
"status": "in_progress"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "Music listening tracker + now-playing",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "Task/goal tracker to replace TickTick",
|
||||
"status": "in_progress"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "Hardware DB (current PC parts first)",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "List of every organisation holding my data and what they know",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "Move to SvelteKit 3",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "website",
|
||||
"title": "Juhee website data",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "AWS cleanup ($0/month)",
|
||||
"status": "done",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Google Cloud cleanup",
|
||||
"status": "done",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "stan-vm hardened (ufw, Tailscale-only SSH)",
|
||||
"status": "done",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Nightly restic backups to B2 + Storage Box",
|
||||
"status": "done",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "WhatsApp bridge on stan-vm",
|
||||
"status": "done",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "gcloud + moneybird-cli on stan-vm",
|
||||
"status": "done",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Domains Squarespace to Cloudflare",
|
||||
"status": "in_progress",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"notes": "Both transfers pending.",
|
||||
"due": "2026-10-29"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Syncthing: Introducer + Auto Accept on Arch and Windows",
|
||||
"status": "in_progress",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Backup follow-up: restic passwords into 1Password",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"due": "2026-10-24",
|
||||
"dueTime": "11:00"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Delete empty B2 bucket",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"due": "2026-10-25"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Rotate weak admin secret + DB password",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"tags": ["security"]
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "1Password overhaul",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"notes": "Imports, 2FA, passkeys, dedupe, delete unused accounts.",
|
||||
"tags": ["security"]
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Moneybird API token, then clean up Moneybird",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Authentik SSO on stan-vm",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "vash-prod memory warnings + finish security audit",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"tags": ["security"]
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Remove old grok-bot devices from Tailscale",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Close empty GCP billing account; decide on AWS 'Vash Software'",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"notes": "AWS part optional."
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Raise WSL RAM",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"notes": "Sees 8 of 16 GB."
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Home server rebuild with ZFS",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Analytics, monitoring, alerts across devices",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Mouse sensitivity cm/360 across devices",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Buy Logitech G840 XL mousepad",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"tags": ["buy"]
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Router and networking setup",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"tags": ["network"]
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "J5 Hexagon research, then move wachtboden",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "Agent-deck + AI/Linux dev setup",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||
},
|
||||
{
|
||||
"area": "setup",
|
||||
"title": "MITM/certificate lab",
|
||||
"status": "todo",
|
||||
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||
"notes": "Idea.",
|
||||
"tags": ["idea"]
|
||||
},
|
||||
{
|
||||
"area": "home",
|
||||
"title": "Unpack boxes; organise batteries and tools; buy thermometer",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "home",
|
||||
"title": "Clean desk, laundry, clothes",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "home",
|
||||
"title": "Connect vinyl player, printer, monitor, switch, osu! pad, tablet, fridge, microwave",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "home",
|
||||
"title": "Hue lights + Home Assistant + Apple Home",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "home",
|
||||
"title": "Fan controller",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "home",
|
||||
"title": "Opa's stuff (WhatsApp, sensor install)",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "health",
|
||||
"title": "Getting in shape with Doc",
|
||||
"status": "in_progress",
|
||||
"notes": "Lesson 1 done; ~2,300–2,400 kcal and 160–190 g protein a day.",
|
||||
"goal": "Get in shape long term"
|
||||
},
|
||||
{
|
||||
"area": "health",
|
||||
"title": "Join the gym my parents use",
|
||||
"status": "todo",
|
||||
"goal": "Get in shape long term"
|
||||
},
|
||||
{
|
||||
"area": "health",
|
||||
"title": "Brush teeth twice a day, shower, hair care",
|
||||
"status": "todo",
|
||||
"tags": ["habit"]
|
||||
},
|
||||
{
|
||||
"area": "health",
|
||||
"title": "Dentist checkup",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "health",
|
||||
"title": "Pick hardware for Withings/Garmin syncs",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "health",
|
||||
"title": "Curaçao vaccines",
|
||||
"status": "todo",
|
||||
"notes": "Via Doc."
|
||||
},
|
||||
{
|
||||
"area": "reading",
|
||||
"title": "Read 'De meeste mensen deugen' (Bregman)",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "reading",
|
||||
"title": "Re-download 'Haves and Have-Nots'",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "reading",
|
||||
"title": "Book questions: duplicates, 6 unknown files, Nick's repo 404",
|
||||
"status": "todo"
|
||||
},
|
||||
{
|
||||
"area": "planning",
|
||||
"title": "Schedule sync (paper agenda, TickTick, Daisy)",
|
||||
"status": "todo",
|
||||
"goal": "Organise goals into a plan"
|
||||
}
|
||||
]
|
||||
}
|
||||
7
data/tasks/ticktick-merges.json
Normal file
7
data/tasks/ticktick-merges.json
Normal file
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{ "ticktickId": "6ad52191ebcd7400000000ce", "title": "Jan Loman printer visit" },
|
||||
{ "ticktickId": "690d2fd98c824268f297a676", "title": "Router and networking setup" },
|
||||
{ "ticktickId": "6844769df471d103461fa9f1", "title": "Home server rebuild with ZFS" },
|
||||
{ "ticktickId": "6ab6f32759b6090811e91a00", "title": "Dentist checkup" },
|
||||
{ "ticktickId": "68225a8dbfe81104eac49a7a", "title": "Merge banks into Revolut" }
|
||||
]
|
||||
1274
data/worldtodo-seed.json
Normal file
1274
data/worldtodo-seed.json
Normal file
File diff suppressed because it is too large
Load Diff
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
|
||||
|
||||
## 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
|
||||
`/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.
|
||||
|
||||
## 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
|
||||
higher (Raam: €1,475 base, €1,750 total). If Pararius changes markup, the worker
|
||||
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)
|
||||
// - legacy: pre-reorg tables kept read-only for rollback
|
||||
// - energy: owned by ned-collector
|
||||
// - hardware: not modelled yet
|
||||
// Migrations run as the schema owner (`personal`), never as the app role.
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
@@ -20,7 +19,23 @@ export default defineConfig({
|
||||
'animaldex',
|
||||
'site',
|
||||
'meta',
|
||||
'health'
|
||||
'health',
|
||||
'books',
|
||||
'tasks',
|
||||
'groceries',
|
||||
'home',
|
||||
'cars',
|
||||
'music',
|
||||
'hardware',
|
||||
'planes',
|
||||
'wildlife',
|
||||
'trains',
|
||||
'dataholders',
|
||||
'stats',
|
||||
'ai',
|
||||
'movies',
|
||||
'worldtodo',
|
||||
'notes'
|
||||
],
|
||||
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
||||
verbose: true,
|
||||
|
||||
194
drizzle/20261024013208_books/migration.sql
Normal file
194
drizzle/20261024013208_books/migration.sql
Normal file
@@ -0,0 +1,194 @@
|
||||
CREATE SCHEMA "books";
|
||||
--> statement-breakpoint
|
||||
CREATE TYPE "books"."metadata_status" AS ENUM('pending', 'matched', 'low_confidence', 'not_found', 'manual');--> statement-breakpoint
|
||||
CREATE TYPE "books"."reading_status" AS ENUM('want_to_read', 'reading', 'finished', 'abandoned');--> statement-breakpoint
|
||||
CREATE TABLE "books"."files" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"work_id" integer NOT NULL,
|
||||
"path" text NOT NULL,
|
||||
"format" text NOT NULL,
|
||||
"size_bytes" bigint NOT NULL,
|
||||
"sha256" text NOT NULL,
|
||||
"md5" text,
|
||||
"source" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "books"."notes" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"work_id" integer NOT NULL,
|
||||
"file_id" integer,
|
||||
"locator" text,
|
||||
"page" integer,
|
||||
"highlight" text,
|
||||
"note" text,
|
||||
"color" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "books"."progress" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"work_id" integer NOT NULL,
|
||||
"file_id" integer,
|
||||
"locator" text,
|
||||
"page" integer,
|
||||
"percent" real DEFAULT 0 NOT NULL,
|
||||
"started_at" timestamp with time zone,
|
||||
"finished_at" timestamp with time zone,
|
||||
"status" "books"."reading_status" DEFAULT 'want_to_read'::"books"."reading_status" NOT NULL,
|
||||
"rating" smallint,
|
||||
"last_read_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "progress_percent_check" CHECK ("percent" between 0 and 100),
|
||||
CONSTRAINT "progress_rating_check" CHECK ("rating" is null or "rating" between 1 and 5)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "books"."sessions" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"work_id" integer NOT NULL,
|
||||
"file_id" integer,
|
||||
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"ended_at" timestamp with time zone,
|
||||
"start_percent" real,
|
||||
"end_percent" real,
|
||||
"start_page" integer,
|
||||
"end_page" integer,
|
||||
"pages_read" integer,
|
||||
"source" text DEFAULT 'reader' NOT NULL,
|
||||
"note" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "sessions_percent_check" CHECK (("start_percent" is null or "start_percent" between 0 and 100) and ("end_percent" is null or "end_percent" between 0 and 100)),
|
||||
CONSTRAINT "sessions_end_check" CHECK ("ended_at" is null or "ended_at" >= "started_at")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "books"."shelf_works" (
|
||||
"shelf_id" integer,
|
||||
"work_id" integer,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"added_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "shelf_works_pkey" PRIMARY KEY("shelf_id","work_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "books"."shelves" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"slug" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "books"."works" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"title" text NOT NULL,
|
||||
"subtitle" text,
|
||||
"authors" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"isbn10" text,
|
||||
"isbn13" text,
|
||||
"publisher" text,
|
||||
"year" integer,
|
||||
"language" text,
|
||||
"page_count" integer,
|
||||
"description" text,
|
||||
"tags" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"cover_url" text,
|
||||
"cover_path" text,
|
||||
"openlibrary_work_id" text,
|
||||
"openlibrary_edition_id" text,
|
||||
"google_books_id" text,
|
||||
"annas_md5" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"source_refs" jsonb DEFAULT '{}' NOT NULL,
|
||||
"metadata_status" "books"."metadata_status" DEFAULT 'pending'::"books"."metadata_status" NOT NULL,
|
||||
"metadata_source" text,
|
||||
"metadata_fetched_at" timestamp with time zone,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "works_year_check" CHECK ("year" is null or "year" between 0 and 2100)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "files_path_key" ON "books"."files" ("path");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "files_sha256_key" ON "books"."files" ("sha256");--> statement-breakpoint
|
||||
CREATE INDEX "files_work_id_idx" ON "books"."files" ("work_id");--> statement-breakpoint
|
||||
CREATE INDEX "notes_work_id_idx" ON "books"."notes" ("work_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "progress_work_id_key" ON "books"."progress" ("work_id");--> statement-breakpoint
|
||||
CREATE INDEX "progress_status_idx" ON "books"."progress" ("status");--> statement-breakpoint
|
||||
CREATE INDEX "sessions_work_id_idx" ON "books"."sessions" ("work_id");--> statement-breakpoint
|
||||
CREATE INDEX "sessions_started_at_idx" ON "books"."sessions" ("started_at");--> statement-breakpoint
|
||||
CREATE INDEX "shelf_works_work_id_idx" ON "books"."shelf_works" ("work_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "shelves_slug_key" ON "books"."shelves" ("slug");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "works_isbn13_key" ON "books"."works" ("isbn13") WHERE "isbn13" is not null;--> statement-breakpoint
|
||||
CREATE INDEX "works_title_idx" ON "books"."works" (lower("title"));--> statement-breakpoint
|
||||
ALTER TABLE "books"."files" ADD CONSTRAINT "files_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "books"."notes" ADD CONSTRAINT "notes_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "books"."notes" ADD CONSTRAINT "notes_file_id_files_id_fkey" FOREIGN KEY ("file_id") REFERENCES "books"."files"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "books"."progress" ADD CONSTRAINT "progress_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "books"."progress" ADD CONSTRAINT "progress_file_id_files_id_fkey" FOREIGN KEY ("file_id") REFERENCES "books"."files"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "books"."sessions" ADD CONSTRAINT "sessions_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "books"."sessions" ADD CONSTRAINT "sessions_file_id_files_id_fkey" FOREIGN KEY ("file_id") REFERENCES "books"."files"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "books"."shelf_works" ADD CONSTRAINT "shelf_works_shelf_id_shelves_id_fkey" FOREIGN KEY ("shelf_id") REFERENCES "books"."shelves"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "books"."shelf_works" ADD CONSTRAINT "shelf_works_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;
|
||||
--> statement-breakpoint
|
||||
-- Hand-written: updated_at triggers, grants, default shelves, GUI registration.
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY ARRAY['works','files','shelves','progress','notes'] LOOP
|
||||
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON books.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||
END LOOP;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT USAGE ON SCHEMA books TO personal_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA books TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA books TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA books GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA books GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||
GRANT USAGE ON SCHEMA books TO personal_ro;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA books TO personal_ro;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA books GRANT SELECT ON TABLES TO personal_ro;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO books.shelves (slug, name, sort_order) VALUES
|
||||
('to-read', 'To read', 10), ('reading', 'Reading', 20), ('done', 'Done', 30), ('reference', 'Reference', 40)
|
||||
ON CONFLICT DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||
VALUES ('books','works','books-works','Books','core',true,false,1000),
|
||||
('books','shelves','books-shelves','Book shelves','core',true,false,1000),
|
||||
('books','files','books-files','Book files','core',false,false,1000)
|
||||
ON CONFLICT DO NOTHING;
|
||||
-- meta.sync() is executable by personal_app/personal_ddl, not by the migration role
|
||||
-- (personal) in prod; deploy-remote.sh runs it as the app role after migrating.
|
||||
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||
PERFORM meta.sync();
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
--> statement-breakpoint
|
||||
-- Hand-written: updated_at trigger and explicit grants (default privileges from the books migration cover new tables too).
|
||||
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON books.sessions FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at();
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON books.sessions TO personal_app;
|
||||
GRANT USAGE, SELECT ON SEQUENCE books.sessions_id_seq TO personal_app;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||
GRANT SELECT ON books.sessions TO personal_ro;
|
||||
END IF;
|
||||
END $$;
|
||||
13722
drizzle/20261024013208_books/snapshot.json
Normal file
13722
drizzle/20261024013208_books/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "include_in_total" boolean DEFAULT true NOT NULL;
|
||||
13735
drizzle/20261024021157_money_include_in_total/snapshot.json
Normal file
13735
drizzle/20261024021157_money_include_in_total/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
142
drizzle/20261024022836_tasks/migration.sql
Normal file
142
drizzle/20261024022836_tasks/migration.sql
Normal file
@@ -0,0 +1,142 @@
|
||||
CREATE SCHEMA "tasks";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tasks"."activity" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"task_id" integer,
|
||||
"goal_id" integer,
|
||||
"kind" text NOT NULL,
|
||||
"from_status" text,
|
||||
"to_status" text,
|
||||
"note" text,
|
||||
"value" numeric(14,2),
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "activity_target_check" CHECK ("task_id" IS NOT NULL OR "goal_id" IS NOT NULL),
|
||||
CONSTRAINT "activity_kind_check" CHECK ("kind" IN ('created','status','edit','note','progress'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tasks"."areas" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"slug" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"color" text,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "areas_slug_check" CHECK ("slug" ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
|
||||
CONSTRAINT "areas_name_check" CHECK (length(btrim("name")) > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tasks"."goals" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"title" text NOT NULL,
|
||||
"notes" text,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"area_id" integer,
|
||||
"target_date" date,
|
||||
"metric" text,
|
||||
"metric_unit" text,
|
||||
"metric_target" numeric(14,2),
|
||||
"metric_current" numeric(14,2),
|
||||
"flagged" boolean DEFAULT false NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"source" text,
|
||||
"achieved_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "goals_title_check" CHECK (length(btrim("title")) > 0),
|
||||
CONSTRAINT "goals_status_check" CHECK ("status" IN ('active','achieved','dropped'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tasks"."tasks" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"title" text NOT NULL,
|
||||
"notes" text,
|
||||
"status" text DEFAULT 'todo' NOT NULL,
|
||||
"priority" smallint DEFAULT 0 NOT NULL,
|
||||
"flagged" boolean DEFAULT false NOT NULL,
|
||||
"due_date" date,
|
||||
"due_time" time,
|
||||
"reminder_at" timestamp with time zone,
|
||||
"recurrence" text,
|
||||
"area_id" integer,
|
||||
"goal_id" integer,
|
||||
"parent_id" integer,
|
||||
"tags" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"source" text,
|
||||
"external_id" text,
|
||||
"started_at" timestamp with time zone,
|
||||
"completed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "tasks_title_check" CHECK (length(btrim("title")) > 0),
|
||||
CONSTRAINT "tasks_status_check" CHECK ("status" IN ('todo','in_progress','done','dropped')),
|
||||
CONSTRAINT "tasks_priority_check" CHECK ("priority" BETWEEN 0 AND 3),
|
||||
CONSTRAINT "tasks_parent_check" CHECK ("parent_id" IS NULL OR "parent_id" <> "id"),
|
||||
CONSTRAINT "tasks_time_check" CHECK ("due_time" IS NULL OR "due_date" IS NOT NULL)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "activity_task_idx" ON "tasks"."activity" ("task_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "activity_goal_idx" ON "tasks"."activity" ("goal_id","created_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "areas_slug_key" ON "tasks"."areas" ("slug");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "goals_title_key" ON "tasks"."goals" (lower("title"));--> statement-breakpoint
|
||||
CREATE INDEX "tasks_area_idx" ON "tasks"."tasks" ("area_id");--> statement-breakpoint
|
||||
CREATE INDEX "tasks_goal_idx" ON "tasks"."tasks" ("goal_id");--> statement-breakpoint
|
||||
CREATE INDEX "tasks_parent_idx" ON "tasks"."tasks" ("parent_id");--> statement-breakpoint
|
||||
CREATE INDEX "tasks_status_due_idx" ON "tasks"."tasks" ("status","due_date");--> statement-breakpoint
|
||||
CREATE INDEX "tasks_tags_idx" ON "tasks"."tasks" USING gin ("tags");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "tasks_source_external_key" ON "tasks"."tasks" ("source","external_id") WHERE "external_id" IS NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "tasks"."activity" ADD CONSTRAINT "activity_task_id_tasks_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"."tasks"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "tasks"."activity" ADD CONSTRAINT "activity_goal_id_goals_id_fkey" FOREIGN KEY ("goal_id") REFERENCES "tasks"."goals"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "tasks"."goals" ADD CONSTRAINT "goals_area_id_areas_id_fkey" FOREIGN KEY ("area_id") REFERENCES "tasks"."areas"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "tasks"."tasks" ADD CONSTRAINT "tasks_area_id_areas_id_fkey" FOREIGN KEY ("area_id") REFERENCES "tasks"."areas"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "tasks"."tasks" ADD CONSTRAINT "tasks_goal_id_goals_id_fkey" FOREIGN KEY ("goal_id") REFERENCES "tasks"."goals"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "tasks"."tasks" ADD CONSTRAINT "tasks_parent_id_tasks_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "tasks"."tasks"("id") ON DELETE CASCADE;
|
||||
--> statement-breakpoint
|
||||
-- Hand-written below this line (not generated by drizzle-kit).
|
||||
-- updated_at triggers, same as every other core table.
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
|
||||
FOREACH t IN ARRAY ARRAY['areas','goals','tasks'] LOOP
|
||||
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON tasks.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||
END LOOP;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
-- Privacy: like health, the task tracker is readable/writable by the app role only
|
||||
-- (no personal_ro grant). Grants only run on prod, where migrations run as `personal`.
|
||||
REVOKE ALL ON SCHEMA tasks FROM PUBLIC;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT USAGE ON SCHEMA tasks TO personal_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tasks TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA tasks TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA tasks
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA tasks
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
-- Registered in meta.tables (structure only; the lists GUI only shows kind = 'list').
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||
VALUES ('tasks','tasks','tasks-tasks','Tasks','core',true,true,1000),
|
||||
('tasks','goals','tasks-goals','Goals','core',true,false,1000),
|
||||
('tasks','areas','tasks-areas','Task areas','core',true,false,1000),
|
||||
('tasks','activity','tasks-activity','Task activity','core',false,false,1000)
|
||||
ON CONFLICT DO NOTHING;
|
||||
-- deploy-remote.sh runs meta.sync() as the app role after migrating.
|
||||
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||
PERFORM meta.sync();
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
14935
drizzle/20261024022836_tasks/snapshot.json
Normal file
14935
drizzle/20261024022836_tasks/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
29
drizzle/20261024024249_meta_edit_log/migration.sql
Normal file
29
drizzle/20261024024249_meta_edit_log/migration.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE "meta"."edit_log" (
|
||||
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "meta"."edit_log_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||
"at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"user_id" text,
|
||||
"user_email" text,
|
||||
"schema_name" text NOT NULL,
|
||||
"table_name" text NOT NULL,
|
||||
"action" text NOT NULL,
|
||||
"pk" jsonb,
|
||||
"before" jsonb,
|
||||
"after" jsonb,
|
||||
CONSTRAINT "edit_log_action_check" CHECK (action IN ('insert','update','delete','import','add_column'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "edit_log_table_at_index" ON "meta"."edit_log" ("schema_name","table_name","at");--> statement-breakpoint
|
||||
-- Hand-written below this line (not generated by drizzle-kit).
|
||||
-- The app role appends to and reads the log; nobody updates or deletes through the app.
|
||||
-- personal_ro gets no access: before/after values can hold private (health) data.
|
||||
-- Grants only run on prod (as `personal`); the dev DB is owned by personal_dev.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT SELECT, INSERT ON meta.edit_log TO personal_app;
|
||||
GRANT USAGE ON SEQUENCE meta.edit_log_id_seq TO personal_app;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||
REVOKE ALL ON meta.edit_log FROM personal_ro;
|
||||
END IF;
|
||||
END $$;
|
||||
15132
drizzle/20261024024249_meta_edit_log/snapshot.json
Normal file
15132
drizzle/20261024024249_meta_edit_log/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2
drizzle/20261024030132_tasks_ticktick/migration.sql
Normal file
2
drizzle/20261024030132_tasks_ticktick/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "tasks"."tasks" ADD COLUMN "start_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "tasks"."tasks" ADD COLUMN "time_zone" text;
|
||||
15158
drizzle/20261024030132_tasks_ticktick/snapshot.json
Normal file
15158
drizzle/20261024030132_tasks_ticktick/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
112
drizzle/20261024132343_groceries/migration.sql
Normal file
112
drizzle/20261024132343_groceries/migration.sql
Normal file
@@ -0,0 +1,112 @@
|
||||
CREATE SCHEMA "groceries";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "groceries"."prices" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"product_id" integer NOT NULL,
|
||||
"store_id" integer NOT NULL,
|
||||
"price_cents" integer NOT NULL,
|
||||
"unit_price_cents" integer,
|
||||
"promo" boolean DEFAULT false NOT NULL,
|
||||
"observed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"source" text DEFAULT 'manual' NOT NULL,
|
||||
"store_product_id" text,
|
||||
"url" text,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "prices_price_check" CHECK ("price_cents" >= 0),
|
||||
CONSTRAINT "prices_unit_price_check" CHECK ("unit_price_cents" IS NULL OR "unit_price_cents" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "groceries"."products" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"name" text NOT NULL,
|
||||
"brand" text,
|
||||
"category" text,
|
||||
"size" text,
|
||||
"unit_amount" numeric(10,3),
|
||||
"unit" text,
|
||||
"favorite" boolean DEFAULT false NOT NULL,
|
||||
"notes" text,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "products_name_check" CHECK (length(btrim("name")) > 0),
|
||||
CONSTRAINT "products_unit_check" CHECK ("unit" IS NULL OR "unit" IN ('piece','kg','l')),
|
||||
CONSTRAINT "products_unit_amount_check" CHECK ("unit_amount" IS NULL OR "unit_amount" > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "groceries"."stores" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"slug" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"website" text,
|
||||
"color" text,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "stores_slug_check" CHECK ("slug" ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
|
||||
CONSTRAINT "stores_name_check" CHECK (length(btrim("name")) > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "prices_product_store_idx" ON "groceries"."prices" ("product_id","store_id","observed_at");--> statement-breakpoint
|
||||
CREATE INDEX "prices_store_idx" ON "groceries"."prices" ("store_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "products_name_brand_key" ON "groceries"."products" (lower("name"),lower(coalesce("brand", '')));--> statement-breakpoint
|
||||
CREATE INDEX "products_category_idx" ON "groceries"."products" ("category");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "stores_slug_key" ON "groceries"."stores" ("slug");--> statement-breakpoint
|
||||
ALTER TABLE "groceries"."prices" ADD CONSTRAINT "prices_product_id_products_id_fkey" FOREIGN KEY ("product_id") REFERENCES "groceries"."products"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "groceries"."prices" ADD CONSTRAINT "prices_store_id_stores_id_fkey" FOREIGN KEY ("store_id") REFERENCES "groceries"."stores"("id") ON DELETE CASCADE;
|
||||
--> statement-breakpoint
|
||||
-- Hand-written below this line (not generated by drizzle-kit).
|
||||
-- updated_at triggers, same as every other core table.
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
|
||||
FOREACH t IN ARRAY ARRAY['stores','products','prices'] LOOP
|
||||
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON groceries.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||
END LOOP;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
-- Private like tasks/health: app role only, no personal_ro. Grants only run on prod,
|
||||
-- where migrations run as `personal`.
|
||||
REVOKE ALL ON SCHEMA groceries FROM PUBLIC;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT USAGE ON SCHEMA groceries TO personal_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA groceries TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA groceries TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA groceries
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA groceries
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO groceries.stores (slug, name, website, color, sort_order) VALUES
|
||||
('ah', 'Albert Heijn', 'https://www.ah.nl', '#00a0e2', 10),
|
||||
('jumbo', 'Jumbo', 'https://www.jumbo.com', '#eeb717', 20),
|
||||
('lidl', 'Lidl', 'https://www.lidl.nl', '#0050aa', 30),
|
||||
('plus', 'Plus', 'https://www.plus.nl', '#e2001a', 40)
|
||||
ON CONFLICT DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
-- Registered in meta.tables so the /lists database browser shows them (kind 'core').
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||
VALUES ('groceries','products','groceries-products','Grocery products','core',true,false,1100),
|
||||
('groceries','prices','groceries-prices','Grocery prices','core',true,false,1100),
|
||||
('groceries','stores','groceries-stores','Grocery stores','core',true,false,1100)
|
||||
ON CONFLICT DO NOTHING;
|
||||
-- deploy-remote.sh runs meta.sync() as the app role after migrating.
|
||||
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||
PERFORM meta.sync();
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
15861
drizzle/20261024132343_groceries/snapshot.json
Normal file
15861
drizzle/20261024132343_groceries/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
11
drizzle/20261024134401_groceries_drop_manual/migration.sql
Normal file
11
drizzle/20261024134401_groceries_drop_manual/migration.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "groceries"."prices" DROP CONSTRAINT "prices_product_id_products_id_fkey";--> statement-breakpoint
|
||||
DROP TABLE "groceries"."prices";--> statement-breakpoint
|
||||
DROP TABLE "groceries"."products";--> statement-breakpoint
|
||||
-- The hand-entered products/prices tables were never used (0 rows); replaced by store items
|
||||
-- and groups in the next migration. Unregister them from the /lists database browser.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||
DELETE FROM meta.tables WHERE schema_name = 'groceries' AND table_name IN ('products','prices');
|
||||
END IF;
|
||||
END $$;
|
||||
15330
drizzle/20261024134401_groceries_drop_manual/snapshot.json
Normal file
15330
drizzle/20261024134401_groceries_drop_manual/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
138
drizzle/20261024134411_groceries_compare/migration.sql
Normal file
138
drizzle/20261024134411_groceries_compare/migration.sql
Normal file
@@ -0,0 +1,138 @@
|
||||
-- Trigram search for group/item names (trusted extension; the database owner may create it).
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "groceries"."groups" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"key" text,
|
||||
"name" text NOT NULL,
|
||||
"brand" text,
|
||||
"category" text,
|
||||
"unit" text,
|
||||
"family_key" text,
|
||||
"favorite" boolean DEFAULT false NOT NULL,
|
||||
"manual" boolean DEFAULT false NOT NULL,
|
||||
"merged_into_id" integer,
|
||||
"notes" text,
|
||||
"item_count" integer DEFAULT 0 NOT NULL,
|
||||
"store_count" integer DEFAULT 0 NOT NULL,
|
||||
"store_ids" integer[] DEFAULT '{}'::integer[] NOT NULL,
|
||||
"min_unit_price_cents" integer,
|
||||
"min_price_cents" integer,
|
||||
"promo_count" integer DEFAULT 0 NOT NULL,
|
||||
"image_url" text,
|
||||
"search_text" text DEFAULT '' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "groups_name_check" CHECK (length(btrim("name")) > 0),
|
||||
CONSTRAINT "groups_unit_check" CHECK ("unit" IS NULL OR "unit" IN ('piece','kg','l')),
|
||||
CONSTRAINT "groups_not_self_merged" CHECK ("merged_into_id" IS NULL OR "merged_into_id" <> "id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "groceries"."import_runs" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"store_id" integer NOT NULL,
|
||||
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"finished_at" timestamp with time zone,
|
||||
"status" text DEFAULT 'running' NOT NULL,
|
||||
"requests" integer DEFAULT 0 NOT NULL,
|
||||
"fetched" integer DEFAULT 0 NOT NULL,
|
||||
"inserted" integer DEFAULT 0 NOT NULL,
|
||||
"price_changes" integer DEFAULT 0 NOT NULL,
|
||||
"error" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "groceries"."item_prices" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"item_id" integer NOT NULL,
|
||||
"observed_on" date NOT NULL,
|
||||
"price_cents" integer,
|
||||
"promo_price_cents" integer,
|
||||
"promo_label" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "groceries"."items" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"store_id" integer NOT NULL,
|
||||
"store_product_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"brand" text,
|
||||
"size_text" text,
|
||||
"unit" text,
|
||||
"unit_amount" numeric(12,4),
|
||||
"unit_source" text,
|
||||
"price_cents" integer,
|
||||
"promo_price_cents" integer,
|
||||
"promo_label" text,
|
||||
"unit_price_cents" integer,
|
||||
"promo_unit_price_cents" integer,
|
||||
"category" text,
|
||||
"category_path" text,
|
||||
"gtin" text,
|
||||
"gtin_checked_at" timestamp with time zone,
|
||||
"image_url" text,
|
||||
"url" text,
|
||||
"available" boolean DEFAULT true NOT NULL,
|
||||
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"group_id" integer,
|
||||
"match_key" text,
|
||||
"match_method" text,
|
||||
"match_confidence" real,
|
||||
"match_locked" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "items_unit_check" CHECK ("unit" IS NULL OR "unit" IN ('piece','kg','l')),
|
||||
CONSTRAINT "items_price_check" CHECK ("price_cents" IS NULL OR "price_cents" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "groups_key_key" ON "groceries"."groups" ("key");--> statement-breakpoint
|
||||
CREATE INDEX "groups_family_idx" ON "groceries"."groups" ("family_key");--> statement-breakpoint
|
||||
CREATE INDEX "groups_category_idx" ON "groceries"."groups" ("category");--> statement-breakpoint
|
||||
CREATE INDEX "groups_store_ids_idx" ON "groceries"."groups" USING gin ("store_ids");--> statement-breakpoint
|
||||
CREATE INDEX "groups_search_trgm_idx" ON "groceries"."groups" USING gin ("search_text" gin_trgm_ops);--> statement-breakpoint
|
||||
CREATE INDEX "groups_list_idx" ON "groceries"."groups" ("store_count","name");--> statement-breakpoint
|
||||
CREATE INDEX "import_runs_store_idx" ON "groceries"."import_runs" ("store_id","started_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "item_prices_item_day_key" ON "groceries"."item_prices" ("item_id","observed_on");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "items_store_product_key" ON "groceries"."items" ("store_id","store_product_id");--> statement-breakpoint
|
||||
CREATE INDEX "items_group_idx" ON "groceries"."items" ("group_id");--> statement-breakpoint
|
||||
CREATE INDEX "items_gtin_idx" ON "groceries"."items" ("gtin");--> statement-breakpoint
|
||||
CREATE INDEX "items_name_trgm_idx" ON "groceries"."items" USING gin ("name" gin_trgm_ops);--> statement-breakpoint
|
||||
ALTER TABLE "groceries"."groups" ADD CONSTRAINT "groups_merged_into_id_groups_id_fkey" FOREIGN KEY ("merged_into_id") REFERENCES "groceries"."groups"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "groceries"."import_runs" ADD CONSTRAINT "import_runs_store_id_stores_id_fkey" FOREIGN KEY ("store_id") REFERENCES "groceries"."stores"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "groceries"."item_prices" ADD CONSTRAINT "item_prices_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "groceries"."items"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "groceries"."items" ADD CONSTRAINT "items_store_id_stores_id_fkey" FOREIGN KEY ("store_id") REFERENCES "groceries"."stores"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "groceries"."items" ADD CONSTRAINT "items_group_id_groups_id_fkey" FOREIGN KEY ("group_id") REFERENCES "groceries"."groups"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
|
||||
FOREACH t IN ARRAY ARRAY['groups','items'] LOOP
|
||||
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON groceries.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||
END LOOP;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
-- Private: app role only (default privileges from the first groceries migration also apply).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA groceries TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA groceries TO personal_app;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||
VALUES ('groceries','groups','groceries-groups','Grocery groups','core',true,false,1100),
|
||||
('groceries','items','groceries-items','Grocery store items','core',true,false,1100),
|
||||
('groceries','item_prices','groceries-item-prices','Grocery price history','core',false,false,1100),
|
||||
('groceries','import_runs','groceries-import-runs','Grocery import runs','core',false,false,1100)
|
||||
ON CONFLICT DO NOTHING;
|
||||
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||
PERFORM meta.sync();
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
16678
drizzle/20261024134411_groceries_compare/snapshot.json
Normal file
16678
drizzle/20261024134411_groceries_compare/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5
drizzle/20261024140745_house_scrapers/migration.sql
Normal file
5
drizzle/20261024140745_house_scrapers/migration.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "house"."listings" ADD COLUMN "address_key" text;--> statement-breakpoint
|
||||
ALTER TABLE "house"."listings" ADD COLUMN "duplicate_of" integer;--> statement-breakpoint
|
||||
ALTER TABLE "house"."listings" ADD COLUMN "total_price_eur" integer;--> statement-breakpoint
|
||||
CREATE INDEX "listings_address_idx" ON "house"."listings" ("address_key") WHERE address_key IS NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "house"."listings" ADD CONSTRAINT "listings_duplicate_of_listings_id_fkey" FOREIGN KEY ("duplicate_of") REFERENCES "house"."listings"("id") ON DELETE SET NULL;
|
||||
16755
drizzle/20261024140745_house_scrapers/snapshot.json
Normal file
16755
drizzle/20261024140745_house_scrapers/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
20
drizzle/20261024141609_health_scale_metrics/migration.sql
Normal file
20
drizzle/20261024141609_health_scale_metrics/migration.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
ALTER TABLE "health"."body_measurements" ADD COLUMN "muscle_mass_kg" numeric(5,2);--> statement-breakpoint
|
||||
ALTER TABLE "health"."body_measurements" ADD COLUMN "skeletal_muscle_kg" numeric(5,2);--> statement-breakpoint
|
||||
ALTER TABLE "health"."body_measurements" ADD COLUMN "bone_mass_kg" numeric(4,2);--> statement-breakpoint
|
||||
ALTER TABLE "health"."body_measurements" ADD COLUMN "visceral_fat" numeric(4,1);--> statement-breakpoint
|
||||
ALTER TABLE "health"."body_measurements" ADD COLUMN "body_water_pct" numeric(4,1);--> statement-breakpoint
|
||||
ALTER TABLE "health"."body_measurements" ADD COLUMN "protein_pct" numeric(4,1);--> statement-breakpoint
|
||||
ALTER TABLE "health"."body_measurements" ADD COLUMN "bmr_kcal" integer;--> statement-breakpoint
|
||||
ALTER TABLE "health"."body_measurements" ADD COLUMN "bmi" numeric(4,1);--> statement-breakpoint
|
||||
ALTER TABLE "health"."body_measurements" ADD COLUMN "measured_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "health"."body_measurements" ADD COLUMN "source" text;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "body_measurements_date_weight_key" ON "health"."body_measurements" ("date","weight_kg");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "body_measurements_measured_at_key" ON "health"."body_measurements" ("measured_at");--> statement-breakpoint
|
||||
ALTER TABLE "health"."body_measurements" ADD CONSTRAINT "body_measurements_scale_check" CHECK (("muscle_mass_kg" IS NULL OR "muscle_mass_kg" BETWEEN 5 AND 250)
|
||||
AND ("skeletal_muscle_kg" IS NULL OR "skeletal_muscle_kg" BETWEEN 5 AND 200)
|
||||
AND ("bone_mass_kg" IS NULL OR "bone_mass_kg" BETWEEN 0.5 AND 20)
|
||||
AND ("visceral_fat" IS NULL OR "visceral_fat" BETWEEN 1 AND 60)
|
||||
AND ("body_water_pct" IS NULL OR "body_water_pct" BETWEEN 20 AND 85)
|
||||
AND ("protein_pct" IS NULL OR "protein_pct" BETWEEN 5 AND 40)
|
||||
AND ("bmr_kcal" IS NULL OR "bmr_kcal" BETWEEN 500 AND 6000)
|
||||
AND ("bmi" IS NULL OR "bmi" BETWEEN 10 AND 80));
|
||||
16941
drizzle/20261024141609_health_scale_metrics/snapshot.json
Normal file
16941
drizzle/20261024141609_health_scale_metrics/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
66
drizzle/20261024150925_home/migration.sql
Normal file
66
drizzle/20261024150925_home/migration.sql
Normal file
@@ -0,0 +1,66 @@
|
||||
CREATE SCHEMA "home";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "home"."links" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"title" text NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"section" text DEFAULT 'links' NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "links_title_check" CHECK (length(btrim("title")) between 1 and 80),
|
||||
CONSTRAINT "links_url_check" CHECK ("url" ~ '^https?://' and length("url") <= 2000)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "home"."snapshots" (
|
||||
"key" text PRIMARY KEY,
|
||||
"data" jsonb NOT NULL,
|
||||
"collected_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "links_sort_idx" ON "home"."links" ("section","sort_order");--> statement-breakpoint
|
||||
-- Privacy: like tasks and health, the start page is the app role only (no personal_ro).
|
||||
-- Grants only run on prod, where migrations run as `personal`.
|
||||
REVOKE ALL ON SCHEMA home FROM PUBLIC;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT USAGE ON SCHEMA home TO personal_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA home TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA home TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA home
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA home
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
END IF;
|
||||
-- The host-side collector (deploy/home-collector) may only upsert snapshots. The role is
|
||||
-- created out of band (it has a password); see deploy/home-collector/README.md.
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'home_collector') THEN
|
||||
GRANT USAGE ON SCHEMA home TO home_collector;
|
||||
GRANT SELECT, INSERT, UPDATE ON home.snapshots TO home_collector;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
-- Starter links; edited inline on /start.
|
||||
INSERT INTO "home"."links" ("title", "url", "section", "sort_order") VALUES
|
||||
('Gmail', 'https://mail.google.com/mail/u/0/', 'daily', 10),
|
||||
('Calendar', 'https://calendar.google.com/', 'daily', 20),
|
||||
('WhatsApp', 'https://web.whatsapp.com/', 'daily', 30),
|
||||
('Discord', 'https://discord.com/app', 'daily', 40),
|
||||
('X', 'https://x.com/home', 'daily', 50),
|
||||
('YouTube', 'https://www.youtube.com/', 'daily', 60),
|
||||
('Spotify', 'https://open.spotify.com/', 'daily', 70),
|
||||
('Drive', 'https://drive.google.com/', 'daily', 80),
|
||||
('Moneybird', 'https://moneybird.com/390525278507501515', 'money', 110),
|
||||
('Revolut', 'https://app.revolut.com/', 'money', 120),
|
||||
('Knab', 'https://www.knab.nl/', 'money', 130),
|
||||
('Tikkie', 'https://www.tikkie.me/', 'money', 140),
|
||||
('Gitea', 'https://git.stanrunge.dev/', 'dev', 210),
|
||||
('GitHub', 'https://github.com/', 'dev', 220),
|
||||
('Cloudflare', 'https://dash.cloudflare.com/', 'dev', 230),
|
||||
('Tailscale', 'https://login.tailscale.com/admin/machines', 'dev', 240),
|
||||
('Backblaze B2', 'https://secure.backblaze.com/b2_buckets.htm', 'dev', 250),
|
||||
('1Password', 'https://my.1password.com/', 'dev', 260),
|
||||
('osu!', 'https://osu.ppy.sh/', 'fun', 310),
|
||||
('Twitch', 'https://www.twitch.tv/', 'fun', 320);
|
||||
17149
drizzle/20261024150925_home/snapshot.json
Normal file
17149
drizzle/20261024150925_home/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
134
drizzle/20261024153902_cars/migration.sql
Normal file
134
drizzle/20261024153902_cars/migration.sql
Normal file
@@ -0,0 +1,134 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
|
||||
--> statement-breakpoint
|
||||
CREATE SCHEMA "cars";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cars"."brands" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"slug" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"country" text,
|
||||
"model_count" integer DEFAULT 0 NOT NULL,
|
||||
"nl_count" integer DEFAULT 0 NOT NULL,
|
||||
"first_year" smallint,
|
||||
"last_year" smallint,
|
||||
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cars"."models" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"dex" integer NOT NULL,
|
||||
"brand_id" integer NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"search" text NOT NULL,
|
||||
"first_year" smallint,
|
||||
"last_year" smallint,
|
||||
"bodies" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"size_classes" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"fuels" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"drives" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"transmissions" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"engine_l_min" real,
|
||||
"engine_l_max" real,
|
||||
"cylinders_min" smallint,
|
||||
"cylinders_max" smallint,
|
||||
"turbo" boolean,
|
||||
"power_kw_min" real,
|
||||
"power_kw_max" real,
|
||||
"l100_min" real,
|
||||
"l100_max" real,
|
||||
"co2_min" smallint,
|
||||
"co2_max" smallint,
|
||||
"mpg_min" real,
|
||||
"mpg_max" real,
|
||||
"range_km_max" smallint,
|
||||
"seats" smallint,
|
||||
"doors" smallint,
|
||||
"mass_kg" smallint,
|
||||
"nl_count" integer DEFAULT 0 NOT NULL,
|
||||
"nl_rank" integer,
|
||||
"nl_first_year" smallint,
|
||||
"nl_last_year" smallint,
|
||||
"nl_price" integer,
|
||||
"nl_price_latest" integer,
|
||||
"nl_price_latest_year" smallint,
|
||||
"nl_years" jsonb DEFAULT '[]' NOT NULL,
|
||||
"nl_names" jsonb DEFAULT '[]' NOT NULL,
|
||||
"us_years" smallint[] DEFAULT '{}'::smallint[] NOT NULL,
|
||||
"variant_count" integer DEFAULT 0 NOT NULL,
|
||||
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cars"."variants" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"model_id" integer NOT NULL,
|
||||
"source" text DEFAULT 'fueleconomy.gov' NOT NULL,
|
||||
"source_id" integer,
|
||||
"year" smallint NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"engine_l" real,
|
||||
"cylinders" smallint,
|
||||
"transmission" text,
|
||||
"drive" text,
|
||||
"fuel" text,
|
||||
"size_class" text,
|
||||
"mpg_city" real,
|
||||
"mpg_hwy" real,
|
||||
"mpg_comb" real,
|
||||
"co2_gpm" smallint,
|
||||
"range_mi" smallint,
|
||||
"ev_motor" text,
|
||||
"turbo" boolean DEFAULT false NOT NULL,
|
||||
"supercharged" boolean DEFAULT false NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "brands_slug_key" ON "cars"."brands" ("slug");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "models_dex_key" ON "cars"."models" ("dex");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "models_brand_slug_key" ON "cars"."models" ("brand_id","slug");--> statement-breakpoint
|
||||
CREATE INDEX "models_nl_count_idx" ON "cars"."models" ("nl_count");--> statement-breakpoint
|
||||
CREATE INDEX "models_search_trgm_idx" ON "cars"."models" USING gin ("search" public.gin_trgm_ops);--> statement-breakpoint
|
||||
CREATE INDEX "variants_model_year_idx" ON "cars"."variants" ("model_id","year");--> statement-breakpoint
|
||||
ALTER TABLE "cars"."models" ADD CONSTRAINT "models_brand_id_brands_id_fkey" FOREIGN KEY ("brand_id") REFERENCES "cars"."brands"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "cars"."variants" ADD CONSTRAINT "variants_model_id_models_id_fkey" FOREIGN KEY ("model_id") REFERENCES "cars"."models"("id") ON DELETE CASCADE;
|
||||
--> statement-breakpoint
|
||||
-- Hand-written: updated_at triggers, grants, GUI registration. Data comes from scripts/cars-import.ts.
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY ARRAY['brands','models'] LOOP
|
||||
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON cars.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||
END LOOP;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT USAGE ON SCHEMA cars TO personal_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA cars TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA cars TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA cars GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA cars GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||
GRANT USAGE ON SCHEMA cars TO personal_ro;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA cars TO personal_ro;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA cars GRANT SELECT ON TABLES TO personal_ro;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||
VALUES ('cars','brands','cars-brands','Car brands','core',false,false,1000),
|
||||
('cars','models','cars-models','Car models','core',false,false,1000)
|
||||
ON CONFLICT DO NOTHING;
|
||||
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||
PERFORM meta.sync();
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
18350
drizzle/20261024153902_cars/snapshot.json
Normal file
18350
drizzle/20261024153902_cars/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
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
14
drizzle/20261025133601_reading_kinds/migration.sql
Normal file
14
drizzle/20261025133601_reading_kinds/migration.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE TYPE "books"."kind" AS ENUM('book', 'paper', 'article');--> statement-breakpoint
|
||||
ALTER TABLE "books"."works" ADD COLUMN "kind" "books"."kind" DEFAULT 'book'::"books"."kind" NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "books"."works" ADD COLUMN "url" text;--> statement-breakpoint
|
||||
ALTER TABLE "books"."works" ADD COLUMN "site_name" text;--> statement-breakpoint
|
||||
ALTER TABLE "books"."works" ADD COLUMN "published_at" timestamp with time zone;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "works_url_key" ON "books"."works" ("url") WHERE "url" is not null;--> statement-breakpoint
|
||||
CREATE INDEX "works_kind_idx" ON "books"."works" ("kind");--> statement-breakpoint
|
||||
-- Classify existing papers: anything with an arXiv id, or a short ISBN-less PDF from the Drive
|
||||
-- "Papers/" folder (Ilya Sutskever's list). Longer works in that folder (Kolmogorov
|
||||
-- Complexity, Machine Super Intelligence) stay books.
|
||||
UPDATE "books"."works" SET "kind" = 'paper'
|
||||
WHERE "source_refs" ? 'arxiv'
|
||||
OR ("isbn13" IS NULL AND coalesce("page_count", 0) BETWEEN 1 AND 99
|
||||
AND EXISTS (SELECT 1 FROM jsonb_array_elements_text(CASE WHEN jsonb_typeof("source_refs"->'drive') = 'array' THEN "source_refs"->'drive' ELSE '[]'::jsonb END) d WHERE d LIKE 'Papers/%'));
|
||||
26247
drizzle/20261025133601_reading_kinds/snapshot.json
Normal file
26247
drizzle/20261025133601_reading_kinds/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
ALTER TABLE "music"."plays" DROP CONSTRAINT "plays_origin_check", ADD CONSTRAINT "plays_origin_check" CHECK ("origin" in ('api', 'export', 'import', 'manual', 'nowplaying'));
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user