Skip to content

fix(sdk): make the Connections delete button delete, and stay deleted - #1089

Open
mlennie wants to merge 1 commit into
mainfrom
monty/fix-connections-delete-guard
Open

fix(sdk): make the Connections delete button delete, and stay deleted#1089
mlennie wants to merge 1 commit into
mainfrom
monty/fix-connections-delete-guard

Conversation

@mlennie

@mlennie mlennie commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

What was wrong

Deleting an environment connection from the UI did nothing, and there were two independent reasons. Fixing only the first would have made things worse, so both are here.

The guard. The card's onDelete gated the mutation with if (!conn.resource). resource is a hypermedia self-link rather than a provenance flag, and the server stamps it in two places: on connections read from publisher.config.json (convertConnectionsToApiConnections), and on every connection loaded from the database, which is every boot after the first (EnvironmentStore.initialize). So on any server that had been restarted, every connection carried one and the button reported "Cannot delete this connection" instead of deleting.

It was not inert in every state, which is worth being precise about because it explains why the existing coverage stayed green. A connection added through the UI has no resource until a restart persists and reloads it, and the existing "Create then Edit then Delete" spec creates one and deletes it inside a single process lifetime. That was the only path where delete worked, and it is the path that spec takes.

The mutation, which only became reachable once the guard was gone. Delete was implemented as a PATCH of the environment with the connection filtered out. addEnvironmentToDatabase upserts the connections the environment still holds and never drops the row for one that went away, so the connection came back on the next boot. The sibling storage-destination sync documents exactly this hazard on its own function and prunes; the connection path does not.

Measured rather than argued:

step result
add a connection via PATCH present
delete it via PATCH, which is what the UI did gone from the API
restart with no --init back again
delete it via DELETE /environments/{env}/connections/{name} gone from the API
restart with no --init still gone

So removing the guard alone would have turned "does nothing" into "appears to work and silently undoes itself". Delete now goes through the dedicated endpoint, which removes the row and also runs the cleanup for a duckdb or ducklake connection's database file. I read that second path rather than exercising it, because an environment-level duckdb connection needs at least one attached foreign database and this setup has no credentials for one.

Why the guard is removed rather than repaired

It came from #499, "Do not allow deleting connections that are in use by a package", which replaced an earlier check that read the explorer's selected connection rather than the card being deleted. Neither predicate ever expressed that intent, and resource never carried provenance. There was one later attempt at a real predicate, the unmerged do-not-allow-deleting-connections branch, which rewrites this line to !conn.isConnectionFromConfig and adds the api-doc field, an is_config column and repository plumbing. It never landed.

Deleting a connection that was declared in publisher.config.json is now possible where it was not, so it is worth saying that plainly rather than burying it. That is consistent with the rest of the system. frozenConfig is what actually enforces immutability, it is checked server-side in both updateEnvironment and ConnectionService.deleteConnection, and the UI already hides the entire action menu when the server reports it. Edit was never gated, so before this you could edit a config-declared connection but not delete it.

I also checked whether the guard was protecting the per-package duckdb sandbox and had lost its condition, since that would have called for a correct predicate rather than a removal. It was not: assembleEnvironmentConnections throws on any connection named duckdb, so the sandbox can never appear in this list.

How it was verified

Reproduced in a browser before and after, against three connections chosen so the cases discriminate rather than agree: one declared in publisher.config.json, one added at runtime that had survived a restart, and one added in the current process lifetime. Before the change the first two failed with the confirmation dialog left open and only the third deleted. After it, all three delete, and a connection deleted through the UI is still absent after a restart with no --init.

The new spec asserts on the DELETE request the UI issues, not only on the card disappearing. That distinction is the whole point: the card also disappears under the PATCH implementation, so an assertion on the card alone cannot tell a correct fix from one that resurrects. It fails against the original guard and against the guard-removed-but-still-PATCHing variant, and I ran it against both. It seeds a real connection row, so everything after the seed runs under a try/finally that removes it unconditionally, which I verified by making the seeded assertion fail on purpose and confirming the cleanup still ran.

Full gate green: typecheck, prettier apart from the six generated files that are git-ignored and that CI checks before codegen, unit at 3219 tests across 152 files, integration at 315 across 37 files with the path anchored, and the environment-connections Playwright file at 6 across 1.

Every truthiness use of .resource in packages/sdk and packages/app was checked, and the guard was the only one. The other occurrences, in ConnectionExplorer.tsx and Package.tsx, read it as a path string.

Two things I did not change, both open to being overruled

A failed delete produces no visible feedback. The snackbar renders inside the Connections subtree while the dialog portals to document.body, and DeleteConnectionDialog has no self-close, so on a 404 or a 500 the user sees the button leave its loading state and nothing else. The same floating-rejection shape exists in the add and edit mutations, so fixing only delete would be inconsistent and fixing all three is a UX change wider than this fix. Happy to fold it in if you would rather it landed here.

#442's actual intent, refusing to delete a connection a package is using, is still unimplemented. ConnectionService.deleteConnection performs no usage check, so a package referencing a deleted connection will fail at its next compile. Implementing it needs the server to expose which connections each package references, which no API surface does today.

Interaction with #1071

Worth a note for whoever merges these. When delete PATCHed the whole connections list, a redaction PR could have caused a delete to write redacted credentials back over every surviving connection. #1071 anticipated that and added mergeConnectionUpdate, its comment naming the delete case. Since delete now uses the dedicated endpoint it does not round-trip the list at all, so it no longer depends on that merge, though add and edit still do. Either merge order is safe.

Deleting an environment connection from the UI did nothing, and the two
reasons had to be fixed together.

First, the card's onDelete guarded the mutation with `if (!conn.resource)`.
`resource` is a hypermedia self-link rather than a provenance flag, and
the server stamps it in two places: on connections read from
publisher.config.json (convertConnectionsToApiConnections) and on every
connection loaded from the database, which is every boot after the first
(EnvironmentStore.initialize). On any server that had been restarted,
every connection carried one and the button reported "Cannot delete this
connection" instead of deleting.

It was not inert in every state, which is why the existing
"Create -> Edit -> Delete" spec stayed green: a connection added through
the UI has no `resource` until a restart persists and reloads it, and
that spec creates one and deletes it inside a single process lifetime.

Second, and only reachable once the guard was gone, the mutation deleted
by PATCHing the environment with the connection filtered out.
addEnvironmentToDatabase upserts the connections the environment still
holds and never drops the row for one that went away, so the connection
came back on the next boot. The sibling storage-destination sync already
documents this hazard on its own function. Removing the guard alone would
have turned "does nothing" into "appears to work and silently undoes
itself", so this routes delete through
DELETE /environments/{env}/connections/{name}, which removes the row and
also runs the cleanup for a duckdb or ducklake connection's database file.
I read that second path rather than exercising it: an environment-level
duckdb connection needs at least one attached foreign database, which this
setup has no credentials for.

The guard came from #499 ("Do not allow deleting connections that are in
use by a package"), which replaced an earlier check that read the
explorer's selected connection rather than the card being deleted.
Neither predicate expressed that intent, and `resource` never carried
provenance. Implementing the intent needs a package-to-connection usage
map that no API surface exposes today, so it stays open.

Deleting a connection declared in publisher.config.json is now possible
where it was not. That is deliberate. frozenConfig is what actually
enforces immutability: it is checked server-side in both updateEnvironment
and ConnectionService.deleteConnection, and the UI already hides the whole
action menu when the server reports it. Edit was never gated, so before
this you could edit a config-declared connection but not delete it.

Verified in a browser. Before, deleting a connection carrying `resource`
left the confirmation dialog open with the "Cannot delete this connection"
snackbar behind the modal; the dialog has no self-close and disappears
only by unmounting when the refetch drops the card. After, the connection
deletes, and it is still absent after a restart with no --init.

The new spec asserts on the DELETE request the UI issues, not only on the
card disappearing. That distinction is the point: it fails against the
original guard, and it also fails against a version with the guard removed
that still PATCHes, which is the variant that looks correct until you
restart. It seeds a real connection row, so everything after the seed runs
under a try/finally that removes it unconditionally; a 404 there is the
success case.

I checked every truthiness use of `.resource` in packages/sdk and
packages/app: the guard was the only one. The other occurrences, in
ConnectionExplorer.tsx and Package.tsx, read it as a path string.

Signed-off-by: Monty Lennie <montylennie@gmail.com>

@Sha-Bang Sha-Bang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removing the resource guard and deleting through the dedicated endpoint is the actual fix — PATCH never dropped the row, so the button appearing to work would have been worse. the spec waits for DELETE, which is what would catch a revert. seed through POST .../connections/{name} instead of PATCHing the whole list; that write is the one this PR just left, and with #1071 it can strip secrets off every other connection. approving, one inline.

Comment on lines +185 to +203
const seeded = await request.patch(
`/api/v0/environments/${DEFAULT_ENV}`,
{
data: {
name: DEFAULT_ENV,
connections: [
...existing,
{
name: connName,
type: "postgres",
resource: `/api/v0/connections/${connName}`,
postgresConnection: {
connectionString: "postgres://test@localhost:5432/test",
},
},
],
},
},
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this rewrites every connection in the environment to add one row. POST /api/v0/environments/{env}/connections/{name} already exists and won’t touch the others. you can still send resource on that body so the UI sees the after-restart shape. as written, a GET-then-PATCH of existing is exactly the round-trip #1071 makes unsafe — the next merge would send the bigquery stub back with no credentials.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants