From efcd7652aeabf88e9b102eafe02cd242f68ede3e Mon Sep 17 00:00:00 2001 From: zk Date: Fri, 8 May 2026 16:54:34 +0000 Subject: [PATCH] rebel shell --- .cursor/debug-7b27d8.log | 144 +++++++++ .cursor/rules/sql-fully-qualified-fields.mdc | 2 +- .../skills/actions-and-pg-migration/SKILL.md | 28 +- .cursor/skills/coverage-tools/SKILL.md | 12 +- .cursor/skills/db-formats/SKILL.md | 8 +- .cursor/skills/exhaustive-switch/SKILL.md | 8 +- .../mongo-migration-constraints/SKILL.md | 2 +- .cursor/skills/no-return-await/SKILL.md | 6 +- .cursor/skills/preserve-comments/SKILL.md | 6 +- .devcontainer/devcontainer.json | 1 - .dockerignore | 2 +- .github/workflows/client.yml | 6 +- .github/workflows/database-seeds.yml | 4 +- .github/workflows/server.yml | 8 +- Justfile-backup | 2 +- Justfile-repo | 6 +- db/migrations/20260301154256_genesis.sql | 35 ++- ...422130000_grant_grafana_ro_public_read.sql | 17 -- ...20260423120000_job_queue_dequeue_index.sql | 4 - dev/bootstrap.sh | 2 +- dev/fish-plugins/fisher/README.md | 6 +- docker/Dockerfile.server | 76 ++--- docs/docs/api/routes/admin.md | 4 +- docs/docs/codebase/setup/config.md | 26 +- .../grafana/dashboards/tachi-api.json | 2 +- tsconfig.base.json | 2 +- typescript/bot/vitest.config.ts | 2 +- typescript/bot/vitest.globalSetup.ts | 2 +- typescript/bot/vitest.setup.ts | 4 +- .../src/app/pages/admin/AdminActionsPage.tsx | 6 +- .../src/app/pages/admin/AdminCronJobsPage.tsx | 6 +- .../app/pages/admin/AdminOperationsPage.tsx | 4 +- .../pages/dashboard/users/UserOrphansPage.tsx | 6 +- .../_game/_playtype/SpecificSessionPage.tsx | 60 +++- .../src/components/activity/Activity.tsx | 51 +++- .../src/components/imports/ImportInfo.tsx | 2 +- typescript/client/src/lib/config.ts | 4 +- typescript/client/src/lib/games/iidx.tsx | 1 - .../src/styles/components/timeline.scss | 20 ++ typescript/client/src/types/api-returns.ts | 5 + typescript/common/.taprc | 13 - .../common/src/config/game-support/iidx.ts | 16 +- typescript/common/src/lib/folder-slug.ts | 2 +- typescript/common/vitest.config.ts | 2 +- .../coverage-tools/src/report-coverage.ts | 2 +- typescript/db-cli/src/index.ts | 2 +- typescript/db-migration-engine/src/index.ts | 8 +- .../db/src/generated/public/PbComposedFrom.ts | 2 + typescript/eslint-config/index.js | 17 +- typescript/rg-stats/vitest.config.ts | 2 +- .../rerunners/usc/add-usc-converts.js | 4 +- .../rerunners/v3/4-tablefolders-to-object.ts | 2 +- typescript/seeds-webui/README.md | 2 +- typescript/seeds-webui/dev/util/git.ts | 4 +- .../seeds-webui/dev/vite-plugin-seeds-dev.ts | 2 +- typescript/seeds-webui/src/App.tsx | 2 +- .../src/components/CollectionDiffRows.tsx | 4 +- .../src/components/CommitSparkline.tsx | 2 +- .../src/components/ResultTable.tsx | 2 +- .../seeds-webui/src/components/RowEditor.tsx | 2 +- .../seeds-webui/src/components/SqlEditor.tsx | 8 +- typescript/seeds-webui/src/lib/config.ts | 2 +- .../src/lib/diff/collection-diff.ts | 4 +- .../src/lib/diff/row-primary-key.ts | 2 +- .../seeds-webui/src/lib/edits/schemas.ts | 2 +- .../src/lib/format/seed-doc-summary.ts | 6 +- .../seeds-webui/src/lib/sqlite/builder.ts | 4 +- .../seeds-webui/src/lib/sqlite/schema.ts | 4 +- .../src/lib/transport/dev-transport.ts | 2 +- .../seeds-webui/src/lib/transport/index.ts | 4 +- typescript/seeds-webui/src/pages/Bulk.tsx | 2 +- .../seeds-webui/src/pages/Collection.tsx | 2 +- typescript/seeds-webui/src/pages/Diff.tsx | 4 +- typescript/seeds-webui/src/pages/Drafts.tsx | 4 +- .../seeds-webui/src/styles/_variables.scss | 4 +- typescript/seeds-webui/src/styles/app.scss | 14 +- typescript/seeds-webui/vite.config.ts | 8 +- typescript/server/.dockerignore | 2 +- typescript/server/.env | 1 - typescript/server/.env.example | 9 +- typescript/server/.env.test | 1 - typescript/server/.taprc | 18 -- .../server/src/actions/resend-verify-email.ts | 2 +- .../server/src/anon-actions/register.test.ts | 140 ++++++++- .../server/src/anon-actions/register.ts | 72 ++++- typescript/server/src/cron-worker.ts | 13 +- .../games/ddr.profile.test.ts | 2 +- .../games/iidx.impl.test.ts | 14 +- .../src/game-implementations/games/iidx.ts | 32 +- .../src/game-implementations/games/itg.ts | 2 +- .../utils/pb-merge.test.ts | 1 - typescript/server/src/job-queue-worker.ts | 20 +- .../server/src/lib/activity/activity.ts | 2 +- typescript/server/src/lib/db-formats/pb.ts | 6 +- typescript/server/src/lib/email/client.ts | 5 +- .../server/src/lib/folders/folder-query.ts | 4 +- .../server/src/lib/jobs/cron/cron-registry.ts | 2 +- .../server/src/lib/jobs/drain-dirty-queues.ts | 4 +- .../server/src/lib/router/typed-router.ts | 2 +- .../score-import/framework/orphans/orphans.ts | 2 +- .../score-import/framework/pb/upsert-pb-pg.ts | 1 + .../framework/pg/finalize-import-pg.ts | 25 +- .../framework/score-import.test.ts | 278 +++++++++++++++++- .../score-import/framework/score-import.ts | 2 +- .../score-importing/score-id.test.ts | 2 +- .../score-importing/score-import-main.ts | 2 +- .../score-importing/score-importing.ts | 2 +- .../framework/sessions/sessions.ts | 4 + .../ugpt-stats/update-ugpt-stats.test.ts | 2 - .../import-types/api/myt-wacca/parser.ts | 2 +- .../common/batch-manual/parser.test.ts | 2 +- .../server/src/lib/search/session-search.ts | 2 +- .../server/src/lib/search/songs.bench.ts | 10 +- typescript/server/src/lib/search/songs.ts | 2 +- .../src/lib/setup/build-email-config.test.ts | 37 ++- typescript/server/src/lib/setup/config.ts | 64 ++-- typescript/server/src/load-tests/README.md | 6 +- .../src/load-tests/score-import-load-cli.ts | 4 +- .../server/src/scripts/migrate-to-postgres.ts | 28 +- typescript/server/src/server/prometheus.ts | 4 +- .../router/api/v1/clients/middleware.ts | 2 +- .../router/api/v1/clients/router.test.ts | 2 +- .../_playtype/charts/router.test.ts | 2 +- .../games/_gameGroup/_playtype/router.test.ts | 2 +- .../_playtype/songs/_songID/router.test.ts | 2 +- .../api/v1/sessions/_sessionID/router.test.ts | 60 ++++ .../api/v1/sessions/_sessionID/router.ts | 14 +- .../server/src/server/router/api/v1/spec.ts | 17 +- .../folders/_folderSlug/router.test.ts | 4 +- .../games/_game/_playtype/pbs/router.test.ts | 2 +- .../_game/_playtype/scores/router.test.ts | 2 +- .../_game/_playtype/settings/router.test.ts | 2 +- .../server/router/api/v1/users/router.test.ts | 2 +- .../server/router/ir/fervidex/router.test.ts | 2 +- typescript/server/src/services/pg/db.ts | 10 + typescript/server/src/services/pg/seeds.ts | 2 +- .../server/src/test-utils/pg-fixtures.ts | 4 +- .../server/src/test-utils/seed-paths.ts | 2 +- typescript/server/src/test-utils/test-data.ts | 2 - typescript/server/src/utils/db.ts | 2 +- typescript/server/src/utils/misc.test.ts | 4 +- typescript/server/src/utils/queries/charts.ts | 30 +- .../server/src/utils/queries/pbs.test.ts | 1 - .../server/src/utils/queries/sessions.ts | 54 ++++ typescript/server/src/utils/user.test.ts | 2 +- typescript/server/vitest.config.ts | 2 +- typescript/server/vitest.globalSetup.ts | 2 +- typescript/server/vitest.setup.ts | 4 +- 148 files changed, 1317 insertions(+), 514 deletions(-) create mode 100644 .cursor/debug-7b27d8.log delete mode 100644 db/migrations/20260422130000_grant_grafana_ro_public_read.sql delete mode 100644 db/migrations/20260423120000_job_queue_dequeue_index.sql delete mode 100644 typescript/common/.taprc delete mode 100644 typescript/server/.taprc diff --git a/.cursor/debug-7b27d8.log b/.cursor/debug-7b27d8.log new file mode 100644 index 000000000..495830bdf --- /dev/null +++ b/.cursor/debug-7b27d8.log @@ -0,0 +1,144 @@ +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"iidx-sp","userID":1,"sessionID":"Q578fa997a13b92203dbc59082a452636631ff691","type":"Created","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":0},"timestamp":1778174638000,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qc252f411274450ce5698af2907997cbab60105d2","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":0},"timestamp":1778174660089,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q5d5e46543de37de7144aabde86aaeaf00498195a","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":1},"timestamp":1778174660094,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qc37df566339572b6fee3ca5edd4b3bf0314e29c5","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":2},"timestamp":1778174660099,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q8cbcd83ef2ee60cecb791c09e573cacb82614819","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":3},"timestamp":1778174660105,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q189cf02b87c556a9b3f871e303758dd88d16c96a","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":4},"timestamp":1778174660108,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qe40bb6742d397db1bfedc9aa2da386994054cb8e","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":5},"timestamp":1778174660112,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q7d9e5a5aa58b424f33253e67721fb416d43c0a8b","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":6},"timestamp":1778174660115,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qcafb063529a7746b14be61b5f8b8f785b4c0bb9d","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":7},"timestamp":1778174660121,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qdde44db5089244cc55f662c6a41ffb2fb714a8b8","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":8},"timestamp":1778174660131,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q6e8d8cbad5078faf2b592504808d09e213ed0129","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":9},"timestamp":1778174660136,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q8559d0c86bf3b2f8b15801d36f27fb876ea552b3","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":10},"timestamp":1778174660142,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qe37e28c4561537d764d26d3084f544d4f07fc794","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":11},"timestamp":1778174660147,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q6657d8805d106093b0dddb14cb504b937f132760","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":12},"timestamp":1778174660155,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q48f3114dd0c29c80252fd65a670d97fa4a600f82","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":13},"timestamp":1778174660162,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q990c934c1383480e3d37baf91bd6a765cf8b3a2a","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":14},"timestamp":1778174660165,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q7a417c5a2159bf29e7a01e5250a0711528697f8f","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":15},"timestamp":1778174660170,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q7e1c4f468c77e2fefc8f7c6883e2de759bdd25b9","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":16},"timestamp":1778174660174,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qee252e93a7d251e87e9a7fe6c17139b2d2f99e7a","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":17},"timestamp":1778174660178,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qa105eb34d8423b192e43c238f8ff91083e1bffc9","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":18},"timestamp":1778174660181,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q27daaebb4468a50aae51882418658cc1aa106a34","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":19},"timestamp":1778174660184,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q8c121fa8b73857c8ee117e700842b866e3baf6a7","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":20},"timestamp":1778174660199,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q7210852aa9d6b82fe8d99da03f02d86782285985","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":21},"timestamp":1778174660205,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qcfb1824558a1a99b1538dbd7432289a8be57b3e6","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":22},"timestamp":1778174660209,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q49efff3408ecd0c5b7e41548875f8e443d0dd7f9","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":23},"timestamp":1778174660217,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qc2618e82e8d9911531d1020c4d467859c9214b36","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":24},"timestamp":1778174660223,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q0f170b6b312d00be302e5cc0d4285da541524fbe","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":25},"timestamp":1778174660227,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qdb008e8c2209f91ad53ec0cdee933f67f6e5331e","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":26},"timestamp":1778174660233,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q045d94351296c45a1eaabae9a30029dc68318e19","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":27},"timestamp":1778174660236,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q39b379df7a10f4a72574f3680b52a7ef69751af4","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":28},"timestamp":1778174660245,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q811a4764a3d2534fb4b1da6cbaea1d407a60d0b5","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":29},"timestamp":1778174660248,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qb79f15ac66e1965c35be2004006e577e071b87b7","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":30},"timestamp":1778174660252,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q0f4611dfe6752e90e0ea7ad0a339aed51391d94a","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":31},"timestamp":1778174660257,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q9f0a302cbb6160468da3399669832c52ae7c5910","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":32},"timestamp":1778174660260,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q9418f31ea6a52ffaca93f0657abab15f648d3f24","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":33},"timestamp":1778174660263,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qb4db5061c31ee7743fd4c592a3136cee6c53225d","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":34},"timestamp":1778174660267,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qa7a6babb5c27931e45e0396d81c95272beee00f8","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":35},"timestamp":1778174660270,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q927681faa73a41ffbcc2b2e51bb3220656f7a27c","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":36},"timestamp":1778174660278,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q1edafb103d7f842bd3164bf2d77cca323ddc24d5","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":37},"timestamp":1778174660287,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qc93f8402727171169df28aa12c1852e59a00b0b4","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":38},"timestamp":1778174660294,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qae0736c538299e163942c0fd4fd5c34bcd8ab7c5","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":39},"timestamp":1778174660299,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qa8723107d169ea4e765d28d9dec764e119d4879e","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":40},"timestamp":1778174660303,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qe8e555ad077e1897eab4ece65580c27f1fd8b0bb","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":41},"timestamp":1778174660309,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qfef6ca0ce28485e788d6562db2527b7a8ddce498","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":42},"timestamp":1778174660314,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q5c68d2fe24dc80dfd015b2abcb89e4eba774feaf","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":43},"timestamp":1778174660317,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q4d88a6581e28b7024f020673feb45b75c1d101b3","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":44},"timestamp":1778174660321,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q7d8887d5d3fd265b73b1b29363d305ce13360c94","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":45},"timestamp":1778174660327,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q6f0fb12cb6e8aaf252a1d6040e00d391ea8464d4","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":46},"timestamp":1778174660345,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qd4ec361100e6590f313350b9e3e9150d6387e2ba","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":47},"timestamp":1778174660348,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qe0381db5230e8b7dd310bf2d23d8427dbbb84822","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":48},"timestamp":1778174660350,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q31e15ba0f00783be89720b1aec46550b58976d3b","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":49},"timestamp":1778174660354,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qba5cc5abba6d6f03e6868628b3a4de1461abbc4c","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":50},"timestamp":1778174660360,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qc9d38a91243fdd685b83f61292103dc53da0d4cf","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":51},"timestamp":1778174660365,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q8dabfc9ce6038eb05610efabd1bbe169fe060c46","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":52},"timestamp":1778174660369,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q44c21d76c1f0aceca460bbd6dd9f32e3ba5c7732","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":53},"timestamp":1778174660372,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qba93137b42cdac7a6a33e6b0b8936879e526089c","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":54},"timestamp":1778174660378,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qac474a57764cbaeb6b055f0c98d12bd1dfc75497","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":55},"timestamp":1778174660383,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q54aed62223d5f4a774bbf7283e1d71bffa6a8877","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":56},"timestamp":1778174660389,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qed26c346b3a65815a31b5228e4e632f42f62fa39","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":57},"timestamp":1778174660401,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qaf7a4851b6f7ddf6e9c186c8418b6130bad70798","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":58},"timestamp":1778174660405,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q801affd326b159befcc5ec230f6eac3149603622","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":59},"timestamp":1778174660415,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qc69f6c9c7ab8ecdbcad074aab2f796f052f5d0f8","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":60},"timestamp":1778174660430,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q8b639b6300480f0ecfd5db79b36d2e3c132a54ac","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":61},"timestamp":1778174660438,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qf85158125f1aeaea09a2827da2b3d7d45aba72bc","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":62},"timestamp":1778174660443,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qd9ea6889198ee5e8a099baa6c91bcffa7eca95c3","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":63},"timestamp":1778174660449,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q0585eab487fb017863aaac9f8768e9d1b72cc123","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":64},"timestamp":1778174660453,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q567de464814f23d575e9004f505e1a322be5de31","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":65},"timestamp":1778174660456,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q0b7df3b5afa5e0b50e42c67639b43f175072ce22","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":66},"timestamp":1778174660462,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qdc9e3f944ec4759285e25a25c7a372303c7e18b9","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":67},"timestamp":1778174660465,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q38e82752ad57d923af66a12471ade3d9b883f958","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":68},"timestamp":1778174660469,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q452773b20751ea073659360a00cec1b3861b85ef","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":69},"timestamp":1778174660474,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qd55da19de534bd8038ed3e52b82a890567b6ad19","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":70},"timestamp":1778174660482,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q314a9e746a8b962a00703e9d84a93190c0e915f3","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":71},"timestamp":1778174660487,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qfa374369968d49856bca05e82f36b4d7145ed1ec","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":72},"timestamp":1778174660490,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q76b0603b5a47f4a0236c9087dfbbfeba1fb3bc7b","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":73},"timestamp":1778174660506,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q203f0fe1b402e9a0448ac39e6cd1db01d511a50a","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":74},"timestamp":1778174660514,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q8c27ac1b60300274df3d669847921d35d01289ae","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":75},"timestamp":1778174660520,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q25b244ba922b0d4f1092bf419ea9788b66708f7c","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":76},"timestamp":1778174660524,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q70d5b9a4d9b79fff4f66b8ae300cf64fc488f02a","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":77},"timestamp":1778174660531,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q20e1e28b721c3b0733cd924e720704962e54e3e8","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":78},"timestamp":1778174660553,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qadc97ca77718ccfb8bde7ac9593edaac4bb87a6f","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":79},"timestamp":1778174660569,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q4b2feb98be77e6882f84d6a9cbeca42887abdbf3","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":80},"timestamp":1778174660573,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q77b97d7a7b1bfda84da3f472cc32bacec267219f","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":81},"timestamp":1778174660581,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q424e90d547303ea4923e0258cfc2d8d392679cae","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":82},"timestamp":1778174660586,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q97f2bc192eeb70752c9e30cfe7dcde99bc47e690","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":83},"timestamp":1778174660590,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q59001766f9b8a52c8dcc97bf1d042b454708eae4","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":84},"timestamp":1778174660593,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q00e706d002c2e9ea6086371b935a50b282706d39","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":85},"timestamp":1778174660598,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q0086ff797f2b95359b9472c107f20ae075898b21","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":86},"timestamp":1778174660601,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qe989edaca5a2f24b8d604bf98ad03a48baf0f010","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":87},"timestamp":1778174660606,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q12ef1db3064ea85095e2ff9d74a45a21d7ccc9b5","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":88},"timestamp":1778174660622,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q660bd3bb616c87eeaa3df3b65f25f1a98c57af10","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":89},"timestamp":1778174660626,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q1d038349e88718833f4acd084ca6940a66deb9c4","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":90},"timestamp":1778174660636,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q11339defac1468d048e2b57b1b4d2cc67751ac86","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":91},"timestamp":1778174660639,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q44a76aab9debe86da79c4b870f83d88f76dc4118","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":92},"timestamp":1778174660642,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qe1adc2f7ec768f394813223ed21bd2b571969a4a","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":93},"timestamp":1778174660652,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q489661edfc15cfc92c3834eceb309f1e2d4bb893","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":94},"timestamp":1778174660660,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q489661edfc15cfc92c3834eceb309f1e2d4bb893","type":"Appended","repeatSessionInThisLoad":true,"willPush":false,"runId":"post-fix","priorReturnCount":95},"timestamp":1778174660671,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q06014b95faecab25d0f47b051f1e38d96af5ea7d","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":95},"timestamp":1778174660680,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qdcf58e13a815753fe75b38a564c8e581fef42d17","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":96},"timestamp":1778174660684,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q016e78c91f15ef13016dedc24a16c91284de26ba","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":97},"timestamp":1778174660688,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q12b6a4887a80ecc13a74079376f1259c5cc40e86","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":98},"timestamp":1778174660690,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q577397051d7966d96c1fb5cf649654f3b8dd6edb","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":99},"timestamp":1778174660699,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qa0b0c7a4bb6862f33febafdc80de2efe2d2adb2d","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":100},"timestamp":1778174660702,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qa17b785caf6e37dc996afe0f218baa008a63fb1d","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":101},"timestamp":1778174660705,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qa5bac01b00d12082158013a264ae8c2b10c837c6","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":102},"timestamp":1778174660708,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q6c6e8a526936a57adab446b28d870ae68bee3910","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":103},"timestamp":1778174660715,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q190d862f7853a94510ae603cefa8fec2d0010c17","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":104},"timestamp":1778174660719,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q68b4212ad3a72c9029272f3c8dba2909f6c717d5","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":105},"timestamp":1778174660724,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q61e5881c2e33e7b9c47afc832e54fa23c07657a3","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":106},"timestamp":1778174660729,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q151c94c4fb26eab70ff8db30652dac361d09d11b","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":107},"timestamp":1778174660736,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qb19bcd6ae2f4c2b1bee880fd6021214bfbf05604","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":108},"timestamp":1778174660741,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q952a8c756b74a7c556f19edb77628dbb04c0800b","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":109},"timestamp":1778174660744,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q673e224863b4bd6073c46591576644da16c4324f","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":110},"timestamp":1778174660750,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qd814902d29a9e34622ddc9dfa5089dc8c57f80bc","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":111},"timestamp":1778174660754,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q3ebba12414b002bbde0e9f5ca58134ca37071867","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":112},"timestamp":1778174660758,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qd073fc8da813e7fefa8fe1d6352714f48dd0ff7a","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":113},"timestamp":1778174660761,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q401bdb009b493618518bd7c4a066bd54acdabc10","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":114},"timestamp":1778174660768,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q2ce7b164c51b3bf0e4dcfd4b9b2228892815b856","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":115},"timestamp":1778174660772,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q0f405f56021d7115617fde39cadcaf6b89866705","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":116},"timestamp":1778174660778,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q9b7187fe54b150ef7e249c75257cd2b252084e85","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":117},"timestamp":1778174660797,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q70dc4b3da3675781ee314b6f44f95187cba7d3c1","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":118},"timestamp":1778174660809,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q60b1a04f2e28aab22c166378e89871125de6d563","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":119},"timestamp":1778174660813,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q7fc3d41514a6629a92e0c89674848e4224fed28d","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":120},"timestamp":1778174660818,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q4d84680f2603292d7f650921e4fdea73b0a6c6ca","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":121},"timestamp":1778174660822,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q3d517c038e36209ee4256306c34690fd53a8306e","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":122},"timestamp":1778174660826,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qaa50b6fadf96a3e12d60cca87f87cb5f7f462c69","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":123},"timestamp":1778174660843,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qc769036b5a38cb57c5ee1bf6f17360ed47f5ea54","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":124},"timestamp":1778174660858,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q84b63c6eefe648254d00a5ec71dc3808339dcce2","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":125},"timestamp":1778174660862,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q7a1ed0017220e8b96e861fa6289129b7e7f0cb33","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":126},"timestamp":1778174660867,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q86e26421f03118913a2f7a9adf630a3b10c879f9","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":127},"timestamp":1778174660873,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q7554af752b6d25d731396bdb31432e41e77ac694","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":128},"timestamp":1778174660877,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q100d03555b2f9ffbc2a9da5bb2e973b75356f9e7","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":129},"timestamp":1778174660883,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q9a42fb36c8479ca5c84e5d7c9e9d5103ae61e8fb","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":130},"timestamp":1778174660888,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q935ab1b2ff13b6a00f83bff9dc92e1b816eb8c98","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":131},"timestamp":1778174660893,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q97c8f2cf1053a36e4966d82265ecea04d1051f1e","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":132},"timestamp":1778174660904,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q5ded6f7f8d7f5d37223f280f2d900da118f7a193","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":133},"timestamp":1778174660908,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q7d5db734e4edef4477ef4cab6b6427fb9f45e4a8","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":134},"timestamp":1778174660911,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q948fd05d2c9ed5d49aa75daace5658c6a64aa74c","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":135},"timestamp":1778174660919,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q10363461fbf6a6be6dd31c1f1e978b0c0a9c08a9","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":136},"timestamp":1778174660923,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Q55b15f2d894f8e3bcd768ed1510da91c2158992f","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":137},"timestamp":1778174660928,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qb87640a3371880e254627383c3b23cf5230527c9","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":138},"timestamp":1778174660936,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"sessions.ts:LoadScoresIntoSessions:beforePush","message":"sessionInfoReturn push","data":{"game":"jubeat","userID":1,"sessionID":"Qf199ca3f596016185da0299200f1c4322656cce1","type":"Appended","repeatSessionInThisLoad":false,"willPush":true,"runId":"post-fix","priorReturnCount":139},"timestamp":1778174660941,"hypothesisId":"H1"} +{"sessionId":"7b27d8","location":"finalize-import-pg.ts:finalizeImportToPostgres:entry","message":"finalizeImportToPostgres called","data":{"importID":"4584b4feb70692dba1707a42c243a56a4b3be56b","runId":"post-fix"},"timestamp":1778174661244,"hypothesisId":"H2"} +{"sessionId":"7b27d8","location":"finalize-import-pg.ts:import_session:preInsert","message":"import_session insert payload stats","data":{"importID":"4584b4feb70692dba1707a42c243a56a4b3be56b","createdLen":140,"uniqueSessionCount":140,"dupSessionIds":[],"runId":"post-fix"},"timestamp":1778174661246,"hypothesisId":"H1"} diff --git a/.cursor/rules/sql-fully-qualified-fields.mdc b/.cursor/rules/sql-fully-qualified-fields.mdc index d36562149..d169600b8 100644 --- a/.cursor/rules/sql-fully-qualified-fields.mdc +++ b/.cursor/rules/sql-fully-qualified-fields.mdc @@ -9,7 +9,7 @@ alwaysApply: false In Kysely `select`, `where`, `orderBy`, `join` conditions, and raw `sql` fragments, **always** qualify column names with the table or join alias. Do not use bare column names when the query references a table (including single-table queries, for consistency and safer refactors). ```typescript -// ❌ BAD — ambiguous once joins or CTEs are added; harder to grep +// ❌ BAD - ambiguous once joins or CTEs are added; harder to grep .select(["data", "chart_id", "user_id"]) .where("id", "=", id) diff --git a/.cursor/skills/actions-and-pg-migration/SKILL.md b/.cursor/skills/actions-and-pg-migration/SKILL.md index e3cc6fdb0..0fb977e9f 100644 --- a/.cursor/skills/actions-and-pg-migration/SKILL.md +++ b/.cursor/skills/actions-and-pg-migration/SKILL.md @@ -3,7 +3,7 @@ name: actions-and-pg-migration description: Patterns for writing actions (MakeAction/MakeAnonAction), using the Postgres DB (Kysely), and migrating Express routes from MongoDB to Postgres in the Tachi server. Use when adding a new mutation, migrating a Mongo-backed router to Postgres, writing action files, or writing tests for actions or routers. --- -# Actions & Postgres Migration — Tachi Server +# Actions & Postgres Migration - Tachi Server ## Actions @@ -31,9 +31,9 @@ import { ExpectedErr } from "bliss"; export const ACTION_MyAction = MakeAction( "MY_ACTION", async (taker, { fieldA, fieldB }) => { - // taker.acct.id — authenticated user's numeric ID - // taker.acct.username — their username - // taker.ip — request IP (for audit log) + // taker.acct.id - authenticated user's numeric ID + // taker.acct.username - their username + // taker.ip - request IP (for audit log) if (somethingWrong) { throw new ExpectedErr(400, "Human-readable reason."); @@ -50,18 +50,18 @@ export const ACTION_MyAction = MakeAction( ### ExpectedErr -`ExpectedErr(code, reason)` is an intentional control-flow error. Throw it for 400/403/404/409 etc. The global Express error handler in `server.ts` (`MAIN_ERR_HANDLER`) catches it and returns `{ success: false, description: reason }` with the right HTTP status. **Never wrap action calls in `try/catch`** — let it propagate. +`ExpectedErr(code, reason)` is an intentional control-flow error. Throw it for 400/403/404/409 etc. The global Express error handler in `server.ts` (`MAIN_ERR_HANDLER`) catches it and returns `{ success: false, description: reason }` with the right HTTP status. **Never wrap action calls in `try/catch`** - let it propagate. ### Calling actions from a router ```typescript -// taker construction — same pattern everywhere: +// taker construction - same pattern everywhere: const user = req.session.tachi?.user; if (!user) return res.status(401).json({ success: false, description: "..." }); const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; -// No try/catch — errors reach MAIN_ERR_HANDLER automatically +// No try/catch - errors reach MAIN_ERR_HANDLER automatically const result = await ACTION_MyAction(taker, { fieldA: body.fieldA }); return res.status(200).json({ success: true, description: "...", body: result }); @@ -123,7 +123,7 @@ await DB.selectFrom("priv_api_client") // Insert await DB.insertInto("priv_api_client").values({ ... }).execute(); -// Update (fetch after separately — don't use .returning() with prefixed column lists) +// Update (fetch after separately - don't use .returning() with prefixed column lists) await DB.updateTable("priv_api_client").set({ name: "New" }).where("client_id", "=", id).execute(); const updated = await GetClientByID(id); // re-fetch via existing query helper @@ -146,12 +146,12 @@ Existing query helpers (e.g. `GetClientByID`) live in `src/utils/queries/`. Use ### Checklist 1. **Identify Mongo collections** → find the equivalent `priv_*` Postgres table. -2. **Reads** — replace `MONGODB_KILL["collection"].find/findOne()` with Kysely selects. Use the existing `SELECT_*` + `To*Document` helpers from `src/lib/db-formats/`. -3. **Mutations** — extract each write operation into a `MakeAction`-wrapped file in `src/actions/`. Add its signature to `ActionSignatures` in `actions.ts`. -4. **Middleware** — update any middleware that does Mongo lookups (e.g. `GetClientFromID`) to use a query helper or direct Kysely query. -5. **Remove Mongo import** — `MONGODB_KILL` should be gone from the file. -6. **Ownership checks** — move them inside the action (`taker.acct.id === row.author`) rather than in Express middleware, so the action is self-contained. -7. **Error handler** — throw `ExpectedErr` instead of returning early; no `try/catch` in routes needed. +2. **Reads** - replace `MONGODB_KILL["collection"].find/findOne()` with Kysely selects. Use the existing `SELECT_*` + `To*Document` helpers from `src/lib/db-formats/`. +3. **Mutations** - extract each write operation into a `MakeAction`-wrapped file in `src/actions/`. Add its signature to `ActionSignatures` in `actions.ts`. +4. **Middleware** - update any middleware that does Mongo lookups (e.g. `GetClientFromID`) to use a query helper or direct Kysely query. +5. **Remove Mongo import** - `MONGODB_KILL` should be gone from the file. +6. **Ownership checks** - move them inside the action (`taker.acct.id === row.author`) rather than in Express middleware, so the action is self-contained. +7. **Error handler** - throw `ExpectedErr` instead of returning early; no `try/catch` in routes needed. ### Permission columns diff --git a/.cursor/skills/coverage-tools/SKILL.md b/.cursor/skills/coverage-tools/SKILL.md index 850741e99..3c4e1dcc2 100644 --- a/.cursor/skills/coverage-tools/SKILL.md +++ b/.cursor/skills/coverage-tools/SKILL.md @@ -36,9 +36,9 @@ Implementation: workspace package `typescript/coverage-tools` (`bun run --filter Edit `typescript/coverage-tools/src/manifest.ts`: add a `CoverageSource` with: -- `id` — short name for `--packages` -- `packageRoot` — e.g. `typescript/my-pkg` -- `coverageFinal` — path to `coverage/coverage-final.json` relative to repo root (Vitest default unless `coverage.reportsDirectory` overrides) +- `id` - short name for `--packages` +- `packageRoot` - e.g. `typescript/my-pkg` +- `coverageFinal` - path to `coverage/coverage-final.json` relative to repo root (Vitest default unless `coverage.reportsDirectory` overrides) ## Programmatic use @@ -50,6 +50,6 @@ import { buildReport, COVERAGE_SOURCES } from "tachi-coverage-tools"; ## Related files -- `typescript/coverage-tools/src/manifest.ts` — source registry -- `typescript/coverage-tools/src/report-coverage.ts` — CLI flags and `buildReport` -- `Justfile-test` — `coverage-report` recipe +- `typescript/coverage-tools/src/manifest.ts` - source registry +- `typescript/coverage-tools/src/report-coverage.ts` - CLI flags and `buildReport` +- `Justfile-test` - `coverage-report` recipe diff --git a/.cursor/skills/db-formats/SKILL.md b/.cursor/skills/db-formats/SKILL.md index d3cf35997..21e90c4ce 100644 --- a/.cursor/skills/db-formats/SKILL.md +++ b/.cursor/skills/db-formats/SKILL.md @@ -23,8 +23,8 @@ description: Guidance on using db-formats/ column lists (SELECT_*) and document Every Postgres table that is returned through the API needs two things in `src/lib/db-formats/.ts`: -1. **`SELECT_*`** — a `const` array of `"table.column"` strings listing exactly the columns needed -2. **`To*Document`** — a mapper function that converts a typed Kysely row to the matching `tachi-common` document type +1. **`SELECT_*`** - a `const` array of `"table.column"` strings listing exactly the columns needed +2. **`To*Document`** - a mapper function that converts a typed Kysely row to the matching `tachi-common` document type ```typescript // src/lib/db-formats/my-table.ts @@ -47,7 +47,7 @@ export function ToMyDocument( } ``` -The `Selection` type ensures the mapper only receives columns that were actually selected — Kysely will error at compile time if the select array and mapper fall out of sync. +The `Selection` type ensures the mapper only receives columns that were actually selected - Kysely will error at compile time if the select array and mapper fall out of sync. --- @@ -116,7 +116,7 @@ const { clientSecret: _secret, ...publicDoc } = ToAPIClientDocument(row); 3. Export `ToDocument` typed with `Selection)[number]>` 4. Return the matching `tachi-common` document type (import from `"tachi-common"`) 5. Apply all naming/shape conventions from the table above -6. Import and use `SELECT_*` + `To*Document` in the router/util — never `.selectAll()` +6. Import and use `SELECT_*` + `To*Document` in the router/util - never `.selectAll()` --- diff --git a/.cursor/skills/exhaustive-switch/SKILL.md b/.cursor/skills/exhaustive-switch/SKILL.md index 3d8bb74cd..425101186 100644 --- a/.cursor/skills/exhaustive-switch/SKILL.md +++ b/.cursor/skills/exhaustive-switch/SKILL.md @@ -38,7 +38,7 @@ default: { } ``` -Reference: `staticAssertUnreachable` in `typescript/server/src/utils/misc.ts` — it takes `never` and throws at runtime if execution reaches it. +Reference: `staticAssertUnreachable` in `typescript/server/src/utils/misc.ts` - it takes `never` and throws at runtime if execution reaches it. ## Why @@ -47,13 +47,13 @@ Reference: `staticAssertUnreachable` in `typescript/server/src/utils/misc.ts` ## Avoid -- A `default` that only `throw new Error("unreachable")` **without** passing a `never`-typed value — you lose exhaustiveness checking. -- `default: break` or empty `default` on switches that are supposed to be exhaustive — same problem. +- A `default` that only `throw new Error("unreachable")` **without** passing a `never`-typed value - you lose exhaustiveness checking. +- `default: break` or empty `default` on switches that are supposed to be exhaustive - same problem. ## When not to use - Switches that are **intentionally partial** (e.g. only handle some cases and fall through to shared logic). Use an explicit `default` that handles “everything else” with correct typing, not `staticAssertUnreachable`. -- If the discriminant is typed too widely (e.g. `string`), `default` will not be `never` and the pattern will not typecheck — narrow the type first or handle the real domain of values. +- If the discriminant is typed too widely (e.g. `string`), `default` will not be `never` and the pattern will not typecheck - narrow the type first or handle the real domain of values. ## Imports diff --git a/.cursor/skills/mongo-migration-constraints/SKILL.md b/.cursor/skills/mongo-migration-constraints/SKILL.md index 5feb98ca2..8a3f62c65 100644 --- a/.cursor/skills/mongo-migration-constraints/SKILL.md +++ b/.cursor/skills/mongo-migration-constraints/SKILL.md @@ -3,7 +3,7 @@ name: mongo-migration-constraints description: Migrates server code from MongoDB to Postgres without modifying the legacy Mongo service. Never edits typescript/server/src/services/mongo/ (especially db.ts). Requires removing MONGODB_KILL imports and replacing usage with Postgres (Kysely) in migrated files. Use during Mongo-to-Postgres migration, when migrating routers/actions/utils off #services/mongo/db, or when the user says not to touch mongo/db.ts. --- -# Mongo → Postgres migration — off-limits Mongo layer +# Mongo → Postgres migration - off-limits Mongo layer ## Hard rules diff --git a/.cursor/skills/no-return-await/SKILL.md b/.cursor/skills/no-return-await/SKILL.md index 70d6bbdd1..1eca7b160 100644 --- a/.cursor/skills/no-return-await/SKILL.md +++ b/.cursor/skills/no-return-await/SKILL.md @@ -9,7 +9,7 @@ description: Prefer `return` over `return await` in async functions when the awa In an `async function`, **`return await x` and `return x` are equivalent** for callers when `x` is a Promise (or thenable): both return a Promise with the same fulfillment and rejection. -Prefer **`return x`** — the extra `await` adds a microtask and obscures that you are simply forwarding the result. +Prefer **`return x`** - the extra `await` adds a microtask and obscures that you are simply forwarding the result. ```typescript // Prefer @@ -17,7 +17,7 @@ async function load() { return fetchData(); } -// Avoid (unless you need await for control flow — see below) +// Avoid (unless you need await for control flow - see below) async function load() { return await fetchData(); } @@ -40,7 +40,7 @@ async function safeLoad() { } ``` -- **`finally`** that must run after the inner work settles (same idea — often needs `await` in the `try`). +- **`finally`** that must run after the inner work settles (same idea - often needs `await` in the `try`). If there is **no** `try`/`catch`/`finally` depending on that promise settling inside the function, **do not** use `return await`. diff --git a/.cursor/skills/preserve-comments/SKILL.md b/.cursor/skills/preserve-comments/SKILL.md index 908b11a7f..5f77dc2a9 100644 --- a/.cursor/skills/preserve-comments/SKILL.md +++ b/.cursor/skills/preserve-comments/SKILL.md @@ -9,15 +9,15 @@ description: Preserves existing code comments (line, block, JSDoc, region marker When changing code, **keep existing comments** in place unless one of the exceptions below applies. -- **Line comments** (`//`), **block comments** (`/* */`), **JSDoc/TSDoc** (`/** */`), and **region/pragma-style markers** the file already uses — preserve them; do not strip them to “clean up” or shorten the diff. +- **Line comments** (`//`), **block comments** (`/* */`), **JSDoc/TSDoc** (`/** */`), and **region/pragma-style markers** the file already uses - preserve them; do not strip them to “clean up” or shorten the diff. - If a comment sits next to changed logic, **update the comment only when the behavior it describes changed**. Otherwise leave it untouched. - **Do not** replace a nuanced comment with nothing, or with a shorter generic note, unless the user asked for that. ## When removal or heavy edits are OK - The user **explicitly** asked to remove comments, delete dead documentation, or “dedupe” comments. -- The comment is **factually wrong** after the edit and would mislead readers — then fix or remove that comment and prefer a short accurate replacement over silence. -- The **entire block of code** the comment referred to is deleted — remove or relocate the comment so it does not orphan misleading text (or move it if it still applies elsewhere). +- The comment is **factually wrong** after the edit and would mislead readers - then fix or remove that comment and prefer a short accurate replacement over silence. +- The **entire block of code** the comment referred to is deleted - remove or relocate the comment so it does not orphan misleading text (or move it if it still applies elsewhere). ## What this is not diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6bc17f1a7..55c8bb9ff 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -6,7 +6,6 @@ "containerEnv": { "MINIO_ENDPOINT": "http://tachi-s3:9000", "TACHI_EMAIL_FROM": "dev@localhost", - "TACHI_EMAIL_AUTH_POSTMARK": "false", "TACHI_EMAIL_HOST": "tachi-mailpit", "TACHI_EMAIL_PORT": "1025", "TACHI_EMAIL_SECURE": "false" diff --git a/.dockerignore b/.dockerignore index 64e8ee5d4..19e94187e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,7 @@ .git .bun -# Env files — keep secrets out of the build context; runtime env comes from orchestrator / secrets. +# Env files - keep secrets out of the build context; runtime env comes from orchestrator / secrets. .env .env.* **/.env diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index 97c8a763a..44f34c995 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -69,7 +69,7 @@ jobs: VITE_RECAPTCHA_KEY: "6LcsYbIpAAAAAEJffjIXmbQcxj_SBZG7BnSPjF4L" VITE_SERVER_URL: "https://v3-boku.tachi.ac" VITE_DISCORD: "https://discord.gg/E4n8ZZF5ES" - VITE_CDN_URL: "https://cdn-boku.tachi.ac" + VITE_CDN_URL: "https://cdn-v3-boku.tachi.ac" TACHI_NAME: "Bokutachi" BUILD_OUT_DIR: /tmp/boku @@ -83,7 +83,7 @@ jobs: VITE_MIN_CLIENT_ID: "A0DBDBB063CD800530EF01C6488B282137E0191E" VITE_DISCORD: "https://discord.gg/NNgGJbpQUj" VITE_TCHIC_MODE: "kamai" - VITE_CDN_URL: "https://cdn-kamai.tachi.ac" + VITE_CDN_URL: "https://cdn-v3-kamai.tachi.ac" VITE_GIT_REPO: "GitHub:zkldi/Tachi" VITE_RECAPTCHA_KEY: "6LcsYbIpAAAAAEJffjIXmbQcxj_SBZG7BnSPjF4L" TACHI_NAME: "Kamaitachi" @@ -95,7 +95,7 @@ jobs: VITE_MANDATE_LOGIN: true VITE_SERVER_URL: "https://dev.tachi.ac" VITE_TCHIC_MODE: "omni" - VITE_CDN_URL: "https://cdn-staging.tachi.ac" + VITE_CDN_URL: "https://cdn-dev.tachi.ac" VITE_GIT_REPO: "GitHub:zkldi/Tachi" VITE_RECAPTCHA_KEY: "6LcsYbIpAAAAAEJffjIXmbQcxj_SBZG7BnSPjF4L" TACHI_NAME: "Tachi Dev" diff --git a/.github/workflows/database-seeds.yml b/.github/workflows/database-seeds.yml index f38d8e612..0fd41e418 100644 --- a/.github/workflows/database-seeds.yml +++ b/.github/workflows/database-seeds.yml @@ -60,7 +60,7 @@ jobs: chmod 600 ~/.ssh/id_rsa printf '%s' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts - - name: Deploy updates + - name: Deploy seeds env: TACHI_HOST: ${{ secrets.TACHI_HOST }} - run: ssh "ci@${TACHI_HOST}" /home/ci/tachi-devops/scripts/deploy_seeds.sh + run: ssh -i ~/.ssh/id_rsa "ci@${TACHI_HOST}" /home/ci/tachi-deploy/scripts/deploy-seeds.sh "${{ github.sha }}" diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index c4bb00367..865c1fd7c 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -121,12 +121,17 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1 + - name: Read server package version + id: server_version + run: | + echo "version=$(jq -r .version typescript/server/package.json)" >> "$GITHUB_OUTPUT" + - name: Build and push unified server image uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0 with: context: . file: docker/Dockerfile.server - target: prod-unified + target: prod push: ${{ github.ref == 'refs/heads/main' }} provenance: true sbom: true @@ -135,6 +140,7 @@ jobs: ghcr.io/zkldi/tachi:main ghcr.io/zkldi/tachi:latest build-args: | + VERSION=${{ steps.server_version.outputs.version }} COMMIT_HASH=${{ github.sha }} cache-from: type=gha,scope=server-${{ github.ref_name }} cache-to: type=gha,mode=max,scope=server-${{ github.ref_name }} diff --git a/Justfile-backup b/Justfile-backup index 33f0aaa13..ab1209299 100644 --- a/Justfile-backup +++ b/Justfile-backup @@ -1,4 +1,4 @@ -# Postgres backup/restore via Docker (host only). Requires `docker` on PATH — not available +# Postgres backup/restore via Docker (host only). Requires `docker` on PATH - not available # inside the devcontainer, so these recipes fail fast there. POSTGRES_CONTAINER := "tachi-postgres" diff --git a/Justfile-repo b/Justfile-repo index c276cf03d..227093f36 100644 --- a/Justfile-repo +++ b/Justfile-repo @@ -13,8 +13,10 @@ check-all-docker-builds: assert-docker-host set -euo pipefail root="$(git rev-parse --show-toplevel)" sha="$(git -C "$root" rev-parse --short HEAD)" - echo "==> server (prod-unified)" - docker build -f "$root/docker/Dockerfile.server" --target prod-unified \ + server_ver="$(jq -r .version "$root/typescript/server/package.json")" + echo "==> server (prod)" + docker build -f "$root/docker/Dockerfile.server" --target prod \ + --build-arg "VERSION=$server_ver" \ --build-arg "COMMIT_HASH=$sha" -t tachi-server:check "$root" echo "==> bot" docker build -f "$root/docker/Dockerfile.bot" -t tachi-bot:check "$root" diff --git a/db/migrations/20260301154256_genesis.sql b/db/migrations/20260301154256_genesis.sql index 86ebe2f8d..7915abb63 100644 --- a/db/migrations/20260301154256_genesis.sql +++ b/db/migrations/20260301154256_genesis.sql @@ -128,6 +128,9 @@ CREATE TABLE "job_queue" ( CREATE INDEX job_queue_partial_index_btree ON job_queue (created_at, status) WHERE status = 0; +CREATE INDEX job_queue_dequeue_idx +ON job_queue (scheduled_for ASC, created_at ASC) +WHERE status = 0; CREATE TABLE "cron_task" ( id TEXT PRIMARY KEY, @@ -706,7 +709,6 @@ CREATE TABLE "import_timing" ( CREATE TABLE "score" ( id TEXT PRIMARY KEY NOT NULL, - -- TODO(zk): Redundant? joins on session(id). user_id BIGINT REFERENCES account(id) NOT NULL, chart_id TEXT REFERENCES chart(id) NOT NULL, @@ -915,6 +917,7 @@ CREATE TABLE "pb" ( CREATE TABLE "pb_composed_from" ( pb_id UUID REFERENCES pb(row_id) NOT NULL, score_id TEXT REFERENCES score(id) NOT NULL, + merge_name TEXT NOT NULL, PRIMARY KEY (pb_id, score_id) ); @@ -1047,7 +1050,7 @@ CREATE TABLE "quest" ( name TEXT NOT NULL, description TEXT NOT NULL, - -- Array — sections with titles, descs, and goal references. + -- Array - sections with titles, descs, and goal references. -- Too structured/variable to flatten usefully. quest_data JSONB NOT NULL ); @@ -1131,7 +1134,7 @@ CREATE INDEX score_user_recent_idx ON score (user_id, time_added DESC); CREATE INDEX score_user_highlights_idx ON score (user_id, time_added DESC) WHERE highlight = true; --- "All scores on chart X" — chart pages, playcount, import duplicate check. +-- "All scores on chart X" - chart pages, playcount, import duplicate check. -- Needed separately because score_user_chart_idx has user_id as the left column. CREATE INDEX score_chart_idx ON score (chart_id); @@ -1142,7 +1145,7 @@ CREATE INDEX score_import_uncommitted_idx ON score (import_id) CREATE UNIQUE INDEX orphan_score_orphan_id_key ON orphan_score (orphan_id); --- pb (5M rows) — hottest table +-- pb (5M rows) - hottest table -- The single most important index in the schema. -- Serves: chart_leaderboard view, leaderboard pagination, and the COUNT rank -- queries ("how many PBs beat mine?"). chart_id partitions the 5M rows into @@ -1215,8 +1218,8 @@ CREATE INDEX folder_view_user_idx ON folder_view (user_id, last_viewed DESC); -- so game-first lookups need their own index. CREATE INDEX game_stats_snapshot_game_idx ON game_stats_snapshot (game, timestamp DESC); --- chart — general --- "All charts for song X" — song pages, chart listing. Postgres does NOT +-- chart - general +-- "All charts for song X" - song pages, chart listing. Postgres does NOT -- auto-index FK columns, so this needs to be explicit. CREATE INDEX chart_song_idx ON chart (song_id); @@ -1232,7 +1235,7 @@ CREATE UNIQUE INDEX chart_primary_song_difficulty_idx ON chart (game, song_id, difficulty) WHERE is_primary = true; --- chart — game-specific JSONB expression indexes +-- chart - game-specific JSONB expression indexes -- Partial expression indexes mirroring the per-game Mongo collections. -- Used during score import to look up charts by their game-native ID or hash. @@ -1303,4 +1306,20 @@ CREATE TABLE "priv_discord_user_map" ( ); CREATE UNIQUE INDEX priv_discord_user_map_user_id_idx ON priv_discord_user_map (user_id); --- <== End bot \ No newline at end of file +-- <== End bot + +-- Optional read-only role used by Grafana (may not exist in local/dev). +DO $grant$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN + EXECUTE 'GRANT USAGE ON SCHEMA public TO grafana_ro'; + EXECUTE 'GRANT SELECT ON ALL TABLES IN SCHEMA public TO grafana_ro'; + EXECUTE 'GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO grafana_ro'; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') + AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'tachi') THEN + EXECUTE 'ALTER DEFAULT PRIVILEGES FOR ROLE tachi IN SCHEMA public GRANT SELECT ON TABLES TO grafana_ro'; + EXECUTE 'ALTER DEFAULT PRIVILEGES FOR ROLE tachi IN SCHEMA public GRANT SELECT ON SEQUENCES TO grafana_ro'; + END IF; +END +$grant$; \ No newline at end of file diff --git a/db/migrations/20260422130000_grant_grafana_ro_public_read.sql b/db/migrations/20260422130000_grant_grafana_ro_public_read.sql deleted file mode 100644 index 5bbe5d80b..000000000 --- a/db/migrations/20260422130000_grant_grafana_ro_public_read.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Grafana local-dev role (see dev/postgres-init.sql): init runs before migrations, so --- GRANT SELECT ON ALL TABLES saw no tables; default privileges can also miss edge cases. --- Re-apply read access to all existing objects; no-op when grafana_ro is absent. -DO $grant$ -BEGIN - IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN - EXECUTE 'GRANT USAGE ON SCHEMA public TO grafana_ro'; - EXECUTE 'GRANT SELECT ON ALL TABLES IN SCHEMA public TO grafana_ro'; - EXECUTE 'GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO grafana_ro'; - END IF; - IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') - AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'tachi') THEN - EXECUTE 'ALTER DEFAULT PRIVILEGES FOR ROLE tachi IN SCHEMA public GRANT SELECT ON TABLES TO grafana_ro'; - EXECUTE 'ALTER DEFAULT PRIVILEGES FOR ROLE tachi IN SCHEMA public GRANT SELECT ON SEQUENCES TO grafana_ro'; - END IF; -END -$grant$; diff --git a/db/migrations/20260423120000_job_queue_dequeue_index.sql b/db/migrations/20260423120000_job_queue_dequeue_index.sql deleted file mode 100644 index 948087d70..000000000 --- a/db/migrations/20260423120000_job_queue_dequeue_index.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Fast dequeue of queued (status = 0) jobs ordered by when they are due. -CREATE INDEX job_queue_dequeue_idx -ON job_queue (scheduled_for ASC, created_at ASC) -WHERE status = 0; diff --git a/dev/bootstrap.sh b/dev/bootstrap.sh index 0f3ca237b..b49fb7087 100755 --- a/dev/bootstrap.sh +++ b/dev/bootstrap.sh @@ -39,7 +39,7 @@ function seedMinioCdn { # Debian package `minio-client` installs the `minio-client` binary (same CLI as upstream `mc`). # override endpoint with MINIO_ENDPOINT (devcontainer sets http://tachi-s3:9000). if ! command -v minio-client &> /dev/null; then - echo "Couldn't find MinIO client (minio-client). On Debian: apt install minio-client — see Dockerfile.dev." + echo "Couldn't find MinIO client (minio-client). On Debian: apt install minio-client - see Dockerfile.dev." exit 1 fi diff --git a/dev/fish-plugins/fisher/README.md b/dev/fish-plugins/fisher/README.md index 0f6116e89..72fc45691 100644 --- a/dev/fish-plugins/fisher/README.md +++ b/dev/fish-plugins/fisher/README.md @@ -1,12 +1,12 @@ # Fisher -> A plugin manager for [Fish](https://fishshell.com)—your friendly interactive shell. [Snag fresh plugins!](https://github.com/jorgebucaran/awsm.fish#readme) +> A plugin manager for [Fish](https://fishshell.com)-your friendly interactive shell. [Snag fresh plugins!](https://github.com/jorgebucaran/awsm.fish#readme) Take control of functions, completions, bindings, and snippets from the command line. Unleash your shell's true potential, perfect your prompt, and craft repeatable configurations across different systems effortlessly. Fisher's zero impact on shell startup keeps your shell zippy and responsive. No gimmicks, just smooth sailing! - Fisher is 100% pure-Fish, making it easy to contribute or modify - Scorching fast concurrent plugin downloads that'll make you question reality -- Zero configuration needed—we're not kidding! +- Zero configuration needed-we're not kidding! - Oh My Fish! plugins supported too > #### ☝️ [Upgrading from Fisher `3.x` or older? Strap in and read this!](https://github.com/jorgebucaran/fisher/issues/652) @@ -29,7 +29,7 @@ To install plugins, use the `install` command and point it to the GitHub reposit fisher install jorgebucaran/nvm.fish ``` -> Wanna install from GitLab? No problemo—just prepend `gitlab.com/` to the plugin path. +> Wanna install from GitLab? No problemo-just prepend `gitlab.com/` to the plugin path. You can also snag a specific version of a plugin by adding an `@` symbol after the plugin name, followed by a tag, branch, or [commit](https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-aiddefcommit-ishacommit-ishalsocommittish). diff --git a/docker/Dockerfile.server b/docker/Dockerfile.server index 2d56f4828..a996ddd6a 100644 --- a/docker/Dockerfile.server +++ b/docker/Dockerfile.server @@ -1,74 +1,39 @@ # Build context must be the repo root (same as GHA docker-push: context `.`). # Respects .dockerignore (see repo root). Full tree copy keeps workspace installs correct. -# docker build -f docker/Dockerfile.server --target prod-unified --build-arg COMMIT_HASH="$(git rev-parse --short HEAD)" -t tachi-server:local . -# docker build -f docker/Dockerfile.server --target prod-api . -# docker build -f docker/Dockerfile.server --target prod-job-worker . -# docker build -f docker/Dockerfile.server --target prod-cron-worker . +# docker build -f docker/Dockerfile.server \ +# --build-arg VERSION="$(jq -r .version typescript/server/package.json)" \ +# --build-arg COMMIT_HASH="$(git rev-parse --short HEAD)" -t tachi-server:local . +# +# Default CMD runs the API server. Override to run a worker: +# docker run tachi-server:local bun run ./typescript/server/src/job-queue-worker.ts +# docker run tachi-server:local bun run ./typescript/server/src/cron-worker.ts +# +# Worker containers use a file-based heartbeat (/tmp/worker-heartbeat) rather than HTTP, +# so their HEALTHCHECK must be configured at the deployment layer (compose/k8s), not here. # # NOTE: bun build --compile is NOT used here because it's # FUCKING BROKEN -FROM oven/bun:alpine AS base +FROM oven/bun:alpine AS deps WORKDIR /app - -# --- install: full tree so bun sees every workspace package --- -FROM base AS deps - COPY . . RUN bun install --frozen-lockfile -# --- API image --- -FROM oven/bun:alpine AS prod-api - -ARG COMMIT_HASH -ENV COMMIT_HASH=${COMMIT_HASH} -ENV MIGRATIONS_DIR=/app/db/migrations -ENV PORT=8080 - -RUN apk add --no-cache curl - -WORKDIR /app -COPY --from=deps /app . - -EXPOSE 8080 - -HEALTHCHECK --interval=15s --timeout=5s --start-period=30s \ - CMD curl -sf http://localhost:8080/.deploy/up || exit 1 - -CMD ["bun", "run", "./typescript/server/src/main.ts"] - -# --- Job queue worker (no HTTP port) --- -FROM oven/bun:alpine AS prod-job-worker - -ARG COMMIT_HASH -ENV COMMIT_HASH=${COMMIT_HASH} -ENV MIGRATIONS_DIR=/app/db/migrations - -WORKDIR /app -COPY --from=deps /app . - -CMD ["bun", "run", "./typescript/server/src/job-queue-worker.ts"] - -# --- Cron worker (no HTTP port) --- -FROM oven/bun:alpine AS prod-cron-worker - -ARG COMMIT_HASH -ENV COMMIT_HASH=${COMMIT_HASH} -ENV MIGRATIONS_DIR=/app/db/migrations - -WORKDIR /app -COPY --from=deps /app . - -CMD ["bun", "run", "./typescript/server/src/cron-worker.ts"] - -# --- All-in-one image. -FROM oven/bun:alpine AS prod-unified +FROM oven/bun:alpine AS prod +ARG VERSION ARG COMMIT_HASH +# Fail the build if either arg is omitted (Docker has no required-ARG syntax). +RUN set -eu; \ + : "${VERSION:?Dockerfile.server: VERSION build-arg is required}"; \ + : "${COMMIT_HASH:?Dockerfile.server: COMMIT_HASH build-arg is required}"; + +ENV VERSION=${VERSION} ENV COMMIT_HASH=${COMMIT_HASH} ENV MIGRATIONS_DIR=/app/db/migrations ENV PORT=8080 +# curl is only needed by the API healthcheck below; workers don't use it. RUN apk add --no-cache curl WORKDIR /app @@ -76,6 +41,7 @@ COPY --from=deps /app . EXPOSE 8080 +# Matches the default CMD (API server). Worker deployments must override this. HEALTHCHECK --interval=15s --timeout=5s --start-period=30s \ CMD curl -sf http://localhost:8080/.deploy/up || exit 1 diff --git a/docs/docs/api/routes/admin.md b/docs/docs/api/routes/admin.md index 6e763994c..e96900f21 100644 --- a/docs/docs/api/routes/admin.md +++ b/docs/docs/api/routes/admin.md @@ -69,7 +69,7 @@ Empty Object. `POST /api/v1/admin/recalc-pbs` -Inserts every distinct `(user_id, chart_id)` from the **`score`** table into **`pb_dirty`**, then **drains** `pb_dirty` and downstream **`session_dirty`** / **`game_profile_dirty`** queues until nothing remains (same batching as the background worker, but the HTTP request waits until idle). Intended when PBs may be out of sync (e.g. after a bad migration). There is **no request body** and no filter—always all distinct pairs that appear on scores. +Inserts every distinct `(user_id, chart_id)` from the **`score`** table into **`pb_dirty`**, then **drains** `pb_dirty` and downstream **`session_dirty`** / **`game_profile_dirty`** queues until nothing remains (same batching as the background worker, but the HTTP request waits until idle). Intended when PBs may be out of sync (e.g. after a bad migration). There is **no request body** and no filter-always all distinct pairs that appear on scores. ### Permissions @@ -191,7 +191,7 @@ Empty Object. `POST /api/v1/admin/recalc` -Enqueues **every chart** into **`score_rederive`**, then **drains** `score_rederive` and downstream **`pb_dirty`**, **`session_dirty`**, and **`game_profile_dirty`** queues until nothing remains (the request waits until idle). Re-runs `scoreDeriver` and `scoreCalcs` for every score. There is **no request body** and no filter—always all charts. +Enqueues **every chart** into **`score_rederive`**, then **drains** `score_rederive` and downstream **`pb_dirty`**, **`session_dirty`**, and **`game_profile_dirty`** queues until nothing remains (the request waits until idle). Re-runs `scoreDeriver` and `scoreCalcs` for every score. There is **no request body** and no filter-always all charts. ### Permissions diff --git a/docs/docs/codebase/setup/config.md b/docs/docs/codebase/setup/config.md index 603816e91..1451426c1 100644 --- a/docs/docs/codebase/setup/config.md +++ b/docs/docs/codebase/setup/config.md @@ -236,13 +236,13 @@ provide callback URLs inside emails. SMTP is always configured. Set: -- `TACHI_EMAIL_FROM` — `From` header (must match a verified sender when using Postmark). -- `TACHI_EMAIL_AUTH_POSTMARK` — `true` or `false`. When `true`, uses Postmark’s SMTP - endpoint; set `TACHI_EMAIL_AUTH_USER` and `TACHI_EMAIL_AUTH_PASS` to your server API token - (both are the token for Postmark SMTP). -- When Postmark is `false`: `TACHI_EMAIL_HOST`, `TACHI_EMAIL_PORT`, `TACHI_EMAIL_SECURE` - (`true` / `false`), and optionally `TACHI_EMAIL_AUTH_USER` / `TACHI_EMAIL_AUTH_PASS` - for servers that require auth (local Mailpit typically needs no auth). +- `TACHI_EMAIL_FROM` - `From` header (must match a verified sender when using Postmark). +- `TACHI_EMAIL_HOST`, `TACHI_EMAIL_PORT`, `TACHI_EMAIL_SECURE` (`true` / `false`). +- Optionally `TACHI_EMAIL_AUTH_USER` / `TACHI_EMAIL_AUTH_PASS` for SMTP auth (local Mailpit + typically needs none). +- For Postmark, use host `smtp.postmarkapp.com` (usually port `587` with `TACHI_EMAIL_SECURE=false`) + and set either auth field to your server API token (both username and password are the token for + Postmark SMTP). `TRANSPORT_OPS` is derived from these variables and passed to Nodemailer. @@ -272,6 +272,18 @@ interface INVITE_CODE_CONFIG: { }; ``` +### TACHI_INVITE_ADMIN_INITIAL_INVITE_CODE + +- Type: String (Optional, environment variable only) + +A one-time bootstrap invite code for first-time instance setup. When `INVITE_CODE_CONFIG` is +configured, new instances have a chicken-and-egg problem: registration requires an invite code, +but invite codes can only be created by an existing user. + +Set this environment variable to a long, random secret. The first person to register with this +code - while the `account` table is still empty - becomes the site admin. Once the first admin +exists, the code is no longer accepted. + ### TACHI_CONFIG - Type: TACHI_CONFIG diff --git a/observability/grafana/dashboards/tachi-api.json b/observability/grafana/dashboards/tachi-api.json index bbe592dae..1d2a660ef 100644 --- a/observability/grafana/dashboards/tachi-api.json +++ b/observability/grafana/dashboards/tachi-api.json @@ -12,7 +12,7 @@ } ] }, - "description": "Tachi API — endpoint latencies, score import durations, error rates & system health", + "description": "Tachi API - endpoint latencies, score import durations, error rates & system health", "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 1, diff --git a/tsconfig.base.json b/tsconfig.base.json index 42d13640a..afd515981 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -7,7 +7,7 @@ "resolveJsonModule": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": false, - // all packages run via bun directly from source — no emit needed + // all packages run via bun directly from source - no emit needed "noEmit": true, "types": [] } diff --git a/typescript/bot/vitest.config.ts b/typescript/bot/vitest.config.ts index 81b0ad26d..741571d55 100644 --- a/typescript/bot/vitest.config.ts +++ b/typescript/bot/vitest.config.ts @@ -32,7 +32,7 @@ export default defineConfig({ DISCORD_SERVER_ID: "test-server-id", }, - // Parallel test execution — each worker gets its own isolated Postgres database. + // Parallel test execution - each worker gets its own isolated Postgres database. fileParallelism: true, globalSetup: "./vitest.globalSetup.ts", setupFiles: "./vitest.setup.ts", diff --git a/typescript/bot/vitest.globalSetup.ts b/typescript/bot/vitest.globalSetup.ts index 154d354fa..a842353d3 100644 --- a/typescript/bot/vitest.globalSetup.ts +++ b/typescript/bot/vitest.globalSetup.ts @@ -1,7 +1,7 @@ import { execSync } from "node:child_process"; /** - * Global vitest setup — runs ONCE before any workers start. + * Global vitest setup - runs ONCE before any workers start. * * Creates a fully-migrated template database. Workers clone from it * instead of running migrations themselves, which is much faster. diff --git a/typescript/bot/vitest.setup.ts b/typescript/bot/vitest.setup.ts index 5ed7f1142..0af114447 100644 --- a/typescript/bot/vitest.setup.ts +++ b/typescript/bot/vitest.setup.ts @@ -20,7 +20,7 @@ const POSTGRES_HOST = "tachi-postgres"; const POSTGRES_USER = "tachi"; const POSTGRES_PASS = "tachi"; -// Set POSTGRES_URL before any app code is imported — config.ts reads it at load time. +// Set POSTGRES_URL before any app code is imported - config.ts reads it at load time. process.env.POSTGRES_URL = `postgresql://${POSTGRES_USER}:${POSTGRES_PASS}@${POSTGRES_HOST}/${WORKER_DB_NAME}`; // Mock global fetch before any app code is imported. @@ -45,7 +45,7 @@ globalThis.fetch = (url: string | Request | URL): Promise => { } as unknown as Response); } - // Blow up loudly if any unexpected fetch slips through — this keeps tests hermetic. + // Blow up loudly if any unexpected fetch slips through - this keeps tests hermetic. throw new Error(`Unexpected fetch() in test: ${url.toString()}`); }; diff --git a/typescript/client/src/app/pages/admin/AdminActionsPage.tsx b/typescript/client/src/app/pages/admin/AdminActionsPage.tsx index 101da36ef..00bffb274 100644 --- a/typescript/client/src/app/pages/admin/AdminActionsPage.tsx +++ b/typescript/client/src/app/pages/admin/AdminActionsPage.tsx @@ -33,7 +33,7 @@ type ActionsResponse = { function durationMs(start: string, end: string): string { const ms = Date.parse(end) - Date.parse(start); if (Number.isNaN(ms)) { - return "—"; + return "-"; } if (ms < 1000) { return `${ms}ms`; @@ -196,11 +196,11 @@ export default function AdminActionsPage() { {action.username} ) : ( - + - )} diff --git a/typescript/client/src/app/pages/admin/AdminOperationsPage.tsx b/typescript/client/src/app/pages/admin/AdminOperationsPage.tsx index f38f99db9..fefdced1c 100644 --- a/typescript/client/src/app/pages/admin/AdminOperationsPage.tsx +++ b/typescript/client/src/app/pages/admin/AdminOperationsPage.tsx @@ -47,7 +47,7 @@ export default function AdminOperationsPage() { }} value={announcementGame === "" ? "" : announcementGame} > - + {TachiConfig.GAME_GROUPS.map((g) => ( + {announcementGameConfig.playtypes.map((pt) => ( - + @@ -230,7 +230,7 @@ export default function UserOrphansPage({ reqUser }: { reqUser: UserDocument }) o.gameGroup} - + + +
); } + +function SessionNavRow({ + adjacent, + currentName, + game, + username, +}: { + adjacent: SessionAdjacentReturns | null; + currentName: string; + game: string; + username: string; +}) { + const sessionUrl = (s: SessionDocument) => + `/u/${username}/games/${s.game}/sessions/${s.sessionID}`; + + return ( +
+
+ {adjacent?.prev ? ( + + « Prev + + ) : ( + + )} +
+ {currentName} +
+ {adjacent?.next ? ( + + Next » + + ) : ( + + )} +
+
+ ); +} diff --git a/typescript/client/src/components/activity/Activity.tsx b/typescript/client/src/components/activity/Activity.tsx index 842c8d514..6d0512cdb 100644 --- a/typescript/client/src/components/activity/Activity.tsx +++ b/typescript/client/src/components/activity/Activity.tsx @@ -44,6 +44,10 @@ import { type UserDocument, } from "tachi-common"; +function isActivityInteractiveTarget(el: HTMLElement) { + return Boolean(el.closest("a, button, input, textarea, select, [role='button']")); +} + // Records activity for a group of users on a GPT. Also used for single users. export default function Activity({ url, @@ -291,7 +295,16 @@ function ScoresActivity({
-
setShow(!show)}> +
{ + if (isActivityInteractiveTarget(e.target as HTMLElement)) { + return; + } + + setShow(!show); + }} + >
@@ -323,10 +336,10 @@ function ScoresActivity({
{show && ( - <> +
- +
)}
@@ -367,7 +380,16 @@ function GoalActivity({
-
setShow(!show)}> +
{ + if (isActivityInteractiveTarget(e.target as HTMLElement)) { + return; + } + + setShow(!show); + }} + >
@@ -399,7 +421,7 @@ function GoalActivity({
{show && ( - <> +
{data.goals.map((e) => ( @@ -410,7 +432,7 @@ function GoalActivity({ /> ))}
- +
)}
@@ -485,7 +507,16 @@ function SessionActivity({
-
setShow(!show)}> +
{ + if (isActivityInteractiveTarget(e.target as HTMLElement)) { + return; + } + + setShow(!show); + }} + >
@@ -529,7 +560,11 @@ function SessionActivity({
- {show && } + {show && ( +
+ +
+ )}
); diff --git a/typescript/client/src/components/imports/ImportInfo.tsx b/typescript/client/src/components/imports/ImportInfo.tsx index 77b23ffcc..1942e1eeb 100644 --- a/typescript/client/src/components/imports/ImportInfo.tsx +++ b/typescript/client/src/components/imports/ImportInfo.tsx @@ -131,7 +131,7 @@ export default function ImportInfo({
SongOrChartNotFound means the score was still{" "} saved as an orphan for nightly matching (around 00:01 - UTC) or manual reprocess — see{" "} + UTC) or manual reprocess - see{" "} {user ? ( Orphan scores ) : ( diff --git a/typescript/client/src/lib/config.ts b/typescript/client/src/lib/config.ts index 01508da59..f9606a8a2 100644 --- a/typescript/client/src/lib/config.ts +++ b/typescript/client/src/lib/config.ts @@ -22,10 +22,10 @@ try { // Do NOT use document.open() / document.write() / document.close() here. // document.open() fires unload/beforeunload/pagehide events, which Vite's HMR // client intercepts to close its WebSocket. The HMR reconnection logic then - // triggers a full page reload, re-running this module, failing again — an + // triggers a full page reload, re-running this module, failing again - an // infinite refresh loop. // - // Do NOT throw either — an uncaught module-evaluation error is another signal + // Do NOT throw either - an uncaught module-evaluation error is another signal // that Vite HMR uses to trigger a reload. // // Instead, use direct DOM mutation (no unload events) and console.error. diff --git a/typescript/client/src/lib/games/iidx.tsx b/typescript/client/src/lib/games/iidx.tsx index a18d3f657..fef2c28e6 100644 --- a/typescript/client/src/lib/games/iidx.tsx +++ b/typescript/client/src/lib/games/iidx.tsx @@ -120,7 +120,6 @@ const IIDX_PROFILE_RATING_VALUE_CELL_STYLE: NonNullable< GPTClientImplementation["profileRatingAlgRowStyle"] > = { ktLampRating: { backgroundColor: ChangeOpacity(COLOUR_SET.purple, 0.12) }, - ktLampRatingNC: { backgroundColor: ChangeOpacity(COLOUR_SET.blue, 0.14) }, ktLampRatingHC: { backgroundColor: ChangeOpacity(COLOUR_SET.orange, 0.14) }, ktLampRatingEXHC: { backgroundColor: ChangeOpacity(COLOUR_SET.gold, 0.12) }, BPI: { backgroundColor: ChangeOpacity(COLOUR_SET.paleBlue, 0.12) }, diff --git a/typescript/client/src/styles/components/timeline.scss b/typescript/client/src/styles/components/timeline.scss index 114f2feb5..615503f2c 100644 --- a/typescript/client/src/styles/components/timeline.scss +++ b/typescript/client/src/styles/components/timeline.scss @@ -93,3 +93,23 @@ } } } + +// Activity feed (Activity.tsx): row hover covers full `.timeline-item`, but clickable +// region was only `.timeline-content-inner` inset inside padded `.timeline-content`. +// Move padding onto the toggle row so clicks match the highlight. +.timeline.activity-timeline { + .timeline-item .timeline-content:has(> .activity-entry-toggle:first-child) { + padding: 0; + } + + .timeline-item .activity-entry-toggle { + box-sizing: border-box; + cursor: pointer; + padding: 1rem; + width: 100%; + } + + .timeline-item .activity-expand-body { + padding: 0 1rem 1rem; + } +} diff --git a/typescript/client/src/types/api-returns.ts b/typescript/client/src/types/api-returns.ts index 43f99d817..118759772 100644 --- a/typescript/client/src/types/api-returns.ts +++ b/typescript/client/src/types/api-returns.ts @@ -97,6 +97,11 @@ export interface SessionReturns { user: UserDocument; } +export interface SessionAdjacentReturns { + prev: SessionDocument | null; + next: SessionDocument | null; +} + export interface UGPTChartPBComposition { scores: ScoreDocument[]; chart: ChartDocument; diff --git a/typescript/common/.taprc b/typescript/common/.taprc deleted file mode 100644 index f95561c3e..000000000 --- a/typescript/common/.taprc +++ /dev/null @@ -1,13 +0,0 @@ -files: "src/**/*.test.ts" -test-env: NODE_ENV=test -timeout: 120 -ts: true - -# all this is broken now for some reason. who knows. -check-coverage: false - -# branches: 0 # 80 -# functions: 0 # 80 -# lines: 0 # 80 -# statements 0: # 80 -reporter: terse diff --git a/typescript/common/src/config/game-support/iidx.ts b/typescript/common/src/config/game-support/iidx.ts index 6d3dd91a7..ef72c776c 100644 --- a/typescript/common/src/config/game-support/iidx.ts +++ b/typescript/common/src/config/game-support/iidx.ts @@ -172,10 +172,6 @@ export const GAME_IIDX_SP_CONF = { description: "A rating system that values your clear lamps on charts. Tierlist information is taken into account.", }, - ktLampRatingNC: { - description: - "Your normal clear performance. Tierlist information is taken into account.", - }, ktLampRatingHC: { description: "Your hard clear performance. Tierlist information is taken into account.", }, @@ -195,30 +191,24 @@ export const GAME_IIDX_SP_CONF = { associatedScoreAlgs: ["ktLampRating"], displayOrder: 0, }, - ktLampRatingNC: { - description: `An average of your best 20 ktLampRatingNCs.`, - associatedScoreAlgs: ["ktLampRatingNC"], - displayOrder: 1, - }, ktLampRatingHC: { description: `An average of your best 20 ktLampRatingHCs.`, associatedScoreAlgs: ["ktLampRatingHC"], - displayOrder: 2, + displayOrder: 1, }, ktLampRatingEXHC: { description: `An average of your best 20 ktLampRatingEXHCs.`, associatedScoreAlgs: ["ktLampRatingEXHC"], - displayOrder: 3, + displayOrder: 2, }, BPI: { description: `An average of your best 20 BPIs.`, associatedScoreAlgs: ["BPI"], - displayOrder: 4, + displayOrder: 3, }, }, sessionRatingAlgs: { ktLampRating: { description: `An average of the best 10 ktLampRatings this session.` }, - ktLampRatingNC: { description: `An average of the best 10 ktLampRatingNCs this session.` }, ktLampRatingHC: { description: `An average of the best 10 ktLampRatingHCs this session.` }, ktLampRatingEXHC: { description: `An average of the best 10 ktLampRatingEXHCs this session.`, diff --git a/typescript/common/src/lib/folder-slug.ts b/typescript/common/src/lib/folder-slug.ts index 01fe41162..c1ffd044b 100644 --- a/typescript/common/src/lib/folder-slug.ts +++ b/typescript/common/src/lib/folder-slug.ts @@ -351,7 +351,7 @@ function encodeBmsTableKey(game: string, key: string): string { } throw new Error( - `No BMS_TABLES row for game=${JSON.stringify(game)} prefix=${JSON.stringify(key)} — add a BMSTableInfo entry or use an ascii-only prefix.`, + `No BMS_TABLES row for game=${JSON.stringify(game)} prefix=${JSON.stringify(key)} - add a BMSTableInfo entry or use an ascii-only prefix.`, ); } diff --git a/typescript/common/vitest.config.ts b/typescript/common/vitest.config.ts index 8b533ee50..9028144e1 100644 --- a/typescript/common/vitest.config.ts +++ b/typescript/common/vitest.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ NODE_ENV: "test", }, exclude: [ - // Compiled output — not test sources. + // Compiled output - not test sources. "build/**", ], coverage: { diff --git a/typescript/coverage-tools/src/report-coverage.ts b/typescript/coverage-tools/src/report-coverage.ts index b21e146d1..91a97d718 100644 --- a/typescript/coverage-tools/src/report-coverage.ts +++ b/typescript/coverage-tools/src/report-coverage.ts @@ -119,7 +119,7 @@ function printHuman(report: CoverageReport): void { for (const p of report.packages) { if (!p.present || !p.metrics) { - console.log(`${p.id.padEnd(10)} (no data — run: just test-typescript)`); + console.log(`${p.id.padEnd(10)} (no data - run: just test-typescript)`); continue; } console.log(row(p.id, p.metrics)); diff --git a/typescript/db-cli/src/index.ts b/typescript/db-cli/src/index.ts index 2a8446875..273a82719 100755 --- a/typescript/db-cli/src/index.ts +++ b/typescript/db-cli/src/index.ts @@ -254,7 +254,7 @@ migrateCmd for (const info of infos) { const status = info.success === null ? "Pending" : info.success ? "Applied" : "Failed"; - const appliedAt = info.appliedAt ? formatDate(info.appliedAt) : "—"; + const appliedAt = info.appliedAt ? formatDate(info.appliedAt) : "-"; const desc = info.description.length > descWidth - 2 ? `${info.description.slice(0, descWidth - 5)}...` diff --git a/typescript/db-migration-engine/src/index.ts b/typescript/db-migration-engine/src/index.ts index 5cf5742bb..a7629418c 100644 --- a/typescript/db-migration-engine/src/index.ts +++ b/typescript/db-migration-engine/src/index.ts @@ -142,7 +142,7 @@ async function withAdvisoryLock(client: Client, fn: () => Promise): Promis /** * Apply all pending migrations from migrationsDir against the given connection. - * Safe to call on every startup — already-applied migrations are skipped. + * Safe to call on every startup - already-applied migrations are skipped. * Throws if a previously-applied migration's file has been modified (checksum mismatch). */ export async function applyMigrations( @@ -180,7 +180,7 @@ export async function applyMigrations( continue; } - // Previously attempted and failed — clear the record and retry. + // Previously attempted and failed - clear the record and retry. await client.query( `DELETE FROM "_migration" WHERE version = $1 AND success = false`, [migration.version], @@ -188,7 +188,7 @@ export async function applyMigrations( } console.log( - `[migrate] Applying ${migration.version} — ${migration.description}...`, + `[migrate] Applying ${migration.version} - ${migration.description}...`, ); const start = process.hrtime.bigint(); @@ -334,7 +334,7 @@ export async function revertLastMigration( const downSql = readFileSync(downPath, "utf8"); - console.log(`[migrate] Reverting ${latestVersion} — ${latest.description}...`); + console.log(`[migrate] Reverting ${latestVersion} - ${latest.description}...`); const start = process.hrtime.bigint(); diff --git a/typescript/db/src/generated/public/PbComposedFrom.ts b/typescript/db/src/generated/public/PbComposedFrom.ts index f6296961b..ce893eb67 100644 --- a/typescript/db/src/generated/public/PbComposedFrom.ts +++ b/typescript/db/src/generated/public/PbComposedFrom.ts @@ -10,6 +10,8 @@ export default interface PbComposedFromTable { pb_id: ColumnType; score_id: ColumnType; + + merge_name: ColumnType; } export type PbComposedFrom = Selectable; diff --git a/typescript/eslint-config/index.js b/typescript/eslint-config/index.js index c882b261d..371d806c4 100644 --- a/typescript/eslint-config/index.js +++ b/typescript/eslint-config/index.js @@ -26,7 +26,7 @@ const base = tseslint.config( ...tseslint.configs.recommended, pluginPerfectionist.configs["recommended-natural"], { - name: "zenith/base", + name: "tachi/base", files: ["**/*.{ts,tsx}"], plugins: { import: pluginImport, @@ -141,10 +141,17 @@ const base = tseslint.config( }, }, }, + { + name: "tachi/test-files", + files: ["**/*.test.ts"], + rules: { + "no-await-in-loop": "off", + }, + }, ); const node = /** @type {import("@typescript-eslint/utils/ts-eslint").FlatConfig.Config} */ ({ - name: "zenith/node", + name: "tachi/node", files: ["**/*.{js,mjs,cjs,ts,tsx}"], languageOptions: { globals: { @@ -154,7 +161,7 @@ const node = /** @type {import("@typescript-eslint/utils/ts-eslint").FlatConfig. }); const react = /** @type {import("@typescript-eslint/utils/ts-eslint").FlatConfig.Config} */ ({ - name: "zenith/react", + name: "tachi/react", files: ["**/*.tsx"], languageOptions: { parserOptions: { @@ -211,8 +218,10 @@ const react = /** @type {import("@typescript-eslint/utils/ts-eslint").FlatConfig }, }); +// unused (from zenith), we don't use remix. We don't even use remix in zenith either +// because it got renamed to rrv7. const reactRemix = /** @type {import("@typescript-eslint/utils/ts-eslint").FlatConfig.Config} */ ({ - name: "zenith/react-remix", + name: "tachi/react-remix", languageOptions: { // @ts-expect-error it is definitely not undefined parserOptions: react.languageOptions.parserOptions, diff --git a/typescript/rg-stats/vitest.config.ts b/typescript/rg-stats/vitest.config.ts index fcf2f917a..ad1b4ac83 100644 --- a/typescript/rg-stats/vitest.config.ts +++ b/typescript/rg-stats/vitest.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ exclude: [ "src/**/*.test.ts", "src/test-utils/**", - // Barrel only re-exports algorithms — no executable lines to cover. + // Barrel only re-exports algorithms - no executable lines to cover. "src/index.ts", // Type-only / unused by current test surface (Tap nyc also did not require these). "src/util/types.ts", diff --git a/typescript/seeds-scripts/rerunners/usc/add-usc-converts.js b/typescript/seeds-scripts/rerunners/usc/add-usc-converts.js index 83e636814..036f40619 100644 --- a/typescript/seeds-scripts/rerunners/usc/add-usc-converts.js +++ b/typescript/seeds-scripts/rerunners/usc/add-usc-converts.js @@ -20,9 +20,7 @@ const options = program.opts(); const DEBUG = options.debug; const db = new Database(options.db, { readonly: true }); -const dbRows = db - .query(`SELECT * FROM Charts WHERE path LIKE '%${options.filter}%'`) - .all(); +const dbRows = db.query(`SELECT * FROM Charts WHERE path LIKE '%${options.filter}%'`).all(); console.log(`Found ${dbRows.length} charts.`); const songs = ReadCollection("songs-usc.json"); diff --git a/typescript/seeds-scripts/rerunners/v3/4-tablefolders-to-object.ts b/typescript/seeds-scripts/rerunners/v3/4-tablefolders-to-object.ts index f7e60a68d..f5e7ababf 100644 --- a/typescript/seeds-scripts/rerunners/v3/4-tablefolders-to-object.ts +++ b/typescript/seeds-scripts/rerunners/v3/4-tablefolders-to-object.ts @@ -35,7 +35,7 @@ function sqlStr(value: string): string { } /** - * `jsonb_array_elements` does not work on JSON objects — rewrite legacy elemMatch SQL + * `jsonb_array_elements` does not work on JSON objects - rewrite legacy elemMatch SQL * to key lookup (must match `5-folders-to-sql-queries.ts`). */ function rewriteFolderWhereTableFoldersElemExists(where: string): string | null { diff --git a/typescript/seeds-webui/README.md b/typescript/seeds-webui/README.md index dc137f6ed..3c27a4ab3 100644 --- a/typescript/seeds-webui/README.md +++ b/typescript/seeds-webui/README.md @@ -1,6 +1,6 @@ # tachi-seeds-webui -The seeds workstation. Browse, query, diff over git history — and, in +The seeds workstation. Browse, query, diff over git history - and, in localdev, edit `db/seeds/*.json` directly. ## Running diff --git a/typescript/seeds-webui/dev/util/git.ts b/typescript/seeds-webui/dev/util/git.ts index cc99bda7c..ba75741b4 100644 --- a/typescript/seeds-webui/dev/util/git.ts +++ b/typescript/seeds-webui/dev/util/git.ts @@ -54,7 +54,7 @@ export async function listBranches( } } } catch { - // detached head — leave current null. + // detached head - leave current null. } return { branches, current }; @@ -129,7 +129,7 @@ export async function statusPorcelain( branch = m?.[1] ?? null; continue; } - // Porcelain format: " XY path" — strip the two-char status + space. + // Porcelain format: " XY path" - strip the two-char status + space. changed.push(line.slice(3)); } return { branch, changed, hasUncommitted: changed.length > 0 }; diff --git a/typescript/seeds-webui/dev/vite-plugin-seeds-dev.ts b/typescript/seeds-webui/dev/vite-plugin-seeds-dev.ts index f6df0b18e..d0f45c781 100644 --- a/typescript/seeds-webui/dev/vite-plugin-seeds-dev.ts +++ b/typescript/seeds-webui/dev/vite-plugin-seeds-dev.ts @@ -15,7 +15,7 @@ import { } from "./util/git"; import { applyPatch, type JsonPatch } from "./util/json-patch"; -// vite-plugin-seeds-dev — mounts /__seeds/* dev-only endpoints against the +// vite-plugin-seeds-dev - mounts /__seeds/* dev-only endpoints against the // local repo's db/seeds directory so the seeds-webui can: // // - GET /__seeds/ping feature probe diff --git a/typescript/seeds-webui/src/App.tsx b/typescript/seeds-webui/src/App.tsx index 95ec0b56e..4c75e6318 100644 --- a/typescript/seeds-webui/src/App.tsx +++ b/typescript/seeds-webui/src/App.tsx @@ -22,7 +22,7 @@ const queryClient = new QueryClient({ // Load the editing routes lazily via a single dynamic import. The import is // only reached when EDIT_MODE is true; since EDIT_MODE is replaced with the -// literal `false` during `vite build`, rollup drops the entire expression — +// literal `false` during `vite build`, rollup drops the entire expression - // which means `editRoutes.ts` and every page it references is never emitted // into the prod bundle. type EditRoute = { component: React.ComponentType; path: string }; diff --git a/typescript/seeds-webui/src/components/CollectionDiffRows.tsx b/typescript/seeds-webui/src/components/CollectionDiffRows.tsx index 0d70d42ce..067923483 100644 --- a/typescript/seeds-webui/src/components/CollectionDiffRows.tsx +++ b/typescript/seeds-webui/src/components/CollectionDiffRows.tsx @@ -126,14 +126,14 @@ function FieldTable({ fields }: { fields: FieldDelta[] }) {
- {action.ip ?? "—"} + {action.ip ?? "-"}
{o.summary ?? "—"}{o.summary ?? "-"} {o.importType} {new Date(o.timeInserted).toLocaleString()}{o.message ?? "—"}{o.message ?? "-"}
{f.path} {f.kind === "added" ? ( - + - ) : (
{formatValue(f.before)}
)}
{f.kind === "removed" ? ( - + - ) : (
{formatValue(f.after)}
)} diff --git a/typescript/seeds-webui/src/components/CommitSparkline.tsx b/typescript/seeds-webui/src/components/CommitSparkline.tsx index d32ef46b9..f4b43a8ba 100644 --- a/typescript/seeds-webui/src/components/CommitSparkline.tsx +++ b/typescript/seeds-webui/src/components/CommitSparkline.tsx @@ -91,7 +91,7 @@ export function CommitSparkline({ commits, days = 90 }: CommitSparklineProps) { y={y} > - {b.day} — {b.count} commit{b.count === 1 ? "" : "s"} + {b.day} - {b.count} commit{b.count === 1 ? "" : "s"} ); diff --git a/typescript/seeds-webui/src/components/ResultTable.tsx b/typescript/seeds-webui/src/components/ResultTable.tsx index d0ce08c9b..bc14d86b2 100644 --- a/typescript/seeds-webui/src/components/ResultTable.tsx +++ b/typescript/seeds-webui/src/components/ResultTable.tsx @@ -39,7 +39,7 @@ interface ResultTableProps { // Pass `expandedContent` for custom content (e.g. a diff); otherwise the // detail is the whole row as pretty-printed JSON. // - `rowDetailMode="inline"` (with `expandedContent`) shows that detail -// under every row with no chevron or click — e.g. Bulk edit previews. +// under every row with no chevron or click - e.g. Bulk edit previews. export function ResultTable({ result, maxRows = 500, diff --git a/typescript/seeds-webui/src/components/RowEditor.tsx b/typescript/seeds-webui/src/components/RowEditor.tsx index 1e5497a4f..f4e714537 100644 --- a/typescript/seeds-webui/src/components/RowEditor.tsx +++ b/typescript/seeds-webui/src/components/RowEditor.tsx @@ -6,7 +6,7 @@ import { type z } from "zod"; // // Replaces the previous schema-driven field-tree form. Seeds documents are // complex enough (discriminated unions, per-game chart data, etc.) that a -// generated form was always going to be leaky — it's cleaner to just edit +// generated form was always going to be leaky - it's cleaner to just edit // the raw JSON and let the schema tell you exactly what's wrong. // // Behaviour: diff --git a/typescript/seeds-webui/src/components/SqlEditor.tsx b/typescript/seeds-webui/src/components/SqlEditor.tsx index d9ced914d..46b26c120 100644 --- a/typescript/seeds-webui/src/components/SqlEditor.tsx +++ b/typescript/seeds-webui/src/components/SqlEditor.tsx @@ -7,7 +7,7 @@ import { basicSetup, EditorView } from "codemirror"; import { useCallback, useEffect, useRef, useState } from "react"; /** - * Replaces the default CodeMirror colors in dark mode — `defaultHighlightStyle` uses #708 + * Replaces the default CodeMirror colors in dark mode - `defaultHighlightStyle` uses #708 * (purple) for keywords, which is nearly unreadable on the seeds-webui near-black page. * When `themeType: "dark"`, this becomes the only non-fallback highlighter, so the purple * palette is not used. @@ -33,7 +33,7 @@ const sqlEditorHighlightDark = HighlightStyle.define( { themeType: "dark" }, ); -/** `data-bs-theme` on — must drive CodeMirror's `EditorView.darkTheme` or the caret stays light-theme (black) on a dark page. */ +/** `data-bs-theme` on - must drive CodeMirror's `EditorView.darkTheme` or the caret stays light-theme (black) on a dark page. */ function useBootstrapPageDark(): boolean { const [isDark, setIsDark] = useState( () => document.documentElement.getAttribute("data-bs-theme") !== "light", @@ -64,7 +64,7 @@ export type SqlEditorProps = { value: string; }; -/** Surfaces, caret, and panels — all use Bootstrap CSS variables so light/dark track the page. */ +/** Surfaces, caret, and panels - all use Bootstrap CSS variables so light/dark track the page. */ // Use baseTheme: `&light` / `&dark` are only expanded in buildTheme(..., lightDarkIDs), not in EditorView.theme(). const surfaceTheme = EditorView.baseTheme({ "&": { @@ -75,7 +75,7 @@ const surfaceTheme = EditorView.baseTheme({ fontFeatureSettings: '"liga" 0, "calt" 0', }, // Caret: drawSelection defaults to black / #ddd for &dark, but the editor was not getting - // `EditorView.darkTheme` — keep an explicit color so the caret always matches body text. + // `EditorView.darkTheme` - keep an explicit color so the caret always matches body text. ".cm-cursor, .cm-dropCursor": { borderLeft: "1.2px solid var(--bs-body-color)", }, diff --git a/typescript/seeds-webui/src/lib/config.ts b/typescript/seeds-webui/src/lib/config.ts index 6791e5a8c..9d553e477 100644 --- a/typescript/seeds-webui/src/lib/config.ts +++ b/typescript/seeds-webui/src/lib/config.ts @@ -3,7 +3,7 @@ // This is a *build-time* flag set in vite.config.ts. In a production build // (`vite build`) it is the literal `false`, so the bundler tree-shakes every // branch guarded by `EDIT_MODE`. That's how we keep the static prod bundle -// absolutely incapable of writing to disk — even if someone self-hosted it. +// absolutely incapable of writing to disk - even if someone self-hosted it. // // `hasDevTransport` (see #lib/transport/transport) additionally probes // /__seeds/ping at runtime before enabling the edit UI, in case a dev-built diff --git a/typescript/seeds-webui/src/lib/diff/collection-diff.ts b/typescript/seeds-webui/src/lib/diff/collection-diff.ts index edf6cf8b5..d5ce5fb70 100644 --- a/typescript/seeds-webui/src/lib/diff/collection-diff.ts +++ b/typescript/seeds-webui/src/lib/diff/collection-diff.ts @@ -46,7 +46,7 @@ export function fieldDeltas(a: Row, b: Row, prefix = ""): FieldDelta[] { out.push({ before: av, kind: "removed", path }); continue; } - // Recurse into plain objects (but not arrays — arrays are treated as + // Recurse into plain objects (but not arrays - arrays are treated as // atomic scalar values, which is the right call for things like // `tierlistData` or chart `data`). if ( @@ -196,7 +196,7 @@ export function singleDocumentDiff( export function formatValue(v: unknown): string { if (v === undefined) { - return "—"; + return "-"; } if (v === null) { return "null"; diff --git a/typescript/seeds-webui/src/lib/diff/row-primary-key.ts b/typescript/seeds-webui/src/lib/diff/row-primary-key.ts index 761eac5b4..e5dfc6418 100644 --- a/typescript/seeds-webui/src/lib/diff/row-primary-key.ts +++ b/typescript/seeds-webui/src/lib/diff/row-primary-key.ts @@ -1,7 +1,7 @@ export type Row = Record; // Candidate primary keys, in priority order. The first one present on a row is -// used. `id` is listed before `songID` because chart documents carry both — +// used. `id` is listed before `songID` because chart documents carry both - // we want to key a chart by *its* id, not by the song it belongs to. export const PK_KEYS = [ "id", diff --git a/typescript/seeds-webui/src/lib/edits/schemas.ts b/typescript/seeds-webui/src/lib/edits/schemas.ts index c410a2bbb..51f74b8c2 100644 --- a/typescript/seeds-webui/src/lib/edits/schemas.ts +++ b/typescript/seeds-webui/src/lib/edits/schemas.ts @@ -43,7 +43,7 @@ export function schemaForCollection(name: string): z.ZodType | null { return SEEDS_BMS_COURSE_DOCUMENT_SCHEMA as z.ZodType; } - // charts-${V3Game}.json — filenames mirror `db/seeds/` and match ALL_GAMES + // charts-${V3Game}.json - filenames mirror `db/seeds/` and match ALL_GAMES // keys (e.g. iidx-sp, bms-14k, gitadora-dora). Do not split on the last // hyphen; that would turn iidx-sp into iidx and miss the schema map. if (stripped.startsWith("charts-")) { diff --git a/typescript/seeds-webui/src/lib/format/seed-doc-summary.ts b/typescript/seeds-webui/src/lib/format/seed-doc-summary.ts index 49ac8a45c..e0ec32f84 100644 --- a/typescript/seeds-webui/src/lib/format/seed-doc-summary.ts +++ b/typescript/seeds-webui/src/lib/format/seed-doc-summary.ts @@ -12,8 +12,8 @@ export type SeedDocSummaryOpts = { }; /** - * Human-readable one-liner for a seeds row — same rules as the collection - * browser list (FormatChart for charts with a song, title — artist for songs, + * Human-readable one-liner for a seeds row - same rules as the collection + * browser list (FormatChart for charts with a song, title - artist for songs, * etc.). */ export function prettySeedDocSummary( @@ -52,7 +52,7 @@ export function prettySeedDocSummary( case "songs": { const title = str(doc.title); const artist = str(doc.artist); - return artist ? `${title} — ${artist}` : title; + return artist ? `${title} - ${artist}` : title; } case "folders": case "tables": { diff --git a/typescript/seeds-webui/src/lib/sqlite/builder.ts b/typescript/seeds-webui/src/lib/sqlite/builder.ts index d3a1cc1b5..c28555070 100644 --- a/typescript/seeds-webui/src/lib/sqlite/builder.ts +++ b/typescript/seeds-webui/src/lib/sqlite/builder.ts @@ -4,7 +4,7 @@ import { getSqlite } from "./client"; // Fast content hash for JSON payloads. We hash the serialised string because // getCollection returns parsed JSON; this matches what scripts/bundle-current-seeds -// stores (sha256(prefix-16) of the raw file bytes — close enough for change detection). +// stores (sha256(prefix-16) of the raw file bytes - close enough for change detection). async function hashJson(value: unknown): Promise { const s = JSON.stringify(value); const buf = new TextEncoder().encode(s); @@ -36,7 +36,7 @@ export async function* buildSqliteFromTransport( const name = names[i]!; const rows = await transport.getCollection(name, rev); const hash = await hashJson(rows); - // When rev !== undefined we *always* re-ingest — the caller is looking at + // When rev !== undefined we *always* re-ingest - the caller is looking at // a specific commit, not the working copy, and we don't cache historical // loads to avoid OPFS bloat. if (rev === undefined && meta[name] === hash) { diff --git a/typescript/seeds-webui/src/lib/sqlite/schema.ts b/typescript/seeds-webui/src/lib/sqlite/schema.ts index 07e50a065..01099751f 100644 --- a/typescript/seeds-webui/src/lib/sqlite/schema.ts +++ b/typescript/seeds-webui/src/lib/sqlite/schema.ts @@ -3,12 +3,12 @@ // Strategy: *per-collection* tables mirror the on-disk files exactly // (so a row in `charts_iidx_sp` corresponds 1:1 to a JSON object in // `db/seeds/charts-iidx-sp.json`). We project the scalar fields we -// query on into columns and keep the rest as a JSON blob — SQLite's +// query on into columns and keep the rest as a JSON blob - SQLite's // `json_extract` makes that queryable when needed. // // Cross-cutting questions ("how many charts does iidx-sp have across all // versions?") get answered by materialised views built over per-collection -// tables. We build the union of charts/songs lazily — i.e. the view DDL +// tables. We build the union of charts/songs lazily - i.e. the view DDL // references every `charts_` table that has been created. // // Table name convention: lowercase, '-' -> '_', strip '.json'. diff --git a/typescript/seeds-webui/src/lib/transport/dev-transport.ts b/typescript/seeds-webui/src/lib/transport/dev-transport.ts index d058d241d..9fa32aa22 100644 --- a/typescript/seeds-webui/src/lib/transport/dev-transport.ts +++ b/typescript/seeds-webui/src/lib/transport/dev-transport.ts @@ -77,7 +77,7 @@ export function makeDevTransport(): SeedsTransport { }; } -// Server-Sent Events helper — the dev plugin streams stdout/stderr line-by-line +// Server-Sent Events helper - the dev plugin streams stdout/stderr line-by-line // plus a final `exit` event, so we wrap it as an AsyncIterable. async function* streamSSE(pathname: string, body: unknown): AsyncIterable { const res = await fetch(`${BASE}${pathname}`, { diff --git a/typescript/seeds-webui/src/lib/transport/index.ts b/typescript/seeds-webui/src/lib/transport/index.ts index 7f86da70c..40116ad82 100644 --- a/typescript/seeds-webui/src/lib/transport/index.ts +++ b/typescript/seeds-webui/src/lib/transport/index.ts @@ -58,7 +58,7 @@ export type JsonPatch = JsonPatchOp[]; export interface SeedsTransport { readonly mode: "dev" | "github"; - // Read-side — always available. + // Read-side - always available. listCollections(): Promise; listBranches(): Promise<{ branches: Branch[]; current: Branch | null }>; listCommits(opts: { branch?: string; cursor?: string; file?: string }): Promise; @@ -67,7 +67,7 @@ export interface SeedsTransport { // branch HEAD" in github. getCollection(name: CollectionName, rev?: string): Promise; - // Write-side — populated only in dev mode. + // Write-side - populated only in dev mode. writeCollection?: (name: CollectionName, patch: JsonPatch) => Promise; runSort?: () => Promise; gitStatus?: () => Promise; diff --git a/typescript/seeds-webui/src/pages/Bulk.tsx b/typescript/seeds-webui/src/pages/Bulk.tsx index 3ebacf334..f291025d5 100644 --- a/typescript/seeds-webui/src/pages/Bulk.tsx +++ b/typescript/seeds-webui/src/pages/Bulk.tsx @@ -174,7 +174,7 @@ export function Bulk() { } } if (rejections.length > 0) { - setError(`Bulk merge rejected — would not satisfy schema:\n${rejections.join("\n")}`); + setError(`Bulk merge rejected - would not satisfy schema:\n${rejections.join("\n")}`); return; } diff --git a/typescript/seeds-webui/src/pages/Collection.tsx b/typescript/seeds-webui/src/pages/Collection.tsx index 650b2e852..c130a8083 100644 --- a/typescript/seeds-webui/src/pages/Collection.tsx +++ b/typescript/seeds-webui/src/pages/Collection.tsx @@ -197,7 +197,7 @@ export function Collection() { {!schema && EDIT_MODE ? (
- No zod schema found for this collection — editing is unavailable. + No zod schema found for this collection - editing is unavailable.
) : null} diff --git a/typescript/seeds-webui/src/pages/Diff.tsx b/typescript/seeds-webui/src/pages/Diff.tsx index 8316eb00a..0216cd105 100644 --- a/typescript/seeds-webui/src/pages/Diff.tsx +++ b/typescript/seeds-webui/src/pages/Diff.tsx @@ -25,7 +25,7 @@ function mergeSongRowsById(baseSongs: Row[], headSongs: Row[]): Map // seeds.tachi.ac always points at the canonical upstream. We use this for // every GitHub link in the diff header (commits, compare, PRs). This is a -// deliberate hardcode — the dev transport doesn't know what remote the +// deliberate hardcode - the dev transport doesn't know what remote the // local clone is pointing at, and for the hosted site it's always this repo. const GITHUB_REPO_URL = "https://github.com/zkldi/Tachi"; @@ -341,7 +341,7 @@ function CommitHeader({
- {commit?.author.name ?? "—"} + {commit?.author.name ?? "-"} {commit?.author.email ? ( <{commit.author.email}> ) : null} diff --git a/typescript/seeds-webui/src/pages/Drafts.tsx b/typescript/seeds-webui/src/pages/Drafts.tsx index 745a7c7ca..40ae4d143 100644 --- a/typescript/seeds-webui/src/pages/Drafts.tsx +++ b/typescript/seeds-webui/src/pages/Drafts.tsx @@ -94,7 +94,7 @@ async function computeDocPair(draft: Draft): Promise { } // --------------------------------------------------------------------------- -// Draft item — op / time / discard in a slim bar; diff card always visible +// Draft item - op / time / discard in a slim bar; diff card always visible // (same id=…, optional pretty title, field table, Show JSON as on /diff). // --------------------------------------------------------------------------- @@ -272,7 +272,7 @@ export function Drafts() { ) : null}

- Staged edits. Apply to write them to disk — seeds are re-sorted + Staged edits. Apply to write them to disk - seeds are re-sorted automatically.

diff --git a/typescript/seeds-webui/src/styles/_variables.scss b/typescript/seeds-webui/src/styles/_variables.scss index 4c6f11392..1038238e2 100644 --- a/typescript/seeds-webui/src/styles/_variables.scss +++ b/typescript/seeds-webui/src/styles/_variables.scss @@ -1,4 +1,4 @@ -// Tachi brand pink — matches the .st1 fill in logo.svg. +// Tachi brand pink - matches the .st1 fill in logo.svg. $tachi-pink: #e61c6e; // Bootstrap overrides (must come before `@import "bootstrap-scss/bootstrap"`). @@ -31,7 +31,7 @@ $font-weight-bold: 600; $min-contrast-ratio: 3; -// Slightly warmer/cooler grays on each theme — matches the vibe of tachi-client. +// Slightly warmer/cooler grays on each theme - matches the vibe of tachi-client. $body-bg-dark: #131313; $body-tertiary-bg-dark: #1a1a1a; $body-secondary-bg-dark: #242424; diff --git a/typescript/seeds-webui/src/styles/app.scss b/typescript/seeds-webui/src/styles/app.scss index 3ee6edef8..56d0469de 100644 --- a/typescript/seeds-webui/src/styles/app.scss +++ b/typescript/seeds-webui/src/styles/app.scss @@ -253,7 +253,7 @@ code { } // --------------------------------------------------------------------------- -// Git status strip — appears under the header in dev mode. +// Git status strip - appears under the header in dev mode. // --------------------------------------------------------------------------- .git-strip { @@ -519,7 +519,7 @@ code { } } - // Row-expand column — narrow, center-aligned, no hover bg. + // Row-expand column - narrow, center-aligned, no hover bg. .result-col-expand { width: 2rem; padding: 0.35rem 0.25rem 0.35rem 0.5rem; @@ -709,7 +709,7 @@ code { } // --------------------------------------------------------------------------- -// Skeleton loading placeholder — used anywhere a list of rows is loading. +// Skeleton loading placeholder - used anywhere a list of rows is loading. // --------------------------------------------------------------------------- @keyframes skeleton-shimmer { @@ -791,7 +791,7 @@ code { } // --------------------------------------------------------------------------- -// Ingest toast — pinned bottom-right overlay that reports sqlite build +// Ingest toast - pinned bottom-right overlay that reports sqlite build // progress from anywhere in the app. Non-blocking; slides up on mount and // fades out once the database is ready. // --------------------------------------------------------------------------- @@ -934,7 +934,7 @@ code { } // --------------------------------------------------------------------------- -// Diff view — card-per-row with field-level highlighting. Readable over the +// Diff view - card-per-row with field-level highlighting. Readable over the // previous ellipsised table. // --------------------------------------------------------------------------- @@ -1296,7 +1296,7 @@ $diff-chg-border: rgba(245, 177, 76, 0.4); } // --------------------------------------------------------------------------- -// Seed document list — Collection page renderer. +// Seed document list - Collection page renderer. // // Each seed document is a collapsible row: a one-line summary header with // optional edit/delete buttons, and a pretty-printed JSON block when open. @@ -1597,7 +1597,7 @@ $diff-chg-border: rgba(245, 177, 76, 0.4); } // --------------------------------------------------------------------------- -// Floating drafts bar — pinned bottom-left, shown when there are staged edits +// Floating drafts bar - pinned bottom-left, shown when there are staged edits // and the user is not already on /drafts. // --------------------------------------------------------------------------- diff --git a/typescript/seeds-webui/vite.config.ts b/typescript/seeds-webui/vite.config.ts index 7c9a2ccea..ce86fc099 100644 --- a/typescript/seeds-webui/vite.config.ts +++ b/typescript/seeds-webui/vite.config.ts @@ -8,12 +8,12 @@ import { seedsDevPlugin } from "./dev/vite-plugin-seeds-dev"; // In `vite dev` the seedsDevPlugin mounts /__seeds/* endpoints against the // local repo's db/seeds/ so the UI can edit, query git history, and run // the seeds-scripts tests. These endpoints are NOT present in the -// production build — all references to them are gated behind +// production build - all references to them are gated behind // import.meta.env.VITE_SEEDS_EDIT_MODE and tree-shaken out. const REPO_ROOT = path.resolve(__dirname, "../.."); -/** Hoisted path for a workspace @codemirror/* package (single instance — avoids "Unrecognized extension value" from nested node_modules). */ +/** Hoisted path for a workspace @codemirror/* package (single instance - avoids "Unrecognized extension value" from nested node_modules). */ const cmPackage = (name: string) => path.join(REPO_ROOT, "node_modules", "@codemirror", name); const codemirrorSingleInstanceAliases: { @@ -35,7 +35,7 @@ const lezerSingleInstanceAliases: { })); export default defineConfig(({ command }) => ({ - // Edit mode is only on for `vite dev` — `vite build` and `vite preview` + // Edit mode is only on for `vite dev` - `vite build` and `vite preview` // produce a read-only bundle suitable for hosting at seeds.tachi.ac. define: { "import.meta.env.VITE_SEEDS_EDIT_MODE": JSON.stringify(command === "serve"), @@ -67,7 +67,7 @@ export default defineConfig(({ command }) => ({ // In prod builds, short-circuit modules that are only reachable // when EDIT_MODE is on. `editRoutes` hosts the edit-only pages // and `schemas` pulls in the whole tachi-common game config tree - // for zod introspection — we don't want either of those shipping + // for zod introspection - we don't want either of those shipping // to seeds.tachi.ac. { name: "seeds-webui:strip-edit-routes", diff --git a/typescript/server/.dockerignore b/typescript/server/.dockerignore index c8d119350..320924d78 100644 --- a/typescript/server/.dockerignore +++ b/typescript/server/.dockerignore @@ -1,6 +1,6 @@ Dockerfile -# Env files — mirror repo root `.dockerignore` when this package is build context alone. +# Env files - mirror repo root `.dockerignore` when this package is build context alone. .env .env.* **/.env diff --git a/typescript/server/.env b/typescript/server/.env index 07e787c37..769fc721a 100644 --- a/typescript/server/.env +++ b/typescript/server/.env @@ -13,7 +13,6 @@ MIGRATIONS_DIR=/tachi/db/migrations LOG_LEVEL=debug TACHI_EMAIL_FROM=dev@localhost -TACHI_EMAIL_AUTH_POSTMARK=false TACHI_EMAIL_HOST=tachi-mailpit TACHI_EMAIL_PORT=1025 TACHI_EMAIL_SECURE=false diff --git a/typescript/server/.env.example b/typescript/server/.env.example index 99aa68833..95b9a0962 100644 --- a/typescript/server/.env.example +++ b/typescript/server/.env.example @@ -53,7 +53,6 @@ TACHI_CDN_SAVE_LOCATION_PRIVATE_REGION= # Email (required) TACHI_EMAIL_FROM= -TACHI_EMAIL_AUTH_POSTMARK= TACHI_EMAIL_HOST= TACHI_EMAIL_PORT= TACHI_EMAIL_SECURE= @@ -69,7 +68,7 @@ TACHI_MYT_API_HOST= TACHI_ARC_AUTH_TOKEN= TACHI_MYT_AUTH_TOKEN= -# OAuth2 — set all 3 for a provider or none +# OAuth2 - set all 3 for a provider or none TACHI_FLO_OAUTH2_CLIENT_ID= TACHI_FLO_OAUTH2_CLIENT_SECRET= TACHI_FLO_OAUTH2_REDIRECT_URI= @@ -80,7 +79,7 @@ TACHI_MIN_OAUTH2_CLIENT_ID= TACHI_MIN_OAUTH2_CLIENT_SECRET= TACHI_MIN_OAUTH2_REDIRECT_URI= -# CG — set both for a suffix or neither +# CG - set both for a suffix or neither TACHI_CG_DEV_API_KEY= TACHI_CG_DEV_URL= TACHI_CG_NAG_API_KEY= @@ -88,7 +87,7 @@ TACHI_CG_NAG_URL= TACHI_CG_GAN_API_KEY= TACHI_CG_GAN_URL= -# Seeds (optional) — set TACHI_SEEDS_TYPE to LOCAL_FILES or GIT_REPO to enable +# Seeds (optional) - set TACHI_SEEDS_TYPE to LOCAL_FILES or GIT_REPO to enable TACHI_SEEDS_TYPE= TACHI_SEEDS_PATH= TACHI_SEEDS_REPO_URL= @@ -96,7 +95,7 @@ TACHI_SEEDS_USER_NAME= TACHI_SEEDS_USER_EMAIL= TACHI_SEEDS_BRANCH= -# Invite codes — set all 3 or none +# Invite codes - set all 3 or none TACHI_INVITE_CODE_BATCH_SIZE= TACHI_INVITE_CODE_INVITE_CAP= TACHI_INVITE_CODE_BETA_USER_BONUS= diff --git a/typescript/server/.env.test b/typescript/server/.env.test index 97a64d0c7..ea1338fec 100644 --- a/typescript/server/.env.test +++ b/typescript/server/.env.test @@ -42,7 +42,6 @@ TACHI_GAME_GROUPS=iidx,museca,sdvx,bms,chunithm,usc,wacca,popn,jubeat,pms,maimai TACHI_IMPORT_TYPES=file/eamusement-iidx-csv,file/batch-manual,file/solid-state-squad,file/pli-iidx-csv,ir/direct-manual,ir/barbatos,ir/fervidex,ir/fervidex-static,ir/beatoraja,ir/usc,ir/kshook-sv6c,api/eag-iidx,api/eag-sdvx,api/flo-iidx,api/flo-sdvx,api/min-sdvx TACHI_EMAIL_FROM=test@example.com -TACHI_EMAIL_AUTH_POSTMARK=false TACHI_EMAIL_HOST=127.0.0.1 TACHI_EMAIL_PORT=1025 TACHI_EMAIL_SECURE=false diff --git a/typescript/server/.taprc b/typescript/server/.taprc deleted file mode 100644 index 189103720..000000000 --- a/typescript/server/.taprc +++ /dev/null @@ -1,18 +0,0 @@ -files: "src/test-utils/single-process-tap.ts" -test-env: - - NODE_ENV=test - - PORT=8080 - - LOG_LEVEL=info -# On a local machine, these tests run in under 2-3 minutes. -# On github actions, these tests can take an inordinate amount of time. -# The timeout is set to 10 minutes, which is ridiculous. -# Doesn't matter for real machines. -timeout: 600 -ts: true -before: "./src/test-utils/hack-setup.js" -check-coverage: true -branches: 30 -functions: 80 -lines: 80 -statements: 80 -reporter: terse diff --git a/typescript/server/src/actions/resend-verify-email.ts b/typescript/server/src/actions/resend-verify-email.ts index af0bff66e..65bc06945 100644 --- a/typescript/server/src/actions/resend-verify-email.ts +++ b/typescript/server/src/actions/resend-verify-email.ts @@ -31,6 +31,6 @@ export const ACTION_ResendVerifyEmail = MakeAction("RESEND_VERIFY_EMAIL", async const { text, html } = EmailFormatVerifyEmail(taker.acct.username, newToken); - void SendEmail(verifyInfo.email, "Email Verification", html, text); + await SendEmail(verifyInfo.email, "Email Verification", html, text); return {}; }); diff --git a/typescript/server/src/anon-actions/register.test.ts b/typescript/server/src/anon-actions/register.test.ts index ef5c65b7d..224ef4fd6 100644 --- a/typescript/server/src/anon-actions/register.test.ts +++ b/typescript/server/src/anon-actions/register.test.ts @@ -1,6 +1,7 @@ +import { ServerConfig } from "#lib/setup/config"; import DB from "#services/pg/db"; import { seedInvite, seedUser } from "#test-utils/pg-fixtures"; -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { ANON_ACTION_Register } from "./register"; @@ -217,6 +218,52 @@ describe("ANON_ACTION_Register", () => { expect(user).toBeUndefined(); }); + // ── Sequence gaps ───────────────────────────────────────────────────────── + + it("does not burn sequence values on failed registrations with bad invite codes", async () => { + const first = await ANON_ACTION_Register(taker, { + username: "firstuser", + "!password": "securepassword", + email: "first@example.com", + captcha: "test", + inviteCode, + }); + + // Need a fresh invite for the next successful signup. + const { id: seedId } = await seedUser({ + username: "inviter2", + email: "inviter2@example.com", + }); + const secondInvite = await seedInvite(seedId, "SECOND_INVITE"); + + // 10 failed registrations — bad invite code but unique username/email, + // so they enter the transaction and reach AddNewUser before failing. + for (let i = 0; i < 10; i++) { + await expect( + ANON_ACTION_Register(taker, { + username: `baduser${i}`, + "!password": "securepassword", + email: `bad${i}@example.com`, + captcha: "test", + inviteCode: "BOGUS_CODE", + }), + ).rejects.toThrow(); + } + + const second = await ANON_ACTION_Register(taker, { + username: "seconduser", + "!password": "securepassword", + email: "second@example.com", + captcha: "test", + inviteCode: secondInvite, + }); + + // If sequences are gap-free, the second real user should be first.id + 1 + // (skipping only the seedUser helper we inserted, which also burns one). + // With BIGSERIAL, this FAILS: the 10 rolled-back txns each burned a value. + expect(second.userID).toBe(first.userID + 2); + }); + // ── Audit log ────────────────────────────────────────────────────────────── it("writes a BAD action row when the username is already taken", async () => { @@ -276,3 +323,94 @@ describe("ANON_ACTION_Register", () => { expect(input).not.toHaveProperty("!password"); }); }); + +// ─── Bootstrap invite (INVITE_ADMIN_INITIAL_INVITE_CODE) ────────────────────── + +describe("ANON_ACTION_Register - bootstrap invite", () => { + const taker = { ip: "127.0.0.1" }; + const BOOTSTRAP_CODE = "BOOTSTRAP_SECRET_FOR_TESTS"; + + let originalBootstrap: typeof ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE; + + beforeEach(() => { + originalBootstrap = ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE; + ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE = BOOTSTRAP_CODE; + }); + + afterEach(() => { + ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE = originalBootstrap; + }); + + it("creates an admin user when the instance has no accounts", async () => { + const result = await ANON_ACTION_Register(taker, { + username: "firstadmin", + "!password": "securepassword", + email: "admin@example.com", + captcha: "test", + inviteCode: BOOTSTRAP_CODE, + }); + + const account = await DB.selectFrom("account") + .select(["account.id", "account.auth_level"]) + .where("account.id", "=", result.userID) + .executeTakeFirstOrThrow(); + + expect(account.auth_level).toBe("admin"); + }); + + it("does not consume any priv_invite row", async () => { + await ANON_ACTION_Register(taker, { + username: "firstadmin", + "!password": "securepassword", + email: "admin@example.com", + captcha: "test", + inviteCode: BOOTSTRAP_CODE, + }); + + const invites = await DB.selectFrom("priv_invite") + .select(DB.fn.countAll().as("count")) + .executeTakeFirstOrThrow(); + + expect(Number(invites.count)).toBe(0); + }); + + it("rejects the bootstrap code when an account already exists", async () => { + await seedUser(); + + await expect( + ANON_ACTION_Register(taker, { + username: "seconduser", + "!password": "securepassword", + email: "second@example.com", + captcha: "test", + inviteCode: BOOTSTRAP_CODE, + }), + ).rejects.toMatchObject({ code: 400 }); + }); + + it("rejects a wrong code even on an empty instance", async () => { + await expect( + ANON_ACTION_Register(taker, { + username: "firstadmin", + "!password": "securepassword", + email: "admin@example.com", + captcha: "test", + inviteCode: "WRONG_CODE", + }), + ).rejects.toThrow(); + }); + + it("falls through to normal invite validation when env is unset", async () => { + ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE = undefined; + + await expect( + ANON_ACTION_Register(taker, { + username: "firstadmin", + "!password": "securepassword", + email: "admin@example.com", + captcha: "test", + inviteCode: "NONEXISTENT", + }), + ).rejects.toThrow(); + }); +}); diff --git a/typescript/server/src/anon-actions/register.ts b/typescript/server/src/anon-actions/register.ts index 6bac4c44b..177d25b89 100644 --- a/typescript/server/src/anon-actions/register.ts +++ b/typescript/server/src/anon-actions/register.ts @@ -7,8 +7,25 @@ import DB from "#services/pg/db"; import { Random20Hex } from "#utils/misc"; import { CheckIfEmailInUse, GetUserCaseInsensitive } from "#utils/user"; import { ExpectedErr, log } from "bliss"; +import crypto from "crypto"; +import { sql } from "kysely"; import { type UserDocument } from "tachi-common"; +/** Namespace for the advisory lock that serialises bootstrap-invite registrations. */ +const BOOTSTRAP_INVITE_ADVISORY_KEY1 = 0x42_6f_6f_74; // "Boot" +const BOOTSTRAP_INVITE_ADVISORY_KEY2 = 0x49_6e_76_74; // "Invt" + +function bootstrapCodeMatches(input: string, expected: string): boolean { + const a = Buffer.from(input, "utf8"); + const b = Buffer.from(expected, "utf8"); + + if (a.length !== b.length) { + return false; + } + + return crypto.timingSafeEqual(a, b); +} + export const ANON_ACTION_Register = MakeAnonAction( "REGISTER", async (taker, { email, "!password": password, inviteCode, username, captcha }) => { @@ -44,10 +61,6 @@ export const ANON_ACTION_Register = MakeAnonAction( } const newUser = await DB.transaction().execute(async (txn): Promise => { - // if we get to this point, We're good to create the user. - - const { newUser, newSettings: _ } = await AddNewUser(txn, username, password, email); - if (ServerConfig.INVITE_CODE_CONFIG) { if (!inviteCode) { throw new ExpectedErr( @@ -55,20 +68,61 @@ export const ANON_ACTION_Register = MakeAnonAction( "No invite code given, yet the server uses invites.", ); } + } + const isBootstrapInvite = + ServerConfig.INVITE_CODE_CONFIG && + ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE && + inviteCode !== null && + bootstrapCodeMatches(inviteCode, ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE); + + if (isBootstrapInvite) { + await sql`SELECT pg_advisory_xact_lock(${BOOTSTRAP_INVITE_ADVISORY_KEY1}, ${BOOTSTRAP_INVITE_ADVISORY_KEY2})`.execute( + txn, + ); + + const { count } = await txn + .selectFrom("account") + .select(txn.fn.countAll().as("count")) + .executeTakeFirstOrThrow(); + + if (Number(count) > 0) { + log.info("Bootstrap invite used but instance already has users."); + throw new ExpectedErr(400, `Invalid invite code given: ${inviteCode}.`); + } + } else if (ServerConfig.INVITE_CODE_CONFIG) { + // Validate the invite code BEFORE creating the user so a bad code + // never burns a sequence value on `account.id`. + // FOR UPDATE locks the row so a concurrent signup can't consume + // the same code between our check and the update below. const inviteCodeDoc = await txn .selectFrom("priv_invite") - .select("code") - .where("code", "=", inviteCode) - .where("consumed", "=", false) + .select("priv_invite.code") + .where("priv_invite.code", "=", inviteCode!) + .where("priv_invite.consumed", "=", false) + .forUpdate() .executeTakeFirst(); if (!inviteCodeDoc) { log.info(`Invalid invite code given: ${inviteCode}.`); throw new ExpectedErr(400, `Invalid invite code given: ${inviteCode}.`); } + } - log.info(`Consumed invite ${inviteCodeDoc.code}.`); + const { newUser, newSettings: _ } = await AddNewUser(txn, username, password, email); + + if (isBootstrapInvite) { + await txn + .updateTable("account") + .set({ auth_level: "admin" }) + .where("account.id", "=", newUser.id) + .execute(); + + log.info( + `Bootstrap invite consumed — user ${newUser.username} (${newUser.id}) is now admin.`, + ); + } else if (ServerConfig.INVITE_CODE_CONFIG) { + log.info(`Consumed invite ${inviteCode}.`); await txn .updateTable("priv_invite") @@ -77,7 +131,7 @@ export const ANON_ACTION_Register = MakeAnonAction( consumed_at: new Date().toISOString(), consumed_by: newUser.id, }) - .where("code", "=", inviteCode) + .where("priv_invite.code", "=", inviteCode!) .execute(); } diff --git a/typescript/server/src/cron-worker.ts b/typescript/server/src/cron-worker.ts index 17bcb8f99..fcbd7e7b5 100644 --- a/typescript/server/src/cron-worker.ts +++ b/typescript/server/src/cron-worker.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-await-in-loop */ import { loadServerEnvFile } from "#lib/setup/load-server-env"; loadServerEnvFile(process.env.NODE_ENV === "test" ? ".env.test" : ".env"); @@ -5,8 +6,12 @@ import { runCronTickOnce } from "#lib/jobs/cron/cron-service"; import { log } from "#lib/log/log"; import { Env } from "#lib/setup/config"; import { ClosePgConnection } from "#services/pg/db"; +import { Sleep } from "#utils/misc"; +import { writeFileSync } from "fs"; import { applyMigrations } from "tachi-db-migration-engine"; +const HEARTBEAT_FILE = "/tmp/worker-heartbeat"; + const TICK_MS = 5_000; process.on("uncaughtException", (err, origin) => { @@ -23,6 +28,7 @@ void bootstrap(); async function bootstrap() { await applyMigrations(Env.POSTGRES_URL, Env.MIGRATIONS_DIR); log.info({ bootInfo: true }, "tachi cron worker starting."); + let stopping = false; const shutdown = () => { stopping = true; @@ -32,8 +38,8 @@ async function bootstrap() { // eslint-disable-next-line no-unmodified-loop-condition while (!stopping) { + writeFileSync(HEARTBEAT_FILE, Date.now().toString()); try { - // eslint-disable-next-line no-await-in-loop await runCronTickOnce(); } catch (e) { log.error(e, "Cron tick error."); @@ -41,10 +47,7 @@ async function bootstrap() { if (stopping) { break; } - // eslint-disable-next-line no-await-in-loop - await new Promise((r) => { - setTimeout(r, TICK_MS); - }); + await Sleep(TICK_MS); } log.info("Cron worker stopped."); await ClosePgConnection(); diff --git a/typescript/server/src/game-implementations/games/ddr.profile.test.ts b/typescript/server/src/game-implementations/games/ddr.profile.test.ts index b9aa0dab3..425d4f686 100644 --- a/typescript/server/src/game-implementations/games/ddr.profile.test.ts +++ b/typescript/server/src/game-implementations/games/ddr.profile.test.ts @@ -150,7 +150,7 @@ describe("DDR profileCalcs (flareSkill, Postgres)", () => { it("counts at most 30 PBs per flare category (global flare order, per-category index)", async () => { const { id: userId } = await seedUser(); - // 31 CLASSIC rows, flareSkill 1031..1001 — sorted desc; index 30 is dropped (top < 30 keeps 0..29). + // 31 CLASSIC rows, flareSkill 1031..1001 - sorted desc; index 30 is dropped (top < 30 keeps 0..29). for (let i = 0; i < 31; i++) { await seedDdrPbRow(userId, { flareSkill: 1031 - i, diff --git a/typescript/server/src/game-implementations/games/iidx.impl.test.ts b/typescript/server/src/game-implementations/games/iidx.impl.test.ts index 1fc537286..92d48fe39 100644 --- a/typescript/server/src/game-implementations/games/iidx.impl.test.ts +++ b/typescript/server/src/game-implementations/games/iidx.impl.test.ts @@ -236,7 +236,7 @@ describe("IIDX_IMPL (unit)", () => { }); }); - describe("scoreCalcs ktLampRatingNC / ktLampRatingHC / ktLampRatingEXHC", () => { + describe("scoreCalcs ktLampRatingHC / ktLampRatingEXHC", () => { it("IIDX SP", () => { const run = ( scoreData: Partial>, @@ -258,37 +258,30 @@ describe("IIDX_IMPL (unit)", () => { const tiered = { ncTier: mkTier(15), hcTier: mkTier(16), exhcTier: mkTier(17) }; expect(run({ lamp: "FAILED" }, {})).toMatchObject({ - ktLampRatingNC: 0, ktLampRatingHC: 0, ktLampRatingEXHC: 0, }); expect(run({ lamp: "EASY CLEAR" }, {})).toMatchObject({ - ktLampRatingNC: 0, ktLampRatingHC: 0, ktLampRatingEXHC: 0, }); expect(run({ lamp: "CLEAR" }, tiered)).toMatchObject({ - ktLampRatingNC: 15, ktLampRatingHC: 0, ktLampRatingEXHC: 0, }); expect(run({ lamp: "HARD CLEAR" }, tiered)).toMatchObject({ - ktLampRatingNC: 15, ktLampRatingHC: 16, ktLampRatingEXHC: 0, }); expect(run({ lamp: "EX HARD CLEAR" }, tiered)).toMatchObject({ - ktLampRatingNC: 15, ktLampRatingHC: 16, ktLampRatingEXHC: 17, }); expect(run({ lamp: "FULL COMBO" }, tiered)).toMatchObject({ - ktLampRatingNC: 15, ktLampRatingHC: 16, ktLampRatingEXHC: 17, }); expect(run({ lamp: "HARD CLEAR" }, { ncTier: mkTier(15) })).toMatchObject({ - ktLampRatingNC: 15, ktLampRatingHC: 15, ktLampRatingEXHC: 0, }); @@ -313,27 +306,22 @@ describe("IIDX_IMPL (unit)", () => { } expect(run({ lamp: "EASY CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({ - ktLampRatingNC: 0, ktLampRatingHC: 0, ktLampRatingEXHC: 0, }); expect(run({ lamp: "CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({ - ktLampRatingNC: 15, ktLampRatingHC: 0, ktLampRatingEXHC: 0, }); expect(run({ lamp: "HARD CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({ - ktLampRatingNC: 15, ktLampRatingHC: 15, ktLampRatingEXHC: 0, }); expect(run({ lamp: "EX HARD CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({ - ktLampRatingNC: 15, ktLampRatingHC: 15, ktLampRatingEXHC: 15, }); expect(run({ lamp: "FULL COMBO" }, { dpTier: mkTier(15) })).toMatchObject({ - ktLampRatingNC: 15, ktLampRatingHC: 15, ktLampRatingEXHC: 15, }); diff --git a/typescript/server/src/game-implementations/games/iidx.ts b/typescript/server/src/game-implementations/games/iidx.ts index f49382cb3..cb5e2e71c 100644 --- a/typescript/server/src/game-implementations/games/iidx.ts +++ b/typescript/server/src/game-implementations/games/iidx.ts @@ -29,23 +29,19 @@ type IIDXGames = "iidx-dp" | "iidx-sp"; const IIDX_SESSION_CALCS: GPTSessionCalcs = (arr) => ({ BPI: SessionAvgBest10For("BPI")(arr), ktLampRating: SessionAvgBest10For("ktLampRating")(arr), - ktLampRatingNC: SessionAvgBest10For("ktLampRatingNC")(arr), ktLampRatingHC: SessionAvgBest10For("ktLampRatingHC")(arr), ktLampRatingEXHC: SessionAvgBest10For("ktLampRatingEXHC")(arr), }); const IIDX_PROFILE_CALCS: GPTProfileCalcs = async (game, userID) => { - const [BPI, ktLampRating, ktLampRatingNC, ktLampRatingHC, ktLampRatingEXHC] = await Promise.all( - [ - ProfileAvgBestN("BPI", 20, true)(game, userID), - ProfileAvgBestN("ktLampRating", 20)(game, userID), - ProfileAvgBestN("ktLampRatingNC", 20)(game, userID), - ProfileAvgBestN("ktLampRatingHC", 20)(game, userID), - ProfileAvgBestN("ktLampRatingEXHC", 20)(game, userID), - ], - ); + const [BPI, ktLampRating, ktLampRatingHC, ktLampRatingEXHC] = await Promise.all([ + ProfileAvgBestN("BPI", 20, true)(game, userID), + ProfileAvgBestN("ktLampRating", 20)(game, userID), + ProfileAvgBestN("ktLampRatingHC", 20)(game, userID), + ProfileAvgBestN("ktLampRatingEXHC", 20)(game, userID), + ]); - return { BPI, ktLampRating, ktLampRatingNC, ktLampRatingHC, ktLampRatingEXHC }; + return { BPI, ktLampRating, ktLampRatingHC, ktLampRatingEXHC }; }; const IIDX_MERGERS: Array> = [ @@ -156,11 +152,6 @@ export const IIDX_SP_IMPL: GameImplementation<"iidx-sp"> = { ktLampRating = 0; } - const atLeastNcClear = - scoreData.lamp === "CLEAR" || - scoreData.lamp === "HARD CLEAR" || - scoreData.lamp === "EX HARD CLEAR" || - scoreData.lamp === "FULL COMBO"; const atLeastHcClear = scoreData.lamp === "HARD CLEAR" || scoreData.lamp === "EX HARD CLEAR" || @@ -168,14 +159,12 @@ export const IIDX_SP_IMPL: GameImplementation<"iidx-sp"> = { const atLeastExhcClear = scoreData.lamp === "EX HARD CLEAR" || scoreData.lamp === "FULL COMBO"; - const ktLampRatingNC = atLeastNcClear ? ncValue : 0; const ktLampRatingHC = atLeastHcClear ? hcValue : 0; const ktLampRatingEXHC = atLeastExhcClear ? exhcValue : 0; return { BPI: bpi, ktLampRating, - ktLampRatingNC, ktLampRatingHC, ktLampRatingEXHC, }; @@ -235,11 +224,6 @@ export const IIDX_DP_IMPL: GameImplementation<"iidx-dp"> = { ktLampRating = 0; } - const atLeastNcClear = - scoreData.lamp === "CLEAR" || - scoreData.lamp === "HARD CLEAR" || - scoreData.lamp === "EX HARD CLEAR" || - scoreData.lamp === "FULL COMBO"; const atLeastHcClear = scoreData.lamp === "HARD CLEAR" || scoreData.lamp === "EX HARD CLEAR" || @@ -247,14 +231,12 @@ export const IIDX_DP_IMPL: GameImplementation<"iidx-dp"> = { const atLeastExhcClear = scoreData.lamp === "EX HARD CLEAR" || scoreData.lamp === "FULL COMBO"; - const ktLampRatingNC = atLeastNcClear ? ecValue : 0; const ktLampRatingHC = atLeastHcClear ? ecValue : 0; const ktLampRatingEXHC = atLeastExhcClear ? ecValue : 0; return { BPI: bpi, ktLampRating, - ktLampRatingNC, ktLampRatingHC, ktLampRatingEXHC, }; diff --git a/typescript/server/src/game-implementations/games/itg.ts b/typescript/server/src/game-implementations/games/itg.ts index 70fcb196a..84058b2a1 100644 --- a/typescript/server/src/game-implementations/games/itg.ts +++ b/typescript/server/src/game-implementations/games/itg.ts @@ -12,7 +12,7 @@ import { GoalFmtPercent, GradeGoalFormatter } from "./_common"; export const ITG_STAMINA_IMPL: GameImplementation<"itg-stamina"> = { chartSpecificValidators: {}, scoreDeriver: (scoreData, _chart) => ({ - // *important*: don't check survivedPercent === 100 — floating point can + // *important*: don't check survivedPercent === 100 - floating point can // produce a 100% survived-percent on a fail for very long charts. finalPercent: scoreData.lamp === "FAILED" ? scoreData.survivedPercent : 100 + scoreData.scorePercent, diff --git a/typescript/server/src/game-implementations/utils/pb-merge.test.ts b/typescript/server/src/game-implementations/utils/pb-merge.test.ts index e9283e663..f6bccca4a 100644 --- a/typescript/server/src/game-implementations/utils/pb-merge.test.ts +++ b/typescript/server/src/game-implementations/utils/pb-merge.test.ts @@ -60,7 +60,6 @@ describe("CreatePBMergeFor (Postgres)", () => { judgements: s.mongo.judgements ?? {}, }); - // eslint-disable-next-line no-await-in-loop await DB.insertInto("score") .values({ id: scoreId, diff --git a/typescript/server/src/job-queue-worker.ts b/typescript/server/src/job-queue-worker.ts index da05e6892..56db91d82 100644 --- a/typescript/server/src/job-queue-worker.ts +++ b/typescript/server/src/job-queue-worker.ts @@ -10,8 +10,12 @@ import { processScoreImportJobFromPayload } from "#lib/score-import/worker/score import { Env } from "#lib/setup/config"; import { ClosePgConnection } from "#services/pg/db"; import { CloseRedisConnection } from "#services/redis/redis"; +import { Sleep } from "#utils/misc"; +import { writeFileSync } from "fs"; import { applyMigrations } from "tachi-db-migration-engine"; +const HEARTBEAT_FILE = "/tmp/worker-heartbeat"; + const POLL_MS = 250; process.on("uncaughtException", (err, origin) => { @@ -21,31 +25,32 @@ process.on("uncaughtException", (err, origin) => { void bootstrap(); -/** - * Often run by `just server` (one or more via `TACHI_SERVER_JOB_WORKER_COUNT`); each process claims - * with `FOR UPDATE SKIP LOCKED`. - */ async function bootstrap() { await applyMigrations(Env.POSTGRES_URL, Env.MIGRATIONS_DIR); log.info({ bootInfo: true }, "tachi job-queue worker starting (Postgres job_queue)."); + let stopping = false; const shutdown = () => { stopping = true; }; process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); + // eslint-disable-next-line no-unmodified-loop-condition while (!stopping) { + writeFileSync(HEARTBEAT_FILE, Date.now().toString()); + const job = await ClaimNextJob(); + if (!job) { if (stopping) { break; } - await new Promise((r) => { - setTimeout(r, POLL_MS); - }); + + await Sleep(POLL_MS); continue; } + try { switch (job.job_kind) { case JOB_KIND_SCORE_IMPORT: @@ -61,6 +66,7 @@ async function bootstrap() { await MarkJobFailed(job.row_id); } } + log.info("Job worker loop stopped, closing resources."); await CloseScoreImportQueue(); await CloseRedisConnection(); diff --git a/typescript/server/src/lib/activity/activity.ts b/typescript/server/src/lib/activity/activity.ts index 3e8430892..8281232fc 100644 --- a/typescript/server/src/lib/activity/activity.ts +++ b/typescript/server/src/lib/activity/activity.ts @@ -38,7 +38,7 @@ export type ActivityConstraint = { userID?: integer | { $in: Array }; }; -/** Kysely dynamic column refs — same pattern as `whereUserIdOnGoalSub` in `#utils/db`. */ +/** Kysely dynamic column refs - same pattern as `whereUserIdOnGoalSub` in `#utils/db`. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OperandExpressionFactory varies by query (session / join / class_achievement). type ActivityWhereEb = any; diff --git a/typescript/server/src/lib/db-formats/pb.ts b/typescript/server/src/lib/db-formats/pb.ts index 70c0630cf..4dbe1f46b 100644 --- a/typescript/server/src/lib/db-formats/pb.ts +++ b/typescript/server/src/lib/db-formats/pb.ts @@ -92,13 +92,13 @@ export const SELECT_PB_DOCUMENT_WITH_LEADERBOARD = [ export async function ToPbScoreDocument(row: PbDocumentJoinRow): Promise { const composedRows = await DB.selectFrom("pb_composed_from") .where("pb_id", "=", row.row_id) - .select("score_id") + .select(["score_id", "merge_name"]) .execute(); const composedFrom: [PBReference, ...PBReference[]] = composedRows.length > 0 - ? (composedRows.map((c, i) => ({ - name: i === 0 ? "Primary" : `Merge${i}`, + ? (composedRows.map((c) => ({ + name: c.merge_name, scoreID: c.score_id, })) as [PBReference, ...PBReference[]]) : [{ name: "Primary", scoreID: "unknown" }]; diff --git a/typescript/server/src/lib/email/client.ts b/typescript/server/src/lib/email/client.ts index fb1e5c599..51d8816d3 100644 --- a/typescript/server/src/lib/email/client.ts +++ b/typescript/server/src/lib/email/client.ts @@ -23,7 +23,7 @@ try { } else { transporter.verify((err) => { if (err) { - // Do NOT throw here — this is an async callback and the throw would + // Do NOT throw here - this is an async callback and the throw would // become an uncaught exception, crashing the process. Log and exit instead. log.fatal({ err }, `Could not connect to email server.`); process.exit(1); @@ -65,7 +65,7 @@ export function SendEmail( headers: transporter.options.headers, }) .catch((err: unknown) => { - log.info( + log.error( { err, subject, @@ -74,5 +74,6 @@ export function SendEmail( }, `Failed to send email to ${to}.`, ); + throw err; }); } diff --git a/typescript/server/src/lib/folders/folder-query.ts b/typescript/server/src/lib/folders/folder-query.ts index 6175ccf0f..5f9c2c999 100644 --- a/typescript/server/src/lib/folders/folder-query.ts +++ b/typescript/server/src/lib/folders/folder-query.ts @@ -5,7 +5,7 @@ import { type Kysely, sql } from "kysely"; /** * Folder membership SQL (see tests in `folders.test.ts`). - * Does not import the global `DB` module — safe for scripts that only set `POSTGRES_URL`. + * Does not import the global `DB` module - safe for scripts that only set `POSTGRES_URL`. */ export async function BuildFolderQuery(folderID: string, db: Kysely) { const folder = await db @@ -75,7 +75,7 @@ export async function GetFolderChartIDs(folderID: string, db: Kysely) /** * Evaluates `folder.where` SQL (same as a full rebuild). Used by - * {@link rebuildFolderChartLookup}; do not call for normal reads — use {@link GetFolderChartIDs}. + * {@link rebuildFolderChartLookup}; do not call for normal reads - use {@link GetFolderChartIDs}. */ export async function computeFolderChartIdsFromFolderSql(folderID: string, db: Kysely) { const { folderQuery } = await BuildFolderQuery(folderID, db); diff --git a/typescript/server/src/lib/jobs/cron/cron-registry.ts b/typescript/server/src/lib/jobs/cron/cron-registry.ts index 1d2926889..b2eda8e95 100644 --- a/typescript/server/src/lib/jobs/cron/cron-registry.ts +++ b/typescript/server/src/lib/jobs/cron/cron-registry.ts @@ -46,7 +46,7 @@ function buildList(): Array { }, { id: "drain_stats_queues", - schedule: "* * * * *", + schedule: "* * * * *", // TODO(zk): really? this is so lazy description: "Drain score_rederive, pb_dirty, session_dirty, game_profile_dirty (ordered)", run: drainStatsQueuesInOrder, diff --git a/typescript/server/src/lib/jobs/drain-dirty-queues.ts b/typescript/server/src/lib/jobs/drain-dirty-queues.ts index 46d445a54..9e9bb9ee7 100644 --- a/typescript/server/src/lib/jobs/drain-dirty-queues.ts +++ b/typescript/server/src/lib/jobs/drain-dirty-queues.ts @@ -291,7 +291,7 @@ export async function drainStatsQueuesInOrder(): Promise { /** * Drain `score_rederive`, then `pb_dirty`, `session_dirty`, and `game_profile_dirty`, * repeating until a full pass moves nothing. No per-tick row cap (unlike the cron - * drain) — intended for admin synchronous recalc. + * drain) - intended for admin synchronous recalc. */ export async function drainStatsQueuesFully(): Promise { for (;;) { @@ -345,7 +345,7 @@ export async function drainStatsQueuesFully(): Promise { /** * Drain `pb_dirty` then `session_dirty` and `game_profile_dirty`, repeating until - * idle. No per-tick row cap — intended for admin synchronous PB recalc. + * idle. No per-tick row cap - intended for admin synchronous PB recalc. */ export async function drainPbDirtyAndDownstream(): Promise { for (;;) { diff --git a/typescript/server/src/lib/router/typed-router.ts b/typescript/server/src/lib/router/typed-router.ts index c412155b0..d83d28e87 100644 --- a/typescript/server/src/lib/router/typed-router.ts +++ b/typescript/server/src/lib/router/typed-router.ts @@ -192,7 +192,7 @@ export class TypedRouter { if (!outputParseResult.success) { log.error( { route, issues: outputParseResult.error.issues }, - "TypedRouter: output validation failed — handler returned body not matching spec output schema", + "TypedRouter: output validation failed - handler returned body not matching spec output schema", ); } diff --git a/typescript/server/src/lib/score-import/framework/orphans/orphans.ts b/typescript/server/src/lib/score-import/framework/orphans/orphans.ts index 3b74f6a4e..c0903332d 100644 --- a/typescript/server/src/lib/score-import/framework/orphans/orphans.ts +++ b/typescript/server/src/lib/score-import/framework/orphans/orphans.ts @@ -155,7 +155,7 @@ export async function deleteOrphanScoreForUser( /** * Lists orphan_score rows for a user, newest first, with keyset pagination on `row_id` + `time_inserted`. - * @param afterRowID — `row_id` of the last item from the previous page (omit on first page). + * @param afterRowID - `row_id` of the last item from the previous page (omit on first page). */ export async function listOrphanScoresForUser(opts: { afterRowID?: string; diff --git a/typescript/server/src/lib/score-import/framework/pb/upsert-pb-pg.ts b/typescript/server/src/lib/score-import/framework/pb/upsert-pb-pg.ts index 9eaf65df9..06c53a459 100644 --- a/typescript/server/src/lib/score-import/framework/pb/upsert-pb-pg.ts +++ b/typescript/server/src/lib/score-import/framework/pb/upsert-pb-pg.ts @@ -103,6 +103,7 @@ export async function upsertPbFromMongoDoc( pbDoc.composedFrom.map((ref) => ({ pb_id: pbId, score_id: ref.scoreID, + merge_name: ref.name, })), ) .execute(); diff --git a/typescript/server/src/lib/score-import/framework/pg/finalize-import-pg.ts b/typescript/server/src/lib/score-import/framework/pg/finalize-import-pg.ts index 2f06b2ee9..22f7940cf 100644 --- a/typescript/server/src/lib/score-import/framework/pg/finalize-import-pg.ts +++ b/typescript/server/src/lib/score-import/framework/pg/finalize-import-pg.ts @@ -99,6 +99,7 @@ export async function finalizeImportToPostgres( await db .insertInto("import_game") .values(games.map((game) => ({ id: importID, game }))) + .onConflict((oc) => oc.columns(["id", "game"]).doNothing()) .execute(); } @@ -113,6 +114,7 @@ export async function finalizeImportToPostgres( message: err.message, })), ) + .onConflict((oc) => oc.column("row_id").doNothing()) .execute(); } @@ -130,6 +132,7 @@ export async function finalizeImportToPostgres( new: d.new, })), ) + .onConflict((oc) => oc.column("row_id").doNothing()) .execute(); } @@ -145,6 +148,7 @@ export async function finalizeImportToPostgres( type: s.type.toLowerCase() as "appended" | "created", })), ) + .onConflict((oc) => oc.columns(["import_id", "session_id"]).doNothing()) .execute(); } @@ -169,6 +173,7 @@ export async function finalizeImportToPostgres( new_progress_human: g.new.progressHuman, })), ) + .onConflict((oc) => oc.column("row_id").doNothing()) .execute(); } @@ -187,6 +192,7 @@ export async function finalizeImportToPostgres( new_progress: q.new.progress, })), ) + .onConflict((oc) => oc.column("row_id").doNothing()) .execute(); } @@ -211,23 +217,6 @@ export async function finalizeImportToPostgres( quest_secs: timing.questMs, total_secs: timing.totalMs, }) - .onConflict((oc) => - oc.column("id").doUpdateSet({ - timestamp: tsNow, - import_secs_avg: timing.importMs / n, - import_parse_secs_avg: timing.importParseMs / n, - pb_secs_avg: timing.pbMs / n, - session_secs_avg: timing.sessionMs / n, - parse_secs: timing.parseMs, - import_secs: timing.importMs, - import_parse_secs: timing.importParseMs, - session_secs: timing.sessionMs, - pb_secs: timing.pbMs, - ugs_secs: timing.ugsMs, - goal_secs: timing.goalMs, - quest_secs: timing.questMs, - total_secs: timing.totalMs, - }), - ) + .onConflict((oc) => oc.column("id").doNothing()) .execute(); } diff --git a/typescript/server/src/lib/score-import/framework/score-import.test.ts b/typescript/server/src/lib/score-import/framework/score-import.test.ts index a5650d040..9723ff270 100644 --- a/typescript/server/src/lib/score-import/framework/score-import.test.ts +++ b/typescript/server/src/lib/score-import/framework/score-import.test.ts @@ -2,8 +2,14 @@ import { seedUser } from "#actions/test-utils/api-tokens"; import { CDNRetrieve } from "#lib/cdn/cdn"; import { LoadImportDocumentById } from "#lib/db-formats/import-document"; import DB from "#services/pg/db"; -import { FakeSmallBatchManual, Testing511Song, Testing511SPA } from "#test-utils/test-data"; +import { + FakeSmallBatchManual, + Testing511Song, + Testing511SPA, + TestingJubeatSong, +} from "#test-utils/test-data"; import { Sleep } from "#utils/misc"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; import { beforeEach, describe, expect, it } from "vitest"; import { MakeScoreImport } from "./score-import"; @@ -110,3 +116,273 @@ describe("MakeScoreImport (ported from score-import.oldtest.ts)", () => { }, ); }); + +const JUBEAT_ARRAY_IG_FIRST = 80000037; + +function mkBatchManualMulterFile(body: object): Express.Multer.File { + return { + buffer: Buffer.from(JSON.stringify(body), "utf-8"), + } as Express.Multer.File; +} + +function jubeatScoreLine(opts: { + identifier: number | string; + musicRate?: number; + score?: number; + timeAchieved: number; +}) { + return { + difficulty: "ADV" as const, + identifier: String(opts.identifier), + judgements: { + good: 0, + great: 0, + miss: 0, + perfect: 100, + poor: 0, + }, + lamp: "CLEAR" as const, + matchType: "inGameID" as const, + musicRate: opts.musicRate ?? 96.5, + score: opts.score ?? 920_000, + timeAchieved: opts.timeAchieved, + }; +} + +async function seedJubeatChartArrayInGameID(opts: { chartId: string; legacyId: number }) { + await DB.insertInto("song") + .values({ + id: `${TestingJubeatSong.id}-smoke-${opts.chartId}`, + legacy_id: opts.legacyId, + game_group: "jubeat", + title: TestingJubeatSong.title, + artist: TestingJubeatSong.artist, + search_terms: TestingJubeatSong.searchTerms, + alt_titles: TestingJubeatSong.altTitles, + data: TestingJubeatSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: opts.chartId, + legacy_id: String(opts.legacyId), + game: "jubeat", + song_id: `${TestingJubeatSong.id}-smoke-${opts.chartId}`, + difficulty: "ADV", + level: "6", + level_num: 6, + is_primary: true, + versions: ["festo"], + data: JSON.stringify({ + inGameID: [JUBEAT_ARRAY_IG_FIRST, 50_000_020], + noteCount: 100, + musicBar: [0, 1, 2, 3], + }), + }) + .execute(); +} + +async function seedJubeatChartScalarInGameID(opts: { + chartId: string; + inGameID: number; + legacyId: number; + songIdSuffix: string; +}) { + await DB.insertInto("song") + .values({ + id: `${TestingJubeatSong.id}-${opts.songIdSuffix}`, + legacy_id: opts.legacyId, + game_group: "jubeat", + title: TestingJubeatSong.title, + artist: TestingJubeatSong.artist, + search_terms: TestingJubeatSong.searchTerms, + alt_titles: TestingJubeatSong.altTitles, + data: TestingJubeatSong.data, + fts_document: "", + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: opts.chartId, + legacy_id: String(opts.legacyId), + game: "jubeat", + song_id: `${TestingJubeatSong.id}-${opts.songIdSuffix}`, + difficulty: "ADV", + level: "6", + level_num: 6, + is_primary: true, + versions: ["festo"], + data: JSON.stringify({ + inGameID: opts.inGameID, + noteCount: 100, + musicBar: [0, 1, 2, 3], + }), + }) + .execute(); +} + +describe("batch-manual score import (smoke)", () => { + beforeEach(async () => { + await seedUser({ + username: "test_batch_manual_smoke", + email: "batch-manual-smoke@example.com", + withCredential: true, + withSettings: true, + }); + }); + + it("file/batch-manual: jubeat inGameID matches chart with array inGameID and commits scores", async () => { + const chartId = "chart-smoke-jubeat-array-ingameid"; + await seedJubeatChartArrayInGameID({ chartId, legacyId: 9_001 }); + + const baseMs = Date.UTC(2024, 5, 1, 12, 0, 0, 0); + const batch = { + meta: { game: "jubeat", playtype: "Single", service: "smoke-test" }, + scores: [jubeatScoreLine({ identifier: JUBEAT_ARRAY_IG_FIRST, timeAchieved: baseMs })], + }; + + const importID = "import-smoke-jubeat-array"; + const doc = await MakeScoreImport({ + importID, + importType: "file/batch-manual", + parserArguments: [mkBatchManualMulterFile(batch), {}], + userID: 1, + userIntent: true, + }); + + expect(doc.scoreIDs).toHaveLength(1); + expect(doc.errors).toHaveLength(0); + + const nCommitted = await DB.selectFrom("score") + .select((eb) => eb.fn.countAll().as("c")) + .where("import_id", "=", importID) + .where("committed", "=", true) + .executeTakeFirst(); + + expect(Number(nCommitted?.c)).toBe(1); + }); + + it("second import of identical data reports no sessions", async () => { + await seedJubeatChartScalarInGameID({ + chartId: "chart-dedup-sessions", + inGameID: 20_000_001, + legacyId: 9_010, + songIdSuffix: "dedup-sessions-song", + }); + + const baseMs = Date.UTC(2024, 3, 1, 14, 0, 0, 0); + const batch = { + meta: { game: "jubeat", playtype: "Single", service: "dedup-test" }, + scores: [jubeatScoreLine({ identifier: 20_000_001, timeAchieved: baseMs })], + }; + + const doc1 = await MakeScoreImport({ + importID: "import-dedup-sessions-1", + importType: "file/batch-manual", + parserArguments: [mkBatchManualMulterFile(batch), {}], + userID: 1, + userIntent: true, + }); + + // Sanity check: first import should have created exactly one session + expect(doc1.createdSessions).toHaveLength(1); + expect(doc1.createdSessions[0]?.type).toBe("Created"); + + const doc2 = await MakeScoreImport({ + importID: "import-dedup-sessions-2", + importType: "file/batch-manual", + parserArguments: [mkBatchManualMulterFile(batch), {}], + userID: 1, + userIntent: true, + }); + + // Second import of identical data should claim no sessions — no new scores + // were actually committed, so no session was touched. + expect(doc2.createdSessions).toHaveLength(0); + }); + + it("file/batch-manual: two session groups appending the same nearby session finalizes import_session without error", async () => { + await seedJubeatChartScalarInGameID({ + chartId: "chart-smoke-jubeat-dup-a", + inGameID: 10_000_001, + legacyId: 9_002, + songIdSuffix: "smoke2-song-a", + }); + await seedJubeatChartScalarInGameID({ + chartId: "chart-smoke-jubeat-dup-b", + inGameID: 10_000_002, + legacyId: 9_003, + songIdSuffix: "smoke2-song-b", + }); + + const baseMs = Date.UTC(2024, 5, 10, 8, 0, 0, 0); + const sessionId = `Q${"a".repeat(40)}`; + + const sixHoursMs = 6 * 60 * 60 * 1000; + const oneHourMs = 60 * 60 * 1000; + + await DB.insertInto("session") + .values({ + id: sessionId, + user_id: 1, + game: "jubeat", + name: "preseed-smoke", + description: null, + time_inserted: UnixMillisecondsToISO8601(Date.now()), + time_started: UnixMillisecondsToISO8601(baseMs), + time_ended: UnixMillisecondsToISO8601(baseMs + sixHoursMs), + calculated_data: JSON.stringify({ jubility: null }), + highlight: false, + }) + .execute(); + + const batch = { + meta: { game: "jubeat", playtype: "Single", service: "smoke-test" }, + scores: [ + jubeatScoreLine({ + identifier: 10_000_001, + musicRate: 95.0, + score: 910_000, + timeAchieved: baseMs + oneHourMs, + }), + jubeatScoreLine({ + identifier: 10_000_002, + musicRate: 97.0, + score: 925_000, + timeAchieved: baseMs + 5 * oneHourMs, + }), + ], + }; + + const importID = "import-smoke-jubeat-session-meta"; + const doc = await MakeScoreImport({ + importID, + importType: "file/batch-manual", + parserArguments: [mkBatchManualMulterFile(batch), {}], + userID: 1, + userIntent: true, + }); + + expect(doc.scoreIDs).toHaveLength(2); + expect(doc.errors).toHaveLength(0); + + const nCommitted = await DB.selectFrom("score") + .select((eb) => eb.fn.countAll().as("c")) + .where("import_id", "=", importID) + .where("committed", "=", true) + .executeTakeFirst(); + + expect(Number(nCommitted?.c)).toBe(2); + + const importSessionRows = await DB.selectFrom("import_session") + .select("session_id") + .where("import_id", "=", importID) + .execute(); + + expect(importSessionRows).toHaveLength(1); + expect(importSessionRows[0]?.session_id).toBe(sessionId); + }); +}); diff --git a/typescript/server/src/lib/score-import/framework/score-import.ts b/typescript/server/src/lib/score-import/framework/score-import.ts index f4fc90a16..f611b5d2b 100644 --- a/typescript/server/src/lib/score-import/framework/score-import.ts +++ b/typescript/server/src/lib/score-import/framework/score-import.ts @@ -13,7 +13,7 @@ import ScoreImportFatalError from "./score-importing/score-import-error"; * Makes a score import given ScoreImportJobData (same process as the API). * * When `USE_EXTERNAL_SCORE_IMPORT_WORKER` is true, HTTP routes call - * {@link EnqueueScoreImportJob} instead — this function is only used for the + * {@link EnqueueScoreImportJob} instead - this function is only used for the * inline (non-queued) path. */ export async function MakeScoreImport( diff --git a/typescript/server/src/lib/score-import/framework/score-importing/score-id.test.ts b/typescript/server/src/lib/score-import/framework/score-importing/score-id.test.ts index fd9be2f49..e7d51e00a 100644 --- a/typescript/server/src/lib/score-import/framework/score-importing/score-id.test.ts +++ b/typescript/server/src/lib/score-import/framework/score-importing/score-id.test.ts @@ -155,7 +155,7 @@ describe("CreateScoreID", () => { ); }); - it("is deterministic (canary — changing the algorithm is a breaking change)", () => { + it("is deterministic (canary - changing the algorithm is a breaking change)", () => { const scoreID = CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, Testing511SPA.chartID); expect(scoreID).toBe("T5d669c4d5d6ca80761e87698acd77c51d2bed95b64ab76e65952dbca7c26bc81"); diff --git a/typescript/server/src/lib/score-import/framework/score-importing/score-import-main.ts b/typescript/server/src/lib/score-import/framework/score-importing/score-import-main.ts index 66928a73e..8b2e5f707 100644 --- a/typescript/server/src/lib/score-import/framework/score-importing/score-import-main.ts +++ b/typescript/server/src/lib/score-import/framework/score-importing/score-import-main.ts @@ -303,7 +303,7 @@ export async function HandlePostImportSteps( // best score). const pbTimeStart = process.hrtime.bigint(); - // processing PBs is a playtype-specific action. As such, we need to split chartIDs + // processing PBs is a game-specific action. As such, we need to split chartIDs // accordingly const chartIDsSeparatedByGame: ChartIDGameMap = {}; diff --git a/typescript/server/src/lib/score-import/framework/score-importing/score-importing.ts b/typescript/server/src/lib/score-import/framework/score-importing/score-importing.ts index 4dea17405..6f424ab83 100644 --- a/typescript/server/src/lib/score-import/framework/score-importing/score-importing.ts +++ b/typescript/server/src/lib/score-import/framework/score-importing/score-importing.ts @@ -407,7 +407,7 @@ async function HydrateCheckAndInsertScore( } } else { if (importId === null && !directCommit) { - log.debug(`Skipped score — missing import id.`); + log.debug(`Skipped score - missing import id.`); return null; } diff --git a/typescript/server/src/lib/score-import/framework/sessions/sessions.ts b/typescript/server/src/lib/score-import/framework/sessions/sessions.ts index edbdba4b7..56bdc4ed4 100644 --- a/typescript/server/src/lib/score-import/framework/sessions/sessions.ts +++ b/typescript/server/src/lib/score-import/framework/sessions/sessions.ts @@ -29,6 +29,8 @@ const TWO_HOURS = ONE_HOUR * 2; export async function CreateSessions(userID: integer, scoreGameMap: ScoreGameMap, log: KtLogger) { const allSessionInfo = []; + console.dir(scoreGameMap); + for (const [game, scores] of Object.entries(scoreGameMap)) { // eslint-disable-next-line no-await-in-loop const sessionInfo = await LoadScoresIntoSessions(userID, scores, game as V3Game, log); @@ -36,6 +38,8 @@ export async function CreateSessions(userID: integer, scoreGameMap: ScoreGameMap allSessionInfo.push(...sessionInfo); } + console.dir(allSessionInfo); + return allSessionInfo; } diff --git a/typescript/server/src/lib/score-import/framework/ugpt-stats/update-ugpt-stats.test.ts b/typescript/server/src/lib/score-import/framework/ugpt-stats/update-ugpt-stats.test.ts index 5ac12e86f..dcfbccf6c 100644 --- a/typescript/server/src/lib/score-import/framework/ugpt-stats/update-ugpt-stats.test.ts +++ b/typescript/server/src/lib/score-import/framework/ugpt-stats/update-ugpt-stats.test.ts @@ -126,7 +126,6 @@ describe("UpdateUsersGamePlaytypeStats (ported from update-ugpt-stats.oldtest.ts const ratings = typeof gp.ratings === "string" ? JSON.parse(gp.ratings) : gp.ratings; expect(ratings).toMatchObject({ ktLampRating: expect.any(Number), - ktLampRatingNC: expect.any(Number), ktLampRatingHC: expect.any(Number), ktLampRatingEXHC: expect.any(Number), }); @@ -175,7 +174,6 @@ describe("UpdateUsersGamePlaytypeStats (ported from update-ugpt-stats.oldtest.ts calculatedData: { BPI: 10.1, ktLampRating: e, - ktLampRatingNC: e, ktLampRatingHC: e, ktLampRatingEXHC: e, }, diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-wacca/parser.ts b/typescript/server/src/lib/score-import/import-types/api/myt-wacca/parser.ts index a3e4d7dd7..ef85b0491 100644 --- a/typescript/server/src/lib/score-import/import-types/api/myt-wacca/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/api/myt-wacca/parser.ts @@ -23,7 +23,7 @@ async function* streamPlaylog(apiId: string, log: KtLogger): AsyncIterable { ); }); - it("No Playtype — game group without playtype is not a V3 game", () => { + it("No Playtype - game group without playtype is not a V3 game", () => { expectThrowsFatal( () => ParserFn( diff --git a/typescript/server/src/lib/search/session-search.ts b/typescript/server/src/lib/search/session-search.ts index 1f488b36d..cad54bdb4 100644 --- a/typescript/server/src/lib/search/session-search.ts +++ b/typescript/server/src/lib/search/session-search.ts @@ -47,7 +47,7 @@ async function finalizeHits( /** * Search a user's sessions for one GPT: `websearch_to_tsquery` on `session.textsearch`, optional - * short-query exact match, then pg_trgm / ILIKE — same strategy as + * short-query exact match, then pg_trgm / ILIKE - same strategy as * {@link SearchSongsForGameFtsAndTrgm} / {@link SearchFoldersForGameFtsAndTrgm}. * * Returns full {@link SessionDocument}s (no follow-up `WHERE id IN (...)` on `session`). diff --git a/typescript/server/src/lib/search/songs.bench.ts b/typescript/server/src/lib/search/songs.bench.ts index ecbea6ab7..c892fe4ff 100644 --- a/typescript/server/src/lib/search/songs.bench.ts +++ b/typescript/server/src/lib/search/songs.bench.ts @@ -31,23 +31,23 @@ describe("Postgres song search (full seeds)", () => { await importSeeds(DB, SEEDS_DIR); }, 600_000); - bench("iidx FTS — gradius (title)", async () => { + bench("iidx FTS - gradius (title)", async () => { await SearchSongsForGameFtsAndTrgm(GAME_IIDX, "gradius", 50); }); - bench("iidx FTS — taka (artist)", async () => { + bench("iidx FTS - taka (artist)", async () => { await SearchSongsForGameFtsAndTrgm(GAME_IIDX, "taka", 50); }); - bench("iidx short query — ab (FTS + trgm)", async () => { + bench("iidx short query - ab (FTS + trgm)", async () => { await SearchSongsForGameFtsAndTrgm(GAME_IIDX, "ab", 50); }); - bench("iidx sparse — xyzunlikely (mostly trgm / empty FTS)", async () => { + bench("iidx sparse - xyzunlikely (mostly trgm / empty FTS)", async () => { await SearchSongsForGameFtsAndTrgm(GAME_IIDX, "xyzunlikely", 50); }); - bench("bms FTS — fezike (artist)", async () => { + bench("bms FTS - fezike (artist)", async () => { await SearchSongsForGameFtsAndTrgm(GAME_BMS, "fezike", 50); }); diff --git a/typescript/server/src/lib/search/songs.ts b/typescript/server/src/lib/search/songs.ts index c3ce56bd8..9d400dc92 100644 --- a/typescript/server/src/lib/search/songs.ts +++ b/typescript/server/src/lib/search/songs.ts @@ -51,7 +51,7 @@ export type SongSearchRow = { /** * Indexed song search: PostgreSQL FTS (tsvector) plus optional pg_trgm / ILIKE fallback - * (Zenith-style — no full-table load, no huge IN lists). + * (Zenith-style - no full-table load, no huge IN lists). * * Queries with length ≤ {@link SHORT_QUERY_STRICT_MAX_LEN} also run an **exact** match pass * (title, artist, `search_terms`, `alt_titles`) with a boosted rank; substring diff --git a/typescript/server/src/lib/setup/build-email-config.test.ts b/typescript/server/src/lib/setup/build-email-config.test.ts index 2e421f506..b83f09945 100644 --- a/typescript/server/src/lib/setup/build-email-config.test.ts +++ b/typescript/server/src/lib/setup/build-email-config.test.ts @@ -7,10 +7,12 @@ describe("buildEmailConfig", () => { expect(() => buildEmailConfig({})).toThrow(/TACHI_EMAIL_FROM is required/u); }); - it("builds Postmark transport when TACHI_EMAIL_AUTH_POSTMARK is true", () => { + it("builds Postmark transport when host is smtp.postmarkapp.com", () => { const cfg = buildEmailConfig({ TACHI_EMAIL_FROM: "from@example.com", - TACHI_EMAIL_AUTH_POSTMARK: "true", + TACHI_EMAIL_HOST: "smtp.postmarkapp.com", + TACHI_EMAIL_PORT: "587", + TACHI_EMAIL_SECURE: "false", TACHI_EMAIL_AUTH_PASS: "pm-token", }); expect(cfg.TRANSPORT_OPS).toMatchObject({ @@ -21,10 +23,11 @@ describe("buildEmailConfig", () => { }); }); - it("uses TACHI_EMAIL_AUTH_USER when POSTMARK and only user is set", () => { + it("uses TACHI_EMAIL_AUTH_USER for Postmark when only user is set", () => { const cfg = buildEmailConfig({ TACHI_EMAIL_FROM: "from@example.com", - TACHI_EMAIL_AUTH_POSTMARK: "1", + TACHI_EMAIL_HOST: "smtp.postmarkapp.com", + TACHI_EMAIL_PORT: "587", TACHI_EMAIL_AUTH_USER: "only-user-token", }); expect(cfg.TRANSPORT_OPS).toMatchObject({ @@ -32,19 +35,32 @@ describe("buildEmailConfig", () => { }); }); - it("throws when POSTMARK is true but no token is given", () => { + it("matches Postmark host case-insensitively for auth token rules", () => { + const cfg = buildEmailConfig({ + TACHI_EMAIL_FROM: "from@example.com", + TACHI_EMAIL_HOST: "SMTP.POSTMARKAPP.COM", + TACHI_EMAIL_PORT: "587", + TACHI_EMAIL_AUTH_PASS: "pm-token", + }); + expect(cfg.TRANSPORT_OPS).toMatchObject({ + host: "SMTP.POSTMARKAPP.COM", + auth: { user: "pm-token", pass: "pm-token" }, + }); + }); + + it("throws when Postmark host is set but no token is given", () => { expect(() => buildEmailConfig({ TACHI_EMAIL_FROM: "a@b.com", - TACHI_EMAIL_AUTH_POSTMARK: "true", + TACHI_EMAIL_HOST: "smtp.postmarkapp.com", + TACHI_EMAIL_PORT: "587", }), ).toThrow(/TACHI_EMAIL_AUTH_PASS or TACHI_EMAIL_AUTH_USER/u); }); - it("builds generic SMTP without auth when POSTMARK is false", () => { + it("builds generic SMTP without auth", () => { const cfg = buildEmailConfig({ TACHI_EMAIL_FROM: "dev@localhost", - TACHI_EMAIL_AUTH_POSTMARK: "false", TACHI_EMAIL_HOST: "tachi-mailpit", TACHI_EMAIL_PORT: "1025", TACHI_EMAIL_SECURE: "false", @@ -59,7 +75,6 @@ describe("buildEmailConfig", () => { it("includes auth when user and/or pass are set for generic SMTP", () => { const cfg = buildEmailConfig({ TACHI_EMAIL_FROM: "a@b.com", - TACHI_EMAIL_AUTH_POSTMARK: "false", TACHI_EMAIL_HOST: "smtp.example", TACHI_EMAIL_PORT: "587", TACHI_EMAIL_SECURE: "false", @@ -77,7 +92,6 @@ describe("buildEmailConfig", () => { it("defaults TACHI_EMAIL_SECURE to false", () => { const cfg = buildEmailConfig({ TACHI_EMAIL_FROM: "a@b.com", - TACHI_EMAIL_AUTH_POSTMARK: "false", TACHI_EMAIL_HOST: "h", TACHI_EMAIL_PORT: "25", }); @@ -88,7 +102,6 @@ describe("buildEmailConfig", () => { expect(() => buildEmailConfig({ TACHI_EMAIL_FROM: "a@b.com", - TACHI_EMAIL_AUTH_POSTMARK: "false", TACHI_EMAIL_PORT: "1025", }), ).toThrow(/TACHI_EMAIL_HOST is required/u); @@ -98,7 +111,6 @@ describe("buildEmailConfig", () => { expect(() => buildEmailConfig({ TACHI_EMAIL_FROM: "a@b.com", - TACHI_EMAIL_AUTH_POSTMARK: "false", TACHI_EMAIL_HOST: "localhost", }), ).toThrow(/TACHI_EMAIL_PORT is required/u); @@ -108,7 +120,6 @@ describe("buildEmailConfig", () => { expect(() => buildEmailConfig({ TACHI_EMAIL_FROM: "a@b.com", - TACHI_EMAIL_AUTH_POSTMARK: "false", TACHI_EMAIL_HOST: "localhost", TACHI_EMAIL_PORT: "nope", }), diff --git a/typescript/server/src/lib/setup/config.ts b/typescript/server/src/lib/setup/config.ts index b35bad9ab..90cde4f1f 100644 --- a/typescript/server/src/lib/setup/config.ts +++ b/typescript/server/src/lib/setup/config.ts @@ -82,6 +82,7 @@ const configSchema = z.object({ BETA_USER_BONUS: z.number().int().nonnegative(), }) .optional(), + INVITE_ADMIN_INITIAL_INVITE_CODE: z.string().optional(), TACHI_CONFIG: z.object({ NAME: z.string(), TYPE: z.enum(["kamai", "boku", "omni"]), @@ -263,14 +264,17 @@ function envBoolFrom(env: NodeJS.ProcessEnv, key: string, defaultVal: boolean): return defaultVal; } +const POSTMARK_SMTP_HOST = "smtp.postmarkapp.com"; + /** * SMTP settings from env. Required for every deployment. * - * - `TACHI_EMAIL_FROM` — `From` header (must match Postmark sender when using Postmark). - * - `TACHI_EMAIL_AUTH_POSTMARK` — if `true`, uses Postmark SMTP; set `TACHI_EMAIL_AUTH_USER` / - * `TACHI_EMAIL_AUTH_PASS` to your server token (both are the token for Postmark). - * - Otherwise: `TACHI_EMAIL_HOST`, `TACHI_EMAIL_PORT`, `TACHI_EMAIL_SECURE`, and optionally - * `TACHI_EMAIL_AUTH_USER` / `TACHI_EMAIL_AUTH_PASS` (e.g. Mailpit: no auth). + * - `TACHI_EMAIL_FROM` - `From` header (must match a verified sender when using Postmark). + * - `TACHI_EMAIL_HOST`, `TACHI_EMAIL_PORT`, `TACHI_EMAIL_SECURE` (`true` / `false`). + * - Optionally `TACHI_EMAIL_AUTH_USER` / `TACHI_EMAIL_AUTH_PASS` when the server needs SMTP auth + * (Mailpit locally typically needs none). + * - For Postmark, set host to `smtp.postmarkapp.com` (commonly port `587`, `TACHI_EMAIL_SECURE=false`). + * Either auth field may hold the server token; the other is filled with the same value. * * @internal Exported for unit tests. */ @@ -280,39 +284,14 @@ export function buildEmailConfig(env: NodeJS.ProcessEnv): TachiServerConfig["EMA throw new Error(`TACHI_EMAIL_FROM is required.`); } - if (envBoolFrom(env, "TACHI_EMAIL_AUTH_POSTMARK", false)) { - const pass = envOptFrom(env, "TACHI_EMAIL_AUTH_PASS"); - const user = envOptFrom(env, "TACHI_EMAIL_AUTH_USER"); - const token = pass ?? user; - if (token === undefined) { - throw new Error( - `TACHI_EMAIL_AUTH_PASS or TACHI_EMAIL_AUTH_USER is required when TACHI_EMAIL_AUTH_POSTMARK is true.`, - ); - } - const authUser = user ?? token; - const authPass = pass ?? token; - return { - FROM: from, - TRANSPORT_OPS: { - host: "smtp.postmarkapp.com", - port: 587, - secure: false, - auth: { - user: authUser, - pass: authPass, - }, - }, - }; - } - const host = envOptFrom(env, "TACHI_EMAIL_HOST"); if (host === undefined) { - throw new Error(`TACHI_EMAIL_HOST is required when TACHI_EMAIL_AUTH_POSTMARK is false.`); + throw new Error(`TACHI_EMAIL_HOST is required.`); } const portRaw = envOptFrom(env, "TACHI_EMAIL_PORT"); if (portRaw === undefined) { - throw new Error(`TACHI_EMAIL_PORT is required when TACHI_EMAIL_AUTH_POSTMARK is false.`); + throw new Error(`TACHI_EMAIL_PORT is required.`); } const port = Number.parseInt(portRaw, 10); if (Number.isNaN(port)) { @@ -320,8 +299,19 @@ export function buildEmailConfig(env: NodeJS.ProcessEnv): TachiServerConfig["EMA } const secure = envBoolFrom(env, "TACHI_EMAIL_SECURE", false); - const authUser = envOptFrom(env, "TACHI_EMAIL_AUTH_USER"); - const authPass = envOptFrom(env, "TACHI_EMAIL_AUTH_PASS"); + let authUser = envOptFrom(env, "TACHI_EMAIL_AUTH_USER"); + let authPass = envOptFrom(env, "TACHI_EMAIL_AUTH_PASS"); + + if (host.toLowerCase() === POSTMARK_SMTP_HOST) { + const token = authPass ?? authUser; + if (token === undefined) { + throw new Error( + `TACHI_EMAIL_AUTH_PASS or TACHI_EMAIL_AUTH_USER is required when TACHI_EMAIL_HOST is ${POSTMARK_SMTP_HOST}.`, + ); + } + authUser = authUser ?? token; + authPass = authPass ?? token; + } const transportOps: Record = { host, @@ -393,6 +383,7 @@ const eagOauth = oauth2Optional("EAG"); const minOauth = oauth2Optional("MIN"); const emailCfg = emailConfig(); const inviteCfg = inviteCodeConfig(); +const bootstrapInvite = opt("TACHI_INVITE_ADMIN_INITIAL_INVITE_CODE")?.trim() || undefined; const seedsCfg = seedsConfig(); const clientDev = clientDevServer(); const extWorkerConc = opt("TACHI_EXTERNAL_SCORE_IMPORT_WORKER_CONCURRENCY"); @@ -437,6 +428,7 @@ const configFromEnv: unknown = { MAX_RIVALS: parseIntEnv("TACHI_MAX_RIVALS", 5), OUR_URL: req("TACHI_OUR_URL"), ...(inviteCfg !== undefined ? { INVITE_CODE_CONFIG: inviteCfg } : {}), + ...(bootstrapInvite !== undefined ? { INVITE_ADMIN_INITIAL_INVITE_CODE: bootstrapInvite } : {}), TACHI_CONFIG: { NAME: req("TACHI_NAME"), TYPE: req("TACHI_TYPE"), @@ -526,14 +518,14 @@ if (!MIGRATIONS_DIR) { let version = process.env.VERSION; if (!version) { - log.error(`No VERSION specified in environment. defaulting to 0.0.0.`); + log.warn(`No VERSION specified in environment. defaulting to 0.0.0.`); version = "0.0.0"; } let commitHash = process.env.COMMIT_HASH; if (!commitHash) { - log.error(`No COMMIT_HASH specified in environment. defaulting to unknown commit.`); + log.warn(`No COMMIT_HASH specified in environment. defaulting to unknown commit.`); commitHash = "unknown"; } diff --git a/typescript/server/src/load-tests/README.md b/typescript/server/src/load-tests/README.md index 012f7cc20..e2d0b252e 100644 --- a/typescript/server/src/load-tests/README.md +++ b/typescript/server/src/load-tests/README.md @@ -6,7 +6,7 @@ Stress **`POST /api/v1/import/file`** against a **running** Tachi instance using - Server reachable (e.g. `http://127.0.0.1:8080` when using local `PORT`). - Accounts with **`submit_score`**: - - **Session:** login cookie (see below) — **one in-flight import per user**, so keep **`--concurrency 1`** unless you only care about rate limits. + - **Session:** login cookie (see below) - **one in-flight import per user**, so keep **`--concurrency 1`** unless you only care about rate limits. - **API tokens:** one token per parallel slot. Generate many tokens with the seeder (below). **Rate limiting:** In dev, score imports are limited to **5 per minute per IP** unless you set: @@ -96,5 +96,5 @@ just load-test-score-import -- \ ## Files -- `score-import-load-cli.ts` — multipart client. -- `seed-stress-api-tokens.ts` — bulk token seeder for dev DBs. +- `score-import-load-cli.ts` - multipart client. +- `seed-stress-api-tokens.ts` - bulk token seeder for dev DBs. diff --git a/typescript/server/src/load-tests/score-import-load-cli.ts b/typescript/server/src/load-tests/score-import-load-cli.ts index a3c555a5e..f6ec8447b 100644 --- a/typescript/server/src/load-tests/score-import-load-cli.ts +++ b/typescript/server/src/load-tests/score-import-load-cli.ts @@ -1,7 +1,7 @@ /** * HTTP stress harness: multipart POST /api/v1/import/file against a live Tachi instance. * - * Uses real fixtures under src/test-utils/test-data/ (CSV, XML, JSON) — not MER. + * Uses real fixtures under src/test-utils/test-data/ (CSV, XML, JSON) - not MER. * * One in-flight import per user (409 if you exceed). Use a token pool sized ≥ concurrency. * @@ -155,7 +155,7 @@ function maybeMutateFileBody( return buf; } } - // XML / others: suffix a harmless byte that most XML parsers ignore after root — skip + // XML / others: suffix a harmless byte that most XML parsers ignore after root - skip return buf; } diff --git a/typescript/server/src/scripts/migrate-to-postgres.ts b/typescript/server/src/scripts/migrate-to-postgres.ts index 654bc63fb..cdc2ab3dd 100644 --- a/typescript/server/src/scripts/migrate-to-postgres.ts +++ b/typescript/server/src/scripts/migrate-to-postgres.ts @@ -215,7 +215,7 @@ function tsReq(ms: number): string { // ────────────────────────────────────────────────────────────────────────────── // API permission helpers // (Old MongoDB data uses dash-separated permission names, not the underscore -// form in the current APIPermissions type — kept as plain string lookups.) +// form in the current APIPermissions type - kept as plain string lookups.) // ────────────────────────────────────────────────────────────────────────────── /** Extract one Postgres pm_* column from a MongoDB requestedPermissions array. */ @@ -371,7 +371,7 @@ async function main(): Promise { const chartIdMap = buildChartIdMap(SEEDS_DIR); // ══════════════════════════════════════════════════════════════════════════ - // LEVEL 0 — No FK dependencies + // LEVEL 0 - No FK dependencies // ══════════════════════════════════════════════════════════════════════════ console.log("\n── Level 0 ──────────────────────────────────────────────────────"); @@ -489,7 +489,7 @@ async function main(): Promise { } // ══════════════════════════════════════════════════════════════════════════ - // LEVEL 1 — Depend on account and/or level-0 tables + // LEVEL 1 - Depend on account and/or level-0 tables // ══════════════════════════════════════════════════════════════════════════ console.log("\n── Level 1 ──────────────────────────────────────────────────────"); @@ -894,7 +894,7 @@ async function main(): Promise { game: mongoGameToPg(a.game, (a as { playtype?: string }).playtype), user_id: a.userID, class_set: a.classSet as string, - // classOldValue can be null in Mongo; Postgres requires a string — use empty string. + // classOldValue can be null in Mongo; Postgres requires a string - use empty string. class_prev_value: a.classOldValue ?? "", class_value: a.classValue, timestamp: tsReq(a.timeAchieved), @@ -1049,18 +1049,18 @@ async function main(): Promise { if (skipped > 0) { console.warn( - ` [import_session] Skipped ${skipped} row(s) — session not found`, + ` [import_session] Skipped ${skipped} row(s) - session not found`, ); } } - // Base rows first — children have FK references to import(id). + // Base rows first - children have FK references to import(id). await batchInsert("import", importRows); await batchInsert("import_game", importGameRows); await batchInsert("import_error", importErrorRows); await batchInsert("import_class", importClassRows); await batchInsert("import_session", importSessionRows); - // import_goal and import_quest are intentionally skipped — the + // import_goal and import_quest are intentionally skipped - the // historical data doesn't align with reality. }, "import + children", @@ -1088,7 +1088,7 @@ async function main(): Promise { for (const gs of goalSubs) { if (!existingGoalIds.has(gs.goalID)) { - console.warn(` [goal_sub] Skipping — goal ${gs.goalID} not found in DB`); + console.warn(` [goal_sub] Skipping - goal ${gs.goalID} not found in DB`); continue; } @@ -1170,7 +1170,7 @@ async function main(): Promise { const skipped = folderViews.length - viewRows.length; if (skipped > 0) { - console.warn(` [folder_view] Skipping ${skipped} row(s) — folder not found in DB`); + console.warn(` [folder_view] Skipping ${skipped} row(s) - folder not found in DB`); } for (let i = 0; i < viewRows.length; i = i + INSERT_CHUNK) { @@ -1207,7 +1207,7 @@ async function main(): Promise { } // ══════════════════════════════════════════════════════════════════════════ - // LEVEL 2 — Depend on level 1 tables + // LEVEL 2 - Depend on level 1 tables // ══════════════════════════════════════════════════════════════════════════ console.log("\n── Level 2 ──────────────────────────────────────────────────────"); @@ -1244,7 +1244,7 @@ async function main(): Promise { console.warn( ` ${ apiTokens.length - validTokens.length - } API tokens skipped — null token/userID or deleted oauth2 client`, + } API tokens skipped - null token/userID or deleted oauth2 client`, ); } @@ -1332,7 +1332,7 @@ async function main(): Promise { } if (skipped > 0) { - console.warn(` [import_timing] Skipped ${skipped} row(s) — import not found`); + console.warn(` [import_timing] Skipped ${skipped} row(s) - import not found`); } await batchInsert("import_timing", rows); @@ -1341,7 +1341,7 @@ async function main(): Promise { ); // ══════════════════════════════════════════════════════════════════════════ - // LEVEL 3 — Large collections: scores and PBs (cursor-streamed) + // LEVEL 3 - Large collections: scores and PBs (cursor-streamed) // ══════════════════════════════════════════════════════════════════════════ console.log("\n── Level 3 (streaming) ──────────────────────────────────────────"); @@ -1397,7 +1397,7 @@ async function main(): Promise { if (chartSid === undefined) { throw new Error( - ` [score] Skipping score ${s.scoreID} — no sid for chartID ${s.chartID}`, + ` [score] Skipping score ${s.scoreID} - no sid for chartID ${s.chartID}`, ); } diff --git a/typescript/server/src/server/prometheus.ts b/typescript/server/src/server/prometheus.ts index 7d56752bb..aeb5ce8b8 100644 --- a/typescript/server/src/server/prometheus.ts +++ b/typescript/server/src/server/prometheus.ts @@ -7,10 +7,10 @@ import { collectDefaultMetrics, Counter, Histogram, Registry } from "prom-client /** Dedicated listener for `GET /metrics` (see `main.ts`). */ export const METRICS_PORT = 9779; -/** Seconds — aligns with typical Prometheus HTTP latency buckets, with extra resolution under 100ms. */ +/** Seconds - aligns with typical Prometheus HTTP latency buckets, with extra resolution under 100ms. */ const HTTP_DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]; -/** Seconds — score imports can run from sub-second to tens of minutes. */ +/** Seconds - score imports can run from sub-second to tens of minutes. */ const SCORE_IMPORT_DURATION_BUCKETS = [0.25, 0.5, 1, 2, 5, 10, 30, 60, 120, 300, 600, 1800]; let scoreImportDurationSeconds: Histogram | null = null; diff --git a/typescript/server/src/server/router/api/v1/clients/middleware.ts b/typescript/server/src/server/router/api/v1/clients/middleware.ts index a3ce02327..c5985355f 100644 --- a/typescript/server/src/server/router/api/v1/clients/middleware.ts +++ b/typescript/server/src/server/router/api/v1/clients/middleware.ts @@ -13,7 +13,7 @@ export const GetClientFromID: RequestHandler = async (req, res, next) => { }); } - // Strip the client secret — this middleware is used for public lookups. + // Strip the client secret - this middleware is used for public lookups. const { clientSecret: _secret, ...publicClient } = client; REQ_AssignToReqTachiData(req, { apiClientDoc: publicClient }); diff --git a/typescript/server/src/server/router/api/v1/clients/router.test.ts b/typescript/server/src/server/router/api/v1/clients/router.test.ts index a15cd6f3f..c8d38b43a 100644 --- a/typescript/server/src/server/router/api/v1/clients/router.test.ts +++ b/typescript/server/src/server/router/api/v1/clients/router.test.ts @@ -244,7 +244,7 @@ describe("GET /api/v1/clients/:clientID", () => { // ─── PATCH /api/v1/clients/:clientID ───────────────────────────────────────── describe("PATCH /api/v1/clients/:clientID", () => { - /** Prudence `?string` fields — omit vs null is not optional; send null when not updating. */ + /** Prudence `?string` fields - omit vs null is not optional; send null when not updating. */ const nullTemplateAndUris = { apiKeyTemplate: null, redirectUri: null, diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/router.test.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/router.test.ts index 080800919..f7807df68 100644 --- a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/router.test.ts +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/charts/router.test.ts @@ -91,5 +91,5 @@ describe("POST /api/v1/games/:game/charts/resolve", () => { }); describe("GET /api/v1/games/:game/charts (popular & search)", () => { - it.todo("requires large IIDX chart/PB corpus — port from router.oldtest.ts"); + it.todo("requires large IIDX chart/PB corpus - port from router.oldtest.ts"); }); diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/router.test.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/router.test.ts index 37ad2ffb7..d8f7d47da 100644 --- a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/router.test.ts +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/router.test.ts @@ -77,7 +77,7 @@ describe("GET /api/v1/games/:game", () => { }); it.todo( - "GET /api/v1/games/:v3Game stats (chart/player/score): blocked — Express matches GET /games/:gameGroup before GET /games/:game, so iidx-sp is rejected as a game group", + "GET /api/v1/games/:v3Game stats (chart/player/score): blocked - Express matches GET /games/:gameGroup before GET /games/:game, so iidx-sp is rejected as a game group", ); it("returns 400 for an unsupported game slug", async () => { diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/songs/_songID/router.test.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/songs/_songID/router.test.ts index 4fbe7f5e1..2f69a0a58 100644 --- a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/songs/_songID/router.test.ts +++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/songs/_songID/router.test.ts @@ -141,7 +141,7 @@ describe("GET /api/v1/games/iidx-sp/songs/:songID", () => { }); it("only returns charts for the requested playtype", async () => { - // Insert a DP chart for the same song — should not appear in the SP response. + // Insert a DP chart for the same song - should not appear in the SP response. await DB.insertInto("chart") .values({ id: "C_TEST_IIDX_DP_CHART", diff --git a/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.test.ts b/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.test.ts index bc13454f2..ab8b2fcc5 100644 --- a/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.test.ts +++ b/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.test.ts @@ -167,6 +167,66 @@ describe("GET /api/v1/sessions/:sessionID/folder-raises", () => { }); }); +describe("GET /api/v1/sessions/:sessionID/adjacent", () => { + async function seedThreeSessions() { + const { id: userId } = await seedUser({ username: "adjacent_user" }); + + const sessionIds = ["adj_session_oldest", "adj_session_middle", "adj_session_newest"]; + + for (const [i, id] of sessionIds.entries()) { + const t = new Date(2024, 0, 1 + i).toISOString(); + + await DB.insertInto("session") + .values({ + id, + user_id: userId, + game: "iidx-sp", + name: `Session ${i}`, + description: null, + time_inserted: t, + time_started: t, + time_ended: t, + calculated_data: JSON.stringify({}), + highlight: false, + }) + .execute(); + } + + return { userId, sessionIds }; + } + + it("returns both neighbors for the middle session", async () => { + const { sessionIds } = await seedThreeSessions(); + + const res = await mockApi.get(`/api/v1/sessions/${sessionIds[1]}/adjacent`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.body.prev?.sessionID).toBe(sessionIds[0]); + expect(res.body.body.next?.sessionID).toBe(sessionIds[2]); + }); + + it("returns null next for the newest session", async () => { + const { sessionIds } = await seedThreeSessions(); + + const res = await mockApi.get(`/api/v1/sessions/${sessionIds[2]}/adjacent`); + + expect(res.status).toBe(200); + expect(res.body.body.next).toBeNull(); + expect(res.body.body.prev?.sessionID).toBe(sessionIds[1]); + }); + + it("returns null prev for the oldest session", async () => { + const { sessionIds } = await seedThreeSessions(); + + const res = await mockApi.get(`/api/v1/sessions/${sessionIds[0]}/adjacent`); + + expect(res.status).toBe(200); + expect(res.body.body.prev).toBeNull(); + expect(res.body.body.next?.sessionID).toBe(sessionIds[1]); + }); +}); + describe("PATCH /api/v1/sessions/:sessionID", () => { it("updates the session name when authorised", async () => { const { sessionId, userId } = await seedSessionFixture(); diff --git a/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.ts b/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.ts index ec6bc7b6d..7b43b41da 100644 --- a/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.ts +++ b/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.ts @@ -4,7 +4,7 @@ import { GetSessionFolderRaises } from "#lib/folders/get-session-folder-raises"; import { withSession, withSessionOwner } from "#lib/router/middleware"; import { success } from "#lib/router/typed-router"; import { API_V1_ROUTER } from "#server/router/api/v1/router"; -import { GetSessionData } from "#utils/queries/sessions"; +import { GetAdjacentSessions, GetSessionData } from "#utils/queries/sessions"; import { GetUserWithID } from "#utils/user"; import { ExpectedErr } from "bliss"; @@ -44,6 +44,18 @@ API_V1_ROUTER.add("GET /sessions/:sessionID/folder-raises", withSession, async ( return success("Retrieved folder raises.", raises); }); +/** + * Returns the chronologically adjacent sessions (prev = older, next = newer) + * for the same user and game. + * + * @name GET /api/v1/sessions/:sessionID/adjacent + */ +API_V1_ROUTER.add("GET /sessions/:sessionID/adjacent", withSession, async ({ ctx }) => { + const { prev, next } = await GetAdjacentSessions(ctx.sessionDoc); + + return success("Retrieved adjacent sessions.", { next, prev }); +}); + /** * Modifies a session. * diff --git a/typescript/server/src/server/router/api/v1/spec.ts b/typescript/server/src/server/router/api/v1/spec.ts index 52a6793f9..f57aa38aa 100644 --- a/typescript/server/src/server/router/api/v1/spec.ts +++ b/typescript/server/src/server/router/api/v1/spec.ts @@ -61,7 +61,7 @@ type AdminJobQueueFilters = { job_kind?: string; scope?: string; status?: number type AdminActionFilters = { kind?: string; username?: string }; // ─── Output schema helpers ──────────────────────────────────────────────────── -// Opaque MongoDB document — validates the value is a record but does not +// Opaque MongoDB document - validates the value is a record but does not // constrain individual fields. Pass `T` so `z.infer` and handler output types // match the API document type (runtime validation stays shallow). function doc>(): z.ZodType { @@ -72,7 +72,7 @@ function docArray>(): z.ZodType { return z.array(z.record(z.string(), z.unknown())) as z.ZodType; } -// Empty success body — mutation endpoints that carry no return payload. +// Empty success body - mutation endpoints that carry no return payload. const empty = z.object({}); export const API_V1_SPEC = { @@ -624,7 +624,7 @@ export const API_V1_SPEC = { // Users /:userID/games/:game (UGPT) // ──────────────────────────────────────────────── - // User games — literal gameGroup segments (game-specific) + // User games - literal gameGroup segments (game-specific) "GET /users/:userID/games/:game/custom-tables/:tableUrlName": { description: "HTML stub for a user-specific custom BMS table; things like their rivals, etc.", @@ -1259,7 +1259,7 @@ export const API_V1_SPEC = { }), }, - // Games — BMS / IIDX literal paths (game-specific) + // Games - BMS / IIDX literal paths (game-specific) "GET /games/:game/custom-tables": { description: "List custom BMS tables available.", input: z.object({}), @@ -1622,6 +1622,15 @@ export const API_V1_SPEC = { output: empty, }, + "GET /sessions/:sessionID/adjacent": { + description: "Retrieve the chronologically adjacent sessions (prev/next) for this session.", + input: z.object({}), + output: z.strictObject({ + prev: doc().nullable(), + next: doc().nullable(), + }), + }, + "GET /sessions/:sessionID/folder-raises": { description: "Folder raise summary for a session.", input: z.object({}), diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.test.ts index 972c58b00..92518bb82 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.test.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.test.ts @@ -154,7 +154,7 @@ describe("GET /api/v1/users/:userID/games/:game/folders/:folderID/timeline", () const baseSd = TestingIIDXSPScore.scoreData; - // Chart A: CLEAR lamp, null time — sorts first in final array (null → 0). + // Chart A: CLEAR lamp, null time - sorts first in final array (null → 0). await insertIidxSpScore({ id: "tl_s_a", userId, @@ -178,7 +178,7 @@ describe("GET /api/v1/users/:userID/games/:game/folders/:folderID/timeline", () scoreData: baseSd, timeAchievedMs: 100, }); - // EASY CLEAR (index 3) — does not satisfy CLEAR floor (index 4). + // EASY CLEAR (index 3) - does not satisfy CLEAR floor (index 4). await insertIidxSpScore({ id: "tl_s_b_easy", userId, diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.test.ts index e3ef812a5..b74b1f3d1 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.test.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.test.ts @@ -114,7 +114,7 @@ async function seedIidxChartPb(opts: { userId: number; withComposition?: boolean if (opts.withComposition) { await DB.insertInto("pb_composed_from") - .values({ pb_id: pbIns.row_id, score_id: scoreId }) + .values({ pb_id: pbIns.row_id, score_id: scoreId, name: "Default" }) .execute(); } diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.test.ts index 1ba538c8e..31f1e81bf 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.test.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.test.ts @@ -146,7 +146,7 @@ describe("GET /api/v1/users/:userID/games/:game/scores/:chartID", () => { expect(res.body.success).toBe(true); expect(res.body.body).toEqual([]); - // Other user may have scores on the same chart — still empty for target. + // Other user may have scores on the same chart - still empty for target. await DB.insertInto("score") .values({ id: `sc-ugpt-empty-other-${seedCounter}`, diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.test.ts index 86b30f374..a4ee209ef 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.test.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.test.ts @@ -74,7 +74,7 @@ describe("PATCH /api/v1/users/:userID/games/:game/settings", () => { }); }); -describe("PATCH UGPT settings — extended cases", () => { +describe("PATCH UGPT settings - extended cases", () => { it.todo( "port remaining router.oldtest.ts cases (BPI bounds, defaultTable validation, cross-user 403, …)", ); diff --git a/typescript/server/src/server/router/api/v1/users/router.test.ts b/typescript/server/src/server/router/api/v1/users/router.test.ts index 57ff0f723..638e896a6 100644 --- a/typescript/server/src/server/router/api/v1/users/router.test.ts +++ b/typescript/server/src/server/router/api/v1/users/router.test.ts @@ -92,7 +92,7 @@ describe("GET /api/v1/users?search=", () => { it("treats regex special characters as literals", async () => { // '.*' matches everything as a regex, but is treated as a literal ILIKE - // pattern — no usernames contain that substring, so results are empty. + // pattern - no usernames contain that substring, so results are empty. const res = await mockApi.get("/api/v1/users?search=.*"); expect(res.status).toBe(200); diff --git a/typescript/server/src/server/router/ir/fervidex/router.test.ts b/typescript/server/src/server/router/ir/fervidex/router.test.ts index bc143fc71..3160ac749 100644 --- a/typescript/server/src/server/router/ir/fervidex/router.test.ts +++ b/typescript/server/src/server/router/ir/fervidex/router.test.ts @@ -433,6 +433,6 @@ describe("POST /ir/fervidex/score/submit (Postgres)", () => { }); }); -describe.skip("POST /ir/fervidex/profile/submit — deferred KT bulk seed to Postgres", () => { +describe.skip("POST /ir/fervidex/profile/submit - deferred KT bulk seed to Postgres", () => { it.skip("imports fervidex-static profile", () => {}); }); diff --git a/typescript/server/src/services/pg/db.ts b/typescript/server/src/services/pg/db.ts index f7bd2b1b4..5059911d1 100644 --- a/typescript/server/src/services/pg/db.ts +++ b/typescript/server/src/services/pg/db.ts @@ -14,6 +14,16 @@ pg.types.setTypeParser(pg.types.builtins.INT8, (val) => Number(val)); const pool = new Pool({ connectionString: Env.POSTGRES_URL }); +if (process.env.NODE_ENV === "test") { + // Swallow 57P01 (admin_shutdown) errors that arrive on idle pool connections + // during test teardown. Prevents some flakiness in CI. + pool.on("error", (err: { code?: string } & Error) => { + if (err.code !== "57P01") { + throw err; + } + }); +} + const DB = new Kysely({ dialect: new PostgresDialect({ pool }), }); diff --git a/typescript/server/src/services/pg/seeds.ts b/typescript/server/src/services/pg/seeds.ts index 82dadffda..8e9588fa2 100644 --- a/typescript/server/src/services/pg/seeds.ts +++ b/typescript/server/src/services/pg/seeds.ts @@ -594,7 +594,7 @@ export async function importSeeds(pg: Kysely, seedsDir: string): Promi }; }); - // Goals are never updated once created — only new ones are inserted. + // Goals are never updated once created - only new ones are inserted. await batchIgnorePg(pg, "goal", goalRows); console.log(` ${goals.length} goals\n`); } diff --git a/typescript/server/src/test-utils/pg-fixtures.ts b/typescript/server/src/test-utils/pg-fixtures.ts index dbea16dda..13a1847d7 100644 --- a/typescript/server/src/test-utils/pg-fixtures.ts +++ b/typescript/server/src/test-utils/pg-fixtures.ts @@ -61,8 +61,8 @@ interface SeedUserOpts { * Insert an `account` row (and optionally `priv_account_credential` / * `account_settings`) and return the resulting data. * - * - `withCredential: true` — also inserts `priv_account_credential` - * - `withSettings: true` — also inserts `account_settings` + * - `withCredential: true` - also inserts `priv_account_credential` + * - `withSettings: true` - also inserts `account_settings` */ export async function seedUser(opts?: SeedUserOpts) { const username = opts?.username ?? "test_user"; diff --git a/typescript/server/src/test-utils/seed-paths.ts b/typescript/server/src/test-utils/seed-paths.ts index 482408f83..8ee9807bc 100644 --- a/typescript/server/src/test-utils/seed-paths.ts +++ b/typescript/server/src/test-utils/seed-paths.ts @@ -4,7 +4,7 @@ import path from "path"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -/** Default `db/seeds` (collections JSON) relative to the server package — same as `load-seeds-pg.ts`. */ +/** Default `db/seeds` (collections JSON) relative to the server package - same as `load-seeds-pg.ts`. */ export const DEFAULT_SEEDS_DIR = path.resolve(__dirname, "../../../../db/seeds"); export function resolveSeedsDir(): string { diff --git a/typescript/server/src/test-utils/test-data.ts b/typescript/server/src/test-utils/test-data.ts index c3a678eab..26fc7e75f 100644 --- a/typescript/server/src/test-utils/test-data.ts +++ b/typescript/server/src/test-utils/test-data.ts @@ -72,7 +72,6 @@ export const TestingIIDXSPScorePB: PBScoreDocument<"iidx-sp"> = { calculatedData: { BPI: 10.1, ktLampRating: 0, - ktLampRatingNC: 0, ktLampRatingHC: 0, ktLampRatingEXHC: 0, }, @@ -202,7 +201,6 @@ export const TestingIIDXSPScore: ScoreDocument<"iidx-sp"> = { calculatedData: { BPI: null, ktLampRating: 10, - ktLampRatingNC: 10, ktLampRatingHC: 0, ktLampRatingEXHC: 0, }, diff --git a/typescript/server/src/utils/db.ts b/typescript/server/src/utils/db.ts index 67fde2707..8e2b72e14 100644 --- a/typescript/server/src/utils/db.ts +++ b/typescript/server/src/utils/db.ts @@ -147,7 +147,7 @@ function whereUserIdOnQuestSub(userID: unknown) { } /** - * Next numeric `song.legacy_id` for BMS/PMS — `max(existing) + 1`, or `1` if none. + * Next numeric `song.legacy_id` for BMS/PMS - `max(existing) + 1`, or `1` if none. * Replaces Mongo `counters` `*-song-id` documents. * * This shit sucks and should be dropped asap: TODO(zk) diff --git a/typescript/server/src/utils/misc.test.ts b/typescript/server/src/utils/misc.test.ts index 8b43e41f8..4d04d9074 100644 --- a/typescript/server/src/utils/misc.test.ts +++ b/typescript/server/src/utils/misc.test.ts @@ -56,8 +56,8 @@ describe("IsValidURL", () => { expect(IsValidURL("ftp://example.com")).toBeFalsy(); }); - // expect(IsValidURL("http://example")).toBe(false) — lol this is valid???? insane. - // expect(IsValidURL("http:/example.com")).toBe(false) — this is also valid, the JS URL parser is ridiculously lenient. Whatever. + // expect(IsValidURL("http://example")).toBe(false) - lol this is valid???? insane. + // expect(IsValidURL("http:/example.com")).toBe(false) - this is also valid, the JS URL parser is ridiculously lenient. Whatever. }); describe("RoundToNDecimalPlaces", () => { diff --git a/typescript/server/src/utils/queries/charts.ts b/typescript/server/src/utils/queries/charts.ts index 2e8f37b27..589758f79 100644 --- a/typescript/server/src/utils/queries/charts.ts +++ b/typescript/server/src/utils/queries/charts.ts @@ -221,7 +221,7 @@ export async function FindITGChartsByHashGSv3(hash: string): Promise`( + (jsonb_typeof(chart.data::jsonb->'inGameID') = 'number' AND (chart.data::jsonb->>'inGameID')::int = ${inGameID}) + OR + (jsonb_typeof(chart.data::jsonb->'inGameID') = 'array' AND (chart.data::jsonb->'inGameID') @> to_jsonb(${inGameID}::int)) + )`; +} + /** * Find a chart on its in-game-ID, playtype and difficulty. */ @@ -295,7 +307,7 @@ export async function FindChartOnInGameID( .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) .where("chart.game", "=", game) - .where(sql`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`) + .where(sqlChartDataInGameIDEquals(inGameID)) .where("chart.difficulty", "=", difficulty as string) .executeTakeFirst(); @@ -318,7 +330,7 @@ export async function FindChartOnInGameIDPrimary( .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) .where("chart.game", "=", game) - .where(sql`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`) + .where(sqlChartDataInGameIDEquals(inGameID)) .where("chart.difficulty", "=", difficulty as string) .where("chart.is_primary", "=", true) .executeTakeFirst(); @@ -342,7 +354,7 @@ export async function FindIIDXChartOnInGameID( .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) .where("chart.game", "=", "iidx-sp") - .where(sql`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`) + .where(sqlChartDataInGameIDEquals(inGameID)) .where(sql`(chart.data->>'2dxtraSet') IS NULL`) .where("chart.is_primary", "=", true) .where("chart.difficulty", "=", difficulty as string) @@ -369,7 +381,7 @@ export async function FindIIDXChartOnInGameIDVersion( .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) .where("chart.game", "=", game) - .where(sql`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`) + .where(sqlChartDataInGameIDEquals(inGameID)) .where(sql`(chart.data->>'2dxtraSet') IS NULL`) .where("chart.difficulty", "=", difficulty as string) .where(sql`${sql.lit(String(version))} = ANY(chart.versions)`) @@ -395,7 +407,7 @@ export async function FindChartOnInGameIDVersion( .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) .where("chart.game", "=", game) - .where(sql`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`) + .where(sqlChartDataInGameIDEquals(inGameID)) .where("chart.difficulty", "=", difficulty as string) .where(sql`${sql.lit(String(version))} = ANY(chart.versions)`) .executeTakeFirst(); @@ -490,7 +502,7 @@ export async function FindSDVXChartOnInGameID( .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) .where("chart.game", "=", "sdvx") - .where(sql`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`) + .where(sqlChartDataInGameIDEquals(inGameID)) .where("chart.is_primary", "=", true); q = @@ -516,7 +528,7 @@ export async function FindSDVXChartOnInGameIDVersion( .innerJoin("song", "song.id", "chart.song_id") .select(SELECT_CHART) .where("chart.game", "=", "sdvx") - .where(sql`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`) + .where(sqlChartDataInGameIDEquals(inGameID)) .where(sql`${sql.lit(String(version))} = ANY(chart.versions)`); q = @@ -640,7 +652,7 @@ export async function FindUSCChartsByHashSHA1(hash: string): Promise { .execute(); for (const r of rows) { - // eslint-disable-next-line no-await-in-loop await DB.insertInto("pb") .values({ calculated_data: JSON.stringify({}), diff --git a/typescript/server/src/utils/queries/sessions.ts b/typescript/server/src/utils/queries/sessions.ts index 6242e2e53..e119ef9a3 100644 --- a/typescript/server/src/utils/queries/sessions.ts +++ b/typescript/server/src/utils/queries/sessions.ts @@ -1,8 +1,10 @@ import { SELECT_CHART, ToChartDocument } from "#lib/db-formats/chart"; import { SELECT_SCORE_DOCUMENT, ToScoreDocument } from "#lib/db-formats/score"; +import { SELECT_SESSION_DOCUMENT, ToSessionDocument } from "#lib/db-formats/session"; import { SELECT_SONG_DOCUMENT, ToSongDocument } from "#lib/db-formats/song"; import { GetSessionScoreInfo } from "#lib/score-import/framework/sessions/sessions"; import DB from "#services/pg/db"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; import { GetUserWithIDGuaranteed } from "#utils/user"; import _ from "lodash"; import { @@ -43,6 +45,58 @@ export async function GetScoreIdsGroupedBySessionId( return map; } +/** + * Returns the chronologically adjacent sessions (prev = older, next = newer) + * for the same user and game, using (time_ended, id) as a deterministic sort key. + */ +export async function GetAdjacentSessions( + session: SessionDocument, +): Promise<{ next: SessionDocument | null; prev: SessionDocument | null }> { + const timeEnded = UnixMillisecondsToISO8601(session.timeEnded); + + const [newerRow, olderRow] = await Promise.all([ + DB.selectFrom("session") + .select(SELECT_SESSION_DOCUMENT) + .where("session.user_id", "=", session.userID) + .where("session.game", "=", session.game) + .where((eb) => + eb.or([ + eb("session.time_ended", ">", timeEnded), + eb.and([ + eb("session.time_ended", "=", timeEnded), + eb("session.id", ">", session.sessionID), + ]), + ]), + ) + .orderBy("session.time_ended", "asc") + .orderBy("session.id", "asc") + .limit(1) + .executeTakeFirst(), + DB.selectFrom("session") + .select(SELECT_SESSION_DOCUMENT) + .where("session.user_id", "=", session.userID) + .where("session.game", "=", session.game) + .where((eb) => + eb.or([ + eb("session.time_ended", "<", timeEnded), + eb.and([ + eb("session.time_ended", "=", timeEnded), + eb("session.id", "<", session.sessionID), + ]), + ]), + ) + .orderBy("session.time_ended", "desc") + .orderBy("session.id", "desc") + .limit(1) + .executeTakeFirst(), + ]); + + return { + next: newerRow ? ToSessionDocument(newerRow, []) : null, + prev: olderRow ? ToSessionDocument(olderRow, []) : null, + }; +} + export async function GetSessionData(session: SessionDocument): Promise<{ charts: Array; scoreInfo: Array; diff --git a/typescript/server/src/utils/user.test.ts b/typescript/server/src/utils/user.test.ts index 57d2e348a..5005e863b 100644 --- a/typescript/server/src/utils/user.test.ts +++ b/typescript/server/src/utils/user.test.ts @@ -86,7 +86,7 @@ describe("GetUsersRankingAndOutOf", () => { expect(result).toEqual({ ranking: 3, outOf: 5 }); }); - it("handles tied ratings — only strictly greater counts", async () => { + it("handles tied ratings - only strictly greater counts", async () => { const user1 = await seedUser({ username: "tied_a" }); const user2 = await seedUser({ username: "tied_b" }); const user3 = await seedUser({ username: "tied_c" }); diff --git a/typescript/server/vitest.config.ts b/typescript/server/vitest.config.ts index 4fec3460b..75a83581a 100644 --- a/typescript/server/vitest.config.ts +++ b/typescript/server/vitest.config.ts @@ -41,7 +41,7 @@ export default defineConfig({ COMMIT_HASH: "test", }, - // Parallel test execution — each worker gets its own isolated Postgres database. + // Parallel test execution - each worker gets its own isolated Postgres database. fileParallelism: true, globalSetup: "./vitest.globalSetup.ts", setupFiles: "./vitest.setup.ts", diff --git a/typescript/server/vitest.globalSetup.ts b/typescript/server/vitest.globalSetup.ts index 84da6bd90..98c73d366 100644 --- a/typescript/server/vitest.globalSetup.ts +++ b/typescript/server/vitest.globalSetup.ts @@ -3,7 +3,7 @@ import { execSync } from "node:child_process"; import { ensureTestCdnBucket } from "./src/test-utils/ensure-test-cdn-bucket"; /** - * Global vitest setup — runs ONCE before any workers start. + * Global vitest setup - runs ONCE before any workers start. * * Creates a fully-migrated template database. Workers clone from it * instead of running migrations themselves, which is much faster. diff --git a/typescript/server/vitest.setup.ts b/typescript/server/vitest.setup.ts index 22fd3b120..ef1114fa5 100644 --- a/typescript/server/vitest.setup.ts +++ b/typescript/server/vitest.setup.ts @@ -18,7 +18,7 @@ const POSTGRES_HOST = "tachi-postgres"; const POSTGRES_USER = "tachi"; const POSTGRES_PASS = "tachi"; -// Set POSTGRES_URL before any app code is imported — config.ts reads it at load time. +// Set POSTGRES_URL before any app code is imported - config.ts reads it at load time. process.env.POSTGRES_URL = `postgresql://${POSTGRES_USER}:${POSTGRES_PASS}@${POSTGRES_HOST}/${WORKER_DB_NAME}`; // Now that env vars are set, we can safely import external packages. @@ -92,7 +92,7 @@ async function resetDatabase() { const { clearGameStatsCacheForTests } = await import("#server/router/api/v1/games/router"); clearGameStatsCacheForTests(); } catch { - // ignore — router not loaded in edge test contexts + // ignore - router not loaded in edge test contexts } }