@dbx-tools/databricks 0.6.9 → 0.6.10

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.
@@ -0,0 +1,444 @@
1
+ /**
2
+ * Databricks {@link FileSystem} backed by {@link WorkspaceClient}.
3
+ *
4
+ * Extends {@link BaseFileSystem}. Routes I/O by absolute path:
5
+ * - `/Workspace`, `/Users`, `/Repos`, `/Shared` → workspace objects API
6
+ * - `/Volumes/...` (or `catalog.schema.volume` roots) → Unity Catalog Files API
7
+ * - `/dbfs/...` → DBFS API
8
+ *
9
+ * Every primitive picks its API through {@link DatabricksFileSystem.dispatch}
10
+ * rather than re-branching by hand, and error normalization is inherited from
11
+ * {@link BaseFileSystem}, so a primitive here is just the SDK call.
12
+ *
13
+ * @module
14
+ */
15
+
16
+ import { WorkspaceClient } from "@databricks/sdk-experimental";
17
+ import { hash } from "@dbx-tools/shared-core";
18
+ import {
19
+ BaseFileSystem,
20
+ baseFS,
21
+ FileSystemError,
22
+ posixPath,
23
+ type CopyOptions,
24
+ type FileEntry,
25
+ type FileStat,
26
+ type WriteFileOptions,
27
+ } from "@dbx-tools/shared-fs";
28
+ import {
29
+ isHomeRelativePath,
30
+ normalizeDatabricksRoot,
31
+ resolveDatabricksFilesBackend,
32
+ resolveDatabricksRoot,
33
+ type DatabricksFilesBackend,
34
+ } from "./databricks-path.ts";
35
+ import { getWorkspaceClient } from "./workspace.ts";
36
+
37
+ const DBFS_READ_CHUNK_BYTES = 1024 * 1024;
38
+ const DBFS_PUT_MAX_BYTES = 1024 * 1024;
39
+
40
+ /** Segments in `/Volumes/<catalog>/<schema>/<volume>`, provisioned out of band. */
41
+ const VOLUME_ROOT_DEPTH = 4;
42
+
43
+ /**
44
+ * One handler per Databricks API, keyed by {@link DatabricksFilesBackend} so
45
+ * adding a backend is a compile error until every call site handles it.
46
+ */
47
+ type BackendHandlers<T> = Record<DatabricksFilesBackend, (client: WorkspaceClient) => Promise<T>>;
48
+
49
+ /** Options for {@link DatabricksFileSystem}. */
50
+ export interface DatabricksFileSystemOptions {
51
+ /** Unique identifier. Defaults to a stable hash of the normalized root. */
52
+ id?: string;
53
+
54
+ /**
55
+ * Filesystem root. Accepts:
56
+ * - `/Volumes/catalog/schema/volume` (also `/Volume/...`)
57
+ * - `catalog.schema.volume`
58
+ * - `~` / `~/...` → `/Workspace/Users/<userName>/...` (needs {@link userName},
59
+ * or use {@link DatabricksFileSystem.create})
60
+ * - `/Workspace/...`, `/Users/...`, `/Repos/...`, `/Shared/...`
61
+ * - `/dbfs/...`
62
+ */
63
+ root: string;
64
+
65
+ /**
66
+ * Username for expanding `~`. When omitted and {@link root} is home-relative,
67
+ * prefer {@link DatabricksFileSystem.create} which resolves it via
68
+ * `getCurrentUserName`.
69
+ */
70
+ userName?: string;
71
+
72
+ /**
73
+ * Databricks workspace client. Defaults to `tryGetWorkspaceClient()`, otherwise
74
+ * a default {@link WorkspaceClient} from env / profile auth.
75
+ */
76
+ client?: WorkspaceClient;
77
+
78
+ /** Block all write operations. Defaults to false. */
79
+ readOnly?: boolean;
80
+
81
+ /**
82
+ * Create {@link root} (and parents) during init. Defaults to false (volume
83
+ * and workspace roots are usually provisioned out of band).
84
+ */
85
+ createRoot?: boolean;
86
+ }
87
+
88
+ /**
89
+ * {@link FileSystem} implementation over Databricks workspace files, UC
90
+ * volumes, and DBFS.
91
+ *
92
+ * @example
93
+ * ```ts
94
+ * const fs = new DatabricksFileSystem({ root: "main.default.assets" });
95
+ * await fs.writeFile("notes/hello.txt", "hi");
96
+ *
97
+ * const home = await DatabricksFileSystem.create({ root: "~" });
98
+ * const listing = await home.readdir(".");
99
+ * ```
100
+ */
101
+ export class DatabricksFileSystem extends BaseFileSystem<"databricks"> {
102
+ private client: WorkspaceClient | undefined;
103
+ private readonly clientOption: WorkspaceClient | undefined;
104
+
105
+ constructor(options: DatabricksFileSystemOptions) {
106
+ const root = normalizeDatabricksRoot(options.root, { userName: options.userName });
107
+ super({
108
+ id: options.id ?? `databricks-${hash.fnvHash(root)}`,
109
+ backend: "databricks",
110
+ root,
111
+ readOnly: options.readOnly,
112
+ createRoot: options.createRoot ?? false,
113
+ });
114
+ this.clientOption = options.client;
115
+ this.client = options.client;
116
+ }
117
+
118
+ /**
119
+ * Construct a {@link DatabricksFileSystem}, resolving `~` roots via the
120
+ * workspace current user when needed.
121
+ */
122
+ static async create(options: DatabricksFileSystemOptions): Promise<DatabricksFileSystem> {
123
+ if (!isHomeRelativePath(options.root) || options.userName?.trim()) {
124
+ return new DatabricksFileSystem(options);
125
+ }
126
+ const client = options.client ?? (await getWorkspaceClient());
127
+ const root = await resolveDatabricksRoot(options.root, { client });
128
+ return new DatabricksFileSystem({ ...options, root, client });
129
+ }
130
+
131
+ protected override async onInit(): Promise<void> {
132
+ this.client = this.clientOption ?? (await getWorkspaceClient());
133
+ }
134
+
135
+ /** Run the handler for `absolutePath`'s Databricks API with the live client. */
136
+ private dispatch<T>(absolutePath: string, handlers: BackendHandlers<T>): Promise<T> {
137
+ if (!this.client) {
138
+ throw new FileSystemError(
139
+ "IO_ERROR",
140
+ "Databricks filesystem is not initialized (no WorkspaceClient)",
141
+ absolutePath,
142
+ );
143
+ }
144
+ return handlers[resolveDatabricksFilesBackend(absolutePath)](this.client);
145
+ }
146
+
147
+ /* ------------------------------------------------------------------ */
148
+ /* Primitives */
149
+ /* ------------------------------------------------------------------ */
150
+
151
+ protected override async createRootDirectory(): Promise<void> {
152
+ await this.createDirectoryAt(this.root);
153
+ }
154
+
155
+ protected override async readBytesAt(resolvedPath: string): Promise<Uint8Array> {
156
+ const buffer = await this.dispatch<Buffer>(resolvedPath, {
157
+ dbfs: (client) => this.readDbfsFile(client, resolvedPath),
158
+ workspace: async (client) => {
159
+ const response = await client.workspace.export({ path: resolvedPath, format: "AUTO" });
160
+ return decodeBase64(response.content);
161
+ },
162
+ volumes: async (client) => {
163
+ const response = await client.files.download({ file_path: resolvedPath });
164
+ return readResponseBody(
165
+ response.contents as globalThis.ReadableStream<Uint8Array> | undefined,
166
+ );
167
+ },
168
+ });
169
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
170
+ }
171
+
172
+ protected override async writeBytesAt(
173
+ resolvedPath: string,
174
+ content: Uint8Array,
175
+ options: Required<WriteFileOptions>,
176
+ ): Promise<void> {
177
+ const buffer = Buffer.from(content);
178
+ const overwrite = options.overwrite;
179
+ await this.dispatch<unknown>(resolvedPath, {
180
+ dbfs: (client) => this.writeDbfsFile(client, resolvedPath, buffer, overwrite),
181
+ workspace: (client) =>
182
+ client.workspace.import({
183
+ path: resolvedPath,
184
+ format: "AUTO",
185
+ content: buffer.toString("base64"),
186
+ overwrite,
187
+ }),
188
+ volumes: (client) =>
189
+ client.files.upload({
190
+ file_path: resolvedPath,
191
+ contents: bufferToReadableStream(buffer) as never,
192
+ overwrite,
193
+ }),
194
+ });
195
+ }
196
+
197
+ protected override async deleteFileAt(resolvedPath: string): Promise<void> {
198
+ await this.dispatch<unknown>(resolvedPath, {
199
+ dbfs: (client) => client.dbfs.delete({ path: resolvedPath, recursive: false }),
200
+ workspace: (client) => client.workspace.delete({ path: resolvedPath, recursive: false }),
201
+ volumes: (client) => client.files.delete({ file_path: resolvedPath }),
202
+ });
203
+ }
204
+
205
+ protected override async createDirectoryAt(resolvedPath: string): Promise<void> {
206
+ await this.dispatch<unknown>(resolvedPath, {
207
+ dbfs: (client) => client.dbfs.mkdirs({ path: resolvedPath }),
208
+ workspace: (client) => client.workspace.mkdirs({ path: resolvedPath }),
209
+ volumes: (client) => this.createVolumeDirectories(client, resolvedPath),
210
+ });
211
+ }
212
+
213
+ protected override async removeDirectoryAt(resolvedPath: string): Promise<void> {
214
+ await this.dispatch<unknown>(resolvedPath, {
215
+ dbfs: (client) => client.dbfs.delete({ path: resolvedPath, recursive: false }),
216
+ workspace: (client) => client.workspace.delete({ path: resolvedPath, recursive: false }),
217
+ volumes: (client) => client.files.deleteDirectory({ directory_path: resolvedPath }),
218
+ });
219
+ }
220
+
221
+ protected override async listDirectoryAt(resolvedPath: string): Promise<FileEntry[]> {
222
+ return this.dispatch<FileEntry[]>(resolvedPath, {
223
+ dbfs: (client) =>
224
+ collect(client.dbfs.list({ path: resolvedPath }), (info) => ({
225
+ name: posixPath.basename(info.path ?? ""),
226
+ type: info.is_dir ? "directory" : "file",
227
+ size: info.file_size,
228
+ })),
229
+ workspace: (client) =>
230
+ collect(client.workspace.list({ path: resolvedPath }), (info) => ({
231
+ name: posixPath.basename(info.path ?? ""),
232
+ type: info.object_type === "DIRECTORY" ? "directory" : "file",
233
+ })),
234
+ volumes: (client) =>
235
+ collect(client.files.listDirectoryContents({ directory_path: resolvedPath }), (entry) => ({
236
+ name: entry.name ?? posixPath.basename(entry.path ?? ""),
237
+ type: entry.is_directory ? "directory" : "file",
238
+ size: entry.file_size,
239
+ })),
240
+ });
241
+ }
242
+
243
+ protected override async statAt(resolvedPath: string): Promise<Omit<FileStat, "path">> {
244
+ const fallbackName = posixPath.basename(resolvedPath) || posixPath.basename(this.root);
245
+ return this.dispatch<Omit<FileStat, "path">>(resolvedPath, {
246
+ dbfs: async (client) => {
247
+ const info = await client.dbfs.getStatus({ path: resolvedPath });
248
+ const modified = toDate(info.modification_time);
249
+ return {
250
+ name: posixPath.basename(info.path ?? resolvedPath) || fallbackName,
251
+ type: info.is_dir ? "directory" : "file",
252
+ size: info.file_size,
253
+ createdAt: modified,
254
+ modifiedAt: modified,
255
+ };
256
+ },
257
+ workspace: async (client) => {
258
+ const info = await client.workspace.getStatus({ path: resolvedPath });
259
+ return {
260
+ name: posixPath.basename(info.path ?? resolvedPath) || fallbackName,
261
+ type: info.object_type === "DIRECTORY" ? "directory" : "file",
262
+ createdAt: toDate(info.created_at),
263
+ modifiedAt: toDate(info.modified_at),
264
+ };
265
+ },
266
+ volumes: (client) => this.statVolumePath(client, resolvedPath, fallbackName),
267
+ });
268
+ }
269
+
270
+ protected override async tryMoveFileAt(
271
+ sourcePath: string,
272
+ destinationPath: string,
273
+ _options: Required<CopyOptions>,
274
+ ): Promise<boolean> {
275
+ // Only DBFS exposes a server-side move; everything else falls back to the
276
+ // portable copy + delete in BaseFileSystem.
277
+ if (
278
+ resolveDatabricksFilesBackend(sourcePath) !== "dbfs" ||
279
+ resolveDatabricksFilesBackend(destinationPath) !== "dbfs"
280
+ ) {
281
+ return false;
282
+ }
283
+ await this.dispatch<unknown>(sourcePath, {
284
+ dbfs: (client) =>
285
+ client.dbfs.move({ source_path: sourcePath, destination_path: destinationPath }),
286
+ workspace: unreachableBackend,
287
+ volumes: unreachableBackend,
288
+ });
289
+ return true;
290
+ }
291
+
292
+ /* ------------------------------------------------------------------ */
293
+ /* Backend-specific I/O */
294
+ /* ------------------------------------------------------------------ */
295
+
296
+ /**
297
+ * UC `createDirectory` is single-level, so walk the path creating each level
298
+ * below the volume itself. A level that already exists is not an error.
299
+ */
300
+ private async createVolumeDirectories(
301
+ client: WorkspaceClient,
302
+ absolutePath: string,
303
+ ): Promise<void> {
304
+ const parts = absolutePath.split("/").filter(Boolean);
305
+ let current = "";
306
+ for (let i = 0; i < parts.length; i++) {
307
+ current = `${current}/${parts[i]}`;
308
+ if (i < VOLUME_ROOT_DEPTH) continue;
309
+ try {
310
+ await client.files.createDirectory({ directory_path: current });
311
+ } catch (err) {
312
+ // Already present is fine; anything else is a real failure.
313
+ try {
314
+ await client.files.getDirectoryMetadata({ directory_path: current });
315
+ } catch {
316
+ throw err;
317
+ }
318
+ }
319
+ }
320
+ }
321
+
322
+ /** Stat a UC path: file metadata first, then a directory probe. */
323
+ private async statVolumePath(
324
+ client: WorkspaceClient,
325
+ absolutePath: string,
326
+ fallbackName: string,
327
+ ): Promise<Omit<FileStat, "path">> {
328
+ try {
329
+ const metadata = await client.files.getMetadata({ file_path: absolutePath });
330
+ const modified = parseHttpDate(metadata["last-modified"]);
331
+ return {
332
+ name: fallbackName,
333
+ type: "file",
334
+ size: Number(metadata["content-length"] ?? 0),
335
+ createdAt: modified,
336
+ modifiedAt: modified,
337
+ mimeType: metadata["content-type"],
338
+ };
339
+ } catch (fileErr) {
340
+ // A directory has no file metadata, and an unreadable one reports as
341
+ // missing / denied - both are worth a directory probe before failing.
342
+ const code = baseFS.mapFileSystemError(fileErr, absolutePath).code;
343
+ if (code !== "NOT_FOUND" && code !== "PERMISSION_DENIED") throw fileErr;
344
+ await client.files.getDirectoryMetadata({ directory_path: absolutePath });
345
+ return { name: fallbackName, type: "directory" };
346
+ }
347
+ }
348
+
349
+ private async readDbfsFile(client: WorkspaceClient, absolutePath: string): Promise<Buffer> {
350
+ const chunks: Buffer[] = [];
351
+ let offset = 0;
352
+ while (true) {
353
+ const response = await client.dbfs.read({
354
+ path: absolutePath,
355
+ offset,
356
+ length: DBFS_READ_CHUNK_BYTES,
357
+ });
358
+ const chunk = decodeBase64(response.data);
359
+ if (chunk.length === 0) break;
360
+ chunks.push(chunk);
361
+ offset += chunk.length;
362
+ if (chunk.length < DBFS_READ_CHUNK_BYTES) break;
363
+ }
364
+ return Buffer.concat(chunks);
365
+ }
366
+
367
+ private async writeDbfsFile(
368
+ client: WorkspaceClient,
369
+ absolutePath: string,
370
+ buffer: Buffer,
371
+ overwrite: boolean,
372
+ ): Promise<void> {
373
+ if (buffer.length <= DBFS_PUT_MAX_BYTES) {
374
+ await client.dbfs.put({
375
+ path: absolutePath,
376
+ contents: buffer.toString("base64"),
377
+ overwrite,
378
+ });
379
+ return;
380
+ }
381
+ const created = await client.dbfs.create({ path: absolutePath, overwrite });
382
+ const handle = created.handle;
383
+ if (handle === undefined) {
384
+ throw new FileSystemError("IO_ERROR", "DBFS upload handle missing", absolutePath);
385
+ }
386
+ for (let offset = 0; offset < buffer.length; offset += DBFS_PUT_MAX_BYTES) {
387
+ const slice = buffer.subarray(offset, offset + DBFS_PUT_MAX_BYTES);
388
+ await client.dbfs.addBlock({ handle, data: slice.toString("base64") });
389
+ }
390
+ await client.dbfs.close({ handle });
391
+ }
392
+ }
393
+
394
+ /* ------------------------------ helpers ------------------------------ */
395
+
396
+ /** Drain an SDK async iterable into a mapped array. */
397
+ async function collect<S, T>(source: AsyncIterable<S>, map: (item: S) => T): Promise<T[]> {
398
+ const items: T[] = [];
399
+ for await (const item of source) items.push(map(item));
400
+ return items;
401
+ }
402
+
403
+ /** Handler for a backend a call site has already ruled out. */
404
+ function unreachableBackend(): Promise<never> {
405
+ throw new FileSystemError("NOT_SUPPORTED", "Unsupported Databricks backend for this operation");
406
+ }
407
+
408
+ function decodeBase64(data: string | undefined): Buffer {
409
+ if (!data) return Buffer.alloc(0);
410
+ return Buffer.from(data, "base64");
411
+ }
412
+
413
+ async function readResponseBody(
414
+ contents: globalThis.ReadableStream<Uint8Array> | undefined,
415
+ ): Promise<Buffer> {
416
+ if (!contents) return Buffer.alloc(0);
417
+ const reader = contents.getReader();
418
+ const chunks: Uint8Array[] = [];
419
+ while (true) {
420
+ const { done, value } = await reader.read();
421
+ if (done) break;
422
+ if (value) chunks.push(value);
423
+ }
424
+ return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
425
+ }
426
+
427
+ function bufferToReadableStream(buffer: Buffer): globalThis.ReadableStream<Uint8Array> {
428
+ return new ReadableStream<Uint8Array>({
429
+ start(controller) {
430
+ controller.enqueue(new Uint8Array(buffer));
431
+ controller.close();
432
+ },
433
+ });
434
+ }
435
+
436
+ function toDate(value: number | undefined): Date | undefined {
437
+ return value === undefined ? undefined : new Date(value);
438
+ }
439
+
440
+ function parseHttpDate(value: string | undefined): Date | undefined {
441
+ if (!value) return undefined;
442
+ const parsed = Date.parse(value);
443
+ return Number.isFinite(parsed) ? new Date(parsed) : undefined;
444
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Databricks path recognition and normalization for {@link DatabricksFileSystem}.
3
+ *
4
+ * Roots may be written as:
5
+ * - Unity Catalog volume: `/Volumes/catalog/schema/volume` (also accepts `/Volume/...`)
6
+ * - Three-part volume id: `catalog.schema.volume` → `/Volumes/catalog/schema/volume`
7
+ * - Home shorthand: `~` / `~/...` → `/Workspace/Users/<userName>/...`
8
+ * - Workspace tree: `/Workspace/...`, `/Users/...`, `/Repos/...`, `/Shared/...`
9
+ * - DBFS: `/dbfs/...`
10
+ *
11
+ * @module
12
+ */
13
+
14
+ import type { WorkspaceClient } from "@databricks/sdk-experimental";
15
+ import { posixPath } from "@dbx-tools/shared-fs";
16
+ import { getCurrentUserName } from "./workspace.ts";
17
+
18
+ /** Which Databricks Files API serves an absolute path. */
19
+ export type DatabricksFilesBackend = "workspace" | "volumes" | "dbfs";
20
+
21
+ /** `catalog.schema.volume` (no slashes, exactly three dotted segments). */
22
+ const VOLUME_THREE_PART = /^([^.\/\s]+)\.([^.\/\s]+)\.([^.\/\s]+)$/;
23
+
24
+ /** Options for synchronous {@link normalizeDatabricksRoot}. */
25
+ export interface NormalizeDatabricksRootOptions {
26
+ /**
27
+ * Username used to expand `~` → `/Workspace/Users/<userName>`.
28
+ * Required when {@link root} is `~` or `~/...`. Prefer
29
+ * {@link resolveDatabricksRoot} when the name should come from the workspace
30
+ * client.
31
+ */
32
+ userName?: string;
33
+ }
34
+
35
+ /** Options for async {@link resolveDatabricksRoot}. */
36
+ export interface ResolveDatabricksRootOptions {
37
+ /** Client used to resolve the current username for `~` expansion. */
38
+ client?: WorkspaceClient;
39
+ }
40
+
41
+ /** True when {@link input} is `~` or a path under `~/`. See {@link posixPath.isHomeRelativePath}. */
42
+ export const isHomeRelativePath = posixPath.isHomeRelativePath;
43
+
44
+ /**
45
+ * Expand `~` / `~/...` to `/Workspace/Users/<userName>/...`.
46
+ * Non-home inputs are returned unchanged (POSIX separators only).
47
+ */
48
+ export function expandHomePath(input: string, userName: string): string {
49
+ const name = userName.trim();
50
+ if (!name) {
51
+ throw new TypeError("Databricks home expansion requires a non-empty userName");
52
+ }
53
+ if (!isHomeRelativePath(input)) {
54
+ return posixPath.toPosix(input.trim());
55
+ }
56
+ return posixPath.expandHome(input, `/Workspace/Users/${name}`);
57
+ }
58
+
59
+ /**
60
+ * Normalize a Databricks filesystem root to an absolute POSIX path.
61
+ *
62
+ * - `catalog.schema.volume` → `/Volumes/catalog/schema/volume`
63
+ * - `/Volume/...` → `/Volumes/...` (singular typo / shorthand)
64
+ * - `~` / `~/...` → `/Workspace/Users/<userName>/...` (needs {@link NormalizeDatabricksRootOptions.userName})
65
+ * - strips trailing slashes (except `/`)
66
+ *
67
+ * For `~` without a known username, use {@link resolveDatabricksRoot}.
68
+ */
69
+ export function normalizeDatabricksRoot(
70
+ root: string,
71
+ options: NormalizeDatabricksRootOptions = {},
72
+ ): string {
73
+ const trimmed = root.trim();
74
+ if (!trimmed) {
75
+ throw new TypeError("Databricks filesystem root cannot be empty");
76
+ }
77
+
78
+ if (isHomeRelativePath(trimmed)) {
79
+ if (!options.userName?.trim()) {
80
+ throw new TypeError(
81
+ `Databricks home path "${root}" requires userName; use resolveDatabricksRoot() or pass userName`,
82
+ );
83
+ }
84
+ return posixPath.normalizeRoot(expandHomePath(trimmed, options.userName));
85
+ }
86
+
87
+ const threePart = VOLUME_THREE_PART.exec(trimmed);
88
+ if (threePart) {
89
+ return posixPath.normalizeRoot(`/Volumes/${threePart[1]}/${threePart[2]}/${threePart[3]}`);
90
+ }
91
+
92
+ let posix = posixPath.toPosix(trimmed);
93
+ if (posix === "/Volume" || posix.startsWith("/Volume/")) {
94
+ posix = `/Volumes${posix.slice("/Volume".length)}`;
95
+ }
96
+
97
+ if (!posixPath.isAbsolute(posix)) {
98
+ throw new TypeError(
99
+ `Databricks filesystem root must be absolute, catalog.schema.volume, or ~, got: ${root}`,
100
+ );
101
+ }
102
+
103
+ return posixPath.normalizeRoot(posix);
104
+ }
105
+
106
+ /**
107
+ * Like {@link normalizeDatabricksRoot}, but resolves `~` via
108
+ * {@link getCurrentUserName} (AppKit context user, else `currentUser.me()`).
109
+ */
110
+ export async function resolveDatabricksRoot(
111
+ root: string,
112
+ options: ResolveDatabricksRootOptions = {},
113
+ ): Promise<string> {
114
+ if (!isHomeRelativePath(root)) {
115
+ return normalizeDatabricksRoot(root);
116
+ }
117
+ const userName = await getCurrentUserName(options.client);
118
+ return normalizeDatabricksRoot(root, { userName });
119
+ }
120
+
121
+ /** True when {@link absolutePath} is served by the workspace objects API. */
122
+ export function isWorkspaceFilesPath(absolutePath: string): boolean {
123
+ const p = posixPath.toPosix(absolutePath);
124
+ return (
125
+ p === "/Workspace" ||
126
+ p.startsWith("/Workspace/") ||
127
+ p.startsWith("/Users/") ||
128
+ p.startsWith("/Repos/") ||
129
+ p.startsWith("/Shared/")
130
+ );
131
+ }
132
+
133
+ /** True when {@link absolutePath} is a Unity Catalog volume path. */
134
+ export function isVolumesPath(absolutePath: string): boolean {
135
+ const p = posixPath.toPosix(absolutePath);
136
+ return p === "/Volumes" || p.startsWith("/Volumes/");
137
+ }
138
+
139
+ /** True when {@link absolutePath} is a DBFS path. */
140
+ export function isDbfsPath(absolutePath: string): boolean {
141
+ const p = posixPath.toPosix(absolutePath);
142
+ return p === "/dbfs" || p.startsWith("/dbfs/");
143
+ }
144
+
145
+ /**
146
+ * Pick the Databricks API backend for an absolute path.
147
+ *
148
+ * Volume three-part roots are normalized before this runs, so callers should
149
+ * pass paths from {@link normalizeDatabricksRoot} / {@link BaseFileSystem.resolvePath}.
150
+ */
151
+ export function resolveDatabricksFilesBackend(absolutePath: string): DatabricksFilesBackend {
152
+ if (isDbfsPath(absolutePath)) return "dbfs";
153
+ if (isWorkspaceFilesPath(absolutePath)) return "workspace";
154
+ if (isVolumesPath(absolutePath)) return "volumes";
155
+ // Default: UC Files API (covers unusual absolute roots under the workspace host).
156
+ return "volumes";
157
+ }
package/src/workspace.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Resolve the current Databricks workspace's URL and numeric id from the active
3
- * execution context (AppKit, when initialized), a default `WorkspaceClient`, or
4
- * the environment. Server-only.
2
+ * Resolve the current Databricks workspace's URL, numeric id, and client from
3
+ * the active execution context (AppKit, when initialized), a default
4
+ * {@link WorkspaceClient}, or the environment. Server-only.
5
5
  *
6
6
  * @module
7
7
  */
@@ -20,21 +20,61 @@ const WORKSPACE_ID_REGEX = /\d{10,20}/;
20
20
  */
21
21
  const getDefaultWorkspaceClient = functionModule.memoize(async () => new WorkspaceClient({}));
22
22
 
23
+ /**
24
+ * The AppKit execution-context workspace client when available, otherwise
25
+ * `undefined`. Never constructs a fallback client and never throws for a
26
+ * missing AppKit scope.
27
+ */
28
+ export function tryGetWorkspaceClient(): WorkspaceClient | undefined {
29
+ return appkit.tryGetExecutionContext()?.client as WorkspaceClient | undefined;
30
+ }
31
+
32
+ /**
33
+ * Resolve a {@link WorkspaceClient}: {@link tryGetWorkspaceClient} first, else
34
+ * a memoized default client from env / profile auth.
35
+ */
36
+ export async function getWorkspaceClient(): Promise<WorkspaceClient> {
37
+ return tryGetWorkspaceClient() ?? (await getDefaultWorkspaceClient());
38
+ }
39
+
23
40
  /**
24
41
  * The active workspace `Config`: the AppKit execution-context client's config
25
42
  * when AppKit is initialized, else the default client's. Returns `undefined`
26
43
  * (never throws) when neither is available.
27
44
  */
28
45
  async function getWorkspaceConfig(): Promise<Config | undefined> {
29
- let client = appkit.tryGetExecutionContext()?.client as WorkspaceClient | undefined;
30
- if (!client) {
31
- try {
32
- client = await getDefaultWorkspaceClient();
33
- } catch {
34
- // no client available; fall back to the environment
35
- }
46
+ try {
47
+ return (await getWorkspaceClient()).config;
48
+ } catch {
49
+ return undefined;
36
50
  }
37
- return client?.config;
51
+ }
52
+
53
+ /**
54
+ * Current Databricks username for workspace home paths
55
+ * (`/Workspace/Users/<userName>`).
56
+ *
57
+ * Prefers `userName` on the AppKit execution context when set, otherwise
58
+ * calls `currentUser.me()` on {@link client} (or {@link getWorkspaceClient}).
59
+ */
60
+ export async function getCurrentUserName(client?: WorkspaceClient): Promise<string> {
61
+ const contextUser = readContextUserName(appkit.tryGetExecutionContext());
62
+ if (contextUser) return contextUser;
63
+
64
+ const resolved = client ?? (await getWorkspaceClient());
65
+ const me = await resolved.currentUser.me();
66
+ const userName = me.userName?.trim();
67
+ if (!userName) {
68
+ throw new Error("Databricks currentUser.me() did not return a userName");
69
+ }
70
+ return userName;
71
+ }
72
+
73
+ /** Best-effort `userName` off an AppKit execution context (shape varies by mode). */
74
+ function readContextUserName(ctx: unknown): string | undefined {
75
+ if (typeof ctx !== "object" || ctx === null || !("userName" in ctx)) return undefined;
76
+ const value = (ctx as { userName?: unknown }).userName;
77
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
38
78
  }
39
79
 
40
80
  /**