tina4ruby 3.13.93 → 3.13.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +883 -0
  3. data/README.md +1 -1
  4. data/lib/tina4/auth.rb +166 -87
  5. data/lib/tina4/auto_crud.rb +29 -32
  6. data/lib/tina4/cache_backends/base_backend.rb +19 -0
  7. data/lib/tina4/cache_backends/database_backend.rb +29 -0
  8. data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
  9. data/lib/tina4/cache_backends/memory_backend.rb +15 -0
  10. data/lib/tina4/cache_backends/redis_backend.rb +173 -52
  11. data/lib/tina4/cache_backends.rb +10 -1
  12. data/lib/tina4/cli.rb +35 -43
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +458 -48
  16. data/lib/tina4/database_adapter.rb +178 -0
  17. data/lib/tina4/database_result.rb +63 -17
  18. data/lib/tina4/database_url.rb +363 -0
  19. data/lib/tina4/dev.rb +0 -1
  20. data/lib/tina4/dev_admin.rb +118 -20
  21. data/lib/tina4/dev_mailbox.rb +5 -1
  22. data/lib/tina4/dispatch_pipeline.rb +605 -0
  23. data/lib/tina4/docstore.rb +274 -60
  24. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  25. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  26. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  27. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  28. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  29. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  30. data/lib/tina4/drivers/sqlite_driver.rb +25 -3
  31. data/lib/tina4/env.rb +176 -34
  32. data/lib/tina4/field_types.rb +12 -0
  33. data/lib/tina4/frond.rb +102 -10
  34. data/lib/tina4/health.rb +30 -14
  35. data/lib/tina4/job.rb +15 -5
  36. data/lib/tina4/log.rb +236 -32
  37. data/lib/tina4/mcp.rb +11 -5
  38. data/lib/tina4/messenger.rb +317 -82
  39. data/lib/tina4/metrics.rb +179 -891
  40. data/lib/tina4/middleware.rb +191 -56
  41. data/lib/tina4/migration.rb +17 -1
  42. data/lib/tina4/orm.rb +114 -17
  43. data/lib/tina4/public/css/tina4.min.css +1 -1
  44. data/lib/tina4/queue.rb +154 -9
  45. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  46. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  47. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  48. data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
  49. data/lib/tina4/rack_app.rb +94 -316
  50. data/lib/tina4/request.rb +48 -8
  51. data/lib/tina4/response.rb +42 -1
  52. data/lib/tina4/response_cache.rb +142 -24
  53. data/lib/tina4/router.rb +141 -12
  54. data/lib/tina4/session.rb +243 -29
  55. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  56. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  57. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  58. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  59. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  60. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  61. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  62. data/lib/tina4/shutdown.rb +180 -30
  63. data/lib/tina4/sql_translator.rb +110 -0
  64. data/lib/tina4/swagger.rb +50 -18
  65. data/lib/tina4/version.rb +1 -1
  66. data/lib/tina4/webserver.rb +28 -6
  67. data/lib/tina4.rb +301 -38
  68. metadata +35 -17
  69. data/lib/tina4/scss_compiler.rb +0 -349
@@ -1 +1 @@
1
- *,*::before,*::after{box-sizing:border-box}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;scroll-behavior:smooth}body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.25}p{margin-top:0;margin-bottom:1rem}ol,ul{padding-left:2rem;margin-top:0;margin-bottom:1rem}a{color:#4a90d9;text-decoration:none}a:hover{color:#256ab1;text-decoration:underline}img,svg{max-width:100%;height:auto;vertical-align:middle}table{border-collapse:collapse;caption-side:bottom}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button;cursor:pointer}hr{margin:1rem 0;color:inherit;border:0;border-top:1px solid rgba(0,0,0,.1);opacity:.25}pre,code,kbd,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto}[hidden]{display:none !important}h1{font-size:2.25rem}h2{font-size:1.875rem}h3{font-size:1.5rem}h4{font-size:1.25rem}h5{font-size:1.125rem}h6{font-size:1rem}small,.small{font-size:.875em}mark,.mark{padding:.2em;background-color:rgba(255,193,7,.3)}blockquote{margin:0 0 1rem;padding:.5rem 1rem;border-left:.25rem solid rgba(0,0,0,.1);font-size:1.125rem}code{font-size:.875em;color:#dc3545;word-wrap:break-word}pre{display:block;padding:1rem;font-size:.875em;color:#212529;background-color:#f8f9fa;border-radius:.25rem}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.container,.container-fluid{width:100%;padding-right:.75rem;padding-left:.75rem;margin-right:auto;margin-left:auto}@media(min-width: 576px){.container{max-width:540px}}@media(min-width: 768px){.container{max-width:720px}}@media(min-width: 992px){.container{max-width:960px}}@media(min-width: 1200px){.container{max-width:1140px}}@media(min-width: 1400px){.container{max-width:1320px}}.row{display:flex;flex-wrap:wrap;margin-right:-0.75rem;margin-left:-0.75rem}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:.75rem;padding-left:.75rem}.col{flex:1 0 0%}.col-1{flex:0 0 auto;width:8.3333333333%}.col-2{flex:0 0 auto;width:16.6666666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.3333333333%}.col-5{flex:0 0 auto;width:41.6666666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.3333333333%}.col-8{flex:0 0 auto;width:66.6666666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.3333333333%}.col-11{flex:0 0 auto;width:91.6666666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.3333333333%}.offset-2{margin-left:16.6666666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.3333333333%}.offset-0{margin-left:0}@media(min-width: 576px){.col-sm{flex:1 0 0%}.col-sm-1{flex:0 0 auto;width:8.3333333333%}.col-sm-2{flex:0 0 auto;width:16.6666666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.3333333333%}.col-sm-5{flex:0 0 auto;width:41.6666666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.3333333333%}.col-sm-8{flex:0 0 auto;width:66.6666666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.3333333333%}.col-sm-11{flex:0 0 auto;width:91.6666666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}}@media(min-width: 768px){.col-md{flex:1 0 0%}.col-md-1{flex:0 0 auto;width:8.3333333333%}.col-md-2{flex:0 0 auto;width:16.6666666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.3333333333%}.col-md-5{flex:0 0 auto;width:41.6666666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.3333333333%}.col-md-8{flex:0 0 auto;width:66.6666666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.3333333333%}.col-md-11{flex:0 0 auto;width:91.6666666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}}@media(min-width: 992px){.col-lg{flex:1 0 0%}.col-lg-1{flex:0 0 auto;width:8.3333333333%}.col-lg-2{flex:0 0 auto;width:16.6666666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.3333333333%}.col-lg-5{flex:0 0 auto;width:41.6666666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.3333333333%}.col-lg-8{flex:0 0 auto;width:66.6666666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.3333333333%}.col-lg-11{flex:0 0 auto;width:91.6666666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}}@media(min-width: 1200px){.col-xl{flex:1 0 0%}.col-xl-1{flex:0 0 auto;width:8.3333333333%}.col-xl-2{flex:0 0 auto;width:16.6666666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.3333333333%}.col-xl-5{flex:0 0 auto;width:41.6666666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.3333333333%}.col-xl-8{flex:0 0 auto;width:66.6666666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.3333333333%}.col-xl-11{flex:0 0 auto;width:91.6666666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}}.justify-start{justify-content:flex-start}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.align-start{align-items:flex-start}.align-center{align-items:center}.align-end{align-items:flex-end}.btn{display:inline-block;font-weight:400;line-height:1.5;color:#212529;text-align:center;vertical-align:middle;cursor:pointer;user-select:none;background-color:rgba(0,0,0,0);border:1px solid rgba(0,0,0,0);padding:.375rem .75rem;font-size:1rem;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;text-decoration:none}.btn:disabled,.btn.disabled{pointer-events:none;opacity:.65}.btn-primary{color:#fff;background-color:#4a90d9;border-color:#4a90d9}.btn-primary:hover{background-color:#2b7bcf;border-color:#2a76c6}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{background-color:#596167;border-color:#545b62}.btn-success{color:#fff;background-color:#28a745;border-color:#28a745}.btn-success:hover{background-color:#208637;border-color:#1e7e34}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{background-color:#c62232;border-color:#bd2130}.btn-warning{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{background-color:#dda600;border-color:#d39e00}.btn-info{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-info:hover{background-color:#128294;border-color:#117a8b}.btn-light{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{background-color:#e0e5e9;border-color:#dae0e5}.btn-dark{color:#fff;background-color:#212529;border-color:#212529}.btn-dark:hover{background-color:#0f1112;border-color:#0a0c0d}.btn-outline-primary{color:#4a90d9;border-color:#4a90d9;background-color:rgba(0,0,0,0)}.btn-outline-primary:hover{color:#fff;background-color:#4a90d9}.btn-outline-secondary{color:#6c757d;border-color:#6c757d;background-color:rgba(0,0,0,0)}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d}.btn-outline-success{color:#28a745;border-color:#28a745;background-color:rgba(0,0,0,0)}.btn-outline-success:hover{color:#fff;background-color:#28a745}.btn-outline-danger{color:#dc3545;border-color:#dc3545;background-color:rgba(0,0,0,0)}.btn-outline-danger:hover{color:#fff;background-color:#dc3545}.btn-outline-warning{color:#ffc107;border-color:#ffc107;background-color:rgba(0,0,0,0)}.btn-outline-warning:hover{color:#212529;background-color:#ffc107}.btn-outline-info{color:#17a2b8;border-color:#17a2b8;background-color:rgba(0,0,0,0)}.btn-outline-info:hover{color:#fff;background-color:#17a2b8}.btn-outline-light{color:#f8f9fa;border-color:#f8f9fa;background-color:rgba(0,0,0,0)}.btn-outline-light:hover{color:#212529;background-color:#f8f9fa}.btn-outline-dark{color:#212529;border-color:#212529;background-color:rgba(0,0,0,0)}.btn-outline-dark:hover{color:#fff;background-color:#212529}.btn-sm{padding:.25rem .5rem;font-size:.875rem;border-radius:.25rem}.btn-lg{padding:.5rem 1rem;font-size:1.125rem;border-radius:.5rem}.btn-block{display:block;width:100%}.form-group{margin-bottom:1rem}.form-label{display:inline-block;margin-bottom:.5rem;font-weight:700}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;appearance:none}.form-control:focus{color:#212529;background-color:#fff;border-color:#b3d1ef;outline:0;box-shadow:0 0 0 .2rem rgba(74,144,217,.25)}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled{background-color:#f8f9fa;opacity:1}textarea.form-control{min-height:calc(1.5em + .75rem + 2px)}.form-select{display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;appearance:none}.form-select:focus{border-color:#b3d1ef;outline:0;box-shadow:0 0 0 .2rem rgba(74,144,217,.25)}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check-input{float:left;width:1em;height:1em;margin-left:-1.5em;margin-top:.25em;appearance:none;background-color:#fff;border:1px solid rgba(0,0,0,.25)}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:checked{background-color:#4a90d9;border-color:#4a90d9}.form-check-input:focus{border-color:#b3d1ef;outline:0;box-shadow:0 0 0 .2rem rgba(74,144,217,.25)}.form-check-label{cursor:pointer}.is-valid{border-color:#28a745 !important}.is-valid:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.25) !important}.is-invalid{border-color:#dc3545 !important}.is-invalid:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.25) !important}.valid-feedback,.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em}.valid-feedback{color:#28a745}.invalid-feedback{color:#dc3545}.is-valid~.valid-feedback,.is-invalid~.invalid-feedback{display:block}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;line-height:1.5;color:#212529;text-align:center;white-space:nowrap;background-color:#f8f9fa;border:1px solid #ced4da;border-radius:.25rem}.card{position:relative;display:flex;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.5rem}.card-header{padding:.75rem 1rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.5rem - 1px) calc(.5rem - 1px) 0 0}.card-body{flex:1 1 auto;padding:1rem}.card-footer{padding:.75rem 1rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.5rem - 1px) calc(.5rem - 1px)}.card-title{margin-bottom:.5rem;font-weight:700}.card-text:last-child{margin-bottom:0}.card-img-top{width:100%;border-top-left-radius:calc(.5rem - 1px);border-top-right-radius:calc(.5rem - 1px)}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem;color:#4a90d9;text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out}.nav-link:hover,.nav-link:focus{color:#256ab1}.nav-link.disabled{color:#6c757d;pointer-events:none;cursor:default}.nav-link.active{color:#4a90d9;font-weight:700}.navbar{position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:.5rem 1rem}.navbar-brand{padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;font-weight:700;color:inherit;text-decoration:none;white-space:nowrap}.navbar-brand:hover{color:inherit;opacity:.8}.navbar-nav{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-toggler{padding:.25rem .75rem;font-size:1.125rem;line-height:1;background-color:rgba(0,0,0,0);border:1px solid rgba(0,0,0,.1);border-radius:.25rem;cursor:pointer}.navbar-toggler:focus{outline:0;box-shadow:0 0 0 .2rem rgba(74,144,217,.25)}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center;display:none}.navbar-collapse.show{display:flex}@media(min-width: 576px){.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-collapse{display:flex;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}@media(min-width: 768px){.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-collapse{display:flex;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}}@media(min-width: 992px){.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-collapse{display:flex;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}}@media(min-width: 1200px){.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-collapse{display:flex;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}}@media(min-width: 1400px){.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-collapse{display:flex;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}}@media(min-width: 992px){.navbar-nav{flex-direction:row}.navbar-collapse{display:flex;flex-basis:auto}.navbar-toggler{display:none}}.navbar-dark{color:#fff;background-color:#212529}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .nav-link{color:rgba(255,255,255,.75)}.navbar-dark .nav-link:hover,.navbar-dark .nav-link.active{color:#fff}.navbar-light{background-color:#f8f9fa}.navbar-light .nav-link{color:rgba(0,0,0,.55)}.navbar-light .nav-link:hover,.navbar-light .nav-link.active{color:rgba(0,0,0,.9)}.breadcrumb{display:flex;flex-wrap:wrap;padding:.5rem 1rem;margin-bottom:1rem;list-style:none;background-color:#f8f9fa;border-radius:.25rem}.breadcrumb-item+.breadcrumb-item::before{display:inline-block;padding-right:.5rem;padding-left:.5rem;color:#6c757d;content:"/"}.breadcrumb-item.active{color:#6c757d}.modal{position:fixed;top:0;left:0;z-index:1050;display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal.show{display:block}.modal-dialog{position:relative;width:auto;margin:1.75rem auto;max-width:500px}.modal-sm{max-width:300px}.modal-lg{max-width:800px}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.5rem;box-shadow:0 .5rem 1rem rgba(0,0,0,.15);outline:0}.modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:1rem;border-bottom:1px solid rgba(0,0,0,.1)}.modal-title{margin-bottom:0;font-weight:700}.modal-body{position:relative;flex:1 1 auto;padding:1rem}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:.75rem;border-top:1px solid rgba(0,0,0,.1);gap:.5rem}.modal-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:rgba(0,0,0,.5);display:none}.modal-backdrop.show{display:block}.alert{position:relative;padding:.75rem 1rem;margin-bottom:1rem;border:1px solid rgba(0,0,0,0);border-radius:.25rem}.alert-primary{color:#1c5187;background-color:#deeaf8;border-color:#b3d1ef}.alert-secondary{color:#313539;background-color:#caced1;border-color:#afb5ba}.alert-success{color:#0f401b;background-color:#9be7ac;border-color:#71dd8a}.alert-danger{color:#7c151f;background-color:#f6cdd1;border-color:#efa2a9}.alert-warning{color:#876500;background-color:#ffeeba;border-color:#ffe187}.alert-info{color:#093e47;background-color:#90e4f1;border-color:#63d9ec}.alert-light{color:#aeb9c5;background-color:#fff;border-color:#fff}.alert-dark{color:#000;background-color:#717e8c;border-color:#5a6570}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;padding:.9375rem 1rem;background:rgba(0,0,0,0);border:0;cursor:pointer;color:inherit;opacity:.5}.alert-dismissible .btn-close:hover{opacity:.75}.table{width:100%;margin-bottom:1rem;vertical-align:top;border-color:rgba(0,0,0,.1)}.table>:not(caption)>*>*{padding:.5rem;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:inherit}.table>thead{vertical-align:bottom;border-bottom:2px solid currentColor}.table>tbody>tr:last-child>*{border-bottom-color:rgba(0,0,0,0)}.table-sm>:not(caption)>*>*{padding:.25rem}.table-bordered{border:1px solid rgba(0,0,0,.1)}.table-bordered>:not(caption)>*>*{border-width:1px;border-style:solid;border-color:inherit}.table-striped>tbody>tr:nth-of-type(odd)>*{background-color:rgba(0,0,0,.02)}.table-hover>tbody>tr:hover>*{background-color:rgba(0,0,0,.04)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}.badge{display:inline-block;padding:.25em .65em;font-size:.75em;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:50rem}.badge-primary{color:#fff;background-color:#4a90d9}.badge-secondary{color:#fff;background-color:#6c757d}.badge-success{color:#fff;background-color:#28a745}.badge-danger{color:#fff;background-color:#dc3545}.badge-warning{color:#212529;background-color:#ffc107}.badge-info{color:#fff;background-color:#17a2b8}.badge-light{color:#212529;background-color:#f8f9fa}.badge-dark{color:#fff;background-color:#212529}.pagination{display:flex;flex-wrap:wrap;padding-left:0;list-style:none;gap:.25rem}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;background-color:#fff;border-color:rgba(0,0,0,.1)}.page-item.active .page-link{color:#fff;background-color:#4a90d9;border-color:#4a90d9}.page-link{display:block;padding:.375rem .75rem;color:#4a90d9;background-color:#fff;border:1px solid rgba(0,0,0,.15);border-radius:.25rem;text-decoration:none;line-height:1.25;transition:color .15s,background-color .15s,border-color .15s}.page-link:hover{color:#2a76c6;background-color:rgba(74,144,217,.08);border-color:rgba(0,0,0,.2)}.page-link:focus{outline:0;box-shadow:0 0 0 .2rem rgba(74,144,217,.25)}.crud-img-preview{max-height:200px;max-width:100%;border-radius:.25rem;object-fit:cover}.crud-img-preview-sm{max-height:180px;max-width:100%;border-radius:.25rem;object-fit:cover}.d-none{display:none !important}.d-block{display:block !important}.d-flex{display:flex !important}.d-inline{display:inline !important}.d-inline-block{display:inline-block !important}.d-grid{display:grid !important}@media(min-width: 576px){.d-sm-none{display:none !important}.d-sm-block{display:block !important}.d-sm-flex{display:flex !important}.d-sm-inline{display:inline !important}.d-sm-inline-block{display:inline-block !important}}@media(min-width: 768px){.d-md-none{display:none !important}.d-md-block{display:block !important}.d-md-flex{display:flex !important}.d-md-inline{display:inline !important}.d-md-inline-block{display:inline-block !important}}@media(min-width: 992px){.d-lg-none{display:none !important}.d-lg-block{display:block !important}.d-lg-flex{display:flex !important}.d-lg-inline{display:inline !important}.d-lg-inline-block{display:inline-block !important}}.flex-row{flex-direction:row !important}.flex-column{flex-direction:column !important}.flex-wrap{flex-wrap:wrap !important}.flex-nowrap{flex-wrap:nowrap !important}.flex-grow-0{flex-grow:0 !important}.flex-grow-1{flex-grow:1 !important}.justify-content-start{justify-content:flex-start !important}.justify-content-end{justify-content:flex-end !important}.justify-content-center{justify-content:center !important}.justify-content-between{justify-content:space-between !important}.justify-content-around{justify-content:space-around !important}.align-items-start{align-items:flex-start !important}.align-items-end{align-items:flex-end !important}.align-items-center{align-items:center !important}.gap-0{gap:0 !important}.gap-1{gap:.25rem !important}.gap-2{gap:.5rem !important}.gap-3{gap:1rem !important}.gap-4{gap:1.5rem !important}.gap-5{gap:3rem !important}.m-0{margin:0 !important}.m-1{margin:.25rem !important}.m-2{margin:.5rem !important}.m-3{margin:1rem !important}.m-4{margin:1.5rem !important}.m-5{margin:3rem !important}.mt-0{margin-top:0 !important}.mt-1{margin-top:.25rem !important}.mt-2{margin-top:.5rem !important}.mt-3{margin-top:1rem !important}.mt-4{margin-top:1.5rem !important}.mt-5{margin-top:3rem !important}.mb-0{margin-bottom:0 !important}.mb-1{margin-bottom:.25rem !important}.mb-2{margin-bottom:.5rem !important}.mb-3{margin-bottom:1rem !important}.mb-4{margin-bottom:1.5rem !important}.mb-5{margin-bottom:3rem !important}.ms-0{margin-left:0 !important}.ms-1{margin-left:.25rem !important}.ms-2{margin-left:.5rem !important}.ms-3{margin-left:1rem !important}.ms-4{margin-left:1.5rem !important}.ms-5{margin-left:3rem !important}.me-0{margin-right:0 !important}.me-1{margin-right:.25rem !important}.me-2{margin-right:.5rem !important}.me-3{margin-right:1rem !important}.me-4{margin-right:1.5rem !important}.me-5{margin-right:3rem !important}.my-0{margin-top:0 !important;margin-bottom:0 !important}.mx-0{margin-left:0 !important;margin-right:0 !important}.my-1{margin-top:.25rem !important;margin-bottom:.25rem !important}.mx-1{margin-left:.25rem !important;margin-right:.25rem !important}.my-2{margin-top:.5rem !important;margin-bottom:.5rem !important}.mx-2{margin-left:.5rem !important;margin-right:.5rem !important}.my-3{margin-top:1rem !important;margin-bottom:1rem !important}.mx-3{margin-left:1rem !important;margin-right:1rem !important}.my-4{margin-top:1.5rem !important;margin-bottom:1.5rem !important}.mx-4{margin-left:1.5rem !important;margin-right:1.5rem !important}.my-5{margin-top:3rem !important;margin-bottom:3rem !important}.mx-5{margin-left:3rem !important;margin-right:3rem !important}.p-0{padding:0 !important}.p-1{padding:.25rem !important}.p-2{padding:.5rem !important}.p-3{padding:1rem !important}.p-4{padding:1.5rem !important}.p-5{padding:3rem !important}.pt-0{padding-top:0 !important}.pt-1{padding-top:.25rem !important}.pt-2{padding-top:.5rem !important}.pt-3{padding-top:1rem !important}.pt-4{padding-top:1.5rem !important}.pt-5{padding-top:3rem !important}.pb-0{padding-bottom:0 !important}.pb-1{padding-bottom:.25rem !important}.pb-2{padding-bottom:.5rem !important}.pb-3{padding-bottom:1rem !important}.pb-4{padding-bottom:1.5rem !important}.pb-5{padding-bottom:3rem !important}.ps-0{padding-left:0 !important}.ps-1{padding-left:.25rem !important}.ps-2{padding-left:.5rem !important}.ps-3{padding-left:1rem !important}.ps-4{padding-left:1.5rem !important}.ps-5{padding-left:3rem !important}.pe-0{padding-right:0 !important}.pe-1{padding-right:.25rem !important}.pe-2{padding-right:.5rem !important}.pe-3{padding-right:1rem !important}.pe-4{padding-right:1.5rem !important}.pe-5{padding-right:3rem !important}.py-0{padding-top:0 !important;padding-bottom:0 !important}.px-0{padding-left:0 !important;padding-right:0 !important}.py-1{padding-top:.25rem !important;padding-bottom:.25rem !important}.px-1{padding-left:.25rem !important;padding-right:.25rem !important}.py-2{padding-top:.5rem !important;padding-bottom:.5rem !important}.px-2{padding-left:.5rem !important;padding-right:.5rem !important}.py-3{padding-top:1rem !important;padding-bottom:1rem !important}.px-3{padding-left:1rem !important;padding-right:1rem !important}.py-4{padding-top:1.5rem !important;padding-bottom:1.5rem !important}.px-4{padding-left:1.5rem !important;padding-right:1.5rem !important}.py-5{padding-top:3rem !important;padding-bottom:3rem !important}.px-5{padding-left:3rem !important;padding-right:3rem !important}.mx-auto{margin-left:auto !important;margin-right:auto !important}.text-start{text-align:left !important}.text-center{text-align:center !important}.text-end{text-align:right !important}.text-uppercase{text-transform:uppercase !important}.text-capitalize{text-transform:capitalize !important}.text-nowrap{white-space:nowrap !important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fw-bold{font-weight:700 !important}.fw-normal{font-weight:400 !important}.fw-light{font-weight:300 !important}.fs-1{font-size:2.25rem !important}.fs-2{font-size:1.875rem !important}.fs-3{font-size:1.5rem !important}.fs-4{font-size:1.25rem !important}.fs-5{font-size:1rem !important}.fs-6{font-size:.875rem !important}.text-primary{color:#4a90d9 !important}.text-secondary{color:#6c757d !important}.text-success{color:#28a745 !important}.text-danger{color:#dc3545 !important}.text-warning{color:#ffc107 !important}.text-info{color:#17a2b8 !important}.text-light{color:#f8f9fa !important}.text-dark{color:#212529 !important}.text-muted{color:#6c757d !important}.text-white{color:#fff !important}.bg-primary{background-color:#4a90d9 !important}.bg-secondary{background-color:#6c757d !important}.bg-success{background-color:#28a745 !important}.bg-danger{background-color:#dc3545 !important}.bg-warning{background-color:#ffc107 !important}.bg-info{background-color:#17a2b8 !important}.bg-light{background-color:#f8f9fa !important}.bg-dark{background-color:#212529 !important}.bg-white{background-color:#fff !important}.border{border:1px solid rgba(0,0,0,.1) !important}.border-0{border:0 !important}.rounded{border-radius:.25rem !important}.rounded-0{border-radius:0 !important}.rounded-circle{border-radius:50% !important}.rounded-pill{border-radius:50rem !important}.w-25{width:25% !important}.h-25{height:25% !important}.w-50{width:50% !important}.h-50{height:50% !important}.w-75{width:75% !important}.h-75{height:75% !important}.w-100{width:100% !important}.h-100{height:100% !important}.mw-100{max-width:100% !important}.mh-100{max-height:100% !important}.position-relative{position:relative !important}.position-absolute{position:absolute !important}.position-fixed{position:fixed !important}.position-sticky{position:sticky !important}.position-static{position:static !important}.visible{visibility:visible !important}.invisible{visibility:hidden !important}.overflow-hidden{overflow:hidden !important}.overflow-auto{overflow:auto !important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15) !important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075) !important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175) !important}.shadow-none{box-shadow:none !important}.collapse{display:none}.collapse.show{display:block}.fade{opacity:0;transition:opacity .15s linear}.fade.show{opacity:1}.clearfix::after{display:block;clear:both;content:""}.cursor-pointer{cursor:pointer !important}.btn-close{box-sizing:content-box;width:1em;height:1em;padding:.25em;color:#000;background:rgba(0,0,0,0);border:0;opacity:.5;cursor:pointer;font-size:1.25rem;line-height:1}.btn-close:hover{opacity:.75}.btn-close:focus{opacity:1;outline:0;box-shadow:0 0 0 .2rem rgba(74,144,217,.25)}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid rgba(0,0,0,.1);border-radius:.25rem;max-width:100%;height:auto}.border-top{border-top:1px solid rgba(0,0,0,.1) !important}.border-bottom{border-bottom:1px solid rgba(0,0,0,.1) !important}.align-middle{vertical-align:middle !important}.align-top{vertical-align:top !important}.align-bottom{vertical-align:bottom !important}.float-start{float:left !important}.float-end{float:right !important}.float-none{float:none !important}.visually-hidden{position:absolute !important;width:1px !important;height:1px !important;padding:0 !important;margin:-1px !important;overflow:hidden !important;clip:rect(0, 0, 0, 0) !important;white-space:nowrap !important;border:0 !important}
1
+ *,*::before,*::after{box-sizing:border-box}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;scroll-behavior:smooth}body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.25}p{margin-top:0;margin-bottom:1rem}ol,ul{padding-left:2rem;margin-top:0;margin-bottom:1rem}a{color:#4a90d9;text-decoration:none}a:hover{color:#256ab1;text-decoration:underline}img,svg{max-width:100%;height:auto;vertical-align:middle}table{border-collapse:collapse;caption-side:bottom}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button;cursor:pointer}hr{margin:1rem 0;color:inherit;border:0;border-top:1px solid rgba(0,0,0,.1);opacity:.25}pre,code,kbd,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto}[hidden]{display:none !important}h1{font-size:2.25rem}h2{font-size:1.875rem}h3{font-size:1.5rem}h4{font-size:1.25rem}h5{font-size:1.125rem}h6{font-size:1rem}small,.small{font-size:.875em}mark,.mark{padding:.2em;background-color:rgba(255,193,7,.3)}blockquote{margin:0 0 1rem;padding:.5rem 1rem;border-left:.25rem solid rgba(0,0,0,.1);font-size:1.125rem}code{font-size:.875em;color:#dc3545;word-wrap:break-word}pre{display:block;padding:1rem;font-size:.875em;color:#212529;background-color:#f8f9fa;border-radius:.25rem}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.container,.container-fluid{width:100%;padding-right:.75rem;padding-left:.75rem;margin-right:auto;margin-left:auto}@media (min-width: 576px){.container{max-width:540px}}@media (min-width: 768px){.container{max-width:720px}}@media (min-width: 992px){.container{max-width:960px}}@media (min-width: 1200px){.container{max-width:1140px}}@media (min-width: 1400px){.container{max-width:1320px}}.row{display:flex;flex-wrap:wrap;margin-right:-.75rem;margin-left:-.75rem}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:.75rem;padding-left:.75rem}.col{flex:1 0 0%}.col-1{flex:0 0 auto;width:8.3333333333%}.col-2{flex:0 0 auto;width:16.6666666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.3333333333%}.col-5{flex:0 0 auto;width:41.6666666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.3333333333%}.col-8{flex:0 0 auto;width:66.6666666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.3333333333%}.col-11{flex:0 0 auto;width:91.6666666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.3333333333%}.offset-2{margin-left:16.6666666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.3333333333%}.offset-0{margin-left:0}@media (min-width: 576px){.col-sm{flex:1 0 0%}.col-sm-1{flex:0 0 auto;width:8.3333333333%}.col-sm-2{flex:0 0 auto;width:16.6666666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.3333333333%}.col-sm-5{flex:0 0 auto;width:41.6666666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.3333333333%}.col-sm-8{flex:0 0 auto;width:66.6666666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.3333333333%}.col-sm-11{flex:0 0 auto;width:91.6666666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}}@media (min-width: 768px){.col-md{flex:1 0 0%}.col-md-1{flex:0 0 auto;width:8.3333333333%}.col-md-2{flex:0 0 auto;width:16.6666666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.3333333333%}.col-md-5{flex:0 0 auto;width:41.6666666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.3333333333%}.col-md-8{flex:0 0 auto;width:66.6666666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.3333333333%}.col-md-11{flex:0 0 auto;width:91.6666666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}}@media (min-width: 992px){.col-lg{flex:1 0 0%}.col-lg-1{flex:0 0 auto;width:8.3333333333%}.col-lg-2{flex:0 0 auto;width:16.6666666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.3333333333%}.col-lg-5{flex:0 0 auto;width:41.6666666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.3333333333%}.col-lg-8{flex:0 0 auto;width:66.6666666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.3333333333%}.col-lg-11{flex:0 0 auto;width:91.6666666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}}@media (min-width: 1200px){.col-xl{flex:1 0 0%}.col-xl-1{flex:0 0 auto;width:8.3333333333%}.col-xl-2{flex:0 0 auto;width:16.6666666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.3333333333%}.col-xl-5{flex:0 0 auto;width:41.6666666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.3333333333%}.col-xl-8{flex:0 0 auto;width:66.6666666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.3333333333%}.col-xl-11{flex:0 0 auto;width:91.6666666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}}.justify-start{justify-content:flex-start}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.align-start{align-items:flex-start}.align-center{align-items:center}.align-end{align-items:flex-end}.btn{display:inline-block;font-weight:400;line-height:1.5;color:#212529;text-align:center;vertical-align:middle;cursor:pointer;user-select:none;background-color:rgba(0,0,0,0);border:1px solid rgba(0,0,0,0);padding:.375rem .75rem;font-size:1rem;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;text-decoration:none}.btn:disabled,.btn.disabled{pointer-events:none;opacity:.65}.btn-primary{color:#fff;background-color:#4a90d9;border-color:#4a90d9}.btn-primary:hover{background-color:#2b7bcf;border-color:#2a76c6}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{background-color:#596167;border-color:#545b62}.btn-success{color:#fff;background-color:#28a745;border-color:#28a745}.btn-success:hover{background-color:#208637;border-color:#1e7e34}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{background-color:#c62232;border-color:#bd2130}.btn-warning{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{background-color:#dda600;border-color:#d39e00}.btn-info{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-info:hover{background-color:#128294;border-color:#117a8b}.btn-light{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{background-color:#e0e5e9;border-color:#dae0e5}.btn-dark{color:#fff;background-color:#212529;border-color:#212529}.btn-dark:hover{background-color:#0f1112;border-color:#0a0c0d}.btn-outline-primary{color:#4a90d9;border-color:#4a90d9;background-color:rgba(0,0,0,0)}.btn-outline-primary:hover{color:#fff;background-color:#4a90d9}.btn-outline-secondary{color:#6c757d;border-color:#6c757d;background-color:rgba(0,0,0,0)}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d}.btn-outline-success{color:#28a745;border-color:#28a745;background-color:rgba(0,0,0,0)}.btn-outline-success:hover{color:#fff;background-color:#28a745}.btn-outline-danger{color:#dc3545;border-color:#dc3545;background-color:rgba(0,0,0,0)}.btn-outline-danger:hover{color:#fff;background-color:#dc3545}.btn-outline-warning{color:#ffc107;border-color:#ffc107;background-color:rgba(0,0,0,0)}.btn-outline-warning:hover{color:#212529;background-color:#ffc107}.btn-outline-info{color:#17a2b8;border-color:#17a2b8;background-color:rgba(0,0,0,0)}.btn-outline-info:hover{color:#fff;background-color:#17a2b8}.btn-outline-light{color:#f8f9fa;border-color:#f8f9fa;background-color:rgba(0,0,0,0)}.btn-outline-light:hover{color:#212529;background-color:#f8f9fa}.btn-outline-dark{color:#212529;border-color:#212529;background-color:rgba(0,0,0,0)}.btn-outline-dark:hover{color:#fff;background-color:#212529}.btn-sm{padding:.25rem .5rem;font-size:.875rem;border-radius:.25rem}.btn-lg{padding:.5rem 1rem;font-size:1.125rem;border-radius:.5rem}.btn-block{display:block;width:100%}.form-group{margin-bottom:1rem}.form-label{display:inline-block;margin-bottom:.5rem;font-weight:700}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;appearance:none}.form-control:focus{color:#212529;background-color:#fff;border-color:#b3d1ef;outline:0;box-shadow:0 0 0 .2rem rgba(74,144,217,.25)}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled{background-color:#f8f9fa;opacity:1}textarea.form-control{min-height:calc(1.5em + .75rem + 2px)}.form-select{display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;appearance:none}.form-select:focus{border-color:#b3d1ef;outline:0;box-shadow:0 0 0 .2rem rgba(74,144,217,.25)}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check-input{float:left;width:1em;height:1em;margin-left:-1.5em;margin-top:.25em;appearance:none;background-color:#fff;border:1px solid rgba(0,0,0,.25)}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:checked{background-color:#4a90d9;border-color:#4a90d9}.form-check-input:focus{border-color:#b3d1ef;outline:0;box-shadow:0 0 0 .2rem rgba(74,144,217,.25)}.form-check-label{cursor:pointer}.is-valid{border-color:#28a745 !important}.is-valid:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.25) !important}.is-invalid{border-color:#dc3545 !important}.is-invalid:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.25) !important}.valid-feedback,.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em}.valid-feedback{color:#28a745}.invalid-feedback{color:#dc3545}.is-valid~.valid-feedback,.is-invalid~.invalid-feedback{display:block}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;line-height:1.5;color:#212529;text-align:center;white-space:nowrap;background-color:#f8f9fa;border:1px solid #ced4da;border-radius:.25rem}.card{position:relative;display:flex;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.5rem}.card-header{padding:.75rem 1rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.5rem - 1px) calc(.5rem - 1px) 0 0}.card-body{flex:1 1 auto;padding:1rem}.card-footer{padding:.75rem 1rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.5rem - 1px) calc(.5rem - 1px)}.card-title{margin-bottom:.5rem;font-weight:700}.card-text:last-child{margin-bottom:0}.card-img-top{width:100%;border-top-left-radius:calc(.5rem - 1px);border-top-right-radius:calc(.5rem - 1px)}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem;color:#4a90d9;text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out}.nav-link:hover,.nav-link:focus{color:#256ab1}.nav-link.disabled{color:#6c757d;pointer-events:none;cursor:default}.nav-link.active{color:#4a90d9;font-weight:700}.navbar{position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:.5rem 1rem}.navbar-brand{padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;font-weight:700;color:inherit;text-decoration:none;white-space:nowrap}.navbar-brand:hover{color:inherit;opacity:.8}.navbar-nav{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-toggler{padding:.25rem .75rem;font-size:1.125rem;line-height:1;background-color:rgba(0,0,0,0);border:1px solid rgba(0,0,0,.1);border-radius:.25rem;cursor:pointer}.navbar-toggler:focus{outline:0;box-shadow:0 0 0 .2rem rgba(74,144,217,.25)}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center;display:none}.navbar-collapse.show{display:flex}@media (min-width: 576px){.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-collapse{display:flex;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}@media (min-width: 768px){.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-collapse{display:flex;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}}@media (min-width: 992px){.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-collapse{display:flex;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}}@media (min-width: 1200px){.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-collapse{display:flex;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}}@media (min-width: 1400px){.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-collapse{display:flex;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}}@media (min-width: 992px){.navbar-nav{flex-direction:row}.navbar-collapse{display:flex;flex-basis:auto}.navbar-toggler{display:none}}.navbar-dark{color:#fff;background-color:#212529}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .nav-link{color:rgba(255,255,255,.75)}.navbar-dark .nav-link:hover,.navbar-dark .nav-link.active{color:#fff}.navbar-light{background-color:#f8f9fa}.navbar-light .nav-link{color:rgba(0,0,0,.55)}.navbar-light .nav-link:hover,.navbar-light .nav-link.active{color:rgba(0,0,0,.9)}.breadcrumb{display:flex;flex-wrap:wrap;padding:.5rem 1rem;margin-bottom:1rem;list-style:none;background-color:#f8f9fa;border-radius:.25rem}.breadcrumb-item+.breadcrumb-item::before{display:inline-block;padding-right:.5rem;padding-left:.5rem;color:#6c757d;content:"/"}.breadcrumb-item.active{color:#6c757d}.modal{position:fixed;top:0;left:0;z-index:1050;display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal.show{display:block}.modal-dialog{position:relative;width:auto;margin:1.75rem auto;max-width:500px}.modal-sm{max-width:300px}.modal-lg{max-width:800px}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.5rem;box-shadow:0 .5rem 1rem rgba(0,0,0,.15);outline:0}.modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:1rem;border-bottom:1px solid rgba(0,0,0,.1)}.modal-title{margin-bottom:0;font-weight:700}.modal-body{position:relative;flex:1 1 auto;padding:1rem}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:.75rem;border-top:1px solid rgba(0,0,0,.1);gap:.5rem}.modal-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:rgba(0,0,0,.5);display:none}.modal-backdrop.show{display:block}.alert{position:relative;padding:.75rem 1rem;margin-bottom:1rem;border:1px solid rgba(0,0,0,0);border-radius:.25rem}.alert-primary{color:#1c5187;background-color:#deeaf8;border-color:#b3d1ef}.alert-secondary{color:#313539;background-color:#caced1;border-color:#afb5ba}.alert-success{color:#0f401b;background-color:#9be7ac;border-color:#71dd8a}.alert-danger{color:#7c151f;background-color:#f6cdd1;border-color:#efa2a9}.alert-warning{color:#876500;background-color:#ffeeba;border-color:#ffe187}.alert-info{color:#093e47;background-color:#90e4f1;border-color:#63d9ec}.alert-light{color:#aeb9c5;background-color:#fff;border-color:#fff}.alert-dark{color:#000;background-color:#717e8c;border-color:#5a6570}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;padding:.9375rem 1rem;background:rgba(0,0,0,0);border:0;cursor:pointer;color:inherit;opacity:.5}.alert-dismissible .btn-close:hover{opacity:.75}.table{width:100%;margin-bottom:1rem;vertical-align:top;border-color:rgba(0,0,0,.1)}.table>:not(caption)>*>*{padding:.5rem;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:inherit}.table>thead{vertical-align:bottom;border-bottom:2px solid currentColor}.table>tbody>tr:last-child>*{border-bottom-color:rgba(0,0,0,0)}.table-sm>:not(caption)>*>*{padding:.25rem}.table-bordered{border:1px solid rgba(0,0,0,.1)}.table-bordered>:not(caption)>*>*{border-width:1px;border-style:solid;border-color:inherit}.table-striped>tbody>tr:nth-of-type(odd)>*{background-color:rgba(0,0,0,.02)}.table-hover>tbody>tr:hover>*{background-color:rgba(0,0,0,.04)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}.badge{display:inline-block;padding:.25em .65em;font-size:.75em;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:50rem}.badge-primary{color:#fff;background-color:#4a90d9}.badge-secondary{color:#fff;background-color:#6c757d}.badge-success{color:#fff;background-color:#28a745}.badge-danger{color:#fff;background-color:#dc3545}.badge-warning{color:#212529;background-color:#ffc107}.badge-info{color:#fff;background-color:#17a2b8}.badge-light{color:#212529;background-color:#f8f9fa}.badge-dark{color:#fff;background-color:#212529}.pagination{display:flex;flex-wrap:wrap;padding-left:0;list-style:none;gap:.25rem}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;background-color:#fff;border-color:rgba(0,0,0,.1)}.page-item.active .page-link{color:#fff;background-color:#4a90d9;border-color:#4a90d9}.page-link{display:block;padding:.375rem .75rem;color:#4a90d9;background-color:#fff;border:1px solid rgba(0,0,0,.15);border-radius:.25rem;text-decoration:none;line-height:1.25;transition:color .15s,background-color .15s,border-color .15s}.page-link:hover{color:#2a76c6;background-color:rgba(74,144,217,.08);border-color:rgba(0,0,0,.2)}.page-link:focus{outline:0;box-shadow:0 0 0 .2rem rgba(74,144,217,.25)}.crud-img-preview{max-height:200px;max-width:100%;border-radius:.25rem;object-fit:cover}.crud-img-preview-sm{max-height:180px;max-width:100%;border-radius:.25rem;object-fit:cover}.d-none{display:none !important}.d-block{display:block !important}.d-flex{display:flex !important}.d-inline{display:inline !important}.d-inline-block{display:inline-block !important}.d-grid{display:grid !important}@media (min-width: 576px){.d-sm-none{display:none !important}.d-sm-block{display:block !important}.d-sm-flex{display:flex !important}.d-sm-inline{display:inline !important}.d-sm-inline-block{display:inline-block !important}}@media (min-width: 768px){.d-md-none{display:none !important}.d-md-block{display:block !important}.d-md-flex{display:flex !important}.d-md-inline{display:inline !important}.d-md-inline-block{display:inline-block !important}}@media (min-width: 992px){.d-lg-none{display:none !important}.d-lg-block{display:block !important}.d-lg-flex{display:flex !important}.d-lg-inline{display:inline !important}.d-lg-inline-block{display:inline-block !important}}.flex-row{flex-direction:row !important}.flex-column{flex-direction:column !important}.flex-wrap{flex-wrap:wrap !important}.flex-nowrap{flex-wrap:nowrap !important}.flex-grow-0{flex-grow:0 !important}.flex-grow-1{flex-grow:1 !important}.justify-content-start{justify-content:flex-start !important}.justify-content-end{justify-content:flex-end !important}.justify-content-center{justify-content:center !important}.justify-content-between{justify-content:space-between !important}.justify-content-around{justify-content:space-around !important}.align-items-start{align-items:flex-start !important}.align-items-end{align-items:flex-end !important}.align-items-center{align-items:center !important}.gap-0{gap:0 !important}.gap-1{gap:.25rem !important}.gap-2{gap:.5rem !important}.gap-3{gap:1rem !important}.gap-4{gap:1.5rem !important}.gap-5{gap:3rem !important}.m-0{margin:0 !important}.m-1{margin:.25rem !important}.m-2{margin:.5rem !important}.m-3{margin:1rem !important}.m-4{margin:1.5rem !important}.m-5{margin:3rem !important}.mt-0{margin-top:0 !important}.mt-1{margin-top:.25rem !important}.mt-2{margin-top:.5rem !important}.mt-3{margin-top:1rem !important}.mt-4{margin-top:1.5rem !important}.mt-5{margin-top:3rem !important}.mb-0{margin-bottom:0 !important}.mb-1{margin-bottom:.25rem !important}.mb-2{margin-bottom:.5rem !important}.mb-3{margin-bottom:1rem !important}.mb-4{margin-bottom:1.5rem !important}.mb-5{margin-bottom:3rem !important}.ms-0{margin-left:0 !important}.ms-1{margin-left:.25rem !important}.ms-2{margin-left:.5rem !important}.ms-3{margin-left:1rem !important}.ms-4{margin-left:1.5rem !important}.ms-5{margin-left:3rem !important}.me-0{margin-right:0 !important}.me-1{margin-right:.25rem !important}.me-2{margin-right:.5rem !important}.me-3{margin-right:1rem !important}.me-4{margin-right:1.5rem !important}.me-5{margin-right:3rem !important}.my-0{margin-top:0 !important;margin-bottom:0 !important}.mx-0{margin-left:0 !important;margin-right:0 !important}.my-1{margin-top:.25rem !important;margin-bottom:.25rem !important}.mx-1{margin-left:.25rem !important;margin-right:.25rem !important}.my-2{margin-top:.5rem !important;margin-bottom:.5rem !important}.mx-2{margin-left:.5rem !important;margin-right:.5rem !important}.my-3{margin-top:1rem !important;margin-bottom:1rem !important}.mx-3{margin-left:1rem !important;margin-right:1rem !important}.my-4{margin-top:1.5rem !important;margin-bottom:1.5rem !important}.mx-4{margin-left:1.5rem !important;margin-right:1.5rem !important}.my-5{margin-top:3rem !important;margin-bottom:3rem !important}.mx-5{margin-left:3rem !important;margin-right:3rem !important}.p-0{padding:0 !important}.p-1{padding:.25rem !important}.p-2{padding:.5rem !important}.p-3{padding:1rem !important}.p-4{padding:1.5rem !important}.p-5{padding:3rem !important}.pt-0{padding-top:0 !important}.pt-1{padding-top:.25rem !important}.pt-2{padding-top:.5rem !important}.pt-3{padding-top:1rem !important}.pt-4{padding-top:1.5rem !important}.pt-5{padding-top:3rem !important}.pb-0{padding-bottom:0 !important}.pb-1{padding-bottom:.25rem !important}.pb-2{padding-bottom:.5rem !important}.pb-3{padding-bottom:1rem !important}.pb-4{padding-bottom:1.5rem !important}.pb-5{padding-bottom:3rem !important}.ps-0{padding-left:0 !important}.ps-1{padding-left:.25rem !important}.ps-2{padding-left:.5rem !important}.ps-3{padding-left:1rem !important}.ps-4{padding-left:1.5rem !important}.ps-5{padding-left:3rem !important}.pe-0{padding-right:0 !important}.pe-1{padding-right:.25rem !important}.pe-2{padding-right:.5rem !important}.pe-3{padding-right:1rem !important}.pe-4{padding-right:1.5rem !important}.pe-5{padding-right:3rem !important}.py-0{padding-top:0 !important;padding-bottom:0 !important}.px-0{padding-left:0 !important;padding-right:0 !important}.py-1{padding-top:.25rem !important;padding-bottom:.25rem !important}.px-1{padding-left:.25rem !important;padding-right:.25rem !important}.py-2{padding-top:.5rem !important;padding-bottom:.5rem !important}.px-2{padding-left:.5rem !important;padding-right:.5rem !important}.py-3{padding-top:1rem !important;padding-bottom:1rem !important}.px-3{padding-left:1rem !important;padding-right:1rem !important}.py-4{padding-top:1.5rem !important;padding-bottom:1.5rem !important}.px-4{padding-left:1.5rem !important;padding-right:1.5rem !important}.py-5{padding-top:3rem !important;padding-bottom:3rem !important}.px-5{padding-left:3rem !important;padding-right:3rem !important}.mx-auto{margin-left:auto !important;margin-right:auto !important}.text-start{text-align:left !important}.text-center{text-align:center !important}.text-end{text-align:right !important}.text-uppercase{text-transform:uppercase !important}.text-capitalize{text-transform:capitalize !important}.text-nowrap{white-space:nowrap !important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fw-bold{font-weight:700 !important}.fw-normal{font-weight:400 !important}.fw-light{font-weight:300 !important}.fs-1{font-size:2.25rem !important}.fs-2{font-size:1.875rem !important}.fs-3{font-size:1.5rem !important}.fs-4{font-size:1.25rem !important}.fs-5{font-size:1rem !important}.fs-6{font-size:.875rem !important}.text-primary{color:#4a90d9 !important}.text-secondary{color:#6c757d !important}.text-success{color:#28a745 !important}.text-danger{color:#dc3545 !important}.text-warning{color:#ffc107 !important}.text-info{color:#17a2b8 !important}.text-light{color:#f8f9fa !important}.text-dark{color:#212529 !important}.text-muted{color:#6c757d !important}.text-white{color:#fff !important}.bg-primary{background-color:#4a90d9 !important}.bg-secondary{background-color:#6c757d !important}.bg-success{background-color:#28a745 !important}.bg-danger{background-color:#dc3545 !important}.bg-warning{background-color:#ffc107 !important}.bg-info{background-color:#17a2b8 !important}.bg-light{background-color:#f8f9fa !important}.bg-dark{background-color:#212529 !important}.bg-white{background-color:#fff !important}.border{border:1px solid rgba(0,0,0,.1) !important}.border-0{border:0 !important}.rounded{border-radius:.25rem !important}.rounded-0{border-radius:0 !important}.rounded-circle{border-radius:50% !important}.rounded-pill{border-radius:50rem !important}.w-25{width:25% !important}.h-25{height:25% !important}.w-50{width:50% !important}.h-50{height:50% !important}.w-75{width:75% !important}.h-75{height:75% !important}.w-100{width:100% !important}.h-100{height:100% !important}.mw-100{max-width:100% !important}.mh-100{max-height:100% !important}.position-relative{position:relative !important}.position-absolute{position:absolute !important}.position-fixed{position:fixed !important}.position-sticky{position:sticky !important}.position-static{position:static !important}.visible{visibility:visible !important}.invisible{visibility:hidden !important}.overflow-hidden{overflow:hidden !important}.overflow-auto{overflow:auto !important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15) !important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075) !important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175) !important}.shadow-none{box-shadow:none !important}.collapse{display:none}.collapse.show{display:block}.fade{opacity:0;transition:opacity .15s linear}.fade.show{opacity:1}.clearfix::after{display:block;clear:both;content:""}.cursor-pointer{cursor:pointer !important}.btn-close{box-sizing:content-box;width:1em;height:1em;padding:.25em;color:#000;background:rgba(0,0,0,0);border:0;opacity:.5;cursor:pointer;font-size:1.25rem;line-height:1}.btn-close:hover{opacity:.75}.btn-close:focus{opacity:1;outline:0;box-shadow:0 0 0 .2rem rgba(74,144,217,.25)}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid rgba(0,0,0,.1);border-radius:.25rem;max-width:100%;height:auto}.border-top{border-top:1px solid rgba(0,0,0,.1) !important}.border-bottom{border-bottom:1px solid rgba(0,0,0,.1) !important}.align-middle{vertical-align:middle !important}.align-top{vertical-align:top !important}.align-bottom{vertical-align:bottom !important}.float-start{float:left !important}.float-end{float:right !important}.float-none{float:none !important}.visually-hidden{position:absolute !important;width:1px !important;height:1px !important;padding:0 !important;margin:-1px !important;overflow:hidden !important;clip:rect(0, 0, 0, 0) !important;white-space:nowrap !important;border:0 !important}
data/lib/tina4/queue.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  require "json"
3
3
  require "securerandom"
4
+ require "uri"
4
5
  require_relative "job"
5
6
 
6
7
  module Tina4
@@ -35,6 +36,67 @@ module Tina4
35
36
  @backend = resolve_backend_arg(backend)
36
37
  end
37
38
 
39
+ # ── The file store's layout: the ONE answer to "where do the queue files
40
+ # live". ─────────────────────────────────────────────────────────────────
41
+ #
42
+ # Everything that touches the store asks here — the lite backend that
43
+ # writes it, and every dev-admin queue handler that reads it. It exists
44
+ # because they used to disagree: the dev admin scanned a hardcoded
45
+ # Dir.pwd/data/queue while the backend wrote to Dir.pwd/.queue and read
46
+ # TINA4_QUEUE_PATH nowhere, so the panel counted one directory and listed
47
+ # another, and its topic list could never name a real topic. Re-deriving
48
+ # any of this anywhere else re-opens that defect.
49
+ #
50
+ # The layout is the canonical cross-framework one, identical to the Python
51
+ # master, PHP and Node:
52
+ #
53
+ # <base>/<topic>/*.queue-data pending
54
+ # <base>/<topic>/reserved/*.queue-data reserved (in flight)
55
+ # <base>/dead_letter/*.queue-data dead-lettered, tagged by topic
56
+
57
+ # Canonical job-file extension in all four frameworks.
58
+ JOB_EXTENSION = ".queue-data"
59
+
60
+ # Dead letters share one directory and carry their topic in the record.
61
+ DEAD_LETTER_DIRNAME = "dead_letter"
62
+
63
+ # Root of the file store: TINA4_QUEUE_PATH, else "data/queue" relative to
64
+ # the working directory.
65
+ #
66
+ # Absolute, so every caller gets the same directory rather than one that
67
+ # depends on when it was resolved. A blank value is treated as unset (the
68
+ # same normalisation TINA4_SESSION_BACKEND applies) — File.join("", topic)
69
+ # would otherwise silently address /<topic>, at the filesystem root.
70
+ def self.base_path
71
+ configured = ENV["TINA4_QUEUE_PATH"].to_s.strip
72
+ File.expand_path(configured.empty? ? "data/queue" : configured)
73
+ end
74
+
75
+ # The single path segment a topic maps to.
76
+ #
77
+ # Sanitised HERE, in the one place that resolves it, so no caller can build
78
+ # a store path out of an unsanitised topic name — the dev-admin queue panel
79
+ # takes its topic straight off a query string, where "../.." would
80
+ # otherwise walk out of the store.
81
+ def self.topic_dirname(topic)
82
+ topic.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
83
+ end
84
+
85
+ # Directory holding +topic+'s pending jobs.
86
+ def self.topic_path(topic)
87
+ File.join(base_path, topic_dirname(topic))
88
+ end
89
+
90
+ # Every job file in +dir+, name-sorted so two readers of the same directory
91
+ # always see the same order.
92
+ def self.job_files(dir)
93
+ Dir.glob(File.join(dir, "*#{JOB_EXTENSION}")).sort
94
+ end
95
+
96
+ def self.job_file(dir, id)
97
+ File.join(dir, "#{id}#{JOB_EXTENSION}")
98
+ end
99
+
38
100
  # Reservation/visibility timeout in seconds, from env (default 300 = 5 min).
39
101
  def self.default_visibility_timeout
40
102
  Float(ENV.fetch("TINA4_QUEUE_VISIBILITY_TIMEOUT", "300"))
@@ -77,43 +139,89 @@ module Tina4
77
139
 
78
140
  # Clear all pending jobs from this queue's topic. Returns count removed.
79
141
  def clear # -> int
80
- return 0 unless @backend.respond_to?(:clear)
142
+ # Was `return 0` - a SILENT no-op that looked exactly like an empty
143
+ # queue. A backend that cannot clear says so, naming itself.
144
+ unless @backend.respond_to?(:clear)
145
+ raise NotImplementedError,
146
+ "The #{@backend.class.name.split('::').last} queue backend cannot " \
147
+ "perform clear(): it cannot remove jobs by topic. Use the file or " \
148
+ "mongodb backend."
149
+ end
81
150
  @backend.clear(@topic)
82
151
  end
83
152
 
153
+ # Release the backend's connection and free its resources.
154
+ #
155
+ # A queue on RabbitMQ, Kafka or MongoDB holds a REAL client and socket.
156
+ # Until 3.13.95 there was no way to hand it back: close existed on the
157
+ # rabbitmq/mongo/kafka backends but was surfaced on NOTHING, and the lite
158
+ # backend had none at all - so every `backend.close if respond_to?(:close)`
159
+ # guard in the tree silently skipped the default backend. An app that built
160
+ # a Queue per request leaked one client per request, invisibly, until the
161
+ # broker refused new connections. Same class of leak as ADR-0025 corollary 4
162
+ # (client-lifecycle-is-bounded).
163
+ #
164
+ # Safe on EVERY backend: the lite backend holds no connection and closes as
165
+ # a documented no-op, so a TINA4_QUEUE_BACKEND change never turns a working
166
+ # shutdown path into an error. Idempotent - each backend drops its handles
167
+ # on the first call, so a second call finds nothing to close and returns.
168
+ #
169
+ # Treat the queue as spent afterwards and build a new one to keep working.
170
+ def close
171
+ @backend.close
172
+ end
173
+
84
174
  # Get jobs that failed but are still eligible for retry (under max_retries).
85
175
  def failed # -> list[dict]
86
- return [] unless @backend.respond_to?(:failed)
176
+ refuse_operation!("failed()") unless @backend.respond_to?(:failed)
87
177
  @backend.failed(@topic, max_retries: @max_retries)
88
178
  end
89
179
 
90
180
  # Retry a specific failed job by ID, or all dead-letter jobs if no id given.
91
181
  # Returns true if re-queued.
92
182
  def retry(job_id = nil, delay_seconds: 0) # -> bool
93
- return false unless @backend.respond_to?(:retry_job)
183
+ refuse_operation!("retry()") unless @backend.respond_to?(:retry_job)
94
184
  @backend.retry_job(@topic, job_id: job_id, delay_seconds: delay_seconds)
95
185
  end
96
186
 
97
187
  # Get dead letter jobs — messages that exceeded max retries.
98
188
  # Pass max_retries to override the queue's default.
99
189
  def dead_letters(max_retries: nil) # -> list[dict]
100
- return [] unless @backend.respond_to?(:dead_letters)
190
+ refuse_operation!("dead_letters()") unless @backend.respond_to?(:dead_letters)
101
191
  @backend.dead_letters(@topic, max_retries: max_retries || @max_retries)
102
192
  end
103
193
 
104
194
  # Delete messages by status (completed, failed, dead).
105
195
  def purge(status, max_retries: nil) # -> int
106
- return 0 unless @backend.respond_to?(:purge)
196
+ refuse_operation!("purge()") unless @backend.respond_to?(:purge)
107
197
  @backend.purge(@topic, status)
108
198
  end
109
199
 
110
200
  # Re-queue failed messages (under max_retries) back to pending.
111
201
  # Returns the number of jobs re-queued.
112
202
  def retry_failed(max_retries: nil) # -> int
113
- return 0 unless @backend.respond_to?(:retry_failed)
203
+ refuse_operation!("retry_failed()") unless @backend.respond_to?(:retry_failed)
114
204
  @backend.retry_failed(@topic, max_retries: max_retries || @max_retries)
115
205
  end
116
206
 
207
+ # A backend that does not implement an operation must SAY SO, naming itself
208
+ # and the operation (invariant 6).
209
+ #
210
+ # These five methods used to `return [] / false / 0 unless
211
+ # @backend.respond_to?(...)`. That turned "this backend cannot do this" into
212
+ # "nothing has failed" / "nothing needed retrying" / "nothing was purged" --
213
+ # answers indistinguishable from a genuine empty success (ADR-0022
214
+ # decision 7). It is the silent-no-op class this invariant exists to remove,
215
+ # and it hid every missing broker method behind a plausible-looking result.
216
+ def refuse_operation!(operation)
217
+ name = @backend.class.name.to_s.split("::").last.sub(/Backend\z/, "").downcase
218
+ raise NotImplementedError,
219
+ "The #{name} queue backend cannot perform #{operation}: it does not " \
220
+ "implement that operation. Returning an empty result would be " \
221
+ "indistinguishable from a successful empty answer. Use the file or " \
222
+ "mongodb backend, which implement the full failure lifecycle."
223
+ end
224
+
117
225
  # Produce a message onto a topic. Convenience wrapper around push().
118
226
  def produce(topic, payload, priority: 0, delay_seconds: 0)
119
227
  available_at = delay_seconds > 0 ? Time.now + delay_seconds : nil
@@ -210,7 +318,13 @@ module Tina4
210
318
  # the given topic (defaults to this queue's topic). Returns the matching
211
319
  # Job (claimed/removed from the queue) or nil.
212
320
  def pop_by_id(topic = nil, id)
213
- return nil unless @backend.respond_to?(:find_by_id)
321
+ # Was `return nil` - indistinguishable from "no such job".
322
+ unless @backend.respond_to?(:find_by_id)
323
+ raise NotImplementedError,
324
+ "The #{@backend.class.name.split('::').last} queue backend cannot " \
325
+ "perform pop_by_id(): it cannot address a single message by id. Use " \
326
+ "the file or mongodb backend."
327
+ end
214
328
  attach(@backend.find_by_id(topic || @topic, id))
215
329
  end
216
330
 
@@ -290,7 +404,12 @@ module Tina4
290
404
 
291
405
  # Resolve the default backend from env vars.
292
406
  def self.resolve_backend(name = nil, max_retries: 3, retry_backoff: 0, visibility_timeout: nil)
293
- chosen = name || ENV.fetch("TINA4_QUEUE_BACKEND", "file").downcase.strip
407
+ # Normalise BOTH sources, not just the env var. `.downcase.strip` used to
408
+ # bind only to the ENV.fetch branch, so an explicit
409
+ # Queue.new(backend: "FILE") fell through to the unknown-backend raise
410
+ # while the identical spelling in TINA4_QUEUE_BACKEND resolved fine -
411
+ # and while python, php and nodejs all accepted it. Python is master here.
412
+ chosen = (name || ENV.fetch("TINA4_QUEUE_BACKEND", "file")).to_s.downcase.strip
294
413
  vt = visibility_timeout.nil? ? default_visibility_timeout : visibility_timeout
295
414
 
296
415
  case chosen.to_s
@@ -301,11 +420,19 @@ module Tina4
301
420
  when "rabbitmq"
302
421
  # Broker manages visibility/redelivery (unacked messages requeue on
303
422
  # channel close) — the framework timeout is accepted but not used.
423
+ # max_retries IS used: fail() counts attempts itself and dead-letters
424
+ # past the limit. Without threading it through, the backend fell back to
425
+ # its own default of 3, so Queue.new(max_retries: 2) gave a job THREE
426
+ # attempts on rabbitmq and two on file — the dead-letter threshold
427
+ # silently changed with the provider.
304
428
  config = resolve_rabbitmq_config
429
+ config[:max_retries] = max_retries
305
430
  Tina4::QueueBackends::RabbitmqBackend.new(config)
306
431
  when "kafka"
307
432
  # Consumer-group offsets manage redelivery — framework timeout N/A.
433
+ # max_retries threaded through for the same reason as rabbitmq.
308
434
  config = resolve_kafka_config
435
+ config[:max_retries] = max_retries
309
436
  Tina4::QueueBackends::KafkaBackend.new(config)
310
437
  when "mongodb", "mongo"
311
438
  config = resolve_mongo_config
@@ -403,7 +530,25 @@ module Tina4
403
530
 
404
531
  if rest.include?("/")
405
532
  hostport, vhost = rest.split("/", 2)
406
- config[:vhost] = vhost.start_with?("/") ? vhost : "/#{vhost}" if vhost && !vhost.empty?
533
+ # THE VHOST IS THE PATH SEGMENT, URL-DECODED, WITH NO LEADING SLASH
534
+ # (RabbitMQ URI spec). This used to prepend "/", so
535
+ # amqp://guest:guest@rabbit:5672/orders asked for a vhost literally
536
+ # named "/orders". No broker has that one - it is named "orders" - so
537
+ # every publish failed against a named vhost, which is the ordinary
538
+ # multi-tenant setup and the form every RabbitMQ tutorial shows.
539
+ # MEASURED against a real broker: 4 of 5 URL shapes resolved to the
540
+ # wrong name, and the only one that worked carried no vhost at all,
541
+ # which is why four green suites never noticed.
542
+ #
543
+ # Decoding matters for the same reason: the DEFAULT vhost is named "/",
544
+ # which cannot appear literally in a path, so the spec spells it "%2f".
545
+ #
546
+ # DELIBERATE DEVIATION, one shape: the spec reads a bare trailing slash
547
+ # as the EMPTY vhost name. Tina4 treats it as "not specified" and keeps
548
+ # the caller's default - nobody writes a trailing slash intending a
549
+ # vhost named "", and reading it literally would break a working
550
+ # "amqp://host:5672/" for no benefit.
551
+ config[:vhost] = URI::DEFAULT_PARSER.unescape(vhost) if vhost && !vhost.empty?
407
552
  else
408
553
  hostport = rest
409
554
  end
@@ -7,8 +7,13 @@ module Tina4
7
7
  require "rdkafka"
8
8
  @brokers = options[:brokers] || "localhost:9092"
9
9
  @group_id = options[:group_id] || "tina4_consumer_group"
10
+ @max_retries = options[:max_retries] || 3
10
11
 
11
12
  security = self.class._security_config
13
+ # Kept so dead_letters() can build a short-lived READER consumer with
14
+ # its own group id, which reads the dead-letter topic from the start
15
+ # without disturbing the main subscription or committing offsets.
16
+ @security = security
12
17
 
13
18
  producer_config = {
14
19
  "bootstrap.servers" => @brokers
@@ -35,6 +40,9 @@ module Tina4
35
40
  # many Kafka deployments already set. Honours security.protocol (e.g. SSL,
36
41
  # SASL_SSL), ssl.ca.location, and optional SASL (mechanism / username /
37
42
  # password). Unset keys are omitted, leaving librdkafka's PLAINTEXT default.
43
+ # Queue propagates its own configuration onto the backend after construction.
44
+ attr_accessor :max_retries
45
+
38
46
  def self._security_config
39
47
  # rdkafka key -> env suffix (read as TINA4_KAFKA_<suffix>, then KAFKA_<suffix>)
40
48
  mapping = {
@@ -63,6 +71,22 @@ module Tina4
63
71
  private_class_method :env_value
64
72
 
65
73
  def enqueue(message)
74
+ if message.priority.to_i > 0
75
+ raise NotImplementedError,
76
+ "The kafka queue backend cannot honour push(priority): Kafka has no " \
77
+ "priority concept at all - a consumer reads a partition in offset " \
78
+ "order. Use the file or mongodb backend for prioritised jobs."
79
+ end
80
+
81
+ if message.available_at
82
+ raise NotImplementedError,
83
+ "The kafka queue backend cannot honour push(delay_seconds): Kafka " \
84
+ "has no per-message delay at all. A consumer reads a partition in " \
85
+ "offset order, so a delayed record would stall every record behind " \
86
+ "it. Use the file or mongodb backend for delayed jobs, or schedule " \
87
+ "the push itself."
88
+ end
89
+
66
90
  @producer.produce(
67
91
  topic: message.topic,
68
92
  payload: message.to_json,
@@ -99,15 +123,31 @@ module Tina4
99
123
  data = JSON.parse(msg.payload)
100
124
  @last_message = msg
101
125
 
126
+ # attempts and error MUST be carried back. Rebuilding from
127
+ # topic/payload/id alone reset attempts to 0 on every redelivery, so
128
+ # fail()'s attempts >= max_retries check could never trip and a poison
129
+ # record would be re-produced forever instead of dead-lettering.
102
130
  Tina4::Job.new(
103
131
  topic: data["topic"],
104
132
  payload: data["payload"],
105
- id: data["id"]
133
+ id: data["id"],
134
+ attempts: data["attempts"] || 0,
135
+ error: data["error"]
106
136
  )
107
137
  rescue Rdkafka::RdkafkaError
108
138
  nil
109
139
  end
110
140
 
141
+ # Kafka has no queue depth: a log is a sequence of offsets, not a queue,
142
+ # and computing a "remaining" count means an admin round-trip per call.
143
+ # ADR-0022 decision 5 records 0 as the documented answer, which Python and
144
+ # PHP already return. Ruby had no size method at all, so queue.size raised
145
+ # NoMethodError on kafka - a method that does not resolve at all, which is
146
+ # what invariant 1 forbids.
147
+ def size(_topic)
148
+ 0
149
+ end
150
+
111
151
  def acknowledge(_message)
112
152
  @consumer.commit if @last_message
113
153
  end
@@ -117,17 +157,166 @@ module Tina4
117
157
  end
118
158
 
119
159
  def dead_letter(message)
160
+ # attempts and error MUST ride along, else every Kafka dead letter reads
161
+ # back as attempts=0 with no reason while file/mongodb report the real
162
+ # values.
120
163
  dead_msg = Tina4::Job.new(
121
164
  topic: "#{message.topic}.dead_letter",
122
165
  payload: message.payload,
123
- id: message.id
166
+ id: message.id,
167
+ attempts: message.attempts,
168
+ error: message.error
124
169
  )
125
170
  enqueue(dead_msg)
126
171
  end
127
172
 
173
+ # Terminal ack. Job#complete calls backend.complete, and this backend only
174
+ # had acknowledge() - so job.complete was a silent no-op on kafka and the
175
+ # consumer-group offset was never committed, making the broker redeliver
176
+ # every completed record.
177
+ def complete(message)
178
+ acknowledge(message)
179
+ end
180
+
181
+ # Record a failed attempt, then retry it or dead-letter it.
182
+ #
183
+ # This did not exist. Job#fail guarded on respond_to?(:fail) and silently
184
+ # degraded to in-memory bookkeeping, so job.fail() NEVER reached Kafka:
185
+ # the offset was never committed and no dead letter was produced.
186
+ #
187
+ # A retry RE-PRODUCES a record carrying the new count, because a Kafka
188
+ # record is immutable and carries no delivery counter. The offset is
189
+ # committed LAST so a crash in between redelivers rather than loses -
190
+ # at-least-once, which is the contract.
191
+ def fail(job, error = "")
192
+ job.attempts += 1
193
+ job.error = error
194
+ if job.attempts >= @max_retries
195
+ dead_letter(job)
196
+ else
197
+ enqueue(job)
198
+ end
199
+ acknowledge(job)
200
+ end
201
+
202
+ def retry(job, delay_seconds: 0)
203
+ if delay_seconds.to_f > 0
204
+ raise NotImplementedError,
205
+ "The kafka queue backend cannot honour retry(delay_seconds): " \
206
+ "Kafka has no per-message delay, for the same reason " \
207
+ "push(delay_seconds) is refused - a consumer reads a partition " \
208
+ "in offset order, so a delayed record stalls every record behind " \
209
+ "it. Re-producing immediately while silently dropping the delay " \
210
+ "would run the job far sooner than asked. Use the file or mongodb " \
211
+ "backend for delayed retries."
212
+ end
213
+
214
+ job.attempts += 1
215
+ job.error = nil
216
+ enqueue(job)
217
+ acknowledge(job)
218
+ true
219
+ end
220
+
221
+ # Dead-lettered jobs, read back from the <topic>.dead_letter topic this
222
+ # backend produces to itself. Kafka's log is not queryable by job state,
223
+ # but it IS readable from the beginning - so this ANSWERS rather than
224
+ # refusing, and a dead-letter handler written against the file backend
225
+ # finds the same jobs here (invariant 3).
226
+ #
227
+ # Uses a short-lived consumer with a UNIQUE group id and never commits, so
228
+ # it always reads from the start and a read never consumes. Reading with
229
+ # the main consumer would move its offsets and steal the subscription.
230
+ def dead_letters(topic, max_retries: 3)
231
+ name = "#{topic}.dead_letter"
232
+ reader = Rdkafka::Config.new({
233
+ "bootstrap.servers" => @brokers,
234
+ "group.id" => "#{@group_id}.reader.#{Process.pid}.#{object_id}",
235
+ "auto.offset.reset" => "earliest",
236
+ "enable.auto.commit" => "false"
237
+ }.merge(@security)).consumer
238
+ out = []
239
+ begin
240
+ reader.subscribe(name)
241
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) +
242
+ (ENV["TINA4_KAFKA_ASSIGN_TIMEOUT"] || "15").to_f
243
+ loop do
244
+ msg = reader.poll(500)
245
+ if msg
246
+ data = JSON.parse(msg.payload)
247
+ data["status"] = "dead"
248
+ out << data
249
+ next
250
+ end
251
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
252
+ end
253
+ rescue Rdkafka::RdkafkaError
254
+ # An absent topic means nothing has been dead-lettered yet.
255
+ nil
256
+ ensure
257
+ reader.close
258
+ end
259
+ out
260
+ end
261
+
262
+ def failed(_topic, max_retries: 3)
263
+ raise NotImplementedError,
264
+ "The kafka queue backend cannot answer failed(): a job that failed " \
265
+ "but is still retryable is re-produced to the main topic, and a log " \
266
+ "cannot be queried by job state, so it cannot be told apart from a " \
267
+ "normal pending record. Returning an empty list would claim nothing " \
268
+ "has failed. Use dead_letters() for exhausted jobs, or the file or " \
269
+ "mongodb backend to enumerate retryable failures."
270
+ end
271
+
272
+ def retry_failed(_topic, max_retries: 3)
273
+ raise NotImplementedError,
274
+ "The kafka queue backend cannot perform retry_failed(): it must " \
275
+ "first enumerate the failed-but-retryable jobs, which a log cannot " \
276
+ "be queried for. Returning 0 would claim nothing needed retrying. " \
277
+ "Use retry(job_id) with an id you already hold, or the file or " \
278
+ "mongodb backend."
279
+ end
280
+
281
+ def retry_job(_topic, job_id: nil, delay_seconds: 0)
282
+ raise NotImplementedError,
283
+ "The kafka queue backend cannot perform retry(job_id): a log cannot " \
284
+ "retract a record, so the dead letter would stay on the " \
285
+ ".dead_letter topic and be revived again on the next call, " \
286
+ "duplicating the job. Re-produce it yourself with push() if that " \
287
+ "is what you want, or use the file or mongodb backend."
288
+ end
289
+
290
+ def purge(_topic, _status)
291
+ raise NotImplementedError,
292
+ "The kafka queue backend cannot perform purge(): a log cannot " \
293
+ "delete records by status - retention is time- and size-based and " \
294
+ "set on the topic. Returning 0 would claim nothing needed purging. " \
295
+ "Configure topic retention, or use the file or mongodb backend."
296
+ end
297
+
298
+ def clear(_topic)
299
+ raise NotImplementedError,
300
+ "The kafka queue backend cannot perform clear(): a log cannot " \
301
+ "delete records on demand - retention is time- and size-based and " \
302
+ "set on the topic. Returning 0 would claim the queue was emptied " \
303
+ "when it was not. Configure topic retention, or use the file or " \
304
+ "mongodb backend."
305
+ end
306
+
307
+ # Close the rdkafka producer and consumer (leaving the consumer group).
308
+ #
309
+ # IDEMPOTENT by construction: the handles are dropped in an ensure, so a
310
+ # second close finds nothing and returns. Before 3.13.95 they were left
311
+ # set, and rdkafka raises on closing an already-closed consumer - so a
312
+ # shutdown path that ran twice crashed on the second pass.
128
313
  def close
129
314
  @producer&.close
130
315
  @consumer&.close
316
+ ensure
317
+ @producer = nil
318
+ @consumer = nil
319
+ @subscribed_topics = []
131
320
  end
132
321
  end
133
322
  end