omnibase_infra 0.2.8__py3-none-any.whl → 0.3.0__py3-none-any.whl

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 (88) hide show
  1. omnibase_infra/__init__.py +1 -1
  2. omnibase_infra/enums/__init__.py +4 -0
  3. omnibase_infra/enums/enum_declarative_node_violation.py +102 -0
  4. omnibase_infra/errors/__init__.py +18 -0
  5. omnibase_infra/errors/repository/__init__.py +78 -0
  6. omnibase_infra/errors/repository/errors_repository.py +424 -0
  7. omnibase_infra/event_bus/adapters/__init__.py +31 -0
  8. omnibase_infra/event_bus/adapters/adapter_protocol_event_publisher_kafka.py +517 -0
  9. omnibase_infra/mixins/mixin_async_circuit_breaker.py +113 -1
  10. omnibase_infra/models/__init__.py +9 -0
  11. omnibase_infra/models/event_bus/__init__.py +22 -0
  12. omnibase_infra/models/event_bus/model_consumer_retry_config.py +367 -0
  13. omnibase_infra/models/event_bus/model_dlq_config.py +177 -0
  14. omnibase_infra/models/event_bus/model_idempotency_config.py +131 -0
  15. omnibase_infra/models/event_bus/model_offset_policy_config.py +107 -0
  16. omnibase_infra/models/resilience/model_circuit_breaker_config.py +15 -0
  17. omnibase_infra/models/validation/__init__.py +8 -0
  18. omnibase_infra/models/validation/model_declarative_node_validation_result.py +139 -0
  19. omnibase_infra/models/validation/model_declarative_node_violation.py +169 -0
  20. omnibase_infra/nodes/architecture_validator/__init__.py +28 -7
  21. omnibase_infra/nodes/architecture_validator/constants.py +36 -0
  22. omnibase_infra/nodes/architecture_validator/handlers/__init__.py +28 -0
  23. omnibase_infra/nodes/architecture_validator/handlers/contract.yaml +120 -0
  24. omnibase_infra/nodes/architecture_validator/handlers/handler_architecture_validation.py +359 -0
  25. omnibase_infra/nodes/architecture_validator/node.py +1 -0
  26. omnibase_infra/nodes/architecture_validator/node_architecture_validator.py +48 -336
  27. omnibase_infra/nodes/contract_registry_reducer/reducer.py +12 -2
  28. omnibase_infra/nodes/node_ledger_projection_compute/__init__.py +16 -2
  29. omnibase_infra/nodes/node_ledger_projection_compute/contract.yaml +14 -4
  30. omnibase_infra/nodes/node_ledger_projection_compute/handlers/__init__.py +18 -0
  31. omnibase_infra/nodes/node_ledger_projection_compute/handlers/contract.yaml +53 -0
  32. omnibase_infra/nodes/node_ledger_projection_compute/handlers/handler_ledger_projection.py +354 -0
  33. omnibase_infra/nodes/node_ledger_projection_compute/node.py +20 -256
  34. omnibase_infra/nodes/node_registry_effect/node.py +20 -73
  35. omnibase_infra/protocols/protocol_dispatch_engine.py +90 -0
  36. omnibase_infra/runtime/__init__.py +11 -0
  37. omnibase_infra/runtime/baseline_subscriptions.py +150 -0
  38. omnibase_infra/runtime/db/__init__.py +73 -0
  39. omnibase_infra/runtime/db/models/__init__.py +41 -0
  40. omnibase_infra/runtime/db/models/model_repository_runtime_config.py +211 -0
  41. omnibase_infra/runtime/db/postgres_repository_runtime.py +545 -0
  42. omnibase_infra/runtime/event_bus_subcontract_wiring.py +455 -24
  43. omnibase_infra/runtime/kafka_contract_source.py +13 -5
  44. omnibase_infra/runtime/service_message_dispatch_engine.py +112 -0
  45. omnibase_infra/runtime/service_runtime_host_process.py +6 -11
  46. omnibase_infra/services/__init__.py +36 -0
  47. omnibase_infra/services/contract_publisher/__init__.py +95 -0
  48. omnibase_infra/services/contract_publisher/config.py +199 -0
  49. omnibase_infra/services/contract_publisher/errors.py +243 -0
  50. omnibase_infra/services/contract_publisher/models/__init__.py +28 -0
  51. omnibase_infra/services/contract_publisher/models/model_contract_error.py +67 -0
  52. omnibase_infra/services/contract_publisher/models/model_infra_error.py +62 -0
  53. omnibase_infra/services/contract_publisher/models/model_publish_result.py +112 -0
  54. omnibase_infra/services/contract_publisher/models/model_publish_stats.py +79 -0
  55. omnibase_infra/services/contract_publisher/service.py +617 -0
  56. omnibase_infra/services/contract_publisher/sources/__init__.py +52 -0
  57. omnibase_infra/services/contract_publisher/sources/model_discovered.py +155 -0
  58. omnibase_infra/services/contract_publisher/sources/protocol.py +101 -0
  59. omnibase_infra/services/contract_publisher/sources/source_composite.py +309 -0
  60. omnibase_infra/services/contract_publisher/sources/source_filesystem.py +174 -0
  61. omnibase_infra/services/contract_publisher/sources/source_package.py +221 -0
  62. omnibase_infra/services/observability/__init__.py +40 -0
  63. omnibase_infra/services/observability/agent_actions/__init__.py +64 -0
  64. omnibase_infra/services/observability/agent_actions/config.py +209 -0
  65. omnibase_infra/services/observability/agent_actions/consumer.py +1320 -0
  66. omnibase_infra/services/observability/agent_actions/models/__init__.py +87 -0
  67. omnibase_infra/services/observability/agent_actions/models/model_agent_action.py +142 -0
  68. omnibase_infra/services/observability/agent_actions/models/model_detection_failure.py +125 -0
  69. omnibase_infra/services/observability/agent_actions/models/model_envelope.py +85 -0
  70. omnibase_infra/services/observability/agent_actions/models/model_execution_log.py +159 -0
  71. omnibase_infra/services/observability/agent_actions/models/model_performance_metric.py +130 -0
  72. omnibase_infra/services/observability/agent_actions/models/model_routing_decision.py +138 -0
  73. omnibase_infra/services/observability/agent_actions/models/model_transformation_event.py +124 -0
  74. omnibase_infra/services/observability/agent_actions/tests/__init__.py +20 -0
  75. omnibase_infra/services/observability/agent_actions/tests/test_consumer.py +1154 -0
  76. omnibase_infra/services/observability/agent_actions/tests/test_models.py +645 -0
  77. omnibase_infra/services/observability/agent_actions/tests/test_writer.py +709 -0
  78. omnibase_infra/services/observability/agent_actions/writer_postgres.py +926 -0
  79. omnibase_infra/validation/__init__.py +12 -0
  80. omnibase_infra/validation/contracts/declarative_node.validation.yaml +143 -0
  81. omnibase_infra/validation/infra_validators.py +4 -1
  82. omnibase_infra/validation/validation_exemptions.yaml +111 -0
  83. omnibase_infra/validation/validator_declarative_node.py +850 -0
  84. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/METADATA +2 -2
  85. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/RECORD +88 -30
  86. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/WHEEL +0 -0
  87. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/entry_points.txt +0 -0
  88. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,545 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """PostgreSQL Repository Runtime.
4
+
5
+ This module provides a generic runtime for executing repository contracts
6
+ against PostgreSQL databases. The runtime enforces safety constraints,
7
+ deterministic query ordering, and configurable operation limits.
8
+
9
+ Key Features:
10
+ - Contract-driven: All operations defined in ModelDbRepositoryContract
11
+ - Positional parameters: Uses $1, $2, ... (no named param rewriting)
12
+ - Determinism enforcement: ORDER BY injection for multi-row queries
13
+ - Limit enforcement: LIMIT injection with configurable maximum
14
+ - Operation validation: Allowlist-based operation control
15
+ - Timeout enforcement: asyncio.wait_for() for query cancellation
16
+
17
+ Usage Example:
18
+ >>> import asyncpg
19
+ >>> from omnibase_infra.runtime.db import (
20
+ ... ModelDbRepositoryContract,
21
+ ... ModelDbOperation,
22
+ ... ModelDbReturn,
23
+ ... ModelRepositoryRuntimeConfig,
24
+ ... )
25
+ >>> from omnibase_infra.runtime.db.postgres_repository_runtime import (
26
+ ... PostgresRepositoryRuntime,
27
+ ... )
28
+ >>>
29
+ >>> # Create contract
30
+ >>> contract = ModelDbRepositoryContract(
31
+ ... name="users",
32
+ ... database_ref="primary",
33
+ ... ops={
34
+ ... "find_by_id": ModelDbOperation(
35
+ ... mode="select",
36
+ ... sql="SELECT * FROM users WHERE id = $1",
37
+ ... params=["user_id"],
38
+ ... returns=ModelDbReturn(many=False),
39
+ ... ),
40
+ ... },
41
+ ... )
42
+ >>>
43
+ >>> # Create runtime (with pool)
44
+ >>> pool = await asyncpg.create_pool(...)
45
+ >>> runtime = PostgresRepositoryRuntime(pool, contract)
46
+ >>>
47
+ >>> # Execute operation
48
+ >>> user = await runtime.call("find_by_id", 123)
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import asyncio
54
+ import logging
55
+ import re
56
+ import time
57
+ from typing import TYPE_CHECKING
58
+
59
+ from omnibase_infra.enums import EnumInfraTransportType
60
+ from omnibase_infra.errors.repository import (
61
+ RepositoryContractError,
62
+ RepositoryExecutionError,
63
+ RepositoryTimeoutError,
64
+ RepositoryValidationError,
65
+ )
66
+ from omnibase_infra.models.errors import ModelInfraErrorContext
67
+ from omnibase_infra.runtime.db.models import (
68
+ ModelDbOperation,
69
+ ModelDbRepositoryContract,
70
+ ModelRepositoryRuntimeConfig,
71
+ )
72
+
73
+ if TYPE_CHECKING:
74
+ import asyncpg
75
+
76
+ logger = logging.getLogger(__name__)
77
+
78
+ # =============================================================================
79
+ # SQL Clause Detection Patterns
80
+ # =============================================================================
81
+ #
82
+ # These regex patterns provide simple ORDER BY and LIMIT clause detection
83
+ # for determinism enforcement. They use word boundaries (\b) and case-insensitive
84
+ # matching to identify SQL keywords.
85
+ #
86
+ # KNOWN LIMITATIONS:
87
+ # -----------------
88
+ # These patterns use simple regex matching, NOT a full SQL parser. As such,
89
+ # they can produce false positives in certain edge cases:
90
+ #
91
+ # 1. String Literals: Patterns inside quoted strings will match:
92
+ # - SELECT description FROM items WHERE note = 'sort ORDER BY priority'
93
+ # - SELECT * FROM logs WHERE message LIKE '%LIMIT 10 reached%'
94
+ #
95
+ # 2. Subqueries: Patterns in nested queries will match the outer detection:
96
+ # - SELECT * FROM (SELECT id FROM users ORDER BY created_at LIMIT 5) sub
97
+ # - The outer query appears to have ORDER BY/LIMIT, but doesn't
98
+ #
99
+ # 3. Comments: Patterns inside SQL comments will match:
100
+ # - SELECT * FROM users -- ORDER BY id for debugging
101
+ # - SELECT * FROM users /* LIMIT 100 was here */
102
+ #
103
+ # WHY THIS IS ACCEPTABLE:
104
+ # ----------------------
105
+ # 1. Contract SQL should be simple, predictable queries. Complex queries with
106
+ # subqueries, dynamic string construction, or embedded SQL in literals
107
+ # indicate contract design that should be reconsidered.
108
+ #
109
+ # 2. This detection is defense-in-depth, not primary validation. The contract
110
+ # author has explicit control over the SQL and can always add explicit
111
+ # ORDER BY and LIMIT clauses to avoid injection entirely.
112
+ #
113
+ # 3. False positives (detecting ORDER BY/LIMIT when not present at outer level)
114
+ # are safer than false negatives. A false positive skips injection, leaving
115
+ # the query unchanged. A false negative would inject duplicate clauses.
116
+ #
117
+ # RECOMMENDATION FOR COMPLEX QUERIES:
118
+ # ----------------------------------
119
+ # If your contract requires complex SQL with subqueries or string operations
120
+ # that contain SQL keywords, explicitly include ORDER BY and LIMIT in the
121
+ # outer query. This bypasses regex detection entirely:
122
+ #
123
+ # GOOD: "SELECT * FROM (SELECT id FROM users ORDER BY id) sub ORDER BY id LIMIT 100"
124
+ # AVOID: Relying on injection for queries with embedded SQL-like strings
125
+ #
126
+ # =============================================================================
127
+ _ORDER_BY_PATTERN = re.compile(r"\bORDER\s+BY\b", re.IGNORECASE)
128
+ _LIMIT_PATTERN = re.compile(r"\bLIMIT\s+(\d+)\b", re.IGNORECASE)
129
+
130
+
131
+ class PostgresRepositoryRuntime:
132
+ """Runtime for executing repository contracts against PostgreSQL.
133
+
134
+ Executes operations defined in a ModelDbRepositoryContract with
135
+ safety constraints, determinism guarantees, and configurable limits.
136
+
137
+ Thread Safety:
138
+ This class is NOT thread-safe for concurrent modifications.
139
+ The pool itself handles connection-level concurrency.
140
+ Multiple coroutines may call() concurrently on the same runtime.
141
+
142
+ Attributes:
143
+ pool: asyncpg connection pool for database access.
144
+ contract: Repository contract defining available operations.
145
+ config: Runtime configuration for safety and behavior.
146
+
147
+ Example:
148
+ >>> pool = await asyncpg.create_pool(dsn="postgresql://...")
149
+ >>> runtime = PostgresRepositoryRuntime(pool, contract)
150
+ >>> results = await runtime.call("find_all")
151
+ """
152
+
153
+ __slots__ = ("_config", "_contract", "_pool")
154
+
155
+ def __init__(
156
+ self,
157
+ pool: asyncpg.Pool,
158
+ contract: ModelDbRepositoryContract,
159
+ config: ModelRepositoryRuntimeConfig | None = None,
160
+ ) -> None:
161
+ """Initialize the repository runtime.
162
+
163
+ Args:
164
+ pool: asyncpg connection pool for database access.
165
+ contract: Repository contract defining available operations.
166
+ config: Optional runtime configuration. If None, uses defaults.
167
+
168
+ Example:
169
+ >>> runtime = PostgresRepositoryRuntime(
170
+ ... pool=pool,
171
+ ... contract=contract,
172
+ ... config=ModelRepositoryRuntimeConfig(max_row_limit=100),
173
+ ... )
174
+ """
175
+ self._pool = pool
176
+ self._contract = contract
177
+ self._config = config or ModelRepositoryRuntimeConfig()
178
+
179
+ @property
180
+ def contract(self) -> ModelDbRepositoryContract:
181
+ """Get the repository contract."""
182
+ return self._contract
183
+
184
+ @property
185
+ def config(self) -> ModelRepositoryRuntimeConfig:
186
+ """Get the runtime configuration."""
187
+ return self._config
188
+
189
+ async def call(
190
+ self, op_name: str, *args: object
191
+ ) -> list[dict[str, object]] | dict[str, object] | None:
192
+ """Execute a named operation from the contract.
193
+
194
+ Validates the operation exists, checks allowed operations,
195
+ validates argument count, applies determinism and limit
196
+ constraints, and executes with timeout enforcement.
197
+
198
+ Args:
199
+ op_name: Operation name as defined in contract.ops.
200
+ *args: Positional arguments matching contract params order.
201
+
202
+ Returns:
203
+ For many=True: list of dicts (possibly empty)
204
+ For many=False: single dict or None if no row found
205
+
206
+ Raises:
207
+ RepositoryContractError: Operation not found, forbidden mode,
208
+ or determinism constraint violation (no PK for multi-row).
209
+ RepositoryValidationError: Argument count mismatch.
210
+ RepositoryExecutionError: Database execution error.
211
+ RepositoryTimeoutError: Query exceeded timeout.
212
+
213
+ Example:
214
+ >>> # Single row lookup
215
+ >>> user = await runtime.call("find_by_id", 123)
216
+ >>> # Multi-row query
217
+ >>> users = await runtime.call("find_by_status", "active")
218
+ """
219
+ start_time = time.monotonic()
220
+ context = self._create_error_context(op_name)
221
+
222
+ # Lookup operation in contract
223
+ operation = self._get_operation(op_name, context)
224
+
225
+ # Validate operation is allowed
226
+ self._validate_operation_allowed(operation, op_name, context)
227
+
228
+ # Validate argument count
229
+ self._validate_arg_count(operation, args, op_name, context)
230
+
231
+ # Build final SQL with determinism and limit constraints
232
+ sql = self._build_sql(operation, op_name, context)
233
+
234
+ # Execute with timeout
235
+ try:
236
+ result = await self._execute_with_timeout(
237
+ sql, args, operation, op_name, context
238
+ )
239
+ except TimeoutError as e:
240
+ timeout_seconds = self._config.timeout_ms / 1000.0
241
+ raise RepositoryTimeoutError(
242
+ f"Query '{op_name}' exceeded timeout of {timeout_seconds}s",
243
+ op_name=op_name,
244
+ table=self._get_primary_table(),
245
+ timeout_seconds=timeout_seconds,
246
+ sql_fingerprint=self._fingerprint_sql(sql),
247
+ context=context,
248
+ ) from e
249
+
250
+ # Log metrics if enabled
251
+ if self._config.emit_metrics:
252
+ elapsed_ms = (time.monotonic() - start_time) * 1000
253
+ row_count = (
254
+ len(result) if isinstance(result, list) else (1 if result else 0)
255
+ )
256
+ logger.info(
257
+ "Repository operation completed",
258
+ extra={
259
+ "op_name": op_name,
260
+ "duration_ms": round(elapsed_ms, 2),
261
+ "rows_returned": row_count,
262
+ "repository": self._contract.name,
263
+ },
264
+ )
265
+
266
+ return result
267
+
268
+ def _create_error_context(self, op_name: str) -> ModelInfraErrorContext:
269
+ """Create error context for infrastructure errors."""
270
+ return ModelInfraErrorContext.with_correlation(
271
+ transport_type=EnumInfraTransportType.DATABASE,
272
+ operation=f"repository.{op_name}",
273
+ target_name=self._contract.name,
274
+ )
275
+
276
+ def _get_operation(
277
+ self, op_name: str, context: ModelInfraErrorContext
278
+ ) -> ModelDbOperation:
279
+ """Get operation from contract, raising error if not found."""
280
+ operation = self._contract.ops.get(op_name)
281
+ if operation is None:
282
+ available_ops = list(self._contract.ops.keys())
283
+ raise RepositoryContractError(
284
+ f"Unknown operation '{op_name}' not defined in contract '{self._contract.name}'. "
285
+ f"Available operations: {available_ops}",
286
+ op_name=op_name,
287
+ table=self._get_primary_table(),
288
+ context=context,
289
+ )
290
+ return operation
291
+
292
+ def _validate_operation_allowed(
293
+ self,
294
+ operation: ModelDbOperation,
295
+ op_name: str,
296
+ context: ModelInfraErrorContext,
297
+ ) -> None:
298
+ """Validate operation mode is allowed by config.
299
+
300
+ The contract uses 'read' or 'write' modes (validated by omnibase_core
301
+ validators at contract load time to ensure SQL verb matching).
302
+ """
303
+ mode = operation.mode
304
+
305
+ # Check write operations against feature flag
306
+ if mode == "write" and not self._config.allow_write_operations:
307
+ raise RepositoryContractError(
308
+ f"Operation '{op_name}' uses 'write' mode which is disabled. "
309
+ "Set allow_write_operations=True in config to enable.",
310
+ op_name=op_name,
311
+ table=self._get_primary_table(),
312
+ context=context,
313
+ )
314
+
315
+ # Check mode against allowlist
316
+ if mode not in self._config.allowed_modes:
317
+ raise RepositoryContractError(
318
+ f"Operation mode '{mode}' for '{op_name}' is not in allowed_modes. "
319
+ f"Allowed: {set(self._config.allowed_modes)}",
320
+ op_name=op_name,
321
+ table=self._get_primary_table(),
322
+ context=context,
323
+ )
324
+
325
+ def _validate_arg_count(
326
+ self,
327
+ operation: ModelDbOperation,
328
+ args: tuple[object, ...],
329
+ op_name: str,
330
+ context: ModelInfraErrorContext,
331
+ ) -> None:
332
+ """Validate argument count matches contract params.
333
+
334
+ Contract params is a dict[str, ModelDbParam] where keys are param names.
335
+ """
336
+ param_names = list(operation.params.keys())
337
+ expected = len(param_names)
338
+ actual = len(args)
339
+ if actual != expected:
340
+ raise RepositoryValidationError(
341
+ f"Operation '{op_name}' expects {expected} argument(s) ({param_names}), "
342
+ f"but received {actual}",
343
+ op_name=op_name,
344
+ table=self._get_primary_table(),
345
+ context=context,
346
+ expected_args=expected,
347
+ actual_args=actual,
348
+ param_names=param_names,
349
+ )
350
+
351
+ def _build_sql(
352
+ self,
353
+ operation: ModelDbOperation,
354
+ op_name: str,
355
+ context: ModelInfraErrorContext,
356
+ ) -> str:
357
+ """Build final SQL with determinism and limit constraints.
358
+
359
+ Applies ORDER BY injection for multi-row queries without ORDER BY.
360
+ Applies LIMIT injection or validation based on config.
361
+
362
+ Only applies constraints to 'read' mode operations (SELECT).
363
+ """
364
+ sql = operation.sql
365
+ is_read = operation.mode == "read"
366
+ is_multi_row = operation.returns.many
367
+
368
+ # Only apply constraints to read operations
369
+ if not is_read:
370
+ return sql
371
+
372
+ # Apply determinism constraints for multi-row reads
373
+ if is_multi_row:
374
+ sql = self._inject_order_by(sql, op_name, context)
375
+
376
+ # Apply limit constraints for multi-row reads
377
+ if is_multi_row:
378
+ sql = self._inject_limit(sql, op_name, context)
379
+
380
+ return sql
381
+
382
+ def _inject_order_by(
383
+ self,
384
+ sql: str,
385
+ op_name: str,
386
+ context: ModelInfraErrorContext,
387
+ ) -> str:
388
+ """Inject ORDER BY clause for deterministic multi-row results.
389
+
390
+ Rules:
391
+ - If ORDER BY exists: no injection needed
392
+ - If no ORDER BY and PK declared: inject ORDER BY {pk}
393
+ - If no ORDER BY and no PK: HARD ERROR
394
+
395
+ Args:
396
+ sql: The SQL query to potentially modify.
397
+ op_name: Operation name for error context.
398
+ context: Error context for exception raising.
399
+
400
+ Returns:
401
+ SQL with ORDER BY clause (injected or original).
402
+
403
+ Raises:
404
+ RepositoryContractError: No ORDER BY and no primary_key_column.
405
+ """
406
+ has_order_by = bool(_ORDER_BY_PATTERN.search(sql))
407
+ if has_order_by:
408
+ return sql
409
+
410
+ # No ORDER BY - check if we can inject
411
+ pk_column = self._config.primary_key_column
412
+ if pk_column is None:
413
+ raise RepositoryContractError(
414
+ f"Multi-row query '{op_name}' has no ORDER BY clause and "
415
+ "primary_key_column is not configured. Deterministic results "
416
+ "cannot be guaranteed. Either add ORDER BY to the SQL or "
417
+ "set primary_key_column in config.",
418
+ op_name=op_name,
419
+ table=self._get_primary_table(),
420
+ sql_fingerprint=self._fingerprint_sql(sql),
421
+ context=context,
422
+ )
423
+
424
+ # Inject ORDER BY using configured order or just PK
425
+ order_by = self._config.default_order_by or pk_column
426
+ return f"{sql.rstrip().rstrip(';')} ORDER BY {order_by}"
427
+
428
+ def _inject_limit(
429
+ self,
430
+ sql: str,
431
+ op_name: str,
432
+ context: ModelInfraErrorContext,
433
+ ) -> str:
434
+ """Inject or validate LIMIT clause for multi-row results.
435
+
436
+ Rules:
437
+ - If LIMIT > max_row_limit: HARD ERROR
438
+ - If no LIMIT: inject LIMIT {max_row_limit}
439
+ - If LIMIT <= max_row_limit: OK (no change)
440
+
441
+ Args:
442
+ sql: The SQL query to potentially modify.
443
+ op_name: Operation name for error context.
444
+ context: Error context for exception raising.
445
+
446
+ Returns:
447
+ SQL with LIMIT clause (injected or original).
448
+
449
+ Raises:
450
+ RepositoryContractError: LIMIT exceeds max_row_limit.
451
+ """
452
+ max_limit = self._config.max_row_limit
453
+ limit_match = _LIMIT_PATTERN.search(sql)
454
+
455
+ if limit_match:
456
+ # Existing LIMIT - validate it
457
+ existing_limit = int(limit_match.group(1))
458
+ if existing_limit > max_limit:
459
+ raise RepositoryContractError(
460
+ f"Query '{op_name}' has LIMIT {existing_limit} which exceeds "
461
+ f"max_row_limit of {max_limit}. Reduce the LIMIT or increase "
462
+ "max_row_limit in config.",
463
+ op_name=op_name,
464
+ table=self._get_primary_table(),
465
+ sql_fingerprint=self._fingerprint_sql(sql),
466
+ context=context,
467
+ existing_limit=existing_limit,
468
+ max_row_limit=max_limit,
469
+ )
470
+ return sql
471
+
472
+ # No LIMIT - inject one
473
+ return f"{sql.rstrip().rstrip(';')} LIMIT {max_limit}"
474
+
475
+ async def _execute_with_timeout(
476
+ self,
477
+ sql: str,
478
+ args: tuple[object, ...],
479
+ operation: ModelDbOperation,
480
+ op_name: str,
481
+ context: ModelInfraErrorContext,
482
+ ) -> list[dict[str, object]] | dict[str, object] | None:
483
+ """Execute query with timeout enforcement.
484
+
485
+ Uses asyncio.wait_for() to enforce timeout.
486
+ Uses fetch() for many=True, fetchrow() for many=False.
487
+
488
+ Args:
489
+ sql: Final SQL query to execute.
490
+ args: Positional arguments for the query.
491
+ operation: Operation specification.
492
+ op_name: Operation name for error context.
493
+ context: Error context for exception raising.
494
+
495
+ Returns:
496
+ Query results as appropriate type.
497
+
498
+ Raises:
499
+ asyncio.TimeoutError: Query exceeded timeout (caught by caller).
500
+ RepositoryExecutionError: Database execution error.
501
+ """
502
+ timeout_seconds = self._config.timeout_ms / 1000.0
503
+
504
+ try:
505
+ async with self._pool.acquire() as conn:
506
+ if operation.returns.many:
507
+ # Multi-row: use fetch()
508
+ coro = conn.fetch(sql, *args)
509
+ records = await asyncio.wait_for(coro, timeout=timeout_seconds)
510
+ return [dict(record) for record in records]
511
+ else:
512
+ # Single-row: use fetchrow()
513
+ coro = conn.fetchrow(sql, *args)
514
+ record = await asyncio.wait_for(coro, timeout=timeout_seconds)
515
+ return dict(record) if record is not None else None
516
+ except TimeoutError:
517
+ # Re-raise for caller to handle
518
+ raise
519
+ except Exception as e:
520
+ # Wrap all other exceptions
521
+ raise RepositoryExecutionError(
522
+ f"Failed to execute operation '{op_name}': {e}",
523
+ op_name=op_name,
524
+ table=self._get_primary_table(),
525
+ sql_fingerprint=self._fingerprint_sql(sql),
526
+ context=context,
527
+ ) from e
528
+
529
+ def _get_primary_table(self) -> str | None:
530
+ """Get the primary table from contract for error context."""
531
+ return self._contract.tables[0] if self._contract.tables else None
532
+
533
+ def _fingerprint_sql(self, sql: str) -> str:
534
+ """Create a safe fingerprint of SQL for logging/errors.
535
+
536
+ Truncates long SQL and removes potentially sensitive values.
537
+ """
538
+ # Simple approach: truncate to reasonable length
539
+ max_len = 200
540
+ if len(sql) <= max_len:
541
+ return sql
542
+ return sql[:max_len] + "..."
543
+
544
+
545
+ __all__: list[str] = ["PostgresRepositoryRuntime"]