From 7a914f8c656de2ffa3f662e49bc95b24dd36b854 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 20 May 2026 12:14:50 -0700 Subject: [PATCH 001/126] Clear stale tables/other resources when DB removed, closes #2723 --- datasette/app.py | 23 +++++++++++++++---- docs/changelog.rst | 2 +- tests/test_internal_db.py | 48 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 358081ef..218d40c6 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -618,11 +618,24 @@ class Datasette: stale_databases = set(current_schema_versions.keys()) - set( self.databases.keys() ) - for stale_db_name in stale_databases: - await internal_db.execute_write( - "DELETE FROM catalog_databases WHERE database_name = ?", - [stale_db_name], - ) + if stale_databases: + + def delete_stale_database_catalog(conn): + for stale_db_name in stale_databases: + for table in ( + "catalog_columns", + "catalog_foreign_keys", + "catalog_indexes", + "catalog_views", + "catalog_tables", + "catalog_databases", + ): + conn.execute( + "DELETE FROM {} WHERE database_name = ?".format(table), + [stale_db_name], + ) + + await internal_db.execute_write_fn(delete_stale_database_catalog) for database_name, db in self.databases.items(): schema_version = (await db.execute("PRAGMA schema_version")).first()[0] # Compare schema versions to see if we should skip it diff --git a/docs/changelog.rst b/docs/changelog.rst index 5b637797..eb408287 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,7 +10,7 @@ Unreleased ---------- - Dropped Janus as a dependency, previously used to manage the write queue. This should not have any impact on plugin developers or end-users. (:issue:`1752`) - +- Fixed a bug where stale tables and other related resources were not removed from ``catalog_*`` tables when a database was removed. (:issue:`2723`) .. _v1_0_a29: diff --git a/tests/test_internal_db.py b/tests/test_internal_db.py index 7a0d1630..ec013b43 100644 --- a/tests/test_internal_db.py +++ b/tests/test_internal_db.py @@ -139,3 +139,51 @@ async def test_stale_catalog_entry_database_fix(tmp_path): f"Index page should return 200, not {response.status_code}. " "This fails due to stale catalog entries causing KeyError." ) + + +@pytest.mark.asyncio +async def test_stale_catalog_child_entries_removed_for_missing_database(tmp_path): + from datasette.app import Datasette + + import sqlite3 + + internal_db_path = str(tmp_path / "internal.db") + alpha_db_path = str(tmp_path / "alpha.db") + bravo_db_path = str(tmp_path / "bravo.db") + + for db_path, table_name in ( + (alpha_db_path, "alpha_table"), + (bravo_db_path, "bravo_table"), + (bravo_db_path, "bravo_table_2"), + ): + conn = sqlite3.connect(db_path) + conn.execute(f"CREATE TABLE {table_name} (id INTEGER PRIMARY KEY)") + conn.close() + + ds1 = Datasette(files=[alpha_db_path, bravo_db_path], internal=internal_db_path) + await ds1.invoke_startup() + + catalog_tables = await ds1.get_internal_database().execute(""" + SELECT database_name, table_name + FROM catalog_tables + ORDER BY database_name, table_name + """) + assert [tuple(row) for row in catalog_tables.rows] == [ + ("alpha", "alpha_table"), + ("bravo", "bravo_table"), + ("bravo", "bravo_table_2"), + ] + + ds1.close() + + ds2 = Datasette(files=[alpha_db_path], internal=internal_db_path) + await ds2.invoke_startup() + + catalog_tables = await ds2.get_internal_database().execute(""" + SELECT database_name, table_name + FROM catalog_tables + ORDER BY database_name, table_name + """) + assert [tuple(row) for row in catalog_tables.rows] == [("alpha", "alpha_table")] + + ds2.close() From 5d6de0154d18d0ed07e7ac7cf02cd3c37324ddbc Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 20 May 2026 12:18:01 -0700 Subject: [PATCH 002/126] Bump Black to black==26.3.1 Refs https://github.com/advisories/GHSA-3936-cmfr-pm3m --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c50c720a..38085476 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ dev = [ "pytest-xdist>=2.2.1", "pytest-asyncio>=1.2.0", "beautifulsoup4>=4.8.1", - "black==26.1.0", + "black==26.3.1", "blacken-docs==1.20.0", "pytest-timeout>=1.4.2", "trustme>=0.7", From bbbc1cd59620c7c53a2eff6138ef338c8901ba6e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 20 May 2026 12:33:33 -0700 Subject: [PATCH 003/126] Remove height: 100% to fix Safari bug, closes #2724 --- datasette/static/navigation-search.js | 1 - docs/changelog.rst | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/datasette/static/navigation-search.js b/datasette/static/navigation-search.js index 95e7dfc5..d2c300e2 100644 --- a/datasette/static/navigation-search.js +++ b/datasette/static/navigation-search.js @@ -54,7 +54,6 @@ class NavigationSearch extends HTMLElement { .search-container { display: flex; flex-direction: column; - height: 100%; } .search-input-wrapper { diff --git a/docs/changelog.rst b/docs/changelog.rst index eb408287..56c49ea3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,6 +11,7 @@ Unreleased - Dropped Janus as a dependency, previously used to manage the write queue. This should not have any impact on plugin developers or end-users. (:issue:`1752`) - Fixed a bug where stale tables and other related resources were not removed from ``catalog_*`` tables when a database was removed. (:issue:`2723`) +- Fixed a Safari bug with the table search mechanism triggered by pressing ``/``. (:issue:`2724`) .. _v1_0_a29: From 54b272baf61cb014e6d262fea1b01dc84981c1d0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 20 May 2026 12:39:54 -0700 Subject: [PATCH 004/126] Remove existing stale catalog_ tables, refs #2723 Now if there are any existing stale records in internal.db those will be removed as well. --- datasette/app.py | 30 ++++++++++++++++---------- tests/test_internal_db.py | 45 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 218d40c6..b1f9b2f7 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -614,22 +614,30 @@ class Datasette: "select database_name, schema_version from catalog_databases" ) } - # Delete stale entries for databases that are no longer attached - stale_databases = set(current_schema_versions.keys()) - set( - self.databases.keys() + catalog_table_names = ( + "catalog_columns", + "catalog_foreign_keys", + "catalog_indexes", + "catalog_views", + "catalog_tables", + "catalog_databases", ) + # Delete stale entries for databases that are no longer attached + catalog_database_names = set(current_schema_versions.keys()) + for table in catalog_table_names[:-1]: + catalog_database_names.update( + row["database_name"] + for row in await internal_db.execute( + "select distinct database_name from {}".format(table) + ) + if row["database_name"] is not None + ) + stale_databases = catalog_database_names - set(self.databases.keys()) if stale_databases: def delete_stale_database_catalog(conn): for stale_db_name in stale_databases: - for table in ( - "catalog_columns", - "catalog_foreign_keys", - "catalog_indexes", - "catalog_views", - "catalog_tables", - "catalog_databases", - ): + for table in catalog_table_names: conn.execute( "DELETE FROM {} WHERE database_name = ?".format(table), [stale_db_name], diff --git a/tests/test_internal_db.py b/tests/test_internal_db.py index ec013b43..dcf14126 100644 --- a/tests/test_internal_db.py +++ b/tests/test_internal_db.py @@ -187,3 +187,48 @@ async def test_stale_catalog_child_entries_removed_for_missing_database(tmp_path assert [tuple(row) for row in catalog_tables.rows] == [("alpha", "alpha_table")] ds2.close() + + +@pytest.mark.asyncio +async def test_orphan_stale_catalog_child_entries_removed(tmp_path): + from datasette.app import Datasette + + import sqlite3 + + internal_db_path = str(tmp_path / "internal.db") + alpha_db_path = str(tmp_path / "alpha.db") + + conn = sqlite3.connect(alpha_db_path) + conn.execute("CREATE TABLE alpha_table (id INTEGER PRIMARY KEY)") + conn.close() + + ds1 = Datasette(files=[alpha_db_path], internal=internal_db_path) + await ds1.invoke_startup() + ds1.close() + + # Simulate the state left behind by old cleanup code: the parent database + # row was deleted, but child catalog rows survived because foreign key + # enforcement is not enabled for these internal catalog writes. + conn = sqlite3.connect(internal_db_path) + conn.execute("DELETE FROM catalog_databases WHERE database_name = 'fixtures'") + conn.execute(""" + INSERT INTO catalog_tables (database_name, table_name, rootpage, sql) + VALUES ('fixtures', 'stale_table', 1, 'CREATE TABLE stale_table (id INTEGER)') + """) + conn.commit() + conn.close() + + ds2 = Datasette(files=[alpha_db_path], internal=internal_db_path) + await ds2.invoke_startup() + + catalog_tables = await ds2.get_internal_database().execute(""" + SELECT database_name, table_name + FROM catalog_tables + ORDER BY database_name, table_name + """) + assert [tuple(row) for row in catalog_tables.rows] == [("alpha", "alpha_table")] + + response = await ds2.client.get("/-/tables.json") + assert response.status_code == 200 + + ds2.close() From d3330695fa42ad1cdb2f2b1b80470e95bea8ed12 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 20 May 2026 13:23:05 -0700 Subject: [PATCH 005/126] Always show 'Jump to...' menu item, closes #2725 --- datasette/static/app.css | 26 ++++++++++++++++++++++++++ datasette/static/navigation-search.js | 13 ++++++++++++- datasette/templates/base.html | 7 +++---- docs/changelog.rst | 1 + tests/test_html.py | 18 +++++++++++++++--- 5 files changed, 57 insertions(+), 8 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index 1ce84bc8..c21d0dc4 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -362,6 +362,32 @@ form.nav-menu-logout { .nav-menu-inner a { display: block; } +.nav-menu-inner button.button-as-link { + display: block; + width: 100%; + text-align: left; + font: inherit; +} +.nav-menu-inner .keyboard-shortcut { + float: right; + box-sizing: border-box; + min-width: 1.4em; + margin-left: 0.75rem; + padding: 0 0.35em; + border: 1px solid rgba(255,255,244,0.6); + border-radius: 3px; + background: rgba(255,255,244,0.12); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85em; + line-height: 1.35; + text-align: center; + text-decoration: none; +} +@media (max-width: 640px) { + .nav-menu-inner .keyboard-shortcut { + display: none; + } +} /* Table/database actions menu */ .page-action-menu { diff --git a/datasette/static/navigation-search.js b/datasette/static/navigation-search.js index d2c300e2..09d58898 100644 --- a/datasette/static/navigation-search.js +++ b/datasette/static/navigation-search.js @@ -199,6 +199,15 @@ class NavigationSearch extends HTMLElement { } }); + document.addEventListener("click", (e) => { + const trigger = e.target.closest("[data-navigation-search-open]"); + if (trigger) { + e.preventDefault(); + trigger.closest("details")?.removeAttribute("open"); + this.openMenu(); + } + }); + // Input event input.addEventListener("input", (e) => { this.handleSearch(e.target.value); @@ -390,7 +399,9 @@ class NavigationSearch extends HTMLElement { const dialog = this.shadowRoot.querySelector("dialog"); const input = this.shadowRoot.querySelector(".search-input"); - dialog.showModal(); + if (!dialog.open) { + dialog.showModal(); + } input.value = ""; input.focus(); diff --git a/datasette/templates/base.html b/datasette/templates/base.html index 21f8c693..b4fecf70 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -20,7 +20,7 @@