svc-infra 0.1.562__py3-none-any.whl → 0.1.654__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.
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/models.py +142 -4
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +797 -0
- svc_infra/apf_payments/provider/base.py +178 -12
- svc_infra/apf_payments/provider/stripe.py +757 -48
- svc_infra/apf_payments/schemas.py +163 -1
- svc_infra/apf_payments/service.py +582 -42
- svc_infra/apf_payments/settings.py +22 -2
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/router.py +792 -73
- svc_infra/api/fastapi/apf_payments/setup.py +13 -4
- svc_infra/api/fastapi/auth/add.py +10 -4
- svc_infra/api/fastapi/auth/gaurd.py +67 -5
- svc_infra/api/fastapi/auth/routers/oauth_router.py +74 -34
- svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/db/sql/users.py +13 -1
- svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +41 -6
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/idempotency.py +82 -42
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +84 -11
- svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +244 -38
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +133 -32
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +23 -14
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +32 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +10 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/outbox.py +104 -0
- svc_infra/db/sql/repository.py +52 -12
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +13 -8
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +9 -5
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/docs/acceptance-matrix.md +71 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/api.md +59 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +90 -0
- svc_infra/jobs/easy.py +32 -0
- svc_infra/jobs/loader.py +45 -0
- svc_infra/jobs/queue.py +81 -0
- svc_infra/jobs/redis_queue.py +191 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +41 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +53 -0
- svc_infra/obs/metrics.py +52 -0
- svc_infra/security/add.py +201 -0
- svc_infra/security/audit.py +130 -0
- svc_infra/security/audit_service.py +73 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +95 -0
- svc_infra/security/jwt_rotation.py +53 -0
- svc_infra/security/lockout.py +96 -0
- svc_infra/security/models.py +255 -0
- svc_infra/security/org_invites.py +128 -0
- svc_infra/security/passwords.py +77 -0
- svc_infra/security/permissions.py +149 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +80 -0
- svc_infra/webhooks/__init__.py +16 -0
- svc_infra/webhooks/add.py +322 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +67 -0
- svc_infra/webhooks/signing.py +30 -0
- svc_infra-0.1.654.dist-info/METADATA +154 -0
- {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/RECORD +174 -56
- svc_infra-0.1.562.dist-info/METADATA +0 -79
- {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Awaitable, Callable, Iterable, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def run_fixtures(
|
|
9
|
+
loaders: Iterable[Callable[[], None | Awaitable[None]]], *, run_once_file: Optional[str] = None
|
|
10
|
+
) -> None:
|
|
11
|
+
"""Run a sequence of fixture loaders (sync or async).
|
|
12
|
+
|
|
13
|
+
- If run_once_file is provided and exists, does nothing.
|
|
14
|
+
- On success, creates the run_once_file sentinel (parent dirs included).
|
|
15
|
+
"""
|
|
16
|
+
if run_once_file:
|
|
17
|
+
sentinel = Path(run_once_file)
|
|
18
|
+
if sentinel.exists():
|
|
19
|
+
return
|
|
20
|
+
for fn in loaders:
|
|
21
|
+
res = fn()
|
|
22
|
+
if inspect.isawaitable(res): # type: ignore[arg-type]
|
|
23
|
+
await res # type: ignore[misc]
|
|
24
|
+
if run_once_file:
|
|
25
|
+
sentinel.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
Path(run_once_file).write_text("ok")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def make_on_load_fixtures(
|
|
30
|
+
*loaders: Callable[[], None | Awaitable[None]], run_once_file: Optional[str] = None
|
|
31
|
+
) -> Callable[[], Awaitable[None]]:
|
|
32
|
+
"""Return an async callable suitable for add_data_lifecycle(on_load_fixtures=...)."""
|
|
33
|
+
|
|
34
|
+
async def _runner() -> None:
|
|
35
|
+
await run_fixtures(loaders, run_once_file=run_once_file)
|
|
36
|
+
|
|
37
|
+
return _runner
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = ["run_fixtures", "make_on_load_fixtures"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from typing import Any, Iterable, Optional, Protocol, Sequence
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SqlSession(Protocol): # minimal protocol for tests/integration
|
|
9
|
+
async def execute(self, stmt: Any) -> Any:
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class RetentionPolicy:
|
|
15
|
+
name: str
|
|
16
|
+
model: Any # SQLAlchemy model or test double exposing columns
|
|
17
|
+
older_than_days: int
|
|
18
|
+
soft_delete_field: Optional[str] = "deleted_at"
|
|
19
|
+
extra_where: Optional[Sequence[Any]] = None
|
|
20
|
+
hard_delete: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def purge_policy(session: SqlSession, policy: RetentionPolicy) -> int:
|
|
24
|
+
"""Execute a single retention purge according to policy.
|
|
25
|
+
|
|
26
|
+
If hard_delete is False and soft_delete_field exists on model, set timestamp; else DELETE.
|
|
27
|
+
Returns number of affected rows (best-effort; test doubles may return an int directly).
|
|
28
|
+
"""
|
|
29
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=policy.older_than_days)
|
|
30
|
+
m = policy.model
|
|
31
|
+
where = list(policy.extra_where or [])
|
|
32
|
+
created_col = getattr(m, "created_at", None)
|
|
33
|
+
if created_col is not None and hasattr(created_col, "__le__"):
|
|
34
|
+
where.append(created_col <= cutoff) # type: ignore[operator]
|
|
35
|
+
|
|
36
|
+
# Soft-delete path when available and requested
|
|
37
|
+
if not policy.hard_delete and policy.soft_delete_field and hasattr(m, policy.soft_delete_field):
|
|
38
|
+
stmt = m.update().where(*where).values({policy.soft_delete_field: cutoff}) # type: ignore[attr-defined]
|
|
39
|
+
res = await session.execute(stmt)
|
|
40
|
+
return getattr(res, "rowcount", 0)
|
|
41
|
+
|
|
42
|
+
# Hard delete fallback
|
|
43
|
+
stmt = m.delete().where(*where) # type: ignore[attr-defined]
|
|
44
|
+
res = await session.execute(stmt)
|
|
45
|
+
return getattr(res, "rowcount", 0)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def run_retention_purge(session: SqlSession, policies: Iterable[RetentionPolicy]) -> int:
|
|
49
|
+
total = 0
|
|
50
|
+
for p in policies:
|
|
51
|
+
total += await purge_policy(session, p)
|
|
52
|
+
return total
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = ["RetentionPolicy", "purge_policy", "run_retention_purge"]
|
svc_infra/db/inbox.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class InboxStore(Protocol):
|
|
8
|
+
def mark_if_new(self, key: str, ttl_seconds: int = 24 * 3600) -> bool:
|
|
9
|
+
"""Mark key as processed if not seen; return True if newly marked, False if duplicate."""
|
|
10
|
+
...
|
|
11
|
+
|
|
12
|
+
def purge_expired(self) -> int:
|
|
13
|
+
"""Optional: remove expired keys, return number purged."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
def is_marked(self, key: str) -> bool:
|
|
17
|
+
"""Return True if key is already marked (not expired), without modifying it."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InMemoryInboxStore:
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self._keys: dict[str, float] = {}
|
|
24
|
+
|
|
25
|
+
def mark_if_new(self, key: str, ttl_seconds: int = 24 * 3600) -> bool:
|
|
26
|
+
now = time.time()
|
|
27
|
+
exp = self._keys.get(key)
|
|
28
|
+
if exp and exp > now:
|
|
29
|
+
return False
|
|
30
|
+
self._keys[key] = now + ttl_seconds
|
|
31
|
+
return True
|
|
32
|
+
|
|
33
|
+
def purge_expired(self) -> int:
|
|
34
|
+
now = time.time()
|
|
35
|
+
to_del = [k for k, e in self._keys.items() if e <= now]
|
|
36
|
+
for k in to_del:
|
|
37
|
+
self._keys.pop(k, None)
|
|
38
|
+
return len(to_del)
|
|
39
|
+
|
|
40
|
+
def is_marked(self, key: str) -> bool:
|
|
41
|
+
now = time.time()
|
|
42
|
+
exp = self._keys.get(key)
|
|
43
|
+
return bool(exp and exp > now)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SqlInboxStore:
|
|
47
|
+
"""Skeleton for a SQL-backed inbox store (dedupe table).
|
|
48
|
+
|
|
49
|
+
Implementations should:
|
|
50
|
+
- INSERT key with expires_at if not exists (unique constraint)
|
|
51
|
+
- Return False on duplicate key violations
|
|
52
|
+
- Periodically DELETE expired rows
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, session_factory):
|
|
56
|
+
self._session_factory = session_factory
|
|
57
|
+
|
|
58
|
+
def mark_if_new(
|
|
59
|
+
self, key: str, ttl_seconds: int = 24 * 3600
|
|
60
|
+
) -> bool: # pragma: no cover - skeleton
|
|
61
|
+
raise NotImplementedError
|
|
62
|
+
|
|
63
|
+
def purge_expired(self) -> int: # pragma: no cover - skeleton
|
|
64
|
+
raise NotImplementedError
|
|
65
|
+
|
|
66
|
+
def is_marked(self, key: str) -> bool: # pragma: no cover - skeleton
|
|
67
|
+
raise NotImplementedError
|
|
@@ -29,17 +29,17 @@ We provide four CLI commands. You can register them on your Typer app or invoke
|
|
|
29
29
|
|
|
30
30
|
### Commands
|
|
31
31
|
|
|
32
|
-
- `mongo
|
|
33
|
-
- `mongo
|
|
34
|
-
- `mongo
|
|
35
|
-
- `mongo
|
|
32
|
+
- `mongo scaffold` — create both document **and** CRUD schemas
|
|
33
|
+
- `mongo scaffold-documents` — create only the **document** model (Pydantic)
|
|
34
|
+
- `mongo scaffold-schemas` — create only the **CRUD schemas**
|
|
35
|
+
- `mongo scaffold-resources` — create a starter `resources.py` with a `RESOURCES` list
|
|
36
36
|
|
|
37
37
|
### Typical usage
|
|
38
38
|
|
|
39
39
|
#### A) Scaffold documents + schemas together
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
|
-
yourapp mongo
|
|
42
|
+
yourapp mongo scaffold \
|
|
43
43
|
--entity-name Product \
|
|
44
44
|
--documents-dir ./src/your_app/products \
|
|
45
45
|
--schemas-dir ./src/your_app/products \
|
|
@@ -57,7 +57,7 @@ src/your_app/products/schemas.py # ProductRead/ProductCreate/ProductUpdate
|
|
|
57
57
|
B) Documents only
|
|
58
58
|
|
|
59
59
|
```bash
|
|
60
|
-
yourapp mongo
|
|
60
|
+
yourapp mongo scaffold-documents \
|
|
61
61
|
--dest-dir ./src/your_app/products \
|
|
62
62
|
--entity-name Product \
|
|
63
63
|
--documents-filename product_doc.py
|
|
@@ -66,7 +66,7 @@ yourapp mongo-scaffold-documents \
|
|
|
66
66
|
C) Schemas only
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
|
-
yourapp mongo
|
|
69
|
+
yourapp mongo scaffold-schemas \
|
|
70
70
|
--dest-dir ./src/your_app/products \
|
|
71
71
|
--entity-name Product \
|
|
72
72
|
--schemas-filename product_schemas.py
|
|
@@ -75,7 +75,7 @@ yourapp mongo-scaffold-schemas \
|
|
|
75
75
|
D) Starter resources.py
|
|
76
76
|
|
|
77
77
|
```bash
|
|
78
|
-
yourapp mongo
|
|
78
|
+
yourapp mongo scaffold-resources \
|
|
79
79
|
--dest-dir ./src/your_app/mongo \
|
|
80
80
|
--filename resources.py \
|
|
81
81
|
--overwrite
|
|
@@ -131,7 +131,7 @@ There are two flavors:
|
|
|
131
131
|
A) Async, minimal (connect, create collections, apply indexes)
|
|
132
132
|
|
|
133
133
|
```bash
|
|
134
|
-
yourapp mongo
|
|
134
|
+
yourapp mongo prepare \
|
|
135
135
|
--resources your_app.mongo.resources:RESOURCES \
|
|
136
136
|
--mongo-url "$MONGO_URL" \
|
|
137
137
|
--mongo-db "$MONGO_DB"
|
|
@@ -140,7 +140,7 @@ yourapp mongo-prepare \
|
|
|
140
140
|
B) Synchronous wrapper (end-to-end convenience)
|
|
141
141
|
|
|
142
142
|
```bash
|
|
143
|
-
yourapp mongo
|
|
143
|
+
yourapp mongo setup-and-prepare \
|
|
144
144
|
--resources your_app.mongo.resources:RESOURCES \
|
|
145
145
|
--mongo-url "$MONGO_URL" \
|
|
146
146
|
--mongo-db "$MONGO_DB"
|
|
@@ -149,7 +149,7 @@ yourapp mongo-setup-and-prepare \
|
|
|
149
149
|
You can also ping connectivity:
|
|
150
150
|
|
|
151
151
|
```bash
|
|
152
|
-
yourapp mongo
|
|
152
|
+
yourapp mongo ping --mongo-url "$MONGO_URL" --mongo-db "$MONGO_DB"
|
|
153
153
|
```
|
|
154
154
|
|
|
155
155
|
Behind the scenes, preparation also locks a service ID to a DB name to prevent accidental cross-DB usage. You can pass --allow-rebind if you intentionally move environments.
|
|
@@ -430,9 +430,9 @@ NoSqlResource(
|
|
|
430
430
|
• If using explicit schemas with PyObjectId, make sure model_config.json_encoders includes {PyObjectId: str}.
|
|
431
431
|
• When using auto-schemas, we expose ObjectId-like fields as str so no custom encoder is needed.
|
|
432
432
|
• Connected to wrong DB name
|
|
433
|
-
|
|
433
|
+
• The system locks a service_id to the DB name once prepared. If you change DBs, run `mongo prepare` with --allow-rebind.
|
|
434
434
|
• Indexes not created
|
|
435
|
-
|
|
435
|
+
• Double-check RESOURCES[indexes]. Run `mongo prepare` again and inspect the output dictionary of created indexes.
|
|
436
436
|
|
|
437
437
|
⸻
|
|
438
438
|
|
svc_infra/db/outbox.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Dict, Iterable, List, Optional, Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class OutboxMessage:
|
|
10
|
+
id: int
|
|
11
|
+
topic: str
|
|
12
|
+
payload: Dict[str, Any]
|
|
13
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
14
|
+
attempts: int = 0
|
|
15
|
+
processed_at: Optional[datetime] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OutboxStore(Protocol):
|
|
19
|
+
def enqueue(self, topic: str, payload: Dict[str, Any]) -> OutboxMessage:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
def fetch_next(self, *, topics: Optional[Iterable[str]] = None) -> Optional[OutboxMessage]:
|
|
23
|
+
"""Return the next undispatched, unprocessed message (FIFO per-topic), or None.
|
|
24
|
+
|
|
25
|
+
Notes:
|
|
26
|
+
- Messages with attempts > 0 are considered "dispatched" to the job queue and won't be re-enqueued.
|
|
27
|
+
- Delivery retries are handled by the job queue worker, not by re-reading the outbox.
|
|
28
|
+
"""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
def mark_processed(self, msg_id: int) -> None:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
def mark_failed(self, msg_id: int) -> None:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class InMemoryOutboxStore:
|
|
39
|
+
"""Simple in-memory outbox for tests and local runs."""
|
|
40
|
+
|
|
41
|
+
def __init__(self):
|
|
42
|
+
self._seq = 0
|
|
43
|
+
self._messages: List[OutboxMessage] = []
|
|
44
|
+
|
|
45
|
+
def enqueue(self, topic: str, payload: Dict[str, Any]) -> OutboxMessage:
|
|
46
|
+
self._seq += 1
|
|
47
|
+
msg = OutboxMessage(id=self._seq, topic=topic, payload=dict(payload))
|
|
48
|
+
self._messages.append(msg)
|
|
49
|
+
return msg
|
|
50
|
+
|
|
51
|
+
def fetch_next(self, *, topics: Optional[Iterable[str]] = None) -> Optional[OutboxMessage]:
|
|
52
|
+
allowed = set(topics) if topics else None
|
|
53
|
+
for msg in self._messages:
|
|
54
|
+
if msg.processed_at is not None:
|
|
55
|
+
continue
|
|
56
|
+
# skip already dispatched messages (attempts>0)
|
|
57
|
+
if msg.attempts > 0:
|
|
58
|
+
continue
|
|
59
|
+
if allowed is not None and msg.topic not in allowed:
|
|
60
|
+
continue
|
|
61
|
+
return msg
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def mark_processed(self, msg_id: int) -> None:
|
|
65
|
+
for msg in self._messages:
|
|
66
|
+
if msg.id == msg_id:
|
|
67
|
+
msg.processed_at = datetime.now(timezone.utc)
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
def mark_failed(self, msg_id: int) -> None:
|
|
71
|
+
for msg in self._messages:
|
|
72
|
+
if msg.id == msg_id:
|
|
73
|
+
msg.attempts += 1
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SqlOutboxStore:
|
|
78
|
+
"""Skeleton for a SQL-backed outbox store.
|
|
79
|
+
|
|
80
|
+
Implementations should:
|
|
81
|
+
- INSERT on enqueue
|
|
82
|
+
- SELECT FOR UPDATE SKIP LOCKED (or equivalent) to fetch next
|
|
83
|
+
- UPDATE processed_at (and attempts on failure)
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, session_factory):
|
|
87
|
+
self._session_factory = session_factory
|
|
88
|
+
|
|
89
|
+
# Placeholders to outline the API; not implemented here.
|
|
90
|
+
def enqueue(
|
|
91
|
+
self, topic: str, payload: Dict[str, Any]
|
|
92
|
+
) -> OutboxMessage: # pragma: no cover - skeleton
|
|
93
|
+
raise NotImplementedError
|
|
94
|
+
|
|
95
|
+
def fetch_next(
|
|
96
|
+
self, *, topics: Optional[Iterable[str]] = None
|
|
97
|
+
) -> Optional[OutboxMessage]: # pragma: no cover - skeleton
|
|
98
|
+
raise NotImplementedError
|
|
99
|
+
|
|
100
|
+
def mark_processed(self, msg_id: int) -> None: # pragma: no cover - skeleton
|
|
101
|
+
raise NotImplementedError
|
|
102
|
+
|
|
103
|
+
def mark_failed(self, msg_id: int) -> None: # pragma: no cover - skeleton
|
|
104
|
+
raise NotImplementedError
|
svc_infra/db/sql/repository.py
CHANGED
|
@@ -56,20 +56,31 @@ class SqlRepository:
|
|
|
56
56
|
limit: int,
|
|
57
57
|
offset: int,
|
|
58
58
|
order_by: Optional[Sequence[Any]] = None,
|
|
59
|
+
where: Optional[Sequence[Any]] = None,
|
|
59
60
|
) -> Sequence[Any]:
|
|
60
|
-
stmt = self._base_select()
|
|
61
|
+
stmt = self._base_select()
|
|
62
|
+
if where:
|
|
63
|
+
stmt = stmt.where(and_(*where))
|
|
64
|
+
stmt = stmt.limit(limit).offset(offset)
|
|
61
65
|
if order_by:
|
|
62
66
|
stmt = stmt.order_by(*order_by)
|
|
63
67
|
rows = (await session.execute(stmt)).scalars().all()
|
|
64
68
|
return rows
|
|
65
69
|
|
|
66
|
-
async def count(self, session: AsyncSession) -> int:
|
|
67
|
-
|
|
70
|
+
async def count(self, session: AsyncSession, *, where: Optional[Sequence[Any]] = None) -> int:
|
|
71
|
+
base = self._base_select()
|
|
72
|
+
if where:
|
|
73
|
+
base = base.where(and_(*where))
|
|
74
|
+
stmt = select(func.count()).select_from(base.subquery())
|
|
68
75
|
return (await session.execute(stmt)).scalar_one()
|
|
69
76
|
|
|
70
|
-
async def get(
|
|
77
|
+
async def get(
|
|
78
|
+
self, session: AsyncSession, id_value: Any, *, where: Optional[Sequence[Any]] = None
|
|
79
|
+
) -> Any | None:
|
|
71
80
|
# honors soft-delete if configured
|
|
72
81
|
stmt = self._base_select().where(self._id_column() == id_value)
|
|
82
|
+
if where:
|
|
83
|
+
stmt = stmt.where(and_(*where))
|
|
73
84
|
return (await session.execute(stmt)).scalars().first()
|
|
74
85
|
|
|
75
86
|
async def create(self, session: AsyncSession, data: dict[str, Any]) -> Any:
|
|
@@ -78,12 +89,18 @@ class SqlRepository:
|
|
|
78
89
|
obj = self.model(**filtered)
|
|
79
90
|
session.add(obj)
|
|
80
91
|
await session.flush()
|
|
92
|
+
await session.refresh(obj)
|
|
81
93
|
return obj
|
|
82
94
|
|
|
83
95
|
async def update(
|
|
84
|
-
self,
|
|
96
|
+
self,
|
|
97
|
+
session: AsyncSession,
|
|
98
|
+
id_value: Any,
|
|
99
|
+
data: dict[str, Any],
|
|
100
|
+
*,
|
|
101
|
+
where: Optional[Sequence[Any]] = None,
|
|
85
102
|
) -> Any | None:
|
|
86
|
-
obj = await self.get(session, id_value)
|
|
103
|
+
obj = await self.get(session, id_value, where=where)
|
|
87
104
|
if not obj:
|
|
88
105
|
return None
|
|
89
106
|
valid = self._model_columns()
|
|
@@ -91,21 +108,32 @@ class SqlRepository:
|
|
|
91
108
|
if k in valid and k not in self.immutable_fields:
|
|
92
109
|
setattr(obj, k, v)
|
|
93
110
|
await session.flush()
|
|
111
|
+
await session.refresh(obj)
|
|
94
112
|
return obj
|
|
95
113
|
|
|
96
|
-
async def delete(
|
|
97
|
-
|
|
114
|
+
async def delete(
|
|
115
|
+
self, session: AsyncSession, id_value: Any, *, where: Optional[Sequence[Any]] = None
|
|
116
|
+
) -> bool:
|
|
117
|
+
# Fast path: when no extra filters provided, use session.get for simplicity (matches tests)
|
|
118
|
+
if not where:
|
|
119
|
+
obj = await session.get(self.model, id_value)
|
|
120
|
+
else:
|
|
121
|
+
# Respect soft-delete and optional tenant/extra filters by selecting through base select
|
|
122
|
+
stmt = self._base_select().where(self._id_column() == id_value)
|
|
123
|
+
stmt = stmt.where(and_(*where))
|
|
124
|
+
obj = (await session.execute(stmt)).scalars().first()
|
|
98
125
|
if not obj:
|
|
99
126
|
return False
|
|
100
127
|
if self.soft_delete:
|
|
101
128
|
# Prefer timestamp, also optionally set flag to False
|
|
102
|
-
|
|
129
|
+
# Check attributes on the instance to support test doubles without class-level fields
|
|
130
|
+
if hasattr(obj, self.soft_delete_field):
|
|
103
131
|
setattr(obj, self.soft_delete_field, func.now())
|
|
104
|
-
if self.soft_delete_flag_field and hasattr(
|
|
132
|
+
if self.soft_delete_flag_field and hasattr(obj, self.soft_delete_flag_field):
|
|
105
133
|
setattr(obj, self.soft_delete_flag_field, False)
|
|
106
134
|
await session.flush()
|
|
107
135
|
return True
|
|
108
|
-
|
|
136
|
+
session.delete(obj)
|
|
109
137
|
await session.flush()
|
|
110
138
|
return True
|
|
111
139
|
|
|
@@ -118,6 +146,7 @@ class SqlRepository:
|
|
|
118
146
|
limit: int,
|
|
119
147
|
offset: int,
|
|
120
148
|
order_by: Optional[Sequence[Any]] = None,
|
|
149
|
+
where: Optional[Sequence[Any]] = None,
|
|
121
150
|
) -> Sequence[Any]:
|
|
122
151
|
ilike = f"%{q}%"
|
|
123
152
|
conditions = []
|
|
@@ -130,6 +159,8 @@ class SqlRepository:
|
|
|
130
159
|
# skip columns that cannot be used in ilike even with cast
|
|
131
160
|
continue
|
|
132
161
|
stmt = self._base_select()
|
|
162
|
+
if where:
|
|
163
|
+
stmt = stmt.where(and_(*where))
|
|
133
164
|
if conditions:
|
|
134
165
|
stmt = stmt.where(or_(*conditions))
|
|
135
166
|
stmt = stmt.limit(limit).offset(offset)
|
|
@@ -137,7 +168,14 @@ class SqlRepository:
|
|
|
137
168
|
stmt = stmt.order_by(*order_by)
|
|
138
169
|
return (await session.execute(stmt)).scalars().all()
|
|
139
170
|
|
|
140
|
-
async def count_filtered(
|
|
171
|
+
async def count_filtered(
|
|
172
|
+
self,
|
|
173
|
+
session: AsyncSession,
|
|
174
|
+
*,
|
|
175
|
+
q: str,
|
|
176
|
+
fields: Sequence[str],
|
|
177
|
+
where: Optional[Sequence[Any]] = None,
|
|
178
|
+
) -> int:
|
|
141
179
|
ilike = f"%{q}%"
|
|
142
180
|
conditions = []
|
|
143
181
|
for f in fields:
|
|
@@ -148,6 +186,8 @@ class SqlRepository:
|
|
|
148
186
|
except Exception:
|
|
149
187
|
continue
|
|
150
188
|
stmt = self._base_select()
|
|
189
|
+
if where:
|
|
190
|
+
stmt = stmt.where(and_(*where))
|
|
151
191
|
if conditions:
|
|
152
192
|
stmt = stmt.where(or_(*conditions))
|
|
153
193
|
# SELECT COUNT(*) FROM (<stmt>) as t
|
svc_infra/db/sql/resource.py
CHANGED
|
@@ -34,3 +34,8 @@ class SqlResource:
|
|
|
34
34
|
|
|
35
35
|
# Only a type reference; no runtime dependency on FastAPI layer
|
|
36
36
|
service_factory: Optional[Callable[[SqlRepository], "SqlService"]] = None
|
|
37
|
+
|
|
38
|
+
# Tenancy
|
|
39
|
+
tenant_field: Optional[str] = (
|
|
40
|
+
None # when set, CRUD router will require TenantId and scope by field
|
|
41
|
+
)
|
|
@@ -18,7 +18,7 @@ class Timestamped(BaseModel):
|
|
|
18
18
|
|
|
19
19
|
class ProviderAccountBase(BaseModel):
|
|
20
20
|
model_config = ConfigDict(from_attributes=True)
|
|
21
|
-
provider: str = Field(..., examples
|
|
21
|
+
provider: str = Field(..., json_schema_extra={"examples": ["google", "github", "linkedin", "microsoft"]})
|
|
22
22
|
provider_account_id: str
|
|
23
23
|
|
|
24
24
|
class ProviderAccountRead(ProviderAccountBase, Timestamped):
|
|
@@ -7,12 +7,8 @@ import sys, pathlib, importlib, pkgutil, traceback
|
|
|
7
7
|
|
|
8
8
|
from alembic import context
|
|
9
9
|
from sqlalchemy.engine import make_url, URL
|
|
10
|
-
from sqlalchemy.ext.asyncio import create_async_engine
|
|
11
10
|
|
|
12
|
-
from svc_infra.db.sql.utils import
|
|
13
|
-
get_database_url_from_env,
|
|
14
|
-
_ensure_ssl_default_async as _ensure_ssl_default,
|
|
15
|
-
)
|
|
11
|
+
from svc_infra.db.sql.utils import get_database_url_from_env
|
|
16
12
|
|
|
17
13
|
try:
|
|
18
14
|
from svc_infra.db.sql.types import GUID as _GUID # type: ignore
|
|
@@ -105,7 +101,6 @@ def _coerce_to_async(u: URL) -> URL:
|
|
|
105
101
|
|
|
106
102
|
u = make_url(effective_url)
|
|
107
103
|
u = _coerce_to_async(u)
|
|
108
|
-
u = _ensure_ssl_default(u)
|
|
109
104
|
config.set_main_option("sqlalchemy.url", u.render_as_string(hide_password=False))
|
|
110
105
|
|
|
111
106
|
# feature flags
|
|
@@ -177,8 +172,16 @@ def _collect_metadata() -> list[object]:
|
|
|
177
172
|
if name not in pkgs:
|
|
178
173
|
pkgs.append(name)
|
|
179
174
|
|
|
175
|
+
# Only attempt bare 'models' import if it is discoverable to avoid noisy tracebacks
|
|
180
176
|
if "models" not in pkgs:
|
|
181
|
-
|
|
177
|
+
try:
|
|
178
|
+
spec = getattr(importlib, "util", None)
|
|
179
|
+
if spec is not None and getattr(spec, "find_spec", None) is not None:
|
|
180
|
+
if spec.find_spec("models") is not None:
|
|
181
|
+
pkgs.append("models")
|
|
182
|
+
except Exception:
|
|
183
|
+
# Best-effort; if discovery fails, skip adding bare 'models'
|
|
184
|
+
pass
|
|
182
185
|
|
|
183
186
|
def _import_and_collect(modname: str):
|
|
184
187
|
try:
|
|
@@ -352,7 +355,9 @@ def _do_run_migrations(connection):
|
|
|
352
355
|
|
|
353
356
|
async def run_migrations_online() -> None:
|
|
354
357
|
url = config.get_main_option("sqlalchemy.url")
|
|
355
|
-
|
|
358
|
+
# Use build_engine to ensure proper driver-specific handling (e.g., asyncpg SSL)
|
|
359
|
+
from svc_infra.db.sql.utils import build_engine
|
|
360
|
+
engine = build_engine(url)
|
|
356
361
|
async with engine.connect() as connection:
|
|
357
362
|
await connection.run_sync(_do_run_migrations)
|
|
358
363
|
await engine.dispose()
|
|
@@ -9,8 +9,6 @@ from alembic import context
|
|
|
9
9
|
from sqlalchemy.engine import make_url, URL
|
|
10
10
|
|
|
11
11
|
from svc_infra.db.sql.utils import (
|
|
12
|
-
_coerce_sync_driver,
|
|
13
|
-
_ensure_ssl_default,
|
|
14
12
|
get_database_url_from_env,
|
|
15
13
|
build_engine,
|
|
16
14
|
)
|
|
@@ -103,7 +101,6 @@ if not effective_url:
|
|
|
103
101
|
|
|
104
102
|
u = make_url(effective_url)
|
|
105
103
|
u = _coerce_sync_driver(u)
|
|
106
|
-
u = _ensure_ssl_default(u)
|
|
107
104
|
config.set_main_option("sqlalchemy.url", u.render_as_string(hide_password=False))
|
|
108
105
|
|
|
109
106
|
|
|
@@ -191,9 +188,16 @@ def _collect_metadata() -> list[object]:
|
|
|
191
188
|
if name not in pkgs:
|
|
192
189
|
pkgs.append(name)
|
|
193
190
|
|
|
194
|
-
#
|
|
191
|
+
# Only attempt a bare 'models' import if discoverable to avoid noisy tracebacks
|
|
195
192
|
if "models" not in pkgs:
|
|
196
|
-
|
|
193
|
+
try:
|
|
194
|
+
spec = getattr(importlib, "util", None)
|
|
195
|
+
if spec is not None and getattr(spec, "find_spec", None) is not None:
|
|
196
|
+
if spec.find_spec("models") is not None:
|
|
197
|
+
pkgs.append("models")
|
|
198
|
+
except Exception:
|
|
199
|
+
# If discovery fails, skip adding bare 'models'
|
|
200
|
+
pass
|
|
197
201
|
|
|
198
202
|
def _import_and_collect(modname: str):
|
|
199
203
|
try:
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Sequence
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
|
|
7
|
+
from .service import SqlService
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TenantSqlService(SqlService):
|
|
11
|
+
"""
|
|
12
|
+
SQL service wrapper that automatically scopes operations to a tenant.
|
|
13
|
+
|
|
14
|
+
- Adds a where filter (model.tenant_field == tenant_id) for list/get/update/delete/search/count.
|
|
15
|
+
- On create, if the model has the tenant field and it's not set in data, injects tenant_id.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, repo, *, tenant_id: str, tenant_field: str = "tenant_id"):
|
|
19
|
+
super().__init__(repo)
|
|
20
|
+
self.tenant_id = tenant_id
|
|
21
|
+
self.tenant_field = tenant_field
|
|
22
|
+
|
|
23
|
+
def _where(self) -> Sequence[Any]:
|
|
24
|
+
model = self.repo.model
|
|
25
|
+
col = getattr(model, self.tenant_field, None)
|
|
26
|
+
if col is None:
|
|
27
|
+
return []
|
|
28
|
+
return [col == self.tenant_id]
|
|
29
|
+
|
|
30
|
+
async def list(self, session: AsyncSession, *, limit: int, offset: int, order_by=None):
|
|
31
|
+
return await self.repo.list(
|
|
32
|
+
session, limit=limit, offset=offset, order_by=order_by, where=self._where()
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
async def count(self, session: AsyncSession) -> int:
|
|
36
|
+
return await self.repo.count(session, where=self._where())
|
|
37
|
+
|
|
38
|
+
async def get(self, session: AsyncSession, id_value: Any):
|
|
39
|
+
return await self.repo.get(session, id_value, where=self._where())
|
|
40
|
+
|
|
41
|
+
async def create(self, session: AsyncSession, data: dict[str, Any]):
|
|
42
|
+
data = await self.pre_create(data)
|
|
43
|
+
# inject tenant_id if model supports it and value missing
|
|
44
|
+
if self.tenant_field in self.repo._model_columns() and self.tenant_field not in data:
|
|
45
|
+
data[self.tenant_field] = self.tenant_id
|
|
46
|
+
return await self.repo.create(session, data)
|
|
47
|
+
|
|
48
|
+
async def update(self, session: AsyncSession, id_value: Any, data: dict[str, Any]):
|
|
49
|
+
data = await self.pre_update(data)
|
|
50
|
+
return await self.repo.update(session, id_value, data, where=self._where())
|
|
51
|
+
|
|
52
|
+
async def delete(self, session: AsyncSession, id_value: Any) -> bool:
|
|
53
|
+
return await self.repo.delete(session, id_value, where=self._where())
|
|
54
|
+
|
|
55
|
+
async def search(
|
|
56
|
+
self,
|
|
57
|
+
session: AsyncSession,
|
|
58
|
+
*,
|
|
59
|
+
q: str,
|
|
60
|
+
fields: Sequence[str],
|
|
61
|
+
limit: int,
|
|
62
|
+
offset: int,
|
|
63
|
+
order_by=None,
|
|
64
|
+
):
|
|
65
|
+
return await self.repo.search(
|
|
66
|
+
session,
|
|
67
|
+
q=q,
|
|
68
|
+
fields=fields,
|
|
69
|
+
limit=limit,
|
|
70
|
+
offset=offset,
|
|
71
|
+
order_by=order_by,
|
|
72
|
+
where=self._where(),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def count_filtered(self, session: AsyncSession, *, q: str, fields: Sequence[str]) -> int:
|
|
76
|
+
return await self.repo.count_filtered(session, q=q, fields=fields, where=self._where())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = ["TenantSqlService"]
|