From 99042cf296e3f62f2a6b7f02d428720035684f7b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 14 Aug 2026 02:10:14 +0200 Subject: [PATCH] refactor(api): move query-engine to a private internal tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The queryEngine group was the largest and busiest thing left on the public `/api/*` surface — 62 endpoints, ~4,500 requests/week — and `/docs` published every one of them as browsable public API. It is not public API: across 30 days and all 62 endpoints, production telemetry (`maple.auth.method`) recorded exactly one API-key request. Everything else is a Clerk session. Publishing it costs real freedom. `docs/http-api-migration.md` already forbids a generic public query endpoint, because those contracts freeze Maple's storage and dashboard internals into the public API. The group now serves from its own `HttpApi` (`MapleInternalApi`) at `/internal/query-engine`, behind a session-only `SessionAuthorization`. The boundary is policy, not protocol: the typed contract, atom retention, the execute batcher and the v1 error envelope are all unchanged, so the frontend keeps working exactly as before. Being a separate `HttpApi` is what drops it from `/docs`, which is generated from `MapleApi`. `SessionAuthorization` refuses an API-key-shaped bearer on the `maple_ak_` prefix rather than by resolving it, so a rejected key costs no Postgres dial on what is the busiest path in the API. The refusal is a typed 403 pointing at `/v2` — a bare 401 would read as "your key is broken" and send people to rotate a key that is fine. Also removes four endpoints that reached zero traffic days ago: - `execute`, superseded by the batcher in dc21b942b1. The batcher has no single-request fallback at any size, so nothing called it any more. - `service-dependencies`, `service-db-edges`, `service-platforms`, superseded by `serviceMapBundle` in a3128de0de, which runs the same registry queries in-process. `QueryEngineService.execute` stays — the batch fan-out, the serviceDetailOverview bundle and six v2 telemetry handlers still call it in-process. `runWarehouseQuery` now accepts either v1 client and provides both layers: it is shared with the session-replay adapters, which are still on `/api`. It narrows again when sessionReplays moves. --- .../{v1 => internal}/query-engine.http.ts | 34 +---------- apps/api/src/routes/query-engine-batch.ts | 2 +- apps/api/src/runtime/http-graph.ts | 26 +++++++-- .../auth/SessionAuthorizationLayer.ts | 58 +++++++++++++++++++ .../web/src/api/warehouse/cloudflare-infra.ts | 19 +++--- apps/web/src/api/warehouse/custom-charts.ts | 4 +- apps/web/src/api/warehouse/effect-utils.ts | 23 ++++++-- apps/web/src/api/warehouse/error-rates.ts | 4 +- apps/web/src/api/warehouse/errors.ts | 10 ++-- apps/web/src/api/warehouse/execute-batcher.ts | 6 +- apps/web/src/api/warehouse/infra.ts | 36 ++++++------ apps/web/src/api/warehouse/logs.ts | 6 +- apps/web/src/api/warehouse/metrics.ts | 6 +- .../src/api/warehouse/planetscale-infra.ts | 4 +- apps/web/src/api/warehouse/raw-sql-chart.ts | 4 +- apps/web/src/api/warehouse/service-infra.ts | 4 +- apps/web/src/api/warehouse/service-map.ts | 14 ++--- .../src/api/warehouse/service-operations.ts | 4 +- apps/web/src/api/warehouse/service-usage.ts | 4 +- apps/web/src/api/warehouse/services.ts | 10 ++-- apps/web/src/api/warehouse/traces.ts | 6 +- apps/web/src/api/warehouse/web-analytics.ts | 12 ++-- apps/web/src/lib/registry.ts | 8 ++- .../services/common/internal-atom-client.ts | 29 ++++++++++ docs/api-v2.md | 14 ++--- docs/http-api-migration.md | 22 +++---- packages/domain/src/http/api.ts | 2 - packages/domain/src/http/current-tenant.ts | 43 ++++++++++++++ packages/domain/src/http/index.ts | 1 + packages/domain/src/http/internal-api.ts | 34 +++++++++++ packages/domain/src/http/query-engine.ts | 40 ++----------- .../query-engine/src/ch/queries/traces.ts | 2 +- 32 files changed, 317 insertions(+), 174 deletions(-) rename apps/api/src/routes/{v1 => internal}/query-engine.http.ts (98%) create mode 100644 apps/api/src/services/auth/SessionAuthorizationLayer.ts create mode 100644 apps/web/src/lib/services/common/internal-atom-client.ts create mode 100644 packages/domain/src/http/internal-api.ts diff --git a/apps/api/src/routes/v1/query-engine.http.ts b/apps/api/src/routes/internal/query-engine.http.ts similarity index 98% rename from apps/api/src/routes/v1/query-engine.http.ts rename to apps/api/src/routes/internal/query-engine.http.ts index 89f6329ca..dc63bac24 100644 --- a/apps/api/src/routes/v1/query-engine.http.ts +++ b/apps/api/src/routes/internal/query-engine.http.ts @@ -1,7 +1,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { CurrentTenant, - MapleApi, + MapleInternalApi, RawSqlExecuteResponse, type RawSqlValidationError, SpanHierarchyResponse, @@ -15,8 +15,6 @@ import { ServiceHealthSnapshotResponse, ServiceHealthBaselineResponse, ServiceApdexResponse, - ServiceDependenciesResponse, - ServiceDbEdgesResponse, PlanetScaleInfraTimeseriesResponse, ServiceCloudflareStatsResponse, ServicePlanetScaleStatsResponse, @@ -33,7 +31,6 @@ import { ServiceDetailOverviewResponse, ServiceDependenciesBundleResponse, ServiceMapBundleResponse, - ServicePlatformsResponse, ServiceWorkloadsResponse, ServiceUsageResponse, ServiceOperationsResponse, @@ -271,7 +268,7 @@ const toServiceWorkloadRow = (row: CH.ServiceWorkloadsOutput) => ({ row.avgMemoryLimitUtilization == null ? null : Number(row.avgMemoryLimitUtilization), }) -export const HttpQueryEngineLive = HttpApiBuilder.group(MapleApi, "queryEngine", (handlers) => +export const HttpQueryEngineLive = HttpApiBuilder.group(MapleInternalApi, "queryEngine", (handlers) => Effect.gen(function* () { const queryEngine = yield* QueryEngineService const warehouse = yield* WarehouseQueryService @@ -283,12 +280,6 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleApi, "queryEngine", >(warehouse) return handlers - .handle("execute", ({ payload }) => - Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context - return yield* queryEngine.execute(tenant, payload) - }), - ) .handle("executeBatch", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context @@ -503,20 +494,6 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleApi, "queryEngine", }) }), ) - .handle("serviceDependencies", ({ payload }) => - Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context - const rows = yield* runQuery(Queries.serviceDependencies, tenant, payload) - return new ServiceDependenciesResponse({ data: rows.map((row) => ({ ...row })) }) - }), - ) - .handle("serviceDbEdges", ({ payload }) => - Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context - const rows = yield* runQuery(Queries.serviceDbEdges, tenant, payload) - return new ServiceDbEdgesResponse({ data: rows.map((row) => ({ ...row })) }) - }), - ) .handle("serviceCloudflareStats", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context @@ -1073,13 +1050,6 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleApi, "queryEngine", }) }), ) - .handle("servicePlatforms", ({ payload }) => - Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context - const rows = yield* runQuery(Queries.servicePlatforms, tenant, payload) - return new ServicePlatformsResponse({ data: rows.map(toServicePlatformRow) }) - }), - ) .handle("serviceWorkloads", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context diff --git a/apps/api/src/routes/query-engine-batch.ts b/apps/api/src/routes/query-engine-batch.ts index 8d71a00ee..d3b90863d 100644 --- a/apps/api/src/routes/query-engine-batch.ts +++ b/apps/api/src/routes/query-engine-batch.ts @@ -3,7 +3,7 @@ import { Clock, Duration, Effect } from "effect" import { QueryEngineTimeoutError, type SelfDescribingHttpError } from "@maple/domain/http" /** - * Fan-out for `POST /api/query-engine/execute-batch`. + * Fan-out for `POST /internal/query-engine/execute-batch`. * * Extracted from the route handler so the two things that actually carry risk — * the shared deadline and per-item failure isolation — are testable without an diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index 0727fe91f..f2d0739a9 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -1,4 +1,4 @@ -import { MapleApi } from "@maple/domain/http" +import { MapleApi, MapleInternalApi } from "@maple/domain/http" import { MapleApiV2 } from "@maple/domain/http/v2" import { Layer } from "effect" import { Headers, HttpMiddleware, HttpRouter, HttpServerResponse } from "effect/unstable/http" @@ -25,7 +25,7 @@ import { HttpOrgClickHouseSettingsLive } from "@/routes/v1/org-clickhouse-settin import { HttpOrganizationsLive } from "@/routes/v1/organizations.http" import { PlanetScaleWebhookRouter } from "@/routes/v1/planetscale-webhook.http" import { PrometheusScrapeProxyRouter } from "@/routes/v1/prometheus-scrape-proxy.http" -import { HttpQueryEngineLive } from "@/routes/v1/query-engine.http" +import { HttpQueryEngineLive } from "@/routes/internal/query-engine.http" import { ScraperInternalRouter } from "@/routes/v1/scraper-internal.http" import { HttpSessionReplaysLive } from "@/routes/v1/session-replay.http" import { SlackCallbackRouter, SlackInternalRouter } from "@/routes/v1/slack-integration.http" @@ -58,6 +58,7 @@ import { } from "@/routes/v2/telemetry.http" import { ApiAuthorizationLayer } from "@/services/auth/ApiAuthorizationLayer" import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer" +import { SessionAuthorizationLayer } from "@/services/auth/SessionAuthorizationLayer" import { ApiV2RateLimiter } from "@/services/auth/ApiV2RateLimiter" import { ApiKeysService } from "@/services/org/ApiKeysService" @@ -91,7 +92,19 @@ const ApiRoutes = HttpApiBuilder.layer(MapleApi).pipe( Layer.provide(HttpOnboardingLive), Layer.provide(HttpOrgClickHouseSettingsLive), Layer.provide(HttpOrganizationsLive), - Layer.provide(Layer.mergeAll(HttpQueryEngineLive, HttpSessionReplaysLive, HttpWarehouseLive)), + Layer.provide(Layer.mergeAll(HttpSessionReplaysLive, HttpWarehouseLive)), + Layer.provide(V1ErrorBoundaryLive), +) + +/** + * The dashboard's private transport, served under `/internal/*`. + * + * Session-only: `SessionAuthorizationLayer` refuses API-key-shaped bearers, so + * nothing here is reachable as public API. It is also absent from `/docs`, + * which is generated from `MapleApi`. + */ +const ApiInternalRoutes = HttpApiBuilder.layer(MapleInternalApi).pipe( + Layer.provide(HttpQueryEngineLive), Layer.provide(V1ErrorBoundaryLive), ) @@ -128,6 +141,7 @@ const ApiV2Routes = HttpApiBuilder.layer(MapleApiV2).pipe( export const AllRoutes = Layer.mergeAll( ApiRoutes, + ApiInternalRoutes, ApiV2Routes, ChatSessionsRouter, IntegrationsCallbackRouter, @@ -144,7 +158,11 @@ export const AllRoutes = Layer.mergeAll( DocsV2Route, ).pipe(Layer.provideMerge(HttpRouter.cors(API_CORS_OPTIONS))) -export const ApiAuthLive = Layer.mergeAll(ApiAuthorizationLayer, ApiAuthorizationV2Layer).pipe( +export const ApiAuthLive = Layer.mergeAll( + ApiAuthorizationLayer, + ApiAuthorizationV2Layer, + SessionAuthorizationLayer, +).pipe( Layer.provideMerge(ApiV2RateLimiter.layer), Layer.provideMerge(ApiKeysService.layer), Layer.provideMerge(Env.layer), diff --git a/apps/api/src/services/auth/SessionAuthorizationLayer.ts b/apps/api/src/services/auth/SessionAuthorizationLayer.ts new file mode 100644 index 000000000..3917091d7 --- /dev/null +++ b/apps/api/src/services/auth/SessionAuthorizationLayer.ts @@ -0,0 +1,58 @@ +import { HttpServerRequest } from "effect/unstable/http" +import { API_KEY_PREFIX } from "@maple/db" +import { CurrentTenant } from "@maple/domain/http" +import { Effect, Layer } from "effect" +import { makeResolveTenant } from "./AuthService" +import { annotateAuthSpan } from "@/services/auth/auth-span" +import { Env } from "@/platform/Env" + +const getBearerToken = (headers: Record): string | undefined => { + const header = headers["authorization"] ?? headers["Authorization"] + if (!header) return undefined + const [scheme, token] = header.split(" ") + if (!scheme || !token || scheme.toLowerCase() !== "bearer") return undefined + return token +} + +/** + * Authorization for the internal API: Clerk sessions only. + * + * The public `ApiAuthorizationLayer` tries an API key first and falls back to a + * session. This one inverts the posture — an API-key-shaped token is refused + * outright, because these endpoints are dashboard transport whose request and + * response shapes change with the UI. + * + * The refusal is a prefix test, not a lookup: `API_KEY_PREFIX` identifies the + * credential without consulting Postgres, so a rejected key costs no database + * dial on what is the busiest path in the API. Session tokens never match the + * prefix, so they reach `resolveTenant` exactly as before. + */ +export const SessionAuthorizationLayer = Layer.effect( + CurrentTenant.SessionAuthorization, + Effect.gen(function* () { + const env = yield* Env + const resolveTenant = makeResolveTenant(env) + + return CurrentTenant.SessionAuthorization.of({ + bearer: (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + + const token = getBearerToken(request.headers) + if (token?.startsWith(API_KEY_PREFIX)) { + return yield* new CurrentTenant.ApiKeyNotAcceptedError({ + message: "API keys cannot call the internal API; use the /v2 API instead", + }) + } + + const tenant = yield* resolveTenant(request.headers) + yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) + return yield* Effect.provideService( + httpEffect, + CurrentTenant.Context, + new CurrentTenant.TenantSchema(tenant), + ) + }), + }) + }), +) diff --git a/apps/web/src/api/warehouse/cloudflare-infra.ts b/apps/web/src/api/warehouse/cloudflare-infra.ts index 5042dbede..d7586ec1a 100644 --- a/apps/web/src/api/warehouse/cloudflare-infra.ts +++ b/apps/web/src/api/warehouse/cloudflare-infra.ts @@ -19,6 +19,7 @@ import { CloudflareTopTrafficRequest, } from "@maple/domain/http" import { MapleApiAtomClient } from "@/lib/services/common/atom-client" +import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import { WarehouseDateTimeString, decodeInput, runWarehouseQuery } from "@/api/warehouse/effect-utils" const ZONE_SERVICE_PREFIX = "cloudflare/" @@ -151,7 +152,7 @@ export const getCloudflareZones = Effect.fn("QueryEngine.getCloudflareZones")(fu const input = yield* decodeInput(TimeRangeInputSchema, data, "getCloudflareZones") const result = yield* runWarehouseQuery("cloudflareInfraZones", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.cloudflareInfraZones({ payload: new CloudflareInfraZonesRequest({ startTime: input.startTime, @@ -197,7 +198,7 @@ export const getCloudflareZoneTimeseries = Effect.fn("QueryEngine.getCloudflareZ const input = yield* decodeInput(TimeseriesInputSchema, data, "getCloudflareZoneTimeseries") const result = yield* runWarehouseQuery("cloudflareInfraZoneTimeseries", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.cloudflareInfraZoneTimeseries({ payload: new CloudflareInfraZoneTimeseriesRequest({ startTime: input.startTime, @@ -268,7 +269,7 @@ export const getCloudflareZoneDetail = Effect.fn("QueryEngine.getCloudflareZoneD const input = yield* decodeInput(ZoneDetailInputSchema, data, "getCloudflareZoneDetail") const result = yield* runWarehouseQuery("cloudflareInfraZoneDetail", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.cloudflareInfraZoneDetail({ payload: new CloudflareInfraZoneDetailRequest({ serviceName: input.serviceName, @@ -320,7 +321,7 @@ export const getCloudflareWorkers = Effect.fn("QueryEngine.getCloudflareWorkers" const input = yield* decodeInput(TimeRangeInputSchema, data, "getCloudflareWorkers") const result = yield* runWarehouseQuery("cloudflareInfraWorkers", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.cloudflareInfraWorkers({ payload: new CloudflareInfraWorkersRequest({ startTime: input.startTime, @@ -375,7 +376,7 @@ export const getCloudflareZoneSecurity = Effect.fn("QueryEngine.getCloudflareZon const input = yield* decodeInput(ZoneDetailInputSchema, data, "getCloudflareZoneSecurity") const result = yield* runWarehouseQuery("cloudflareInfraZoneSecurity", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.cloudflareInfraZoneSecurity({ payload: new CloudflareInfraZoneSecurityRequest({ serviceName: input.serviceName, @@ -429,7 +430,7 @@ export const getCloudflareZoneDns = Effect.fn("QueryEngine.getCloudflareZoneDns" const input = yield* decodeInput(ZoneDetailInputSchema, data, "getCloudflareZoneDns") const result = yield* runWarehouseQuery("cloudflareInfraZoneDns", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.cloudflareInfraZoneDns({ payload: new CloudflareInfraZoneDnsRequest({ serviceName: input.serviceName, @@ -487,7 +488,7 @@ export const getCloudflarePlatformResources = Effect.fn("QueryEngine.getCloudfla const input = yield* decodeInput(TimeRangeInputSchema, data, "getCloudflarePlatformResources") const result = yield* runWarehouseQuery("cloudflareInfraPlatformResources", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.cloudflareInfraPlatformResources({ payload: new CloudflareInfraPlatformResourcesRequest({ startTime: input.startTime, @@ -599,7 +600,7 @@ export const getCloudflareZoneBreakdown = Effect.fn("QueryEngine.getCloudflareZo const input = yield* decodeInput(BreakdownInputSchema, data, "getCloudflareZoneBreakdown") const result = yield* runWarehouseQuery("cloudflareInfraZoneBreakdown", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.cloudflareInfraZoneBreakdown({ payload: new CloudflareInfraZoneBreakdownRequest({ serviceName: input.serviceName, @@ -661,7 +662,7 @@ export const getCloudflareZoneFacets = Effect.fn("QueryEngine.getCloudflareZoneF const input = yield* decodeInput(ZoneFacetsInputSchema, data, "getCloudflareZoneFacets") return yield* runWarehouseQuery("cloudflareInfraZoneFacets", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.cloudflareInfraZoneFacets({ payload: new CloudflareInfraZoneFacetsRequest({ serviceName: input.serviceName, diff --git a/apps/web/src/api/warehouse/custom-charts.ts b/apps/web/src/api/warehouse/custom-charts.ts index 83b4c8d05..18b1902ed 100644 --- a/apps/web/src/api/warehouse/custom-charts.ts +++ b/apps/web/src/api/warehouse/custom-charts.ts @@ -32,7 +32,7 @@ import { invalidWarehouseInput, runWarehouseQuery, } from "@/api/warehouse/effect-utils" -import { MapleApiAtomClient } from "@/lib/services/common/atom-client" +import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import type { ServiceDetailTimeSeriesPoint, ServiceTimeSeriesPoint } from "@/api/warehouse/services" import { QUERY_BUILDER_DATA_SOURCES, QUERY_BUILDER_METRIC_TYPES } from "@maple/query-model" const dateTimeString = WarehouseDateTimeString @@ -868,7 +868,7 @@ const getServiceDetailOverviewEffect = Effect.fn("QueryEngine.getServiceDetailOv const result = yield* runWarehouseQuery("serviceDetailOverview", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.serviceDetailOverview({ payload: new ServiceDetailOverviewRequest({ serviceName: input.serviceName, diff --git a/apps/web/src/api/warehouse/effect-utils.ts b/apps/web/src/api/warehouse/effect-utils.ts index 4aded1e23..ec455994b 100644 --- a/apps/web/src/api/warehouse/effect-utils.ts +++ b/apps/web/src/api/warehouse/effect-utils.ts @@ -7,11 +7,17 @@ import { type DurationStats, type AttributeValueItem, } from "@maple/query-engine" -import { Effect, Schema } from "effect" +import { Effect, Layer, Schema } from "effect" import { PublicHttpErrorBodySchema, type AnyPublicHttpErrorBody } from "@maple/domain/http" import { MapleApiAtomClient } from "@/lib/services/common/atom-client" +import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" -import { mapleApiClientLayer, mapleApiV2ClientLayer, mapleRuntime } from "@/lib/registry" +import { + mapleApiClientLayer, + mapleApiV2ClientLayer, + mapleInternalClientLayer, + mapleRuntime, +} from "@/lib/registry" import { makeClientErrorBody } from "@/lib/error-messages" import { makeExecuteBatcher } from "./execute-batcher" @@ -148,15 +154,22 @@ export function decodeInput( operation: string, - execute: () => Effect.Effect, + execute: () => Effect.Effect, ): Effect.Effect { return Effect.suspend(execute).pipe( Effect.withSpan(operation), // Warehouse adapters are imperative server-function entrypoints and own this runtime layer. // oxlint-disable-next-line effecttsgo/strict-effect-provide - Effect.provide(mapleApiClientLayer), + Effect.provide(Layer.mergeAll(mapleApiClientLayer, mapleInternalClientLayer)), Effect.mapError((cause) => normalizeWarehouseError(operation, cause)), ) } @@ -198,7 +211,7 @@ export function invalidWarehouseInput( const executeBatcher = makeExecuteBatcher((requests) => mapleRuntime.runPromise( Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient const response = yield* client.queryEngine.executeBatch({ payload: new QueryEngineExecuteBatchRequest({ requests }), }) diff --git a/apps/web/src/api/warehouse/error-rates.ts b/apps/web/src/api/warehouse/error-rates.ts index 12aa1c2ac..d602e5b64 100644 --- a/apps/web/src/api/warehouse/error-rates.ts +++ b/apps/web/src/api/warehouse/error-rates.ts @@ -1,6 +1,6 @@ import { Clock, Effect, Schema } from "effect" import { ErrorRateByServiceRequest } from "@maple/domain/http" -import { MapleApiAtomClient } from "@/lib/services/common/atom-client" +import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import { WarehouseDateTimeString, decodeInput, runWarehouseQuery } from "@/api/warehouse/effect-utils" import { formatWarehouseDateTime } from "@maple/query-engine" @@ -28,7 +28,7 @@ export const getErrorRateByService = Effect.fn("QueryEngine.getErrorRateByServic const fallback = defaultTimeRange(yield* Clock.currentTimeMillis) const result = yield* runWarehouseQuery("errorRateByService", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.errorRateByService({ payload: new ErrorRateByServiceRequest({ startTime: input.startTime ?? fallback.startTime, diff --git a/apps/web/src/api/warehouse/errors.ts b/apps/web/src/api/warehouse/errors.ts index f83d57e39..673876644 100644 --- a/apps/web/src/api/warehouse/errors.ts +++ b/apps/web/src/api/warehouse/errors.ts @@ -13,7 +13,7 @@ import { FingerprintHash, ServiceName, } from "@maple/domain/http" -import { MapleApiAtomClient } from "@/lib/services/common/atom-client" +import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import { WarehouseDateTimeString, decodeInput, @@ -63,7 +63,7 @@ const getErrorsByTypeEffect = Effect.fn("QueryEngine.getErrorsByType")(function* const result = yield* runWarehouseQuery("errorsByType", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.errorsByType({ payload: new ErrorsByTypeRequest({ startTime: input.startTime ?? fallback.startTime, @@ -196,7 +196,7 @@ const getErrorsSummaryEffect = Effect.fn("QueryEngine.getErrorsSummary")(functio const result = yield* runWarehouseQuery("errorsSummary", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.errorsSummary({ payload: new ErrorsSummaryRequest({ startTime: input.startTime ?? fallback.startTime, @@ -260,7 +260,7 @@ const getErrorDetailTracesEffect = Effect.fn("QueryEngine.getErrorDetailTraces") const result = yield* runWarehouseQuery("errorDetailTraces", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.errorDetailTraces({ payload: new ErrorDetailTracesRequest({ startTime: input.startTime ?? fallback.startTime, @@ -317,7 +317,7 @@ const getErrorsTimeseriesEffect = Effect.fn("QueryEngine.getErrorsTimeseries")(f const result = yield* runWarehouseQuery("errorsTimeseries", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.errorsTimeseries({ payload: new ErrorsTimeseriesRequest({ startTime: input.startTime ?? fallback.startTime, diff --git a/apps/web/src/api/warehouse/execute-batcher.ts b/apps/web/src/api/warehouse/execute-batcher.ts index e27dea798..f2285c89d 100644 --- a/apps/web/src/api/warehouse/execute-batcher.ts +++ b/apps/web/src/api/warehouse/execute-batcher.ts @@ -6,7 +6,11 @@ import { } from "@maple/query-engine" /** - * Coalesces `/api/query-engine/execute` calls into `/execute-batch`. + * Coalesces individual query executions into `/internal/query-engine/execute-batch`. + * + * This is now the only way a query reaches the warehouse from the browser — the + * single-query `/execute` endpoint was removed once this batcher had carried all + * traffic for a full release. * * Every warehouse module funnels through one choke point, and each atom runs on * its own fiber, so a render pass used to emit one POST per query — each with diff --git a/apps/web/src/api/warehouse/infra.ts b/apps/web/src/api/warehouse/infra.ts index dc5b2d80e..8deeedcff 100644 --- a/apps/web/src/api/warehouse/infra.ts +++ b/apps/web/src/api/warehouse/infra.ts @@ -35,7 +35,7 @@ import { type WorkloadFacetsResponse, } from "@maple/domain/http" import { Effect } from "effect" -import { MapleApiAtomClient } from "@/lib/services/common/atom-client" +import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import { runWarehouseQuery } from "./effect-utils" export type WorkloadKind = "deployment" | "statefulset" | "daemonset" @@ -59,7 +59,7 @@ export interface ListHostsInput { export function listHosts({ data }: { data: ListHostsInput }) { return runWarehouseQuery("listHosts", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient const response: ListHostsResponse = yield* client.queryEngine.listHosts({ payload: new ListHostsRequest({ startTime: data.startTime, @@ -83,7 +83,7 @@ export interface HostDetailSummaryInput { export function hostDetailSummary({ data }: { data: HostDetailSummaryInput }) { return runWarehouseQuery("hostDetailSummary", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient const response: HostDetailSummaryResponse = yield* client.queryEngine.hostDetailSummary({ payload: new HostDetailSummaryRequest({ startTime: data.startTime, @@ -115,7 +115,7 @@ export interface FleetUtilizationTimeseriesInput { export function fleetUtilizationTimeseries({ data }: { data: FleetUtilizationTimeseriesInput }) { return runWarehouseQuery("fleetUtilizationTimeseries", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient const response: FleetUtilizationTimeseriesResponse = yield* client.queryEngine.fleetUtilizationTimeseries({ payload: new FleetUtilizationTimeseriesRequest({ @@ -132,7 +132,7 @@ export function fleetUtilizationTimeseries({ data }: { data: FleetUtilizationTim export function hostInfraTimeseries({ data }: { data: HostInfraTimeseriesInput }) { return runWarehouseQuery("hostInfraTimeseries", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient const response: HostInfraTimeseriesResponse = yield* client.queryEngine.hostInfraTimeseries({ payload: new HostInfraTimeseriesRequest({ startTime: data.startTime, @@ -173,7 +173,7 @@ export interface ListPodsInput { export function listPods({ data }: { data: ListPodsInput }) { return runWarehouseQuery("listPods", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient const response: ListPodsResponse = yield* client.queryEngine.listPods({ payload: new ListPodsRequest({ startTime: data.startTime, @@ -219,7 +219,7 @@ export interface PodsSummaryInput { export function podsSummary({ data }: { data: PodsSummaryInput }) { return runWarehouseQuery("podsSummary", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient const response: PodsSummaryResponse = yield* client.queryEngine.podsSummary({ payload: new PodsSummaryRequest({ startTime: data.startTime, @@ -253,7 +253,7 @@ export interface PodFacetsInput { export function getPodFacets({ data }: { data: PodFacetsInput }) { return runWarehouseQuery("podFacets", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient const response: PodFacetsResponse = yield* client.queryEngine.podFacets({ payload: new PodFacetsRequest({ startTime: data.startTime, @@ -286,7 +286,7 @@ export interface PodDetailSummaryInput { export function podDetailSummary({ data }: { data: PodDetailSummaryInput }) { return runWarehouseQuery("podDetailSummary", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient const response: PodDetailSummaryResponse = yield* client.queryEngine.podDetailSummary({ payload: new PodDetailSummaryRequest({ startTime: data.startTime, @@ -314,7 +314,7 @@ export interface PodInfraTimeseriesInput { export function podInfraTimeseries({ data }: { data: PodInfraTimeseriesInput }) { return runWarehouseQuery("podInfraTimeseries", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient const response: PodInfraTimeseriesResponse = yield* client.queryEngine.podInfraTimeseries({ payload: new PodInfraTimeseriesRequest({ startTime: data.startTime, @@ -344,7 +344,7 @@ export interface ListNodesInput { export function listNodes({ data }: { data: ListNodesInput }) { return runWarehouseQuery("listNodes", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient const response: ListNodesResponse = yield* client.queryEngine.listNodes({ payload: new ListNodesRequest({ startTime: data.startTime, @@ -374,7 +374,7 @@ export interface NodeFacetsInput { export function getNodeFacets({ data }: { data: NodeFacetsInput }) { return runWarehouseQuery("nodeFacets", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient const response: NodeFacetsResponse = yield* client.queryEngine.nodeFacets({ payload: new NodeFacetsRequest({ startTime: data.startTime, @@ -399,7 +399,7 @@ export interface NodeDetailSummaryInput { export function nodeDetailSummary({ data }: { data: NodeDetailSummaryInput }) { return runWarehouseQuery("nodeDetailSummary", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient const response: NodeDetailSummaryResponse = yield* client.queryEngine.nodeDetailSummary({ payload: new NodeDetailSummaryRequest({ startTime: data.startTime, @@ -425,7 +425,7 @@ export interface NodeInfraTimeseriesInput { export function nodeInfraTimeseries({ data }: { data: NodeInfraTimeseriesInput }) { return runWarehouseQuery("nodeInfraTimeseries", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient const response: NodeInfraTimeseriesResponse = yield* client.queryEngine.nodeInfraTimeseries({ payload: new NodeInfraTimeseriesRequest({ startTime: data.startTime, @@ -459,7 +459,7 @@ export interface ListWorkloadsInput { export function listWorkloads({ data }: { data: ListWorkloadsInput }) { return runWarehouseQuery("listWorkloads", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient const response: ListWorkloadsResponse = yield* client.queryEngine.listWorkloads({ payload: new ListWorkloadsRequest({ startTime: data.startTime, @@ -495,7 +495,7 @@ export interface WorkloadFacetsInput { export function getWorkloadFacets({ data }: { data: WorkloadFacetsInput }) { return runWarehouseQuery("workloadFacets", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient const response: WorkloadFacetsResponse = yield* client.queryEngine.workloadFacets({ payload: new WorkloadFacetsRequest({ startTime: data.startTime, @@ -525,7 +525,7 @@ export interface WorkloadDetailSummaryInput { export function workloadDetailSummary({ data }: { data: WorkloadDetailSummaryInput }) { return runWarehouseQuery("workloadDetailSummary", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient const response: WorkloadDetailSummaryResponse = yield* client.queryEngine.workloadDetailSummary({ payload: new WorkloadDetailSummaryRequest({ startTime: data.startTime, @@ -556,7 +556,7 @@ export interface WorkloadInfraTimeseriesInput { export function workloadInfraTimeseries({ data }: { data: WorkloadInfraTimeseriesInput }) { return runWarehouseQuery("workloadInfraTimeseries", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient const response: WorkloadInfraTimeseriesResponse = yield* client.queryEngine.workloadInfraTimeseries({ payload: new WorkloadInfraTimeseriesRequest({ diff --git a/apps/web/src/api/warehouse/logs.ts b/apps/web/src/api/warehouse/logs.ts index d041dd68a..fb8f0b653 100644 --- a/apps/web/src/api/warehouse/logs.ts +++ b/apps/web/src/api/warehouse/logs.ts @@ -8,7 +8,7 @@ import { ServiceName, ServiceNamespace, } from "@maple/domain/http" -import { MapleApiAtomClient } from "@/lib/services/common/atom-client" +import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import { WarehouseDateTimeString, decodeInput, @@ -105,7 +105,7 @@ const listLogsEffect = Effect.fn("QueryEngine.listLogs")(function* ({ data }: { const logsResult = yield* runWarehouseQuery("listLogs", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.listLogs({ payload: new ListLogsRequest({ startTime: input.startTime ?? fallback.startTime, @@ -165,7 +165,7 @@ const getLogEffect = Effect.fn("QueryEngine.getLog")(function* ({ data }: { data const response = yield* runWarehouseQuery("getLog", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.getLog({ payload: new GetLogRequest({ timestamp: input.timestamp, diff --git a/apps/web/src/api/warehouse/metrics.ts b/apps/web/src/api/warehouse/metrics.ts index 9f1b29813..47844d896 100644 --- a/apps/web/src/api/warehouse/metrics.ts +++ b/apps/web/src/api/warehouse/metrics.ts @@ -1,7 +1,7 @@ import { QueryEngineExecuteRequest, formatWarehouseDateTime } from "@maple/query-engine" import { Clock, Effect, Schema } from "effect" import { ListMetricsRequest, MetricName, MetricsSummaryRequest, ServiceName } from "@maple/domain/http" -import { MapleApiAtomClient } from "@/lib/services/common/atom-client" +import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import { WarehouseDateTimeString, decodeInput, @@ -67,7 +67,7 @@ const listMetricsEffect = Effect.fn("QueryEngine.listMetrics")(function* ({ const result = yield* runWarehouseQuery("listMetrics", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.listMetrics({ payload: new ListMetricsRequest({ startTime: input.startTime ?? fallback.startTime, @@ -174,7 +174,7 @@ const getMetricsSummaryEffect = Effect.fn("QueryEngine.getMetricsSummary")(funct const fallback = defaultTimeRange(yield* Clock.currentTimeMillis) const result = yield* runWarehouseQuery("metricsSummary", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.metricsSummary({ payload: new MetricsSummaryRequest({ startTime: input.startTime ?? fallback.startTime, diff --git a/apps/web/src/api/warehouse/planetscale-infra.ts b/apps/web/src/api/warehouse/planetscale-infra.ts index 1d199b9c3..15e40318e 100644 --- a/apps/web/src/api/warehouse/planetscale-infra.ts +++ b/apps/web/src/api/warehouse/planetscale-infra.ts @@ -1,6 +1,6 @@ import { Clock, Effect, Schema } from "effect" import { PlanetScaleInfraTimeseriesRequest } from "@maple/domain/http" -import { MapleApiAtomClient } from "@/lib/services/common/atom-client" +import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { WarehouseDateTimeString, @@ -209,7 +209,7 @@ export const getPlanetScaleInfraTimeseries = Effect.fn("QueryEngine.getPlanetSca const result = yield* runWarehouseQuery("planetscaleInfraTimeseries", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.planetscaleInfraTimeseries({ payload: new PlanetScaleInfraTimeseriesRequest({ startTime: input.startTime ?? fallback.startTime, diff --git a/apps/web/src/api/warehouse/raw-sql-chart.ts b/apps/web/src/api/warehouse/raw-sql-chart.ts index efa9189d9..62a623672 100644 --- a/apps/web/src/api/warehouse/raw-sql-chart.ts +++ b/apps/web/src/api/warehouse/raw-sql-chart.ts @@ -1,6 +1,6 @@ import { Effect, Schema } from "effect" import { RawSqlExecuteRequest, RawSqlDisplayType } from "@maple/domain/http" -import { MapleApiAtomClient } from "@/lib/services/common/atom-client" +import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import { WarehouseDateTimeString, decodeInput, runWarehouseQuery } from "@/api/warehouse/effect-utils" // Raw SQL chart server function (widget data source `raw_sql_chart`). @@ -108,7 +108,7 @@ export const getRawSqlChart = Effect.fn("QueryEngine.getRawSqlChart")(function* const result = yield* runWarehouseQuery("rawSqlChart", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.executeRawSql({ payload: new RawSqlExecuteRequest({ sql: input.sql, diff --git a/apps/web/src/api/warehouse/service-infra.ts b/apps/web/src/api/warehouse/service-infra.ts index 830592468..3033c9c5c 100644 --- a/apps/web/src/api/warehouse/service-infra.ts +++ b/apps/web/src/api/warehouse/service-infra.ts @@ -1,6 +1,6 @@ import { Effect, Schema } from "effect" import { ServiceName, ServiceWorkloadsRequest } from "@maple/domain/http" -import { MapleApiAtomClient } from "@/lib/services/common/atom-client" +import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import { WarehouseDateTimeString, decodeInput, runWarehouseQuery } from "@/api/warehouse/effect-utils" type ServiceWorkloadKind = "deployment" | "statefulset" | "daemonset" | "unknown" @@ -41,7 +41,7 @@ export const getServiceWorkloads = Effect.fn("QueryEngine.getServiceWorkloads")( const result = yield* runWarehouseQuery("serviceWorkloads", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.serviceWorkloads({ payload: new ServiceWorkloadsRequest({ startTime: input.startTime, diff --git a/apps/web/src/api/warehouse/service-map.ts b/apps/web/src/api/warehouse/service-map.ts index 078a87382..64ee7d687 100644 --- a/apps/web/src/api/warehouse/service-map.ts +++ b/apps/web/src/api/warehouse/service-map.ts @@ -8,7 +8,7 @@ import { ServiceName, ServicePlanetScaleStatsRequest, } from "@maple/domain/http" -import { MapleApiAtomClient } from "@/lib/services/common/atom-client" +import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import { summarizeSampling } from "@/lib/sampling" import { WarehouseDateTimeString, decodeInput, runWarehouseQuery } from "@/api/warehouse/effect-utils" import { transformExternalEdge } from "@/api/warehouse/service-external-edges" @@ -163,7 +163,7 @@ export const getServiceDependenciesBundle = Effect.fn("QueryEngine.getServiceDep const result = yield* runWarehouseQuery("serviceDependenciesBundle", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.serviceDependenciesBundle({ payload: new ServiceDependenciesBundleRequest({ serviceName: input.serviceName, @@ -260,7 +260,7 @@ export const getServiceMapCloudflare = Effect.fn("QueryEngine.getServiceMapCloud const result = yield* runWarehouseQuery("serviceCloudflareStats", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.serviceCloudflareStats({ payload: new ServiceCloudflareStatsRequest({ startTime: input.startTime ?? fallback.startTime, @@ -328,7 +328,7 @@ export const getServiceMapPlanetScale = Effect.fn("QueryEngine.getServiceMapPlan const result = yield* runWarehouseQuery("servicePlanetScaleStats", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.servicePlanetScaleStats({ payload: new ServicePlanetScaleStatsRequest({ startTime: input.startTime ?? fallback.startTime, @@ -361,7 +361,7 @@ export const getPlanetScaleBranchStats = Effect.fn("QueryEngine.getPlanetScaleBr const result = yield* runWarehouseQuery("planetscaleBranchStats", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.servicePlanetScaleStats({ payload: new ServicePlanetScaleStatsRequest({ startTime: input.startTime ?? fallback.startTime, @@ -392,7 +392,7 @@ export const getServiceDbQuerySummary = Effect.fn("QueryEngine.getServiceDbQuery const result = yield* runWarehouseQuery("serviceDbQuerySummary", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.serviceDbQuerySummary({ payload: new ServiceDbQuerySummaryRequest({ dbSystem: input.dbSystem, @@ -427,7 +427,7 @@ export const getServiceMapBundle = Effect.fn("QueryEngine.getServiceMapBundle")( const result = yield* runWarehouseQuery("serviceMapBundle", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.serviceMapBundle({ payload: new ServiceMapBundleRequest({ startTime, diff --git a/apps/web/src/api/warehouse/service-operations.ts b/apps/web/src/api/warehouse/service-operations.ts index db9f31b6e..c40ccadfd 100644 --- a/apps/web/src/api/warehouse/service-operations.ts +++ b/apps/web/src/api/warehouse/service-operations.ts @@ -1,6 +1,6 @@ import { Effect, Schema } from "effect" import { DeploymentEnvironment, ServiceName, ServiceOperationsRequest } from "@maple/domain/http" -import { MapleApiAtomClient } from "@/lib/services/common/atom-client" +import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import { WarehouseDateTimeString, decodeInput, runWarehouseQuery } from "@/api/warehouse/effect-utils" export interface ServiceOperationSparklinePoint { @@ -43,7 +43,7 @@ export const getServiceOperations = Effect.fn("QueryEngine.getServiceOperations" const result = yield* runWarehouseQuery("serviceOperations", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.serviceOperations({ payload: new ServiceOperationsRequest({ serviceName: input.serviceName, diff --git a/apps/web/src/api/warehouse/service-usage.ts b/apps/web/src/api/warehouse/service-usage.ts index 50ebd6499..858ebc258 100644 --- a/apps/web/src/api/warehouse/service-usage.ts +++ b/apps/web/src/api/warehouse/service-usage.ts @@ -1,6 +1,6 @@ import { Clock, Effect, Schema } from "effect" import { ServiceName, ServiceUsageRequest } from "@maple/domain/http" -import { MapleApiAtomClient } from "@/lib/services/common/atom-client" +import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import { WarehouseDateTimeString, decodeInput, runWarehouseQuery } from "@/api/warehouse/effect-utils" import { formatWarehouseDateTime } from "@maple/query-engine" @@ -56,7 +56,7 @@ export const getServiceUsage = Effect.fn("QueryEngine.getServiceUsage")(function const result = yield* runWarehouseQuery("serviceUsage", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.serviceUsage({ payload: new ServiceUsageRequest({ startTime: input.startTime ?? fallback.startTime, diff --git a/apps/web/src/api/warehouse/services.ts b/apps/web/src/api/warehouse/services.ts index 95d1696cd..676bc32de 100644 --- a/apps/web/src/api/warehouse/services.ts +++ b/apps/web/src/api/warehouse/services.ts @@ -14,7 +14,7 @@ import { ServiceHealthSnapshotRequest, ServiceOverviewRequest, } from "@maple/domain/http" -import { MapleApiAtomClient } from "@/lib/services/common/atom-client" +import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import { buildBucketTimeline, computeBucketSeconds, @@ -166,7 +166,7 @@ const getServiceOverviewEffect = Effect.fn("QueryEngine.getServiceOverview")(fun // disagree with the env-scoped detail page. const result = yield* runWarehouseQuery("serviceOverview", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.serviceOverview({ payload: new ServiceOverviewRequest({ startTime, @@ -228,7 +228,7 @@ const getServiceHealthSnapshotEffect = Effect.fn("QueryEngine.getServiceHealthSn const response = yield* runWarehouseQuery("serviceHealthSnapshot", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.serviceHealthSnapshot({ payload: new ServiceHealthSnapshotRequest({ startTime, @@ -305,7 +305,7 @@ const getServiceHealthBaselineEffect = Effect.fn("QueryEngine.getServiceHealthBa const response = yield* runWarehouseQuery("serviceHealthBaseline", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.serviceHealthBaseline({ payload: new ServiceHealthBaselineRequest({ startTime, @@ -509,7 +509,7 @@ const getServiceApdexTimeSeriesEffect = Effect.fn("QueryEngine.getServiceApdexTi const result = yield* runWarehouseQuery("serviceApdex", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.serviceApdex({ payload: new ServiceApdexRequest({ serviceName: input.serviceName, diff --git a/apps/web/src/api/warehouse/traces.ts b/apps/web/src/api/warehouse/traces.ts index a87b55bb1..0fcc626b6 100644 --- a/apps/web/src/api/warehouse/traces.ts +++ b/apps/web/src/api/warehouse/traces.ts @@ -14,7 +14,7 @@ import { SpanHierarchyRequest, SpanName, } from "@maple/domain/http" -import { MapleApiAtomClient } from "@/lib/services/common/atom-client" +import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import { computeTraceTimeWindow } from "@/lib/trace-time-window" import { WarehouseDateTimeString, @@ -399,7 +399,7 @@ const getSpanHierarchyEffect = Effect.fn("QueryEngine.getSpanHierarchy")(functio const result = yield* runWarehouseQuery("spanHierarchy", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.spanHierarchy({ payload: new SpanHierarchyRequest({ traceId: input.traceId, @@ -459,7 +459,7 @@ const getSpanDetailEffect = Effect.fn("QueryEngine.getSpanDetail")(function* ({ const result = yield* runWarehouseQuery("spanDetail", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.spanDetail({ payload: new SpanDetailRequest({ traceId: input.traceId, diff --git a/apps/web/src/api/warehouse/web-analytics.ts b/apps/web/src/api/warehouse/web-analytics.ts index 36fc3079b..3f66d9604 100644 --- a/apps/web/src/api/warehouse/web-analytics.ts +++ b/apps/web/src/api/warehouse/web-analytics.ts @@ -11,7 +11,7 @@ import { WebAnalyticsSummaryRequest, WebAnalyticsTimeseriesRequest, } from "@maple/domain/http" -import { MapleApiAtomClient } from "@/lib/services/common/atom-client" +import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import { WarehouseDateTimeString, decodeInput, runWarehouseQuery } from "@/api/warehouse/effect-utils" const WebAnalyticsFilterFields = { @@ -151,7 +151,7 @@ const getWebAnalyticsSummaryEffect = Effect.fn("QueryEngine.getWebAnalyticsSumma const result = yield* runWarehouseQuery("webAnalyticsSummary", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.webAnalyticsSummary({ payload: new WebAnalyticsSummaryRequest(input), }) @@ -179,7 +179,7 @@ const getWebAnalyticsTimeseriesEffect = Effect.fn("QueryEngine.getWebAnalyticsTi const result = yield* runWarehouseQuery("webAnalyticsTimeseries", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.webAnalyticsTimeseries({ payload: new WebAnalyticsTimeseriesRequest(input), }) @@ -202,7 +202,7 @@ const getWebAnalyticsPageviewsEffect = Effect.fn("QueryEngine.getWebAnalyticsPag const result = yield* runWarehouseQuery("webAnalyticsPageviews", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.webAnalyticsPageviews({ payload: new WebAnalyticsPageviewsRequest(input), }) @@ -225,7 +225,7 @@ const getWebAnalyticsPagesEffect = Effect.fn("QueryEngine.getWebAnalyticsPages") const result = yield* runWarehouseQuery("webAnalyticsPages", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.webAnalyticsPages({ payload: new WebAnalyticsPagesRequest(input), }) @@ -248,7 +248,7 @@ const getWebAnalyticsBreakdownsEffect = Effect.fn("QueryEngine.getWebAnalyticsBr const result = yield* runWarehouseQuery("webAnalyticsBreakdowns", () => Effect.gen(function* () { - const client = yield* MapleApiAtomClient + const client = yield* MapleInternalAtomClient return yield* client.queryEngine.webAnalyticsBreakdowns({ payload: new WebAnalyticsBreakdownsRequest(input), }) diff --git a/apps/web/src/lib/registry.ts b/apps/web/src/lib/registry.ts index 8a3fe6cd0..a0736747e 100644 --- a/apps/web/src/lib/registry.ts +++ b/apps/web/src/lib/registry.ts @@ -4,6 +4,7 @@ import { AtomRegistry } from "effect/unstable/reactivity" import { MapleApiAtomClient } from "./services/common/atom-client" import { MapleFetchHttpClientLive } from "./services/common/http-client" import { mapleOtelLayer } from "./services/common/otel-layer" +import { MapleInternalAtomClient } from "./services/common/internal-atom-client" import { MapleApiV2AtomClient } from "./services/common/v2-atom-client" import { makeAppRuntime } from "./make-app-runtime" @@ -24,6 +25,7 @@ export const sharedAtomRuntime = MapleApiAtomClient.runtime appRegistry.mount(sharedAtomRuntime) appRegistry.mount(MapleApiV2AtomClient.runtime) +appRegistry.mount(MapleInternalAtomClient.runtime) // Extract the typed layer from the AtomRuntime for imperative Effect.provide() usage export const mapleApiClientLayer: Layer.Layer = appRegistry.get( @@ -34,6 +36,10 @@ export const mapleApiV2ClientLayer: Layer.Layer = appRegis MapleApiV2AtomClient.runtime.layer, ) +export const mapleInternalClientLayer: Layer.Layer = appRegistry.get( + MapleInternalAtomClient.runtime.layer, +) + // One persistent ManagedRuntime built from both typed API layers, shared by every // imperative (non-React) Effect run: `runMapleApiV2` (collection write handlers) and // the `optimisticAction` atoms in @maple/effect-db. Building it once avoids @@ -42,6 +48,6 @@ export const mapleApiV2ClientLayer: Layer.Layer = appRegis // Sharing the registry memo map is load-bearing: nested `Effect.provide` calls // reuse the atom-owned client and tracer instances instead of rebuilding them. export const mapleRuntime = makeAppRuntime( - Layer.mergeAll(mapleApiClientLayer, mapleApiV2ClientLayer), + Layer.mergeAll(mapleApiClientLayer, mapleApiV2ClientLayer, mapleInternalClientLayer), appMemoMap, ) diff --git a/apps/web/src/lib/services/common/internal-atom-client.ts b/apps/web/src/lib/services/common/internal-atom-client.ts new file mode 100644 index 000000000..26f8b3c48 --- /dev/null +++ b/apps/web/src/lib/services/common/internal-atom-client.ts @@ -0,0 +1,29 @@ +import { AtomHttpApi } from "@/lib/effect-atom" +import { MapleInternalApi } from "@maple/domain/http" +import { apiBaseUrl } from "./api-base-url" +import { transformMapleApiClient } from "./api-client-transform" +import { MapleFetchHttpClientLive } from "./http-client" + +/** + * Client for the dashboard's private transport (`/internal/*`). + * + * Same origin and same `apiBaseUrl` as the public clients, so `mapleFetch`'s + * URL scoping still attaches the Clerk JWT. `MapleFetchHttpClientLive` is passed + * through untouched for the reason spelled out in `atom-client.ts` and + * `registry.ts`: rewrapping it defeats the memoMap priming and memoizes a + * second, non-JWT-injecting fetch. + * + * There is deliberately no `retainedQuery` equivalent here. Every caller reaches + * this client through `runWarehouseQuery` in `api/warehouse/effect-utils.ts`, + * which owns retention and span naming for warehouse reads; a second retention + * path would just be a way to get the cache identity wrong. + */ +export class MapleInternalAtomClient extends AtomHttpApi.Service()( + "@maple/web/services/common/MapleInternalAtomClient", + { + api: MapleInternalApi, + httpClient: MapleFetchHttpClientLive, + baseUrl: apiBaseUrl, + transformClient: transformMapleApiClient, + }, +) {} diff --git a/docs/api-v2.md b/docs/api-v2.md index 5ca018520..e27cdc87a 100644 --- a/docs/api-v2.md +++ b/docs/api-v2.md @@ -155,20 +155,20 @@ Implemented in phases; the pilot (`api_keys`) ships first and proves every conve | Resource | Endpoints | Backing service | | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `api_keys` ✅ pilot | list/create/retrieve/roll/revoke, `scopes` param | `apiKeys` / `ApiKeysService` | -| `ingest_keys` ✅ | retrieve, `POST …/public/roll`, `POST …/private/roll` | `ingestKeys` | +| `api_keys` ✅ pilot | list/create/retrieve/roll/revoke, `scopes` param | `ApiKeysService` | +| `ingest_keys` ✅ | retrieve, `POST …/public/roll`, `POST …/private/roll` | `OrgIngestKeysService` | | `dashboards` ✅ | CRUD + `versions` (list/retrieve/restore) + `templates` (list/preview/instantiate) + Perses import | `dashboards` | | `alerts/rules` ✅ | CRUD + `test` + `preview` + `checks` | `AlertsService` | | `alerts/destinations` ✅ | CRUD + `test` | `AlertsService` | | `alerts/incidents` ✅ | list/retrieve | `AlertsService` | | `alerts/deliveries` ✅ | list delivery attempts | `AlertsService` | | `error_issues` 🟡 | list/retrieve ✅; `events`, `comments`, `transitions`, `assignee`, `severity` deferred | `errors` | -| `investigations` ✅ | list/retrieve/create/status | `investigations` | +| `investigations` ✅ | list/retrieve/create/status | `InvestigationService` | | `anomalies` ✅ | incidents list/retrieve/timeseries/resolve/link-issue + `PATCH` settings | `anomalies` | -| `instrumentation/recommendations` ✅ | list + dismiss/reopen | `recommendationIssues` | +| `instrumentation/recommendations` ✅ | list + dismiss/reopen | `RecommendationIssueService` | | `instrumentation/audit` ✅ | retrieve (singleton report, recomputed per request) | `SetupAuditService` | -| `scrape_targets` ✅ | CRUD + `probe` + `checks` | `scrapeTargets` | -| `attribute_mappings` ✅ | CRUD | `ingestAttributeMappings` | +| `scrape_targets` ✅ | CRUD + `probe` + `checks` | `ScrapeTargetsService` | +| `attribute_mappings` ✅ | CRUD | `IngestAttributeMappingService` | | `integrations/slack` ✅ | status + admin-only install/uninstall/`channels` (channel ids for `slack-bot` destinations) | `SlackIntegrationService` | | `integrations/planetscale` ✅ | status + connect/organizations/`select_organization`/`metrics_token`/disconnect + databases/`webhook_config`/`query_insights`/`events` | `PlanetScaleConnectionService`, `PlanetScaleOAuthService`, `PlanetScaleService` | | `session_replays` ✅ | `search`/retrieve + events/transcript/`for_trace` (reduced; `facets`/`trace-summaries` deferred) | `sessionReplays` | @@ -179,7 +179,7 @@ Implemented in phases; the pilot (`api_keys`) ships first and proves every conve | `services` ✅ | `GET /v2/services`, `GET /v2/services/{name}` | `queryEngine` | | `service_map` ✅ | `GET /v2/service_map` | `queryEngine` | -The long tail of ~40 query-engine RPC endpoints (facets, infra hosts/pods/nodes/workloads, Cloudflare infra, the PlanetScale infra timeseries) starts in the internal RPC tier and is promoted into `/v2` individually as shapes stabilize. +The long tail of ~40 query-engine endpoints (facets, infra hosts/pods/nodes/workloads, Cloudflare infra, the PlanetScale infra timeseries) lives in the internal tier at `/internal/query-engine`, session-only and undocumented, and is promoted into `/v2` individually as shapes stabilize. ### Telemetry reads diff --git a/docs/http-api-migration.md b/docs/http-api-migration.md index 4b792fba5..e9bc4a259 100644 --- a/docs/http-api-migration.md +++ b/docs/http-api-migration.md @@ -5,7 +5,7 @@ Status: implementation plan, audited 2026-08-13. This document decides where the remaining `/api/...` surface belongs and defines the gate for deleting it. The governing rule is consumer intent, not transport convenience: - `/v2` is the stable public resource API for customers, agents, IaC, and the dashboard. -- `/rpc` is the private dashboard transport for product workflows that can change with the UI. +- `/internal` is the private dashboard transport for product workflows that can change with the UI. It is a separate `HttpApi` (`MapleInternalApi`), session-only via `SessionAuthorization`, and absent from `/docs`. It is deliberately NOT a distinct wire protocol: the boundary is who may call it, not how. Earlier drafts of this document called this tier `/rpc`; the name changed when it shipped, the intent did not. - Raw `HttpRouter` routes remain version-neutral when an external protocol requires redirects, signatures, streaming, or a provider-owned response shape. - No new endpoint is added to v1 unless it is required to complete a safe migration of an existing v1 group. @@ -13,15 +13,11 @@ This document decides where the remaining `/api/...` surface belongs and defines These groups already have a v2 replacement used by the repository. Mark the v1 operations deprecated now, stop feature work on them, and remove each group after the retirement gate below passes. -| v1 group or provider | v2 replacement | action | -| ---------------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `apiKeys` | `/v2/api_keys` | Retire the whole v1 group. | -| `ingestKeys` | `/v2/ingest_keys` | Retire the whole v1 group. | -| `ingestAttributeMappings` | `/v2/attribute_mappings` | Retire the whole v1 group. | -| `recommendationIssues` | `/v2/instrumentation/recommendations` | Retire the whole v1 group. | -| `scrapeTargets` | `/v2/scrape_targets` | Retire the whole v1 group. | -| `investigations` | `/v2/investigations` | Retire the whole v1 group. | -| PlanetScale operations in `integrations` | `/v2/integrations/planetscale` | Already deprecated. Split provider operations out of the monolithic v1 group so they can be deleted independently. Keep callback and webhook router paths. | +| v1 group or provider | v2 replacement | action | +| ---------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| PlanetScale operations in `integrations` | `/v2/integrations/planetscale` | Already deprecated. Split provider operations out of the monolithic v1 group so they can be deleted independently. Keep callback and webhook router paths. | + +`apiKeys`, `ingestKeys`, `ingestAttributeMappings`, `recommendationIssues`, `scrapeTargets`, and `investigations` have passed the gate and are **removed**. Their `HttpApiGroup`s and `apps/api/src/routes/v1/*.http.ts` handlers are deleted; the matching `packages/domain/src/http/*.ts` files remain as schema-and-error modules because the v2 contracts and the backing services import their domain types. `dashboards`, `anomalies`, and `sessionReplays` are close, but repository callers still use part of their v1 surface. Migrate those callers before starting the external-traffic clock: @@ -40,7 +36,7 @@ These v1 groups mix public resource operations with private orchestration. Split | `errors` | Issue events, comments, state transitions, assignee, and severity under `/v2/error_issues/{id}/...`. | Agent registration, claim, heartbeat, release, escalation-policy evaluation, and other worker coordination. | | `organizations` + `orgClickHouseSettings` | Organization update/delete and customer-managed ClickHouse configuration as organization subresources, with explicit admin scopes. | Setup wizards or UI-only probes that merely coordinate several public operations. | | `integrations` | Promote a provider only when a public resource or supported external automation needs it. Slack and PlanetScale already meet that bar. | Cloudflare, GitHub, and Hazel dashboard control surfaces remain private until public demand exists. Split providers into independent contracts so one provider does not block retirement of another. | -| `queryEngine`, `warehouse`, `observability` | Keep the existing stable telemetry resources: traces, logs, metrics, services, and service map. Add a specific public resource endpoint only after its request and response shape is stable. | Raw SQL, generic query documents, arbitrary warehouse execution, attribute/facet discovery used only by dashboard builders, infrastructure drill-down helpers, and provider-specific chart queries. | +| `warehouse`, `observability` | Keep the existing stable telemetry resources: traces, logs, metrics, services, and service map. Add a specific public resource endpoint only after its request and response shape is stable. | Raw SQL, generic query documents, arbitrary warehouse execution, attribute/facet discovery used only by dashboard builders, infrastructure drill-down helpers, and provider-specific chart queries. (`queryEngine` is **done** — the whole group moved to `/internal/query-engine`, so nothing was left to split.) | There must never be a generic `/v2/query`, `/v2/sql`, or public query-builder execution endpoint. Those contracts expose Maple's storage and dashboard implementation rather than a durable product resource. @@ -82,8 +78,8 @@ If external traffic prevents removal, keep the compatibility adapter thin over t ## Execution order 1. **Boundary consistency (this change):** apply API-wide v1 request-validation and defect middleware; apply the same v2 middleware once at `MapleApiV2`; distinguish request-decode failures from server-side response drift; define one exhaustive public policy map per domain error union and preserve every typed error's semantic tag through the v2 envelope. -2. **Deprecate complete duplicates:** `apiKeys`, `ingestKeys`, `ingestAttributeMappings`, `recommendationIssues`, `scrapeTargets`, `investigations`, and PlanetScale v1 operations. Add operation-level traffic counters before starting the 30-day clock. +2. **Deprecate complete duplicates:** done for `apiKeys`, `ingestKeys`, `ingestAttributeMappings`, `recommendationIssues`, `scrapeTargets`, and `investigations` — per-operation telemetry showed no production caller (the only August 2026 requests were manual `curl` probes), so the groups were deleted outright rather than deprecated. PlanetScale v1 operations still serve traffic and remain deprecated-not-removed. 3. **Finish near-complete resources:** move the remaining dashboard callers for dashboards, anomalies, and session replays to v2. -4. **Build the internal RPC tier:** move billing, onboarding, demo, chat apply, digest, AI triage, generic query/warehouse helpers, and error-agent coordination. Preserve the billing-specific authentication retry behavior when its client moves. +4. **Build the internal tier:** done for `queryEngine`, which now serves from `MapleInternalApi` at `/internal/query-engine` behind `SessionAuthorization` — telemetry showed one API-key call across all 62 endpoints in 30 days, so the group was moved wholesale rather than split. Still to move: billing, onboarding, demo, chat apply, digest, AI triage, the remaining warehouse helpers, and error-agent coordination. Preserve the billing-specific authentication retry behavior when its client moves. 5. **Split mixed v1 groups:** separate `errors`, provider integrations, and query/warehouse operations so public promotions and private RPC moves can be retired independently. 6. **Delete by evidence:** remove each empty v1 group as soon as its retirement gate passes; do not wait for every v1 group to be ready. diff --git a/packages/domain/src/http/api.ts b/packages/domain/src/http/api.ts index 863c5209e..b05017e84 100644 --- a/packages/domain/src/http/api.ts +++ b/packages/domain/src/http/api.ts @@ -13,7 +13,6 @@ import { ObservabilityApiGroup } from "./observability" import { OnboardingApiGroup } from "./onboarding" import { OrgClickHouseSettingsApiGroup } from "./org-clickhouse-settings" import { OrganizationsApiGroup } from "./organizations" -import { QueryEngineApiGroup } from "./query-engine" import { SessionReplaysApiGroup } from "./session-replay" import { WarehouseApiGroup } from "./warehouse" import { V1SchemaErrors, V1UnexpectedErrors } from "./v1-boundary" @@ -34,7 +33,6 @@ export class MapleApi extends HttpApi.make("MapleApi") .add(OnboardingApiGroup) .add(OrgClickHouseSettingsApiGroup) .add(OrganizationsApiGroup) - .add(QueryEngineApiGroup) .add(SessionReplaysApiGroup) .add(WarehouseApiGroup) .middleware(V1SchemaErrors) diff --git a/packages/domain/src/http/current-tenant.ts b/packages/domain/src/http/current-tenant.ts index b96e2c0c4..f409ff482 100644 --- a/packages/domain/src/http/current-tenant.ts +++ b/packages/domain/src/http/current-tenant.ts @@ -59,3 +59,46 @@ export class Authorization extends HttpApiMiddleware.Service< bearer: HttpApiSecurity.bearer, }, }) {} + +/** + * An API key was presented to a session-only (internal) endpoint. + * + * Distinct from `UnauthorizedError` on purpose: the credential is valid, it is + * simply not accepted here. A bare 401 would read as "your key is broken" and + * send people to rotate it; this says where the supported surface is instead. + */ +export class ApiKeyNotAcceptedError extends HttpTaggedError()( + "@maple/http/errors/ApiKeyNotAcceptedError", + { + message: Schema.String, + }, + { + status: 403, + code: "api_key_not_accepted", + title: "Not available to API keys", + message: + "This endpoint backs the Maple dashboard and is not part of the public API. Use the /v2 API instead.", + retry: "never", + recovery: "none", + exposure: "public_message", + }, +) {} + +/** + * Session-only sibling of {@link Authorization}, for endpoints that are + * dashboard transport rather than public API. + * + * Provides the same `Context`, so handlers written against `Authorization` need + * no changes — only the group's `.middleware(...)` line differs. + */ +export class SessionAuthorization extends HttpApiMiddleware.Service< + SessionAuthorization, + { + provides: Context + } +>()("SessionAuthorization", { + error: [UnauthorizedError, AuthorizationUnavailableError, ApiKeyNotAcceptedError], + security: { + bearer: HttpApiSecurity.bearer, + }, +}) {} diff --git a/packages/domain/src/http/index.ts b/packages/domain/src/http/index.ts index 68e0aec52..64b5cf563 100644 --- a/packages/domain/src/http/index.ts +++ b/packages/domain/src/http/index.ts @@ -1,4 +1,5 @@ export * from "./api" +export * from "./internal-api" export * from "./ai-triage" export * from "./investigations" export * from "./anomalies" diff --git a/packages/domain/src/http/internal-api.ts b/packages/domain/src/http/internal-api.ts new file mode 100644 index 000000000..904ae8c3b --- /dev/null +++ b/packages/domain/src/http/internal-api.ts @@ -0,0 +1,34 @@ +import { HttpApi, OpenApi } from "effect/unstable/httpapi" +import { QueryEngineApiGroup } from "./query-engine" +import { V1SchemaErrors, V1UnexpectedErrors } from "./v1-boundary" + +/** + * The dashboard's private transport. + * + * Deliberately a separate `HttpApi` from `MapleApi` rather than another group + * inside it. Two things follow from the split, and both are the point: + * `/docs` is generated from `MapleApi`, so these operations stop being + * published as public API; and the groups here can carry session-only + * authorization without loosening it for anything else. + * + * What belongs here is transport whose request and response shapes are allowed + * to change with the UI — raw SQL, generic query documents, dashboard-builder + * facet discovery, infrastructure drill-downs. Nothing here is a stable public + * contract, and nothing here should be promoted to `/v2` without a deliberate + * redesign of its shape first. See `docs/http-api-migration.md`. + * + * The error envelope is v1's on purpose: `apps/web` already decodes it, so the + * split costs the frontend nothing. + */ +export class MapleInternalApi extends HttpApi.make("MapleInternalApi") + .add(QueryEngineApiGroup) + .middleware(V1SchemaErrors) + .middleware(V1UnexpectedErrors) + .annotateMerge( + OpenApi.annotations({ + title: "Maple Internal API", + version: "1.0.0", + description: + "Private dashboard transport. Not public API, not documented, not stable — do not build against it.", + }), + ) {} diff --git a/packages/domain/src/http/query-engine.ts b/packages/domain/src/http/query-engine.ts index 980efb671..e1312e0ae 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -19,7 +19,7 @@ import { QueryEngineExecuteResponse, TinybirdDateTime, } from "../query-engine" -import { Authorization } from "./current-tenant" +import { SessionAuthorization } from "./current-tenant" import { HttpTaggedError } from "./error-policy" import { warehouseHttpErrors } from "./warehouse" @@ -1719,16 +1719,9 @@ const validatedQueryEndpointErrors = [ export class QueryEngineApiGroup extends HttpApiGroup.make("queryEngine") .add( - HttpApiEndpoint.post("execute", "/execute", { - payload: QueryEngineExecuteRequest, - success: QueryEngineExecuteResponse, - error: validatedQueryEndpointErrors, - }), - ) - .add( - // Batched sibling of `execute`. Per-item failures ride in the SUCCESS - // payload (see QueryEngineBatchOutcome); the error list here is for - // whole-request failures only — auth, decode, a blown batch cap. + // The one query-execution entry point. Per-item failures ride in the + // SUCCESS payload (see QueryEngineBatchOutcome); the error list here is + // for whole-request failures only — auth, decode, a blown batch cap. HttpApiEndpoint.post("executeBatch", "/execute-batch", { payload: QueryEngineExecuteBatchRequest, success: QueryEngineExecuteBatchResponse, @@ -1812,20 +1805,6 @@ export class QueryEngineApiGroup extends HttpApiGroup.make("queryEngine") error: queryEngineEndpointErrors, }), ) - .add( - HttpApiEndpoint.post("serviceDependencies", "/service-dependencies", { - payload: ServiceDependenciesRequest, - success: ServiceDependenciesResponse, - error: queryEngineEndpointErrors, - }), - ) - .add( - HttpApiEndpoint.post("serviceDbEdges", "/service-db-edges", { - payload: ServiceDbEdgesRequest, - success: ServiceDbEdgesResponse, - error: queryEngineEndpointErrors, - }), - ) .add( HttpApiEndpoint.post("serviceCloudflareStats", "/service-cloudflare-stats", { payload: ServiceCloudflareStatsRequest, @@ -1939,13 +1918,6 @@ export class QueryEngineApiGroup extends HttpApiGroup.make("queryEngine") error: queryEngineEndpointErrors, }), ) - .add( - HttpApiEndpoint.post("servicePlatforms", "/service-platforms", { - payload: ServicePlatformsRequest, - success: ServicePlatformsResponse, - error: queryEngineEndpointErrors, - }), - ) .add( HttpApiEndpoint.post("serviceWorkloads", "/service-workloads", { payload: ServiceWorkloadsRequest, @@ -2161,5 +2133,5 @@ export class QueryEngineApiGroup extends HttpApiGroup.make("queryEngine") ] as const, }), ) - .prefix("/api/query-engine") - .middleware(Authorization) {} + .prefix("/internal/query-engine") + .middleware(SessionAuthorization) {} diff --git a/packages/query-engine/src/ch/queries/traces.ts b/packages/query-engine/src/ch/queries/traces.ts index 41182c2d1..13cb49a93 100644 --- a/packages/query-engine/src/ch/queries/traces.ts +++ b/packages/query-engine/src/ch/queries/traces.ts @@ -316,7 +316,7 @@ export interface TracesTimeseriesOpts extends TracesQueryOpts { * * This is the retry knob for an org whose ClickHouse has not applied migration * 0015 — not a rollout switch. See `makeRollupFallback` in - * `apps/api/src/routes/v1/query-engine.http.ts`. + * `apps/api/src/routes/internal/query-engine.http.ts`. */ overviewTiers?: "hour" | "minute" }