Skip to main content

Postgres Language Server — the optional schema linter

The Postgres Language Server ships a schema linter, dblint. SQLens can run it and report its security checks alongside its own — row-level security enabled with no policy, a policy on a table where row-level security is off, functions with a mutable search_path, extensions installed in public.

It is an amplifier, never a requirement. Every SQLens rule runs without it, and a run that lacks it says so rather than quietly checking less.

What a run does to your database​

This is the one adapter whose tool opens its own connection, so the question was measured rather than reasoned about. It connects to the server the audit read: the connection as the framework resolves it, its url included, and on a read/write split the one host the audit was pinned to. The defaults Laravel's config/database.php keeps beside a url are never where it goes. It connects the way that connection does, too: sslmode, sslrootcert, sslcert and sslkey reach the tool as the PGSSL* variables its client reads, so a connection set to verify-full is not audited over plaintext. With statement logging on, one run issues sixteen statements, none of them mutating, over ten sessions open at once: twelve catalog reads spread across them, and on one session a transaction that empties search_path for the lint query. A run therefore takes ten connection slots while it lasts, and the tool has no setting that lowers the number.

They are not free of locks. Two of them call pg_total_relation_size() over every relation in the database, which opens each one with an ACCESS SHARE lock for the length of the call. That is the lock any read takes, and it conflicts only with ACCESS EXCLUSIVE, which is what a migration's ALTER TABLE holds while it runs.

So the tool's sessions run under the bounds SQLens gives its own: the lock_timeout and idle_in_transaction_timeout of catalog.session, a statement bound equal to the tool's timeout, read-only transactions, and the catalog.session.application_name with -pgls appended, so they can be found in pg_stat_activity. Measured with a migration holding ACCESS EXCLUSIVE on one table: without these bounds the run waited out its whole timeout, and the stopped tool's sessions stayed in the lock queue until the migration ended. With them the run ends after the lock bound and leaves nothing waiting.

The sessions also set client_connection_check_interval to one second. A statement already running when the run is stopped does not notice the tool is gone until it next writes to the connection, so it would run on to the end of its bound. Measured on PostgreSQL 18.0: the statement of a client killed two seconds in was still active three seconds later without the check, and gone with it.

That is a property of somebody else's binary, which is exactly the kind of fact that changes without telling you. So it is not a promise in prose: a test in this package's own suite runs the tool as a role with no write privilege at all, and a version that started wanting one turns it red.

Install it, or do not​

npm install --global @postgrestools/[email protected]

The version matters more than it usually does. This tool is below 1.0, where a version number promises nothing about compatibility, so SQLens accepts one MINOR range — the range its adapter was measured against. A build outside it is reported as unsupported rather than trusted, because a report in a shape nobody measured does not fail to parse: it parses into the wrong thing.

Configuration​

// config/sqlens.php
'tools' => [
'pgls' => [
'path' => null, // or an absolute path to pin a specific binary
'enabled' => true,
'timeout' => 30,
],
],

There is no fast_path key, and the absence is the statement: the single-file route judges one migration with a sub-second budget, and this tool answers by connecting to a database. There is no version of that route it belongs in.

A pinned path that does not run is reported rather than worked around. Naming a path is a statement about which binary, so falling back to $PATH would honor the configuration in appearance and ignore it in fact.

What its findings look like here​

They carry SQLens rule ids under a PGLS. prefix, at high, medium or low — translated from the tool's own ranking rather than passed through, because the two scales measure different things. critical is deliberately unreachable from an external check: a re-rating upstream must not fail a build nobody changed.

They are security findings, and they pass the run's gates the way a rule's do: the severity axis decides, not the level, so they appear at any --level, and a run whose --category leaves out security reports none of them. An entry in the baseline under a PGLS. id is not judged in such a run either, because it never asked.

Every finding names the tool and the exact version that produced it, plus that tool's own documentation for the rule. The link a report renders as "read about this" stays a SQLens page, because it has to explain what SQLens did with the finding — which the upstream page cannot.

Where it overlaps with SQLens's own rules​

Two of the tool's six security rules check ground SQLens already covers:

PGLS ruleSQLens rule
policyExistsRlsDisabledSEC.RLS.DISABLED
rlsEnabledNoPolicySEC.RLS.NO_POLICY

Where both fire on the same object you see one finding, and it is SQLens's. Not because it is better — because it carries this package's documentation, severity and remediation, and because a finding whose id changed depending on whether an optional binary is installed could not be baselined.

The tool's agreement is not thrown away. It is recorded on the finding as a confirmation naming the build that agreed, so a report still shows two sources reaching the same conclusion.

The other four rules are pure addition — extensions in public, outdated extension versions, mutable function search paths, unsupported reg* types. SQLens has no rule for any of them, so installing the tool genuinely adds four checks.

That table is an allowlist of known overlaps, never a filter: a rule missing from it passes through untouched. The opposite reading would silently drop whatever nobody thought to list, and the checks most worth having are the ones nobody thought about.

When it cannot run​

A missing binary, a database it cannot reach, or a report it cannot read all end as an undetermined naming which of those happened. Never as "no findings": a run that never reached your database has checked nothing, and saying otherwise is the one failure this package is built to refuse.

SQLENS_PGLS_TIMEOUT sets the timeout per environment, in seconds. A run that hits its timeout is stopped together with every process it started. That matters here because of how the npm package installs the tool: postgrestools on the path is a node launcher that runs the native binary as its child. Stopping only the launcher left the native binary running, with its database sessions open, after the run had reported the timeout. SQLens reads which processes the tool started before it stops it, from /proc on Linux and from ps elsewhere, and ends them first.

A table under ACCESS EXCLUSIVE while the tool reads ends the run the same way, after the lock bound. The tool answers that with the same text it gives for a connection it cannot make, so the undetermined names both causes.

Under a strict-tools profile, a fixable absence ends the run as a misconfiguration instead.