aws-python-helper 1.0.2__tar.gz → 1.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/PKG-INFO +30 -12
  2. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/README.md +29 -11
  3. aws_python_helper-1.0.3/aws_python_helper/model_index_sync/base.py +191 -0
  4. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/repository/base.py +68 -0
  5. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper.egg-info/PKG-INFO +30 -12
  6. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/pyproject.toml +1 -1
  7. aws_python_helper-1.0.2/aws_python_helper/model_index_sync/base.py +0 -114
  8. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/__init__.py +0 -0
  9. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/api/__init__.py +0 -0
  10. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/api/auth_middleware.py +0 -0
  11. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/api/auth_validators.py +0 -0
  12. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/api/base.py +0 -0
  13. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/api/dispatcher.py +0 -0
  14. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/api/exceptions.py +0 -0
  15. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/api/fetcher.py +0 -0
  16. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/api/handler.py +0 -0
  17. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/context/__init__.py +0 -0
  18. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/context/session.py +0 -0
  19. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/context/state_validator.py +0 -0
  20. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/database/__init__.py +0 -0
  21. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/database/database_proxy.py +0 -0
  22. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/database/external_database_proxy.py +0 -0
  23. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/database/external_mongo_manager.py +0 -0
  24. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/database/mongo_manager.py +0 -0
  25. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/fargate/__init__.py +0 -0
  26. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/fargate/executor.py +0 -0
  27. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/fargate/fetcher.py +0 -0
  28. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/fargate/handler.py +0 -0
  29. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/fargate/task_base.py +0 -0
  30. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/invoker/__init__.py +0 -0
  31. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/invoker/api_client.py +0 -0
  32. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/invoker/exceptions.py +0 -0
  33. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/invoker/lambda_invoker.py +0 -0
  34. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/lambda_standalone/__init__.py +0 -0
  35. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/lambda_standalone/base.py +0 -0
  36. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/lambda_standalone/fetcher.py +0 -0
  37. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/lambda_standalone/handler.py +0 -0
  38. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/model_index_sync/__init__.py +0 -0
  39. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/model_query/__init__.py +0 -0
  40. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/model_query/base.py +0 -0
  41. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/repository/__init__.py +0 -0
  42. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/sns/__init__.py +0 -0
  43. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/sns/publisher.py +0 -0
  44. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/sqs/__init__.py +0 -0
  45. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/sqs/consumer_base.py +0 -0
  46. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/sqs/fetcher.py +0 -0
  47. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/sqs/handler.py +0 -0
  48. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/utils/__init__.py +0 -0
  49. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/utils/json_encoder.py +0 -0
  50. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/utils/response.py +0 -0
  51. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper/utils/serializer.py +0 -0
  52. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper.egg-info/SOURCES.txt +0 -0
  53. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper.egg-info/dependency_links.txt +0 -0
  54. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper.egg-info/requires.txt +0 -0
  55. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/aws_python_helper.egg-info/top_level.txt +0 -0
  56. {aws_python_helper-1.0.2 → aws_python_helper-1.0.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-python-helper
3
- Version: 1.0.2
3
+ Version: 1.0.3
4
4
  Summary: AWS Python Helper Framework
5
5
  Author-email: Fabian Claros <neufabiae@gmail.com>
6
6
  License-Expression: MIT
@@ -39,7 +39,7 @@ Mini-framework to create REST APIs, SQS Consumers, SNS Publishers, Fargate Tasks
39
39
  - **LambdaInvoker**: Invoke other Lambda functions (sync or async) with built-in error handling
40
40
  - **ApiClient**: HTTP client for inter-service communication — resolves service URLs and auth token from environment variables automatically
41
41
  - **ModelQueryLambda**: Base class for MongoDB query-proxy Lambdas — declare an `allowed_collections` whitelist and the framework handles `find` and `aggregate` operations generically
42
- - **ModelIndexSyncLambda**: Base class for index-synchronization Lambdas — declare your `repositories` and the framework creates every declared index across the `core` database and all active state databases, idempotently, in a single run (run once per deploy)
42
+ - **ModelIndexSyncLambda**: Base class for index-synchronization Lambdas — declare your `repositories` and the framework creates the declared indexes **and prunes orphans** across the `core` database and all active state databases, idempotently and in parallel, in a single run (run once per deploy)
43
43
  - **JSON utilities**: Automatic serialization of MongoDB types
44
44
  - **Type hints**: Modern Python with type annotations
45
45
  - **Async/await**: Full support for asynchronous operations
@@ -68,7 +68,7 @@ All available classes and functions:
68
68
  | `fargate_handler` | `aws_python_helper.fargate.handler` | Entry point handler for Fargate |
69
69
  | `Repository` | `aws_python_helper.repository.base` | Base class for MongoDB repositories |
70
70
  | `ModelQueryLambda` | `aws_python_helper` | Generic MongoDB query-proxy Lambda base class |
71
- | `ModelIndexSyncLambda` | `aws_python_helper` | Index-sync Lambda base class (creates indexes across `core` + all state DBs) |
71
+ | `ModelIndexSyncLambda` | `aws_python_helper` | Index-sync Lambda base class (creates declared indexes + prunes orphans across `core` + all state DBs, in parallel) |
72
72
  | `LambdaInvoker` | `aws_python_helper` | Invoke Lambda functions (sync/async) with error handling |
73
73
  | `ApiClient` | `aws_python_helper` | HTTP client for inter-service communication |
74
74
  | `LambdaInvocationError` | `aws_python_helper` | Raised when boto3 fails to invoke a Lambda |
@@ -1360,10 +1360,16 @@ Always pass `self.session.to_dict()` when calling from an API or Lambda to ensur
1360
1360
 
1361
1361
  ## 🧱 ModelIndexSyncLambda
1362
1362
 
1363
- `ModelIndexSyncLambda` is a ready-to-use base class for **creating all of a service's MongoDB indexes deterministically**, in one run. Because index creation is no longer triggered at request time (see the [Repository Pattern](#️-repository-pattern) note), this Lambda is the single source of truth for materializing indexes — typically invoked **once per deploy**.
1363
+ `ModelIndexSyncLambda` is a ready-to-use base class for **synchronizing all of a service's MongoDB indexes deterministically**, in one run. Because index creation is no longer triggered at request time (see the [Repository Pattern](#️-repository-pattern) note), this Lambda is the single source of truth for materializing indexes — typically invoked **once per deploy**.
1364
1364
 
1365
1365
  **Why:** in short-lived Lambda/API runtimes, creating indexes lazily on first access often never finishes (the function responds and freezes). This Lambda creates every declared index up front, `await`-ing each one, idempotently — and across **every** active state database, not just the one of the current request.
1366
1366
 
1367
+ It does a **two-way sync** per collection: it creates the declared indexes **and prunes orphans** — any index present in MongoDB that is no longer declared in the repository's `indexes` is dropped (the default `_id_` index and special text/geo/hashed indexes are always preserved).
1368
+
1369
+ > ⚠️ **Orphan pruning drops indexes not declared in code.** Indexes created out-of-band (by a DBA, Atlas, or another tool) that aren't in any repository's `indexes` will be removed on every run. Declare every index you want to keep.
1370
+
1371
+ **Concurrency & fault tolerance:** each `(collection, database)` unit is synced **in parallel** (bounded by `max_concurrency`, default 10); index work *within* a unit stays sequential. A failure in one unit is **captured and isolated** — it never aborts the others, so a single broken collection doesn't stop the rest of the sync. The top-level response is always `success: true`; per-unit outcomes are reported in `results[].success`.
1372
+
1367
1373
  ### Creating an index-sync Lambda
1368
1374
 
1369
1375
  Create `src/lambda/model-index-sync/main.py` and declare which repositories to sync:
@@ -1409,14 +1415,20 @@ This Lambda sets `requires_state = False`: it is not tied to a single state and
1409
1415
 
1410
1416
  ### Response format
1411
1417
 
1418
+ The top-level `success` is **always `true`** (a failing collection no longer aborts the run). Inspect `data.summary` for the OK/failed counts and `data.results[].success` for each unit; failed units include an `error` field.
1419
+
1412
1420
  ```json
1413
1421
  {
1414
1422
  "success": true,
1415
- "data": [
1416
- {"repository": "ParcelsRepository", "database": "connecticut", "collection": "parcels", "indexes": ["parcel_id_1", "..."]},
1417
- {"repository": "ParcelsRepository", "database": "new_jersey", "collection": "parcels", "indexes": ["parcel_id_1", "..."]},
1418
- {"repository": "UsersRepository", "database": "core", "collection": "users", "indexes": ["email_1"]}
1419
- ]
1423
+ "data": {
1424
+ "summary": { "total": 4, "succeeded": 3, "failed": 1 },
1425
+ "results": [
1426
+ {"repository": "ParcelsRepository", "database": "connecticut", "collection": "parcels", "indexes": ["parcel_id_1", "..."], "orphan_indexes_dropped": ["old_field_1"], "success": true},
1427
+ {"repository": "ParcelsRepository", "database": "new_jersey", "collection": "parcels", "indexes": ["parcel_id_1", "..."], "orphan_indexes_dropped": [], "success": true},
1428
+ {"repository": "UsersRepository", "database": "core", "collection": "users", "indexes": ["email_1"], "orphan_indexes_dropped": [], "success": true},
1429
+ {"repository": "TokensRepository", "database": "core", "collection": "tokens", "indexes": [], "orphan_indexes_dropped": [], "success": false, "error": "..."}
1430
+ ]
1431
+ }
1420
1432
  }
1421
1433
  ```
1422
1434
 
@@ -1427,11 +1439,14 @@ This Lambda sets `requires_state = False`: it is not tied to a single state and
1427
1439
  | `repositories` | `[]` | **Required.** List of `Repository` subclasses (the classes, not instances) to sync. Empty raises a validation error |
1428
1440
  | `states_database` | `"core"` | Database holding the states collection |
1429
1441
  | `states_collection` | `"states"` | Collection listing the states (filtered by `is_active = true`, field `name`) |
1442
+ | `max_concurrency` | `10` | Max `(collection, database)` units synced in parallel. `0` (or negative) = unlimited |
1430
1443
  | `requires_state` | `False` | Inherited override — the Lambda runs across all states, no session required |
1431
1444
 
1432
1445
  ### Running it once per deploy (Terraform)
1433
1446
 
1434
- Since deploys are applied manually with Terraform, attach an `aws_lambda_invocation` that runs **on every `apply`**, right after the function code is updated. `timestamp()` changes on every plan, so it always re-runs (creation is idempotent and also picks up any newly-activated state). The `postcondition` fails the `apply` if the sync reports `success = false` (the framework handler returns HTTP 200 with `success = false` on internal errors, so this check is required to surface failures):
1447
+ Since deploys are applied manually with Terraform, attach an `aws_lambda_invocation` that runs **on every `apply`**, right after the function code is updated. `timestamp()` changes on every plan, so it always re-runs (creation is idempotent and also picks up any newly-activated state).
1448
+
1449
+ > **Detecting failures in the deploy.** The top-level `success` is now **always `true`** — a single failed collection no longer aborts the run, so checking `success` alone won't catch partial failures. If you want the `apply` to fail when any collection failed, point the `postcondition` at `data.summary.failed` instead (shown below). If you'd rather the deploy never fail on index errors and only review them afterward (in the response/logs), simply omit the `lifecycle` block.
1435
1450
 
1436
1451
  ```hcl
1437
1452
  module "lambda" {
@@ -1449,10 +1464,13 @@ resource "aws_lambda_invocation" "sync_on_deploy" {
1449
1464
 
1450
1465
  depends_on = [module.lambda] # run after the code is updated
1451
1466
 
1467
+ # Optional: fail the apply if any collection failed to sync.
1468
+ # Omit this whole lifecycle block if you prefer the deploy to never fail on
1469
+ # index errors (review data.results[].success / logs instead).
1452
1470
  lifecycle {
1453
1471
  postcondition {
1454
- condition = try(jsondecode(self.result).success, false) == true
1455
- error_message = "Index sync failed during apply: ${self.result}"
1472
+ condition = try(jsondecode(self.result).data.summary.failed, 1) == 0
1473
+ error_message = "Index sync had failures during apply: ${self.result}"
1456
1474
  }
1457
1475
  }
1458
1476
  }
@@ -19,7 +19,7 @@ Mini-framework to create REST APIs, SQS Consumers, SNS Publishers, Fargate Tasks
19
19
  - **LambdaInvoker**: Invoke other Lambda functions (sync or async) with built-in error handling
20
20
  - **ApiClient**: HTTP client for inter-service communication — resolves service URLs and auth token from environment variables automatically
21
21
  - **ModelQueryLambda**: Base class for MongoDB query-proxy Lambdas — declare an `allowed_collections` whitelist and the framework handles `find` and `aggregate` operations generically
22
- - **ModelIndexSyncLambda**: Base class for index-synchronization Lambdas — declare your `repositories` and the framework creates every declared index across the `core` database and all active state databases, idempotently, in a single run (run once per deploy)
22
+ - **ModelIndexSyncLambda**: Base class for index-synchronization Lambdas — declare your `repositories` and the framework creates the declared indexes **and prunes orphans** across the `core` database and all active state databases, idempotently and in parallel, in a single run (run once per deploy)
23
23
  - **JSON utilities**: Automatic serialization of MongoDB types
24
24
  - **Type hints**: Modern Python with type annotations
25
25
  - **Async/await**: Full support for asynchronous operations
@@ -48,7 +48,7 @@ All available classes and functions:
48
48
  | `fargate_handler` | `aws_python_helper.fargate.handler` | Entry point handler for Fargate |
49
49
  | `Repository` | `aws_python_helper.repository.base` | Base class for MongoDB repositories |
50
50
  | `ModelQueryLambda` | `aws_python_helper` | Generic MongoDB query-proxy Lambda base class |
51
- | `ModelIndexSyncLambda` | `aws_python_helper` | Index-sync Lambda base class (creates indexes across `core` + all state DBs) |
51
+ | `ModelIndexSyncLambda` | `aws_python_helper` | Index-sync Lambda base class (creates declared indexes + prunes orphans across `core` + all state DBs, in parallel) |
52
52
  | `LambdaInvoker` | `aws_python_helper` | Invoke Lambda functions (sync/async) with error handling |
53
53
  | `ApiClient` | `aws_python_helper` | HTTP client for inter-service communication |
54
54
  | `LambdaInvocationError` | `aws_python_helper` | Raised when boto3 fails to invoke a Lambda |
@@ -1340,10 +1340,16 @@ Always pass `self.session.to_dict()` when calling from an API or Lambda to ensur
1340
1340
 
1341
1341
  ## 🧱 ModelIndexSyncLambda
1342
1342
 
1343
- `ModelIndexSyncLambda` is a ready-to-use base class for **creating all of a service's MongoDB indexes deterministically**, in one run. Because index creation is no longer triggered at request time (see the [Repository Pattern](#️-repository-pattern) note), this Lambda is the single source of truth for materializing indexes — typically invoked **once per deploy**.
1343
+ `ModelIndexSyncLambda` is a ready-to-use base class for **synchronizing all of a service's MongoDB indexes deterministically**, in one run. Because index creation is no longer triggered at request time (see the [Repository Pattern](#️-repository-pattern) note), this Lambda is the single source of truth for materializing indexes — typically invoked **once per deploy**.
1344
1344
 
1345
1345
  **Why:** in short-lived Lambda/API runtimes, creating indexes lazily on first access often never finishes (the function responds and freezes). This Lambda creates every declared index up front, `await`-ing each one, idempotently — and across **every** active state database, not just the one of the current request.
1346
1346
 
1347
+ It does a **two-way sync** per collection: it creates the declared indexes **and prunes orphans** — any index present in MongoDB that is no longer declared in the repository's `indexes` is dropped (the default `_id_` index and special text/geo/hashed indexes are always preserved).
1348
+
1349
+ > ⚠️ **Orphan pruning drops indexes not declared in code.** Indexes created out-of-band (by a DBA, Atlas, or another tool) that aren't in any repository's `indexes` will be removed on every run. Declare every index you want to keep.
1350
+
1351
+ **Concurrency & fault tolerance:** each `(collection, database)` unit is synced **in parallel** (bounded by `max_concurrency`, default 10); index work *within* a unit stays sequential. A failure in one unit is **captured and isolated** — it never aborts the others, so a single broken collection doesn't stop the rest of the sync. The top-level response is always `success: true`; per-unit outcomes are reported in `results[].success`.
1352
+
1347
1353
  ### Creating an index-sync Lambda
1348
1354
 
1349
1355
  Create `src/lambda/model-index-sync/main.py` and declare which repositories to sync:
@@ -1389,14 +1395,20 @@ This Lambda sets `requires_state = False`: it is not tied to a single state and
1389
1395
 
1390
1396
  ### Response format
1391
1397
 
1398
+ The top-level `success` is **always `true`** (a failing collection no longer aborts the run). Inspect `data.summary` for the OK/failed counts and `data.results[].success` for each unit; failed units include an `error` field.
1399
+
1392
1400
  ```json
1393
1401
  {
1394
1402
  "success": true,
1395
- "data": [
1396
- {"repository": "ParcelsRepository", "database": "connecticut", "collection": "parcels", "indexes": ["parcel_id_1", "..."]},
1397
- {"repository": "ParcelsRepository", "database": "new_jersey", "collection": "parcels", "indexes": ["parcel_id_1", "..."]},
1398
- {"repository": "UsersRepository", "database": "core", "collection": "users", "indexes": ["email_1"]}
1399
- ]
1403
+ "data": {
1404
+ "summary": { "total": 4, "succeeded": 3, "failed": 1 },
1405
+ "results": [
1406
+ {"repository": "ParcelsRepository", "database": "connecticut", "collection": "parcels", "indexes": ["parcel_id_1", "..."], "orphan_indexes_dropped": ["old_field_1"], "success": true},
1407
+ {"repository": "ParcelsRepository", "database": "new_jersey", "collection": "parcels", "indexes": ["parcel_id_1", "..."], "orphan_indexes_dropped": [], "success": true},
1408
+ {"repository": "UsersRepository", "database": "core", "collection": "users", "indexes": ["email_1"], "orphan_indexes_dropped": [], "success": true},
1409
+ {"repository": "TokensRepository", "database": "core", "collection": "tokens", "indexes": [], "orphan_indexes_dropped": [], "success": false, "error": "..."}
1410
+ ]
1411
+ }
1400
1412
  }
1401
1413
  ```
1402
1414
 
@@ -1407,11 +1419,14 @@ This Lambda sets `requires_state = False`: it is not tied to a single state and
1407
1419
  | `repositories` | `[]` | **Required.** List of `Repository` subclasses (the classes, not instances) to sync. Empty raises a validation error |
1408
1420
  | `states_database` | `"core"` | Database holding the states collection |
1409
1421
  | `states_collection` | `"states"` | Collection listing the states (filtered by `is_active = true`, field `name`) |
1422
+ | `max_concurrency` | `10` | Max `(collection, database)` units synced in parallel. `0` (or negative) = unlimited |
1410
1423
  | `requires_state` | `False` | Inherited override — the Lambda runs across all states, no session required |
1411
1424
 
1412
1425
  ### Running it once per deploy (Terraform)
1413
1426
 
1414
- Since deploys are applied manually with Terraform, attach an `aws_lambda_invocation` that runs **on every `apply`**, right after the function code is updated. `timestamp()` changes on every plan, so it always re-runs (creation is idempotent and also picks up any newly-activated state). The `postcondition` fails the `apply` if the sync reports `success = false` (the framework handler returns HTTP 200 with `success = false` on internal errors, so this check is required to surface failures):
1427
+ Since deploys are applied manually with Terraform, attach an `aws_lambda_invocation` that runs **on every `apply`**, right after the function code is updated. `timestamp()` changes on every plan, so it always re-runs (creation is idempotent and also picks up any newly-activated state).
1428
+
1429
+ > **Detecting failures in the deploy.** The top-level `success` is now **always `true`** — a single failed collection no longer aborts the run, so checking `success` alone won't catch partial failures. If you want the `apply` to fail when any collection failed, point the `postcondition` at `data.summary.failed` instead (shown below). If you'd rather the deploy never fail on index errors and only review them afterward (in the response/logs), simply omit the `lifecycle` block.
1415
1430
 
1416
1431
  ```hcl
1417
1432
  module "lambda" {
@@ -1429,10 +1444,13 @@ resource "aws_lambda_invocation" "sync_on_deploy" {
1429
1444
 
1430
1445
  depends_on = [module.lambda] # run after the code is updated
1431
1446
 
1447
+ # Optional: fail the apply if any collection failed to sync.
1448
+ # Omit this whole lifecycle block if you prefer the deploy to never fail on
1449
+ # index errors (review data.results[].success / logs instead).
1432
1450
  lifecycle {
1433
1451
  postcondition {
1434
- condition = try(jsondecode(self.result).success, false) == true
1435
- error_message = "Index sync failed during apply: ${self.result}"
1452
+ condition = try(jsondecode(self.result).data.summary.failed, 1) == 0
1453
+ error_message = "Index sync had failures during apply: ${self.result}"
1436
1454
  }
1437
1455
  }
1438
1456
  }
@@ -0,0 +1,191 @@
1
+ """
2
+ ModelIndexSyncLambda - Synchronizes MongoDB indexes for a service's repositories.
3
+
4
+ Creates (idempotently) every index declared in the service's repositories, in
5
+ the correct databases:
6
+ - Repositories with a fixed database_key (e.g. 'core') → that database.
7
+ - State-scoped repositories (database_key is None) → every active state
8
+ database, read from core.states (is_active=True). A state-scoped collection
9
+ (e.g. 'parcels') therefore gets its indexes created in 'connecticut',
10
+ 'new_jersey', etc.
11
+
12
+ It also prunes orphan indexes: any index present in MongoDB but no longer
13
+ declared in a repository's `indexes` is dropped (see Repository.prune_orphan_indexes).
14
+
15
+ Designed to run once per deploy (and on demand), guaranteeing index creation
16
+ without the per-request overhead of creating indexes at runtime.
17
+
18
+ Each (collection, database) unit is synced concurrently (bounded by
19
+ `max_concurrency`); index creation WITHIN a unit stays sequential. A failure in
20
+ one unit is captured and never aborts the others, so a single broken collection
21
+ does not stop the rest of the sync — the failure is reported instead.
22
+
23
+ Usage — subclass and declare the repositories to sync:
24
+
25
+ import aws_python_helper
26
+ from repositories.parcels import ParcelsRepository
27
+ from repositories.users import UsersRepository
28
+
29
+ class PropertiesModelIndexSyncLambda(aws_python_helper.ModelIndexSyncLambda):
30
+ @property
31
+ def repositories(self) -> list:
32
+ return [ParcelsRepository, UsersRepository]
33
+
34
+ This Lambda does not require a session state (requires_state = False); it
35
+ operates across all active states.
36
+
37
+ Response (via Lambda base run()) — always success=True at the top level; per-unit
38
+ outcome is reported in results[].success:
39
+ {"success": True, "data": {
40
+ "summary": {"total": 3, "succeeded": 2, "failed": 1},
41
+ "results": [
42
+ {"repository": "ParcelsRepository", "database": "connecticut",
43
+ "collection": "parcels", "indexes": ["parcel_id_1", ...],
44
+ "orphan_indexes_dropped": ["old_field_1"], "success": True},
45
+ {"repository": "UsersRepository", "database": "core",
46
+ "collection": "users", "indexes": ["email_1"],
47
+ "orphan_indexes_dropped": [], "success": True},
48
+ {"repository": "TokensRepository", "database": "core",
49
+ "collection": "tokens", "indexes": [], "orphan_indexes_dropped": [],
50
+ "success": False, "error": "..."},
51
+ ]
52
+ }}
53
+ """
54
+
55
+ import asyncio
56
+ from typing import Any, List, Type
57
+
58
+ from ..lambda_standalone.base import Lambda
59
+ from ..repository.base import Repository
60
+ from ..database.mongo_manager import MongoManager
61
+
62
+
63
+ class ModelIndexSyncLambda(Lambda):
64
+ """
65
+ Base class for index-synchronization Lambdas.
66
+
67
+ Subclass and override `repositories` to declare which repositories to sync.
68
+ Everything else (resolving core vs state-scoped, reading active states and
69
+ creating the indexes) is handled automatically.
70
+ """
71
+
72
+ @property
73
+ def requires_state(self) -> bool:
74
+ # Operates across all states; not tied to a single one.
75
+ return False
76
+
77
+ @property
78
+ def repositories(self) -> List[Type[Repository]]:
79
+ """
80
+ Repositories whose indexes should be synchronized.
81
+
82
+ Override returning a list of Repository subclasses (the classes, not
83
+ instances). Declare them explicitly rather than auto-discovering, so the
84
+ synced set is intentional and nothing is silently missed or added.
85
+ """
86
+ return []
87
+
88
+ @property
89
+ def states_database(self) -> str:
90
+ """Database holding the states collection. Default: 'core'."""
91
+ return "core"
92
+
93
+ @property
94
+ def states_collection(self) -> str:
95
+ """Collection listing the states. Default: 'states'."""
96
+ return "states"
97
+
98
+ @property
99
+ def max_concurrency(self) -> int:
100
+ """
101
+ Max number of (collection, database) index syncs running in parallel.
102
+ 0 (or negative) means unlimited. Default: 10.
103
+ """
104
+ return 10
105
+
106
+ async def validate(self):
107
+ if not self.repositories:
108
+ raise ValueError(
109
+ "repositories is not configured on this ModelIndexSyncLambda"
110
+ )
111
+
112
+ async def _active_states(self) -> List[str]:
113
+ """Names of active states — i.e. the state-scoped database names."""
114
+ db = MongoManager.get_database(self.states_database)
115
+ cursor = db[self.states_collection].find(
116
+ {"is_active": True}, {"name": 1, "_id": 0}
117
+ )
118
+ return [doc["name"] async for doc in cursor if doc.get("name")]
119
+
120
+ async def _sync_target(self, repo_cls: Type[Repository], db_name: str) -> dict:
121
+ """
122
+ Sync one collection in one database: create declared indexes and drop
123
+ orphan ones.
124
+
125
+ Captures its own errors so a single failure never aborts the rest of the
126
+ sync — a failed unit returns success=False with the error message, and
127
+ any indexes created before the failure are still reported.
128
+ """
129
+ result = {
130
+ "repository": repo_cls.__name__,
131
+ "database": db_name,
132
+ "collection": None,
133
+ "indexes": [],
134
+ "orphan_indexes_dropped": [],
135
+ "success": True,
136
+ }
137
+ try:
138
+ repo = repo_cls()
139
+ result["collection"] = repo.collection_name
140
+ result["indexes"] = await repo.ensure_indexes(database_name=db_name)
141
+ result["orphan_indexes_dropped"] = await repo.prune_orphan_indexes(
142
+ database_name=db_name, dry_run=False
143
+ )
144
+ except Exception as e:
145
+ self.logger.error(
146
+ f"Index sync failed for {repo_cls.__name__} on '{db_name}': {e}"
147
+ )
148
+ result["success"] = False
149
+ result["error"] = str(e)
150
+ return result
151
+
152
+ async def process(self) -> Any:
153
+ active_states = await self._active_states()
154
+
155
+ # Plan all independent (repo_cls, db_name) work units.
156
+ targets: List[tuple] = []
157
+ for repo_cls in self.repositories:
158
+ repo = repo_cls()
159
+ # database_key is None -> state-scoped (collection repeats per state db).
160
+ is_state_scoped = repo.database_key is None and not repo.is_external
161
+ target_databases = active_states if is_state_scoped else [repo.database_name]
162
+ for db_name in target_databases:
163
+ targets.append((repo_cls, db_name))
164
+
165
+ # Each (collection, database) runs concurrently; index creation WITHIN
166
+ # each one stays sequential (via await). _sync_target captures its own
167
+ # errors, so one failed unit never aborts the others.
168
+ limit = self.max_concurrency
169
+ if limit and limit > 0:
170
+ semaphore = asyncio.Semaphore(limit)
171
+
172
+ async def run(repo_cls, db_name):
173
+ async with semaphore:
174
+ return await self._sync_target(repo_cls, db_name)
175
+ else:
176
+ async def run(repo_cls, db_name):
177
+ return await self._sync_target(repo_cls, db_name)
178
+
179
+ results = list(await asyncio.gather(
180
+ *(run(repo_cls, db_name) for repo_cls, db_name in targets)
181
+ ))
182
+
183
+ failed = [r for r in results if not r["success"]]
184
+ return {
185
+ "summary": {
186
+ "total": len(results),
187
+ "succeeded": len(results) - len(failed),
188
+ "failed": len(failed),
189
+ },
190
+ "results": results,
191
+ }
@@ -255,3 +255,71 @@ class Repository(ABC):
255
255
 
256
256
  self._indexes_created[key] = True
257
257
  return created
258
+
259
+ async def prune_orphan_indexes(
260
+ self, database_name: Optional[str] = None, dry_run: bool = True
261
+ ) -> List[str]:
262
+ """
263
+ Detects and (optionally) drops indexes that exist in MongoDB but are NOT
264
+ declared in the `indexes` property — i.e. orphan indexes left behind
265
+ after an index was removed from the code.
266
+
267
+ Matching is done by index key spec (fields + direction), never by name,
268
+ since index names are auto-generated by MongoDB. The default '_id_' index
269
+ is always preserved, and special indexes (text/geo/hashed, whose key spec
270
+ MongoDB rewrites internally) are skipped to avoid false positives.
271
+
272
+ Args:
273
+ database_name: Target database. Defaults to the resolved
274
+ database_name (database_key or current session state).
275
+ dry_run: If True (default), only reports orphans without dropping.
276
+ If False, drops each orphan index.
277
+
278
+ Returns:
279
+ List of orphan index names (dropped when dry_run=False).
280
+ """
281
+ db_name = database_name or self.database_name
282
+
283
+ if self.is_external:
284
+ if not self.cluster_name:
285
+ raise ValueError(
286
+ f"{self.__class__.__name__}: 'cluster_name' is required when is_external=True"
287
+ )
288
+ db = ExternalMongoManager.get_database(self.cluster_name, db_name)
289
+ else:
290
+ db = MongoManager.get_database(db_name)
291
+ collection = db[self.collection_name]
292
+
293
+ declared = {
294
+ tuple(tuple(field) for field in index_def["key"])
295
+ for index_def in self.indexes
296
+ if index_def.get("key")
297
+ }
298
+
299
+ orphans: List[str] = []
300
+ async for existing in collection.list_indexes():
301
+ name = existing.get("name")
302
+ if name == "_id_": # the default _id index can never be dropped
303
+ continue
304
+
305
+ key_spec = list(existing.get("key", {}).items())
306
+ # Skip special indexes (text/geo/hashed): MongoDB rewrites their key
307
+ # spec ("text"/2dsphere/"hashed", _fts/_ftsx), so a key-based
308
+ # comparison would wrongly flag them as orphans.
309
+ if any(isinstance(direction, str) for _, direction in key_spec) \
310
+ or any(field in ("_fts", "_ftsx") for field, _ in key_spec):
311
+ continue
312
+
313
+ if tuple(key_spec) not in declared:
314
+ orphans.append(name)
315
+ if not dry_run:
316
+ await collection.drop_index(name)
317
+ self.logger.info(
318
+ f"Dropped orphan index '{name}' on '{db_name}.{self.collection_name}'"
319
+ )
320
+ else:
321
+ self.logger.info(
322
+ f"[dry-run] orphan index '{name}' on '{db_name}.{self.collection_name}'"
323
+ )
324
+
325
+ return orphans
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-python-helper
3
- Version: 1.0.2
3
+ Version: 1.0.3
4
4
  Summary: AWS Python Helper Framework
5
5
  Author-email: Fabian Claros <neufabiae@gmail.com>
6
6
  License-Expression: MIT
@@ -39,7 +39,7 @@ Mini-framework to create REST APIs, SQS Consumers, SNS Publishers, Fargate Tasks
39
39
  - **LambdaInvoker**: Invoke other Lambda functions (sync or async) with built-in error handling
40
40
  - **ApiClient**: HTTP client for inter-service communication — resolves service URLs and auth token from environment variables automatically
41
41
  - **ModelQueryLambda**: Base class for MongoDB query-proxy Lambdas — declare an `allowed_collections` whitelist and the framework handles `find` and `aggregate` operations generically
42
- - **ModelIndexSyncLambda**: Base class for index-synchronization Lambdas — declare your `repositories` and the framework creates every declared index across the `core` database and all active state databases, idempotently, in a single run (run once per deploy)
42
+ - **ModelIndexSyncLambda**: Base class for index-synchronization Lambdas — declare your `repositories` and the framework creates the declared indexes **and prunes orphans** across the `core` database and all active state databases, idempotently and in parallel, in a single run (run once per deploy)
43
43
  - **JSON utilities**: Automatic serialization of MongoDB types
44
44
  - **Type hints**: Modern Python with type annotations
45
45
  - **Async/await**: Full support for asynchronous operations
@@ -68,7 +68,7 @@ All available classes and functions:
68
68
  | `fargate_handler` | `aws_python_helper.fargate.handler` | Entry point handler for Fargate |
69
69
  | `Repository` | `aws_python_helper.repository.base` | Base class for MongoDB repositories |
70
70
  | `ModelQueryLambda` | `aws_python_helper` | Generic MongoDB query-proxy Lambda base class |
71
- | `ModelIndexSyncLambda` | `aws_python_helper` | Index-sync Lambda base class (creates indexes across `core` + all state DBs) |
71
+ | `ModelIndexSyncLambda` | `aws_python_helper` | Index-sync Lambda base class (creates declared indexes + prunes orphans across `core` + all state DBs, in parallel) |
72
72
  | `LambdaInvoker` | `aws_python_helper` | Invoke Lambda functions (sync/async) with error handling |
73
73
  | `ApiClient` | `aws_python_helper` | HTTP client for inter-service communication |
74
74
  | `LambdaInvocationError` | `aws_python_helper` | Raised when boto3 fails to invoke a Lambda |
@@ -1360,10 +1360,16 @@ Always pass `self.session.to_dict()` when calling from an API or Lambda to ensur
1360
1360
 
1361
1361
  ## 🧱 ModelIndexSyncLambda
1362
1362
 
1363
- `ModelIndexSyncLambda` is a ready-to-use base class for **creating all of a service's MongoDB indexes deterministically**, in one run. Because index creation is no longer triggered at request time (see the [Repository Pattern](#️-repository-pattern) note), this Lambda is the single source of truth for materializing indexes — typically invoked **once per deploy**.
1363
+ `ModelIndexSyncLambda` is a ready-to-use base class for **synchronizing all of a service's MongoDB indexes deterministically**, in one run. Because index creation is no longer triggered at request time (see the [Repository Pattern](#️-repository-pattern) note), this Lambda is the single source of truth for materializing indexes — typically invoked **once per deploy**.
1364
1364
 
1365
1365
  **Why:** in short-lived Lambda/API runtimes, creating indexes lazily on first access often never finishes (the function responds and freezes). This Lambda creates every declared index up front, `await`-ing each one, idempotently — and across **every** active state database, not just the one of the current request.
1366
1366
 
1367
+ It does a **two-way sync** per collection: it creates the declared indexes **and prunes orphans** — any index present in MongoDB that is no longer declared in the repository's `indexes` is dropped (the default `_id_` index and special text/geo/hashed indexes are always preserved).
1368
+
1369
+ > ⚠️ **Orphan pruning drops indexes not declared in code.** Indexes created out-of-band (by a DBA, Atlas, or another tool) that aren't in any repository's `indexes` will be removed on every run. Declare every index you want to keep.
1370
+
1371
+ **Concurrency & fault tolerance:** each `(collection, database)` unit is synced **in parallel** (bounded by `max_concurrency`, default 10); index work *within* a unit stays sequential. A failure in one unit is **captured and isolated** — it never aborts the others, so a single broken collection doesn't stop the rest of the sync. The top-level response is always `success: true`; per-unit outcomes are reported in `results[].success`.
1372
+
1367
1373
  ### Creating an index-sync Lambda
1368
1374
 
1369
1375
  Create `src/lambda/model-index-sync/main.py` and declare which repositories to sync:
@@ -1409,14 +1415,20 @@ This Lambda sets `requires_state = False`: it is not tied to a single state and
1409
1415
 
1410
1416
  ### Response format
1411
1417
 
1418
+ The top-level `success` is **always `true`** (a failing collection no longer aborts the run). Inspect `data.summary` for the OK/failed counts and `data.results[].success` for each unit; failed units include an `error` field.
1419
+
1412
1420
  ```json
1413
1421
  {
1414
1422
  "success": true,
1415
- "data": [
1416
- {"repository": "ParcelsRepository", "database": "connecticut", "collection": "parcels", "indexes": ["parcel_id_1", "..."]},
1417
- {"repository": "ParcelsRepository", "database": "new_jersey", "collection": "parcels", "indexes": ["parcel_id_1", "..."]},
1418
- {"repository": "UsersRepository", "database": "core", "collection": "users", "indexes": ["email_1"]}
1419
- ]
1423
+ "data": {
1424
+ "summary": { "total": 4, "succeeded": 3, "failed": 1 },
1425
+ "results": [
1426
+ {"repository": "ParcelsRepository", "database": "connecticut", "collection": "parcels", "indexes": ["parcel_id_1", "..."], "orphan_indexes_dropped": ["old_field_1"], "success": true},
1427
+ {"repository": "ParcelsRepository", "database": "new_jersey", "collection": "parcels", "indexes": ["parcel_id_1", "..."], "orphan_indexes_dropped": [], "success": true},
1428
+ {"repository": "UsersRepository", "database": "core", "collection": "users", "indexes": ["email_1"], "orphan_indexes_dropped": [], "success": true},
1429
+ {"repository": "TokensRepository", "database": "core", "collection": "tokens", "indexes": [], "orphan_indexes_dropped": [], "success": false, "error": "..."}
1430
+ ]
1431
+ }
1420
1432
  }
1421
1433
  ```
1422
1434
 
@@ -1427,11 +1439,14 @@ This Lambda sets `requires_state = False`: it is not tied to a single state and
1427
1439
  | `repositories` | `[]` | **Required.** List of `Repository` subclasses (the classes, not instances) to sync. Empty raises a validation error |
1428
1440
  | `states_database` | `"core"` | Database holding the states collection |
1429
1441
  | `states_collection` | `"states"` | Collection listing the states (filtered by `is_active = true`, field `name`) |
1442
+ | `max_concurrency` | `10` | Max `(collection, database)` units synced in parallel. `0` (or negative) = unlimited |
1430
1443
  | `requires_state` | `False` | Inherited override — the Lambda runs across all states, no session required |
1431
1444
 
1432
1445
  ### Running it once per deploy (Terraform)
1433
1446
 
1434
- Since deploys are applied manually with Terraform, attach an `aws_lambda_invocation` that runs **on every `apply`**, right after the function code is updated. `timestamp()` changes on every plan, so it always re-runs (creation is idempotent and also picks up any newly-activated state). The `postcondition` fails the `apply` if the sync reports `success = false` (the framework handler returns HTTP 200 with `success = false` on internal errors, so this check is required to surface failures):
1447
+ Since deploys are applied manually with Terraform, attach an `aws_lambda_invocation` that runs **on every `apply`**, right after the function code is updated. `timestamp()` changes on every plan, so it always re-runs (creation is idempotent and also picks up any newly-activated state).
1448
+
1449
+ > **Detecting failures in the deploy.** The top-level `success` is now **always `true`** — a single failed collection no longer aborts the run, so checking `success` alone won't catch partial failures. If you want the `apply` to fail when any collection failed, point the `postcondition` at `data.summary.failed` instead (shown below). If you'd rather the deploy never fail on index errors and only review them afterward (in the response/logs), simply omit the `lifecycle` block.
1435
1450
 
1436
1451
  ```hcl
1437
1452
  module "lambda" {
@@ -1449,10 +1464,13 @@ resource "aws_lambda_invocation" "sync_on_deploy" {
1449
1464
 
1450
1465
  depends_on = [module.lambda] # run after the code is updated
1451
1466
 
1467
+ # Optional: fail the apply if any collection failed to sync.
1468
+ # Omit this whole lifecycle block if you prefer the deploy to never fail on
1469
+ # index errors (review data.results[].success / logs instead).
1452
1470
  lifecycle {
1453
1471
  postcondition {
1454
- condition = try(jsondecode(self.result).success, false) == true
1455
- error_message = "Index sync failed during apply: ${self.result}"
1472
+ condition = try(jsondecode(self.result).data.summary.failed, 1) == 0
1473
+ error_message = "Index sync had failures during apply: ${self.result}"
1456
1474
  }
1457
1475
  }
1458
1476
  }
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aws-python-helper"
7
- version = "1.0.2"
7
+ version = "1.0.3"
8
8
  description = "AWS Python Helper Framework"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,114 +0,0 @@
1
- """
2
- ModelIndexSyncLambda - Synchronizes MongoDB indexes for a service's repositories.
3
-
4
- Creates (idempotently) every index declared in the service's repositories, in
5
- the correct databases:
6
- - Repositories with a fixed database_key (e.g. 'core') → that database.
7
- - State-scoped repositories (database_key is None) → every active state
8
- database, read from core.states (is_active=True). A state-scoped collection
9
- (e.g. 'parcels') therefore gets its indexes created in 'connecticut',
10
- 'new_jersey', etc.
11
-
12
- Designed to run once per deploy (and on demand), guaranteeing index creation
13
- without the per-request overhead of creating indexes at runtime.
14
-
15
- Usage — subclass and declare the repositories to sync:
16
-
17
- import aws_python_helper
18
- from repositories.parcels import ParcelsRepository
19
- from repositories.users import UsersRepository
20
-
21
- class PropertiesModelIndexSyncLambda(aws_python_helper.ModelIndexSyncLambda):
22
- @property
23
- def repositories(self) -> list:
24
- return [ParcelsRepository, UsersRepository]
25
-
26
- This Lambda does not require a session state (requires_state = False); it
27
- operates across all active states.
28
-
29
- Response (via Lambda base run()):
30
- {"success": True, "data": [
31
- {"repository": "ParcelsRepository", "database": "connecticut",
32
- "collection": "parcels", "indexes": ["parcel_id_1", ...]},
33
- {"repository": "UsersRepository", "database": "core",
34
- "collection": "users", "indexes": ["email_1"]},
35
- ...
36
- ]}
37
- """
38
-
39
- from typing import Any, List, Type
40
-
41
- from ..lambda_standalone.base import Lambda
42
- from ..repository.base import Repository
43
- from ..database.mongo_manager import MongoManager
44
-
45
-
46
- class ModelIndexSyncLambda(Lambda):
47
- """
48
- Base class for index-synchronization Lambdas.
49
-
50
- Subclass and override `repositories` to declare which repositories to sync.
51
- Everything else (resolving core vs state-scoped, reading active states and
52
- creating the indexes) is handled automatically.
53
- """
54
-
55
- @property
56
- def requires_state(self) -> bool:
57
- # Operates across all states; not tied to a single one.
58
- return False
59
-
60
- @property
61
- def repositories(self) -> List[Type[Repository]]:
62
- """
63
- Repositories whose indexes should be synchronized.
64
-
65
- Override returning a list of Repository subclasses (the classes, not
66
- instances). Declare them explicitly rather than auto-discovering, so the
67
- synced set is intentional and nothing is silently missed or added.
68
- """
69
- return []
70
-
71
- @property
72
- def states_database(self) -> str:
73
- """Database holding the states collection. Default: 'core'."""
74
- return "core"
75
-
76
- @property
77
- def states_collection(self) -> str:
78
- """Collection listing the states. Default: 'states'."""
79
- return "states"
80
-
81
- async def validate(self):
82
- if not self.repositories:
83
- raise ValueError(
84
- "repositories is not configured on this ModelIndexSyncLambda"
85
- )
86
-
87
- async def _active_states(self) -> List[str]:
88
- """Names of active states — i.e. the state-scoped database names."""
89
- db = MongoManager.get_database(self.states_database)
90
- cursor = db[self.states_collection].find(
91
- {"is_active": True}, {"name": 1, "_id": 0}
92
- )
93
- return [doc["name"] async for doc in cursor if doc.get("name")]
94
-
95
- async def process(self) -> Any:
96
- active_states = await self._active_states()
97
- report: List[dict] = []
98
-
99
- for repo_cls in self.repositories:
100
- repo = repo_cls()
101
- # database_key is None -> state-scoped (collection repeats per state db).
102
- is_state_scoped = repo.database_key is None and not repo.is_external
103
- target_databases = active_states if is_state_scoped else [repo.database_name]
104
-
105
- for db_name in target_databases:
106
- created = await repo.ensure_indexes(database_name=db_name)
107
- report.append({
108
- "repository": repo_cls.__name__,
109
- "database": db_name,
110
- "collection": repo.collection_name,
111
- "indexes": created,
112
- })
113
-
114
- return report