@dbx-tools/search 0.6.107 → 0.6.111

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plugin.ts CHANGED
@@ -1,37 +1,29 @@
1
1
  /**
2
- * AppKit plugin (registered name: `search`) that turns a Databricks AI
3
- * Search (Vector Search) index into a batteries-included search surface. It is
4
- * the "shortcut" half of this package: register it with nothing but an index
5
- * name and you get, all at once -
2
+ * AppKit extension plugin (registered name: `search`) around a sibling
3
+ * `aiSearch` provider. Native AppKit owns Vector Search query execution; this
4
+ * plugin adds the surfaces that remain useful across native and Lakebase
5
+ * providers:
6
6
  *
7
7
  * - a `search` / `universal_search` / (opt-in) `add_documents` tool set for
8
8
  * both Mastra and AppKit agents (autocomplete is a small-`limit` search);
9
- * - HTTP routes under `/api/search` a browser search box calls directly
9
+ * - compatibility and extension routes under `/api/search`
10
10
  * (`POST /` search, `POST /universal` federated, `GET /indexes` catalogue,
11
11
  * and `POST /documents` when writes are enabled);
12
12
  * - a `clientConfig()` payload so a UI knows the indexes, default, and page
13
13
  * size at boot with no round-trip;
14
14
  * - `exports()` so app code can `appkit.search.search(...)` directly.
15
15
  *
16
- * Everything runs under the caller's OBO identity (routes wrap in `asUser`),
17
- * so Unity Catalog ACLs on the index apply. Registering the plugin resolves
18
- * and logs the effective config (default index, known indexes, page size,
19
- * mode) so a misconfiguration shows up in the boot log rather than on the
20
- * first search.
16
+ * Vector Search reads delegate to the registered native AppKit `aiSearch`
17
+ * plugin, preserving its OBO, caching, reranking, and response handling. The
18
+ * alternative `lakebaseAiSearch` provider supplies the same query contract
19
+ * over PostgreSQL full-text search.
21
20
  *
22
21
  * @module
23
22
  */
24
23
 
24
+ import { Plugin, toPlugin, type IAppRouter, type PluginManifest } from "@databricks/appkit";
25
25
  import {
26
- lakebase,
27
- Plugin,
28
- ResourceType,
29
- toPlugin,
30
- type IAppRouter,
31
- type PluginManifest,
32
- type ResourceRequirement,
33
- } from "@databricks/appkit";
34
- import {
26
+ aiSearch,
35
27
  defineTool,
36
28
  executeFromRegistry,
37
29
  toolsFromRegistry,
@@ -42,16 +34,9 @@ import {
42
34
  import { plugin as appkitPlugin } from "@dbx-tools/appkit";
43
35
  import { error as sharedError, log, string } from "@dbx-tools/shared-core";
44
36
  import { search as sharedSearch, type SearchClientConfig } from "@dbx-tools/shared-search";
45
- import {
46
- SEARCH_CONFIG_SCHEMA,
47
- DATABRICKS_INDEX_ENV,
48
- ENDPOINT_ENV,
49
- INDEX_ENV,
50
- resolveSearchConfig,
51
- type SearchPluginConfig,
52
- } from "./config.ts";
37
+ import { SEARCH_CONFIG_SCHEMA, resolveSearchConfig, type SearchPluginConfig } from "./config.ts";
53
38
  import { toCreateIndexOptions } from "./index-tools.ts";
54
- import { LakebaseSearchBackend } from "./lakebase.ts";
39
+ import { nativeAiSearchBackend } from "./native.ts";
55
40
  import { toDocumentArray } from "./query.ts";
56
41
  import { getSearchRuntime, resetSearchRuntime } from "./runtime.ts";
57
42
  import {
@@ -64,9 +49,6 @@ import {
64
49
 
65
50
  const logger = log.logger("search");
66
51
 
67
- /** Mount-relative route (under `/api/search`) for a single-index search. */
68
- const SEARCH_ROUTE = "/";
69
-
70
52
  /** Mount-relative route for a universal (federated) search across indexes. */
71
53
  const UNIVERSAL_ROUTE = "/universal";
72
54
 
@@ -83,53 +65,31 @@ const INDEX_ROUTE = "/index";
83
65
  const INDEX_SYNC_ROUTE = "/index/sync";
84
66
 
85
67
  /**
86
- * The AI Search index resource. Declared optional because the plugin is happy
87
- * with no default index (a caller can name one per request); it is promoted to
88
- * required once a deployment pins one. `SELECT` is the permission a query needs.
89
- */
90
- const INDEX_RESOURCE = {
91
- type: ResourceType.VECTOR_SEARCH_INDEX,
92
- alias: "AI Search Index",
93
- resourceKey: "search-index",
94
- description:
95
- "Databricks AI Search (Vector Search) index the app searches by default " +
96
- "(catalog.schema.index). Optional: a request may name any index the caller can read.",
97
- permission: "SELECT",
98
- fields: {
99
- name: {
100
- env: INDEX_ENV,
101
- description: `Default AI Search index. ${DATABRICKS_INDEX_ENV} is also honored.`,
102
- discovery: {
103
- type: "cli",
104
- cliCommand:
105
- "databricks vector-search-indexes list-indexes --endpoint-name <ENDPOINT> --output json",
106
- selectField: ".name",
107
- },
108
- },
109
- },
110
- } satisfies Omit<ResourceRequirement, "required">;
111
-
112
- /**
113
- * AppKit plugin exposing AI Search as a search box, an agent tool set, and a
114
- * direct API.
68
+ * AppKit extension plugin for agent tools, federated search, and optional
69
+ * Vector Search lifecycle operations. Register `aiSearch()` or
70
+ * `lakebaseAiSearch()` before this plugin.
115
71
  *
116
72
  * @example
117
73
  * ```ts
118
74
  * import { createApp, server } from "@databricks/appkit";
75
+ * import { aiSearch } from "@databricks/appkit/beta";
119
76
  * import { plugin as searchPlugin } from "@dbx-tools/search";
120
77
  *
121
78
  * await createApp({
122
79
  * plugins: [
123
80
  * server(),
124
- * // zero-config: reads DATABRICKS_VECTOR_SEARCH_INDEX
125
- * searchPlugin.search(),
126
- * // or go deeper:
127
- * // searchPlugin.search({
128
- * // index: "main.support.docs",
129
- * // indexes: ["main.support.docs", "main.catalog.products"],
130
- * // columns: ["title", "url", "body"],
131
- * // mode: "hybrid",
132
- * // }),
81
+ * aiSearch({
82
+ * indexes: {
83
+ * docs: {
84
+ * indexName: "main.support.docs",
85
+ * columns: ["id", "title", "body"],
86
+ * },
87
+ * },
88
+ * }),
89
+ * searchPlugin.search({
90
+ * index: "main.support.docs",
91
+ * indexes: [{ name: "main.support.docs", alias: "docs" }],
92
+ * }),
133
93
  * ],
134
94
  * });
135
95
  * ```
@@ -144,7 +104,7 @@ export class SearchPlugin extends Plugin<SearchPluginConfig> implements ToolProv
144
104
  stability: "beta",
145
105
  resources: {
146
106
  required: [],
147
- optional: [INDEX_RESOURCE],
107
+ optional: [],
148
108
  },
149
109
  config: { schema: SEARCH_CONFIG_SCHEMA },
150
110
  } satisfies PluginManifest<"search">;
@@ -154,18 +114,6 @@ export class SearchPlugin extends Plugin<SearchPluginConfig> implements ToolProv
154
114
  return `/api/${SearchPlugin.manifest.name}`;
155
115
  }
156
116
 
157
- /**
158
- * Promote the index to a required resource once a deployment pins a default,
159
- * through plugin config or either environment name.
160
- */
161
- static getResourceRequirements(config: SearchPluginConfig): ResourceRequirement[] {
162
- const pinned =
163
- string.trimToNull(config.index) ??
164
- string.trimToNull(process.env[INDEX_ENV]) ??
165
- string.trimToNull(process.env[DATABRICKS_INDEX_ENV]);
166
- return pinned === null ? [] : [{ ...INDEX_RESOURCE, required: true }];
167
- }
168
-
169
117
  /**
170
118
  * The tools this plugin offers to an AppKit agent. `search` /
171
119
  * `universal_search` are reads; `add_documents` / `create_index` /
@@ -174,7 +122,7 @@ export class SearchPlugin extends Plugin<SearchPluginConfig> implements ToolProv
174
122
  * granted explicitly.
175
123
  */
176
124
  private get tools(): ToolRegistry {
177
- const { config } = getSearchRuntime({ config: this.config });
125
+ const { config, readBackend } = getSearchRuntime({ config: this.config });
178
126
  const registry: ToolRegistry = {
179
127
  search: defineTool({
180
128
  description: SEARCH_TOOL_DESCRIPTION,
@@ -201,40 +149,38 @@ export class SearchPlugin extends Plugin<SearchPluginConfig> implements ToolProv
201
149
  autoInheritable: false,
202
150
  execute: async (args, signal) => this.runAddDocuments(args, signal),
203
151
  });
204
- registry.create_index = defineTool({
205
- description: CREATE_INDEX_TOOL_DESCRIPTION,
206
- schema: sharedSearch.createIndexRequestSchema,
207
- annotations: { effect: "write", requiresUserContext: true },
208
- autoInheritable: false,
209
- execute: async (args, signal) => this.runCreateIndex(args, signal),
210
- });
211
- registry.sync_index = defineTool({
212
- description: SYNC_INDEX_TOOL_DESCRIPTION,
213
- schema: sharedSearch.syncIndexRequestSchema,
214
- annotations: { effect: "write", requiresUserContext: true },
215
- autoInheritable: false,
216
- execute: async (args, signal) => this.runSyncIndex(args, signal),
217
- });
152
+ if (readBackend?.supportsLifecycle) {
153
+ registry.create_index = defineTool({
154
+ description: CREATE_INDEX_TOOL_DESCRIPTION,
155
+ schema: sharedSearch.createIndexRequestSchema,
156
+ annotations: { effect: "write", requiresUserContext: true },
157
+ autoInheritable: false,
158
+ execute: async (args, signal) => this.runCreateIndex(args, signal),
159
+ });
160
+ registry.sync_index = defineTool({
161
+ description: SYNC_INDEX_TOOL_DESCRIPTION,
162
+ schema: sharedSearch.syncIndexRequestSchema,
163
+ annotations: { effect: "write", requiresUserContext: true },
164
+ autoInheritable: false,
165
+ execute: async (args, signal) => this.runSyncIndex(args, signal),
166
+ });
167
+ }
218
168
  }
219
169
  return registry;
220
170
  }
221
171
 
222
172
  /** Prime the shared runtime from config and log the effective policy at boot. */
223
173
  override async setup(): Promise<void> {
224
- // Choose a backend. Vector Search is primary; when NO Vector Search
225
- // endpoint is configured but the AppKit `lakebase` plugin is registered,
226
- // fall back to a Postgres full-text index. Both answer with the identical
227
- // shape, so this choice is invisible to tools, routes, and the UI.
228
- const lakebaseBackend = this.resolveLakebaseBackend();
174
+ const readBackend = this.resolveProviderBackend();
229
175
  // The runtime may already have been built (config-only) when `tools()` ran
230
176
  // during registration; rebuild it so it carries the chosen backend.
231
177
  resetSearchRuntime();
232
178
  const { config } = getSearchRuntime({
233
179
  config: this.config,
234
- ...(lakebaseBackend ? { lakebase: lakebaseBackend } : {}),
180
+ readBackend,
235
181
  });
236
182
  logger.info("ready", {
237
- backend: lakebaseBackend ? "lakebase" : "vector-search",
183
+ backend: readBackend.supportsLifecycle ? "appkit-ai-search" : "lakebase-ai-search",
238
184
  defaultIndex: config.defaultIndex ?? "(none - pass per request)",
239
185
  indexes: config.indexes.map((i) => i.alias),
240
186
  pageSize: config.pageSize,
@@ -247,33 +193,15 @@ export class SearchPlugin extends Plugin<SearchPluginConfig> implements ToolProv
247
193
  });
248
194
  // Provision a real index in the BACKGROUND so a slow first-time endpoint or
249
195
  // index build never blocks the server from coming up.
250
- if (config.ensureOnSetup) void this.runEnsureOnSetup(config);
196
+ if (config.ensureOnSetup && readBackend.supportsLifecycle) {
197
+ void this.runEnsureOnSetup(config);
198
+ }
251
199
  }
252
200
 
253
- /**
254
- * Build the Lakebase FALLBACK backend, or return `undefined` when Vector
255
- * Search should be used. Falls back only when there is NO Vector Search
256
- * endpoint configured (plugin config or `SEARCH_ENDPOINT` /
257
- * `DATABRICKS_VECTOR_SEARCH_ENDPOINT`) AND the sibling AppKit `lakebase`
258
- * plugin is registered. The pg pool is built from that plugin's
259
- * service-principal config exactly like `@dbx-tools/appkit-mastra`'s memory
260
- * pool - no auth is re-implemented here.
261
- */
262
- private resolveLakebaseBackend(): LakebaseSearchBackend | undefined {
263
- const config = resolveSearchConfig(this.config);
264
- const hasEndpoint =
265
- config.endpoint !== undefined ||
266
- string.trimToNull(process.env[ENDPOINT_ENV]) !== null ||
267
- string.trimToNull(process.env.DATABRICKS_VECTOR_SEARCH_ENDPOINT) !== null;
268
- if (hasEndpoint) return undefined;
269
- const lake = appkitPlugin.instance(this.context, lakebase);
270
- if (!lake) return undefined;
271
- logger.info("backend-lakebase", {
272
- reason: "no Vector Search endpoint configured; using the Lakebase full-text fallback",
273
- });
274
- // `getPgConfig()` must be read OUTSIDE any asUser scope (as it is here at
275
- // setup) so it carries the SP connection target + token-refresh callback.
276
- return new LakebaseSearchBackend(() => lake.exports().getPgConfig());
201
+ /** Resolve the registered AppKit-compatible AI Search provider. */
202
+ private resolveProviderBackend(): ReturnType<typeof nativeAiSearchBackend> {
203
+ const provider = appkitPlugin.require(this.context, aiSearch, this).exports();
204
+ return nativeAiSearchBackend(provider, resolveSearchConfig(this.config));
277
205
  }
278
206
 
279
207
  /**
@@ -356,23 +284,10 @@ export class SearchPlugin extends Plugin<SearchPluginConfig> implements ToolProv
356
284
  }
357
285
 
358
286
  /**
359
- * Mount the search routes under `/api/search`. Each is wrapped in
360
- * `asUser(req)` so the query runs as the requesting user and the index's
361
- * Unity Catalog ACLs apply. `GET /indexes` needs no user scope - it just
362
- * echoes the configured catalogue.
287
+ * Mount extension routes under `/api/search`. Single-index queries stay on
288
+ * the provider's native `/api/ai-search/:alias/query` surface.
363
289
  */
364
290
  override injectRoutes(router: IAppRouter): void {
365
- this.route(router, {
366
- name: "search",
367
- method: "post",
368
- path: SEARCH_ROUTE,
369
- handler: async (req, res) => {
370
- await this.respond(res, "search", () => {
371
- const request = sharedSearch.searchRequestSchema.parse(req.body ?? {});
372
- return this.asUser(req).runSearch(request);
373
- });
374
- },
375
- });
376
291
  this.route(router, {
377
292
  name: "universalSearch",
378
293
  method: "post",
@@ -412,8 +327,8 @@ export class SearchPlugin extends Plugin<SearchPluginConfig> implements ToolProv
412
327
  method: "post",
413
328
  path: INDEX_ROUTE,
414
329
  handler: async (req, res) => {
415
- const { config } = getSearchRuntime();
416
- if (!config.allowWrite) {
330
+ const { config, readBackend } = getSearchRuntime();
331
+ if (!config.allowWrite || !readBackend?.supportsLifecycle) {
417
332
  res.status(403).json({ error: "the index write surface is disabled" });
418
333
  return;
419
334
  }
@@ -427,8 +342,8 @@ export class SearchPlugin extends Plugin<SearchPluginConfig> implements ToolProv
427
342
  method: "post",
428
343
  path: INDEX_SYNC_ROUTE,
429
344
  handler: async (req, res) => {
430
- const { config } = getSearchRuntime();
431
- if (!config.allowWrite) {
345
+ const { config, readBackend } = getSearchRuntime();
346
+ if (!config.allowWrite || !readBackend?.supportsLifecycle) {
432
347
  res.status(403).json({ error: "the index write surface is disabled" });
433
348
  return;
434
349
  }
package/src/query.ts CHANGED
@@ -1,115 +1,7 @@
1
- /**
2
- * Translation between the browser-safe search contract and the Databricks
3
- * Vector Search query API, kept in one place so the client, the tools, and the
4
- * routes never hand-roll a `filters_json` string or unpack a `data_array` by
5
- * hand.
6
- *
7
- * - {@link toQueryType} maps the friendly {@link SearchMode} onto the API's
8
- * `query_type` (`HYBRID` / `ANN`). Keyword-only search rides on `ANN` with
9
- * text but no vector, which Databricks answers with its BM25 path.
10
- * - {@link compileFilter} turns the `{ column: value }` filter object (with
11
- * optional operator maps like `{ ">=": 10 }`) into the `filters_json`
12
- * string the API expects, so a caller never learns Databricks' filter
13
- * spelling.
14
- * - {@link toHits} unpacks the columnar `{ manifest, result }` response into
15
- * `{ id, score, fields }` hits, pulling the score out of the reserved
16
- * `__db_score` / `score` column and the id out of the primary-key column.
17
- *
18
- * @module
19
- */
1
+ /** Request-shape helpers for search extension write routes and tools. */
20
2
 
21
3
  import { ValidationError } from "@databricks/appkit";
22
4
  import { json, object } from "@dbx-tools/shared-core";
23
- import type { SearchHit, SearchMode } from "@dbx-tools/shared-search";
24
-
25
- /** The column name Databricks Vector Search returns the relevance score under. */
26
- const SCORE_COLUMN = "__db_score";
27
-
28
- /** Map a {@link SearchMode} onto the serving API `query_type`. */
29
- export function toQueryType(mode: SearchMode): string {
30
- return mode === "hybrid" ? "HYBRID" : "ANN";
31
- }
32
-
33
- /**
34
- * Compile a `{ column: value }` filter object into the `filters_json` string
35
- * the query API expects. A scalar becomes an equality; an array becomes an
36
- * IN-style match; an operator map (`{ ">=": 10, "<": 20 }`) expands to the
37
- * `column operator` keys Databricks uses. Returns `undefined` for an empty
38
- * filter so the field is omitted rather than sent as `{}`.
39
- */
40
- export function compileFilter(filter: Record<string, unknown> | undefined): string | undefined {
41
- if (!filter || Object.keys(filter).length === 0) return undefined;
42
- const compiled: Record<string, unknown> = {};
43
- for (const [column, value] of Object.entries(filter)) {
44
- if (object.isRecord(value)) {
45
- for (const [op, operand] of Object.entries(value)) {
46
- compiled[`${column} ${op}`] = operand;
47
- }
48
- } else {
49
- compiled[column] = value;
50
- }
51
- }
52
- return JSON.stringify(compiled);
53
- }
54
-
55
- /** A minimal structural view of the Vector Search query response. */
56
- export interface QueryResponseLike {
57
- manifest?: { columns?: Array<{ name?: string }> };
58
- result?: { data_array?: Array<Array<unknown>> };
59
- next_page_token?: string;
60
- }
61
-
62
- /**
63
- * Unpack a columnar query response into {@link SearchHit}s. The manifest names
64
- * the columns in order; each row is a positional array. The score comes from
65
- * the reserved score column and the id from `primaryKey` (falling back to the
66
- * first column when the key is unknown). The score column is stripped from
67
- * `fields` so a hit's fields are just the document.
68
- */
69
- export function toHits(
70
- response: QueryResponseLike,
71
- primaryKey: string | undefined,
72
- indexName?: string,
73
- ): SearchHit[] {
74
- const columns = (response.manifest?.columns ?? []).map((c) => c.name ?? "");
75
- const rows = response.result?.data_array ?? [];
76
- const scoreIdx = columns.indexOf(SCORE_COLUMN);
77
- const keyIdx = primaryKey ? columns.indexOf(primaryKey) : -1;
78
- return rows.map((row, rowIndex) => {
79
- const fields: Record<string, unknown> = {};
80
- columns.forEach((name, i) => {
81
- if (i === scoreIdx || !name) return;
82
- fields[name] = row[i];
83
- });
84
- const scoreRaw = scoreIdx >= 0 ? Number(row[scoreIdx]) : NaN;
85
- const idRaw =
86
- keyIdx >= 0 ? row[keyIdx] : primaryKey ? fields[primaryKey] : (row[0] ?? rowIndex);
87
- return {
88
- id: String(idRaw ?? rowIndex),
89
- score: Number.isFinite(scoreRaw) ? scoreRaw : 0,
90
- fields,
91
- ...(indexName ? { index: indexName } : {}),
92
- };
93
- });
94
- }
95
-
96
- /**
97
- * The columns to request from an index for a search. When neither the request
98
- * nor the index config names columns, the score column alone is requested and
99
- * the primary key is appended so a hit always has an id. Callers that want the
100
- * whole document should pass the index's own column list.
101
- */
102
- export function toRequestColumns(
103
- requested: readonly string[] | undefined,
104
- fallback: readonly string[] | undefined,
105
- primaryKey: string | undefined,
106
- ): string[] {
107
- const base = requested && requested.length > 0 ? requested : (fallback ?? []);
108
- const columns = new Set<string>(base);
109
- if (primaryKey) columns.add(primaryKey);
110
- if (columns.size === 0 && primaryKey) columns.add(primaryKey);
111
- return [...columns];
112
- }
113
5
 
114
6
  /**
115
7
  * Parse a JSON document payload the model / a route supplied for a write.
package/src/runtime.ts CHANGED
@@ -8,31 +8,24 @@
8
8
  * @module
9
9
  */
10
10
 
11
- import { createSearchClient, SearchClient } from "./client.ts";
11
+ import { createSearchClient, SearchClient, type SearchReadBackend } from "./client.ts";
12
12
  import {
13
13
  resolveSearchConfig,
14
14
  type SearchPluginConfig,
15
15
  type ResolvedSearchConfig,
16
16
  } from "./config.ts";
17
- import { LakebaseSearchBackend } from "./lakebase.ts";
18
17
 
19
- /**
20
- * How the runtime is built. `lakebase` (when present) is the Postgres full-text
21
- * FALLBACK backend the plugin wires up when no Vector Search endpoint is
22
- * configured but the AppKit `lakebase` plugin is registered. Every read/write
23
- * returns the same shape either way.
24
- */
18
+ /** Configuration and provider used to build the shared extension runtime. */
25
19
  export interface SearchRuntimeOptions {
26
20
  config?: SearchPluginConfig;
27
- lakebase?: LakebaseSearchBackend;
21
+ readBackend?: SearchReadBackend;
28
22
  }
29
23
 
30
24
  /** The shared resolved config plus the client reads run through. */
31
25
  export interface SearchRuntime {
32
26
  config: ResolvedSearchConfig;
33
27
  client: SearchClient;
34
- /** The Lakebase fallback backend, when the runtime is backed by Postgres. */
35
- lakebase?: LakebaseSearchBackend;
28
+ readBackend?: SearchReadBackend;
36
29
  }
37
30
 
38
31
  let runtime: SearchRuntime | undefined;
@@ -46,11 +39,10 @@ let runtime: SearchRuntime | undefined;
46
39
  export function getSearchRuntime(options?: SearchRuntimeOptions): SearchRuntime {
47
40
  if (!runtime) {
48
41
  const config = resolveSearchConfig(options?.config);
49
- const lakebase = options?.lakebase;
50
42
  runtime = {
51
43
  config,
52
- client: createSearchClient(config, undefined, lakebase),
53
- ...(lakebase ? { lakebase } : {}),
44
+ client: createSearchClient(config, undefined, options?.readBackend),
45
+ ...(options?.readBackend ? { readBackend: options.readBackend } : {}),
54
46
  };
55
47
  }
56
48
  return runtime;
@@ -58,7 +50,5 @@ export function getSearchRuntime(options?: SearchRuntimeOptions): SearchRuntime
58
50
 
59
51
  /** Drop the memoized runtime so the next {@link getSearchRuntime} rebuilds it. */
60
52
  export function resetSearchRuntime(): void {
61
- const backend = runtime?.lakebase;
62
53
  runtime = undefined;
63
- if (backend) void backend.close();
64
54
  }