fix(persistence): make resource URL catalog downgrade work standalone

0041_resource_urls could not be rolled back through the supported entry point
(`python -m langbot.pkg.persistence.alembic_runner downgrade`):

- SQLite failed with `no such function: langbot_expand_resource_urls`. The UDF
  is registered by an Engine `connect` hook that only runs when the application
  installs the catalog, so a standalone migration process held a connection
  without it.
- Both backends failed with `AttributeError: 'Engine' object has no attribute
  '_langbot_resource_aliases'`, because `uninstall()` assumed `install()` had
  already run on that engine.

Register the expansion UDF on the migration connection for SQLite, and let
`uninstall()` skip the alias cache when the engine never installed it. The
downgrade still rewrites retained payloads back to literal URLs and drops the
catalog; only the missing hooks were at fault.

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Chin Rock
2026-10-29 12:30:28 +00:00
co-authored by multica-agent
parent c1284be8fc
commit 1f81966606
2 changed files with 21 additions and 1 deletions
@@ -70,12 +70,29 @@ def upgrade():
cursor = rows[-1][key.name]
def _ensure_sqlite_expansion(connection) -> None:
"""Install the expansion UDF on this already-open SQLite connection.
Downgrade is often run by a standalone migration process whose engine was
created outside the application boot path, so the ``connect`` hook never
registered `langbot_expand_resource_urls` for the connection in use.
"""
from langbot.pkg.persistence.resource_urls import register_sqlite
dbapi = getattr(connection.connection, 'dbapi_connection', None)
if dbapi is None:
raise RuntimeError('SQLite resource URL downgrade requires a DBAPI connection')
register_sqlite(dbapi, None)
def downgrade():
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.persistence.media import MediaReferenceText, MediaReferenceJSON
from langbot.pkg.persistence.resource_urls import uninstall
connection = op.get_bind()
if connection.dialect.name == 'sqlite':
_ensure_sqlite_expansion(connection)
existing = set(sa.inspect(connection).get_table_names())
for table in sorted(Base.metadata.tables.values(), key=lambda table: table.name):
if table.name not in existing:
+4 -1
View File
@@ -148,7 +148,10 @@ def uninstall(engine):
sa.event.remove(engine, 'before_cursor_execute', normalize_parameters)
engine.dialect._langbot_resource_urls = False
engine.clear_compiled_cache()
engine._langbot_resource_aliases.clear()
# A migration process may uninstall an engine that never ran install().
aliases = getattr(engine, '_langbot_resource_aliases', None)
if aliases is not None:
aliases.clear()
def normalize_parameters(connection, cursor, statement, parameters, context, executemany):