Skip to content

feat: add CRUD REST API for saved searches (/api/v2/saved-searches)#2544

Open
ankitcharolia wants to merge 14 commits into
hyperdxio:mainfrom
ankitcharolia:feat/saved-searches-external-api
Open

feat: add CRUD REST API for saved searches (/api/v2/saved-searches)#2544
ankitcharolia wants to merge 14 commits into
hyperdxio:mainfrom
ankitcharolia:feat/saved-searches-external-api

Conversation

@ankitcharolia

Copy link
Copy Markdown

Summary

Adds full CRUD endpoints for saved searches to the external API (/api/v2/saved-searches), enabling infrastructure-as-code tooling to provision saved searches programmatically.

The SavedSearch mongoose model and toExternalJSON() method already existed — only the router and its registration were missing.

New endpoints:

Method Path Description
GET /api/v2/saved-searches List all saved searches for the team
POST /api/v2/saved-searches Create a saved search
GET /api/v2/saved-searches/:id Get a saved search by ID
PUT /api/v2/saved-searches/:id Update a saved search
DELETE /api/v2/saved-searches/:id Delete a saved search

Screenshots or video

Before After
/api/v2/saved-searches → 404 GET /api/v2/saved-searches{ data: [...] }

How to test on Vercel preview

Steps:

  1. Obtain a Bearer token from Team Settings → API Keys
  2. curl -H 'Authorization: Bearer <token>' <preview-url>/api/v2/saved-searches — should return { "data": [] }
  3. POST a new saved search with name, sourceId, and optional where/whereLanguage/select/orderBy/tags
  4. Verify GET/PUT/DELETE round-trips work correctly
  5. Confirm that saved searches created here appear in the UI and can be referenced in alerts via savedSearchId

References

Adds GET/POST /api/v2/saved-searches and GET/PUT/DELETE
/api/v2/saved-searches/:id to the external API.

The SavedSearch mongoose model and toExternalJSON() already exist;
this PR adds only the missing router and index registration.

Closes hyperdxio#2543
@vercel

vercel Bot commented Jun 29, 2026

Copy link
Copy Markdown

@ankitcharolia is attempting to deploy a commit to the HyperDX Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot

changeset-bot Bot commented Jun 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7391f48

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@hyperdx/api Minor
@hyperdx/app Minor
@hyperdx/otel-collector Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@greptile-apps

greptile-apps Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds saved-search CRUD support to the external API. The main changes are:

  • New /api/v2/saved-searches list, create, read, update, and delete routes.
  • Team-scoped source validation for saved-search create and update.
  • Full-replace update behavior for optional saved-search fields.
  • Cascade deletion of alerts that reference a deleted saved search.
  • OpenAPI docs, changeset, and integration coverage for the new endpoints.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
packages/api/src/routers/external-api/v2/savedSearches.ts Adds the saved-search CRUD router with validation, team-scoped access, source ownership checks, and full-replace update handling.
packages/api/src/controllers/savedSearch.ts Adds replacement update support and updates saved-search deletion to remove referencing alerts first.
packages/api/src/routers/external-api/v2/index.ts Registers the saved-searches router behind the existing external API middleware.

Reviews (14): Last reviewed commit: "Merge branch 'main' into feat/saved-sear..." | Re-trigger Greptile

Comment thread packages/api/src/routers/external-api/v2/savedSearches.ts Outdated
Comment thread packages/api/src/routers/external-api/v2/savedSearches.ts Outdated
ankitcharolia and others added 3 commits June 29, 2026 23:42
Adds a Source.findOne({ _id: sourceId, team: teamId }) check to both
POST and PUT handlers, returning 400 if the source does not exist or
belongs to another team. Also adds test coverage for cross-team and
non-existent sourceId scenarios.

Addresses greptile review comments on unchecked source ownership.
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found. No P0/P1 ship-blockers: every handler scopes queries to the authenticated team, input is length-bounded via zod, no injection or auth-bypass path exists, and the loose fieldsToSet typing still type-checks (so CI is not broken). The items below are correctness/reliability recommendations and nits.

🟡 P2 — recommended

  • packages/api/src/routers/external-api/v2/savedSearches.ts:42 — Internally-created saved searches with whereLanguage: 'promql' or a non-empty where and no whereLanguage are returned by GET but rejected by POST/PUT, so an external client cannot round-trip-edit them without silently altering query semantics.
    • Fix: Tolerate promql and where-without-whereLanguage on the write path, or document such searches as read-only through the external API.
    • adversarial, api-contract
  • packages/api/src/controllers/savedSearch.ts:71 — The reordered cascade delete removes referencing alerts before the search with no transaction, so a failure at the findOneAndDelete step leaves the search live but its alerts permanently gone, silently disabling its monitoring.
    • Fix: Wrap both deletes in a mongoose session/transaction, or guarantee the delete is retried to convergence and document that requirement.
    • reliability, adversarial
  • packages/api/src/routers/external-api/v2/savedSearches.ts:339 — The OpenAPI docs declare application/json Error bodies for 401/403/404, but the handlers use res.sendStatus(), which returns a plain-text body with no { message } payload.
    • Fix: Return res.status(n).json({ message }) for these paths, or update the OpenAPI responses to document an empty/plain body.
    • api-contract
  • packages/api/src/routers/external-api/__tests__/savedSearches.test.ts:729 — No test asserts cross-team alert-cascade isolation on DELETE; the reorder now relies solely on the untested team filter inside deleteSavedSearchAlerts.
    • Fix: Add a case creating another team's alert that references the same savedSearch, delete as the current team (expect 404), and assert the other team's alert still exists.
    • testing
🔵 P3 nitpicks (7)
  • packages/api/src/routers/external-api/v2/savedSearches.ts:596fieldsToSet typed as Record<string, unknown> erases the replaceSavedSearch typed contract and masks a POST-vs-PUT inconsistency: POST stores updatedBy via userId?.toString() (string) while PUT assigns the raw ObjectId; runtime is safe because mongoose accepts an ObjectId ref.
    • Fix: Type fieldsToSet as Partial<SavedSearchWithoutId & { updatedBy?: string }> and set updatedBy: userId?.toString() to match the create path.
  • packages/api/src/routers/external-api/v2/savedSearches.ts:126 — The field definitions (maxLength/enum/example) are hand-written across the SavedSearch, CreateSavedSearchRequest, and UpdateSavedSearchRequest OpenAPI schemas plus the zod bodySchema, so a bound change must land in four places and can silently drift.
    • Fix: Compose the request schemas from a shared component via $ref/allOf, keeping only create/update deltas inline.
  • packages/api/src/routers/external-api/v2/savedSearches.ts:24boundedFilterSchema re-implements the shared FilterSchema by hand purely to add .max() bounds, so a new filter type or operator added upstream would silently diverge.
    • Fix: Derive the bounded schema from the shared FilterSchema union rather than re-declaring its shape.
  • packages/api/src/routers/external-api/v2/savedSearches.ts:336GET / runs SavedSearch.find({ team }).sort({ name: 1 }) with no limit or pagination, returning an unbounded result set; impact is low at realistic per-team sizes.
    • Fix: Add a bounded default page size and cursor/offset pagination consistent with other list endpoints.
  • packages/api/src/controllers/savedSearch.ts:71 — The alert-first delete ordering's partial-failure/retry claim is asserted only in a comment, with no fault-injection test.
    • Fix: Add a controller-level test mocking findOneAndDelete to reject after deleteSavedSearchAlerts resolves, asserting error propagation and safe retryability.
  • packages/api/src/routers/external-api/v2/savedSearches.ts:338 — The teamId == null403 guard is duplicated in all five handlers (pre-existing pattern shared with sibling external-api routers).
    • Fix: Extract a requireTeam middleware that attaches a non-null teamId and 403s once, then drop the per-handler guard.
  • packages/api/src/mcp/tools/savedSearches/index.ts — The new external DELETE has no MCP counterpart (get + save tools exist, but no clickstack_delete_saved_search), so an agent has create/read/update but not delete parity; pre-existing gap surfaced by this PR.
    • Fix: File a follow-up to add a deleteSavedSearch MCP tool mirroring the existing deleteDashboard pattern.

Reviewers (12): correctness, security, adversarial, api-contract, reliability, kieran-typescript, testing, maintainability, project-standards, performance, agent-native, learnings-researcher.

Testing gaps:

  • Cross-team alert-cascade isolation on DELETE is unverified (P2 above).
  • Delete partial-failure state (alerts gone, search survives) has no fault-injection coverage.
  • No round-trip test for internally-created searches with whereLanguage: 'promql', non-empty where without whereLanguage, or fields exceeding external maxLength — all rejected by the external write schema.
  • No test asserts the zod bounds match the documented OpenAPI maxLength/maxItems values, so doc↔enforcement drift would go undetected.

Notes: security and performance returned no findings. project-standards confirmed the changeset and router conventions match the connections.ts reference; its only flag (setBusinessContext instrumentation) is a pre-existing directory-wide convention and was dropped. No prior learnings exist in docs/solutions/ for this area.

ankitcharolia and others added 9 commits July 7, 2026 11:34
- DELETE now routes through the deleteSavedSearch controller so alerts that
  reference the search are cascade-deleted instead of orphaned
- create/update reuse controllers / set updatedBy so createdBy/updatedBy audit
  metadata is persisted consistently with the internal API
- body schema defaults where/select/tags to empty values (matching the
  internal invariant) and reuses the shared whereLanguage enum; accepts filters
- PUT now has deterministic full-replace semantics (omitted optional fields are
  cleared) and is documented + tested
- define OpenAPI component schemas (fixes dangling CreateSavedSearchRequest
  $ref) and register the Saved Searches swagger tag
- sourceId-not-found now returns { message } for a consistent error contract;
  extracted assertOwnedSource helper
- add changeset for @hyperdx/api
- add tests: zod limits, whereLanguage enum rejection, invalid-id 400 on
  PUT/DELETE, PUT field-omission semantics, alert cascade, empty delete body
…t filter schema

Addresses follow-up greptile review:
- add filters create/persist/GET round-trip test + malformed-filters rejection
- add PUT test asserting explicitly-provided whereLanguage/orderBy/filters persist ($set branch)
- assert GET list is sorted by name ascending across multiple documents
- define a SavedSearchFilter OpenAPI component matching FilterSchema and
  reference it from the filters arrays instead of a bare 'type: object'
…d 403 docs

Addresses latest greptile P2/P3 review:
- filters now use a local boundedFilterSchema with .max(8192) on all string
  fields so 100 filters can't persist multi-MB documents
- removed dead updateSavedSearch import
- renamed assertOwnedSource → isSourceOwnedByTeam (predicate naming)
- added '403' response to all OpenAPI endpoint blocks
- new tests: sql_ast filter round-trip, PUT clears previously-set filters,
  select/orderBy/tag-length/filter-count negative validation
…s non-empty, add filter-length tests

Addresses latest greptile P2:
- fix \\ →  in four 403 JSDoc blocks so openapi.json generates
  valid JSON References instead of unresolvable escaped strings
- add .refine() to bodySchema requiring whereLanguage when where is non-empty,
  preventing downstream renderWhere from defaulting a Lucene expression to SQL
- add rejection tests for over-length filter condition/left/right (8192 cap)
  and for non-empty where submitted without whereLanguage
…im, existence-first PUT

P2 fixes:
- Extracted replaceSavedSearch controller (mirrors updateConnection unsetFields
  pattern) so PUT no longer hand-rolls / inline
- Added maxLength/maxItems to all OpenAPI request/response schemas matching
  the zod bounds (name: 1024, where: 8192, select: 4096, orderBy: 1024,
  tags: 50×32, filters: 100)
- Documented full-replace semantics prominently and sourceId-change caveat

P3 fixes:
- PUT now checks saved-search existence before sourceId ownership (404 before 400)
- Added .trim() to name so whitespace-only names are rejected (parity with internal)
- Added 'Required when where is non-empty' to whereLanguage OpenAPI description
- Documented sourceId-repoint risk in UpdateSavedSearchRequest
- Added alert cross-reference guidance to SavedSearch.teamId description

New tests:
- whitespace-only name rejected
- PUT rejects non-empty where without whereLanguage
- PUT persists filters: [] as empty array (not unset)
- PUT returns 404 before 400 when search doesn't exist (existence-first)
…CLEARABLE, minLength, tests

P2 fixes:
- DELETE cascade: alerts are now deleted before the search document so a
  partial failure doesn't orphan alerts (search still exists for retry)
- boundedFilterSchema drift: CLEARABLE_ON_REPLACE is now typed as
  (keyof BodyOutput)[] so a field rename breaks compilation
- replaceSavedSearch keeps typed fieldsToSet for compile-time checking

P3 fixes:
- whereLanguage now uses SearchConditionTrimmedLanguageSchema (excludes
  promql) preventing un-renderable PromQL queries against log/trace sources
- Added minLength: 1 to name in all three OpenAPI schemas
- Improved clears-filters assertion to distinguish unset from empty-array
- OpenAPI whereLanguage enum updated to [lucene, sql] only

New tests:
- createdBy/createdAt preserved across PUT replace
- whereLanguage: 'promql' rejected on PUT (400)
@pulpdrew

pulpdrew commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Hey @ankitcharolia - thank you for the contribution! Unfortunately it seems like a team member was concurrently working on the same thing in #2586 as part of ongoing terraform-provider work. We will likely move ahead with #2586 and close this PR, but if you have any thoughts on #2586 or think it will not cover your use-case, please let us know or feel free to comment there.

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.

feat: add CRUD REST API for saved searches (/api/v2/saved-searches)

2 participants