From ab8a5f48d122a6a85233f1e35562c02300470cfc Mon Sep 17 00:00:00 2001 From: Charlie Tonneslan Date: Mon, 18 May 2026 10:40:23 -0400 Subject: [PATCH 1/2] inspect: skip the table-count cap so on-disk numbers are exact Closes #2712. Database.table_counts has a count_limit=10000 cap so the runtime UI doesn't sit on huge tables. The datasette inspect CLI shared that path, so the JSON it persists capped every row count at 10001. Add an exact=True path that issues a plain SELECT COUNT(*) (the same query inspect_tables in inspect.py uses) and have the CLI call that. The result also doesn't get cached on the Database, since a precomputed count is wrong if the file changes later. Signed-off-by: Charlie Tonneslan --- datasette/cli.py | 2 +- datasette/database.py | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/datasette/cli.py b/datasette/cli.py index 93aa22ef21..a6bd10a0fd 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -154,7 +154,7 @@ async def inspect_(files, sqlite_extensions): app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions) data = {} for name, database in app.databases.items(): - counts = await database.table_counts(limit=3600 * 1000) + counts = await database.table_counts(limit=3600 * 1000, exact=True) data[name] = { "hash": database.hash, "size": database.size, diff --git a/datasette/database.py b/datasette/database.py index 66d50ffa50..6c0175e2e1 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -561,25 +561,26 @@ def size(self): self.cached_size = Path(self.path).stat().st_size return self.cached_size - async def table_counts(self, limit=10): + async def table_counts(self, limit=10, exact=False): if not self.is_mutable and self.cached_table_counts is not None: return self.cached_table_counts # Try to get counts for each table, $limit timeout for each count counts = {} for table in await self.table_names(): try: + if exact: + sql = f"select count(*) from [{table}]" + else: + sql = f"select count(*) from (select * from [{table}] limit {self.count_limit + 1})" table_count = ( - await self.execute( - f"select count(*) from (select * from [{table}] limit {self.count_limit + 1})", - custom_time_limit=limit, - ) + await self.execute(sql, custom_time_limit=limit) ).rows[0][0] counts[table] = table_count # In some cases I saw "SQL Logic Error" here in addition to # QueryInterrupted - so we catch that too: except (QueryInterrupted, sqlite3.OperationalError, sqlite3.DatabaseError): counts[table] = None - if not self.is_mutable: + if not self.is_mutable and not exact: self._cached_table_counts = counts return counts From 6b727a152df78c23f2f31d397e85ea4e4e36c18c Mon Sep 17 00:00:00 2001 From: Charlie Tonneslan Date: Mon, 18 May 2026 10:42:15 -0400 Subject: [PATCH 2/2] actions_sql: always emit anon_rules CTE so empty-anon path doesn't 500 Closes #2644. anon_rules was only added when at least one anonymous permission rule came back from the hooks. With --default-deny and no config file all the hooks return nothing, but the downstream anon_child_lvl / anon_parent_lvl / anon_global_lvl CTEs LEFT JOIN anon_rules unconditionally, so the full query was invalid SQL and the index pages returned 500. Emit an empty CTE with matching columns in the no-rules case. Signed-off-by: Charlie Tonneslan --- datasette/utils/actions_sql.py | 14 ++++++++++++++ tests/test_default_deny.py | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/datasette/utils/actions_sql.py b/datasette/utils/actions_sql.py index e679ae76da..4ce1f9645c 100644 --- a/datasette/utils/actions_sql.py +++ b/datasette/utils/actions_sql.py @@ -241,6 +241,20 @@ async def _build_single_action_sql( "),", ] ) + else: + # The anon_*_lvl CTEs below LEFT JOIN anon_rules unconditionally, + # so when no anonymous rules are produced (e.g. --default-deny + # without a config file) we still need an empty CTE with the + # expected columns. Without it the whole permissions query is + # invalid SQL and the index pages 500. + query_parts.extend( + [ + "anon_rules AS (", + " SELECT NULL AS parent, NULL AS child, NULL AS allow,", + " NULL AS reason, NULL AS source_plugin WHERE 0", + "),", + ] + ) # Continue with the cascading logic query_parts.extend( diff --git a/tests/test_default_deny.py b/tests/test_default_deny.py index 81e95b845b..9f797fe3e2 100644 --- a/tests/test_default_deny.py +++ b/tests/test_default_deny.py @@ -127,3 +127,25 @@ async def test_default_deny_basic_permissions(): # Authenticated user without explicit permission should also be denied assert await ds.allowed(action="view-instance", actor={"id": "user"}) is False + + +@pytest.mark.asyncio +async def test_default_deny_no_config_index_pages_dont_500(): + """Regression for #2644: root visiting the index pages on a --default-deny + instance without any config file used to return 500 because the anon_rules + CTE was conditionally emitted but unconditionally referenced.""" + ds = Datasette(default_deny=True) + ds.root_enabled = True + await ds.invoke_startup() + + db = ds.add_memory_database("test_db_no_config") + await db.execute_write("create table test_table (id integer primary key)") + await ds._refresh_schemas() + + cookies = {"ds_actor": ds.client.actor_cookie({"id": "root"})} + + response = await ds.client.get("/", cookies=cookies) + assert response.status_code == 200 + + response = await ds.client.get("/test_db_no_config", cookies=cookies) + assert response.status_code == 200