swiftpy-framework 0.1.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 (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,507 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import importlib.util
5
+ import inspect
6
+ import time
7
+ from collections.abc import Awaitable, Callable
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from functools import partial
11
+ from pathlib import Path
12
+ from types import ModuleType
13
+ from typing import Any
14
+
15
+ import asyncpg
16
+
17
+ from swiftpy.core.container import get_container
18
+ from swiftpy.database.identifiers import validate_identifier
19
+ from swiftpy.database.migrations.lock import MigrationLock
20
+ from swiftpy.database.migrations.migration import Migration, MigrationProtocol
21
+ from swiftpy.database.pool import DatabasePool
22
+ from swiftpy.database.transaction import TransactionConnection, _active_connection
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class MigrationFile:
27
+ """Represents a discovered migration file on the filesystem."""
28
+
29
+ name: str
30
+ path: Path
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class MigrationRecord:
35
+ """Represents an applied migration record from the database migrations table."""
36
+
37
+ id: int
38
+ migration: str
39
+ batch: int
40
+ applied_at: datetime
41
+ checksum: str | None = None
42
+
43
+
44
+ class _FunctionMigrationAdapter:
45
+ """Adapts module-level up/down async functions into MigrationProtocol."""
46
+
47
+ def __init__(self, up_fn: Any, down_fn: Any) -> None:
48
+ self._up_fn = up_fn
49
+ self._down_fn = down_fn
50
+
51
+ async def up(self) -> None:
52
+ if callable(self._up_fn):
53
+ res = self._up_fn()
54
+ if inspect.isawaitable(res):
55
+ await res
56
+
57
+ async def down(self) -> None:
58
+ if callable(self._down_fn):
59
+ res = self._down_fn()
60
+ if inspect.isawaitable(res):
61
+ await res
62
+
63
+
64
+ class Migrator:
65
+ """
66
+ Migration Engine for SwiftPY.
67
+
68
+ Discovers timestamped migration files, tracks applied migrations across batches,
69
+ and runs forward and backward migrations within transactional advisory locks.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ pool: DatabasePool | None = None,
75
+ migrations_dir: Path | str | None = None,
76
+ table_name: str = "migrations",
77
+ lock_timeout: float = 300.0,
78
+ ) -> None:
79
+ self.pool: DatabasePool = pool or get_container().resolve(DatabasePool)
80
+ if migrations_dir is None:
81
+ self.migrations_dir: Path = Path.cwd() / "database" / "migrations"
82
+ else:
83
+ self.migrations_dir = Path(migrations_dir)
84
+ self.table_name: str = validate_identifier(table_name)
85
+ self.lock_timeout: float = lock_timeout
86
+
87
+ @staticmethod
88
+ def compute_checksum(file: MigrationFile) -> str:
89
+ """Compute the SHA-256 hex digest of a migration file's contents."""
90
+ return hashlib.sha256(file.path.read_bytes()).hexdigest()
91
+
92
+ def get_migration_files(self) -> list[MigrationFile]:
93
+ """Discover all migration Python files in the migrations directory, sorted by timestamp prefix."""
94
+ if not self.migrations_dir.exists():
95
+ return []
96
+
97
+ files: list[MigrationFile] = []
98
+ for p in self.migrations_dir.glob("*.py"):
99
+ if p.name.startswith(("_", ".")) or p.name == "__init__.py":
100
+ continue
101
+ files.append(MigrationFile(name=p.stem, path=p))
102
+
103
+ # Natural sort ensures strict timestamp-ordered execution (YYYY_MM_DD_HHMMSS_...)
104
+ files.sort(key=lambda f: f.name)
105
+ return files
106
+
107
+ async def ensure_repository(self, conn: asyncpg.Connection) -> None:
108
+ """Create the migrations tracking table if it does not already exist."""
109
+ sql = f"""
110
+ CREATE TABLE IF NOT EXISTS "{self.table_name}" (
111
+ "id" SERIAL PRIMARY KEY,
112
+ "migration" VARCHAR(255) NOT NULL UNIQUE,
113
+ "batch" INTEGER NOT NULL,
114
+ "checksum" VARCHAR(64),
115
+ "applied_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
116
+ );
117
+ """
118
+ await conn.execute(sql)
119
+ await conn.execute(
120
+ f'ALTER TABLE "{self.table_name}" ADD COLUMN IF NOT EXISTS "checksum" VARCHAR(64);'
121
+ )
122
+
123
+ async def get_ran_migrations(self, conn: asyncpg.Connection) -> list[str]:
124
+ """Fetch all migration names that have already been executed."""
125
+ rows = await conn.fetch(
126
+ f'SELECT "migration" FROM "{self.table_name}" ORDER BY "batch" ASC, "id" ASC;'
127
+ )
128
+ return [str(r["migration"]) for r in rows]
129
+
130
+ async def _get_ran_checksums(
131
+ self, conn: asyncpg.Connection
132
+ ) -> dict[str, str | None]:
133
+ """Fetch applied migration names mapped to their recorded SHA-256 checksums."""
134
+ rows = await conn.fetch(
135
+ f'SELECT "migration", "checksum" FROM "{self.table_name}" ORDER BY "batch" ASC, "id" ASC;'
136
+ )
137
+ return {
138
+ str(r["migration"]): (
139
+ str(r["checksum"]) if r["checksum"] is not None else None
140
+ )
141
+ for r in rows
142
+ }
143
+
144
+ async def get_last_batch(self, conn: asyncpg.Connection) -> int:
145
+ """Fetch the highest batch number recorded."""
146
+ val = await conn.fetchval(
147
+ f'SELECT COALESCE(MAX("batch"), 0) FROM "{self.table_name}";'
148
+ )
149
+ return int(val or 0)
150
+
151
+ def resolve_migration_instance(self, file: MigrationFile) -> MigrationProtocol:
152
+ """Dynamically load and instantiate the migration class from a file."""
153
+ module_name = f"swiftpy_migration_{file.name}"
154
+ spec = importlib.util.spec_from_file_location(module_name, file.path)
155
+ if spec is None or spec.loader is None:
156
+ raise ImportError(f"Cannot load migration file: {file.path}")
157
+
158
+ module = importlib.util.module_from_spec(spec)
159
+ spec.loader.exec_module(module)
160
+
161
+ # 1. Look for a class that inherits from Migration or provides up/down
162
+ migration_cls = self._find_migration_class(module)
163
+ if migration_cls is not None:
164
+ inst = migration_cls()
165
+ if isinstance(inst, MigrationProtocol):
166
+ return inst
167
+
168
+ # 2. Look for module-level up/down async functions
169
+ up_fn = getattr(module, "up", None)
170
+ down_fn = getattr(module, "down", None)
171
+ if callable(up_fn) and callable(down_fn):
172
+ return _FunctionMigrationAdapter(up_fn, down_fn)
173
+
174
+ raise RuntimeError(
175
+ f"Migration file {file.path.name} must define a class with up() and down() methods "
176
+ f"or module-level up() and down() functions."
177
+ )
178
+
179
+ @staticmethod
180
+ def _find_migration_class(module: ModuleType) -> type[Any] | None:
181
+ """Find a migration class defined in the loaded module."""
182
+ candidates: list[type[Any]] = []
183
+
184
+ for _, obj in inspect.getmembers(module, inspect.isclass):
185
+ if obj.__module__ != module.__name__:
186
+ continue
187
+
188
+ if issubclass(obj, Migration) and obj is not Migration:
189
+ return obj
190
+
191
+ if hasattr(obj, "up") and hasattr(obj, "down"):
192
+ candidates.append(obj)
193
+
194
+ if candidates:
195
+ return candidates[0]
196
+ return None
197
+
198
+ async def run(
199
+ self,
200
+ step: int | None = None,
201
+ on_applied: Callable[[str, float], None] | None = None,
202
+ ) -> list[str]:
203
+ """
204
+ Run all pending migrations within a transactional advisory lock.
205
+ Returns the list of migration names applied in this batch.
206
+ """
207
+ if step is not None and (
208
+ isinstance(step, bool) or not isinstance(step, int) or step < 1
209
+ ):
210
+ raise ValueError(f"step must be an integer >= 1, got {step!r}")
211
+
212
+ raw_pool = self.pool.get_pool()
213
+
214
+ async with (
215
+ raw_pool.acquire() as conn,
216
+ MigrationLock(conn, timeout=self.lock_timeout),
217
+ ):
218
+ await self.ensure_repository(conn)
219
+
220
+ files = self.get_migration_files()
221
+ ran_checksums = await self._get_ran_checksums(conn)
222
+ ran = list(ran_checksums.keys())
223
+
224
+ # Verify no checksum drift on already-applied migrations
225
+ for file in files:
226
+ if file.name in ran_checksums:
227
+ recorded = ran_checksums[file.name]
228
+ current = self.compute_checksum(file)
229
+ if recorded is not None and recorded != current:
230
+ raise RuntimeError(
231
+ f"Migration checksum drift detected for '{file.name}': "
232
+ f"recorded {recorded}, current file {current}"
233
+ )
234
+
235
+ pending = [f for f in files if f.name not in ran_checksums]
236
+ if not pending:
237
+ return []
238
+
239
+ # Detect out-of-order unapplied migrations that sort before an already-applied migration
240
+ if ran:
241
+ max_applied = max(ran)
242
+ for file in pending:
243
+ if file.name < max_applied:
244
+ raise RuntimeError(
245
+ f"Out-of-order unapplied migration '{file.name}' precedes "
246
+ f"already-applied migration '{max_applied}'."
247
+ )
248
+
249
+ if step is not None:
250
+ pending = pending[:step]
251
+
252
+ next_batch = (await self.get_last_batch(conn)) + 1
253
+ applied: list[str] = []
254
+
255
+ for file in pending:
256
+ started = time.perf_counter()
257
+ checksum = self.compute_checksum(file)
258
+ instance = self.resolve_migration_instance(file)
259
+
260
+ await self._execute_in_transaction(
261
+ conn,
262
+ partial(
263
+ self._apply_migration_tx,
264
+ instance=instance,
265
+ migration_name=file.name,
266
+ batch=next_batch,
267
+ checksum=checksum,
268
+ ),
269
+ )
270
+ duration_ms = (time.perf_counter() - started) * 1000
271
+ applied.append(file.name)
272
+ if on_applied is not None:
273
+ on_applied(file.name, duration_ms)
274
+
275
+ return applied
276
+
277
+ async def rollback(
278
+ self,
279
+ steps: int = 1,
280
+ on_rolled_back: Callable[[str, float], None] | None = None,
281
+ ) -> list[str]:
282
+ """
283
+ Roll back the last batch of migrations (or the last N batches if steps > 1).
284
+ Returns the list of migration names rolled back.
285
+ """
286
+ if isinstance(steps, bool) or not isinstance(steps, int) or steps < 1:
287
+ raise ValueError(f"steps must be an integer >= 1, got {steps!r}")
288
+
289
+ raw_pool = self.pool.get_pool()
290
+
291
+ async with (
292
+ raw_pool.acquire() as conn,
293
+ MigrationLock(conn, timeout=self.lock_timeout),
294
+ ):
295
+ await self.ensure_repository(conn)
296
+
297
+ last_batch = await self.get_last_batch(conn)
298
+ if last_batch <= 0:
299
+ return []
300
+
301
+ min_batch = max(1, last_batch - steps + 1)
302
+ rows = await conn.fetch(
303
+ f'SELECT "migration" FROM "{self.table_name}" '
304
+ f'WHERE "batch" >= $1 ORDER BY "id" DESC;',
305
+ min_batch,
306
+ )
307
+
308
+ if not rows:
309
+ return []
310
+
311
+ files_by_name = {f.name: f for f in self.get_migration_files()}
312
+
313
+ # Up-front validation: verify all migration files exist before executing rollbacks
314
+ missing = [
315
+ str(r["migration"])
316
+ for r in rows
317
+ if str(r["migration"]) not in files_by_name
318
+ ]
319
+ if missing:
320
+ raise FileNotFoundError(
321
+ f"Cannot roll back, migration files missing on disk: {', '.join(missing)}"
322
+ )
323
+
324
+ rolled_back: list[str] = []
325
+
326
+ for row in rows:
327
+ name = str(row["migration"])
328
+ file = files_by_name[name]
329
+ started = time.perf_counter()
330
+ instance = self.resolve_migration_instance(file)
331
+
332
+ await self._execute_in_transaction(
333
+ conn,
334
+ partial(
335
+ self._rollback_migration_tx,
336
+ instance=instance,
337
+ migration_name=name,
338
+ ),
339
+ )
340
+ duration_ms = (time.perf_counter() - started) * 1000
341
+ rolled_back.append(name)
342
+ if on_rolled_back is not None:
343
+ on_rolled_back(name, duration_ms)
344
+
345
+ return rolled_back
346
+
347
+ async def _apply_migration_tx(
348
+ self,
349
+ tx_handle: TransactionConnection,
350
+ *,
351
+ instance: MigrationProtocol,
352
+ migration_name: str,
353
+ batch: int,
354
+ checksum: str,
355
+ ) -> None:
356
+ await instance.up()
357
+ await tx_handle.execute(
358
+ f'INSERT INTO "{self.table_name}" ("migration", "batch", "checksum") '
359
+ f"VALUES ($1, $2, $3);",
360
+ migration_name,
361
+ batch,
362
+ checksum,
363
+ )
364
+
365
+ async def _rollback_migration_tx(
366
+ self,
367
+ tx_handle: TransactionConnection,
368
+ *,
369
+ instance: MigrationProtocol,
370
+ migration_name: str,
371
+ ) -> None:
372
+ await instance.down()
373
+ await tx_handle.execute(
374
+ f'DELETE FROM "{self.table_name}" WHERE "migration" = $1;',
375
+ migration_name,
376
+ )
377
+
378
+ @staticmethod
379
+ async def _execute_in_transaction(
380
+ conn: asyncpg.Connection,
381
+ operation: Callable[[TransactionConnection], Awaitable[None]],
382
+ ) -> None:
383
+ """Bind a TransactionConnection handle per migration step and flush on_commit after closing."""
384
+ tx_handle = TransactionConnection(conn)
385
+ token = _active_connection.set(tx_handle)
386
+ committed_callbacks: list[Callable[[], Any]] = []
387
+ try:
388
+ async with tx_handle.transaction(defer_callbacks=True):
389
+ await operation(tx_handle)
390
+ committed_callbacks = tx_handle.drain_commit_callbacks()
391
+ finally:
392
+ tx_handle._close()
393
+ _active_connection.reset(token)
394
+
395
+ if committed_callbacks:
396
+ await TransactionConnection.run_commit_callbacks(committed_callbacks)
397
+
398
+ async def reset(self) -> list[str]:
399
+ """Roll back all migrations in reverse chronological order."""
400
+ raw_pool = self.pool.get_pool()
401
+
402
+ async with (
403
+ raw_pool.acquire() as conn,
404
+ MigrationLock(conn, timeout=self.lock_timeout),
405
+ ):
406
+ await self.ensure_repository(conn)
407
+
408
+ rows = await conn.fetch(
409
+ f'SELECT "migration" FROM "{self.table_name}" ORDER BY "id" DESC;'
410
+ )
411
+ if not rows:
412
+ return []
413
+
414
+ files_by_name = {f.name: f for f in self.get_migration_files()}
415
+
416
+ # Up-front validation
417
+ missing = [
418
+ str(r["migration"])
419
+ for r in rows
420
+ if str(r["migration"]) not in files_by_name
421
+ ]
422
+ if missing:
423
+ raise FileNotFoundError(
424
+ f"Cannot roll back, migration files missing on disk: {', '.join(missing)}"
425
+ )
426
+
427
+ rolled_back: list[str] = []
428
+
429
+ for row in rows:
430
+ name = str(row["migration"])
431
+ file = files_by_name[name]
432
+ instance = self.resolve_migration_instance(file)
433
+
434
+ await self._execute_in_transaction(
435
+ conn,
436
+ partial(
437
+ self._rollback_migration_tx,
438
+ instance=instance,
439
+ migration_name=name,
440
+ ),
441
+ )
442
+ rolled_back.append(name)
443
+
444
+ return rolled_back
445
+
446
+ async def status(self) -> list[dict[str, Any]]:
447
+ """Return the execution status of all discovered migration files and applied records."""
448
+ raw_pool = self.pool.get_pool()
449
+
450
+ async with raw_pool.acquire() as conn:
451
+ await self.ensure_repository(conn)
452
+
453
+ rows = await conn.fetch(
454
+ f'SELECT "migration", "batch", "checksum", "applied_at" '
455
+ f'FROM "{self.table_name}" ORDER BY "id" ASC;'
456
+ )
457
+ records = {str(r["migration"]): r for r in rows}
458
+
459
+ files = self.get_migration_files()
460
+ seen_files: set[str] = set()
461
+ statuses: list[dict[str, Any]] = []
462
+
463
+ for f in files:
464
+ seen_files.add(f.name)
465
+ rec = records.get(f.name)
466
+ current_checksum = self.compute_checksum(f)
467
+ recorded_checksum = (
468
+ str(rec["checksum"])
469
+ if rec is not None and rec["checksum"] is not None
470
+ else None
471
+ )
472
+ statuses.append(
473
+ {
474
+ "migration": f.name,
475
+ "ran": rec is not None,
476
+ "batch": rec["batch"] if rec else None,
477
+ "applied_at": rec["applied_at"] if rec else None,
478
+ "checksum": recorded_checksum or current_checksum,
479
+ "checksum_valid": (
480
+ True
481
+ if recorded_checksum is None
482
+ else recorded_checksum == current_checksum
483
+ ),
484
+ "missing_file": False,
485
+ }
486
+ )
487
+
488
+ for name, rec in records.items():
489
+ if name in seen_files:
490
+ continue
491
+ statuses.append(
492
+ {
493
+ "migration": name,
494
+ "ran": True,
495
+ "batch": rec["batch"],
496
+ "applied_at": rec["applied_at"],
497
+ "checksum": (
498
+ str(rec["checksum"])
499
+ if rec["checksum"] is not None
500
+ else None
501
+ ),
502
+ "checksum_valid": False,
503
+ "missing_file": True,
504
+ }
505
+ )
506
+
507
+ return statuses