kryten-webqueue 0.48.1__tar.gz → 0.49.0__tar.gz

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 (255) hide show
  1. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/CHANGELOG.md +29 -0
  2. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/PKG-INFO +1 -1
  3. kryten_webqueue-0.49.0/docs/postgres-migration/HANDOFF-repository-port-status.md +510 -0
  4. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/__init__.py +75 -19
  5. kryten_webqueue-0.49.0/kryten_webqueue/catalog/db/_pg_base_domain.py +157 -0
  6. kryten_webqueue-0.49.0/kryten_webqueue/catalog/db/_pg_catalog_db.py +1128 -0
  7. kryten_webqueue-0.49.0/kryten_webqueue/catalog/db/_pg_jobs_db.py +318 -0
  8. kryten_webqueue-0.49.0/kryten_webqueue/catalog/db/_pg_queue_db.py +782 -0
  9. kryten_webqueue-0.49.0/kryten_webqueue/catalog/db/_pg_users_db.py +265 -0
  10. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/_playlists.py +7 -3
  11. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/sql/001_initial_schema.sql +1 -0
  12. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/config.py +35 -0
  13. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/pyproject.toml +1 -1
  14. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_postgres_config_and_engine.py +36 -0
  15. kryten_webqueue-0.49.0/tests/test_postgres_domain_crud.py +624 -0
  16. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/.github/workflows/gitleaks.yml +0 -0
  17. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/.github/workflows/python-publish.yml +0 -0
  18. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/.github/workflows/release.yml +0 -0
  19. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/.gitignore +0 -0
  20. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/.gitleaks.toml +0 -0
  21. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/.pre-commit-config.yaml +0 -0
  22. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/AGENT-WORKFLOW-GUIDE.md +0 -0
  23. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/AGENTS.md +0 -0
  24. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/Containerfile +0 -0
  25. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/README.md +0 -0
  26. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/config.example.json +0 -0
  27. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/deploy/kryten-webqueue.service +0 -0
  28. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/deploy/nginx-queue.conf +0 -0
  29. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/deploy/podman/webqueue/webqueue-app.container +0 -0
  30. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/deploy/podman/webqueue/webqueue.network +0 -0
  31. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/DATABASE_ARCHITECTURE_PLAN.md +0 -0
  32. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  33. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/IMPL_API_GATE.md +0 -0
  34. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/IMPL_ECONOMY.md +0 -0
  35. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  36. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/IMPL_ROBOT.md +0 -0
  37. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  38. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  39. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/Log for incorrect promo insertion problem.txt +0 -0
  40. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  41. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/PRD_TMDB_LOCAL_INDEX.md +0 -0
  42. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/PRE_PLAN_GAPS.md +0 -0
  43. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/PRODUCT_PLAN.md +0 -0
  44. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/PUBLIC_API.md +0 -0
  45. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/SPEC-Visual-UX-Sprint-August-2026.md +0 -0
  46. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/SPEC_ADMIN_ITEM_EDITOR.md +0 -0
  47. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
  48. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/SPEC_EMOTE_REHOST.md +0 -0
  49. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  50. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/SPEC_TMDB_LOCAL_INDEX.md +0 -0
  51. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/UX_POLISH_PLAN.md +0 -0
  52. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/postgres-cutover.md +0 -0
  53. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/postgres-migration/PRD-postgres-migration.md +0 -0
  54. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/postgres-migration/SPEC-Sortie-1-db-config-and-pool.md +0 -0
  55. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/postgres-migration/SPEC-Sortie-2-connection-layer-port.md +0 -0
  56. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/postgres-migration/SPEC-Sortie-3-fts5-to-tsvector.md +0 -0
  57. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/postgres-migration/SPEC-Sortie-4-tmdb-index-database.md +0 -0
  58. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/postgres-migration/SPEC-Sortie-5-etl-migration.md +0 -0
  59. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/postgres-migration/SPEC-Sortie-6-tests-cutover-release.md +0 -0
  60. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/sqlite-domain-separation/PRD-sqlite-domain-separation.md +0 -0
  61. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/sqlite-domain-separation/SPEC-Sortie-1-schema-partitioning-and-config.md +0 -0
  62. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/sqlite-domain-separation/SPEC-Sortie-2-multi-db-connection-layer.md +0 -0
  63. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/sqlite-domain-separation/SPEC-Sortie-3-cross-domain-query-decoupling.md +0 -0
  64. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/docs/sqlite-domain-separation/SPEC-Sortie-4-etl-split-script-and-validation.md +0 -0
  65. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/__init__.py +0 -0
  66. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/__main__.py +0 -0
  67. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  68. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/api_gate/client.py +0 -0
  69. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/app.py +0 -0
  70. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/auth/__init__.py +0 -0
  71. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/auth/device_keys.py +0 -0
  72. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/auth/otp.py +0 -0
  73. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  74. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/auth/session.py +0 -0
  75. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/__init__.py +0 -0
  76. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/_base_domain.py +0 -0
  77. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/_blackouts.py +0 -0
  78. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/_catalog.py +0 -0
  79. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/_catalog_db.py +0 -0
  80. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/_connection.py +0 -0
  81. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/_devices.py +0 -0
  82. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/_enrichment.py +0 -0
  83. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  84. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/_fetch_queue.py +0 -0
  85. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/_jobs_db.py +0 -0
  86. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/_motd.py +0 -0
  87. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/_people.py +0 -0
  88. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/_queue.py +0 -0
  89. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/_queue_db.py +0 -0
  90. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/_users_db.py +0 -0
  91. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
  92. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/engine.py +0 -0
  93. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/schemas/__init__.py +0 -0
  94. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/schemas/catalog_schema.py +0 -0
  95. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/schemas/jobs_schema.py +0 -0
  96. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/schemas/queue_schema.py +0 -0
  97. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/db/schemas/users_schema.py +0 -0
  98. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/enrichment/__init__.py +0 -0
  99. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/enrichment/classify.py +0 -0
  100. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/enrichment/normalise.py +0 -0
  101. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/enrichment/pipeline.py +0 -0
  102. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/enrichment/providers.py +0 -0
  103. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/enrichment/report.py +0 -0
  104. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/enrichment/steps/__init__.py +0 -0
  105. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/enrichment/steps/art.py +0 -0
  106. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/enrichment/steps/categories.py +0 -0
  107. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/enrichment/steps/identify.py +0 -0
  108. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/enrichment/steps/meta.py +0 -0
  109. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/enrichment/steps/sync.py +0 -0
  110. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/enrichment/steps/tags.py +0 -0
  111. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/enrichment/steps/title.py +0 -0
  112. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/images.py +0 -0
  113. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  114. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/sync.py +0 -0
  115. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/tmdb_index/__init__.py +0 -0
  116. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/tmdb_index/_schema.py +0 -0
  117. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/tmdb_index/_textmatch.py +0 -0
  118. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/tmdb_index/_ttscrape.py +0 -0
  119. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/tmdb_index/builder.py +0 -0
  120. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/tmdb_index/coverage.py +0 -0
  121. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/catalog/tmdb_index/index.py +0 -0
  122. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/integrations/__init__.py +0 -0
  123. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  124. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  125. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/integrations/cmsutils/blackout_scan.py +0 -0
  126. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  127. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  128. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  129. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  130. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
  131. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
  132. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  133. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  134. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/jobs/__init__.py +0 -0
  135. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  136. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/jobs/job_scheduler.py +0 -0
  137. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/jobs/log_capture.py +0 -0
  138. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/jobs/manager.py +0 -0
  139. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/jobs/motd_publish.py +0 -0
  140. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
  141. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/jobs/tasks.py +0 -0
  142. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/logging_config.py +0 -0
  143. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/migrate_sqlite_to_pg.py +0 -0
  144. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/motd/__init__.py +0 -0
  145. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/motd/builder.py +0 -0
  146. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/motd/render.py +0 -0
  147. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/playlists/__init__.py +0 -0
  148. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  149. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/playlists/fire.py +0 -0
  150. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/playlists/importer.py +0 -0
  151. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/playlists/ordering.py +0 -0
  152. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  153. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/promos/__init__.py +0 -0
  154. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/promos/director.py +0 -0
  155. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/queue/__init__.py +0 -0
  156. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/queue/completion.py +0 -0
  157. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/queue/ordering.py +0 -0
  158. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/queue/poller.py +0 -0
  159. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/queue/presence.py +0 -0
  160. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/queue/race_poller.py +0 -0
  161. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/queue/shadow.py +0 -0
  162. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/routes/__init__.py +0 -0
  163. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  164. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/routes/admin_feedback.py +0 -0
  165. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
  166. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  167. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/routes/admin_moderation.py +0 -0
  168. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/routes/admin_motd.py +0 -0
  169. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  170. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  171. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  172. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  173. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/routes/auth.py +0 -0
  174. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/routes/catalog.py +0 -0
  175. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/routes/devices.py +0 -0
  176. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/routes/feedback.py +0 -0
  177. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/routes/pages.py +0 -0
  178. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/routes/public_api.py +0 -0
  179. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/routes/queue.py +0 -0
  180. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/routes/user.py +0 -0
  181. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/scripts/__init__.py +0 -0
  182. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/scripts/split_databases.py +0 -0
  183. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/static/css/main.css +0 -0
  184. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/static/js/admin-motd.js +0 -0
  185. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  186. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/static/js/admin-promos.js +0 -0
  187. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  188. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  189. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/static/js/admin.js +0 -0
  190. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/static/js/keybindings.js +0 -0
  191. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/static/js/main.js +0 -0
  192. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/templates/admin/index.html +0 -0
  193. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/templates/admin/motd.html +0 -0
  194. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  195. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  196. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  197. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  198. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/templates/auth/login.html +0 -0
  199. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/templates/base.html +0 -0
  200. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  201. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/templates/catalog/edit_item_modal.html +0 -0
  202. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  203. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  204. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/templates/feedback/index.html +0 -0
  205. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/templates/motd/channel_z.html +0 -0
  206. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/templates/queue/index.html +0 -0
  207. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/templates/race.html +0 -0
  208. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  209. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/templates/user/link_device.html +0 -0
  210. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/templates/user/my_list.html +0 -0
  211. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/ws/__init__.py +0 -0
  212. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/ws/handler.py +0 -0
  213. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/kryten_webqueue/ws/manager.py +0 -0
  214. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/scripts/AGENTS.md +0 -0
  215. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/scripts/patch_mediacms_bulk_facets.py +0 -0
  216. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/scripts/patch_mediacms_tags.py +0 -0
  217. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/scripts/split_databases.py +0 -0
  218. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/scripts/trigger_enrich.py +0 -0
  219. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/__init__.py +0 -0
  220. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_catalog_blackout.py +0 -0
  221. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_catalog_delete.py +0 -0
  222. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_classify.py +0 -0
  223. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_concurrency_split.py +0 -0
  224. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_config_persistence.py +0 -0
  225. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_database_config_and_schemas.py +0 -0
  226. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_device_linking.py +0 -0
  227. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_feedback.py +0 -0
  228. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_fetch_queue_pacing.py +0 -0
  229. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_fetchurls_sharepoint.py +0 -0
  230. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_identify_step.py +0 -0
  231. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_job_logs_and_queue_visibility.py +0 -0
  232. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_motd_publish.py +0 -0
  233. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_multi_db_connection.py +0 -0
  234. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_people_facets.py +0 -0
  235. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_phase1.py +0 -0
  236. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_phase2_jobs.py +0 -0
  237. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_phase3_jobs.py +0 -0
  238. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_phase4_live_fixes.py +0 -0
  239. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_playlist_import.py +0 -0
  240. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_postgres_live.py +0 -0
  241. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_postgres_pruner.py +0 -0
  242. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_presence_refund.py +0 -0
  243. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_promo_director.py +0 -0
  244. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_promo_pool_exclusion.py +0 -0
  245. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_providers.py +0 -0
  246. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_queue_announce.py +0 -0
  247. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_queue_preview_fallback.py +0 -0
  248. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_race_poller.py +0 -0
  249. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_recently_played_hide.py +0 -0
  250. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_save_results_to_playlist.py +0 -0
  251. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_schedule_lock.py +0 -0
  252. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_search_facets.py +0 -0
  253. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_title_normalization.py +0 -0
  254. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_tmdb_index.py +0 -0
  255. {kryten_webqueue-0.48.1 → kryten_webqueue-0.49.0}/tests/test_watchlist.py +0 -0
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.49.0] - 2026-09-23
4
+
5
+ ### Added
6
+
7
+ - **Postgres repository port complete (Sortie 2/3)**: all ~140 SQLite domain methods
8
+ across `catalog`/`queue`/`jobs`/`users` are now implemented for the `asyncpg`/Postgres
9
+ backend (`_pg_catalog_db.py`, `_pg_queue_db.py`, `_pg_jobs_db.py`, `_pg_users_db.py`),
10
+ wired into `Database.__init__` for `database.backend == "postgres"`. `search()`/
11
+ `search_count()` use a hybrid `tsvector` + `pg_trgm` two-CTE query in place of SQLite's
12
+ FTS5. Live-tested end-to-end against a disposable `webqueue_test` database on
13
+ `chandra-1` (`tests/test_postgres_domain_crud.py`).
14
+
15
+ ### Fixed
16
+
17
+ - **`Database` facade never dispatched cross-domain orchestration methods (`browse`,
18
+ `search`, `get_item`, `is_restricted`, etc.) for the Postgres backend** — these all
19
+ checked `layout == "partitioned"` only, never `backend == "postgres"`, so a Postgres
20
+ `Database` would silently fall through to the SQLite-monolith code path. Introduced
21
+ `Database._domain_dispatch` (`layout == "partitioned" or backend == "postgres"`) and
22
+ updated every dispatch check to use it.
23
+ - **`rotate_playlist_item_to_bottom` silently failed on every real play completion** in
24
+ both partitioned SQLite and Postgres: it queried a bare `catalog` table from within the
25
+ `queue`-only connection/schema, which doesn't exist there (`OperationalError`/
26
+ `UndefinedTableError`), swallowed by a bare `except Exception` in
27
+ `queue/completion.py`. Added an `is_partitioned` flag to skip the same-connection
28
+ lookup, and a `Database.rotate_playlist_item_to_bottom` facade override that resolves
29
+ `friendly_token -> manifest_url` via the `catalog` domain first, mirroring the existing
30
+ `_get_reserved_tokens()` pattern.
31
+
3
32
  ## [0.48.1] - 2026-09-23
4
33
 
5
34
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kryten-webqueue
3
- Version: 0.48.1
3
+ Version: 0.49.0
4
4
  Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
5
5
  Author: grobertson
6
6
  License-Expression: MIT
@@ -0,0 +1,510 @@
1
+ # HANDOFF: PostgreSQL Repository Port (Sortie 2/3) — In-Progress Status
2
+
3
+ **Status as of**: 2026-09-23, session end
4
+ **For**: The next agent/engineer picking up the Postgres cutover of `kryten-webqueue`
5
+ **Read this before touching anything.** Production (`https://queue.dropsugar.co/`) is
6
+ currently served by `kryten-webqueue.service` on `grindhouse.local`, running SQLite in
7
+ `layout=partitioned` mode at **v0.48.1**. It is healthy and untouched by this work.
8
+ **Nothing in this document has been deployed to production.**
9
+
10
+ ---
11
+
12
+ ## 1. TL;DR
13
+
14
+ The goal is to run `kryten-webqueue` as a Podman container on `chandra-1`, reading/writing
15
+ the already-provisioned PostgreSQL database `webqueue` (schemas `catalog`, `queue`, `jobs`,
16
+ `users`, `tmdb`) instead of SQLite.
17
+
18
+ **What actually exists and is verified working:**
19
+ - PostgreSQL `webqueue` database on `chandra-1`, schema applied, `pg_trgm` installed,
20
+ `kryten` role granted full privileges on all 5 schemas.
21
+ - A one-shot ETL (`kryten_webqueue/migrate_sqlite_to_pg.py`) has already been run once and
22
+ reported 100% row-count parity — **but this data is now stale** (see §5.6). It must be
23
+ re-run in the final maintenance window, not relied upon as-is.
24
+ - A Podman image `localhost/kryten-webqueue:latest` exists on `chandra-1`, but it was built
25
+ **2026-09-23 15:21 UTC from commit `533d6ed` (v0.48.0)** — it predates the v0.48.1 hotfix
26
+ and **all** of the Postgres repository-port work described below. **It must be rebuilt
27
+ before any deployment.**
28
+ - Quadlet unit files exist in the repo (`deploy/podman/webqueue/webqueue.network`,
29
+ `webqueue-app.container`) but are **not installed** on `chandra-1`
30
+ (`/etc/containers/systemd/` has nothing webqueue-related). No `webqueue-app.service`
31
+ exists yet; nothing is running there.
32
+ - nginx on `grindhouse.local` still points at `127.0.0.1:2010`. It has not been touched.
33
+
34
+ **What does NOT exist yet (the actual remaining work):**
35
+ - The application's database layer (`kryten_webqueue/catalog/db/_catalog.py`, `_queue.py`,
36
+ `_playlists.py`, `_jobs_db.py`, `_users_db.py`, `_devices.py`, `_feedback.py`,
37
+ `_watchlist.py`, `_people.py`, `_enrichment.py`, `_fetch_queue.py`, `_blackouts.py`,
38
+ `_motd.py` — roughly **4,700 lines**) is **100% SQLite-only**. There is no code path that
39
+ reads or writes Postgres for any actual application query yet. `Database.__init__` in
40
+ `kryten_webqueue/catalog/db/__init__.py` only branches on SQLite `layout`
41
+ (monolith/partitioned); it does not look at `config.database.backend` at all.
42
+ - This session started that port (see §3) but only the shared low-level connection helper
43
+ (`_pg_base_domain.py`) exists so far. **Zero of the ~140 domain methods have been ported.**
44
+
45
+ **Do not build/deploy the chandra-1 pod against Postgres until §4's checklist is complete
46
+ and tested.** Standing up the container today would either crash immediately (schema
47
+ mismatch, e.g. missing `id` column on `users.otps` — see §3.2) or, worse, silently write
48
+ malformed data if partially wired.
49
+
50
+ ---
51
+
52
+ ## 2. Why this exists / prior session context
53
+
54
+ This is the continuation of the `postgres-migration` sprint documented in
55
+ `docs/postgres-migration/PRD-postgres-migration.md` and its `SPEC-Sortie-*.md` files. Prior
56
+ sessions completed:
57
+ - Sortie 1 (config + engine scaffold) — done, `PostgresConfig`/`DatabaseConfig.backend` in
58
+ `kryten_webqueue/config.py`, `kryten_webqueue/catalog/db/engine.py`.
59
+ - Sortie 4 (TMDB schema) and Sortie 5 (ETL) — schema + script exist and were run once.
60
+ - Sortie 6 (pruner, Quadlet files, cutover runbook) — files exist in the repo, not deployed.
61
+
62
+ **Sortie 2 (SQLAlchemy 2.0 repository port) and Sortie 3 (FTS5 → tsvector/pg_trgm) were
63
+ never done.** That is the actual bulk of "finishing the migration," and it's what this
64
+ session began. See `docs/postgres-migration/SPEC-Sortie-2-connection-layer-port.md` and
65
+ `SPEC-Sortie-3-fts5-to-tsvector.md` for the original design intent (SQLAlchemy Core). **This
66
+ session took a different, lighter-weight implementation approach — see §3.1 for why, and
67
+ make a deliberate decision about whether to continue with it or switch to the SQLAlchemy
68
+ plan before writing more code.**
69
+
70
+ ---
71
+
72
+ ## 3. What this session actually did
73
+
74
+ ### 3.1 Architecture decision made (needs confirmation/ratification)
75
+
76
+ Instead of the SPEC's SQLAlchemy Core/ORM approach, this session started a **direct
77
+ `asyncpg`, per-domain connection pool** design:
78
+
79
+ - Each of the 4 domains (`catalog`, `queue`, `jobs`, `users`) gets its own `asyncpg.Pool`,
80
+ created with an `init` callback that runs `SET search_path TO <schema>, public` on every
81
+ connection.
82
+ - Because of that `search_path` scoping, **the existing SQL text's unqualified table names
83
+ (`catalog`, `tags`, `queue_shadow`, `job_runs`, `otps`, etc.) resolve correctly to
84
+ `<schema>.<table>` without rewriting every query to be schema-qualified.** This was the
85
+ key insight that makes porting ~140 methods tractable instead of requiring a full ORM
86
+ rewrite.
87
+ - `?` positional placeholders are mechanically translated to asyncpg's `$1, $2, …` by a
88
+ shared `to_pg_sql()` helper. This is the *only* automatic translation — everything else
89
+ (SQLite-specific functions, `rowid`/`lastrowid`, upsert syntax, FTS5) must be hand-fixed
90
+ per call site (see §4).
91
+
92
+ This is **not** what `kryten_webqueue/catalog/db/engine.py` was scaffolded for — that file
93
+ builds a SQLAlchemy `AsyncEngine`/`async_sessionmaker` and **is not used by anything**
94
+ (dead code as of now). Before continuing, an explicit decision is needed:
95
+
96
+ - **(A) Keep the asyncpg/search_path approach** (what this session started) — faster to
97
+ finish, less abstraction, `engine.py` should probably be deleted or repurposed once this
98
+ is proven, and `SPEC-Sortie-2-connection-layer-port.md` should be updated to reflect
99
+ reality.
100
+ - **(B) Discard `_pg_base_domain.py` and do the original SQLAlchemy Core port** — more
101
+ idiomatic, matches the existing SPEC and `engine.py`, but is materially more work (schema
102
+ metadata, Core `text()` binds, session-per-request wiring in FastAPI dependencies) with no
103
+ session-1 progress toward it yet.
104
+
105
+ **Recommendation: (A).** The reasoning above (search_path reuse) is sound and most of the
106
+ remaining work is mechanical per-file translation, not architecture. This document assumes
107
+ (A) going forward.
108
+
109
+ ### 3.2 Files created/modified this session
110
+
111
+ | File | Status |
112
+ |---|---|
113
+ | `kryten_webqueue/catalog/db/_pg_base_domain.py` | **New.** `_PgDomainDB` base class: connection pool, `_execute`, `_execute_returning_id`, `_executemany`, `_fetch_one`, `_fetch_all`, `_fetch_val`, plus `to_pg_sql()` and a `_PgResult` shim (`.rowcount`, `.lastrowid`) so call sites can look similar to the aiosqlite ones. **Untested — never connected to a live database, never imported anywhere.** |
114
+ | `kryten_webqueue/catalog/db/sql/001_initial_schema.sql` | **Modified.** Added `id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY` to `users.otps` (SQLite's `verify_otp`/etc. use `rowid`, which doesn't exist in Postgres — needed a real PK). **This change is repo-only. It has NOT been applied to the live `chandra-1` database.** Live `users.otps` on chandra-1 currently has no `id` column (verified via `\d users.otps` this session). |
115
+
116
+ **Nothing else changed.** No domain classes, no facade wiring, no tests, no deployment.
117
+
118
+ ### 3.3 Immediate next action if picking (A)
119
+
120
+ Apply the schema fix live before writing/testing the users-domain Postgres class:
121
+
122
+ ```bash
123
+ ssh groberts@chandra-1.local
124
+ PGPASSWORD=kryten_secret_password psql -h 127.0.0.1 -U kryten -d webqueue -c \
125
+ "ALTER TABLE users.otps ADD COLUMN id BIGINT GENERATED ALWAYS AS IDENTITY;"
126
+ PGPASSWORD=kryten_secret_password psql -h 127.0.0.1 -U kryten -d webqueue -c \
127
+ "ALTER TABLE users.otps ADD PRIMARY KEY (id);"
128
+ ```
129
+
130
+ Verify existing rows got sequential ids (`SELECT * FROM users.otps ORDER BY id;`) and that
131
+ this didn't violate anything (it won't — `otps` currently has ~4-8 rows, no FK references
132
+ it).
133
+
134
+ ---
135
+
136
+ ## 4. Remaining work: exact checklist
137
+
138
+ Authoritative method lists below are taken directly from `_DOMAIN_METHOD_MAP` in
139
+ `kryten_webqueue/catalog/db/__init__.py` — that dict is the single source of truth for what
140
+ must exist on each domain object. Cross-check against it; it may have shifted since this
141
+ was written.
142
+
143
+ ### 4.1 Wiring (do this last, after at least one domain is ported and tested)
144
+
145
+ In `kryten_webqueue/catalog/db/__init__.py`, `Database.__init__` currently only branches on
146
+ `self._layout` (`monolith` vs `partitioned`) — it never looks at
147
+ `self._db_config.backend`. Needs a new top-level branch:
148
+
149
+ ```python
150
+ if self._db_config.backend == "postgres":
151
+ dsn = self._db_config.postgres.get_async_url() # already asyncpg-flavored (postgresql+asyncpg://...)
152
+ # NOTE: asyncpg.connect()/create_pool() wants a plain "postgresql://" DSN, NOT
153
+ # "postgresql+asyncpg://" (that prefix is a SQLAlchemy convention). Strip it, or add
154
+ # a separate get_asyncpg_dsn() to PostgresConfig that returns the bare form.
155
+ self.catalog = _PgCatalogDB(dsn, schema="catalog")
156
+ self.queue = _PgQueueDB(dsn, schema="queue")
157
+ self.jobs = _PgJobsDB(dsn, schema="jobs")
158
+ self.users = _PgUsersDB(dsn, schema="users")
159
+ elif self._layout == "partitioned":
160
+ ... # existing sqlite branch, unchanged
161
+ else:
162
+ ... # existing sqlite monolith branch, unchanged
163
+ ```
164
+
165
+ `connect()`, `close()` already just `asyncio.gather` across `self.catalog/queue/jobs/users`
166
+ — those should work unchanged as long as the Pg domain classes expose `connect()`/`close()`
167
+ with the same signature (they do, per `_pg_base_domain.py`).
168
+
169
+ `run_migrations()` must **skip** entirely for the Postgres branch — schema is applied via
170
+ `sql/001_initial_schema.sql` out-of-band, not via the SQLite-style versioned migration list.
171
+ `_PgDomainDB.run_migrations()` is already a no-op; just make sure `Database.run_migrations()`
172
+ doesn't call it in a way that breaks (currently fine, it's `await asyncio.gather(...)` on
173
+ whatever `.run_migrations()` no-ops to).
174
+
175
+ **PostgresConfig needs a fix**: `get_async_url()` returns `postgresql+asyncpg://...` which is
176
+ correct for SQLAlchemy but `asyncpg.connect()`/`create_pool()` do **not** accept the
177
+ `+asyncpg` dialect suffix — they want bare `postgresql://`. Add a
178
+ `get_asyncpg_dsn()` method (or strip the suffix inline) before wiring this up. This will
179
+ crash instantly if not fixed — write a quick unit test for it.
180
+
181
+ ### 4.2 Domain: `users` (smallest, do first)
182
+
183
+ Methods (from `_DOMAIN_METHOD_MAP`, `users` domain), source mixins in parens:
184
+
185
+ ```text
186
+ watchlist_add, watchlist_remove, watchlist_tokens, watchlist_count,
187
+ get_user_watchlist_tokens (_watchlist.py, _users_db.py)
188
+ store_otp, verify_otp, cleanup_expired_otps (_users_db.py)
189
+ create_link_code, get_valid_link_code, delete_link_code,
190
+ link_code_exists, purge_expired_link_codes,
191
+ create_device_key, get_device_key_by_hash, touch_device_key,
192
+ list_device_keys, delete_device_key, revoke_user_device_keys,
193
+ device_key_usernames (_devices.py)
194
+ add_feedback, list_feedback, count_feedback,
195
+ set_feedback_status, delete_feedback,
196
+ add_title_suggestion, list_title_suggestions,
197
+ count_title_suggestions, set_title_suggestion_status,
198
+ delete_title_suggestion (_feedback.py)
199
+ ```
200
+ (Note: `create_feedback`/`update_feedback_status`/`create_title_suggestion`/
201
+ `update_title_suggestion_status` are also in the map as aliases — check whether those are
202
+ actually called anywhere or are dead map entries before implementing twice.)
203
+
204
+ **Known SQLite-isms to fix in this domain** (grep for these patterns yourself too, this list
205
+ may not be exhaustive):
206
+ - `_users_db.py::verify_otp` — `SELECT rowid FROM otps ...` / `UPDATE otps SET used=1 WHERE
207
+ rowid=?` → use the new `id` column (§3.3) instead of `rowid`.
208
+ - `datetime('now')` → `now()`. Appears in: `_devices.py` (`get_valid_link_code`,
209
+ `purge_expired_link_codes`, `touch_device_key`), `_users_db.py` (`verify_otp`,
210
+ `cleanup_expired_otps`).
211
+ - `datetime(expires_at) > datetime('now')` → `expires_at > now()` (the `datetime(...)` wrap
212
+ on a timestamptz column is a SQLite-ism for string-normalizing; unnecessary and invalid in
213
+ Postgres — just drop it).
214
+ - `cursor.lastrowid` → `_execute_returning_id(sql + " RETURNING id", params)`. Appears in
215
+ `_devices.py::create_device_key`, `_feedback.py::add_feedback` and
216
+ `add_title_suggestion`.
217
+ - `INSERT OR IGNORE INTO user_watchlist (...)` (`_watchlist.py::watchlist_add`) → `INSERT
218
+ INTO user_watchlist (...) VALUES (...) ON CONFLICT DO NOTHING`. Return value should be
219
+ based on whether a row was actually inserted — asyncpg's `execute()` status string for a
220
+ no-op conflict is `"INSERT 0 0"` vs `"INSERT 0 1"` on success; `_PgResult.rowcount` already
221
+ parses the trailing number, so `rowcount > 0` still works as the existing code expects.
222
+
223
+ ### 4.3 Domain: `jobs`
224
+
225
+ Methods:
226
+
227
+ ```text
228
+ start_job_run, finish_job_run, update_job_run_detail, add_job_run_logs,
229
+ get_job_run_logs, get_job_run, get_job_runs, reconcile_orphaned_job_runs,
230
+ get_job_schedules, get_job_schedule, upsert_job_schedule, delete_job_schedule,
231
+ prune_job_run_logs (_jobs_db.py)
232
+ enqueue_fetch, claim_next_fetch_item, finish_fetch_item, requeue_fetch_item,
233
+ requeue_fetch_item_for_retry, reset_running_fetch_items, get_fetch_queue,
234
+ count_fetch_queue, count_fetch_queue_pending, delete_fetch_queue_item
235
+ (_fetch_queue.py)
236
+ ```
237
+
238
+ **Known SQLite-isms:**
239
+ - `cursor.lastrowid` in `start_job_run`, `_fetch_queue.py::enqueue_fetch` → `RETURNING id`.
240
+ - `_db.executemany(...)` in `add_job_run_logs` → `_executemany()` (already implemented in
241
+ `_pg_base_domain.py` via `conn.executemany`).
242
+ - `datetime('now')` / `datetime('now', ?)` throughout (`finish_job_run`,
243
+ `upsert_job_schedule`'s `updated_at`, `prune_job_run_logs`'s `WHERE logged_at <
244
+ datetime('now', ?)`, `_fetch_queue.py`'s `started_at`/`finished_at`/the
245
+ `added_at = (SELECT datetime(MAX(added_at), '+1 second') FROM fetch_queue)` self-referencing
246
+ insert, and `finished_at < datetime('now', '-24 hours')`).
247
+ - `datetime('now', ?)` with a param like `'-30 days'` → `(now() + ($n)::interval)` — the
248
+ interval literal syntax (`'-30 days'`) is valid in both dialects, so the **parameter
249
+ value itself doesn't need to change**, only the SQL text wrapping it.
250
+ - `datetime(MAX(added_at), '+1 second')` → `(MAX(added_at) + interval '1 second')`.
251
+ - `datetime('now', '-24 hours')` (literal, no param) → `(now() - interval '24 hours')`.
252
+ - `ON CONFLICT(job_name) DO UPDATE SET ... updated_at = datetime('now')` in
253
+ `upsert_job_schedule` — the `ON CONFLICT` structure is already Postgres-compatible, just
254
+ fix the `datetime('now')` inside it.
255
+ - **This is the domain with the automated 30-day log pruner** (`prune_job_run_logs`,
256
+ see `docs/postgres-migration/SPEC-Sortie-6-tests-cutover-release.md` §3.1). The compliance
257
+ constraint from that spec still applies: this must only ever delete from
258
+ `jobs.job_run_logs`. There's an existing SQLite test for this
259
+ (`tests/test_postgres_pruner.py`) — port/duplicate it for the Postgres path once this
260
+ domain works, don't skip it.
261
+
262
+ ### 4.4 Domain: `queue`
263
+
264
+ Methods (largest non-catalog domain):
265
+
266
+ ```text
267
+ get_shadow_items, upsert_shadow_item, remove_shadow_items, update_shadow_position,
268
+ update_shadow_estimated_start, get_last_pay_uid, get_shadow_position_after,
269
+ get_pay_items, get_request_id_for_uid (_queue.py)
270
+ save_spend_request, get_spend_request, mark_spend_refunded, add_queue_history,
271
+ get_user_queue_history (_queue.py)
272
+ create_saved_playlist, get_saved_playlist, get_saved_playlists, list_saved_playlists,
273
+ update_saved_playlist, delete_saved_playlist, save_playlist_items,
274
+ get_saved_playlist_items, replace_playlist_items, append_playlist_item,
275
+ append_playlist_items, rotate_playlist_item_to_bottom, get_most_recent_playlist,
276
+ get_playlist_by_name, get_playlist_by_name_any, get_promo_pools,
277
+ get_promo_pool_items (_playlists.py)
278
+ create_playlist_schedule, get_playlist_schedule, list_playlist_schedules,
279
+ update_playlist_schedule, delete_playlist_schedule, get_schedules, get_schedule,
280
+ create_schedule, update_schedule, delete_schedule, mark_schedule_fired,
281
+ get_active_schedule, set_active_schedule, clear_active_schedule,
282
+ disable_active_lock, is_event_lock_active (_playlists.py)
283
+ record_play_completion, unrecord_play_completion, clear_play_state,
284
+ get_active_hidden_media_ids, get_reserved_media_ids, get_active_blackout_tokens,
285
+ is_media_restricted, get_played_at_for_tokens, get_promo_pool_media_ids,
286
+ purge_promo_completions, get_recently_played_completions (_queue_db.py)
287
+ upsert_blackout, prune_expired_blackouts, is_blackout,
288
+ count_active_blackouts, list_active_blackouts (_blackouts.py)
289
+ ```
290
+
291
+ **Known SQLite-isms — this is the highest-risk domain (real money via `spend_requests`,
292
+ live playback scheduling via `playlist_schedules`):**
293
+ - `INSERT OR REPLACE INTO queue_shadow (...)` (`_queue.py::upsert_shadow_item`) → needs
294
+ `ON CONFLICT (uid) DO UPDATE SET <every column> = EXCLUDED.<column>`. Write out every
295
+ column explicitly; don't try to be clever/generic here, get it right by hand and test it.
296
+ - `INSERT OR REPLACE INTO active_schedule (...)` (`_playlists.py::set_active_schedule`) →
297
+ same pattern, conflict target is `(id)` (it's always id=1, a singleton row) —
298
+ `ON CONFLICT (id) DO UPDATE SET ...`.
299
+ - `INSERT OR REPLACE INTO playlist_item_played (...)` (`_queue_db.py::record_play_completion`)
300
+ → conflict target `(playlist_id, position)`.
301
+ - `INSERT OR IGNORE INTO spend_requests (...)` (`_queue.py`) → `ON CONFLICT DO NOTHING`.
302
+ - `cursor.lastrowid` in `_playlists.py` (playlist creation, schedule creation) →
303
+ `RETURNING id`.
304
+ - Pervasive `datetime('now')` / `datetime(col, ...)` arithmetic in `_playlists.py`'s
305
+ schedule-firing queries — **read the existing code comment above
306
+ `get_due_schedules`-equivalent logic carefully, it explicitly warns**:
307
+ > `# NOTE: fire_at must be wrapped in datetime() on BOTH sides. fire_at is ... A bare
308
+ > 'fire_at > datetime('now')' is a [bug]`
309
+ The Postgres equivalent needs the same care but is actually **simpler** since `fire_at` is
310
+ a native `timestamptz` column — comparisons like `fire_at > now()` just work without any
311
+ wrapping. But the *dynamic interval arithmetic* needs an explicit cast:
312
+ ```sql
313
+ -- SQLite:
314
+ datetime(fire_at, '-' || pre_fire_lock_minutes || ' minutes') <= datetime('now')
315
+ -- Postgres:
316
+ (fire_at - (pre_fire_lock_minutes::text || ' minutes')::interval) <= now()
317
+ ```
318
+ There are at least 3 near-identical copies of this predicate in `_playlists.py`
319
+ (`get_due_schedules`, `claim_due_schedule`-ish methods — check current method names).
320
+ **Write one test per schedule-firing method with a schedule due in 1 minute and one due in
321
+ 1 hour, confirm only the due one is returned, before trusting this.**
322
+ - `record_play_completion` in `_queue_db.py` also does `datetime('now', ?)` for the
323
+ recently-played window — same interval-cast fix as jobs domain.
324
+ - `is_media_restricted`, `get_reserved_media_ids`, `resolve_friendly_tokens` — these were
325
+ added in the *SQLite partitioned* cross-domain-decoupling work (Sortie 3 of the SQLite
326
+ sprint, not the Postgres one) to work around SQLite's inability to `JOIN` across separate
327
+ database files. **In Postgres, catalog/queue/jobs/users are schemas in the *same*
328
+ database** — a real cross-schema `JOIN` is possible again (grant already covers it). You
329
+ have a choice here:
330
+ - (a) Keep the same Python-side decoupling pattern (`Database._get_reserved_tokens()` etc.
331
+ in `__init__.py`) for consistency with the SQLite code path and lower risk, or
332
+ - (b) Simplify to a single cross-schema SQL query now that it's actually possible.
333
+ **Recommendation: keep (a) for this port.** Don't do (b) as part of this migration; it's
334
+ a separate, lower-priority cleanup that touches both backends' facade methods and isn't
335
+ required for correctness. Flag it as a follow-up instead.
336
+ - **Important correctness note**: the SQLite path's `get_reserved_media_ids()` returns raw
337
+ `media_id` values that are often manifest URLs, not `friendly_token`s, and there is a
338
+ `catalog.resolve_friendly_tokens()` helper that maps them back (this was itself a bug fix
339
+ made earlier in this same session for the SQLite partitioned path — see CHANGELOG
340
+ v0.48.1 entry). **The Postgres queue domain's `get_reserved_media_ids()` must have the
341
+ same behavior** (return raw `media_id`, let the catalog domain resolve it) — don't
342
+ "fix" this differently for Postgres or you'll reintroduce the exact bug that was just
343
+ patched for SQLite.
344
+
345
+ ### 4.5 Domain: `catalog` (largest, hardest, do last)
346
+
347
+ Methods:
348
+
349
+ ```text
350
+ get_item, get_item_admin, resolve_media, delete_catalog_item, get_catalog_brief,
351
+ get_item_facets, get_categories, get_tags, upsert_category, upsert_tag,
352
+ set_catalog_categories, set_catalog_tags, add_catalog_tag, remove_catalog_tag,
353
+ insert_catalog, update_catalog, update_cover_art, set_imdb_tt, get_item_by_imdb_tt,
354
+ delete_stale_catalog_items, find_catalog_by_title, start_sync_log, finish_sync_log,
355
+ get_sync_logs, log_item_edit, get_item_edit_history, get_items_by_tokens,
356
+ get_hidden_category_and_tag_tokens, resolve_friendly_tokens (_catalog.py)
357
+ get_people, get_studios, upsert_person, upsert_studio,
358
+ set_catalog_people, set_catalog_studios (_people.py)
359
+ get_enrichment_state, update_enrichment_state, get_enrichment_candidates,
360
+ get_identify_coverage (_enrichment.py)
361
+ get_motd_override, get_motd_overrides_for_week, upsert_motd_override,
362
+ delete_motd_override (_motd.py)
363
+ ```
364
+ Plus **`browse`, `browse_count`, `search`, `search_count`** — these are NOT in the domain
365
+ map because they're already overridden directly on the `Database` facade in `__init__.py`
366
+ (cross-domain orchestration between `catalog` and `queue`). The facade methods call
367
+ `self.catalog.browse(..., is_partitioned=True, exclude_tokens=...)` etc. — the underlying
368
+ `_catalog.py::browse`/`browse_count` already accept `is_partitioned`/`exclude_tokens` params
369
+ (added for the SQLite partitioned path). **Whatever Postgres catalog implementation you
370
+ write needs to accept the same two params with the same semantics**, so the existing facade
371
+ orchestration code in `__init__.py` keeps working unchanged for both backends.
372
+
373
+ **Known SQLite-isms — this is where FTS5 must be replaced (Sortie 3's actual scope):**
374
+ - `search()`/`search_count()` currently do `FROM catalog_fts fts JOIN catalog c ON c.rowid =
375
+ fts.rowid WHERE catalog_fts MATCH ?` — **`catalog_fts` does not exist in Postgres and
376
+ `rowid` doesn't either.** This must become the hybrid `tsvector` + `pg_trgm` query
377
+ described in `docs/postgres-migration/SPEC-Sortie-3-fts5-to-tsvector.md` §4.3:
378
+ ```sql
379
+ WITH fts_matches AS (
380
+ SELECT c.friendly_token, c.title, ts_rank(c.search_vector, q) AS rank_score, 1 AS match_type
381
+ FROM catalog c, websearch_to_tsquery('english', $1) q
382
+ WHERE c.search_vector @@ q
383
+ ),
384
+ trgm_matches AS (
385
+ SELECT c.friendly_token, c.title, similarity(c.title, $1) AS rank_score, 2 AS match_type
386
+ FROM catalog c
387
+ WHERE similarity(c.title, $1) > 0.3
388
+ AND c.friendly_token NOT IN (SELECT friendly_token FROM fts_matches)
389
+ )
390
+ SELECT * FROM fts_matches UNION ALL SELECT * FROM trgm_matches ORDER BY match_type ASC, rank_score DESC;
391
+ ```
392
+ This is a genuinely different query shape (ranking, pagination, and the existing
393
+ `_facet_filter`/`_duration_range_filter`/exclusion-fragment helpers in `_catalog.py`
394
+ compose SQL via string concatenation onto a `WHERE` clause) — you cannot just swap the
395
+ `FROM`/`WHERE` and keep everything else; the whole `search()` method needs rewriting
396
+ around this two-CTE shape. Budget real time for this specifically; it's the single
397
+ hardest piece in the whole port. There is a fixture-corpus test plan in the SPEC (§6) —
398
+ build it before considering this domain "done."
399
+ - `insert_catalog`/`update_catalog` currently maintain the FTS5 index manually
400
+ (`INSERT INTO catalog_fts(rowid, friendly_token, ...) SELECT rowid, ... FROM catalog WHERE
401
+ friendly_token = ?`). **Delete this logic entirely for Postgres** — `search_vector` is a
402
+ `GENERATED ALWAYS AS (...) STORED` column, Postgres maintains it automatically on
403
+ INSERT/UPDATE. Do not try to port the manual FTS maintenance calls.
404
+ - `cursor.lastrowid` — none expected in this domain (catalog rows are keyed by
405
+ `friendly_token`, not autoincrement), but double-check `upsert_category`/`upsert_tag`/
406
+ `upsert_person`/`upsert_studio`/`upsert_month...` — those likely do need `RETURNING id`.
407
+ - `INSERT OR IGNORE INTO catalog_categories/catalog_tags/catalog_people/catalog_studios` →
408
+ `ON CONFLICT DO NOTHING` (these are all pure junction tables with composite PKs already).
409
+ - `datetime('now')` in `update_catalog`'s `updated_at`, `log_item_edit`'s `edited_at`
410
+ (check — may already default via `DEFAULT clock_timestamp()` in the Postgres schema, in
411
+ which case just omit the column from the INSERT rather than porting the SQLite literal).
412
+
413
+ ---
414
+
415
+ ## 5. Testing plan (do not skip this)
416
+
417
+ There is currently **zero** test coverage for any Postgres domain CRUD path. Existing
418
+ `tests/test_postgres_live.py` and `tests/test_postgres_config_and_engine.py` only check
419
+ schema/config, not application behavior, and they run against **the real chandra-1 database
420
+ that now holds real migrated production data** (spend history, watchlists, etc. from the
421
+ one ETL run).
422
+
423
+ ### 5.1 Do not write test data into the shared chandra-1 database carelessly
424
+
425
+ Any new domain-CRUD tests must either:
426
+ - Use uniquely-prefixed, easily-identifiable test rows (e.g. `username='__test_pguser__'`,
427
+ `friendly_token` prefixed `test_pg_`) and clean them up in a fixture teardown, **or**
428
+ - Stand up a disposable test database/schema set on chandra-1 (e.g. `webqueue_test`) so real
429
+ data is never touched. **This is the safer option and is recommended** — ask for/verify
430
+ whether creating a second database is acceptable, or reuse the existing `sql/
431
+ 001_initial_schema.sql` against a throwaway DB name.
432
+
433
+ ### 5.2 Suggested test structure
434
+
435
+ Mirror `tests/test_multi_db_connection.py` (the SQLite partitioned test file) — same test
436
+ names/scenarios, but against the new Pg domain classes. That file already has good coverage
437
+ of exactly the cross-domain behaviors that matter (decoupled watchlist, reserved-item
438
+ exclusion via manifest_url resolution, blackout/recently-played filtering, job log
439
+ independence). Port those tests 1:1 as a baseline, then add the FTS/trigram-specific search
440
+ tests from `SPEC-Sortie-3-fts5-to-tsvector.md` §6.
441
+
442
+ ### 5.3 Run order
443
+
444
+ 1. Apply the `users.otps.id` migration (§3.3).
445
+ 2. Fix `PostgresConfig.get_asyncpg_dsn()` (§4.1) and add a unit test for it.
446
+ 3. Port `users` domain, test it in isolation.
447
+ 4. Port `jobs` domain (includes the pruner — port `tests/test_postgres_pruner.py` for it),
448
+ test it.
449
+ 5. Port `queue` domain (highest risk — economy + scheduling), test it thoroughly, including
450
+ the `datetime(fire_at, ...)` interval-arithmetic edge cases explicitly.
451
+ 6. Port `catalog` domain including the FTS/trigram search rewrite, test it against a fixture
452
+ corpus per the SPEC.
453
+ 7. Wire `Database.__init__` backend branch (§4.1).
454
+ 8. Run the **entire** existing test suite (`uv run pytest -q`, currently 407 passing) to
455
+ confirm the SQLite path is completely unaffected — it must still be 100% green.
456
+ 9. Only then: rebuild the chandra-1 Podman image, install the Quadlet units, start the
457
+ service, and smoke-test it **standing alone, before touching nginx**.
458
+
459
+ ### 5.4 Smoke test after deployment (before nginx cutover)
460
+
461
+ From `chandra-1` itself (never through the public domain until this passes):
462
+ ```bash
463
+ curl -I http://127.0.0.1:2010/auth/login
464
+ curl -s http://127.0.0.1:2010/queue/next-schedule
465
+ ```
466
+ Then exercise an actual login + browse + search + watchlist round trip using a real browser
467
+ session pointed at `http://chandra-1.local:2010` directly (bypass nginx entirely for this
468
+ first pass).
469
+
470
+ ### 5.5 Re-run the ETL immediately before real cutover
471
+
472
+ The one-shot ETL was run once, earlier, from a since-superseded snapshot of the grindhouse
473
+ SQLite data. `grindhouse.local` has continued serving live production traffic since then
474
+ (spend requests, watchlist adds, job runs, etc.). **Before the actual maintenance-window
475
+ cutover**, re-run `kryten_webqueue/migrate_sqlite_to_pg.py` against a *fresh* stop-the-world
476
+ snapshot, per `docs/postgres-cutover.md`. Do not assume the existing chandra-1 data is
477
+ current — it is now stale by however many days elapse between this session and the actual
478
+ cutover.
479
+
480
+ ### 5.6 Rebuild and redeploy the image
481
+
482
+ ```bash
483
+ # on chandra-1, after pulling latest main (including this port + v0.48.1 hotfix)
484
+ cd /opt/Devel/Kryten-Ecosystem/kryten-webqueue
485
+ git fetch --all --tags
486
+ git checkout <new-tag> # whatever version this port ships as
487
+ sudo podman build -f Containerfile -t localhost/kryten-webqueue:latest .
488
+ ```
489
+ Then install the Quadlet units and start the service per
490
+ `docs/postgres-cutover.md` §"Step 5: Start Podman Quadlet on Chandra-1".
491
+
492
+ ---
493
+
494
+ ## 6. Safety reminders
495
+
496
+ - **Never point nginx at chandra-1 until the smoke tests in §5.4 pass directly against the
497
+ container.** Reverting nginx is the fast rollback if something goes wrong post-cutover —
498
+ see `docs/postgres-cutover.md` §3 for the exact rollback commands.
499
+ - **The economy/spend-request and playlist-scheduling logic in the `queue` domain is the
500
+ highest blast-radius code in this port.** Real money and live playback scheduling depend
501
+ on it. Do not rush the `datetime(fire_at, ...)` interval-arithmetic translation — test it
502
+ explicitly with schedules due in the near future vs. far future before trusting it.
503
+ - **Do not delete/rename the SQLite domain classes or the `_layout` branch in
504
+ `Database.__init__`.** `grindhouse.local` continues running SQLite in production during
505
+ and after this work; both code paths must coexist and both must stay green in the test
506
+ suite.
507
+ - If anything here is unclear or a method/table has changed since this was written, treat
508
+ `_DOMAIN_METHOD_MAP` in `kryten_webqueue/catalog/db/__init__.py` and the live chandra-1
509
+ schema (`\dt catalog.*`, `\dt queue.*`, etc. via `psql`) as the sources of truth, not this
510
+ document's method lists.