fix(sdk): make the Connections delete button delete, and stay deleted - #1089
fix(sdk): make the Connections delete button delete, and stay deleted#1089mlennie wants to merge 1 commit into
Conversation
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
left a comment
There was a problem hiding this comment.
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.
| 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", | ||
| }, | ||
| }, | ||
| ], | ||
| }, | ||
| }, | ||
| ); |
There was a problem hiding this comment.
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.
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
onDeletegated the mutation withif (!conn.resource).resourceis a hypermedia self-link rather than a provenance flag, and the server stamps it in two places: on connections read frompublisher.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
resourceuntil 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.
addEnvironmentToDatabaseupserts 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:
--initDELETE /environments/{env}/connections/{name}--initSo 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
resourcenever carried provenance. There was one later attempt at a real predicate, the unmergeddo-not-allow-deleting-connectionsbranch, which rewrites this line to!conn.isConnectionFromConfigand adds the api-doc field, anis_configcolumn and repository plumbing. It never landed.Deleting a connection that was declared in
publisher.config.jsonis 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.frozenConfigis what actually enforces immutability, it is checked server-side in bothupdateEnvironmentandConnectionService.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
duckdbsandbox and had lost its condition, since that would have called for a correct predicate rather than a removal. It was not:assembleEnvironmentConnectionsthrows on any connection namedduckdb, 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/finallythat 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-connectionsPlaywright file at 6 across 1.Every truthiness use of
.resourceinpackages/sdkandpackages/appwas checked, and the guard was the only one. The other occurrences, inConnectionExplorer.tsxandPackage.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
Connectionssubtree while the dialog portals todocument.body, andDeleteConnectionDialoghas 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.deleteConnectionperforms 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.