Follow-up to #10142. Geometry Viewer's custom tile provider was only
configurable per-user (Preferences), with no way for an administrator
to set an organization-wide default (e.g. an internal tile server)
that applies out of the box for every user.
config.DEFAULT_GEOMETRY_VIEWER_PROVIDER is a plain dict; the five
custom_tile_* preference defaults (url, name, crs, attribution,
max_zoom) are sourced from it instead of hardcoded literals.
Preference.get() only reads a per-user DB row if the user has
explicitly saved one, otherwise it falls back to this default --
override semantics, one active provider, no naming-conflict surface.
config_local.py/config_distro.py/PGADMIN_CONFIG_* replace this config
variable wholesale, not merged key-by-key, so a naive
DEFAULT_GEOMETRY_VIEWER_PROVIDER['name'] lookup would KeyError-crash
preference registration (and app startup) on any partial admin
override, or AttributeError on a wrong-typed value.
resolve_geometry_viewer_provider_defaults() guards against this:
falls back per-field on missing/wrong-type values, validates crs
against the 3 supported choices, validates max_zoom is a non-bool int
in [0, 25], degrading to the original hardcoded defaults instead of
crashing.
No new attacker-reachable surface: DEFAULT_GEOMETRY_VIEWER_PROVIDER is
filesystem/deployment-level admin config, same trust boundary as
DEFAULT_BINARY_PATHS/OAUTH2_CONFIG. Existing DOMPurify sanitization on
name/attribution and the http(s):// + {x}/{y}/{z} URL validation in
GeometryViewerUtils.js (from #10142) apply uniformly to config-sourced
and per-user values alike.
Adds test_geometry_viewer_provider_defaults.py (9 scenarios: fully
valid, partial override, non-dict, invalid CRS, out-of-range/non-int/
bool max_zoom, non-string fields, empty dict).
Geometry Viewer's base-layer choices were hardcoded (Empty, Street,
Topography, Gray Style, Light Color/Dark Matter), with no way to
point at a private/internal tile server or a provider not in that
built-in list.
Add a geometry_viewer preferences category (custom_tile_url, _name,
_crs, _attribution, _max_zoom) under sqleditor. When a valid custom
tile provider URL is configured (must be http(s) and contain {x},
{y}, {z}), it's used as the default base layer; built-in tiled
layers are hidden unless the custom provider's CRS is also
EPSG:3857 (Web Mercator), since they can't be reprojected together.
Non-mercator custom providers (e.g. EPSG:4326) only apply to SRID
4326 geometry data; other SRIDs still render on the existing blank
Cartesian plane.
Custom name/attribution are DOMPurify-sanitized before being passed
to Leaflet (both are innerHTML sinks). URL is not sanitized but is
gated by the http(s):// prefix check, so javascript:/data: URLs are
rejected; it's consumed as a tile-image src template, not an HTML
sink.
Adds GeometryViewerUtils.spec.js covering URL validation, defaults,
sanitization, CRS resolution/fallback, and base-layer composition.
OAuth2 provider settings are only read from the OAUTH2_CONFIG list;
bare top-level keys like OAUTH2_CLIENT_ID or
OAUTH2_SSL_CERT_VERIFICATION are silently ignored. This is an easy
trap in container deployments where every other option is set via
an individual PGADMIN_CONFIG_<KEY> env var.
Add warn_on_misplaced_oauth2_config(), called once at OAuth2 module
startup, that logs when per-provider OAUTH2_* keys exist at the top
level with no provider configured in OAUTH2_CONFIG. No behavior
change for correctly configured or unconfigured deployments.
Documents the supported container approach in oauth2.rst.
Closes#10053
The PDF docs build in the Check documentation builds workflow began
failing after the 9.16 release notes added "Mai Phạm Hiền" as the
reporter for CVE-2026-12049. pdflatex's default utf8 inputenc maps
Latin-1 and Latin-Extended-A but rejects the precomposed Vietnamese
code points U+1EA1 (a with dot below) and U+1EC1 (e with circumflex
and grave) with:
! LaTeX Error: Unicode character ạ (U+1EA1)
not set up for use with LaTeX.
Declare the two characters in the LaTeX preamble via
\DeclareUnicodeCharacter so they typeset correctly:
ạ -> \d{a} (a with combining dot below)
ề -> \`{\^e} (e with circumflex + grave)
Picked this over switching to xelatex because it is a single-file
change, keeps the existing pdflatex toolchain, and the CI workflow
already has the apt packages it needs (no .github/workflows change).
Verified by running make docs-pdf in an ubuntu:22.04 container that
mirrors check-doc-builds.yml (same apt packages, locale-gen
en_US.UTF-8, sphinx + sphinxcontrib-youtube): 682-page pgadmin4.pdf
produced cleanly, zero Unicode-character errors in the log.
The login page hardcoded the 'fab' (brands) Font Awesome style for the
OAuth2 button icon, so non-brand icons could not be used. Use the
configured OAUTH2_ICON as-is when it already specifies a style class
(e.g. 'fas fa-key'), and fall back to 'fab' when only an icon name is
given, preserving backward compatibility.
The CLI set-prefs path (save_pref -> Preferences.save_cli) wrote the raw
value to the configuration database without any type validation and
always reported success, unlike the GUI path which validates via
_Preference.set(). Route save_cli through the same set() validation
(set() now accepts an explicit user_id so it works outside a request
context), and make setup.py set-prefs check the result and report
preferences whose value was invalid.
* Fix View/Edit Data crash on a stale/non-filter session transaction object
initialize_viewdata restores the filter and data-sorting from any command
object previously stored in session['gridData'] under the same trans_id. It
assumed that object was always a filter-capable (View/Edit Data) command and
accessed old_trans_obj._row_filter / ._data_sorting directly.
That assumption is wrong: the same trans_id may have been used by the Query
Tool (a QueryToolCommand, which does not inherit SQLFilter), or the session
may contain an incompatible object persisted by an older version after an
upgrade. In those cases the attribute access raised AttributeError, returning
a 500 from the endpoint and - in desktop mode, where this runs during startup
- preventing the application from loading at all.
Guard the restore with isinstance(old_trans_obj, SQLFilter) (short-circuited
before the did/obj_id checks) so a non-filter object is simply skipped. Add a
regression test that seeds a pickled QueryToolCommand under the trans_id and
asserts initialize/viewdata returns 200.
Closes#9744
* test: avoid hard-coded PK constraint name colliding across the suite
The regression test created its table with a fixed 'table_pk' primary key
constraint name, which collides with the same name used by TestViewData in
this package when the full suite runs against one database. That made the
CREATE TABLE fail in CI (table not found -> IndexError on the OID lookup).
Let PostgreSQL auto-name the primary key instead.
---------
Co-authored-by: Ashesh Vashi <ashesh.vashi@enterprisedb.com>
Add 14 release-note entries that were merged on master but not yet
captured in the 9.16 notes: 1 new feature (#2431), 6 housekeeping
(#9817, #9866, #9917, #9959, #10014, #10023) and 7 bug fixes (#9701,
#9782, #9933, #9952, #9985, #10013, #10030). Entries inserted in
numeric order within each section.
Also add an "Additional changes (no associated issue) -> Dependencies"
section mirroring the 9.15 format, listing net direct dep bumps
between REL-9_15 and HEAD across Python (requirements.txt,
tools/requirements.txt, web/regression/requirements.txt), web/
package.json, and runtime/package.json. Transitive yarn resolutions
and the setuptools pin (covered by bug fix#9829) are excluded.
The Forgot Password and Reset Password pages had no way to navigate back
to the login page. Added a "Back to login" link (using the login URL
already used by the login form) to both pages.
Webpack 5 asset modules include the leading dot in the [ext] token, so
the 'img/[name].[ext]' and 'fonts/[name].[ext]' templates produced
filenames with a double dot (e.g. Roboto-Bold..ttf). Use '[name][ext]'
so the emitted filenames are correct.
The base (default) SQL templates previously targeted PostgreSQL < 12.
Re-base them so the default target is 14 - the oldest supported server
version - by collapsing every version bucket <= 14 (11_plus, 12_plus,
13_plus, 14_plus and the old default) into a single `default`, keeping
per file the content a v14 server resolves today. Buckets for newer
versions (15_plus, 16_plus, 17_plus, 18_plus) are retained as overrides.
The transformation is behaviour-preserving for every server version >= 14:
template (and test-fixture) resolution is byte-identical before and after
for all supported versions, verified programmatically across every bucket
container and confirmed by the resql, ERD and Schema Diff suites against
PostgreSQL 18.
Also drop PostgreSQL/EDB Advanced Server 13 from the 9.16 supported-server
list and repoint the sqleditor explain_plan tests (which referenced the
removed 12_plus/13_plus buckets) at the new default template.
Closes#10050
* Propagate column renames to FK and unique constraints. #9060
In the new-table dialog, the primary key already updated its column
references when a column was renamed, but foreign key and unique
constraint definitions did not, leaving them pointing at the old name.
Mirror the PK rename-propagation in the foreign_key and unique_constraint
depChange handlers (and add 'columns' to the unique constraint deps so it
fires on column changes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Address review feedback for column rename propagation (#9060)
- Remap unique constraint INCLUDE columns on rename. The INCLUDE list
holds bare name strings (not {column} objects), so renaming an
included column previously emitted stale DDL. Now mirrors the
primary key INCLUDE handling.
- Add regression tests covering rename propagation in depChange for
foreign_key and unique_constraint, including the unique constraint
INCLUDE case.
- Correct the release note wording from "Create/Edit Table" to
"Create Table"; the propagation only runs on the new-table path
(state.oid === undefined).
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Ashesh Vashi <ashesh.vashi@enterprisedb.com>
setuptools 82 stops shipping pkg_resources, which passlib (via
Flask-Security-Too on Python 3.9) imports, so a clean install on Python
3.9 failed with "No module named 'pkg_resources'". Python 3.9 is still a
supported target (RHEL/Rocky/AlmaLinux 8 and 9 build with system Python
3.9). Split the pin by Python version, mirroring the existing
Flask-Security-Too split.
The fix for #9570 (stop Alt+F5 showing a crosshair cursor) changed the
rectangular-selection eventFilter to require Alt+Ctrl and moved the
crosshair cue to Control. That broke the long-standing Alt+drag block
(column) selection and left an inconsistent Ctrl crosshair cue.
Restore the default rectangularSelection() (Alt+drag) and drop
crosshairCursor entirely - the crosshair-on-Alt was exactly the artifact
#9570 wanted gone, and CodeMirror's crosshair cannot be limited to an
active drag. This brings back block selection (#9864, #10029) while
keeping #9570's intent (no crosshair on the Alt+F5 shortcut).
removeOneToManyLink looked up a column by the FK's stored local_column
name and read .attnum unconditionally. After a column rename the stored
name no longer matches, so _.find returned undefined and .attnum threw,
blocking deletion of the table/link. Use optional chaining so a stale FK
simply doesn't match the link being removed and deletion proceeds.
Apply muted server foreground colour to column type labels (#9766)
Issue #9766 asked for both the object counts and the column type text
to follow the server's custom foreground colour. The dynamic per-server
CSS rule only recoloured the file-name and children-count spans, leaving
the column type text (span.text-muted) at its default low-contrast
colour.
Recolour span.text-muted to a reduced-emphasis blend of the foreground
colour using color-mix, so the datatype still reads as de-emphasised
secondary text while following the server colour.
The breadcrumbs popup is an absolutely-positioned, informational overlay
at the bottom-left of the object explorer, so it intercepted pointer
events and blocked clicks on the tree items beneath it. Set
pointer-events: none so clicks pass through to the tree.
The Query Tool's JSON cell editor pretty-printed jsonb values by parsing
and re-stringifying them with json-bignumber. While that preserves big
integers, it normalizes decimals through a JS float, so trailing
fractional zeros are dropped (10.00 -> 10, 3.140 -> 3.14). Because the
reformatted text is what gets written back, opening an unrelated jsonb
document and saving it silently rewrote numbers it never edited - which
can break applications that rely on the canonical jsonb text.
Switch the editor to lossless-json, which preserves the exact numeric
representation (big integers and trailing zeros alike), and pass it to
the underlying vanilla-jsoneditor as its parser so the in-editor format
action and tree/table modes are lossless too. The lossless helpers are
centralized in a small json_utils module with unit tests.
Closes#9854
The Query History panel formats entry dates/times with
Date.prototype.toLocaleDateString()/toLocaleTimeString(). On runtimes
whose default locale (derived from the OS/environment) is malformed,
these throw "RangeError: Incorrect locale information provided".
Because getDateFormatted()/getTimeFormatted() are called during render
(via getGroups/getGroupHeader/getDatePrefix), the uncaught exception
unmounts the whole SQL editor React tree, leaving the user with a blank
white screen and losing any unsaved query work.
Guard both helpers and fall back to a moment-based format (moment uses
its own locale data and does not depend on the broken Intl default) so
the editor keeps working instead of crashing.
Closes#7596
After a backend/pod restart the in-memory crypt key is gone, so
manager.connection() raises CryptKeyMissing. The new-connection
endpoints (_check_server_connection_status and get_new_connection_*)
swallowed it in a broad "except Exception", logged a full ERROR
traceback, and returned a generic error the client cannot recognise.
The standard recovery (a 503 CRYPTKEY_MISSING response that the client
uses to transparently re-establish the key and retry) therefore never
fired, leaving a spurious "Crypt key is missing" message in the Query
Tool and noisy tracebacks in the log.
Re-raise CryptKeyMissing (along with ConnectionLost /
SSHTunnelConnectionLost) before the generic handler, matching the
pattern already used by the query execution path, so these endpoints
emit the standard CRYPTKEY_MISSING response and the client recovers
gracefully.
Closes#10027
The macOS app is built for a single architecture (matching the build
machine, via ${ARCH}), but relocatable-python pulls the python.org
universal2 installer, so the entire Python.framework ships both arm64
and x86_64 slices. PostgreSQL-sourced dylibs may be universal too. The
foreign slice is dead weight that bloats the bundle and DMG.
Add a _strip_architecture step, run after _complete_bundle and before
code-signing (lipo invalidates signatures, so the existing sign passes
re-sign the thinned binaries). It removes the universal2 stragglers
(python*-intel64 launcher, config-*-darwin/python.o) and lipo-thins
every fat Mach-O in the bundle to the build arch, preserving file modes
and warning on anything lacking the target slice. Already single-arch
inputs (Electron and its helpers) are skipped.
When a tool (Query Tool, View/Edit Data, etc.) opens a connection for a
server whose password was not saved, it relies on the password cached on
the server manager. If that cached password is unavailable, the tool
prompts for it. The entered password was POSTed to the connect_server
endpoint, which short-circuited with "Server connected" whenever the
server's primary connection was already established -- silently discarding
the entered password. The tool's own connection therefore still had no
password and re-prompted immediately, producing an infinite prompt loop
in which the re-entered password appeared to be rejected.
Cache the entered password on the server manager (encrypted) in that
short-circuit path so the tool's connection can reuse it. The password
overwrites any cached value, so a regenerated short-lived cloud auth
token (AWS RDS IAM / Azure Entra) takes effect immediately.
Opening a huge JSON/JSONB cell in the Query Tool's cell editor parses,
pretty-prints and renders the entire document on the main thread. For
pathologically large values (e.g. a jsonb object with 100k keys) this
blocks the UI thread and pgAdmin becomes completely unresponsive, with
no chance for the user to back out.
Guard the JSON editor: when the raw cell value exceeds a size threshold,
render nothing until the user confirms via a warning dialog. If they
cancel, the editor is closed without doing the expensive work. Small
values are unaffected and open immediately as before. The editor already
uses commitOnOutsideClick: false, so the confirm dialog does not dismiss
the editor.
The size-threshold logic is a small, separately tested helper.
Closes#9868
refreshMenus() rebuilt the application menu from the module-level
cachedMenus, which is only populated once the renderer sends its menu
definition via the 'setMenus' IPC. When a menu refresh was triggered
before that happened - e.g. an auto-update event, or the user closing
the window while the UI was still loading - cachedMenus was undefined
and bindMenuClicks() crashed with 'Cannot read properties of undefined
(reading map)', surfacing as an uncaught-exception dialog.
Guard refreshMenus() so it bails out when there are no cached menus to
rebuild.
Closes#9762
The SQL-standard body detection used a regex that matched 'return'
anywhere in the body, so a plain SQL body containing a RETURNING clause
(or an identifier like 'returned_value') was wrongly treated as a
SQL-standard (BEGIN ATOMIC / RETURN) body. The generated CREATE OR
REPLACE statement then dropped the AS $BODY$ ... $BODY$ wrapper,
producing invalid SQL and a syntax error on save.
Anchor the RETURN form to the start of the body so only genuine
SQL-standard bodies are detected. Add unit tests for the detection.
Closes#10059
Resolves open Dependabot security advisories for transitive npm
dependencies that have no direct manifest entry (so Dependabot cannot
auto-open fix PRs for them).
tar (6 x HIGH): an old tar@6.2.1 was pulled in via
ttf2woff2@4.0.5 -> node-gyp@9.4.1 (and node-gyp's
make-fetch-happen@10 -> cacache@16 chain). ttf2woff2 6+ switched to an
ESM/default export that breaks @vusion/webfonts-generator's callable
usage, so rather than bump ttf2woff2 we override its node-gyp to
^11.2.0 via a scoped resolution. That modernises the whole sub-tree
(node-gyp 11, make-fetch-happen 14/15, cacache 19/20) onto tar@7.5.16
while keeping ttf2woff2 at 4.0.5 so webfont generation still works.
flatted (1 x HIGH): bumped 3.4.1 -> 3.4.2 in the Electron runtime
(GHSA-rf6f-7fwh-wjgh).
Corrected "varible" -> "variable" and rewrote the broken sentence to
explain that PGPASS_FILE is the path to a pgpass file that is copied into
the container and used as the .pgpass file.
doc: Correct the macOS Replace shortcut in the Query Tool toolbar docs. #9817
The Edit toolbar table listed Replace as "Option + Cmd + F (MAC)" /
"Ctrl + Shift + F (Others)", but the default Replace shortcut is
Cmd/Ctrl + R, per register_editor_preferences.py.
Tighten the release-notes prose for the two security entries whose
original one-liner didn't carry the impact framing settled during
Dave Page's 2026-06-11 CVSS review.
#10068 (CVE-2026-12048): re-lead with the critical-severity chain.
Default pgAdmin Content-Security-Policy allows inline script and an
iframe srcdoc inherits the embedding origin, so attacker JavaScript
ran same-origin to the victim's authenticated pgAdmin session and
could read every saved server connection credential and issue
arbitrary SQL against every server the victim was connected to.
The previous wording described the sinks but not the impact.
#10078 (CVE-2026-12044): re-lead with the stored
pgstattuple/pgstatindex sink (low-privilege user names a table or
index foo'bar, superuser viewer triggers SQL under the superuser
role). That sub-defect is what earns the score; the description-
field self-injection is bundled because the fix is the same. The
previous wording led with the self-injection only.
Both rewrites match the descriptions used in the CVE JSON records
submitted to the PostgreSQL CNA.
When a table inherits a column from a parent, the generated table
SQL/EDIT script omitted the column's default. Emit a post-create
ALTER TABLE ... ALTER COLUMN ... SET DEFAULT for inherited columns
that carry a default, and show the default in the inherited-column
comment, across all version buckets.
Generated columns (colconstype 'g') are excluded from both the new
ALTER statement and the comment default, consistent with the inline
column-definition logic, since SET DEFAULT is invalid for a generated
column.
When a database connection was silently dropped while pgAdmin sat idle
(common on Linux Desktop behind a firewall/NAT), the Object Explorer and
Query Tool would hang on an infinite spinner instead of offering to
reconnect, because connected() only checks local driver state and misses
stale/half-open TCP sockets.
- ping() now performs a real network check (SELECT 1) guarded by the
connection's transaction_status, so an in-progress query or open
transaction is never disrupted; on failure it tears down the dead
connection and returns False.
- The Object Explorer children() endpoint uses ping() to detect a dead
connection up front and returns 503 CONNECTION_LOST; the tree shows a
reconnect dialog (deduplicated per server) and marks the node
disconnected.
- TCP keepalive defaults are applied to all connections so the OS
surfaces dead sockets in seconds rather than the full retransmission
timeout.
- The Query Tool re-checks connection status when its tab becomes
visible again, and its layout/visibility listeners are now cleaned up
on unmount.
Refs #9700, #8279. Adds a 9.16 release note.
The passfile kwarg passed to Connection.connect() was only ever used as
a gate for the passexec fallback; it was never forwarded to
create_connection_string(), which builds the DSN's passfile from the
ServerManager's connection parameters. This made the gate inconsistent
with the passfile actually used for the connection.
Use the ServerManager passfile for the credential gate so the check
matches what is used to connect. The manager passfile now takes
precedence over both passexec and any passfile kwarg; warnings are
emitted when either is ignored in its favor.
The entries for #9875 (EXPLAIN/blank-line query extraction) and #9988
(OAuth2 metadata URL guidance) were added to release_notes_9_9.rst,
which is an already-released version. Move them to the in-progress
9.16 notes where they belong.
The two init containers in the Helm deployment template had hardcoded
securityContext blocks, unlike the main container which already renders
its context from .Values.containerSecurityContext via the
renderSecurityContext helper. Switch the init containers to the same
pattern so operators can customise (or disable) their security context.
Default behaviour is unchanged: containerSecurityContext defaults to
enabled with values identical to the previous hardcoded block, and the
helper continues to gate appArmorProfile on
global.compatibility.appArmor.enabled. Verified with helm template that
the rendered init-container securityContext is unchanged for the default
values, honours the appArmor toggle, and is omitted entirely when
containerSecurityContext.enabled=false.
Adds a 9.16 release note.
getQueryAt now uses the syntax tree as the primary check for whether a
blank-line boundary cut through a SQL statement. It is split into two
helpers: _findQueryBoundaries (the original scan, parameterized by
stopAtBlankLine) and _needsExpansion, which detects when a Statement
node straddles the extracted range and re-scans ignoring blank lines.
A STATEMENT_STARTERS keyword list guards the case where the parser
merges semicolon-less queries into one Statement, and WRAPPER_STARTERS
(EXPLAIN, ANALYZE, WITH) force expansion when the Statement extends past
the range. Also fixes a Lezer boundary bug (tree.iterate is inclusive at
boundaries) with a node.to > startPos check. Adds 21 tests covering
EXPLAIN, negative no-merge cases, comments, and boundary edge cases.
Some OpenAI-compatible providers emit empty or null name/arguments/id
fields in streaming continuation deltas to keep the response schema
stable. pgAdmin's accumulator overwrote the real tool name (captured in
the first delta) with the later null, producing a tool call named "null"
that could not be dispatched.
Skip falsy name/arguments/id when accumulating (matching the OpenAI
Python SDK, which ignores nulls the same way) so the values captured in
the first delta survive. Also guard against a null `function` object in
a delta, which previously raised TypeError. Without the id guard a null
id in a continuation delta clobbered the real id, which the final build
then replaced with a random uuid rather than the provider's id.
Adds a unit test covering the null-continuation, multi-chunk-arguments,
and null-function cases, and a 9.16 release note.
When OAUTH2_SCOPE contains 'openid' but OAUTH2_SERVER_METADATA_URL is
not set, Authlib fails deep inside id_token verification with a cryptic
`Missing "jwks_uri" in metadata` error that names neither the config
knob nor the fix.
Add a pre-flight check at the entry of _authorize_access_token: if the
scope includes 'openid' and no (non-whitespace) metadata URL is set,
raise a RuntimeError with actionable guidance before any network
round-trip. server_metadata_url is the only way pgAdmin feeds JWKS to
Authlib, so this carries no regression risk for correctly-configured
providers. Clarify the OAUTH2_SERVER_METADATA_URL comment in config.py
and add regression coverage.
The container entrypoint substituted ${PGADMIN_CONFIG_CONFIG_DATABASE_URI}
into a double-quoted Python string for `python3 -c`. Combined with the
config_distro.py convention (where the env var's value must itself be a
Python literal, i.e. users set it to 'postgresql+psycopg://...'), the
entrypoint re-wrapped the already-quoted value, producing a string with
literal quotes inside that SQLAlchemy could not parse -- and the Python
crash made the first-launch check capture an empty string, silently
skipping PGADMIN_DEFAULT_EMAIL / PGADMIN_DEFAULT_PASSWORD setup.
Read the env var inside Python via os.environ so the shell no longer
participates in Python-literal quoting (also removing a shell-injection
surface), and use ast.literal_eval to unwrap the legacy quoted form while
letting raw values pass through. external_config_db_exists now stays
"False" on any Python failure so first-launch setup still runs.
Adds a 9.16 release note.
An old configuration database (e.g. a pre-9.0 pgadmin4.db lingering in
%appdata%) can carry a stale foreign key such as server.user_id ->
user_old, a table removed long ago. SQLAlchemy's MetaData.reflect()
defaults to resolve_fks=True, which auto-follows the reflected table's
foreign keys and reflects their targets too; the orphan target trips
NoSuchTableError and aborts the startup migration, surfacing in the GUI
as the misleading "Server could not be contacted".
None of the migrations use the FK-target tables -- each operates only on
the explicitly requested table(s). Pass resolve_fks=False at all 14
meta.reflect() call sites across the 12 migration files so reflection no
longer cascades into broken FK targets. Behaviour is unchanged for
healthy databases.
Adds a 9.16 release note.