@databricks/appkit 0.82.0 → 0.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +21 -1
- package/dist/agents/databricks.d.ts +15 -3
- package/dist/agents/databricks.d.ts.map +1 -1
- package/dist/agents/databricks.js +25 -8
- package/dist/agents/databricks.js.map +1 -1
- package/dist/appkit/package.js +1 -1
- package/dist/beta.d.ts +2 -2
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +6 -1
- package/dist/cache/index.js.map +1 -1
- package/dist/cli/commands/agent/eval.js +1 -1
- package/dist/cli/commands/generate-types.js +1 -1
- package/dist/cli/commands/plugin/sync/sync.js +28 -15
- package/dist/cli/commands/plugin/sync/sync.js.map +1 -1
- package/dist/cli/commands/registry/add.js +3 -11
- package/dist/cli/commands/registry/add.js.map +1 -1
- package/dist/cli/commands/registry/config-writer.js +1 -1
- package/dist/connectors/lakebase/index.js +1 -1
- package/dist/connectors/lakebase/routing-pool.d.ts +1 -1
- package/dist/connectors/lakebase/routing-pool.d.ts.map +1 -1
- package/dist/connectors/lakebase/routing-pool.js +6 -12
- package/dist/connectors/lakebase/routing-pool.js.map +1 -1
- package/dist/context/caller-context.d.ts +25 -0
- package/dist/context/caller-context.d.ts.map +1 -0
- package/dist/context/caller-context.js +21 -0
- package/dist/context/caller-context.js.map +1 -0
- package/dist/context/deprecation.js +14 -0
- package/dist/context/deprecation.js.map +1 -0
- package/dist/context/execution-context.d.ts +40 -4
- package/dist/context/execution-context.d.ts.map +1 -1
- package/dist/context/execution-context.js +87 -33
- package/dist/context/execution-context.js.map +1 -1
- package/dist/context/index.d.ts +3 -2
- package/dist/context/index.js +3 -1
- package/dist/context/request-scope.d.ts +2 -0
- package/dist/context/request-scope.js +39 -0
- package/dist/context/request-scope.js.map +1 -0
- package/dist/context/resource-capabilities.js +59 -0
- package/dist/context/resource-capabilities.js.map +1 -0
- package/dist/context/scoped-api.js +104 -0
- package/dist/context/scoped-api.js.map +1 -0
- package/dist/context/service-context.d.ts +18 -12
- package/dist/context/service-context.d.ts.map +1 -1
- package/dist/context/service-context.js +45 -55
- package/dist/context/service-context.js.map +1 -1
- package/dist/context/user-context.d.ts +16 -22
- package/dist/context/user-context.d.ts.map +1 -1
- package/dist/context/user-context.js +57 -3
- package/dist/context/user-context.js.map +1 -1
- package/dist/core/agent/load-agents.d.ts.map +1 -1
- package/dist/core/agent/load-agents.js +5 -2
- package/dist/core/agent/load-agents.js.map +1 -1
- package/dist/core/agent/run-agent.d.ts +19 -6
- package/dist/core/agent/run-agent.d.ts.map +1 -1
- package/dist/core/agent/run-agent.js +53 -17
- package/dist/core/agent/run-agent.js.map +1 -1
- package/dist/core/agent/types.d.ts +18 -1
- package/dist/core/agent/types.d.ts.map +1 -1
- package/dist/core/agent/types.js.map +1 -1
- package/dist/core/appkit.d.ts +4 -3
- package/dist/core/appkit.d.ts.map +1 -1
- package/dist/core/appkit.js +34 -11
- package/dist/core/appkit.js.map +1 -1
- package/dist/core/plugin-context.d.ts +14 -15
- package/dist/core/plugin-context.d.ts.map +1 -1
- package/dist/core/plugin-context.js +24 -14
- package/dist/core/plugin-context.js.map +1 -1
- package/dist/errors/base.d.ts +2 -2
- package/dist/errors/base.js +2 -2
- package/dist/errors/base.js.map +1 -1
- package/dist/errors/identity-expired.d.ts +14 -0
- package/dist/errors/identity-expired.d.ts.map +1 -0
- package/dist/errors/identity-expired.js +33 -0
- package/dist/errors/identity-expired.js.map +1 -0
- package/dist/errors/index.js +1 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.js +7 -2
- package/dist/logging/logger.js +1 -1
- package/dist/plugin/execution-result.d.ts +4 -1
- package/dist/plugin/execution-result.d.ts.map +1 -1
- package/dist/plugin/interceptors/telemetry.js +6 -4
- package/dist/plugin/interceptors/telemetry.js.map +1 -1
- package/dist/plugin/plugin.d.ts +14 -22
- package/dist/plugin/plugin.d.ts.map +1 -1
- package/dist/plugin/plugin.js +42 -123
- package/dist/plugin/plugin.js.map +1 -1
- package/dist/plugins/agents/agents.d.ts +13 -1
- package/dist/plugins/agents/agents.d.ts.map +1 -1
- package/dist/plugins/agents/agents.js +65 -15
- package/dist/plugins/agents/agents.js.map +1 -1
- package/dist/plugins/agents/auth-mode.js +42 -0
- package/dist/plugins/agents/auth-mode.js.map +1 -0
- package/dist/plugins/agents/index.d.ts +1 -1
- package/dist/plugins/agents/mlflow.js +11 -3
- package/dist/plugins/agents/mlflow.js.map +1 -1
- package/dist/plugins/agents/tool-dispatch.js +9 -1
- package/dist/plugins/agents/tool-dispatch.js.map +1 -1
- package/dist/plugins/ai-search/ai-search.d.ts +1 -1
- package/dist/plugins/ai-search/ai-search.d.ts.map +1 -1
- package/dist/plugins/ai-search/ai-search.js +4 -4
- package/dist/plugins/ai-search/ai-search.js.map +1 -1
- package/dist/plugins/analytics/analytics.d.ts +3 -3
- package/dist/plugins/analytics/analytics.d.ts.map +1 -1
- package/dist/plugins/analytics/analytics.js +9 -7
- package/dist/plugins/analytics/analytics.js.map +1 -1
- package/dist/plugins/database/crud/contract.js +2 -2
- package/dist/plugins/database/crud/contract.js.map +1 -1
- package/dist/plugins/database/database.d.ts +1 -1
- package/dist/plugins/files/plugin.d.ts +21 -15
- package/dist/plugins/files/plugin.d.ts.map +1 -1
- package/dist/plugins/files/plugin.js +50 -29
- package/dist/plugins/files/plugin.js.map +1 -1
- package/dist/plugins/genie/genie.d.ts +10 -1
- package/dist/plugins/genie/genie.d.ts.map +1 -1
- package/dist/plugins/genie/genie.js +12 -3
- package/dist/plugins/genie/genie.js.map +1 -1
- package/dist/plugins/genie/manifest.js +1 -0
- package/dist/plugins/jobs/plugin.d.ts +1 -1
- package/dist/plugins/jobs/plugin.js +2 -2
- package/dist/plugins/jobs/plugin.js.map +1 -1
- package/dist/plugins/lakebase/lakebase.d.ts +11 -18
- package/dist/plugins/lakebase/lakebase.d.ts.map +1 -1
- package/dist/plugins/lakebase/lakebase.js +16 -21
- package/dist/plugins/lakebase/lakebase.js.map +1 -1
- package/dist/plugins/server/client-config-sanitizer.js +1 -4
- package/dist/plugins/server/client-config-sanitizer.js.map +1 -1
- package/dist/plugins/server/dev-obo-middleware.js +60 -0
- package/dist/plugins/server/dev-obo-middleware.js.map +1 -0
- package/dist/plugins/server/index.d.ts +1 -1
- package/dist/plugins/server/index.d.ts.map +1 -1
- package/dist/plugins/server/index.js +3 -0
- package/dist/plugins/server/index.js.map +1 -1
- package/dist/plugins/serving/manifest.js +1 -0
- package/dist/plugins/serving/serving.d.ts +1 -1
- package/dist/plugins/serving/serving.js +6 -6
- package/dist/plugins/serving/serving.js.map +1 -1
- package/dist/resources/index.d.ts +1 -0
- package/dist/resources/index.js +3 -0
- package/dist/resources/warehouse.d.ts +16 -0
- package/dist/resources/warehouse.d.ts.map +1 -0
- package/dist/resources/warehouse.js +93 -0
- package/dist/resources/warehouse.js.map +1 -0
- package/dist/schemas/manifest.d.ts +44 -1
- package/dist/schemas/manifest.d.ts.map +1 -1
- package/dist/schemas/manifest.js +122 -3
- package/dist/schemas/manifest.js.map +1 -1
- package/dist/shared/src/dev-obo.js +86 -0
- package/dist/shared/src/dev-obo.js.map +1 -0
- package/dist/shared/src/index.d.ts +1 -1
- package/dist/shared/src/plugin.d.ts +12 -1
- package/dist/shared/src/plugin.d.ts.map +1 -1
- package/dist/shared/src/schemas/manifest.d.ts +17 -0
- package/dist/shared/src/schemas/manifest.d.ts.map +1 -1
- package/dist/shared/src/schemas/manifest.js +53 -3
- package/dist/shared/src/schemas/manifest.js.map +1 -1
- package/dist/stream/stream-manager.d.ts.map +1 -1
- package/dist/stream/stream-manager.js +5 -2
- package/dist/stream/stream-manager.js.map +1 -1
- package/dist/telemetry/execution-span-processor.js +21 -0
- package/dist/telemetry/execution-span-processor.js.map +1 -0
- package/dist/telemetry/telemetry-manager.js +2 -1
- package/dist/telemetry/telemetry-manager.js.map +1 -1
- package/dist/testing/create-test-app.d.ts +2 -2
- package/dist/testing/create-test-app.js +2 -2
- package/dist/testing/create-test-app.js.map +1 -1
- package/dist/testing/fixtures.d.ts +3 -2
- package/dist/testing/fixtures.d.ts.map +1 -1
- package/dist/testing/fixtures.js +21 -14
- package/dist/testing/fixtures.js.map +1 -1
- package/dist/testing/reset-singletons.js +1 -1
- package/dist/testing/test-plugin-context.d.ts +3 -12
- package/dist/testing/test-plugin-context.d.ts.map +1 -1
- package/dist/testing/test-plugin-context.js +24 -16
- package/dist/testing/test-plugin-context.js.map +1 -1
- package/dist/utils/is-plain-object.js +11 -0
- package/dist/utils/is-plain-object.js.map +1 -0
- package/docs/api/appkit/Class.AppKitError.md +2 -1
- package/docs/api/appkit/Class.AuthenticationError.md +1 -1
- package/docs/api/appkit/Class.ConfigurationError.md +1 -1
- package/docs/api/appkit/Class.ConnectionError.md +1 -1
- package/docs/api/appkit/Class.DatabaseValidationError.md +1 -1
- package/docs/api/appkit/Class.ExecutionError.md +1 -1
- package/docs/api/appkit/Class.IdentityExpiredError.md +190 -0
- package/docs/api/appkit/Class.InitializationError.md +1 -1
- package/docs/api/appkit/Class.Plugin.md +9 -13
- package/docs/api/appkit/Class.ServerError.md +1 -1
- package/docs/api/appkit/Class.ServiceContext.md +169 -0
- package/docs/api/appkit/Class.TunnelError.md +1 -1
- package/docs/api/appkit/Class.ValidationError.md +1 -1
- package/docs/api/appkit/Function.createApp.md +12 -12
- package/docs/api/appkit/Function.getCallerContext.md +12 -0
- package/docs/api/appkit/Function.getCurrentActorId.md +12 -0
- package/docs/api/appkit/Function.getCurrentPrincipalKey.md +12 -0
- package/docs/api/appkit/Function.getCurrentUserId.md +14 -0
- package/docs/api/appkit/Function.getExecutionContext.md +5 -3
- package/docs/api/appkit/Function.getUserContext.md +16 -0
- package/docs/api/appkit/Function.getWarehouseId.md +20 -0
- package/docs/api/appkit/Function.isInUserContext.md +12 -0
- package/docs/api/appkit/Function.isUserContext.md +20 -0
- package/docs/api/appkit/Function.runAgent.md +1 -1
- package/docs/api/appkit/Function.runInCallerContext.md +27 -0
- package/docs/api/appkit/Function.runInUserContext.md +29 -0
- package/docs/api/appkit/Interface.AgentDefinition.md +11 -0
- package/docs/api/appkit/Interface.AgentsPluginConfig.md +11 -0
- package/docs/api/appkit/Interface.CallerContext.md +54 -0
- package/docs/api/appkit/Interface.IndexConfig.md +1 -1
- package/docs/api/appkit/Interface.PluginManifest.md +33 -2
- package/docs/api/appkit/Interface.RegisteredAgent.md +11 -0
- package/docs/api/appkit/Interface.RunAgentInput.md +45 -1
- package/docs/api/appkit/TypeAlias.AgentAuth.md +8 -0
- package/docs/api/appkit/TypeAlias.AppKitApi.md +35 -0
- package/docs/api/appkit/TypeAlias.CallerPrincipal.md +13 -0
- package/docs/api/appkit/TypeAlias.ExecutionContext.md +9 -0
- package/docs/api/appkit/TypeAlias.ExecutionResult.md +65 -0
- package/docs/api/appkit/TypeAlias.ScopedPluginMap.md +12 -0
- package/docs/api/appkit/TypeAlias.UserContext.md +109 -0
- package/docs/api/appkit/TypeAlias.UserScopedApp.md +39 -0
- package/docs/api/appkit.md +20 -0
- package/docs/plugins/agents.md +45 -0
- package/docs/plugins/analytics.md +8 -0
- package/docs/plugins/execution-context.md +101 -50
- package/docs/plugins/lakebase.md +6 -82
- package/llms.txt +21 -1
- package/package.json +1 -1
- package/sbom.cdx.json +1 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { AppKitError } from "../errors/base.js";
|
|
2
|
+
import { IdentityExpiredError } from "../errors/identity-expired.js";
|
|
2
3
|
import { ExecutionError } from "../errors/execution.js";
|
|
3
4
|
import { createLogger } from "../logging/logger.js";
|
|
5
|
+
import { normalizeIdentityError } from "../context/execution-context.js";
|
|
4
6
|
import { EventRingBuffer } from "./buffers.js";
|
|
5
7
|
import { streamDefaults } from "./defaults.js";
|
|
6
8
|
import { SSEErrorCode } from "./types.js";
|
|
@@ -162,10 +164,11 @@ var StreamManager = class {
|
|
|
162
164
|
streamEntry.lastAccess = Date.now();
|
|
163
165
|
}
|
|
164
166
|
this._finalizeStream(streamEntry);
|
|
165
|
-
} catch (
|
|
167
|
+
} catch (caught) {
|
|
168
|
+
const error = normalizeIdentityError(caught);
|
|
166
169
|
const rawMsg = error instanceof Error ? error.message : "Internal server error";
|
|
167
170
|
const clientMsg = error instanceof AppKitError ? error.clientMessage : "Internal server error";
|
|
168
|
-
const upstreamCode = error instanceof ExecutionError ? error.errorCode : void 0;
|
|
171
|
+
const upstreamCode = error instanceof IdentityExpiredError ? error.code : error instanceof ExecutionError ? error.errorCode : void 0;
|
|
169
172
|
const errorEventId = randomUUID();
|
|
170
173
|
const errorCode = this._categorizeError(error);
|
|
171
174
|
if (errorCode === SSEErrorCode.STREAM_ABORTED) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stream-manager.js","names":[],"sources":["../../src/stream/stream-manager.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\n\nimport { context } from \"@opentelemetry/api\";\nimport type { IAppResponse, StreamConfig } from \"shared\";\n\nimport { AppKitError } from \"../errors/base\";\nimport { ExecutionError } from \"../errors/execution\";\nimport { createLogger } from \"../logging/logger\";\nimport { EventRingBuffer } from \"./buffers\";\nimport { streamDefaults } from \"./defaults\";\nimport { SSEWriter } from \"./sse-writer\";\nimport { StreamRegistry } from \"./stream-registry\";\nimport { clearGraceTimer, clearRemovalTimer } from \"./timers\";\nimport { SSEErrorCode, type StreamEntry, type StreamOperation } from \"./types\";\nimport { StreamValidator } from \"./validator\";\n\nconst logger = createLogger(\"stream\");\n\n// main entry point for Server-Sent events streaming\nexport class StreamManager {\n private activeOperations: Set<StreamOperation>;\n private streamRegistry: StreamRegistry;\n private sseWriter: SSEWriter;\n private maxEventSize: number;\n private bufferTTL: number;\n private disconnectGraceMs: number;\n\n constructor(options?: StreamConfig) {\n this.streamRegistry = new StreamRegistry(\n options?.maxActiveStreams ?? streamDefaults.maxActiveStreams,\n );\n this.sseWriter = new SSEWriter();\n this.maxEventSize = options?.maxEventSize ?? streamDefaults.maxEventSize;\n this.bufferTTL = options?.bufferTTL ?? streamDefaults.bufferTTL;\n this.disconnectGraceMs =\n options?.disconnectGraceMs ?? streamDefaults.disconnectGraceMs;\n this.activeOperations = new Set();\n }\n\n // main streaming method - handles new connection and reconnection\n async stream(\n res: IAppResponse,\n handler: (signal: AbortSignal) => AsyncGenerator<any, void, unknown>,\n options?: StreamConfig,\n ownerKey?: string,\n ): Promise<void> {\n const { streamId } = options || {};\n\n // check if response is already closed\n if (res.writableEnded || res.destroyed) {\n return;\n }\n\n // setup SSE headers\n this.sseWriter.setupHeaders(res);\n\n // handle reconnection\n if (streamId && StreamValidator.validateStreamId(streamId)) {\n const existingStream = this.streamRegistry.get(streamId);\n if (existingStream) {\n // Enforce per-user binding: the stream's owner key must match the\n // requesting caller's owner key. This prevents cross-user stream\n // takeover via guessed/leaked stream IDs (the SSE registry was\n // previously a global lookup with no authorization step).\n if (existingStream.ownerKey !== ownerKey) {\n this.sseWriter.writeError(\n res,\n randomUUID(),\n \"Stream not found or access denied\",\n SSEErrorCode.STREAM_FORBIDDEN,\n );\n res.end();\n return;\n }\n return this._attachToExistingStream(res, existingStream, options);\n }\n }\n\n // if stream does not exist, create a new one\n return this._createNewStream(res, handler, options, ownerKey);\n }\n\n // abort all active operations\n abortAll(): void {\n // pending disconnect-grace timers are cleared by streamRegistry.clear() below\n this.activeOperations.forEach((operation) => {\n if (operation.heartbeat) clearInterval(operation.heartbeat);\n operation.controller.abort(\n new DOMException(\"Server shutdown\", \"AbortError\"),\n );\n });\n this.activeOperations.clear();\n this.streamRegistry.clear();\n }\n\n // get the number of active operations\n getActiveCount(): number {\n return this.activeOperations.size;\n }\n\n // attach to existing stream\n private async _attachToExistingStream(\n res: IAppResponse,\n streamEntry: StreamEntry,\n options?: StreamConfig,\n ): Promise<void> {\n // handle reconnection - replay missed events\n const lastEventId = res.req?.headers[\"last-event-id\"];\n\n if (StreamValidator.validateEventId(lastEventId)) {\n // cast to string after validation\n const validEventId = lastEventId as string;\n if (streamEntry.eventBuffer.has(validEventId)) {\n const missedEvents =\n streamEntry.eventBuffer.getEventsSince(validEventId);\n // broadcast missed events to client\n for (const event of missedEvents) {\n if (options?.userSignal?.aborted) break;\n this.sseWriter.writeBufferedEvent(res, event);\n }\n } else {\n // buffer overflow - send warning\n this.sseWriter.writeBufferOverflowWarning(res, validEventId);\n }\n }\n\n // a reconnecting client cancels the pending disconnect-grace abort\n clearGraceTimer(streamEntry);\n\n // a reconnect cancels any pending registry removal so the entry isn't\n // pulled out from under the newly attached client\n clearRemovalTimer(streamEntry);\n\n // add client to stream entry\n streamEntry.clients.add(res);\n streamEntry.lastAccess = Date.now();\n\n // start heartbeat\n const combinedSignal = this._combineSignals(\n streamEntry.abortController.signal,\n options?.userSignal,\n );\n const heartbeat = this.sseWriter.startHeartbeat(res, combinedSignal);\n\n // track operation\n const streamOperation: StreamOperation = {\n controller: streamEntry.abortController,\n type: \"stream\",\n heartbeat,\n };\n this.activeOperations.add(streamOperation);\n\n // handle client disconnect\n res.on(\"close\", () => {\n clearInterval(heartbeat);\n streamEntry.clients.delete(res);\n this.activeOperations.delete(streamOperation);\n\n // grace-abort instead of aborting now, so a reconnect can resume\n if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) {\n this._scheduleGraceAbort(streamEntry);\n }\n\n // cleanup if stream is completed and no clients are connected\n this._scheduleRemovalAfterTTL(streamEntry);\n });\n\n // if stream is completed, close connection\n if (streamEntry.isCompleted) {\n res.end();\n // cleanup operation\n this.activeOperations.delete(streamOperation);\n clearInterval(heartbeat);\n // we deliberately ended this client, so drop it from the entry and\n // schedule removal now instead of relying on the transport's `close`\n // event (the close handler's later delete is a safe no-op)\n streamEntry.clients.delete(res);\n this._scheduleRemovalAfterTTL(streamEntry);\n }\n }\n private async _createNewStream(\n res: IAppResponse,\n handler: (signal: AbortSignal) => AsyncGenerator<any, void, unknown>,\n options?: StreamConfig,\n ownerKey?: string,\n ): Promise<void> {\n const streamId = options?.streamId ?? randomUUID();\n\n // abort stream if response is closed\n if (res.writableEnded || res.destroyed) {\n return;\n }\n\n const abortController = new AbortController();\n\n // create event buffer\n const eventBuffer = new EventRingBuffer(\n options?.bufferSize ?? streamDefaults.bufferSize,\n );\n const maxEventSize = options?.maxEventSize ?? this.maxEventSize;\n\n // setup signals and heartbeat\n const combinedSignal = this._combineSignals(\n abortController.signal,\n options?.userSignal,\n );\n const heartbeat = this.sseWriter.startHeartbeat(res, combinedSignal);\n\n // capture the current trace context at stream creation time\n const traceContext = context.active();\n\n // abort stream if response is closed\n if (res.writableEnded || res.destroyed) {\n clearInterval(heartbeat);\n return;\n }\n\n // create stream entry\n const streamEntry: StreamEntry = {\n streamId,\n ownerKey,\n generator: handler(combinedSignal),\n eventBuffer,\n clients: new Set([res]),\n isCompleted: false,\n lastAccess: Date.now(),\n abortController,\n traceContext,\n maxEventSize,\n };\n this.streamRegistry.add(streamEntry);\n\n // track operation\n const streamOperation: StreamOperation = {\n controller: abortController,\n type: \"stream\",\n heartbeat,\n };\n this.activeOperations.add(streamOperation);\n\n res.on(\"close\", () => {\n clearInterval(heartbeat);\n this.activeOperations.delete(streamOperation);\n streamEntry.clients.delete(res);\n\n // grace-abort instead of aborting now, so a reconnect can resume\n if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) {\n this._scheduleGraceAbort(streamEntry);\n }\n\n // if the stream already finished (completed or errored), schedule\n // registry removal once the buffer TTL elapses so completed streams\n // don't accumulate in the registry forever\n this._scheduleRemovalAfterTTL(streamEntry);\n });\n\n await this._processGeneratorInBackground(streamEntry);\n\n // cleanup\n clearInterval(heartbeat);\n this.activeOperations.delete(streamOperation);\n }\n\n private async _processGeneratorInBackground(\n streamEntry: StreamEntry,\n ): Promise<void> {\n // run the entire generator processing within the captured trace context\n return context.with(streamEntry.traceContext, async () => {\n try {\n // retrieve all events from generator\n for await (const event of streamEntry.generator) {\n if (streamEntry.abortController.signal.aborted) break;\n const eventId = randomUUID();\n const eventData = JSON.stringify(event);\n const { maxEventSize } = streamEntry;\n\n // UTF-8 bytes, not `String.length`: non-ASCII payloads used to slip\n // past a limit they exceeded on the wire.\n if (Buffer.byteLength(eventData, \"utf8\") > maxEventSize) {\n const errorMsg = `Event exceeds max size of ${maxEventSize} bytes`;\n const errorCode = SSEErrorCode.INVALID_REQUEST;\n // broadcast error to all connected clients\n this._broadcastErrorToClients(\n streamEntry,\n eventId,\n errorMsg,\n errorCode,\n );\n continue;\n }\n\n // buffer event for reconnection\n streamEntry.eventBuffer.add({\n id: eventId,\n type: event.type,\n data: eventData,\n timestamp: Date.now(),\n });\n\n // broadcast to all connected clients\n this._broadcastEventsToClients(streamEntry, eventId, event);\n streamEntry.lastAccess = Date.now();\n }\n\n this._finalizeStream(streamEntry);\n } catch (error) {\n // Two distinct messages: a *raw* one for server-side logs (full\n // detail, statement fragments, correlation IDs) and a *client*\n // one for the SSE payload (sanitized, stable, safe to render in\n // a UI). Mixing them leaks upstream wording to anyone connected\n // to the stream — see CWE-209.\n const rawMsg =\n error instanceof Error ? error.message : \"Internal server error\";\n const clientMsg =\n error instanceof AppKitError\n ? error.clientMessage\n : \"Internal server error\";\n // Upstream structured code (e.g. RESULT_TOO_LARGE_FOR_JSON_FALLBACK,\n // NOT_IMPLEMENTED). UI should branch on this, not on `error`.\n const upstreamCode =\n error instanceof ExecutionError ? error.errorCode : undefined;\n const errorEventId = randomUUID();\n const errorCode = this._categorizeError(error);\n\n // client cancellation is a normal control-flow signal, not a failure\n if (errorCode === SSEErrorCode.STREAM_ABORTED) {\n logger.info(\"Stream aborted by client (code=%s)\", errorCode);\n this._finalizeStream(streamEntry);\n return;\n }\n\n logger.error(\n \"Stream execution failed: %s (code=%s upstreamCode=%s)\",\n rawMsg,\n errorCode,\n upstreamCode ?? \"n/a\",\n );\n\n const payload: Record<string, unknown> = {\n error: clientMsg,\n code: errorCode,\n };\n if (upstreamCode) payload.errorCode = upstreamCode;\n\n // buffer error event\n streamEntry.eventBuffer.add({\n id: errorEventId,\n type: \"error\",\n data: JSON.stringify(payload),\n timestamp: Date.now(),\n });\n\n // send error event to all connected clients\n this._broadcastErrorToClients(\n streamEntry,\n errorEventId,\n clientMsg,\n errorCode,\n true,\n upstreamCode,\n );\n // the broadcast above already ended the connected clients; finalize\n // still detaches them so removal is scheduled without waiting on a\n // transport `close` event\n this._finalizeStream(streamEntry);\n }\n });\n }\n\n private _combineSignals(\n internalSignal?: AbortSignal,\n userSignal?: AbortSignal,\n ): AbortSignal {\n if (!userSignal) return internalSignal || new AbortController().signal;\n\n const signals = [internalSignal, userSignal].filter(\n Boolean,\n ) as AbortSignal[];\n const controller = new AbortController();\n\n signals.forEach((signal) => {\n if (signal?.aborted) {\n controller.abort(signal.reason);\n return;\n }\n\n signal?.addEventListener(\n \"abort\",\n () => {\n controller.abort(signal.reason);\n },\n { once: true },\n );\n });\n return controller.signal;\n }\n\n // broadcast events to all connected clients\n private _broadcastEventsToClients(\n streamEntry: StreamEntry,\n eventId: string,\n event: any,\n ): void {\n for (const client of streamEntry.clients) {\n if (!client.writableEnded) {\n this.sseWriter.writeEvent(client, eventId, event);\n }\n }\n }\n\n // broadcast error to all connected clients\n private _broadcastErrorToClients(\n streamEntry: StreamEntry,\n eventId: string,\n errorMessage: string,\n errorCode: SSEErrorCode,\n closeClients: boolean = false,\n upstreamCode?: string,\n ): void {\n for (const client of streamEntry.clients) {\n if (!client.writableEnded) {\n this.sseWriter.writeError(\n client,\n eventId,\n errorMessage,\n errorCode,\n upstreamCode,\n );\n if (closeClients) {\n client.end();\n }\n }\n }\n }\n\n private _finalizeStream(streamEntry: StreamEntry): void {\n streamEntry.isCompleted = true;\n clearGraceTimer(streamEntry);\n this._closeAllClients(streamEntry);\n this._scheduleRemovalAfterTTL(streamEntry);\n }\n\n // close all connected clients and remove them from the stream entry.\n // We are deliberately terminating these connections, so cleanup must not\n // depend on the transport emitting a `close` event for each client (it may\n // never fire). The close handlers' later `clients.delete(...)` calls remain\n // safe no-ops.\n private _closeAllClients(streamEntry: StreamEntry): void {\n for (const client of streamEntry.clients) {\n if (!client.writableEnded) {\n client.end();\n }\n }\n streamEntry.clients.clear();\n }\n\n // abort the generator after the grace window unless a client reconnects first\n private _scheduleGraceAbort(streamEntry: StreamEntry): void {\n // clear any existing timer to avoid stacking\n clearGraceTimer(streamEntry);\n\n const timer = setTimeout(() => {\n streamEntry.disconnectGraceTimer = undefined;\n if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) {\n streamEntry.abortController.abort(\n new DOMException(\"Client disconnected (grace expired)\", \"AbortError\"),\n );\n }\n }, this.disconnectGraceMs);\n\n // never keep the process alive solely for a grace timer\n timer.unref?.();\n streamEntry.disconnectGraceTimer = timer;\n }\n\n // schedule registry removal once a finished (completed or errored) stream\n // has no connected clients. The event buffer stays available for\n // reconnect replay for `bufferTTL` after the last client disconnects;\n // after that the stream entry is removed from the registry so it can be\n // garbage collected.\n private _scheduleRemovalAfterTTL(streamEntry: StreamEntry): void {\n if (!streamEntry.isCompleted || streamEntry.clients.size > 0) {\n return;\n }\n\n // at most one removal timer per stream: rescheduling replaces any\n // pending timer instead of stacking a new one (each pending timer pins\n // the entry's buffer/generator/trace context for the full TTL)\n clearRemovalTimer(streamEntry);\n\n // mark the moment the stream became idle so a reconnect during the TTL\n // window (which refreshes lastAccess) makes the pending timer a no-op\n streamEntry.lastAccess = Date.now();\n\n streamEntry.removalTimer = setTimeout(() => {\n streamEntry.removalTimer = undefined;\n // safety net: no-op if a client reconnected during the TTL window;\n // a fresh removal is scheduled when that client disconnects\n if (\n streamEntry.clients.size === 0 &&\n Date.now() - streamEntry.lastAccess >= this.bufferTTL\n ) {\n this.streamRegistry.remove(streamEntry.streamId);\n }\n }, this.bufferTTL);\n\n // don't keep the process alive just to clean up finished streams\n streamEntry.removalTimer.unref?.();\n }\n\n private _categorizeError(error: unknown): SSEErrorCode {\n if (error instanceof Error) {\n const message = error.message.toLowerCase();\n if (message.includes(\"timeout\") || message.includes(\"timed out\")) {\n return SSEErrorCode.TIMEOUT;\n }\n\n if (message.includes(\"unavailable\") || message.includes(\"econnrefused\")) {\n return SSEErrorCode.TEMPORARY_UNAVAILABLE;\n }\n\n if (error.name === \"AbortError\") {\n return SSEErrorCode.STREAM_ABORTED;\n }\n\n // Defense-in-depth: upstream layers (SQL client, cache) may wrap an\n // AbortError into ExecutionError, losing `name` but keeping the message.\n if (\n message.includes(\"operation was aborted\") ||\n message.includes(\"the request was aborted\") ||\n message.includes(\"statement was canceled\")\n ) {\n return SSEErrorCode.STREAM_ABORTED;\n }\n\n // Detect upstream API errors (e.g., from Databricks SDK ApiError)\n if (\n \"statusCode\" in error &&\n typeof (error as any).statusCode === \"number\"\n ) {\n return SSEErrorCode.UPSTREAM_ERROR;\n }\n }\n\n return SSEErrorCode.INTERNAL_ERROR;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"stream-manager.js","names":[],"sources":["../../src/stream/stream-manager.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\n\nimport { context } from \"@opentelemetry/api\";\nimport type { IAppResponse, StreamConfig } from \"shared\";\n\nimport { normalizeIdentityError } from \"../context/execution-context\";\nimport { AppKitError } from \"../errors/base\";\nimport { ExecutionError } from \"../errors/execution\";\nimport { IdentityExpiredError } from \"../errors/identity-expired\";\nimport { createLogger } from \"../logging/logger\";\nimport { EventRingBuffer } from \"./buffers\";\nimport { streamDefaults } from \"./defaults\";\nimport { SSEWriter } from \"./sse-writer\";\nimport { StreamRegistry } from \"./stream-registry\";\nimport { clearGraceTimer, clearRemovalTimer } from \"./timers\";\nimport { SSEErrorCode, type StreamEntry, type StreamOperation } from \"./types\";\nimport { StreamValidator } from \"./validator\";\n\nconst logger = createLogger(\"stream\");\n\n// main entry point for Server-Sent events streaming\nexport class StreamManager {\n private activeOperations: Set<StreamOperation>;\n private streamRegistry: StreamRegistry;\n private sseWriter: SSEWriter;\n private maxEventSize: number;\n private bufferTTL: number;\n private disconnectGraceMs: number;\n\n constructor(options?: StreamConfig) {\n this.streamRegistry = new StreamRegistry(\n options?.maxActiveStreams ?? streamDefaults.maxActiveStreams,\n );\n this.sseWriter = new SSEWriter();\n this.maxEventSize = options?.maxEventSize ?? streamDefaults.maxEventSize;\n this.bufferTTL = options?.bufferTTL ?? streamDefaults.bufferTTL;\n this.disconnectGraceMs =\n options?.disconnectGraceMs ?? streamDefaults.disconnectGraceMs;\n this.activeOperations = new Set();\n }\n\n // main streaming method - handles new connection and reconnection\n async stream(\n res: IAppResponse,\n handler: (signal: AbortSignal) => AsyncGenerator<any, void, unknown>,\n options?: StreamConfig,\n ownerKey?: string,\n ): Promise<void> {\n const { streamId } = options || {};\n\n // check if response is already closed\n if (res.writableEnded || res.destroyed) {\n return;\n }\n\n // setup SSE headers\n this.sseWriter.setupHeaders(res);\n\n // handle reconnection\n if (streamId && StreamValidator.validateStreamId(streamId)) {\n const existingStream = this.streamRegistry.get(streamId);\n if (existingStream) {\n // Enforce per-user binding: the stream's owner key must match the\n // requesting caller's owner key. This prevents cross-user stream\n // takeover via guessed/leaked stream IDs (the SSE registry was\n // previously a global lookup with no authorization step).\n if (existingStream.ownerKey !== ownerKey) {\n this.sseWriter.writeError(\n res,\n randomUUID(),\n \"Stream not found or access denied\",\n SSEErrorCode.STREAM_FORBIDDEN,\n );\n res.end();\n return;\n }\n return this._attachToExistingStream(res, existingStream, options);\n }\n }\n\n // if stream does not exist, create a new one\n return this._createNewStream(res, handler, options, ownerKey);\n }\n\n // abort all active operations\n abortAll(): void {\n // pending disconnect-grace timers are cleared by streamRegistry.clear() below\n this.activeOperations.forEach((operation) => {\n if (operation.heartbeat) clearInterval(operation.heartbeat);\n operation.controller.abort(\n new DOMException(\"Server shutdown\", \"AbortError\"),\n );\n });\n this.activeOperations.clear();\n this.streamRegistry.clear();\n }\n\n // get the number of active operations\n getActiveCount(): number {\n return this.activeOperations.size;\n }\n\n // attach to existing stream\n private async _attachToExistingStream(\n res: IAppResponse,\n streamEntry: StreamEntry,\n options?: StreamConfig,\n ): Promise<void> {\n // handle reconnection - replay missed events\n const lastEventId = res.req?.headers[\"last-event-id\"];\n\n if (StreamValidator.validateEventId(lastEventId)) {\n // cast to string after validation\n const validEventId = lastEventId as string;\n if (streamEntry.eventBuffer.has(validEventId)) {\n const missedEvents =\n streamEntry.eventBuffer.getEventsSince(validEventId);\n // broadcast missed events to client\n for (const event of missedEvents) {\n if (options?.userSignal?.aborted) break;\n this.sseWriter.writeBufferedEvent(res, event);\n }\n } else {\n // buffer overflow - send warning\n this.sseWriter.writeBufferOverflowWarning(res, validEventId);\n }\n }\n\n // a reconnecting client cancels the pending disconnect-grace abort\n clearGraceTimer(streamEntry);\n\n // a reconnect cancels any pending registry removal so the entry isn't\n // pulled out from under the newly attached client\n clearRemovalTimer(streamEntry);\n\n // add client to stream entry\n streamEntry.clients.add(res);\n streamEntry.lastAccess = Date.now();\n\n // start heartbeat\n const combinedSignal = this._combineSignals(\n streamEntry.abortController.signal,\n options?.userSignal,\n );\n const heartbeat = this.sseWriter.startHeartbeat(res, combinedSignal);\n\n // track operation\n const streamOperation: StreamOperation = {\n controller: streamEntry.abortController,\n type: \"stream\",\n heartbeat,\n };\n this.activeOperations.add(streamOperation);\n\n // handle client disconnect\n res.on(\"close\", () => {\n clearInterval(heartbeat);\n streamEntry.clients.delete(res);\n this.activeOperations.delete(streamOperation);\n\n // grace-abort instead of aborting now, so a reconnect can resume\n if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) {\n this._scheduleGraceAbort(streamEntry);\n }\n\n // cleanup if stream is completed and no clients are connected\n this._scheduleRemovalAfterTTL(streamEntry);\n });\n\n // if stream is completed, close connection\n if (streamEntry.isCompleted) {\n res.end();\n // cleanup operation\n this.activeOperations.delete(streamOperation);\n clearInterval(heartbeat);\n // we deliberately ended this client, so drop it from the entry and\n // schedule removal now instead of relying on the transport's `close`\n // event (the close handler's later delete is a safe no-op)\n streamEntry.clients.delete(res);\n this._scheduleRemovalAfterTTL(streamEntry);\n }\n }\n private async _createNewStream(\n res: IAppResponse,\n handler: (signal: AbortSignal) => AsyncGenerator<any, void, unknown>,\n options?: StreamConfig,\n ownerKey?: string,\n ): Promise<void> {\n const streamId = options?.streamId ?? randomUUID();\n\n // abort stream if response is closed\n if (res.writableEnded || res.destroyed) {\n return;\n }\n\n const abortController = new AbortController();\n\n // create event buffer\n const eventBuffer = new EventRingBuffer(\n options?.bufferSize ?? streamDefaults.bufferSize,\n );\n const maxEventSize = options?.maxEventSize ?? this.maxEventSize;\n\n // setup signals and heartbeat\n const combinedSignal = this._combineSignals(\n abortController.signal,\n options?.userSignal,\n );\n const heartbeat = this.sseWriter.startHeartbeat(res, combinedSignal);\n\n // capture the current trace context at stream creation time\n const traceContext = context.active();\n\n // abort stream if response is closed\n if (res.writableEnded || res.destroyed) {\n clearInterval(heartbeat);\n return;\n }\n\n // create stream entry\n const streamEntry: StreamEntry = {\n streamId,\n ownerKey,\n generator: handler(combinedSignal),\n eventBuffer,\n clients: new Set([res]),\n isCompleted: false,\n lastAccess: Date.now(),\n abortController,\n traceContext,\n maxEventSize,\n };\n this.streamRegistry.add(streamEntry);\n\n // track operation\n const streamOperation: StreamOperation = {\n controller: abortController,\n type: \"stream\",\n heartbeat,\n };\n this.activeOperations.add(streamOperation);\n\n res.on(\"close\", () => {\n clearInterval(heartbeat);\n this.activeOperations.delete(streamOperation);\n streamEntry.clients.delete(res);\n\n // grace-abort instead of aborting now, so a reconnect can resume\n if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) {\n this._scheduleGraceAbort(streamEntry);\n }\n\n // if the stream already finished (completed or errored), schedule\n // registry removal once the buffer TTL elapses so completed streams\n // don't accumulate in the registry forever\n this._scheduleRemovalAfterTTL(streamEntry);\n });\n\n await this._processGeneratorInBackground(streamEntry);\n\n // cleanup\n clearInterval(heartbeat);\n this.activeOperations.delete(streamOperation);\n }\n\n private async _processGeneratorInBackground(\n streamEntry: StreamEntry,\n ): Promise<void> {\n // run the entire generator processing within the captured trace context\n return context.with(streamEntry.traceContext, async () => {\n try {\n // retrieve all events from generator\n for await (const event of streamEntry.generator) {\n if (streamEntry.abortController.signal.aborted) break;\n const eventId = randomUUID();\n const eventData = JSON.stringify(event);\n const { maxEventSize } = streamEntry;\n\n // UTF-8 bytes, not `String.length`: non-ASCII payloads used to slip\n // past a limit they exceeded on the wire.\n if (Buffer.byteLength(eventData, \"utf8\") > maxEventSize) {\n const errorMsg = `Event exceeds max size of ${maxEventSize} bytes`;\n const errorCode = SSEErrorCode.INVALID_REQUEST;\n // broadcast error to all connected clients\n this._broadcastErrorToClients(\n streamEntry,\n eventId,\n errorMsg,\n errorCode,\n );\n continue;\n }\n\n // buffer event for reconnection\n streamEntry.eventBuffer.add({\n id: eventId,\n type: event.type,\n data: eventData,\n timestamp: Date.now(),\n });\n\n // broadcast to all connected clients\n this._broadcastEventsToClients(streamEntry, eventId, event);\n streamEntry.lastAccess = Date.now();\n }\n\n this._finalizeStream(streamEntry);\n } catch (caught) {\n const error = normalizeIdentityError(caught);\n // Two distinct messages: a *raw* one for server-side logs (full\n // detail, statement fragments, correlation IDs) and a *client*\n // one for the SSE payload (sanitized, stable, safe to render in\n // a UI). Mixing them leaks upstream wording to anyone connected\n // to the stream — see CWE-209.\n const rawMsg =\n error instanceof Error ? error.message : \"Internal server error\";\n const clientMsg =\n error instanceof AppKitError\n ? error.clientMessage\n : \"Internal server error\";\n // Upstream structured code (e.g. RESULT_TOO_LARGE_FOR_JSON_FALLBACK,\n // NOT_IMPLEMENTED). UI should branch on this, not on `error`.\n const upstreamCode =\n error instanceof IdentityExpiredError\n ? error.code\n : error instanceof ExecutionError\n ? error.errorCode\n : undefined;\n const errorEventId = randomUUID();\n const errorCode = this._categorizeError(error);\n\n // client cancellation is a normal control-flow signal, not a failure\n if (errorCode === SSEErrorCode.STREAM_ABORTED) {\n logger.info(\"Stream aborted by client (code=%s)\", errorCode);\n this._finalizeStream(streamEntry);\n return;\n }\n\n logger.error(\n \"Stream execution failed: %s (code=%s upstreamCode=%s)\",\n rawMsg,\n errorCode,\n upstreamCode ?? \"n/a\",\n );\n\n const payload: Record<string, unknown> = {\n error: clientMsg,\n code: errorCode,\n };\n if (upstreamCode) payload.errorCode = upstreamCode;\n\n // buffer error event\n streamEntry.eventBuffer.add({\n id: errorEventId,\n type: \"error\",\n data: JSON.stringify(payload),\n timestamp: Date.now(),\n });\n\n // send error event to all connected clients\n this._broadcastErrorToClients(\n streamEntry,\n errorEventId,\n clientMsg,\n errorCode,\n true,\n upstreamCode,\n );\n // the broadcast above already ended the connected clients; finalize\n // still detaches them so removal is scheduled without waiting on a\n // transport `close` event\n this._finalizeStream(streamEntry);\n }\n });\n }\n\n private _combineSignals(\n internalSignal?: AbortSignal,\n userSignal?: AbortSignal,\n ): AbortSignal {\n if (!userSignal) return internalSignal || new AbortController().signal;\n\n const signals = [internalSignal, userSignal].filter(\n Boolean,\n ) as AbortSignal[];\n const controller = new AbortController();\n\n signals.forEach((signal) => {\n if (signal?.aborted) {\n controller.abort(signal.reason);\n return;\n }\n\n signal?.addEventListener(\n \"abort\",\n () => {\n controller.abort(signal.reason);\n },\n { once: true },\n );\n });\n return controller.signal;\n }\n\n // broadcast events to all connected clients\n private _broadcastEventsToClients(\n streamEntry: StreamEntry,\n eventId: string,\n event: any,\n ): void {\n for (const client of streamEntry.clients) {\n if (!client.writableEnded) {\n this.sseWriter.writeEvent(client, eventId, event);\n }\n }\n }\n\n // broadcast error to all connected clients\n private _broadcastErrorToClients(\n streamEntry: StreamEntry,\n eventId: string,\n errorMessage: string,\n errorCode: SSEErrorCode,\n closeClients: boolean = false,\n upstreamCode?: string,\n ): void {\n for (const client of streamEntry.clients) {\n if (!client.writableEnded) {\n this.sseWriter.writeError(\n client,\n eventId,\n errorMessage,\n errorCode,\n upstreamCode,\n );\n if (closeClients) {\n client.end();\n }\n }\n }\n }\n\n private _finalizeStream(streamEntry: StreamEntry): void {\n streamEntry.isCompleted = true;\n clearGraceTimer(streamEntry);\n this._closeAllClients(streamEntry);\n this._scheduleRemovalAfterTTL(streamEntry);\n }\n\n // close all connected clients and remove them from the stream entry.\n // We are deliberately terminating these connections, so cleanup must not\n // depend on the transport emitting a `close` event for each client (it may\n // never fire). The close handlers' later `clients.delete(...)` calls remain\n // safe no-ops.\n private _closeAllClients(streamEntry: StreamEntry): void {\n for (const client of streamEntry.clients) {\n if (!client.writableEnded) {\n client.end();\n }\n }\n streamEntry.clients.clear();\n }\n\n // abort the generator after the grace window unless a client reconnects first\n private _scheduleGraceAbort(streamEntry: StreamEntry): void {\n // clear any existing timer to avoid stacking\n clearGraceTimer(streamEntry);\n\n const timer = setTimeout(() => {\n streamEntry.disconnectGraceTimer = undefined;\n if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) {\n streamEntry.abortController.abort(\n new DOMException(\"Client disconnected (grace expired)\", \"AbortError\"),\n );\n }\n }, this.disconnectGraceMs);\n\n // never keep the process alive solely for a grace timer\n timer.unref?.();\n streamEntry.disconnectGraceTimer = timer;\n }\n\n // schedule registry removal once a finished (completed or errored) stream\n // has no connected clients. The event buffer stays available for\n // reconnect replay for `bufferTTL` after the last client disconnects;\n // after that the stream entry is removed from the registry so it can be\n // garbage collected.\n private _scheduleRemovalAfterTTL(streamEntry: StreamEntry): void {\n if (!streamEntry.isCompleted || streamEntry.clients.size > 0) {\n return;\n }\n\n // at most one removal timer per stream: rescheduling replaces any\n // pending timer instead of stacking a new one (each pending timer pins\n // the entry's buffer/generator/trace context for the full TTL)\n clearRemovalTimer(streamEntry);\n\n // mark the moment the stream became idle so a reconnect during the TTL\n // window (which refreshes lastAccess) makes the pending timer a no-op\n streamEntry.lastAccess = Date.now();\n\n streamEntry.removalTimer = setTimeout(() => {\n streamEntry.removalTimer = undefined;\n // safety net: no-op if a client reconnected during the TTL window;\n // a fresh removal is scheduled when that client disconnects\n if (\n streamEntry.clients.size === 0 &&\n Date.now() - streamEntry.lastAccess >= this.bufferTTL\n ) {\n this.streamRegistry.remove(streamEntry.streamId);\n }\n }, this.bufferTTL);\n\n // don't keep the process alive just to clean up finished streams\n streamEntry.removalTimer.unref?.();\n }\n\n private _categorizeError(error: unknown): SSEErrorCode {\n if (error instanceof Error) {\n const message = error.message.toLowerCase();\n if (message.includes(\"timeout\") || message.includes(\"timed out\")) {\n return SSEErrorCode.TIMEOUT;\n }\n\n if (message.includes(\"unavailable\") || message.includes(\"econnrefused\")) {\n return SSEErrorCode.TEMPORARY_UNAVAILABLE;\n }\n\n if (error.name === \"AbortError\") {\n return SSEErrorCode.STREAM_ABORTED;\n }\n\n // Defense-in-depth: upstream layers (SQL client, cache) may wrap an\n // AbortError into ExecutionError, losing `name` but keeping the message.\n if (\n message.includes(\"operation was aborted\") ||\n message.includes(\"the request was aborted\") ||\n message.includes(\"statement was canceled\")\n ) {\n return SSEErrorCode.STREAM_ABORTED;\n }\n\n // Detect upstream API errors (e.g., from Databricks SDK ApiError)\n if (\n \"statusCode\" in error &&\n typeof (error as any).statusCode === \"number\"\n ) {\n return SSEErrorCode.UPSTREAM_ERROR;\n }\n }\n\n return SSEErrorCode.INTERNAL_ERROR;\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ServiceContext } from "../context/service-context.js";
|
|
2
|
+
import { getCallerContext, getCurrentActorId } from "../context/execution-context.js";
|
|
3
|
+
|
|
4
|
+
//#region src/telemetry/execution-span-processor.ts
|
|
5
|
+
/** Attach identity at span creation, including cache, tool, and connector spans. */
|
|
6
|
+
var ExecutionSpanProcessor = class {
|
|
7
|
+
onStart(span, _parent) {
|
|
8
|
+
const caller = getCallerContext();
|
|
9
|
+
span.setAttribute("appkit.execution.principal", caller ? "user" : "app");
|
|
10
|
+
span.setAttribute("appkit.execution.principal_id", caller?.principal.userId ?? (ServiceContext.isInitialized() ? ServiceContext.get().serviceUserId : "app"));
|
|
11
|
+
const actor = getCurrentActorId();
|
|
12
|
+
if (actor) span.setAttribute("appkit.execution.actor_id", actor);
|
|
13
|
+
}
|
|
14
|
+
onEnd(_span) {}
|
|
15
|
+
async forceFlush() {}
|
|
16
|
+
async shutdown() {}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
//#endregion
|
|
20
|
+
export { ExecutionSpanProcessor };
|
|
21
|
+
//# sourceMappingURL=execution-span-processor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"execution-span-processor.js","names":[],"sources":["../../src/telemetry/execution-span-processor.ts"],"sourcesContent":["import type { Context } from \"@opentelemetry/api\";\nimport type {\n ReadableSpan,\n Span,\n SpanProcessor,\n} from \"@opentelemetry/sdk-trace-base\";\n\nimport {\n getCallerContext,\n getCurrentActorId,\n} from \"../context/execution-context\";\nimport { ServiceContext } from \"../context/service-context\";\n\n/** Attach identity at span creation, including cache, tool, and connector spans. */\nexport class ExecutionSpanProcessor implements SpanProcessor {\n onStart(span: Span, _parent: Context): void {\n const caller = getCallerContext();\n span.setAttribute(\"appkit.execution.principal\", caller ? \"user\" : \"app\");\n span.setAttribute(\n \"appkit.execution.principal_id\",\n caller?.principal.userId ??\n (ServiceContext.isInitialized()\n ? ServiceContext.get().serviceUserId\n : \"app\"),\n );\n const actor = getCurrentActorId();\n if (actor) span.setAttribute(\"appkit.execution.actor_id\", actor);\n }\n onEnd(_span: ReadableSpan): void {}\n async forceFlush(): Promise<void> {}\n async shutdown(): Promise<void> {}\n}\n"],"mappings":";;;;;AAcA,IAAa,yBAAb,MAA6D;CAC3D,QAAQ,MAAY,SAAwB;EAC1C,MAAM,SAAS,kBAAkB;AACjC,OAAK,aAAa,8BAA8B,SAAS,SAAS,MAAM;AACxE,OAAK,aACH,iCACA,QAAQ,UAAU,WACf,eAAe,eAAe,GAC3B,eAAe,KAAK,CAAC,gBACrB,OACP;EACD,MAAM,QAAQ,mBAAmB;AACjC,MAAI,MAAO,MAAK,aAAa,6BAA6B,MAAM;;CAElE,MAAM,OAA2B;CACjC,MAAM,aAA4B;CAClC,MAAM,WAA0B"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createLogger } from "../logging/logger.js";
|
|
2
|
+
import { ExecutionSpanProcessor } from "./execution-span-processor.js";
|
|
2
3
|
import { TelemetryProvider } from "./telemetry-provider.js";
|
|
3
4
|
import { AppKitSampler } from "./trace-sampler.js";
|
|
4
5
|
import { metrics } from "@opentelemetry/api";
|
|
@@ -132,7 +133,7 @@ var TelemetryManager = class TelemetryManager {
|
|
|
132
133
|
this.tracerProvider = new NodeTracerProvider({
|
|
133
134
|
resource: this.resource,
|
|
134
135
|
sampler: new AppKitSampler(),
|
|
135
|
-
spanProcessors: this.spanProcessors
|
|
136
|
+
spanProcessors: [new ExecutionSpanProcessor(), ...this.spanProcessors]
|
|
136
137
|
});
|
|
137
138
|
this.tracerProvider.register();
|
|
138
139
|
logger.debug("Tracer provider started with %d span processor(s)", this.spanProcessors.length);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"telemetry-manager.js","names":[],"sources":["../../src/telemetry/telemetry-manager.ts"],"sourcesContent":["import { metrics } from \"@opentelemetry/api\";\nimport { logs } from \"@opentelemetry/api-logs\";\nimport { getNodeAutoInstrumentations } from \"@opentelemetry/auto-instrumentations-node\";\nimport { OTLPLogExporter } from \"@opentelemetry/exporter-logs-otlp-proto\";\nimport { OTLPMetricExporter } from \"@opentelemetry/exporter-metrics-otlp-proto\";\nimport { OTLPTraceExporter } from \"@opentelemetry/exporter-trace-otlp-proto\";\nimport {\n type Instrumentation,\n registerInstrumentations as otelRegisterInstrumentations,\n} from \"@opentelemetry/instrumentation\";\nimport {\n detectResources,\n envDetector,\n hostDetector,\n processDetector,\n type Resource,\n resourceFromAttributes,\n} from \"@opentelemetry/resources\";\nimport {\n BatchLogRecordProcessor,\n LoggerProvider,\n} from \"@opentelemetry/sdk-logs\";\nimport {\n MeterProvider,\n PeriodicExportingMetricReader,\n} from \"@opentelemetry/sdk-metrics\";\nimport {\n BatchSpanProcessor,\n type SpanProcessor,\n} from \"@opentelemetry/sdk-trace-base\";\nimport { NodeTracerProvider } from \"@opentelemetry/sdk-trace-node\";\nimport {\n ATTR_SERVICE_NAME,\n ATTR_SERVICE_VERSION,\n} from \"@opentelemetry/semantic-conventions\";\nimport type { TelemetryOptions } from \"shared\";\n\nimport { createLogger } from \"../logging/logger\";\nimport { TelemetryProvider } from \"./telemetry-provider\";\nimport { AppKitSampler } from \"./trace-sampler\";\nimport type { TelemetryConfig } from \"./types\";\n\nconst logger = createLogger(\"telemetry\");\n\n/**\n * Owns the app's OpenTelemetry providers, split into two phases so plugins can\n * contribute trace span processors before the tracer provider is built.\n *\n * - `initialize()` runs at app bootstrap, before plugin setup. It registers the\n * meter and logger providers eagerly, because OTel's metrics API has no lazy\n * proxy: a counter/histogram bound against the NoOp meter (as every connector\n * and the cache do in their constructors) stays NoOp for the process lifetime.\n * It does NOT register a tracer provider.\n * - `registerSpanProcessor()` is called by plugins during `setup()` to add a\n * span processor (e.g. an MLflow exporter) to the not-yet-built tracer.\n * - `start()` runs after all plugin `setup()` completes. It builds the single\n * global tracer provider with the OTLP processor (if configured) plus every\n * contributed processor. Deferring is safe for traces: OTel's ProxyTracer\n * rebinds tracers obtained before registration, and no span is emitted during\n * setup.\n */\nexport class TelemetryManager {\n private static readonly DEFAULT_EXPORT_INTERVAL_MS = 10000;\n private static readonly DEFAULT_FALLBACK_APP_NAME = \"databricks-app\";\n\n private static instance?: TelemetryManager;\n private resource?: Resource;\n private meterProvider?: MeterProvider;\n private loggerProvider?: LoggerProvider;\n private tracerProvider?: NodeTracerProvider;\n private readonly spanProcessors: SpanProcessor[] = [];\n private started = false;\n private shutdownPromise?: Promise<void>;\n\n /**\n * Create a scoped telemetry provider for a specific plugin.\n * The plugin's name will be used as the default tracer/meter name.\n * @param pluginName - The name of the plugin to create scoped telemetry for\n * @param telemetryConfig - The telemetry configuration for the plugin\n * @returns A scoped telemetry instance for the plugin\n */\n static getProvider(\n pluginName: string,\n telemetryConfig?: TelemetryOptions,\n ): TelemetryProvider {\n const globalManager = TelemetryManager.getInstance();\n return new TelemetryProvider(pluginName, globalManager, telemetryConfig);\n }\n\n private constructor() {}\n\n static getInstance(): TelemetryManager {\n if (!TelemetryManager.instance) {\n TelemetryManager.instance = new TelemetryManager();\n }\n return TelemetryManager.instance;\n }\n\n static initialize(config: Partial<TelemetryConfig> = {}): void {\n const instance = TelemetryManager.getInstance();\n instance._initialize(config);\n }\n\n /**\n * Contribute a span processor to the not-yet-built tracer provider. Called by\n * plugins during `setup()`. No-op with a warning once `start()` has run, since\n * a started provider's processors are immutable in OTel JS 2.x.\n */\n static registerSpanProcessor(processor: SpanProcessor): void {\n TelemetryManager.getInstance()._registerSpanProcessor(processor);\n }\n\n private _registerSpanProcessor(processor: SpanProcessor): void {\n if (this.started) {\n logger.warn(\n \"registerSpanProcessor called after start(); processor ignored. \" +\n \"Contribute span processors during plugin setup().\",\n );\n return;\n }\n this.spanProcessors.push(processor);\n }\n\n /**\n * Phase 1: register the meter and logger providers eagerly (before plugin\n * setup), so metric instruments bound in connector/cache constructors attach\n * to real meters. The tracer provider is deferred to `start()`.\n *\n * When no OTLP endpoint is configured, meter/logger registration is skipped;\n * a contributed span processor can still bring up tracing in `start()`.\n */\n private _initialize(config: Partial<TelemetryConfig>): void {\n if (this.resource) return;\n this.resource = this.createResource(config);\n\n // OTLP exporters need an endpoint. Without one there is nothing to export\n // metrics/logs to, so skip those providers — but still capture the resource\n // and let `start()` bring up a tracer if a plugin contributed a processor.\n if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {\n return;\n }\n\n try {\n this.meterProvider = new MeterProvider({\n resource: this.resource,\n readers: [\n new PeriodicExportingMetricReader({\n exporter: new OTLPMetricExporter({ headers: config.headers }),\n exportIntervalMillis:\n config.exportIntervalMs ||\n TelemetryManager.DEFAULT_EXPORT_INTERVAL_MS,\n }),\n ],\n });\n metrics.setGlobalMeterProvider(this.meterProvider);\n\n this.loggerProvider = new LoggerProvider({\n resource: this.resource,\n processors: [\n new BatchLogRecordProcessor(\n new OTLPLogExporter({ headers: config.headers }),\n ),\n ],\n });\n logs.setGlobalLoggerProvider(this.loggerProvider);\n\n // The OTLP trace exporter is the first span processor; contributed\n // processors join it in `start()`.\n this.spanProcessors.push(\n new BatchSpanProcessor(\n new OTLPTraceExporter({ headers: config.headers }),\n ),\n );\n\n this.registerInstrumentations(this.getDefaultInstrumentations());\n logger.debug(\"Meter/logger providers initialized\");\n } catch (error) {\n logger.error(\"Failed to initialize: %O\", error);\n }\n }\n\n /**\n * Phase 2: build and register the global tracer provider. Called by core\n * after every plugin's `setup()` completes, so all contributed span\n * processors are known. No-op when nothing needs tracing (no OTLP endpoint\n * and no contributed processor), preserving \"no telemetry unless configured\".\n *\n * `NodeTracerProvider.register()` installs the async-hooks context manager and\n * W3C propagators — the same wiring `NodeSDK.start()` did — so span nesting\n * across awaits is preserved.\n */\n static start(): void {\n TelemetryManager.getInstance()._start();\n }\n\n private _start(): void {\n if (this.started) return;\n this.started = true;\n\n if (this.spanProcessors.length === 0) {\n return;\n }\n\n try {\n this.tracerProvider = new NodeTracerProvider({\n resource: this.resource,\n sampler: new AppKitSampler(),\n spanProcessors: this.spanProcessors,\n });\n this.tracerProvider.register();\n logger.debug(\n \"Tracer provider started with %d span processor(s)\",\n this.spanProcessors.length,\n );\n } catch (error) {\n logger.error(\"Failed to start tracer provider: %O\", error);\n }\n }\n\n /**\n * Register OpenTelemetry instrumentations.\n * Can be called at any time, but recommended to call in plugin constructor.\n * @param instrumentations - Array of OpenTelemetry instrumentations to register\n */\n registerInstrumentations(instrumentations: Instrumentation[]): void {\n otelRegisterInstrumentations({\n // Instrumentations bind to the global providers registered by start()\n // (tracer) and _initialize() (meter/logger).\n instrumentations,\n });\n }\n\n private createResource(config: Partial<TelemetryConfig>): Resource {\n const serviceName =\n config.serviceName ||\n process.env.OTEL_SERVICE_NAME ||\n process.env.DATABRICKS_APP_NAME ||\n TelemetryManager.DEFAULT_FALLBACK_APP_NAME;\n const initialResource = resourceFromAttributes({\n [ATTR_SERVICE_NAME]: serviceName,\n [ATTR_SERVICE_VERSION]: config.serviceVersion ?? undefined,\n });\n const detectedResource = detectResources({\n detectors: [envDetector, hostDetector, processDetector],\n });\n return initialResource.merge(detectedResource);\n }\n\n private getDefaultInstrumentations(): Instrumentation[] {\n return [\n ...getNodeAutoInstrumentations({\n //\n // enabled as a part of the server plugin\n //\n \"@opentelemetry/instrumentation-http\": {\n enabled: false,\n },\n \"@opentelemetry/instrumentation-express\": {\n enabled: false,\n },\n //\n // reduce noise\n //\n \"@opentelemetry/instrumentation-fs\": {\n enabled: false,\n },\n \"@opentelemetry/instrumentation-dns\": {\n enabled: false,\n },\n \"@opentelemetry/instrumentation-net\": {\n enabled: false,\n },\n }),\n ];\n }\n\n /**\n * Flush and shut down the tracer, meter, and logger providers.\n *\n * Idempotent: the provider references are cleared synchronously and concurrent\n * or repeated calls await the same in-flight flush. Awaited by the core\n * lifecycle manager during graceful shutdown — that manager owns the\n * process signal handlers, so telemetry no longer registers its own.\n *\n * Survives re-`initialize()`. `shutdownPromise` is deliberately *not* cleared\n * when the flush settles, and that is safe: the memo is only ever reassigned\n * for whatever providers are currently live, so a stale resolved promise can\n * only be returned when there is nothing to flush. The covering test asserts\n * every provider set across repeated initialize/shutdown cycles is flushed.\n */\n async shutdown(): Promise<void> {\n const providers = [\n this.tracerProvider,\n this.meterProvider,\n this.loggerProvider,\n ].filter((p): p is NonNullable<typeof p> => p !== undefined);\n\n if (providers.length > 0) {\n this.tracerProvider = undefined;\n this.meterProvider = undefined;\n this.loggerProvider = undefined;\n this.shutdownPromise = (async () => {\n await Promise.all(\n providers.map(async (provider) => {\n try {\n await provider.shutdown();\n } catch (error) {\n logger.error(\"Error shutting down: %O\", error);\n }\n }),\n );\n })();\n }\n\n return this.shutdownPromise;\n }\n\n /**\n * Drop the singleton so the next {@link getInstance} builds a fresh manager.\n *\n * Does not flush: callers `shutdown()` first, then reset — the order\n * `LifecycleManager.shutdown()` uses.\n *\n * @internal\n */\n static reset(): void {\n TelemetryManager.instance = undefined;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"telemetry-manager.js","names":[],"sources":["../../src/telemetry/telemetry-manager.ts"],"sourcesContent":["import { metrics } from \"@opentelemetry/api\";\nimport { logs } from \"@opentelemetry/api-logs\";\nimport { getNodeAutoInstrumentations } from \"@opentelemetry/auto-instrumentations-node\";\nimport { OTLPLogExporter } from \"@opentelemetry/exporter-logs-otlp-proto\";\nimport { OTLPMetricExporter } from \"@opentelemetry/exporter-metrics-otlp-proto\";\nimport { OTLPTraceExporter } from \"@opentelemetry/exporter-trace-otlp-proto\";\nimport {\n type Instrumentation,\n registerInstrumentations as otelRegisterInstrumentations,\n} from \"@opentelemetry/instrumentation\";\nimport {\n detectResources,\n envDetector,\n hostDetector,\n processDetector,\n type Resource,\n resourceFromAttributes,\n} from \"@opentelemetry/resources\";\nimport {\n BatchLogRecordProcessor,\n LoggerProvider,\n} from \"@opentelemetry/sdk-logs\";\nimport {\n MeterProvider,\n PeriodicExportingMetricReader,\n} from \"@opentelemetry/sdk-metrics\";\nimport {\n BatchSpanProcessor,\n type SpanProcessor,\n} from \"@opentelemetry/sdk-trace-base\";\nimport { NodeTracerProvider } from \"@opentelemetry/sdk-trace-node\";\nimport {\n ATTR_SERVICE_NAME,\n ATTR_SERVICE_VERSION,\n} from \"@opentelemetry/semantic-conventions\";\nimport type { TelemetryOptions } from \"shared\";\n\nimport { createLogger } from \"../logging/logger\";\nimport { ExecutionSpanProcessor } from \"./execution-span-processor\";\nimport { TelemetryProvider } from \"./telemetry-provider\";\nimport { AppKitSampler } from \"./trace-sampler\";\nimport type { TelemetryConfig } from \"./types\";\n\nconst logger = createLogger(\"telemetry\");\n\n/**\n * Owns the app's OpenTelemetry providers, split into two phases so plugins can\n * contribute trace span processors before the tracer provider is built.\n *\n * - `initialize()` runs at app bootstrap, before plugin setup. It registers the\n * meter and logger providers eagerly, because OTel's metrics API has no lazy\n * proxy: a counter/histogram bound against the NoOp meter (as every connector\n * and the cache do in their constructors) stays NoOp for the process lifetime.\n * It does NOT register a tracer provider.\n * - `registerSpanProcessor()` is called by plugins during `setup()` to add a\n * span processor (e.g. an MLflow exporter) to the not-yet-built tracer.\n * - `start()` runs after all plugin `setup()` completes. It builds the single\n * global tracer provider with the OTLP processor (if configured) plus every\n * contributed processor. Deferring is safe for traces: OTel's ProxyTracer\n * rebinds tracers obtained before registration, and no span is emitted during\n * setup.\n */\nexport class TelemetryManager {\n private static readonly DEFAULT_EXPORT_INTERVAL_MS = 10000;\n private static readonly DEFAULT_FALLBACK_APP_NAME = \"databricks-app\";\n\n private static instance?: TelemetryManager;\n private resource?: Resource;\n private meterProvider?: MeterProvider;\n private loggerProvider?: LoggerProvider;\n private tracerProvider?: NodeTracerProvider;\n private readonly spanProcessors: SpanProcessor[] = [];\n private started = false;\n private shutdownPromise?: Promise<void>;\n\n /**\n * Create a scoped telemetry provider for a specific plugin.\n * The plugin's name will be used as the default tracer/meter name.\n * @param pluginName - The name of the plugin to create scoped telemetry for\n * @param telemetryConfig - The telemetry configuration for the plugin\n * @returns A scoped telemetry instance for the plugin\n */\n static getProvider(\n pluginName: string,\n telemetryConfig?: TelemetryOptions,\n ): TelemetryProvider {\n const globalManager = TelemetryManager.getInstance();\n return new TelemetryProvider(pluginName, globalManager, telemetryConfig);\n }\n\n private constructor() {}\n\n static getInstance(): TelemetryManager {\n if (!TelemetryManager.instance) {\n TelemetryManager.instance = new TelemetryManager();\n }\n return TelemetryManager.instance;\n }\n\n static initialize(config: Partial<TelemetryConfig> = {}): void {\n const instance = TelemetryManager.getInstance();\n instance._initialize(config);\n }\n\n /**\n * Contribute a span processor to the not-yet-built tracer provider. Called by\n * plugins during `setup()`. No-op with a warning once `start()` has run, since\n * a started provider's processors are immutable in OTel JS 2.x.\n */\n static registerSpanProcessor(processor: SpanProcessor): void {\n TelemetryManager.getInstance()._registerSpanProcessor(processor);\n }\n\n private _registerSpanProcessor(processor: SpanProcessor): void {\n if (this.started) {\n logger.warn(\n \"registerSpanProcessor called after start(); processor ignored. \" +\n \"Contribute span processors during plugin setup().\",\n );\n return;\n }\n this.spanProcessors.push(processor);\n }\n\n /**\n * Phase 1: register the meter and logger providers eagerly (before plugin\n * setup), so metric instruments bound in connector/cache constructors attach\n * to real meters. The tracer provider is deferred to `start()`.\n *\n * When no OTLP endpoint is configured, meter/logger registration is skipped;\n * a contributed span processor can still bring up tracing in `start()`.\n */\n private _initialize(config: Partial<TelemetryConfig>): void {\n if (this.resource) return;\n this.resource = this.createResource(config);\n\n // OTLP exporters need an endpoint. Without one there is nothing to export\n // metrics/logs to, so skip those providers — but still capture the resource\n // and let `start()` bring up a tracer if a plugin contributed a processor.\n if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {\n return;\n }\n\n try {\n this.meterProvider = new MeterProvider({\n resource: this.resource,\n readers: [\n new PeriodicExportingMetricReader({\n exporter: new OTLPMetricExporter({ headers: config.headers }),\n exportIntervalMillis:\n config.exportIntervalMs ||\n TelemetryManager.DEFAULT_EXPORT_INTERVAL_MS,\n }),\n ],\n });\n metrics.setGlobalMeterProvider(this.meterProvider);\n\n this.loggerProvider = new LoggerProvider({\n resource: this.resource,\n processors: [\n new BatchLogRecordProcessor(\n new OTLPLogExporter({ headers: config.headers }),\n ),\n ],\n });\n logs.setGlobalLoggerProvider(this.loggerProvider);\n\n // The OTLP trace exporter is the first span processor; contributed\n // processors join it in `start()`.\n this.spanProcessors.push(\n new BatchSpanProcessor(\n new OTLPTraceExporter({ headers: config.headers }),\n ),\n );\n\n this.registerInstrumentations(this.getDefaultInstrumentations());\n logger.debug(\"Meter/logger providers initialized\");\n } catch (error) {\n logger.error(\"Failed to initialize: %O\", error);\n }\n }\n\n /**\n * Phase 2: build and register the global tracer provider. Called by core\n * after every plugin's `setup()` completes, so all contributed span\n * processors are known. No-op when nothing needs tracing (no OTLP endpoint\n * and no contributed processor), preserving \"no telemetry unless configured\".\n *\n * `NodeTracerProvider.register()` installs the async-hooks context manager and\n * W3C propagators — the same wiring `NodeSDK.start()` did — so span nesting\n * across awaits is preserved.\n */\n static start(): void {\n TelemetryManager.getInstance()._start();\n }\n\n private _start(): void {\n if (this.started) return;\n this.started = true;\n\n if (this.spanProcessors.length === 0) {\n return;\n }\n\n try {\n this.tracerProvider = new NodeTracerProvider({\n resource: this.resource,\n sampler: new AppKitSampler(),\n spanProcessors: [new ExecutionSpanProcessor(), ...this.spanProcessors],\n });\n this.tracerProvider.register();\n logger.debug(\n \"Tracer provider started with %d span processor(s)\",\n this.spanProcessors.length,\n );\n } catch (error) {\n logger.error(\"Failed to start tracer provider: %O\", error);\n }\n }\n\n /**\n * Register OpenTelemetry instrumentations.\n * Can be called at any time, but recommended to call in plugin constructor.\n * @param instrumentations - Array of OpenTelemetry instrumentations to register\n */\n registerInstrumentations(instrumentations: Instrumentation[]): void {\n otelRegisterInstrumentations({\n // Instrumentations bind to the global providers registered by start()\n // (tracer) and _initialize() (meter/logger).\n instrumentations,\n });\n }\n\n private createResource(config: Partial<TelemetryConfig>): Resource {\n const serviceName =\n config.serviceName ||\n process.env.OTEL_SERVICE_NAME ||\n process.env.DATABRICKS_APP_NAME ||\n TelemetryManager.DEFAULT_FALLBACK_APP_NAME;\n const initialResource = resourceFromAttributes({\n [ATTR_SERVICE_NAME]: serviceName,\n [ATTR_SERVICE_VERSION]: config.serviceVersion ?? undefined,\n });\n const detectedResource = detectResources({\n detectors: [envDetector, hostDetector, processDetector],\n });\n return initialResource.merge(detectedResource);\n }\n\n private getDefaultInstrumentations(): Instrumentation[] {\n return [\n ...getNodeAutoInstrumentations({\n //\n // enabled as a part of the server plugin\n //\n \"@opentelemetry/instrumentation-http\": {\n enabled: false,\n },\n \"@opentelemetry/instrumentation-express\": {\n enabled: false,\n },\n //\n // reduce noise\n //\n \"@opentelemetry/instrumentation-fs\": {\n enabled: false,\n },\n \"@opentelemetry/instrumentation-dns\": {\n enabled: false,\n },\n \"@opentelemetry/instrumentation-net\": {\n enabled: false,\n },\n }),\n ];\n }\n\n /**\n * Flush and shut down the tracer, meter, and logger providers.\n *\n * Idempotent: the provider references are cleared synchronously and concurrent\n * or repeated calls await the same in-flight flush. Awaited by the core\n * lifecycle manager during graceful shutdown — that manager owns the\n * process signal handlers, so telemetry no longer registers its own.\n *\n * Survives re-`initialize()`. `shutdownPromise` is deliberately *not* cleared\n * when the flush settles, and that is safe: the memo is only ever reassigned\n * for whatever providers are currently live, so a stale resolved promise can\n * only be returned when there is nothing to flush. The covering test asserts\n * every provider set across repeated initialize/shutdown cycles is flushed.\n */\n async shutdown(): Promise<void> {\n const providers = [\n this.tracerProvider,\n this.meterProvider,\n this.loggerProvider,\n ].filter((p): p is NonNullable<typeof p> => p !== undefined);\n\n if (providers.length > 0) {\n this.tracerProvider = undefined;\n this.meterProvider = undefined;\n this.loggerProvider = undefined;\n this.shutdownPromise = (async () => {\n await Promise.all(\n providers.map(async (provider) => {\n try {\n await provider.shutdown();\n } catch (error) {\n logger.error(\"Error shutting down: %O\", error);\n }\n }),\n );\n })();\n }\n\n return this.shutdownPromise;\n }\n\n /**\n * Drop the singleton so the next {@link getInstance} builds a fresh manager.\n *\n * Does not flush: callers `shutdown()` first, then reset — the order\n * `LifecycleManager.shutdown()` uses.\n *\n * @internal\n */\n static reset(): void {\n TelemetryManager.instance = undefined;\n }\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PluginConstructor, PluginData
|
|
1
|
+
import { AppKitApi, PluginConstructor, PluginData } from "../shared/src/plugin.js";
|
|
2
2
|
import { CacheConfig } from "../shared/src/cache.js";
|
|
3
3
|
import "../shared/src/index.js";
|
|
4
4
|
import { WorkspaceClient } from "../workspace-client/index.js";
|
|
@@ -60,7 +60,7 @@ interface TestApp<T extends Plugins> {
|
|
|
60
60
|
* Plugin exports by manifest name. Nested rather than spread because `get` and
|
|
61
61
|
* `delete` are plausible plugin names and would collide with the request methods.
|
|
62
62
|
*/
|
|
63
|
-
plugins:
|
|
63
|
+
plugins: AppKitApi<T>;
|
|
64
64
|
/** The same object a handler resolves at runtime. */
|
|
65
65
|
client: WorkspaceClient;
|
|
66
66
|
/** e.g. `http://127.0.0.1:54321`. Throws when `server: false`. */
|
|
@@ -29,13 +29,13 @@ function releaseEnvBaseline() {
|
|
|
29
29
|
Object.assign(process.env, baseline);
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
|
-
* Point `ServiceContext.
|
|
32
|
+
* Point `ServiceContext.createCallerContext` at the harness's mock so an `obo`
|
|
33
33
|
* request does not construct a real SDK client from `DATABRICKS_HOST`.
|
|
34
34
|
*
|
|
35
35
|
* Mirrors the `createUserContextSpy` in `fixtures.ts`; returns its restore.
|
|
36
36
|
*/
|
|
37
37
|
function stubUserContext(client) {
|
|
38
|
-
const spy = vi.spyOn(ServiceContext, "
|
|
38
|
+
const spy = vi.spyOn(ServiceContext, "createCallerContext").mockImplementation((token, userId, userName, userEmail) => fakeUserContext(client, ServiceContext.get())(token, userId, userName, userEmail));
|
|
39
39
|
return () => spy.mockRestore();
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-test-app.js","names":[],"sources":["../../src/testing/create-test-app.ts"],"sourcesContent":["/**\n * Boot a real AppKit app with no workspace, credentials, or network, then call it\n * over real HTTP.\n */\n\nimport type { Server } from \"node:http\";\n\nimport type {\n CacheConfig,\n PluginConstructor,\n PluginData,\n PluginMap,\n} from \"shared\";\nimport { vi } from \"vitest\";\n\nimport { InMemoryStorage } from \"../cache/storage/memory\";\nimport { ServiceContext } from \"../context/service-context\";\nimport { AppKit, disposeApp } from \"../core/appkit\";\nimport type { WorkspaceClient } from \"../workspace-client\";\nimport type { OboOption } from \"./fixtures\";\nimport { fakeUserContext, oboHeaders, setupDatabricksEnv } from \"./fixtures\";\nimport type { CreateMockWorkspaceClientOptions } from \"./mock-workspace-client\";\nimport { createMockWorkspaceClient } from \"./mock-workspace-client\";\nimport { dropCoreSingletons } from \"./reset-singletons\";\n\n// Loose shapes are intentional here; `noExplicitAny` is off repo-wide (see\n// .oxlintrc.json), so a local alias keeps the intent readable.\ntype Any = any;\n\n/**\n * The env snapshot to restore on close, and whether an app currently holds it.\n *\n * One live app at a time (see the guard in `createTestApp`), so a flag suffices.\n */\nlet envBaseline: NodeJS.ProcessEnv | undefined;\nlet harnessAppLive = false;\n\n/** Take the baseline on boot. */\nfunction acquireEnvBaseline(): void {\n envBaseline = { ...process.env };\n harnessAppLive = true;\n}\n\n/** Restore the baseline on close. */\nfunction releaseEnvBaseline(): void {\n harnessAppLive = false;\n if (!envBaseline) return;\n\n const baseline = envBaseline;\n envBaseline = undefined;\n for (const key of Object.keys(process.env)) {\n if (!(key in baseline)) delete process.env[key];\n }\n Object.assign(process.env, baseline);\n}\n\n/** Plugin descriptors, exactly as `createApp` takes them. */\ntype Plugins = PluginData<PluginConstructor, unknown, string>[];\n\n/** Options for {@link createTestApp}. */\nexport interface CreateTestAppOptions<T extends Plugins> {\n /** The plugins under test, as `createApp` takes them. */\n plugins?: T;\n\n /** Dotted-path responses for the built-in mock. Refused when `client` is set. */\n responses?: CreateMockWorkspaceClientOptions[\"responses\"];\n\n /**\n * Make the built-in mock throw when a path with no declared response is\n * called, rather than resolving `undefined`. Refused when `client` is set —\n * configure it on your own client instead.\n */\n strict?: CreateMockWorkspaceClientOptions[\"strict\"];\n\n /**\n * Replaces the built-in mock. You then own `currentUser.me()` — boot reads\n * `currentUser.id` and fails without it.\n */\n client?: WorkspaceClient;\n\n /** Extra env for the boot, restored on `close()`; satisfies declared resources. */\n env?: Record<string, string>;\n\n /** No socket; setup, validation, and teardown still run, request methods throw. */\n server?: false;\n\n /**\n * Defaults to `\"test\"`. `\"development\"` is refused — it throws a `RangeError`\n * in `get-port` on `port: 0`, boots Vite, and relaxes validation.\n *\n * Beyond refusing `development`, this decides error-response redaction:\n * `errorHandlerMiddleware` returns the real message unless `NODE_ENV` is\n * `production`, where a 5xx becomes `\"Server error\"`. Pass `\"production\"` to\n * assert what a deployed app actually returns to a client.\n */\n nodeEnv?: string;\n\n /** Defaults to in-memory, which is what keeps boot offline. */\n cache?: CacheConfig;\n}\n\n/** Per-request options for the {@link TestApp} HTTP methods. */\nexport interface TestRequestOptions {\n /** A non-string value is JSON-encoded with `content-type: application/json`. */\n body?: unknown;\n /** Merged last, so they win over anything the harness sets. */\n headers?: Record<string, string>;\n /** Same convention as `createMockRequest({ obo })`. */\n obo?: OboOption;\n /** Forwarded to `fetch`. */\n signal?: AbortSignal;\n}\n\n/** A booted test app. */\nexport interface TestApp<T extends Plugins> {\n /**\n * Plugin exports by manifest name. Nested rather than spread because `get` and\n * `delete` are plausible plugin names and would collide with the request methods.\n */\n plugins: PluginMap<T>;\n /** The same object a handler resolves at runtime. */\n client: WorkspaceClient;\n /** e.g. `http://127.0.0.1:54321`. Throws when `server: false`. */\n baseUrl: string;\n /** The bound ephemeral port. Throws when `server: false`. */\n port: number;\n /** The underlying HTTP server, or `undefined` with `server: false`. */\n server?: Server;\n\n /** Release the app and restore env. Idempotent. */\n close(): Promise<void>;\n [Symbol.asyncDispose](): Promise<void>;\n\n get(path: string, options?: TestRequestOptions): Promise<Response>;\n post(path: string, options?: TestRequestOptions): Promise<Response>;\n put(path: string, options?: TestRequestOptions): Promise<Response>;\n patch(path: string, options?: TestRequestOptions): Promise<Response>;\n delete(path: string, options?: TestRequestOptions): Promise<Response>;\n}\n\n/**\n * Point `ServiceContext.createUserContext` at the harness's mock so an `obo`\n * request does not construct a real SDK client from `DATABRICKS_HOST`.\n *\n * Mirrors the `createUserContextSpy` in `fixtures.ts`; returns its restore.\n */\nfunction stubUserContext(client: WorkspaceClient): () => void {\n const spy = vi\n .spyOn(ServiceContext, \"createUserContext\")\n .mockImplementation((token, userId, userName, userEmail) =>\n fakeUserContext(client, ServiceContext.get())(\n token,\n userId,\n userName,\n userEmail,\n ),\n );\n return () => spy.mockRestore();\n}\n\n/**\n * Wait for a server to finish binding and return the port it landed on.\n *\n * Needed with `port: 0`: `start()` returns once `listen()` is invoked, before\n * the bind completes, so `address()` is null until the `listening` event fires.\n * `createTestApp` does this for you — reach for it when hand-rolling a server.\n */\nexport async function getListeningPort(server: Server): Promise<number> {\n const addr = server.address();\n if (addr && typeof addr === \"object\" && typeof addr.port === \"number\") {\n return addr.port;\n }\n await new Promise<void>((resolve, reject) => {\n server.once(\"listening\", () => resolve());\n server.once(\"error\", (err) => reject(err));\n });\n const ready = server.address();\n if (!ready || typeof ready !== \"object\") {\n throw new Error(\"Server is listening but address() returned null\");\n }\n return ready.port;\n}\n\n/**\n * Boot a real app — real Express wiring, routes, and resource validation — with\n * no workspace, credentials, or network. `createTestPluginContext` is cheaper\n * when you only need to unit-test wiring.\n *\n * Does **not** validate config values against `manifest.config.schema`; no\n * runtime validator exists for that.\n *\n * @example\n * ```ts\n * const app = await createTestApp({ plugins: [myPlugin()] });\n * try {\n * const res = await app.post(\"/api/my-plugin/thing\", { body: { q: 1 }, obo: true });\n * await expectStream(res).toEmit(\"status\", \"result\");\n * } finally {\n * await app.close();\n * }\n * ```\n */\nexport async function createTestApp<T extends Plugins>(\n options: CreateTestAppOptions<T> = {},\n): Promise<TestApp<T>> {\n const {\n plugins = [] as unknown as T,\n responses,\n strict,\n client: suppliedClient,\n env = {},\n server: serverOption,\n nodeEnv = \"test\",\n cache,\n } = options;\n\n if (nodeEnv === \"development\") {\n throw new Error(\n 'createTestApp: nodeEnv \"development\" is not supported. Dev mode routes ' +\n \"the harness's ephemeral `port: 0` through get-port, which throws a \" +\n \"RangeError, and it also boots a real Vite dev server, downgrades \" +\n \"resource validation to a warning, and stops filtering dev-only \" +\n \"plugins. Pin a port explicitly with your own server plugin if you \" +\n \"need dev behaviour.\",\n );\n }\n\n // Refused rather than half-supported: AppKit's workspace client, cache and\n // on-behalf-of fake are process-wide, so a second live app cannot own its\n // own. Checked before any mutation, so a refused boot leaves the live app\n // untouched.\n if (harnessAppLive) {\n throw new Error(\n \"createTestApp: a harness app is already open. AppKit's workspace \" +\n \"client, cache, and on-behalf-of fake are process-wide, so a second \" +\n \"app would not receive its own `client`/`responses`, and closing \" +\n \"either would un-fake the other's on-behalf-of path. Close the first \" +\n \"app before booting another — `await using`, or try/finally.\",\n );\n }\n\n // Wholesale rather than a whitelist: plugins read vars we cannot enumerate.\n acquireEnvBaseline();\n\n let app: Awaited<ReturnType<typeof AppKit._createApp>> | undefined;\n let restoreUserContext: (() => void) | undefined;\n\n // Runs the booted plugins' shutdown() hooks and closes the server it started;\n // the harness owns dropping the singletons and restoring env. The `as Any` is\n // the one escape hatch the symbol-keyed teardown forces on the harness.\n const disposeBooted = (a: unknown): Promise<void> => (a as Any)[disposeApp]();\n\n try {\n process.env.NODE_ENV = nodeEnv;\n\n // Redundant while NODE_ENV is pinned, but keeps the throw-on-missing-resource\n // contract if that pin ever changes. No opt-out: the warning path is\n // dev-only, and dev is refused.\n process.env.APPKIT_STRICT_VALIDATION = \"true\";\n\n // The workspace ID short-circuits getWorkspaceId's SCIM probe, which would\n // otherwise show up as an apiClient.request call.\n setupDatabricksEnv({\n DATABRICKS_WORKSPACE_ID: \"test-workspace-id\",\n ...env,\n });\n\n dropCoreSingletons();\n\n // `responses` only seeds the built-in mock, so alongside a caller-supplied\n // client it would silently do nothing. Refuse instead, matching the\n // `server: false` conflict below.\n if (suppliedClient && (responses !== undefined || strict !== undefined)) {\n throw new Error(\n \"createTestApp: `responses` and `strict` configure the built-in mock \" +\n \"client, so they do nothing when you also pass `client`. Drop them \" +\n \"and configure your own client instead.\",\n );\n }\n\n // Boot runs ServiceContext.createContext for real, which reads\n // currentUser.id — the mock's built-in default is what lets it through.\n const client =\n suppliedClient ?? createMockWorkspaceClient({ responses, strict });\n\n // createApp({ client }) installs only the service-principal client. An `obo`\n // request reaches ServiceContext.createUserContext, which builds a *real*\n // client from process.env.DATABRICKS_HOST — so the user-scoped path is faked\n // here too, or \"no network\" is false the moment a handler calls asUser.\n restoreUserContext = stubUserContext(client);\n\n // createApp never auto-adds a server, so without this there is nothing to\n // fetch. Lazily imported: the plugin runs dotenv.config() at module load, so\n // a static import would mutate a consumer's env on import of this kit.\n const hasServer = plugins.some((p) => p?.name === \"server\");\n if (serverOption === false && hasServer) {\n // The plugin would still bind a socket while the handle denied one existed.\n throw new Error(\n \"createTestApp: `server: false` conflicts with the server plugin in \" +\n \"`plugins`. Drop one — omit `server: false` to use your plugin, or \" +\n \"remove the plugin to boot without a socket.\",\n );\n }\n const bootPlugins = [...plugins] as Plugins;\n if (serverOption !== false && !hasServer) {\n const { server: serverPlugin } = await import(\"../plugins/server\");\n bootPlugins.push(serverPlugin({ port: 0, host: \"127.0.0.1\" }));\n }\n\n // Both extras are required to stay offline: without explicit storage the\n // cache builds its own client and probes Lakebase, and without the opt-out\n // TelemetryReporter fires an apiClient.request on boot.\n app = await AppKit._createApp({\n plugins: bootPlugins as Any,\n client,\n cache: cache ?? {\n storage: new InMemoryStorage({ enabled: true } as Any),\n },\n disableInternalTelemetry: true,\n // The harness boots repeatedly in one process and runs its own teardown,\n // so it must not accumulate SIGTERM/SIGINT handlers across boots.\n installSignalHandlers: false,\n });\n\n const serverExports = (app as Any).server;\n const httpServer: Server | undefined =\n serverOption === false ? undefined : serverExports?.getServer?.();\n const port = httpServer ? await getListeningPort(httpServer) : undefined;\n const baseUrl = port === undefined ? undefined : `http://127.0.0.1:${port}`;\n\n const bootedApp = app;\n let closed: Promise<void> | undefined;\n\n /** Memoized, so repeated calls are safe in nested `finally`s. */\n const close = () => {\n closed ??= (async () => {\n try {\n await disposeBooted(bootedApp);\n } finally {\n dropCoreSingletons();\n restoreUserContext?.();\n releaseEnvBaseline();\n }\n })();\n return closed;\n };\n\n const request = async (\n method: string,\n path: string,\n reqOptions: TestRequestOptions = {},\n ): Promise<Response> => {\n if (baseUrl === undefined) {\n throw new Error(\n \"createTestApp: no HTTP server was started (server: false), so \" +\n `${method} ${path} cannot be issued.`,\n );\n }\n\n const headers: Record<string, string> = {};\n if (reqOptions.obo) {\n Object.assign(headers, oboHeaders(reqOptions.obo));\n }\n\n let body: string | undefined;\n if (reqOptions.body !== undefined) {\n if (typeof reqOptions.body === \"string\") {\n body = reqOptions.body;\n } else {\n body = JSON.stringify(reqOptions.body);\n headers[\"content-type\"] = \"application/json\";\n }\n }\n\n // Caller headers last, so an explicit content-type or identity wins.\n // Lowercased first: `Headers` comma-joins case variants instead of\n // replacing, so a mixed-case override would corrupt the value into\n // \"alice, bob\" rather than win.\n for (const [name, value] of Object.entries(reqOptions.headers ?? {})) {\n headers[name.toLowerCase()] = value;\n }\n\n return fetch(new URL(path, baseUrl), {\n method,\n headers,\n body,\n signal: reqOptions.signal,\n });\n };\n\n return {\n plugins: bootedApp as unknown as PluginMap<T>,\n client,\n get baseUrl() {\n if (baseUrl === undefined) {\n throw new Error(\n \"createTestApp: no HTTP server was started (server: false).\",\n );\n }\n return baseUrl;\n },\n get port() {\n if (port === undefined) {\n throw new Error(\n \"createTestApp: no HTTP server was started (server: false).\",\n );\n }\n return port;\n },\n server: httpServer,\n close,\n [Symbol.asyncDispose]: close,\n get: (path, o) => request(\"GET\", path, o),\n post: (path, o) => request(\"POST\", path, o),\n put: (path, o) => request(\"PUT\", path, o),\n patch: (path, o) => request(\"PATCH\", path, o),\n delete: (path, o) => request(\"DELETE\", path, o),\n };\n } catch (err) {\n // Teardown must run from the failure path too, or the boot leaks env\n // mutations and singletons into every later test in the file.\n if (app) {\n try {\n await disposeBooted(app);\n } catch {\n // The boot error is the interesting one; don't let teardown mask it.\n }\n }\n // dispose() no longer drops the singletons, and a failure before boot may\n // have left a partial init — drop unconditionally either way.\n dropCoreSingletons();\n restoreUserContext?.();\n releaseEnvBaseline();\n throw err;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"create-test-app.js","names":[],"sources":["../../src/testing/create-test-app.ts"],"sourcesContent":["/**\n * Boot a real AppKit app with no workspace, credentials, or network, then call it\n * over real HTTP.\n */\n\nimport type { Server } from \"node:http\";\n\nimport type {\n CacheConfig,\n PluginConstructor,\n PluginData,\n AppKitApi,\n} from \"shared\";\nimport { vi } from \"vitest\";\n\nimport { InMemoryStorage } from \"../cache/storage/memory\";\nimport { ServiceContext } from \"../context/service-context\";\nimport { AppKit, disposeApp } from \"../core/appkit\";\nimport type { WorkspaceClient } from \"../workspace-client\";\nimport type { OboOption } from \"./fixtures\";\nimport { fakeUserContext, oboHeaders, setupDatabricksEnv } from \"./fixtures\";\nimport type { CreateMockWorkspaceClientOptions } from \"./mock-workspace-client\";\nimport { createMockWorkspaceClient } from \"./mock-workspace-client\";\nimport { dropCoreSingletons } from \"./reset-singletons\";\n\n// Loose shapes are intentional here; `noExplicitAny` is off repo-wide (see\n// .oxlintrc.json), so a local alias keeps the intent readable.\ntype Any = any;\n\n/**\n * The env snapshot to restore on close, and whether an app currently holds it.\n *\n * One live app at a time (see the guard in `createTestApp`), so a flag suffices.\n */\nlet envBaseline: NodeJS.ProcessEnv | undefined;\nlet harnessAppLive = false;\n\n/** Take the baseline on boot. */\nfunction acquireEnvBaseline(): void {\n envBaseline = { ...process.env };\n harnessAppLive = true;\n}\n\n/** Restore the baseline on close. */\nfunction releaseEnvBaseline(): void {\n harnessAppLive = false;\n if (!envBaseline) return;\n\n const baseline = envBaseline;\n envBaseline = undefined;\n for (const key of Object.keys(process.env)) {\n if (!(key in baseline)) delete process.env[key];\n }\n Object.assign(process.env, baseline);\n}\n\n/** Plugin descriptors, exactly as `createApp` takes them. */\ntype Plugins = PluginData<PluginConstructor, unknown, string>[];\n\n/** Options for {@link createTestApp}. */\nexport interface CreateTestAppOptions<T extends Plugins> {\n /** The plugins under test, as `createApp` takes them. */\n plugins?: T;\n\n /** Dotted-path responses for the built-in mock. Refused when `client` is set. */\n responses?: CreateMockWorkspaceClientOptions[\"responses\"];\n\n /**\n * Make the built-in mock throw when a path with no declared response is\n * called, rather than resolving `undefined`. Refused when `client` is set —\n * configure it on your own client instead.\n */\n strict?: CreateMockWorkspaceClientOptions[\"strict\"];\n\n /**\n * Replaces the built-in mock. You then own `currentUser.me()` — boot reads\n * `currentUser.id` and fails without it.\n */\n client?: WorkspaceClient;\n\n /** Extra env for the boot, restored on `close()`; satisfies declared resources. */\n env?: Record<string, string>;\n\n /** No socket; setup, validation, and teardown still run, request methods throw. */\n server?: false;\n\n /**\n * Defaults to `\"test\"`. `\"development\"` is refused — it throws a `RangeError`\n * in `get-port` on `port: 0`, boots Vite, and relaxes validation.\n *\n * Beyond refusing `development`, this decides error-response redaction:\n * `errorHandlerMiddleware` returns the real message unless `NODE_ENV` is\n * `production`, where a 5xx becomes `\"Server error\"`. Pass `\"production\"` to\n * assert what a deployed app actually returns to a client.\n */\n nodeEnv?: string;\n\n /** Defaults to in-memory, which is what keeps boot offline. */\n cache?: CacheConfig;\n}\n\n/** Per-request options for the {@link TestApp} HTTP methods. */\nexport interface TestRequestOptions {\n /** A non-string value is JSON-encoded with `content-type: application/json`. */\n body?: unknown;\n /** Merged last, so they win over anything the harness sets. */\n headers?: Record<string, string>;\n /** Same convention as `createMockRequest({ obo })`. */\n obo?: OboOption;\n /** Forwarded to `fetch`. */\n signal?: AbortSignal;\n}\n\n/** A booted test app. */\nexport interface TestApp<T extends Plugins> {\n /**\n * Plugin exports by manifest name. Nested rather than spread because `get` and\n * `delete` are plausible plugin names and would collide with the request methods.\n */\n plugins: AppKitApi<T>;\n /** The same object a handler resolves at runtime. */\n client: WorkspaceClient;\n /** e.g. `http://127.0.0.1:54321`. Throws when `server: false`. */\n baseUrl: string;\n /** The bound ephemeral port. Throws when `server: false`. */\n port: number;\n /** The underlying HTTP server, or `undefined` with `server: false`. */\n server?: Server;\n\n /** Release the app and restore env. Idempotent. */\n close(): Promise<void>;\n [Symbol.asyncDispose](): Promise<void>;\n\n get(path: string, options?: TestRequestOptions): Promise<Response>;\n post(path: string, options?: TestRequestOptions): Promise<Response>;\n put(path: string, options?: TestRequestOptions): Promise<Response>;\n patch(path: string, options?: TestRequestOptions): Promise<Response>;\n delete(path: string, options?: TestRequestOptions): Promise<Response>;\n}\n\n/**\n * Point `ServiceContext.createCallerContext` at the harness's mock so an `obo`\n * request does not construct a real SDK client from `DATABRICKS_HOST`.\n *\n * Mirrors the `createUserContextSpy` in `fixtures.ts`; returns its restore.\n */\nfunction stubUserContext(client: WorkspaceClient): () => void {\n const spy = vi\n .spyOn(ServiceContext, \"createCallerContext\")\n .mockImplementation((token, userId, userName, userEmail) =>\n fakeUserContext(client, ServiceContext.get())(\n token,\n userId,\n userName,\n userEmail,\n ),\n );\n return () => spy.mockRestore();\n}\n\n/**\n * Wait for a server to finish binding and return the port it landed on.\n *\n * Needed with `port: 0`: `start()` returns once `listen()` is invoked, before\n * the bind completes, so `address()` is null until the `listening` event fires.\n * `createTestApp` does this for you — reach for it when hand-rolling a server.\n */\nexport async function getListeningPort(server: Server): Promise<number> {\n const addr = server.address();\n if (addr && typeof addr === \"object\" && typeof addr.port === \"number\") {\n return addr.port;\n }\n await new Promise<void>((resolve, reject) => {\n server.once(\"listening\", () => resolve());\n server.once(\"error\", (err) => reject(err));\n });\n const ready = server.address();\n if (!ready || typeof ready !== \"object\") {\n throw new Error(\"Server is listening but address() returned null\");\n }\n return ready.port;\n}\n\n/**\n * Boot a real app — real Express wiring, routes, and resource validation — with\n * no workspace, credentials, or network. `createTestPluginContext` is cheaper\n * when you only need to unit-test wiring.\n *\n * Does **not** validate config values against `manifest.config.schema`; no\n * runtime validator exists for that.\n *\n * @example\n * ```ts\n * const app = await createTestApp({ plugins: [myPlugin()] });\n * try {\n * const res = await app.post(\"/api/my-plugin/thing\", { body: { q: 1 }, obo: true });\n * await expectStream(res).toEmit(\"status\", \"result\");\n * } finally {\n * await app.close();\n * }\n * ```\n */\nexport async function createTestApp<T extends Plugins>(\n options: CreateTestAppOptions<T> = {},\n): Promise<TestApp<T>> {\n const {\n plugins = [] as unknown as T,\n responses,\n strict,\n client: suppliedClient,\n env = {},\n server: serverOption,\n nodeEnv = \"test\",\n cache,\n } = options;\n\n if (nodeEnv === \"development\") {\n throw new Error(\n 'createTestApp: nodeEnv \"development\" is not supported. Dev mode routes ' +\n \"the harness's ephemeral `port: 0` through get-port, which throws a \" +\n \"RangeError, and it also boots a real Vite dev server, downgrades \" +\n \"resource validation to a warning, and stops filtering dev-only \" +\n \"plugins. Pin a port explicitly with your own server plugin if you \" +\n \"need dev behaviour.\",\n );\n }\n\n // Refused rather than half-supported: AppKit's workspace client, cache and\n // on-behalf-of fake are process-wide, so a second live app cannot own its\n // own. Checked before any mutation, so a refused boot leaves the live app\n // untouched.\n if (harnessAppLive) {\n throw new Error(\n \"createTestApp: a harness app is already open. AppKit's workspace \" +\n \"client, cache, and on-behalf-of fake are process-wide, so a second \" +\n \"app would not receive its own `client`/`responses`, and closing \" +\n \"either would un-fake the other's on-behalf-of path. Close the first \" +\n \"app before booting another — `await using`, or try/finally.\",\n );\n }\n\n // Wholesale rather than a whitelist: plugins read vars we cannot enumerate.\n acquireEnvBaseline();\n\n let app: Awaited<ReturnType<typeof AppKit._createApp>> | undefined;\n let restoreUserContext: (() => void) | undefined;\n\n // Runs the booted plugins' shutdown() hooks and closes the server it started;\n // the harness owns dropping the singletons and restoring env. The `as Any` is\n // the one escape hatch the symbol-keyed teardown forces on the harness.\n const disposeBooted = (a: unknown): Promise<void> => (a as Any)[disposeApp]();\n\n try {\n process.env.NODE_ENV = nodeEnv;\n\n // Redundant while NODE_ENV is pinned, but keeps the throw-on-missing-resource\n // contract if that pin ever changes. No opt-out: the warning path is\n // dev-only, and dev is refused.\n process.env.APPKIT_STRICT_VALIDATION = \"true\";\n\n // The workspace ID short-circuits getWorkspaceId's SCIM probe, which would\n // otherwise show up as an apiClient.request call.\n setupDatabricksEnv({\n DATABRICKS_WORKSPACE_ID: \"test-workspace-id\",\n ...env,\n });\n\n dropCoreSingletons();\n\n // `responses` only seeds the built-in mock, so alongside a caller-supplied\n // client it would silently do nothing. Refuse instead, matching the\n // `server: false` conflict below.\n if (suppliedClient && (responses !== undefined || strict !== undefined)) {\n throw new Error(\n \"createTestApp: `responses` and `strict` configure the built-in mock \" +\n \"client, so they do nothing when you also pass `client`. Drop them \" +\n \"and configure your own client instead.\",\n );\n }\n\n // Boot runs ServiceContext.createContext for real, which reads\n // currentUser.id — the mock's built-in default is what lets it through.\n const client =\n suppliedClient ?? createMockWorkspaceClient({ responses, strict });\n\n // createApp({ client }) installs only the service-principal client. An `obo`\n // request reaches ServiceContext.createCallerContext, which builds a *real*\n // client from process.env.DATABRICKS_HOST — so the user-scoped path is faked\n // here too, or \"no network\" is false the moment a handler calls asUser.\n restoreUserContext = stubUserContext(client);\n\n // createApp never auto-adds a server, so without this there is nothing to\n // fetch. Lazily imported: the plugin runs dotenv.config() at module load, so\n // a static import would mutate a consumer's env on import of this kit.\n const hasServer = plugins.some((p) => p?.name === \"server\");\n if (serverOption === false && hasServer) {\n // The plugin would still bind a socket while the handle denied one existed.\n throw new Error(\n \"createTestApp: `server: false` conflicts with the server plugin in \" +\n \"`plugins`. Drop one — omit `server: false` to use your plugin, or \" +\n \"remove the plugin to boot without a socket.\",\n );\n }\n const bootPlugins = [...plugins] as Plugins;\n if (serverOption !== false && !hasServer) {\n const { server: serverPlugin } = await import(\"../plugins/server\");\n bootPlugins.push(serverPlugin({ port: 0, host: \"127.0.0.1\" }));\n }\n\n // Both extras are required to stay offline: without explicit storage the\n // cache builds its own client and probes Lakebase, and without the opt-out\n // TelemetryReporter fires an apiClient.request on boot.\n app = await AppKit._createApp({\n plugins: bootPlugins as Any,\n client,\n cache: cache ?? {\n storage: new InMemoryStorage({ enabled: true } as Any),\n },\n disableInternalTelemetry: true,\n // The harness boots repeatedly in one process and runs its own teardown,\n // so it must not accumulate SIGTERM/SIGINT handlers across boots.\n installSignalHandlers: false,\n });\n\n const serverExports = (app as Any).server;\n const httpServer: Server | undefined =\n serverOption === false ? undefined : serverExports?.getServer?.();\n const port = httpServer ? await getListeningPort(httpServer) : undefined;\n const baseUrl = port === undefined ? undefined : `http://127.0.0.1:${port}`;\n\n const bootedApp = app;\n let closed: Promise<void> | undefined;\n\n /** Memoized, so repeated calls are safe in nested `finally`s. */\n const close = () => {\n closed ??= (async () => {\n try {\n await disposeBooted(bootedApp);\n } finally {\n dropCoreSingletons();\n restoreUserContext?.();\n releaseEnvBaseline();\n }\n })();\n return closed;\n };\n\n const request = async (\n method: string,\n path: string,\n reqOptions: TestRequestOptions = {},\n ): Promise<Response> => {\n if (baseUrl === undefined) {\n throw new Error(\n \"createTestApp: no HTTP server was started (server: false), so \" +\n `${method} ${path} cannot be issued.`,\n );\n }\n\n const headers: Record<string, string> = {};\n if (reqOptions.obo) {\n Object.assign(headers, oboHeaders(reqOptions.obo));\n }\n\n let body: string | undefined;\n if (reqOptions.body !== undefined) {\n if (typeof reqOptions.body === \"string\") {\n body = reqOptions.body;\n } else {\n body = JSON.stringify(reqOptions.body);\n headers[\"content-type\"] = \"application/json\";\n }\n }\n\n // Caller headers last, so an explicit content-type or identity wins.\n // Lowercased first: `Headers` comma-joins case variants instead of\n // replacing, so a mixed-case override would corrupt the value into\n // \"alice, bob\" rather than win.\n for (const [name, value] of Object.entries(reqOptions.headers ?? {})) {\n headers[name.toLowerCase()] = value;\n }\n\n return fetch(new URL(path, baseUrl), {\n method,\n headers,\n body,\n signal: reqOptions.signal,\n });\n };\n\n return {\n plugins: bootedApp,\n client,\n get baseUrl() {\n if (baseUrl === undefined) {\n throw new Error(\n \"createTestApp: no HTTP server was started (server: false).\",\n );\n }\n return baseUrl;\n },\n get port() {\n if (port === undefined) {\n throw new Error(\n \"createTestApp: no HTTP server was started (server: false).\",\n );\n }\n return port;\n },\n server: httpServer,\n close,\n [Symbol.asyncDispose]: close,\n get: (path, o) => request(\"GET\", path, o),\n post: (path, o) => request(\"POST\", path, o),\n put: (path, o) => request(\"PUT\", path, o),\n patch: (path, o) => request(\"PATCH\", path, o),\n delete: (path, o) => request(\"DELETE\", path, o),\n };\n } catch (err) {\n // Teardown must run from the failure path too, or the boot leaks env\n // mutations and singletons into every later test in the file.\n if (app) {\n try {\n await disposeBooted(app);\n } catch {\n // The boot error is the interesting one; don't let teardown mask it.\n }\n }\n // dispose() no longer drops the singletons, and a failure before boot may\n // have left a partial init — drop unconditionally either way.\n dropCoreSingletons();\n restoreUserContext?.();\n releaseEnvBaseline();\n throw err;\n }\n}\n"],"mappings":"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"}
|
|
@@ -160,8 +160,9 @@ interface TestContextOptions {
|
|
|
160
160
|
}
|
|
161
161
|
/**
|
|
162
162
|
* Mocks the `ServiceContext` singleton for testing — spies `get`,
|
|
163
|
-
* `initialize`, `isInitialized`, and `
|
|
163
|
+
* `initialize`, `isInitialized`, and `createCallerContext` so code that resolves
|
|
164
164
|
* the service principal or an on-behalf-of user context gets test doubles.
|
|
165
|
+
* Also supplies the app-level warehouse binding through WarehouseResource.
|
|
165
166
|
* Call in `beforeEach`; call the returned `restore()` in `afterEach`.
|
|
166
167
|
*
|
|
167
168
|
* @returns The mock context plus the spies and a `restore()` helper.
|
|
@@ -171,7 +172,7 @@ declare function mockServiceContext(options?: TestContextOptions): {
|
|
|
171
172
|
getSpy: vitest.MockInstance<typeof ServiceContext.get>;
|
|
172
173
|
initSpy: vitest.MockInstance<typeof ServiceContext.initialize>;
|
|
173
174
|
isInitializedSpy: vitest.MockInstance<typeof ServiceContext.isInitialized>;
|
|
174
|
-
createUserContextSpy: vitest.MockInstance<typeof ServiceContext.
|
|
175
|
+
createUserContextSpy: vitest.MockInstance<typeof ServiceContext.createCallerContext>;
|
|
175
176
|
restore: () => void;
|
|
176
177
|
};
|
|
177
178
|
/** The handle {@link mockServiceContext} returns (spies + `restore`). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fixtures.d.ts","names":[],"sources":["../../src/testing/fixtures.ts"],"mappings":";;;;;;;;;
|
|
1
|
+
{"version":3,"file":"fixtures.d.ts","names":[],"sources":["../../src/testing/fixtures.ts"],"mappings":";;;;;;;;;KAqBK,GAAA;;;;AAL0C;;;iBAa/B,mBAAA,CAAA,GAAuB,UAAA;;AAAvC;;;iBAqDgB,gBAAA,CAAA;EACd,MAAA,EAAQ,UAAA;EACR,QAAA,EAAU,MAAA,SAAe,GAAA;EACzB,UAAA,GAAa,MAAA,UAAgB,IAAA,aAAiB,GAAA;AAAA;;;;;;;;KAqCpC,SAAA;EAtCV,sDA0CI,MAAA,WA1CqB;EA4CrB,KAAA,WA3CS;EA6CT,KAAA;AAAA;;;;;;;;;;;;;;;;iBA0EU,iBAAA,CAAkB,SAAA,GAAW,GAAA;;;;;;;;;;;;;;;;;iBA+C7B,kBAAA,CAAA;;UAAkB,MAAA,CAAA,IAAA;;QAWC,MAAA,CAAA,IAAA,QAAH,GAAA;;OAKE,MAAA,CAAA,IAAA,QAAH,GAAA,EAAG,KAAA;SAcE,MAAA,CAAA,IAAA,QAAH,GAAA,EAAG,KAAA;aAOI,MAAA,CAAA,IAAA,QAAH,GAAA;;;MAST,MAAA,CAAA,IAAA,QAFhB,GAAA,EAAG,KAAA,UACI,OAAA,MACA,IAAA,EAAM,GAAA;OAWG,MAAA,CAAA,IAAA,QAFhB,GAAA,EAAG,KAAA,UACI,OAAA,MACA,IAAA,EAAM,GAAA;;;;;;;;iBAgBN,QAAA;AAAA;;;;;iBAWH,kBAAA,CAAmB,SAAA,GAAW,MAAA;;;;;;;AAA9C;;;;;AAmEA;;;;;;;;;;;;;;iBAAgB,OAAA,GAAA,CACd,IAAA,EAAM,MAAA,kBACN,EAAA,QAAU,CAAA,GAAI,OAAA,CAAQ,CAAA,IACrB,CAAA,GAAI,OAAA,CAAQ,CAAA;;;;;;;;;;AAmDf;;;;;AAcA;;;;;iBAdsB,cAAA,CAAA,GAAkB,OAAA;;;;UAcvB,kBAAA;EAUf;EARA,uBAAA,GAA0B,GAAA;EAUf;EARX,oBAAA,GAAuB,GAAA;EAuCT;EArCd,MAAA;;EAEA,aAAA;;EAEA,WAAA;;EAEA,WAAA;AAAA;;;;;;;;;;iBA+Bc,kBAAA,CAAmB,OAAA,GAAS,kBAAA;;;;;;;;;KAkDhC,kBAAA,GAAqB,UAAA,QAAkB,kBAAA;;;;;;;;;;;AAAnD;;;;;AA4BA;;;;;;;;;;AAgCA;iBAhCgB,qBAAA,CAAsB,OAAA,GAAS,kBAAA;EAAA,SACpC,OAAA,EAAS,kBAAA;AAAA;;;;;iBA+BE,qBAAA,GAAA,CACpB,EAAA,QAAU,CAAA,GAAI,OAAA,CAAQ,CAAA,GACtB,OAAA,GAAU,kBAAA,GACT,OAAA,CAAQ,CAAA;;;;;iBAcK,2BAAA,CACd,IAAA,EAAM,GAAA,MACN,OAAA,EAAS,KAAA;EAAQ,IAAA;EAAc,SAAA;AAAA;;;;;;;;;;;;;;;;;;iBAoBjB,uBAAA,CAAwB,YAAA;;;;;;;;;;AAAxC;;;;;;;;;;;;AA+BA;;;;;;iBAAgB,cAAA,CAAe,OAAA;EAC7B,UAAA;EACA,OAAA;EACA,SAAA;AAAA,IACE,QAAA"}
|
package/dist/testing/fixtures.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { ApiError } from "../shared/src/workspace-client/errors.js";
|
|
2
2
|
import { AuthenticationError } from "../errors/authentication.js";
|
|
3
3
|
import "../errors/index.js";
|
|
4
|
+
import { WarehouseResource } from "../resources/warehouse.js";
|
|
5
|
+
import { snapshotCallerContext } from "../context/caller-context.js";
|
|
4
6
|
import { ServiceContext } from "../context/service-context.js";
|
|
5
7
|
import { CacheManager } from "../cache/index.js";
|
|
6
8
|
import { createMockWorkspaceClient } from "./mock-workspace-client.js";
|
|
@@ -74,7 +76,7 @@ function createMockRouter() {
|
|
|
74
76
|
};
|
|
75
77
|
}
|
|
76
78
|
/**
|
|
77
|
-
* The one fake of `ServiceContext.
|
|
79
|
+
* The one fake of `ServiceContext.createCallerContext` this kit uses, shared by
|
|
78
80
|
* `mockServiceContext` and `createTestApp`.
|
|
79
81
|
*
|
|
80
82
|
* Shared rather than duplicated because the two used to disagree, and neither
|
|
@@ -88,16 +90,17 @@ function createMockRouter() {
|
|
|
88
90
|
function fakeUserContext(client, ids) {
|
|
89
91
|
return (token, userId, userName, userEmail) => {
|
|
90
92
|
if (!token) throw AuthenticationError.missingToken("user token");
|
|
91
|
-
return {
|
|
93
|
+
return snapshotCallerContext({
|
|
92
94
|
client,
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
95
|
+
principal: {
|
|
96
|
+
type: "user",
|
|
97
|
+
userId,
|
|
98
|
+
userName,
|
|
99
|
+
userEmail
|
|
100
|
+
},
|
|
96
101
|
tokenFingerprint: createHash("sha256").update(token).digest("hex").slice(0, 16),
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
isUserContext: true
|
|
100
|
-
};
|
|
102
|
+
workspaceId: ids.workspaceId
|
|
103
|
+
});
|
|
101
104
|
};
|
|
102
105
|
}
|
|
103
106
|
/**
|
|
@@ -319,28 +322,31 @@ async function resetTestCache() {
|
|
|
319
322
|
* singleton. Internal building block for {@link mockServiceContext}, which
|
|
320
323
|
* installs the state as spies — that installer is the public entry point.
|
|
321
324
|
*/
|
|
322
|
-
function buildServiceContextState(options
|
|
325
|
+
function buildServiceContextState(options, resources) {
|
|
323
326
|
return {
|
|
324
327
|
client: options.serviceDatabricksClient || createMockWorkspaceClient(),
|
|
325
328
|
serviceUserId: options.serviceUserId || "test-service-user",
|
|
326
|
-
warehouseId:
|
|
329
|
+
warehouseId: resources.warehouseId,
|
|
327
330
|
workspaceId: Promise.resolve(options.workspaceId || "test-workspace-id")
|
|
328
331
|
};
|
|
329
332
|
}
|
|
330
333
|
/**
|
|
331
334
|
* Mocks the `ServiceContext` singleton for testing — spies `get`,
|
|
332
|
-
* `initialize`, `isInitialized`, and `
|
|
335
|
+
* `initialize`, `isInitialized`, and `createCallerContext` so code that resolves
|
|
333
336
|
* the service principal or an on-behalf-of user context gets test doubles.
|
|
337
|
+
* Also supplies the app-level warehouse binding through WarehouseResource.
|
|
334
338
|
* Call in `beforeEach`; call the returned `restore()` in `afterEach`.
|
|
335
339
|
*
|
|
336
340
|
* @returns The mock context plus the spies and a `restore()` helper.
|
|
337
341
|
*/
|
|
338
342
|
function mockServiceContext(options = {}) {
|
|
339
|
-
const
|
|
343
|
+
const resources = Object.freeze({ warehouseId: Promise.resolve(options.warehouseId || "test-warehouse-id") });
|
|
344
|
+
const serviceContext = buildServiceContextState(options, resources);
|
|
345
|
+
const resourcesSpy = vi.spyOn(WarehouseResource, "get").mockReturnValue(resources);
|
|
340
346
|
const getSpy = vi.spyOn(ServiceContext, "get").mockReturnValue(serviceContext);
|
|
341
347
|
const initSpy = vi.spyOn(ServiceContext, "initialize").mockResolvedValue(serviceContext);
|
|
342
348
|
const isInitializedSpy = vi.spyOn(ServiceContext, "isInitialized").mockReturnValue(true);
|
|
343
|
-
const createUserContextSpy = vi.spyOn(ServiceContext, "
|
|
349
|
+
const createUserContextSpy = vi.spyOn(ServiceContext, "createCallerContext").mockImplementation(fakeUserContext(options.userDatabricksClient || createMockWorkspaceClient(), serviceContext));
|
|
344
350
|
return {
|
|
345
351
|
serviceContext,
|
|
346
352
|
getSpy,
|
|
@@ -352,6 +358,7 @@ function mockServiceContext(options = {}) {
|
|
|
352
358
|
initSpy.mockRestore();
|
|
353
359
|
isInitializedSpy.mockRestore();
|
|
354
360
|
createUserContextSpy.mockRestore();
|
|
361
|
+
resourcesSpy.mockRestore();
|
|
355
362
|
}
|
|
356
363
|
};
|
|
357
364
|
}
|