@databricks/appkit 0.44.0 → 0.46.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.
Files changed (114) hide show
  1. package/CLAUDE.md +1 -0
  2. package/dist/agents/databricks.d.ts +25 -1
  3. package/dist/agents/databricks.d.ts.map +1 -1
  4. package/dist/agents/databricks.js +20 -1
  5. package/dist/agents/databricks.js.map +1 -1
  6. package/dist/appkit/package.js +1 -1
  7. package/dist/beta.d.ts +2 -2
  8. package/dist/connectors/sql-warehouse/arrow-schema.js +283 -0
  9. package/dist/connectors/sql-warehouse/arrow-schema.js.map +1 -0
  10. package/dist/connectors/sql-warehouse/client.js +166 -60
  11. package/dist/connectors/sql-warehouse/client.js.map +1 -1
  12. package/dist/core/agent/load-agents.d.ts.map +1 -1
  13. package/dist/core/agent/load-agents.js +52 -0
  14. package/dist/core/agent/load-agents.js.map +1 -1
  15. package/dist/core/agent/types.d.ts +11 -0
  16. package/dist/core/agent/types.d.ts.map +1 -1
  17. package/dist/core/agent/types.js.map +1 -1
  18. package/dist/core/appkit.d.ts.map +1 -1
  19. package/dist/core/appkit.js +2 -0
  20. package/dist/core/appkit.js.map +1 -1
  21. package/dist/core/lifecycle-manager.js +183 -0
  22. package/dist/core/lifecycle-manager.js.map +1 -0
  23. package/dist/core/plugin-context.d.ts +19 -1
  24. package/dist/core/plugin-context.d.ts.map +1 -1
  25. package/dist/core/plugin-context.js +9 -3
  26. package/dist/core/plugin-context.js.map +1 -1
  27. package/dist/errors/base.d.ts +18 -0
  28. package/dist/errors/base.d.ts.map +1 -1
  29. package/dist/errors/base.js +20 -0
  30. package/dist/errors/base.js.map +1 -1
  31. package/dist/errors/execution.d.ts +34 -2
  32. package/dist/errors/execution.d.ts.map +1 -1
  33. package/dist/errors/execution.js +40 -5
  34. package/dist/errors/execution.js.map +1 -1
  35. package/dist/plugin/plugin.d.ts.map +1 -1
  36. package/dist/plugin/plugin.js +2 -1
  37. package/dist/plugin/plugin.js.map +1 -1
  38. package/dist/plugins/agents/agents.d.ts.map +1 -1
  39. package/dist/plugins/agents/agents.js +3 -3
  40. package/dist/plugins/agents/agents.js.map +1 -1
  41. package/dist/plugins/analytics/analytics.d.ts +84 -8
  42. package/dist/plugins/analytics/analytics.d.ts.map +1 -1
  43. package/dist/plugins/analytics/analytics.js +296 -61
  44. package/dist/plugins/analytics/analytics.js.map +1 -1
  45. package/dist/plugins/analytics/index.js +1 -1
  46. package/dist/plugins/analytics/result-delivery.js +287 -0
  47. package/dist/plugins/analytics/result-delivery.js.map +1 -0
  48. package/dist/plugins/analytics/types.d.ts +8 -0
  49. package/dist/plugins/analytics/types.d.ts.map +1 -1
  50. package/dist/plugins/analytics/types.js.map +1 -1
  51. package/dist/plugins/files/plugin.js +1 -1
  52. package/dist/plugins/genie/genie.js +1 -1
  53. package/dist/plugins/jobs/types.d.ts +1 -1
  54. package/dist/plugins/lakebase/lakebase.d.ts +10 -2
  55. package/dist/plugins/lakebase/lakebase.d.ts.map +1 -1
  56. package/dist/plugins/lakebase/lakebase.js +19 -8
  57. package/dist/plugins/lakebase/lakebase.js.map +1 -1
  58. package/dist/plugins/server/index.d.ts +51 -1
  59. package/dist/plugins/server/index.d.ts.map +1 -1
  60. package/dist/plugins/server/index.js +110 -23
  61. package/dist/plugins/server/index.js.map +1 -1
  62. package/dist/registry/resource-registry.d.ts +9 -1
  63. package/dist/registry/resource-registry.d.ts.map +1 -1
  64. package/dist/registry/resource-registry.js +22 -5
  65. package/dist/registry/resource-registry.js.map +1 -1
  66. package/dist/shared/src/execute.d.ts +1 -3
  67. package/dist/shared/src/execute.d.ts.map +1 -1
  68. package/dist/shared/src/index.d.ts +1 -0
  69. package/dist/shared/src/plugin.d.ts +16 -0
  70. package/dist/shared/src/plugin.d.ts.map +1 -1
  71. package/dist/shared/src/sse/analytics.d.ts +1 -0
  72. package/dist/shared/src/sse/analytics.js +40 -0
  73. package/dist/shared/src/sse/analytics.js.map +1 -0
  74. package/dist/stream/arrow-stream-processor.js +93 -117
  75. package/dist/stream/arrow-stream-processor.js.map +1 -1
  76. package/dist/stream/buffers.js +1 -1
  77. package/dist/stream/buffers.js.map +1 -1
  78. package/dist/stream/defaults.js +1 -3
  79. package/dist/stream/defaults.js.map +1 -1
  80. package/dist/stream/sse-writer.js +3 -2
  81. package/dist/stream/sse-writer.js.map +1 -1
  82. package/dist/stream/stream-manager.d.ts +2 -2
  83. package/dist/stream/stream-manager.d.ts.map +1 -1
  84. package/dist/stream/stream-manager.js +41 -33
  85. package/dist/stream/stream-manager.js.map +1 -1
  86. package/dist/stream/stream-registry.js +30 -23
  87. package/dist/stream/stream-registry.js.map +1 -1
  88. package/dist/stream/timers.js +17 -0
  89. package/dist/stream/timers.js.map +1 -0
  90. package/dist/stream/types.js.map +1 -1
  91. package/dist/telemetry/telemetry-manager.js +19 -13
  92. package/dist/telemetry/telemetry-manager.js.map +1 -1
  93. package/dist/type-generator/query-registry.js +32 -2
  94. package/dist/type-generator/query-registry.js.map +1 -1
  95. package/dist/utils/safe-handler.js +28 -0
  96. package/dist/utils/safe-handler.js.map +1 -0
  97. package/docs/api/appkit/Class.AppKitError.md +40 -8
  98. package/docs/api/appkit/Class.AuthenticationError.md +56 -16
  99. package/docs/api/appkit/Class.ConfigurationError.md +57 -17
  100. package/docs/api/appkit/Class.ConnectionError.md +57 -17
  101. package/docs/api/appkit/Class.ExecutionError.md +80 -22
  102. package/docs/api/appkit/Class.InitializationError.md +55 -15
  103. package/docs/api/appkit/Class.Plugin.md +2 -0
  104. package/docs/api/appkit/Class.ResourceRegistry.md +1 -1
  105. package/docs/api/appkit/Class.ServerError.md +55 -15
  106. package/docs/api/appkit/Class.TunnelError.md +56 -16
  107. package/docs/api/appkit/Class.ValidationError.md +55 -15
  108. package/docs/api/appkit/Interface.AgentDefinition.md +11 -0
  109. package/docs/api/appkit/Interface.GenerationParams.md +58 -0
  110. package/docs/api/appkit/Interface.RegisteredAgent.md +11 -0
  111. package/docs/api/appkit.md +1 -0
  112. package/llms.txt +1 -0
  113. package/package.json +1 -1
  114. package/sbom.cdx.json +1 -1
@@ -1,9 +1,12 @@
1
1
  import { createLogger } from "../logging/logger.js";
2
+ import { AppKitError } from "../errors/base.js";
3
+ import { ExecutionError } from "../errors/execution.js";
2
4
  import { EventRingBuffer } from "./buffers.js";
3
5
  import { streamDefaults } from "./defaults.js";
4
6
  import { SSEErrorCode } from "./types.js";
5
7
  import { StreamValidator } from "./validator.js";
6
8
  import { SSEWriter } from "./sse-writer.js";
9
+ import { clearGraceTimer, clearRemovalTimer } from "./timers.js";
7
10
  import { StreamRegistry } from "./stream-registry.js";
8
11
  import { randomUUID } from "node:crypto";
9
12
  import { context } from "@opentelemetry/api";
@@ -65,7 +68,8 @@ var StreamManager = class {
65
68
  }
66
69
  } else this.sseWriter.writeBufferOverflowWarning(res, validEventId);
67
70
  }
68
- this._clearGraceTimer(streamEntry);
71
+ clearGraceTimer(streamEntry);
72
+ clearRemovalTimer(streamEntry);
69
73
  streamEntry.clients.add(res);
70
74
  streamEntry.lastAccess = Date.now();
71
75
  const combinedSignal = this._combineSignals(streamEntry.abortController.signal, options?.userSignal);
@@ -81,14 +85,14 @@ var StreamManager = class {
81
85
  streamEntry.clients.delete(res);
82
86
  this.activeOperations.delete(streamOperation);
83
87
  if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) this._scheduleGraceAbort(streamEntry);
84
- if (streamEntry.isCompleted && streamEntry.clients.size === 0) setTimeout(() => {
85
- if (streamEntry.clients.size === 0) this.streamRegistry.remove(streamEntry.streamId);
86
- }, this.bufferTTL);
88
+ this._scheduleRemovalAfterTTL(streamEntry);
87
89
  });
88
90
  if (streamEntry.isCompleted) {
89
91
  res.end();
90
92
  this.activeOperations.delete(streamOperation);
91
93
  clearInterval(heartbeat);
94
+ streamEntry.clients.delete(res);
95
+ this._scheduleRemovalAfterTTL(streamEntry);
92
96
  }
93
97
  }
94
98
  async _createNewStream(res, handler, options, ownerKey) {
@@ -126,6 +130,7 @@ var StreamManager = class {
126
130
  this.activeOperations.delete(streamOperation);
127
131
  streamEntry.clients.delete(res);
128
132
  if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) this._scheduleGraceAbort(streamEntry);
133
+ this._scheduleRemovalAfterTTL(streamEntry);
129
134
  });
130
135
  await this._processGeneratorInBackground(streamEntry);
131
136
  clearInterval(heartbeat);
@@ -153,35 +158,32 @@ var StreamManager = class {
153
158
  this._broadcastEventsToClients(streamEntry, eventId, event);
154
159
  streamEntry.lastAccess = Date.now();
155
160
  }
156
- streamEntry.isCompleted = true;
157
- this._clearGraceTimer(streamEntry);
158
- this._closeAllClients(streamEntry);
159
- this._cleanupStream(streamEntry);
161
+ this._finalizeStream(streamEntry);
160
162
  } catch (error) {
161
- const errorMsg = error instanceof Error ? error.message : "Internal server error";
163
+ const rawMsg = error instanceof Error ? error.message : "Internal server error";
164
+ const clientMsg = error instanceof AppKitError ? error.clientMessage : "Internal server error";
165
+ const upstreamCode = error instanceof ExecutionError ? error.errorCode : void 0;
162
166
  const errorEventId = randomUUID();
163
167
  const errorCode = this._categorizeError(error);
164
168
  if (errorCode === SSEErrorCode.STREAM_ABORTED) {
165
169
  logger.info("Stream aborted by client (code=%s)", errorCode);
166
- streamEntry.isCompleted = true;
167
- this._clearGraceTimer(streamEntry);
168
- this._closeAllClients(streamEntry);
169
- this._cleanupStream(streamEntry);
170
+ this._finalizeStream(streamEntry);
170
171
  return;
171
172
  }
172
- logger.error("Stream execution failed: %s (code=%s)", errorMsg, errorCode);
173
+ logger.error("Stream execution failed: %s (code=%s upstreamCode=%s)", rawMsg, errorCode, upstreamCode ?? "n/a");
174
+ const payload = {
175
+ error: clientMsg,
176
+ code: errorCode
177
+ };
178
+ if (upstreamCode) payload.errorCode = upstreamCode;
173
179
  streamEntry.eventBuffer.add({
174
180
  id: errorEventId,
175
181
  type: "error",
176
- data: JSON.stringify({
177
- error: errorMsg,
178
- code: errorCode
179
- }),
182
+ data: JSON.stringify(payload),
180
183
  timestamp: Date.now()
181
184
  });
182
- this._broadcastErrorToClients(streamEntry, errorEventId, errorMsg, errorCode, true);
183
- streamEntry.isCompleted = true;
184
- this._clearGraceTimer(streamEntry);
185
+ this._broadcastErrorToClients(streamEntry, errorEventId, clientMsg, errorCode, true, upstreamCode);
186
+ this._finalizeStream(streamEntry);
185
187
  }
186
188
  });
187
189
  }
@@ -203,17 +205,24 @@ var StreamManager = class {
203
205
  _broadcastEventsToClients(streamEntry, eventId, event) {
204
206
  for (const client of streamEntry.clients) if (!client.writableEnded) this.sseWriter.writeEvent(client, eventId, event);
205
207
  }
206
- _broadcastErrorToClients(streamEntry, eventId, errorMessage, errorCode, closeClients = false) {
208
+ _broadcastErrorToClients(streamEntry, eventId, errorMessage, errorCode, closeClients = false, upstreamCode) {
207
209
  for (const client of streamEntry.clients) if (!client.writableEnded) {
208
- this.sseWriter.writeError(client, eventId, errorMessage, errorCode);
210
+ this.sseWriter.writeError(client, eventId, errorMessage, errorCode, upstreamCode);
209
211
  if (closeClients) client.end();
210
212
  }
211
213
  }
214
+ _finalizeStream(streamEntry) {
215
+ streamEntry.isCompleted = true;
216
+ clearGraceTimer(streamEntry);
217
+ this._closeAllClients(streamEntry);
218
+ this._scheduleRemovalAfterTTL(streamEntry);
219
+ }
212
220
  _closeAllClients(streamEntry) {
213
221
  for (const client of streamEntry.clients) if (!client.writableEnded) client.end();
222
+ streamEntry.clients.clear();
214
223
  }
215
224
  _scheduleGraceAbort(streamEntry) {
216
- this._clearGraceTimer(streamEntry);
225
+ clearGraceTimer(streamEntry);
217
226
  const timer = setTimeout(() => {
218
227
  streamEntry.disconnectGraceTimer = void 0;
219
228
  if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) streamEntry.abortController.abort(new DOMException("Client disconnected (grace expired)", "AbortError"));
@@ -221,16 +230,15 @@ var StreamManager = class {
221
230
  timer.unref?.();
222
231
  streamEntry.disconnectGraceTimer = timer;
223
232
  }
224
- _clearGraceTimer(streamEntry) {
225
- if (streamEntry.disconnectGraceTimer) {
226
- clearTimeout(streamEntry.disconnectGraceTimer);
227
- streamEntry.disconnectGraceTimer = void 0;
228
- }
229
- }
230
- _cleanupStream(streamEntry) {
231
- if (streamEntry.clients.size === 0) setTimeout(() => {
232
- if (streamEntry.clients.size === 0) this.streamRegistry.remove(streamEntry.streamId);
233
+ _scheduleRemovalAfterTTL(streamEntry) {
234
+ if (!streamEntry.isCompleted || streamEntry.clients.size > 0) return;
235
+ clearRemovalTimer(streamEntry);
236
+ streamEntry.lastAccess = Date.now();
237
+ streamEntry.removalTimer = setTimeout(() => {
238
+ streamEntry.removalTimer = void 0;
239
+ if (streamEntry.clients.size === 0 && Date.now() - streamEntry.lastAccess >= this.bufferTTL) this.streamRegistry.remove(streamEntry.streamId);
233
240
  }, this.bufferTTL);
241
+ streamEntry.removalTimer.unref?.();
234
242
  }
235
243
  _categorizeError(error) {
236
244
  if (error instanceof Error) {
@@ -1 +1 @@
1
- {"version":3,"file":"stream-manager.js","names":[],"sources":["../../src/stream/stream-manager.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { context } from \"@opentelemetry/api\";\nimport type { IAppResponse, StreamConfig } from \"shared\";\nimport { createLogger } from \"../logging/logger\";\nimport { EventRingBuffer } from \"./buffers\";\nimport { streamDefaults } from \"./defaults\";\nimport { SSEWriter } from \"./sse-writer\";\nimport { StreamRegistry } from \"./stream-registry\";\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 this._clearGraceTimer(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 if (streamEntry.isCompleted && streamEntry.clients.size === 0) {\n setTimeout(() => {\n if (streamEntry.clients.size === 0) {\n this.streamRegistry.remove(streamEntry.streamId);\n }\n }, this.bufferTTL);\n }\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 }\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\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 };\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\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\n // validate event size\n if (eventData.length > this.maxEventSize) {\n const errorMsg = `Event exceeds max size of ${this.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 streamEntry.isCompleted = true;\n\n // no late grace abort should fire on a completed stream\n this._clearGraceTimer(streamEntry);\n\n // close all clients\n this._closeAllClients(streamEntry);\n\n // cleanup if no clients are connected\n this._cleanupStream(streamEntry);\n } catch (error) {\n const errorMsg =\n error instanceof Error ? error.message : \"Internal server error\";\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 streamEntry.isCompleted = true;\n this._clearGraceTimer(streamEntry);\n this._closeAllClients(streamEntry);\n this._cleanupStream(streamEntry);\n return;\n }\n\n logger.error(\n \"Stream execution failed: %s (code=%s)\",\n errorMsg,\n errorCode,\n );\n\n // buffer error event\n streamEntry.eventBuffer.add({\n id: errorEventId,\n type: \"error\",\n data: JSON.stringify({ error: errorMsg, code: errorCode }),\n timestamp: Date.now(),\n });\n\n // send error event to all connected clients\n this._broadcastErrorToClients(\n streamEntry,\n errorEventId,\n errorMsg,\n errorCode,\n true,\n );\n streamEntry.isCompleted = true;\n this._clearGraceTimer(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 ): void {\n for (const client of streamEntry.clients) {\n if (!client.writableEnded) {\n this.sseWriter.writeError(client, eventId, errorMessage, errorCode);\n if (closeClients) {\n client.end();\n }\n }\n }\n }\n\n // close all connected clients\n private _closeAllClients(streamEntry: StreamEntry): void {\n for (const client of streamEntry.clients) {\n if (!client.writableEnded) {\n client.end();\n }\n }\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 this._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 // clear a pending disconnect-grace timer, if any\n private _clearGraceTimer(streamEntry: StreamEntry): void {\n if (streamEntry.disconnectGraceTimer) {\n clearTimeout(streamEntry.disconnectGraceTimer);\n streamEntry.disconnectGraceTimer = undefined;\n }\n }\n\n // cleanup stream if no clients are connected\n private _cleanupStream(streamEntry: StreamEntry): void {\n if (streamEntry.clients.size === 0) {\n setTimeout(() => {\n if (streamEntry.clients.size === 0) {\n this.streamRegistry.remove(streamEntry.streamId);\n }\n }, this.bufferTTL);\n }\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":";;;;;;;;;;;AAWA,MAAM,SAAS,aAAa,SAAS;AAGrC,IAAa,gBAAb,MAA2B;CACzB,AAAQ;CACR,AAAQ;CACR,AAAQ;CACR,AAAQ;CACR,AAAQ;CACR,AAAQ;CAER,YAAY,SAAwB;AAClC,OAAK,iBAAiB,IAAI,eACxB,SAAS,oBAAoB,eAAe,iBAC7C;AACD,OAAK,YAAY,IAAI,WAAW;AAChC,OAAK,eAAe,SAAS,gBAAgB,eAAe;AAC5D,OAAK,YAAY,SAAS,aAAa,eAAe;AACtD,OAAK,oBACH,SAAS,qBAAqB,eAAe;AAC/C,OAAK,mCAAmB,IAAI,KAAK;;CAInC,MAAM,OACJ,KACA,SACA,SACA,UACe;EACf,MAAM,EAAE,aAAa,WAAW,EAAE;AAGlC,MAAI,IAAI,iBAAiB,IAAI,UAC3B;AAIF,OAAK,UAAU,aAAa,IAAI;AAGhC,MAAI,YAAY,gBAAgB,iBAAiB,SAAS,EAAE;GAC1D,MAAM,iBAAiB,KAAK,eAAe,IAAI,SAAS;AACxD,OAAI,gBAAgB;AAKlB,QAAI,eAAe,aAAa,UAAU;AACxC,UAAK,UAAU,WACb,KACA,YAAY,EACZ,qCACA,aAAa,iBACd;AACD,SAAI,KAAK;AACT;;AAEF,WAAO,KAAK,wBAAwB,KAAK,gBAAgB,QAAQ;;;AAKrE,SAAO,KAAK,iBAAiB,KAAK,SAAS,SAAS,SAAS;;CAI/D,WAAiB;AAEf,OAAK,iBAAiB,SAAS,cAAc;AAC3C,OAAI,UAAU,UAAW,eAAc,UAAU,UAAU;AAC3D,aAAU,WAAW,MACnB,IAAI,aAAa,mBAAmB,aAAa,CAClD;IACD;AACF,OAAK,iBAAiB,OAAO;AAC7B,OAAK,eAAe,OAAO;;CAI7B,iBAAyB;AACvB,SAAO,KAAK,iBAAiB;;CAI/B,MAAc,wBACZ,KACA,aACA,SACe;EAEf,MAAM,cAAc,IAAI,KAAK,QAAQ;AAErC,MAAI,gBAAgB,gBAAgB,YAAY,EAAE;GAEhD,MAAM,eAAe;AACrB,OAAI,YAAY,YAAY,IAAI,aAAa,EAAE;IAC7C,MAAM,eACJ,YAAY,YAAY,eAAe,aAAa;AAEtD,SAAK,MAAM,SAAS,cAAc;AAChC,SAAI,SAAS,YAAY,QAAS;AAClC,UAAK,UAAU,mBAAmB,KAAK,MAAM;;SAI/C,MAAK,UAAU,2BAA2B,KAAK,aAAa;;AAKhE,OAAK,iBAAiB,YAAY;AAGlC,cAAY,QAAQ,IAAI,IAAI;AAC5B,cAAY,aAAa,KAAK,KAAK;EAGnC,MAAM,iBAAiB,KAAK,gBAC1B,YAAY,gBAAgB,QAC5B,SAAS,WACV;EACD,MAAM,YAAY,KAAK,UAAU,eAAe,KAAK,eAAe;EAGpE,MAAM,kBAAmC;GACvC,YAAY,YAAY;GACxB,MAAM;GACN;GACD;AACD,OAAK,iBAAiB,IAAI,gBAAgB;AAG1C,MAAI,GAAG,eAAe;AACpB,iBAAc,UAAU;AACxB,eAAY,QAAQ,OAAO,IAAI;AAC/B,QAAK,iBAAiB,OAAO,gBAAgB;AAG7C,OAAI,YAAY,QAAQ,SAAS,KAAK,CAAC,YAAY,YACjD,MAAK,oBAAoB,YAAY;AAIvC,OAAI,YAAY,eAAe,YAAY,QAAQ,SAAS,EAC1D,kBAAiB;AACf,QAAI,YAAY,QAAQ,SAAS,EAC/B,MAAK,eAAe,OAAO,YAAY,SAAS;MAEjD,KAAK,UAAU;IAEpB;AAGF,MAAI,YAAY,aAAa;AAC3B,OAAI,KAAK;AAET,QAAK,iBAAiB,OAAO,gBAAgB;AAC7C,iBAAc,UAAU;;;CAG5B,MAAc,iBACZ,KACA,SACA,SACA,UACe;EACf,MAAM,WAAW,SAAS,YAAY,YAAY;AAGlD,MAAI,IAAI,iBAAiB,IAAI,UAC3B;EAGF,MAAM,kBAAkB,IAAI,iBAAiB;EAG7C,MAAM,cAAc,IAAI,gBACtB,SAAS,cAAc,eAAe,WACvC;EAGD,MAAM,iBAAiB,KAAK,gBAC1B,gBAAgB,QAChB,SAAS,WACV;EACD,MAAM,YAAY,KAAK,UAAU,eAAe,KAAK,eAAe;EAGpE,MAAM,eAAe,QAAQ,QAAQ;AAGrC,MAAI,IAAI,iBAAiB,IAAI,WAAW;AACtC,iBAAc,UAAU;AACxB;;EAIF,MAAM,cAA2B;GAC/B;GACA;GACA,WAAW,QAAQ,eAAe;GAClC;GACA,SAAS,IAAI,IAAI,CAAC,IAAI,CAAC;GACvB,aAAa;GACb,YAAY,KAAK,KAAK;GACtB;GACA;GACD;AACD,OAAK,eAAe,IAAI,YAAY;EAGpC,MAAM,kBAAmC;GACvC,YAAY;GACZ,MAAM;GACN;GACD;AACD,OAAK,iBAAiB,IAAI,gBAAgB;AAE1C,MAAI,GAAG,eAAe;AACpB,iBAAc,UAAU;AACxB,QAAK,iBAAiB,OAAO,gBAAgB;AAC7C,eAAY,QAAQ,OAAO,IAAI;AAG/B,OAAI,YAAY,QAAQ,SAAS,KAAK,CAAC,YAAY,YACjD,MAAK,oBAAoB,YAAY;IAEvC;AAEF,QAAM,KAAK,8BAA8B,YAAY;AAGrD,gBAAc,UAAU;AACxB,OAAK,iBAAiB,OAAO,gBAAgB;;CAG/C,MAAc,8BACZ,aACe;AAEf,SAAO,QAAQ,KAAK,YAAY,cAAc,YAAY;AACxD,OAAI;AAEF,eAAW,MAAM,SAAS,YAAY,WAAW;AAC/C,SAAI,YAAY,gBAAgB,OAAO,QAAS;KAChD,MAAM,UAAU,YAAY;KAC5B,MAAM,YAAY,KAAK,UAAU,MAAM;AAGvC,SAAI,UAAU,SAAS,KAAK,cAAc;MACxC,MAAM,WAAW,6BAA6B,KAAK,aAAa;MAChE,MAAM,YAAY,aAAa;AAE/B,WAAK,yBACH,aACA,SACA,UACA,UACD;AACD;;AAIF,iBAAY,YAAY,IAAI;MAC1B,IAAI;MACJ,MAAM,MAAM;MACZ,MAAM;MACN,WAAW,KAAK,KAAK;MACtB,CAAC;AAGF,UAAK,0BAA0B,aAAa,SAAS,MAAM;AAC3D,iBAAY,aAAa,KAAK,KAAK;;AAGrC,gBAAY,cAAc;AAG1B,SAAK,iBAAiB,YAAY;AAGlC,SAAK,iBAAiB,YAAY;AAGlC,SAAK,eAAe,YAAY;YACzB,OAAO;IACd,MAAM,WACJ,iBAAiB,QAAQ,MAAM,UAAU;IAC3C,MAAM,eAAe,YAAY;IACjC,MAAM,YAAY,KAAK,iBAAiB,MAAM;AAG9C,QAAI,cAAc,aAAa,gBAAgB;AAC7C,YAAO,KAAK,sCAAsC,UAAU;AAC5D,iBAAY,cAAc;AAC1B,UAAK,iBAAiB,YAAY;AAClC,UAAK,iBAAiB,YAAY;AAClC,UAAK,eAAe,YAAY;AAChC;;AAGF,WAAO,MACL,yCACA,UACA,UACD;AAGD,gBAAY,YAAY,IAAI;KAC1B,IAAI;KACJ,MAAM;KACN,MAAM,KAAK,UAAU;MAAE,OAAO;MAAU,MAAM;MAAW,CAAC;KAC1D,WAAW,KAAK,KAAK;KACtB,CAAC;AAGF,SAAK,yBACH,aACA,cACA,UACA,WACA,KACD;AACD,gBAAY,cAAc;AAC1B,SAAK,iBAAiB,YAAY;;IAEpC;;CAGJ,AAAQ,gBACN,gBACA,YACa;AACb,MAAI,CAAC,WAAY,QAAO,kBAAkB,IAAI,iBAAiB,CAAC;EAEhE,MAAM,UAAU,CAAC,gBAAgB,WAAW,CAAC,OAC3C,QACD;EACD,MAAM,aAAa,IAAI,iBAAiB;AAExC,UAAQ,SAAS,WAAW;AAC1B,OAAI,QAAQ,SAAS;AACnB,eAAW,MAAM,OAAO,OAAO;AAC/B;;AAGF,WAAQ,iBACN,eACM;AACJ,eAAW,MAAM,OAAO,OAAO;MAEjC,EAAE,MAAM,MAAM,CACf;IACD;AACF,SAAO,WAAW;;CAIpB,AAAQ,0BACN,aACA,SACA,OACM;AACN,OAAK,MAAM,UAAU,YAAY,QAC/B,KAAI,CAAC,OAAO,cACV,MAAK,UAAU,WAAW,QAAQ,SAAS,MAAM;;CAMvD,AAAQ,yBACN,aACA,SACA,cACA,WACA,eAAwB,OAClB;AACN,OAAK,MAAM,UAAU,YAAY,QAC/B,KAAI,CAAC,OAAO,eAAe;AACzB,QAAK,UAAU,WAAW,QAAQ,SAAS,cAAc,UAAU;AACnE,OAAI,aACF,QAAO,KAAK;;;CAOpB,AAAQ,iBAAiB,aAAgC;AACvD,OAAK,MAAM,UAAU,YAAY,QAC/B,KAAI,CAAC,OAAO,cACV,QAAO,KAAK;;CAMlB,AAAQ,oBAAoB,aAAgC;AAE1D,OAAK,iBAAiB,YAAY;EAElC,MAAM,QAAQ,iBAAiB;AAC7B,eAAY,uBAAuB;AACnC,OAAI,YAAY,QAAQ,SAAS,KAAK,CAAC,YAAY,YACjD,aAAY,gBAAgB,MAC1B,IAAI,aAAa,uCAAuC,aAAa,CACtE;KAEF,KAAK,kBAAkB;AAG1B,QAAM,SAAS;AACf,cAAY,uBAAuB;;CAIrC,AAAQ,iBAAiB,aAAgC;AACvD,MAAI,YAAY,sBAAsB;AACpC,gBAAa,YAAY,qBAAqB;AAC9C,eAAY,uBAAuB;;;CAKvC,AAAQ,eAAe,aAAgC;AACrD,MAAI,YAAY,QAAQ,SAAS,EAC/B,kBAAiB;AACf,OAAI,YAAY,QAAQ,SAAS,EAC/B,MAAK,eAAe,OAAO,YAAY,SAAS;KAEjD,KAAK,UAAU;;CAItB,AAAQ,iBAAiB,OAA8B;AACrD,MAAI,iBAAiB,OAAO;GAC1B,MAAM,UAAU,MAAM,QAAQ,aAAa;AAC3C,OAAI,QAAQ,SAAS,UAAU,IAAI,QAAQ,SAAS,YAAY,CAC9D,QAAO,aAAa;AAGtB,OAAI,QAAQ,SAAS,cAAc,IAAI,QAAQ,SAAS,eAAe,CACrE,QAAO,aAAa;AAGtB,OAAI,MAAM,SAAS,aACjB,QAAO,aAAa;AAKtB,OACE,QAAQ,SAAS,wBAAwB,IACzC,QAAQ,SAAS,0BAA0B,IAC3C,QAAQ,SAAS,yBAAyB,CAE1C,QAAO,aAAa;AAItB,OACE,gBAAgB,SAChB,OAAQ,MAAc,eAAe,SAErC,QAAO,aAAa;;AAIxB,SAAO,aAAa"}
1
+ {"version":3,"file":"stream-manager.js","names":[],"sources":["../../src/stream/stream-manager.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { context } from \"@opentelemetry/api\";\nimport type { IAppResponse, StreamConfig } from \"shared\";\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\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 };\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\n // validate event size\n if (eventData.length > this.maxEventSize) {\n const errorMsg = `Event exceeds max size of ${this.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,50 +1,56 @@
1
- import { RingBuffer } from "./buffers.js";
2
1
  import { SSEErrorCode } from "./types.js";
2
+ import { clearGraceTimer, clearRemovalTimer } from "./timers.js";
3
3
 
4
4
  //#region src/stream/stream-registry.ts
5
5
  var StreamRegistry = class {
6
6
  streams;
7
+ maxActiveStreams;
7
8
  constructor(maxActiveStreams) {
8
- this.streams = new RingBuffer(maxActiveStreams, (entry) => entry.streamId);
9
+ this.streams = /* @__PURE__ */ new Map();
10
+ this.maxActiveStreams = maxActiveStreams;
9
11
  }
10
12
  add(entry) {
11
- if (this.streams.getSize() >= this.streams.capacity) this._evictOldestStream(entry.streamId);
12
- this.streams.add(entry);
13
+ if (!this.streams.has(entry.streamId) && this.streams.size >= this.maxActiveStreams) this._evictOldestStream(entry.streamId);
14
+ this.streams.set(entry.streamId, entry);
13
15
  }
14
16
  get(streamId) {
15
- return this.streams.get(streamId);
17
+ return this.streams.get(streamId) ?? null;
16
18
  }
17
19
  has(streamId) {
18
20
  return this.streams.has(streamId);
19
21
  }
20
22
  remove(streamId) {
21
- this.streams.remove(streamId);
23
+ this.streams.delete(streamId);
22
24
  }
23
25
  size() {
24
- return this.streams.getSize();
26
+ return this.streams.size;
25
27
  }
26
28
  clear() {
27
- const allStreams = this.streams.getAll();
28
- for (const stream of allStreams) {
29
- this._clearGraceTimer(stream);
29
+ for (const stream of this.streams.values()) {
30
30
  stream.abortController.abort("Server shutdown");
31
+ clearGraceTimer(stream);
32
+ clearRemovalTimer(stream);
31
33
  }
32
34
  this.streams.clear();
33
35
  }
34
- _clearGraceTimer(stream) {
35
- if (stream.disconnectGraceTimer) {
36
- clearTimeout(stream.disconnectGraceTimer);
37
- stream.disconnectGraceTimer = void 0;
38
- }
39
- }
40
36
  _evictOldestStream(excludeStreamId) {
41
- const allStreams = this.streams.getAll();
37
+ const allStreams = this.streams.values();
42
38
  let oldestStream = null;
43
39
  let oldestAccess = Infinity;
44
- for (const stream of allStreams) if (stream.streamId !== excludeStreamId && stream.lastAccess < oldestAccess) {
45
- oldestStream = stream;
46
- oldestAccess = stream.lastAccess;
40
+ let oldestCompletedStream = null;
41
+ let oldestCompletedAccess = Infinity;
42
+ for (const stream of allStreams) {
43
+ if (stream.streamId === excludeStreamId) continue;
44
+ if (stream.lastAccess < oldestAccess) {
45
+ oldestStream = stream;
46
+ oldestAccess = stream.lastAccess;
47
+ }
48
+ if (stream.isCompleted && stream.lastAccess < oldestCompletedAccess) {
49
+ oldestCompletedStream = stream;
50
+ oldestCompletedAccess = stream.lastAccess;
51
+ }
47
52
  }
53
+ oldestStream = oldestCompletedStream ?? oldestStream;
48
54
  if (oldestStream) {
49
55
  for (const client of oldestStream.clients) if (!client.writableEnded) try {
50
56
  client.write(`event: error\n`);
@@ -53,9 +59,10 @@ var StreamRegistry = class {
53
59
  code: SSEErrorCode.STREAM_EVICTED
54
60
  })}\n\n`);
55
61
  } catch (_error) {}
56
- this._clearGraceTimer(oldestStream);
57
- oldestStream.abortController.abort("Stream evicted");
58
- this.streams.remove(oldestStream.streamId);
62
+ clearGraceTimer(oldestStream);
63
+ oldestStream.abortController.abort(new DOMException("Stream evicted", "AbortError"));
64
+ clearRemovalTimer(oldestStream);
65
+ this.streams.delete(oldestStream.streamId);
59
66
  }
60
67
  }
61
68
  };
@@ -1 +1 @@
1
- {"version":3,"file":"stream-registry.js","names":[],"sources":["../../src/stream/stream-registry.ts"],"sourcesContent":["import { RingBuffer } from \"./buffers\";\nimport { SSEErrorCode, type StreamEntry } from \"./types\";\n\nexport class StreamRegistry {\n private streams: RingBuffer<StreamEntry>;\n\n constructor(maxActiveStreams: number) {\n this.streams = new RingBuffer<StreamEntry>(\n maxActiveStreams,\n (entry) => entry.streamId,\n );\n }\n\n // add a stream to the registry\n add(entry: StreamEntry): void {\n // enforce hard cap\n if (this.streams.getSize() >= this.streams.capacity) {\n this._evictOldestStream(entry.streamId);\n }\n\n this.streams.add(entry);\n }\n\n // get a stream from the registry\n get(streamId: string): StreamEntry | null {\n return this.streams.get(streamId);\n }\n\n // check if a stream exists in the registry\n has(streamId: string): boolean {\n return this.streams.has(streamId);\n }\n\n // remove a stream from the registry\n remove(streamId: string): void {\n this.streams.remove(streamId);\n }\n\n // get the number of streams in the registry\n size(): number {\n return this.streams.getSize();\n }\n\n clear(): void {\n const allStreams = this.streams.getAll();\n\n for (const stream of allStreams) {\n this._clearGraceTimer(stream);\n stream.abortController.abort(\"Server shutdown\");\n }\n\n this.streams.clear();\n }\n\n // clear a pending grace timer so a removed stream isn't pinned until it fires\n private _clearGraceTimer(stream: StreamEntry): void {\n if (stream.disconnectGraceTimer) {\n clearTimeout(stream.disconnectGraceTimer);\n stream.disconnectGraceTimer = undefined;\n }\n }\n\n // evict the oldest stream from the registry\n private _evictOldestStream(excludeStreamId: string): void {\n const allStreams = this.streams.getAll();\n let oldestStream: StreamEntry | null = null;\n let oldestAccess = Infinity;\n\n // find the least recently accessed stream\n for (const stream of allStreams) {\n if (\n stream.streamId !== excludeStreamId &&\n stream.lastAccess < oldestAccess\n ) {\n oldestStream = stream;\n oldestAccess = stream.lastAccess;\n }\n }\n\n // abort the oldest stream\n if (oldestStream) {\n // broadcast stream eviction error to all clients\n for (const client of oldestStream.clients) {\n if (!client.writableEnded) {\n try {\n client.write(`event: error\\n`);\n client.write(\n `data: ${JSON.stringify({ error: \"Stream evicted\", code: SSEErrorCode.STREAM_EVICTED })}\\n\\n`,\n );\n } catch (_error) {\n // ignore\n }\n }\n }\n this._clearGraceTimer(oldestStream);\n oldestStream.abortController.abort(\"Stream evicted\");\n this.streams.remove(oldestStream.streamId);\n }\n }\n}\n"],"mappings":";;;;AAGA,IAAa,iBAAb,MAA4B;CAC1B,AAAQ;CAER,YAAY,kBAA0B;AACpC,OAAK,UAAU,IAAI,WACjB,mBACC,UAAU,MAAM,SAClB;;CAIH,IAAI,OAA0B;AAE5B,MAAI,KAAK,QAAQ,SAAS,IAAI,KAAK,QAAQ,SACzC,MAAK,mBAAmB,MAAM,SAAS;AAGzC,OAAK,QAAQ,IAAI,MAAM;;CAIzB,IAAI,UAAsC;AACxC,SAAO,KAAK,QAAQ,IAAI,SAAS;;CAInC,IAAI,UAA2B;AAC7B,SAAO,KAAK,QAAQ,IAAI,SAAS;;CAInC,OAAO,UAAwB;AAC7B,OAAK,QAAQ,OAAO,SAAS;;CAI/B,OAAe;AACb,SAAO,KAAK,QAAQ,SAAS;;CAG/B,QAAc;EACZ,MAAM,aAAa,KAAK,QAAQ,QAAQ;AAExC,OAAK,MAAM,UAAU,YAAY;AAC/B,QAAK,iBAAiB,OAAO;AAC7B,UAAO,gBAAgB,MAAM,kBAAkB;;AAGjD,OAAK,QAAQ,OAAO;;CAItB,AAAQ,iBAAiB,QAA2B;AAClD,MAAI,OAAO,sBAAsB;AAC/B,gBAAa,OAAO,qBAAqB;AACzC,UAAO,uBAAuB;;;CAKlC,AAAQ,mBAAmB,iBAA+B;EACxD,MAAM,aAAa,KAAK,QAAQ,QAAQ;EACxC,IAAI,eAAmC;EACvC,IAAI,eAAe;AAGnB,OAAK,MAAM,UAAU,WACnB,KACE,OAAO,aAAa,mBACpB,OAAO,aAAa,cACpB;AACA,kBAAe;AACf,kBAAe,OAAO;;AAK1B,MAAI,cAAc;AAEhB,QAAK,MAAM,UAAU,aAAa,QAChC,KAAI,CAAC,OAAO,cACV,KAAI;AACF,WAAO,MAAM,iBAAiB;AAC9B,WAAO,MACL,SAAS,KAAK,UAAU;KAAE,OAAO;KAAkB,MAAM,aAAa;KAAgB,CAAC,CAAC,MACzF;YACM,QAAQ;AAKrB,QAAK,iBAAiB,aAAa;AACnC,gBAAa,gBAAgB,MAAM,iBAAiB;AACpD,QAAK,QAAQ,OAAO,aAAa,SAAS"}
1
+ {"version":3,"file":"stream-registry.js","names":[],"sources":["../../src/stream/stream-registry.ts"],"sourcesContent":["import { clearGraceTimer, clearRemovalTimer } from \"./timers\";\nimport { SSEErrorCode, type StreamEntry } from \"./types\";\n\nexport class StreamRegistry {\n // keyed storage with explicit, policy-driven eviction. A ring buffer is\n // unsuitable here: it overwrites by insertion slot, so evicting an entry\n // chosen by policy (e.g. a completed stream) and then adding would\n // silently clobber an unrelated live stream sitting in the oldest slot.\n private streams: Map<string, StreamEntry>;\n private maxActiveStreams: number;\n\n constructor(maxActiveStreams: number) {\n this.streams = new Map();\n this.maxActiveStreams = maxActiveStreams;\n }\n\n // add a stream to the registry\n add(entry: StreamEntry): void {\n // enforce hard cap, but only when inserting a genuinely new key:\n // re-adding an existing streamId updates in place and doesn't grow the\n // map, so evicting another stream for it would destroy an innocent one\n if (\n !this.streams.has(entry.streamId) &&\n this.streams.size >= this.maxActiveStreams\n ) {\n this._evictOldestStream(entry.streamId);\n }\n\n this.streams.set(entry.streamId, entry);\n }\n\n // get a stream from the registry\n get(streamId: string): StreamEntry | null {\n return this.streams.get(streamId) ?? null;\n }\n\n // check if a stream exists in the registry\n has(streamId: string): boolean {\n return this.streams.has(streamId);\n }\n\n // remove a stream from the registry\n remove(streamId: string): void {\n this.streams.delete(streamId);\n }\n\n // get the number of streams in the registry\n size(): number {\n return this.streams.size;\n }\n\n clear(): void {\n for (const stream of this.streams.values()) {\n stream.abortController.abort(\"Server shutdown\");\n clearGraceTimer(stream);\n clearRemovalTimer(stream);\n }\n\n this.streams.clear();\n }\n\n // evict the oldest stream from the registry, preferring completed streams.\n // Completed streams waiting out their buffer TTL can look recently\n // accessed, so plain LRU could evict a live stream while dead ones\n // survive. Prefer the oldest completed stream when one exists and fall\n // back to LRU over all streams otherwise.\n private _evictOldestStream(excludeStreamId: string): void {\n const allStreams = this.streams.values();\n let oldestStream: StreamEntry | null = null;\n let oldestAccess = Infinity;\n let oldestCompletedStream: StreamEntry | null = null;\n let oldestCompletedAccess = Infinity;\n\n // find the least recently accessed stream (overall and completed-only)\n for (const stream of allStreams) {\n if (stream.streamId === excludeStreamId) continue;\n\n if (stream.lastAccess < oldestAccess) {\n oldestStream = stream;\n oldestAccess = stream.lastAccess;\n }\n\n if (stream.isCompleted && stream.lastAccess < oldestCompletedAccess) {\n oldestCompletedStream = stream;\n oldestCompletedAccess = stream.lastAccess;\n }\n }\n\n oldestStream = oldestCompletedStream ?? oldestStream;\n\n // abort the evicted stream\n if (oldestStream) {\n // broadcast stream eviction error to all clients\n for (const client of oldestStream.clients) {\n if (!client.writableEnded) {\n try {\n client.write(`event: error\\n`);\n client.write(\n `data: ${JSON.stringify({ error: \"Stream evicted\", code: SSEErrorCode.STREAM_EVICTED })}\\n\\n`,\n );\n } catch (_error) {\n // ignore\n }\n }\n }\n clearGraceTimer(oldestStream);\n // abort with a DOMException AbortError so the error categorizer routes\n // eviction as an abort (matching the manager's terminal paths) rather\n // than a stream failure\n oldestStream.abortController.abort(\n new DOMException(\"Stream evicted\", \"AbortError\"),\n );\n // a pending removal timer would otherwise pin the evicted entry\n clearRemovalTimer(oldestStream);\n this.streams.delete(oldestStream.streamId);\n }\n }\n}\n"],"mappings":";;;;AAGA,IAAa,iBAAb,MAA4B;CAK1B,AAAQ;CACR,AAAQ;CAER,YAAY,kBAA0B;AACpC,OAAK,0BAAU,IAAI,KAAK;AACxB,OAAK,mBAAmB;;CAI1B,IAAI,OAA0B;AAI5B,MACE,CAAC,KAAK,QAAQ,IAAI,MAAM,SAAS,IACjC,KAAK,QAAQ,QAAQ,KAAK,iBAE1B,MAAK,mBAAmB,MAAM,SAAS;AAGzC,OAAK,QAAQ,IAAI,MAAM,UAAU,MAAM;;CAIzC,IAAI,UAAsC;AACxC,SAAO,KAAK,QAAQ,IAAI,SAAS,IAAI;;CAIvC,IAAI,UAA2B;AAC7B,SAAO,KAAK,QAAQ,IAAI,SAAS;;CAInC,OAAO,UAAwB;AAC7B,OAAK,QAAQ,OAAO,SAAS;;CAI/B,OAAe;AACb,SAAO,KAAK,QAAQ;;CAGtB,QAAc;AACZ,OAAK,MAAM,UAAU,KAAK,QAAQ,QAAQ,EAAE;AAC1C,UAAO,gBAAgB,MAAM,kBAAkB;AAC/C,mBAAgB,OAAO;AACvB,qBAAkB,OAAO;;AAG3B,OAAK,QAAQ,OAAO;;CAQtB,AAAQ,mBAAmB,iBAA+B;EACxD,MAAM,aAAa,KAAK,QAAQ,QAAQ;EACxC,IAAI,eAAmC;EACvC,IAAI,eAAe;EACnB,IAAI,wBAA4C;EAChD,IAAI,wBAAwB;AAG5B,OAAK,MAAM,UAAU,YAAY;AAC/B,OAAI,OAAO,aAAa,gBAAiB;AAEzC,OAAI,OAAO,aAAa,cAAc;AACpC,mBAAe;AACf,mBAAe,OAAO;;AAGxB,OAAI,OAAO,eAAe,OAAO,aAAa,uBAAuB;AACnE,4BAAwB;AACxB,4BAAwB,OAAO;;;AAInC,iBAAe,yBAAyB;AAGxC,MAAI,cAAc;AAEhB,QAAK,MAAM,UAAU,aAAa,QAChC,KAAI,CAAC,OAAO,cACV,KAAI;AACF,WAAO,MAAM,iBAAiB;AAC9B,WAAO,MACL,SAAS,KAAK,UAAU;KAAE,OAAO;KAAkB,MAAM,aAAa;KAAgB,CAAC,CAAC,MACzF;YACM,QAAQ;AAKrB,mBAAgB,aAAa;AAI7B,gBAAa,gBAAgB,MAC3B,IAAI,aAAa,kBAAkB,aAAa,CACjD;AAED,qBAAkB,aAAa;AAC/B,QAAK,QAAQ,OAAO,aAAa,SAAS"}
@@ -0,0 +1,17 @@
1
+ //#region src/stream/timers.ts
2
+ function clearGraceTimer(entry) {
3
+ if (entry.disconnectGraceTimer) {
4
+ clearTimeout(entry.disconnectGraceTimer);
5
+ entry.disconnectGraceTimer = void 0;
6
+ }
7
+ }
8
+ function clearRemovalTimer(entry) {
9
+ if (entry.removalTimer) {
10
+ clearTimeout(entry.removalTimer);
11
+ entry.removalTimer = void 0;
12
+ }
13
+ }
14
+
15
+ //#endregion
16
+ export { clearGraceTimer, clearRemovalTimer };
17
+ //# sourceMappingURL=timers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timers.js","names":[],"sources":["../../src/stream/timers.ts"],"sourcesContent":["import type { StreamEntry } from \"./types\";\n\n// clear a pending disconnect-grace timer so a removed/reconnected stream\n// isn't pinned until it fires\nexport function clearGraceTimer(entry: StreamEntry): void {\n if (entry.disconnectGraceTimer) {\n clearTimeout(entry.disconnectGraceTimer);\n entry.disconnectGraceTimer = undefined;\n }\n}\n\n// clear a pending registry-removal timer so a reconnected/evicted stream\n// isn't pulled out from under a client\nexport function clearRemovalTimer(entry: StreamEntry): void {\n if (entry.removalTimer) {\n clearTimeout(entry.removalTimer);\n entry.removalTimer = undefined;\n }\n}\n"],"mappings":";AAIA,SAAgB,gBAAgB,OAA0B;AACxD,KAAI,MAAM,sBAAsB;AAC9B,eAAa,MAAM,qBAAqB;AACxC,QAAM,uBAAuB;;;AAMjC,SAAgB,kBAAkB,OAA0B;AAC1D,KAAI,MAAM,cAAc;AACtB,eAAa,MAAM,aAAa;AAChC,QAAM,eAAe"}
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","names":[],"sources":["../../src/stream/types.ts"],"sourcesContent":["import type { Context } from \"@opentelemetry/api\";\nimport type { IAppResponse } from \"shared\";\nimport type { EventRingBuffer } from \"./buffers\";\n\nexport const SSEWarningCode = {\n BUFFER_OVERFLOW_RESTART: \"BUFFER_OVERFLOW_RESTART\",\n} as const satisfies Record<string, string>;\n\nexport type SSEWarningCode =\n (typeof SSEWarningCode)[keyof typeof SSEWarningCode];\n\nexport const SSEErrorCode = {\n TEMPORARY_UNAVAILABLE: \"TEMPORARY_UNAVAILABLE\",\n TIMEOUT: \"TIMEOUT\",\n INTERNAL_ERROR: \"INTERNAL_ERROR\",\n INVALID_REQUEST: \"INVALID_REQUEST\",\n STREAM_ABORTED: \"STREAM_ABORTED\",\n STREAM_EVICTED: \"STREAM_EVICTED\",\n STREAM_FORBIDDEN: \"STREAM_FORBIDDEN\",\n UPSTREAM_ERROR: \"UPSTREAM_ERROR\",\n} as const satisfies Record<string, string>;\n\nexport type SSEErrorCode = (typeof SSEErrorCode)[keyof typeof SSEErrorCode];\n\nexport interface SSEError {\n error: string;\n code: SSEErrorCode;\n}\n\nexport interface BufferedEvent {\n id: string;\n type: string;\n data: string;\n timestamp: number;\n}\n\nexport interface StreamEntry {\n streamId: string;\n /**\n * Identifier of the principal that created the stream (e.g. end-user ID\n * or service principal user ID). When set, only requests sharing the\n * same owner key may reconnect to the stream.\n */\n ownerKey?: string;\n generator: AsyncGenerator<any, void, unknown>;\n eventBuffer: EventRingBuffer;\n clients: Set<IAppResponse>;\n isCompleted: boolean;\n lastAccess: number;\n abortController: AbortController;\n traceContext: Context;\n // pending grace-window abort, set while the last client is disconnected\n disconnectGraceTimer?: NodeJS.Timeout;\n}\n\nexport interface StreamOperation {\n controller: AbortController;\n type: \"query\" | \"stream\";\n heartbeat?: NodeJS.Timeout;\n}\n"],"mappings":";AAIA,MAAa,iBAAiB,EAC5B,yBAAyB,2BAC1B;AAKD,MAAa,eAAe;CAC1B,uBAAuB;CACvB,SAAS;CACT,gBAAgB;CAChB,iBAAiB;CACjB,gBAAgB;CAChB,gBAAgB;CAChB,kBAAkB;CAClB,gBAAgB;CACjB"}
1
+ {"version":3,"file":"types.js","names":[],"sources":["../../src/stream/types.ts"],"sourcesContent":["import type { Context } from \"@opentelemetry/api\";\nimport type { IAppResponse } from \"shared\";\nimport type { EventRingBuffer } from \"./buffers\";\n\nexport const SSEWarningCode = {\n BUFFER_OVERFLOW_RESTART: \"BUFFER_OVERFLOW_RESTART\",\n} as const satisfies Record<string, string>;\n\nexport type SSEWarningCode =\n (typeof SSEWarningCode)[keyof typeof SSEWarningCode];\n\nexport const SSEErrorCode = {\n TEMPORARY_UNAVAILABLE: \"TEMPORARY_UNAVAILABLE\",\n TIMEOUT: \"TIMEOUT\",\n INTERNAL_ERROR: \"INTERNAL_ERROR\",\n INVALID_REQUEST: \"INVALID_REQUEST\",\n STREAM_ABORTED: \"STREAM_ABORTED\",\n STREAM_EVICTED: \"STREAM_EVICTED\",\n STREAM_FORBIDDEN: \"STREAM_FORBIDDEN\",\n UPSTREAM_ERROR: \"UPSTREAM_ERROR\",\n} as const satisfies Record<string, string>;\n\nexport type SSEErrorCode = (typeof SSEErrorCode)[keyof typeof SSEErrorCode];\n\nexport interface SSEError {\n error: string;\n code: SSEErrorCode;\n /**\n * Upstream-domain structured code (e.g. `RESULT_TOO_LARGE_FOR_JSON_FALLBACK`,\n * `NOT_IMPLEMENTED`). UI code should branch on this instead of parsing\n * the human-readable `error` string.\n */\n errorCode?: string;\n}\n\nexport interface BufferedEvent {\n id: string;\n type: string;\n data: string;\n timestamp: number;\n}\n\nexport interface StreamEntry {\n streamId: string;\n /**\n * Identifier of the principal that created the stream (e.g. end-user ID\n * or service principal user ID). When set, only requests sharing the\n * same owner key may reconnect to the stream.\n */\n ownerKey?: string;\n generator: AsyncGenerator<any, void, unknown>;\n eventBuffer: EventRingBuffer;\n clients: Set<IAppResponse>;\n isCompleted: boolean;\n lastAccess: number;\n abortController: AbortController;\n traceContext: Context;\n // pending grace-window abort, set while the last client is disconnected\n disconnectGraceTimer?: NodeJS.Timeout;\n /**\n * Pending registry-removal timer. At most one removal timer exists per\n * stream; scheduling a new one clears any previous timer first.\n */\n removalTimer?: NodeJS.Timeout;\n}\n\nexport interface StreamOperation {\n controller: AbortController;\n type: \"query\" | \"stream\";\n heartbeat?: NodeJS.Timeout;\n}\n"],"mappings":";AAIA,MAAa,iBAAiB,EAC5B,yBAAyB,2BAC1B;AAKD,MAAa,eAAe;CAC1B,uBAAuB;CACvB,SAAS;CACT,gBAAgB;CAChB,iBAAiB;CACjB,gBAAgB;CAChB,gBAAgB;CAChB,kBAAkB;CAClB,gBAAgB;CACjB"}
@@ -19,6 +19,7 @@ var TelemetryManager = class TelemetryManager {
19
19
  static DEFAULT_FALLBACK_APP_NAME = "databricks-app";
20
20
  static instance;
21
21
  sdk;
22
+ shutdownPromise;
22
23
  /**
23
24
  * Create a scoped telemetry provider for a specific plugin.
24
25
  * The plugin's name will be used as the default tracer/meter name.
@@ -54,7 +55,6 @@ var TelemetryManager = class TelemetryManager {
54
55
  instrumentations: this.getDefaultInstrumentations()
55
56
  });
56
57
  this.sdk.start();
57
- this.registerShutdown();
58
58
  logger.debug("Initialized successfully");
59
59
  } catch (error) {
60
60
  logger.error("Failed to initialize: %O", error);
@@ -90,21 +90,27 @@ var TelemetryManager = class TelemetryManager {
90
90
  "@opentelemetry/instrumentation-net": { enabled: false }
91
91
  })];
92
92
  }
93
- registerShutdown() {
94
- const shutdownFn = async () => {
95
- await TelemetryManager.getInstance().shutdown();
96
- };
97
- process.once("SIGTERM", shutdownFn);
98
- process.once("SIGINT", shutdownFn);
99
- }
93
+ /**
94
+ * Flush and shut down the OpenTelemetry SDK.
95
+ *
96
+ * Idempotent: the SDK reference is cleared synchronously and concurrent
97
+ * or repeated calls await the same in-flight flush. Awaited by the core
98
+ * lifecycle manager during graceful shutdown — that manager owns the
99
+ * process signal handlers, so telemetry no longer registers its own.
100
+ */
100
101
  async shutdown() {
101
- if (!this.sdk) return;
102
- try {
103
- await this.sdk.shutdown();
102
+ if (this.sdk) {
103
+ const sdk = this.sdk;
104
104
  this.sdk = void 0;
105
- } catch (error) {
106
- logger.error("Error shutting down: %O", error);
105
+ this.shutdownPromise = (async () => {
106
+ try {
107
+ await sdk.shutdown();
108
+ } catch (error) {
109
+ logger.error("Error shutting down: %O", error);
110
+ }
111
+ })();
107
112
  }
113
+ return this.shutdownPromise;
108
114
  }
109
115
  };
110
116
 
@@ -1 +1 @@
1
- {"version":3,"file":"telemetry-manager.js","names":[],"sources":["../../src/telemetry/telemetry-manager.ts"],"sourcesContent":["import { 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 { BatchLogRecordProcessor } from \"@opentelemetry/sdk-logs\";\nimport { PeriodicExportingMetricReader } from \"@opentelemetry/sdk-metrics\";\nimport { NodeSDK } from \"@opentelemetry/sdk-node\";\nimport {\n ATTR_SERVICE_NAME,\n ATTR_SERVICE_VERSION,\n} from \"@opentelemetry/semantic-conventions\";\nimport type { TelemetryOptions } from \"shared\";\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\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 sdk?: NodeSDK;\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 private _initialize(config: Partial<TelemetryConfig>): void {\n if (this.sdk) return;\n\n if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {\n return;\n }\n\n try {\n this.sdk = new NodeSDK({\n resource: this.createResource(config),\n autoDetectResources: false,\n sampler: new AppKitSampler(),\n traceExporter: new OTLPTraceExporter({ headers: config.headers }),\n metricReaders: [\n new PeriodicExportingMetricReader({\n exporter: new OTLPMetricExporter({ headers: config.headers }),\n exportIntervalMillis:\n config.exportIntervalMs ||\n TelemetryManager.DEFAULT_EXPORT_INTERVAL_MS,\n }),\n ],\n logRecordProcessors: [\n new BatchLogRecordProcessor(\n new OTLPLogExporter({ headers: config.headers }),\n ),\n ],\n instrumentations: this.getDefaultInstrumentations(),\n });\n\n this.sdk.start();\n this.registerShutdown();\n logger.debug(\"Initialized successfully\");\n } catch (error) {\n logger.error(\"Failed to initialize: %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 // global providers set by NodeSDK.start()\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 private registerShutdown() {\n const shutdownFn = async () => {\n await TelemetryManager.getInstance().shutdown();\n };\n process.once(\"SIGTERM\", shutdownFn);\n process.once(\"SIGINT\", shutdownFn);\n }\n\n private async shutdown(): Promise<void> {\n if (!this.sdk) {\n return;\n }\n\n try {\n await this.sdk.shutdown();\n this.sdk = undefined;\n } catch (error) {\n logger.error(\"Error shutting down: %O\", error);\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AA6BA,MAAM,SAAS,aAAa,YAAY;AAExC,IAAa,mBAAb,MAAa,iBAAiB;CAC5B,OAAwB,6BAA6B;CACrD,OAAwB,4BAA4B;CAEpD,OAAe;CACf,AAAQ;;;;;;;;CASR,OAAO,YACL,YACA,iBACmB;AAEnB,SAAO,IAAI,kBAAkB,YADP,iBAAiB,aAAa,EACI,gBAAgB;;CAG1E,AAAQ,cAAc;CAEtB,OAAO,cAAgC;AACrC,MAAI,CAAC,iBAAiB,SACpB,kBAAiB,WAAW,IAAI,kBAAkB;AAEpD,SAAO,iBAAiB;;CAG1B,OAAO,WAAW,SAAmC,EAAE,EAAQ;AAE7D,EADiB,iBAAiB,aAAa,CACtC,YAAY,OAAO;;CAG9B,AAAQ,YAAY,QAAwC;AAC1D,MAAI,KAAK,IAAK;AAEd,MAAI,CAAC,QAAQ,IAAI,4BACf;AAGF,MAAI;AACF,QAAK,MAAM,IAAI,QAAQ;IACrB,UAAU,KAAK,eAAe,OAAO;IACrC,qBAAqB;IACrB,SAAS,IAAI,eAAe;IAC5B,eAAe,IAAI,kBAAkB,EAAE,SAAS,OAAO,SAAS,CAAC;IACjE,eAAe,CACb,IAAI,8BAA8B;KAChC,UAAU,IAAI,mBAAmB,EAAE,SAAS,OAAO,SAAS,CAAC;KAC7D,sBACE,OAAO,oBACP,iBAAiB;KACpB,CAAC,CACH;IACD,qBAAqB,CACnB,IAAI,wBACF,IAAI,gBAAgB,EAAE,SAAS,OAAO,SAAS,CAAC,CACjD,CACF;IACD,kBAAkB,KAAK,4BAA4B;IACpD,CAAC;AAEF,QAAK,IAAI,OAAO;AAChB,QAAK,kBAAkB;AACvB,UAAO,MAAM,2BAA2B;WACjC,OAAO;AACd,UAAO,MAAM,4BAA4B,MAAM;;;;;;;;CASnD,yBAAyB,kBAA2C;AAClE,2BAA6B,EAE3B,kBACD,CAAC;;CAGJ,AAAQ,eAAe,QAA4C;EACjE,MAAM,cACJ,OAAO,eACP,QAAQ,IAAI,qBACZ,QAAQ,IAAI,uBACZ,iBAAiB;EACnB,MAAM,kBAAkB,uBAAuB;IAC5C,oBAAoB;IACpB,uBAAuB,OAAO,kBAAkB;GAClD,CAAC;EACF,MAAM,mBAAmB,gBAAgB,EACvC,WAAW;GAAC;GAAa;GAAc;GAAgB,EACxD,CAAC;AACF,SAAO,gBAAgB,MAAM,iBAAiB;;CAGhD,AAAQ,6BAAgD;AACtD,SAAO,CACL,GAAG,4BAA4B;GAI7B,uCAAuC,EACrC,SAAS,OACV;GACD,0CAA0C,EACxC,SAAS,OACV;GAID,qCAAqC,EACnC,SAAS,OACV;GACD,sCAAsC,EACpC,SAAS,OACV;GACD,sCAAsC,EACpC,SAAS,OACV;GACF,CAAC,CACH;;CAGH,AAAQ,mBAAmB;EACzB,MAAM,aAAa,YAAY;AAC7B,SAAM,iBAAiB,aAAa,CAAC,UAAU;;AAEjD,UAAQ,KAAK,WAAW,WAAW;AACnC,UAAQ,KAAK,UAAU,WAAW;;CAGpC,MAAc,WAA0B;AACtC,MAAI,CAAC,KAAK,IACR;AAGF,MAAI;AACF,SAAM,KAAK,IAAI,UAAU;AACzB,QAAK,MAAM;WACJ,OAAO;AACd,UAAO,MAAM,2BAA2B,MAAM"}
1
+ {"version":3,"file":"telemetry-manager.js","names":[],"sources":["../../src/telemetry/telemetry-manager.ts"],"sourcesContent":["import { 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 { BatchLogRecordProcessor } from \"@opentelemetry/sdk-logs\";\nimport { PeriodicExportingMetricReader } from \"@opentelemetry/sdk-metrics\";\nimport { NodeSDK } from \"@opentelemetry/sdk-node\";\nimport {\n ATTR_SERVICE_NAME,\n ATTR_SERVICE_VERSION,\n} from \"@opentelemetry/semantic-conventions\";\nimport type { TelemetryOptions } from \"shared\";\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\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 sdk?: NodeSDK;\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 private _initialize(config: Partial<TelemetryConfig>): void {\n if (this.sdk) return;\n\n if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {\n return;\n }\n\n try {\n this.sdk = new NodeSDK({\n resource: this.createResource(config),\n autoDetectResources: false,\n sampler: new AppKitSampler(),\n traceExporter: new OTLPTraceExporter({ headers: config.headers }),\n metricReaders: [\n new PeriodicExportingMetricReader({\n exporter: new OTLPMetricExporter({ headers: config.headers }),\n exportIntervalMillis:\n config.exportIntervalMs ||\n TelemetryManager.DEFAULT_EXPORT_INTERVAL_MS,\n }),\n ],\n logRecordProcessors: [\n new BatchLogRecordProcessor(\n new OTLPLogExporter({ headers: config.headers }),\n ),\n ],\n instrumentations: this.getDefaultInstrumentations(),\n });\n\n this.sdk.start();\n logger.debug(\"Initialized successfully\");\n } catch (error) {\n logger.error(\"Failed to initialize: %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 // global providers set by NodeSDK.start()\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 OpenTelemetry SDK.\n *\n * Idempotent: the SDK reference is 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 async shutdown(): Promise<void> {\n if (this.sdk) {\n const sdk = this.sdk;\n this.sdk = undefined;\n this.shutdownPromise = (async () => {\n try {\n await sdk.shutdown();\n } catch (error) {\n logger.error(\"Error shutting down: %O\", error);\n }\n })();\n }\n\n return this.shutdownPromise;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AA6BA,MAAM,SAAS,aAAa,YAAY;AAExC,IAAa,mBAAb,MAAa,iBAAiB;CAC5B,OAAwB,6BAA6B;CACrD,OAAwB,4BAA4B;CAEpD,OAAe;CACf,AAAQ;CACR,AAAQ;;;;;;;;CASR,OAAO,YACL,YACA,iBACmB;AAEnB,SAAO,IAAI,kBAAkB,YADP,iBAAiB,aAAa,EACI,gBAAgB;;CAG1E,AAAQ,cAAc;CAEtB,OAAO,cAAgC;AACrC,MAAI,CAAC,iBAAiB,SACpB,kBAAiB,WAAW,IAAI,kBAAkB;AAEpD,SAAO,iBAAiB;;CAG1B,OAAO,WAAW,SAAmC,EAAE,EAAQ;AAE7D,EADiB,iBAAiB,aAAa,CACtC,YAAY,OAAO;;CAG9B,AAAQ,YAAY,QAAwC;AAC1D,MAAI,KAAK,IAAK;AAEd,MAAI,CAAC,QAAQ,IAAI,4BACf;AAGF,MAAI;AACF,QAAK,MAAM,IAAI,QAAQ;IACrB,UAAU,KAAK,eAAe,OAAO;IACrC,qBAAqB;IACrB,SAAS,IAAI,eAAe;IAC5B,eAAe,IAAI,kBAAkB,EAAE,SAAS,OAAO,SAAS,CAAC;IACjE,eAAe,CACb,IAAI,8BAA8B;KAChC,UAAU,IAAI,mBAAmB,EAAE,SAAS,OAAO,SAAS,CAAC;KAC7D,sBACE,OAAO,oBACP,iBAAiB;KACpB,CAAC,CACH;IACD,qBAAqB,CACnB,IAAI,wBACF,IAAI,gBAAgB,EAAE,SAAS,OAAO,SAAS,CAAC,CACjD,CACF;IACD,kBAAkB,KAAK,4BAA4B;IACpD,CAAC;AAEF,QAAK,IAAI,OAAO;AAChB,UAAO,MAAM,2BAA2B;WACjC,OAAO;AACd,UAAO,MAAM,4BAA4B,MAAM;;;;;;;;CASnD,yBAAyB,kBAA2C;AAClE,2BAA6B,EAE3B,kBACD,CAAC;;CAGJ,AAAQ,eAAe,QAA4C;EACjE,MAAM,cACJ,OAAO,eACP,QAAQ,IAAI,qBACZ,QAAQ,IAAI,uBACZ,iBAAiB;EACnB,MAAM,kBAAkB,uBAAuB;IAC5C,oBAAoB;IACpB,uBAAuB,OAAO,kBAAkB;GAClD,CAAC;EACF,MAAM,mBAAmB,gBAAgB,EACvC,WAAW;GAAC;GAAa;GAAc;GAAgB,EACxD,CAAC;AACF,SAAO,gBAAgB,MAAM,iBAAiB;;CAGhD,AAAQ,6BAAgD;AACtD,SAAO,CACL,GAAG,4BAA4B;GAI7B,uCAAuC,EACrC,SAAS,OACV;GACD,0CAA0C,EACxC,SAAS,OACV;GAID,qCAAqC,EACnC,SAAS,OACV;GACD,sCAAsC,EACpC,SAAS,OACV;GACD,sCAAsC,EACpC,SAAS,OACV;GACF,CAAC,CACH;;;;;;;;;;CAWH,MAAM,WAA0B;AAC9B,MAAI,KAAK,KAAK;GACZ,MAAM,MAAM,KAAK;AACjB,QAAK,MAAM;AACX,QAAK,mBAAmB,YAAY;AAClC,QAAI;AACF,WAAM,IAAI,UAAU;aACb,OAAO;AACd,YAAO,MAAM,2BAA2B,MAAM;;OAE9C;;AAGN,SAAO,KAAK"}
@@ -10,6 +10,7 @@ import { WorkspaceClient } from "@databricks/sdk-experimental";
10
10
  import pc from "picocolors";
11
11
  import fs from "node:fs/promises";
12
12
  import path from "node:path";
13
+ import { tableFromIPC } from "apache-arrow";
13
14
 
14
15
  //#region src/type-generator/query-registry.ts
15
16
  const logger = createLogger("type-generator:query-registry");
@@ -95,12 +96,41 @@ function formatParametersType(sql) {
95
96
  return `/** ${sqlType || "any"} - use ${helper} */\n ${p}: ${markerType}`;
96
97
  }).join(";\n ")};\n }` : "Record<string, never>";
97
98
  }
99
+ /**
100
+ * Decode a base64 Arrow IPC attachment from a DESCRIBE QUERY response and
101
+ * extract column metadata. Returns the same shape as rows parsed from the
102
+ * legacy data_array path.
103
+ *
104
+ * IMPORTANT: a DESCRIBE QUERY response is itself a result *table* with rows
105
+ * shaped like `(col_name, data_type, comment)` describing the user query's
106
+ * output schema. We must read those rows — NOT `table.schema.fields`, which
107
+ * would describe DESCRIBE QUERY's own output (`col_name`, `data_type`,
108
+ * `comment`) and yield bogus types for every query.
109
+ */
110
+ function columnsFromArrowAttachment(attachment) {
111
+ return tableFromIPC(Buffer.from(attachment, "base64")).toArray().map((row) => {
112
+ const obj = row.toJSON();
113
+ return {
114
+ name: typeof obj.col_name === "string" ? obj.col_name : "",
115
+ type_name: typeof obj.data_type === "string" ? obj.data_type.toUpperCase() : "STRING",
116
+ comment: typeof obj.comment === "string" && obj.comment !== "" ? obj.comment : void 0
117
+ };
118
+ });
119
+ }
98
120
  function convertToQueryType(result, sql, queryName) {
99
- const columns = (result.result?.data_array || []).map((row) => ({
121
+ let columns = (result.result?.data_array || []).map((row) => ({
100
122
  name: row[0] || "",
101
123
  type_name: row[1]?.toUpperCase() || "STRING",
102
124
  comment: row[2] || void 0
103
125
  }));
126
+ if (columns.length === 0 && result.result?.attachment) {
127
+ logger.debug("data_array empty, decoding Arrow IPC attachment for schema");
128
+ try {
129
+ columns = columnsFromArrowAttachment(result.result.attachment);
130
+ } catch (err) {
131
+ logger.warn("Failed to decode Arrow IPC attachment: %s", err instanceof Error ? err.message : String(err));
132
+ }
133
+ }
104
134
  const paramsType = formatParametersType(sql);
105
135
  const resultFields = columns.map((column) => {
106
136
  const mappedType = typeMap[normalizeTypeName(column.type_name)] || "unknown";
@@ -444,7 +474,7 @@ async function generateQueriesFromDescribe(queryFolder, warehouseId, options = {
444
474
  const result = await describeAdaptive(client, `DESCRIBE QUERY ${cleanedSql}`, warehouseId, describeFormat);
445
475
  completed++;
446
476
  spinner.update(`Describing ${total} ${total === 1 ? "query" : "queries"} (${completed}/${total})`);
447
- logger.debug("DESCRIBE result for %s: state=%s, rows=%d", queryName, result.status.state, result.result?.data_array?.length ?? 0);
477
+ logger.debug("DESCRIBE result for %s: state=%s, rows=%d, hasAttachment=%s", queryName, result.status.state, result.result?.data_array?.length ?? 0, !!result.result?.attachment);
448
478
  if (result.status.state === "FAILED") {
449
479
  const sqlError = result.status.error?.message || "Query execution failed";
450
480
  return {