altcodepro-polydb-python 2.5.19__py3-none-any.whl → 2.5.21__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: altcodepro-polydb-python
3
- Version: 2.5.19
3
+ Version: 2.5.21
4
4
  Summary: Production-ready multi-cloud database abstraction layer with connection pooling, retry logic, and thread safety
5
5
  Author: AltCodePro
6
6
  Project-URL: Homepage, https://github.com/altcodepro/polydb-python
@@ -1,12 +1,12 @@
1
- altcodepro_polydb_python-2.5.19.dist-info/licenses/LICENSE,sha256=9X8GLocsBwy-5aR5JGOt2SAMDDPs9Qv-YnqmHBHOXrw,1067
1
+ altcodepro_polydb_python-2.5.21.dist-info/licenses/LICENSE,sha256=9X8GLocsBwy-5aR5JGOt2SAMDDPs9Qv-YnqmHBHOXrw,1067
2
2
  polydb/PolyDB.py,sha256=Lp0QtG7D89Z6P6nt9fVoT8rVHAy8xlE2kFRSS70r-mk,23672
3
- polydb/__init__.py,sha256=p9dAWJ3-JNCeHgb1WzkTMkuURFQy-RUNI4QeD9zBVf0,1876
3
+ polydb/__init__.py,sha256=fNrKwNBgwMJKrBhs_eaRu4XpQ3olREipIOiwBmv38b8,1876
4
4
  polydb/advanced_query.py,sha256=M9sKmdF2PsLZWSc-zKpXRcwaDumbYYTsibmL_IAjKP4,6931
5
5
  polydb/aio.py,sha256=0EQ6hXrf6N4If6pcht6L9B7qz0PU2yGahyGzEss2eQg,8340
6
6
  polydb/batch.py,sha256=ulvSdtVeofsOiVNVAQwrE9tXIIaP8fjiW1Sbpz3dcv0,4093
7
7
  polydb/cache.py,sha256=ArI0zRbuhAYd6kn4TPVMBHBptdRmuZyy05_hwIATUsQ,11784
8
8
  polydb/cloudDatabaseFactory.py,sha256=eJiNKycdaC9nO2BKWPjkig3GaNGkNjoJci6yVJC7A_c,25301
9
- polydb/databaseFactory.py,sha256=8A6Nmx0TTBxyVxLBMTBPp-sxuO7SeUWF5eVlG0I8WCE,54546
9
+ polydb/databaseFactory.py,sha256=0gjc5wk0zkOsCqOY7ET8klaNG8wZMPJhKOvzmdYTwr0,59610
10
10
  polydb/errors.py,sha256=AtXCLdzkHE2ncUaE1P0wH5HmIyW3NXx9KHL-fCejjA4,1832
11
11
  polydb/json_safe.py,sha256=jj3ZA6gImGwgTO0dCZVcckjeUHtewa2HP6OA1-u6VZ0,137
12
12
  polydb/models.py,sha256=Pq_63FXu1IiTxl-RshZ2vmoe-5m5k6qft-tXpHLn-70,12600
@@ -17,22 +17,22 @@ polydb/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
17
  polydb/query.py,sha256=17V-e70df9NaiaUYPN5_MQz7O0vRdBaCR4xlZU3F2YE,8511
18
18
  polydb/registry.py,sha256=RD_elvFXcmhTdCyZDm2f3ej0elxqhArnSJ2aO9k5VCU,2352
19
19
  polydb/retry.py,sha256=0rILN3-auuzomOXPveIAvgnhBbtXXGNKvxg6qcRgN4g,4926
20
- polydb/schema.py,sha256=jDjUuOaz2DANddhMTSxMwZ1MB-EOVDgDrCHbxkp-iM4,7712
20
+ polydb/schema.py,sha256=v3lnD9jUoBippMj2eLNwvmykPygO0nFx806X7cmeb8Q,9840
21
21
  polydb/security.py,sha256=IF8Oh6Y6YU9xSjz_pi6lotfJuhIUJgP7cCmpGc8bz6w,20158
22
- polydb/types.py,sha256=Z1R8z06-WYoh7cnn0oE_2N10v6hdx7VJD7TNL5ynWhA,2667
22
+ polydb/types.py,sha256=Kx_d9_XJP9UnIbABaVCOcWiBEem_cfUrDwiPc3uaxCY,2825
23
23
  polydb/utils.py,sha256=YS-BhBpQfQsOf-z_i1zw_kvMYwJ05Zn1S2Xt6K4_2ak,3171
24
24
  polydb/validation.py,sha256=DK9-AFndTr0cLodpc3KX0wnABDpw2vPB4H6hp67BJKE,4570
25
25
  polydb/adapters/AWSSecretsManagerAdapter.py,sha256=W4B3xhQFcBAlNlwQ4uTgcDmRrOmZr6v-suC4qihTpJw,2747
26
26
  polydb/adapters/AzureBlobStorageAdapter.py,sha256=ZTovSC_CVduGnIoIQUUeNxDQlf-IbFDBRzjGDk8U9Io,7084
27
27
  polydb/adapters/AzureFileStorageAdapter.py,sha256=VZNprqlBXCuWUgtqClNT-NrQmRf-XFYEiRA2BLbf-Sc,7046
28
- polydb/adapters/AzureKeyVaultAdapter.py,sha256=7Nizs9qGh6cytbZNgV78f-odOO4Z6sUlwm_HQYqt-pc,2339
28
+ polydb/adapters/AzureKeyVaultAdapter.py,sha256=EtsrxI3X75nORnO0KbvKTVXJfliHd-gt3y9Mo8Npugs,3365
29
29
  polydb/adapters/AzureQueueAdapter.py,sha256=1e2HiFvkH1BI_JKS9fgays2J41kOfH6pUPbZDe1wmWA,13658
30
- polydb/adapters/AzureTableStorageAdapter.py,sha256=u2DsUIMOjYnBX_2G4abaLCjRaVOCORQQnwC4e0XrJ0Q,35567
30
+ polydb/adapters/AzureTableStorageAdapter.py,sha256=oW80ZeCsj8hkIlmlrv404rsB8m5TA9_nHL5OM57WQSg,38068
31
31
  polydb/adapters/BlockchainBlobAdapter.py,sha256=85H3PxiL9rGDtK-sTsW26jOuUQ_SGipLSPu3s-juZRA,3298
32
32
  polydb/adapters/BlockchainFileAdapter.py,sha256=lZbRyC18LghZcVh5QyIRP7tCftNF_Ep1FKG0KRkvMmk,8335
33
33
  polydb/adapters/BlockchainKVAdapter.py,sha256=pvpiPshDE5Z_IRcSBvBwgAXT--86lgEyIsEUeqcmoE8,5422
34
34
  polydb/adapters/BlockchainQueueAdapter.py,sha256=n1MD1JJmHslwC_RrZrhlAVgXBZ4fXlV67eokxrhLelU,4195
35
- polydb/adapters/DynamoDBAdapter.py,sha256=XlNhS6lLeeET-1uZlz1m3nE3lS_DYGXaT6LB1reiLZs,19399
35
+ polydb/adapters/DynamoDBAdapter.py,sha256=vVQig4hhbgnrI2gcCIqj8gGzycjt2d51HYWIH30kenU,19943
36
36
  polydb/adapters/EFSAdapter.py,sha256=yB243Mf7pCb60233CQag6z5GbOSU_hirzYZL26w5_88,2937
37
37
  polydb/adapters/FirestoreAdapter.py,sha256=WCYZ6EThZgDGsdJcKXBFXHckdLnIY9jvtVYHSb3z7ao,15260
38
38
  polydb/adapters/GCPFilestoreAdapter.py,sha256=LqYqGwFlbNxkYhx0uQ14sTeWPlyvWK6lKIpAxDEIC4A,3006
@@ -57,7 +57,7 @@ polydb/audit/__init__.py,sha256=VANh47kW5V9gi7anUCZ7LklQ6ofaFRmBKrPVitRxdw0,245
57
57
  polydb/audit/context.py,sha256=jbw58vDoslY2mEcS0qUBlEhYg54EoMk4Xcfuq1L3FuY,1945
58
58
  polydb/audit/manager.py,sha256=DgBkDcZlf_EotUm-RlKA1CWAoVi3DIUlRCsN4wzsfgc,1237
59
59
  polydb/audit/models.py,sha256=XHT-te2XpmdywfakKm1cVH09yi3C9Fa4FaBTZaJ5vqY,7044
60
- polydb/base/NoSQLKVAdapter.py,sha256=TJ9jyYiLA26WmxJjGuL5DbQ00ZN-RYTV-IznN7RwMw4,16927
60
+ polydb/base/NoSQLKVAdapter.py,sha256=9nG--KZ1Q0-KTc6n_i1nBIGDlSgOVEei0-QhBmqC_tI,17175
61
61
  polydb/base/ObjectStorageAdapter.py,sha256=mr6UMGmAFOBKlJeoyubaUJ7x7BDZMlylqnuggIatEv0,2289
62
62
  polydb/base/QueueAdapter.py,sha256=PzTlEuc37TXEnYNdsrrmxzFworbieWBxljDXJ-qq4XQ,8206
63
63
  polydb/base/SecretsAdapter.py,sha256=RaWejh21oQji0TdEBa7P5JgD9y6N06M1aCBbLvzTQU0,990
@@ -68,7 +68,7 @@ polydb/observability/logging.py,sha256=FWYky-Mg1IhLpwuaKuLIsTROBZc9bI_-AO-y53nvE
68
68
  polydb/services/__init__.py,sha256=Zs3PZbHutgPdPjJatygvtYvKPZMBt1qKSqESbFRPHJs,241
69
69
  polydb/services/compliance_service.py,sha256=UMjCbQ4z13-w6pT4RLQpMi_u1zbShEpiz8kawA2WsSU,5075
70
70
  polydb/services/security_service.py,sha256=gGo_j9Ca_rgvxS7s6C5JUktugQBj1PV1DXpMnOYAGbI,4992
71
- altcodepro_polydb_python-2.5.19.dist-info/METADATA,sha256=jxSgbkqTALqYYLf5XwA6qNZPY--DLXDSdsxCwbItZR8,15599
72
- altcodepro_polydb_python-2.5.19.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
73
- altcodepro_polydb_python-2.5.19.dist-info/top_level.txt,sha256=WgLFWJoYjUhwvyPxJFl6jYLrVFuBJDX3OABf4ocwk_E,7
74
- altcodepro_polydb_python-2.5.19.dist-info/RECORD,,
71
+ altcodepro_polydb_python-2.5.21.dist-info/METADATA,sha256=SbnlRNiCE6ZiK4aVCr5-Znz4R4xUJpDQyh10PQYpO34,15599
72
+ altcodepro_polydb_python-2.5.21.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
73
+ altcodepro_polydb_python-2.5.21.dist-info/top_level.txt,sha256=WgLFWJoYjUhwvyPxJFl6jYLrVFuBJDX3OABf4ocwk_E,7
74
+ altcodepro_polydb_python-2.5.21.dist-info/RECORD,,
polydb/__init__.py CHANGED
@@ -4,7 +4,7 @@ PolyDB - Enterprise Cloud-Independent Database Abstraction
4
4
  Full LINQ support, field-level audit, cache, soft delete, overflow storage
5
5
  """
6
6
 
7
- __version__ = "2.5.18"
7
+ __version__ = "2.5.20"
8
8
 
9
9
  from .aio import AsyncDatabaseFactory, AsyncPolyDB
10
10
  from .audit.context import AuditContext
@@ -19,10 +19,24 @@ class AzureKeyVaultAdapter(SecretsAdapter):
19
19
  self._initialize_client()
20
20
 
21
21
  def _initialize_client(self) -> None:
22
- from azure.identity import DefaultAzureCredential
23
22
  from azure.keyvault.secrets import SecretClient
24
23
 
25
- self._client = SecretClient(vault_url=self.vault_url, credential=DefaultAzureCredential())
24
+ self._client = SecretClient(vault_url=self.vault_url, credential=self._build_credential())
25
+
26
+ @staticmethod
27
+ def _build_credential():
28
+ """Inside Azure Container Apps / App Service / Functions a managed identity is available
29
+ (IDENTITY_ENDPOINT / MSI_ENDPOINT). DefaultAzureCredential tries EnvironmentCredential FIRST, and a deployment that
30
+ also exports AZURE_CLIENT_ID + AZURE_CLIENT_SECRET (for some other purpose) then fails with AADSTS7000232
31
+ "MSI identity should not use ClientSecretCredential". So when a managed identity exists it goes first
32
+ (AZURE_CLIENT_ID, if set, names a user-assigned identity); the normal chain stays as the fallback, so local
33
+ development (az login, environment variables) behaves as before."""
34
+ from azure.identity import ChainedTokenCredential, DefaultAzureCredential, ManagedIdentityCredential
35
+
36
+ if os.getenv("IDENTITY_ENDPOINT") or os.getenv("MSI_ENDPOINT"):
37
+ client_id = os.getenv("AZURE_CLIENT_ID") or None
38
+ return ChainedTokenCredential(ManagedIdentityCredential(client_id=client_id), DefaultAzureCredential())
39
+ return DefaultAzureCredential()
26
40
 
27
41
  # Key Vault secret names may only contain alphanumerics and dashes --
28
42
  # our secret keys (e.g. "tenant-42/stripe/api_key") use slashes, so
@@ -47,6 +47,59 @@ _RETRY_TOTAL = int(os.getenv("AZURE_TABLE_RETRY_TOTAL", "3"))
47
47
  logging.getLogger("azure").setLevel(logging.ERROR)
48
48
 
49
49
 
50
+
51
+ def _is_table_missing(exc: BaseException) -> bool:
52
+ text = f"{type(exc).__name__} {exc}"
53
+ return "TableNotFound" in text or "The table specified does not exist" in text
54
+
55
+
56
+ class _SelfHealingTableClient:
57
+ """Wraps an Azure TableClient so a table that disappears while the process is running (deleted by hand, a data
58
+ reset, a restore) is created again and the operation retried once, instead of failing with TableNotFound until
59
+ the process restarts. Lazy iterators (query_entities / list_entities) are healed only if the failure happens
60
+ before the first row was produced."""
61
+
62
+ _LAZY = ("query_entities", "list_entities")
63
+
64
+ def __init__(self, adapter: "AzureTableStorageAdapter", table_name: str, client: Any):
65
+ self._adapter = adapter
66
+ self._table_name = table_name
67
+ self._client = client
68
+
69
+ def _heal(self) -> None:
70
+ self._adapter._ensured_tables.discard(self._table_name)
71
+ self._adapter._client.create_table_if_not_exists(self._table_name)
72
+ self._adapter._ensured_tables.add(self._table_name)
73
+ logger.warning(f"Azure Table {self._table_name} was missing; created it again")
74
+
75
+ def __getattr__(self, name: str):
76
+ target = getattr(self._client, name)
77
+ if not callable(target):
78
+ return target
79
+ if name in self._LAZY:
80
+ def lazy(*args, **kwargs):
81
+ produced = False
82
+ try:
83
+ for item in target(*args, **kwargs):
84
+ produced = True
85
+ yield item
86
+ except Exception as exc: # noqa: BLE001
87
+ if produced or not _is_table_missing(exc):
88
+ raise
89
+ self._heal()
90
+ yield from getattr(self._client, name)(*args, **kwargs)
91
+ return lazy
92
+
93
+ def call(*args, **kwargs):
94
+ try:
95
+ return target(*args, **kwargs)
96
+ except Exception as exc: # noqa: BLE001
97
+ if not _is_table_missing(exc):
98
+ raise
99
+ self._heal()
100
+ return getattr(self._client, name)(*args, **kwargs)
101
+ return call
102
+
50
103
  class AzureTableStorageAdapter(NoSQLKVAdapter):
51
104
  """
52
105
  Azure Table Storage adapter with:
@@ -441,17 +494,21 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
441
494
  try:
442
495
  self._client.create_table_if_not_exists(table_name)
443
496
  logger.info(f"✅ Azure Table ensured/created: {table_name}")
497
+ self._ensured_tables.add(table_name)
444
498
  except Exception as e:
445
499
  msg = str(e)
446
- if "TableAlreadyExists" not in msg and "already exists" not in msg.lower():
447
- logger.warning(f"Could not create table {table_name}: {e}")
448
- self._ensured_tables.add(table_name)
500
+ if "TableAlreadyExists" in msg or "already exists" in msg.lower():
501
+ self._ensured_tables.add(table_name)
502
+ else:
503
+ # Do NOT remember a failed creation as "ensured": the next call tries again instead of
504
+ # failing with TableNotFound for the rest of the process's life.
505
+ logger.error(f"Could not create table {table_name}: {e}")
449
506
 
450
507
  if cached is None:
451
508
  cached = self._client.get_table_client(table_name=table_name)
452
509
  self._table_clients_cache[table_name] = cached
453
510
 
454
- return cached
511
+ return _SelfHealingTableClient(self, table_name, cached)
455
512
 
456
513
  def _restore_overflow_properties(self, entity_dict: JsonDict) -> JsonDict:
457
514
  restored = {}
@@ -196,6 +196,13 @@ class DynamoDBAdapter(NoSQLKVAdapter):
196
196
  def _blob_key(self, model: type, pk: str, rk: str, checksum: str) -> str:
197
197
  return f"overflow/{self._table_name(model)}/{pk}/{rk}/{checksum}.json"
198
198
 
199
+ def _check_overflow(self, data: JsonDict): # type: ignore[override]
200
+ """DynamoDB overflows inside `_put_raw` (`_maybe_overflow_to_s3`, into this adapter's own bucket), never in the
201
+ base class. The base whole-record overflow replaced the record with a stub pointing at a key in the separate
202
+ object store, which `_resolve_overflow` (it reads this adapter's bucket) can never find -- the record was lost
203
+ on read. Returning the data untouched leaves the one working path in charge."""
204
+ return data, None
205
+
199
206
  def _maybe_overflow_to_s3(
200
207
  self, model: type, pk: str, rk: str, payload: JsonDict
201
208
  ) -> Optional[JsonDict]:
@@ -291,7 +291,9 @@ class NoSQLKVAdapter:
291
291
  ) -> Tuple[List[JsonDict], Optional[str]]:
292
292
  # Basic implementation - override per provider
293
293
  offset = int(continuation_token) if continuation_token else 0
294
- results = self.query(model, query, limit=page_size + 1)
294
+ # Fetch through the end of THIS page (+1 to learn whether another follows) and drop what earlier pages already
295
+ # returned -- reading from the start every time made every page after the first a repeat of the first.
296
+ results = self.query(model, query, limit=offset + page_size + 1)[offset:]
295
297
 
296
298
  has_more = len(results) > page_size
297
299
  if has_more:
polydb/databaseFactory.py CHANGED
@@ -19,7 +19,7 @@ from dataclasses import dataclass, field
19
19
  from datetime import datetime
20
20
  from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
21
21
 
22
- from tenacity import retry, stop_after_attempt, wait_exponential
22
+ from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
23
23
 
24
24
  from .adapters.PostgreSQLAdapter import PostgreSQLAdapter
25
25
  from .audit.context import AuditContext
@@ -32,6 +32,7 @@ from .errors import AdapterConfigurationError
32
32
  from .models import PageRequest, PageResult
33
33
  from .monitoring import HealthCheck, MetricsCollector, PerformanceMonitor
34
34
  from .query import QueryBuilder
35
+ from .retry import _is_non_retryable
35
36
  from .security import DataMasking, FieldEncryption
36
37
  from .types import JsonDict, Lookup, ModelMeta
37
38
 
@@ -53,6 +54,31 @@ _DEFAULT_RETRY = retry(
53
54
  reraise=True,
54
55
  )
55
56
 
57
+ _TRANSIENT_MARKERS = (
58
+ "timeout", "timed out", "connection reset", "connection refused", "connection closed", "connection aborted",
59
+ "server closed the connection", "temporarily unavailable", "too many requests", "throttl", "service unavailable",
60
+ "serverbusy", "503", "could not connect",
61
+ )
62
+
63
+
64
+ def _is_transient(exc: BaseException) -> bool:
65
+ if _is_non_retryable(exc):
66
+ return False
67
+ if isinstance(exc, (ConnectionError, TimeoutError)):
68
+ return True
69
+ s = str(exc).lower()
70
+ return any(m in s for m in _TRANSIENT_MARKERS)
71
+
72
+
73
+ def _TRANSIENT_RETRY(fn):
74
+ return retry(
75
+ retry=retry_if_exception(_is_transient),
76
+ wait=wait_exponential(multiplier=0.5, min=0.5, max=6),
77
+ stop=stop_after_attempt(3),
78
+ reraise=True,
79
+ )(fn)
80
+
81
+
56
82
  _UNIQUE_VIOLATION_MARKERS = (
57
83
  "23505", # Postgres SQLSTATE
58
84
  "duplicate key value violates", # Postgres message
@@ -140,6 +166,7 @@ def _extract_meta(model: Union[type, str]) -> ModelMeta:
140
166
  provider=raw.get("provider"),
141
167
  cache=raw.get("cache", False),
142
168
  cache_ttl=raw.get("cache_ttl"),
169
+ encrypted_fields=tuple(raw.get("encrypted_fields") or ()),
143
170
  )
144
171
  return ModelMeta(storage="nosql", table=None, collection=None)
145
172
 
@@ -348,7 +375,42 @@ class DatabaseFactory:
348
375
  return result
349
376
 
350
377
  def _run(self, fn: Callable[[], Any]) -> Any:
351
- return fn()
378
+ """Run one storage operation, retrying only TRANSIENT failures (connection loss, timeouts, throttling) with
379
+ exponential backoff when retries are enabled. Constraint violations, bad arguments and anything the retry
380
+ classifier marks non-retryable are raised at once -- a retry could not change their outcome."""
381
+ if not self._enable_retries:
382
+ return fn()
383
+ return _TRANSIENT_RETRY(fn)()
384
+
385
+ def _audit_write(
386
+ self, action: str, name: str, meta: ModelMeta, entity_id: Any, success: bool,
387
+ before: Optional[JsonDict], after: Optional[JsonDict], error: Optional[str],
388
+ ) -> None:
389
+ """Record a write in the audit log. Never raises: an audit failure must not turn a completed write into an
390
+ error. Encrypted fields are masked so the log never holds their plaintext."""
391
+ if not (self._enable_audit and self._audit):
392
+ return
393
+
394
+ def _mask(row: Optional[JsonDict]) -> Optional[JsonDict]:
395
+ if not row or not meta.encrypted_fields:
396
+ return row
397
+ return {k: ("[encrypted]" if k in meta.encrypted_fields else v) for k, v in row.items()}
398
+
399
+ try:
400
+ before_m, after_m = _mask(before), _mask(after)
401
+ changed = (
402
+ sorted(k for k in set(after_m or {}) | set(before_m or {}) if (before_m or {}).get(k) != (after_m or {}).get(k))
403
+ if before_m is not None and after_m is not None else None
404
+ )
405
+ if isinstance(entity_id, dict):
406
+ entity_id = entity_id.get("id")
407
+ self._audit.record(
408
+ action=action, model=name, entity_id=None if entity_id is None else str(entity_id),
409
+ storage_type=meta.storage, provider=meta.provider or "default", success=success,
410
+ before=before_m, after=after_m, error=error, changed_fields=changed,
411
+ )
412
+ except Exception as exc: # noqa: BLE001
413
+ logger.warning("Audit write failed (non-fatal): %s", exc)
352
414
 
353
415
  def _is_sql(self, meta: ModelMeta, override: Optional[EngineOverride] = None) -> bool:
354
416
  if override and override.force_sql:
@@ -432,9 +494,12 @@ class DatabaseFactory:
432
494
  with monitor as m:
433
495
  result = self._run(_op)
434
496
  m.rows_affected = 1
435
- return result
436
- return self._run(_op)
437
- except Exception:
497
+ else:
498
+ result = self._run(_op)
499
+ self._audit_write("create", name, meta, entity_id, True, None, after_plain, None)
500
+ return result
501
+ except Exception as exc:
502
+ self._audit_write("create", name, meta, entity_id, False, None, None, str(exc))
438
503
  raise
439
504
 
440
505
  # ──────────────────────────────────────────────────────────
@@ -467,14 +532,26 @@ class DatabaseFactory:
467
532
  projected = bool(fields or omit)
468
533
  sql_columns = select_columns(fields, omit, columns)
469
534
 
535
+ drop_deleted = False
470
536
  if self._soft_delete and not include_deleted:
471
- query = self._apply_soft_delete_filter(query)
537
+ if self._is_sql(meta, engine_override):
538
+ query = self._apply_soft_delete_filter(query)
539
+ else:
540
+ # A NoSQL equality filter on `deleted_at = None` matches nothing when the attribute was never written
541
+ # (Azure Table, DynamoDB), so filter the returned rows instead.
542
+ drop_deleted = True
472
543
 
473
544
  adapters = self._adapters_for(model, meta, engine_override)
474
545
  use_external_cache = (
475
546
  self._enable_cache and self._cache and getattr(meta, "cache", False) and not projected
476
547
  )
477
548
  encrypted_fields = getattr(meta, "encrypted_fields", [])
549
+ # The cache key must cover everything that changes the result: the query, the window, and the session variables
550
+ # (e.g. the tenant a row-level-security policy filters by) -- otherwise two callers share an entry.
551
+ cache_key_query = {
552
+ "q": query or {}, "limit": limit, "offset": offset,
553
+ "sv": sorted((session_vars or {}).items()), "e": bool(engine_override),
554
+ }
478
555
 
479
556
  def _op() -> List[JsonDict]:
480
557
  if self._is_sql(meta, engine_override):
@@ -497,22 +574,27 @@ class DatabaseFactory:
497
574
  nosql_kwargs: Dict[str, Any] = {}
498
575
  if projected:
499
576
  nosql_kwargs = {"fields": fields, "omit": omit}
577
+ skip = offset or 0
500
578
  raw = adapters.nosql.query(
501
- cls, query=query, limit=limit, no_cache=no_cache or bool(use_external_cache),
502
- **nosql_kwargs,
579
+ cls, query=query, limit=(limit + skip) if limit else None,
580
+ no_cache=no_cache or bool(use_external_cache), **nosql_kwargs,
503
581
  )
582
+ if skip:
583
+ raw = raw[skip:] # NoSQL adapters take no offset: read through it, then drop the skipped rows
584
+ if drop_deleted:
585
+ raw = [r for r in raw if not r.get("deleted_at")]
504
586
  if self.encryption and encrypted_fields:
505
587
  raw = [self.encryption.decrypt_fields(r, encrypted_fields) for r in raw]
506
588
  if self._cache and use_external_cache and not no_cache:
507
589
  ttl = cache_ttl or getattr(meta, "cache_ttl", 300)
508
590
  try:
509
- self._cache.set(name, query or {}, raw, ttl)
591
+ self._cache.set(name, cache_key_query, raw, ttl)
510
592
  except Exception as _ce:
511
593
  logger.warning("Cache set failed (non-fatal): %s", _ce)
512
594
  return raw
513
595
 
514
596
  if self._cache and use_external_cache and not no_cache:
515
- cached = self._cache.get(name, query or {})
597
+ cached = self._cache.get(name, cache_key_query)
516
598
  if cached is not None:
517
599
  return cached
518
600
 
@@ -677,9 +759,12 @@ class DatabaseFactory:
677
759
  with monitor as m:
678
760
  result = self._run(_op)
679
761
  m.rows_affected = 1
680
- return result
681
- return self._run(_op)
682
- except Exception:
762
+ else:
763
+ result = self._run(_op)
764
+ self._audit_write("update", name, meta, entity_id, True, before, after_plain, None)
765
+ return result
766
+ except Exception as exc:
767
+ self._audit_write("update", name, meta, entity_id, False, before, None, str(exc))
683
768
  raise
684
769
 
685
770
  # ──────────────────────────────────────────────────────────
@@ -737,9 +822,12 @@ class DatabaseFactory:
737
822
  with monitor as m:
738
823
  result = self._run(_op)
739
824
  m.rows_affected = 1
740
- return result
741
- return self._run(_op)
742
- except Exception:
825
+ else:
826
+ result = self._run(_op)
827
+ self._audit_write("upsert", name, meta, (after_plain or {}).get('id'), True, None, after_plain, None)
828
+ return result
829
+ except Exception as exc:
830
+ self._audit_write("upsert", name, meta, None, False, None, None, str(exc))
743
831
  raise
744
832
 
745
833
  # ──────────────────────────────────────────────────────────
@@ -850,9 +938,12 @@ class DatabaseFactory:
850
938
  with monitor as m:
851
939
  result = self._run(_op)
852
940
  m.rows_affected = 1
853
- return result
854
- return self._run(_op)
855
- except Exception:
941
+ else:
942
+ result = self._run(_op)
943
+ self._audit_write("delete", name, meta, entity_id, True, before, None, None)
944
+ return result
945
+ except Exception as exc:
946
+ self._audit_write("delete", name, meta, entity_id, False, before, None, str(exc))
856
947
  raise
857
948
 
858
949
  # ──────────────────────────────────────────────────────────
@@ -906,8 +997,12 @@ class DatabaseFactory:
906
997
  name = _model_name(model)
907
998
  meta = _extract_meta(model)
908
999
 
1000
+ drop_deleted = False
909
1001
  if self._soft_delete and not include_deleted:
910
- query = self._apply_soft_delete_filter(query)
1002
+ if self._is_sql(meta, engine_override):
1003
+ query = self._apply_soft_delete_filter(query)
1004
+ else:
1005
+ drop_deleted = True
911
1006
 
912
1007
  adapters = self._adapters_for(model, meta, engine_override)
913
1008
  encrypted_fields = getattr(meta, "encrypted_fields", [])
@@ -924,6 +1019,8 @@ class DatabaseFactory:
924
1019
  else type(name, (), {"__polydb__": meta.__dict__})
925
1020
  )
926
1021
  raw, token = adapters.nosql.query_page(cls, query, page_size, continuation_token)
1022
+ if drop_deleted:
1023
+ raw = [r for r in raw if not r.get("deleted_at")]
927
1024
  if self.encryption and encrypted_fields:
928
1025
  raw = [self.encryption.decrypt_fields(r, encrypted_fields) for r in raw]
929
1026
  return raw, token
polydb/schema.py CHANGED
@@ -131,6 +131,27 @@ class SchemaBuilder:
131
131
 
132
132
  return sql
133
133
 
134
+ def to_add_missing_columns(self, table_name: str) -> List[str]:
135
+ """`ALTER TABLE ... ADD COLUMN IF NOT EXISTS` for every non-key column. `CREATE TABLE IF NOT EXISTS` never adds a
136
+ column that was declared after the table was first created; appending these to the same migration makes an edited
137
+ column list reach existing tables. Added columns are always nullable (existing rows have no value) and never
138
+ UNIQUE; a declared default is kept."""
139
+ validate_table_name(table_name)
140
+ keys = set(self.primary_keys or [])
141
+ out: List[str] = []
142
+ for col in self.columns:
143
+ if col.name in keys or col.primary_key:
144
+ continue
145
+ parts = [validate_column_name(col.name)]
146
+ if col.type == ColumnType.VARCHAR and col.max_length:
147
+ parts.append(f"VARCHAR({col.max_length})")
148
+ else:
149
+ parts.append(col.type.value)
150
+ if col.default is not None:
151
+ parts.append(_render_default(col.default))
152
+ out.append(f"ALTER TABLE {table_name} ADD COLUMN IF NOT EXISTS {' '.join(parts)};")
153
+ return out
154
+
134
155
  def to_create_indexes(self, table_name: str) -> List[str]:
135
156
  """Generate CREATE INDEX statements"""
136
157
  statements = []
@@ -185,15 +206,31 @@ class MigrationManager:
185
206
 
186
207
  # Check if already applied
187
208
  existing = self.sql.execute(
188
- "SELECT version FROM polydb_migrations WHERE version = %s", [version], fetch_one=True
209
+ "SELECT version, checksum FROM polydb_migrations WHERE version = %s", [version], fetch_one=True
189
210
  )
190
211
 
191
- if existing:
192
- return False
193
-
194
212
  # Calculate checksum
195
213
  checksum = hashlib.sha256(up_sql.encode()).hexdigest()
196
214
 
215
+ if existing:
216
+ stored = existing.get("checksum") if isinstance(existing, dict) else None
217
+ if not stored or stored == checksum:
218
+ return False
219
+ # The DDL under this version was edited (a column added, an index changed). Re-apply it -- the statements
220
+ # this codebase generates are idempotent (IF NOT EXISTS) -- and remember the new checksum. A failure here
221
+ # must not stop a deployment from starting: keep the old checksum so the next start tries again.
222
+ try:
223
+ self.sql.execute(up_sql)
224
+ self.sql.execute(
225
+ "UPDATE polydb_migrations SET checksum = %s WHERE version = %s", [checksum, version]
226
+ )
227
+ return True
228
+ except Exception as exc: # noqa: BLE001
229
+ import logging
230
+
231
+ logging.getLogger(__name__).warning("Re-applying edited migration %s failed (kept old): %s", version, exc)
232
+ return False
233
+
197
234
  try:
198
235
  # Execute migration
199
236
  self.sql.execute(up_sql)
polydb/types.py CHANGED
@@ -24,6 +24,8 @@ class ModelMeta:
24
24
  provider: Optional[str] = None
25
25
  cache: bool = False
26
26
  cache_ttl: Optional[int] = None
27
+ # Fields stored encrypted when the factory has encryption enabled (a tuple: the dataclass is frozen/hashable).
28
+ encrypted_fields: Tuple[str, ...] = ()
27
29
 
28
30
 
29
31
  @runtime_checkable