From 259e09561dabd25cb85351ced5216e4d143387e9 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Mon, 3 Aug 2026 11:14:17 +0200 Subject: [PATCH 01/27] feat(appkit): export vector-search plugin at beta The vector-search plugin was fully implemented but unexported: its manifest had `hidden: true`, and the auto-generated export barrels are driven by each manifest's `stability` field. Flip the manifest to `stability: beta` and teach the barrel generator to derive the camelCase binding from a kebab-case manifest name (`vector-search` -> `vectorSearch`), mirroring `manifestNameToBinding` in the plugin `promote` command. The folder-name check is relaxed to the schema charset since it is only interpolated into a string path. `vectorSearch` now ships from `@databricks/appkit/beta`, alongside its public config and query types. The dev-playground consumer is re-enabled (clearing its standing TODO). The runtime route stays `/api/vector-search`, so docs and existing clients are unaffected. Signed-off-by: MarioCadenas --- apps/dev-playground/server/index.ts | 22 +++--- docs/docs/plugins/vector-search.md | 6 ++ packages/appkit/src/beta.ts | 12 +++- .../src/plugins/beta-exports.generated.ts | 1 + .../src/plugins/vector-search/manifest.json | 2 +- template/appkit.plugins.json | 31 +++++++++ tools/generate-plugin-entries.ts | 68 ++++++++++++------- 7 files changed, 103 insertions(+), 39 deletions(-) diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts index beb19fead..92d2827e2 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -18,6 +18,7 @@ import { DatabricksAdapter, supervisorTools, tool, + vectorSearch, } from "@databricks/appkit/beta"; import { z } from "zod"; import { lakebaseExamples } from "./lakebase-examples-plugin"; @@ -429,17 +430,16 @@ createApp({ // sense as the user-facing landing agent). defaultAgent: "helper", }), - // TODO: re-enable once vector-search is exported from @databricks/appkit - // vectorSearch({ - // indexes: { - // demo: { - // indexName: - // process.env.DATABRICKS_VS_INDEX_NAME ?? "catalog.schema.index", - // columns: ["id", "text", "title"], - // queryType: "hybrid", - // }, - // }, - // }), + vectorSearch({ + indexes: { + demo: { + indexName: + process.env.DATABRICKS_VS_INDEX_NAME ?? "catalog.schema.index", + columns: ["id", "text", "title"], + queryType: "hybrid", + }, + }, + }), ], ...(process.env.APPKIT_E2E_TEST && { client: createMockClient() }), async onPluginsReady(appkit) { diff --git a/docs/docs/plugins/vector-search.md b/docs/docs/plugins/vector-search.md index 5d704641d..0d60d431d 100644 --- a/docs/docs/plugins/vector-search.md +++ b/docs/docs/plugins/vector-search.md @@ -4,6 +4,12 @@ sidebar_position: 9 # Vector Search plugin + +:::warning Beta plugin +This plugin is currently **beta**. APIs may change between minor releases. Import from `@databricks/appkit/beta`. See [Plugin Stability Tiers](./stability.md). +::: + + Query Databricks Vector Search indexes with hybrid search, reranking, and cursor pagination from your AppKit application. **Key features:** diff --git a/packages/appkit/src/beta.ts b/packages/appkit/src/beta.ts index b20833fb6..123b04771 100644 --- a/packages/appkit/src/beta.ts +++ b/packages/appkit/src/beta.ts @@ -89,5 +89,15 @@ export { loadAgentFromFile, loadAgentsFromDir, } from "./plugins/agents"; - export * from "./plugins/beta-exports.generated"; +// Vector Search plugin config and query types (the `vectorSearch` binding +// itself is exported via the generated barrel above). +export type { + IndexConfig, + IVectorSearchConfig, + RerankerConfig, + SearchFilters, + SearchRequest, + SearchResponse, + SearchResult, +} from "./plugins/vector-search/types"; diff --git a/packages/appkit/src/plugins/beta-exports.generated.ts b/packages/appkit/src/plugins/beta-exports.generated.ts index 82f6c4a78..12127d93f 100644 --- a/packages/appkit/src/plugins/beta-exports.generated.ts +++ b/packages/appkit/src/plugins/beta-exports.generated.ts @@ -6,3 +6,4 @@ // manifests and the synced appkit.plugins.json. export { agents } from "./agents"; +export { vectorSearch } from "./vector-search"; diff --git a/packages/appkit/src/plugins/vector-search/manifest.json b/packages/appkit/src/plugins/vector-search/manifest.json index a4451b1af..6b144cc5b 100644 --- a/packages/appkit/src/plugins/vector-search/manifest.json +++ b/packages/appkit/src/plugins/vector-search/manifest.json @@ -2,7 +2,7 @@ "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", "name": "vector-search", "displayName": "Vector Search Plugin", - "hidden": true, + "stability": "beta", "description": "Query Databricks Vector Search indexes with built-in hybrid search, reranking, and pagination", "resources": { "required": [ diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index a4aa1c26e..4f3ad0298 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -308,6 +308,37 @@ ], "optional": [] } + }, + "vector-search": { + "name": "vector-search", + "displayName": "Vector Search Plugin", + "description": "Query Databricks Vector Search indexes with built-in hybrid search, reranking, and pagination", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "vector_search_index", + "alias": "Vector Search Index", + "resourceKey": "vector-search-index", + "description": "A Databricks Vector Search index to query. Index names configured via plugin config.", + "permission": "SELECT", + "fields": { + "indexName": { + "env": "DATABRICKS_VS_INDEX_NAME", + "description": "Three-level UC name of the default index (catalog.schema.index_name)", + "origin": "user" + }, + "endpointName": { + "env": "DATABRICKS_VS_ENDPOINT_NAME", + "description": "Vector Search endpoint name (required for pagination)", + "origin": "user" + } + } + } + ], + "optional": [] + }, + "stability": "beta" } }, "scaffolding": { diff --git a/tools/generate-plugin-entries.ts b/tools/generate-plugin-entries.ts index c2aa6ec7a..d260db04c 100644 --- a/tools/generate-plugin-entries.ts +++ b/tools/generate-plugin-entries.ts @@ -31,7 +31,8 @@ const HEADER = `// AUTO-GENERATED from packages/appkit/src/plugins//manife `; interface PluginInfo { - name: string; + /** camelCase JS-identifier binding emitted into the barrel. */ + binding: string; folder: string; stability: "beta" | "ga"; } @@ -39,25 +40,34 @@ interface PluginInfo { /** * Mirrors `^[a-z][a-z0-9-]*$` from `plugin-manifest.schema.json`. Catches * malformed manifests that bypassed `appkit plugin validate`. + * + * Doubles as a defense-in-depth gate against code-injection (CWE-94): both the + * manifest `name` and the folder name flow into the generated TS source, and + * this charset forbids quotes, semicolons, braces, backslashes, and newlines, + * so neither can break out of the string/identifier context it lands in. */ const SCHEMA_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; /** - * Generator-only: the `name` field is interpolated unescaped into a TS - * `export { } from "./";` template, so it MUST be a valid - * JavaScript identifier. The schema accepts hyphens (e.g. "my-plugin"), - * which would produce `export { my-plugin }` — a TypeScript syntax error. - * - * This is also a defense-in-depth gate against code-injection (CWE-94) - * via a malicious `name` containing `}`, `;`, quotes, newlines, etc. - * - * Restricted to camelCase / underscore identifiers starting with a lowercase - * letter to match the existing built-in plugins (`analytics`, `lakebase`, - * `vectorSearch`, …) and the schema's lowercase-first rule. + * The barrel exports each plugin under a JS-identifier binding + * (`export { } from "./";`). A manifest `name` may be + * kebab-case per the schema, but the binding must be a valid identifier, so it + * is derived via kebab->camelCase. This pattern is the final assertion that the + * derived binding is safe to interpolate unescaped. */ const JS_IDENTIFIER_PATTERN = /^[a-z][a-zA-Z0-9_]*$/; -function validateIdentifier( +/** + * Convert a kebab-case manifest name to its camelCase JS-identifier binding + * (e.g. `vector-search` -> `vectorSearch`). Mirrors `manifestNameToBinding` in + * the plugin `promote` command and the convention first-party plugin index + * files follow, so the emitted binding matches the plugin's actual export. + */ +function manifestNameToBinding(name: string): string { + return name.replace(/-+([a-z0-9])/g, (_, c: string) => c.toUpperCase()); +} + +function validateSchemaName( value: string, kind: "manifest name" | "folder name", manifestPath: string, @@ -67,11 +77,6 @@ function validateIdentifier( `${kind} "${value}" in ${manifestPath} doesn't match the plugin manifest schema pattern ^[a-z][a-z0-9-]*$. Run \`appkit plugin validate\` to catch this earlier.`, ); } - if (!JS_IDENTIFIER_PATTERN.test(value)) { - throw new Error( - `${kind} "${value}" in ${manifestPath} is not a valid JavaScript identifier (must match ^[a-z][a-zA-Z0-9_]*$). The generator interpolates this name into \`export { ${value} } from "./";\` and would emit invalid TypeScript. Rename the plugin folder + manifest \`name\` to camelCase, or set \`hidden: true\` to exclude it from the auto-generated barrels.`, - ); - } } function readPluginInfos(): PluginInfo[] { @@ -102,12 +107,21 @@ function readPluginInfos(): PluginInfo[] { throw new Error(`Manifest missing "name": ${manifestPath}`); } - // Both the manifest `name` (used as the exported binding) and the + // Both the manifest `name` (source of the exported binding) and the // folder name (used as the `from` path) flow into a TS source file - // unescaped. Validate both against the schema and the JS-identifier - // rule before we emit anything. - validateIdentifier(manifest.name, "manifest name", manifestPath); - validateIdentifier(entry.name, "folder name", manifestPath); + // unescaped, so both must match the schema charset before we emit + // anything. + validateSchemaName(manifest.name, "manifest name", manifestPath); + validateSchemaName(entry.name, "folder name", manifestPath); + + // The schema permits kebab-case names, but the barrel binding must be a + // valid JS identifier, so derive it via kebab->camelCase and assert. + const binding = manifestNameToBinding(manifest.name); + if (!JS_IDENTIFIER_PATTERN.test(binding)) { + throw new Error( + `Manifest name "${manifest.name}" in ${manifestPath} does not convert to a valid JavaScript identifier (got "${binding}"). The generator emits \`export { ${binding} } from "./";\`, which would be invalid TypeScript. Rename the plugin so its name is kebab-case or camelCase, or set \`hidden: true\` to exclude it from the auto-generated barrels.`, + ); + } const tier = manifest.stability ?? "ga"; if (tier !== "ga" && tier !== "beta") { @@ -117,14 +131,14 @@ function readPluginInfos(): PluginInfo[] { } infos.push({ - name: manifest.name, + binding, folder: entry.name, stability: tier, }); } // Deterministic order so re-runs produce reproducible diffs. - infos.sort((a, b) => a.name.localeCompare(b.name)); + infos.sort((a, b) => a.binding.localeCompare(b.binding)); return infos; } @@ -132,7 +146,9 @@ function renderBarrel(infos: PluginInfo[]): string { if (infos.length === 0) { return `${HEADER}\nexport {};\n`; } - const lines = infos.map((p) => `export { ${p.name} } from "./${p.folder}";`); + const lines = infos.map( + (p) => `export { ${p.binding} } from "./${p.folder}";`, + ); return `${HEADER}\n${lines.join("\n")}\n`; } From 5286e46253ecd962fc58f94da008a95daf90f115 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Mon, 3 Aug 2026 18:01:05 +0200 Subject: [PATCH 02/27] refactor(appkit): rename vector-search plugin to ai-search Rename the beta plugin's public identity from `vectorSearch` to `aiSearch` end to end: manifest name/displayName, folder and file names, the plugin and connector classes, config/binding symbols, telemetry scopes, docs, and the dev-playground + template consumers. The runtime route follows `manifest.name`, so it moves from `/api/vector-search` to `/api/ai-search`. Databricks-platform identifiers are intentionally left as-is since they name the underlying product, not this plugin: the `vector_search_index` resource type and its schema/permissions, the `/api/2.0/vector-search` REST paths, `Vs*` wire-format types, `vs.*` span attributes, `DATABRICKS_VS_*` env vars, and the "Vector Search Index" resource alias. Regenerated the beta export barrel, template appkit.plugins.json, the dev-playground route tree, and the typedoc API reference. Signed-off-by: MarioCadenas --- apps/dev-playground/client/src/lib/nav.ts | 4 +- .../client/src/routeTree.gen.ts | 42 +++---- ...r-search.route.tsx => ai-search.route.tsx} | 8 +- apps/dev-playground/server/index.ts | 4 +- .../api/appkit/Interface.BasePluginConfig.md | 1 + .../api/appkit/Interface.IAiSearchConfig.md | 65 +++++++++++ docs/docs/api/appkit/Interface.IndexConfig.md | 103 ++++++++++++++++++ .../api/appkit/Interface.RerankerConfig.md | 9 ++ .../api/appkit/Interface.SearchRequest.md | 57 ++++++++++ .../api/appkit/Interface.SearchResponse.md | 47 ++++++++ .../docs/api/appkit/Interface.SearchResult.md | 23 ++++ .../api/appkit/TypeAlias.SearchFilters.md | 5 + docs/docs/api/appkit/Variable.aiSearch.md | 5 + docs/docs/api/appkit/index.md | 8 ++ docs/docs/api/appkit/typedoc-sidebar.ts | 40 +++++++ .../{vector-search.md => ai-search.md} | 28 ++--- knip.json | 2 +- packages/appkit/src/beta.ts | 8 +- .../{vector-search => ai-search}/client.ts | 20 ++-- .../{vector-search => ai-search}/index.ts | 0 .../{vector-search => ai-search}/types.ts | 2 +- packages/appkit/src/connectors/index.ts | 2 +- .../ai-search.ts} | 30 ++--- .../{vector-search => ai-search}/defaults.ts | 2 +- .../appkit/src/plugins/ai-search/index.ts | 2 + .../manifest.json | 4 +- .../tests/ai-search.test.ts} | 32 +++--- .../{vector-search => ai-search}/types.ts | 2 +- .../src/plugins/beta-exports.generated.ts | 2 +- .../appkit/src/plugins/vector-search/index.ts | 2 - packages/shared/src/schemas/manifest.ts | 2 +- template/appkit.plugins.json | 62 +++++------ template/client/src/App.tsx | 14 +-- .../AiSearchPage.tsx} | 6 +- 34 files changed, 503 insertions(+), 140 deletions(-) rename apps/dev-playground/client/src/routes/{vector-search.route.tsx => ai-search.route.tsx} (96%) create mode 100644 docs/docs/api/appkit/Interface.IAiSearchConfig.md create mode 100644 docs/docs/api/appkit/Interface.IndexConfig.md create mode 100644 docs/docs/api/appkit/Interface.RerankerConfig.md create mode 100644 docs/docs/api/appkit/Interface.SearchRequest.md create mode 100644 docs/docs/api/appkit/Interface.SearchResponse.md create mode 100644 docs/docs/api/appkit/Interface.SearchResult.md create mode 100644 docs/docs/api/appkit/TypeAlias.SearchFilters.md create mode 100644 docs/docs/api/appkit/Variable.aiSearch.md rename docs/docs/plugins/{vector-search.md => ai-search.md} (93%) rename packages/appkit/src/connectors/{vector-search => ai-search}/client.ts (91%) rename packages/appkit/src/connectors/{vector-search => ai-search}/index.ts (100%) rename packages/appkit/src/connectors/{vector-search => ai-search}/types.ts (95%) rename packages/appkit/src/plugins/{vector-search/vector-search.ts => ai-search/ai-search.ts} (92%) rename packages/appkit/src/plugins/{vector-search => ai-search}/defaults.ts (73%) create mode 100644 packages/appkit/src/plugins/ai-search/index.ts rename packages/appkit/src/plugins/{vector-search => ai-search}/manifest.json (96%) rename packages/appkit/src/plugins/{vector-search/tests/vector-search.test.ts => ai-search/tests/ai-search.test.ts} (91%) rename packages/appkit/src/plugins/{vector-search => ai-search}/types.ts (96%) delete mode 100644 packages/appkit/src/plugins/vector-search/index.ts rename template/client/src/pages/{vector-search/VectorSearchPage.tsx => ai-search/AiSearchPage.tsx} (97%) diff --git a/apps/dev-playground/client/src/lib/nav.ts b/apps/dev-playground/client/src/lib/nav.ts index 00f70dfec..86c086805 100644 --- a/apps/dev-playground/client/src/lib/nav.ts +++ b/apps/dev-playground/client/src/lib/nav.ts @@ -114,8 +114,8 @@ export const NAV_GROUPS: ReadonlyArray = [ icon: LineChartIcon, }, { - to: "/vector-search", - label: "Vector Search", + to: "/ai-search", + label: "AI Search", description: "Semantic search backed by Databricks vector indexes, wired into AppKit's retrieval API.", icon: SearchIcon, diff --git a/apps/dev-playground/client/src/routeTree.gen.ts b/apps/dev-playground/client/src/routeTree.gen.ts index 450287592..94034f5f7 100644 --- a/apps/dev-playground/client/src/routeTree.gen.ts +++ b/apps/dev-playground/client/src/routeTree.gen.ts @@ -9,7 +9,6 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as VectorSearchRouteRouteImport } from './routes/vector-search.route' import { Route as UiVariantsRouteRouteImport } from './routes/ui-variants.route' import { Route as TypeSafetyRouteRouteImport } from './routes/type-safety.route' import { Route as TelemetryRouteRouteImport } from './routes/telemetry.route' @@ -26,14 +25,10 @@ import { Route as DataVisualizationRouteRouteImport } from './routes/data-visual import { Route as ChartInferenceRouteRouteImport } from './routes/chart-inference.route' import { Route as ArrowAnalyticsRouteRouteImport } from './routes/arrow-analytics.route' import { Route as AnalyticsRouteRouteImport } from './routes/analytics.route' +import { Route as AiSearchRouteRouteImport } from './routes/ai-search.route' import { Route as AgentRouteRouteImport } from './routes/agent.route' import { Route as IndexRouteImport } from './routes/index' -const VectorSearchRouteRoute = VectorSearchRouteRouteImport.update({ - id: '/vector-search', - path: '/vector-search', - getParentRoute: () => rootRouteImport, -} as any) const UiVariantsRouteRoute = UiVariantsRouteRouteImport.update({ id: '/ui-variants', path: '/ui-variants', @@ -114,6 +109,11 @@ const AnalyticsRouteRoute = AnalyticsRouteRouteImport.update({ path: '/analytics', getParentRoute: () => rootRouteImport, } as any) +const AiSearchRouteRoute = AiSearchRouteRouteImport.update({ + id: '/ai-search', + path: '/ai-search', + getParentRoute: () => rootRouteImport, +} as any) const AgentRouteRoute = AgentRouteRouteImport.update({ id: '/agent', path: '/agent', @@ -128,6 +128,7 @@ const IndexRoute = IndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/agent': typeof AgentRouteRoute + '/ai-search': typeof AiSearchRouteRoute '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/chart-inference': typeof ChartInferenceRouteRoute @@ -144,11 +145,11 @@ export interface FileRoutesByFullPath { '/telemetry': typeof TelemetryRouteRoute '/type-safety': typeof TypeSafetyRouteRoute '/ui-variants': typeof UiVariantsRouteRoute - '/vector-search': typeof VectorSearchRouteRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/agent': typeof AgentRouteRoute + '/ai-search': typeof AiSearchRouteRoute '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/chart-inference': typeof ChartInferenceRouteRoute @@ -165,12 +166,12 @@ export interface FileRoutesByTo { '/telemetry': typeof TelemetryRouteRoute '/type-safety': typeof TypeSafetyRouteRoute '/ui-variants': typeof UiVariantsRouteRoute - '/vector-search': typeof VectorSearchRouteRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/agent': typeof AgentRouteRoute + '/ai-search': typeof AiSearchRouteRoute '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/chart-inference': typeof ChartInferenceRouteRoute @@ -187,13 +188,13 @@ export interface FileRoutesById { '/telemetry': typeof TelemetryRouteRoute '/type-safety': typeof TypeSafetyRouteRoute '/ui-variants': typeof UiVariantsRouteRoute - '/vector-search': typeof VectorSearchRouteRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' | '/agent' + | '/ai-search' | '/analytics' | '/arrow-analytics' | '/chart-inference' @@ -210,11 +211,11 @@ export interface FileRouteTypes { | '/telemetry' | '/type-safety' | '/ui-variants' - | '/vector-search' fileRoutesByTo: FileRoutesByTo to: | '/' | '/agent' + | '/ai-search' | '/analytics' | '/arrow-analytics' | '/chart-inference' @@ -231,11 +232,11 @@ export interface FileRouteTypes { | '/telemetry' | '/type-safety' | '/ui-variants' - | '/vector-search' id: | '__root__' | '/' | '/agent' + | '/ai-search' | '/analytics' | '/arrow-analytics' | '/chart-inference' @@ -252,12 +253,12 @@ export interface FileRouteTypes { | '/telemetry' | '/type-safety' | '/ui-variants' - | '/vector-search' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute AgentRouteRoute: typeof AgentRouteRoute + AiSearchRouteRoute: typeof AiSearchRouteRoute AnalyticsRouteRoute: typeof AnalyticsRouteRoute ArrowAnalyticsRouteRoute: typeof ArrowAnalyticsRouteRoute ChartInferenceRouteRoute: typeof ChartInferenceRouteRoute @@ -274,18 +275,10 @@ export interface RootRouteChildren { TelemetryRouteRoute: typeof TelemetryRouteRoute TypeSafetyRouteRoute: typeof TypeSafetyRouteRoute UiVariantsRouteRoute: typeof UiVariantsRouteRoute - VectorSearchRouteRoute: typeof VectorSearchRouteRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/vector-search': { - id: '/vector-search' - path: '/vector-search' - fullPath: '/vector-search' - preLoaderRoute: typeof VectorSearchRouteRouteImport - parentRoute: typeof rootRouteImport - } '/ui-variants': { id: '/ui-variants' path: '/ui-variants' @@ -398,6 +391,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AnalyticsRouteRouteImport parentRoute: typeof rootRouteImport } + '/ai-search': { + id: '/ai-search' + path: '/ai-search' + fullPath: '/ai-search' + preLoaderRoute: typeof AiSearchRouteRouteImport + parentRoute: typeof rootRouteImport + } '/agent': { id: '/agent' path: '/agent' @@ -418,6 +418,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AgentRouteRoute: AgentRouteRoute, + AiSearchRouteRoute: AiSearchRouteRoute, AnalyticsRouteRoute: AnalyticsRouteRoute, ArrowAnalyticsRouteRoute: ArrowAnalyticsRouteRoute, ChartInferenceRouteRoute: ChartInferenceRouteRoute, @@ -434,7 +435,6 @@ const rootRouteChildren: RootRouteChildren = { TelemetryRouteRoute: TelemetryRouteRoute, TypeSafetyRouteRoute: TypeSafetyRouteRoute, UiVariantsRouteRoute: UiVariantsRouteRoute, - VectorSearchRouteRoute: VectorSearchRouteRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/dev-playground/client/src/routes/vector-search.route.tsx b/apps/dev-playground/client/src/routes/ai-search.route.tsx similarity index 96% rename from apps/dev-playground/client/src/routes/vector-search.route.tsx rename to apps/dev-playground/client/src/routes/ai-search.route.tsx index bed4885c7..ca2c5b7e5 100644 --- a/apps/dev-playground/client/src/routes/vector-search.route.tsx +++ b/apps/dev-playground/client/src/routes/ai-search.route.tsx @@ -11,8 +11,8 @@ import { Search } from "lucide-react"; import { useState } from "react"; import { Header } from "@/components/layout/header"; -export const Route = createFileRoute("/vector-search")({ - component: VectorSearchRoute, +export const Route = createFileRoute("/ai-search")({ + component: AiSearchRoute, }); interface SearchResult { @@ -27,7 +27,7 @@ interface SearchResponse { queryType: string; } -function VectorSearchRoute() { +function AiSearchRoute() { const [query, setQuery] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -40,7 +40,7 @@ function VectorSearchRoute() { setResponse(null); try { - const res = await fetch("/api/vector-search/demo/query", { + const res = await fetch("/api/ai-search/demo/query", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ queryText: query }), diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts index 92d2827e2..e286034c8 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -14,11 +14,11 @@ import { } from "@databricks/appkit"; import { agents, + aiSearch, createAgent, DatabricksAdapter, supervisorTools, tool, - vectorSearch, } from "@databricks/appkit/beta"; import { z } from "zod"; import { lakebaseExamples } from "./lakebase-examples-plugin"; @@ -430,7 +430,7 @@ createApp({ // sense as the user-facing landing agent). defaultAgent: "helper", }), - vectorSearch({ + aiSearch({ indexes: { demo: { indexName: diff --git a/docs/docs/api/appkit/Interface.BasePluginConfig.md b/docs/docs/api/appkit/Interface.BasePluginConfig.md index 653df68ce..a109fd560 100644 --- a/docs/docs/api/appkit/Interface.BasePluginConfig.md +++ b/docs/docs/api/appkit/Interface.BasePluginConfig.md @@ -5,6 +5,7 @@ Base configuration interface for AppKit plugins ## Extended by - [`AgentsPluginConfig`](Interface.AgentsPluginConfig.md) +- [`IAiSearchConfig`](Interface.IAiSearchConfig.md) - [`IJobsConfig`](Interface.IJobsConfig.md) ## Indexable diff --git a/docs/docs/api/appkit/Interface.IAiSearchConfig.md b/docs/docs/api/appkit/Interface.IAiSearchConfig.md new file mode 100644 index 000000000..316b4aac3 --- /dev/null +++ b/docs/docs/api/appkit/Interface.IAiSearchConfig.md @@ -0,0 +1,65 @@ +# Interface: IAiSearchConfig + +Base configuration interface for AppKit plugins + +## Extends + +- [`BasePluginConfig`](Interface.BasePluginConfig.md) + +## Indexable + +```ts +[key: string]: unknown +``` + +## Properties + +### host? + +```ts +optional host: string; +``` + +#### Inherited from + +[`BasePluginConfig`](Interface.BasePluginConfig.md).[`host`](Interface.BasePluginConfig.md#host) + +*** + +### indexes? + +```ts +optional indexes: Record; +``` + +*** + +### name? + +```ts +optional name: string; +``` + +#### Inherited from + +[`BasePluginConfig`](Interface.BasePluginConfig.md).[`name`](Interface.BasePluginConfig.md#name) + +*** + +### telemetry? + +```ts +optional telemetry: TelemetryOptions; +``` + +#### Inherited from + +[`BasePluginConfig`](Interface.BasePluginConfig.md).[`telemetry`](Interface.BasePluginConfig.md#telemetry) + +*** + +### timeout? + +```ts +optional timeout: number; +``` diff --git a/docs/docs/api/appkit/Interface.IndexConfig.md b/docs/docs/api/appkit/Interface.IndexConfig.md new file mode 100644 index 000000000..392a10011 --- /dev/null +++ b/docs/docs/api/appkit/Interface.IndexConfig.md @@ -0,0 +1,103 @@ +# Interface: IndexConfig + +## Properties + +### auth? + +```ts +optional auth: "service-principal" | "on-behalf-of-user"; +``` + +Auth mode — "service-principal" uses the app's SP, "on-behalf-of-user" proxies the logged-in user's token + +*** + +### columns + +```ts +columns: string[]; +``` + +Columns to return in results + +*** + +### embeddingFn()? + +```ts +optional embeddingFn: (text: string) => Promise; +``` + +For self-managed embedding indexes: converts query text to an embedding vector. +When provided, the plugin calls this function and sends query_vector to VS. +When omitted, query_text is sent and VS computes embeddings server-side (managed mode). + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `text` | `string` | + +#### Returns + +`Promise`\<`number`[]\> + +*** + +### endpointName? + +```ts +optional endpointName: string; +``` + +VS endpoint name (required when pagination is true) + +*** + +### indexName + +```ts +indexName: string; +``` + +Three-level UC name: catalog.schema.index_name + +*** + +### numResults? + +```ts +optional numResults: number; +``` + +Max results per query + +*** + +### pagination? + +```ts +optional pagination: boolean; +``` + +Enable cursor pagination + +*** + +### queryType? + +```ts +optional queryType: "ann" | "hybrid" | "full_text"; +``` + +Default search mode + +*** + +### reranker? + +```ts +optional reranker: boolean | RerankerConfig; +``` + +Enable built-in reranker. Pass true to rerank all non-id columns, or an object for fine control. diff --git a/docs/docs/api/appkit/Interface.RerankerConfig.md b/docs/docs/api/appkit/Interface.RerankerConfig.md new file mode 100644 index 000000000..4012fc326 --- /dev/null +++ b/docs/docs/api/appkit/Interface.RerankerConfig.md @@ -0,0 +1,9 @@ +# Interface: RerankerConfig + +## Properties + +### columnsToRerank + +```ts +columnsToRerank: string[]; +``` diff --git a/docs/docs/api/appkit/Interface.SearchRequest.md b/docs/docs/api/appkit/Interface.SearchRequest.md new file mode 100644 index 000000000..3180f7d28 --- /dev/null +++ b/docs/docs/api/appkit/Interface.SearchRequest.md @@ -0,0 +1,57 @@ +# Interface: SearchRequest + +## Properties + +### columns? + +```ts +optional columns: string[]; +``` + +*** + +### filters? + +```ts +optional filters: SearchFilters; +``` + +*** + +### numResults? + +```ts +optional numResults: number; +``` + +*** + +### queryText? + +```ts +optional queryText: string; +``` + +*** + +### queryType? + +```ts +optional queryType: "ann" | "hybrid" | "full_text"; +``` + +*** + +### queryVector? + +```ts +optional queryVector: number[]; +``` + +*** + +### reranker? + +```ts +optional reranker: boolean; +``` diff --git a/docs/docs/api/appkit/Interface.SearchResponse.md b/docs/docs/api/appkit/Interface.SearchResponse.md new file mode 100644 index 000000000..26020438f --- /dev/null +++ b/docs/docs/api/appkit/Interface.SearchResponse.md @@ -0,0 +1,47 @@ +# Interface: SearchResponse\ + +## Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `T` *extends* `Record`\<`string`, `unknown`\> | `Record`\<`string`, `unknown`\> | + +## Properties + +### nextPageToken + +```ts +nextPageToken: string | null; +``` + +*** + +### queryTimeMs + +```ts +queryTimeMs: number; +``` + +*** + +### queryType + +```ts +queryType: "ann" | "hybrid" | "full_text"; +``` + +*** + +### results + +```ts +results: SearchResult[]; +``` + +*** + +### totalCount + +```ts +totalCount: number; +``` diff --git a/docs/docs/api/appkit/Interface.SearchResult.md b/docs/docs/api/appkit/Interface.SearchResult.md new file mode 100644 index 000000000..0ba3bf264 --- /dev/null +++ b/docs/docs/api/appkit/Interface.SearchResult.md @@ -0,0 +1,23 @@ +# Interface: SearchResult\ + +## Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `T` *extends* `Record`\<`string`, `unknown`\> | `Record`\<`string`, `unknown`\> | + +## Properties + +### data + +```ts +data: T; +``` + +*** + +### score + +```ts +score: number; +``` diff --git a/docs/docs/api/appkit/TypeAlias.SearchFilters.md b/docs/docs/api/appkit/TypeAlias.SearchFilters.md new file mode 100644 index 000000000..4b3149f02 --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.SearchFilters.md @@ -0,0 +1,5 @@ +# Type Alias: SearchFilters + +```ts +type SearchFilters = Record; +``` diff --git a/docs/docs/api/appkit/Variable.aiSearch.md b/docs/docs/api/appkit/Variable.aiSearch.md new file mode 100644 index 000000000..0fec700d8 --- /dev/null +++ b/docs/docs/api/appkit/Variable.aiSearch.md @@ -0,0 +1,5 @@ +# Variable: aiSearch + +```ts +const aiSearch: ToPlugin; +``` diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index 124db7c8f..b7d394176 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -51,7 +51,9 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [GenerateDatabaseCredentialRequest](Interface.GenerateDatabaseCredentialRequest.md) | Request parameters for generating database OAuth credentials | | [GenerationParams](Interface.GenerationParams.md) | Optional generation parameters forwarded to the OpenAI-compatible serving request body. Names match the serving API wire keys. Only keys that are set are sent — undefined values are omitted so the endpoint applies its own defaults. Ranges are not validated here; the serving endpoint validates. | | [HostedSupervisorTool](Interface.HostedSupervisorTool.md) | Tagged record returned by every [supervisorTools](Variable.supervisorTools.md) factory. The `__kind` discriminator lets the agents plugin (and standalone `runAgent`) classify these tools without a structural match against the wire format — keeps the SA wire shape free to evolve and avoids namespace collisions with MCP hosted tools (which use `type: "genie-space"` hyphenated, vs SA's `type: "genie_space"` underscored). | +| [IAiSearchConfig](Interface.IAiSearchConfig.md) | Base configuration interface for AppKit plugins | | [IJobsConfig](Interface.IJobsConfig.md) | Configuration for the Jobs plugin. | +| [IndexConfig](Interface.IndexConfig.md) | - | | [ITelemetry](Interface.ITelemetry.md) | Plugin-facing interface for OpenTelemetry instrumentation. Provides a thin abstraction over OpenTelemetry APIs for plugins. | | [JobAPI](Interface.JobAPI.md) | User-facing API for a single configured job. | | [JobConfig](Interface.JobConfig.md) | Per-job configuration options. | @@ -67,10 +69,14 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [RegisteredAgent](Interface.RegisteredAgent.md) | - | | [RequestedClaims](Interface.RequestedClaims.md) | Optional claims for fine-grained Unity Catalog table permissions When specified, the returned token will be scoped to only the requested tables | | [RequestedResource](Interface.RequestedResource.md) | Resource to request permissions for in Unity Catalog | +| [RerankerConfig](Interface.RerankerConfig.md) | - | | [ResourceEntry](Interface.ResourceEntry.md) | Internal representation of a resource in the registry. Extends ResourceRequirement with resolution state and plugin ownership. | | [ResourceRequirement](Interface.ResourceRequirement.md) | Declares a resource requirement for a plugin. Can be defined statically in a manifest or dynamically via getResourceRequirements(). | | [RunAgentInput](Interface.RunAgentInput.md) | - | | [RunAgentResult](Interface.RunAgentResult.md) | - | +| [SearchRequest](Interface.SearchRequest.md) | - | +| [SearchResponse](Interface.SearchResponse.md) | - | +| [SearchResult](Interface.SearchResult.md) | - | | [ServingEndpointEntry](Interface.ServingEndpointEntry.md) | Shape of a single registry entry. | | [ServingEndpointRegistry](Interface.ServingEndpointRegistry.md) | Registry interface for serving endpoint type generation. Empty by default — augmented by the Vite type generator's `.d.ts` output via module augmentation. When populated, provides autocomplete for alias names and typed request/response/chunk per endpoint. | | [StreamExecutionSettings](Interface.StreamExecutionSettings.md) | Execution settings for streaming endpoints. Extends PluginExecutionSettings with SSE stream configuration. | @@ -112,6 +118,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [ResolvedToolEntry](TypeAlias.ResolvedToolEntry.md) | Internal tool-index entry after a tool record has been resolved to a dispatchable form. | | [ResourceFieldEntry](TypeAlias.ResourceFieldEntry.md) | - | | [ResourcePermission](TypeAlias.ResourcePermission.md) | Union of all possible permission levels across all resource types. | +| [SearchFilters](TypeAlias.SearchFilters.md) | - | | [ServingFactory](TypeAlias.ServingFactory.md) | Factory function returned by `AppKit.serving`. | | [SupervisorTool](TypeAlias.SupervisorTool.md) | Tools supported by the Databricks AI Gateway Responses API. The shapes match the wire format the endpoint expects, so the adapter passes the array straight into the request body. | | [ToolRegistry](TypeAlias.ToolRegistry.md) | - | @@ -122,6 +129,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | Variable | Description | | ------ | ------ | | [agents](Variable.agents.md) | Plugin factory for the agents plugin. Reads `config/agents/*.md` by default, resolves toolkits/tools from registered plugins, exposes `appkit.agents.*` runtime API and mounts `POST /invocations` and `POST /responses` (aliased non-streaming invoke endpoints) plus `POST /chat` (streaming, HITL-capable). | +| [aiSearch](Variable.aiSearch.md) | - | | [READ\_ACTIONS](Variable.READ_ACTIONS.md) | Actions that only read data. | | [sql](Variable.sql.md) | SQL helper namespace | | [SUPERVISOR\_EXTENSION\_KEY](Variable.SUPERVISOR_EXTENSION_KEY.md) | Namespace key under which the adapter reads its hosted-tool payload from [AgentInput.extensions](Interface.AgentInput.md#extensions). Exported so the agents plugin and standalone `runAgent` (the producers) can write under the same key the adapter reads. | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 1c510b7a8..489424a2e 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -187,11 +187,21 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.HostedSupervisorTool", label: "HostedSupervisorTool" }, + { + type: "doc", + id: "api/appkit/Interface.IAiSearchConfig", + label: "IAiSearchConfig" + }, { type: "doc", id: "api/appkit/Interface.IJobsConfig", label: "IJobsConfig" }, + { + type: "doc", + id: "api/appkit/Interface.IndexConfig", + label: "IndexConfig" + }, { type: "doc", id: "api/appkit/Interface.ITelemetry", @@ -267,6 +277,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.RequestedResource", label: "RequestedResource" }, + { + type: "doc", + id: "api/appkit/Interface.RerankerConfig", + label: "RerankerConfig" + }, { type: "doc", id: "api/appkit/Interface.ResourceEntry", @@ -287,6 +302,21 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.RunAgentResult", label: "RunAgentResult" }, + { + type: "doc", + id: "api/appkit/Interface.SearchRequest", + label: "SearchRequest" + }, + { + type: "doc", + id: "api/appkit/Interface.SearchResponse", + label: "SearchResponse" + }, + { + type: "doc", + id: "api/appkit/Interface.SearchResult", + label: "SearchResult" + }, { type: "doc", id: "api/appkit/Interface.ServingEndpointEntry", @@ -473,6 +503,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/TypeAlias.ResourcePermission", label: "ResourcePermission" }, + { + type: "doc", + id: "api/appkit/TypeAlias.SearchFilters", + label: "SearchFilters" + }, { type: "doc", id: "api/appkit/TypeAlias.ServingFactory", @@ -504,6 +539,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Variable.agents", label: "agents" }, + { + type: "doc", + id: "api/appkit/Variable.aiSearch", + label: "aiSearch" + }, { type: "doc", id: "api/appkit/Variable.READ_ACTIONS", diff --git a/docs/docs/plugins/vector-search.md b/docs/docs/plugins/ai-search.md similarity index 93% rename from docs/docs/plugins/vector-search.md rename to docs/docs/plugins/ai-search.md index 0d60d431d..36f27519d 100644 --- a/docs/docs/plugins/vector-search.md +++ b/docs/docs/plugins/ai-search.md @@ -2,7 +2,7 @@ sidebar_position: 9 --- -# Vector Search plugin +# AI Search plugin :::warning Beta plugin @@ -23,12 +23,12 @@ Query Databricks Vector Search indexes with hybrid search, reranking, and cursor ## Basic usage ```ts -import { createApp, vectorSearch, server } from "@databricks/appkit"; +import { createApp, aiSearch, server } from "@databricks/appkit"; await createApp({ plugins: [ server(), - vectorSearch({ + aiSearch({ indexes: { products: { indexName: "catalog.schema.products_idx", @@ -54,7 +54,7 @@ await createApp({ Index aliases let you reference multiple Vector Search indexes by name. The alias is used in API routes and programmatic calls: ```ts -vectorSearch({ +aiSearch({ indexes: { products: { indexName: "catalog.schema.products_idx", @@ -94,7 +94,7 @@ vectorSearch({ Reranking improves result relevance by running a second-stage model over the initial candidates: ```ts -vectorSearch({ +aiSearch({ indexes: { products: { indexName: "catalog.schema.products_idx", @@ -112,7 +112,7 @@ Pass `reranker: true` to rerank across all returned columns. By default, queries run as the app's service principal. Set `auth: "on-behalf-of-user"` to execute queries as the signed-in user instead: ```ts -vectorSearch({ +aiSearch({ indexes: { documents: { indexName: "catalog.schema.documents_idx", @@ -128,7 +128,7 @@ vectorSearch({ Enable cursor pagination to page through large result sets: ```ts -vectorSearch({ +aiSearch({ indexes: { products: { indexName: "catalog.schema.products_idx", @@ -149,7 +149,7 @@ For indexes that manage their own embeddings, provide an `embeddingFn` that take ```ts import { embed } from "./my-embedding-client"; -vectorSearch({ +aiSearch({ indexes: { products: { indexName: "catalog.schema.products_idx", @@ -163,7 +163,7 @@ vectorSearch({ ## HTTP routes -Routes are mounted at `/api/vector-search`. +Routes are mounted at `/api/ai-search`. | Method | Path | Description | |--------|------|-------------| @@ -174,7 +174,7 @@ Routes are mounted at `/api/vector-search`. ### Query an index ``` -POST /api/vector-search/:alias/query +POST /api/ai-search/:alias/query Content-Type: application/json { @@ -199,7 +199,7 @@ Response: ### Fetch the next page ``` -POST /api/vector-search/:alias/next-page +POST /api/ai-search/:alias/next-page Content-Type: application/json { @@ -211,7 +211,7 @@ Content-Type: application/json ### Get index config ``` -GET /api/vector-search/:alias/config +GET /api/ai-search/:alias/config ``` Returns the resolved `IndexConfig` for the alias (excluding `embeddingFn`). @@ -224,7 +224,7 @@ The plugin exposes a `query` method for server-side use: const AppKit = await createApp({ plugins: [ server(), - vectorSearch({ + aiSearch({ indexes: { products: { indexName: "catalog.schema.products_idx", @@ -235,7 +235,7 @@ const AppKit = await createApp({ ], }); -const result = await AppKit.vectorSearch.query("products", { +const result = await AppKit.aiSearch.query("products", { queryText: "machine learning guide", }); diff --git a/knip.json b/knip.json index 251cc61eb..0e96b7df5 100644 --- a/knip.json +++ b/knip.json @@ -15,7 +15,7 @@ "**/*.generated.ts", "**/*.example.tsx", "**/*.css", - "packages/appkit/src/plugins/vector-search/**", + "packages/appkit/src/plugins/ai-search/**", "packages/appkit/src/plugin/index.ts", "packages/appkit/src/plugin/to-plugin.ts", "packages/appkit/src/plugins/agents/**", diff --git a/packages/appkit/src/beta.ts b/packages/appkit/src/beta.ts index 123b04771..d94b4e0d4 100644 --- a/packages/appkit/src/beta.ts +++ b/packages/appkit/src/beta.ts @@ -89,15 +89,15 @@ export { loadAgentFromFile, loadAgentsFromDir, } from "./plugins/agents"; -export * from "./plugins/beta-exports.generated"; -// Vector Search plugin config and query types (the `vectorSearch` binding +// AI Search plugin config and query types (the `aiSearch` binding // itself is exported via the generated barrel above). export type { + IAiSearchConfig, IndexConfig, - IVectorSearchConfig, RerankerConfig, SearchFilters, SearchRequest, SearchResponse, SearchResult, -} from "./plugins/vector-search/types"; +} from "./plugins/ai-search/types"; +export * from "./plugins/beta-exports.generated"; diff --git a/packages/appkit/src/connectors/vector-search/client.ts b/packages/appkit/src/connectors/ai-search/client.ts similarity index 91% rename from packages/appkit/src/connectors/vector-search/client.ts rename to packages/appkit/src/connectors/ai-search/client.ts index f424b17e4..288a5da5a 100644 --- a/packages/appkit/src/connectors/vector-search/client.ts +++ b/packages/appkit/src/connectors/ai-search/client.ts @@ -8,20 +8,20 @@ import { } from "../../telemetry"; import type { WorkspaceClient } from "../../workspace-client"; import type { - VectorSearchConnectorConfig, + AiSearchConnectorConfig, VsNextPageParams, VsQueryParams, VsRawResponse, } from "./types"; -const logger = createLogger("connectors:vector-search"); +const logger = createLogger("connectors:ai-search"); -export class VectorSearchConnector { +export class AiSearchConnector { private readonly telemetry: TelemetryProvider; - constructor(config: VectorSearchConnectorConfig = {}) { + constructor(config: AiSearchConnectorConfig = {}) { this.telemetry = TelemetryManager.getProvider( - "vector-search", + "ai-search", config.telemetry, ); } @@ -62,7 +62,7 @@ export class VectorSearchConnector { ); return this.telemetry.startActiveSpan( - "vector-search.query", + "ai-search.query", { kind: SpanKind.CLIENT, attributes: { @@ -97,7 +97,7 @@ export class VectorSearchConnector { span.setAttribute("vs.duration_ms", duration); span.setStatus({ code: SpanStatusCode.OK }); - logger.event()?.setContext("vector-search", { + logger.event()?.setContext("ai-search", { index_name: params.indexName, query_type: params.queryType, result_count: response.result.row_count, @@ -115,7 +115,7 @@ export class VectorSearchConnector { throw error; } }, - { name: "vector-search", includePrefix: true }, + { name: "ai-search", includePrefix: true }, ); } @@ -135,7 +135,7 @@ export class VectorSearchConnector { ); return this.telemetry.startActiveSpan( - "vector-search.queryNextPage", + "ai-search.queryNextPage", { kind: SpanKind.CLIENT, attributes: { @@ -170,7 +170,7 @@ export class VectorSearchConnector { throw error; } }, - { name: "vector-search", includePrefix: true }, + { name: "ai-search", includePrefix: true }, ); } } diff --git a/packages/appkit/src/connectors/vector-search/index.ts b/packages/appkit/src/connectors/ai-search/index.ts similarity index 100% rename from packages/appkit/src/connectors/vector-search/index.ts rename to packages/appkit/src/connectors/ai-search/index.ts diff --git a/packages/appkit/src/connectors/vector-search/types.ts b/packages/appkit/src/connectors/ai-search/types.ts similarity index 95% rename from packages/appkit/src/connectors/vector-search/types.ts rename to packages/appkit/src/connectors/ai-search/types.ts index df042e8c6..f7a73795d 100644 --- a/packages/appkit/src/connectors/vector-search/types.ts +++ b/packages/appkit/src/connectors/ai-search/types.ts @@ -1,6 +1,6 @@ import type { TelemetryOptions } from "shared"; -export interface VectorSearchConnectorConfig { +export interface AiSearchConnectorConfig { timeout?: number; telemetry?: TelemetryOptions; } diff --git a/packages/appkit/src/connectors/index.ts b/packages/appkit/src/connectors/index.ts index 5fad31d99..438d334af 100644 --- a/packages/appkit/src/connectors/index.ts +++ b/packages/appkit/src/connectors/index.ts @@ -1,7 +1,7 @@ +export * from "./ai-search"; export * from "./files"; export * from "./genie"; export * from "./jobs"; export * from "./lakebase"; export * from "./mcp"; export * from "./sql-warehouse"; -export * from "./vector-search"; diff --git a/packages/appkit/src/plugins/vector-search/vector-search.ts b/packages/appkit/src/plugins/ai-search/ai-search.ts similarity index 92% rename from packages/appkit/src/plugins/vector-search/vector-search.ts rename to packages/appkit/src/plugins/ai-search/ai-search.ts index fefc3f409..14e1d5a59 100644 --- a/packages/appkit/src/plugins/vector-search/vector-search.ts +++ b/packages/appkit/src/plugins/ai-search/ai-search.ts @@ -1,39 +1,39 @@ import type express from "express"; import type { IAppRouter, PluginExecutionSettings } from "shared"; -import { VectorSearchConnector } from "../../connectors/vector-search/client"; -import type { VsRawResponse } from "../../connectors/vector-search/types"; +import { AiSearchConnector } from "../../connectors/ai-search/client"; +import type { VsRawResponse } from "../../connectors/ai-search/types"; import { getWorkspaceClient } from "../../context"; import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; import type { PluginManifest } from "../../registry"; -import { vectorSearchDefaults } from "./defaults"; +import { aiSearchDefaults } from "./defaults"; import manifest from "./manifest.json"; import type { + IAiSearchConfig, IndexConfig, - IVectorSearchConfig, SearchRequest, SearchResponse, } from "./types"; -const logger = createLogger("vector-search"); +const logger = createLogger("ai-search"); const querySettings: PluginExecutionSettings = { - default: vectorSearchDefaults, + default: aiSearchDefaults, }; -export class VectorSearchPlugin extends Plugin { - static manifest = manifest as PluginManifest<"vector-search">; +export class AiSearchPlugin extends Plugin { + static manifest = manifest as PluginManifest<"ai-search">; protected static description = "Query Databricks Vector Search indexes with hybrid search, reranking, and pagination"; - protected declare config: IVectorSearchConfig; + protected declare config: IAiSearchConfig; - private connector: VectorSearchConnector; + private connector: AiSearchConnector; - constructor(config: IVectorSearchConfig) { + constructor(config: IAiSearchConfig) { super(config); this.config = config; - this.connector = new VectorSearchConnector({ + this.connector = new AiSearchConnector({ timeout: config.timeout, telemetry: config.telemetry, }); @@ -42,7 +42,7 @@ export class VectorSearchPlugin extends Plugin { async setup(): Promise { if (!this.config.indexes || Object.keys(this.config.indexes).length === 0) { throw new Error( - 'VectorSearchPlugin requires at least one index in "indexes" config', + 'AiSearchPlugin requires at least one index in "indexes" config', ); } for (const [alias, idx] of Object.entries(this.config.indexes)) { @@ -226,7 +226,7 @@ export class VectorSearchPlugin extends Plugin { } /** - * Programmatic query API — available as `appkit.vectorSearch.query()`. + * Programmatic query API — available as `appkit.aiSearch.query()`. * When called through `asUser(req)`, executes with the user's credentials. */ async query(alias: string, request: SearchRequest): Promise { @@ -373,4 +373,4 @@ export class VectorSearchPlugin extends Plugin { } } -export const vectorSearch = toPlugin(VectorSearchPlugin); +export const aiSearch = toPlugin(AiSearchPlugin); diff --git a/packages/appkit/src/plugins/vector-search/defaults.ts b/packages/appkit/src/plugins/ai-search/defaults.ts similarity index 73% rename from packages/appkit/src/plugins/vector-search/defaults.ts rename to packages/appkit/src/plugins/ai-search/defaults.ts index c02b6e804..d927c4490 100644 --- a/packages/appkit/src/plugins/vector-search/defaults.ts +++ b/packages/appkit/src/plugins/ai-search/defaults.ts @@ -1,6 +1,6 @@ import type { PluginExecuteConfig } from "shared"; -export const vectorSearchDefaults: PluginExecuteConfig = { +export const aiSearchDefaults: PluginExecuteConfig = { cache: { enabled: false }, retry: { enabled: true, initialDelay: 1000, attempts: 3 }, timeout: 30_000, diff --git a/packages/appkit/src/plugins/ai-search/index.ts b/packages/appkit/src/plugins/ai-search/index.ts new file mode 100644 index 000000000..8577b6f08 --- /dev/null +++ b/packages/appkit/src/plugins/ai-search/index.ts @@ -0,0 +1,2 @@ +export * from "./ai-search"; +export * from "./types"; diff --git a/packages/appkit/src/plugins/vector-search/manifest.json b/packages/appkit/src/plugins/ai-search/manifest.json similarity index 96% rename from packages/appkit/src/plugins/vector-search/manifest.json rename to packages/appkit/src/plugins/ai-search/manifest.json index 6b144cc5b..416f1ee3a 100644 --- a/packages/appkit/src/plugins/vector-search/manifest.json +++ b/packages/appkit/src/plugins/ai-search/manifest.json @@ -1,7 +1,7 @@ { "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", - "name": "vector-search", - "displayName": "Vector Search Plugin", + "name": "ai-search", + "displayName": "AI Search Plugin", "stability": "beta", "description": "Query Databricks Vector Search indexes with built-in hybrid search, reranking, and pagination", "resources": { diff --git a/packages/appkit/src/plugins/vector-search/tests/vector-search.test.ts b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts similarity index 91% rename from packages/appkit/src/plugins/vector-search/tests/vector-search.test.ts rename to packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts index 104eb1cb6..e46bf8026 100644 --- a/packages/appkit/src/plugins/vector-search/tests/vector-search.test.ts +++ b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts @@ -96,9 +96,9 @@ const mockWorkspaceClient = { apiClient: { request: mockRequest }, }; -import { VectorSearchPlugin } from "../vector-search"; +import { AiSearchPlugin } from "../ai-search"; -describe("VectorSearchPlugin", () => { +describe("AiSearchPlugin", () => { beforeEach(() => { mockRequest.mockClear(); mockRequest.mockResolvedValue(validVsResponse); @@ -106,7 +106,7 @@ describe("VectorSearchPlugin", () => { describe("setup()", () => { it("throws if any index is missing indexName", async () => { - const plugin = new VectorSearchPlugin({ + const plugin = new AiSearchPlugin({ indexes: { test: { indexName: "", columns: ["id"] }, }, @@ -115,7 +115,7 @@ describe("VectorSearchPlugin", () => { }); it("throws if any index is missing columns", async () => { - const plugin = new VectorSearchPlugin({ + const plugin = new AiSearchPlugin({ indexes: { test: { indexName: "cat.sch.idx", columns: [] }, }, @@ -124,7 +124,7 @@ describe("VectorSearchPlugin", () => { }); it("throws if pagination enabled but no endpointName", async () => { - const plugin = new VectorSearchPlugin({ + const plugin = new AiSearchPlugin({ indexes: { test: { indexName: "cat.sch.idx", @@ -137,7 +137,7 @@ describe("VectorSearchPlugin", () => { }); it("succeeds with valid config", async () => { - const plugin = new VectorSearchPlugin({ + const plugin = new AiSearchPlugin({ indexes: { products: { indexName: "cat.sch.products_idx", @@ -153,13 +153,13 @@ describe("VectorSearchPlugin", () => { describe("manifest", () => { it("has correct name", () => { - expect(VectorSearchPlugin.manifest.name).toBe("vector-search"); + expect(AiSearchPlugin.manifest.name).toBe("ai-search"); }); }); describe("exports()", () => { it("returns object with query function", () => { - const plugin = new VectorSearchPlugin({ + const plugin = new AiSearchPlugin({ indexes: { test: { indexName: "cat.sch.idx", columns: ["id"] }, }, @@ -172,7 +172,7 @@ describe("VectorSearchPlugin", () => { describe("query()", () => { it("calls VS API via connector and parses response", async () => { - const plugin = new VectorSearchPlugin({ + const plugin = new AiSearchPlugin({ indexes: { products: { indexName: "cat.sch.products", @@ -196,7 +196,7 @@ describe("VectorSearchPlugin", () => { }); it("constructs correct API request", async () => { - const plugin = new VectorSearchPlugin({ + const plugin = new AiSearchPlugin({ indexes: { test: { indexName: "cat.sch.idx", @@ -224,7 +224,7 @@ describe("VectorSearchPlugin", () => { }); it("throws Error for unknown alias", async () => { - const plugin = new VectorSearchPlugin({ + const plugin = new AiSearchPlugin({ indexes: { test: { indexName: "cat.sch.idx", columns: ["id"] }, }, @@ -237,7 +237,7 @@ describe("VectorSearchPlugin", () => { }); it("includes filters when provided", async () => { - const plugin = new VectorSearchPlugin({ + const plugin = new AiSearchPlugin({ indexes: { test: { indexName: "cat.sch.idx", @@ -256,7 +256,7 @@ describe("VectorSearchPlugin", () => { }); it("includes reranker config when enabled on index", async () => { - const plugin = new VectorSearchPlugin({ + const plugin = new AiSearchPlugin({ indexes: { test: { indexName: "cat.sch.idx", @@ -278,7 +278,7 @@ describe("VectorSearchPlugin", () => { it("calls embeddingFn for self-managed indexes", async () => { const mockEmbeddingFn = vi.fn().mockResolvedValue([0.1, 0.2, 0.3]); - const plugin = new VectorSearchPlugin({ + const plugin = new AiSearchPlugin({ indexes: { test: { indexName: "cat.sch.idx", @@ -300,7 +300,7 @@ describe("VectorSearchPlugin", () => { const mockEmbeddingFn = vi .fn() .mockRejectedValue(new Error("embedding service unavailable")); - const plugin = new VectorSearchPlugin({ + const plugin = new AiSearchPlugin({ indexes: { test: { indexName: "cat.sch.idx", @@ -319,7 +319,7 @@ describe("VectorSearchPlugin", () => { describe("shutdown()", () => { it("does not throw", async () => { - const plugin = new VectorSearchPlugin({ + const plugin = new AiSearchPlugin({ indexes: { test: { indexName: "cat.sch.idx", columns: ["id"] }, }, diff --git a/packages/appkit/src/plugins/vector-search/types.ts b/packages/appkit/src/plugins/ai-search/types.ts similarity index 96% rename from packages/appkit/src/plugins/vector-search/types.ts rename to packages/appkit/src/plugins/ai-search/types.ts index a2760fced..c791c793b 100644 --- a/packages/appkit/src/plugins/vector-search/types.ts +++ b/packages/appkit/src/plugins/ai-search/types.ts @@ -1,6 +1,6 @@ import type { BasePluginConfig } from "shared"; -export interface IVectorSearchConfig extends BasePluginConfig { +export interface IAiSearchConfig extends BasePluginConfig { timeout?: number; indexes?: Record; } diff --git a/packages/appkit/src/plugins/beta-exports.generated.ts b/packages/appkit/src/plugins/beta-exports.generated.ts index 12127d93f..7e556ebd9 100644 --- a/packages/appkit/src/plugins/beta-exports.generated.ts +++ b/packages/appkit/src/plugins/beta-exports.generated.ts @@ -6,4 +6,4 @@ // manifests and the synced appkit.plugins.json. export { agents } from "./agents"; -export { vectorSearch } from "./vector-search"; +export { aiSearch } from "./ai-search"; diff --git a/packages/appkit/src/plugins/vector-search/index.ts b/packages/appkit/src/plugins/vector-search/index.ts deleted file mode 100644 index d733a0f27..000000000 --- a/packages/appkit/src/plugins/vector-search/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./types"; -export * from "./vector-search"; diff --git a/packages/shared/src/schemas/manifest.ts b/packages/shared/src/schemas/manifest.ts index ceb0412ab..ed7930149 100644 --- a/packages/shared/src/schemas/manifest.ts +++ b/packages/shared/src/schemas/manifest.ts @@ -555,7 +555,7 @@ export const configSchemaPropertySchema: z.ZodType = z.lazy(() => maxLength: z.number().int().min(0).optional(), required: z.array(z.string()).optional(), // `additionalProperties` is a standard JSON Schema keyword used by core - // plugin manifests (e.g., serving, vector-search, genie) to constrain + // plugin manifests (e.g., serving, ai-search, genie) to constrain // dictionary-shaped properties. Allowed on nested property entries as // either a boolean or a sub-schema, mirroring JSON Schema semantics. additionalProperties: z diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index 4f3ad0298..0f82fcae6 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -28,6 +28,37 @@ }, "stability": "beta" }, + "ai-search": { + "name": "ai-search", + "displayName": "AI Search Plugin", + "description": "Query Databricks Vector Search indexes with built-in hybrid search, reranking, and pagination", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "vector_search_index", + "alias": "Vector Search Index", + "resourceKey": "vector-search-index", + "description": "A Databricks Vector Search index to query. Index names configured via plugin config.", + "permission": "SELECT", + "fields": { + "indexName": { + "env": "DATABRICKS_VS_INDEX_NAME", + "description": "Three-level UC name of the default index (catalog.schema.index_name)", + "origin": "user" + }, + "endpointName": { + "env": "DATABRICKS_VS_ENDPOINT_NAME", + "description": "Vector Search endpoint name (required for pagination)", + "origin": "user" + } + } + } + ], + "optional": [] + }, + "stability": "beta" + }, "analytics": { "name": "analytics", "displayName": "Analytics Plugin", @@ -308,37 +339,6 @@ ], "optional": [] } - }, - "vector-search": { - "name": "vector-search", - "displayName": "Vector Search Plugin", - "description": "Query Databricks Vector Search indexes with built-in hybrid search, reranking, and pagination", - "package": "@databricks/appkit", - "resources": { - "required": [ - { - "type": "vector_search_index", - "alias": "Vector Search Index", - "resourceKey": "vector-search-index", - "description": "A Databricks Vector Search index to query. Index names configured via plugin config.", - "permission": "SELECT", - "fields": { - "indexName": { - "env": "DATABRICKS_VS_INDEX_NAME", - "description": "Three-level UC name of the default index (catalog.schema.index_name)", - "origin": "user" - }, - "endpointName": { - "env": "DATABRICKS_VS_ENDPOINT_NAME", - "description": "Vector Search endpoint name (required for pagination)", - "origin": "user" - } - } - } - ], - "optional": [] - }, - "stability": "beta" } }, "scaffolding": { diff --git a/template/client/src/App.tsx b/template/client/src/App.tsx index 2e9f9a9c6..3b4f0b664 100644 --- a/template/client/src/App.tsx +++ b/template/client/src/App.tsx @@ -31,8 +31,8 @@ import { FilesPage } from './pages/files/FilesPage'; {{- if .plugins.serving}} import { ServingPage } from './pages/serving/ServingPage'; {{- end}} -{{- if .plugins.vectorSearch}} -import { VectorSearchPage } from './pages/vector-search/VectorSearchPage'; +{{- if .plugins.aiSearch}} +import { AiSearchPage } from './pages/ai-search/AiSearchPage'; {{- end}} {{- if .plugins.jobs}} import { JobsPage } from './pages/jobs/JobsPage'; @@ -90,9 +90,9 @@ function NavLinks({ className, linkClass, onClick }: { className?: string; linkC Serving {{- end}} -{{- if .plugins.vectorSearch}} - - Vector Search +{{- if .plugins.aiSearch}} + + AI Search {{- end}} {{- if .plugins.jobs}} @@ -166,8 +166,8 @@ const router = createBrowserRouter([ {{- if .plugins.serving}} { path: '/serving', element: }, {{- end}} -{{- if .plugins.vectorSearch}} - { path: '/vector-search', element: }, +{{- if .plugins.aiSearch}} + { path: '/ai-search', element: }, {{- end}} {{- if .plugins.jobs}} { path: '/jobs', element: }, diff --git a/template/client/src/pages/vector-search/VectorSearchPage.tsx b/template/client/src/pages/ai-search/AiSearchPage.tsx similarity index 97% rename from template/client/src/pages/vector-search/VectorSearchPage.tsx rename to template/client/src/pages/ai-search/AiSearchPage.tsx index f1e5e58fe..1c5c19df4 100644 --- a/template/client/src/pages/vector-search/VectorSearchPage.tsx +++ b/template/client/src/pages/ai-search/AiSearchPage.tsx @@ -1,4 +1,4 @@ -{{if .plugins.vectorSearch -}} +{{if .plugins.aiSearch -}} import { Button, Card, @@ -23,7 +23,7 @@ interface SearchResponse { queryType: string; } -export function VectorSearchPage() { +export function AiSearchPage() { const [query, setQuery] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -36,7 +36,7 @@ export function VectorSearchPage() { setResponse(null); try { - const res = await fetch('/api/vector-search/default/query', { + const res = await fetch('/api/ai-search/default/query', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ queryText: query }), From 5ab046910104020ef2bba46d01b25be6faaf5578 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Mon, 3 Aug 2026 18:04:33 +0200 Subject: [PATCH 03/27] docs(appkit): import aiSearch from @databricks/appkit/beta in examples The ai-search plugin ships from the /beta subpath, but the doc examples imported it from the main entry. Split the imports so `aiSearch` comes from `@databricks/appkit/beta` while `createApp`/`server` stay on the main entry, matching the agents plugin docs. Also add the import header to the programmatic-access example so it is copy-pasteable. Signed-off-by: MarioCadenas --- docs/docs/plugins/ai-search.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/docs/plugins/ai-search.md b/docs/docs/plugins/ai-search.md index 36f27519d..4ef7df7d4 100644 --- a/docs/docs/plugins/ai-search.md +++ b/docs/docs/plugins/ai-search.md @@ -23,7 +23,8 @@ Query Databricks Vector Search indexes with hybrid search, reranking, and cursor ## Basic usage ```ts -import { createApp, aiSearch, server } from "@databricks/appkit"; +import { createApp, server } from "@databricks/appkit"; +import { aiSearch } from "@databricks/appkit/beta"; await createApp({ plugins: [ @@ -221,6 +222,9 @@ Returns the resolved `IndexConfig` for the alias (excluding `embeddingFn`). The plugin exposes a `query` method for server-side use: ```ts +import { createApp, server } from "@databricks/appkit"; +import { aiSearch } from "@databricks/appkit/beta"; + const AppKit = await createApp({ plugins: [ server(), From c686e9841725ef13ad12a1d0958714a6cebf57d6 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Mon, 3 Aug 2026 18:15:33 +0200 Subject: [PATCH 04/27] ci: point generated-files check at renamed ai-search.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Check generated types are up to date' step diffs a hardcoded list of plugin doc pages. After renaming the plugin, the doc page moved from vector-search.md to ai-search.md, but the workflow still referenced the old path — so `git diff` exited 128 (unknown path) and failed the check with a misleading 'out of sync' message rather than a real drift. Signed-off-by: MarioCadenas --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 212e06a89..fc0fb6272 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,7 +68,7 @@ jobs: docs/docs/plugins/lakebase.md \ docs/docs/plugins/model-serving.md \ docs/docs/plugins/server.md \ - docs/docs/plugins/vector-search.md; then + docs/docs/plugins/ai-search.md; then echo "❌ Error: Generated files are out of sync with their source manifests/schemas." echo "" echo "To fix this:" From 4144c5b434368b551f7c0e5592d8ea8f5a44350f Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 4 Aug 2026 10:49:38 +0200 Subject: [PATCH 05/27] fix(appkit): correct ai-search embedding, cancellation, and error handling Addresses issues found in review of the ai-search plugin: - embeddingFn no longer clears queryText for hybrid/full_text queries. It was cleared unconditionally, which silently degraded hybrid to vector-only and produced invalid full_text requests. Now only ann drops the text, and full_text skips embedding entirely. - Forward the execution's AbortSignal to the Vector Search REST call via the SDK Context/CancellationToken (mirroring the serving connector), so the configured timeout and client-disconnect actually cancel an in-flight query. Both query and queryNextPage were affected. - _handleError now gates the raw error message on NODE_ENV, matching the base Plugin.execute() convention, so upstream error text (e.g. from a user-supplied embeddingFn) isn't leaked to clients in production. - Fix the /query response example in the docs to the real nested shape ({ results: [{ score, data }], totalCount, queryTimeMs, queryType, nextPageToken }); it previously showed a flat row shape. - Add tests for the three route handlers and their validation branches, queryNextPage wiring, _parseResponse edge cases (no score column, present next_page_token, latency_ms fallback), request-level overrides, object and request-suppressed reranker, and the embedding query-type matrix. Plugin line coverage 49% -> 91%. Signed-off-by: MarioCadenas --- docs/docs/plugins/ai-search.md | 10 +- .../appkit/src/connectors/ai-search/client.ts | 88 +++- .../appkit/src/plugins/ai-search/ai-search.ts | 21 +- .../plugins/ai-search/tests/ai-search.test.ts | 387 +++++++++++++++++- 4 files changed, 482 insertions(+), 24 deletions(-) diff --git a/docs/docs/plugins/ai-search.md b/docs/docs/plugins/ai-search.md index 4ef7df7d4..b7ab84c74 100644 --- a/docs/docs/plugins/ai-search.md +++ b/docs/docs/plugins/ai-search.md @@ -189,13 +189,19 @@ Response: ```json { "results": [ - { "id": "42", "name": "Intro to ML", "description": "..." } + { + "score": 0.87, + "data": { "id": "42", "name": "Intro to ML", "description": "..." } + } ], + "totalCount": 1, + "queryTimeMs": 35, + "queryType": "hybrid", "nextPageToken": "eyJvZmZzZXQiOjEwfQ==" } ``` -`nextPageToken` is only present when `pagination` is enabled and more results are available. +Each result carries its relevance `score` and the returned columns under `data`. `nextPageToken` is `null` unless `pagination` is enabled and more results are available. ### Fetch the next page diff --git a/packages/appkit/src/connectors/ai-search/client.ts b/packages/appkit/src/connectors/ai-search/client.ts index 288a5da5a..440b6fb36 100644 --- a/packages/appkit/src/connectors/ai-search/client.ts +++ b/packages/appkit/src/connectors/ai-search/client.ts @@ -1,3 +1,5 @@ +import type { CancellationToken } from "@databricks/sdk-experimental"; +import { Context } from "@databricks/sdk-experimental"; import { createLogger } from "../../logging/logger"; import type { TelemetryProvider } from "../../telemetry"; import { @@ -16,6 +18,42 @@ import type { const logger = createLogger("connectors:ai-search"); +/** + * Bridges {@link AbortSignal} to the SDK's {@link CancellationToken} so + * `apiClient.request` aborts the outbound HTTP request when the execution's + * timeout fires or the client disconnects. Mirrors the serving connector. + */ +function cancellationTokenFromAbortSignal( + signal: AbortSignal, +): CancellationToken { + const listeners = new Set<() => void>(); + signal.addEventListener( + "abort", + () => { + for (const cb of listeners) { + try { + cb(); + } catch { + // ignore listener failures — abort must stay best-effort + } + } + }, + { passive: true }, + ); + + return { + get isCancellationRequested() { + return signal.aborted; + }, + onCancellationRequested(callback: (e?: unknown) => unknown) { + listeners.add(callback as () => void); + if (signal.aborted) { + void callback(); + } + }, + }; +} + export class AiSearchConnector { private readonly telemetry: TelemetryProvider; @@ -79,14 +117,21 @@ export class AiSearchConnector { async (span: Span) => { const startTime = Date.now(); try { - const response = (await workspaceClient.apiClient.request({ - method: "POST", - path: `/api/2.0/vector-search/indexes/${params.indexName}/query`, - payload: body, - headers: new Headers({ "Content-Type": "application/json" }), - raw: false, - query: {}, - })) as VsRawResponse; + const response = (await workspaceClient.apiClient.request( + { + method: "POST", + path: `/api/2.0/vector-search/indexes/${params.indexName}/query`, + payload: body, + headers: new Headers({ "Content-Type": "application/json" }), + raw: false, + query: {}, + }, + signal + ? new Context({ + cancellationToken: cancellationTokenFromAbortSignal(signal), + }) + : undefined, + )) as VsRawResponse; const duration = Date.now() - startTime; span.setAttribute("vs.result_count", response.result.row_count); @@ -146,17 +191,24 @@ export class AiSearchConnector { }, async (span: Span) => { try { - const response = (await workspaceClient.apiClient.request({ - method: "POST", - path: `/api/2.0/vector-search/indexes/${params.indexName}/query-next-page`, - payload: { - endpoint_name: params.endpointName, - page_token: params.pageToken, + const response = (await workspaceClient.apiClient.request( + { + method: "POST", + path: `/api/2.0/vector-search/indexes/${params.indexName}/query-next-page`, + payload: { + endpoint_name: params.endpointName, + page_token: params.pageToken, + }, + headers: new Headers({ "Content-Type": "application/json" }), + raw: false, + query: {}, }, - headers: new Headers({ "Content-Type": "application/json" }), - raw: false, - query: {}, - })) as VsRawResponse; + signal + ? new Context({ + cancellationToken: cancellationTokenFromAbortSignal(signal), + }) + : undefined, + )) as VsRawResponse; span.setAttribute("vs.result_count", response.result.row_count); span.setStatus({ code: SpanStatusCode.OK }); diff --git a/packages/appkit/src/plugins/ai-search/ai-search.ts b/packages/appkit/src/plugins/ai-search/ai-search.ts index 14e1d5a59..c300261fa 100644 --- a/packages/appkit/src/plugins/ai-search/ai-search.ts +++ b/packages/appkit/src/plugins/ai-search/ai-search.ts @@ -294,10 +294,19 @@ export class AiSearchPlugin extends Plugin { let queryText = request.queryText; let queryVector = request.queryVector; - if (indexConfig.embeddingFn && queryText && !queryVector) { + // Self-managed embedding indexes need a query_vector for the vector half + // of the search (ann, hybrid). full_text never uses a vector, so skip + // embedding entirely. Only ann is vector-only — for hybrid the text is + // still needed for the keyword half, so keep queryText. + if ( + indexConfig.embeddingFn && + queryText && + !queryVector && + queryType !== "full_text" + ) { try { queryVector = await indexConfig.embeddingFn(queryText); - queryText = undefined; + if (queryType === "ann") queryText = undefined; } catch (error) { throw new Error( `Embedding generation failed: ${error instanceof Error ? error.message : String(error)}`, @@ -368,7 +377,13 @@ export class AiSearchPlugin extends Plugin { fallbackMessage: string, ): void { logger.error("%s: %O", fallbackMessage, error); - const message = error instanceof Error ? error.message : fallbackMessage; + // Mirror the base Plugin.execute() convention: only surface the raw error + // message outside production. In production the detail stays in the log + // above and the client gets the generic fallback, so upstream error text + // (e.g. from a user-supplied embeddingFn) isn't leaked. + const isDev = process.env.NODE_ENV !== "production"; + const message = + isDev && error instanceof Error ? error.message : fallbackMessage; res.status(500).json({ error: message, plugin: this.name }); } } diff --git a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts index e46bf8026..8d22bb3c4 100644 --- a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts +++ b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts @@ -1,3 +1,8 @@ +import { + createMockRequest, + createMockResponse, + createMockRouter, +} from "@tools/test-helpers"; import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../../../context", () => ({ @@ -214,6 +219,8 @@ describe("AiSearchPlugin", () => { method: "POST", path: "/api/2.0/vector-search/indexes/cat.sch.idx/query", }), + // 2nd arg is the SDK Context bridging the execution's abort signal. + expect.anything(), ); const callBody = mockRequest.mock.calls[0][0].payload; @@ -276,13 +283,14 @@ describe("AiSearchPlugin", () => { ]); }); - it("calls embeddingFn for self-managed indexes", async () => { + it("calls embeddingFn and drops query_text for ann (vector-only)", async () => { const mockEmbeddingFn = vi.fn().mockResolvedValue([0.1, 0.2, 0.3]); const plugin = new AiSearchPlugin({ indexes: { test: { indexName: "cat.sch.idx", columns: ["id", "title"], + queryType: "ann", embeddingFn: mockEmbeddingFn, }, }, @@ -296,6 +304,48 @@ describe("AiSearchPlugin", () => { expect(callBody.query_text).toBeUndefined(); }); + it("keeps query_text alongside the embedded vector for hybrid", async () => { + const mockEmbeddingFn = vi.fn().mockResolvedValue([0.1, 0.2, 0.3]); + const plugin = new AiSearchPlugin({ + indexes: { + test: { + indexName: "cat.sch.idx", + columns: ["id", "title"], + queryType: "hybrid", + embeddingFn: mockEmbeddingFn, + }, + }, + }); + await plugin.setup(); + await plugin.query("test", { queryText: "test" }); + + expect(mockEmbeddingFn).toHaveBeenCalledWith("test"); + const callBody = mockRequest.mock.calls[0][0].payload; + expect(callBody.query_vector).toEqual([0.1, 0.2, 0.3]); + expect(callBody.query_text).toBe("test"); + }); + + it("skips embeddingFn for full_text and sends query_text only", async () => { + const mockEmbeddingFn = vi.fn().mockResolvedValue([0.1, 0.2, 0.3]); + const plugin = new AiSearchPlugin({ + indexes: { + test: { + indexName: "cat.sch.idx", + columns: ["id", "title"], + queryType: "full_text", + embeddingFn: mockEmbeddingFn, + }, + }, + }); + await plugin.setup(); + await plugin.query("test", { queryText: "test" }); + + expect(mockEmbeddingFn).not.toHaveBeenCalled(); + const callBody = mockRequest.mock.calls[0][0].payload; + expect(callBody.query_text).toBe("test"); + expect(callBody.query_vector).toBeUndefined(); + }); + it("throws when embeddingFn fails", async () => { const mockEmbeddingFn = vi .fn() @@ -327,4 +377,339 @@ describe("AiSearchPlugin", () => { await expect(plugin.shutdown()).resolves.not.toThrow(); }); }); + + describe("_parseResponse edge cases", () => { + it("defaults score to 0 when the index returns no score column", async () => { + mockRequest.mockResolvedValueOnce({ + manifest: { column_count: 2, columns: [{ name: "id" }, { name: "t" }] }, + result: { row_count: 1, data_array: [[1, "hi"]] }, + next_page_token: null, + }); + const plugin = new AiSearchPlugin({ + indexes: { test: { indexName: "cat.sch.idx", columns: ["id", "t"] } }, + }); + await plugin.setup(); + const res = await plugin.query("test", { queryText: "q" }); + + expect(res.results[0].score).toBe(0); + expect(res.results[0].data).toEqual({ id: 1, t: "hi" }); + }); + + it("propagates a non-null next_page_token", async () => { + mockRequest.mockResolvedValueOnce({ + ...validVsResponse, + next_page_token: "tok-123", + }); + const plugin = new AiSearchPlugin({ + indexes: { test: { indexName: "cat.sch.idx", columns: ["id"] } }, + }); + await plugin.setup(); + const res = await plugin.query("test", { queryText: "q" }); + + expect(res.nextPageToken).toBe("tok-123"); + }); + + it("falls back to latency_ms for queryTimeMs when response_time is absent", async () => { + mockRequest.mockResolvedValueOnce({ + manifest: { column_count: 1, columns: [{ name: "id" }] }, + result: { row_count: 0, data_array: [] }, + next_page_token: null, + debug_info: { latency_ms: 42 }, + }); + const plugin = new AiSearchPlugin({ + indexes: { test: { indexName: "cat.sch.idx", columns: ["id"] } }, + }); + await plugin.setup(); + const res = await plugin.query("test", { queryText: "q" }); + + expect(res.queryTimeMs).toBe(42); + expect(res.results).toEqual([]); + expect(res.totalCount).toBe(0); + }); + }); + + describe("query() overrides and reranker", () => { + it("lets the request override index queryType, numResults, and columns", async () => { + const plugin = new AiSearchPlugin({ + indexes: { + test: { + indexName: "cat.sch.idx", + columns: ["id", "title"], + queryType: "hybrid", + numResults: 10, + }, + }, + }); + await plugin.setup(); + await plugin.query("test", { + queryText: "q", + queryType: "ann", + numResults: 5, + columns: ["id"], + }); + + const callBody = mockRequest.mock.calls[0][0].payload; + expect(callBody.query_type).toBe("ANN"); + expect(callBody.num_results).toBe(5); + expect(callBody.columns).toEqual(["id"]); + }); + + it("passes an object reranker through untouched", async () => { + const plugin = new AiSearchPlugin({ + indexes: { + test: { + indexName: "cat.sch.idx", + columns: ["id", "title", "body"], + reranker: { columnsToRerank: ["title"] }, + }, + }, + }); + await plugin.setup(); + await plugin.query("test", { queryText: "q" }); + + const callBody = mockRequest.mock.calls[0][0].payload; + expect(callBody.reranker.parameters.columns_to_rerank).toEqual(["title"]); + }); + + it("lets request.reranker=false suppress an index-enabled reranker", async () => { + const plugin = new AiSearchPlugin({ + indexes: { + test: { + indexName: "cat.sch.idx", + columns: ["id", "title"], + reranker: true, + }, + }, + }); + await plugin.setup(); + await plugin.query("test", { queryText: "q", reranker: false }); + + const callBody = mockRequest.mock.calls[0][0].payload; + expect(callBody.reranker).toBeUndefined(); + }); + + it("throws a wrapped error when the connector query fails", async () => { + // Persistent reject so the retry interceptor exhausts its attempts and + // execute() surfaces a failed result, driving the !result.ok branch. + mockRequest.mockRejectedValue(new Error("VS 503")); + const plugin = new AiSearchPlugin({ + indexes: { products: { indexName: "cat.sch.p", columns: ["id"] } }, + }); + await plugin.setup(); + + await expect( + plugin.query("products", { queryText: "q" }), + ).rejects.toThrow(/Vector search query failed for index "products"/); + }); + }); + + describe("injectRoutes", () => { + const makePlugin = () => + new AiSearchPlugin({ + indexes: { + demo: { + indexName: "cat.sch.idx", + columns: ["id", "title"], + queryType: "hybrid", + }, + paged: { + indexName: "cat.sch.paged", + columns: ["id"], + pagination: true, + endpointName: "ep", + }, + }, + }); + + it("registers the three routes", () => { + const plugin = makePlugin(); + const { router } = createMockRouter(); + plugin.injectRoutes(router); + + expect(router.post).toHaveBeenCalledWith( + "/:alias/query", + expect.any(Function), + ); + expect(router.post).toHaveBeenCalledWith( + "/:alias/next-page", + expect.any(Function), + ); + expect(router.get).toHaveBeenCalledWith( + "/:alias/config", + expect.any(Function), + ); + }); + + describe("/:alias/query", () => { + it("404s an unknown alias", async () => { + const plugin = makePlugin(); + const { router, getHandler } = createMockRouter(); + plugin.injectRoutes(router); + + const res = createMockResponse(); + await getHandler("POST", "/:alias/query")( + createMockRequest({ params: { alias: "nope" }, body: {} }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(404); + }); + + it("400s when neither queryText nor queryVector is provided", async () => { + const plugin = makePlugin(); + const { router, getHandler } = createMockRouter(); + plugin.injectRoutes(router); + + const res = createMockResponse(); + await getHandler("POST", "/:alias/query")( + createMockRequest({ params: { alias: "demo" }, body: {} }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("returns the parsed response on success", async () => { + const plugin = makePlugin(); + const { router, getHandler } = createMockRouter(); + plugin.injectRoutes(router); + + const res = createMockResponse(); + await getHandler("POST", "/:alias/query")( + createMockRequest({ + params: { alias: "demo" }, + body: { queryText: "hi" }, + }), + res, + ); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ totalCount: 2, queryType: "hybrid" }), + ); + }); + + it("500s (via _handleError) when query preparation throws", async () => { + // A throw *outside* execute() (here, a failing embeddingFn) reaches the + // handler's catch → _handleError → 500. Connector failures instead flow + // through execute() as a non-ok result with its own status. + const plugin = new AiSearchPlugin({ + indexes: { + demo: { + indexName: "cat.sch.idx", + columns: ["id", "title"], + queryType: "ann", + embeddingFn: vi.fn().mockRejectedValue(new Error("embed down")), + }, + }, + }); + const { router, getHandler } = createMockRouter(); + plugin.injectRoutes(router); + + const res = createMockResponse(); + await getHandler("POST", "/:alias/query")( + createMockRequest({ + params: { alias: "demo" }, + body: { queryText: "hi" }, + }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(500); + }); + }); + + describe("/:alias/next-page", () => { + it("400s when pagination is not enabled", async () => { + const plugin = makePlugin(); + const { router, getHandler } = createMockRouter(); + plugin.injectRoutes(router); + + const res = createMockResponse(); + await getHandler("POST", "/:alias/next-page")( + createMockRequest({ + params: { alias: "demo" }, + body: { pageToken: "t" }, + }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("400s when pageToken is missing", async () => { + const plugin = makePlugin(); + const { router, getHandler } = createMockRouter(); + plugin.injectRoutes(router); + + const res = createMockResponse(); + await getHandler("POST", "/:alias/next-page")( + createMockRequest({ params: { alias: "paged" }, body: {} }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("fetches the next page on success", async () => { + const plugin = makePlugin(); + const { router, getHandler } = createMockRouter(); + plugin.injectRoutes(router); + + const res = createMockResponse(); + await getHandler("POST", "/:alias/next-page")( + createMockRequest({ + params: { alias: "paged" }, + body: { pageToken: "t" }, + }), + res, + ); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + path: "/api/2.0/vector-search/indexes/cat.sch.paged/query-next-page", + payload: { endpoint_name: "ep", page_token: "t" }, + }), + expect.anything(), + ); + expect(res.json).toHaveBeenCalled(); + }); + }); + + describe("/:alias/config", () => { + it("404s an unknown alias", async () => { + const plugin = makePlugin(); + const { router, getHandler } = createMockRouter(); + plugin.injectRoutes(router); + + const res = createMockResponse(); + await getHandler("GET", "/:alias/config")( + createMockRequest({ params: { alias: "nope" } }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(404); + }); + + it("returns resolved config with defaults", async () => { + const plugin = makePlugin(); + const { router, getHandler } = createMockRouter(); + plugin.injectRoutes(router); + + const res = createMockResponse(); + await getHandler("GET", "/:alias/config")( + createMockRequest({ params: { alias: "demo" } }), + res, + ); + + expect(res.json).toHaveBeenCalledWith({ + alias: "demo", + columns: ["id", "title"], + queryType: "hybrid", + numResults: 20, + reranker: false, + pagination: false, + }); + }); + }); + }); }); From aa60011b351c3c3f3b04116291bc068b5378c2c4 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 4 Aug 2026 10:58:24 +0200 Subject: [PATCH 06/27] feat(appkit): make ai-search query() generic over the result row type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SearchResponse/SearchResult were parameterized over the row shape, but query() returned the non-generic default and took no type parameter, so consumers could never supply T — result.data was always Record and had to be cast. Thread the generic through query() and _parseResponse() so `appkit.aiSearch.query(...)` yields a typed result.data. The dynamic column-to-data mapping keeps one boundary cast (data as T); the caller asserts T matches the configured columns. Backward-compatible: T defaults to Record, so existing callers are unaffected. Signed-off-by: MarioCadenas --- .../appkit/src/plugins/ai-search/ai-search.ts | 19 +++++++++++---- .../plugins/ai-search/tests/ai-search.test.ts | 23 +++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/appkit/src/plugins/ai-search/ai-search.ts b/packages/appkit/src/plugins/ai-search/ai-search.ts index c300261fa..8abd8ac79 100644 --- a/packages/appkit/src/plugins/ai-search/ai-search.ts +++ b/packages/appkit/src/plugins/ai-search/ai-search.ts @@ -13,6 +13,7 @@ import type { IndexConfig, SearchRequest, SearchResponse, + SearchResult, } from "./types"; const logger = createLogger("ai-search"); @@ -229,7 +230,10 @@ export class AiSearchPlugin extends Plugin { * Programmatic query API — available as `appkit.aiSearch.query()`. * When called through `asUser(req)`, executes with the user's credentials. */ - async query(alias: string, request: SearchRequest): Promise { + async query = Record>( + alias: string, + request: SearchRequest, + ): Promise> { const indexConfig = this._resolveIndex(alias); if (!indexConfig) { throw new Error(`No index configured with alias "${alias}"`); @@ -343,21 +347,26 @@ export class AiSearchPlugin extends Plugin { return { columnsToRerank: columns.filter((c) => c !== "id") }; } - private _parseResponse( + private _parseResponse< + T extends Record = Record, + >( raw: VsRawResponse, queryType: "ann" | "hybrid" | "full_text", - ): SearchResponse { + ): SearchResponse { const columnNames = raw.manifest.columns.map((c) => c.name); const scoreIndex = columnNames.indexOf("score"); - const results = raw.result.data_array.map((row) => { + // `data` is assembled dynamically from the index's returned columns, so + // its shape can't be statically verified against T — the caller asserts + // T matches the configured columns. Cast once here, at the boundary. + const results: SearchResult[] = raw.result.data_array.map((row) => { const data: Record = {}; for (let i = 0; i < columnNames.length; i++) { if (columnNames[i] !== "score") data[columnNames[i]] = row[i]; } return { score: scoreIndex >= 0 ? (row[scoreIndex] as number) : 0, - data, + data: data as T, }; }); diff --git a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts index 8d22bb3c4..b2d410c3c 100644 --- a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts +++ b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts @@ -200,6 +200,29 @@ describe("AiSearchPlugin", () => { expect(result.queryTimeMs).toBe(35); }); + it("types result.data via the generic parameter", async () => { + interface Doc extends Record { + id: number; + title: string; + } + const plugin = new AiSearchPlugin({ + indexes: { + products: { indexName: "cat.sch.products", columns: ["id", "title"] }, + }, + }); + await plugin.setup(); + + const result = await plugin.query("products", { + queryText: "machine learning", + }); + + // Compile-time: `data` is typed as Doc, so these fields resolve without + // a cast. Runtime: they carry the parsed values. + const first: Doc = result.results[0].data; + expect(first.id).toBe(1); + expect(first.title).toBe("ML Guide"); + }); + it("constructs correct API request", async () => { const plugin = new AiSearchPlugin({ indexes: { From a992ce13406917fe0ee3125391f48280e4e50398 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 4 Aug 2026 11:22:32 +0200 Subject: [PATCH 07/27] feat(appkit): expose plugins under a camelCase SDK alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-word plugins were only reachable on the appkit handle by their kebab-case manifest name (e.g. appkit["ai-search"]), because the accessor key, the TS PluginMap type, and the HTTP route prefix all derive from manifest.name. Add a camelCase alias derived from that name so ai-search is usable as appkit.aiSearch. - New kebabToCamel() runtime helper + KebabToCamel type in shared. - appkit.ts registers the plugin under its kebab name and, when the camelCase form differs, additively defines the same accessor under the alias. The kebab key keeps working; the HTTP route stays /api/ai-search. - PluginMap gains the camelCase keys as an intersection, so both appkit.aiSearch and appkit["ai-search"] type-check. Single-word names are a no-op (camel === kebab), so no behavior change there. This is uniform, so the GA ui-variants plugin also gains a uiVariants alias alongside its existing ui-variants key — additive, nothing breaks. Signed-off-by: MarioCadenas --- packages/appkit/src/core/appkit.ts | 15 +++++++-- .../appkit/src/core/tests/databricks.test.ts | 31 +++++++++++++++++++ packages/shared/src/plugin.ts | 30 +++++++++++++++--- 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/packages/appkit/src/core/appkit.ts b/packages/appkit/src/core/appkit.ts index 54f107568..b1c0ff9df 100644 --- a/packages/appkit/src/core/appkit.ts +++ b/packages/appkit/src/core/appkit.ts @@ -7,6 +7,7 @@ import type { PluginData, PluginMap, } from "shared"; +import { kebabToCamel } from "shared"; import { version as productVersion } from "../../package.json"; import { CacheManager } from "../cache"; import { ServiceContext } from "../context"; @@ -107,13 +108,23 @@ export class AppKit { const self = this; - Object.defineProperty(this, name, { + const accessor: PropertyDescriptor = { get() { const plugin = self.#pluginInstances[name]; return self.wrapWithAsUser(plugin); }, enumerable: true, - }); + }; + Object.defineProperty(this, name, accessor); + + // Also expose the plugin under a camelCase alias so multi-word plugins are + // reachable as `appkit.aiSearch` in addition to `appkit["ai-search"]`. The + // HTTP route prefix stays the kebab `name`; this is purely the SDK handle. + // No-op for single-word names (camel === name). + const camelName = kebabToCamel(name); + if (camelName !== name) { + Object.defineProperty(this, camelName, accessor); + } } /** diff --git a/packages/appkit/src/core/tests/databricks.test.ts b/packages/appkit/src/core/tests/databricks.test.ts index 7db561cdb..50d831ca5 100644 --- a/packages/appkit/src/core/tests/databricks.test.ts +++ b/packages/appkit/src/core/tests/databricks.test.ts @@ -504,6 +504,37 @@ describe("AppKit", () => { }); }); + describe("camelCase accessor alias", () => { + class MultiWordPlugin extends NormalTestPlugin { + static manifest = createTestManifest("multi-word"); + name = "multi-word"; + } + + test("exposes a multi-word plugin under both the kebab name and a camelCase alias", async () => { + const instance = (await createApp({ + plugins: [{ plugin: MultiWordPlugin, config: {}, name: "multi-word" }], + })) as any; + + expect(instance["multi-word"]).toBeDefined(); + expect(instance.multiWord).toBeDefined(); + // Both keys resolve to the same underlying plugin exports. + expect(instance.multiWord.setupCalled).toBe( + instance["multi-word"].setupCalled, + ); + }); + + test("does not add an alias key for single-word plugins", async () => { + const instance = (await createApp({ + plugins: [{ plugin: NormalTestPlugin, config: {}, name: "normalTest" }], + })) as any; + + // camelCase of a name with no hyphen is the name itself — no extra key. + expect( + Object.keys(instance).filter((k) => k === "normalTest"), + ).toHaveLength(1); + }); + }); + describe("preparePlugins", () => { test("should transform plugin data array to plugin map", () => { const pluginData = [ diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index 9800c261f..095966d0f 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -248,13 +248,31 @@ export type WithAsUser = SDK extends (...args: any[]) => any asUser: (req: IAppRequest) => SDK; }; +/** + * Converts a kebab-case plugin name to its camelCase form at the type level + * (e.g. `"ai-search"` -> `"aiSearch"`). Single-word names are unchanged. + * Mirrors {@link kebabToCamel}. + */ +export type KebabToCamel = + S extends `${infer Head}-${infer Tail}` + ? `${Head}${Capitalize>}` + : S; + +/** + * Runtime counterpart to {@link KebabToCamel}: `"ai-search"` -> `"aiSearch"`. + * A no-op for names without hyphens. + */ +export function kebabToCamel(name: string): string { + return name.replace(/-+([a-z0-9])/g, (_, c: string) => c.toUpperCase()); +} + /** * Maps plugin names to their exported types (with asUser automatically added). - * Each plugin exposes its public API via the exports() method, - * and AppKit wraps it with asUser() for user-scoped execution. * - * Callable exports (functions) are passed through without wrapping, - * as they manage their own `asUser` pattern (e.g. files plugin). + * Each plugin is reachable under both its declared kebab-case name + * (`appkit["ai-search"]`) and the camelCase alias derived from it + * (`appkit.aiSearch`); the alias equals the original for single-word names, so + * this only adds keys for multi-word plugins. */ export type PluginMap< U extends readonly PluginData[], @@ -262,6 +280,10 @@ export type PluginMap< [P in U[number] as P["name"]]: WithAsUser< PluginExports> >; +} & { + [P in U[number] as KebabToCamel]: WithAsUser< + PluginExports> + >; }; /** Tuple of plugin class, config, and name. Created by `toPlugin()` and passed to `createApp()`. */ From 4a9431da73305fb81d7132515d72b0798ecc2e17 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 4 Aug 2026 11:33:27 +0200 Subject: [PATCH 08/27] refactor(appkit): make the camelCase SDK key canonical, drop the kebab alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous change exposed multi-word plugins under both keys (appkit.aiSearch and appkit["ai-search"]). Register the accessor under the camelCase key only, so there is a single canonical handle. Internal lookups (#pluginInstances, context.registerPlugin) and the HTTP route prefix still use the kebab name — only the public property changes. PluginMap maps solely to the camelCase keys to match, so appkit["ai-search"] is now a type error as well as undefined at runtime. Single-word plugins are unaffected (camel === kebab). The GA ui-variants plugin moves from appkit["ui-variants"] to appkit.uiVariants (inert handle, no exports; route /api/ui-variants unchanged); its tests are updated accordingly. Signed-off-by: MarioCadenas --- packages/appkit/src/core/appkit.ts | 20 ++++++++----------- .../appkit/src/core/tests/databricks.test.ts | 20 +++++++++---------- packages/shared/src/plugin.ts | 15 +++++++------- 3 files changed, 25 insertions(+), 30 deletions(-) diff --git a/packages/appkit/src/core/appkit.ts b/packages/appkit/src/core/appkit.ts index b1c0ff9df..4282664c5 100644 --- a/packages/appkit/src/core/appkit.ts +++ b/packages/appkit/src/core/appkit.ts @@ -108,23 +108,19 @@ export class AppKit { const self = this; - const accessor: PropertyDescriptor = { + // The SDK handle key is always the camelCase form of the plugin name, so a + // multi-word plugin is reached as `appkit.aiSearch` (not + // `appkit["ai-search"]`). Internal lookups and the HTTP route prefix still + // use the kebab `name`; this only shapes the public accessor. For + // single-word names the camel form equals the name, so nothing changes. + const accessorKey = kebabToCamel(name); + Object.defineProperty(this, accessorKey, { get() { const plugin = self.#pluginInstances[name]; return self.wrapWithAsUser(plugin); }, enumerable: true, - }; - Object.defineProperty(this, name, accessor); - - // Also expose the plugin under a camelCase alias so multi-word plugins are - // reachable as `appkit.aiSearch` in addition to `appkit["ai-search"]`. The - // HTTP route prefix stays the kebab `name`; this is purely the SDK handle. - // No-op for single-word names (camel === name). - const camelName = kebabToCamel(name); - if (camelName !== name) { - Object.defineProperty(this, camelName, accessor); - } + }); } /** diff --git a/packages/appkit/src/core/tests/databricks.test.ts b/packages/appkit/src/core/tests/databricks.test.ts index 50d831ca5..64a9d68b7 100644 --- a/packages/appkit/src/core/tests/databricks.test.ts +++ b/packages/appkit/src/core/tests/databricks.test.ts @@ -472,7 +472,8 @@ describe("AppKit", () => { plugins: [{ plugin: NormalTestPlugin, config: {}, name: "normalTest" }], })) as any; - expect(instance["ui-variants"]).toBeDefined(); + // Exposed under the camelCase handle key, not the kebab manifest name. + expect(instance.uiVariants).toBeDefined(); }); test("drops the default ui-variants in production", async () => { @@ -484,7 +485,7 @@ describe("AppKit", () => { // The default is devOnly, so the same guard that skips user devOnly // plugins strips it from a deployed app. - expect(Object.keys(instance)).not.toContain("ui-variants"); + expect(Object.keys(instance)).not.toContain("uiVariants"); expect(instance.normalTest).toBeDefined(); }); @@ -496,10 +497,10 @@ describe("AppKit", () => { plugins: [explicit], })) as any; - expect(instance["ui-variants"]).toBeDefined(); + expect(instance.uiVariants).toBeDefined(); // Only one instance was constructed for the single name. expect( - Object.keys(instance).filter((k) => k === "ui-variants"), + Object.keys(instance).filter((k) => k === "uiVariants"), ).toHaveLength(1); }); }); @@ -510,17 +511,16 @@ describe("AppKit", () => { name = "multi-word"; } - test("exposes a multi-word plugin under both the kebab name and a camelCase alias", async () => { + test("exposes a multi-word plugin under its camelCase key, not the kebab name", async () => { const instance = (await createApp({ plugins: [{ plugin: MultiWordPlugin, config: {}, name: "multi-word" }], })) as any; - expect(instance["multi-word"]).toBeDefined(); expect(instance.multiWord).toBeDefined(); - // Both keys resolve to the same underlying plugin exports. - expect(instance.multiWord.setupCalled).toBe( - instance["multi-word"].setupCalled, - ); + expect(instance.multiWord.setupCalled).toBe(true); + // The kebab-case name is not exposed on the handle. + expect(instance["multi-word"]).toBeUndefined(); + expect(Object.keys(instance)).not.toContain("multi-word"); }); test("does not add an alias key for single-word plugins", async () => { diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index 095966d0f..644aa27a5 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -268,19 +268,18 @@ export function kebabToCamel(name: string): string { /** * Maps plugin names to their exported types (with asUser automatically added). + * Each plugin exposes its public API via the exports() method, and AppKit + * wraps it with asUser() for user-scoped execution. * - * Each plugin is reachable under both its declared kebab-case name - * (`appkit["ai-search"]`) and the camelCase alias derived from it - * (`appkit.aiSearch`); the alias equals the original for single-word names, so - * this only adds keys for multi-word plugins. + * The handle key is the camelCase form of the plugin name, so a multi-word + * plugin is reached as `appkit.aiSearch` rather than `appkit["ai-search"]`. + * For single-word names the camelCase form is identical, so the key is + * unchanged. Callable exports (functions) are passed through without wrapping, + * as they manage their own `asUser` pattern (e.g. files plugin). */ export type PluginMap< U extends readonly PluginData[], > = { - [P in U[number] as P["name"]]: WithAsUser< - PluginExports> - >; -} & { [P in U[number] as KebabToCamel]: WithAsUser< PluginExports> >; From e6516e78ff23fd98d89cfb6d91313124ca27a946 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 4 Aug 2026 12:02:55 +0200 Subject: [PATCH 09/27] refactor(appkit): dedup ai-search query payload, abort bridge, and kebab-camel Cleanups from a /simplify pass over the ai-search branch: - _prepareQuery now returns Omit (carries filters, renames rerankerConfig->reranker), so the /query route and programmatic query() collapse to `{ indexName, ...prepared }` instead of duplicating an 8-field payload. Removes the accidental body.filters vs request.filters split. - Hoist the AbortSignal->CancellationToken bridge into a shared connectors/context.ts (cancellationTokenFromAbortSignal + contextFromAbortSignal); ai-search and serving import it instead of each carrying a copy. - promote.ts uses the shared kebabToCamel instead of its own manifestNameToBinding copy. - _parseResponse skips the score column by index (i !== scoreIndex) rather than a per-cell string compare. - Trim verbose comments introduced on the branch to the one-line bar. Left alone (flagged, out of scope): the jobs connector's separate _createContext variant (different listener semantics), scaffold.ts's toCamelCase (different impl), and tools/generate-plugin-entries.ts (package boundary). Signed-off-by: MarioCadenas --- .../appkit/src/connectors/ai-search/client.ts | 51 +--------------- packages/appkit/src/connectors/context.ts | 52 ++++++++++++++++ .../appkit/src/connectors/serving/client.ts | 47 +-------------- packages/appkit/src/core/appkit.ts | 7 +-- .../appkit/src/plugins/ai-search/ai-search.ts | 59 ++++--------------- .../plugins/ai-search/tests/ai-search.test.ts | 8 +-- .../cli/commands/plugin/promote/promote.ts | 15 +---- packages/shared/src/plugin.ts | 22 +++---- 8 files changed, 85 insertions(+), 176 deletions(-) create mode 100644 packages/appkit/src/connectors/context.ts diff --git a/packages/appkit/src/connectors/ai-search/client.ts b/packages/appkit/src/connectors/ai-search/client.ts index 440b6fb36..eb5cc3d32 100644 --- a/packages/appkit/src/connectors/ai-search/client.ts +++ b/packages/appkit/src/connectors/ai-search/client.ts @@ -1,5 +1,3 @@ -import type { CancellationToken } from "@databricks/sdk-experimental"; -import { Context } from "@databricks/sdk-experimental"; import { createLogger } from "../../logging/logger"; import type { TelemetryProvider } from "../../telemetry"; import { @@ -9,6 +7,7 @@ import { TelemetryManager, } from "../../telemetry"; import type { WorkspaceClient } from "../../workspace-client"; +import { contextFromAbortSignal } from "../context"; import type { AiSearchConnectorConfig, VsNextPageParams, @@ -18,42 +17,6 @@ import type { const logger = createLogger("connectors:ai-search"); -/** - * Bridges {@link AbortSignal} to the SDK's {@link CancellationToken} so - * `apiClient.request` aborts the outbound HTTP request when the execution's - * timeout fires or the client disconnects. Mirrors the serving connector. - */ -function cancellationTokenFromAbortSignal( - signal: AbortSignal, -): CancellationToken { - const listeners = new Set<() => void>(); - signal.addEventListener( - "abort", - () => { - for (const cb of listeners) { - try { - cb(); - } catch { - // ignore listener failures — abort must stay best-effort - } - } - }, - { passive: true }, - ); - - return { - get isCancellationRequested() { - return signal.aborted; - }, - onCancellationRequested(callback: (e?: unknown) => unknown) { - listeners.add(callback as () => void); - if (signal.aborted) { - void callback(); - } - }, - }; -} - export class AiSearchConnector { private readonly telemetry: TelemetryProvider; @@ -126,11 +89,7 @@ export class AiSearchConnector { raw: false, query: {}, }, - signal - ? new Context({ - cancellationToken: cancellationTokenFromAbortSignal(signal), - }) - : undefined, + contextFromAbortSignal(signal), )) as VsRawResponse; const duration = Date.now() - startTime; @@ -203,11 +162,7 @@ export class AiSearchConnector { raw: false, query: {}, }, - signal - ? new Context({ - cancellationToken: cancellationTokenFromAbortSignal(signal), - }) - : undefined, + contextFromAbortSignal(signal), )) as VsRawResponse; span.setAttribute("vs.result_count", response.result.row_count); diff --git a/packages/appkit/src/connectors/context.ts b/packages/appkit/src/connectors/context.ts new file mode 100644 index 000000000..d1cdd82dd --- /dev/null +++ b/packages/appkit/src/connectors/context.ts @@ -0,0 +1,52 @@ +import { type CancellationToken, Context } from "../workspace-client"; + +/** + * Bridges {@link AbortSignal} to the SDK's {@link CancellationToken} so + * `apiClient.request` can abort the outbound HTTP request (and stop pulling a + * response body) when the caller aborts. + */ +function cancellationTokenFromAbortSignal( + signal: AbortSignal, +): CancellationToken { + const listeners = new Set<() => void>(); + signal.addEventListener( + "abort", + () => { + for (const cb of listeners) { + try { + cb(); + } catch { + // ignore listener failures — abort must stay best-effort + } + } + }, + { passive: true }, + ); + + return { + get isCancellationRequested() { + return signal.aborted; + }, + onCancellationRequested(callback: (e?: unknown) => unknown) { + listeners.add(callback as () => void); + if (signal.aborted) { + void callback(); + } + }, + }; +} + +/** + * Wraps an optional {@link AbortSignal} in an SDK {@link Context} for the + * second argument of `apiClient.request`. Returns `undefined` when no signal + * is given, so callers can pass the result straight through. + */ +export function contextFromAbortSignal( + signal?: AbortSignal, +): InstanceType | undefined { + return signal + ? new Context({ + cancellationToken: cancellationTokenFromAbortSignal(signal), + }) + : undefined; +} diff --git a/packages/appkit/src/connectors/serving/client.ts b/packages/appkit/src/connectors/serving/client.ts index ba54b6687..de9d0465c 100644 --- a/packages/appkit/src/connectors/serving/client.ts +++ b/packages/appkit/src/connectors/serving/client.ts @@ -1,46 +1,9 @@ import { createLogger } from "../../logging/logger"; -import { - type CancellationToken, - Context, - type serving, - type WorkspaceClient, -} from "../../workspace-client"; +import type { serving, WorkspaceClient } from "../../workspace-client"; +import { contextFromAbortSignal } from "../context"; const logger = createLogger("connectors:serving"); -/** - * Bridges {@link AbortSignal} to the SDK's {@link CancellationToken} so - * `apiClient.request` can abort the outbound HTTP request (and stop pulling - * the SSE body) when the agent run is cancelled. - */ -function cancellationTokenFromAbortSignal( - signal: AbortSignal, -): CancellationToken { - const listeners = new Set<() => void>(); - const fire = () => { - for (const cb of listeners) { - try { - cb(); - } catch { - // ignore listener failures — abort must stay best-effort - } - } - }; - signal.addEventListener("abort", fire, { passive: true }); - - return { - get isCancellationRequested() { - return signal.aborted; - }, - onCancellationRequested(callback: (e?: unknown) => unknown) { - listeners.add(callback as () => void); - if (signal.aborted) { - void callback(); - } - }, - }; -} - /** * Structural shape of a Databricks SDK client we need for the low-level * `apiClient.request` call. Lets `streamPath` be reused by adapters that @@ -115,11 +78,7 @@ export async function streamPath( ): Promise> { logger.debug("Streaming from path %s", path); - const context = signal - ? new Context({ - cancellationToken: cancellationTokenFromAbortSignal(signal), - }) - : undefined; + const context = contextFromAbortSignal(signal); const response = (await client.apiClient.request( { diff --git a/packages/appkit/src/core/appkit.ts b/packages/appkit/src/core/appkit.ts index 4282664c5..0f89b52c0 100644 --- a/packages/appkit/src/core/appkit.ts +++ b/packages/appkit/src/core/appkit.ts @@ -108,11 +108,8 @@ export class AppKit { const self = this; - // The SDK handle key is always the camelCase form of the plugin name, so a - // multi-word plugin is reached as `appkit.aiSearch` (not - // `appkit["ai-search"]`). Internal lookups and the HTTP route prefix still - // use the kebab `name`; this only shapes the public accessor. For - // single-word names the camel form equals the name, so nothing changes. + // The public handle key is camelCase; the kebab `name` still drives + // internal lookups and the HTTP route prefix. const accessorKey = kebabToCamel(name); Object.defineProperty(this, accessorKey, { get() { diff --git a/packages/appkit/src/plugins/ai-search/ai-search.ts b/packages/appkit/src/plugins/ai-search/ai-search.ts index 8abd8ac79..e62e5fd0f 100644 --- a/packages/appkit/src/plugins/ai-search/ai-search.ts +++ b/packages/appkit/src/plugins/ai-search/ai-search.ts @@ -1,7 +1,10 @@ import type express from "express"; import type { IAppRouter, PluginExecutionSettings } from "shared"; import { AiSearchConnector } from "../../connectors/ai-search/client"; -import type { VsRawResponse } from "../../connectors/ai-search/types"; +import type { + VsQueryParams, + VsRawResponse, +} from "../../connectors/ai-search/types"; import { getWorkspaceClient } from "../../context"; import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; @@ -100,16 +103,7 @@ export class AiSearchPlugin extends Plugin { async (signal) => this.connector.query( getWorkspaceClient(), - { - indexName: indexConfig.indexName, - queryText: prepared.queryText, - queryVector: prepared.queryVector, - columns: prepared.columns, - numResults: prepared.numResults, - queryType: prepared.queryType, - filters: body.filters, - reranker: prepared.rerankerConfig, - }, + { indexName: indexConfig.indexName, ...prepared }, signal, ), querySettings, @@ -245,16 +239,7 @@ export class AiSearchPlugin extends Plugin { async (signal) => this.connector.query( getWorkspaceClient(), - { - indexName: indexConfig.indexName, - queryText: prepared.queryText, - queryVector: prepared.queryVector, - columns: prepared.columns, - numResults: prepared.numResults, - queryType: prepared.queryType, - filters: request.filters, - reranker: prepared.rerankerConfig, - }, + { indexName: indexConfig.indexName, ...prepared }, signal, ), querySettings, @@ -286,22 +271,12 @@ export class AiSearchPlugin extends Plugin { private async _prepareQuery( request: SearchRequest, indexConfig: IndexConfig, - ): Promise<{ - queryText: string | undefined; - queryVector: number[] | undefined; - queryType: "ann" | "hybrid" | "full_text"; - columns: string[]; - numResults: number; - rerankerConfig: { columnsToRerank: string[] } | undefined; - }> { + ): Promise> { const queryType = request.queryType ?? indexConfig.queryType ?? "hybrid"; let queryText = request.queryText; let queryVector = request.queryVector; - // Self-managed embedding indexes need a query_vector for the vector half - // of the search (ann, hybrid). full_text never uses a vector, so skip - // embedding entirely. Only ann is vector-only — for hybrid the text is - // still needed for the keyword half, so keep queryText. + // full_text uses no vector; hybrid keeps the text for its keyword half. if ( indexConfig.embeddingFn && queryText && @@ -325,11 +300,8 @@ export class AiSearchPlugin extends Plugin { queryType, columns, numResults: request.numResults ?? indexConfig.numResults ?? 20, - rerankerConfig: this._resolveReranker( - request.reranker, - indexConfig, - columns, - ), + filters: request.filters, + reranker: this._resolveReranker(request.reranker, indexConfig, columns), }; } @@ -356,13 +328,11 @@ export class AiSearchPlugin extends Plugin { const columnNames = raw.manifest.columns.map((c) => c.name); const scoreIndex = columnNames.indexOf("score"); - // `data` is assembled dynamically from the index's returned columns, so - // its shape can't be statically verified against T — the caller asserts - // T matches the configured columns. Cast once here, at the boundary. + // `data` is built dynamically, so T is the caller's unchecked assertion. const results: SearchResult[] = raw.result.data_array.map((row) => { const data: Record = {}; for (let i = 0; i < columnNames.length; i++) { - if (columnNames[i] !== "score") data[columnNames[i]] = row[i]; + if (i !== scoreIndex) data[columnNames[i]] = row[i]; } return { score: scoreIndex >= 0 ? (row[scoreIndex] as number) : 0, @@ -386,10 +356,7 @@ export class AiSearchPlugin extends Plugin { fallbackMessage: string, ): void { logger.error("%s: %O", fallbackMessage, error); - // Mirror the base Plugin.execute() convention: only surface the raw error - // message outside production. In production the detail stays in the log - // above and the client gets the generic fallback, so upstream error text - // (e.g. from a user-supplied embeddingFn) isn't leaked. + // Match Plugin.execute(): the raw message is only exposed outside production. const isDev = process.env.NODE_ENV !== "production"; const message = isDev && error instanceof Error ? error.message : fallbackMessage; diff --git a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts index b2d410c3c..1f020ba27 100644 --- a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts +++ b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts @@ -216,8 +216,7 @@ describe("AiSearchPlugin", () => { queryText: "machine learning", }); - // Compile-time: `data` is typed as Doc, so these fields resolve without - // a cast. Runtime: they carry the parsed values. + // `data` is typed as Doc — these fields resolve without a cast. const first: Doc = result.results[0].data; expect(first.id).toBe(1); expect(first.title).toBe("ML Guide"); @@ -612,9 +611,8 @@ describe("AiSearchPlugin", () => { }); it("500s (via _handleError) when query preparation throws", async () => { - // A throw *outside* execute() (here, a failing embeddingFn) reaches the - // handler's catch → _handleError → 500. Connector failures instead flow - // through execute() as a non-ok result with its own status. + // A throw outside execute() (failing embeddingFn) hits _handleError; + // connector failures instead surface as a non-ok result. const plugin = new AiSearchPlugin({ indexes: { demo: { diff --git a/packages/shared/src/cli/commands/plugin/promote/promote.ts b/packages/shared/src/cli/commands/plugin/promote/promote.ts index 39d62d602..69a25b0e3 100644 --- a/packages/shared/src/cli/commands/plugin/promote/promote.ts +++ b/packages/shared/src/cli/commands/plugin/promote/promote.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { Command } from "commander"; +import { kebabToCamel } from "../../../../plugin"; import { resolveManifestInDir } from "../manifest-resolve"; import { isWithinDirectory } from "../sync/sync"; import { shouldAllowJsManifestForDir } from "../trusted-js-manifest"; @@ -172,18 +173,6 @@ function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -/** - * Convert a kebab-case manifest name to its camelCase JS identifier form - * (e.g. `vector-search` -> `vectorSearch`). Mirrors the convention used by - * first-party plugin index files: a manifest's `name` field may be - * kebab-case (the schema permits `^[a-z][a-z0-9-]*$`), but the actual - * exported binding is always a JS identifier. We try both forms when - * matching specifiers in user code. - */ -function manifestNameToBinding(pluginName: string): string { - return pluginName.replace(/-+([a-z0-9])/g, (_, c: string) => c.toUpperCase()); -} - /** * Returns true when the named import specifier `spec` resolves to either * `pluginName` itself or its kebab-to-camelCase JS-identifier form. @@ -196,7 +185,7 @@ function specifierMatchesPlugin(spec: string, pluginName: string): boolean { const stripped = spec.replace(/^type\s+/, "").trim(); const head = stripped.split(/\s+as\s+/)[0]?.trim(); if (!head) return false; - return head === pluginName || head === manifestNameToBinding(pluginName); + return head === pluginName || head === kebabToCamel(pluginName); } /** diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index 644aa27a5..98927415e 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -248,20 +248,13 @@ export type WithAsUser = SDK extends (...args: any[]) => any asUser: (req: IAppRequest) => SDK; }; -/** - * Converts a kebab-case plugin name to its camelCase form at the type level - * (e.g. `"ai-search"` -> `"aiSearch"`). Single-word names are unchanged. - * Mirrors {@link kebabToCamel}. - */ +/** Type-level kebab-to-camelCase (e.g. `"ai-search"` -> `"aiSearch"`). */ export type KebabToCamel = S extends `${infer Head}-${infer Tail}` ? `${Head}${Capitalize>}` : S; -/** - * Runtime counterpart to {@link KebabToCamel}: `"ai-search"` -> `"aiSearch"`. - * A no-op for names without hyphens. - */ +/** Runtime {@link KebabToCamel}. */ export function kebabToCamel(name: string): string { return name.replace(/-+([a-z0-9])/g, (_, c: string) => c.toUpperCase()); } @@ -269,13 +262,12 @@ export function kebabToCamel(name: string): string { /** * Maps plugin names to their exported types (with asUser automatically added). * Each plugin exposes its public API via the exports() method, and AppKit - * wraps it with asUser() for user-scoped execution. + * wraps it with asUser() for user-scoped execution. Callable exports + * (functions) are passed through without wrapping, as they manage their own + * `asUser` pattern (e.g. files plugin). * - * The handle key is the camelCase form of the plugin name, so a multi-word - * plugin is reached as `appkit.aiSearch` rather than `appkit["ai-search"]`. - * For single-word names the camelCase form is identical, so the key is - * unchanged. Callable exports (functions) are passed through without wrapping, - * as they manage their own `asUser` pattern (e.g. files plugin). + * The key is the camelCase form of the plugin name (`appkit.aiSearch`, not + * `appkit["ai-search"]`). */ export type PluginMap< U extends readonly PluginData[], From 2a64f33ab6033ad5d7bf9da2af267d548b54bdac Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 4 Aug 2026 12:28:54 +0200 Subject: [PATCH 10/27] refactor(appkit): trim ai-search setup() to non-type-expressible checks setup() re-validated fields the TS types and manifest config.schema already require (at least one index; columns non-empty), duplicating guarantees the config layer provides. Drop those and keep only the two runtime checks types can't express: an empty indexName (an env var populating it was unset) and the pagination -> endpointName dependency. Also drops the stale 'Vector Search plugin configured' debug log (no sibling plugin logs this). This does not overlap with the framework's ResourceRegistry, which validates the manifest's declared resource env vars, not the indexes config object. Signed-off-by: MarioCadenas --- .../appkit/src/plugins/ai-search/ai-search.ts | 19 +++++-------------- .../plugins/ai-search/tests/ai-search.test.ts | 11 +---------- 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/packages/appkit/src/plugins/ai-search/ai-search.ts b/packages/appkit/src/plugins/ai-search/ai-search.ts index e62e5fd0f..80b89b0c8 100644 --- a/packages/appkit/src/plugins/ai-search/ai-search.ts +++ b/packages/appkit/src/plugins/ai-search/ai-search.ts @@ -44,30 +44,21 @@ export class AiSearchPlugin extends Plugin { } async setup(): Promise { - if (!this.config.indexes || Object.keys(this.config.indexes).length === 0) { - throw new Error( - 'AiSearchPlugin requires at least one index in "indexes" config', - ); - } - for (const [alias, idx] of Object.entries(this.config.indexes)) { + // Only validate what the config schema and TS types can't: an empty + // indexName (an env var populating it was unset) and the runtime + // pagination -> endpointName dependency. + for (const [alias, idx] of Object.entries(this.config.indexes ?? {})) { if (!idx.indexName) { throw new Error( - `Index "${alias}" is missing required field "indexName"`, + `Index "${alias}" has an empty "indexName" (an env var populating it may be unset)`, ); } - if (!idx.columns || idx.columns.length === 0) { - throw new Error(`Index "${alias}" is missing required field "columns"`); - } if (idx.pagination && !idx.endpointName) { throw new Error( `Index "${alias}" has pagination enabled but is missing "endpointName"`, ); } } - logger.debug( - "Vector Search plugin configured with %d index(es)", - Object.keys(this.config.indexes).length, - ); } injectRoutes(router: IAppRouter) { diff --git a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts index 1f020ba27..4eafee0c4 100644 --- a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts +++ b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts @@ -110,7 +110,7 @@ describe("AiSearchPlugin", () => { }); describe("setup()", () => { - it("throws if any index is missing indexName", async () => { + it("throws if an index has an empty indexName (unset env var)", async () => { const plugin = new AiSearchPlugin({ indexes: { test: { indexName: "", columns: ["id"] }, @@ -119,15 +119,6 @@ describe("AiSearchPlugin", () => { await expect(plugin.setup()).rejects.toThrow("indexName"); }); - it("throws if any index is missing columns", async () => { - const plugin = new AiSearchPlugin({ - indexes: { - test: { indexName: "cat.sch.idx", columns: [] }, - }, - }); - await expect(plugin.setup()).rejects.toThrow("columns"); - }); - it("throws if pagination enabled but no endpointName", async () => { const plugin = new AiSearchPlugin({ indexes: { From 80ef7e3e2727d394596e88c3ecd78a5128525311 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 4 Aug 2026 12:52:54 +0200 Subject: [PATCH 11/27] feat(appkit): default ai-search indexName to DATABRICKS_VS_INDEX_NAME Make IndexConfig.indexName optional and resolve it from the DATABRICKS_VS_INDEX_NAME env var when omitted, so the manifest's declared resource env var actually feeds config. columns stays required (the VS query API requires it and it can't be env-derived). Resolution happens once in _resolveIndex, so both the HTTP routes and the programmatic query() pick up the default; setup() now rejects only an index whose indexName resolves from neither config nor env. Manifest config.schema drops indexName from required. Also document that IndexConfig.auth governs the built-in HTTP routes; programmatic callers select OBO per call via appkit.aiSearch.asUser(req). auth is kept as the declarative route toggle, mirroring the files plugin (the only other plugin with per-resource auth config). Signed-off-by: MarioCadenas --- docs/docs/api/appkit/Interface.IndexConfig.md | 11 ++++--- docs/docs/plugins/ai-search.md | 2 +- .../appkit/src/plugins/ai-search/ai-search.ts | 20 +++++++----- .../src/plugins/ai-search/manifest.json | 2 +- .../plugins/ai-search/tests/ai-search.test.ts | 31 +++++++++++++++++-- .../appkit/src/plugins/ai-search/types.ts | 13 ++++++-- 6 files changed, 60 insertions(+), 19 deletions(-) diff --git a/docs/docs/api/appkit/Interface.IndexConfig.md b/docs/docs/api/appkit/Interface.IndexConfig.md index 392a10011..fea9dc84e 100644 --- a/docs/docs/api/appkit/Interface.IndexConfig.md +++ b/docs/docs/api/appkit/Interface.IndexConfig.md @@ -8,7 +8,9 @@ optional auth: "service-principal" | "on-behalf-of-user"; ``` -Auth mode — "service-principal" uses the app's SP, "on-behalf-of-user" proxies the logged-in user's token +Auth mode for the built-in HTTP routes — "service-principal" (default) +uses the app's SP, "on-behalf-of-user" proxies the logged-in user's token. +Programmatic callers select per call via `appkit.aiSearch.asUser(req)`. *** @@ -54,13 +56,14 @@ VS endpoint name (required when pagination is true) *** -### indexName +### indexName? ```ts -indexName: string; +optional indexName: string; ``` -Three-level UC name: catalog.schema.index_name +Three-level UC name: catalog.schema.index_name. Defaults to the +`DATABRICKS_VS_INDEX_NAME` env var when omitted. *** diff --git a/docs/docs/plugins/ai-search.md b/docs/docs/plugins/ai-search.md index b7ab84c74..88bee4164 100644 --- a/docs/docs/plugins/ai-search.md +++ b/docs/docs/plugins/ai-search.md @@ -74,7 +74,7 @@ aiSearch({ | Field | Type | Default | Description | |-------|------|---------|-------------| -| `indexName` | `string` | — | **Required.** Three-level Unity Catalog name (`catalog.schema.index`) | +| `indexName` | `string` | `DATABRICKS_VS_INDEX_NAME` | Three-level Unity Catalog name (`catalog.schema.index`). Defaults to the `DATABRICKS_VS_INDEX_NAME` env var when omitted. | | `columns` | `string[]` | — | **Required.** Columns to return in query results | | `queryType` | `"ann" \| "hybrid" \| "full_text"` | `"hybrid"` | Search mode | | `numResults` | `number` | `20` | Maximum results per query | diff --git a/packages/appkit/src/plugins/ai-search/ai-search.ts b/packages/appkit/src/plugins/ai-search/ai-search.ts index 80b89b0c8..70a6d9b88 100644 --- a/packages/appkit/src/plugins/ai-search/ai-search.ts +++ b/packages/appkit/src/plugins/ai-search/ai-search.ts @@ -44,13 +44,13 @@ export class AiSearchPlugin extends Plugin { } async setup(): Promise { - // Only validate what the config schema and TS types can't: an empty - // indexName (an env var populating it was unset) and the runtime - // pagination -> endpointName dependency. + // Only validate what the config schema and TS types can't: an indexName + // that resolves to nothing (neither config nor DATABRICKS_VS_INDEX_NAME + // set) and the runtime pagination -> endpointName dependency. for (const [alias, idx] of Object.entries(this.config.indexes ?? {})) { - if (!idx.indexName) { + if (!this._resolveIndex(alias)) { throw new Error( - `Index "${alias}" has an empty "indexName" (an env var populating it may be unset)`, + `Index "${alias}" has no indexName (set it in config or via DATABRICKS_VS_INDEX_NAME)`, ); } if (idx.pagination && !idx.endpointName) { @@ -255,8 +255,14 @@ export class AiSearchPlugin extends Plugin { }; } - private _resolveIndex(alias: string): IndexConfig | undefined { - return this.config.indexes?.[alias]; + private _resolveIndex( + alias: string, + ): (IndexConfig & { indexName: string }) | undefined { + const idx = this.config.indexes?.[alias]; + if (!idx) return undefined; + const indexName = idx.indexName ?? process.env.DATABRICKS_VS_INDEX_NAME; + if (!indexName) return undefined; + return { ...idx, indexName }; } private async _prepareQuery( diff --git a/packages/appkit/src/plugins/ai-search/manifest.json b/packages/appkit/src/plugins/ai-search/manifest.json index 416f1ee3a..c04b8fded 100644 --- a/packages/appkit/src/plugins/ai-search/manifest.json +++ b/packages/appkit/src/plugins/ai-search/manifest.json @@ -55,7 +55,7 @@ "default": 20 } }, - "required": ["indexName", "columns"] + "required": ["columns"] } }, "timeout": { diff --git a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts index 4eafee0c4..ad7a614ac 100644 --- a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts +++ b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts @@ -3,7 +3,7 @@ import { createMockResponse, createMockRouter, } from "@tools/test-helpers"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../../../context", () => ({ getWorkspaceClient: vi.fn(() => mockWorkspaceClient), @@ -110,15 +110,40 @@ describe("AiSearchPlugin", () => { }); describe("setup()", () => { - it("throws if an index has an empty indexName (unset env var)", async () => { + const originalIndexEnv = process.env.DATABRICKS_VS_INDEX_NAME; + afterEach(() => { + if (originalIndexEnv === undefined) { + delete process.env.DATABRICKS_VS_INDEX_NAME; + } else { + process.env.DATABRICKS_VS_INDEX_NAME = originalIndexEnv; + } + }); + + it("throws if indexName is unset in both config and env", async () => { + delete process.env.DATABRICKS_VS_INDEX_NAME; const plugin = new AiSearchPlugin({ indexes: { - test: { indexName: "", columns: ["id"] }, + test: { columns: ["id"] }, }, }); await expect(plugin.setup()).rejects.toThrow("indexName"); }); + it("defaults indexName from DATABRICKS_VS_INDEX_NAME when omitted", async () => { + process.env.DATABRICKS_VS_INDEX_NAME = "cat.sch.from_env"; + const plugin = new AiSearchPlugin({ + indexes: { + test: { columns: ["id"] }, + }, + }); + await expect(plugin.setup()).resolves.not.toThrow(); + + await plugin.query("test", { queryText: "q" }); + expect(mockRequest.mock.calls[0][0].path).toBe( + "/api/2.0/vector-search/indexes/cat.sch.from_env/query", + ); + }); + it("throws if pagination enabled but no endpointName", async () => { const plugin = new AiSearchPlugin({ indexes: { diff --git a/packages/appkit/src/plugins/ai-search/types.ts b/packages/appkit/src/plugins/ai-search/types.ts index c791c793b..4d8cd889c 100644 --- a/packages/appkit/src/plugins/ai-search/types.ts +++ b/packages/appkit/src/plugins/ai-search/types.ts @@ -6,8 +6,11 @@ export interface IAiSearchConfig extends BasePluginConfig { } export interface IndexConfig { - /** Three-level UC name: catalog.schema.index_name */ - indexName: string; + /** + * Three-level UC name: catalog.schema.index_name. Defaults to the + * `DATABRICKS_VS_INDEX_NAME` env var when omitted. + */ + indexName?: string; /** Columns to return in results */ columns: string[]; /** Default search mode */ @@ -16,7 +19,11 @@ export interface IndexConfig { numResults?: number; /** Enable built-in reranker. Pass true to rerank all non-id columns, or an object for fine control. */ reranker?: boolean | RerankerConfig; - /** Auth mode — "service-principal" uses the app's SP, "on-behalf-of-user" proxies the logged-in user's token */ + /** + * Auth mode for the built-in HTTP routes — "service-principal" (default) + * uses the app's SP, "on-behalf-of-user" proxies the logged-in user's token. + * Programmatic callers select per call via `appkit.aiSearch.asUser(req)`. + */ auth?: "service-principal" | "on-behalf-of-user"; /** Enable cursor pagination */ pagination?: boolean; From 15211eb94f92a5bfff1e3ecd6526eaedf49933cd Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 4 Aug 2026 13:04:14 +0200 Subject: [PATCH 12/27] fix(appkit): don't crash dev when ai-search indexName is unresolved setup() threw unconditionally on an index whose indexName resolves from neither config nor DATABRICKS_VS_INDEX_NAME. That contradicted the framework's resource-validation policy, which only warns for missing resources in dev (NODE_ENV=development) and throws in prod. It also fired after that warning had already been emitted, so a dev app crashed despite the graceful path. Mirror the framework policy: warn and skip the unusable index in dev, throw outside dev. The pagination -> endpointName check stays a hard throw in all modes since it's a config logic error, not a missing resource. The dev-playground demo drops its manual env fallback now that the plugin resolves indexName from the env var itself. The genie plugin has the same unconditional-throw pattern for a missing space ID; left as-is (out of scope) but worth a follow-up. Signed-off-by: MarioCadenas --- apps/dev-playground/server/index.ts | 2 -- .../appkit/src/plugins/ai-search/ai-search.ts | 17 +++++++++++------ .../plugins/ai-search/tests/ai-search.test.ts | 16 +++++++++++++++- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts index e286034c8..b30c51684 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -433,8 +433,6 @@ createApp({ aiSearch({ indexes: { demo: { - indexName: - process.env.DATABRICKS_VS_INDEX_NAME ?? "catalog.schema.index", columns: ["id", "text", "title"], queryType: "hybrid", }, diff --git a/packages/appkit/src/plugins/ai-search/ai-search.ts b/packages/appkit/src/plugins/ai-search/ai-search.ts index 70a6d9b88..75ba1572e 100644 --- a/packages/appkit/src/plugins/ai-search/ai-search.ts +++ b/packages/appkit/src/plugins/ai-search/ai-search.ts @@ -44,15 +44,20 @@ export class AiSearchPlugin extends Plugin { } async setup(): Promise { - // Only validate what the config schema and TS types can't: an indexName - // that resolves to nothing (neither config nor DATABRICKS_VS_INDEX_NAME - // set) and the runtime pagination -> endpointName dependency. + const isDev = process.env.NODE_ENV === "development"; for (const [alias, idx] of Object.entries(this.config.indexes ?? {})) { + // A missing indexName is a missing-resource condition, which the + // framework's resource validation already reports (warn in dev, throw + // in prod). Mirror that policy here instead of hard-crashing dev. if (!this._resolveIndex(alias)) { - throw new Error( - `Index "${alias}" has no indexName (set it in config or via DATABRICKS_VS_INDEX_NAME)`, - ); + const message = `Index "${alias}" has no indexName (set it in config or via DATABRICKS_VS_INDEX_NAME)`; + if (isDev) { + logger.warn(message); + continue; + } + throw new Error(message); } + // A config logic error, not a missing resource — always fail fast. if (idx.pagination && !idx.endpointName) { throw new Error( `Index "${alias}" has pagination enabled but is missing "endpointName"`, diff --git a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts index ad7a614ac..13cc3a8a7 100644 --- a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts +++ b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts @@ -111,16 +111,19 @@ describe("AiSearchPlugin", () => { describe("setup()", () => { const originalIndexEnv = process.env.DATABRICKS_VS_INDEX_NAME; + const originalNodeEnv = process.env.NODE_ENV; afterEach(() => { if (originalIndexEnv === undefined) { delete process.env.DATABRICKS_VS_INDEX_NAME; } else { process.env.DATABRICKS_VS_INDEX_NAME = originalIndexEnv; } + process.env.NODE_ENV = originalNodeEnv; }); - it("throws if indexName is unset in both config and env", async () => { + it("throws outside dev if indexName is unset in both config and env", async () => { delete process.env.DATABRICKS_VS_INDEX_NAME; + process.env.NODE_ENV = "production"; const plugin = new AiSearchPlugin({ indexes: { test: { columns: ["id"] }, @@ -129,6 +132,17 @@ describe("AiSearchPlugin", () => { await expect(plugin.setup()).rejects.toThrow("indexName"); }); + it("only warns (does not throw) for a missing indexName in dev", async () => { + delete process.env.DATABRICKS_VS_INDEX_NAME; + process.env.NODE_ENV = "development"; + const plugin = new AiSearchPlugin({ + indexes: { + test: { columns: ["id"] }, + }, + }); + await expect(plugin.setup()).resolves.not.toThrow(); + }); + it("defaults indexName from DATABRICKS_VS_INDEX_NAME when omitted", async () => { process.env.DATABRICKS_VS_INDEX_NAME = "cat.sch.from_env"; const plugin = new AiSearchPlugin({ From 60ebe31684cbd54e82bfe56057626eb9532fbd59 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 4 Aug 2026 14:47:00 +0200 Subject: [PATCH 13/27] refactor(appkit): let the framework own ai-search missing-resource policy setup()'s indexName-presence check duplicated the framework's resource validation, which already warns-in-dev / throws-in-prod for a missing DATABRICKS_VS_INDEX_NAME and runs before setup(). The inline NODE_ENV gate was therefore redundant, its prod-throw branch was unreachable in the real createApp flow, and it silently ignored APPKIT_STRICT_VALIDATION (which the registry honors). Drop it and keep only the pagination -> endpointName check, a config logic error the registry can't see. Unresolved aliases still 404 on the routes and throw in query(). Removes the two now-obsolete setup() tests. Signed-off-by: MarioCadenas --- .../appkit/src/plugins/ai-search/ai-search.ts | 17 ++++--------- .../plugins/ai-search/tests/ai-search.test.ts | 24 ------------------- 2 files changed, 4 insertions(+), 37 deletions(-) diff --git a/packages/appkit/src/plugins/ai-search/ai-search.ts b/packages/appkit/src/plugins/ai-search/ai-search.ts index 75ba1572e..2927cc86a 100644 --- a/packages/appkit/src/plugins/ai-search/ai-search.ts +++ b/packages/appkit/src/plugins/ai-search/ai-search.ts @@ -44,20 +44,11 @@ export class AiSearchPlugin extends Plugin { } async setup(): Promise { - const isDev = process.env.NODE_ENV === "development"; + // A missing indexName is a missing-resource condition owned by the + // framework's resource validation (warn in dev, throw in prod). Only the + // pagination -> endpointName dependency is a config logic error the + // framework can't see, so it's the sole check here. for (const [alias, idx] of Object.entries(this.config.indexes ?? {})) { - // A missing indexName is a missing-resource condition, which the - // framework's resource validation already reports (warn in dev, throw - // in prod). Mirror that policy here instead of hard-crashing dev. - if (!this._resolveIndex(alias)) { - const message = `Index "${alias}" has no indexName (set it in config or via DATABRICKS_VS_INDEX_NAME)`; - if (isDev) { - logger.warn(message); - continue; - } - throw new Error(message); - } - // A config logic error, not a missing resource — always fail fast. if (idx.pagination && !idx.endpointName) { throw new Error( `Index "${alias}" has pagination enabled but is missing "endpointName"`, diff --git a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts index 13cc3a8a7..4d82ec6f4 100644 --- a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts +++ b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts @@ -111,36 +111,12 @@ describe("AiSearchPlugin", () => { describe("setup()", () => { const originalIndexEnv = process.env.DATABRICKS_VS_INDEX_NAME; - const originalNodeEnv = process.env.NODE_ENV; afterEach(() => { if (originalIndexEnv === undefined) { delete process.env.DATABRICKS_VS_INDEX_NAME; } else { process.env.DATABRICKS_VS_INDEX_NAME = originalIndexEnv; } - process.env.NODE_ENV = originalNodeEnv; - }); - - it("throws outside dev if indexName is unset in both config and env", async () => { - delete process.env.DATABRICKS_VS_INDEX_NAME; - process.env.NODE_ENV = "production"; - const plugin = new AiSearchPlugin({ - indexes: { - test: { columns: ["id"] }, - }, - }); - await expect(plugin.setup()).rejects.toThrow("indexName"); - }); - - it("only warns (does not throw) for a missing indexName in dev", async () => { - delete process.env.DATABRICKS_VS_INDEX_NAME; - process.env.NODE_ENV = "development"; - const plugin = new AiSearchPlugin({ - indexes: { - test: { columns: ["id"] }, - }, - }); - await expect(plugin.setup()).resolves.not.toThrow(); }); it("defaults indexName from DATABRICKS_VS_INDEX_NAME when omitted", async () => { From 70d3b4b9c3e3a75ab2adc044cc6980920539e627 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 4 Aug 2026 15:32:59 +0200 Subject: [PATCH 14/27] fix(appkit): extract kebabToCamel to a leaf module so the bundled CLI can reuse it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier dedup pointed promote.ts at kebabToCamel in plugin.ts. That resolved in-repo but broke the published tarball: the CLI is copied to appkit's dist/cli flattened, and its `../../../../plugin` import pointed at a dist/plugin.js that dist-appkit.ts never copies — so `npm run typegen` in the template failed with ERR_MODULE_NOT_FOUND (caught by the PR Template Artifact check). Move kebabToCamel + KebabToCamel into a dependency-free leaf module shared/src/naming.ts. plugin.ts re-exports them (so `from "shared"` consumers like appkit.ts are unchanged), promote.ts imports from ../../../../naming, and dist-appkit.ts copies naming.js next to the CLI in the tarball — mirroring how it already copies schemas/. Verified by building the prerelease tarball and loading the bundled promote.js: it now resolves naming.js and imports cleanly. Genuine single-source (no duplicated helper) that survives bundling, rather than dragging the heavy plugin.ts graph into the CLI bundle. Signed-off-by: MarioCadenas --- .../src/cli/commands/plugin/promote/promote.ts | 2 +- packages/shared/src/naming.ts | 16 ++++++++++++++++ packages/shared/src/plugin.ts | 14 +++----------- tools/dist-appkit.ts | 11 +++++++++++ 4 files changed, 31 insertions(+), 12 deletions(-) create mode 100644 packages/shared/src/naming.ts diff --git a/packages/shared/src/cli/commands/plugin/promote/promote.ts b/packages/shared/src/cli/commands/plugin/promote/promote.ts index 69a25b0e3..6f6506fc7 100644 --- a/packages/shared/src/cli/commands/plugin/promote/promote.ts +++ b/packages/shared/src/cli/commands/plugin/promote/promote.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { Command } from "commander"; -import { kebabToCamel } from "../../../../plugin"; +import { kebabToCamel } from "../../../../naming"; import { resolveManifestInDir } from "../manifest-resolve"; import { isWithinDirectory } from "../sync/sync"; import { shouldAllowJsManifestForDir } from "../trusted-js-manifest"; diff --git a/packages/shared/src/naming.ts b/packages/shared/src/naming.ts new file mode 100644 index 000000000..cb49489ca --- /dev/null +++ b/packages/shared/src/naming.ts @@ -0,0 +1,16 @@ +/** + * Plugin-name casing helpers. A dependency-free leaf module so both the + * runtime (appkit) and the bundled CLI can import it without pulling in the + * heavier `plugin.ts` graph. + */ + +/** Type-level kebab-to-camelCase (e.g. `"ai-search"` -> `"aiSearch"`). */ +export type KebabToCamel = + S extends `${infer Head}-${infer Tail}` + ? `${Head}${Capitalize>}` + : S; + +/** Runtime {@link KebabToCamel}. */ +export function kebabToCamel(name: string): string { + return name.replace(/-+([a-z0-9])/g, (_, c: string) => c.toUpperCase()); +} diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index 98927415e..1f515cae2 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -1,5 +1,6 @@ import type express from "express"; import type { JSONSchema7 } from "json-schema"; +import { type KebabToCamel, kebabToCamel } from "./naming"; import type { DiscoveryDescriptor, PluginManifest as GeneratedPluginManifest, @@ -11,6 +12,8 @@ import type { // Sourced from `./schemas/manifest` (the Zod canonical) so `DiscoveryDescriptor` // stays the discriminated union shape rather than the free-form predecessor. export type { ResourceFieldEntry, DiscoveryDescriptor, PluginScaffoldingRules }; +// Re-export the naming helpers so `shared` consumers keep importing them here. +export { type KebabToCamel, kebabToCamel }; /** Base plugin interface. */ export interface BasePlugin { @@ -248,17 +251,6 @@ export type WithAsUser = SDK extends (...args: any[]) => any asUser: (req: IAppRequest) => SDK; }; -/** Type-level kebab-to-camelCase (e.g. `"ai-search"` -> `"aiSearch"`). */ -export type KebabToCamel = - S extends `${infer Head}-${infer Tail}` - ? `${Head}${Capitalize>}` - : S; - -/** Runtime {@link KebabToCamel}. */ -export function kebabToCamel(name: string): string { - return name.replace(/-+([a-z0-9])/g, (_, c: string) => c.toUpperCase()); -} - /** * Maps plugin names to their exported types (with asUser automatically added). * Each plugin exposes its public API via the exports() method, and AppKit diff --git a/tools/dist-appkit.ts b/tools/dist-appkit.ts index abedf4754..9005e8905 100644 --- a/tools/dist-appkit.ts +++ b/tools/dist-appkit.ts @@ -90,6 +90,17 @@ if (fs.existsSync(sharedBin)) { fs.cpSync(sharedCliDist, tmpCliDist, { recursive: true }); } + // The CLI imports leaf modules that live outside dist/cli (e.g. + // `naming.ts`, referenced by the `plugin promote` command). Copy them to + // tmp/dist so the CLI's relative imports resolve in the published tarball. + const sharedNaming = path.join( + __dirname, + "../packages/shared/dist/naming.js", + ); + if (fs.existsSync(sharedNaming)) { + fs.copyFileSync(sharedNaming, "tmp/dist/naming.js"); + } + // Copy JSON schemas so CLI (e.g. plugin validate/sync) can load them at runtime. // Place in both dist/schemas and dist/cli/schemas so resolution works whether // the running module's __dirname is under dist/ or dist/cli/ (e.g. after bundling). From 8887dc7162bf93b0e9e45ba599023948563a6c92 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 4 Aug 2026 16:47:55 +0200 Subject: [PATCH 15/27] refactor(appkit): make plugin manifest name camelCase, derive kebab routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolding an app with a multi-word plugin generated invalid server code (`import { ai-search }` — a hyphen isn't a valid JS identifier) and the client view didn't render (the template guards `.plugins.aiSearch` but the plugin-map key was `ai-search`). Go templates can't express `.plugins.ai-search` at all. ai-search is the first multi-word plugin, so it exposed that the whole system assumes manifest.name is a valid JS identifier. Make manifest.name camelCase (aiSearch, uiVariants) the single canonical identifier — SDK accessor, JS export binding, and template plugin-map key. Derive the kebab HTTP route (/api/ai-search) and doc filename via a new camelToKebab; folders stay kebab. This inverts the earlier kebabToCamel accessor approach: the accessor and PluginMap are now identity. - naming.ts: add camelToKebab; plugin.ts re-exports it, PluginMap keys on P["name"] directly. - server/index.ts + plugin.ts: route prefix uses camelToKebab(name). - appkit.ts: accessor key is name verbatim. - schema name regex loosened to ^[a-z][a-zA-Z0-9-]*$ at the two plugin-name sites only (resourceKey + project-name patterns untouched); create.ts and the two generators updated; generate-plugin-entries splits name (camel) vs folder (kebab) patterns; doc-banners derives the kebab .md basename. - Rename ai-search->aiSearch and ui-variants->uiVariants manifests + type params + name-assertion test; regenerated schemas + template. Verified end to end: scaffolding with --features=aiSearch now emits valid `import { aiSearch }`, wires the AiSearchPage view, and keeps /api/ai-search and /api/ui-variants/confirm routes. Full suite + typecheck + knip + tarball load all pass. Signed-off-by: MarioCadenas --- .../schemas/plugin-manifest.schema.json | 4 +-- .../schemas/template-plugins.schema.json | 4 +-- packages/appkit/src/core/appkit.ts | 8 ++--- .../appkit/src/core/tests/databricks.test.ts | 30 ++++++------------- packages/appkit/src/plugin/plugin.ts | 3 +- .../appkit/src/plugins/ai-search/ai-search.ts | 2 +- .../src/plugins/ai-search/manifest.json | 2 +- .../plugins/ai-search/tests/ai-search.test.ts | 2 +- packages/appkit/src/plugins/server/index.ts | 3 +- .../appkit/src/plugins/ui-variants/index.ts | 2 +- .../src/plugins/ui-variants/manifest.json | 2 +- .../src/cli/commands/plugin/create/create.ts | 2 +- packages/shared/src/naming.ts | 9 ++++++ packages/shared/src/plugin.ts | 11 ++++--- packages/shared/src/schemas/manifest.ts | 8 ++--- template/appkit.plugins.json | 4 +-- tools/generate-plugin-doc-banners.ts | 16 ++++++---- tools/generate-plugin-entries.ts | 21 ++++++------- 18 files changed, 68 insertions(+), 65 deletions(-) diff --git a/docs/static/schemas/plugin-manifest.schema.json b/docs/static/schemas/plugin-manifest.schema.json index 3e2279e73..280c4df4c 100644 --- a/docs/static/schemas/plugin-manifest.schema.json +++ b/docs/static/schemas/plugin-manifest.schema.json @@ -10,8 +10,8 @@ }, "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." + "pattern": "^[a-z][a-zA-Z0-9-]*$", + "description": "Plugin identifier and JS binding. Must start with a lowercase letter; camelCase for multi-word names (e.g. aiSearch)." }, "displayName": { "type": "string", diff --git a/docs/static/schemas/template-plugins.schema.json b/docs/static/schemas/template-plugins.schema.json index fb7c32a3f..018f9ca3a 100644 --- a/docs/static/schemas/template-plugins.schema.json +++ b/docs/static/schemas/template-plugins.schema.json @@ -23,8 +23,8 @@ "properties": { "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." + "pattern": "^[a-z][a-zA-Z0-9-]*$", + "description": "Plugin identifier and JS binding. Must start with a lowercase letter; camelCase for multi-word names (e.g. aiSearch)." }, "displayName": { "type": "string", diff --git a/packages/appkit/src/core/appkit.ts b/packages/appkit/src/core/appkit.ts index 0f89b52c0..201acf190 100644 --- a/packages/appkit/src/core/appkit.ts +++ b/packages/appkit/src/core/appkit.ts @@ -7,7 +7,6 @@ import type { PluginData, PluginMap, } from "shared"; -import { kebabToCamel } from "shared"; import { version as productVersion } from "../../package.json"; import { CacheManager } from "../cache"; import { ServiceContext } from "../context"; @@ -108,10 +107,9 @@ export class AppKit { const self = this; - // The public handle key is camelCase; the kebab `name` still drives - // internal lookups and the HTTP route prefix. - const accessorKey = kebabToCamel(name); - Object.defineProperty(this, accessorKey, { + // The manifest `name` is camelCase, so it doubles as the public handle key + // (`appkit.aiSearch`). The kebab HTTP route is derived separately. + Object.defineProperty(this, name, { get() { const plugin = self.#pluginInstances[name]; return self.wrapWithAsUser(plugin); diff --git a/packages/appkit/src/core/tests/databricks.test.ts b/packages/appkit/src/core/tests/databricks.test.ts index 64a9d68b7..b3abc5bea 100644 --- a/packages/appkit/src/core/tests/databricks.test.ts +++ b/packages/appkit/src/core/tests/databricks.test.ts @@ -505,33 +505,21 @@ describe("AppKit", () => { }); }); - describe("camelCase accessor alias", () => { + describe("plugin accessor", () => { class MultiWordPlugin extends NormalTestPlugin { - static manifest = createTestManifest("multi-word"); - name = "multi-word"; + static manifest = createTestManifest("aiSearch"); + name = "aiSearch"; } - test("exposes a multi-word plugin under its camelCase key, not the kebab name", async () => { + test("exposes a plugin under its camelCase manifest name", async () => { const instance = (await createApp({ - plugins: [{ plugin: MultiWordPlugin, config: {}, name: "multi-word" }], + plugins: [{ plugin: MultiWordPlugin, config: {}, name: "aiSearch" }], })) as any; - expect(instance.multiWord).toBeDefined(); - expect(instance.multiWord.setupCalled).toBe(true); - // The kebab-case name is not exposed on the handle. - expect(instance["multi-word"]).toBeUndefined(); - expect(Object.keys(instance)).not.toContain("multi-word"); - }); - - test("does not add an alias key for single-word plugins", async () => { - const instance = (await createApp({ - plugins: [{ plugin: NormalTestPlugin, config: {}, name: "normalTest" }], - })) as any; - - // camelCase of a name with no hyphen is the name itself — no extra key. - expect( - Object.keys(instance).filter((k) => k === "normalTest"), - ).toHaveLength(1); + // The camelCase name is the accessor key verbatim (no transform). + expect(instance.aiSearch).toBeDefined(); + expect(instance.aiSearch.setupCalled).toBe(true); + expect(Object.keys(instance)).toContain("aiSearch"); }); }); diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts index 84c2e247c..ee403a924 100644 --- a/packages/appkit/src/plugin/plugin.ts +++ b/packages/appkit/src/plugin/plugin.ts @@ -12,6 +12,7 @@ import type { StreamExecuteHandler, StreamExecutionSettings, } from "shared"; +import { camelToKebab } from "shared"; import { AppManager } from "../app"; import { CacheManager } from "../cache"; import { getCurrentUserId, runInUserContext, ServiceContext } from "../context"; @@ -669,7 +670,7 @@ export abstract class Plugin< router[method](path, forwardAsyncErrors(handler)); - const fullPath = `/api/${this.name}${path}`; + const fullPath = `/api/${camelToKebab(this.name)}${path}`; this.registerEndpoint(name, fullPath); if (config.skipBodyParsing) { diff --git a/packages/appkit/src/plugins/ai-search/ai-search.ts b/packages/appkit/src/plugins/ai-search/ai-search.ts index 2927cc86a..619c21c63 100644 --- a/packages/appkit/src/plugins/ai-search/ai-search.ts +++ b/packages/appkit/src/plugins/ai-search/ai-search.ts @@ -26,7 +26,7 @@ const querySettings: PluginExecutionSettings = { }; export class AiSearchPlugin extends Plugin { - static manifest = manifest as PluginManifest<"ai-search">; + static manifest = manifest as PluginManifest<"aiSearch">; protected static description = "Query Databricks Vector Search indexes with hybrid search, reranking, and pagination"; diff --git a/packages/appkit/src/plugins/ai-search/manifest.json b/packages/appkit/src/plugins/ai-search/manifest.json index c04b8fded..6a3622ffb 100644 --- a/packages/appkit/src/plugins/ai-search/manifest.json +++ b/packages/appkit/src/plugins/ai-search/manifest.json @@ -1,6 +1,6 @@ { "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", - "name": "ai-search", + "name": "aiSearch", "displayName": "AI Search Plugin", "stability": "beta", "description": "Query Databricks Vector Search indexes with built-in hybrid search, reranking, and pagination", diff --git a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts index 4d82ec6f4..e907012f9 100644 --- a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts +++ b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts @@ -164,7 +164,7 @@ describe("AiSearchPlugin", () => { describe("manifest", () => { it("has correct name", () => { - expect(AiSearchPlugin.manifest.name).toBe("ai-search"); + expect(AiSearchPlugin.manifest.name).toBe("aiSearch"); }); }); diff --git a/packages/appkit/src/plugins/server/index.ts b/packages/appkit/src/plugins/server/index.ts index 968f7bc78..bf1e58091 100644 --- a/packages/appkit/src/plugins/server/index.ts +++ b/packages/appkit/src/plugins/server/index.ts @@ -5,6 +5,7 @@ import dotenv from "dotenv"; import express from "express"; import getPort, { portNumbers } from "get-port"; import type { PluginClientConfigs, PluginPhase } from "shared"; +import { camelToKebab } from "shared"; import { AppKitError, ServerError } from "../../errors"; import { TelemetryReporter } from "../../internal-telemetry"; import { createLogger } from "../../logging/logger"; @@ -272,7 +273,7 @@ export class ServerPlugin extends Plugin { plugin.injectRoutes(router); - const basePath = `/api/${plugin.name}`; + const basePath = `/api/${camelToKebab(plugin.name)}`; this.serverApplication.use(basePath, router); endpoints[plugin.name] = plugin.getEndpoints(); diff --git a/packages/appkit/src/plugins/ui-variants/index.ts b/packages/appkit/src/plugins/ui-variants/index.ts index e53a7ff6d..c33f2ae3e 100644 --- a/packages/appkit/src/plugins/ui-variants/index.ts +++ b/packages/appkit/src/plugins/ui-variants/index.ts @@ -25,7 +25,7 @@ interface ConfirmRequestBody { * per `` id — and the plugin only records; it never edits source. */ class UiVariantsPlugin extends Plugin { - static manifest = manifest as PluginManifest<"ui-variants">; + static manifest = manifest as PluginManifest<"uiVariants">; protected static description = "Dev-only recorder for the UI picker"; diff --git a/packages/appkit/src/plugins/ui-variants/manifest.json b/packages/appkit/src/plugins/ui-variants/manifest.json index 2c9fbc27c..94cde02c8 100644 --- a/packages/appkit/src/plugins/ui-variants/manifest.json +++ b/packages/appkit/src/plugins/ui-variants/manifest.json @@ -1,6 +1,6 @@ { "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", - "name": "ui-variants", + "name": "uiVariants", "displayName": "UI Variants Plugin", "description": "Dev-only recorder for the UI picker: records the developer's chosen variant so a coding agent can finalize the component source", "hidden": true, diff --git a/packages/shared/src/cli/commands/plugin/create/create.ts b/packages/shared/src/cli/commands/plugin/create/create.ts index 5917cfe11..c9641bf48 100644 --- a/packages/shared/src/cli/commands/plugin/create/create.ts +++ b/packages/shared/src/cli/commands/plugin/create/create.ts @@ -25,7 +25,7 @@ import { import { resolveTargetDir, scaffoldPlugin } from "./scaffold"; import type { CreateAnswers, Placement, SelectedResource } from "./types"; -const NAME_PATTERN = /^[a-z][a-z0-9-]*$/; +const NAME_PATTERN = /^[a-z][a-zA-Z0-9-]*$/; const DEFAULT_VERSION = "0.1.0"; const VALID_PLACEMENTS: Placement[] = ["in-repo", "isolated"]; const REQUIRED_FLAGS = ["placement", "path", "name", "description"] as const; diff --git a/packages/shared/src/naming.ts b/packages/shared/src/naming.ts index cb49489ca..543666eda 100644 --- a/packages/shared/src/naming.ts +++ b/packages/shared/src/naming.ts @@ -14,3 +14,12 @@ export type KebabToCamel = export function kebabToCamel(name: string): string { return name.replace(/-+([a-z0-9])/g, (_, c: string) => c.toUpperCase()); } + +/** + * camelCase to kebab-case (e.g. `"aiSearch"` -> `"ai-search"`). Used to derive + * HTTP route prefixes and folder paths from the canonical camelCase plugin + * name. A no-op for single-word names. + */ +export function camelToKebab(name: string): string { + return name.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); +} diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index 1f515cae2..46f1186b9 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -1,6 +1,5 @@ import type express from "express"; import type { JSONSchema7 } from "json-schema"; -import { type KebabToCamel, kebabToCamel } from "./naming"; import type { DiscoveryDescriptor, PluginManifest as GeneratedPluginManifest, @@ -12,8 +11,8 @@ import type { // Sourced from `./schemas/manifest` (the Zod canonical) so `DiscoveryDescriptor` // stays the discriminated union shape rather than the free-form predecessor. export type { ResourceFieldEntry, DiscoveryDescriptor, PluginScaffoldingRules }; -// Re-export the naming helpers so `shared` consumers keep importing them here. -export { type KebabToCamel, kebabToCamel }; +// Re-export the naming helpers so `shared` consumers import them from here. +export { camelToKebab, kebabToCamel } from "./naming"; /** Base plugin interface. */ export interface BasePlugin { @@ -258,13 +257,13 @@ export type WithAsUser = SDK extends (...args: any[]) => any * (functions) are passed through without wrapping, as they manage their own * `asUser` pattern (e.g. files plugin). * - * The key is the camelCase form of the plugin name (`appkit.aiSearch`, not - * `appkit["ai-search"]`). + * The key is the plugin's manifest `name`, which is camelCase by convention + * (`appkit.aiSearch`), so it doubles as a valid JS accessor. */ export type PluginMap< U extends readonly PluginData[], > = { - [P in U[number] as KebabToCamel]: WithAsUser< + [P in U[number] as P["name"]]: WithAsUser< PluginExports> >; }; diff --git a/packages/shared/src/schemas/manifest.ts b/packages/shared/src/schemas/manifest.ts index ed7930149..661c043e1 100644 --- a/packages/shared/src/schemas/manifest.ts +++ b/packages/shared/src/schemas/manifest.ts @@ -672,9 +672,9 @@ export const pluginManifestSchema = z .describe("Reference to the JSON Schema for validation"), name: z .string() - .regex(/^[a-z][a-z0-9-]*$/) + .regex(/^[a-z][a-zA-Z0-9-]*$/) .describe( - "Plugin identifier. Must be lowercase, start with a letter, and contain only letters, numbers, and hyphens.", + "Plugin identifier and JS binding. Must start with a lowercase letter; camelCase for multi-word names (e.g. aiSearch).", ), displayName: z .string() @@ -908,9 +908,9 @@ export const templatePluginSchema = z .object({ name: z .string() - .regex(/^[a-z][a-z0-9-]*$/) + .regex(/^[a-z][a-zA-Z0-9-]*$/) .describe( - "Plugin identifier. Must be lowercase, start with a letter, and contain only letters, numbers, and hyphens.", + "Plugin identifier and JS binding. Must start with a lowercase letter; camelCase for multi-word names (e.g. aiSearch).", ), displayName: z .string() diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index 0f82fcae6..1316c7959 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -28,8 +28,8 @@ }, "stability": "beta" }, - "ai-search": { - "name": "ai-search", + "aiSearch": { + "name": "aiSearch", "displayName": "AI Search Plugin", "description": "Query Databricks Vector Search indexes with built-in hybrid search, reranking, and pagination", "package": "@databricks/appkit", diff --git a/tools/generate-plugin-doc-banners.ts b/tools/generate-plugin-doc-banners.ts index 470729f6c..c614e249a 100644 --- a/tools/generate-plugin-doc-banners.ts +++ b/tools/generate-plugin-doc-banners.ts @@ -23,10 +23,16 @@ const PLUGINS_DIR = path.join(REPO_ROOT, "packages/appkit/src/plugins"); const DOCS_DIR = path.join(REPO_ROOT, "docs/docs/plugins"); /** - * Same as `plugin-manifest.schema.json` `name` pattern; keeps `path.join` targets - * under `docs/docs/plugins` (defense in depth vs path traversal in `name`). + * Same as `plugin-manifest.schema.json` `name` pattern (camelCase); keeps + * `path.join` targets under `docs/docs/plugins` (defense in depth vs path + * traversal in `name`). */ -const SCHEMA_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; +const SCHEMA_NAME_PATTERN = /^[a-z][a-zA-Z0-9-]*$/; + +/** camelCase manifest name -> kebab doc basename (e.g. aiSearch -> ai-search). */ +function camelToKebab(name: string): string { + return name.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); +} /** * Checks whether a resolved file path is within a given directory boundary. @@ -128,7 +134,7 @@ function readPluginInfos(): PluginInfo[] { } const docBasename = - DOC_FILE_OVERRIDES[manifest.name] ?? `${manifest.name}.md`; + DOC_FILE_OVERRIDES[manifest.name] ?? `${camelToKebab(manifest.name)}.md`; if ( docBasename.includes("..") || docBasename !== path.basename(docBasename) || @@ -191,7 +197,7 @@ function main(): void { for (const s of summary) { const rel = path.relative(REPO_ROOT, DOCS_DIR); - const docName = DOC_FILE_OVERRIDES[s.name] ?? `${s.name}.md`; + const docName = DOC_FILE_OVERRIDES[s.name] ?? `${camelToKebab(s.name)}.md`; if (s.action === "missing") { console.warn( ` warn: ${s.name} — no doc page at ${rel}/${docName} (skipping)`, diff --git a/tools/generate-plugin-entries.ts b/tools/generate-plugin-entries.ts index d260db04c..504c492d0 100644 --- a/tools/generate-plugin-entries.ts +++ b/tools/generate-plugin-entries.ts @@ -38,15 +38,14 @@ interface PluginInfo { } /** - * Mirrors `^[a-z][a-z0-9-]*$` from `plugin-manifest.schema.json`. Catches - * malformed manifests that bypassed `appkit plugin validate`. - * - * Doubles as a defense-in-depth gate against code-injection (CWE-94): both the - * manifest `name` and the folder name flow into the generated TS source, and - * this charset forbids quotes, semicolons, braces, backslashes, and newlines, - * so neither can break out of the string/identifier context it lands in. + * Charsets the manifest `name` (camelCase, from `plugin-manifest.schema.json`) + * and the folder name (kebab) must match. Both flow into generated TS source, + * so these double as a code-injection gate (CWE-94): the charsets forbid + * quotes, semicolons, braces, backslashes, and newlines, so neither can break + * out of the string/identifier context it lands in. */ -const SCHEMA_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; +const SCHEMA_NAME_PATTERN = /^[a-z][a-zA-Z0-9-]*$/; +const FOLDER_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; /** * The barrel exports each plugin under a JS-identifier binding @@ -72,9 +71,11 @@ function validateSchemaName( kind: "manifest name" | "folder name", manifestPath: string, ): void { - if (!SCHEMA_NAME_PATTERN.test(value)) { + const pattern = + kind === "manifest name" ? SCHEMA_NAME_PATTERN : FOLDER_NAME_PATTERN; + if (!pattern.test(value)) { throw new Error( - `${kind} "${value}" in ${manifestPath} doesn't match the plugin manifest schema pattern ^[a-z][a-z0-9-]*$. Run \`appkit plugin validate\` to catch this earlier.`, + `${kind} "${value}" in ${manifestPath} doesn't match ${pattern.source}. Run \`appkit plugin validate\` to catch this earlier.`, ); } } From fb1f6eaac7edfa3f66a99e258e70f3306f0c1848 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 4 Aug 2026 17:25:51 +0200 Subject: [PATCH 16/27] docs(appkit): regenerate aiSearch API doc for camelCase name The ToPlugin type literal in the generated API reference now reflects the camelCase manifest name ("ai-search" -> "aiSearch"). Regenerated via docs:build; keeps the CI generated-docs freshness check green. Signed-off-by: MarioCadenas --- docs/docs/api/appkit/Variable.aiSearch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/api/appkit/Variable.aiSearch.md b/docs/docs/api/appkit/Variable.aiSearch.md index 0fec700d8..712d8d94a 100644 --- a/docs/docs/api/appkit/Variable.aiSearch.md +++ b/docs/docs/api/appkit/Variable.aiSearch.md @@ -1,5 +1,5 @@ # Variable: aiSearch ```ts -const aiSearch: ToPlugin; +const aiSearch: ToPlugin; ``` From 96dcfaabfd4472ebd716888daac31cd6ca233af7 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 4 Aug 2026 17:43:07 +0200 Subject: [PATCH 17/27] ci: retrigger template artifact build (new commit for a fresh merge short-SHA) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior commit's CI merge short-SHA was 0092322 — an all-numeric, leading-zero prerelease identifier that npm semver rejects (`Invalid Version: 0.53.0-pr.0092322`), failing the PR Template Artifact build. This is pre-existing release-tooling behavior unrelated to the plugin changes; a fresh commit yields a new short-SHA to build a valid version. Signed-off-by: MarioCadenas From 123af298f72b99b6b05d8e445147327a899a87c5 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 4 Aug 2026 18:27:06 +0200 Subject: [PATCH 18/27] =?UTF-8?q?feat(appkit):=20ai-search=20works=20with?= =?UTF-8?q?=20less=20config=20=E2=80=94=20dev=20column=20auto-discovery=20?= =?UTF-8?q?+=20scaffolding=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ai-search was the only plugin needing hand-written config (columns) to work; a scaffolded app was a bare aiSearch() that couldn't serve a query. The VS query API requires columns (can't omit), but the index's source-table columns are discoverable over REST with no warehouse. Runtime (dev-only): make IndexConfig.columns optional. In development, setup() resolves missing columns from the index's Delta-Sync source table (getIndex -> unity-catalog tables API, excluding the embedding vector column), stores them on the config, and prints a boxed 'would fail in production' banner. Best-effort: any failure is warned and skipped. Production never runs discovery — a missing columns surfaces as the normal query error, so this can't mask a config gap that would fail once deployed. Scaffold-time: add manifest scaffolding.rules that tell the init agent to look up the index's columns, ask the user which to return, and write a complete aiSearch({ indexes: {...} }) into the server file. - connectors/ai-search: getIndex + getSourceColumns (REST, no warehouse). - ai-search.ts: dev-gated _autoDiscoverColumns; reranker/prepare tolerate empty columns. - manifest: columns no longer required in config.schema; scaffolding.rules added. - Regenerated IndexConfig API doc + template; docs note columns is dev-optional. Verified end to end against a live dogfood index: a dev app with zero columns configured auto-discovered all 4 source columns, warned, and served a hybrid query returning them. Full suite +3 tests (dev discovery, prod skip, reranker empty); typecheck, lint, knip all green. Signed-off-by: MarioCadenas --- docs/docs/api/appkit/Interface.IndexConfig.md | 8 +- docs/docs/plugins/ai-search.md | 2 +- .../appkit/src/connectors/ai-search/client.ts | 45 ++++++++++ .../appkit/src/connectors/ai-search/types.ts | 14 +++ .../appkit/src/plugins/ai-search/ai-search.ts | 79 ++++++++++++++++- .../src/plugins/ai-search/manifest.json | 12 ++- .../plugins/ai-search/tests/ai-search.test.ts | 88 +++++++++++++++++++ .../appkit/src/plugins/ai-search/types.ts | 8 +- template/appkit.plugins.json | 11 ++- 9 files changed, 256 insertions(+), 11 deletions(-) diff --git a/docs/docs/api/appkit/Interface.IndexConfig.md b/docs/docs/api/appkit/Interface.IndexConfig.md index fea9dc84e..96fb29688 100644 --- a/docs/docs/api/appkit/Interface.IndexConfig.md +++ b/docs/docs/api/appkit/Interface.IndexConfig.md @@ -14,13 +14,15 @@ Programmatic callers select per call via `appkit.aiSearch.asUser(req)`. *** -### columns +### columns? ```ts -columns: string[]; +optional columns: string[]; ``` -Columns to return in results +Columns to return in results. Optional: in development the plugin +auto-discovers them from the index's source table when omitted (and warns +that they should be set explicitly for production). *** diff --git a/docs/docs/plugins/ai-search.md b/docs/docs/plugins/ai-search.md index 88bee4164..dbfb0ac45 100644 --- a/docs/docs/plugins/ai-search.md +++ b/docs/docs/plugins/ai-search.md @@ -75,7 +75,7 @@ aiSearch({ | Field | Type | Default | Description | |-------|------|---------|-------------| | `indexName` | `string` | `DATABRICKS_VS_INDEX_NAME` | Three-level Unity Catalog name (`catalog.schema.index`). Defaults to the `DATABRICKS_VS_INDEX_NAME` env var when omitted. | -| `columns` | `string[]` | — | **Required.** Columns to return in query results | +| `columns` | `string[]` | auto-discovered in dev | Columns to return in query results. Optional in development — when omitted, the plugin reads them from the index's source table and warns. **Set explicitly for production**, where a missing value is not auto-filled. | | `queryType` | `"ann" \| "hybrid" \| "full_text"` | `"hybrid"` | Search mode | | `numResults` | `number` | `20` | Maximum results per query | | `reranker` | `boolean \| { columnsToRerank: string[] }` | — | Enable reranking. Pass `true` to rerank all result columns, or specify a subset | diff --git a/packages/appkit/src/connectors/ai-search/client.ts b/packages/appkit/src/connectors/ai-search/client.ts index eb5cc3d32..04d0a6f71 100644 --- a/packages/appkit/src/connectors/ai-search/client.ts +++ b/packages/appkit/src/connectors/ai-search/client.ts @@ -10,6 +10,8 @@ import type { WorkspaceClient } from "../../workspace-client"; import { contextFromAbortSignal } from "../context"; import type { AiSearchConnectorConfig, + UcTableInfo, + VsIndexInfo, VsNextPageParams, VsQueryParams, VsRawResponse, @@ -180,4 +182,47 @@ export class AiSearchConnector { { name: "ai-search", includePrefix: true }, ); } + + /** + * Fetches index metadata (index type, source table). Used to auto-discover + * returnable columns when they aren't configured. No warehouse required. + */ + async getIndex( + workspaceClient: WorkspaceClient, + indexName: string, + signal?: AbortSignal, + ): Promise { + return (await workspaceClient.apiClient.request( + { + method: "GET", + path: `/api/2.0/vector-search/indexes/${indexName}`, + headers: new Headers({ "Content-Type": "application/json" }), + raw: false, + query: {}, + }, + contextFromAbortSignal(signal), + )) as VsIndexInfo; + } + + /** + * Lists a Unity Catalog table's column names via the tables REST API + * (no warehouse required). + */ + async getSourceColumns( + workspaceClient: WorkspaceClient, + sourceTable: string, + signal?: AbortSignal, + ): Promise { + const table = (await workspaceClient.apiClient.request( + { + method: "GET", + path: `/api/2.1/unity-catalog/tables/${sourceTable}`, + headers: new Headers({ "Content-Type": "application/json" }), + raw: false, + query: {}, + }, + contextFromAbortSignal(signal), + )) as UcTableInfo; + return (table.columns ?? []).map((c) => c.name); + } } diff --git a/packages/appkit/src/connectors/ai-search/types.ts b/packages/appkit/src/connectors/ai-search/types.ts index f7a73795d..0d3e175b2 100644 --- a/packages/appkit/src/connectors/ai-search/types.ts +++ b/packages/appkit/src/connectors/ai-search/types.ts @@ -22,6 +22,20 @@ export interface VsNextPageParams { pageToken: string; } +/** Subset of the get-index response used for column auto-discovery. */ +export interface VsIndexInfo { + index_type?: "DELTA_SYNC" | "DIRECT_ACCESS"; + delta_sync_index_spec?: { + source_table?: string; + embedding_vector_columns?: Array<{ name: string }>; + }; +} + +/** Subset of the Unity Catalog get-table response used for column discovery. */ +export interface UcTableInfo { + columns?: Array<{ name: string }>; +} + export interface VsRawResponse { manifest: { column_count: number; diff --git a/packages/appkit/src/plugins/ai-search/ai-search.ts b/packages/appkit/src/plugins/ai-search/ai-search.ts index 619c21c63..82f1aa8cf 100644 --- a/packages/appkit/src/plugins/ai-search/ai-search.ts +++ b/packages/appkit/src/plugins/ai-search/ai-search.ts @@ -55,6 +55,78 @@ export class AiSearchPlugin extends Plugin { ); } } + + // Development convenience: fill in `columns` for any index that omits them + // by reading the index's source table. Never runs in production, where a + // missing `columns` surfaces as a normal query error — so this can't mask a + // config gap that would fail once deployed. + if (process.env.NODE_ENV === "development") { + await this._autoDiscoverColumns(); + } + } + + /** + * For each configured index missing `columns`, discover the returnable + * columns from its Delta-Sync source table and store them back on the config. + * Best-effort: any failure (no auth, index not ready, non-Delta-Sync index) + * is logged and skipped rather than thrown. Emits one warning banner listing + * what was auto-filled, since these must be set explicitly before production. + */ + private async _autoDiscoverColumns(): Promise { + const discovered: Record = {}; + for (const [alias, idx] of Object.entries(this.config.indexes ?? {})) { + if (idx.columns && idx.columns.length > 0) continue; + const indexName = idx.indexName ?? process.env.DATABRICKS_VS_INDEX_NAME; + if (!indexName) continue; + try { + const client = getWorkspaceClient(); + const info = await this.connector.getIndex(client, indexName); + const sourceTable = info.delta_sync_index_spec?.source_table; + if (!sourceTable) continue; + const excluded = new Set( + (info.delta_sync_index_spec?.embedding_vector_columns ?? []).map( + (c) => c.name, + ), + ); + const columns = ( + await this.connector.getSourceColumns(client, sourceTable) + ).filter((c) => !excluded.has(c)); + if (columns.length > 0) { + idx.columns = columns; + discovered[alias] = columns; + } + } catch (error) { + logger.warn( + 'Could not auto-discover columns for index "%s": %s', + alias, + error instanceof Error ? error.message : String(error), + ); + } + } + if (Object.keys(discovered).length > 0) { + logger.warn("\n%s", this._formatColumnDiscoveryBanner(discovered)); + } + } + + private _formatColumnDiscoveryBanner( + discovered: Record, + ): string { + const lines = [ + "AI SEARCH: columns auto-discovered (dev mode — would fail in production)", + "", + ]; + for (const [alias, columns] of Object.entries(discovered)) { + lines.push(` ${alias}: ${columns.join(", ")}`); + } + lines.push(""); + lines.push( + "Set `columns` explicitly in the plugin config before deploying.", + ); + + const maxLen = Math.max(...lines.map((l) => l.length)); + const border = "=".repeat(maxLen + 4); + const boxed = lines.map((l) => `| ${l.padEnd(maxLen)} |`); + return [border, ...boxed, border].join("\n"); } injectRoutes(router: IAppRouter) { @@ -286,7 +358,7 @@ export class AiSearchPlugin extends Plugin { } } - const columns = request.columns ?? indexConfig.columns; + const columns = request.columns ?? indexConfig.columns ?? []; return { queryText, queryVector, @@ -309,7 +381,10 @@ export class AiSearchPlugin extends Plugin { if (typeof indexConfig.reranker === "object") { return indexConfig.reranker; } - return { columnsToRerank: columns.filter((c) => c !== "id") }; + // Auto-derive from returnable columns (excluding the id). With no columns + // resolved there's nothing to rerank on, so skip it. + const columnsToRerank = columns.filter((c) => c !== "id"); + return columnsToRerank.length > 0 ? { columnsToRerank } : undefined; } private _parseResponse< diff --git a/packages/appkit/src/plugins/ai-search/manifest.json b/packages/appkit/src/plugins/ai-search/manifest.json index 6a3622ffb..96d5e7977 100644 --- a/packages/appkit/src/plugins/ai-search/manifest.json +++ b/packages/appkit/src/plugins/ai-search/manifest.json @@ -26,6 +26,15 @@ ], "optional": [] }, + "scaffolding": { + "rules": { + "must": [ + "List the vector search index's source-table columns (unity-catalog tables API) so the user can pick which to return", + "Ask the user which columns each index should return, and which alias name to use for it", + "Write a complete aiSearch({ indexes: { : { columns: [...] } } }) into the server file using the chosen columns" + ] + } + }, "config": { "schema": { "type": "object", @@ -54,8 +63,7 @@ "type": "number", "default": 20 } - }, - "required": ["columns"] + } } }, "timeout": { diff --git a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts index e907012f9..b2a70aad7 100644 --- a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts +++ b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts @@ -162,6 +162,82 @@ describe("AiSearchPlugin", () => { }); }); + describe("setup() column auto-discovery", () => { + const originalNodeEnv = process.env.NODE_ENV; + // Route GET metadata calls to discovery fixtures; POST queries stay on the + // default validVsResponse. + const routeByPath = (opts: { method: string; path: string }) => { + if (opts.path.endsWith("/query")) return Promise.resolve(validVsResponse); + if (opts.path.startsWith("/api/2.0/vector-search/indexes/")) { + return Promise.resolve({ + index_type: "DELTA_SYNC", + delta_sync_index_spec: { + source_table: "cat.sch.src", + embedding_vector_columns: [{ name: "__vec" }], + }, + }); + } + if (opts.path.startsWith("/api/2.1/unity-catalog/tables/")) { + return Promise.resolve({ + columns: [{ name: "id" }, { name: "body" }, { name: "__vec" }], + }); + } + return Promise.resolve(validVsResponse); + }; + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + it("fills columns from the source table in development and warns", async () => { + process.env.NODE_ENV = "development"; + mockRequest.mockImplementation(routeByPath); + const plugin = new AiSearchPlugin({ + indexes: { docs: { indexName: "cat.sch.idx" } }, + }); + + await plugin.setup(); + + // Discovered columns, minus the embedding vector column. + await plugin.query("docs", { queryText: "q" }); + const queryCall = mockRequest.mock.calls.find((c) => + c[0].path.endsWith("/query"), + ); + expect(queryCall?.[0].payload.columns).toEqual(["id", "body"]); + }); + + it("does not discover columns outside development", async () => { + process.env.NODE_ENV = "production"; + mockRequest.mockImplementation(routeByPath); + const plugin = new AiSearchPlugin({ + indexes: { docs: { indexName: "cat.sch.idx" } }, + }); + + await plugin.setup(); + + // No get-index / get-table calls were made. + const metadataCalls = mockRequest.mock.calls.filter( + (c) => !c[0].path.endsWith("/query"), + ); + expect(metadataCalls).toHaveLength(0); + }); + + it("skips (does not throw) when an index already has columns", async () => { + process.env.NODE_ENV = "development"; + mockRequest.mockImplementation(routeByPath); + const plugin = new AiSearchPlugin({ + indexes: { docs: { indexName: "cat.sch.idx", columns: ["id"] } }, + }); + + await plugin.setup(); + + const metadataCalls = mockRequest.mock.calls.filter( + (c) => !c[0].path.endsWith("/query"), + ); + expect(metadataCalls).toHaveLength(0); + }); + }); + describe("manifest", () => { it("has correct name", () => { expect(AiSearchPlugin.manifest.name).toBe("aiSearch"); @@ -516,6 +592,18 @@ describe("AiSearchPlugin", () => { expect(callBody.reranker).toBeUndefined(); }); + it("skips the reranker when enabled but no columns are resolved", async () => { + const plugin = new AiSearchPlugin({ + indexes: { test: { indexName: "cat.sch.idx", reranker: true } }, + }); + await plugin.setup(); + await plugin.query("test", { queryText: "q" }); + + const callBody = mockRequest.mock.calls[0][0].payload; + expect(callBody.reranker).toBeUndefined(); + expect(callBody.columns).toEqual([]); + }); + it("throws a wrapped error when the connector query fails", async () => { // Persistent reject so the retry interceptor exhausts its attempts and // execute() surfaces a failed result, driving the !result.ok branch. diff --git a/packages/appkit/src/plugins/ai-search/types.ts b/packages/appkit/src/plugins/ai-search/types.ts index 4d8cd889c..89abea818 100644 --- a/packages/appkit/src/plugins/ai-search/types.ts +++ b/packages/appkit/src/plugins/ai-search/types.ts @@ -11,8 +11,12 @@ export interface IndexConfig { * `DATABRICKS_VS_INDEX_NAME` env var when omitted. */ indexName?: string; - /** Columns to return in results */ - columns: string[]; + /** + * Columns to return in results. Optional: in development the plugin + * auto-discovers them from the index's source table when omitted (and warns + * that they should be set explicitly for production). + */ + columns?: string[]; /** Default search mode */ queryType?: "ann" | "hybrid" | "full_text"; /** Max results per query */ diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index 1316c7959..135680b56 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -57,7 +57,16 @@ ], "optional": [] }, - "stability": "beta" + "stability": "beta", + "scaffolding": { + "rules": { + "must": [ + "List the vector search index's source-table columns (unity-catalog tables API) so the user can pick which to return", + "Ask the user which columns each index should return, and which alias name to use for it", + "Write a complete aiSearch({ indexes: { : { columns: [...] } } }) into the server file using the chosen columns" + ] + } + } }, "analytics": { "name": "analytics", From f1e48e2bb6f3f86701d948c1986d0e684f8d3efa Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 4 Aug 2026 18:38:50 +0200 Subject: [PATCH 19/27] feat(appkit): seed a default ai-search index from env so aiSearch() works bare Mirrors the genie plugin's default space: when no `indexes` are configured and DATABRICKS_VS_INDEX_NAME is set, seed `indexes = { default: { indexName } }`. Combined with dev column auto-discovery, a bare `aiSearch()` now serves /api/ai-search/default/query in development with just the env var set. queryType already defaults to hybrid, so no config is needed there. Also align the scaffolding rules to the 'default' alias the template client queries (was: ask the user for an arbitrary alias, which could mismatch the client's hardcoded path), and fix the stale 'Vector Search' heading in the template page to 'AI Search'. Test: bare aiSearch({}) with the env var set resolves the default alias to the env index path. Signed-off-by: MarioCadenas --- .../appkit/src/plugins/ai-search/ai-search.ts | 15 ++++++++++++++- .../appkit/src/plugins/ai-search/manifest.json | 4 ++-- .../src/plugins/ai-search/tests/ai-search.test.ts | 11 +++++++++++ template/appkit.plugins.json | 4 ++-- .../client/src/pages/ai-search/AiSearchPage.tsx | 4 ++-- 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/appkit/src/plugins/ai-search/ai-search.ts b/packages/appkit/src/plugins/ai-search/ai-search.ts index 82f1aa8cf..16a6318ce 100644 --- a/packages/appkit/src/plugins/ai-search/ai-search.ts +++ b/packages/appkit/src/plugins/ai-search/ai-search.ts @@ -36,13 +36,26 @@ export class AiSearchPlugin extends Plugin { constructor(config: IAiSearchConfig) { super(config); - this.config = config; + this.config = { + ...config, + indexes: config.indexes ?? this._defaultIndexes(), + }; this.connector = new AiSearchConnector({ timeout: config.timeout, telemetry: config.telemetry, }); } + /** + * Seeds a `default` index from `DATABRICKS_VS_INDEX_NAME` when no `indexes` + * are configured, so `aiSearch()` is usable with just the env var (columns + * are auto-discovered in dev). Mirrors the genie plugin's default space. + */ + private _defaultIndexes(): Record { + const indexName = process.env.DATABRICKS_VS_INDEX_NAME; + return indexName ? { default: { indexName } } : {}; + } + async setup(): Promise { // A missing indexName is a missing-resource condition owned by the // framework's resource validation (warn in dev, throw in prod). Only the diff --git a/packages/appkit/src/plugins/ai-search/manifest.json b/packages/appkit/src/plugins/ai-search/manifest.json index 96d5e7977..1516f986b 100644 --- a/packages/appkit/src/plugins/ai-search/manifest.json +++ b/packages/appkit/src/plugins/ai-search/manifest.json @@ -30,8 +30,8 @@ "rules": { "must": [ "List the vector search index's source-table columns (unity-catalog tables API) so the user can pick which to return", - "Ask the user which columns each index should return, and which alias name to use for it", - "Write a complete aiSearch({ indexes: { : { columns: [...] } } }) into the server file using the chosen columns" + "Ask the user which of those columns the search should return", + "Write aiSearch({ indexes: { default: { columns: [...] } } }) in the server file; the client queries the 'default' alias" ] } }, diff --git a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts index b2a70aad7..16cc16381 100644 --- a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts +++ b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts @@ -134,6 +134,17 @@ describe("AiSearchPlugin", () => { ); }); + it("seeds a 'default' index from the env var when no indexes are configured", async () => { + process.env.DATABRICKS_VS_INDEX_NAME = "cat.sch.from_env"; + // Bare aiSearch() — no indexes config. + const plugin = new AiSearchPlugin({}); + + await plugin.query("default", { queryText: "q", columns: ["id"] }); + expect(mockRequest.mock.calls[0][0].path).toBe( + "/api/2.0/vector-search/indexes/cat.sch.from_env/query", + ); + }); + it("throws if pagination enabled but no endpointName", async () => { const plugin = new AiSearchPlugin({ indexes: { diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index 135680b56..703450ecd 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -62,8 +62,8 @@ "rules": { "must": [ "List the vector search index's source-table columns (unity-catalog tables API) so the user can pick which to return", - "Ask the user which columns each index should return, and which alias name to use for it", - "Write a complete aiSearch({ indexes: { : { columns: [...] } } }) into the server file using the chosen columns" + "Ask the user which of those columns the search should return", + "Write aiSearch({ indexes: { default: { columns: [...] } } }) in the server file; the client queries the 'default' alias" ] } } diff --git a/template/client/src/pages/ai-search/AiSearchPage.tsx b/template/client/src/pages/ai-search/AiSearchPage.tsx index 1c5c19df4..56d7617b8 100644 --- a/template/client/src/pages/ai-search/AiSearchPage.tsx +++ b/template/client/src/pages/ai-search/AiSearchPage.tsx @@ -65,9 +65,9 @@ export function AiSearchPage() { return (
-

Vector Search

+

AI Search

- Query a Databricks Vector Search index using natural language. + Query a Databricks AI Search index using natural language.

From 19dbac4c5d1fdaeab3ace38faf7f6245ff795b58 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 4 Aug 2026 18:56:39 +0200 Subject: [PATCH 20/27] feat(appkit-ui): useAiSearchQuery hook; template reads indexes from clientConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ai-search plugin now exposes its configured indexes via clientConfig() (serialized to window.__appkit__), and a new useAiSearchQuery hook reads that list to POST to /api/ai-search//query — defaulting to the first index so a single-index app needs no alias. The scaffolded AiSearchPage and the dev-playground route use the hook instead of hardcoding the "default" endpoint. Signed-off-by: MarioCadenas --- .../client/src/routes/ai-search.route.tsx | 71 ++------ docs/docs/plugins/ai-search.md | 24 +++ docs/static/appkit-ui/styles.gen.css | 6 + .../__tests__/use-ai-search-query.test.ts | 157 ++++++++++++++++++ packages/appkit-ui/src/react/hooks/index.ts | 11 ++ packages/appkit-ui/src/react/hooks/types.ts | 45 +++++ .../src/react/hooks/use-ai-search-query.ts | 114 +++++++++++++ .../appkit/src/plugins/ai-search/ai-search.ts | 18 ++ .../appkit/src/plugins/ai-search/types.ts | 7 + .../src/pages/ai-search/AiSearchPage.tsx | 61 ++----- 10 files changed, 409 insertions(+), 105 deletions(-) create mode 100644 packages/appkit-ui/src/react/hooks/__tests__/use-ai-search-query.test.ts create mode 100644 packages/appkit-ui/src/react/hooks/use-ai-search-query.ts diff --git a/apps/dev-playground/client/src/routes/ai-search.route.tsx b/apps/dev-playground/client/src/routes/ai-search.route.tsx index ca2c5b7e5..f2d0569a3 100644 --- a/apps/dev-playground/client/src/routes/ai-search.route.tsx +++ b/apps/dev-playground/client/src/routes/ai-search.route.tsx @@ -5,6 +5,7 @@ import { CardHeader, CardTitle, Input, + useAiSearchQuery, } from "@databricks/appkit-ui/react"; import { createFileRoute } from "@tanstack/react-router"; import { Search } from "lucide-react"; @@ -15,64 +16,25 @@ export const Route = createFileRoute("/ai-search")({ component: AiSearchRoute, }); -interface SearchResult { - score: number; - data: Record; -} - -interface SearchResponse { - results: SearchResult[]; - totalCount: number; - queryTimeMs: number; - queryType: string; -} - function AiSearchRoute() { const [query, setQuery] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [response, setResponse] = useState(null); - - const handleSearch = async () => { - if (!query.trim()) return; - setLoading(true); - setError(null); - setResponse(null); - - try { - const res = await fetch("/api/ai-search/demo/query", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ queryText: query }), - }); - - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error ?? `HTTP ${res.status}: ${res.statusText}`); - } + const { search, data, loading, error } = useAiSearchQuery({ alias: "demo" }); - const data: SearchResponse = await res.json(); - setResponse(data); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } + const handleSearch = () => { + if (query.trim()) void search(query); }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - void handleSearch(); - } + if (e.key === "Enter") handleSearch(); }; return (
@@ -84,10 +46,7 @@ function AiSearchRoute() { onKeyDown={handleKeyDown} className="flex-1" /> -
)} - {response && ( + {data && (
- {response.totalCount} result - {response.totalCount !== 1 ? "s" : ""} ·{" "} - {response.queryTimeMs}ms · {response.queryType} + {data.totalCount} result + {data.totalCount !== 1 ? "s" : ""} · {data.queryTimeMs}ms + · {data.queryType}
- {response.results.length === 0 ? ( + {data.results.length === 0 ? (

No results found.

) : ( - response.results.map((result, index) => ( + data.results.map((result, index) => ( diff --git a/docs/docs/plugins/ai-search.md b/docs/docs/plugins/ai-search.md index dbfb0ac45..109561141 100644 --- a/docs/docs/plugins/ai-search.md +++ b/docs/docs/plugins/ai-search.md @@ -253,3 +253,27 @@ console.log(result.results); ``` Pass optional overrides as a second argument to `query` to adjust `numResults` or other per-call settings. + +## React hook + +`useAiSearchQuery` reads the configured indexes from the plugin's client config and posts to the right `/:alias/query` route, so the UI never hardcodes an alias. With one index configured it needs no arguments; pass `{ alias }` to target a specific one. + +```tsx +import { useAiSearchQuery } from "@databricks/appkit-ui/react"; + +function Search() { + const { search, data, loading, error } = useAiSearchQuery(); + + return ( + <> + e.key === "Enter" && search(e.currentTarget.value)} /> + {error &&

{error}

} + {data?.results.map((r, i) => ( +
{JSON.stringify(r.data)}
+ ))} + + ); +} +``` + +`search` also accepts a full request object (`{ queryText, numResults, filters, ... }`) for per-call control. The hook's `indexes` field lists every configured index, which you can use to build an index picker. diff --git a/docs/static/appkit-ui/styles.gen.css b/docs/static/appkit-ui/styles.gen.css index 58ecf8142..9e1d5c0c0 100644 --- a/docs/static/appkit-ui/styles.gen.css +++ b/docs/static/appkit-ui/styles.gen.css @@ -2773,6 +2773,12 @@ border-color: var(--ring); } } + .focus-visible\:shadow-none { + &:focus-visible { + --tw-shadow: 0 0 #0000; + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } .focus-visible\:ring-0 { &:focus-visible { --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-ai-search-query.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-ai-search-query.test.ts new file mode 100644 index 000000000..d9081652c --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-ai-search-query.test.ts @@ -0,0 +1,157 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const mockUsePluginClientConfig = vi.fn(); + +vi.mock("../use-plugin-config", () => ({ + usePluginClientConfig: (...args: unknown[]) => + mockUsePluginClientConfig(...args), +})); + +import { useAiSearchQuery } from "../use-ai-search-query"; + +const RESPONSE = { + results: [{ score: 0.9, data: { id: "1", text: "hi" } }], + totalCount: 1, + queryTimeMs: 12, + queryType: "hybrid", + nextPageToken: null, +}; + +describe("useAiSearchQuery", () => { + beforeEach(() => { + mockUsePluginClientConfig.mockReturnValue({ + indexes: [{ alias: "demo", queryType: "hybrid", pagination: false }], + }); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(RESPONSE), { status: 200 }), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("defaults to the first configured index", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const { result } = renderHook(() => useAiSearchQuery()); + + expect(result.current.alias).toBe("demo"); + expect(result.current.error).toBeNull(); + + act(() => { + void result.current.search("hello"); + }); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + "/api/ai-search/demo/query", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ queryText: "hello" }), + }), + ); + }); + }); + + test("uses the provided alias", async () => { + mockUsePluginClientConfig.mockReturnValue({ + indexes: [ + { alias: "demo", queryType: "hybrid", pagination: false }, + { alias: "docs", queryType: "ann", pagination: false }, + ], + }); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const { result } = renderHook(() => useAiSearchQuery({ alias: "docs" })); + + act(() => { + void result.current.search("hello"); + }); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + "/api/ai-search/docs/query", + expect.any(Object), + ); + }); + }); + + test("forwards a full request object", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const { result } = renderHook(() => useAiSearchQuery()); + + act(() => { + void result.current.search({ queryText: "hi", numResults: 5 }); + }); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + "/api/ai-search/demo/query", + expect.objectContaining({ + body: JSON.stringify({ queryText: "hi", numResults: 5 }), + }), + ); + }); + }); + + test("errors when no indexes are configured", () => { + mockUsePluginClientConfig.mockReturnValue({ indexes: [] }); + + const { result } = renderHook(() => useAiSearchQuery()); + + expect(result.current.alias).toBeNull(); + expect(result.current.error).toBe("No AI Search indexes are configured."); + }); + + test("errors for an unknown alias without calling fetch", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const { result } = renderHook(() => useAiSearchQuery({ alias: "nope" })); + + expect(result.current.error).toBe( + 'Unknown AI Search index "nope". Available: demo', + ); + + let returnValue: unknown; + act(() => { + returnValue = result.current.search("hello"); + }); + + expect(await returnValue).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test("sets data on a successful search", async () => { + const { result } = renderHook(() => useAiSearchQuery()); + + act(() => { + void result.current.search("hello"); + }); + + await waitFor(() => { + expect(result.current.data).toEqual(RESPONSE); + expect(result.current.loading).toBe(false); + }); + }); + + test("surfaces the server error message", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ error: "index not ready" }), { + status: 500, + }), + ); + + const { result } = renderHook(() => useAiSearchQuery()); + + await act(async () => { + void result.current.search("hello"); + await new Promise((r) => setTimeout(r, 10)); + }); + + await waitFor(() => { + expect(result.current.error).toBe("index not ready"); + expect(result.current.loading).toBe(false); + }); + }); +}); diff --git a/packages/appkit-ui/src/react/hooks/index.ts b/packages/appkit-ui/src/react/hooks/index.ts index 63b639761..557f45533 100644 --- a/packages/appkit-ui/src/react/hooks/index.ts +++ b/packages/appkit-ui/src/react/hooks/index.ts @@ -6,6 +6,12 @@ export { useResourceStatusToaster, } from "../resource-status-indicator"; export type { + AiSearchClientConfig, + AiSearchIndexSummary, + AiSearchQueryType, + AiSearchRequest, + AiSearchResponse, + AiSearchResult, AnalyticsFormat, InferResultByFormat, InferRowType, @@ -28,6 +34,11 @@ export { type UseAgentChatResult, useAgentChat, } from "./use-agent-chat"; +export { + type UseAiSearchQueryOptions, + type UseAiSearchQueryResult, + useAiSearchQuery, +} from "./use-ai-search-query"; export { useAnalyticsQuery } from "./use-analytics-query"; export { type UseChartDataOptions, diff --git a/packages/appkit-ui/src/react/hooks/types.ts b/packages/appkit-ui/src/react/hooks/types.ts index aa0df8905..7f4dba04e 100644 --- a/packages/appkit-ui/src/react/hooks/types.ts +++ b/packages/appkit-ui/src/react/hooks/types.ts @@ -197,6 +197,51 @@ export interface ServingClientConfig { aliases: string[]; } +// ============================================================================ +// AI Search +// ============================================================================ + +export type AiSearchQueryType = "ann" | "hybrid" | "full_text"; + +/** One configured index, as exposed by the ai-search plugin's `clientConfig()`. */ +export interface AiSearchIndexSummary { + alias: string; + queryType: AiSearchQueryType; + pagination: boolean; +} + +/** Shape of the ai-search plugin's client config on `window.__appkit__`. */ +export interface AiSearchClientConfig { + indexes: AiSearchIndexSummary[]; +} + +export interface AiSearchRequest { + queryText?: string; + queryVector?: number[]; + columns?: string[]; + numResults?: number; + queryType?: AiSearchQueryType; + filters?: Record; + reranker?: boolean; +} + +export interface AiSearchResult< + T extends Record = Record, +> { + score: number; + data: T; +} + +export interface AiSearchResponse< + T extends Record = Record, +> { + results: AiSearchResult[]; + totalCount: number; + queryTimeMs: number; + queryType: AiSearchQueryType; + nextPageToken: string | null; +} + // ============================================================================ // Serving Endpoint Registry // ============================================================================ diff --git a/packages/appkit-ui/src/react/hooks/use-ai-search-query.ts b/packages/appkit-ui/src/react/hooks/use-ai-search-query.ts new file mode 100644 index 000000000..f7c8e782c --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-ai-search-query.ts @@ -0,0 +1,114 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { + AiSearchClientConfig, + AiSearchIndexSummary, + AiSearchRequest, + AiSearchResponse, +} from "./types"; +import { usePluginClientConfig } from "./use-plugin-config"; + +export interface UseAiSearchQueryOptions { + /** + * Index alias to query. Defaults to the first index exposed by the plugin's + * `clientConfig()`, so a single-index app needs no alias. + */ + alias?: string; +} + +export interface UseAiSearchQueryResult< + T extends Record = Record, +> { + /** Run a search. Pass query text, or a full request for filters/paging control. */ + search: ( + query: string | AiSearchRequest, + ) => Promise | null>; + /** Latest response, null until the first successful search. */ + data: AiSearchResponse | null; + /** Whether a search is in progress. */ + loading: boolean; + /** Error message, if any. */ + error: string | null; + /** The resolved alias this hook queries. */ + alias: string | null; + /** All configured indexes, for building a selector. */ + indexes: AiSearchIndexSummary[]; +} + +/** + * Hook for querying a Databricks AI Search index. Reads the available indexes + * from the ai-search plugin's `clientConfig()` and POSTs to + * `/api/ai-search/{alias}/query`, so the UI never hardcodes an endpoint alias. + */ +export function useAiSearchQuery< + T extends Record = Record, +>(options: UseAiSearchQueryOptions = {}): UseAiSearchQueryResult { + const config = usePluginClientConfig("aiSearch"); + const indexes = config.indexes ?? []; + + const alias = options.alias ?? indexes[0]?.alias ?? null; + + const aliasError = useMemo(() => { + if (!alias) return "No AI Search indexes are configured."; + if (options.alias && !indexes.some((i) => i.alias === options.alias)) { + const available = indexes.map((i) => i.alias).join(", ") || "none"; + return `Unknown AI Search index "${options.alias}". Available: ${available}`; + } + return null; + }, [alias, options.alias, indexes]); + + const [data, setData] = useState | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(aliasError); + const abortControllerRef = useRef(null); + + const search = useCallback( + (query: string | AiSearchRequest): Promise | null> => { + if (aliasError || !alias) { + setError(aliasError); + return Promise.resolve(null); + } + + abortControllerRef.current?.abort(); + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + setLoading(true); + setError(null); + setData(null); + + const body: AiSearchRequest = + typeof query === "string" ? { queryText: query } : query; + + return fetch(`/api/ai-search/${encodeURIComponent(alias)}/query`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: abortController.signal, + }) + .then(async (res) => { + if (!res.ok) { + const errorBody = await res.json().catch(() => null); + throw new Error(errorBody?.error || `HTTP ${res.status}`); + } + return res.json(); + }) + .then((result: AiSearchResponse) => { + if (abortController.signal.aborted) return null; + setData(result); + setLoading(false); + return result; + }) + .catch((err: Error) => { + if (abortController.signal.aborted) return null; + setError(err.message || "Search failed"); + setLoading(false); + return null; + }); + }, + [alias, aliasError], + ); + + useEffect(() => () => abortControllerRef.current?.abort(), []); + + return { search, data, loading, error, alias, indexes }; +} diff --git a/packages/appkit/src/plugins/ai-search/ai-search.ts b/packages/appkit/src/plugins/ai-search/ai-search.ts index 16a6318ce..e4e0a01b6 100644 --- a/packages/appkit/src/plugins/ai-search/ai-search.ts +++ b/packages/appkit/src/plugins/ai-search/ai-search.ts @@ -14,6 +14,7 @@ import manifest from "./manifest.json"; import type { IAiSearchConfig, IndexConfig, + IndexSummary, SearchRequest, SearchResponse, SearchResult, @@ -292,6 +293,23 @@ export class AiSearchPlugin extends Plugin { }); } + /** + * Configured index aliases + metadata, serialized to the browser at boot via + * `window.__appkit__` (read client-side with `usePluginClientConfig`). Lets the + * UI discover available indexes instead of hardcoding an alias. No secrets — + * only alias names and non-sensitive query metadata. + */ + clientConfig(): { indexes: IndexSummary[] } { + const indexes = Object.entries(this.config.indexes ?? {}).map( + ([alias, idx]) => ({ + alias, + queryType: idx.queryType ?? "hybrid", + pagination: !!idx.pagination, + }), + ); + return { indexes }; + } + /** * Programmatic query API — available as `appkit.aiSearch.query()`. * When called through `asUser(req)`, executes with the user's credentials. diff --git a/packages/appkit/src/plugins/ai-search/types.ts b/packages/appkit/src/plugins/ai-search/types.ts index 89abea818..7829482a8 100644 --- a/packages/appkit/src/plugins/ai-search/types.ts +++ b/packages/appkit/src/plugins/ai-search/types.ts @@ -45,6 +45,13 @@ export interface RerankerConfig { columnsToRerank: string[]; } +/** Public summary of a configured index, exposed to the client via `clientConfig()`. */ +export interface IndexSummary { + alias: string; + queryType: "ann" | "hybrid" | "full_text"; + pagination: boolean; +} + export type SearchFilters = Record< string, string | number | boolean | (string | number)[] diff --git a/template/client/src/pages/ai-search/AiSearchPage.tsx b/template/client/src/pages/ai-search/AiSearchPage.tsx index 56d7617b8..0f973d92b 100644 --- a/template/client/src/pages/ai-search/AiSearchPage.tsx +++ b/template/client/src/pages/ai-search/AiSearchPage.tsx @@ -7,59 +7,22 @@ import { CardTitle, Input, Skeleton, + useAiSearchQuery, } from '@databricks/appkit-ui/react'; import { Search } from 'lucide-react'; import { useState } from 'react'; -interface SearchResult { - score: number; - data: Record; -} - -interface SearchResponse { - results: SearchResult[]; - totalCount: number; - queryTimeMs: number; - queryType: string; -} - export function AiSearchPage() { const [query, setQuery] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [response, setResponse] = useState(null); - - const handleSearch = async () => { - if (!query.trim()) return; - setLoading(true); - setError(null); - setResponse(null); - - try { - const res = await fetch('/api/ai-search/default/query', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ queryText: query }), - }); - - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error ?? `HTTP ${res.status}: ${res.statusText}`); - } + // Queries the first configured index. Pass `{ alias }` to target another. + const { search, data, loading, error } = useAiSearchQuery(); - const data: SearchResponse = await res.json(); - setResponse(data); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } + const handleSearch = () => { + if (query.trim()) void search(query); }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - void handleSearch(); - } + if (e.key === 'Enter') handleSearch(); }; return ( @@ -79,7 +42,7 @@ export function AiSearchPage() { onKeyDown={handleKeyDown} className="flex-1" /> -
)} - {response && !loading && ( + {data && !loading && (

- {response.totalCount} result{response.totalCount !== 1 ? 's' : ''} ·{' '} - {response.queryTimeMs}ms · {response.queryType} + {data.totalCount} result{data.totalCount !== 1 ? 's' : ''} ·{' '} + {data.queryTimeMs}ms · {data.queryType}

- {response.results.length === 0 ? ( + {data.results.length === 0 ? (

No results found.

) : ( - response.results.map((result, index) => ( + data.results.map((result, index) => ( From fdb8c7e2929383b86efabfdc8f14affab6e6a066 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 5 Aug 2026 10:31:40 +0200 Subject: [PATCH 21/27] refactor(appkit-ui): move useAiSearchQuery to the beta React entry The hook's contract is defined entirely by the aiSearch plugin, which ships at beta from @databricks/appkit/beta. Export the hook and its types from @databricks/appkit-ui/react/beta so its stability tracks the plugin, instead of the stable @databricks/appkit-ui/react surface. Consumers and docs updated. Signed-off-by: MarioCadenas --- .../client/src/routes/ai-search.route.tsx | 2 +- docs/docs/plugins/ai-search.md | 2 +- packages/appkit-ui/src/react/beta.ts | 16 ++++++++++++++++ packages/appkit-ui/src/react/hooks/index.ts | 11 ----------- .../client/src/pages/ai-search/AiSearchPage.tsx | 2 +- 5 files changed, 19 insertions(+), 14 deletions(-) diff --git a/apps/dev-playground/client/src/routes/ai-search.route.tsx b/apps/dev-playground/client/src/routes/ai-search.route.tsx index f2d0569a3..b64874167 100644 --- a/apps/dev-playground/client/src/routes/ai-search.route.tsx +++ b/apps/dev-playground/client/src/routes/ai-search.route.tsx @@ -5,8 +5,8 @@ import { CardHeader, CardTitle, Input, - useAiSearchQuery, } from "@databricks/appkit-ui/react"; +import { useAiSearchQuery } from "@databricks/appkit-ui/react/beta"; import { createFileRoute } from "@tanstack/react-router"; import { Search } from "lucide-react"; import { useState } from "react"; diff --git a/docs/docs/plugins/ai-search.md b/docs/docs/plugins/ai-search.md index 109561141..14012c060 100644 --- a/docs/docs/plugins/ai-search.md +++ b/docs/docs/plugins/ai-search.md @@ -259,7 +259,7 @@ Pass optional overrides as a second argument to `query` to adjust `numResults` o `useAiSearchQuery` reads the configured indexes from the plugin's client config and posts to the right `/:alias/query` route, so the UI never hardcodes an alias. With one index configured it needs no arguments; pass `{ alias }` to target a specific one. ```tsx -import { useAiSearchQuery } from "@databricks/appkit-ui/react"; +import { useAiSearchQuery } from "@databricks/appkit-ui/react/beta"; function Search() { const { search, data, loading, error } = useAiSearchQuery(); diff --git a/packages/appkit-ui/src/react/beta.ts b/packages/appkit-ui/src/react/beta.ts index 0405e50de..992a79635 100644 --- a/packages/appkit-ui/src/react/beta.ts +++ b/packages/appkit-ui/src/react/beta.ts @@ -1,2 +1,18 @@ // Beta React components -- APIs may change between minor releases. // Import from '@databricks/appkit-ui/react' once graduated to stable. + +// AI Search hook + types. Tracks the `aiSearch` plugin, which ships at beta +// from '@databricks/appkit/beta'. +export type { + AiSearchClientConfig, + AiSearchIndexSummary, + AiSearchQueryType, + AiSearchRequest, + AiSearchResponse, + AiSearchResult, +} from "./hooks/types"; +export { + type UseAiSearchQueryOptions, + type UseAiSearchQueryResult, + useAiSearchQuery, +} from "./hooks/use-ai-search-query"; diff --git a/packages/appkit-ui/src/react/hooks/index.ts b/packages/appkit-ui/src/react/hooks/index.ts index 557f45533..63b639761 100644 --- a/packages/appkit-ui/src/react/hooks/index.ts +++ b/packages/appkit-ui/src/react/hooks/index.ts @@ -6,12 +6,6 @@ export { useResourceStatusToaster, } from "../resource-status-indicator"; export type { - AiSearchClientConfig, - AiSearchIndexSummary, - AiSearchQueryType, - AiSearchRequest, - AiSearchResponse, - AiSearchResult, AnalyticsFormat, InferResultByFormat, InferRowType, @@ -34,11 +28,6 @@ export { type UseAgentChatResult, useAgentChat, } from "./use-agent-chat"; -export { - type UseAiSearchQueryOptions, - type UseAiSearchQueryResult, - useAiSearchQuery, -} from "./use-ai-search-query"; export { useAnalyticsQuery } from "./use-analytics-query"; export { type UseChartDataOptions, diff --git a/template/client/src/pages/ai-search/AiSearchPage.tsx b/template/client/src/pages/ai-search/AiSearchPage.tsx index 0f973d92b..5d07b335a 100644 --- a/template/client/src/pages/ai-search/AiSearchPage.tsx +++ b/template/client/src/pages/ai-search/AiSearchPage.tsx @@ -7,8 +7,8 @@ import { CardTitle, Input, Skeleton, - useAiSearchQuery, } from '@databricks/appkit-ui/react'; +import { useAiSearchQuery } from '@databricks/appkit-ui/react/beta'; import { Search } from 'lucide-react'; import { useState } from 'react'; From d72efc24f6c5d6ccdf65570534ad6be14b243680 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 5 Aug 2026 12:04:24 +0200 Subject: [PATCH 22/27] fix(appkit): ai-search resource uses `id` field, drop pagination-only endpointName The vector_search_index resource named its fields indexName/endpointName, but the CLI's uc_securable mapping expects a single `id` field for the securable's full name. The mismatch broke deployment three ways: - databricks.yml referenced ${var.vector_search_index_id} (never assigned) -> "no value assigned to required variable" abort. - app.yaml mapped both DATABRICKS_VS_INDEX_NAME and DATABRICKS_VS_ENDPOINT_NAME to the same securable, so the endpoint env got the index name. - Resource validation requires every env-bearing field, so a missing DATABRICKS_VS_ENDPOINT_NAME threw ConfigurationError at startup even for non-paginated apps. Rename the field to `id` (env unchanged: DATABRICKS_VS_INDEX_NAME) and drop endpointName from the resource. Pagination users set DATABRICKS_VS_ENDPOINT_NAME via plain env/config; it was never derivable from the securable. Signed-off-by: MarioCadenas --- packages/appkit/src/plugins/ai-search/manifest.json | 6 +----- template/appkit.plugins.json | 7 +------ 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/appkit/src/plugins/ai-search/manifest.json b/packages/appkit/src/plugins/ai-search/manifest.json index 1516f986b..849c3315a 100644 --- a/packages/appkit/src/plugins/ai-search/manifest.json +++ b/packages/appkit/src/plugins/ai-search/manifest.json @@ -13,13 +13,9 @@ "description": "A Databricks Vector Search index to query. Index names configured via plugin config.", "permission": "SELECT", "fields": { - "indexName": { + "id": { "env": "DATABRICKS_VS_INDEX_NAME", "description": "Three-level UC name of the default index (catalog.schema.index_name)" - }, - "endpointName": { - "env": "DATABRICKS_VS_ENDPOINT_NAME", - "description": "Vector Search endpoint name (required for pagination)" } } } diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index 703450ecd..078ab524a 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -42,15 +42,10 @@ "description": "A Databricks Vector Search index to query. Index names configured via plugin config.", "permission": "SELECT", "fields": { - "indexName": { + "id": { "env": "DATABRICKS_VS_INDEX_NAME", "description": "Three-level UC name of the default index (catalog.schema.index_name)", "origin": "user" - }, - "endpointName": { - "env": "DATABRICKS_VS_ENDPOINT_NAME", - "description": "Vector Search endpoint name (required for pagination)", - "origin": "user" } } } From 595c577616f2313f6a420744b4dfac77c158c185 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 5 Aug 2026 14:49:36 +0200 Subject: [PATCH 23/27] fix(appkit): address ai-search review findings (security, correctness, robustness) - Column projection allowlist on HTTP: the /:alias/query route drops client-supplied `columns` so callers can't read fields the app didn't opt to expose (routes run as the service principal). Programmatic query() keeps the override. - filters -> filters_json: the VS query API ignores an object under `filters`; send JSON.stringify under `filters_json` so filtered searches actually filter. - Fail fast on empty columns: setup() throws outside development when an index has no columns, instead of deploying an app that 500s every query. - OBO embeddingFn: run query preparation inside execute() so a self-managed embeddingFn shares the on-behalf-of-user context instead of the SP. - UI hook: reset data/error and abort in-flight requests when the alias changes, so an index switch can't surface the previous index's results. - next-page echoes the request's queryType instead of the index default, keeping paged responses consistent with page 1. - Remove the unused KebabToCamel type; document the indexName env fallback collision, the dev-only column-discovery limitation, and query()'s unchecked generic. Add direct tests for contextFromAbortSignal and tighten the abort assertion from expect.anything() to expect.any(Context). Signed-off-by: MarioCadenas --- docs/docs/api/appkit/Interface.IndexConfig.md | 4 +- docs/docs/plugins/ai-search.md | 7 ++ .../__tests__/use-ai-search-query.test.ts | 42 +++++++++++ .../src/react/hooks/use-ai-search-query.ts | 11 +++ .../appkit/src/connectors/ai-search/client.ts | 4 +- .../src/connectors/tests/context.test.ts | 61 ++++++++++++++++ .../appkit/src/plugins/ai-search/ai-search.ts | 69 ++++++++++++------ .../plugins/ai-search/tests/ai-search.test.ts | 73 +++++++++++++++++-- .../appkit/src/plugins/ai-search/types.ts | 4 +- packages/shared/src/naming.ts | 8 +- 10 files changed, 244 insertions(+), 39 deletions(-) create mode 100644 packages/appkit/src/connectors/tests/context.test.ts diff --git a/docs/docs/api/appkit/Interface.IndexConfig.md b/docs/docs/api/appkit/Interface.IndexConfig.md index 96fb29688..c31bd5e42 100644 --- a/docs/docs/api/appkit/Interface.IndexConfig.md +++ b/docs/docs/api/appkit/Interface.IndexConfig.md @@ -65,7 +65,9 @@ optional indexName: string; ``` Three-level UC name: catalog.schema.index_name. Defaults to the -`DATABRICKS_VS_INDEX_NAME` env var when omitted. +`DATABRICKS_VS_INDEX_NAME` env var when omitted — so multiple aliases that +omit it all resolve to that same physical index. Set it explicitly per +alias when they should point at distinct indexes. *** diff --git a/docs/docs/plugins/ai-search.md b/docs/docs/plugins/ai-search.md index 14012c060..397e00f3a 100644 --- a/docs/docs/plugins/ai-search.md +++ b/docs/docs/plugins/ai-search.md @@ -70,6 +70,13 @@ aiSearch({ }); ``` +:::note +An alias without its own `indexName` falls back to the `DATABRICKS_VS_INDEX_NAME` +env var. If several aliases omit `indexName`, they all resolve to that one +physical index (with their own per-alias `columns`, `queryType`, etc.). Give +each alias an explicit `indexName` when you mean distinct indexes. +::: + ## IndexConfig | Field | Type | Default | Description | diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-ai-search-query.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-ai-search-query.test.ts index d9081652c..e208f47ac 100644 --- a/packages/appkit-ui/src/react/hooks/__tests__/use-ai-search-query.test.ts +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-ai-search-query.test.ts @@ -154,4 +154,46 @@ describe("useAiSearchQuery", () => { expect(result.current.loading).toBe(false); }); }); + + test("clears stale data when the alias changes", async () => { + mockUsePluginClientConfig.mockReturnValue({ + indexes: [ + { alias: "demo", queryType: "hybrid", pagination: false }, + { alias: "docs", queryType: "ann", pagination: false }, + ], + }); + + const { result, rerender } = renderHook( + ({ alias }) => useAiSearchQuery({ alias }), + { initialProps: { alias: "demo" } }, + ); + + act(() => { + void result.current.search("hello"); + }); + await waitFor(() => expect(result.current.data).toEqual(RESPONSE)); + + // Switch index: prior results must not linger under the new alias. + rerender({ alias: "docs" }); + expect(result.current.alias).toBe("docs"); + expect(result.current.data).toBeNull(); + }); + + test("re-syncs the error when the alias becomes unknown", async () => { + mockUsePluginClientConfig.mockReturnValue({ + indexes: [{ alias: "demo", queryType: "hybrid", pagination: false }], + }); + + const { result, rerender } = renderHook( + ({ alias }) => useAiSearchQuery({ alias }), + { initialProps: { alias: "demo" } }, + ); + + expect(result.current.error).toBeNull(); + + rerender({ alias: "nope" }); + expect(result.current.error).toBe( + 'Unknown AI Search index "nope". Available: demo', + ); + }); }); diff --git a/packages/appkit-ui/src/react/hooks/use-ai-search-query.ts b/packages/appkit-ui/src/react/hooks/use-ai-search-query.ts index f7c8e782c..f160341b2 100644 --- a/packages/appkit-ui/src/react/hooks/use-ai-search-query.ts +++ b/packages/appkit-ui/src/react/hooks/use-ai-search-query.ts @@ -108,6 +108,17 @@ export function useAiSearchQuery< [alias, aliasError], ); + // Reset when the target alias changes: abort any in-flight request (its + // result would otherwise land under the new alias) and clear stale + // data/error, re-syncing error to the new alias's validation state. + // biome-ignore lint/correctness/useExhaustiveDependencies: `alias` is needed — switching between two valid aliases leaves `aliasError` null, so keying on it alone would skip the reset. + useEffect(() => { + abortControllerRef.current?.abort(); + setData(null); + setLoading(false); + setError(aliasError); + }, [alias, aliasError]); + useEffect(() => () => abortControllerRef.current?.abort(), []); return { search, data, loading, error, alias, indexes }; diff --git a/packages/appkit/src/connectors/ai-search/client.ts b/packages/appkit/src/connectors/ai-search/client.ts index 04d0a6f71..7a629614a 100644 --- a/packages/appkit/src/connectors/ai-search/client.ts +++ b/packages/appkit/src/connectors/ai-search/client.ts @@ -48,7 +48,9 @@ export class AiSearchConnector { if (params.queryText) body.query_text = params.queryText; if (params.queryVector) body.query_vector = params.queryVector; if (params.filters && Object.keys(params.filters).length > 0) { - body.filters = params.filters; + // The VS query API expects a JSON-encoded string under `filters_json`; + // an object under `filters` is silently ignored (query runs unfiltered). + body.filters_json = JSON.stringify(params.filters); } if (params.reranker) { body.reranker = { diff --git a/packages/appkit/src/connectors/tests/context.test.ts b/packages/appkit/src/connectors/tests/context.test.ts new file mode 100644 index 000000000..58eb034fc --- /dev/null +++ b/packages/appkit/src/connectors/tests/context.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import { Context } from "../../workspace-client"; +import { contextFromAbortSignal } from "../context"; + +describe("contextFromAbortSignal", () => { + it("returns undefined when no signal is given", () => { + expect(contextFromAbortSignal()).toBeUndefined(); + }); + + it("wraps a signal in an SDK Context", () => { + const ctx = contextFromAbortSignal(new AbortController().signal); + expect(ctx).toBeInstanceOf(Context); + expect(ctx?.cancellationToken).toBeDefined(); + }); + + it("reflects the signal's aborted state via isCancellationRequested", () => { + const controller = new AbortController(); + const token = contextFromAbortSignal(controller.signal)?.cancellationToken; + + expect(token?.isCancellationRequested).toBe(false); + controller.abort(); + expect(token?.isCancellationRequested).toBe(true); + }); + + it("fires registered callbacks when the signal aborts", () => { + const controller = new AbortController(); + const token = contextFromAbortSignal(controller.signal)?.cancellationToken; + + const cb = vi.fn(); + token?.onCancellationRequested(cb); + expect(cb).not.toHaveBeenCalled(); + + controller.abort(); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("fires immediately when registering on an already-aborted signal", () => { + const controller = new AbortController(); + controller.abort(); + const token = contextFromAbortSignal(controller.signal)?.cancellationToken; + + const cb = vi.fn(); + token?.onCancellationRequested(cb); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("isolates callback failures so abort stays best-effort", () => { + const controller = new AbortController(); + const token = contextFromAbortSignal(controller.signal)?.cancellationToken; + + const bad = vi.fn(() => { + throw new Error("listener boom"); + }); + const good = vi.fn(); + token?.onCancellationRequested(bad); + token?.onCancellationRequested(good); + + expect(() => controller.abort()).not.toThrow(); + expect(good).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/appkit/src/plugins/ai-search/ai-search.ts b/packages/appkit/src/plugins/ai-search/ai-search.ts index e4e0a01b6..f1069cf37 100644 --- a/packages/appkit/src/plugins/ai-search/ai-search.ts +++ b/packages/appkit/src/plugins/ai-search/ai-search.ts @@ -71,11 +71,19 @@ export class AiSearchPlugin extends Plugin { } // Development convenience: fill in `columns` for any index that omits them - // by reading the index's source table. Never runs in production, where a - // missing `columns` surfaces as a normal query error — so this can't mask a - // config gap that would fail once deployed. + // by reading the index's source table. In production there's no discovery; + // an index with no columns can never query (the VS API requires `columns`), + // so fail fast at boot instead of 500ing every request. if (process.env.NODE_ENV === "development") { await this._autoDiscoverColumns(); + } else { + for (const [alias, idx] of Object.entries(this.config.indexes ?? {})) { + if (!idx.columns || idx.columns.length === 0) { + throw new Error( + `Index "${alias}" has no columns configured. Vector Search queries require "columns"; set them explicitly (auto-discovered only in development).`, + ); + } + } } } @@ -85,6 +93,11 @@ export class AiSearchPlugin extends Plugin { * Best-effort: any failure (no auth, index not ready, non-Delta-Sync index) * is logged and skipped rather than thrown. Emits one warning banner listing * what was auto-filled, since these must be set explicitly before production. + * + * Uses all source-table columns (minus embedding vectors). Indexes with a + * partial `columns_to_sync` may include columns the index can't return; since + * this is a dev-only convenience (prod requires explicit `columns`), the + * discovered list is a starting point to trim, not an authoritative set. */ private async _autoDiscoverColumns(): Promise { const discovered: Record = {}; @@ -167,20 +180,29 @@ export class AiSearchPlugin extends Plugin { return; } - try { - const prepared = await this._prepareQuery(body, indexConfig); - const plugin = - indexConfig.auth === "on-behalf-of-user" ? this.asUser(req) : this; + // Configured `columns` are the projection allowlist over HTTP: drop + // any client-supplied `columns` so a caller can't read fields the app + // didn't opt to expose (routes run as the service principal by + // default). Programmatic `query()` callers are trusted and keep the + // override. + const { columns: _clientColumns, ...safeBody } = body; + const plugin = + indexConfig.auth === "on-behalf-of-user" ? this.asUser(req) : this; + const queryType = + safeBody.queryType ?? indexConfig.queryType ?? "hybrid"; - const result = await plugin.execute( - async (signal) => - this.connector.query( - getWorkspaceClient(), - { indexName: indexConfig.indexName, ...prepared }, - signal, - ), - querySettings, - ); + try { + // Prepare inside execute so query preparation — notably a + // self-managed `embeddingFn` — runs in the same OBO context as the + // VS call, not as the service principal. + const result = await plugin.execute(async (signal) => { + const prepared = await this._prepareQuery(safeBody, indexConfig); + return this.connector.query( + getWorkspaceClient(), + { indexName: indexConfig.indexName, ...prepared }, + signal, + ); + }, querySettings); if (!result.ok) { res @@ -188,7 +210,7 @@ export class AiSearchPlugin extends Plugin { .json({ error: result.message, plugin: this.name }); return; } - res.json(this._parseResponse(result.data, prepared.queryType)); + res.json(this._parseResponse(result.data, queryType)); } catch (error) { this._handleError(res, error, "Query failed"); } @@ -225,7 +247,7 @@ export class AiSearchPlugin extends Plugin { return; } - const { pageToken } = req.body; + const { pageToken, queryType } = req.body; if (!pageToken) { res.status(400).json({ error: "pageToken is required", @@ -233,6 +255,9 @@ export class AiSearchPlugin extends Plugin { }); return; } + // Echo the original query's queryType so paged responses stay + // consistent with page 1; fall back to the index default. + const pageQueryType = queryType ?? indexConfig.queryType ?? "hybrid"; try { const plugin = @@ -258,9 +283,7 @@ export class AiSearchPlugin extends Plugin { .json({ error: result.message, plugin: this.name }); return; } - res.json( - this._parseResponse(result.data, indexConfig.queryType ?? "hybrid"), - ); + res.json(this._parseResponse(result.data, pageQueryType)); } catch (error) { this._handleError(res, error, "Next-page query failed"); } @@ -313,6 +336,10 @@ export class AiSearchPlugin extends Plugin { /** * Programmatic query API — available as `appkit.aiSearch.query()`. * When called through `asUser(req)`, executes with the user's credentials. + * + * @remarks `T` is an unchecked assertion on each result's `data`: rows are + * built from the index's returned columns and cast to `T` with no runtime + * validation, so a mismatched `T` won't be caught here. */ async query = Record>( alias: string, diff --git a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts index 16cc16381..3a8efc0ab 100644 --- a/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts +++ b/packages/appkit/src/plugins/ai-search/tests/ai-search.test.ts @@ -4,6 +4,7 @@ import { createMockRouter, } from "@tools/test-helpers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Context } from "../../../workspace-client"; vi.mock("../../../context", () => ({ getWorkspaceClient: vi.fn(() => mockWorkspaceClient), @@ -171,6 +172,34 @@ describe("AiSearchPlugin", () => { }); await expect(plugin.setup()).resolves.not.toThrow(); }); + + it("throws outside development when an index has no columns", async () => { + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "production"; + try { + const plugin = new AiSearchPlugin({ + indexes: { docs: { indexName: "cat.sch.idx" } }, + }); + await expect(plugin.setup()).rejects.toThrow( + 'Index "docs" has no columns configured', + ); + } finally { + process.env.NODE_ENV = originalNodeEnv; + } + }); + + it("does not throw outside development when columns are configured", async () => { + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "production"; + try { + const plugin = new AiSearchPlugin({ + indexes: { docs: { indexName: "cat.sch.idx", columns: ["id"] } }, + }); + await expect(plugin.setup()).resolves.not.toThrow(); + } finally { + process.env.NODE_ENV = originalNodeEnv; + } + }); }); describe("setup() column auto-discovery", () => { @@ -220,8 +249,10 @@ describe("AiSearchPlugin", () => { it("does not discover columns outside development", async () => { process.env.NODE_ENV = "production"; mockRequest.mockImplementation(routeByPath); + // Columns set so the prod no-columns guard doesn't fire; this test only + // asserts discovery doesn't run outside development. const plugin = new AiSearchPlugin({ - indexes: { docs: { indexName: "cat.sch.idx" } }, + indexes: { docs: { indexName: "cat.sch.idx", columns: ["id"] } }, }); await plugin.setup(); @@ -335,7 +366,7 @@ describe("AiSearchPlugin", () => { path: "/api/2.0/vector-search/indexes/cat.sch.idx/query", }), // 2nd arg is the SDK Context bridging the execution's abort signal. - expect.anything(), + expect.any(Context), ); const callBody = mockRequest.mock.calls[0][0].payload; @@ -374,7 +405,12 @@ describe("AiSearchPlugin", () => { }); const callBody = mockRequest.mock.calls[0][0].payload; - expect(callBody.filters).toEqual({ category: ["books"] }); + // VS expects a JSON-encoded string under `filters_json`; a raw object + // under `filters` is silently ignored by the API. + expect(callBody.filters).toBeUndefined(); + expect(callBody.filters_json).toBe( + JSON.stringify({ category: ["books"] }), + ); }); it("includes reranker config when enabled on index", async () => { @@ -604,10 +640,11 @@ describe("AiSearchPlugin", () => { }); it("skips the reranker when enabled but no columns are resolved", async () => { + // Query-time behavior only; skip setup() (its prod guard rejects the + // deliberately column-less config used to exercise this path). const plugin = new AiSearchPlugin({ indexes: { test: { indexName: "cat.sch.idx", reranker: true } }, }); - await plugin.setup(); await plugin.query("test", { queryText: "q" }); const callBody = mockRequest.mock.calls[0][0].payload; @@ -715,9 +752,29 @@ describe("AiSearchPlugin", () => { ); }); - it("500s (via _handleError) when query preparation throws", async () => { - // A throw outside execute() (failing embeddingFn) hits _handleError; - // connector failures instead surface as a non-ok result. + it("ignores a client-supplied columns override and uses the configured projection", async () => { + const plugin = makePlugin(); + const { router, getHandler } = createMockRouter(); + plugin.injectRoutes(router); + + const res = createMockResponse(); + await getHandler("POST", "/:alias/query")( + createMockRequest({ + params: { alias: "demo" }, + body: { queryText: "hi", columns: ["ssn", "internal_notes"] }, + }), + res, + ); + + // demo is configured with columns ["id", "title"]; the request's + // columns must not widen the projection. + const callBody = mockRequest.mock.calls[0][0].payload; + expect(callBody.columns).toEqual(["id", "title"]); + }); + + it("500s when query preparation throws", async () => { + // Query prep (embeddingFn) runs inside execute() so it shares the OBO + // context; a failure surfaces as a non-ok result → 500. const plugin = new AiSearchPlugin({ indexes: { demo: { @@ -795,7 +852,7 @@ describe("AiSearchPlugin", () => { path: "/api/2.0/vector-search/indexes/cat.sch.paged/query-next-page", payload: { endpoint_name: "ep", page_token: "t" }, }), - expect.anything(), + expect.any(Context), ); expect(res.json).toHaveBeenCalled(); }); diff --git a/packages/appkit/src/plugins/ai-search/types.ts b/packages/appkit/src/plugins/ai-search/types.ts index 7829482a8..bc34b0b8f 100644 --- a/packages/appkit/src/plugins/ai-search/types.ts +++ b/packages/appkit/src/plugins/ai-search/types.ts @@ -8,7 +8,9 @@ export interface IAiSearchConfig extends BasePluginConfig { export interface IndexConfig { /** * Three-level UC name: catalog.schema.index_name. Defaults to the - * `DATABRICKS_VS_INDEX_NAME` env var when omitted. + * `DATABRICKS_VS_INDEX_NAME` env var when omitted — so multiple aliases that + * omit it all resolve to that same physical index. Set it explicitly per + * alias when they should point at distinct indexes. */ indexName?: string; /** diff --git a/packages/shared/src/naming.ts b/packages/shared/src/naming.ts index 543666eda..d3f6e9830 100644 --- a/packages/shared/src/naming.ts +++ b/packages/shared/src/naming.ts @@ -4,13 +4,7 @@ * heavier `plugin.ts` graph. */ -/** Type-level kebab-to-camelCase (e.g. `"ai-search"` -> `"aiSearch"`). */ -export type KebabToCamel = - S extends `${infer Head}-${infer Tail}` - ? `${Head}${Capitalize>}` - : S; - -/** Runtime {@link KebabToCamel}. */ +/** kebab-case to camelCase (e.g. `"ai-search"` -> `"aiSearch"`). */ export function kebabToCamel(name: string): string { return name.replace(/-+([a-z0-9])/g, (_, c: string) => c.toUpperCase()); } From c4a4e55e510a306e3d7b0d03c7cc33ae2fd9b980 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 5 Aug 2026 15:39:01 +0200 Subject: [PATCH 24/27] refactor(appkit): simplify ai-search plugin and dedup shared helpers - Extract the ASCII warning-box drawing into utils/banner.ts, shared by the resource registry and the ai-search column-discovery banner. - Import camelToKebab/kebabToCamel from shared/naming in the doc-banner and plugin-entry generators instead of keeping local copies. - Collapse the repeated route boilerplate into _resolveOr404 and _sendResult. - Derive the hook's `error` from a single fetchError state + the alias validation, dropping the seed/sync coupling and an unneeded useMemo. - Add a SearchQueryType alias for the repeated query-mode union. - Trim over-long comments to their load-bearing facts. Signed-off-by: MarioCadenas --- .../src/react/hooks/use-ai-search-query.ts | 36 ++--- .../appkit/src/connectors/ai-search/client.ts | 3 +- .../appkit/src/plugins/ai-search/ai-search.ts | 146 ++++++++---------- .../appkit/src/plugins/ai-search/types.ts | 11 +- .../appkit/src/registry/resource-registry.ts | 8 +- packages/appkit/src/utils/banner.ts | 11 ++ packages/appkit/src/utils/index.ts | 1 + tools/generate-plugin-doc-banners.ts | 6 +- tools/generate-plugin-entries.ts | 13 +- 9 files changed, 105 insertions(+), 130 deletions(-) create mode 100644 packages/appkit/src/utils/banner.ts diff --git a/packages/appkit-ui/src/react/hooks/use-ai-search-query.ts b/packages/appkit-ui/src/react/hooks/use-ai-search-query.ts index f160341b2..e15559588 100644 --- a/packages/appkit-ui/src/react/hooks/use-ai-search-query.ts +++ b/packages/appkit-ui/src/react/hooks/use-ai-search-query.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { AiSearchClientConfig, AiSearchIndexSummary, @@ -47,24 +47,24 @@ export function useAiSearchQuery< const alias = options.alias ?? indexes[0]?.alias ?? null; - const aliasError = useMemo(() => { - if (!alias) return "No AI Search indexes are configured."; - if (options.alias && !indexes.some((i) => i.alias === options.alias)) { - const available = indexes.map((i) => i.alias).join(", ") || "none"; - return `Unknown AI Search index "${options.alias}". Available: ${available}`; - } - return null; - }, [alias, options.alias, indexes]); + // Config validation error; null for a valid alias. + let aliasError: string | null = null; + if (!alias) { + aliasError = "No AI Search indexes are configured."; + } else if (options.alias && !indexes.some((i) => i.alias === options.alias)) { + const available = indexes.map((i) => i.alias).join(", ") || "none"; + aliasError = `Unknown AI Search index "${options.alias}". Available: ${available}`; + } const [data, setData] = useState | null>(null); const [loading, setLoading] = useState(false); - const [error, setError] = useState(aliasError); + const [fetchError, setFetchError] = useState(null); + const error = fetchError ?? aliasError; const abortControllerRef = useRef(null); const search = useCallback( (query: string | AiSearchRequest): Promise | null> => { if (aliasError || !alias) { - setError(aliasError); return Promise.resolve(null); } @@ -73,7 +73,7 @@ export function useAiSearchQuery< abortControllerRef.current = abortController; setLoading(true); - setError(null); + setFetchError(null); setData(null); const body: AiSearchRequest = @@ -100,7 +100,7 @@ export function useAiSearchQuery< }) .catch((err: Error) => { if (abortController.signal.aborted) return null; - setError(err.message || "Search failed"); + setFetchError(err.message || "Search failed"); setLoading(false); return null; }); @@ -109,15 +109,15 @@ export function useAiSearchQuery< ); // Reset when the target alias changes: abort any in-flight request (its - // result would otherwise land under the new alias) and clear stale - // data/error, re-syncing error to the new alias's validation state. - // biome-ignore lint/correctness/useExhaustiveDependencies: `alias` is needed — switching between two valid aliases leaves `aliasError` null, so keying on it alone would skip the reset. + // result would otherwise land under the new alias) and clear stale data + + // fetch error. `error` re-derives from the new alias's validation state. + // biome-ignore lint/correctness/useExhaustiveDependencies: `alias` is the intended trigger — the effect runs to reset on every alias change, not because it reads alias. useEffect(() => { abortControllerRef.current?.abort(); setData(null); setLoading(false); - setError(aliasError); - }, [alias, aliasError]); + setFetchError(null); + }, [alias]); useEffect(() => () => abortControllerRef.current?.abort(), []); diff --git a/packages/appkit/src/connectors/ai-search/client.ts b/packages/appkit/src/connectors/ai-search/client.ts index 7a629614a..f4ed44b15 100644 --- a/packages/appkit/src/connectors/ai-search/client.ts +++ b/packages/appkit/src/connectors/ai-search/client.ts @@ -48,8 +48,7 @@ export class AiSearchConnector { if (params.queryText) body.query_text = params.queryText; if (params.queryVector) body.query_vector = params.queryVector; if (params.filters && Object.keys(params.filters).length > 0) { - // The VS query API expects a JSON-encoded string under `filters_json`; - // an object under `filters` is silently ignored (query runs unfiltered). + // VS silently ignores an object under `filters`; it wants a JSON string. body.filters_json = JSON.stringify(params.filters); } if (params.reranker) { diff --git a/packages/appkit/src/plugins/ai-search/ai-search.ts b/packages/appkit/src/plugins/ai-search/ai-search.ts index f1069cf37..345961672 100644 --- a/packages/appkit/src/plugins/ai-search/ai-search.ts +++ b/packages/appkit/src/plugins/ai-search/ai-search.ts @@ -9,12 +9,14 @@ import { getWorkspaceClient } from "../../context"; import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; import type { PluginManifest } from "../../registry"; +import { formatWarningBanner } from "../../utils/banner"; import { aiSearchDefaults } from "./defaults"; import manifest from "./manifest.json"; import type { IAiSearchConfig, IndexConfig, IndexSummary, + SearchQueryType, SearchRequest, SearchResponse, SearchResult, @@ -49,8 +51,7 @@ export class AiSearchPlugin extends Plugin { /** * Seeds a `default` index from `DATABRICKS_VS_INDEX_NAME` when no `indexes` - * are configured, so `aiSearch()` is usable with just the env var (columns - * are auto-discovered in dev). Mirrors the genie plugin's default space. + * are configured, so `aiSearch()` works with just the env var. */ private _defaultIndexes(): Record { const indexName = process.env.DATABRICKS_VS_INDEX_NAME; @@ -58,10 +59,8 @@ export class AiSearchPlugin extends Plugin { } async setup(): Promise { - // A missing indexName is a missing-resource condition owned by the - // framework's resource validation (warn in dev, throw in prod). Only the - // pagination -> endpointName dependency is a config logic error the - // framework can't see, so it's the sole check here. + // pagination needs an endpointName the framework's resource validation + // can't see, so check it here. for (const [alias, idx] of Object.entries(this.config.indexes ?? {})) { if (idx.pagination && !idx.endpointName) { throw new Error( @@ -70,10 +69,8 @@ export class AiSearchPlugin extends Plugin { } } - // Development convenience: fill in `columns` for any index that omits them - // by reading the index's source table. In production there's no discovery; - // an index with no columns can never query (the VS API requires `columns`), - // so fail fast at boot instead of 500ing every request. + // Dev fills in missing `columns` from the source table; prod can't query + // without them (VS requires `columns`), so fail fast at boot. if (process.env.NODE_ENV === "development") { await this._autoDiscoverColumns(); } else { @@ -88,16 +85,10 @@ export class AiSearchPlugin extends Plugin { } /** - * For each configured index missing `columns`, discover the returnable - * columns from its Delta-Sync source table and store them back on the config. - * Best-effort: any failure (no auth, index not ready, non-Delta-Sync index) - * is logged and skipped rather than thrown. Emits one warning banner listing - * what was auto-filled, since these must be set explicitly before production. - * - * Uses all source-table columns (minus embedding vectors). Indexes with a - * partial `columns_to_sync` may include columns the index can't return; since - * this is a dev-only convenience (prod requires explicit `columns`), the - * discovered list is a starting point to trim, not an authoritative set. + * For each configured index missing `columns`, fill them from its Delta-Sync + * source table (all source columns minus embedding vectors). Best-effort: + * failures are logged and skipped, never thrown. A partial `columns_to_sync` + * isn't honored, so the discovered list is a starting point to trim. */ private async _autoDiscoverColumns(): Promise { const discovered: Record = {}; @@ -150,10 +141,7 @@ export class AiSearchPlugin extends Plugin { "Set `columns` explicitly in the plugin config before deploying.", ); - const maxLen = Math.max(...lines.map((l) => l.length)); - const border = "=".repeat(maxLen + 4); - const boxed = lines.map((l) => `| ${l.padEnd(maxLen)} |`); - return [border, ...boxed, border].join("\n"); + return formatWarningBanner(lines); } injectRoutes(router: IAppRouter) { @@ -162,14 +150,8 @@ export class AiSearchPlugin extends Plugin { method: "post", path: "/:alias/query", handler: async (req: express.Request, res: express.Response) => { - const indexConfig = this._resolveIndex(req.params.alias); - if (!indexConfig) { - res.status(404).json({ - error: `No index configured with alias "${req.params.alias}"`, - plugin: this.name, - }); - return; - } + const indexConfig = this._resolveOr404(req, res); + if (!indexConfig) return; const body: SearchRequest = req.body; if (!body.queryText && !body.queryVector) { @@ -180,11 +162,9 @@ export class AiSearchPlugin extends Plugin { return; } - // Configured `columns` are the projection allowlist over HTTP: drop - // any client-supplied `columns` so a caller can't read fields the app - // didn't opt to expose (routes run as the service principal by - // default). Programmatic `query()` callers are trusted and keep the - // override. + // Drop client-supplied `columns` so an HTTP caller can't widen the + // projection past what the app configured. (query() callers are + // trusted and keep the override.) const { columns: _clientColumns, ...safeBody } = body; const plugin = indexConfig.auth === "on-behalf-of-user" ? this.asUser(req) : this; @@ -192,9 +172,8 @@ export class AiSearchPlugin extends Plugin { safeBody.queryType ?? indexConfig.queryType ?? "hybrid"; try { - // Prepare inside execute so query preparation — notably a - // self-managed `embeddingFn` — runs in the same OBO context as the - // VS call, not as the service principal. + // Prepare inside execute so a self-managed embeddingFn runs in the + // same OBO context as the query, not as the service principal. const result = await plugin.execute(async (signal) => { const prepared = await this._prepareQuery(safeBody, indexConfig); return this.connector.query( @@ -204,13 +183,7 @@ export class AiSearchPlugin extends Plugin { ); }, querySettings); - if (!result.ok) { - res - .status(result.status) - .json({ error: result.message, plugin: this.name }); - return; - } - res.json(this._parseResponse(result.data, queryType)); + this._sendResult(res, result, queryType); } catch (error) { this._handleError(res, error, "Query failed"); } @@ -222,14 +195,8 @@ export class AiSearchPlugin extends Plugin { method: "post", path: "/:alias/next-page", handler: async (req: express.Request, res: express.Response) => { - const indexConfig = this._resolveIndex(req.params.alias); - if (!indexConfig) { - res.status(404).json({ - error: `No index configured with alias "${req.params.alias}"`, - plugin: this.name, - }); - return; - } + const indexConfig = this._resolveOr404(req, res); + if (!indexConfig) return; if (!indexConfig.pagination) { res.status(400).json({ @@ -277,13 +244,7 @@ export class AiSearchPlugin extends Plugin { querySettings, ); - if (!result.ok) { - res - .status(result.status) - .json({ error: result.message, plugin: this.name }); - return; - } - res.json(this._parseResponse(result.data, pageQueryType)); + this._sendResult(res, result, pageQueryType); } catch (error) { this._handleError(res, error, "Next-page query failed"); } @@ -295,17 +256,10 @@ export class AiSearchPlugin extends Plugin { method: "get", path: "/:alias/config", handler: async (req: express.Request, res: express.Response) => { - const { alias } = req.params; - const indexConfig = this._resolveIndex(alias); - if (!indexConfig) { - res.status(404).json({ - error: `No index configured with alias "${alias}"`, - plugin: this.name, - }); - return; - } + const indexConfig = this._resolveOr404(req, res); + if (!indexConfig) return; res.json({ - alias, + alias: req.params.alias, columns: indexConfig.columns, queryType: indexConfig.queryType ?? "hybrid", numResults: indexConfig.numResults ?? 20, @@ -317,10 +271,8 @@ export class AiSearchPlugin extends Plugin { } /** - * Configured index aliases + metadata, serialized to the browser at boot via - * `window.__appkit__` (read client-side with `usePluginClientConfig`). Lets the - * UI discover available indexes instead of hardcoding an alias. No secrets — - * only alias names and non-sensitive query metadata. + * Index aliases + non-sensitive query metadata, serialized to the client so + * the UI can discover available indexes instead of hardcoding an alias. */ clientConfig(): { indexes: IndexSummary[] } { const indexes = Object.entries(this.config.indexes ?? {}).map( @@ -337,9 +289,8 @@ export class AiSearchPlugin extends Plugin { * Programmatic query API — available as `appkit.aiSearch.query()`. * When called through `asUser(req)`, executes with the user's credentials. * - * @remarks `T` is an unchecked assertion on each result's `data`: rows are - * built from the index's returned columns and cast to `T` with no runtime - * validation, so a mismatched `T` won't be caught here. + * @remarks `T` types each result's `data` but is an unchecked cast — the row + * shape isn't validated at runtime. */ async query = Record>( alias: string, @@ -391,6 +342,37 @@ export class AiSearchPlugin extends Plugin { return { ...idx, indexName }; } + /** Resolve an index by route alias, or send a 404 and return null. */ + private _resolveOr404( + req: express.Request, + res: express.Response, + ): (IndexConfig & { indexName: string }) | null { + const indexConfig = this._resolveIndex(req.params.alias); + if (!indexConfig) { + res.status(404).json({ + error: `No index configured with alias "${req.params.alias}"`, + plugin: this.name, + }); + return null; + } + return indexConfig; + } + + /** Send an execution result as JSON, or its error status/message. */ + private _sendResult( + res: express.Response, + result: Awaited>>, + queryType: SearchQueryType, + ): void { + if (!result.ok) { + res + .status(result.status) + .json({ error: result.message, plugin: this.name }); + return; + } + res.json(this._parseResponse(result.data, queryType)); + } + private async _prepareQuery( request: SearchRequest, indexConfig: IndexConfig, @@ -447,14 +429,10 @@ export class AiSearchPlugin extends Plugin { private _parseResponse< T extends Record = Record, - >( - raw: VsRawResponse, - queryType: "ann" | "hybrid" | "full_text", - ): SearchResponse { + >(raw: VsRawResponse, queryType: SearchQueryType): SearchResponse { const columnNames = raw.manifest.columns.map((c) => c.name); const scoreIndex = columnNames.indexOf("score"); - // `data` is built dynamically, so T is the caller's unchecked assertion. const results: SearchResult[] = raw.result.data_array.map((row) => { const data: Record = {}; for (let i = 0; i < columnNames.length; i++) { diff --git a/packages/appkit/src/plugins/ai-search/types.ts b/packages/appkit/src/plugins/ai-search/types.ts index bc34b0b8f..93dba6063 100644 --- a/packages/appkit/src/plugins/ai-search/types.ts +++ b/packages/appkit/src/plugins/ai-search/types.ts @@ -1,5 +1,8 @@ import type { BasePluginConfig } from "shared"; +/** Vector Search query mode: semantic (`ann`), keyword+semantic (`hybrid`), or keyword-only (`full_text`). */ +export type SearchQueryType = "ann" | "hybrid" | "full_text"; + export interface IAiSearchConfig extends BasePluginConfig { timeout?: number; indexes?: Record; @@ -20,7 +23,7 @@ export interface IndexConfig { */ columns?: string[]; /** Default search mode */ - queryType?: "ann" | "hybrid" | "full_text"; + queryType?: SearchQueryType; /** Max results per query */ numResults?: number; /** Enable built-in reranker. Pass true to rerank all non-id columns, or an object for fine control. */ @@ -50,7 +53,7 @@ export interface RerankerConfig { /** Public summary of a configured index, exposed to the client via `clientConfig()`. */ export interface IndexSummary { alias: string; - queryType: "ann" | "hybrid" | "full_text"; + queryType: SearchQueryType; pagination: boolean; } @@ -64,7 +67,7 @@ export interface SearchRequest { queryVector?: number[]; columns?: string[]; numResults?: number; - queryType?: "ann" | "hybrid" | "full_text"; + queryType?: SearchQueryType; filters?: SearchFilters; reranker?: boolean; } @@ -75,7 +78,7 @@ export interface SearchResponse< results: SearchResult[]; totalCount: number; queryTimeMs: number; - queryType: "ann" | "hybrid" | "full_text"; + queryType: SearchQueryType; nextPageToken: string | null; } diff --git a/packages/appkit/src/registry/resource-registry.ts b/packages/appkit/src/registry/resource-registry.ts index 7b2025b21..489faa536 100644 --- a/packages/appkit/src/registry/resource-registry.ts +++ b/packages/appkit/src/registry/resource-registry.ts @@ -13,6 +13,7 @@ import type { BasePluginConfig, PluginConstructor, PluginData } from "shared"; import { ConfigurationError } from "../errors"; import { createLogger } from "../logging/logger"; +import { formatWarningBanner } from "../utils/banner"; import { getPluginManifest } from "./manifest-loader"; import type { ResourceEntry, @@ -477,11 +478,6 @@ export class ResourceRegistry { "Add these to your .env file or environment to suppress this warning.", ); - const maxLen = Math.max(...contentLines.map((l) => l.length)); - const border = "=".repeat(maxLen + 4); - - const boxed = contentLines.map((line) => `| ${line.padEnd(maxLen)} |`); - - return [border, ...boxed, border].join("\n"); + return formatWarningBanner(contentLines); } } diff --git a/packages/appkit/src/utils/banner.ts b/packages/appkit/src/utils/banner.ts new file mode 100644 index 000000000..251d2d865 --- /dev/null +++ b/packages/appkit/src/utils/banner.ts @@ -0,0 +1,11 @@ +/** + * Frames content lines in an ASCII box (`===` border, `| … |` sides) padded to + * the widest line. Used for prominent dev-mode warnings that must not be missed + * in a noisy console. + */ +export function formatWarningBanner(lines: string[]): string { + const maxLen = Math.max(...lines.map((l) => l.length)); + const border = "=".repeat(maxLen + 4); + const boxed = lines.map((line) => `| ${line.padEnd(maxLen)} |`); + return [border, ...boxed, border].join("\n"); +} diff --git a/packages/appkit/src/utils/index.ts b/packages/appkit/src/utils/index.ts index c0b1b55bd..d0ebedc44 100644 --- a/packages/appkit/src/utils/index.ts +++ b/packages/appkit/src/utils/index.ts @@ -1,3 +1,4 @@ +export * from "./banner"; export * from "./merge"; export * from "./path-exclusions"; export * from "./vite-config-merge"; diff --git a/tools/generate-plugin-doc-banners.ts b/tools/generate-plugin-doc-banners.ts index c614e249a..08b7c3155 100644 --- a/tools/generate-plugin-doc-banners.ts +++ b/tools/generate-plugin-doc-banners.ts @@ -16,6 +16,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { camelToKebab } from "../packages/shared/src/naming"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.join(__dirname, ".."); @@ -29,11 +30,6 @@ const DOCS_DIR = path.join(REPO_ROOT, "docs/docs/plugins"); */ const SCHEMA_NAME_PATTERN = /^[a-z][a-zA-Z0-9-]*$/; -/** camelCase manifest name -> kebab doc basename (e.g. aiSearch -> ai-search). */ -function camelToKebab(name: string): string { - return name.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); -} - /** * Checks whether a resolved file path is within a given directory boundary. */ diff --git a/tools/generate-plugin-entries.ts b/tools/generate-plugin-entries.ts index 504c492d0..78fafea01 100644 --- a/tools/generate-plugin-entries.ts +++ b/tools/generate-plugin-entries.ts @@ -14,6 +14,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { kebabToCamel } from "../packages/shared/src/naming"; import { formatWithBiome } from "./format-with-biome.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -56,16 +57,6 @@ const FOLDER_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; */ const JS_IDENTIFIER_PATTERN = /^[a-z][a-zA-Z0-9_]*$/; -/** - * Convert a kebab-case manifest name to its camelCase JS-identifier binding - * (e.g. `vector-search` -> `vectorSearch`). Mirrors `manifestNameToBinding` in - * the plugin `promote` command and the convention first-party plugin index - * files follow, so the emitted binding matches the plugin's actual export. - */ -function manifestNameToBinding(name: string): string { - return name.replace(/-+([a-z0-9])/g, (_, c: string) => c.toUpperCase()); -} - function validateSchemaName( value: string, kind: "manifest name" | "folder name", @@ -117,7 +108,7 @@ function readPluginInfos(): PluginInfo[] { // The schema permits kebab-case names, but the barrel binding must be a // valid JS identifier, so derive it via kebab->camelCase and assert. - const binding = manifestNameToBinding(manifest.name); + const binding = kebabToCamel(manifest.name); if (!JS_IDENTIFIER_PATTERN.test(binding)) { throw new Error( `Manifest name "${manifest.name}" in ${manifestPath} does not convert to a valid JavaScript identifier (got "${binding}"). The generator emits \`export { ${binding} } from "./";\`, which would be invalid TypeScript. Rename the plugin so its name is kebab-case or camelCase, or set \`hidden: true\` to exclude it from the auto-generated barrels.`, From 2dff1482d7e1c33f77e62507e5ede9717cd1fb9b Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 5 Aug 2026 15:54:57 +0200 Subject: [PATCH 25/27] docs(appkit): regenerate ai-search API docs for SearchQueryType alias Signed-off-by: MarioCadenas --- docs/docs/api/appkit/Interface.IndexConfig.md | 2 +- docs/docs/api/appkit/Interface.SearchRequest.md | 2 +- docs/docs/api/appkit/Interface.SearchResponse.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/docs/api/appkit/Interface.IndexConfig.md b/docs/docs/api/appkit/Interface.IndexConfig.md index c31bd5e42..95b6797cc 100644 --- a/docs/docs/api/appkit/Interface.IndexConfig.md +++ b/docs/docs/api/appkit/Interface.IndexConfig.md @@ -94,7 +94,7 @@ Enable cursor pagination ### queryType? ```ts -optional queryType: "ann" | "hybrid" | "full_text"; +optional queryType: SearchQueryType; ``` Default search mode diff --git a/docs/docs/api/appkit/Interface.SearchRequest.md b/docs/docs/api/appkit/Interface.SearchRequest.md index 3180f7d28..6479ab0da 100644 --- a/docs/docs/api/appkit/Interface.SearchRequest.md +++ b/docs/docs/api/appkit/Interface.SearchRequest.md @@ -37,7 +37,7 @@ optional queryText: string; ### queryType? ```ts -optional queryType: "ann" | "hybrid" | "full_text"; +optional queryType: SearchQueryType; ``` *** diff --git a/docs/docs/api/appkit/Interface.SearchResponse.md b/docs/docs/api/appkit/Interface.SearchResponse.md index 26020438f..40d8941f3 100644 --- a/docs/docs/api/appkit/Interface.SearchResponse.md +++ b/docs/docs/api/appkit/Interface.SearchResponse.md @@ -27,7 +27,7 @@ queryTimeMs: number; ### queryType ```ts -queryType: "ann" | "hybrid" | "full_text"; +queryType: SearchQueryType; ``` *** From f65c792b116843ae77a1fd423a31d918cf96e004 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 5 Aug 2026 16:20:19 +0200 Subject: [PATCH 26/27] refactor(shared): plugin name must be camelCase (forbid kebab) The manifest name doubles as the JS binding and the appkit accessor key, so a kebab name yields appkit["my-plugin"] rather than a clean accessor. The regex previously allowed both; tighten it to camelCase-only and hoist it to a single PLUGIN_NAME_PATTERN constant in shared/naming, consumed by the manifest schema, the plugin-create command, and the plugin-tree generators. Update test fixtures that used kebab plugin names (directory/file names stay kebab). Signed-off-by: MarioCadenas --- docs/static/schemas/plugin-manifest.schema.json | 2 +- docs/static/schemas/template-plugins.schema.json | 2 +- .../src/cli/commands/plugin/create/create.ts | 12 ++++++------ .../src/cli/commands/plugin/list/list.test.ts | 16 ++++++++-------- .../plugin/validate/validate-manifest.test.ts | 4 ++-- packages/shared/src/naming.ts | 8 ++++++++ packages/shared/src/schemas/manifest.ts | 5 +++-- tools/generate-plugin-doc-banners.ts | 16 ++++++---------- tools/generate-plugin-entries.ts | 16 +++++++++------- 9 files changed, 44 insertions(+), 37 deletions(-) diff --git a/docs/static/schemas/plugin-manifest.schema.json b/docs/static/schemas/plugin-manifest.schema.json index 280c4df4c..c964aafed 100644 --- a/docs/static/schemas/plugin-manifest.schema.json +++ b/docs/static/schemas/plugin-manifest.schema.json @@ -10,7 +10,7 @@ }, "name": { "type": "string", - "pattern": "^[a-z][a-zA-Z0-9-]*$", + "pattern": "^[a-z][a-zA-Z0-9]*$", "description": "Plugin identifier and JS binding. Must start with a lowercase letter; camelCase for multi-word names (e.g. aiSearch)." }, "displayName": { diff --git a/docs/static/schemas/template-plugins.schema.json b/docs/static/schemas/template-plugins.schema.json index 018f9ca3a..61ed50f88 100644 --- a/docs/static/schemas/template-plugins.schema.json +++ b/docs/static/schemas/template-plugins.schema.json @@ -23,7 +23,7 @@ "properties": { "name": { "type": "string", - "pattern": "^[a-z][a-zA-Z0-9-]*$", + "pattern": "^[a-z][a-zA-Z0-9]*$", "description": "Plugin identifier and JS binding. Must start with a lowercase letter; camelCase for multi-word names (e.g. aiSearch)." }, "displayName": { diff --git a/packages/shared/src/cli/commands/plugin/create/create.ts b/packages/shared/src/cli/commands/plugin/create/create.ts index c9641bf48..4779f59b7 100644 --- a/packages/shared/src/cli/commands/plugin/create/create.ts +++ b/packages/shared/src/cli/commands/plugin/create/create.ts @@ -13,6 +13,7 @@ import { text, } from "@clack/prompts"; import { Command, Option } from "commander"; +import { PLUGIN_NAME_PATTERN } from "../../../../naming"; import { promptOneResource } from "./prompt-resource"; import { DEFAULT_PERMISSION_BY_TYPE, @@ -25,7 +26,6 @@ import { import { resolveTargetDir, scaffoldPlugin } from "./scaffold"; import type { CreateAnswers, Placement, SelectedResource } from "./types"; -const NAME_PATTERN = /^[a-z][a-zA-Z0-9-]*$/; const DEFAULT_VERSION = "0.1.0"; const VALID_PLACEMENTS: Placement[] = ["in-repo", "isolated"]; const REQUIRED_FLAGS = ["placement", "path", "name", "description"] as const; @@ -194,9 +194,9 @@ function runNonInteractive(opts: CreateOptions): void { } const name = opts.name as string; - if (!NAME_PATTERN.test(name)) { + if (!PLUGIN_NAME_PATTERN.test(name)) { console.error( - "Error: --name must be lowercase, start with a letter, and use only letters, numbers, and hyphens.", + "Error: --name must start with a lowercase letter and be camelCase (letters and numbers only, e.g. aiSearch).", ); process.exit(1); } @@ -289,11 +289,11 @@ async function runInteractive(): Promise { const name = await text({ message: "Plugin name (id)", - placeholder: "my-plugin", + placeholder: "aiSearch", validate(value) { if (!value?.trim()) return "Name is required."; - if (!NAME_PATTERN.test(value as string)) { - return "Must be lowercase, start with a letter, and use only letters, numbers, and hyphens."; + if (!PLUGIN_NAME_PATTERN.test(value as string)) { + return "Must start with a lowercase letter and be camelCase (letters and numbers only, e.g. aiSearch)."; } return undefined; }, diff --git a/packages/shared/src/cli/commands/plugin/list/list.test.ts b/packages/shared/src/cli/commands/plugin/list/list.test.ts index e7fe8887f..1ce35aa92 100644 --- a/packages/shared/src/cli/commands/plugin/list/list.test.ts +++ b/packages/shared/src/cli/commands/plugin/list/list.test.ts @@ -46,7 +46,7 @@ const TEMPLATE_MANIFEST_JSON = { const PLUGIN_MANIFEST_JSON = { $schema: "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", - name: "my-feature", + name: "myFeature", displayName: "My Feature", description: "A test plugin", resources: { required: [], optional: [] }, @@ -144,7 +144,7 @@ describe("list", () => { plugins: { ...TEMPLATE_MANIFEST_JSON.plugins, beta: { - name: "beta-plugin", + name: "betaPlugin", displayName: "Beta Plugin", package: "@databricks/appkit", stability: "beta", @@ -156,7 +156,7 @@ describe("list", () => { fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); const rows = listFromManifestFile(manifestPath); - const betaRow = rows.find((r) => r.name === "beta-plugin"); + const betaRow = rows.find((r) => r.name === "betaPlugin"); const gaRow = rows.find((r) => r.name === "server"); expect(betaRow?.stability).toBe("beta"); @@ -178,7 +178,7 @@ describe("list", () => { const rows = await listFromDirectory(tmp, path.dirname(tmp)); expect(rows).toHaveLength(1); - expect(rows[0].name).toBe("my-feature"); + expect(rows[0].name).toBe("myFeature"); expect(rows[0].displayName).toBe("My Feature"); expect(rows[0].package).toContain("my-feature"); expect(rows[0].required).toBe(0); @@ -212,7 +212,7 @@ describe("list", () => { const rows = await listFromDirectory(tmp, path.dirname(tmp)); expect(rows).toHaveLength(1); - expect(rows[0].name).toBe("my-feature"); + expect(rows[0].name).toBe("myFeature"); }); it("reads stability from manifest in directory scan", async () => { @@ -224,7 +224,7 @@ describe("list", () => { path.join(pluginDir, "manifest.json"), JSON.stringify({ ...PLUGIN_MANIFEST_JSON, - name: "beta-feature", + name: "betaFeature", stability: "beta", }), ); @@ -277,7 +277,7 @@ describe("list", () => { const rows = await listFromDirectory(tmp, path.dirname(tmp), true); expect(rows).toHaveLength(1); - expect(rows[0].name).toBe("my-feature"); + expect(rows[0].name).toBe("myFeature"); }); it("loads JS manifests from trusted node_modules packages by default", async () => { @@ -298,7 +298,7 @@ describe("list", () => { const rows = await listFromDirectory(tmp, tmp); expect(rows).toHaveLength(1); - expect(rows[0].name).toBe("my-feature"); + expect(rows[0].name).toBe("myFeature"); }); it("does not load JS manifests from untrusted node_modules packages by default", async () => { diff --git a/packages/shared/src/cli/commands/plugin/validate/validate-manifest.test.ts b/packages/shared/src/cli/commands/plugin/validate/validate-manifest.test.ts index 70490747e..ee448beea 100644 --- a/packages/shared/src/cli/commands/plugin/validate/validate-manifest.test.ts +++ b/packages/shared/src/cli/commands/plugin/validate/validate-manifest.test.ts @@ -15,7 +15,7 @@ import { const VALID_MANIFEST = { $schema: "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", - name: "test-plugin", + name: "testPlugin", displayName: "Test Plugin", description: "A test plugin", resources: { @@ -90,7 +90,7 @@ describe("validate-manifest", () => { const result = validateManifest(VALID_MANIFEST); expect(result.valid).toBe(true); expect(result.manifest).toBeDefined(); - expect(result.manifest?.name).toBe("test-plugin"); + expect(result.manifest?.name).toBe("testPlugin"); }); it("validates a manifest with resources", () => { diff --git a/packages/shared/src/naming.ts b/packages/shared/src/naming.ts index d3f6e9830..9f0f037b2 100644 --- a/packages/shared/src/naming.ts +++ b/packages/shared/src/naming.ts @@ -4,6 +4,14 @@ * heavier `plugin.ts` graph. */ +/** + * Canonical plugin-name charset: a lowercase-initial camelCase JS identifier + * (e.g. `aiSearch`). The name doubles as the JS binding and the accessor key, + * so kebab is not allowed. Single source of truth for the manifest schema and + * the plugin-tree generators. + */ +export const PLUGIN_NAME_PATTERN = /^[a-z][a-zA-Z0-9]*$/; + /** kebab-case to camelCase (e.g. `"ai-search"` -> `"aiSearch"`). */ export function kebabToCamel(name: string): string { return name.replace(/-+([a-z0-9])/g, (_, c: string) => c.toUpperCase()); diff --git a/packages/shared/src/schemas/manifest.ts b/packages/shared/src/schemas/manifest.ts index 661c043e1..bf41293f7 100644 --- a/packages/shared/src/schemas/manifest.ts +++ b/packages/shared/src/schemas/manifest.ts @@ -29,6 +29,7 @@ */ import { z } from "zod"; +import { PLUGIN_NAME_PATTERN } from "../naming"; // ── Resource type + per-type permission enums ──────────────────────────── @@ -672,7 +673,7 @@ export const pluginManifestSchema = z .describe("Reference to the JSON Schema for validation"), name: z .string() - .regex(/^[a-z][a-zA-Z0-9-]*$/) + .regex(PLUGIN_NAME_PATTERN) .describe( "Plugin identifier and JS binding. Must start with a lowercase letter; camelCase for multi-word names (e.g. aiSearch).", ), @@ -908,7 +909,7 @@ export const templatePluginSchema = z .object({ name: z .string() - .regex(/^[a-z][a-zA-Z0-9-]*$/) + .regex(PLUGIN_NAME_PATTERN) .describe( "Plugin identifier and JS binding. Must start with a lowercase letter; camelCase for multi-word names (e.g. aiSearch).", ), diff --git a/tools/generate-plugin-doc-banners.ts b/tools/generate-plugin-doc-banners.ts index 08b7c3155..59812a0bd 100644 --- a/tools/generate-plugin-doc-banners.ts +++ b/tools/generate-plugin-doc-banners.ts @@ -16,20 +16,16 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { camelToKebab } from "../packages/shared/src/naming"; +import { + camelToKebab, + PLUGIN_NAME_PATTERN, +} from "../packages/shared/src/naming"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.join(__dirname, ".."); const PLUGINS_DIR = path.join(REPO_ROOT, "packages/appkit/src/plugins"); const DOCS_DIR = path.join(REPO_ROOT, "docs/docs/plugins"); -/** - * Same as `plugin-manifest.schema.json` `name` pattern (camelCase); keeps - * `path.join` targets under `docs/docs/plugins` (defense in depth vs path - * traversal in `name`). - */ -const SCHEMA_NAME_PATTERN = /^[a-z][a-zA-Z0-9-]*$/; - /** * Checks whether a resolved file path is within a given directory boundary. */ @@ -116,9 +112,9 @@ function readPluginInfos(): PluginInfo[] { continue; // not a valid plugin manifest, skip silently } - if (!SCHEMA_NAME_PATTERN.test(manifest.name)) { + if (!PLUGIN_NAME_PATTERN.test(manifest.name)) { throw new Error( - `Manifest name "${manifest.name}" in ${manifestPath} doesn't match the plugin manifest schema pattern ^[a-z][a-z0-9-]*$.`, + `Manifest name "${manifest.name}" in ${manifestPath} doesn't match the plugin manifest schema pattern ${PLUGIN_NAME_PATTERN.source}.`, ); } diff --git a/tools/generate-plugin-entries.ts b/tools/generate-plugin-entries.ts index 78fafea01..f0ce6980e 100644 --- a/tools/generate-plugin-entries.ts +++ b/tools/generate-plugin-entries.ts @@ -14,7 +14,10 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { kebabToCamel } from "../packages/shared/src/naming"; +import { + kebabToCamel, + PLUGIN_NAME_PATTERN, +} from "../packages/shared/src/naming"; import { formatWithBiome } from "./format-with-biome.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -45,15 +48,14 @@ interface PluginInfo { * quotes, semicolons, braces, backslashes, and newlines, so neither can break * out of the string/identifier context it lands in. */ -const SCHEMA_NAME_PATTERN = /^[a-z][a-zA-Z0-9-]*$/; const FOLDER_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; /** * The barrel exports each plugin under a JS-identifier binding - * (`export { } from "./";`). A manifest `name` may be - * kebab-case per the schema, but the binding must be a valid identifier, so it - * is derived via kebab->camelCase. This pattern is the final assertion that the - * derived binding is safe to interpolate unescaped. + * (`export { } from "./";`). The camelCase `name` is the + * binding; `kebabToCamel` is a no-op for it but normalizes the kebab folder + * name. This pattern is the final assertion that the derived binding is safe + * to interpolate unescaped. */ const JS_IDENTIFIER_PATTERN = /^[a-z][a-zA-Z0-9_]*$/; @@ -63,7 +65,7 @@ function validateSchemaName( manifestPath: string, ): void { const pattern = - kind === "manifest name" ? SCHEMA_NAME_PATTERN : FOLDER_NAME_PATTERN; + kind === "manifest name" ? PLUGIN_NAME_PATTERN : FOLDER_NAME_PATTERN; if (!pattern.test(value)) { throw new Error( `${kind} "${value}" in ${manifestPath} doesn't match ${pattern.source}. Run \`appkit plugin validate\` to catch this earlier.`, From 7e180b4af44f62fe7f524f6bee02d60fc3b51e9e Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 5 Aug 2026 16:27:59 +0200 Subject: [PATCH 27/27] fix(shared): plugin create derives names from camelCase, not kebab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deriveDisplayName split on "-", which no longer appears in a camelCase plugin name — it now splits on camelCase boundaries (myPlugin -> "My Plugin"). The name is already the JS binding, so deriveExportName is dropped. --name examples and validation hints use a camelCase placeholder (paths stay kebab). Signed-off-by: MarioCadenas --- .../src/cli/commands/plugin/create/create.ts | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/packages/shared/src/cli/commands/plugin/create/create.ts b/packages/shared/src/cli/commands/plugin/create/create.ts index 4779f59b7..db9e659b4 100644 --- a/packages/shared/src/cli/commands/plugin/create/create.ts +++ b/packages/shared/src/cli/commands/plugin/create/create.ts @@ -42,17 +42,9 @@ interface CreateOptions { } function deriveDisplayName(name: string): string { - return name - .split("-") - .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) - .join(" "); -} - -function deriveExportName(name: string): string { - return name - .split("-") - .map((s, i) => (i === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1))) - .join(""); + // camelCase -> Title Case words (e.g. "myPlugin" -> "My Plugin"). + const spaced = name.replace(/([a-z0-9])([A-Z])/g, "$1 $2"); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); } function buildResourceFromType(type: string): SelectedResource { @@ -138,7 +130,8 @@ function printNextSteps(answers: CreateAnswers, targetDir: string): void { const importPath = relativePath.startsWith(".") ? relativePath : `./${relativePath}`; - const exportName = deriveExportName(answers.name); + // The camelCase plugin name is already the JS binding. + const exportName = answers.name; console.log("\nNext steps:\n"); if (answers.placement === "in-repo") { @@ -169,7 +162,7 @@ function runNonInteractive(opts: CreateOptions): void { ); console.error(`Missing: ${missing.map((f) => `--${f}`).join(", ")}`); console.error( - ' appkit plugin create --placement in-repo --path plugins/my-plugin --name my-plugin --description "Does X"', + ' appkit plugin create --placement in-repo --path plugins/my-plugin --name myPlugin --description "Does X"', ); process.exit(1); } @@ -196,7 +189,7 @@ function runNonInteractive(opts: CreateOptions): void { const name = opts.name as string; if (!PLUGIN_NAME_PATTERN.test(name)) { console.error( - "Error: --name must start with a lowercase letter and be camelCase (letters and numbers only, e.g. aiSearch).", + "Error: --name must start with a lowercase letter and be camelCase (letters and numbers only, e.g. myPlugin).", ); process.exit(1); } @@ -289,11 +282,11 @@ async function runInteractive(): Promise { const name = await text({ message: "Plugin name (id)", - placeholder: "aiSearch", + placeholder: "myPlugin", validate(value) { if (!value?.trim()) return "Name is required."; if (!PLUGIN_NAME_PATTERN.test(value as string)) { - return "Must start with a lowercase letter and be camelCase (letters and numbers only, e.g. aiSearch)."; + return "Must start with a lowercase letter and be camelCase (letters and numbers only, e.g. myPlugin)."; } return undefined; }, @@ -445,7 +438,7 @@ async function runPluginCreate(opts: CreateOptions): Promise { `Error: Non-interactive mode requires: ${REQUIRED_FLAGS.map((f) => `--${f}`).join(", ")}`, ); console.error( - ' appkit plugin create --placement in-repo --path plugins/my-plugin --name my-plugin --description "Does X"', + ' appkit plugin create --placement in-repo --path plugins/my-plugin --name myPlugin --description "Does X"', ); process.exit(1); } @@ -478,8 +471,8 @@ export const pluginCreateCommand = new Command("create") ` Examples: $ appkit plugin create - $ appkit plugin create --placement in-repo --path plugins/my-plugin --name my-plugin --description "Does X" - $ appkit plugin create --placement in-repo --path plugins/my-plugin --name my-plugin --description "Does X" --resources sql_warehouse,volume --force + $ appkit plugin create --placement in-repo --path plugins/my-plugin --name myPlugin --description "Does X" + $ appkit plugin create --placement in-repo --path plugins/my-plugin --name myPlugin --description "Does X" --resources sql_warehouse,volume --force $ appkit plugin create --placement isolated --path appkit-plugin-ml --name ml --description "ML" --resources-json '[{"type":"serving_endpoint"}]'`, ) .action((opts) =>