rebel shell

This commit is contained in:
zk
2026-05-08 16:54:34 +00:00
parent 03ad9a7dfd
commit efcd7652ae
148 changed files with 1317 additions and 514 deletions
+144
View File
@@ -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"}
+1 -1
View File
@@ -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). 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 ```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"]) .select(["data", "chart_id", "user_id"])
.where("id", "=", id) .where("id", "=", id)
@@ -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. 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 ## Actions
@@ -31,9 +31,9 @@ import { ExpectedErr } from "bliss";
export const ACTION_MyAction = MakeAction( export const ACTION_MyAction = MakeAction(
"MY_ACTION", "MY_ACTION",
async (taker, { fieldA, fieldB }) => { async (taker, { fieldA, fieldB }) => {
// taker.acct.id authenticated user's numeric ID // taker.acct.id - authenticated user's numeric ID
// taker.acct.username their username // taker.acct.username - their username
// taker.ip request IP (for audit log) // taker.ip - request IP (for audit log)
if (somethingWrong) { if (somethingWrong) {
throw new ExpectedErr(400, "Human-readable reason."); throw new ExpectedErr(400, "Human-readable reason.");
@@ -50,18 +50,18 @@ export const ACTION_MyAction = MakeAction(
### ExpectedErr ### 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 ### Calling actions from a router
```typescript ```typescript
// taker construction same pattern everywhere: // taker construction - same pattern everywhere:
const user = req.session.tachi?.user; const user = req.session.tachi?.user;
if (!user) return res.status(401).json({ success: false, description: "..." }); if (!user) return res.status(401).json({ success: false, description: "..." });
const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; 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 }); const result = await ACTION_MyAction(taker, { fieldA: body.fieldA });
return res.status(200).json({ success: true, description: "...", body: result }); return res.status(200).json({ success: true, description: "...", body: result });
@@ -123,7 +123,7 @@ await DB.selectFrom("priv_api_client")
// Insert // Insert
await DB.insertInto("priv_api_client").values({ ... }).execute(); 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(); 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 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 ### Checklist
1. **Identify Mongo collections** → find the equivalent `priv_*` Postgres table. 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/`. 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`. 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. 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. 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. 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. 7. **Error handler** - throw `ExpectedErr` instead of returning early; no `try/catch` in routes needed.
### Permission columns ### Permission columns
+6 -6
View File
@@ -36,9 +36,9 @@ Implementation: workspace package `typescript/coverage-tools` (`bun run --filter
Edit `typescript/coverage-tools/src/manifest.ts`: add a `CoverageSource` with: Edit `typescript/coverage-tools/src/manifest.ts`: add a `CoverageSource` with:
- `id` short name for `--packages` - `id` - short name for `--packages`
- `packageRoot` e.g. `typescript/my-pkg` - `packageRoot` - e.g. `typescript/my-pkg`
- `coverageFinal` path to `coverage/coverage-final.json` relative to repo root (Vitest default unless `coverage.reportsDirectory` overrides) - `coverageFinal` - path to `coverage/coverage-final.json` relative to repo root (Vitest default unless `coverage.reportsDirectory` overrides)
## Programmatic use ## Programmatic use
@@ -50,6 +50,6 @@ import { buildReport, COVERAGE_SOURCES } from "tachi-coverage-tools";
## Related files ## Related files
- `typescript/coverage-tools/src/manifest.ts` source registry - `typescript/coverage-tools/src/manifest.ts` - source registry
- `typescript/coverage-tools/src/report-coverage.ts` CLI flags and `buildReport` - `typescript/coverage-tools/src/report-coverage.ts` - CLI flags and `buildReport`
- `Justfile-test` `coverage-report` recipe - `Justfile-test` - `coverage-report` recipe
+4 -4
View File
@@ -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/<table>.ts`: Every Postgres table that is returned through the API needs two things in `src/lib/db-formats/<table>.ts`:
1. **`SELECT_*`** a `const` array of `"table.column"` strings listing exactly the columns needed 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 2. **`To*Document`** - a mapper function that converts a typed Kysely row to the matching `tachi-common` document type
```typescript ```typescript
// src/lib/db-formats/my-table.ts // src/lib/db-formats/my-table.ts
@@ -47,7 +47,7 @@ export function ToMyDocument(
} }
``` ```
The `Selection<Database, "table", (typeof SELECT_*)[number]>` 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<Database, "table", (typeof SELECT_*)[number]>` 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 `To<Entity>Document` typed with `Selection<Database, "table", (typeof SELECT_<TABLE>)[number]>` 3. Export `To<Entity>Document` typed with `Selection<Database, "table", (typeof SELECT_<TABLE>)[number]>`
4. Return the matching `tachi-common` document type (import from `"tachi-common"`) 4. Return the matching `tachi-common` document type (import from `"tachi-common"`)
5. Apply all naming/shape conventions from the table above 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()`
--- ---
+4 -4
View File
@@ -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 ## Why
@@ -47,13 +47,13 @@ Reference: `staticAssertUnreachable` in `typescript/server/src/utils/misc.ts`
## Avoid ## Avoid
- A `default` that only `throw new Error("unreachable")` **without** passing a `never`-typed value you lose exhaustiveness checking. - 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. - `default: break` or empty `default` on switches that are supposed to be exhaustive - same problem.
## When not to use ## 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`. - 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 ## Imports
@@ -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. 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 ## Hard rules
+3 -3
View File
@@ -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. 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 ```typescript
// Prefer // Prefer
@@ -17,7 +17,7 @@ async function load() {
return fetchData(); 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() { async function load() {
return await fetchData(); 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`. If there is **no** `try`/`catch`/`finally` depending on that promise settling inside the function, **do not** use `return await`.
+3 -3
View File
@@ -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. 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. - 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. - **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 ## When removal or heavy edits are OK
- The user **explicitly** asked to remove comments, delete dead documentation, or “dedupe” comments. - 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 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 **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 ## What this is not
-1
View File
@@ -6,7 +6,6 @@
"containerEnv": { "containerEnv": {
"MINIO_ENDPOINT": "http://tachi-s3:9000", "MINIO_ENDPOINT": "http://tachi-s3:9000",
"TACHI_EMAIL_FROM": "dev@localhost", "TACHI_EMAIL_FROM": "dev@localhost",
"TACHI_EMAIL_AUTH_POSTMARK": "false",
"TACHI_EMAIL_HOST": "tachi-mailpit", "TACHI_EMAIL_HOST": "tachi-mailpit",
"TACHI_EMAIL_PORT": "1025", "TACHI_EMAIL_PORT": "1025",
"TACHI_EMAIL_SECURE": "false" "TACHI_EMAIL_SECURE": "false"
+1 -1
View File
@@ -1,7 +1,7 @@
.git .git
.bun .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.* .env.*
**/.env **/.env
+3 -3
View File
@@ -69,7 +69,7 @@ jobs:
VITE_RECAPTCHA_KEY: "6LcsYbIpAAAAAEJffjIXmbQcxj_SBZG7BnSPjF4L" VITE_RECAPTCHA_KEY: "6LcsYbIpAAAAAEJffjIXmbQcxj_SBZG7BnSPjF4L"
VITE_SERVER_URL: "https://v3-boku.tachi.ac" VITE_SERVER_URL: "https://v3-boku.tachi.ac"
VITE_DISCORD: "https://discord.gg/E4n8ZZF5ES" 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" TACHI_NAME: "Bokutachi"
BUILD_OUT_DIR: /tmp/boku BUILD_OUT_DIR: /tmp/boku
@@ -83,7 +83,7 @@ jobs:
VITE_MIN_CLIENT_ID: "A0DBDBB063CD800530EF01C6488B282137E0191E" VITE_MIN_CLIENT_ID: "A0DBDBB063CD800530EF01C6488B282137E0191E"
VITE_DISCORD: "https://discord.gg/NNgGJbpQUj" VITE_DISCORD: "https://discord.gg/NNgGJbpQUj"
VITE_TCHIC_MODE: "kamai" 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_GIT_REPO: "GitHub:zkldi/Tachi"
VITE_RECAPTCHA_KEY: "6LcsYbIpAAAAAEJffjIXmbQcxj_SBZG7BnSPjF4L" VITE_RECAPTCHA_KEY: "6LcsYbIpAAAAAEJffjIXmbQcxj_SBZG7BnSPjF4L"
TACHI_NAME: "Kamaitachi" TACHI_NAME: "Kamaitachi"
@@ -95,7 +95,7 @@ jobs:
VITE_MANDATE_LOGIN: true VITE_MANDATE_LOGIN: true
VITE_SERVER_URL: "https://dev.tachi.ac" VITE_SERVER_URL: "https://dev.tachi.ac"
VITE_TCHIC_MODE: "omni" 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_GIT_REPO: "GitHub:zkldi/Tachi"
VITE_RECAPTCHA_KEY: "6LcsYbIpAAAAAEJffjIXmbQcxj_SBZG7BnSPjF4L" VITE_RECAPTCHA_KEY: "6LcsYbIpAAAAAEJffjIXmbQcxj_SBZG7BnSPjF4L"
TACHI_NAME: "Tachi Dev" TACHI_NAME: "Tachi Dev"
+2 -2
View File
@@ -60,7 +60,7 @@ jobs:
chmod 600 ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa
printf '%s' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts printf '%s' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Deploy updates - name: Deploy seeds
env: env:
TACHI_HOST: ${{ secrets.TACHI_HOST }} 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 }}"
+7 -1
View File
@@ -121,12 +121,17 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1 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 - name: Build and push unified server image
uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0 uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0
with: with:
context: . context: .
file: docker/Dockerfile.server file: docker/Dockerfile.server
target: prod-unified target: prod
push: ${{ github.ref == 'refs/heads/main' }} push: ${{ github.ref == 'refs/heads/main' }}
provenance: true provenance: true
sbom: true sbom: true
@@ -135,6 +140,7 @@ jobs:
ghcr.io/zkldi/tachi:main ghcr.io/zkldi/tachi:main
ghcr.io/zkldi/tachi:latest ghcr.io/zkldi/tachi:latest
build-args: | build-args: |
VERSION=${{ steps.server_version.outputs.version }}
COMMIT_HASH=${{ github.sha }} COMMIT_HASH=${{ github.sha }}
cache-from: type=gha,scope=server-${{ github.ref_name }} cache-from: type=gha,scope=server-${{ github.ref_name }}
cache-to: type=gha,mode=max,scope=server-${{ github.ref_name }} cache-to: type=gha,mode=max,scope=server-${{ github.ref_name }}
+1 -1
View File
@@ -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. # inside the devcontainer, so these recipes fail fast there.
POSTGRES_CONTAINER := "tachi-postgres" POSTGRES_CONTAINER := "tachi-postgres"
+4 -2
View File
@@ -13,8 +13,10 @@ check-all-docker-builds: assert-docker-host
set -euo pipefail set -euo pipefail
root="$(git rev-parse --show-toplevel)" root="$(git rev-parse --show-toplevel)"
sha="$(git -C "$root" rev-parse --short HEAD)" sha="$(git -C "$root" rev-parse --short HEAD)"
echo "==> server (prod-unified)" server_ver="$(jq -r .version "$root/typescript/server/package.json")"
docker build -f "$root/docker/Dockerfile.server" --target prod-unified \ 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" --build-arg "COMMIT_HASH=$sha" -t tachi-server:check "$root"
echo "==> bot" echo "==> bot"
docker build -f "$root/docker/Dockerfile.bot" -t tachi-bot:check "$root" docker build -f "$root/docker/Dockerfile.bot" -t tachi-bot:check "$root"
+27 -8
View File
@@ -128,6 +128,9 @@ CREATE TABLE "job_queue" (
CREATE INDEX job_queue_partial_index_btree CREATE INDEX job_queue_partial_index_btree
ON job_queue (created_at, status) ON job_queue (created_at, status)
WHERE status = 0; 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" ( CREATE TABLE "cron_task" (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -706,7 +709,6 @@ CREATE TABLE "import_timing" (
CREATE TABLE "score" ( CREATE TABLE "score" (
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
-- TODO(zk): Redundant? joins on session(id).
user_id BIGINT REFERENCES account(id) NOT NULL, user_id BIGINT REFERENCES account(id) NOT NULL,
chart_id TEXT REFERENCES chart(id) NOT NULL, chart_id TEXT REFERENCES chart(id) NOT NULL,
@@ -915,6 +917,7 @@ CREATE TABLE "pb" (
CREATE TABLE "pb_composed_from" ( CREATE TABLE "pb_composed_from" (
pb_id UUID REFERENCES pb(row_id) NOT NULL, pb_id UUID REFERENCES pb(row_id) NOT NULL,
score_id TEXT REFERENCES score(id) NOT NULL, score_id TEXT REFERENCES score(id) NOT NULL,
merge_name TEXT NOT NULL,
PRIMARY KEY (pb_id, score_id) PRIMARY KEY (pb_id, score_id)
); );
@@ -1047,7 +1050,7 @@ CREATE TABLE "quest" (
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
-- Array<QuestSection> sections with titles, descs, and goal references. -- Array<QuestSection> - sections with titles, descs, and goal references.
-- Too structured/variable to flatten usefully. -- Too structured/variable to flatten usefully.
quest_data JSONB NOT NULL 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) CREATE INDEX score_user_highlights_idx ON score (user_id, time_added DESC)
WHERE highlight = true; 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. -- Needed separately because score_user_chart_idx has user_id as the left column.
CREATE INDEX score_chart_idx ON score (chart_id); 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); 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. -- The single most important index in the schema.
-- Serves: chart_leaderboard view, leaderboard pagination, and the COUNT rank -- Serves: chart_leaderboard view, leaderboard pagination, and the COUNT rank
-- queries ("how many PBs beat mine?"). chart_id partitions the 5M rows into -- 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. -- so game-first lookups need their own index.
CREATE INDEX game_stats_snapshot_game_idx ON game_stats_snapshot (game, timestamp DESC); CREATE INDEX game_stats_snapshot_game_idx ON game_stats_snapshot (game, timestamp DESC);
-- chart general -- chart - general
-- "All charts for song X" song pages, chart listing. Postgres does NOT -- "All charts for song X" - song pages, chart listing. Postgres does NOT
-- auto-index FK columns, so this needs to be explicit. -- auto-index FK columns, so this needs to be explicit.
CREATE INDEX chart_song_idx ON chart (song_id); 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) ON chart (game, song_id, difficulty)
WHERE is_primary = true; 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. -- Partial expression indexes mirroring the per-game Mongo collections.
-- Used during score import to look up charts by their game-native ID or hash. -- 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); CREATE UNIQUE INDEX priv_discord_user_map_user_id_idx ON priv_discord_user_map (user_id);
-- <== End bot -- <== 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$;
@@ -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$;
@@ -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;
+1 -1
View File
@@ -39,7 +39,7 @@ function seedMinioCdn {
# Debian package `minio-client` installs the `minio-client` binary (same CLI as upstream `mc`). # 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). # override endpoint with MINIO_ENDPOINT (devcontainer sets http://tachi-s3:9000).
if ! command -v minio-client &> /dev/null; then 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 exit 1
fi fi
+3 -3
View File
@@ -1,12 +1,12 @@
# Fisher # 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! 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 - Fisher is 100% pure-Fish, making it easy to contribute or modify
- Scorching fast concurrent plugin downloads that'll make you question reality - Scorching fast concurrent plugin downloads that'll make you question reality
- Zero configuration neededwe're not kidding! - Zero configuration needed-we're not kidding!
- Oh My Fish! plugins supported too - 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) > #### ☝️ [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 fisher install jorgebucaran/nvm.fish
``` ```
> Wanna install from GitLab? No problemojust 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). 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).
+21 -55
View File
@@ -1,74 +1,39 @@
# Build context must be the repo root (same as GHA docker-push: context `.`). # 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. # 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 \
# docker build -f docker/Dockerfile.server --target prod-api . # --build-arg VERSION="$(jq -r .version typescript/server/package.json)" \
# docker build -f docker/Dockerfile.server --target prod-job-worker . # --build-arg COMMIT_HASH="$(git rev-parse --short HEAD)" -t tachi-server:local .
# docker build -f docker/Dockerfile.server --target prod-cron-worker . #
# 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 # NOTE: bun build --compile is NOT used here because it's
# FUCKING BROKEN # FUCKING BROKEN
FROM oven/bun:alpine AS base FROM oven/bun:alpine AS deps
WORKDIR /app WORKDIR /app
# --- install: full tree so bun sees every workspace package ---
FROM base AS deps
COPY . . COPY . .
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
# --- API image --- FROM oven/bun:alpine AS prod
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
ARG VERSION
ARG COMMIT_HASH 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 COMMIT_HASH=${COMMIT_HASH}
ENV MIGRATIONS_DIR=/app/db/migrations ENV MIGRATIONS_DIR=/app/db/migrations
ENV PORT=8080 ENV PORT=8080
# curl is only needed by the API healthcheck below; workers don't use it.
RUN apk add --no-cache curl RUN apk add --no-cache curl
WORKDIR /app WORKDIR /app
@@ -76,6 +41,7 @@ COPY --from=deps /app .
EXPOSE 8080 EXPOSE 8080
# Matches the default CMD (API server). Worker deployments must override this.
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s \ HEALTHCHECK --interval=15s --timeout=5s --start-period=30s \
CMD curl -sf http://localhost:8080/.deploy/up || exit 1 CMD curl -sf http://localhost:8080/.deploy/up || exit 1
+2 -2
View File
@@ -69,7 +69,7 @@ Empty Object.
`POST /api/v1/admin/recalc-pbs` `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 filteralways 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 ### Permissions
@@ -191,7 +191,7 @@ Empty Object.
`POST /api/v1/admin/recalc` `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 filteralways 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 ### Permissions
+19 -7
View File
@@ -236,13 +236,13 @@ provide callback URLs inside emails.
SMTP is always configured. Set: SMTP is always configured. Set:
- `TACHI_EMAIL_FROM` `From` header (must match a verified sender when using Postmark). - `TACHI_EMAIL_FROM` - `From` header (must match a verified sender when using Postmark).
- `TACHI_EMAIL_AUTH_POSTMARK` `true` or `false`. When `true`, uses Postmarks SMTP - `TACHI_EMAIL_HOST`, `TACHI_EMAIL_PORT`, `TACHI_EMAIL_SECURE` (`true` / `false`).
endpoint; set `TACHI_EMAIL_AUTH_USER` and `TACHI_EMAIL_AUTH_PASS` to your server API token - Optionally `TACHI_EMAIL_AUTH_USER` / `TACHI_EMAIL_AUTH_PASS` for SMTP auth (local Mailpit
(both are the token for Postmark SMTP). typically needs none).
- When Postmark is `false`: `TACHI_EMAIL_HOST`, `TACHI_EMAIL_PORT`, `TACHI_EMAIL_SECURE` - For Postmark, use host `smtp.postmarkapp.com` (usually port `587` with `TACHI_EMAIL_SECURE=false`)
(`true` / `false`), and optionally `TACHI_EMAIL_AUTH_USER` / `TACHI_EMAIL_AUTH_PASS` and set either auth field to your server API token (both username and password are the token for
for servers that require auth (local Mailpit typically needs no auth). Postmark SMTP).
`TRANSPORT_OPS` is derived from these variables and passed to Nodemailer. `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 ### TACHI_CONFIG
- Type: TACHI_CONFIG - Type: TACHI_CONFIG
@@ -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, "editable": true,
"fiscalYearStartMonth": 0, "fiscalYearStartMonth": 0,
"graphTooltip": 1, "graphTooltip": 1,
+1 -1
View File
@@ -7,7 +7,7 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": false, "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, "noEmit": true,
"types": [] "types": []
} }
+1 -1
View File
@@ -32,7 +32,7 @@ export default defineConfig({
DISCORD_SERVER_ID: "test-server-id", 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, fileParallelism: true,
globalSetup: "./vitest.globalSetup.ts", globalSetup: "./vitest.globalSetup.ts",
setupFiles: "./vitest.setup.ts", setupFiles: "./vitest.setup.ts",
+1 -1
View File
@@ -1,7 +1,7 @@
import { execSync } from "node:child_process"; 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 * Creates a fully-migrated template database. Workers clone from it
* instead of running migrations themselves, which is much faster. * instead of running migrations themselves, which is much faster.
+2 -2
View File
@@ -20,7 +20,7 @@ const POSTGRES_HOST = "tachi-postgres";
const POSTGRES_USER = "tachi"; const POSTGRES_USER = "tachi";
const POSTGRES_PASS = "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}`; process.env.POSTGRES_URL = `postgresql://${POSTGRES_USER}:${POSTGRES_PASS}@${POSTGRES_HOST}/${WORKER_DB_NAME}`;
// Mock global fetch before any app code is imported. // Mock global fetch before any app code is imported.
@@ -45,7 +45,7 @@ globalThis.fetch = (url: string | Request | URL): Promise<Response> => {
} as unknown as Response); } 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()}`); throw new Error(`Unexpected fetch() in test: ${url.toString()}`);
}; };
@@ -33,7 +33,7 @@ type ActionsResponse = {
function durationMs(start: string, end: string): string { function durationMs(start: string, end: string): string {
const ms = Date.parse(end) - Date.parse(start); const ms = Date.parse(end) - Date.parse(start);
if (Number.isNaN(ms)) { if (Number.isNaN(ms)) {
return ""; return "-";
} }
if (ms < 1000) { if (ms < 1000) {
return `${ms}ms`; return `${ms}ms`;
@@ -196,11 +196,11 @@ export default function AdminActionsPage() {
{action.username} {action.username}
</Link> </Link>
) : ( ) : (
<span className="text-body-secondary"></span> <span className="text-body-secondary">-</span>
)} )}
</td> </td>
<td className="font-monospace small"> <td className="font-monospace small">
{action.ip ?? ""} {action.ip ?? "-"}
</td> </td>
<td className="align-middle"> <td className="align-middle">
<Button <Button
@@ -31,11 +31,11 @@ type CronResponse = {
function durationMs(startedAt: string, completedAt: string | null): string { function durationMs(startedAt: string, completedAt: string | null): string {
if (!completedAt) { if (!completedAt) {
return ""; return "-";
} }
const ms = Date.parse(completedAt) - Date.parse(startedAt); const ms = Date.parse(completedAt) - Date.parse(startedAt);
if (Number.isNaN(ms)) { if (Number.isNaN(ms)) {
return ""; return "-";
} }
if (ms < 1000) { if (ms < 1000) {
return `${ms}ms`; return `${ms}ms`;
@@ -175,7 +175,7 @@ export default function AdminCronJobsPage() {
</details> </details>
) : ( ) : (
<span className="text-body-secondary"> <span className="text-body-secondary">
-
</span> </span>
)} )}
</td> </td>
@@ -47,7 +47,7 @@ export default function AdminOperationsPage() {
}} }}
value={announcementGame === "" ? "" : announcementGame} value={announcementGame === "" ? "" : announcementGame}
> >
<option value=""> Site-wide </option> <option value="">- Site-wide -</option>
{TachiConfig.GAME_GROUPS.map((g) => ( {TachiConfig.GAME_GROUPS.map((g) => (
<option key={g} value={g}> <option key={g} value={g}>
{g} {g}
@@ -62,7 +62,7 @@ export default function AdminOperationsPage() {
onChange={(e) => setAnnouncementPlaytype(e.target.value)} onChange={(e) => setAnnouncementPlaytype(e.target.value)}
value={announcementPlaytype} value={announcementPlaytype}
> >
<option value=""></option> <option value="">-</option>
{announcementGameConfig.playtypes.map((pt) => ( {announcementGameConfig.playtypes.map((pt) => (
<option key={pt} value={pt}> <option key={pt} value={pt}>
{FormatGame( {FormatGame(
@@ -187,7 +187,7 @@ export default function UserOrphansPage({ reqUser }: { reqUser: UserDocument })
<Modal onHide={() => setDetailOpen(false)} show={detailOpen} size="lg"> <Modal onHide={() => setDetailOpen(false)} show={detailOpen} size="lg">
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title>Orphan details {detailTitle}</Modal.Title> <Modal.Title>Orphan details - {detailTitle}</Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
{detailLoading ? ( {detailLoading ? (
@@ -221,7 +221,7 @@ export default function UserOrphansPage({ reqUser }: { reqUser: UserDocument })
]} ]}
rowFunction={(o) => ( rowFunction={(o) => (
<tr key={o.orphanID}> <tr key={o.orphanID}>
<td>{o.summary ?? ""}</td> <td>{o.summary ?? "-"}</td>
<td> <td>
<code className="small">{o.importType}</code> <code className="small">{o.importType}</code>
</td> </td>
@@ -230,7 +230,7 @@ export default function UserOrphansPage({ reqUser }: { reqUser: UserDocument })
o.gameGroup} o.gameGroup}
</td> </td>
<td>{new Date(o.timeInserted).toLocaleString()}</td> <td>{new Date(o.timeInserted).toLocaleString()}</td>
<td className="small text-muted">{o.message ?? ""}</td> <td className="small text-muted">{o.message ?? "-"}</td>
<td> <td>
<div className="d-flex flex-wrap gap-1"> <div className="d-flex flex-wrap gap-1">
<Button <Button
@@ -6,18 +6,19 @@ import DebugContent from "#components/util/DebugContent";
import Divider from "#components/util/Divider"; import Divider from "#components/util/Divider";
import EditableText from "#components/util/EditableText"; import EditableText from "#components/util/EditableText";
import Icon from "#components/util/Icon"; import Icon from "#components/util/Icon";
import LinkButton from "#components/util/LinkButton";
import Loading from "#components/util/Loading"; import Loading from "#components/util/Loading";
import useApiQuery from "#components/util/query/useApiQuery"; import useApiQuery from "#components/util/query/useApiQuery";
import { UserContext } from "#context/UserContext"; import { UserContext } from "#context/UserContext";
import { UserSettingsContext } from "#context/UserSettingsContext"; import { UserSettingsContext } from "#context/UserSettingsContext";
import { type SessionReturns } from "#types/api-returns"; import { type SessionAdjacentReturns, type SessionReturns } from "#types/api-returns";
import { type UGPT } from "#types/react"; import { type UGPT } from "#types/react";
import { APIFetchV1 } from "#util/api"; import { APIFetchV1 } from "#util/api";
import { CreateChartMap, CreateScoreIDMap, CreateSongMap } from "#util/data"; import { CreateChartMap, CreateScoreIDMap, CreateSongMap } from "#util/data";
import React, { useContext, useMemo, useState } from "react"; import React, { useContext, useMemo, useState } from "react";
import { Badge, Button, Col, Row } from "react-bootstrap"; import { Badge, Button, Col, Row } from "react-bootstrap";
import { Redirect, useParams } from "react-router-dom"; import { Redirect, useParams } from "react-router-dom";
import { GameToGameGroup, GetGameGroupConfig } from "tachi-common"; import { GameToGameGroup, GetGameGroupConfig, type SessionDocument } from "tachi-common";
export default function SpecificSessionPage({ reqUser, game }: UGPT) { export default function SpecificSessionPage({ reqUser, game }: UGPT) {
const { sessionID } = useParams<{ sessionID: string }>(); const { sessionID } = useParams<{ sessionID: string }>();
@@ -127,8 +128,20 @@ function SessionPage({ data, game }: { data: SessionReturns } & UGPT) {
); );
}; };
const { data: adjacentData } = useApiQuery<SessionAdjacentReturns>(
`/sessions/${session.sessionID}/adjacent`,
);
return ( return (
<Row className="justify-content-center"> <Row className="justify-content-center">
<Col xs={12}>
<SessionNavRow
adjacent={adjacentData ?? null}
currentName={session.name}
game={game}
username={user.username}
/>
</Col>
<Col className="text-center" xs={12}> <Col className="text-center" xs={12}>
<div className="d-flex flex-column gap-2 flex-wrap justify-content-center align-items-center"> <div className="d-flex flex-column gap-2 flex-wrap justify-content-center align-items-center">
<EditableText <EditableText
@@ -259,3 +272,46 @@ function SessionPage({ data, game }: { data: SessionReturns } & UGPT) {
</Row> </Row>
); );
} }
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 (
<div className="d-flex align-items-center justify-content-between mb-4">
<div style={{ minWidth: "140px" }}>
{adjacent?.prev ? (
<LinkButton to={sessionUrl(adjacent.prev)} variant="outline-secondary">
&laquo; Prev
</LinkButton>
) : (
<Button disabled variant="outline-secondary">
&laquo; Prev
</Button>
)}
</div>
<span className="text-muted text-truncate px-2">{currentName}</span>
<div style={{ minWidth: "140px" }} className="text-end">
{adjacent?.next ? (
<LinkButton to={sessionUrl(adjacent.next)} variant="outline-secondary">
Next &raquo;
</LinkButton>
) : (
<Button disabled variant="outline-secondary">
Next &raquo;
</Button>
)}
</div>
</div>
);
}
@@ -44,6 +44,10 @@ import {
type UserDocument, type UserDocument,
} from "tachi-common"; } 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. // Records activity for a group of users on a GPT. Also used for single users.
export default function Activity({ export default function Activity({
url, url,
@@ -291,7 +295,16 @@ function ScoresActivity({
<div className="timeline-item timeline-hover my-4"> <div className="timeline-item timeline-hover my-4">
<div className="timeline-badge bg-warning"></div> <div className="timeline-badge bg-warning"></div>
<div className="timeline-content flex-nowrap"> <div className="timeline-content flex-nowrap">
<div className="timeline-content-inner" onClick={() => setShow(!show)}> <div
className="timeline-content-inner activity-entry-toggle"
onClick={(e) => {
if (isActivityInteractiveTarget(e.target as HTMLElement)) {
return;
}
setShow(!show);
}}
>
<div className="timeline-content-title"> <div className="timeline-content-title">
<span className="me-2"> <span className="me-2">
<ProfilePicture size="sm" toGPT={{ game }} user={user} /> <ProfilePicture size="sm" toGPT={{ game }} user={user} />
@@ -323,10 +336,10 @@ function ScoresActivity({
</div> </div>
{show && ( {show && (
<> <div className="activity-expand-body">
<Divider /> <Divider />
<ScoreTable dataset={dataset} game={game} noTopDisplayStr /> <ScoreTable dataset={dataset} game={game} noTopDisplayStr />
</> </div>
)} )}
</div> </div>
</div> </div>
@@ -367,7 +380,16 @@ function GoalActivity({
<div className="timeline-item timeline-hover my-4"> <div className="timeline-item timeline-hover my-4">
<div className="timeline-badge bg-warning"></div> <div className="timeline-badge bg-warning"></div>
<div className="timeline-content"> <div className="timeline-content">
<div className="timeline-content-inner" onClick={() => setShow(!show)}> <div
className="timeline-content-inner activity-entry-toggle"
onClick={(e) => {
if (isActivityInteractiveTarget(e.target as HTMLElement)) {
return;
}
setShow(!show);
}}
>
<div className="timeline-content-title"> <div className="timeline-content-title">
<span className="me-2"> <span className="me-2">
<ProfilePicture size="sm" toGPT={{ game }} user={user} /> <ProfilePicture size="sm" toGPT={{ game }} user={user} />
@@ -399,7 +421,7 @@ function GoalActivity({
</div> </div>
{show && ( {show && (
<> <div className="activity-expand-body">
<Divider /> <Divider />
<div className="ps-4"> <div className="ps-4">
{data.goals.map((e) => ( {data.goals.map((e) => (
@@ -410,7 +432,7 @@ function GoalActivity({
/> />
))} ))}
</div> </div>
</> </div>
)} )}
</div> </div>
</div> </div>
@@ -485,7 +507,16 @@ function SessionActivity({
<div className="timeline-item timeline-hover"> <div className="timeline-item timeline-hover">
<div className={`timeline-badge bg-${data.highlight ? "warning" : "secondary"}`}></div> <div className={`timeline-badge bg-${data.highlight ? "warning" : "secondary"}`}></div>
<div className="timeline-content"> <div className="timeline-content">
<div className="timeline-content-inner" onClick={() => setShow(!show)}> <div
className="timeline-content-inner activity-entry-toggle"
onClick={(e) => {
if (isActivityInteractiveTarget(e.target as HTMLElement)) {
return;
}
setShow(!show);
}}
>
<div className="timeline-content-title"> <div className="timeline-content-title">
<span className="me-2"> <span className="me-2">
<ProfilePicture size="sm" toGPT={{ game }} user={user} /> <ProfilePicture size="sm" toGPT={{ game }} user={user} />
@@ -529,7 +560,11 @@ function SessionActivity({
</span> </span>
</div> </div>
</div> </div>
{show && <SessionShower sessionID={data.sessionID} />} {show && (
<div className="activity-expand-body">
<SessionShower sessionID={data.sessionID} />
</div>
)}
</div> </div>
</div> </div>
); );
@@ -131,7 +131,7 @@ export default function ImportInfo({
<br /> <br />
<strong>SongOrChartNotFound</strong> means the score was still{" "} <strong>SongOrChartNotFound</strong> means the score was still{" "}
<strong>saved as an orphan</strong> for nightly matching (around 00:01 <strong>saved as an orphan</strong> for nightly matching (around 00:01
UTC) or manual reprocess see{" "} UTC) or manual reprocess - see{" "}
{user ? ( {user ? (
<Link to={`/u/${user.username}/orphans`}>Orphan scores</Link> <Link to={`/u/${user.username}/orphans`}>Orphan scores</Link>
) : ( ) : (
+2 -2
View File
@@ -22,10 +22,10 @@ try {
// Do NOT use document.open() / document.write() / document.close() here. // Do NOT use document.open() / document.write() / document.close() here.
// document.open() fires unload/beforeunload/pagehide events, which Vite's HMR // document.open() fires unload/beforeunload/pagehide events, which Vite's HMR
// client intercepts to close its WebSocket. The HMR reconnection logic then // 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. // 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. // that Vite HMR uses to trigger a reload.
// //
// Instead, use direct DOM mutation (no unload events) and console.error. // Instead, use direct DOM mutation (no unload events) and console.error.
-1
View File
@@ -120,7 +120,6 @@ const IIDX_PROFILE_RATING_VALUE_CELL_STYLE: NonNullable<
GPTClientImplementation<GamesForGroup["iidx"]>["profileRatingAlgRowStyle"] GPTClientImplementation<GamesForGroup["iidx"]>["profileRatingAlgRowStyle"]
> = { > = {
ktLampRating: { backgroundColor: ChangeOpacity(COLOUR_SET.purple, 0.12) }, ktLampRating: { backgroundColor: ChangeOpacity(COLOUR_SET.purple, 0.12) },
ktLampRatingNC: { backgroundColor: ChangeOpacity(COLOUR_SET.blue, 0.14) },
ktLampRatingHC: { backgroundColor: ChangeOpacity(COLOUR_SET.orange, 0.14) }, ktLampRatingHC: { backgroundColor: ChangeOpacity(COLOUR_SET.orange, 0.14) },
ktLampRatingEXHC: { backgroundColor: ChangeOpacity(COLOUR_SET.gold, 0.12) }, ktLampRatingEXHC: { backgroundColor: ChangeOpacity(COLOUR_SET.gold, 0.12) },
BPI: { backgroundColor: ChangeOpacity(COLOUR_SET.paleBlue, 0.12) }, BPI: { backgroundColor: ChangeOpacity(COLOUR_SET.paleBlue, 0.12) },
@@ -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;
}
}
@@ -97,6 +97,11 @@ export interface SessionReturns<GPT extends V3Game = V3Game> {
user: UserDocument; user: UserDocument;
} }
export interface SessionAdjacentReturns {
prev: SessionDocument | null;
next: SessionDocument | null;
}
export interface UGPTChartPBComposition<GPT extends V3Game = V3Game> { export interface UGPTChartPBComposition<GPT extends V3Game = V3Game> {
scores: ScoreDocument<GPT>[]; scores: ScoreDocument<GPT>[];
chart: ChartDocument<GPT>; chart: ChartDocument<GPT>;
-13
View File
@@ -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
@@ -172,10 +172,6 @@ export const GAME_IIDX_SP_CONF = {
description: description:
"A rating system that values your clear lamps on charts. Tierlist information is taken into account.", "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: { ktLampRatingHC: {
description: "Your hard clear performance. Tierlist information is taken into account.", description: "Your hard clear performance. Tierlist information is taken into account.",
}, },
@@ -195,30 +191,24 @@ export const GAME_IIDX_SP_CONF = {
associatedScoreAlgs: ["ktLampRating"], associatedScoreAlgs: ["ktLampRating"],
displayOrder: 0, displayOrder: 0,
}, },
ktLampRatingNC: {
description: `An average of your best 20 ktLampRatingNCs.`,
associatedScoreAlgs: ["ktLampRatingNC"],
displayOrder: 1,
},
ktLampRatingHC: { ktLampRatingHC: {
description: `An average of your best 20 ktLampRatingHCs.`, description: `An average of your best 20 ktLampRatingHCs.`,
associatedScoreAlgs: ["ktLampRatingHC"], associatedScoreAlgs: ["ktLampRatingHC"],
displayOrder: 2, displayOrder: 1,
}, },
ktLampRatingEXHC: { ktLampRatingEXHC: {
description: `An average of your best 20 ktLampRatingEXHCs.`, description: `An average of your best 20 ktLampRatingEXHCs.`,
associatedScoreAlgs: ["ktLampRatingEXHC"], associatedScoreAlgs: ["ktLampRatingEXHC"],
displayOrder: 3, displayOrder: 2,
}, },
BPI: { BPI: {
description: `An average of your best 20 BPIs.`, description: `An average of your best 20 BPIs.`,
associatedScoreAlgs: ["BPI"], associatedScoreAlgs: ["BPI"],
displayOrder: 4, displayOrder: 3,
}, },
}, },
sessionRatingAlgs: { sessionRatingAlgs: {
ktLampRating: { description: `An average of the best 10 ktLampRatings this session.` }, 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.` }, ktLampRatingHC: { description: `An average of the best 10 ktLampRatingHCs this session.` },
ktLampRatingEXHC: { ktLampRatingEXHC: {
description: `An average of the best 10 ktLampRatingEXHCs this session.`, description: `An average of the best 10 ktLampRatingEXHCs this session.`,
+1 -1
View File
@@ -351,7 +351,7 @@ function encodeBmsTableKey(game: string, key: string): string {
} }
throw new Error( 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.`,
); );
} }
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig({
NODE_ENV: "test", NODE_ENV: "test",
}, },
exclude: [ exclude: [
// Compiled output not test sources. // Compiled output - not test sources.
"build/**", "build/**",
], ],
coverage: { coverage: {
@@ -119,7 +119,7 @@ function printHuman(report: CoverageReport): void {
for (const p of report.packages) { for (const p of report.packages) {
if (!p.present || !p.metrics) { 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; continue;
} }
console.log(row(p.id, p.metrics)); console.log(row(p.id, p.metrics));
+1 -1
View File
@@ -254,7 +254,7 @@ migrateCmd
for (const info of infos) { for (const info of infos) {
const status = const status =
info.success === null ? "Pending" : info.success ? "Applied" : "Failed"; info.success === null ? "Pending" : info.success ? "Applied" : "Failed";
const appliedAt = info.appliedAt ? formatDate(info.appliedAt) : ""; const appliedAt = info.appliedAt ? formatDate(info.appliedAt) : "-";
const desc = const desc =
info.description.length > descWidth - 2 info.description.length > descWidth - 2
? `${info.description.slice(0, descWidth - 5)}...` ? `${info.description.slice(0, descWidth - 5)}...`
+4 -4
View File
@@ -142,7 +142,7 @@ async function withAdvisoryLock<T>(client: Client, fn: () => Promise<T>): Promis
/** /**
* Apply all pending migrations from migrationsDir against the given connection. * 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). * Throws if a previously-applied migration's file has been modified (checksum mismatch).
*/ */
export async function applyMigrations( export async function applyMigrations(
@@ -180,7 +180,7 @@ export async function applyMigrations(
continue; continue;
} }
// Previously attempted and failed clear the record and retry. // Previously attempted and failed - clear the record and retry.
await client.query( await client.query(
`DELETE FROM "_migration" WHERE version = $1 AND success = false`, `DELETE FROM "_migration" WHERE version = $1 AND success = false`,
[migration.version], [migration.version],
@@ -188,7 +188,7 @@ export async function applyMigrations(
} }
console.log( console.log(
`[migrate] Applying ${migration.version} ${migration.description}...`, `[migrate] Applying ${migration.version} - ${migration.description}...`,
); );
const start = process.hrtime.bigint(); const start = process.hrtime.bigint();
@@ -334,7 +334,7 @@ export async function revertLastMigration(
const downSql = readFileSync(downPath, "utf8"); const downSql = readFileSync(downPath, "utf8");
console.log(`[migrate] Reverting ${latestVersion} ${latest.description}...`); console.log(`[migrate] Reverting ${latestVersion} - ${latest.description}...`);
const start = process.hrtime.bigint(); const start = process.hrtime.bigint();
@@ -10,6 +10,8 @@ export default interface PbComposedFromTable {
pb_id: ColumnType<pb_row_id, pb_row_id, pb_row_id>; pb_id: ColumnType<pb_row_id, pb_row_id, pb_row_id>;
score_id: ColumnType<score_id, score_id, score_id>; score_id: ColumnType<score_id, score_id, score_id>;
merge_name: ColumnType<string, string, string>;
} }
export type PbComposedFrom = Selectable<PbComposedFromTable>; export type PbComposedFrom = Selectable<PbComposedFromTable>;
+13 -4
View File
@@ -26,7 +26,7 @@ const base = tseslint.config(
...tseslint.configs.recommended, ...tseslint.configs.recommended,
pluginPerfectionist.configs["recommended-natural"], pluginPerfectionist.configs["recommended-natural"],
{ {
name: "zenith/base", name: "tachi/base",
files: ["**/*.{ts,tsx}"], files: ["**/*.{ts,tsx}"],
plugins: { plugins: {
import: pluginImport, 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} */ ({ const node = /** @type {import("@typescript-eslint/utils/ts-eslint").FlatConfig.Config} */ ({
name: "zenith/node", name: "tachi/node",
files: ["**/*.{js,mjs,cjs,ts,tsx}"], files: ["**/*.{js,mjs,cjs,ts,tsx}"],
languageOptions: { languageOptions: {
globals: { 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} */ ({ const react = /** @type {import("@typescript-eslint/utils/ts-eslint").FlatConfig.Config} */ ({
name: "zenith/react", name: "tachi/react",
files: ["**/*.tsx"], files: ["**/*.tsx"],
languageOptions: { languageOptions: {
parserOptions: { 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} */ ({ const reactRemix = /** @type {import("@typescript-eslint/utils/ts-eslint").FlatConfig.Config} */ ({
name: "zenith/react-remix", name: "tachi/react-remix",
languageOptions: { languageOptions: {
// @ts-expect-error it is definitely not undefined // @ts-expect-error it is definitely not undefined
parserOptions: react.languageOptions.parserOptions, parserOptions: react.languageOptions.parserOptions,
+1 -1
View File
@@ -19,7 +19,7 @@ export default defineConfig({
exclude: [ exclude: [
"src/**/*.test.ts", "src/**/*.test.ts",
"src/test-utils/**", "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", "src/index.ts",
// Type-only / unused by current test surface (Tap nyc also did not require these). // Type-only / unused by current test surface (Tap nyc also did not require these).
"src/util/types.ts", "src/util/types.ts",
@@ -20,9 +20,7 @@ const options = program.opts();
const DEBUG = options.debug; const DEBUG = options.debug;
const db = new Database(options.db, { readonly: true }); const db = new Database(options.db, { readonly: true });
const dbRows = db const dbRows = db.query(`SELECT * FROM Charts WHERE path LIKE '%${options.filter}%'`).all();
.query(`SELECT * FROM Charts WHERE path LIKE '%${options.filter}%'`)
.all();
console.log(`Found ${dbRows.length} charts.`); console.log(`Found ${dbRows.length} charts.`);
const songs = ReadCollection("songs-usc.json"); const songs = ReadCollection("songs-usc.json");
@@ -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`). * to key lookup (must match `5-folders-to-sql-queries.ts`).
*/ */
function rewriteFolderWhereTableFoldersElemExists(where: string): string | null { function rewriteFolderWhereTableFoldersElemExists(where: string): string | null {
+1 -1
View File
@@ -1,6 +1,6 @@
# tachi-seeds-webui # 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. localdev, edit `db/seeds/*.json` directly.
## Running ## Running
+2 -2
View File
@@ -54,7 +54,7 @@ export async function listBranches(
} }
} }
} catch { } catch {
// detached head leave current null. // detached head - leave current null.
} }
return { branches, current }; return { branches, current };
@@ -129,7 +129,7 @@ export async function statusPorcelain(
branch = m?.[1] ?? null; branch = m?.[1] ?? null;
continue; 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)); changed.push(line.slice(3));
} }
return { branch, changed, hasUncommitted: changed.length > 0 }; return { branch, changed, hasUncommitted: changed.length > 0 };
@@ -15,7 +15,7 @@ import {
} from "./util/git"; } from "./util/git";
import { applyPatch, type JsonPatch } from "./util/json-patch"; 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: // local repo's db/seeds directory so the seeds-webui can:
// //
// - GET /__seeds/ping feature probe // - GET /__seeds/ping feature probe
+1 -1
View File
@@ -22,7 +22,7 @@ const queryClient = new QueryClient({
// Load the editing routes lazily via a single dynamic import. The import is // 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 // 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 // which means `editRoutes.ts` and every page it references is never emitted
// into the prod bundle. // into the prod bundle.
type EditRoute = { component: React.ComponentType; path: string }; type EditRoute = { component: React.ComponentType; path: string };
@@ -126,14 +126,14 @@ function FieldTable({ fields }: { fields: FieldDelta[] }) {
<td className="diff-fields-path mono">{f.path}</td> <td className="diff-fields-path mono">{f.path}</td>
<td className="diff-fields-before"> <td className="diff-fields-before">
{f.kind === "added" ? ( {f.kind === "added" ? (
<span className="text-muted"></span> <span className="text-muted">-</span>
) : ( ) : (
<pre className="mono">{formatValue(f.before)}</pre> <pre className="mono">{formatValue(f.before)}</pre>
)} )}
</td> </td>
<td className="diff-fields-after"> <td className="diff-fields-after">
{f.kind === "removed" ? ( {f.kind === "removed" ? (
<span className="text-muted"></span> <span className="text-muted">-</span>
) : ( ) : (
<pre className="mono">{formatValue(f.after)}</pre> <pre className="mono">{formatValue(f.after)}</pre>
)} )}
@@ -91,7 +91,7 @@ export function CommitSparkline({ commits, days = 90 }: CommitSparklineProps) {
y={y} y={y}
> >
<title> <title>
{b.day} {b.count} commit{b.count === 1 ? "" : "s"} {b.day} - {b.count} commit{b.count === 1 ? "" : "s"}
</title> </title>
</rect> </rect>
); );
@@ -39,7 +39,7 @@ interface ResultTableProps {
// Pass `expandedContent` for custom content (e.g. a diff); otherwise the // Pass `expandedContent` for custom content (e.g. a diff); otherwise the
// detail is the whole row as pretty-printed JSON. // detail is the whole row as pretty-printed JSON.
// - `rowDetailMode="inline"` (with `expandedContent`) shows that detail // - `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({ export function ResultTable({
result, result,
maxRows = 500, maxRows = 500,
@@ -6,7 +6,7 @@ import { type z } from "zod";
// //
// Replaces the previous schema-driven field-tree form. Seeds documents are // Replaces the previous schema-driven field-tree form. Seeds documents are
// complex enough (discriminated unions, per-game chart data, etc.) that a // 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. // the raw JSON and let the schema tell you exactly what's wrong.
// //
// Behaviour: // Behaviour:
@@ -7,7 +7,7 @@ import { basicSetup, EditorView } from "codemirror";
import { useCallback, useEffect, useRef, useState } from "react"; 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. * (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 * When `themeType: "dark"`, this becomes the only non-fallback highlighter, so the purple
* palette is not used. * palette is not used.
@@ -33,7 +33,7 @@ const sqlEditorHighlightDark = HighlightStyle.define(
{ themeType: "dark" }, { themeType: "dark" },
); );
/** `data-bs-theme` on <html> must drive CodeMirror's `EditorView.darkTheme` or the caret stays light-theme (black) on a dark page. */ /** `data-bs-theme` on <html> - must drive CodeMirror's `EditorView.darkTheme` or the caret stays light-theme (black) on a dark page. */
function useBootstrapPageDark(): boolean { function useBootstrapPageDark(): boolean {
const [isDark, setIsDark] = useState( const [isDark, setIsDark] = useState(
() => document.documentElement.getAttribute("data-bs-theme") !== "light", () => document.documentElement.getAttribute("data-bs-theme") !== "light",
@@ -64,7 +64,7 @@ export type SqlEditorProps = {
value: string; 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(). // Use baseTheme: `&light` / `&dark` are only expanded in buildTheme(..., lightDarkIDs), not in EditorView.theme().
const surfaceTheme = EditorView.baseTheme({ const surfaceTheme = EditorView.baseTheme({
"&": { "&": {
@@ -75,7 +75,7 @@ const surfaceTheme = EditorView.baseTheme({
fontFeatureSettings: '"liga" 0, "calt" 0', fontFeatureSettings: '"liga" 0, "calt" 0',
}, },
// Caret: drawSelection defaults to black / #ddd for &dark, but the editor was not getting // 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": { ".cm-cursor, .cm-dropCursor": {
borderLeft: "1.2px solid var(--bs-body-color)", borderLeft: "1.2px solid var(--bs-body-color)",
}, },
+1 -1
View File
@@ -3,7 +3,7 @@
// This is a *build-time* flag set in vite.config.ts. In a production build // 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 // (`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 // 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 // `hasDevTransport` (see #lib/transport/transport) additionally probes
// /__seeds/ping at runtime before enabling the edit UI, in case a dev-built // /__seeds/ping at runtime before enabling the edit UI, in case a dev-built
@@ -46,7 +46,7 @@ export function fieldDeltas(a: Row, b: Row, prefix = ""): FieldDelta[] {
out.push({ before: av, kind: "removed", path }); out.push({ before: av, kind: "removed", path });
continue; 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 // atomic scalar values, which is the right call for things like
// `tierlistData` or chart `data`). // `tierlistData` or chart `data`).
if ( if (
@@ -196,7 +196,7 @@ export function singleDocumentDiff(
export function formatValue(v: unknown): string { export function formatValue(v: unknown): string {
if (v === undefined) { if (v === undefined) {
return ""; return "-";
} }
if (v === null) { if (v === null) {
return "null"; return "null";
@@ -1,7 +1,7 @@
export type Row = Record<string, unknown>; export type Row = Record<string, unknown>;
// Candidate primary keys, in priority order. The first one present on a row is // 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. // we want to key a chart by *its* id, not by the song it belongs to.
export const PK_KEYS = [ export const PK_KEYS = [
"id", "id",
@@ -43,7 +43,7 @@ export function schemaForCollection(name: string): z.ZodType<unknown> | null {
return SEEDS_BMS_COURSE_DOCUMENT_SCHEMA as z.ZodType<unknown>; return SEEDS_BMS_COURSE_DOCUMENT_SCHEMA as z.ZodType<unknown>;
} }
// 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 // 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. // hyphen; that would turn iidx-sp into iidx and miss the schema map.
if (stripped.startsWith("charts-")) { if (stripped.startsWith("charts-")) {
@@ -12,8 +12,8 @@ export type SeedDocSummaryOpts = {
}; };
/** /**
* Human-readable one-liner for a seeds row same rules as the collection * 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, * browser list (FormatChart for charts with a song, title - artist for songs,
* etc.). * etc.).
*/ */
export function prettySeedDocSummary( export function prettySeedDocSummary(
@@ -52,7 +52,7 @@ export function prettySeedDocSummary(
case "songs": { case "songs": {
const title = str(doc.title); const title = str(doc.title);
const artist = str(doc.artist); const artist = str(doc.artist);
return artist ? `${title} ${artist}` : title; return artist ? `${title} - ${artist}` : title;
} }
case "folders": case "folders":
case "tables": { case "tables": {
@@ -4,7 +4,7 @@ import { getSqlite } from "./client";
// Fast content hash for JSON payloads. We hash the serialised string because // Fast content hash for JSON payloads. We hash the serialised string because
// getCollection returns parsed JSON; this matches what scripts/bundle-current-seeds // 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<string> { async function hashJson(value: unknown): Promise<string> {
const s = JSON.stringify(value); const s = JSON.stringify(value);
const buf = new TextEncoder().encode(s); const buf = new TextEncoder().encode(s);
@@ -36,7 +36,7 @@ export async function* buildSqliteFromTransport(
const name = names[i]!; const name = names[i]!;
const rows = await transport.getCollection(name, rev); const rows = await transport.getCollection(name, rev);
const hash = await hashJson(rows); 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 // a specific commit, not the working copy, and we don't cache historical
// loads to avoid OPFS bloat. // loads to avoid OPFS bloat.
if (rev === undefined && meta[name] === hash) { if (rev === undefined && meta[name] === hash) {
@@ -3,12 +3,12 @@
// Strategy: *per-collection* tables mirror the on-disk files exactly // 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 // (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 // `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. // `json_extract` makes that queryable when needed.
// //
// Cross-cutting questions ("how many charts does iidx-sp have across all // Cross-cutting questions ("how many charts does iidx-sp have across all
// versions?") get answered by materialised views built over per-collection // 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_<game>` table that has been created. // references every `charts_<game>` table that has been created.
// //
// Table name convention: lowercase, '-' -> '_', strip '.json'. // Table name convention: lowercase, '-' -> '_', strip '.json'.
@@ -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<RunEvent>. // plus a final `exit` event, so we wrap it as an AsyncIterable<RunEvent>.
async function* streamSSE<T>(pathname: string, body: unknown): AsyncIterable<T> { async function* streamSSE<T>(pathname: string, body: unknown): AsyncIterable<T> {
const res = await fetch(`${BASE}${pathname}`, { const res = await fetch(`${BASE}${pathname}`, {
@@ -58,7 +58,7 @@ export type JsonPatch = JsonPatchOp[];
export interface SeedsTransport { export interface SeedsTransport {
readonly mode: "dev" | "github"; readonly mode: "dev" | "github";
// Read-side always available. // Read-side - always available.
listCollections(): Promise<CollectionName[]>; listCollections(): Promise<CollectionName[]>;
listBranches(): Promise<{ branches: Branch[]; current: Branch | null }>; listBranches(): Promise<{ branches: Branch[]; current: Branch | null }>;
listCommits(opts: { branch?: string; cursor?: string; file?: string }): Promise<CommitPage>; listCommits(opts: { branch?: string; cursor?: string; file?: string }): Promise<CommitPage>;
@@ -67,7 +67,7 @@ export interface SeedsTransport {
// branch HEAD" in github. // branch HEAD" in github.
getCollection(name: CollectionName, rev?: string): Promise<unknown[]>; getCollection(name: CollectionName, rev?: string): Promise<unknown[]>;
// Write-side populated only in dev mode. // Write-side - populated only in dev mode.
writeCollection?: (name: CollectionName, patch: JsonPatch) => Promise<void>; writeCollection?: (name: CollectionName, patch: JsonPatch) => Promise<void>;
runSort?: () => Promise<void>; runSort?: () => Promise<void>;
gitStatus?: () => Promise<GitStatus>; gitStatus?: () => Promise<GitStatus>;
+1 -1
View File
@@ -174,7 +174,7 @@ export function Bulk() {
} }
} }
if (rejections.length > 0) { 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; return;
} }
@@ -197,7 +197,7 @@ export function Collection() {
{!schema && EDIT_MODE ? ( {!schema && EDIT_MODE ? (
<div className="alert alert-warning mono"> <div className="alert alert-warning mono">
No zod schema found for this collection editing is unavailable. No zod schema found for this collection - editing is unavailable.
</div> </div>
) : null} ) : null}
+2 -2
View File
@@ -25,7 +25,7 @@ function mergeSongRowsById(baseSongs: Row[], headSongs: Row[]): Map<string, Row>
// seeds.tachi.ac always points at the canonical upstream. We use this for // 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 // 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. // local clone is pointing at, and for the hosted site it's always this repo.
const GITHUB_REPO_URL = "https://github.com/zkldi/Tachi"; const GITHUB_REPO_URL = "https://github.com/zkldi/Tachi";
@@ -341,7 +341,7 @@ function CommitHeader({
</div> </div>
<div className="commit-header-meta"> <div className="commit-header-meta">
<span> <span>
<strong>{commit?.author.name ?? ""}</strong> <strong>{commit?.author.name ?? "-"}</strong>
{commit?.author.email ? ( {commit?.author.email ? (
<span className="text-muted"> &lt;{commit.author.email}&gt;</span> <span className="text-muted"> &lt;{commit.author.email}&gt;</span>
) : null} ) : null}
+2 -2
View File
@@ -94,7 +94,7 @@ async function computeDocPair(draft: Draft): Promise<DocPair | null> {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 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). // (same id=…, optional pretty title, field table, Show JSON as on /diff).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -272,7 +272,7 @@ export function Drafts() {
) : null} ) : null}
</h2> </h2>
<p className="page-subtitle"> <p className="page-subtitle">
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. automatically.
</p> </p>
</div> </div>
@@ -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; $tachi-pink: #e61c6e;
// Bootstrap overrides (must come before `@import "bootstrap-scss/bootstrap"`). // Bootstrap overrides (must come before `@import "bootstrap-scss/bootstrap"`).
@@ -31,7 +31,7 @@ $font-weight-bold: 600;
$min-contrast-ratio: 3; $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-bg-dark: #131313;
$body-tertiary-bg-dark: #1a1a1a; $body-tertiary-bg-dark: #1a1a1a;
$body-secondary-bg-dark: #242424; $body-secondary-bg-dark: #242424;
+7 -7
View File
@@ -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 { .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 { .result-col-expand {
width: 2rem; width: 2rem;
padding: 0.35rem 0.25rem 0.35rem 0.5rem; 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 { @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 // progress from anywhere in the app. Non-blocking; slides up on mount and
// fades out once the database is ready. // 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. // 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 // 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. // 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. // and the user is not already on /drafts.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+4 -4
View File
@@ -8,12 +8,12 @@ import { seedsDevPlugin } from "./dev/vite-plugin-seeds-dev";
// In `vite dev` the seedsDevPlugin mounts /__seeds/* endpoints against the // 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 // 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 // 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. // import.meta.env.VITE_SEEDS_EDIT_MODE and tree-shaken out.
const REPO_ROOT = path.resolve(__dirname, "../.."); 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 cmPackage = (name: string) => path.join(REPO_ROOT, "node_modules", "@codemirror", name);
const codemirrorSingleInstanceAliases: { const codemirrorSingleInstanceAliases: {
@@ -35,7 +35,7 @@ const lezerSingleInstanceAliases: {
})); }));
export default defineConfig(({ command }) => ({ 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. // produce a read-only bundle suitable for hosting at seeds.tachi.ac.
define: { define: {
"import.meta.env.VITE_SEEDS_EDIT_MODE": JSON.stringify(command === "serve"), "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 // In prod builds, short-circuit modules that are only reachable
// when EDIT_MODE is on. `editRoutes` hosts the edit-only pages // when EDIT_MODE is on. `editRoutes` hosts the edit-only pages
// and `schemas` pulls in the whole tachi-common game config tree // 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. // to seeds.tachi.ac.
{ {
name: "seeds-webui:strip-edit-routes", name: "seeds-webui:strip-edit-routes",
+1 -1
View File
@@ -1,6 +1,6 @@
Dockerfile 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.* .env.*
**/.env **/.env
-1
View File
@@ -13,7 +13,6 @@ MIGRATIONS_DIR=/tachi/db/migrations
LOG_LEVEL=debug LOG_LEVEL=debug
TACHI_EMAIL_FROM=dev@localhost TACHI_EMAIL_FROM=dev@localhost
TACHI_EMAIL_AUTH_POSTMARK=false
TACHI_EMAIL_HOST=tachi-mailpit TACHI_EMAIL_HOST=tachi-mailpit
TACHI_EMAIL_PORT=1025 TACHI_EMAIL_PORT=1025
TACHI_EMAIL_SECURE=false TACHI_EMAIL_SECURE=false
+4 -5
View File
@@ -53,7 +53,6 @@ TACHI_CDN_SAVE_LOCATION_PRIVATE_REGION=
# Email (required) # Email (required)
TACHI_EMAIL_FROM= TACHI_EMAIL_FROM=
TACHI_EMAIL_AUTH_POSTMARK=
TACHI_EMAIL_HOST= TACHI_EMAIL_HOST=
TACHI_EMAIL_PORT= TACHI_EMAIL_PORT=
TACHI_EMAIL_SECURE= TACHI_EMAIL_SECURE=
@@ -69,7 +68,7 @@ TACHI_MYT_API_HOST=
TACHI_ARC_AUTH_TOKEN= TACHI_ARC_AUTH_TOKEN=
TACHI_MYT_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_ID=
TACHI_FLO_OAUTH2_CLIENT_SECRET= TACHI_FLO_OAUTH2_CLIENT_SECRET=
TACHI_FLO_OAUTH2_REDIRECT_URI= TACHI_FLO_OAUTH2_REDIRECT_URI=
@@ -80,7 +79,7 @@ TACHI_MIN_OAUTH2_CLIENT_ID=
TACHI_MIN_OAUTH2_CLIENT_SECRET= TACHI_MIN_OAUTH2_CLIENT_SECRET=
TACHI_MIN_OAUTH2_REDIRECT_URI= 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_API_KEY=
TACHI_CG_DEV_URL= TACHI_CG_DEV_URL=
TACHI_CG_NAG_API_KEY= TACHI_CG_NAG_API_KEY=
@@ -88,7 +87,7 @@ TACHI_CG_NAG_URL=
TACHI_CG_GAN_API_KEY= TACHI_CG_GAN_API_KEY=
TACHI_CG_GAN_URL= 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_TYPE=
TACHI_SEEDS_PATH= TACHI_SEEDS_PATH=
TACHI_SEEDS_REPO_URL= TACHI_SEEDS_REPO_URL=
@@ -96,7 +95,7 @@ TACHI_SEEDS_USER_NAME=
TACHI_SEEDS_USER_EMAIL= TACHI_SEEDS_USER_EMAIL=
TACHI_SEEDS_BRANCH= 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_BATCH_SIZE=
TACHI_INVITE_CODE_INVITE_CAP= TACHI_INVITE_CODE_INVITE_CAP=
TACHI_INVITE_CODE_BETA_USER_BONUS= TACHI_INVITE_CODE_BETA_USER_BONUS=
-1
View File
@@ -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_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_FROM=test@example.com
TACHI_EMAIL_AUTH_POSTMARK=false
TACHI_EMAIL_HOST=127.0.0.1 TACHI_EMAIL_HOST=127.0.0.1
TACHI_EMAIL_PORT=1025 TACHI_EMAIL_PORT=1025
TACHI_EMAIL_SECURE=false TACHI_EMAIL_SECURE=false
-18
View File
@@ -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
@@ -31,6 +31,6 @@ export const ACTION_ResendVerifyEmail = MakeAction("RESEND_VERIFY_EMAIL", async
const { text, html } = EmailFormatVerifyEmail(taker.acct.username, newToken); 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 {}; return {};
}); });
@@ -1,6 +1,7 @@
import { ServerConfig } from "#lib/setup/config";
import DB from "#services/pg/db"; import DB from "#services/pg/db";
import { seedInvite, seedUser } from "#test-utils/pg-fixtures"; 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"; import { ANON_ACTION_Register } from "./register";
@@ -217,6 +218,52 @@ describe("ANON_ACTION_Register", () => {
expect(user).toBeUndefined(); 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 ────────────────────────────────────────────────────────────── // ── Audit log ──────────────────────────────────────────────────────────────
it("writes a BAD action row when the username is already taken", async () => { 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"); 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();
});
});
+63 -9
View File
@@ -7,8 +7,25 @@ import DB from "#services/pg/db";
import { Random20Hex } from "#utils/misc"; import { Random20Hex } from "#utils/misc";
import { CheckIfEmailInUse, GetUserCaseInsensitive } from "#utils/user"; import { CheckIfEmailInUse, GetUserCaseInsensitive } from "#utils/user";
import { ExpectedErr, log } from "bliss"; import { ExpectedErr, log } from "bliss";
import crypto from "crypto";
import { sql } from "kysely";
import { type UserDocument } from "tachi-common"; 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( export const ANON_ACTION_Register = MakeAnonAction(
"REGISTER", "REGISTER",
async (taker, { email, "!password": password, inviteCode, username, captcha }) => { 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<UserDocument> => { const newUser = await DB.transaction().execute(async (txn): Promise<UserDocument> => {
// 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 (ServerConfig.INVITE_CODE_CONFIG) {
if (!inviteCode) { if (!inviteCode) {
throw new ExpectedErr( throw new ExpectedErr(
@@ -55,20 +68,61 @@ export const ANON_ACTION_Register = MakeAnonAction(
"No invite code given, yet the server uses invites.", "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 const inviteCodeDoc = await txn
.selectFrom("priv_invite") .selectFrom("priv_invite")
.select("code") .select("priv_invite.code")
.where("code", "=", inviteCode) .where("priv_invite.code", "=", inviteCode!)
.where("consumed", "=", false) .where("priv_invite.consumed", "=", false)
.forUpdate()
.executeTakeFirst(); .executeTakeFirst();
if (!inviteCodeDoc) { if (!inviteCodeDoc) {
log.info(`Invalid invite code given: ${inviteCode}.`); log.info(`Invalid invite code given: ${inviteCode}.`);
throw new ExpectedErr(400, `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 await txn
.updateTable("priv_invite") .updateTable("priv_invite")
@@ -77,7 +131,7 @@ export const ANON_ACTION_Register = MakeAnonAction(
consumed_at: new Date().toISOString(), consumed_at: new Date().toISOString(),
consumed_by: newUser.id, consumed_by: newUser.id,
}) })
.where("code", "=", inviteCode) .where("priv_invite.code", "=", inviteCode!)
.execute(); .execute();
} }
+8 -5
View File
@@ -1,3 +1,4 @@
/* eslint-disable no-await-in-loop */
import { loadServerEnvFile } from "#lib/setup/load-server-env"; import { loadServerEnvFile } from "#lib/setup/load-server-env";
loadServerEnvFile(process.env.NODE_ENV === "test" ? ".env.test" : ".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 { log } from "#lib/log/log";
import { Env } from "#lib/setup/config"; import { Env } from "#lib/setup/config";
import { ClosePgConnection } from "#services/pg/db"; import { ClosePgConnection } from "#services/pg/db";
import { Sleep } from "#utils/misc";
import { writeFileSync } from "fs";
import { applyMigrations } from "tachi-db-migration-engine"; import { applyMigrations } from "tachi-db-migration-engine";
const HEARTBEAT_FILE = "/tmp/worker-heartbeat";
const TICK_MS = 5_000; const TICK_MS = 5_000;
process.on("uncaughtException", (err, origin) => { process.on("uncaughtException", (err, origin) => {
@@ -23,6 +28,7 @@ void bootstrap();
async function bootstrap() { async function bootstrap() {
await applyMigrations(Env.POSTGRES_URL, Env.MIGRATIONS_DIR); await applyMigrations(Env.POSTGRES_URL, Env.MIGRATIONS_DIR);
log.info({ bootInfo: true }, "tachi cron worker starting."); log.info({ bootInfo: true }, "tachi cron worker starting.");
let stopping = false; let stopping = false;
const shutdown = () => { const shutdown = () => {
stopping = true; stopping = true;
@@ -32,8 +38,8 @@ async function bootstrap() {
// eslint-disable-next-line no-unmodified-loop-condition // eslint-disable-next-line no-unmodified-loop-condition
while (!stopping) { while (!stopping) {
writeFileSync(HEARTBEAT_FILE, Date.now().toString());
try { try {
// eslint-disable-next-line no-await-in-loop
await runCronTickOnce(); await runCronTickOnce();
} catch (e) { } catch (e) {
log.error(e, "Cron tick error."); log.error(e, "Cron tick error.");
@@ -41,10 +47,7 @@ async function bootstrap() {
if (stopping) { if (stopping) {
break; break;
} }
// eslint-disable-next-line no-await-in-loop await Sleep(TICK_MS);
await new Promise((r) => {
setTimeout(r, TICK_MS);
});
} }
log.info("Cron worker stopped."); log.info("Cron worker stopped.");
await ClosePgConnection(); await ClosePgConnection();
@@ -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 () => { it("counts at most 30 PBs per flare category (global flare order, per-category index)", async () => {
const { id: userId } = await seedUser(); 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++) { for (let i = 0; i < 31; i++) {
await seedDdrPbRow(userId, { await seedDdrPbRow(userId, {
flareSkill: 1031 - i, flareSkill: 1031 - i,
@@ -236,7 +236,7 @@ describe("IIDX_IMPL (unit)", () => {
}); });
}); });
describe("scoreCalcs ktLampRatingNC / ktLampRatingHC / ktLampRatingEXHC", () => { describe("scoreCalcs ktLampRatingHC / ktLampRatingEXHC", () => {
it("IIDX SP", () => { it("IIDX SP", () => {
const run = ( const run = (
scoreData: Partial<ScoreData<"iidx-sp">>, scoreData: Partial<ScoreData<"iidx-sp">>,
@@ -258,37 +258,30 @@ describe("IIDX_IMPL (unit)", () => {
const tiered = { ncTier: mkTier(15), hcTier: mkTier(16), exhcTier: mkTier(17) }; const tiered = { ncTier: mkTier(15), hcTier: mkTier(16), exhcTier: mkTier(17) };
expect(run({ lamp: "FAILED" }, {})).toMatchObject({ expect(run({ lamp: "FAILED" }, {})).toMatchObject({
ktLampRatingNC: 0,
ktLampRatingHC: 0, ktLampRatingHC: 0,
ktLampRatingEXHC: 0, ktLampRatingEXHC: 0,
}); });
expect(run({ lamp: "EASY CLEAR" }, {})).toMatchObject({ expect(run({ lamp: "EASY CLEAR" }, {})).toMatchObject({
ktLampRatingNC: 0,
ktLampRatingHC: 0, ktLampRatingHC: 0,
ktLampRatingEXHC: 0, ktLampRatingEXHC: 0,
}); });
expect(run({ lamp: "CLEAR" }, tiered)).toMatchObject({ expect(run({ lamp: "CLEAR" }, tiered)).toMatchObject({
ktLampRatingNC: 15,
ktLampRatingHC: 0, ktLampRatingHC: 0,
ktLampRatingEXHC: 0, ktLampRatingEXHC: 0,
}); });
expect(run({ lamp: "HARD CLEAR" }, tiered)).toMatchObject({ expect(run({ lamp: "HARD CLEAR" }, tiered)).toMatchObject({
ktLampRatingNC: 15,
ktLampRatingHC: 16, ktLampRatingHC: 16,
ktLampRatingEXHC: 0, ktLampRatingEXHC: 0,
}); });
expect(run({ lamp: "EX HARD CLEAR" }, tiered)).toMatchObject({ expect(run({ lamp: "EX HARD CLEAR" }, tiered)).toMatchObject({
ktLampRatingNC: 15,
ktLampRatingHC: 16, ktLampRatingHC: 16,
ktLampRatingEXHC: 17, ktLampRatingEXHC: 17,
}); });
expect(run({ lamp: "FULL COMBO" }, tiered)).toMatchObject({ expect(run({ lamp: "FULL COMBO" }, tiered)).toMatchObject({
ktLampRatingNC: 15,
ktLampRatingHC: 16, ktLampRatingHC: 16,
ktLampRatingEXHC: 17, ktLampRatingEXHC: 17,
}); });
expect(run({ lamp: "HARD CLEAR" }, { ncTier: mkTier(15) })).toMatchObject({ expect(run({ lamp: "HARD CLEAR" }, { ncTier: mkTier(15) })).toMatchObject({
ktLampRatingNC: 15,
ktLampRatingHC: 15, ktLampRatingHC: 15,
ktLampRatingEXHC: 0, ktLampRatingEXHC: 0,
}); });
@@ -313,27 +306,22 @@ describe("IIDX_IMPL (unit)", () => {
} }
expect(run({ lamp: "EASY CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({ expect(run({ lamp: "EASY CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({
ktLampRatingNC: 0,
ktLampRatingHC: 0, ktLampRatingHC: 0,
ktLampRatingEXHC: 0, ktLampRatingEXHC: 0,
}); });
expect(run({ lamp: "CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({ expect(run({ lamp: "CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({
ktLampRatingNC: 15,
ktLampRatingHC: 0, ktLampRatingHC: 0,
ktLampRatingEXHC: 0, ktLampRatingEXHC: 0,
}); });
expect(run({ lamp: "HARD CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({ expect(run({ lamp: "HARD CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({
ktLampRatingNC: 15,
ktLampRatingHC: 15, ktLampRatingHC: 15,
ktLampRatingEXHC: 0, ktLampRatingEXHC: 0,
}); });
expect(run({ lamp: "EX HARD CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({ expect(run({ lamp: "EX HARD CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({
ktLampRatingNC: 15,
ktLampRatingHC: 15, ktLampRatingHC: 15,
ktLampRatingEXHC: 15, ktLampRatingEXHC: 15,
}); });
expect(run({ lamp: "FULL COMBO" }, { dpTier: mkTier(15) })).toMatchObject({ expect(run({ lamp: "FULL COMBO" }, { dpTier: mkTier(15) })).toMatchObject({
ktLampRatingNC: 15,
ktLampRatingHC: 15, ktLampRatingHC: 15,
ktLampRatingEXHC: 15, ktLampRatingEXHC: 15,
}); });
@@ -29,23 +29,19 @@ type IIDXGames = "iidx-dp" | "iidx-sp";
const IIDX_SESSION_CALCS: GPTSessionCalcs<IIDXGames> = (arr) => ({ const IIDX_SESSION_CALCS: GPTSessionCalcs<IIDXGames> = (arr) => ({
BPI: SessionAvgBest10For("BPI")(arr), BPI: SessionAvgBest10For("BPI")(arr),
ktLampRating: SessionAvgBest10For("ktLampRating")(arr), ktLampRating: SessionAvgBest10For("ktLampRating")(arr),
ktLampRatingNC: SessionAvgBest10For("ktLampRatingNC")(arr),
ktLampRatingHC: SessionAvgBest10For("ktLampRatingHC")(arr), ktLampRatingHC: SessionAvgBest10For("ktLampRatingHC")(arr),
ktLampRatingEXHC: SessionAvgBest10For("ktLampRatingEXHC")(arr), ktLampRatingEXHC: SessionAvgBest10For("ktLampRatingEXHC")(arr),
}); });
const IIDX_PROFILE_CALCS: GPTProfileCalcs<IIDXGames> = async (game, userID) => { const IIDX_PROFILE_CALCS: GPTProfileCalcs<IIDXGames> = async (game, userID) => {
const [BPI, ktLampRating, ktLampRatingNC, ktLampRatingHC, ktLampRatingEXHC] = await Promise.all( const [BPI, ktLampRating, ktLampRatingHC, ktLampRatingEXHC] = await Promise.all([
[ ProfileAvgBestN("BPI", 20, true)(game, userID),
ProfileAvgBestN("BPI", 20, true)(game, userID), ProfileAvgBestN("ktLampRating", 20)(game, userID),
ProfileAvgBestN("ktLampRating", 20)(game, userID), ProfileAvgBestN("ktLampRatingHC", 20)(game, userID),
ProfileAvgBestN("ktLampRatingNC", 20)(game, userID), ProfileAvgBestN("ktLampRatingEXHC", 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<PBMergeFunction<IIDXGames>> = [ const IIDX_MERGERS: Array<PBMergeFunction<IIDXGames>> = [
@@ -156,11 +152,6 @@ export const IIDX_SP_IMPL: GameImplementation<"iidx-sp"> = {
ktLampRating = 0; ktLampRating = 0;
} }
const atLeastNcClear =
scoreData.lamp === "CLEAR" ||
scoreData.lamp === "HARD CLEAR" ||
scoreData.lamp === "EX HARD CLEAR" ||
scoreData.lamp === "FULL COMBO";
const atLeastHcClear = const atLeastHcClear =
scoreData.lamp === "HARD CLEAR" || scoreData.lamp === "HARD CLEAR" ||
scoreData.lamp === "EX HARD CLEAR" || scoreData.lamp === "EX HARD CLEAR" ||
@@ -168,14 +159,12 @@ export const IIDX_SP_IMPL: GameImplementation<"iidx-sp"> = {
const atLeastExhcClear = const atLeastExhcClear =
scoreData.lamp === "EX HARD CLEAR" || scoreData.lamp === "FULL COMBO"; scoreData.lamp === "EX HARD CLEAR" || scoreData.lamp === "FULL COMBO";
const ktLampRatingNC = atLeastNcClear ? ncValue : 0;
const ktLampRatingHC = atLeastHcClear ? hcValue : 0; const ktLampRatingHC = atLeastHcClear ? hcValue : 0;
const ktLampRatingEXHC = atLeastExhcClear ? exhcValue : 0; const ktLampRatingEXHC = atLeastExhcClear ? exhcValue : 0;
return { return {
BPI: bpi, BPI: bpi,
ktLampRating, ktLampRating,
ktLampRatingNC,
ktLampRatingHC, ktLampRatingHC,
ktLampRatingEXHC, ktLampRatingEXHC,
}; };
@@ -235,11 +224,6 @@ export const IIDX_DP_IMPL: GameImplementation<"iidx-dp"> = {
ktLampRating = 0; ktLampRating = 0;
} }
const atLeastNcClear =
scoreData.lamp === "CLEAR" ||
scoreData.lamp === "HARD CLEAR" ||
scoreData.lamp === "EX HARD CLEAR" ||
scoreData.lamp === "FULL COMBO";
const atLeastHcClear = const atLeastHcClear =
scoreData.lamp === "HARD CLEAR" || scoreData.lamp === "HARD CLEAR" ||
scoreData.lamp === "EX HARD CLEAR" || scoreData.lamp === "EX HARD CLEAR" ||
@@ -247,14 +231,12 @@ export const IIDX_DP_IMPL: GameImplementation<"iidx-dp"> = {
const atLeastExhcClear = const atLeastExhcClear =
scoreData.lamp === "EX HARD CLEAR" || scoreData.lamp === "FULL COMBO"; scoreData.lamp === "EX HARD CLEAR" || scoreData.lamp === "FULL COMBO";
const ktLampRatingNC = atLeastNcClear ? ecValue : 0;
const ktLampRatingHC = atLeastHcClear ? ecValue : 0; const ktLampRatingHC = atLeastHcClear ? ecValue : 0;
const ktLampRatingEXHC = atLeastExhcClear ? ecValue : 0; const ktLampRatingEXHC = atLeastExhcClear ? ecValue : 0;
return { return {
BPI: bpi, BPI: bpi,
ktLampRating, ktLampRating,
ktLampRatingNC,
ktLampRatingHC, ktLampRatingHC,
ktLampRatingEXHC, ktLampRatingEXHC,
}; };
@@ -12,7 +12,7 @@ import { GoalFmtPercent, GradeGoalFormatter } from "./_common";
export const ITG_STAMINA_IMPL: GameImplementation<"itg-stamina"> = { export const ITG_STAMINA_IMPL: GameImplementation<"itg-stamina"> = {
chartSpecificValidators: {}, chartSpecificValidators: {},
scoreDeriver: (scoreData, _chart) => ({ 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. // produce a 100% survived-percent on a fail for very long charts.
finalPercent: finalPercent:
scoreData.lamp === "FAILED" ? scoreData.survivedPercent : 100 + scoreData.scorePercent, scoreData.lamp === "FAILED" ? scoreData.survivedPercent : 100 + scoreData.scorePercent,
@@ -60,7 +60,6 @@ describe("CreatePBMergeFor (Postgres)", () => {
judgements: s.mongo.judgements ?? {}, judgements: s.mongo.judgements ?? {},
}); });
// eslint-disable-next-line no-await-in-loop
await DB.insertInto("score") await DB.insertInto("score")
.values({ .values({
id: scoreId, id: scoreId,
+13 -7
View File
@@ -10,8 +10,12 @@ import { processScoreImportJobFromPayload } from "#lib/score-import/worker/score
import { Env } from "#lib/setup/config"; import { Env } from "#lib/setup/config";
import { ClosePgConnection } from "#services/pg/db"; import { ClosePgConnection } from "#services/pg/db";
import { CloseRedisConnection } from "#services/redis/redis"; import { CloseRedisConnection } from "#services/redis/redis";
import { Sleep } from "#utils/misc";
import { writeFileSync } from "fs";
import { applyMigrations } from "tachi-db-migration-engine"; import { applyMigrations } from "tachi-db-migration-engine";
const HEARTBEAT_FILE = "/tmp/worker-heartbeat";
const POLL_MS = 250; const POLL_MS = 250;
process.on("uncaughtException", (err, origin) => { process.on("uncaughtException", (err, origin) => {
@@ -21,31 +25,32 @@ process.on("uncaughtException", (err, origin) => {
void bootstrap(); 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() { async function bootstrap() {
await applyMigrations(Env.POSTGRES_URL, Env.MIGRATIONS_DIR); await applyMigrations(Env.POSTGRES_URL, Env.MIGRATIONS_DIR);
log.info({ bootInfo: true }, "tachi job-queue worker starting (Postgres job_queue)."); log.info({ bootInfo: true }, "tachi job-queue worker starting (Postgres job_queue).");
let stopping = false; let stopping = false;
const shutdown = () => { const shutdown = () => {
stopping = true; stopping = true;
}; };
process.on("SIGINT", shutdown); process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown); process.on("SIGTERM", shutdown);
// eslint-disable-next-line no-unmodified-loop-condition // eslint-disable-next-line no-unmodified-loop-condition
while (!stopping) { while (!stopping) {
writeFileSync(HEARTBEAT_FILE, Date.now().toString());
const job = await ClaimNextJob(); const job = await ClaimNextJob();
if (!job) { if (!job) {
if (stopping) { if (stopping) {
break; break;
} }
await new Promise((r) => {
setTimeout(r, POLL_MS); await Sleep(POLL_MS);
});
continue; continue;
} }
try { try {
switch (job.job_kind) { switch (job.job_kind) {
case JOB_KIND_SCORE_IMPORT: case JOB_KIND_SCORE_IMPORT:
@@ -61,6 +66,7 @@ async function bootstrap() {
await MarkJobFailed(job.row_id); await MarkJobFailed(job.row_id);
} }
} }
log.info("Job worker loop stopped, closing resources."); log.info("Job worker loop stopped, closing resources.");
await CloseScoreImportQueue(); await CloseScoreImportQueue();
await CloseRedisConnection(); await CloseRedisConnection();
@@ -38,7 +38,7 @@ export type ActivityConstraint = {
userID?: integer | { $in: Array<integer> }; userID?: integer | { $in: Array<integer> };
}; };
/** 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). // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OperandExpressionFactory varies by query (session / join / class_achievement).
type ActivityWhereEb = any; type ActivityWhereEb = any;
+3 -3
View File
@@ -92,13 +92,13 @@ export const SELECT_PB_DOCUMENT_WITH_LEADERBOARD = [
export async function ToPbScoreDocument(row: PbDocumentJoinRow): Promise<PBScoreDocument> { export async function ToPbScoreDocument(row: PbDocumentJoinRow): Promise<PBScoreDocument> {
const composedRows = await DB.selectFrom("pb_composed_from") const composedRows = await DB.selectFrom("pb_composed_from")
.where("pb_id", "=", row.row_id) .where("pb_id", "=", row.row_id)
.select("score_id") .select(["score_id", "merge_name"])
.execute(); .execute();
const composedFrom: [PBReference, ...PBReference[]] = const composedFrom: [PBReference, ...PBReference[]] =
composedRows.length > 0 composedRows.length > 0
? (composedRows.map((c, i) => ({ ? (composedRows.map((c) => ({
name: i === 0 ? "Primary" : `Merge${i}`, name: c.merge_name,
scoreID: c.score_id, scoreID: c.score_id,
})) as [PBReference, ...PBReference[]]) })) as [PBReference, ...PBReference[]])
: [{ name: "Primary", scoreID: "unknown" }]; : [{ name: "Primary", scoreID: "unknown" }];
+3 -2
View File
@@ -23,7 +23,7 @@ try {
} else { } else {
transporter.verify((err) => { transporter.verify((err) => {
if (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. // become an uncaught exception, crashing the process. Log and exit instead.
log.fatal({ err }, `Could not connect to email server.`); log.fatal({ err }, `Could not connect to email server.`);
process.exit(1); process.exit(1);
@@ -65,7 +65,7 @@ export function SendEmail(
headers: transporter.options.headers, headers: transporter.options.headers,
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
log.info( log.error(
{ {
err, err,
subject, subject,
@@ -74,5 +74,6 @@ export function SendEmail(
}, },
`Failed to send email to ${to}.`, `Failed to send email to ${to}.`,
); );
throw err;
}); });
} }
@@ -5,7 +5,7 @@ import { type Kysely, sql } from "kysely";
/** /**
* Folder membership SQL (see tests in `folders.test.ts`). * 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<Database>) { export async function BuildFolderQuery(folderID: string, db: Kysely<Database>) {
const folder = await db const folder = await db
@@ -75,7 +75,7 @@ export async function GetFolderChartIDs(folderID: string, db: Kysely<Database>)
/** /**
* Evaluates `folder.where` SQL (same as a full rebuild). Used by * 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<Database>) { export async function computeFolderChartIdsFromFolderSql(folderID: string, db: Kysely<Database>) {
const { folderQuery } = await BuildFolderQuery(folderID, db); const { folderQuery } = await BuildFolderQuery(folderID, db);
@@ -46,7 +46,7 @@ function buildList(): Array<CronTaskDef> {
}, },
{ {
id: "drain_stats_queues", id: "drain_stats_queues",
schedule: "* * * * *", schedule: "* * * * *", // TODO(zk): really? this is so lazy
description: description:
"Drain score_rederive, pb_dirty, session_dirty, game_profile_dirty (ordered)", "Drain score_rederive, pb_dirty, session_dirty, game_profile_dirty (ordered)",
run: drainStatsQueuesInOrder, run: drainStatsQueuesInOrder,
@@ -291,7 +291,7 @@ export async function drainStatsQueuesInOrder(): Promise<void> {
/** /**
* Drain `score_rederive`, then `pb_dirty`, `session_dirty`, and `game_profile_dirty`, * 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 * 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<void> { export async function drainStatsQueuesFully(): Promise<void> {
for (;;) { for (;;) {
@@ -345,7 +345,7 @@ export async function drainStatsQueuesFully(): Promise<void> {
/** /**
* Drain `pb_dirty` then `session_dirty` and `game_profile_dirty`, repeating until * 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<void> { export async function drainPbDirtyAndDownstream(): Promise<void> {
for (;;) { for (;;) {
@@ -192,7 +192,7 @@ export class TypedRouter<S extends AnyRouterSpec> {
if (!outputParseResult.success) { if (!outputParseResult.success) {
log.error( log.error(
{ route, issues: outputParseResult.error.issues }, { 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",
); );
} }
@@ -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`. * 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: { export async function listOrphanScoresForUser(opts: {
afterRowID?: string; afterRowID?: string;

Some files were not shown because too many files have changed in this diff Show More