aws-python-helper 1.0.0__tar.gz → 1.0.2__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 (55) hide show
  1. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/PKG-INFO +138 -5
  2. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/README.md +137 -4
  3. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/__init__.py +5 -1
  4. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/api/fetcher.py +4 -3
  5. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/fargate/fetcher.py +4 -3
  6. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/lambda_standalone/base.py +24 -5
  7. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/lambda_standalone/fetcher.py +4 -3
  8. aws_python_helper-1.0.2/aws_python_helper/model_index_sync/__init__.py +3 -0
  9. aws_python_helper-1.0.2/aws_python_helper/model_index_sync/base.py +114 -0
  10. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/repository/base.py +40 -22
  11. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/sqs/fetcher.py +4 -3
  12. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper.egg-info/PKG-INFO +138 -5
  13. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper.egg-info/SOURCES.txt +2 -0
  14. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/pyproject.toml +1 -1
  15. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/api/__init__.py +0 -0
  16. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/api/auth_middleware.py +0 -0
  17. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/api/auth_validators.py +0 -0
  18. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/api/base.py +0 -0
  19. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/api/dispatcher.py +0 -0
  20. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/api/exceptions.py +0 -0
  21. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/api/handler.py +0 -0
  22. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/context/__init__.py +0 -0
  23. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/context/session.py +0 -0
  24. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/context/state_validator.py +0 -0
  25. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/database/__init__.py +0 -0
  26. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/database/database_proxy.py +0 -0
  27. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/database/external_database_proxy.py +0 -0
  28. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/database/external_mongo_manager.py +0 -0
  29. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/database/mongo_manager.py +0 -0
  30. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/fargate/__init__.py +0 -0
  31. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/fargate/executor.py +0 -0
  32. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/fargate/handler.py +0 -0
  33. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/fargate/task_base.py +0 -0
  34. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/invoker/__init__.py +0 -0
  35. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/invoker/api_client.py +0 -0
  36. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/invoker/exceptions.py +0 -0
  37. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/invoker/lambda_invoker.py +0 -0
  38. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/lambda_standalone/__init__.py +0 -0
  39. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/lambda_standalone/handler.py +0 -0
  40. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/model_query/__init__.py +0 -0
  41. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/model_query/base.py +0 -0
  42. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/repository/__init__.py +0 -0
  43. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/sns/__init__.py +0 -0
  44. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/sns/publisher.py +0 -0
  45. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/sqs/__init__.py +0 -0
  46. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/sqs/consumer_base.py +0 -0
  47. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/sqs/handler.py +0 -0
  48. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/utils/__init__.py +0 -0
  49. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/utils/json_encoder.py +0 -0
  50. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/utils/response.py +0 -0
  51. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper/utils/serializer.py +0 -0
  52. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper.egg-info/dependency_links.txt +0 -0
  53. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper.egg-info/requires.txt +0 -0
  54. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/aws_python_helper.egg-info/top_level.txt +0 -0
  55. {aws_python_helper-1.0.0 → aws_python_helper-1.0.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-python-helper
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: AWS Python Helper Framework
5
5
  Author-email: Fabian Claros <neufabiae@gmail.com>
6
6
  License-Expression: MIT
@@ -39,6 +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
43
  - **JSON utilities**: Automatic serialization of MongoDB types
43
44
  - **Type hints**: Modern Python with type annotations
44
45
  - **Async/await**: Full support for asynchronous operations
@@ -67,6 +68,7 @@ All available classes and functions:
67
68
  | `fargate_handler` | `aws_python_helper.fargate.handler` | Entry point handler for Fargate |
68
69
  | `Repository` | `aws_python_helper.repository.base` | Base class for MongoDB repositories |
69
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) |
70
72
  | `LambdaInvoker` | `aws_python_helper` | Invoke Lambda functions (sync/async) with error handling |
71
73
  | `ApiClient` | `aws_python_helper` | HTTP client for inter-service communication |
72
74
  | `LambdaInvocationError` | `aws_python_helper` | Raised when boto3 fails to invoke a Lambda |
@@ -77,6 +79,8 @@ All available classes and functions:
77
79
  | `Session` | `aws_python_helper` | Request-scoped session object (state + user) |
78
80
  | `get_session` | `aws_python_helper` | Read the current Session from async context |
79
81
  | `set_session` | `aws_python_helper` | Set the current Session in async context |
82
+ | `StateValidator` | `aws_python_helper` | Validates a state against `core.states` (cached, active states only) |
83
+ | `InvalidStateError` | `aws_python_helper` | Raised when a state is not found or not active |
80
84
  | `MongoJSONEncoder` | `aws_python_helper.utils.json_encoder` | JSON encoder for MongoDB types |
81
85
  | `mongo_json_dumps` | `aws_python_helper.utils.json_encoder` | Helper to serialize MongoDB types |
82
86
  | `serialize_mongo_types` | `aws_python_helper.utils.serializer` | Recursively serialize MongoDB types |
@@ -250,6 +254,15 @@ class GenerateRouteLambda(Lambda):
250
254
  }
251
255
  ```
252
256
 
257
+ > **`requires_state` (session requirement)** — By default (`requires_state = True`) the framework requires the event to carry a `session` with a valid `state` and validates it against `core.states` before running. Override `requires_state` to return `False` for Lambdas that operate across **all** states and must not be tied to a single one (e.g. index synchronization) — the `session`/`state` then becomes optional. See [ModelIndexSyncLambda](#-modelindexsynclambda).
258
+ >
259
+ > ```python
260
+ > class SyncAllStatesLambda(Lambda):
261
+ > @property
262
+ > def requires_state(self) -> bool:
263
+ > return False # no session.state required; runs across every state
264
+ > ```
265
+
253
266
  **2. Configure the handler** in `src/handlers/lambda_handler.py`:
254
267
 
255
268
  ```python
@@ -511,7 +524,9 @@ class AddressAPI(API):
511
524
 
512
525
  ## 🗂️ Repository Pattern
513
526
 
514
- The framework provides a `Repository` base class that eliminates repetitive boilerplate in data access layers. Each repository only declares what collection it uses, whether it belongs to an external cluster, and what indexes to create. The base class handles the MongoDB connection and index creation automatically.
527
+ The framework provides a `Repository` base class that eliminates repetitive boilerplate in data access layers. Each repository only declares what collection it uses, whether it belongs to an external cluster, and what indexes it needs. The base class handles the MongoDB connection automatically.
528
+
529
+ > **Index creation is not triggered at request time.** Declaring `indexes` only *describes* the indexes; it does not create them on collection access (this avoids per-request overhead and half-finished index builds in short-lived Lambda/API runtimes). Indexes are created by the [`ModelIndexSyncLambda`](#-modelindexsynclambda) — run once per deploy — or by calling `await repo.ensure_indexes()` explicitly.
515
530
 
516
531
  ### Properties to override
517
532
 
@@ -542,7 +557,15 @@ def indexes(self):
542
557
  ]
543
558
  ```
544
559
 
545
- Indexes are created automatically in the background on first collection access no need to call any initialization method.
560
+ These definitions are consumed by `ensure_indexes()`, which the [`ModelIndexSyncLambda`](#-modelindexsynclambda) runs across the relevant databases at deploy time. `ensure_indexes()` is idempotent (safe to re-run) and accepts an explicit `database_name` so the same state-scoped repository can be materialized in every state database:
561
+
562
+ ```python
563
+ # Create this repository's indexes in a specific database (what the index-sync Lambda does):
564
+ await repo.ensure_indexes(database_name="connecticut")
565
+
566
+ # Or, with no argument, in the repository's resolved database (database_key or session.state):
567
+ await repo.ensure_indexes()
568
+ ```
546
569
 
547
570
  ### Repository with a fixed database
548
571
 
@@ -669,7 +692,7 @@ The framework propagates a `Session` object automatically across the entire asyn
669
692
  | Entry point | How the session is read |
670
693
  |-------------|-------------------------|
671
694
  | **API Gateway** | `constitution-state` header → `session.state` (when `AUTHORIZATION` includes `state`); auth middleware → `session.user` (when includes `user`). Returns `400` if required header is missing |
672
- | **Standalone Lambda** | `session` dict in the event payload — **required** (must include `state`), raises `ValueError` if missing |
695
+ | **Standalone Lambda** | `session` dict in the event payload — **required by default** (must include `state`), raises `ValueError` if missing. A Lambda can opt out via `requires_state = False` (e.g. `ModelIndexSyncLambda`), making the session optional |
673
696
  | **SQS Consumer (single mode)** | Per-record: reads `session` from SNS `MessageAttributes` (Base64-encoded JSON) |
674
697
  | **SQS Consumer (batch mode)** | Groups records by `session.state`; calls `process_batch()` once per group with the correct session in context |
675
698
  | **Fargate Task** | `SESSION` env var (JSON) — auto-injected by `FargateExecutor` |
@@ -1179,13 +1202,15 @@ Create `src/lambda/model-query/main.py` in the microservice that owns the data:
1179
1202
  ```python
1180
1203
  from aws_python_helper import ModelQueryLambda
1181
1204
 
1182
- class ModelQueryLambda(ModelQueryLambda):
1205
+ class OrdersModelQueryLambda(ModelQueryLambda):
1183
1206
 
1184
1207
  @property
1185
1208
  def allowed_collections(self) -> list:
1186
1209
  return ["orders", "customers"] # whitelist — unlisted collections are rejected
1187
1210
  ```
1188
1211
 
1212
+ > **Class discovery:** name your subclass distinctly from the imported base (e.g. `OrdersModelQueryLambda`, not `ModelQueryLambda`). The framework loads the class **defined in the file** — not the imported base — so subclassing an imported concrete base (`ModelQueryLambda`, `ModelIndexSyncLambda`) works without name collisions.
1213
+
1189
1214
  Register the handler in `src/handlers/lambda_handler.py`:
1190
1215
 
1191
1216
  ```python
@@ -1333,6 +1358,114 @@ Always pass `self.session.to_dict()` when calling from an API or Lambda to ensur
1333
1358
 
1334
1359
  ---
1335
1360
 
1361
+ ## 🧱 ModelIndexSyncLambda
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**.
1364
+
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
+
1367
+ ### Creating an index-sync Lambda
1368
+
1369
+ Create `src/lambda/model-index-sync/main.py` and declare which repositories to sync:
1370
+
1371
+ ```python
1372
+ import aws_python_helper
1373
+
1374
+ from repositories.parcels import ParcelsRepository # state-scoped (database_key = None)
1375
+ from repositories.users import UsersRepository # core (database_key = "core")
1376
+ from repositories.tokens import TokensRepository # core
1377
+
1378
+ class OrdersModelIndexSyncLambda(aws_python_helper.ModelIndexSyncLambda):
1379
+
1380
+ @property
1381
+ def repositories(self) -> list:
1382
+ return [ParcelsRepository, UsersRepository, TokensRepository]
1383
+ ```
1384
+
1385
+ Register the handler in `src/handlers/lambda_handler.py`:
1386
+
1387
+ ```python
1388
+ from aws_python_helper.lambda_standalone.handler import lambda_handler
1389
+
1390
+ index_sync_handler = lambda_handler('model-index-sync')
1391
+
1392
+ __all__ = ["index_sync_handler"]
1393
+ ```
1394
+
1395
+ > Declare repositories **explicitly** (don't auto-discover) so the synced set is intentional. List your own collections; **external** repositories (`is_external = True`) belong to another cluster/service and are typically excluded.
1396
+
1397
+ ### How it routes each repository
1398
+
1399
+ For every repository in the list, the Lambda decides where to create its indexes:
1400
+
1401
+ | Repository kind | `database_key` | Target databases |
1402
+ |-----------------|----------------|------------------|
1403
+ | **Core / fixed** | `"core"` (or any string) | that single database |
1404
+ | **State-scoped** | `None` | **every active state**, read from `core.states` where `is_active = true` (e.g. `connecticut`, `new_jersey`, …) |
1405
+
1406
+ A state-scoped collection such as `parcels` therefore gets its indexes created in `connecticut`, `new_jersey`, and any other active state — automatically. Creation is idempotent, so re-running it is safe.
1407
+
1408
+ This Lambda sets `requires_state = False`: it is not tied to a single state and does not require a `session` in the event.
1409
+
1410
+ ### Response format
1411
+
1412
+ ```json
1413
+ {
1414
+ "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
+ ]
1420
+ }
1421
+ ```
1422
+
1423
+ ### Properties to override / configure
1424
+
1425
+ | Property | Default | Purpose |
1426
+ |----------|---------|---------|
1427
+ | `repositories` | `[]` | **Required.** List of `Repository` subclasses (the classes, not instances) to sync. Empty raises a validation error |
1428
+ | `states_database` | `"core"` | Database holding the states collection |
1429
+ | `states_collection` | `"states"` | Collection listing the states (filtered by `is_active = true`, field `name`) |
1430
+ | `requires_state` | `False` | Inherited override — the Lambda runs across all states, no session required |
1431
+
1432
+ ### Running it once per deploy (Terraform)
1433
+
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):
1435
+
1436
+ ```hcl
1437
+ module "lambda" {
1438
+ source = "../../../../../modules/lambda"
1439
+ function_name = "${...service_name}${var.lambda_name}-${...env}"
1440
+ source_handler = "handlers.lambda_handler.index_sync_handler"
1441
+ timeout = 300
1442
+ memory_size = 512
1443
+ # ... mongo + vpc config (same as any other Lambda)
1444
+ }
1445
+
1446
+ resource "aws_lambda_invocation" "sync_on_deploy" {
1447
+ function_name = module.lambda.lambda_function_name
1448
+ input = jsonencode({ _invoked_at = timestamp() }) # always changes → always re-runs
1449
+
1450
+ depends_on = [module.lambda] # run after the code is updated
1451
+
1452
+ lifecycle {
1453
+ postcondition {
1454
+ condition = try(jsondecode(self.result).success, false) == true
1455
+ error_message = "Index sync failed during apply: ${self.result}"
1456
+ }
1457
+ }
1458
+ }
1459
+ ```
1460
+
1461
+ You can also invoke it manually at any time (e.g. after activating a new state without a code deploy):
1462
+
1463
+ ```bash
1464
+ aws lambda invoke --function-name MyServiceModelIndexSync-dev out.json
1465
+ ```
1466
+
1467
+ ---
1468
+
1336
1469
  ## 🏗️ Architecture Overview
1337
1470
 
1338
1471
  Typical flow for event-driven architectures using this framework:
@@ -19,6 +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
23
  - **JSON utilities**: Automatic serialization of MongoDB types
23
24
  - **Type hints**: Modern Python with type annotations
24
25
  - **Async/await**: Full support for asynchronous operations
@@ -47,6 +48,7 @@ All available classes and functions:
47
48
  | `fargate_handler` | `aws_python_helper.fargate.handler` | Entry point handler for Fargate |
48
49
  | `Repository` | `aws_python_helper.repository.base` | Base class for MongoDB repositories |
49
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) |
50
52
  | `LambdaInvoker` | `aws_python_helper` | Invoke Lambda functions (sync/async) with error handling |
51
53
  | `ApiClient` | `aws_python_helper` | HTTP client for inter-service communication |
52
54
  | `LambdaInvocationError` | `aws_python_helper` | Raised when boto3 fails to invoke a Lambda |
@@ -57,6 +59,8 @@ All available classes and functions:
57
59
  | `Session` | `aws_python_helper` | Request-scoped session object (state + user) |
58
60
  | `get_session` | `aws_python_helper` | Read the current Session from async context |
59
61
  | `set_session` | `aws_python_helper` | Set the current Session in async context |
62
+ | `StateValidator` | `aws_python_helper` | Validates a state against `core.states` (cached, active states only) |
63
+ | `InvalidStateError` | `aws_python_helper` | Raised when a state is not found or not active |
60
64
  | `MongoJSONEncoder` | `aws_python_helper.utils.json_encoder` | JSON encoder for MongoDB types |
61
65
  | `mongo_json_dumps` | `aws_python_helper.utils.json_encoder` | Helper to serialize MongoDB types |
62
66
  | `serialize_mongo_types` | `aws_python_helper.utils.serializer` | Recursively serialize MongoDB types |
@@ -230,6 +234,15 @@ class GenerateRouteLambda(Lambda):
230
234
  }
231
235
  ```
232
236
 
237
+ > **`requires_state` (session requirement)** — By default (`requires_state = True`) the framework requires the event to carry a `session` with a valid `state` and validates it against `core.states` before running. Override `requires_state` to return `False` for Lambdas that operate across **all** states and must not be tied to a single one (e.g. index synchronization) — the `session`/`state` then becomes optional. See [ModelIndexSyncLambda](#-modelindexsynclambda).
238
+ >
239
+ > ```python
240
+ > class SyncAllStatesLambda(Lambda):
241
+ > @property
242
+ > def requires_state(self) -> bool:
243
+ > return False # no session.state required; runs across every state
244
+ > ```
245
+
233
246
  **2. Configure the handler** in `src/handlers/lambda_handler.py`:
234
247
 
235
248
  ```python
@@ -491,7 +504,9 @@ class AddressAPI(API):
491
504
 
492
505
  ## 🗂️ Repository Pattern
493
506
 
494
- The framework provides a `Repository` base class that eliminates repetitive boilerplate in data access layers. Each repository only declares what collection it uses, whether it belongs to an external cluster, and what indexes to create. The base class handles the MongoDB connection and index creation automatically.
507
+ The framework provides a `Repository` base class that eliminates repetitive boilerplate in data access layers. Each repository only declares what collection it uses, whether it belongs to an external cluster, and what indexes it needs. The base class handles the MongoDB connection automatically.
508
+
509
+ > **Index creation is not triggered at request time.** Declaring `indexes` only *describes* the indexes; it does not create them on collection access (this avoids per-request overhead and half-finished index builds in short-lived Lambda/API runtimes). Indexes are created by the [`ModelIndexSyncLambda`](#-modelindexsynclambda) — run once per deploy — or by calling `await repo.ensure_indexes()` explicitly.
495
510
 
496
511
  ### Properties to override
497
512
 
@@ -522,7 +537,15 @@ def indexes(self):
522
537
  ]
523
538
  ```
524
539
 
525
- Indexes are created automatically in the background on first collection access no need to call any initialization method.
540
+ These definitions are consumed by `ensure_indexes()`, which the [`ModelIndexSyncLambda`](#-modelindexsynclambda) runs across the relevant databases at deploy time. `ensure_indexes()` is idempotent (safe to re-run) and accepts an explicit `database_name` so the same state-scoped repository can be materialized in every state database:
541
+
542
+ ```python
543
+ # Create this repository's indexes in a specific database (what the index-sync Lambda does):
544
+ await repo.ensure_indexes(database_name="connecticut")
545
+
546
+ # Or, with no argument, in the repository's resolved database (database_key or session.state):
547
+ await repo.ensure_indexes()
548
+ ```
526
549
 
527
550
  ### Repository with a fixed database
528
551
 
@@ -649,7 +672,7 @@ The framework propagates a `Session` object automatically across the entire asyn
649
672
  | Entry point | How the session is read |
650
673
  |-------------|-------------------------|
651
674
  | **API Gateway** | `constitution-state` header → `session.state` (when `AUTHORIZATION` includes `state`); auth middleware → `session.user` (when includes `user`). Returns `400` if required header is missing |
652
- | **Standalone Lambda** | `session` dict in the event payload — **required** (must include `state`), raises `ValueError` if missing |
675
+ | **Standalone Lambda** | `session` dict in the event payload — **required by default** (must include `state`), raises `ValueError` if missing. A Lambda can opt out via `requires_state = False` (e.g. `ModelIndexSyncLambda`), making the session optional |
653
676
  | **SQS Consumer (single mode)** | Per-record: reads `session` from SNS `MessageAttributes` (Base64-encoded JSON) |
654
677
  | **SQS Consumer (batch mode)** | Groups records by `session.state`; calls `process_batch()` once per group with the correct session in context |
655
678
  | **Fargate Task** | `SESSION` env var (JSON) — auto-injected by `FargateExecutor` |
@@ -1159,13 +1182,15 @@ Create `src/lambda/model-query/main.py` in the microservice that owns the data:
1159
1182
  ```python
1160
1183
  from aws_python_helper import ModelQueryLambda
1161
1184
 
1162
- class ModelQueryLambda(ModelQueryLambda):
1185
+ class OrdersModelQueryLambda(ModelQueryLambda):
1163
1186
 
1164
1187
  @property
1165
1188
  def allowed_collections(self) -> list:
1166
1189
  return ["orders", "customers"] # whitelist — unlisted collections are rejected
1167
1190
  ```
1168
1191
 
1192
+ > **Class discovery:** name your subclass distinctly from the imported base (e.g. `OrdersModelQueryLambda`, not `ModelQueryLambda`). The framework loads the class **defined in the file** — not the imported base — so subclassing an imported concrete base (`ModelQueryLambda`, `ModelIndexSyncLambda`) works without name collisions.
1193
+
1169
1194
  Register the handler in `src/handlers/lambda_handler.py`:
1170
1195
 
1171
1196
  ```python
@@ -1313,6 +1338,114 @@ Always pass `self.session.to_dict()` when calling from an API or Lambda to ensur
1313
1338
 
1314
1339
  ---
1315
1340
 
1341
+ ## 🧱 ModelIndexSyncLambda
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**.
1344
+
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
+
1347
+ ### Creating an index-sync Lambda
1348
+
1349
+ Create `src/lambda/model-index-sync/main.py` and declare which repositories to sync:
1350
+
1351
+ ```python
1352
+ import aws_python_helper
1353
+
1354
+ from repositories.parcels import ParcelsRepository # state-scoped (database_key = None)
1355
+ from repositories.users import UsersRepository # core (database_key = "core")
1356
+ from repositories.tokens import TokensRepository # core
1357
+
1358
+ class OrdersModelIndexSyncLambda(aws_python_helper.ModelIndexSyncLambda):
1359
+
1360
+ @property
1361
+ def repositories(self) -> list:
1362
+ return [ParcelsRepository, UsersRepository, TokensRepository]
1363
+ ```
1364
+
1365
+ Register the handler in `src/handlers/lambda_handler.py`:
1366
+
1367
+ ```python
1368
+ from aws_python_helper.lambda_standalone.handler import lambda_handler
1369
+
1370
+ index_sync_handler = lambda_handler('model-index-sync')
1371
+
1372
+ __all__ = ["index_sync_handler"]
1373
+ ```
1374
+
1375
+ > Declare repositories **explicitly** (don't auto-discover) so the synced set is intentional. List your own collections; **external** repositories (`is_external = True`) belong to another cluster/service and are typically excluded.
1376
+
1377
+ ### How it routes each repository
1378
+
1379
+ For every repository in the list, the Lambda decides where to create its indexes:
1380
+
1381
+ | Repository kind | `database_key` | Target databases |
1382
+ |-----------------|----------------|------------------|
1383
+ | **Core / fixed** | `"core"` (or any string) | that single database |
1384
+ | **State-scoped** | `None` | **every active state**, read from `core.states` where `is_active = true` (e.g. `connecticut`, `new_jersey`, …) |
1385
+
1386
+ A state-scoped collection such as `parcels` therefore gets its indexes created in `connecticut`, `new_jersey`, and any other active state — automatically. Creation is idempotent, so re-running it is safe.
1387
+
1388
+ This Lambda sets `requires_state = False`: it is not tied to a single state and does not require a `session` in the event.
1389
+
1390
+ ### Response format
1391
+
1392
+ ```json
1393
+ {
1394
+ "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
+ ]
1400
+ }
1401
+ ```
1402
+
1403
+ ### Properties to override / configure
1404
+
1405
+ | Property | Default | Purpose |
1406
+ |----------|---------|---------|
1407
+ | `repositories` | `[]` | **Required.** List of `Repository` subclasses (the classes, not instances) to sync. Empty raises a validation error |
1408
+ | `states_database` | `"core"` | Database holding the states collection |
1409
+ | `states_collection` | `"states"` | Collection listing the states (filtered by `is_active = true`, field `name`) |
1410
+ | `requires_state` | `False` | Inherited override — the Lambda runs across all states, no session required |
1411
+
1412
+ ### Running it once per deploy (Terraform)
1413
+
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):
1415
+
1416
+ ```hcl
1417
+ module "lambda" {
1418
+ source = "../../../../../modules/lambda"
1419
+ function_name = "${...service_name}${var.lambda_name}-${...env}"
1420
+ source_handler = "handlers.lambda_handler.index_sync_handler"
1421
+ timeout = 300
1422
+ memory_size = 512
1423
+ # ... mongo + vpc config (same as any other Lambda)
1424
+ }
1425
+
1426
+ resource "aws_lambda_invocation" "sync_on_deploy" {
1427
+ function_name = module.lambda.lambda_function_name
1428
+ input = jsonencode({ _invoked_at = timestamp() }) # always changes → always re-runs
1429
+
1430
+ depends_on = [module.lambda] # run after the code is updated
1431
+
1432
+ lifecycle {
1433
+ postcondition {
1434
+ condition = try(jsondecode(self.result).success, false) == true
1435
+ error_message = "Index sync failed during apply: ${self.result}"
1436
+ }
1437
+ }
1438
+ }
1439
+ ```
1440
+
1441
+ You can also invoke it manually at any time (e.g. after activating a new state without a code deploy):
1442
+
1443
+ ```bash
1444
+ aws lambda invoke --function-name MyServiceModelIndexSync-dev out.json
1445
+ ```
1446
+
1447
+ ---
1448
+
1316
1449
  ## 🏗️ Architecture Overview
1317
1450
 
1318
1451
  Typical flow for event-driven architectures using this framework:
@@ -2,7 +2,7 @@
2
2
  AWS Python Framework
3
3
  """
4
4
 
5
- __version__ = "0.1.0"
5
+ __version__ = "1.0.2"
6
6
 
7
7
  # All classes
8
8
  from .api.base import API
@@ -34,6 +34,9 @@ from .context.state_validator import StateValidator, InvalidStateError
34
34
  # Model Query
35
35
  from .model_query import ModelQueryLambda
36
36
 
37
+ # Model Index Sync
38
+ from .model_index_sync import ModelIndexSyncLambda
39
+
37
40
  # Invoker
38
41
  from .invoker import (
39
42
  LambdaInvoker,
@@ -69,6 +72,7 @@ __all__ = [
69
72
  'StateValidator',
70
73
  'InvalidStateError',
71
74
  'ModelQueryLambda',
75
+ 'ModelIndexSyncLambda',
72
76
  'LambdaInvoker',
73
77
  'ApiClient',
74
78
  'LambdaInvocationError',
@@ -190,9 +190,10 @@ class Fetcher:
190
190
  controller_class = None
191
191
  for item_name in dir(module):
192
192
  item = getattr(module, item_name)
193
- if (isinstance(item, type) and
194
- hasattr(item, 'process') and
195
- item.__name__ not in ['API', 'ABC']):
193
+ if (isinstance(item, type) and
194
+ hasattr(item, 'process') and
195
+ item.__name__ not in ['API', 'ABC'] and
196
+ item.__module__ == module.__name__):
196
197
  controller_class = item
197
198
  break
198
199
 
@@ -98,9 +98,10 @@ class FargateTaskFetcher:
98
98
  task_class = None
99
99
  for item_name in dir(module):
100
100
  item = getattr(module, item_name)
101
- if (isinstance(item, type) and
102
- hasattr(item, 'execute') and
103
- item.__name__ not in ['FargateTask', 'ABC']):
101
+ if (isinstance(item, type) and
102
+ hasattr(item, 'execute') and
103
+ item.__name__ not in ['FargateTask', 'ABC'] and
104
+ item.__module__ == module.__name__):
104
105
  task_class = item
105
106
  break
106
107
 
@@ -47,6 +47,21 @@ class Lambda(ABC):
47
47
  """
48
48
  return get_session()
49
49
 
50
+ @property
51
+ def requires_state(self) -> bool:
52
+ """
53
+ Whether this Lambda requires a valid 'state' in the session.
54
+
55
+ Default True: run() enforces that the event carries session.state and
56
+ validates it against core.states before processing.
57
+
58
+ Override to return False for Lambdas that operate across all states and
59
+ must not be tied to a single one (e.g. index synchronization). When
60
+ False, a session is still set if provided, but state is not required
61
+ nor validated.
62
+ """
63
+ return True
64
+
50
65
  @property
51
66
  def schema(self):
52
67
  """
@@ -188,11 +203,15 @@ class Lambda(ABC):
188
203
  try:
189
204
  # Step 0: Setup session context
190
205
  session_data = self.event.get('session')
191
- if not session_data or not session_data.get('state'):
192
- raise ValueError("'session' with 'state' is required in the event")
193
- session = Session.from_dict(session_data)
194
- await StateValidator.validate(session.state)
195
- set_session(session)
206
+ if self.requires_state:
207
+ if not session_data or not session_data.get('state'):
208
+ raise ValueError("'session' with 'state' is required in the event")
209
+ session = Session.from_dict(session_data)
210
+ await StateValidator.validate(session.state)
211
+ set_session(session)
212
+ elif session_data:
213
+ # state not required, but honor a session if one was provided
214
+ set_session(Session.from_dict(session_data))
196
215
 
197
216
  # Step 1: Schema validation (automatic, if schema property is defined)
198
217
  if self.schema is not None:
@@ -100,9 +100,10 @@ class LambdaFetcher:
100
100
  lambda_class = None
101
101
  for item_name in dir(module):
102
102
  item = getattr(module, item_name)
103
- if (isinstance(item, type) and
104
- issubclass(item, Lambda) and
105
- item.__name__ not in ['Lambda', 'ABC']):
103
+ if (isinstance(item, type) and
104
+ issubclass(item, Lambda) and
105
+ item.__name__ not in ['Lambda', 'ABC'] and
106
+ item.__module__ == module.__name__):
106
107
  lambda_class = item
107
108
  break
108
109
 
@@ -0,0 +1,3 @@
1
+ from .base import ModelIndexSyncLambda
2
+
3
+ __all__ = ["ModelIndexSyncLambda"]
@@ -0,0 +1,114 @@
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
@@ -6,7 +6,6 @@ collection access, and index creation without requiring the user to
6
6
  pass a database connection or call any initialization method.
7
7
  """
8
8
 
9
- import asyncio
10
9
  import logging
11
10
  from abc import ABC, abstractmethod
12
11
  from typing import Any, Dict, List, Optional, Tuple
@@ -183,8 +182,9 @@ class Repository(ABC):
183
182
  so state-scoped repositories resolve the correct collection for each
184
183
  request state without cross-contamination between requests.
185
184
 
186
- On first access for a given key, schedules index creation as a
187
- background asyncio task so indexes are created without blocking the caller.
185
+ Index creation is NOT triggered here: indexes are created exclusively
186
+ by the index-sync Lambda (see ensure_indexes), so request-time access
187
+ carries no index-creation overhead.
188
188
  """
189
189
  key = (self.database_name, self.collection_name)
190
190
 
@@ -200,29 +200,45 @@ class Repository(ABC):
200
200
 
201
201
  self._collection_cache[key] = db[self.collection_name]
202
202
 
203
- # Schedule index creation as a background task on the running event loop.
204
- if self.indexes and not self._indexes_created.get(key):
205
- try:
206
- asyncio.get_running_loop().create_task(self.ensure_indexes())
207
- except RuntimeError:
208
- pass # No running event loop (e.g. synchronous test context)
209
-
210
203
  return self._collection_cache[key]
211
204
 
212
- async def ensure_indexes(self):
205
+ async def ensure_indexes(self, database_name: Optional[str] = None):
213
206
  """
214
- Creates all indexes defined in the `indexes` property.
207
+ Creates all indexes defined in the `indexes` property, blocking until done.
208
+
209
+ Args:
210
+ database_name: Target database to create the indexes in.
211
+ - If None (default): uses the resolved database_name
212
+ (database_key or the current session state).
213
+ - If set: creates the indexes in that specific database. This is
214
+ used by the index-sync Lambda to create the indexes of a
215
+ state-scoped repository across every active state database
216
+ (e.g. 'connecticut', 'new_jersey', ...).
217
+
218
+ Index creation is no longer triggered automatically at request time;
219
+ call this explicitly (the index-sync Lambda does) to guarantee creation.
215
220
 
216
- Called automatically in background on first collection access.
217
- Can also be called explicitly at the start of a method when index
218
- creation must be guaranteed to complete before proceeding.
221
+ Idempotent per (database, collection): safe to call multiple times.
219
222
 
220
- Idempotent: safe to call multiple times, only runs once per (database, collection).
223
+ Returns:
224
+ List of created index names (as returned by create_index).
221
225
  """
222
- key = (self.database_name, self.collection_name)
226
+ db_name = database_name or self.database_name
227
+ key = (db_name, self.collection_name)
223
228
  if self._indexes_created.get(key):
224
- return
225
-
229
+ return []
230
+
231
+ if self.is_external:
232
+ if not self.cluster_name:
233
+ raise ValueError(
234
+ f"{self.__class__.__name__}: 'cluster_name' is required when is_external=True"
235
+ )
236
+ db = ExternalMongoManager.get_database(self.cluster_name, db_name)
237
+ else:
238
+ db = MongoManager.get_database(db_name)
239
+ collection = db[self.collection_name]
240
+
241
+ created: List[str] = []
226
242
  for index_def in self.indexes:
227
243
  index_key = index_def.get("key")
228
244
  if not index_key:
@@ -231,9 +247,11 @@ class Repository(ABC):
231
247
  options = {k: v for k, v in index_def.items() if k != "key"}
232
248
  options.setdefault("background", True)
233
249
  try:
234
- await self.collection.create_index(index_key, **options)
235
- self.logger.debug(f"Index created: {index_key}")
250
+ name = await collection.create_index(index_key, **options)
251
+ created.append(name)
252
+ self.logger.debug(f"Index created on '{db_name}.{self.collection_name}': {index_key}")
236
253
  except Exception as e:
237
- self.logger.error(f"Error creating index {index_key}: {e}")
254
+ self.logger.error(f"Error creating index {index_key} on '{db_name}': {e}")
238
255
 
239
256
  self._indexes_created[key] = True
257
+ return created
@@ -91,9 +91,10 @@ class SQSFetcher:
91
91
  consumer_class = None
92
92
  for item_name in dir(module):
93
93
  item = getattr(module, item_name)
94
- if (isinstance(item, type) and
95
- hasattr(item, 'process_record') and
96
- item.__name__ not in ['SQSConsumer', 'ABC']):
94
+ if (isinstance(item, type) and
95
+ hasattr(item, 'process_record') and
96
+ item.__name__ not in ['SQSConsumer', 'ABC'] and
97
+ item.__module__ == module.__name__):
97
98
  consumer_class = item
98
99
  break
99
100
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-python-helper
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: AWS Python Helper Framework
5
5
  Author-email: Fabian Claros <neufabiae@gmail.com>
6
6
  License-Expression: MIT
@@ -39,6 +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
43
  - **JSON utilities**: Automatic serialization of MongoDB types
43
44
  - **Type hints**: Modern Python with type annotations
44
45
  - **Async/await**: Full support for asynchronous operations
@@ -67,6 +68,7 @@ All available classes and functions:
67
68
  | `fargate_handler` | `aws_python_helper.fargate.handler` | Entry point handler for Fargate |
68
69
  | `Repository` | `aws_python_helper.repository.base` | Base class for MongoDB repositories |
69
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) |
70
72
  | `LambdaInvoker` | `aws_python_helper` | Invoke Lambda functions (sync/async) with error handling |
71
73
  | `ApiClient` | `aws_python_helper` | HTTP client for inter-service communication |
72
74
  | `LambdaInvocationError` | `aws_python_helper` | Raised when boto3 fails to invoke a Lambda |
@@ -77,6 +79,8 @@ All available classes and functions:
77
79
  | `Session` | `aws_python_helper` | Request-scoped session object (state + user) |
78
80
  | `get_session` | `aws_python_helper` | Read the current Session from async context |
79
81
  | `set_session` | `aws_python_helper` | Set the current Session in async context |
82
+ | `StateValidator` | `aws_python_helper` | Validates a state against `core.states` (cached, active states only) |
83
+ | `InvalidStateError` | `aws_python_helper` | Raised when a state is not found or not active |
80
84
  | `MongoJSONEncoder` | `aws_python_helper.utils.json_encoder` | JSON encoder for MongoDB types |
81
85
  | `mongo_json_dumps` | `aws_python_helper.utils.json_encoder` | Helper to serialize MongoDB types |
82
86
  | `serialize_mongo_types` | `aws_python_helper.utils.serializer` | Recursively serialize MongoDB types |
@@ -250,6 +254,15 @@ class GenerateRouteLambda(Lambda):
250
254
  }
251
255
  ```
252
256
 
257
+ > **`requires_state` (session requirement)** — By default (`requires_state = True`) the framework requires the event to carry a `session` with a valid `state` and validates it against `core.states` before running. Override `requires_state` to return `False` for Lambdas that operate across **all** states and must not be tied to a single one (e.g. index synchronization) — the `session`/`state` then becomes optional. See [ModelIndexSyncLambda](#-modelindexsynclambda).
258
+ >
259
+ > ```python
260
+ > class SyncAllStatesLambda(Lambda):
261
+ > @property
262
+ > def requires_state(self) -> bool:
263
+ > return False # no session.state required; runs across every state
264
+ > ```
265
+
253
266
  **2. Configure the handler** in `src/handlers/lambda_handler.py`:
254
267
 
255
268
  ```python
@@ -511,7 +524,9 @@ class AddressAPI(API):
511
524
 
512
525
  ## 🗂️ Repository Pattern
513
526
 
514
- The framework provides a `Repository` base class that eliminates repetitive boilerplate in data access layers. Each repository only declares what collection it uses, whether it belongs to an external cluster, and what indexes to create. The base class handles the MongoDB connection and index creation automatically.
527
+ The framework provides a `Repository` base class that eliminates repetitive boilerplate in data access layers. Each repository only declares what collection it uses, whether it belongs to an external cluster, and what indexes it needs. The base class handles the MongoDB connection automatically.
528
+
529
+ > **Index creation is not triggered at request time.** Declaring `indexes` only *describes* the indexes; it does not create them on collection access (this avoids per-request overhead and half-finished index builds in short-lived Lambda/API runtimes). Indexes are created by the [`ModelIndexSyncLambda`](#-modelindexsynclambda) — run once per deploy — or by calling `await repo.ensure_indexes()` explicitly.
515
530
 
516
531
  ### Properties to override
517
532
 
@@ -542,7 +557,15 @@ def indexes(self):
542
557
  ]
543
558
  ```
544
559
 
545
- Indexes are created automatically in the background on first collection access no need to call any initialization method.
560
+ These definitions are consumed by `ensure_indexes()`, which the [`ModelIndexSyncLambda`](#-modelindexsynclambda) runs across the relevant databases at deploy time. `ensure_indexes()` is idempotent (safe to re-run) and accepts an explicit `database_name` so the same state-scoped repository can be materialized in every state database:
561
+
562
+ ```python
563
+ # Create this repository's indexes in a specific database (what the index-sync Lambda does):
564
+ await repo.ensure_indexes(database_name="connecticut")
565
+
566
+ # Or, with no argument, in the repository's resolved database (database_key or session.state):
567
+ await repo.ensure_indexes()
568
+ ```
546
569
 
547
570
  ### Repository with a fixed database
548
571
 
@@ -669,7 +692,7 @@ The framework propagates a `Session` object automatically across the entire asyn
669
692
  | Entry point | How the session is read |
670
693
  |-------------|-------------------------|
671
694
  | **API Gateway** | `constitution-state` header → `session.state` (when `AUTHORIZATION` includes `state`); auth middleware → `session.user` (when includes `user`). Returns `400` if required header is missing |
672
- | **Standalone Lambda** | `session` dict in the event payload — **required** (must include `state`), raises `ValueError` if missing |
695
+ | **Standalone Lambda** | `session` dict in the event payload — **required by default** (must include `state`), raises `ValueError` if missing. A Lambda can opt out via `requires_state = False` (e.g. `ModelIndexSyncLambda`), making the session optional |
673
696
  | **SQS Consumer (single mode)** | Per-record: reads `session` from SNS `MessageAttributes` (Base64-encoded JSON) |
674
697
  | **SQS Consumer (batch mode)** | Groups records by `session.state`; calls `process_batch()` once per group with the correct session in context |
675
698
  | **Fargate Task** | `SESSION` env var (JSON) — auto-injected by `FargateExecutor` |
@@ -1179,13 +1202,15 @@ Create `src/lambda/model-query/main.py` in the microservice that owns the data:
1179
1202
  ```python
1180
1203
  from aws_python_helper import ModelQueryLambda
1181
1204
 
1182
- class ModelQueryLambda(ModelQueryLambda):
1205
+ class OrdersModelQueryLambda(ModelQueryLambda):
1183
1206
 
1184
1207
  @property
1185
1208
  def allowed_collections(self) -> list:
1186
1209
  return ["orders", "customers"] # whitelist — unlisted collections are rejected
1187
1210
  ```
1188
1211
 
1212
+ > **Class discovery:** name your subclass distinctly from the imported base (e.g. `OrdersModelQueryLambda`, not `ModelQueryLambda`). The framework loads the class **defined in the file** — not the imported base — so subclassing an imported concrete base (`ModelQueryLambda`, `ModelIndexSyncLambda`) works without name collisions.
1213
+
1189
1214
  Register the handler in `src/handlers/lambda_handler.py`:
1190
1215
 
1191
1216
  ```python
@@ -1333,6 +1358,114 @@ Always pass `self.session.to_dict()` when calling from an API or Lambda to ensur
1333
1358
 
1334
1359
  ---
1335
1360
 
1361
+ ## 🧱 ModelIndexSyncLambda
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**.
1364
+
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
+
1367
+ ### Creating an index-sync Lambda
1368
+
1369
+ Create `src/lambda/model-index-sync/main.py` and declare which repositories to sync:
1370
+
1371
+ ```python
1372
+ import aws_python_helper
1373
+
1374
+ from repositories.parcels import ParcelsRepository # state-scoped (database_key = None)
1375
+ from repositories.users import UsersRepository # core (database_key = "core")
1376
+ from repositories.tokens import TokensRepository # core
1377
+
1378
+ class OrdersModelIndexSyncLambda(aws_python_helper.ModelIndexSyncLambda):
1379
+
1380
+ @property
1381
+ def repositories(self) -> list:
1382
+ return [ParcelsRepository, UsersRepository, TokensRepository]
1383
+ ```
1384
+
1385
+ Register the handler in `src/handlers/lambda_handler.py`:
1386
+
1387
+ ```python
1388
+ from aws_python_helper.lambda_standalone.handler import lambda_handler
1389
+
1390
+ index_sync_handler = lambda_handler('model-index-sync')
1391
+
1392
+ __all__ = ["index_sync_handler"]
1393
+ ```
1394
+
1395
+ > Declare repositories **explicitly** (don't auto-discover) so the synced set is intentional. List your own collections; **external** repositories (`is_external = True`) belong to another cluster/service and are typically excluded.
1396
+
1397
+ ### How it routes each repository
1398
+
1399
+ For every repository in the list, the Lambda decides where to create its indexes:
1400
+
1401
+ | Repository kind | `database_key` | Target databases |
1402
+ |-----------------|----------------|------------------|
1403
+ | **Core / fixed** | `"core"` (or any string) | that single database |
1404
+ | **State-scoped** | `None` | **every active state**, read from `core.states` where `is_active = true` (e.g. `connecticut`, `new_jersey`, …) |
1405
+
1406
+ A state-scoped collection such as `parcels` therefore gets its indexes created in `connecticut`, `new_jersey`, and any other active state — automatically. Creation is idempotent, so re-running it is safe.
1407
+
1408
+ This Lambda sets `requires_state = False`: it is not tied to a single state and does not require a `session` in the event.
1409
+
1410
+ ### Response format
1411
+
1412
+ ```json
1413
+ {
1414
+ "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
+ ]
1420
+ }
1421
+ ```
1422
+
1423
+ ### Properties to override / configure
1424
+
1425
+ | Property | Default | Purpose |
1426
+ |----------|---------|---------|
1427
+ | `repositories` | `[]` | **Required.** List of `Repository` subclasses (the classes, not instances) to sync. Empty raises a validation error |
1428
+ | `states_database` | `"core"` | Database holding the states collection |
1429
+ | `states_collection` | `"states"` | Collection listing the states (filtered by `is_active = true`, field `name`) |
1430
+ | `requires_state` | `False` | Inherited override — the Lambda runs across all states, no session required |
1431
+
1432
+ ### Running it once per deploy (Terraform)
1433
+
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):
1435
+
1436
+ ```hcl
1437
+ module "lambda" {
1438
+ source = "../../../../../modules/lambda"
1439
+ function_name = "${...service_name}${var.lambda_name}-${...env}"
1440
+ source_handler = "handlers.lambda_handler.index_sync_handler"
1441
+ timeout = 300
1442
+ memory_size = 512
1443
+ # ... mongo + vpc config (same as any other Lambda)
1444
+ }
1445
+
1446
+ resource "aws_lambda_invocation" "sync_on_deploy" {
1447
+ function_name = module.lambda.lambda_function_name
1448
+ input = jsonencode({ _invoked_at = timestamp() }) # always changes → always re-runs
1449
+
1450
+ depends_on = [module.lambda] # run after the code is updated
1451
+
1452
+ lifecycle {
1453
+ postcondition {
1454
+ condition = try(jsondecode(self.result).success, false) == true
1455
+ error_message = "Index sync failed during apply: ${self.result}"
1456
+ }
1457
+ }
1458
+ }
1459
+ ```
1460
+
1461
+ You can also invoke it manually at any time (e.g. after activating a new state without a code deploy):
1462
+
1463
+ ```bash
1464
+ aws lambda invoke --function-name MyServiceModelIndexSync-dev out.json
1465
+ ```
1466
+
1467
+ ---
1468
+
1336
1469
  ## 🏗️ Architecture Overview
1337
1470
 
1338
1471
  Typical flow for event-driven architectures using this framework:
@@ -35,6 +35,8 @@ aws_python_helper/lambda_standalone/__init__.py
35
35
  aws_python_helper/lambda_standalone/base.py
36
36
  aws_python_helper/lambda_standalone/fetcher.py
37
37
  aws_python_helper/lambda_standalone/handler.py
38
+ aws_python_helper/model_index_sync/__init__.py
39
+ aws_python_helper/model_index_sync/base.py
38
40
  aws_python_helper/model_query/__init__.py
39
41
  aws_python_helper/model_query/base.py
40
42
  aws_python_helper/repository/__init__.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aws-python-helper"
7
- version = "1.0.0"
7
+ version = "1.0.2"
8
8
  description = "AWS Python Helper Framework"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"