@@ -244,3 +244,83 @@ def run(self) -> list[PreflightResult]:
244244 )
245245
246246 return errors
247+
248+
249+ @register_check ("models.prunable_migrations" )
250+ class CheckPrunableMigrations (PreflightCheck ):
251+ """Warns about stale migration records in the database."""
252+
253+ def run (self ) -> list [PreflightResult ]:
254+ # Import here to avoid circular import issues
255+ from plain .models .migrations .loader import MigrationLoader
256+ from plain .models .migrations .recorder import MigrationRecorder
257+
258+ errors = []
259+
260+ # Load migrations from disk and database
261+ loader = MigrationLoader (db_connection , ignore_no_migrations = True )
262+ recorder = MigrationRecorder (db_connection )
263+ recorded_migrations = recorder .applied_migrations ()
264+
265+ # disk_migrations should not be None after MigrationLoader initialization,
266+ # but check to satisfy type checker
267+ if loader .disk_migrations is None :
268+ return errors
269+
270+ # Find all prunable migrations (recorded but not on disk)
271+ all_prunable = [
272+ migration
273+ for migration in recorded_migrations
274+ if migration not in loader .disk_migrations
275+ ]
276+
277+ if not all_prunable :
278+ return errors
279+
280+ # Separate into existing packages vs orphaned packages
281+ existing_packages = set (loader .migrated_packages )
282+ prunable_existing : list [tuple [str , str ]] = []
283+ prunable_orphaned : list [tuple [str , str ]] = []
284+
285+ for migration in all_prunable :
286+ package , name = migration
287+ if package in existing_packages :
288+ prunable_existing .append (migration )
289+ else :
290+ prunable_orphaned .append (migration )
291+
292+ # Build the warning message
293+ total_count = len (all_prunable )
294+ message_parts = [
295+ f"Found { total_count } stale migration record{ 's' if total_count != 1 else '' } in the database."
296+ ]
297+
298+ if prunable_existing :
299+ existing_list = ", " .join (
300+ f"{ pkg } .{ name } " for pkg , name in prunable_existing [:3 ]
301+ )
302+ if len (prunable_existing ) > 3 :
303+ existing_list += f" (and { len (prunable_existing ) - 3 } more)"
304+ message_parts .append (f"From existing packages: { existing_list } ." )
305+
306+ if prunable_orphaned :
307+ orphaned_list = ", " .join (
308+ f"{ pkg } .{ name } " for pkg , name in prunable_orphaned [:3 ]
309+ )
310+ if len (prunable_orphaned ) > 3 :
311+ orphaned_list += f" (and { len (prunable_orphaned ) - 3 } more)"
312+ message_parts .append (f"From removed packages: { orphaned_list } ." )
313+
314+ message_parts .append (
315+ "Run 'plain models prune-migrations' to review and remove them."
316+ )
317+
318+ errors .append (
319+ PreflightResult (
320+ fix = " " .join (message_parts ),
321+ id = "models.prunable_migrations" ,
322+ warning = True ,
323+ )
324+ )
325+
326+ return errors
0 commit comments