@databricks/appkit 0.75.1 → 0.76.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 (54) hide show
  1. package/CLAUDE.md +1 -1
  2. package/dist/appkit/package.js +1 -1
  3. package/dist/cache/index.d.ts +10 -0
  4. package/dist/cache/index.d.ts.map +1 -1
  5. package/dist/cache/index.js +13 -0
  6. package/dist/cache/index.js.map +1 -1
  7. package/dist/connectors/sql-warehouse/client.js +13 -1
  8. package/dist/connectors/sql-warehouse/client.js.map +1 -1
  9. package/dist/core/appkit.d.ts +1 -1
  10. package/dist/core/appkit.d.ts.map +1 -1
  11. package/dist/core/appkit.js +26 -5
  12. package/dist/core/appkit.js.map +1 -1
  13. package/dist/core/lifecycle-manager.js +47 -27
  14. package/dist/core/lifecycle-manager.js.map +1 -1
  15. package/dist/shared/src/schemas/manifest.d.ts +33 -33
  16. package/dist/telemetry/telemetry-manager.js +17 -0
  17. package/dist/telemetry/telemetry-manager.js.map +1 -1
  18. package/dist/testing/create-test-app.d.ts +111 -0
  19. package/dist/testing/create-test-app.d.ts.map +1 -0
  20. package/dist/testing/create-test-app.js +187 -0
  21. package/dist/testing/create-test-app.js.map +1 -0
  22. package/dist/testing/create-test-plugin.d.ts +17 -0
  23. package/dist/testing/create-test-plugin.d.ts.map +1 -0
  24. package/dist/testing/create-test-plugin.js +22 -0
  25. package/dist/testing/create-test-plugin.js.map +1 -0
  26. package/dist/testing/fixtures.d.ts +51 -35
  27. package/dist/testing/fixtures.d.ts.map +1 -1
  28. package/dist/testing/fixtures.js +129 -54
  29. package/dist/testing/fixtures.js.map +1 -1
  30. package/dist/testing/index.d.ts +8 -3
  31. package/dist/testing/index.js +7 -2
  32. package/dist/testing/mock-workspace-client.d.ts +47 -0
  33. package/dist/testing/mock-workspace-client.d.ts.map +1 -0
  34. package/dist/testing/mock-workspace-client.js +192 -0
  35. package/dist/testing/mock-workspace-client.js.map +1 -0
  36. package/dist/testing/reset-singletons.js +39 -0
  37. package/dist/testing/reset-singletons.js.map +1 -0
  38. package/dist/testing/test-app.d.ts +54 -0
  39. package/dist/testing/test-app.d.ts.map +1 -0
  40. package/dist/testing/test-app.js +55 -0
  41. package/dist/testing/test-app.js.map +1 -0
  42. package/dist/testing/test-cache.d.ts +60 -0
  43. package/dist/testing/test-cache.d.ts.map +1 -0
  44. package/dist/testing/test-cache.js +65 -0
  45. package/dist/testing/test-cache.js.map +1 -0
  46. package/dist/testing/test-plugin-context.d.ts +43 -2
  47. package/dist/testing/test-plugin-context.d.ts.map +1 -1
  48. package/dist/testing/test-plugin-context.js +47 -2
  49. package/dist/testing/test-plugin-context.js.map +1 -1
  50. package/docs/api/appkit/Function.createApp.md +9 -9
  51. package/docs/plugins/testing.md +440 -14
  52. package/llms.txt +1 -1
  53. package/package.json +1 -1
  54. package/sbom.cdx.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"appkit.js","names":["#context","#pluginInstances","#setupPromises","productVersion"],"sources":["../../src/core/appkit.ts"],"sourcesContent":["import type {\n BasePlugin,\n CacheConfig,\n InputPluginMap,\n OptionalConfigPluginDef,\n PluginConstructor,\n PluginData,\n PluginMap,\n} from \"shared\";\n\nimport { version as productVersion } from \"../../package.json\";\nimport { CacheManager } from \"../cache\";\nimport { ServiceContext } from \"../context\";\nimport {\n isInternalTelemetryEnabled,\n TelemetryReporter,\n} from \"../internal-telemetry\";\nimport { createLogger } from \"../logging/logger\";\nimport { isPlainObject } from \"../plugin/plugin\";\nimport { uiVariants } from \"../plugins/ui-variants\";\nimport { ResourceRegistry, ResourceType } from \"../registry\";\nimport type { TelemetryConfig } from \"../telemetry\";\nimport { TelemetryManager } from \"../telemetry\";\nimport type { WorkspaceClient } from \"../workspace-client\";\nimport { LifecycleManager } from \"./lifecycle-manager\";\nimport { isToolProvider, PluginContext } from \"./plugin-context\";\n\nconst logger = createLogger(\"appkit\");\n\nexport class AppKit<TPlugins extends InputPluginMap> {\n #pluginInstances: Record<string, BasePlugin> = {};\n #setupPromises: Promise<void>[] = [];\n #context: PluginContext;\n\n private constructor(config: { plugins: TPlugins }) {\n const { plugins, ...globalConfig } = config;\n\n this.#context = new PluginContext();\n\n const pluginEntries = Object.entries(plugins);\n\n const corePlugins = pluginEntries.filter(([_, p]) => {\n return (p?.plugin?.phase ?? \"normal\") === \"core\";\n });\n const normalPlugins = pluginEntries.filter(\n ([_, p]) => (p?.plugin?.phase ?? \"normal\") === \"normal\",\n );\n const deferredPlugins = pluginEntries.filter(\n ([_, p]) => (p?.plugin?.phase ?? \"normal\") === \"deferred\",\n );\n\n for (const [name, pluginData] of corePlugins) {\n if (pluginData) {\n this.createAndRegisterPlugin(globalConfig, name, pluginData, {\n context: this.#context,\n });\n }\n }\n\n for (const [name, pluginData] of normalPlugins) {\n if (pluginData) {\n this.createAndRegisterPlugin(globalConfig, name, pluginData, {\n context: this.#context,\n });\n }\n }\n\n for (const [name, pluginData] of deferredPlugins) {\n if (pluginData) {\n this.createAndRegisterPlugin(globalConfig, name, pluginData, {\n context: this.#context,\n });\n }\n }\n }\n\n private createAndRegisterPlugin<T extends PluginConstructor>(\n config: Omit<{ plugins: TPlugins }, \"plugins\">,\n name: string,\n pluginData: OptionalConfigPluginDef<T>,\n extraData?: Record<string, unknown>,\n ) {\n const { plugin: Plugin, config: pluginConfig } = pluginData;\n const baseConfig = {\n ...config,\n ...Plugin.DEFAULT_CONFIG,\n ...pluginConfig,\n name,\n ...extraData,\n };\n const pluginInstance = new Plugin(baseConfig);\n\n if (typeof pluginInstance.attachContext === \"function\") {\n pluginInstance.attachContext({\n context: this.#context,\n telemetryConfig: baseConfig.telemetry,\n });\n }\n\n this.#pluginInstances[name] = pluginInstance;\n\n this.#context.registerPlugin(name, pluginInstance);\n if (isToolProvider(pluginInstance)) {\n this.#context.registerToolProvider(name, pluginInstance);\n }\n\n this.#setupPromises.push(pluginInstance.setup());\n\n const self = this;\n\n // The manifest `name` is camelCase, so it doubles as the public handle key\n // (`appkit.aiSearch`). The kebab HTTP route is derived separately.\n Object.defineProperty(this, name, {\n get() {\n const plugin = self.#pluginInstances[name];\n return self.wrapWithAsUser(plugin);\n },\n enumerable: true,\n });\n }\n\n /**\n * Binds all function properties in an exports object to the given context.\n * Recurses into plain objects to handle nested APIs (e.g., volume APIs).\n */\n private bindExportMethods(\n exports: Record<string, unknown>,\n context: BasePlugin,\n ) {\n for (const key in exports) {\n if (!Object.hasOwn(exports, key)) continue;\n const val = exports[key];\n if (typeof val === \"function\") {\n exports[key] = (val as (...args: unknown[]) => unknown).bind(context);\n } else if (isPlainObject(val)) {\n this.bindExportMethods(val as Record<string, unknown>, context);\n }\n }\n }\n\n /**\n * Wraps a plugin's exports with an `asUser` method that returns\n * a user-scoped version of the exports.\n *\n * When `exports()` returns a callable (function), it is returned as-is\n * since the plugin manages its own `asUser` per-call (e.g. files plugin).\n * When it returns a plain object, the standard `asUser` wrapper is added.\n *\n * The OBO-side wrapping lives inside `Plugin.asUser` — calling\n * `plugin.asUser(req).exports()` returns exports whose functions already\n * run inside the user's AsyncLocalStorage scope. AppKit only adapts the\n * shape; it does not own the user-context concept.\n */\n private wrapWithAsUser<T extends BasePlugin>(plugin: T) {\n // If plugin doesn't implement exports(), return empty object\n const pluginExports = plugin.exports?.() ?? {};\n\n // If exports is a function, the plugin manages its own asUser pattern\n if (typeof pluginExports === \"function\") {\n return pluginExports;\n }\n\n const objExports = pluginExports as Record<string, unknown>;\n this.bindExportMethods(objExports, plugin);\n\n // If plugin doesn't support asUser (no asUser method), return exports as-is\n if (typeof (plugin as any).asUser !== \"function\") {\n return objExports;\n }\n\n return {\n ...objExports,\n /**\n * Execute operations using the user's identity from the request.\n * Returns user-scoped exports where all methods execute with the\n * user's Databricks credentials instead of the service principal.\n */\n asUser: (req: import(\"express\").Request) =>\n (plugin as any).asUser(req).exports() as Record<string, unknown>,\n };\n }\n\n static async _createApp<\n T extends PluginData<PluginConstructor, unknown, string>[],\n >(\n config: {\n plugins?: T;\n telemetry?: TelemetryConfig;\n cache?: CacheConfig;\n client?: WorkspaceClient;\n onPluginsReady?: (appkit: PluginMap<T>) => void | Promise<void>;\n disableInternalTelemetry?: boolean;\n } = {},\n ): Promise<PluginMap<T>> {\n // Initialize core services\n TelemetryManager.initialize(config?.telemetry);\n await CacheManager.getInstance(config?.cache);\n\n const withDefaults = AppKit.withDefaultPlugins(config.plugins as T);\n const rawPlugins = AppKit.filterDevOnlyPlugins(withDefaults);\n\n // Collect manifest resources via registry\n const registry = new ResourceRegistry();\n registry.collectResources(rawPlugins);\n\n // Derive ServiceContext needs from what manifests declared\n const needsWarehouse = registry\n .getRequired()\n .some((r) => r.type === ResourceType.SQL_WAREHOUSE);\n await ServiceContext.initialize(\n { warehouseId: needsWarehouse },\n config?.client,\n );\n\n // Validate env vars\n registry.enforceValidation();\n\n const preparedPlugins = AppKit.preparePlugins(rawPlugins);\n const mergedConfig = {\n plugins: preparedPlugins,\n };\n\n const instance = new AppKit(mergedConfig);\n\n await Promise.all(instance.#setupPromises);\n\n // Build the global tracer provider now that every plugin's setup() has run\n // and contributed any span processors. Deferred to here so a single provider\n // carries all processors (OTLP + plugin-contributed); see TelemetryManager.\n TelemetryManager.start();\n\n await instance.#context.emitLifecycle(\"setup:complete\");\n\n const handle = instance as unknown as PluginMap<T>;\n\n if (config.onPluginsReady) {\n logger.debug(\"Running onPluginsReady hook\");\n await config.onPluginsReady(handle);\n logger.debug(\"onPluginsReady hook completed\");\n }\n\n if (isInternalTelemetryEnabled(config)) {\n AppKit.bootstrapInternalTelemetry();\n }\n\n const serverPlugin = instance.#pluginInstances.server;\n if (serverPlugin && typeof (serverPlugin as any).start === \"function\") {\n await (serverPlugin as any).start();\n }\n\n // Core owns graceful shutdown: install the signal handlers once every\n // plugin has started. Applies uniformly whether or not a server plugin\n // is present — server-less apps still get their telemetry flushed and\n // plugin shutdown() hooks run.\n new LifecycleManager(instance.#context).installSignalHandlers();\n\n return handle;\n }\n\n private static bootstrapInternalTelemetry(): void {\n const serviceCtx = ServiceContext.get();\n const reporter = TelemetryReporter.initialize({\n workspaceId: serviceCtx.workspaceId,\n client: serviceCtx.client,\n appId: process.env.DATABRICKS_CLIENT_ID || \"\",\n appkitVersion: productVersion,\n });\n reporter.start();\n reporter.sendStartup().catch(() => {});\n }\n\n /**\n * Injects framework-owned default plugins the app author shouldn't have to\n * register by hand. Runs before {@link filterDevOnlyPlugins}, so a default\n * that is itself `devOnly` (like `ui-variants`) is present in dev and stripped\n * in prod through the exact same guard as any user plugin — never alive in a\n * deployed app.\n *\n * Currently injects the dev-only `ui-variants` recorder that backs the\n * `<Variants>` UI picker, so neither the developer nor their coding agent has\n * to remember to add it. If the app already registered it explicitly, that\n * entry wins and no duplicate is added.\n */\n private static withDefaultPlugins<\n T extends PluginData<PluginConstructor, unknown, string>[],\n >(plugins: T): T {\n const list = (plugins ?? []) as PluginData<\n PluginConstructor,\n unknown,\n string\n >[];\n\n const defaults: PluginData<PluginConstructor, unknown, string>[] = [\n uiVariants(),\n ];\n\n const missing = defaults.filter(\n (def) => !list.some((p) => p?.name === def.name),\n );\n\n return [...list, ...missing] as T;\n }\n\n /**\n * Drops plugins whose manifest declares `devOnly: true` unless\n * `NODE_ENV === \"development\"`. Runs before resource collection so a skipped\n * plugin is never constructed, never injects routes, and has its resource\n * requirements ignored — the framework, not the app author, enforces that\n * dev-only tooling can never run in a deployed app.\n *\n * This is the primary guard; plugins that expose a mutating dev endpoint\n * should still keep an in-handler `NODE_ENV` check as fail-safe defense\n * against being mounted through a path that bypasses this filter.\n */\n private static filterDevOnlyPlugins<\n T extends PluginData<PluginConstructor, unknown, string>[],\n >(plugins: T): T {\n if (!plugins || process.env.NODE_ENV === \"development\") return plugins;\n\n return plugins.filter((pluginData) => {\n const isDevOnly = pluginData?.plugin?.manifest?.devOnly === true;\n if (isDevOnly) {\n logger.debug(\n \"Skipping dev-only plugin %s (NODE_ENV=%s)\",\n pluginData?.name ?? \"unknown\",\n process.env.NODE_ENV ?? \"<unset>\",\n );\n }\n return !isDevOnly;\n }) as T;\n }\n\n private static preparePlugins(\n plugins: PluginData<PluginConstructor, unknown, string>[],\n ) {\n const result: InputPluginMap = {};\n for (const currentPlugin of plugins) {\n result[currentPlugin.name] = {\n plugin: currentPlugin.plugin,\n config: currentPlugin.config as Record<string, unknown>,\n };\n }\n return result;\n }\n}\n\n/**\n * Bootstraps AppKit with the provided configuration.\n *\n * Initializes telemetry, cache, and service context, then registers plugins\n * in phase order (core, normal, deferred) and awaits their setup.\n * If a `onPluginsReady` callback is provided it runs after plugin setup but\n * before the server starts, giving you access to the full appkit handle\n * for registering custom routes or performing async setup.\n * The returned object maps each plugin name to its `exports()` API,\n * with an `asUser(req)` method for user-scoped execution.\n *\n * @returns A `PluginMap` keyed by plugin name with typed exports\n *\n * @example Minimal server\n * ```ts\n * import { createApp, server } from \"@databricks/appkit\";\n *\n * await createApp({\n * plugins: [server()],\n * });\n * ```\n *\n * @example Server with custom routes via onPluginsReady\n * ```ts\n * import { createApp, server, analytics } from \"@databricks/appkit\";\n *\n * await createApp({\n * plugins: [server(), analytics({})],\n * onPluginsReady(appkit) {\n * appkit.server.extend((app) => {\n * app.get(\"/custom\", (_req, res) => res.json({ ok: true }));\n * });\n * },\n * });\n * ```\n */\nexport async function createApp<\n T extends PluginData<PluginConstructor, unknown, string>[],\n>(\n config: {\n plugins?: T;\n telemetry?: TelemetryConfig;\n cache?: CacheConfig;\n client?: WorkspaceClient;\n onPluginsReady?: (appkit: PluginMap<T>) => void | Promise<void>;\n disableInternalTelemetry?: boolean;\n } = {},\n): Promise<PluginMap<T>> {\n return AppKit._createApp(config);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"appkit.js","names":["#context","#pluginInstances","#setupPromises","#lifecycle","productVersion"],"sources":["../../src/core/appkit.ts"],"sourcesContent":["import type {\n BasePlugin,\n CacheConfig,\n InputPluginMap,\n OptionalConfigPluginDef,\n PluginConstructor,\n PluginData,\n PluginMap,\n} from \"shared\";\n\nimport { version as productVersion } from \"../../package.json\";\nimport { CacheManager } from \"../cache\";\nimport { ServiceContext } from \"../context\";\nimport {\n isInternalTelemetryEnabled,\n TelemetryReporter,\n} from \"../internal-telemetry\";\nimport { createLogger } from \"../logging/logger\";\nimport { isPlainObject } from \"../plugin/plugin\";\nimport { uiVariants } from \"../plugins/ui-variants\";\nimport { ResourceRegistry, ResourceType } from \"../registry\";\nimport type { TelemetryConfig } from \"../telemetry\";\nimport { TelemetryManager } from \"../telemetry\";\nimport type { WorkspaceClient } from \"../workspace-client\";\nimport { LifecycleManager } from \"./lifecycle-manager\";\nimport { isToolProvider, PluginContext } from \"./plugin-context\";\n\nconst logger = createLogger(\"appkit\");\n\n/**\n * Internal teardown entry for the test harness (see `createTestApp`).\n * Symbol-keyed so it cannot collide with — or be shadowed by — a plugin\n * manifest name, and so it stays off the public `PluginMap` surface.\n * @internal\n */\nexport const disposeApp = Symbol(\"appkit.internal.dispose\");\n\nexport class AppKit<TPlugins extends InputPluginMap> {\n #pluginInstances: Record<string, BasePlugin> = {};\n #setupPromises: Promise<void>[] = [];\n #context: PluginContext;\n /** Owns the shutdown sequence; assigned once every plugin has started. */\n #lifecycle: LifecycleManager | undefined;\n\n private constructor(config: { plugins: TPlugins }) {\n const { plugins, ...globalConfig } = config;\n\n this.#context = new PluginContext();\n\n const pluginEntries = Object.entries(plugins);\n\n const corePlugins = pluginEntries.filter(([_, p]) => {\n return (p?.plugin?.phase ?? \"normal\") === \"core\";\n });\n const normalPlugins = pluginEntries.filter(\n ([_, p]) => (p?.plugin?.phase ?? \"normal\") === \"normal\",\n );\n const deferredPlugins = pluginEntries.filter(\n ([_, p]) => (p?.plugin?.phase ?? \"normal\") === \"deferred\",\n );\n\n for (const [name, pluginData] of corePlugins) {\n if (pluginData) {\n this.createAndRegisterPlugin(globalConfig, name, pluginData, {\n context: this.#context,\n });\n }\n }\n\n for (const [name, pluginData] of normalPlugins) {\n if (pluginData) {\n this.createAndRegisterPlugin(globalConfig, name, pluginData, {\n context: this.#context,\n });\n }\n }\n\n for (const [name, pluginData] of deferredPlugins) {\n if (pluginData) {\n this.createAndRegisterPlugin(globalConfig, name, pluginData, {\n context: this.#context,\n });\n }\n }\n }\n\n private createAndRegisterPlugin<T extends PluginConstructor>(\n config: Omit<{ plugins: TPlugins }, \"plugins\">,\n name: string,\n pluginData: OptionalConfigPluginDef<T>,\n extraData?: Record<string, unknown>,\n ) {\n const { plugin: Plugin, config: pluginConfig } = pluginData;\n const baseConfig = {\n ...config,\n ...Plugin.DEFAULT_CONFIG,\n ...pluginConfig,\n name,\n ...extraData,\n };\n const pluginInstance = new Plugin(baseConfig);\n\n if (typeof pluginInstance.attachContext === \"function\") {\n pluginInstance.attachContext({\n context: this.#context,\n telemetryConfig: baseConfig.telemetry,\n });\n }\n\n this.#pluginInstances[name] = pluginInstance;\n\n this.#context.registerPlugin(name, pluginInstance);\n if (isToolProvider(pluginInstance)) {\n this.#context.registerToolProvider(name, pluginInstance);\n }\n\n this.#setupPromises.push(pluginInstance.setup());\n\n const self = this;\n\n // The manifest `name` is camelCase, so it doubles as the public handle key\n // (`appkit.aiSearch`). The kebab HTTP route is derived separately.\n Object.defineProperty(this, name, {\n get() {\n const plugin = self.#pluginInstances[name];\n return self.wrapWithAsUser(plugin);\n },\n enumerable: true,\n });\n }\n\n /**\n * Binds all function properties in an exports object to the given context.\n * Recurses into plain objects to handle nested APIs (e.g., volume APIs).\n */\n private bindExportMethods(\n exports: Record<string, unknown>,\n context: BasePlugin,\n ) {\n for (const key in exports) {\n if (!Object.hasOwn(exports, key)) continue;\n const val = exports[key];\n if (typeof val === \"function\") {\n exports[key] = (val as (...args: unknown[]) => unknown).bind(context);\n } else if (isPlainObject(val)) {\n this.bindExportMethods(val as Record<string, unknown>, context);\n }\n }\n }\n\n /**\n * Wraps a plugin's exports with an `asUser` method that returns\n * a user-scoped version of the exports.\n *\n * When `exports()` returns a callable (function), it is returned as-is\n * since the plugin manages its own `asUser` per-call (e.g. files plugin).\n * When it returns a plain object, the standard `asUser` wrapper is added.\n *\n * The OBO-side wrapping lives inside `Plugin.asUser` — calling\n * `plugin.asUser(req).exports()` returns exports whose functions already\n * run inside the user's AsyncLocalStorage scope. AppKit only adapts the\n * shape; it does not own the user-context concept.\n */\n private wrapWithAsUser<T extends BasePlugin>(plugin: T) {\n // If plugin doesn't implement exports(), return empty object\n const pluginExports = plugin.exports?.() ?? {};\n\n // If exports is a function, the plugin manages its own asUser pattern\n if (typeof pluginExports === \"function\") {\n return pluginExports;\n }\n\n const objExports = pluginExports as Record<string, unknown>;\n this.bindExportMethods(objExports, plugin);\n\n // If plugin doesn't support asUser (no asUser method), return exports as-is\n if (typeof (plugin as any).asUser !== \"function\") {\n return objExports;\n }\n\n return {\n ...objExports,\n /**\n * Execute operations using the user's identity from the request.\n * Returns user-scoped exports where all methods execute with the\n * user's Databricks credentials instead of the service principal.\n */\n asUser: (req: import(\"express\").Request) =>\n (plugin as any).asUser(req).exports() as Record<string, unknown>,\n };\n }\n\n static async _createApp<\n T extends PluginData<PluginConstructor, unknown, string>[],\n >(\n config: {\n plugins?: T;\n telemetry?: TelemetryConfig;\n cache?: CacheConfig;\n client?: WorkspaceClient;\n onPluginsReady?: (appkit: PluginMap<T>) => void | Promise<void>;\n disableInternalTelemetry?: boolean;\n /**\n * Skip installing the SIGTERM/SIGINT handlers. Internal, and not exposed\n * on {@link createApp}: only the test harness sets it, because it boots\n * repeatedly in one process and manages its own teardown, so\n * accumulating signal handlers would be a leak.\n */\n installSignalHandlers?: boolean;\n } = {},\n ): Promise<PluginMap<T>> {\n // Initialize core services\n TelemetryManager.initialize(config?.telemetry);\n await CacheManager.getInstance(config?.cache);\n\n const withDefaults = AppKit.withDefaultPlugins(config.plugins as T);\n const rawPlugins = AppKit.filterDevOnlyPlugins(withDefaults);\n\n // Collect manifest resources via registry\n const registry = new ResourceRegistry();\n registry.collectResources(rawPlugins);\n\n // Derive ServiceContext needs from what manifests declared\n const needsWarehouse = registry\n .getRequired()\n .some((r) => r.type === ResourceType.SQL_WAREHOUSE);\n await ServiceContext.initialize(\n { warehouseId: needsWarehouse },\n config?.client,\n );\n\n // Validate env vars\n registry.enforceValidation();\n\n const preparedPlugins = AppKit.preparePlugins(rawPlugins);\n const mergedConfig = {\n plugins: preparedPlugins,\n };\n\n const instance = new AppKit(mergedConfig);\n\n await Promise.all(instance.#setupPromises);\n\n // Build the global tracer provider now that every plugin's setup() has run\n // and contributed any span processors. Deferred to here so a single provider\n // carries all processors (OTLP + plugin-contributed); see TelemetryManager.\n TelemetryManager.start();\n\n await instance.#context.emitLifecycle(\"setup:complete\");\n\n const app = instance as unknown as PluginMap<T>;\n\n if (config.onPluginsReady) {\n logger.debug(\"Running onPluginsReady hook\");\n await config.onPluginsReady(app);\n logger.debug(\"onPluginsReady hook completed\");\n }\n\n if (isInternalTelemetryEnabled(config)) {\n AppKit.bootstrapInternalTelemetry();\n }\n\n const serverPlugin = instance.#pluginInstances.server;\n if (serverPlugin && typeof (serverPlugin as any).start === \"function\") {\n await (serverPlugin as any).start();\n }\n\n // Core owns graceful shutdown: install the signal handlers once every\n // plugin has started. Applies uniformly whether or not a server plugin\n // is present — server-less apps still get their telemetry flushed and\n // plugin shutdown() hooks run.\n instance.#lifecycle = new LifecycleManager(instance.#context);\n if (config.installSignalHandlers !== false) {\n instance.#lifecycle.installSignalHandlers();\n }\n\n return app;\n }\n\n /**\n * Internal teardown entry point for the test harness: delegates to the\n * lifecycle's non-exiting `shutdown({ exit: false })` (the phases are\n * canonical there). Not public API — AppKit does not support re-booting or\n * embedding, so real apps tear down only through the signal path. The harness\n * drops the core singletons and restores env after this resolves.\n * @internal\n */\n async [disposeApp](): Promise<void> {\n await this.#lifecycle?.shutdown({ exit: false });\n }\n\n private static bootstrapInternalTelemetry(): void {\n const serviceCtx = ServiceContext.get();\n const reporter = TelemetryReporter.initialize({\n workspaceId: serviceCtx.workspaceId,\n client: serviceCtx.client,\n appId: process.env.DATABRICKS_CLIENT_ID || \"\",\n appkitVersion: productVersion,\n });\n reporter.start();\n reporter.sendStartup().catch(() => {});\n }\n\n /**\n * Injects framework-owned default plugins the app author shouldn't have to\n * register by hand. Runs before {@link filterDevOnlyPlugins}, so a default\n * that is itself `devOnly` (like `ui-variants`) is present in dev and stripped\n * in prod through the exact same guard as any user plugin — never alive in a\n * deployed app.\n *\n * Currently injects the dev-only `ui-variants` recorder that backs the\n * `<Variants>` UI picker, so neither the developer nor their coding agent has\n * to remember to add it. If the app already registered it explicitly, that\n * entry wins and no duplicate is added.\n */\n private static withDefaultPlugins<\n T extends PluginData<PluginConstructor, unknown, string>[],\n >(plugins: T): T {\n const list = (plugins ?? []) as PluginData<\n PluginConstructor,\n unknown,\n string\n >[];\n\n const defaults: PluginData<PluginConstructor, unknown, string>[] = [\n uiVariants(),\n ];\n\n const missing = defaults.filter(\n (def) => !list.some((p) => p?.name === def.name),\n );\n\n return [...list, ...missing] as T;\n }\n\n /**\n * Drops plugins whose manifest declares `devOnly: true` unless\n * `NODE_ENV === \"development\"`. Runs before resource collection so a skipped\n * plugin is never constructed, never injects routes, and has its resource\n * requirements ignored — the framework, not the app author, enforces that\n * dev-only tooling can never run in a deployed app.\n *\n * This is the primary guard; plugins that expose a mutating dev endpoint\n * should still keep an in-handler `NODE_ENV` check as fail-safe defense\n * against being mounted through a path that bypasses this filter.\n */\n private static filterDevOnlyPlugins<\n T extends PluginData<PluginConstructor, unknown, string>[],\n >(plugins: T): T {\n if (!plugins || process.env.NODE_ENV === \"development\") return plugins;\n\n return plugins.filter((pluginData) => {\n const isDevOnly = pluginData?.plugin?.manifest?.devOnly === true;\n if (isDevOnly) {\n logger.debug(\n \"Skipping dev-only plugin %s (NODE_ENV=%s)\",\n pluginData?.name ?? \"unknown\",\n process.env.NODE_ENV ?? \"<unset>\",\n );\n }\n return !isDevOnly;\n }) as T;\n }\n\n private static preparePlugins(\n plugins: PluginData<PluginConstructor, unknown, string>[],\n ) {\n const result: InputPluginMap = {};\n for (const currentPlugin of plugins) {\n result[currentPlugin.name] = {\n plugin: currentPlugin.plugin,\n config: currentPlugin.config as Record<string, unknown>,\n };\n }\n return result;\n }\n}\n\n/**\n * Bootstraps AppKit with the provided configuration.\n *\n * Initializes telemetry, cache, and service context, then registers plugins\n * in phase order (core, normal, deferred) and awaits their setup.\n * If a `onPluginsReady` callback is provided it runs after plugin setup but\n * before the server starts, giving you access to the full appkit handle\n * for registering custom routes or performing async setup.\n * The returned object maps each plugin name to its `exports()` API,\n * with an `asUser(req)` method for user-scoped execution.\n *\n * @returns A `PluginMap` keyed by plugin name with typed exports\n *\n * @example Minimal server\n * ```ts\n * import { createApp, server } from \"@databricks/appkit\";\n *\n * await createApp({\n * plugins: [server()],\n * });\n * ```\n *\n * @example Server with custom routes via onPluginsReady\n * ```ts\n * import { createApp, server, analytics } from \"@databricks/appkit\";\n *\n * await createApp({\n * plugins: [server(), analytics({})],\n * onPluginsReady(appkit) {\n * appkit.server.extend((app) => {\n * app.get(\"/custom\", (_req, res) => res.json({ ok: true }));\n * });\n * },\n * });\n * ```\n */\nexport async function createApp<\n T extends PluginData<PluginConstructor, unknown, string>[],\n>(\n config: {\n plugins?: T;\n telemetry?: TelemetryConfig;\n cache?: CacheConfig;\n client?: WorkspaceClient;\n /** Runs after plugin setup but **before** the server starts. */\n onPluginsReady?: (appkit: PluginMap<T>) => void | Promise<void>;\n disableInternalTelemetry?: boolean;\n } = {},\n): Promise<PluginMap<T>> {\n return AppKit._createApp(config);\n}\n"],"mappings":"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"}
@@ -44,33 +44,30 @@ var LifecycleManager = class LifecycleManager {
44
44
  */
45
45
  static PHASE_SHUTDOWN_TIMEOUT_MS = 2e3;
46
46
  /**
47
- * Guards against re-entrant shutdown (e.g. SIGTERM followed by SIGINT).
48
- * The flag set in `shutdown` must remain synchronous and first — any
49
- * `await` before it would open a window for a second signal to re-enter
50
- * the sequence.
51
- */
52
- isShuttingDown = false;
53
- /**
54
- * Name of the shutdown phase currently in flight, so the force-exit log
55
- * can say where shutdown got stuck without extra bookkeeping.
47
+ * The in-flight teardown, memoized. A boolean guard would let a second caller
48
+ * return while teardown was still running — fine for a signal, wrong for the
49
+ * harness path (`{ exit: false }`), which must not resolve before resources
50
+ * are released.
56
51
  */
52
+ teardown;
53
+ /** Reported by the force-exit log so a stuck shutdown names its phase. */
57
54
  shutdownPhase = "not started";
58
55
  constructor(context) {
59
56
  this.context = context;
60
57
  }
61
58
  /**
62
- * Install the SIGTERM/SIGINT handlers that trigger {@link shutdown}.
63
- *
64
- * Uses `process.once` (not `on`) so a repeated signal cannot register the
65
- * handler twice; re-entrancy from a *different* signal is guarded by
66
- * `isShuttingDown` inside {@link shutdown}.
59
+ * Install the SIGTERM/SIGINT handlers that trigger {@link shutdown}. Never
60
+ * removed: the signal path exits the process, and the harness opts out of
61
+ * installing them, so nothing accumulates across boots.
67
62
  */
68
63
  installSignalHandlers() {
69
- process.once("SIGTERM", () => this.shutdown());
70
- process.once("SIGINT", () => this.shutdown());
64
+ process.once("SIGTERM", () => void this.shutdown());
65
+ process.once("SIGINT", () => void this.shutdown());
71
66
  }
72
67
  /**
73
- * Run the graceful-shutdown sequence and exit the process.
68
+ * Run the graceful-shutdown sequence. Exits the process unless
69
+ * `exit: false` — the flag the test harness passes so it can tear a booted
70
+ * app down between tests without killing the vitest process.
74
71
  *
75
72
  * Phases:
76
73
  * 1. stop the internal-telemetry reporter
@@ -80,20 +77,37 @@ var LifecycleManager = class LifecycleManager {
80
77
  * 4. emit the `"shutdown"` lifecycle event, bounded
81
78
  * 5. close the cache storage and flush telemetry concurrently, each bounded
82
79
  *
80
+ * Every phase is individually bounded, so the sequence always completes —
81
+ * `{ exit: false }` therefore needs no outer timeout and an `afterEach`
82
+ * cannot hang on it. A second call joins the first teardown.
83
+ *
83
84
  * Exits 0 on completion (and on the force-exit backstop): a deliberate
84
85
  * shutdown is not a crash. Exit 1 is reserved for an unexpected error
85
86
  * thrown by the sequence itself.
86
87
  */
87
- async shutdown() {
88
- if (this.isShuttingDown) return;
89
- this.isShuttingDown = true;
90
- logger.info("Starting graceful shutdown...");
91
- let exitCode = 0;
88
+ async shutdown(options = {}) {
89
+ if (!(options.exit ?? true)) {
90
+ await this.runPhasesOnce();
91
+ return;
92
+ }
92
93
  const forceExitTimer = setTimeout(() => {
93
94
  logger.error("Graceful shutdown did NOT complete within the %dms budget (phase in flight: %s); force-exiting with code 0.", LifecycleManager.SHUTDOWN_TIMEOUT_MS, this.shutdownPhase);
94
95
  process.exit(0);
95
96
  }, LifecycleManager.SHUTDOWN_TIMEOUT_MS);
96
97
  forceExitTimer.unref();
98
+ const exitCode = await this.runPhasesOnce();
99
+ clearTimeout(forceExitTimer);
100
+ process.exit(exitCode);
101
+ }
102
+ /** No `await` between read and assign — that gap is the re-entrancy window. */
103
+ runPhasesOnce() {
104
+ this.teardown ??= this.runPhases();
105
+ return this.teardown;
106
+ }
107
+ /** Run the phases and report an exit code; no process-termination concerns. */
108
+ async runPhases() {
109
+ logger.info("Starting graceful shutdown...");
110
+ let exitCode = 0;
97
111
  try {
98
112
  const plugins = Array.from(this.context.getPlugins().values());
99
113
  this.shutdownPhase = "stopping internal telemetry reporter";
@@ -119,10 +133,9 @@ var LifecycleManager = class LifecycleManager {
119
133
  logger.error("Error during graceful shutdown: %O", err);
120
134
  exitCode = 1;
121
135
  }
122
- clearTimeout(forceExitTimer);
123
- process.exit(exitCode);
136
+ return exitCode;
124
137
  }
125
- /** Close the cache storage, bounded and error-isolated. */
138
+ /** Bounded and error-isolated. Reads the cache manager at phase-5 time. */
126
139
  async closeCacheStorage() {
127
140
  let cache;
128
141
  try {
@@ -136,10 +149,17 @@ var LifecycleManager = class LifecycleManager {
136
149
  logger.error("Error closing cache storage during shutdown: %O", err);
137
150
  }
138
151
  }
139
- /** Flush and shut down the telemetry SDK, bounded and error-isolated. */
152
+ /** Bounded and error-isolated. Reads the telemetry manager at phase-5 time. */
140
153
  async flushTelemetry() {
154
+ let telemetry;
155
+ try {
156
+ telemetry = TelemetryManager.getInstance();
157
+ } catch {
158
+ return;
159
+ }
160
+ if (!telemetry) return;
141
161
  try {
142
- await this.raceWithTimeout(TelemetryManager.getInstance().shutdown(), LifecycleManager.PHASE_SHUTDOWN_TIMEOUT_MS, "telemetry flush");
162
+ await this.raceWithTimeout(telemetry.shutdown(), LifecycleManager.PHASE_SHUTDOWN_TIMEOUT_MS, "telemetry flush");
143
163
  } catch (err) {
144
164
  logger.error("Error flushing telemetry during shutdown: %O", err);
145
165
  }
@@ -1 +1 @@
1
- {"version":3,"file":"lifecycle-manager.js","names":[],"sources":["../../src/core/lifecycle-manager.ts"],"sourcesContent":["import type { BasePlugin } from \"shared\";\n\nimport { CacheManager } from \"../cache\";\nimport { TelemetryReporter } from \"../internal-telemetry\";\nimport { createLogger } from \"../logging/logger\";\nimport { TelemetryManager } from \"../telemetry\";\nimport type { PluginContext } from \"./plugin-context\";\n\nconst logger = createLogger(\"lifecycle\");\n\n/**\n * Owns the process's graceful-shutdown sequence.\n *\n * Created by AppKit core once every plugin has started. It is the single\n * owner of the SIGTERM/SIGINT handlers and of `process.exit`, mirroring the\n * core-owned startup in `AppKit._createApp`: core initializes telemetry,\n * cache, and the internal-telemetry reporter, and core tears them all down\n * here. Plugins participate through the generic hooks\n * (`abortActiveOperations()`, `shutdown()`, and `onLifecycle(\"shutdown\")`) —\n * they do not touch process signals or the core singletons themselves.\n */\nexport class LifecycleManager {\n /**\n * Overall graceful-shutdown budget before the process is force-exited.\n *\n * Budget arithmetic: plugin `shutdown()` hooks run concurrently and are\n * bounded by {@link PLUGIN_SHUTDOWN_TIMEOUT_MS} (10s); the lifecycle emit\n * is bounded by {@link PHASE_SHUTDOWN_TIMEOUT_MS} (2s); the cache storage\n * close and the telemetry flush run concurrently, each bounded by\n * {@link PHASE_SHUTDOWN_TIMEOUT_MS} (2s). Worst case is\n * 10s + 2s + max(2s, 2s) = 14s, leaving ~1s of margin for the remaining\n * steps (aborts) before this timer force-exits.\n */\n private static readonly SHUTDOWN_TIMEOUT_MS = 15_000;\n /**\n * Per-plugin budget for `shutdown()` hooks. Sized to cover the longest\n * built-in drain (the files plugin waits up to 10s for in-flight writes).\n */\n private static readonly PLUGIN_SHUTDOWN_TIMEOUT_MS = 10_000;\n /**\n * Budget for each non-plugin shutdown phase (the `\"shutdown\"` lifecycle\n * emit, the cache storage close, and the telemetry flush). Keeps the\n * worst-case total under {@link SHUTDOWN_TIMEOUT_MS} — see the arithmetic\n * there.\n */\n private static readonly PHASE_SHUTDOWN_TIMEOUT_MS = 2_000;\n\n /**\n * Guards against re-entrant shutdown (e.g. SIGTERM followed by SIGINT).\n * The flag set in `shutdown` must remain synchronous and first — any\n * `await` before it would open a window for a second signal to re-enter\n * the sequence.\n */\n private isShuttingDown = false;\n /**\n * Name of the shutdown phase currently in flight, so the force-exit log\n * can say where shutdown got stuck without extra bookkeeping.\n */\n private shutdownPhase = \"not started\";\n\n constructor(private readonly context: PluginContext) {}\n\n /**\n * Install the SIGTERM/SIGINT handlers that trigger {@link shutdown}.\n *\n * Uses `process.once` (not `on`) so a repeated signal cannot register the\n * handler twice; re-entrancy from a *different* signal is guarded by\n * `isShuttingDown` inside {@link shutdown}.\n */\n installSignalHandlers(): void {\n process.once(\"SIGTERM\", () => this.shutdown());\n process.once(\"SIGINT\", () => this.shutdown());\n }\n\n /**\n * Run the graceful-shutdown sequence and exit the process.\n *\n * Phases:\n * 1. stop the internal-telemetry reporter\n * 2. abort in-flight work on every plugin (cancellation only — teardown of\n * shared resources belongs in `shutdown()` so peers can still drain)\n * 3. run every plugin's `shutdown()` hook concurrently, each bounded\n * 4. emit the `\"shutdown\"` lifecycle event, bounded\n * 5. close the cache storage and flush telemetry concurrently, each bounded\n *\n * Exits 0 on completion (and on the force-exit backstop): a deliberate\n * shutdown is not a crash. Exit 1 is reserved for an unexpected error\n * thrown by the sequence itself.\n */\n async shutdown(): Promise<void> {\n // Must stay synchronous and first: any await before the flag is set\n // would let a second signal re-enter the shutdown sequence.\n if (this.isShuttingDown) return;\n this.isShuttingDown = true;\n\n logger.info(\"Starting graceful shutdown...\");\n\n let exitCode = 0;\n\n // Force exit once the overall budget is spent. Exit 0 is deliberate:\n // a force-timeout still happens on a routine deploy (deliberate\n // shutdown, not a crash), and orchestrators record nonzero exits on\n // deploys as crashes. The error log below is the stuck-shutdown\n // signal instead of the exit code.\n const forceExitTimer = setTimeout(() => {\n logger.error(\n \"Graceful shutdown did NOT complete within the %dms budget (phase in flight: %s); force-exiting with code 0.\",\n LifecycleManager.SHUTDOWN_TIMEOUT_MS,\n this.shutdownPhase,\n );\n process.exit(0);\n }, LifecycleManager.SHUTDOWN_TIMEOUT_MS);\n // unref so this backstop timer never by itself keeps the process alive.\n // Any real pending teardown (OTEL export timer, DB pool sockets, the\n // still-open HTTP listener) is a ref'd handle that holds the loop open\n // until this fires; if nothing is ref'd, there is nothing left to tear\n // down and exiting early is correct.\n forceExitTimer.unref();\n\n try {\n const plugins = Array.from(this.context.getPlugins().values());\n\n // 1. stop the internal-telemetry reporter (no-op if never started).\n this.shutdownPhase = \"stopping internal telemetry reporter\";\n TelemetryReporter.getInstance()?.stop();\n\n // 2. abort active operations from plugins (in-flight executions, SSE\n // streams). Cancellation only — resource teardown (e.g. the\n // lakebase pools, the server's socket close) belongs in plugin\n // shutdown() hooks / lifecycle subscribers so other plugins can\n // still drain state through them.\n this.shutdownPhase = \"aborting active operations\";\n for (const plugin of plugins) {\n if (plugin.abortActiveOperations) {\n try {\n plugin.abortActiveOperations();\n } catch (err) {\n logger.error(\n \"Error aborting operations for plugin %s: %O\",\n plugin.name,\n err,\n );\n }\n }\n }\n\n // 3. run every plugin's shutdown() hook concurrently, each bounded\n // by a per-plugin timeout so one hung plugin cannot stall exit.\n this.shutdownPhase = \"plugin shutdown() hooks\";\n await Promise.all(\n plugins\n .filter((plugin) => typeof plugin.shutdown === \"function\")\n .map((plugin) => this.runPluginShutdown(plugin)),\n );\n\n // 4. notify lifecycle subscribers, bounded so a slow subscriber\n // cannot eat the remaining budget. The server plugin closes its\n // remaining sockets here, after other plugins have drained.\n this.shutdownPhase = \"shutdown lifecycle emit\";\n try {\n await this.raceWithTimeout(\n this.context.emitLifecycle(\"shutdown\"),\n LifecycleManager.PHASE_SHUTDOWN_TIMEOUT_MS,\n \"shutdown lifecycle emit\",\n );\n } catch (err) {\n logger.error(\"Error emitting shutdown lifecycle event: %O\", err);\n }\n\n // 5. close the cache manager's storage (drains the persistent\n // Lakebase pool; no-op for in-memory storage) and flush telemetry.\n // Runs after the lifecycle emit so subscribers can still read the\n // cache. The two are independent (the flush never touches the\n // cache), so they run concurrently — each bounded so a stuck pool\n // drain or stalled OTLP export cannot eat the remaining budget.\n this.shutdownPhase = \"cache storage close + telemetry flush\";\n await Promise.all([this.closeCacheStorage(), this.flushTelemetry()]);\n\n logger.info(\"Graceful shutdown complete\");\n } catch (err) {\n // Exit 1 is reserved for an unexpected error thrown by the sequence\n // itself; every per-phase failure above is already caught and logged.\n logger.error(\"Error during graceful shutdown: %O\", err);\n exitCode = 1;\n }\n\n clearTimeout(forceExitTimer);\n process.exit(exitCode);\n }\n\n /** Close the cache storage, bounded and error-isolated. */\n private async closeCacheStorage(): Promise<void> {\n let cache: CacheManager;\n try {\n cache = CacheManager.getInstanceSync();\n } catch {\n // Cache was never initialized — nothing to close.\n return;\n }\n try {\n await this.raceWithTimeout(\n cache.close(),\n LifecycleManager.PHASE_SHUTDOWN_TIMEOUT_MS,\n \"cache storage close\",\n );\n } catch (err) {\n logger.error(\"Error closing cache storage during shutdown: %O\", err);\n }\n }\n\n /** Flush and shut down the telemetry SDK, bounded and error-isolated. */\n private async flushTelemetry(): Promise<void> {\n try {\n await this.raceWithTimeout(\n TelemetryManager.getInstance().shutdown(),\n LifecycleManager.PHASE_SHUTDOWN_TIMEOUT_MS,\n \"telemetry flush\",\n );\n } catch (err) {\n logger.error(\"Error flushing telemetry during shutdown: %O\", err);\n }\n }\n\n /**\n * Run a single plugin's `shutdown()` hook bounded by\n * {@link LifecycleManager.PLUGIN_SHUTDOWN_TIMEOUT_MS}. Errors and timeouts\n * are logged but never thrown so one misbehaving plugin cannot block\n * the rest of the shutdown sequence.\n */\n private async runPluginShutdown(plugin: BasePlugin): Promise<void> {\n try {\n await this.raceWithTimeout(\n plugin.shutdown?.(),\n LifecycleManager.PLUGIN_SHUTDOWN_TIMEOUT_MS,\n \"shutdown()\",\n );\n } catch (err) {\n logger.error(\"Error shutting down plugin %s: %O\", plugin.name, err);\n }\n }\n\n /**\n * Race `work` against a timeout. Rejects with a labeled error when the\n * timeout wins. A no-op rejection handler is attached to the work promise\n * before racing so a branch that rejects after the timeout already won\n * does not surface as an unhandledRejection.\n */\n private async raceWithTimeout<T>(\n work: Promise<T> | T,\n timeoutMs: number,\n label: string,\n ): Promise<T> {\n const promise = Promise.resolve(work);\n promise.catch(() => {});\n let timer: NodeJS.Timeout | undefined;\n try {\n return await Promise.race([\n promise,\n new Promise<never>((_, reject) => {\n timer = setTimeout(\n () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),\n timeoutMs,\n );\n timer.unref();\n }),\n ]);\n } finally {\n if (timer) clearTimeout(timer);\n }\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"lifecycle-manager.js","names":[],"sources":["../../src/core/lifecycle-manager.ts"],"sourcesContent":["import type { BasePlugin } from \"shared\";\n\nimport { CacheManager } from \"../cache\";\nimport { TelemetryReporter } from \"../internal-telemetry\";\nimport { createLogger } from \"../logging/logger\";\nimport { TelemetryManager } from \"../telemetry\";\nimport type { PluginContext } from \"./plugin-context\";\n\nconst logger = createLogger(\"lifecycle\");\n\n/**\n * Owns the process's graceful-shutdown sequence.\n *\n * Created by AppKit core once every plugin has started. It is the single\n * owner of the SIGTERM/SIGINT handlers and of `process.exit`, mirroring the\n * core-owned startup in `AppKit._createApp`: core initializes telemetry,\n * cache, and the internal-telemetry reporter, and core tears them all down\n * here. Plugins participate through the generic hooks\n * (`abortActiveOperations()`, `shutdown()`, and `onLifecycle(\"shutdown\")`) —\n * they do not touch process signals or the core singletons themselves.\n */\nexport class LifecycleManager {\n /**\n * Overall graceful-shutdown budget before the process is force-exited.\n *\n * Budget arithmetic: plugin `shutdown()` hooks run concurrently and are\n * bounded by {@link PLUGIN_SHUTDOWN_TIMEOUT_MS} (10s); the lifecycle emit\n * is bounded by {@link PHASE_SHUTDOWN_TIMEOUT_MS} (2s); the cache storage\n * close and the telemetry flush run concurrently, each bounded by\n * {@link PHASE_SHUTDOWN_TIMEOUT_MS} (2s). Worst case is\n * 10s + 2s + max(2s, 2s) = 14s, leaving ~1s of margin for the remaining\n * steps (aborts) before this timer force-exits.\n */\n private static readonly SHUTDOWN_TIMEOUT_MS = 15_000;\n /**\n * Per-plugin budget for `shutdown()` hooks. Sized to cover the longest\n * built-in drain (the files plugin waits up to 10s for in-flight writes).\n */\n private static readonly PLUGIN_SHUTDOWN_TIMEOUT_MS = 10_000;\n /**\n * Budget for each non-plugin shutdown phase (the `\"shutdown\"` lifecycle\n * emit, the cache storage close, and the telemetry flush). Keeps the\n * worst-case total under {@link SHUTDOWN_TIMEOUT_MS} — see the arithmetic\n * there.\n */\n private static readonly PHASE_SHUTDOWN_TIMEOUT_MS = 2_000;\n\n /**\n * The in-flight teardown, memoized. A boolean guard would let a second caller\n * return while teardown was still running — fine for a signal, wrong for the\n * harness path (`{ exit: false }`), which must not resolve before resources\n * are released.\n */\n private teardown: Promise<number> | undefined;\n /** Reported by the force-exit log so a stuck shutdown names its phase. */\n private shutdownPhase = \"not started\";\n\n constructor(private readonly context: PluginContext) {}\n\n /**\n * Install the SIGTERM/SIGINT handlers that trigger {@link shutdown}. Never\n * removed: the signal path exits the process, and the harness opts out of\n * installing them, so nothing accumulates across boots.\n */\n installSignalHandlers(): void {\n process.once(\"SIGTERM\", () => void this.shutdown());\n process.once(\"SIGINT\", () => void this.shutdown());\n }\n\n /**\n * Run the graceful-shutdown sequence. Exits the process unless\n * `exit: false` — the flag the test harness passes so it can tear a booted\n * app down between tests without killing the vitest process.\n *\n * Phases:\n * 1. stop the internal-telemetry reporter\n * 2. abort in-flight work on every plugin (cancellation only — teardown of\n * shared resources belongs in `shutdown()` so peers can still drain)\n * 3. run every plugin's `shutdown()` hook concurrently, each bounded\n * 4. emit the `\"shutdown\"` lifecycle event, bounded\n * 5. close the cache storage and flush telemetry concurrently, each bounded\n *\n * Every phase is individually bounded, so the sequence always completes —\n * `{ exit: false }` therefore needs no outer timeout and an `afterEach`\n * cannot hang on it. A second call joins the first teardown.\n *\n * Exits 0 on completion (and on the force-exit backstop): a deliberate\n * shutdown is not a crash. Exit 1 is reserved for an unexpected error\n * thrown by the sequence itself.\n */\n async shutdown(options: { exit?: boolean } = {}): Promise<void> {\n const exit = options.exit ?? true;\n\n if (!exit) {\n // Harness path: no backstop, no process.exit. The phases are internally\n // bounded, and this is fully awaited before the harness drops the\n // singletons — so phase 5 always acts on this app's own cache/telemetry.\n await this.runPhasesOnce();\n return;\n }\n\n // Exit 0 on force-timeout: a stuck deploy shutdown is not a crash, and\n // orchestrators read nonzero deploy exits as one. The error log is the\n // signal instead. Belt-and-suspenders over the per-phase budgets.\n const forceExitTimer = setTimeout(() => {\n logger.error(\n \"Graceful shutdown did NOT complete within the %dms budget (phase in flight: %s); force-exiting with code 0.\",\n LifecycleManager.SHUTDOWN_TIMEOUT_MS,\n this.shutdownPhase,\n );\n process.exit(0);\n }, LifecycleManager.SHUTDOWN_TIMEOUT_MS);\n // unref'd so the backstop alone never holds the process open; real pending\n // teardown is ref'd and keeps the loop alive until this fires.\n forceExitTimer.unref();\n\n const exitCode = await this.runPhasesOnce();\n\n clearTimeout(forceExitTimer);\n process.exit(exitCode);\n }\n\n /** No `await` between read and assign — that gap is the re-entrancy window. */\n private runPhasesOnce(): Promise<number> {\n this.teardown ??= this.runPhases();\n return this.teardown;\n }\n\n /** Run the phases and report an exit code; no process-termination concerns. */\n private async runPhases(): Promise<number> {\n logger.info(\"Starting graceful shutdown...\");\n\n let exitCode = 0;\n\n try {\n const plugins = Array.from(this.context.getPlugins().values());\n\n // 1. stop the internal-telemetry reporter (no-op if never started).\n this.shutdownPhase = \"stopping internal telemetry reporter\";\n TelemetryReporter.getInstance()?.stop();\n\n // 2. abort active operations from plugins (in-flight executions, SSE\n // streams). Cancellation only — resource teardown (e.g. the\n // lakebase pools, the server's socket close) belongs in plugin\n // shutdown() hooks / lifecycle subscribers so other plugins can\n // still drain state through them.\n this.shutdownPhase = \"aborting active operations\";\n for (const plugin of plugins) {\n if (plugin.abortActiveOperations) {\n try {\n plugin.abortActiveOperations();\n } catch (err) {\n logger.error(\n \"Error aborting operations for plugin %s: %O\",\n plugin.name,\n err,\n );\n }\n }\n }\n\n // 3. run every plugin's shutdown() hook concurrently, each bounded\n // by a per-plugin timeout so one hung plugin cannot stall exit.\n this.shutdownPhase = \"plugin shutdown() hooks\";\n await Promise.all(\n plugins\n .filter((plugin) => typeof plugin.shutdown === \"function\")\n .map((plugin) => this.runPluginShutdown(plugin)),\n );\n\n // 4. notify lifecycle subscribers, bounded so a slow subscriber\n // cannot eat the remaining budget. The server plugin closes its\n // remaining sockets here, after other plugins have drained.\n this.shutdownPhase = \"shutdown lifecycle emit\";\n try {\n await this.raceWithTimeout(\n this.context.emitLifecycle(\"shutdown\"),\n LifecycleManager.PHASE_SHUTDOWN_TIMEOUT_MS,\n \"shutdown lifecycle emit\",\n );\n } catch (err) {\n logger.error(\"Error emitting shutdown lifecycle event: %O\", err);\n }\n\n // 5. close the cache manager's storage (drains the persistent\n // Lakebase pool; no-op for in-memory storage) and flush telemetry.\n // Runs after the lifecycle emit so subscribers can still read the\n // cache. The two are independent (the flush never touches the\n // cache), so they run concurrently — each bounded so a stuck pool\n // drain or stalled OTLP export cannot eat the remaining budget.\n this.shutdownPhase = \"cache storage close + telemetry flush\";\n await Promise.all([this.closeCacheStorage(), this.flushTelemetry()]);\n\n logger.info(\"Graceful shutdown complete\");\n } catch (err) {\n // Exit 1 is reserved for an unexpected error thrown by the sequence\n // itself; every per-phase failure above is already caught and logged.\n logger.error(\"Error during graceful shutdown: %O\", err);\n exitCode = 1;\n }\n\n return exitCode;\n }\n\n /** Bounded and error-isolated. Reads the cache manager at phase-5 time. */\n private async closeCacheStorage(): Promise<void> {\n let cache: CacheManager | undefined;\n try {\n cache = CacheManager.getInstanceSync();\n } catch {\n // Never initialized — nothing to close.\n return;\n }\n try {\n await this.raceWithTimeout(\n cache.close(),\n LifecycleManager.PHASE_SHUTDOWN_TIMEOUT_MS,\n \"cache storage close\",\n );\n } catch (err) {\n logger.error(\"Error closing cache storage during shutdown: %O\", err);\n }\n }\n\n /** Bounded and error-isolated. Reads the telemetry manager at phase-5 time. */\n private async flushTelemetry(): Promise<void> {\n let telemetry: TelemetryManager | undefined;\n try {\n telemetry = TelemetryManager.getInstance();\n } catch {\n // Unavailable or mocked away — nothing to flush.\n return;\n }\n if (!telemetry) return;\n try {\n await this.raceWithTimeout(\n telemetry.shutdown(),\n LifecycleManager.PHASE_SHUTDOWN_TIMEOUT_MS,\n \"telemetry flush\",\n );\n } catch (err) {\n logger.error(\"Error flushing telemetry during shutdown: %O\", err);\n }\n }\n\n /**\n * Run a single plugin's `shutdown()` hook bounded by\n * {@link LifecycleManager.PLUGIN_SHUTDOWN_TIMEOUT_MS}. Errors and timeouts\n * are logged but never thrown so one misbehaving plugin cannot block\n * the rest of the shutdown sequence.\n */\n private async runPluginShutdown(plugin: BasePlugin): Promise<void> {\n try {\n await this.raceWithTimeout(\n plugin.shutdown?.(),\n LifecycleManager.PLUGIN_SHUTDOWN_TIMEOUT_MS,\n \"shutdown()\",\n );\n } catch (err) {\n logger.error(\"Error shutting down plugin %s: %O\", plugin.name, err);\n }\n }\n\n /**\n * Race `work` against a timeout. Rejects with a labeled error when the\n * timeout wins. A no-op rejection handler is attached to the work promise\n * before racing so a branch that rejects after the timeout already won\n * does not surface as an unhandledRejection.\n */\n private async raceWithTimeout<T>(\n work: Promise<T> | T,\n timeoutMs: number,\n label: string,\n ): Promise<T> {\n const promise = Promise.resolve(work);\n promise.catch(() => {});\n let timer: NodeJS.Timeout | undefined;\n try {\n return await Promise.race([\n promise,\n new Promise<never>((_, reject) => {\n timer = setTimeout(\n () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),\n timeoutMs,\n );\n timer.unref();\n }),\n ]);\n } finally {\n if (timer) clearTimeout(timer);\n }\n }\n}\n"],"mappings":"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"}