@dbx-tools/search 0.6.9
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/README.md +297 -0
- package/index.ts +28 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.js +22 -0
- package/lib/src/client.d.ts +304 -0
- package/lib/src/client.js +598 -0
- package/lib/src/config.d.ts +154 -0
- package/lib/src/config.js +225 -0
- package/lib/src/index-tools.d.ts +19 -0
- package/lib/src/index-tools.js +34 -0
- package/lib/src/lakebase.d.ts +86 -0
- package/lib/src/lakebase.js +218 -0
- package/lib/src/plugin.d.ts +216 -0
- package/lib/src/plugin.js +506 -0
- package/lib/src/query.d.ts +64 -0
- package/lib/src/query.js +109 -0
- package/lib/src/runtime.d.ts +38 -0
- package/lib/src/runtime.js +38 -0
- package/lib/src/schema.d.ts +83 -0
- package/lib/src/schema.js +64 -0
- package/lib/src/tool.d.ts +116 -0
- package/lib/src/tool.js +145 -0
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/package.json +65 -0
- package/src/client.ts +848 -0
- package/src/config.ts +356 -0
- package/src/index-tools.ts +40 -0
- package/src/lakebase.ts +299 -0
- package/src/plugin.ts +582 -0
- package/src/query.ts +127 -0
- package/src/runtime.ts +64 -0
- package/src/schema.ts +75 -0
- package/src/tool.ts +169 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small, Meilisearch-shaped client over Databricks AI Search (Vector
|
|
3
|
+
* Search). The whole point of this module is ergonomics: the Databricks SDK's
|
|
4
|
+
* `vectorSearchIndexes.queryIndex({ index_name, columns, query_text,
|
|
5
|
+
* query_type, num_results, filters_json })` is powerful but verbose, and the
|
|
6
|
+
* response is columnar. This client hides all of that behind two objects:
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const client = createSearchClient();
|
|
10
|
+
* const index = client.index("main.support.docs");
|
|
11
|
+
* const { hits } = await index.search("reset my password", { limit: 5 });
|
|
12
|
+
* await index.addDocuments([{ id: "42", title: "Reset", body: "..." }]);
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* `client.search(query, opts)` searches the default index; `client.index(name)`
|
|
16
|
+
* returns a handle bound to one index; `client.universalSearch(query)` fans a
|
|
17
|
+
* query across several indexes and merges the results (Meilisearch's federated
|
|
18
|
+
* / multi-search, the "universal search" the caller asked for). Everything is
|
|
19
|
+
* async and cancellable, resolves the OBO workspace client from the active
|
|
20
|
+
* AppKit execution context (falling back to a service-principal client outside
|
|
21
|
+
* a request), and returns the browser-safe shapes from
|
|
22
|
+
* `@dbx-tools/shared-search`.
|
|
23
|
+
*
|
|
24
|
+
* Autocomplete is just a search with a small `limit` and the raw query text -
|
|
25
|
+
* hybrid mode already prefix-matches, so no separate endpoint is needed; the
|
|
26
|
+
* {@link SearchIndex.autocomplete} helper is a thin, self-documenting alias.
|
|
27
|
+
*
|
|
28
|
+
* @module
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { ExecutionError, getExecutionContext } from "@databricks/appkit";
|
|
32
|
+
import { Context } from "@databricks/sdk-experimental";
|
|
33
|
+
import { appkit, databricks } from "@dbx-tools/appkit";
|
|
34
|
+
import { invoke, resolve as modelResolve, serving } from "@dbx-tools/model";
|
|
35
|
+
import type {
|
|
36
|
+
SearchDocument,
|
|
37
|
+
SearchHit,
|
|
38
|
+
SearchMode,
|
|
39
|
+
SearchResult,
|
|
40
|
+
UpsertResult,
|
|
41
|
+
} from "@dbx-tools/shared-search";
|
|
42
|
+
import { async as asyncUtil, json, log, string } from "@dbx-tools/shared-core";
|
|
43
|
+
import { ModelClass } from "@dbx-tools/shared-model";
|
|
44
|
+
import {
|
|
45
|
+
DEFAULT_MODE,
|
|
46
|
+
DEFAULT_PAGE_SIZE,
|
|
47
|
+
DEFAULT_TIMEOUT_MS,
|
|
48
|
+
indexConfigFor,
|
|
49
|
+
resolveIndexName,
|
|
50
|
+
type ResolvedSearchConfig,
|
|
51
|
+
} from "./config.ts";
|
|
52
|
+
import {
|
|
53
|
+
compileFilter,
|
|
54
|
+
toHits,
|
|
55
|
+
toQueryType,
|
|
56
|
+
toRequestColumns,
|
|
57
|
+
type QueryResponseLike,
|
|
58
|
+
} from "./query.ts";
|
|
59
|
+
import type { LakebaseSearchBackend } from "./lakebase.ts";
|
|
60
|
+
|
|
61
|
+
type WorkspaceClientLike = appkit.WorkspaceClientLike;
|
|
62
|
+
const logger = log.logger("search/client");
|
|
63
|
+
|
|
64
|
+
/** Options accepted by a single-index search. */
|
|
65
|
+
export interface SearchOptions {
|
|
66
|
+
/** Maximum hits to return. Defaults to the configured page size. */
|
|
67
|
+
limit?: number;
|
|
68
|
+
/** Match mode. Defaults to the configured mode (hybrid). */
|
|
69
|
+
mode?: SearchMode;
|
|
70
|
+
/** Columns to return per hit. Defaults to the index's configured columns. */
|
|
71
|
+
columns?: readonly string[];
|
|
72
|
+
/** Attribute filters as `{ column: value }` or `{ column: { ">=": n } }`. */
|
|
73
|
+
filter?: Record<string, unknown>;
|
|
74
|
+
/** Drop hits below this score. */
|
|
75
|
+
scoreThreshold?: number;
|
|
76
|
+
/** External cancellation. */
|
|
77
|
+
signal?: AbortSignal;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Options accepted by a universal (federated) search. */
|
|
81
|
+
export interface UniversalSearchOptions {
|
|
82
|
+
/** Indexes to search. Defaults to every known index. */
|
|
83
|
+
indexes?: readonly string[];
|
|
84
|
+
/** Maximum hits per index before merging. */
|
|
85
|
+
limit?: number;
|
|
86
|
+
/** Match mode. */
|
|
87
|
+
mode?: SearchMode;
|
|
88
|
+
/** External cancellation. */
|
|
89
|
+
signal?: AbortSignal;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A handle bound to one index. Modeled on Meilisearch's `client.index(uid)`:
|
|
94
|
+
* `search` / `autocomplete` read, `addDocuments` / `deleteDocuments` write (to
|
|
95
|
+
* a direct-access index), and `info` fetches the live definition.
|
|
96
|
+
*/
|
|
97
|
+
export class SearchIndex {
|
|
98
|
+
constructor(
|
|
99
|
+
readonly name: string,
|
|
100
|
+
private readonly client: SearchClient,
|
|
101
|
+
) {}
|
|
102
|
+
|
|
103
|
+
/** Search this index. See {@link SearchClient.search}. */
|
|
104
|
+
search(query: string, options?: SearchOptions): Promise<SearchResult> {
|
|
105
|
+
return this.client.search(query, { ...options, index: this.name });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Autocomplete against this index: a search with a small default `limit` and
|
|
110
|
+
* the raw prefix as the query. Hybrid mode already handles prefixes, so this
|
|
111
|
+
* is a self-documenting alias rather than a separate code path.
|
|
112
|
+
*/
|
|
113
|
+
autocomplete(prefix: string, options?: SearchOptions): Promise<SearchResult> {
|
|
114
|
+
return this.search(prefix, { limit: 5, ...options });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Add or update documents in this (direct-access) index. */
|
|
118
|
+
addDocuments(documents: SearchDocument[], signal?: AbortSignal): Promise<UpsertResult> {
|
|
119
|
+
return this.client.addDocuments(this.name, documents, signal);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Delete documents from this (direct-access) index by primary key. */
|
|
123
|
+
deleteDocuments(ids: Array<string | number>, signal?: AbortSignal): Promise<UpsertResult> {
|
|
124
|
+
return this.client.deleteDocuments(this.name, ids, signal);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Fetch this index's live definition (primary key, columns, readiness). */
|
|
128
|
+
info(signal?: AbortSignal): Promise<IndexInfo> {
|
|
129
|
+
return this.client.getIndex(this.name, signal);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Trigger a sync of this (Delta Sync) index from its source table. */
|
|
133
|
+
sync(signal?: AbortSignal): Promise<void> {
|
|
134
|
+
return this.client.syncIndex(this.name, signal);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Delete this index. */
|
|
138
|
+
delete(signal?: AbortSignal): Promise<void> {
|
|
139
|
+
return this.client.deleteIndex(this.name, signal);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Create this index if it does not exist, otherwise return the existing one. */
|
|
143
|
+
ensure(options?: CreateIndexOptions): Promise<IndexInfo> {
|
|
144
|
+
return this.client.ensureIndex(this.name, options);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A resolved live index definition. */
|
|
149
|
+
export interface IndexInfo {
|
|
150
|
+
name: string;
|
|
151
|
+
endpoint?: string;
|
|
152
|
+
primaryKey?: string;
|
|
153
|
+
columns: string[];
|
|
154
|
+
ready: boolean;
|
|
155
|
+
rowCount?: number;
|
|
156
|
+
/** True for a DIRECT_ACCESS index (you supply vectors; queries embed client-side). */
|
|
157
|
+
directAccess?: boolean;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Options for {@link SearchClient.createIndex} / {@link SearchClient.ensureIndex}.
|
|
162
|
+
* The goal is the same as the rest of the client: name a source table and a
|
|
163
|
+
* text column, and everything else - the endpoint, the embedding model, the
|
|
164
|
+
* index type, the sync mode - has a sensible default that infers from the
|
|
165
|
+
* workspace, overridable when a deployment needs to go deeper.
|
|
166
|
+
*/
|
|
167
|
+
export interface CreateIndexOptions {
|
|
168
|
+
/**
|
|
169
|
+
* Source Delta table (catalog.schema.table) for a Delta Sync index. Provide
|
|
170
|
+
* this for the common case: Databricks computes and syncs embeddings from it.
|
|
171
|
+
* Omit it (and pass {@link embeddingDimension}) to create a direct-access
|
|
172
|
+
* index you write vectors to yourself.
|
|
173
|
+
*/
|
|
174
|
+
sourceTable?: string;
|
|
175
|
+
/** Primary-key column. Defaults to `id`. */
|
|
176
|
+
primaryKey?: string;
|
|
177
|
+
/**
|
|
178
|
+
* The text column embeddings are computed from (Delta Sync). Defaults to the
|
|
179
|
+
* first of `text` / `content` / `body` present, else the caller must set it.
|
|
180
|
+
*/
|
|
181
|
+
embeddingSourceColumn?: string;
|
|
182
|
+
/**
|
|
183
|
+
* Embedding model endpoint. A loose name is fuzzy-matched; when omitted the
|
|
184
|
+
* best embedding endpoint in the workspace is chosen ({@link resolveEmbeddingModel}).
|
|
185
|
+
*/
|
|
186
|
+
embeddingModel?: string;
|
|
187
|
+
/** Vector Search endpoint to host the index on. Defaults to the plugin's `endpoint`. */
|
|
188
|
+
endpoint?: string;
|
|
189
|
+
/** For a direct-access index: the embedding vector dimension (self-managed vectors, no source table). */
|
|
190
|
+
embeddingDimension?: number;
|
|
191
|
+
/** For a direct-access index: the column the vector is stored in. Defaults to `embedding`. */
|
|
192
|
+
embeddingVectorColumn?: string;
|
|
193
|
+
/**
|
|
194
|
+
* The column-name -> type map used to build a direct-access index's
|
|
195
|
+
* `schema_json` (types: `string`, `int`, `long`, `float`, `double`,
|
|
196
|
+
* `boolean`, `date`, `timestamp`). Must include the primary key and any
|
|
197
|
+
* columns you upsert. Defaults to `{ id: "string", text: "string" }`. The
|
|
198
|
+
* embedding vector column is added automatically.
|
|
199
|
+
*/
|
|
200
|
+
schema?: Record<string, string>;
|
|
201
|
+
/** Sync mode for a Delta Sync index. `TRIGGERED` (default) syncs on demand; `CONTINUOUS` keeps fresh. */
|
|
202
|
+
pipelineType?: "TRIGGERED" | "CONTINUOUS";
|
|
203
|
+
/** Extra columns to sync alongside the embedding source (Delta Sync). */
|
|
204
|
+
columnsToSync?: string[];
|
|
205
|
+
/** External cancellation. */
|
|
206
|
+
signal?: AbortSignal;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Options for {@link SearchClient.ensureEndpoint}. */
|
|
210
|
+
export interface EnsureEndpointOptions {
|
|
211
|
+
/** Wait for the endpoint to come online before returning. Defaults to false. */
|
|
212
|
+
wait?: boolean;
|
|
213
|
+
/** External cancellation. */
|
|
214
|
+
signal?: AbortSignal;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Options for {@link SearchClient.provision} - a one-call "make this index real
|
|
219
|
+
* and searchable" used at boot or in a seed script. It ensures the endpoint,
|
|
220
|
+
* ensures the index, and (optionally) seeds documents when the index is empty.
|
|
221
|
+
*/
|
|
222
|
+
export interface ProvisionOptions extends CreateIndexOptions {
|
|
223
|
+
/** Documents to seed when the index has no rows yet. Skipped if it already has data. */
|
|
224
|
+
seed?: SearchDocument[];
|
|
225
|
+
/**
|
|
226
|
+
* Wait for the endpoint AND index to come online before returning (needed
|
|
227
|
+
* before seeding). Defaults to true. Endpoint creation can take many minutes.
|
|
228
|
+
*/
|
|
229
|
+
wait?: boolean;
|
|
230
|
+
/** How long to wait for readiness before giving up. Defaults to 20 minutes. */
|
|
231
|
+
timeoutMs?: number;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The AI Search client. Construct it with {@link createSearchClient} (which
|
|
236
|
+
* reads a resolved config) or directly for one-off use. All reads resolve the
|
|
237
|
+
* OBO workspace client from the active execution context.
|
|
238
|
+
*/
|
|
239
|
+
export class SearchClient {
|
|
240
|
+
constructor(
|
|
241
|
+
private readonly config: ResolvedSearchConfig = {
|
|
242
|
+
indexes: [],
|
|
243
|
+
pageSize: DEFAULT_PAGE_SIZE,
|
|
244
|
+
mode: DEFAULT_MODE,
|
|
245
|
+
basePath: "/api/search",
|
|
246
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
247
|
+
allowWrite: false,
|
|
248
|
+
},
|
|
249
|
+
private readonly workspaceClientFactory: () => WorkspaceClientLike = defaultWorkspaceClient,
|
|
250
|
+
/**
|
|
251
|
+
* Optional Lakebase full-text FALLBACK backend. Present only when no Vector
|
|
252
|
+
* Search endpoint is configured but a Lakebase pool is available; when set,
|
|
253
|
+
* search / provision / write operations delegate to it and return the exact
|
|
254
|
+
* same shapes, so nothing downstream can tell which backend answered.
|
|
255
|
+
*/
|
|
256
|
+
private readonly lakebase?: LakebaseSearchBackend,
|
|
257
|
+
) {}
|
|
258
|
+
|
|
259
|
+
/** True when this client is answering out of the Lakebase fallback backend. */
|
|
260
|
+
get usesLakebase(): boolean {
|
|
261
|
+
return this.lakebase !== undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** A handle bound to one index (by full UC name or configured alias). */
|
|
265
|
+
index(reference: string): SearchIndex {
|
|
266
|
+
const name = resolveIndexName(this.config, reference) ?? reference;
|
|
267
|
+
return new SearchIndex(name, this);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Search one index. `index` may be a full UC name, a configured alias, or
|
|
272
|
+
* omitted to use the default index. Returns hits sorted most-relevant-first.
|
|
273
|
+
*/
|
|
274
|
+
async search(
|
|
275
|
+
query: string,
|
|
276
|
+
options: SearchOptions & { index?: string } = {},
|
|
277
|
+
): Promise<SearchResult> {
|
|
278
|
+
const text = string.trimToEmpty(query);
|
|
279
|
+
const name = resolveIndexName(this.config, options.index);
|
|
280
|
+
if (name === null) {
|
|
281
|
+
throw new ExecutionError("search: no index configured; set a default index or pass one", {
|
|
282
|
+
context: { operation: "search" },
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
if (this.lakebase) {
|
|
286
|
+
return this.lakebase.search(name, text, {
|
|
287
|
+
limit: options.limit ?? this.config.pageSize,
|
|
288
|
+
...(options.scoreThreshold !== undefined ? { scoreThreshold: options.scoreThreshold } : {}),
|
|
289
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
const known = indexConfigFor(this.config, name);
|
|
293
|
+
const primaryKey = known?.primaryKey;
|
|
294
|
+
const columns = toRequestColumns(
|
|
295
|
+
options.columns,
|
|
296
|
+
known?.columns ?? this.config.columns,
|
|
297
|
+
primaryKey,
|
|
298
|
+
);
|
|
299
|
+
const mode = options.mode ?? this.config.mode;
|
|
300
|
+
const limit = options.limit ?? this.config.pageSize;
|
|
301
|
+
|
|
302
|
+
// Databricks only manages embeddings for Delta Sync indexes, so a query
|
|
303
|
+
// against a DIRECT_ACCESS index must carry a query VECTOR, not text. Embed
|
|
304
|
+
// the query client-side in that case (the index type is cached after the
|
|
305
|
+
// first lookup so this costs one extra call per index, not per search).
|
|
306
|
+
const directAccess = await this.isDirectAccess(name, options.signal);
|
|
307
|
+
const queryVector = directAccess
|
|
308
|
+
? (await this.embed([text], this.config.embeddingModel, options.signal))[0]
|
|
309
|
+
: undefined;
|
|
310
|
+
|
|
311
|
+
const response = await this.withClient("search", options.signal, async (client, context) => {
|
|
312
|
+
return client.vectorSearchIndexes.queryIndex(
|
|
313
|
+
{
|
|
314
|
+
index_name: name,
|
|
315
|
+
columns,
|
|
316
|
+
...(queryVector ? { query_vector: queryVector } : { query_text: text }),
|
|
317
|
+
query_type: toQueryType(mode),
|
|
318
|
+
num_results: limit,
|
|
319
|
+
...(compileFilter(options.filter) ? { filters_json: compileFilter(options.filter) } : {}),
|
|
320
|
+
...(options.scoreThreshold !== undefined
|
|
321
|
+
? { score_threshold: options.scoreThreshold }
|
|
322
|
+
: {}),
|
|
323
|
+
},
|
|
324
|
+
context,
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const hits = toHits(response as QueryResponseLike, primaryKey);
|
|
329
|
+
return { query: text, index: name, hits, count: hits.length };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Cache of index-name -> is-DIRECT_ACCESS, so a search embeds its query only when needed. */
|
|
333
|
+
private readonly directAccessCache = new Map<string, boolean>();
|
|
334
|
+
|
|
335
|
+
/** Whether an index is DIRECT_ACCESS (memoized); a lookup failure assumes Delta Sync. */
|
|
336
|
+
private async isDirectAccess(name: string, signal?: AbortSignal): Promise<boolean> {
|
|
337
|
+
const cached = this.directAccessCache.get(name);
|
|
338
|
+
if (cached !== undefined) return cached;
|
|
339
|
+
try {
|
|
340
|
+
const info = await this.getIndex(name, signal);
|
|
341
|
+
const value = info.directAccess ?? false;
|
|
342
|
+
this.directAccessCache.set(name, value);
|
|
343
|
+
return value;
|
|
344
|
+
} catch {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Fan a query across several indexes and merge the hits, sorted by score -
|
|
351
|
+
* the "universal search" a single box over many collections needs. Each
|
|
352
|
+
* index is searched concurrently; an index that errors is logged and skipped
|
|
353
|
+
* so one bad index does not sink the whole search.
|
|
354
|
+
*/
|
|
355
|
+
async universalSearch(
|
|
356
|
+
query: string,
|
|
357
|
+
options: UniversalSearchOptions = {},
|
|
358
|
+
): Promise<SearchResult> {
|
|
359
|
+
const text = string.trimToEmpty(query);
|
|
360
|
+
const names =
|
|
361
|
+
options.indexes && options.indexes.length > 0
|
|
362
|
+
? options.indexes.map((ref) => resolveIndexName(this.config, ref) ?? ref)
|
|
363
|
+
: this.config.indexes.map((i) => i.name);
|
|
364
|
+
const perIndex = options.limit ?? this.config.pageSize;
|
|
365
|
+
|
|
366
|
+
const settled = await Promise.allSettled(
|
|
367
|
+
names.map(async (name) => {
|
|
368
|
+
const result = await this.search(text, {
|
|
369
|
+
index: name,
|
|
370
|
+
limit: perIndex,
|
|
371
|
+
...(options.mode ? { mode: options.mode } : {}),
|
|
372
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
373
|
+
});
|
|
374
|
+
return result.hits.map((hit): SearchHit => ({ ...hit, index: name }));
|
|
375
|
+
}),
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
const hits = settled
|
|
379
|
+
.flatMap((outcome, i) => {
|
|
380
|
+
if (outcome.status === "fulfilled") return outcome.value;
|
|
381
|
+
logger.warn("universal-index-failed", { index: names[i] });
|
|
382
|
+
return [];
|
|
383
|
+
})
|
|
384
|
+
.sort((a, b) => b.score - a.score);
|
|
385
|
+
|
|
386
|
+
return { query: text, hits, count: hits.length };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Resolve an index reference (full UC name or configured alias) to the name
|
|
391
|
+
* the API expects, failing fast when it resolves to nothing. Without this a
|
|
392
|
+
* blank reference - what an omitted `index` becomes when no default is
|
|
393
|
+
* configured - reaches the SDK as an empty `index_name`, which builds a
|
|
394
|
+
* URL with the name segment missing and comes back as a confusing
|
|
395
|
+
* `ENDPOINT_NOT_FOUND` instead of naming the real problem.
|
|
396
|
+
*/
|
|
397
|
+
private requireIndexName(reference: string, operation: string): string {
|
|
398
|
+
const name = string.trimToNull(resolveIndexName(this.config, reference) ?? reference);
|
|
399
|
+
if (name === null) {
|
|
400
|
+
throw new ExecutionError(
|
|
401
|
+
`search: no index configured; set a default index or pass one to ${operation}`,
|
|
402
|
+
{ context: { operation } },
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
return name;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Fetch an index's live definition. */
|
|
409
|
+
async getIndex(reference: string, signal?: AbortSignal): Promise<IndexInfo> {
|
|
410
|
+
const name = this.requireIndexName(reference, "getIndex");
|
|
411
|
+
const index = await this.withClient("getIndex", signal, (client, context) =>
|
|
412
|
+
client.vectorSearchIndexes.getIndex({ index_name: name }, context),
|
|
413
|
+
);
|
|
414
|
+
const directAccess = index.direct_access_index_spec !== undefined;
|
|
415
|
+
const spec = index.delta_sync_index_spec ?? index.direct_access_index_spec;
|
|
416
|
+
// Delta Sync surfaces its embedding source columns directly; a direct-access
|
|
417
|
+
// index carries its columns in schema_json (minus the vector column).
|
|
418
|
+
let columns = (spec?.embedding_source_columns ?? []).map((c) => c.name ?? "").filter(Boolean);
|
|
419
|
+
const schemaJson = index.direct_access_index_spec?.schema_json;
|
|
420
|
+
if (directAccess && schemaJson) {
|
|
421
|
+
const schema = json.parseRecord(schemaJson) ?? {};
|
|
422
|
+
const vectorNames = new Set(
|
|
423
|
+
(index.direct_access_index_spec?.embedding_vector_columns ?? [])
|
|
424
|
+
.map((c) => c.name)
|
|
425
|
+
.filter((n): n is string => Boolean(n)),
|
|
426
|
+
);
|
|
427
|
+
columns = Object.keys(schema).filter((c) => !vectorNames.has(c));
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
name: index.name ?? name,
|
|
431
|
+
...(index.endpoint_name ? { endpoint: index.endpoint_name } : {}),
|
|
432
|
+
...(index.primary_key ? { primaryKey: index.primary_key } : {}),
|
|
433
|
+
columns,
|
|
434
|
+
ready: index.status?.ready ?? false,
|
|
435
|
+
...(index.status?.indexed_row_count !== undefined
|
|
436
|
+
? { rowCount: index.status.indexed_row_count }
|
|
437
|
+
: {}),
|
|
438
|
+
...(directAccess ? { directAccess: true } : {}),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Add or update documents in a direct-access index. */
|
|
443
|
+
async addDocuments(
|
|
444
|
+
reference: string,
|
|
445
|
+
documents: SearchDocument[],
|
|
446
|
+
signal?: AbortSignal,
|
|
447
|
+
): Promise<UpsertResult> {
|
|
448
|
+
const name = this.requireIndexName(reference, "addDocuments");
|
|
449
|
+
if (this.lakebase) {
|
|
450
|
+
return this.lakebase.addDocuments(
|
|
451
|
+
name,
|
|
452
|
+
documents,
|
|
453
|
+
this.config.ensureOnSetup?.textColumn ?? "text",
|
|
454
|
+
signal,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
await this.withClient("addDocuments", signal, (client, context) =>
|
|
458
|
+
client.vectorSearchIndexes.upsertDataVectorIndex(
|
|
459
|
+
{ index_name: name, inputs_json: JSON.stringify(documents) },
|
|
460
|
+
context,
|
|
461
|
+
),
|
|
462
|
+
);
|
|
463
|
+
return { index: name, count: documents.length };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Delete documents from a direct-access index by primary key. */
|
|
467
|
+
async deleteDocuments(
|
|
468
|
+
reference: string,
|
|
469
|
+
ids: Array<string | number>,
|
|
470
|
+
signal?: AbortSignal,
|
|
471
|
+
): Promise<UpsertResult> {
|
|
472
|
+
const name = this.requireIndexName(reference, "deleteDocuments");
|
|
473
|
+
if (this.lakebase) {
|
|
474
|
+
return this.lakebase.deleteDocuments(name, ids, signal);
|
|
475
|
+
}
|
|
476
|
+
await this.withClient("deleteDocuments", signal, (client, context) =>
|
|
477
|
+
client.vectorSearchIndexes.deleteDataVectorIndex(
|
|
478
|
+
{ index_name: name, primary_keys: ids.map(String) },
|
|
479
|
+
context,
|
|
480
|
+
),
|
|
481
|
+
);
|
|
482
|
+
return { index: name, count: ids.length };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Resolve an embedding endpoint id for creating a Delta Sync index. Reuses
|
|
487
|
+
* the model resolver: a configured / passed name is fuzzy-matched against the
|
|
488
|
+
* live catalogue, otherwise the highest-ranked embedding endpoint is chosen.
|
|
489
|
+
*/
|
|
490
|
+
async resolveEmbeddingModel(requested?: string, signal?: AbortSignal): Promise<string | null> {
|
|
491
|
+
const explicit = string.trimToNull(requested ?? this.config.embeddingModel);
|
|
492
|
+
// An explicit name that already looks like an endpoint id (no whitespace)
|
|
493
|
+
// is used verbatim - no need to fetch and fuzzy-match the live catalogue.
|
|
494
|
+
// A genuinely loose name (e.g. "gte large") still resolves against it.
|
|
495
|
+
if (explicit && !/\s/.test(explicit)) return explicit;
|
|
496
|
+
return this.withClient("resolveEmbeddingModel", signal, async (client) => {
|
|
497
|
+
const host = (await client.config.getHost()).toString();
|
|
498
|
+
const endpoints = await serving.listServingEndpoints(client, host);
|
|
499
|
+
const { modelId } = modelResolve.resolveModel(endpoints, {
|
|
500
|
+
...(explicit ? { explicit } : { modelClass: ModelClass.Embedding }),
|
|
501
|
+
});
|
|
502
|
+
return string.trimToNull(modelId);
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Embed text via a Databricks embedding serving endpoint, returning one
|
|
508
|
+
* vector per input. Used to seed a direct-access index and to turn a search
|
|
509
|
+
* query into a query vector - Databricks only manages embeddings for Delta
|
|
510
|
+
* Sync indexes, so a direct-access index (no Delta table, no warehouse) needs
|
|
511
|
+
* the client to embed on write and on query. The endpoint is resolved the
|
|
512
|
+
* same way as for index creation when not named.
|
|
513
|
+
*/
|
|
514
|
+
async embed(texts: string[], model?: string, signal?: AbortSignal): Promise<number[][]> {
|
|
515
|
+
if (texts.length === 0) return [];
|
|
516
|
+
const endpoint = await this.resolveEmbeddingModel(model, signal);
|
|
517
|
+
if (!endpoint) {
|
|
518
|
+
throw new ExecutionError("search: could not resolve an embedding model to embed text", {
|
|
519
|
+
context: { operation: "embed" },
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
return this.withClient("embed", signal, async (client) => {
|
|
523
|
+
const host = (await client.config.getHost()).toString();
|
|
524
|
+
const url = invoke.invocationsUrl(host, endpoint);
|
|
525
|
+
const headers = await invoke.authHeaders(client);
|
|
526
|
+
const response = await fetch(url, {
|
|
527
|
+
method: "POST",
|
|
528
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
529
|
+
body: JSON.stringify({ input: texts }),
|
|
530
|
+
...(signal ? { signal } : {}),
|
|
531
|
+
});
|
|
532
|
+
if (!response.ok) {
|
|
533
|
+
throw new ExecutionError(
|
|
534
|
+
`search: embedding endpoint ${endpoint} failed (${response.status})`,
|
|
535
|
+
{ context: { operation: "embed" } },
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
const body = (await response.json()) as { data?: Array<{ embedding: number[] }> };
|
|
539
|
+
const vectors = (body.data ?? []).map((row) => row.embedding);
|
|
540
|
+
if (vectors.length !== texts.length) {
|
|
541
|
+
throw new ExecutionError("search: embedding response did not match the input count", {
|
|
542
|
+
context: { operation: "embed" },
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
return vectors;
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** The vector dimension a model produces (embeds a probe string once). */
|
|
550
|
+
private async embeddingDimension(model?: string, signal?: AbortSignal): Promise<number> {
|
|
551
|
+
const [vector] = await this.embed(["dimension probe"], model, signal);
|
|
552
|
+
if (!vector || vector.length === 0) {
|
|
553
|
+
throw new ExecutionError("search: could not determine the embedding dimension", {
|
|
554
|
+
context: { operation: "createIndex" },
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
return vector.length;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Create an AI Search index with as little ceremony as possible. Two shapes:
|
|
562
|
+
*
|
|
563
|
+
* - **Delta Sync** (the default): pass `sourceTable`; Databricks computes
|
|
564
|
+
* embeddings from the text column and keeps the index synced. The
|
|
565
|
+
* embedding model is resolved automatically when not named.
|
|
566
|
+
* - **Direct Access**: omit `sourceTable` and pass `embeddingDimension`;
|
|
567
|
+
* you write vectors yourself via {@link addDocuments}.
|
|
568
|
+
*
|
|
569
|
+
* Everything else infers: the endpoint from the plugin config, the primary
|
|
570
|
+
* key (`id`), the text column (`text` / `content` / `body`), and the vector
|
|
571
|
+
* column (`embedding`). Returns the created index's {@link IndexInfo}.
|
|
572
|
+
*/
|
|
573
|
+
async createIndex(name: string, options: CreateIndexOptions = {}): Promise<IndexInfo> {
|
|
574
|
+
const endpoint = options.endpoint ?? this.config.endpoint;
|
|
575
|
+
if (!endpoint) {
|
|
576
|
+
throw new ExecutionError(
|
|
577
|
+
"search: no Vector Search endpoint configured; pass `endpoint` or set it on the plugin",
|
|
578
|
+
{ context: { operation: "createIndex" } },
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
const primaryKey = options.primaryKey ?? "id";
|
|
582
|
+
const direct = !options.sourceTable;
|
|
583
|
+
const sourceColumn = options.embeddingSourceColumn ?? "text";
|
|
584
|
+
const vectorColumn = options.embeddingVectorColumn ?? "embedding";
|
|
585
|
+
|
|
586
|
+
// Delta Sync lets Databricks embed a source column, so it needs an embedding
|
|
587
|
+
// model. A direct-access index stores vectors YOU supply (Databricks only
|
|
588
|
+
// supports managed embeddings on Delta Sync), so it needs a dimension; we
|
|
589
|
+
// resolve one from the embedding model when not given so a direct-access
|
|
590
|
+
// index still works with zero extra config (embed via `embed()` on seed +
|
|
591
|
+
// query - see `provision` / `search`).
|
|
592
|
+
const embeddingModel =
|
|
593
|
+
(await this.resolveEmbeddingModel(options.embeddingModel, options.signal)) ?? undefined;
|
|
594
|
+
if (!embeddingModel) {
|
|
595
|
+
throw new ExecutionError(
|
|
596
|
+
"search: could not resolve an embedding model; pass `embeddingModel`",
|
|
597
|
+
{ context: { operation: "createIndex" } },
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
const dimension =
|
|
601
|
+
options.embeddingDimension ?? (await this.embeddingDimension(embeddingModel, options.signal));
|
|
602
|
+
|
|
603
|
+
const schema = {
|
|
604
|
+
[primaryKey]: "string",
|
|
605
|
+
[sourceColumn]: "string",
|
|
606
|
+
...(options.schema ?? {}),
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const directSpec = {
|
|
610
|
+
direct_access_index_spec: {
|
|
611
|
+
embedding_vector_columns: [{ name: vectorColumn, embedding_dimension: dimension }],
|
|
612
|
+
schema_json: JSON.stringify({ ...schema, [vectorColumn]: `array<float>` }),
|
|
613
|
+
},
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
await this.withClient("createIndex", options.signal, (client, context) =>
|
|
617
|
+
client.vectorSearchIndexes.createIndex(
|
|
618
|
+
{
|
|
619
|
+
name,
|
|
620
|
+
endpoint_name: endpoint,
|
|
621
|
+
primary_key: primaryKey,
|
|
622
|
+
index_type: direct ? "DIRECT_ACCESS" : "DELTA_SYNC",
|
|
623
|
+
...(direct
|
|
624
|
+
? directSpec
|
|
625
|
+
: {
|
|
626
|
+
delta_sync_index_spec: {
|
|
627
|
+
source_table: options.sourceTable,
|
|
628
|
+
pipeline_type: options.pipelineType ?? "TRIGGERED",
|
|
629
|
+
embedding_source_columns: [
|
|
630
|
+
{ name: sourceColumn, embedding_model_endpoint_name: embeddingModel },
|
|
631
|
+
],
|
|
632
|
+
...(options.columnsToSync && options.columnsToSync.length > 0
|
|
633
|
+
? { columns_to_sync: options.columnsToSync }
|
|
634
|
+
: {}),
|
|
635
|
+
},
|
|
636
|
+
}),
|
|
637
|
+
},
|
|
638
|
+
context,
|
|
639
|
+
),
|
|
640
|
+
);
|
|
641
|
+
logger.info("index-created", {
|
|
642
|
+
index: name,
|
|
643
|
+
endpoint,
|
|
644
|
+
type: direct ? "DIRECT_ACCESS" : "DELTA_SYNC",
|
|
645
|
+
});
|
|
646
|
+
return this.getIndex(name, options.signal);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Create the index if it does not already exist, otherwise return the
|
|
651
|
+
* existing one. Idempotent - safe to call on every boot to guarantee an
|
|
652
|
+
* index is present.
|
|
653
|
+
*/
|
|
654
|
+
async ensureIndex(name: string, options: CreateIndexOptions = {}): Promise<IndexInfo> {
|
|
655
|
+
try {
|
|
656
|
+
return await this.getIndex(name, options.signal);
|
|
657
|
+
} catch {
|
|
658
|
+
return this.createIndex(name, options);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Ensure an index exists, is online, and (optionally) holds seed data - the
|
|
664
|
+
* "wire up a real index on boot" path. Idempotent and cheap to call every
|
|
665
|
+
* boot: it creates the endpoint and index only if missing, waits for them to
|
|
666
|
+
* come online, and seeds documents ONLY when the index is still empty.
|
|
667
|
+
*
|
|
668
|
+
* For the demo/dummy-data case this needs no Delta table and no warehouse:
|
|
669
|
+
* the default is a MANAGED direct-access index (Databricks embeds the `text`
|
|
670
|
+
* column on write and query), so seeding is just an `addDocuments` of plain
|
|
671
|
+
* rows and search-by-text works immediately.
|
|
672
|
+
*/
|
|
673
|
+
async provision(name: string, options: ProvisionOptions = {}): Promise<IndexInfo> {
|
|
674
|
+
if (this.lakebase) {
|
|
675
|
+
// Lakebase fallback: no endpoint, no embeddings, no vectors - just a
|
|
676
|
+
// Postgres full-text table. Returns an IndexInfo-shaped result so the
|
|
677
|
+
// caller's logging + readiness handling is identical to Vector Search.
|
|
678
|
+
const rowCount = await this.lakebase.provision(name, {
|
|
679
|
+
textColumn: options.embeddingSourceColumn ?? "text",
|
|
680
|
+
...(options.seed ? { seed: options.seed } : {}),
|
|
681
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
682
|
+
});
|
|
683
|
+
return {
|
|
684
|
+
name,
|
|
685
|
+
primaryKey: options.primaryKey ?? "id",
|
|
686
|
+
columns: [],
|
|
687
|
+
ready: true,
|
|
688
|
+
rowCount,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
const wait = options.wait ?? true;
|
|
692
|
+
const timeoutMs = options.timeoutMs ?? 20 * 60 * 1000;
|
|
693
|
+
const endpoint = options.endpoint ?? this.config.endpoint;
|
|
694
|
+
if (endpoint) await this.ensureEndpoint(endpoint, { wait, signal: options.signal });
|
|
695
|
+
|
|
696
|
+
const { seed: _seed, wait: _wait, timeoutMs: _timeoutMs, ...createOptions } = options;
|
|
697
|
+
let info = await this.ensureIndex(name, createOptions);
|
|
698
|
+
|
|
699
|
+
if (wait && !info.ready) info = await this.waitForIndexReady(name, timeoutMs, options.signal);
|
|
700
|
+
|
|
701
|
+
const seed = options.seed ?? [];
|
|
702
|
+
if (seed.length > 0 && (info.rowCount ?? 0) === 0) {
|
|
703
|
+
const sourceColumn = options.embeddingSourceColumn ?? "text";
|
|
704
|
+
const vectorColumn = options.embeddingVectorColumn ?? "embedding";
|
|
705
|
+
// Direct-access indexes store vectors we supply, so embed the text column
|
|
706
|
+
// for any seed row that did not already carry a vector.
|
|
707
|
+
const needEmbed = seed.some((doc) => doc[vectorColumn] === undefined);
|
|
708
|
+
let rows = seed;
|
|
709
|
+
if (needEmbed) {
|
|
710
|
+
const texts = seed.map((doc) => string.trimToEmpty(String(doc[sourceColumn] ?? "")));
|
|
711
|
+
const vectors = await this.embed(texts, options.embeddingModel, options.signal);
|
|
712
|
+
rows = seed.map((doc, i) =>
|
|
713
|
+
doc[vectorColumn] === undefined ? { ...doc, [vectorColumn]: vectors[i] } : doc,
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
await this.addDocuments(name, rows, options.signal);
|
|
717
|
+
logger.info("index-seeded", { index: name, count: rows.length });
|
|
718
|
+
info = await this.getIndex(name, options.signal);
|
|
719
|
+
}
|
|
720
|
+
return info;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** Poll an index until it reports ready, or throw after {@link timeoutMs}. */
|
|
724
|
+
private async waitForIndexReady(
|
|
725
|
+
name: string,
|
|
726
|
+
timeoutMs: number,
|
|
727
|
+
signal?: AbortSignal,
|
|
728
|
+
): Promise<IndexInfo> {
|
|
729
|
+
const deadline = Date.now() + timeoutMs;
|
|
730
|
+
let info = await this.getIndex(name, signal);
|
|
731
|
+
while (!info.ready) {
|
|
732
|
+
if (Date.now() > deadline) {
|
|
733
|
+
throw new ExecutionError(`search: index ${name} did not come online in time`, {
|
|
734
|
+
context: { operation: "provision" },
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
await asyncUtil.sleep(5000, signal);
|
|
738
|
+
info = await this.getIndex(name, signal);
|
|
739
|
+
}
|
|
740
|
+
return info;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Trigger a sync of a Delta Sync index from its source table. */
|
|
744
|
+
async syncIndex(reference: string, signal?: AbortSignal): Promise<void> {
|
|
745
|
+
const name = this.requireIndexName(reference, "syncIndex");
|
|
746
|
+
await this.withClient("syncIndex", signal, (client, context) =>
|
|
747
|
+
client.vectorSearchIndexes.syncIndex({ index_name: name }, context),
|
|
748
|
+
);
|
|
749
|
+
logger.info("index-synced", { index: name });
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** Delete an index. */
|
|
753
|
+
async deleteIndex(reference: string, signal?: AbortSignal): Promise<void> {
|
|
754
|
+
const name = this.requireIndexName(reference, "deleteIndex");
|
|
755
|
+
await this.withClient("deleteIndex", signal, (client, context) =>
|
|
756
|
+
client.vectorSearchIndexes.deleteIndex({ index_name: name }, context),
|
|
757
|
+
);
|
|
758
|
+
logger.info("index-deleted", { index: name });
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** List the indexes hosted on a Vector Search endpoint (name + type only). */
|
|
762
|
+
async listIndexes(endpoint?: string, signal?: AbortSignal): Promise<string[]> {
|
|
763
|
+
const endpointName = endpoint ?? this.config.endpoint;
|
|
764
|
+
if (!endpointName) {
|
|
765
|
+
throw new ExecutionError("search: no endpoint configured to list indexes", {
|
|
766
|
+
context: { operation: "listIndexes" },
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
return this.withClient("listIndexes", signal, async (client, context) => {
|
|
770
|
+
const names: string[] = [];
|
|
771
|
+
for await (const index of client.vectorSearchIndexes.listIndexes(
|
|
772
|
+
{ endpoint_name: endpointName },
|
|
773
|
+
context,
|
|
774
|
+
)) {
|
|
775
|
+
if (index.name) names.push(index.name);
|
|
776
|
+
}
|
|
777
|
+
return names;
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Ensure a Vector Search endpoint exists, creating a `STANDARD` one when it
|
|
783
|
+
* does not. Optionally wait for it to come online. Idempotent.
|
|
784
|
+
*/
|
|
785
|
+
async ensureEndpoint(name?: string, options: EnsureEndpointOptions = {}): Promise<void> {
|
|
786
|
+
const endpoint = name ?? this.config.endpoint;
|
|
787
|
+
if (!endpoint) {
|
|
788
|
+
throw new ExecutionError("search: no endpoint name to ensure", {
|
|
789
|
+
context: { operation: "ensureEndpoint" },
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
await this.withClient("ensureEndpoint", options.signal, async (client, context) => {
|
|
793
|
+
try {
|
|
794
|
+
await client.vectorSearchEndpoints.getEndpoint({ endpoint_name: endpoint }, context);
|
|
795
|
+
return;
|
|
796
|
+
} catch {
|
|
797
|
+
const waiter = await client.vectorSearchEndpoints.createEndpoint(
|
|
798
|
+
{ name: endpoint, endpoint_type: "STANDARD" },
|
|
799
|
+
context,
|
|
800
|
+
);
|
|
801
|
+
logger.info("endpoint-created", { endpoint });
|
|
802
|
+
if (options.wait) await waiter.wait();
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Resolve the workspace client and run one call under a bounded timeout. The
|
|
809
|
+
* caller's signal (if any) and a timeout are merged into one SDK `Context`
|
|
810
|
+
* via {@link appkit.databricks.toContext}, so either unwinds the request.
|
|
811
|
+
*/
|
|
812
|
+
private async withClient<T>(
|
|
813
|
+
operation: string,
|
|
814
|
+
signal: AbortSignal | undefined,
|
|
815
|
+
fn: (client: WorkspaceClientLike, context?: Context) => Promise<T>,
|
|
816
|
+
): Promise<T> {
|
|
817
|
+
const client = this.workspaceClientFactory();
|
|
818
|
+
const controller = new AbortController();
|
|
819
|
+
const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
|
|
820
|
+
const context = databricks.toContext(controller, signal);
|
|
821
|
+
try {
|
|
822
|
+
return await fn(client, context);
|
|
823
|
+
} catch (err) {
|
|
824
|
+
if (signal?.aborted) throw ExecutionError.canceled();
|
|
825
|
+
logger.warn("execution-failed", { operation });
|
|
826
|
+
throw err;
|
|
827
|
+
} finally {
|
|
828
|
+
clearTimeout(timer);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** The OBO workspace client from the active context, or a service-principal client. */
|
|
834
|
+
function defaultWorkspaceClient(): WorkspaceClientLike {
|
|
835
|
+
const ctx = appkit.tryGetExecutionContext();
|
|
836
|
+
if (ctx?.client) return ctx.client;
|
|
837
|
+
// Outside a request scope (a script, a test): a fresh env-auth client.
|
|
838
|
+
return getExecutionContext().client;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Construct a {@link SearchClient} from a resolved config. */
|
|
842
|
+
export function createSearchClient(
|
|
843
|
+
config?: ResolvedSearchConfig,
|
|
844
|
+
workspaceClientFactory?: () => WorkspaceClientLike,
|
|
845
|
+
lakebase?: LakebaseSearchBackend,
|
|
846
|
+
): SearchClient {
|
|
847
|
+
return new SearchClient(config, workspaceClientFactory, lakebase);
|
|
848
|
+
}
|