altcodepro-polydb-python 2.5.9__py3-none-any.whl → 2.5.11__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.
- {altcodepro_polydb_python-2.5.9.dist-info → altcodepro_polydb_python-2.5.11.dist-info}/METADATA +1 -1
- altcodepro_polydb_python-2.5.11.dist-info/RECORD +74 -0
- polydb/PolyDB.py +1 -1
- polydb/__init__.py +25 -14
- polydb/adapters/AzureBlobStorageAdapter.py +2 -1
- polydb/adapters/AzureQueueAdapter.py +3 -5
- polydb/adapters/AzureTableStorageAdapter.py +27 -9
- polydb/adapters/BlockchainBlobAdapter.py +0 -1
- polydb/adapters/BlockchainFileAdapter.py +1 -1
- polydb/adapters/BlockchainKVAdapter.py +20 -5
- polydb/adapters/BlockchainQueueAdapter.py +0 -1
- polydb/adapters/DynamoDBAdapter.py +2 -6
- polydb/adapters/EFSAdapter.py +1 -1
- polydb/adapters/GCPFilestoreAdapter.py +1 -1
- polydb/adapters/GCPPubSubAdapter.py +2 -2
- polydb/adapters/GCPStorageAdapter.py +1 -1
- polydb/adapters/KafkaQueueAdapter.py +10 -16
- polydb/adapters/MongoDBAdapter.py +3 -5
- polydb/adapters/PostgreSQLAdapter.py +38 -13
- polydb/adapters/RabbitMQAdapter.py +2 -2
- polydb/adapters/S3Adapter.py +1 -3
- polydb/adapters/S3CompatibleAdapter.py +1 -1
- polydb/adapters/SQSAdapter.py +1 -2
- polydb/adapters/VercelBlobAdapter.py +1 -1
- polydb/adapters/VercelKVAdapter.py +6 -5
- polydb/adapters/VercelQueueAdapter.py +18 -4
- polydb/advanced_query.py +5 -4
- polydb/aio.py +198 -0
- polydb/audit/AuditStorage.py +14 -19
- polydb/audit/__init__.py +2 -2
- polydb/audit/context.py +6 -5
- polydb/audit/manager.py +3 -3
- polydb/audit/models.py +3 -2
- polydb/base/NoSQLKVAdapter.py +22 -14
- polydb/base/ObjectStorageAdapter.py +3 -2
- polydb/base/QueueAdapter.py +2 -3
- polydb/base/SecretsAdapter.py +2 -1
- polydb/base/SharedFilesAdapter.py +2 -3
- polydb/batch.py +38 -49
- polydb/cache.py +5 -2
- polydb/cloudDatabaseFactory.py +59 -22
- polydb/databaseFactory.py +7 -13
- polydb/errors.py +2 -0
- polydb/json_safe.py +1 -2
- polydb/models.py +1 -1
- polydb/monitoring.py +27 -15
- polydb/multitenancy.py +50 -59
- polydb/observability/logging.py +0 -1
- polydb/overflow_gc.py +179 -0
- polydb/query.py +3 -1
- polydb/retry.py +1 -1
- polydb/schema.py +45 -60
- polydb/security.py +7 -9
- polydb/services/__init__.py +2 -2
- polydb/services/compliance_service.py +10 -4
- polydb/services/security_service.py +9 -12
- polydb/types.py +28 -62
- polydb/utils.py +7 -8
- polydb/validation.py +50 -52
- altcodepro_polydb_python-2.5.9.dist-info/RECORD +0 -73
- polydb/decorators.py +0 -1040
- {altcodepro_polydb_python-2.5.9.dist-info → altcodepro_polydb_python-2.5.11.dist-info}/WHEEL +0 -0
- {altcodepro_polydb_python-2.5.9.dist-info → altcodepro_polydb_python-2.5.11.dist-info}/licenses/LICENSE +0 -0
- {altcodepro_polydb_python-2.5.9.dist-info → altcodepro_polydb_python-2.5.11.dist-info}/top_level.txt +0 -0
polydb/adapters/S3Adapter.py
CHANGED
|
@@ -11,9 +11,8 @@ import os
|
|
|
11
11
|
import threading
|
|
12
12
|
from typing import Any, Dict, List, Optional
|
|
13
13
|
|
|
14
|
-
|
|
15
14
|
from ..base.ObjectStorageAdapter import ObjectStorageAdapter
|
|
16
|
-
from ..errors import
|
|
15
|
+
from ..errors import ConnectionError, StorageError
|
|
17
16
|
from ..retry import retry
|
|
18
17
|
|
|
19
18
|
|
|
@@ -72,7 +71,6 @@ class S3Adapter(ObjectStorageAdapter):
|
|
|
72
71
|
|
|
73
72
|
def _ensure_bucket_exists(self):
|
|
74
73
|
"""Create bucket if it doesn't exist (safe for AWS + LocalStack)"""
|
|
75
|
-
import boto3
|
|
76
74
|
from botocore.exceptions import ClientError
|
|
77
75
|
|
|
78
76
|
if not self._client:
|
|
@@ -5,7 +5,7 @@ import threading
|
|
|
5
5
|
from typing import Any, Dict, List, Optional
|
|
6
6
|
|
|
7
7
|
from ..base.ObjectStorageAdapter import ObjectStorageAdapter
|
|
8
|
-
from ..errors import
|
|
8
|
+
from ..errors import ConnectionError, StorageError
|
|
9
9
|
from ..retry import retry
|
|
10
10
|
|
|
11
11
|
|
polydb/adapters/SQSAdapter.py
CHANGED
|
@@ -5,11 +5,10 @@ import os
|
|
|
5
5
|
import threading
|
|
6
6
|
from typing import Any, Dict, List
|
|
7
7
|
|
|
8
|
-
|
|
9
8
|
from ..base.QueueAdapter import QueueAdapter
|
|
10
9
|
from ..errors import ConnectionError, QueueError
|
|
11
|
-
from ..retry import retry
|
|
12
10
|
from ..json_safe import json_safe
|
|
11
|
+
from ..retry import retry
|
|
13
12
|
|
|
14
13
|
|
|
15
14
|
class SQSAdapter(QueueAdapter):
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
# src/polydb/adapters/VercelKVAdapter.py
|
|
2
2
|
|
|
3
|
-
import os
|
|
4
3
|
import json
|
|
5
|
-
import
|
|
4
|
+
import os
|
|
6
5
|
from typing import Any, Dict, List, Optional, Tuple
|
|
7
6
|
|
|
7
|
+
import redis
|
|
8
|
+
|
|
9
|
+
from ..base.NoSQLKVAdapter import NoSQLKVAdapter
|
|
10
|
+
from ..errors import DatabaseError, NoSQLError
|
|
8
11
|
from ..json_safe import json_safe
|
|
9
|
-
from ..
|
|
12
|
+
from ..models import PartitionConfig
|
|
10
13
|
from ..retry import retry
|
|
11
14
|
from ..types import JsonDict
|
|
12
|
-
from ..models import PartitionConfig
|
|
13
|
-
from ..base.NoSQLKVAdapter import NoSQLKVAdapter
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
class VercelKVAdapter(NoSQLKVAdapter):
|
|
@@ -1,17 +1,31 @@
|
|
|
1
|
-
import os
|
|
2
1
|
import json
|
|
3
|
-
import
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
4
5
|
import redis
|
|
5
|
-
|
|
6
|
+
import requests
|
|
6
7
|
|
|
7
8
|
from ..base.QueueAdapter import QueueAdapter
|
|
8
9
|
from ..errors import QueueError
|
|
9
10
|
from ..retry import retry
|
|
10
11
|
|
|
11
12
|
|
|
12
|
-
class VercelQueueAdapter:
|
|
13
|
+
class VercelQueueAdapter(QueueAdapter):
|
|
14
|
+
"""Every sibling queue adapter (SQS/Azure/RabbitMQ) extends the real
|
|
15
|
+
QueueAdapter(ABC) contract -- this one didn't (a real, found-while-
|
|
16
|
+
adding-CI-lint drift, not a deliberate design choice: `send`/
|
|
17
|
+
`receive`/`delete` below already satisfy every abstract method the
|
|
18
|
+
base requires). Without it, `nack`/`purge`/`declare`/`status`/
|
|
19
|
+
`extend`/`delay`/`cancel` fell through to a plain AttributeError
|
|
20
|
+
instead of the base's own intentional, well-named
|
|
21
|
+
NotImplementedError('VercelQueueAdapter does not implement ...') --
|
|
22
|
+
and this class silently opted out of `isinstance(adapter,
|
|
23
|
+
QueueAdapter)` checks. `super().__init__()` is a no-op beyond setting
|
|
24
|
+
`self.logger` (unused here today, same as every other adapter's own
|
|
25
|
+
inherited logger until something actually calls it)."""
|
|
13
26
|
|
|
14
27
|
def __init__(self, url: str = "", token: str = ""):
|
|
28
|
+
super().__init__()
|
|
15
29
|
self.url = url or os.getenv("KV_REST_API_URL")
|
|
16
30
|
self.token = token or os.getenv("KV_REST_API_TOKEN")
|
|
17
31
|
|
polydb/advanced_query.py
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
"""
|
|
3
3
|
Advanced query capabilities: JOIN, subqueries, aggregates
|
|
4
4
|
"""
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
import re
|
|
6
7
|
from dataclasses import dataclass, field
|
|
7
8
|
from enum import Enum
|
|
8
|
-
import
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
9
10
|
|
|
10
11
|
from .errors import ValidationError
|
|
11
|
-
from .utils import
|
|
12
|
+
from .utils import validate_column_name, validate_table_name
|
|
12
13
|
|
|
13
14
|
# table.column / alias.column - each half validated with the same allowlists
|
|
14
15
|
# used elsewhere for identifiers, joined by a single literal dot.
|
|
@@ -155,7 +156,7 @@ class AdvancedQueryBuilder:
|
|
|
155
156
|
select_parts = list(self.group_by_fields) + select_parts
|
|
156
157
|
sql = f"SELECT {', '.join(select_parts)}"
|
|
157
158
|
else:
|
|
158
|
-
sql =
|
|
159
|
+
sql = "SELECT *"
|
|
159
160
|
|
|
160
161
|
# FROM clause
|
|
161
162
|
sql += f" FROM {self.table}"
|
polydb/aio.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Async API for PolyDB -- closes CLAUDE.md's own known-gap #5 ("No async
|
|
2
|
+
# API. Everything is synchronous... A documented stance would at least
|
|
3
|
+
# set expectations").
|
|
4
|
+
#
|
|
5
|
+
# THE STANCE, stated plainly: PolyDB's entire adapter layer is built on
|
|
6
|
+
# genuinely synchronous SDKs -- psycopg2 (not asyncpg), boto3 (not
|
|
7
|
+
# aioboto3), the sync azure-* / google-cloud-* clients, pymongo (not
|
|
8
|
+
# motor), pika, kafka-python. A "real" native-async rewrite would mean
|
|
9
|
+
# swapping every one of those for an async-native equivalent across every
|
|
10
|
+
# adapter in `adapters/*.py` -- a from-scratch, parallel adapter layer
|
|
11
|
+
# roughly doubling this package's own maintenance surface (every fix,
|
|
12
|
+
# every new provider feature, landing twice), for a benefit (not blocking
|
|
13
|
+
# the event loop) that a MUCH smaller, purely additive wrapper already
|
|
14
|
+
# delivers for the overwhelming majority of real callers: an
|
|
15
|
+
# asyncio/FastAPI app that doesn't want a slow Postgres/S3/DynamoDB call
|
|
16
|
+
# to stall its event loop.
|
|
17
|
+
#
|
|
18
|
+
# So this module is that smaller thing, not a rewrite: `AsyncPolyDB`/
|
|
19
|
+
# `AsyncDatabaseFactory` wrap the real, unchanged, synchronous
|
|
20
|
+
# `PolyDB`/`DatabaseFactory` instance and run every one of its methods in
|
|
21
|
+
# a worker thread via `asyncio.to_thread` (or a dedicated
|
|
22
|
+
# `ThreadPoolExecutor` when `max_workers` is given), returning an
|
|
23
|
+
# awaitable. This is the same "wrap a blocking DB-API driver in a thread
|
|
24
|
+
# pool" pattern `encode/databases` and Starlette's own docs recommend for
|
|
25
|
+
# exactly this situation -- not a novel trick, the standard one.
|
|
26
|
+
#
|
|
27
|
+
# Genuinely additive: `PolyDB`/`DatabaseFactory` themselves are completely
|
|
28
|
+
# untouched by this module -- every existing synchronous caller keeps
|
|
29
|
+
# working byte-for-byte unchanged. A caller who needs real native async
|
|
30
|
+
# (avoiding thread-pool overhead entirely, or wanting async cancellation
|
|
31
|
+
# semantics psycopg2 can't give) still needs the from-scratch rewrite
|
|
32
|
+
# described above -- that is real, separate, much larger future work,
|
|
33
|
+
# not something this module pretends to already be.
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
import functools
|
|
38
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
39
|
+
from typing import Any, Callable, Generic, Optional, TypeVar
|
|
40
|
+
|
|
41
|
+
_T = TypeVar("_T")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _AsyncProxy(Generic[_T]):
|
|
45
|
+
"""Wraps any synchronous object `sync` and exposes every one of its
|
|
46
|
+
public callables as an `async def`-equivalent (an async function
|
|
47
|
+
returned from `__getattr__`, run via `asyncio.to_thread`/a dedicated
|
|
48
|
+
executor); every non-callable attribute passes through unchanged,
|
|
49
|
+
read synchronously (cheap, in-memory access -- never worth a thread
|
|
50
|
+
hop). Not specific to PolyDB/DatabaseFactory -- either concrete
|
|
51
|
+
subclass below just fixes the wrapped type for clearer type hints
|
|
52
|
+
and a more specific `__repr__`.
|
|
53
|
+
|
|
54
|
+
`max_workers=None` (the default) runs every call via
|
|
55
|
+
`asyncio.to_thread`, i.e. the running loop's own default executor --
|
|
56
|
+
shared with anything else in the same process already using
|
|
57
|
+
`asyncio.to_thread`, the same default a plain asyncio app already
|
|
58
|
+
has. Pass `max_workers=N` for a DEDICATED thread pool sized for this
|
|
59
|
+
instance's own expected DB/cloud-call concurrency, decoupled from
|
|
60
|
+
whatever else the host application runs on the default executor --
|
|
61
|
+
worth doing once real concurrent load is a concern, since the
|
|
62
|
+
default executor is sized `min(32, os.cpu_count() + 4)` by Python
|
|
63
|
+
itself, which can bottleneck a database-heavy workload sharing it
|
|
64
|
+
with other blocking work.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, sync: _T, *, max_workers: Optional[int] = None) -> None:
|
|
68
|
+
self._sync = sync
|
|
69
|
+
self._executor: Optional[ThreadPoolExecutor] = (
|
|
70
|
+
ThreadPoolExecutor(
|
|
71
|
+
max_workers=max_workers, thread_name_prefix=f"{type(self).__name__.lower()}"
|
|
72
|
+
)
|
|
73
|
+
if max_workers is not None
|
|
74
|
+
else None
|
|
75
|
+
)
|
|
76
|
+
self._wrapper_cache: dict[str, Callable[..., Any]] = {}
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def sync(self) -> _T:
|
|
80
|
+
"""Escape hatch to the real, wrapped, synchronous instance -- for
|
|
81
|
+
anything this wrapper doesn't cover, or to call something
|
|
82
|
+
deliberately on the current thread (already running inside a
|
|
83
|
+
worker thread, a one-off script with no running event loop,
|
|
84
|
+
etc.)."""
|
|
85
|
+
return self._sync
|
|
86
|
+
|
|
87
|
+
async def _run(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
|
88
|
+
bound = functools.partial(fn, *args, **kwargs)
|
|
89
|
+
if self._executor is not None:
|
|
90
|
+
loop = asyncio.get_running_loop()
|
|
91
|
+
return await loop.run_in_executor(self._executor, bound)
|
|
92
|
+
return await asyncio.to_thread(bound)
|
|
93
|
+
|
|
94
|
+
def __getattr__(self, name: str) -> Any:
|
|
95
|
+
if name.startswith("_"):
|
|
96
|
+
raise AttributeError(name)
|
|
97
|
+
cached = self._wrapper_cache.get(name)
|
|
98
|
+
if cached is not None:
|
|
99
|
+
return cached
|
|
100
|
+
attr = getattr(self._sync, name)
|
|
101
|
+
if not callable(attr):
|
|
102
|
+
return attr
|
|
103
|
+
|
|
104
|
+
async def _async_call(*args: Any, __attr: Callable[..., Any] = attr, **kwargs: Any) -> Any:
|
|
105
|
+
return await self._run(__attr, *args, **kwargs)
|
|
106
|
+
|
|
107
|
+
_async_call.__name__ = name
|
|
108
|
+
_async_call.__doc__ = getattr(attr, "__doc__", None)
|
|
109
|
+
self._wrapper_cache[name] = _async_call
|
|
110
|
+
return _async_call
|
|
111
|
+
|
|
112
|
+
def __dir__(self) -> list[str]:
|
|
113
|
+
return sorted(set(super().__dir__()) | set(dir(self._sync)))
|
|
114
|
+
|
|
115
|
+
def __repr__(self) -> str:
|
|
116
|
+
return f"{type(self).__name__}({self._sync!r})"
|
|
117
|
+
|
|
118
|
+
def close(self) -> None:
|
|
119
|
+
"""Shuts down the dedicated executor, if one was created
|
|
120
|
+
(`max_workers` was given). A no-op when running off the shared
|
|
121
|
+
default executor (`max_workers=None`) -- there is nothing this
|
|
122
|
+
instance owns to shut down in that case."""
|
|
123
|
+
if self._executor is not None:
|
|
124
|
+
self._executor.shutdown(wait=True)
|
|
125
|
+
|
|
126
|
+
async def __aenter__(self) -> "_AsyncProxy[_T]":
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
async def __aexit__(self, *exc_info: Any) -> None:
|
|
130
|
+
self.close()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class AsyncDatabaseFactory(
|
|
134
|
+
_AsyncProxy["DatabaseFactory"]
|
|
135
|
+
): # noqa: F821 -- DatabaseFactory imported lazily below for typing only
|
|
136
|
+
"""Async wrapper over `DatabaseFactory` -- for callers using the
|
|
137
|
+
lower-level factory directly rather than the `PolyDB` facade. See
|
|
138
|
+
this module's own top comment for the full design rationale."""
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
*args: Any,
|
|
143
|
+
max_workers: Optional[int] = None,
|
|
144
|
+
sync: Optional[Any] = None,
|
|
145
|
+
**kwargs: Any,
|
|
146
|
+
) -> None:
|
|
147
|
+
if sync is None:
|
|
148
|
+
from .databaseFactory import DatabaseFactory
|
|
149
|
+
|
|
150
|
+
sync = DatabaseFactory(*args, **kwargs)
|
|
151
|
+
elif args or kwargs:
|
|
152
|
+
raise TypeError(
|
|
153
|
+
"AsyncDatabaseFactory: pass either `sync=<existing DatabaseFactory>` or constructor args, not both"
|
|
154
|
+
)
|
|
155
|
+
super().__init__(sync, max_workers=max_workers)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class AsyncPolyDB(
|
|
159
|
+
_AsyncProxy["PolyDB"]
|
|
160
|
+
): # noqa: F821 -- PolyDB imported lazily below for typing only
|
|
161
|
+
"""Async wrapper over the `PolyDB` facade -- the primary
|
|
162
|
+
developer-facing async entrypoint. See this module's own top comment
|
|
163
|
+
for the full design rationale (why this is a thread-pool wrapper, not
|
|
164
|
+
a native-async rewrite, and what a real native-async story would
|
|
165
|
+
require).
|
|
166
|
+
|
|
167
|
+
Usage::
|
|
168
|
+
|
|
169
|
+
db = AsyncPolyDB(provider=CloudProvider.POSTGRESQL)
|
|
170
|
+
row = await db.create(MyModel, {"id": "1", "name": "hi"})
|
|
171
|
+
rows = await db.read(MyModel, {"name": "hi"})
|
|
172
|
+
|
|
173
|
+
# or, for a dedicated thread pool sized for this instance:
|
|
174
|
+
db = AsyncPolyDB(provider=CloudProvider.POSTGRESQL, max_workers=20)
|
|
175
|
+
|
|
176
|
+
# or, wrapping an already-constructed sync PolyDB:
|
|
177
|
+
db = AsyncPolyDB(sync=existing_polydb_instance)
|
|
178
|
+
|
|
179
|
+
async with db:
|
|
180
|
+
... # db.close() runs automatically on exit
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(
|
|
184
|
+
self,
|
|
185
|
+
*args: Any,
|
|
186
|
+
max_workers: Optional[int] = None,
|
|
187
|
+
sync: Optional[Any] = None,
|
|
188
|
+
**kwargs: Any,
|
|
189
|
+
) -> None:
|
|
190
|
+
if sync is None:
|
|
191
|
+
from .PolyDB import PolyDB
|
|
192
|
+
|
|
193
|
+
sync = PolyDB(*args, **kwargs)
|
|
194
|
+
elif args or kwargs:
|
|
195
|
+
raise TypeError(
|
|
196
|
+
"AsyncPolyDB: pass either `sync=<existing PolyDB>` or constructor args, not both"
|
|
197
|
+
)
|
|
198
|
+
super().__init__(sync, max_workers=max_workers)
|
polydb/audit/AuditStorage.py
CHANGED
|
@@ -4,10 +4,10 @@ from __future__ import annotations
|
|
|
4
4
|
import hmac
|
|
5
5
|
import threading
|
|
6
6
|
from dataclasses import dataclass
|
|
7
|
-
from typing import Optional
|
|
7
|
+
from typing import Optional
|
|
8
8
|
|
|
9
|
-
from .models import AuditRecord
|
|
10
9
|
from ..cloudDatabaseFactory import CloudDatabaseFactory
|
|
10
|
+
from .models import AuditRecord
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class AuditStorage:
|
|
@@ -54,14 +54,14 @@ class AuditStorage:
|
|
|
54
54
|
CONSTRAINT uq_audit_chain UNIQUE (tenant_id, previous_hash),
|
|
55
55
|
created_at TIMESTAMP DEFAULT NOW()
|
|
56
56
|
);
|
|
57
|
-
|
|
58
|
-
CREATE INDEX IF NOT EXISTS idx_audit_tenant_timestamp
|
|
57
|
+
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_audit_tenant_timestamp
|
|
59
59
|
ON polydb_audit_log(tenant_id, timestamp DESC);
|
|
60
|
-
CREATE INDEX IF NOT EXISTS idx_audit_model_entity
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_audit_model_entity
|
|
61
61
|
ON polydb_audit_log(model, entity_id);
|
|
62
|
-
CREATE INDEX IF NOT EXISTS idx_audit_actor
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_audit_actor
|
|
63
63
|
ON polydb_audit_log(actor_id, timestamp DESC);
|
|
64
|
-
CREATE INDEX IF NOT EXISTS idx_audit_hash_chain
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_audit_hash_chain
|
|
65
65
|
ON polydb_audit_log(tenant_id, timestamp DESC, previous_hash);
|
|
66
66
|
"""
|
|
67
67
|
|
|
@@ -74,7 +74,7 @@ class AuditStorage:
|
|
|
74
74
|
"""Get most recent hash with strict ordering (distributed-safe)"""
|
|
75
75
|
with self._lock:
|
|
76
76
|
try:
|
|
77
|
-
from ..query import
|
|
77
|
+
from ..query import Operator, QueryBuilder
|
|
78
78
|
|
|
79
79
|
builder = QueryBuilder()
|
|
80
80
|
|
|
@@ -99,9 +99,10 @@ class AuditStorage:
|
|
|
99
99
|
and re-chains instead of forking. (threading.Lock alone was only
|
|
100
100
|
process-local — the old "distributed-safe" claim was false.)"""
|
|
101
101
|
from dataclasses import asdict
|
|
102
|
+
|
|
102
103
|
from .models import (
|
|
103
|
-
AuditKeyMissingError,
|
|
104
104
|
AUDIT_HMAC_KEY_ENV,
|
|
105
|
+
AuditKeyMissingError,
|
|
105
106
|
audit_hmac_key,
|
|
106
107
|
compute_audit_hash,
|
|
107
108
|
require_hmac,
|
|
@@ -157,9 +158,7 @@ class AuditStorage:
|
|
|
157
158
|
"""Verify BOTH chain linkage AND per-record content integrity."""
|
|
158
159
|
return self.verify_chain_detailed(tenant_id).ok
|
|
159
160
|
|
|
160
|
-
def verify_chain_detailed(
|
|
161
|
-
self, tenant_id: Optional[str] = None
|
|
162
|
-
) -> "ChainVerification":
|
|
161
|
+
def verify_chain_detailed(self, tenant_id: Optional[str] = None) -> "ChainVerification":
|
|
163
162
|
"""Verify the chain, reporting *why* rather than only whether.
|
|
164
163
|
|
|
165
164
|
Two things are checked per record: that it links to its predecessor,
|
|
@@ -184,7 +183,7 @@ class AuditStorage:
|
|
|
184
183
|
as everything after it. ``legacy_records`` reports how much of the
|
|
185
184
|
chain is still resting on that seal.
|
|
186
185
|
"""
|
|
187
|
-
from ..query import
|
|
186
|
+
from ..query import Operator, QueryBuilder
|
|
188
187
|
from .models import audit_hmac_key, compute_audit_hash
|
|
189
188
|
|
|
190
189
|
builder = QueryBuilder()
|
|
@@ -215,13 +214,9 @@ class AuditStorage:
|
|
|
215
214
|
|
|
216
215
|
stored = r.get("hash") or ""
|
|
217
216
|
|
|
218
|
-
if key is not None and hmac.compare_digest(
|
|
219
|
-
stored, compute_audit_hash(r, key=key)
|
|
220
|
-
):
|
|
217
|
+
if key is not None and hmac.compare_digest(stored, compute_audit_hash(r, key=key)):
|
|
221
218
|
seen_keyed = True
|
|
222
|
-
elif not seen_keyed and hmac.compare_digest(
|
|
223
|
-
stored, compute_audit_hash(r, key=None)
|
|
224
|
-
):
|
|
219
|
+
elif not seen_keyed and hmac.compare_digest(stored, compute_audit_hash(r, key=None)):
|
|
225
220
|
# Predates the key. Sealed by the first keyed record that
|
|
226
221
|
# follows it, so it is only trusted while none has appeared.
|
|
227
222
|
legacy += 1
|
polydb/audit/__init__.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# src/polydb/audit/__init__.py
|
|
2
|
-
from .
|
|
2
|
+
from .AuditStorage import AuditStorage
|
|
3
3
|
from .context import AuditContext
|
|
4
4
|
from .manager import AuditManager
|
|
5
|
-
from .
|
|
5
|
+
from .models import AuditRecord
|
|
6
6
|
|
|
7
7
|
__all__ = ["AuditRecord", "AuditContext", "AuditManager", "AuditStorage"]
|
polydb/audit/context.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# src/polydb/audit/AuditContext.py
|
|
2
2
|
from contextvars import ContextVar
|
|
3
|
-
from typing import
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
4
5
|
|
|
5
6
|
class AuditContext:
|
|
6
7
|
"""Context variables for audit trail"""
|
|
7
|
-
|
|
8
|
+
|
|
8
9
|
actor_id: ContextVar[Optional[str]] = ContextVar("actor_id", default=None)
|
|
9
10
|
roles: ContextVar[List[str]] = ContextVar("roles", default=[])
|
|
10
11
|
tenant_id: ContextVar[Optional[str]] = ContextVar("tenant_id", default=None)
|
|
@@ -12,7 +13,7 @@ class AuditContext:
|
|
|
12
13
|
request_id: ContextVar[Optional[str]] = ContextVar("request_id", default=None)
|
|
13
14
|
ip_address: ContextVar[Optional[str]] = ContextVar("ip_address", default=None)
|
|
14
15
|
user_agent: ContextVar[Optional[str]] = ContextVar("user_agent", default=None)
|
|
15
|
-
|
|
16
|
+
|
|
16
17
|
@classmethod
|
|
17
18
|
def set(
|
|
18
19
|
cls,
|
|
@@ -40,7 +41,7 @@ class AuditContext:
|
|
|
40
41
|
cls.ip_address.set(ip_address)
|
|
41
42
|
if user_agent is not None:
|
|
42
43
|
cls.user_agent.set(user_agent)
|
|
43
|
-
|
|
44
|
+
|
|
44
45
|
@classmethod
|
|
45
46
|
def clear(cls):
|
|
46
47
|
"""Clear all context variables"""
|
|
@@ -50,4 +51,4 @@ class AuditContext:
|
|
|
50
51
|
cls.trace_id.set(None)
|
|
51
52
|
cls.request_id.set(None)
|
|
52
53
|
cls.ip_address.set(None)
|
|
53
|
-
cls.user_agent.set(None)
|
|
54
|
+
cls.user_agent.set(None)
|
polydb/audit/manager.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# src/polydb/audit/manager.py
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from typing import
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
5
|
|
|
6
|
-
from .models import AuditRecord
|
|
7
6
|
from .AuditStorage import AuditStorage
|
|
8
7
|
from .context import AuditContext
|
|
8
|
+
from .models import AuditRecord
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class AuditManager:
|
|
@@ -24,7 +24,7 @@ class AuditManager:
|
|
|
24
24
|
before: Optional[Dict[str, Any]],
|
|
25
25
|
after: Optional[Dict[str, Any]],
|
|
26
26
|
error: Optional[str],
|
|
27
|
-
changed_fields: List[str] | None
|
|
27
|
+
changed_fields: List[str] | None,
|
|
28
28
|
) -> None:
|
|
29
29
|
tenant_id = AuditContext.tenant_id.get()
|
|
30
30
|
previous_hash = self.storage.get_last_hash(tenant_id)
|
polydb/audit/models.py
CHANGED
|
@@ -18,15 +18,16 @@ Records written before a key was configured stay verifiable. See
|
|
|
18
18
|
than a loophole.
|
|
19
19
|
"""
|
|
20
20
|
|
|
21
|
-
from dataclasses import dataclass, asdict
|
|
22
|
-
from typing import Any, Dict, List, Optional
|
|
23
21
|
import hashlib
|
|
24
22
|
import hmac
|
|
25
23
|
import json
|
|
26
24
|
import logging
|
|
27
25
|
import os
|
|
28
26
|
import uuid
|
|
27
|
+
from dataclasses import asdict, dataclass
|
|
29
28
|
from datetime import datetime, timezone
|
|
29
|
+
from typing import Any, Dict, List, Optional
|
|
30
|
+
|
|
30
31
|
from ..json_safe import json_safe
|
|
31
32
|
|
|
32
33
|
logger = logging.getLogger("polydb.audit")
|
polydb/base/NoSQLKVAdapter.py
CHANGED
|
@@ -5,14 +5,12 @@ import base64
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import json
|
|
7
7
|
import threading
|
|
8
|
-
from typing import Any, Dict, List, Optional, Tuple, Union,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
from ..json_safe import json_safe
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, cast
|
|
12
9
|
|
|
13
10
|
from ..errors import NoSQLError, StorageError
|
|
11
|
+
from ..json_safe import json_safe
|
|
12
|
+
from ..query import Operator, QueryBuilder
|
|
14
13
|
from ..retry import retry
|
|
15
|
-
from ..query import QueryBuilder, Operator
|
|
16
14
|
from ..types import JsonDict, Lookup
|
|
17
15
|
|
|
18
16
|
if TYPE_CHECKING:
|
|
@@ -234,7 +232,8 @@ class NoSQLKVAdapter:
|
|
|
234
232
|
# Protocol implementation
|
|
235
233
|
def put(self, model: type, data: JsonDict) -> JsonDict:
|
|
236
234
|
pk, rk = self._get_pk_rk(model, data)
|
|
237
|
-
|
|
235
|
+
store_data, _ = self._check_overflow(data)
|
|
236
|
+
return self._put_raw(model, pk, rk, store_data)
|
|
238
237
|
|
|
239
238
|
def query(
|
|
240
239
|
self,
|
|
@@ -340,8 +339,11 @@ class NoSQLKVAdapter:
|
|
|
340
339
|
# ---------------------------------------------------------------
|
|
341
340
|
|
|
342
341
|
@property
|
|
343
|
-
def capabilities(
|
|
342
|
+
def capabilities(
|
|
343
|
+
self,
|
|
344
|
+
) -> "BackendCapabilities": # noqa: F821 -- lazily imported below, quoted on purpose
|
|
344
345
|
from ..models import BackendCapabilities
|
|
346
|
+
|
|
345
347
|
return BackendCapabilities()
|
|
346
348
|
|
|
347
349
|
def _encode_cursor(self, data: dict) -> str:
|
|
@@ -353,7 +355,11 @@ class NoSQLKVAdapter:
|
|
|
353
355
|
except Exception:
|
|
354
356
|
return {}
|
|
355
357
|
|
|
356
|
-
def query_paged(
|
|
358
|
+
def query_paged(
|
|
359
|
+
self,
|
|
360
|
+
model: type,
|
|
361
|
+
request: "PageRequest", # noqa: F821 -- lazily imported below, quoted on purpose
|
|
362
|
+
) -> "PageResult": # noqa: F821 -- lazily imported below, quoted on purpose
|
|
357
363
|
"""In-memory pagination with opaque offset cursor. Subclasses may override."""
|
|
358
364
|
from ..models import PageResult
|
|
359
365
|
|
|
@@ -378,14 +384,16 @@ class NoSQLKVAdapter:
|
|
|
378
384
|
|
|
379
385
|
has_more = len(results) > request.limit
|
|
380
386
|
if has_more:
|
|
381
|
-
results = results[:request.limit]
|
|
387
|
+
results = results[: request.limit]
|
|
382
388
|
|
|
383
389
|
next_cursor = None
|
|
384
390
|
if has_more:
|
|
385
|
-
next_cursor = self._encode_cursor(
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
391
|
+
next_cursor = self._encode_cursor(
|
|
392
|
+
{
|
|
393
|
+
"offset": offset + request.limit,
|
|
394
|
+
"order_by": request.order_by,
|
|
395
|
+
"desc": request.order_desc,
|
|
396
|
+
}
|
|
397
|
+
)
|
|
390
398
|
|
|
391
399
|
return PageResult(items=results, next_cursor=next_cursor, has_more=has_more)
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
from ..errors import StorageError
|
|
2
|
-
from ..utils import setup_logger
|
|
3
1
|
from abc import ABC, abstractmethod
|
|
4
2
|
from typing import Any, Dict, List, Optional
|
|
5
3
|
|
|
4
|
+
from ..errors import StorageError
|
|
5
|
+
from ..utils import setup_logger
|
|
6
|
+
|
|
6
7
|
|
|
7
8
|
class ObjectStorageAdapter(ABC):
|
|
8
9
|
"""Base class for Object Storage with automatic optimization"""
|
polydb/base/QueueAdapter.py
CHANGED
polydb/base/SecretsAdapter.py
CHANGED