Browse Source

fix(api): write routes commit before they respond (issue #3320)

- get_db commits after yield, and with FastAPI's default request scope
  that ran after the response was sent: a read issued right after a
  write could 404 or see the old value, and a commit that failed was
  rolled back behind a 200 the client already had. Every
  Depends(get_db) now uses scope="function", so the commit runs after
  the handler and serialization but before the response goes out. All
  696 sites change together: FastAPI caches dependencies per scope,
  so mixing them would give a route and its auth dependency two
  sessions.
- No streaming body, background task or yield dependency uses the
  injected session after the handler returns.
- Tests: a guard walks every route and fails on a request-scoped
  get_db; two tests drive the ASGI app on a file database and check
  the row is committed when the response starts, and that a failed
  commit is not answered with 200.
maziggy 2 hours ago
parent
commit
d8375fab2f
63 changed files with 856 additions and 696 deletions
  1. 1 0
      CHANGELOG.md
  2. 1 1
      backend/app/api/routes/ams_history.py
  3. 2 2
      backend/app/api/routes/announcements.py
  4. 5 5
      backend/app/api/routes/api_keys.py
  5. 4 4
      backend/app/api/routes/archive_purge.py
  6. 66 66
      backend/app/api/routes/archives.py
  7. 30 23
      backend/app/api/routes/auth.py
  8. 8 8
      backend/app/api/routes/bulk.py
  9. 7 7
      backend/app/api/routes/camera.py
  10. 1 1
      backend/app/api/routes/camwall.py
  11. 16 16
      backend/app/api/routes/cloud.py
  12. 9 9
      backend/app/api/routes/connected_apps.py
  13. 10 10
      backend/app/api/routes/external_links.py
  14. 8 8
      backend/app/api/routes/filaments.py
  15. 20 20
      backend/app/api/routes/finance.py
  16. 4 4
      backend/app/api/routes/firmware.py
  17. 13 13
      backend/app/api/routes/github_backup.py
  18. 7 7
      backend/app/api/routes/groups.py
  19. 7 7
      backend/app/api/routes/ha_sensors.py
  20. 64 64
      backend/app/api/routes/inventory.py
  21. 7 7
      backend/app/api/routes/kprofiles.py
  22. 4 4
      backend/app/api/routes/labels.py
  23. 35 35
      backend/app/api/routes/library.py
  24. 5 5
      backend/app/api/routes/library_tags.py
  25. 8 8
      backend/app/api/routes/library_trash.py
  26. 7 7
      backend/app/api/routes/library_variants.py
  27. 9 9
      backend/app/api/routes/local_presets.py
  28. 7 7
      backend/app/api/routes/location_ha_sensors.py
  29. 14 14
      backend/app/api/routes/maintenance.py
  30. 4 4
      backend/app/api/routes/makerworld.py
  31. 9 9
      backend/app/api/routes/manyfold.py
  32. 1 1
      backend/app/api/routes/metrics.py
  33. 24 24
      backend/app/api/routes/mfa.py
  34. 4 4
      backend/app/api/routes/notification_templates.py
  35. 13 13
      backend/app/api/routes/notifications.py
  36. 1 1
      backend/app/api/routes/obico.py
  37. 6 6
      backend/app/api/routes/orca_cloud.py
  38. 7 7
      backend/app/api/routes/pending_uploads.py
  39. 8 8
      backend/app/api/routes/pipeline_runs.py
  40. 5 5
      backend/app/api/routes/print_log.py
  41. 21 21
      backend/app/api/routes/print_queue.py
  42. 5 5
      backend/app/api/routes/printer_locations.py
  43. 1 1
      backend/app/api/routes/printer_sensor_history.py
  44. 57 57
      backend/app/api/routes/printers.py
  45. 25 25
      backend/app/api/routes/projects.py
  46. 3 3
      backend/app/api/routes/scheduled_dryings.py
  47. 15 15
      backend/app/api/routes/settings.py
  48. 5 5
      backend/app/api/routes/slicer_pipelines.py
  49. 4 4
      backend/app/api/routes/slicer_presets.py
  50. 14 14
      backend/app/api/routes/smart_plugs.py
  51. 2 2
      backend/app/api/routes/sponsor_prompt.py
  52. 23 23
      backend/app/api/routes/spoolbuddy.py
  53. 11 11
      backend/app/api/routes/spoolman.py
  54. 30 30
      backend/app/api/routes/spoolman_inventory.py
  55. 2 2
      backend/app/api/routes/system.py
  56. 2 2
      backend/app/api/routes/updates.py
  57. 2 2
      backend/app/api/routes/user_notifications.py
  58. 8 8
      backend/app/api/routes/users.py
  59. 6 6
      backend/app/api/routes/virtual_printers.py
  60. 6 6
      backend/app/api/routes/webhook.py
  61. 1 1
      backend/app/core/auth.py
  62. 121 0
      backend/tests/integration/test_commit_before_response_3320.py
  63. 31 0
      backend/tests/unit/test_get_db_function_scope.py

+ 1 - 0
CHANGELOG.md

@@ -174,6 +174,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A write is saved before its response is sent (#3320, reported by @MorganMLGman)** — Many API routes left the final save to the end of the request, and that ran only after the response had gone out. A client reading straight after a successful write could miss it (a new project came back as 404, an edit still showed the old value), and a save that failed at that point was undone behind a success the client already had. Every route now saves before it answers, and a failed save is reported as an error.
 - **A failed or cancelled print's card says its cost is for one full print (#3318, reported by @ojimpo)** — After a print failed or was cancelled, the archive card showed the cost and grams of the whole file with no hint, as if that were what the run cost. The archive keeps one full print's figures on purpose (reprints are estimated and billed from them); the cost and grams now carry a tooltip saying so, pointing to the archive's Print Log for what the run used. With Spoolman, that run's cost is priced from the spools it was charged to.
 - **A print that fails or is cancelled early is charged only for the filament it used (#3317, reported by @ojimpo)** — Two errors added up to a failed print being charged to Spoolman as if it had finished, sometimes for more than the finished print would have used.
   - **The previous print's layer:** right after a print starts, the printer can send one update that still carries the layer the previous print ended on (seen on an H2S: layer 357 of 357 at 0 %). Bambuddy took it as this print's last layer, so a print that failed before its first layer was charged up to layer 357. That value is now ignored until the new print reports a layer of its own. It also no longer counts as a layer change, so it no longer adds a layer-timelapse frame at print start.

+ 1 - 1
backend/app/api/routes/ams_history.py

@@ -40,7 +40,7 @@ async def get_ams_history(
     printer_id: int,
     ams_id: int,
     hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
 ):
     """Get AMS sensor history for a specific printer and AMS unit."""

+ 2 - 2
backend/app/api/routes/announcements.py

@@ -39,7 +39,7 @@ async def _may_see(db: AsyncSession, user: User | None) -> bool:
 
 @router.get("")
 async def list_announcements(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = Depends(require_auth_if_enabled),
 ) -> dict:
     """Whether this user may see announcements, and the live ones newest first,
@@ -55,7 +55,7 @@ async def list_announcements(
 @router.post("/{public_id}/read", status_code=status.HTTP_204_NO_CONTENT)
 async def mark_announcement_read(
     public_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = Depends(require_auth_if_enabled),
 ) -> None:
     if not await _may_see(db, current_user):

+ 5 - 5
backend/app/api/routes/api_keys.py

@@ -28,7 +28,7 @@ router = APIRouter(prefix="/api-keys", tags=["api-keys"])
 
 @router.get("/", response_model=list[APIKeyResponse])
 async def list_api_keys(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
 ):
     """List all API keys (without full key values)."""
@@ -39,7 +39,7 @@ async def list_api_keys(
 @router.post("/", response_model=APIKeyCreateResponse)
 async def create_api_key(
     data: APIKeyCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_CREATE),
 ):
     """Create a new API key.
@@ -114,7 +114,7 @@ async def create_api_key(
 @router.get("/{key_id}", response_model=APIKeyResponse)
 async def get_api_key(
     key_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
 ):
     """Get an API key by ID."""
@@ -131,7 +131,7 @@ async def get_api_key(
 async def update_api_key(
     key_id: int,
     data: APIKeyUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_UPDATE),
 ):
     """Update an API key."""
@@ -220,7 +220,7 @@ async def drop_api_key_tokens(db: AsyncSession, key_ids) -> None:
 @router.delete("/{key_id}")
 async def delete_api_key(
     key_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_DELETE),
 ):
     """Delete (revoke) an API key."""

+ 4 - 4
backend/app/api/routes/archive_purge.py

@@ -48,7 +48,7 @@ async def preview_archive_purge(
             "hard-delete). #1390."
         ),
     ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
 ):
     """Count + size of archives eligible for purge. Read-only."""
@@ -59,7 +59,7 @@ async def preview_archive_purge(
 @router.post("/purge", response_model=ArchivePurgeResponse)
 async def execute_archive_purge(
     body: ArchivePurgeRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
 ):
     """Bulk-delete archives older than the threshold.
@@ -78,7 +78,7 @@ async def execute_archive_purge(
 
 @router.get("/purge/settings", response_model=ArchivePurgeSettings)
 async def get_archive_purge_settings(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
 ):
     cfg = await archive_purge_service.get_settings(db)
@@ -88,7 +88,7 @@ async def get_archive_purge_settings(
 @router.put("/purge/settings", response_model=ArchivePurgeSettings)
 async def update_archive_purge_settings(
     body: ArchivePurgeSettings,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
 ):
     if body.days < MIN_AUTO_PURGE_DAYS or body.days > MAX_AUTO_PURGE_DAYS:

+ 66 - 66
backend/app/api/routes/archives.py

@@ -433,7 +433,7 @@ async def list_archives(
     ),
     limit: int = 50,
     offset: int = 0,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -561,7 +561,7 @@ async def list_archives(
 
 @router.get("/last-per-printer", response_model=list[ArchiveResponse])
 async def list_last_archive_per_printer(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -620,7 +620,7 @@ async def list_last_archive_per_printer(
 
 @router.get("/no-3mf-warning")
 async def no_3mf_warning(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -733,7 +733,7 @@ async def list_archives_slim(
     created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
     limit: int = Query(default=10000, le=50000),
     offset: int = 0,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -850,7 +850,7 @@ async def search_archives(
     status: str | None = None,
     limit: int = 50,
     offset: int = 0,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -981,7 +981,7 @@ async def search_archives(
 
 @router.post("/search/rebuild-index")
 async def rebuild_search_index(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
     """Rebuild the full-text search index from existing archives.
@@ -1032,7 +1032,7 @@ async def analyze_failures(
     printer_id: int | None = None,
     project_id: int | None = None,
     created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -1074,7 +1074,7 @@ async def analyze_failures(
 @router.get("/compare")
 async def compare_archives(
     archive_ids: str = Query(..., description="Comma-separated archive IDs (2-5)"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -1146,7 +1146,7 @@ async def export_archives(
         None, max_length=64, description="IANA zone the dates are calendar days in; the server's when omitted"
     ),
     search: str | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     printer_scope: PrinterScope = RequestPrinterScope,
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
@@ -1230,7 +1230,7 @@ async def export_stats(
     printer_id: int | None = None,
     project_id: int | None = None,
     created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
     actor: User | ApiKeyActor | None = RequestActor,
@@ -1273,7 +1273,7 @@ async def get_archive_stats(
         None, max_length=64, description="IANA zone the dates are calendar days in; the server's when omitted"
     ),
     created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
     actor: User | ApiKeyActor | None = RequestActor,
@@ -1661,7 +1661,7 @@ async def _sum_snapshot_deltas(
 
 @router.get("/tags")
 async def get_all_tags(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -1704,7 +1704,7 @@ async def get_all_tags(
 async def rename_tag(
     tag_name: str,
     request: Request,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -1757,7 +1757,7 @@ async def rename_tag(
 @router.delete("/tags/{tag_name}")
 async def delete_tag(
     tag_name: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -1793,7 +1793,7 @@ async def delete_tag(
 @router.get("/{archive_id}", response_model=ArchiveResponse)
 async def get_archive(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -1823,7 +1823,7 @@ async def get_archive(
 @router.get("/{archive_id}/delete-impact")
 async def get_archive_delete_impact(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -1853,7 +1853,7 @@ async def get_archive_delete_impact(
 @router.get("/{archive_id}/runs", response_model=PrintLogResponse)
 async def list_archive_runs(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -1886,7 +1886,7 @@ async def list_archive_runs(
 async def find_similar_archives(
     archive_id: int,
     limit: int = 10,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -1920,7 +1920,7 @@ async def find_similar_archives(
 async def update_archive(
     archive_id: int,
     update_data: ArchiveUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_UPDATE_ALL,
@@ -2079,7 +2079,7 @@ async def update_archive(
 @router.post("/{archive_id}/favorite", response_model=ArchiveResponse)
 async def toggle_favorite(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_UPDATE_ALL,
@@ -2176,7 +2176,7 @@ async def _one_print_cost(db: AsyncSession, archive: PrintArchive, previous_gram
 
 @router.post("/recalculate-costs")
 async def recalculate_all_costs(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -2277,7 +2277,7 @@ async def recalculate_all_costs(
 @router.get("/{archive_id}/duplicates")
 async def get_archive_duplicates(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -2304,7 +2304,7 @@ async def get_archive_duplicates(
 
 @router.post("/backfill-hashes")
 async def backfill_content_hashes(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -2348,7 +2348,7 @@ async def delete_archive(
             "drop the row from statistics (#1343)."
         ),
     ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_DELETE_ALL,
@@ -2418,7 +2418,7 @@ async def delete_archive(
 async def download_archive(
     archive_id: int,
     inline: bool = False,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -2451,7 +2451,7 @@ async def download_archive(
 async def download_archive_with_filename(
     archive_id: int,
     filename: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -2479,7 +2479,7 @@ async def download_archive_with_filename(
 @router.post("/{archive_id}/slicer-token")
 async def create_archive_slicer_token(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -2508,7 +2508,7 @@ async def download_archive_for_slicer(
     archive_id: int,
     token: str,
     filename: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Download 3MF file using a slicer download token.
 
@@ -2542,7 +2542,7 @@ async def download_archive_for_slicer(
 @router.get("/{archive_id}/thumbnail")
 async def get_thumbnail(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.ARCHIVES_READ_ALL,
@@ -2782,7 +2782,7 @@ async def download_archive_media_with_token(
 @router.get("/{archive_id}/timelapse")
 async def get_timelapse(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.ARCHIVES_READ_ALL,
@@ -2828,7 +2828,7 @@ async def get_timelapse(
 @router.delete("/{archive_id}/timelapse")
 async def delete_timelapse(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_DELETE_ALL,
@@ -2883,7 +2883,7 @@ async def scan_timelapse(
 
     # Read the archive + printer in a short session and release the pooled DB
     # connection BEFORE the FTP scan/download below — a timelapse pull walks
-    # several directories and fetches a 100MB+ video, so holding Depends(get_db)
+    # several directories and fetches a 100MB+ video, so holding Depends(get_db, scope="function")
     # across it pinned one connection idle-in-transaction for minutes (#2572).
     # Scalar columns stay readable on the detached rows (expire_on_commit=False);
     # the attach at the end runs in its own fresh short session.
@@ -3265,7 +3265,7 @@ async def select_timelapse(
 async def upload_timelapse(
     archive_id: int,
     file: UploadFile = File(...),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -3291,7 +3291,7 @@ async def upload_timelapse(
 @router.get("/{archive_id}/timelapse/info")
 async def get_timelapse_info(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -3328,7 +3328,7 @@ async def get_timelapse_thumbnails(
     archive_id: int,
     count: int = Query(10, ge=1, le=30),
     width: int = Query(160, ge=80, le=320),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -3376,7 +3376,7 @@ async def process_timelapse(
     save_mode: str = Form("new"),
     output_filename: str = Form(None),
     audio: UploadFile = File(None),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -3518,7 +3518,7 @@ async def _timelapse_job_archive(archive_id: int, db: AsyncSession, printer_scop
 @router.get("/{archive_id}/timelapse/job")
 async def get_timelapse_job(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -3536,7 +3536,7 @@ async def get_timelapse_job(
 @router.delete("/{archive_id}/timelapse/job")
 async def cancel_timelapse_job(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -3561,7 +3561,7 @@ async def cancel_timelapse_job(
 async def upload_photo(
     archive_id: int,
     file: UploadFile = File(...),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_UPDATE_ALL,
@@ -3609,7 +3609,7 @@ async def upload_photo(
 async def get_photo(
     archive_id: int,
     filename: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.ARCHIVES_READ_ALL,
@@ -3661,7 +3661,7 @@ async def get_photo(
 async def delete_photo(
     archive_id: int,
     filename: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_DELETE_ALL,
@@ -3851,7 +3851,7 @@ async def confirm_outcome_page(
     request: Request,
     token: str,
     verdict: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Open a one-tap verdict link. Reads only — the verdict is recorded by POST.
 
@@ -3886,7 +3886,7 @@ async def confirm_outcome_page(
 async def confirm_outcome_by_token(
     token: str,
     verdict: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Record a print-outcome verdict via the capability token from a push notification.
 
@@ -3946,7 +3946,7 @@ async def get_qrcode(
     archive_id: int,
     request: Request,
     size: int = 200,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.ARCHIVES_READ_ALL,
@@ -4009,7 +4009,7 @@ async def get_qrcode(
 @router.get("/{archive_id}/capabilities")
 async def get_archive_capabilities(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -4237,7 +4237,7 @@ async def get_archive_capabilities(
 async def get_gcode(
     archive_id: int,
     plate: int | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -4295,7 +4295,7 @@ async def get_gcode(
 @router.get("/{archive_id}/plate-preview")
 async def get_plate_preview(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.ARCHIVES_READ_ALL,
@@ -4381,7 +4381,7 @@ async def upload_archive(
             "title is a stale name from whoever originally sliced it."
         ),
     ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -4444,7 +4444,7 @@ async def upload_archives_bulk(
             "by default, which keeps the embedded name."
         ),
     ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -4522,7 +4522,7 @@ async def upload_archives_bulk(
 @router.get("/{archive_id}/plates")
 async def get_archive_plates(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -4844,7 +4844,7 @@ async def get_archive_plates(
 async def get_plate_thumbnail(
     archive_id: int,
     plate_index: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.ARCHIVES_READ_ALL,
@@ -4931,7 +4931,7 @@ async def get_filament_requirements(
     plate_id: int | None = None,
     request_id: str | None = None,
     full_slots: bool = False,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -5102,7 +5102,7 @@ async def get_filament_requirements(
 async def slice_archive(
     archive_id: int,
     request: SliceRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
     printer_scope: PrinterScope = RequestPrinterScope,
     actor: User | ApiKeyActor | None = RequestActor,
@@ -5245,7 +5245,7 @@ async def reprint_archive(
 @router.get("/{archive_id}/project-page")
 async def get_project_page(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -5276,7 +5276,7 @@ async def get_project_page(
 async def update_project_page(
     archive_id: int,
     update_data: dict,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_UPDATE_ALL,
@@ -5311,7 +5311,7 @@ async def update_project_page(
 async def get_project_image(
     archive_id: int,
     image_path: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.ARCHIVES_READ_ALL,
@@ -5405,7 +5405,7 @@ def _resolve_source_3mf_path(archive: PrintArchive, source_filename: str) -> Pat
 async def upload_source_3mf(
     archive_id: int,
     file: UploadFile = File(...),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_UPDATE_ALL,
@@ -5457,7 +5457,7 @@ async def upload_source_3mf(
 @router.get("/{archive_id}/source")
 async def download_source_3mf(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -5492,7 +5492,7 @@ async def download_source_3mf(
 async def download_source_3mf_for_slicer(
     archive_id: int,
     filename: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -5523,7 +5523,7 @@ async def download_source_3mf_for_slicer(
 @router.post("/{archive_id}/source-slicer-token")
 async def create_source_slicer_token(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -5550,7 +5550,7 @@ async def download_source_3mf_for_slicer_with_token(
     archive_id: int,
     token: str,
     filename: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Download source 3MF using a slicer download token.
 
@@ -5586,7 +5586,7 @@ async def download_source_3mf_for_slicer_with_token(
 async def upload_source_3mf_by_name(
     file: UploadFile = File(...),
     print_name: str = Query(None, description="Match archive by print name"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -5678,7 +5678,7 @@ async def upload_source_3mf_by_name(
 @router.delete("/{archive_id}/source")
 async def delete_source_3mf(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_DELETE_ALL,
@@ -5716,7 +5716,7 @@ async def delete_source_3mf(
 async def upload_f3d(
     archive_id: int,
     file: UploadFile = File(...),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_UPDATE_ALL,
@@ -5768,7 +5768,7 @@ async def upload_f3d(
 @router.get("/{archive_id}/f3d")
 async def download_f3d(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -5802,7 +5802,7 @@ async def download_f3d(
 @router.delete("/{archive_id}/f3d")
 async def delete_f3d(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_DELETE_ALL,

+ 30 - 23
backend/app/api/routes/auth.py

@@ -261,7 +261,7 @@ async def set_setup_completed(db: AsyncSession, completed: bool) -> None:
 
 
 @router.post("/setup", response_model=SetupResponse)
-async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
+async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db, scope="function")):
     """First-time setup: enable/disable authentication and create admin user."""
     import logging
 
@@ -414,7 +414,7 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/status")
-async def get_auth_status(db: AsyncSession = Depends(get_db)):
+async def get_auth_status(db: AsyncSession = Depends(get_db, scope="function")):
     """Get authentication status (public endpoint)."""
     auth_enabled = await is_auth_enabled(db)
     setup_completed = await is_setup_completed(db)
@@ -426,7 +426,7 @@ async def get_auth_status(db: AsyncSession = Depends(get_db)):
 @router.post("/disable", response_model=dict)
 async def disable_auth(
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Disable authentication (admin only)."""
     import logging
@@ -467,7 +467,12 @@ async def disable_auth(
 
 
 @router.post("/login", response_model=LoginResponse)
-async def login(raw_request: Request, request: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
+async def login(
+    raw_request: Request,
+    request: LoginRequest,
+    response: Response,
+    db: AsyncSession = Depends(get_db, scope="function"),
+):
     """Login and get access token.
 
     Supports username or email-based login. Username lookup is case-insensitive.
@@ -718,7 +723,7 @@ async def mint_media_token(
 async def get_current_user_info(
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
     x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Get current user information.
 
@@ -852,7 +857,7 @@ async def test_smtp_connection(
     test_request: TestSMTPRequest,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Test SMTP connection using the form's settings, else the saved ones (admin only when auth enabled).
 
@@ -898,7 +903,7 @@ async def test_smtp_connection(
 @router.get("/smtp", response_model=SMTPSettings | None)
 async def get_smtp_config(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Get SMTP settings (admin only when auth enabled). Password is not returned."""
     smtp_settings = await get_smtp_settings(db)
@@ -913,7 +918,7 @@ async def save_smtp_config(
     smtp_settings: SMTPSettings,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Save SMTP settings (admin only when auth enabled).
 
@@ -960,7 +965,7 @@ async def save_smtp_config(
 @router.post("/advanced-auth/enable", response_model=dict)
 async def enable_advanced_auth(
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Enable advanced authentication (admin only).
 
@@ -1005,7 +1010,7 @@ async def enable_advanced_auth(
 @router.post("/advanced-auth/disable", response_model=dict)
 async def disable_advanced_auth(
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Disable advanced authentication (admin only)."""
     import logging
@@ -1037,7 +1042,7 @@ async def disable_advanced_auth(
 
 
 @router.get("/advanced-auth/status")
-async def get_advanced_auth_status(db: AsyncSession = Depends(get_db)):
+async def get_advanced_auth_status(db: AsyncSession = Depends(get_db, scope="function")):
     """Get advanced authentication status.
 
     Surfaces ``local_login_enabled`` and ``autologin_provider_id`` (#1589)
@@ -1128,7 +1133,7 @@ async def forgot_password(
     request: ForgotPasswordRequest,
     background_tasks: BackgroundTasks,
     raw_request: Request,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Request password reset via email (advanced auth only).
 
@@ -1269,7 +1274,9 @@ async def forgot_password(
 
 
 @router.post("/forgot-password/confirm", response_model=ForgotPasswordResponse)
-async def forgot_password_confirm(request: ForgotPasswordConfirmRequest, db: AsyncSession = Depends(get_db)):
+async def forgot_password_confirm(
+    request: ForgotPasswordConfirmRequest, db: AsyncSession = Depends(get_db, scope="function")
+):
     """Complete a password reset by supplying the token from the reset email.
 
     H-6: Atomically consumes the single-use token (DELETE…RETURNING) and sets
@@ -1327,7 +1334,7 @@ async def reset_user_password(
     request: ResetPasswordRequest,
     background_tasks: BackgroundTasks,
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Reset a user's password and send them an email (admin only, advanced auth only)."""
     # Reload user with groups for proper is_admin check
@@ -1566,7 +1573,7 @@ async def test_ldap(
     body: LDAPTestRequest | None = None,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Test the LDAP connection with the form's settings over the saved ones (admin only when auth enabled).
 
@@ -1604,7 +1611,7 @@ async def test_ldap(
 
 
 @router.get("/ldap/status")
-async def get_ldap_status(db: AsyncSession = Depends(get_db)):
+async def get_ldap_status(db: AsyncSession = Depends(get_db, scope="function")):
     """Get LDAP authentication status."""
     # Only fetch the minimum keys needed — never load secrets
     ldap_keys = ["ldap_enabled", "ldap_server_url"]
@@ -1629,7 +1636,7 @@ async def search_ldap_directory(
     q: str,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Search the LDAP directory for users matching `q`.
 
@@ -1703,7 +1710,7 @@ async def provision_ldap_user(
     payload: LDAPProvisionRequest,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Provision a BamBuddy user from an existing LDAP directory entry.
 
@@ -1817,7 +1824,7 @@ async def create_long_lived_camera_token(
     payload: dict,
     response: Response,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Mint a long-lived camera-stream token (#1108).
 
@@ -1882,7 +1889,7 @@ async def create_long_lived_camera_token(
 async def list_long_lived_tokens(
     user_id: int | None = None,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """List long-lived tokens.
 
@@ -1920,7 +1927,7 @@ async def list_long_lived_tokens(
 @router.get("/tokens/all", response_model=list[dict])
 async def list_all_long_lived_tokens(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Admin-only: every active long-lived token in the system, newest first.
     Used by the leak-triage view in admin settings.
@@ -1945,7 +1952,7 @@ async def list_all_long_lived_tokens(
 async def revoke_long_lived_token(
     token_id: int,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Revoke a long-lived token. Owners can revoke their own; admins any."""
     from backend.app.models.long_lived_token import LongLivedToken
@@ -1984,7 +1991,7 @@ async def revoke_long_lived_token(
 @router.get("/encryption-status", response_model=EncryptionStatusResponse)
 async def get_encryption_status(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> EncryptionStatusResponse:
     """Report at-rest encryption status for OIDC + TOTP secrets.
 

+ 8 - 8
backend/app/api/routes/bulk.py

@@ -62,7 +62,7 @@ async def get_printer_statuses(
     ids: str = Query(..., description="Comma-separated printer ids"),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """``GET /printers/{printer_id}/status`` for several printers. Unknown ids are left out."""
     wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
@@ -78,7 +78,7 @@ async def get_slot_presets_bulk(
     ids: str = Query(..., description="Comma-separated printer ids"),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """``GET /printers/{printer_id}/slot-presets`` for several printers, keyed by printer id."""
     wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
@@ -95,7 +95,7 @@ async def get_ams_labels_bulk(
     ids: str = Query(..., description="Comma-separated printer ids"),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """``GET /printers/{printer_id}/ams-labels`` for several printers, keyed by printer id."""
     wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
@@ -105,7 +105,7 @@ async def get_ams_labels_bulk(
 @router.get("/card-plugs", response_model=None, responses={200: {"model": dict[int, PrinterCardPlugs]}})
 async def get_smart_plugs_by_printers(
     ids: str = Query(..., description="Comma-separated printer ids"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
     can_edit: bool = CallerEditsPlugs,
@@ -139,7 +139,7 @@ async def get_smart_plugs_by_printers(
 @router.get("/ha-sensor-readings", response_model=dict[int, list[PrinterHASensorReading]])
 async def get_sensor_readings_by_printers(
     ids: str = Query(..., description="Comma-separated printer ids"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -160,7 +160,7 @@ async def get_sensor_readings_by_printers(
 @router.get("/firmware-updates", response_model=list[FirmwareUpdateInfo])
 async def check_printers_firmware(
     ids: str = Query(..., description="Comma-separated printer ids"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -186,7 +186,7 @@ async def check_printers_firmware(
 async def list_queue_by_printers(
     ids: str = Query(..., description="Comma-separated printer ids"),
     status: str | None = Query(None, description="Filter by status; several may be given comma-separated"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
@@ -252,7 +252,7 @@ async def list_queue_by_printers(
 @router.get("/archive-folders", response_model=dict[int, list[FolderResponse]])
 async def get_folders_by_archives(
     ids: str = Query(..., description="Comma-separated archive ids"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,

+ 7 - 7
backend/app/api/routes/camera.py

@@ -1239,7 +1239,7 @@ async def camera_stream(
     # Fetch the printer in a short-lived session so the pooled DB connection is
     # released BEFORE we start streaming. A live MJPEG stream runs for as long
     # as the browser tab stays open (potentially hours); holding the
-    # Depends(get_db) session across it pinned one pooled connection per open
+    # Depends(get_db, scope="function") session across it pinned one pooled connection per open
     # camera tab per printer — a top contributor to pool exhaustion on large
     # farms (issue #2572). expire_on_commit=False keeps the printer's already-
     # loaded columns readable after the session closes, and everything below
@@ -1583,7 +1583,7 @@ async def camera_snapshot(
     """
     # Fetch the printer in a short-lived session and release the pooled DB
     # connection BEFORE the camera capture below (up to 15s, longer under a
-    # saturated FTP/camera pool). Holding a Depends(get_db) session across the
+    # saturated FTP/camera pool). Holding a Depends(get_db, scope="function") session across the
     # grab pinned one connection per snapshot — and the cam wall polls this
     # per tile every 8s — so overlapping captures could pile up connections on
     # a large farm (issue #2572, sibling of the camera_stream fix). Everything
@@ -2080,7 +2080,7 @@ async def calibrate_plate_detection(
 async def get_plate_detection_status(
     printer_id: int,
     plate_type: str | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Check plate detection status for a printer and plate type.
@@ -2124,7 +2124,7 @@ async def get_plate_detection_status(
 @router.get("/{printer_id}/camera/plate-detection/references")
 async def get_plate_references(
     printer_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Get all calibration references for a printer with metadata.
@@ -2158,7 +2158,7 @@ async def get_plate_references(
 async def get_reference_thumbnail(
     printer_id: int,
     index: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get thumbnail image for a calibration reference.
@@ -2189,7 +2189,7 @@ async def update_reference_label(
     printer_id: int,
     index: int,
     label: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
 ):
     """Update the label for a calibration reference."""
@@ -2214,7 +2214,7 @@ async def update_reference_label(
 async def delete_reference(
     printer_id: int,
     index: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
 ):
     """Delete a specific calibration reference."""

+ 1 - 1
backend/app/api/routes/camwall.py

@@ -36,7 +36,7 @@ router = APIRouter(prefix="/camwall", tags=["camwall"])
 @router.get("/printers")
 async def list_camwall_printers(
     printer_scope: PrinterScope = RequireCamWallTokenIfAuthEnabled,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> list[dict]:
     """Every printer the token's owner may see (#1727), plus the handful of
     status fields a Cam Wall tile draws.

+ 16 - 16
backend/app/api/routes/cloud.py

@@ -76,7 +76,7 @@ async def _cloud_api_key_gate(
     request: Request,
     credentials: HTTPAuthorizationCredentials | None = Depends(security),
     x_api_key: str | None = Header(default=None, alias="X-API-Key"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> None:
     """Router-level dependency: enforce API-key cloud-access fences (#1182).
 
@@ -148,7 +148,7 @@ def cloud_caller(*permissions: Permission):
 async def resolve_api_key_cloud_owner(
     credentials: HTTPAuthorizationCredentials | None = Depends(security),
     x_api_key: str | None = Header(default=None, alias="X-API-Key"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> User | None:
     """Route-level dep for non-/cloud/* endpoints that need to read the
     caller's stored Bambu Cloud token (e.g. the slice path resolving cloud
@@ -384,7 +384,7 @@ async def build_authenticated_cloud(db: AsyncSession, user: User | None) -> Bamb
 
 @router.get("/status", response_model=CloudAuthStatus)
 async def get_auth_status(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
     """Get current cloud authentication status.
@@ -442,7 +442,7 @@ async def get_auth_status(
 @router.post("/login", response_model=CloudLoginResponse)
 async def login(
     request: CloudLoginRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
     """
@@ -484,7 +484,7 @@ async def login(
 @router.post("/verify", response_model=CloudLoginResponse)
 async def verify_code(
     request: CloudVerifyRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
     """
@@ -531,7 +531,7 @@ async def verify_code(
 @router.post("/token", response_model=CloudAuthStatus)
 async def set_token(
     request: CloudTokenRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
     """
@@ -569,7 +569,7 @@ async def set_token(
 
 @router.post("/logout")
 async def logout(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
     """Log out of Bambu Cloud."""
@@ -580,7 +580,7 @@ async def logout(
 @router.get("/settings", response_model=SlicerSettingsResponse)
 async def get_slicer_settings(
     version: str = _SLICER_API_VERSION,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
     """
@@ -653,7 +653,7 @@ async def get_slicer_settings(
 @router.get("/settings/{setting_id}")
 async def get_setting_detail(
     setting_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
     """
@@ -682,7 +682,7 @@ async def get_setting_detail(
 @router.get("/filaments", response_model=list[SlicerSetting])
 async def get_filament_presets(
     version: str = _SLICER_API_VERSION,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
 ):
     """
@@ -967,7 +967,7 @@ _filament_id_to_setting_id = filament_id_to_setting_id
 @router.post("/filament-info")
 async def get_filament_info(
     setting_ids: list[str] = Body(...),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
 ):
     """
@@ -1035,7 +1035,7 @@ async def get_filament_info(
 
 @router.get("/devices", response_model=list[CloudDevice])
 async def get_devices(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.PRINTERS_READ),
 ):
     """
@@ -1074,7 +1074,7 @@ async def get_devices(
 
 @router.get("/firmware-updates", response_model=FirmwareUpdatesResponse)
 async def get_firmware_updates(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.FIRMWARE_READ),
 ):
     """
@@ -1154,7 +1154,7 @@ async def get_firmware_updates(
 @router.post("/settings")
 async def create_setting(
     request: SlicerSettingCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
     """
@@ -1193,7 +1193,7 @@ async def create_setting(
 async def update_setting(
     setting_id: str,
     request: SlicerSettingUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
     """
@@ -1226,7 +1226,7 @@ async def update_setting(
 @router.delete("/settings/{setting_id}", response_model=SlicerSettingDeleteResponse)
 async def delete_setting(
     setting_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
     """

+ 9 - 9
backend/app/api/routes/connected_apps.py

@@ -121,7 +121,7 @@ async def _get_app_or_404(db: AsyncSession, app_id: int) -> ConnectedApp:
 
 @router.get("/apps", response_model=list[ConnectedAppResponse])
 async def list_connected_apps(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     result = await db.execute(select(ConnectedApp).order_by(ConnectedApp.created_at.desc()))
@@ -131,7 +131,7 @@ async def list_connected_apps(
 @router.post("/apps", response_model=ConnectedAppSecretResponse)
 async def create_connected_app(
     data: ConnectedAppCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
@@ -160,7 +160,7 @@ async def create_connected_app(
 async def update_connected_app(
     app_id: int,
     data: ConnectedAppUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
@@ -179,7 +179,7 @@ async def update_connected_app(
 @router.post("/apps/{app_id}/rotate-secret", response_model=ConnectedAppSecretResponse)
 async def rotate_connected_app_secret(
     app_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
@@ -196,7 +196,7 @@ async def rotate_connected_app_secret(
 @router.delete("/apps/{app_id}")
 async def delete_connected_app(
     app_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
@@ -222,7 +222,7 @@ async def delete_connected_app(
 
 async def require_signed_in_user(
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> User:
     """The user behind a Bambuddy login token. API keys are refused.
 
@@ -249,7 +249,7 @@ async def _app_for_authorize(db: AsyncSession, client_id: str, redirect_uri: str
 async def authorize_info(
     client_id: str = Query(..., max_length=64),
     redirect_uri: str = Query(..., max_length=500),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     user: User = Depends(require_signed_in_user),
 ):
     """What the consent screen shows. Validates the request before the page acts on it."""
@@ -265,7 +265,7 @@ async def authorize_info(
 @router.post("/authorize", response_model=ConnectAuthorizeResponse)
 async def authorize(
     data: ConnectAuthorizeRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     user: User = Depends(require_signed_in_user),
 ):
     """Issue a code for the signed-in user and record their consent."""
@@ -328,7 +328,7 @@ def _token_error(status_code: int, error: str) -> HTTPException:
 async def exchange_code(
     request: Request,
     data: ConnectTokenRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Swap a code for the user's identity and permissions.
 

+ 10 - 10
backend/app/api/routes/external_links.py

@@ -63,7 +63,7 @@ def _same_place(url: str) -> tuple[str, str, int | None, str]:
 
 @router.get("/", response_model=list[ExternalLinkResponse])
 async def list_external_links(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
 ):
     """List all external links ordered by sort_order."""
@@ -85,7 +85,7 @@ async def list_external_links(
 @router.put("/badge")
 async def set_badge(
     data: ExternalLinkBadgeSet,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: ScopedCaller = Depends(require_notification_send()),
 ):
     """Set the count on the sidebar links pointing at an app, e.g. Bambuddy
@@ -120,7 +120,7 @@ async def set_badge(
 @router.post("/", response_model=ExternalLinkResponse)
 async def create_external_link(
     link_data: ExternalLinkCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_CREATE),
 ):
     """Create a new external link."""
@@ -148,7 +148,7 @@ async def create_external_link(
 @router.get("/{link_id}", response_model=ExternalLinkResponse)
 async def get_external_link(
     link_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
 ):
     """Get a specific external link."""
@@ -165,7 +165,7 @@ async def get_external_link(
 async def update_external_link(
     link_id: int,
     update_data: ExternalLinkUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
 ):
     """Update an external link."""
@@ -191,7 +191,7 @@ async def update_external_link(
 @router.delete("/{link_id}")
 async def delete_external_link(
     link_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_DELETE),
 ):
     """Delete an external link."""
@@ -213,7 +213,7 @@ async def delete_external_link(
 @router.put("/reorder", response_model=list[ExternalLinkResponse])
 async def reorder_external_links(
     reorder_data: ExternalLinkReorder,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
 ):
     """Update the sort order of external links."""
@@ -239,7 +239,7 @@ async def reorder_external_links(
 async def upload_icon(
     link_id: int,
     file: UploadFile = File(...),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
 ):
     """Upload a custom icon for an external link."""
@@ -288,7 +288,7 @@ async def upload_icon(
 @router.delete("/{link_id}/icon", response_model=ExternalLinkResponse)
 async def delete_icon(
     link_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
 ):
     """Delete the custom icon for an external link."""
@@ -313,7 +313,7 @@ async def delete_icon(
 @router.get("/{link_id}/icon")
 async def get_icon(
     link_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_media_token_permission(Permission.EXTERNAL_LINKS_READ)),
 ):
     """Get the custom icon for an external link.

+ 8 - 8
backend/app/api/routes/filaments.py

@@ -19,7 +19,7 @@ router = APIRouter(prefix="/filament-catalog", tags=["filament-catalog"])
 
 @router.get("/", response_model=list[FilamentResponse])
 async def list_filaments(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
     """List all filaments."""
@@ -30,7 +30,7 @@ async def list_filaments(
 @router.post("/", response_model=FilamentResponse)
 async def create_filament(
     filament_data: FilamentCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),
 ):
     """Create a new filament entry."""
@@ -44,7 +44,7 @@ async def create_filament(
 @router.get("/{filament_id}", response_model=FilamentResponse)
 async def get_filament(
     filament_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
     """Get a specific filament."""
@@ -59,7 +59,7 @@ async def get_filament(
 async def update_filament(
     filament_id: int,
     filament_data: FilamentUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
 ):
     """Update a filament."""
@@ -79,7 +79,7 @@ async def update_filament(
 @router.delete("/{filament_id}")
 async def delete_filament(
     filament_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_DELETE),
 ):
     """Delete a filament."""
@@ -97,7 +97,7 @@ async def delete_filament(
 async def calculate_cost(
     filament_id: int,
     weight_grams: float,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
     """Calculate the cost for a given weight of filament."""
@@ -120,7 +120,7 @@ async def calculate_cost(
 @router.get("/by-type/{filament_type}", response_model=list[FilamentResponse])
 async def get_filaments_by_type(
     filament_type: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
     """Get all filaments of a specific type."""
@@ -130,7 +130,7 @@ async def get_filaments_by_type(
 
 @router.post("/seed-defaults")
 async def seed_default_filaments(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),
 ):
     """Seed the database with common filament types."""

+ 20 - 20
backend/app/api/routes/finance.py

@@ -415,7 +415,7 @@ async def _create_wallet_adjustment(
 
 @router.get("/me/balance", response_model=WalletBalanceResponse)
 async def get_my_balance(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_OWN),
 ):
     """Return the current user's wallet balance."""
@@ -427,7 +427,7 @@ async def get_my_balance(
 async def get_my_transactions(
     limit: int = Query(50, ge=1, le=500),
     offset: int = Query(0, ge=0),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_OWN),
 ):
     """Return wallet ledger entries for the current user."""
@@ -468,7 +468,7 @@ async def get_all_transactions(
     limit: int = Query(50, ge=1, le=500),
     offset: int = Query(0, ge=0),
     user_id: int | None = Query(None, description="Optional filter by user id"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
 ):
     """Return wallet ledger entries across users for admin finance view."""
@@ -508,7 +508,7 @@ async def _rebuild_wallet_ledger_for_user(db: AsyncSession, user_id: int | None)
 @router.delete("/transactions/{transaction_id}")
 async def delete_transaction(
     transaction_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
     """Delete a wallet transaction and rebuild the user's ledger to keep balances consistent."""
@@ -542,7 +542,7 @@ async def delete_transaction(
 async def edit_transaction(
     transaction_id: int,
     request: TransactionEditRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
     """Edit a wallet transaction (user_id, cost_center_id, amount, description) and rebuild ledger."""
@@ -593,7 +593,7 @@ async def edit_transaction(
 @router.post("/transactions/manual", response_model=WalletTransactionResponse)
 async def create_manual_print(
     request: ManualPrintRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
     """Create a manual print charge transaction (for admin purposes)."""
@@ -636,7 +636,7 @@ async def create_manual_print(
 
 @router.get("/cost-centers/mine", response_model=list[CostCenterSummaryResponse])
 async def get_my_cost_centers(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = Depends(require_auth_if_enabled),
     actor: User | ApiKeyActor | None = RequestActor,
 ):
@@ -695,7 +695,7 @@ async def get_my_cost_centers(
 @router.get("/users/{user_id}/balance", response_model=WalletBalanceResponse)
 async def get_user_balance(
     user_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
 ):
     """Return a specific user's wallet balance."""
@@ -709,7 +709,7 @@ async def get_user_transactions(
     user_id: int,
     limit: int = Query(50, ge=1, le=500),
     offset: int = Query(0, ge=0),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
 ):
     """Return wallet ledger entries for a specific user."""
@@ -730,7 +730,7 @@ async def get_user_transactions(
 async def deposit_user_balance(
     user_id: int,
     body: WalletAdjustmentRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
     """Add funds to a user's wallet."""
@@ -751,7 +751,7 @@ async def deposit_user_balance(
 async def withdraw_user_balance(
     user_id: int,
     body: WalletAdjustmentRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
     """Withdraw funds from a user's wallet."""
@@ -770,7 +770,7 @@ async def withdraw_user_balance(
 
 @router.post("/rebuild-balance-ledger")
 async def rebuild_balance_ledger(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
     """Recompute balance_after for all wallet transactions.
@@ -795,7 +795,7 @@ async def rebuild_balance_ledger(
 @router.get("/cost-centers", response_model=list[CostCenterSummaryResponse])
 async def list_cost_centers(
     include_inactive: bool = Query(False),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = Depends(require_auth_if_enabled),
 ):
     """List all cost centers.
@@ -830,7 +830,7 @@ async def list_cost_centers(
 @router.post("/cost-centers", response_model=CostCenterSummaryResponse)
 async def create_cost_center(
     body: CostCenterCreateRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_CREATE),
 ):
     """Create a shared cost center."""
@@ -861,7 +861,7 @@ async def create_cost_center(
 @router.get("/cost-centers/{cost_center_id}", response_model=CostCenterDetailResponse)
 async def get_cost_center(
     cost_center_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = Depends(require_auth_if_enabled),
 ):
     """Get one cost center with its memberships."""
@@ -888,7 +888,7 @@ async def get_cost_center(
 async def update_cost_center(
     cost_center_id: int,
     body: CostCenterUpdateRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
     """Update name or active-state of a cost center."""
@@ -925,7 +925,7 @@ async def update_cost_center(
 async def update_cost_center_budgets(
     cost_center_id: int,
     body: CostCenterBudgetUpdateRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
     """Update budget values of a cost center."""
@@ -960,7 +960,7 @@ async def update_cost_center_budgets(
 async def upsert_cost_center_member(
     cost_center_id: int,
     body: CostCenterMemberRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
     """Assign or update a user's membership on a cost center."""
@@ -993,7 +993,7 @@ async def upsert_cost_center_member(
 @router.delete("/cost-centers/{cost_center_id}")
 async def delete_cost_center(
     cost_center_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
     """Delete a shared cost center."""
@@ -1040,7 +1040,7 @@ async def delete_cost_center(
 async def remove_cost_center_member(
     cost_center_id: int,
     user_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
 ):
     """Remove a user from a shared cost center."""

+ 4 - 4
backend/app/api/routes/firmware.py

@@ -109,7 +109,7 @@ def _not_checked(printer: Printer) -> FirmwareUpdateInfo:
 
 @router.get("/updates", response_model=FirmwareUpdatesResponse)
 async def check_firmware_updates(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -168,7 +168,7 @@ async def check_firmware_updates(
 @router.get("/updates/{printer_id}", response_model=FirmwareUpdateInfo)
 async def check_printer_firmware(
     printer_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
     """
@@ -276,7 +276,7 @@ class FirmwareUploadStartResponse(BaseModel):
 async def prepare_firmware_upload(
     printer_id: int,
     version: str | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
     """
@@ -299,7 +299,7 @@ async def prepare_firmware_upload(
 async def start_firmware_upload(
     printer_id: int,
     version: str | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FIRMWARE_UPDATE),
 ):
     """

+ 13 - 13
backend/app/api/routes/github_backup.py

@@ -129,7 +129,7 @@ async def _count_cloud_accounts(db: AsyncSession) -> tuple[int, int]:
 
 @router.get("/cloud-accounts", response_model=CloudAccountCounts)
 async def get_cloud_accounts(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """How many cloud accounts the Cloud Profiles category would collect from.
@@ -172,7 +172,7 @@ def _config_to_response(config: GitHubBackupConfig) -> dict:
 
 @router.get("/config", response_model=GitHubBackupConfigResponse | None)
 async def get_config(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Get the current GitHub backup configuration."""
@@ -188,7 +188,7 @@ async def get_config(
 @router.post("/config", response_model=GitHubBackupConfigResponse)
 async def save_config(
     config_data: GitHubBackupConfigCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Create or update GitHub backup configuration.
@@ -264,7 +264,7 @@ async def save_config(
 @router.patch("/config", response_model=GitHubBackupConfigResponse)
 async def update_config(
     update_data: GitHubBackupConfigUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Partially update GitHub backup configuration."""
@@ -325,7 +325,7 @@ async def update_config(
 
 @router.delete("/config")
 async def delete_config(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Delete the GitHub backup configuration and all logs."""
@@ -363,7 +363,7 @@ async def test_connection(
 
 @router.post("/test-stored", response_model=GitHubTestConnectionResponse)
 async def test_stored_connection(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Test GitHub connection using stored configuration."""
@@ -386,7 +386,7 @@ async def test_stored_connection(
 
 @router.post("/run", response_model=GitHubBackupTriggerResponse)
 async def trigger_backup(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Manually trigger a backup."""
@@ -406,7 +406,7 @@ async def trigger_backup(
 
 @router.get("/status", response_model=GitHubBackupStatus)
 async def get_status(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Get current backup status."""
@@ -439,7 +439,7 @@ async def get_status(
 @router.get("/commits", response_model=GitHubCommitListResponse)
 async def list_commits(
     limit: int = Query(default=20, ge=1, le=100),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
 ):
     """List recent backup commits so the user can pick one to restore from."""
@@ -456,7 +456,7 @@ async def list_commits(
 @router.get("/restore/preview", response_model=GitHubRestorePreview)
 async def preview_restore(
     ref: str = Query(default="HEAD", pattern=REF_PATTERN),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
 ):
     """Report which categories a given backup commit contains."""
@@ -475,7 +475,7 @@ async def preview_restore(
 @router.post("/restore", response_model=GitHubRestoreResponse)
 async def restore_backup(
     request: GitHubRestoreRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
 ):
     """Restore selected categories from one backup commit.
@@ -531,7 +531,7 @@ async def restore_backup(
 async def get_logs(
     limit: int = Query(default=50, ge=1, le=200),
     offset: int = Query(default=0, ge=0),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Get backup logs."""
@@ -569,7 +569,7 @@ async def get_logs(
 @router.delete("/logs")
 async def clear_logs(
     keep_last: int = Query(default=10, ge=0, le=100, description="Number of recent logs to keep"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Clear backup logs, optionally keeping the most recent entries."""

+ 7 - 7
backend/app/api/routes/groups.py

@@ -181,7 +181,7 @@ async def list_permissions(
 @router.get("/", response_model=list[GroupResponse])
 async def list_groups(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """List all groups."""
     result = await db.execute(select(Group).options(selectinload(Group.users)).order_by(Group.name))
@@ -202,7 +202,7 @@ async def create_group(
     group_data: GroupCreate,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_CREATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Create a new group."""
     # Check if group name already exists
@@ -241,7 +241,7 @@ async def create_group(
 async def get_group(
     group_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Get a group by ID with user list. Read-only — gated on
     ``GROUPS_READ`` only."""
@@ -275,7 +275,7 @@ async def update_group(
     group_data: GroupUpdate,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Update a group."""
     result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
@@ -346,7 +346,7 @@ async def delete_group(
     group_id: int,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_DELETE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Delete a group (non-system groups only)."""
     result = await db.execute(select(Group).where(Group.id == group_id))
@@ -378,7 +378,7 @@ async def add_user_to_group(
     user_id: int,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Add a user to a group."""
     # Get group with users
@@ -417,7 +417,7 @@ async def remove_user_from_group(
     user_id: int,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Remove a user from a group."""
     # Get group with users

+ 7 - 7
backend/app/api/routes/ha_sensors.py

@@ -65,7 +65,7 @@ async def _load_sensor(db: AsyncSession, sensor_id: int, printer_scope: PrinterS
 @router.get("/", response_model=list[PrinterHASensorResponse])
 async def list_ha_sensors(
     printer_id: int | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _READ,
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -83,7 +83,7 @@ async def list_ha_sensors(
 @router.get("/entities", response_model=list[HADisplayEntity])
 async def list_bindable_entities(
     search: str | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _READ,
 ):
     """List the Home Assistant entities that can be bound to a printer."""
@@ -103,7 +103,7 @@ async def list_bindable_entities(
 @router.get("/by-printer/{printer_id}/readings", response_model=list[PrinterHASensorReading])
 async def get_printer_sensor_readings(
     printer_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
 ):
     """Live state of a printer's card-visible sensors.
@@ -151,7 +151,7 @@ def _reading(sensor: PrinterHASensor) -> PrinterHASensorReading:
 @router.post("/", response_model=PrinterHASensorResponse)
 async def create_ha_sensor(
     data: PrinterHASensorCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _CREATE,
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -187,7 +187,7 @@ async def create_ha_sensor(
 @router.get("/{sensor_id}", response_model=PrinterHASensorResponse)
 async def get_ha_sensor(
     sensor_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _READ,
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -198,7 +198,7 @@ async def get_ha_sensor(
 async def update_ha_sensor(
     sensor_id: int,
     data: PrinterHASensorUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _UPDATE,
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -243,7 +243,7 @@ async def update_ha_sensor(
 @router.delete("/{sensor_id}")
 async def delete_ha_sensor(
     sensor_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _DELETE,
     printer_scope: PrinterScope = RequestPrinterScope,
 ):

+ 64 - 64
backend/app/api/routes/inventory.py

@@ -516,7 +516,7 @@ class ColorByMaterialResult(BaseModel):
 
 @router.get("/catalog", response_model=list[CatalogEntryResponse])
 async def get_spool_catalog(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """Get all spool catalog entries."""
@@ -527,7 +527,7 @@ async def get_spool_catalog(
 @router.post("/catalog", response_model=CatalogEntryResponse)
 async def add_catalog_entry(
     entry: CatalogEntryCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Add a new spool catalog entry."""
@@ -542,7 +542,7 @@ async def add_catalog_entry(
 async def update_catalog_entry(
     entry_id: int,
     entry: CatalogEntryUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Update a spool catalog entry."""
@@ -560,7 +560,7 @@ async def update_catalog_entry(
 @router.delete("/catalog/{entry_id}")
 async def delete_catalog_entry(
     entry_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Delete a spool catalog entry."""
@@ -576,7 +576,7 @@ async def delete_catalog_entry(
 @router.post("/catalog/bulk-delete")
 async def bulk_delete_catalog_entries(
     data: BulkDeleteIdsRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Delete multiple spool catalog entries by ID."""
@@ -592,7 +592,7 @@ async def bulk_delete_catalog_entries(
 
 @router.post("/catalog/reset")
 async def reset_spool_catalog(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Reset spool catalog to defaults."""
@@ -697,7 +697,7 @@ def _location_to_response(location: Location, spool_count: int) -> LocationRespo
 
 @router.get("/locations", response_model=list[LocationResponse])
 async def list_locations(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """List all storage locations with spool counts."""
@@ -713,7 +713,7 @@ async def list_locations(
 @router.post("/locations", response_model=LocationResponse, status_code=201)
 async def create_location(
     data: LocationCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Create a storage location."""
@@ -737,7 +737,7 @@ async def create_location(
 async def update_location(
     location_id: int,
     data: LocationUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Update a storage location (rename propagates to assigned spools)."""
@@ -793,7 +793,7 @@ async def update_location(
 @router.delete("/locations/{location_id}")
 async def delete_location(
     location_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Delete a storage location when no spools are assigned."""
@@ -893,7 +893,7 @@ async def _prune_orphaned_spoolman_supplier_rows(db: AsyncSession) -> int:
 
 @router.get("/suppliers", response_model=list[SupplierResponse])
 async def list_suppliers(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """List all suppliers with their spool-usage counts."""
@@ -913,7 +913,7 @@ async def list_suppliers(
 @router.post("/suppliers", response_model=SupplierResponse, status_code=201)
 async def create_supplier(
     data: SupplierCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Create a supplier (mirrors create_location, duplicate name included)."""
@@ -936,7 +936,7 @@ async def create_supplier(
 async def update_supplier(
     supplier_id: int,
     data: SupplierUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Update a supplier."""
@@ -966,7 +966,7 @@ async def update_supplier(
 @router.delete("/suppliers/{supplier_id}")
 async def delete_supplier(
     supplier_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Delete a supplier when no spools reference it (mirrors delete_location)."""
@@ -993,7 +993,7 @@ async def delete_supplier(
 
 @router.get("/colors", response_model=list[ColorEntryResponse])
 async def get_color_catalog(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """Get all color catalog entries."""
@@ -1007,7 +1007,7 @@ async def get_color_catalog(
 
 @router.get("/colors/map")
 async def get_color_name_map(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_auth_if_enabled),
 ):
     """Compact {hex: name} map for frontend color-name resolution.
@@ -1108,7 +1108,7 @@ async def get_color_name_map(
 @router.post("/colors", response_model=ColorEntryResponse)
 async def add_color_entry(
     entry: ColorEntryCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Add a new color catalog entry."""
@@ -1131,7 +1131,7 @@ async def add_color_entry(
 async def update_color_entry(
     entry_id: int,
     entry: ColorEntryUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Update a color catalog entry."""
@@ -1153,7 +1153,7 @@ async def update_color_entry(
 @router.delete("/colors/{entry_id}")
 async def delete_color_entry(
     entry_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Delete a color catalog entry."""
@@ -1169,7 +1169,7 @@ async def delete_color_entry(
 @router.post("/colors/bulk-delete")
 async def bulk_delete_color_entries(
     data: BulkDeleteIdsRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Delete multiple color catalog entries by ID."""
@@ -1185,7 +1185,7 @@ async def bulk_delete_color_entries(
 
 @router.post("/colors/reset")
 async def reset_color_catalog(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Reset color catalog to defaults."""
@@ -1211,7 +1211,7 @@ async def lookup_color(
     manufacturer: str,
     color_name: str,
     material: str | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """Look up a color by manufacturer and color name."""
@@ -1233,7 +1233,7 @@ async def lookup_color(
 async def get_color_by_material(
     hex: str,
     material: str | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_auth_if_enabled),
 ):
     """Disambiguated hex→name lookup that respects material context.
@@ -1300,7 +1300,7 @@ async def get_color_by_material(
 async def search_colors(
     manufacturer: str | None = None,
     material: str | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """Search colors by manufacturer and/or material."""
@@ -1432,7 +1432,7 @@ async def sync_from_filamentcolors(
 @router.get("/spools", response_model=list[SpoolResponse])
 async def list_spools(
     include_archived: bool = False,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """List all spools, excluding archived by default."""
@@ -1451,7 +1451,7 @@ async def list_spools(
 
 @router.get("/spools/export")
 async def export_spools_csv(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """Export the active inventory as CSV (same schema the importer accepts)."""
@@ -1480,7 +1480,7 @@ async def export_spools_csv(
 async def import_spools_csv(
     file: UploadFile = File(...),
     dry_run: bool = Query(False),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Import spools from a CSV file.
@@ -1558,7 +1558,7 @@ async def get_spool_by_tag(
     tray_uuid: str | None = None,
     tag_uid: str | None = None,
     include_archived: bool = False,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_UPDATE),
 ):
     """Find a single spool by its NFC ``tray_uuid`` and/or ``tag_uid``.
@@ -1599,7 +1599,7 @@ async def get_spool_by_tag(
 @router.get("/spools/{spool_id}", response_model=SpoolResponse)
 async def get_spool(
     spool_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """Get a single spool with k_profiles."""
@@ -1622,7 +1622,7 @@ def _lock_entered_weight(payload: dict, fields_set: set[str]) -> dict:
 @router.post("/spools", response_model=SpoolResponse)
 async def create_spool(
     spool_data: SpoolCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Create a new spool."""
@@ -1649,7 +1649,7 @@ async def create_spool(
 @router.post("/spools/bulk", response_model=list[SpoolResponse])
 async def bulk_create_spools(
     data: SpoolBulkCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Create multiple identical spools."""
@@ -1680,7 +1680,7 @@ async def bulk_create_spools(
 async def update_spool(
     spool_id: int,
     spool_data: SpoolUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Update a spool."""
@@ -1715,7 +1715,7 @@ async def update_spool(
 @router.delete("/spools/{spool_id}")
 async def delete_spool(
     spool_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Hard delete a spool."""
@@ -1761,7 +1761,7 @@ async def _announce_freed_slots(freed: list[tuple[int, int, int]]) -> None:
 @router.post("/spools/{spool_id}/archive", response_model=SpoolResponse)
 async def archive_spool(
     spool_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Soft-delete a spool by setting archived_at."""
@@ -1783,7 +1783,7 @@ async def archive_spool(
 @router.post("/spools/{spool_id}/restore", response_model=SpoolResponse)
 async def restore_spool(
     spool_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Restore an archived spool."""
@@ -1802,7 +1802,7 @@ async def restore_spool(
 @router.post("/spools/{spool_id}/reset-consumed-counter", response_model=SpoolResponse)
 async def reset_spool_consumed_counter(
     spool_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Zero the displayed "Total Consumed" counter without touching remaining.
@@ -1834,7 +1834,7 @@ async def reset_spool_consumed_counter(
 @router.post("/spools/reset-consumed-counter-bulk")
 async def bulk_reset_spool_consumed_counter(
     payload: dict,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Bulk-stamp baseline = weight_used across the given spool IDs.
@@ -1872,7 +1872,7 @@ class BulkIdsRequest(BaseModel):
 @router.post("/spools/bulk-update")
 async def bulk_update_spools(
     payload: BulkUpdateRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Apply the same partial update to every listed spool.
@@ -1916,7 +1916,7 @@ async def bulk_update_spools(
 @router.post("/spools/bulk-delete")
 async def bulk_delete_spools(
     payload: BulkIdsRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Hard-delete every listed spool. Unknown IDs are returned in not_found."""
@@ -1937,7 +1937,7 @@ async def bulk_delete_spools(
 @router.post("/spools/bulk-archive")
 async def bulk_archive_spools(
     payload: BulkIdsRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Soft-archive every listed spool (sets archived_at). Already-archived spools are left alone and counted in already_archived."""
@@ -1967,7 +1967,7 @@ async def bulk_archive_spools(
 @router.post("/spools/bulk-restore")
 async def bulk_restore_spools(
     payload: BulkIdsRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Restore every listed archived spool. Non-archived rows are no-ops counted in already_active."""
@@ -1995,7 +1995,7 @@ async def bulk_restore_spools(
 @router.get("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
 async def list_k_profiles(
     spool_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """List K-profiles for a spool."""
@@ -2007,7 +2007,7 @@ async def list_k_profiles(
 async def replace_k_profiles(
     spool_id: int,
     profiles: list[SpoolKProfileBase],
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Replace all K-profiles for a spool (batch save)."""
@@ -2038,7 +2038,7 @@ async def replace_k_profiles(
 async def replace_spool_suppliers(
     spool_id: int,
     links: list[SpoolSupplierLinkInput],
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Replace a spool's supplier assignments (#2988, batch save).
@@ -2087,7 +2087,7 @@ async def replace_spool_suppliers(
 @router.get("/spools/{spool_id}/filament-presets", response_model=list[SpoolFilamentPresetResponse])
 async def list_filament_presets(
     spool_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """List per-printer-model preset overrides for a spool.
@@ -2104,7 +2104,7 @@ async def list_filament_presets(
 async def replace_filament_presets(
     spool_id: int,
     presets: list[SpoolFilamentPresetBase],
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Replace all per-printer-model preset overrides for a spool.
@@ -2157,7 +2157,7 @@ async def replace_filament_presets(
 @router.get("/assignments", response_model=list[SpoolAssignmentResponse])
 async def list_assignments(
     printer_id: int | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_VIEW_ASSIGNMENTS),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -2225,7 +2225,7 @@ async def list_assignments(
 @router.post("/assignments", response_model=SpoolAssignmentResponse)
 async def assign_spool(
     data: SpoolAssignmentCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -2457,7 +2457,7 @@ async def unassign_spool(
     printer_id: int,
     ams_id: int,
     tray_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Unassign a spool from an AMS slot."""
@@ -2518,7 +2518,7 @@ def _validate_tag_input(
 async def link_tag_to_spool(
     spool_id: int,
     data: LinkTagRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Link an RFID tag_uid/tray_uuid to an existing spool.
@@ -2619,7 +2619,7 @@ async def link_tag_to_spool(
 async def get_spool_usage_history(
     spool_id: int,
     limit: int = 50,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """Get usage history for a specific spool."""
@@ -2646,7 +2646,7 @@ async def get_supplier_stats(
     tz: str | None = Query(
         None, max_length=64, description="IANA zone the dates are calendar days in; the server's when omitted"
     ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """Aggregate the inventory by purchase-source supplier (#2988).
@@ -2737,7 +2737,7 @@ async def get_material_number_stats(
     tz: str | None = Query(
         None, max_length=64, description="IANA zone the dates are calendar days in; the server's when omitted"
     ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """Aggregate the inventory by material number (#2870).
@@ -2820,7 +2820,7 @@ async def get_material_number_stats(
 async def get_all_usage_history(
     limit: int = 100,
     printer_id: int | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -2839,7 +2839,7 @@ async def get_all_usage_history(
 @router.delete("/spools/{spool_id}/usage")
 async def clear_spool_usage_history(
     spool_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Clear usage history for a spool."""
@@ -2857,7 +2857,7 @@ async def clear_spool_usage_history(
 
 @router.post("/sync-ams-weights")
 async def sync_weights_from_ams(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -3016,7 +3016,7 @@ class FilamentSkuSettingsUpsert(BaseModel):
 
 @router.get("/sku-settings", response_model=list[FilamentSkuSettingsResponse])
 async def list_sku_settings(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_FORECAST_READ),
 ):
     """List all filament SKU reorder settings."""
@@ -3031,7 +3031,7 @@ async def list_sku_settings(
 @router.post("/sku-settings", response_model=FilamentSkuSettingsResponse)
 async def upsert_sku_settings(
     data: FilamentSkuSettingsUpsert,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequireAnyPermissionIfAuthEnabled(
         Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
     ),
@@ -3104,7 +3104,7 @@ class ShoppingListItemStatusUpdate(BaseModel):
 
 @router.get("/shopping-list", response_model=list[ShoppingListItemResponse])
 async def get_shopping_list(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_FORECAST_READ),
 ):
     """Get the filament shopping list."""
@@ -3132,7 +3132,7 @@ async def get_shopping_list(
 @router.post("/shopping-list", response_model=ShoppingListItemResponse)
 async def add_to_shopping_list(
     data: ShoppingListItemCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequireAnyPermissionIfAuthEnabled(
         Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
     ),
@@ -3169,7 +3169,7 @@ async def add_to_shopping_list(
 async def update_shopping_list_status(
     item_id: int,
     data: ShoppingListItemStatusUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequireAnyPermissionIfAuthEnabled(
         Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
     ),
@@ -3211,7 +3211,7 @@ async def update_shopping_list_status(
 @router.delete("/shopping-list/{item_id}")
 async def remove_from_shopping_list(
     item_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequireAnyPermissionIfAuthEnabled(
         Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
     ),
@@ -3230,7 +3230,7 @@ async def remove_from_shopping_list(
 
 @router.delete("/shopping-list")
 async def clear_shopping_list(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequireAnyPermissionIfAuthEnabled(
         Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
     ),
@@ -3253,7 +3253,7 @@ class CreateSpoolFromSlotRequest(BaseModel):
 @router.post("/spools/from-slot", response_model=SpoolResponse)
 async def create_spool_from_slot(
     req: CreateSpoolFromSlotRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):

+ 7 - 7
backend/app/api/routes/kprofiles.py

@@ -33,7 +33,7 @@ router = APIRouter(prefix="/printers/{printer_id}/kprofiles", tags=["kprofiles"]
 async def get_kprofiles(
     printer_id: int,
     nozzle_diameter: str = "0.4",
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_READ),
 ):
     """Get K-profiles from a printer.
@@ -82,7 +82,7 @@ async def get_kprofiles(
 async def set_kprofile(
     printer_id: int,
     profile: KProfileCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
     """Create or update a K-profile on the printer.
@@ -235,7 +235,7 @@ def _is_saved_profile(stored, profile: KProfileCreate) -> bool:
 async def set_kprofiles_batch(
     printer_id: int,
     profiles: list[KProfileCreate],
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
     """Create multiple K-profiles in a single command (for dual-nozzle).
@@ -302,7 +302,7 @@ async def set_kprofiles_batch(
 async def delete_kprofile(
     printer_id: int,
     profile: KProfileDelete,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
 ):
     """Delete a K-profile from the printer.
@@ -352,7 +352,7 @@ async def delete_kprofile(
 @router.get("/notes", response_model=KProfileNoteResponse)
 async def get_kprofile_notes(
     printer_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_READ),
 ):
     """Get all K-profile notes for a printer.
@@ -380,7 +380,7 @@ async def get_kprofile_notes(
 async def set_kprofile_note(
     printer_id: int,
     note_data: KProfileNote,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
     """Set or update a note for a K-profile.
@@ -429,7 +429,7 @@ async def set_kprofile_note(
 async def delete_kprofile_note(
     printer_id: int,
     setting_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
 ):
     """Delete a note for a K-profile.

+ 4 - 4
backend/app/api/routes/labels.py

@@ -299,7 +299,7 @@ async def _spoolman_label_data(spool_ids: list[int], request: Request, db: Async
 async def render_local_inventory_labels(
     body: LabelRequest,
     request: Request,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ) -> Response:
     """Render labels for spools in the local inventory."""
@@ -311,7 +311,7 @@ async def render_local_inventory_labels(
 async def preview_local_inventory_label(
     body: LabelPreviewRequest,
     request: Request,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ) -> Response:
     """One label for a local spool, as a PNG, for the picker's preview."""
@@ -323,7 +323,7 @@ async def preview_local_inventory_label(
 async def render_spoolman_labels(
     body: LabelRequest,
     request: Request,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ) -> Response:
     """Render labels for spools tracked in Spoolman."""
@@ -335,7 +335,7 @@ async def render_spoolman_labels(
 async def preview_spoolman_label(
     body: LabelPreviewRequest,
     request: Request,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ) -> Response:
     """One label for a Spoolman spool, as a PNG, for the picker's preview."""

+ 35 - 35
backend/app/api/routes/library.py

@@ -1021,7 +1021,7 @@ async def _backfill_external_thumbnails(folder_ids: list[int]) -> None:
 @router.get("/folders/", response_model=list[FolderTreeItem])
 async def list_folders(
     response: Response,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
@@ -1141,7 +1141,7 @@ async def list_folders(
 @router.get("/folders/by-project/{project_id}", response_model=list[FolderResponse])
 async def get_folders_by_project(
     project_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
@@ -1209,7 +1209,7 @@ async def get_folders_by_project(
 @router.get("/folders/by-archive/{archive_id}", response_model=list[FolderResponse])
 async def get_folders_by_archive(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
@@ -1288,7 +1288,7 @@ async def _folders_by_archive(
 @router.post("/folders/", response_model=FolderResponse)
 async def create_folder(
     data: FolderCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
     actor: User | ApiKeyActor | None = RequestActor,
     printer_scope: PrinterScope = RequestPrinterScope,
@@ -1350,7 +1350,7 @@ async def create_folder(
 @router.get("/folders/{folder_id}", response_model=FolderResponse)
 async def get_folder(
     folder_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
@@ -1419,7 +1419,7 @@ _README_PREFERRED_STEMS = ("readme", "description")
 @router.get("/folders/{folder_id}/readme", response_model=FolderReadmeResponse)
 async def get_folder_readme(
     folder_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
@@ -1480,7 +1480,7 @@ async def get_folder_readme(
 async def update_folder(
     folder_id: int,
     data: FolderUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
@@ -1603,7 +1603,7 @@ async def update_folder(
 @router.delete("/folders/{folder_id}")
 async def delete_folder(
     folder_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
@@ -1840,7 +1840,7 @@ def _validate_external_path(path_str: str) -> Path:
 @router.post("/folders/external", response_model=FolderResponse)
 async def create_external_folder(
     data: ExternalFolderCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     # GHSA-r2qv follow-up (I1): elevated from LIBRARY_UPLOAD to SETTINGS_UPDATE.
     # Registering a host filesystem path as a Bambuddy library folder is an
     # operator-level capability that crosses user boundaries (one user's
@@ -1917,7 +1917,7 @@ def _mtime_to_datetime(mtime: float) -> datetime:
 @router.post("/folders/{folder_id}/scan")
 async def scan_external_folder(
     folder_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
     actor: User | ApiKeyActor | None = RequestActor,
 ):
@@ -2282,7 +2282,7 @@ async def list_files(
     external_only: bool = False,
     recursive: bool = False,
     tag_ids: list[int] = Query(default_factory=list),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
@@ -2447,7 +2447,7 @@ async def upload_file(
     file: UploadFile = File(...),
     folder_id: int | None = None,
     generate_stl_thumbnails: bool = Query(default=True),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
     actor: User | ApiKeyActor | None = RequestActor,
 ):
@@ -2624,7 +2624,7 @@ async def extract_zip_file(
     preserve_structure: bool = Query(default=True),
     create_folder_from_zip: bool = Query(default=False),
     generate_stl_thumbnails: bool = Query(default=True),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
     actor: User | ApiKeyActor | None = RequestActor,
 ):
@@ -2941,7 +2941,7 @@ async def extract_zip_file(
 @router.post("/generate-stl-thumbnails", response_model=BatchThumbnailResponse)
 async def batch_generate_stl_thumbnails(
     request: BatchThumbnailRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
@@ -3088,7 +3088,7 @@ def is_sliced_file(filename: str) -> bool:
 @router.post("/files/combine", response_model=FileUploadResponse)
 async def combine_files(
     request: CombineFilesRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
     actor: User | ApiKeyActor | None = RequestActor,
 ):
@@ -3170,7 +3170,7 @@ async def combine_files(
 @router.post("/files/add-to-queue", response_model=AddToQueueResponse)
 async def add_files_to_queue(
     request: AddToQueueRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),
     printer_scope: PrinterScope = RequestPrinterScope,
     review_required: bool = QueueReviewRequired,
@@ -3410,7 +3410,7 @@ async def add_files_to_queue(
 @router.get("/files/{file_id}/plates")
 async def get_library_file_plates(
     file_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
@@ -3703,7 +3703,7 @@ async def get_library_file_plates(
 async def get_library_file_plate_thumbnail(
     file_id: int,
     plate_index: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.LIBRARY_READ_ALL,
@@ -3791,7 +3791,7 @@ async def get_library_file_filament_requirements(
     plate_id: int | None = None,
     request_id: str | None = None,
     full_slots: bool = False,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
@@ -5216,7 +5216,7 @@ async def slice_and_persist_as_archive(
 async def slice_library_file(
     file_id: int,
     request: SliceRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     actor: User | ApiKeyActor | None = RequestActor,
@@ -5360,7 +5360,7 @@ async def print_library_file(
 @router.get("/files/{file_id}", response_model=FileResponseSchema)
 async def get_file(
     file_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
@@ -5459,7 +5459,7 @@ async def get_file(
 async def update_file(
     file_id: int,
     data: FileUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
@@ -5539,7 +5539,7 @@ async def update_file(
 @router.delete("/files/{file_id}")
 async def delete_file(
     file_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
@@ -5597,7 +5597,7 @@ async def delete_file(
 @router.get("/files/{file_id}/download")
 async def download_file(
     file_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
@@ -5624,7 +5624,7 @@ async def download_file(
 @router.post("/files/{file_id}/slicer-token")
 async def create_library_slicer_token(
     file_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
@@ -5652,7 +5652,7 @@ async def download_library_file_for_slicer(
     file_id: int,
     token: str,
     filename: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Download a library file using a slicer download token.
 
@@ -5686,7 +5686,7 @@ async def download_library_file_for_slicer(
 @router.get("/files/{file_id}/thumbnail")
 async def get_thumbnail(
     file_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.LIBRARY_READ_ALL,
@@ -5727,7 +5727,7 @@ async def get_thumbnail(
 async def upload_preview_thumbnail(
     file_id: int,
     thumbnail: UploadFile = File(...),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
@@ -5828,7 +5828,7 @@ async def upload_preview_thumbnail(
 async def upload_file_photo(
     file_id: int,
     file: UploadFile = File(...),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
@@ -5884,7 +5884,7 @@ async def upload_file_photo(
 async def get_file_photo(
     file_id: int,
     filename: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.LIBRARY_READ_ALL,
@@ -5917,7 +5917,7 @@ async def get_file_photo(
 async def delete_file_photo(
     file_id: int,
     filename: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
@@ -5963,7 +5963,7 @@ async def delete_file_photo(
 async def get_gcode(
     file_id: int,
     plate: int | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
@@ -6026,7 +6026,7 @@ async def get_gcode(
 @router.post("/files/move")
 async def move_files(
     data: FileMoveRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
@@ -6132,7 +6132,7 @@ async def move_files(
 @router.post("/bulk-delete", response_model=BulkDeleteResponse)
 async def bulk_delete(
     data: BulkDeleteRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
@@ -6232,7 +6232,7 @@ async def bulk_delete(
 
 @router.get("/stats")
 async def get_library_stats(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,

+ 5 - 5
backend/app/api/routes/library_tags.py

@@ -67,7 +67,7 @@ def _name_key(name: str) -> str:
 @router.get("", response_model=list[TagResponse])
 @router.get("/", response_model=list[TagResponse])
 async def list_tags(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
@@ -124,7 +124,7 @@ async def list_tags(
 @router.post("/", response_model=TagResponse, status_code=201)
 async def create_tag(
     payload: TagCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
 ) -> TagResponse:
     """Create a tag. Case-insensitive dup → 409."""
@@ -147,7 +147,7 @@ async def create_tag(
 async def update_tag(
     tag_id: int,
     payload: TagUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
 ) -> TagResponse:
     """Rename a tag. Case-insensitive dup → 409 (own-name no-op is allowed)."""
@@ -194,7 +194,7 @@ async def update_tag(
 @router.delete("/{tag_id}", status_code=204, response_model=None)
 async def delete_tag(
     tag_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
 ) -> None:
     """Delete a tag. Association rows ON DELETE CASCADE — files are untouched."""
@@ -208,7 +208,7 @@ async def delete_tag(
 @router.post("/bulk-assign", response_model=TagBulkAssignResponse)
 async def bulk_assign(
     payload: TagBulkAssignRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,

+ 8 - 8
backend/app/api/routes/library_trash.py

@@ -52,7 +52,7 @@ router = APIRouter(prefix="/library", tags=["library-trash"])
 async def preview_purge(
     older_than_days: int = Query(ge=1, le=3650),
     include_never_printed: bool = True,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
 ):
     """Preview how many files would move to trash for the given age threshold.
@@ -70,7 +70,7 @@ async def preview_purge(
 @router.post("/purge", response_model=PurgeResponse)
 async def execute_purge(
     body: PurgeRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
 ):
     """Move matching files to trash. Idempotent — already-trashed rows skip."""
@@ -89,7 +89,7 @@ async def execute_purge(
 async def list_trash(
     limit: int = Query(default=100, ge=1, le=500),
     offset: int = Query(default=0, ge=0),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
@@ -175,7 +175,7 @@ async def _load_trashed_file(
 @router.post("/trash/{file_id}/restore")
 async def restore_from_trash(
     file_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
@@ -192,7 +192,7 @@ async def restore_from_trash(
 @router.delete("/trash/{file_id}")
 async def hard_delete_from_trash(
     file_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
@@ -209,7 +209,7 @@ async def hard_delete_from_trash(
 
 @router.delete("/trash", response_model=EmptyTrashResponse)
 async def empty_trash(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
@@ -242,7 +242,7 @@ async def empty_trash(
 
 @router.get("/trash/settings", response_model=TrashSettings)
 async def get_trash_settings(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
 ):
     retention = await library_trash_service.get_retention_days(db)
@@ -258,7 +258,7 @@ async def get_trash_settings(
 @router.put("/trash/settings", response_model=TrashSettings)
 async def update_trash_settings(
     body: TrashSettings,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
 ):
     if body.retention_days < MIN_RETENTION_DAYS or body.retention_days > MAX_RETENTION_DAYS:

+ 7 - 7
backend/app/api/routes/library_variants.py

@@ -186,7 +186,7 @@ async def _dissolve_if_too_small(db: AsyncSession, group: FileVariantGroup) -> b
 @router.post("/", response_model=VariantGroupResponse, status_code=201)
 async def create_variant_group(
     payload: VariantGroupCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
@@ -241,7 +241,7 @@ async def create_variant_group(
 @router.get("/by-file/{file_id}", response_model=VariantGroupResponse)
 async def get_group_for_file(
     file_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
@@ -268,7 +268,7 @@ async def get_group_for_file(
 @router.get("/{group_id}", response_model=VariantGroupResponse)
 async def get_variant_group(
     group_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
@@ -283,7 +283,7 @@ async def get_variant_group(
 async def update_variant_group(
     group_id: int,
     payload: VariantGroupUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
@@ -323,7 +323,7 @@ async def update_variant_group(
 async def add_variant_group_member(
     payload: VariantGroupMemberRequest,
     group_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
@@ -379,7 +379,7 @@ async def add_variant_group_member(
 async def remove_variant_group_member(
     group_id: int,
     file_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
@@ -406,7 +406,7 @@ async def remove_variant_group_member(
 @router.delete("/{group_id}", response_model=None, status_code=204)
 async def delete_variant_group(
     group_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,

+ 9 - 9
backend/app/api/routes/local_presets.py

@@ -37,7 +37,7 @@ router = APIRouter(prefix="/local-presets", tags=["Local Presets"])
 @router.get("/", response_model=LocalPresetsResponse)
 async def list_local_presets(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """List all local presets grouped by type."""
     result = await db.execute(select(LocalPreset).order_by(LocalPreset.name))
@@ -60,7 +60,7 @@ async def list_local_presets(
 async def get_local_preset(
     preset_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Get full detail for a local preset including the setting JSON."""
     result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))
@@ -81,7 +81,7 @@ async def get_local_preset(
 async def import_presets(
     file: UploadFile,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Import presets from an OrcaSlicer export file (.json, .orca_filament, .bbscfg, .bbsflmt, .zip)."""
     if not file.filename:
@@ -99,7 +99,7 @@ async def import_presets(
 async def create_local_preset(
     data: LocalPresetCreate,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Manually create a local preset."""
     if data.preset_type not in ("filament", "printer", "process"):
@@ -126,7 +126,7 @@ async def update_local_preset(
     preset_id: int,
     data: LocalPresetUpdate,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Update a local preset's name or settings."""
     result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))
@@ -161,7 +161,7 @@ async def update_local_preset(
 async def delete_local_preset(
     preset_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Delete a local preset."""
     result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))
@@ -176,7 +176,7 @@ async def delete_local_preset(
 @router.get("/base-cache/status")
 async def base_cache_status(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Get the status of the OrcaSlicer base profile cache."""
     return await get_cache_status(db)
@@ -185,7 +185,7 @@ async def base_cache_status(
 @router.post("/base-cache/refresh")
 async def refresh_cache(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Force refresh all cached base profiles from GitHub."""
     return await refresh_base_cache(db)
@@ -194,7 +194,7 @@ async def refresh_cache(
 @router.post("/reclassify")
 async def reclassify(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Re-evaluate preset types for all local presets using the improved heuristic."""
     return await reclassify_presets(db)

+ 7 - 7
backend/app/api/routes/location_ha_sensors.py

@@ -110,7 +110,7 @@ async def _refresh_quietly(sensor: LocationHASensor, db: AsyncSession) -> None:
 @router.get("/", response_model=list[LocationHASensorResponse])
 async def list_location_ha_sensors(
     location_id: int | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _READ,
 ):
     """List configured sensors, grouped by location and in display order."""
@@ -125,7 +125,7 @@ async def list_location_ha_sensors(
 @router.get("/entities", response_model=list[HADisplayEntity])
 async def list_bindable_entities(
     search: str | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _READ,
 ):
     """List the Home Assistant entities that can be bound to a storage location."""
@@ -146,7 +146,7 @@ async def list_bindable_entities(
 async def get_location_sensor_readings(
     location_id: int,
     show_on_card: bool = True,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _READ,
 ):
     """Live state of a location's card-visible sensors.
@@ -192,7 +192,7 @@ async def get_location_sensor_readings(
 @router.post("/", response_model=LocationHASensorResponse)
 async def create_location_ha_sensor(
     data: LocationHASensorCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _CREATE,
 ):
     """Bind a Home Assistant entity to a storage location."""
@@ -235,7 +235,7 @@ async def create_location_ha_sensor(
 @router.get("/{sensor_id}", response_model=LocationHASensorResponse)
 async def get_location_ha_sensor(
     sensor_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _READ,
 ):
     sensor = await db.get(LocationHASensor, sensor_id)
@@ -248,7 +248,7 @@ async def get_location_ha_sensor(
 async def update_location_ha_sensor(
     sensor_id: int,
     data: LocationHASensorUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _UPDATE,
 ):
     sensor = await db.get(LocationHASensor, sensor_id)
@@ -309,7 +309,7 @@ async def update_location_ha_sensor(
 @router.delete("/{sensor_id}")
 async def delete_location_ha_sensor(
     sensor_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = _DELETE,
 ):
     sensor = await db.get(LocationHASensor, sensor_id)

+ 14 - 14
backend/app/api/routes/maintenance.py

@@ -189,7 +189,7 @@ async def ensure_default_types(db: AsyncSession) -> None:
 
 @router.get("/types", response_model=list[MaintenanceTypeResponse])
 async def get_maintenance_types(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
 ):
     """Get all maintenance types."""
@@ -205,7 +205,7 @@ async def get_maintenance_types(
 @router.post("/types", response_model=MaintenanceTypeResponse)
 async def create_maintenance_type(
     data: MaintenanceTypeCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
 ):
     """Create a custom maintenance type."""
@@ -228,7 +228,7 @@ async def create_maintenance_type(
 async def update_maintenance_type(
     type_id: int,
     data: MaintenanceTypeUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
 ):
     """Update a maintenance type."""
@@ -249,7 +249,7 @@ async def update_maintenance_type(
 @router.delete("/types/{type_id}")
 async def delete_maintenance_type(
     type_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
 ):
     """Delete a maintenance type."""
@@ -270,7 +270,7 @@ async def delete_maintenance_type(
 
 @router.post("/types/restore-defaults")
 async def restore_default_maintenance_types(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
 ):
     """Restore deleted default maintenance types."""
@@ -480,7 +480,7 @@ async def _get_printer_maintenance_internal(
 @router.get("/printers/{printer_id}", response_model=PrinterMaintenanceOverview)
 async def get_printer_maintenance(
     printer_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
 ):
     """Get maintenance overview for a specific printer."""
@@ -489,7 +489,7 @@ async def get_printer_maintenance(
 
 @router.get("/overview", response_model=list[PrinterMaintenanceOverview])
 async def get_all_maintenance_overview(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -515,7 +515,7 @@ async def get_all_maintenance_overview(
 async def update_printer_maintenance(
     item_id: int,
     data: PrinterMaintenanceUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -542,7 +542,7 @@ async def update_printer_maintenance(
 async def assign_maintenance_type(
     printer_id: int,
     type_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
 ):
     """Assign a maintenance type to a specific printer (for custom types)."""
@@ -601,7 +601,7 @@ async def assign_maintenance_type(
 @router.delete("/items/{item_id}")
 async def remove_maintenance_item(
     item_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -629,7 +629,7 @@ async def remove_maintenance_item(
 async def perform_maintenance(
     item_id: int,
     data: PerformMaintenanceRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -709,7 +709,7 @@ async def perform_maintenance(
 @router.get("/items/{item_id}/history", response_model=list[MaintenanceHistoryResponse])
 async def get_maintenance_history(
     item_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -729,7 +729,7 @@ async def get_maintenance_history(
 
 @router.get("/summary")
 async def get_maintenance_summary(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -768,7 +768,7 @@ async def get_maintenance_summary(
 async def set_printer_hours(
     printer_id: int,
     total_hours: float,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
 ):
     """Set the total print hours for a printer (adjusts offset to match).

+ 4 - 4
backend/app/api/routes/makerworld.py

@@ -216,7 +216,7 @@ async def proxy_thumbnail(
 
 @router.get("/status", response_model=MakerWorldStatus)
 async def get_status(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(makerworld_provider.view_permission),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
 ):
@@ -256,7 +256,7 @@ async def get_status(
 )
 async def resolve_url(
     body: MakerWorldResolveRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     credentials: HTTPAuthorizationCredentials | None = Depends(security),
     x_api_key: str | None = Header(default=None, alias="X-API-Key"),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
@@ -318,7 +318,7 @@ async def resolve_url(
 )
 async def import_instance(
     body: MakerWorldImportRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     credentials: HTTPAuthorizationCredentials | None = Depends(security),
     x_api_key: str | None = Header(default=None, alias="X-API-Key"),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
@@ -453,7 +453,7 @@ async def import_instance(
 @router.get("/recent-imports", response_model=list[MakerWorldRecentImport])
 async def recent_imports(
     limit: int = 10,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(makerworld_provider.view_permission),
 ):
     """Last N MakerWorld imports, newest first.

+ 9 - 9
backend/app/api/routes/manyfold.py

@@ -122,7 +122,7 @@ def _config_response(config: ManyfoldConfig) -> ManyfoldConfigResponse:
 
 @router.get("/config", response_model=ManyfoldConfigResponse)
 async def get_config(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
 ):
     """The stored connection, without the secret."""
@@ -132,7 +132,7 @@ async def get_config(
 @router.put("/config", response_model=ManyfoldConfigResponse)
 async def update_config(
     body: ManyfoldConfigUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Store the connection. An empty secret keeps the stored one."""
@@ -154,7 +154,7 @@ async def update_config(
 
 @router.delete("/config", status_code=204)
 async def delete_config(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Disconnect Manyfold. Files imported earlier stay in the library."""
@@ -166,7 +166,7 @@ async def delete_config(
 @router.post("/config/test", response_model=ManyfoldTestResponse)
 async def test_config(
     body: ManyfoldTestRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Sign in with the entered values and count the models Bambuddy can see.
@@ -195,7 +195,7 @@ async def test_config(
 
 @router.get("/status", response_model=ManyfoldStatus)
 async def get_status(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_VIEW),
 ):
     """Whether Manyfold is set up, and where it is."""
@@ -207,7 +207,7 @@ async def get_status(
 async def list_models(
     q: str = "",
     page: int = 1,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_VIEW),
 ):
     """One page of Manyfold's models, optionally searched.
@@ -227,7 +227,7 @@ async def list_models(
 @router.get("/models/{model_id}", response_model=ManyfoldModel)
 async def get_model(
     model_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_VIEW),
 ):
     """A model's details and files, with the library file of each earlier import."""
@@ -266,7 +266,7 @@ async def get_model(
 @router.get("/models/{model_id}/preview")
 async def get_preview(
     model_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_VIEW),
 ):
     """The model's preview image, fetched from Manyfold.
@@ -306,7 +306,7 @@ async def _import_folder_id(db: AsyncSession, folder_id: int | None, user: User
 @router.post("/import", response_model=ManyfoldImportResponse)
 async def import_file(
     body: ManyfoldImportRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_IMPORT),
     actor: User | ApiKeyActor | None = RequestActor,
 ):

+ 1 - 1
backend/app/api/routes/metrics.py

@@ -53,7 +53,7 @@ def state_to_numeric(state: str) -> int:
 
 @router.get("/metrics", response_class=Response)
 async def get_metrics(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     authorization: str | None = Header(None),
 ):
     """

+ 24 - 24
backend/app/api/routes/mfa.py

@@ -624,7 +624,7 @@ async def _set_email_2fa_enabled(db: AsyncSession, user_id: int, enabled: bool)
 @router.get("/2fa/status", response_model=TwoFAStatusResponse)
 async def get_2fa_status(
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> TwoFAStatusResponse:
     """Return the current 2FA configuration for the authenticated user."""
     result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == current_user.id))
@@ -645,7 +645,7 @@ async def get_2fa_status(
 async def setup_totp(
     body: TOTPSetupRequest | None = Body(default=None),
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> TOTPSetupResponse:
     """Initiate TOTP setup: generates a new secret and QR code.
 
@@ -711,7 +711,7 @@ async def setup_totp(
 async def enable_totp(
     body: TOTPEnableRequest,
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> TOTPEnableResponse:
     """Confirm TOTP setup by verifying a code from the authenticator app.
 
@@ -754,7 +754,7 @@ async def enable_totp(
 async def disable_totp(
     body: TOTPDisableRequest,
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> dict:
     """Disable TOTP by verifying a valid TOTP code or a backup code.
 
@@ -816,7 +816,7 @@ async def disable_totp(
 async def regenerate_backup_codes(
     body: TOTPDisableRequest,
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> BackupCodesResponse:
     """Generate 10 new backup codes. Requires a valid TOTP code OR a backup code.
 
@@ -882,7 +882,7 @@ async def regenerate_backup_codes(
 @router.post("/2fa/email/enable")
 async def enable_email_otp(
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> dict:
     """Step 1 of email OTP enable: send a verification code to the user's email.
 
@@ -961,7 +961,7 @@ async def enable_email_otp(
 async def confirm_enable_email_otp(
     body: EmailOTPEnableConfirmRequest,
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> dict:
     """Step 2 of email OTP enable: verify the code and activate email 2FA.
 
@@ -1018,7 +1018,7 @@ async def confirm_enable_email_otp(
 async def disable_email_otp(
     body: EmailOTPDisableRequest,
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> dict:
     """Disable email-based OTP 2FA for the current user.
 
@@ -1041,7 +1041,7 @@ async def disable_email_otp(
 async def send_email_otp(
     request: Request,
     body: EmailOTPSendRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> dict:
     """Send a 6-digit OTP code to the user's email address.
 
@@ -1130,7 +1130,7 @@ async def send_email_otp(
 async def verify_2fa(
     request: Request,
     body: TwoFAVerifyRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> TwoFAVerifyResponse:
     """Verify a 2FA code and exchange the pre_auth_token for a full JWT.
 
@@ -1276,7 +1276,7 @@ async def admin_disable_2fa(
     body: AdminDisable2FARequest = Body(default_factory=AdminDisable2FARequest),
     _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> dict:
     """Admin endpoint: disable all 2FA for a given user.
 
@@ -1338,7 +1338,7 @@ async def admin_disable_2fa(
 
 @router.get("/oidc/providers", response_model=list[OIDCProviderPublicResponse])
 async def list_oidc_providers(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> list[OIDCProviderPublicResponse]:
     """List all enabled OIDC providers (public).
 
@@ -1359,7 +1359,7 @@ async def list_oidc_providers(
 @router.get("/oidc/providers/all", response_model=list[OIDCProviderResponse])
 async def list_all_oidc_providers(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> list[OIDCProviderResponse]:
     """List ALL OIDC providers including disabled ones (admin only)."""
     result2 = await db.execute(select(OIDCProvider))
@@ -1372,7 +1372,7 @@ async def create_oidc_provider(
     body: OIDCProviderCreate,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> OIDCProviderResponse:
     """Create a new OIDC provider (admin only).
 
@@ -1473,7 +1473,7 @@ async def update_oidc_provider(
     body: OIDCProviderUpdate,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> OIDCProviderResponse:
     """Update an existing OIDC provider (admin only).
 
@@ -1573,7 +1573,7 @@ async def delete_oidc_provider(
     provider_id: int,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> dict:
     """Delete an OIDC provider and all its user links (admin only)."""
     result2 = await db.execute(select(OIDCProvider).where(OIDCProvider.id == provider_id))
@@ -1596,7 +1596,7 @@ async def delete_oidc_provider(
 async def get_oidc_provider_icon(
     provider_id: int,
     if_none_match: str | None = Header(default=None, alias="If-None-Match"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> Response:
     """Serve the cached icon for an enabled OIDC provider (public, no auth).
 
@@ -1636,7 +1636,7 @@ async def delete_oidc_provider_icon(
     provider_id: int,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> Response:
     """Remove the icon entirely for a provider (admin only).
 
@@ -1666,7 +1666,7 @@ async def refresh_oidc_provider_icon(
     provider_id: int,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> OIDCProviderResponse:
     """Refetch the icon from the stored `icon_url` (admin only).
 
@@ -1703,7 +1703,7 @@ async def refresh_oidc_provider_icon(
 @router.get("/oidc/authorize/{provider_id}", response_model=OIDCAuthorizeResponse)
 async def oidc_authorize(
     provider_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> OIDCAuthorizeResponse:
     """Return the OIDC authorization URL for the given provider."""
     result = await db.execute(
@@ -1788,7 +1788,7 @@ async def oidc_callback(
     code: str | None = Query(default=None, max_length=2048),
     state: str | None = Query(default=None, max_length=2048),
     error: str | None = Query(default=None, max_length=256),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> RedirectResponse:
     """Handle the OIDC authorization code callback from the identity provider."""
     external_url = await _get_base_external_url(db)
@@ -2201,7 +2201,7 @@ async def oidc_exchange(
     body: OIDCExchangeRequest,
     raw_request: Request,
     response: Response,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> LoginResponse:
     """Exchange an OIDC exchange token (from the callback redirect) for a full JWT.
 
@@ -2287,7 +2287,7 @@ async def oidc_exchange(
 @router.get("/oidc/links", response_model=list[OIDCLinkResponse])
 async def list_oidc_links(
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> list[OIDCLinkResponse]:
     """List all OIDC provider links for the current user."""
     result = await db.execute(
@@ -2315,7 +2315,7 @@ async def list_oidc_links(
 async def remove_oidc_link(
     provider_id: int,
     current_user: User = Depends(get_current_active_user),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> dict:
     """Remove the OIDC link between the current user and a provider."""
     result = await db.execute(

+ 4 - 4
backend/app/api/routes/notification_templates.py

@@ -74,7 +74,7 @@ EVENT_NAMES = {
 @router.get("", response_model=list[NotificationTemplateResponse])
 @router.get("/", response_model=list[NotificationTemplateResponse])
 async def get_templates(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
 ):
     """Get all notification templates."""
@@ -101,7 +101,7 @@ async def get_variables(
 @router.get("/{template_id}", response_model=NotificationTemplateResponse)
 async def get_template(
     template_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
 ):
     """Get a single notification template."""
@@ -116,7 +116,7 @@ async def get_template(
 async def update_template(
     template_id: int,
     update: NotificationTemplateUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),
 ):
     """Update a notification template."""
@@ -142,7 +142,7 @@ async def update_template(
 @router.post("/{template_id}/reset", response_model=NotificationTemplateResponse)
 async def reset_template(
     template_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),
 ):
     """Reset a notification template to its default values."""

+ 13 - 13
backend/app/api/routes/notifications.py

@@ -239,7 +239,7 @@ def _ensure_target_in_scope(printer_id: int | None, printer_scope: PrinterScope)
 
 @router.get("/", response_model=list[NotificationProviderResponse])
 async def list_notification_providers(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -253,7 +253,7 @@ async def list_notification_providers(
 @router.post("/", response_model=NotificationProviderResponse)
 async def create_notification_provider(
     provider_data: NotificationProviderCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -352,7 +352,7 @@ async def create_notification_provider(
 @router.post("/test-config", response_model=NotificationTestResponse)
 async def test_notification_config(
     test_request: NotificationTestRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
     can_update: bool = Depends(probe_permissions_if_auth_enabled(Permission.NOTIFICATIONS_UPDATE)),
     printer_scope: PrinterScope = RequestPrinterScope,
@@ -384,7 +384,7 @@ async def test_notification_config(
 
 @router.post("/test-all")
 async def test_all_notification_providers(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -447,7 +447,7 @@ async def get_notification_logs(
     event_type: str | None = Query(default=None),
     success: bool | None = Query(default=None),
     days: int | None = Query(default=7, ge=1, le=90, description="Filter logs from the last N days"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -514,7 +514,7 @@ async def get_notification_logs(
 @router.get("/logs/stats", response_model=NotificationLogStats)
 async def get_notification_log_stats(
     days: int = Query(default=7, ge=1, le=90, description="Statistics for the last N days"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -567,7 +567,7 @@ async def get_notification_log_stats(
 @router.delete("/logs")
 async def clear_notification_logs(
     older_than_days: int = Query(default=30, ge=1, description="Delete logs older than N days"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -648,7 +648,7 @@ def _check_app_message_rate(key: str) -> None:
 @router.post("/app-message", response_model=AppMessageResult)
 async def send_app_message(
     data: AppMessage,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     caller: ScopedCaller = Depends(require_notification_send()),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -671,7 +671,7 @@ async def send_app_message(
 
 @router.get("/app-message/channels", response_model=list[AppMessageChannel])
 async def app_message_channels(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: ScopedCaller = Depends(require_notification_send()),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -692,7 +692,7 @@ async def app_message_channels(
 @router.get("/{provider_id}", response_model=NotificationProviderResponse)
 async def get_notification_provider(
     provider_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -706,7 +706,7 @@ async def get_notification_provider(
 async def update_notification_provider(
     provider_id: int,
     update_data: NotificationProviderUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -795,7 +795,7 @@ async def update_notification_provider(
 @router.delete("/{provider_id}")
 async def delete_notification_provider(
     provider_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -815,7 +815,7 @@ async def delete_notification_provider(
 @router.post("/{provider_id}/test", response_model=NotificationTestResponse)
 async def test_notification_provider(
     provider_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):

+ 1 - 1
backend/app/api/routes/obico.py

@@ -83,7 +83,7 @@ async def get_printer_status(
 @router.post("/test-connection")
 async def test_connection(
     req: TestConnectionRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Ping the Obico ML API health endpoint and check the token. Returns ok + raw body."""

+ 6 - 6
backend/app/api/routes/orca_cloud.py

@@ -516,7 +516,7 @@ async def _build_authenticated_service(
 @router.post("/device/start", response_model=OrcaDeviceStartResponse)
 async def device_start(
     request: Request,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
     """Begin device pairing. Requests a device code from Orca, stores it
@@ -555,7 +555,7 @@ async def device_start(
 
 @router.post("/device/poll", response_model=OrcaDevicePollResponse)
 async def device_poll(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
     """Poll the token endpoint once for the in-flight pairing. Returns an
@@ -608,7 +608,7 @@ async def device_poll(
 
 @router.get("/status", response_model=OrcaAuthStatusResponse)
 async def get_status(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
     """Return whether the caller has an Orca Cloud session stored, plus
@@ -623,7 +623,7 @@ async def get_status(
 
 @router.post("/logout")
 async def logout(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
     """Clear stored Orca Cloud credentials. Does not call Orca's disconnect
@@ -635,7 +635,7 @@ async def logout(
 
 @router.get("/profiles", response_model=OrcaProfileListResponse)
 async def list_profiles(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
     """Return profile metadata grouped by type (``filament`` / ``printer``
@@ -675,7 +675,7 @@ async def list_profiles(
 @router.get("/profiles/{profile_id}", response_model=OrcaProfileDetail)
 async def get_profile(
     profile_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
     """Fetch a single profile's full content, shaped like

+ 7 - 7
backend/app/api/routes/pending_uploads.py

@@ -91,7 +91,7 @@ async def _augment_with_display_name(
 
 @router.get("/", response_model=list[PendingUploadResponse])
 async def list_pending_uploads(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
@@ -109,7 +109,7 @@ async def list_pending_uploads(
 
 @router.get("/count")
 async def get_pending_count(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
@@ -130,7 +130,7 @@ async def get_pending_count(
 
 @router.post("/archive-all")
 async def archive_all_pending(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
 ):
     """Archive all pending uploads."""
@@ -190,7 +190,7 @@ async def archive_all_pending(
 
 @router.delete("/discard-all")
 async def discard_all_pending(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
 ):
     """Discard all pending uploads."""
@@ -218,7 +218,7 @@ async def discard_all_pending(
 @router.get("/{upload_id}", response_model=PendingUploadResponse)
 async def get_pending_upload(
     upload_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
@@ -240,7 +240,7 @@ async def get_pending_upload(
 async def archive_pending_upload(
     upload_id: int,
     request: ArchiveRequest = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
 ):
     """Archive a pending upload."""
@@ -317,7 +317,7 @@ async def archive_pending_upload(
 @router.delete("/{upload_id}")
 async def discard_pending_upload(
     upload_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
 ):
     """Discard a pending upload without archiving."""

+ 8 - 8
backend/app/api/routes/pipeline_runs.py

@@ -683,7 +683,7 @@ async def check_eligibility(
     body: CheckEligibilityRequest,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     actor: User | ApiKeyActor | None = RequestActor,
 ):
     pipeline = await _load_pipeline(db, pipeline_id)
@@ -716,7 +716,7 @@ async def run_pipeline(
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     printer_scope: PrinterScope = RequestPrinterScope,
     review_required: bool = QueueReviewRequired,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     actor: User | ApiKeyActor | None = RequestActor,
 ):
     from backend.app.api.routes.settings import get_setting
@@ -869,7 +869,7 @@ async def list_runs_for_pipeline(
     pipeline_id: int,
     limit: int = 10,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
     limit = max(1, min(limit, 100))
@@ -897,7 +897,7 @@ async def list_all_runs(
     target_printer_id: int | None = None,
     target_model_class: str | None = None,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Dashboard list. Newest first; filters on pipeline_id + status +
@@ -945,7 +945,7 @@ _TERMINAL_RUN_STATUSES = ("completed", "failed", "cancelled", "partial_failure")
 @pipeline_run_router.post("/clear")
 async def clear_terminal_runs(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Delete every terminal pipeline run (completed / failed / cancelled /
@@ -973,7 +973,7 @@ async def clear_terminal_runs(
 async def get_run(
     run_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
     run = await _load_run_in_scope(db, run_id, printer_scope)
@@ -984,7 +984,7 @@ async def get_run(
 async def cancel_run(
     run_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Cancel a queued / in-flight run. Cascades to all non-terminal queue
@@ -1047,7 +1047,7 @@ async def retry_failed(
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     printer_scope: PrinterScope = RequestPrinterScope,
     review_required: bool = QueueReviewRequired,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     actor: User | ApiKeyActor | None = RequestActor,
 ):
     """Create a new run with copies = (failed + cancelled count) from the

+ 5 - 5
backend/app/api/routes/print_log.py

@@ -64,7 +64,7 @@ async def get_print_log(
     offset: int = Query(default=0, ge=0),
     sort_by: str = Query(default="date"),
     sort_dir: str = Query(default="desc", pattern="^(asc|desc)$"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -145,7 +145,7 @@ async def get_print_log(
 @router.get("/{entry_id}/thumbnail")
 async def get_print_log_thumbnail(
     entry_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.ARCHIVES_READ_ALL,
@@ -190,7 +190,7 @@ async def get_print_log_thumbnail(
 
 @router.delete("/")
 async def clear_print_log(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_ALL),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -213,7 +213,7 @@ async def clear_print_log(
 @router.delete("/{entry_id}")
 async def delete_print_log_entry(
     entry_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_DELETE_ALL,
@@ -285,7 +285,7 @@ _STATUS_KEYS = frozenset({"completed", "failed", "stopped", "cancelled", "skippe
 async def update_print_log_entry(
     entry_id: int,
     update: PrintLogEntryUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_UPDATE_ALL,

+ 21 - 21
backend/app/api/routes/print_queue.py

@@ -712,7 +712,7 @@ async def list_queue(
     target_model: str | None = Query(
         None, description="Filter by target model (also includes model-based items when combined with printer_id)"
     ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
@@ -835,7 +835,7 @@ async def list_queue_history(
     # as the page always could.
     limit: int = Query(50, ge=1, le=1_000_000),
     offset: int = Query(0, ge=0),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
@@ -953,7 +953,7 @@ async def clear_queue_history(
     printer_id: int | None = Query(None, description="Filter by printer (-1 for unassigned)"),
     status: str | None = Query(None, description="One of completed, failed, skipped, cancelled"),
     location: str | None = Query(None, description="Filter by location"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_DELETE_ALL,
@@ -1154,7 +1154,7 @@ def _variant_values(
 @router.post("/", response_model=PrintQueueItemResponse)
 async def add_to_queue(
     data: PrintQueueItemCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
     printer_scope: PrinterScope = RequestPrinterScope,
     review_required: bool = QueueReviewRequired,
@@ -1582,7 +1582,7 @@ def _releases_model_job(item: PrintQueueItem, update_data: dict) -> bool:
 @router.patch("/bulk", response_model=PrintQueueBulkUpdateResponse)
 async def bulk_update_queue_items(
     data: PrintQueueBulkUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_UPDATE_ALL,
@@ -1802,7 +1802,7 @@ async def _ensure_batch_in_scope(db: AsyncSession, batch_id: int, printer_scope:
 @router.post("/batches", response_model=PrintBatchResponse)
 async def create_batch(
     data: PrintBatchCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
     printer_scope: PrinterScope = RequestPrinterScope,
     actor: User | ApiKeyActor | None = RequestActor,
@@ -1921,7 +1921,7 @@ async def create_batch(
 async def update_batch(
     batch_id: int,
     data: PrintBatchUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
     actor: User | ApiKeyActor | None = RequestActor,
     printer_scope: PrinterScope = RequestPrinterScope,
@@ -2023,7 +2023,7 @@ async def _validate_copies_budget(
 async def dispatch_batch(
     batch_id: int,
     data: PrintBatchDispatchRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
     printer_scope: PrinterScope = RequestPrinterScope,
     review_required: bool = QueueReviewRequired,
@@ -2083,7 +2083,7 @@ async def dispatch_batch(
 @router.post("/batches/{batch_id}/ungroup", response_model=PrintBatchUngroupResponse)
 async def ungroup_batch(
     batch_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
     printer_scope: PrinterScope = RequestPrinterScope,
     actor: User | ApiKeyActor | None = RequestActor,
@@ -2133,7 +2133,7 @@ async def list_batches(
     status: str | None = Query(None, description="Filter by status (active, completed, cancelled)"),
     external_source: str | None = Query(None, description="Filter by the integration that created the batch"),
     external_ref: str | None = Query(None, description="Filter by the external record the batch fulfils"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
@@ -2195,7 +2195,7 @@ async def list_batches(
 @router.get("/batches/{batch_id}", response_model=PrintBatchResponse)
 async def get_batch(
     batch_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
@@ -2223,7 +2223,7 @@ async def get_batch(
 @router.delete("/batches/{batch_id}")
 async def cancel_batch(
     batch_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -2343,7 +2343,7 @@ async def _build_batch_response(
 @router.get("/{item_id}", response_model=PrintQueueItemResponse)
 async def get_queue_item(
     item_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
@@ -2383,7 +2383,7 @@ async def get_queue_item(
 async def update_queue_item(
     item_id: int,
     data: PrintQueueItemUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_UPDATE_ALL,
@@ -2634,7 +2634,7 @@ def _notify_dispatch_cancelled(item_ids: list[int]) -> None:
 @router.delete("/{item_id}")
 async def delete_queue_item(
     item_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_DELETE_ALL,
@@ -2688,7 +2688,7 @@ async def delete_queue_item(
 @router.post("/reorder")
 async def reorder_queue(
     data: PrintQueueReorder,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -2708,7 +2708,7 @@ async def reorder_queue(
 @router.post("/printer/{printer_id}/resume")
 async def resume_queue_after_failure(
     printer_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
 ):
     """Clear the previous-success gate for a printer and restore skipped items.
@@ -2766,7 +2766,7 @@ async def resume_queue_after_failure(
 @router.post("/model/{model}/resume")
 async def resume_model_queue_after_failure(
     model: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -2806,7 +2806,7 @@ async def resume_model_queue_after_failure(
 @router.post("/{item_id}/cancel")
 async def cancel_queue_item(
     item_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_UPDATE_ALL,
@@ -2855,7 +2855,7 @@ async def cancel_queue_item(
 @router.post("/{item_id}/stop")
 async def stop_queue_item(
     item_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_UPDATE_ALL,
@@ -2989,7 +2989,7 @@ async def stop_queue_item(
 async def start_queue_item(
     item_id: int,
     skip_filament_check: bool = Query(default=False),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_UPDATE_ALL,

+ 5 - 5
backend/app/api/routes/printer_locations.py

@@ -161,7 +161,7 @@ async def _broadcast() -> None:
 
 @router.get("/", response_model=list[PrinterLocationResponse])
 async def list_printer_locations(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -189,7 +189,7 @@ async def list_printer_locations(
 @router.post("/", response_model=PrinterLocationResponse, status_code=201)
 async def create_printer_location(
     data: PrinterLocationCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
 ):
     """Create a location, with or without printers in it yet."""
@@ -215,7 +215,7 @@ async def create_printer_location(
 @router.patch("/", response_model=PrinterLocationResponse)
 async def update_printer_location(
     data: PrinterLocationUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -280,7 +280,7 @@ async def update_printer_location(
 @router.post("/delete", response_model=PrinterLocationDeleteResult)
 async def delete_printer_locations(
     data: PrinterLocationDelete,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -317,7 +317,7 @@ async def delete_printer_locations(
 @router.post("/assign", response_model=PrinterLocationAssignResult)
 async def assign_printer_location(
     data: PrinterLocationAssign,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):

+ 1 - 1
backend/app/api/routes/printer_sensor_history.py

@@ -45,7 +45,7 @@ async def get_printer_sensor_history(
         default=None,
         description="Comma-separated list of sensor kinds (nozzle, nozzle_2, bed, chamber). All by default.",
     ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ),
 ):
     """Return per-sensor heater history for a printer."""

+ 57 - 57
backend/app/api/routes/printers.py

@@ -154,7 +154,7 @@ def _serialize_printer(printer: Printer, *, include_secret: bool):
 async def list_printers(
     user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """List the configured printers the caller may see (#1727).
 
@@ -175,7 +175,7 @@ async def list_printers(
 async def create_printer(
     printer_data: PrinterCreate,
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Add a new printer.
 
@@ -256,7 +256,7 @@ async def get_available_filaments(
     location: str | None = Query(None, description="Optional location filter"),
     _=RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
     printer_scope: PrinterScope = RequestPrinterScope,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Get deduplicated list of filaments loaded across all active printers of a given model.
 
@@ -363,7 +363,7 @@ async def get_available_filaments(
 async def get_developer_mode_warnings(
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Check if any connected printer the caller can see lacks developer LAN mode."""
     result = await db.execute(select(Printer).where(Printer.is_active == True))  # noqa: E712
@@ -387,7 +387,7 @@ async def get_developer_mode_warnings(
 async def get_printer(
     printer_id: int,
     user: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Get a specific printer.
 
@@ -408,7 +408,7 @@ async def update_printer(
     printer_id: int,
     printer_data: PrinterUpdate,
     user: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Update a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -480,7 +480,7 @@ async def delete_printer(
     printer_id: int,
     delete_archives: bool = True,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_DELETE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Delete a printer.
 
@@ -628,7 +628,7 @@ async def delete_printer(
 async def get_printer_status(
     printer_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Get real-time status of a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -902,7 +902,7 @@ async def _printer_status(db: AsyncSession, printer: Printer) -> PrinterStatus:
 async def get_overlay_status(
     printer_id: int,
     _: None = RequireOverlayTokenIfAuthEnabled,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> dict:
     """Everything the streaming overlay (#2613) draws for one printer.
 
@@ -972,7 +972,7 @@ async def get_overlay_status(
 async def get_current_print_user(
     printer_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Get the user who started the current print (for reprint tracking).
 
@@ -995,7 +995,7 @@ async def get_current_print_user(
 async def refresh_printer_status(
     printer_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Request a full status refresh from the printer (sends pushall command)."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -1014,7 +1014,7 @@ async def refresh_printer_status(
 async def connect_printer(
     printer_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Manually connect to a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -1030,7 +1030,7 @@ async def connect_printer(
 async def disconnect_printer(
     printer_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Manually disconnect from a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -1169,7 +1169,7 @@ async def get_printer_cover(
     """
     # Fetch the printer in a short-lived session and release the pooled DB
     # connection BEFORE the FTP download below. Previously this route took its
-    # row via Depends(get_db), whose session stays open for the whole request —
+    # row via Depends(get_db, scope="function"), whose session stays open for the whole request —
     # so a 3MF cover download (up to 8 paths × 3 retries with backoff, minutes
     # under FTP contention) pinned one pooled connection idle-in-transaction the
     # entire time (issue #2572). db is used only for this one SELECT; everything
@@ -1607,7 +1607,7 @@ async def _load_printer_or_404(printer_id: int) -> Printer:
 
     The file-manager and storage routes talk FTP to the printer, which can
     block for the full socket timeout — longer when a saturated FTP pool backs
-    up. Holding the request's Depends(get_db) session across that FTP pinned one
+    up. Holding the request's Depends(get_db, scope="function") session across that FTP pinned one
     pooled connection idle-in-transaction per in-flight request, a top cause of
     pool exhaustion on large farms. The returned row's scalar columns stay
     readable after the session closes (expire_on_commit=False). Raises 404 when
@@ -2217,7 +2217,7 @@ async def get_printer_storage(
 async def enable_mqtt_logging(
     printer_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Enable MQTT message logging for a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -2236,7 +2236,7 @@ async def enable_mqtt_logging(
 async def disable_mqtt_logging(
     printer_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Disable MQTT message logging for a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -2255,7 +2255,7 @@ async def disable_mqtt_logging(
 async def get_mqtt_logs(
     printer_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Get MQTT message logs for a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -2282,7 +2282,7 @@ async def get_mqtt_logs(
 async def clear_mqtt_logs(
     printer_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Clear MQTT message logs for a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -2313,7 +2313,7 @@ async def start_drying(
     filament: str = "",
     rotate_tray: bool = False,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Send AMS drying start command. temp=45-85, duration=hours."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -2361,7 +2361,7 @@ async def stop_drying(
     printer_id: int,
     ams_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Send AMS drying stop command."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -2402,7 +2402,7 @@ async def set_print_option(
     print_halt: bool = True,
     sensitivity: str = "medium",
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Set an AI detection / print option on the printer.
 
@@ -2466,7 +2466,7 @@ async def set_ams_backup(
     printer_id: int,
     enabled: bool,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Toggle AMS Filament Backup (auto-switch to a backup spool when one runs out)."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -2489,7 +2489,7 @@ async def set_ams_backup(
 async def get_inventory_remain(
     printer_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Per-globalTrayId remaining grams for slots bound to an inventory spool.
 
@@ -2539,7 +2539,7 @@ async def start_calibration(
     nozzle_offset: bool = False,
     high_temp_heatbed: bool = False,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Start printer calibration with selected options.
 
@@ -2604,7 +2604,7 @@ def _slot_preset_key(ams_id: int, tray_id: int) -> int:
 async def get_slot_presets(
     printer_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Get all saved slot-to-preset mappings for a printer."""
     result = await db.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer_id))
@@ -2631,7 +2631,7 @@ async def get_slot_preset(
     ams_id: int,
     tray_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Get the saved preset for a specific slot."""
     result = await db.execute(
@@ -2665,7 +2665,7 @@ async def save_slot_preset(
     preset_source: str = "cloud",
     tray_info_idx: str | None = None,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Save a preset mapping for a specific slot.
 
@@ -2730,7 +2730,7 @@ async def delete_slot_preset(
     ams_id: int,
     tray_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Delete a saved preset mapping for a slot."""
     result = await db.execute(
@@ -2754,7 +2754,7 @@ async def get_slot_spool_defaults(
     printer_id: int,
     ams_id: int,
     tray_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
 ):
     """What the spool assigned to this slot is configured to use here.
@@ -2878,7 +2878,7 @@ async def configure_ams_slot(
     kprofile_setting_id: str = Query(""),
     k_value: float = Query(0.0),
     orca_profile_id: str = Query(""),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
 ):
     """Configure an AMS slot with a specific filament setting and K profile.
@@ -3387,7 +3387,7 @@ async def reset_ams_slot(
     printer_id: int,
     ams_id: int,
     tray_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
 ):
     """Reset an AMS slot to empty/unconfigured state.
@@ -3434,7 +3434,7 @@ async def reset_ams_slot(
 async def get_ams_labels(
     printer_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Get all user-defined AMS labels for a printer, keyed by AMS unit ID.
 
@@ -3494,7 +3494,7 @@ async def save_ams_label(
     ams_id: int,
     body: AmsLabelBody,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Create or update the friendly name for a specific AMS unit.
 
@@ -3531,7 +3531,7 @@ async def delete_ams_label(
     ams_id: int,
     ams_serial: str = Query(default="", max_length=50),
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Delete the friendly name for a specific AMS unit, reverting to the auto label."""
     stripped = ams_serial.strip() if ams_serial else ""
@@ -3556,7 +3556,7 @@ async def delete_ams_label(
 async def stop_print(
     printer_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Stop/cancel the current print job."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -3582,7 +3582,7 @@ async def stop_print(
 async def clear_plate(
     printer_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CLEAR_PLATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Acknowledge that the build plate has been cleared after a finished/failed print.
 
@@ -3637,7 +3637,7 @@ async def clear_plate(
 async def pause_print(
     printer_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Pause the current print job."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -3660,7 +3660,7 @@ async def pause_print(
 async def resume_print(
     printer_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Resume a paused print job."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -3684,7 +3684,7 @@ async def set_print_speed(
     printer_id: int,
     mode: int = Query(..., ge=1, le=4, description="Speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)"),
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Set the print speed mode."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -3710,7 +3710,7 @@ async def set_nozzle_temperature(
     target: int = Query(..., ge=0, le=320, description="Target nozzle temperature in Celsius; 0 turns heating off"),
     nozzle: int = Query(0, ge=0, le=1, description="Nozzle/extruder index (0=right/default, 1=left)"),
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Set a nozzle target temperature."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -3734,7 +3734,7 @@ async def set_bed_temperature(
     printer_id: int,
     target: int = Query(..., ge=0, le=140, description="Target bed temperature in Celsius; 0 turns heating off"),
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Set the bed target temperature."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -3763,7 +3763,7 @@ async def set_chamber_temperature(
         description="Target chamber temperature in Celsius; 0 turns heating off",
     ),
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Set the chamber target temperature.
 
@@ -3797,7 +3797,7 @@ async def set_fan_speed(
     fan: str = Query(..., description="Fan to control: part, aux, aux2 (left aux), or chamber"),
     speed: int = Query(..., ge=0, le=100, description="Fan speed percentage"),
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Set a fan speed by percentage.
 
@@ -3859,7 +3859,7 @@ async def select_extruder(
     printer_id: int,
     extruder: int = Query(..., ge=0, le=1, description="Extruder index (0=right, 1=left)"),
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Select the active extruder/nozzle on dual-nozzle printers."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -3883,7 +3883,7 @@ async def set_airduct_mode(
     printer_id: int,
     mode: str = Query(..., description="Airduct mode: 'cooling' or 'heating'"),
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Set the airduct mode (cooling/heating) on supported printers (P2S/H2*)."""
     if mode not in ("cooling", "heating"):
@@ -3910,7 +3910,7 @@ async def set_chamber_light(
     printer_id: int,
     on: bool = Query(..., description="True to turn on, False to turn off"),
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Turn the chamber light on or off."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -3946,7 +3946,7 @@ async def bed_jog(
         ),
     ),
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Adjust the nozzle-bed gap by a relative distance.
 
@@ -4025,7 +4025,7 @@ async def xy_jog(
     x: float = Query(0, description="Signed relative X movement in mm"),
     y: float = Query(0, description="Signed relative Y movement in mm"),
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Move the toolhead by a relative X/Y distance."""
     if (x == 0 and y == 0) or abs(x) > 200 or abs(y) > 200:
@@ -4062,7 +4062,7 @@ async def extruder_jog(
         ..., description="Signed relative extrusion distance in mm. Positive extrudes, negative retracts."
     ),
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Extrude or retract filament by a relative distance.
 
@@ -4096,7 +4096,7 @@ async def home_axes(
         description="Legacy; accepted values are 'z' | 'xy' | 'all'. Always runs the printer's full auto-home sequence — see below.",
     ),
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Run the printer's full auto-home sequence via bare `G28`.
 
@@ -4136,7 +4136,7 @@ async def home_axes(
 async def clear_hms_errors(
     printer_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Clear HMS/print errors on the printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -4321,7 +4321,7 @@ async def skip_objects(
     printer_id: int,
     object_ids: list[int],
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Skip specific objects during the current print.
 
@@ -4376,7 +4376,7 @@ async def refresh_ams_slot(
     ams_id: int,
     slot_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_AMS_RFID),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Re-read RFID for an AMS slot (triggers filament info refresh)."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -4652,7 +4652,7 @@ async def ams_load(
         ),
     ),
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Load filament from a specific AMS slot or external spool.
 
@@ -4703,7 +4703,7 @@ async def ams_unload(
         ),
     ),
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Unload the filament in a given slot, or the currently loaded one."""
     if tray_id is not None and not _is_valid_load_tray_id(tray_id):
@@ -4735,7 +4735,7 @@ async def execute_hms_action(
     printer_id: int,
     body: HmsActionBody,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Execute an HMS action on the printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))

+ 25 - 25
backend/app/api/routes/projects.py

@@ -429,7 +429,7 @@ async def compute_subtree_stats(db: AsyncSession, root_id: int) -> _SubtreeRepor
 @router.get("/", response_model=list[ProjectListResponse])
 async def list_projects(
     status: str | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -560,7 +560,7 @@ async def list_projects(
 @router.post("/", response_model=ProjectResponse)
 async def create_project(
     data: ProjectCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
 ):
     """Create a new project."""
@@ -630,7 +630,7 @@ async def create_project(
 
 @router.get("/templates", response_model=list[ProjectListResponse])
 async def list_templates(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """List all project templates."""
@@ -676,7 +676,7 @@ async def list_templates(
 async def create_project_from_template(
     template_id: int,
     name: str = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
 ):
     """Create a new project from a template."""
@@ -765,7 +765,7 @@ async def create_project_from_template(
 @router.get("/{project_id}", response_model=ProjectResponse)
 async def get_project(
     project_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """Get a project by ID with detailed stats."""
@@ -819,7 +819,7 @@ async def get_project(
 async def update_project(
     project_id: int,
     data: ProjectUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Update a project."""
@@ -930,7 +930,7 @@ async def update_project(
 @router.delete("/{project_id}")
 async def delete_project(
     project_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_DELETE),
 ):
     """Delete a project. Archives and queue items will have project_id set to NULL."""
@@ -956,7 +956,7 @@ async def list_project_archives(
     project_id: int,
     limit: int = 100,
     offset: int = 0,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -999,7 +999,7 @@ async def list_project_archives(
 @router.get("/{project_id}/queue")
 async def list_project_queue(
     project_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -1022,7 +1022,7 @@ async def list_project_queue(
 @router.get("/{project_id}/file-progress", response_model=list[ProjectFileProgress])
 async def get_project_file_progress(
     project_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """Completed-run counts per library file inside a project (#1897).
@@ -1130,7 +1130,7 @@ def _editable_rows(
 async def add_archives_to_project(
     project_id: int,
     data: BatchAddArchives,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
     actor: User | ApiKeyActor | None = RequestActor,
@@ -1160,7 +1160,7 @@ async def add_archives_to_project(
 async def add_queue_items_to_project(
     project_id: int,
     data: BatchAddQueueItems,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
     actor: User | ApiKeyActor | None = RequestActor,
@@ -1190,7 +1190,7 @@ async def add_queue_items_to_project(
 async def remove_archives_from_project(
     project_id: int,
     data: BatchAddArchives,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
     actor: User | ApiKeyActor | None = RequestActor,
@@ -1238,7 +1238,7 @@ COVER_IMAGE_CONTENT_TYPES = {
 async def upload_project_cover_image(
     project_id: int,
     file: UploadFile = File(...),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Upload (or replace) the project's cover image (#1155).
@@ -1300,7 +1300,7 @@ async def upload_project_cover_image(
 @router.get("/{project_id}/cover-image")
 async def get_project_cover_image(
     project_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_media_token_permission(Permission.PROJECTS_READ)),
 ):
     """Stream the project's cover image (#1155).
@@ -1339,7 +1339,7 @@ async def get_project_cover_image(
 @router.delete("/{project_id}/cover-image")
 async def delete_project_cover_image(
     project_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Remove the project's cover image (#1155)."""
@@ -1411,7 +1411,7 @@ def _bom_response(item: ProjectBOMItem, visible_names: dict[int, str | None]) ->
 @router.get("/{project_id}/bom", response_model=list[BOMItemResponse])
 async def list_bom_items(
     project_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -1437,7 +1437,7 @@ async def list_bom_items(
 async def create_bom_item(
     project_id: int,
     data: BOMItemCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -1481,7 +1481,7 @@ async def update_bom_item(
     project_id: int,
     item_id: int,
     data: BOMItemUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -1528,7 +1528,7 @@ async def update_bom_item(
 async def delete_bom_item(
     project_id: int,
     item_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Delete a BOM item."""
@@ -1551,7 +1551,7 @@ async def delete_bom_item(
 @router.post("/{project_id}/create-template", response_model=ProjectResponse)
 async def create_template_from_project(
     project_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
 ):
     """Create a template from an existing project."""
@@ -1657,7 +1657,7 @@ def _queue_item_name(item: PrintQueueItem) -> str | None:
 async def get_project_timeline(
     project_id: int,
     limit: int = 50,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -1790,7 +1790,7 @@ def _stored_library_path(stored: str | None) -> Path | None:
 async def export_project(
     project_id: int,
     format: str = "zip",  # "zip" (with files) or "json" (metadata only)
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
     actor: User | ApiKeyActor | None = RequestActor,
 ):
@@ -1960,7 +1960,7 @@ async def _import_folder(
 @router.post("/import", response_model=ProjectResponse)
 async def import_project(
     data: ProjectImport,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
     actor: User | ApiKeyActor | None = RequestActor,
 ):
@@ -2038,7 +2038,7 @@ async def import_project(
 @router.post("/import/file", response_model=ProjectResponse)
 async def import_project_file(
     file: UploadFile = File(...),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
     actor: User | ApiKeyActor | None = RequestActor,
 ):

+ 3 - 3
backend/app/api/routes/scheduled_dryings.py

@@ -31,7 +31,7 @@ async def create_scheduled_drying(
     payload: ScheduledDryingCreate,
     user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     printer_scope: PrinterScope = RequestPrinterScope,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     printer_scope.ensure(payload.printer_id)
     result = await db.execute(select(Printer).where(Printer.id == payload.printer_id))
@@ -74,7 +74,7 @@ async def list_scheduled_dryings(
     printer_id: int | None = None,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     query = select(ScheduledDrying).where(ScheduledDrying.status.in_(LISTED_STATUSES))
     if (clause := printer_scope.where_strict(ScheduledDrying.printer_id)) is not None:
@@ -90,7 +90,7 @@ async def cancel_scheduled_drying(
     scheduled_drying_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     printer_scope: PrinterScope = RequestPrinterScope,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     result = await db.execute(select(ScheduledDrying).where(ScheduledDrying.id == scheduled_drying_id))
     row = result.scalar_one_or_none()

+ 15 - 15
backend/app/api/routes/settings.py

@@ -329,7 +329,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False, m
 @router.get("", response_model=AppSettings)
 @router.get("/", response_model=AppSettings)
 async def get_settings(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     _is_api_key: bool = Depends(caller_is_api_key),
 ):
@@ -354,7 +354,7 @@ def _same_setting_value(new: object, old: object) -> bool:
 @router.put("/", response_model=AppSettings)
 async def update_settings(
     settings_update: AppSettingsUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Update application settings."""
@@ -509,7 +509,7 @@ async def update_settings(
 @router.patch("", response_model=AppSettings)
 async def patch_settings(
     settings_update: AppSettingsUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Partially update application settings (same as PUT, for REST compatibility)."""
@@ -531,7 +531,7 @@ class ElectricityPriceUpdate(BaseModel):
 @router.post("/electricity-price", response_model=AppSettings)
 async def update_electricity_price(
     payload: ElectricityPriceUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: ScopedCaller = Depends(require_energy_cost_update()),
     _is_api_key: bool = Depends(caller_is_api_key),
 ):
@@ -552,7 +552,7 @@ async def update_electricity_price(
 
 @router.post("/reset", response_model=AppSettings)
 async def reset_settings(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
@@ -570,7 +570,7 @@ async def reset_settings(
 
 @router.get("/default-sidebar-order")
 async def get_default_sidebar_order(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Get the admin-set default sidebar order.
 
@@ -615,7 +615,7 @@ _UI_PREFERENCE_FIELDS: tuple[str, ...] = (
 
 
 @router.get("/ui-preferences")
-async def get_ui_preferences(db: AsyncSession = Depends(get_db)):
+async def get_ui_preferences(db: AsyncSession = Depends(get_db, scope="function")):
     """Get the curated subset of settings that any page needs to render correctly.
 
     Intentionally not gated on SETTINGS_READ — every authenticated user (and
@@ -702,7 +702,7 @@ _UI_FLAG_FIELDS: tuple[str, ...] = (
 
 @router.get("/ui-flags")
 async def get_ui_flags(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = Depends(require_auth_if_enabled),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -752,7 +752,7 @@ async def check_ffmpeg(
 
 @router.get("/spoolman")
 async def get_spoolman_settings(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
 ):
     """Get Spoolman integration settings."""
@@ -776,7 +776,7 @@ async def get_spoolman_settings(
 @router.put("/spoolman")
 async def update_spoolman_settings(
     settings: dict,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Update Spoolman integration settings.
@@ -1029,7 +1029,7 @@ async def create_backup_zip(output_path: Path | None = None) -> tuple[Path, str]
 
 @router.get("/backup")
 async def create_backup(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
 ):
@@ -1468,7 +1468,7 @@ async def _import_sqlite_to_postgres(sqlite_path: Path):
 @router.post("/restore")
 async def restore_backup(
     file: UploadFile = File(...),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_RESTORE),
 ):
@@ -1654,7 +1654,7 @@ async def restore_backup(
                 # checkpointed yet (seed_default_groups + init_db on first
                 # start, plus whatever background heartbeats wrote during
                 # the request window) sits in bambuddy.db-wal with valid
-                # checksums. The route handler's own `db: Depends(get_db)`
+                # checksums. The route handler's own `db: Depends(get_db, scope="function")`
                 # session also keeps a connection checked out across
                 # engine.dispose(), holding fds to the WAL inode. With
                 # `shutil.copy2` SQLite finds the stale WAL on the next
@@ -1830,7 +1830,7 @@ def _legacy_vp_status(status: dict, printer_scope: PrinterScope) -> dict:
 
 @router.get("/virtual-printer")
 async def get_virtual_printer_settings(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -1861,7 +1861,7 @@ async def update_virtual_printer_settings(
     remote_interface_ip: str = None,
     tailscale_disabled: bool = None,
     archive_name_source: str = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):

+ 5 - 5
backend/app/api/routes/slicer_pipelines.py

@@ -66,7 +66,7 @@ def _to_response(row: SlicerPipeline) -> SlicerPipelineResponse:
 @router.get("/", response_model=SlicerPipelineListResponse)
 async def list_pipelines(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """List all pipelines, newest first. Soft-deleted rows are hidden."""
     result = await db.execute(
@@ -80,7 +80,7 @@ async def list_pipelines(
 async def create_pipeline(
     data: SlicerPipelineCreate,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Create a new pipeline."""
     row = SlicerPipeline(
@@ -104,7 +104,7 @@ async def create_pipeline(
 async def get_pipeline(
     pipeline_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Read one pipeline by id."""
     result = await db.execute(
@@ -124,7 +124,7 @@ async def update_pipeline(
     pipeline_id: int,
     data: SlicerPipelineUpdate,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Update a pipeline. Only fields present in the payload are written."""
     result = await db.execute(
@@ -184,7 +184,7 @@ async def update_pipeline(
 async def delete_pipeline(
     pipeline_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Soft-delete a pipeline (sets is_deleted=True so PR B+ run history can
     still resolve pipeline metadata)."""

+ 4 - 4
backend/app/api/routes/slicer_presets.py

@@ -602,7 +602,7 @@ async def get_preset_values(
     source: str = Query(..., description="Preset tier: 'local', 'cloud', 'orca_cloud' or 'standard'."),
     id: str = Query(..., description="Preset id within that tier."),
     slot: str = Query("process", description="Preset slot. Only 'process' is supported today."),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
 ) -> dict:
@@ -664,7 +664,7 @@ async def get_preset_values(
 
 @router.get("/presets", response_model=UnifiedPresetsResponse)
 async def list_unified_presets(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     refresh: bool = Query(
@@ -736,7 +736,7 @@ def _loaded_spool_tray(raw: dict, ams_id: int, tray_id: int, saved: SlotPresetMa
 
 @router.get("/loaded-spools", response_model=LoadedSpoolsResponse)
 async def list_loaded_spools(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD, Permission.PRINTERS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ) -> LoadedSpoolsResponse:
@@ -818,7 +818,7 @@ async def list_loaded_spools(
 @router.get("/preview-progress/{request_id}")
 async def get_preview_slice_progress(
     request_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,

+ 14 - 14
backend/app/api/routes/smart_plugs.py

@@ -80,7 +80,7 @@ def _serialize_plug(plug: SmartPlug, *, can_edit: bool) -> SmartPlugResponse:
 
 @router.get("/", response_model=None)
 async def list_smart_plugs(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
     can_edit: bool = CallerEditsPlugs,
@@ -111,7 +111,7 @@ async def _printer_has_other_tasmota_plug(
 @router.post("/", response_model=SmartPlugResponseWithSecret)
 async def create_smart_plug(
     data: SmartPlugCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -298,7 +298,7 @@ class PrinterCardPlugs(BaseModel):
 @router.get("/by-printer/{printer_id}", response_model=None)
 async def get_smart_plug_by_printer(
     printer_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     can_edit: bool = CallerEditsPlugs,
 ):
@@ -315,7 +315,7 @@ async def get_smart_plug_by_printer(
 @router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
 async def get_script_plugs_by_printer(
     printer_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
 ):
     """Get all HA entities assigned to a printer for display on printer card.
@@ -485,7 +485,7 @@ async def get_discovered_tasmota_devices(
 @router.post("/ha/test-connection", response_model=HATestConnectionResponse)
 async def test_ha_connection(
     request: HATestConnectionRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
     can_update_settings: bool = Depends(probe_permissions_if_auth_enabled(Permission.SETTINGS_UPDATE)),
 ):
@@ -512,7 +512,7 @@ async def test_ha_connection(
 @router.post("/rest/test-connection", response_model=RESTTestConnectionResponse)
 async def test_rest_connection(
     request: RESTTestConnectionRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
     can_update: bool = Depends(probe_permissions_if_auth_enabled(Permission.SMART_PLUGS_UPDATE)),
     printer_scope: PrinterScope = RequestPrinterScope,
@@ -536,7 +536,7 @@ async def test_rest_connection(
 
 @router.get("/ha/entities", response_model=list[HAEntity])
 async def list_ha_entities(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     search: str | None = None,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
 ):
@@ -564,7 +564,7 @@ async def list_ha_entities(
 
 @router.get("/ha/sensors", response_model=list[HASensorEntity])
 async def list_ha_sensor_entities(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
 ):
     """List available Home Assistant sensor entities for energy monitoring.
@@ -590,7 +590,7 @@ async def list_ha_sensor_entities(
 @router.get("/{plug_id}", response_model=None)
 async def get_smart_plug(
     plug_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
     can_edit: bool = CallerEditsPlugs,
@@ -607,7 +607,7 @@ async def get_smart_plug(
 async def update_smart_plug(
     plug_id: int,
     data: SmartPlugUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -730,7 +730,7 @@ async def delete_plug(db: AsyncSession, plug: SmartPlug) -> None:
 @router.delete("/{plug_id}")
 async def delete_smart_plug(
     plug_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -769,7 +769,7 @@ async def _get_service_for_plug(plug: SmartPlug, db: AsyncSession):
 async def control_smart_plug(
     plug_id: int,
     control: SmartPlugControl,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -841,7 +841,7 @@ async def control_smart_plug(
 @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
 async def get_plug_status(
     plug_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -920,7 +920,7 @@ async def get_plug_status(
 @router.post("/test-connection")
 async def test_connection(
     data: SmartPlugTestConnection,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
     can_update: bool = Depends(probe_permissions_if_auth_enabled(Permission.SMART_PLUGS_UPDATE)),
     printer_scope: PrinterScope = RequestPrinterScope,

+ 2 - 2
backend/app/api/routes/sponsor_prompt.py

@@ -27,7 +27,7 @@ def _user_id(current_user: User | None) -> int | None:
 @router.get("/check", response_model=SponsorPromptCheckResponse)
 async def check_sponsor_prompt(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Return the next eligible sponsor-toast trigger, or `{show: false}`."""
     trigger = await service.evaluate(db, _user_id(current_user))
@@ -47,7 +47,7 @@ async def check_sponsor_prompt(
 async def dismiss_sponsor_prompt(
     data: SponsorPromptDismissRequest,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Anchor the 14-day cooldown and record the milestone as shown."""
     await service.dismiss(db, _user_id(current_user), data.milestone)

+ 23 - 23
backend/app/api/routes/spoolbuddy.py

@@ -193,7 +193,7 @@ def _should_broadcast_online(device_id: str, force: bool = False) -> bool:
 @router.post("/devices/register", response_model=DeviceResponse)
 async def register_device(
     req: DeviceRegisterRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Register or re-register a SpoolBuddy device."""
@@ -264,7 +264,7 @@ async def register_device(
 
 @router.get("/devices", response_model=list[DeviceResponse])
 async def list_devices(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """List all registered SpoolBuddy devices."""
@@ -276,7 +276,7 @@ async def list_devices(
 @router.delete("/devices/{device_id}")
 async def unregister_device(
     device_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_DELETE),
 ):
     """Unregister a SpoolBuddy device. The daemon can re-register via heartbeat later."""
@@ -297,7 +297,7 @@ async def unregister_device(
 async def device_heartbeat(
     device_id: str,
     req: HeartbeatRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Daemon heartbeat — updates status and returns pending commands."""
@@ -442,7 +442,7 @@ async def _backfill_spoolman_tray_uuid(client, sm_spool: dict, tag_uid: str, tra
 @router.post("/nfc/tag-scanned")
 async def nfc_tag_scanned(
     req: TagScannedRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """RPi reports NFC tag detected — lookup spool and broadcast.
@@ -662,7 +662,7 @@ async def nfc_tag_removed(
 @router.post("/nfc/write-tag")
 async def nfc_write_tag(
     req: WriteTagRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Queue an NFC tag write command for a SpoolBuddy device.
@@ -783,7 +783,7 @@ async def nfc_write_tag(
 @router.post("/nfc/write-result")
 async def nfc_write_result(
     req: WriteTagResultRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Handle NFC tag write result from SpoolBuddy daemon."""
@@ -987,7 +987,7 @@ async def nfc_write_result(
 @router.post("/devices/{device_id}/cancel-write")
 async def cancel_write(
     device_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Cancel a pending write-tag command."""
@@ -1029,7 +1029,7 @@ async def scale_reading(
 @router.post("/scale/update-spool-weight")
 async def update_spool_weight(
     req: UpdateSpoolWeightRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Update spool's used weight from scale reading.
@@ -1115,7 +1115,7 @@ async def update_spool_weight(
 @router.post("/devices/{device_id}/calibration/tare")
 async def tare_scale(
     device_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Set pending tare command for the device to pick up."""
@@ -1133,7 +1133,7 @@ async def tare_scale(
 async def set_tare_offset(
     device_id: str,
     req: SetTareRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Store tare offset reported by the daemon after executing a tare."""
@@ -1157,7 +1157,7 @@ async def set_tare_offset(
 async def set_calibration_factor(
     device_id: str,
     req: SetCalibrationFactorRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Calculate and store calibration factor from a known weight."""
@@ -1194,7 +1194,7 @@ async def set_calibration_factor(
 @router.get("/devices/{device_id}/calibration", response_model=CalibrationResponse)
 async def get_calibration(
     device_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """Get current calibration values for a device."""
@@ -1215,7 +1215,7 @@ async def get_calibration(
 @router.get("/devices/{device_id}/display")
 async def get_display_settings(
     device_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Read current display brightness and screen blank timeout for a device.
@@ -1238,7 +1238,7 @@ async def get_display_settings(
 async def update_display_settings(
     device_id: str,
     req: DisplaySettingsRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Update display brightness and screen blank timeout for a device."""
@@ -1264,7 +1264,7 @@ async def update_display_settings(
 async def queue_system_config_update(
     device_id: str,
     req: SystemConfigRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Queue update of SpoolBuddy .env config on the device."""
@@ -1301,7 +1301,7 @@ VALID_SYSTEM_COMMANDS = {"reboot", "shutdown", "restart_daemon", "restart_browse
 async def queue_system_command(
     device_id: str,
     req: SystemCommandRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     # Aligns with the rest of the kiosk-scoped device routes (calibration,
     # display, cancel-write, command-result — all INVENTORY_UPDATE). The
     # previous SETTINGS_UPDATE gate locked operators out of the QuickMenu's
@@ -1337,7 +1337,7 @@ async def queue_system_command(
 async def system_command_result(
     device_id: str,
     req: SystemCommandResultRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Receive completion status for queued system command from daemon."""
@@ -1378,7 +1378,7 @@ async def system_command_result(
 async def queue_diagnostic(
     device_id: str,
     diagnostic: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Queue a hardware diagnostic to run on the SpoolBuddy device.
@@ -1410,7 +1410,7 @@ async def queue_diagnostic(
 async def get_diagnostic_result(
     device_id: str,
     diagnostic: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """Get the latest diagnostic result for a device.
@@ -1440,7 +1440,7 @@ async def get_diagnostic_result(
 async def report_diagnostic_result(
     device_id: str,
     req: DiagnosticResultRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Report diagnostic result from SpoolBuddy device."""
@@ -1472,7 +1472,7 @@ async def report_diagnostic_result(
 @router.get("/devices/{device_id}/update-check")
 async def check_daemon_update(
     device_id: str,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """Check if the SpoolBuddy daemon needs updating to match the Bambuddy backend version."""
@@ -1497,7 +1497,7 @@ async def check_daemon_update(
 async def trigger_daemon_update(
     device_id: str,
     req: dict | None = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     # Aligns with the rest of the kiosk-scoped device routes (calibration,
     # display, cancel-write, system/command — all INVENTORY_UPDATE).
     # SETTINGS_UPDATE is on the API-key deny-list, which blocks the Update

+ 11 - 11
backend/app/api/routes/spoolman.py

@@ -113,7 +113,7 @@ async def get_spoolman_settings(db: AsyncSession) -> dict:
 
 @router.get("/status", response_model=SpoolmanStatus)
 async def get_spoolman_status(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
     """Get Spoolman integration status.
@@ -171,7 +171,7 @@ async def get_spoolman_status(
 
 @router.post("/connect")
 async def connect_spoolman(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Connect to Spoolman server using configured URL."""
@@ -256,7 +256,7 @@ async def _refresh_slot_preset(db: AsyncSession, spool: dict, tray, printer_id:
 @router.post("/sync/{printer_id}", response_model=SyncResult)
 async def sync_printer_ams(
     printer_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
 ):
     """Sync AMS data from a specific printer to Spoolman."""
@@ -488,7 +488,7 @@ async def sync_printer_ams(
 
 @router.post("/sync-all", response_model=SyncResult)
 async def sync_all_printers(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -690,7 +690,7 @@ async def sync_all_printers(
 
 @router.get("/spools")
 async def get_spools(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
     """Get all spools from Spoolman."""
@@ -715,7 +715,7 @@ async def get_spools(
 
 @router.get("/filaments")
 async def get_filaments(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
     """Get all filaments from Spoolman."""
@@ -752,7 +752,7 @@ class UnlinkedSpool(BaseModel):
 
 @router.get("/spools/unlinked", response_model=list[UnlinkedSpool])
 async def get_unlinked_spools(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
     """Get all Spoolman spools not currently assigned to an AMS slot."""
@@ -806,7 +806,7 @@ async def get_unlinked_spools(
 
 @router.get("/spools/linked")
 async def get_linked_spools(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
     """Get a map of tag -> spool_id for all Spoolman spools that have a tag assigned."""
@@ -862,7 +862,7 @@ class LinkSpoolRequest(BaseModel):
 async def link_spool(
     spool_id: int,
     request: LinkSpoolRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -1248,7 +1248,7 @@ async def link_spool(
 @router.post("/spools/{spool_id}/unlink")
 async def unlink_spool(
     spool_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
 ):
     """Unlink a Spoolman spool from AMS by clearing Spoolman extra.tag."""
@@ -1311,7 +1311,7 @@ class CreateSpoolFromSlotRequest(BaseModel):
 @router.post("/spools/from-slot")
 async def create_spool_from_slot(
     req: CreateSpoolFromSlotRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):

+ 30 - 30
backend/app/api/routes/spoolman_inventory.py

@@ -468,7 +468,7 @@ class SpoolSlotAssignmentRequest(BaseModel):
 @router.get("/spools")
 async def list_spools(
     include_archived: bool = Query(False),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ) -> list[dict]:
     """Return all Spoolman spools in the InventorySpool format."""
@@ -521,7 +521,7 @@ async def list_spools(
 @router.get("/spools/{spool_id}")
 async def get_spool(
     spool_id: int = Path(..., gt=0),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ) -> dict:
     """Return a single Spoolman spool in the InventorySpool format."""
@@ -574,7 +574,7 @@ async def _resolve_filament_id(data: SpoolmanInventoryCreate, client: SpoolmanCl
 @router.post("/spools")
 async def create_spool(
     data: SpoolmanInventoryCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> dict:
     """Create a new spool in Spoolman, auto-creating vendor and filament as needed."""
@@ -656,7 +656,7 @@ async def create_spool(
 @router.post("/spools/bulk")
 async def bulk_create_spools(
     payload: SpoolmanInventoryBulkCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> Response:
     """Create multiple identical spools in Spoolman."""
@@ -745,7 +745,7 @@ async def update_spool(
     *,
     spool_id: int = Path(..., gt=0),
     data: SpoolmanInventoryUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> dict:
     """Update an existing Spoolman spool, re-linking the filament if metadata changed."""
@@ -1052,7 +1052,7 @@ async def _purge_local_rows_for_spool(db: AsyncSession, spool_id: int) -> None:
 @router.delete("/spools/{spool_id}")
 async def delete_spool(
     spool_id: int = Path(..., gt=0),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> dict:
     """Permanently delete a spool from Spoolman."""
@@ -1072,7 +1072,7 @@ async def delete_spool(
 @router.post("/spools/{spool_id}/archive")
 async def archive_spool(
     spool_id: int = Path(..., gt=0),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> dict:
     """Archive a spool in Spoolman (soft-delete)."""
@@ -1095,7 +1095,7 @@ async def archive_spool(
 @router.post("/spools/{spool_id}/restore")
 async def restore_spool(
     spool_id: int = Path(..., gt=0),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> dict:
     """Restore an archived spool in Spoolman."""
@@ -1114,7 +1114,7 @@ async def restore_spool(
 @router.post("/spools/{spool_id}/reset-consumed-counter")
 async def reset_spool_consumed_counter(
     spool_id: int = Path(..., gt=0),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> dict:
     """Zero the displayed "Total Consumed" counter for a Spoolman spool.
@@ -1155,7 +1155,7 @@ class SpoolmanBulkIdsRequest(BaseModel):
 @router.post("/spools/bulk-update")
 async def bulk_update_spools(
     payload: SpoolmanBulkUpdateRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> dict:
     """Apply the same partial update to every listed Spoolman spool.
@@ -1188,7 +1188,7 @@ async def bulk_update_spools(
 @router.post("/spools/bulk-delete")
 async def bulk_delete_spools(
     payload: SpoolmanBulkIdsRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> dict:
     """Hard-delete every listed Spoolman spool. Per-spool failures are collected."""
@@ -1218,7 +1218,7 @@ async def bulk_delete_spools(
 @router.post("/spools/bulk-archive")
 async def bulk_archive_spools(
     payload: SpoolmanBulkIdsRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> dict:
     """Archive every listed Spoolman spool. Per-spool failures are collected."""
@@ -1247,7 +1247,7 @@ async def bulk_archive_spools(
 @router.post("/spools/bulk-restore")
 async def bulk_restore_spools(
     payload: SpoolmanBulkIdsRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> dict:
     """Restore every listed archived Spoolman spool. Per-spool failures are collected."""
@@ -1272,7 +1272,7 @@ async def bulk_restore_spools(
 @router.post("/spools/reset-consumed-counter-bulk")
 async def bulk_reset_spool_consumed_counter(
     payload: dict = Body(...),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> dict:
     """Bulk reset the "Total Consumed" counter across the given Spoolman spool IDs.
@@ -1311,7 +1311,7 @@ async def sync_spool_weight(
     *,
     spool_id: int = Path(..., gt=0),
     data: SpoolWeightUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> dict:
     """Update a spool's remaining weight from a measured gross weight.
@@ -1353,7 +1353,7 @@ async def link_tag_to_spoolman_spool(
     *,
     spool_id: int = Path(..., gt=0),
     data: SpoolTagLinkRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> dict:
     """Write an NFC tag UID or Bambu tray UUID into Spoolman's extra.tag for a spool.
@@ -1450,7 +1450,7 @@ _AMS_CHIP_UID_PADDING = "00000100"
 @router.post("/tags/migrate")
 async def migrate_tags_to_native(
     dry_run: bool = Query(True, description="Only count what would move, change nothing"),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> dict:
     """Move tags from Spoolman's extra.tag into its native tags (Spoolman 0.27+).
@@ -1525,7 +1525,7 @@ async def migrate_tags_to_native(
 @router.get("/slot-assignments/all", response_model=list[SpoolmanSlotAssignmentEnriched])
 async def get_all_spoolman_slot_assignments(
     printer_id: int | None = Query(None, gt=0),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ) -> list[SpoolmanSlotAssignmentEnriched]:
@@ -1619,7 +1619,7 @@ async def get_all_spoolman_slot_assignments(
 
 @router.post("/sync-ams-weights")
 async def sync_spoolman_ams_weights(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -1770,7 +1770,7 @@ async def sync_spoolman_ams_weights(
 @router.post("/slot-assignments")
 async def assign_spoolman_slot(
     body: SpoolSlotAssignmentRequest,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ) -> dict:
@@ -2120,7 +2120,7 @@ async def assign_spoolman_slot(
 @router.delete("/slot-assignments/{spoolman_spool_id}")
 async def unassign_spoolman_slot(
     spoolman_spool_id: int = Path(..., gt=0),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ) -> dict:
@@ -2160,7 +2160,7 @@ async def get_spoolman_slot_assignment(
     printer_id: int = Query(..., gt=0),
     ams_id: int = Query(..., ge=0, le=7),
     tray_id: int = Query(..., ge=0, le=3),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ) -> dict | None:
@@ -2245,7 +2245,7 @@ def _filament_preset_to_dict(p: SpoolmanFilamentPreset) -> dict:
 @router.get("/spools/{spool_id}/filament-presets")
 async def get_spoolman_filament_presets(
     spool_id: int = Path(..., gt=0),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ) -> list[dict]:
     """Return all per-printer-model preset overrides for a Spoolman spool."""
@@ -2260,7 +2260,7 @@ async def get_spoolman_filament_presets(
 async def save_spoolman_filament_presets(
     spool_id: int = Path(..., gt=0),
     presets: list[SpoolFilamentPresetBase] = Body(...),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> list[dict]:
     """Replace all per-printer-model preset overrides for a Spoolman spool."""
@@ -2345,7 +2345,7 @@ def _normalize_filament(raw: dict) -> NormalizedFilament | None:
 
 @router.get("/filaments")
 async def list_spoolman_filaments(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ) -> list[NormalizedFilament]:
     """Return all filaments from Spoolman, normalised for the frontend catalog picker."""
@@ -2363,7 +2363,7 @@ async def patch_spoolman_filament(
     *,
     filament_id: int = Path(..., gt=0),
     body: SpoolmanFilamentPatch = Body(...),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> NormalizedFilament:
     """Update a Spoolman filament's name and/or spool_weight.
@@ -2455,7 +2455,7 @@ async def patch_spoolman_filament(
 @router.get("/spools/{spool_id}/k-profiles")
 async def get_spoolman_k_profiles(
     spool_id: int = Path(..., gt=0),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ) -> list[dict]:
     """Return all local K-value calibration profiles for a Spoolman spool."""
@@ -2469,7 +2469,7 @@ async def get_spoolman_k_profiles(
 async def save_spoolman_k_profiles(
     spool_id: int = Path(..., gt=0),
     profiles: list[SpoolKProfileBase] = Body(...),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> list[dict]:
     """Replace all K-value calibration profiles for a Spoolman spool."""
@@ -2525,7 +2525,7 @@ def _supplier_link_to_dict(link: SpoolmanSpoolSupplier) -> dict:
 @router.get("/spools/{spool_id}/suppliers")
 async def get_spoolman_spool_suppliers(
     spool_id: int = Path(..., gt=0),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ) -> list[dict]:
     """Supplier assignments for a Spoolman spool (#2988, Bambuddy-side rows)."""
@@ -2542,7 +2542,7 @@ async def get_spoolman_spool_suppliers(
 async def save_spoolman_spool_suppliers(
     spool_id: int = Path(..., gt=0),
     links: list[SpoolSupplierLinkInput] = Body(...),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> list[dict]:
     """Replace a Spoolman spool's supplier assignments (#2988).

+ 2 - 2
backend/app/api/routes/system.py

@@ -402,7 +402,7 @@ async def _get_storage_usage_cached(refresh: bool, max_age_seconds: int) -> dict
 
 @router.get("/info")
 async def get_system_info(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -602,7 +602,7 @@ async def get_storage_usage(
 
 @router.get("/health", response_model=ScanResult)
 async def get_system_health(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):

+ 2 - 2
backend/app/api/routes/updates.py

@@ -495,7 +495,7 @@ async def get_version():
 
 @router.get("/check")
 async def check_for_updates(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
 ):
     """Check GitHub for available updates."""
@@ -1003,7 +1003,7 @@ async def _perform_update(target_ref: str):
 @router.post("/apply")
 async def apply_update(
     background_tasks: BackgroundTasks,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Apply available update (git pull + rebuild)."""

+ 2 - 2
backend/app/api/routes/user_notifications.py

@@ -21,7 +21,7 @@ router = APIRouter(prefix="/user-notifications", tags=["user-notifications"])
 @router.get("/preferences", response_model=UserEmailPreferenceResponse)
 async def get_user_email_preferences(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_USER_EMAIL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Get the current user's email notification preferences.
 
@@ -55,7 +55,7 @@ async def get_user_email_preferences(
 async def update_user_email_preferences(
     data: UserEmailPreferenceUpdate,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_USER_EMAIL),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Update the current user's email notification preferences."""
     if current_user is None:

+ 8 - 8
backend/app/api/routes/users.py

@@ -82,7 +82,7 @@ def _user_to_response(user: User) -> UserResponse:
 @router.get("/", response_model=list[UserResponse])
 async def list_users(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """List all users.
 
@@ -120,7 +120,7 @@ async def create_user(
     user_data: UserCreate,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Create a new user.
 
@@ -227,7 +227,7 @@ async def create_user(
 @router.get("/slim", response_model=list[UserSlim])
 async def list_users_slim(
     _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.USERS_READ_SLIM, Permission.USERS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """List users as ``{id, username}`` only (#1894).
 
@@ -253,7 +253,7 @@ async def list_users_slim(
 async def get_user(
     user_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Get a user by ID. Read-only — gated on ``USERS_READ`` only."""
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
@@ -273,7 +273,7 @@ async def update_user(
     user_data: UserUpdate,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Update a user."""
     close_sockets = False
@@ -446,7 +446,7 @@ async def update_user(
 async def get_user_items_count(
     user_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Get count of items created by this user. Read-only — gated on
     ``USERS_READ`` only."""
@@ -490,7 +490,7 @@ async def delete_user(
     delete_items: bool = Query(False, description="Delete all items created by this user"),
     _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Delete a user.
 
@@ -733,7 +733,7 @@ async def change_own_password(
     password_data: ChangePasswordRequest,
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
     current_user: User | None = Depends(get_current_user_optional),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Change the current user's password. Requires current password verification."""
     if not current_user:

+ 6 - 6
backend/app/api/routes/virtual_printers.py

@@ -136,7 +136,7 @@ async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
 
 @router.get("")
 async def list_virtual_printers(
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -166,7 +166,7 @@ async def list_virtual_printers(
 @router.post("")
 async def create_virtual_printer(
     body: VirtualPrinterCreate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -330,7 +330,7 @@ async def get_ca_certificate(
 @router.get("/{vp_id}/diagnostic", response_model=VPDiagnosticResult)
 async def diagnose_virtual_printer(
     vp_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -355,7 +355,7 @@ async def diagnose_virtual_printer(
 @router.get("/{vp_id}")
 async def get_virtual_printer(
     vp_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -378,7 +378,7 @@ async def get_virtual_printer(
 async def update_virtual_printer(
     vp_id: int,
     body: VirtualPrinterUpdate,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
@@ -604,7 +604,7 @@ def remove_upload_dir(upload_dir: Path) -> None:
 @router.delete("/{vp_id}")
 async def delete_virtual_printer(
     vp_id: int,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):

+ 6 - 6
backend/app/api/routes/webhook.py

@@ -108,7 +108,7 @@ def _job_id(subtask_id) -> str | None:
 async def webhook_add_to_queue(
     data: QueueAddRequest,
     api_key: APIKey = Depends(get_api_key),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Add a print to the queue via webhook.
 
@@ -190,7 +190,7 @@ async def webhook_add_to_queue(
 async def webhook_start_print(
     printer_id: int,
     api_key: APIKey = Depends(get_api_key),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Trigger the next manual-start queue item on a printer.
 
@@ -271,7 +271,7 @@ def _stop_current_print(printer_id: int) -> None:
 async def webhook_stop_print(
     printer_id: int,
     api_key: APIKey = Depends(get_api_key),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Stop the current print on a printer.
 
@@ -299,7 +299,7 @@ async def webhook_stop_print(
 async def webhook_cancel_print(
     printer_id: int,
     api_key: APIKey = Depends(get_api_key),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Cancel the current print on a printer.
 
@@ -325,7 +325,7 @@ async def webhook_cancel_print(
 async def webhook_get_printer_status(
     printer_id: int,
     api_key: APIKey = Depends(get_api_key),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Get status of a printer.
 
@@ -378,7 +378,7 @@ async def webhook_get_printer_status(
 async def webhook_get_queue_status(
     printer_id: int | None = None,
     api_key: APIKey = Depends(get_api_key),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Get queue status for all printers or a specific printer.
 

+ 1 - 1
backend/app/core/auth.py

@@ -2036,7 +2036,7 @@ def generate_api_key() -> tuple[str, str, str]:
 async def get_api_key(
     authorization: Annotated[str | None, Header(alias="Authorization")] = None,
     x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
 ) -> APIKey:
     """Get and validate API key from request headers (the webhook routes).
 

+ 121 - 0
backend/tests/integration/test_commit_before_response_3320.py

@@ -0,0 +1,121 @@
+"""A write is committed before its response is sent (#3320).
+
+``get_db`` commits after ``yield``. With FastAPI's default ``scope="request"``
+that code runs after ``await response(...)``: the client had its 200 while the
+row was still uncommitted, so a read issued straight after it could 404, and a
+commit that failed was rolled back behind a 200 the client already had.
+
+An httpx client only returns once the whole ASGI call has finished, so it
+cannot see the order. These tests drive the app directly and look at the
+database at the moment the response starts.
+"""
+
+import json
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.exc import OperationalError
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+from backend.app.core.database import Base, get_db
+from backend.app.main import app
+from backend.app.models.project import Project
+
+
+async def _call(method: str, path: str, body: dict, on_start) -> list[dict]:
+    """Run one HTTP request through the ASGI app; ``on_start`` runs as the response starts."""
+    payload = json.dumps(body).encode()
+    scope = {
+        "type": "http",
+        "asgi": {"version": "3.0"},
+        "http_version": "1.1",
+        "method": method,
+        "scheme": "http",
+        "path": path,
+        "raw_path": path.encode(),
+        "query_string": b"",
+        "root_path": "",
+        "headers": [(b"host", b"test"), (b"content-type", b"application/json")],
+        "client": ("127.0.0.1", 1234),
+        "server": ("test", 80),
+    }
+    sent = False
+
+    async def receive():
+        nonlocal sent
+        if sent:
+            return {"type": "http.disconnect"}
+        sent = True
+        return {"type": "http.request", "body": payload, "more_body": False}
+
+    messages: list[dict] = []
+
+    async def send(message):
+        if message["type"] == "http.response.start":
+            await on_start(message)
+        messages.append(message)
+
+    await app(scope, receive, send)
+    return messages
+
+
+@pytest.fixture
+async def file_engine(async_client, tmp_path, monkeypatch):
+    """The production ``get_db`` on a file database instead of the conftest override.
+
+    The shared test engine is in-memory SQLite: one connection, on which a
+    "fresh" session sees rows nobody committed yet. A file gives every session
+    its own connection, as a real install has.
+    """
+    engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'bambuddy.db'}")
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    sessions = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+    monkeypatch.setattr("backend.app.core.database.async_session", sessions)
+    monkeypatch.setattr("backend.app.core.auth.async_session", sessions)
+    override = app.dependency_overrides.pop(get_db, None)
+    yield engine
+    if override is not None:
+        app.dependency_overrides[get_db] = override
+    await engine.dispose()
+
+
+class TestCommitBeforeResponse:
+    async def test_new_row_is_committed_when_the_response_starts(self, file_engine):
+        fresh = async_sessionmaker(file_engine, class_=AsyncSession, expire_on_commit=False)
+        seen: list[int] = []
+
+        async def on_start(message):
+            assert message["status"] == 200
+            async with fresh() as session:
+                rows = (await session.execute(select(Project.id).where(Project.name == "issue-3320"))).all()
+            seen.append(len(rows))
+
+        # create_project only flushes; the commit is get_db's.
+        await _call("POST", "/api/v1/projects/", {"name": "issue-3320"}, on_start)
+
+        assert seen == [1]
+
+    async def test_failed_commit_is_an_error_not_a_200(self, file_engine, monkeypatch):
+        failing = async_sessionmaker(file_engine, class_=AsyncSession, expire_on_commit=False)
+
+        async def refuse(self):
+            raise OperationalError("COMMIT", {}, Exception("database is locked"))
+
+        def factory():
+            session = failing()
+            monkeypatch.setattr(session, "commit", refuse.__get__(session))
+            return session
+
+        monkeypatch.setattr("backend.app.core.database.async_session", factory)
+        statuses: list[int] = []
+
+        async def on_start(message):
+            statuses.append(message["status"])
+
+        try:
+            await _call("POST", "/api/v1/projects/", {"name": "issue-3320-locked"}, on_start)
+        except OperationalError:
+            pass  # surfaced to the server once nothing was sent yet
+
+        assert 200 not in statuses

+ 31 - 0
backend/tests/unit/test_get_db_function_scope.py

@@ -0,0 +1,31 @@
+"""Every ``get_db`` dependency is function-scoped (#3320).
+
+With the default request scope, ``get_db`` commits after the response has been
+sent, so a read straight after a write can miss it. FastAPI keys its dependency
+cache on the scope too: one route mixing both would get two sessions, and a
+request-scoped yield dependency cannot use a function-scoped one at all. So it
+is all of them or none.
+"""
+
+from fastapi.routing import APIRoute, APIWebSocketRoute
+
+from backend.app.core.database import get_db
+from backend.app.main import app
+
+
+def _get_db_uses(dependant, path: str, found: list):
+    for dep in dependant.dependencies:
+        if dep.call is get_db:
+            found.append((path, dep.computed_scope))
+        _get_db_uses(dep, path, found)
+
+
+def test_every_get_db_dependency_is_function_scoped():
+    found: list[tuple[str, str | None]] = []
+    for route in app.routes:
+        if isinstance(route, (APIRoute, APIWebSocketRoute)):
+            _get_db_uses(route.dependant, route.path, found)
+
+    assert len(found) > 100  # the walk reaches the routes at all
+    request_scoped = sorted({path for path, scope in found if scope != "function"})
+    assert request_scoped == [], 'use Depends(get_db, scope="function"):\n' + "\n".join(request_scoped)