From ff847ee00741cf5a201e182d82bd7d842f72a0a3 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 4 Feb 2026 14:49:19 +0100 Subject: [PATCH 1/6] chore: resource registry --- CLAUDE.md | 5 - .../dev-playground/server/reconnect-plugin.ts | 11 +- .../server/telemetry-example-plugin.ts | 11 +- docs/docs/api/appkit/Class.Plugin.md | 28 -- .../docs/api/appkit/Class.ResourceRegistry.md | 239 +++++++++++++ .../api/appkit/Enumeration.ResourceType.md | 2 +- docs/docs/api/appkit/index.md | 3 +- docs/docs/api/appkit/typedoc-sidebar.ts | 5 + docs/docs/plugins.md | 16 +- packages/appkit/src/core/appkit.ts | 96 ++++- .../appkit/src/core/tests/databricks.test.ts | 58 +-- packages/appkit/src/index.ts | 1 + packages/appkit/src/plugin/plugin.ts | 10 +- .../appkit/src/plugin/tests/plugin.test.ts | 25 -- .../appkit/src/plugins/analytics/analytics.ts | 1 - packages/appkit/src/plugins/server/index.ts | 1 - .../server/tests/server.integration.test.ts | 1 - .../src/plugins/server/tests/server.test.ts | 1 - packages/appkit/src/registry/index.ts | 3 +- .../appkit/src/registry/resource-registry.ts | 336 ++++++++++++++++++ packages/appkit/src/registry/types.ts | 2 +- packages/appkit/src/utils/env-validator.ts | 15 - packages/appkit/src/utils/index.ts | 1 - packages/shared/src/plugin.ts | 2 - 24 files changed, 718 insertions(+), 155 deletions(-) create mode 100644 docs/docs/api/appkit/Class.ResourceRegistry.md create mode 100644 packages/appkit/src/registry/resource-registry.ts delete mode 100644 packages/appkit/src/utils/env-validator.ts diff --git a/CLAUDE.md b/CLAUDE.md index 75df02b5..25f598a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -174,11 +174,6 @@ import { Plugin, toPlugin } from '@databricks/appkit'; class MyPlugin extends Plugin { name: string = "myPlugin"; - // Validate required environment variables - validateEnv() { - // Check process.env for required vars - } - // Async initialization async setup() { // Initialize resources diff --git a/apps/dev-playground/server/reconnect-plugin.ts b/apps/dev-playground/server/reconnect-plugin.ts index 949de36c..908b0e1a 100644 --- a/apps/dev-playground/server/reconnect-plugin.ts +++ b/apps/dev-playground/server/reconnect-plugin.ts @@ -15,7 +15,16 @@ interface ReconnectStreamResponse { export class ReconnectPlugin extends Plugin { public name = "reconnect"; - protected envVars: string[] = []; + + static manifest = { + name: "reconnect", + displayName: "Reconnect Plugin", + description: "A plugin that reconnects to the server", + resources: { + required: [], + optional: [], + }, + }; injectRoutes(router: IAppRouter): void { this.route(router, { diff --git a/apps/dev-playground/server/telemetry-example-plugin.ts b/apps/dev-playground/server/telemetry-example-plugin.ts index 714bbbef..8f879687 100644 --- a/apps/dev-playground/server/telemetry-example-plugin.ts +++ b/apps/dev-playground/server/telemetry-example-plugin.ts @@ -17,7 +17,16 @@ import type { Request, Response, Router } from "express"; class TelemetryExamples extends Plugin { public name = "telemetry-examples" as const; - protected envVars: string[] = []; + + static manifest = { + name: "telemetry-examples", + displayName: "Telemetry Examples Plugin", + description: "A plugin that provides telemetry examples", + resources: { + required: [], + optional: [], + }, + }; private requestCounter: Counter; private durationHistogram: Histogram; diff --git a/docs/docs/api/appkit/Class.Plugin.md b/docs/docs/api/appkit/Class.Plugin.md index 796b31fb..8372513e 100644 --- a/docs/docs/api/appkit/Class.Plugin.md +++ b/docs/docs/api/appkit/Class.Plugin.md @@ -32,9 +32,7 @@ const myManifest: PluginManifest = { class MyPlugin extends Plugin { static manifest = myManifest; // Required! - name = 'myPlugin'; - protected envVars: string[] = []; async setup() { // Initialize your plugin @@ -110,14 +108,6 @@ protected devFileReader: DevFileReader; *** -### envVars - -```ts -abstract protected envVars: string[]; -``` - -*** - ### isReady ```ts @@ -400,21 +390,3 @@ setup(): Promise; ```ts BasePlugin.setup ``` - -*** - -### validateEnv() - -```ts -validateEnv(): void; -``` - -#### Returns - -`void` - -#### Implementation of - -```ts -BasePlugin.validateEnv -``` diff --git a/docs/docs/api/appkit/Class.ResourceRegistry.md b/docs/docs/api/appkit/Class.ResourceRegistry.md new file mode 100644 index 00000000..13703835 --- /dev/null +++ b/docs/docs/api/appkit/Class.ResourceRegistry.md @@ -0,0 +1,239 @@ +# Class: ResourceRegistry + +Central registry for tracking plugin resource requirements. +Implements singleton pattern to ensure a single source of truth. + +## Methods + +### clear() + +```ts +clear(): void; +``` + +Clears all registered resources. +Useful for testing or when rebuilding the registry. + +#### Returns + +`void` + +*** + +### get() + +```ts +get(type: string, alias: string): ResourceEntry | undefined; +``` + +Gets a specific resource by type and alias. + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `type` | `string` | Resource type | +| `alias` | `string` | Resource alias | + +#### Returns + +[`ResourceEntry`](Interface.ResourceEntry.md) \| `undefined` + +The resource entry if found, undefined otherwise + +*** + +### getAll() + +```ts +getAll(): ResourceEntry[]; +``` + +Retrieves all registered resources. +Returns a copy of the array to prevent external mutations. + +#### Returns + +[`ResourceEntry`](Interface.ResourceEntry.md)[] + +Array of all registered resource entries + +*** + +### getByPlugin() + +```ts +getByPlugin(pluginName: string): ResourceEntry[]; +``` + +Gets all resources required by a specific plugin. + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `pluginName` | `string` | Name of the plugin | + +#### Returns + +[`ResourceEntry`](Interface.ResourceEntry.md)[] + +Array of resources where the plugin is listed as a requester + +*** + +### getOptional() + +```ts +getOptional(): ResourceEntry[]; +``` + +Gets all optional resources (where required=false). + +#### Returns + +[`ResourceEntry`](Interface.ResourceEntry.md)[] + +Array of optional resource entries + +*** + +### getRequired() + +```ts +getRequired(): ResourceEntry[]; +``` + +Gets all required resources (where required=true). + +#### Returns + +[`ResourceEntry`](Interface.ResourceEntry.md)[] + +Array of required resource entries + +*** + +### register() + +```ts +register(plugin: string, resource: ResourceRequirement): void; +``` + +Registers a resource requirement for a plugin. +If a resource with the same type+alias already exists, merges them: +- Combines plugin names (comma-separated) +- Uses the most permissive permission +- Marks as required if any plugin requires it +- Combines descriptions if they differ +- Keeps the env variable (or merges if they differ) + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `plugin` | `string` | Name of the plugin registering the resource | +| `resource` | [`ResourceRequirement`](Interface.ResourceRequirement.md) | Resource requirement specification | + +#### Returns + +`void` + +*** + +### size() + +```ts +size(): number; +``` + +Returns the number of registered resources. + +#### Returns + +`number` + +*** + +### validate() + +```ts +validate(): ValidationResult; +``` + +Validates all registered resources against the environment. + +Checks each resource's environment variable to determine if it's resolved. +Updates the `resolved` and `value` fields on each resource entry. + +Only required resources affect the `valid` status - optional resources +are checked but don't cause validation failure. + +#### Returns + +[`ValidationResult`](Interface.ValidationResult.md) + +ValidationResult with validity status, missing resources, and all resources + +#### Example + +```typescript +const registry = ResourceRegistry.getInstance(); +const result = registry.validate(); + +if (!result.valid) { + console.error("Missing resources:", result.missing.map(r => r.env)); +} +``` + +*** + +### formatMissingResources() + +```ts +static formatMissingResources(missing: ResourceEntry[]): string; +``` + +Formats missing resources into a human-readable error message. + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `missing` | [`ResourceEntry`](Interface.ResourceEntry.md)[] | Array of missing resource entries | + +#### Returns + +`string` + +Formatted error message string + +*** + +### getInstance() + +```ts +static getInstance(): ResourceRegistry; +``` + +Gets the singleton instance of the ResourceRegistry. +Creates a new instance if one doesn't exist. + +#### Returns + +`ResourceRegistry` + +*** + +### resetInstance() + +```ts +static resetInstance(): void; +``` + +Resets the singleton instance. +Primarily used for testing to ensure clean state between tests. + +#### Returns + +`void` diff --git a/docs/docs/api/appkit/Enumeration.ResourceType.md b/docs/docs/api/appkit/Enumeration.ResourceType.md index 53241e47..65d59812 100644 --- a/docs/docs/api/appkit/Enumeration.ResourceType.md +++ b/docs/docs/api/appkit/Enumeration.ResourceType.md @@ -1,6 +1,6 @@ # Enumeration: ResourceType -Supported Databricks resource types that plugins can depend on. +Supported resource types that plugins can depend on. ## Enumeration Members diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index ac912bf2..4a8bb03a 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -7,7 +7,7 @@ plugin architecture, and React integration. | Enumeration | Description | | ------ | ------ | -| [ResourceType](Enumeration.ResourceType.md) | Supported Databricks resource types that plugins can depend on. | +| [ResourceType](Enumeration.ResourceType.md) | Supported resource types that plugins can depend on. | ## Classes @@ -20,6 +20,7 @@ plugin architecture, and React integration. | [ExecutionError](Class.ExecutionError.md) | Error thrown when an operation execution fails. Use for statement failures, canceled operations, or unexpected states. | | [InitializationError](Class.InitializationError.md) | Error thrown when a service or component is not properly initialized. Use when accessing services before they are ready. | | [Plugin](Class.Plugin.md) | Base abstract class for creating AppKit plugins. | +| [ResourceRegistry](Class.ResourceRegistry.md) | Central registry for tracking plugin resource requirements. Implements singleton pattern to ensure a single source of truth. | | [ServerError](Class.ServerError.md) | Error thrown when server lifecycle operations fail. Use for server start/stop issues, configuration conflicts, etc. | | [TunnelError](Class.TunnelError.md) | Error thrown when remote tunnel operations fail. Use for tunnel connection issues, message parsing failures, etc. | | [ValidationError](Class.ValidationError.md) | Error thrown when input validation fails. Use for invalid parameters, missing required fields, or type mismatches. | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index c310eb62..8fce7996 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -51,6 +51,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Class.Plugin", label: "Plugin" }, + { + type: "doc", + id: "api/appkit/Class.ResourceRegistry", + label: "ResourceRegistry" + }, { type: "doc", id: "api/appkit/Class.ServerError", diff --git a/docs/docs/plugins.md b/docs/docs/plugins.md index 1c8fee2a..c01b76c3 100644 --- a/docs/docs/plugins.md +++ b/docs/docs/plugins.md @@ -219,7 +219,19 @@ import type express from "express"; class MyPlugin extends Plugin { name = "myPlugin"; - envVars = ["MY_API_KEY"]; + + // Define resource requirements in the static manifest + static manifest = { + name: "myPlugin", + displayName: "My Plugin", + description: "A custom plugin", + resources: { + required: [ + { type: "SECRET_SCOPE", alias: "MY_API_KEY" } + ], + optional: [] + } + }; async setup() { // Initialize your plugin @@ -250,7 +262,7 @@ export const myPlugin = toPlugin, "myPlug ### Key extension points - **Route injection**: Implement `injectRoutes()` to add custom endpoints using [`IAppRouter`](api/appkit/TypeAlias.IAppRouter.md) -- **Lifecycle hooks**: Override `setup()`, `shutdown()`, and `validateEnv()` methods +- **Lifecycle hooks**: Override `setup()`, and `shutdown()` methods - **Shared services**: - **Cache management**: Access the cache service via `this.cache`. See [`CacheConfig`](api/appkit/Interface.CacheConfig.md) for configuration. - **Telemetry**: Instrument your plugin with traces and metrics via `this.telemetry`. See [`ITelemetry`](api/appkit/Interface.ITelemetry.md). diff --git a/packages/appkit/src/core/appkit.ts b/packages/appkit/src/core/appkit.ts index c34588e3..44b5cb10 100644 --- a/packages/appkit/src/core/appkit.ts +++ b/packages/appkit/src/core/appkit.ts @@ -1,5 +1,6 @@ import type { BasePlugin, + BasePluginConfig, CacheConfig, InputPluginMap, OptionalConfigPluginDef, @@ -9,9 +10,18 @@ import type { } from "shared"; import { CacheManager } from "../cache"; import { ServiceContext } from "../context"; +import { ConfigurationError } from "../errors"; +import { createLogger } from "../logging"; +import { + getPluginManifest, + ResourceRegistry, + type ResourceRequirement, +} from "../registry"; import type { TelemetryConfig } from "../telemetry"; import { TelemetryManager } from "../telemetry"; +const logger = createLogger("appkit"); + export class AppKit { #pluginInstances: Record = {}; #setupPromises: Promise[] = []; @@ -70,8 +80,6 @@ export class AppKit { this.#pluginInstances[name] = pluginInstance; - pluginInstance.validateEnv(); - this.#setupPromises.push(pluginInstance.setup()); const self = this; @@ -152,6 +160,90 @@ export class AppKit { await ServiceContext.initialize(); const rawPlugins = config.plugins as T; + + // Phase 1a: Collect resource requirements from all plugins + const registry = ResourceRegistry.getInstance(); + registry.clear(); // Clear any previous state + + for (const pluginData of rawPlugins) { + if (!pluginData?.plugin) continue; + + const pluginName = pluginData.name; + + // Load manifest and register static resources + try { + const manifest = getPluginManifest(pluginData.plugin); + + // Register required resources + for (const resource of manifest.resources.required) { + registry.register(pluginName, { ...resource, required: true }); + } + + // Register optional resources + for (const resource of manifest.resources.optional || []) { + registry.register(pluginName, { ...resource, required: false }); + } + + // Check for runtime resource requirements + if (typeof pluginData.plugin.getResourceRequirements === "function") { + const runtimeResources = pluginData.plugin.getResourceRequirements( + pluginData.config as BasePluginConfig, + ); + for (const resource of runtimeResources) { + // Cast from shared's ResourceRequirement to registry's ResourceRequirement + // The shared type has looser typing (string) vs registry (ResourceType enum) + registry.register(pluginName, resource as ResourceRequirement); + } + } + + logger.debug( + "Collected resources from plugin %s: %d total", + pluginName, + registry.getByPlugin(pluginName).length, + ); + } catch (error) { + // Plugin doesn't have a manifest - this is allowed for legacy plugins + // or plugins that don't declare resources + logger.debug( + "Plugin %s has no manifest or invalid manifest: %s", + pluginName, + error instanceof Error ? error.message : String(error), + ); + } + } + + // Phase 1b: Validate resources + const validation = registry.validate(); + const isDevelopment = process.env.NODE_ENV === "development"; + + if (!validation.valid) { + const errorMessage = ResourceRegistry.formatMissingResources( + validation.missing, + ); + + if (isDevelopment) { + // In development mode, warn but continue + logger.warn( + "Missing resources detected (continuing in dev mode):\n%s", + errorMessage, + ); + } else { + // In production, throw error + throw new ConfigurationError(errorMessage, { + context: { + missingResources: validation.missing.map((r) => ({ + type: r.type, + alias: r.alias, + plugin: r.plugin, + env: r.env, + })), + }, + }); + } + } else if (registry.size() > 0) { + logger.debug("All %d resources validated successfully", registry.size()); + } + const preparedPlugins = AppKit.preparePlugins(rawPlugins); const mergedConfig = { plugins: preparedPlugins, diff --git a/packages/appkit/src/core/tests/databricks.test.ts b/packages/appkit/src/core/tests/databricks.test.ts index 6b4abe0d..a50511ac 100644 --- a/packages/appkit/src/core/tests/databricks.test.ts +++ b/packages/appkit/src/core/tests/databricks.test.ts @@ -16,9 +16,8 @@ const createTestManifest = (name: string): PluginManifest => ({ }, }); -// Mock environment validation +// Mock utilities vi.mock("../utils", () => ({ - validateEnv: vi.fn(), deepMerge: vi.fn((a, b) => ({ ...a, ...b })), })); @@ -47,17 +46,12 @@ class CoreTestPlugin implements BasePlugin { static manifest = createTestManifest("coreTest"); name = "coreTest"; setupCalled = false; - validateEnvCalled = false; injectedConfig: any; constructor(config: any) { this.injectedConfig = config; } - validateEnv() { - this.validateEnvCalled = true; - } - async setup() { this.setupCalled = true; } @@ -72,7 +66,6 @@ class CoreTestPlugin implements BasePlugin { return { // Expose internal state for testing setupCalled: this.setupCalled, - validateEnvCalled: this.validateEnvCalled, injectedConfig: this.injectedConfig, }; } @@ -84,17 +77,12 @@ class NormalTestPlugin implements BasePlugin { static manifest = createTestManifest("normalTest"); name = "normalTest"; setupCalled = false; - validateEnvCalled = false; injectedConfig: any; constructor(config: any) { this.injectedConfig = config; } - validateEnv() { - this.validateEnvCalled = true; - } - async setup() { this.setupCalled = true; } @@ -108,7 +96,6 @@ class NormalTestPlugin implements BasePlugin { exports() { return { setupCalled: this.setupCalled, - validateEnvCalled: this.validateEnvCalled, injectedConfig: this.injectedConfig, }; } @@ -120,7 +107,6 @@ class DeferredTestPlugin implements BasePlugin { static manifest = createTestManifest("deferredTest"); name = "deferredTest"; setupCalled = false; - validateEnvCalled = false; injectedConfig: any; injectedPlugins: any; @@ -129,10 +115,6 @@ class DeferredTestPlugin implements BasePlugin { this.injectedPlugins = config.plugins; } - validateEnv() { - this.validateEnvCalled = true; - } - async setup() { this.setupCalled = true; } @@ -146,7 +128,6 @@ class DeferredTestPlugin implements BasePlugin { exports() { return { setupCalled: this.setupCalled, - validateEnvCalled: this.validateEnvCalled, injectedConfig: this.injectedConfig, injectedPlugins: this.injectedPlugins, }; @@ -164,8 +145,6 @@ class SlowSetupPlugin implements BasePlugin { this.setupDelay = config.setupDelay || 100; } - validateEnv() {} - async setup() { await new Promise((resolve) => setTimeout(resolve, this.setupDelay)); this.setupCalled = true; @@ -189,10 +168,6 @@ class FailingPlugin implements BasePlugin { static manifest = createTestManifest("failing"); name = "failing"; - validateEnv() { - throw new Error("Environment validation failed"); - } - async setup() { throw new Error("Setup failed"); } @@ -245,7 +220,6 @@ describe("AppKit", () => { expect(instance.coreTest).toBeDefined(); // instance.coreTest returns the SDK, not the plugin instance expect(instance.coreTest.setupCalled).toBe(true); - expect(instance.coreTest.validateEnvCalled).toBe(true); }); test("should merge default and custom plugin configs", async () => { @@ -353,34 +327,8 @@ describe("AppKit", () => { expect(instance.slow2.setupCalled).toBe(true); }); - test("should validate environment for all plugins", async () => { - const pluginData = [ - { plugin: CoreTestPlugin, config: {}, name: "coreTest" }, - { plugin: NormalTestPlugin, config: {}, name: "normalTest" }, - ]; - - const instance = (await createApp({ plugins: pluginData })) as any; - - expect(instance.coreTest.validateEnvCalled).toBe(true); - expect(instance.normalTest.validateEnvCalled).toBe(true); - }); - - test("should throw error if plugin environment validation fails", async () => { - const pluginData = [ - { plugin: FailingPlugin, config: {}, name: "failing" }, - ]; - - await expect(createApp({ plugins: pluginData })).rejects.toThrow( - "Environment validation failed", - ); - }); - test("should throw error if plugin setup fails", async () => { - const FailingSetupPlugin = class extends FailingPlugin { - validateEnv() { - // Don't throw in validateEnv for this test - } - }; + const FailingSetupPlugin = class extends FailingPlugin {}; const pluginData = [ { plugin: FailingSetupPlugin, config: {}, name: "failing" }, @@ -548,7 +496,6 @@ describe("AppKit", () => { name = "contextTest"; private counter = 0; - validateEnv() {} async setup() {} injectRoutes() {} getEndpoints() { @@ -589,7 +536,6 @@ describe("AppKit", () => { name = "callbackTest"; private values: number[] = []; - validateEnv() {} async setup() {} injectRoutes() {} getEndpoints() { diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index 5fe94593..96f72ba3 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -44,6 +44,7 @@ export type { export { getPluginManifest, getResourceRequirements, + ResourceRegistry, ResourceType, } from "./registry"; // Telemetry (for advanced custom telemetry) diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts index 4d9c168a..908a53a0 100644 --- a/packages/appkit/src/plugin/plugin.ts +++ b/packages/appkit/src/plugin/plugin.ts @@ -27,7 +27,7 @@ import { normalizeTelemetryOptions, TelemetryManager, } from "../telemetry"; -import { deepMerge, validateEnv } from "../utils"; +import { deepMerge } from "../utils"; import { DevFileReader } from "./dev-reader"; import { CacheInterceptor } from "./interceptors/cache"; import { RetryInterceptor } from "./interceptors/retry"; @@ -49,7 +49,6 @@ const EXCLUDED_FROM_PROXY = new Set([ // Lifecycle methods "setup", "shutdown", - "validateEnv", "injectRoutes", "getEndpoints", "abortActiveOperations", @@ -91,9 +90,7 @@ const EXCLUDED_FROM_PROXY = new Set([ * * class MyPlugin extends Plugin { * static manifest = myManifest; // Required! - * * name = 'myPlugin'; - * protected envVars: string[] = []; * * async setup() { * // Initialize your plugin @@ -117,7 +114,6 @@ export abstract class Plugin< protected devFileReader: DevFileReader; protected streamManager: StreamManager; protected telemetry: ITelemetry; - protected abstract envVars: string[]; /** Registered endpoints for this plugin */ private registeredEndpoints: PluginEndpointMap = {}; @@ -146,10 +142,6 @@ export abstract class Plugin< this.isReady = true; } - validateEnv() { - validateEnv(this.envVars); - } - injectRoutes(_: express.Router) { return; } diff --git a/packages/appkit/src/plugin/tests/plugin.test.ts b/packages/appkit/src/plugin/tests/plugin.test.ts index b960a163..51f677a8 100644 --- a/packages/appkit/src/plugin/tests/plugin.test.ts +++ b/packages/appkit/src/plugin/tests/plugin.test.ts @@ -12,7 +12,6 @@ import { ServiceContext } from "../../context/service-context"; import { StreamManager } from "../../stream"; import type { ITelemetry, TelemetryProvider } from "../../telemetry"; import { TelemetryManager } from "../../telemetry"; -import { validateEnv } from "../../utils"; import type { InterceptorContext } from "../interceptors/types"; import { Plugin } from "../plugin"; @@ -25,7 +24,6 @@ vi.mock("../../cache", () => ({ })); vi.mock("../../stream"); vi.mock("../../utils", () => ({ - validateEnv: vi.fn(), deepMerge: vi.fn((a, b) => { if (!a) return b; if (!b) return a; @@ -85,8 +83,6 @@ vi.mock("../interceptors/telemetry", () => ({ // Test plugin implementations class TestPlugin extends Plugin { - envVars = ["TEST_ENV_VAR"]; - async customMethod(value: string): Promise { return `processed-${value}`; } @@ -174,7 +170,6 @@ describe("Plugin", () => { vi.mocked(TelemetryManager.getProvider).mockReturnValue( mockTelemetry as TelemetryProvider, ); - vi.mocked(validateEnv).mockImplementation(() => {}); vi.clearAllMocks(); }); @@ -210,26 +205,6 @@ describe("Plugin", () => { }); }); - describe("validateEnv", () => { - test("should call validateEnv with plugin envVars", () => { - const plugin = new TestPlugin(config); - - plugin.validateEnv(); - - expect(validateEnv).toHaveBeenCalledWith(["TEST_ENV_VAR"]); - }); - - test("should propagate validation errors", () => { - vi.mocked(validateEnv).mockImplementation(() => { - throw new Error("Validation failed"); - }); - - const plugin = new TestPlugin(config); - - expect(() => plugin.validateEnv()).toThrow("Validation failed"); - }); - }); - describe("setup", () => { test("should have empty default setup", async () => { const plugin = new TestPlugin(config); diff --git a/packages/appkit/src/plugins/analytics/analytics.ts b/packages/appkit/src/plugins/analytics/analytics.ts index cc590436..1619bdf0 100644 --- a/packages/appkit/src/plugins/analytics/analytics.ts +++ b/packages/appkit/src/plugins/analytics/analytics.ts @@ -27,7 +27,6 @@ const logger = createLogger("analytics"); export class AnalyticsPlugin extends Plugin { name = "analytics"; - protected envVars: string[] = []; /** Plugin manifest declaring metadata and resource requirements */ static manifest = analyticsManifest; diff --git a/packages/appkit/src/plugins/server/index.ts b/packages/appkit/src/plugins/server/index.ts index 61228f35..40cf01e0 100644 --- a/packages/appkit/src/plugins/server/index.ts +++ b/packages/appkit/src/plugins/server/index.ts @@ -44,7 +44,6 @@ export class ServerPlugin extends Plugin { static manifest = serverManifest; public name = "server" as const; - protected envVars: string[] = []; private serverApplication: express.Application; private server: HTTPServer | null; private viteDevServer?: ViteDevServer; diff --git a/packages/appkit/src/plugins/server/tests/server.integration.test.ts b/packages/appkit/src/plugins/server/tests/server.integration.test.ts index ded42c84..84496348 100644 --- a/packages/appkit/src/plugins/server/tests/server.integration.test.ts +++ b/packages/appkit/src/plugins/server/tests/server.integration.test.ts @@ -105,7 +105,6 @@ describe("ServerPlugin with custom plugin", () => { resources: { required: [], optional: [] }, }; name = "test-plugin" as const; - envVars: string[] = []; injectRoutes(router: any) { router.get("/echo", (_req: any, res: any) => { diff --git a/packages/appkit/src/plugins/server/tests/server.test.ts b/packages/appkit/src/plugins/server/tests/server.test.ts index a1521d1e..4a389e7e 100644 --- a/packages/appkit/src/plugins/server/tests/server.test.ts +++ b/packages/appkit/src/plugins/server/tests/server.test.ts @@ -91,7 +91,6 @@ vi.mock("../../../cache", () => ({ })); vi.mock("../../../utils", () => ({ - validateEnv: vi.fn(), deepMerge: vi.fn((a, b) => ({ ...a, ...b })), })); diff --git a/packages/appkit/src/registry/index.ts b/packages/appkit/src/registry/index.ts index d5c0c07b..688a2f8b 100644 --- a/packages/appkit/src/registry/index.ts +++ b/packages/appkit/src/registry/index.ts @@ -7,9 +7,10 @@ * Components: * - Type definitions for resources, manifests, and validation * - Manifest loader for reading plugin declarations - * - (Future) ResourceRegistry singleton for tracking requirements + * - ResourceRegistry singleton for tracking requirements across all plugins * - (Future) Config generators for app.yaml, databricks.yml, .env.example */ export { getPluginManifest, getResourceRequirements } from "./manifest-loader"; +export { ResourceRegistry } from "./resource-registry"; export * from "./types"; diff --git a/packages/appkit/src/registry/resource-registry.ts b/packages/appkit/src/registry/resource-registry.ts new file mode 100644 index 00000000..09614593 --- /dev/null +++ b/packages/appkit/src/registry/resource-registry.ts @@ -0,0 +1,336 @@ +/** + * Resource Registry Singleton + * + * Central registry that tracks all resource requirements across all plugins. + * Provides global visibility into Databricks resources needed by the application + * and handles deduplication when multiple plugins require the same resource. + */ + +import { createLogger } from "../logging/logger"; +import type { + ResourceEntry, + ResourcePermission, + ResourceRequirement, + ValidationResult, +} from "./types"; + +const logger = createLogger("resource-registry"); + +/** + * Permission hierarchy for merging logic. + * Higher index = more permissive. + */ +const PERMISSION_HIERARCHY: ResourcePermission[] = [ + "CAN_VIEW", + "READ", + "CAN_USE", + "WRITE", + "EXECUTE", + "CAN_MANAGE", +]; + +/** + * Returns the most permissive permission between two permissions. + */ +function getMostPermissivePermission( + p1: ResourcePermission, + p2: ResourcePermission, +): ResourcePermission { + const index1 = PERMISSION_HIERARCHY.indexOf(p1); + const index2 = PERMISSION_HIERARCHY.indexOf(p2); + return index1 > index2 ? p1 : p2; +} + +/** + * Generates a unique key for a resource based on type and alias. + */ +function getResourceKey(type: string, alias: string): string { + return `${type}:${alias}`; +} + +/** + * Central registry for tracking plugin resource requirements. + * Implements singleton pattern to ensure a single source of truth. + */ +export class ResourceRegistry { + private static instance: ResourceRegistry | null = null; + private resources: Map = new Map(); + + /** + * Private constructor to enforce singleton pattern. + */ + private constructor() {} + + /** + * Gets the singleton instance of the ResourceRegistry. + * Creates a new instance if one doesn't exist. + */ + public static getInstance(): ResourceRegistry { + if (!ResourceRegistry.instance) { + ResourceRegistry.instance = new ResourceRegistry(); + } + return ResourceRegistry.instance; + } + + /** + * Resets the singleton instance. + * Primarily used for testing to ensure clean state between tests. + */ + public static resetInstance(): void { + ResourceRegistry.instance = null; + } + + /** + * Registers a resource requirement for a plugin. + * If a resource with the same type+alias already exists, merges them: + * - Combines plugin names (comma-separated) + * - Uses the most permissive permission + * - Marks as required if any plugin requires it + * - Combines descriptions if they differ + * - Keeps the env variable (or merges if they differ) + * + * @param plugin - Name of the plugin registering the resource + * @param resource - Resource requirement specification + */ + public register(plugin: string, resource: ResourceRequirement): void { + const key = getResourceKey(resource.type, resource.alias); + const existing = this.resources.get(key); + + if (existing) { + // Merge with existing resource + const merged = this.mergeResources(existing, plugin, resource); + this.resources.set(key, merged); + } else { + // Create new resource entry + const entry: ResourceEntry = { + ...resource, + plugin, + resolved: false, + }; + this.resources.set(key, entry); + } + } + + /** + * Merges a new resource requirement with an existing entry. + * Applies intelligent merging logic for conflicting properties. + */ + private mergeResources( + existing: ResourceEntry, + newPlugin: string, + newResource: ResourceRequirement, + ): ResourceEntry { + // Combine plugin names if not already included + const plugins = existing.plugin.split(", "); + if (!plugins.includes(newPlugin)) { + plugins.push(newPlugin); + } + + // Use the most permissive permission + const permission = getMostPermissivePermission( + existing.permission, + newResource.permission, + ); + + // Mark as required if any plugin requires it + const required = existing.required || newResource.required; + + // Combine descriptions if they differ + let description = existing.description; + if ( + newResource.description && + newResource.description !== existing.description + ) { + // Check if the new description is already included + if (!existing.description.includes(newResource.description)) { + description = `${existing.description}; ${newResource.description}`; + } + } + + // Handle env variable merging + let env = existing.env; + if (newResource.env && newResource.env !== existing.env) { + // If env vars differ, prefer existing but note the conflict + if (existing.env) { + // Keep existing env, could log a warning here + env = existing.env; + } else { + env = newResource.env; + } + } else if (newResource.env) { + env = newResource.env; + } + + return { + ...existing, + plugin: plugins.join(", "), + permission, + required, + description, + env, + }; + } + + /** + * Retrieves all registered resources. + * Returns a copy of the array to prevent external mutations. + * + * @returns Array of all registered resource entries + */ + public getAll(): ResourceEntry[] { + return Array.from(this.resources.values()); + } + + /** + * Gets a specific resource by type and alias. + * + * @param type - Resource type + * @param alias - Resource alias + * @returns The resource entry if found, undefined otherwise + */ + public get(type: string, alias: string): ResourceEntry | undefined { + const key = getResourceKey(type, alias); + return this.resources.get(key); + } + + /** + * Clears all registered resources. + * Useful for testing or when rebuilding the registry. + */ + public clear(): void { + this.resources.clear(); + } + + /** + * Returns the number of registered resources. + */ + public size(): number { + return this.resources.size; + } + + /** + * Gets all resources required by a specific plugin. + * + * @param pluginName - Name of the plugin + * @returns Array of resources where the plugin is listed as a requester + */ + public getByPlugin(pluginName: string): ResourceEntry[] { + return this.getAll().filter((entry) => + entry.plugin.split(", ").includes(pluginName), + ); + } + + /** + * Gets all required resources (where required=true). + * + * @returns Array of required resource entries + */ + public getRequired(): ResourceEntry[] { + return this.getAll().filter((entry) => entry.required); + } + + /** + * Gets all optional resources (where required=false). + * + * @returns Array of optional resource entries + */ + public getOptional(): ResourceEntry[] { + return this.getAll().filter((entry) => !entry.required); + } + + /** + * Validates all registered resources against the environment. + * + * Checks each resource's environment variable to determine if it's resolved. + * Updates the `resolved` and `value` fields on each resource entry. + * + * Only required resources affect the `valid` status - optional resources + * are checked but don't cause validation failure. + * + * @returns ValidationResult with validity status, missing resources, and all resources + * + * @example + * ```typescript + * const registry = ResourceRegistry.getInstance(); + * const result = registry.validate(); + * + * if (!result.valid) { + * console.error("Missing resources:", result.missing.map(r => r.env)); + * } + * ``` + */ + public validate(): ValidationResult { + const missing: ResourceEntry[] = []; + + for (const entry of this.resources.values()) { + if (entry.env) { + const value = process.env[entry.env]; + if (value) { + entry.resolved = true; + entry.value = value; + logger.debug( + "Resource %s:%s resolved from %s", + entry.type, + entry.alias, + entry.env, + ); + } else { + entry.resolved = false; + entry.value = undefined; + + // Only required resources affect validation + if (entry.required) { + missing.push(entry); + logger.debug( + "Required resource %s:%s missing (env: %s)", + entry.type, + entry.alias, + entry.env, + ); + } else { + logger.debug( + "Optional resource %s:%s not configured (env: %s)", + entry.type, + entry.alias, + entry.env, + ); + } + } + } else { + // Resources without env vars are considered resolved + // (they may be provided through other means like config) + entry.resolved = true; + logger.debug( + "Resource %s:%s has no env var, marking as resolved", + entry.type, + entry.alias, + ); + } + } + + return { + valid: missing.length === 0, + missing, + all: this.getAll(), + }; + } + + /** + * Formats missing resources into a human-readable error message. + * + * @param missing - Array of missing resource entries + * @returns Formatted error message string + */ + public static formatMissingResources(missing: ResourceEntry[]): string { + if (missing.length === 0) { + return "No missing resources"; + } + + const lines = missing.map((entry) => { + const envHint = entry.env ? ` (set ${entry.env})` : ""; + return ` - ${entry.type}:${entry.alias} [${entry.plugin}]${envHint}`; + }); + + return `Missing required resources:\n${lines.join("\n")}`; + } +} diff --git a/packages/appkit/src/registry/types.ts b/packages/appkit/src/registry/types.ts index 18216521..331fc8fe 100644 --- a/packages/appkit/src/registry/types.ts +++ b/packages/appkit/src/registry/types.ts @@ -7,7 +7,7 @@ */ /** - * Supported Databricks resource types that plugins can depend on. + * Supported resource types that plugins can depend on. */ export enum ResourceType { /** Databricks SQL Warehouse for query execution */ diff --git a/packages/appkit/src/utils/env-validator.ts b/packages/appkit/src/utils/env-validator.ts deleted file mode 100644 index adc35a22..00000000 --- a/packages/appkit/src/utils/env-validator.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ValidationError } from "../errors"; - -export function validateEnv(envVars: string[]) { - const missingVars = []; - - for (const envVar of envVars) { - if (!process.env[envVar]) { - missingVars.push(envVar); - } - } - - if (missingVars.length > 0) { - throw ValidationError.missingEnvVars(missingVars); - } -} diff --git a/packages/appkit/src/utils/index.ts b/packages/appkit/src/utils/index.ts index 23770d21..c0b1b55b 100644 --- a/packages/appkit/src/utils/index.ts +++ b/packages/appkit/src/utils/index.ts @@ -1,4 +1,3 @@ -export * from "./env-validator"; export * from "./merge"; export * from "./path-exclusions"; export * from "./vite-config-merge"; diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index e390f835..5e42615c 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -6,8 +6,6 @@ export interface BasePlugin { abortActiveOperations?(): void; - validateEnv(): void; - setup(): Promise; injectRoutes(router: express.Router): void; From 88d75fdd2dd3b362ee910be9ef4e3926910bf79b Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 5 Feb 2026 12:15:53 +0100 Subject: [PATCH 2/6] chore: fixup --- docs/docs/plugins.md | 2 +- docs/package.json | 3 +- docs/scripts/copy-schemas.ts | 47 ++++ .../schemas/plugin-manifest.schema.json | 216 ++++++++++++++++++ packages/appkit/src/core/appkit.ts | 4 +- .../src/plugins/analytics/manifest.json | 39 ++++ .../appkit/src/plugins/analytics/manifest.ts | 53 +---- .../appkit/src/plugins/server/manifest.json | 36 +++ .../appkit/src/plugins/server/manifest.ts | 51 +---- .../src/plugins/server/tests/server.test.ts | 18 +- packages/appkit/src/registry/index.ts | 17 ++ .../schemas/plugin-manifest.schema.json | 216 ++++++++++++++++++ packages/appkit/tsdown.config.ts | 14 ++ 13 files changed, 625 insertions(+), 91 deletions(-) create mode 100644 docs/scripts/copy-schemas.ts create mode 100644 docs/static/schemas/plugin-manifest.schema.json create mode 100644 packages/appkit/src/plugins/analytics/manifest.json create mode 100644 packages/appkit/src/plugins/server/manifest.json create mode 100644 packages/appkit/src/registry/schemas/plugin-manifest.schema.json diff --git a/docs/docs/plugins.md b/docs/docs/plugins.md index c01b76c3..d636f865 100644 --- a/docs/docs/plugins.md +++ b/docs/docs/plugins.md @@ -193,7 +193,7 @@ In local development (`NODE_ENV=development`), if `asUser(req)` is called withou Configure plugins when creating your AppKit instance: ```typescript -import { createApp, server, analytics } from "@databricks/app-kit"; +import { createApp, server, analytics } from "@databricks/appkit"; const AppKit = await createApp({ plugins: [ diff --git a/docs/package.json b/docs/package.json index 658df190..78232d69 100644 --- a/docs/package.json +++ b/docs/package.json @@ -6,8 +6,9 @@ "docusaurus": "docusaurus", "dev": "pnpm run gen && docusaurus start --no-open", "build": "pnpm run gen && docusaurus build", - "gen": "pnpm run build-appkit-ui-styles && pnpm run generate-component-docs", + "gen": "pnpm run build-appkit-ui-styles && pnpm run generate-component-docs && pnpm run copy-schemas", "build-appkit-ui-styles": "tsx scripts/build-appkit-ui-styles.ts", + "copy-schemas": "tsx scripts/copy-schemas.ts", "generate-component-docs": "tsx ../tools/generate-component-mdx.ts", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", diff --git a/docs/scripts/copy-schemas.ts b/docs/scripts/copy-schemas.ts new file mode 100644 index 00000000..c519ddbd --- /dev/null +++ b/docs/scripts/copy-schemas.ts @@ -0,0 +1,47 @@ +/** + * Copies JSON schemas from packages to docs/static for hosting. + * + * Schemas are served at: + * https://databricks.github.io/appkit/schemas/{schema-name}.json + */ + +import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const SCHEMAS_SOURCE = join( + __dirname, + "../../packages/appkit/src/registry/schemas", +); +const SCHEMAS_DEST = join(__dirname, "../static/schemas"); + +function copySchemas() { + console.log("Copying JSON schemas to docs/static/schemas..."); + + // Ensure destination directory exists + if (!existsSync(SCHEMAS_DEST)) { + mkdirSync(SCHEMAS_DEST, { recursive: true }); + } + + // Check if source directory exists + if (!existsSync(SCHEMAS_SOURCE)) { + console.warn(`Schemas source directory not found: ${SCHEMAS_SOURCE}`); + return; + } + + // Copy all .json files + const files = readdirSync(SCHEMAS_SOURCE).filter((f) => f.endsWith(".json")); + + for (const file of files) { + const src = join(SCHEMAS_SOURCE, file); + const dest = join(SCHEMAS_DEST, file); + copyFileSync(src, dest); + console.log(` Copied: ${file}`); + } + + console.log(`Done! ${files.length} schema(s) copied.`); +} + +copySchemas(); diff --git a/docs/static/schemas/plugin-manifest.schema.json b/docs/static/schemas/plugin-manifest.schema.json new file mode 100644 index 00000000..8c865b26 --- /dev/null +++ b/docs/static/schemas/plugin-manifest.schema.json @@ -0,0 +1,216 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + "title": "AppKit Plugin Manifest", + "description": "Schema for Databricks AppKit plugin manifest files. Defines plugin metadata, resource requirements, and configuration options.", + "type": "object", + "required": ["name", "displayName", "description", "resources"], + "properties": { + "$schema": { + "type": "string", + "description": "Reference to the JSON Schema for validation" + }, + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Plugin identifier. Must be lowercase, start with a letter, and contain only letters, numbers, and hyphens.", + "examples": ["analytics", "server", "my-custom-plugin"] + }, + "displayName": { + "type": "string", + "minLength": 1, + "description": "Human-readable display name for UI and CLI", + "examples": ["Analytics Plugin", "Server Plugin"] + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Brief description of what the plugin does", + "examples": ["SQL query execution against Databricks SQL Warehouses"] + }, + "resources": { + "type": "object", + "required": ["required", "optional"], + "description": "Databricks resource requirements for this plugin", + "properties": { + "required": { + "type": "array", + "description": "Resources that must be available for the plugin to function", + "items": { + "$ref": "#/$defs/resourceRequirement" + } + }, + "optional": { + "type": "array", + "description": "Resources that enhance functionality but are not mandatory", + "items": { + "$ref": "#/$defs/resourceRequirement" + } + } + }, + "additionalProperties": false + }, + "config": { + "type": "object", + "description": "Configuration schema for the plugin", + "properties": { + "schema": { + "$ref": "#/$defs/configSchema" + } + }, + "additionalProperties": false + }, + "author": { + "type": "string", + "description": "Author name or organization" + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.]+)?$", + "description": "Plugin version (semver format)", + "examples": ["1.0.0", "2.1.0-beta.1"] + }, + "repository": { + "type": "string", + "format": "uri", + "description": "URL to the plugin's source repository" + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Keywords for plugin discovery" + }, + "license": { + "type": "string", + "description": "SPDX license identifier", + "examples": ["Apache-2.0", "MIT"] + } + }, + "additionalProperties": false, + "$defs": { + "resourceType": { + "type": "string", + "enum": [ + "sql-warehouse", + "lakebase", + "job", + "secret-scope", + "serving-endpoint", + "vector-search-index", + "unity-catalog" + ], + "description": "Type of Databricks resource" + }, + "resourcePermission": { + "type": "string", + "enum": ["CAN_USE", "CAN_MANAGE", "CAN_VIEW", "READ", "WRITE", "EXECUTE"], + "description": "Permission level required for the resource" + }, + "resourceRequirement": { + "type": "object", + "required": ["type", "alias", "description", "permission"], + "properties": { + "type": { + "$ref": "#/$defs/resourceType" + }, + "alias": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9_]*$", + "description": "Unique alias for this resource within the plugin", + "examples": ["warehouse", "secrets", "vectorIndex"] + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Human-readable description of why this resource is needed" + }, + "permission": { + "$ref": "#/$defs/resourcePermission" + }, + "env": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$", + "description": "Environment variable name where the resource ID should be provided", + "examples": ["DATABRICKS_WAREHOUSE_ID", "DATABRICKS_SECRET_SCOPE"] + } + }, + "additionalProperties": false + }, + "configSchemaProperty": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["object", "array", "string", "number", "boolean", "integer"] + }, + "description": { + "type": "string" + }, + "default": {}, + "enum": { + "type": "array" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/configSchemaProperty" + } + }, + "items": { + "$ref": "#/$defs/configSchemaProperty" + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "minLength": { + "type": "integer", + "minimum": 0 + }, + "maxLength": { + "type": "integer", + "minimum": 0 + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "configSchema": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["object", "array", "string", "number", "boolean"] + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/configSchemaProperty" + } + }, + "items": { + "$ref": "#/$defs/configSchema" + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + }, + "additionalProperties": { + "type": "boolean" + } + } + } + } +} diff --git a/packages/appkit/src/core/appkit.ts b/packages/appkit/src/core/appkit.ts index 44b5cb10..1c0f34ca 100644 --- a/packages/appkit/src/core/appkit.ts +++ b/packages/appkit/src/core/appkit.ts @@ -161,9 +161,9 @@ export class AppKit { const rawPlugins = config.plugins as T; - // Phase 1a: Collect resource requirements from all plugins const registry = ResourceRegistry.getInstance(); - registry.clear(); // Clear any previous state + + registry.clear(); for (const pluginData of rawPlugins) { if (!pluginData?.plugin) continue; diff --git a/packages/appkit/src/plugins/analytics/manifest.json b/packages/appkit/src/plugins/analytics/manifest.json new file mode 100644 index 00000000..69cfb81d --- /dev/null +++ b/packages/appkit/src/plugins/analytics/manifest.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + "name": "analytics", + "displayName": "Analytics Plugin", + "description": "SQL query execution against Databricks SQL Warehouses", + "resources": { + "required": [ + { + "type": "sql-warehouse", + "alias": "warehouse", + "description": "SQL Warehouse for executing analytics queries", + "permission": "CAN_USE", + "env": "DATABRICKS_WAREHOUSE_ID" + } + ], + "optional": [] + }, + "config": { + "schema": { + "type": "object", + "properties": { + "timeout": { + "type": "number", + "default": 30000, + "description": "Query execution timeout in milliseconds" + }, + "queriesDir": { + "type": "string", + "description": "Directory containing SQL query files" + }, + "cacheEnabled": { + "type": "boolean", + "default": true, + "description": "Enable query result caching" + } + } + } + } +} diff --git a/packages/appkit/src/plugins/analytics/manifest.ts b/packages/appkit/src/plugins/analytics/manifest.ts index bc431b93..fe74e345 100644 --- a/packages/appkit/src/plugins/analytics/manifest.ts +++ b/packages/appkit/src/plugins/analytics/manifest.ts @@ -1,49 +1,20 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import type { PluginManifest } from "../../registry"; -import { ResourceType } from "../../registry"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); /** * Analytics plugin manifest. * * The analytics plugin requires a SQL Warehouse for executing queries * against Databricks data sources. + * + * @remarks + * The source of truth for this manifest is `manifest.json` in the same directory. + * This file loads the JSON and exports it with proper TypeScript typing. */ -export const analyticsManifest: PluginManifest = { - name: "analytics", - displayName: "Analytics Plugin", - description: "SQL query execution against Databricks SQL Warehouses", - - resources: { - required: [ - { - type: ResourceType.SQL_WAREHOUSE, - alias: "warehouse", - description: "SQL Warehouse for executing analytics queries", - permission: "CAN_USE", - env: "DATABRICKS_WAREHOUSE_ID", - }, - ], - optional: [], - }, - - config: { - schema: { - type: "object", - properties: { - timeout: { - type: "number", - default: 30000, - description: "Query execution timeout in milliseconds", - }, - queriesDir: { - type: "string", - description: "Directory containing SQL query files", - }, - cacheEnabled: { - type: "boolean", - default: true, - description: "Enable query result caching", - }, - }, - }, - }, -}; +export const analyticsManifest: PluginManifest = JSON.parse( + readFileSync(join(__dirname, "manifest.json"), "utf-8"), +) as PluginManifest; diff --git a/packages/appkit/src/plugins/server/manifest.json b/packages/appkit/src/plugins/server/manifest.json new file mode 100644 index 00000000..11822beb --- /dev/null +++ b/packages/appkit/src/plugins/server/manifest.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + "name": "server", + "displayName": "Server Plugin", + "description": "HTTP server with Express, static file serving, and Vite dev mode support", + "resources": { + "required": [], + "optional": [] + }, + "config": { + "schema": { + "type": "object", + "properties": { + "autoStart": { + "type": "boolean", + "default": true, + "description": "Automatically start the server on plugin setup" + }, + "host": { + "type": "string", + "default": "0.0.0.0", + "description": "Host address to bind the server to" + }, + "port": { + "type": "number", + "default": 8000, + "description": "Port number for the server" + }, + "staticPath": { + "type": "string", + "description": "Path to static files directory (auto-detected if not provided)" + } + } + } + } +} diff --git a/packages/appkit/src/plugins/server/manifest.ts b/packages/appkit/src/plugins/server/manifest.ts index 0973230e..97a4c716 100644 --- a/packages/appkit/src/plugins/server/manifest.ts +++ b/packages/appkit/src/plugins/server/manifest.ts @@ -1,47 +1,20 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import type { PluginManifest } from "../../registry"; +const __dirname = dirname(fileURLToPath(import.meta.url)); + /** * Server plugin manifest. * * The server plugin doesn't require any Databricks resources - it only * provides HTTP server functionality and static file serving. + * + * @remarks + * The source of truth for this manifest is `manifest.json` in the same directory. + * This file loads the JSON and exports it with proper TypeScript typing. */ -export const serverManifest: PluginManifest = { - name: "server", - displayName: "Server Plugin", - description: - "HTTP server with Express, static file serving, and Vite dev mode support", - - resources: { - required: [], - optional: [], - }, - - config: { - schema: { - type: "object", - properties: { - autoStart: { - type: "boolean", - default: true, - description: "Automatically start the server on plugin setup", - }, - host: { - type: "string", - default: "0.0.0.0", - description: "Host address to bind the server to", - }, - port: { - type: "number", - default: 8000, - description: "Port number for the server", - }, - staticPath: { - type: "string", - description: - "Path to static files directory (auto-detected if not provided)", - }, - }, - }, - }, -}; +export const serverManifest: PluginManifest = JSON.parse( + readFileSync(join(__dirname, "manifest.json"), "utf-8"), +) as PluginManifest; diff --git a/packages/appkit/src/plugins/server/tests/server.test.ts b/packages/appkit/src/plugins/server/tests/server.test.ts index 4a389e7e..31305fc7 100644 --- a/packages/appkit/src/plugins/server/tests/server.test.ts +++ b/packages/appkit/src/plugins/server/tests/server.test.ts @@ -142,13 +142,17 @@ vi.mock("dotenv", () => ({ default: { config: vi.fn() }, })); -// Mock fs for findStaticPath -vi.mock("node:fs", () => ({ - default: { - existsSync: vi.fn().mockReturnValue(false), - readFileSync: vi.fn(), - }, -})); +// Mock fs for findStaticPath and manifest loading +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { + existsSync: vi.fn().mockReturnValue(false), + readFileSync: actual.readFileSync, + }, + }; +}); vi.mock("../utils", () => ({ getRoutes: vi.fn().mockReturnValue([]), diff --git a/packages/appkit/src/registry/index.ts b/packages/appkit/src/registry/index.ts index 688a2f8b..1bbdd478 100644 --- a/packages/appkit/src/registry/index.ts +++ b/packages/appkit/src/registry/index.ts @@ -8,9 +8,26 @@ * - Type definitions for resources, manifests, and validation * - Manifest loader for reading plugin declarations * - ResourceRegistry singleton for tracking requirements across all plugins + * - JSON Schema for validating plugin manifests * - (Future) Config generators for app.yaml, databricks.yml, .env.example */ export { getPluginManifest, getResourceRequirements } from "./manifest-loader"; export { ResourceRegistry } from "./resource-registry"; export * from "./types"; + +/** + * URL to the plugin manifest JSON Schema hosted on GitHub Pages. + * Can be used for validation or referenced in manifest files. + * + * @example + * ```json + * { + * "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + * "name": "my-plugin", + * ... + * } + * ``` + */ +export const MANIFEST_SCHEMA_ID = + "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json"; diff --git a/packages/appkit/src/registry/schemas/plugin-manifest.schema.json b/packages/appkit/src/registry/schemas/plugin-manifest.schema.json new file mode 100644 index 00000000..8c865b26 --- /dev/null +++ b/packages/appkit/src/registry/schemas/plugin-manifest.schema.json @@ -0,0 +1,216 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + "title": "AppKit Plugin Manifest", + "description": "Schema for Databricks AppKit plugin manifest files. Defines plugin metadata, resource requirements, and configuration options.", + "type": "object", + "required": ["name", "displayName", "description", "resources"], + "properties": { + "$schema": { + "type": "string", + "description": "Reference to the JSON Schema for validation" + }, + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Plugin identifier. Must be lowercase, start with a letter, and contain only letters, numbers, and hyphens.", + "examples": ["analytics", "server", "my-custom-plugin"] + }, + "displayName": { + "type": "string", + "minLength": 1, + "description": "Human-readable display name for UI and CLI", + "examples": ["Analytics Plugin", "Server Plugin"] + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Brief description of what the plugin does", + "examples": ["SQL query execution against Databricks SQL Warehouses"] + }, + "resources": { + "type": "object", + "required": ["required", "optional"], + "description": "Databricks resource requirements for this plugin", + "properties": { + "required": { + "type": "array", + "description": "Resources that must be available for the plugin to function", + "items": { + "$ref": "#/$defs/resourceRequirement" + } + }, + "optional": { + "type": "array", + "description": "Resources that enhance functionality but are not mandatory", + "items": { + "$ref": "#/$defs/resourceRequirement" + } + } + }, + "additionalProperties": false + }, + "config": { + "type": "object", + "description": "Configuration schema for the plugin", + "properties": { + "schema": { + "$ref": "#/$defs/configSchema" + } + }, + "additionalProperties": false + }, + "author": { + "type": "string", + "description": "Author name or organization" + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.]+)?$", + "description": "Plugin version (semver format)", + "examples": ["1.0.0", "2.1.0-beta.1"] + }, + "repository": { + "type": "string", + "format": "uri", + "description": "URL to the plugin's source repository" + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Keywords for plugin discovery" + }, + "license": { + "type": "string", + "description": "SPDX license identifier", + "examples": ["Apache-2.0", "MIT"] + } + }, + "additionalProperties": false, + "$defs": { + "resourceType": { + "type": "string", + "enum": [ + "sql-warehouse", + "lakebase", + "job", + "secret-scope", + "serving-endpoint", + "vector-search-index", + "unity-catalog" + ], + "description": "Type of Databricks resource" + }, + "resourcePermission": { + "type": "string", + "enum": ["CAN_USE", "CAN_MANAGE", "CAN_VIEW", "READ", "WRITE", "EXECUTE"], + "description": "Permission level required for the resource" + }, + "resourceRequirement": { + "type": "object", + "required": ["type", "alias", "description", "permission"], + "properties": { + "type": { + "$ref": "#/$defs/resourceType" + }, + "alias": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9_]*$", + "description": "Unique alias for this resource within the plugin", + "examples": ["warehouse", "secrets", "vectorIndex"] + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Human-readable description of why this resource is needed" + }, + "permission": { + "$ref": "#/$defs/resourcePermission" + }, + "env": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$", + "description": "Environment variable name where the resource ID should be provided", + "examples": ["DATABRICKS_WAREHOUSE_ID", "DATABRICKS_SECRET_SCOPE"] + } + }, + "additionalProperties": false + }, + "configSchemaProperty": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["object", "array", "string", "number", "boolean", "integer"] + }, + "description": { + "type": "string" + }, + "default": {}, + "enum": { + "type": "array" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/configSchemaProperty" + } + }, + "items": { + "$ref": "#/$defs/configSchemaProperty" + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "minLength": { + "type": "integer", + "minimum": 0 + }, + "maxLength": { + "type": "integer", + "minimum": 0 + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "configSchema": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["object", "array", "string", "number", "boolean"] + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/configSchemaProperty" + } + }, + "items": { + "$ref": "#/$defs/configSchema" + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + }, + "additionalProperties": { + "type": "boolean" + } + } + } + } +} diff --git a/packages/appkit/tsdown.config.ts b/packages/appkit/tsdown.config.ts index 414efbb2..fb6cafe8 100644 --- a/packages/appkit/tsdown.config.ts +++ b/packages/appkit/tsdown.config.ts @@ -37,6 +37,20 @@ export default defineConfig([ from: "src/plugins/server/remote-tunnel/denied.html", to: "dist/plugins/server/remote-tunnel/denied.html", }, + // Plugin manifest JSON files (source of truth for static analysis) + { + from: "src/plugins/analytics/manifest.json", + to: "dist/plugins/analytics/manifest.json", + }, + { + from: "src/plugins/server/manifest.json", + to: "dist/plugins/server/manifest.json", + }, + // JSON Schema for plugin manifests + { + from: "src/registry/schemas/plugin-manifest.schema.json", + to: "dist/registry/schemas/plugin-manifest.schema.json", + }, ], }, ]); From f3aca2e97f63dc651afbf7b7e86bc73b2033b98f Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 5 Feb 2026 13:09:05 +0100 Subject: [PATCH 3/6] chore: fixup --- packages/appkit/src/core/appkit.ts | 1 - packages/appkit/src/registry/index.ts | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/appkit/src/core/appkit.ts b/packages/appkit/src/core/appkit.ts index 1c0f34ca..76090f25 100644 --- a/packages/appkit/src/core/appkit.ts +++ b/packages/appkit/src/core/appkit.ts @@ -212,7 +212,6 @@ export class AppKit { } } - // Phase 1b: Validate resources const validation = registry.validate(); const isDevelopment = process.env.NODE_ENV === "development"; diff --git a/packages/appkit/src/registry/index.ts b/packages/appkit/src/registry/index.ts index 1bbdd478..bc543027 100644 --- a/packages/appkit/src/registry/index.ts +++ b/packages/appkit/src/registry/index.ts @@ -29,5 +29,8 @@ export * from "./types"; * } * ``` */ +// TODO: We may want to open a PR to https://github.com/SchemaStore/schemastore +// export const MANIFEST_SCHEMA_ID = +// "https://json.schemastore.org/databricks-appkit-plugin-manifest.json"; export const MANIFEST_SCHEMA_ID = "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json"; From de23e5c1f3d9ca80c78d5d920ea84ac37ccbf35b Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 5 Feb 2026 15:26:52 +0100 Subject: [PATCH 4/6] chore: fixup --- docs/docs/plugins.md | 52 ++++++++++++++++++++ packages/appkit/src/plugin/plugin.ts | 71 +++++++++++++++++++++------- 2 files changed, 105 insertions(+), 18 deletions(-) diff --git a/docs/docs/plugins.md b/docs/docs/plugins.md index d636f865..62a8204d 100644 --- a/docs/docs/plugins.md +++ b/docs/docs/plugins.md @@ -259,6 +259,58 @@ export const myPlugin = toPlugin, "myPlug ); ``` +### Config-dependent resources + +The manifest defines resources as either `required` (always needed) or `optional` (may be needed). +For resources that become required based on plugin configuration, implement a static +`getResourceRequirements(config)` method: + +```typescript +interface MyPluginConfig extends BasePluginConfig { + enableCaching?: boolean; +} + +class MyPlugin extends Plugin { + name = "myPlugin"; + + static manifest = { + name: "myPlugin", + displayName: "My Plugin", + description: "A plugin with optional caching", + resources: { + required: [ + { type: "sql-warehouse", alias: "warehouse", description: "Query execution", permission: "CAN_USE" } + ], + optional: [ + // Listed as optional in manifest for static analysis + { type: "lakebase", alias: "cache", description: "Query result caching (if enabled)", permission: "CAN_USE" } + ] + } + }; + + // Runtime: Convert optional resources to required based on config + static getResourceRequirements(config: MyPluginConfig) { + const resources = []; + if (config.enableCaching) { + // When caching is enabled, Lakebase becomes required + resources.push({ + type: "lakebase", + alias: "cache", + description: "Query result caching", + permission: "CAN_USE", + env: "DATABRICKS_LAKEBASE_ID", + required: true // Mark as required at runtime + }); + } + return resources; + } +} +``` + +This pattern allows: +- **Static tools** (CLI, docs) to show all possible resources +- **Runtime validation** to enforce resources based on actual configuration + ### Key extension points - **Route injection**: Implement `injectRoutes()` to add custom endpoints using [`IAppRouter`](api/appkit/TypeAlias.IAppRouter.md) diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts index 908a53a0..d9abfebe 100644 --- a/packages/appkit/src/plugin/plugin.ts +++ b/packages/appkit/src/plugin/plugin.ts @@ -62,46 +62,81 @@ const EXCLUDED_FROM_PROXY = new Set([ * Base abstract class for creating AppKit plugins. * * All plugins must declare a static `manifest` property with their metadata - * and resource requirements. Plugins can also implement a static - * `getResourceRequirements()` method for dynamic requirements based on config. + * and resource requirements. The manifest defines: + * - `required` resources: Always needed for the plugin to function + * - `optional` resources: May be needed depending on plugin configuration * - * @example + * ## Static vs Runtime Resource Requirements + * + * The manifest is static and doesn't know the plugin's runtime configuration. + * For resources that become required based on config options, plugins can + * implement a static `getResourceRequirements(config)` method. + * + * At runtime, this method is called with the actual config to determine + * which "optional" resources should be treated as "required". + * + * @example Basic plugin with static requirements * ```typescript * import { Plugin, toPlugin, PluginManifest, ResourceType } from '@databricks/appkit'; * - * // Define manifest (required) * const myManifest: PluginManifest = { * name: 'myPlugin', * displayName: 'My Plugin', * description: 'Does something awesome', * resources: { * required: [ - * { - * type: ResourceType.SQL_WAREHOUSE, - * alias: 'warehouse', - * description: 'SQL Warehouse for queries', - * permission: 'CAN_USE', - * env: 'DATABRICKS_WAREHOUSE_ID' - * } + * { type: ResourceType.SQL_WAREHOUSE, alias: 'warehouse', ... } * ], * optional: [] * } * }; * * class MyPlugin extends Plugin { - * static manifest = myManifest; // Required! + * static manifest = myManifest; * name = 'myPlugin'; + * } + * ``` * - * async setup() { - * // Initialize your plugin + * @example Plugin with config-dependent resources + * ```typescript + * interface MyConfig extends BasePluginConfig { + * enableCaching?: boolean; + * } + * + * const myManifest: PluginManifest = { + * name: 'myPlugin', + * resources: { + * required: [ + * { type: ResourceType.SQL_WAREHOUSE, alias: 'warehouse', ... } + * ], + * optional: [ + * // Lakebase is optional in the static manifest + * { type: ResourceType.LAKEBASE, alias: 'cache', description: 'Required if caching enabled', ... } + * ] * } + * }; * - * injectRoutes(router: Router) { - * // Register HTTP endpoints + * class MyPlugin extends Plugin { + * static manifest = myManifest; + * name = 'myPlugin'; + * + * // Runtime method: converts optional resources to required based on config + * static getResourceRequirements(config: MyConfig) { + * const resources = []; + * if (config.enableCaching) { + * // When caching is enabled, Lakebase becomes required + * resources.push({ + * type: ResourceType.LAKEBASE, + * alias: 'cache', + * description: 'Cache storage for query results', + * permission: 'CAN_USE', + * env: 'DATABRICKS_LAKEBASE_ID', + * required: true // Mark as required at runtime + * }); + * } + * return resources; * } * } - * - * export const myPlugin = toPlugin(MyPlugin, 'myPlugin'); * ``` */ export abstract class Plugin< From ddef0cb9a0c91f9a7e065ab71df6c6c1f34e1b95 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 5 Feb 2026 16:43:53 +0100 Subject: [PATCH 5/6] chore: fixup --- docs/docs/plugins.md | 20 ++-- packages/appkit/src/plugin/plugin.ts | 12 +- .../src/plugins/analytics/manifest.json | 2 +- .../schemas/plugin-manifest.schema.json | 94 +++++++++++++-- .../registry/tests/manifest-loader.test.ts | 8 +- packages/appkit/src/registry/types.ts | 109 ++++++++++++++---- 6 files changed, 199 insertions(+), 46 deletions(-) diff --git a/docs/docs/plugins.md b/docs/docs/plugins.md index 62a8204d..16475245 100644 --- a/docs/docs/plugins.md +++ b/docs/docs/plugins.md @@ -227,7 +227,13 @@ class MyPlugin extends Plugin { description: "A custom plugin", resources: { required: [ - { type: "SECRET_SCOPE", alias: "MY_API_KEY" } + { + type: "secret", + alias: "apiKey", + description: "API key for external service", + permission: "READ", + env: "MY_API_KEY" + } ], optional: [] } @@ -279,11 +285,11 @@ class MyPlugin extends Plugin { description: "A plugin with optional caching", resources: { required: [ - { type: "sql-warehouse", alias: "warehouse", description: "Query execution", permission: "CAN_USE" } + { type: "sql_warehouse", alias: "warehouse", description: "Query execution", permission: "CAN_USE" } ], optional: [ // Listed as optional in manifest for static analysis - { type: "lakebase", alias: "cache", description: "Query result caching (if enabled)", permission: "CAN_USE" } + { type: "database", alias: "cache", description: "Query result caching (if enabled)", permission: "CAN_CONNECT_AND_CREATE" } ] } }; @@ -292,13 +298,13 @@ class MyPlugin extends Plugin { static getResourceRequirements(config: MyPluginConfig) { const resources = []; if (config.enableCaching) { - // When caching is enabled, Lakebase becomes required + // When caching is enabled, Database becomes required resources.push({ - type: "lakebase", + type: "database", alias: "cache", description: "Query result caching", - permission: "CAN_USE", - env: "DATABRICKS_LAKEBASE_ID", + permission: "CAN_CONNECT_AND_CREATE", + env: "DATABRICKS_DATABASE_ID", required: true // Mark as required at runtime }); } diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts index d9abfebe..3ef7a79f 100644 --- a/packages/appkit/src/plugin/plugin.ts +++ b/packages/appkit/src/plugin/plugin.ts @@ -110,8 +110,8 @@ const EXCLUDED_FROM_PROXY = new Set([ * { type: ResourceType.SQL_WAREHOUSE, alias: 'warehouse', ... } * ], * optional: [ - * // Lakebase is optional in the static manifest - * { type: ResourceType.LAKEBASE, alias: 'cache', description: 'Required if caching enabled', ... } + * // Database is optional in the static manifest + * { type: ResourceType.DATABASE, alias: 'cache', description: 'Required if caching enabled', ... } * ] * } * }; @@ -124,13 +124,13 @@ const EXCLUDED_FROM_PROXY = new Set([ * static getResourceRequirements(config: MyConfig) { * const resources = []; * if (config.enableCaching) { - * // When caching is enabled, Lakebase becomes required + * // When caching is enabled, Database becomes required * resources.push({ - * type: ResourceType.LAKEBASE, + * type: ResourceType.DATABASE, * alias: 'cache', * description: 'Cache storage for query results', - * permission: 'CAN_USE', - * env: 'DATABRICKS_LAKEBASE_ID', + * permission: 'CAN_CONNECT_AND_CREATE', + * env: 'DATABRICKS_DATABASE_ID', * required: true // Mark as required at runtime * }); * } diff --git a/packages/appkit/src/plugins/analytics/manifest.json b/packages/appkit/src/plugins/analytics/manifest.json index 69cfb81d..5ccc695f 100644 --- a/packages/appkit/src/plugins/analytics/manifest.json +++ b/packages/appkit/src/plugins/analytics/manifest.json @@ -6,7 +6,7 @@ "resources": { "required": [ { - "type": "sql-warehouse", + "type": "sql_warehouse", "alias": "warehouse", "description": "SQL Warehouse for executing analytics queries", "permission": "CAN_USE", diff --git a/packages/appkit/src/registry/schemas/plugin-manifest.schema.json b/packages/appkit/src/registry/schemas/plugin-manifest.schema.json index 8c865b26..d1f93b74 100644 --- a/packages/appkit/src/registry/schemas/plugin-manifest.schema.json +++ b/packages/appkit/src/registry/schemas/plugin-manifest.schema.json @@ -93,20 +93,98 @@ "resourceType": { "type": "string", "enum": [ - "sql-warehouse", - "lakebase", + "secret", "job", - "secret-scope", - "serving-endpoint", - "vector-search-index", - "unity-catalog" + "sql_warehouse", + "serving_endpoint", + "volume", + "vector_search_index", + "uc_function", + "uc_connection", + "database", + "genie_space", + "experiment", + "app" ], "description": "Type of Databricks resource" }, + "secretPermission": { + "type": "string", + "enum": ["MANAGE", "READ", "WRITE"], + "description": "Permission for secret resources" + }, + "jobPermission": { + "type": "string", + "enum": ["CAN_MANAGE", "CAN_MANAGE_RUN", "CAN_VIEW"], + "description": "Permission for job resources" + }, + "sqlWarehousePermission": { + "type": "string", + "enum": ["CAN_MANAGE", "CAN_USE"], + "description": "Permission for SQL warehouse resources" + }, + "servingEndpointPermission": { + "type": "string", + "enum": ["CAN_MANAGE", "CAN_QUERY", "CAN_VIEW"], + "description": "Permission for serving endpoint resources" + }, + "volumePermission": { + "type": "string", + "enum": ["READ_VOLUME", "WRITE_VOLUME"], + "description": "Permission for Unity Catalog volume resources" + }, + "vectorSearchIndexPermission": { + "type": "string", + "enum": ["SELECT"], + "description": "Permission for vector search index resources" + }, + "ucFunctionPermission": { + "type": "string", + "enum": ["EXECUTE"], + "description": "Permission for Unity Catalog function resources" + }, + "ucConnectionPermission": { + "type": "string", + "enum": ["USE_CONNECTION"], + "description": "Permission for Unity Catalog connection resources" + }, + "databasePermission": { + "type": "string", + "enum": ["CAN_CONNECT_AND_CREATE"], + "description": "Permission for database resources" + }, + "genieSpacePermission": { + "type": "string", + "enum": ["CAN_EDIT", "CAN_VIEW", "CAN_RUN", "CAN_MANAGE"], + "description": "Permission for Genie Space resources" + }, + "experimentPermission": { + "type": "string", + "enum": ["CAN_READ", "CAN_EDIT", "CAN_MANAGE"], + "description": "Permission for MLflow experiment resources" + }, + "appPermission": { + "type": "string", + "enum": ["CAN_USE"], + "description": "Permission for Databricks App resources" + }, "resourcePermission": { "type": "string", - "enum": ["CAN_USE", "CAN_MANAGE", "CAN_VIEW", "READ", "WRITE", "EXECUTE"], - "description": "Permission level required for the resource" + "description": "Permission level required for the resource. Valid values depend on resource type.", + "oneOf": [ + { "$ref": "#/$defs/secretPermission" }, + { "$ref": "#/$defs/jobPermission" }, + { "$ref": "#/$defs/sqlWarehousePermission" }, + { "$ref": "#/$defs/servingEndpointPermission" }, + { "$ref": "#/$defs/volumePermission" }, + { "$ref": "#/$defs/vectorSearchIndexPermission" }, + { "$ref": "#/$defs/ucFunctionPermission" }, + { "$ref": "#/$defs/ucConnectionPermission" }, + { "$ref": "#/$defs/databasePermission" }, + { "$ref": "#/$defs/genieSpacePermission" }, + { "$ref": "#/$defs/experimentPermission" }, + { "$ref": "#/$defs/appPermission" } + ] }, "resourceRequirement": { "type": "object", diff --git a/packages/appkit/src/registry/tests/manifest-loader.test.ts b/packages/appkit/src/registry/tests/manifest-loader.test.ts index 0578b9dc..d21904da 100644 --- a/packages/appkit/src/registry/tests/manifest-loader.test.ts +++ b/packages/appkit/src/registry/tests/manifest-loader.test.ts @@ -195,7 +195,7 @@ describe("Manifest Loader", () => { required: [], optional: [ { - type: ResourceType.SECRET_SCOPE, + type: ResourceType.SECRET, alias: "secrets", description: "Optional secrets", permission: "READ", @@ -270,7 +270,7 @@ describe("Manifest Loader", () => { required: [], optional: [ { - type: ResourceType.SECRET_SCOPE, + type: ResourceType.SECRET, alias: "secrets", description: "Optional secrets", permission: "READ", @@ -289,7 +289,7 @@ describe("Manifest Loader", () => { ); expect(resources).toHaveLength(1); expect(resources[0]).toMatchObject({ - type: ResourceType.SECRET_SCOPE, + type: ResourceType.SECRET, alias: "secrets", required: false, }); @@ -312,7 +312,7 @@ describe("Manifest Loader", () => { ], optional: [ { - type: ResourceType.SECRET_SCOPE, + type: ResourceType.SECRET, alias: "secrets", description: "Optional secrets", permission: "READ", diff --git a/packages/appkit/src/registry/types.ts b/packages/appkit/src/registry/types.ts index 331fc8fe..30ad728e 100644 --- a/packages/appkit/src/registry/types.ts +++ b/packages/appkit/src/registry/types.ts @@ -4,45 +4,114 @@ * This module defines the type system for the AppKit Resource Registry, * which enables plugins to declare their Databricks resource requirements * in a machine-readable format. + * + * Resource types are exposed as first-class citizens with their specific + * permissions, making it simple for users to declare dependencies. + * Internal tooling handles conversion to Databricks app.yaml format. */ /** * Supported resource types that plugins can depend on. + * Each type has its own set of valid permissions. */ export enum ResourceType { - /** Databricks SQL Warehouse for query execution */ - SQL_WAREHOUSE = "sql-warehouse", - - /** Lakebase instance for persistent caching or data storage */ - LAKEBASE = "lakebase", + /** Secret scope for secure credential storage */ + SECRET = "secret", /** Databricks Job for scheduled or triggered workflows */ JOB = "job", - /** Secret scope for secure credential storage */ - SECRET_SCOPE = "secret-scope", + /** Databricks SQL Warehouse for query execution */ + SQL_WAREHOUSE = "sql_warehouse", /** Model serving endpoint for ML inference */ - SERVING_ENDPOINT = "serving-endpoint", + SERVING_ENDPOINT = "serving_endpoint", + + /** Unity Catalog Volume for file storage */ + VOLUME = "volume", - /** Vector search index for similarity search */ - VECTOR_SEARCH_INDEX = "vector-search-index", + /** Vector Search Index for similarity search */ + VECTOR_SEARCH_INDEX = "vector_search_index", - /** Unity Catalog for data governance and metadata */ - UNITY_CATALOG = "unity-catalog", + /** Unity Catalog Function */ + UC_FUNCTION = "uc_function", + + /** Unity Catalog Connection for external data sources */ + UC_CONNECTION = "uc_connection", + + /** Database (Lakebase) for persistent storage */ + DATABASE = "database", + + /** Genie Space for AI assistant */ + GENIE_SPACE = "genie_space", + + /** MLflow Experiment for ML tracking */ + EXPERIMENT = "experiment", + + /** Databricks App dependency */ + APP = "app", } +// ============================================================================ +// Permissions per resource type +// ============================================================================ + +/** Permissions for SECRET resources */ +export type SecretPermission = "MANAGE" | "READ" | "WRITE"; + +/** Permissions for JOB resources */ +export type JobPermission = "CAN_MANAGE" | "CAN_MANAGE_RUN" | "CAN_VIEW"; + +/** Permissions for SQL_WAREHOUSE resources */ +export type SqlWarehousePermission = "CAN_MANAGE" | "CAN_USE"; + +/** Permissions for SERVING_ENDPOINT resources */ +export type ServingEndpointPermission = "CAN_MANAGE" | "CAN_QUERY" | "CAN_VIEW"; + +/** Permissions for VOLUME resources */ +export type VolumePermission = "READ_VOLUME" | "WRITE_VOLUME"; + +/** Permissions for VECTOR_SEARCH_INDEX resources */ +export type VectorSearchIndexPermission = "SELECT"; + +/** Permissions for UC_FUNCTION resources */ +export type UcFunctionPermission = "EXECUTE"; + +/** Permissions for UC_CONNECTION resources */ +export type UcConnectionPermission = "USE_CONNECTION"; + +/** Permissions for DATABASE resources */ +export type DatabasePermission = "CAN_CONNECT_AND_CREATE"; + +/** Permissions for GENIE_SPACE resources */ +export type GenieSpacePermission = + | "CAN_EDIT" + | "CAN_VIEW" + | "CAN_RUN" + | "CAN_MANAGE"; + +/** Permissions for EXPERIMENT resources */ +export type ExperimentPermission = "CAN_READ" | "CAN_EDIT" | "CAN_MANAGE"; + +/** Permissions for APP resources */ +export type AppPermission = "CAN_USE"; + /** - * Permission levels that can be required for a resource. - * Based on Databricks permission model. + * Union of all possible permission levels across all resource types. */ export type ResourcePermission = - | "CAN_USE" - | "CAN_MANAGE" - | "CAN_VIEW" - | "READ" - | "WRITE" - | "EXECUTE"; + | SecretPermission + | JobPermission + | SqlWarehousePermission + | ServingEndpointPermission + | VolumePermission + | VectorSearchIndexPermission + | UcFunctionPermission + | UcConnectionPermission + | DatabasePermission + | GenieSpacePermission + | ExperimentPermission + | AppPermission; /** * Declares a resource requirement for a plugin. From 0a59be7942b63e56df8303dd6e0e4064faf46c06 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 5 Feb 2026 16:46:48 +0100 Subject: [PATCH 6/6] chore: fixup --- docs/docs/api/appkit/Class.Plugin.md | 70 ++++++++++---- .../api/appkit/Enumeration.ResourceType.md | 81 +++++++++++++--- .../appkit/TypeAlias.ResourcePermission.md | 17 +++- docs/docs/api/appkit/index.md | 4 +- .../schemas/plugin-manifest.schema.json | 94 +++++++++++++++++-- 5 files changed, 220 insertions(+), 46 deletions(-) diff --git a/docs/docs/api/appkit/Class.Plugin.md b/docs/docs/api/appkit/Class.Plugin.md index 8372513e..6dcb91c3 100644 --- a/docs/docs/api/appkit/Class.Plugin.md +++ b/docs/docs/api/appkit/Class.Plugin.md @@ -3,47 +3,81 @@ Base abstract class for creating AppKit plugins. All plugins must declare a static `manifest` property with their metadata -and resource requirements. Plugins can also implement a static -`getResourceRequirements()` method for dynamic requirements based on config. +and resource requirements. The manifest defines: +- `required` resources: Always needed for the plugin to function +- `optional` resources: May be needed depending on plugin configuration -## Example +## Static vs Runtime Resource Requirements + +The manifest is static and doesn't know the plugin's runtime configuration. +For resources that become required based on config options, plugins can +implement a static `getResourceRequirements(config)` method. + +At runtime, this method is called with the actual config to determine +which "optional" resources should be treated as "required". + +## Examples ```typescript import { Plugin, toPlugin, PluginManifest, ResourceType } from '@databricks/appkit'; -// Define manifest (required) const myManifest: PluginManifest = { name: 'myPlugin', displayName: 'My Plugin', description: 'Does something awesome', resources: { required: [ - { - type: ResourceType.SQL_WAREHOUSE, - alias: 'warehouse', - description: 'SQL Warehouse for queries', - permission: 'CAN_USE', - env: 'DATABRICKS_WAREHOUSE_ID' - } + { type: ResourceType.SQL_WAREHOUSE, alias: 'warehouse', ... } ], optional: [] } }; class MyPlugin extends Plugin { - static manifest = myManifest; // Required! + static manifest = myManifest; name = 'myPlugin'; +} +``` - async setup() { - // Initialize your plugin +```typescript +interface MyConfig extends BasePluginConfig { + enableCaching?: boolean; +} + +const myManifest: PluginManifest = { + name: 'myPlugin', + resources: { + required: [ + { type: ResourceType.SQL_WAREHOUSE, alias: 'warehouse', ... } + ], + optional: [ + // Database is optional in the static manifest + { type: ResourceType.DATABASE, alias: 'cache', description: 'Required if caching enabled', ... } + ] } +}; - injectRoutes(router: Router) { - // Register HTTP endpoints +class MyPlugin extends Plugin { + static manifest = myManifest; + name = 'myPlugin'; + + // Runtime method: converts optional resources to required based on config + static getResourceRequirements(config: MyConfig) { + const resources = []; + if (config.enableCaching) { + // When caching is enabled, Database becomes required + resources.push({ + type: ResourceType.DATABASE, + alias: 'cache', + description: 'Cache storage for query results', + permission: 'CAN_CONNECT_AND_CREATE', + env: 'DATABRICKS_DATABASE_ID', + required: true // Mark as required at runtime + }); + } + return resources; } } - -export const myPlugin = toPlugin(MyPlugin, 'myPlugin'); ``` ## Type Parameters diff --git a/docs/docs/api/appkit/Enumeration.ResourceType.md b/docs/docs/api/appkit/Enumeration.ResourceType.md index 65d59812..bb2d12db 100644 --- a/docs/docs/api/appkit/Enumeration.ResourceType.md +++ b/docs/docs/api/appkit/Enumeration.ResourceType.md @@ -1,33 +1,64 @@ # Enumeration: ResourceType Supported resource types that plugins can depend on. +Each type has its own set of valid permissions. ## Enumeration Members -### JOB +### APP ```ts -JOB: "job"; +APP: "app"; ``` -Databricks Job for scheduled or triggered workflows +Databricks App dependency *** -### LAKEBASE +### DATABASE ```ts -LAKEBASE: "lakebase"; +DATABASE: "database"; ``` -Lakebase instance for persistent caching or data storage +Database (Lakebase) for persistent storage *** -### SECRET\_SCOPE +### EXPERIMENT ```ts -SECRET_SCOPE: "secret-scope"; +EXPERIMENT: "experiment"; +``` + +MLflow Experiment for ML tracking + +*** + +### GENIE\_SPACE + +```ts +GENIE_SPACE: "genie_space"; +``` + +Genie Space for AI assistant + +*** + +### JOB + +```ts +JOB: "job"; +``` + +Databricks Job for scheduled or triggered workflows + +*** + +### SECRET + +```ts +SECRET: "secret"; ``` Secret scope for secure credential storage @@ -37,7 +68,7 @@ Secret scope for secure credential storage ### SERVING\_ENDPOINT ```ts -SERVING_ENDPOINT: "serving-endpoint"; +SERVING_ENDPOINT: "serving_endpoint"; ``` Model serving endpoint for ML inference @@ -47,27 +78,47 @@ Model serving endpoint for ML inference ### SQL\_WAREHOUSE ```ts -SQL_WAREHOUSE: "sql-warehouse"; +SQL_WAREHOUSE: "sql_warehouse"; ``` Databricks SQL Warehouse for query execution *** -### UNITY\_CATALOG +### UC\_CONNECTION + +```ts +UC_CONNECTION: "uc_connection"; +``` + +Unity Catalog Connection for external data sources + +*** + +### UC\_FUNCTION ```ts -UNITY_CATALOG: "unity-catalog"; +UC_FUNCTION: "uc_function"; ``` -Unity Catalog for data governance and metadata +Unity Catalog Function *** ### VECTOR\_SEARCH\_INDEX ```ts -VECTOR_SEARCH_INDEX: "vector-search-index"; +VECTOR_SEARCH_INDEX: "vector_search_index"; +``` + +Vector Search Index for similarity search + +*** + +### VOLUME + +```ts +VOLUME: "volume"; ``` -Vector search index for similarity search +Unity Catalog Volume for file storage diff --git a/docs/docs/api/appkit/TypeAlias.ResourcePermission.md b/docs/docs/api/appkit/TypeAlias.ResourcePermission.md index eb91fd57..76bc8723 100644 --- a/docs/docs/api/appkit/TypeAlias.ResourcePermission.md +++ b/docs/docs/api/appkit/TypeAlias.ResourcePermission.md @@ -1,8 +1,19 @@ # Type Alias: ResourcePermission ```ts -type ResourcePermission = "CAN_USE" | "CAN_MANAGE" | "CAN_VIEW" | "READ" | "WRITE" | "EXECUTE"; +type ResourcePermission = + | SecretPermission + | JobPermission + | SqlWarehousePermission + | ServingEndpointPermission + | VolumePermission + | VectorSearchIndexPermission + | UcFunctionPermission + | UcConnectionPermission + | DatabasePermission + | GenieSpacePermission + | ExperimentPermission + | AppPermission; ``` -Permission levels that can be required for a resource. -Based on Databricks permission model. +Union of all possible permission levels across all resource types. diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index 4a8bb03a..00a54916 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -7,7 +7,7 @@ plugin architecture, and React integration. | Enumeration | Description | | ------ | ------ | -| [ResourceType](Enumeration.ResourceType.md) | Supported resource types that plugins can depend on. | +| [ResourceType](Enumeration.ResourceType.md) | Supported resource types that plugins can depend on. Each type has its own set of valid permissions. | ## Classes @@ -46,7 +46,7 @@ plugin architecture, and React integration. | Type Alias | Description | | ------ | ------ | | [IAppRouter](TypeAlias.IAppRouter.md) | Express router type for plugin route registration | -| [ResourcePermission](TypeAlias.ResourcePermission.md) | Permission levels that can be required for a resource. Based on Databricks permission model. | +| [ResourcePermission](TypeAlias.ResourcePermission.md) | Union of all possible permission levels across all resource types. | ## Variables diff --git a/docs/static/schemas/plugin-manifest.schema.json b/docs/static/schemas/plugin-manifest.schema.json index 8c865b26..d1f93b74 100644 --- a/docs/static/schemas/plugin-manifest.schema.json +++ b/docs/static/schemas/plugin-manifest.schema.json @@ -93,20 +93,98 @@ "resourceType": { "type": "string", "enum": [ - "sql-warehouse", - "lakebase", + "secret", "job", - "secret-scope", - "serving-endpoint", - "vector-search-index", - "unity-catalog" + "sql_warehouse", + "serving_endpoint", + "volume", + "vector_search_index", + "uc_function", + "uc_connection", + "database", + "genie_space", + "experiment", + "app" ], "description": "Type of Databricks resource" }, + "secretPermission": { + "type": "string", + "enum": ["MANAGE", "READ", "WRITE"], + "description": "Permission for secret resources" + }, + "jobPermission": { + "type": "string", + "enum": ["CAN_MANAGE", "CAN_MANAGE_RUN", "CAN_VIEW"], + "description": "Permission for job resources" + }, + "sqlWarehousePermission": { + "type": "string", + "enum": ["CAN_MANAGE", "CAN_USE"], + "description": "Permission for SQL warehouse resources" + }, + "servingEndpointPermission": { + "type": "string", + "enum": ["CAN_MANAGE", "CAN_QUERY", "CAN_VIEW"], + "description": "Permission for serving endpoint resources" + }, + "volumePermission": { + "type": "string", + "enum": ["READ_VOLUME", "WRITE_VOLUME"], + "description": "Permission for Unity Catalog volume resources" + }, + "vectorSearchIndexPermission": { + "type": "string", + "enum": ["SELECT"], + "description": "Permission for vector search index resources" + }, + "ucFunctionPermission": { + "type": "string", + "enum": ["EXECUTE"], + "description": "Permission for Unity Catalog function resources" + }, + "ucConnectionPermission": { + "type": "string", + "enum": ["USE_CONNECTION"], + "description": "Permission for Unity Catalog connection resources" + }, + "databasePermission": { + "type": "string", + "enum": ["CAN_CONNECT_AND_CREATE"], + "description": "Permission for database resources" + }, + "genieSpacePermission": { + "type": "string", + "enum": ["CAN_EDIT", "CAN_VIEW", "CAN_RUN", "CAN_MANAGE"], + "description": "Permission for Genie Space resources" + }, + "experimentPermission": { + "type": "string", + "enum": ["CAN_READ", "CAN_EDIT", "CAN_MANAGE"], + "description": "Permission for MLflow experiment resources" + }, + "appPermission": { + "type": "string", + "enum": ["CAN_USE"], + "description": "Permission for Databricks App resources" + }, "resourcePermission": { "type": "string", - "enum": ["CAN_USE", "CAN_MANAGE", "CAN_VIEW", "READ", "WRITE", "EXECUTE"], - "description": "Permission level required for the resource" + "description": "Permission level required for the resource. Valid values depend on resource type.", + "oneOf": [ + { "$ref": "#/$defs/secretPermission" }, + { "$ref": "#/$defs/jobPermission" }, + { "$ref": "#/$defs/sqlWarehousePermission" }, + { "$ref": "#/$defs/servingEndpointPermission" }, + { "$ref": "#/$defs/volumePermission" }, + { "$ref": "#/$defs/vectorSearchIndexPermission" }, + { "$ref": "#/$defs/ucFunctionPermission" }, + { "$ref": "#/$defs/ucConnectionPermission" }, + { "$ref": "#/$defs/databasePermission" }, + { "$ref": "#/$defs/genieSpacePermission" }, + { "$ref": "#/$defs/experimentPermission" }, + { "$ref": "#/$defs/appPermission" } + ] }, "resourceRequirement": { "type": "object",